Files
HexTrails/tests/tutorial-progress.test.js
T

120 lines
5.7 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { readTutorialProgress, tutorialLaunch, tutorialProgressKey, writeTutorialProgress } from '../src/tutorial-progress.js';
function storage(entries = {}) {
const items = new Map(Object.entries(entries));
const writes = [];
return {
items,
writes,
getItem: key => items.get(key) ?? null,
setItem(key, value) { writes.push(key); items.set(key, value); },
};
}
const campaignId = 'v0.5';
const fresh = {current: 0, completed: []};
const key = tutorialProgressKey(campaignId);
test('new visitors start with tutorial 1 without writing any progress', () => {
const saved = storage();
const progress = readTutorialProgress(saved, campaignId, 3);
assert.deepEqual(progress, fresh);
assert.equal(tutorialLaunch('', 3, progress, fresh), 0);
assert.equal(tutorialLaunch('?campaign=v0.5', 3, progress, fresh), 0);
assert.deepEqual(saved.writes, []);
assert.equal(key, 'hex-trails-tutorial-v1-v0.5');
});
test('tutorial saves retain valid indices and discard malformed or duplicate entries', () => {
const saved = storage({[key]: JSON.stringify({current: 2, completed: [-1, 0, 0, 2, 3, 1.5, '1', null]})});
assert.deepEqual(readTutorialProgress(saved, campaignId, 3), {current: 2, completed: [0, 2]});
for (const current of [-1, 3, 1.5, '2', null]) {
saved.items.set(key, JSON.stringify({current, completed: {0: 1}}));
assert.deepEqual(readTutorialProgress(saved, campaignId, 3), fresh);
}
assert.deepEqual(saved.writes, []);
});
test('bad JSON and non-object saves are harmless and never rewritten during reading', () => {
for (const raw of ['{', 'null', '[]', 'true', '"text"', '42']) {
const saved = storage({[key]: raw});
assert.deepEqual(readTutorialProgress(saved, campaignId, 3), fresh);
assert.equal(saved.getItem(key), raw);
assert.deepEqual(saved.writes, []);
}
});
test('tutorial writes stay separate from campaign progress, historical saves and version selection', () => {
const original = {
'hex-trails-campaign-v0.5': JSON.stringify({current: 999, completed: [0, 998, 999]}),
'hex-trails-observe-v3-r2': JSON.stringify({current: 6, completed: [6]}),
'hex-trails-campaign-version': 'v0.4',
};
const saved = storage(original);
assert.equal(writeTutorialProgress(saved, campaignId, 3, {current: 2, completed: [0, 0, 1, 8]}), true);
assert.deepEqual(readTutorialProgress(saved, campaignId, 3), {current: 2, completed: [0, 1]});
assert.deepEqual(saved.writes, [key]);
for (const [name, value] of Object.entries(original)) assert.equal(saved.getItem(name), value);
assert.deepEqual(readTutorialProgress(saved, 'v0.4', 3), fresh);
});
test('unavailable storage never blocks loading or playing tutorials', () => {
const denied = {getItem() { throw new Error('denied'); }, setItem() { throw new Error('denied'); }};
for (const saved of [undefined, null, {}, denied]) {
assert.deepEqual(readTutorialProgress(saved, campaignId, 3), fresh);
assert.equal(writeTutorialProgress(saved, campaignId, 3, {current: 1, completed: [0]}), false);
}
});
test('explicit tutorial links can replay completed lessons and take priority over level links', () => {
const done = {current: 2, completed: [0, 1, 2]};
const experienced = {current: 999, completed: [0, 999]};
for (let index = 0; index < 3; index += 1) {
assert.equal(tutorialLaunch(`?tutorial=${index + 1}&level=1000`, 3, done, experienced), index);
}
assert.equal(tutorialLaunch(new URLSearchParams('campaign=v0.5&tutorial=2'), 3, fresh, fresh), 1);
});
test('every explicit campaign level parameter bypasses automatic tutorials', () => {
for (const search of ['?level=1', '?level=1000', '?level=', '?level=bad', '?tutorial=9&level=1']) {
assert.equal(tutorialLaunch(search, 3, fresh, fresh), null, search);
}
});
test('existing campaign players skip automatic tutorials but may request them explicitly', () => {
for (const campaignProgress of [{current: 1, completed: []}, {current: 0, completed: [0]}, {current: 999, completed: [999]}]) {
assert.equal(tutorialLaunch('', 3, fresh, campaignProgress), null);
assert.equal(tutorialLaunch('?tutorial=1', 3, fresh, campaignProgress), 0);
}
});
test('unfinished tutorials resume at the saved lesson or the next incomplete lesson', () => {
assert.equal(tutorialLaunch('', 3, {current: 1, completed: [0]}, fresh), 1);
assert.equal(tutorialLaunch('', 3, {current: 2, completed: [0]}, fresh), 2);
assert.equal(tutorialLaunch('', 3, {current: 0, completed: [0]}, fresh), 1);
assert.equal(tutorialLaunch('', 3, {current: 2, completed: [0, 2]}, fresh), 1);
assert.equal(tutorialLaunch('', 3, {current: 99, completed: [0, 0, -1, 4]}, fresh), 1);
assert.equal(tutorialLaunch('', 3, {current: 2, completed: [0, 1, 2]}, fresh), null);
});
test('invalid tutorial parameters follow the normal launch rules', () => {
for (const value of ['', '0', '4', '-1', '1.5', '1e0', 'Infinity', 'abc']) {
assert.equal(tutorialLaunch(`?tutorial=${value}`, 3, {current: 1, completed: [0]}, fresh), 1, value);
assert.equal(tutorialLaunch(`?tutorial=${value}`, 3, fresh, {current: 2, completed: []}), null, value);
}
});
test('campaigns without tutorials do not read, write or launch tutorial state', () => {
const unreachable = {
getItem() { assert.fail('should not read a tutorial slot'); },
setItem() { assert.fail('should not write a tutorial slot'); },
};
for (const count of [0, -1, 1.5, undefined, NaN]) {
assert.deepEqual(readTutorialProgress(unreachable, campaignId, count), fresh);
assert.equal(writeTutorialProgress(unreachable, campaignId, count, fresh), false);
assert.equal(tutorialLaunch('?tutorial=1', count, fresh, fresh), null);
}
});