t191/t192:daemon 主視窗(Google Drive 風格)+「同步中…」進行中狀態

解 issue #18+#17(leo 指定併做)。

## #17 進行中狀態——真兇是「工作時間對用戶隱形」
leo:「按『立刻同步』後很快就回到『看守中』,看起來好像就做完了,
      這時使用者一看沒做完啊,就覺得是 bug」。
真兇:collector **只在一輪跑完後**才印 JSON ⇒ 托盤無從得知「正在跑」。
修:開工前先印 phase:"start"、跑完印 phase:"done";
    supervisor 新增 StateSyncing;托盤/主視窗顯示「同步中… 正在讀檔並整理成知識卡」。
形狀相容:兩筆都有 at,舊版托盤只會多算一次 round,不會壞。

實測(真的跑一輪 --once):
  第1筆 phase='start' at=21:31:26
  第2筆 phase='done'  at=21:31:27

## #18 主視窗(新檔 mainwindow.go)
leo:「daemon 設定項越來越多,不可能統統塞在下拉選單」
     「參考 Google Drive 設定畫面:點擊 daemon 就開一個視窗,佔螢幕約 1/2」
     「資料夾清單要可捲動——我每個 gitea 專案都要同步,那就是幾十個,根本塞不下」
- 920x620 視窗,關窗=隱藏(daemon 續跑)
- 上:大字狀態+副標(上次同步/已整理幾份/失敗幾份;有錯誤優先顯示)
- 中:widget.List 虛擬捲動的資料夾清單(帳號標題+其下資料夾攤平成單層)
- 下:白話動作列(加入資料夾/新增知識庫帳號/AI 設定/檢查更新)
- 每秒自動刷新 ⇒ 同步中看得到在動
- 無帳號時引導去連線(onboarding),不是丟錯誤或空面板
- **所有動作複用既有 handler**,不在視窗那邊重寫一份邏輯

托盤選單第一項加「開啟 Arcrun…」——leo 已點破兩次
「能力做出來了,入口沒出現在用戶會看的地方」(docs 沒連結/MCP 零處提及),
這次做完就讓它看得見。

測試:t192_test.go 八則(狀態文案四態/同步中要說明在做什麼/
副標優先顯示錯誤/副標不可空白/清單攤平且 accIdx 正確/60 個資料夾/無帳號空清單)全過。
collector 與 supervisor 全套綠。

⚠️ 未驗:fyne GUI 的實際外觀需真機開窗(無螢幕環境驗不到),
   CIS 視覺套用待 leo 看過畫面再調。
