`pair_lang` had always been answering a second question nobody asked: it says which two languages, and every surface built on it assumed English was the one being learned. That is why hanzi is never tokenized, never spell-checked, never glossed — correct for a Mandarin native practising English, backwards for an English native practising Mandarin. `users.direction` (migration 0016) separates the two questions; a `zh-learner` pair code would have been cheaper and would have made two directions of one pair look like two unrelated languages to every query. Segmentation is what replaces `wordAt` where there are no spaces: a shortest-path walk over log-probabilities, 232 ms and 14 MB for 188,522 words. The browser gets the word list because segmentation runs on hover; the server keeps the whole dictionary. Their coverage gates come out opposite on purpose — the client list is frequency-gated because the segmentation is measurably identical without the tail, and the dictionary is gated by nothing, because its only power is to explain and the word a learner stops on is the rare one. The 错别字 pack is 24 confusable pairs behind two mechanical gates. One admits a pair only if the wrong form is not a dictionary word and the right form is, which is why it refuses 自已 for 自己 — a real error whose wrong form is a headword. The other asks the segmenter whether the two characters already belong to two different words, without which 自己经常, 睡觉的时候 and 不知到底 would all be corrupted silently into text still made of real characters. Not deployed (this carries a migration), not seen in a browser, and no account has ever been in the learner direction. The IME composition guards were in scope and are not done — see BUILD_PLAN Phase 26.
151 lines
4.8 KiB
Go
151 lines
4.8 KiB
Go
package lexicon
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// The Chinese direction of the lexicon, against the real embedded asset — not a
|
|
// fixture. The dataset is built by scripts/build_cedict.py, which asserts its
|
|
// own invariants at build time; what these assert is that the *lookup* over it
|
|
// behaves, including on the entries the build script goes out of its way to keep.
|
|
|
|
func TestHanziLookup(t *testing.T) {
|
|
l := New()
|
|
|
|
res, err := l.Hanzi("公园")
|
|
if err != nil {
|
|
t.Fatalf("lookup 公园: %v", err)
|
|
}
|
|
if len(res.Readings) == 0 {
|
|
t.Fatal("公园 has no readings")
|
|
}
|
|
// Tone marks, not the numbered pinyin CC-CEDICT stores. The number is the
|
|
// storage format; the marks are what a learner reads.
|
|
if got := res.Readings[0].Pinyin; got != "gōngyuán" {
|
|
t.Errorf("公园 pinyin = %q, want gōngyuán", got)
|
|
}
|
|
if !strings.Contains(res.Readings[0].Senses, "park") {
|
|
t.Errorf("公园 senses = %q, want something about a park", res.Readings[0].Senses)
|
|
}
|
|
// A word answered whole says nothing about its characters — the fallback is
|
|
// the other branch, and sending both would double the payload of the common
|
|
// case to no purpose.
|
|
if len(res.Chars) != 0 {
|
|
t.Errorf("a whole-word hit also returned %d characters", len(res.Chars))
|
|
}
|
|
}
|
|
|
|
// 得 is the reason readings are a list. Answered with only dé "to obtain", a
|
|
// learner hovering it in 说得很好 has been told something false about the
|
|
// sentence they are looking at.
|
|
func TestHanziParticleCarriesItsGrammaticalReading(t *testing.T) {
|
|
l := New()
|
|
|
|
for _, particle := range []string{"的", "地", "得"} {
|
|
res, err := l.Hanzi(particle)
|
|
if err != nil {
|
|
t.Fatalf("lookup %s: %v", particle, err)
|
|
}
|
|
var found bool
|
|
for _, r := range res.Readings {
|
|
if r.Pinyin == "de" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("%s never reads as neutral \"de\": %+v", particle, res.Readings)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The fallback the segmentation gap makes necessary: jieba knows ordinary
|
|
// compounds CC-CEDICT has no headword for, so a hover can land on a real word
|
|
// with no entry. Chinese compounds are usually transparent from their parts, so
|
|
// the characters are a real second answer.
|
|
func TestHanziFallsBackToCharacters(t *testing.T) {
|
|
l := New()
|
|
|
|
// Constructed rather than borrowed from the corpus: a word that CC-CEDICT
|
|
// *does* carry would test the other branch, and which compounds it happens to
|
|
// omit is not something a test should pin.
|
|
const made = "猫书"
|
|
if _, ok := hanzi.entries[made]; ok {
|
|
t.Skipf("%s has become a real headword; pick another compound", made)
|
|
}
|
|
res, err := l.Hanzi(made)
|
|
if err != nil {
|
|
t.Fatalf("lookup %s: %v", made, err)
|
|
}
|
|
if len(res.Readings) != 0 {
|
|
t.Fatalf("%s answered as a whole word: %+v", made, res.Readings)
|
|
}
|
|
if len(res.Chars) != 2 {
|
|
t.Fatalf("character fallback gave %d entries, want 2: %+v", len(res.Chars), res.Chars)
|
|
}
|
|
if res.Chars[0].Char != "猫" || !strings.Contains(res.Chars[0].Senses, "cat") {
|
|
t.Errorf("first character = %+v, want 猫 ~ cat", res.Chars[0])
|
|
}
|
|
if res.Chars[0].Pinyin != "māo" {
|
|
t.Errorf("猫 pinyin = %q, want māo", res.Chars[0].Pinyin)
|
|
}
|
|
}
|
|
|
|
func TestHanziMisses(t *testing.T) {
|
|
l := New()
|
|
|
|
for name, word := range map[string]string{
|
|
// A single character with no entry has no parts to fall back to.
|
|
"lone unknown character": "龥",
|
|
"empty": "",
|
|
"whitespace": " ",
|
|
// Not Chinese at all: the English tokenizer owns these, and answering
|
|
// would mean guessing.
|
|
"english": "hello",
|
|
"mixed": "猫cat",
|
|
// Longer than a word: a bad segmentation, not something to spell out
|
|
// character by character.
|
|
"a whole clause": "我今天早上去公园跑步了",
|
|
} {
|
|
res, err := l.Hanzi(word)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", name, err)
|
|
}
|
|
if len(res.Readings) != 0 || len(res.Chars) != 0 {
|
|
t.Errorf("%s (%q) answered with %+v / %+v", name, word, res.Readings, res.Chars)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHanziEndpoint(t *testing.T) {
|
|
h := NewHandler(nil, NewSet(nil))
|
|
r := chi.NewRouter()
|
|
r.Mount("/hanzi", h.HanziRoutes())
|
|
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/hanzi/"+"跑步", nil))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", w.Code)
|
|
}
|
|
var got HanziResult
|
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if got.Word != "跑步" || len(got.Readings) == 0 || got.Readings[0].Pinyin != "pǎobù" {
|
|
t.Fatalf("response = %+v", got)
|
|
}
|
|
|
|
// A miss is a 200 with empty lists, like the other two lookups — the tooltip
|
|
// quietly doesn't open rather than showing an error over her writing.
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/hanzi/zzz", nil))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("miss: status = %d, want 200", w.Code)
|
|
}
|
|
}
|