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:
MechaCat02
2026-09-16 21:59:48 +02:00
parent 3ba5c293f1
commit ab9b75ff32
13 changed files with 1368 additions and 185 deletions

View File

@@ -53,8 +53,15 @@ function toCard(row: Record<string, unknown>): Card | null {
// every word a unit introduces is a card, because the recall evidence and
// the phase review both hang off one.
const REVIEWABLE = "('curated', 'sfx', 'grammar', 'custom', 'curriculum')";
const REVIEWABLE_SOURCES = new Set(["curated", "sfx", "grammar", "custom", "curriculum"]);
const SENTENCE_SOURCE = "('sentence')";
/* A card on any other dictionary entry is a word he added himself — from a
lesson's popover, or the add form when the word was already in the
dictionary. Its row stays the dictionary's, so the card is what says it
is his. */
const ADDED = "(c.lemma_id IS NOT NULL AND l.source <> 'sentence')";
export interface DeckOptions {
/** Mix glossed sentence chunks in, per the session preference. */
sentences?: boolean;
@@ -75,7 +82,8 @@ export const MY_UNITS_MIN = 20;
* them a word added from a lesson was never reviewed again.
*/
export function isMine(e: Pick<DeckEntry, "source" | "unitId">, p: ProgressState): boolean {
if (e.source === "custom") return true;
// His own words, and dictionary words he added (see ADDED).
if (e.source === "custom" || (!REVIEWABLE_SOURCES.has(e.source) && e.source !== "sentence")) return true;
if (!e.unitId) return false;
const at = unitIndex(e.unitId);
return at >= 0 && (Boolean(p.done[e.unitId]) || at <= unitIndex(p.current));
@@ -94,9 +102,8 @@ export function studyPool(entries: DeckEntry[], p: ProgressState): DeckEntry[] {
function sourceClause(opts: DeckOptions): string {
if (opts.only === "sentences") return `l.source IN ${SENTENCE_SOURCE}`;
return opts.sentences
? `l.source IN ${REVIEWABLE} OR l.source IN ${SENTENCE_SOURCE}`
: `l.source IN ${REVIEWABLE}`;
const words = `l.source IN ${REVIEWABLE} OR ${ADDED}`;
return opts.sentences ? `${words} OR l.source IN ${SENTENCE_SOURCE}` : words;
}
export async function deck(db: Db, opts: DeckOptions = {}): Promise<DeckEntry[]> {

138
app/src/domain/english.ts Normal file
View File

@@ -0,0 +1,138 @@
/* English → 한국어, for the English side of an exercise.
Building a sentence from "The cat is on the table" is exactly when he
needs to know what "table" is. The artifact indexed its whole lexicon on
the English: the gloss whole, each clause of it, and each content word,
so "cafe", "older brother" and "to be big" all resolve, and a tap tries
the longest phrase around the word first.
The index holds the deck and the course's own material only — curated
words, grammar, sentences, sound words, the curriculum's forms and his
own words. Never the frequency bands: thirty thousand dictionary glosses
would answer "table" with a dozen words he has never been taught. */
import type { Db } from "../db/types.js";
export interface EnEntry {
ko: string;
gloss: string;
note: string;
pos: string;
/** In his review deck — a sentence chunk is not. */
inDeck: boolean;
}
export type EnIndex = Map<string, EnEntry[]>;
/** The artifact's list: words that are never worth a lookup. */
const STOP = new Set([
"the", "a", "an", "to", "of", "in", "on", "at", "is", "are", "am", "be", "was", "were",
"it", "that", "this", "these", "those", "and", "or", "for", "with", "as", "by", "do", "does",
"did", "not", "no", "my", "your", "our", "their", "his", "her", "its", "i", "you", "he", "she",
"we", "they", "me", "him", "them", "one", "some", "any", "there", "here", "from", "up", "out",
"if", "so", "than", "then", "too", "very", "just", "about", "into", "over", "when", "what",
"who", "which", "how", "why", "has", "have", "had", "will", "would", "can", "could", "may",
"might", "said", "says", "say", "get", "got", "go", "goes", "s", "t", "re", "ll", "ve",
]);
const PER_KEY = 10;
const MAX_HITS = 8;
const clean = (k: string) =>
k
.toLowerCase()
.replace(/[^a-z0-9' -]/g, "")
.trim();
export function buildEnIndex(entries: EnEntry[]): EnIndex {
const index: EnIndex = new Map();
const add = (key: string, e: EnEntry) => {
const k = clean(key);
if (k.length < 2 || STOP.has(k)) return;
let list = index.get(k);
if (!list) index.set(k, (list = []));
if (list.length < PER_KEY && !list.some((x) => x.ko === e.ko)) list.push(e);
};
for (const e of entries) {
const gloss = e.gloss.toLowerCase();
if (!gloss) continue;
add(gloss, e);
for (const clause of gloss.split(/[;,/()·]|\s+—\s+|\s+-\s+/)) {
// "to be big" is "big"; the artifact's pattern tried "to" before
// "to be", which left "be big".
const part = clause.trim().replace(/^(to be|to|be)\s+/, "");
if (!part) continue;
add(part, e);
for (const w of part.split(/\s+/)) add(w, e);
}
}
return index;
}
/** Whether a word is worth underlining at all. */
export const isLookupWord = (word: string): boolean =>
word.length >= 2 && !STOP.has(word.toLowerCase());
/**
* What `words[at]` means in Korean: the longest phrase around it first —
* "older brother" before "brother" — then a lone plural made singular.
*/
export function enLookup(index: EnIndex, words: string[], at: number): EnEntry[] {
const phrase = (ws: string[]) =>
ws
.join(" ")
.toLowerCase()
.replace(/[^a-z0-9' ]/g, "")
.replace(/'s\b/g, "")
.trim();
const tries: string[] = [];
for (let len = 3; len >= 1; len--) {
for (let start = Math.max(0, at - len + 1); start <= at && start + len <= words.length; start++) {
tries.push(phrase(words.slice(start, start + len)));
}
}
for (const t of [...tries]) {
if (t.includes(" ") || t.length <= 3 || !t.endsWith("s") || t.endsWith("ss")) continue;
tries.push(t.slice(0, -1)); // words → word
if (t.endsWith("es")) tries.push(t.slice(0, -2)); // boxes → box
}
const seen = new Set<string>();
const out: EnEntry[] = [];
for (const t of tries) {
for (const e of index.get(t) ?? []) {
if (seen.has(e.ko)) continue;
seen.add(e.ko);
out.push(e);
}
if (out.length >= MAX_HITS) break;
}
return out.slice(0, MAX_HITS);
}
const NOTE: Record<string, string> = {
custom: "your own word",
sentence: "seen in a sentence",
sfx: "의성어 · 의태어",
};
export async function loadEnglishIndex(db: Db): Promise<EnIndex> {
const rows = await db.all<{ ko: string; gloss: string; pos: string; source: string }>(
`SELECT headword AS ko, gloss_en AS gloss, pos, source FROM lemma
WHERE source IN ('curated', 'grammar', 'sentence', 'sfx', 'curriculum', 'custom')
AND gloss_en <> ''
ORDER BY CASE source WHEN 'curated' THEN 0 WHEN 'custom' THEN 1 WHEN 'curriculum' THEN 2
WHEN 'grammar' THEN 3 WHEN 'sfx' THEN 4 ELSE 5 END`,
);
return buildEnIndex(
rows.map((r) => ({
ko: r.ko,
gloss: r.gloss,
note: NOTE[r.source] ?? r.pos,
pos: r.pos,
inDeck: r.source !== "sentence",
})),
);
}

View File

@@ -65,7 +65,7 @@ export function lexicon(): Lexicon {
const MAX_IN = 900; // Android's bound-parameter ceiling; see db/writes.ts.
/** Every dictionary headword each form is a surface of, in one pass. */
async function dictionaryHeads(db: Db, forms: string[]): Promise<Map<string, string[]>> {
export async function dictionaryHeads(db: Db, forms: string[]): Promise<Map<string, string[]>> {
const out = new Map<string, string[]>();
for (let i = 0; i < forms.length; i += MAX_IN) {
const batch = forms.slice(i, i + MAX_IN);

142
app/src/domain/words.ts Normal file
View File

@@ -0,0 +1,142 @@
/* What a Korean word on the screen is to him.
Every Korean word in a message, a gloss or an exercise is tappable, and
its underline says what it is — the first tier of looking a word up:
new in his deck, not studied yet
learning being learned
review in review
known secure
gloss explainable, but not a word in his deck
unknown nothing knows it: a tap offers a search
A form resolves through the one resolver (resolver.ts): 먹었어 is 먹다's,
so it carries 먹다's state. */
import type { WordEntry } from "@lib/blocks.js";
import { statusOf, type Card } from "@lib/srs.js";
import type { Db } from "../db/types.js";
import { dictionaryHeads, glossMany, lexicon, type Gloss } from "./resolver.js";
export type WordState = "new" | "learning" | "review" | "known" | "gloss" | "unknown";
export interface WordInfo {
token: string;
state: WordState;
gloss: Gloss | null;
/** The deck word it is, or is a form of. */
head: string | null;
/** A word he studies, or one the course will teach him. */
inDeck: boolean;
/** The dictionary entry it resolves to — what "add to deck" adds. */
lemma: { headword: string; pos: string; gloss: string } | null;
}
/** A card, or a word the review deck holds. See cards.ts. */
const DECK_SOURCES = "('curated', 'grammar', 'sfx', 'curriculum', 'custom')";
const MAX_IN = 900;
function batches<T>(items: T[]): T[][] {
const out: T[][] = [];
for (let i = 0; i < items.length; i += MAX_IN) out.push(items.slice(i, i + MAX_IN));
return out;
}
const place = (n: number) => Array.from({ length: n }, () => "?").join(",");
export async function wordInfo(
db: Db,
tokens: Iterable<string>,
declared: WordEntry[] | null = null,
): Promise<Map<string, WordInfo>> {
const forms = [...new Set(tokens)];
const out = new Map<string, WordInfo>();
if (!forms.length) return out;
const lex = lexicon();
const glosses = await glossMany(db, forms, declared);
// What each form could be: itself, then every word lexicon.js routes it
// to, then — only where lexicon.js has no route — the dictionary's.
const candidates = new Map<string, string[]>();
const unrouted: string[] = [];
for (const f of forms) {
const heads = lex.heads(f).filter((h) => h !== f);
candidates.set(f, [f, ...heads]);
if (!heads.length) unrouted.push(f);
}
if (unrouted.length) {
for (const [form, heads] of await dictionaryHeads(db, unrouted)) {
candidates.set(form, [form, ...heads.filter((h) => h !== form)]);
}
}
// The deck: the best card per headword, a studied card before an unstudied
// entry. Homographs share an underline — 눈 is one word on the screen.
const deck = new Map<string, Card | null>();
const words = [...new Set([...candidates.values()].flat())];
for (const batch of batches(words)) {
const rows = await db.all<Card & { headword: string; carded: number | null }>(
`SELECT l.headword, c.lemma_id AS carded, c.state, c.ease, c.interval, c.due, c.reps, c.lapses
FROM lemma l LEFT JOIN card c ON c.lemma_id = l.id
WHERE l.headword IN (${place(batch.length)})
AND (l.source IN ${DECK_SOURCES} OR c.lemma_id IS NOT NULL)
ORDER BY c.lemma_id IS NULL, c.state = 0, c.interval DESC`,
batch,
);
for (const r of rows) {
if (deck.has(r.headword)) continue;
deck.set(
r.headword,
r.carded == null
? null
: { state: r.state, ease: r.ease, interval: r.interval, due: r.due, reps: r.reps, lapses: r.lapses },
);
}
}
// The dictionary entry behind each form, for adding it.
const lemmas = new Map<string, WordInfo["lemma"]>();
for (const batch of batches(forms)) {
const rows = await db.all<{ form: string; headword: string; pos: string; gloss: string }>(
`SELECT s.form AS form, l.headword, l.pos, l.gloss_en AS gloss
FROM surface s JOIN lemma l ON l.id = s.lemma_id
WHERE s.form IN (${place(batch.length)}) AND l.gloss_en <> ''
ORDER BY CASE l.source WHEN 'curated' THEN 0 WHEN 'grammar' THEN 1 WHEN 'curriculum' THEN 2
WHEN 'custom' THEN 3 WHEN 'sfx' THEN 4 ELSE 5 END,
l.freq_rank IS NULL, l.freq_rank`,
batch,
);
for (const r of rows) {
if (!lemmas.has(r.form)) lemmas.set(r.form, { headword: r.headword, pos: r.pos, gloss: r.gloss });
}
}
for (const f of forms) {
const head = candidates.get(f)!.find((c) => deck.has(c)) ?? null;
const gloss = glosses.get(f) ?? null;
let state: WordState;
if (head !== null) {
const status = statusOf(deck.get(head));
state = status === "secure" ? "known" : status;
} else {
state = gloss?.gloss ? "gloss" : "unknown";
}
out.set(f, { token: f, state, gloss, head, inDeck: head !== null, lemma: lemmas.get(f) ?? null });
}
return out;
}
/**
* What adding a looked-up word adds: its dictionary entry when it has one —
* 먹었어 adds 먹다 — and otherwise the form as he met it.
*/
export function wordToAdd(info: WordInfo): { headword: string; pos: string; gloss: string } {
if (info.lemma) return info.lemma;
return {
headword: info.token,
pos: /다$/.test(info.token) ? "verb" : "noun",
gloss: info.gloss?.gloss || "—",
};
}

View File

@@ -8,18 +8,20 @@
import { Fragment } from "react";
import type { GlossBlock as Block, GlossPart, GlossRole } from "@lib/blocks.js";
import { ROLE_STYLES, isRole, legendFor } from "./roles.js";
import { KoText } from "./lookup.js";
import "./gloss.css";
/** Wrap the LAST occurrence of the highlight, which is where a suffix sits. */
function withHighlight(ko: string, highlight: string) {
if (!highlight) return ko;
const at = ko.lastIndexOf(highlight);
if (at < 0) return ko;
const at = highlight ? ko.lastIndexOf(highlight) : -1;
if (at < 0) return <KoText text={ko} />;
return (
<>
{ko.slice(0, at)}
<em>{highlight}</em>
{ko.slice(at + highlight.length)}
<KoText text={ko.slice(0, at)} />
<em>
<KoText text={highlight} />
</em>
<KoText text={ko.slice(at + highlight.length)} />
</>
);
}

View File

@@ -9,6 +9,7 @@
- a line opening with ✓ or ✗ is a marked answer, and gets the colour. */
import { Fragment } from "react";
import { KoText } from "./lookup.js";
const KOREAN = /[가-힣]/g;
const PUNCT = /[\s.,!?·…"'“”()[\]:;~-]/g;
@@ -32,14 +33,27 @@ function isKoreanLine(line: string): boolean {
learn:*" kept its asterisks, and a "---" divider showed as three dashes.
Degrading gracefully costs little and keeps a lesson readable. */
/** **bold**, and *italic* as a concession to models that use it. */
/** **bold**, and *italic* as a concession to models that use it. Korean
in any of it can be looked up. */
function inline(text: string) {
return text.split(/(\*\*[^*]+\*\*|\*[^*\n]+\*)/g).map((part, i) => {
if (part.startsWith("**") && part.endsWith("**") && part.length > 4)
return <strong key={i}>{part.slice(2, -2)}</strong>;
return (
<strong key={i}>
<KoText text={part.slice(2, -2)} />
</strong>
);
if (part.startsWith("*") && part.endsWith("*") && part.length > 2)
return <em key={i}>{part.slice(1, -1)}</em>;
return <Fragment key={i}>{part}</Fragment>;
return (
<em key={i}>
<KoText text={part.slice(1, -1)} />
</em>
);
return (
<Fragment key={i}>
<KoText text={part} />
</Fragment>
);
});
}

View File

@@ -23,6 +23,7 @@ import { answerText } from "@lib/blocks.js";
import { recallLetterBlock } from "../../domain/letters.js";
import { useComposer } from "../keyboard/Keyboard.js";
import { useFields, type KeyField } from "./fields.js";
import { EnText, KoText, useChipGloss } from "./lookup.js";
import "./task.css";
/* A shuffle seed from the turn's id — a string now, see db/ids.ts. */
@@ -155,7 +156,9 @@ function Translate({ task, turnId, answers, update, disabled, onEnter }: FieldsP
const id = `${turnId}:${i}`;
return (
<div className="ti-row" key={i} data-active={answering && activeId === id ? "1" : undefined}>
<span className="q ko">{it.q}</span>
<span className="q ko">
<KoText text={it.q} />
</span>
<AnswerField
id={id}
script="en"
@@ -186,7 +189,7 @@ function Recall({ task, turnId, answers, update, disabled, onEnter }: FieldsProp
return (
<div className="ti-row recall" key={i} data-active={answering && activeId === id ? "1" : undefined}>
<span className="q">
{it.q}
<EnText text={it.q} />
{it.hint && <span className="rc-hint"> · {it.hint}</span>}
</span>
<AnswerField
@@ -278,7 +281,10 @@ function Match({ task, turnId, done, setDone, selected, setSelected, disabled }:
<div className="mt-pairs">
{done.map((p, i) => (
<span className="mt-pair" key={`${p.left}-${p.right}`}>
<span className="ko">{task.pairs[p.left]!.ko}</span> = {task.pairs[p.right]!.gloss}
<span className="ko">
<KoText text={task.pairs[p.left]!.ko} />
</span>{" "}
= <EnText text={task.pairs[p.right]!.gloss} />
<button
aria-label={`Undo ${task.pairs[p.left]!.ko}`}
disabled={disabled}
@@ -338,7 +344,9 @@ function Build({ task, turnId, placed, setPlaced, disabled }: {
<div className="bd">
{task.items.map((it, i) => (
<div className="bd-item" key={i}>
<div className="bd-en">{it.en}</div>
<div className="bd-en">
<EnText text={it.en} />
</div>
<div
className="bd-slot"
onDragOver={(e) => e.preventDefault()}
@@ -396,7 +404,9 @@ function Choice({ task, picks, setPicks, disabled }: {
<div className="ch">
{task.items.map((it, i) => (
<div className="ch-item" key={i}>
<div className="ch-q ko">{it.q}</div>
<div className="ch-q ko">
<EnText text={it.q} />
</div>
<div className="ch-opts">
{it.options.map((opt, j) => (
<button
@@ -440,6 +450,10 @@ export function TaskHost({
spent a turn — and a round of the tutor's attention — on saying no. */
const [skipped, setSkipped] = useState(false);
const root = useRef<HTMLDivElement>(null);
/** 힌트: a tap on a chip glosses it instead of answering. */
const [hint, setHint] = useState(false);
const chips = useChipGloss(hint);
const holdable = task.type === "match" || task.type === "build" || task.type === "choice";
const update = (i: number, fn: (prev: string) => string) =>
setAnswers((prev) => {
@@ -520,10 +534,20 @@ export function TaskHost({
}
return (
<div className="task" data-spent={spent} ref={root}>
<div className="task" data-spent={spent} ref={root} {...(holdable && open ? chips.root : {})}>
<div className="task-h">
<span className="eyebrow"> · {task.type}</span>
<span className="hint">{LABEL[task.type]}</span>
{holdable && open && (
<button
className="hintbtn ko"
aria-pressed={hint}
title="Tap a chip to see what it means — or hold it"
onClick={() => setHint((h) => !h)}
>
{hint ? "힌트 켜짐" : "힌트"}
</button>
)}
</div>
<div className="task-b">

View File

@@ -21,7 +21,15 @@
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import { useStore } from "../../state/store.js";
import { editChatClear, editChatRemove, editChatTurn, pruneChat, seedChatTurn } from "../../db/writes.js";
import {
editAddCustomWord,
editChatClear,
editChatRemove,
editChatTurn,
editPeek,
pruneChat,
seedChatTurn,
} from "../../db/writes.js";
import { parseMessage } from "../../domain/gloss.js";
import {
gateFor,
@@ -36,7 +44,9 @@ import type { FocusMode } from "../../domain/gate.js";
import { currentUnit, noteAnswer } from "../../domain/progress.js";
import { makeStubTutor, SampleError, type Sample, type StubWord } from "../../domain/stub-tutor.js";
import { makeRemoteTutor } from "../../domain/tutor-client.js";
import { lookupMany } from "../../domain/lexicon.js";
import { koreanTokens, lookupMany } from "../../domain/lexicon.js";
import { wordInfo, wordToAdd, type WordInfo } from "../../domain/words.js";
import { enLookup, isLookupWord, loadEnglishIndex, type EnIndex } from "../../domain/english.js";
import { applyReply, readRecent, runTurn } from "../../domain/turn.js";
import { coverage } from "../../domain/ledger.js";
import type { Retry } from "../../domain/prompt-tail.js";
@@ -52,6 +62,8 @@ import { WordSheet, type Detent } from "./WordSheet.js";
import { RoadStrip } from "./RoadStrip.js";
import { AnswerBar, Composer } from "./Composer.js";
import { FieldsContext, liveAnswerFields, type Fields, type KeyField } from "./fields.js";
import { LookupContext, type Lookup } from "./lookup.js";
import { EnPop, KoPop, type PopTarget } from "./WordPop.js";
import { Keyboard } from "../keyboard/Keyboard.js";
import { RouteHead, useRouteActive } from "../shell/Route.js";
import { useLayer } from "../shell/router.js";
@@ -171,6 +183,14 @@ export function TutorTab() {
/** Flagged words per turn id, so the flag stays under its own message. */
const [flags, setFlags] = useState<Record<string, string[]>>({});
const [roadOpen, setRoadOpen] = useState(false);
/** The word popover, and what the word it is open on is to him. */
const [pop, setPop] = useState<PopTarget | null>(null);
const [popInfo, setPopInfo] = useState<WordInfo | null>(null);
/** Every Korean word in the transcript, for its underline. */
const [words, setWords] = useState<Map<string, WordInfo>>(new Map());
const [enIndex, setEnIndex] = useState<EnIndex | null>(null);
/** Added from an English popover this session, for its ✓. */
const [addedEn, setAddedEn] = useState<Set<string>>(new Set());
const [menuOpen, setMenuOpen] = useState(false);
const [detent, setDetent] = useState<Detent>("closed");
@@ -556,6 +576,109 @@ export function TutorTab() {
[turns],
);
/* The first tier: what every Korean word on the screen is to him. Read
again when the transcript changes, and whenever cards may have. */
useEffect(() => {
let cancelled = false;
const tokens = koreanTokens(turns.map((t) => t.body).join("\n"));
const declared = parsedTurns.flatMap(({ parsed }) => parsed?.words ?? []);
void wordInfo(db, tokens, declared).then((m) => {
if (!cancelled) setWords(m);
});
return () => {
cancelled = true;
};
}, [db, parsedTurns, revision, turns]);
useEffect(() => {
let cancelled = false;
void loadEnglishIndex(db).then((index) => {
if (!cancelled) setEnIndex(index);
});
return () => {
cancelled = true;
};
}, [db, revision]);
/* The second tier. Any lookup is a lookup — for the answer's "I had to
look up" and for the peek tally, which counts the Korean he looked at:
the artifact counted an English lookup under the English word. */
const lookedUp = useCallback(
(kos: string[]) => {
setRevealed((r) => new Set([...r, ...kos]));
for (const ko of kos) void editPeek(db, ko);
},
[db],
);
const openKo = useCallback(
(anchor: HTMLElement, token: string) => {
setPop({ kind: "ko", token, anchor });
lookedUp([token]);
},
[lookedUp],
);
const openEn = useCallback(
(anchor: HTMLElement, word: string, list: string[], at: number) => {
const hits = enIndex ? enLookup(enIndex, list, at) : [];
if (!hits.length) return;
setPop({ kind: "en", word, hits, anchor });
lookedUp(hits.map((h) => h.ko));
},
[enIndex, lookedUp],
);
const lookup = useMemo<Lookup>(
() => ({
stateOf: (token) => words.get(token)?.state,
hasEnglish: (list, at) =>
enIndex !== null && isLookupWord(list[at] ?? "") && enLookup(enIndex, list, at).length > 0,
openKo,
openEn,
}),
[enIndex, openEn, openKo, words],
);
useEffect(() => {
if (pop?.kind !== "ko") {
setPopInfo(null);
return;
}
const known = words.get(pop.token);
if (known) {
setPopInfo(known);
return;
}
// A chip or a word the transcript's scan did not reach.
let cancelled = false;
setPopInfo(null);
void wordInfo(db, [pop.token]).then((m) => {
if (!cancelled) setPopInfo(m.get(pop.token) ?? null);
});
return () => {
cancelled = true;
};
}, [db, pop, words]);
// The word it is open on stays highlighted while it is.
useEffect(() => {
const el = pop?.anchor;
if (!el) return;
el.dataset.open = "1";
return () => {
delete el.dataset.open;
};
}, [pop]);
const addWord = useCallback(
async (word: { headword: string; pos: string; gloss: string }) => {
await editAddCustomWord(db, word);
invalidate();
},
[db, invalidate],
);
/* parseMessage() re-parsed the entire partial reply on every token, and
on every unrelated re-render of the lesson. */
const streamingBody = useMemo(() => {
@@ -717,6 +840,16 @@ export function TutorTab() {
setDetent("half");
};
/** The popover's "Word list": the column is already there on a wide screen. */
const openList = () => {
if (wide) return;
if (answering) {
if (detent === "closed") toggleWords();
return;
}
setDetent((d) => (d === "closed" || d === "peek" ? "half" : d));
};
useEffect(() => {
if (detent !== "closed" || !keepAnswering.current) return;
const field = resumeField.current;
@@ -770,6 +903,8 @@ export function TutorTab() {
onReveal: reveal,
query: railQuery,
onQuery: setRailQuery,
infoOf: (ko: string) => words.get(ko),
onAdd: addWord,
};
return (
@@ -856,6 +991,54 @@ export function TutorTab() {
</div>
</Pop>
<Pop
id="word"
open={pop !== null && active}
onClose={() => setPop(null)}
anchor={pop?.anchor ?? null}
label={pop?.kind === "en" ? "English to Korean" : "Word"}
>
{pop?.kind === "ko" && (
<KoPop
token={pop.token}
info={popInfo}
stray={strays.includes(pop.token)}
onAdd={() => {
if (popInfo) void addWord(wordToAdd(popInfo));
setPop(null);
}}
onAsk={() => {
setPop(null);
void send(
`You used ${pop.token} in that exercise, but you never taught it and it is not in my word list. What does it mean — and was it meant to be there at all?`,
);
}}
onFind={() => {
setRailQuery(pop.token);
setPop(null);
openList();
}}
onList={() => {
setRailQuery("");
setPop(null);
openList();
}}
/>
)}
{pop?.kind === "en" && (
<EnPop
word={pop.word}
hits={pop.hits}
added={addedEn}
onAdd={(e) => {
setAddedEn((a) => new Set(a).add(e.ko));
void addWord({ headword: e.ko, pos: e.pos, gloss: e.gloss });
}}
/>
)}
</Pop>
<LookupContext.Provider value={lookup}>
<FieldsContext.Provider value={fields}>
<div className="lesson-wrap">
<div
@@ -1017,6 +1200,7 @@ export function TutorTab() {
)}
</div>
</FieldsContext.Provider>
</LookupContext.Provider>
{!wide && active && (
<WordSheet detent={detent} onDetent={setDetent}>

View File

@@ -0,0 +1,121 @@
/* The popover: one word, what it means, and what to do about it. */
import type { WordInfo } from "../../domain/words.js";
import type { EnEntry } from "../../domain/english.js";
export type PopTarget =
| { kind: "ko"; token: string; anchor: HTMLElement }
| { kind: "en"; word: string; hits: EnEntry[]; anchor: HTMLElement };
/** Its buttons must not take focus from an answer being typed either. */
const keepFocus = (e: { preventDefault: () => void }) => e.preventDefault();
export function KoPop({
token,
info,
stray,
onAdd,
onAsk,
onFind,
onList,
}: {
token: string;
/** Null while it is being looked up. */
info: WordInfo | null;
/** 선생님 used it in the last exercise without having taught it. */
stray: boolean;
onAdd: () => void;
onAsk: () => void;
onFind: () => void;
onList: () => void;
}) {
const gloss = info?.gloss?.gloss;
const notes = [
info?.gloss?.note,
info?.head && info.head !== token && !info.gloss?.note.includes(info.head) ? `a form of ${info.head}` : "",
].filter(Boolean);
return (
<div onPointerDown={keepFocus}>
<div className="pop-b">
<span className="pop-k ko">{token}</span>
{gloss ? (
<span className="pop-m">{gloss}</span>
) : (
<span className="pop-m dim">
{!info
? "…"
: stray
? "선생님 used this without teaching it — ask him"
: "not in the word list yet"}
</span>
)}
{notes.length > 0 && <span className="pop-n">{notes.join(" · ")}</span>}
</div>
<div className="pop-f">
{gloss ? (
<button className="btn" disabled={!info || info.inDeck} onClick={onAdd}>
{info?.inDeck ? "✓ in your deck" : "+ Add to deck"}
</button>
) : stray ? (
<button className="btn" onClick={onAsk}>
Ask
</button>
) : (
<button className="btn" disabled={!info} onClick={onFind}>
Search <span className="ko"></span>
</button>
)}
<button className="btn" onClick={onList}>
Word list
</button>
</div>
</div>
);
}
export function EnPop({
word,
hits,
added,
onAdd,
}: {
word: string;
hits: EnEntry[];
/** Added from this popover just now. */
added: Set<string>;
onAdd: (e: EnEntry) => void;
}) {
return (
<div onPointerDown={keepFocus}>
<div className="pop-b">
<span className="pop-n">English </span>
<span className="pop-en">{word}</span>
</div>
<div className="pop-list">
{hits.map((e) => {
const inDeck = e.inDeck || added.has(e.ko);
return (
<div className="pop-row" key={e.ko}>
<span className="k ko">{e.ko}</span>
<span className="m">
{e.gloss}
{e.note && <i>{e.note}</i>}
</span>
<button
className="wr-add"
data-in={inDeck ? "1" : undefined}
disabled={inDeck}
aria-label={inDeck ? `${e.ko} is in your deck` : `Add ${e.ko} to your deck`}
title={inDeck ? "in your deck" : "add to your deck"}
onClick={() => onAdd(e)}
>
{inDeck ? "✓" : "+"}
</button>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -23,8 +23,10 @@ import { useEffect, useState, type ReactNode } from "react";
import type { WordEntry } from "@lib/blocks.js";
import { useStore } from "../../state/store.js";
import { editPeek } from "../../db/writes.js";
import { koreanTokens, lookupMany, search, type Entry } from "../../domain/lexicon.js";
import { koreanTokens, search, type Entry } from "../../domain/lexicon.js";
import { ensureReferenceBand } from "../../domain/dictionary.js";
import { glossMany } from "../../domain/resolver.js";
import type { WordInfo } from "../../domain/words.js";
import "./rail.css";
export interface RailWord {
@@ -40,8 +42,8 @@ const SEARCH_LIMIT = 60;
/**
* Merge the tutor's ::words block with a scan of the message. Tutor entries
* win; scanned tokens the dictionary cannot gloss are dropped rather than
* shown blank.
* win; scanned tokens are glossed by the one resolver the popover and the
* gate use, and those it cannot gloss are dropped rather than shown blank.
*/
export async function collectWords(
db: ReturnType<typeof useStore>["db"],
@@ -59,17 +61,12 @@ export async function collectWords(
const tokens = koreanTokens(text).filter((t) => !seen.has(t));
if (tokens.length) {
const found = await lookupMany(db, tokens);
const found = await glossMany(db, tokens);
for (const t of tokens) {
const hit = found.get(t);
if (!hit || seen.has(t)) continue;
if (!hit?.gloss || seen.has(t)) continue;
seen.add(t);
out.push({
ko: t,
gloss: hit.glossEn,
note: hit.analysis && hit.headword !== t ? hit.analysis : "",
fromTutor: false,
});
out.push({ ko: t, gloss: hit.gloss, note: hit.note, fromTutor: false });
}
}
@@ -88,11 +85,48 @@ export interface RailPanelProps {
titleId?: string;
/** Buttons at the end of the heading row — the sheet's expand and close. */
actions?: ReactNode;
/** What a word of the lesson is to him — whether it is in his deck. */
infoOf: (ko: string) => WordInfo | undefined;
onAdd: (word: { headword: string; pos: string; gloss: string }) => void;
}
export function RailPanel({ words, revealed, onReveal, query, onQuery, titleId, actions }: RailPanelProps) {
const DECK_SOURCES = new Set(["curated", "grammar", "sfx", "curriculum", "custom"]);
/** + adds the word to his deck; ✓ says it is there. */
function AddButton({ ko, inDeck, onAdd }: { ko: string; inDeck: boolean; onAdd: () => void }) {
return (
<button
className="wr-add"
data-in={inDeck ? "1" : undefined}
disabled={inDeck}
title={inDeck ? "in your deck" : "add to your deck"}
aria-label={inDeck ? `${ko} is in your deck` : `Add ${ko} to your deck`}
onClick={onAdd}
>
{inDeck ? "✓" : "+"}
</button>
);
}
export function RailPanel({
words,
revealed,
onReveal,
query,
onQuery,
titleId,
actions,
infoOf,
onAdd,
}: RailPanelProps) {
const { db, prefs, setPref } = useStore();
const [results, setResults] = useState<Entry[]>([]);
/** Added from this list just now: a search row cannot tell otherwise. */
const [added, setAdded] = useState<Set<string>>(new Set());
const add = (word: { headword: string; pos: string; gloss: string }) => {
setAdded((a) => new Set(a).add(word.headword));
onAdd(word);
};
const searching = query.trim().length > 0;
useEffect(() => {
@@ -169,6 +203,11 @@ export function RailPanel({ words, revealed, onReveal, query, onQuery, titleId,
{r.glossEn}
<i>{r.pos}</i>
</span>
<AddButton
ko={r.headword}
inDeck={DECK_SOURCES.has(r.source) || added.has(r.headword)}
onAdd={() => add({ headword: r.headword, pos: r.pos, gloss: r.glossEn })}
/>
</div>
))
) : (
@@ -199,6 +238,20 @@ export function RailPanel({ words, revealed, onReveal, query, onQuery, titleId,
{w.note && <i>{w.note}</i>}
</span>
)}
<AddButton
ko={w.ko}
inDeck={Boolean(infoOf(w.ko)?.inDeck) || added.has(w.ko)}
onAdd={() => {
const info = infoOf(w.ko);
add(
info?.lemma ?? {
headword: w.ko,
pos: /다$/.test(w.ko) ? "verb" : "noun",
gloss: w.gloss,
},
);
}}
/>
</div>
);
})

203
app/src/ui/tutor/lookup.css Normal file
View File

@@ -0,0 +1,203 @@
/* Looking a word up: the underlines, the popover's lists, the add button,
and 힌트. */
/* text-decoration follows the font's own metrics. An inset box-shadow sat
on top of ㄲ and ㄹ descenders and made them hard to read. */
.kw,
.kw-en {
padding: 0 1px;
border: none;
border-radius: 2px;
background: none;
font: inherit;
color: inherit;
letter-spacing: inherit;
cursor: pointer;
text-decoration-line: underline;
-webkit-tap-highlight-color: transparent;
}
.kw {
text-decoration-thickness: 2px;
text-underline-offset: 0.24em;
text-decoration-skip-ink: none;
text-decoration-color: var(--line2);
}
.kw[data-s="new"] {
text-decoration-color: var(--hwang);
}
.kw[data-s="learning"] {
text-decoration-color: var(--r-sub);
}
/* The artifact had no colour for a word in review: it fell back to the
quiet grey of a word he does not study. */
.kw[data-s="review"] {
text-decoration-color: var(--jade);
}
/* Secure, or explainable but not his: present, but quiet. */
.kw[data-s="known"],
.kw[data-s="gloss"] {
text-decoration-thickness: 1px;
text-decoration-color: var(--line2);
}
/* Nothing knows it: the tap offers a search. */
.kw[data-s="unknown"] {
text-decoration-style: dotted;
text-decoration-thickness: 1px;
}
/* In a gloss block the role already carries an underline and the meaning
is printed beneath, so a state marker would be a second, competing line.
Still tappable, just not marked twice. */
.gloss .kw {
text-decoration: none;
}
.kw-en {
text-decoration-style: dotted;
text-decoration-thickness: 1.5px;
text-decoration-color: var(--ink3);
text-underline-offset: 0.2em;
}
.kw:active,
.kw[data-open="1"],
.kw-en:active,
.kw-en[data-open="1"] {
background: var(--jade-soft);
}
/* ── the popover's content ───────────────────────────────────────── */
.pop-m.dim {
color: var(--ink3);
}
.pop-en {
font-size: 20px;
font-weight: 600;
}
.pop-list {
max-height: 44vh;
overflow-y: auto;
overscroll-behavior: contain;
border-top: 1px solid var(--line);
}
.pop-row {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 14px;
border-bottom: 1px solid var(--line);
}
.pop-row:last-child {
border-bottom: none;
}
.pop-row .k {
min-width: 64px;
font-size: 19px;
font-weight: 500;
word-break: keep-all;
}
.pop-row .m {
flex: 1;
min-width: 0;
font-size: 13.5px;
}
.pop-row .m i {
display: block;
font-style: normal;
font-size: 11.5px;
color: var(--ink3);
}
/* ── add to the deck ─────────────────────────────────────────────── */
.wr-add {
position: relative;
width: 32px;
height: 32px;
flex: none;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--line2);
border-radius: 8px;
font-size: 14px;
color: var(--ink3);
}
/* A 44px target. */
.wr-add::before {
content: "";
position: absolute;
inset: -6px;
}
.wr-add:hover:not(:disabled) {
border-color: var(--jade);
color: var(--jade);
}
.wr-add[data-in="1"] {
border-color: var(--jade);
background: var(--jade-soft);
color: var(--jade-ink);
cursor: default;
}
/* ── 힌트 ────────────────────────────────────────────────────────── */
.hintbtn {
flex: none;
min-height: 30px;
padding: 4px 11px;
border: 1px solid var(--line2);
border-radius: 14px;
background: var(--raise);
font-size: 12px;
color: var(--ink2);
}
.hintbtn[aria-pressed="true"] {
border-color: var(--hwang);
background: var(--hwang);
color: #fff;
}
.task-h .hint + .hintbtn {
margin-left: 8px;
}
.task-h .hintbtn:first-of-type {
margin-left: auto;
}
@media (max-width: 420px) {
.task-h .hint {
display: none;
}
.task-h .hint + .hintbtn {
margin-left: auto;
}
}
/* A chip being held should not select its text or open a callout. */
.mt-chip,
.chip-w,
.ch-opts button {
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}

168
app/src/ui/tutor/lookup.tsx Normal file
View File

@@ -0,0 +1,168 @@
/* Looking a word up, in three tiers.
1. Every Korean word in a message, a gloss or an exercise is a button,
and its underline says what it is to him (domain/words.ts) — ambient,
at no cost in space.
2. Tapping one opens a popover beside it. English on an exercise's
English side opens its Korean. An answer chip's tap belongs to the
exercise, so a press-and-hold glosses the chip instead — or a single
tap, with 힌트 switched on.
3. The whole word list, in the sheet or the docked column.
Every lookup is a lookup: it goes into the answer's "I had to look up"
and the peek tally, whichever tier it came through. Pressing a word never
takes focus from the answer being typed — that would close the keyboard
under his finger. */
import {
createContext,
useContext,
useRef,
type MouseEvent,
type PointerEvent,
type ReactNode,
} from "react";
import type { WordState } from "../../domain/words.js";
import "./lookup.css";
export interface Lookup {
stateOf: (token: string) => WordState | undefined;
/** Whether `words[at]` has a Korean meaning worth offering. */
hasEnglish: (words: string[], at: number) => boolean;
openKo: (anchor: HTMLElement, token: string) => void;
openEn: (anchor: HTMLElement, word: string, words: string[], at: number) => void;
}
export const LookupContext = createContext<Lookup | null>(null);
/** Pressing a word must not move focus off the field being answered. */
const keepFocus = (e: PointerEvent | MouseEvent) => e.preventDefault();
/** Korean runs in `text` become lookups; the rest stays text. */
export function KoText({ text }: { text: string }): ReactNode {
const lookup = useContext(LookupContext);
if (!lookup || !/[가-힣]/.test(text)) return text;
return text.split(/([가-힣]+)/).map((part, i) =>
i % 2 ? (
<button
type="button"
key={i}
className="kw"
data-s={lookup.stateOf(part)}
onPointerDown={keepFocus}
onMouseDown={keepFocus}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
lookup.openKo(e.currentTarget, part);
}}
>
{part}
</button>
) : (
part
),
);
}
/**
* English words with a Korean meaning become lookups — only those: an
* underline under every English word would be noise. Korean in the same
* text is wrapped as KoText does.
*/
export function EnText({ text }: { text: string }): ReactNode {
const lookup = useContext(LookupContext);
if (!lookup) return text;
const parts = text.split(/([A-Za-z][A-Za-z'-]*)/);
const words = parts.filter((_, i) => i % 2 === 1);
let at = -1;
return parts.map((part, i) => {
if (i % 2 === 0) return part ? <KoText key={i} text={part} /> : null;
const here = ++at;
if (!lookup.hasEnglish(words, here)) return part;
return (
<button
type="button"
key={i}
className="kw-en"
onPointerDown={keepFocus}
onMouseDown={keepFocus}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
lookup.openEn(e.currentTarget, part, words, here);
}}
>
{part}
</button>
);
});
}
const CHIPS = ".mt-chip, .chip-w, .ch-opts button";
const HOLD_MS = 420;
/**
* Gloss an exercise's answer chips: a press-and-hold on any of them, or a
* tap while `hint` is on. The hold's own click is swallowed in the capture
* phase, before the exercise sees it — a gloss must not also answer.
*/
export function useChipGloss(hint: boolean) {
const lookup = useContext(LookupContext);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const swallow = useRef(false);
const gloss = (chip: HTMLElement) => {
const text = (chip.textContent ?? "").trim();
if (!text || !lookup) return;
const korean = text.match(/[가-힣]+/);
if (korean) lookup.openKo(chip, korean[0]);
else {
const words = text.split(/\s+/);
lookup.openEn(chip, words[0]!, words, 0);
}
};
const cancel = () => {
clearTimeout(timer.current);
timer.current = undefined;
};
return {
/** Spread on the exercise's root. */
root: {
onPointerDown: (e: PointerEvent) => {
// A hold that never became a click must not swallow the next tap.
swallow.current = false;
const chip = (e.target as HTMLElement).closest<HTMLButtonElement>(CHIPS);
if (!chip || chip.disabled) return;
cancel();
timer.current = setTimeout(() => {
timer.current = undefined;
swallow.current = true;
navigator.vibrate?.(8);
gloss(chip);
}, HOLD_MS);
},
onPointerUp: cancel,
onPointerCancel: cancel,
onPointerMove: (e: PointerEvent) => {
if (timer.current && !(e.target as HTMLElement).closest(CHIPS)) cancel();
},
onClickCapture: (e: MouseEvent) => {
const chip = (e.target as HTMLElement).closest<HTMLElement>(CHIPS);
if (!chip) return;
if (swallow.current || hint) {
e.preventDefault();
e.stopPropagation();
if (!swallow.current) gloss(chip);
swallow.current = false;
}
},
onContextMenu: (e: MouseEvent) => {
// A long press on Android opens the context menu over the chip.
if ((e.target as HTMLElement).closest(CHIPS)) e.preventDefault();
},
},
};
}

127
test/domain/words.test.ts Normal file
View 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([]);
});
});