/* Regression tests for the bugs found in the post-implementation scan. These are pinned because two of them are invisible on the platform they were written on: sqlite-wasm allows 32,766 bound parameters, Android's SQLite allows 999, so a batch sized for the browser fails only on the phone — at first launch, loading the dictionary. */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { SqliteWasmDb } from "@app/db/sqlite-wasm-core.js"; import { migrate } from "@app/db/migrate.js"; import { insertBand, type LemmaRow, type SurfaceRow } from "@app/db/writes.js"; import { lookupMany, search } from "@app/domain/lexicon.js"; import type { Db } from "@app/db/types.js"; /** The ceiling Android imposes. Nothing may exceed it in one statement. */ const ANDROID_MAX_PARAMS = 999; let db: Db; beforeEach(async () => { db = await SqliteWasmDb.open({ memory: true }); await migrate(db); }); afterEach(async () => { await db.close(); }); const lemma = (id: number, headword: string): LemmaRow => ({ id, headword, pos: "noun", freq_rank: id, level: null, gloss_en: `gloss ${id}`, gloss_ko: "", unit_band: 0, source: "curated", }); describe("bound-parameter ceiling", () => { /** * Records the parameter count of every statement the code issues. * The counter is shared with any transaction handle handed to a callback — * insertBand does all its work inside a tx, so a probe that started a fresh * counter there would observe nothing at all. */ function counting(inner: Db, shared?: { max: number }): { db: Db; max: () => number } { const state = shared ?? { max: 0 }; const note = (params?: readonly unknown[]) => { state.max = Math.max(state.max, params?.length ?? 0); }; const wrapped: Db = { all: (sql, params) => (note(params), inner.all(sql, params)), get: (sql, params) => (note(params), inner.get(sql, params)), run: (sql, params) => (note(params), inner.run(sql, params)), exec: (sql) => inner.exec(sql), tx: (fn) => inner.tx((tx) => fn(counting(tx, state).db)), close: () => inner.close(), }; return { db: wrapped, max: () => state.max }; } it("keeps a band insert under Android's limit", async () => { const lemmas = Array.from({ length: 2500 }, (_, i) => lemma(i + 1, `말${i}`)); const surfaces: SurfaceRow[] = lemmas.map((l) => ({ form: l.headword, lemma_id: l.id, analysis: "headword", })); const probe = counting(db); await insertBand(probe.db, lemmas, surfaces); // The probe must actually have seen the inserts, or it proves nothing. expect(probe.max()).toBeGreaterThan(0); expect(probe.max()).toBeLessThanOrEqual(ANDROID_MAX_PARAMS); // …and it must still have inserted everything. const n = await db.get<{ n: number }>("SELECT count(*) AS n FROM lemma"); expect(n?.n).toBe(2500); }); it("batches a lookup of more forms than the limit allows", async () => { const lemmas = Array.from({ length: 1500 }, (_, i) => lemma(i + 1, `낱${i}`)); await insertBand(db, lemmas, []); const probe = counting(db); const found = await lookupMany( probe.db, lemmas.map((l) => l.headword), ); expect(probe.max()).toBeLessThanOrEqual(ANDROID_MAX_PARAMS); expect(found.size).toBe(1500); }); }); describe("search — LIKE wildcards in user input", () => { beforeEach(async () => { await insertBand( db, [ { ...lemma(1, "밥"), gloss_en: "rice" }, { ...lemma(2, "학교"), gloss_en: "school" }, { ...lemma(3, "백"), gloss_en: "100% pure" }, ], [], ); }); it("treats % as a character, not a wildcard", async () => { // Unescaped, "%" matches every row in the table. const hits = await search(db, "%", { includeReference: true }); expect(hits.map((h) => h.headword)).toEqual(["백"]); // the one gloss containing "%" }); it("treats _ as a character, not a single-character wildcard", async () => { const hits = await search(db, "_", { includeReference: true }); expect(hits).toHaveLength(0); }); it("still finds ordinary queries", async () => { expect((await search(db, "밥", { includeReference: true })).map((h) => h.headword)).toEqual([ "밥", ]); expect((await search(db, "school", { includeReference: true })).length).toBeGreaterThan(0); }); });