visionA/local-agent/visiona-agent/log_buffer_test.go
jim800121chen 3f0175f1a9 feat(local-agent): Phase 0.5 visionA Agent — Wails 桌面 + tunnel client + 配對 UI
從 local-tool 複製出獨立的「visionA Agent」桌面應用(A3 純橋樑:
tunnel client + 配對 UI + 設定,不開 HTTP port、不做本機裝置/推論 UI)。
Bundle ID 與 local-tool 不同(com.innovedus.visiona-agent vs visiona-local),
雙 app 可共存。fork 後不主動 sync,需要時手動 cherry-pick。

Backend / Wails Go(AB1-AB13):
- internal/tunnel:6 狀態機(Idle/Connecting/Connected/Reconnecting/Failed/Stopped)
  + Pair/Unpair/Reconnect/Disconnect binding + ClientHooks event
- internal/auth:encrypted file token store(AES-GCM + scrypt + machineID
  fallback salt + 13 tests)
- internal/config:YAML validation + atomic write + 11 tests
- internal/log:ring buffer + ExportLog 升級 zip
- visionA-backend /api/pairing/exchange:SessionTokenStore + 17 new tests
- 三平台 build 驗證(macOS DMG 160 MB / Windows EXE / Linux AppImage)
- end-to-end 5 milestone 全綠(pairing → tunnel → forward → reuse 防護
  → tunnel drop failover)

Frontend / Next.js(AF1-AF7,沿用 visionA-frontend 基礎):
- AppShell + Header + TabNav(StatusView / PairView / SettingsView 三 tab)
- ConnectionStatusBadge 5 種狀態
- TokenInput regex 驗證 + 7 種錯誤 + 0.5s auto-switch 到狀態頁
- 設定頁 4 區塊(含重新配對 AlertDialog)
- agent-api.ts 封裝 Wails bindings(mock/real 雙實作)+ 90 tests

Phase 0.7 review-driven fix(Round 2):
- A1 Session fixation 防護(RotateSessionID)
- A3 mock pairing 預設改 false(必須明確 opt-in)+ startup log
- A4 Pair 失敗後 state 清理矩陣(exchange/Save/Start fail 各自終態)
- A5 Pair/Unpair/Reconnect lifecycleMu + 50 goroutine race test
- F1 重新配對次按鈕 / F2 PairView Esc cancel / F3 Wails BrowserOpenURL
  / F4 Settings draft 持久 + 未儲存 badge

驗證:agent backend go test -race -count=3 ./... 4 packages 全綠 /
agent frontend pnpm test 119 tests 全綠

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:22:01 +08:00

152 lines
3.5 KiB
Go

package main
// log_buffer_test.go — M8-4 ring buffer + level parse + rate limit 的單元測試
import (
"fmt"
"testing"
"time"
)
func TestLogBuffer_AppendAndSnapshot(t *testing.T) {
b := NewLogBuffer()
if got := b.Size(); got != 0 {
t.Fatalf("initial size=%d, want 0", got)
}
// 先塞 3 行
for i := 0; i < 3; i++ {
b.Append(LogLine{
Ts: time.Now().UnixMilli(),
Stream: "stdout",
Line: fmt.Sprintf("line-%d", i),
})
}
if got := b.Size(); got != 3 {
t.Fatalf("size after 3 appends=%d, want 3", got)
}
// Snapshot(0) 應回全部
all := b.Snapshot(0)
if len(all) != 3 {
t.Fatalf("snapshot(0) len=%d, want 3", len(all))
}
for i := 0; i < 3; i++ {
if all[i].Line != fmt.Sprintf("line-%d", i) {
t.Fatalf("snapshot[%d].Line=%q", i, all[i].Line)
}
}
// Snapshot(2) 應回最後 2 行
last2 := b.Snapshot(2)
if len(last2) != 2 {
t.Fatalf("snapshot(2) len=%d", len(last2))
}
if last2[0].Line != "line-1" || last2[1].Line != "line-2" {
t.Fatalf("snapshot(2) 內容錯誤: %+v", last2)
}
}
func TestLogBuffer_WrapAround(t *testing.T) {
b := NewLogBuffer()
// 塞超過 cap 100 行
total := logBufferCap + 100
for i := 0; i < total; i++ {
b.Append(LogLine{Line: fmt.Sprintf("line-%d", i)})
}
if got := b.Size(); got != logBufferCap {
t.Fatalf("size=%d, want %d", got, logBufferCap)
}
if got := b.Dropped(); got != 100 {
t.Fatalf("dropped=%d, want 100", got)
}
// 取最後 3 行,應該是 line-{total-3} ... line-{total-1}
last3 := b.Snapshot(3)
if len(last3) != 3 {
t.Fatalf("snapshot len=%d, want 3", len(last3))
}
expected := []string{
fmt.Sprintf("line-%d", total-3),
fmt.Sprintf("line-%d", total-2),
fmt.Sprintf("line-%d", total-1),
}
for i, e := range expected {
if last3[i].Line != e {
t.Fatalf("last3[%d]=%q, want %q", i, last3[i].Line, e)
}
}
// 取全部應該是 line-100 ... line-2099
all := b.Snapshot(0)
if len(all) != logBufferCap {
t.Fatalf("all len=%d", len(all))
}
if all[0].Line != fmt.Sprintf("line-%d", total-logBufferCap) {
t.Fatalf("all[0]=%q", all[0].Line)
}
if all[len(all)-1].Line != fmt.Sprintf("line-%d", total-1) {
t.Fatalf("all[last]=%q", all[len(all)-1].Line)
}
}
func TestLogBuffer_Reset(t *testing.T) {
b := NewLogBuffer()
for i := 0; i < 10; i++ {
b.Append(LogLine{Line: fmt.Sprintf("x-%d", i)})
}
b.Reset()
if got := b.Size(); got != 0 {
t.Fatalf("size after reset=%d, want 0", got)
}
snap := b.Snapshot(0)
if len(snap) != 0 {
t.Fatalf("snapshot after reset len=%d, want 0", len(snap))
}
}
func TestLogBuffer_RateLimit(t *testing.T) {
b := NewLogBuffer()
// 一秒內前 logRateLimitBurst 次 ShouldEmit 都應為 true
allowed := 0
denied := 0
for i := 0; i < logRateLimitBurst+50; i++ {
if b.ShouldEmit() {
allowed++
} else {
denied++
}
}
if allowed != logRateLimitBurst {
t.Fatalf("allowed=%d, want %d", allowed, logRateLimitBurst)
}
if denied != 50 {
t.Fatalf("denied=%d, want 50", denied)
}
}
func TestParseLogLevel(t *testing.T) {
cases := []struct {
line string
level string
}{
{"2026/04/14 14:23:01 [INFO] server started", "info"},
{"[WARN] something odd", "warn"},
{"[ERROR] python died", "error"},
{"[DEBUG] trace", "debug"},
{"INFO: message", "info"},
{"[GIN] 200 | 2.1ms | GET /api/system/info", "info"},
{"[GIN] 404 | 0.8ms | GET /missing", "warn"},
{"[GIN] 500 | 12ms | POST /api/boom", "error"},
{"random text line", ""},
{"", ""},
}
for _, c := range cases {
got := parseLogLevel(c.line)
if got != c.level {
t.Errorf("parseLogLevel(%q)=%q, want %q", c.line, got, c.level)
}
}
}