A Documents picker on /chat offers the collections the person may read in the chosen project, the default when there is one, or none; the sources an answer was given are listed under it. The page's choice becomes the X-Llmgw-Knowledge header; one the page sent itself is dropped. In English and Turkish. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
204 lines
7.0 KiB
Go
204 lines
7.0 KiB
Go
package web
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
func chatPage(t *testing.T, names ...string) string {
|
|
t.Helper()
|
|
models := make([]ChatModel, 0, len(names))
|
|
for _, n := range names {
|
|
models = append(models, ChatModel{Name: n})
|
|
}
|
|
return chatPageWith(t, models)
|
|
}
|
|
|
|
func chatPageWith(t *testing.T, models []ChatModel) string {
|
|
t.Helper()
|
|
h := Chat(store.User{Email: "a@example.com"}, models, nil, "", "v1", ChatKnowledge{})
|
|
r := httptest.NewRequest(http.MethodGet, "http://x/chat", nil)
|
|
w := httptest.NewRecorder()
|
|
h(w, r)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status %d", w.Code)
|
|
}
|
|
return w.Body.String()
|
|
}
|
|
|
|
func TestMathIsRenderedWithoutTrustingTheModel(t *testing.T) {
|
|
// The ordering in the page is the safety argument, so it is worth asserting
|
|
// rather than remembering: the reply is put in place with textContent and
|
|
// only then walked for maths. KaTeX replaces the spans it recognises, so
|
|
// nothing the model wrote is ever parsed as HTML.
|
|
page := chatPage(t, "m")
|
|
|
|
if !strings.Contains(page, "/_static/katex/katex.min.css") ||
|
|
!strings.Contains(page, "/_static/katex/auto-render.min.js") {
|
|
t.Fatal("the page does not load KaTeX")
|
|
}
|
|
// Vendored, not fetched: this gateway makes no outbound requests and is
|
|
// expected to run where it could not.
|
|
for _, cdn := range []string{"cdn.jsdelivr", "unpkg.com", "cdnjs.", "//katex.org"} {
|
|
if strings.Contains(page, cdn) {
|
|
t.Errorf("the page loads KaTeX from %s instead of the binary", cdn)
|
|
}
|
|
}
|
|
|
|
if !strings.Contains(page, "trust: false") {
|
|
t.Error("trust is not pinned off; \\href in model output would become a link")
|
|
}
|
|
if !strings.Contains(page, "throwOnError: false") {
|
|
t.Error("a single malformed expression would take the whole reply with it")
|
|
}
|
|
|
|
// The reply still reaches the page as text first.
|
|
if !strings.Contains(page, "out.textContent = reply") {
|
|
t.Error("the reply is no longer written with textContent")
|
|
}
|
|
// Only the server's rendering of a finished reply is placed as HTML; see
|
|
// TestTheTruncationNoticeIsNotPutInsideTheReply.
|
|
if strings.Count(page, "innerHTML") != 1 {
|
|
t.Error("the chat page assigns innerHTML beyond the rendered reply")
|
|
}
|
|
}
|
|
|
|
func TestMathIsRenderedOnceTheReplyIsComplete(t *testing.T) {
|
|
// Typesettingevery chunk would try to render half an expression on every token
|
|
// and flicker through the whole reply.
|
|
page := chatPage(t, "m")
|
|
body := page[strings.Index(page, "form.onsubmit"):]
|
|
|
|
renderAt := strings.Index(body, "renderMath(out)")
|
|
streamAt := strings.Index(body, "out.textContent = reply")
|
|
if renderAt < 0 || streamAt < 0 {
|
|
t.Fatal("the streaming loop or the render call is missing")
|
|
}
|
|
if renderAt < streamAt {
|
|
t.Error("maths is rendered before the text that carries it")
|
|
}
|
|
}
|
|
|
|
func TestDisplayDelimitersComeBeforeInlineOnes(t *testing.T) {
|
|
// $$…$$ has to be tried before $…$, or the opening $$ is read as an empty
|
|
// inline expression and everything after it is wrong.
|
|
page := chatPage(t, "m")
|
|
dd := strings.Index(page, `left: "$$"`)
|
|
d := strings.Index(page, `left: "$"`)
|
|
if dd < 0 || d < 0 {
|
|
t.Fatal("the delimiters are missing")
|
|
}
|
|
if dd > d {
|
|
t.Error(`$ is tried before $$`)
|
|
}
|
|
}
|
|
|
|
func TestTheChatPageOffersWhatItWasGiven(t *testing.T) {
|
|
page := chatPage(t, "alpha", "beta")
|
|
for _, m := range []string{"alpha", "beta"} {
|
|
if !strings.Contains(page, `value="`+m+`"`) {
|
|
t.Errorf("the picker does not offer %s", m)
|
|
}
|
|
}
|
|
empty := chatPageWith(t, nil)
|
|
if strings.Contains(empty, "<form id=\"composer\"") {
|
|
t.Error("a composer was rendered with no model to send to")
|
|
}
|
|
if !strings.Contains(empty, "No models are available") {
|
|
t.Error("the page does not explain why it is empty")
|
|
}
|
|
}
|
|
|
|
func TestTheSimulatedModelSaysSo(t *testing.T) {
|
|
// big-model returns Greek letters at a fixed rate. Offered next to a real
|
|
// model with nothing to tell them apart, it reads as a broken model rather
|
|
// than a load generator, which is how it was first reported.
|
|
page := chatPageWith(t, []ChatModel{
|
|
{Name: "qwen"},
|
|
{Name: "big-model", Simulated: true},
|
|
})
|
|
if !strings.Contains(page, `>big-model (simulated)<`) {
|
|
t.Error("the simulated model is offered without saying it is simulated")
|
|
}
|
|
if strings.Contains(page, `>qwen (simulated)<`) {
|
|
t.Error("a real model was labelled simulated")
|
|
}
|
|
if !strings.Contains(page, `value="big-model" data-simulated="1"`) ||
|
|
!strings.Contains(page, `value="qwen" data-simulated="0"`) {
|
|
t.Error("the script cannot tell the two apart")
|
|
}
|
|
if !strings.Contains(page, "placeholder text, not inference") {
|
|
t.Error("the page never explains what a simulated model returns")
|
|
}
|
|
}
|
|
|
|
func TestTheFirstOfferedModelAnswers(t *testing.T) {
|
|
// A browser selects the first option, so whatever leads the list is what
|
|
// someone gets when they type and press Send without looking at the picker.
|
|
page := chatPageWith(t, []ChatModel{
|
|
{Name: "qwen"},
|
|
{Name: "big-model", Simulated: true},
|
|
})
|
|
real, sim := strings.Index(page, `value="qwen"`), strings.Index(page, `value="big-model"`)
|
|
if real < 0 || sim < 0 {
|
|
t.Fatal("both models should be offered")
|
|
}
|
|
if real > sim {
|
|
t.Error("a simulated model is preselected; the default reply would be placeholder text")
|
|
}
|
|
}
|
|
|
|
func TestATruncatedReplySaysSo(t *testing.T) {
|
|
// A reply that hit the token cap looks exactly like one that finished: the
|
|
// stream ends and the text stops. Reported as the model not completing the
|
|
// request, which is a fair reading when nothing says otherwise.
|
|
page := chatPage(t, "m")
|
|
if !strings.Contains(page, "finish_reason") {
|
|
t.Error("the page never reads why the model stopped")
|
|
}
|
|
if !strings.Contains(page, `finish === "length"`) {
|
|
t.Error("the page does not distinguish running out of room from finishing")
|
|
}
|
|
if !strings.Contains(page, "Cut off at the length limit") {
|
|
t.Error("nothing tells the reader the answer was cut short")
|
|
}
|
|
}
|
|
|
|
func TestTheTruncationNoticeIsNotPutInsideTheReply(t *testing.T) {
|
|
// out holds model output placed with textContent. The notice is ours, so
|
|
// it goes beside that element — putting it inside would mean building
|
|
// markup in the same node the model writes into.
|
|
page := chatPage(t, "m")
|
|
if !strings.Contains(page, "out.after(note)") {
|
|
t.Error("the notice is not appended as a sibling of the reply")
|
|
}
|
|
// The one exception is the finished reply as /chat/render returns it —
|
|
// markdown the server has already made safe — and nothing else may be
|
|
// written into the reply as HTML.
|
|
if n := strings.Count(page, "innerHTML ="); n != 1 ||
|
|
!strings.Contains(page, "out.innerHTML = await resp.text();") ||
|
|
!strings.Contains(page, `fetch("/chat/render"`) {
|
|
t.Errorf("the reply is written as HTML other than from /chat/render (%d assignments)", n)
|
|
}
|
|
}
|
|
|
|
func TestChatModelRunsNamesTheHardware(t *testing.T) {
|
|
for _, c := range []struct {
|
|
hw []string
|
|
want string
|
|
}{
|
|
{nil, ""},
|
|
{[]string{"cpu"}, "CPU"},
|
|
{[]string{"6000-24g"}, "GPU"},
|
|
{[]string{"cpu", "6000-24g"}, "GPU + CPU"},
|
|
} {
|
|
if got := (ChatModel{Hardware: c.hw}).Runs(); got != c.want {
|
|
t.Errorf("%v: %q, want %q", c.hw, got, c.want)
|
|
}
|
|
}
|
|
}
|