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); } });