c915115096
CheckFeminine flagged "ты заплатил за домен" as a masculine self-reference. The second pass reads a masculine past-tense verb before "тебе", "тебя" or "за" as her speaking with the pronoun dropped, and it checked neither the subject nor what "за" pointed at. He is male, so a verb governed by "ты" must be masculine, and "за домен" is a price rather than a favour. The talk fixture was under-reporting by a point whenever a reply addressed him in the past tense, which is common.
230 lines
9.1 KiB
Go
230 lines
9.1 KiB
Go
package eval
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/kami/maven/internal/loop"
|
|
"github.com/kami/maven/internal/phraser"
|
|
)
|
|
|
|
func TestLoadFixture(t *testing.T) {
|
|
f, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if _, err := f.Now(); err != nil {
|
|
t.Fatalf("Now: %v", err)
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, c := range f.Cases {
|
|
if seen[c.ID] {
|
|
t.Errorf("duplicate case id %q", c.ID)
|
|
}
|
|
seen[c.ID] = true
|
|
if c.Rule == "" || c.Severity < 1 || c.Severity > 4 {
|
|
t.Errorf("%s: rule %q severity %d", c.ID, c.Rule, c.Severity)
|
|
}
|
|
if len(c.WantAny) == 0 {
|
|
t.Errorf("%s: no want_any, the on-topic check would always pass", c.ID)
|
|
}
|
|
}
|
|
// Coverage floor: all five loop rules plus both minted families, or the
|
|
// fixture measures a subset and the score does not mean what it says.
|
|
for _, rule := range []string{"water", "meal", "break", "service_down", "netdata_critical", "routine", "morning"} {
|
|
found := false
|
|
for _, c := range f.Cases {
|
|
if ruleFamily(c.Rule) == rule {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("no case for rule family %q", rule)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestStubBaseline is the CI ratchet: the deterministic Stub, no model, no
|
|
// network. The floor is low on purpose — the Stub is English template phrasing,
|
|
// so it fails `lang` on every case by construction. The point of the ratchet is
|
|
// that the checks keep running and the Stub does not get worse, not that the
|
|
// Stub is good.
|
|
func TestStubBaseline(t *testing.T) {
|
|
f, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
rep, err := Score(context.Background(), "stub (deterministic floor)", phraser.NewStub(), f)
|
|
if err != nil {
|
|
t.Fatalf("Score: %v", err)
|
|
}
|
|
t.Log("\n" + rep.String() + rep.Messages())
|
|
|
|
if rep.Errors != 0 {
|
|
t.Errorf("stub returned %d errors — the deterministic path must never fail", rep.Errors)
|
|
}
|
|
// Per-check ratchets rather than one composite: the Stub's composite is 0
|
|
// (it never passes `lang`), so a composite floor would catch nothing.
|
|
floors := map[string]int{
|
|
CheckMood: 15,
|
|
// 12, not 15: the Stub's `break` template genuinely runs past the
|
|
// ceiling ("you've been at your desk for 4 hours without a break — step
|
|
// away for a bit." is 76 chars but 16 words). Left failing rather than
|
|
// raising the ceiling to hide it.
|
|
CheckLength: 12,
|
|
CheckFeminine: 15,
|
|
CheckHisGender: 15,
|
|
CheckAddress: 15,
|
|
CheckCringe: 15,
|
|
CheckOnTopic: 12,
|
|
}
|
|
for name, floor := range floors {
|
|
if rep.ByCheck[name] < floor {
|
|
t.Errorf("check %s: %d/%d, below ratchet %d — phrasing regressed",
|
|
name, rep.ByCheck[name], rep.Total, floor)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestChecksCatchWhatTheyClaim — the checks are the measurement, so they get
|
|
// their own tests. Without these, a bad regexp would silently make every
|
|
// phrasing run look clean.
|
|
func TestChecksCatchWhatTheyClaim(t *testing.T) {
|
|
water := Case{Rule: "water", WantAny: []string{"вод"}}
|
|
|
|
cases := []struct {
|
|
name string
|
|
body string
|
|
want string // the check that must fail, "" for a clean message
|
|
}{
|
|
{"clean", "уже четыре часа без воды — попей.", ""},
|
|
{"long", "уже четыре часа без воды, а это довольно много, и вообще пить надо регулярно, иначе будет плохо совсем", CheckLength},
|
|
{"english", "you haven't had water in 4 hours, drink something", CheckLang},
|
|
{"masculine self", "я напомнил про воду.", CheckFeminine},
|
|
{"masculine dropped pronoun", "напомнил тебе про воду.", CheckFeminine},
|
|
{"masculine predicative", "я должен сказать: попей воды.", CheckFeminine},
|
|
// The other direction: HE is male, so second-person masculine is right.
|
|
{"second person masculine ok", "ты не пил воду четыре часа.", ""},
|
|
// The recorded false positive: "заплатил" sits before "за", and the
|
|
// second pass read that as her dropping the pronoun. The subject is
|
|
// "ты" and he is male, so the reply is right (Vikunja #462).
|
|
{"second person masculine before за", "ты заплатил за домен до марта, а воду пить всё равно надо.", ""},
|
|
// The same shape she really does get wrong still trips.
|
|
{"masculine on his behalf", "проверил за тебя — воды не было четыре часа.", CheckFeminine},
|
|
// The real observed failure: she addressed him as a woman.
|
|
{"feminine second person", "ты давно не отдыхала — попей воды.", CheckHisGender},
|
|
{"feminine second person no dash", "ты пила воду четыре часа назад.", CheckHisGender},
|
|
// Her own feminine verb next to "ты" is correct and must not be flagged.
|
|
{"her feminine verb near ты", "я заметила, что ты не пил воду.", ""},
|
|
{"her feminine verb about him", "напомнила тебе про воду.", ""},
|
|
// A feminine noun subject in the same sentence is not him.
|
|
{"feminine noun subject ok", "зарядка была утром, ты её пропустил, попей воды.", ""},
|
|
{"feminine self ok", "я заметила: воды не было четыре часа.", ""},
|
|
{"pet name", "милый, попей воды.", CheckCringe},
|
|
{"emoji", "попей воды 💧", CheckCringe},
|
|
{"exclamations", "попей воды!!", CheckCringe},
|
|
{"fake concern", "я беспокоюсь: воды не было четыре часа.", CheckCringe},
|
|
{"apology", "извини, что отвлекаю — попей воды.", CheckCringe},
|
|
{"emotional support", "я рядом, ты не один. попей воды.", CheckCringe},
|
|
{"asks how he feels", "как ты себя чувствуешь? попей воды.", CheckCringe},
|
|
{"praise", "молодец! теперь попей воды.", CheckCringe},
|
|
{"off topic", "пора бы уже что-то сделать.", CheckOnTopic},
|
|
// The two recorded persona breaks from the phrasing eval run. Pinned as
|
|
// unit tests because an eval run is sampled and may not reproduce them.
|
|
{"formal plural", "Приходите… Жду вас", CheckAddress},
|
|
{"third person about him", "Он не ел 11 дней", CheckAddress},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var failed []string
|
|
for _, r := range RunChecks(water, tc.body, "neutral") {
|
|
if !r.Pass {
|
|
failed = append(failed, r.Name+"("+r.Detail+")")
|
|
}
|
|
}
|
|
joined := strings.Join(failed, " ")
|
|
switch {
|
|
case tc.want == "" && len(failed) > 0:
|
|
t.Errorf("clean message flagged: %s", joined)
|
|
case tc.want != "" && !strings.Contains(joined, tc.want+"("):
|
|
t.Errorf("want %s to fail, got %q", tc.want, joined)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAddressCheck — the address check on its own, so the messages that must NOT
|
|
// trip it can be written without also having to satisfy the on-topic check.
|
|
func TestAddressCheck(t *testing.T) {
|
|
bad := []string{
|
|
"Приходите… Жду вас", // the recorded formal-plural break
|
|
"Он не ел 11 дней", // the recorded third-person break
|
|
"Выпейте воды, пожалуйста.", // plural imperative on its own
|
|
"Ваш обед был давно.", // formal possessive
|
|
}
|
|
for _, body := range bad {
|
|
if r := checkAddress(body); r.Pass {
|
|
t.Errorf("persona break not caught: %q", body)
|
|
} else {
|
|
t.Logf("%q -> %s", body, r.Detail)
|
|
}
|
|
}
|
|
|
|
good := []string{
|
|
"ты не пил воду четыре часа — попей.", // correct informal address
|
|
"сервис netdata упал, он не отвечает.", // legitimately about a third party
|
|
"я заметила, что зарядка была утром.", // no address at all
|
|
"в интернете опять тихо, всё работает.", // "интернете" is a noun, not an imperative
|
|
}
|
|
for _, body := range good {
|
|
if r := checkAddress(body); !r.Pass {
|
|
t.Errorf("clean message flagged: %q -> %s", body, r.Detail)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMoodCheckUsesTheEnum(t *testing.T) {
|
|
if r := checkMood("cheerful"); r.Pass {
|
|
t.Error("mood outside the enum passed")
|
|
}
|
|
if r := checkMood(""); r.Pass {
|
|
t.Error("empty mood passed")
|
|
}
|
|
for m := range Moods {
|
|
if r := checkMood(m); !r.Pass {
|
|
t.Errorf("enum mood %q failed", m)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCandidateCarriesTheContext — the whole harness is worthless if the
|
|
// Candidate it builds does not carry the duration the prompt is supposed to
|
|
// name.
|
|
func TestCandidateCarriesTheContext(t *testing.T) {
|
|
f, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
now, _ := f.Now()
|
|
for _, c := range f.Cases {
|
|
cand := c.Candidate(now)
|
|
if cand.Rule.Name != c.Rule || cand.Severity != loop.Severity(c.Severity) {
|
|
t.Errorf("%s: candidate lost rule or severity", c.ID)
|
|
}
|
|
if c.SinceMinutes > 0 {
|
|
d, ok := cand.State.Since(c.Rule)
|
|
if !ok || int(d.Minutes()) != c.SinceMinutes {
|
|
t.Errorf("%s: since %v ok=%v, want %d minutes", c.ID, d, ok, c.SinceMinutes)
|
|
}
|
|
}
|
|
if c.FactKey != "" {
|
|
fact, ok := cand.State.Fact(c.Rule)
|
|
if !ok || fact.Key != c.FactKey {
|
|
t.Errorf("%s: fact key %q, want %q", c.ID, fact.Key, c.FactKey)
|
|
}
|
|
}
|
|
}
|
|
}
|