feat(lib): type declarations and golden tests for the exported modules

lib/ ships unchanged, so its types live in types/lib/ and are wired up by a
tsconfig path mapping. Adding types costs nothing; reimplementing the logic
would cost the two things that make this port possible.

The tests exist so a later refactor cannot silently drift them:

  hangul       the nine Composer cases named in the export README —
               먹어 · 왔어 · 읽어 · 괜찮아 · 값 · 의사 · 뭐야 and backspace
  conjugation  all seven irregular classes, and every form in
               IRREGULAR_FORMS reachable through haeche()
  srs          the SM-2 transitions, ease and interval clamps
  blocks       parse → answerText round-trip for all four task types

101 tests.

One is a pinned defect rather than a guarantee. blocks.parse() never closes
a ::gloss block on its "=" line, so a multi-sentence gloss — which the tutor
prompt explicitly invites — collapses into one run-on line keeping only the
last translation. lib/ ships unchanged, so the test records the real
behaviour and the app works around it at the call site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-08 19:12:19 +02:00
parent b80deb2f6f
commit 966043ab3f
9 changed files with 931 additions and 0 deletions

80
types/lib/blocks.d.ts vendored Normal file
View File

@@ -0,0 +1,80 @@
/* Declarations for lib/blocks.js — the module itself ships unchanged. */
export interface WordEntry {
ko: string;
gloss: string;
note: string;
}
export interface TranslateTask {
type: "translate";
items: { q: string }[];
}
export interface MatchTask {
type: "match";
pairs: { ko: string; gloss: string }[];
}
export interface BuildTask {
type: "build";
/** chips are given in correct order; the UI shuffles them. */
items: { en: string; chips: string[] }[];
}
export interface ChoiceTask {
type: "choice";
items: { q: string; options: string[] }[];
}
export type Task = TranslateTask | MatchTask | BuildTask | ChoiceTask;
export type TaskType = Task["type"];
/** One uppercase letter; see ROLES. */
export type GlossRole = "S" | "T" | "O" | "V" | "P" | "C" | "Q" | "M" | "N";
export interface GlossPart {
ko: string;
role: GlossRole;
gloss: string;
/** The meaningful piece INSIDE the word — particle, tense marker, ending. */
highlight: string;
}
export interface GlossBlock {
parts: GlossPart[];
en: string;
}
export interface Progress {
score: number;
note: string;
}
export interface ParsedMessage {
body: string;
words: WordEntry[] | null;
task: Task | null;
gloss: GlossBlock[] | null;
progress: Progress | null;
}
export function parse(text: string): ParsedMessage;
/** Roles a gloss part can carry, and what the UI should do with each. */
export const ROLES: Record<GlossRole, string>;
/** State shapes accepted by answerText(), per task type. */
export type TranslateState = string[];
export type MatchState = { pairs: { ko: string; gloss: string }[] };
export type BuildState = string[][];
export type ChoiceState = (number | null)[];
export type TaskState = TranslateState | MatchState | BuildState | ChoiceState;
/** Turn a completed task back into the message the student sends.
The state shape follows the task type, so these are overloads rather
than one signature over a union. */
export function answerText(
task: TranslateTask,
state: TranslateState,
lookups?: string[],
): string;
export function answerText(task: MatchTask, state: MatchState, lookups?: string[]): string;
export function answerText(task: BuildTask, state: BuildState, lookups?: string[]): string;
export function answerText(task: ChoiceTask, state: ChoiceState, lookups?: string[]): string;
export function answerText(task: Task, state: TaskState, lookups?: string[]): string;

33
types/lib/conjugation.d.ts vendored Normal file
View File

@@ -0,0 +1,33 @@
/* Declarations for lib/conjugation.js — the module itself ships unchanged. */
/** Forms that do not fall out of the rules and are simply known. */
export const IRREGULAR_FORMS: Record<string, string>;
/** Dictionary form → 반말 present (해체). Returns null for non-verbs. */
export function haeche(dict: string): string | null;
/** 반말 present → 반말 past. 먹어 → 먹었어. */
export function past(present: string | null): string | null;
export function polite(present: string | null): string | null;
export type IrregularClass =
| "ㅡ" | "ㅂ" | "ㄷ" | "르" | "ㅅ" | "ㅎ" | "special" | "regular";
/** Which class a dictionary form belongs to — drives the "why" in feedback. */
export function irregularClass(dict: string): IrregularClass;
/** Human explanation of the rule applied — shown when an answer is wrong. */
export function explain(dict: string): string;
export interface SurfaceForm {
form: string;
gloss: string;
note: string;
}
/**
* Build-time: every surface form a learner will meet, mapped back to its lemma.
* This is what replaces a runtime morphological analyser.
*/
export function surfaceForms(dict: string, gloss: string): SurfaceForm[];

