用 Claude Code 接手旧代码:先给 CSV 导出器补特征测试
先解释哪些旧行为必须保留,再让一次有限重构有可以复跑的依据。
- 把已观察行为与人确认的兼容合同分开
- 独立手写 CSV 预期,并用代表性输入约束排序、转义与金额
- 测试先通过旧实现,再限制重构范围,并检验测试能拒绝具体回归
1. 接手一个没有测试的导出函数
旧导出器只有二十多行,已经被好几个脚本调用。你想把筛选、排序和金额格式拆开,让下一次加字段容易一点,却说不清下游究竟依赖哪些细节:空结果是否带表头?相同名字的记录能不能换顺序?一分钱应该写成 0.01,还是 0.1?这些问题没有答案时,让编码助手直接“优化代码”,很容易把格式调整和业务变化混在一起。
本文从一份原创的 Python CSV 导出器开始。先读旧代码,把能观察到的行为记下来;由人决定本轮必须保留的合同,再写独立预期,最后才批准限定范围的重构。这类测试通常叫特征测试:它描述系统已经承担、且你决定继续承担的行为。测试本身并不能替你决定某个历史现象是不是正确需求。
示例使用 Python 标准库,无需安装依赖或插件。本文已在本地运行旧实现、本地编写的参考重构与故意损坏的版本;没有启动 Claude Code、访问 Claude 账户或调用模型。后面的对话是读者可执行的操作建议,不是伪造的 Claude 实测记录。三张图均为原创示意。CodexApp Guide 是独立教程站,本文不代表 Anthropic 官方说明。
2. 先把可接受行为与待确认问题分开
本例把输入验证放在调用方:records 是字典列表,name 是字符串,quantity 与 unit_cents 是非负整数且不含布尔值,status 只允许 paid、pending、cancelled。unit_cents 表示单价的整数分值,导出的 amount 是它与数量相乘后的十进制文本。这里只讨论软件中的格式兼容,不讨论真实结算、币种或财务规则。
这些限制必须先说清楚。旧函数虽然没有验证输入,也写了负数格式分支,但这不等于负数、浮点数、缺字段和任意状态都已经成为受支持接口。真实项目应检查调用者、旧报表、文档和相关人的判断。本例的“确认”是作者为教学明确作出的合同选择,不是假称某家公司完成过验收。
| 观察到的行为 | 本例决定 | 理由与测试抓手 |
|---|---|---|
| 四列固定顺序,LF 结尾 | 保留 | 完整字符串包含表头、字段顺序和末尾换行 |
| 按名称 casefold 后、数量升序 | 保留 | 混合大小写样本;不是地区化中文排序 |
| 同排序键保持输入顺序 | 保留 | 用两个金额不同的同键记录,再反转输入 |
| 逗号、双引号和换行正确转义 | 保留 | 一条刻意包含全部字符的名称 |
| 整数乘法后显示两位金额;零值不删 | 保留 | 7 分、335 分乘 3、零数量和零单价 |
| 默认排除 cancelled,显式 True 包含 | 保留 | 单条取消样本与完整输出分别断言 |
| 空结果仍有表头;输入不变 | 保留 | 空列表、全取消列表、深拷贝前后比较 |
| CANCELLED、未知状态被输出 | 待确认 | 超出有效输入合同,检查上游而不冻结旧现象 |
| 缺字段报 KeyError;负数有输出 | 待确认 | 不在本轮扩展验证或错误模型 |
“先不为可疑行为写兼容断言”并不等于可以顺手修掉它。本轮参考重构不主动改这些路径。若业务后来确认大写取消状态也必须过滤,应单独写出新需求和失败测试,再实现修复。那时改变的是行为,审查理由也应随之变化。
3. 在空目录放入原始样本
新建一个空目录,把下面的 legacy_export.py、sample_data.py 和后续 test_export.py 分别保存为 UTF-8 文件。原始导出器有意保留了混合职责:在一个函数中筛选记录、排序、计算金额并写 CSV。它不是从真实客户系统抽出的代码,样本名称和数据都是虚构的。
legacy_export.py
"""Original teaching fixture. Validated records only; not a spreadsheet sanitizer."""
import csv
import io
def export_csv(records, include_cancelled=False):
rows = []
for record in records:
if record['status'] == 'cancelled' and not include_cancelled:
continue
rows.append(record)
rows = sorted(rows, key=lambda r: (r['name'].casefold(), r['quantity']))
output = io.StringIO(newline='')
writer = csv.writer(output, lineterminator='\n')
writer.writerow(['name', 'quantity', 'amount', 'status'])
for row in rows:
cents = row['unit_cents'] * row['quantity']
sign = '-' if cents < 0 else ''
cents = abs(cents)
amount = f'{sign}{cents // 100}.{cents % 100:02d}'
writer.writerow([row['name'], row['quantity'], amount, row['status']])
return output.getvalue()
if __name__ == '__main__':
from sample_data import RECORDS
print(export_csv(RECORDS), end='')
sample_data.py
"""Synthetic input and independently handwritten expectations."""
RECORDS = [
{'name': 'zeta', 'quantity': 1, 'unit_cents': 500, 'status': 'cancelled'},
{'name': 'Bravo', 'quantity': 2, 'unit_cents': 105, 'status': 'paid'},
{'name': 'alpha', 'quantity': 2, 'unit_cents': 125, 'status': 'paid'},
{'name': 'ALPHA', 'quantity': 2, 'unit_cents': 200, 'status': 'pending'},
{'name': 'alpha', 'quantity': 1, 'unit_cents': 0, 'status': 'paid'},
{'name': 'Comma, "quote"\nline', 'quantity': 1, 'unit_cents': 7, 'status': 'paid'},
]
HEADER = 'name,quantity,amount,status\n'
EXPECTED = (
'name,quantity,amount,status\n'
'alpha,1,0.00,paid\n'
'alpha,2,2.50,paid\n'
'ALPHA,2,4.00,pending\n'
'Bravo,2,2.10,paid\n'
'"Comma, ""quote""\nline",1,0.07,paid\n'
)
EXPECTED_WITH_CANCELLED = EXPECTED + 'zeta,1,5.00,cancelled\n'
sample_data.py 里的 EXPECTED 是独立手写的常量。它没有调用 export_csv,也没有用另一段相似算法生成答案。测试的预期与实现如果一起计算、一起犯错,就失去了制约作用。作者先按合同排列记录,再逐项核对 amount 和引号,把结果写成可审查的字面量。
python3 -B legacy_export.py这个命令可直接运行原始 fixture。输出包含一个名称内部的换行,所以屏幕上看到的物理行数大于数据记录数。这是合法 CSV 的正常现象;不要为“行数整齐”把名称里的换行删掉。Python csv 文档说明 writer 负责分隔格式与引号。函数返回的是字符串,没有打开文件,因而本例合同也没有隐含承诺编码、BOM 或磁盘写入策略。
4. 让预期能够独立指出哪里变了
下面的测试默认导入旧实现。完整输出断言锁定组合行为,几个小测试负责让失败容易定位。金额样本中的 7 分会拒绝小数部分没补零的实现,335 分乘 3 会产生 10.05,零数量则确认记录没有被当成“无效”而自动删掉。
test_export.py
"""Same contract for either implementation; expected text is not generated."""
import copy
import importlib
import os
import unittest
from sample_data import RECORDS, HEADER, EXPECTED, EXPECTED_WITH_CANCELLED
export_csv = importlib.import_module(
os.environ.get('EXPORT_MODULE', 'legacy_export')).export_csv
class ExportContract(unittest.TestCase):
def test_complete_output(self):
self.assertEqual(export_csv(RECORDS), EXPECTED)
def test_empty_retains_header(self):
self.assertEqual(export_csv([]), HEADER)
def test_cancelled_flag(self):
row = [{'name': 'zeta', 'quantity': 1, 'unit_cents': 500,
'status': 'cancelled'}]
self.assertEqual(export_csv(row), HEADER)
self.assertEqual(export_csv(RECORDS, True), EXPECTED_WITH_CANCELLED)
def test_same_key_keeps_input_order(self):
rows = [
{'name': 'x', 'quantity': 1, 'unit_cents': 100, 'status': 'paid'},
{'name': 'X', 'quantity': 1, 'unit_cents': 200, 'status': 'pending'},
]
self.assertEqual(export_csv(rows), HEADER +
'x,1,1.00,paid\nX,1,2.00,pending\n')
self.assertEqual(export_csv(rows[::-1]), HEADER +
'X,1,2.00,pending\nx,1,1.00,paid\n')
def test_integer_money_and_zero_quantity(self):
rows = [
{'name': 'a', 'quantity': 3, 'unit_cents': 335, 'status': 'paid'},
{'name': 'b', 'quantity': 0, 'unit_cents': 999, 'status': 'pending'},
{'name': 'c', 'quantity': 1, 'unit_cents': 7, 'status': 'paid'},
]
self.assertEqual(export_csv(rows), HEADER +
'a,3,10.05,paid\nb,0,0.00,pending\nc,1,0.07,paid\n')
def test_does_not_change_input(self):
rows = copy.deepcopy(RECORDS)
before = copy.deepcopy(rows)
identities = [id(row) for row in rows]
export_csv(rows)
export_csv(rows, True)
self.assertEqual(rows, before)
self.assertEqual([id(row) for row in rows], identities)
if __name__ == '__main__':
unittest.main()
同键测试刻意反转了两条输入。只测一个顺序,错误实现可能刚好按金额排序后仍然通过;两个方向都要求保留输入顺序,可以区分“稳定排序”与“碰巧输出一样”。输入不变测试同时比较嵌套字典内容和列表中对象的身份顺序,避免重构使用原地 sort 或临时改写字典字段。
空列表与默认过滤掉唯一取消记录的列表都应返回表头。它们是不同入口到同一结果:前者覆盖没有数据,后者覆盖筛选后没有数据。取消开关还要在混合样本上测试,确认开启后不是把所有状态都改成 cancelled,也没有改变原有记录的次序。
特征测试可以比较完整字符串,这里选择这样做是因为列顺序、引号与换行都属于合同。如果只用 csv.reader 解析再比较字段,就可能放过你本来想固定的引号策略和行结束符。不过在只承诺语义、不承诺字节表示的接口上,解析后比较反而更合适。先确定消费者依赖的层次,再决定断言的颗粒度。
python3 -B -m unittest -v test_export第一次应当在旧实现完全不改的条件下运行。它不需要先红一次:这一阶段要证明测试描述了已确认的旧行为,而不是故意引入一个新要求。如果这里失败,先检查手写预期、样本和合同有没有冲突,不要让助手通过修改生产代码把基线“修绿”。
5. 把调查与改代码分成两次批准
Anthropic 的最佳实践建议先探索、规划,再实现,并提供能够执行的验证条件。本文把这个建议具体落实到没有测试的旧函数:先调查行为,第一次只加测试,第二次才重构。官方开发用例也把理解陌生代码、为既有代码补测试、按计划重构列为不同任务;它们不保证自动生成的测试天然正确。
如果你已在自己的环境配置 Claude Code,可进入实验目录运行下面的命令。安装和登录不属于本教程的实验步骤;只想学习测试设计的读者可以跳过 Claude 对话,直接运行全部 Python 文件。
claude --permission-mode plan该参数见CLI reference。查看状态栏确认 plan 模式,不要启用跳过权限检查的选项。权限模式文档说明计划阶段可读文件、运行探索命令并写计划,批准后才进入相应执行模式;允许 bypass 的交互会话存在例外,因此 plan 不能被笼统描述为任何设置下都有效的安全沙箱。遇到命令权限请求,逐条核对它与当前调查是否相关。
阅读 @legacy_export.py 和 @sample_data.py。
先不要改任何源文件或预期值。解释 export_csv 的输入、输出、排序、取消筛选、金额与 CSV 转义。
按“已观察 / 建议保留 / 可疑且待确认”列出行为,标明对应代码位置。
给出先补测试、再有限重构的计划。不要把未知状态、负数或缺字段的现象自动当作业务合同。这里 @ 后面是具体文件。常见工作流说明文件引用会纳入文件正文,目录引用展示文件列表而非全部文件内容。不要只写 @lab 就假定助手已经逐一读完源码。真实仓库应再提供一个有代表性的调用方和一份既有测试,让助手核对接口使用方式和测试风格;本例独立小目录没有隐藏依赖。
拿到调查结果后,把上面的行为表与自己的业务证据对照,删去未经确认的承诺。下面这份 BEHAVIOR.md 可以作为本实验的人审结果。它也提醒后续实现阶段哪些问题仍然悬而未决。
BEHAVIOR.md
# Export contract for this teaching fixture
The input has already been validated by its caller: records is a list of dictionaries;
name is a string, quantity and unit_cents are nonnegative integers (not booleans),
and status is one of paid, pending, cancelled. include_cancelled is a boolean.
The function returns a CSV string, not a file. No input object may be changed.
Accepted behavior:
- Fixed columns: name, quantity, amount, status; LF row endings and a final LF.
- Filter exactly cancelled by default; True includes cancelled unchanged.
- Sort by (name.casefold(), quantity), ascending; equal keys retain input order.
- amount = unit_cents * quantity rendered as decimal with exactly two places.
- csv.writer quoting for commas, double quotes and line breaks.
- Empty result still contains the header; zero amount and zero quantity remain.
Review separately, without adding compatibility assertions yet:
- Unknown/case-variant status currently slips through the cancellation filter.
- Missing keys raise KeyError; types are not validated here.
- Negative values have old formatting code but are outside this fixture's contract.
- Strings beginning with spreadsheet formula characters are not sanitized.
This is an author-defined teaching contract, not evidence of any real team's approval.
Do not convert these open questions into promises by copying current output.
我确认 @BEHAVIOR.md 中的 Accepted behavior 是本轮合同。
第一阶段只允许新增或修改 test_export.py,不修改 legacy_export.py 或 sample_data.py。
按这个合同写独立手写预期与特征测试,运行 python3 -B -m unittest -v test_export。
若旧代码不满足合同,报告差异并停下,不要改代码或把预期改成实际输出。
提交文件差异、命令和完整失败/通过结果,等我检查后再继续。在计划审批界面选择 Yes, manually approve edits;批准后会退出 plan 模式,再执行第一阶段。逐项批准编辑,并检查实际文件差异。本文已提供测试文件;亲自练习时,可以先只放原始实现与样本,让助手另写测试,再与本文参考比较,不必把参考测试当成唯一正确答案。检查重点是预期从哪里来、每个分支是否有代表性样本,以及有没有把不确定行为误写成永久合同。
6. 基线通过后,只拆开三个职责
第二次批准要小到能够审查。这里允许抽出筛选、排序和金额格式函数,保留 export_csv(records, include_cancelled=False) 接口,继续用 csv.writer。没有新增依赖、修改输入验证、扩展状态集合或改变异常承诺的必要。
在允许改写 legacy_export.py 之前,先给第7节保留一份已通过基线测试的旧实现。以下命令在本实验目录执行,只新建 legacy_export_baseline.py;文件已存在时会失败,不会覆盖已有基线。
python3 -B -c "from pathlib import Path
original = Path('legacy_export.py').read_bytes()
with Path('legacy_export_baseline.py').open('xb') as saved:
saved.write(original)"第7节的三个变体依赖旧源码中的具体语句,不能从重构后的文件继续按原字符串替换。将 legacy_export_baseline.py 留作这项实验的固定输入,不把它作为新的生产入口,也不要授权助手修改它。若你已经重构且没有留副本,从第3节原始代码单独保存此文件;不要用当前重构结果冒充旧基线。
基线测试已由我核对。现在只允许重构 legacy_export.py。
保持 export_csv(records, include_cancelled=False) 的签名、返回字符串和已确认输出。
把筛选、排序和金额格式提取为小函数,不新增依赖,不修改测试、样本、BEHAVIOR.md 或 legacy_export_baseline.py。
不要顺手修未知状态、负数或缺字段。运行原有测试并展示 diff;若需要改变任何合同,先问我。以下 refactored_export.py 是本文提供的参考答案,单独保留文件名是为了让读者并排复跑。真实 Claude 流程的目标文件仍是 legacy_export.py;不要求助手必须生成相同私有函数名。评价对象是公共行为和可读性,而不是与参考补丁逐字符一致。
refactored_export.py
"""Handwritten reference refactor; public interface remains unchanged."""
import csv
import io
def _select(records, include_cancelled):
return [r for r in records if include_cancelled or r['status'] != 'cancelled']
def _order(records):
return sorted(records, key=lambda r: (r['name'].casefold(), r['quantity']))
def _amount(unit_cents, quantity):
cents = unit_cents * quantity
sign = '-' if cents < 0 else ''
whole, fraction = divmod(abs(cents), 100)
return f'{sign}{whole}.{fraction:02d}'
def export_csv(records, include_cancelled=False):
output = io.StringIO(newline='')
writer = csv.writer(output, lineterminator='\n')
writer.writerow(['name', 'quantity', 'amount', 'status'])
for row in _order(_select(records, include_cancelled)):
writer.writerow([row['name'], row['quantity'],
_amount(row['unit_cents'], row['quantity']), row['status']])
return output.getvalue()
if __name__ == '__main__':
from sample_data import RECORDS
print(export_csv(RECORDS), end='')
Python 排序指南说明 sorted 返回新列表且排序稳定。关键变化是拆职责而非改算法。_order 仍使用返回新列表的 sorted;casefold 和数量构成同一排序键。_amount 仍然先做整数乘法,只把商和余数交给 divmod。不能为了“更简洁”转成浮点金额再格式化,也不应把 csv.writer 换成逗号拼接。私有函数的名字和调用层次并不是本轮要冻结的公共接口,因此测试继续从 export_csv 入口调用。
EXPORT_MODULE=refactored_export python3 -B -m unittest -v test_export
python3 -B refactored_export.py以上环境变量写法适用于 POSIX shell。PowerShell 中可先执行 $env:EXPORT_MODULE="refactored_export",再运行 python -B -m unittest -v test_export,最后执行 Remove-Item Env:EXPORT_MODULE。本文实际运行环境是 Linux;这些跨平台命令说明不代表已在 Windows 完成测试。
7. 故意弄坏三次,检查测试能不能拒绝
一份测试在旧代码和新代码上都通过,还可能只是断言太宽松。下面的小脚本在临时目录创建三个可导入的坏模块:交换表头两列、手工拼接 CSV 行、去掉金额补零。每次都运行同一份 test_export.py,并要求完整输出测试真的出现断言失败。语法错误、模块没找到和命令没有执行都不能算“抓住了回归”。
脚本读取第6节在重构前保存的 legacy_export_baseline.py,并在临时目录中制造三个旧基线变体。此时 legacy_export.py 可以已经完成重构,原合同仍须对它通过;不要为了让变体脚本运行而把工作文件改回旧版本。这三个反例只证明当前测试能拒绝列序、转义和补零错误,不证明任意新重构缺陷都已覆盖。
check_bad_versions.py
"""Run the actual contract against three deliberate mistakes in temp modules."""
import os
from pathlib import Path
import subprocess
import sys
import tempfile
ROOT = Path(__file__).resolve().parent
source = (ROOT / 'legacy_export_baseline.py').read_text(encoding='utf-8')
mutations = {
'column_order': (
"['name', 'quantity', 'amount', 'status']",
"['quantity', 'name', 'amount', 'status']",
),
'csv_escaping': (
"writer.writerow([row['name'], row['quantity'], amount, row['status']])",
"output.write(','.join(map(str, [row['name'], row['quantity'], amount, row['status']])) + '\\n')",
),
'amount_padding': (
"amount = f'{sign}{cents // 100}.{cents % 100:02d}'",
"amount = f'{sign}{cents // 100}.{cents % 100}'",
),
}
with tempfile.TemporaryDirectory() as temporary:
for name, (old, new) in mutations.items():
if source.count(old) != 1:
raise RuntimeError(f'{name}: mutation target changed')
module = f'bad_{name}'
Path(temporary, module + '.py').write_text(source.replace(old, new), encoding='utf-8')
env = dict(os.environ, EXPORT_MODULE=module,
PYTHONPATH=os.pathsep.join([temporary, str(ROOT)]),
PYTHONDONTWRITEBYTECODE='1')
result = subprocess.run([sys.executable, '-B', '-m', 'unittest', '-v', 'test_export'],
cwd=ROOT, env=env, capture_output=True, text=True)
print(f'=== {name}: exit {result.returncode} ===')
print(result.stderr, end='')
# A syntax/import error is not evidence that the contract caught the bug.
if result.returncode != 1 or 'FAIL: test_complete_output' not in result.stderr:
raise SystemExit(f'{name}: expected an assertion failure')
print('All three deliberate mistakes were rejected by output assertions.')
python3 -B check_bad_versions.py脚本的退出约定与普通测试不同:三个坏版本都被断言拒绝,脚本自身才返回 0;任何坏版本没有被正确拦住,它会非零退出。因此不要只看最外层的绿色状态,要读每个坏模块的失败原因。原始文件没有被覆盖,临时目录会在退出时清理。
这不是完整的变异测试平台,也没有测出“覆盖率达到多少”。三个变更是依据本次重构最容易破坏的合同手工挑选的。假如实际重构还改变了取消条件或排序键,应再挑出对应的错误版本,确认现有样本能够区分。不要为了提高测试数把同一事实拆成许多重复断言。
8. 怎样读本地验证结果
本文在 Linux 的 Python 3.12.14 上运行:旧实现的合同测试通过,参考重构的同一组测试通过,两个独立入口打印的 CSV 一致。三种坏实现都能加载并执行,且被 test_complete_output 的断言拒绝。每个实现当前有六个测试方法;这个数字只是帮助读者核对运行是否完整,不是质量目标。
测试方法中有多个断言,不能把六个方法宣传成六种全部覆盖的业务场景。完整字符串检查的是这份手写样本,几个补充样本覆盖特定边界。它们没有证明所有合法输入都等价,更没有证明任意 Unicode 名称、超大数据量或非法对象的行为。没有在真实仓库执行 Claude,也没有测量模型耗时、费用或重构速度。
如果第一次基线失败,把测试差异中的预期与实际逐字段展开。缺少末尾 LF、双引号数量不对或金额从 0.07 变成 0.7,都是可以独立定位的差异。若只有参考重构失败,先查最近改动,不要让助手同时改测试与实现来消除报错。若坏版本意外通过,说明断言或样本没有约束到你以为约束的行为。
真实项目最后还要查看 diff、复跑项目已有测试,并用有代表性的调用方验证接口。确认没有改动测试预期或扩大行为范围后,再决定是否提交;本文没有包含发布、上传数据或向外部服务发送真实报表的动作。
9. 接回真实导出器时,先处理这几个边界
真实 CSV 常有多个消费者。一个按列号读取的脚本会在列顺序改变时受影响,另一个按表头名称读取的工具可能不关心列序。先找出消费者再决定特征测试要固定多少细节。旧输出样本可以帮助调查,但不能未经核对就整体录成黄金文件,尤其要去除私人数据并核实样本是否代表受支持输入。
OWASP 的 CSV Injection 说明提醒:CSV 格式正确不代表可以安全地在任意电子表格中打开。名称若以 =、+、-、@ 等字符开头,某些表格程序或导入方式可能把内容解释为公式。csv.writer 处理的是分隔与引号,不是公式注入防护。本教学 fixture 没有这种名称,也没有宣称导出器是电子表格安全方案。真实需求若需要安全导出,应为指定消费者另定编码或文本策略、评估兼容影响并补测试,不能在兼容重构中偷偷加前缀。
另一个容易忽略的边界是换行和文件写入。这里明确返回 LF 结尾的 str。调用方最终用什么编码、是否写 BOM、怎样打开 newline 参数,应在那个边界再做集成测试。如果你只拿文本编辑器截图判断结果,往往看不出这些差异。
当业务确认一个可疑现象确实是缺陷,就为那次修复写新测试并说明旧行为为什么需要变更。特征测试的意义是让改变可见,而不是把所有历史偶然现象永远保存下来。能指出一次重构保留了什么、没有证明什么,以及接下来需要谁判断什么,比一句“全部通过,可以合并”更有用。
10. 文件写坏了,读回来却仍相等
上一节把文件写入留给调用方,这里把那条边界补成一个可运行实验。导出器返回正确字符串后,调用方若用 newline='\r\n' 打开文本文件,每个 LF 都会被转换。这个文本层不知道哪个 LF 结束一条记录,哪个 LF 属于引号里的名称。随后用默认 Path.read_text() 读回来,CRLF 又被转成 LF;一次看似严格的字符串相等检查,可能把写错的文件判成正确。
先看只有一条数据的输入:名称为 'two\nlines',数量为 1,单价为 7 分,状态为 paid。保留换行写出的文件有 52 字节;强制 CRLF 后有 55 字节。两者默认读回都等于导出器返回值,但以 newline='' 打开并交给 CSV reader,第二个文件的名称已经是 'two\r\nlines'。这是字段内容变化,不能只说成“不同系统的行尾”。
也要留下反方向的对照。名称仅为 plain 时,两份文件分别有 46、48 字节,解析字段完全一致,差异只在记录结束符。若消费者只承诺字段语义,未必需要拒绝这类表示变化;本次另行选择的文件合同则要求 UTF-8、无 BOM、保留导出字符串中的全部换行,因此仍会拒绝它。不要把这个教学选择扩展成所有 CSV 消费者的要求。
读取端也会改变正确文件。原始名称若为 'two\r\nlines',正确保存后仍应保有 CRLF;默认 newline=None 的读取却交给 CSV reader 一个 LF。TextIOWrapper 文档说明了这两种读写转换,csv 文档则要求文件对象使用 newline=''。CSV 引号不会让底层文本转换自动绕过字段。
下面所有文件操作实际运行于 Linux / Python 3.12.14。CRLF 是显式注入的写入策略,没有将它冒充 Windows 默认行为实测,也没有从文件结果推断 JSON 标准输入或 BOM 的处理方式。旧导出器和参考重构都不改,新增的是调用方的保存与验收实验。
11. 把五个输入落成十个文件
把下面三个文件与前文源代码放在同一目录。fixtures.json 的五个名称依次覆盖普通文本、字段内 LF、CRLF、单独 CR,以及中文、逗号、双引号和 LF 的组合。JSON 中的转义由 JSON 解析器还原,读者无需在编辑器中手工插入不可见字符。每个样本单独导出,以免排序掩盖观察目标。
fixtures.json
{
"plain": {"name": "plain", "quantity": 1, "unit_cents": 7, "status": "paid"},
"lf_field": {"name": "two\nlines", "quantity": 1, "unit_cents": 7, "status": "paid"},
"crlf_field": {"name": "two\r\nlines", "quantity": 1, "unit_cents": 7, "status": "paid"},
"cr_field": {"name": "two\rlines", "quantity": 1, "unit_cents": 7, "status": "paid"},
"unicode_quote": {"name": "\u96ea,\"quote\"\nend", "quantity": 1, "unit_cents": 7, "status": "paid"}
}
expected.json 给出十份完整 CSV 文本和两种读取方式下的名称。name_raw 是保留换行的解析结果,name_default 是默认读取后的结果;后者用于展示损失,不是新的兼容目标。这些预期是按样本逐项手写的,没有调用导出器、CSV writer 或读取实际产物来生成答案。测试只把已写明的文本编码为 UTF-8,再与磁盘字节比较。
expected.json
{
"plain": {
"preserve": {"text": "name,quantity,amount,status\nplain,1,0.07,paid\n", "name_raw": "plain", "name_default": "plain"},
"force_crlf": {"text": "name,quantity,amount,status\r\nplain,1,0.07,paid\r\n", "name_raw": "plain", "name_default": "plain"}
},
"lf_field": {
"preserve": {"text": "name,quantity,amount,status\n\"two\nlines\",1,0.07,paid\n", "name_raw": "two\nlines", "name_default": "two\nlines"},
"force_crlf": {"text": "name,quantity,amount,status\r\n\"two\r\nlines\",1,0.07,paid\r\n", "name_raw": "two\r\nlines", "name_default": "two\nlines"}
},
"crlf_field": {
"preserve": {"text": "name,quantity,amount,status\n\"two\r\nlines\",1,0.07,paid\n", "name_raw": "two\r\nlines", "name_default": "two\nlines"},
"force_crlf": {"text": "name,quantity,amount,status\r\n\"two\r\r\nlines\",1,0.07,paid\r\n", "name_raw": "two\r\r\nlines", "name_default": "two\n\nlines"}
},
"cr_field": {
"preserve": {"text": "name,quantity,amount,status\n\"two\rlines\",1,0.07,paid\n", "name_raw": "two\rlines", "name_default": "two\nlines"},
"force_crlf": {"text": "name,quantity,amount,status\r\n\"two\rlines\",1,0.07,paid\r\n", "name_raw": "two\rlines", "name_default": "two\nlines"}
},
"unicode_quote": {
"preserve": {"text": "name,quantity,amount,status\n\"\u96ea,\"\"quote\"\"\nend\",1,0.07,paid\n", "name_raw": "\u96ea,\"quote\"\nend", "name_default": "\u96ea,\"quote\"\nend"},
"force_crlf": {"text": "name,quantity,amount,status\r\n\"\u96ea,\"\"quote\"\"\r\nend\",1,0.07,paid\r\n", "name_raw": "\u96ea,\"quote\"\r\nend", "name_default": "\u96ea,\"quote\"\nend"}
}
}
file_newline_lab.py 为每个输入创建 preserve 与 force_crlf 两份真实 CSV。前者以 newline='' 禁用转换,后者以 newline='\r\n' 制造反例。报告同时保留原始字节的十六进制、长度、SHA-256 和两份字段视图;哈希方便核对文件身份,不能替代预期内容断言。保存与读取都显式使用 UTF-8,整个过程不访问网络或模型。
file_newline_lab.py
"""Synthetic CSV file-I/O experiment. No real input, model, or network access."""
import argparse
import csv
import hashlib
import json
from pathlib import Path
import platform
import sys
from legacy_export import export_csv
ROOT = Path(__file__).resolve().parent
POLICIES = {'preserve': '', 'force_crlf': '\r\n'}
def write_new(path, text, newline):
"""Create a new text file. I/O failure can leave a partial file; no rollback."""
with path.open('x', encoding='utf-8', errors='strict', newline=newline) as output:
output.write(text)
def inspect_file(path, original_text):
raw = path.read_bytes()
with path.open('r', encoding='utf-8', errors='strict', newline='') as source:
rows_preserved = list(csv.reader(source, strict=True))
with path.open('r', encoding='utf-8', errors='strict', newline=None) as source:
rows_default = list(csv.reader(source, strict=True))
return {
'raw_hex': raw.hex(' '),
'raw_sha256': hashlib.sha256(raw).hexdigest(),
'raw_bytes': len(raw),
'raw_matches_export_utf8': raw == original_text.encode('utf-8'),
'default_text_equals_export': path.read_text(encoding='utf-8') == original_text,
'rows_preserved': rows_preserved,
'rows_default': rows_default,
}
def run(output):
"""New output directory only. report.json is written after all ten files."""
fixtures = json.loads((ROOT / 'fixtures.json').read_text(encoding='utf-8'))
output.mkdir(parents=True, exist_ok=False)
report = {'python': platform.python_version(), 'platform': platform.system(),
'scope': 'Actual local files; explicit CRLF policy, not a Windows test',
'cases': {}}
for name, record in fixtures.items():
original = export_csv([record])
report['cases'][name] = {}
for policy, newline in POLICIES.items():
path = output / f'{name}-{policy}.csv'
write_new(path, original, newline)
report['cases'][name][policy] = inspect_file(path, original)
with (output / 'report.json').open('x', encoding='utf-8', newline='') as target:
json.dump(report, target, ensure_ascii=True, indent=2)
target.write('\n')
return report
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('output', type=Path, help='new directory for ten synthetic CSV files')
args = parser.parse_args()
try:
report = run(args.output)
except (OSError, UnicodeError, csv.Error) as exc:
print(f'{type(exc).__name__}: {exc}', file=sys.stderr)
print('No successful result. Any created directory may be incomplete.', file=sys.stderr)
return 2
for name, policies in report['cases'].items():
good, bad = policies['preserve'], policies['force_crlf']
print(f'{name}: preserve={good["raw_bytes"]}B, force_crlf={bad["raw_bytes"]}B; '
f'bad_default_text_equal={str(bad["default_text_equals_export"]).lower()}')
print('Saved ten synthetic files. Inspect bytes and preserved field values separately.')
return 0
if __name__ == '__main__':
sys.exit(main())
python3 -B file_newline_lab.py experiment-new
首次运行使用一个尚不存在的目录名。以下为本次完整标准输出;最后一列只反映默认文本相等,不能当作文件正确性的结论。
plain: preserve=46B, force_crlf=48B; bad_default_text_equal=true
lf_field: preserve=52B, force_crlf=55B; bad_default_text_equal=true
crlf_field: preserve=53B, force_crlf=56B; bad_default_text_equal=false
cr_field: preserve=52B, force_crlf=54B; bad_default_text_equal=false
unicode_quote: preserve=60B, force_crlf=63B; bad_default_text_equal=true
Saved ten synthetic files. Inspect bytes and preserved field values separately.
再保存只读检查脚本,用它展开两个容易被报告布尔值掩盖的事实。这里用 Path.open() 明确选择读取策略:Python 3.12 的 read_text 接口没有 newline 参数;read_bytes() 则直接返回文件字节。
inspect_readback.py
"""Read-only display of three views of the generated synthetic files."""
import csv
from pathlib import Path
import sys
from legacy_export import export_csv
root = Path(sys.argv[1])
record = {'name': 'two\nlines', 'quantity': 1, 'unit_cents': 7, 'status': 'paid'}
original = export_csv([record])
for policy in ['preserve', 'force_crlf']:
path = root / f'lf_field-{policy}.csv'
with path.open('r', encoding='utf-8', newline='') as source:
name = list(csv.reader(source, strict=True))[1][0]
print(f'{policy}: {len(path.read_bytes())} bytes')
print('default text equal:', path.read_text(encoding='utf-8') == original)
print('raw bytes equal:', path.read_bytes() == original.encode('utf-8'))
print('preserved name:', repr(name))
path = root / 'crlf_field-preserve.csv'
for newline in ['', None]:
with path.open('r', encoding='utf-8', newline=newline) as source:
name = list(csv.reader(source, strict=True))[1][0]
print(f'correct CRLF file, reader newline={newline!r}: {name!r}')
python3 -B inspect_readback.py experiment-new
preserve: 52 bytes
default text equal: True
raw bytes equal: True
preserved name: 'two\nlines'
force_crlf: 55 bytes
default text equal: True
raw bytes equal: False
preserved name: 'two\r\nlines'
correct CRLF file, reader newline='': 'two\r\nlines'
correct CRLF file, reader newline=None: 'two\nlines'
已有 CRLF 再经过强制 CRLF 写入时,原先的 CR 留下、LF 又扩展为 CRLF,字段变成 'two\r\r\nlines';默认读取后是 'two\n\nlines'。这也说明不能对整个 CSV 做 replace('\r\n', '\n') 来“修复行尾”:它会同时改动合法字段。真要改变记录结束符,应另行确认序列化合同,在字段层保留原值,并让 CSV writer 使用所需的 lineterminator;不要在这次兼容重构里偷偷替换整份输出。
12. 让边界测试拒绝三种坏修复
下面的九个测试独立运行在真实临时目录中。除了比较十份文件的完整字节和字段,它还复现默认读取造成的假通过、普通名称的记录结束符对照,以及正确 CRLF 字段被读取或全局替换破坏的情形。两个原有导出器都先对五份固定文本预期通过,文件实验不需要改它们。
test_file_newline_lab.py
"""Independent handwritten byte/field expectations; actual temporary file I/O."""
import csv
import hashlib
import importlib
import os
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
import legacy_export
import refactored_export
lab = importlib.import_module(os.environ.get('FILE_LAB_MODULE', 'file_newline_lab'))
ROOT = Path(__file__).resolve().parent
FIXTURES = json.loads((ROOT / 'fixtures.json').read_text(encoding='utf-8'))
EXPECTED = json.loads((ROOT / 'expected.json').read_text(encoding='utf-8'))
HEADER = ['name', 'quantity', 'amount', 'status']
def rows(name):
return [HEADER, [name, '1', '0.07', 'paid']]
class FileBoundary(unittest.TestCase):
def test_both_unchanged_exporters_match_fixed_text(self):
for module in [legacy_export, refactored_export]:
for name, record in FIXTURES.items():
with self.subTest(module=module.__name__, case=name):
self.assertEqual(module.export_csv([record]), EXPECTED[name]['preserve']['text'])
def test_all_ten_files_match_fixed_bytes_and_both_field_views(self):
with tempfile.TemporaryDirectory() as td:
output = Path(td) / 'experiment'
report = lab.run(output)
self.assertEqual(len(list(output.glob('*.csv'))), 10)
self.assertEqual(set(report['cases']), set(EXPECTED))
for name, policies in EXPECTED.items():
for policy, want in policies.items():
with self.subTest(case=name, policy=policy):
path = output / f'{name}-{policy}.csv'
self.assertEqual(path.read_bytes(), want['text'].encode('utf-8'))
actual = report['cases'][name][policy]
self.assertEqual(actual['rows_preserved'], rows(want['name_raw']))
self.assertEqual(actual['rows_default'], rows(want['name_default']))
self.assertEqual(actual['raw_bytes'], len(want['text'].encode('utf-8')))
self.assertEqual(actual['raw_hex'], want['text'].encode('utf-8').hex(' '))
self.assertEqual(actual['raw_sha256'],
hashlib.sha256(want['text'].encode('utf-8')).hexdigest())
def test_record_terminator_difference_can_preserve_fields(self):
with tempfile.TemporaryDirectory() as td:
report = lab.run(Path(td) / 'experiment')['cases']['plain']
self.assertFalse(report['force_crlf']['raw_matches_export_utf8'])
self.assertEqual(report['preserve']['rows_preserved'], report['force_crlf']['rows_preserved'])
self.assertTrue(report['force_crlf']['default_text_equals_export'])
def test_default_text_equality_hides_embedded_lf_corruption(self):
with tempfile.TemporaryDirectory() as td:
report = lab.run(Path(td) / 'experiment')['cases']['lf_field']['force_crlf']
self.assertTrue(report['default_text_equals_export'])
self.assertFalse(report['raw_matches_export_utf8'])
self.assertEqual(report['rows_preserved'][1][0], 'two\r\nlines')
self.assertNotEqual(report['rows_preserved'], rows('two\nlines'))
self.assertEqual(report['rows_default'], rows('two\nlines'))
def test_default_read_changes_a_correct_crlf_field(self):
with tempfile.TemporaryDirectory() as td:
report = lab.run(Path(td) / 'experiment')['cases']['crlf_field']['preserve']
self.assertTrue(report['raw_matches_export_utf8'])
self.assertEqual(report['rows_preserved'], rows('two\r\nlines'))
self.assertEqual(report['rows_default'], rows('two\nlines'))
self.assertNotEqual(report['rows_preserved'], report['rows_default'])
def test_whole_text_newline_replacement_is_not_record_conversion(self):
original = EXPECTED['crlf_field']['preserve']['text']
defective = original.replace('\r\n', '\n')
with tempfile.TemporaryDirectory() as td:
path = Path(td) / 'bad.csv'
lab.write_new(path, defective, '')
actual = lab.inspect_file(path, original)
self.assertFalse(actual['raw_matches_export_utf8'])
self.assertEqual(actual['rows_preserved'], rows('two\nlines'))
self.assertNotEqual(actual['rows_preserved'], rows('two\r\nlines'))
def test_existing_file_and_directory_are_not_overwritten(self):
with tempfile.TemporaryDirectory() as td:
path = Path(td) / 'keep.csv'
path.write_bytes(b'ORIGINAL')
with self.assertRaises(FileExistsError):
lab.write_new(path, 'replacement', '')
self.assertEqual(path.read_bytes(), b'ORIGINAL')
with self.assertRaises(FileExistsError):
lab.run(Path(td))
self.assertEqual(list(Path(td).iterdir()), [path])
def test_mid_run_io_failure_has_no_final_report(self):
real_write = lab.write_new
calls = 0
def fail_third(path, text, newline):
nonlocal calls
calls += 1
if calls == 3:
raise OSError('synthetic write failure')
real_write(path, text, newline)
with tempfile.TemporaryDirectory() as td:
output = Path(td) / 'incomplete'
with patch.object(lab, 'write_new', fail_third):
with self.assertRaisesRegex(OSError, 'synthetic write failure'):
lab.run(output)
self.assertEqual(len(list(output.glob('*.csv'))), 2)
self.assertFalse((output / 'report.json').exists())
with self.assertRaises(FileExistsError):
lab.run(output)
def test_cli_reports_existing_destination_as_failure(self):
with tempfile.TemporaryDirectory() as td:
result = subprocess.run([sys.executable, '-B', str(ROOT / 'file_newline_lab.py'), td],
capture_output=True, timeout=5)
self.assertEqual(result.returncode, 2)
self.assertEqual(result.stdout, b'')
self.assertIn(b'FileExistsError', result.stderr)
self.assertIn(b'No successful result', result.stderr)
self.assertEqual(list(Path(td).iterdir()), [])
if __name__ == '__main__':
unittest.main()
测试通过后,再故意损坏文件边界三次:把 preserve 写入也改成 CRLF、把保留换行的读取改成默认读取、写入前全局替换 CRLF。下面脚本只在临时目录制造坏模块。每个模块必须实际运行,并在完整字节/字段测试中出现断言失败;导入失败、语法错误或其他 ERROR 都不能算成功抓住问题。
check_bad_file_versions.py
"""Require three real file-boundary defects to fail the fixed byte/field test."""
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
ROOT = Path(__file__).resolve().parent
source = (ROOT / 'file_newline_lab.py').read_text(encoding='utf-8')
mutations = {
'translate_write': (
"POLICIES = {'preserve': '', 'force_crlf': '\\r\\n'}",
"POLICIES = {'preserve': '\\r\\n', 'force_crlf': '\\r\\n'}",
),
'normalize_read': (
"with path.open('r', encoding='utf-8', errors='strict', newline='') as source:",
"with path.open('r', encoding='utf-8', errors='strict', newline=None) as source:",
),
'replace_whole_csv': (
'output.write(text)',
"output.write(text.replace('\\r\\n', '\\n'))",
),
}
for name, (old, new) in mutations.items():
if source.count(old) != 1:
raise RuntimeError(f'{name}: expected one mutation target')
with tempfile.TemporaryDirectory() as temporary:
work = Path(temporary)
for item in ROOT.iterdir():
if item.suffix in {'.py', '.json'}:
shutil.copyfile(item, work / item.name)
module = 'bad_' + name
(work / (module + '.py')).write_text(source.replace(old, new), encoding='utf-8')
env = dict(os.environ, FILE_LAB_MODULE=module, PYTHONDONTWRITEBYTECODE='1')
env.pop('EXPORT_MODULE', None)
result = subprocess.run(
[sys.executable, '-B', '-m', 'unittest', '-v', 'test_file_newline_lab'],
cwd=work, env=env, capture_output=True, text=True, timeout=20)
print(f'=== {name}: exit {result.returncode} ===')
print(result.stderr, end='')
caught = 'FAIL: test_all_ten_files_match_fixed_bytes_and_both_field_views'
if result.returncode != 1 or caught not in result.stderr or 'ERROR:' in result.stderr:
raise SystemExit(f'{name}: expected byte/field assertion failure without errors')
print('All three file-boundary mistakes were rejected by byte/field assertions.')
python3 -B -m unittest -v test_export
EXPORT_MODULE=refactored_export python3 -B -m unittest -v test_export
python3 -B check_bad_versions.py
python3 -B -m unittest -v test_file_newline_lab
python3 -B check_bad_file_versions.py
本次复跑中,原有六个测试在旧实现与参考重构上分别通过,原有列序、CSV 转义、金额补零三个坏版本仍被拒绝;新增九个测试通过,新增三个文件边界坏版本也都被字节或字段断言拒绝。变体检查器自身退出 0,表示它看到了预期失败。六个旧方法、九个新方法和多次子测试不是“全部输入已覆盖”的证明。
失败恢复也属于文件边界。目录必须不存在,每个 CSV 用排他创建模式 'x',避免覆盖已留存的结果;其语义见 open 文档。第二次向同一目录运行时,命令返回 2,标准输出为空,标准错误包含 FileExistsError。测试还在第三次写入前注入 I/O 错误,确认留下两份 CSV、没有最终报告,且对原目录的重跑被拒绝。
这不提供事务或原子写入保证:现实中的故障可能发生在文件中途,也可能留下半份 report.json。因此看见报告文件不等于完成;要检查进程退出状态、报告可解析性与字节/字段断言。遇到失败先保留并检查现场,排除权限、空间或路径问题,再选一个新目录名重跑。不要自动删除输出、覆盖原始报表,或把手写预期改成实际结果来消除失败。
真实接入时,先问清消费者究竟依赖文件字节还是字段值,再为对应层次写断言。本例两者都要求:原始字节对独立预期,字段用保留换行的只读文件对象解析。写入正确与读取正确需要分别证明,单一的“读回来相等”不够。
本补充的 Python 官方主源核对日期为 2026-10-13;3.12 文档页面当时标为 3.12.15,运行时实际是 3.12.14。新增实验没有验证 Windows、macOS、电子表格、真实 Claude 会话或生产文件,也没有提供大文件性能、恶意路径隔离与崩溃恢复保证。配套离线包包括完整文章、原始代码、固定输入、独立预期、新旧测试和原创图;不含执行缓存、实际运行日志或账户信息。
下载完整离线实验包(SHA-256:5f327fab75cb4b501dbdee94d3baf934b5b9c898461d355696830c9df3dfcbd9)。也可逐文件复制正文中的代码,保留前文的旧基线副本后复跑。
13. 补一个能区分 casefold 与 lower 的排序见证
原有六个测试仍有一个可以实际复现的缺口:把旧基线排序键里的 .casefold() 换成 .lower(),六项全部通过。alpha、ALPHA、x、X 的大小写样本区分不了这两个操作。通过测试只说明这些样本没有发现差异,不能证明排序合同中的 casefold 已被约束。
Python 的 str.casefold 文档给出一个直接见证:'ß'.casefold() 是 'ss',而 'ß'.lower() 仍是 'ß'。所以名称 ß 与 SS 的第一排序键相同;数量也相同时,应按稳定排序保留输入顺序,数量不同时则先按数量排序。导出的名称仍须保留原始拼写,不能把折叠后的键写回字段。这没有新增业务要求,只是为前文已经选择的合同补一个能区分错误实现的样本。
把下面两个文件放到同一实验目录。新的测试单独保存,不修改原有六项,也不改变两个导出器。两份完整预期直接写成字面量,没有调用 casefold、lower、sorted 或被测导出器生成答案。同键测试以两个输入方向运行;另一项把数量为 2 的 SS 放在前面,要求数量为 1 的 ß 先输出,避免只测试输入已经排好的情况。
test_casefold_contract.py
"""Two handwritten cases distinguish casefold ordering from lower ordering."""
import importlib
import os
import unittest
export_csv = importlib.import_module(
os.environ.get('EXPORT_MODULE', 'legacy_export')).export_csv
HEADER = 'name,quantity,amount,status\n'
class CasefoldContract(unittest.TestCase):
def test_folded_ties_keep_input_order_and_original_names(self):
rows = [
{'name': 'ß', 'quantity': 1, 'unit_cents': 100, 'status': 'paid'},
{'name': 'SS', 'quantity': 1, 'unit_cents': 200, 'status': 'pending'},
]
cases = [
(rows, HEADER + 'ß,1,1.00,paid\nSS,1,2.00,pending\n'),
(rows[::-1], HEADER + 'SS,1,2.00,pending\nß,1,1.00,paid\n'),
]
for index, (records, expected) in enumerate(cases):
with self.subTest(order=index):
self.assertEqual(export_csv(records), expected)
def test_folded_name_tie_uses_quantity_before_input_order(self):
rows = [
{'name': 'SS', 'quantity': 2, 'unit_cents': 100, 'status': 'pending'},
{'name': 'ß', 'quantity': 1, 'unit_cents': 100, 'status': 'paid'},
]
self.assertEqual(export_csv(rows), HEADER +
'ß,1,1.00,paid\nSS,2,2.00,pending\n')
if __name__ == '__main__':
unittest.main()
先确认旧实现与参考重构都满足补充后的合同。每个命令合计运行八个测试方法,其中两项来自新文件;不要把子测试的次数当成额外方法数。
python3 -B -m unittest -v test_export test_casefold_contract
EXPORT_MODULE=refactored_export python3 -B -m unittest -v test_export test_casefold_contract
再复现原有测试漏过、补充测试拒绝的对照。脚本读取前面保留的 legacy_export_baseline.py,只在临时目录创建 lower 变体。先要求原有测试仍然通过,再要求两个新测试实际出现排序断言失败;导入失败、运行错误或超时不能算抓住这个缺陷。若你后来已经给原有测试补了相同见证,第一阶段可能不再通过,应检查测试变化,不要削弱测试来维持这项历史对照。
check_casefold_regression.py
"""Reproduce the ASCII-only gap, then require the added contract to reject it."""
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
ROOT = Path(__file__).resolve().parent
source = (ROOT / 'legacy_export_baseline.py').read_text(encoding='utf-8')
if source.count('.casefold()') != 1:
raise SystemExit('Expected exactly one casefold call in the saved old baseline.')
with tempfile.TemporaryDirectory() as temporary:
work = Path(temporary)
for name in ['test_export.py', 'sample_data.py', 'test_casefold_contract.py']:
shutil.copyfile(ROOT / name, work / name)
(work / 'bad_lower.py').write_text(
source.replace('.casefold()', '.lower()'), encoding='utf-8')
env = dict(os.environ, EXPORT_MODULE='bad_lower', PYTHONDONTWRITEBYTECODE='1')
env.pop('PYTHONPATH', None)
flags = ['-O'] if sys.flags.optimize else []
for suite in ['test_export', 'test_casefold_contract']:
result = subprocess.run(
[sys.executable, '-B', *flags, '-m', 'unittest', '-v', suite],
cwd=work, env=env, capture_output=True, text=True, timeout=10)
print(f'=== lower mutant / {suite}: exit {result.returncode} ===')
print(result.stderr, end='')
if suite == 'test_export':
if result.returncode != 0:
raise SystemExit('The old suite no longer reproduces the demonstrated gap; inspect it.')
else:
failures = [
'FAIL: test_folded_ties_keep_input_order_and_original_names',
'FAIL: test_folded_name_tie_uses_quantity_before_input_order',
]
if (result.returncode != 1 or 'ERROR:' in result.stderr or
any(marker not in result.stderr for marker in failures)):
raise SystemExit('Expected both ordering assertion failures, with no test errors.')
print('The old suite missed lower(); the added casefold assertions rejected it.')
python3 -B check_casefold_regression.py
2026-10-20 在 Linux / Python 3.12.14 复跑:旧实现和参考重构各八项通过;lower 变体对原有六项退出 0,对新增两项退出 1,出现两处预期断言失败且无 ERROR。检查器自身退出 0,表示它确认了这组对照,不表示坏变体正确。使用 python3 -O -B 重跑上述三条命令也得到相同结论;检查器会把优化标志传给测试子进程,测试使用 unittest 断言而非会被 -O 移除的普通 assert。
这两个见证仍不代表全部 Unicode、地区化排序、规范化或所有合法输入已验证,也没有检验真实 Claude 会话、模型、生产数据、Windows 或 macOS。前文的文件边界实验与三种原有坏实现不变。本节新增源码已收入前面的完整离线实验包;复跑时仍要运行原有合同、文件边界与各自反例,而不是只跑这两个新增测试。
14. 官方资料与复跑文件
官方页面核对日期为 2026-10-07。产品工作流、参数和权限语义以官方文档为准;CSV fixture、行为表、提示词和测试设计均为本文原创。
- Common workflows:具体文件引用与规划入口
- Best practices:探索、计划与可执行验证
- CLI reference:--permission-mode 参数
- Permission modes:计划审批和模式边界
- Common developer use cases:理解旧代码、补测试和重构
本文所有源码、原始输入、手写预期和测试均已逐文件展开,复制到同一个空目录即可复跑。没有账户信息、真实客户数据、执行缓存或私有验证材料。
测试通过就证明重构完全等价吗?
没有。这里验证有限样本与明确合同;真实项目仍要补调用方、代表性数据和既有测试。
文章展示的是 Claude 实际生成的代码吗?
不是。旧实现、测试与参考重构均为本文提供的原创教学样本,Claude 对话是读者操作建议。
应该把每个旧现象都写成测试吗?
不应该。先调查并由人确认哪些是合同;未知状态、负数、缺字段等问题单独记录,不自动冻结。
CSV 引号能防电子表格公式注入吗?
不能保证。示例只演示格式合同,没有针对任意表格程序的公式安全策略。