Files
HomeAgent/internal/agent/core/rtchild_r11_test.go
JianFeeeee acaf0887c7 fix(webui): 聊天图片"加载成功却显示加载失败"——HTML 属性里的反斜杠转义
## 缺陷

生产截图:图片加载成功了(img 元素在、src 正确),界面却多显示一行
蓝字「图片加载失败"/>」。

根因是内联事件属性里用了反斜杠转义:

    onerror="this.parentElement.innerHTML='<span class=\"att-err\">图片加载失败</span>'"

**HTML 属性值里没有反斜杠转义** —— 那个 \" 就是引号本身,属性在
`<span class=` 之后就闭合了。浏览器切出来的是:

    onerror = "this.parentElement.innerHTML='<span class="

剩下的 `att-err">图片加载失败</span>'"/>` 变成**文本节点**直接显示。

症状极具误导性:看起来像"图片坏了",实际图片是好的;而那个 onerror
因为语法错误**永不执行** —— 真失败时反而什么都不显示。

## 修法

不用内联属性(那要求以后每次拼 HTML 都记得转义规则),改成
**捕获阶段的事件委托**:

- error 事件**不冒泡**,只能在捕获阶段拿到(注释里写明了)
- 用 createElement + textContent 构造占位,不再拼 innerHTML 字符串
- 只处理 .chat-attachment-img,不影响正文里的 markdown 图片

## 顺带:两个类没有 CSS

`chat-attachment-img` 与 `att-err` 只出现在 JS 里,CSS 里一条都没有。
图片靠内联 style 撑着还能看,失败占位则完全没有样式。已补。

## 判据(2 条,变异验证)

- `TestChatAttachmentImgHasNoBrokenInlineHandler`:不许内联 onerror、
  必须用 DOM API、必须有 error 监听
- `TestDashboardNoEscapedQuoteInHTMLAttributes`:通用扫描,
  任何 `name="...\"...` 形态都会红(下次别人写内联事件也拦得住)

变异:把内联 onerror 放回去 ⇒ 两条同时红。

★ 写判据时踩到的坑(都已修):判据被**自己的注释**打红(注释里必须写出
错误写法来解释),需先用已有的 stripJSComments 剥离;另外我先自己写了一个
stripJSComments,与 dashboard_js_scope_test.go 的重名 —— 应复用已有的。

## 顺带修一条脆弱判据

`TestRTChild_RapidInputCollapsesToLatest` 全量跑时偶发红
("连发 50 条却跑了 5 轮")。

探针查实:连发 50 条只花 ~80µs,而测试 provider 的 ChatStream **同步瞬间
返回**,两者同量级 ⇒ 消费方能在连发过程中抢到几次执行。探针显示
dropped=48,collapse 明明生效了。

所以 `Rounds > 3` 是在测调度抖动,不是测契约。改为断言**覆盖主导**
(dropped >= burst/2)。真实通话每轮几十毫秒,连发微秒级,生产里
"轮次≈1" 自然成立。

变异验证:让 dropped 不计数 ⇒ 判据红(dropped=0(连发 50 条))。

go build/vet/gofmt 干净;go test ./... -count=1 -race = 62 ok / 0 FAIL / 0 RACE
2026-10-31 10:03:43 +08:00

