Files
2026-10-18 16:21:54 +08:00

180 lines
11 KiB
Python

"""Cross-version migration: old research remains intact, framework remains clean."""
from __future__ import annotations
import importlib.util
import json
import os
from pathlib import Path
import shutil
import sys
import tempfile
import unittest
from zipfile import ZipFile, ZIP_DEFLATED
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('c4h_migrate', ROOT / 'tools' / 'migrate.py')
mig = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = mig
spec.loader.exec_module(mig)
class MigrationTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.base = Path(self.tmp.name)
self.old = self.base / 'old'
self.new = self.base / 'new'
shutil.copytree(ROOT, self.old, ignore=shutil.ignore_patterns('.git', '__pycache__', '.pytest_cache'))
shutil.copytree(ROOT, self.new, ignore=shutil.ignore_patterns('.git', '__pycache__', '.pytest_cache'))
self.bundle = self.base / 'research.c4h.zip'
def make_research(self):
(self.old / 'topic.md').write_text('历史主题:托儿所与社会责任\n', encoding='utf-8')
(self.old / 'USERINFO.md').write_text('只使用中文。\n', encoding='utf-8')
(self.old / 'STATE.md').write_text('已精读三份史料;正在准备章节一。\n', encoding='utf-8')
(self.old / '01_sources/originals/primary/ARCHIVE.pdf').write_bytes(b'%PDF-1.7\nraw original bytes\x00\xff')
(self.old / '02_evidence/EV-0001-sample.md').write_text('原文已校核,第32页。', encoding='utf-8')
(self.old / '06_arguments/syntheses/SYN-0001-compare.md').write_text('多份材料形成对照。', encoding='utf-8')
(self.old / '07_manuscript/sections/01-draft.md').write_text('第一节:制度变化。', encoding='utf-8')
(self.old / '09_outputs/稿件终稿.docx').write_bytes(b'office binary\x00')
(self.old / '00_governance/research-log.md').write_text('2026-10-17 阅读完成', encoding='utf-8')
(self.old / '.env').write_text('SECRET_SHOULD_NOT_LEAK=true')
(self.old / 'AGENTS.md').write_text('OLD USER RULE: do not discard this customization', encoding='utf-8')
(self.old / '.agents/skills/custom-skill/SKILL.md').parent.mkdir(parents=True, exist_ok=True)
(self.old / '.agents/skills/custom-skill/SKILL.md').write_text('custom code/skill', encoding='utf-8')
def test_export_and_restore_protects_original_and_rules(self):
self.make_research()
initial_original = (self.old / '01_sources/originals/primary/ARCHIVE.pdf').read_bytes()
original_rules = (self.new / 'AGENTS.md').read_bytes()
package = mig.export(self.old, self.bundle, quiet=True)
names = {x['path']: x['kind'] for x in package['entries']}
self.assertEqual(names['AGENTS.md'], 'framework_customization')
self.assertEqual(names['.agents/skills/custom-skill/SKILL.md'], 'framework_customization')
self.assertIn('01_sources/originals/primary/ARCHIVE.pdf', names)
self.assertNotIn('.env', names)
self.assertNotIn('tools/migrate.py', names)
self.assertNotIn('tools/migration_baselines.json', names)
summary = mig.restore(self.bundle, self.new, quiet=True)
self.assertGreater(summary['fill-stock-placeholder'], 1)
self.assertEqual((self.new / 'USERINFO.md').read_text(encoding='utf-8'), '只使用中文。\n')
self.assertEqual((self.new / 'STATE.md').read_text(encoding='utf-8'), '已精读三份史料;正在准备章节一。\n')
self.assertEqual((self.new / 'topic.md').read_text(encoding='utf-8'), '历史主题:托儿所与社会责任\n')
self.assertEqual((self.new / '01_sources/originals/primary/ARCHIVE.pdf').read_bytes(), initial_original)
self.assertEqual((self.old / '01_sources/originals/primary/ARCHIVE.pdf').read_bytes(), initial_original)
self.assertEqual((self.new / 'AGENTS.md').read_bytes(), original_rules)
self.assertFalse((self.new / '.agents/skills/custom-skill').exists())
self.assertTrue(list((self.new / '.code4history-migration/conflicts').glob('*/framework_customizations/AGENTS.md')))
self.assertTrue(list((self.new / '.code4history-migration/conflicts').glob('*/framework_customizations/.agents/skills/custom-skill/SKILL.md')))
self.assertTrue((self.new / '09_outputs/稿件终稿.docx').exists())
def test_exchange_area_and_pending_upload_survive_upgrade(self):
# The workbench is project data, even when the pending files are gitignored.
self.make_research()
for name in ('upload','todo','requests','handoff'):
(self.old/name).mkdir(exist_ok=True)
(self.old/'upload/unread.pdf').write_bytes(b'%PDF-1.4\nnever throw away')
(self.old/'upload/unread.pdf.source.md').write_text('Archive reference unknown; ask researcher.')
(self.old/'todo/TODO-0007-confirm.md').write_text('---\nstatus: open\n---\nNeed original page.')
(self.old/'requests/recheck.md').write_text('Please compare these two translations.')
(self.old/'handoff/latest.md').write_text('研究进展:等待补充原件。')
mig.export(self.old,self.bundle,quiet=True)
mig.restore(self.bundle,self.new,quiet=True)
self.assertEqual((self.new/'upload/unread.pdf').read_bytes(),b'%PDF-1.4\nnever throw away')
self.assertEqual((self.new/'upload/unread.pdf.source.md').read_text(),'Archive reference unknown; ask researcher.')
self.assertIn('Need original page.',(self.new/'todo/TODO-0007-confirm.md').read_text())
self.assertIn('translations.',(self.new/'requests/recheck.md').read_text())
self.assertEqual((self.new/'handoff/latest.md').read_text(),'研究进展:等待补充原件。')
self.assertTrue((self.old/'upload/unread.pdf').exists())
def test_existing_user_content_not_overwritten(self):
self.make_research()
(self.new / 'topic.md').write_text('新项目自己已经有主题,不能覆盖。', encoding='utf-8')
mig.export(self.old, self.bundle, quiet=True)
summary = mig.restore(self.bundle, self.new, quiet=True)
self.assertGreater(summary['conflict'], 0)
self.assertEqual((self.new / 'topic.md').read_text(encoding='utf-8'), '新项目自己已经有主题,不能覆盖。')
incoming = list((self.new / '.code4history-migration/conflicts').glob('*/incoming/topic.md'))
self.assertEqual(len(incoming), 1)
self.assertIn('历史主题', incoming[0].read_text(encoding='utf-8'))
def test_tampered_bundle_rejected_before_writes(self):
self.make_research()
mig.export(self.old, self.bundle, quiet=True)
damaged = self.base / 'tampered.zip'
with ZipFile(self.bundle, 'r') as src, ZipFile(damaged, 'w', ZIP_DEFLATED) as dst:
for member in src.namelist():
data = src.read(member)
if member == 'research/topic.md': data = b'TAMPERED'
dst.writestr(member, data)
original_topic = (self.new / 'topic.md').read_bytes()
with self.assertRaises(ValueError): mig.restore(damaged, self.new, quiet=True)
self.assertEqual((self.new / 'topic.md').read_bytes(), original_topic)
self.assertFalse((self.new / '02_evidence/EV-0001-sample.md').exists())
def test_export_standalone_and_dry_run(self):
self.make_research()
manifest = mig.export(self.old, self.bundle, dry_run=True, quiet=True)
self.assertTrue(any(x['path'] == 'topic.md' for x in manifest['entries']))
self.assertFalse(self.bundle.exists())
mig.export(self.old, self.bundle, quiet=True)
before = (self.new / 'topic.md').read_bytes()
summary = mig.restore(self.bundle, self.new, dry_run=True, quiet=True)
self.assertGreater(summary['fill-stock-placeholder'], 0)
self.assertEqual((self.new / 'topic.md').read_bytes(), before)
def test_reject_unsafe_bundle_path(self):
b = self.base / 'evil.zip'
payload = {'format': mig.BUNDLE_TYPE, 'version': 1, 'entries': [
{'path': '../other', 'kind': 'research', 'size': 1, 'sha256': '0'*64}
]}
with ZipFile(b, 'w') as z:
z.writestr('manifest.json', json.dumps(payload))
z.writestr('research/../other', 'x')
with self.assertRaises(ValueError): mig.restore(b, self.new, quiet=True)
def test_restore_rejects_target_symlink(self):
self.make_research()
mig.export(self.old, self.bundle, quiet=True)
original = self.base / 'outer'
original.mkdir()
shutil.rmtree(self.new / '02_evidence')
try:
(self.new / '02_evidence').symlink_to(original, target_is_directory=True)
except (OSError, NotImplementedError):
self.skipTest('OS disallows symlink')
with self.assertRaises(ValueError): mig.restore(self.bundle, self.new, quiet=True)
self.assertEqual(list(original.iterdir()), [])
if __name__ == '__main__':
unittest.main()
class LegacyUpgradeTests(unittest.TestCase):
def test_v01_and_v03_upgrade_using_real_old_archives(self):
import subprocess
for version in ('0.1', '0.3', '0.6'):
with self.subTest(version=version), tempfile.TemporaryDirectory() as td:
home = Path(td)
archive = Path(os.environ.get('C4H_LEGACY_ARCHIVE_DIR', str(ROOT.parent))) / f'code4history-v{version}.zip'
if not archive.exists():
self.skipTest('可设置 C4H_LEGACY_ARCHIVE_DIR 指向旧版本 ZIP 文件所在目录进行回归测试')
with ZipFile(archive) as z:
z.extractall(home / 'previous')
old = home / 'previous/code4history'
(old / 'topic.md').write_text(f'选题版本{version},实际研究问题。', encoding='utf-8')
(old / 'STATE.md').write_text('完成了若干篇史料精读。', encoding='utf-8')
(old / '01_sources/originals/primary/copy.pdf').write_bytes(b'%PDF-1.4\nold research data')
template = home / 'code4history-v0.8.zip'
with ZipFile(template, 'w', ZIP_DEFLATED) as z:
for f in ROOT.rglob('*'):
if f.is_file() and '__pycache__' not in f.parts:
z.write(f, 'code4history/' + f.relative_to(ROOT).as_posix())
target = home / 'new-code4history'
mig.upgrade(old, template, target)
self.assertEqual((target / 'topic.md').read_text(encoding='utf-8'), f'选题版本{version},实际研究问题。')
self.assertEqual((target / 'STATE.md').read_text(encoding='utf-8'), '完成了若干篇史料精读。')
self.assertEqual((target / '01_sources/originals/primary/copy.pdf').read_bytes(), b'%PDF-1.4\nold research data')
self.assertIn("Codex instructions", (target / "AGENTS.md").read_text(encoding="utf-8"))
self.assertTrue(old.is_dir())