package suggestions import ( "encoding/json" "net/http" "testing" "gitea.parodia.dev/drwily/petal/internal/db" ) // The offline rule pack and the LLM now share the collocation family, which is // the point: the writer sees one rail and is never told which engine spoke. What // makes that safe is `source` — each pass replaces only its own rows. These tests // pin the two ways that could go wrong, both of which the old type-scoped DELETEs // would have hit. // pendingOfType counts the pending rows of one family in a response body. func pendingOfType(got []db.Suggestion, typ string) []db.Suggestion { var out []db.Suggestion for _, s := range got { if s.Type == typ { out = append(out, s) } } return out } // TestOfflineCollocationFilesAsCollocation proves a miscollocation the rule pack // found is stored in the collocation family (so accepting it plants a garden // card, exactly as the coach's would) while still being marked as locally found. func TestOfflineCollocationFilesAsCollocation(t *testing.T) { srv, docID, _ := newTestServer(t, &stubClient{response: `{"suggestions":[]}`}) got := postMechanics(t, srv, docID, `[ {"from":0,"to":13,"original":"do a decision","replacement":"make a decision","explanation":"pairing","type":"collocation"}, {"from":20,"to":27,"original":"the the","replacement":"the","explanation":"doubled word","type":"mechanics"} ]`) if len(got) != 2 { t.Fatalf("want both findings, got %+v", got) } coll := pendingOfType(got, db.SuggestionTypeCollocation) if len(coll) != 1 { t.Fatalf("want 1 collocation, got %+v", got) } if coll[0].Source != db.SuggestionSourceLocal { t.Errorf("offline finding should be source=local, got %q", coll[0].Source) } if mech := pendingOfType(got, db.SuggestionTypeMechanics); len(mech) != 1 { t.Fatalf("want 1 mechanics finding, got %+v", got) } } // TestUnknownLocalTypeFallsBackToMechanics: a family the offline pass isn't // allowed to claim (or an older client sending none at all) must land in // mechanics. Otherwise a stray label would smuggle a row into an LLM family, // where nothing would ever replace it. func TestUnknownLocalTypeFallsBackToMechanics(t *testing.T) { srv, docID, _ := newTestServer(t, &stubClient{response: `{"suggestions":[]}`}) got := postMechanics(t, srv, docID, `[ {"from":0,"to":5,"original":"aaaaa","replacement":"bbbbb","explanation":"x","type":"voice"}, {"from":6,"to":11,"original":"ccccc","replacement":"ddddd","explanation":"y"} ]`) if len(got) != 2 { t.Fatalf("want 2 findings, got %+v", got) } for _, s := range got { if s.Type != db.SuggestionTypeMechanics { t.Errorf("offline finding claimed family %q; only mechanics/collocation are allowed", s.Type) } } } // TestCoachDoesNotWipeOfflineCollocations is the collision the source column // exists for: the LLM collocation pass replaces the collocation family, and the // rule pack's share of that family has to survive it. Before `source`, running // the coach silently deleted every offline chunk on the page. func TestCoachDoesNotWipeOfflineCollocations(t *testing.T) { client := &stubClient{response: `{"suggestions":[ {"original":"apple","replacement":"an apple","explanation":"article","type":"collocation"} ]}`} srv, docID, _ := newTestServer(t, client) // The seeded doc is "I has two apple." — the coach's flag anchors on "apple" // at [10,15], so the offline finding is given a span well clear of it. Two // findings fighting over the same characters is a different rule (see // TestOfflineCardWinsSpanCollision); this test is about the DELETE. postMechanics(t, srv, docID, `[ {"from":0,"to":5,"original":"do a decision","replacement":"make a decision","explanation":"pairing","type":"collocation"} ]`) rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/collocation", "") if rec.Code != http.StatusOK { t.Fatalf("collocation pass: code=%d body=%s", rec.Code, rec.Body) } var got []db.Suggestion if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("decode: %v", err) } var local, llm int for _, s := range pendingOfType(got, db.SuggestionTypeCollocation) { if s.Source == db.SuggestionSourceLocal { local++ } else { llm++ } } if local != 1 { t.Errorf("the coach wiped the offline collocation: local=%d, got %+v", local, got) } if llm != 1 { t.Errorf("want the coach's own flag alongside it: llm=%d, got %+v", llm, got) } } // TestOfflinePassReplacesItsOwnCollocations is the mirror: the rule pack // recomputes the whole document every run, so a chunk the current text no longer // warrants must go — and the coach's flags must stay. Scoping the offline DELETE // by type instead of source would have stranded the first row forever. func TestOfflinePassReplacesItsOwnCollocations(t *testing.T) { client := &stubClient{response: `{"suggestions":[ {"original":"apple","replacement":"an apple","explanation":"article","type":"collocation"} ]}`} srv, docID, _ := newTestServer(t, client) // A coach flag, then an offline chunk, then a rerun that no longer finds it. do(t, srv, http.MethodPost, "/docs/"+docID+"/collocation", "") postMechanics(t, srv, docID, `[ {"from":0,"to":13,"original":"do a decision","replacement":"make a decision","explanation":"pairing","type":"collocation"} ]`) got := postMechanics(t, srv, docID, `[]`) for _, s := range got { if s.Source == db.SuggestionSourceLocal { t.Errorf("stale offline finding survived a recompute: %+v", s) } } if len(pendingOfType(got, db.SuggestionTypeCollocation)) != 1 { t.Fatalf("the coach's own flag should be untouched, got %+v", got) } } // TestOfflineCollocationPlantsOnAccept closes the loop the family split was for: // a chunk the rule pack found, accepted, becomes a vocabulary-garden card — with // no model involved anywhere in the path. func TestOfflineCollocationPlantsOnAccept(t *testing.T) { srv, docID, h := newTestServer(t, &stubClient{response: `{"suggestions":[]}`}) if _, err := h.DB.Exec( `UPDATE documents SET content_text = ? WHERE id = ?`, "I had to do a decision about the job.", docID, ); err != nil { t.Fatalf("set content: %v", err) } got := postMechanics(t, srv, docID, `[ {"from":9,"to":22,"original":"do a decision","replacement":"make a decision","explanation":"pairing","type":"collocation"} ]`) if len(got) != 1 { t.Fatalf("want the offline chunk, got %+v", got) } if rec := do(t, srv, http.MethodPost, "/suggestions/"+got[0].ID+"/accept", ""); rec.Code != http.StatusNoContent { t.Fatalf("accept: code=%d body=%s", rec.Code, rec.Body) } cards := gardenCards(t, h) if len(cards) != 1 || cards[0].word != "make a decision" { t.Fatalf("want a planted phrase card, got %+v", cards) } // The example is the corrected sentence — the phrasing she kept, not the one // she just left behind. if cards[0].example != "I had to make a decision about the job." { t.Errorf("example should be the corrected sentence, got %q", cards[0].example) } } // TestOfflineCardWinsSpanCollision: the tiebreak is by engine, not by family. An // offline miscollocation has an exact span; the coach's overlapping flag is only // advisory, so it is the one that goes. func TestOfflineCardWinsSpanCollision(t *testing.T) { client := &stubClient{response: `{"suggestions":[ {"original":"do a decision about","replacement":"decide about","explanation":"wordy","type":"collocation"} ]}`} srv, docID, h := newTestServer(t, client) if _, err := h.DB.Exec( `UPDATE documents SET content_text = ? WHERE id = ?`, "I had to do a decision about the job.", docID, ); err != nil { t.Fatalf("set content: %v", err) } do(t, srv, http.MethodPost, "/docs/"+docID+"/collocation", "") got := postMechanics(t, srv, docID, `[ {"from":9,"to":22,"original":"do a decision","replacement":"make a decision","explanation":"pairing","type":"collocation"} ]`) if len(got) != 1 { t.Fatalf("want the overlapping coach flag dropped, got %+v", got) } if got[0].Source != db.SuggestionSourceLocal { t.Errorf("the exact offline card should own the span, got %+v", got[0]) } }