japanese/tests/test_cleaner_workflow.py
panli 2e4dcf8980 feat: 新增 jlearn 学习资料生成器 + 日语音变标注体系
新增学习资料生成器模块(learner),从权威库生成多维日语学习资料:
- 拼音/假名/汉字/熟字训四类索引,带拼音↔假名↔汉字交叉跳转
- 逐字音训分类(KANJIDIC2 精确查表 + 启发式回退 + 排序键)
- 音变标注体系:浊化(連濁)、半浊化、促音变(促音便)、连声(れんじょう)
  独立配色 + 合并逻辑 + 音变规律说明
- 显式标注表:rendaku_marks(连用形连浊)、renjou_marks(连声)
- 每索引独立例词数配置(jlearn.toml + --config)
- HTML 单页应用 + 静态 HTML + PDF(playwright)

清洗工具增强:
- 拼音校验器(pinyin_checker)集成到 jclean
- 多音字拼音校正、ます形サ変動詞转原型

数据:
- 权威库补充连声词(反応/天皇/陰陽/観音/因縁/三位/輪廻/安穏)
- KANJIDIC2 音训分类表、拼音校正字典

整理 .gitignore:忽略生成产物(output/study_materials)、词典数据库、
任务运行日志、备份文件
2026-09-09 15:44:50 +08:00

