Files
svfs/tests/test_roundtrip.py
T
chakcy ecdfacc620 fix+feat: 修复打包/解包主链路,并把存储层重建为内容寻址 + 快照
起始状态:主链路 100% 不可用,而且失败被静默吞掉——打包后库里 0 个文件、
界面仍打印"文件数量: N";解包导出 0 个文件并在磁盘上生成几百层 sub/sub/sub 垃圾目录。

缺陷修复(4 个 P0 + 12 个 P1,逐条见 BUG_REPORT.md):
- 建表列名 compresse_algo 与 INSERT/read_file 用的 compress_algo 不一致
- list_directory 按 path 而非 parent_path 过滤,导致导出无限自递归
- CLI --ignore-timestamps 把 export_filesystem 赋成引用自身的 lambda
- add_file 缺少 commit(直接调用 API 时静默丢数据)
- LIKE 前缀误伤兄弟目录(删 doc 连带删 docs);mv_dir 二次改名
- 失败路径一律返回 exit code 0;--exclude 是子串匹配而非 glob
- FolderPacker.add_file 反向调用 export_file_safe,会用库内内容覆盖源文件

存储层(schema v3,格式变更,提供 vfsmigrate 迁移):
- 内容移入 blobs(hash PRIMARY KEY):相同内容只存一份,跨路径/跨快照共享
- files 的唯一约束改为 UNIQUE(snapshot_id, path):一个库存多个版本
- 完整性:每文件 sha256 + 快照级摘要 + HMAC-SHA256 签名,只读连接即可验证
- 增量打包:按 mtime+size 或 sha256 只写变化项,未变文件不重写不重压
- get_file_info 不再返回 content(元数据查询不该搬 BLOB)

新增能力:
- 可选 FastAPI 静态文件插件(extras,核心仍零第三方依赖)
- 内容寻址发布到对象存储 + 逐对象校验的取回客户端
- 压缩默认改回 zlib:实测比自研哈夫曼体积小 1/3~1/170、快数百倍

其它:
- P2-4:时间戳显式转字符串,清掉 1240 条 DeprecationWarning(盘上格式不变)
- read_file 负数 offset/size 改为报错(原先会走切片语义从末尾取)
- requires-python 从 >=3.7 改为按实际 API 推导的 >=3.9

测试:0 → 343 个用例,纯标准库 unittest,有/无 FastAPI 两种环境都跑通。
证据见 docs/evidence-*.txt。
2026-09-14 00:11:42 +08:00

