kgames

KGames — free keyboard games for kids
git clone https://www.keyboard.games/code/kgames.git
Log | Files | Refs | README | LICENSE

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