chore: take in the 16 Sep bundle — lib, curriculum v5, prompt, gate audit
The artifact was reworked after real incidents: a week of lost data, a
student taught out of order, and spelling diagnoses the model invented.
This takes the new export in verbatim; the port catches up in the
commits that follow.
Copied byte-identical from the bundle:
lib/ lexicon.js and sync.js are new; gate.js gains enforcement,
hangul.js letter-level marking, srs.js recall evidence,
conjugation.js deconjugate(); blocks.js now takes the last
block, closes gloss at "=", and parses recall, ::result and
::confirmed
data/ curriculum.json v5 — six 다지기 phase reviews; the 371
roadmap words are unchanged and no band moves
prompt/ English-only rule, recall, LETTER-LEVEL CHECK, marking
audit-gate.mjs, run-checks.sh, fixtures/ — the word gate measured
against 54 real tutor messages
CI runs run-checks.sh in place of validate.mjs alone, and `npm run check`
gains the audit. Baselines: validate PASS 0/0; audit 7 of 41 and 2 of 13.
types/lib/ declares the new API, and test/lib/ pins it: letterCheck on
the prompt's own 짧다/빫다 case, deconjugation, the roadmap-first order
that keeps 마셔 out of Phase 1, sync's three gates, and recall evidence —
including the two ways lib's evidence is looser than PORT.md, pinned as
they are so the call site that tightens them is visibly needed.
TaskHost gains a plain recall renderer so the tree typechecks against the
wider Task union.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,12 +4,12 @@
|
||||
sentences appeared as prose above the exercise those same lines had
|
||||
already been rendered into.
|
||||
|
||||
It is lib/blocks.js, not the model. parse() removes ::words by
|
||||
truncating the body at its index, then removes ::task by substring —
|
||||
but RE.task's terminator (?:\n::|$) is part of the match, so t[0] ends
|
||||
with the "\n::" of the following ::words that the truncation just cut
|
||||
off. The substring no longer occurs, replace() is a no-op, and the whole
|
||||
task block stays in the body.
|
||||
It is lib/blocks.js, not the model. parse() deletes each block with a
|
||||
regex whose terminator (?:\n::|$) is part of the match, so deleting one
|
||||
block also deletes the "::" that opens the next. That block is then no
|
||||
longer a block, and stays in the body as a bare word plus its rows. The
|
||||
16 Sep lib changed which block survives — it now strips every kind in
|
||||
turn instead of truncating at ::words — but not the defect.
|
||||
|
||||
Order is what decides it. The stub tutor emits ::words before ::task and
|
||||
is therefore fine; a real model emitted ::task first. Nothing in the
|
||||
@@ -33,15 +33,17 @@ function translateItems(task: ParsedMessage["task"]): { q: string }[] {
|
||||
const CAPTURED = "::task translate\n\ub098 \uac00\ub2e4 \n\ub108 \uba39\ub2e4 \n\uc6b0\ub9ac \ub9c8\uc2dc\ub2e4 \n\uc774 \uc790\ub2e4 \n\uadf8 \ub098\ubb34 \uc11c\ub2e4 \n\n::words\n\ub098 | I, me (casual) | pron \n\uac00\ub2e4 | to go | verb (plain) \n\ub108 | you (casual) | pron \n\uba39\ub2e4 | to eat | verb (plain) \n\uc6b0\ub9ac | we, our | pron \n\ub9c8\uc2dc\ub2e4 | to drink | verb (plain) \n\uc774 | this | det \n\uc790\ub2e4 | to sleep | verb (plain) \n\uadf8 | that (near you) | det \n\ub098\ubb34 | tree | noun \n\uc11c\ub2e4 | to stand | verb (plain) \n\n::progress 20";
|
||||
|
||||
describe("lib/blocks.js — the leak, pinned as it is", () => {
|
||||
it("leaves the whole ::task block in body when ::task precedes ::words", () => {
|
||||
it("leaves the de-coloned ::words block in body when ::task precedes ::words", () => {
|
||||
const r = parse(CAPTURED);
|
||||
// The blocks themselves parse correctly...
|
||||
expect(r.task?.type).toBe("translate");
|
||||
expect(translateItems(r.task)).toHaveLength(5);
|
||||
expect(r.words).toHaveLength(11);
|
||||
// ...and the body still carries the markup that produced them.
|
||||
expect(r.body).toContain("::task translate");
|
||||
expect(r.body).toContain("나 가다");
|
||||
// ...and the body carries the words block, its "::" eaten by the task's
|
||||
// terminator: not even recognisable as markup any more.
|
||||
expect(r.body).toMatch(/^words\n/);
|
||||
expect(r.body).toContain("나 | I, me (casual) | pron");
|
||||
expect(r.body).not.toContain("::");
|
||||
});
|
||||
|
||||
it("is fine in the other order, which is why the stub never showed it", () => {
|
||||
|
||||
@@ -15,22 +15,42 @@ describe("parse — ::words", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("truncates the body at ::words — the block is contractually last", () => {
|
||||
it("strips a closed block without truncating what follows it", () => {
|
||||
// The old parse() cut the body at ::words; every block is now removed
|
||||
// on its own, so prose after a properly closed block survives.
|
||||
const p = parse("Read this.\n::words\n밥 | rice\n::\ntrailing junk");
|
||||
expect(p.body).toBe("Read this.");
|
||||
expect(p.body).toBe("Read this.\n\ntrailing junk");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parse — the four task types", () => {
|
||||
it("translate", () => {
|
||||
describe("parse — the five task types", () => {
|
||||
it("translate — and every task carries its raw rows and a retraction count", () => {
|
||||
const p = parse("Try these.\n::task translate\n우리 밥 먹어\n학교 작아\n::");
|
||||
expect(p.task).toEqual({ type: "translate", items: [{ q: "우리 밥 먹어" }, { q: "학교 작아" }] });
|
||||
expect(p.task).toEqual({
|
||||
type: "translate",
|
||||
items: [{ q: "우리 밥 먹어" }, { q: "학교 작아" }],
|
||||
retracted: 0,
|
||||
rows: ["우리 밥 먹어", "학교 작아"],
|
||||
});
|
||||
expect(p.body).toBe("Try these.");
|
||||
});
|
||||
|
||||
it("recall — English prompt, optional hint in the second field", () => {
|
||||
const p = parse("::task recall\nchicken | double batchim\nthe sea\n::");
|
||||
expect(p.task).toEqual({
|
||||
type: "recall",
|
||||
items: [
|
||||
{ q: "chicken", hint: "double batchim" },
|
||||
{ q: "the sea", hint: "" },
|
||||
],
|
||||
retracted: 0,
|
||||
rows: ["chicken | double batchim", "the sea"],
|
||||
});
|
||||
});
|
||||
|
||||
it("match", () => {
|
||||
const p = parse("::task match\n친구 | friend\n물 | water\n::");
|
||||
expect(p.task).toEqual({
|
||||
expect(p.task).toMatchObject({
|
||||
type: "match",
|
||||
pairs: [
|
||||
{ ko: "친구", gloss: "friend" },
|
||||
@@ -41,7 +61,7 @@ describe("parse — the four task types", () => {
|
||||
|
||||
it("build — first field is the English, the rest are chips in order", () => {
|
||||
const p = parse("::task build\nWe eat rice. | 우리 | 밥 | 먹어\n::");
|
||||
expect(p.task).toEqual({
|
||||
expect(p.task).toMatchObject({
|
||||
type: "build",
|
||||
items: [{ en: "We eat rice.", chips: ["우리", "밥", "먹어"] }],
|
||||
});
|
||||
@@ -49,16 +69,22 @@ describe("parse — the four task types", () => {
|
||||
|
||||
it("choice", () => {
|
||||
const p = parse("::task choice\n나 학교 ___ 가 | 에 | 에서 | 을\n::");
|
||||
expect(p.task).toEqual({
|
||||
expect(p.task).toMatchObject({
|
||||
type: "choice",
|
||||
items: [{ q: "나 학교 ___ 가", options: ["에", "에서", "을"] }],
|
||||
});
|
||||
});
|
||||
|
||||
it("drops malformed rows rather than emitting half a task", () => {
|
||||
// A choice needs more than one option; a match needs both sides.
|
||||
expect(parse("::task choice\nonly a question\n::").task).toEqual({ type: "choice", items: [] });
|
||||
expect(parse("::task match\n친구\n::").task).toEqual({ type: "match", pairs: [] });
|
||||
// A choice needs more than one option; a match needs both sides. The raw
|
||||
// rows are kept regardless — the gate reads those, not the parsed items.
|
||||
expect(parse("::task choice\nonly a question\n::").task).toEqual({
|
||||
type: "choice",
|
||||
items: [],
|
||||
retracted: 0,
|
||||
rows: ["only a question"],
|
||||
});
|
||||
expect(parse("::task match\n친구\n::").task).toMatchObject({ type: "match", pairs: [] });
|
||||
});
|
||||
|
||||
it("is null when there is no task block", () => {
|
||||
@@ -66,6 +92,51 @@ describe("parse — the four task types", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/* The tutor sometimes writes an exercise, notices it broke the gate, and
|
||||
writes a corrected one below. Matching the FIRST block handed the student
|
||||
the draft that had just been withdrawn. */
|
||||
describe("parse — several blocks of one kind", () => {
|
||||
const reply = [
|
||||
"Draft:",
|
||||
"::task translate",
|
||||
"원 없어",
|
||||
"::",
|
||||
"Sorry, 원 is not his yet. Here:",
|
||||
"::task translate",
|
||||
"물 없어",
|
||||
"::",
|
||||
"::words",
|
||||
"물 | water",
|
||||
"::",
|
||||
"::words",
|
||||
"물 | WATER",
|
||||
"없어 | there is none",
|
||||
"::",
|
||||
"::progress 30 | a",
|
||||
"::progress 40 | b",
|
||||
].join("\n");
|
||||
|
||||
it("takes the LAST task and counts the drafts it replaced", () => {
|
||||
const p = parse(reply);
|
||||
expect(p.task).toMatchObject({ type: "translate", items: [{ q: "물 없어" }], retracted: 1 });
|
||||
});
|
||||
|
||||
it("accumulates words, the first gloss of a term winning", () => {
|
||||
expect(parse(reply).words).toEqual([
|
||||
{ ko: "물", gloss: "water", note: "" },
|
||||
{ ko: "없어", gloss: "there is none", note: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("takes the last progress line", () => {
|
||||
expect(parse(reply).progress).toEqual({ score: 40, note: "b" });
|
||||
});
|
||||
|
||||
it("strips every block from the body, not only the one it used", () => {
|
||||
expect(parse(reply).body).toBe("Draft:\n\nSorry, 원 is not his yet. Here:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parse — ::gloss", () => {
|
||||
it("reads parts and the = line, defaulting the role to N", () => {
|
||||
const p = parse(
|
||||
@@ -83,23 +154,17 @@ describe("parse — ::gloss", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
/* KNOWN LIMITATION, pinned deliberately.
|
||||
The system prompt tells the tutor it may put several sentences in one
|
||||
::gloss block, separated by their = lines. parse() sets `en` on the
|
||||
current block when it meets "=", but never closes the block, so the
|
||||
parts of every sentence pile into one run-on line and only the last
|
||||
translation survives.
|
||||
|
||||
lib/ ships unchanged, so this is not fixed here. The app splits a
|
||||
::gloss block on its = lines and calls parse() once per sentence —
|
||||
see app/src/domain/gloss.ts. If lib/blocks.js is ever revised, the
|
||||
one-line fix is `cur = null` after setting `en`, and this test and
|
||||
that workaround both go away. */
|
||||
it("does NOT close a block on the = line (see gloss.ts for the workaround)", () => {
|
||||
it("closes a sentence at its = line, so one block can gloss several", () => {
|
||||
const p = parse("::gloss\n나 | S | I\n가 | V | go\n= I go.\n밥 | O | rice\n= Rice.\n::");
|
||||
expect(p.gloss).toHaveLength(1);
|
||||
expect(p.gloss![0]!.parts.map((x) => x.ko)).toEqual(["나", "가", "밥"]);
|
||||
expect(p.gloss![0]!.en).toBe("Rice."); // "I go." is lost
|
||||
expect(p.gloss).toHaveLength(2);
|
||||
expect(p.gloss![0]!.parts.map((x) => x.ko)).toEqual(["나", "가"]);
|
||||
expect(p.gloss![0]!.en).toBe("I go.");
|
||||
expect(p.gloss![1]!.en).toBe("Rice.");
|
||||
});
|
||||
|
||||
it("accumulates separate gloss blocks", () => {
|
||||
const p = parse("::gloss\n나 | S | I\n= I.\n::\nand\n::gloss\n밥 | O | rice\n= Rice.\n::");
|
||||
expect(p.gloss!.map((g) => g.en)).toEqual(["I.", "Rice."]);
|
||||
});
|
||||
|
||||
it("defaults a missing role to N and an absent highlight to empty", () => {
|
||||
@@ -108,6 +173,41 @@ describe("parse — ::gloss", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("parse — ::result", () => {
|
||||
it("reads item | outcome | mistaken-for", () => {
|
||||
expect(parse("::result\n닭 | ok\n여덟 | wrong | 여덜\n::").results).toEqual([
|
||||
{ item: "닭", ok: true, mistakenFor: "" },
|
||||
{ item: "여덟", ok: false, mistakenFor: "여덜" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("counts only a literal ok as correct", () => {
|
||||
// The prompt asks for "ok or wrong". Anything else is not a pass.
|
||||
expect(parse("::result\n값 | right\n::").results![0]!.ok).toBe(false);
|
||||
expect(parse("::result\n값 | OK\n::").results![0]!.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("takes the last block", () => {
|
||||
expect(parse("::result\n닭 | wrong\n::\n::result\n닭 | ok\n::").results).toEqual([
|
||||
{ item: "닭", ok: true, mistakenFor: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("is null when there is no result block", () => {
|
||||
expect(parse("Nice.").results).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parse — ::confirmed", () => {
|
||||
it("reads the first column, keeping a leading minus", () => {
|
||||
expect(parse("::confirmed\n연음\n-닭\n값 | extra\n::").confirmed).toEqual(["연음", "-닭", "값"]);
|
||||
});
|
||||
|
||||
it("takes the last block", () => {
|
||||
expect(parse("::confirmed\n연음\n::\n::confirmed\n닭\n::").confirmed).toEqual(["닭"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parse — ::progress", () => {
|
||||
it("reads the score and the note", () => {
|
||||
const p = parse("Nice work.\n::progress 72 | particles are landing");
|
||||
@@ -142,6 +242,9 @@ describe("parse — a full reply", () => {
|
||||
"밥 | rice",
|
||||
"먹어 | eat | from 먹다",
|
||||
"::",
|
||||
"::result",
|
||||
"밥 | ok",
|
||||
"::",
|
||||
"::progress 64 | word order is solid",
|
||||
].join("\n");
|
||||
|
||||
@@ -151,6 +254,7 @@ describe("parse — a full reply", () => {
|
||||
expect(p.task!.type).toBe("translate");
|
||||
expect(p.words).toHaveLength(2);
|
||||
expect(p.gloss).toHaveLength(1);
|
||||
expect(p.results).toHaveLength(1);
|
||||
expect(p.progress!.score).toBe(64);
|
||||
});
|
||||
});
|
||||
@@ -173,6 +277,16 @@ describe("answerText — the message the student sends back", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("recall — the letter-level block rides between the answers and the lookups", () => {
|
||||
const task = parse("::task recall\nchicken | double batchim\nthe sea\n::").task!;
|
||||
expect(answerText(task, ["닭", ""], ["닭"], "BLOCK")).toBe(
|
||||
"My written answers:\nchicken → 닭\nthe sea → (not sure)\n\nBLOCK\n\n(I had to look up: 닭)",
|
||||
);
|
||||
expect(answerText(task, ["닭", "바다"], [])).toBe(
|
||||
"My written answers:\nchicken → 닭\nthe sea → 바다\n\n(No lookups.)",
|
||||
);
|
||||
});
|
||||
|
||||
it("match", () => {
|
||||
const task = parse("::task match\n친구 | friend\n물 | water\n::").task!;
|
||||
const out = answerText(task, { pairs: [{ ko: "친구", gloss: "friend" }] }, []);
|
||||
@@ -201,6 +315,7 @@ describe("answerText — the message the student sends back", () => {
|
||||
it("round-trips: every task type parses and answers without throwing", () => {
|
||||
const blocks: [string, TaskState][] = [
|
||||
["::task translate\n밥\n::", ["rice"]],
|
||||
["::task recall\nrice\n::", ["밥"]],
|
||||
["::task match\n밥 | rice\n::", { pairs: [] }],
|
||||
["::task build\nRice. | 밥\n::", [["밥"]]],
|
||||
["::task choice\n___ | 밥 | 물\n::", [1]],
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
explain,
|
||||
surfaceForms,
|
||||
IRREGULAR_FORMS,
|
||||
deconjugate,
|
||||
deconjugateCandidates,
|
||||
} from "@lib/conjugation.js";
|
||||
|
||||
describe("haeche — the 아/어 rule", () => {
|
||||
@@ -142,3 +144,35 @@ describe("surfaceForms — the build-time index generator", () => {
|
||||
expect(surfaceForms("학교", "school")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/* Reading an inflected form back to its dictionary entry. Without it every
|
||||
one of 660 realistic inflections of the curriculum's verbs failed to
|
||||
resolve, and the student was told a taught word was not in the list. */
|
||||
describe("deconjugate", () => {
|
||||
const verbs = new Set(["가다", "앉다", "먹다", "마시다", "좋다"]);
|
||||
const isVerb = (d: string) => verbs.has(d);
|
||||
|
||||
it.each([
|
||||
["갑니다", "가다"],
|
||||
["먹습니다", "먹다"],
|
||||
["앉으면", "앉다"],
|
||||
["가고", "가다"],
|
||||
["가면", "가다"],
|
||||
["가네", "가다"],
|
||||
["간다", "가다"],
|
||||
["먹었어요", "먹다"],
|
||||
["좋아서", "좋다"],
|
||||
])("%s → %s", (form, dict) => {
|
||||
expect(deconjugate(form, isVerb)).toBe(dict);
|
||||
});
|
||||
|
||||
it("proposes every plausible dictionary form, unguarded", () => {
|
||||
expect(deconjugateCandidates("갑니다")).toEqual(["가다", "갑니다"]);
|
||||
expect(deconjugateCandidates("앉으면")).toEqual(["앉다", "앉으다"]);
|
||||
});
|
||||
|
||||
it("accepts nothing the lexicon does not hold as a verb — 가지 is an eggplant", () => {
|
||||
expect(deconjugateCandidates("가지")).toContain("가다");
|
||||
expect(deconjugate("가지", () => false)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,19 @@
|
||||
The nine cases named in the export README are all here. */
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Composer, compose, decompose, isJamo, CHO, JUNG, JONG, KEYBOARD } from "@lib/hangul.js";
|
||||
import {
|
||||
Composer,
|
||||
compose,
|
||||
decompose,
|
||||
isJamo,
|
||||
CHO,
|
||||
JUNG,
|
||||
JONG,
|
||||
KEYBOARD,
|
||||
spellOut,
|
||||
letterDiff,
|
||||
letterCheck,
|
||||
} from "@lib/hangul.js";
|
||||
|
||||
/** Type a sequence of jamo into a fresh composer, returning the final value. */
|
||||
function type(jamo: string[]): string {
|
||||
@@ -132,3 +144,55 @@ describe("KEYBOARD", () => {
|
||||
for (const base of Object.keys(KEYBOARD.shift)) expect(flatKeys.has(base), base).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/* A model cannot see the letters inside a syllable. Asked about 빫다 for
|
||||
짧다 the artifact's tutor blamed the ㄼ — identical in both — when the slip
|
||||
was the initial ㅉ→ㅃ. These are the strings the tutor is handed instead. */
|
||||
describe("letter-level marking", () => {
|
||||
it("spells a word out jamo by jamo, unpacking a double batchim", () => {
|
||||
expect(spellOut("짧다")).toBe("짧=ㅉ+ㅏ+ㄼ(ㄹ+ㅂ) · 다=ㄷ+ㅏ");
|
||||
});
|
||||
|
||||
it("names the wrong slot AND the right ones — the prompt's own example", () => {
|
||||
expect(letterDiff("짧다", "빫다")).toBe(
|
||||
"syllable 1 (빫 for 짧) — WRONG: first consonant: wrote ㅃ, should be ㅉ — " +
|
||||
"CORRECT, do not call these mistakes: vowel ㅏ, batchim ㄼ (ㄹ+ㅂ)",
|
||||
);
|
||||
});
|
||||
|
||||
it("catches a dropped half of a double batchim", () => {
|
||||
expect(letterDiff("닭", "닥")).toBe(
|
||||
"syllable 1 (닥 for 닭) — WRONG: batchim: wrote ㄱ, should be ㄺ (ㄹ+ㄱ) — " +
|
||||
"CORRECT, do not call these mistakes: first consonant ㄷ, vowel ㅏ",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports identity, a length mismatch, and says nothing about an empty side", () => {
|
||||
expect(letterDiff("학교", "학교")).toBe("identical");
|
||||
expect(letterDiff("바다", "바")).toBe("length differs: 2 syllables expected, 1 written");
|
||||
expect(letterDiff("", "x")).toBe("");
|
||||
});
|
||||
|
||||
it("builds the block a marking message carries, skipping correct answers", () => {
|
||||
const block = letterCheck([
|
||||
{ prompt: "to be short (dictionary form)", expected: "짧다", written: "빫다" },
|
||||
{ prompt: "the sea", expected: "바다", written: "바다" },
|
||||
]);
|
||||
expect(block).toBe(
|
||||
[
|
||||
"════ LETTER-LEVEL CHECK — computed by the app ════",
|
||||
"This is the actual jamo comparison. Use it exactly. Do NOT work out for yourself " +
|
||||
"which letter was wrong, and never call a letter wrong that is listed as correct.",
|
||||
"• to be short (dictionary form)",
|
||||
" wanted 짧다 [짧=ㅉ+ㅏ+ㄼ(ㄹ+ㅂ) · 다=ㄷ+ㅏ]",
|
||||
" wrote 빫다 [빫=ㅃ+ㅏ+ㄼ(ㄹ+ㅂ) · 다=ㄷ+ㅏ]",
|
||||
" syllable 1 (빫 for 짧) — WRONG: first consonant: wrote ㅃ, should be ㅉ — " +
|
||||
"CORRECT, do not call these mistakes: vowel ㅏ, batchim ㄼ (ㄹ+ㅂ)",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("is empty when nothing differs, so no block is sent at all", () => {
|
||||
expect(letterCheck([{ prompt: "the sea", expected: "바다", written: "바다" }])).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
123
test/lib/lexicon.test.ts
Normal file
123
test/lib/lexicon.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/* Golden tests pinning lib/lexicon.js — the one resolver shared by word
|
||||
lookup and the gate. It ships unchanged. */
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { buildLexicon, Lexicon, PARTICLES } from "@lib/lexicon.js";
|
||||
import { haeche, past } from "@lib/conjugation.js";
|
||||
import { flatten } from "@lib/gate.js";
|
||||
import type { Curriculum } from "@lib/gate.js";
|
||||
|
||||
const conj = { haeche, past };
|
||||
|
||||
describe("ORDER MATTERS — roadmap words are entered first", () => {
|
||||
/* 마셔 belongs to unit 2.3. Expanded from the deck first, it becomes a form
|
||||
of 마시다 and inherits that stem's permission, so a unit-2.3 word turns
|
||||
legal in Phase 1. In the fixture corpus that is four messages of
|
||||
violations that stop being reported. */
|
||||
it("keeps a scheduled form its own head", () => {
|
||||
const lex = buildLexicon(
|
||||
{ roadmapWords: ["마셔"], deck: [{ ko: "마시다", en: "to drink", pos: "verb" }] },
|
||||
conj,
|
||||
);
|
||||
expect(lex.heads("마셔")).toEqual(["마셔"]);
|
||||
expect(lex.get("마셔")).toMatchObject({ src: "roadmap", base: "" });
|
||||
});
|
||||
|
||||
it("links it to the stem when the deck got there first", () => {
|
||||
const lex = buildLexicon({ deck: [{ ko: "마시다", en: "to drink", pos: "verb" }] }, conj);
|
||||
expect(lex.heads("마셔")).toEqual(["마셔", "마시다"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("heads — every route, best first", () => {
|
||||
const lex = buildLexicon(
|
||||
{
|
||||
roadmapWords: ["닭"],
|
||||
deck: [
|
||||
{ ko: "마시다", en: "to drink", pos: "verb" },
|
||||
{ ko: "친구", en: "friend", pos: "noun" },
|
||||
],
|
||||
glossExtra: [["이야", "it is"]],
|
||||
sfx: [["쿵", "thud"]],
|
||||
},
|
||||
conj,
|
||||
);
|
||||
|
||||
it("strips a particle", () => {
|
||||
expect(lex.heads("닭이")).toEqual(["닭"]);
|
||||
expect(lex.heads("친구가")).toEqual(["친구"]);
|
||||
});
|
||||
|
||||
it("follows a generated form back to its stem", () => {
|
||||
expect(lex.heads("마셨어")).toEqual(["마셨어", "마시다"]);
|
||||
});
|
||||
|
||||
it("deconjugates a form nobody generated, but only onto a known verb", () => {
|
||||
expect(lex.heads("마시면")).toEqual(["마시다"]);
|
||||
});
|
||||
|
||||
it("returns nothing for a word it cannot place", () => {
|
||||
expect(lex.heads("없는말")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lookup — what the student sees on a tap", () => {
|
||||
const lex = buildLexicon(
|
||||
{ deck: [{ ko: "마시다", en: "to drink", pos: "verb" }, { ko: "친구", en: "friend", pos: "noun" }] },
|
||||
conj,
|
||||
);
|
||||
|
||||
it("notes the particle", () => {
|
||||
expect(lex.lookup("친구가")).toMatchObject({ ko: "친구가", gloss: "friend", note: "with 조사 가" });
|
||||
});
|
||||
|
||||
it("notes the dictionary form", () => {
|
||||
expect(lex.lookup("마시면")).toMatchObject({ gloss: "to drink", note: "a form of 마시다" });
|
||||
});
|
||||
|
||||
it("is null for an unknown word", () => {
|
||||
expect(lex.lookup("없는말")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Lexicon", () => {
|
||||
it("never replaces an existing entry — the first writer wins", () => {
|
||||
const lex = new Lexicon();
|
||||
lex.add("밥", "rice");
|
||||
lex.add("밥", "meal");
|
||||
expect(lex.get("밥")!.gloss).toBe("rice");
|
||||
});
|
||||
|
||||
it("strips the longer particle forms", () => {
|
||||
expect(PARTICLES).toEqual(expect.arrayContaining(["에서", "한테", "까지", "처럼"]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the shipped data", () => {
|
||||
const read = (f: string) => JSON.parse(readFileSync(new URL(`../../data/${f}`, import.meta.url), "utf8"));
|
||||
const curriculum = read("curriculum.json") as Curriculum;
|
||||
const deck = read("deck.json") as { topics: Record<string, [string, string, string, string][]> };
|
||||
const glossExtra = read("gloss-extra.json") as { entries: { ko: string; en: string; note?: string }[] };
|
||||
const sentences = read("sentences.json") as { sentences: { parts: [string, string][] }[] };
|
||||
const sfx = read("sfx.json") as { items: { ko: string; en: string }[] };
|
||||
|
||||
const units = flatten(curriculum);
|
||||
const lex = buildLexicon(
|
||||
{
|
||||
roadmapWords: units.flatMap((u) => u.words ?? []),
|
||||
deck: Object.values(deck.topics)
|
||||
.flat()
|
||||
.map(([ko, , en, pos]) => ({ ko, en, pos })),
|
||||
glossExtra: glossExtra.entries.map((g) => [g.ko, g.en, g.note] as const),
|
||||
sentences: sentences.sentences,
|
||||
sfx: sfx.items.map((i) => [i.ko, i.en] as const),
|
||||
},
|
||||
conj,
|
||||
);
|
||||
|
||||
it("resolves every roadmap word", () => {
|
||||
const missing = units.flatMap((u) => u.words ?? []).filter((w) => !lex.lookup(w));
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,13 @@ import {
|
||||
statusOf,
|
||||
preview,
|
||||
dayNumber,
|
||||
LEARNED_OK,
|
||||
LEARNED_STREAK,
|
||||
LEARNED_SPAN,
|
||||
newEvidence,
|
||||
noteOutcome,
|
||||
isLearned,
|
||||
acceptConfirmation,
|
||||
} from "@lib/srs.js";
|
||||
|
||||
const TODAY = 20_000;
|
||||
@@ -168,3 +175,74 @@ describe("dayNumber", () => {
|
||||
expect(c - b).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
/* Recall evidence decides whether a word is KNOWN; the schedule above only
|
||||
decides when to show it. The client refuses a ::confirmed it does not
|
||||
support, because the tutor certified words on a single correct answer. */
|
||||
describe("recall evidence", () => {
|
||||
const run = (steps: [outcome: "ok" | "wrong", round: number, lookedUp?: boolean][]) =>
|
||||
steps.reduce((e, [o, r, l]) => noteOutcome(e, o, r, l), newEvidence());
|
||||
|
||||
it("starts empty", () => {
|
||||
expect(newEvidence()).toEqual({
|
||||
ok: 0,
|
||||
wrong: 0,
|
||||
lookups: 0,
|
||||
streak: 0,
|
||||
firstRound: 0,
|
||||
lastRound: 0,
|
||||
lastSeen: 0,
|
||||
rounds: 0,
|
||||
});
|
||||
expect([LEARNED_OK, LEARNED_STREAK, LEARNED_SPAN]).toEqual([3, 2, 5]);
|
||||
});
|
||||
|
||||
it("is pure — the record passed in is not modified", () => {
|
||||
const e = newEvidence();
|
||||
noteOutcome(e, "ok", 1);
|
||||
expect(e.ok).toBe(0);
|
||||
});
|
||||
|
||||
it("never counts a lookup as recall, and a lookup resets the streak", () => {
|
||||
const e = run([
|
||||
["ok", 1],
|
||||
["ok", 2],
|
||||
["ok", 3, true],
|
||||
]);
|
||||
expect(e).toMatchObject({ ok: 2, lookups: 1, streak: 0, rounds: 3 });
|
||||
});
|
||||
|
||||
it("learns three corrects in three rounds spanning five", () => {
|
||||
const e = run([
|
||||
["ok", 1],
|
||||
["ok", 4],
|
||||
["ok", 7],
|
||||
]);
|
||||
expect(isLearned(e)).toBe(true);
|
||||
expect(acceptConfirmation(e)).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses three corrects crammed into consecutive rounds", () => {
|
||||
expect(isLearned(run([["ok", 1], ["ok", 2], ["ok", 3]]))).toBe(false);
|
||||
});
|
||||
|
||||
/* PINNED AS IT IS — lib is looser than PORT.md in two ways, and the app
|
||||
enforces PORT.md's version at the call site (domain/turn.ts):
|
||||
· several corrects in ONE round each count;
|
||||
· the span runs from the first outcome of any kind, not the first
|
||||
correct, so a wrong answer in round 1 lengthens it. */
|
||||
it("counts every correct within a round", () => {
|
||||
expect(run([["ok", 1], ["ok", 1]])).toMatchObject({ ok: 2, streak: 2, rounds: 1 });
|
||||
});
|
||||
|
||||
it("measures the span from the first outcome, correct or not", () => {
|
||||
const e = run([
|
||||
["wrong", 1],
|
||||
["ok", 4],
|
||||
["ok", 5],
|
||||
["ok", 6],
|
||||
]);
|
||||
expect(e).toMatchObject({ firstRound: 1, lastRound: 6 });
|
||||
expect(isLearned(e)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
96
test/lib/sync.test.ts
Normal file
96
test/lib/sync.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/* Golden tests pinning lib/sync.js — the three gates, as the artifact
|
||||
applies them to four whole documents. The port syncs rows instead and
|
||||
applies the same rules row by row; these pin the reference behaviour. */
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { weigh, isLater, reconcile, makeWriter } from "@lib/sync.js";
|
||||
import type { RemoteDoc } from "@lib/sync.js";
|
||||
|
||||
describe("weigh — how much a copy holds", () => {
|
||||
it("counts turns, finished units, cards and days", () => {
|
||||
expect(weigh("chat", { turns: [1, 2, 3] })).toBe(3);
|
||||
expect(weigh("meta", { road: { done: { "1.1": 1 } } })).toBe(1);
|
||||
expect(weigh("srs", { cards: { a: 1, b: 2 } })).toBe(2);
|
||||
expect(weigh("log", { days: {} })).toBe(0);
|
||||
expect(weigh("chat", null)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLater — a counter, not a clock", () => {
|
||||
it("compares counters when both sides have one, whatever the clocks say", () => {
|
||||
expect(isLater({ v: 3, u: 1 }, 2, 99)).toBe(true);
|
||||
expect(isLater({ v: 1, u: 999 }, 2, 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("breaks a counter tie on the stamp", () => {
|
||||
expect(isLater({ v: 2, u: 5 }, 2, 4)).toBe(true);
|
||||
expect(isLater({ v: 2, u: 4 }, 2, 4)).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the clock only when one side has no counter", () => {
|
||||
expect(isLater({ u: 5 }, 0, 4)).toBe(true);
|
||||
expect(isLater({ v: 9 }, 0, 4)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconcile — no silent shrinking", () => {
|
||||
const local = { version: 3, stamp: 100, data: { turns: [1, 2, 3] } };
|
||||
|
||||
it("reasserts ours when a later copy holds less and nobody said so", () => {
|
||||
expect(reconcile("chat", { v: 4, u: 200, d: { turns: [1] } }, local)).toBe("reassert");
|
||||
});
|
||||
|
||||
it("obeys a deliberate shrink", () => {
|
||||
expect(reconcile("chat", { v: 4, u: 200, d: { turns: [1] }, x: 1 }, local)).toBe("adopt");
|
||||
});
|
||||
|
||||
it("adopts a later copy that holds at least as much", () => {
|
||||
expect(reconcile("chat", { v: 4, u: 200, d: { turns: [1, 2, 3, 4] } }, local)).toBe("adopt");
|
||||
});
|
||||
|
||||
it("ignores an older copy, and a missing one", () => {
|
||||
expect(reconcile("chat", { v: 2, u: 900, d: { turns: [] } }, local)).toBe("ignore");
|
||||
expect(reconcile("chat", null, local)).toBe("ignore");
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeWriter — hydration", () => {
|
||||
function writer() {
|
||||
let t = 1000;
|
||||
const pushed: [string, RemoteDoc][] = [];
|
||||
const w = makeWriter({
|
||||
push: (name, body) => {
|
||||
pushed.push([name, body]);
|
||||
return "sent";
|
||||
},
|
||||
now: () => ++t,
|
||||
});
|
||||
return { w, pushed };
|
||||
}
|
||||
|
||||
it("holds an edit made before hydration, unstamped and unpushed", () => {
|
||||
const { w, pushed } = writer();
|
||||
expect(w.touch("chat")).toMatchObject({ version: 0, stamp: 0, dirty: true, hydrated: false });
|
||||
expect(w.flush("chat", { turns: [] })).toBeNull();
|
||||
expect(pushed).toEqual([]);
|
||||
});
|
||||
|
||||
it("stamps the held edit when hydration finds nothing newer, then pushes it", () => {
|
||||
const { w, pushed } = writer();
|
||||
w.touch("chat");
|
||||
expect(w.hydrate("chat")).toMatchObject({ version: 1, stamp: 1001, hydrated: true });
|
||||
expect(w.flush("chat", { turns: [1] })).toBe("sent");
|
||||
expect(pushed).toEqual([["chat", { u: 1001, v: 1, d: { turns: [1] } }]]);
|
||||
});
|
||||
|
||||
it("carries a deliberate shrink as x on exactly one push", () => {
|
||||
const { w, pushed } = writer();
|
||||
w.touch("meta", true);
|
||||
w.hydrate("meta");
|
||||
w.flush("meta", {});
|
||||
expect(pushed[0]![1]).toMatchObject({ x: 1 });
|
||||
w.touch("meta");
|
||||
w.flush("meta", {});
|
||||
expect(pushed[1]![1]).not.toHaveProperty("x");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user