feat(t215): 每個知識庫顯示是否要更新,落後就給 install 頁連結

leo 08-08:「在每個知識庫會看到其他需要更新的,在每個知識庫上顯示是否要更新,
如果要,加開啓 install 頁的連結」。小幫手以前只提示自己(daemon 本體)要不要
更新,使用者連著多個雲端知識庫時完全看不出哪一個落後。

- collector/cloud_latest.go:EvalCloudUpdate + FetchLatestCloudRelease(30 分鐘節流),
  判準與 portal 版本卡 loadVersion() 同一套(bundle_version vs install.arcrun.dev/
  api/latest 的 release,semver 逐段整數比較),不是 t103 minCloudRelease 那把相容
  底線,避免同一個知識庫在兩處得到相反答案。
- direct.go/sync_status.go:每輪同步順帶算好每個帳號的 CloudUpdateKnown/
  CloudUpdateStale/CloudLatest,寫進 status.json。
- app.go:GetState 把這些欄位接進 UIAccount,供前端讀。
- main.js/style.css:首頁新增「知識庫版本」卡(每庫一行,落後才出現「前往安裝頁
  更新」按鈕,帶 email 預填);側邊欄庫名旁加警示點;各庫頁也顯示同一行版本狀態。
  查不到版本(連不上/latest 暫時查不到)一律誠實說「查不到」,不當成「已最新」。

已用假 window.go 在瀏覽器實測四種情境(落後/已最新/連不上/查得到 mine 但查不到
latest)+零知識庫的 onboarding 頁+深色模式,畫面與按鈕行為皆符合預期。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 00:21:40 +08:00
parent 2bbfa453d3
commit 779a1b801e
8 changed files with 328 additions and 10 deletions
+84
View File
@@ -0,0 +1,84 @@
// cloud_latest_test.go — t215 單元測試:EvalCloudUpdate 的判準要跟 portal 版本卡一致。
package collector
import "testing"
func TestEvalCloudUpdate(t *testing.T) {
cases := []struct {
name string
mine string
mineOK bool
latest string
latestOK bool
wantKnown bool
wantUpdate bool
}{
{
name: "兩邊都拿得到、mine 落後", mine: "1.4.1", mineOK: true, latest: "1.4.2", latestOK: true,
wantKnown: true, wantUpdate: true,
},
{
name: "兩邊都拿得到、已是最新", mine: "1.4.2", mineOK: true, latest: "1.4.2", latestOK: true,
wantKnown: true, wantUpdate: false,
},
{
// 字串比較會誤判 "1.10.0" < "1.9.0";逐段整數比較才對(同 t103 迴歸守衛)。
name: "1.10.0 比 1.9.0 新,不該判落後", mine: "1.10.0", mineOK: true, latest: "1.9.0", latestOK: true,
wantKnown: true, wantUpdate: false,
},
{
// 老格式(YYYY-MM-DD+sha)——portal 版本卡註解原話:「這種情況一律當成落後」。
name: "老格式版本一律當落後", mine: "2026-07-31+8e83589", mineOK: true, latest: "1.4.2", latestOK: true,
wantKnown: true, wantUpdate: true,
},
{
name: "連不上這個知識庫(mineOK=false)→ 查不到,不能裝沒事", mine: "", mineOK: false, latest: "1.4.2", latestOK: true,
wantKnown: false, wantUpdate: false,
},
{
name: "查得到 mine 但暫時查不到最新版 → 查不到,不是已最新", mine: "1.4.2", mineOK: true, latest: "", latestOK: false,
wantKnown: false, wantUpdate: false,
},
{
name: "/health 可達但 bundle_version 空字串(老實例)→ 查不到", mine: "", mineOK: true, latest: "1.4.2", latestOK: true,
wantKnown: false, wantUpdate: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := EvalCloudUpdate(tc.mine, tc.mineOK, tc.latest, tc.latestOK)
if got.Known != tc.wantKnown {
t.Errorf("Known = %vwant %v", got.Known, tc.wantKnown)
}
if got.NeedsUpdate != tc.wantUpdate {
t.Errorf("NeedsUpdate = %vwant %v", got.NeedsUpdate, tc.wantUpdate)
}
})
}
}
// TestFetchLatestCloudReleaseThrottle 驗證節流:窗口內第二次呼叫不重打 fetchLatestCloudReleaseRaw。
func TestFetchLatestCloudReleaseThrottle(t *testing.T) {
calls := 0
orig := fetchLatestCloudReleaseRaw
defer func() {
fetchLatestCloudReleaseRaw = orig
latestMu.Lock()
latestCached, latestCachedOK, latestFetched = "", false, latestFetched.Add(-2*latestCacheTTL)
latestMu.Unlock()
}()
fetchLatestCloudReleaseRaw = func() (string, bool) { calls++; return "1.4.2", true }
// 強制第一次一定重打(避開其他測試留下的快取)。
latestMu.Lock()
latestFetched = latestFetched.Add(-2 * latestCacheTTL)
latestMu.Unlock()
r1, ok1 := FetchLatestCloudRelease()
r2, ok2 := FetchLatestCloudRelease()
if calls != 1 {
t.Errorf("節流窗口內第二次呼叫不該重打,calls = %d", calls)
}
if r1 != "1.4.2" || !ok1 || r2 != "1.4.2" || !ok2 {
t.Errorf("兩次結果應相同,got (%q,%v) (%q,%v)", r1, ok1, r2, ok2)
}
}