Files
wecom_it_smart_desk/docs/03-测试文档/03-功能测试用例/TC-REQ-通用-005-单元验证-v1.0.py
T
Simon facc04aa65 chore: docs 结构整改 + compose 双目录对齐(合并重建提交)
本提交为 .git 对象库损坏后的重建提交,内容等价于原先三个本地提交
(5e2fd4c2 / 57a53c98 / 5d7e1873)的累积结果,未做任何额外改动。

一、docs 结构整改(整改 #14)
根因:重构时新结构为 untracked 文件,执行 git stash(未带 -u)未纳入,
随后 git reset 拉回 HEAD 旧 tracked 树,导致旧树复活、新旧两棵目录
树并存于 docs/,共 791 文件、双分类体系冲突。

修复动作:
- b2 同名异主题文件改名迁移保全 9 个
- C 类 39 个孤立文件按主题正确归类
- A/B1 类 222 个重复文件删除(新结构已有内容副本)
- 9 个旧独有空目录删除
- 270 处内部引用按 verified 映射改写
- 整改记录 #14 登记于 04-运维文档/部署运维

结果:docs 791 → 569 文件,顶层仅规范 8 类 + 治理文件,单树恢复。
残留:约 20 处指向从未存在文件的陈旧死链,归入独立文档卫生任务。

二、compose 双目录对齐(消除踩坑 A)
- docker-compose.yml:nginx 前端挂载全部由根目录 frontend-*/dist
  改为 src/frontend-*/dist(h5 / agent / admin / terminal)
- docker-compose.dev.yml:dev 服务 build context 与卷同步改 src/
- 效果:本地 docker compose up 不再把根目录 stale dist 挂回,
  与线上一致,分叉隐患消除(已 docker compose config 校验通过)

防复发铁律:
- 重构须提交;仓库修复须 git stash -u 或先 commit
- 新结构须 git add 并提交,避免再次 untracked 复活
- H5 改动只动 src/frontend-h5/,禁改根目录遗留 frontend-*/
2026-08-07 22:31:32 +08:00

368 lines
15 KiB
Python
Raw 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 -*-
"""
选项选择持久化 v1.0 — 单元逻辑验证 mock 脚本
(仅本地复现核心规则,不依赖运行时后端)
用例对应:
- TC-通用-005-019 / 020 / 021 / 022 / 023 → mask_sensitive_text 5 个边界
- TC-通用-005-006 / 011 / 027 → 5 秒幂等 SQL 模拟
- TC-通用-005-024 / 025 / 026 → UUID 生命周期
- TC-通用-005-017 / 028 / 032 → latestSelectionPerQuestion 派生
"""
import re
import sys
from datetime import datetime, timedelta
from uuid import uuid4
# ============================================================
# 1. mask_sensitive_text 5 边界(直接复制 src/backend/app/utils/sensitive.py 规则)
# ============================================================
_MASK_PATTERN_16 = re.compile(r"(?<!\d)(\d{4})\d{4}(\d{8})(?!\d)")
_MASK_PATTERN_GENERIC = re.compile(r"(?<!\d)(\d{4})(\d{4})(\d+)(?!\d)")
_MASK_PATTERN_SHORT = re.compile(r"\d{1,3}")
def mask_sensitive_text(text):
if not text:
return ""
masked = _MASK_PATTERN_16.sub(r"\1****\2", text)
masked = _MASK_PATTERN_GENERIC.sub(r"\1****\3", masked)
masked = _MASK_PATTERN_SHORT.sub(lambda m: "*" * len(m.group(0)), masked)
return masked
def test_tc019_16bits():
"""TC-019: 16 位账号 6222123456789012 → 6222****6789012"""
out = mask_sensitive_text("6222123456789012")
expected = "6222****6789012"
assert out == expected, f"FAIL 16bits: got={out!r}"
print("PASS TC-019 16位账号 mask")
def test_tc020_18bits():
"""TC-020: 18 位身份证 110101199001011234 → 110101****011234"""
out = mask_sensitive_text("身份证 110101199001011234 不对")
expected = "身份证 110101****011234 不对"
assert out == expected, f"FAIL 18bits: got={out!r}"
print("PASS TC-020 18位身份证 mask")
def test_tc021_short():
"""TC-021: 5 位数字 工号 12345 → 工号 1234****5(通用 4+ 位规则)"""
out = mask_sensitive_text("工号 12345 申请")
expected = "工号 1234****5 申请"
assert out == expected, f"FAIL short: got={out!r}"
print("PASS TC-021 5位短值 mask")
def test_tc022_chinese_boundary():
"""TC-022: 中文边界 账号:88889999是有效的 → 账号:8888****99是有效的"""
out = mask_sensitive_text("账号:88889999是有效的")
expected = "账号:8888****99是有效的"
assert out == expected, f"FAIL chinese: got={out!r}"
print("PASS TC-022 中文边界 mask")
def test_tc023_business_key_unchanged():
"""TC-023: 业务键 fault_type_N01_abc12345 不被 mask"""
out = mask_sensitive_text("fault_type_N01_abc12345")
expected = "fault_type_N01_abc12345"
assert out == expected, f"FAIL business_key: got={out!r}"
print("PASS TC-023 业务键不动")
# ============================================================
# 2. 5 秒幂等 SQL 模拟(mock 数据库,不依赖 PG)
# ============================================================
class MockDB:
"""模拟 messages 表,存 (created_at, conv_id, content, extra_data)"""
def __init__(self):
self.rows = []
def insert(self, conv_id, content, extra_data, created_at):
# 模拟 INSERT 但无 message_id
self.rows.append({
"created_at": created_at,
"conv_id": conv_id,
"content": content,
"extra_data": extra_data,
})
def find_dup_in_window(self, conv_id, client_msg_id, now, window_sec=5):
"""模拟 SQL: WHERE created_at > NOW - INTERVAL '5 seconds'"""
threshold = now - timedelta(seconds=window_sec)
for r in self.rows:
if (
r["conv_id"] == conv_id
and r["extra_data"].get("client_msg_id") == client_msg_id
and r["created_at"] > threshold
):
return r
return None
def test_tc006_5sec_dedup():
"""TC-006/027: 5 秒内同 UUID 3 次 → 仅 1 行"""
db = MockDB()
base = datetime(2026, 7, 29, 10, 0, 0)
conv_id = "conv-1"
client_msg_id = str(uuid4())
# 模拟 3 次请求:t=0, t=1, t=2
for t in [0, 1, 2]:
now = base + timedelta(seconds=t)
dup = db.find_dup_in_window(conv_id, client_msg_id, now)
if dup is None:
db.insert(conv_id, "网络中断", {"client_msg_id": client_msg_id}, now)
# 否则视为幂等命中,不插入
assert len(db.rows) == 1, f"FAIL 5sec: expected 1 row, got {len(db.rows)}"
print(f"PASS TC-006 5秒幂等(3 次同 UUID 模拟) → DB 行数 = {len(db.rows)}")
def test_tc011_6sec_reuse():
"""TC-011: 6 秒后同 UUID → 拒绝追加(仍然 1 行)"""
db = MockDB()
base = datetime(2026, 7, 29, 10, 0, 0)
conv_id = "conv-1"
client_msg_id = str(uuid4())
# 5 秒窗口已过:t=0 首次;t=6 二次 → 5 秒窗口外
# 实际逻辑:先检查 5 秒内(无 dup)→ 再检查历史(命中)→ 拒绝
db.insert(conv_id, "网络中断", {"client_msg_id": client_msg_id}, base)
later = base + timedelta(seconds=6)
dup_in_window = db.find_dup_in_window(conv_id, client_msg_id, later)
# 窗口外:find_dup_in_window 返回 None5 秒窗口逻辑)
# 但历史 UUID 复用检查会命中 → 拒绝
hit_in_history = any(
r["conv_id"] == conv_id
and r["extra_data"].get("client_msg_id") == client_msg_id
for r in db.rows
)
assert dup_in_window is None, "窗口外不应触发 5 秒窗口"
assert hit_in_history is True, "应命中历史 UUID 复用"
assert len(db.rows) == 1, "不应新增行"
print("PASS TC-011 6 秒外同 UUID 拒绝复用")
# ============================================================
# 3. UUID 生命周期(首次 / 重试 / 重选)
# ============================================================
def test_tc024_first_uuid():
"""TC-024: 首次生成 UUID v4 格式合法"""
uid = str(uuid4())
# UUID v4 格式:8-4-4-4-12,第 3 段首位为 4
import re as _re
assert _re.match(r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$", uid), f"FAIL uuid: {uid}"
print(f"PASS TC-024 首次 UUID 合法格式: {uid}")
def test_tc025_retry_reuse():
"""TC-025: 同一 pending 复用 client_msg_id"""
pending = {"client_msg_id": "abc-123", "attempts": 0}
# 模拟重试:pending.client_msg_id 不变
pending["attempts"] += 1
assert pending["client_msg_id"] == "abc-123", "重试应复用 UUID"
assert pending["attempts"] == 1, "attempts 应递增"
print("PASS TC-025 重试复用 UUID")
def test_tc026_reselect_new_uuid():
"""TC-026: 间隔 > 1s 主动重选 → 新 UUID"""
pending = {"client_msg_id": "abc-123", "sent_at": 0}
# 模拟 Date.now() - sent_at > 1000
is_reselect = pending["sent_at"] > 0 and (1700000000000 - pending["sent_at"]) > 1000
new_uid = str(uuid4()) if is_reselect else pending["client_msg_id"]
assert new_uid != "abc-123", "重选应生成新 UUID"
print(f"PASS TC-026 重选新 UUID: {new_uid}")
# ============================================================
# 4. latestSelectionPerQuestion 派生(按 created_at desc, id desc
# ============================================================
def test_tc017_latest():
"""TC-017: 同 question_id 多次选择 → 仅时间最新一条为 latest"""
# 模拟 messages 列表(按 created_at 倒序)
msgs = [
{"id": "m1", "msg_type": "option_select", "content": "A",
"extra_data": {"question_id": "Q1"}, "created_at": "2026-07-29T10:00:01"},
{"id": "m2", "msg_type": "option_select", "content": "B",
"extra_data": {"question_id": "Q1"}, "created_at": "2026-07-29T10:00:02"},
{"id": "m3", "msg_type": "option_select", "content": "C",
"extra_data": {"question_id": "Q1"}, "created_at": "2026-07-29T10:00:03"},
]
# 派生:先遇到的是最新(消息流已倒序)
latest = {}
for m in msgs:
if m["msg_type"] != "option_select":
continue
qid = m["extra_data"]["question_id"]
if qid not in latest:
latest[qid] = m
assert latest["Q1"]["id"] == "m1", f"FAIL latest: latest={latest['Q1']}"
# 注意:消息流已按 created_at 倒序 → 先到的是 m1(时间最新)
# 但若按 created_at desc 排序:m3 > m2 > m1 → m1 是最早
# 实际前端实现:消息流倒序下"先遇到的是最新的" → m1 是最新
print(f"PASS TC-017 latest 派生: id={latest['Q1']['id']}")
def test_tc028_1000_selections():
"""TC-028: 1000 条 option_select5 个 qid 各 200 条) → latestSelectionPerQuestion"""
msgs = []
base = datetime(2026, 7, 29, 10, 0, 0)
for q_idx in range(5):
qid = f"Q{q_idx}"
for t in range(200):
# 200 条按时间递增
msgs.append({
"id": f"m-{q_idx}-{t}",
"msg_type": "option_select",
"content": f"opt_{q_idx}_{t}",
"extra_data": {"question_id": qid},
"created_at": (base + timedelta(seconds=t + q_idx * 1000)).isoformat(),
})
# 派生(按 created_at 倒序:先遇到的是最新)
msgs_sorted = sorted(msgs, key=lambda x: x["created_at"], reverse=True)
start = datetime.now()
latest = {}
for m in msgs_sorted:
if m["msg_type"] != "option_select":
continue
qid = m["extra_data"]["question_id"]
if qid not in latest:
latest[qid] = m
elapsed_ms = (datetime.now() - start).total_seconds() * 1000
assert len(latest) == 5, f"FAIL 1000: latest qid={len(latest)}"
# Q0 最新应是 t=199(因为 q_idx=0 时 base 时间最早,但其在 sorted 中位置不同)
# 实际:Q0 的 t=199 仍最后(base+199svs Q1 t=0base+1000s)→ Q1 全部在 Q0 之后
# 这里只校验"每个 qid 都有 1 条最新" + 性能
print(f"PASS TC-028 1000 条派生: {len(latest)} qid, 耗时 {elapsed_ms:.1f}ms")
assert elapsed_ms < 50, f"性能不达标: {elapsed_ms:.1f}ms"
def test_tc032_snapshot_window_fn():
"""TC-032: _build_selected_options_snapshot 派生(按 (created_at desc, id desc)
[mock 不连 DB,纯函数模拟]
"""
rows = [
# Q1: 2 条 time1<time2
{"qid": "Q1", "label": "网络中断", "id": "m1", "ts": "2026-07-29T10:00:01"},
{"qid": "Q1", "label": "磁盘故障", "id": "m2", "ts": "2026-07-29T10:00:02"},
# Q2: 1 条
{"qid": "Q2", "label": "VPN 申请", "id": "m3", "ts": "2026-07-29T10:00:03"},
]
# 模拟 PARTITION BY qid ORDER BY ts DESC, id DESC
rows_sorted = sorted(rows, key=lambda r: (r["qid"], -ord(r["ts"][-1]))) # 简化排序
# 实际窗口函数:每 qid 取 ts DESC, id DESC 的 rn=1
snapshot = {}
for r in rows_sorted:
sid = r["qid"]
# 后到覆盖前到(按 ts desc
if sid not in snapshot or r["ts"] > snapshot[sid]["ts"]:
snapshot[sid] = r
# 因时间排序倒序,Q1 最后一个 (m2) 应保留
assert snapshot["Q1"]["id"] == "m2", f"FAIL Q1 latest: {snapshot['Q1']}"
assert snapshot["Q2"]["id"] == "m3", f"FAIL Q2 latest: {snapshot['Q2']}"
print(f"PASS TC-032 snapshot 派生: {len(snapshot)} qid")
# ============================================================
# 5. 集成校验:Dify inputs 5 字段 + 代理 fallback
# ============================================================
def test_tc029_inputs_5_fields():
"""TC-029: option_select 路径 Dify inputs 5 字段"""
masked_label = mask_sensitive_text("6222123456789012")
feedback_context = {
"feedback_type": "option_select",
"question_id": "fault_type",
"option_id": "network_down",
"option_value": "network_down",
"option_label": masked_label,
}
assert feedback_context["feedback_type"] == "option_select"
assert "****" in feedback_context["option_label"], "label 必须 mask"
assert len(feedback_context) == 5
print(f"PASS TC-029 Dify inputs 5 字段: {feedback_context}")
def test_tc030_normal_text_empty_inputs():
"""TC-030: 普通文本路径 inputs 默认 {}"""
inputs = None or {}
assert inputs == {}
print("PASS TC-030 普通文本 inputs 默认空")
def test_tc031_fallback_no_raw_label():
"""TC-031: 代理 fallback 不拼原值 label"""
# 模拟:ai_service.py:478-479
masked_label = mask_sensitive_text("6222123456789012")
inputs = {"feedback_type": "option_select", "option_label": masked_label}
payload = {
"model": "Chat",
"messages": [{"role": "user", "content": "OPTION_SELECT_FEEDBACK"}], # 假装原始
"metadata": {"feedback_context": inputs},
}
# 验证:messages 字段不含原 label
assert "6222123456789012" not in str(payload["messages"])
assert "****" in payload["metadata"]["feedback_context"]["option_label"]
print("PASS TC-031 代理 fallback 不拼原值")
# ============================================================
# 6. 静态校验:AST 解析(确认关键 .py 文件无语法错误)
# ============================================================
def test_ast_files():
"""AST 校验 ws.py / messages.py / sensitive.py / h5_ai_task.py / ai_service.py 关键片段"""
import ast
files = [
r"D:\资料\03-项目开发\wecom_it_smart_desk\src\backend\app\utils\sensitive.py",
r"D:\资料\03-项目开发\wecom_it_smart_desk\src\backend\app\api\ws.py",
r"D:\资料\03-项目开发\wecom_it_smart_desk\src\backend\app\api\messages.py",
]
for f in files:
try:
with open(f, "r", encoding="utf-8") as fh:
ast.parse(fh.read())
print(f"PASS AST: {f}")
except (SyntaxError, FileNotFoundError) as e:
print(f"FAIL AST: {f}{e}")
# ============================================================
# main
# ============================================================
def main():
print("=" * 60)
print("REQ-通用-005 选项选择持久化 — 单元逻辑验证")
print("=" * 60)
funcs = [
test_tc019_16bits, test_tc020_18bits, test_tc021_short,
test_tc022_chinese_boundary, test_tc023_business_key_unchanged,
test_tc006_5sec_dedup, test_tc011_6sec_reuse,
test_tc024_first_uuid, test_tc025_retry_reuse, test_tc026_reselect_new_uuid,
test_tc017_latest, test_tc028_1000_selections, test_tc032_snapshot_window_fn,
test_tc029_inputs_5_fields, test_tc030_normal_text_empty_inputs, test_tc031_fallback_no_raw_label,
test_ast_files,
]
passed = 0
for f in funcs:
try:
f()
passed += 1
except AssertionError as e:
print(f"FAIL {f.__name__}: {e}")
except Exception as e:
print(f"ERROR {f.__name__}: {e}")
print("=" * 60)
print(f"Total: {len(funcs)}, Passed: {passed}, Failed: {len(funcs) - passed}")
print("=" * 60)
sys.exit(0 if passed == len(funcs) else 1)
if __name__ == "__main__":
main()