147 lines
4.1 KiB
TypeScript
147 lines
4.1 KiB
TypeScript
import fc from "fast-check";
|
|
import { describe, expect, it } from "vitest";
|
|
import { analyzeSumLab, sumCombinationKey } from "../../src/helpers/sumLab";
|
|
|
|
function mask(...digits: number[]): number {
|
|
return digits.reduce((value, digit) => value | (2 ** (digit - 1)), 0);
|
|
}
|
|
|
|
describe("generalized sum lab", () => {
|
|
it("enumerates deterministic, sorted combinations", () => {
|
|
const result = analyzeSumLab({ cellCount: 2, target: 10 });
|
|
|
|
expect(result.combinations.map(({ digits }) => digits)).toEqual([
|
|
[1, 9],
|
|
[2, 8],
|
|
[3, 7],
|
|
[4, 6],
|
|
]);
|
|
expect(result.truncated).toBe(false);
|
|
});
|
|
|
|
it("supports a digit range, repeats, required and excluded digits", () => {
|
|
const result = analyzeSumLab({
|
|
cellCount: 3,
|
|
target: 15,
|
|
minimumDigit: 3,
|
|
maximumDigit: 7,
|
|
allowRepeats: true,
|
|
requiredDigits: [3],
|
|
excludedDigits: [4],
|
|
});
|
|
|
|
expect(result.combinations.map(({ digits }) => digits)).toEqual([
|
|
[3, 5, 7],
|
|
[3, 6, 6],
|
|
]);
|
|
});
|
|
|
|
it("uses per-cell candidate masks and derives positional possibilities", () => {
|
|
const result = analyzeSumLab({
|
|
cellCount: 2,
|
|
target: 10,
|
|
candidateMasks: [mask(1, 2), mask(8, 9)],
|
|
});
|
|
|
|
expect(result.combinations.map(({ digits }) => digits)).toEqual([
|
|
[1, 9],
|
|
[2, 8],
|
|
]);
|
|
expect(result.assignments).toEqual([
|
|
[1, 9],
|
|
[2, 8],
|
|
]);
|
|
expect(result.possibleByCell).toEqual([
|
|
[1, 2],
|
|
[8, 9],
|
|
]);
|
|
});
|
|
|
|
it("removes toggled combinations from every live deduction", () => {
|
|
const eliminatedKeys = new Set([sumCombinationKey([1, 9])]);
|
|
const result = analyzeSumLab({
|
|
cellCount: 2,
|
|
target: 10,
|
|
eliminatedKeys,
|
|
});
|
|
|
|
expect(result.combinations).toHaveLength(4);
|
|
expect(result.eliminatedCombinations.map(({ digits }) => digits)).toEqual([
|
|
[1, 9],
|
|
]);
|
|
expect(result.activeCombinations.map(({ digits }) => digits)).toEqual([
|
|
[2, 8],
|
|
[3, 7],
|
|
[4, 6],
|
|
]);
|
|
expect(result.possibleDigits).not.toContain(1);
|
|
expect(result.possibleDigits).not.toContain(9);
|
|
});
|
|
|
|
it("reports which independent safety bound stopped an analysis", () => {
|
|
const result = analyzeSumLab({
|
|
cellCount: 8,
|
|
target: 36,
|
|
allowRepeats: true,
|
|
maxSearchNodes: 1,
|
|
});
|
|
|
|
expect(result.truncated).toBe(true);
|
|
expect(result.truncationReason).toBe("search");
|
|
expect(result.exploredNodes).toBe(2);
|
|
});
|
|
|
|
it("validates positional candidates and contradictory digit filters", () => {
|
|
expect(() =>
|
|
analyzeSumLab({ cellCount: 2, target: 3, candidateMasks: [mask(1)] }),
|
|
).toThrow(/exactly 2/u);
|
|
expect(
|
|
analyzeSumLab({
|
|
cellCount: 2,
|
|
target: 3,
|
|
requiredDigits: [1],
|
|
excludedDigits: [1],
|
|
}).combinations,
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("preserves combination invariants over generated inputs", () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.integer({ min: 3, max: 12 }),
|
|
fc.integer({ min: 1, max: 6 }),
|
|
fc.integer({ min: 0, max: 72 }),
|
|
fc.boolean(),
|
|
(maximumDigit, cellCount, target, allowRepeats) => {
|
|
if (target > maximumDigit * cellCount) return;
|
|
const result = analyzeSumLab({
|
|
cellCount,
|
|
target,
|
|
maximumDigit,
|
|
allowRepeats,
|
|
});
|
|
const keys = new Set<string>();
|
|
for (const combination of result.combinations) {
|
|
expect(combination.digits).toHaveLength(cellCount);
|
|
expect(
|
|
combination.digits.reduce((total, digit) => total + digit, 0),
|
|
).toBe(target);
|
|
expect(combination.digits).toEqual(
|
|
[...combination.digits].sort((left, right) => left - right),
|
|
);
|
|
if (!allowRepeats) {
|
|
expect(new Set(combination.digits)).toHaveProperty(
|
|
"size",
|
|
cellCount,
|
|
);
|
|
}
|
|
keys.add(combination.key);
|
|
}
|
|
expect(keys.size).toBe(result.combinations.length);
|
|
},
|
|
),
|
|
{ numRuns: 100 },
|
|
);
|
|
});
|
|
});
|