Compare commits

..

No commits in common. "9397a4d31f24d8b6e06df8b552adccd3973230c1" and "af44a9f9ba7675ec808f453be5a93ca74de2a7e7" have entirely different histories.

53 changed files with 183 additions and 9889 deletions

View File

@ -78,7 +78,3 @@ desktop.ini
*.pem *.pem
*.key *.key
.autoflow/
.mcp.json
graphify-out/

View File

@ -18,153 +18,6 @@ OS := $(shell uname -s | tr A-Z a-z)
DIST := dist DIST := dist
PAYLOAD := visiona-local/payload PAYLOAD := visiona-local/payload
# ── 打包用 .nef 白名單M5-a────────────────────────────────────────
#
# server/data/ 底下有 8 個 .nefKL520 五個、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 Bashwindows-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 \ .PHONY: help \
vendor-sync vendor-python vendor-wheels vendor-ffmpeg vendor-ffmpeg-macos-build \ vendor-sync vendor-python vendor-wheels vendor-ffmpeg vendor-ffmpeg-macos-build \
vendor-python-windows vendor-wheels-windows vendor-ffmpeg-windows \ vendor-python-windows vendor-wheels-windows vendor-ffmpeg-windows \
@ -237,7 +90,7 @@ vendor-python: ## 下載 python-build-standalone tarball → vendor/python/darwi
fi fi
vendor-wheels: ## 同步 wheels → vendor/wheels/darwin/(內部 wheel 從 visiona-local/wheels 複製,公開相依用 pip download vendor-wheels: ## 同步 wheels → vendor/wheels/darwin/(內部 wheel 從 visiona-local/wheels 複製,公開相依用 pip download
$(call clean_wheels_dir,vendor/wheels/darwin) @mkdir -p vendor/wheels/darwin
@echo "==> 同步內部 wheelsKneronPLUS 等)..." @echo "==> 同步內部 wheelsKneronPLUS 等)..."
@if [ -d visiona-local/wheels/macos ]; then \ @if [ -d visiona-local/wheels/macos ]; then \
cp visiona-local/wheels/macos/*.whl vendor/wheels/darwin/ 2>/dev/null || true; \ cp visiona-local/wheels/macos/*.whl vendor/wheels/darwin/ 2>/dev/null || true; \
@ -392,12 +245,9 @@ payload-macos: build-server vendor-python vendor-wheels vendor-ffmpeg ## 準備
cp vendor/ffmpeg/macos/ffprobe payload/darwin/bin/ cp vendor/ffmpeg/macos/ffprobe payload/darwin/bin/
cp vendor/ffmpeg/macos/COPYING.LGPLv3 payload/darwin/bin/ffmpeg-COPYING.LGPLv3 cp vendor/ffmpeg/macos/COPYING.LGPLv3 payload/darwin/bin/ffmpeg-COPYING.LGPLv3
chmod +x payload/darwin/bin/ffmpeg payload/darwin/bin/ffprobe chmod +x payload/darwin/bin/ffmpeg payload/darwin/bin/ffprobe
$(call copy_bundled_data,payload/darwin/data) cp -R server/data/* payload/darwin/data/
cp -R server/scripts/* payload/darwin/scripts/ cp -R server/scripts/* payload/darwin/scripts/
cp vendor/python/darwin/python.tar.gz payload/darwin/python/ 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 @cp vendor/wheels/darwin/*.whl payload/darwin/wheels/ 2>/dev/null || true
@echo "==> macOS payload 完成:" @echo "==> macOS payload 完成:"
@du -sh payload/darwin @du -sh payload/darwin
@ -437,7 +287,7 @@ vendor-python-windows: ## 下載 python-build-standalone Windows x86_64 → vend
fi fi
vendor-wheels-windows: ## 同步 Windows wheels → vendor/wheels/windows/ vendor-wheels-windows: ## 同步 Windows wheels → vendor/wheels/windows/
$(call clean_wheels_dir,vendor/wheels/windows) @mkdir -p vendor/wheels/windows
@echo "==> 同步內部 wheels (Windows, KneronPLUS 等)..." @echo "==> 同步內部 wheels (Windows, KneronPLUS 等)..."
@if [ -d visiona-local/wheels/windows ]; then \ @if [ -d visiona-local/wheels/windows ]; then \
cp visiona-local/wheels/windows/*.whl vendor/wheels/windows/ 2>/dev/null || true; \ cp visiona-local/wheels/windows/*.whl vendor/wheels/windows/ 2>/dev/null || true; \
@ -510,13 +360,9 @@ payload-windows: build-server-windows vendor-python-windows vendor-wheels-window
@# LGPL 授權條款BtbN build 自帶 LICENSE.txtCOPYING.LGPLv3 不一定在壓縮檔內,失敗不致命) @# LGPL 授權條款BtbN build 自帶 LICENSE.txtCOPYING.LGPLv3 不一定在壓縮檔內,失敗不致命)
@cp vendor/ffmpeg/windows/LICENSE.txt payload/windows/bin/ffmpeg-LICENSE.txt 2>/dev/null || true @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 vendor/ffmpeg/windows/COPYING.LGPLv3 payload/windows/bin/ffmpeg-COPYING.LGPLv3 2>/dev/null || true
$(call copy_bundled_data,payload/windows/data) cp -R server/data/. payload/windows/data/
cp -R server/scripts/. payload/windows/scripts/ cp -R server/scripts/. payload/windows/scripts/
cp vendor/python/windows/python.tar.gz payload/windows/python/ cp vendor/python/windows/python.tar.gz payload/windows/python/
@# 先清空再複製payload-windows 刻意不 rm -rf payload/windowsserver.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 @cp vendor/wheels/windows/*.whl payload/windows/wheels/ 2>/dev/null || true
@echo "==> Windows payload 完成:" @echo "==> Windows payload 完成:"
@du -sh payload/windows @du -sh payload/windows
@ -551,7 +397,7 @@ vendor-python-linux: ## 下載 python-build-standalone Linux x86_64 → vendor/p
fi fi
vendor-wheels-linux: ## 同步 Linux wheels → vendor/wheels/linux/ vendor-wheels-linux: ## 同步 Linux wheels → vendor/wheels/linux/
$(call clean_wheels_dir,vendor/wheels/linux) @mkdir -p vendor/wheels/linux
@echo "==> 同步內部 wheels (Linux, KneronPLUS 等)..." @echo "==> 同步內部 wheels (Linux, KneronPLUS 等)..."
@if [ -d visiona-local/wheels/linux ]; then \ @if [ -d visiona-local/wheels/linux ]; then \
cp visiona-local/wheels/linux/*.whl vendor/wheels/linux/ 2>/dev/null || true; \ cp visiona-local/wheels/linux/*.whl vendor/wheels/linux/ 2>/dev/null || true; \
@ -598,15 +444,13 @@ 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/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/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 @cp vendor/ffmpeg/linux/LICENSE.txt payload/linux/bin/ffmpeg-LICENSE.txt 2>/dev/null || true
$(call copy_bundled_data,payload/linux/data) @if [ -d server/data ]; then cp -R server/data/. payload/linux/data/; fi
@if [ -d server/scripts ]; then cp -R server/scripts/. payload/linux/scripts/; fi @if [ -d server/scripts ]; then cp -R server/scripts/. payload/linux/scripts/; fi
@if [ ! -f vendor/python/linux/python.tar.gz ]; then \ @if [ ! -f vendor/python/linux/python.tar.gz ]; then \
echo "!! ERROR: vendor/python/linux/python.tar.gz 不存在vendor-python-linux 應該已先跑過 !!"; \ echo "!! ERROR: vendor/python/linux/python.tar.gz 不存在vendor-python-linux 應該已先跑過 !!"; \
exit 1; \ exit 1; \
fi fi
@cp vendor/python/linux/python.tar.gz payload/linux/python/ @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 @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); \ @wheel_count=$$(ls -1 payload/linux/wheels/*.whl 2>/dev/null | wc -l); \
if [ "$$wheel_count" -lt 4 ]; then \ if [ "$$wheel_count" -lt 4 ]; then \

View File

@ -1,371 +0,0 @@
# 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` 觸發 flashload 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 L1401M7 同型坑的前身)。
---
### §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.2Windows不再提供 2.0.0macOS的 `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.mdM4 之後的 fix
---
### §7 健康檢查阻斷啟動(自造 regression→ 健康的 venv 被判壞、擋住啟動
**症狀**
- 環境明明是好的bridge 實際能跑、能掃到裝置app 卻因「Python 相依驗證失敗」擋住啟動。
**根因(這是修 §6 時自己造成的 regression**
- §6 的健康檢查 probe 裸跑 `python -c "import kp"`**沒有補 Windows 的 DLL 搜尋路徑**。
- `kp` 在 import 時就會 `ctypes.CDLL` 載入 `kp/lib` 下的 native DLLlibkplus / 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 dashU+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.2shape 搬進巢狀 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 「檔案存在」≠「可用」
§6venv 半套安裝)和 §7健康檢查都栽在這。
- venv 只檢查 `python.exe` 存在就跳過安裝 → 半套環境永久卡死。
- 狀態檢查要驗**「真的能用」**(實際 import / 實際跑一次),而非「檔案在」。
- 但驗「能用」時要注意 §7 的教訓:驗證環境要貼近真實執行環境,否則會誤殺健康的環境;而且**健康檢查不能是啟動阻斷點**。
### 10.3 錯誤被吞掉會讓 debug 成本爆炸
§8wheels 多版本)的三輪來回,根因是 pip 錯誤被 Makefile 的 `|| echo WARN` 分支吞掉,拿不到真正的錯誤訊息。
- 吞錯誤 = 把「一次就能定位」變成「三輪還在猜」。
- fail-loud 優於 fail-silent。§4 的後置檢查(數 .nef 個數不符就直接 fail就是正面教材——它讓「安裝包偷偷多帶模型」在 build 期就爆,而不是等出貨後使用者才發現。
- 對照 §5input size 錯掉時 NPU **不報錯只給錯結果**——這種「靜默失敗」最貴,所以要靠 log 印出 source 當唯一線索。
### 10.4 「湊巧正確」的行為改動要格外小心
§5input size 優先序)是經典案例:
- 舊版用檔名猜測,某些模型湊巧猜到 224、剛好對。
- 改成「看起來更可信」的 declared使用者手填值反而錯——因為使用者是隨手填的。
- **教訓**當你把一個「能動但你覺得不夠嚴謹」的邏輯改成「更正確」的版本時先確認新來源真的更可信。「更正式的欄位」不代表「更可信的值」——declared 是正式欄位、但值是垃圾。
### 10.5 兩層防護(源頭 + 救援)
§8 的解法是兩層都做:`clean_wheels_dir` 從源頭杜絕(新安裝包乾淨)+ `selectLatestWheelPerPackage` 救援(舊安裝包自癒)。
- 只做源頭 → 已出貨的舊安裝包救不了。
- 只做救援 → 每個新安裝包都帶著髒 vendor 出貨、依賴 app 端補救。
- 涉及「已出貨產物」的問題,通常源頭與救援都要有。
---
## §附錄 AKneronPLUS 版本相容性
三平台的 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 unionDLL 需求更嚴 |
**具體差異與注意事項**
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 DLLlibkplus / 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 驗,或明確寫成「先試新版結構、失敗再退舊版」的相容寫法。
---
## §附錄 BBuild 完驗收 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` — 開發模式資源同步坑。

View File

@ -8,7 +8,6 @@ import { InferencePanel } from '@/components/inference/inference-panel';
import { FlashDialog } from '@/components/devices/flash-dialog'; import { FlashDialog } from '@/components/devices/flash-dialog';
import { useDeviceStore } from '@/stores/device-store'; import { useDeviceStore } from '@/stores/device-store';
import { useInferenceStore } from '@/stores/inference-store'; import { useInferenceStore } from '@/stores/inference-store';
import { useInferenceOptionsStore } from '@/stores/inference-options-store';
import { useInferenceStream } from '@/hooks/use-inference-stream'; import { useInferenceStream } from '@/hooks/use-inference-stream';
import { useCameraStore } from '@/stores/camera-store'; import { useCameraStore } from '@/stores/camera-store';
import { useResolvedParams } from '@/hooks/use-resolved-params'; import { useResolvedParams } from '@/hooks/use-resolved-params';
@ -18,7 +17,6 @@ export default function WorkspaceClient() {
const { deviceId } = useResolvedParams(); const { deviceId } = useResolvedParams();
const { selectedDevice, fetchDevice } = useDeviceStore(); const { selectedDevice, fetchDevice } = useDeviceStore();
const { isRunning, setRunning, reset } = useInferenceStore(); const { isRunning, setRunning, reset } = useInferenceStore();
const resetInferenceOptions = useInferenceOptionsStore((s) => s.reset);
const { isStreaming, sourceType } = useCameraStore(); const { isStreaming, sourceType } = useCameraStore();
// For image/video mode, inference runs automatically as part of the pipeline // For image/video mode, inference runs automatically as part of the pipeline
@ -43,11 +41,8 @@ export default function WorkspaceClient() {
} }
return () => { return () => {
reset(); 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, resetInferenceOptions]); }, [deviceId, fetchDevice, fetchCameras, reset]);
const handleStartInference = async () => { const handleStartInference = async () => {
await api.post(`/devices/${deviceId}/inference/start`); await api.post(`/devices/${deviceId}/inference/start`);
@ -94,7 +89,7 @@ export default function WorkspaceClient() {
<CameraInferenceView deviceId={deviceId} /> <CameraInferenceView deviceId={deviceId} />
</div> </div>
<div className="w-80 shrink-0"> <div className="w-80 shrink-0">
<InferencePanel deviceId={deviceId} /> <InferencePanel />
</div> </div>
</div> </div>
</div> </div>

View File

@ -2,7 +2,7 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { CameraFeed } from './camera-feed'; import { CameraFeed } from './camera-feed';
import { InferenceOverlay } from './inference-overlay'; import { CameraOverlay } from './camera-overlay';
import { SourceSelector } from './source-selector'; import { SourceSelector } from './source-selector';
import { BatchImageThumbnails } from './batch-image-thumbnails'; import { BatchImageThumbnails } from './batch-image-thumbnails';
import { useCameraStore } from '@/stores/camera-store'; import { useCameraStore } from '@/stores/camera-store';
@ -25,10 +25,11 @@ export function CameraInferenceView({ deviceId }: CameraInferenceViewProps) {
setRenderedSize((prev) => (prev && prev.w === w && prev.h === h ? prev : { w, h })); setRenderedSize((prev) => (prev && prev.w === w && prev.h === h ? prev : { w, h }));
}, []); }, []);
// In batch mode, show the selected image's result // In batch mode, show the selected image's detections
const selectedResult = isBatchMode const selectedResult = isBatchMode
? batchResults[batchSelectedIndex] ? batchResults[batchSelectedIndex]
: result; : result;
const detections = selectedResult?.detections || [];
// In batch mode, use static image endpoint for viewing selected image // In batch mode, use static image endpoint for viewing selected image
const batchImageUrl = isBatchMode const batchImageUrl = isBatchMode
@ -47,8 +48,8 @@ export function CameraInferenceView({ deviceId }: CameraInferenceViewProps) {
onDimensionsChange={handleDimensionsChange} onDimensionsChange={handleDimensionsChange}
overlay={ overlay={
isStreaming && renderedSize ? ( isStreaming && renderedSize ? (
<InferenceOverlay <CameraOverlay
result={selectedResult} detections={detections}
width={renderedSize.w} width={renderedSize.w}
height={renderedSize.h} height={renderedSize.h}
confidenceThreshold={confidenceThreshold} confidenceThreshold={confidenceThreshold}

View File

@ -25,6 +25,16 @@ export function CameraOverlay({ detections, width, height, confidenceThreshold }
const filtered = detections.filter((d) => d.confidence >= 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) => { filtered.forEach((det, i) => {
const color = COLORS[i % COLORS.length]; const color = COLORS[i % COLORS.length];
// Convert normalized coordinates (0-1) to pixel values // Convert normalized coordinates (0-1) to pixel values
@ -58,7 +68,6 @@ export function CameraOverlay({ detections, width, height, confidenceThreshold }
height={height} height={height}
style={{ width, height }} style={{ width, height }}
className="absolute left-0 top-0 pointer-events-none" className="absolute left-0 top-0 pointer-events-none"
data-testid="camera-overlay"
/> />
); );
} }

View File

@ -1,67 +0,0 @@
'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>
);
}

View File

@ -1,53 +0,0 @@
'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}
/>
);
}

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -39,16 +39,11 @@ export function FlashDialog({ deviceId }: FlashDialogProps) {
const device = devices.find((d) => d.id === deviceId); const device = devices.find((d) => d.id === deviceId);
const selectedModel = models.find((m) => m.id === selectedModelId); const selectedModel = models.find((m) => m.id === selectedModelId);
// 載入模型時不選推論種類解析方式改由推論頁InferenceOptions即時切換 // S2: 資料載入前預設 compatible=true避免在 model/device 還沒載入時就顯示不相容警告
// 那裡不需要重燒就能改,功能完全涵蓋燒錄時選一次的舊做法。 const compatible = useMemo(() => {
if (!selectedModel || !device) return true;
// S2: 資料載入前預設 compatible=true避免在 model/device 還沒載入時就顯示不相容警告。 return isModelCompatible(selectedModel.supportedHardware, device.type);
// 這裡不再包 useMemo —— isModelCompatible 只是一次陣列比對,手動 memo 反而讓 }, [selectedModel, device]);
// React Compiler 整個元件跳過優化react-hooks/preserve-manual-memoization
const compatible =
!selectedModel || !device
? true
: isModelCompatible(selectedModel.supportedHardware, device.type);
useEffect(() => { useEffect(() => {
if (open) { if (open) {

View File

@ -2,7 +2,6 @@
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Cell } from 'recharts'; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Cell } from 'recharts';
import type { ClassResult } from '@/types/inference'; import type { ClassResult } from '@/types/inference';
import { classResultLabel } from '@/lib/classification';
import { useTranslation } from '@/lib/i18n'; import { useTranslation } from '@/lib/i18n';
interface ClassificationResultProps { interface ClassificationResultProps {
@ -19,10 +18,8 @@ export function ClassificationResult({ results, confidenceThreshold }: Classific
.sort((a, b) => b.confidence - a.confidence) .sort((a, b) => b.confidence - a.confidence)
.slice(0, 8); .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) => ({ const data = filtered.map((r) => ({
label: classResultLabel(r), label: r.label,
confidence: +(r.confidence * 100).toFixed(1), confidence: +(r.confidence * 100).toFixed(1),
})); }));
@ -35,19 +32,17 @@ export function ClassificationResult({ results, confidenceThreshold }: Classific
} }
return ( return (
<div data-testid="classification-result-chart"> <ResponsiveContainer width="100%" height={250}>
<ResponsiveContainer width="100%" height={250}> <BarChart data={data} layout="vertical" margin={{ left: 80, right: 20, top: 5, bottom: 5 }}>
<BarChart data={data} layout="vertical" margin={{ left: 80, right: 20, top: 5, bottom: 5 }}> <CartesianGrid strokeDasharray="3 3" />
<CartesianGrid strokeDasharray="3 3" /> <XAxis type="number" domain={[0, 100]} unit="%" />
<XAxis type="number" domain={[0, 100]} unit="%" /> <YAxis type="category" dataKey="label" width={70} fontSize={12} />
<YAxis type="category" dataKey="label" width={70} fontSize={12} /> <Bar dataKey="confidence" radius={[0, 4, 4, 0]}>
<Bar dataKey="confidence" radius={[0, 4, 4, 0]}> {data.map((_, index) => (
{data.map((_, index) => ( <Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} /> ))}
))} </Bar>
</Bar> </BarChart>
</BarChart> </ResponsiveContainer>
</ResponsiveContainer>
</div>
); );
} }

View File

@ -1,54 +0,0 @@
'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>
);
}

View File

@ -1,227 +0,0 @@
'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>
);
}

View File

@ -2,21 +2,14 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ClassificationResult } from './classification-result'; import { ClassificationResult } from './classification-result';
import { DetectionResultList } from './detection-result';
import { PerformanceMetrics } from './performance-metrics'; import { PerformanceMetrics } from './performance-metrics';
import { ConfidenceSlider } from './confidence-slider'; import { ConfidenceSlider } from './confidence-slider';
import { VideoProgress } from './video-progress'; import { VideoProgress } from './video-progress';
import { InferenceOptions } from './inference-options';
import { useInferenceStore } from '@/stores/inference-store'; import { useInferenceStore } from '@/stores/inference-store';
import { useCameraStore } from '@/stores/camera-store'; import { useCameraStore } from '@/stores/camera-store';
import { isClassificationTask } from '@/lib/classification';
import { useTranslation } from '@/lib/i18n'; import { useTranslation } from '@/lib/i18n';
interface InferencePanelProps { export function InferencePanel() {
deviceId: string;
}
export function InferencePanel({ deviceId }: InferencePanelProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { result, fps, avgLatency, isRunning, confidenceThreshold, batchResults } = const { result, fps, avgLatency, isRunning, confidenceThreshold, batchResults } =
useInferenceStore(); useInferenceStore();
@ -29,10 +22,6 @@ export function InferencePanel({ deviceId }: InferencePanelProps) {
? batchResults[batchSelectedIndex] ? batchResults[batchSelectedIndex]
: result; : result;
const classifications = displayResult?.classifications || []; 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 ( return (
<div className="w-80 space-y-4"> <div className="w-80 space-y-4">
@ -73,33 +62,13 @@ export function InferencePanel({ deviceId }: InferencePanelProps) {
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm">{t('inference.options.title')}</CardTitle> <CardTitle className="text-sm">{t('inference.classificationResults')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<InferenceOptions deviceId={deviceId} resultTaskType={displayResult?.taskType} /> <ClassificationResult
</CardContent> results={classifications}
</Card> confidenceThreshold={confidenceThreshold}
/>
<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> </CardContent>
</Card> </Card>

View File

@ -1,72 +0,0 @@
'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;
}

View File

@ -1,22 +1,12 @@
import { getApiBaseUrl, getRelayToken, fetchAndCacheRelayToken } from './constants'; import { getApiBaseUrl, getRelayToken, fetchAndCacheRelayToken } from './constants';
export interface ApiError { export interface ApiResponse<T> {
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; success: boolean;
data?: T; data?: T;
error?: E; error?: {
code: string;
message: string;
};
} }
// Ensure relay token is available before making API requests. // Ensure relay token is available before making API requests.
@ -50,10 +40,7 @@ function buildHeaders(): Record<string, string> {
}; };
} }
async function request<T, E extends ApiError = ApiError>( async function request<T>(path: string, options?: RequestInit): Promise<ApiResponse<T>> {
path: string,
options?: RequestInit,
): Promise<ApiResponse<T, E>> {
// Wait for relay token to be available before first request // Wait for relay token to be available before first request
await ensureRelayToken(); await ensureRelayToken();
@ -64,32 +51,10 @@ async function request<T, E extends ApiError = ApiError>(
return res.json(); 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 = { export const api = {
get: <T>(path: string) => request<T>(path), get: <T>(path: string) => request<T>(path),
post: <T, E extends ApiError = ApiError>(path: string, body?: unknown) => post: <T>(path: string, body?: unknown) =>
request<T, E>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }), request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
postForm,
put: <T>(path: string, body?: unknown) => put: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }), request<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
del: <T>(path: string) => request<T>(path, { method: 'DELETE' }), del: <T>(path: string) => request<T>(path, { method: 'DELETE' }),

View File

@ -1,146 +0,0 @@
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 };
}

View File

@ -125,12 +125,6 @@ export const en: TranslationDict = {
flashFailed: 'Flash Failed', flashFailed: 'Flash Failed',
preparingFlash: 'Preparing flash...', preparingFlash: 'Preparing flash...',
flashComplete: 'Flash complete!', 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: { card: {
fwBadge: { fwBadge: {
@ -240,9 +234,6 @@ export const en: TranslationDict = {
confidenceFilter: 'Confidence Filter', confidenceFilter: 'Confidence Filter',
confidenceThreshold: 'Confidence Threshold', confidenceThreshold: 'Confidence Threshold',
classificationResults: 'Classification Results', classificationResults: 'Classification Results',
detectionResults: 'Detection Results',
detectedCount: 'Detected',
unrecognized: 'Unrecognized',
noResultsAboveThreshold: 'No results above threshold', noResultsAboveThreshold: 'No results above threshold',
details: 'Details', details: 'Details',
model: 'Model', model: 'Model',
@ -259,27 +250,6 @@ export const en: TranslationDict = {
videoProgress: 'Video Progress', videoProgress: 'Video Progress',
frames: 'frames', frames: 'frames',
framesProcessed: 'Frames Processed', 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: { settings: {
title: 'Settings', title: 'Settings',

View File

@ -123,10 +123,6 @@ export interface TranslationDict {
flashFailed: string; flashFailed: string;
preparingFlash: string; preparingFlash: string;
flashComplete: string; flashComplete: string;
selectModelFirst: string;
// Shared with the inference page's InferenceOptions selector.
taskTypeObjectDetection: string;
taskTypeClassification: string;
}; };
card: { card: {
fwBadge: { fwBadge: {
@ -236,9 +232,6 @@ export interface TranslationDict {
confidenceFilter: string; confidenceFilter: string;
confidenceThreshold: string; confidenceThreshold: string;
classificationResults: string; classificationResults: string;
detectionResults: string;
detectedCount: string;
unrecognized: string;
noResultsAboveThreshold: string; noResultsAboveThreshold: string;
details: string; details: string;
model: string; model: string;
@ -255,25 +248,6 @@ export interface TranslationDict {
videoProgress: string; videoProgress: string;
frames: string; frames: string;
framesProcessed: 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: { settings: {
title: string; title: string;

View File

@ -125,11 +125,6 @@ export const zhTW: TranslationDict = {
flashFailed: '燒錄失敗', flashFailed: '燒錄失敗',
preparingFlash: '準備燒錄中...', preparingFlash: '準備燒錄中...',
flashComplete: '燒錄完成!', flashComplete: '燒錄完成!',
selectModelFirst: '請先選擇模型',
// 推論種類的顯示名稱。燒錄對話框已不再選推論種類,但推論頁的
// InferenceOptions 仍共用這兩個標籤,故保留。
taskTypeObjectDetection: '物件偵測',
taskTypeClassification: '分類',
}, },
card: { card: {
fwBadge: { fwBadge: {
@ -239,9 +234,6 @@ export const zhTW: TranslationDict = {
confidenceFilter: '信心度篩選', confidenceFilter: '信心度篩選',
confidenceThreshold: '信心度門檻', confidenceThreshold: '信心度門檻',
classificationResults: '分類結果', classificationResults: '分類結果',
detectionResults: '偵測結果',
detectedCount: '偵測數量',
unrecognized: '無法辨識',
noResultsAboveThreshold: '沒有超過門檻的結果', noResultsAboveThreshold: '沒有超過門檻的結果',
details: '詳細資訊', details: '詳細資訊',
model: '模型', model: '模型',
@ -258,25 +250,6 @@ export const zhTW: TranslationDict = {
videoProgress: '影片進度', videoProgress: '影片進度',
frames: '幀', frames: '幀',
framesProcessed: '已處理幀數', 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: { settings: {
title: '設定', title: '設定',

View File

@ -1,29 +0,0 @@
/**
* 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';
}

View File

@ -1,198 +0,0 @@
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 };
}

View File

@ -1,54 +0,0 @@
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();
});
});

View File

@ -1,191 +0,0 @@
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' });
});
});

View File

@ -1,345 +0,0 @@
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());
});
});

View File

@ -1,109 +0,0 @@
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('石頭');
});
});

View File

@ -1,87 +0,0 @@
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();
});
});

View File

@ -1,179 +0,0 @@
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();
});
});

View File

@ -6,31 +6,6 @@ afterEach(() => {
cleanup(); 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', { Object.defineProperty(window, 'matchMedia', {
writable: true, writable: true,
value: vi.fn().mockImplementation((query: string) => ({ value: vi.fn().mockImplementation((query: string) => ({

View File

@ -1,169 +0,0 @@
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 });
});
});

View File

@ -8,11 +8,6 @@ export interface BBox {
export interface ClassResult { export interface ClassResult {
label: string; label: string;
confidence: number; 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 { export interface DetectionResult {

View File

@ -104,108 +104,6 @@ function Convert-ToMsysPath($winPath) {
} }
$msysPython = Convert-ToMsysPath $realPython $msysPython = Convert-ToMsysPath $realPython
# ── 偵測 Go / wails / node / pnpm 的實際安裝目錄 ────────────────────
# 為什麼需要這段:
# $env:MSYS2_PATH_TYPE = 'inherit' 只影響 MSYS2 自己的 shell 啟動器
# msys2.exe / mingw64.exe直接呼叫 bash.exe -l 時 /etc/profile 會
# 重建 PATHWindows 的 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
}
# Gowinget 預設 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"
# wailsgo 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"
# nodeMakefile 的 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"
# pnpmnpm 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) # 找 Inno Setup Compiler (ISCC.exe)
# 重要:檔名是 ISCC.exe大寫winget 裝在 Program Files (x86) 不在 PATH 裡 # 重要:檔名是 ISCC.exe大寫winget 裝在 Program Files (x86) 不在 PATH 裡
function Find-Iscc { function Find-Iscc {
@ -280,16 +178,6 @@ $bashParts = @(
"cd '$msysPath'", "cd '$msysPath'",
"export VISIONA_PYTHON='$msysPython'" "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) { if ($msysIsccDir) {
$bashParts += "export PATH=`"$msysIsccDir`":`$PATH" $bashParts += "export PATH=`"$msysIsccDir`":`$PATH"
} }

View File

@ -40,8 +40,8 @@
}, },
{ {
"id": "kl520-fcos-detection", "id": "kl520-fcos-detection",
"name": "物件辨識", "name": "FCOS Detection (KL520)",
"description": "通用物件偵測模型,可辨識人、車輛、動物等常見物件,適合一般場景的多物件偵測。", "description": "FCOS (Fully Convolutional One-Stage) object detection with DarkNet53s backbone, compiled for KL520. Anchor-free detection at 512x512.",
"thumbnail": "/images/models/fcos-det.png", "thumbnail": "/images/models/fcos-det.png",
"taskType": "object_detection", "taskType": "object_detection",
"categories": [ "categories": [
@ -109,8 +109,8 @@
}, },
{ {
"id": "kl520-tiny-yolov3", "id": "kl520-tiny-yolov3",
"name": "人型監測", "name": "Tiny YOLOv3 (KL520)",
"description": "輕量快速的人員偵測模型,適合即時監控場景,可在邊緣裝置上高速偵測畫面中的人員。", "description": "Tiny YOLOv3 object detection model compiled for KL520. Compact and fast model for general-purpose multi-object detection on edge devices.",
"thumbnail": "/images/models/tiny-yolov3.png", "thumbnail": "/images/models/tiny-yolov3.png",
"taskType": "object_detection", "taskType": "object_detection",
"categories": [ "categories": [

View File

@ -2,14 +2,9 @@ package handlers
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io"
"net/http"
"os" "os"
"path/filepath"
"runtime" "runtime"
"strings"
"time" "time"
"visiona-local/server/internal/api/ws" "visiona-local/server/internal/api/ws"
@ -17,7 +12,6 @@ import (
"visiona-local/server/internal/driver" "visiona-local/server/internal/driver"
"visiona-local/server/internal/flash" "visiona-local/server/internal/flash"
"visiona-local/server/internal/inference" "visiona-local/server/internal/inference"
"visiona-local/server/internal/labelfile"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@ -181,8 +175,6 @@ func (h *DeviceHandler) DisconnectDevice(c *gin.Context) {
func (h *DeviceHandler) FlashDevice(c *gin.Context) { func (h *DeviceHandler) FlashDevice(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
// 燒錄只需要 modelId。推論種類一律用 models.json 宣告的值;要改解析方式
// 走 POST /devices/:id/inference/options推論期即時切換、不必重燒
var req struct { var req struct {
ModelID string `json:"modelId"` ModelID string `json:"modelId"`
} }
@ -215,289 +207,6 @@ func (h *DeviceHandler) FlashDevice(c *gin.Context) {
c.JSON(200, gin.H{"success": true, "data": gin.H{"taskId": taskID}}) 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 sessiondisconnect / 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) { func (h *DeviceHandler) StartInference(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
resultCh := make(chan *driver.InferenceResult, 10) resultCh := make(chan *driver.InferenceResult, 10)

View File

@ -1,500 +0,0 @@
package handlers
// device_inference_options_test.go — M4POST /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 bodylabelFileName 為空表示不帶檔案)。
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 具體型別而非自己寫一個滿足介面的 stubstub 只證明「我寫的
// 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
}

View File

@ -125,9 +125,7 @@ func (f *fakeDriver) Info() driver.DeviceInfo { return f.in
func (f *fakeDriver) Connect() error { return nil } func (f *fakeDriver) Connect() error { return nil }
func (f *fakeDriver) Disconnect() error { return nil } func (f *fakeDriver) Disconnect() error { return nil }
func (f *fakeDriver) IsConnected() bool { return false } func (f *fakeDriver) IsConnected() bool { return false }
func (f *fakeDriver) Flash(_ string, _ driver.FlashOptions, _ chan<- driver.FlashProgress) error { func (f *fakeDriver) Flash(_ string, _ chan<- driver.FlashProgress) error { return nil }
return nil
}
func (f *fakeDriver) StartInference() error { return nil } func (f *fakeDriver) StartInference() error { return nil }
func (f *fakeDriver) StopInference() error { return nil } func (f *fakeDriver) StopInference() error { return nil }
func (f *fakeDriver) ReadInference() (*driver.InferenceResult, error) { func (f *fakeDriver) ReadInference() (*driver.InferenceResult, error) {

View File

@ -95,9 +95,6 @@ func NewRouter(
api.POST("/devices/:id/flash", deviceHandler.FlashDevice) api.POST("/devices/:id/flash", deviceHandler.FlashDevice)
api.POST("/devices/:id/inference/start", deviceHandler.StartInference) api.POST("/devices/:id/inference/start", deviceHandler.StartInference)
api.POST("/devices/:id/inference/stop", deviceHandler.StopInference) 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 階段) // Firmware (M9-3、A 階段)
// upgrade endpoint 走 202 + WebSocket room "firmware:<id>" 推進度。 // upgrade endpoint 走 202 + WebSocket room "firmware:<id>" 推進度。

View File

@ -15,9 +15,7 @@ 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) 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) Disconnect() error { d.connected = false; d.info.Status = driver.StatusDisconnected; return nil }
func (d *testDriver) IsConnected() bool { return d.connected } func (d *testDriver) IsConnected() bool { return d.connected }
func (d *testDriver) Flash(_ string, _ driver.FlashOptions, _ chan<- driver.FlashProgress) error { func (d *testDriver) Flash(_ string, _ chan<- driver.FlashProgress) error { return nil }
return nil
}
func (d *testDriver) StartInference() error { return nil } func (d *testDriver) StartInference() error { return nil }
func (d *testDriver) StopInference() error { return nil } func (d *testDriver) StopInference() error { return nil }
func (d *testDriver) ReadInference() (*driver.InferenceResult, error) { return nil, nil } func (d *testDriver) ReadInference() (*driver.InferenceResult, error) { return nil, nil }

View File

@ -7,7 +7,7 @@ type DeviceDriver interface {
Connect() error Connect() error
Disconnect() error Disconnect() error
IsConnected() bool IsConnected() bool
Flash(modelPath string, opts FlashOptions, progressCh chan<- FlashProgress) error Flash(modelPath string, progressCh chan<- FlashProgress) error
StartInference() error StartInference() error
StopInference() error StopInference() error
ReadInference() (*InferenceResult, error) ReadInference() (*InferenceResult, error)
@ -39,53 +39,6 @@ const (
StatusDisconnected DeviceStatus = "disconnected" 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 輸出原始 enumclass_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 表,回到原始 enumclass_N
//
// ⚠️ 因此 Labels 的判斷必須用 `!= nil` 而非 `len() > 0` —— 用長度判斷會讓
// 「清空」這個合法意圖永遠送不出去。
type InferenceOptions struct {
TaskType string
Labels []string
}
type FlashProgress struct { type FlashProgress struct {
Percent int `json:"percent"` Percent int `json:"percent"`
Stage string `json:"stage"` Stage string `json:"stage"`
@ -115,9 +68,6 @@ type InferenceResult struct {
type ClassResult struct { type ClassResult struct {
Label string `json:"label"` Label string `json:"label"`
Confidence float64 `json:"confidence"` Confidence float64 `json:"confidence"`
// ClassIndex 是模型輸出的原始類別索引,供前端在 label 缺漏時 fallback 顯示。
// 刻意不加 omitempty —— index 0 是合法類別omitempty 會把它吃掉。
ClassIndex int `json:"classIndex"`
} }
type DetectionResult struct { type DetectionResult struct {

View File

@ -123,26 +123,8 @@ func (d *KneronDriver) startPython() error {
// On macOS with Apple Silicon, Kneron SDK requires x86_64 (Rosetta 2). // On macOS with Apple Silicon, Kneron SDK requires x86_64 (Rosetta 2).
// The venv should already contain the correct architecture Python. // The venv should already contain the correct architecture Python.
// Set DYLD_LIBRARY_PATH so libkplus.dylib can be found. // Set DYLD_LIBRARY_PATH so libkplus.dylib can be found.
// PYTHONUTF8=1 啟用 Python UTF-8 ModePEP 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(), cmd.Env = append(os.Environ(),
"PYTHONUNBUFFERED=1", "PYTHONUNBUFFERED=1",
"PYTHONUTF8=1",
) )
// Add library path for native kp module if lib directory exists. // Add library path for native kp module if lib directory exists.
@ -526,37 +508,6 @@ func (d *KneronDriver) restartBridge() error {
return nil return nil
} }
// buildLoadModelCommand 組出 load_model 的 JSON-RPC payload。
//
// ⚠️ Flash 有四處 load_model 呼叫點(初次 + 三條 retry 路徑)。四處都必須走這個
// helper —— 如果任一處自己手寫 mapretry 成功後 task_type / labels 會遺失,
// 而且不會報錯bridge 會 fallback 到檔名猜測classification model 被誤判成
// YOLO 只會回空結果)。這種失敗完全靜默,所以刻意集中在單一建構點。
//
// 空值欄位不放進 payloadbridge 端把「缺欄位」與「空值」都當成未指定,
// 但少送欄位可讓 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 // Flash loads a model onto the Kneron device. Progress is reported through
// the provided channel. // the provided channel.
// //
@ -565,10 +516,7 @@ func buildLoadModelCommand(modelPath string, opts driver.FlashOptions) map[strin
// a full device reset + bridge restart + firmware reload. // a full device reset + bridge restart + firmware reload.
// - KL720 (flash-based): models can be freely reloaded. Error 40 // - KL720 (flash-based): models can be freely reloaded. Error 40
// should not occur; if it does, a simple retry is attempted first. // 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 metadatataskType / labels會隨每一次
// load_model 送給 Python bridge —— 包含所有 retry 路徑。
func (d *KneronDriver) Flash(modelPath string, opts driver.FlashOptions, progressCh chan<- driver.FlashProgress) error {
d.mu.Lock() d.mu.Lock()
d.info.Status = driver.StatusFlashing d.info.Status = driver.StatusFlashing
pythonReady := d.pythonReady pythonReady := d.pythonReady
@ -606,7 +554,10 @@ func (d *KneronDriver) Flash(modelPath string, opts driver.FlashOptions, progres
} }
d.mu.Lock() d.mu.Lock()
_, err := d.sendCommand(buildLoadModelCommand(modelPath, opts)) _, err := d.sendCommand(map[string]interface{}{
"cmd": "load_model",
"path": modelPath,
})
d.mu.Unlock() d.mu.Unlock()
// Handle retryable errors (error 40, broken pipe). // Handle retryable errors (error 40, broken pipe).
@ -631,7 +582,10 @@ func (d *KneronDriver) Flash(modelPath string, opts driver.FlashOptions, progres
} }
d.mu.Lock() d.mu.Lock()
_, err = d.sendCommand(buildLoadModelCommand(modelPath, opts)) _, err = d.sendCommand(map[string]interface{}{
"cmd": "load_model",
"path": modelPath,
})
d.mu.Unlock() d.mu.Unlock()
// If still failing, fall back to bridge restart as last resort. // If still failing, fall back to bridge restart as last resort.
@ -645,7 +599,10 @@ func (d *KneronDriver) Flash(modelPath string, opts driver.FlashOptions, progres
} }
d.mu.Lock() d.mu.Lock()
d.info.Status = driver.StatusFlashing d.info.Status = driver.StatusFlashing
_, err = d.sendCommand(buildLoadModelCommand(modelPath, opts)) _, err = d.sendCommand(map[string]interface{}{
"cmd": "load_model",
"path": modelPath,
})
d.mu.Unlock() d.mu.Unlock()
} }
} else { } else {
@ -669,7 +626,10 @@ func (d *KneronDriver) Flash(modelPath string, opts driver.FlashOptions, progres
d.driverLog("INFO", "[kneron] bridge restarted, retrying load_model...") d.driverLog("INFO", "[kneron] bridge restarted, retrying load_model...")
d.mu.Lock() d.mu.Lock()
d.info.Status = driver.StatusFlashing d.info.Status = driver.StatusFlashing
_, err = d.sendCommand(buildLoadModelCommand(modelPath, opts)) _, err = d.sendCommand(map[string]interface{}{
"cmd": "load_model",
"path": modelPath,
})
d.mu.Unlock() d.mu.Unlock()
} }
} }
@ -737,58 +697,6 @@ func (d *KneronDriver) Flash(modelPath string, opts driver.FlashOptions, progres
return nil 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. // StartInference begins continuous inference mode.
func (d *KneronDriver) StartInference() error { func (d *KneronDriver) StartInference() error {
d.mu.Lock() d.mu.Lock()

View File

@ -1,313 +0,0 @@
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_EmptyLabelsOmittedlabels 為 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_SerializesToBridgeContractpayload 經 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 呼叫點 = %dwant %d "+
"(初次 + KL720 retry + KL720 restart retry + KL520 restart retry)。"+
"若確實新增/移除了 retry 路徑,請確認新路徑有帶 opts 後再更新此數字",
builderCalls, wantCallSites)
}
}
// TestParseInferenceResult_ClassIndexPreservedbridge 回傳的 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_ClassIndexZeroNotOmittedindex 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_ZeroInputSizeOmittedmodels.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_InputSizeSerializesToBridgeShapepayload 實際被
// 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)
}
}

View File

@ -1,180 +0,0 @@
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%swant 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)
}
}

View File

@ -12,28 +12,6 @@ import (
"visiona-local/server/internal/model" "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 { func isCompatible(modelHardware []string, deviceType string) bool {
dt := strings.ToUpper(deviceType) dt := strings.ToUpper(deviceType)
for _, hw := range modelHardware { for _, hw := range modelHardware {
@ -106,11 +84,6 @@ func (s *Service) CleanupTask(taskID string) {
s.tracker.Remove(taskID) 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) { func (s *Service) StartFlash(deviceID, modelID string) (string, <-chan driver.FlashProgress, error) {
session, err := s.deviceMgr.GetDevice(deviceID) session, err := s.deviceMgr.GetDevice(deviceID)
if err != nil { if err != nil {
@ -164,18 +137,7 @@ func (s *Service) StartFlash(deviceID, modelID string) (string, <-chan driver.Fl
time.Sleep(500 * time.Millisecond) time.Sleep(500 * time.Millisecond)
// 把 models.json 宣告的 metadata 一起帶下去 —— bridge 端有 taskType flashErr := session.Driver.Flash(modelPath, task.ProgressCh)
// 就不再靠檔名猜 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。 // Flash 完成或失敗後driver 不會再寫 progressCh安全地寫 error 訊息然後 close。
if flashErr != nil { if flashErr != nil {

View File

@ -1,190 +0,0 @@
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 的邏輯。
//
// 為什麼不直接跑 StartFlashService 依賴具體的 *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_CarriesClassificationMetadataclassification 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_EmptyMetadataIsZeroValuemodel 沒宣告 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_CarriesDeclaredInputSizemodels.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_MissingInputSizeIsZeromodels.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 ""
}

View File

@ -1,233 +0,0 @@
// 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 擋單一巨大 indexMaxLines 擋「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 而非 mapplan §3.2 A1Model.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 的表:
//
// 空行 / 純空白行 → 略過
// `#` 開頭 → 註解、略過
// 行尾 \rCRLF → 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
}

View File

@ -1,423 +0,0 @@
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行尾 \rCRLF→ 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)
}
}
// 表列 6index 不是整數 → 整檔拒絕 + 行號
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)
}
})
}
}
// 表列 7index 為負數 → 整檔拒絕
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)
}
}
// 表列 8index 重複 → 整檔拒絕 + 指出重複的 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)
}
// 表列 9index 不連續 → 接受,缺的位置補空字串
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)
}
}
// 表列 10index 不從 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: %vBOM 未被 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
}

View File

@ -4,7 +4,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings" "strings"
"sync" "sync"
) )
@ -14,15 +13,6 @@ type Repository struct {
mu sync.RWMutex 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 { func NewRepository(dataPath string) *Repository {
r := &Repository{} r := &Repository{}
data, err := os.ReadFile(dataPath) data, err := os.ReadFile(dataPath)
@ -30,57 +20,12 @@ func NewRepository(dataPath string) *Repository {
fmt.Printf("Warning: could not load models from %s: %v\n", dataPath, err) fmt.Printf("Warning: could not load models from %s: %v\n", dataPath, err)
return r return r
} }
var declared []Model if err := json.Unmarshal(data, &r.models); err != nil {
if err := json.Unmarshal(data, &declared); err != nil {
fmt.Printf("Warning: could not parse models JSON: %v\n", err) fmt.Printf("Warning: could not parse models JSON: %v\n", err)
return r
} }
r.models = filterAvailableModels(declared, filepath.Dir(dataPath))
return r 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) { func (r *Repository) List(filter ModelFilter) ([]ModelSummary, int) {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()

View File

@ -1,9 +1,6 @@
package model package model
import ( import (
"encoding/json"
"os"
"path/filepath"
"testing" "testing"
) )
@ -101,193 +98,6 @@ 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) { func TestRepository_Remove(t *testing.T) {
repo := newTestRepo() repo := newTestRepo()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,261 +0,0 @@
#!/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 cp950JSON-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 編碼破壞的結果
因此修正必須同時涵蓋 stdindecode stdout / stderrencode
測試策略 如何在 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-8bridge 必須正確解碼。"""
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):
"""基準線:本機預設 localemacOS / 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 若被綁到 cp950em dashU+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 / Linuxlocale 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)

View File

@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"net" "net"
@ -11,7 +10,6 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"sort"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@ -107,13 +105,6 @@ type App struct {
// 目前仍保留供開發 log 使用,最終在 M8-4b 整個流程改寫後會被拿掉。 // 目前仍保留供開發 log 使用,最終在 M8-4b 整個流程改寫後會被拿掉。
bootstrapStatus string bootstrapStatus string
// pythonDepsWarning 記錄「Python 相依健康檢查沒過、自動修復也沒成功」。
//
// 刻意做成 warning 而非 error這個檢查會誤判見 ensureBundledPython 註解),
// 擋下啟動的代價遠大於放行。所以照常啟動、把訊息留在這裡讓使用者在 app 內
// 看得到,而不是把人擋在啟動畫面外。
pythonDepsWarning string
// L-1server 健康偵測 goroutine 控制 // L-1server 健康偵測 goroutine 控制
watchCancel context.CancelFunc watchCancel context.CancelFunc
@ -522,24 +513,6 @@ func (a *App) setBootstrapStatus(msg string) {
a.appLog("bootstrap: %s", msg) 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。 // OpenBrowser 用系統預設瀏覽器開啟 URL。
func (a *App) OpenBrowser(url string) error { func (a *App) OpenBrowser(url string) error {
return openBrowser(url) return openBrowser(url)
@ -899,31 +872,13 @@ func (a *App) ensurePythonRuntime(mode PythonMode) (string, PythonMode, error) {
} }
switch mode { switch mode {
case PythonModeAuto: case PythonModeAuto:
// ⚠️ 兩條失敗原因都必須保留到最終 error。 if bin, err := a.ensureBundledPython(); err == nil {
// return bin, PythonModeBundled, nil
// 事故背景:舊版把 bundled / system 的 err 直接丟棄,只回一句通用的
// "no python runtime available"。實機上 pip 因為 wheels 目錄有同一套件
// 多版本而 2 秒內 ResolutionImpossible但使用者與我們在 log 裡永遠
// 只看得到那句通用訊息,連續三輪都無法定位真因。
//
// 失敗原因是這條路徑上唯一能讓使用者自救的資訊,不可以吞。
bundledBin, bundledErr := a.ensureBundledPython()
if bundledErr == nil {
return bundledBin, PythonModeBundled, nil
} }
fmt.Fprintf(os.Stderr, "[visiona-local] bundled python 失敗,改試 system python%v\n", bundledErr) if bin, err := a.findSystemPython(); err == nil {
return bin, PythonModeSystem, nil
systemBin, systemErr := a.findSystemPython()
if systemErr == nil {
return systemBin, PythonModeSystem, nil
} }
fmt.Fprintf(os.Stderr, "[visiona-local] system python 也失敗:%v\n", systemErr) return "", PythonModeAuto, fmt.Errorf("no python runtime available (tried bundled + system)")
// %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: case PythonModeSystem:
bin, err := a.findSystemPython() bin, err := a.findSystemPython()
@ -991,27 +946,8 @@ func (a *App) ensureBundledPython() (string, error) {
} }
// 已建立好就直接回傳(幂等)— 日常啟動走這條,不會暫停 hard timeout。 // 已建立好就直接回傳(幂等)— 日常啟動走這條,不會暫停 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 { if _, err := os.Stat(pythonBin); err == nil {
return a.reuseExistingVenv(runtimeDir, pythonBin, wheelsDir), nil return pythonBin, nil
} }
// 首次 bootstrap 路徑:解壓 tarball + 建 venv + pip install 9 個 wheel // 首次 bootstrap 路徑:解壓 tarball + 建 venv + pip install 9 個 wheel
@ -1056,345 +992,19 @@ func (a *App) ensureBundledPython() (string, error) {
return "", fmt.Errorf("create venv: %w (%s)", err, string(out)) return "", fmt.Errorf("create venv: %w (%s)", err, string(out))
} }
// 首次安裝pip 真的失敗才算致命(沒有 venv 可用、後面一定跑不動)。 // 列舉 wheelsDir 下所有 .whl
// 但「pip 成功、只是 import 驗證沒過」不擋啟動 —— 那個 probe 會誤判。 var wheels []string
if err := a.installBundledWheels(runtimeDir, pythonBin, wheelsDir); err != nil { if entries, err := os.ReadDir(wheelsDir); err == nil {
if errors.Is(err, errPythonDepsProbeFailed) { for _, e := range entries {
a.setPythonDepsWarning(fmt.Sprintf( if !e.IsDir() && strings.HasSuffix(e.Name(), ".whl") {
"Python 套件已安裝,但載入驗證未通過:%v\n"+ wheels = append(wheels, filepath.Join(wheelsDir, e.Name()))
"應用程式仍會啟動。若推論或裝置掃描異常,請關閉應用程式後刪除 %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 是啟動時要驗證的關鍵模組。
//
// 只放「少了就完全跑不動」的三個kpKneron 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僅此一次成功後補寫標記
// 下次啟動即回到快路徑。
//
// 為什麼不是每次都跑 importimport 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 DLLlibkplus /
// 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/*/libkp 的 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") {
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})
} }
} }
chosen := make([]string, 0, len(best)+len(keep)) if len(wheels) == 0 {
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 已建立但未安裝任何相依") fmt.Fprintln(os.Stderr, "[visiona-local] WARN: no wheels found in", wheelsDir, "— venv 已建立但未安裝任何相依")
return nil return pythonBin, 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))) a.setBootstrapStatus(fmt.Sprintf("正在安裝 %d 個 Python 套件 (numpy / opencv / KneronPLUS ...) (~30-60 秒)...", len(wheels)))
@ -1406,23 +1016,11 @@ func (a *App) installBundledWheels(runtimeDir, pythonBin, wheelsDir string) erro
pipCmd := exec.Command(pythonBin, args...) pipCmd := exec.Command(pythonBin, args...)
configureSysProcAttr(pipCmd) configureSysProcAttr(pipCmd)
if out, err := pipCmd.CombinedOutput(); err != nil { 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))
} }
// 安裝完立刻驗一次pip 回 0 不保證 import 得起來(架構不符的 wheel、 a.setBootstrapStatus("Python 環境就緒")
// 缺系統層 DLL 等)。驗過才寫標記,否則下次啟動又會被快路徑放行。 return pythonBin, nil
//
// 包成 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 目錄。 // locateBundledPythonAssets 找 python tarball 與 wheels 目錄。

View File

@ -1,371 +0,0 @@
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 DLLlibkplus / libusb-1.0 /
// libwdi。Windows 上這些 DLL 不在預設搜尋路徑,要先 add_dll_directory —— 真正
// 跑 bridge 的 kl720_driver.go startPython() 有注入 PATHprobe 沒有。
//
// 代價不對稱:放行 → 使用者進 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")
// 假 pythonimport 失敗、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)
}
}

View File

@ -1,365 +0,0 @@
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/darwin16 顆)。
// 這組資料就是會讓 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("乾淨目錄不應有 droppedgot=%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.0got=%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 的真實原因
// ---------------------------------------------------------------------------
// 這是使用者「連續三輪看不到真因」的直接迴歸測試。
//
// 測試必須是確定性的,所以兩條路徑都強制失敗:
// - bundledcwd 切到空目錄 + dataDir 空 → locateBundledPythonAssets 找不到資產
// - systemPATH 清空 → 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")
}
}