1import test from 'node:test';2import assert from 'node:assert/strict';3import { createGiftScene, giftScene } from '../src/scenes/gift.js';45const pack = {6 manifest: { id: 'word-garden', name: 'Word Garden', author: 'A. Designer' },7 palette: { MIAU: '🐈' },8 levels: [{ id: 'intro', title: 'Read the board', width: 3, height: 2, objects: [] }],9};1011test('gift scene exposes preview metadata and explicit keep/later actions', () => {12 const actions = [];13 const scene = createGiftScene({ pack, onAction: action => actions.push(action) });14 assert.equal(scene.open, true);15 assert.equal(scene.mode, 'prompt');16 assert.equal(scene.pack.manifest.name, 'Word Garden');17 assert.equal(scene.selected, 'keep');18 assert.equal(scene.input('confirm').action, 'keep');19 assert.deepEqual(actions[0].install, {});20 assert.equal(scene.open, false);21 assert.equal(giftScene.id, 'gift');22});2324test('collision requires an explicit replace or copy choice', () => {25 const actions = [];26 const scene = createGiftScene({ pack, collision: true, onAction: action => actions.push(action) });27 assert.equal(scene.input('confirm').action, 'collision');28 assert.equal(scene.mode, 'collision');29 assert.equal(scene.selected, 'replace');30 assert.deepEqual(scene.input('confirm').install, { replace: true });3132 scene.show(pack, { collision: true });33 scene.input('confirm');34 scene.input('down');35 assert.equal(scene.selected, 'copy');36 assert.deepEqual(scene.input('confirm').install, { keepBoth: true });37 assert.deepEqual(actions.map(action => action.action), ['replace', 'copy']);38});3940test('collision metadata carries the installed pack name for the duplicate card', () => {41 const scene = createGiftScene({ pack, collision: { id: 'word-garden', name: 'Word Garden' } });42 assert.deepEqual(scene.collision, { id: 'word-garden', name: 'Word Garden' });43 assert.equal(scene.input('confirm').action, 'collision');44 assert.equal(scene.mode, 'collision');45});4647test('first-level preview derives active rules through the engine', () => {48 const labels = [];49 const scene = createGiftScene({ pack: { ...pack, levels: [{ id: 'intro', title: 'Read the proof', width: 3, height: 2, objects: [50 { id: 'miau-word', kind: 'word', text: 'MIAU', x: 0, y: 0 },51 { id: 'is-word', kind: 'word', text: 'IS', x: 1, y: 0 },52 { id: 'you-word', kind: 'word', text: 'YOU', x: 2, y: 0 },53 { id: 'miau', kind: 'object', noun: 'MIAU', x: 0, y: 1 },54 ] }] } });55 const ctx = {56 canvas: { width: 800, height: 600 }, save() {}, restore() {}, beginPath() {}, roundRect() {}, rect() {}, fill() {}, stroke() {}, arc() {}, fillRect() {}, moveTo() {}, lineTo() {}, closePath() {}, clip() {}, ellipse() {},57 fillText(value) { labels.push(String(value)); }, measureText(value) { return { width: String(value).length * 8 }; },58 };59 scene.draw(ctx, 0, { width: 800, height: 600 });60 assert.equal(labels.includes('NOTHING'), false);61});6263test('escape dismisses without installing', () => {64 const actions = [];65 const scene = createGiftScene({ pack, onAction: action => actions.push(action) });66 assert.equal(scene.input('escape').action, 'later');67 assert.equal(scene.open, false);68 assert.equal(actions.length, 1);69 assert.equal(actions[0].install, null);70});7172test('gift preview and actions stay in separate regions on desktop', () => {73 const scene = createGiftScene({ pack: { ...pack, levels: [{ ...pack.levels[0], width: 12, height: 9 }] } });74 const ctx = {75 canvas: { width: 800, height: 600 }, save() {}, restore() {}, beginPath() {}, roundRect() {}, rect() {}, fill() {}, stroke() {}, arc() {}, fillRect() {}, moveTo() {}, lineTo() {}, closePath() {}, clip() {}, ellipse() {},76 fillText() {}, measureText(value) { return { width: String(value).length * 8 }; },77 };78 const layout = scene.draw(ctx, 0, { width: 800, height: 600 });79 assert.ok(layout.preview.x + layout.preview.width + 24 <= Math.min(...layout.actions.map(action => action.x)));80});8182test('collision gift stacks preview, three actions, and footer safely on narrow mobile', () => {83 const scene = createGiftScene({ pack: { ...pack, levels: [{ ...pack.levels[0], width: 12, height: 9 }] }, collision: { id: 'old', name: 'Old Pack' } });84 scene.input('confirm');85 const ctx = {86 canvas: { width: 390, height: 844 }, save() {}, restore() {}, beginPath() {}, roundRect() {}, rect() {}, fill() {}, stroke() {}, arc() {}, fillRect() {}, moveTo() {}, lineTo() {}, closePath() {}, clip() {}, ellipse() {},87 fillText() {}, measureText(value) { return { width: String(value).length * 8 }; },88 };89 const layout = scene.draw(ctx, 0, { width: 390, height: 844 });90 assert.equal(layout.narrow, true);91 assert.equal(layout.actions.length, 3);92 assert.equal(layout.preview.width, 318);93 assert.ok(layout.preview.y + layout.preview.height + 10 <= layout.actions[0].y);94 assert.ok(layout.actions.at(-1).y + layout.actions.at(-1).height + 20 <= layout.hintY);95});9697test('long Gift copy is ellipsized and the narrow footer stays inside the card', () => {98 const longPack = {99 manifest: { id: 'long', name: 'An Extremely Long Shared Pack Name That Must Fit', author: 'A Very Long Author Name That Must Fit', version: '1.0.0' },100 levels: [{ id: 'intro', title: 'An Extremely Long First Proof Title That Must Fit', width: 12, height: 9, objects: [] }],101 };102 const labels = [];103 const ctx = {104 canvas: { width: 320, height: 844 }, save() {}, restore() {}, beginPath() {}, roundRect() {}, rect() {}, fill() {}, stroke() {}, arc() {}, fillRect() {}, moveTo() {}, lineTo() {}, closePath() {}, clip() {}, ellipse() {},105 fillText(value) { labels.push(String(value)); }, measureText(value) { return { width: String(value).length * 8 }; },106 };107 const layout = createGiftScene({ pack: longPack }).draw(ctx, 0, { width: 320, height: 844 });108 assert.equal(layout.narrow, true);109 assert.ok(labels.some(value => value.endsWith('…')));110 assert.ok(layout.hints.x >= layout.x + 8);111 assert.ok(layout.hints.x + layout.hints.width <= layout.x + layout.width - 8);112});113114test('Gift keeps warning notes nonblocking, expands player details, and copies technical context', () => {115 let copied = '';116 const warnings = [{117 word: 'SHUT', levelId: 'intro', levelTitle: 'Read the board',118 sentence: 'DOOR IS SHUT', message: 'SHUT has no behaviour in this proof.', detail: 'levels[0].objects[3]'119 }];120 const scene = createGiftScene({ pack, warnings, copyToClipboard: value => { copied = value; } });121 assert.deepEqual(scene.warnings, warnings);122 assert.equal(scene.detailsOpen, false);123 assert.deepEqual(scene.input('i'), { ok: true, action: 'details', open: true });124 assert.equal(scene.detailsOpen, true);125 assert.match(scene.accessibilityText(), /Read the board: SHUT has no behaviour/);126 assert.deepEqual(scene.input('c'), { ok: true, action: 'copy', text: 'SHUT | intro | DOOR IS SHUT | levels[0].objects[3]', copied: undefined });127 assert.equal(copied, 'SHUT | intro | DOOR IS SHUT | levels[0].objects[3]');128 assert.equal(scene.input('enter').action, 'keep');129});130131test('Gift warning details render an amber NOTE and plain language proof line', () => {132 const labels = [];133 const fills = [];134 const scene = createGiftScene({ pack, warnings: [{135 word: 'SHUT', levelId: 'intro', levelTitle: 'Read the board',136 sentence: 'DOOR IS SHUT', message: 'SHUT has no behaviour in this proof.'137 }] });138 const ctx = {139 canvas: { width: 800, height: 600 }, save() {}, restore() {}, beginPath() {}, roundRect() {}, rect() {}, fill() { fills.push(this.fillStyle); }, stroke() {}, arc() {}, fillRect() {}, moveTo() {}, lineTo() {}, closePath() {}, clip() {}, ellipse() {},140 fillText(value) { labels.push(String(value)); }, measureText(value) { return { width: String(value).length * 8 }; },141 };142 scene.input('details');143 scene.draw(ctx, 0, { width: 800, height: 600 });144 assert.ok(labels.some(value => value.includes('NOTE')));145 assert.ok(labels.some(value => value.includes('1 word may not work as rules')));146 assert.ok(fills.includes('#FFB020'));147 assert.ok(labels.some(value => value.includes("SHUT isn't a rule word here")));148});149150test('Gift note line and COPY text are tappable on mobile without keycaps', () => {151 let copied = '';152 const warnings = Array.from({ length: 5 }, (_, index) => ({153 word: `WORD${index}`, levelId: 'intro', levelTitle: 'Read the board',154 sentence: `DOOR IS WORD${index}`,155 message: `WORD${index} has no behaviour in this proof and will not change the door.`,156 detail: `levels[0].objects[${index}]`,157 }));158 const scene = createGiftScene({ pack, warnings, copyToClipboard: value => { copied = value; } });159 let layout = scene.draw({160 canvas: { width: 390, height: 844 }, save() {}, restore() {}, beginPath() {}, roundRect() {}, rect() {}, fill() {}, stroke() {}, arc() {}, fillRect() {}, moveTo() {}, lineTo() {}, closePath() {}, clip() {}, ellipse() {},161 fillText() {}, measureText(value) { return { width: String(value).length * 5 }; },162 }, 0, { width: 390, height: 844 });163 assert.equal(layout.hintActions.length, 0);164 assert.equal(layout.noteActions.length, 1);165 const noteLine = layout.noteActions[0];166 assert.ok(scene.hit(noteLine.x + noteLine.width / 2, noteLine.y + noteLine.height / 2).open);167 layout = scene.draw({168 canvas: { width: 390, height: 844 }, save() {}, restore() {}, beginPath() {}, roundRect() {}, rect() {}, fill() {}, stroke() {}, arc() {}, fillRect() {}, moveTo() {}, lineTo() {}, closePath() {}, clip() {}, ellipse() {},169 fillText() {}, measureText(value) { return { width: String(value).length * 5 }; },170 }, 0, { width: 390, height: 844 });171 assert.ok(layout.preview.y > layout.y);172 assert.ok(layout.preview.y + layout.preview.height <= layout.actions[0].y);173 assert.equal(layout.hintRows, 0);174 assert.ok(layout.noteActions.some(item => item.action === 'copy'));175 const copyButton = layout.noteActions.find(item => item.action === 'copy');176 assert.ok(scene.hit(copyButton.x + copyButton.width / 2, copyButton.y + copyButton.height / 2).ok);177 assert.match(copied, /WORD0/);178});