225 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
// R11 判据:待机零消耗 + 长时 e2e(契约 4 / 契约 1 的持续正确性)。
//
// 契约 4:无输入即静默,**不烧轮次、不烧 token、不轮询**
//
// "不轮询"这条容易被忽略但同样重要:轮询会烧 CPU,且在静默期产生
// 无意义的唤醒。实现上 rtLoop 必须 block 在 Wake() 上(见 rtchild.go)。
import (
"context"
"sync"
"testing"
"time"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
)
// rtCountingProvider 统计调用次数与总 token 量(近似:正文长度)。
type rtCountingProvider struct {
mu sync.Mutex
calls int
chars int
}
func (p *rtCountingProvider) Name() string { return "rtCounting" }
func (p *rtCountingProvider) MaxContextTokens() int { return 8192 }
func (p *rtCountingProvider) Chat(_ context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
p.mu.Lock()
p.calls++
p.mu.Unlock()
return &agentAPI.CompletionResponse{Content: "ok"}, nil
}
func (p *rtCountingProvider) ChatStream(_ context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
p.mu.Lock()
p.calls++
p.mu.Unlock()
ch := make(chan agentAPI.StreamChunk, 2)
ch <- agentAPI.StreamChunk{Content: "答"}
p.mu.Lock()
p.chars++
p.mu.Unlock()
ch <- agentAPI.StreamChunk{Done: true}
close(ch)
return ch, nil
}
func (p *rtCountingProvider) stats() (int, int) {
p.mu.Lock()
defer p.mu.Unlock()
return p.calls, p.chars
}
// TestRTChild_IdleZeroTokens 钉住契约 4:**静默期零模型调用**。
//
// 若 rtLoop 改成轮询(select 带 default 或定时 tick),本判据会看到
// calls > 0 —— 那意味着空转烧钱。
func TestRTChild_IdleZeroTokens(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
id := rtSpawn(t, host, "idle")
// 给 rtLoop 足够时间"如果它要空转的话"必然已经转了。
time.Sleep(300 * time.Millisecond)
if calls, _ := p.stats(); calls != 0 {
t.Fatalf("静默 300ms 期间模型被调用 %d 次 —— 待机必须零消耗(契约 4)", calls)
}
// 槽也必须仍是空的(没有凭空产生输入)。
if st, ok := host.RTChildStats(id); !ok {
t.Fatal("实例不在登记表")
} else if st.Pending {
t.Fatal("静默期槽里出现了待处理输入")
} else if st.Rounds != 0 {
t.Fatalf("静默期跑了 %d 轮", st.Rounds)
}
// 再等一段,确认不是"刚启动还没轮到"。
time.Sleep(200 * time.Millisecond)
if calls, _ := p.stats(); calls != 0 {
t.Fatalf("静默 500ms 累计调用 %d 次", calls)
}
}
// TestRTChild_IdleStopsWhenInputArrives 是配套正例:来输入必须**真的**跑起来。
//
// 只有"静默零调用"这一半的判定条件是坏的 —— "永不调用模型"的实现也能通过,
// 但那是死进程。正例是必需品。
func TestRTChild_IdleStopsWhenInputArrives(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
id := rtSpawn(t, host, "wakeup")
time.Sleep(100 * time.Millisecond)
if calls, _ := p.stats(); calls != 0 {
t.Fatalf("静默期不该有调用,得到 %d", calls)
}
host.PushRTInput(id, "你好")
waitFor(t, "输入后跑了一轮", func() bool {
calls, _ := p.stats()
return calls >= 1
})
}
// TestRTChild_E2E_Sustained 是长时 e2e:连续灌入输入,验证
//
// · 每一批都被处理(不丢活)
// · 无 goroutine 泄漏 / 无 panic
// · history 持续增长(普通上下文)
// · 全程 -race 干净(由调用方加 -race)
//
// 用 -short 跳过:默认用例集不该被一段秒级压测拖慢。
func TestRTChild_E2E_Sustained(t *testing.T) {
if testing.Short() {
t.Skip("长时 e2e;-short 跳过")
}
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
id := rtSpawn(t, host, "e2e")
// 定速灌入(模拟插件攒批后的节奏)。总数取 60:足够暴露累积问题,
// 又不至于让默认用例集跑太久。
const total = 60
for i := 0; i < total; i++ {
host.PushRTInput(id, "第 N 句话")
// 给 rtLoop 一点时间消费;不追求"每条都跑完",要的是压力。
if i%10 == 0 {
time.Sleep(5 * time.Millisecond)
}
}
// 收尾:等最后一条被处理。
waitFor(t, "所有输入都被消费", func() bool {
st, ok := host.RTChildStats(id)
return ok && !st.Pending
})
tr, ok := host.RTTranscript(id)
if !ok {
t.Fatal("取不到 transcript")
}
if len(tr) == 0 {
t.Fatal("跑了多轮但 history 为空 —— 普通上下文没在累积")
}
calls, _ := p.stats()
if calls == 0 {
t.Fatal("全程零模型调用 —— rtLoop 没工作")
}
t.Logf("e2e: 灌入 %d 条,模型调用 %d 次,history %d 条", total, calls, len(tr))
// 关闭必须干净。
if err := host.StopRTChild(id); err != nil {
t.Fatalf("停止: %v", err)
}
if got := len(host.RTChildren()); got != 0 {
t.Fatalf("停止后仍有 %d 个实例", got)
}
}
// TestRTChild_RapidInputCollapsesToLatest 钉住契约 1+3 的**联合语义**:
// 输入到达得比处理快时,只有最新一条会被处理,其余被覆盖丢弃。
//
// 这不是缺陷,是设计:rt 场景下"用户刚说的话"才是当前态,旧语音已经过期。
// 若有人把 rtSlot 改成队列(让每条都跑),本判据会看到 rounds > 1 —— 那时
// 通话会变成"逐条追赶",用户说完很久机器才反应到,违反实时性。
//
// ★ 同时钉住 dropped 计数器:它是"打断真的发生了"的唯一可观测凭据。
func TestRTChild_RapidInputCollapsesToLatest(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
id := rtSpawn(t, host, "collapse")
time.Sleep(30 * time.Millisecond)
// 一口气推 50 条(比 rtLoop 处理得快得多)。
const burst = 50
for i := 0; i < burst; i++ {
host.PushRTInput(id, "连发")
}
waitFor(t, "槽被消费", func() bool {
st, ok := host.RTChildStats(id)
return ok && !st.Pending
})
st, ok := host.RTChildStats(id)
if !ok {
t.Fatal("实例不存在")
}
// ★ 判据是「**绝大多数输入被覆盖**」,不是「轮次必须 ≤3」。
//
// ## 为什么不能只看 Rounds(实测:本判据曾偶发红)
//
// rtCountingProvider 的 ChatStream **同步瞬间返回**(缓冲 2 的 channel
// 里塞好就 close),于是消费循环的每一轮是微秒级;而连发 50 条本身
// 也只花 ~80µs(探针实测)。两者同量级 ⇒ 消费方有机会在连发过程中
// 抢到几次执行,轮次可能到 5。
//
// 这不是缺陷:探针实测 dropped=48、rounds=2,collapse 明明生效了。
// 真实通话里每轮要几十毫秒(LLM 往返),而连发是微秒级,
// 所以"轮次≈1"在生产里自然成立 —— 用微秒级 provider 去要求它,
// 是在测调度抖动,不是在测契约。
//
// ## 真正该钉的是什么
//
// 契约 1/3 的核心是「**不排队**」:绝大多数输入必须被**覆盖丢弃**,
// 而不是逐条被处理。所以断言 dropped 占绝对多数。
if st.Rounds > burst/4 {
t.Fatalf("连发 %d 条却跑了 %d 轮 —— rtSlot 退化成了队列(契约 1/3:最新者胜、不排队)",
burst, st.Rounds)
}
if st.Dropped < burst/2 {
t.Fatalf("dropped=%d(连发 %d 条)—— 绝大多数输入应被覆盖丢弃,"+
"而不是逐条处理(契约 3:不吃队列、不补偿)", st.Dropped, burst)
}
if st.Dropped == 0 {
t.Fatal("连发 50 条但 dropped=0 —— 覆盖没有发生,或计数器没接线")
}
if st.Dropped+st.Rounds < burst/2 {
t.Fatalf("dropped=%d rounds=%d,合计远小于连发数 %d —— 有输入凭空消失了(既没被处理也没计入丢弃)",
st.Dropped, st.Rounds, burst)
}
}