feat: expand sudoku analysis and interoperability
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
aidMemoireCellDescription,
|
||||
aidMemoireFromPortable,
|
||||
aidMemoireToPortable,
|
||||
clearAidMemoireEntries,
|
||||
compactAidMemoireColumns,
|
||||
configureAidMemoire,
|
||||
createAidMemoire,
|
||||
enterAidMemoireCell,
|
||||
eraseAidMemoireCell,
|
||||
labelAidMemoireCell,
|
||||
MAX_AID_MEMOIRE_CELLS,
|
||||
normalizePortableAidMemoire,
|
||||
resetAidMemoire,
|
||||
} from "../../src/state/aidMemoire";
|
||||
import { maskValues } from "../../src/state/session";
|
||||
|
||||
describe("aid-mémoire state", () => {
|
||||
it("creates a bounded row and preserves existing cells while reflowing", () => {
|
||||
let state = createAidMemoire(9, { enabled: true });
|
||||
expect(state.cells).toHaveLength(9);
|
||||
expect(state.columns).toBe(9);
|
||||
expect(state.cells.map((cell) => cell.label)).toEqual([
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
]);
|
||||
|
||||
state = labelAidMemoireCell(state, 0, "Prime");
|
||||
state = enterAidMemoireCell(state, 0, "value", 7, 9);
|
||||
state = configureAidMemoire(state, 9, 12, 4);
|
||||
expect(state.cells).toHaveLength(12);
|
||||
expect(state.columns).toBe(4);
|
||||
expect(state.cells[0]).toMatchObject({ label: "Prime", value: 7 });
|
||||
expect(state.cells[11]?.label).toBe("12");
|
||||
|
||||
state = configureAidMemoire(state, 9, 1_000, 1_000);
|
||||
expect(state.cells).toHaveLength(MAX_AID_MEMOIRE_CELLS);
|
||||
expect(state.columns).toBe(MAX_AID_MEMOIRE_CELLS);
|
||||
state = configureAidMemoire(state, 9, Number.NaN, Number.NaN);
|
||||
expect(state.cells).toHaveLength(9);
|
||||
expect(state.columns).toBe(1);
|
||||
state = configureAidMemoire(state, 9, 3.9, 2.8);
|
||||
expect(state.cells).toHaveLength(3);
|
||||
expect(state.columns).toBe(2);
|
||||
expect(compactAidMemoireColumns(9)).toBe(3);
|
||||
expect(compactAidMemoireColumns(Number.NaN)).toBe(1);
|
||||
});
|
||||
|
||||
it("edits values, both mark types and colours like normal cells", () => {
|
||||
let state = createAidMemoire(9, { enabled: true, cellCount: 1 });
|
||||
state = enterAidMemoireCell(state, 0, "corner", 2, 9);
|
||||
state = enterAidMemoireCell(state, 0, "corner", 8, 9);
|
||||
state = enterAidMemoireCell(state, 0, "center", 4, 9);
|
||||
state = enterAidMemoireCell(state, 0, "color", 6, 9);
|
||||
expect(maskValues(state.cells[0]!.cornerMarks, 9)).toEqual([2, 8]);
|
||||
expect(maskValues(state.cells[0]!.centerMarks, 9)).toEqual([4]);
|
||||
expect(state.cells[0]?.color).toBe(6);
|
||||
|
||||
state = enterAidMemoireCell(state, 0, "value", 5, 9);
|
||||
expect(state.cells[0]).toMatchObject({
|
||||
value: 5,
|
||||
cornerMarks: 0,
|
||||
centerMarks: 0,
|
||||
color: 6,
|
||||
});
|
||||
expect(enterAidMemoireCell(state, 0, "center", 3, 9)).toBe(state);
|
||||
expect(enterAidMemoireCell(state, 0, "value", 2.5, 9)).toBe(state);
|
||||
state = eraseAidMemoireCell(state, 0, "value");
|
||||
state = enterAidMemoireCell(state, 0, "color", 6, 9);
|
||||
expect(state.cells[0]).toMatchObject({ value: 0, color: 0 });
|
||||
});
|
||||
|
||||
it("clears entries without losing layout or labels and can reset all", () => {
|
||||
let state = createAidMemoire(6, {
|
||||
enabled: true,
|
||||
cellCount: 4,
|
||||
columns: 2,
|
||||
});
|
||||
state = labelAidMemoireCell(state, 0, "Used");
|
||||
state = enterAidMemoireCell(state, 0, "value", 3, 6);
|
||||
const cleared = clearAidMemoireEntries(state);
|
||||
expect(cleared).toMatchObject({ enabled: true, columns: 2 });
|
||||
expect(cleared.cells[0]).toMatchObject({ label: "Used", value: 0 });
|
||||
|
||||
const reset = resetAidMemoire(cleared, 6);
|
||||
expect(reset).toMatchObject({ enabled: true, columns: 6 });
|
||||
expect(reset.cells).toHaveLength(6);
|
||||
expect(reset.cells[0]?.label).toBe("1");
|
||||
});
|
||||
|
||||
it("round-trips a stable portable representation", () => {
|
||||
const portable = normalizePortableAidMemoire(
|
||||
{
|
||||
version: 1,
|
||||
enabled: true,
|
||||
columns: 2,
|
||||
cells: [
|
||||
{
|
||||
label: "Primes",
|
||||
value: 0,
|
||||
cornerMarks: [7, 2, 2],
|
||||
centerMarks: [5, 3],
|
||||
color: 4,
|
||||
},
|
||||
{ label: "Used", value: 8, color: 0 },
|
||||
],
|
||||
},
|
||||
9,
|
||||
);
|
||||
expect(portable.cells[0]).toMatchObject({
|
||||
cornerMarks: [2, 7],
|
||||
centerMarks: [3, 5],
|
||||
});
|
||||
expect(
|
||||
aidMemoireToPortable(aidMemoireFromPortable(portable, 9), 9),
|
||||
).toEqual(portable);
|
||||
});
|
||||
|
||||
it("rejects unsafe portable layouts and provides complete cell narration", () => {
|
||||
expect(() =>
|
||||
normalizePortableAidMemoire(
|
||||
{
|
||||
version: 1,
|
||||
enabled: true,
|
||||
columns: 1,
|
||||
cells: Array.from({ length: MAX_AID_MEMOIRE_CELLS + 1 }, () => ({})),
|
||||
},
|
||||
9,
|
||||
),
|
||||
).toThrow(/1 to 36/u);
|
||||
|
||||
let state = createAidMemoire(9, { cellCount: 1 });
|
||||
state = labelAidMemoireCell(state, 0, "Odd candidates");
|
||||
state = enterAidMemoireCell(state, 0, "corner", 1, 9);
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { navigateGridCell } from "../../src/state/gridNavigation";
|
||||
|
||||
describe("accessible grid navigation", () => {
|
||||
it("moves with arrows without wrapping at edges", () => {
|
||||
expect(navigateGridCell(4, 3, "ArrowRight")).toBe(5);
|
||||
expect(navigateGridCell(5, 3, "ArrowRight")).toBe(5);
|
||||
expect(navigateGridCell(3, 3, "ArrowLeft")).toBe(3);
|
||||
expect(navigateGridCell(1, 3, "ArrowUp")).toBe(1);
|
||||
expect(navigateGridCell(7, 3, "ArrowDown")).toBe(7);
|
||||
});
|
||||
|
||||
it("supports row, grid and column-edge shortcuts", () => {
|
||||
expect(navigateGridCell(4, 3, "Home")).toBe(3);
|
||||
expect(navigateGridCell(4, 3, "End")).toBe(5);
|
||||
expect(navigateGridCell(4, 3, "Home", true)).toBe(0);
|
||||
expect(navigateGridCell(4, 3, "End", true)).toBe(8);
|
||||
expect(navigateGridCell(4, 3, "PageUp")).toBe(1);
|
||||
expect(navigateGridCell(4, 3, "PageDown")).toBe(7);
|
||||
});
|
||||
|
||||
it("ignores non-navigation keys and rejects unsafe inputs", () => {
|
||||
expect(navigateGridCell(0, 9, "Enter")).toBeNull();
|
||||
expect(() => navigateGridCell(-1, 9, "Home")).toThrow(/outside/i);
|
||||
expect(() => navigateGridCell(0, 0, "Home")).toThrow(/size/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
activeHypothesis,
|
||||
beginHypothesis,
|
||||
createGameplayHistory,
|
||||
createSavepoint,
|
||||
deleteSavepoint,
|
||||
describeGameplayChange,
|
||||
finishHypothesis,
|
||||
gameplayMoment,
|
||||
recordGameplayMoment,
|
||||
restoreSavepoint,
|
||||
sessionFromGameplayState,
|
||||
} from "../../src/state/playHistory";
|
||||
import {
|
||||
createAidMemoire,
|
||||
enterAidMemoireCell,
|
||||
} from "../../src/state/aidMemoire";
|
||||
import { createSession } from "../../src/state/session";
|
||||
|
||||
describe("gameplay history", () => {
|
||||
it("records immutable states with useful move labels", () => {
|
||||
const initial = createSession([0, 0, 0, 0]);
|
||||
const changed = { ...initial, values: [0, 3, 0, 0] };
|
||||
const label = describeGameplayChange(initial, changed, 2);
|
||||
const history = recordGameplayMoment(
|
||||
createGameplayHistory(initial),
|
||||
changed,
|
||||
label,
|
||||
);
|
||||
|
||||
expect(label).toBe("Set r1c2 to 3");
|
||||
expect(history.moments.map((moment) => moment.label)).toEqual([
|
||||
"Puzzle opened",
|
||||
"Set r1c2 to 3",
|
||||
]);
|
||||
changed.values[1] = 4;
|
||||
expect(history.moments[1]!.state.values[1]).toBe(3);
|
||||
});
|
||||
|
||||
it("creates, restores and deletes full named savepoints", () => {
|
||||
const session = createSession([0, 0, 0, 0]);
|
||||
session.values[1] = 2;
|
||||
session.centerMarks[2] = 5;
|
||||
session.colors[3] = 4;
|
||||
session.elapsedSeconds = 75;
|
||||
let history = createSavepoint(
|
||||
createGameplayHistory(session),
|
||||
session,
|
||||
"Before chain",
|
||||
);
|
||||
|
||||
session.values[1] = 4;
|
||||
session.centerMarks[2] = 0;
|
||||
const transition = restoreSavepoint(history, history.savepoints[0]!.id);
|
||||
const restored = sessionFromGameplayState(transition.state);
|
||||
expect(restored.values[1]).toBe(2);
|
||||
expect(restored.centerMarks[2]).toBe(5);
|
||||
expect(restored.colors[3]).toBe(4);
|
||||
expect(restored.elapsedSeconds).toBe(75);
|
||||
expect(transition.history.moments.at(-1)?.label).toBe(
|
||||
"Restored “Before chain”",
|
||||
);
|
||||
|
||||
history = deleteSavepoint(
|
||||
transition.history,
|
||||
transition.history.savepoints[0]!.id,
|
||||
);
|
||||
expect(history.savepoints).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("captures aid-mémoire state in moments and savepoints", () => {
|
||||
const session = createSession([0, 0, 0, 0]);
|
||||
let aidMemoire = createAidMemoire(2, {
|
||||
enabled: true,
|
||||
cellCount: 2,
|
||||
});
|
||||
aidMemoire = enterAidMemoireCell(aidMemoire, 0, "value", 1, 2);
|
||||
const history = createSavepoint(
|
||||
createGameplayHistory(session, aidMemoire),
|
||||
session,
|
||||
"Scratch one",
|
||||
aidMemoire,
|
||||
);
|
||||
|
||||
aidMemoire = enterAidMemoireCell(aidMemoire, 0, "value", 2, 2);
|
||||
const restored = restoreSavepoint(history, history.savepoints[0]!.id);
|
||||
expect(restored.state.aidMemoire?.cells[0]?.value).toBe(1);
|
||||
expect(history.moments[0]?.state.aidMemoire?.cells[0]?.value).toBe(1);
|
||||
expect(aidMemoire.cells[0]?.value).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps discarded hypotheses available while restoring their exact base", () => {
|
||||
const session = createSession([0, 0, 0, 0]);
|
||||
session.values[0] = 1;
|
||||
let transition = beginHypothesis(
|
||||
createGameplayHistory(session),
|
||||
session,
|
||||
"Try a 2",
|
||||
);
|
||||
expect(activeHypothesis(transition.history)?.name).toBe("Try a 2");
|
||||
|
||||
const branchSession = sessionFromGameplayState(transition.state);
|
||||
branchSession.values[1] = 2;
|
||||
transition = {
|
||||
history: recordGameplayMoment(
|
||||
transition.history,
|
||||
branchSession,
|
||||
"Set r1c2 to 2",
|
||||
),
|
||||
state: transition.state,
|
||||
};
|
||||
const result = finishHypothesis(
|
||||
transition.history,
|
||||
branchSession,
|
||||
"discard",
|
||||
);
|
||||
|
||||
expect(result.state.values).toEqual([1, 0, 0, 0]);
|
||||
expect(result.history.branches[0]?.status).toBe("discarded");
|
||||
expect(
|
||||
result.history.moments.some(
|
||||
(moment) =>
|
||||
moment.branchId === result.history.branches[0]!.id &&
|
||||
moment.state.values[1] === 2,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(result.history.moments.at(-1)?.label).toBe(
|
||||
"Discarded hypothesis “Try a 2”",
|
||||
);
|
||||
});
|
||||
|
||||
it("can branch from a replayed moment and merge accepted changes", () => {
|
||||
const initial = createSession([0, 0, 0, 0]);
|
||||
const first = { ...initial, values: [1, 0, 0, 0] };
|
||||
const second = { ...initial, values: [1, 2, 0, 0] };
|
||||
let history = recordGameplayMoment(
|
||||
createGameplayHistory(initial),
|
||||
first,
|
||||
"First move",
|
||||
);
|
||||
const replayId = history.currentMomentId;
|
||||
history = recordGameplayMoment(history, second, "Second move");
|
||||
|
||||
let transition = beginHypothesis(history, second, "Alternative", replayId);
|
||||
expect(transition.state.values).toEqual([1, 0, 0, 0]);
|
||||
const alternative = sessionFromGameplayState(transition.state);
|
||||
alternative.values[2] = 3;
|
||||
transition = {
|
||||
history: recordGameplayMoment(
|
||||
transition.history,
|
||||
alternative,
|
||||
"Alternative move",
|
||||
),
|
||||
state: transition.state,
|
||||
};
|
||||
const kept = finishHypothesis(transition.history, alternative, "keep");
|
||||
|
||||
expect(kept.state.values).toEqual([1, 0, 3, 0]);
|
||||
expect(kept.history.branches.at(-1)?.status).toBe("kept");
|
||||
expect(
|
||||
gameplayMoment(kept.history, kept.history.currentMomentId)?.label,
|
||||
).toBe("Kept hypothesis “Alternative”");
|
||||
});
|
||||
|
||||
it("rejects blank checkpoint and hypothesis names", () => {
|
||||
const session = createSession([0, 0, 0, 0]);
|
||||
const history = createGameplayHistory(session);
|
||||
expect(() => createSavepoint(history, session, " ")).toThrow(
|
||||
"Savepoint name cannot be empty",
|
||||
);
|
||||
expect(() => beginHypothesis(history, session, " ")).toThrow(
|
||||
"Hypothesis name cannot be empty",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user