1import test from 'node:test';2import assert from 'node:assert/strict';3import {4 INDEX_NODES_PER_SHEET,5 createIndexLayout,6 createIndexNavigator,7 createProgressStore,8 decorateIndex,9 getProgressKey,10} from '../src/scenes/map.js';1112const level = (packId, localId, title = localId, origin = packId === 'core' ? 'bundled' : 'local') => ({13 id: `${packId}:${localId}`,14 localId,15 packId,16 packName: packId === 'core' ? 'Miau Core' : 'Word Lab',17 origin,18 title,19 width: 1,20 height: 1,21 objects: [],22});2324test('INDEX auto layout is serpentine and continues across sheets', () => {25 const levels = Array.from({ length: 17 }, (_, index) => level('core', `proof-${index + 1}`));26 levels.push(level('word-lab', 'first'));27 const layout = createIndexLayout(levels);28 assert.equal(layout.sheets.length, 3);29 assert.equal(layout.sheets[0].nodes.length, INDEX_NODES_PER_SHEET);30 assert.equal(layout.sheets[1].nodes.length, 2);31 assert.equal(layout.sheets[2].packId, 'word-lab');32 assert.deepEqual(layout.sheets[0].nodes.slice(0, 5).map(node => [node.column, node.row]), [[0, 0], [1, 0], [2, 0], [3, 0], [4, 0]]);33 assert.deepEqual(layout.sheets[0].nodes.slice(5, 10).map(node => [node.column, node.row]), [[4, 1], [3, 1], [2, 1], [1, 1], [0, 1]]);34 assert.equal(layout.sheets[0].nodes[14].nextId, 'core:proof-16');35 assert.equal(layout.sheets[0].nextSheetId, 'core:sheet-2');36 assert.equal(layout.sheets[1].previousSheetId, 'core:sheet-1');37 assert.equal(layout.nodes[16].id, 'core:proof-17');38 assert.equal(layout.nodes.at(-1).id, 'word-lab:first');39});4041test('every pack owns its sheet range in registration order', () => {42 const levels = [43 ...Array.from({ length: 3 }, (_, index) => level('core', `core-${index + 1}`)),44 ...Array.from({ length: 3 }, (_, index) => level('garden-basics', `garden-${index + 1}`, undefined, 'bundled')),45 ...Array.from({ length: 3 }, (_, index) => level('word-lab', `lab-${index + 1}`, undefined, 'bundled')),46 level('imported-pack', 'intro'),47 ];48 const layout = createIndexLayout(levels);49 assert.equal(layout.sheets.length, 4);50 assert.equal(layout.sheets[0].nodes.length, 3);51 assert.equal(layout.sheets[0].id, 'core:sheet-1');52 assert.deepEqual(layout.sheets.map(sheet => [sheet.id, sheet.nodes.length]), [53 ['core:sheet-1', 3], ['garden-basics:sheet-1', 3], ['word-lab:sheet-1', 3], ['imported-pack:sheet-1', 1],54 ]);55 assert.deepEqual(layout.worlds.map(world => [world.number, world.id, world.sheets]), [56 [1, 'core', ['core:sheet-1']],57 [2, 'garden-basics', ['garden-basics:sheet-1']],58 [3, 'word-lab', ['word-lab:sheet-1']],59 [4, 'imported-pack', ['imported-pack:sheet-1']],60 ]);61 assert.equal(layout.sheets[0].nextSheetId, null);62 assert.equal(layout.sheets[0].nextWorldId, 'garden-basics');63});6465test('bundled proofs continue after fifteen without merging imported packs', () => {66 const levels = [67 ...Array.from({ length: 16 }, (_, index) => level('core', `core-${index + 1}`)),68 ...Array.from({ length: 16 }, (_, index) => level('imported-pack', `proof-${index + 1}`)),69 ];70 const layout = createIndexLayout(levels);71 assert.deepEqual(layout.sheets.map(sheet => [sheet.id, sheet.nodes.length]), [72 ['core:sheet-1', 15], ['core:sheet-2', 1], ['imported-pack:sheet-1', 15], ['imported-pack:sheet-2', 1],73 ]);74});7576test('indexed layout keeps neighbor and world lookups stable across large sheets', () => {77 const levels = [78 ...Array.from({ length: 47 }, (_, index) => level('core', `core-${index + 1}`)),79 ...Array.from({ length: 31 }, (_, index) => level('tidewater', `tide-${index + 1}`)),80 ];81 const layout = createIndexLayout(levels);82 assert.equal(layout.sheets.length, 7);83 assert.equal(layout.getWorldNodes('core').length, 47);84 assert.equal(layout.getWorldNodes('tidewater').length, 31);85 for (const sheet of layout.sheets) {86 for (const node of sheet.nodes) {87 const adjacent = (row, column) => sheet.nodes.find(candidate => candidate.row === row && candidate.column === column)?.id || null;88 assert.deepEqual(node.neighbors, {89 left: adjacent(node.row, node.column - 1),90 right: adjacent(node.row, node.column + 1),91 up: adjacent(node.row - 1, node.column),92 down: adjacent(node.row + 1, node.column),93 });94 }95 }96});9798test('progress uses stable pack/level keys and locks the next proof', () => {99 const storage = new Map();100 const fakeStorage = {101 get length() { return storage.size; },102 key(index) { return [...storage.keys()][index] ?? null; },103 getItem(key) { return storage.get(key) ?? null; },104 setItem(key, value) { storage.set(key, String(value)); },105 removeItem(key) { storage.delete(key); },106 };107 const progress = createProgressStore(fakeStorage);108 assert.equal(getProgressKey('core', 'intro'), 'miau.index.progress/core/intro');109 const layout = createIndexLayout([level('core', 'intro'), level('core', 'second'), level('word-lab', 'first')]);110 let indexed = decorateIndex(layout, progress);111 assert.deepEqual(indexed.nodes.map(node => [node.id, node.state]), [112 ['core:intro', 'available'], ['core:second', 'locked'], ['word-lab:first', 'available'],113 ]);114 progress.markSolved('core', 'intro');115 indexed = decorateIndex(layout, progress);116 assert.equal(indexed.nodes[0].solved, true);117 assert.equal(indexed.nodes[1].locked, false);118 assert.equal(storage.get('miau.index.progress/core/intro'), '1');119 progress.clear('core', 'intro');120 assert.equal(progress.isSolved('core', 'intro'), false);121});122123test('navigator indicates locked proofs, can skip them, and enters available proofs', () => {124 const layout = decorateIndex(createIndexLayout([level('core', 'intro'), level('core', 'second'), level('core', 'third')]), {125 isSolved(packId, levelId) { return packId === 'core' && levelId === 'intro'; },126 });127 const navigator = createIndexNavigator(layout, { locked: 'indicate' });128 assert.equal(navigator.current.node.id, 'core:intro');129 assert.equal(navigator.enter().ok, true);130 let result = navigator.move('next');131 assert.equal(result.moved, true);132 assert.equal(result.node.id, 'core:second');133 result = navigator.move('next');134 assert.equal(result.locked, true);135 assert.equal(result.node.id, 'core:third');136 assert.equal(navigator.current.node.id, 'core:second');137 assert.equal(navigator.enter().levelId, 'second');138139 const skipping = createIndexNavigator(layout, { startNodeId: 'core:intro', locked: 'skip' });140 result = skipping.move('next');141 assert.equal(result.moved, true);142 assert.equal(result.node.id, 'core:second');143 result = skipping.move('next');144 assert.equal(result.moved, false);145 assert.equal(result.node.id, 'core:second');146 assert.equal(skipping.enter().levelId, 'second');147});148149test('arrow directions follow visible serpentine neighbors', () => {150 const levels = Array.from({ length: 10 }, (_, index) => level('core', `proof-${index + 1}`));151 const layout = decorateIndex(createIndexLayout(levels), { isSolved: () => true });152 const navigator = createIndexNavigator(layout, { startNodeId: 'core:proof-7' });153154 // Row two runs right-to-left: 07 is visibly beside 08 on its left.155 let result = navigator.move('left');156 assert.equal(result.moved, true);157 assert.equal(result.node.id, 'core:proof-8');158 result = navigator.move('right');159 assert.equal(result.moved, true);160 assert.equal(result.node.id, 'core:proof-7');161162 // 07 has no vertical path edge: 04 is merely grid-adjacent above it.163 result = navigator.move('up');164 assert.equal(result.moved, false);165 assert.equal(result.node.id, 'core:proof-7');166167 // The path turn is the actual vertical edge between 05 and 06.168 navigator.setNode('core:proof-6');169 result = navigator.move('up');170 assert.equal(result.moved, true);171 assert.equal(result.node.id, 'core:proof-5');172 result = navigator.move('down');173 assert.equal(result.moved, true);174 assert.equal(result.node.id, 'core:proof-6');175 // Explicit path traversal still follows numbering, independent of display.176 navigator.setNode('core:proof-7');177 result = navigator.move('next');178 assert.equal(result.node.id, 'core:proof-8');179});180181test('arrow directions page across sheet and imported-pack boundaries', () => {182 const levels = [183 ...Array.from({ length: 16 }, (_, index) => level('core', `proof-${index + 1}`)),184 level('imported-pack', 'intro'),185 ];186 const layout = decorateIndex(createIndexLayout(levels), { isSolved: () => true });187 const navigator = createIndexNavigator(layout, { startNodeId: 'core:proof-15' });188189 let result = navigator.move('right');190 assert.equal(result.moved, true);191 assert.equal(result.node.id, 'core:proof-16');192 result = navigator.move('right');193 assert.equal(result.moved, true);194 assert.equal(result.node.id, 'imported-pack:intro');195 result = navigator.move('left');196 assert.equal(result.moved, true);197 assert.equal(result.node.id, 'core:proof-16');198});199200test('world navigator switches worlds without wraparound and remembers cursors', () => {201 const layout = decorateIndex(createIndexLayout([202 level('core', 'one'), level('core', 'two'),203 level('garden-basics', 'one'), level('garden-basics', 'two'),204 level('word-lab', 'one'),205 ]), { isSolved: () => true });206 const navigator = createIndexNavigator(layout, { startNodeId: 'core:one' });207 assert.equal(navigator.current.worldId, 'core');208 assert.equal(navigator.moveWorld(-1).moved, false);209 assert.equal(navigator.moveWorld(1).node.id, 'garden-basics:one');210 navigator.move('next');211 assert.equal(navigator.current.node.id, 'garden-basics:two');212 assert.equal(navigator.moveWorld(1).node.id, 'word-lab:one');213 assert.equal(navigator.moveWorld(1).moved, false);214 assert.equal(navigator.moveWorld(-1).node.id, 'garden-basics:two');215 assert.equal(navigator.moveWorld(-1).node.id, 'core:one');216});217218test('world cursor snapshots survive index reconstruction and ignore stale IDs', () => {219 const layout = decorateIndex(createIndexLayout([220 level('core', 'one'), level('core', 'two'),221 level('garden-basics', 'one'), level('garden-basics', 'two'),222 ]), { isSolved: () => true });223 const navigator = createIndexNavigator(layout, { startNodeId: 'core:one' });224 navigator.moveWorld(1);225 navigator.move('next');226 navigator.moveWorld(-1);227 const snapshot = navigator.cursorSnapshot();228 assert.deepEqual(snapshot, { core: 'core:one', 'garden-basics': 'garden-basics:two' });229230 const rebuilt = decorateIndex(createIndexLayout([231 level('core', 'one'), level('core', 'two'),232 level('garden-basics', 'one'), level('garden-basics', 'two'),233 ]), { isSolved: () => true });234 const restored = createIndexNavigator(rebuilt, {235 startNodeId: 'core:one',236 cursorByWorld: { ...snapshot, removed: 'removed:proof' },237 });238 assert.deepEqual(restored.cursorSnapshot(), snapshot);239 assert.equal(restored.moveWorld(1).node.id, 'garden-basics:two');240 assert.deepEqual(restored.restoreCursorSnapshot({ removed: 'gone', 'garden-basics': 'garden-basics:one' }), {241 'garden-basics': 'garden-basics:one',242 });243});244245test('removing the active imported world repairs the INDEX cursor', () => {246 const layout = decorateIndex(createIndexLayout([level('core', 'one')]), null);247 const navigator = createIndexNavigator(layout, {248 startNodeId: 'removed:proof',249 cursorByWorld: { removed: 'removed:proof' },250 });251 assert.equal(navigator.current.node.id, 'core:one');252 assert.deepEqual(navigator.cursorSnapshot(), {});253});254255test('catalog pack metadata becomes world metadata and completion is scoped', () => {256 const catalogLike = {257 getLevels() { return [level('core', 'one'), level('tidewater', 'one')]; },258 getPacks() {259 return [260 { id: 'core', origin: 'bundled', manifest: { name: 'Miau Core', author: 'team', world: { glyph: '🐈', tagline: 'Core proofs', accent: 'you' } } },261 { id: 'tidewater', origin: 'bundled', manifest: { name: 'Tidewater', world: { glyph: '🌊', accent: 'sink' } } },262 ];263 },264 };265 const layout = createIndexLayout(catalogLike);266 assert.deepEqual(layout.worlds.map(world => [world.id, world.number, world.glyph, world.tagline]), [267 ['core', 1, '🐈', 'Core proofs'], ['tidewater', 2, '🌊', null],268 ]);269 const decorated = decorateIndex(layout, { isSolved(packId) { return packId === 'tidewater'; } });270 assert.deepEqual(decorated.worlds.map(world => world.completedCount), [0, 1]);271 assert.equal(decorated.getWorld('tidewater').completedCount, 1);272 assert.equal(decorated.getSheet('tidewater:sheet-1').nodes[0].solved, true);273 const detachedGetNode = decorated.getNode;274 assert.equal(detachedGetNode('tidewater:one').id, 'tidewater:one');275});276277test('world openers stay available while solved counts and locks remain isolated', () => {278 const layout = createIndexLayout([279 level('alpha', 'opener'), level('alpha', 'second'), level('alpha', 'third'),280 level('beta', 'opener'), level('beta', 'second'),281 ]);282 const storage = new Map();283 const fakeStorage = {284 get length() { return storage.size; },285 key(index) { return [...storage.keys()][index] ?? null; },286 getItem(key) { return storage.get(key) ?? null; },287 setItem(key, value) { storage.set(key, String(value)); },288 removeItem(key) { storage.delete(key); },289 };290 const progress = createProgressStore(fakeStorage);291292 progress.markSolved('alpha', 'opener');293 progress.markSolved('alpha', 'second');294 let indexed = decorateIndex(layout, progress);295 assert.deepEqual(indexed.worlds.map(world => world.completedCount), [2, 0]);296 assert.deepEqual(indexed.nodes.map(node => [node.id, node.state]), [297 ['alpha:opener', 'solved'], ['alpha:second', 'solved'], ['alpha:third', 'available'],298 ['beta:opener', 'available'], ['beta:second', 'locked'],299 ]);300301 // Rebuilding INDEX after changing alpha progress must leave beta unchanged.302 progress.clear('alpha', 'second');303 indexed = decorateIndex(layout, progress);304 assert.deepEqual(indexed.worlds.map(world => world.completedCount), [1, 0]);305 assert.equal(indexed.getNode('alpha:third').state, 'locked');306 assert.equal(indexed.getNode('beta:opener').state, 'available');307 assert.equal(indexed.getNode('beta:second').state, 'locked');308309 progress.markSolved('beta', 'opener');310 indexed = decorateIndex(layout, progress);311 assert.deepEqual(indexed.worlds.map(world => world.completedCount), [1, 1]);312 assert.equal(indexed.getNode('alpha:third').state, 'locked');313 assert.equal(indexed.getNode('beta:second').state, 'available');314});