Grounding (F2–F5): - Each ground_dinner job carries the dinner's new grounding_revision (migration 0010). Renaming a pending Regular raises the revision, and a job only grounds and writes a dinner still at its revision, so a queued original job no longer grounds the new name or decides the outcome. - Background grounding keeps a method, minutes or difficulty a member saved while the Regular was pending, and fills only those left empty. - Grounding by hand clears the pasted recipe_url, and link deduplication uses the source_url of the attached recipe, else the pending link. - Archived dinners are not grounded: the job checks before any search, fetch or model call, and its final update requires an unarchived row. Onboarding (F6, F7): - Step 3 shows "Add Regulars" back to step 2 when the server refuses to finish with fewer than three Regulars. - Common picks are hidden by a copy of harness Normalize/Similar, checked against the same shared cases as the Go code. Docs (F8): ARCHITECTURE.md covers the three-Regular minimum, step-3 recovery, the grounding revision and the dedupe rules. F1 was fixed in 1304487; its test now also asserts the old link is never grounded and recipe_url stays cleared. Maestro task 5037ff86-181d-448e-927b-c2fd2258a25d. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
203 lines
6.7 KiB
Go
203 lines
6.7 KiB
Go
package harness
|
||
|
||
import (
|
||
"encoding/json"
|
||
"os"
|
||
"testing"
|
||
"unicode"
|
||
)
|
||
|
||
// TestSharedDedupeCases runs testdata/dedupe_cases.json, which the PWA's
|
||
// copy of Normalize and Similar (web/src/dinners/names.ts) runs too, so the
|
||
// two cannot drift apart on these names.
|
||
func TestSharedDedupeCases(t *testing.T) {
|
||
raw, err := os.ReadFile("testdata/dedupe_cases.json")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var cases struct {
|
||
Normalize []struct{ Name, Want string }
|
||
Similar []struct {
|
||
A, B string
|
||
Want bool
|
||
}
|
||
}
|
||
if err := json.Unmarshal(raw, &cases); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(cases.Normalize) == 0 || len(cases.Similar) == 0 {
|
||
t.Fatal("dedupe_cases.json has no cases")
|
||
}
|
||
for _, c := range cases.Normalize {
|
||
if got := Normalize(c.Name); got != c.Want {
|
||
t.Errorf("Normalize(%q) = %q, want %q", c.Name, got, c.Want)
|
||
}
|
||
}
|
||
for _, c := range cases.Similar {
|
||
if got := Similar(c.A, c.B); got != c.Want {
|
||
t.Errorf("Similar(%q, %q) = %v, want %v", c.A, c.B, got, c.Want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestNormalize(t *testing.T) {
|
||
tests := []struct {
|
||
name, want string
|
||
}{
|
||
{"Easy Chicken Tikka Masala", "chicken tikka masala"},
|
||
{" The BEST homemade\tLasagna ", "lasagna"},
|
||
{"A Quick, Simple Classic Chili Recipe", "chili"},
|
||
{"Tacos (beef)", "tacos beef"},
|
||
{"Mac-and-Cheese!", "mac and cheese"},
|
||
{"Shepherd's Pie", "shepherds pie"},
|
||
{"Shepherd’s Pie", "shepherds pie"},
|
||
{"Crème Brûlée", "creme brulee"},
|
||
{"Jalapeño Poppers", "jalapeno poppers"},
|
||
{"Paella à la Valenciana", "paella la valenciana"}, // "à" folds to the filler "a"
|
||
{"Smørrebrød", "smorrebrod"},
|
||
{"Bœuf Bourguignon", "boeuf bourguignon"},
|
||
{"Käsespätzle mit Weißwurst", "kasespatzle mit weisswurst"},
|
||
{"Cre\u0300me bru\u0302le\u0301e", "creme brulee"}, // decomposed accents
|
||
{"İskender Kebab", "iskender kebab"},
|
||
{"Pho 24", "pho 24"},
|
||
{"Phở Bò", "pho bo"},
|
||
{"Bún Chả Hà Nội", "bun cha ha noi"},
|
||
{"Łosoś z Koperkiem", "losos z koperkiem"},
|
||
{"Ćevapčići", "cevapcici"},
|
||
{"Easy Recipe", ""},
|
||
{"", ""},
|
||
{"!?-- & ...", ""}, // punctuation only
|
||
}
|
||
for _, tt := range tests {
|
||
if got := Normalize(tt.name); got != tt.want {
|
||
t.Errorf("Normalize(%q) = %q, want %q", tt.name, got, tt.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestNormalizeCanonicalEquivalence checks that a composed spelling and its
|
||
// canonically equivalent decomposed spelling normalize equal and are Similar.
|
||
func TestNormalizeCanonicalEquivalence(t *testing.T) {
|
||
tests := []struct {
|
||
composed, decomposed, want string
|
||
}{
|
||
{"Phở", "Pho\u031b\u0309", "pho"}, // o + horn + hook above
|
||
{"Bánh Xèo", "Ba\u0301nh Xe\u0300o", "banh xeo"},
|
||
{"Cơm Tấm", "Co\u031bm Ta\u0302\u0301m", "com tam"},
|
||
{"Ẓaʿtar Ȟash", "Z\u0323aʿtar H\u030cash", "zaʿtar hash"},
|
||
{"Smǫrrebrød", "Smo\u0328rrebrød", "smorrebrod"},
|
||
{"Smǿrrebrǿd", "Smø\u0301rrebrø\u0301d", "smorrebrod"}, // ǿ folds through ø
|
||
{"Ǽbleskiver", "Æ\u0301bleskiver", "aebleskiver"}, // ǽ folds through æ
|
||
}
|
||
for _, tt := range tests {
|
||
c, d := Normalize(tt.composed), Normalize(tt.decomposed)
|
||
if c != tt.want || d != tt.want {
|
||
t.Errorf("Normalize(%q) = %q, Normalize(%+q) = %q, want both %q",
|
||
tt.composed, c, tt.decomposed, d, tt.want)
|
||
}
|
||
if !Similar(tt.composed, tt.decomposed) {
|
||
t.Errorf("Similar(%q, %+q) = false, want true", tt.composed, tt.decomposed)
|
||
}
|
||
dedupe := NewDeduper(DedupeSources{Regulars: []string{tt.decomposed}})
|
||
if !dedupe.IsDuplicate(tt.composed) {
|
||
t.Errorf("IsDuplicate(%q) over %+q = false, want true", tt.composed, tt.decomposed)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestLetterFoldsFullyReduced checks that every fold result is final:
|
||
// Normalize writes a result without folding it again, so a result that
|
||
// still holds a foldable letter, a combining mark or an upper-case letter
|
||
// would split canonically equivalent spellings (ǿ -> ø instead of o).
|
||
func TestLetterFoldsFullyReduced(t *testing.T) {
|
||
for r, folded := range letterFolds {
|
||
for _, c := range folded {
|
||
if letterFolds[c] != "" || unicode.Is(unicode.Mn, c) || unicode.ToLower(c) != c {
|
||
t.Errorf("letterFolds[%U] = %q holds %U, which Normalize would change", r, folded, c)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestSimilar(t *testing.T) {
|
||
tests := []struct {
|
||
a, b string
|
||
want bool
|
||
}{
|
||
{"Easy Chicken Tikka Masala", "chicken tikka masala", true},
|
||
{"Beef Tacos", "Tacos (beef)", true},
|
||
{"Chicken tacos", "Fish tacos", false},
|
||
{"Spaghetti Bolognese", "spaghetti bolognese recipe", true},
|
||
{"Crème Brûlée", "creme brulee", true},
|
||
{"Jalapeño Poppers", "JALAPENO POPPERS", true},
|
||
{"Smørrebrød", "Smorrebrod", true},
|
||
{"Phở", "Pho\u031b\u0309", true},
|
||
{"Phở", "Pho", true},
|
||
{"Chicken Tikka Masala", "Chicken Tikka Masala Curry", false}, // 3/4
|
||
{"Tacos", "Fish tacos", false},
|
||
{"Pad Thai", "Thai Green Curry", false},
|
||
{"Easy Recipe", "The Classic", false}, // nothing left to compare
|
||
{"Recipe", "Chili", false},
|
||
}
|
||
for _, tt := range tests {
|
||
if got := Similar(tt.a, tt.b); got != tt.want {
|
||
t.Errorf("Similar(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want)
|
||
}
|
||
if got := Similar(tt.b, tt.a); got != tt.want {
|
||
t.Errorf("Similar(%q, %q) = %v, want %v", tt.b, tt.a, got, tt.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestSimilarity(t *testing.T) {
|
||
for _, tc := range []struct {
|
||
a, b string
|
||
want float64
|
||
}{
|
||
{"Easy Chicken Curry", "chicken curry", 1},
|
||
{"Thai Green Curry", "Chicken curry", 0.25},
|
||
{"Beef Stew", "Chicken curry", 0},
|
||
{"The Recipe", "chicken curry", 0},
|
||
} {
|
||
if got := Similarity(tc.a, tc.b); got != tc.want {
|
||
t.Errorf("Similarity(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestDeduper(t *testing.T) {
|
||
d := NewDeduper(DedupeSources{
|
||
Regulars: []string{"Beef Tacos", "Spaghetti Bolognese"},
|
||
QuestLog: []string{"Chicken Tikka Masala"},
|
||
Pending: []string{"Crème Brûlée"},
|
||
Declined: []string{"Shepherd's Pie"},
|
||
})
|
||
tests := []struct {
|
||
name string
|
||
want bool
|
||
}{
|
||
{"Tacos (beef)", true}, // Regular
|
||
{"spaghetti bolognese recipe", true}, // Regular
|
||
{"Easy Chicken Tikka Masala", true}, // Quest log
|
||
{"creme brulee", true}, // pending suggestion
|
||
{"The Best Shepherds Pie", true}, // declined
|
||
{"Fish tacos", false}, // shares only "tacos"
|
||
{"Chicken Tikka Masala Curry", false}, // below the threshold
|
||
{"Jalapeño Poppers", false}, // new
|
||
{"Easy Recipe", false}, // nothing left to compare
|
||
{"Spaghetti Carbonara", false}, // shares only "spaghetti"
|
||
{"Bolognese Spaghetti (Classic)", true}, // word order does not matter
|
||
}
|
||
for _, tt := range tests {
|
||
if got := d.IsDuplicate(tt.name); got != tt.want {
|
||
t.Errorf("IsDuplicate(%q) = %v, want %v", tt.name, got, tt.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestDeduperEmpty(t *testing.T) {
|
||
if NewDeduper(DedupeSources{}).IsDuplicate("Beef Tacos") {
|
||
t.Error("an empty Deduper reported a duplicate")
|
||
}
|
||
}
|