Upgrade Biome to 2.4.7 and enable 54 additional lint rules

Add rules covering bug prevention (noLeakedRender, noFloatingPromises,
noImportCycles, noReactForwardRef), security (noScriptUrl, noAlert),
performance (noAwaitInLoops, useTopLevelRegex), and code style
(noNestedTernary, useGlobalThis, useNullishCoalescing, useSortedClasses,
plus ~40 more). Fix all violations: extract top-level regex constants,
guard React && renders with boolean coercion, refactor nested ternaries,
replace window with globalThis, sort Tailwind classes, and introduce
expectDomainError test helper to eliminate conditional expects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-14 14:25:09 +01:00
parent 473f1eaefe
commit 36768d3aa1
54 changed files with 428 additions and 441 deletions

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { addCombatant } from "../add-combatant.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
// --- Helpers ---
@@ -112,20 +113,14 @@ describe("addCombatant", () => {
const e = enc([A, B]);
const result = addCombatant(e, combatantId("x"), "");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
expectDomainError(result, "invalid-name");
});
it("scenario 6: whitespace-only name returns error", () => {
const e = enc([A, B]);
const result = addCombatant(e, combatantId("x"), " ");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
expectDomainError(result, "invalid-name");
});
});
@@ -146,12 +141,10 @@ describe("addCombatant", () => {
for (const e of scenarios) {
const result = successResult(e, "new", "New");
const { combatants, activeIndex } = result.encounter;
if (combatants.length > 0) {
expect(activeIndex).toBeGreaterThanOrEqual(0);
expect(activeIndex).toBeLessThan(combatants.length);
} else {
expect(activeIndex).toBe(0);
}
// After adding a combatant, list is always non-empty
expect(combatants.length).toBeGreaterThan(0);
expect(activeIndex).toBeGreaterThanOrEqual(0);
expect(activeIndex).toBeLessThan(combatants.length);
}
});
@@ -188,7 +181,7 @@ describe("addCombatant", () => {
it("INV-7: new combatant is always appended at the end", () => {
const e = enc([A, B]);
const { encounter } = successResult(e, "C", "C");
expect(encounter.combatants[encounter.combatants.length - 1]).toEqual({
expect(encounter.combatants.at(-1)).toEqual({
id: combatantId("C"),
name: "C",
});

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { adjustHp } from "../adjust-hp.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
function makeCombatant(
name: string,
@@ -101,37 +102,25 @@ describe("adjustHp", () => {
it("returns error for nonexistent combatant", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const result = adjustHp(e, combatantId("Z"), -1);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
expectDomainError(result, "combatant-not-found");
});
it("returns error when combatant has no HP tracking", () => {
const e = enc([makeCombatant("A")]);
const result = adjustHp(e, combatantId("A"), -1);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("no-hp-tracking");
}
expectDomainError(result, "no-hp-tracking");
});
it("returns error for zero delta", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const result = adjustHp(e, combatantId("A"), 0);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("zero-delta");
}
expectDomainError(result, "zero-delta");
});
it("returns error for non-integer delta", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const result = adjustHp(e, combatantId("A"), 1.5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-delta");
}
expectDomainError(result, "invalid-delta");
});
});

View File

@@ -7,6 +7,7 @@ import {
createEncounter,
type Encounter,
} from "../types.js";
import { expectDomainError } from "./test-helpers.js";
// --- Helpers ---
@@ -150,10 +151,7 @@ describe("advanceTurn", () => {
};
const result = advanceTurn(enc);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-encounter");
}
expectDomainError(result, "invalid-encounter");
});
it("scenario 8: three advances on [A,B,C] completes a full round cycle", () => {

View File

@@ -3,6 +3,7 @@ import { createPlayerCharacter } from "../create-player-character.js";
import type { PlayerCharacter } from "../player-character-types.js";
import { playerCharacterId } from "../player-character-types.js";
import { isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
const id = playerCharacterId("pc-1");
@@ -80,10 +81,7 @@ describe("createPlayerCharacter", () => {
it("rejects empty name", () => {
const result = createPlayerCharacter([], id, "", 10, 50, "blue", "sword");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
expectDomainError(result, "invalid-name");
});
it("rejects whitespace-only name", () => {
@@ -96,10 +94,7 @@ describe("createPlayerCharacter", () => {
"blue",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
expectDomainError(result, "invalid-name");
});
it("rejects negative AC", () => {
@@ -112,10 +107,7 @@ describe("createPlayerCharacter", () => {
"blue",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-ac");
}
expectDomainError(result, "invalid-ac");
});
it("rejects non-integer AC", () => {
@@ -128,10 +120,7 @@ describe("createPlayerCharacter", () => {
"blue",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-ac");
}
expectDomainError(result, "invalid-ac");
});
it("allows AC of 0", () => {
@@ -149,10 +138,7 @@ describe("createPlayerCharacter", () => {
"blue",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
expectDomainError(result, "invalid-max-hp");
});
it("rejects negative maxHp", () => {
@@ -165,10 +151,7 @@ describe("createPlayerCharacter", () => {
"blue",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
expectDomainError(result, "invalid-max-hp");
});
it("rejects non-integer maxHp", () => {
@@ -181,10 +164,7 @@ describe("createPlayerCharacter", () => {
"blue",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
expectDomainError(result, "invalid-max-hp");
});
it("rejects invalid color", () => {
@@ -197,10 +177,7 @@ describe("createPlayerCharacter", () => {
"neon",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-color");
}
expectDomainError(result, "invalid-color");
});
it("rejects invalid icon", () => {
@@ -213,10 +190,7 @@ describe("createPlayerCharacter", () => {
"blue",
"banana",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-icon");
}
expectDomainError(result, "invalid-icon");
});
it("allows undefined color", () => {

View File

@@ -3,6 +3,7 @@ import { deletePlayerCharacter } from "../delete-player-character.js";
import type { PlayerCharacter } from "../player-character-types.js";
import { playerCharacterId } from "../player-character-types.js";
import { isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
const id1 = playerCharacterId("pc-1");
const id2 = playerCharacterId("pc-2");
@@ -28,10 +29,7 @@ describe("deletePlayerCharacter", () => {
it("returns error for not-found id", () => {
const result = deletePlayerCharacter([makePC()], id2);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("player-character-not-found");
}
expectDomainError(result, "player-character-not-found");
});
it("emits PlayerCharacterDeleted event", () => {

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { editCombatant } from "../edit-combatant.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
// --- Helpers ---
@@ -124,40 +125,28 @@ describe("editCombatant", () => {
const e = enc([Alice, Bob]);
const result = editCombatant(e, combatantId("nonexistent"), "NewName");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
expectDomainError(result, "combatant-not-found");
});
it("empty name returns invalid-name error", () => {
const e = enc([Alice, Bob]);
const result = editCombatant(e, combatantId("Alice"), "");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
expectDomainError(result, "invalid-name");
});
it("whitespace-only name returns invalid-name error", () => {
const e = enc([Alice, Bob]);
const result = editCombatant(e, combatantId("Alice"), " ");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
expectDomainError(result, "invalid-name");
});
it("empty encounter returns combatant-not-found for any id", () => {
const e = enc([]);
const result = editCombatant(e, combatantId("any"), "Name");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
expectDomainError(result, "combatant-not-found");
});
});
});

View File

@@ -3,6 +3,7 @@ import { editPlayerCharacter } from "../edit-player-character.js";
import type { PlayerCharacter } from "../player-character-types.js";
import { playerCharacterId } from "../player-character-types.js";
import { isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
const id = playerCharacterId("pc-1");
@@ -42,50 +43,32 @@ describe("editPlayerCharacter", () => {
playerCharacterId("pc-999"),
{ name: "Nope" },
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("player-character-not-found");
}
expectDomainError(result, "player-character-not-found");
});
it("rejects empty name", () => {
const result = editPlayerCharacter([makePC()], id, { name: "" });
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
expectDomainError(result, "invalid-name");
});
it("rejects invalid AC", () => {
const result = editPlayerCharacter([makePC()], id, { ac: -1 });
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-ac");
}
expectDomainError(result, "invalid-ac");
});
it("rejects invalid maxHp", () => {
const result = editPlayerCharacter([makePC()], id, { maxHp: 0 });
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
expectDomainError(result, "invalid-max-hp");
});
it("rejects invalid color", () => {
const result = editPlayerCharacter([makePC()], id, { color: "neon" });
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-color");
}
expectDomainError(result, "invalid-color");
});
it("rejects invalid icon", () => {
const result = editPlayerCharacter([makePC()], id, { icon: "banana" });
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-icon");
}
expectDomainError(result, "invalid-icon");
});
it("returns error when no fields changed", () => {
@@ -94,10 +77,7 @@ describe("editPlayerCharacter", () => {
name: pc.name,
ac: pc.ac,
});
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("no-changes");
}
expectDomainError(result, "no-changes");
});
it("emits exactly one event on success", () => {

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { removeCombatant } from "../remove-combatant.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
// --- Helpers ---
@@ -92,10 +93,7 @@ describe("removeCombatant", () => {
const e = enc([A, B], 0, 1);
const result = removeCombatant(e, combatantId("nonexistent"));
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
expectDomainError(result, "combatant-not-found");
});
});

View File

@@ -8,6 +8,7 @@ import {
type Encounter,
isDomainError,
} from "../types.js";
import { expectDomainError } from "./test-helpers.js";
// --- Helpers ---
@@ -83,10 +84,7 @@ describe("retreatTurn", () => {
const enc = encounter([A, B, C], 0, 1);
const result = retreatTurn(enc);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("no-previous-turn");
}
expectDomainError(result, "no-previous-turn");
});
it("scenario 4: single-combatant retreat — wraps to same combatant, decrements round", () => {
@@ -117,10 +115,7 @@ describe("retreatTurn", () => {
};
const result = retreatTurn(enc);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-encounter");
}
expectDomainError(result, "invalid-encounter");
});
});

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { rollInitiative } from "../roll-initiative.js";
import { isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
describe("rollInitiative", () => {
describe("valid rolls", () => {
@@ -32,18 +33,12 @@ describe("rollInitiative", () => {
describe("invalid dice rolls", () => {
it("rejects 0", () => {
const result = rollInitiative(0, 5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-dice-roll");
}
expectDomainError(result, "invalid-dice-roll");
});
it("rejects 21", () => {
const result = rollInitiative(21, 5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-dice-roll");
}
expectDomainError(result, "invalid-dice-roll");
});
it("rejects non-integer (3.5)", () => {

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { setAc } from "../set-ac.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
function makeCombatant(name: string, ac?: number): Combatant {
return ac === undefined
@@ -67,30 +68,21 @@ describe("setAc", () => {
const e = enc([makeCombatant("A")]);
const result = setAc(e, combatantId("nonexistent"), 10);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
expectDomainError(result, "combatant-not-found");
});
it("returns error for negative AC", () => {
const e = enc([makeCombatant("A")]);
const result = setAc(e, combatantId("A"), -1);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-ac");
}
expectDomainError(result, "invalid-ac");
});
it("returns error for non-integer AC", () => {
const e = enc([makeCombatant("A")]);
const result = setAc(e, combatantId("A"), 3.5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-ac");
}
expectDomainError(result, "invalid-ac");
});
it("returns error for NaN", () => {

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { setHp } from "../set-hp.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
function makeCombatant(
name: string,
@@ -10,9 +11,9 @@ function makeCombatant(
return {
id: combatantId(name),
name,
...(opts?.maxHp !== undefined
? { maxHp: opts.maxHp, currentHp: opts.currentHp ?? opts.maxHp }
: {}),
...(opts?.maxHp === undefined
? {}
: { maxHp: opts.maxHp, currentHp: opts.currentHp ?? opts.maxHp }),
};
}
@@ -116,37 +117,25 @@ describe("setHp", () => {
it("returns error for nonexistent combatant", () => {
const e = enc([makeCombatant("A")]);
const result = setHp(e, combatantId("Z"), 10);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
expectDomainError(result, "combatant-not-found");
});
it("rejects maxHp of 0", () => {
const e = enc([makeCombatant("A")]);
const result = setHp(e, combatantId("A"), 0);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
expectDomainError(result, "invalid-max-hp");
});
it("rejects negative maxHp", () => {
const e = enc([makeCombatant("A")]);
const result = setHp(e, combatantId("A"), -5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
expectDomainError(result, "invalid-max-hp");
});
it("rejects non-integer maxHp", () => {
const e = enc([makeCombatant("A")]);
const result = setHp(e, combatantId("A"), 3.5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
expectDomainError(result, "invalid-max-hp");
});
});

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { setInitiative } from "../set-initiative.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
// --- Helpers ---
@@ -73,10 +74,7 @@ describe("setInitiative", () => {
const e = enc([A, B], 0);
const result = setInitiative(e, combatantId("A"), 3.5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-initiative");
}
expectDomainError(result, "invalid-initiative");
});
it("AS-3b: reject NaN", () => {
@@ -109,10 +107,7 @@ describe("setInitiative", () => {
const e = enc([A, B], 0);
const result = setInitiative(e, combatantId("nonexistent"), 10);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
expectDomainError(result, "combatant-not-found");
});
});

View File

@@ -0,0 +1,9 @@
import { expect } from "vitest";
import { type DomainError, isDomainError } from "../types.js";
export function expectDomainError(result: unknown, code: string): DomainError {
expect(isDomainError(result)).toBe(true);
if (!isDomainError(result)) throw new Error("unreachable");
expect(result.code).toBe(code);
return result;
}

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { toggleConcentration } from "../toggle-concentration.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
function makeCombatant(name: string, isConcentrating?: boolean): Combatant {
return isConcentrating
@@ -46,10 +47,7 @@ describe("toggleConcentration", () => {
const e = enc([makeCombatant("A")]);
const result = toggleConcentration(e, combatantId("missing"));
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
expectDomainError(result, "combatant-not-found");
});
it("does not mutate input encounter", () => {

View File

@@ -4,6 +4,7 @@ import { CONDITION_DEFINITIONS } from "../conditions.js";
import { toggleCondition } from "../toggle-condition.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
function makeCombatant(
name: string,
@@ -77,20 +78,14 @@ describe("toggleCondition", () => {
"flying" as ConditionId,
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("unknown-condition");
}
expectDomainError(result, "unknown-condition");
});
it("returns error for nonexistent combatant", () => {
const e = enc([makeCombatant("A")]);
const result = toggleCondition(e, combatantId("missing"), "blinded");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
expectDomainError(result, "combatant-not-found");
});
it("does not mutate input encounter", () => {