feat(dictionary): build a test wordlist without the upstream download

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.
This commit is contained in:
tiennm99 committed 2026-09-05 14:18:03 +07:00
1 parent aea9ea8cc0
commit 4277c2014c
3 files changed
+328 -3

No files matched your search

+17 -2
View File
@@ -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
+69 -1
View File
@@ -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.
+242
View File
@@ -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