Dish_Recognition/tools/ui_smoke_test.py

442 lines
14 KiB
Python
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.

# -*- coding: utf-8 -*-
"""UI 冒烟测试:真实实例化全部组件与弹窗,捕捉 Tk 运行时错误。
不属于项目代码,仅用于本次改版验证,运行后可删除。
"""
import glob
import os
import sys
import traceback
import types
import tkinter as tk
from tkinter import ttk
from unittest.mock import MagicMock
ROOT = r"D:\Comproject\Dish_Recognition"
sys.path.insert(0, ROOT)
os.chdir(ROOT)
# ---- 打桩:本机缺少 torch / torchvision / ultralytics,UI 层并不需要它们 ----
class _NNModule:
def __init__(self, *a, **k):
pass
def __call__(self, *a, **k):
return None
def to(self, *a, **k):
return self
def eval(self):
return self
def train(self, *a, **k):
return self
def parameters(self):
return []
def children(self):
return []
def _stub(name, **attrs):
m = types.ModuleType(name)
m.__dict__.update(attrs)
sys.modules[name] = m
return m
torch = _stub("torch", device=lambda *a, **k: "cpu", Tensor=object)
_stub("torch.cuda", is_available=lambda: False)
_stub("torch.nn.functional", normalize=MagicMock())
nn = _stub("torch.nn", Module=_NNModule, Sequential=MagicMock())
nn.functional = sys.modules["torch.nn.functional"]
torch.nn = nn
torch.cuda = sys.modules["torch.cuda"]
tv = _stub("torchvision")
tv_models = _stub("torchvision.models", convnext_tiny=MagicMock())
_stub("torchvision.models.convnext", ConvNeXt_Tiny_Weights=MagicMock())
tv_models.convnext = sys.modules["torchvision.models.convnext"]
tv.models = tv_models
_stub("torchvision.transforms", Compose=MagicMock(), Resize=MagicMock(),
CenterCrop=MagicMock(), ToTensor=MagicMock(), Normalize=MagicMock())
tv.transforms = sys.modules["torchvision.transforms"]
_stub("ultralytics", YOLO=MagicMock())
import ui.classifier_ui as CU
from ui.theme import COLORS, apply_theme
from ui import widgets as W
# 屏蔽真实数据库探测,避免依赖 MySQL
CU.is_server_available = lambda: True
SAMPLES = glob.glob(os.path.join(ROOT, "detect", "*.jpg")) or \
glob.glob(os.path.join(ROOT, "cutting", "*.jpeg"))
SAMPLE = SAMPLES[0] if SAMPLES else None
failures = []
passed = []
def check(name, fn):
try:
fn()
passed.append(name)
except Exception:
failures.append((name, traceback.format_exc()))
class StubManager:
threshold = 0.7
def get_pending_count(self):
return 3
def get_template_list(self):
return [("土豆丝", 3, None), ("红烧肉", 2, None), ("番茄炒蛋", 1, None)]
def get_template_details(self, name):
return [{"add_time": "2026-09-01 10:00:00", "path": SAMPLE or "missing.jpg"}]
def get_base_info(self):
return {"base_code": "BASE_01", "id": 1}
def get_sync_status(self):
return {"online": True, "last_sync": "2026-09-14 10:00:00",
"pending": 2, "failed": 1, "last_error": ""}
def list_upload_queue(self, statuses=None, limit=100):
return [{"id": 1, "status": "pending", "action": "insert", "retry_count": 0,
"update_time": "2026-09-14 10:00:00", "error_msg": ""}]
def get_pending_list(self):
return [{"id": 11, "image_path": SAMPLE, "suggested_category": "土豆丝",
"score": 0.62, "capture_time": "2026-09-14 09:00:00"},
{"id": 12, "image_path": None, "suggested_category": "",
"score": None, "capture_time": "2026-09-14 09:05:00"}]
def set_threshold(self, v):
self.threshold = v
def delete_template_category(self, name):
return True
def ignore_pending(self, pid):
return True
def delete_pending(self, pid):
return True
class StubApi:
mock = True
api_url = "http://127.0.0.1:9000/api/dish/recognize/result"
base_id = "BASE_LOCAL_01"
class StubRepo:
def list_recent(self, limit=80):
return [{"id": 1, "capture_time": "2026-09-14 09:22:41", "base_code": "BASE_01",
"dish_count": 3, "unknown_count": 1, "summary": "土豆丝、红烧肉、未知(蒸蛋)",
"request_id": "REQ_1", "source_image": "images/a.jpg",
"labeled_image": "detect/a.jpg",
"result_json": '[{"index":1,"name":"土豆丝","score":0.91,"is_unknown":false}]'}]
def get(self, rid):
return self.list_recent()[0]
root = tk.Tk()
apply_theme(root)
root.geometry("1440x900")
# ---------------------------------------------------------------- 组件
host = tk.Frame(root, bg=COLORS["bg"])
host.pack(fill=tk.BOTH, expand=True)
def build_widgets():
c = COLORS
b1 = W.PillButton(host, text="选择图片识别", variant="primary", icon="📂")
b1.pack(side=tk.LEFT, padx=4)
for variant in ("secondary", "ghost", "danger"):
W.PillButton(host, text=f"按钮-{variant}", variant=variant,
size="sm").pack(side=tk.LEFT, padx=4)
W.PillButton(host, text="拉伸", variant="ghost", tooltip="悬停提示").pack(fill=tk.X, padx=4)
W.StatusBadge(host, text="USB相机已连接", tone="ok", tooltip="相机状态").pack(side=tk.LEFT)
W.StatusBadge(host, text="API模拟", tone="warn").pack(side=tk.LEFT)
W.Chip(host, text="待确认 2", tone="warn").pack(side=tk.LEFT, padx=4)
W.IconButton(host, text="🗑", command=lambda: None, tooltip="清空").pack(side=tk.LEFT)
W.Divider(host).pack(fill=tk.X)
W.SectionTitle(host, text="模板库", bg=c["bg"]).pack(fill=tk.X)
W.StatTile(host, value=12, label="菜品", tone="info", width=100).pack(side=tk.LEFT)
W.StatTile(host, value="86%", label="命中率", tone="ok", width=100).pack(side=tk.LEFT)
W.SearchBox(host, placeholder="搜索菜名…", command=lambda t: None, height=32).pack(fill=tk.X)
W.EmptyState(host, text="暂无数据", hint="提示文案", bg=c["bg"]).pack(fill=tk.X)
card = W.RoundCard(host, title="识别控制", subtitle="副标题", expand_body=True,
radius=12, padding=12)
card.pack(fill=tk.BOTH, expand=True)
tk.Label(card.body, text="卡片内容", bg=c["surface"]).pack()
W.PillButton(card.header, text="头部操作", variant="secondary", size="sm").pack(side=tk.RIGHT)
prev = W.RoundedPreview(host, radius=12)
prev.pack(fill=tk.BOTH, expand=True)
if SAMPLE:
W.make_rounded_thumb("土豆丝", size=42)
W.make_photo_rounded(SAMPLE, size=42)
W.make_photo_rounded("不存在的路径.jpg", size=42)
check("widgets 全部实例化", build_widgets)
def button_click():
fired = {"n": 0}
btn = W.PillButton(host, text="可点击", variant="primary",
command=lambda: fired.__setitem__("n", fired["n"] + 1))
btn.pack()
btn._on_enter()
btn._on_press()
btn._on_release()
assert fired["n"] == 1, "点击未触发回调"
btn.config(state=tk.DISABLED)
btn._on_press()
btn._on_release()
assert fired["n"] == 1, "禁用后仍被触发"
btn.config(state=tk.NORMAL)
btn.config(text="改名")
btn.set_variant("ghost")
btn._on_leave()
check("PillButton 点击/禁用/改文案", button_click)
def badge_notice():
badge = W.StatusBadge(host, text="初始", tone="ok")
badge.set("已连接", "ok")
badge.set("已断开", "err")
bar = W.NoticeBar(host)
bar.pack(fill=tk.X)
bar.set("就绪", "ok")
bar.set("正在识别…", "busy")
bar.set("失败", "err")
check("StatusBadge / NoticeBar", badge_notice)
def search_box():
seen = []
box = W.SearchBox(host, placeholder="输入…", command=lambda t: seen.append(t))
box.pack(fill=tk.X)
box._on_focus_in()
box._entry.insert(0, "土豆")
box._on_key()
assert box.get() == "土豆", box.get()
box.clear()
assert box.get() == "", box.get()
check("SearchBox 输入/清理", search_box)
def tooltip_toast():
lbl = tk.Label(host, text="悬停我", bg=COLORS["bg"])
lbl.pack()
tp = W.Tooltip(lbl, "这是提示")
tp._show()
tp.hide()
win = W.Toast.show(root, "识别完成,共 3 盘", "ok")
assert win is not None
W.Toast.show(root, "第二条提示", "warn")
W.Toast._dismiss(root)
check("Tooltip / Toast", tooltip_toast)
# ---------------------------------------------------------------- 主界面
fake = object.__new__(CU.BaseDishApp)
fake.root = root
fake.manager = StubManager()
fake.result_api = StubApi()
fake.recognition_repo = StubRepo()
fake.class_name = tk.StringVar(value="")
fake.thresh_val = tk.DoubleVar(value=0.7)
fake._selected_dish = None
fake._thumb_refs = []
fake._template_filter = ""
fake._scroll_canvases = []
fake._wheel_bound = False
fake._last_dishes = []
fake._syncing = False
fake._sync_dialog = None
fake._loading = False
fake._loading_job = None
fake._loading_frame = 0
fake.camera_display_name = "USB相机"
fake.app_title = "菜品识别系统"
fake.edition_label = "USB 相机版"
fake.logo_glyph = "菜"
def build_main_ui():
holder = tk.Frame(root, bg=COLORS["bg"])
holder.pack(fill=tk.BOTH, expand=True)
real_root = fake.root
fake.root = holder
try:
CU.BaseDishApp.create_ui(fake)
finally:
fake.root = real_root
check("BaseDishApp.create_ui 三栏布局", build_main_ui)
root.update()
def simulate_click(widget, seq=("<Button-1>", "<ButtonRelease-1>")):
for ev in seq:
widget._canvas.event_generate(ev)
root.update()
def interact_layout():
simulate_click(fake.camera_btn)
simulate_click(fake.camera_add_btn)
fake.refresh_template_list()
fake.render_result_dock([
{"index": 1, "label": "土豆丝", "score": 0.91, "is_unknown": False,
"suggested_category": "土豆丝", "crop_image": SAMPLE},
{"index": 2, "label": None, "score": 0.54, "is_unknown": True,
"suggested_category": "红烧肉", "crop_image": None},
])
fake._on_search_change("土豆")
fake._on_search_change("")
fake._set_result_text("测试状态", "ok")
fake._update_camera_ui_success()
fake._update_camera_ui_fail()
check("布局交互 + 结果栏渲染 + 搜索过滤", interact_layout)
# ---------------------------------------------------------------- 弹窗
def dialogs():
for name, opener in (
("同步面板", fake.show_sync_dialog),
("识别记录", fake.show_recognition_history),
("设置", fake.show_settings_dialog),
("识别统计", fake.show_statistics_dialog),
("待学习库", fake.show_pending_dialog),
("模板详情", lambda: fake.show_template_detail("土豆丝")),
):
opener()
root.update()
for w in list(root.winfo_children()):
if isinstance(w, tk.Toplevel):
try:
w.grab_release()
except Exception:
pass
w.destroy()
root.update()
check("全部弹窗构建", dialogs)
def new_dish_dialog_noop():
# 无未知菜时应直接返回,不阻塞
CU.BaseDishApp.confirm_unknown_dishes(fake, [])
check("新菜确认(空列表早退)", new_dish_dialog_noop)
def theme_tokens():
import ui.theme as T
assert T.mix("#000000", "#FFFFFF", 0.5) == "#7F7F7F", T.mix("#000000", "#FFFFFF", 0.5)
assert T.hex_to_rgb("#165DFF") == (22, 93, 255)
assert T.tone_pair("warn") and T.tone_text("err")
for key in ("bg", "surface", "primary", "text", "danger", "preview_bg",
"row_selected", "accent", "chip_ok_bg", "sidebar_muted"):
assert key in T.COLORS, key
for key in ("ui", "title", "section", "card_title", "button_lg", "small",
"result", "preview_hint", "h2", "metric"):
assert key in T.FONTS, key
check("theme 令牌与色彩工具", theme_tokens)
# ------------------------------------------------- 向后兼容:纯 ttk 旧宿主
def legacy_host_compat():
import core.common_classifier as CC
from ui.pending_learning_ui import (
PendingLearningUIMixin, format_dish_box_label, format_predict_detail,
)
class LegacyHost(PendingLearningUIMixin):
def __init__(self, host_root, manager):
self.root = host_root
self.manager = manager
self.result_label = ttk.Label(host_root, text="启动中") # 纯 ttk 宿主
self.refresh_calls = 0
def refresh_template_list(self):
self.refresh_calls += 1
ttk_root = tk.Toplevel(root)
host = LegacyHost(ttk_root, StubManager())
holder = ttk.Frame(ttk_root)
holder.pack(fill=tk.BOTH, expand=True)
host.setup_pending_learning_ui(holder) # 旧签名,无增强参数
host.update_pending_btn()
host._pl_toast("兼容性提示", "ok") # 无 _toast 时应回退到 Toast
assert format_dish_box_label({"label": "土豆丝", "score": 0.9}) == "土豆丝 0.900"
assert format_dish_box_label({"is_unknown": True, "score": 0.4,
"suggested_category": "红烧肉"}).startswith("未知?红烧肉")
assert format_predict_detail({"error": "boom"}) == "boom"
assert format_predict_detail(None) == "识别失败"
host.predict_crop = lambda p, auto_pending=True: {
"label": "土豆丝", "score": 0.9, "is_unknown": False,
"suggested_category": "土豆丝", "pending_id": None, "text": "土豆丝 相似度:0.900",
}
host.build_dish_labeled_image = lambda *a, **k: None
_, labels, unknown, _, dishes = host.run_plate_predict_batch(
"src.jpg", ["a.jpg", "b.jpg"], [[0, 0, 1, 1], [1, 1, 2, 2]])
assert len(dishes) == 2 and unknown == 0, dishes
payload_keys = {"index", "label", "score", "is_unknown",
"suggested_category", "pending_id", "text", "box", "crop_image"}
assert payload_keys <= set(dishes[0].keys()), set(dishes[0].keys())
assert CC.BaseClassifierUI is not None
# 旧版保留的 API 仍可用
assert callable(W.make_section_card)
assert W.make_rounded_thumb("土豆丝").width() == 42
ttk_root.destroy()
check("旧版纯 ttk 宿主 + Mixin 向后兼容", legacy_host_compat)
root.destroy()
print("=" * 60)
print(f"通过 {len(passed)} 项:")
for p in passed:
print(f" [OK] {p}")
if failures:
print(f"\n失败 {len(failures)} 项:")
for n, tb in failures:
print(f" [FAIL] {n}")
print(tb)
sys.exit(1)
print("\n全部通过")