feat: complete advanced Sudoku workbench

This commit is contained in:
2026-08-31 08:20:30 +02:00
parent 8ca9300ab3
commit 0a1bdc1a8c
99 changed files with 20793 additions and 923 deletions
+1 -1
View File
@@ -143,7 +143,7 @@ describe("aid-mémoire state", () => {
state = enterAidMemoireCell(state, 0, "center", 7, 9);
state = enterAidMemoireCell(state, 0, "color", 3, 9);
expect(aidMemoireCellDescription(state.cells[0]!, 0, 9)).toBe(
"Aid-mémoire cell 1, label Odd candidates, empty, corner marks 1, centre marks 7, colour 3",
"Aid-mémoire cell 1, label Odd candidates, empty, corner marks 1, centre marks 7, colour 3: yellow, dots",
);
});
});
+145
View File
@@ -0,0 +1,145 @@
import { describe, expect, it } from "vitest";
import { classicRegions, type PuzzleDefinition } from "../../src/domain";
import type { LogicalStep } from "../../src/solver";
import {
applyLogicalStepToSession,
autoRemoveNotesAfterPlacements,
fillLegalCenterCandidates,
pruneInvalidNotes,
} from "../../src/state/candidateMaintenance";
import { createSession, maskValues } from "../../src/state/session";
function puzzle4(): PuzzleDefinition {
return {
version: 1,
size: 4,
givens: new Array<number>(16).fill(0),
regions: classicRegions(4),
constraints: [],
};
}
describe("candidate maintenance", () => {
it("fills legal center candidates without changing corner notes or metadata", () => {
const session = createSession(new Array<number>(16).fill(0));
session.values[0] = 1;
session.cornerMarks[1] = 0b1111;
session.centerMarks[10] = 0b0010;
session.colors[3] = 4;
session.elapsedSeconds = 73;
session.paused = true;
const result = fillLegalCenterCandidates(session, puzzle4());
expect(result).not.toBe(session);
expect(result.centerMarks).not.toBe(session.centerMarks);
expect(result.centerMarks[0]).toBe(0);
expect(maskValues(result.centerMarks[1]!, 4)).toEqual([2, 3, 4]);
expect(maskValues(result.centerMarks[10]!, 4)).toEqual([1, 2, 3, 4]);
expect(result.cornerMarks[1]).toBe(0b1111);
expect(result.colors[3]).toBe(4);
expect(result.elapsedSeconds).toBe(73);
expect(result.paused).toBe(true);
expect(session.centerMarks[10]).toBe(0b0010);
});
it("only removes illegal center and corner notes", () => {
const session = createSession(new Array<number>(16).fill(0));
session.values[0] = 1;
session.centerMarks.fill(0b1111);
session.cornerMarks.fill(0b1111 | (1 << 8));
const result = pruneInvalidNotes(session, puzzle4());
expect(result.centerMarks[0]).toBe(0);
expect(result.cornerMarks[0]).toBe(0);
expect(maskValues(result.centerMarks[1]!, 4)).toEqual([2, 3, 4]);
expect(maskValues(result.cornerMarks[4]!, 4)).toEqual([2, 3, 4]);
expect(maskValues(result.centerMarks[10]!, 4)).toEqual([1, 2, 3, 4]);
expect(result.cornerMarks[10]).toBe(0b1111);
expect(session.cornerMarks[1]).toBe(0b1111 | (1 << 8));
});
it("auto-removes notes after placements but does not invent notes on erase", () => {
const previous = createSession(new Array<number>(16).fill(0));
previous.centerMarks.fill(0b1111);
previous.cornerMarks.fill(0b1111);
const placed = createSession(new Array<number>(16).fill(0));
placed.centerMarks.fill(0b1111);
placed.cornerMarks.fill(0b1111);
placed.values[0] = 1;
const maintained = autoRemoveNotesAfterPlacements(
previous,
placed,
puzzle4(),
);
expect(maskValues(maintained.centerMarks[1]!, 4)).toEqual([2, 3, 4]);
expect(maskValues(maintained.cornerMarks[4]!, 4)).toEqual([2, 3, 4]);
expect(maintained.centerMarks[10]).toBe(0b1111);
const erased = createSession(new Array<number>(16).fill(0));
erased.centerMarks[1] = 0b0001;
const previousFilled = createSession(new Array<number>(16).fill(0));
previousFilled.values[0] = 1;
const afterErase = autoRemoveNotesAfterPlacements(
previousFilled,
erased,
puzzle4(),
);
expect(afterErase.centerMarks[1]).toBe(0b0001);
expect(afterErase).not.toBe(erased);
});
it("applies a logical step to tracked candidates as one immutable transition", () => {
const base = createSession(new Array<number>(16).fill(0));
base.cornerMarks.fill(0b1111);
base.colors[10] = 3;
base.elapsedSeconds = 99;
const session = fillLegalCenterCandidates(base, puzzle4());
const step: LogicalStep = {
technique: "naked-single",
placements: [{ cell: 0, value: 1 }],
eliminations: [{ cell: 10, values: [2] }],
focusCells: [0, 10],
explanation: "Test step",
};
const result = applyLogicalStepToSession(session, step, puzzle4());
expect(result.values[0]).toBe(1);
expect(result.centerMarks[0]).toBe(0);
expect(result.cornerMarks[0]).toBe(0);
expect(maskValues(result.centerMarks[1]!, 4)).toEqual([2, 3, 4]);
expect(maskValues(result.centerMarks[10]!, 4)).toEqual([1, 3, 4]);
expect(result.cornerMarks[1]).toBe(0b1111);
expect(result.colors[10]).toBe(3);
expect(result.elapsedSeconds).toBe(99);
expect(session.values[0]).toBe(0);
expect(session.centerMarks[10]).toBe(0b1111);
});
it("leaves absent tracked candidates absent and rejects stale steps", () => {
const session = createSession(new Array<number>(16).fill(0));
const elimination: LogicalStep = {
technique: "pointing",
placements: [],
eliminations: [{ cell: 1, values: [1] }],
focusCells: [1],
explanation: "Test elimination",
};
expect(
applyLogicalStepToSession(session, elimination, puzzle4()).centerMarks[1],
).toBe(0);
session.values[0] = 2;
const placement: LogicalStep = {
...elimination,
placements: [{ cell: 0, value: 1 }],
eliminations: [],
};
expect(() =>
applyLogicalStepToSession(session, placement, puzzle4()),
).toThrow(/filled cell/u);
});
});
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import {
createGameplayHistory,
parseGameplayHistory,
serializeGameplayHistory,
} from "../../src/state/playHistory";
import { createSession } from "../../src/state/session";
describe("gameplay history persistence", () => {
it("round-trips independent bounded history", () => {
const history = createGameplayHistory(
createSession(Array<number>(16).fill(0)),
);
const encoded = serializeGameplayHistory(history, 4);
const decoded = parseGameplayHistory(encoded, 4);
expect(decoded).toEqual(history);
(decoded.moments[0]!.state.values as number[])[0] = 4;
expect(history.moments[0]!.state.values[0]).toBe(0);
});
it("rejects dangling and oversized persisted state", () => {
const history = createGameplayHistory(
createSession(Array<number>(16).fill(0)),
);
const raw = JSON.parse(serializeGameplayHistory(history, 4)) as {
history: { currentMomentId: string };
};
raw.history.currentMomentId = "missing";
expect(() => parseGameplayHistory(JSON.stringify(raw), 4)).toThrow(
/missing active branch or moment/u,
);
expect(() => parseGameplayHistory("x".repeat(1_048_577), 4)).toThrow(
/too large/u,
);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from "vitest";
import {
BOARD_SCALE_STORAGE_KEY,
colorMarkDescription,
normalizeBoardScale,
parseCandidateVerbosity,
readStoredBoardScale,
writeStoredBoardScale,
} from "../../src/state/uiPreferences";
describe("UI accessibility preferences", () => {
it("clamps and snaps persisted board scale safely", () => {
expect(normalizeBoardScale(1.13)).toBe(1.25);
expect(normalizeBoardScale(0.1)).toBe(0.75);
expect(normalizeBoardScale(9)).toBe(2);
expect(normalizeBoardScale("not a scale")).toBe(1);
const storage = { getItem: vi.fn(() => "1.49") };
expect(readStoredBoardScale(storage)).toBe(1.5);
expect(storage.getItem).toHaveBeenCalledWith(BOARD_SCALE_STORAGE_KEY);
const setItem = vi.fn();
writeStoredBoardScale(1.74, { setItem });
expect(setItem).toHaveBeenCalledWith(BOARD_SCALE_STORAGE_KEY, "1.75");
});
it("survives unavailable storage and normalizes announcement settings", () => {
expect(
readStoredBoardScale({
getItem: () => {
throw new Error("blocked");
},
}),
).toBe(1);
expect(() =>
writeStoredBoardScale(1.5, {
setItem: () => {
throw new Error("blocked");
},
}),
).not.toThrow();
expect(parseCandidateVerbosity("concise")).toBe("concise");
expect(parseCandidateVerbosity("everything")).toBe("detailed");
});
it("gives every colour mark a non-colour identifier", () => {
expect(colorMarkDescription(1)).toBe("red, diagonal stripes");
expect(colorMarkDescription(8)).toBe("pink, rings");
expect(colorMarkDescription(99)).toBe("mark 99");
});
});