Files
MeowOS-SDK/tuku/test/test_cbz.c
T
AskaEth c775f8558c fix(tuku): 评审 P2-1/P2-2——解压页与缩略图缓存互删自愈 + 大页溢出文档化
P2-1(必修):解压页缓存与缩略图 LRU 在 *.bmp 上相互踩踏
- ui/library 的 cache_prune 先按 page_cache 的共用判据 mr_cache_is_page_name
  跳过 mrpage_*,不再把 BMP 解压页算进缩略图额度或 unlink(含 Vol10.cbz 这类
  BMP 档不再出现「被缩略图 LRU 删掉 → 首页解码失败」);
- mr_cache_touch 命中时 stat 校验文件存在与大小,缺失/尺寸不符即移出索引并按
  未命中返回,调用方重新解压——外部清理/掉电残留/误删都能自愈,不再返回死路径。

P2-2(文档化一次性溢出):
- page_cache.h 与 make_room_locked 写明:单条目本身超过 32MiB 时只保留该条、
  其它全部淘汰,占用上限受页源单页 64MiB(MR_ZIP_PAGE_MAX_BYTES)约束;
  大页仍可读,不因预算拒绝落缓存。
- 新增 33MiB 页夹具(PNG 头 + 可压零填充,deflate 后仅数十 KB)与断言:
  大页可读、cache_bytes 允许超 32MiB、小页被淘汰、再来小页时大页按预算淘汰。

顺手:page_cache.h 注释包名 os.meow.picobrowser → os.meow.picbrowser(测试路径同步)。

测试:test_cbz 267 → 296 项全 PASS;run_tests.sh(zip 106)与
run_webtoon_tests.sh 全绿;交叉构建 sdk/tuku 零警告,产物静态 MIPS32r2 ELF。
2026-11-05 12:13:47 +08:00

921 lines
38 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 */
#define HUGE_PAGE_LEN (33 * 1024 * 1024) /* 与 make_cbz_samples.py 的 HUGE_PAGE_LEN 一致 */
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.picbrowser/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);
/* P2-1:BMP 页的缓存文件名也是 *.bmp,但必须能被缩略图 LRU 判据识别为解压页 */
char vp[MR_CACHE_FILE_MAX];
CHECK(mr_zip_book_page_path(&zb, 0, vp, (int)sizeof vp) == 0, "bmp page path");
const char *vbase = strrchr(vp, '/');
CHECK(vbase && mr_cache_is_page_name(vbase + 1) == 1,
"bmp page cache looks like a thumbnail: %s", vp);
CHECK(vbase && strstr(vbase, ".bmp") != NULL, "bmp page lost its extension: %s", vp);
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];
/* 解压页缓存与缩略图的文件名判据(P2-1):缩略图 LRU 依赖它跳过 mrpage_*。 */
CHECK(mr_cache_is_page_name("mrpage_0123456789abcdef.bmp") == 1, "page name bmp");
CHECK(mr_cache_is_page_name("mrpage_0123456789abcdef.png") == 1, "page name png");
CHECK(mr_cache_is_page_name("0123456789abcdef_1700000000_1234.bmp") == 0, "thumb name");
CHECK(mr_cache_is_page_name("thumb.bmp") == 0, "plain thumb");
CHECK(mr_cache_is_page_name("") == 0 && mr_cache_is_page_name(NULL) == 0,
"page name edges");
joinp(path, sizeof path, g_feat, "big.cbz");
/* ---- (a) 64 页 LRU ---- */
joinp(data_dir, sizeof data_dir, g_root, "c1/os.meow.picbrowser/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");
/* 假命中自愈(P2-1):外部删掉缓存文件后再取同页,应重新解压而不是返回死路径 */
unlink(p79);
CHECK(access(p79, F_OK) != 0, "unlink p79");
CHECK(mr_zip_book_page_path(&zb, 79, p, (int)sizeof p) == 0, "page79 after unlink");
CHECK(access(p79, F_OK) == 0, "page79 not re-extracted after external unlink");
CHECK(mr_cache_count() == MR_CACHE_MAX_PAGES, "count after self-heal=%d",
mr_cache_count());
mr_zip_book_free(&zb);
/* ---- (b) 32MiB 字节上限 + 启动清孤儿 ---- */
joinp(data_dir, sizeof data_dir, g_root, "c2/os.meow.picbrowser/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) 单页 > 32MiB:文档化的一次性溢出(大页可读,其它条目按预算淘汰) ---- */
joinp(data_dir, sizeof data_dir, g_root, "c3/os.meow.picbrowser/data");
mr_cache_shutdown();
CHECK(mr_cache_init(data_dir) == 0, "cache init c3");
joinp(arc, sizeof arc, g_lib, "box/box1.cbz");
mr_zip_book small;
CHECK(mr_zip_book_init(&small, arc, 720, fake_load, fake_unload, NULL) == 2,
"small book init");
char sp[MR_CACHE_FILE_MAX];
CHECK(mr_zip_book_page_path(&small, 0, sp, (int)sizeof sp) == 0, "small page");
CHECK(mr_cache_count() == 1, "small cache count=%d", mr_cache_count());
mr_zip_book_free(&small);
joinp(arc, sizeof arc, g_feat, "hugepage.cbz");
mr_zip_book huge;
CHECK(mr_zip_book_init(&huge, arc, 720, fake_load, fake_unload, NULL) == 1,
"hugepage init");
char hp[MR_CACHE_FILE_MAX];
CHECK(mr_zip_book_page_path(&huge, 0, hp, (int)sizeof hp) == 0, "huge page path");
CHECK(mr_cache_count() == 1, "overflow count=%d", mr_cache_count());
CHECK(mr_cache_bytes() == (long long)HUGE_PAGE_LEN,
"overflow bytes=%lld (want %d)", mr_cache_bytes(), HUGE_PAGE_LEN);
CHECK(mr_cache_bytes() > MR_CACHE_MAX_BYTES, "huge page should exceed the budget");
CHECK(access(sp, F_OK) != 0, "huge page should evict the small page");
CHECK(access(hp, F_OK) == 0, "huge page file missing");
CHECK(count_cache_files() == 1, "overflow left extra cache files");
mr_book hb;
mr_zip_book_bind(&hb, &huge);
int before_load = g_load_n;
meow_img *him = mr_book_open(&hb, 0);
CHECK(him != NULL && g_load_n == before_load + 1, "huge page not readable");
mr_book_close(&hb, him);
mr_book_destroy(&hb);
/* 再来一个小页:整体预算仍然生效,大页被淘汰 */
joinp(arc, sizeof arc, g_lib, "box/box1.cbz");
mr_zip_book small2;
CHECK(mr_zip_book_init(&small2, arc, 720, fake_load, fake_unload, NULL) == 2,
"small2 init");
CHECK(mr_zip_book_page_path(&small2, 0, sp, (int)sizeof sp) == 0, "small2 page");
CHECK(mr_cache_count() == 1 && mr_cache_bytes() < MR_CACHE_MAX_BYTES,
"budget not restored: count=%d bytes=%lld", mr_cache_count(),
mr_cache_bytes());
CHECK(access(hp, F_OK) != 0, "huge page should be evicted by the budget");
mr_zip_book_free(&small2);
/* ---- (d) 缓存不可用时优雅失败(不崩、不写文件) ---- */
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.picbrowser/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.picbrowser/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;
}