story-stamp-lib.test.js (16127B)
1 // Unit tests for games/story-stamp/lib.js — phonics content, topics, the 2 // queue, scheduler, blend timeline, profile store. No browser, no audio. 3 const { test } = require('node:test'); 4 const assert = require('node:assert'); 5 const SS = require('../../games/story-stamp/lib.js'); 6 7 const DAY = SS.DAY; 8 const NOW = Date.UTC(2026, 8, 14, 9, 0, 0); 9 10 function profile(name) { return SS.newProfile(name || 'Test', SS.AVATARS[0], NOW, 'p1'); } 11 12 // ── Content integrity ────────────────────────────────────────────────────── 13 14 test('every word splits into known graphemes and phonemes and belongs to a topic', () => { 15 for (const w of SS.WORDS) { 16 const gs = SS.graphemes(w); 17 assert.strictEqual(gs.join(''), w.w, `${w.w}: graphemes must spell the word`); 18 const units = SS.phonemeUnits(w); 19 assert.strictEqual(units.length, gs.length, `${w.w}: one phoneme unit per grapheme`); 20 for (const unit of units) for (const p of unit) assert.ok(SS.PHONEMES[p], `${w.w}: unknown phoneme ${p}`); 21 for (const g of gs) assert.ok(SS.SEQUENCE.includes(SS.baseGrapheme(g)), `${w.w}: grapheme ${g} is not in the teaching sequence`); 22 assert.ok(w.art && /^[A-Z]/.test(w.art), `${w.w}: art must be a Fluent Emoji asset name`); 23 assert.ok(w.t.length >= 1 && w.t.every((t) => SS.TOPIC_BY_ID[t]), `${w.w}: topics ${w.t}`); 24 } 25 }); 26 27 test('word list has no duplicates and every sequence grapheme unlocks something', () => { 28 const names = SS.WORDS.map((w) => w.w); 29 assert.strictEqual(new Set(names).size, names.length); 30 const known = []; 31 let prev = 0; 32 for (const g of SS.SEQUENCE) { 33 known.push(g); 34 const n = SS.availableWords(known).length; 35 assert.ok(n >= prev, `pool never shrinks (${g})`); 36 prev = n; 37 } 38 assert.strictEqual(prev, SS.WORDS.length, 'the full sequence unlocks every word'); 39 assert.ok(SS.availableWords(SS.SEQUENCE.slice(0, SS.STARTING_GRAPHEMES)).length >= 6, 'the opening six letters give a few words'); 40 assert.ok(SS.WORDS.length >= 200, 'a big enough library to keep topics varied'); 41 }); 42 43 test('every topic has plenty of books and every phoneme is used by a word', () => { 44 for (const t of SS.TOPICS) { 45 const n = SS.WORDS.filter((w) => w.t.includes(t.id)).length; 46 assert.ok(n >= 8, `topic ${t.id} has only ${n} words`); 47 } 48 const used = new Set(); 49 SS.WORDS.forEach((w) => SS.phonemeUnits(w).forEach((u) => u.forEach((p) => used.add(p)))); 50 for (const p of Object.keys(SS.PHONEMES)) assert.ok(used.has(p), `phoneme ${p} appears in no word`); 51 }); 52 53 test('doubled consonants, ck, qu, x and alternative spellings resolve to the taught sound', () => { 54 assert.deepStrictEqual(SS.graphemePhonemes('ll'), ['l']); 55 assert.deepStrictEqual(SS.graphemePhonemes('ck'), ['k']); 56 assert.deepStrictEqual(SS.graphemePhonemes('x'), ['k', 's']); 57 assert.deepStrictEqual(SS.graphemePhonemes('qu'), ['k', 'w']); 58 assert.deepStrictEqual(SS.graphemePhonemes('tch'), ['ch']); 59 assert.deepStrictEqual(SS.graphemePhonemes('ay'), ['ai']); 60 assert.strictEqual(SS.baseGrapheme('ll'), 'l'); 61 assert.strictEqual(SS.baseGrapheme('ck'), 'ck'); 62 assert.strictEqual(SS.baseGrapheme('oy'), 'oi'); 63 assert.strictEqual(SS.baseGrapheme('tch'), 'ch'); 64 assert.throws(() => SS.graphemePhonemes('q')); 65 }); 66 67 test('phoneme overrides keep book short and robot long, and gate availability on both spellings', () => { 68 assert.deepStrictEqual(SS.phonemeUnits(SS.WORD_BY_NAME.book), [['b'], ['uu'], ['k']]); 69 assert.deepStrictEqual(SS.phonemeUnits(SS.WORD_BY_NAME.moon), [['m'], ['oo'], ['n']]); 70 assert.deepStrictEqual(SS.phonemeUnits(SS.WORD_BY_NAME.robot), [['r'], ['oa'], ['b'], ['o'], ['t']]); 71 assert.deepStrictEqual(SS.phonemeUnits(SS.WORD_BY_NAME.thumb), [['th'], ['u'], ['m'], []]); 72 const upToOo = SS.SEQUENCE.slice(0, SS.SEQUENCE.indexOf('oo') + 1); 73 assert.ok(SS.wordIsAvailable(SS.WORD_BY_NAME.book, upToOo), 'book needs b, oo, k'); 74 assert.ok(!SS.wordIsAvailable(SS.WORD_BY_NAME.robot, upToOo), 'robot also needs oa for its first o'); 75 assert.ok(SS.wordIsAvailable(SS.WORD_BY_NAME.robot, upToOo.concat(['oa']))); 76 assert.strictEqual(SS.homeGrapheme('k'), 'c'); 77 assert.strictEqual(SS.homeGrapheme('uu'), 'oo'); 78 }); 79 80 test('patrons are deterministic per word and cover the cast', () => { 81 assert.strictEqual(SS.patronFor('cat'), SS.patronFor('cat')); 82 const ids = new Set(SS.WORDS.map((w) => SS.patronFor(w.w).id)); 83 assert.ok(ids.size >= SS.PATRONS.length - 2, 'most regulars get at least one favourite book'); 84 }); 85 86 // ── Availability and introduction ───────────────────────────────────────── 87 88 test('a new profile knows the first six letters and can be served', () => { 89 const p = profile(); 90 assert.deepStrictEqual(p.introduced, ['s', 'a', 't', 'p', 'i', 'n']); 91 const words = SS.unseenWords(p); 92 assert.ok(words.includes('ant') && words.includes('tap')); 93 assert.ok(!words.includes('cat')); 94 assert.strictEqual(SS.nextGrapheme(p), 'm'); 95 }); 96 97 test('new sounds arrive on mastery, not on a schedule', () => { 98 const p = profile(); 99 assert.ok(!SS.shouldIntroduce(p), 'nothing yet'); 100 SS.recordOrder(p, 'tap', true, NOW); 101 SS.recordOrder(p, 'pin', true, NOW); 102 assert.ok(!SS.shouldIntroduce(p), 'two orders is too soon'); 103 SS.recordOrder(p, 'pan', false, NOW); 104 SS.recordOrder(p, 'ant', true, NOW); 105 assert.ok(!SS.shouldIntroduce(p), '3 of 4 first-try is under the bar'); 106 SS.recordOrder(p, 'nap', true, NOW); 107 assert.ok(SS.shouldIntroduce(p), '4 of the last 5 first-try unlocks the next sound'); 108 assert.strictEqual(SS.introduceNext(p), 'm'); 109 assert.strictEqual(p.sinceIntro, 0); 110 assert.ok(!SS.shouldIntroduce(p), 'not two in a row'); 111 }); 112 113 test('a dry pool forces the next sound even when accuracy is poor', () => { 114 const p = profile(); 115 SS.unseenWords(p).forEach((w) => SS.recordOrder(p, w, false, NOW)); 116 assert.strictEqual(SS.unseenWords(p).length, 0); 117 assert.ok(SS.shouldIntroduce(p)); 118 }); 119 120 // ── Spaced repetition ───────────────────────────────────────────────────── 121 122 test('first-try orders grow the interval; a miss resets it and brings the regular back soon', () => { 123 const p = profile(); 124 let rec = SS.recordOrder(p, 'tap', true, NOW); 125 assert.strictEqual(rec.streak, 1); 126 assert.strictEqual(rec.due, NOW + 1 * DAY); 127 rec = SS.recordOrder(p, 'tap', true, NOW + DAY); 128 assert.strictEqual(rec.interval, 3); 129 assert.ok(SS.isMastered(rec)); 130 rec = SS.recordOrder(p, 'tap', false, NOW + 4 * DAY); 131 assert.strictEqual(rec.streak, 0); 132 assert.strictEqual(rec.lapses, 1); 133 assert.strictEqual(rec.due, NOW + 4 * DAY + 5 * SS.MIN); 134 assert.ok(!SS.isMastered(rec)); 135 assert.deepStrictEqual(SS.shakyWords(p), ['tap']); 136 }); 137 138 test('due words come back before fresh ones, most overdue first', () => { 139 const p = profile(); 140 SS.recordOrder(p, 'tap', true, NOW - 5 * DAY); 141 SS.recordOrder(p, 'pin', true, NOW - 3 * DAY); 142 SS.recordOrder(p, 'ant', true, NOW); 143 p.lastWord = null; 144 assert.deepStrictEqual(SS.dueWords(p, NOW), ['tap', 'pin']); 145 const rng = SS.mulberry32(1); 146 const seen = new Set(); 147 for (let i = 0; i < 40; i++) { 148 const n = SS.pickNext(p, NOW, rng); 149 if (n.review) seen.add(n.word.w); 150 } 151 assert.ok(seen.has('tap') && seen.has('pin')); 152 assert.ok(!seen.has('ant'), 'a word served today is not due'); 153 }); 154 155 test('pickNext never repeats the previous word and prefers words using the newest sound', () => { 156 const p = profile(); 157 p.introduced.push('m'); 158 const rng = SS.mulberry32(7); 159 let last = null; 160 for (let i = 0; i < 30; i++) { 161 const n = SS.pickNext(p, NOW, rng); 162 assert.notStrictEqual(n.word.w, last); 163 if (!n.review) assert.ok(SS.graphemes(n.word).map(SS.baseGrapheme).includes('m'), `fresh word ${n.word.w} should use the new sound`); 164 SS.recordOrder(p, n.word.w, true, NOW); 165 last = n.word.w; 166 if (SS.unseenWords(p).filter((w) => w !== last && SS.graphemes(SS.WORD_BY_NAME[w]).map(SS.baseGrapheme).includes('m')).length === 0) break; 167 } 168 }); 169 170 test('returns (typing by ear) only unlock after five mastered words', () => { 171 const p = profile(); 172 const rng = SS.mulberry32(3); 173 ['tap', 'pin', 'pan'].forEach((w) => { SS.recordOrder(p, w, true, NOW - 3 * DAY); SS.recordOrder(p, w, true, NOW - 2 * DAY); }); 174 p.lastWord = null; 175 for (let i = 0; i < 30; i++) assert.strictEqual(SS.pickNext(p, NOW, rng).mode, 'read'); 176 ['ant', 'nap', 'tin'].forEach((w) => { SS.recordOrder(p, w, true, NOW - 3 * DAY); SS.recordOrder(p, w, true, NOW - 2 * DAY); }); 177 p.lastWord = null; 178 assert.ok(SS.masteredWords(p).length >= SS.RETURN_MODE_UNLOCK); 179 // Returns only make sense for a book the child has met: make them all due. 180 Object.values(p.words).forEach((r) => { r.due = NOW - DAY; }); 181 let returns = 0; 182 for (let i = 0; i < 80; i++) if (SS.pickNext(p, NOW, rng).mode === 'return') returns++; 183 assert.ok(returns > 10 && returns < 70, `returns should be a healthy mix, got ${returns}/80`); 184 }); 185 186 test('pickNext respects a filter and an exclusion list', () => { 187 const p = profile(); 188 p.introduced = SS.SEQUENCE.slice(0, 16); 189 const rng = SS.mulberry32(5); 190 for (let i = 0; i < 20; i++) { 191 const n = SS.pickNext(p, NOW, rng, (w) => w.t.includes('animals'), ['dog']); 192 assert.ok(n.word.t.includes('animals')); 193 assert.notStrictEqual(n.word.w, 'dog'); 194 } 195 assert.strictEqual(SS.pickNext(p, NOW, rng, () => false), null); 196 }); 197 198 // ── The queue ───────────────────────────────────────────────────────────── 199 200 test('the queue offers different topics, different words, and a patron for each', () => { 201 const p = profile(); 202 p.introduced = SS.SEQUENCE.slice(0, 20); 203 const rng = SS.mulberry32(11); 204 for (let i = 0; i < 10; i++) { 205 const q = SS.buildQueue(p, NOW, rng, 3); 206 assert.strictEqual(q.length, 3); 207 assert.strictEqual(new Set(q.map((e) => e.topic.id)).size, 3, 'topics differ'); 208 assert.strictEqual(new Set(q.map((e) => e.word.w)).size, 3, 'words differ'); 209 q.forEach((e) => { 210 assert.ok(e.patron && e.patron.name); 211 if (e.topic.id !== 'any') assert.ok(e.word.t.includes(e.topic.id), `${e.word.w} is not about ${e.topic.id}`); 212 }); 213 } 214 }); 215 216 test('a beginner still gets a choice: generic patrons fill in until topics open up', () => { 217 const p = profile(); 218 const rng = SS.mulberry32(2); 219 const q = SS.buildQueue(p, NOW, rng, 3); 220 assert.ok(q.length >= 2, 'at least two to choose from'); 221 q.forEach((e) => assert.ok(SS.wordIsAvailable(e.word, p.introduced))); 222 const topics = SS.topicsWithWords(p).map((t) => t.id); 223 assert.ok(topics.includes('bugs') && topics.includes('play'), `opening topics: ${topics}`); 224 }); 225 226 test('recordTopic counts what the child asks for', () => { 227 const p = profile(); 228 SS.recordTopic(p, 'dinos'); SS.recordTopic(p, 'dinos'); SS.recordTopic(p, 'sea'); 229 assert.deepStrictEqual(p.topics, { dinos: 2, sea: 1 }); 230 }); 231 232 // ── Distractors ─────────────────────────────────────────────────────────── 233 234 test('distractors are two other words with different pictures, sharing letters when possible', () => { 235 const p = profile(); 236 p.introduced = SS.SEQUENCE.slice(0, 12); 237 ['cat', 'cap', 'pig', 'dog', 'sun', 'map'].forEach((w) => SS.recordOrder(p, w, true, NOW)); 238 const rng = SS.mulberry32(9); 239 for (let i = 0; i < 20; i++) { 240 const d = SS.distractors(SS.WORD_BY_NAME.cat, p, rng); 241 assert.strictEqual(d.length, 2); 242 assert.ok(!d.some((w) => w.w === 'cat')); 243 assert.strictEqual(new Set(d.map((w) => w.art).concat(['Cat'])).size, 3, 'pictures must differ'); 244 assert.ok(d.some((w) => /[cat]/.test(w.w)), 'at least one near miss'); 245 } 246 const fresh = profile(); 247 assert.strictEqual(SS.distractors(SS.WORD_BY_NAME.tap, fresh, rng).length, 2, 'works before any word is served'); 248 }); 249 250 test('a profile that served a word since removed from the list still schedules cleanly', () => { 251 const p = profile(); 252 SS.recordOrder(p, 'tap', true, NOW - 3 * DAY); 253 SS.recordOrder(p, 'zzzgone', true, NOW - 3 * DAY); 254 assert.deepStrictEqual(SS.servedWords(p), ['tap']); 255 assert.deepStrictEqual(SS.dueWords(p, NOW), ['tap']); 256 assert.strictEqual(SS.masteredWords(p).length, 0); 257 const n = SS.pickNext(p, NOW, SS.mulberry32(1)); 258 assert.ok(n.word && n.word.w !== 'zzzgone'); 259 assert.ok(SS.distractors(SS.WORD_BY_NAME.tap, p, SS.mulberry32(1)).every((w) => w.w !== 'zzzgone')); 260 }); 261 262 // ── Library growth ──────────────────────────────────────────────────────── 263 264 test('the library grows with mastered words', () => { 265 assert.deepStrictEqual(SS.libraryUpgrades(0), []); 266 assert.deepStrictEqual(SS.libraryUpgrades(2).map((u) => u.id), ['rug']); 267 assert.strictEqual(SS.libraryUpgrades(100).length, SS.LIBRARY_UPGRADES.length); 268 }); 269 270 // ── Blend timeline ──────────────────────────────────────────────────────── 271 272 test('blend timeline: three passes of shrinking gaps and holds, never the whole word', () => { 273 const units = SS.phonemeUnits(SS.WORD_BY_NAME.sat || SS.WORD_BY_NAME.tap); 274 const durations = { s: 1.0, a: 0.9, t: 0.1, p: 0.09 }; 275 const ev = SS.blendTimeline(units, durations); 276 const phon = ev.filter((e) => e.kind === 'phoneme'); 277 assert.strictEqual(phon.length, units.length * 3); 278 assert.ok(!ev.some((e) => e.kind === 'word'), 'the word is left for the child to say'); 279 assert.strictEqual(ev[ev.length - 1].kind, 'done'); 280 const pass = (i) => phon.filter((e) => e.pass === i); 281 const gap = (i) => pass(i)[1].t - (pass(i)[0].t + pass(i)[0].len); 282 assert.ok(gap(0) > gap(1) && gap(1) > gap(2), 'gaps shrink'); 283 const held = (i) => pass(i).find((e) => SS.PHONEMES[e.id].stretch); 284 assert.ok(held(0).len > held(1).len && held(1).len > held(2).len, 'held sounds get shorter'); 285 assert.strictEqual(held(0).len, SS.BLEND_PASSES[0].hold); 286 const stop = (i) => pass(i).find((e) => SS.PHONEMES[e.id].type === 'stop'); 287 assert.strictEqual(stop(0).len, stop(2).len, 'stops keep their natural length'); 288 for (let i = 1; i < ev.length; i++) assert.ok(ev[i].t >= ev[i - 1].t, 'events are in time order'); 289 }); 290 291 test('phonemeLength cuts held sounds to the pass but never below the minimum or above the clip', () => { 292 assert.strictEqual(SS.phonemeLength('s', 0.4, { s: 1.0 }), 0.4); 293 assert.strictEqual(SS.phonemeLength('s', 0.05, { s: 1.0 }), SS.MIN_HOLD); 294 assert.strictEqual(SS.phonemeLength('s', 2, { s: 1.0 }), 1.0); 295 assert.strictEqual(SS.phonemeLength('p', 0.4, { p: 0.09 }), 0.09, 'stops are never stretched or cut'); 296 assert.strictEqual(SS.phonemeLength('ow', 0.1, { ow: 0.4 }), 0.4, 'diphthongs play whole'); 297 }); 298 299 // ── Store ───────────────────────────────────────────────────────────────── 300 301 test('profiles round-trip through storage and bad data is ignored', () => { 302 const mem = {}; 303 const storage = { getItem: (k) => (k in mem ? mem[k] : null), setItem: (k, v) => { mem[k] = v; } }; 304 const store = SS.loadStore(storage); 305 assert.deepStrictEqual(store.profiles, []); 306 const p = profile('Ada'); 307 SS.recordOrder(p, 'tap', true, NOW); 308 store.profiles.push(p); store.last = p.id; 309 SS.saveStore(storage, store); 310 const back = SS.loadStore(storage); 311 assert.strictEqual(back.profiles[0].name, 'Ada'); 312 assert.strictEqual(back.profiles[0].words.tap.streak, 1); 313 assert.deepStrictEqual(back.profiles[0].topics, {}); 314 assert.strictEqual(back.last, p.id); 315 mem[SS.STORE_KEY] = '{not json'; 316 assert.deepStrictEqual(SS.loadStore(storage).profiles, []); 317 });