442 lines
14 KiB
Python
442 lines
14 KiB
Python
# -*- 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全部通过")
|