feat(tuku): CBZ 档案册集成——档案页源/书库列册/解压缓存

- logic/reader/archive_source.c:档案册页源实现 M6 mr_book 契约
  (mz_open/mz_count/mz_entry_at/mz_read_alloc),条目自然序 + 图片后缀 +
  路径安全(`..`/绝对路径/反斜杠/隐藏段/内嵌 NUL)+ 单页尺寸上限过滤;
  meta 按需解压只读图片头(不落缓存,结果按页记忆);mr_book_open_auto
  统一目录册/档案册开册,翻页与条漫两套消费代码零改动。
- logic/reader/page_cache.c/h:解压页落 MeowData/<pkg>/cache,LRU 64 页或
  32MiB,启动清 mrpage_* 孤儿,写入 .part + rename;与缩略图缓存同目录、
  前缀隔离;互斥量保护,解码/缩略图线程可并发。
- logic/library.c/h:扫描直接子文件里的 .cbz/.zip 成册(行=文件名+页数,
  点入直接阅读);档案探测由调用方注入(logic 不依赖 zip 容器),不注入时
  与 M7 行为逐字一致;册间衔接兄弟表=含图目录∪含页档案册,同列自然序。
  条目构造/副标题/角标/兄弟判据拆成小函数,便于 M13 书库重写拼接。
- ui/library:档案册行 + 封面缩略图(解压档内首图)、扫描注入探测、缓存
  目录统一由 page_cache 推导(一份规则)。
- ui/reader:open_book 走 mr_book_open_auto;跨册衔接纳入档案册。
- build_tuku.sh 补 zip/archive/cache 源与 sysroot zlib 头(zip.c include
  <zlib.h>);run_tests.sh 纳入 zip+cbz 用例;run_webtoon_tests.sh 的宿主
  集成构建补新源与 -lz;.gitignore 同步测试产物。
- test/test_cbz.c(267 项断言)+ make_cbz_samples.py 真实 cbz 夹具:
  列档/自然序/解压字节 CRC/路径安全/坏档与 zip 炸弹容错/LRU 与 32MiB 上限/
  启动清孤儿/续读角标/封面首图。
This commit is contained in:
AskaEth committed 2026-11-05 11:58:12 +08:00
1 parent 19620a7957
commit 562cc44931
16 files changed
+2330 -108

No files matched your search

