package suggestions import ( "encoding/json" "net/http" "testing" "gitea.parodia.dev/drwily/petal/internal/db" ) // The settled endpoint exists for the offline half of the loop. The rule pack // detects from the text alone, 250 ms after a keystroke, and has no memory // between runs — so without the document's record of what she has already // answered, a dismissed finding is re-detected and re-rendered on the next // keystroke, and stays there for as long as the server can't be reached. func getSettled(t *testing.T, srv http.Handler, docID string) []string { t.Helper() rec := do(t, srv, http.MethodGet, "/docs/"+docID+"/settled", "") if rec.Code != http.StatusOK { t.Fatalf("settled: code=%d body=%s", rec.Code, rec.Body) } var out struct { Originals []string `json:"originals"` } if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatalf("decode: %v", err) } return out.Originals } func contains(list []string, want string) bool { for _, s := range list { if s == want { return true } } return false } // TestSettledListsActionedSpans proves the endpoint reports exactly the spans the // suppressor would drop: accepted and dismissed, never pending. A pending row // leaking in would be the damaging direction — the client would hide a card she // has never been shown an answer to. func TestSettledListsActionedSpans(t *testing.T) { client := &stubClient{response: `{"suggestions":[ {"original":"I has","replacement":"I have","explanation":"agreement","type":"grammar"}, {"original":"two apple","replacement":"two apples","explanation":"plural","type":"grammar"} ]}`} srv, docID, _ := newTestServer(t, client) if got := getSettled(t, srv, docID); len(got) != 0 { t.Fatalf("nothing actioned yet, got %v", got) } rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "") var got []db.Suggestion _ = json.Unmarshal(rec.Body.Bytes(), &got) if len(got) != 2 { t.Fatalf("first pass: want 2, got %d", len(got)) } // One accepted, one still pending: only the accepted span is settled. do(t, srv, http.MethodPost, "/suggestions/"+got[0].ID+"/accept", "") settled := getSettled(t, srv, docID) if len(settled) != 1 || settled[0] != got[0].Original { t.Fatalf("want just %q settled, got %v", got[0].Original, settled) } // A dismissal settles a span just as an accept does — the whole point of the // item: "you already decided about this one" doesn't mean "you agreed". do(t, srv, http.MethodPost, "/suggestions/"+got[1].ID+"/dismiss", "") settled = getSettled(t, srv, docID) if len(settled) != 2 || !contains(settled, got[1].Original) { t.Fatalf("dismissed span missing from %v", settled) } } // TestSettledNormalizesAndDedupes proves the payload is normalized server-side // and collapsed. The client compares its freshly-detected findings against these // strings, so the two sides have to agree on what "the same span" is — the // editor's quote churn is the case that breaks a byte-exact match, and it is why // normalizeForDedup exists at all. func TestSettledNormalizesAndDedupes(t *testing.T) { client := &stubClient{response: `{"suggestions":[]}`} srv, docID, h := newTestServer(t, client) // The same span twice, differing only in quote style and line breaks — one // accepted, one dismissed. Distinct rows; one settled span. a := seedSuggestion(t, h, docID, "text", db.SuggestionTypeGrammar, "She said \"hello\"\n to me", "She said 'hello' to me", "quotes") b := seedSuggestion(t, h, docID, "text", db.SuggestionTypeGrammar, "She said “hello” to me", "She said 'hello' to me", "quotes") do(t, srv, http.MethodPost, "/suggestions/"+a+"/accept", "") do(t, srv, http.MethodPost, "/suggestions/"+b+"/dismiss", "") settled := getSettled(t, srv, docID) if len(settled) != 1 { t.Fatalf("two spellings of one span should collapse to one, got %v", settled) } if want := "She said 'hello' to me"; settled[0] != want { t.Fatalf("settled[0] = %q, want normalized %q", settled[0], want) } } // TestNormalizeMatchesTheClient is the Go half of a pair. Every case here also // appears in web/src/lib/settled.test.ts, asserted against the TypeScript // reimplementation of this function. The two are compared across a network // boundary — the server normalizes what it sends, the client normalizes what it // checks against it — so they have to fold the same characters the same way, and // nothing but a shared list of cases can say so. Add to both or neither. func TestNormalizeMatchesTheClient(t *testing.T) { cases := []struct{ in, want string }{ {"She said “hello”", "She said 'hello'"}, {"She said \"hello\"", "She said 'hello'"}, {"it‘s", "it's"}, {"it’s", "it's"}, {"`code´", "'code'"}, {" a apple\n here ", "a apple here"}, {"a\tapple", "a apple"}, {" \n ", ""}, {"我想说这句话", "我想说这句话"}, } for _, c := range cases { if got := normalizeForDedup(c.in); got != c.want { t.Errorf("normalizeForDedup(%q) = %q, want %q", c.in, got, c.want) } } } // TestSettledEmptyIsAList guards the shape rather than the content: the client // spreads this array into its settled set, and a null would throw there. Go // marshals a nil slice as null, so this is one `[]string{}` away from breaking. func TestSettledEmptyIsAList(t *testing.T) { client := &stubClient{response: `{"suggestions":[]}`} srv, docID, _ := newTestServer(t, client) rec := do(t, srv, http.MethodGet, "/docs/"+docID+"/settled", "") if body := rec.Body.String(); body != "{\"originals\":[]}\n" && body != "{\"originals\":[]}" { t.Fatalf("empty settled body = %q, want an empty list", body) } }