visionA/local-tool/visiona-local/venv_health_test.go
jim800121chen f9fbc778be fix: input size 優先序錯誤導致 Windows 推論失敗 + venv 半套安裝永久卡住
## input size 優先序(Windows Error 12 的根因)

先前把使用者手填的 inputSize 排在檔名解析之前,Windows 上 SDK 讀不到
shape 時就用了隨手填的 640x640(模型實際 224x224)→ 推論回
KP_ERROR_INVALID_PARAM。改動前靠檔名 fallback 的 224 反而是對的。

新優先序(可信度由高到低):
  SDK > 檔名明確解析 wNNNhNNN > 使用者宣告 > 已知 model id > 寫死預設

檔名排在宣告之前,因為它由編譯工具鏈產生、沒有人為亂填空間;宣告不降到
最底,是因為使用者若刻意填對,仍比無資訊時的預設值貼近現實。

配套:_size_from_name_or_none 讓「真的解析到」與「用了 default」可區分
(舊版兩者回傳型別相同,預設值會偽裝成檔名來源蓋掉宣告值)。

## KneronPLUS 3.1.2 相容

3.1.2 把 shape 搬進巢狀 union,TensorDescriptor 不再有 shape_onnx:
  2.0.0  TensorDescriptor.shape_onnx
  3.1.2  TensorDescriptor.tensor_shape_info.data → V1 .shape_onnx / V2 .shape

舊碼 getattr 失敗被 except 靜默吃掉,SDK 層在 Windows 永遠落空。現在
兩版都支援,不依賴 enum 版本判斷。

## Error 12 診斷

KP_ERROR_INVALID_PARAM 對使用者無法理解,現在附上當前 input size 與
來源,並針對 declared 來源提示「此尺寸來自手動填寫欄位,請優先確認」。
原始錯誤保留不吞。

## venv 半套安裝

app.go 原本只檢查 python.exe 存在就跳過安裝,導致 wheels 裝到一半中斷
後每次啟動都跳過、永遠卡住且無提示,使用者必須手動刪整個 runtime 目錄。

改為比對 wheel 清單指紋(快路徑不啟動 process),不符才實跑
import kp/numpy/cv2 驗證,失敗則只重跑 wheels 安裝。標記檔僅在 pip 成功
且 import 驗過後才寫入,不留「已就緒」假象。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 23:41:19 +08:00