154 lines
6.3 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.
"""端到端回归测试:打包 → 解包,必须逐字节无损。
这是本项目最核心的验收测试:只要它绿了,说明"把文件夹装进一个 .svfs 再拿出来"
这条主链路是通的。
"""
import os
import random
import unittest
import helpers
from sqlite_vfs.core import SQLiteVFS
def build_spec():
"""构造一个覆盖面较广的目录树。
刻意包含:嵌套目录、空目录、空文件、压缩阈值两侧的样本、
可压缩与不可压缩内容、中文路径、带空格的路径、
以及含 % 和 _ 的文件名(这两个字符在 SQL LIKE 里是通配符)。
"""
rnd = random.Random(20240913)
return {
"top.txt": b"top level\n",
"empty.txt": b"",
"one_byte.bin": b"\x00",
"below_threshold.bin": rnd.randbytes(999),
"compressible.txt": b"hello world, this is a highly compressible payload. " * 200,
"random.bin": rnd.randbytes(4096),
"empty_dir": None,
"sub/deep/deeper/nested.txt": b"nested\n",
"sub/space name.txt": b"spaces\n",
"sub/中文名.txt": "中文内容,用来验证非 ASCII 路径与内容。\n".encode("utf-8"),
"sub/percent%name_.txt": b"wildcards\n",
"doc/a.txt": b"a\n",
"docs/b.txt": b"b\n",
}
class RoundTripTest(helpers.VFSTestCase):
def _pack(self, compress):
src = helpers.make_tree(self.path("src"), build_spec())
db = helpers.pack(src, self.path("out.svfs"), compress=compress, name="roundtrip")
return src, db
def _assert_lossless(self, src, db, ignore_timestamps=False):
out = self.path("out")
with helpers.quiet():
helpers.unpack(db, out, ignore_timestamps=ignore_timestamps)
# 以磁盘上的源目录为基准:build_spec 里没显式写出的中间目录
# (sub、sub/deep 等)也必须在导出结果里出现
helpers.assert_tree_equal(self, helpers.read_tree(src), helpers.read_tree(out))
def test_roundtrip_without_compression(self):
src, db = self._pack(compress=False)
self._assert_lossless(src, db)
def test_roundtrip_with_compression(self):
src, db = self._pack(compress=True)
self._assert_lossless(src, db)
def test_roundtrip_with_ignore_timestamps(self):
src, db = self._pack(compress=True)
self._assert_lossless(src, db, ignore_timestamps=True)
def test_every_source_file_is_stored(self):
"""P0-1:打包后库里必须有和源目录一一对应的文件记录。"""
src, db = self._pack(compress=True)
vfs = SQLiteVFS(db)
self.addCleanup(vfs.close)
expected = {rel for rel, content in build_spec().items() if content is not None}
stored = set(helpers.stored_files(vfs))
self.assertEqual(
"无差异",
helpers.diff_trees({p: None for p in expected}, {p: None for p in stored}),
)
def test_metadata_counts_match_database(self):
"""P1-5:上报的统计数字必须来自真实入库的数据,而不是遍历计数。"""
src, db = self._pack(compress=True)
vfs = SQLiteVFS(db)
self.addCleanup(vfs.close)
stats = vfs.get_stats()
stored_count = helpers.count_files(vfs)
source_count = len(
[rel for rel, content in build_spec().items() if content is not None]
)
self.assertEqual(source_count, stored_count, "入库文件数 ≠ 源文件数")
self.assertEqual(
source_count,
stats["total_files"],
"元数据里的文件数与实际入库数不一致",
)
# total_size 记录的是原始文件大小,压缩后 content 里存的是压缩数据,
# 所以这里要和源文件大小比,而不是库里 blob 的长度
source_size = sum(
len(content) for content in build_spec().values() if content is not None
)
self.assertEqual(source_size, stats["total_size"])
def test_compression_reduces_database_size(self):
"""用一个足够大的重复文本,让压缩效果超过 SQLite 页粒度(4KB)的噪声。
build_spec 里的小文件即使压得动,省下的字节也可能被页对齐吃掉,
所以这里单独造一份 92KB 的样本。
"""
payload = b"hello world, this is a highly compressible payload. " * 2000
src = helpers.make_tree(self.path("big_src"), {"big.txt": payload})
plain_db = helpers.pack(src, self.path("plain.svfs"), compress=False)
compressed_db = helpers.pack(src, self.path("compressed.svfs"), compress=True)
plain_size = os.path.getsize(plain_db)
compressed_size = os.path.getsize(compressed_db)
self.assertLess(
compressed_size,
plain_size,
f"压缩后数据库应当更小: plain={plain_size}, compressed={compressed_size}",
)
def test_stored_blob_of_compressible_file_is_smaller(self):
"""压缩不只是"标记了一下":库里真正存的字节必须变少。"""
src, db = self._pack(compress=True)
vfs = SQLiteVFS(db)
self.addCleanup(vfs.close)
blob = vfs.get_blob_info("compressible.txt")
self.assertEqual("zlib", blob["compress_algo"])
self.assertIsNone(blob["freq_blob"], "zlib 不需要频率表")
with open(os.path.join(src, "compressible.txt"), "rb") as f:
original = f.read()
self.assertEqual(len(original), blob["size"], "blob 记录的应当是原始大小")
# 高度重复的文本,zlib 至少能压掉一半(huffman 只能压到 ~56%)
self.assertLess(blob["stored_size"], len(original) // 2)
self.assertEqual(original, vfs.read_file("compressible.txt"))
def test_packed_database_contains_no_duplicate_paths(self):
"""唯一性现在是"每个快照内"的:UNIQUE(snapshot_id, path)。"""
src, db = self._pack(compress=False)
vfs = SQLiteVFS(db)
self.addCleanup(vfs.close)
snapshot_id = vfs._active_snapshot()
total = vfs.conn.execute(
"SELECT COUNT(*) AS c FROM files WHERE snapshot_id = ?", (snapshot_id,)
).fetchone()["c"]
self.assertEqual(total, len(helpers.all_paths(vfs)))
if __name__ == "__main__":
unittest.main()