80
types/lib/gate.d.ts vendored Normal file
View File

@@ -0,0 +1,80 @@
/* Declarations for lib/gate.js — the module itself ships unchanged.
The gate is what stops material being taught out of order. buildGate()
computes it from curriculum + progress; renderGate() turns it into the
{{GATE}} section of prompt/tutor-system.md. */
export interface Unit {
id: string;
ko: string;
name: string;
goal: string;
vocabUnit: boolean;
teaches: string[];
avoid?: string[];
/** Strictly NEW vocabulary. Deliberate repeats live in revisits[]. */
words: string[];
revisits?: { word: string; from: string }[];
}
/** A Unit with its phase stamped on, as flatten() returns it. */
export interface FlatUnit extends Unit {
phase: number;
phaseKo: string;
phaseName: string;
}
export interface Phase {
phase: number;
ko: string;
name: string;
units: Unit[];
}
export interface Curriculum {
version: number;
note?: string;
phases: Phase[];
}
export interface ProgressState {
current: string;
done: Record<string, boolean>;
confidence?: Record<string, number>;
}
/**
* Replaces the hand-listed word set with a dictionary query. Returning a
* frequency band here is what turns 371 hand-typed words into something
* that scales.
*/
export type VocabQuery = (unit: FlatUnit, done: FlatUnit[]) => string[];
export interface GateOptions {
vocabQuery?: VocabQuery;
}
export interface Gate {
unit: FlatUnit;
phase: { n: number; ko: string; name: string };
taught: string[];
forbidden: { near: string[]; tailUnit: FlatUnit | null; count: number };
vocabulary: string[];
newWords: string[];
/** Words met earlier that this unit should deliberately bring back. */
revisits: string[];
confidence: number | null;
next: FlatUnit | null;
finished: string[];
}
export function flatten(curriculum: Curriculum): FlatUnit[];
export function buildGate(
curriculum: Curriculum,
progress: ProgressState,
opts?: GateOptions,
): Gate;
/** Render the gate into the system prompt section. Keep the headings. */
export function renderGate(g: Gate): string;

38
types/lib/hangul.d.ts vendored Normal file
View File

@@ -0,0 +1,38 @@
/* Declarations for lib/hangul.js — the module itself ships unchanged. */
export const CHO: string;
export const JUNG: string;
export const JONG: string;
/** [initialIndex, medialIndex, finalIndex], or null if not a syllable block. */
export function decompose(ch: string): [number, number, number] | null;
export function compose(i: number, m: number, f?: number): string;
export const isJamo: {
initial(c: string): boolean;
medial(c: string): boolean;
final(c: string): boolean;
};
/**
* A 두벌식 IME. Hold one per input: feed jamo with key(), literal text with
* text(), Backspace with back(). Each call returns the full new value.
*/
export class Composer {
cho: string | null;
jung: string | null;
jong: string | null;
reset(): void;
readonly empty: boolean;
/** The syllable currently being assembled, as text. */
render(): string;
key(value: string, jamo: string): string;
back(value: string): string;
/** Commit the buffer and append literal text (space, punctuation). */
text(value: string, t: string): string;
}
export const KEYBOARD: {
rows: string[][];
shift: Record<string, string>;
};

38
types/lib/srs.d.ts vendored Normal file
View File

@@ -0,0 +1,38 @@
/* Declarations for lib/srs.js — the module itself ships unchanged. */
export const AGAIN: 0;
export const HARD: 1;
export const GOOD: 2;
export const EASY: 3;
export type Grade = 0 | 1 | 2 | 3;
export const NEW: 0;
export const LEARNING: 1;
export const REVIEW: 2;
export type CardState = 0 | 1 | 2;
/** Days at which a card counts as known. */
export const SECURE_INTERVAL: number;
export interface Card {
state: CardState;
interval: number;
ease: number;
/** Day number, not a timestamp — see dayNumber(). */
due: number;
reps: number;
lapses: number;
}
export function newCard(): Card;
export function grade(card: Card, g: Grade, today: number): Card;
export function markKnown(today: number): Card;
export type CardStatus = "new" | "learning" | "review" | "secure";
export function statusOf(card: Card | null | undefined): CardStatus;
/** Label for the interval a grade would produce — shown on the buttons. */
export function preview(card: Card | null | undefined, g: Grade, today: number): string;
/** Local day number, DST-safe. */
export function dayNumber(d?: Date): number;