202 lines
7.5 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 main
// venv_health_test.go — bundled Python venv 健康檢查單元測試
//
// 背景:舊版 ensureBundledPython 只 os.Stat(python.exe) 就視為就緒。實機踩到的
// 情境是 python.exe 在、但 `import kp` 失敗pip 中途失敗留下半套 venv
// 於是每次啟動都跳過安裝、永遠卡住且無任何提示,使用者只能手動刪整個 runtime。
//
// 這裡驗證的三件事:
// 1. 指紋wheels 清單)能偵測到 wheels 換版
// 2. 標記檔的快路徑不會啟動任何 process成本
// 3. 壞掉的 venv 不會被誤判成健康
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func wheelsDirWith(t *testing.T, names ...string) string {
t.Helper()
dir := t.TempDir()
for _, n := range names {
writeFile(t, filepath.Join(dir, n), "x")
}
return dir
}
func TestBundledWheelsFingerprint_StableRegardlessOfReadOrder(t *testing.T) {
a := wheelsDirWith(t, "numpy-2.4.4.whl", "KneronPLUS-3.1.2.whl", "opencv.whl")
b := wheelsDirWith(t, "opencv.whl", "KneronPLUS-3.1.2.whl", "numpy-2.4.4.whl")
if bundledWheelsFingerprint(a) != bundledWheelsFingerprint(b) {
t.Fatalf("fingerprint 應與檔案列舉順序無關\na=%q\nb=%q",
bundledWheelsFingerprint(a), bundledWheelsFingerprint(b))
}
}
func TestBundledWheelsFingerprint_ChangesWhenWheelVersionChanges(t *testing.T) {
// 這是升級 KneronPLUS 時觸發重裝的機制wheel 檔名帶版本號。
old := wheelsDirWith(t, "KneronPLUS-2.0.0-py3-none-any.whl", "numpy-2.4.4.whl")
upgraded := wheelsDirWith(t, "KneronPLUS-3.1.2-py3-none-any.whl", "numpy-2.4.4.whl")
if bundledWheelsFingerprint(old) == bundledWheelsFingerprint(upgraded) {
t.Fatal("wheels 換版後 fingerprint 必須改變,否則升級不會觸發重裝")
}
}
func TestBundledWheelsFingerprint_IgnoresNonWheelFiles(t *testing.T) {
dir := wheelsDirWith(t, "numpy-2.4.4.whl")
writeFile(t, filepath.Join(dir, "README.txt"), "not a wheel")
writeFile(t, filepath.Join(dir, ".DS_Store"), "junk")
if got := bundledWheelsFingerprint(dir); got != "numpy-2.4.4.whl" {
t.Fatalf("fingerprint=%q, 只應包含 .whl", got)
}
}
func TestBundledWheelsFingerprint_MissingDirIsEmpty(t *testing.T) {
if got := bundledWheelsFingerprint(filepath.Join(t.TempDir(), "nope")); got != "" {
t.Fatalf("不存在的目錄 fingerprint=%q, want empty", got)
}
}
// 快路徑:標記檔內容與 wheels 相符 → 直接放行,**不執行 pythonBin**。
// 用一個不存在的 pythonBin 路徑證明它真的沒被執行(真跑會失敗)。
func TestBundledPythonDepsHealthy_MarkerFastPathSkipsProcess(t *testing.T) {
runtimeDir := t.TempDir()
wheels := wheelsDirWith(t, "KneronPLUS-3.1.2.whl", "numpy-2.4.4.whl")
writeFile(t, filepath.Join(runtimeDir, venvReadyMarkerName),
bundledWheelsFingerprint(wheels))
a := &App{}
nonExistentPython := filepath.Join(runtimeDir, "definitely-not-a-python")
if !a.bundledPythonDepsHealthy(runtimeDir, nonExistentPython, wheels) {
t.Fatal("標記相符時應走快路徑回 true且不得執行 python")
}
}
// 標記內容與當前 wheels 不符(升級情境)→ 必須離開快路徑去實跑驗證。
// pythonBin 不存在 → 驗證失敗 → 回 false觸發重裝
func TestBundledPythonDepsHealthy_StaleMarkerTriggersRevalidation(t *testing.T) {
runtimeDir := t.TempDir()
wheels := wheelsDirWith(t, "KneronPLUS-3.1.2.whl")
writeFile(t, filepath.Join(runtimeDir, venvReadyMarkerName),
"KneronPLUS-2.0.0.whl") // 舊版留下的標記
a := &App{}
if a.bundledPythonDepsHealthy(runtimeDir, filepath.Join(runtimeDir, "no-python"), wheels) {
t.Fatal("標記過期且無法實跑驗證時,不可回報健康")
}
}
// 沒有標記檔(舊版 venv / 首次升級到本版)→ 走慢路徑實跑驗證。
func TestBundledPythonDepsHealthy_NoMarkerAndBrokenPythonIsUnhealthy(t *testing.T) {
runtimeDir := t.TempDir()
wheels := wheelsDirWith(t, "numpy-2.4.4.whl")
a := &App{}
if a.bundledPythonDepsHealthy(runtimeDir, filepath.Join(runtimeDir, "no-python"), wheels) {
t.Fatal("無標記且 python 不可執行時,不可回報健康")
}
}
// 這是使用者實機踩到的核心情境python 執行檔在、但 import kp 失敗。
// 舊版只 Stat 檔案存在就放行;新版必須判定為不健康。
func TestBundledPythonDepsHealthy_PythonExistsButImportFails(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell-script 假 python 在 Windows 上不適用")
}
runtimeDir := t.TempDir()
wheels := wheelsDirWith(t, "KneronPLUS-3.1.2.whl")
// 假 python任何呼叫都以非 0 結束,模擬 import kp 失敗
fakePython := filepath.Join(runtimeDir, "python3")
writeFile(t, fakePython, "#!/bin/sh\necho \"ModuleNotFoundError: No module named 'kp'\" >&2\nexit 1\n")
if err := os.Chmod(fakePython, 0o755); err != nil {
t.Fatalf("chmod: %v", err)
}
a := &App{}
if a.bundledPythonDepsHealthy(runtimeDir, fakePython, wheels) {
t.Fatal("import 失敗的 venv 必須判定為不健康(這正是使用者卡住的情境)")
}
// 且不可留下就緒標記,否則下次啟動又被快路徑放行
if _, err := os.Stat(filepath.Join(runtimeDir, venvReadyMarkerName)); err == nil {
t.Fatal("驗證失敗時不可寫入就緒標記")
}
}
// import 成功 → 回 true 並補寫標記,讓下次啟動走快路徑(成本設計的關鍵)。
func TestBundledPythonDepsHealthy_HealthyPythonWritesMarker(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell-script 假 python 在 Windows 上不適用")
}
runtimeDir := t.TempDir()
wheels := wheelsDirWith(t, "KneronPLUS-3.1.2.whl", "numpy-2.4.4.whl")
fakePython := filepath.Join(runtimeDir, "python3")
writeFile(t, fakePython, "#!/bin/sh\nexit 0\n")
if err := os.Chmod(fakePython, 0o755); err != nil {
t.Fatalf("chmod: %v", err)
}
a := &App{}
if !a.bundledPythonDepsHealthy(runtimeDir, fakePython, wheels) {
t.Fatal("import 成功時應回報健康")
}
got, err := os.ReadFile(filepath.Join(runtimeDir, venvReadyMarkerName))
if err != nil {
t.Fatalf("應補寫就緒標記讓下次走快路徑: %v", err)
}
if strings.TrimSpace(string(got)) != bundledWheelsFingerprint(wheels) {
t.Fatalf("標記內容=%q, want=%q", got, bundledWheelsFingerprint(wheels))
}
}
func TestProbePythonModules_EmptyModuleListIsNoop(t *testing.T) {
// 空清單不該啟動 process傳不存在的路徑也不能失敗
if err := probePythonModules("/definitely/not/a/python", nil); err != nil {
t.Fatalf("空模組清單應為 no-op, got %v", err)
}
}
func TestProbePythonModules_ReportsStderrOnFailure(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell-script 假 python 在 Windows 上不適用")
}
dir := t.TempDir()
fakePython := filepath.Join(dir, "python3")
writeFile(t, fakePython, "#!/bin/sh\necho \"No module named 'kp'\" >&2\nexit 1\n")
if err := os.Chmod(fakePython, 0o755); err != nil {
t.Fatalf("chmod: %v", err)
}
err := probePythonModules(fakePython, []string{"kp"})
if err == nil {
t.Fatal("非 0 結束碼應回報錯誤")
}
// 錯誤訊息要帶上 python 的 stderr否則使用者看不到真正原因
if !strings.Contains(err.Error(), "No module named") {
t.Fatalf("錯誤訊息應包含 python stderr, got: %v", err)
}
}
func TestListBundledWheelNames_MissingDirReturnsNil(t *testing.T) {
if got := listBundledWheelNames(filepath.Join(t.TempDir(), "nope")); len(got) != 0 {
t.Fatalf("不存在的目錄應回空, got %v", got)
}
}