japanese/tests/test_cleaner_workflow.py
panli ef3df73166 refactor: 三层架构重构 + 配置文件 + 项目目录整理
清洗管线重构为严格三层架构:
- tango_analyser.py(底层:单词分析)
- task_processor.py(中层:文件 I/O、桶管理)
- workflow.py(顶层:状态机、任务推进)
- 移除旧的 batch_processor.py

新增配置文件系统:
- config.py:TOML 配置,相对路径相对配置文件目录解析
- 查找优先级 --config > cwd > 项目根 > ~ > 默认值
- count=None 语义为处理到文件末尾

项目目录整理:
- 根脚本归档到 scripts/analysis 与 scripts/legacy
- 文档归档到 docs/{design,history,analysis}
- 临时报告移到 reports/(已 gitignore)

文档质量:
- 新增 .markdownlint.json 与 scripts/mdlint.cmd
- 修复全部 14 个 md 文件的 markdownlint 警告

测试:74 passed(8 cleaner + 31 workflow + 6 tango + 15 validator + 14 config)
2026-08-19 19:40:02 +08:00

420 lines
16 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.

"""
清洗工作流测试(三层架构版本)
测试策略:数据驱动 + 隔离临时任务,覆盖:
1. 底层单词分析TangoAnalyser词条 → 状态分类
2. 中间层文件处理TaskProcessor文件搬运、去重、格式校验
3. 工作流编排CleanerWorkflow状态机推进、人工介入门禁
所有测试用隔离临时目录tmp_path不碰真实数据。
"""
from pathlib import Path
import pytest
from pl_japanese.cleaner import (
TangoAnalyser, AnalysisStatus,
TaskProcessor, TaskManager, CleanerWorkflow,
ACTION_PROCESSED, ACTION_NEED_HUMAN, ACTION_COMPLETED, ACTION_EMPTY,
)
from pl_japanese.cleaner.task import (
STATUS_CREATED, STATUS_PROCESSING, STATUS_REVIEWING, STATUS_READY, STATUS_MERGED,
)
# ==========================================================================
# 测试辅助
# ==========================================================================
def _make_task(tmp_path: Path, source_lines: list[str], task_id="test_task"):
"""创建隔离临时任务数据源、任务目录、main/skipped 都在 tmp_path 下)"""
source = tmp_path / "source.txt"
source.write_text("\n".join(source_lines) + "\n", encoding="utf-8", newline="\n")
tm = TaskManager(tasks_root=str(tmp_path / "tasks"))
task = tm.create_task(
task_id=task_id,
source=str(source),
name=f"test_{task_id}",
start_line=1,
count=len(source_lines),
main=str(tmp_path / "main.txt"),
skipped=str(tmp_path / "skipped.txt"),
)
return tm, task
# ==========================================================================
# 1. 底层单词分析TangoAnalyser词条 → 状态分类
# ==========================================================================
# (kanji, kana, 期望状态, 输出子串检查)
ANALYSE_CASES = [
pytest.param("日本人", "にほんじん", AnalysisStatus.SUCCESS, "日|本|人:", id="success-basic"),
pytest.param("中国人", "ちゅうごくじん", AnalysisStatus.SUCCESS, "中|国|人:", id="success-polyphone-resolved"),
pytest.param("IT", "アイティー", AnalysisStatus.SKIP, "IT:", id="skip-no-kanji"),
pytest.param("行列", "こうれつ", AnalysisStatus.SUCCESS, "行|列:こう|れつ:hang|lie", id="polyphone-resolved-by-dict"),
pytest.param("女将", "おかみ", AnalysisStatus.SPLIT_FAILED, "女将:", id="split-failed"),
pytest.param("食べる", "たべる", AnalysisStatus.SUCCESS, "食|べる:た|べる:", id="verb-auto"),
pytest.param("Uターン", "ユーターン", AnalysisStatus.SKIP, "Uターン:", id="skip-no-kanji-with-latin"),
]
@pytest.mark.parametrize("kanji,kana,status,substring", ANALYSE_CASES)
def test_analyser_status_classification(kanji, kana, status, substring):
"""底层分析器:词条 → 状态分类正确,输出格式符合预期"""
analyser = TangoAnalyser()
result = analyser.analyze(kanji, kana)
assert result.status == status
assert substring in result.formatted_line
def test_analyser_needs_review_logic():
"""needs_review 属性SUCCESS/SKIP 返回 False其余返回 True"""
analyser = TangoAnalyser()
success = analyser.analyze("日本人", "にほんじん")
assert success.is_success is True
assert success.needs_review is False
skip = analyser.analyze("IT", "アイティー")
assert skip.status == AnalysisStatus.SKIP
assert skip.needs_review is False
# 用一个真正无法自动处理的词(分割失败)
split_fail = analyser.analyze("女将", "おかみ")
assert split_fail.needs_review is True
# ==========================================================================
# 2. 中间层文件处理TaskProcessorprocess_source 条数铁律
# ==========================================================================
# (源数据, 期望有效行数, 期望输出总数, 期望桶计数子集)
PROCESS_CASES = [
pytest.param(
["日本人:にほんじん:", "中国人:ちゅうごくじん:"],
2, 2, {"auto": 2, "skip": 0},
id="all-auto",
),
pytest.param(
["中国人:ちゅうごくじん:", "", "学生:がくせい:"],
2, 2, {"auto": 2},
id="blank-ignored",
),
pytest.param(
["IT:アイティー:", ":パーセント:"],
2, 2, {"skip": 2, "auto": 0},
id="all-skip",
),
pytest.param(
["日本人:にほんじん:", "IT:アイティー:"],
2, 2, {"auto": 1, "skip": 1},
id="mixed",
),
pytest.param(
["行列:こうれつ:", "Uターン:ユーターン:"],
2, 2, {"auto": 1, "skip": 1},
id="mixed-auto-skip",
),
]
@pytest.mark.parametrize("lines,valid,total,bucket_subset", PROCESS_CASES)
def test_processor_count_law(tmp_path, lines, valid, total, bucket_subset):
"""中间层文件处理器:条数铁律(有效输入 == 输出总数)+ 桶分布"""
tm, task = _make_task(tmp_path, lines)
proc = TaskProcessor(task)
result = proc.process_source()
assert result["valid_lines"] == valid
assert result["output_total"] == total
assert result["validation"] is True
for bucket, cnt in bucket_subset.items():
assert result["buckets"][bucket] == cnt
def test_processor_append_mode(tmp_path):
"""process_source 追加模式:多次调用累加到桶文件"""
tm, task = _make_task(tmp_path, ["日本人:にほんじん:", "中国人:ちゅうごくじん:"])
proc = TaskProcessor(task)
r1 = proc.process_source(count=1)
assert r1["output_total"] == 1
assert proc.snapshot_counts()["auto_done"] == 1
r2 = proc.process_source(start_line=2, count=1)
assert r2["output_total"] == 1
assert proc.snapshot_counts()["auto_done"] == 2
# ==========================================================================
# 3. 中间层merge_final 去重 + 格式校验
# ==========================================================================
def test_merge_deduplication(tmp_path):
"""merge_final 去重:重复词条只保留一份"""
tm, task = _make_task(tmp_path, ["日本人:にほんじん:", "日本人:にほんじん:"])
proc = TaskProcessor(task)
proc.process_source()
result = proc.merge_final(dry_run=False)
assert result["merged_auto"] == 1 # 2 条输入去重后只合并 1 条
assert result["duplicated_auto"] == 1
assert result["main_total"] == 1
def test_merge_idempotent(tmp_path):
"""幂等性:同一批数据再次合并,主库不增长"""
tm, task = _make_task(tmp_path, ["日本人:にほんじん:"])
proc = TaskProcessor(task)
proc.process_source()
r1 = proc.merge_final(dry_run=False)
assert r1["merged_auto"] == 1
proc.clear_single_batch()
proc.process_source() # 再次处理同样数据
r2 = proc.merge_final(dry_run=False)
assert r2["merged_auto"] == 0 # 无新增
assert r2["duplicated_auto"] == 1 # 全部重复
assert r2["main_total"] == 1 # 总数不变
def test_merge_validation_rejects_bad_format(tmp_path):
"""merge_final 格式校验:非法行被 reject不进主库"""
tm, task = _make_task(tmp_path, ["日本人:にほんじん:"])
proc = TaskProcessor(task)
# 先不 process直接手工写 auto_done 来测试校验
# 手工塞一条合法行(段数匹配) + 一条格式非法行(只有 1 个冒号)
proc.auto_done.write_text(
"日|本|人:に|ほん|じん:ri|ben|ren\n非法行\n",
encoding="utf-8", newline="\n",
)
result = proc.merge_final(dry_run=False)
assert result["rejected"] == 1
assert result["merged_auto"] == 1 # 合法的那条成功
assert any("非法行" in r[2] for r in result["rejects"])
# ==========================================================================
# 4. 中间层process_review 重跑
# ==========================================================================
# (塞入 review 桶名, 内容, 期望重跑条数, 期望落到的桶)
REPROCESS_CASES = [
pytest.param("pinyin", "中国人:ちゅうごくじん:", 1, "auto", id="pinyin-to-auto"),
pytest.param("split", "日本人:にほんじん:", 1, "auto", id="split-to-auto"),
pytest.param("special", "IT:アイティー:", 1, "skip", id="special-to-skip"),
]
@pytest.mark.parametrize("bucket,content,reprocessed,land_bucket", REPROCESS_CASES)
def test_process_review_reclassifies(tmp_path, bucket, content, reprocessed, land_bucket):
"""process_review重新分类后清空原桶条目重新分流"""
tm, task = _make_task(tmp_path, ["先生:せんせい:"])
proc = TaskProcessor(task)
proc.process_source()
# 手工塞一条到 review 桶
proc.review_files[bucket].write_text(content + "\n", encoding="utf-8", newline="\n")
result = proc.process_review(bucket)
assert result["reprocessed"] == reprocessed
assert result["new_distribution"][land_bucket] >= 1
# 原 review 桶已清零
assert len(proc._read_clean_lines(proc.review_files[bucket])) == 0
def test_process_review_unknown_bucket_raises(tmp_path):
"""process_review 非法桶名抛异常"""
tm, task = _make_task(tmp_path, ["先生:せんせい:"])
proc = TaskProcessor(task)
with pytest.raises(ValueError, match="未知 review 桶"):
proc.process_review("unknown_bucket")
# ==========================================================================
# 5. 工作流编排CleanerWorkflow状态机推进
# ==========================================================================
def test_workflow_run_auto_complete(tmp_path):
"""全自动流程created → ready无 review→ merged → completed"""
tm, task = _make_task(tmp_path, ["日本人:にほんじん:", "中国人:ちゅうごくじん:"])
wf = CleanerWorkflow(task)
# 第一步created → process → ready全部 auto无 review
r1 = wf.run()
assert r1["action"] == ACTION_PROCESSED
assert r1["status"] == STATUS_READY
assert r1["review_total"] == 0
# 第二步ready → merge → merged
r2 = wf.run()
assert r2["action"] == ACTION_PROCESSED
assert r2["status"] == STATUS_MERGED
assert r2["merge"]["merged_auto"] == 2
# 第三步merged → completed终态
r3 = wf.run()
assert r3["action"] == ACTION_COMPLETED
assert r3["status"] == STATUS_MERGED
def test_workflow_run_with_review_gate(tmp_path):
"""带人工介入流程created → reviewing → 返回待人工(不推进)"""
# 使用真正会进 review 的词:女将(分割失败)
tm, task = _make_task(tmp_path, ["女将:おかみ:", "日本人:にほんじん:"])
wf = CleanerWorkflow(task)
# 第一步created → process → reviewing有 review_split
r1 = wf.run()
assert r1["action"] == ACTION_PROCESSED
assert r1["status"] == STATUS_REVIEWING
assert r1["review_total"] > 0
# 第二步reviewing 且 review 非空 → 返回待人工,不推进、不写文件
r2 = wf.run()
assert r2["action"] == ACTION_NEED_HUMAN
assert r2["status"] == STATUS_REVIEWING
assert "review" in r2
assert r2["review_total"] > 0
def test_workflow_review_cleared_advances_to_ready(tmp_path):
"""人工重跑清零 review 后run() 自动推进到 ready"""
# 使用会进 review_split 的词
tm, task = _make_task(tmp_path, ["女将:おかみ:"])
wf = CleanerWorkflow(task)
proc = wf.processor
# 处理 → reviewing
r1 = wf.run()
assert r1["status"] == STATUS_REVIEWING
# 人工重跑 review_split假设改了字典重跑后全部转 auto
proc.review_files["split"].write_text("", encoding="utf-8", newline="\n")
proc.auto_done.write_text("女|将:お|かみ:nv|jiang\n", encoding="utf-8", newline="\n")
task.state.bucket_counts = proc.snapshot_counts()
task.save()
# 再次 run()review 清零 → 转 ready不合并把合并留给下一次
r2 = wf.run()
assert r2["action"] == ACTION_PROCESSED
assert r2["status"] == STATUS_READY
def test_workflow_merge_rejects_when_review_reopened(tmp_path):
"""合并前门禁ready 时 review 又有内容(被人工重新塞入),拒绝合并"""
tm, task = _make_task(tmp_path, ["日本人:にほんじん:"])
wf = CleanerWorkflow(task)
proc = wf.processor
# 处理 → ready
r1 = wf.run()
assert r1["status"] == STATUS_READY
# 人工重新塞一条 review模拟发现新问题
proc.review_files["pinyin"].write_text(
"测试:てすと:ce|shi\n", encoding="utf-8", newline="\n",
)
# run() 合并被拒绝,状态回到 reviewing
r2 = wf.run()
assert r2["action"] == ACTION_NEED_HUMAN
assert r2["status"] == STATUS_REVIEWING
def test_workflow_merge_rejects_bad_format(tmp_path):
"""合并时格式校验失败:不推进状态,保持 ready提示人工修正"""
tm, task = _make_task(tmp_path, ["日本人:にほんじん:"])
wf = CleanerWorkflow(task)
proc = wf.processor
r1 = wf.run()
assert r1["status"] == STATUS_READY
# 手工塞一条非法格式
proc.auto_done.write_text(
"日|本|人:にほん|じん:ri|ben|ren\n非法行",
encoding="utf-8", newline="\n",
)
r2 = wf.run()
assert r2["action"] == ACTION_NEED_HUMAN
assert r2["status"] == STATUS_READY
assert "格式非法" in r2["message"]
def test_workflow_empty_source(tmp_path):
"""空源文件:返回 empty不推进"""
tm, task = _make_task(tmp_path, ["", " "])
wf = CleanerWorkflow(task)
result = wf.run()
assert result["action"] == ACTION_EMPTY
assert result["process"]["valid_lines"] == 0
# ==========================================================================
# 6. 任务管理TaskManagerCRUD + 多任务隔离
# ==========================================================================
def test_task_manager_create_and_load(tmp_path):
"""任务创建和加载:配置持久化正确"""
tm, task = _make_task(tmp_path, ["日本人:にほんじん:"], task_id="task1")
loaded = tm.load_task("task1")
assert loaded.task_id == "task1"
assert loaded.config.source == task.config.source
assert loaded.state.status == STATUS_CREATED
def test_task_manager_list_summaries(tmp_path):
"""list_task_summaries返回所有任务摘要"""
source1 = tmp_path / "s1.txt"
source1.write_text("日本人:にほんじん:\n", encoding="utf-8")
source2 = tmp_path / "s2.txt"
source2.write_text("中国人:ちゅうごくじん:\n", encoding="utf-8")
tm = TaskManager(tasks_root=str(tmp_path / "tasks"))
tm.create_task("t1", source=str(source1), name="任务1")
tm.create_task("t2", source=str(source2), name="任务2")
summaries = tm.list_task_summaries()
assert len(summaries) == 2
ids = {s["task_id"] for s in summaries}
assert ids == {"t1", "t2"}
def test_task_manager_duplicate_rejected(tmp_path):
"""重复 task_id 创建被拒绝"""
tm, task = _make_task(tmp_path, ["日本人:にほんじん:"])
with pytest.raises(FileExistsError):
tm.create_task(task.task_id, source=task.config.source)
def test_multi_task_isolation(tmp_path):
"""多任务隔离:各任务的单批文件独立,互不干扰"""
source = tmp_path / "source.txt"
source.write_text("日本人:にほんじん:\n中国人:ちゅうごくじん:\n", encoding="utf-8")
tm = TaskManager(tasks_root=str(tmp_path / "tasks"))
t1 = tm.create_task("t1", source=str(source), count=1,
main=str(tmp_path / "main.txt"),
skipped=str(tmp_path / "skip.txt"))
t2 = tm.create_task("t2", source=str(source), start_line=2, count=1,
main=str(tmp_path / "main.txt"),
skipped=str(tmp_path / "skip.txt"))
p1 = TaskProcessor(t1)
p2 = TaskProcessor(t2)
p1.process_source()
p2.process_source()
# 各自单批文件独立
assert p1.snapshot_counts()["auto_done"] == 1
assert p2.snapshot_counts()["auto_done"] == 1
# 但 main 库是共享的(都指向同一文件)
p1.merge_final(dry_run=False)
p2.merge_final(dry_run=False)
assert p1.final_counts()["main"] == 2
assert p2.final_counts()["main"] == 2