import { describe, it, expect, beforeEach } from 'vitest';
import { draftStore } from './draft-store.js';
// Reset state before each test
beforeEach(() => {
draftStore.resetAll();
});
describe('DraftStore', () => {
describe('createDraft', () => {
it('should create a draft with defaults', () => {
const draft = draftStore.createDraft('Test Level');
expect(draft.name).toBe('Test Level');
expect(draft.gridWidth).toBe(10);
expect(draft.gridHeight).toBe(10);
expect(draft.par).toBe(0);
expect(draft.startPosition).toEqual({ x: 1, y: 8 });
expect(draft.goalPosition).toEqual({ x: 8, y: 1 });
expect(draft.obstacles).toEqual([]);
expect(draft.isDirty).toBe(false);
});
it('should create a draft with custom size', () => {
const draft = draftStore.createDraft('Custom', 15, 12);
expect(draft.gridWidth).toBe(15);
expect(draft.gridHeight).toBe(12);
expect(draft.startPosition).toEqual({ x: 1, y: 10 });
expect(draft.goalPosition).toEqual({ x: 13, y: 1 });
});
it('should reset warp counter on new draft', () => {
draftStore.createDraft('First');
draftStore.addWarpPair(2, 2, 5, 5);
draftStore.addWarpPair(3, 3, 6, 6);
// Counter would be at 3 now
const newDraft = draftStore.createDraft('Second');
draftStore.addWarpPair(2, 2, 5, 5);
expect(newDraft.warpPairs).toEqual([]); // New draft starts clean
const updated = draftStore.getCurrentDraft()!;
expect(updated.warpPairs[0].id).toBe('warp_1'); // Counter reset
});
});
describe('updateDraft', () => {
it('should update draft fields', () => {
draftStore.createDraft('Test');
const updated = draftStore.updateDraft({ name: 'Updated' });
expect(updated.name).toBe('Updated');
expect(updated.isDirty).toBe(true);
});
it('should respect explicit isDirty: false', () => {
draftStore.createDraft('Test');
const updated = draftStore.updateDraft({ isDirty: false });
expect(updated.isDirty).toBe(false);
});
it('should throw if no current draft', () => {
expect(() => draftStore.updateDraft({ name: 'x' })).toThrow('No current draft');
});
});
describe('placeElement / removeElement', () => {
it('should place and remove obstacle', () => {
draftStore.createDraft('Test');
draftStore.placeElement(3, 3, 'rock');
let draft = draftStore.getCurrentDraft()!;
expect(draft.obstacles).toHaveLength(1);
expect(draft.obstacles[0]).toEqual({ x: 3, y: 3, type: 'rock' });
draftStore.removeElement(3, 3);
draft = draftStore.getCurrentDraft()!;
expect(draft.obstacles).toHaveLength(0);
});
it('should replace existing obstacle at same position', () => {
draftStore.createDraft('Test');
draftStore.placeElement(3, 3, 'rock');
draftStore.placeElement(3, 3, 'lava');
const draft = draftStore.getCurrentDraft()!;
expect(draft.obstacles).toHaveLength(1);
expect(draft.obstacles[0].type).toBe('lava');
});
it('should clear special elements when placing a regular obstacle', () => {
draftStore.createDraft('Test');
draftStore.addThinIce([{ x: 3, y: 3 }]);
draftStore.addPushableRock([{ x: 4, y: 4 }]);
draftStore.addWarpPair(5, 5, 6, 6);
draftStore.setPressurePlate(7, 7);
draftStore.setBarrier(8, 8);
draftStore.placeElement(3, 3, 'rock');
draftStore.placeElement(4, 4, 'lava');
draftStore.placeElement(5, 5, 'hot_coals');
draftStore.placeElement(7, 7, 'rock');
draftStore.placeElement(8, 8, 'rock');
const draft = draftStore.getCurrentDraft()!;
expect(draft.thinIceTiles).not.toContainEqual({ x: 3, y: 3 });
expect(draft.pushableRocks).not.toContainEqual({ x: 4, y: 4 });
expect(draft.warpPairs).toHaveLength(0); // Pair is removed when either endpoint is cleared.
expect(draft.pressurePlate).toBeNull();
expect(draft.barrier).toBeNull();
});
});
describe('clearPosition', () => {
it('should clear all element types at a coordinate', () => {
draftStore.createDraft('Clear Test');
draftStore.placeElement(3, 3, 'rock');
draftStore.addThinIce([{ x: 4, y: 4 }]);
draftStore.addPushableRock([{ x: 5, y: 5 }]);
draftStore.addWarpPair(6, 6, 7, 7);
draftStore.setPressurePlate(2, 2);
draftStore.setBarrier(8, 2);
draftStore.clearPosition(3, 3);
draftStore.clearPosition(4, 4);
draftStore.clearPosition(5, 5);
draftStore.clearPosition(6, 6);
draftStore.clearPosition(2, 2);
draftStore.clearPosition(8, 2);
const draft = draftStore.getCurrentDraft()!;
expect(draft.obstacles.some((obs) => obs.x === 3 && obs.y === 3)).toBe(false);
expect(draft.thinIceTiles.some((tile) => tile.x === 4 && tile.y === 4)).toBe(false);
expect(draft.pushableRocks.some((rock) => rock.x === 5 && rock.y === 5)).toBe(false);
expect(draft.warpPairs).toHaveLength(0);
expect(draft.pressurePlate).toBeNull();
expect(draft.barrier).toBeNull();
});
});
describe('save / load / delete', () => {
it('should save and load draft', () => {
draftStore.createDraft('Saved Level');
draftStore.placeElement(3, 3, 'rock');
const id = draftStore.saveDraft();
// Create a new draft (clears current)
draftStore.createDraft('Other');
// Load saved
const loaded = draftStore.loadDraft(id);
expect(loaded).not.toBeNull();
expect(loaded!.name).toBe('Saved Level');
expect(loaded!.obstacles).toHaveLength(1);
});
it('should return null for nonexistent draft', () => {
expect(draftStore.loadDraft('nonexistent')).toBeNull();
});
it('should delete draft', () => {
draftStore.createDraft('To Delete');
const id = draftStore.saveDraft();
expect(draftStore.deleteDraft(id)).toBe(true);
expect(draftStore.deleteDraft(id)).toBe(false); // Already deleted
});
it('should list drafts', () => {
draftStore.createDraft('A');
draftStore.saveDraft();
draftStore.createDraft('B');
draftStore.saveDraft();
const drafts = draftStore.listDrafts();
expect(drafts).toHaveLength(2);
});
});
describe('exportPuzzleData / importPuzzleData', () => {
it('should export valid puzzle data', () => {
draftStore.createDraft('Export Test', 8, 6);
draftStore.placeElement(3, 3, 'rock');
draftStore.placeElement(4, 4, 'lava');
const data = draftStore.exportPuzzleData();
expect(data).not.toBeNull();
expect(data!.width).toBe(8);
expect(data!.height).toBe(6);
expect(data!.par).toBe(0);
expect(data!.start).toEqual({ x: 1, y: 4 });
expect(data!.goal).toEqual({ x: 6, y: 1 });
expect(data!.obstacles).toHaveLength(2);
});
it('should preserve explicit par through export/import', () => {
draftStore.createDraft('Par Test', 8, 6);
draftStore.updateDraft({ par: 9 });
const exported = draftStore.exportPuzzleData();
expect(exported).not.toBeNull();
expect(exported!.par).toBe(9);
const imported = draftStore.importPuzzleData(exported!);
expect(imported.par).toBe(9);
});
it('should not export legacy dirtTiles field', () => {
draftStore.createDraft('Export Shape');
draftStore.placeElement(3, 3, 'rock');
const data = draftStore.exportPuzzleData();
expect(data).not.toBeNull();
expect('dirtTiles' in (data as any)).toBe(false);
});
it('should round-trip via export/import', () => {
draftStore.createDraft('Round Trip', 12, 10);
draftStore.placeElement(3, 3, 'rock');
draftStore.placeElement(5, 5, 'lava');
draftStore.addThinIce([{ x: 4, y: 4 }]);
draftStore.addPushableRock([{ x: 6, y: 6 }]);
draftStore.addWarpPair(2, 2, 8, 8);
const exported = draftStore.exportPuzzleData()!;
draftStore.importPuzzleData(exported);
const reimported = draftStore.getCurrentDraft()!;
expect(reimported.gridWidth).toBe(12);
expect(reimported.gridHeight).toBe(10);
expect(reimported.obstacles.length).toBeGreaterThanOrEqual(2);
expect(reimported.thinIceTiles).toHaveLength(1);
expect(reimported.pushableRocks).toHaveLength(1);
expect(reimported.warpPairs).toHaveLength(1);
});
it('should handle barrier obstacles in obstacles array', () => {
draftStore.createDraft('Barrier Test', 10, 10);
// Add a barrier as an obstacle (not via setBarrier)
draftStore.placeElement(5, 5, 'barrier');
const exported = draftStore.exportPuzzleData();
expect(exported).not.toBeNull();
// Barrier obstacle should be migrated to barrier field
expect(exported!.barrier).toEqual({ x: 5, y: 5 });
// Barrier should NOT appear in obstacles array
expect(exported!.obstacles.some(o => o.type === 'barrier')).toBe(false);
});
it('should normalize legacy thin_ice and pushable_rock obstacle encodings', () => {
const legacyPuzzle = {
id: 'legacy',
name: 'Legacy Encodings',
theme: 'ice',
width: 10,
height: 10,
par: 5,
start: { x: 1, y: 8 },
goal: { x: 8, y: 1 },
obstacles: [
{ x: 3, y: 3, type: 'thin_ice' as const },
{ x: 4, y: 4, type: 'pushable_rock' as const },
],
};
const draft = draftStore.importPuzzleData(legacyPuzzle as any);
expect(draft.thinIceTiles).toContainEqual({ x: 3, y: 3 });
expect(draft.pushableRocks).toContainEqual({ x: 4, y: 4 });
expect(draft.obstacles.some((obs) => obs.type === 'thin_ice')).toBe(false);
expect(draft.obstacles.some((obs) => obs.type === 'pushable_rock')).toBe(false);
const exported = draftStore.exportPuzzleData()!;
expect(exported.thinIceTiles).toContainEqual({ x: 3, y: 3 });
expect(exported.pushableRocks).toContainEqual({ x: 4, y: 4 });
expect(exported.obstacles.some((obs) => obs.type === 'thin_ice')).toBe(false);
expect(exported.obstacles.some((obs) => obs.type === 'pushable_rock')).toBe(false);
});
it('should throw on invalid puzzle data import', () => {
expect(() => draftStore.importPuzzleData({} as any)).toThrow('Invalid puzzle data');
});
it('should reject legacy dirtTiles imports', () => {
const puzzleData = {
id: 'legacy-dirt',
name: 'Legacy Dirt',
theme: 'ice',
width: 10,
height: 10,
par: 5,
start: { x: 1, y: 8 },
goal: { x: 8, y: 1 },
obstacles: [],
dirtTiles: [{ x: 3, y: 3 }],
};
expect(() => draftStore.importPuzzleData(puzzleData as any)).toThrow('dirtTiles is no longer supported');
});
it('should reject duplicate obstacle positions', () => {
const puzzleData = {
id: 'test',
name: 'Test',
theme: 'ice',
width: 10,
height: 10,
par: 5,
start: { x: 1, y: 8 },
goal: { x: 8, y: 1 },
obstacles: [
{ x: 3, y: 3, type: 'rock' as const },
{ x: 3, y: 3, type: 'lava' as const }, // Duplicate position
],
};
expect(() => draftStore.importPuzzleData(puzzleData)).toThrow();
});
it('should reject invalid warp count (1 position)', () => {
const puzzleData = {
id: 'test',
name: 'Test',
theme: 'ice',
width: 10,
height: 10,
par: 5,
start: { x: 1, y: 8 },
goal: { x: 8, y: 1 },
obstacles: [],
warps: [
{ x: 3, y: 3, id: 'w1' }, // Only 1 position for warp
],
};
expect(() => draftStore.importPuzzleData(puzzleData)).toThrow();
});
it('should reject invalid warp count (3 positions)', () => {
const puzzleData = {
id: 'test',
name: 'Test',
theme: 'ice',
width: 10,
height: 10,
par: 5,
start: { x: 1, y: 8 },
goal: { x: 8, y: 1 },
obstacles: [],
warps: [
{ x: 2, y: 2, id: 'w1' },
{ x: 5, y: 5, id: 'w1' },
{ x: 8, y: 8, id: 'w1' }, // 3 positions for same warp
],
};
expect(() => draftStore.importPuzzleData(puzzleData)).toThrow();
});
it('should reject start == goal positions', () => {
const puzzleData = {
id: 'test',
name: 'Test',
theme: 'ice',
width: 10,
height: 10,
par: 5,
start: { x: 5, y: 5 },
goal: { x: 5, y: 5 }, // Same as start
obstacles: [],
};
expect(() => draftStore.importPuzzleData(puzzleData)).toThrow();
});
it('should reject obstacle outside playable area (on wall)', () => {
const puzzleData = {
id: 'test',
name: 'Test',
theme: 'ice',
width: 10,
height: 10,
par: 5,
start: { x: 1, y: 8 },
goal: { x: 8, y: 1 },
obstacles: [
{ x: 0, y: 0, type: 'rock' as const }, // Corner wall position
],
};
expect(() => draftStore.importPuzzleData(puzzleData)).toThrow();
});
});
describe('warps', () => {
it('should add and remove warp pairs', () => {
draftStore.createDraft('Warp Test');
draftStore.addWarpPair(2, 2, 5, 5);
let draft = draftStore.getCurrentDraft()!;
expect(draft.warpPairs).toHaveLength(1);
expect(draft.warpPairs[0].positions).toEqual([{ x: 2, y: 2 }, { x: 5, y: 5 }]);
draftStore.removeWarp(draft.warpPairs[0].id);
draft = draftStore.getCurrentDraft()!;
expect(draft.warpPairs).toHaveLength(0);
});
});
describe('thin ice', () => {
it('should add and remove thin ice', () => {
draftStore.createDraft('Ice Test');
draftStore.addThinIce([{ x: 3, y: 3 }, { x: 4, y: 4 }]);
let draft = draftStore.getCurrentDraft()!;
expect(draft.thinIceTiles).toHaveLength(2);
draftStore.removeThinIce([{ x: 3, y: 3 }]);
draft = draftStore.getCurrentDraft()!;
expect(draft.thinIceTiles).toHaveLength(1);
});
it('should dedupe thin ice positions', () => {
draftStore.createDraft('Thin Dedupe');
draftStore.addThinIce([{ x: 3, y: 3 }, { x: 3, y: 3 }, { x: 4, y: 4 }]);
const draft = draftStore.getCurrentDraft()!;
expect(draft.thinIceTiles).toHaveLength(2);
});
});
describe('pushable rocks', () => {
it('should dedupe pushable rock positions', () => {
draftStore.createDraft('Rock Dedupe');
draftStore.addPushableRock([{ x: 3, y: 3 }, { x: 3, y: 3 }, { x: 4, y: 4 }]);
const draft = draftStore.getCurrentDraft()!;
expect(draft.pushableRocks).toHaveLength(2);
});
});
describe('pressure plate / barrier', () => {
it('should set and remove pressure plate', () => {
draftStore.createDraft('Plate Test');
draftStore.setPressurePlate(3, 3);
let draft = draftStore.getCurrentDraft()!;
expect(draft.pressurePlate).toEqual({ x: 3, y: 3 });
draftStore.removePressurePlate();
draft = draftStore.getCurrentDraft()!;
expect(draft.pressurePlate).toBeNull();
});
it('should set and remove barrier', () => {
draftStore.createDraft('Barrier Test');
draftStore.setBarrier(5, 5);
let draft = draftStore.getCurrentDraft()!;
expect(draft.barrier).toEqual({ x: 5, y: 5 });
draftStore.removeBarrier();
draft = draftStore.getCurrentDraft()!;
expect(draft.barrier).toBeNull();
});
});
describe('start/goal positioning', () => {
it('clears conflicting elements when setting start position', () => {
draftStore.createDraft('Start Position');
draftStore.placeElement(3, 3, 'rock');
draftStore.setStartPosition(3, 3);
const draft = draftStore.getCurrentDraft()!;
expect(draft.startPosition).toEqual({ x: 3, y: 3 });
expect(draft.obstacles.some((obs) => obs.x === 3 && obs.y === 3)).toBe(false);
});
it('clears conflicting elements when setting goal position', () => {
draftStore.createDraft('Goal Position');
draftStore.addThinIce([{ x: 3, y: 3 }]);
draftStore.setGoalPosition(3, 3);
const draft = draftStore.getCurrentDraft()!;
expect(draft.goalPosition).toEqual({ x: 3, y: 3 });
expect(draft.thinIceTiles.some((tile) => tile.x === 3 && tile.y === 3)).toBe(false);
});
it('rejects start == goal when setting start or goal', () => {
draftStore.createDraft('Start Goal Guard');
expect(() => draftStore.setStartPosition(8, 1)).toThrow('Start and goal positions cannot be the same');
expect(() => draftStore.setGoalPosition(1, 8)).toThrow('Start and goal positions cannot be the same');
});
});
describe('resizeGrid', () => {
it('guarantees start and goal are distinct after resize', () => {
draftStore.createDraft('Resize Distinct');
draftStore.updateDraft({
startPosition: { x: 2, y: 2 },
goalPosition: { x: 2, y: 2 },
});
const resized = draftStore.resizeGrid(5, 5);
expect(resized.startPosition).not.toEqual(resized.goalPosition);
});
it('clears elements that overlap clamped start/goal positions', () => {
draftStore.createDraft('Resize Cleanup', 10, 10);
draftStore.updateDraft({
obstacles: [{ x: 1, y: 3, type: 'rock' }],
thinIceTiles: [{ x: 4, y: 1 }],
pushableRocks: [{ x: 1, y: 3 }],
warpPairs: [{ id: 'warp_1', positions: [{ x: 1, y: 3 }, { x: 2, y: 2 }] }],
pressurePlate: { x: 1, y: 3 },
barrier: { x: 4, y: 1 },
});
const resized = draftStore.resizeGrid(5, 5);
expect(resized.startPosition).toEqual({ x: 1, y: 3 });
expect(resized.goalPosition).toEqual({ x: 4, y: 1 });
expect(resized.obstacles.some((obs) => obs.x === 1 && obs.y === 3)).toBe(false);
expect(resized.thinIceTiles.some((tile) => tile.x === 4 && tile.y === 1)).toBe(false);
expect(resized.pushableRocks.some((rock) => rock.x === 1 && rock.y === 3)).toBe(false);
expect(resized.warpPairs).toHaveLength(0);
expect(resized.pressurePlate).toBeNull();
expect(resized.barrier).toBeNull();
});
it('moves corner goals inward after resize', () => {
draftStore.createDraft('Corner Goal');
draftStore.updateDraft({ goalPosition: { x: 0, y: 0 } });
const resized = draftStore.resizeGrid(5, 5);
expect(resized.goalPosition).toEqual({ x: 1, y: 1 });
});
});
describe('history + recovery', () => {
it('undo/redo should restore state snapshots', () => {
draftStore.createDraft('History');
draftStore.placeElement(3, 3, 'rock');
draftStore.placeElement(4, 4, 'lava');
const beforeUndo = draftStore.getCurrentDraft()!;
expect(beforeUndo.obstacles).toHaveLength(2);
const undone = draftStore.undo();
expect(undone).not.toBeNull();
expect(undone!.obstacles).toHaveLength(1);
expect(undone!.obstacles[0]).toEqual({ x: 3, y: 3, type: 'rock' });
const redone = draftStore.redo();
expect(redone).not.toBeNull();
expect(redone!.obstacles).toHaveLength(2);
expect(redone!.obstacles.some((obs) => obs.x === 4 && obs.y === 4 && obs.type === 'lava')).toBe(true);
});
it('withSingleHistoryEntry should create one undo point for grouped edits', () => {
draftStore.createDraft('Grouped');
draftStore.withSingleHistoryEntry(() => {
draftStore.placeElement(2, 2, 'rock');
draftStore.placeElement(3, 2, 'rock');
draftStore.placeElement(4, 2, 'rock');
});
const draft = draftStore.getCurrentDraft()!;
expect(draft.obstacles).toHaveLength(3);
const history = draftStore.getHistoryStatus();
expect(history.undoDepth).toBe(1);
});
it('revertToLastSolvable should restore saved solvable snapshot', () => {
draftStore.createDraft('Recover');
draftStore.updateDraft({
lastSolverResult: {
solvable: true,
solution: ['right'],
moves: 1,
iterations: 5,
visitedCount: 5,
},
}, { trackHistory: false });
draftStore.markCurrentAsLastSolvable();
draftStore.placeElement(3, 3, 'rock');
draftStore.placeElement(4, 4, 'lava');
const reverted = draftStore.revertToLastSolvable();
expect(reverted).not.toBeNull();
expect(reverted!.obstacles).toHaveLength(0);
expect(reverted!.lastSolverResult?.solvable).toBe(true);
});
});
describe('manual solve snapshot metadata', () => {
it('persists manual solve baseline + fingerprint across save/load', () => {
draftStore.createDraft('Snapshot Meta');
draftStore.updateDraft({
manualSolveMechanicsBaseline: ['warp', 'thin_ice'],
manualSolveFingerprint: 'fingerprint-v1',
}, { trackHistory: false });
const id = draftStore.saveDraft();
draftStore.createDraft('Other');
const loaded = draftStore.loadDraft(id);
expect(loaded).not.toBeNull();
expect(loaded!.manualSolveMechanicsBaseline).toEqual(['warp', 'thin_ice']);
expect(loaded!.manualSolveFingerprint).toBe('fingerprint-v1');
});
});
describe('rename + move', () => {
it('renames current draft', () => {
draftStore.createDraft('Old Name');
const renamed = draftStore.renameCurrentDraft('New Name');
expect(renamed.name).toBe('New Name');
});
it('moves obstacle tiles between coordinates', () => {
draftStore.createDraft('Move Tile');
draftStore.placeElement(3, 3, 'rock');
const moved = draftStore.moveElement({ x: 3, y: 3 }, { x: 5, y: 5 });
expect(moved.movedType).toBe('rock');
expect(moved.draft.obstacles.some((obs) => obs.x === 3 && obs.y === 3)).toBe(false);
expect(moved.draft.obstacles.some((obs) => obs.x === 5 && obs.y === 5 && obs.type === 'rock')).toBe(true);
});
});
});