Files
HexTrails/tests/tutorial-app.test.js

232 lines
14 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { Game } from '../src/core.js';
import { campaignVersion } from '../src/campaign-versions.js';
import { center, layout } from '../src/geometry.js';
import { CanvasPainter } from '../src/renderer.js';
import { tutorialProgressKey } from '../src/tutorial-progress.js';
const campaign = campaignVersion('v0.5');
const officialKey = 'hex-trails-campaign-v0.5';
const teachingKey = tutorialProgressKey('v0.5');
let importId = 0;
// Exercise app.js pointer handlers, result buttons and persistence with a virtual clock.
async function withApp({search = '?campaign=v0.5', entries = {}}, run) {
const keys = ['document', 'window', 'location', 'performance', 'localStorage', 'devicePixelRatio', 'matchMedia', 'Image', 'ResizeObserver', 'MutationObserver', 'requestAnimationFrame', 'cancelAnimationFrame', 'setTimeout', 'clearTimeout'];
const saved = new Map(keys.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
const elements = new Map(), frames = new Map(), timers = new Map(), slots = new Map(Object.entries(entries));
const actions = [], writes = [];
let now = 0, serial = 0;
const rect = {left: 0, top: 0, width: 700, height: 700};
const context = new Proxy({}, {get: (object, key) => object[key] ?? (() => {}), set: (object, key, value) => (object[key] = value, true)});
function element() {
const listeners = new Map(), classes = new Set();
return {
style: {setProperty() {}}, dataset: {}, textContent: '', innerHTML: '', hidden: false, open: false, disabled: false,
classList: {
add(...names) { for (const name of names) classes.add(name); },
remove(...names) { for (const name of names) classes.delete(name); },
contains: name => classes.has(name),
toggle(name, force) { const added = force ?? !classes.has(name); if (added) classes.add(name); else classes.delete(name); return added; },
},
addEventListener(name, callback) { if (!listeners.has(name)) listeners.set(name, []); listeners.get(name).push(callback); },
emit(name, event = {}) { for (const callback of listeners.get(name) ?? []) callback(event); },
getBoundingClientRect: () => rect, getContext: () => context, setPointerCapture() {},
querySelector: () => get('dialog-close'), querySelectorAll: () => [element(), element(), element()],
insertBefore() {}, setAttribute() {}, focus() {},
showModal() { this.open = true; }, close() { this.open = false; this.emit('close'); },
click() { if (!this.disabled) this.onclick?.(); },
};
}
function get(id) { if (!elements.has(id)) elements.set(id, element()); return elements.get(id); }
get('board').parentElement = element();
const storage = {getItem: key => slots.get(key) ?? null, setItem(key, value) { slots.set(key, value); writes.push(key); }};
const advance = ms => {
now += ms;
const pending = [...frames.values()]; frames.clear();
for (const callback of pending) callback(now);
for (const [id, timer] of [...timers]) if (timer.at <= now) { timers.delete(id); timer.callback(); }
};
const clickTile = (level, id) => {
const tile = level.tiles.find(tile => tile.id === id);
assert.ok(tile, `fixture contains ${id}`);
const point = center(tile, layout(level.cells, rect.width, rect.height));
const event = {clientX: point.x, clientY: point.y, pointerId: 1};
get('board').emit('pointerdown', event); get('board').emit('pointerup', event);
};
const originalTap = Game.prototype.tap, originalRotate = Game.prototype.rotate, originalSprite = CanvasPainter.prototype.sprite;
const replacements = {
document: {getElementById: get, createElement: element, querySelectorAll: () => [], addEventListener() {}, body: element()},
window: {localStorage: storage, scrollTo() {}}, localStorage: storage, location: {search}, performance: {now: () => now}, devicePixelRatio: 1,
matchMedia: () => ({matches: false}), Image: class {}, ResizeObserver: class {observe() {}}, MutationObserver: class {observe() {}},
requestAnimationFrame: callback => { frames.set(++serial, callback); return serial; }, cancelAnimationFrame: id => frames.delete(id),
setTimeout: (callback, delay) => { timers.set(++serial, {callback, at: now + delay}); return serial; }, clearTimeout: id => timers.delete(id),
};
try {
for (const [key, value] of Object.entries(replacements)) Object.defineProperty(globalThis, key, {configurable: true, writable: true, value});
Game.prototype.tap = function(id) {
const result = originalTap.call(this, id);
actions.push({type: 'tap', id, status: result.status, at: now, moves: this.moves});
return result;
};
Game.prototype.rotate = function(direction) {
const before = JSON.stringify(this.tiles), result = originalRotate.call(this, direction);
actions.push({type: 'rotate', direction, status: result.status, before, after: JSON.stringify(this.tiles), moves: this.moves});
return result;
};
CanvasPainter.prototype.sprite = () => true;
await import(`../src/app.js?tutorial-app-test=${++importId}`);
await run({get, advance, clickTile, actions, writes, slots, read: key => slots.has(key) ? JSON.parse(slots.get(key)) : null});
} finally {
Game.prototype.tap = originalTap; Game.prototype.rotate = originalRotate; CanvasPainter.prototype.sprite = originalSprite;
for (const [key, descriptor] of saved) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else delete globalThis[key]; }
}
}
test('three actual tutorial pointer flows teach departure, clearing a blocker and rotation before LEVEL 01', async () => {
await withApp({}, ({get, advance, clickTile, actions, writes, read}) => {
const [first, second, third] = campaign.tutorials;
const remaining = () => Number(get('moves').textContent);
assert.equal(get('level-eyebrow').textContent, '入门 1 / 3');
assert.equal(remaining(), 1);
assert.equal(get('rotate-left').disabled, true); assert.equal(get('rotate-right').disabled, true);
assert.equal(get('board-toast').textContent, first.lesson);
assert.equal(get('board-toast').classList.contains('visible'), true);
advance(6500);
assert.equal(get('board-toast').classList.contains('visible'), false, 'teaching uses the existing timed lesson notice');
assert.deepEqual(writes, [], 'loading the introduction alone writes no progress');
clickTile(first, 'traveler');
assert.equal(actions.at(-1).status, 'exit'); assert.equal(remaining(), 0);
assert.equal(get('result').open, false, 'completion waits for the visible departure');
advance(30000);
assert.equal(get('result').open, true);
assert.deepEqual(read(teachingKey), {current: 1, completed: [0]});
assert.equal(read(officialKey), null);
get('next-level').click();
assert.equal(get('level-eyebrow').textContent, '入门 2 / 3'); assert.equal(remaining(), 2);
clickTile(second, 'traveler');
assert.equal(actions.at(-1).status, 'blocked'); assert.equal(actions.at(-1).moves, 2);
assert.equal(remaining(), 2, 'trying the blocked tile costs no departure or piece');
advance(1000);
clickTile(second, 'gate'); clickTile(second, 'traveler');
assert.deepEqual(actions.slice(-2).map(action => [action.id, action.status]), [['gate', 'exit'], ['traveler', 'exit']]);
assert.equal(actions.at(-1).at, actions.at(-2).at, 'the newly opened tile departs without waiting for the gate animation');
assert.equal(remaining(), 0); assert.equal(get('result').open, false);
advance(30000);
assert.deepEqual(read(teachingKey), {current: 2, completed: [0, 1]});
assert.equal(read(officialKey), null);
get('next-level').click();
assert.equal(get('level-eyebrow').textContent, '入门 3 / 3'); assert.equal(remaining(), 2);
assert.equal(get('rotate-right').disabled, false);
get('rotate-right').click();
const rotation = actions.at(-1);
assert.equal(rotation.status, 'rotated'); assert.equal(rotation.before, rotation.after, 'only the tracks turn; arrows and pieces stay put');
assert.equal(rotation.moves, 2, 'rotation is free');
advance(5000);
assert.match(get('board-toast').textContent, /出口打开了/);
assert.equal(get('board-toast').classList.contains('visible'), false, 'rotation feedback keeps the ordinary toast timeout');
clickTile(third, 'north'); clickTile(third, 'south');
assert.deepEqual(actions.slice(-2).map(action => action.status), ['exit', 'exit']);
assert.equal(remaining(), 0); advance(30000);
assert.equal(get('result').open, true);
assert.deepEqual(read(teachingKey), {current: 2, completed: [0, 1, 2]});
assert.equal(read(officialKey), null, 'all three lessons are separate from formal completion');
assert.ok(writes.every(key => key === teachingKey));
assert.match(get('result').innerHTML, /开始第 1 关/);
get('next-level').click();
assert.equal(get('level-eyebrow').textContent, 'LEVEL 01');
assert.equal(remaining(), campaign.levels[0].tiles.length);
assert.deepEqual(read(officialKey), {current: 0, completed: []});
assert.equal(get('progress-label').textContent, '0 / 1000');
assert.equal(get('result').open, false);
});
});
test('replaying lesson 3 leaves an existing level 20 save intact and never completes level 20', async () => {
const previous = {current: 19, completed: [0, 1]}, previousRaw = JSON.stringify(previous);
await withApp({search: '?campaign=v0.5&tutorial=3', entries: {[officialKey]: previousRaw}}, ({get, advance, clickTile, slots, read}) => {
const third = campaign.tutorials[2];
assert.equal(get('level-eyebrow').textContent, '入门 3 / 3');
assert.equal(slots.get(officialKey), previousRaw);
for (let replay = 0; replay < 2; replay += 1) {
get('rotate-left').click(); advance(1000);
clickTile(third, 'north'); clickTile(third, 'south'); advance(30000);
assert.equal(get('result').open, true);
assert.equal(slots.get(officialKey), previousRaw, 'finishing or replaying a lesson does not save the current official level as completed');
assert.equal(get('progress-label').textContent, '2 / 1000');
if (!replay) {
get('replay-level').click();
assert.equal(get('level-eyebrow').textContent, '入门 3 / 3');
assert.equal(Number(get('moves').textContent), 2);
assert.equal(slots.get(officialKey), previousRaw);
}
}
assert.deepEqual(read(teachingKey), {current: 2, completed: [2]});
get('next-level').click();
assert.equal(get('level-eyebrow').textContent, 'LEVEL 01');
assert.deepEqual(read(officialKey), {current: 0, completed: [0, 1]});
assert.equal(read(officialKey).completed.includes(19), false);
});
});
test('the map can reopen a tutorial from an explicit official level without changing official progress', async () => {
const previous = {current: 19, completed: [0, 1]}, previousRaw = JSON.stringify(previous);
await withApp({search: '?campaign=v0.5&level=20', entries: {[officialKey]: previousRaw}}, ({get, advance, clickTile, slots, read}) => {
assert.equal(get('level-eyebrow').textContent, 'LEVEL 20');
get('map-button').click();
assert.equal(get('info-dialog').open, true);
for (let index = 0; index < 3; index += 1) assert.ok(get('dialog-content').innerHTML.includes(`data-tutorial-pick="${index}"`));
get('dialog-content').emit('click', {target: {closest: selector => selector === '[data-tutorial-pick]' ? {dataset: {tutorialPick: '1'}} : null}});
assert.equal(get('info-dialog').open, false);
assert.equal(get('level-eyebrow').textContent, '入门 2 / 3');
assert.equal(slots.get(officialKey), previousRaw);
clickTile(campaign.tutorials[1], 'gate'); clickTile(campaign.tutorials[1], 'traveler'); advance(30000);
assert.equal(get('result').open, true);
assert.deepEqual(read(teachingKey), {current: 2, completed: [1]});
assert.equal(slots.get(officialKey), previousRaw);
});
});
test('switching editions into the introduction remembers the edition across reloads', async () => {
let reloadedEntries;
await withApp({search: '', entries: {
'hex-trails-campaign-version': 'v0.3',
'hex-trails-campaign-v0.3': JSON.stringify({current: 6, completed: [0]}),
}}, ({get, slots, read}) => {
assert.equal(get('level-eyebrow').textContent, 'LEVEL 07');
get('map-button').click();
get('dialog-content').emit('change', {target: {id: 'campaign-version', value: 'v0.5'}});
assert.equal(get('level-eyebrow').textContent, '入门 1 / 3');
assert.equal(slots.get('hex-trails-campaign-version'), 'v0.5');
assert.equal(read(officialKey), null, 'selecting the edition does not create official progress');
assert.deepEqual(read('hex-trails-campaign-v0.3'), {current: 6, completed: [0]});
reloadedEntries = Object.fromEntries(slots);
});
await withApp({search: '', entries: reloadedEntries}, ({get, slots}) => {
assert.equal(get('level-eyebrow').textContent, '入门 1 / 3');
assert.equal(slots.get('hex-trails-campaign-version'), 'v0.5');
});
});
test('entering the workshop from an introductory lesson uses a supported rotatable campaign board', async () => {
await withApp({search: '?campaign=v0.5&tutorial=1'}, ({get, actions}) => {
assert.equal(get('level-eyebrow').textContent, '入门 1 / 3');
assert.equal(get('rotate-right').disabled, true);
get('edit-tab').click();
assert.equal(get('level-eyebrow').textContent, 'YOUR CREATION');
const expectedRadius = Math.max(...campaign.levels[0].cells.map(cell => Math.max(Math.abs(cell.q), Math.abs(cell.r), Math.abs(cell.q + cell.r))));
assert.equal(get('editor-radius').value, String(expectedRadius));
assert.ok(expectedRadius >= 2 && expectedRadius <= 6, 'the editor can represent the selected board size');
assert.equal(Number(get('editor-moves').value), campaign.levels[0].moveLimit);
get('trial-button').click();
assert.equal(get('level-eyebrow').textContent, 'PLAYTEST');
assert.equal(Number(get('moves').textContent), campaign.levels[0].moveLimit);
assert.equal(get('rotate-right').disabled, false, 'the tutorial rotation lock is not inherited by workshop playtests');
get('rotate-right').click();
assert.equal(actions.at(-1).status, 'rotated');
});
});