This commit is contained in:
2026-08-04 21:32:31 +08:00
parent 8c7b50d9a9
commit cc6534dd39
5 changed files with 500 additions and 2 deletions
+121
View File
@@ -0,0 +1,121 @@
package main
import (
"strings"
"testing"
"time"
"arcrun-rag/collector/supervisor"
)
// t192/#17:正在跑就要**看得出來在跑**。
// leo:「按『立刻同步』後很快就回到『看守中』,看起來好像就做完了,
// 這時使用者一看沒做完啊,就覺得是 bug」。
func TestMainWindowStatusShowsSyncing(t *testing.T) {
cases := []struct {
name string
state supervisor.State
want string
}{
{"同步中要講在做什麼", supervisor.StateSyncing, "同步中"},
{"看守中", supervisor.StateWatching, "看守中"},
{"啟動中", supervisor.StateStarting, "啟動中"},
{"出錯要說會自動重試", supervisor.StateError, "重試"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := mainWindowStatusText(supervisor.Status{State: c.state})
if !strings.Contains(got, c.want) {
t.Errorf("state=%s → %q,應含 %q", c.state, got, c.want)
}
})
}
}
// 同步中的文案必須說明「正在做什麼」,不能只寫兩個字讓人乾等。
func TestSyncingTextExplainsWhatItIsDoing(t *testing.T) {
got := mainWindowStatusText(supervisor.Status{State: supervisor.StateSyncing})
if !strings.Contains(got, "讀檔") && !strings.Contains(got, "知識卡") {
t.Errorf("同步中應說明正在做什麼,got %q", got)
}
}
// 副標:有錯誤時**優先顯示錯誤**(不能被「上次同步 12:34」蓋掉)。
func TestSubTextPrioritisesError(t *testing.T) {
got := mainWindowSubText(
supervisor.Status{LastRoundAt: time.Now()},
traySyncStatus{ExtractorOK: false, ExtractorError: "還沒連上知識庫"},
)
if !strings.Contains(got, "還沒連上知識庫") {
t.Errorf("有錯誤時應優先顯示,got %q", got)
}
}
// 副標:正常時要看得到「上次同步時間」與「已整理幾份」。
func TestSubTextShowsProgress(t *testing.T) {
got := mainWindowSubText(
supervisor.Status{LastRoundAt: time.Date(2026, 8, 4, 21, 5, 0, 0, time.Local)},
traySyncStatus{ExtractorOK: true, ExtractedOK: 3, ExtractFailed: 1},
)
for _, want := range []string{"21:05", "已整理 3", "1 份失敗"} {
if !strings.Contains(got, want) {
t.Errorf("副標應含 %qgot %q", want, got)
}
}
}
// 沒同步過時不能顯示空白(空白=用戶不知道是壞了還是還沒開始)。
func TestSubTextNeverEmpty(t *testing.T) {
got := mainWindowSubText(supervisor.Status{}, traySyncStatus{ExtractorOK: true})
if strings.TrimSpace(got) == "" {
t.Error("副標不可空白——用戶會分不清是壞了還是還沒開始")
}
}
// t192/#18:資料夾清單攤平——帳號一列標題,其下每個資料夾各一列。
// 用扁平清單才能吃 widget.List 的虛擬捲動(leo:「幾十個,根本塞不下」)。
func TestRebuildRowsFlattensAccountsAndFolders(t *testing.T) {
m := &mainWindow{cfg: &directConfig{Accounts: []accountCfg{
{InstanceName: "geek6688", CypherURL: "https://a.workers.dev", WatchFolders: []string{"/p/one", "/p/two"}},
{InstanceName: "youlin", CypherURL: "https://b.workers.dev", WatchFolders: []string{"/p/three"}},
}}}
m.rebuildRows()
if len(m.rows) != 5 { // 2 標題 + 3 資料夾
t.Fatalf("應攤平成 5 列,got %d%+v", len(m.rows), m.rows)
}
if !m.rows[0].isHeader || m.rows[0].title != "geek6688" {
t.Errorf("第 0 列應是帳號標題 geek6688got %+v", m.rows[0])
}
if m.rows[1].isHeader || m.rows[1].title != "/p/one" {
t.Errorf("第 1 列應是資料夾 /p/onegot %+v", m.rows[1])
}
// 刪除要作用在正確帳號上(t101 踩過:刪錯帳號的資料夾)
if m.rows[4].accIdx != 1 {
t.Errorf("第 4 列(youlin 的資料夾)accIdx 應為 1got %d", m.rows[4].accIdx)
}
}
// 幾十個資料夾也要能攤平(不是為了效能,是確保沒有隱藏上限)。
func TestRebuildRowsHandlesManyFolders(t *testing.T) {
folders := make([]string, 60)
for i := range folders {
folders[i] = "/gitea/project-" + string(rune('a'+i%26))
}
m := &mainWindow{cfg: &directConfig{Accounts: []accountCfg{
{InstanceName: "leo", CypherURL: "https://x.workers.dev", WatchFolders: folders},
}}}
m.rebuildRows()
if len(m.rows) != 61 {
t.Errorf("60 個資料夾+1 標題應為 61 列,got %d", len(m.rows))
}
}
// 沒有任何帳號時清單是空的(走 onboarding,不是顯示壞掉的空面板)。
func TestRebuildRowsEmptyWhenNoAccount(t *testing.T) {
m := &mainWindow{cfg: &directConfig{}}
m.rebuildRows()
if len(m.rows) != 0 {
t.Errorf("無帳號時應為空清單,got %d 列", len(m.rows))
}
}