432 lines
18 KiB
Python
Raw Permalink 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 下)
返回: (tm, task, main_path, skipped_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_path = str(tmp_path / "main.txt")
skipped_path = str(tmp_path / "skipped.txt")
return tm, task, main_path, skipped_path
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 条数铁律
# ==========================================================================
# (源数据, 期望有效行数, 期望输出总数, 期望桶计数子集)
# (源行, 有效行数, 输出总数, {auto_pass期望, need_review期望})
PROCESS_CASES = [
pytest.param(
["日本人:にほんじん:", "中国人:ちゅうごくじん:"],
2, 2, {"auto_pass": 2, "need_review": 0},
id="all-auto",
),
pytest.param(
["中国人:ちゅうごくじん:", "", "学生:がくせい:"],
2, 2, {"auto_pass": 2},
id="blank-ignored",
),
pytest.param(
["IT:アイティー:", ":パーセント:"],
2, 2, {"auto_pass": 0, "need_review": 2, "suggest_skip": 2},
id="all-skip",
),
pytest.param(
["日本人:にほんじん:", "IT:アイティー:"],
2, 2, {"auto_pass": 1, "need_review": 1, "suggest_skip": 1},
id="mixed",
),
pytest.param(
["行列:こうれつ:", "Uターン:ユーターン:"],
2, 2, {"auto_pass": 1, "need_review": 1},
id="mixed-auto-skip",
),
]
@pytest.mark.parametrize("lines,valid,total,expect_subset", PROCESS_CASES)
def test_processor_count_law(tmp_path, lines, valid, total, expect_subset):
"""中间层文件处理器:条数铁律(有效输入 == 输出总数)+ 分区分布"""
tm, task, main_path, skipped_path = _make_task(tmp_path, lines)
proc = TaskProcessor(task, main=main_path, skipped=skipped_path)
result = proc.process_source()
assert result["valid_lines"] == valid
assert result["output_total"] == total
assert result["validation"] is True
for key, cnt in expect_subset.items():
assert result[key] == cnt
def test_processor_writes_single_review_file(tmp_path):
"""process_source 写单一待确认文件,条数=源行数"""
tm, task, main_path, skipped_path = _make_task(tmp_path, ["日本人:にほんじん:", "IT:アイティー:"])
proc = TaskProcessor(task, main=main_path, skipped=skipped_path)
proc.process_source()
# 待确认文件包含全部条目(自动通过段 + 待确认段)
assert proc.total_entries() == 2
# 待确认段只有 IT建议skip
assert proc.review_count() == 1
# auto_done/skip 此时还没写(等 apply_review
assert proc.snapshot_counts()["auto_done"] == 0
# ==========================================================================
# 3. 中间层merge_final 去重 + 格式校验
# ==========================================================================
def _process_and_confirm(proc):
"""辅助:处理源文件后模拟人工确认(末尾加 done落地 auto_done/skip。"""
proc.process_source()
review_file = proc.review
content = review_file.read_text(encoding="utf-8")
review_file.write_text(content + "done\n", encoding="utf-8", newline="\n")
return proc.apply_review()
def test_merge_deduplication(tmp_path):
"""merge_final 去重:重复词条只保留一份"""
tm, task, main_path, skipped_path = _make_task(tmp_path, ["日本人:にほんじん:", "日本人:にほんじん:"])
proc = TaskProcessor(task, main=main_path, skipped=skipped_path)
_process_and_confirm(proc)
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, main_path, skipped_path = _make_task(tmp_path, ["日本人:にほんじん:"])
proc = TaskProcessor(task, main=main_path, skipped=skipped_path)
_process_and_confirm(proc)
r1 = proc.merge_final(dry_run=False)
assert r1["merged_auto"] == 1
proc.clear_single_batch()
_process_and_confirm(proc) # 再次处理同样数据
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, main_path, skipped_path = _make_task(tmp_path, ["日本人:にほんじん:"])
proc = TaskProcessor(task, main=main_path, skipped=skipped_path)
# 先不 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[0] for r in result["rejects"])
# ==========================================================================
# 4. 中间层apply_review 应用人工裁决
# ==========================================================================
def test_apply_review_requires_done_signal(tmp_path):
"""apply_review没有 done 信号时拒绝应用"""
tm, task, main_path, skipped_path = _make_task(tmp_path, ["中国人:ちゅうごくじん:"])
proc = TaskProcessor(task, main=main_path, skipped=skipped_path)
proc.process_source()
# 没加 done → 拒绝
result = proc.apply_review()
assert result["success"] is False
def test_apply_review_accepts_and_skips(tmp_path):
"""apply_review待确认段无skip前缀进auto有skip前缀进skip"""
tm, task, main_path, skipped_path = _make_task(tmp_path, ["女将:おかみ:", "IT:アイティー:"])
proc = TaskProcessor(task, main=main_path, skipped=skipped_path)
proc.process_source()
# 女将分割失败进待确认(格式:女|将:おかみ:nv|jiangIT进建议skip
# 人工:女将手动分割假名(改成:女|将:おん|な:nv|jiangIT保持skip加done
content = proc.review.read_text(encoding="utf-8")
# 将"女|将:おかみ:"修正为"女|将:おん|な:"(简化测试,不追求正确性)
content = content.replace("女|将:おかみ:", "女|将:おん|な:")
proc.review.write_text(content + "done\n", encoding="utf-8", newline="\n")
result = proc.apply_review()
# 验证 IT 进 skip女将进auto_done
skip_lines = proc._read_clean_lines(proc.skip)
assert any("IT" in s for s in skip_lines)
def test_apply_review_add_marker(tmp_path):
"""apply_reviewadd 前缀的人工新增行也入主库(如一词多音的第二个读音)"""
tm, task, main_path, skipped_path = _make_task(tmp_path, ["農作物:のうさくぶつ:"])
proc = TaskProcessor(task, main=main_path, skipped=skipped_path)
proc.process_source()
content = proc.review.read_text(encoding="utf-8")
# 主行手动拆好假名,并用 add 追加第二个读音条目
content = content.replace(
"農|作|物:のうさくぶつ:nong|zuo|wu",
"農|作|物:のう|さく|ぶつ:nong|zuo|wu",
)
content += "add 農|作|物:のう|さく|もつ:nong|zuo|wu\n"
proc.review.write_text(content + "done\n", encoding="utf-8", newline="\n")
result = proc.apply_review()
assert result["success"] is True
# 两条读音都应进 auto_done
accepted = proc._read_clean_lines(proc.auto_done)
assert any("ぶつ" in s for s in accepted)
assert any("もつ" in s for s in accepted)
# ==========================================================================
# 5. 工作流编排CleanerWorkflow状态机推进
# ==========================================================================
def test_workflow_run_auto_complete(tmp_path):
"""全自动流程created → ready全部自动通过→ merged → completed"""
tm, task, main_path, skipped_path = _make_task(tmp_path, ["日本人:にほんじん:", "中国人:ちゅうごくじん:"])
wf = CleanerWorkflow(task, main=main_path, skipped=skipped_path)
# 第一步created → process → ready全部 auto_pass待确认段为0
r1 = wf.run()
assert r1["action"] == ACTION_PROCESSED
assert r1["status"] == STATUS_READY
# 第二步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 → 无done时返回待人工不推进"""
# 使用真正会进待确认的词:女将(分割失败)
tm, task, main_path, skipped_path = _make_task(tmp_path, ["女将:おかみ:", "日本人:にほんじん:"])
wf = CleanerWorkflow(task, main=main_path, skipped=skipped_path)
# 第一步created → process → reviewing有待确认条目
r1 = wf.run()
assert r1["action"] == ACTION_PROCESSED
assert r1["status"] == STATUS_REVIEWING
assert r1["review_count"] > 0
# 第二步reviewing 且无 done → 返回待人工,不推进
r2 = wf.run()
assert r2["action"] == ACTION_NEED_HUMAN
assert r2["status"] == STATUS_REVIEWING
assert "done" in r2["message"]
def test_workflow_review_done_advances_to_ready(tmp_path):
"""人工加 done 后run() 应用修改并推进到 ready"""
tm, task, main_path, skipped_path = _make_task(tmp_path, ["女将:おかみ:"])
wf = CleanerWorkflow(task, main=main_path, skipped=skipped_path)
proc = wf.processor
# 处理 → reviewing
r1 = wf.run()
assert r1["status"] == STATUS_REVIEWING
# 人工:把女将改成合格三段式(模拟人工分割修正),加 done
review_content = (
"# ===== 自动通过(无需确认)=====\n\n"
"# ===== 待确认(请检查,改拼音或加/删 skip=====\n"
"女|将:おか|み:nv|jiang # 无法拆分, L1\n"
"done\n"
)
proc.review.write_text(review_content, encoding="utf-8", newline="\n")
# 再次 run():应用 → 转 ready
r2 = wf.run()
assert r2["action"] == ACTION_PROCESSED
assert r2["status"] == STATUS_READY
def test_workflow_merge_rejects_bad_format(tmp_path):
"""合并时格式校验失败:不推进状态,保持 ready提示人工修正"""
tm, task, main_path, skipped_path = _make_task(tmp_path, ["日本人:にほんじん:"])
wf = CleanerWorkflow(task, main=main_path, skipped=skipped_path)
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"] or "校验" in r2["message"]
def test_workflow_empty_source(tmp_path):
"""空源文件:返回 empty不推进"""
tm, task, main_path, skipped_path = _make_task(tmp_path, ["", " "])
wf = CleanerWorkflow(task, main=main_path, skipped=skipped_path)
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, main_path, skipped_path = _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, main_path, skipped_path = _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)
t2 = tm.create_task("t2", source=str(source), start_line=2, count=1)
main_path = str(tmp_path / "main.txt")
skipped_path = str(tmp_path / "skip.txt")
p1 = TaskProcessor(t1, main=main_path, skipped=skipped_path)
p2 = TaskProcessor(t2, main=main_path, skipped=skipped_path)
# 处理 + 应用加done
_process_and_confirm(p1)
_process_and_confirm(p2)
# 各自单批文件独立
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