起始状态:主链路 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。
154 lines
6.3 KiB
Python
154 lines
6.3 KiB
Python
"""端到端回归测试:打包 → 解包,必须逐字节无损。
|
||
|
||
这是本项目最核心的验收测试:只要它绿了,说明"把文件夹装进一个 .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()
|