package suggestions import ( "encoding/json" "net/http" "strings" "testing" "gitea.parodia.dev/drwily/petal/internal/db" "gitea.parodia.dev/drwily/petal/internal/llm" ) // byOriginal indexes a pending set by the text each card flags. func byOriginal(in []db.Suggestion) map[string]db.Suggestion { out := map[string]db.Suggestion{} for _, s := range in { out[s.Original] = s } return out } // TestUntouchedSentencesKeepTheirCards is the heart of the stability work: she // edits one sentence, and the cards on every other sentence stay exactly as they // were — same id (so the rail keeps the card instead of remounting it), same // explanation (the model re-words its reasoning every time it is asked, and one // unchanged mistake used to carry three different explanations in a sitting). // The model is only asked about the sentence that changed. func TestUntouchedSentencesKeepTheirCards(t *testing.T) { client := &stubClient{response: `{"suggestions":[ {"original":"I has two apple","replacement":"I have two apples","explanation":"first wording","type":"grammar"}, {"original":"She go to market","replacement":"She goes to market","explanation":"agreement","type":"grammar"} ]}`} srv, docID, h := newTestServer(t, client) h.Limit = llm.NewRateLimiter(0) setDocText(t, h, docID, "I has two apple. She go to market yesterday.") rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "") var first []db.Suggestion if err := json.Unmarshal(rec.Body.Bytes(), &first); err != nil { t.Fatalf("decode: %v", err) } if len(first) != 2 { t.Fatalf("first pass: want 2, got %d: %+v", len(first), first) } kept := byOriginal(first)["I has two apple"] // She fixes only the second sentence. The model, asked again, re-words its // reasoning about the first — which it must never get the chance to do. setDocText(t, h, docID, "I has two apple. She goes to market yesterday.") client.response = `{"suggestions":[ {"original":"I has two apple","replacement":"I have two apples","explanation":"REWORDED","type":"grammar"} ]}` rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "") var second []db.Suggestion if err := json.Unmarshal(rec.Body.Bytes(), &second); err != nil { t.Fatalf("decode: %v", err) } if strings.Contains(client.lastPrompt, "I has two apple") { t.Fatalf("untouched sentence was sent to the model:\n%s", client.lastPrompt) } if !strings.Contains(client.lastPrompt, "She goes to market") { t.Fatalf("edited sentence was not sent to the model:\n%s", client.lastPrompt) } now := byOriginal(second)["I has two apple"] if now.ID != kept.ID { t.Fatalf("card was remounted: id %q became %q", kept.ID, now.ID) } if now.Explanation != "first wording" { t.Fatalf("explanation drifted: %q", now.Explanation) } // The fixed sentence's card is gone, and the model's stray re-proposal for the // cached sentence did not become a second card. if len(second) != 1 { t.Fatalf("want exactly one card left, got %d: %+v", len(second), second) } } // TestUnchangedDocumentSkipsTheModel proves a check with nothing new to read // costs nothing: no model call, and every card left standing untouched. This is // the doc-open and tone-less re-check path. func TestUnchangedDocumentSkipsTheModel(t *testing.T) { client := &stubClient{response: `{"suggestions":[ {"original":"I has","replacement":"I have","explanation":"agreement","type":"grammar"} ]}`} srv, docID, h := newTestServer(t, client) h.Limit = llm.NewRateLimiter(0) rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "") var first []db.Suggestion _ = json.Unmarshal(rec.Body.Bytes(), &first) if len(first) != 1 || client.calls != 1 { t.Fatalf("first pass: %d cards, %d calls", len(first), client.calls) } rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "") var second []db.Suggestion if err := json.Unmarshal(rec.Body.Bytes(), &second); err != nil { t.Fatalf("decode: %v", err) } if client.calls != 1 { t.Fatalf("re-checking an unedited document called the model %d times", client.calls) } if len(second) != 1 || second[0].ID != first[0].ID { t.Fatalf("card did not survive an idle re-check: %+v", second) } } // TestDeletedSentenceDropsItsCard covers the other half of the skip path: she // removes a flagged sentence outright, so nothing changed that the model could // be asked about — but its card must still go. func TestDeletedSentenceDropsItsCard(t *testing.T) { client := &stubClient{response: `{"suggestions":[ {"original":"I has","replacement":"I have","explanation":"agreement","type":"grammar"} ]}`} srv, docID, h := newTestServer(t, client) h.Limit = llm.NewRateLimiter(0) do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "") setDocText(t, h, docID, "") rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "") var got []db.Suggestion if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("decode: %v", err) } if len(got) != 0 { t.Fatalf("card outlived its sentence: %+v", got) } } // TestToneChangeReopensEverySentence: the checkpoint's advice is written for the // document's tone, so switching from a journal to an academic essay has to // re-read sentences that haven't changed a character. func TestToneChangeReopensEverySentence(t *testing.T) { client := &stubClient{response: `{"suggestions":[ {"original":"I has","replacement":"I have","explanation":"agreement","type":"grammar"} ]}`} srv, docID, h := newTestServer(t, client) h.Limit = llm.NewRateLimiter(0) do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "") if _, err := h.DB.Exec(`UPDATE documents SET tone = 'academic' WHERE id = ?`, docID); err != nil { t.Fatalf("set tone: %v", err) } do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "") if client.calls != 2 { t.Fatalf("tone change did not re-read the document: %d model calls", client.calls) } } // TestMechanicsFindingsKeepTheirRows: the rule pack re-runs 250 ms after every // keystroke. A finding it still reports must keep its row, or the rail would // remount several times a sentence — collapsing a card she has open, and // re-firing the arrival chime for advice she is already reading. func TestMechanicsFindingsKeepTheirRows(t *testing.T) { srv, docID, _ := newTestServer(t, &stubClient{response: `{"suggestions":[]}`}) body := `{"findings":[ {"from":0,"to":5,"original":"I has","replacement":"I have","explanation":"agreement"}, {"from":6,"to":15,"original":"two apple","replacement":"two apples","explanation":"plural"} ]}` rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/mechanics", body) var first []db.Suggestion if err := json.Unmarshal(rec.Body.Bytes(), &first); err != nil { t.Fatalf("decode: %v", err) } if len(first) != 2 { t.Fatalf("want 2 rows, got %d", len(first)) } // She types elsewhere: same findings, shifted spans, one of them now fixed. rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/mechanics", `{"findings":[ {"from":20,"to":25,"original":"I has","replacement":"I have","explanation":"agreement"} ]}`) var second []db.Suggestion if err := json.Unmarshal(rec.Body.Bytes(), &second); err != nil { t.Fatalf("decode: %v", err) } if len(second) != 1 { t.Fatalf("want 1 row, got %d: %+v", len(second), second) } if second[0].ID != byOriginal(first)["I has"].ID { t.Fatalf("surviving finding was given a new identity: %+v", second[0]) } if second[0].FromPos != 20 { t.Fatalf("span did not follow the text: %+v", second[0]) } }