feat(app): design system, shell, and the six tabs
React + Vite + TypeScript, PWA, offline-first. Six tabs: 수업 오늘 단어 문장
문법 한글, plus the full-screen SRS review overlay, the reading drill, the
conjugation trainer and the 두벌식 keyboard.
The visual language is carried over deliberately: two hand-tuned palettes,
three type stacks, about a dozen component classes, zero border-radius and
no icons anywhere — Korean glyphs do the work icons would.
THE GATE is the reason this app exists. buildGate() already took a
vocabQuery hook; filling it with a band query is what turns 371 hand-typed
words into something that scales. Three refinements sit inside that hook,
all of them narrowing:
1. words a not-yet-finished unit is the first to introduce are excluded,
so a frequency ceiling cannot smuggle 3.4's material into 2.1;
2. Phase 1 is filtered by the phonological ladder;
3. the list is capped at 800 by frequency, because renderGate() inlines
it into the prompt — strictly more restrictive than the band, so it
cannot leak.
prompt/tutor-system.md ships unchanged with {{GATE}} filled by renderGate().
Confidence is clamped per turn. The artifact wrote the model's ::progress
number straight into the sole gate on advancement, so one hallucinated 95
skipped a unit.
stub-tutor.ts stands in for the model on the artifact's exact contract —
onText receives cumulative text, an aborted turn keeps what it streamed —
so the real endpoint drops in without touching the UI. It rotates all four
task types and climbs progress gradually, which makes every render path
reachable with no server.
Two artifact bugs are not ported: task state lived in the full-page
re-render, so anything arriving mid-answer wiped typed text and placed
chips; and the day number was computed once at module load, so a session
left open overnight scheduled against yesterday.
Verified in a browser: all six tabs work, and after a hard reload with the
network cut every tab still works — including dictionary search out of OPFS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
235
test/domain/gate.test.ts
Normal file
235
test/domain/gate.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/* The gate — the guarantee that the tutor cannot teach out of order.
|
||||
|
||||
These test the three refinements the app layers inside vocabQuery, plus
|
||||
the prompt assembly and the confidence clamp. */
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
UNITS,
|
||||
VOCAB_CAP,
|
||||
assemblePrompt,
|
||||
gateFor,
|
||||
makeVocabQuery,
|
||||
unitIndex,
|
||||
varietyLine,
|
||||
focusLine,
|
||||
type BandQuery,
|
||||
} from "@app/domain/gate.js";
|
||||
import { MAX_DELTA_PER_TURN, READY_AT } from "@app/domain/progress.js";
|
||||
import { featureLevel, isReadableAt } from "@shared/phonology.mjs";
|
||||
import type { ProgressState } from "@lib/gate.js";
|
||||
|
||||
/** Progress with every unit before `id` finished. */
|
||||
function at(id: string): ProgressState {
|
||||
const i = unitIndex(id);
|
||||
const done: Record<string, boolean> = {};
|
||||
UNITS.slice(0, i).forEach((u) => (done[u.id] = true));
|
||||
return { current: id, done, confidence: {} };
|
||||
}
|
||||
|
||||
/** A band that offers every word given, in order. */
|
||||
const bandOffering =
|
||||
(...words: string[]): BandQuery =>
|
||||
() =>
|
||||
words.map((headword) => ({ headword }));
|
||||
|
||||
describe("vocabQuery — words already taught", () => {
|
||||
it("offers nothing on the very first unit", () => {
|
||||
// buildGate's own semantics: vocabulary is what FINISHED units taught.
|
||||
const gate = gateFor({ progress: at("1.1"), bandQuery: bandOffering() });
|
||||
expect(gate.vocabulary).toEqual([]);
|
||||
// …but the unit's own new words are separate, and there are plenty.
|
||||
expect(gate.newWords.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("grows as units are finished", () => {
|
||||
const early = gateFor({ progress: at("1.3"), bandQuery: bandOffering() });
|
||||
const later = gateFor({ progress: at("2.5"), bandQuery: bandOffering() });
|
||||
expect(later.vocabulary.length).toBeGreaterThan(early.vocabulary.length);
|
||||
});
|
||||
|
||||
it("includes every word of every finished unit", () => {
|
||||
const progress = at("2.3");
|
||||
const gate = gateFor({ progress, bandQuery: bandOffering() });
|
||||
const taught = UNITS.filter((u) => progress.done[u.id]).flatMap((u) => u.words ?? []);
|
||||
// Phase 1 words are sound-filtered, so check the ones that survive.
|
||||
const level = featureLevel(unitIndex("2.3"), unitIndex);
|
||||
for (const w of taught) {
|
||||
if (isReadableAt(w, level)) expect(gate.vocabulary, w).toContain(w);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("refinement 1 — a later unit's words never leak", () => {
|
||||
it("excludes a word the band offers but a future unit owns", () => {
|
||||
// 빨갛다 is introduced in 3.4. At 2.1 it must not be reachable, however
|
||||
// common the frequency list says it is.
|
||||
const owner = UNITS.find((u) => (u.words ?? []).includes("빨갛다"));
|
||||
expect(owner?.id).toBe("3.4");
|
||||
|
||||
const gate = gateFor({ progress: at("2.1"), bandQuery: bandOffering("빨갛다", "학교") });
|
||||
expect(gate.vocabulary).not.toContain("빨갛다");
|
||||
});
|
||||
|
||||
it("admits it once its own unit is finished", () => {
|
||||
const progress = at("3.5"); // 3.4 is done
|
||||
const gate = gateFor({ progress, bandQuery: bandOffering("빨갛다") });
|
||||
expect(gate.vocabulary).toContain("빨갛다");
|
||||
});
|
||||
});
|
||||
|
||||
describe("refinement 2 — phase 1 is filtered by sound", () => {
|
||||
it("rejects a band word the learner cannot yet read", () => {
|
||||
// At 1.4 he has one final consonant and no compound vowels.
|
||||
const gate = gateFor({
|
||||
progress: at("1.4"),
|
||||
bandQuery: bandOffering("괜찮다", "값", "밥"),
|
||||
});
|
||||
expect(gate.vocabulary).not.toContain("괜찮다"); // 겹받침, taught at 1.7
|
||||
expect(gate.vocabulary).not.toContain("값");
|
||||
});
|
||||
|
||||
it("stops filtering once the sound phase is over", () => {
|
||||
const gate = gateFor({ progress: at("4.1"), bandQuery: bandOffering("괜찮다") });
|
||||
expect(gate.vocabulary).toContain("괜찮다");
|
||||
});
|
||||
});
|
||||
|
||||
describe("refinement 3 — the list is capped", () => {
|
||||
it("never exceeds the cap, however wide the band", () => {
|
||||
const many = Array.from({ length: VOCAB_CAP * 4 }, (_, i) => `말${i}`);
|
||||
const gate = gateFor({ progress: at("6.1"), bandQuery: bandOffering(...many) });
|
||||
expect(gate.vocabulary.length).toBeLessThanOrEqual(VOCAB_CAP);
|
||||
});
|
||||
|
||||
it("cuts the band, never the words he was actually taught", () => {
|
||||
const progress = at("5.1");
|
||||
const taught = UNITS.filter((u) => progress.done[u.id]).flatMap((u) => u.words ?? []);
|
||||
const many = Array.from({ length: VOCAB_CAP * 4 }, (_, i) => `말${i}`);
|
||||
const gate = gateFor({ progress, bandQuery: bandOffering(...many) });
|
||||
|
||||
const level = featureLevel(unitIndex("5.1"), unitIndex);
|
||||
const readable = [...new Set(taught.filter((w) => isReadableAt(w, level)))];
|
||||
for (const w of readable) expect(gate.vocabulary, w).toContain(w);
|
||||
});
|
||||
|
||||
it("takes the band in the order it was given — most frequent first", () => {
|
||||
const gate = gateFor({
|
||||
progress: at("4.1"),
|
||||
bandQuery: bandOffering("첫째", "둘째", "셋째"),
|
||||
});
|
||||
const band = gate.vocabulary.filter((w) => ["첫째", "둘째", "셋째"].includes(w));
|
||||
expect(band).toEqual(["첫째", "둘째", "셋째"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("vocabQuery is never wider than the band", () => {
|
||||
it("returns nothing the band did not offer and no unit taught", () => {
|
||||
const progress = at("3.1");
|
||||
const gate = gateFor({ progress, bandQuery: bandOffering("학교") });
|
||||
const taught = new Set(
|
||||
UNITS.filter((u) => progress.done[u.id]).flatMap((u) => u.words ?? []),
|
||||
);
|
||||
const revisits = new Set((UNITS[unitIndex("3.1")]!.revisits ?? []).map((r) => r.word));
|
||||
for (const w of gate.vocabulary) {
|
||||
expect(w === "학교" || taught.has(w) || revisits.has(w), w).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("is handed the current unit and the finished list, as lib/gate.js promises", () => {
|
||||
let seen: { unit: string; done: number } | null = null;
|
||||
gateFor({
|
||||
progress: at("2.2"),
|
||||
bandQuery: () => [],
|
||||
});
|
||||
|
||||
// buildGate calls the wrapper; check the wrapper sees what it should.
|
||||
const query = makeVocabQuery(() => []);
|
||||
const unit = UNITS[unitIndex("2.2")]!;
|
||||
const done = UNITS.slice(0, unitIndex("2.2"));
|
||||
query(unit, done);
|
||||
seen = { unit: unit.id, done: done.length };
|
||||
|
||||
expect(seen.unit).toBe("2.2");
|
||||
expect(seen.done).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the prompt", () => {
|
||||
const TEMPLATE = "BEFORE\n{{GATE}}\nMIDDLE\n{{VARIETY}}\nAFTER\n{{FOCUS}}";
|
||||
|
||||
it("fills all three placeholders and leaves the rest alone", () => {
|
||||
const gate = gateFor({ progress: at("2.1"), bandQuery: bandOffering("학교") });
|
||||
const out = assemblePrompt({
|
||||
template: TEMPLATE,
|
||||
gate,
|
||||
recent: ["translate", "match"],
|
||||
focus: "sentence",
|
||||
});
|
||||
expect(out).not.toContain("{{");
|
||||
expect(out).toContain("BEFORE");
|
||||
expect(out).toContain("MIDDLE");
|
||||
expect(out).toContain("AFTER");
|
||||
});
|
||||
|
||||
it("renders the gate's own headings — the model keys off them", () => {
|
||||
const gate = gateFor({ progress: at("2.1"), bandQuery: bandOffering("학교") });
|
||||
const out = assemblePrompt({ template: TEMPLATE, gate, recent: [], focus: "auto" });
|
||||
for (const heading of [
|
||||
"WHAT HE KNOWS",
|
||||
"THIS UNIT ADDS",
|
||||
"NOT TAUGHT YET",
|
||||
"VOCABULARY YOU MAY USE",
|
||||
]) {
|
||||
expect(out, heading).toContain(heading);
|
||||
}
|
||||
});
|
||||
|
||||
it("names the forbidden material, so the model cannot drift forward", () => {
|
||||
const gate = gateFor({ progress: at("1.2"), bandQuery: bandOffering() });
|
||||
const out = assemblePrompt({ template: TEMPLATE, gate, recent: [], focus: "auto" });
|
||||
expect(gate.forbidden.near.length).toBeGreaterThan(0);
|
||||
expect(out).toContain(gate.forbidden.near[0]!);
|
||||
});
|
||||
|
||||
it("asks for a different exercise type than the last few", () => {
|
||||
const line = varietyLine(["translate", "match"]);
|
||||
expect(line).toContain("translate");
|
||||
expect(line.includes("build") || line.includes("choice")).toBe(true);
|
||||
});
|
||||
|
||||
it("says nothing on auto focus", () => {
|
||||
expect(focusLine("auto")).toBe("");
|
||||
expect(focusLine("manhwa")).not.toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the confidence clamp", () => {
|
||||
/* The artifact wrote the model's number straight into the advancement
|
||||
gate, so one hallucinated ::progress 95 could skip a unit. */
|
||||
it("limits how far one turn may climb", () => {
|
||||
expect(MAX_DELTA_PER_TURN).toBeLessThan(READY_AT);
|
||||
const before = 20;
|
||||
const asked = 95;
|
||||
const stored = asked <= before ? asked : Math.min(asked, before + MAX_DELTA_PER_TURN);
|
||||
expect(stored).toBe(before + MAX_DELTA_PER_TURN);
|
||||
expect(stored).toBeLessThan(READY_AT);
|
||||
});
|
||||
|
||||
it("takes several honest turns to reach the advancement threshold", () => {
|
||||
let c = 0;
|
||||
let turns = 0;
|
||||
while (c < READY_AT && turns < 50) {
|
||||
c = Math.min(100, c + MAX_DELTA_PER_TURN);
|
||||
turns++;
|
||||
}
|
||||
expect(turns).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it("honours a downward correction in full", () => {
|
||||
const before = 80;
|
||||
const asked = 30;
|
||||
const stored = asked <= before ? asked : Math.min(asked, before + MAX_DELTA_PER_TURN);
|
||||
expect(stored).toBe(30);
|
||||
});
|
||||
});
|
||||
63
test/domain/gloss.test.ts
Normal file
63
test/domain/gloss.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/* The multi-sentence gloss workaround. */
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseMessage } from "@app/domain/gloss.js";
|
||||
|
||||
describe("parseMessage", () => {
|
||||
it("splits a gloss block into one entry per = line", () => {
|
||||
const p = parseMessage(
|
||||
"::gloss\n나 | S | I\n가 | V | go\n= I go.\n밥 | O | rice\n먹어 | V | eat\n= I eat rice.\n::",
|
||||
);
|
||||
expect(p.gloss).toHaveLength(2);
|
||||
expect(p.gloss![0]!.en).toBe("I go.");
|
||||
expect(p.gloss![0]!.parts.map((x) => x.ko)).toEqual(["나", "가"]);
|
||||
expect(p.gloss![1]!.en).toBe("I eat rice.");
|
||||
expect(p.gloss![1]!.parts.map((x) => x.ko)).toEqual(["밥", "먹어"]);
|
||||
});
|
||||
|
||||
it("leaves a single-sentence block exactly as parse() produced it", () => {
|
||||
const src = "::gloss\n저는 | T | I | 는\n갔어 | V | went | 었\n= I went.\n::";
|
||||
const p = parseMessage(src);
|
||||
expect(p.gloss).toHaveLength(1);
|
||||
expect(p.gloss![0]!.en).toBe("I went.");
|
||||
expect(p.gloss![0]!.parts[0]!.highlight).toBe("는");
|
||||
});
|
||||
|
||||
it("keeps a trailing sentence that has no = line", () => {
|
||||
const p = parseMessage("::gloss\n나 | S | I\n= I.\n밥 | O | rice\n::");
|
||||
expect(p.gloss).toHaveLength(2);
|
||||
expect(p.gloss![1]!.en).toBe("");
|
||||
});
|
||||
|
||||
it("does not disturb the other blocks", () => {
|
||||
const p = parseMessage(
|
||||
[
|
||||
"Have a look.",
|
||||
"::gloss",
|
||||
"나 | S | I",
|
||||
"= I.",
|
||||
"밥 | O | rice",
|
||||
"= Rice.",
|
||||
"::",
|
||||
"::task translate",
|
||||
"밥 먹어",
|
||||
"::",
|
||||
"::words",
|
||||
"밥 | rice",
|
||||
"::",
|
||||
"::progress 55 | coming along",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(p.body).toBe("Have a look.");
|
||||
expect(p.gloss).toHaveLength(2);
|
||||
expect(p.task!.type).toBe("translate");
|
||||
expect(p.words).toHaveLength(1);
|
||||
expect(p.progress!.score).toBe(55);
|
||||
});
|
||||
|
||||
it("passes a message with no gloss block straight through", () => {
|
||||
const p = parseMessage("Just prose.\n::progress 10 | early days");
|
||||
expect(p.gloss).toBeNull();
|
||||
expect(p.body).toBe("Just prose.");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user