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。
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
"""压缩算法分发层的直接单测。
|
||||
|
||||
`compression.py` 是"用哪种算法"的唯一分发点(core 只按 `compress_algo` 值分发,
|
||||
不关心具体算法)。这里锁住它的契约,这样以后往 `ALGORITHMS` 里加算法
|
||||
(包括自定义算法、或未来的加密层)时,有东西能兜住回归。
|
||||
|
||||
分层关系:`huffman.py` 只管纯算法(有自己的 test_huffman.py),
|
||||
`compression.py` 管分发与算法名到数据的映射,`core.py` 管存取。
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import zlib
|
||||
|
||||
import helpers # noqa: F401 -- 把 src 加入 sys.path
|
||||
|
||||
from sqlite_vfs import compression
|
||||
from sqlite_vfs.huffman import FREQ_SIZE
|
||||
|
||||
|
||||
class AlgorithmRegistryTest(unittest.TestCase):
|
||||
def test_default_algorithm_is_zlib(self):
|
||||
self.assertEqual("zlib", compression.DEFAULT_ALGORITHM)
|
||||
|
||||
def test_registry_contains_the_three_builtins(self):
|
||||
self.assertEqual(("none", "zlib", "huffman"), compression.ALGORITHMS)
|
||||
|
||||
def test_every_registered_algorithm_roundtrips(self):
|
||||
data = b"the quick brown fox jumps over the lazy dog. " * 60
|
||||
for algorithm in compression.ALGORITHMS:
|
||||
with self.subTest(algorithm=algorithm):
|
||||
algo, freq, blob = compression.compress_with(data, algorithm)
|
||||
self.assertEqual(algorithm, algo)
|
||||
self.assertEqual(data, compression.decompress_with(algo, freq, blob))
|
||||
|
||||
def test_unknown_algorithm_is_rejected_on_both_sides(self):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
compression.compress_with(b"x", "lzma")
|
||||
self.assertIn("lzma", str(ctx.exception))
|
||||
self.assertIn("none", str(ctx.exception), "报错要列出可用算法")
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
compression.decompress_with("lzma", None, b"x")
|
||||
|
||||
|
||||
class CompressContractTest(unittest.TestCase):
|
||||
DATA = b"payload " * 100
|
||||
|
||||
def test_none_returns_the_input_unchanged(self):
|
||||
algo, freq, blob = compression.compress_with(self.DATA, "none")
|
||||
self.assertEqual("none", algo)
|
||||
self.assertIsNone(freq)
|
||||
self.assertIs(self.DATA, blob, "none 应当零拷贝直接返回原对象")
|
||||
|
||||
def test_zlib_produces_a_real_zlib_stream(self):
|
||||
algo, freq, blob = compression.compress_with(self.DATA, "zlib")
|
||||
self.assertIsNone(freq, "zlib 不需要附加数据")
|
||||
self.assertEqual(self.DATA, zlib.decompress(blob))
|
||||
self.assertLess(len(blob), len(self.DATA))
|
||||
|
||||
def test_huffman_carries_a_fixed_size_frequency_table(self):
|
||||
algo, freq, blob = compression.compress_with(self.DATA, "huffman")
|
||||
self.assertEqual("huffman", algo)
|
||||
self.assertEqual(FREQ_SIZE, len(freq), "频率表固定 1024 字节")
|
||||
self.assertEqual(self.DATA, compression.decompress_with(algo, freq, blob))
|
||||
|
||||
def test_huffman_without_frequency_table_is_rejected(self):
|
||||
algo, _freq, blob = compression.compress_with(self.DATA, "huffman")
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
compression.decompress_with(algo, None, blob)
|
||||
self.assertIn("频率表", str(ctx.exception))
|
||||
|
||||
def test_stored_size_counts_the_frequency_table(self):
|
||||
_algo, freq, blob = compression.compress_with(self.DATA, "huffman")
|
||||
self.assertEqual(
|
||||
len(blob) + len(freq), compression.stored_size(blob, freq)
|
||||
)
|
||||
self.assertEqual(len(blob), compression.stored_size(blob, None))
|
||||
|
||||
def test_stored_size_ignores_an_empty_frequency_table(self):
|
||||
self.assertEqual(3, compression.stored_size(b"abc", b""))
|
||||
|
||||
|
||||
class AlgorithmTradeoffTest(unittest.TestCase):
|
||||
"""把实测出来的取舍钉在测试里:默认算法不能悄悄退化成更差的那个。"""
|
||||
|
||||
#: 高度重复的文本——LZ77 的强项,纯熵编码的弱项
|
||||
REPETITIVE = b"function render(el){return el.innerHTML}\n" * 800
|
||||
|
||||
def test_zlib_beats_huffman_by_a_lot_on_repetitive_text(self):
|
||||
_a1, f1, z = compression.compress_with(self.REPETITIVE, "zlib")
|
||||
_a2, f2, h = compression.compress_with(self.REPETITIVE, "huffman")
|
||||
zlib_total = compression.stored_size(z, f1)
|
||||
huffman_total = compression.stored_size(h, f2)
|
||||
self.assertLess(
|
||||
zlib_total * 10,
|
||||
huffman_total,
|
||||
f"zlib={zlib_total} huffman={huffman_total}——差距应当在一个数量级上",
|
||||
)
|
||||
|
||||
def test_huffman_is_not_used_for_small_files(self):
|
||||
"""huffman 每个文件要带 1024 字节频率表,小文件必然负收益。"""
|
||||
small = b"tiny content"
|
||||
_algo, freq, blob = compression.compress_with(small, "huffman")
|
||||
self.assertGreater(
|
||||
compression.stored_size(blob, freq),
|
||||
len(small),
|
||||
"小文件压完更大——这正是 core 用 COMPRESS_MIN_SIZE 挡住它的原因",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user