+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""生成 tuku 档案册(CBZ)集成测试夹具:真实 zipfile 产物 + 定点字节修补。
用法: python3 make_cbz_samples.py <out_dir>
产出:
<out>/library/ 书库夹具(文件夹册 + 档案册混排)
bookA/001.png 002.png 文件夹册(2 页,PNG)
vol2.cbz 3 页:PNG(41x31) / PNG(40x30) / 中文名 PNG(12x34)
+ 非图片、目录项、0 字节条目(都应被过滤)
Vol10.cbz 1 页 BMP(7x9),stored
box/box1.cbz box2.cbz 只含档案册的子目录(书库行应写 "2 books")
notabook.zip 只有文本 → 不是书
emptypages.cbz 目录项 + 文本 → 不是书
broken.cbz 非 zip 字节 → 不是书
truncated.cbz vol2 砍半(EOCD 丢失) → 不是书
.hidden.cbz 隐藏名 → 不参与
<out>/features/
pathsafe.cbz 安全/不安全条目混排(只应收 2 个安全图片页)
cjk.cbz 中文名自然序(第1页 < 第2页 < 第10页)
big.cbz 80 页 8x8 PNG(解压缓存 LRU 上限用)
bomb.cbz CD 声明单页 96MiB(应被过滤,防 zip 炸弹)
<out>/manifest.txt 每档案每条目一行:<档案相对路径>\t<条目名>\t<crc32>\t<size>
图片内容都是结构合法的 PNG/BMP/JPEG 头(PNG 是真图),C 测试用假解码器,
只校验解压字节与 manifest 的 CRC/尺寸一致、以及 meta 探测出的原始尺寸。
"""
import os
import struct
import sys
import zipfile
import zlib
DATE = (2024, 1, 1, 0, 0, 0)
# ---------------------------------------------------------------- 图片构造
def png_bytes(w, h, seed=0):
raw = bytearray()
for y in range(h):
raw.append(0) # filter: none
for x in range(w):
raw += bytes(((x * 7 + y * 13 + seed) & 0xFF,
(x * 3 + y * 5 + seed) & 0xFF,
(x + y + seed) & 0xFF))
def chunk(tag, data):
return (struct.pack(">I", len(data)) + tag + data +
struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF))
ihdr = struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0) # 8-bit RGB
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) +
chunk(b"IDAT", zlib.compress(bytes(raw), 9)) + chunk(b"IEND", b""))
def bmp_bytes(w, h):
stride = (w * 3 + 3) & ~3
px = bytes(((i * 11) & 0xFF) for i in range(stride * h))
hdr = b"BM" + struct.pack("<IHHI", 14 + 40 + len(px), 0, 0, 54)
ih = struct.pack("<IiiHHIIiiII", 40, w, h, 1, 24, 0, len(px), 2835, 2835, 0, 0)
return hdr + ih + px
def jpeg_header_bytes(w, h):
"""SOI + APP0 + DQT + SOF0 + EOI:头段合法(探测只看 SOF0),不做熵编码。"""
app0 = (b"\xFF\xE0" + struct.pack(">H", 16) +
b"JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00")
dqt = b"\xFF\xDB" + struct.pack(">H", 67) + b"\x00" + bytes(64)
sof = b"\xFF\xC0" + struct.pack(">HBHHB", 11, 8, h, w, 1) + b"\x01\x11\x00"
return b"\xFF\xD8" + app0 + dqt + sof + b"\xFF\xD9"
# ---------------------------------------------------------------- zip 构造
def write_zip(path, entries):
"""entries: [(name, data, zipfile 压缩方式)]"""
with zipfile.ZipFile(path, "w") as z:
for name, data, method in entries:
zi = zipfile.ZipInfo(name, DATE)
zi.compress_type = method
z.writestr(zi, data)
def find_cd_record(data, name):
eocd = data.rfind(b"PK\x05\x06")
if eocd < 0:
raise SystemExit("generator: no EOCD")
total, _cd_size, cd_off = struct.unpack_from("<HII", data, eocd + 10)
p = cd_off
for _ in range(total):
if bytes(data[p:p + 4]) != b"PK\x01\x02":
raise SystemExit("generator: bad CD signature")
nlen, elen, clen = struct.unpack_from("<HHH", data, p + 28)
got = bytes(data[p + 46:p + 46 + nlen]).decode("utf-8")
if got == name:
return p
p += 46 + nlen + elen + clen
raise SystemExit("generator: entry not found: " + name)
# ---------------------------------------------------------------- 夹具
def build_library(ro):
lib = os.path.join(ro, "library")
os.makedirs(lib, exist_ok=True)
os.makedirs(os.path.join(lib, "bookA"), exist_ok=True)
with open(os.path.join(lib, "bookA", "001.png"), "wb") as f:
f.write(png_bytes(30, 20, 1))
with open(os.path.join(lib, "bookA", "002.png"), "wb") as f:
f.write(png_bytes(30, 21, 2))
write_zip(os.path.join(lib, "vol2.cbz"), [
("ch01/p10.png", png_bytes(40, 30, 3), zipfile.ZIP_DEFLATED),
("readme.txt", b"not a page\n", zipfile.ZIP_STORED),
("ch01/p2.png", png_bytes(41, 31, 4), zipfile.ZIP_DEFLATED),
("onlydir/", b"", zipfile.ZIP_STORED),
("\u4e2d\u6587\u540d.png", png_bytes(12, 34, 5), zipfile.ZIP_DEFLATED),
("empty.bin", b"", zipfile.ZIP_DEFLATED),
])
write_zip(os.path.join(lib, "Vol10.cbz"), [
("cover.bmp", bmp_bytes(7, 9), zipfile.ZIP_STORED),
])
os.makedirs(os.path.join(lib, "box"), exist_ok=True)
write_zip(os.path.join(lib, "box", "box1.cbz"), [
("a.png", png_bytes(8, 8, 6), zipfile.ZIP_DEFLATED),
("b.png", png_bytes(8, 9, 7), zipfile.ZIP_DEFLATED),
])
write_zip(os.path.join(lib, "box", "box2.cbz"), [
("c.jpg", jpeg_header_bytes(12, 34), zipfile.ZIP_DEFLATED),
])
write_zip(os.path.join(lib, "notabook.zip"), [
("readme.txt", b"nothing to read\n", zipfile.ZIP_STORED),
])
write_zip(os.path.join(lib, "emptypages.cbz"), [
("onlydir/", b"", zipfile.ZIP_STORED),
("readme.txt", b"x\n", zipfile.ZIP_STORED),
])
with open(os.path.join(lib, "broken.cbz"), "wb") as f:
f.write(b"NOTAZIP!" * 100)
raw = open(os.path.join(lib, "vol2.cbz"), "rb").read()
with open(os.path.join(lib, "truncated.cbz"), "wb") as f:
f.write(raw[:len(raw) // 2])
write_zip(os.path.join(lib, ".hidden.cbz"), [
("h.png", png_bytes(8, 8, 8), zipfile.ZIP_DEFLATED),
])
def build_features(ro):
feat = os.path.join(ro, "features")
os.makedirs(feat, exist_ok=True)
write_zip(os.path.join(feat, "pathsafe.cbz"), [
("ok1.png", png_bytes(10, 10, 9), zipfile.ZIP_DEFLATED),
("../evil.png", png_bytes(10, 10, 10), zipfile.ZIP_DEFLATED),
("/abs.png", png_bytes(10, 10, 11), zipfile.ZIP_DEFLATED),
("a/../../b.png", png_bytes(10, 10, 12), zipfile.ZIP_DEFLATED),
("sub/ok2.jpg", jpeg_header_bytes(13, 17), zipfile.ZIP_DEFLATED),
("sub/../ok3.png", png_bytes(10, 10, 13), zipfile.ZIP_DEFLATED),
(".hidden/x.png", png_bytes(10, 10, 14), zipfile.ZIP_DEFLATED),
("._mac.png", png_bytes(10, 10, 15), zipfile.ZIP_DEFLATED),
("sub\\win.png", png_bytes(10, 10, 16), zipfile.ZIP_DEFLATED),
("text.txt", b"nope\n", zipfile.ZIP_STORED),
("dir/", b"", zipfile.ZIP_STORED),
])
write_zip(os.path.join(feat, "cjk.cbz"), [
("\u7b2c2\u9875.png", png_bytes(9, 9, 17), zipfile.ZIP_DEFLATED),
("\u7b2c10\u9875.png", png_bytes(9, 9, 18), zipfile.ZIP_DEFLATED),
("\u7b2c1\u9875.png", png_bytes(9, 9, 19), zipfile.ZIP_DEFLATED),
])
entries = []
for i in range(80):
entries.append(("p%03d.png" % i, png_bytes(8, 8, 20 + i), zipfile.ZIP_DEFLATED))
write_zip(os.path.join(feat, "big.cbz"), entries)
bomb = os.path.join(feat, "bomb.cbz")
write_zip(bomb, [("huge.png", png_bytes(10, 10, 30), zipfile.ZIP_STORED)])
data = bytearray(open(bomb, "rb").read())
rec = find_cd_record(data, "huge.png")
struct.pack_into("<I", data, rec + 24, 0x06000000) # 96MiB > 64MiB 上限
with open(bomb, "wb") as f:
f.write(bytes(data))
raw = open(os.path.join(feat, "pathsafe.cbz"), "rb").read()
with open(os.path.join(feat, "truncated.cbz"), "wb") as f:
f.write(raw[:len(raw) // 2])
def write_manifest(ro):
"""每档案每条目:<相对路径>\t<条目名>\t<crc32>\t<size>(Tab 分隔,UTF-8 原名)。"""
lines = []
for base, _dirs, files in os.walk(ro):
for name in sorted(files):
if not name.lower().endswith((".cbz", ".zip")):
continue
path = os.path.join(base, name)
rel = os.path.relpath(path, ro)
try:
with zipfile.ZipFile(path) as z:
for info in z.infolist():
if info.is_dir():
continue
payload = z.read(info)
lines.append("%s\t%s\t%08x\t%d" % (
rel, info.filename, zlib.crc32(payload) & 0xFFFFFFFF,
len(payload)))
except zipfile.BadZipFile:
continue
with open(os.path.join(ro, "manifest.txt"), "w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
def main():
if len(sys.argv) != 2:
raise SystemExit("usage: make_cbz_samples.py <out_dir>")
ro = sys.argv[1]
os.makedirs(ro, exist_ok=True)
build_library(ro)
build_features(ro)
write_manifest(ro)
if __name__ == "__main__":
main()
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
# run_cbz_tests.sh — M11 档案册(CBZ/zip)集成宿主测试(纯 C + -lz,无 SDK/设备)。
# 夹具由 test/make_cbz_samples.py 现场生成到 $TMPDIR 临时目录,结束后删除。
set -e
cd "$(dirname "$0")/.."
SDK="${MEOW_SDK_DIR:-$HOME/Proj/MeowOS-SDK/sdk}"
trap 'rm -f test/test_cbz' EXIT
gcc -Wall -Wextra -Werror -O1 -I. -I"$SDK" \
test/test_cbz.c logic/library.c logic/reader.c \
logic/reader/page_source.c logic/reader/archive_source.c \
logic/reader/page_cache.c logic/zip/zip.c test/settings_reader_stub.c \
-lz -o test/test_cbz
./test/test_cbz ./test/make_cbz_samples.py
+4
View File
@@ -52,3 +52,7 @@ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then
else
echo "SKIP test_settings_lock_host: SDK 源码树不可用(只发布 meow_ui.a 的环境)"
fi
# ---- 档案册(M11):zip 容器 + CBZ 书库/阅读器集成 ----
./test/run_zip_tests.sh
./test/run_cbz_tests.sh
+4 -2
View File
@@ -31,7 +31,9 @@ gcc $CFLAGS test/test_cont_adjacent.c logic/library.c logic/reader.c \
if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then
gcc $CFLAGS -DMEOW_UI_HOST_TEST -DMR_READER_HOST_TEST -I"$SDK/include" \
test/test_mode_switch_host.c ui/reader/reader.c \
logic/library.c logic/reader.c logic/reader/page_source.c logic/reader/nav.c \
logic/library.c logic/reader.c logic/reader/page_source.c \
logic/reader/archive_source.c logic/reader/page_cache.c logic/zip/zip.c \
logic/reader/nav.c \
"$SDK"/meow_ui_gfx.c "$SDK"/meow_ui_sys.c "$SDK"/meow_ui_theme.c \
"$SDK"/meow_ui_vol.c "$SDK"/meow_ui_log.c "$SDK"/meow_ui_widget.c \
"$SDK"/meow_ui_input.c "$SDK"/meow_ui_activity.c "$SDK"/meow_ui_dialog.c \
@@ -40,7 +42,7 @@ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then
"$SDK"/meow_ui_anim.c "$SDK"/meow_ui_scroll.c "$SDK"/meow_ui_img.c \
"$SDK"/meow_ui_media.c "$SDK"/meow_notify_ipc.c "$SDK"/meow_ui_config.c \
"$SDK"/meow_ui_nav.c "$SDK"/stb_image.c "$SDK"/test_host_stub.c \
-pthread -lm -o "$OUT/test_mode_switch_host"
-pthread -lm -lz -o "$OUT/test_mode_switch_host"
"$OUT/test_mode_switch_host"
else
echo "SKIP test_mode_switch_host: SDK 源码树不可用(只发布 meow_ui.a 的环境)"
+847
View File
@@ -0,0 +1,847 @@
/* test_cbz.c — M11 档案册(CBZ/zip)集成宿主测试(纯 C + -lz,无 SDK/硬件)。
*
* 夹具由 test/make_cbz_samples.py 用 python3 zipfile 现场生成(真实 zip 产物 +
* 定点字节修补),放在 $TMPDIR 下临时目录,结束后删除。
*
* 覆盖:
* T1 扩展名判据:cbz/zip 大小写不敏感;cbr/gz/无扩展名不算档案。
* T2 页枚举:只收图片页(目录项/文本/0 字节/加密/超限过滤),跨子目录自然序;
* 中文条目名按原始 UTF-8 字节透传。
* T3 路径安全:绝对路径、'..' 段、反斜杠、内嵌隐藏段一律不收;解压字节与
* manifest 的 CRC/尺寸一致,缓存文件落在 MeowData/<pkg>/cache。
* T4 meta:PNG/BMP/JPEG 原始尺寸经「解压到内存只读头」探测(翻页/条漫排版共用)。
* T5 解压缓存纪律:64 页 LRU 淘汰最旧、命中不重写(inode 不变)、32MiB 字节上限、
* 启动清孤儿(只删 mrpage_*,不动缩略图 *.bmp)、缓存不可用时优雅失败。
* T6 坏档容错:非 zip/截断/无图片页/声明 96MiB 的 zip 炸弹 → 打开失败或 0 页,不崩。
* T7 统一开册契约:目录册与档案册走同一个 mr_book(count/meta/open/close/destroy),
* 越界 open 返回 NULL,close_auto 幂等。
* T8 书库列档:.cbz 与文件夹册同列自然序(bookA < box < vol2.cbz < Vol10.cbz),
* 行=文件名+页数;坏档/无图档案不列;只含档案册的目录写 "N books";
* 不注入探测回调时行为与 M7 完全一致(纯目录书库)。
* T9 册间衔接:同父目录自然序混排(bookA → vol2.cbz → Vol10.cbz),
* 首/末册返回 0;不注入探测回调时档案册不参与(M7 语义)。
* T10 续读角标:档案路径作为册标识,副标题后缀「· 续读 Pn」。
*
* 编译(在 tuku/ 下):
* gcc -Wall -Wextra -Werror -O1 -I. test/test_cbz.c logic/library.c logic/reader.c \
* logic/reader/page_source.c logic/reader/archive_source.c \
* logic/reader/page_cache.c logic/zip/zip.c test/settings_reader_stub.c \
* -lz -o test/test_cbz && ./test/test_cbz
* 或 test/run_cbz_tests.sh。
*/
#define _POSIX_C_SOURCE 200809L
#include "logic/library.h"
#include "logic/reader.h"
#include "logic/reader/page_cache.h"
#include "logic/reader/page_source.h"
#include "test/settings_reader_stub.h"
#include <dirent.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
#include <zlib.h>
#define PATH_CAP 1024
#define CJK_PAGE "\xe4\xb8\xad\xe6\x96\x87\xe5\x90\x8d.png" /* 中文名.png */
static int g_pass;
static int g_fail;
static char g_root[PATH_CAP]; /* 夹具根 */
static char g_lib[PATH_CAP]; /* <root>/library */
static char g_feat[PATH_CAP]; /* <root>/features */
static char g_data[PATH_CAP]; /* <root>/app/os.meow.picobrowser/data */
static const char *g_argv0;
#define CHECK(cond, ...) do { \
if (cond) { \
g_pass++; \
} else { \
g_fail++; \
printf("FAIL %s:%d ", __FILE__, __LINE__); \
printf(__VA_ARGS__); \
printf("\n"); \
} \
} while (0)
/* ============================================================ */
/* 夹具/文件辅助 */
/* ============================================================ */
static void joinp(char *out, size_t cap, const char *a, const char *b)
{
snprintf(out, cap, "%s/%s", a, b);
}
static int rm_rf(const char *path)
{
struct stat st;
if (lstat(path, &st) != 0) return -1;
if (!S_ISDIR(st.st_mode)) return unlink(path);
DIR *d = opendir(path);
if (!d) return -1;
struct dirent *de;
while ((de = readdir(d)) != NULL) {
if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) continue;
char full[PATH_CAP];
joinp(full, sizeof full, path, de->d_name);
rm_rf(full);
}
closedir(d);
return rmdir(path);
}
static int make_temp_dir(void)
{
const char *bases[3];
int nb = 0;
const char *env = getenv("TMPDIR");
if (env && env[0]) bases[nb++] = env;
bases[nb++] = "/data/data/com.termux/files/usr/tmp";
bases[nb++] = "/tmp";
for (int i = 0; i < nb; i++) {
char tmpl[PATH_CAP];
snprintf(tmpl, sizeof tmpl, "%s/tuku_cbz_XXXXXX", bases[i]);
if (mkdtemp(tmpl)) {
snprintf(g_root, sizeof g_root, "%s", tmpl);
return 1;
}
}
return 0;
}
static const char *find_generator(char *buf, size_t cap)
{
static const char *fallbacks[] = {
"test/make_cbz_samples.py", "make_cbz_samples.py",
};
const char *a0 = g_argv0 ? g_argv0 : "";
const char *slash = strrchr(a0, '/');
if (a0[0] && strcmp(a0, "-") != 0 && slash) {
snprintf(buf, cap, "%.*s/make_cbz_samples.py", (int)(slash - a0), a0);
if (access(buf, R_OK) == 0) return buf;
}
for (size_t i = 0; i < sizeof fallbacks / sizeof fallbacks[0]; i++)
if (access(fallbacks[i], R_OK) == 0) return fallbacks[i];
return fallbacks[0];
}
static int run_generator(const char *gen)
{
char cmd[PATH_CAP * 2 + 32];
snprintf(cmd, sizeof cmd, "python3 '%s' '%s'", gen, g_root);
int st = system(cmd);
return st != -1 && WIFEXITED(st) && WEXITSTATUS(st) == 0;
}
static int file_crc(const char *path, unsigned long *crc, long *size)
{
FILE *f = fopen(path, "rb");
if (!f) return 0;
unsigned char buf[8192];
uLong c = crc32(0L, Z_NULL, 0);
long total = 0;
size_t n;
while ((n = fread(buf, 1, sizeof buf, f)) > 0) {
c = crc32(c, buf, (uInt)n);
total += (long)n;
}
fclose(f);
if (crc) *crc = (unsigned long)c;
if (size) *size = total;
return 1;
}
/* manifest.txt 行:<档案相对路径>\t<条目名>\t<crc32>\t<size> */
static int manifest_lookup(const char *rel, const char *entry,
unsigned long *crc, long *size)
{
char path[PATH_CAP];
joinp(path, sizeof path, g_root, "manifest.txt");
FILE *f = fopen(path, "r");
if (!f) return 0;
char line[PATH_CAP * 2];
char m_rel[PATH_CAP], m_ent[512];
unsigned long m_crc;
long m_size;
int found = 0;
while (fgets(line, sizeof line, f) != NULL) {
if (sscanf(line, "%1023[^\t]\t%511[^\t]\t%lx\t%ld",
m_rel, m_ent, &m_crc, &m_size) != 4)
continue;
if (strcmp(m_rel, rel) == 0 && strcmp(m_ent, entry) == 0) {
if (crc) *crc = m_crc;
if (size) *size = m_size;
found = 1;
break;
}
}
fclose(f);
return found;
}
/* ============================================================ */
/* 假解码回调(契约边界:logic 不链 SDK) */
/* ============================================================ */
static int g_dummy;
static int g_load_n;
static int g_unload_n;
static char g_load_path[MR_CACHE_FILE_MAX];
static meow_img *fake_load(const char *path, void *ud, int maxdim)
{
(void)ud;
(void)maxdim;
g_load_n++;
snprintf(g_load_path, sizeof g_load_path, "%s", path ? path : "");
return (meow_img *)&g_dummy;
}
static void fake_unload(meow_img *img)
{
if (img) g_unload_n++;
}
static int archive_pages_probe(const char *path, void *ud)
{
(void)ud;
return mr_zip_count_pages(path);
}
static int count_cache_files(void)
{
const char *dir = mr_cache_dir();
if (!dir || !dir[0]) return -1;
DIR *d = opendir(dir);
if (!d) return -1;
int n = 0;
size_t plen = strlen(MR_CACHE_PREFIX);
struct dirent *de;
while ((de = readdir(d)) != NULL) {
if (strncmp(de->d_name, MR_CACHE_PREFIX, plen) == 0) n++;
}
closedir(d);
return n;
}
/* ============================================================ */
/* T1 扩展名 */
/* ============================================================ */
static void test_ext(void)
{
CHECK(mr_path_is_archive("a.cbz") == 1, "cbz");
CHECK(mr_path_is_archive("A.CBZ") == 1, "CBZ");
CHECK(mr_path_is_archive("x.zip") == 1, "zip");
CHECK(mr_path_is_archive("X.ZiP") == 1, "ZiP");
CHECK(mr_path_is_archive("a.cbr") == 0, "cbr not archive");
CHECK(mr_path_is_archive("a.gz") == 0, "gz not archive");
CHECK(mr_path_is_archive("cbz") == 0, "no extension");
CHECK(mr_path_is_archive(".cbz") == 0, "dot name");
CHECK(mr_path_is_archive(NULL) == 0, "NULL safe");
}
/* ============================================================ */
/* T2 页枚举 / 排序 / 名字透传 */
/* ============================================================ */
static void test_enum(void)
{
char path[PATH_CAP];
joinp(path, sizeof path, g_lib, "vol2.cbz");
mr_zip_book zb;
int n = mr_zip_book_init(&zb, path, 720, fake_load, fake_unload, NULL);
CHECK(n == 3, "vol2 pages=%d (want 3)", n);
CHECK(zb.count == 3, "zb.count=%d", zb.count);
if (n != 3) {
mr_zip_book_free(&zb);
return;
}
char name[512];
mr_zip_book_page_name(&zb, 0, name, sizeof name);
CHECK(strcmp(name, "ch01/p2.png") == 0, "page0=%s", name);
mr_zip_book_page_name(&zb, 1, name, sizeof name);
CHECK(strcmp(name, "ch01/p10.png") == 0, "page1=%s", name);
mr_zip_book_page_name(&zb, 2, name, sizeof name);
CHECK(strcmp(name, CJK_PAGE) == 0, "page2=%s", name);
mr_zip_book_page_name(&zb, -1, name, sizeof name);
CHECK(name[0] == '\0', "page_name(-1) not empty");
mr_zip_book_page_name(&zb, 3, name, sizeof name);
CHECK(name[0] == '\0', "page_name(count) not empty");
mr_zip_book_page_name(&zb, 0, NULL, 10);
mr_zip_book_page_name(&zb, 0, name, 0);
/* 目录项/文本/0 字节条目都被过滤(count==3 已证明)。 */
mr_book b;
memset(&b, 0, sizeof b);
mr_zip_book_bind(&b, NULL);
CHECK(!mr_book_valid(&b), "bind NULL must leave book invalid");
CHECK(mr_book_count(&b) == 0 && mr_book_open(&b, 0) == NULL, "invalid book safe");
mr_zip_book_bind(&b, &zb);
CHECK(mr_book_valid(&b), "valid after bind");
CHECK(mr_book_count(&b) == 3, "book count=%d", mr_book_count(&b));
CHECK(mr_book_open(&b, 3) == NULL, "open(count) != NULL");
CHECK(mr_book_open(&b, -1) == NULL, "open(-1) != NULL");
int before = g_load_n;
meow_img *img = mr_book_open(&b, 0);
CHECK(img != NULL, "open(0) returned NULL");
CHECK(g_load_n == before + 1, "load callback not called");
CHECK(strncmp(g_load_path, mr_cache_dir(), strlen(mr_cache_dir())) == 0,
"load path not in cache dir: %s", g_load_path);
CHECK(access(g_load_path, R_OK) == 0, "extracted file missing: %s", g_load_path);
CHECK(strstr(g_load_path, ".png") != NULL, "extension not preserved: %s", g_load_path);
mr_book_close(&b, img);
CHECK(g_unload_n >= 1, "unload not called");
mr_book_close(&b, NULL); /* 容忍 NULL */
mr_book_destroy(&b); /* 走 destroy→zip_free */
CHECK(!mr_book_valid(&b), "destroy left book valid");
mr_zip_book_free(&zb); /* 幂等 */
}
/* ============================================================ */
/* T3 路径安全 + 解压内容 */
/* ============================================================ */
static void test_pathsafe(void)
{
char path[PATH_CAP];
joinp(path, sizeof path, g_feat, "pathsafe.cbz");
mr_zip_book zb;
int n = mr_zip_book_init(&zb, path, 720, fake_load, fake_unload, NULL);
CHECK(n == 2, "pathsafe pages=%d (want 2: ok1.png, sub/ok2.jpg)", n);
if (n != 2) {
mr_zip_book_free(&zb);
return;
}
char name[512];
mr_zip_book_page_name(&zb, 0, name, sizeof name);
CHECK(strcmp(name, "ok1.png") == 0, "page0=%s", name);
mr_zip_book_page_name(&zb, 1, name, sizeof name);
CHECK(strcmp(name, "sub/ok2.jpg") == 0, "page1=%s", name);
const char *bad[] = { "../evil.png", "/abs.png", "a/../../b.png", "sub/../ok3.png",
".hidden/x.png", "._mac.png", "sub\\win.png" };
for (size_t i = 0; i < sizeof bad / sizeof bad[0]; i++) {
CHECK(mr_zip_entry_is_page(bad[i], (unsigned)strlen(bad[i])) == 0,
"unsafe entry accepted: %s", bad[i]);
}
for (int i = 0; i < n; i++) {
char out[MR_CACHE_FILE_MAX];
CHECK(mr_zip_book_page_path(&zb, i, out, (int)sizeof out) == 0,
"page_path(%d) failed", i);
if (!out[0]) continue;
CHECK(strncmp(out, mr_cache_dir(), strlen(mr_cache_dir())) == 0,
"cache path escaped dir: %s", out);
mr_zip_book_page_name(&zb, i, name, sizeof name);
unsigned long crc = 0;
long size = 0;
CHECK(manifest_lookup("features/pathsafe.cbz", name, &crc, &size),
"manifest miss %s", name);
unsigned long got_crc = 0;
long got_size = 0;
CHECK(file_crc(out, &got_crc, &got_size), "crc read failed: %s", out);
CHECK(got_crc == crc && got_size == size,
"extracted bytes differ for %s (crc %lx/%lx size %ld/%ld)",
name, got_crc, crc, got_size, size);
}
mr_zip_book_free(&zb);
}
/* ============================================================ */
/* T4 meta(原始像素尺寸) */
/* ============================================================ */
static void test_meta(void)
{
char path[PATH_CAP];
joinp(path, sizeof path, g_lib, "vol2.cbz");
mr_zip_book zb;
CHECK(mr_zip_book_init(&zb, path, 720, fake_load, fake_unload, NULL) == 3,
"vol2 init for meta");
mr_book b;
mr_zip_book_bind(&b, &zb);
int w = 0, h = 0;
CHECK(mr_book_meta(&b, 0, &w, &h) == 1 && w == 41 && h == 31,
"meta0=%dx%d (want 41x31)", w, h);
CHECK(mr_book_meta(&b, 1, &w, &h) == 1 && w == 40 && h == 30,
"meta1=%dx%d (want 40x30)", w, h);
CHECK(mr_book_meta(&b, 2, &w, &h) == 1 && w == 12 && h == 34,
"meta2=%dx%d (want 12x34)", w, h);
CHECK(mr_book_meta(&b, 3, &w, &h) == 0 && w == 0 && h == 0, "meta(count)");
mr_book_destroy(&b);
joinp(path, sizeof path, g_lib, "Vol10.cbz");
CHECK(mr_zip_book_init(&zb, path, 720, fake_load, fake_unload, NULL) == 1,
"Vol10 init");
mr_zip_book_bind(&b, &zb);
CHECK(mr_book_meta(&b, 0, &w, &h) == 1 && w == 7 && h == 9,
"bmp meta=%dx%d (want 7x9)", w, h);
mr_book_destroy(&b);
joinp(path, sizeof path, g_lib, "box/box2.cbz");
CHECK(mr_zip_book_init(&zb, path, 720, fake_load, fake_unload, NULL) == 1,
"box2 init");
mr_zip_book_bind(&b, &zb);
CHECK(mr_book_meta(&b, 0, &w, &h) == 1 && w == 12 && h == 34,
"jpeg meta=%dx%d (want 12x34)", w, h);
mr_book_destroy(&b);
/* 内存探测边界 */
CHECK(mr_probe_size_mem(NULL, 100, &w, &h) == 0, "probe NULL");
CHECK(mr_probe_size_mem("ABCDEFGH", 8, &w, &h) == 0, "probe junk");
/* meta 只解压到内存读头,不落解压缓存(box1 从未被 open 过)。 */
joinp(path, sizeof path, g_lib, "box/box1.cbz");
CHECK(mr_zip_book_init(&zb, path, 720, fake_load, fake_unload, NULL) == 2,
"box1 init");
mr_zip_book_bind(&b, &zb);
CHECK(mr_book_meta(&b, 0, &w, &h) == 1 && w == 8 && h == 8,
"box1 meta=%dx%d (want 8x8)", w, h);
struct stat st;
char pname[256], cpath[MR_CACHE_FILE_MAX];
CHECK(stat(path, &st) == 0, "stat box1");
mr_zip_book_page_name(&zb, 0, pname, (int)sizeof pname);
CHECK(mr_cache_path(path, (long long)st.st_mtime, (long long)st.st_size,
pname, 0, cpath, (int)sizeof cpath) == 0, "meta cache path");
CHECK(access(cpath, F_OK) != 0, "meta wrote the page cache");
mr_book_destroy(&b);
}
/* ============================================================ */
/* T5 解压缓存纪律 */
/* ============================================================ */
static void test_cache(void)
{
char path[PATH_CAP];
char data_dir[PATH_CAP];
char cache_copy[MR_CACHE_DIR_MAX];
joinp(path, sizeof path, g_feat, "big.cbz");
/* ---- (a) 64 页 LRU ---- */
joinp(data_dir, sizeof data_dir, g_root, "c1/os.meow.picobrowser/data");
CHECK(mr_cache_init(data_dir) == 0, "cache init c1");
snprintf(cache_copy, sizeof cache_copy, "%s", mr_cache_dir());
CHECK(cache_copy[0] && strstr(cache_copy, "/cache") != NULL,
"cache dir=%s", cache_copy);
mr_zip_book zb;
int n = mr_zip_book_init(&zb, path, 720, fake_load, fake_unload, NULL);
CHECK(n == 80, "big pages=%d (want 80)", n);
char p[MR_CACHE_FILE_MAX];
for (int i = 0; i < n; i++)
CHECK(mr_zip_book_page_path(&zb, i, p, (int)sizeof p) == 0,
"big page %d path", i);
CHECK(mr_cache_count() == MR_CACHE_MAX_PAGES,
"cache count=%d (want 64)", mr_cache_count());
CHECK(mr_cache_bytes() <= MR_CACHE_MAX_BYTES, "cache bytes over cap");
CHECK(count_cache_files() == mr_cache_count(),
"disk files=%d index=%d", count_cache_files(), mr_cache_count());
/* 最旧的 16 页被淘汰,最新的仍在 */
struct stat st;
char arc[PATH_CAP];
char name[512];
snprintf(arc, sizeof arc, "%s", path);
CHECK(stat(arc, &st) == 0, "stat big.cbz");
char p0[MR_CACHE_FILE_MAX], p79[MR_CACHE_FILE_MAX];
mr_zip_book_page_name(&zb, 0, name, sizeof name);
CHECK(mr_cache_path(arc, (long long)st.st_mtime, (long long)st.st_size,
name, 0, p0, (int)sizeof p0) == 0, "cache_path p0");
mr_zip_book_page_name(&zb, 79, name, sizeof name);
CHECK(mr_cache_path(arc, (long long)st.st_mtime, (long long)st.st_size,
name, 79, p79, (int)sizeof p79) == 0, "cache_path p79");
CHECK(access(p0, F_OK) != 0, "LRU kept the oldest page");
CHECK(access(p79, F_OK) == 0, "LRU dropped the newest page");
/* 命中不重写:同一 inode */
struct stat s1, s2;
CHECK(stat(p79, &s1) == 0, "stat p79");
CHECK(mr_zip_book_page_path(&zb, 79, p, (int)sizeof p) == 0, "page79 re-get");
CHECK(stat(p, &s2) == 0 && s1.st_ino == s2.st_ino, "cache hit rewrote the file");
mr_zip_book_free(&zb);
/* ---- (b) 32MiB 字节上限 + 启动清孤儿 ---- */
joinp(data_dir, sizeof data_dir, g_root, "c2/os.meow.picobrowser/data");
mr_cache_shutdown();
CHECK(mr_cache_init(data_dir) == 0, "cache init c2");
snprintf(cache_copy, sizeof cache_copy, "%s", mr_cache_dir());
char orphan[MR_CACHE_FILE_MAX], keep[MR_CACHE_FILE_MAX];
snprintf(orphan, sizeof orphan, "%s/%sdeadbeef.png", cache_copy, MR_CACHE_PREFIX);
snprintf(keep, sizeof keep, "%s/thumb.bmp", cache_copy);
FILE *f = fopen(orphan, "wb");
CHECK(f != NULL, "create orphan");
if (f) { fputs("orphan", f); fclose(f); }
f = fopen(keep, "wb");
CHECK(f != NULL, "create thumb");
if (f) { fputs("thumb", f); fclose(f); }
mr_cache_shutdown();
CHECK(mr_cache_init(data_dir) == 0, "cache re-init c2");
CHECK(access(orphan, F_OK) != 0, "startup did not clean orphan mrpage_*");
CHECK(access(keep, F_OK) == 0, "startup deleted non-cache file");
size_t big_len = 20u * 1024u * 1024u;
unsigned char *big = (unsigned char *)malloc(big_len);
CHECK(big != NULL, "malloc 20MiB");
if (big) {
memset(big, 0xA5, big_len);
char b1[MR_CACHE_FILE_MAX], b2[MR_CACHE_FILE_MAX];
snprintf(b1, sizeof b1, "%s/%sbig1.img", cache_copy, MR_CACHE_PREFIX);
snprintf(b2, sizeof b2, "%s/%sbig2.img", cache_copy, MR_CACHE_PREFIX);
mr_cache_shutdown();
mr_cache_init(data_dir);
CHECK(mr_cache_store(b1, big, big_len) == 1, "store 20MiB #1");
CHECK(mr_cache_count() == 1 && mr_cache_bytes() == (long long)big_len,
"count=%d bytes=%lld", mr_cache_count(), mr_cache_bytes());
CHECK(mr_cache_store(b2, big, big_len) == 1, "store 20MiB #2");
CHECK(mr_cache_count() == 1 && mr_cache_bytes() == (long long)big_len,
"byte cap: count=%d bytes=%lld", mr_cache_count(), mr_cache_bytes());
CHECK(access(b1, F_OK) != 0, "byte cap did not evict the oldest");
free(big);
}
unlink(keep);
/* ---- (c) 缓存不可用时优雅失败(不崩、不写文件) ---- */
mr_cache_shutdown();
CHECK(mr_cache_ready() == 0, "shutdown left cache ready");
joinp(path, sizeof path, g_lib, "vol2.cbz");
mr_zip_book zb2;
CHECK(mr_zip_book_init(&zb2, path, 720, fake_load, fake_unload, NULL) == 3,
"init without cache");
CHECK(mr_zip_book_page_path(&zb2, 0, p, (int)sizeof p) == -1,
"page_path without cache should fail");
int before = g_load_n;
mr_book b;
mr_zip_book_bind(&b, &zb2);
CHECK(mr_book_open(&b, 0) == NULL, "open without cache should fail");
CHECK(g_load_n == before, "decode callback called without cache");
mr_book_destroy(&b);
joinp(data_dir, sizeof data_dir, g_root, "app/os.meow.picobrowser/data");
CHECK(mr_cache_init(data_dir) == 0, "cache init app");
}
/* ============================================================ */
/* T6 坏档容错 */
/* ============================================================ */
static void expect_bad(const char *path, const char *tag)
{
mr_book_handle h;
mr_book b;
int n = mr_book_open_auto(&b, &h, path, 720, fake_load, fake_unload, NULL);
CHECK(n == -1, "%s: open_auto=%d (want -1)", tag, n);
CHECK(!mr_book_valid(&b), "%s: book valid after failure", tag);
CHECK(mr_book_open(&b, 0) == NULL, "%s: open after failure", tag);
mr_book_close_auto(&b, &h);
mr_book_close_auto(&b, &h); /* 幂等 */
CHECK(mr_zip_count_pages(path) == 0, "%s: count_pages != 0", tag);
}
static void test_bad(void)
{
char p[PATH_CAP];
joinp(p, sizeof p, g_lib, "broken.cbz");
expect_bad(p, "broken");
joinp(p, sizeof p, g_lib, "truncated.cbz");
expect_bad(p, "truncated");
joinp(p, sizeof p, g_feat, "truncated.cbz");
expect_bad(p, "truncated(feat)");
joinp(p, sizeof p, g_lib, "missing.cbz");
expect_bad(p, "missing");
mr_book_handle h;
mr_book b;
CHECK(mr_book_open_auto(&b, &h, NULL, 720, fake_load, fake_unload, NULL) == -1,
"open_auto(NULL)");
CHECK(mr_book_open_auto(NULL, &h, p, 720, fake_load, fake_unload, NULL) == -1,
"open_auto(NULL book)");
CHECK(mr_book_open_auto(&b, NULL, p, 720, fake_load, fake_unload, NULL) == -1,
"open_auto(NULL handle)");
/* 合法 zip 但没有图片页 / 声明尺寸超上限(zip 炸弹) */
const char *zero[] = { "notabook.zip", "emptypages.cbz" };
for (size_t i = 0; i < sizeof zero / sizeof zero[0]; i++) {
joinp(p, sizeof p, g_lib, zero[i]);
int n = mr_book_open_auto(&b, &h, p, 720, fake_load, fake_unload, NULL);
CHECK(n == 0, "%s: open_auto=%d (want 0)", zero[i], n);
CHECK(!mr_book_valid(&b), "%s: book bound with 0 pages", zero[i]);
CHECK(mr_book_open(&b, 0) == NULL, "%s: open on empty book", zero[i]);
mr_book_close_auto(&b, &h);
CHECK(mr_zip_count_pages(p) == 0, "%s: count_pages != 0", zero[i]);
}
joinp(p, sizeof p, g_feat, "bomb.cbz");
CHECK(mr_zip_count_pages(p) == 0, "bomb.cbz should have no page (96MiB)");
CHECK(mr_book_open_auto(&b, &h, p, 720, fake_load, fake_unload, NULL) == 0,
"bomb: open_auto should be 0 pages");
mr_book_close_auto(&b, &h);
}
/* ============================================================ */
/* T7 统一开册契约 */
/* ============================================================ */
static void test_open_auto(void)
{
char p[PATH_CAP];
joinp(p, sizeof p, g_lib, "bookA");
mr_book_handle h;
mr_book b;
int n = mr_book_open_auto(&b, &h, p, 720, fake_load, fake_unload, NULL);
CHECK(n == 2 && h.kind == MR_BOOK_KIND_DIR, "dir open n=%d kind=%d", n, h.kind);
CHECK(mr_book_valid(&b) && mr_book_count(&b) == 2, "dir book contract");
int before = g_load_n;
meow_img *img = mr_book_open(&b, 1);
CHECK(img != NULL && g_load_n == before + 1, "dir open(1)");
CHECK(strstr(g_load_path, "002.png") != NULL, "dir page path=%s", g_load_path);
mr_book_close(&b, img);
int mw = 0, mh = 0;
CHECK(mr_book_meta(&b, 0, &mw, &mh) == 1 && mw == 30 && mh == 20,
"dir meta=%dx%d (want 30x20)", mw, mh);
mr_book_close_auto(&b, &h);
CHECK(h.kind == MR_BOOK_KIND_NONE && !mr_book_valid(&b), "dir close_auto");
mr_book_close_auto(&b, &h); /* 幂等 */
joinp(p, sizeof p, g_lib, "vol2.cbz");
n = mr_book_open_auto(&b, &h, p, 720, fake_load, fake_unload, NULL);
CHECK(n == 3 && h.kind == MR_BOOK_KIND_ZIP, "zip open n=%d kind=%d", n, h.kind);
CHECK(mr_book_valid(&b) && mr_book_count(&b) == 3, "zip book contract");
CHECK(mr_book_open(&b, 99) == NULL, "zip out of range");
mr_book_close_auto(&b, &h);
CHECK(!mr_book_valid(&b), "zip close_auto");
joinp(p, sizeof p, g_lib, "emptypages.cbz");
n = mr_book_open_auto(&b, &h, p, 720, fake_load, fake_unload, NULL);
CHECK(n == 0 && h.kind == MR_BOOK_KIND_ZIP, "empty zip n=%d kind=%d", n, h.kind);
CHECK(!mr_book_valid(&b), "empty zip bound");
mr_book_close_auto(&b, &h);
}
/* ============================================================ */
/* T8 书库列档 */
/* ============================================================ */
static const mr_library_entry *lib_find(const mr_library *lib, const char *name)
{
for (int i = 0; i < lib->count; i++)
if (strcmp(lib->items[i].name, name) == 0) return &lib->items[i];
return NULL;
}
static void test_library(void)
{
mr_test_cfg_clear();
mr_library lib;
memset(&lib, 0, sizeof lib);
int n = mr_library_scan_ex(&lib, g_lib, MR_LIB_MAX_DEPTH, archive_pages_probe, NULL);
CHECK(n == 4, "library rows=%d (want bookA, box, vol2.cbz, Vol10.cbz)", n);
if (n == 4) {
CHECK(strcmp(lib.items[0].name, "bookA") == 0, "row0=%s", lib.items[0].name);
CHECK(strcmp(lib.items[1].name, "box") == 0, "row1=%s", lib.items[1].name);
CHECK(strcmp(lib.items[2].name, "vol2.cbz") == 0, "row2=%s", lib.items[2].name);
CHECK(strcmp(lib.items[3].name, "Vol10.cbz") == 0, "row3=%s", lib.items[3].name);
}
const mr_library_entry *e = lib_find(&lib, "bookA");
CHECK(e && e->pages == 2 && e->has_images == 1 && e->subdirs == 0, "bookA fields");
CHECK(e && strcmp(e->subtitle, "2 pages") == 0, "bookA subtitle=%s",
e ? e->subtitle : "");
CHECK(e && strstr(e->cover, "001.png") != NULL, "bookA cover=%s",
e ? e->cover : "");
e = lib_find(&lib, "box");
CHECK(e && e->has_images == 0 && e->subdirs == 0, "box fields");
CHECK(e && strcmp(e->subtitle, "2 books") == 0, "box subtitle=%s",
e ? e->subtitle : "");
CHECK(e && e->cover[0] == '\0', "box cover should be empty");
e = lib_find(&lib, "vol2.cbz");
CHECK(e && e->pages == 3 && e->has_images == 1 && e->subdirs == 0,
"vol2.cbz fields");
CHECK(e && strcmp(e->subtitle, "3 pages") == 0, "vol2.cbz subtitle=%s",
e ? e->subtitle : "");
CHECK(e && e->cover[0] == '\0', "archive cover should be empty (UI extracts)");
CHECK(e && strstr(e->path, "vol2.cbz") != NULL, "vol2.cbz path=%s",
e ? e->path : "");
e = lib_find(&lib, "Vol10.cbz");
CHECK(e && e->pages == 1 && strcmp(e->subtitle, "1 page") == 0,
"Vol10.cbz pages/subtitle");
CHECK(lib_find(&lib, "notabook.zip") == NULL, "text-only zip listed");
CHECK(lib_find(&lib, "emptypages.cbz") == NULL, "no-image cbz listed");
CHECK(lib_find(&lib, "broken.cbz") == NULL, "broken cbz listed");
CHECK(lib_find(&lib, "truncated.cbz") == NULL, "truncated cbz listed");
CHECK(lib_find(&lib, ".hidden.cbz") == NULL, "hidden cbz listed");
mr_library_free(&lib);
/* 不注入探测回调:与 M7 完全一致(档案册不参与,只含档案册的目录也不 nav)。 */
memset(&lib, 0, sizeof lib);
CHECK(mr_library_scan(&lib, g_lib, MR_LIB_MAX_DEPTH) == 1,
"legacy scan rows=%d (want 1: bookA)", lib.count);
CHECK(lib.count >= 1 && strcmp(lib.items[0].name, "bookA") == 0,
"legacy scan row0");
mr_library_free(&lib);
}
/* ============================================================ */
/* T9 册间衔接 */
/* ============================================================ */
static void expect_adj(const char *from, int delta, const char *want, const char *tag)
{
char out[MR_PATH_MAX];
int r = mr_book_adjacent_ex(from, delta, out, (int)sizeof out,
archive_pages_probe, NULL);
if (!want) {
CHECK(r == 0 && out[0] == '\0', "%s: r=%d out=%s", tag, r, out);
} else {
CHECK(r == 1 && strcmp(out, want) == 0, "%s: got %s (want %s)", tag, out, want);
}
}
static void test_adjacent(void)
{
char bookA[MR_PATH_MAX], vol2[MR_PATH_MAX], vol10[MR_PATH_MAX];
char broken[MR_PATH_MAX];
joinp(bookA, sizeof bookA, g_lib, "bookA");
joinp(vol2, sizeof vol2, g_lib, "vol2.cbz");
joinp(vol10, sizeof vol10, g_lib, "Vol10.cbz");
joinp(broken, sizeof broken, g_lib, "broken.cbz");
/* 兄弟表 = bookA + vol2.cbz + Vol10.cbz(box 无直接图、坏档不算书)。 */
expect_adj(bookA, 1, vol2, "bookA next");
expect_adj(vol2, 1, vol10, "vol2 next");
expect_adj(vol10, 1, NULL, "vol10 next (end)");
expect_adj(bookA, -1, NULL, "bookA prev (start)");
expect_adj(vol2, -1, bookA, "vol2 prev");
expect_adj(vol10, -1, vol2, "vol10 prev");
expect_adj(broken, 1, NULL, "broken not a book");
expect_adj(broken, -1, NULL, "broken not a book prev");
/* 不注入探测回调:档案册不参与(M7 语义) */
char out[MR_PATH_MAX];
CHECK(mr_book_adjacent(vol2, 1, out, (int)sizeof out) == 0,
"legacy adjacency should ignore archives (r=%d)", 0);
CHECK(mr_book_adjacent(bookA, 1, out, (int)sizeof out) == 0,
"legacy adjacency bookA next should be none");
/* 参数边界 */
CHECK(mr_book_adjacent_ex(vol2, 0, out, (int)sizeof out, archive_pages_probe,
NULL) == -1, "delta 0");
CHECK(mr_book_adjacent_ex(NULL, 1, out, (int)sizeof out, archive_pages_probe,
NULL) == -1, "NULL dir");
CHECK(mr_book_adjacent_ex(vol2, 1, NULL, 32, archive_pages_probe, NULL) == -1,
"NULL out");
CHECK(mr_book_adjacent_ex("/", 1, out, (int)sizeof out, archive_pages_probe,
NULL) == -1, "root");
}
/* ============================================================ */
/* T10 续读角标 + 封面 */
/* ============================================================ */
static void test_badge_cover(void)
{
char vol2[MR_PATH_MAX];
joinp(vol2, sizeof vol2, g_lib, "vol2.cbz");
mr_test_cfg_clear();
mr_cont_save(vol2, 2, MR_MODE_WEBTOON);
mr_library lib;
memset(&lib, 0, sizeof lib);
CHECK(mr_library_scan_ex(&lib, g_lib, MR_LIB_MAX_DEPTH, archive_pages_probe,
NULL) == 4, "rescan for badge");
const mr_library_entry *e = lib_find(&lib, "vol2.cbz");
CHECK(e && strstr(e->subtitle, "续读 P3") != NULL, "badge subtitle=%s",
e ? e->subtitle : "");
CHECK(e && strstr(e->subtitle, "3 pages") != NULL, "badge keeps page text: %s",
e ? e->subtitle : "");
e = lib_find(&lib, "Vol10.cbz");
CHECK(e && strstr(e->subtitle, "续读") == NULL, "Vol10 should have no badge");
mr_library_free(&lib);
/* 清除记录后角标消失 */
mr_cont_save(vol2, 0, MR_MODE_WEBTOON);
memset(&lib, 0, sizeof lib);
mr_library_scan_ex(&lib, g_lib, MR_LIB_MAX_DEPTH, archive_pages_probe, NULL);
e = lib_find(&lib, "vol2.cbz");
CHECK(e && strstr(e->subtitle, "续读") == NULL, "cleared badge gone");
mr_library_free(&lib);
/* 封面 = 档内自然序首图(ch01/p2.png),解压到缓存 */
char cover[MR_CACHE_FILE_MAX];
CHECK(mr_zip_cover_path(vol2, cover, (int)sizeof cover) == 1, "cover path");
CHECK(cover[0] && access(cover, R_OK) == 0, "cover file missing: %s", cover);
unsigned long crc = 0;
long size = 0;
CHECK(file_crc(cover, &crc, &size), "cover crc");
unsigned long want_crc = 0;
long want_size = 0;
CHECK(manifest_lookup("library/vol2.cbz", "ch01/p2.png", &want_crc, &want_size),
"cover manifest");
CHECK(crc == want_crc && size == want_size, "cover bytes differ");
char broken[MR_PATH_MAX];
joinp(broken, sizeof broken, g_lib, "broken.cbz");
CHECK(mr_zip_cover_path(broken, cover, (int)sizeof cover) == 0, "broken cover");
CHECK(mr_zip_cover_path(vol2, NULL, 16) == 0, "cover NULL out");
}
/* ============================================================ */
int main(int argc, char **argv)
{
g_argv0 = argv[0];
char gen_buf[PATH_CAP];
const char *gen = argc > 1 ? argv[1] : find_generator(gen_buf, sizeof gen_buf);
if (!make_temp_dir()) {
perror("mkdtemp");
return 2;
}
joinp(g_lib, sizeof g_lib, g_root, "library");
joinp(g_feat, sizeof g_feat, g_root, "features");
joinp(g_data, sizeof g_data, g_root, "app/os.meow.picobrowser/data");
if (!run_generator(gen)) {
printf("generator failed: %s\n", gen);
rm_rf(g_root);
return 2;
}
if (mr_cache_init(g_data) != 0) {
printf("cache init failed: %s\n", g_data);
rm_rf(g_root);
return 2;
}
test_ext();
test_enum();
test_pathsafe();
test_meta();
test_cache();
test_bad();
test_open_auto();
test_library();
test_adjacent();
test_badge_cover();
mr_cache_shutdown();
rm_rf(g_root);
printf("cbz tests: %d passed, %d failed\n", g_pass, g_fail);
return g_fail == 0 ? 0 : 1;
}