Files

467 lines
15 KiB
Python
Raw Permalink Normal View History

"""测试公共工具:路径引导、临时目录、目录树构造与比对、打包/解包/CLI 封装。
测试用标准库 unittest 编写,因此 `python -m unittest discover -s tests` 与
`pytest tests/` 都能运行,不需要额外安装依赖。
"""
import contextlib
import io
import os
import shutil
import sqlite3
import subprocess
import sys
import tempfile
import unittest
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SRC_DIR = os.path.join(REPO_ROOT, "src")
SAMPLE_DB = os.path.join(REPO_ROOT, "appcation.svfs")
if SRC_DIR not in sys.path:
sys.path.insert(0, SRC_DIR)
# --------------------------------------------------------------------------
# 环境与清理
# --------------------------------------------------------------------------
def force_rmtree(path):
"""删除目录树,容忍超长路径。
P0-2 的缺陷会在导出时生成几百层 sub/sub/sub/... 的垃圾目录链,
普通 shutil.rmtree 在 Windows 上会因为超过 MAX_PATH 而删不掉。
"""
if not path or not os.path.exists(path):
return
if os.name == "nt":
shutil.rmtree("\\\\?\\" + os.path.abspath(path), ignore_errors=True)
shutil.rmtree(path, ignore_errors=True)
@contextlib.contextmanager
def temp_dir(prefix="svfs_test_"):
path = tempfile.mkdtemp(prefix=prefix)
try:
yield path
finally:
force_rmtree(path)
@contextlib.contextmanager
def quiet():
"""屏蔽被测代码的进度输出,让测试失败信息保持可读。"""
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
yield buf
class VFSTestCase(unittest.TestCase):
"""每个用例一份独立的临时目录。"""
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="svfs_test_")
self.addCleanup(force_rmtree, self.tmp)
def path(self, *parts):
return os.path.join(self.tmp, *parts)
# --------------------------------------------------------------------------
# 目录树构造与比对
# --------------------------------------------------------------------------
def make_tree(base_dir, spec):
"""按 spec 生成目录树。
spec 的键是相对路径(用 / 分隔),值为 None 表示目录,bytes 表示文件内容。
"""
for rel_path, content in spec.items():
abs_path = os.path.join(base_dir, *rel_path.split("/"))
if content is None:
os.makedirs(abs_path, exist_ok=True)
else:
parent = os.path.dirname(abs_path)
if parent:
os.makedirs(parent, exist_ok=True)
with open(abs_path, "wb") as f:
f.write(content)
return base_dir
def write_file(path, data):
parent = os.path.dirname(path)
if parent:
os.makedirs(parent, exist_ok=True)
with open(path, "wb") as f:
f.write(data)
return path
def read_bytes(path):
with open(path, "rb") as f:
return f.read()
def md5_of(path):
import hashlib
return hashlib.md5(read_bytes(path)).hexdigest()
def read_tree(base_dir):
"""读取目录树,返回 {相对路径(/ 分隔): bytes 或 None(目录)}。"""
result = {}
for root, dirs, files in os.walk(base_dir):
for name in dirs:
abs_path = os.path.join(root, name)
result[os.path.relpath(abs_path, base_dir).replace("\\", "/")] = None
for name in files:
abs_path = os.path.join(root, name)
with open(abs_path, "rb") as f:
result[os.path.relpath(abs_path, base_dir).replace("\\", "/")] = f.read()
return result
def diff_trees(expected, actual, limit=15):
"""返回人类可读的目录树差异摘要,完全一致时返回 "无差异"。"""
missing = sorted(set(expected) - set(actual))
extra = sorted(set(actual) - set(expected))
changed = sorted(
rel for rel in set(expected) & set(actual) if expected[rel] != actual[rel]
)
lines = []
if missing:
lines.append(f"缺失 {len(missing)} 项,例如: {missing[:limit]}")
if extra:
lines.append(f"多出 {len(extra)} 项,例如: {extra[:limit]}")
if changed:
lines.append(f"内容不一致 {len(changed)} 项,例如: {changed[:limit]}")
return "\n".join(lines) if lines else "无差异"
def assert_tree_equal(case, expected, actual):
case.assertEqual("无差异", diff_trees(expected, actual))
# --------------------------------------------------------------------------
# 数据库直接探查(绕过被测的公开 API,用于独立断言)
# --------------------------------------------------------------------------
def all_paths(vfs):
"""返回当前快照里所有 path 的集合。"""
return {
row["path"]
for row in vfs.conn.execute(
"SELECT path FROM files WHERE snapshot_id = ?", (vfs._active_snapshot(),)
)
}
def path_parents(vfs):
"""返回 {path: parent_path}(当前快照)。"""
return {
row["path"]: row["parent_path"]
for row in vfs.conn.execute(
"SELECT path, parent_path FROM files WHERE snapshot_id = ?",
(vfs._active_snapshot(),),
)
}
def stored_files(vfs):
"""返回 {path: content},仅文件(内容来自 blobs 表)。"""
return {
row["path"]: row["content"]
for row in vfs.conn.execute(
"""
SELECT f.path AS path, b.content AS content
FROM files f JOIN blobs b ON b.hash = f.content_hash
WHERE f.is_directory = 0 AND f.snapshot_id = ?
""",
(vfs._active_snapshot(),),
)
}
def blob_count(vfs):
"""库里 blob 的行数(去重后的内容份数)。"""
return vfs.conn.execute("SELECT COUNT(*) AS c FROM blobs").fetchone()["c"]
def stored_blobs(vfs):
"""返回 {hash: (size, stored_size, compress_algo)}。"""
return {
row["hash"]: (row["size"], row["stored_size"], row["compress_algo"])
for row in vfs.conn.execute(
"SELECT hash, size, stored_size, compress_algo FROM blobs"
)
}
def column_names(conn, table="files"):
return {row["name"] for row in conn.execute(f"PRAGMA table_info({table})")}
def count_files(vfs):
return vfs.conn.execute(
"SELECT COUNT(*) AS c FROM files WHERE is_directory = 0 AND snapshot_id = ?",
(vfs._active_snapshot(),),
).fetchone()["c"]
def seed_rows(vfs, rows):
"""用原始 SQL 造数据(走 blobs + content_hash,等价于库内真实布局)。
只写新旧 schema 共有的列,这样"目录语义"类测试不会被
存储层的细节连带打挂,便于定位问题。
"""
import hashlib
cursor = vfs.conn.cursor()
snapshot_id = vfs._ensure_snapshot()
for row in rows:
rel_path = row["path"]
if "/" in rel_path:
default_name = rel_path.rsplit("/", 1)[1]
default_parent = rel_path.rsplit("/", 1)[0]
else:
default_name = rel_path
default_parent = ""
is_directory = bool(row.get("is_directory"))
content = None if is_directory else (row.get("content") or b"")
content_hash = None
if content is not None:
content_hash = hashlib.sha256(content).hexdigest()
cursor.execute(
"""
INSERT OR IGNORE INTO blobs
(hash, size, stored_size, content, freq_blob, compress_algo)
VALUES (?, ?, ?, ?, NULL, 'none')
""",
(content_hash, len(content), len(content), sqlite3.Binary(content)),
)
cursor.execute(
"""
INSERT INTO files
(snapshot_id, path, name, parent_path, is_directory, file_size, content_hash)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
snapshot_id,
rel_path,
row.get("name", default_name),
row.get("parent_path", default_parent),
1 if is_directory else 0,
row.get("file_size", len(content) if content is not None else 0),
content_hash,
),
)
vfs.conn.commit()
# 摘要与统计要一起写上:seed_rows 造出来的库应当等价于"正常打包出来的库",
# 否则 verify() 会因为"没有记录摘要"而判失败(这是有意的保守策略)
vfs.recount()
def make_old_schema_db(db_path, extra_rows=()):
"""生成 **v1** 旧格式数据库(content BLOB + compressed BOOLEAN)。
对应仓库里 appcation.svfs 的结构,也用于验证迁移工具能处理最早的格式。
"""
conn = sqlite3.connect(db_path)
try:
conn.executescript(
"""
CREATE TABLE filesystem_metadata (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
created_time DATETIME DEFAULT CURRENT_TIMESTAMP,
total_files INTEGER DEFAULT 0,
total_size INTEGER DEFAULT 0
);
CREATE TABLE files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
parent_path TEXT,
is_directory BOOLEAN NOT NULL DEFAULT 0,
file_size INTEGER DEFAULT 0,
created_time DATETIME,
modified_time DATETIME,
permissions INTEGER DEFAULT 0644,
content BLOB,
compressed BOOLEAN DEFAULT 0
);
INSERT INTO filesystem_metadata (name, total_files, total_size)
VALUES ('legacy', 1, 5);
INSERT INTO files (path, name, parent_path, is_directory, file_size, content)
VALUES ('legacy.txt', 'legacy.txt', '', 0, 5, X'68656C6C6F');
"""
)
for row in extra_rows:
conn.execute(
"""
INSERT INTO files
(path, name, parent_path, is_directory, file_size, content, compressed)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
row["path"],
row["path"].rsplit("/", 1)[-1],
row.get("parent_path", ""),
1 if row.get("is_directory") else 0,
row.get("file_size", len(row.get("content") or b"")),
sqlite3.Binary(row["content"]) if row.get("content") is not None else None,
1 if row.get("compressed") else 0,
),
)
conn.commit()
finally:
conn.close()
return db_path
def make_v2_schema_db(db_path, rows=()):
"""生成 **v2** 格式数据库:内容与压缩算法列都在 files 表里(无 blobs/snapshots)。
rows 里可以给 content / compress_algo / freq_blob 来模拟"已经压缩过的内容"。
"""
conn = sqlite3.connect(db_path)
try:
conn.executescript(
"""
CREATE TABLE filesystem_metadata (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
created_time DATETIME DEFAULT CURRENT_TIMESTAMP,
total_files INTEGER DEFAULT 0,
total_size INTEGER DEFAULT 0
);
CREATE TABLE files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
parent_path TEXT,
is_directory BOOLEAN NOT NULL DEFAULT 0,
file_size INTEGER DEFAULT 0,
created_time DATETIME,
modified_time DATETIME,
permissions INTEGER DEFAULT 420,
content BLOB,
freq_blob BLOB,
compress_algo TEXT DEFAULT 'none'
);
INSERT INTO filesystem_metadata (name, total_files, total_size)
VALUES ('v2pack', 0, 0);
"""
)
for row in rows:
conn.execute(
"""
INSERT INTO files
(path, name, parent_path, is_directory, file_size,
content, freq_blob, compress_algo)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
row["path"],
row["path"].rsplit("/", 1)[-1],
row.get("parent_path", "" if "/" not in row["path"] else row["path"].rsplit("/", 1)[0]),
1 if row.get("is_directory") else 0,
row.get("file_size", len(row.get("content") or b"")),
sqlite3.Binary(row["content"]) if row.get("content") is not None else None,
sqlite3.Binary(row["freq_blob"]) if row.get("freq_blob") else None,
row.get("compress_algo", "none"),
),
)
conn.commit()
finally:
conn.close()
return db_path
def vfs_format_error():
"""返回(修复后应当存在的)数据库格式异常类型;未实现时返回 None。"""
from sqlite_vfs import core
return getattr(core, "VFSFormatError", None)
# --------------------------------------------------------------------------
# 打包 / 解包 / CLI 封装
# --------------------------------------------------------------------------
def pack(
source_dir,
db_path,
compress=False,
exclude=None,
name="test_fs",
algorithm=None,
quiet_output=True,
):
from sqlite_vfs import compression
from sqlite_vfs.core import SQLiteVFS
from sqlite_vfs.folder_packer import FolderPacker
kwargs = {}
if algorithm is not None:
kwargs["compress_algorithm"] = algorithm
vfs = SQLiteVFS(db_path, compress=compress, **kwargs)
try:
packer = FolderPacker(
sqlite_vfs=vfs, exclude_patterns=exclude or [], fs_name=name
)
if quiet_output:
with quiet():
packer.pack_folder(source_dir)
else:
packer.pack_folder(source_dir)
finally:
vfs.close()
return db_path
def unpack(db_path, out_dir, ignore_timestamps=False):
from sqlite_vfs.core import SQLiteVFS
from sqlite_vfs.folder_unpacker import FolderUnpacker
vfs = SQLiteVFS(db_path)
try:
unpacker = FolderUnpacker(vfs)
try:
return unpacker.unpack_filesystem(
out_dir, ignore_timestamps=ignore_timestamps
)
except TypeError:
# 兼容尚未支持该参数的老接口
return unpacker.unpack_filesystem(out_dir)
finally:
vfs.close()
def run_cli(module, *args):
"""以子进程运行 CLI 模块,返回 CompletedProcess(text 模式)。"""
env = dict(os.environ)
env["PYTHONPATH"] = SRC_DIR + os.pathsep + env.get("PYTHONPATH", "")
env["PYTHONIOENCODING"] = "utf-8"
return subprocess.run(
[sys.executable, "-m", module, *args],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
env=env,
cwd=REPO_ROOT,
)
def cli_output(result):
"""合并 stdout/stderr,便于断言错误信息。"""
return (result.stdout or "") + (result.stderr or "")