"""SVFS Studio 的逻辑层测试(不需要 PySide6)。 界面靠眼睛验收,但"大小算错、差异分错类、只读判断漏了写语句"这类问题不能靠眼睛, 所以这些纯逻辑都被钉在测试里。测试只 import ``svfs_studio.format`` / ``svfs_studio.analysis``,两个模块都是纯标准库的。 """ import os import sqlite3 import unittest import helpers from sqlite_vfs.core import SQLiteVFS from sqlite_vfs.folder_packer import FolderPacker from svfs_studio import analysis, format as fmt class HumanFormatTest(unittest.TestCase): def test_human_size(self): self.assertEqual(fmt.human_size(None), "-") self.assertEqual(fmt.human_size(0), "0 B") self.assertEqual(fmt.human_size(512), "512 B") self.assertEqual(fmt.human_size(1024), "1.00 KB") self.assertEqual(fmt.human_size(1536), "1.50 KB") self.assertEqual(fmt.human_size(1024 ** 2), "1.00 MB") self.assertEqual(fmt.human_size(-2048), "-2.00 KB") def test_human_count(self): self.assertEqual(fmt.human_count(1234567), "1,234,567") self.assertEqual(fmt.human_count(None), "-") def test_perm_string(self): self.assertEqual(fmt.perm_string(420), "rw-r--r--") # 420 == 0o644 self.assertEqual(fmt.perm_string(493), "rwxr-xr-x") # 493 == 0o755 self.assertEqual(fmt.perm_string(0), "---------") self.assertEqual(fmt.perm_string(None), "-") self.assertEqual(fmt.perm_octal(420), "644") def test_human_time_accepts_db_string_and_datetime(self): self.assertEqual(fmt.human_time("2026-09-14 10:23:45"), "2026-09-14 10:23") self.assertEqual(fmt.human_time(None), "-") self.assertEqual(fmt.human_time(""), "-") self.assertEqual(fmt.human_time("不是时间"), "不是时间") def test_ratio_and_percent(self): self.assertEqual(fmt.ratio_text(50, 100), "50.0%") self.assertEqual(fmt.ratio_text(0, 0), "-") self.assertEqual(fmt.percent_text(25, 100), "25.0%") self.assertEqual(fmt.percent_text(1, 0), "-") def test_sniff_text_decodes_utf8_then_gbk(self): self.assertEqual(fmt.sniff_text("纯文本".encode("utf-8")), (False, "utf-8", "纯文本")) # GBK 的字节不是合法 UTF-8,必须回退到 gbk 而不是显示乱码 binary, encoding, text = fmt.sniff_text("中文内容测试".encode("gbk")) self.assertFalse(binary) self.assertEqual(encoding, "gbk") self.assertEqual(text, "中文内容测试") def test_sniff_text_flags_binary(self): binary, encoding, text = fmt.sniff_text(b"\x00\x01\x02\x03") self.assertTrue(binary) self.assertIsNone(text) self.assertIsNone(encoding) def test_sniff_text_honours_bom(self): binary, encoding, text = fmt.sniff_text(b"\xef\xbb\xbfhi") self.assertFalse(binary) self.assertEqual(encoding, "utf-8-sig") def test_hexdump_layout(self): lines, truncated = fmt.hexdump(b"Hello, SVFS!", limit=8) self.assertTrue(truncated) self.assertEqual(len(lines), 1) self.assertTrue(lines[0].startswith("00000000 48 65 6c 6c 6f 2c 20 53")) self.assertTrue(lines[0].endswith("|Hello, S|")) def test_hexdump_full_line_alignment(self): lines, truncated = fmt.hexdump(bytes(range(16))) self.assertFalse(truncated) self.assertEqual(len(lines), 1) self.assertIn("|................|", lines[0]) # 两行时 ASCII 列要对齐(同一列起) lines, _ = fmt.hexdump(bytes(range(20))) self.assertEqual(len(lines), 2) self.assertEqual(lines[0].index("|"), lines[1].index("|")) self.assertTrue(lines[1].startswith("00000010")) class SnapshotDiffTest(unittest.TestCase): @staticmethod def row(path, is_directory=False, size=0, content_hash=None, mtime="2026-01-01 00:00:00", permissions=420): return { "path": path, "is_directory": is_directory, "file_size": size, "content_hash": content_hash, "modified_time": mtime, "permissions": permissions, } def test_classifies_every_kind(self): old = [ self.row("keep.txt", size=3, content_hash="aaa"), self.row("gone.txt", size=3, content_hash="bbb"), self.row("edit.txt", size=3, content_hash="ccc"), self.row("meta.txt", size=3, content_hash="ddd"), self.row("swap", is_directory=False, size=1, content_hash="eee"), self.row("dir", is_directory=True), ] new = [ self.row("keep.txt", size=3, content_hash="aaa"), self.row("edit.txt", size=5, content_hash="zzz"), self.row("meta.txt", size=3, content_hash="ddd", mtime="2026-02-02 00:00:00"), self.row("swap", is_directory=True), self.row("dir", is_directory=True), self.row("fresh.txt", size=9, content_hash="fff"), ] diff = analysis.snapshot_diff(old, new) self.assertEqual(diff["counts"]["added"], 1) self.assertEqual(diff["added"][0]["path"], "fresh.txt") self.assertEqual(diff["counts"]["removed"], 1) self.assertEqual(diff["removed"][0]["path"], "gone.txt") self.assertEqual([item["path"] for item in diff["content"]], ["edit.txt"]) self.assertEqual(diff["content"][0]["size_delta"], 2) self.assertEqual([item["path"] for item in diff["type"]], ["swap"]) self.assertEqual([item["path"] for item in diff["meta"]], ["meta.txt"]) self.assertEqual(diff["unchanged"], 2) # keep.txt / dir self.assertEqual(diff["total_changed"], 5) def test_directory_mtime_is_ignored(self): """往目录里加文件会改目录 mtime,那不算"变更"(否则差异视图全是噪音)。""" old = [self.row("dir", is_directory=True, mtime="2026-01-01 00:00:00")] new = [self.row("dir", is_directory=True, mtime="2026-03-03 12:00:00")] diff = analysis.snapshot_diff(old, new) self.assertEqual(diff["total_changed"], 0) self.assertEqual(diff["unchanged"], 1) def test_same_size_different_hash_is_content_change(self): old = [self.row("a.bin", size=1024, content_hash="old")] new = [self.row("a.bin", size=1024, content_hash="new")] diff = analysis.snapshot_diff(old, new) self.assertEqual(diff["counts"]["content"], 1) self.assertEqual(diff["content"][0]["size_delta"], 0) def test_permission_only_change_is_meta(self): old = [self.row("x.sh", size=5, content_hash="h", permissions=420)] new = [self.row("x.sh", size=5, content_hash="h", permissions=493)] diff = analysis.snapshot_diff(old, new) self.assertEqual(diff["counts"]["meta"], 1) def test_empty_inputs(self): diff = analysis.snapshot_diff([], []) self.assertEqual(diff["total_changed"], 0) self.assertEqual(diff["unchanged"], 0) class StorageAnalysisTest(unittest.TestCase): def test_storage_summary_accounts_for_each_stage(self): entries = [ {"path": "a.txt", "is_directory": False, "file_size": 100, "content_hash": "h1"}, {"path": "b.txt", "is_directory": False, "file_size": 100, "content_hash": "h1"}, {"path": "dir", "is_directory": True, "file_size": 0, "content_hash": None}, ] blobs = [{"hash": "h1", "size": 100, "stored_size": 40, "compress_algo": "zlib"}] summary = analysis.storage_summary(entries, blobs, db_file_size=12288) self.assertEqual(summary["total_files"], 2) self.assertEqual(summary["total_dirs"], 1) self.assertEqual(summary["logical_size"], 200) self.assertEqual(summary["unique_bytes"], 100) self.assertEqual(summary["dedup_saved"], 100) self.assertEqual(summary["stored_bytes"], 40) self.assertEqual(summary["compression_saved"], 60) self.assertEqual(summary["db_overhead"], 12288 - 40) self.assertEqual(summary["blob_count"], 1) def test_algo_distribution_sorted_by_stored_size(self): blobs = [ {"hash": "a", "size": 10, "stored_size": 10, "compress_algo": "none"}, {"hash": "b", "size": 100, "stored_size": 30, "compress_algo": "zlib"}, {"hash": "c", "size": 100, "stored_size": 20, "compress_algo": "zlib"}, ] distribution = analysis.algo_distribution(blobs) self.assertEqual([item["algo"] for item in distribution], ["zlib", "none"]) self.assertEqual(distribution[0]["stored_size"], 50) self.assertEqual(distribution[0]["count"], 2) def test_duplicate_groups_waste(self): entries = [ {"path": "a/vendor.js", "is_directory": False, "content_hash": "h", "file_size": 60}, {"path": "b/vendor.js", "is_directory": False, "content_hash": "h", "file_size": 60}, {"path": "c/vendor.js", "is_directory": False, "content_hash": "h", "file_size": 60}, {"path": "only.js", "is_directory": False, "content_hash": "k", "file_size": 5}, {"path": "dir", "is_directory": True, "content_hash": None, "file_size": 0}, ] blobs = {"h": {"size": 60, "stored_size": 20, "compress_algo": "zlib"}} groups = analysis.duplicate_groups(entries, blobs) self.assertEqual(len(groups), 1) self.assertEqual(groups[0]["refcount"], 3) self.assertEqual(groups[0]["wasted_bytes"], 120) self.assertEqual(groups[0]["paths"], ["a/vendor.js", "b/vendor.js", "c/vendor.js"]) def test_top_files_carries_blob_info(self): entries = [ {"path": "big.bin", "is_directory": False, "file_size": 900, "content_hash": "h"}, {"path": "small.bin", "is_directory": False, "file_size": 9, "content_hash": "k"}, ] blobs = {"h": {"size": 900, "stored_size": 300, "compress_algo": "zlib"}} top = analysis.top_files(entries, blobs, limit=1) self.assertEqual(len(top), 1) self.assertEqual(top[0]["path"], "big.bin") self.assertEqual(top[0]["stored_size"], 300) self.assertEqual(top[0]["algo"], "zlib") class FakeCursor: def __init__(self, rows): self._rows = rows def fetchone(self): return self._rows[0] class NoDbstatConn: """模拟没编译 dbstat 的 SQLite:只答得出 COUNT(*)。""" def execute(self, sql, *args): if "dbstat" in sql.lower(): raise sqlite3.OperationalError("no such table: dbstat") return FakeCursor([(7,)]) class SqliteInternalsTest(helpers.VFSTestCase): def setUp(self): super().setUp() self.src = helpers.make_tree(self.path("src"), {"a.txt": b"aaa", "sub/b.txt": b"bbb"}) self.db = self.path("p.svfs") helpers.pack(self.src, self.db, compress=True) def test_read_header_parses_real_file(self): header = analysis.read_header(self.db) self.assertTrue(header["valid"]) self.assertEqual(header["page_size"], 4096) self.assertGreater(header["page_count"], 0) self.assertEqual(header["text_encoding_name"], "UTF-8") def test_read_header_rejects_non_sqlite(self): path = self.path("garbage.bin") with open(path, "wb") as handle: handle.write(b"x" * 200) header = analysis.read_header(path) self.assertFalse(header["valid"]) self.assertIn("SQLite", header["error"]) def test_read_header_rejects_tiny_file(self): path = self.path("tiny.bin") with open(path, "wb") as handle: handle.write(b"SQL") self.assertFalse(analysis.read_header(path)["valid"]) def test_internals_reports_occupancy_and_objects(self): vfs = SQLiteVFS(self.db, readonly=True) try: info = analysis.sqlite_internals(vfs.conn, self.db) finally: vfs.close() self.assertTrue(info["ok"], info.get("error")) self.assertIn(info["occupancy_mode"], ("dbstat", "rowcount")) names = {obj["name"] for obj in info["objects"]} self.assertTrue({"files", "blobs", "snapshots", "signatures"} <= names) self.assertEqual(info["total_bytes"], info["pragmas"]["page_size"] * info["pragmas"]["page_count"]) self.assertEqual(info["total_bytes"] - info["free_bytes"], info["used_bytes"]) rows = {row["name"]: row for row in info["occupancy"]} self.assertIsNotNone(rows["files"]["rows"]) if info["occupancy_mode"] == "dbstat": self.assertGreater(rows["files"]["bytes"], 0) def test_occupancy_falls_back_without_dbstat(self): objects = [ {"type": "table", "name": "files", "table": "files", "rootpage": 2, "sql": ""}, {"type": "index", "name": "idx", "table": "files", "rootpage": 3, "sql": ""}, ] occupancy, mode = analysis._occupancy(NoDbstatConn(), objects) self.assertEqual(mode, "rowcount") self.assertEqual([row["name"] for row in occupancy], ["files"]) self.assertEqual(occupancy[0]["rows"], 7) self.assertIsNone(occupancy[0]["bytes"]) class ReadonlySqlTest(unittest.TestCase): def test_accepts_readonly_statements(self): for sql in ( "SELECT * FROM files", "select count(*) from blobs", " WITH x AS (SELECT 1) SELECT * FROM x ", "PRAGMA page_count", "EXPLAIN SELECT 1", "SELECT 1;", ): ok, reason = analysis.check_readonly_sql(sql) self.assertTrue(ok, f"{sql!r} 应被接受,却被拒绝:{reason}") def test_rejects_writes_and_multi_statements(self): for sql in ( "", " ", "INSERT INTO files (path) VALUES ('x')", "DELETE FROM blobs", "DROP TABLE files", "UPDATE snapshots SET name = 'x'", "SELECT 1; DELETE FROM files", "VACUUM", ): ok, reason = analysis.check_readonly_sql(sql) self.assertFalse(ok, f"{sql!r} 应被拒绝") self.assertTrue(reason) class SqlConsoleTest(helpers.VFSTestCase): def setUp(self): super().setUp() self.src = helpers.make_tree( self.path("src"), {"a.txt": b"aaa", "b.txt": b"aaa", "sub/c.txt": b"ccc"} ) self.db = self.path("q.svfs") helpers.pack(self.src, self.db, compress=True) def test_query_and_truncation(self): vfs = SQLiteVFS(self.db, readonly=True) try: columns, rows, truncated = analysis.run_readonly_query( vfs.conn, "SELECT path, file_size FROM files ORDER BY path", limit=2 ) finally: vfs.close() self.assertEqual(columns, ["path", "file_size"]) self.assertEqual(len(rows), 2) self.assertTrue(truncated) def test_write_attempt_fails_at_sqlite_level(self): """就算白名单被绕过,mode=ro 连接也会拒绝写——两道闸都要在。""" vfs = SQLiteVFS(self.db, readonly=True) try: with self.assertRaises(sqlite3.OperationalError): vfs.conn.execute("DELETE FROM files") finally: vfs.close() def test_presets_all_run(self): vfs = SQLiteVFS(self.db, readonly=True) try: for title, sql in analysis.SQL_PRESETS: columns, rows, _truncated = analysis.run_readonly_query(vfs.conn, sql) self.assertTrue(columns, f"预设 {title} 没返回列") finally: vfs.close() def test_refcounts_and_orphans(self): vfs = SQLiteVFS(self.db, readonly=True) try: counts = analysis.refcounts(vfs.conn) hashes = {row["hash"] for row in vfs.list_blobs()} # a.txt 与 b.txt 内容相同,共用一个 blob self.assertTrue(any(value == 2 for value in counts.values())) self.assertEqual(analysis.orphan_blobs(vfs.conn)["count"], 0) self.assertEqual(set(counts), hashes) finally: vfs.close() def test_orphan_detection_on_stray_blob(self): """手工塞一条没人引用的 blob,孤立检测要能抓到(GC 前的预览就靠它)。""" vfs = SQLiteVFS(self.db) try: vfs.conn.execute( "INSERT INTO blobs (hash, size, stored_size, content, freq_blob, compress_algo)" " VALUES ('deadbeef', 123, 123, X'00', NULL, 'none')" ) vfs.conn.commit() finally: vfs.close() vfs = SQLiteVFS(self.db, readonly=True) try: orphans = analysis.orphan_blobs(vfs.conn) self.assertEqual(orphans["count"], 1) self.assertEqual(orphans["size"], 123) finally: vfs.close() def test_snapshot_stats_reports_shared_blobs(self): vfs = SQLiteVFS(self.db, compress=True) try: parent = vfs._active_snapshot() new_id = vfs.create_snapshot("v2", parent_id=parent, copy_entries=True) stats = analysis.snapshot_stats(vfs, new_id) finally: vfs.close() # 整份复制过去的新快照:所有内容都是"别处也在用"的 self.assertEqual(stats["files"], 3) self.assertEqual(stats["shared_blobs"], stats["blob_count"]) self.assertGreater(stats["shared_bytes"], 0) class LocalCompareTest(helpers.VFSTestCase): def setUp(self): super().setUp() self.src = helpers.make_tree( self.path("src"), {"a.txt": b"aaa", "b.txt": b"bbb", "sub/c.txt": b"ccc"} ) self.db = self.path("c.svfs") helpers.pack(self.src, self.db, compress=True) def entries(self): vfs = SQLiteVFS(self.db, readonly=True) try: return vfs.all_entries() finally: vfs.close() def read_pack(self, path): vfs = SQLiteVFS(self.db, readonly=True) try: return vfs.read_file(path) finally: vfs.close() def test_identical_tree_matches(self): result = analysis.local_vs_snapshot_diff(self.src, self.entries()) self.assertTrue(result["ok"], result["counts"]) self.assertEqual(result["counts"]["identical"], 3) self.assertEqual(result["total_local"], 3) def test_detects_each_difference(self): helpers.write_file(os.path.join(self.src, "a.txt"), b"AAA") # 内容不同 helpers.write_file(os.path.join(self.src, "new.txt"), b"new") # 仅本地 os.remove(os.path.join(self.src, "b.txt")) # 仅包内 helpers.write_file(os.path.join(self.src, "sub", "c.txt"), b"ccc") # 内容相同 os.utime(os.path.join(self.src, "sub", "c.txt"), (1, 1)) # 只改了 mtime result = analysis.local_vs_snapshot_diff( self.src, self.entries(), read_content=self.read_pack ) self.assertFalse(result["ok"]) self.assertEqual([item["path"] for item in result["content"]], ["a.txt"]) self.assertEqual([item["path"] for item in result["only_local"]], ["new.txt"]) self.assertEqual([item["path"] for item in result["only_pack"]], ["b.txt"]) self.assertEqual([item["path"] for item in result["meta"]], ["sub/c.txt"]) def test_size_only_mode_needs_no_read_content(self): helpers.write_file(os.path.join(self.src, "a.txt"), b"AAAA") result = analysis.local_vs_snapshot_diff(self.src, self.entries()) self.assertEqual([item["path"] for item in result["content"]], ["a.txt"]) def test_progress_reports_every_local_file(self): seen = [] analysis.local_vs_snapshot_diff( self.src, self.entries(), progress=lambda done, total, path: seen.append((done, total, path)), ) self.assertEqual(len(seen), 3) self.assertEqual([done for done, _total, _p in seen], [1, 2, 3]) self.assertEqual({total for _d, total, _p in seen}, {3}) def test_skips_vcs_directories(self): helpers.write_file(os.path.join(self.src, ".git", "config"), b"[core]") result = analysis.local_vs_snapshot_diff(self.src, self.entries()) self.assertEqual(result["counts"]["only_local"], 0) self.assertTrue(result["ok"]) if __name__ == "__main__": unittest.main()