Refine model management status and optional slot rendering
This commit is contained in:
parent
7ae66b2569
commit
28764a1a6d
@ -241,6 +241,79 @@ func TestConfigPreviewServiceRenderProfileEditorWritesResolvedBindings(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPreviewServiceRenderProfileEditorAllowsUnboundOptionalServiceSlot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{
|
||||
"name":"std_workshop_face_recognition_shoe_alarm",
|
||||
"source":"standard",
|
||||
"slots":{
|
||||
"inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}],
|
||||
"services":[{"name":"token_service_main","type":"token_service","required":false,"description":"认证服务"}],
|
||||
"outputs":[{"name":"stream_output_main","type":"stream_publish","required":true,"description":"主视频输出"}]
|
||||
},
|
||||
"template":{
|
||||
"nodes":[
|
||||
{"id":"input_rtsp_main","type":"input_rtsp","url":"${slot:video_input_main.url}"},
|
||||
{"id":"alarm_violation","type":"alarm","outputs":[{"external_api":{"getTokenUrl":"${slot:token_service_main.get_token_url}"}}]}
|
||||
],
|
||||
"edges":[]
|
||||
}
|
||||
}`)
|
||||
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
if err := repo.SaveVideoSource(
|
||||
"gate_cam_01",
|
||||
"rtsp",
|
||||
"东门入口",
|
||||
"东门主入口摄像头",
|
||||
`{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`,
|
||||
); err != nil {
|
||||
t.Fatalf("SaveVideoSource: %v", err)
|
||||
}
|
||||
|
||||
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
|
||||
mustImportAssetsFromMediaRepo(t, svc)
|
||||
editor := ConfigProfileEditor{
|
||||
Name: "line_a",
|
||||
Instances: []ConfigProfileInstanceEditor{{
|
||||
Name: "cam1",
|
||||
Template: "std_workshop_face_recognition_shoe_alarm",
|
||||
VideoSourceRef: "gate_cam_01",
|
||||
PublishRTSPPort: "8555",
|
||||
ChannelNo: "cam1",
|
||||
}},
|
||||
}
|
||||
|
||||
result, err := svc.RenderProfileEditor(editor, ConfigPreviewRequest{
|
||||
Template: "std_workshop_face_recognition_shoe_alarm",
|
||||
ConfigID: "preview",
|
||||
ConfigVersion: "v1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RenderProfileEditor: %v", err)
|
||||
}
|
||||
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil {
|
||||
t.Fatalf("unmarshal render result: %v", err)
|
||||
}
|
||||
templates, _ := doc["templates"].(map[string]any)
|
||||
renderedTemplate, _ := templates["std_workshop_face_recognition_shoe_alarm__cam1"].(map[string]any)
|
||||
nodes, _ := renderedTemplate["nodes"].([]any)
|
||||
alarmNode, _ := nodes[1].(map[string]any)
|
||||
outputs, _ := alarmNode["outputs"].([]any)
|
||||
output, _ := outputs[0].(map[string]any)
|
||||
externalAPI, _ := output["external_api"].(map[string]any)
|
||||
if got := stringValue(externalAPI["getTokenUrl"]); got != "" {
|
||||
t.Fatalf("expected empty getTokenUrl for unbound optional slot, got %#v", externalAPI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPreviewServiceRenderUsesSQLiteProfileAndOverlay(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "configs", "templates", "std_service_test_stream.json"), `{
|
||||
|
||||
@ -169,7 +169,11 @@ func renderRuntimeSceneInstance(templateRaw map[string]any, templatePath string,
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
renderedTemplateAny, err := expandRuntimeSlotTokens(templateBody, context)
|
||||
slotRequirements, err := runtimeSlotRequirements(templateRaw)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
renderedTemplateAny, err := expandRuntimeSlotTokens(templateBody, context, slotRequirements)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
@ -187,6 +191,24 @@ func renderRuntimeSceneInstance(templateRaw map[string]any, templatePath string,
|
||||
return boundName, renderedTemplate, renderedInstance, nil
|
||||
}
|
||||
|
||||
func runtimeSlotRequirements(templateRaw map[string]any) (map[string]bool, error) {
|
||||
group, err := parseTemplateSlots(templateRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]bool{}
|
||||
for _, slot := range group.Inputs {
|
||||
out[slot.Name] = slot.Required
|
||||
}
|
||||
for _, slot := range group.Services {
|
||||
out[slot.Name] = slot.Required
|
||||
}
|
||||
for _, slot := range group.Outputs {
|
||||
out[slot.Name] = slot.Required
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func buildRuntimeBindingContext(instance map[string]any) (map[string]any, error) {
|
||||
context := map[string]any{}
|
||||
if sceneMeta, ok := instance["scene_meta"].(map[string]any); ok && len(sceneMeta) > 0 {
|
||||
@ -212,12 +234,12 @@ func resolvedRuntimeBindingValue(entry map[string]any) map[string]any {
|
||||
return deepCopyMap(entry)
|
||||
}
|
||||
|
||||
func expandRuntimeSlotTokens(value any, context map[string]any) (any, error) {
|
||||
func expandRuntimeSlotTokens(value any, context map[string]any, slotRequirements map[string]bool) (any, error) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(typed))
|
||||
for key, item := range typed {
|
||||
expanded, err := expandRuntimeSlotTokens(item, context)
|
||||
expanded, err := expandRuntimeSlotTokens(item, context, slotRequirements)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -227,7 +249,7 @@ func expandRuntimeSlotTokens(value any, context map[string]any) (any, error) {
|
||||
case []any:
|
||||
out := make([]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
expanded, err := expandRuntimeSlotTokens(item, context)
|
||||
expanded, err := expandRuntimeSlotTokens(item, context, slotRequirements)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -239,12 +261,22 @@ func expandRuntimeSlotTokens(value any, context map[string]any) (any, error) {
|
||||
if len(match) != 3 {
|
||||
return typed, nil
|
||||
}
|
||||
required, known := slotRequirements[match[1]]
|
||||
if !known {
|
||||
required = true
|
||||
}
|
||||
slotValues, _ := context[match[1]].(map[string]any)
|
||||
if slotValues == nil {
|
||||
if !required {
|
||||
return "", nil
|
||||
}
|
||||
return nil, fmt.Errorf("required slot '%s' is not bound", match[1])
|
||||
}
|
||||
fieldValue, ok := slotValues[match[2]]
|
||||
if !ok {
|
||||
if !required {
|
||||
return "", nil
|
||||
}
|
||||
return nil, fmt.Errorf("required slot field '%s.%s' is not bound", match[1], match[2])
|
||||
}
|
||||
return deepCopyAny(fieldValue), nil
|
||||
|
||||
@ -35,10 +35,12 @@ type ModelStatusCell struct {
|
||||
}
|
||||
|
||||
type ModelStatusRow struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
Online bool `json:"online"`
|
||||
Cells []ModelStatusCell `json:"cells"`
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
Online bool `json:"online"`
|
||||
Cells []ModelStatusCell `json:"cells"`
|
||||
ExtraModelCount int `json:"extra_model_count"`
|
||||
ExtraModels []InstalledModelStatus `json:"extra_models"`
|
||||
}
|
||||
|
||||
type ModelStatusSummary struct {
|
||||
@ -88,6 +90,7 @@ func (s *ModelManagementService) SyncStandardModelsFromDirectory(dir string) err
|
||||
Version: "auto",
|
||||
SHA256: sum,
|
||||
SizeBytes: size,
|
||||
ModelType: inferModelType(entry.Name()),
|
||||
}
|
||||
if err := s.models.Save(record); err != nil {
|
||||
return err
|
||||
@ -111,6 +114,24 @@ func hashFile(path string) (string, int64, error) {
|
||||
return hex.EncodeToString(hasher.Sum(nil)), size, nil
|
||||
}
|
||||
|
||||
func inferModelType(fileName string) string {
|
||||
name := strings.ToLower(strings.TrimSpace(fileName))
|
||||
switch {
|
||||
case strings.Contains(name, "face_det"), strings.Contains(name, "retinaface"), strings.Contains(name, "scrfd"):
|
||||
return "face_detection"
|
||||
case strings.Contains(name, "face_recog"), strings.Contains(name, "mobilefacenet"), strings.Contains(name, "arcface"):
|
||||
return "face_recognition"
|
||||
case strings.Contains(name, "ppe"):
|
||||
return "ppe_detection"
|
||||
case strings.Contains(name, "shoe"):
|
||||
return "shoe_detection"
|
||||
case strings.Contains(name, "object_det"), strings.Contains(name, "yolo"):
|
||||
return "object_detection"
|
||||
default:
|
||||
return "other"
|
||||
}
|
||||
}
|
||||
|
||||
func BuildModelStatusBoard(standardModels []storage.StandardModelRecord, devices []*models.Device, installed map[string][]InstalledModelStatus) ModelStatusBoard {
|
||||
board := ModelStatusBoard{
|
||||
Summary: ModelStatusSummary{
|
||||
@ -127,11 +148,16 @@ func BuildModelStatusBoard(standardModels []storage.StandardModelRecord, devices
|
||||
for _, item := range installed[device.DeviceID] {
|
||||
index[item.Name] = item
|
||||
}
|
||||
standardIndex := make(map[string]struct{}, len(standardModels))
|
||||
for _, model := range standardModels {
|
||||
standardIndex[model.Name] = struct{}{}
|
||||
}
|
||||
row := ModelStatusRow{
|
||||
DeviceID: device.DeviceID,
|
||||
DeviceName: device.DisplayName(),
|
||||
Online: device.Online,
|
||||
Cells: make([]ModelStatusCell, 0, len(standardModels)),
|
||||
DeviceID: device.DeviceID,
|
||||
DeviceName: device.DisplayName(),
|
||||
Online: device.Online,
|
||||
Cells: make([]ModelStatusCell, 0, len(standardModels)),
|
||||
ExtraModels: make([]InstalledModelStatus, 0),
|
||||
}
|
||||
hasMissing := false
|
||||
hasMismatch := false
|
||||
@ -158,6 +184,16 @@ func BuildModelStatusBoard(standardModels []storage.StandardModelRecord, devices
|
||||
}
|
||||
row.Cells = append(row.Cells, cell)
|
||||
}
|
||||
for _, item := range installed[device.DeviceID] {
|
||||
if _, ok := standardIndex[item.Name]; ok {
|
||||
continue
|
||||
}
|
||||
row.ExtraModels = append(row.ExtraModels, item)
|
||||
}
|
||||
sort.Slice(row.ExtraModels, func(i, j int) bool {
|
||||
return row.ExtraModels[i].Name < row.ExtraModels[j].Name
|
||||
})
|
||||
row.ExtraModelCount = len(row.ExtraModels)
|
||||
switch {
|
||||
case hasMismatch:
|
||||
board.Summary.MismatchDevices++
|
||||
|
||||
@ -39,6 +39,9 @@ func TestSyncStandardModelsFromDirectory(t *testing.T) {
|
||||
if items[0].SHA256 == "" {
|
||||
t.Fatalf("expected sha256 to be populated: %#v", items[0])
|
||||
}
|
||||
if items[0].ModelType != "face_detection" {
|
||||
t.Fatalf("expected inferred model type face_detection, got %#v", items[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelStatusBoardMarksMissingAndMismatch(t *testing.T) {
|
||||
@ -67,3 +70,34 @@ func TestBuildModelStatusBoardMarksMissingAndMismatch(t *testing.T) {
|
||||
t.Fatalf("expected second model to be missing, got %#v", board.Rows[0].Cells[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelStatusBoardSeparatesExtraModels(t *testing.T) {
|
||||
modelsList := []storage.StandardModelRecord{
|
||||
{Name: "face_det", FileName: "face_det.rknn", SHA256: "sha-1"},
|
||||
}
|
||||
devices := []*models.Device{
|
||||
{DeviceID: "edge-01", DeviceName: "设备一", Online: true},
|
||||
}
|
||||
installed := map[string][]InstalledModelStatus{
|
||||
"edge-01": {
|
||||
{Name: "face_det", FileName: "face_det.rknn", SHA256: "sha-1"},
|
||||
{Name: "best-640", FileName: "best-640.rknn", SHA256: "sha-x"},
|
||||
{Name: "mobilefacenet_arcface", FileName: "mobilefacenet_arcface.rknn", SHA256: "sha-y"},
|
||||
},
|
||||
}
|
||||
|
||||
board := BuildModelStatusBoard(modelsList, devices, installed)
|
||||
|
||||
if len(board.Rows) != 1 {
|
||||
t.Fatalf("unexpected board rows: %#v", board.Rows)
|
||||
}
|
||||
if got := board.Rows[0].ExtraModelCount; got != 2 {
|
||||
t.Fatalf("expected 2 extra models, got %#v", board.Rows[0])
|
||||
}
|
||||
if len(board.Rows[0].ExtraModels) != 2 {
|
||||
t.Fatalf("expected extra model details to be preserved, got %#v", board.Rows[0].ExtraModels)
|
||||
}
|
||||
if board.Rows[0].ExtraModels[0].Name != "best-640" || board.Rows[0].ExtraModels[1].Name != "mobilefacenet_arcface" {
|
||||
t.Fatalf("expected sorted extra models, got %#v", board.Rows[0].ExtraModels)
|
||||
}
|
||||
}
|
||||
|
||||
@ -211,6 +211,24 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic
|
||||
}
|
||||
return v
|
||||
},
|
||||
"modelTypeLabel": func(v string) string {
|
||||
switch strings.TrimSpace(v) {
|
||||
case "face_detection":
|
||||
return "人脸检测"
|
||||
case "face_recognition":
|
||||
return "人脸识别"
|
||||
case "object_detection":
|
||||
return "通用检测"
|
||||
case "ppe_detection":
|
||||
return "PPE检测"
|
||||
case "shoe_detection":
|
||||
return "工鞋检测"
|
||||
case "other":
|
||||
return "其他"
|
||||
default:
|
||||
return "-"
|
||||
}
|
||||
},
|
||||
"displayDeviceName": func(dev *models.Device, status *ConfigStatusView) string {
|
||||
if dev == nil {
|
||||
return "-"
|
||||
|
||||
@ -275,6 +275,16 @@ tbody tr[data-nav-row]{cursor:pointer}
|
||||
.scene-summary-details[open] summary::before{transform:rotate(45deg)}
|
||||
.scene-summary-details[open] summary{margin-bottom:10px;color:var(--text)}
|
||||
.scene-summary-details .info-list{margin-top:0}
|
||||
.mini-details summary{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:12px;color:var(--table-link);list-style:none}
|
||||
.mini-details summary::before{content:"";width:7px;height:7px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transition:transform .16s ease}
|
||||
.mini-details summary::-webkit-details-marker{display:none}
|
||||
.mini-details[open] summary::before{transform:rotate(45deg)}
|
||||
.mini-details-body{margin-top:8px;padding:8px 10px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft)}
|
||||
.mini-details-item{font-size:11px;line-height:1.5;color:var(--text)}
|
||||
.mini-details-item+.mini-details-item{margin-top:4px}
|
||||
.models-status-table .model-status-col{min-width:92px;max-width:124px;vertical-align:bottom}
|
||||
.models-status-table .model-status-label{display:block;font-size:11px;line-height:1.25;white-space:normal;word-break:break-word;overflow-wrap:anywhere}
|
||||
.models-status-table .model-extra-col{min-width:150px}
|
||||
.scene-actions-row{margin-top:12px}
|
||||
.field-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-bottom:14px}
|
||||
.field-grid label>span{display:block;margin-bottom:6px;font-size:12px;color:var(--muted)}
|
||||
|
||||
@ -49,19 +49,20 @@
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>模型名</th><th>文件名</th><th>版本</th><th>哈希</th><th>大小</th></tr>
|
||||
<tr><th>模型名</th><th>分类</th><th>文件名</th><th>版本</th><th>哈希</th><th>大小</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .StandardModels}}
|
||||
<tr>
|
||||
<td><span class="mono table-key">{{.Name}}</span></td>
|
||||
<td>{{modelTypeLabel .ModelType}}</td>
|
||||
<td class="mono">{{.FileName}}</td>
|
||||
<td>{{if .Version}}{{.Version}}{{else}}auto{{end}}</td>
|
||||
<td class="mono">{{shortHash .SHA256}}</td>
|
||||
<td>{{.SizeBytes}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="5" class="muted">标准模型目录为空。</td></tr>
|
||||
<tr><td colspan="6" class="muted">标准模型目录为空。</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
@ -76,11 +77,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<table class="models-status-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>设备</th>
|
||||
{{range .StandardModels}}<th>{{.Name}}</th>{{end}}
|
||||
{{range .StandardModels}}<th class="model-status-col" title="{{.Name}}"><span class="model-status-label">{{modelTypeLabel .ModelType}}</span></th>{{end}}
|
||||
<th class="model-extra-col">非标准模型</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -98,10 +100,24 @@
|
||||
{{else}}<span class="pill bad">缺失</span>{{end}}
|
||||
</td>
|
||||
{{end}}
|
||||
<td>
|
||||
{{if gt .ExtraModelCount 0}}
|
||||
<details class="mini-details">
|
||||
<summary>{{.ExtraModelCount}} 个 · 更多</summary>
|
||||
<div class="mini-details-body">
|
||||
{{range .ExtraModels}}
|
||||
<div class="mini-details-item mono">{{.FileName}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</details>
|
||||
{{else}}
|
||||
<span class="muted">0</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<tr><td colspan="2" class="muted">暂无设备模型状态。</td></tr>
|
||||
<tr><td colspan="99" class="muted">暂无设备模型状态。</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@ -1843,7 +1843,51 @@ func TestUI_LoadConfigStatusPrefersPreviousConfigFields(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUI_ModelsPageShowsStandardModelsAndDeviceStatus(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/models/status":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"models":[{"name":"face_det_scrfd_500m_640_rk3588","file_name":"face_det_scrfd_500m_640_rk3588.rknn","sha256":"sha-1","size_bytes":123},{"name":"best-640","file_name":"best-640.rknn","sha256":"sha-x","size_bytes":456}]}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
host, portText, err := net.SplitHostPort(strings.TrimPrefix(server.Listener.Addr().String(), "[::]"))
|
||||
if err != nil {
|
||||
t.Fatalf("SplitHostPort: %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil {
|
||||
t.Fatalf("Atoi: %v", err)
|
||||
}
|
||||
cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
|
||||
dbPath := filepath.Join(t.TempDir(), "models.db")
|
||||
store, err := storage.OpenSQLite(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
reg := service.NewRegistryService(cfg, nil, storage.NewDevicesRepo(store.DB()))
|
||||
reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: true})
|
||||
agent := service.NewAgentClient(cfg)
|
||||
tasks := service.NewTaskService(cfg, agent, reg)
|
||||
ui, err := NewUI(nil, reg, agent, tasks, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewUI: %v", err)
|
||||
}
|
||||
modelsRepo := storage.NewModelsRepo(store.DB())
|
||||
if err := modelsRepo.Save(storage.StandardModelRecord{
|
||||
Name: "face_det_scrfd_500m_640_rk3588",
|
||||
FileName: "face_det_scrfd_500m_640_rk3588.rknn",
|
||||
Version: "v1",
|
||||
SHA256: "sha-1",
|
||||
SizeBytes: 123,
|
||||
ModelType: "face_detection",
|
||||
}); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
ui.SetDBPath(dbPath)
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/models", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
@ -1853,7 +1897,12 @@ func TestUI_ModelsPageShowsStandardModelsAndDeviceStatus(t *testing.T) {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"模型管理", "标准模型", "设备模型状态", "完整设备数", "缺失设备数", "版本不一致设备数", "更新全部模型"} {
|
||||
for _, want := range []string{"模型管理", "标准模型", "设备模型状态", "完整设备数", "缺失设备数", "版本不一致设备数", "更新全部模型", "非标准模型", "人脸检测"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected model management HTML to contain %q", want)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"1 个 · 更多", "best-640.rknn"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected model management HTML to contain %q", want)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user