1import test from 'node:test';2import assert from 'node:assert/strict';3import { createIndexLayout, createIndexNavigator, decorateIndex } from '../src/scenes/map.js';4import { drawIndex } from '../src/render/index.js';56function context(width = 640, height = 420) {7 const calls = [];8 return {9 canvas: { width, height }, calls,10 save() { calls.push(['save']); }, restore() { calls.push(['restore']); },11 translate(...args) { calls.push(['translate', ...args]); }, scale(...args) { calls.push(['scale', ...args]); },12 beginPath() { calls.push(['beginPath']); }, closePath() { calls.push(['closePath']); },13 moveTo(...args) { calls.push(['moveTo', ...args]); }, lineTo(...args) { calls.push(['lineTo', ...args]); },14 rect(...args) { calls.push(['rect', ...args]); }, roundRect(...args) { calls.push(['roundRect', ...args]); },15 fill() { calls.push(['fill']); }, stroke() { calls.push(['stroke']); }, fillRect(...args) { calls.push(['fillRect', ...args]); },16 arc(...args) { calls.push(['arc', ...args]); }, fillText(...args) { calls.push(['fillText', ...args, this.font]); },17 measureText(text) { return { width: String(text).length * 8 }; },18 };19}2021const level = (packId, localId, title = localId) => ({22 id: `${packId}:${localId}`, localId, packId, packName: packId === 'core' ? 'Miau Core' : 'Word Lab', title,23 width: 1, height: 1, objects: [],24});2526test('drawIndex returns adaptive non-overlapping node geometry and renders the path', () => {27 const layout = decorateIndex(createIndexLayout(Array.from({ length: 6 }, (_, i) => level('core', `p${i + 1}`))), null);28 const navigator = createIndexNavigator(layout);29 const ctx = context(800, 420);30 const geometry = drawIndex(ctx, layout, navigator);31 assert.equal(geometry.sheetId, 'core:sheet-1');32 assert.equal(geometry.nodes.length, 6);33 assert.ok(ctx.calls.some(call => call[0] === 'lineTo'));34 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '🐈'));35 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'walk the path, pick a proof'));36 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '★ 0 / 6'));37 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'ENTER'));38 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'play'));39 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'ESC'));40 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'menu'));41 const footerLabels = ctx.calls.filter(call => call[0] === 'fillText' && ['ENTER', 'play', 'ESC', 'menu'].includes(call[1]));42 assert.deepEqual(footerLabels.map(call => call[1]), ['ENTER', 'play', 'ESC', 'menu']);43 assert.ok(footerLabels[0][2] < footerLabels[1][2]);44 assert.ok(footerLabels[1][2] < footerLabels[2][2]);45 assert.ok(footerLabels[2][2] < footerLabels[3][2]);46 assert.equal(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'MIAU CORE / SHEET 1'), false);47 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'INDEX | WORLD 01'));48 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '◀'));49 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '▶'));50 assert.equal(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'LOCKED'), false);51 for (let i = 0; i < geometry.nodes.length; i++) for (let j = i + 1; j < geometry.nodes.length; j++) {52 const a = geometry.nodes[i]; const b = geometry.nodes[j];53 const overlap = a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;54 assert.equal(overlap, false, `${a.id} overlaps ${b.id}`);55 }56});5758test('built-in INDEX header keeps only the solved count on the right', () => {59 const layout = decorateIndex(createIndexLayout(Array.from({ length: 9 }, (_, i) => level('core', `p${i + 1}`))), null);60 const navigator = createIndexNavigator(layout);61 const ctx = context(640, 420);62 drawIndex(ctx, layout, navigator);63 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '★ 0 / 9'));64 assert.equal(ctx.calls.some(call => call[0] === 'fillText' && String(call[1]).includes('SHEET')), false);65});6667test('drawIndex renders the active world header and strip on mobile', () => {68 const base = decorateIndex(createIndexLayout(Array.from({ length: 9 }, (_, i) => level('core', `p${i + 1}`))), null);69 const worlds = [70 { ...base.worlds[0], number: 1, name: 'Miau Core', levelCount: 9, completedCount: 2 },71 { ...base.worlds[0], id: 'tidewater', number: 2, name: 'Tidewater', glyph: '🌊', accent: 'sink', levelCount: 9, completedCount: 3 },72 { ...base.worlds[0], id: 'clockwork', number: 3, name: 'Clockwork', levelCount: 9, completedCount: 0 },73 ];74 const layout = { ...base, worlds };75 const navigator = createIndexNavigator(layout);76 const ctx = context(390, 844);77 const geometry = drawIndex(ctx, layout, navigator, { availableWidth: 390, availableHeight: 844 });78 const labels = ctx.calls.filter(call => call[0] === 'fillText').map(call => call[1]);79 assert.ok(labels.includes('INDEX | WORLD 01'));80 assert.ok(labels.includes('🌊 Tidewater') === false, 'inactive world names stay out of the active header');81 assert.ok(labels.includes('★ 2 / 9'));82 assert.ok(labels.includes('◀'));83 assert.ok(labels.includes('▶'));84 assert.equal(labels.includes('ENTER'), false);85 assert.equal(labels.includes('SPACE'), false);86 assert.ok(labels.includes('tap the cat to play'));87 assert.ok(labels.includes('☰'));88 assert.equal(geometry.world.number, 1);89 assert.equal(geometry.worldStrip.dots.length, 3);90 assert.ok(geometry.worldStrip.worldAccent === '#F2EEE6');91});9293test('drawIndex uses world accent for the active cat frame and path', () => {94 const base = decorateIndex(createIndexLayout([level('core', 'one')]), null);95 const worlds = [{ ...base.worlds[0], name: 'Tidewater', number: 1, accent: 'sink', completedCount: 0, levelCount: 1 }];96 const layout = { ...base, worlds };97 const navigator = createIndexNavigator(layout);98 const geometry = drawIndex(context(640, 420), layout, navigator);99 assert.equal(geometry.world.accent, 'sink');100 assert.equal(geometry.worldStrip.worldAccent, '#3AB8FF');101});102103test('drawIndex signals solved and locked proofs while the cat replaces the current tile', () => {104 const layout = decorateIndex(createIndexLayout([level('core', 'one', 'Read'), level('core', 'two', 'Break'), level('core', 'three', 'Push')]), {105 isSolved(packId, levelId) { return packId === 'core' && levelId === 'one'; },106 });107 const navigator = createIndexNavigator(layout, { startNodeId: 'core:three' });108 const ctx = context(600, 400);109 const geometry = drawIndex(ctx, layout, navigator, { showTitles: true });110 assert.equal(geometry.nodes.find(node => node.id === 'core:three').active, true);111 assert.equal(geometry.nodes.find(node => node.id === 'core:one').solved, true);112 assert.equal(geometry.nodes.find(node => node.id === 'core:three').locked, true);113 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'Locked. Prove 02 first.'));114 assert.equal(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'Read'), false);115 assert.equal(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'Push'), false);116 assert.equal(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '02 Break'), false);117 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '★ 1 / 3'));118 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '🐈'));119});120121test('drawIndex centers a nine-proof sheet in the two rows it uses', () => {122 const layout = decorateIndex(createIndexLayout(Array.from({ length: 9 }, (_, i) => level('core', `p${i + 1}`))), null);123 const navigator = createIndexNavigator(layout);124 const ctx = context(640, 420);125 const geometry = drawIndex(ctx, layout, navigator);126 assert.equal(geometry.rows, 2);127 assert.equal(geometry.height, geometry.cell * 2);128 assert.ok(geometry.y > 96);129});130131test('drawIndex accepts a transient locked attempt from navigator.move', () => {132 const layout = decorateIndex(createIndexLayout([level('core', 'one'), level('core', 'two'), level('core', 'three')]), {133 isSolved(packId, levelId) { return packId === 'core' && levelId === 'one'; },134 });135 const navigator = createIndexNavigator(layout, { startNodeId: 'core:two' });136 const attempt = navigator.move('next');137 assert.equal(attempt.locked, true);138 const ctx = context(600, 400);139 drawIndex(ctx, layout, navigator, { lockedNode: attempt.node });140 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'Locked. Prove 02 first.'));141});142143test('drawIndex can hide the banner and footer for pause overlays', () => {144 const layout = decorateIndex(createIndexLayout([level('core', 'one')]), null);145 const navigator = createIndexNavigator(layout);146 const ctx = context(600, 400);147 drawIndex(ctx, layout, navigator, { hideFooter: true });148 assert.equal(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'ENTER'), false);149 assert.equal(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '01 One'), false);150});151152test('drawIndex pops a solved node and emits stars, with a reduced motion cut', () => {153 const layout = decorateIndex(createIndexLayout([level('core', 'one', 'Read')]), {154 isSolved(packId, levelId) { return packId === 'core' && levelId === 'one'; },155 });156 const navigator = createIndexNavigator(layout);157 const animatedContext = context(600, 400);158 const animated = drawIndex(animatedContext, layout, navigator, { popNodeId: 'core:one', popProgress: 0.5 });159 const animatedNode = animated.nodes[0];160 assert.ok(animatedNode.popScale < 1);161 assert.equal(animatedNode.sparks, true);162 assert.ok(animatedContext.calls.filter(call => call[0] === 'fillText' && call[1] === '★').length >= 4);163164 const reducedContext = context(600, 400);165 const reduced = drawIndex(reducedContext, layout, navigator, { popNodeId: 'core:one', popProgress: 0, reducedMotion: true });166 assert.equal(reduced.nodes[0].popScale, 1);167 assert.equal(reduced.nodes[0].sparks, false);168 assert.equal(reducedContext.calls.some(call => call[0] === 'fillText' && call[1] === '★'), false);169});170171test('drawIndex keeps settled hit geometry while nodes reverse-scale on exit', () => {172 const layout = decorateIndex(createIndexLayout([level('core', 'one', 'Read')]), null);173 const navigator = createIndexNavigator(layout);174 const ctx = context();175 const settled = drawIndex(ctx, layout, navigator, { exitProgress: 0 });176 const exiting = drawIndex(ctx, layout, navigator, { exitProgress: 0.5 });177 assert.equal(exiting.nodes[0].x, settled.nodes[0].x);178 assert.equal(exiting.nodes[0].y, settled.nodes[0].y);179 assert.ok(exiting.nodes[0].popScale < settled.nodes[0].popScale);180});181182test('drawIndex mirrors the native-left cat only for right-facing cursor travel', () => {183 const layout = decorateIndex(createIndexLayout([level('core', 'one')]), null);184 const navigator = createIndexNavigator(layout);185 const leftContext = context();186 drawIndex(leftContext, layout, navigator, { catFacing: 'left' });187 assert.equal(leftContext.calls.some(call => call[0] === 'scale' && call[1] === -1 && call[2] === 1), false);188 const rightContext = context();189 drawIndex(rightContext, layout, navigator, { catFacing: 'right' });190 assert.ok(rightContext.calls.some(call => call[0] === 'scale' && call[1] === -1 && call[2] === 1));191});192193test('drawIndex returns an exact desktop Workshop footer hit rectangle', () => {194 const layout = decorateIndex(createIndexLayout([level('core', 'one')]), null);195 const navigator = createIndexNavigator(layout);196 for (const width of [1280]) {197 const geometry = drawIndex(context(width, 420), layout, navigator, { availableWidth: width, availableHeight: 420 });198 const hit = geometry.footerActions.workshop;199 assert.ok(hit, `workshop hit at ${width}px`);200 assert.ok(hit.x >= 0 && hit.x + hit.width <= width, `workshop footer stays within ${width}px`);201 assert.ok(hit.y >= 0 && hit.y + hit.height <= 420, `workshop footer y stays within ${width}px`);202 assert.ok(hit.height >= 44, `workshop footer has a pointer target at ${width}px`);203 }204});205206test('drawIndex keeps the mobile banner readable above its touch prompt', () => {207 const layout = decorateIndex(createIndexLayout([level('core', 'one', 'Break the rule')]), null);208 const navigator = createIndexNavigator(layout);209 const ctx = context(390, 420);210 drawIndex(ctx, layout, navigator, { availableWidth: 390, availableHeight: 420 });211 const banner = ctx.calls.find(call => call[0] === 'fillText' && call[1] === '01 Break the rule');212 const prompt = ctx.calls.find(call => call[0] === 'fillText' && call[1] === 'tap the cat to play');213 assert.ok(banner && prompt);214 assert.ok(Number.parseFloat(banner[4]) >= 16, `banner font should be at least 16px: ${banner[4]}`);215 assert.ok(banner[3] < prompt[3], 'banner should sit above the touch prompt');216});217218test('drawIndex uses the world tagline and exposes touch sized world arrows', () => {219 const base = decorateIndex(createIndexLayout([level('core', 'one')]), null);220 const worlds = [221 { ...base.worlds[0], name: 'Miau Core', tagline: 'one proof at a time', number: 1, levelCount: 1 },222 { ...base.worlds[0], id: 'other', name: 'Other', number: 2, levelCount: 1 },223 ];224 const layout = { ...base, worlds };225 const navigator = createIndexNavigator(layout);226 const geometry = drawIndex(context(390, 844), layout, navigator, { coarsePointer: true, now: 1000 });227 const labels = geometry.worldStrip;228 assert.ok(labels.arrows.previous.width >= 44);229 assert.ok(labels.arrows.next.width >= 44);230 assert.ok(labels.dots.every(dot => dot.width === 10));231 assert.ok(geometry.footerActions.menu.height >= 44);232 assert.equal(geometry.footerActions.workshop, undefined);233});234235test('drawIndex slides the previous and next world while preserving target hit geometry', () => {236 const levels = [level('core', 'one'), level('other', 'one')];237 const base = decorateIndex(createIndexLayout(levels), null);238 const layout = {239 ...base,240 worlds: base.worlds.map((world, index) => ({ ...world, number: index + 1, levelCount: 1 })),241 };242 const navigator = createIndexNavigator(layout);243 assert.equal(navigator.moveWorld(1).moved, true);244 const settledContext = context(800, 420);245 const settled = drawIndex(settledContext, layout, navigator, { availableWidth: 800, availableHeight: 420 });246 const transitionContext = context(800, 420);247 const transition = drawIndex(transitionContext, layout, navigator, {248 availableWidth: 800, availableHeight: 420, now: 1140,249 worldTransition: { from: 'core', fromNodeId: 'core:one', to: 'other', nodeId: 'other:one', delta: 1, startedAt: 1000, duration: 280 },250 });251 assert.equal(transition.transition.progress, 0.5);252 assert.deepEqual(transition.nodes.map(node => [node.id, node.x, node.y]), settled.nodes.map(node => [node.id, node.x, node.y]));253 assert.ok(transition.worldStrip?.arrows?.next);254 assert.ok(transition.footerActions?.workshop);255 assert.equal(transitionContext.calls.filter(call => call[0] === 'fillRect' && call[1] === 0 && call[2] === 0 && call[3] === 800 && call[4] === 420).length, 1);256 assert.ok(transitionContext.calls.some(call => call[0] === 'translate' && call[1] === -400 && call[2] === 0));257 assert.ok(transitionContext.calls.some(call => call[0] === 'translate' && call[1] === 400 && call[2] === 0));258 assert.equal(transitionContext.calls.filter(call => call[0] === 'fillText' && call[1] === 'INDEX | WORLD 02').length, 1);259 assert.equal(transitionContext.calls.filter(call => call[0] === 'fillText' && call[1] === '★ 0 / 1').length, 1);260});261262test('locked mobile INDEX still exposes the menu touch target', () => {263 const layout = decorateIndex(createIndexLayout([level('core', 'one'), level('core', 'two')]), null);264 const navigator = createIndexNavigator(layout);265 const lockedNode = layout.nodes[1];266 const ctx = context(390, 844);267 const geometry = drawIndex(ctx, layout, navigator, { lockedNode, availableWidth: 390, availableHeight: 844 });268 assert.ok(geometry.footerActions.menu.height >= 44);269 assert.equal(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'tap the cat to play'), false);270});271272test('drawIndex keeps a long authored banner complete across two lines', () => {273 const title = 'A deliberately long authored proof title for mobile review';274 const layout = decorateIndex(createIndexLayout([level('core', 'long', title)]), null);275 const navigator = createIndexNavigator(layout);276 const ctx = context(390, 420);277 const geometry = drawIndex(ctx, layout, navigator, { availableWidth: 390, availableHeight: 420 });278 assert.equal(geometry.banner.lines.join(' '), `01 ${title}`);279 assert.equal(geometry.banner.lines.some(line => line.includes('…')), false);280 assert.equal(geometry.banner.lines.length, 2);281 assert.ok(geometry.banner.size >= 16);282});