feat: complete advanced Sudoku workbench
This commit is contained in:
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user