jim800121chen 744283bd28 feat(build): 安裝包只打包指定模型並改用中文名稱
安裝包從 8 個 .nef 縮減為 2 個,models.json 保留完整定義以便日後
加回。

- Makefile 加 BUNDLED_NEFS 白名單,三平台共用 copy_bundled_data
  helper;用 POSIX find/cp 而非 rsync(Windows CI 的 Git Bash 沒有
  rsync)
- 白名單檔案不存在時 build 直接失敗,並在複製後驗證 models.json
  存在且 .nef 數量相符 —— 避免產出「安裝後 0 個模型」卻回報成功
- FCOS Detection (KL520) 改名為「物件辨識」
- Tiny YOLOv3 (KL520) 改名為「人型監測」
  (只改 name/description,id 不動以免影響既有設定與紀錄)
- Repository 啟動時過濾 .nef 不存在的模型,否則使用者會看到未打包
  的模型並在選取後拿到莫名錯誤

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

313 lines
8.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package model
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func newTestRepo() *Repository {
return &Repository{
models: []Model{
{
ID: "model-1",
Name: "YOLOv8",
Description: "Object detection model",
TaskType: "object_detection",
SupportedHardware: []string{"KL720", "KL730"},
},
{
ID: "model-2",
Name: "ResNet",
Description: "Classification model",
TaskType: "classification",
SupportedHardware: []string{"KL720"},
},
{
ID: "custom-1",
Name: "My Custom Model",
TaskType: "object_detection",
IsCustom: true,
},
},
}
}
func TestRepository_List(t *testing.T) {
repo := newTestRepo()
tests := []struct {
name string
filter ModelFilter
expectedCount int
}{
{"no filter", ModelFilter{}, 3},
{"filter by task type", ModelFilter{TaskType: "object_detection"}, 2},
{"filter by hardware", ModelFilter{Hardware: "KL730"}, 1},
{"filter by query", ModelFilter{Query: "YOLO"}, 1},
{"query case insensitive", ModelFilter{Query: "resnet"}, 1},
{"no matches", ModelFilter{TaskType: "segmentation"}, 0},
{"combined filters", ModelFilter{TaskType: "object_detection", Query: "YOLO"}, 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results, count := repo.List(tt.filter)
if count != tt.expectedCount {
t.Errorf("List() count = %d, want %d", count, tt.expectedCount)
}
if len(results) != tt.expectedCount {
t.Errorf("List() len(results) = %d, want %d", len(results), tt.expectedCount)
}
})
}
}
func TestRepository_GetByID(t *testing.T) {
repo := newTestRepo()
tests := []struct {
name string
id string
wantErr bool
}{
{"existing model", "model-1", false},
{"another existing", "model-2", false},
{"non-existing", "model-999", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m, err := repo.GetByID(tt.id)
if (err != nil) != tt.wantErr {
t.Errorf("GetByID() error = %v, wantErr %v", err, tt.wantErr)
}
if !tt.wantErr && m.ID != tt.id {
t.Errorf("GetByID() ID = %s, want %s", m.ID, tt.id)
}
})
}
}
func TestRepository_Add(t *testing.T) {
repo := &Repository{models: []Model{}}
m := Model{ID: "new-1", Name: "New Model"}
repo.Add(m)
if repo.Count() != 1 {
t.Errorf("Count() = %d, want 1", repo.Count())
}
}
// writeModelsJSON 在 dir 底下建立 models.json回傳它的路徑。
func writeModelsJSON(t *testing.T, dir string, models []Model) string {
t.Helper()
data, err := json.MarshalIndent(models, "", " ")
if err != nil {
t.Fatalf("marshal models: %v", err)
}
path := filepath.Join(dir, "models.json")
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatalf("write models.json: %v", err)
}
return path
}
// touchNef 在 dir 底下建立一個假的 .nef內容不重要只檢查存在性
func touchNef(t *testing.T, dir, relPath string) {
t.Helper()
full := filepath.Join(dir, relPath)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir for %s: %v", relPath, err)
}
if err := os.WriteFile(full, []byte("fake nef"), 0o644); err != nil {
t.Fatalf("write %s: %v", relPath, err)
}
}
func TestResolveBuiltInModelPath(t *testing.T) {
dataDir := filepath.Join("/bundle", "data")
tests := []struct {
name string
filePath string
want string
}{
{
name: "strips data/ prefix so it does not become data/data/",
filePath: "data/nef/kl520/a.nef",
want: filepath.Join("/bundle", "data", "nef", "kl520", "a.nef"),
},
{
name: "relative path without data/ prefix joins directly",
filePath: "nef/kl520/a.nef",
want: filepath.Join("/bundle", "data", "nef", "kl520", "a.nef"),
},
{
name: "absolute path is used as-is",
filePath: filepath.Join("/custom", "models", "x", "model.nef"),
want: filepath.Join("/custom", "models", "x", "model.nef"),
},
{
name: "empty file path stays empty",
filePath: "",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := resolveBuiltInModelPath(tt.filePath, dataDir); got != tt.want {
t.Errorf("resolveBuiltInModelPath(%q) = %q, want %q", tt.filePath, got, tt.want)
}
})
}
}
func TestFilterAvailableModels(t *testing.T) {
dataDir := t.TempDir()
touchNef(t, dataDir, "nef/kl520/bundled.nef")
absent := filepath.Join(dataDir, "nef", "kl520", "abs-missing.nef")
present := filepath.Join(dataDir, "nef", "kl520", "abs.nef")
touchNef(t, dataDir, "nef/kl520/abs.nef")
// 目錄而非檔案:不該被當成可用的 model
if err := os.MkdirAll(filepath.Join(dataDir, "nef/kl520/dir.nef"), 0o755); err != nil {
t.Fatalf("mkdir dir.nef: %v", err)
}
models := []Model{
{ID: "bundled", FilePath: "data/nef/kl520/bundled.nef"},
{ID: "not-bundled", FilePath: "data/nef/kl520/nope.nef"},
{ID: "abs-present", FilePath: present},
{ID: "abs-absent", FilePath: absent},
{ID: "no-file-path"},
{ID: "dir-not-file", FilePath: "data/nef/kl520/dir.nef"},
}
got := filterAvailableModels(models, dataDir)
var gotIDs []string
for _, m := range got {
gotIDs = append(gotIDs, m.ID)
}
want := []string{"bundled", "abs-present", "no-file-path"}
if len(gotIDs) != len(want) {
t.Fatalf("filterAvailableModels() = %v, want %v", gotIDs, want)
}
for i := range want {
if gotIDs[i] != want[i] {
t.Errorf("filterAvailableModels()[%d] = %q, want %q", i, gotIDs[i], want[i])
}
}
}
func TestNewRepository_FiltersUnbundledModels(t *testing.T) {
dataDir := t.TempDir()
touchNef(t, dataDir, "nef/kl520/kl520_20004_fcos-drk53s_w512h512.nef")
touchNef(t, dataDir, "nef/kl520/kl520_tiny_yolo_v3.nef")
// 模擬正式情境models.json 宣告 4 個 model但只打包了其中 2 個 .nef
path := writeModelsJSON(t, dataDir, []Model{
{ID: "kl520-fcos-detection", Name: "物件辨識", TaskType: "object_detection",
FilePath: "data/nef/kl520/kl520_20004_fcos-drk53s_w512h512.nef"},
{ID: "kl520-tiny-yolov3", Name: "人型監測", TaskType: "object_detection",
FilePath: "data/nef/kl520/kl520_tiny_yolo_v3.nef"},
{ID: "kl520-yolov5-detection", Name: "YOLOv5", TaskType: "object_detection",
FilePath: "data/nef/kl520/kl520_20005_yolov5-noupsample_w640h640.nef"},
{ID: "kl720-resnet18-classification", Name: "ResNet18", TaskType: "classification",
FilePath: "data/nef/kl720/kl720_20001_resnet18_w224h224.nef"},
})
repo := NewRepository(path)
if repo.Count() != 2 {
t.Fatalf("Count() = %d, want 2 (only bundled .nef should load)", repo.Count())
}
for _, id := range []string{"kl520-fcos-detection", "kl520-tiny-yolov3"} {
if _, err := repo.GetByID(id); err != nil {
t.Errorf("GetByID(%q) failed, expected it to be available: %v", id, err)
}
}
for _, id := range []string{"kl520-yolov5-detection", "kl720-resnet18-classification"} {
if _, err := repo.GetByID(id); err == nil {
t.Errorf("GetByID(%q) succeeded, expected it to be filtered out", id)
}
}
// 過濾後的清單也不該出現在 List()
results, count := repo.List(ModelFilter{})
if count != 2 || len(results) != 2 {
t.Errorf("List() = %d results (count %d), want 2", len(results), count)
}
}
func TestNewRepository_CustomModelsUnaffectedByFilter(t *testing.T) {
dataDir := t.TempDir()
path := writeModelsJSON(t, dataDir, []Model{
{ID: "built-in-missing", FilePath: "data/nef/kl520/missing.nef"},
})
repo := NewRepository(path)
if repo.Count() != 0 {
t.Fatalf("Count() = %d, want 0 after filtering", repo.Count())
}
// 自訂模型走 Add(),不經過過濾
repo.Add(Model{ID: "custom-1", IsCustom: true, FilePath: "/anywhere/model.nef"})
if repo.Count() != 1 {
t.Errorf("Count() = %d after Add(), want 1", repo.Count())
}
if _, err := repo.GetByID("custom-1"); err != nil {
t.Errorf("GetByID(custom-1) failed: %v", err)
}
}
func TestNewRepository_MissingOrInvalidFile(t *testing.T) {
t.Run("missing models.json yields empty repo", func(t *testing.T) {
repo := NewRepository(filepath.Join(t.TempDir(), "nope.json"))
if repo.Count() != 0 {
t.Errorf("Count() = %d, want 0", repo.Count())
}
})
t.Run("invalid JSON yields empty repo", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "models.json")
if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
repo := NewRepository(path)
if repo.Count() != 0 {
t.Errorf("Count() = %d, want 0", repo.Count())
}
})
}
func TestRepository_Remove(t *testing.T) {
repo := newTestRepo()
tests := []struct {
name string
id string
wantErr bool
}{
{"remove custom model", "custom-1", false},
{"cannot remove built-in", "model-1", true},
{"not found", "model-999", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := repo.Remove(tt.id)
if (err != nil) != tt.wantErr {
t.Errorf("Remove() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}