textmachine/backend/internal/seed/decode_test.go

120 lines
4.6 KiB
Go

package seed
import (
"strings"
"testing"
)
// decode_test.go: the UNKNOWN-KEY contract of the two decision files (backlog row 212).
//
// The defect these pin is silent substitution, not a parse failure: yaml.v3 DROPS a key the struct does
// not have, so `gendr:` for `gender:` was thrown away BEFORE any validation ran — and `seed-lint` could
// not see it either, because it validates VALUES and the key never reached it. The same class pack-16
// bought strictness against for book.yaml.
func TestSeedDecodeRefusesAnUnknownKey(t *testing.T) {
_, err := DecodeFile([]byte("terms:\n - src: 方源\n dst: Фан Юань\n gendr: male\n"))
if err == nil {
t.Fatal("a misspelled term key must be a loud error, not a dropped field")
}
if !strings.Contains(err.Error(), "gendr") {
t.Errorf("the error must name the key it did not recognise, got: %v", err)
}
}
func TestSeedDecodeRefusesAnUnknownTopLevelKey(t *testing.T) {
if _, err := DecodeFile([]byte("termz:\n - src: 方源\n")); err == nil {
t.Fatal("a misspelled SECTION name silently yields an empty seed — it must be loud")
}
}
func TestRejectsDecodeRefusesAnUnknownKey(t *testing.T) {
// `rejcts:` used to read as an empty reject list, silently un-declining every term the owner
// declined — they all re-enter the signature map and the auto-bank as if the review never happened.
if _, err := DecodeRejects([]byte("rejcts:\n - src: 方源\n")); err == nil {
t.Fatal("a misspelled reject section must be a loud error")
}
if _, err := DecodeRejects([]byte("rejects:\n - src: 方源\n reasn: no\n")); err == nil {
t.Fatal("a misspelled reject field must be a loud error")
}
}
// TestDecodeAcceptsAnEmptyDocument keeps the strictness from breaking a legitimate state: a decision
// file the engine itself wrote with nothing in it, a file of whitespace, and — the case the first
// version of this list missed — a file holding only COMMENTS.
//
// yaml.Decoder reports a stream with no document as io.EOF where yaml.Unmarshal accepted it as "no
// records", and every caller here used the second one. A whitespace-only pre-check is NOT the same
// test: comment bytes are not blank, so a header-only reject list came back `seed: EOF` and broke a
// file that had always loaded — in a format whose own entries carry a `note` to be self-documenting,
// on the path that governs every book's glossary_seed. Found by review; reproduced against a build of
// HEAD before the fix.
func TestDecodeAcceptsAnEmptyDocument(t *testing.T) {
for _, body := range []string{"", "\n", " \n\n", "# declined terms\n", "# a\n# b\n", " # indented\n"} {
f, err := DecodeFile([]byte(body))
if err != nil || len(f.Terms) != 0 {
t.Errorf("document with no records %q: err=%v terms=%d, want no error and no terms", body, err, len(f.Terms))
}
r, err := DecodeRejects([]byte(body))
if err != nil || len(r.Rejects) != 0 {
t.Errorf("reject document with no records %q: err=%v rejects=%d, want no error and no rejects", body, err, len(r.Rejects))
}
}
}
// TestDecodeRoundTripsEveryTermField is the guard against the strictness itself becoming the defect: a
// document naming every field of the schema must load, or a field added to Term without a matching tag
// would make previously valid files unloadable.
func TestDecodeRoundTripsEveryTermField(t *testing.T) {
body := `terms:
- src: 方源
dst: Фан Юань
type: name
sense: герой
gender: male
speech: сухая
decl: { invariant: false, forms: [Фан Юаня] }
translit_policy: palladius
first_person: я
nickname_translation: keep
since_ch: 1
until_ch: 100
status: approved
allow_short: true
note: n
aliases:
- { alias: 方, type: short }
voices:
- src: 方源
sense: герой
register: r
self_ref: я
address_default: informal
lexicon_markers: [a]
ng_lexicon: [b]
exemplars: [c]
brightness: high
since_ch: 1
until_ch: 2
addresses:
- speaker: 方源
speaker_sense: герой
addressee: 花月
addressee_sense: ""
register: informal
form: ты
closeness: близко
since_ch: 1
until_ch: 2
`
f, err := DecodeFile([]byte(body))
if err != nil {
t.Fatalf("a document naming every schema field must load: %v", err)
}
if len(f.Terms) != 1 || len(f.Voices) != 1 || len(f.Addresses) != 1 {
t.Fatalf("got %d terms, %d voices, %d addresses", len(f.Terms), len(f.Voices), len(f.Addresses))
}
if f.Terms[0].Decl == nil || len(f.Terms[0].Decl.Forms) != 1 || len(f.Terms[0].Aliases) != 1 {
t.Errorf("nested records lost: decl=%v aliases=%v", f.Terms[0].Decl, f.Terms[0].Aliases)
}
}