Merge task/533-ptt-reply-text-shows-instead-of-spaces-q

This commit is contained in:
2026-08-05 02:02:27 +04:00
2 changed files with 66 additions and 1 deletions
+7 -1
View File
@@ -1540,7 +1540,13 @@ func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session
return
}
w.Header().Set("Content-Type", "audio/l16;rate=16000;channels=1")
w.Header().Set("X-Reply-Text", url.QueryEscape(pttResp.ReplyText))
// PathEscape, not QueryEscape (Vikunja #533). QueryEscape writes a space
// as "+", which is form encoding, and the client decodes this header
// with decodeURIComponent, which only knows "%20" — so every space in a
// spoken reply reached the on-page log as a plus sign. PathEscape is the
// flavour decodeURIComponent actually reverses, which keeps the encoding
// a property of the header rather than something the client has to know.
w.Header().Set("X-Reply-Text", url.PathEscape(pttResp.ReplyText))
w.Write(pttResp.ReplyAudio.Bytes)
return
}
+59
View File
@@ -0,0 +1,59 @@
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)
}
}