blowup now locks out subsequent rolls, unit tests added

This commit is contained in:
Rory Hinnen
2026-09-07 10:27:56 -05:00
parent a4d1634df7
commit 025b006194
5 changed files with 381 additions and 39 deletions
+345 -18
View File
@@ -5,32 +5,359 @@
// Created by Rory Hinnen on 22/2/20.
//
import XCTest
import Testing
@testable import Bunch_O__Dice
class Bunch_O__DiceTests: XCTestCase {
// MARK: - Dice Total Calculation
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
@Suite("Dice Total Calculation")
struct DiceTotalTests {
// One die showing each face — a clean baseline for cutoff tests.
func oneEach() -> Dice {
var d = Dice()
d.one = 1; d.two = 1; d.three = 1; d.four = 1; d.five = 1; d.six = 1
return d
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
@Test("No modifier counts faces at or above cutoff")
func totalNoModifier() {
let d = oneEach()
#expect(d.total(cutoff: 6, modifier: .none) == 1)
#expect(d.total(cutoff: 5, modifier: .none) == 2)
#expect(d.total(cutoff: 4, modifier: .none) == 3)
#expect(d.total(cutoff: 3, modifier: .none) == 4)
#expect(d.total(cutoff: 2, modifier: .none) == 5)
}
func testExample() throws {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct results.
// Any test you write for XCTest can be annotated as throws and async.
// Mark your test throws to produce an unexpected failure when your test encounters an uncaught error.
// Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards.
@Test("Negative modifier effectively raises cutoff by 1")
func totalNegativeModifier() {
let d = oneEach()
#expect(d.total(cutoff: 5, modifier: .negative) == 1) // effectively 6+
#expect(d.total(cutoff: 4, modifier: .negative) == 2) // effectively 5+
#expect(d.total(cutoff: 3, modifier: .negative) == 3) // effectively 4+
#expect(d.total(cutoff: 2, modifier: .negative) == 4) // effectively 3+
#expect(d.total(cutoff: 1, modifier: .negative) == 5) // effectively 2+
}
func testPerformanceExample() throws {
// This is an example of a performance test case.
self.measure {
// Put the code you want to measure the time of here.
}
@Test("Positive modifier effectively lowers cutoff by 1")
func totalPositiveModifier() {
let d = oneEach()
#expect(d.total(cutoff: 6, modifier: .positive) == 2) // effectively 5+
#expect(d.total(cutoff: 5, modifier: .positive) == 3) // effectively 4+
#expect(d.total(cutoff: 4, modifier: .positive) == 4) // effectively 3+
#expect(d.total(cutoff: 3, modifier: .positive) == 5) // effectively 2+
#expect(d.total(cutoff: 2, modifier: .positive) == 5) // already at 2+, no further shift
}
}
// MARK: - Armour Save Calculation
@Suite("Armour Save Calculation")
struct ArmourSaveTests {
func oneEach() -> Dice {
var d = Dice()
d.one = 1; d.two = 1; d.three = 1; d.four = 1; d.five = 1; d.six = 1
return d
}
@Test("Counts dice that fail the save")
func normalSaves() {
let d = oneEach()
#expect(d.armour(die: 6, invulnerable: false, save: 6, armourPenetration: 0) == 5) // 1-5 fail
#expect(d.armour(die: 6, invulnerable: false, save: 5, armourPenetration: 0) == 4) // 1-4 fail
#expect(d.armour(die: 6, invulnerable: false, save: 4, armourPenetration: 0) == 3) // 1-3 fail
#expect(d.armour(die: 6, invulnerable: false, save: 3, armourPenetration: 0) == 2) // 1-2 fail
#expect(d.armour(die: 6, invulnerable: false, save: 2, armourPenetration: 0) == 1) // only 1 fails
}
@Test("AP reduces effective save value")
func armourPenetration() {
let d = oneEach()
// 3+ save with AP 1 → effective 4+: 1-3 fail
#expect(d.armour(die: 6, invulnerable: false, save: 3, armourPenetration: 1) == 3)
// 3+ save with AP 2 → effective 5+: 1-4 fail
#expect(d.armour(die: 6, invulnerable: false, save: 3, armourPenetration: 2) == 4)
}
@Test("Invulnerable save ignores AP")
func invulnerableSave() {
let d = oneEach()
// 4++ invulnerable with heavy AP should match a plain 4+ with no AP
let withInvuln = d.armour(die: 6, invulnerable: true, save: 4, armourPenetration: 5)
let noAP = d.armour(die: 6, invulnerable: false, save: 4, armourPenetration: 0)
#expect(withInvuln == noAP)
}
}
// MARK: - Roll Modifiers
@Suite("Roll Modifiers")
struct RollModifierTests {
@Test("add() clamps at positive")
func addModifier() {
#expect(Dice.RollModifiers.negative.add() == .none)
#expect(Dice.RollModifiers.none.add() == .positive)
#expect(Dice.RollModifiers.positive.add() == .positive)
}
@Test("remove() clamps at negative")
func removeModifier() {
#expect(Dice.RollModifiers.positive.remove() == .none)
#expect(Dice.RollModifiers.none.remove() == .negative)
#expect(Dice.RollModifiers.negative.remove() == .negative)
}
}
// MARK: - Wound Cutoff
@Suite("Wound Cutoff")
struct WoundCutoffTests {
@Test("S double or more T: wound on 2+")
func doubleStrength() {
let roll = Roll()
roll.strength = 10; roll.toughness = 5
#expect(roll.woundCutoff == 2)
roll.strength = 6; roll.toughness = 3
#expect(roll.woundCutoff == 2)
}
@Test("S greater than T but less than double: wound on 3+")
func strengthHigher() {
let roll = Roll()
roll.strength = 7; roll.toughness = 5
#expect(roll.woundCutoff == 3)
roll.strength = 4; roll.toughness = 3
#expect(roll.woundCutoff == 3)
}
@Test("S equals T: wound on 4+")
func equal() {
let roll = Roll()
roll.strength = 5; roll.toughness = 5
#expect(roll.woundCutoff == 4)
roll.strength = 3; roll.toughness = 3
#expect(roll.woundCutoff == 4)
}
@Test("T greater than S but less than double: wound on 5+")
func toughnessHigher() {
let roll = Roll()
roll.strength = 3; roll.toughness = 5
#expect(roll.woundCutoff == 5)
roll.strength = 4; roll.toughness = 5
#expect(roll.woundCutoff == 5)
}
@Test("T double or more S: wound on 6+")
func doubleToughness() {
let roll = Roll()
roll.strength = 2; roll.toughness = 5
#expect(roll.woundCutoff == 6)
roll.strength = 1; roll.toughness = 3
#expect(roll.woundCutoff == 6)
}
}
// MARK: - Reroll Logic
@Suite("Reroll Logic")
struct RerollTests {
@Test("rerollBelow: no-op when no dice fall below cutoff")
func rerollBelowNoop() {
var d = Dice()
d.four = 2; d.five = 3; d.six = 1
d.rerollBelow(cutoff: 4)
#expect(d.one == 0)
#expect(d.two == 0)
#expect(d.three == 0)
#expect(d.four == 2)
#expect(d.five == 3)
#expect(d.six == 1)
}
@Test("rerollBelow: preserves total dice count")
func rerollBelowPreservesCount() {
var d = Dice()
d.one = 3; d.two = 2; d.three = 1; d.four = 1; d.five = 1; d.six = 1
d.rerollBelow(cutoff: 4)
let total = d.one + d.two + d.three + d.four + d.five + d.six
#expect(total == 9)
}
@Test("rerollBelow: cutoff 1 is a no-op (empty range)")
func rerollBelowCutoffOne() {
var d = Dice()
d.one = 3; d.six = 2
d.rerollBelow(cutoff: 1)
#expect(d.one == 3)
#expect(d.six == 2)
}
@Test("rerollBelow: dice above cutoff are untouched")
func rerollBelowPreservesHighFaces() {
var d = Dice()
d.one = 2; d.two = 2; d.three = 2; d.six = 4
d.rerollBelow(cutoff: 4)
// The 4 sixes were not in the reroll range and should be unchanged
#expect(d.six == 4)
}
@Test("applyReroll: none mode leaves hasRerolled false")
func applyRerollNoneMode() {
let roll = Roll()
roll.state = .hit
roll.hitRerollMode = .none
roll.applyReroll()
#expect(roll.hitHasRerolled == false)
}
@Test("applyReroll: sets hasRerolled flag after use")
func applyRerollSetsFlag() {
let roll = Roll()
roll.state = .hit
roll.hitRerollMode = .ones
roll.dice.one = 3; roll.dice.six = 2
roll.total = 5
roll.applyReroll()
#expect(roll.hitHasRerolled == true)
}
@Test("applyReroll: guard blocks second call")
func applyRerollRespectsFlagAlreadySet() {
let roll = Roll()
roll.state = .hit
roll.hitRerollMode = .all
roll.hitHasRerolled = true // simulate already used
roll.dice.one = 5 // known state
roll.applyReroll() // should be blocked by guard
#expect(roll.dice.one == 5)
#expect(roll.dice.two == 0)
#expect(roll.dice.three == 0)
#expect(roll.dice.four == 0)
#expect(roll.dice.five == 0)
#expect(roll.dice.six == 0)
}
@Test("applyReroll: only sets flag for the active phase")
func applyRerollPerPhaseIsolation() {
let roll = Roll()
roll.state = .wound
roll.woundRerollMode = .ones
roll.applyReroll()
#expect(roll.woundHasRerolled == true)
#expect(roll.hitHasRerolled == false)
#expect(roll.saveHasRerolled == false)
}
@Test("applyReroll: failed mode preserves total dice count")
func applyRerollFailedModeCount() {
let roll = Roll()
roll.state = .hit
roll.hitCutoff = .four
roll.hitRerollMode = .failed
roll.dice.one = 1; roll.dice.two = 1; roll.dice.three = 1; roll.dice.six = 2
roll.total = 5
roll.applyReroll()
let total = roll.dice.one + roll.dice.two + roll.dice.three +
roll.dice.four + roll.dice.five + roll.dice.six
#expect(total == 5)
#expect(roll.dice.six == 2) // dice above cutoff are untouched
}
@Test("resetPool: clears hitHasRerolled flag")
func resetPoolClearsFlag() {
let roll = Roll()
roll.total = 5
roll.hitHasRerolled = true
roll.resetPool()
#expect(roll.hitHasRerolled == false)
}
@Test("resetPool: rolls dice equal to total count")
func resetPoolRollsCorrectCount() {
let roll = Roll()
roll.total = 10
roll.resetPool()
let total = roll.dice.one + roll.dice.two + roll.dice.three +
roll.dice.four + roll.dice.five + roll.dice.six
#expect(total == 10)
}
}
// MARK: - Blowup Logic
@Suite("Blowup Logic")
struct BlowupTests {
@Test("blowup six: total increases by the original six count")
func blowupSixIncreasesCount() {
var d = Dice()
d.one = 1; d.two = 1; d.six = 3
let originalTotal = 5
let originalSix = 3
d.blowup(dice: .six)
let newTotal = d.one + d.two + d.three + d.four + d.five + d.six
#expect(newTotal == originalTotal + originalSix)
}
@Test("blowup five: total increases by the original five and six count")
func blowupFiveIncreasesCount() {
var d = Dice()
d.one = 1; d.five = 2; d.six = 3
let originalTotal = 6
let originalFiveSix = 5
d.blowup(dice: .five)
let newTotal = d.one + d.two + d.three + d.four + d.five + d.six
#expect(newTotal == originalTotal + originalFiveSix)
}
@Test("blowup six: no-op when no sixes in pool")
func blowupSixNoop() {
var d = Dice()
d.one = 3; d.two = 2
d.blowup(dice: .six)
let total = d.one + d.two + d.three + d.four + d.five + d.six
#expect(total == 5)
}
@Test("applyBlowup: sets blowupUsed flag")
func applyBlowupSetsFlag() {
let roll = Roll()
roll.dice.six = 3
roll.applyBlowup(face: .six)
#expect(roll.blowupUsed == true)
}
@Test("applyBlowup: guard blocks second call")
func applyBlowupGuard() {
let roll = Roll()
roll.dice.six = 2
roll.blowupUsed = true // already used
let totalBefore = roll.dice.one + roll.dice.two + roll.dice.three +
roll.dice.four + roll.dice.five + roll.dice.six
roll.applyBlowup(face: .six)
let totalAfter = roll.dice.one + roll.dice.two + roll.dice.three +
roll.dice.four + roll.dice.five + roll.dice.six
#expect(totalAfter == totalBefore)
}
@Test("applyBlowup: using any face disables both")
func blowupAnyFaceDisablesBoth() {
let roll = Roll()
roll.dice.six = 2; roll.dice.five = 2
roll.applyBlowup(face: .six)
#expect(roll.blowupUsed == true)
// Five blowup should now be blocked
let totalBefore = roll.dice.one + roll.dice.two + roll.dice.three +
roll.dice.four + roll.dice.five + roll.dice.six
roll.applyBlowup(face: .five)
let totalAfter = roll.dice.one + roll.dice.two + roll.dice.three +
roll.dice.four + roll.dice.five + roll.dice.six
#expect(totalAfter == totalBefore)
}
}