feat: expand sudoku analysis and interoperability
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import fc from "fast-check";
|
||||
import { compressToBase64, compressToEncodedURIComponent } from "lz-string";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
LzStringOutputLimitError,
|
||||
decompressFromBase64Bounded,
|
||||
decompressFromBase64OrUriComponentBounded,
|
||||
decompressFromEncodedURIComponentBounded,
|
||||
} from "../../src/formats/boundedLz";
|
||||
import {
|
||||
MAX_DOCUMENT_BYTES,
|
||||
PUZZLE_HASH_PREFIX,
|
||||
SudokuFormatError,
|
||||
decodePuzzleHash,
|
||||
importFpuzzles,
|
||||
importPuzzle,
|
||||
importSudokuPad,
|
||||
parseFpuzzles,
|
||||
} from "../../src/formats";
|
||||
|
||||
function emptyGrid(size = 4) {
|
||||
return Array.from({ length: size }, () =>
|
||||
Array.from({ length: size }, () => ({})),
|
||||
);
|
||||
}
|
||||
|
||||
function expectLimitExceeded(action: () => unknown): void {
|
||||
try {
|
||||
action();
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(SudokuFormatError);
|
||||
expect(error).toMatchObject({ code: "LIMIT_EXCEEDED" });
|
||||
return;
|
||||
}
|
||||
throw new Error("Expected the import to reject an oversized payload.");
|
||||
}
|
||||
|
||||
describe("bounded puzzle imports", () => {
|
||||
it("keeps both LZ-String wire formats compatible for arbitrary text", () => {
|
||||
fc.assert(
|
||||
fc.property(fc.string({ maxLength: 512 }), (source) => {
|
||||
const byteLength = new TextEncoder().encode(source).byteLength;
|
||||
const limit = Math.max(1, byteLength);
|
||||
expect(
|
||||
decompressFromBase64Bounded(compressToBase64(source), limit),
|
||||
).toBe(source);
|
||||
expect(
|
||||
decompressFromEncodedURIComponentBounded(
|
||||
compressToEncodedURIComponent(source),
|
||||
limit,
|
||||
),
|
||||
).toBe(source);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("counts UTF-8 bytes while expanding and stops before oversized output", () => {
|
||||
const source = "🧩".repeat(1_024);
|
||||
const compressed = compressToEncodedURIComponent(source);
|
||||
const exactBytes = new TextEncoder().encode(source).byteLength;
|
||||
|
||||
expect(
|
||||
decompressFromEncodedURIComponentBounded(compressed, exactBytes),
|
||||
).toBe(source);
|
||||
expect(() =>
|
||||
decompressFromEncodedURIComponentBounded(compressed, exactBytes - 1),
|
||||
).toThrow(LzStringOutputLimitError);
|
||||
});
|
||||
|
||||
it("selects URI-safe payloads before an invalid Base64 interpretation", () => {
|
||||
const title = "AE(a^'XS2pclC*+Q: ?8IJnG(Fe-nR";
|
||||
const grid = emptyGrid();
|
||||
const fpuzzlesJson = JSON.stringify({ size: 4, grid, title });
|
||||
const fpuzzlesPayload = compressToEncodedURIComponent(fpuzzlesJson);
|
||||
const sudokuPadJson = JSON.stringify({
|
||||
id: "local-scl",
|
||||
cells: grid,
|
||||
metadata: {
|
||||
title,
|
||||
author: "Tester",
|
||||
rules: "Normal rules apply.",
|
||||
antiknight: true,
|
||||
},
|
||||
cages: [
|
||||
{
|
||||
cells: [
|
||||
[0, 0],
|
||||
[0, 1],
|
||||
],
|
||||
value: "3",
|
||||
unique: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const sudokuPadPayload = compressToEncodedURIComponent(sudokuPadJson);
|
||||
|
||||
expect(fpuzzlesPayload).toMatch(/[-$]/u);
|
||||
expect(sudokuPadPayload).toMatch(/[-$]/u);
|
||||
expect(
|
||||
decompressFromBase64Bounded(fpuzzlesPayload, MAX_DOCUMENT_BYTES),
|
||||
).not.toBe(fpuzzlesJson);
|
||||
expect(
|
||||
decompressFromBase64Bounded(sudokuPadPayload, MAX_DOCUMENT_BYTES),
|
||||
).not.toBe(sudokuPadJson);
|
||||
expect(
|
||||
decompressFromBase64OrUriComponentBounded(
|
||||
fpuzzlesPayload,
|
||||
MAX_DOCUMENT_BYTES,
|
||||
),
|
||||
).toBe(fpuzzlesJson);
|
||||
expect(importFpuzzles(`fpuzzles${fpuzzlesPayload}`).title).toBe(title);
|
||||
expect(importSudokuPad(`ctc${sudokuPadPayload}`).title).toBe(title);
|
||||
});
|
||||
|
||||
it("rejects compressed bombs in every LZ-backed import format", () => {
|
||||
const oversized = " ".repeat(MAX_DOCUMENT_BYTES + 1);
|
||||
const base64 = compressToBase64(oversized);
|
||||
const uri = compressToEncodedURIComponent(oversized);
|
||||
|
||||
expectLimitExceeded(() => importFpuzzles(`fpuzzles${base64}`));
|
||||
expectLimitExceeded(() => importSudokuPad(`ctc${base64}`));
|
||||
expectLimitExceeded(() => decodePuzzleHash(`${PUZZLE_HASH_PREFIX}${uri}`));
|
||||
});
|
||||
|
||||
it("checks raw JSON character and byte limits before parsing", async () => {
|
||||
const tooManyCharacters = `{${" ".repeat(MAX_DOCUMENT_BYTES)}}`;
|
||||
await expect(importPuzzle(tooManyCharacters)).rejects.toMatchObject({
|
||||
code: "LIMIT_EXCEEDED",
|
||||
});
|
||||
|
||||
const tooManyUtf8Bytes = `{"future":"${"é".repeat(
|
||||
Math.floor(MAX_DOCUMENT_BYTES / 2),
|
||||
)}"}`;
|
||||
expect(tooManyUtf8Bytes.length).toBeLessThan(MAX_DOCUMENT_BYTES);
|
||||
await expect(importPuzzle(tooManyUtf8Bytes)).rejects.toMatchObject({
|
||||
code: "LIMIT_EXCEEDED",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unknown fpuzzles inequality markers", () => {
|
||||
for (const value of [undefined, "≤", "left", 1]) {
|
||||
expect(() =>
|
||||
parseFpuzzles({
|
||||
size: 4,
|
||||
grid: emptyGrid(),
|
||||
inequality: [{ cells: ["R1C1", "R1C2"], value }],
|
||||
}),
|
||||
).toThrow(/inequality\.value must be either/u);
|
||||
}
|
||||
|
||||
expect(
|
||||
parseFpuzzles({
|
||||
size: 4,
|
||||
grid: emptyGrid(),
|
||||
inequality: [{ cells: ["R1C1", "R1C2"], value: "<" }],
|
||||
}).constraints,
|
||||
).toContainEqual({ type: "inequality", lesser: 0, greater: 1 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user