package phraser import ( "errors" "strings" "testing" ) // A reply that starts a JSON object and never finishes it is a failed // generation, not a reply. Before this, the parser returned ("", "") for these // and every caller then shipped the raw fragment as the thing Maven said. A // real run produced replies of literally "{" and "{\n \"". func TestParseResponseMoodRejectsUnfinishedJSON(t *testing.T) { for _, raw := range []string{ `{`, "{\n \"", `{"response": "неполн`, `{"response": "текст", "mood":`, } { text, mood, err := parseResponseMood(raw) if !errors.Is(err, errBrokenJSON) { t.Errorf("parseResponseMood(%q) err = %v, want errBrokenJSON", raw, err) } if text != "" || mood != "" { t.Errorf("parseResponseMood(%q) leaked %q/%q — a fragment must never come back as a reply", raw, text, mood) } } } // Bare prose is still fine. Small models sometimes answer without any JSON at // all, and that reply is usable — so the new error must not swallow it. func TestParseResponseMoodAllowsBareProse(t *testing.T) { for _, raw := range []string{ "норм, а ты как?", "вот что я нашла: ключ у соседа", } { text, mood, err := parseResponseMood(raw) if err != nil { t.Errorf("parseResponseMood(%q) err = %v, want nil", raw, err) } // No JSON means no fields; the caller ships raw as-is. if text != "" || mood != "" { t.Errorf("parseResponseMood(%q) = %q/%q, want empty", raw, text, mood) } } } // The measured failure: the model wants more than 400 characters and the old // grammar cut it off mid-word. Guards the bound against being tightened back. func TestGrammarStringBoundHasRoomForARealAnswer(t *testing.T) { if !strings.Contains(responseGrammar, "{0,1000}") { t.Error("grammar string bound is not 1000; 400 truncated real replies mid-word (see the comment on responseGrammar)") } } // A multi-line reply is the sixty-failure shape from the 2026-08-05 temperature // sweep (Vikunja #537). The model wrote a literal newline inside the string, // which the old grammar allowed and json.Unmarshal rejects with "invalid // character '\n' in string literal". The object starts with "{", so it came back // as errBrokenJSON and the case answered with nothing at all. // // The grammar is the fix. This is the parser's own second line, for the paths // that send no grammar: the reply is readable, so read it. func TestParseResponseMoodRepairsARawNewline(t *testing.T) { raw := "{\"response\": \"первая строка\nвторая строка\", \"mood\": \"neutral\"}" text, mood, err := parseResponseMood(raw) if err != nil { t.Fatalf("err = %v, want nil — a raw newline is repairable, not a failed generation", err) } if want := "первая строка\nвторая строка"; text != want { t.Errorf("response = %q, want %q", text, want) } if mood != "neutral" { t.Errorf("mood = %q, want neutral", mood) } } // Repairing must not rewrite JSON that was already fine: an escaped newline // stays one newline, and a backslash the model escaped properly is left alone. func TestEscapeRawControlsLeavesValidJSONAlone(t *testing.T) { raw := `{"response": "строка\nдве \\ и \"кавычки\"", "mood": "happy"}` if got := escapeRawControls(raw); got != raw { t.Errorf("escapeRawControls rewrote valid JSON:\n got %q\nwant %q", got, raw) } text, _, err := parseResponseMood(raw) if err != nil { t.Fatalf("err = %v", err) } if want := "строка\nдве \\ и \"кавычки\""; text != want { t.Errorf("response = %q, want %q", text, want) } } // Pretty-printed JSON is what Qwen3-1.7B writes: it opens the object and puts // three newlines before the first key. Escaping those structural newlines made // the object unparseable, so 31 of 36 conversational cases in the talk fixture // answered from the stub (Vikunja #44, measured 2026-08-05). func TestParseResponseMoodReadsPrettyPrintedJSON(t *testing.T) { raw := "{\n\n\n \"response\": \"Хорошо настроение.\",\n \"mood\": \"neutral\"\n}" text, mood, err := parseResponseMood(raw) if err != nil { t.Fatalf("err = %v, want nil — this is valid JSON, not a failed generation", err) } if want := "Хорошо настроение."; text != want { t.Errorf("response = %q, want %q", text, want) } if mood != "neutral" { t.Errorf("mood = %q, want neutral", mood) } } // Both at once: structural newlines outside the strings, a raw one inside. func TestParseResponseMoodRepairsInsideAndKeepsOutside(t *testing.T) { raw := "{\n\t\"response\": \"первая\nвторая\",\n\t\"mood\": \"tired\"\n}" text, mood, err := parseResponseMood(raw) if err != nil { t.Fatalf("err = %v, want nil", err) } if want := "первая\nвторая"; text != want { t.Errorf("response = %q, want %q", text, want) } if mood != "tired" { t.Errorf("mood = %q, want tired", mood) } } // A tab and a bare control byte take the same path as the newline. func TestParseResponseMoodRepairsOtherControls(t *testing.T) { raw := "{\"response\": \"таб\tи \x01байт\", \"mood\": \"tired\"}" text, _, err := parseResponseMood(raw) if err != nil { t.Fatalf("err = %v, want nil", err) } if want := "таб\tи \x01байт"; text != want { t.Errorf("response = %q, want %q", text, want) } }