00595c2211
The transcript beside the spoken reply read "на+04.08.2026+ничего+нет." X-Reply-Text was written with url.QueryEscape, which is form encoding and writes a space as "+", and static/app.js reads it with decodeURIComponent, which only knows "%20". Every space in a spoken reply arrived as a plus. Fixed on the Go side rather than by replacing plus with space in the client: the encoding is a property of the header, and a client that has to know which flavour it got is a client that will get it wrong again. Escaping in the client's own dialect also keeps a plus the speaker actually said — "2+2" — from becoming a space. PathEscape writes %0A for a newline too, so a two-line reply stays a legal header value instead of a truncated one. The test round-trips through a stand-in for decodeURIComponent rather than checking the encoder alone, because QueryEscape passes any assertion that only looks at what went in. Cosmetic and log-only. The audio was never affected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
60 lines
2.2 KiB
Go
60 lines
2.2 KiB
Go
package main
|
|
|
|
import (
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// decodeURIComponent is what static/app.js calls on X-Reply-Text. PathUnescape
|
|
// is its Go equivalent for this purpose: both turn %XX into bytes and both
|
|
// leave a literal "+" alone. That last part is the whole defect — QueryEscape
|
|
// wrote spaces as "+" and the client had no way to tell those from a plus the
|
|
// speaker actually said.
|
|
func decodeURIComponent(t *testing.T, s string) string {
|
|
t.Helper()
|
|
out, err := url.PathUnescape(s)
|
|
if err != nil {
|
|
t.Fatalf("decodeURIComponent(%q): %v", s, err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// The reply the QA session actually saw was "на+04.08.2026+ничего+нет."
|
|
// (Vikunja #533). Round-tripping through the client's decoder is the assertion
|
|
// that matters — checking the encoder in isolation would have passed with
|
|
// QueryEscape too.
|
|
func TestReplyTextSurvivesTheClientDecoder(t *testing.T) {
|
|
cases := []string{
|
|
"на 04.08.2026 ничего нет.",
|
|
"Я поставила тебе напоминание позвонить маме через час.",
|
|
// A literal plus must stay a plus, which is the case that makes
|
|
// "just replace + with space on the JS side" the wrong fix.
|
|
"два плюс два = 2+2",
|
|
// Headers cannot carry a raw newline. PathEscape writes %0A.
|
|
"первая строка\nвторая строка",
|
|
"", // no reply text at all
|
|
}
|
|
for _, want := range cases {
|
|
encoded := url.PathEscape(want)
|
|
if strings.ContainsAny(encoded, "\r\n") {
|
|
t.Errorf("encoded %q contains a raw newline, which is not a legal header value", want)
|
|
}
|
|
if got := decodeURIComponent(t, encoded); got != want {
|
|
t.Errorf("round trip: got %q, want %q", got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The specific regression, named. QueryEscape is form encoding and this header
|
|
// is not a form.
|
|
func TestReplyTextDoesNotUseFormEncoding(t *testing.T) {
|
|
const spoken = "на 04.08.2026 ничего нет."
|
|
if got := decodeURIComponent(t, url.QueryEscape(spoken)); got == spoken {
|
|
t.Skip("QueryEscape round-trips here, so this test proves nothing — check the decoder stand-in")
|
|
}
|
|
if strings.Contains(url.PathEscape(spoken), "+") {
|
|
t.Errorf("PathEscape(%q) still writes a plus", spoken)
|
|
}
|
|
}
|