Files
MeowOS-SDK/tuku/test/test_cbz.c
T
AskaEth 64336f95d9 图库: 书库改文件管理器模型 + 关闭缩略图 + 根锁定/无卡容错(M18)
用户反馈四件事一起改:
- 「目录里有一张图就被当成最终目录」→ 取消「目录含图=一本书」全部链路
  (探测/页数/封面/续读角标/懒算 worker/sr2 补位),列表 = 目录 + 图片 +
  .cbz/.zip(目录优先、组内自然序)。点目录=进入(空目录也进),点图片=
  mr_reader_open_dir_at(当前目录, 图片自然序下标),点档案册=打开档案册。
- 「文件夹浏览特别卡」→ 删除缩略图线程/磁盘 bmp 缓存/LRU/解码路径与行内
  缩略图;首帧只做一次 opendir/readdir+lstat,副标题(类型·大小)扫描期
  生成一次,行合成/滚动/点击零 IO 零格式化;去掉逐行 snap(自由滚动手感)。
  2000 项目录扫描 ~2.8ms(opendir=1, lstat=2000),行合成 2.78ms/200 万次
  (宿主实测;旧实现 2000 项 entry 含 512B cover,内存 2.7MB→1.7MB)。
- 「不要访问 SD 卡上级目录」→ 浏览根锁定 MR_ROOT_DIR:删除越根的顶栏
  「上级」/library_go_parent,根隐藏顶栏返回,左缘返回到根=退出 App。
- 「没有 SD 卡不要报错」→ 根不可访问(未插卡/挂载失败/根是文件)时友好空态
  「未检测到 SD 卡」+ 顶栏刷新/重进页面重试;子目录不可读才弹提示横幅。

集成陷阱:书库图片下标与阅读器目录册共享同一套过滤器(隐藏项/图片扩展名/
普通文件/符号链接)与 mr_natural_cmp,test_browse_lazy 直接对比
mr_dir_book_init 顺序,test_open_book_host 断言「点 img10 就开 img10」。

reader 侧 M17 遗留:mr_reader_open_dir_at 在阅读页已激活时先 pop 再 push,
走完整 leave/enter(SDK 对同 view 重复 push 是 no-op 且会压重复 activity)。

回归:run_tests.sh / run_webtoon_tests.sh 全 PASS;交叉构建零警告。
2026-11-05 20:26:40 +08:00

903 lines
37 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 书库列档(M18 文件管理器模型):目录 + .cbz/.zip 同列(目录优先、组内自然序,
* bookA < box < … < Vol10.cbz);扫描期一次成行,副标题是「文件夹/档案册 · 大小」,
* 不再探测页数/封面/续读角标;坏档/无图片页档案也照常列出(点开时才由 reader 判)。
* 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();
/* 文件管理器模型:目录 + 档案册同列(目录优先、组内自然序),扫描期一次成行;
* 档案册只按扩展名成行,不探测页数/封面(打开时才由 reader 解档)。 */
mr_library lib;
memset(&lib, 0, sizeof lib);
int n = mr_library_scan(&lib, g_lib, MR_LIB_MAX_DEPTH);
CHECK(n == 8, "library rows=%d (want 2 dirs + 6 archives)", n);
static const char *want[8] = {
"bookA", "box", "broken.cbz", "emptypages.cbz",
"notabook.zip", "truncated.cbz", "vol2.cbz", "Vol10.cbz"
};
for (int i = 0; i < 8 && i < lib.count; i++) {
CHECK(strcmp(lib.items[i].name, want[i]) == 0,
"row[%d]=%s (want %s)", i, lib.items[i].name, want[i]);
}
for (int i = 0; i < 2 && i < lib.count; i++)
CHECK(lib.items[i].kind == MR_LIB_KIND_DIR &&
strcmp(lib.items[i].subtitle, "文件夹") == 0,
"%s dir row subtitle=%s", lib.items[i].name, lib.items[i].subtitle);
for (int i = 2; i < 8 && i < lib.count; i++)
CHECK(lib.items[i].kind == MR_LIB_KIND_ARCHIVE &&
strncmp(lib.items[i].subtitle, "档案册 · ", strlen("档案册 · ")) == 0,
"%s archive row subtitle=%s", lib.items[i].name, lib.items[i].subtitle);
const mr_library_entry *e = lib_find(&lib, "bookA");
CHECK(e && e->kind == MR_LIB_KIND_DIR && e->size == 0, "bookA dir entry");
e = lib_find(&lib, "vol2.cbz");
CHECK(e && e->kind == MR_LIB_KIND_ARCHIVE && e->size > 0 &&
strstr(e->path, "vol2.cbz") != NULL,
"vol2.cbz archive entry (size=%lld)", e ? e->size : -1);
/* 坏档/无图片页档案也照常列出(书库不预判,点开时 reader 才判可读性)。 */
static const char *all_arch[] = { "notabook.zip", "emptypages.cbz",
"broken.cbz", "truncated.cbz" };
for (unsigned i = 0; i < sizeof all_arch / sizeof all_arch[0]; i++) {
e = lib_find(&lib, all_arch[i]);
CHECK(e && e->kind == MR_LIB_KIND_ARCHIVE, "%s listed as archive", all_arch[i]);
}
CHECK(lib_find(&lib, ".hidden.cbz") == NULL, "hidden cbz not listed");
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);
/* 文件管理器模型:书库行只有「档案册 · 大小」,不再有「· 续读 Pn」角标
* (续读仍由 reader 打开该册时恢复,见 test_mode_switch_host)。 */
mr_library lib;
memset(&lib, 0, sizeof lib);
CHECK(mr_library_scan(&lib, g_lib, MR_LIB_MAX_DEPTH) == 8, "rescan rows");
const mr_library_entry *e = lib_find(&lib, "vol2.cbz");
CHECK(e && strstr(e->subtitle, "续读") == NULL,
"no continuation badge in file-manager rows: %s", e ? e->subtitle : "");
CHECK(e && strstr(e->subtitle, "档案册") != NULL, "archive subtitle=%s",
e ? e->subtitle : "");
e = lib_find(&lib, "Vol10.cbz");
CHECK(e && strstr(e->subtitle, "续读") == NULL, "Vol10 no badge either");
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;
}