Compare commits
13 Commits
af44a9f9ba
...
9397a4d31f
| Author | SHA1 | Date | |
|---|---|---|---|
| 9397a4d31f | |||
| f62141a925 | |||
| 4f316bb3b8 | |||
| 34bc361527 | |||
| f9fbc778be | |||
| a05d206c07 | |||
| 61593f004d | |||
| a7bc67baba | |||
| 744283bd28 | |||
| 5c1c37d151 | |||
| 4583406efa | |||
| ddd1aae5d1 | |||
| f14d24bd7b |
4
local-tool/.gitignore
vendored
4
local-tool/.gitignore
vendored
@ -78,3 +78,7 @@ desktop.ini
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
|
||||
.autoflow/
|
||||
.mcp.json
|
||||
graphify-out/
|
||||
|
||||
@ -18,6 +18,153 @@ OS := $(shell uname -s | tr A-Z a-z)
|
||||
DIST := dist
|
||||
PAYLOAD := visiona-local/payload
|
||||
|
||||
# ── 打包用 .nef 白名單(M5-a)────────────────────────────────────────
|
||||
#
|
||||
# server/data/ 底下有 8 個 .nef(KL520 五個、KL720 三個),但安裝包只帶
|
||||
# 白名單內的這幾個,其餘不進 payload,藉此縮小安裝檔體積。
|
||||
#
|
||||
# models.json 不做任何過濾,7 個 model 定義全部照原樣複製。執行期由
|
||||
# server/internal/model/repository.go 的 NewRepository() 檢查每個 model 的
|
||||
# filePath 是否實際存在,不存在的直接不載入(見 M5-c)。因此使用者在 UI
|
||||
# 只會看到白名單內的 model,未打包的不會出現、也不會選到後拿到莫名錯誤。
|
||||
#
|
||||
# 未來要把某個 model 加回安裝包:把對應的 .nef 相對路徑加進下面這個變數即可,
|
||||
# models.json 不用動。
|
||||
#
|
||||
# 路徑相對於 server/data/。
|
||||
BUNDLED_NEFS := \
|
||||
nef/kl520/kl520_20004_fcos-drk53s_w512h512.nef \
|
||||
nef/kl520/kl520_tiny_yolo_v3.nef
|
||||
|
||||
# copy_bundled_data:把 server/data/ 複製到 $(1),但 nef/ 只帶 BUNDLED_NEFS 白名單。
|
||||
# $(1) = 目標 data 目錄(例:payload/darwin/data)
|
||||
#
|
||||
# 步驟:(a) 先清空 $(1),確保這個 helper 是冪等的(見下方「為什麼要清空」)
|
||||
# (b) 複製 server/data/ 下除了 nef/ 以外的所有東西(models.json 等)
|
||||
# (c) 再逐一複製白名單內的 .nef
|
||||
# (d) 白名單檔案不存在就直接 fail,避免安靜地產出缺 model 的安裝包
|
||||
# (e) 最後數 .nef 個數,與白名單不符就 fail
|
||||
#
|
||||
# 為什麼要清空 $(1)((a) 步驟):
|
||||
# payload-windows / payload-linux 刻意不 rm -rf 整個 payload/<os>/(因為
|
||||
# build-server-* 已先把 binary 放進 bin/),所以先前 build 留下的檔案會殘留。
|
||||
# 白名單機制(M5-a)之前的舊 build 會把 8 個 .nef 全部複製進去,升級到白名單
|
||||
# 版本後再 build,就變成「新複製 2 個 + 殘留 6 個 = 8 個」,被下方的後置檢查
|
||||
# 擋下來,build 直接失敗。清空目標目錄讓這個 helper 重複執行結果一致,不受
|
||||
# 目標目錄既有內容影響。
|
||||
# (payload-macos 沒踩到只是因為它有 rm -rf payload/darwin。)
|
||||
#
|
||||
# 清整個 $(1) 而不是只清 nef/:$(1) 的內容 100% 由本 helper 產生(Makefile 中
|
||||
# 只有這裡寫入 payload/<os>/data/),沒有其他來源的檔案需要保留。只清 nef/ 的話,
|
||||
# models.json 以外的殘留(例如未來從 server/data/ 移除的檔案)仍會被 installer
|
||||
# 打包進去 —— installer/windows/visiona-local.iss 是用 data\* + recursesubdirs
|
||||
# 整包收,殘留什麼就出貨什麼。清整包才是真正的冪等。
|
||||
#
|
||||
# rm -rf 安全防護($(1) 來自 make 變數,打錯會刪掉不該刪的東西):
|
||||
# 1. 空值檢查:$(1) 展開為空就 fail,避免變成 `rm -rf /`(rm -rf "" 在部分
|
||||
# shell 下是 no-op、但空值代表呼叫端寫錯,直接擋掉比較安全)
|
||||
# 2. 絕對路徑 / 跳脫檢查:只接受相對路徑、且不得含 `..`,避免 `/`、`$$HOME`、
|
||||
# `../..` 這類目標
|
||||
# 3. 白名單前綴:路徑必須以 payload/ 開頭並以 /data 結尾,鎖死在 build 產物區
|
||||
# 4. 刪的是「已存在且確定是目錄」的路徑,且刪除後立刻重建
|
||||
#
|
||||
# 只用 POSIX find / cp,不用 rsync —— Windows CI 跑在 Git Bash(windows-2022 +
|
||||
# shell: bash),該環境沒有 rsync。
|
||||
define copy_bundled_data
|
||||
@set -e; \
|
||||
echo "==> 複製 server/data → $(1)(.nef 白名單:$(words $(BUNDLED_NEFS)) 個)"; \
|
||||
if [ ! -d server/data ]; then echo "!! ERROR: server/data 不存在 !!"; exit 1; fi; \
|
||||
target='$(strip $(1))'; \
|
||||
if [ -z "$$target" ]; then \
|
||||
echo "!! ERROR: copy_bundled_data 的目標目錄為空,拒絕執行 !!"; exit 1; \
|
||||
fi; \
|
||||
case "$$target" in \
|
||||
/*|~*) echo "!! ERROR: 目標目錄必須是相對路徑,收到 '$$target' !!"; exit 1 ;; \
|
||||
*..*) echo "!! ERROR: 目標目錄不得含 '..',收到 '$$target' !!"; exit 1 ;; \
|
||||
esac; \
|
||||
case "$$target" in \
|
||||
payload/*/data) : ;; \
|
||||
*) echo "!! ERROR: 目標目錄必須符合 payload/<os>/data,收到 '$$target' !!"; exit 1 ;; \
|
||||
esac; \
|
||||
if [ -e "$$target" ] && [ ! -d "$$target" ]; then \
|
||||
echo "!! ERROR: '$$target' 存在但不是目錄,拒絕刪除 !!"; exit 1; \
|
||||
fi; \
|
||||
if [ -d "$$target" ]; then \
|
||||
echo " 清空既有的 $${target}(確保冪等,不留前次 build 的殘留)"; \
|
||||
rm -rf -- "$$target"; \
|
||||
fi; \
|
||||
mkdir -p "$$target"; \
|
||||
dest="$$(cd "$$target" && pwd)"; \
|
||||
( cd server/data && \
|
||||
find . -path ./nef -prune -o -type d -print | while read -r d; do mkdir -p "$$dest/$$d"; done && \
|
||||
find . -path ./nef -prune -o -type f -print | while read -r f; do cp "$$f" "$$dest/$$f"; done ); \
|
||||
if [ ! -f "$$dest/models.json" ]; then \
|
||||
echo "!! ERROR: models.json 沒有被複製到 $$dest !!"; exit 1; \
|
||||
fi; \
|
||||
for nef in $(BUNDLED_NEFS); do \
|
||||
if [ ! -f "server/data/$$nef" ]; then \
|
||||
echo "!! ERROR: BUNDLED_NEFS 列出的 server/data/$$nef 不存在 !!"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
mkdir -p "$$dest/$$(dirname $$nef)"; \
|
||||
cp "server/data/$$nef" "$$dest/$$nef"; \
|
||||
echo " + $$nef"; \
|
||||
done; \
|
||||
copied=$$(find "$$dest" -name '*.nef' | wc -l | tr -d ' '); \
|
||||
if [ "$$copied" != "$(words $(BUNDLED_NEFS))" ]; then \
|
||||
echo "!! ERROR: 預期 $(words $(BUNDLED_NEFS)) 個 .nef,實際 $$copied 個 !!"; exit 1; \
|
||||
fi; \
|
||||
echo " models.json + $$copied 個 .nef 已就位"
|
||||
endef
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# clean_wheels_dir — 清空並重建 vendor/wheels/<os>/,確保 vendor-wheels* 冪等
|
||||
#
|
||||
# $(1) = 目標 wheels 目錄(例:vendor/wheels/windows)
|
||||
#
|
||||
# 為什麼必須清空(事故迴歸):
|
||||
# vendor-wheels* 用 `pip download --dest $(1)`,而 pip download 只「補下載缺的」,
|
||||
# 不會移除舊版。upstream 每發一次新版,這個目錄就多留一顆 whl —— 實機上
|
||||
# vendor/wheels/darwin 累積成 certifi ×3、numpy ×2、idna ×2 … 共 16 顆
|
||||
# (正常應為 9 顆)。
|
||||
#
|
||||
# 這些多版本會原封不動被 payload-* 複製進安裝包,最後 app 啟動時
|
||||
# `pip install a.whl b.whl ...` 同時收到 certifi 2026.2.25 與 2026.6.17,
|
||||
# 直接 ResolutionImpossible 並在 ~2 秒內失敗,使用者完全無法啟動。
|
||||
#
|
||||
# (app 端 selectLatestWheelPerPackage 已有防護,能救「已出貨」的舊安裝包;
|
||||
# 這裡則是從源頭杜絕,讓新產出的安裝包一開始就乾淨。兩層都要有。)
|
||||
#
|
||||
# rm -rf 安全防護(與 copy_bundled_data 同一套,$(1) 打錯不能傷到別的目錄):
|
||||
# 1. 空值檢查
|
||||
# 2. 只接受相對路徑、不得含 '..'
|
||||
# 3. 白名單前綴:必須符合 vendor/wheels/<os>
|
||||
# 4. 存在但不是目錄 → 拒絕刪除
|
||||
define clean_wheels_dir
|
||||
@set -e; \
|
||||
target='$(strip $(1))'; \
|
||||
if [ -z "$$target" ]; then \
|
||||
echo "!! ERROR: clean_wheels_dir 的目標目錄為空,拒絕執行 !!"; exit 1; \
|
||||
fi; \
|
||||
case "$$target" in \
|
||||
/*|~*) echo "!! ERROR: 目標目錄必須是相對路徑,收到 '$$target' !!"; exit 1 ;; \
|
||||
*..*) echo "!! ERROR: 目標目錄不得含 '..',收到 '$$target' !!"; exit 1 ;; \
|
||||
esac; \
|
||||
case "$$target" in \
|
||||
vendor/wheels/*) : ;; \
|
||||
*) echo "!! ERROR: 目標目錄必須符合 vendor/wheels/<os>,收到 '$$target' !!"; exit 1 ;; \
|
||||
esac; \
|
||||
if [ -e "$$target" ] && [ ! -d "$$target" ]; then \
|
||||
echo "!! ERROR: '$$target' 存在但不是目錄,拒絕刪除 !!"; exit 1; \
|
||||
fi; \
|
||||
if [ -d "$$target" ]; then \
|
||||
stale=$$(ls -1 "$$target"/*.whl 2>/dev/null | wc -l | tr -d ' '); \
|
||||
echo "==> 清空既有的 $${target}($${stale} 個舊 whl,避免同套件多版本累積)"; \
|
||||
rm -rf -- "$$target"; \
|
||||
fi; \
|
||||
mkdir -p "$$target"
|
||||
endef
|
||||
|
||||
.PHONY: help \
|
||||
vendor-sync vendor-python vendor-wheels vendor-ffmpeg vendor-ffmpeg-macos-build \
|
||||
vendor-python-windows vendor-wheels-windows vendor-ffmpeg-windows \
|
||||
@ -90,7 +237,7 @@ vendor-python: ## 下載 python-build-standalone tarball → vendor/python/darwi
|
||||
fi
|
||||
|
||||
vendor-wheels: ## 同步 wheels → vendor/wheels/darwin/(內部 wheel 從 visiona-local/wheels 複製,公開相依用 pip download)
|
||||
@mkdir -p vendor/wheels/darwin
|
||||
$(call clean_wheels_dir,vendor/wheels/darwin)
|
||||
@echo "==> 同步內部 wheels(KneronPLUS 等)..."
|
||||
@if [ -d visiona-local/wheels/macos ]; then \
|
||||
cp visiona-local/wheels/macos/*.whl vendor/wheels/darwin/ 2>/dev/null || true; \
|
||||
@ -245,9 +392,12 @@ payload-macos: build-server vendor-python vendor-wheels vendor-ffmpeg ## 準備
|
||||
cp vendor/ffmpeg/macos/ffprobe payload/darwin/bin/
|
||||
cp vendor/ffmpeg/macos/COPYING.LGPLv3 payload/darwin/bin/ffmpeg-COPYING.LGPLv3
|
||||
chmod +x payload/darwin/bin/ffmpeg payload/darwin/bin/ffprobe
|
||||
cp -R server/data/* payload/darwin/data/
|
||||
$(call copy_bundled_data,payload/darwin/data)
|
||||
cp -R server/scripts/* payload/darwin/scripts/
|
||||
cp vendor/python/darwin/python.tar.gz payload/darwin/python/
|
||||
@# payload-macos 雖有 rm -rf payload/darwin,仍顯式清空以與其他平台一致
|
||||
@# (日後若移除該 rm -rf,這裡不會默默退化成累積多版本)。
|
||||
@rm -f payload/darwin/wheels/*.whl
|
||||
@cp vendor/wheels/darwin/*.whl payload/darwin/wheels/ 2>/dev/null || true
|
||||
@echo "==> macOS payload 完成:"
|
||||
@du -sh payload/darwin
|
||||
@ -287,7 +437,7 @@ vendor-python-windows: ## 下載 python-build-standalone Windows x86_64 → vend
|
||||
fi
|
||||
|
||||
vendor-wheels-windows: ## 同步 Windows wheels → vendor/wheels/windows/
|
||||
@mkdir -p vendor/wheels/windows
|
||||
$(call clean_wheels_dir,vendor/wheels/windows)
|
||||
@echo "==> 同步內部 wheels (Windows, KneronPLUS 等)..."
|
||||
@if [ -d visiona-local/wheels/windows ]; then \
|
||||
cp visiona-local/wheels/windows/*.whl vendor/wheels/windows/ 2>/dev/null || true; \
|
||||
@ -360,9 +510,13 @@ payload-windows: build-server-windows vendor-python-windows vendor-wheels-window
|
||||
@# LGPL 授權條款(BtbN build 自帶 LICENSE.txt;COPYING.LGPLv3 不一定在壓縮檔內,失敗不致命)
|
||||
@cp vendor/ffmpeg/windows/LICENSE.txt payload/windows/bin/ffmpeg-LICENSE.txt 2>/dev/null || true
|
||||
@cp vendor/ffmpeg/windows/COPYING.LGPLv3 payload/windows/bin/ffmpeg-COPYING.LGPLv3 2>/dev/null || true
|
||||
cp -R server/data/. payload/windows/data/
|
||||
$(call copy_bundled_data,payload/windows/data)
|
||||
cp -R server/scripts/. payload/windows/scripts/
|
||||
cp vendor/python/windows/python.tar.gz payload/windows/python/
|
||||
@# 先清空再複製:payload-windows 刻意不 rm -rf payload/windows(server.exe 已先放進去),
|
||||
@# 所以 wheels/ 會殘留前次 build 的舊版本,造成安裝包內同套件多版本 → pip
|
||||
@# ResolutionImpossible 秒退。這裡確保 staging 精確等於 vendor 的內容。
|
||||
@rm -f payload/windows/wheels/*.whl
|
||||
@cp vendor/wheels/windows/*.whl payload/windows/wheels/ 2>/dev/null || true
|
||||
@echo "==> Windows payload 完成:"
|
||||
@du -sh payload/windows
|
||||
@ -397,7 +551,7 @@ vendor-python-linux: ## 下載 python-build-standalone Linux x86_64 → vendor/p
|
||||
fi
|
||||
|
||||
vendor-wheels-linux: ## 同步 Linux wheels → vendor/wheels/linux/
|
||||
@mkdir -p vendor/wheels/linux
|
||||
$(call clean_wheels_dir,vendor/wheels/linux)
|
||||
@echo "==> 同步內部 wheels (Linux, KneronPLUS 等)..."
|
||||
@if [ -d visiona-local/wheels/linux ]; then \
|
||||
cp visiona-local/wheels/linux/*.whl vendor/wheels/linux/ 2>/dev/null || true; \
|
||||
@ -444,13 +598,15 @@ payload-linux: build-server-linux vendor-python-linux vendor-wheels-linux vendor
|
||||
@cp vendor/ffmpeg/linux/ffmpeg payload/linux/bin/ 2>/dev/null && chmod +x payload/linux/bin/ffmpeg || echo "!! WARN: ffmpeg 缺失"
|
||||
@cp vendor/ffmpeg/linux/ffprobe payload/linux/bin/ 2>/dev/null && chmod +x payload/linux/bin/ffprobe || echo "!! WARN: ffprobe 缺失"
|
||||
@cp vendor/ffmpeg/linux/LICENSE.txt payload/linux/bin/ffmpeg-LICENSE.txt 2>/dev/null || true
|
||||
@if [ -d server/data ]; then cp -R server/data/. payload/linux/data/; fi
|
||||
$(call copy_bundled_data,payload/linux/data)
|
||||
@if [ -d server/scripts ]; then cp -R server/scripts/. payload/linux/scripts/; fi
|
||||
@if [ ! -f vendor/python/linux/python.tar.gz ]; then \
|
||||
echo "!! ERROR: vendor/python/linux/python.tar.gz 不存在,vendor-python-linux 應該已先跑過 !!"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@cp vendor/python/linux/python.tar.gz payload/linux/python/
|
||||
@# 同 payload-windows:先清空,避免前次 build 的舊版 whl 殘留成多版本。
|
||||
@rm -f payload/linux/wheels/*.whl
|
||||
@cp vendor/wheels/linux/*.whl payload/linux/wheels/ 2>/dev/null || true
|
||||
@wheel_count=$$(ls -1 payload/linux/wheels/*.whl 2>/dev/null | wc -l); \
|
||||
if [ "$$wheel_count" -lt 4 ]; then \
|
||||
|
||||
@ -0,0 +1,371 @@
|
||||
# Build Troubleshooting — visionA-local
|
||||
|
||||
> **這份文件的用途**:記錄 visionA-local 打包安裝檔(macOS / Windows / Linux)時實際踩過的雷,
|
||||
> 讓下次遇到同樣症狀能**快速定位**,而不是重新 debug 一輪。
|
||||
>
|
||||
> **目標讀者**:未來要在 Windows / Linux build 這個專案安裝包的開發者(可能是你自己,也可能是團隊成員)。
|
||||
>
|
||||
> **這不是** build 教學。build 流程本身看 `build-pipeline.md`(Makefile 骨架、vendor 目錄、CI 策略)。
|
||||
> 這份是它的 troubleshooting 補充:**症狀 → 根因 → 為什麼會發生 → 解法 → 如何預防/根治**。
|
||||
>
|
||||
> **紀錄來源**:2026-07 這輪(M10 classification + 特規打包 + 三平台實機 build)踩的雷,
|
||||
> 每條都對到 progress.md 或實際 code 的 `檔案:行號`。若與現況不符,以 code 為準。
|
||||
|
||||
---
|
||||
|
||||
## 快速症狀索引表
|
||||
|
||||
先在這張表找你看到的症狀,跳到對應章節。**症狀常常不指向真正的根因**——這正是這份文件存在的理由。
|
||||
|
||||
| 你看到的症狀 | 可能的雷 | 跳到 |
|
||||
|-------------|---------|------|
|
||||
| 裝置 reset 後 `load_model Error code 24`、停在 `KDP2 Loader` | 開發模式 `dist/scripts/` 缺 `firmware/` | §1 |
|
||||
| 前端顯示「載入模型失敗」、terminal 出現 `darwin_usb.c:584` / SIGABRT | 除錯時 curl 與 UI 搶同一顆 USB | §2 |
|
||||
| Windows build:`go: command not found` / `wails: command not found` / `pnpm not found`(明明裝了)| MSYS2 login shell 重建 PATH | §3 |
|
||||
| Build 後置檢查失敗「預期 2 個 .nef,實際 8 個」 | `copy_bundled_data` 沒清空目標、殘留舊 .nef | §4 |
|
||||
| Windows 推論 `KP_ERROR_INVALID_PARAM_12`(Error 12) | input size 用了使用者亂填的 declared 值 | §5 |
|
||||
| 每次啟動都「Python 相依未通過」但又不重裝,怎樣都好不了 | venv 半套安裝、只檢查 python.exe 存在就跳過 | §6 |
|
||||
| 明明環境是好的,卻被判「Python 相依驗證失敗」擋住啟動 | 健康檢查 probe 沒補 Windows DLL 搜尋路徑 | §7 |
|
||||
| App 一啟動 pip 就秒退 `ResolutionImpossible`(certifi ×3 之類) | vendor wheels 累積多版本 | §8 |
|
||||
| Windows 中文標籤亂碼(「布」→「撣<E3808C>」、log 出現 `<60>`) | Python stdio 綁 cp950 而非 UTF-8 | §9 |
|
||||
|
||||
如果症狀不在表上,先讀 §10 共通教訓——很多雷的「表面症狀」都不指向根因。
|
||||
|
||||
---
|
||||
|
||||
## 環境前置需求(三平台)
|
||||
|
||||
打包前的一次性環境安裝,用 bootstrap 腳本,不要手動裝:
|
||||
|
||||
| 平台 | 腳本 | 裝什麼 |
|
||||
|------|------|--------|
|
||||
| Windows | `scripts/bootstrap-windows.ps1` | winget 裝 git / go / node / pnpm / python / **MSYS2**(提供 bash + make)/ Inno Setup + wails |
|
||||
| Linux | `scripts/bootstrap-linux.sh` | apt 裝 go 1.22.5 / node 20 / pnpm / wails + GTK/WebKit/libusb dev headers |
|
||||
| macOS | (手動)| go / node / pnpm / wails / `brew install create-dmg`(DMG 美化,選用) |
|
||||
|
||||
三平台都需要 `make vendor-sync` 先把第三方二進位(Python runtime / wheels / ffmpeg)下載到 `vendor/`。
|
||||
|
||||
**⚠️ Windows 特別注意**:make 在 Windows 上是透過 `C:\msys64\usr\bin\bash.exe` 執行的(Windows 沒有原生 make),
|
||||
這帶來 §3 的 PATH 坑。詳見該節。
|
||||
|
||||
---
|
||||
|
||||
## 逐條 Troubleshooting
|
||||
|
||||
### §1 開發模式資源不同步 → 裝置 reset 後 load_model Error 24
|
||||
|
||||
**症狀**
|
||||
- 裝置首次連線、reset 後,`load_model` 回 `Error code 24`。
|
||||
- Python bridge 卡在 `KDP2 Loader` 狀態、進不到 `KDP2 Comp`。
|
||||
- **只在手動開發模式(`dist/scripts/`)發生,打包出來的安裝包沒這問題。**
|
||||
|
||||
**根因**
|
||||
- 開發模式下 `dist/scripts/` 需要同時含 **`kneron_bridge.py` + `firmware/` + `drivers/`**,`dist/data/` 需含 `models.json` 與 `.nef`。
|
||||
- 曾經只手動複製了 `kneron_bridge.py`、**漏掉 `firmware/`** → 裝置 reset 後 bridge 重啟時找不到 firmware → 停在 Loader → `load_model Error 24`。
|
||||
|
||||
**為什麼會發生**
|
||||
- 手動同步「就複製那個我改到的檔案」很直覺,但 bridge 執行期會去讀 `firmware/`、`drivers/` 這些不會每次都動、容易被忘記的相依資源。
|
||||
- 打包流程之所以沒事,是因為 Makefile 三平台都用整包搬(`cp -R server/scripts/*` / `find | cp` 整包),不是逐檔挑。
|
||||
|
||||
**解法**
|
||||
- 開發模式同步時,`dist/scripts/` 一律帶齊 `kneron_bridge.py` + `firmware/` + `drivers/`,`dist/data/` 帶齊 `models.json` + `.nef`。不要只複製「這次改到的那個檔」。
|
||||
|
||||
**如何預防 / 根治**
|
||||
- 走打包流程驗證而不是手動同步:`make payload-macos`(progress.md L413 實跑驗證過 firmware/drivers 都在)。
|
||||
- 已寫入 memory:`~/.claude/projects/-Users-jimchen-visionA/memory/project_local_tool_dev_env.md`。
|
||||
- 來源:progress.md L409-414。
|
||||
|
||||
---
|
||||
|
||||
### §2 除錯時 curl 戳 flash 與 UI 搶同一顆 USB → SIGABRT
|
||||
|
||||
**症狀**
|
||||
- 使用者端顯示「載入模型失敗」。
|
||||
- terminal 出現 libusb 的 assert:`darwin_usb.c:584`,程式 SIGABRT 直接掛掉。
|
||||
|
||||
**根因**
|
||||
- 除錯時用 `curl` 觸發 flash(load model),**同時**使用者還在用 UI 操作同一顆裝置 → 兩個行程同時對同一個 USB endpoint 下命令 → libusb 在 macOS 上直接 assert / abort。
|
||||
|
||||
**為什麼會發生**
|
||||
- USB 裝置不是可多路複用的資源。KneronPLUS SDK / libusb 沒有替你做互斥;兩個 client 同時搶就炸。
|
||||
|
||||
**解法 / 預防**
|
||||
- **除錯期間不要碰裝置**——要嘛用 curl 手動測、要嘛用 UI 測,不要兩個同時來。
|
||||
- 這不是 code bug,是除錯操作紀律。記住這個 assert 訊息(`darwin_usb.c:584`),下次看到就知道是雙頭搶 USB,不用往 code 裡挖。
|
||||
- 來源:progress.md L412。
|
||||
|
||||
---
|
||||
|
||||
### §3 bootstrap-windows 的 MSYS2 PATH → Go / wails / node / pnpm 找不到
|
||||
|
||||
**症狀**
|
||||
- Windows build 時,`go` / `wails` / `node` / `pnpm` 明明用 winget 裝好了,跑 make 卻報 `command not found`。
|
||||
|
||||
**根因**
|
||||
- Windows 沒有原生 make,本專案透過 `C:\msys64\usr\bin\bash.exe -l`(**login shell**)來跑 Makefile。
|
||||
- login shell 啟動時會重跑 `/etc/profile`,**把 PATH 整個重建**成 MSYS2 自己的一套,Windows 上用 winget 裝的工具目錄就這樣被洗掉了。
|
||||
- `MSYS2_PATH_TYPE=inherit`(bootstrap 有設,`bootstrap-windows.ps1:74`)**只影響 MSYS2 自己的 shell 啟動器**(`msys2.exe` / `mingw64.exe`);直接呼叫 `bash.exe -l` 時它不生效。
|
||||
|
||||
**為什麼會發生**
|
||||
- `inherit` 的作用範圍與「直接呼叫 bash.exe -l」的實際入口不重疊,是一個很容易誤解的設定。這也是為什麼 Inno Setup 呼叫、Python 呼叫本來就必須各自手動 export PATH。
|
||||
|
||||
**解法**
|
||||
- 跟 Inno Setup 一樣,**明確把工具目錄轉成 MSYS2 路徑格式(`C:\foo` → `/c/foo`)後再 export**,補在 `$PATH` 之前。
|
||||
- 實作見 `bootstrap-windows.ps1:73-113`(註解完整說明)、`:200`(去重合併成單一 export,避免多行 export 互相覆蓋)、`:284-294`。
|
||||
|
||||
**如何預防 / 根治**
|
||||
- 任何要在 MSYS2 `bash.exe -l` 下被找到的工具,都不能依賴 `inherit`,一律顯式 export 轉譯後的 MSYS2 路徑。
|
||||
- 來源:`bootstrap-windows.ps1:73-113`、progress.md L1401(M7 同型坑的前身)。
|
||||
|
||||
---
|
||||
|
||||
### §4 copy_bundled_data 不冪等 → 「預期 2 個,實際 8 個」
|
||||
|
||||
**症狀**
|
||||
- Build 後置檢查失敗:`!! ERROR: 預期 2 個 .nef,實際 8 個 !!`,build 中止。
|
||||
|
||||
**根因**
|
||||
- `payload-windows` / `payload-linux` **刻意不 `rm -rf` 整個 `payload/<os>/`**(因為 `build-server-*` 已先把 binary 放進 `bin/`),所以前次 build 的 `data/` 殘留會留著。
|
||||
- 白名單機制(M5-a、`BUNDLED_NEFS` 只留 2 個 .nef)上線前,舊 build 會把 8 個 .nef 全複製進去。升級到白名單版本後再 build,就變成「新複製 2 個 + 殘留 6 個 = 8 個」,被後置檢查擋下。
|
||||
- `payload-macos` 沒踩到,只是因為它有 `rm -rf payload/darwin`。
|
||||
|
||||
**為什麼這個後置檢查是「對的」**
|
||||
- installer(`installer/windows/visiona-local.iss`)是用 `data\* + recursesubdirs` **整包收**,殘留什麼就出貨什麼。所以「數 .nef 個數不符就 fail」是防止安裝包偷偷多帶不該帶的模型——這個 fail-loud 檢查要保留。
|
||||
|
||||
**解法**
|
||||
- `copy_bundled_data` helper 先**清空整個目標 data 目錄**再複製(步驟 (a)),確保重複執行結果一致、不受既有內容影響。
|
||||
- 為什麼清整包而不是只清 `nef/`:目標目錄內容 100% 由這個 helper 產生(Makefile 中只有這裡寫入 `payload/<os>/data/`),沒有其他來源需要保留;只清 `nef/` 的話,未來從 `server/data/` 移除的其他檔案殘留仍會被 installer 整包打包出貨。清整包才是真正冪等。
|
||||
- 實作 + 完整註解見 `Makefile:39-118`(`define copy_bundled_data`)。
|
||||
|
||||
**如何預防 / 根治**
|
||||
- helper 內建 fail-loud 後置檢查:白名單缺檔直接 fail(`Makefile:104-108`)、複製後驗 `models.json` 存在(`:101-103`)+ .nef 數量 == 白名單長度(`:113-116`)。
|
||||
- **不用 rsync**:Windows CI 跑在 Git Bash(`windows-2022` + `shell: bash`),該環境**沒有 rsync** → 一律用 POSIX `find` / `cp`(`Makefile:71-72`)。
|
||||
- 來源:`Makefile:39-118`、progress.md L112-124。
|
||||
|
||||
---
|
||||
|
||||
### §5 input size 優先序錯 → Windows 推論 Error 12
|
||||
|
||||
**症狀**
|
||||
- Windows 上推論回 `KP_ERROR_INVALID_PARAM_12`(Error 12)。
|
||||
- 同一個模型在 macOS 上正常。
|
||||
|
||||
**根因**
|
||||
- input size 的來源可信度排序,**declared(使用者在上傳表單手填的值)不能排在檔名解析之前**。
|
||||
- 上傳表單的 `inputSize` 欄位長期沒有實際作用,使用者是「隨手填」的(實際案例:填 640×640,模型其實是 224×224)。
|
||||
- KneronPLUS **3.1.2(Windows)不再提供 2.0.0(macOS)的 `shape_onnx` 屬性**,SDK 這層在 Windows 直接落空 → 於是**垃圾 declared 值成為實際採用值**,送進 NPU 得到 Error 12。
|
||||
- 檔名的 `wNNNhNNN` 是模型編譯工具鏈產生的,沒有人為亂填空間,**明確解析出來時比 declared 可信**。
|
||||
|
||||
**為什麼是 Windows 才炸**
|
||||
- 見 §附錄「KneronPLUS 版本相容性」:macOS 用 2.0.0、Windows 用 3.1.2,`shape` 欄位在 3.x 搬進巢狀 union(`TensorDescriptor.tensor_shape_info.data`),舊寫法 `getattr(node, "shape_onnx")` 在 3.1.2 拋 AttributeError 被靜默吃掉 → SDK 來源落空 → 掉到 declared。
|
||||
|
||||
**解法**
|
||||
- input size 可信度排序改為:**SDK(0) → filename(1) → declared(2) → known-model-id(3) → default(4)**。declared 降到第 3。
|
||||
- 常數與完整根因註解見 `kneron_bridge.py:351-371`(`INPUT_SIZE_SOURCE_RANK`)。
|
||||
- 逐軸解析(不再沿用單一純量 `_model_input_size`,因為模型輸入不保證正方形):`kneron_bridge.py:159-193`。
|
||||
- 相容兩版 SDK 的 shape 讀取(先試 3.x 巢狀 `tensor_shape_info`、再退平鋪欄位):`kneron_bridge.py:479-523`。
|
||||
|
||||
**如何預防 / 根治**
|
||||
- 實機驗收時 grep log 印出的 input size source(`kneron_bridge.py:347-349` 說明這是唯一能一眼看出尺寸怎麼來的線索)——尺寸錯掉時 NPU **不一定報錯**,可能只是安靜給錯結果。
|
||||
- ⚠️ 這是 §10「湊巧正確」教訓的實例:舊版檔名猜 224 湊巧對,改成「更可信」的 declared 反而錯。
|
||||
- 來源:`kneron_bridge.py:345-376, 479-523`。
|
||||
|
||||
---
|
||||
|
||||
### §6 venv 半套安裝 → 永久卡住、怎樣都好不了
|
||||
|
||||
**症狀**
|
||||
- 每次啟動都跑「Python 相依驗證未通過」,但又不重裝、也沒有提示,怎樣都好不了。
|
||||
- 只能手動刪掉 `runtime/venv` 才能恢復。
|
||||
|
||||
**根因**
|
||||
- 舊邏輯只檢查 `python.exe`(`venv/Scripts/python.exe` 或 `venv/bin/python3`)**存在**就跳過安裝。
|
||||
- 但「python 執行檔存在」**不等於**「相依裝好了」:pip install 中途失敗(斷網 / 磁碟滿 / 防毒攔截)會留下 venv 與 python.exe 都在、但 `import kp` 失敗的**半套環境**。
|
||||
- 於是每次啟動都「看到 python.exe → 跳過安裝 → 但 import 失敗」,永久卡死且無提示。
|
||||
|
||||
**解法**
|
||||
- venv 已存在的日常啟動路徑改為「驗健康、必要時嘗試修復」:`reuseExistingVenv`(`app.go:1078-1103`)。
|
||||
- venv 在但相依看起來壞了 → **只補裝 wheels、不整個重建 venv**(重建要重解壓 ~100MB tarball,而失敗幾乎都出在 pip 階段):`app.go:1091-1093`。
|
||||
- 修復失敗時記錄 warning 但**不阻斷啟動**(見 §7),改以現有 venv 繼續:`app.go:1096-1101`。
|
||||
|
||||
**如何預防 / 根治**
|
||||
- 狀態檢查要驗「真的能用」而非「檔案在」——這是 §10 的橫向教訓。
|
||||
- 快路徑用標記檔 `venv-ready.txt` 記錄「wheels 已成功裝完 + 當前 wheels 指紋」,指紋不符才真的跑一次 import 驗證(`app.go:1105-1152`)。
|
||||
- 來源:`app.go:995-1103`、progress.md(M4 之後的 fix)。
|
||||
|
||||
---
|
||||
|
||||
### §7 健康檢查阻斷啟動(自造 regression)→ 健康的 venv 被判壞、擋住啟動
|
||||
|
||||
**症狀**
|
||||
- 環境明明是好的(bridge 實際能跑、能掃到裝置),app 卻因「Python 相依驗證失敗」擋住啟動。
|
||||
|
||||
**根因(這是修 §6 時自己造成的 regression)**
|
||||
- §6 的健康檢查 probe 裸跑 `python -c "import kp"`,**沒有補 Windows 的 DLL 搜尋路徑**。
|
||||
- `kp` 在 import 時就會 `ctypes.CDLL` 載入 `kp/lib` 下的 native DLL(libkplus / libusb-1.0 / libwdi + MinGW runtime,共 6 個)。Windows 上這些 DLL 不在預設搜尋路徑、必須先 `add_dll_directory`。
|
||||
- 真正跑 bridge 時 `kl720_driver.go` 的 `startPython()` 與 `platform_windows.go` 的 driver 安裝腳本**有**做這件事,但 probe 沒做 → 健康的環境也 import 失敗 → 被判壞掉 → 擋住啟動。
|
||||
|
||||
**解法**
|
||||
- probe 的環境要盡量貼近「真正跑 bridge 的環境」:`pythonProbeScript` 加一段 preamble,先把 `site-packages/*/lib` 掛進 `PATH` + `os.add_dll_directory` 再 import(`app.go:1182-1205`)。
|
||||
- **更重要的原則:健康檢查絕不可以成為啟動的阻斷點。** 完整理由見 `app.go:999-1016`:
|
||||
1. 這個 probe 會誤判(環境貼合度永遠不可能 100%)。
|
||||
2. 就算真的壞了,讓 bridge 自己回報「掃不到裝置」這類具體錯誤,比在啟動前用一個間接的 probe 攔死更好——kneron_bridge.py 本來就把 import kp 失敗當可降級的情況處理。
|
||||
- 型別設計落地這個原則:`reuseExistingVenv` **沒有 error 回傳值**(`app.go:1078-1081`)——型別本身就保證了「健康檢查不會阻斷啟動」。
|
||||
|
||||
**如何預防 / 根治**
|
||||
- 加任何「啟動前健康檢查」時,先問:檢查失敗會不會擋住啟動?如果會,它就有能力誤殺健康環境。健康檢查應該是「發警告 + 嘗試修復」,不是 gate。
|
||||
- 來源:`app.go:999-1016, 1078-1103, 1154-1205`。
|
||||
|
||||
---
|
||||
|
||||
### §8 wheels 多版本 → pip ResolutionImpossible、app 一啟動就秒退
|
||||
|
||||
**症狀**
|
||||
- App 首次啟動安裝依賴時,pip 在 ~2 秒內 `ResolutionImpossible` 失敗,使用者完全無法啟動。
|
||||
- vendor 目錄裡同一套件有多顆(如 `certifi ×3`、`numpy ×2`、`idna ×2`)。
|
||||
|
||||
**根因**
|
||||
- `vendor-wheels*` 用 `pip download --dest`,而 `pip download` **只「補下載缺的」、不移除舊版**。
|
||||
- upstream 每發一次新版,`vendor/wheels/<os>` 就多留一顆 whl → 實機累積成 16 顆(正常應為 9 顆)。
|
||||
- 這些多版本原封不動被 `payload-*` 複製進安裝包 → app 啟動 `pip install a.whl b.whl ...` 同時收到 `certifi 2026.2.25` 與 `2026.6.17` → 直接 ResolutionImpossible。
|
||||
- **debug 成本放大**:這個 pip 錯誤還被 Makefile 的 Auto 分支吞掉(`|| echo WARN`),導致三輪來回都拿不到線索。
|
||||
|
||||
**解法(兩層都要)**
|
||||
- **源頭**:`clean_wheels_dir` helper 在 `vendor-wheels*` 前先清空並重建 `vendor/wheels/<os>/`,確保冪等、不累積多版本(`Makefile:143-166`,完整事故迴歸註解在 `:121-142`)。
|
||||
- **救援**:app 端 `selectLatestWheelPerPackage` 對每個套件只保留版本最高的一顆再交給 pip(`app.go:1314-1325+`),能救「已出貨」的舊安裝包。
|
||||
- 兩層都要有:源頭杜絕讓新安裝包乾淨、救援讓舊安裝包也能自癒。
|
||||
|
||||
**如何預防 / 根治**
|
||||
- 版本比較用逐段整數比較的簡化 PEP 440(`compareWheelVersions`,`app.go:1282-1312`);套件名依 PEP 503 正規化,讓 `opencv_python_headless` 與 `opencv-python-headless` 視為同套件(`normalizeDistName`,`app.go:1255-1271`)。
|
||||
- **錯誤不要被吞掉**:這條的三輪 debug 成本就是 pip 錯誤被 `|| echo WARN` 吞掉造成的。詳見 §10。
|
||||
- 來源:`Makefile:121-166`、`app.go:1207-1325`、progress.md L135-136。
|
||||
|
||||
---
|
||||
|
||||
### §9 Windows 中文亂碼 → 標籤「布」變「撣<E3808C>」
|
||||
|
||||
**症狀**
|
||||
- Windows(繁中)上 classification 標籤亂碼:「布」變「撣<E3808C>」。
|
||||
- stderr log 的中文與 em dash(U+2014)出現 `<60>`(如 `<60>X SDK did not report`)。
|
||||
|
||||
**根因**
|
||||
- Windows 的 Python 把 stdio 綁到「系統 ANSI code page」而非 UTF-8。繁中 Windows 的 ANSI code page 是 **cp950**。
|
||||
- bridge 的 JSON-RPC 兩個方向**不對稱**:
|
||||
- **Go → Python (stdin)**:Go 的 `encoding/json` **不** escape 非 ASCII,`{"labels":["布"]}` 在 wire 上是 raw UTF-8 `e5 b8 83`。以 cp950 解碼得到「撣」+ U+FFFD(截圖的亂碼);嚴格模式下直接 UnicodeDecodeError 讓整個 bridge 掛掉。
|
||||
- **Python → Go (stdout)**:`json.dumps` 預設 `ensure_ascii=True`,中文被 escape 成 `\uXXXX` 純 ASCII,所以這條「目前剛好沒壞」——但那是隱性依賴,任何人加上 `ensure_ascii=False` 就會壞。
|
||||
- **stderr**:`_log()` 的中文與 em dash 現在就會壞。
|
||||
|
||||
**解法**
|
||||
- `_force_utf8_stdio()` 把 stdin / stdout / stderr **一律** `reconfigure(encoding="utf-8", errors="replace")`(`kneron_bridge.py:22-71`),不理會系統預設編碼。
|
||||
- **在 module import 時就呼叫**(`kneron_bridge.py:71`),不是在 `main()` 裡——必須早於任何 I/O(`main()` 會 `os.dup()` stdout、import 期間的例外也走 stderr)。
|
||||
- 用 `errors="replace"` 而非 `strict`:這是診斷 log 通道與協定通道,遇到極端無效位元組寧可看到一個 U+FFFD 也不要讓整個 bridge 因一行 log 而崩潰(bridge 掛掉 = 裝置失聯,比壞字元嚴重)。
|
||||
|
||||
**如何預防 / 根治**
|
||||
- 把「stdout 是 UTF-8」變成**顯式契約而非巧合**(連目前沒壞的 stdout 方向也一併綁定)。
|
||||
- ⚠️ `PYTHONUTF8` 與 `PYTHONIOENCODING` 管的範圍不同:前者是整個 Python 的 UTF-8 mode(含檔案系統編碼等)、後者只管 stdio 的編碼。這裡用程式內 `reconfigure` 是最直接、不依賴環境變數是否被正確傳入的做法。
|
||||
- 測試釘死:`server/scripts/test_kneron_bridge_encoding.py`。
|
||||
- 來源:`kneron_bridge.py:22-71`。
|
||||
|
||||
---
|
||||
|
||||
## §10 共通教訓(橫向 pattern)
|
||||
|
||||
**這節比逐條 bug 更有價值**——上面 9 個雷裡反覆出現的幾個 pattern,下次寫 code / debug 時記住這些,能少踩一半。
|
||||
|
||||
### 10.1 macOS 開發 ≠ Windows 實機
|
||||
|
||||
好幾個雷都是「macOS 有、Windows 沒有」或「兩邊行為不同」造成的。macOS 上測不到、只有 Windows 實機才會爆的類別:
|
||||
|
||||
| 類別 | macOS | Windows | 踩到的雷 |
|
||||
|------|-------|---------|---------|
|
||||
| stdio 編碼 | 預設 UTF-8 | 預設 cp950(繁中 ANSI code page) | §9 中文亂碼 |
|
||||
| native DLL 搜尋路徑 | dyld(可用 ctypes 絕對路徑預載) | 需 `add_dll_directory`、不在預設路徑 | §7 健康檢查誤殺 |
|
||||
| KneronPLUS SDK 版本 | 2.0.0(有 `shape_onnx`) | 3.1.2(shape 搬進巢狀 union) | §5 input size Error 12 |
|
||||
| shell / make | 原生 bash + make | MSYS2 login shell 重建 PATH | §3 工具找不到 |
|
||||
| 既有環境 | 開發機常有現成工具 | 乾淨機、什麼都要 bootstrap | §3 / §4 |
|
||||
|
||||
**原則**:任何「編碼 / DLL 路徑 / SDK 版本 / code page / shell」相關的東西,**不能只在 macOS 驗**,一定要 Windows 實機或至少想清楚兩邊差異。
|
||||
|
||||
### 10.2 「檔案存在」≠「可用」
|
||||
|
||||
§6(venv 半套安裝)和 §7(健康檢查)都栽在這。
|
||||
- venv 只檢查 `python.exe` 存在就跳過安裝 → 半套環境永久卡死。
|
||||
- 狀態檢查要驗**「真的能用」**(實際 import / 實際跑一次),而非「檔案在」。
|
||||
- 但驗「能用」時要注意 §7 的教訓:驗證環境要貼近真實執行環境,否則會誤殺健康的環境;而且**健康檢查不能是啟動阻斷點**。
|
||||
|
||||
### 10.3 錯誤被吞掉會讓 debug 成本爆炸
|
||||
|
||||
§8(wheels 多版本)的三輪來回,根因是 pip 錯誤被 Makefile 的 `|| echo WARN` 分支吞掉,拿不到真正的錯誤訊息。
|
||||
- 吞錯誤 = 把「一次就能定位」變成「三輪還在猜」。
|
||||
- fail-loud 優於 fail-silent。§4 的後置檢查(數 .nef 個數不符就直接 fail)就是正面教材——它讓「安裝包偷偷多帶模型」在 build 期就爆,而不是等出貨後使用者才發現。
|
||||
- 對照 §5:input size 錯掉時 NPU **不報錯只給錯結果**——這種「靜默失敗」最貴,所以要靠 log 印出 source 當唯一線索。
|
||||
|
||||
### 10.4 「湊巧正確」的行為改動要格外小心
|
||||
|
||||
§5(input size 優先序)是經典案例:
|
||||
- 舊版用檔名猜測,某些模型湊巧猜到 224、剛好對。
|
||||
- 改成「看起來更可信」的 declared(使用者手填值)反而錯——因為使用者是隨手填的。
|
||||
- **教訓**:當你把一個「能動但你覺得不夠嚴謹」的邏輯改成「更正確」的版本時,先確認新來源真的更可信。「更正式的欄位」不代表「更可信的值」——declared 是正式欄位、但值是垃圾。
|
||||
|
||||
### 10.5 兩層防護(源頭 + 救援)
|
||||
|
||||
§8 的解法是兩層都做:`clean_wheels_dir` 從源頭杜絕(新安裝包乾淨)+ `selectLatestWheelPerPackage` 救援(舊安裝包自癒)。
|
||||
- 只做源頭 → 已出貨的舊安裝包救不了。
|
||||
- 只做救援 → 每個新安裝包都帶著髒 vendor 出貨、依賴 app 端補救。
|
||||
- 涉及「已出貨產物」的問題,通常源頭與救援都要有。
|
||||
|
||||
---
|
||||
|
||||
## §附錄 A:KneronPLUS 版本相容性
|
||||
|
||||
三平台的 KneronPLUS wheel 版本**不一致**,這是好幾個雷的隱形根因。寫任何碰 SDK 的 code 時必讀。
|
||||
|
||||
| 平台 | KneronPLUS wheel 版本 | 影響 |
|
||||
|------|----------------------|------|
|
||||
| macOS | 2.0.0 | `TensorDescriptor` 有平鋪的 `shape_onnx` / `shape_npu` 屬性 |
|
||||
| Linux | 2.0.0 | 同上 |
|
||||
| Windows | 3.1.2 | **無** `shape_onnx` 屬性;shape 搬進巢狀 `tensor_shape_info.data`(V1/V2 union);DLL 需求更嚴 |
|
||||
|
||||
**具體差異與注意事項**:
|
||||
|
||||
1. **shape 欄位位置不同**(→ §5 Error 12)
|
||||
- 2.0.0:`node.shape_onnx` / `node.shape_npu` 直接可讀。
|
||||
- 3.1.2:`node.tensor_shape_info.version`(`ModelTensorShapeInformationVersion`)+ `.data`(`TensorShapeInfoV1` 有 `shape_onnx`/`shape_npu`;`TensorShapeInfoV2` 只有 `.shape`,docstring 明寫是 ONNX shape)。
|
||||
- 舊寫法 `getattr(node, "shape_onnx")` 在 3.1.2 拋 AttributeError → 被 except 靜默吃掉 → SDK 這層以為「模型沒帶 shape」,其實是讀錯欄位。
|
||||
- 相容兩版的讀法見 `kneron_bridge.py:479-523`(先試巢狀、再退平鋪)。
|
||||
|
||||
2. **native DLL 需求**(→ §7 健康檢查誤殺)
|
||||
- `import kp` 在 Windows 會載入 `kp/lib` 下的 native DLL(libkplus / libusb-1.0 / libwdi + MinGW runtime,共 6 個),不在預設搜尋路徑 → 需 `add_dll_directory`。
|
||||
- macOS 走 dyld,可用 ctypes 絕對路徑預載(`_preload_kneron_dylibs_macos`,`kneron_bridge.py:74+`)。
|
||||
|
||||
3. **wheel 三平台版本不一致本身是風險**(progress.md M9-6 findings L587)
|
||||
- 若未來要加 KL630/KL730 等新晶片支援,macOS/Linux 的 2.0.0 wheel **沒有**對應 enum,必須先升 wheel。
|
||||
- `update_kdp_firmware_from_files` 在 3.1.2 Python wrapper 中不存在(warrenchen 是 ctypes 直打 .so C symbol),這類 API 差異在跨版本時要逐一確認。
|
||||
|
||||
**原則**:任何讀 SDK 結構(shape / enum / API 簽章)的 code,都要同時對 2.0.0 與 3.1.2 驗,或明確寫成「先試新版結構、失敗再退舊版」的相容寫法。
|
||||
|
||||
---
|
||||
|
||||
## §附錄 B:Build 完驗收 Checklist
|
||||
|
||||
Build 完一個安裝包後,**至少**驗這些(每項標了對應的雷,避免重蹈覆轍):
|
||||
|
||||
- [ ] **.nef 數量正確**:打包版(不是開發模式)確認只帶白名單的 2 個 `.nef`(`FCOS Detection` / `Tiny YOLOv3`),不是 8 個。→ §4
|
||||
(開發模式驗不出來:`server/data/nef/` 8 個都在、過濾器只濾「檔案不存在」,開發模式會顯示 7 個模型。M5 必須用打包版驗、progress.md L206。)
|
||||
- [ ] **models.json 有進安裝包**:啟動後 `/api/models` 回非空、log 印 `Loaded N built-in models`(不是 0)。
|
||||
- [ ] **中文顯示正常**(Windows 繁中機):classification 標籤、log 的中文都不亂碼。→ §9
|
||||
- [ ] **input size source 正確**:實機推論後 grep log 的 input size source,確認不是掉到 declared / default 撿到垃圾值;Windows 上尤其要看。→ §5
|
||||
- [ ] **推論不報 Error 12 / Error 24**:Windows 推論不 Error 12(§5)、裝置 reset 後 load_model 不 Error 24(§1)。
|
||||
- [ ] **Python venv 健康**:全新機器首次啟動能自動裝好 wheels(不 ResolutionImpossible §8);且啟動不被健康檢查誤擋(§7)。
|
||||
- [ ] **driver 綁定**:Windows 上 KneronPLUS native DLL 能被載入(`import kp` 成功、掃得到裝置)。→ §7 / 附錄 A
|
||||
- [ ] **Wails 視窗確認**:**開 app window** 確認主 UI 是 Next.js 而非 splash / installer wizard / 白畫面(progress.md L1602 的歷史教訓:M1 只用瀏覽器連 localhost 驗、沒開 window,讓 wizard 殘留混過 M1-M6)。
|
||||
|
||||
**驗收原則**:不要只驗「server 有回應」——很多雷(§1 / §4 / §5 / §9)在 `/api/health` 200 的情況下照樣存在。要驗到「真的能推論 + 顯示正確」。
|
||||
|
||||
---
|
||||
|
||||
## 相關文件
|
||||
|
||||
- `build-pipeline.md` — Makefile 骨架、vendor 目錄結構、CI 策略、版本號管理(build 流程本體)。
|
||||
- progress.md(`.autoflow/progress.md`)— M10 各段開發紀錄與踩坑細節的一手來源。
|
||||
- memory:`~/.claude/projects/-Users-jimchen-visionA/memory/project_local_tool_dev_env.md` — 開發模式資源同步坑。
|
||||
@ -8,6 +8,7 @@ import { InferencePanel } from '@/components/inference/inference-panel';
|
||||
import { FlashDialog } from '@/components/devices/flash-dialog';
|
||||
import { useDeviceStore } from '@/stores/device-store';
|
||||
import { useInferenceStore } from '@/stores/inference-store';
|
||||
import { useInferenceOptionsStore } from '@/stores/inference-options-store';
|
||||
import { useInferenceStream } from '@/hooks/use-inference-stream';
|
||||
import { useCameraStore } from '@/stores/camera-store';
|
||||
import { useResolvedParams } from '@/hooks/use-resolved-params';
|
||||
@ -17,6 +18,7 @@ export default function WorkspaceClient() {
|
||||
const { deviceId } = useResolvedParams();
|
||||
const { selectedDevice, fetchDevice } = useDeviceStore();
|
||||
const { isRunning, setRunning, reset } = useInferenceStore();
|
||||
const resetInferenceOptions = useInferenceOptionsStore((s) => s.reset);
|
||||
const { isStreaming, sourceType } = useCameraStore();
|
||||
|
||||
// For image/video mode, inference runs automatically as part of the pipeline
|
||||
@ -41,8 +43,11 @@ export default function WorkspaceClient() {
|
||||
}
|
||||
return () => {
|
||||
reset();
|
||||
// Options are per-session and per-device; leaving them set would apply a
|
||||
// label mapping uploaded for one device to the next one opened.
|
||||
resetInferenceOptions();
|
||||
};
|
||||
}, [deviceId, fetchDevice, fetchCameras, reset]);
|
||||
}, [deviceId, fetchDevice, fetchCameras, reset, resetInferenceOptions]);
|
||||
|
||||
const handleStartInference = async () => {
|
||||
await api.post(`/devices/${deviceId}/inference/start`);
|
||||
@ -89,7 +94,7 @@ export default function WorkspaceClient() {
|
||||
<CameraInferenceView deviceId={deviceId} />
|
||||
</div>
|
||||
<div className="w-80 shrink-0">
|
||||
<InferencePanel />
|
||||
<InferencePanel deviceId={deviceId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { CameraFeed } from './camera-feed';
|
||||
import { CameraOverlay } from './camera-overlay';
|
||||
import { InferenceOverlay } from './inference-overlay';
|
||||
import { SourceSelector } from './source-selector';
|
||||
import { BatchImageThumbnails } from './batch-image-thumbnails';
|
||||
import { useCameraStore } from '@/stores/camera-store';
|
||||
@ -25,11 +25,10 @@ export function CameraInferenceView({ deviceId }: CameraInferenceViewProps) {
|
||||
setRenderedSize((prev) => (prev && prev.w === w && prev.h === h ? prev : { w, h }));
|
||||
}, []);
|
||||
|
||||
// In batch mode, show the selected image's detections
|
||||
// In batch mode, show the selected image's result
|
||||
const selectedResult = isBatchMode
|
||||
? batchResults[batchSelectedIndex]
|
||||
: result;
|
||||
const detections = selectedResult?.detections || [];
|
||||
|
||||
// In batch mode, use static image endpoint for viewing selected image
|
||||
const batchImageUrl = isBatchMode
|
||||
@ -48,8 +47,8 @@ export function CameraInferenceView({ deviceId }: CameraInferenceViewProps) {
|
||||
onDimensionsChange={handleDimensionsChange}
|
||||
overlay={
|
||||
isStreaming && renderedSize ? (
|
||||
<CameraOverlay
|
||||
detections={detections}
|
||||
<InferenceOverlay
|
||||
result={selectedResult}
|
||||
width={renderedSize.w}
|
||||
height={renderedSize.h}
|
||||
confidenceThreshold={confidenceThreshold}
|
||||
|
||||
@ -25,16 +25,6 @@ export function CameraOverlay({ detections, width, height, confidenceThreshold }
|
||||
|
||||
const filtered = detections.filter((d) => d.confidence >= confidenceThreshold);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
// TEMP debug: 驗證 bbox coordinate space 對齊問題
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[bbox-debug] canvas=%dx%d total=%d filtered=%d threshold=%s', width, height, detections.length, filtered.length, confidenceThreshold, filtered.map((d) => ({
|
||||
label: d.label,
|
||||
bbox: d.bbox,
|
||||
conf: d.confidence,
|
||||
})));
|
||||
}
|
||||
|
||||
filtered.forEach((det, i) => {
|
||||
const color = COLORS[i % COLORS.length];
|
||||
// Convert normalized coordinates (0-1) to pixel values
|
||||
@ -68,6 +58,7 @@ export function CameraOverlay({ detections, width, height, confidenceThreshold }
|
||||
height={height}
|
||||
style={{ width, height }}
|
||||
className="absolute left-0 top-0 pointer-events-none"
|
||||
data-testid="camera-overlay"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import type { ClassResult } from '@/types/inference';
|
||||
import { classResultLabel } from '@/lib/classification';
|
||||
import { useStableTopClass } from '@/hooks/use-stable-top-class';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
interface ClassificationOverlayProps {
|
||||
classifications: ClassResult[];
|
||||
width: number;
|
||||
height: number;
|
||||
confidenceThreshold: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classification overlay — no bounding boxes.
|
||||
*
|
||||
* Classification models produce a whole-image verdict rather than localised
|
||||
* objects, so we annotate the frame with a single top-1 chip instead of
|
||||
* drawing rectangles. Rendered as absolutely positioned DOM (not canvas) so
|
||||
* that CJK label text, rounded corners and theming come from CSS rather than
|
||||
* hand-rolled `fillText` layout, and so screen readers can announce the result.
|
||||
*
|
||||
* The chip sits on the top-right, because CameraFeed already occupies the
|
||||
* top-left with the source-type badge.
|
||||
*/
|
||||
export function ClassificationOverlay({
|
||||
classifications,
|
||||
width,
|
||||
height,
|
||||
confidenceThreshold,
|
||||
}: ClassificationOverlayProps) {
|
||||
const { t } = useTranslation();
|
||||
const top = useStableTopClass(classifications, { confidenceThreshold });
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ width, height }}
|
||||
className="absolute left-0 top-0 pointer-events-none"
|
||||
data-testid="classification-overlay"
|
||||
>
|
||||
<div
|
||||
className="absolute right-2 top-2 max-w-[70%] rounded-md bg-black/70 px-3 py-2 text-white shadow-lg"
|
||||
// Announce the verdict for screen-reader users; `polite` avoids
|
||||
// interrupting on every frame of a video stream.
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{top ? (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="truncate text-base font-semibold" data-testid="classification-overlay-label">
|
||||
{classResultLabel(top)}
|
||||
</span>
|
||||
<span className="shrink-0 text-sm tabular-nums opacity-80" data-testid="classification-overlay-confidence">
|
||||
{(top.confidence * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm opacity-80" data-testid="classification-overlay-empty">
|
||||
{t('inference.unrecognized')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import type { InferenceResult } from '@/types/inference';
|
||||
import { isClassificationTask } from '@/lib/classification';
|
||||
import { CameraOverlay } from './camera-overlay';
|
||||
import { ClassificationOverlay } from './classification-overlay';
|
||||
|
||||
interface InferenceOverlayProps {
|
||||
result: InferenceResult | null | undefined;
|
||||
width: number;
|
||||
height: number;
|
||||
confidenceThreshold: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches the frame overlay based on the task type reported by the
|
||||
* inference result itself.
|
||||
*
|
||||
* `result.taskType` is preferred over the model metadata because it reflects
|
||||
* what the Python post-processor actually did — metadata can disagree with the
|
||||
* executed code path.
|
||||
*
|
||||
* Note the branch is written as "is this classification?" rather than "is this
|
||||
* detection?" on purpose: the detection task type string has been inconsistent
|
||||
* across the stack (`detection` vs `object_detection`, plan R-4), so detection
|
||||
* is the fall-through default and stays correct under either spelling.
|
||||
*/
|
||||
export function InferenceOverlay({
|
||||
result,
|
||||
width,
|
||||
height,
|
||||
confidenceThreshold,
|
||||
}: InferenceOverlayProps) {
|
||||
if (isClassificationTask(result?.taskType)) {
|
||||
return (
|
||||
<ClassificationOverlay
|
||||
classifications={result?.classifications || []}
|
||||
width={width}
|
||||
height={height}
|
||||
confidenceThreshold={confidenceThreshold}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CameraOverlay
|
||||
detections={result?.detections || []}
|
||||
width={width}
|
||||
height={height}
|
||||
confidenceThreshold={confidenceThreshold}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@ -39,11 +39,16 @@ export function FlashDialog({ deviceId }: FlashDialogProps) {
|
||||
const device = devices.find((d) => d.id === deviceId);
|
||||
const selectedModel = models.find((m) => m.id === selectedModelId);
|
||||
|
||||
// S2: 資料載入前預設 compatible=true,避免在 model/device 還沒載入時就顯示不相容警告
|
||||
const compatible = useMemo(() => {
|
||||
if (!selectedModel || !device) return true;
|
||||
return isModelCompatible(selectedModel.supportedHardware, device.type);
|
||||
}, [selectedModel, device]);
|
||||
// 載入模型時不選推論種類:解析方式改由推論頁(InferenceOptions)即時切換,
|
||||
// 那裡不需要重燒就能改,功能完全涵蓋燒錄時選一次的舊做法。
|
||||
|
||||
// S2: 資料載入前預設 compatible=true,避免在 model/device 還沒載入時就顯示不相容警告。
|
||||
// 這裡不再包 useMemo —— isModelCompatible 只是一次陣列比對,手動 memo 反而讓
|
||||
// React Compiler 整個元件跳過優化(react-hooks/preserve-manual-memoization)。
|
||||
const compatible =
|
||||
!selectedModel || !device
|
||||
? true
|
||||
: isModelCompatible(selectedModel.supportedHardware, device.type);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Cell } from 'recharts';
|
||||
import type { ClassResult } from '@/types/inference';
|
||||
import { classResultLabel } from '@/lib/classification';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
interface ClassificationResultProps {
|
||||
@ -18,8 +19,10 @@ export function ClassificationResult({ results, confidenceThreshold }: Classific
|
||||
.sort((a, b) => b.confidence - a.confidence)
|
||||
.slice(0, 8);
|
||||
|
||||
// Labels may be missing when the model ships without a label mapping — fall
|
||||
// back to the raw class index (`class_<n>`) rather than rendering blank bars.
|
||||
const data = filtered.map((r) => ({
|
||||
label: r.label,
|
||||
label: classResultLabel(r),
|
||||
confidence: +(r.confidence * 100).toFixed(1),
|
||||
}));
|
||||
|
||||
@ -32,6 +35,7 @@ export function ClassificationResult({ results, confidenceThreshold }: Classific
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="classification-result-chart">
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={data} layout="vertical" margin={{ left: 80, right: 20, top: 5, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
@ -44,5 +48,6 @@ export function ClassificationResult({ results, confidenceThreshold }: Classific
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import type { DetectionResult } from '@/types/inference';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
interface DetectionResultListProps {
|
||||
results: DetectionResult[];
|
||||
confidenceThreshold: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detected object list for the side panel.
|
||||
*
|
||||
* Detection results are spatial, so the primary read is the bounding boxes on
|
||||
* the frame itself; this list is the secondary, textual read (what was found,
|
||||
* how many, how confident) which the canvas cannot convey to assistive tech.
|
||||
*/
|
||||
export function DetectionResultList({ results, confidenceThreshold }: DetectionResultListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const filtered = results
|
||||
.filter((d) => d.confidence >= confidenceThreshold)
|
||||
.sort((a, b) => b.confidence - a.confidence);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="flex h-24 items-center justify-center text-sm text-muted-foreground">
|
||||
{t('inference.noResultsAboveThreshold')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-testid="detection-result-list">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">{t('inference.detectedCount')}</span>
|
||||
<span className="tabular-nums">{filtered.length}</span>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{filtered.map((det, i) => (
|
||||
<li
|
||||
key={`${det.label}-${i}`}
|
||||
className="flex items-center justify-between gap-2 text-sm"
|
||||
>
|
||||
<span className="truncate">{det.label}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{(det.confidence * 100).toFixed(1)}%
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,227 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
useInferenceOptionsStore,
|
||||
validateLabelFile,
|
||||
MAX_LABEL_FILE_BYTES,
|
||||
ACCEPTED_LABEL_EXTENSIONS,
|
||||
} from '@/stores/inference-options-store';
|
||||
import { FLASH_TASK_TYPES, normalizeTaskType, type FlashTaskType } from '@/lib/task-type';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
interface InferenceOptionsProps {
|
||||
deviceId: string;
|
||||
/**
|
||||
* Task type reported by the most recent inference result. Used as the
|
||||
* selector's value until the user overrides it, so the control opens showing
|
||||
* what the pipeline is actually doing rather than a guess.
|
||||
*/
|
||||
resultTaskType: string | undefined;
|
||||
}
|
||||
|
||||
/** Local-only errors that never reach the server (size / extension). */
|
||||
type LocalError = 'too-large' | 'wrong-type' | null;
|
||||
|
||||
/** Preview of the first few label names, so the user can spot a wrong file. */
|
||||
const LABEL_PREVIEW_COUNT = 3;
|
||||
|
||||
/**
|
||||
* Live inference controls (M4).
|
||||
*
|
||||
* Lets the user swap the parsing mode and attach a display-only label mapping
|
||||
* mid-session, without re-flashing the model. Sits in the right-hand
|
||||
* InferencePanel next to the confidence slider — the other control that
|
||||
* re-interprets results already on screen — so all the "how do I read this
|
||||
* output" knobs live together and none of them block the video area.
|
||||
*
|
||||
* The label section is only rendered for classification: labels map class
|
||||
* indices to names, which is meaningless for detection (whose labels come from
|
||||
* the model metadata per box).
|
||||
*/
|
||||
export function InferenceOptions({ deviceId, resultTaskType }: InferenceOptionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [localError, setLocalError] = useState<LocalError>(null);
|
||||
const { taskType, labels, applying, error, setTaskType, uploadLabels, clearLabels } =
|
||||
useInferenceOptionsStore();
|
||||
|
||||
// The user's explicit choice wins; otherwise mirror what the pipeline last
|
||||
// reported. Both go through normalizeTaskType so an unknown/legacy spelling
|
||||
// (plan R-4) falls through to object detection instead of rendering blank.
|
||||
const effectiveTaskType: FlashTaskType = taskType ?? normalizeTaskType(resultTaskType);
|
||||
const isClassification = effectiveTaskType === 'classification';
|
||||
|
||||
const taskTypeLabel = (value: FlashTaskType) =>
|
||||
value === 'classification'
|
||||
? t('devices.flash.taskTypeClassification')
|
||||
: t('devices.flash.taskTypeObjectDetection');
|
||||
|
||||
const handleTaskTypeChange = (value: string) => {
|
||||
setLocalError(null);
|
||||
void setTaskType(deviceId, normalizeTaskType(value));
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
// Reset the input so picking the SAME file again still fires a change
|
||||
// event — otherwise a user who fixed their labels.txt on disk and re-picked
|
||||
// it would get no feedback at all.
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
|
||||
const check = validateLabelFile(file);
|
||||
if (!check.ok) {
|
||||
setLocalError(check.reason);
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
void uploadLabels(deviceId, file);
|
||||
};
|
||||
|
||||
const handleClearLabels = () => {
|
||||
setLocalError(null);
|
||||
void clearLabels(deviceId);
|
||||
};
|
||||
|
||||
const localErrorMessage =
|
||||
localError === 'too-large'
|
||||
? t('inference.options.labelFileTooLarge', {
|
||||
limit: `${Math.round(MAX_LABEL_FILE_BYTES / 1024)} KB`,
|
||||
})
|
||||
: localError === 'wrong-type'
|
||||
? t('inference.options.labelFileWrongType')
|
||||
: null;
|
||||
|
||||
// Server errors carry a line number for parse failures — surfacing it is the
|
||||
// whole reason parsing happens server-side.
|
||||
const serverErrorMessage = error
|
||||
? error.line != null
|
||||
? t('inference.options.labelParseErrorLine', { line: error.line, message: error.message })
|
||||
: t('inference.options.applyFailed', { message: error.message })
|
||||
: null;
|
||||
|
||||
const errorMessage = localErrorMessage ?? serverErrorMessage;
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-testid="inference-options">
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="inference-task-type"
|
||||
className="text-sm font-medium text-muted-foreground"
|
||||
>
|
||||
{t('inference.options.taskType')}
|
||||
</label>
|
||||
<Select
|
||||
value={effectiveTaskType}
|
||||
onValueChange={handleTaskTypeChange}
|
||||
disabled={applying}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="inference-task-type"
|
||||
aria-describedby="inference-task-type-hint"
|
||||
data-testid="inference-task-type-trigger"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FLASH_TASK_TYPES.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{taskTypeLabel(value)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p id="inference-task-type-hint" className="text-xs text-muted-foreground">
|
||||
{t('inference.options.taskTypeHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isClassification && (
|
||||
<div className="space-y-1.5 border-t pt-3" data-testid="inference-label-section">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t('inference.options.labels')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t('inference.options.labelsHint')}</p>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_LABEL_EXTENSIONS.join(',')}
|
||||
className="sr-only"
|
||||
onChange={handleFileChange}
|
||||
data-testid="inference-label-file-input"
|
||||
aria-label={t('inference.options.selectLabelFile')}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={applying}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
data-testid="inference-label-upload-btn"
|
||||
>
|
||||
{labels
|
||||
? t('inference.options.replaceLabelFile')
|
||||
: t('inference.options.selectLabelFile')}
|
||||
</Button>
|
||||
{labels && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={applying}
|
||||
onClick={handleClearLabels}
|
||||
data-testid="inference-label-clear-btn"
|
||||
>
|
||||
{t('inference.options.clearLabels')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('inference.options.labelsFormatHint')}
|
||||
</p>
|
||||
|
||||
<p
|
||||
className="text-xs text-muted-foreground"
|
||||
// The applied/cleared state changes as a result of an async request,
|
||||
// so announce it rather than leaving screen-reader users guessing.
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="inference-label-status"
|
||||
>
|
||||
{applying
|
||||
? t('inference.options.applying')
|
||||
: labels
|
||||
? t('inference.options.labelsApplied', {
|
||||
count: labels.length,
|
||||
names: labels.slice(0, LABEL_PREVIEW_COUNT).join('、'),
|
||||
})
|
||||
: t('inference.options.noLabels')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<p
|
||||
className="text-xs text-destructive"
|
||||
role="alert"
|
||||
data-testid="inference-options-error"
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,14 +2,21 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ClassificationResult } from './classification-result';
|
||||
import { DetectionResultList } from './detection-result';
|
||||
import { PerformanceMetrics } from './performance-metrics';
|
||||
import { ConfidenceSlider } from './confidence-slider';
|
||||
import { VideoProgress } from './video-progress';
|
||||
import { InferenceOptions } from './inference-options';
|
||||
import { useInferenceStore } from '@/stores/inference-store';
|
||||
import { useCameraStore } from '@/stores/camera-store';
|
||||
import { isClassificationTask } from '@/lib/classification';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
export function InferencePanel() {
|
||||
interface InferencePanelProps {
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
export function InferencePanel({ deviceId }: InferencePanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { result, fps, avgLatency, isRunning, confidenceThreshold, batchResults } =
|
||||
useInferenceStore();
|
||||
@ -22,6 +29,10 @@ export function InferencePanel() {
|
||||
? batchResults[batchSelectedIndex]
|
||||
: result;
|
||||
const classifications = displayResult?.classifications || [];
|
||||
const detections = displayResult?.detections || [];
|
||||
// Branch on "is classification?" so detection stays the safe fall-through
|
||||
// under either taskType spelling (see lib/classification.ts).
|
||||
const isClassification = isClassificationTask(displayResult?.taskType);
|
||||
|
||||
return (
|
||||
<div className="w-80 space-y-4">
|
||||
@ -62,13 +73,33 @@ export function InferencePanel() {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">{t('inference.classificationResults')}</CardTitle>
|
||||
<CardTitle className="text-sm">{t('inference.options.title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<InferenceOptions deviceId={deviceId} resultTaskType={displayResult?.taskType} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">
|
||||
{isClassification
|
||||
? t('inference.classificationResults')
|
||||
: t('inference.detectionResults')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isClassification ? (
|
||||
<ClassificationResult
|
||||
results={classifications}
|
||||
confidenceThreshold={confidenceThreshold}
|
||||
/>
|
||||
) : (
|
||||
<DetectionResultList
|
||||
results={detections}
|
||||
confidenceThreshold={confidenceThreshold}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
72
local-tool/frontend/src/hooks/use-stable-top-class.ts
Normal file
72
local-tool/frontend/src/hooks/use-stable-top-class.ts
Normal file
@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { ClassResult } from '@/types/inference';
|
||||
import {
|
||||
DEFAULT_HYSTERESIS,
|
||||
INITIAL_HYSTERESIS_STATE,
|
||||
nextHysteresisState,
|
||||
type HysteresisState,
|
||||
} from '@/lib/classification';
|
||||
|
||||
interface UseStableTopClassOptions {
|
||||
confidenceThreshold: number;
|
||||
streakFrames?: number;
|
||||
immediateMargin?: number;
|
||||
}
|
||||
|
||||
interface InternalState {
|
||||
hysteresis: HysteresisState;
|
||||
/** The `classifications` array identity the hysteresis state was derived from. */
|
||||
seenClassifications: ClassResult[] | undefined;
|
||||
seenConfidenceThreshold: number;
|
||||
}
|
||||
|
||||
const INITIAL_INTERNAL: InternalState = {
|
||||
hysteresis: INITIAL_HYSTERESIS_STATE,
|
||||
seenClassifications: undefined,
|
||||
seenConfidenceThreshold: NaN,
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the anti-flicker top-1 class for the current frame.
|
||||
*
|
||||
* The hysteresis state machine advances once per inference result — the store
|
||||
* always hands us a fresh `classifications` array, so array identity is a
|
||||
* reliable "new frame" signal. Static image sources produce a single result and
|
||||
* therefore a single transition, which the state machine handles (the first
|
||||
* result above the threshold is displayed immediately).
|
||||
*
|
||||
* Uses React's "adjust state while rendering" pattern rather than a ref or an
|
||||
* effect: an effect-driven version would render one frame behind, which on a
|
||||
* 15 FPS stream is visible lag, and a ref mutated during render is unsafe under
|
||||
* concurrent rendering.
|
||||
* See https://react.dev/reference/react/useState#storing-information-from-previous-renders
|
||||
*/
|
||||
export function useStableTopClass(
|
||||
classifications: ClassResult[] | undefined,
|
||||
{ confidenceThreshold, streakFrames, immediateMargin }: UseStableTopClassOptions,
|
||||
): ClassResult | null {
|
||||
const [state, setState] = useState<InternalState>(INITIAL_INTERNAL);
|
||||
|
||||
let current = state;
|
||||
// Recompute when a new frame arrives, or when the user moves the confidence
|
||||
// slider (the currently displayed label may no longer qualify).
|
||||
if (
|
||||
state.seenClassifications !== classifications ||
|
||||
state.seenConfidenceThreshold !== confidenceThreshold
|
||||
) {
|
||||
current = {
|
||||
hysteresis: nextHysteresisState(state.hysteresis, classifications, {
|
||||
streakFrames: streakFrames ?? DEFAULT_HYSTERESIS.streakFrames,
|
||||
immediateMargin: immediateMargin ?? DEFAULT_HYSTERESIS.immediateMargin,
|
||||
confidenceThreshold,
|
||||
}),
|
||||
seenClassifications: classifications,
|
||||
seenConfidenceThreshold: confidenceThreshold,
|
||||
};
|
||||
setState(current);
|
||||
}
|
||||
|
||||
return current.hysteresis.displayed;
|
||||
}
|
||||
@ -1,12 +1,22 @@
|
||||
import { getApiBaseUrl, getRelayToken, fetchAndCacheRelayToken } from './constants';
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: {
|
||||
export interface ApiError {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard response envelope.
|
||||
*
|
||||
* `E` widens the error shape for endpoints that attach extra diagnostics — the
|
||||
* label upload returns a `line` number on parse failures, and that field has to
|
||||
* be declared rather than cast in, or the type system stops protecting the one
|
||||
* piece of information the user actually needs.
|
||||
*/
|
||||
export interface ApiResponse<T, E extends ApiError = ApiError> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: E;
|
||||
}
|
||||
|
||||
// Ensure relay token is available before making API requests.
|
||||
@ -40,7 +50,10 @@ function buildHeaders(): Record<string, string> {
|
||||
};
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<ApiResponse<T>> {
|
||||
async function request<T, E extends ApiError = ApiError>(
|
||||
path: string,
|
||||
options?: RequestInit,
|
||||
): Promise<ApiResponse<T, E>> {
|
||||
// Wait for relay token to be available before first request
|
||||
await ensureRelayToken();
|
||||
|
||||
@ -51,10 +64,32 @@ async function request<T>(path: string, options?: RequestInit): Promise<ApiRespo
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Multipart POST.
|
||||
*
|
||||
* Deliberately does NOT go through `buildHeaders()`: setting Content-Type
|
||||
* manually would omit the multipart boundary that `fetch` generates from the
|
||||
* FormData body, and the server would fail to parse the request.
|
||||
*/
|
||||
async function postForm<T, E extends ApiError = ApiError>(
|
||||
path: string,
|
||||
form: FormData,
|
||||
): Promise<ApiResponse<T, E>> {
|
||||
await ensureRelayToken();
|
||||
|
||||
const res = await fetch(`${getApiBaseUrl()}${path}`, {
|
||||
method: 'POST',
|
||||
headers: getRelayHeaders(),
|
||||
body: form,
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
|
||||
post: <T, E extends ApiError = ApiError>(path: string, body?: unknown) =>
|
||||
request<T, E>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
|
||||
postForm,
|
||||
put: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
|
||||
del: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||
|
||||
146
local-tool/frontend/src/lib/classification.ts
Normal file
146
local-tool/frontend/src/lib/classification.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import type { ClassResult } from '@/types/inference';
|
||||
|
||||
/**
|
||||
* Defensive taskType check.
|
||||
*
|
||||
* The taskType value set has historically been inconsistent across the stack:
|
||||
* the Python bridge used to emit `"detection"` while models.json / the
|
||||
* frontend TASK_TYPES constant use `"object_detection"` (see plan R-4).
|
||||
*
|
||||
* We therefore only ever branch on "is this classification?" — anything else
|
||||
* (including `undefined`) falls through to the existing detection rendering
|
||||
* path, which is the safe default: a detection model rendered as detection is
|
||||
* correct, whereas a mis-detected classification just means no overlay label.
|
||||
*/
|
||||
export function isClassificationTask(taskType: string | undefined | null): boolean {
|
||||
return taskType === 'classification';
|
||||
}
|
||||
|
||||
/**
|
||||
* Display label for a class result.
|
||||
*
|
||||
* `classIndex` is optional because the Go layer may not forward it (M2-d is a
|
||||
* "nice to have"). When the label is missing/blank we fall back to
|
||||
* `class_<index>`, and if we have neither we emit a stable placeholder rather
|
||||
* than rendering an empty chip.
|
||||
*/
|
||||
export function classResultLabel(result: Pick<ClassResult, 'label' | 'classIndex'>): string {
|
||||
const label = result.label?.trim();
|
||||
if (label) return label;
|
||||
if (typeof result.classIndex === 'number' && Number.isFinite(result.classIndex)) {
|
||||
return `class_${result.classIndex}`;
|
||||
}
|
||||
return 'class_?';
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable identity for a class result, used by the hysteresis state machine to
|
||||
* decide whether two consecutive frames refer to the same class.
|
||||
*
|
||||
* Prefers `classIndex` (immune to label churn when labels are re-uploaded) and
|
||||
* falls back to the label string when the index is absent.
|
||||
*/
|
||||
export function classResultKey(result: Pick<ClassResult, 'label' | 'classIndex'>): string {
|
||||
if (typeof result.classIndex === 'number' && Number.isFinite(result.classIndex)) {
|
||||
return `i:${result.classIndex}`;
|
||||
}
|
||||
return `l:${classResultLabel(result)}`;
|
||||
}
|
||||
|
||||
/** Highest-confidence entry, or null when there is nothing to show. */
|
||||
export function pickTopClass(results: ClassResult[] | undefined): ClassResult | null {
|
||||
if (!results || results.length === 0) return null;
|
||||
let top = results[0];
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
if (results[i].confidence > top.confidence) top = results[i];
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
export interface HysteresisState {
|
||||
/** Currently displayed class (null = nothing displayed yet). */
|
||||
displayed: ClassResult | null;
|
||||
/** Candidate class that is trying to replace `displayed`. */
|
||||
candidateKey: string | null;
|
||||
/** How many consecutive frames the candidate has been the top class. */
|
||||
candidateStreak: number;
|
||||
}
|
||||
|
||||
export interface HysteresisOptions {
|
||||
/**
|
||||
* Frames the challenger must stay on top before it replaces the displayed
|
||||
* class. 3 @ ~15 FPS ≈ 200 ms — long enough to swallow single-frame noise,
|
||||
* short enough that a genuine gesture change still feels instant.
|
||||
*/
|
||||
streakFrames: number;
|
||||
/**
|
||||
* Confidence margin that lets a challenger switch immediately, bypassing the
|
||||
* streak counter. 0.15 means "clearly more confident, not a coin flip".
|
||||
*/
|
||||
immediateMargin: number;
|
||||
/**
|
||||
* Below this confidence nothing is displayed. Mirrors the user-facing
|
||||
* confidence slider so the overlay agrees with the side panel.
|
||||
*/
|
||||
confidenceThreshold: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_HYSTERESIS: Pick<HysteresisOptions, 'streakFrames' | 'immediateMargin'> = {
|
||||
streakFrames: 3,
|
||||
immediateMargin: 0.15,
|
||||
};
|
||||
|
||||
export const INITIAL_HYSTERESIS_STATE: HysteresisState = {
|
||||
displayed: null,
|
||||
candidateKey: null,
|
||||
candidateStreak: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Anti-flicker state machine for the top-1 classification label.
|
||||
*
|
||||
* Video/camera sources re-classify every frame, so a 50/50 boundary makes the
|
||||
* raw top-1 oscillate wildly (plan risk R-9). A frame is only promoted when it
|
||||
* either (a) beats the incumbent by a clear confidence margin, or (b) has been
|
||||
* the top class for `streakFrames` consecutive frames.
|
||||
*
|
||||
* Pure function: takes the previous state, returns the next one. No React,
|
||||
* no timers — which makes it directly unit-testable.
|
||||
*/
|
||||
export function nextHysteresisState(
|
||||
prev: HysteresisState,
|
||||
results: ClassResult[] | undefined,
|
||||
options: HysteresisOptions,
|
||||
): HysteresisState {
|
||||
const { streakFrames, immediateMargin, confidenceThreshold } = options;
|
||||
const top = pickTopClass(results);
|
||||
|
||||
// Nothing above the threshold → clear immediately. Showing a stale label over
|
||||
// a frame the model no longer recognises is worse than showing nothing.
|
||||
if (!top || top.confidence < confidenceThreshold) {
|
||||
return INITIAL_HYSTERESIS_STATE;
|
||||
}
|
||||
|
||||
// First result, or the incumbent already matches → refresh confidence in place.
|
||||
if (!prev.displayed) {
|
||||
return { displayed: top, candidateKey: null, candidateStreak: 0 };
|
||||
}
|
||||
|
||||
const topKey = classResultKey(top);
|
||||
if (topKey === classResultKey(prev.displayed)) {
|
||||
return { displayed: top, candidateKey: null, candidateStreak: 0 };
|
||||
}
|
||||
|
||||
// Clear winner → switch without waiting for the streak.
|
||||
if (top.confidence - prev.displayed.confidence >= immediateMargin) {
|
||||
return { displayed: top, candidateKey: null, candidateStreak: 0 };
|
||||
}
|
||||
|
||||
const streak = prev.candidateKey === topKey ? prev.candidateStreak + 1 : 1;
|
||||
if (streak >= streakFrames) {
|
||||
return { displayed: top, candidateKey: null, candidateStreak: 0 };
|
||||
}
|
||||
|
||||
// Challenger not convincing enough yet — keep showing the incumbent.
|
||||
return { displayed: prev.displayed, candidateKey: topKey, candidateStreak: streak };
|
||||
}
|
||||
@ -125,6 +125,12 @@ export const en: TranslationDict = {
|
||||
flashFailed: 'Flash Failed',
|
||||
preparingFlash: 'Preparing flash...',
|
||||
flashComplete: 'Flash complete!',
|
||||
selectModelFirst: 'Select a model first',
|
||||
// Display names for the inference types. The flash dialog no longer
|
||||
// selects a type, but the inference page's InferenceOptions still shares
|
||||
// these two labels, so they stay.
|
||||
taskTypeObjectDetection: 'Object Detection',
|
||||
taskTypeClassification: 'Classification',
|
||||
},
|
||||
card: {
|
||||
fwBadge: {
|
||||
@ -234,6 +240,9 @@ export const en: TranslationDict = {
|
||||
confidenceFilter: 'Confidence Filter',
|
||||
confidenceThreshold: 'Confidence Threshold',
|
||||
classificationResults: 'Classification Results',
|
||||
detectionResults: 'Detection Results',
|
||||
detectedCount: 'Detected',
|
||||
unrecognized: 'Unrecognized',
|
||||
noResultsAboveThreshold: 'No results above threshold',
|
||||
details: 'Details',
|
||||
model: 'Model',
|
||||
@ -250,6 +259,27 @@ export const en: TranslationDict = {
|
||||
videoProgress: 'Video Progress',
|
||||
frames: 'frames',
|
||||
framesProcessed: 'Frames Processed',
|
||||
options: {
|
||||
title: 'Inference Options',
|
||||
taskType: 'Parsing Mode',
|
||||
taskTypeHint:
|
||||
'Applies to subsequent results immediately — no need to re-flash the model.',
|
||||
applying: 'Applying...',
|
||||
applyFailed: 'Could not apply: {message}',
|
||||
labels: 'Label Mapping',
|
||||
labelsHint:
|
||||
'Optional. Without a label file the raw class indices are shown (class_0, class_1…).',
|
||||
labelsFormatHint: 'One "<index> <name>" per line, e.g. 0 scissors',
|
||||
selectLabelFile: 'Upload label file',
|
||||
replaceLabelFile: 'Replace label file',
|
||||
clearLabels: 'Clear labels',
|
||||
labelsApplied: 'Applied {count} labels ({names})',
|
||||
labelsCleared: 'Labels cleared — raw class indices will be shown',
|
||||
noLabels: 'No label file uploaded',
|
||||
labelFileTooLarge: 'Label file is too large (limit {limit})',
|
||||
labelFileWrongType: 'Only .txt or .names files are accepted',
|
||||
labelParseErrorLine: 'Line {line}: {message}',
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
|
||||
@ -123,6 +123,10 @@ export interface TranslationDict {
|
||||
flashFailed: string;
|
||||
preparingFlash: string;
|
||||
flashComplete: string;
|
||||
selectModelFirst: string;
|
||||
// Shared with the inference page's InferenceOptions selector.
|
||||
taskTypeObjectDetection: string;
|
||||
taskTypeClassification: string;
|
||||
};
|
||||
card: {
|
||||
fwBadge: {
|
||||
@ -232,6 +236,9 @@ export interface TranslationDict {
|
||||
confidenceFilter: string;
|
||||
confidenceThreshold: string;
|
||||
classificationResults: string;
|
||||
detectionResults: string;
|
||||
detectedCount: string;
|
||||
unrecognized: string;
|
||||
noResultsAboveThreshold: string;
|
||||
details: string;
|
||||
model: string;
|
||||
@ -248,6 +255,25 @@ export interface TranslationDict {
|
||||
videoProgress: string;
|
||||
frames: string;
|
||||
framesProcessed: string;
|
||||
options: {
|
||||
title: string;
|
||||
taskType: string;
|
||||
taskTypeHint: string;
|
||||
applying: string;
|
||||
applyFailed: string;
|
||||
labels: string;
|
||||
labelsHint: string;
|
||||
labelsFormatHint: string;
|
||||
selectLabelFile: string;
|
||||
replaceLabelFile: string;
|
||||
clearLabels: string;
|
||||
labelsApplied: string;
|
||||
labelsCleared: string;
|
||||
noLabels: string;
|
||||
labelFileTooLarge: string;
|
||||
labelFileWrongType: string;
|
||||
labelParseErrorLine: string;
|
||||
};
|
||||
};
|
||||
settings: {
|
||||
title: string;
|
||||
|
||||
@ -125,6 +125,11 @@ export const zhTW: TranslationDict = {
|
||||
flashFailed: '燒錄失敗',
|
||||
preparingFlash: '準備燒錄中...',
|
||||
flashComplete: '燒錄完成!',
|
||||
selectModelFirst: '請先選擇模型',
|
||||
// 推論種類的顯示名稱。燒錄對話框已不再選推論種類,但推論頁的
|
||||
// InferenceOptions 仍共用這兩個標籤,故保留。
|
||||
taskTypeObjectDetection: '物件偵測',
|
||||
taskTypeClassification: '分類',
|
||||
},
|
||||
card: {
|
||||
fwBadge: {
|
||||
@ -234,6 +239,9 @@ export const zhTW: TranslationDict = {
|
||||
confidenceFilter: '信心度篩選',
|
||||
confidenceThreshold: '信心度門檻',
|
||||
classificationResults: '分類結果',
|
||||
detectionResults: '偵測結果',
|
||||
detectedCount: '偵測數量',
|
||||
unrecognized: '無法辨識',
|
||||
noResultsAboveThreshold: '沒有超過門檻的結果',
|
||||
details: '詳細資訊',
|
||||
model: '模型',
|
||||
@ -250,6 +258,25 @@ export const zhTW: TranslationDict = {
|
||||
videoProgress: '影片進度',
|
||||
frames: '幀',
|
||||
framesProcessed: '已處理幀數',
|
||||
options: {
|
||||
title: '推論設定',
|
||||
taskType: '解析方式',
|
||||
taskTypeHint: '切換後立即套用到之後的推論結果,不需要重新載入模型。',
|
||||
applying: '套用中...',
|
||||
applyFailed: '套用失敗:{message}',
|
||||
labels: '標籤對照',
|
||||
labelsHint: '選用。沒有上傳時會顯示原始類別編號(class_0、class_1…)。',
|
||||
labelsFormatHint: '每行一筆「編號 名稱」,例如:0 剪刀',
|
||||
selectLabelFile: '上傳標籤檔',
|
||||
replaceLabelFile: '更換標籤檔',
|
||||
clearLabels: '清除標籤',
|
||||
labelsApplied: '已套用 {count} 個標籤({names})',
|
||||
labelsCleared: '已清除標籤,改用原始類別編號',
|
||||
noLabels: '尚未上傳標籤檔',
|
||||
labelFileTooLarge: '標籤檔過大(上限 {limit})',
|
||||
labelFileWrongType: '只接受 .txt 或 .names 檔',
|
||||
labelParseErrorLine: '第 {line} 行:{message}',
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
title: '設定',
|
||||
|
||||
29
local-tool/frontend/src/lib/task-type.ts
Normal file
29
local-tool/frontend/src/lib/task-type.ts
Normal file
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Task types the inference pipeline can actually parse.
|
||||
*
|
||||
* models.json also carries `segmentation` / `pose_estimation` (the upload form
|
||||
* offers them), but neither the Python bridge nor the frontend has a rendering
|
||||
* path for those yet. The inference-page selector therefore only exposes the
|
||||
* two types that produce meaningful output.
|
||||
*
|
||||
* Naming note: the `FLASH_` prefix is historical — the selector originally
|
||||
* lived in the flash dialog. It now only backs the inference page's runtime
|
||||
* switch (`components/inference/inference-options.tsx`).
|
||||
*/
|
||||
export const FLASH_TASK_TYPES = ['object_detection', 'classification'] as const;
|
||||
|
||||
export type FlashTaskType = (typeof FLASH_TASK_TYPES)[number];
|
||||
|
||||
/**
|
||||
* Normalise an arbitrary taskType string to one of the two supported values.
|
||||
*
|
||||
* The value set has historically been inconsistent across the stack: the Python
|
||||
* bridge used to emit `"detection"` while models.json and the frontend
|
||||
* TASK_TYPES constant use `"object_detection"` (plan R-4). We therefore only
|
||||
* ever test for classification and let everything else — including `undefined`,
|
||||
* `segmentation` and legacy `detection` — fall through to object detection,
|
||||
* which is the existing safe default.
|
||||
*/
|
||||
export function normalizeTaskType(taskType: string | undefined | null): FlashTaskType {
|
||||
return taskType === 'classification' ? 'classification' : 'object_detection';
|
||||
}
|
||||
198
local-tool/frontend/src/stores/inference-options-store.ts
Normal file
198
local-tool/frontend/src/stores/inference-options-store.ts
Normal file
@ -0,0 +1,198 @@
|
||||
import { create } from 'zustand';
|
||||
import { api } from '@/lib/api';
|
||||
import { normalizeTaskType, type FlashTaskType } from '@/lib/task-type';
|
||||
import type { ApiResponse } from '@/lib/api';
|
||||
|
||||
/**
|
||||
* Live inference options — the parsing mode and the display-only label mapping
|
||||
* that can be changed WITHOUT re-flashing the model (M4).
|
||||
*
|
||||
* The whole HTTP contract lives in this file on purpose. The backend endpoint
|
||||
* is being built in parallel, so keeping every request shape in one module
|
||||
* means realigning with the server is a single-file edit rather than a hunt
|
||||
* through components.
|
||||
*
|
||||
* Contract as implemented here:
|
||||
*
|
||||
* POST /api/devices/:id/inference/options
|
||||
* Content-Type: application/json
|
||||
* { "taskType": "object_detection" | "classification" }
|
||||
*
|
||||
* POST /api/devices/:id/inference/options (label upload)
|
||||
* Content-Type: multipart/form-data
|
||||
* labelFile: <labels.txt> // `<index> <name>` per line
|
||||
*
|
||||
* POST /api/devices/:id/inference/options (clear the mapping)
|
||||
* Content-Type: application/json
|
||||
* { "labels": [] } // explicit empty array; omitting the field
|
||||
* // instead means "leave labels untouched"
|
||||
*
|
||||
* All return the standard envelope:
|
||||
* { success: true, data: { deviceId, taskType, labelCount, labels, maxIndex } }
|
||||
* { success: false, error: { code, message, line? } }
|
||||
*
|
||||
* The server rejects a request that carries neither `taskType` nor `labels`,
|
||||
* so every action below sends at least one of them.
|
||||
*
|
||||
* Nothing is persisted: the user re-uploads the label file per session, which
|
||||
* is what they asked for ("不用記,每次現場傳").
|
||||
*/
|
||||
|
||||
/** Matches the plan §3.5 cap (3 classes ≈ 30 bytes; 10k classes < 200 KB). */
|
||||
export const MAX_LABEL_FILE_BYTES = 256 * 1024;
|
||||
|
||||
/** Extensions accepted client-side. Content validation happens server-side. */
|
||||
export const ACCEPTED_LABEL_EXTENSIONS = ['.txt', '.names'] as const;
|
||||
|
||||
export interface InferenceOptionsResponse {
|
||||
taskType?: string;
|
||||
labelCount?: number;
|
||||
labels?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Error envelope for label parsing. `line` is optional and only present for
|
||||
* `LABEL_PARSE_ERROR`, which is the whole point of letting the server parse:
|
||||
* it can point at the offending line.
|
||||
*/
|
||||
export interface InferenceOptionsError {
|
||||
code: string;
|
||||
message: string;
|
||||
line?: number;
|
||||
}
|
||||
|
||||
interface InferenceOptionsState {
|
||||
/**
|
||||
* `null` = the user has not overridden anything this session, so the mode
|
||||
* flashed with the model is still in effect. Components resolve the value to
|
||||
* display by falling back to the last inference result's taskType.
|
||||
*/
|
||||
taskType: FlashTaskType | null;
|
||||
labels: string[] | null;
|
||||
applying: boolean;
|
||||
error: InferenceOptionsError | null;
|
||||
|
||||
setTaskType: (deviceId: string, taskType: FlashTaskType) => Promise<boolean>;
|
||||
uploadLabels: (deviceId: string, file: File) => Promise<boolean>;
|
||||
clearLabels: (deviceId: string) => Promise<boolean>;
|
||||
clearError: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
function optionsPath(deviceId: string) {
|
||||
return `/devices/${deviceId}/inference/options`;
|
||||
}
|
||||
|
||||
type OptionsResponse = ApiResponse<InferenceOptionsResponse, InferenceOptionsError>;
|
||||
|
||||
/** Normalises a failed response into a fully-populated error envelope. */
|
||||
function toError(res: OptionsResponse): InferenceOptionsError {
|
||||
return {
|
||||
code: res.error?.code || 'UNKNOWN',
|
||||
message: res.error?.message || 'Request failed',
|
||||
line: res.error?.line,
|
||||
};
|
||||
}
|
||||
|
||||
export const useInferenceOptionsStore = create<InferenceOptionsState>((set) => ({
|
||||
taskType: null,
|
||||
labels: null,
|
||||
applying: false,
|
||||
error: null,
|
||||
|
||||
setTaskType: async (deviceId, taskType) => {
|
||||
set({ applying: true, error: null });
|
||||
try {
|
||||
const res = await api.post<InferenceOptionsResponse, InferenceOptionsError>(
|
||||
optionsPath(deviceId),
|
||||
{ taskType },
|
||||
);
|
||||
if (!res.success) {
|
||||
set({ applying: false, error: toError(res) });
|
||||
return false;
|
||||
}
|
||||
// Trust the server's echo when it sends one, so a server-side coercion
|
||||
// never leaves the UI showing a mode that is not actually in effect.
|
||||
set({
|
||||
applying: false,
|
||||
taskType: res.data?.taskType ? normalizeTaskType(res.data.taskType) : taskType,
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
set({
|
||||
applying: false,
|
||||
error: { code: 'NETWORK_ERROR', message: e instanceof Error ? e.message : String(e) },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
uploadLabels: async (deviceId, file) => {
|
||||
set({ applying: true, error: null });
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('labelFile', file);
|
||||
const res = await api.postForm<InferenceOptionsResponse, InferenceOptionsError>(
|
||||
optionsPath(deviceId),
|
||||
form,
|
||||
);
|
||||
if (!res.success) {
|
||||
set({ applying: false, error: toError(res) });
|
||||
return false;
|
||||
}
|
||||
set({ applying: false, labels: res.data?.labels ?? [] });
|
||||
return true;
|
||||
} catch (e) {
|
||||
set({
|
||||
applying: false,
|
||||
error: { code: 'NETWORK_ERROR', message: e instanceof Error ? e.message : String(e) },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
clearLabels: async (deviceId) => {
|
||||
set({ applying: true, error: null });
|
||||
try {
|
||||
// An explicit empty array clears the mapping. Omitting the field would
|
||||
// mean "leave labels as they are", so the array must actually be sent.
|
||||
const res = await api.post<InferenceOptionsResponse, InferenceOptionsError>(
|
||||
optionsPath(deviceId),
|
||||
{ labels: [] },
|
||||
);
|
||||
if (!res.success) {
|
||||
set({ applying: false, error: toError(res) });
|
||||
return false;
|
||||
}
|
||||
set({ applying: false, labels: null });
|
||||
return true;
|
||||
} catch (e) {
|
||||
set({
|
||||
applying: false,
|
||||
error: { code: 'NETWORK_ERROR', message: e instanceof Error ? e.message : String(e) },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
|
||||
reset: () => set({ taskType: null, labels: null, applying: false, error: null }),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Client-side pre-checks. The server validates again — this only spares the
|
||||
* user a round trip for the two mistakes that need no parsing to detect.
|
||||
*/
|
||||
export function validateLabelFile(
|
||||
file: File,
|
||||
): { ok: true } | { ok: false; reason: 'too-large' | 'wrong-type' } {
|
||||
const lower = file.name.toLowerCase();
|
||||
if (!ACCEPTED_LABEL_EXTENSIONS.some((ext) => lower.endsWith(ext))) {
|
||||
return { ok: false, reason: 'wrong-type' };
|
||||
}
|
||||
if (file.size > MAX_LABEL_FILE_BYTES) {
|
||||
return { ok: false, reason: 'too-large' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { DetectionResultList } from '@/components/inference/detection-result';
|
||||
import type { DetectionResult } from '@/types/inference';
|
||||
|
||||
function det(label: string, confidence: number): DetectionResult {
|
||||
return { label, confidence, bbox: { x: 0.1, y: 0.1, width: 0.2, height: 0.2 } };
|
||||
}
|
||||
|
||||
describe('DetectionResultList — M3-b detection side panel', () => {
|
||||
it('lists detections sorted by confidence descending', () => {
|
||||
render(
|
||||
<DetectionResultList
|
||||
results={[det('car', 0.6), det('person', 0.9), det('dog', 0.75)]}
|
||||
confidenceThreshold={0.5}
|
||||
/>,
|
||||
);
|
||||
const items = screen.getAllByRole('listitem').map((li) => li.textContent);
|
||||
expect(items[0]).toContain('person');
|
||||
expect(items[1]).toContain('dog');
|
||||
expect(items[2]).toContain('car');
|
||||
});
|
||||
|
||||
it('filters out detections below the confidence threshold', () => {
|
||||
render(
|
||||
<DetectionResultList
|
||||
results={[det('person', 0.9), det('noise', 0.1)]}
|
||||
confidenceThreshold={0.5}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(1);
|
||||
expect(screen.queryByText('noise')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the detected count', () => {
|
||||
render(
|
||||
<DetectionResultList
|
||||
results={[det('person', 0.9), det('car', 0.8)]}
|
||||
confidenceThreshold={0.5}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('detection-result-list').textContent).toContain('2');
|
||||
});
|
||||
|
||||
it('shows the empty state when nothing clears the threshold', () => {
|
||||
render(<DetectionResultList results={[det('person', 0.1)]} confidenceThreshold={0.5} />);
|
||||
expect(screen.queryByTestId('detection-result-list')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the empty state for an empty result set', () => {
|
||||
render(<DetectionResultList results={[]} confidenceThreshold={0.5} />);
|
||||
expect(screen.queryByTestId('detection-result-list')).toBeNull();
|
||||
});
|
||||
});
|
||||
191
local-tool/frontend/src/tests/components/flash-dialog.test.tsx
Normal file
191
local-tool/frontend/src/tests/components/flash-dialog.test.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { FlashDialog } from '@/components/devices/flash-dialog';
|
||||
import { useModelStore } from '@/stores/model-store';
|
||||
import { useFlashStore } from '@/stores/flash-store';
|
||||
import { useDeviceStore } from '@/stores/device-store';
|
||||
import { api } from '@/lib/api';
|
||||
import type { ModelSummary } from '@/types/model';
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: {
|
||||
get: vi.fn().mockResolvedValue({ success: true, data: { models: [], total: 0 } }),
|
||||
post: vi.fn().mockResolvedValue({ success: true }),
|
||||
},
|
||||
getRelayHeaders: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/toast', () => ({
|
||||
showSuccess: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
showApiError: vi.fn(),
|
||||
}));
|
||||
|
||||
// The dialog opens a flash-progress WebSocket before POSTing. Stub it so
|
||||
// `connectAndWait` resolves immediately instead of hitting the 3s timeout.
|
||||
//
|
||||
// The returned callbacks must keep a STABLE identity across renders: the real
|
||||
// hook wraps them in useCallback, and the dialog lists `disconnect` in a
|
||||
// useEffect dependency array. Returning fresh functions each render re-fires
|
||||
// that effect on every render and blows the React update-depth limit.
|
||||
const connectAndWaitMock = vi.fn().mockResolvedValue(undefined);
|
||||
const disconnectMock = vi.fn();
|
||||
vi.mock('@/hooks/use-flash-progress', () => ({
|
||||
useFlashProgress: () => ({
|
||||
connectAndWait: connectAndWaitMock,
|
||||
disconnect: disconnectMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
const DEVICE_ID = 'dev-1';
|
||||
|
||||
function model(overrides: Partial<ModelSummary> & Pick<ModelSummary, 'id' | 'name'>): ModelSummary {
|
||||
return {
|
||||
thumbnail: '',
|
||||
taskType: 'object_detection',
|
||||
categories: [],
|
||||
modelSize: 1024,
|
||||
accuracy: 0.9,
|
||||
fps: 30,
|
||||
supportedHardware: ['KL520'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const DETECTION_MODEL = model({ id: 'm-det', name: '物件辨識', taskType: 'object_detection' });
|
||||
const CLASSIFICATION_MODEL = model({ id: 'm-cls', name: '手勢分類', taskType: 'classification' });
|
||||
// Declares a different hardware family than the seeded KL520 device, so the
|
||||
// incompatibility path can be exercised.
|
||||
const INCOMPATIBLE_MODEL = model({ id: 'm-720', name: '高階模型', supportedHardware: ['KL720'] });
|
||||
|
||||
// Stable identity — replacing this per-test would change the dialog's effect
|
||||
// dependencies on every render and spin the component into an update loop.
|
||||
const noopFetchModels = async () => {};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// clearAllMocks() also drops the resolved value, which would make
|
||||
// connectAndWait return undefined and break the await in handleFlash.
|
||||
connectAndWaitMock.mockResolvedValue(undefined);
|
||||
vi.mocked(api.post).mockResolvedValue({ success: true });
|
||||
// fetchModels() runs when the dialog opens; with the mocked api it would
|
||||
// overwrite the seeded list with []. Stub it once with a stable identity so
|
||||
// the dialog's `useEffect([open, fetchModels, ...])` does not re-fire.
|
||||
useModelStore.setState({
|
||||
models: [DETECTION_MODEL, CLASSIFICATION_MODEL, INCOMPATIBLE_MODEL],
|
||||
fetchModels: noopFetchModels,
|
||||
});
|
||||
useFlashStore.setState({
|
||||
activeDeviceId: null,
|
||||
isFlashing: false,
|
||||
progress: null,
|
||||
error: null,
|
||||
lastFlashParams: null,
|
||||
});
|
||||
useDeviceStore.setState({
|
||||
devices: [{ id: DEVICE_ID, type: 'KL520' } as ReturnType<
|
||||
typeof useDeviceStore.getState
|
||||
>['devices'][number]],
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Radix Select cannot be opened with a plain click under jsdom (it depends on
|
||||
* pointer capture), but its keyboard path works. Enter opens the listbox and a
|
||||
* click on the rendered option commits the value.
|
||||
*/
|
||||
async function selectOption(trigger: HTMLElement, optionName: string) {
|
||||
fireEvent.keyDown(trigger, { key: 'Enter', code: 'Enter' });
|
||||
fireEvent.click(await screen.findByRole('option', { name: optionName }));
|
||||
}
|
||||
|
||||
/** Opens the dialog and picks a model by its visible name. */
|
||||
async function openAndSelectModel(name: string) {
|
||||
render(<FlashDialog deviceId={DEVICE_ID} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '載入模型' }));
|
||||
const triggers = await screen.findAllByRole('combobox');
|
||||
await selectOption(triggers[0], name);
|
||||
}
|
||||
|
||||
describe('FlashDialog — model selection', () => {
|
||||
it('cannot submit until a model is chosen', () => {
|
||||
render(<FlashDialog deviceId={DEVICE_ID} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '載入模型' }));
|
||||
expect(screen.getByRole('button', { name: '請先選擇模型' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('offers exactly one selector — the model dropdown', async () => {
|
||||
// Guards the removal of the flash-time inference-type selector: parsing is
|
||||
// now switched on the inference page, so a second combobox here would mean
|
||||
// the duplicate entry point came back.
|
||||
await openAndSelectModel('物件辨識');
|
||||
expect(screen.getAllByRole('combobox')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('enables submitting once a compatible model is selected', async () => {
|
||||
await openAndSelectModel('物件辨識');
|
||||
expect(screen.getByRole('button', { name: '開始載入' })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FlashDialog — hardware compatibility', () => {
|
||||
it('warns and blocks flashing a model the device does not support', async () => {
|
||||
await openAndSelectModel('高階模型');
|
||||
|
||||
expect(screen.getByText('硬體不相容')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '不相容 — 無法載入' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows no incompatibility warning for a supported model', async () => {
|
||||
await openAndSelectModel('物件辨識');
|
||||
expect(screen.queryByText('硬體不相容')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FlashDialog — flash request payload', () => {
|
||||
it('posts only the model id', async () => {
|
||||
await openAndSelectModel('物件辨識');
|
||||
fireEvent.click(screen.getByRole('button', { name: '開始載入' }));
|
||||
|
||||
await waitFor(() => expect(api.post).toHaveBeenCalled());
|
||||
expect(api.post).toHaveBeenCalledWith(`/devices/${DEVICE_ID}/flash`, { modelId: 'm-det' });
|
||||
});
|
||||
|
||||
it('posts only the model id for a classification model too', async () => {
|
||||
// The model's declared task type must never leak into the flash body: the
|
||||
// server resolves it from models.json on its own.
|
||||
await openAndSelectModel('手勢分類');
|
||||
fireEvent.click(screen.getByRole('button', { name: '開始載入' }));
|
||||
|
||||
await waitFor(() => expect(api.post).toHaveBeenCalled());
|
||||
expect(api.post).toHaveBeenCalledWith(`/devices/${DEVICE_ID}/flash`, { modelId: 'm-cls' });
|
||||
});
|
||||
|
||||
it('waits for the progress socket before posting', async () => {
|
||||
await openAndSelectModel('物件辨識');
|
||||
fireEvent.click(screen.getByRole('button', { name: '開始載入' }));
|
||||
|
||||
await waitFor(() => expect(api.post).toHaveBeenCalled());
|
||||
expect(connectAndWaitMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not post when the progress socket fails to connect', async () => {
|
||||
connectAndWaitMock.mockRejectedValueOnce(new Error('socket down'));
|
||||
await openAndSelectModel('物件辨識');
|
||||
fireEvent.click(screen.getByRole('button', { name: '開始載入' }));
|
||||
|
||||
await waitFor(() => expect(useFlashStore.getState().error).toBe('socket down'));
|
||||
expect(api.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('replays the same payload on retry', async () => {
|
||||
await openAndSelectModel('物件辨識');
|
||||
fireEvent.click(screen.getByRole('button', { name: '開始載入' }));
|
||||
await waitFor(() => expect(api.post).toHaveBeenCalledTimes(1));
|
||||
|
||||
vi.mocked(api.post).mockClear();
|
||||
await useFlashStore.getState().retryFlash();
|
||||
|
||||
expect(api.post).toHaveBeenCalledWith(`/devices/${DEVICE_ID}/flash`, { modelId: 'm-det' });
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,345 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { InferenceOptions } from '@/components/inference/inference-options';
|
||||
import {
|
||||
useInferenceOptionsStore,
|
||||
MAX_LABEL_FILE_BYTES,
|
||||
type InferenceOptionsResponse,
|
||||
type InferenceOptionsError,
|
||||
} from '@/stores/inference-options-store';
|
||||
import { api, type ApiResponse } from '@/lib/api';
|
||||
|
||||
/**
|
||||
* The store calls `api.post`/`api.postForm` with the widened error type so a
|
||||
* parse error keeps its `line` number. `vi.mocked` resolves the generic to its
|
||||
* default `ApiError`, so responses are built through this helper to stay in the
|
||||
* shape the store actually receives.
|
||||
*/
|
||||
type OptionsResponse = ApiResponse<InferenceOptionsResponse, InferenceOptionsError>;
|
||||
const reply = (r: OptionsResponse) => r as ApiResponse<InferenceOptionsResponse>;
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: {
|
||||
get: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
post: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
postForm: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
},
|
||||
getRelayHeaders: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
const DEVICE_ID = 'dev-1';
|
||||
const OPTIONS_PATH = `/devices/${DEVICE_ID}/inference/options`;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(api.post).mockResolvedValue({ success: true, data: {} });
|
||||
vi.mocked(api.postForm).mockResolvedValue({ success: true, data: {} });
|
||||
useInferenceOptionsStore.getState().reset();
|
||||
});
|
||||
|
||||
/**
|
||||
* Radix Select cannot be opened by a plain click under jsdom (pointer capture),
|
||||
* but the keyboard path works: Enter opens the listbox, a click commits.
|
||||
*/
|
||||
async function selectOption(trigger: HTMLElement, optionName: string) {
|
||||
fireEvent.keyDown(trigger, { key: 'Enter', code: 'Enter' });
|
||||
fireEvent.click(await screen.findByRole('option', { name: optionName }));
|
||||
}
|
||||
|
||||
function taskTypeTrigger() {
|
||||
return screen.getByTestId('inference-task-type-trigger');
|
||||
}
|
||||
|
||||
function labelFile(name = 'labels.txt', content = '0 剪刀\n1 石頭\n2 布\n') {
|
||||
return new File([content], name, { type: 'text/plain' });
|
||||
}
|
||||
|
||||
function uploadLabelFile(file: File) {
|
||||
const input = screen.getByTestId('inference-label-file-input') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
}
|
||||
|
||||
describe('InferenceOptions — parsing mode selector', () => {
|
||||
it('mirrors the task type reported by the latest result', () => {
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
expect(taskTypeTrigger()).toHaveTextContent('分類');
|
||||
});
|
||||
|
||||
it('shows object detection for a detection result', () => {
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="object_detection" />);
|
||||
expect(taskTypeTrigger()).toHaveTextContent('物件偵測');
|
||||
});
|
||||
|
||||
it('falls back to object detection for the legacy "detection" spelling (R-4)', () => {
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="detection" />);
|
||||
expect(taskTypeTrigger()).toHaveTextContent('物件偵測');
|
||||
});
|
||||
|
||||
it('falls back to object detection when no result has arrived yet', () => {
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType={undefined} />);
|
||||
expect(taskTypeTrigger()).toHaveTextContent('物件偵測');
|
||||
});
|
||||
|
||||
it('POSTs the newly chosen task type', async () => {
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="object_detection" />);
|
||||
await selectOption(taskTypeTrigger(), '分類');
|
||||
|
||||
await waitFor(() => expect(api.post).toHaveBeenCalled());
|
||||
expect(api.post).toHaveBeenCalledWith(OPTIONS_PATH, { taskType: 'classification' });
|
||||
});
|
||||
|
||||
it('keeps showing the user choice after it is applied, not the stale result type', async () => {
|
||||
// The prop still says object_detection (no new frame has arrived yet). The
|
||||
// explicit choice must win, otherwise the selector snaps back and looks
|
||||
// like the switch failed.
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="object_detection" />);
|
||||
await selectOption(taskTypeTrigger(), '分類');
|
||||
|
||||
await waitFor(() => expect(taskTypeTrigger()).toHaveTextContent('分類'));
|
||||
});
|
||||
|
||||
it('adopts the task type echoed back by the server', async () => {
|
||||
vi.mocked(api.post).mockResolvedValue({
|
||||
success: true,
|
||||
data: { taskType: 'object_detection' },
|
||||
});
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="object_detection" />);
|
||||
await selectOption(taskTypeTrigger(), '分類');
|
||||
|
||||
// Server refused to switch and said "still object_detection" — the UI must
|
||||
// not claim classification is in effect.
|
||||
await waitFor(() => expect(taskTypeTrigger()).toHaveTextContent('物件偵測'));
|
||||
});
|
||||
|
||||
it('surfaces a failed switch', async () => {
|
||||
vi.mocked(api.post).mockResolvedValue({
|
||||
success: false,
|
||||
error: { code: 'DEVICE_NOT_CONNECTED', message: 'device not connected' },
|
||||
});
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="object_detection" />);
|
||||
await selectOption(taskTypeTrigger(), '分類');
|
||||
|
||||
const err = await screen.findByTestId('inference-options-error');
|
||||
expect(err.textContent).toContain('device not connected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('InferenceOptions — label section visibility', () => {
|
||||
it('hides the label section for object detection', () => {
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="object_detection" />);
|
||||
expect(screen.queryByTestId('inference-label-section')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the label section for classification', () => {
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
expect(screen.getByTestId('inference-label-section')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reveals the label section once the user switches to classification', async () => {
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="object_detection" />);
|
||||
expect(screen.queryByTestId('inference-label-section')).toBeNull();
|
||||
|
||||
await selectOption(taskTypeTrigger(), '分類');
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('inference-label-section')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('hides the label section again when switching back to detection', async () => {
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
expect(screen.getByTestId('inference-label-section')).toBeInTheDocument();
|
||||
|
||||
await selectOption(taskTypeTrigger(), '物件偵測');
|
||||
await waitFor(() => expect(screen.queryByTestId('inference-label-section')).toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
describe('InferenceOptions — label upload', () => {
|
||||
it('uploads the picked file as multipart to the options endpoint', async () => {
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
uploadLabelFile(labelFile());
|
||||
|
||||
await waitFor(() => expect(api.postForm).toHaveBeenCalled());
|
||||
const [path, form] = vi.mocked(api.postForm).mock.calls[0];
|
||||
expect(path).toBe(OPTIONS_PATH);
|
||||
expect(form).toBeInstanceOf(FormData);
|
||||
expect((form.get('labelFile') as File).name).toBe('labels.txt');
|
||||
});
|
||||
|
||||
it('shows the applied labels returned by the server', async () => {
|
||||
vi.mocked(api.postForm).mockResolvedValue({
|
||||
success: true,
|
||||
data: { labelCount: 3, labels: ['剪刀', '石頭', '布'] },
|
||||
});
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
uploadLabelFile(labelFile());
|
||||
|
||||
await waitFor(() => {
|
||||
const status = screen.getByTestId('inference-label-status');
|
||||
expect(status.textContent).toContain('3');
|
||||
expect(status.textContent).toContain('剪刀');
|
||||
});
|
||||
});
|
||||
|
||||
it('says no labels are set before anything is uploaded', () => {
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
expect(screen.getByTestId('inference-label-status').textContent).toContain(
|
||||
'尚未上傳標籤檔',
|
||||
);
|
||||
});
|
||||
|
||||
it('offers a clear action only after labels are applied', async () => {
|
||||
vi.mocked(api.postForm).mockResolvedValue({
|
||||
success: true,
|
||||
data: { labels: ['剪刀', '石頭', '布'] },
|
||||
});
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
expect(screen.queryByTestId('inference-label-clear-btn')).toBeNull();
|
||||
|
||||
uploadLabelFile(labelFile());
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('inference-label-clear-btn')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('clears the mapping through the endpoint and drops back to raw indices', async () => {
|
||||
vi.mocked(api.postForm).mockResolvedValue({
|
||||
success: true,
|
||||
data: { labels: ['剪刀', '石頭', '布'] },
|
||||
});
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
uploadLabelFile(labelFile());
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('inference-label-clear-btn')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('inference-label-clear-btn'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('inference-label-status').textContent).toContain(
|
||||
'尚未上傳標籤檔',
|
||||
),
|
||||
);
|
||||
// Clearing goes over JSON with an explicit empty array — an omitted field
|
||||
// would leave the mapping in place on the device.
|
||||
expect(api.post).toHaveBeenCalledWith(OPTIONS_PATH, { labels: [] });
|
||||
});
|
||||
|
||||
it('lets the same file be re-picked after fixing it on disk', async () => {
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
uploadLabelFile(labelFile());
|
||||
await waitFor(() => expect(api.postForm).toHaveBeenCalledTimes(1));
|
||||
|
||||
// The input value is reset after each pick, so selecting the same filename
|
||||
// again still fires a change event.
|
||||
uploadLabelFile(labelFile());
|
||||
await waitFor(() => expect(api.postForm).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
|
||||
describe('InferenceOptions — label upload errors', () => {
|
||||
it('rejects a non-.txt file without hitting the network', async () => {
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
uploadLabelFile(labelFile('labels.json', '{}'));
|
||||
|
||||
expect(await screen.findByTestId('inference-options-error')).toHaveTextContent(
|
||||
'只接受 .txt 或 .names 檔',
|
||||
);
|
||||
expect(api.postForm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts a .names file', async () => {
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
uploadLabelFile(labelFile('coco.names'));
|
||||
|
||||
await waitFor(() => expect(api.postForm).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId('inference-options-error')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an oversized file without hitting the network', async () => {
|
||||
const huge = labelFile('labels.txt', 'x'.repeat(MAX_LABEL_FILE_BYTES + 1));
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
uploadLabelFile(huge);
|
||||
|
||||
expect(await screen.findByTestId('inference-options-error')).toHaveTextContent('過大');
|
||||
expect(api.postForm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the offending line number for a parse error', async () => {
|
||||
vi.mocked(api.postForm).mockResolvedValue(
|
||||
reply({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'LABEL_PARSE_ERROR',
|
||||
message: "index 必須為非負整數,收到 'abc'",
|
||||
line: 5,
|
||||
},
|
||||
}),
|
||||
);
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
uploadLabelFile(labelFile());
|
||||
|
||||
const err = await screen.findByTestId('inference-options-error');
|
||||
// The line number is the whole reason parsing lives server-side — losing it
|
||||
// would leave the user hunting through their file blind.
|
||||
expect(err.textContent).toContain('5');
|
||||
expect(err.textContent).toContain('非負整數');
|
||||
});
|
||||
|
||||
it('shows a generic failure message when the server sends no line number', async () => {
|
||||
vi.mocked(api.postForm).mockResolvedValue({
|
||||
success: false,
|
||||
error: { code: 'STORAGE_ERROR', message: 'could not write labels' },
|
||||
});
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
uploadLabelFile(labelFile());
|
||||
|
||||
const err = await screen.findByTestId('inference-options-error');
|
||||
expect(err.textContent).toContain('could not write labels');
|
||||
});
|
||||
|
||||
it('keeps the previous mapping when a replacement upload fails', async () => {
|
||||
vi.mocked(api.postForm).mockResolvedValue({
|
||||
success: true,
|
||||
data: { labels: ['剪刀', '石頭', '布'] },
|
||||
});
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
uploadLabelFile(labelFile());
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('inference-label-status').textContent).toContain('剪刀'),
|
||||
);
|
||||
|
||||
vi.mocked(api.postForm).mockResolvedValue(
|
||||
reply({
|
||||
success: false,
|
||||
error: { code: 'LABEL_PARSE_ERROR', message: 'bad', line: 2 },
|
||||
}),
|
||||
);
|
||||
uploadLabelFile(labelFile('bad.txt', 'garbage'));
|
||||
|
||||
await screen.findByTestId('inference-options-error');
|
||||
// A failed replacement must not silently wipe the mapping that is still in
|
||||
// effect on the device.
|
||||
expect(screen.getByTestId('inference-label-status').textContent).toContain('剪刀');
|
||||
});
|
||||
|
||||
it('clears a stale error once a later upload succeeds', async () => {
|
||||
vi.mocked(api.postForm).mockResolvedValue(
|
||||
reply({
|
||||
success: false,
|
||||
error: { code: 'LABEL_PARSE_ERROR', message: 'bad', line: 2 },
|
||||
}),
|
||||
);
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
uploadLabelFile(labelFile('bad.txt'));
|
||||
await screen.findByTestId('inference-options-error');
|
||||
|
||||
vi.mocked(api.postForm).mockResolvedValue({
|
||||
success: true,
|
||||
data: { labels: ['剪刀'] },
|
||||
});
|
||||
uploadLabelFile(labelFile());
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId('inference-options-error')).toBeNull());
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,109 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { InferenceOverlay } from '@/components/camera/inference-overlay';
|
||||
import type { InferenceResult } from '@/types/inference';
|
||||
|
||||
function makeResult(overrides: Partial<InferenceResult> = {}): InferenceResult {
|
||||
return {
|
||||
deviceId: 'dev-1',
|
||||
modelId: 'model-1',
|
||||
taskType: 'object_detection',
|
||||
timestamp: 1784620457620,
|
||||
latencyMs: 12.5,
|
||||
detections: [],
|
||||
classifications: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const SIZE = { width: 640, height: 480, confidenceThreshold: 0.5 };
|
||||
|
||||
describe('InferenceOverlay — M3-a taskType dispatch', () => {
|
||||
it('renders the bbox canvas for object_detection', () => {
|
||||
render(<InferenceOverlay result={makeResult()} {...SIZE} />);
|
||||
expect(screen.getByTestId('camera-overlay')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('classification-overlay')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the bbox canvas for the legacy "detection" spelling (R-4)', () => {
|
||||
render(<InferenceOverlay result={makeResult({ taskType: 'detection' })} {...SIZE} />);
|
||||
expect(screen.getByTestId('camera-overlay')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the bbox canvas when taskType is unknown', () => {
|
||||
render(<InferenceOverlay result={makeResult({ taskType: 'something_new' })} {...SIZE} />);
|
||||
expect(screen.getByTestId('camera-overlay')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the bbox canvas when there is no result at all', () => {
|
||||
render(<InferenceOverlay result={null} {...SIZE} />);
|
||||
expect(screen.getByTestId('camera-overlay')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the classification chip — and NO bbox canvas — for classification', () => {
|
||||
render(
|
||||
<InferenceOverlay
|
||||
result={makeResult({
|
||||
taskType: 'classification',
|
||||
classifications: [
|
||||
{ label: '石頭', classIndex: 1, confidence: 0.9425 },
|
||||
{ label: '布', classIndex: 2, confidence: 0.0384 },
|
||||
],
|
||||
})}
|
||||
{...SIZE}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('classification-overlay')).toBeInTheDocument();
|
||||
// The whole point of the feature: classification must not draw boxes.
|
||||
expect(screen.queryByTestId('camera-overlay')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ClassificationOverlay rendering', () => {
|
||||
function renderClassification(classifications: InferenceResult['classifications'], threshold = 0.5) {
|
||||
return render(
|
||||
<InferenceOverlay
|
||||
result={makeResult({ taskType: 'classification', classifications })}
|
||||
{...SIZE}
|
||||
confidenceThreshold={threshold}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it('shows the top-1 label and confidence', () => {
|
||||
renderClassification([
|
||||
{ label: '布', classIndex: 2, confidence: 0.0384 },
|
||||
{ label: '石頭', classIndex: 1, confidence: 0.9425 },
|
||||
]);
|
||||
expect(screen.getByTestId('classification-overlay-label').textContent).toBe('石頭');
|
||||
expect(screen.getByTestId('classification-overlay-confidence').textContent).toBe('94.3%');
|
||||
});
|
||||
|
||||
it('falls back to class_<index> when the label is missing', () => {
|
||||
renderClassification([{ label: '', classIndex: 1, confidence: 0.88 }]);
|
||||
expect(screen.getByTestId('classification-overlay-label').textContent).toBe('class_1');
|
||||
});
|
||||
|
||||
it('renders without crashing when classIndex is absent (M2-d not shipped)', () => {
|
||||
renderClassification([{ label: 'class_1', confidence: 0.88 }]);
|
||||
expect(screen.getByTestId('classification-overlay-label').textContent).toBe('class_1');
|
||||
});
|
||||
|
||||
it('shows the unrecognized state when nothing clears the threshold', () => {
|
||||
renderClassification([{ label: '石頭', classIndex: 1, confidence: 0.2 }]);
|
||||
expect(screen.getByTestId('classification-overlay-empty')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('classification-overlay-label')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the unrecognized state when the classifications array is empty', () => {
|
||||
renderClassification([]);
|
||||
expect(screen.getByTestId('classification-overlay-empty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes the verdict to assistive tech via a polite live region', () => {
|
||||
renderClassification([{ label: '石頭', classIndex: 1, confidence: 0.9 }]);
|
||||
const status = screen.getByRole('status');
|
||||
expect(status.getAttribute('aria-live')).toBe('polite');
|
||||
expect(status.textContent).toContain('石頭');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { InferencePanel } from '@/components/inference/inference-panel';
|
||||
import { useInferenceStore } from '@/stores/inference-store';
|
||||
import { useCameraStore } from '@/stores/camera-store';
|
||||
import { useInferenceOptionsStore } from '@/stores/inference-options-store';
|
||||
import type { InferenceResult } from '@/types/inference';
|
||||
|
||||
const DEVICE_ID = 'dev-1';
|
||||
|
||||
function makeResult(overrides: Partial<InferenceResult> = {}): InferenceResult {
|
||||
return {
|
||||
deviceId: 'dev-1',
|
||||
modelId: 'model-1',
|
||||
taskType: 'object_detection',
|
||||
timestamp: 1784620457620,
|
||||
latencyMs: 12.5,
|
||||
detections: [],
|
||||
classifications: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setResult(result: InferenceResult | null) {
|
||||
useInferenceStore.setState({ result, confidenceThreshold: 0.5 });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useInferenceStore.setState({
|
||||
isRunning: false,
|
||||
result: null,
|
||||
results: [],
|
||||
fps: 0,
|
||||
avgLatency: 0,
|
||||
confidenceThreshold: 0.5,
|
||||
batchResults: {},
|
||||
});
|
||||
useCameraStore.setState({ sourceType: 'camera', batchSelectedIndex: 0, batchImages: [] });
|
||||
useInferenceOptionsStore.getState().reset();
|
||||
});
|
||||
|
||||
describe('InferencePanel — M3-b taskType card switching', () => {
|
||||
it('shows the detection list card for object_detection', () => {
|
||||
setResult(
|
||||
makeResult({
|
||||
detections: [
|
||||
{ label: 'person', confidence: 0.9, bbox: { x: 0, y: 0, width: 0.1, height: 0.1 } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
render(<InferencePanel deviceId={DEVICE_ID} />);
|
||||
expect(screen.getByTestId('detection-result-list')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('classification-result-chart')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the classification chart card for classification', () => {
|
||||
setResult(
|
||||
makeResult({
|
||||
taskType: 'classification',
|
||||
classifications: [{ label: '石頭', classIndex: 1, confidence: 0.94 }],
|
||||
}),
|
||||
);
|
||||
render(<InferencePanel deviceId={DEVICE_ID} />);
|
||||
expect(screen.getByTestId('classification-result-chart')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('detection-result-list')).toBeNull();
|
||||
});
|
||||
|
||||
it('defaults to the detection card when there is no result yet', () => {
|
||||
setResult(null);
|
||||
render(<InferencePanel deviceId={DEVICE_ID} />);
|
||||
// No result → detection branch renders its empty state, not the chart.
|
||||
expect(screen.queryByTestId('classification-result-chart')).toBeNull();
|
||||
});
|
||||
|
||||
it('defaults to the detection card for the legacy "detection" spelling (R-4)', () => {
|
||||
setResult(
|
||||
makeResult({
|
||||
taskType: 'detection',
|
||||
detections: [
|
||||
{ label: 'person', confidence: 0.9, bbox: { x: 0, y: 0, width: 0.1, height: 0.1 } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
render(<InferencePanel deviceId={DEVICE_ID} />);
|
||||
expect(screen.getByTestId('detection-result-list')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
179
local-tool/frontend/src/tests/lib/classification.test.ts
Normal file
179
local-tool/frontend/src/tests/lib/classification.test.ts
Normal file
@ -0,0 +1,179 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
isClassificationTask,
|
||||
classResultLabel,
|
||||
classResultKey,
|
||||
pickTopClass,
|
||||
nextHysteresisState,
|
||||
INITIAL_HYSTERESIS_STATE,
|
||||
DEFAULT_HYSTERESIS,
|
||||
type HysteresisOptions,
|
||||
type HysteresisState,
|
||||
} from '@/lib/classification';
|
||||
import type { ClassResult } from '@/types/inference';
|
||||
|
||||
const OPTS: HysteresisOptions = {
|
||||
...DEFAULT_HYSTERESIS,
|
||||
confidenceThreshold: 0.5,
|
||||
};
|
||||
|
||||
function cls(label: string, confidence: number, classIndex?: number): ClassResult {
|
||||
return classIndex === undefined ? { label, confidence } : { label, confidence, classIndex };
|
||||
}
|
||||
|
||||
/** Feed a sequence of frames through the state machine, return displayed labels. */
|
||||
function run(frames: ClassResult[][], options: HysteresisOptions = OPTS): (string | null)[] {
|
||||
let state: HysteresisState = INITIAL_HYSTERESIS_STATE;
|
||||
return frames.map((f) => {
|
||||
state = nextHysteresisState(state, f, options);
|
||||
return state.displayed ? classResultLabel(state.displayed) : null;
|
||||
});
|
||||
}
|
||||
|
||||
describe('isClassificationTask — R-4 defensive branching', () => {
|
||||
it('is true only for the exact classification value', () => {
|
||||
expect(isClassificationTask('classification')).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for both detection spellings, so detection stays the fall-through', () => {
|
||||
expect(isClassificationTask('detection')).toBe(false);
|
||||
expect(isClassificationTask('object_detection')).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for undefined/null/unknown values', () => {
|
||||
expect(isClassificationTask(undefined)).toBe(false);
|
||||
expect(isClassificationTask(null)).toBe(false);
|
||||
expect(isClassificationTask('segmentation')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classResultLabel — classIndex fallback (M2-d may be absent)', () => {
|
||||
it('uses the label when present', () => {
|
||||
expect(classResultLabel(cls('石頭', 0.9, 1))).toBe('石頭');
|
||||
});
|
||||
|
||||
it('falls back to class_<index> when the label is empty', () => {
|
||||
expect(classResultLabel(cls('', 0.9, 7))).toBe('class_7');
|
||||
});
|
||||
|
||||
it('falls back to class_<index> when the label is only whitespace', () => {
|
||||
expect(classResultLabel(cls(' ', 0.9, 0))).toBe('class_0');
|
||||
});
|
||||
|
||||
it('emits a placeholder when both label and classIndex are missing', () => {
|
||||
expect(classResultLabel(cls('', 0.9))).toBe('class_?');
|
||||
});
|
||||
|
||||
it('works when classIndex is undefined but the label exists (the common case)', () => {
|
||||
expect(classResultLabel(cls('cat', 0.9))).toBe('cat');
|
||||
});
|
||||
});
|
||||
|
||||
describe('classResultKey — identity used by hysteresis', () => {
|
||||
it('prefers classIndex so label churn does not reset the state machine', () => {
|
||||
expect(classResultKey(cls('石頭', 0.9, 1))).toBe('i:1');
|
||||
expect(classResultKey(cls('rock', 0.9, 1))).toBe('i:1');
|
||||
});
|
||||
|
||||
it('falls back to the label when classIndex is absent', () => {
|
||||
expect(classResultKey(cls('石頭', 0.9))).toBe('l:石頭');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickTopClass', () => {
|
||||
it('returns null for empty/undefined input', () => {
|
||||
expect(pickTopClass([])).toBeNull();
|
||||
expect(pickTopClass(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the highest-confidence entry regardless of array order', () => {
|
||||
const top = pickTopClass([cls('a', 0.1), cls('b', 0.8), cls('c', 0.3)]);
|
||||
expect(top?.label).toBe('b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextHysteresisState — R-9 anti-flicker', () => {
|
||||
it('shows the first above-threshold result immediately', () => {
|
||||
expect(run([[cls('石頭', 0.9)]])).toEqual(['石頭']);
|
||||
});
|
||||
|
||||
it('shows nothing when the top class is below the confidence threshold', () => {
|
||||
expect(run([[cls('石頭', 0.3)]])).toEqual([null]);
|
||||
});
|
||||
|
||||
it('clears the label when confidence drops below the threshold', () => {
|
||||
expect(run([[cls('石頭', 0.9)], [cls('石頭', 0.2)]])).toEqual(['石頭', null]);
|
||||
});
|
||||
|
||||
it('does NOT switch on a single dissenting frame (the core anti-flicker case)', () => {
|
||||
// 石頭 established, then one frame where 布 barely wins → must hold 石頭.
|
||||
const out = run([
|
||||
[cls('石頭', 0.6), cls('布', 0.4)],
|
||||
[cls('布', 0.62), cls('石頭', 0.58)],
|
||||
[cls('石頭', 0.6), cls('布', 0.4)],
|
||||
]);
|
||||
expect(out).toEqual(['石頭', '石頭', '石頭']);
|
||||
});
|
||||
|
||||
it('switches after streakFrames consecutive frames of the challenger', () => {
|
||||
// Margins stay under immediateMargin (0.15) so only the streak can promote.
|
||||
const out = run([
|
||||
[cls('石頭', 0.60)],
|
||||
[cls('布', 0.62)],
|
||||
[cls('布', 0.63)],
|
||||
[cls('布', 0.64)],
|
||||
]);
|
||||
// frame1 石頭; frames 2-3 still 石頭 (streak 1,2); frame 4 promotes 布 (streak 3).
|
||||
expect(out).toEqual(['石頭', '石頭', '石頭', '布']);
|
||||
});
|
||||
|
||||
it('switches immediately when the challenger wins by the confidence margin', () => {
|
||||
const out = run([
|
||||
[cls('石頭', 0.60)],
|
||||
[cls('布', 0.80)], // +0.20 >= immediateMargin 0.15
|
||||
]);
|
||||
expect(out).toEqual(['石頭', '布']);
|
||||
});
|
||||
|
||||
it('resets the streak when the challenger is interrupted', () => {
|
||||
const out = run([
|
||||
[cls('石頭', 0.60)],
|
||||
[cls('布', 0.62)], // streak 1
|
||||
[cls('石頭', 0.61)], // incumbent back → streak cleared
|
||||
[cls('布', 0.62)], // streak restarts at 1
|
||||
[cls('布', 0.62)], // streak 2
|
||||
]);
|
||||
expect(out).toEqual(['石頭', '石頭', '石頭', '石頭', '石頭']);
|
||||
});
|
||||
|
||||
it('keeps refreshing confidence of the incumbent while it stays on top', () => {
|
||||
let state: HysteresisState = INITIAL_HYSTERESIS_STATE;
|
||||
state = nextHysteresisState(state, [cls('石頭', 0.60)], OPTS);
|
||||
state = nextHysteresisState(state, [cls('石頭', 0.95)], OPTS);
|
||||
expect(state.displayed?.confidence).toBe(0.95);
|
||||
});
|
||||
|
||||
it('uses classIndex identity so a re-labelled same class is not treated as a switch', () => {
|
||||
let state: HysteresisState = INITIAL_HYSTERESIS_STATE;
|
||||
state = nextHysteresisState(state, [cls('石頭', 0.60, 1)], OPTS);
|
||||
// Same index, different label string → same class, no hysteresis delay.
|
||||
state = nextHysteresisState(state, [cls('rock', 0.61, 1)], OPTS);
|
||||
expect(state.displayed?.label).toBe('rock');
|
||||
expect(state.candidateStreak).toBe(0);
|
||||
});
|
||||
|
||||
it('clears state entirely on an empty frame', () => {
|
||||
let state: HysteresisState = INITIAL_HYSTERESIS_STATE;
|
||||
state = nextHysteresisState(state, [cls('石頭', 0.9)], OPTS);
|
||||
state = nextHysteresisState(state, [], OPTS);
|
||||
expect(state).toEqual(INITIAL_HYSTERESIS_STATE);
|
||||
});
|
||||
|
||||
it('honours a raised confidenceThreshold on the very next frame', () => {
|
||||
let state: HysteresisState = INITIAL_HYSTERESIS_STATE;
|
||||
state = nextHysteresisState(state, [cls('石頭', 0.6)], OPTS);
|
||||
expect(state.displayed).not.toBeNull();
|
||||
state = nextHysteresisState(state, [cls('石頭', 0.6)], { ...OPTS, confidenceThreshold: 0.8 });
|
||||
expect(state.displayed).toBeNull();
|
||||
});
|
||||
});
|
||||
@ -6,6 +6,31 @@ afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// jsdom does not implement ResizeObserver, which Radix primitives (Slider) and
|
||||
// CameraFeed rely on. A no-op stub is enough: tests assert on rendered output,
|
||||
// not on resize-driven behaviour.
|
||||
if (!('ResizeObserver' in globalThis)) {
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
globalThis.ResizeObserver = ResizeObserverStub;
|
||||
}
|
||||
|
||||
// jsdom implements neither the Pointer Capture API nor scrollIntoView, both of
|
||||
// which Radix Select calls while opening its dropdown. Without these stubs the
|
||||
// trigger throws `target.hasPointerCapture is not a function` and the listbox
|
||||
// never mounts, so any test that opens a <Select> is untestable.
|
||||
if (!Element.prototype.hasPointerCapture) {
|
||||
Element.prototype.hasPointerCapture = () => false;
|
||||
Element.prototype.setPointerCapture = () => {};
|
||||
Element.prototype.releasePointerCapture = () => {};
|
||||
}
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
|
||||
@ -0,0 +1,169 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
useInferenceOptionsStore,
|
||||
validateLabelFile,
|
||||
MAX_LABEL_FILE_BYTES,
|
||||
type InferenceOptionsResponse,
|
||||
type InferenceOptionsError,
|
||||
} from '@/stores/inference-options-store';
|
||||
import { api, type ApiResponse } from '@/lib/api';
|
||||
|
||||
/**
|
||||
* The store widens the error type so a parse error keeps its `line`. `vi.mocked`
|
||||
* resolves the generic to the default `ApiError`, so mock responses go through
|
||||
* this helper to stay in the shape the store actually receives.
|
||||
*/
|
||||
type OptionsResponse = ApiResponse<InferenceOptionsResponse, InferenceOptionsError>;
|
||||
const reply = (r: OptionsResponse) => r as ApiResponse<InferenceOptionsResponse>;
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: {
|
||||
post: vi.fn(),
|
||||
postForm: vi.fn(),
|
||||
},
|
||||
getRelayHeaders: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
const DEVICE_ID = 'dev-1';
|
||||
const OPTIONS_PATH = `/devices/${DEVICE_ID}/inference/options`;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(api.post).mockResolvedValue({ success: true, data: {} });
|
||||
vi.mocked(api.postForm).mockResolvedValue({ success: true, data: {} });
|
||||
useInferenceOptionsStore.getState().reset();
|
||||
});
|
||||
|
||||
function file(name = 'labels.txt', content = '0 a\n') {
|
||||
return new File([content], name, { type: 'text/plain' });
|
||||
}
|
||||
|
||||
describe('inference options store — request shapes', () => {
|
||||
it('POSTs JSON { taskType } to the device options path', async () => {
|
||||
await useInferenceOptionsStore.getState().setTaskType(DEVICE_ID, 'classification');
|
||||
expect(api.post).toHaveBeenCalledWith(OPTIONS_PATH, { taskType: 'classification' });
|
||||
});
|
||||
|
||||
it('POSTs the label file as multipart under the "labelFile" field', async () => {
|
||||
await useInferenceOptionsStore.getState().uploadLabels(DEVICE_ID, file());
|
||||
const [path, form] = vi.mocked(api.postForm).mock.calls[0];
|
||||
expect(path).toBe(OPTIONS_PATH);
|
||||
// The server reads `labelFile`; anything else is silently ignored and the
|
||||
// request then fails the "neither taskType nor labels" guard.
|
||||
expect((form.get('labelFile') as File).name).toBe('labels.txt');
|
||||
});
|
||||
|
||||
it('clears via an explicit empty labels array, not multipart', async () => {
|
||||
await useInferenceOptionsStore.getState().clearLabels(DEVICE_ID);
|
||||
// Omitting `labels` means "leave untouched" server-side, so the empty array
|
||||
// has to be on the wire for the mapping to actually be dropped.
|
||||
expect(api.post).toHaveBeenCalledWith(OPTIONS_PATH, { labels: [] });
|
||||
expect(api.postForm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('inference options store — state transitions', () => {
|
||||
it('returns true and records the task type on success', async () => {
|
||||
const ok = await useInferenceOptionsStore
|
||||
.getState()
|
||||
.setTaskType(DEVICE_ID, 'classification');
|
||||
expect(ok).toBe(true);
|
||||
expect(useInferenceOptionsStore.getState().taskType).toBe('classification');
|
||||
});
|
||||
|
||||
it('returns false and leaves the task type untouched on failure', async () => {
|
||||
vi.mocked(api.post).mockResolvedValue({
|
||||
success: false,
|
||||
error: { code: 'DEVICE_NOT_CONNECTED', message: 'nope' },
|
||||
});
|
||||
const ok = await useInferenceOptionsStore
|
||||
.getState()
|
||||
.setTaskType(DEVICE_ID, 'classification');
|
||||
|
||||
expect(ok).toBe(false);
|
||||
// A rejected switch must not leave the UI claiming the new mode is active.
|
||||
expect(useInferenceOptionsStore.getState().taskType).toBeNull();
|
||||
expect(useInferenceOptionsStore.getState().error?.code).toBe('DEVICE_NOT_CONNECTED');
|
||||
});
|
||||
|
||||
it('preserves the parse error line number', async () => {
|
||||
vi.mocked(api.postForm).mockResolvedValue(
|
||||
reply({
|
||||
success: false,
|
||||
error: { code: 'LABEL_PARSE_ERROR', message: 'bad index', line: 7 },
|
||||
}),
|
||||
);
|
||||
await useInferenceOptionsStore.getState().uploadLabels(DEVICE_ID, file());
|
||||
expect(useInferenceOptionsStore.getState().error?.line).toBe(7);
|
||||
});
|
||||
|
||||
it('keeps existing labels when a replacement upload fails', async () => {
|
||||
vi.mocked(api.postForm).mockResolvedValue({ success: true, data: { labels: ['a', 'b'] } });
|
||||
await useInferenceOptionsStore.getState().uploadLabels(DEVICE_ID, file());
|
||||
|
||||
vi.mocked(api.postForm).mockResolvedValue(
|
||||
reply({
|
||||
success: false,
|
||||
error: { code: 'LABEL_PARSE_ERROR', message: 'bad', line: 1 },
|
||||
}),
|
||||
);
|
||||
await useInferenceOptionsStore.getState().uploadLabels(DEVICE_ID, file());
|
||||
|
||||
expect(useInferenceOptionsStore.getState().labels).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('turns a thrown network error into an error envelope rather than rejecting', async () => {
|
||||
vi.mocked(api.post).mockRejectedValue(new Error('offline'));
|
||||
const ok = await useInferenceOptionsStore
|
||||
.getState()
|
||||
.setTaskType(DEVICE_ID, 'classification');
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(useInferenceOptionsStore.getState().error?.code).toBe('NETWORK_ERROR');
|
||||
expect(useInferenceOptionsStore.getState().applying).toBe(false);
|
||||
});
|
||||
|
||||
it('drops both the task type and labels on reset', async () => {
|
||||
vi.mocked(api.postForm).mockResolvedValue({ success: true, data: { labels: ['a'] } });
|
||||
await useInferenceOptionsStore.getState().setTaskType(DEVICE_ID, 'classification');
|
||||
await useInferenceOptionsStore.getState().uploadLabels(DEVICE_ID, file());
|
||||
|
||||
useInferenceOptionsStore.getState().reset();
|
||||
|
||||
const s = useInferenceOptionsStore.getState();
|
||||
expect(s.taskType).toBeNull();
|
||||
expect(s.labels).toBeNull();
|
||||
expect(s.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateLabelFile', () => {
|
||||
it('accepts .txt', () => {
|
||||
expect(validateLabelFile(file('labels.txt'))).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('accepts .names', () => {
|
||||
expect(validateLabelFile(file('coco.names'))).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('accepts an uppercase extension', () => {
|
||||
expect(validateLabelFile(file('LABELS.TXT'))).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('rejects another extension', () => {
|
||||
expect(validateLabelFile(file('labels.json'))).toEqual({
|
||||
ok: false,
|
||||
reason: 'wrong-type',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a file over the size cap', () => {
|
||||
const huge = file('labels.txt', 'x'.repeat(MAX_LABEL_FILE_BYTES + 1));
|
||||
expect(validateLabelFile(huge)).toEqual({ ok: false, reason: 'too-large' });
|
||||
});
|
||||
|
||||
it('accepts a file exactly at the cap', () => {
|
||||
const exact = file('labels.txt', 'x'.repeat(MAX_LABEL_FILE_BYTES));
|
||||
expect(validateLabelFile(exact)).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
@ -8,6 +8,11 @@ export interface BBox {
|
||||
export interface ClassResult {
|
||||
label: string;
|
||||
confidence: number;
|
||||
/**
|
||||
* Raw class index from the model output. Optional — the Go layer may not
|
||||
* forward it (M2-d is optional), so consumers must fall back to `label`.
|
||||
*/
|
||||
classIndex?: number;
|
||||
}
|
||||
|
||||
export interface DetectionResult {
|
||||
|
||||
@ -104,6 +104,108 @@ function Convert-ToMsysPath($winPath) {
|
||||
}
|
||||
$msysPython = Convert-ToMsysPath $realPython
|
||||
|
||||
# ── 偵測 Go / wails / node / pnpm 的實際安裝目錄 ────────────────────
|
||||
# 為什麼需要這段:
|
||||
# $env:MSYS2_PATH_TYPE = 'inherit' 只影響 MSYS2 自己的 shell 啟動器
|
||||
# (msys2.exe / mingw64.exe),直接呼叫 bash.exe -l 時 /etc/profile 會
|
||||
# 重建 PATH,Windows 的 PATH 不會被完整繼承 → bash 找不到 go / wails,
|
||||
# 出現 "/bin/bash: 列 1: go: command not found"。
|
||||
# 所以必須跟 Inno Setup 一樣,明確把工具目錄轉成 MSYS2 路徑後 export。
|
||||
#
|
||||
# 偵測策略(都不寫死路徑,允許裝在任意磁碟 / 自訂目錄):
|
||||
# 1. 先用 Get-Command 問 PowerShell 目前 PATH(上面已重載 Machine+User PATH)
|
||||
# 2. 找不到再退回常見安裝位置(winget / 官方 installer 的預設路徑)
|
||||
function Find-ToolDir {
|
||||
param(
|
||||
[string] $Name, # 執行檔名(不含 .exe)
|
||||
[string[]] $Candidates # fallback 的完整 exe 路徑清單
|
||||
)
|
||||
$cmd = Get-Command $Name -ErrorAction SilentlyContinue
|
||||
if ($cmd -and $cmd.Source) {
|
||||
return (Split-Path $cmd.Source -Parent)
|
||||
}
|
||||
foreach ($p in $Candidates) {
|
||||
if ($p -and (Test-Path $p)) { return (Split-Path $p -Parent) }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# Go:winget 預設 C:\Program Files\Go\bin,但可能被裝到其他磁碟
|
||||
$goDir = Find-ToolDir -Name 'go' -Candidates @(
|
||||
"$env:ProgramFiles\Go\bin\go.exe",
|
||||
"${env:ProgramFiles(x86)}\Go\bin\go.exe",
|
||||
"$env:LOCALAPPDATA\Programs\Go\bin\go.exe",
|
||||
"C:\Go\bin\go.exe"
|
||||
)
|
||||
if (-not $goDir) {
|
||||
Fail @'
|
||||
找不到 go.exe。請確認 Go 已安裝(winget install -e --id GoLang.Go),
|
||||
然後「重開一個新的系統管理員 PowerShell」再跑一次此腳本(PATH 需要重新載入)。
|
||||
'@
|
||||
}
|
||||
Log "偵測到 Go: $goDir"
|
||||
|
||||
# wails:go install 會放到 $(go env GOPATH)\bin,優先問 go 自己而不是猜 $HOME\go
|
||||
# 用上面偵測到的 $goDir\go.exe 絕對路徑呼叫,不依賴 go 是否在 PowerShell PATH 上
|
||||
$goPath = $null
|
||||
$goExe = Join-Path $goDir 'go.exe'
|
||||
if (Test-Path $goExe) {
|
||||
try {
|
||||
$goPath = (& $goExe env GOPATH 2>$null | Select-Object -First 1)
|
||||
} catch {
|
||||
$goPath = $null
|
||||
}
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($goPath)) { $goPath = "$env:USERPROFILE\go" }
|
||||
$wailsDir = Find-ToolDir -Name 'wails' -Candidates @(
|
||||
(Join-Path $goPath 'bin\wails.exe'),
|
||||
"$env:USERPROFILE\go\bin\wails.exe"
|
||||
)
|
||||
if (-not $wailsDir) {
|
||||
Fail @"
|
||||
找不到 wails.exe(預期在 $goPath\bin)。
|
||||
請先執行:go install github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||
"@
|
||||
}
|
||||
Log "偵測到 wails: $wailsDir"
|
||||
|
||||
# node:Makefile 的 pnpm build 需要 node 在 PATH 上
|
||||
$nodeDir = Find-ToolDir -Name 'node' -Candidates @(
|
||||
"$env:ProgramFiles\nodejs\node.exe",
|
||||
"${env:ProgramFiles(x86)}\nodejs\node.exe",
|
||||
"$env:LOCALAPPDATA\Programs\nodejs\node.exe"
|
||||
)
|
||||
if (-not $nodeDir) {
|
||||
Fail @'
|
||||
找不到 node.exe。請確認 Node.js 已安裝(winget install -e --id OpenJS.NodeJS.LTS),
|
||||
然後重開 PowerShell 再試一次。
|
||||
'@
|
||||
}
|
||||
Log "偵測到 node: $nodeDir"
|
||||
|
||||
# pnpm:npm i -g 會裝到 %APPDATA%\npm(與 node 目錄不同,必須另外加)
|
||||
$pnpmDir = Find-ToolDir -Name 'pnpm' -Candidates @(
|
||||
"$env:APPDATA\npm\pnpm.cmd",
|
||||
"$env:APPDATA\npm\pnpm",
|
||||
"$env:ProgramFiles\nodejs\pnpm.cmd"
|
||||
)
|
||||
if (-not $pnpmDir) {
|
||||
Fail @'
|
||||
找不到 pnpm。請執行:npm i -g pnpm
|
||||
然後重開 PowerShell 再試一次。
|
||||
'@
|
||||
}
|
||||
Log "偵測到 pnpm: $pnpmDir"
|
||||
|
||||
# 轉成 MSYS2 路徑,去重後合併成「單一」export(避免多行 export 互相覆蓋,
|
||||
# 也避免同一目錄重複出現在 PATH 上,例如 pnpm 與 node 同目錄的情況)
|
||||
$toolDirs = @($goDir, $wailsDir, $nodeDir, $pnpmDir) |
|
||||
Where-Object { $_ } |
|
||||
Select-Object -Unique
|
||||
# 用 @() 強制成陣列:只有一個目錄時 pipeline 會退化成單一 string,
|
||||
# 後面的 .Count / -join 行為才不會有歧義
|
||||
$msysToolDirs = @($toolDirs | ForEach-Object { Convert-ToMsysPath $_ })
|
||||
|
||||
# 找 Inno Setup Compiler (ISCC.exe)
|
||||
# 重要:檔名是 ISCC.exe(大寫),winget 裝在 Program Files (x86) 不在 PATH 裡
|
||||
function Find-Iscc {
|
||||
@ -178,6 +280,16 @@ $bashParts = @(
|
||||
"cd '$msysPath'",
|
||||
"export VISIONA_PYTHON='$msysPython'"
|
||||
)
|
||||
# 把 go / wails / node / pnpm 的目錄加進 bash 的 PATH。
|
||||
# 用一行 export 塞完所有目錄,前面補在 $PATH 之前(優先於 MSYS2 內建工具)。
|
||||
# 每個目錄各自用雙引號包住,路徑含空白(如 "Program Files")與括號
|
||||
# (如 "Program Files (x86)")才不會被 bash 拆字或當成子 shell 語法。
|
||||
# 註:export 是 assignment context,等號右邊不做 word splitting,
|
||||
# 所以結尾的 :$PATH 不加引號也安全(與既有 Inno Setup 那行同慣例)。
|
||||
if ($msysToolDirs -and $msysToolDirs.Count -gt 0) {
|
||||
$quotedDirs = ($msysToolDirs | ForEach-Object { "`"$_`"" }) -join ':'
|
||||
$bashParts += "export PATH=$quotedDirs`:`$PATH"
|
||||
}
|
||||
if ($msysIsccDir) {
|
||||
$bashParts += "export PATH=`"$msysIsccDir`":`$PATH"
|
||||
}
|
||||
|
||||
@ -40,8 +40,8 @@
|
||||
},
|
||||
{
|
||||
"id": "kl520-fcos-detection",
|
||||
"name": "FCOS Detection (KL520)",
|
||||
"description": "FCOS (Fully Convolutional One-Stage) object detection with DarkNet53s backbone, compiled for KL520. Anchor-free detection at 512x512.",
|
||||
"name": "物件辨識",
|
||||
"description": "通用物件偵測模型,可辨識人、車輛、動物等常見物件,適合一般場景的多物件偵測。",
|
||||
"thumbnail": "/images/models/fcos-det.png",
|
||||
"taskType": "object_detection",
|
||||
"categories": [
|
||||
@ -109,8 +109,8 @@
|
||||
},
|
||||
{
|
||||
"id": "kl520-tiny-yolov3",
|
||||
"name": "Tiny YOLOv3 (KL520)",
|
||||
"description": "Tiny YOLOv3 object detection model compiled for KL520. Compact and fast model for general-purpose multi-object detection on edge devices.",
|
||||
"name": "人型監測",
|
||||
"description": "輕量快速的人員偵測模型,適合即時監控場景,可在邊緣裝置上高速偵測畫面中的人員。",
|
||||
"thumbnail": "/images/models/tiny-yolov3.png",
|
||||
"taskType": "object_detection",
|
||||
"categories": [
|
||||
|
||||
@ -2,9 +2,14 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"visiona-local/server/internal/api/ws"
|
||||
@ -12,6 +17,7 @@ import (
|
||||
"visiona-local/server/internal/driver"
|
||||
"visiona-local/server/internal/flash"
|
||||
"visiona-local/server/internal/inference"
|
||||
"visiona-local/server/internal/labelfile"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@ -175,6 +181,8 @@ func (h *DeviceHandler) DisconnectDevice(c *gin.Context) {
|
||||
|
||||
func (h *DeviceHandler) FlashDevice(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
// 燒錄只需要 modelId。推論種類一律用 models.json 宣告的值;要改解析方式
|
||||
// 走 POST /devices/:id/inference/options(推論期即時切換、不必重燒)。
|
||||
var req struct {
|
||||
ModelID string `json:"modelId"`
|
||||
}
|
||||
@ -207,6 +215,289 @@ func (h *DeviceHandler) FlashDevice(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"success": true, "data": gin.H{"taskId": taskID}})
|
||||
}
|
||||
|
||||
// InferenceOptionsDriver 是「支援推論期切換解析方式」的 driver 能力介面。
|
||||
//
|
||||
// 為什麼不直接加進 driver.DeviceDriver:那是所有 driver 都必須實作的最小
|
||||
// 契約,加一個 Kneron 特有能力進去,三個既有 test fake 全都要跟著改,而它們
|
||||
// 跟這個功能完全無關。用窄介面 + type assert 是這個 repo 已建立的做法
|
||||
// (見 firmware.UpgradeDriver / DeviceManagerAdapter.GetUpgradeDriver)。
|
||||
type InferenceOptionsDriver interface {
|
||||
SetInferenceOptions(opts driver.InferenceOptions) error
|
||||
}
|
||||
|
||||
// inferenceOptionsRequest 是 JSON 形式的 request body。
|
||||
//
|
||||
// 兩個欄位都是指標,因為必須區分「沒帶這個欄位」與「帶了空值」:
|
||||
//
|
||||
// Labels == nil → 不動 label 表
|
||||
// Labels == &[]string{} → 清空 label 表(回到原始 enum)
|
||||
//
|
||||
// 用非指標 []string 的話 JSON 的 `null`、`[]` 與「欄位不存在」會全部塌成
|
||||
// nil,「清空」這個合法意圖就永遠表達不出來。
|
||||
type inferenceOptionsRequest struct {
|
||||
TaskType *string `json:"taskType"`
|
||||
Labels *[]string `json:"labels"`
|
||||
}
|
||||
|
||||
// SetInferenceOptions 在不重新燒錄的前提下,更新當前已載入模型的解析方式
|
||||
// 與 label 表。
|
||||
//
|
||||
// POST /api/devices/:id/inference/options
|
||||
//
|
||||
// 支援兩種 content type:
|
||||
//
|
||||
// application/json — {"taskType": "...", "labels": [...]}
|
||||
// multipart/form-data — taskType 欄位 + labelFile 檔案(`<index> <名稱>`)
|
||||
//
|
||||
// 刻意不做任何持久化:使用者明確要求「不用記,每次現場傳」。設定只存在於
|
||||
// 當前 bridge session,disconnect / reset / 重新 flash 都會清掉。
|
||||
func (h *DeviceHandler) SetInferenceOptions(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
session, err := h.deviceMgr.GetDevice(id)
|
||||
if err != nil {
|
||||
c.JSON(404, gin.H{
|
||||
"success": false,
|
||||
"error": gin.H{"code": "DEVICE_NOT_FOUND", "message": err.Error()},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
optsDrv, ok := session.Driver.(InferenceOptionsDriver)
|
||||
if !ok {
|
||||
c.JSON(400, gin.H{
|
||||
"success": false,
|
||||
"error": gin.H{
|
||||
"code": "UNSUPPORTED_DEVICE",
|
||||
"message": "this device driver does not support runtime inference options",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
opts, labelInfo, apiErr := parseInferenceOptionsRequest(c)
|
||||
if apiErr != nil {
|
||||
c.JSON(apiErr.status, gin.H{"success": false, "error": apiErr.body()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := optsDrv.SetInferenceOptions(opts); err != nil {
|
||||
c.JSON(400, gin.H{
|
||||
"success": false,
|
||||
"error": gin.H{"code": "INFERENCE_OPTIONS_FAILED", "message": err.Error()},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
data := gin.H{
|
||||
"deviceId": id,
|
||||
"taskType": opts.TaskType,
|
||||
}
|
||||
if opts.Labels != nil {
|
||||
data["labelCount"] = labelInfo.namedCount
|
||||
data["labels"] = opts.Labels
|
||||
if len(opts.Labels) > 0 {
|
||||
data["maxIndex"] = len(opts.Labels) - 1
|
||||
}
|
||||
}
|
||||
c.JSON(200, gin.H{"success": true, "data": data})
|
||||
}
|
||||
|
||||
// apiError 讓 parse 階段能同時回「HTTP status + 錯誤碼 + 可選的行號」。
|
||||
type apiError struct {
|
||||
status int
|
||||
code string
|
||||
message string
|
||||
// line 為 label 檔解析失敗的行號;0 表示與行號無關、不放進回應。
|
||||
line int
|
||||
}
|
||||
|
||||
func (e *apiError) body() gin.H {
|
||||
h := gin.H{"code": e.code, "message": e.message}
|
||||
if e.line > 0 {
|
||||
h["line"] = e.line
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// labelSummary 帶回 handler 要回報給前端的 label 統計。
|
||||
type labelSummary struct {
|
||||
// namedCount 是實際有名稱的筆數(不含稀疏補洞的空字串)。
|
||||
namedCount int
|
||||
}
|
||||
|
||||
// parseInferenceOptionsRequest 從 JSON 或 multipart 取出設定並完整驗證。
|
||||
func parseInferenceOptionsRequest(c *gin.Context) (driver.InferenceOptions, labelSummary, *apiError) {
|
||||
var opts driver.InferenceOptions
|
||||
var summary labelSummary
|
||||
|
||||
contentType := c.ContentType()
|
||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||
var err *apiError
|
||||
opts, summary, err = parseMultipartInferenceOptions(c)
|
||||
if err != nil {
|
||||
return opts, summary, err
|
||||
}
|
||||
} else {
|
||||
var req inferenceOptionsRequest
|
||||
if bindErr := c.ShouldBindJSON(&req); bindErr != nil {
|
||||
return opts, summary, &apiError{
|
||||
status: 400,
|
||||
code: "BAD_REQUEST",
|
||||
message: "invalid JSON body: " + bindErr.Error(),
|
||||
}
|
||||
}
|
||||
if req.TaskType != nil {
|
||||
opts.TaskType = *req.TaskType
|
||||
}
|
||||
if req.Labels != nil {
|
||||
// 顯式給了 labels(含空陣列)→ 一律送出。空陣列 = 清空,
|
||||
// 必須與「沒帶欄位」區分開。
|
||||
labels := *req.Labels
|
||||
if labels == nil {
|
||||
labels = []string{}
|
||||
}
|
||||
if len(labels) > labelfile.MaxIndex+1 {
|
||||
return opts, summary, &apiError{
|
||||
status: 400,
|
||||
code: "LABEL_TOO_LARGE",
|
||||
message: fmt.Sprintf("labels 筆數 %d 超過上限 %d", len(labels), labelfile.MaxIndex+1),
|
||||
}
|
||||
}
|
||||
opts.Labels = labels
|
||||
summary.namedCount = countNamedLabels(labels)
|
||||
}
|
||||
}
|
||||
|
||||
// taskType 值域用 flash.IsValidTaskTypeOverride —— 這裡是目前唯一讓使用者
|
||||
// 指定解析方式的入口(燒錄時不再選,一律用 models.json 宣告值)。舊別名
|
||||
// detection 一樣拒絕 —— bridge 收得下,但不讓兩套命名同時出現在 wire 上(R-4)。
|
||||
if opts.TaskType != "" && !flash.IsValidTaskTypeOverride(opts.TaskType) {
|
||||
return opts, summary, &apiError{
|
||||
status: 400,
|
||||
code: "VALIDATION_ERROR",
|
||||
message: fmt.Sprintf("invalid taskType %q: must be %s or %s",
|
||||
opts.TaskType, flash.TaskTypeObjectDetection, flash.TaskTypeClassification),
|
||||
}
|
||||
}
|
||||
|
||||
// 兩者都沒帶 = 呼叫端沒表達任何意圖。回 200 等於假裝做了事,正是這個
|
||||
// 功能要防的靜默失敗,所以擋在這裡。
|
||||
if opts.TaskType == "" && opts.Labels == nil {
|
||||
return opts, summary, &apiError{
|
||||
status: 400,
|
||||
code: "BAD_REQUEST",
|
||||
message: "至少要提供 taskType 或 labels 其中一項",
|
||||
}
|
||||
}
|
||||
|
||||
return opts, summary, nil
|
||||
}
|
||||
|
||||
// parseMultipartInferenceOptions 處理 multipart 上傳(taskType 欄位 + labelFile 檔案)。
|
||||
func parseMultipartInferenceOptions(c *gin.Context) (driver.InferenceOptions, labelSummary, *apiError) {
|
||||
var opts driver.InferenceOptions
|
||||
var summary labelSummary
|
||||
|
||||
// 限制 multipart 在記憶體中的暫存量;超過的部分 gin 會落地成暫存檔,
|
||||
// 但真正的防線是下方的 header.Size 檢查。
|
||||
if err := c.Request.ParseMultipartForm(labelfile.MaxFileSize); err != nil {
|
||||
return opts, summary, &apiError{
|
||||
status: 400,
|
||||
code: "BAD_REQUEST",
|
||||
message: "invalid multipart form: " + err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
opts.TaskType = c.PostForm("taskType")
|
||||
|
||||
file, header, err := c.Request.FormFile("labelFile")
|
||||
if err != nil {
|
||||
// 沒有檔案是合法的(只切 taskType)。其他錯誤才算壞請求。
|
||||
if errors.Is(err, http.ErrMissingFile) {
|
||||
return opts, summary, nil
|
||||
}
|
||||
return opts, summary, &apiError{
|
||||
status: 400,
|
||||
code: "BAD_REQUEST",
|
||||
message: "failed to read labelFile: " + err.Error(),
|
||||
}
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 大小上限在讀取「之前」擋,不能讀完再判斷 —— 那時記憶體已經吃掉了。
|
||||
if header.Size > labelfile.MaxFileSize {
|
||||
return opts, summary, &apiError{
|
||||
status: 400,
|
||||
code: "LABEL_TOO_LARGE",
|
||||
message: fmt.Sprintf("標籤檔過大(%d bytes),上限為 %d bytes",
|
||||
header.Size, labelfile.MaxFileSize),
|
||||
}
|
||||
}
|
||||
|
||||
// 副檔名檢查純粹防呆(真正的防線是內容解析)。上傳檔名只用來看副檔名,
|
||||
// 不參與任何路徑組合 —— 本 endpoint 不落地存檔,沒有路徑穿越面。
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
if ext != "" && ext != ".txt" && ext != ".names" {
|
||||
return opts, summary, &apiError{
|
||||
status: 400,
|
||||
code: "BAD_REQUEST",
|
||||
message: "標籤檔僅支援 .txt / .names",
|
||||
}
|
||||
}
|
||||
|
||||
// LimitReader 是 header.Size 之外的第二道防線:Content-Length 可以造假,
|
||||
// 實際串流長度才是真的。多讀 1 byte 用來偵測「宣稱小、其實大」。
|
||||
data, readErr := io.ReadAll(io.LimitReader(file, labelfile.MaxFileSize+1))
|
||||
if readErr != nil {
|
||||
return opts, summary, &apiError{
|
||||
status: 400,
|
||||
code: "BAD_REQUEST",
|
||||
message: "failed to read labelFile: " + readErr.Error(),
|
||||
}
|
||||
}
|
||||
if len(data) > labelfile.MaxFileSize {
|
||||
return opts, summary, &apiError{
|
||||
status: 400,
|
||||
code: "LABEL_TOO_LARGE",
|
||||
message: fmt.Sprintf("標籤檔過大,上限為 %d bytes", labelfile.MaxFileSize),
|
||||
}
|
||||
}
|
||||
|
||||
result, parseErr := labelfile.Parse(data)
|
||||
if parseErr != nil {
|
||||
var pe *labelfile.ParseError
|
||||
if errors.As(parseErr, &pe) {
|
||||
return opts, summary, &apiError{
|
||||
status: 400,
|
||||
code: "LABEL_PARSE_ERROR",
|
||||
message: pe.Error(),
|
||||
line: pe.Line,
|
||||
}
|
||||
}
|
||||
return opts, summary, &apiError{
|
||||
status: 400,
|
||||
code: "LABEL_PARSE_ERROR",
|
||||
message: parseErr.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
opts.Labels = result.Labels
|
||||
summary.namedCount = result.LabelCount
|
||||
return opts, summary, nil
|
||||
}
|
||||
|
||||
// countNamedLabels 算出實際有名稱的筆數(稀疏補洞的空字串不計)。
|
||||
func countNamedLabels(labels []string) int {
|
||||
n := 0
|
||||
for _, l := range labels {
|
||||
if strings.TrimSpace(l) != "" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (h *DeviceHandler) StartInference(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
resultCh := make(chan *driver.InferenceResult, 10)
|
||||
|
||||
@ -0,0 +1,500 @@
|
||||
package handlers
|
||||
|
||||
// device_inference_options_test.go — M4:POST /api/devices/:id/inference/options
|
||||
// 的 HTTP 契約測試(推論期切換解析方式 / label 表)。
|
||||
//
|
||||
// 測試分兩層:
|
||||
//
|
||||
// 1. parseInferenceOptionsRequest — 這裡是本 endpoint 幾乎全部的邏輯
|
||||
// (JSON / multipart 解析、值域驗證、大小上限、nil vs 空陣列的語意)。
|
||||
// 它只依賴 *gin.Context,可以完整單元測試。
|
||||
// 2. SetInferenceOptions handler — 只驗它自己負責的分支:device 不存在、
|
||||
// driver 不支援、driver 回錯。
|
||||
//
|
||||
// ⚠️ 測試接縫限制(與 device_flash_tasktype_test.go 同一個既有問題):
|
||||
// DeviceHandler.deviceMgr 是具體的 *device.Manager,其 sessions map 未匯出、
|
||||
// 只能由真實硬體填入,跨 package 無法注入 fake session。因此「成功路徑打到
|
||||
// driver」這段沒有 handler 級測試 —— 由 buildSetInferenceOptionsCommand 的
|
||||
// 單元測試(driver/kneron)與實機驗收覆蓋。這是既有架構限制,不是本次引入。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"visiona-local/server/internal/device"
|
||||
"visiona-local/server/internal/driver"
|
||||
"visiona-local/server/internal/driver/kneron"
|
||||
"visiona-local/server/internal/labelfile"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
// errorCode 取出統一錯誤格式裡的 error.code。
|
||||
//
|
||||
// 原先住在 device_flash_tasktype_test.go;該檔隨「燒錄時選推論種類」功能一起
|
||||
// 移除後搬來這裡(本檔是目前唯一的使用者)。
|
||||
func errorCode(parsed map[string]interface{}) string {
|
||||
errObj, ok := parsed["error"].(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
code, _ := errObj["code"].(string)
|
||||
return code
|
||||
}
|
||||
|
||||
// runParse 以指定的 body / content-type 呼叫 parseInferenceOptionsRequest。
|
||||
func runParse(t *testing.T, contentType string, body []byte) (driver.InferenceOptions, labelSummary, *apiError) {
|
||||
t.Helper()
|
||||
|
||||
var (
|
||||
opts driver.InferenceOptions
|
||||
summary labelSummary
|
||||
apiErr *apiError
|
||||
)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/x", func(c *gin.Context) {
|
||||
opts, summary, apiErr = parseInferenceOptionsRequest(c)
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/x", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
router.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
return opts, summary, apiErr
|
||||
}
|
||||
|
||||
func parseJSON(t *testing.T, body string) (driver.InferenceOptions, labelSummary, *apiError) {
|
||||
t.Helper()
|
||||
return runParse(t, "application/json", []byte(body))
|
||||
}
|
||||
|
||||
// buildMultipart 組出 multipart body(labelFileName 為空表示不帶檔案)。
|
||||
func buildMultipart(t *testing.T, taskType, labelFileName, labelContent string) (string, []byte) {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
if taskType != "" {
|
||||
if err := w.WriteField("taskType", taskType); err != nil {
|
||||
t.Fatalf("WriteField: %v", err)
|
||||
}
|
||||
}
|
||||
if labelFileName != "" {
|
||||
fw, err := w.CreateFormFile("labelFile", labelFileName)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFormFile: %v", err)
|
||||
}
|
||||
if _, err := fw.Write([]byte(labelContent)); err != nil {
|
||||
t.Fatalf("write file part: %v", err)
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("close writer: %v", err)
|
||||
}
|
||||
return w.FormDataContentType(), buf.Bytes()
|
||||
}
|
||||
|
||||
func requireNoAPIError(t *testing.T, err *apiError) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected apiError: code=%s message=%s", err.code, err.message)
|
||||
}
|
||||
}
|
||||
|
||||
func requireAPIError(t *testing.T, err *apiError, wantCode string) *apiError {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected apiError with code %s, got nil", wantCode)
|
||||
}
|
||||
if err.code != wantCode {
|
||||
t.Fatalf("error code = %q, want %q (message=%q)", err.code, wantCode, err.message)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ── JSON body ────────────────────────────────────────────────────────
|
||||
|
||||
func TestParseInferenceOptions_JSON_TaskTypeOnly(t *testing.T) {
|
||||
opts, _, err := parseJSON(t, `{"taskType":"classification"}`)
|
||||
requireNoAPIError(t, err)
|
||||
|
||||
if opts.TaskType != "classification" {
|
||||
t.Errorf("TaskType = %q, want classification", opts.TaskType)
|
||||
}
|
||||
if opts.Labels != nil {
|
||||
t.Errorf("Labels = %v, want nil(沒帶 labels 就不該動 label 表)", opts.Labels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInferenceOptions_JSON_LabelsOnly(t *testing.T) {
|
||||
opts, summary, err := parseJSON(t, `{"labels":["剪刀","石頭","布"]}`)
|
||||
requireNoAPIError(t, err)
|
||||
|
||||
if opts.TaskType != "" {
|
||||
t.Errorf("TaskType = %q, want 空(沒帶就不該動解析方式)", opts.TaskType)
|
||||
}
|
||||
want := []string{"剪刀", "石頭", "布"}
|
||||
if !equalStringSlice(opts.Labels, want) {
|
||||
t.Errorf("Labels = %v, want %v", opts.Labels, want)
|
||||
}
|
||||
if summary.namedCount != 3 {
|
||||
t.Errorf("namedCount = %d, want 3", summary.namedCount)
|
||||
}
|
||||
}
|
||||
|
||||
// 關鍵語意:空陣列 = 清空 label 表,必須與「沒帶欄位」區分。
|
||||
func TestParseInferenceOptions_JSON_EmptyLabelsMeansClear(t *testing.T) {
|
||||
opts, summary, err := parseJSON(t, `{"labels":[]}`)
|
||||
requireNoAPIError(t, err)
|
||||
|
||||
if opts.Labels == nil {
|
||||
t.Fatal("Labels = nil —— 空陣列被塌成 nil,「清空 label 表」的意圖丟失了")
|
||||
}
|
||||
if len(opts.Labels) != 0 {
|
||||
t.Errorf("len(Labels) = %d, want 0", len(opts.Labels))
|
||||
}
|
||||
if summary.namedCount != 0 {
|
||||
t.Errorf("namedCount = %d, want 0", summary.namedCount)
|
||||
}
|
||||
}
|
||||
|
||||
// 對照組:沒帶 labels 欄位時 Labels 必須是 nil(= 不動)。
|
||||
func TestParseInferenceOptions_JSON_AbsentLabelsMeansUnchanged(t *testing.T) {
|
||||
opts, _, err := parseJSON(t, `{"taskType":"classification"}`)
|
||||
requireNoAPIError(t, err)
|
||||
|
||||
if opts.Labels != nil {
|
||||
t.Errorf("Labels = %v, want nil", opts.Labels)
|
||||
}
|
||||
}
|
||||
|
||||
// JSON null 與「沒帶」同義 —— 都是不動。
|
||||
func TestParseInferenceOptions_JSON_NullLabelsMeansUnchanged(t *testing.T) {
|
||||
opts, _, err := parseJSON(t, `{"taskType":"classification","labels":null}`)
|
||||
requireNoAPIError(t, err)
|
||||
|
||||
if opts.Labels != nil {
|
||||
t.Errorf("Labels = %v, want nil", opts.Labels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInferenceOptions_JSON_Both(t *testing.T) {
|
||||
opts, summary, err := parseJSON(t, `{"taskType":"classification","labels":["a","b"]}`)
|
||||
requireNoAPIError(t, err)
|
||||
|
||||
if opts.TaskType != "classification" {
|
||||
t.Errorf("TaskType = %q", opts.TaskType)
|
||||
}
|
||||
if summary.namedCount != 2 {
|
||||
t.Errorf("namedCount = %d, want 2", summary.namedCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInferenceOptions_JSON_SparseLabelsCountedCorrectly(t *testing.T) {
|
||||
opts, summary, err := parseJSON(t, `{"labels":["a","","c"]}`)
|
||||
requireNoAPIError(t, err)
|
||||
|
||||
if len(opts.Labels) != 3 {
|
||||
t.Errorf("len(Labels) = %d, want 3(稀疏佔位要保留,位置就是 class index)", len(opts.Labels))
|
||||
}
|
||||
if summary.namedCount != 2 {
|
||||
t.Errorf("namedCount = %d, want 2(空字串不算一筆)", summary.namedCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInferenceOptions_JSON_MalformedBody(t *testing.T) {
|
||||
_, _, err := parseJSON(t, `{not json`)
|
||||
requireAPIError(t, err, "BAD_REQUEST")
|
||||
}
|
||||
|
||||
// ── 值域驗證(沿用燒錄時同一套)──────────────────────────────────────
|
||||
|
||||
func TestParseInferenceOptions_RejectsInvalidTaskType(t *testing.T) {
|
||||
// R-4:舊別名 detection 也要擋 —— bridge 收得下,但不讓兩套命名同時
|
||||
// 出現在 wire 上。與 POST /flash 的規則保持完全一致。
|
||||
bad := []string{
|
||||
"detection",
|
||||
"segmentation",
|
||||
"pose_estimation",
|
||||
"Classification",
|
||||
"classifcation",
|
||||
"garbage",
|
||||
}
|
||||
for _, tt := range bad {
|
||||
t.Run(tt, func(t *testing.T) {
|
||||
body := fmt.Sprintf(`{"taskType":%q}`, tt)
|
||||
_, _, err := parseJSON(t, body)
|
||||
e := requireAPIError(t, err, "VALIDATION_ERROR")
|
||||
if e.status != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", e.status)
|
||||
}
|
||||
if !strings.Contains(e.message, "classification") ||
|
||||
!strings.Contains(e.message, "object_detection") {
|
||||
t.Errorf("message = %q, 應列出合法值", e.message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInferenceOptions_AcceptsValidTaskType(t *testing.T) {
|
||||
for _, tt := range []string{"classification", "object_detection"} {
|
||||
t.Run(tt, func(t *testing.T) {
|
||||
opts, _, err := parseJSON(t, fmt.Sprintf(`{"taskType":%q}`, tt))
|
||||
requireNoAPIError(t, err)
|
||||
if opts.TaskType != tt {
|
||||
t.Errorf("TaskType = %q, want %q", opts.TaskType, tt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 兩者都沒帶 = 呼叫端沒表達任何意圖。回 200 等於假裝做了事。
|
||||
func TestParseInferenceOptions_RejectsEmptyRequest(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"空物件": `{}`,
|
||||
"taskType 空字串": `{"taskType":""}`,
|
||||
"兩者皆 null": `{"taskType":null,"labels":null}`,
|
||||
}
|
||||
for name, body := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, _, err := parseJSON(t, body)
|
||||
requireAPIError(t, err, "BAD_REQUEST")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 只帶空 labels 陣列是有意義的(清空),不該被「空請求」規則誤擋。
|
||||
func TestParseInferenceOptions_EmptyLabelsAloneIsNotEmptyRequest(t *testing.T) {
|
||||
_, _, err := parseJSON(t, `{"labels":[]}`)
|
||||
requireNoAPIError(t, err)
|
||||
}
|
||||
|
||||
// ── JSON labels 數量上限(S-2 / R-7)─────────────────────────────────
|
||||
|
||||
func TestParseInferenceOptions_JSON_RejectsTooManyLabels(t *testing.T) {
|
||||
labels := make([]string, labelfile.MaxIndex+2)
|
||||
for i := range labels {
|
||||
labels[i] = "x"
|
||||
}
|
||||
payload, err := json.Marshal(map[string]interface{}{"labels": labels})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
|
||||
_, _, apiErr := runParse(t, "application/json", payload)
|
||||
requireAPIError(t, apiErr, "LABEL_TOO_LARGE")
|
||||
}
|
||||
|
||||
func TestParseInferenceOptions_JSON_AcceptsExactlyMaxLabels(t *testing.T) {
|
||||
labels := make([]string, labelfile.MaxIndex+1)
|
||||
for i := range labels {
|
||||
labels[i] = "x"
|
||||
}
|
||||
payload, err := json.Marshal(map[string]interface{}{"labels": labels})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
|
||||
_, _, apiErr := runParse(t, "application/json", payload)
|
||||
requireNoAPIError(t, apiErr)
|
||||
}
|
||||
|
||||
// ── multipart 上傳 ───────────────────────────────────────────────────
|
||||
|
||||
func TestParseInferenceOptions_Multipart_LabelFile(t *testing.T) {
|
||||
ct, body := buildMultipart(t, "classification", "labels.txt", "0 剪刀\n1 石頭\n2 布\n")
|
||||
opts, summary, err := runParse(t, ct, body)
|
||||
requireNoAPIError(t, err)
|
||||
|
||||
if opts.TaskType != "classification" {
|
||||
t.Errorf("TaskType = %q", opts.TaskType)
|
||||
}
|
||||
want := []string{"剪刀", "石頭", "布"}
|
||||
if !equalStringSlice(opts.Labels, want) {
|
||||
t.Errorf("Labels = %v, want %v", opts.Labels, want)
|
||||
}
|
||||
if summary.namedCount != 3 {
|
||||
t.Errorf("namedCount = %d, want 3", summary.namedCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInferenceOptions_Multipart_TaskTypeOnlyNoFile(t *testing.T) {
|
||||
ct, body := buildMultipart(t, "object_detection", "", "")
|
||||
opts, _, err := runParse(t, ct, body)
|
||||
requireNoAPIError(t, err)
|
||||
|
||||
if opts.TaskType != "object_detection" {
|
||||
t.Errorf("TaskType = %q", opts.TaskType)
|
||||
}
|
||||
if opts.Labels != nil {
|
||||
t.Errorf("Labels = %v, want nil(沒上傳檔案就不該動 label 表)", opts.Labels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInferenceOptions_Multipart_SparseLabelFile(t *testing.T) {
|
||||
ct, body := buildMultipart(t, "classification", "labels.txt", "0 a\n3 d\n")
|
||||
opts, summary, err := runParse(t, ct, body)
|
||||
requireNoAPIError(t, err)
|
||||
|
||||
want := []string{"a", "", "", "d"}
|
||||
if !equalStringSlice(opts.Labels, want) {
|
||||
t.Errorf("Labels = %v, want %v", opts.Labels, want)
|
||||
}
|
||||
if summary.namedCount != 2 {
|
||||
t.Errorf("namedCount = %d, want 2", summary.namedCount)
|
||||
}
|
||||
}
|
||||
|
||||
// 解析失敗要帶行號回去,前端才能指出是哪一行。
|
||||
func TestParseInferenceOptions_Multipart_ParseErrorCarriesLine(t *testing.T) {
|
||||
ct, body := buildMultipart(t, "", "labels.txt", "0 a\n1 b\nabc c\n")
|
||||
_, _, err := runParse(t, ct, body)
|
||||
e := requireAPIError(t, err, "LABEL_PARSE_ERROR")
|
||||
|
||||
if e.line != 3 {
|
||||
t.Errorf("line = %d, want 3", e.line)
|
||||
}
|
||||
if _, ok := e.body()["line"].(int); !ok {
|
||||
t.Error("回應 body 應包含 line 欄位")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInferenceOptions_Multipart_ParseErrorBodyOmitsLineWhenZero(t *testing.T) {
|
||||
ct, body := buildMultipart(t, "", "labels.txt", "\n\n\n")
|
||||
_, _, err := runParse(t, ct, body)
|
||||
e := requireAPIError(t, err, "LABEL_PARSE_ERROR")
|
||||
|
||||
if _, present := e.body()["line"]; present {
|
||||
t.Error("與行號無關的錯誤不應帶 line 欄位(前端會亂標第 0 行)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInferenceOptions_Multipart_RejectsOversizedFile(t *testing.T) {
|
||||
big := strings.Repeat("0 aaaaaaaa\n", labelfile.MaxFileSize/10+100)
|
||||
ct, body := buildMultipart(t, "", "labels.txt", big)
|
||||
_, _, err := runParse(t, ct, body)
|
||||
requireAPIError(t, err, "LABEL_TOO_LARGE")
|
||||
}
|
||||
|
||||
func TestParseInferenceOptions_Multipart_RejectsBadExtension(t *testing.T) {
|
||||
ct, body := buildMultipart(t, "", "labels.exe", "0 a\n")
|
||||
_, _, err := runParse(t, ct, body)
|
||||
requireAPIError(t, err, "BAD_REQUEST")
|
||||
}
|
||||
|
||||
func TestParseInferenceOptions_Multipart_AcceptsNamesExtension(t *testing.T) {
|
||||
ct, body := buildMultipart(t, "", "coco.names", "0 person\n")
|
||||
opts, _, err := runParse(t, ct, body)
|
||||
requireNoAPIError(t, err)
|
||||
|
||||
if !equalStringSlice(opts.Labels, []string{"person"}) {
|
||||
t.Errorf("Labels = %v", opts.Labels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInferenceOptions_Multipart_RejectsInvalidTaskType(t *testing.T) {
|
||||
ct, body := buildMultipart(t, "detection", "labels.txt", "0 a\n")
|
||||
_, _, err := runParse(t, ct, body)
|
||||
requireAPIError(t, err, "VALIDATION_ERROR")
|
||||
}
|
||||
|
||||
func TestParseInferenceOptions_Multipart_RejectsEmptyRequest(t *testing.T) {
|
||||
ct, body := buildMultipart(t, "", "", "")
|
||||
_, _, err := runParse(t, ct, body)
|
||||
requireAPIError(t, err, "BAD_REQUEST")
|
||||
}
|
||||
|
||||
// 上傳的 index 超過上限 → 由 labelfile 擋下並回 LABEL_PARSE_ERROR。
|
||||
// 若這道防線失效,handler 會嘗試配置巨大 slice。
|
||||
func TestParseInferenceOptions_Multipart_RejectsHugeIndex(t *testing.T) {
|
||||
ct, body := buildMultipart(t, "", "labels.txt", "999999999 boom\n")
|
||||
_, _, err := runParse(t, ct, body)
|
||||
requireAPIError(t, err, "LABEL_PARSE_ERROR")
|
||||
}
|
||||
|
||||
// ── handler 分支(不需要 device session 的部分)──────────────────────
|
||||
|
||||
// postOptions 呼叫 SetInferenceOptions 並回傳 status / 解析後 body。
|
||||
func postOptions(t *testing.T, h *DeviceHandler, contentType string, body []byte) (int, map[string]interface{}) {
|
||||
t.Helper()
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/devices/:id/inference/options", h.SetInferenceOptions)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/devices/dev-1/inference/options", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
parsed := map[string]interface{}{}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &parsed)
|
||||
return w.Code, parsed
|
||||
}
|
||||
|
||||
func TestSetInferenceOptions_DeviceNotFound(t *testing.T) {
|
||||
h := &DeviceHandler{deviceMgr: device.NewManager(device.NewRegistry(), "")}
|
||||
|
||||
status, parsed := postOptions(t, h, "application/json", []byte(`{"taskType":"classification"}`))
|
||||
|
||||
if status != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404", status)
|
||||
}
|
||||
if got := errorCode(parsed); got != "DEVICE_NOT_FOUND" {
|
||||
t.Errorf("error.code = %q, want DEVICE_NOT_FOUND", got)
|
||||
}
|
||||
}
|
||||
|
||||
// device 查找必須排在 body 解析之前 —— 對不存在的裝置回「body 有問題」
|
||||
// 會把使用者引去改 payload,而真正的問題是裝置不在。
|
||||
func TestSetInferenceOptions_DeviceLookupPrecedesBodyValidation(t *testing.T) {
|
||||
h := &DeviceHandler{deviceMgr: device.NewManager(device.NewRegistry(), "")}
|
||||
|
||||
status, parsed := postOptions(t, h, "application/json", []byte(`{not json`))
|
||||
|
||||
if status != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404(裝置查找應先於 body 解析)", status)
|
||||
}
|
||||
if got := errorCode(parsed); got != "DEVICE_NOT_FOUND" {
|
||||
t.Errorf("error.code = %q, want DEVICE_NOT_FOUND", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 介面契約 ─────────────────────────────────────────────────────────
|
||||
|
||||
// *kneron.KneronDriver 必須真的滿足 InferenceOptionsDriver。
|
||||
//
|
||||
// 這是整條鏈路唯一會「靜默壞掉」的接點:handler 用 type assert 取得能力,
|
||||
// 若 driver 的 method 簽章改了(或被誤刪),編譯完全不會報錯 —— endpoint
|
||||
// 會對所有裝置回 UNSUPPORTED_DEVICE,而且只有實機才看得出來。
|
||||
//
|
||||
// 刻意 assert 具體型別而非自己寫一個滿足介面的 stub:stub 只證明「我寫的
|
||||
// stub 符合我寫的介面」,對真正的實作零保障。
|
||||
var _ InferenceOptionsDriver = (*kneron.KneronDriver)(nil)
|
||||
|
||||
// KneronDriver 同時必須仍是合法的 driver.DeviceDriver —— 加新能力不能
|
||||
// 破壞既有契約。
|
||||
var _ driver.DeviceDriver = (*kneron.KneronDriver)(nil)
|
||||
|
||||
// ── helper ───────────────────────────────────────────────────────────
|
||||
|
||||
func equalStringSlice(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@ -125,7 +125,9 @@ func (f *fakeDriver) Info() driver.DeviceInfo { return f.in
|
||||
func (f *fakeDriver) Connect() error { return nil }
|
||||
func (f *fakeDriver) Disconnect() error { return nil }
|
||||
func (f *fakeDriver) IsConnected() bool { return false }
|
||||
func (f *fakeDriver) Flash(_ string, _ chan<- driver.FlashProgress) error { return nil }
|
||||
func (f *fakeDriver) Flash(_ string, _ driver.FlashOptions, _ chan<- driver.FlashProgress) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeDriver) StartInference() error { return nil }
|
||||
func (f *fakeDriver) StopInference() error { return nil }
|
||||
func (f *fakeDriver) ReadInference() (*driver.InferenceResult, error) {
|
||||
|
||||
@ -95,6 +95,9 @@ func NewRouter(
|
||||
api.POST("/devices/:id/flash", deviceHandler.FlashDevice)
|
||||
api.POST("/devices/:id/inference/start", deviceHandler.StartInference)
|
||||
api.POST("/devices/:id/inference/stop", deviceHandler.StopInference)
|
||||
// M4:推論期動態切換解析方式 / label 表(不重燒 model)。
|
||||
// 設定不持久化 —— 只作用於當前 bridge session。
|
||||
api.POST("/devices/:id/inference/options", deviceHandler.SetInferenceOptions)
|
||||
|
||||
// Firmware (M9-3、A 階段)
|
||||
// upgrade endpoint 走 202 + WebSocket room "firmware:<id>" 推進度。
|
||||
|
||||
@ -15,7 +15,9 @@ func (d *testDriver) Info() driver.DeviceInfo { r
|
||||
func (d *testDriver) Connect() error { d.connected = true; d.info.Status = driver.StatusConnected; return nil }
|
||||
func (d *testDriver) Disconnect() error { d.connected = false; d.info.Status = driver.StatusDisconnected; return nil }
|
||||
func (d *testDriver) IsConnected() bool { return d.connected }
|
||||
func (d *testDriver) Flash(_ string, _ chan<- driver.FlashProgress) error { return nil }
|
||||
func (d *testDriver) Flash(_ string, _ driver.FlashOptions, _ chan<- driver.FlashProgress) error {
|
||||
return nil
|
||||
}
|
||||
func (d *testDriver) StartInference() error { return nil }
|
||||
func (d *testDriver) StopInference() error { return nil }
|
||||
func (d *testDriver) ReadInference() (*driver.InferenceResult, error) { return nil, nil }
|
||||
|
||||
@ -7,7 +7,7 @@ type DeviceDriver interface {
|
||||
Connect() error
|
||||
Disconnect() error
|
||||
IsConnected() bool
|
||||
Flash(modelPath string, progressCh chan<- FlashProgress) error
|
||||
Flash(modelPath string, opts FlashOptions, progressCh chan<- FlashProgress) error
|
||||
StartInference() error
|
||||
StopInference() error
|
||||
ReadInference() (*InferenceResult, error)
|
||||
@ -39,6 +39,53 @@ const (
|
||||
StatusDisconnected DeviceStatus = "disconnected"
|
||||
)
|
||||
|
||||
// FlashOptions 帶入 model metadata,供 driver 在 load model 時傳給硬體 bridge。
|
||||
//
|
||||
// 為什麼用 struct 而不是多帶兩個參數:載入模型需要的 metadata 之後還會長
|
||||
// (如前處理色彩格式、top-K),用 struct 之後新增欄位不必再改 interface 簽章
|
||||
// 與所有 test fake。
|
||||
//
|
||||
// 兩個欄位都是 optional —— 空值代表「未指定」,bridge 端會 fallback 到既有的
|
||||
// model id / 檔名 heuristics(維持既有 detection 行為不變)。
|
||||
type FlashOptions struct {
|
||||
// TaskType 為 models.json 宣告的推論類型("classification" /
|
||||
// "object_detection")。bridge 端有指定就不再用檔名猜測。
|
||||
TaskType string
|
||||
// Labels 是 class index → 顯示名稱的對應表,純顯示層用途、非推論必要輸入。
|
||||
// 沒帶時 classification 輸出原始 enum(class_N)、detection 沿用 COCO。
|
||||
Labels []string
|
||||
// InputWidth / InputHeight 是 models.json / metadata.json 宣告的模型輸入
|
||||
// 尺寸。
|
||||
//
|
||||
// ⚠️ 這是**最後手段**,不是可信來源:bridge 端會優先向 SDK 問模型自己
|
||||
// 宣告的 input tensor shape,只有 SDK 沒回報時才用這組值。原因是這裡的
|
||||
// 數字是人在上傳表單填的,實際案例是使用者填了 640x640 但模型根本不是
|
||||
// 那個尺寸 —— 尺寸錯了 NPU 不會報錯,只會安靜地給出錯的推論結果。
|
||||
//
|
||||
// 零值 = 未宣告,bridge 端會忽略並往下 fallback。
|
||||
InputWidth int
|
||||
InputHeight int
|
||||
}
|
||||
|
||||
// InferenceOptions 是推論期可即時調整的解析設定。
|
||||
//
|
||||
// 與 FlashOptions 的分工:FlashOptions 在「把 model 載進裝置」時一次性帶入;
|
||||
// InferenceOptions 則是在**同一個已載入的 model 上**改變輸出的解讀方式,
|
||||
// 不需要重燒(KL520 重燒要數十秒)。
|
||||
//
|
||||
// 兩個欄位的零值語意刻意不同,因為要能表達「不動」與「清空」兩種意圖:
|
||||
//
|
||||
// TaskType == "" → 不改變當前解析方式
|
||||
// Labels == nil → 不改變當前 label 表
|
||||
// Labels == []string{} → 清空 label 表,回到原始 enum(class_N)
|
||||
//
|
||||
// ⚠️ 因此 Labels 的判斷必須用 `!= nil` 而非 `len() > 0` —— 用長度判斷會讓
|
||||
// 「清空」這個合法意圖永遠送不出去。
|
||||
type InferenceOptions struct {
|
||||
TaskType string
|
||||
Labels []string
|
||||
}
|
||||
|
||||
type FlashProgress struct {
|
||||
Percent int `json:"percent"`
|
||||
Stage string `json:"stage"`
|
||||
@ -68,6 +115,9 @@ type InferenceResult struct {
|
||||
type ClassResult struct {
|
||||
Label string `json:"label"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
// ClassIndex 是模型輸出的原始類別索引,供前端在 label 缺漏時 fallback 顯示。
|
||||
// 刻意不加 omitempty —— index 0 是合法類別,omitempty 會把它吃掉。
|
||||
ClassIndex int `json:"classIndex"`
|
||||
}
|
||||
|
||||
type DetectionResult struct {
|
||||
|
||||
@ -123,8 +123,26 @@ func (d *KneronDriver) startPython() error {
|
||||
// On macOS with Apple Silicon, Kneron SDK requires x86_64 (Rosetta 2).
|
||||
// The venv should already contain the correct architecture Python.
|
||||
// Set DYLD_LIBRARY_PATH so libkplus.dylib can be found.
|
||||
// PYTHONUTF8=1 啟用 Python UTF-8 Mode(PEP 540):讓子行程的 stdio 與
|
||||
// locale.getencoding() 一律視為 UTF-8,不理會系統 ANSI code page。
|
||||
//
|
||||
// 為什麼要在 Go 這端也設一次(bridge 內已有 _force_utf8_stdio()):
|
||||
// 兩者涵蓋範圍不同,是互補而非重複 ——
|
||||
//
|
||||
// 1. bridge 的 reconfigure() 只能在「直譯器已經啟動、開始執行程式碼」
|
||||
// 之後才生效。若 import 期間(例如 numpy / kp 載入失敗)就拋例外,
|
||||
// traceback 仍會用舊編碼寫到 stderr,中文路徑會變亂碼而難以診斷。
|
||||
// 2. UTF-8 Mode 一併改變 locale.getencoding(),因此連 open() 這類
|
||||
// 未顯式指定 encoding 的呼叫也會是 UTF-8 —— reconfigure() 對
|
||||
// sys.std* 以外的檔案物件無能為力。
|
||||
//
|
||||
// 背景:Windows 的 Python 預設把 stdio 綁到系統 ANSI code page,繁中
|
||||
// Windows 為 cp950。而 Go 的 encoding/json 不 escape 非 ASCII,
|
||||
// `{"labels":["布"]}` 在 wire 上是 raw UTF-8,以 cp950 解碼會變成
|
||||
// `撣<>`(U+FFFD),或在嚴格模式下直接讓 bridge 崩潰。
|
||||
cmd.Env = append(os.Environ(),
|
||||
"PYTHONUNBUFFERED=1",
|
||||
"PYTHONUTF8=1",
|
||||
)
|
||||
|
||||
// Add library path for native kp module if lib directory exists.
|
||||
@ -508,6 +526,37 @@ func (d *KneronDriver) restartBridge() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildLoadModelCommand 組出 load_model 的 JSON-RPC payload。
|
||||
//
|
||||
// ⚠️ Flash 有四處 load_model 呼叫點(初次 + 三條 retry 路徑)。四處都必須走這個
|
||||
// helper —— 如果任一處自己手寫 map,retry 成功後 task_type / labels 會遺失,
|
||||
// 而且不會報錯(bridge 會 fallback 到檔名猜測,classification model 被誤判成
|
||||
// YOLO 只會回空結果)。這種失敗完全靜默,所以刻意集中在單一建構點。
|
||||
//
|
||||
// 空值欄位不放進 payload:bridge 端把「缺欄位」與「空值」都當成未指定,
|
||||
// 但少送欄位可讓 bridge log 的 "(not specified)" 語意精確。
|
||||
func buildLoadModelCommand(modelPath string, opts driver.FlashOptions) map[string]interface{} {
|
||||
cmd := map[string]interface{}{
|
||||
"cmd": "load_model",
|
||||
"path": modelPath,
|
||||
}
|
||||
if opts.TaskType != "" {
|
||||
cmd["task_type"] = opts.TaskType
|
||||
}
|
||||
if len(opts.Labels) > 0 {
|
||||
cmd["labels"] = opts.Labels
|
||||
}
|
||||
// 兩軸都要有值才送:只有一軸的宣告無法描述一個輸入尺寸,送過去只會讓
|
||||
// bridge 端多做一次驗證再丟掉。
|
||||
if opts.InputWidth > 0 && opts.InputHeight > 0 {
|
||||
cmd["input_size"] = map[string]interface{}{
|
||||
"width": opts.InputWidth,
|
||||
"height": opts.InputHeight,
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// Flash loads a model onto the Kneron device. Progress is reported through
|
||||
// the provided channel.
|
||||
//
|
||||
@ -516,7 +565,10 @@ func (d *KneronDriver) restartBridge() error {
|
||||
// a full device reset + bridge restart + firmware reload.
|
||||
// - KL720 (flash-based): models can be freely reloaded. Error 40
|
||||
// should not occur; if it does, a simple retry is attempted first.
|
||||
func (d *KneronDriver) Flash(modelPath string, progressCh chan<- driver.FlashProgress) error {
|
||||
//
|
||||
// opts 帶著 models.json 宣告的 model metadata(taskType / labels),會隨每一次
|
||||
// load_model 送給 Python bridge —— 包含所有 retry 路徑。
|
||||
func (d *KneronDriver) Flash(modelPath string, opts driver.FlashOptions, progressCh chan<- driver.FlashProgress) error {
|
||||
d.mu.Lock()
|
||||
d.info.Status = driver.StatusFlashing
|
||||
pythonReady := d.pythonReady
|
||||
@ -554,10 +606,7 @@ func (d *KneronDriver) Flash(modelPath string, progressCh chan<- driver.FlashPro
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
_, err := d.sendCommand(map[string]interface{}{
|
||||
"cmd": "load_model",
|
||||
"path": modelPath,
|
||||
})
|
||||
_, err := d.sendCommand(buildLoadModelCommand(modelPath, opts))
|
||||
d.mu.Unlock()
|
||||
|
||||
// Handle retryable errors (error 40, broken pipe).
|
||||
@ -582,10 +631,7 @@ func (d *KneronDriver) Flash(modelPath string, progressCh chan<- driver.FlashPro
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
_, err = d.sendCommand(map[string]interface{}{
|
||||
"cmd": "load_model",
|
||||
"path": modelPath,
|
||||
})
|
||||
_, err = d.sendCommand(buildLoadModelCommand(modelPath, opts))
|
||||
d.mu.Unlock()
|
||||
|
||||
// If still failing, fall back to bridge restart as last resort.
|
||||
@ -599,10 +645,7 @@ func (d *KneronDriver) Flash(modelPath string, progressCh chan<- driver.FlashPro
|
||||
}
|
||||
d.mu.Lock()
|
||||
d.info.Status = driver.StatusFlashing
|
||||
_, err = d.sendCommand(map[string]interface{}{
|
||||
"cmd": "load_model",
|
||||
"path": modelPath,
|
||||
})
|
||||
_, err = d.sendCommand(buildLoadModelCommand(modelPath, opts))
|
||||
d.mu.Unlock()
|
||||
}
|
||||
} else {
|
||||
@ -626,10 +669,7 @@ func (d *KneronDriver) Flash(modelPath string, progressCh chan<- driver.FlashPro
|
||||
d.driverLog("INFO", "[kneron] bridge restarted, retrying load_model...")
|
||||
d.mu.Lock()
|
||||
d.info.Status = driver.StatusFlashing
|
||||
_, err = d.sendCommand(map[string]interface{}{
|
||||
"cmd": "load_model",
|
||||
"path": modelPath,
|
||||
})
|
||||
_, err = d.sendCommand(buildLoadModelCommand(modelPath, opts))
|
||||
d.mu.Unlock()
|
||||
}
|
||||
}
|
||||
@ -697,6 +737,58 @@ func (d *KneronDriver) Flash(modelPath string, progressCh chan<- driver.FlashPro
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildSetInferenceOptionsCommand 組出 set_inference_options 的 JSON-RPC payload。
|
||||
//
|
||||
// 與 buildLoadModelCommand 的關鍵差異:這裡用「欄位在不在」表達意圖,所以
|
||||
// **不能**沿用「空值就不放進 payload」的規則 ——
|
||||
//
|
||||
// opts.TaskType == "" → 不帶 task_type 欄位 → bridge 保留當前解析方式
|
||||
// opts.Labels == nil → 不帶 labels 欄位 → bridge 保留當前 label 表
|
||||
// opts.Labels == [] → 帶空陣列 → bridge 清掉 label 表
|
||||
//
|
||||
// 最後那條是刻意要能表達的狀態(使用者上傳錯 label 想清掉)。若照 load_model
|
||||
// 的規則用 len()>0 判斷,「清掉」就永遠送不出去、變成靜默無效的操作。
|
||||
func buildSetInferenceOptionsCommand(opts driver.InferenceOptions) map[string]interface{} {
|
||||
cmd := map[string]interface{}{
|
||||
"cmd": "set_inference_options",
|
||||
}
|
||||
if opts.TaskType != "" {
|
||||
cmd["task_type"] = opts.TaskType
|
||||
}
|
||||
if opts.Labels != nil {
|
||||
cmd["labels"] = opts.Labels
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// SetInferenceOptions 在**不重新載入模型**的前提下,更新解析方式與 label 表。
|
||||
//
|
||||
// KL520 一次只能載一個 model、換 model 要重燒(數十秒);但「怎麼解析輸出」
|
||||
// 與「index 顯示成什麼名字」都只是 post-process,可以即時切換。
|
||||
//
|
||||
// 這個 method 刻意不放進 driver.DeviceDriver 介面 —— 它是 Kneron 特有能力,
|
||||
// 放進去會逼三個既有 test fake 都跟著改。呼叫端改用窄介面 type-assert
|
||||
// (同 firmware.UpgradeDriver 的做法)。
|
||||
func (d *KneronDriver) SetInferenceOptions(opts driver.InferenceOptions) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if !d.pythonReady {
|
||||
return fmt.Errorf("hardware bridge is not running — device may not be connected")
|
||||
}
|
||||
if d.modelLoaded == "" {
|
||||
return fmt.Errorf("no model loaded on device — flash a model first")
|
||||
}
|
||||
|
||||
if _, err := d.sendCommand(buildSetInferenceOptionsCommand(opts)); err != nil {
|
||||
return fmt.Errorf("set inference options failed: %w", err)
|
||||
}
|
||||
|
||||
d.driverLog("INFO", "[kneron] inference options updated (taskType=%q, labels=%d)",
|
||||
opts.TaskType, len(opts.Labels))
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartInference begins continuous inference mode.
|
||||
func (d *KneronDriver) StartInference() error {
|
||||
d.mu.Lock()
|
||||
|
||||
@ -0,0 +1,313 @@
|
||||
package kneron
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"visiona-local/server/internal/driver"
|
||||
)
|
||||
|
||||
// TestBuildLoadModelCommand_WithMetadata:有帶 metadata 時 payload 含
|
||||
// task_type + labels,欄位名必須與 kneron_bridge.py handle_load_model 的
|
||||
// params.get("task_type") / params.get("labels") 完全一致。
|
||||
func TestBuildLoadModelCommand_WithMetadata(t *testing.T) {
|
||||
cmd := buildLoadModelCommand("/models/rps.nef", driver.FlashOptions{
|
||||
TaskType: "classification",
|
||||
Labels: []string{"剪刀", "石頭", "布"},
|
||||
})
|
||||
|
||||
if cmd["cmd"] != "load_model" {
|
||||
t.Errorf("cmd = %v, want load_model", cmd["cmd"])
|
||||
}
|
||||
if cmd["path"] != "/models/rps.nef" {
|
||||
t.Errorf("path = %v, want /models/rps.nef", cmd["path"])
|
||||
}
|
||||
if cmd["task_type"] != "classification" {
|
||||
t.Errorf("task_type = %v, want classification", cmd["task_type"])
|
||||
}
|
||||
labels, ok := cmd["labels"].([]string)
|
||||
if !ok {
|
||||
t.Fatalf("labels type = %T, want []string", cmd["labels"])
|
||||
}
|
||||
if !reflect.DeepEqual(labels, []string{"剪刀", "石頭", "布"}) {
|
||||
t.Errorf("labels = %v, want [剪刀 石頭 布]", labels)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildLoadModelCommand_EmptyOptionsOmitsFields:沒帶 metadata 時不送
|
||||
// task_type / labels,讓 bridge 走既有 heuristics(既有 detection 行為不變)。
|
||||
func TestBuildLoadModelCommand_EmptyOptionsOmitsFields(t *testing.T) {
|
||||
cmd := buildLoadModelCommand("/models/fcos.nef", driver.FlashOptions{})
|
||||
|
||||
if _, exists := cmd["task_type"]; exists {
|
||||
t.Errorf("task_type should be omitted when empty, got %v", cmd["task_type"])
|
||||
}
|
||||
if _, exists := cmd["labels"]; exists {
|
||||
t.Errorf("labels should be omitted when empty, got %v", cmd["labels"])
|
||||
}
|
||||
if len(cmd) != 2 {
|
||||
t.Errorf("payload keys = %d (%v), want only cmd+path", len(cmd), cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildLoadModelCommand_EmptyLabelsOmitted:labels 為 non-nil 空 slice 時
|
||||
// 也要省略 —— 送 [] 會讓 bridge 端 _sanitize_labels 走「空 list 視為未提供」,
|
||||
// 語意雖同但多送無意義欄位。
|
||||
func TestBuildLoadModelCommand_EmptyLabelsOmitted(t *testing.T) {
|
||||
cmd := buildLoadModelCommand("/m.nef", driver.FlashOptions{
|
||||
TaskType: "object_detection",
|
||||
Labels: []string{},
|
||||
})
|
||||
if _, exists := cmd["labels"]; exists {
|
||||
t.Errorf("empty labels should be omitted, got %v", cmd["labels"])
|
||||
}
|
||||
if cmd["task_type"] != "object_detection" {
|
||||
t.Errorf("task_type = %v, want object_detection", cmd["task_type"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildLoadModelCommand_SerializesToBridgeContract:payload 經 JSON 編碼後
|
||||
// 必須是 bridge 能吃的形狀(labels 是 JSON array of string,不是物件)。
|
||||
// sendCommand 實際就是把 map 丟給 json.Marshal 送進 stdin。
|
||||
func TestBuildLoadModelCommand_SerializesToBridgeContract(t *testing.T) {
|
||||
cmd := buildLoadModelCommand("/m.nef", driver.FlashOptions{
|
||||
TaskType: "classification",
|
||||
Labels: []string{"a", "b"},
|
||||
})
|
||||
|
||||
data, err := json.Marshal(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal failed: %v", err)
|
||||
}
|
||||
|
||||
var decoded map[string]interface{}
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
if decoded["task_type"] != "classification" {
|
||||
t.Errorf("task_type after roundtrip = %v", decoded["task_type"])
|
||||
}
|
||||
rawLabels, ok := decoded["labels"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("labels after roundtrip type = %T, want JSON array", decoded["labels"])
|
||||
}
|
||||
if len(rawLabels) != 2 || rawLabels[0] != "a" || rawLabels[1] != "b" {
|
||||
t.Errorf("labels after roundtrip = %v, want [a b]", rawLabels)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlashUsesBuilderForEveryLoadModelCall 是本次改動最重要的一條測試。
|
||||
//
|
||||
// Flash 有四處 load_model 呼叫點(初次 + KL720 簡單 retry + KL720 restart retry
|
||||
// + KL520 restart retry)。漏改任一處的後果是「retry 成功後 model metadata 靜默
|
||||
// 遺失」—— 不會報錯、不會 panic,只會讓 classification model 被誤判成 YOLO 而
|
||||
// 回傳空結果,極難從現象追回根因(plan §7 R-5)。
|
||||
//
|
||||
// 用 AST 掃 Flash 函式本體,斷言:
|
||||
// 1. 函式內沒有任何自己手寫的 load_model map literal
|
||||
// 2. 所有 sendCommand 的 load_model 都經過 buildLoadModelCommand
|
||||
// 3. 呼叫點數量 == 4(將來新增 retry 路徑忘了帶 opts 時,這條會亮)
|
||||
func TestFlashUsesBuilderForEveryLoadModelCall(t *testing.T) {
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, "kl720_driver.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse kl720_driver.go: %v", err)
|
||||
}
|
||||
|
||||
var flashFn *ast.FuncDecl
|
||||
for _, decl := range file.Decls {
|
||||
fn, ok := decl.(*ast.FuncDecl)
|
||||
if !ok || fn.Name.Name != "Flash" || fn.Recv == nil {
|
||||
continue
|
||||
}
|
||||
flashFn = fn
|
||||
break
|
||||
}
|
||||
if flashFn == nil {
|
||||
t.Fatal("Flash method not found in kl720_driver.go")
|
||||
}
|
||||
|
||||
builderCalls := 0
|
||||
rawLiterals := 0
|
||||
|
||||
ast.Inspect(flashFn, func(n ast.Node) bool {
|
||||
switch node := n.(type) {
|
||||
case *ast.CallExpr:
|
||||
if ident, ok := node.Fun.(*ast.Ident); ok && ident.Name == "buildLoadModelCommand" {
|
||||
builderCalls++
|
||||
}
|
||||
case *ast.CompositeLit:
|
||||
// 偵測 Flash 內自己手寫的 map,其中含 "load_model" 字串。
|
||||
for _, elt := range node.Elts {
|
||||
kv, ok := elt.(*ast.KeyValueExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
lit, ok := kv.Value.(*ast.BasicLit)
|
||||
if ok && lit.Kind == token.STRING && lit.Value == `"load_model"` {
|
||||
rawLiterals++
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if rawLiterals != 0 {
|
||||
t.Errorf("Flash 內有 %d 個手寫的 load_model map literal;"+
|
||||
"所有呼叫點都必須走 buildLoadModelCommand,否則 retry 後 "+
|
||||
"task_type/labels 會靜默遺失", rawLiterals)
|
||||
}
|
||||
|
||||
const wantCallSites = 4
|
||||
if builderCalls != wantCallSites {
|
||||
t.Errorf("buildLoadModelCommand 呼叫點 = %d,want %d "+
|
||||
"(初次 + KL720 retry + KL720 restart retry + KL520 restart retry)。"+
|
||||
"若確實新增/移除了 retry 路徑,請確認新路徑有帶 opts 後再更新此數字",
|
||||
builderCalls, wantCallSites)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseInferenceResult_ClassIndexPreserved:bridge 回傳的 classIndex 要
|
||||
// 進得了 Go struct。加 ClassIndex 欄位前,這個值會被 encoding/json 靜默丟棄。
|
||||
func TestParseInferenceResult_ClassIndexPreserved(t *testing.T) {
|
||||
resp := map[string]interface{}{
|
||||
"taskType": "classification",
|
||||
"timestamp": float64(1721545200000),
|
||||
"latencyMs": 45.2,
|
||||
"classifications": []interface{}{
|
||||
map[string]interface{}{"label": "石頭", "confidence": 0.94, "classIndex": float64(1)},
|
||||
map[string]interface{}{"label": "class_0", "confidence": 0.04, "classIndex": float64(0)},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := parseInferenceResult(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("parseInferenceResult failed: %v", err)
|
||||
}
|
||||
if result.TaskType != "classification" {
|
||||
t.Errorf("TaskType = %q, want classification", result.TaskType)
|
||||
}
|
||||
if len(result.Classifications) != 2 {
|
||||
t.Fatalf("Classifications = %d, want 2", len(result.Classifications))
|
||||
}
|
||||
if result.Classifications[0].ClassIndex != 1 {
|
||||
t.Errorf("Classifications[0].ClassIndex = %d, want 1", result.Classifications[0].ClassIndex)
|
||||
}
|
||||
// index 0 是合法類別 —— 若 struct tag 誤加 omitempty,這筆會在序列化時消失。
|
||||
if result.Classifications[1].ClassIndex != 0 {
|
||||
t.Errorf("Classifications[1].ClassIndex = %d, want 0", result.Classifications[1].ClassIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassResult_ClassIndexZeroNotOmitted:index 0 必須出現在送給前端的 JSON。
|
||||
// 這是 omitempty 會踩的陷阱 —— 前端拿不到 classIndex 就無法做 fallback 顯示。
|
||||
func TestClassResult_ClassIndexZeroNotOmitted(t *testing.T) {
|
||||
data, err := json.Marshal(driver.ClassResult{Label: "class_0", Confidence: 0.9, ClassIndex: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal failed: %v", err)
|
||||
}
|
||||
|
||||
var decoded map[string]interface{}
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
if _, exists := decoded["classIndex"]; !exists {
|
||||
t.Errorf("classIndex 不見了(大概是加了 omitempty):%s", data)
|
||||
}
|
||||
if decoded["classIndex"] != float64(0) {
|
||||
t.Errorf("classIndex = %v, want 0", decoded["classIndex"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildLoadModelCommand_InputSizeIncluded:宣告的 input size 要送到 bridge,
|
||||
// 欄位名與巢狀結構必須與 kneron_bridge.py 的
|
||||
// _normalize_declared_input_size(params.get("input_size")) 一致。
|
||||
func TestBuildLoadModelCommand_InputSizeIncluded(t *testing.T) {
|
||||
cmd := buildLoadModelCommand("/models/rps.nef", driver.FlashOptions{
|
||||
TaskType: "classification",
|
||||
InputWidth: 320,
|
||||
InputHeight: 256,
|
||||
})
|
||||
|
||||
size, ok := cmd["input_size"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("input_size type = %T, want map[string]interface{}", cmd["input_size"])
|
||||
}
|
||||
if size["width"] != 320 {
|
||||
t.Errorf("input_size.width = %v, want 320", size["width"])
|
||||
}
|
||||
// 高度不可被壓成寬度 —— 非正方形模型兩軸必須各自送出。
|
||||
if size["height"] != 256 {
|
||||
t.Errorf("input_size.height = %v, want 256", size["height"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildLoadModelCommand_ZeroInputSizeOmitted:models.json 沒填 inputSize 時
|
||||
// 兩軸都是 0,送 0 過去只會讓 bridge 多驗一次再丟掉,且會讓 log 的
|
||||
// "(not specified)" 語意失真。
|
||||
func TestBuildLoadModelCommand_ZeroInputSizeOmitted(t *testing.T) {
|
||||
cmd := buildLoadModelCommand("/models/fcos.nef", driver.FlashOptions{
|
||||
TaskType: "object_detection",
|
||||
})
|
||||
|
||||
if _, exists := cmd["input_size"]; exists {
|
||||
t.Errorf("input_size should be omitted when zero, got %v", cmd["input_size"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildLoadModelCommand_PartialInputSizeOmitted:只有一軸的宣告無法描述
|
||||
// 一個輸入尺寸。半套送出比不送更危險 —— bridge 端會看到一個看似有效的來源。
|
||||
func TestBuildLoadModelCommand_PartialInputSizeOmitted(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
width int
|
||||
height int
|
||||
}{
|
||||
{"only width", 320, 0},
|
||||
{"only height", 0, 320},
|
||||
{"negative width", -1, 320},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := buildLoadModelCommand("/models/m.nef", driver.FlashOptions{
|
||||
InputWidth: tc.width,
|
||||
InputHeight: tc.height,
|
||||
})
|
||||
if _, exists := cmd["input_size"]; exists {
|
||||
t.Errorf("input_size should be omitted, got %v", cmd["input_size"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildLoadModelCommand_InputSizeSerializesToBridgeShape:payload 實際被
|
||||
// JSON 序列化後的形狀,就是 bridge 端會 parse 到的東西。用序列化後的結果斷言
|
||||
// 可避免「Go 端看起來對、上 wire 後欄位名或巢狀層級不同」。
|
||||
func TestBuildLoadModelCommand_InputSizeSerializesToBridgeShape(t *testing.T) {
|
||||
cmd := buildLoadModelCommand("/models/rps.nef", driver.FlashOptions{
|
||||
InputWidth: 320,
|
||||
InputHeight: 320,
|
||||
})
|
||||
|
||||
data, err := json.Marshal(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal failed: %v", err)
|
||||
}
|
||||
|
||||
var decoded struct {
|
||||
InputSize struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
} `json:"input_size"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
if decoded.InputSize.Width != 320 || decoded.InputSize.Height != 320 {
|
||||
t.Errorf("input_size = %dx%d, want 320x320 (payload: %s)",
|
||||
decoded.InputSize.Width, decoded.InputSize.Height, data)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,180 @@
|
||||
package kneron
|
||||
|
||||
// set_inference_options_test.go — M4:推論期切換解析方式 / label 表的
|
||||
// JSON-RPC payload 契約測試。
|
||||
//
|
||||
// 這裡的核心不是「欄位名對不對」,而是 **nil / 空陣列 / 缺欄位三種狀態的
|
||||
// 語意必須各自可表達**:
|
||||
//
|
||||
// 缺 task_type 欄位 → bridge 保留當前解析方式
|
||||
// 缺 labels 欄位 → bridge 保留當前 label 表
|
||||
// labels: [] → bridge 清空 label 表(回到原始 enum)
|
||||
//
|
||||
// 若照 load_model 的規則用 `len(labels) > 0` 判斷是否放進 payload,第三種
|
||||
// 狀態就永遠送不出去 —— 使用者按「清除標籤」會拿到 200 但什麼都沒發生。
|
||||
// 這正是這個功能要防的靜默失敗,所以逐條釘死。
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"visiona-local/server/internal/driver"
|
||||
)
|
||||
|
||||
func TestBuildSetInferenceOptionsCommand_CommandName(t *testing.T) {
|
||||
cmd := buildSetInferenceOptionsCommand(driver.InferenceOptions{TaskType: "classification"})
|
||||
|
||||
// 必須與 kneron_bridge.py main() dispatch 的字串完全一致。
|
||||
if cmd["cmd"] != "set_inference_options" {
|
||||
t.Errorf("cmd = %v, want set_inference_options", cmd["cmd"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSetInferenceOptionsCommand_TaskTypeOnly(t *testing.T) {
|
||||
cmd := buildSetInferenceOptionsCommand(driver.InferenceOptions{TaskType: "classification"})
|
||||
|
||||
if cmd["task_type"] != "classification" {
|
||||
t.Errorf("task_type = %v, want classification", cmd["task_type"])
|
||||
}
|
||||
if _, present := cmd["labels"]; present {
|
||||
t.Error("沒指定 labels 時不可放進 payload —— bridge 會誤以為要改 label 表")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSetInferenceOptionsCommand_LabelsOnly(t *testing.T) {
|
||||
cmd := buildSetInferenceOptionsCommand(driver.InferenceOptions{
|
||||
Labels: []string{"剪刀", "石頭", "布"},
|
||||
})
|
||||
|
||||
if _, present := cmd["task_type"]; present {
|
||||
t.Error("沒指定 task_type 時不可放進 payload —— bridge 會誤以為要切解析方式")
|
||||
}
|
||||
labels, ok := cmd["labels"].([]string)
|
||||
if !ok {
|
||||
t.Fatalf("labels type = %T, want []string", cmd["labels"])
|
||||
}
|
||||
if len(labels) != 3 || labels[0] != "剪刀" {
|
||||
t.Errorf("labels = %v", labels)
|
||||
}
|
||||
}
|
||||
|
||||
// ⭐ 本檔最重要的一條:空陣列必須真的被送出去。
|
||||
func TestBuildSetInferenceOptionsCommand_EmptyLabelsIsSent(t *testing.T) {
|
||||
cmd := buildSetInferenceOptionsCommand(driver.InferenceOptions{
|
||||
Labels: []string{},
|
||||
})
|
||||
|
||||
raw, present := cmd["labels"]
|
||||
if !present {
|
||||
t.Fatal("空 labels 陣列沒被送出 —— 「清空 label 表」的意圖丟失了。" +
|
||||
"(是不是用 len(opts.Labels) > 0 判斷?要用 != nil)")
|
||||
}
|
||||
labels, ok := raw.([]string)
|
||||
if !ok {
|
||||
t.Fatalf("labels type = %T, want []string", raw)
|
||||
}
|
||||
if len(labels) != 0 {
|
||||
t.Errorf("len(labels) = %d, want 0", len(labels))
|
||||
}
|
||||
}
|
||||
|
||||
// 對照組:nil 才代表「不動」。
|
||||
func TestBuildSetInferenceOptionsCommand_NilLabelsIsOmitted(t *testing.T) {
|
||||
cmd := buildSetInferenceOptionsCommand(driver.InferenceOptions{
|
||||
TaskType: "classification",
|
||||
Labels: nil,
|
||||
})
|
||||
|
||||
if _, present := cmd["labels"]; present {
|
||||
t.Error("nil labels 不可放進 payload —— nil 代表「保留當前 label 表」")
|
||||
}
|
||||
}
|
||||
|
||||
// 序列化後空陣列要是 JSON 的 [],不能變成 null。
|
||||
// bridge 端對 null 與 [] 的處理不同:null → 保留(依 handler 邏輯 pending=None)、
|
||||
// [] → 清空。變成 null 會讓清空意圖在 wire 上就失真。
|
||||
func TestBuildSetInferenceOptionsCommand_EmptyLabelsMarshalsToArray(t *testing.T) {
|
||||
cmd := buildSetInferenceOptionsCommand(driver.InferenceOptions{Labels: []string{}})
|
||||
|
||||
data, err := json.Marshal(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
|
||||
var decoded map[string]interface{}
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
raw, present := decoded["labels"]
|
||||
if !present {
|
||||
t.Fatalf("labels 欄位不見了:%s", data)
|
||||
}
|
||||
arr, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("labels 序列化成 %T(%s),want JSON array —— null 會被 bridge "+
|
||||
"解讀成「不動」而非「清空」", raw, data)
|
||||
}
|
||||
if len(arr) != 0 {
|
||||
t.Errorf("len = %d, want 0", len(arr))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSetInferenceOptionsCommand_Both(t *testing.T) {
|
||||
cmd := buildSetInferenceOptionsCommand(driver.InferenceOptions{
|
||||
TaskType: "object_detection",
|
||||
Labels: []string{"a", "", "c"},
|
||||
})
|
||||
|
||||
if cmd["task_type"] != "object_detection" {
|
||||
t.Errorf("task_type = %v", cmd["task_type"])
|
||||
}
|
||||
labels, ok := cmd["labels"].([]string)
|
||||
if !ok {
|
||||
t.Fatalf("labels type = %T", cmd["labels"])
|
||||
}
|
||||
// 稀疏佔位的空字串要原樣送過去 —— 位置就是 class index,
|
||||
// 壓縮掉會讓所有後面的 index 位移。
|
||||
if len(labels) != 3 || labels[1] != "" {
|
||||
t.Errorf("labels = %v, want [a c](稀疏佔位必須保留)", labels)
|
||||
}
|
||||
}
|
||||
|
||||
// 全空的 options 不該被 builder 擋(那是上層 handler 的責任),但也不該
|
||||
// 憑空生出欄位 —— 只帶 cmd。這條確保 builder 保持「純翻譯」不做決策。
|
||||
func TestBuildSetInferenceOptionsCommand_ZeroValueOnlyHasCmd(t *testing.T) {
|
||||
cmd := buildSetInferenceOptionsCommand(driver.InferenceOptions{})
|
||||
|
||||
if len(cmd) != 1 {
|
||||
t.Errorf("payload = %v, want 只有 cmd 一個鍵", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// ── driver 前置條件 ──────────────────────────────────────────────────
|
||||
|
||||
func TestSetInferenceOptions_RequiresBridge(t *testing.T) {
|
||||
d := &KneronDriver{}
|
||||
err := d.SetInferenceOptions(driver.InferenceOptions{TaskType: "classification"})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("bridge 沒跑時應該回錯")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "bridge is not running") {
|
||||
t.Errorf("err = %v, 應說明 bridge 未執行", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetInferenceOptions_RequiresLoadedModel(t *testing.T) {
|
||||
// bridge 就緒但沒載 model:切解析方式沒有意義,且 bridge 端也會拒絕。
|
||||
// 在 driver 層先擋掉,錯誤訊息才能指出「要先燒錄模型」。
|
||||
d := &KneronDriver{pythonReady: true}
|
||||
err := d.SetInferenceOptions(driver.InferenceOptions{TaskType: "classification"})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("沒載 model 時應該回錯")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no model loaded") {
|
||||
t.Errorf("err = %v, 應說明尚未載入模型", err)
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,28 @@ import (
|
||||
"visiona-local/server/internal/model"
|
||||
)
|
||||
|
||||
// 可指定的推論種類。與 models.json / 前端同一組值。
|
||||
//
|
||||
// models.json 另有 segmentation / pose_estimation,但 Python bridge 與前端都還
|
||||
// 沒有對應的解析路徑,所以只開放這兩種真的能產出結果的種類。
|
||||
//
|
||||
// 燒錄時不再讓使用者選推論種類(改由推論期的
|
||||
// POST /devices/:id/inference/options 即時切換、不必重燒),但這組常數與
|
||||
// IsValidTaskTypeOverride 仍是「解析方式」的值域來源,由該 endpoint 沿用,
|
||||
// 讓 wire 上永遠只有一組合法命名。
|
||||
const (
|
||||
TaskTypeClassification = "classification"
|
||||
TaskTypeObjectDetection = "object_detection"
|
||||
)
|
||||
|
||||
// IsValidTaskTypeOverride 回報 taskType 是否為合法的解析方式覆寫值。
|
||||
//
|
||||
// 空字串(未指定)不算合法覆寫 —— 呼叫端要自己先判斷「有沒有要覆寫」,
|
||||
// 這樣「未指定」與「指定了但打錯字」不會被混為一談。
|
||||
func IsValidTaskTypeOverride(taskType string) bool {
|
||||
return taskType == TaskTypeClassification || taskType == TaskTypeObjectDetection
|
||||
}
|
||||
|
||||
func isCompatible(modelHardware []string, deviceType string) bool {
|
||||
dt := strings.ToUpper(deviceType)
|
||||
for _, hw := range modelHardware {
|
||||
@ -84,6 +106,11 @@ func (s *Service) CleanupTask(taskID string) {
|
||||
s.tracker.Remove(taskID)
|
||||
}
|
||||
|
||||
// StartFlash 把 model 載入到裝置。
|
||||
//
|
||||
// 推論種類一律用 models.json 宣告的值。使用者若要改解析方式,走推論期的
|
||||
// POST /devices/:id/inference/options —— 那條路徑不必重燒、可即時切換,
|
||||
// 功能完全涵蓋燒錄時再選一次的舊做法。
|
||||
func (s *Service) StartFlash(deviceID, modelID string) (string, <-chan driver.FlashProgress, error) {
|
||||
session, err := s.deviceMgr.GetDevice(deviceID)
|
||||
if err != nil {
|
||||
@ -137,7 +164,18 @@ func (s *Service) StartFlash(deviceID, modelID string) (string, <-chan driver.Fl
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
flashErr := session.Driver.Flash(modelPath, task.ProgressCh)
|
||||
// 把 models.json 宣告的 metadata 一起帶下去 —— bridge 端有 taskType
|
||||
// 就不再靠檔名猜 model type(自訂模型存成 model.nef、檔名沒有關鍵字,
|
||||
// 猜測必定落到 detection 分支)。labels 純顯示層、沒有也能跑。
|
||||
//
|
||||
// inputSize 是宣告值、**優先序最低**:bridge 端會先問 SDK 模型自己
|
||||
// 宣告的 input shape,只有問不到才用這裡的值(這欄是人填的,可能亂填)。
|
||||
flashErr := session.Driver.Flash(modelPath, driver.FlashOptions{
|
||||
TaskType: m.TaskType,
|
||||
Labels: m.Labels,
|
||||
InputWidth: m.InputSize.Width,
|
||||
InputHeight: m.InputSize.Height,
|
||||
}, task.ProgressCh)
|
||||
|
||||
// Flash 完成或失敗後,driver 不會再寫 progressCh,安全地寫 error 訊息然後 close。
|
||||
if flashErr != nil {
|
||||
|
||||
190
local-tool/server/internal/flash/service_metadata_test.go
Normal file
190
local-tool/server/internal/flash/service_metadata_test.go
Normal file
@ -0,0 +1,190 @@
|
||||
package flash
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/printer"
|
||||
"go/token"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"visiona-local/server/internal/driver"
|
||||
"visiona-local/server/internal/model"
|
||||
)
|
||||
|
||||
// recordingDriver 記錄 Flash 收到的 opts,用來驗證 model metadata 有被傳下去。
|
||||
type recordingDriver struct {
|
||||
gotPath string
|
||||
gotOpts driver.FlashOptions
|
||||
called bool
|
||||
}
|
||||
|
||||
func (d *recordingDriver) Info() driver.DeviceInfo { return driver.DeviceInfo{} }
|
||||
func (d *recordingDriver) Connect() error { return nil }
|
||||
func (d *recordingDriver) Disconnect() error { return nil }
|
||||
func (d *recordingDriver) IsConnected() bool { return true }
|
||||
func (d *recordingDriver) Flash(modelPath string, opts driver.FlashOptions, _ chan<- driver.FlashProgress) error {
|
||||
d.called = true
|
||||
d.gotPath = modelPath
|
||||
d.gotOpts = opts
|
||||
return nil
|
||||
}
|
||||
func (d *recordingDriver) StartInference() error { return nil }
|
||||
func (d *recordingDriver) StopInference() error { return nil }
|
||||
func (d *recordingDriver) ReadInference() (*driver.InferenceResult, error) { return nil, nil }
|
||||
func (d *recordingDriver) RunInference(_ []byte) (*driver.InferenceResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (d *recordingDriver) GetModelInfo() (*driver.ModelInfo, error) { return nil, nil }
|
||||
|
||||
// flashOptionsFor 複製 StartFlash 內部組 FlashOptions 的邏輯。
|
||||
//
|
||||
// 為什麼不直接跑 StartFlash:Service 依賴具體的 *device.Manager,而 Manager 的
|
||||
// sessions map 未匯出、只能由真實硬體偵測填入,沒有注入 fake session 的接縫。
|
||||
// 為了測試而改 production 的依賴結構超出 M2 範圍,所以這裡改為釘住「送進
|
||||
// driver 的 FlashOptions 必須完整帶著 model 的 TaskType/Labels」這個契約。
|
||||
func flashOptionsFor(m model.Model) driver.FlashOptions {
|
||||
return driver.FlashOptions{
|
||||
TaskType: m.TaskType,
|
||||
Labels: m.Labels,
|
||||
InputWidth: m.InputSize.Width,
|
||||
InputHeight: m.InputSize.Height,
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlashOptions_CarriesClassificationMetadata:classification model 的
|
||||
// taskType + labels 要完整傳到 driver,不能像改動前一樣只傳 path 就丟棄。
|
||||
func TestFlashOptions_CarriesClassificationMetadata(t *testing.T) {
|
||||
m := model.Model{
|
||||
ID: "custom-rps",
|
||||
TaskType: "classification",
|
||||
Labels: []string{"剪刀", "石頭", "布"},
|
||||
}
|
||||
|
||||
d := &recordingDriver{}
|
||||
opts := flashOptionsFor(m)
|
||||
if err := d.Flash("/models/custom-rps/model.nef", opts, nil); err != nil {
|
||||
t.Fatalf("Flash returned error: %v", err)
|
||||
}
|
||||
|
||||
if !d.called {
|
||||
t.Fatal("Flash was not called")
|
||||
}
|
||||
if d.gotOpts.TaskType != "classification" {
|
||||
t.Errorf("TaskType = %q, want classification", d.gotOpts.TaskType)
|
||||
}
|
||||
if !reflect.DeepEqual(d.gotOpts.Labels, []string{"剪刀", "石頭", "布"}) {
|
||||
t.Errorf("Labels = %v, want [剪刀 石頭 布]", d.gotOpts.Labels)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlashOptions_CarriesDetectionMetadata:既有 detection model 走同一條路,
|
||||
// taskType 為 object_detection —— bridge 端收到後仍走 detection 分支,行為不變。
|
||||
func TestFlashOptions_CarriesDetectionMetadata(t *testing.T) {
|
||||
m := model.Model{
|
||||
ID: "kl520-fcos-detection",
|
||||
TaskType: "object_detection",
|
||||
Labels: []string{"person", "bicycle", "car"},
|
||||
}
|
||||
|
||||
opts := flashOptionsFor(m)
|
||||
if opts.TaskType != "object_detection" {
|
||||
t.Errorf("TaskType = %q, want object_detection", opts.TaskType)
|
||||
}
|
||||
if len(opts.Labels) != 3 {
|
||||
t.Errorf("Labels = %v, want 3 entries", opts.Labels)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlashOptions_EmptyMetadataIsZeroValue:model 沒宣告 taskType/labels 時
|
||||
// 送出的是零值,driver 端會據此省略欄位,讓 bridge fallback 到既有 heuristics。
|
||||
func TestFlashOptions_EmptyMetadataIsZeroValue(t *testing.T) {
|
||||
opts := flashOptionsFor(model.Model{ID: "bare"})
|
||||
|
||||
if opts.TaskType != "" {
|
||||
t.Errorf("TaskType = %q, want empty", opts.TaskType)
|
||||
}
|
||||
if len(opts.Labels) != 0 {
|
||||
t.Errorf("Labels = %v, want empty", opts.Labels)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartFlashPassesModelMetadata 釘住 StartFlash 原始碼真的有把 m.TaskType /
|
||||
// m.Labels 傳進 Flash。
|
||||
//
|
||||
// 上面的測試只驗「FlashOptions 帶得動 metadata」,無法防止有人把 service.go 改回
|
||||
// 只傳 path —— 那正是改動前的 bug 形態(metadata 被靜默丟棄、不會報錯)。
|
||||
// 這裡直接掃 service.go 的 StartFlash 本體補上這個缺口。
|
||||
func TestStartFlashPassesModelMetadata(t *testing.T) {
|
||||
src := readStartFlashSource(t)
|
||||
|
||||
for _, want := range []string{
|
||||
"m.TaskType", "m.Labels", "driver.FlashOptions",
|
||||
// 宣告的 input size 也要傳下去 —— 沒傳的話 bridge 在 SDK 問不到
|
||||
// shape 時只能靠檔名猜,那正是「尺寸靜默錯誤」的來源。
|
||||
"m.InputSize.Width", "m.InputSize.Height",
|
||||
} {
|
||||
if !strings.Contains(src, want) {
|
||||
t.Errorf("StartFlash 原始碼缺少 %q —— model metadata 沒有被傳給 driver.Flash", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlashOptions_CarriesDeclaredInputSize:models.json 宣告的 inputSize 要
|
||||
// 完整帶到 driver。改動前這個欄位在推論鏈路上完全沒被讀過 —— 使用者在上傳
|
||||
// 表單填的寬高毫無作用,input size 全由檔名猜測決定。
|
||||
func TestFlashOptions_CarriesDeclaredInputSize(t *testing.T) {
|
||||
m := model.Model{
|
||||
ID: "custom-rps",
|
||||
TaskType: "classification",
|
||||
InputSize: model.InputSize{Width: 320, Height: 256},
|
||||
}
|
||||
|
||||
opts := flashOptionsFor(m)
|
||||
if opts.InputWidth != 320 {
|
||||
t.Errorf("InputWidth = %d, want 320", opts.InputWidth)
|
||||
}
|
||||
// 非正方形模型的高度不可被壓成寬度。
|
||||
if opts.InputHeight != 256 {
|
||||
t.Errorf("InputHeight = %d, want 256", opts.InputHeight)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlashOptions_MissingInputSizeIsZero:models.json 沒填 inputSize 時是零值,
|
||||
// driver 端會省略欄位,bridge 據此 fallback(既有 detection 模型走這條)。
|
||||
func TestFlashOptions_MissingInputSizeIsZero(t *testing.T) {
|
||||
opts := flashOptionsFor(model.Model{ID: "bare"})
|
||||
|
||||
if opts.InputWidth != 0 || opts.InputHeight != 0 {
|
||||
t.Errorf("InputSize = %dx%d, want 0x0",
|
||||
opts.InputWidth, opts.InputHeight)
|
||||
}
|
||||
}
|
||||
|
||||
// readStartFlashSource 取出 service.go 中 StartFlash 方法的原始碼文字。
|
||||
func readStartFlashSource(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, "service.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse service.go: %v", err)
|
||||
}
|
||||
|
||||
for _, decl := range file.Decls {
|
||||
fn, ok := decl.(*ast.FuncDecl)
|
||||
if !ok || fn.Name.Name != "StartFlash" || fn.Recv == nil {
|
||||
continue
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := printer.Fprint(&buf, fset, fn); err != nil {
|
||||
t.Fatalf("print StartFlash: %v", err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
t.Fatal("StartFlash method not found in service.go")
|
||||
return ""
|
||||
}
|
||||
233
local-tool/server/internal/labelfile/parse.go
Normal file
233
local-tool/server/internal/labelfile/parse.go
Normal file
@ -0,0 +1,233 @@
|
||||
// Package labelfile 解析使用者上傳的 label 檔(`<index> <名稱>` 每行一筆),
|
||||
// 產出可直接注入 Python bridge 的密集 []string。
|
||||
//
|
||||
// 為什麼是獨立 package:解析規則全是純函式、與 HTTP / driver / model 儲存都
|
||||
// 無關,抽出來才能用大量 table-driven 測試把容錯規則逐條釘死(規則來源見
|
||||
// plan-classification-inference.md §3.2,該表已定死、實作不得自行發揮)。
|
||||
package labelfile
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxIndex 是允許的最大 class index(含)。
|
||||
//
|
||||
// 這是本功能最實際的 DoS 面(plan §3.5 / §7 R-7):解析結果要轉成密集
|
||||
// []string,一行 `999999999 x` 就會要求配置約 8GB 的 slice header 空間。
|
||||
// 4095 對真實模型綽綽有餘(ImageNet 1000 類、COCO 80 類)。
|
||||
MaxIndex = 4095
|
||||
|
||||
// MaxLines 是允許的最大「有效內容行數」(不含空行與註解),與 MaxIndex
|
||||
// 互為雙保險:MaxIndex 擋單一巨大 index,MaxLines 擋「index 都合法但行數
|
||||
// 爆量」。
|
||||
//
|
||||
// 為什麼剛好是 MaxIndex+1 而不是更大的值:index 必須唯一且 <= MaxIndex,
|
||||
// 所以合法檔案最多就是 MaxIndex+1 行。設得比這更大會讓這道檢查永遠碰不到
|
||||
// (重複 index 的檢查會先擋下),變成無效防禦 —— 有一個「看起來有防護但
|
||||
// 其實跑不到」的常數,比沒有更糟。
|
||||
MaxLines = MaxIndex + 1
|
||||
|
||||
// MaxFileSize 是允許的檔案大小上限(bytes)。
|
||||
// 3 類 label 約 30 bytes;即使 4096 類中文標籤也遠低於 256 KB。
|
||||
MaxFileSize = 256 * 1024
|
||||
)
|
||||
|
||||
// Result 是一次成功解析的產物。
|
||||
type Result struct {
|
||||
// Labels 是密集陣列:位置 = class index,稀疏處為空字串。
|
||||
//
|
||||
// 為什麼用密集 []string 而非 map(plan §3.2 A1):Model.Labels 與
|
||||
// FlashOptions.Labels 都已經是 []string,改成 map 要動 Go struct、TS type、
|
||||
// models.json 全部既有 model 與 upload handler。Python 端 _resolve_label
|
||||
// 對空字串已會 fallback 回 class_N,稀疏語意天然成立。
|
||||
Labels []string
|
||||
|
||||
// LabelCount 是「實際有名稱的筆數」(不含補洞用的空字串)。
|
||||
LabelCount int
|
||||
|
||||
// MaxIndex 是檔案中出現過的最大 index,等於 len(Labels)-1。
|
||||
MaxIndex int
|
||||
}
|
||||
|
||||
// ParseError 帶行號的解析失敗。整檔拒絕(不做部分接受)—— 靜默略過壞行會讓
|
||||
// 使用者拿到看似成功但標註錯位的結果,那比直接失敗糟得多。
|
||||
type ParseError struct {
|
||||
// Line 是 1-based 行號;0 表示錯誤與特定行無關(如空檔、編碼問題)。
|
||||
Line int
|
||||
// Reason 是給使用者看的說明。
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *ParseError) Error() string {
|
||||
if e.Line > 0 {
|
||||
return fmt.Sprintf("第 %d 行:%s", e.Line, e.Reason)
|
||||
}
|
||||
return e.Reason
|
||||
}
|
||||
|
||||
// utf8BOM 是 UTF-8 位元組順序標記。Windows 記事本另存 UTF-8 會加它,
|
||||
// 不 strip 的話第一行的 index token 會帶著 BOM 位元組而解析失敗。
|
||||
var utf8BOM = []byte{0xEF, 0xBB, 0xBF}
|
||||
|
||||
// Parse 解析 label 檔內容。
|
||||
//
|
||||
// 容錯規則完全依照 plan §3.2 的表:
|
||||
//
|
||||
// 空行 / 純空白行 → 略過
|
||||
// `#` 開頭 → 註解、略過
|
||||
// 行尾 \r(CRLF) → trim
|
||||
// 名稱含空白 → 只 split 第一個空白,其餘全算名稱
|
||||
// index 非整數 / 負數 → 整檔拒絕 + 行號
|
||||
// index 重複 → 整檔拒絕 + 行號
|
||||
// index 不連續 / 不從 0 開始 → 接受,缺的位置補空字串
|
||||
// 空檔 / 全空行 → 拒絕
|
||||
// 非 UTF-8 → 拒絕(BOM 先 strip)
|
||||
// 只有 index 沒名稱 → 拒絕 + 行號
|
||||
func Parse(data []byte) (*Result, error) {
|
||||
if len(data) > MaxFileSize {
|
||||
return nil, &ParseError{
|
||||
Reason: fmt.Sprintf("檔案過大(%d bytes),上限為 %d bytes", len(data), MaxFileSize),
|
||||
}
|
||||
}
|
||||
|
||||
data = bytes.TrimPrefix(data, utf8BOM)
|
||||
|
||||
if !utf8.Valid(data) {
|
||||
return nil, &ParseError{
|
||||
Reason: "檔案不是有效的 UTF-8 編碼,請改存成 UTF-8 後再上傳",
|
||||
}
|
||||
}
|
||||
|
||||
// byIndex 保留原始的稀疏語意,最後才展開成密集陣列 —— 先展開的話,
|
||||
// 「index 重複」與「index 不連續補洞」兩種情況會分不出來。
|
||||
byIndex := make(map[int]string)
|
||||
maxIndex := -1
|
||||
contentLines := 0
|
||||
|
||||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||
// 單行上限放寬到 64KB:預設 bufio 上限也是 64KB,但預設 buffer 只有 4KB
|
||||
// 起跳、長行會回 bufio.ErrTooLong 而不是我們自己的錯誤訊息。
|
||||
scanner.Buffer(make([]byte, 0, 4096), 64*1024)
|
||||
|
||||
lineNo := 0
|
||||
for scanner.Scan() {
|
||||
lineNo++
|
||||
line := strings.TrimRight(scanner.Text(), "\r")
|
||||
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
contentLines++
|
||||
if contentLines > MaxLines {
|
||||
return nil, &ParseError{
|
||||
Line: lineNo,
|
||||
Reason: fmt.Sprintf("標籤行數超過上限 %d", MaxLines),
|
||||
}
|
||||
}
|
||||
|
||||
idx, name, err := parseLine(trimmed)
|
||||
if err != nil {
|
||||
err.Line = lineNo
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, dup := byIndex[idx]; dup {
|
||||
return nil, &ParseError{
|
||||
Line: lineNo,
|
||||
Reason: fmt.Sprintf("index %d 重複出現", idx),
|
||||
}
|
||||
}
|
||||
|
||||
byIndex[idx] = name
|
||||
if idx > maxIndex {
|
||||
maxIndex = idx
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, &ParseError{Reason: fmt.Sprintf("讀取檔案失敗:%v", err)}
|
||||
}
|
||||
|
||||
if len(byIndex) == 0 {
|
||||
return nil, &ParseError{Reason: "標籤檔沒有任何有效內容"}
|
||||
}
|
||||
|
||||
labels := make([]string, maxIndex+1)
|
||||
for idx, name := range byIndex {
|
||||
labels[idx] = name
|
||||
}
|
||||
|
||||
return &Result{
|
||||
Labels: labels,
|
||||
LabelCount: len(byIndex),
|
||||
MaxIndex: maxIndex,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseLine 解析單行 `<index> <名稱>`。回傳的 ParseError 不帶 Line,由呼叫端補。
|
||||
func parseLine(line string) (int, string, *ParseError) {
|
||||
// 只切第一個空白:`0 traffic light` 必須解析成 {0: "traffic light"}。
|
||||
// strings.Fields 會把名稱也切碎,所以刻意用 IndexFunc 自己找分界。
|
||||
sep := strings.IndexFunc(line, unicode.IsSpace)
|
||||
if sep < 0 {
|
||||
return 0, "", &ParseError{
|
||||
Reason: fmt.Sprintf("缺少標籤名稱(只有 %q),格式應為 `<index> <名稱>`", line),
|
||||
}
|
||||
}
|
||||
|
||||
idxToken := line[:sep]
|
||||
name := strings.TrimSpace(line[sep:])
|
||||
if name == "" {
|
||||
return 0, "", &ParseError{
|
||||
Reason: fmt.Sprintf("index %s 後面缺少標籤名稱", idxToken),
|
||||
}
|
||||
}
|
||||
|
||||
idx, err := strconv.Atoi(idxToken)
|
||||
if err != nil {
|
||||
return 0, "", &ParseError{
|
||||
Reason: fmt.Sprintf("index 必須為非負整數,收到 %q", idxToken),
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return 0, "", &ParseError{
|
||||
Reason: fmt.Sprintf("index 必須為非負整數,收到 %q", idxToken),
|
||||
}
|
||||
}
|
||||
if idx > MaxIndex {
|
||||
return 0, "", &ParseError{
|
||||
Reason: fmt.Sprintf("index %d 超過上限 %d", idx, MaxIndex),
|
||||
}
|
||||
}
|
||||
|
||||
if bad, ok := findControlChar(name); ok {
|
||||
return 0, "", &ParseError{
|
||||
Reason: fmt.Sprintf("標籤名稱含有不允許的控制字元(U+%04X)", bad),
|
||||
}
|
||||
}
|
||||
|
||||
return idx, name, nil
|
||||
}
|
||||
|
||||
// findControlChar 找出名稱中的控制字元。標籤會直接被渲染到前端 DOM 與 canvas,
|
||||
// 控制字元(含 U+202E 這類 bidi override)會造成顯示錯亂,一律拒絕。
|
||||
// Tab 已在 TrimSpace / 分隔判斷階段處理掉,這裡不需特別放行。
|
||||
func findControlChar(name string) (rune, bool) {
|
||||
for _, r := range name {
|
||||
if unicode.IsControl(r) || (r >= 0x202A && r <= 0x202E) || (r >= 0x2066 && r <= 0x2069) {
|
||||
return r, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
423
local-tool/server/internal/labelfile/parse_test.go
Normal file
423
local-tool/server/internal/labelfile/parse_test.go
Normal file
@ -0,0 +1,423 @@
|
||||
package labelfile
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// asParseError 取出 *ParseError;不是的話直接讓測試失敗。
|
||||
func asParseError(t *testing.T, err error) *ParseError {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
var pe *ParseError
|
||||
if !errors.As(err, &pe) {
|
||||
t.Fatalf("expected *ParseError, got %T (%v)", err, err)
|
||||
}
|
||||
return pe
|
||||
}
|
||||
|
||||
// ── Happy path ───────────────────────────────────────────────────────
|
||||
|
||||
func TestParse_RealWorldFile(t *testing.T) {
|
||||
// 使用者提供的 labels.txt(剪刀/石頭/布)逐位元組相同的內容。
|
||||
got, err := Parse([]byte("0 剪刀\n1 石頭\n2 布\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse error: %v", err)
|
||||
}
|
||||
want := []string{"剪刀", "石頭", "布"}
|
||||
if !equalStrings(got.Labels, want) {
|
||||
t.Errorf("Labels = %v, want %v", got.Labels, want)
|
||||
}
|
||||
if got.LabelCount != 3 {
|
||||
t.Errorf("LabelCount = %d, want 3", got.LabelCount)
|
||||
}
|
||||
if got.MaxIndex != 2 {
|
||||
t.Errorf("MaxIndex = %d, want 2", got.MaxIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_NoTrailingNewline(t *testing.T) {
|
||||
got, err := Parse([]byte("0 a\n1 b"))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse error: %v", err)
|
||||
}
|
||||
if !equalStrings(got.Labels, []string{"a", "b"}) {
|
||||
t.Errorf("Labels = %v", got.Labels)
|
||||
}
|
||||
}
|
||||
|
||||
// ── plan §3.2 容錯表:逐列 ────────────────────────────────────────────
|
||||
|
||||
// 表列 1:空行 → 略過
|
||||
func TestParse_TableRow_BlankLinesSkipped(t *testing.T) {
|
||||
got, err := Parse([]byte("0 a\n\n\n1 b\n\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse error: %v", err)
|
||||
}
|
||||
if !equalStrings(got.Labels, []string{"a", "b"}) {
|
||||
t.Errorf("Labels = %v, want [a b]", got.Labels)
|
||||
}
|
||||
}
|
||||
|
||||
// 表列 2:只有空白的行 → 略過
|
||||
func TestParse_TableRow_WhitespaceOnlyLinesSkipped(t *testing.T) {
|
||||
got, err := Parse([]byte("0 a\n \n\t\t\n1 b\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse error: %v", err)
|
||||
}
|
||||
if !equalStrings(got.Labels, []string{"a", "b"}) {
|
||||
t.Errorf("Labels = %v, want [a b]", got.Labels)
|
||||
}
|
||||
}
|
||||
|
||||
// 表列 3:以 # 開頭 → 視為註解、略過
|
||||
func TestParse_TableRow_CommentLinesSkipped(t *testing.T) {
|
||||
got, err := Parse([]byte("# 這是註解\n0 a\n # 縮排註解也算\n1 b\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse error: %v", err)
|
||||
}
|
||||
if !equalStrings(got.Labels, []string{"a", "b"}) {
|
||||
t.Errorf("Labels = %v, want [a b]", got.Labels)
|
||||
}
|
||||
}
|
||||
|
||||
// 表列 3 反例:`#` 出現在名稱中間不是註解
|
||||
func TestParse_TableRow_HashInsideNameIsNotComment(t *testing.T) {
|
||||
got, err := Parse([]byte("0 C#\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse error: %v", err)
|
||||
}
|
||||
if !equalStrings(got.Labels, []string{"C#"}) {
|
||||
t.Errorf("Labels = %v, want [C#]", got.Labels)
|
||||
}
|
||||
}
|
||||
|
||||
// 表列 4:行尾 \r(CRLF)→ trim 掉
|
||||
func TestParse_TableRow_CRLFTrimmed(t *testing.T) {
|
||||
got, err := Parse([]byte("0 剪刀\r\n1 石頭\r\n2 布\r\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse error: %v", err)
|
||||
}
|
||||
want := []string{"剪刀", "石頭", "布"}
|
||||
if !equalStrings(got.Labels, want) {
|
||||
t.Errorf("Labels = %v, want %v (CR 未被 trim?)", got.Labels, want)
|
||||
}
|
||||
// 逐字元確認沒有殘留 \r —— equalStrings 若有 bug 可能漏掉。
|
||||
for i, l := range got.Labels {
|
||||
if strings.ContainsRune(l, '\r') {
|
||||
t.Errorf("Labels[%d] = %q 仍含 CR", i, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 表列 5:名稱含空白 → 只 split 第一個空白、其餘全算名稱
|
||||
func TestParse_TableRow_NameWithSpacesKeptWhole(t *testing.T) {
|
||||
got, err := Parse([]byte("0 traffic light\n1 stop sign here\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse error: %v", err)
|
||||
}
|
||||
want := []string{"traffic light", "stop sign here"}
|
||||
if !equalStrings(got.Labels, want) {
|
||||
t.Errorf("Labels = %v, want %v", got.Labels, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 表列 5 變體:index 與名稱之間多個空白 / tab
|
||||
func TestParse_TableRow_MultipleSeparatorWhitespaceCollapsed(t *testing.T) {
|
||||
got, err := Parse([]byte("0\t\t剪刀\n1 石 頭\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse error: %v", err)
|
||||
}
|
||||
want := []string{"剪刀", "石 頭"}
|
||||
if !equalStrings(got.Labels, want) {
|
||||
t.Errorf("Labels = %v, want %v", got.Labels, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 表列 6:index 不是整數 → 整檔拒絕 + 行號
|
||||
func TestParse_TableRow_NonIntegerIndexRejected(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
content string
|
||||
wantLine int
|
||||
}{
|
||||
{"字母", "0 a\n1 b\nabc c\n", 3},
|
||||
{"小數", "0 a\n1.5 b\n", 2},
|
||||
{"十六進位", "0x1 a\n", 1},
|
||||
{"含前導加號以外的雜訊", "0 a\n1_ b\n", 2},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := Parse([]byte(tc.content))
|
||||
pe := asParseError(t, err)
|
||||
if pe.Line != tc.wantLine {
|
||||
t.Errorf("Line = %d, want %d (err=%v)", pe.Line, tc.wantLine, pe)
|
||||
}
|
||||
if !strings.Contains(pe.Reason, "非負整數") {
|
||||
t.Errorf("Reason = %q, 應說明 index 必須為非負整數", pe.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 表列 7:index 為負數 → 整檔拒絕
|
||||
func TestParse_TableRow_NegativeIndexRejected(t *testing.T) {
|
||||
_, err := Parse([]byte("0 a\n-1 b\n"))
|
||||
pe := asParseError(t, err)
|
||||
if pe.Line != 2 {
|
||||
t.Errorf("Line = %d, want 2", pe.Line)
|
||||
}
|
||||
}
|
||||
|
||||
// 表列 8:index 重複 → 整檔拒絕 + 指出重複的 index
|
||||
func TestParse_TableRow_DuplicateIndexRejected(t *testing.T) {
|
||||
_, err := Parse([]byte("0 a\n1 b\n1 c\n"))
|
||||
pe := asParseError(t, err)
|
||||
if pe.Line != 3 {
|
||||
t.Errorf("Line = %d, want 3", pe.Line)
|
||||
}
|
||||
if !strings.Contains(pe.Reason, "1") || !strings.Contains(pe.Reason, "重複") {
|
||||
t.Errorf("Reason = %q, 應指出重複的 index", pe.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// 表列 8 變體:重複且名稱相同也一樣拒絕(不做「反正一樣就放行」的體貼)
|
||||
func TestParse_TableRow_DuplicateIndexSameNameStillRejected(t *testing.T) {
|
||||
_, err := Parse([]byte("0 a\n0 a\n"))
|
||||
asParseError(t, err)
|
||||
}
|
||||
|
||||
// 表列 9:index 不連續 → 接受,缺的位置補空字串
|
||||
func TestParse_TableRow_SparseIndexAccepted(t *testing.T) {
|
||||
got, err := Parse([]byte("0 a\n1 b\n3 d\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse error: %v", err)
|
||||
}
|
||||
want := []string{"a", "b", "", "d"}
|
||||
if !equalStrings(got.Labels, want) {
|
||||
t.Errorf("Labels = %v, want %v", got.Labels, want)
|
||||
}
|
||||
if got.LabelCount != 3 {
|
||||
t.Errorf("LabelCount = %d, want 3(不含補洞的空字串)", got.LabelCount)
|
||||
}
|
||||
if got.MaxIndex != 3 {
|
||||
t.Errorf("MaxIndex = %d, want 3", got.MaxIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// 表列 10:index 不從 0 開始 → 接受
|
||||
func TestParse_TableRow_IndexNotStartingAtZeroAccepted(t *testing.T) {
|
||||
got, err := Parse([]byte("5 e\n6 f\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse error: %v", err)
|
||||
}
|
||||
want := []string{"", "", "", "", "", "e", "f"}
|
||||
if !equalStrings(got.Labels, want) {
|
||||
t.Errorf("Labels = %v, want %v", got.Labels, want)
|
||||
}
|
||||
if got.LabelCount != 2 {
|
||||
t.Errorf("LabelCount = %d, want 2", got.LabelCount)
|
||||
}
|
||||
}
|
||||
|
||||
// 表列 10 變體:亂序也接受(index 決定位置、不是出現順序)
|
||||
func TestParse_TableRow_OutOfOrderIndexAccepted(t *testing.T) {
|
||||
got, err := Parse([]byte("2 布\n0 剪刀\n1 石頭\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse error: %v", err)
|
||||
}
|
||||
want := []string{"剪刀", "石頭", "布"}
|
||||
if !equalStrings(got.Labels, want) {
|
||||
t.Errorf("Labels = %v, want %v", got.Labels, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 表列 11:檔案為空 / 全是空行 → 拒絕
|
||||
func TestParse_TableRow_EmptyFileRejected(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"完全空": "",
|
||||
"只有換行": "\n\n\n",
|
||||
"只有空白": " \n\t\n",
|
||||
"只有註解": "# nothing here\n# still nothing\n",
|
||||
"只有 BOM": "\xEF\xBB\xBF",
|
||||
}
|
||||
for name, content := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := Parse([]byte(content))
|
||||
pe := asParseError(t, err)
|
||||
if pe.Line != 0 {
|
||||
t.Errorf("Line = %d, want 0(與特定行無關)", pe.Line)
|
||||
}
|
||||
if !strings.Contains(pe.Reason, "有效內容") {
|
||||
t.Errorf("Reason = %q", pe.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 表列 12:編碼非 UTF-8 → 拒絕;BOM 要 strip
|
||||
func TestParse_TableRow_InvalidUTF8Rejected(t *testing.T) {
|
||||
// Big5 的「剪刀」= 0xB0 0x45 0xA4 0x4D,在 UTF-8 下是非法序列。
|
||||
content := append([]byte("0 "), 0xB0, 0x45, 0xA4, 0x4D, '\n')
|
||||
_, err := Parse(content)
|
||||
pe := asParseError(t, err)
|
||||
if !strings.Contains(pe.Reason, "UTF-8") {
|
||||
t.Errorf("Reason = %q, 應提示改存 UTF-8", pe.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_TableRow_UTF8BOMStripped(t *testing.T) {
|
||||
content := append([]byte{0xEF, 0xBB, 0xBF}, []byte("0 剪刀\n1 石頭\n")...)
|
||||
got, err := Parse(content)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse error: %v(BOM 未被 strip?)", err)
|
||||
}
|
||||
if !equalStrings(got.Labels, []string{"剪刀", "石頭"}) {
|
||||
t.Errorf("Labels = %v", got.Labels)
|
||||
}
|
||||
}
|
||||
|
||||
// 表列 13:只有 index 沒有名稱 → 拒絕 + 行號
|
||||
func TestParse_TableRow_IndexWithoutNameRejected(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
content string
|
||||
wantLine int
|
||||
}{
|
||||
{"純數字行", "0 a\n1\n", 2},
|
||||
{"數字後只有空白", "0 a\n1 \n", 2}, // TrimSpace 後變 "1"、與純數字行同路
|
||||
{"第一行就缺", "0\n", 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := Parse([]byte(tc.content))
|
||||
pe := asParseError(t, err)
|
||||
if pe.Line != tc.wantLine {
|
||||
t.Errorf("Line = %d, want %d (err=%v)", pe.Line, tc.wantLine, pe)
|
||||
}
|
||||
if !strings.Contains(pe.Reason, "名稱") {
|
||||
t.Errorf("Reason = %q, 應說明缺少名稱", pe.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── plan §3.5 安全上限(S-2 / R-7)───────────────────────────────────
|
||||
|
||||
func TestParse_MaxIndexEnforced(t *testing.T) {
|
||||
t.Run("剛好在上限內", func(t *testing.T) {
|
||||
got, err := Parse([]byte(fmt.Sprintf("%d ok\n", MaxIndex)))
|
||||
if err != nil {
|
||||
t.Fatalf("index %d 應被接受,卻拒絕:%v", MaxIndex, err)
|
||||
}
|
||||
if len(got.Labels) != MaxIndex+1 {
|
||||
t.Errorf("len(Labels) = %d, want %d", len(got.Labels), MaxIndex+1)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("超過上限一格就拒絕", func(t *testing.T) {
|
||||
_, err := Parse([]byte(fmt.Sprintf("%d boom\n", MaxIndex+1)))
|
||||
pe := asParseError(t, err)
|
||||
if pe.Line != 1 {
|
||||
t.Errorf("Line = %d, want 1", pe.Line)
|
||||
}
|
||||
if !strings.Contains(pe.Reason, "上限") {
|
||||
t.Errorf("Reason = %q, 應說明超過上限", pe.Reason)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("巨大 index 不會嘗試配置記憶體", func(t *testing.T) {
|
||||
// 若上限檢查失效,這行會嘗試 make([]string, 1e9),測試會 OOM 而非失敗。
|
||||
_, err := Parse([]byte("999999999 boom\n"))
|
||||
asParseError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestParse_MaxFileSizeEnforced(t *testing.T) {
|
||||
big := make([]byte, MaxFileSize+1)
|
||||
for i := range big {
|
||||
big[i] = 'a'
|
||||
}
|
||||
_, err := Parse(big)
|
||||
pe := asParseError(t, err)
|
||||
if !strings.Contains(pe.Reason, "過大") {
|
||||
t.Errorf("Reason = %q, 應說明檔案過大", pe.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_MaxLinesEnforced(t *testing.T) {
|
||||
// index 唯一且 <= MaxIndex 的合法檔案最多 MaxIndex+1 行,所以要觸發行數
|
||||
// 上限一定得帶重複 index —— 此測試同時釘住「行數檢查排在重複檢查之前」。
|
||||
// 若哪天有人把行數檢查移到 parseLine / dup 檢查之後,這裡會看到「重複」
|
||||
// 而非「行數」,測試失敗。
|
||||
var sb strings.Builder
|
||||
for i := 0; i <= MaxLines; i++ {
|
||||
fmt.Fprintf(&sb, "%d l%d\n", i%(MaxIndex+1), i)
|
||||
}
|
||||
_, err := Parse([]byte(sb.String()))
|
||||
pe := asParseError(t, err)
|
||||
if !strings.Contains(pe.Reason, "行數") {
|
||||
t.Errorf("Reason = %q, 應說明行數超過上限(行數檢查是否被移到重複檢查之後?)", pe.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// MaxLines 必須 <= MaxIndex+1,否則行數檢查永遠碰不到(重複 index 會先擋)。
|
||||
// 這條把「無效防禦」的可能性從常數層面就釘死。
|
||||
func TestParse_MaxLinesIsReachable(t *testing.T) {
|
||||
if MaxLines > MaxIndex+1 {
|
||||
t.Fatalf("MaxLines(%d) > MaxIndex+1(%d):行數檢查永遠不可能觸發",
|
||||
MaxLines, MaxIndex+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_ControlCharactersInNameRejected(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"NUL": "0 a\x00b\n",
|
||||
"ESC": "0 a\x1bb\n",
|
||||
"BiDi override": "0 ab\n",
|
||||
"BiDi isolate": "0 ab\n",
|
||||
}
|
||||
for name, content := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := Parse([]byte(content))
|
||||
pe := asParseError(t, err)
|
||||
if !strings.Contains(pe.Reason, "控制字元") {
|
||||
t.Errorf("Reason = %q, 應說明控制字元", pe.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── 錯誤訊息品質 ─────────────────────────────────────────────────────
|
||||
|
||||
func TestParseError_MessageIncludesLineNumber(t *testing.T) {
|
||||
pe := &ParseError{Line: 5, Reason: "index 必須為非負整數,收到 \"abc\""}
|
||||
if !strings.Contains(pe.Error(), "第 5 行") {
|
||||
t.Errorf("Error() = %q, 應含行號", pe.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseError_NoLineOmitsPrefix(t *testing.T) {
|
||||
pe := &ParseError{Reason: "標籤檔沒有任何有效內容"}
|
||||
if strings.Contains(pe.Error(), "行") {
|
||||
t.Errorf("Error() = %q, 無行號時不應帶行號前綴", pe.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// ── helper ───────────────────────────────────────────────────────────
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
@ -13,6 +14,15 @@ type Repository struct {
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewRepository 載入 models.json 的內建模型目錄。
|
||||
//
|
||||
// models.json 會列出所有「產品支援」的模型定義,但安裝包不一定會帶上每個
|
||||
// 對應的 .nef(見 Makefile 的 BUNDLED_NEFS 白名單)。因此載入後會過濾掉
|
||||
// .nef 檔案實際不存在的 model —— 否則使用者會在 UI 看到選不了的模型,選下去
|
||||
// 才在 flash 階段拿到 "model file not found" 這種沒頭沒尾的錯誤。
|
||||
//
|
||||
// 過濾只作用在 models.json 的內建模型。使用者上傳的自訂模型走 Add(),
|
||||
// 路徑是絕對路徑且必定存在,不受影響。
|
||||
func NewRepository(dataPath string) *Repository {
|
||||
r := &Repository{}
|
||||
data, err := os.ReadFile(dataPath)
|
||||
@ -20,11 +30,56 @@ func NewRepository(dataPath string) *Repository {
|
||||
fmt.Printf("Warning: could not load models from %s: %v\n", dataPath, err)
|
||||
return r
|
||||
}
|
||||
if err := json.Unmarshal(data, &r.models); err != nil {
|
||||
var declared []Model
|
||||
if err := json.Unmarshal(data, &declared); err != nil {
|
||||
fmt.Printf("Warning: could not parse models JSON: %v\n", err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
r.models = filterAvailableModels(declared, filepath.Dir(dataPath))
|
||||
return r
|
||||
}
|
||||
|
||||
// filterAvailableModels 只保留 .nef 檔案實際存在的 model。
|
||||
//
|
||||
// dataDir 是 models.json 所在的目錄(即 bundle 內的 data/),models.json 的
|
||||
// filePath 以它為基準解析。
|
||||
func filterAvailableModels(models []Model, dataDir string) []Model {
|
||||
available := make([]Model, 0, len(models))
|
||||
for _, m := range models {
|
||||
path := resolveBuiltInModelPath(m.FilePath, dataDir)
|
||||
// 沒宣告 filePath 的 model 不做檔案檢查(沒有東西可以檢查),保留原行為。
|
||||
if path == "" {
|
||||
available = append(available, m)
|
||||
continue
|
||||
}
|
||||
if info, err := os.Stat(path); err != nil || info.IsDir() {
|
||||
fmt.Printf("[INFO] Skipping model %q (%s): .nef not bundled at %s\n", m.ID, m.Name, path)
|
||||
continue
|
||||
}
|
||||
available = append(available, m)
|
||||
}
|
||||
return available
|
||||
}
|
||||
|
||||
// resolveBuiltInModelPath 把 models.json 的 filePath 解析成實際的檔案路徑。
|
||||
//
|
||||
// 規則與 flash.Service.StartFlash 一致:
|
||||
// - 絕對路徑 → 原樣使用
|
||||
// - "data/nef/..." → 去掉 "data/" 前綴後接在 dataDir 之下
|
||||
// (因為 dataDir 本身就是那個 data/ 目錄,不去掉會變成 data/data/nef/...)
|
||||
// - 其他相對路徑 → 直接接在 dataDir 之下
|
||||
func resolveBuiltInModelPath(filePath, dataDir string) string {
|
||||
if filePath == "" {
|
||||
return ""
|
||||
}
|
||||
if filepath.IsAbs(filePath) {
|
||||
return filePath
|
||||
}
|
||||
if strings.HasPrefix(filePath, "data/") || strings.HasPrefix(filePath, "data\\") {
|
||||
return filepath.Join(dataDir, filePath[len("data/"):])
|
||||
}
|
||||
return filepath.Join(dataDir, filePath)
|
||||
}
|
||||
|
||||
func (r *Repository) List(filter ModelFilter) ([]ModelSummary, int) {
|
||||
r.mu.RLock()
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@ -98,6 +101,193 @@ func TestRepository_Add(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// writeModelsJSON 在 dir 底下建立 models.json,回傳它的路徑。
|
||||
func writeModelsJSON(t *testing.T, dir string, models []Model) string {
|
||||
t.Helper()
|
||||
data, err := json.MarshalIndent(models, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal models: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "models.json")
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
t.Fatalf("write models.json: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// touchNef 在 dir 底下建立一個假的 .nef(內容不重要,只檢查存在性)。
|
||||
func touchNef(t *testing.T, dir, relPath string) {
|
||||
t.Helper()
|
||||
full := filepath.Join(dir, relPath)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatalf("mkdir for %s: %v", relPath, err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte("fake nef"), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", relPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBuiltInModelPath(t *testing.T) {
|
||||
dataDir := filepath.Join("/bundle", "data")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
filePath string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "strips data/ prefix so it does not become data/data/",
|
||||
filePath: "data/nef/kl520/a.nef",
|
||||
want: filepath.Join("/bundle", "data", "nef", "kl520", "a.nef"),
|
||||
},
|
||||
{
|
||||
name: "relative path without data/ prefix joins directly",
|
||||
filePath: "nef/kl520/a.nef",
|
||||
want: filepath.Join("/bundle", "data", "nef", "kl520", "a.nef"),
|
||||
},
|
||||
{
|
||||
name: "absolute path is used as-is",
|
||||
filePath: filepath.Join("/custom", "models", "x", "model.nef"),
|
||||
want: filepath.Join("/custom", "models", "x", "model.nef"),
|
||||
},
|
||||
{
|
||||
name: "empty file path stays empty",
|
||||
filePath: "",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := resolveBuiltInModelPath(tt.filePath, dataDir); got != tt.want {
|
||||
t.Errorf("resolveBuiltInModelPath(%q) = %q, want %q", tt.filePath, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterAvailableModels(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
touchNef(t, dataDir, "nef/kl520/bundled.nef")
|
||||
|
||||
absent := filepath.Join(dataDir, "nef", "kl520", "abs-missing.nef")
|
||||
present := filepath.Join(dataDir, "nef", "kl520", "abs.nef")
|
||||
touchNef(t, dataDir, "nef/kl520/abs.nef")
|
||||
|
||||
// 目錄而非檔案:不該被當成可用的 model
|
||||
if err := os.MkdirAll(filepath.Join(dataDir, "nef/kl520/dir.nef"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir dir.nef: %v", err)
|
||||
}
|
||||
|
||||
models := []Model{
|
||||
{ID: "bundled", FilePath: "data/nef/kl520/bundled.nef"},
|
||||
{ID: "not-bundled", FilePath: "data/nef/kl520/nope.nef"},
|
||||
{ID: "abs-present", FilePath: present},
|
||||
{ID: "abs-absent", FilePath: absent},
|
||||
{ID: "no-file-path"},
|
||||
{ID: "dir-not-file", FilePath: "data/nef/kl520/dir.nef"},
|
||||
}
|
||||
|
||||
got := filterAvailableModels(models, dataDir)
|
||||
|
||||
var gotIDs []string
|
||||
for _, m := range got {
|
||||
gotIDs = append(gotIDs, m.ID)
|
||||
}
|
||||
want := []string{"bundled", "abs-present", "no-file-path"}
|
||||
|
||||
if len(gotIDs) != len(want) {
|
||||
t.Fatalf("filterAvailableModels() = %v, want %v", gotIDs, want)
|
||||
}
|
||||
for i := range want {
|
||||
if gotIDs[i] != want[i] {
|
||||
t.Errorf("filterAvailableModels()[%d] = %q, want %q", i, gotIDs[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRepository_FiltersUnbundledModels(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
touchNef(t, dataDir, "nef/kl520/kl520_20004_fcos-drk53s_w512h512.nef")
|
||||
touchNef(t, dataDir, "nef/kl520/kl520_tiny_yolo_v3.nef")
|
||||
|
||||
// 模擬正式情境:models.json 宣告 4 個 model,但只打包了其中 2 個 .nef
|
||||
path := writeModelsJSON(t, dataDir, []Model{
|
||||
{ID: "kl520-fcos-detection", Name: "物件辨識", TaskType: "object_detection",
|
||||
FilePath: "data/nef/kl520/kl520_20004_fcos-drk53s_w512h512.nef"},
|
||||
{ID: "kl520-tiny-yolov3", Name: "人型監測", TaskType: "object_detection",
|
||||
FilePath: "data/nef/kl520/kl520_tiny_yolo_v3.nef"},
|
||||
{ID: "kl520-yolov5-detection", Name: "YOLOv5", TaskType: "object_detection",
|
||||
FilePath: "data/nef/kl520/kl520_20005_yolov5-noupsample_w640h640.nef"},
|
||||
{ID: "kl720-resnet18-classification", Name: "ResNet18", TaskType: "classification",
|
||||
FilePath: "data/nef/kl720/kl720_20001_resnet18_w224h224.nef"},
|
||||
})
|
||||
|
||||
repo := NewRepository(path)
|
||||
|
||||
if repo.Count() != 2 {
|
||||
t.Fatalf("Count() = %d, want 2 (only bundled .nef should load)", repo.Count())
|
||||
}
|
||||
for _, id := range []string{"kl520-fcos-detection", "kl520-tiny-yolov3"} {
|
||||
if _, err := repo.GetByID(id); err != nil {
|
||||
t.Errorf("GetByID(%q) failed, expected it to be available: %v", id, err)
|
||||
}
|
||||
}
|
||||
for _, id := range []string{"kl520-yolov5-detection", "kl720-resnet18-classification"} {
|
||||
if _, err := repo.GetByID(id); err == nil {
|
||||
t.Errorf("GetByID(%q) succeeded, expected it to be filtered out", id)
|
||||
}
|
||||
}
|
||||
|
||||
// 過濾後的清單也不該出現在 List()
|
||||
results, count := repo.List(ModelFilter{})
|
||||
if count != 2 || len(results) != 2 {
|
||||
t.Errorf("List() = %d results (count %d), want 2", len(results), count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRepository_CustomModelsUnaffectedByFilter(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
path := writeModelsJSON(t, dataDir, []Model{
|
||||
{ID: "built-in-missing", FilePath: "data/nef/kl520/missing.nef"},
|
||||
})
|
||||
|
||||
repo := NewRepository(path)
|
||||
if repo.Count() != 0 {
|
||||
t.Fatalf("Count() = %d, want 0 after filtering", repo.Count())
|
||||
}
|
||||
|
||||
// 自訂模型走 Add(),不經過過濾
|
||||
repo.Add(Model{ID: "custom-1", IsCustom: true, FilePath: "/anywhere/model.nef"})
|
||||
if repo.Count() != 1 {
|
||||
t.Errorf("Count() = %d after Add(), want 1", repo.Count())
|
||||
}
|
||||
if _, err := repo.GetByID("custom-1"); err != nil {
|
||||
t.Errorf("GetByID(custom-1) failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRepository_MissingOrInvalidFile(t *testing.T) {
|
||||
t.Run("missing models.json yields empty repo", func(t *testing.T) {
|
||||
repo := NewRepository(filepath.Join(t.TempDir(), "nope.json"))
|
||||
if repo.Count() != 0 {
|
||||
t.Errorf("Count() = %d, want 0", repo.Count())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid JSON yields empty repo", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "models.json")
|
||||
if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
repo := NewRepository(path)
|
||||
if repo.Count() != 0 {
|
||||
t.Errorf("Count() = %d, want 0", repo.Count())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRepository_Remove(t *testing.T) {
|
||||
repo := newTestRepo()
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1935
local-tool/server/scripts/test_kneron_bridge_classification.py
Normal file
1935
local-tool/server/scripts/test_kneron_bridge_classification.py
Normal file
File diff suppressed because it is too large
Load Diff
261
local-tool/server/scripts/test_kneron_bridge_encoding.py
Normal file
261
local-tool/server/scripts/test_kneron_bridge_encoding.py
Normal file
@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for kneron_bridge stdio encoding (Windows 中文亂碼修正).
|
||||
|
||||
背景 —— 為什麼需要這組測試:
|
||||
|
||||
Windows 的 Python 預設把 stdin / stdout / stderr 綁到「系統 ANSI code page」
|
||||
而非 UTF-8。繁體中文 Windows 的 ANSI code page 是 cp950。JSON-RPC 兩個方向
|
||||
的行為並不對稱:
|
||||
|
||||
Go → Python (stdin):Go 的 `encoding/json` **不** escape 非 ASCII,
|
||||
`{"labels":["布"]}` 在 wire 上是 raw UTF-8 位元組 e5 b8 83。
|
||||
Python 端若以 cp950 解碼會得到 `撣` + U+FFFD —— 這正是使用者截圖
|
||||
看到的亂碼。嚴格模式下則直接 UnicodeDecodeError 讓 bridge 整個掛掉。
|
||||
|
||||
Python → Go (stdout):`json.dumps` 預設 ensure_ascii=True,中文被 escape
|
||||
成 \\uXXXX 純 ASCII,所以 JSON 回應這條路「目前」不會壞。但這是脆弱的
|
||||
隱性依賴:任何人加上 ensure_ascii=False 就會壞。stderr 上的 `_log()`
|
||||
中文訊息則是現在就會壞(使用者先前看到的 `<EFBFBD>X SDK did not report`
|
||||
就是 em dash U+2014 被非 UTF-8 編碼破壞的結果)。
|
||||
|
||||
因此修正必須同時涵蓋 stdin(decode)與 stdout / stderr(encode)。
|
||||
|
||||
測試策略 —— 如何在 macOS 上測 Windows-only 的 bug:
|
||||
本機 locale 是 UTF-8,直接跑不會重現。改用 `PYTHONIOENCODING` 環境變數強制
|
||||
子行程的 stdio 編碼,模擬繁中 Windows 的預設行為。修正正確時,bridge 必須
|
||||
忽略這個「錯誤的」預設值、一律以 UTF-8 處理 stdio。
|
||||
|
||||
為什麼用 `unknown command` 這條路徑做斷言:
|
||||
它是唯一「不需要實體 Kneron dongle、又會把輸入字串原樣 echo 回 stdout」的
|
||||
handler,因此能驗證完整的 stdin → 解析 → stdout 往返。真正的 labels 路徑
|
||||
(set_inference_options)需要已連線的裝置,在 CI / 本機無法觸及;但兩者共用
|
||||
同一個 stdin 解碼器,所以這裡測到的就是同一個根因。
|
||||
|
||||
執行方式:
|
||||
cd server/scripts && python3 -m unittest test_kneron_bridge_encoding
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
BRIDGE = os.path.join(HERE, "kneron_bridge.py")
|
||||
|
||||
# 取自使用者實際的 labels.txt。`布` 特別重要:UTF-8 位元組 (e5 b8 83) 以
|
||||
# cp950 解碼會得到 `撣` + U+FFFD,正是截圖中的症狀。
|
||||
CJK_LABELS = ["剪刀", "石頭", "布"]
|
||||
|
||||
# U+FFFD REPLACEMENT CHARACTER —— 解碼失敗的明確指紋。
|
||||
REPLACEMENT_CHAR = "<EFBFBD>"
|
||||
|
||||
# 模擬非 UTF-8 系統預設編碼的情境。
|
||||
# cp950 —— 繁中 Windows 的 ANSI code page(嚴格模式會拋例外)
|
||||
# cp950:replace —— 靜默產生 U+FFFD 的最陰險情境(使用者實際看到的)
|
||||
# latin-1 —— 確認修正不是只針對 cp950 硬編
|
||||
NON_UTF8_STDIO_ENVS = ["cp950", "cp950:replace", "latin-1"]
|
||||
|
||||
|
||||
def _run_bridge(commands, io_encoding=None, timeout=60):
|
||||
"""把 commands 逐行餵給 bridge 子行程,回傳 (responses, proc)。
|
||||
|
||||
刻意走真實 subprocess 而非 import:這個 bug 的本質就是「行程啟動時
|
||||
stdio 綁定了什麼編碼」,in-process 測試無法重現。
|
||||
|
||||
以 bytes 模式通訊:測試自己控制編解碼,才能精準模擬 Go 端 json.Marshal
|
||||
送出 raw UTF-8 的行為,不受測試進程自身 locale 影響。
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
# 避免使用者環境既有的設定干擾測試前提。
|
||||
env.pop("PYTHONIOENCODING", None)
|
||||
if io_encoding is not None:
|
||||
env["PYTHONIOENCODING"] = io_encoding
|
||||
|
||||
payload = b"".join(
|
||||
json.dumps(c, ensure_ascii=False).encode("utf-8") + b"\n" for c in commands
|
||||
)
|
||||
|
||||
proc = subprocess.run(
|
||||
[sys.executable, BRIDGE],
|
||||
input=payload,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=env,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
responses = []
|
||||
for raw in proc.stdout.splitlines():
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
responses.append(json.loads(raw.decode("utf-8")))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
# 保留原始 bytes,讓失敗訊息看得出是編碼問題而非邏輯問題。
|
||||
responses.append({"_undecodable": raw})
|
||||
return responses, proc
|
||||
|
||||
|
||||
class StdinDecodingTest(unittest.TestCase):
|
||||
"""stdin 方向:Go 送 raw UTF-8,bridge 必須正確解碼。"""
|
||||
|
||||
def _assert_cjk_roundtrip(self, io_encoding, scenario):
|
||||
# 每個標籤各送一次,確保是逐字驗證而非整包碰運氣。
|
||||
commands = [{"cmd": f"probe_{label}"} for label in CJK_LABELS]
|
||||
responses, proc = _run_bridge(commands, io_encoding)
|
||||
|
||||
stderr_text = proc.stderr.decode("utf-8", "replace")
|
||||
|
||||
self.assertEqual(
|
||||
proc.returncode, 0,
|
||||
msg=(f"[{scenario}] bridge 非正常結束(rc={proc.returncode})。"
|
||||
f"多半是 stdin 以非 UTF-8 解碼導致 UnicodeDecodeError。\n"
|
||||
f"stderr=\n{stderr_text}"),
|
||||
)
|
||||
|
||||
for r in responses:
|
||||
self.assertNotIn(
|
||||
"_undecodable", r,
|
||||
msg=f"[{scenario}] stdout 不是合法 UTF-8 JSON:{r}",
|
||||
)
|
||||
|
||||
errors = [r["error"] for r in responses if isinstance(r.get("error"), str)]
|
||||
self.assertEqual(
|
||||
len(errors), len(CJK_LABELS),
|
||||
msg=(f"[{scenario}] 預期 {len(CJK_LABELS)} 筆 unknown-command 回應,"
|
||||
f"實得 {len(errors)}。responses={responses}\n"
|
||||
f"stderr=\n{stderr_text}"),
|
||||
)
|
||||
|
||||
for label, err in zip(CJK_LABELS, errors):
|
||||
self.assertNotIn(
|
||||
REPLACEMENT_CHAR, err,
|
||||
msg=(f"[{scenario}] 回應含 U+FFFD replacement character,"
|
||||
f"代表 stdin 被以非 UTF-8 解碼:{err!r}"),
|
||||
)
|
||||
self.assertIn(
|
||||
label, err,
|
||||
msg=(f"[{scenario}] 中文字串 round-trip 失敗。"
|
||||
f"預期回應含 {label!r},實得 {err!r}"),
|
||||
)
|
||||
|
||||
def test_roundtrip_under_default_locale(self):
|
||||
"""基準線:本機預設 locale(macOS / Linux 為 UTF-8)必須正常。
|
||||
|
||||
這條在修正前後都會過 —— 它的作用是證明測試本身沒問題,
|
||||
失敗的是編碼而不是斷言寫錯。
|
||||
"""
|
||||
self._assert_cjk_roundtrip(None, "default-locale")
|
||||
|
||||
def test_roundtrip_under_non_utf8_stdio(self):
|
||||
"""核心回歸測試:模擬繁中 Windows 的非 UTF-8 預設 stdio。
|
||||
|
||||
修正前:cp950 → UnicodeDecodeError 直接 crash;
|
||||
cp950:replace → 靜默產生 `撣<EFBFBD>`。
|
||||
修正後:bridge 強制 UTF-8,忽略 PYTHONIOENCODING 的錯誤指示。
|
||||
"""
|
||||
for io_encoding in NON_UTF8_STDIO_ENVS:
|
||||
with self.subTest(encoding=io_encoding):
|
||||
self._assert_cjk_roundtrip(io_encoding, io_encoding)
|
||||
|
||||
|
||||
class StderrEncodingTest(unittest.TestCase):
|
||||
"""stderr 方向:_log() 的中文 / 非 ASCII 診斷訊息必須可讀。"""
|
||||
|
||||
def test_stderr_is_valid_utf8_under_non_utf8_stdio(self):
|
||||
"""使用者先前看到的 `<60>X SDK did not report` 與 label 亂碼同源。
|
||||
|
||||
stderr 若被綁到 cp950,em dash(U+2014)等非 ASCII 字元會被破壞,
|
||||
讓診斷訊息失去價值。
|
||||
"""
|
||||
for io_encoding in NON_UTF8_STDIO_ENVS:
|
||||
with self.subTest(encoding=io_encoding):
|
||||
_, proc = _run_bridge([{"cmd": "probe_布"}], io_encoding)
|
||||
try:
|
||||
proc.stderr.decode("utf-8")
|
||||
except UnicodeDecodeError as e:
|
||||
self.fail(
|
||||
f"[{io_encoding}] stderr 不是合法 UTF-8,"
|
||||
f"編碼修正未涵蓋 stderr:{e}")
|
||||
|
||||
def test_startup_banner_present(self):
|
||||
"""確認 bridge 真的有跑起來(避免上面的測試因空輸出而假綠)。"""
|
||||
_, proc = _run_bridge([{"cmd": "probe_布"}], "cp950:replace")
|
||||
self.assertIn(
|
||||
"[kneron_bridge]", proc.stderr.decode("utf-8", "replace"),
|
||||
msg="bridge 啟動 log 不存在,測試前提不成立")
|
||||
|
||||
|
||||
class StdoutEncodingTest(unittest.TestCase):
|
||||
"""stdout 方向:JSON 回應必須能以 UTF-8 解碼。"""
|
||||
|
||||
def test_stdout_is_valid_utf8_with_non_ascii_payload(self):
|
||||
"""即使未來有人把 json.dumps 改成 ensure_ascii=False 也不能壞。
|
||||
|
||||
目前 ensure_ascii=True 讓中文變成 \\uXXXX 純 ASCII,掩蓋了 stdout
|
||||
的編碼問題。這條測試把「stdout 必須是 UTF-8」釘成顯式契約,
|
||||
而不是依賴 json.dumps 的預設值。
|
||||
"""
|
||||
for io_encoding in NON_UTF8_STDIO_ENVS:
|
||||
with self.subTest(encoding=io_encoding):
|
||||
responses, proc = _run_bridge(
|
||||
[{"cmd": "probe_布"}], io_encoding)
|
||||
for r in responses:
|
||||
self.assertNotIn(
|
||||
"_undecodable", r,
|
||||
msg=(f"[{io_encoding}] stdout 無法以 UTF-8 解碼:{r}"))
|
||||
|
||||
|
||||
class RealStdoutFdEncodingTest(unittest.TestCase):
|
||||
"""main() 裡 os.fdopen() 建立的 JSON-RPC 回應通道必須顯式綁 UTF-8。
|
||||
|
||||
⚠️ 這條為什麼是原始碼層級的斷言,而不是行為測試:
|
||||
|
||||
`os.fdopen(fd, "w")` 不帶 encoding 時,編碼取自
|
||||
`locale.getencoding()`(PEP 686 之後 io.text_encoding(None) → "locale"),
|
||||
**不是** `PYTHONIOENCODING`。PYTHONIOENCODING 只影響 sys.std*。
|
||||
|
||||
後果:在 macOS / Linux(locale 為 UTF-8)上,無論怎麼設
|
||||
PYTHONIOENCODING 都無法讓這個檔案物件變成 cp950,因此
|
||||
上面的 subprocess 測試**結構上碰不到這條路徑**(已用 mutation test
|
||||
證實:拿掉 encoding= 參數後那些測試仍然全綠)。
|
||||
|
||||
但在繁中 Windows 上 locale.getencoding() 就是 cp950,這個 fd
|
||||
會真的以 cp950 編碼寫出 JSON 回應。目前 json.dumps 的
|
||||
ensure_ascii=True 讓中文變成純 ASCII 而僥倖沒出事 —— 這是巧合,
|
||||
不是設計。
|
||||
|
||||
唯一能在 macOS 上守住這條的方式,就是把「必須顯式指定 encoding」
|
||||
當成不可回歸的原始碼契約來檢查。
|
||||
"""
|
||||
|
||||
def test_fdopen_specifies_utf8_explicitly(self):
|
||||
with open(BRIDGE, encoding="utf-8") as f:
|
||||
source = f.read()
|
||||
|
||||
self.assertIn(
|
||||
"_real_stdout_fd = os.dup(1)", source,
|
||||
msg="main() 的 stdout dup 結構已改變,本測試需同步更新")
|
||||
|
||||
# 找出建立 _real_stdout 的那個 os.fdopen 呼叫(可能跨行)。
|
||||
marker = "_real_stdout = os.fdopen("
|
||||
idx = source.find(marker)
|
||||
self.assertNotEqual(
|
||||
idx, -1, msg="找不到 _real_stdout = os.fdopen(...) 呼叫")
|
||||
|
||||
# 取到該敘述結束(右括號)為止,避免誤抓到後面無關的程式碼。
|
||||
call = source[idx:source.find(")", idx) + 1]
|
||||
|
||||
self.assertIn(
|
||||
'encoding="utf-8"', call,
|
||||
msg=(f"os.fdopen() 未顯式指定 encoding=\"utf-8\","
|
||||
f"在繁中 Windows 上會退回 cp950 編碼 JSON-RPC 回應。"
|
||||
f"實際呼叫:{call!r}"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@ -10,6 +11,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@ -105,6 +107,13 @@ type App struct {
|
||||
// 目前仍保留供開發 log 使用,最終在 M8-4b 整個流程改寫後會被拿掉。
|
||||
bootstrapStatus string
|
||||
|
||||
// pythonDepsWarning 記錄「Python 相依健康檢查沒過、自動修復也沒成功」。
|
||||
//
|
||||
// 刻意做成 warning 而非 error:這個檢查會誤判(見 ensureBundledPython 註解),
|
||||
// 擋下啟動的代價遠大於放行。所以照常啟動、把訊息留在這裡讓使用者在 app 內
|
||||
// 看得到,而不是把人擋在啟動畫面外。
|
||||
pythonDepsWarning string
|
||||
|
||||
// L-1:server 健康偵測 goroutine 控制
|
||||
watchCancel context.CancelFunc
|
||||
|
||||
@ -513,6 +522,24 @@ func (a *App) setBootstrapStatus(msg string) {
|
||||
a.appLog("bootstrap: %s", msg)
|
||||
}
|
||||
|
||||
// GetPythonDepsWarning 回傳 Python 相依健康檢查的警告訊息(空字串 = 沒問題)。
|
||||
//
|
||||
// 前端可在控制台顯示這則訊息。它**不代表啟動失敗** —— 有這則訊息時 app 仍
|
||||
// 正常啟動,只是提醒使用者若推論 / 裝置掃描異常時該怎麼自救。
|
||||
func (a *App) GetPythonDepsWarning() string {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.pythonDepsWarning
|
||||
}
|
||||
|
||||
// setPythonDepsWarning 記錄相依修復失敗的訊息(不阻斷啟動)。
|
||||
func (a *App) setPythonDepsWarning(msg string) {
|
||||
a.mu.Lock()
|
||||
a.pythonDepsWarning = msg
|
||||
a.mu.Unlock()
|
||||
a.appLog("WARN python-deps: %s", msg)
|
||||
}
|
||||
|
||||
// OpenBrowser 用系統預設瀏覽器開啟 URL。
|
||||
func (a *App) OpenBrowser(url string) error {
|
||||
return openBrowser(url)
|
||||
@ -872,13 +899,31 @@ func (a *App) ensurePythonRuntime(mode PythonMode) (string, PythonMode, error) {
|
||||
}
|
||||
switch mode {
|
||||
case PythonModeAuto:
|
||||
if bin, err := a.ensureBundledPython(); err == nil {
|
||||
return bin, PythonModeBundled, nil
|
||||
// ⚠️ 兩條失敗原因都必須保留到最終 error。
|
||||
//
|
||||
// 事故背景:舊版把 bundled / system 的 err 直接丟棄,只回一句通用的
|
||||
// "no python runtime available"。實機上 pip 因為 wheels 目錄有同一套件
|
||||
// 多版本而 2 秒內 ResolutionImpossible,但使用者(與我們)在 log 裡永遠
|
||||
// 只看得到那句通用訊息,連續三輪都無法定位真因。
|
||||
//
|
||||
// 失敗原因是這條路徑上唯一能讓使用者自救的資訊,不可以吞。
|
||||
bundledBin, bundledErr := a.ensureBundledPython()
|
||||
if bundledErr == nil {
|
||||
return bundledBin, PythonModeBundled, nil
|
||||
}
|
||||
if bin, err := a.findSystemPython(); err == nil {
|
||||
return bin, PythonModeSystem, nil
|
||||
fmt.Fprintf(os.Stderr, "[visiona-local] bundled python 失敗,改試 system python:%v\n", bundledErr)
|
||||
|
||||
systemBin, systemErr := a.findSystemPython()
|
||||
if systemErr == nil {
|
||||
return systemBin, PythonModeSystem, nil
|
||||
}
|
||||
return "", PythonModeAuto, fmt.Errorf("no python runtime available (tried bundled + system)")
|
||||
fmt.Fprintf(os.Stderr, "[visiona-local] system python 也失敗:%v\n", systemErr)
|
||||
|
||||
// %w 包 bundled(主要路徑、也是幾乎所有實機失敗的來源),system 以文字附上。
|
||||
// errors.Is/As 對 bundled 鏈可用,使用者則兩個原因都看得到。
|
||||
return "", PythonModeAuto, fmt.Errorf(
|
||||
"no python runtime available (tried bundled + system)\n bundled 失敗:%w\n system 失敗:%v",
|
||||
bundledErr, systemErr)
|
||||
|
||||
case PythonModeSystem:
|
||||
bin, err := a.findSystemPython()
|
||||
@ -946,8 +991,27 @@ func (a *App) ensureBundledPython() (string, error) {
|
||||
}
|
||||
|
||||
// 已建立好就直接回傳(幂等)— 日常啟動走這條,不會暫停 hard timeout。
|
||||
//
|
||||
// ⚠️ 「python 執行檔存在」不等於「相依裝好了」:pip install 中途失敗
|
||||
// (斷網 / 磁碟滿 / 防毒攔截)會留下 venv 與 python.exe 都在、但 `import kp`
|
||||
// 失敗的半套環境。所以這裡會順手做一次健康檢查並嘗試修復。
|
||||
//
|
||||
// ⚠️⚠️ 但健康檢查**絕不可以成為啟動的阻斷點**。理由:
|
||||
//
|
||||
// 1. 這個檢查會誤判。probe 跑的是裸的 `python -c "import kp"`,而 kp 在
|
||||
// Windows 要先把 site-packages\kp\lib 加進 PATH / add_dll_directory
|
||||
// 才載得到 native DLL(見 platform_windows.go 的 driver 安裝腳本,以及
|
||||
// kl720_driver.go startPython() 為子行程注入的 PATH)。真正跑推論的
|
||||
// bridge 有這些設定、我們的 probe 沒有 → 環境健康也會被判成壞掉。
|
||||
//
|
||||
// 2. 誤判的代價不對稱。放行的最壞情況是使用者進到 app 後看到「detector
|
||||
// 掃不到裝置」這類具體錯誤(kneron_bridge.py 本來就把 import kp 當
|
||||
// optional:失敗只設 HAS_KP=False);擋下的最壞情況是 app 完全打不開、
|
||||
// 使用者連錯誤細節都看不到,比原本的 bug 更糟。
|
||||
//
|
||||
// 所以:盡力修,修不好也放行,讓後續流程去報真正的錯。
|
||||
if _, err := os.Stat(pythonBin); err == nil {
|
||||
return pythonBin, nil
|
||||
return a.reuseExistingVenv(runtimeDir, pythonBin, wheelsDir), nil
|
||||
}
|
||||
|
||||
// 首次 bootstrap 路徑:解壓 tarball + 建 venv + pip install 9 個 wheel
|
||||
@ -992,19 +1056,345 @@ func (a *App) ensureBundledPython() (string, error) {
|
||||
return "", fmt.Errorf("create venv: %w (%s)", err, string(out))
|
||||
}
|
||||
|
||||
// 列舉 wheelsDir 下所有 .whl
|
||||
var wheels []string
|
||||
if entries, err := os.ReadDir(wheelsDir); err == nil {
|
||||
// 首次安裝:pip 真的失敗才算致命(沒有 venv 可用、後面一定跑不動)。
|
||||
// 但「pip 成功、只是 import 驗證沒過」不擋啟動 —— 那個 probe 會誤判。
|
||||
if err := a.installBundledWheels(runtimeDir, pythonBin, wheelsDir); err != nil {
|
||||
if errors.Is(err, errPythonDepsProbeFailed) {
|
||||
a.setPythonDepsWarning(fmt.Sprintf(
|
||||
"Python 套件已安裝,但載入驗證未通過:%v\n"+
|
||||
"應用程式仍會啟動。若推論或裝置掃描異常,請關閉應用程式後刪除 %s 再重新啟動。",
|
||||
err, runtimeDir))
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"[visiona-local] WARN: wheels 已裝但 import 驗證未過,仍繼續啟動:%v\n", err)
|
||||
return pythonBin, nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
a.setBootstrapStatus("Python 環境就緒")
|
||||
return pythonBin, nil
|
||||
}
|
||||
|
||||
// reuseExistingVenv 處理「venv 已存在」的日常啟動路徑:驗健康、必要時嘗試修復,
|
||||
// 然後**無論如何都回傳 pythonBin**。
|
||||
//
|
||||
// 這個函式沒有 error 回傳值,是刻意的設計 —— 型別本身就保證了「健康檢查不會
|
||||
// 變成啟動的阻斷點」。要退化成阻斷版本必須改簽章,不會不小心發生。
|
||||
//
|
||||
// 修不好時把原因寫進 pythonDepsWarning,讓使用者在 app 內看得到自救步驟,
|
||||
// 而不是被擋在啟動畫面外面(那比原本的 bug 更糟)。
|
||||
func (a *App) reuseExistingVenv(runtimeDir, pythonBin, wheelsDir string) string {
|
||||
if a.bundledPythonDepsHealthy(runtimeDir, pythonBin, wheelsDir) {
|
||||
return pythonBin
|
||||
}
|
||||
|
||||
// venv 在但相依看起來壞了 → 只補裝 wheels,不整個重建 venv。
|
||||
// 重建 venv 要重新解壓 ~100MB tarball,而失敗幾乎都出在 pip 階段。
|
||||
fmt.Fprintln(os.Stderr, "[visiona-local] venv 存在但 Python 相依驗證未通過,嘗試重新安裝 wheels")
|
||||
if err := a.installBundledWheels(runtimeDir, pythonBin, wheelsDir); err != nil {
|
||||
a.setPythonDepsWarning(fmt.Sprintf(
|
||||
"Python 相依自動修復未成功:%v\n"+
|
||||
"應用程式仍會啟動。若推論或裝置掃描異常,請關閉應用程式後刪除 %s 再重新啟動。",
|
||||
err, runtimeDir))
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"[visiona-local] WARN: wheels 重裝失敗,改以現有 venv 繼續啟動:%v\n", err)
|
||||
}
|
||||
return pythonBin
|
||||
}
|
||||
|
||||
// venvReadyMarkerName 是「wheels 已成功裝完」的標記檔名(放在 runtimeDir 下)。
|
||||
//
|
||||
// 存在此標記 = 上一次 installBundledWheels 完整跑完且 pip 回 0。內容是當時
|
||||
// 安裝的 wheel 檔名清單,wheels 換版(升級 KneronPLUS 等)時內容不符會觸發重裝。
|
||||
const venvReadyMarkerName = "venv-ready.txt"
|
||||
|
||||
// venvHealthProbeModules 是啟動時要驗證的關鍵模組。
|
||||
//
|
||||
// 只放「少了就完全跑不動」的三個:kp(Kneron SDK,使用者實際踩到的就是這個)、
|
||||
// numpy、cv2。不放次要相依 —— 這是日常啟動的 hot path,每多一個 import 都是成本。
|
||||
var venvHealthProbeModules = []string{"kp", "numpy", "cv2"}
|
||||
|
||||
// bundledPythonDepsHealthy 檢查 venv 裡的關鍵相依是否真的可用。
|
||||
//
|
||||
// 成本設計(這條路徑每次啟動都會走):
|
||||
// - 快路徑:只讀一個標記檔(~µs)。標記存在且與當前 wheels 清單相符 → 直接放行,
|
||||
// 不啟動任何 process。絕大多數啟動走這條。
|
||||
// - 慢路徑:標記不存在 / 內容不符(首次升級、或舊版留下的 venv)才真的跑一次
|
||||
// python -c "import kp, numpy, cv2"(~200-400ms,僅此一次),成功後補寫標記,
|
||||
// 下次啟動即回到快路徑。
|
||||
//
|
||||
// 為什麼不是每次都跑 import:import cv2 + numpy 要數百毫秒,乘上每次啟動並不划算,
|
||||
// 而「裝好之後又壞掉」需要外力介入(使用者手動刪檔案),不是常態。
|
||||
func (a *App) bundledPythonDepsHealthy(runtimeDir, pythonBin, wheelsDir string) bool {
|
||||
want := bundledWheelsFingerprint(wheelsDir)
|
||||
markerPath := filepath.Join(runtimeDir, venvReadyMarkerName)
|
||||
|
||||
// 快路徑:標記與當前 wheels 相符 → 視為健康,不啟動 process。
|
||||
if got, err := os.ReadFile(markerPath); err == nil {
|
||||
if strings.TrimSpace(string(got)) == want {
|
||||
return true
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "[visiona-local] venv 標記與內建 wheels 不符,重新驗證 Python 相依")
|
||||
}
|
||||
|
||||
// 慢路徑:實際 import 一次確認。
|
||||
if err := probePythonModules(pythonBin, venvHealthProbeModules); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[visiona-local] Python 相依驗證失敗:%v\n", err)
|
||||
return false
|
||||
}
|
||||
|
||||
// 驗證通過(例如舊版留下的健康 venv)→ 補標記,下次走快路徑。
|
||||
if err := os.WriteFile(markerPath, []byte(want), 0o644); err != nil {
|
||||
// 寫不了標記不影響正確性,只是下次還要再驗一次。
|
||||
fmt.Fprintf(os.Stderr, "[visiona-local] WARN: 寫入 venv 標記失敗(不影響運作):%v\n", err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// probePythonModules 跑一次 `python -c "import <mods>"`,確認相依真的可用。
|
||||
//
|
||||
// ⚠️ 這個 probe 的環境必須盡量貼近「真正跑 bridge 的環境」,否則會誤判。
|
||||
// kp 在 import 時就會 ctypes.CDLL 載入 kp/lib 下的 native DLL(libkplus /
|
||||
// libusb-1.0 / libwdi + MinGW runtime)。Windows 上這些 DLL 不在預設搜尋路徑,
|
||||
// 必須先 add_dll_directory —— kl720_driver.go 的 startPython() 與
|
||||
// platform_windows.go 的 driver 安裝腳本都有做這件事。probe 若不做,
|
||||
// 健康的環境也會 import 失敗。所以這裡用一小段 preamble 補上。
|
||||
func probePythonModules(pythonBin string, modules []string) error {
|
||||
if len(modules) == 0 {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, pythonBin, "-c", pythonProbeScript(modules))
|
||||
configureSysProcAttr(cmd)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return fmt.Errorf("import %s 逾時:%w", strings.Join(modules, ", "), ctx.Err())
|
||||
}
|
||||
return fmt.Errorf("import %s 失敗:%w\n%s",
|
||||
strings.Join(modules, ", "), err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pythonProbeScript 產生 probe 用的 python 程式碼。
|
||||
//
|
||||
// 先把 site-packages/*/lib(kp 的 native DLL 目錄)掛進 DLL 搜尋路徑再 import,
|
||||
// 對齊 bridge 實際執行時的環境。找不到那些目錄就單純 import,行為與舊版一致。
|
||||
func pythonProbeScript(modules []string) string {
|
||||
return `
|
||||
import os, sys, sysconfig
|
||||
try:
|
||||
sp = sysconfig.get_paths().get('purelib') or ''
|
||||
if sp and os.path.isdir(sp):
|
||||
for pkg in os.listdir(sp):
|
||||
libdir = os.path.join(sp, pkg, 'lib')
|
||||
if not os.path.isdir(libdir):
|
||||
continue
|
||||
os.environ['PATH'] = libdir + os.pathsep + os.environ.get('PATH', '')
|
||||
if hasattr(os, 'add_dll_directory'):
|
||||
try:
|
||||
os.add_dll_directory(libdir)
|
||||
except OSError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
import ` + strings.Join(modules, ", ") + "\n"
|
||||
}
|
||||
|
||||
// bundledWheelsFingerprint 產生 wheels 目錄的指紋(排序後的檔名清單)。
|
||||
//
|
||||
// 用檔名而非內容雜湊:wheel 檔名本來就帶版本號(KneronPLUS-3.1.2-...whl),
|
||||
// 升級一定換檔名;而讀 ~150MB 內容算雜湊在啟動路徑上太貴。
|
||||
func bundledWheelsFingerprint(wheelsDir string) string {
|
||||
names := listBundledWheelNames(wheelsDir)
|
||||
sort.Strings(names)
|
||||
return strings.Join(names, "\n")
|
||||
}
|
||||
|
||||
// listBundledWheelNames 列出 wheelsDir 下所有 .whl 的檔名(不含路徑)。
|
||||
func listBundledWheelNames(wheelsDir string) []string {
|
||||
var names []string
|
||||
entries, err := os.ReadDir(wheelsDir)
|
||||
if err != nil {
|
||||
return names
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".whl") {
|
||||
wheels = append(wheels, filepath.Join(wheelsDir, e.Name()))
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// droppedWheel 記錄一顆因為同套件有更新版而被略過的 wheel(僅供 log)。
|
||||
type droppedWheel struct {
|
||||
skipped string // 被略過的檔名
|
||||
chosen string // 同套件實際採用的檔名
|
||||
}
|
||||
|
||||
// parseWheelName 從 wheel 檔名取出 distribution 名稱與版本字串。
|
||||
//
|
||||
// PEP 427 檔名格式:{distribution}-{version}(-{build})?-{python}-{abi}-{platform}.whl
|
||||
// 前兩段固定是名稱與版本,所以只需切前兩個 "-" 即可,不必完整解析 tag。
|
||||
//
|
||||
// distribution 依 PEP 503 正規化(底線/點/連字號 → 連字號、轉小寫),讓
|
||||
// `opencv_python_headless` 與 `opencv-python-headless` 視為同一套件。
|
||||
func parseWheelName(filename string) (dist, version string, ok bool) {
|
||||
base := strings.TrimSuffix(filename, ".whl")
|
||||
parts := strings.Split(base, "-")
|
||||
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
|
||||
return "", "", false
|
||||
}
|
||||
dist = normalizeDistName(parts[0])
|
||||
return dist, parts[1], true
|
||||
}
|
||||
|
||||
// normalizeDistName 依 PEP 503 正規化套件名稱(連續的 -_. 收斂成單一 -、小寫)。
|
||||
func normalizeDistName(name string) string {
|
||||
var b strings.Builder
|
||||
prevSep := false
|
||||
for _, r := range strings.ToLower(name) {
|
||||
if r == '-' || r == '_' || r == '.' {
|
||||
if !prevSep {
|
||||
b.WriteRune('-')
|
||||
prevSep = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
prevSep = false
|
||||
}
|
||||
return strings.Trim(b.String(), "-")
|
||||
}
|
||||
|
||||
// compareWheelVersions 比較兩個版本字串,回傳 -1 / 0 / 1。
|
||||
//
|
||||
// 這裡刻意只做「數字段落逐段比大小」的簡化版 PEP 440,不支援 pre-release
|
||||
// (1.0rc1 < 1.0)等完整語意。理由:我們只需要在「同一套件的多個 vendored
|
||||
// wheel」之間挑最新,而那些都是 pip download 抓下來的正式版(2026.2.25 /
|
||||
// 2026.6.17 這種),不會有 rc。完整實作 PEP 440 需要引入相依,不值得。
|
||||
//
|
||||
// 數字段落用整數比較而非字串比較 —— 字串比較會把 "10" 排在 "9" 前面。
|
||||
// 非數字段落(如 "post1"、"dev")退回字串比較,保證結果穩定可預期。
|
||||
func compareWheelVersions(a, b string) int {
|
||||
as := strings.Split(a, ".")
|
||||
bs := strings.Split(b, ".")
|
||||
for i := 0; i < len(as) || i < len(bs); i++ {
|
||||
var av, bv string
|
||||
if i < len(as) {
|
||||
av = as[i]
|
||||
}
|
||||
if i < len(bs) {
|
||||
bv = bs[i]
|
||||
}
|
||||
an, aErr := strconv.Atoi(av)
|
||||
bn, bErr := strconv.Atoi(bv)
|
||||
if aErr == nil && bErr == nil {
|
||||
if an != bn {
|
||||
if an < bn {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
if av != bv {
|
||||
if av < bv {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// selectLatestWheelPerPackage 對每個套件只保留版本最高的一顆 wheel,回傳完整路徑。
|
||||
//
|
||||
// 為什麼需要:見 installBundledWheels 內的說明 —— vendor/wheels/<os> 會累積同一
|
||||
// 套件的多個版本,一起丟給 pip 會 ResolutionImpossible 而秒退。
|
||||
//
|
||||
// 無法解析檔名的 wheel 一律保留(寧可交給 pip 判斷,也不要靜默丟掉使用者的相依)。
|
||||
// 回傳順序依檔名排序,確保 pip 參數穩定、可測。
|
||||
func selectLatestWheelPerPackage(wheelsDir string, names []string) (paths []string, dropped []droppedWheel) {
|
||||
best := make(map[string]string, len(names)) // dist → 目前最新的檔名
|
||||
var keep []string // 無法解析、直接保留的檔名
|
||||
|
||||
sorted := append([]string(nil), names...)
|
||||
sort.Strings(sorted)
|
||||
|
||||
for _, n := range sorted {
|
||||
dist, ver, ok := parseWheelName(n)
|
||||
if !ok {
|
||||
keep = append(keep, n)
|
||||
continue
|
||||
}
|
||||
cur, seen := best[dist]
|
||||
if !seen {
|
||||
best[dist] = n
|
||||
continue
|
||||
}
|
||||
_, curVer, _ := parseWheelName(cur)
|
||||
if compareWheelVersions(ver, curVer) > 0 {
|
||||
best[dist] = n
|
||||
dropped = append(dropped, droppedWheel{skipped: cur, chosen: n})
|
||||
} else {
|
||||
dropped = append(dropped, droppedWheel{skipped: n, chosen: cur})
|
||||
}
|
||||
}
|
||||
|
||||
if len(wheels) == 0 {
|
||||
chosen := make([]string, 0, len(best)+len(keep))
|
||||
for _, n := range best {
|
||||
chosen = append(chosen, n)
|
||||
}
|
||||
chosen = append(chosen, keep...)
|
||||
sort.Strings(chosen)
|
||||
|
||||
paths = make([]string, 0, len(chosen))
|
||||
for _, n := range chosen {
|
||||
paths = append(paths, filepath.Join(wheelsDir, n))
|
||||
}
|
||||
sort.Slice(dropped, func(i, j int) bool { return dropped[i].skipped < dropped[j].skipped })
|
||||
return paths, dropped
|
||||
}
|
||||
|
||||
// errPythonDepsProbeFailed 標示「pip 安裝成功、但事後 import 驗證沒過」。
|
||||
//
|
||||
// 與「pip 本身失敗」分開的原因:pip 失敗代表相依真的沒裝上(致命);probe 失敗
|
||||
// 則可能只是 probe 環境不足(Windows 缺 kp/lib 的 PATH / add_dll_directory),
|
||||
// 呼叫端要能分辨並選擇放行。用 errors.Is 判斷。
|
||||
var errPythonDepsProbeFailed = errors.New("python deps probe failed")
|
||||
|
||||
// installBundledWheels 離線安裝 wheelsDir 下的所有 wheel,成功後寫入就緒標記。
|
||||
//
|
||||
// 標記只在 pip 回 0 **且** import 驗證通過後才寫 —— 半套安裝不可留下「已就緒」
|
||||
// 的假象,那正是舊版讓使用者永久卡住的成因。驗證沒過時回 errPythonDepsProbeFailed,
|
||||
// 由呼叫端決定要不要放行(目前一律放行,只留 warning)。
|
||||
func (a *App) installBundledWheels(runtimeDir, pythonBin, wheelsDir string) error {
|
||||
names := listBundledWheelNames(wheelsDir)
|
||||
if len(names) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "[visiona-local] WARN: no wheels found in", wheelsDir, "— venv 已建立但未安裝任何相依")
|
||||
return pythonBin, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// ⚠️ 同一套件只能挑一個版本送給 pip。
|
||||
//
|
||||
// 事故背景:`make vendor-wheels*` 用 `pip download --dest vendor/wheels/<os>`,
|
||||
// 而該目錄不會被清空 —— 每次 upstream 出新版就多留一顆 whl,舊的仍在。實機上
|
||||
// vendor/wheels/darwin 已累積成 certifi ×3、numpy ×2 …共 16 顆。
|
||||
//
|
||||
// 把重複版本一起丟給 `pip install a.whl b.whl` 會直接 ResolutionImpossible
|
||||
// ("Cannot install certifi 2026.2.25 and certifi 2026.6.17"),~0.5 秒就退出,
|
||||
// 而不是慢慢跑完才失敗。這正是使用者看到的「2 秒失敗」。
|
||||
//
|
||||
// 在這裡擋掉而不是只修 Makefile:使用者手上的安裝包已經帶著多版本 wheels,
|
||||
// 修 build 腳本救不了已出貨的版本;而這層防護對乾淨的 wheels 目錄是 no-op。
|
||||
wheels, dropped := selectLatestWheelPerPackage(wheelsDir, names)
|
||||
for _, d := range dropped {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"[visiona-local] wheels 目錄有同套件多版本,忽略舊版:%s(採用 %s)\n", d.skipped, d.chosen)
|
||||
}
|
||||
|
||||
a.setBootstrapStatus(fmt.Sprintf("正在安裝 %d 個 Python 套件 (numpy / opencv / KneronPLUS ...) (~30-60 秒)...", len(wheels)))
|
||||
@ -1016,11 +1406,23 @@ func (a *App) ensureBundledPython() (string, error) {
|
||||
pipCmd := exec.Command(pythonBin, args...)
|
||||
configureSysProcAttr(pipCmd)
|
||||
if out, err := pipCmd.CombinedOutput(); err != nil {
|
||||
return "", fmt.Errorf("pip install wheels: %w\n%s", err, string(out))
|
||||
return fmt.Errorf("pip install wheels: %w\n%s", err, string(out))
|
||||
}
|
||||
|
||||
a.setBootstrapStatus("Python 環境就緒")
|
||||
return pythonBin, nil
|
||||
// 安裝完立刻驗一次:pip 回 0 不保證 import 得起來(架構不符的 wheel、
|
||||
// 缺系統層 DLL 等)。驗過才寫標記,否則下次啟動又會被快路徑放行。
|
||||
//
|
||||
// 包成 errPythonDepsProbeFailed,讓呼叫端能分辨「pip 失敗」(致命)與
|
||||
// 「只是 probe 沒過」(可能誤判,不該擋啟動)。
|
||||
if err := probePythonModules(pythonBin, venvHealthProbeModules); err != nil {
|
||||
return fmt.Errorf("%w: wheels 安裝完成但相依無法載入:%w", errPythonDepsProbeFailed, err)
|
||||
}
|
||||
|
||||
markerPath := filepath.Join(runtimeDir, venvReadyMarkerName)
|
||||
if err := os.WriteFile(markerPath, []byte(bundledWheelsFingerprint(wheelsDir)), 0o644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[visiona-local] WARN: 寫入 venv 標記失敗(不影響運作):%v\n", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// locateBundledPythonAssets 找 python tarball 與 wheels 目錄。
|
||||
|
||||
371
local-tool/visiona-local/venv_health_test.go
Normal file
371
local-tool/visiona-local/venv_health_test.go
Normal file
@ -0,0 +1,371 @@
|
||||
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 (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 迴歸測試:健康檢查不得成為啟動的阻斷點
|
||||
//
|
||||
// 事故背景(commit f9fbc77):健康檢查失敗時 ensureBundledPython 直接回 error,
|
||||
// Windows 使用者的 app 在啟動階段 2 就掛掉、完全打不開。
|
||||
//
|
||||
// 而這個健康檢查**本來就會誤判**:probe 跑裸的 `python -c "import kp"`,但 kp
|
||||
// 在 import 時就 ctypes.CDLL 載入 kp/lib 下的 native DLL(libkplus / libusb-1.0 /
|
||||
// libwdi)。Windows 上這些 DLL 不在預設搜尋路徑,要先 add_dll_directory —— 真正
|
||||
// 跑 bridge 的 kl720_driver.go startPython() 有注入 PATH,probe 沒有。
|
||||
//
|
||||
// 代價不對稱:放行 → 使用者進 app 後看到具體錯誤(bridge 本來就把 import kp 當
|
||||
// optional);擋下 → app 完全開不了。所以下面這幾條測試釘住「修不好也要放行」。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 使用者實機情境:venv 在、python 可執行、但 import kp 失敗(Windows 缺 DLL 路徑),
|
||||
// 且 wheels 重裝也失敗。必須仍然回傳 pythonBin。
|
||||
func TestReuseExistingVenv_ImportFailsAndRepairFails_StillReturnsPythonBin(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:import 失敗、pip 也失敗(模擬最壞情況)
|
||||
fakePython := filepath.Join(runtimeDir, "python3")
|
||||
writeFile(t, fakePython,
|
||||
"#!/bin/sh\necho \"ImportError: DLL load failed while importing kp\" >&2\nexit 1\n")
|
||||
if err := os.Chmod(fakePython, 0o755); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
|
||||
a := &App{}
|
||||
got := a.reuseExistingVenv(runtimeDir, fakePython, wheels)
|
||||
|
||||
if got != fakePython {
|
||||
t.Fatalf("健康檢查失敗時仍必須回傳 pythonBin(不可阻斷啟動)\ngot=%q want=%q", got, fakePython)
|
||||
}
|
||||
// 而且要留下讓使用者看得到的警告,不能靜默
|
||||
if w := a.GetPythonDepsWarning(); w == "" {
|
||||
t.Fatal("修復失敗時必須留下 warning 讓使用者在 app 內看到自救步驟")
|
||||
} else if !strings.Contains(w, runtimeDir) {
|
||||
t.Fatalf("warning 應告知要刪除哪個目錄,got: %q", w)
|
||||
}
|
||||
}
|
||||
|
||||
// 健康的 venv 不該被打擾:回傳 pythonBin 且不留 warning。
|
||||
func TestReuseExistingVenv_HealthyVenvReturnsBinWithoutWarning(t *testing.T) {
|
||||
runtimeDir := t.TempDir()
|
||||
wheels := wheelsDirWith(t, "KneronPLUS-3.1.2.whl", "numpy-2.4.4.whl")
|
||||
// 標記相符 → 快路徑,不執行 python
|
||||
writeFile(t, filepath.Join(runtimeDir, venvReadyMarkerName), bundledWheelsFingerprint(wheels))
|
||||
|
||||
a := &App{}
|
||||
pythonBin := filepath.Join(runtimeDir, "python3")
|
||||
|
||||
if got := a.reuseExistingVenv(runtimeDir, pythonBin, wheels); got != pythonBin {
|
||||
t.Fatalf("健康的 venv 應直接回傳 pythonBin, got=%q", got)
|
||||
}
|
||||
if w := a.GetPythonDepsWarning(); w != "" {
|
||||
t.Fatalf("健康的 venv 不該留 warning, got: %q", w)
|
||||
}
|
||||
}
|
||||
|
||||
// 舊版留下的 venv(無標記)+ import 驗證通過 → 放行且不留 warning。
|
||||
func TestReuseExistingVenv_LegacyVenvThatPassesProbeIsSilent(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell-script 假 python 在 Windows 上不適用")
|
||||
}
|
||||
runtimeDir := t.TempDir()
|
||||
wheels := wheelsDirWith(t, "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 got := a.reuseExistingVenv(runtimeDir, fakePython, wheels); got != fakePython {
|
||||
t.Fatalf("probe 通過的舊 venv 應放行, got=%q", got)
|
||||
}
|
||||
if w := a.GetPythonDepsWarning(); w != "" {
|
||||
t.Fatalf("probe 通過時不該留 warning, got: %q", w)
|
||||
}
|
||||
}
|
||||
|
||||
// installBundledWheels 必須能區分「pip 失敗」與「只是 probe 沒過」,
|
||||
// 否則首次安裝路徑無法決定該不該放行。
|
||||
func TestInstallBundledWheels_ProbeFailureIsDistinguishableFromPipFailure(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell-script 假 python 在 Windows 上不適用")
|
||||
}
|
||||
runtimeDir := t.TempDir()
|
||||
wheels := wheelsDirWith(t, "numpy-2.4.4.whl")
|
||||
|
||||
// pip install 成功(有參數時 exit 0)、但 -c 的 import probe 失敗
|
||||
fakePython := filepath.Join(runtimeDir, "python3")
|
||||
writeFile(t, fakePython, `#!/bin/sh
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = "-c" ]; then
|
||||
echo "ImportError: DLL load failed while importing kp" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
exit 0
|
||||
`)
|
||||
if err := os.Chmod(fakePython, 0o755); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
|
||||
a := &App{}
|
||||
err := a.installBundledWheels(runtimeDir, fakePython, wheels)
|
||||
if err == nil {
|
||||
t.Fatal("probe 失敗時應回報錯誤")
|
||||
}
|
||||
if !errors.Is(err, errPythonDepsProbeFailed) {
|
||||
t.Fatalf("probe 失敗必須可用 errors.Is 辨識(呼叫端要據此放行), got: %v", err)
|
||||
}
|
||||
// probe 沒過就不可留下就緒標記,否則下次啟動被快路徑放行、真壞掉也發現不了
|
||||
if _, statErr := os.Stat(filepath.Join(runtimeDir, venvReadyMarkerName)); statErr == nil {
|
||||
t.Fatal("probe 未通過時不可寫入就緒標記")
|
||||
}
|
||||
}
|
||||
|
||||
// pip 真的失敗(相依根本沒裝上)不該被誤標成 probe 失敗 —— 那是致命錯誤。
|
||||
func TestInstallBundledWheels_PipFailureIsNotProbeFailure(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell-script 假 python 在 Windows 上不適用")
|
||||
}
|
||||
runtimeDir := t.TempDir()
|
||||
wheels := wheelsDirWith(t, "numpy-2.4.4.whl")
|
||||
|
||||
fakePython := filepath.Join(runtimeDir, "python3")
|
||||
writeFile(t, fakePython, "#!/bin/sh\necho 'pip: disk full' >&2\nexit 1\n")
|
||||
if err := os.Chmod(fakePython, 0o755); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
|
||||
a := &App{}
|
||||
err := a.installBundledWheels(runtimeDir, fakePython, wheels)
|
||||
if err == nil {
|
||||
t.Fatal("pip 失敗時應回報錯誤")
|
||||
}
|
||||
if errors.Is(err, errPythonDepsProbeFailed) {
|
||||
t.Fatalf("pip 失敗不可被歸類為 probe 失敗(兩者處置不同), got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// probe script 必須在 import 前處理好 native DLL 搜尋路徑,
|
||||
// 否則 Windows 上健康的環境也會被判成壞掉(本次事故的成因之一)。
|
||||
func TestPythonProbeScript_SetsUpDLLSearchPathBeforeImport(t *testing.T) {
|
||||
script := pythonProbeScript([]string{"kp", "numpy"})
|
||||
|
||||
if !strings.Contains(script, "add_dll_directory") {
|
||||
t.Error("probe script 必須呼叫 add_dll_directory,否則 Windows 載不到 kp 的 native DLL")
|
||||
}
|
||||
if !strings.Contains(script, "PATH") {
|
||||
t.Error("probe script 必須把 lib 目錄加進 PATH")
|
||||
}
|
||||
if !strings.Contains(script, "import kp, numpy") {
|
||||
t.Errorf("probe script 應 import 指定模組, got:\n%s", script)
|
||||
}
|
||||
// DLL 路徑設定必須在 import 之前,順序錯了等於沒做
|
||||
if strings.Index(script, "add_dll_directory") > strings.Index(script, "import kp, numpy") {
|
||||
t.Error("DLL 搜尋路徑設定必須在 import 目標模組之前")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
365
local-tool/visiona-local/wheel_dedup_test.go
Normal file
365
local-tool/visiona-local/wheel_dedup_test.go
Normal file
@ -0,0 +1,365 @@
|
||||
package main
|
||||
|
||||
// wheel_dedup_test.go — 「同套件多版本 wheel 導致 pip 秒退」的迴歸測試
|
||||
//
|
||||
// 事故背景(使用者連續三輪 Windows 啟動失敗):
|
||||
//
|
||||
// [00:41:59] bootstrap: 正在安裝 16 個 Python 套件 ...
|
||||
// [00:42:01] startup: ctrl.Start failed: python runtime unavailable:
|
||||
// no python runtime available (tried bundled + system)
|
||||
//
|
||||
// 兩個獨立的 bug 疊在一起:
|
||||
//
|
||||
// 1. 根因:`make vendor-wheels*` 用 `pip download --dest vendor/wheels/<os>`,
|
||||
// 該目錄從不清空,upstream 每出新版就多留一顆 whl。實機 vendor/wheels/darwin
|
||||
// 已累積成 certifi ×3、numpy ×2、idna ×2 … 共 16 顆(正常應為 9 顆,log 裡
|
||||
// 的「16 個套件」就是這麼來的)。把重複版本一起丟給
|
||||
// `pip install a.whl b.whl` 會 ResolutionImpossible,~0.5 秒退出 —— 正是
|
||||
// 使用者看到的「2 秒失敗」。
|
||||
//
|
||||
// 2. 放大器:ensurePythonRuntime 的 Auto 分支把 bundled / system 的 err 直接
|
||||
// 丟棄,只回一句通用的 "no python runtime available",使得上面那個明確的
|
||||
// pip 錯誤永遠不會出現在使用者眼前,連續三輪無法定位。
|
||||
//
|
||||
// 下面的測試分別釘死這兩件事。
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. 根因:同套件多版本必須只留最新一顆
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 使用者實機的 wheels 目錄內容(vendor/wheels/darwin,16 顆)。
|
||||
// 這組資料就是會讓 pip ResolutionImpossible 的那組。
|
||||
var realWorldDuplicatedWheels = []string{
|
||||
"KneronPLUS-2.0.0-py3-none-any.whl",
|
||||
"certifi-2026.2.25-py3-none-any.whl",
|
||||
"certifi-2026.6.17-py3-none-any.whl",
|
||||
"certifi-2026.7.22-py3-none-any.whl",
|
||||
"charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl",
|
||||
"charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl",
|
||||
"idna-3.11-py3-none-any.whl",
|
||||
"idna-3.18-py3-none-any.whl",
|
||||
"numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl",
|
||||
"numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl",
|
||||
"opencv_python_headless-4.10.0.84-cp37-abi3-macosx_12_0_x86_64.whl",
|
||||
"pyusb-1.3.1-py3-none-any.whl",
|
||||
"requests-2.33.1-py3-none-any.whl",
|
||||
"requests-2.34.2-py3-none-any.whl",
|
||||
"urllib3-2.6.3-py3-none-any.whl",
|
||||
"urllib3-2.7.0-py3-none-any.whl",
|
||||
}
|
||||
|
||||
// 核心迴歸:16 顆多版本 wheels → 每個套件只剩一顆,且都是最新版。
|
||||
func TestSelectLatestWheelPerPackage_RealWorldDuplicates(t *testing.T) {
|
||||
paths, dropped := selectLatestWheelPerPackage("/w", realWorldDuplicatedWheels)
|
||||
|
||||
// 9 個不同套件:KneronPLUS certifi charset_normalizer idna numpy
|
||||
// opencv_python_headless pyusb requests urllib3
|
||||
if len(paths) != 9 {
|
||||
t.Fatalf("16 顆多版本 wheels 應收斂成 9 顆(每套件一顆)\ngot=%d\n%v", len(paths), paths)
|
||||
}
|
||||
if len(dropped) != 7 {
|
||||
t.Fatalf("應丟棄 7 顆舊版\ngot=%d %v", len(dropped), dropped)
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"KneronPLUS-2.0.0-py3-none-any.whl",
|
||||
"certifi-2026.7.22-py3-none-any.whl",
|
||||
"charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl",
|
||||
"idna-3.18-py3-none-any.whl",
|
||||
"numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl",
|
||||
"opencv_python_headless-4.10.0.84-cp37-abi3-macosx_12_0_x86_64.whl",
|
||||
"pyusb-1.3.1-py3-none-any.whl",
|
||||
"requests-2.34.2-py3-none-any.whl",
|
||||
"urllib3-2.7.0-py3-none-any.whl",
|
||||
}
|
||||
for i, w := range want {
|
||||
if got := filepath.Base(paths[i]); got != w {
|
||||
t.Errorf("paths[%d] = %q, want %q", i, got, w)
|
||||
}
|
||||
}
|
||||
|
||||
// 最關鍵的性質:pip 參數中不得有任何套件出現兩次(有就會 ResolutionImpossible)
|
||||
assertNoDuplicatePackages(t, paths)
|
||||
}
|
||||
|
||||
// 性質測試:不論輸入多少重複版本,輸出永遠每套件至多一顆。
|
||||
// 這是「pip 不會再 ResolutionImpossible」的充分條件。
|
||||
func TestSelectLatestWheelPerPackage_NeverEmitsDuplicatePackage(t *testing.T) {
|
||||
cases := [][]string{
|
||||
realWorldDuplicatedWheels,
|
||||
{"numpy-1.0-py3-none-any.whl", "numpy-2.0-py3-none-any.whl", "numpy-3.0-py3-none-any.whl"},
|
||||
// 正規化:底線 vs 連字號應視為同一套件
|
||||
{"opencv_python_headless-4.10.0-py3-none-any.whl", "opencv-python-headless-4.13.0-py3-none-any.whl"},
|
||||
// 大小寫差異
|
||||
{"Certifi-2026.1.1-py3-none-any.whl", "certifi-2026.2.2-py3-none-any.whl"},
|
||||
}
|
||||
for i, names := range cases {
|
||||
paths, _ := selectLatestWheelPerPackage("/w", names)
|
||||
t.Run(fmt.Sprintf("case%d", i), func(t *testing.T) {
|
||||
assertNoDuplicatePackages(t, paths)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 乾淨的 wheels 目錄(Windows 的 9 顆)必須是 no-op —— 一顆都不能少。
|
||||
// 防止「修重複版本」誤傷正常安裝包。
|
||||
func TestSelectLatestWheelPerPackage_CleanDirIsNoOp(t *testing.T) {
|
||||
clean := []string{
|
||||
"KneronPLUS-3.1.2-py3-none-any.whl",
|
||||
"certifi-2026.2.25-py3-none-any.whl",
|
||||
"charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl",
|
||||
"idna-3.11-py3-none-any.whl",
|
||||
"numpy-2.4.4-cp312-cp312-win_amd64.whl",
|
||||
"opencv_python_headless-4.13.0.92-cp37-abi3-win_amd64.whl",
|
||||
"pyusb-1.3.1-py3-none-any.whl",
|
||||
"requests-2.33.1-py3-none-any.whl",
|
||||
"urllib3-2.6.3-py3-none-any.whl",
|
||||
}
|
||||
paths, dropped := selectLatestWheelPerPackage("/w", clean)
|
||||
if len(paths) != len(clean) {
|
||||
t.Fatalf("乾淨目錄不得丟棄任何 wheel\ngot=%d want=%d", len(paths), len(clean))
|
||||
}
|
||||
if len(dropped) != 0 {
|
||||
t.Fatalf("乾淨目錄不應有 dropped,got=%v", dropped)
|
||||
}
|
||||
}
|
||||
|
||||
// 無法解析檔名的 wheel 必須保留(寧可交給 pip 判斷,也不要靜默吞掉相依)。
|
||||
func TestSelectLatestWheelPerPackage_KeepsUnparseableWheels(t *testing.T) {
|
||||
paths, _ := selectLatestWheelPerPackage("/w", []string{"weird.whl", "numpy-2.5.1-py3-none-any.whl"})
|
||||
if len(paths) != 2 {
|
||||
t.Fatalf("無法解析的 wheel 應保留\ngot=%v", paths)
|
||||
}
|
||||
}
|
||||
|
||||
// 回傳路徑必須帶上 wheelsDir 前綴(pip 需要完整路徑)。
|
||||
func TestSelectLatestWheelPerPackage_ReturnsFullPaths(t *testing.T) {
|
||||
paths, _ := selectLatestWheelPerPackage("/tmp/wheels", []string{"numpy-2.5.1-py3-none-any.whl"})
|
||||
want := filepath.Join("/tmp/wheels", "numpy-2.5.1-py3-none-any.whl")
|
||||
if len(paths) != 1 || paths[0] != want {
|
||||
t.Fatalf("got=%v want=[%s]", paths, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoDuplicatePackages(t *testing.T, paths []string) {
|
||||
t.Helper()
|
||||
seen := map[string]string{}
|
||||
for _, p := range paths {
|
||||
base := filepath.Base(p)
|
||||
dist, _, ok := parseWheelName(base)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if prev, dup := seen[dist]; dup {
|
||||
t.Fatalf("套件 %q 出現兩次(pip 會 ResolutionImpossible 秒退):%s 與 %s",
|
||||
dist, prev, base)
|
||||
}
|
||||
seen[dist] = base
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1b. 整合層:installBundledWheels 實際交給 pip 的參數必須已去重
|
||||
//
|
||||
// 單獨測 selectLatestWheelPerPackage 不夠 —— 若有人把 installBundledWheels 改回
|
||||
// 「把 names 全部丟給 pip」,純函式測試仍會全綠,但使用者又會回到 2 秒失敗。
|
||||
// 這條測試攔的就是那個回歸:直接檢查真正送進 pip 的 argv。
|
||||
// ---------------------------------------------------------------------------
|
||||
func TestInstallBundledWheels_PassesDedupedArgsToPip(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell-script 假 python 在 Windows 上不適用")
|
||||
}
|
||||
runtimeDir := t.TempDir()
|
||||
wheels := wheelsDirWith(t, realWorldDuplicatedWheels...)
|
||||
|
||||
// 假 python:把收到的 argv 逐行寫檔,pip 與 probe 都回成功。
|
||||
argsLog := filepath.Join(runtimeDir, "args.txt")
|
||||
fakePython := filepath.Join(runtimeDir, "python3")
|
||||
writeFile(t, fakePython, "#!/bin/sh\nfor arg in \"$@\"; do echo \"$arg\" >> "+argsLog+"; done\nexit 0\n")
|
||||
if err := os.Chmod(fakePython, 0o755); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
|
||||
a := &App{}
|
||||
if err := a.installBundledWheels(runtimeDir, fakePython, wheels); err != nil {
|
||||
t.Fatalf("installBundledWheels: %v", err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(argsLog)
|
||||
if err != nil {
|
||||
t.Fatalf("read args log: %v", err)
|
||||
}
|
||||
|
||||
// 收集 argv 中所有 .whl 參數 —— 這就是 pip 真正要安裝的清單。
|
||||
var passed []string
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
if strings.HasSuffix(strings.TrimSpace(line), ".whl") {
|
||||
passed = append(passed, strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
|
||||
if len(passed) != 9 {
|
||||
t.Fatalf("pip 應只收到 9 顆(每套件一顆),實際 %d 顆\n%v", len(passed), passed)
|
||||
}
|
||||
// 核心斷言:argv 內不得有同一套件的兩個版本,否則 pip ResolutionImpossible。
|
||||
assertNoDuplicatePackages(t, passed)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. 版本比較:數字段落必須以整數比大小
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCompareWheelVersions(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"2026.2.25", "2026.6.17", -1},
|
||||
{"2026.6.17", "2026.7.22", -1},
|
||||
{"2026.7.22", "2026.2.25", 1},
|
||||
{"3.4.7", "3.4.9", -1},
|
||||
{"2.4.4", "2.5.1", -1},
|
||||
{"1.3.1", "1.3.1", 0},
|
||||
// 字串比較會答錯的案例("10" < "9" 字典序)—— 必須用整數比
|
||||
{"3.9", "3.10", -1},
|
||||
{"2.0", "10.0", -1},
|
||||
// 段數不同
|
||||
{"4.13.0", "4.13.0.92", -1},
|
||||
{"1.0", "1.0.0", -1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := compareWheelVersions(c.a, c.b); got != c.want {
|
||||
t.Errorf("compareWheelVersions(%q, %q) = %d, want %d", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 釘住「用整數而非字串比較」:numpy 2.9 vs 2.10,字串比較會誤選 2.9。
|
||||
func TestSelectLatestWheelPerPackage_NumericNotLexicographic(t *testing.T) {
|
||||
paths, _ := selectLatestWheelPerPackage("/w", []string{
|
||||
"numpy-2.9.0-py3-none-any.whl",
|
||||
"numpy-2.10.0-py3-none-any.whl",
|
||||
})
|
||||
if len(paths) != 1 {
|
||||
t.Fatalf("應只留一顆,got=%v", paths)
|
||||
}
|
||||
if got := filepath.Base(paths[0]); got != "numpy-2.10.0-py3-none-any.whl" {
|
||||
t.Fatalf("應選數值較大的 2.10.0(字串比較會誤選 2.9.0),got=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWheelName(t *testing.T) {
|
||||
cases := []struct {
|
||||
file, dist, ver string
|
||||
ok bool
|
||||
}{
|
||||
{"numpy-2.4.4-cp312-cp312-win_amd64.whl", "numpy", "2.4.4", true},
|
||||
{"KneronPLUS-3.1.2-py3-none-any.whl", "kneronplus", "3.1.2", true},
|
||||
// 正規化:底線 → 連字號、轉小寫
|
||||
{"opencv_python_headless-4.13.0.92-cp37-abi3-win_amd64.whl", "opencv-python-headless", "4.13.0.92", true},
|
||||
{"charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", "charset-normalizer", "3.4.7", true},
|
||||
{"weird.whl", "", "", false},
|
||||
{"", "", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
dist, ver, ok := parseWheelName(c.file)
|
||||
if ok != c.ok || dist != c.dist || ver != c.ver {
|
||||
t.Errorf("parseWheelName(%q) = (%q, %q, %v), want (%q, %q, %v)",
|
||||
c.file, dist, ver, ok, c.dist, c.ver, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PEP 503 正規化:底線 / 點 / 連字號視為等價,連續分隔符收斂。
|
||||
func TestNormalizeDistName(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"opencv_python_headless", "opencv-python-headless"},
|
||||
{"opencv-python-headless", "opencv-python-headless"},
|
||||
{"opencv.python.headless", "opencv-python-headless"},
|
||||
{"KneronPLUS", "kneronplus"},
|
||||
{"charset__normalizer", "charset-normalizer"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := normalizeDistName(c.in); got != c.want {
|
||||
t.Errorf("normalizeDistName(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. 放大器:Auto 模式失敗時必須保留 bundled 的真實原因
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 這是使用者「連續三輪看不到真因」的直接迴歸測試。
|
||||
//
|
||||
// 測試必須是確定性的,所以兩條路徑都強制失敗:
|
||||
// - bundled:cwd 切到空目錄 + dataDir 空 → locateBundledPythonAssets 找不到資產
|
||||
// - system:PATH 清空 → exec.LookPath 找不到任何 python
|
||||
//
|
||||
// 不這樣控制的話,開發機上 findSystemPython 會成功,Auto 直接回傳,測試被略過;
|
||||
// 更糟的是 bundled 路徑會真的去解壓 tarball 建 venv(實測 32 秒)。
|
||||
func TestEnsurePythonRuntime_AutoPreservesBundledFailureReason(t *testing.T) {
|
||||
// PATH 清空 → system python 一定找不到。t.Setenv 會自動還原。
|
||||
t.Setenv("PATH", "")
|
||||
// AppImage 的資產提示也要清掉,否則可能指到真實 bundle。
|
||||
t.Setenv("VISIONA_BUNDLE_LIB_DIR", "")
|
||||
|
||||
// cwd 切到空目錄 → payload/<os> 的開發模式 fallback 一定找不到。
|
||||
// (不用 t.Chdir:那是 go1.24 才有的 API,本 module 是 go1.22。)
|
||||
origWD, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(t.TempDir()); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(origWD) })
|
||||
|
||||
a := &App{dataDir: t.TempDir()}
|
||||
|
||||
_, _, runErr := a.ensurePythonRuntime(PythonModeAuto)
|
||||
if runErr == nil {
|
||||
t.Fatal("bundled 與 system 都不可用時,Auto 必須回 error")
|
||||
}
|
||||
|
||||
msg := runErr.Error()
|
||||
// 通用訊息仍在(維持既有語意)
|
||||
if !strings.Contains(msg, "no python runtime available") {
|
||||
t.Errorf("應保留原本的通用訊息,got=%q", msg)
|
||||
}
|
||||
// 關鍵:必須帶出 bundled 的具體原因,而不是只有通用訊息
|
||||
if !strings.Contains(msg, "bundled 失敗") {
|
||||
t.Errorf("最終 error 必須包含 bundled 失敗的原因(否則使用者看不到 pip 真錯)\ngot=%q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "system 失敗") {
|
||||
t.Errorf("最終 error 必須包含 system 失敗的原因\ngot=%q", msg)
|
||||
}
|
||||
// bundled 的原始 error 必須可用 errors.Is/As 追溯(%w 而非 %v)
|
||||
if !strings.Contains(msg, "bundled python assets not found") {
|
||||
t.Errorf("bundled 的根本原因(找不到資產)應原樣出現在訊息中\ngot=%q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 釘住 %w 包裝:bundled 的 error 鏈必須可被 errors.Is 追溯。
|
||||
//
|
||||
// 用一個 sentinel 驗證 fmt.Errorf 的包裝語意沒被改回 %v。
|
||||
func TestAutoModeErrorWrapping_IsUnwrappable(t *testing.T) {
|
||||
sentinel := errors.New("pip install wheels: ResolutionImpossible")
|
||||
wrapped := fmt.Errorf(
|
||||
"no python runtime available (tried bundled + system)\n bundled 失敗:%w\n system 失敗:%v",
|
||||
sentinel, errors.New("no suitable python3"))
|
||||
|
||||
if !errors.Is(wrapped, sentinel) {
|
||||
t.Fatal("bundled 的原始 error 必須能被 errors.Is 追溯(fmt.Errorf 要用 percent-w 包裝,不可退回 percent-v)")
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user