Files
AgentLab/backend/tests/test_names.py
ThearchyHelios 8059cd5147 Excel 导入改造期 1:止血与版本底座——写错的列名不再静默得 0,导入不再悄悄读错,每次上传生成不可变的版本,运行固定发起时的数据版本
连接层
- 所有 SQLite 数据源关闭双引号字符串兼容(DQS),写错的双引号列名直接报错;设置失败就拒绝连接,启动时自检
- 连接缓存按数据源加版本快照分键、按源整体失效、最多 32 个;版本快照以 immutable 只读打开,建连接前核对文件哈希,并在借出连接时核对文件指纹,被改过就拒绝查询
- 结构探查遇到取不了列的视图时逐个对象重取,只跳过坏的那个并注明原因

解析(新 xlsx_scan + tabular)
- 先流式扫描真实边界和版式(不信 dimension),再按边界读值,不整表进内存;按 Excel 自身上限设硬上限,并发解析最多两个
- 隐藏和深度隐藏的工作表不导入;稀疏区域、无缓存值的公式、同名工作表、DTD 拒收
- 去掉空行和两侧空列;列名表名统一规范化(新 names 模块)并保留原表头,撞名不再互相覆盖
- 前导零、超长数字按文本;千分位按数字;日期不带 T;带单位的数字文本写进回执
- 数字列混入非数字、交叉表和多块结构必须由用户决定(422),可选存空值或按原样导入(未规整);STRICT 建表,整份在一个事务里写完

版本底座(新 table_versions、raw_store)
- 每次上传写新库文件、落盘、0444,事务里写构建、导入记录、快照后才切指针;失败时旧版完整可查
- 原件按哈希存档,不提供下载,可以清除;上传源不许改连接字段、不再重新探查;手工 SQLite 源不许指向上传目录和应用自己的数据文件(按文件身份判断)
- 启动时清理半成品与孤儿文件、给老上传补初始版本;被运行引用的版本不回收,发起运行与回收互斥
- 上传接口的解析移到线程池

运行固定数据版本
- 发起时把图里(含子工作流)用到的上传源的当前快照记进运行记录和 run.started,续跑、审批恢复沿用;数据源工具按固定的快照生成描述、表结构并查询
- 固定的版本没了、源被删除后同名重建,都明确报错,不改用当前版本或同名的新源;查询快照记 data_version

证据层
- 新报告记 entity_syntax: 2,[[t:明细]]、[[c:明细.金额]] 和 SQL 里不加引号的中文表名都认得,名字按 SQLite 的口径查找;老报告按原规则复核,结果不变
- 写作规则改为中文表名、字段名一律用 [[t:]] / [[c:]];未规整的表在工具描述、写作目录和裁判摘录里标出

前端
- 上传弹窗接上导入前的决定(混合列、交叉表)和导入回执(跳过的隐藏表、类型转换、警告、原表头对照、未规整标记),上传前告知原件会保存
- 数据源卡片按 origin 认上传的表格,显示当前版本;上传的表格不再给「探查结构」
2026-10-01 13:42:04 +08:00

77 lines
2.6 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
"""名字规范化:导入和证据层共用的口径。"""
from __future__ import annotations
import pytest
from app.data.names import canon, collide_key, name_key, name_problem, to_sql_name
@pytest.mark.parametrize(("raw", "expected"), [
("8-9", "8-9"),
("8–9", "8-9"), # en dash
("8—9", "8-9"), # em dash
("8−9", "8-9"), # 减号
("8-9", "8-9"), # 全角
(" 18-22 时合计 ", "18-22 时合计"),
("金​额", "金额"), # 零宽空格(Cf)
("(人次)", "(人次)"),
])
def test_canon(raw, expected):
assert canon(raw) == expected
@pytest.mark.parametrize("name", ["日客流", "sales", "Sales_2026", "时段客流_表内合计", "a"])
def test_good_names(name):
assert name_problem(name) is None
@pytest.mark.parametrize("name", [
"", "日客流ㅤ", "ABC", "①期", "a b", "2026销售", "金额(元)", "sqlite_master", "select",
"x" * 49, "a​b", "é",
])
def test_bad_names(name):
assert name_problem(name)
def test_name_key_matches_sqlite():
"""name_key 和 SQLite 比较标识符完全一致:只对 ASCII 不区分大小写,不做 NFKC。逐对拿真的 SQLite 对照。"""
import sqlite3
pairs = [("ABC", "abc", True), ("Дата", "дата", False), ("ABC", "abc", False), ("明细", "明细", True),
("Orders", "orders", False), ("Sales_2026", "SALES_2026", True)]
for a, b, same in pairs:
db = sqlite3.connect(":memory:")
db.execute(f'CREATE TABLE "{a}" (x)')
try:
db.execute(f'CREATE TABLE "{b}" (y)')
sqlite_same = False
except sqlite3.OperationalError: # table … already exists:SQLite 认为是同一个名字
sqlite_same = True
db.close()
assert sqlite_same is same, (a, b)
assert (name_key(a) == name_key(b)) is same, (a, b)
def test_collide_key_is_stricter():
assert collide_key("Дата") == collide_key("дата")
assert collide_key("ABC") == collide_key("abc")
@pytest.mark.parametrize(("header", "expected"), [
("金额(元)", "金额_元"),
("销量\n(件)", "销量_件"),
("单价[元]", "单价_元"),
("Unit Price", "Unit_Price"),
("2026年", "c_2026年"),
("order", "order_"),
("sqlite_x", "t_sqlite_x"),
("", "col_3"),
(None, "col_3"),
("()", "col_3"),
("全日客流(人次)", "全日客流_人次"),
])
def test_to_sql_name(header, expected):
out = to_sql_name(header, fallback="col_3")
assert out == expected
assert name_problem(out) is None