From 4277c2014c6471697a7259d1293f9184a9acb238 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 5 Sep 2026 14:18:03 +0700 Subject: [PATCH] feat(dictionary): build a test wordlist without the upstream download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build-dictionary gains a --words mode that reads a plain list instead of the upstream database. Everything after that — filtering, alias generation, writing and verification — is the code the real build uses, so a fixture cannot drift into being shaped differently from what the server loads. testdata/fixture-words.txt is hand-written rather than extracted, so no test artifact carries the upstream release's licence. Its graph is built around one hub syllable: it is the only one with enough continuations for the server to open a game on, so every game starts on a word ending in it and a scripted test always knows the first answer. --- Makefile | 19 ++- server/cmd/build-dictionary/main.go | 70 +++++++- testdata/fixture-words.txt | 242 ++++++++++++++++++++++++++++ 3 files changed, 328 insertions(+), 3 deletions(-) create mode 100644 testdata/fixture-words.txt diff --git a/Makefile b/Makefile index 51ca67c..f2d0786 100644 --- a/Makefile +++ b/Makefile @@ -9,20 +9,24 @@ DICT_SRC := data/dictionary.db # changed under the same tag, or the download was truncated. DICT_SHA256 := 9259403f0675b2991a1bd0ef6d0dbc5933afdb135632af095a60662f09bbf1d3 DICT_OUT := data/noitu.db +FIXTURE_WORDS := testdata/fixture-words.txt +FIXTURE_DB := data/fixture.db SERVER_BIN := noitu-server -.PHONY: help fetch-dict verify-dict dict proto proto-check server web web-dev test test-go test-web run clean +.PHONY: help fetch-dict verify-dict dict fixture-dict proto proto-check server web web-dev test test-go test-web test-e2e run clean help: @echo "fetch-dict download + checksum the upstream dictionary (~179 MB) into data/" @echo "verify-dict re-check the downloaded dictionary against its pinned SHA-256" @echo "dict derive $(DICT_OUT) from $(DICT_SRC)" + @echo "fixture-dict build the small test dictionary — no download needed" @echo "proto regenerate the Go and JS wire types from proto/" @echo "proto-check lint the schema and verify the committed output is in sync" @echo "server build the Go server binary" @echo "web build the SvelteKit frontend" @echo "web-dev run the frontend dev server, proxying /ws to a local server" @echo "test run all tests" + @echo "test-e2e run the Playwright suite against the fixture dictionary" @echo "run build and run the server locally" @echo "clean remove build artifacts (keeps downloaded dictionary)" @@ -44,6 +48,14 @@ $(DICT_SRC): dict: $(DICT_SRC) cd server && go run ./cmd/build-dictionary --in ../$(DICT_SRC) --out ../$(DICT_OUT) +# The dictionary tests, end-to-end runs and CI all play against. Built from a +# checked-in word list through the same pipeline as the real one, so nothing +# has to download 179 MB to get a working database. +$(FIXTURE_DB): $(FIXTURE_WORDS) + cd server && go run ./cmd/build-dictionary --words ../$(FIXTURE_WORDS) --out ../$(FIXTURE_DB) --min-words 150 + +fixture-dict: $(FIXTURE_DB) + # Regenerates both targets from proto/noitu/v1/game.proto. Needs `buf`; the # two code generators come from server/go.mod's tool directive and # web/package.json, so there is nothing to install globally. Generated code is @@ -86,9 +98,12 @@ test-go: test-web: web/node_modules cd web && npm run check && npm test +test-e2e: web/node_modules $(FIXTURE_DB) server + cd web && npm run test:e2e + run: server ./$(SERVER_BIN) clean: - rm -f $(SERVER_BIN) $(SERVER_BIN).exe $(DICT_OUT) + rm -f $(SERVER_BIN) $(SERVER_BIN).exe $(DICT_OUT) $(FIXTURE_DB) rm -rf web/build web/.svelte-kit diff --git a/server/cmd/build-dictionary/main.go b/server/cmd/build-dictionary/main.go index d0fbb51..dfba588 100644 --- a/server/cmd/build-dictionary/main.go +++ b/server/cmd/build-dictionary/main.go @@ -21,6 +21,7 @@ import ( "fmt" "log" "os" + "path/filepath" "sort" "strings" "time" @@ -35,7 +36,10 @@ const ( ) type config struct { - in string + in string + // words is an alternative source: a plain list, one word per line, used to + // build a small fixture database without the upstream download. + words string out string maxSyllables int minWords int @@ -50,6 +54,7 @@ func main() { var cfg config flag.StringVar(&cfg.in, "in", "../data/dictionary.db", "upstream dictionary.db to read") + flag.StringVar(&cfg.words, "words", "", "read a plain word list instead of a source database (one word per line, # comments)") flag.StringVar(&cfg.out, "out", "../data/noitu.db", "derived database to write") flag.IntVar(&cfg.maxSyllables, "max-syllables", 0, "reject words longer than this (0 = no limit)") flag.IntVar(&cfg.minWords, "min-words", 40000, "fail if fewer words survive filtering") @@ -65,6 +70,9 @@ func main() { } func run(cfg config) error { + if cfg.words != "" { + return runFromWordList(cfg) + } if _, err := os.Stat(cfg.in); err != nil { return fmt.Errorf("source database not found at %s — run 'make fetch-dict' first: %w", cfg.in, err) } @@ -107,6 +115,66 @@ func run(cfg config) error { return nil } +// runFromWordList derives a database from a plain list of words instead of the +// upstream release. +// +// It exists so tests and CI have a real dictionary to play against without the +// 179 MB download. The filtering, alias generation, writing and verification +// below are the same functions the real build uses — only the source of the +// raw strings differs — so a fixture cannot drift into being shaped +// differently from what production loads. +func runFromWordList(cfg config) error { + raw, err := os.ReadFile(cfg.words) + if err != nil { + return fmt.Errorf("read word list: %w", err) + } + + words := make(map[string]entry) + rejects := make(map[rejectReason]int) + + for line := range strings.Lines(string(raw)) { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + word, syllables, reason, ok := accept(line, cfg.maxSyllables) + if !ok { + rejects[reason]++ + continue + } + words[word] = entry{ + word: word, + first: syllables[0], + last: syllables[len(syllables)-1], + syllables: len(syllables), + } + } + + logRejects(rejects) + log.Printf("accepted %d distinct words from %s", len(words), cfg.words) + + if len(words) < cfg.minWords { + return fmt.Errorf("only %d words survived filtering, expected at least %d", len(words), cfg.minWords) + } + + aliases, collisions := buildAliases(words) + log.Printf("generated %d spelling aliases (%d skipped as ambiguous or already real words)", len(aliases), collisions) + + // The source spec is what lands in the meta table. Naming the list rather + // than a table makes it obvious in the output which build produced a given + // database. + src := sourceSpec{table: "wordlist:" + filepath.Base(cfg.words)} + if err := write(cfg.out, words, aliases, src); err != nil { + return err + } + if err := verify(cfg.out, cfg.minWords); err != nil { + return fmt.Errorf("output failed verification: %w", err) + } + + log.Printf("wrote %s", cfg.out) + return nil +} + // verify re-opens the finished database and re-checks the invariants the game // depends on. The in-memory checks above can only prove what the builder // intended; this proves what actually landed on disk. diff --git a/testdata/fixture-words.txt b/testdata/fixture-words.txt new file mode 100644 index 0000000..5388060 --- /dev/null +++ b/testdata/fixture-words.txt @@ -0,0 +1,242 @@ +# Fixture wordlist for tests, end-to-end runs and CI. +# +# Hand-written common Vietnamese compounds, not an extract of the upstream +# dictionary: CI must never download the 179 MB release, and a fixture derived +# from it would carry that release's licence into every test artifact. +# +# `make fixture-dict` turns this into a real derived database through the same +# filter, alias and write path production uses, so a fixture cannot drift into +# being shaped differently from what the server actually loads. +# +# The graph is built around one hub syllable, "sinh". It is the only syllable +# with enough continuations to be chosen as an opening, so every game starts on +# a word ending in "sinh" and a scripted test always knows the first answer. + +# --- openings: words ending in the hub syllable --- +học sinh +thí sinh +vệ sinh +phát sinh +khai sinh +tái sinh +dân sinh +nhân sinh +ký sinh +chúng sinh + +# --- the hub: words starting with "sinh" --- +sinh viên +sinh sản +sinh hoạt +sinh học +sinh nhật +sinh vật +sinh tồn +sinh thái +sinh lý +sinh kế +sinh khí +sinh mệnh +sinh trưởng +sinh động +sinh sống +sinh thành +sinh lực +sinh quán +sinh sôi +sinh nở +sinh trắc +sinh hóa + +# --- continuations --- +viên chức +viên mãn +chức năng +chức vụ +năng lực +năng suất +mãn nguyện +nguyện vọng +vọng tưởng + +sản xuất +sản phẩm +sản lượng +xuất bản +xuất phát +phẩm chất +lượng giác +bản đồ +bản sắc +đồ thị +sắc thái + +hoạt động +hoạt bát +động vật +động cơ +động lực +động đất +cơ bản +cơ hội +hội nghị +nghị luận +đất nước +nước ngoài +ngoài trời +trời đất + +học tập +học phí +học hỏi +học bổng +tập trung +tập thể +trung tâm +tâm hồn +hồn nhiên +nhiên liệu +liệu pháp +pháp luật +pháp lý +luật sư +sư phạm +phạm vi +vi phạm + +nhật ký +nhật báo +báo cáo +cáo trạng +trạng thái +thái độ +thái bình +độ cao +cao nguyên +bình minh +minh bạch +bạch tuộc + +vật chất +vật liệu +chất lượng + +tồn tại +tồn kho +tại chỗ +kho tàng +tàng hình +hình ảnh +ảnh hưởng +hưởng thụ + +lý do +lý thuyết +lý luận +lý tưởng +thuyết minh +luận điểm +tưởng tượng +điểm danh +danh sách +sách vở +tượng trưng + +kế hoạch +kế toán +kế tiếp +hoạch định +toán học +tiếp tục +định hướng +tục ngữ +hướng dẫn +ngữ pháp +dẫn chứng +chứng minh + +khí hậu +khí quyển +khí thế +hậu quả +quyển sách +thế giới +quả cảm +giới hạn +hạn chế +chế độ + +mệnh lệnh +mệnh đề +lệnh cấm +đề tài +cấm vận +tài liệu +vận động + +trưởng thành +trưởng phòng +thành phố +thành công +thành lập +thành viên +phòng ban +phố cổ +công việc +công nghiệp +lập trình +việc làm +nghiệp vụ +trình độ +làm việc +cổ điển +điển hình + +sống động +lực lượng +lực sĩ +lượng tử +sĩ quan +tử tế +quan hệ +hệ thống +thống nhất +nhất trí +trí tuệ +tuệ giác + +quán triệt +triệt để +để dành +dành dụm + +sôi nổi +nổi tiếng +tiếng nói +nói chuyện +chuyện trò +trò chơi +chơi đùa + +nở hoa +hoa quả +hóa học +hóa đơn +đơn giản +giản dị +dị thường +thường xuyên +xuyên tạc + +trắc nghiệm +nghiệm thu +thu nhập +nhập khẩu +khẩu hiệu +hiệu quả + +# --- a few longer words, so syllable bonuses and the 3+ badge have cases --- +vô tuyến điện +công nghiệp hóa +tổng hợp chất +điện thoại di động