## 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>
202 lines
7.5 KiB
Go
202 lines
7.5 KiB
Go
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)
|
||
}
|
||
}
|