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) } }) } }