安裝包從 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>
313 lines
8.9 KiB
Go
313 lines
8.9 KiB
Go
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)
|
||
}
|
||
})
|
||
}
|
||
}
|