Files
Hankan/test/domain/words.test.ts
MechaCat02 ab9b75ff32 feat(tutor): look any word up — the underline, the popover, the word list
The reworked artifact made every Korean word on the lesson screen a way in
to the dictionary, in three tiers. This ports them.

1. The underline. Every Korean run in a message, a gloss or an exercise is
   a button whose underline says what the word is to him: amber new, blue
   learning, jade in review, faint when secure or merely explainable,
   dotted when nothing knows it. A form carries its dictionary word's state
   — 갔어 is 가다's — through the one resolver the gate uses
   (domain/words.ts). The artifact gave "in review" no colour at all.

2. The popover, beside the word: the meaning, "+ Add to deck" (the
   dictionary entry, so 먹었어 adds 먹다), "Ask 선생님" for a word he was
   shown without being taught, a search, the word list. English on an
   exercise's English side opens its Korean, from an index of the course's
   own material — never the frequency bands, whose thousands of glosses
   would bury the answer. An answer chip's tap belongs to the exercise, so
   a 420ms hold glosses it, or a tap with 힌트 on; the hold's click is
   swallowed before the exercise sees it. Pressing a word never takes focus
   from the answer being typed, and the popover no longer closes when the
   window resizes — on a phone that was the keyboard moving.

3. The word list gains + / ✓ on every row, search results included.

Every lookup, whichever tier, goes into the answer's "I had to look up"
and the peek tally — counted under the Korean looked at; the artifact
counted an English lookup under the English word.

A dictionary word added this way is studied: a card on an entry outside
the curated sources now counts as a deck word, in review and in "my
units". Before, it got a card that no review would ever show.

The word list glosses through the same resolver as the popover and the
gate, rather than the surface table alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:59:48 +02:00

128 lines
5.4 KiB
TypeScript

/* Looking a word up: what its underline says, what adding it adds, and
English → 한국어 — against the shipped dictionary. */
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
import type { Db } from "@app/db/types.js";
import { editAddCustomWord, editCard } from "@app/db/writes.js";
import { wordInfo, wordToAdd } from "@app/domain/words.js";
import { buildEnIndex, enLookup, isLookupWord, loadEnglishIndex } from "@app/domain/english.js";
import { cardLemmaFor } from "@app/domain/evidence.js";
import { deck } from "@app/domain/cards.js";
import { SECURE_INTERVAL, newCard } from "@lib/srs.js";
import { dictionaryDb } from "../helpers/dict-db.js";
let db: Db;
beforeAll(async () => {
db = await dictionaryDb(1);
});
afterAll(async () => {
vi.unstubAllGlobals();
await db.close();
});
describe("the underline", () => {
it("marks a deck word he has not studied as new, and a secure one as known", async () => {
let info = (await wordInfo(db, ["바다"])).get("바다")!;
expect(info).toMatchObject({ state: "new", inDeck: true, head: "바다" });
expect(info.gloss?.gloss).toBeTruthy();
const id = (await cardLemmaFor(db, "바다"))!;
await editCard(db, id, { ...newCard(), state: 2, interval: SECURE_INTERVAL, due: 99_999, reps: 6 });
info = (await wordInfo(db, ["바다"])).get("바다")!;
expect(info.state).toBe("known");
});
it("gives a form its dictionary word's state", async () => {
const id = (await cardLemmaFor(db, "가다"))!;
await editCard(db, id, { ...newCard(), state: 1, due: 0, reps: 1 });
const info = (await wordInfo(db, ["갔어"])).get("갔어")!;
expect(info.state).toBe("learning");
expect(info.head).toBe("가다");
});
it("calls a word only the dictionary knows explainable, and nothing at all unknown", async () => {
const row = await db.get<{ headword: string }>(
`SELECT l.headword FROM lemma l
WHERE l.source NOT IN ('curated','grammar','sfx','curriculum','custom','sentence')
AND l.gloss_en <> '' AND l.headword NOT IN
(SELECT headword FROM lemma WHERE source IN ('curated','grammar','sfx','curriculum','custom'))
ORDER BY l.freq_rank LIMIT 1`,
);
const word = row!.headword;
const infos = await wordInfo(db, [word, "뷁뷁"]);
expect(infos.get(word)).toMatchObject({ state: "gloss", inDeck: false });
expect(infos.get("뷁뷁")).toMatchObject({ state: "unknown", inDeck: false, gloss: null });
});
});
describe("adding a looked-up word", () => {
it("adds the dictionary word, and it then counts as his — in the deck and underlined new", async () => {
const row = await db.get<{ headword: string }>(
`SELECT l.headword FROM lemma l
WHERE l.source NOT IN ('curated','grammar','sfx','curriculum','custom','sentence')
AND l.gloss_en <> '' AND l.headword NOT IN
(SELECT headword FROM lemma WHERE source IN ('curated','grammar','sfx','curriculum','custom'))
ORDER BY l.freq_rank LIMIT 1 OFFSET 3`,
);
const word = row!.headword;
const before = (await wordInfo(db, [word])).get(word)!;
const add = wordToAdd(before);
expect(add.headword).toBe(word);
await editAddCustomWord(db, add);
const after = (await wordInfo(db, [word])).get(word)!;
expect(after).toMatchObject({ state: "new", inDeck: true });
expect((await deck(db)).some((e) => e.headword === word)).toBe(true);
});
it("adds a form nothing knows as he met it", () => {
expect(
wordToAdd({ token: "뷁다", state: "unknown", gloss: null, head: null, inDeck: false, lemma: null }),
).toEqual({ headword: "뷁다", pos: "verb", gloss: "—" });
});
});
describe("English → 한국어", () => {
const index = buildEnIndex([
{ ko: "형", gloss: "older brother (said by a man)", note: "noun", pos: "noun", inDeck: true },
{ ko: "크다", gloss: "to be big", note: "adj", pos: "adj", inDeck: true },
{ ko: "단어", gloss: "word", note: "noun", pos: "noun", inDeck: true },
{ ko: "책상", gloss: "desk; table", note: "noun", pos: "noun", inDeck: true },
]);
it("tries the longest phrase around the word first", () => {
const words = "my older brother is tall".split(" ");
expect(enLookup(index, words, 2).map((e) => e.ko)).toEqual(["형"]);
});
it("finds a word inside a gloss, a clause of one, and a plural made singular", () => {
expect(enLookup(index, ["big"], 0).map((e) => e.ko)).toEqual(["크다"]);
expect(enLookup(index, ["table"], 0).map((e) => e.ko)).toEqual(["책상"]);
expect(enLookup(index, ["the", "words"], 1).map((e) => e.ko)).toEqual(["단어"]);
});
it("never offers the small words", () => {
expect(isLookupWord("the")).toBe(false);
expect(isLookupWord("a")).toBe(false);
expect(isLookupWord("table")).toBe(true);
});
it("is built from the course's own material, never the frequency bands", async () => {
const shipped = await loadEnglishIndex(db);
expect(enLookup(shipped, ["water"], 0).map((e) => e.ko)).toContain("물");
const course = new Set(
(
await db.all<{ headword: string }>(
`SELECT headword FROM lemma
WHERE source IN ('curated', 'grammar', 'sentence', 'sfx', 'curriculum', 'custom')`,
)
).map((r) => r.headword),
);
const indexed = new Set([...shipped.values()].flat().map((e) => e.ko));
expect(indexed.size).toBeGreaterThan(500);
expect([...indexed].filter((ko) => !course.has(ko))).toEqual([]);
});
});