# -*- 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=("", "")): 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全部通过")