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>
This commit is contained in:
127
test/domain/words.test.ts
Normal file
127
test/domain/words.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/* 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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user