Files
MeowOS-SDK/tuku/test/test_reader_core.c
T
AskaEth 23c9db73df feat(tuku): 阅读器左右翻页核心——页源契约/预解码窗口/跟手翻页/方向感知 tap
- 页源契约(logic/reader):count/meta/open/close + 目录册(文件夹即书)为第一个实现;
  meta 由纯 C 图片头探测(PNG IHDR/JPEG SOF/BMP)提供原始尺寸,条漫可先排版再取图,
  zip 册(M9/M11)只需另写 mr_book_ops,两种消费方式零改动复用
- 翻页引擎:pthread 后台解码窗口=当前±1 页(目录册 downscale 到屏幕长边),翻页只 blit
  缓存页图 + meow_anim 150ms 滑动吸附;未命中立即切页显示加载态、不在翻页路径解码;
  只 flush 内容区;decode/flip 首帧/翻页完成/进出内存全部 meow_log 打点
- 阅读页 UI:fit 三档(适宽/适高/原始,TopAppBar 动作循环,标题显示页码 x/y)+
  双击以点击点为锚放大(临时缓冲超上限自动降倍率)+ 单指拖拽/滑动步进平移;
  tap 三分区方向感知(RTL 默认:左=下一页,LTR 反之),中区切 chrome;
  左缘返回交给 app.c、屏底热区交给 SDK 回桌面,两者都不拦截
- 跟手:app.c 仅补 2 处极小路由(ACT_READER 的 down/move),reader 自行算位移;
  屏底 28px/左缘 24px 不进拖动,竖向主导放弃横滑
- 内存纪律:驻留 ≤3 张 downscale 页图;放大时窗口收缩到当前页;on_leave 停解码线程、
  释放全部页图/册索引/TopAppBar,并记录 VmRSS/MemAvailable 前后对比
- 测试/构建:新增 test_reader_core(契约边界/空册/越界/重复 open-close/首末页/头探测/
  方向语义/fit/平移夹取,108 项);run_tests.sh 与 build_tuku.sh 源列表接线;
  宿主 46+108 全 PASS,交叉构建静态 MIPS32r2 ELF、-Wall -Wextra 零警告
2026-11-04 20:58:05 +08:00

453 lines
18 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_reader_core.c — 阅读器纯逻辑宿主测试(无 SDK/硬件)。
*
* 覆盖:
* R1 页源契约:空册 / 不可读目录 / 越界 open / 重复 open-close / 首末页 / 自然序 /
* 隐藏文件与子目录排除 / 销毁后失效;
* R2 契约对两种消费方式通用:meta() 给条漫连续滚动排高度,open() 给左右翻页取图;
* R3 图片头探测:PNG/JPEG/BMP 原始尺寸(不解码);
* R4 方向语义:tap 分区 / 拖动增量(RTL 默认与 LTR)/ clamp / 提交阈值;
* R5 fit 三档 / 缩放 / 平移夹取 / 进场方向 / 档位循环;
* R6 阅读方向状态(M3 API)。
*
* 编译(在 tuku/ 下):
* gcc -Wall -Wextra -O1 -I. test/test_reader_core.c logic/library.c logic/reader.c \
* logic/reader/page_source.c logic/reader/nav.c -o test/test_reader_core
* 或直接 test/run_tests.sh。
*/
#include "logic/library.h"
#include "logic/reader.h"
#include "logic/reader/nav.h"
#include "logic/reader/page_source.h"
#include <dirent.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
static int g_pass, g_fail;
#define CHECK(cond, ...) do { \
if (cond) { g_pass++; } \
else { g_fail++; printf("FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \
} while (0)
/* ============================================================ */
/* 假解码器:meow_img* 用堆上假句柄代替,记录 open/close 次数 */
/* ============================================================ */
typedef struct fake_img { int id; } fake_img;
static int g_loads, g_frees;
static int g_last_maxdim;
static char g_last_path[MR_PATH_MAX];
static meow_img *fake_load(const char *path, void *ud, int maxdim)
{
(void)ud;
g_loads++;
g_last_maxdim = maxdim;
snprintf(g_last_path, sizeof g_last_path, "%s", path ? path : "");
fake_img *f = (fake_img *)malloc(sizeof *f);
if (!f) return NULL;
f->id = g_loads;
return (meow_img *)f;
}
static void fake_unload(meow_img *img)
{
g_frees++;
free(img);
}
/* ============================================================ */
/* 临时目录工具 */
/* ============================================================ */
static int mkdir_p(const char *path)
{
char tmp[MR_PATH_MAX];
snprintf(tmp, sizeof tmp, "%s", path);
for (size_t i = 1; i <= strlen(tmp); i++) {
if (tmp[i] == '/' || tmp[i] == '\0') {
char ch = tmp[i];
tmp[i] = '\0';
if (mkdir(tmp, 0755) != 0) {
struct stat st;
if (stat(tmp, &st) != 0 || !S_ISDIR(st.st_mode)) return -1;
}
tmp[i] = ch;
}
}
return 0;
}
static int write_bytes(const char *path, const unsigned char *buf, int len)
{
FILE *f = fopen(path, "wb");
if (!f) return -1;
int ok = fwrite(buf, 1, (size_t)len, f) == (size_t)len;
if (fclose(f) != 0) ok = 0;
return ok ? 0 : -1;
}
static int touch_file(const char *path)
{
return write_bytes(path, (const unsigned char *)"x", 1);
}
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[MR_PATH_MAX];
snprintf(full, sizeof full, "%s/%s", path, de->d_name);
rm_rf(full);
}
closedir(d);
return rmdir(path);
}
/* ---- 头文件样例(只写头,不写像素) ---- */
static int write_png(const char *path, int w, int h)
{
unsigned char b[24] = {
0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A,
0, 0, 0, 13, 'I', 'H', 'D', 'R',
(unsigned char)(w >> 24), (unsigned char)(w >> 16),
(unsigned char)(w >> 8), (unsigned char)w,
(unsigned char)(h >> 24), (unsigned char)(h >> 16),
(unsigned char)(h >> 8), (unsigned char)h,
};
return write_bytes(path, b, (int)sizeof b);
}
static int write_jpeg(const char *path, int w, int h)
{
unsigned char b[16] = {
0xFF, 0xD8, /* SOI */
0xFF, 0xC0, 0x00, 0x11, /* SOF0, len=17 */
0x08, /* precision */
(unsigned char)(h >> 8), (unsigned char)h,
(unsigned char)(w >> 8), (unsigned char)w,
0x03, 0x01, 0x11, 0x00, /* 3 分量(长度不足也无妨:只读到 w/h) */
};
return write_bytes(path, b, (int)sizeof b);
}
static int write_bmp(const char *path, int w, int h)
{
unsigned char b[26] = { 0 };
b[0] = 'B';
b[1] = 'M';
b[18] = (unsigned char)w; b[19] = (unsigned char)(w >> 8);
b[20] = (unsigned char)(w >> 16); b[21] = (unsigned char)(w >> 24);
b[22] = (unsigned char)h; b[23] = (unsigned char)(h >> 8);
b[24] = (unsigned char)(h >> 16); b[25] = (unsigned char)(h >> 24);
return write_bytes(path, b, (int)sizeof b);
}
/* ============================================================ */
/* R1/R2 页源契约 */
/* ============================================================ */
static void test_contract(void)
{
char tmpl[] = "./mr_reader_XXXXXX";
char *root = mkdtemp(tmpl);
CHECK(root != NULL, "mkdtemp");
if (!root) return;
char p[MR_PATH_MAX];
snprintf(p, sizeof p, "%s/page10.png", root); write_png(p, 600, 900);
snprintf(p, sizeof p, "%s/page2.jpg", root); write_jpeg(p, 1200, 1800);
snprintf(p, sizeof p, "%s/page1.bmp", root); write_bmp(p, 800, 1200);
snprintf(p, sizeof p, "%s/notes.txt", root); touch_file(p);
snprintf(p, sizeof p, "%s/.hidden.png", root); touch_file(p);
snprintf(p, sizeof p, "%s/sub.png", root); mkdir_p(p); /* 目录不参与成册 */
snprintf(p, sizeof p, "%s/sub/inside.png", root); touch_file(p);
mr_dir_book db;
mr_book book;
int n = mr_dir_book_init(&db, root, 640, fake_load, fake_unload, NULL);
CHECK(n == 3, "init count=%d (want 3)", n);
CHECK(db.count == 3, "db.count=%d", db.count);
mr_dir_book_bind(&book, &db);
CHECK(mr_book_valid(&book) == 1, "book valid");
CHECK(mr_book_count(&book) == 3, "book count=3");
CHECK(mr_book_count(NULL) == 0, "NULL count safe");
CHECK(mr_book_valid(NULL) == 0, "NULL invalid");
CHECK(mr_book_open(NULL, 0) == NULL, "NULL open safe");
mr_book_close(NULL, NULL); /* 全程 NULL 安全,不崩 */
mr_book_destroy(NULL);
/* 自然序 + 首末页 */
char path[MR_PATH_MAX];
mr_dir_book_path(&db, 0, path, sizeof path);
CHECK(strstr(path, "page1.bmp") != NULL, "page 0 = page1 got %s", path);
mr_dir_book_path(&db, 1, path, sizeof path);
CHECK(strstr(path, "page2.jpg") != NULL, "page 1 = page2 got %s", path);
mr_dir_book_path(&db, 2, path, sizeof path);
CHECK(strstr(path, "page10.png") != NULL, "page 2 = page10 got %s", path);
/* 越界:open/meta/path 全部安全 */
CHECK(mr_book_open(&book, -1) == NULL, "open(-1) NULL");
CHECK(mr_book_open(&book, 3) == NULL, "open(count) NULL");
CHECK(mr_book_open(&book, 9999) == NULL, "open(9999) NULL");
int mw = -1, mh = -1;
CHECK(mr_book_meta(&book, -1, &mw, &mh) == 0 && mw == 0 && mh == 0, "meta(-1) unknown");
CHECK(mr_book_meta(&book, 3, &mw, &mh) == 0, "meta(count) unknown");
mr_dir_book_path(&db, 9, path, sizeof path);
CHECK(path[0] == '\0', "path out of range empty");
/* 首末页 open + 重复 open-close(每次 open 的句柄独立,各 close 一次) */
int loads0 = g_loads, frees0 = g_frees;
meow_img *a = mr_book_open(&book, 0);
CHECK(a != NULL, "open(0) ok");
CHECK(strstr(g_last_path, "page1.bmp") != NULL, "open(0) path %s", g_last_path);
CHECK(g_last_maxdim == 640, "open passes maxdim=%d", g_last_maxdim);
meow_img *a2 = mr_book_open(&book, 0);
CHECK(a2 != NULL && a2 != a, "re-open(0) gives independent handle");
CHECK(g_loads == loads0 + 2, "two loads");
mr_book_close(&book, a);
mr_book_close(&book, a2);
CHECK(g_frees == frees0 + 2, "frees=%d", g_frees - frees0);
/* 再开再关一轮:契约允许同一页反复 open/close */
a = mr_book_open(&book, 0);
CHECK(a != NULL && g_loads == loads0 + 3, "re-open after close");
mr_book_close(&book, a);
CHECK(g_frees == frees0 + 3, "re-close frees");
meow_img *last = mr_book_open(&book, 2);
CHECK(last != NULL, "open(last) ok");
CHECK(strstr(g_last_path, "page10.png") != NULL, "open(last) path %s", g_last_path);
mr_book_close(&book, last);
/* meta:PNG/JPEG/BMP 头解析 */
CHECK(mr_book_meta(&book, 0, &mw, &mh) == 1 && mw == 800 && mh == 1200,
"meta bmp=%dx%d", mw, mh);
CHECK(mr_book_meta(&book, 1, &mw, &mh) == 1 && mw == 1200 && mh == 1800,
"meta jpeg=%dx%d", mw, mh);
CHECK(mr_book_meta(&book, 2, &mw, &mh) == 1 && mw == 600 && mh == 900,
"meta png=%dx%d", mw, mh);
/* R2:契约对条漫通用——用 meta 累加高度,按滚动位置定位页(M7 可直接复用)。 */
long y = 0, heights[3];
for (int i = 0; i < 3; i++) {
int sw = 0, sh = 0;
mr_book_meta(&book, i, &sw, &sh);
int fw = 0, fh = 0;
mr_nav_fit_size(MR_FIT_WIDTH, sw, sh, 480, 720, &fw, &fh);
heights[i] = fh;
y += fh;
}
CHECK(y == 720 + 720 + 720, "column height=%ld", y);
CHECK(heights[0] == 720 && heights[1] == 720, "meta-derived fit heights");
/* 销毁:provider 失效,二次 destroy 幂等 */
mr_book_destroy(&book);
CHECK(mr_book_valid(&book) == 0, "destroyed invalid");
CHECK(mr_book_open(&book, 0) == NULL, "open after destroy NULL");
mr_book_destroy(&book);
mr_dir_book_free(&db);
rm_rf(root);
}
static void test_empty_and_bad(void)
{
char tmpl[] = "./mr_reader_empty_XXXXXX";
char *root = mkdtemp(tmpl);
CHECK(root != NULL, "mkdtemp empty");
/* 空目录:可读但 0 页(≠ 错误) */
mr_dir_book db;
int n = mr_dir_book_init(&db, root, 720, fake_load, fake_unload, NULL);
CHECK(n == 0, "empty dir count=0 got %d", n);
CHECK(db.count == 0, "empty db count");
mr_book book;
mr_dir_book_bind(&book, &db);
CHECK(mr_book_count(&book) == 0, "empty book count 0");
CHECK(mr_book_open(&book, 0) == NULL, "empty open NULL");
CHECK(mr_book_meta(&book, 0, NULL, NULL) == 0, "empty meta NULL-safe");
mr_dir_book_free(&db);
/* 目录不存在:-1,且 db 可安全 free */
mr_dir_book bad;
CHECK(mr_dir_book_init(&bad, "/no/such/dir/tuku-reader", 720, fake_load, fake_unload,
NULL) == -1, "missing dir -> -1");
CHECK(bad.count == 0 && bad.names == NULL, "missing dir cleared");
mr_dir_book_free(&bad);
/* 参数非法 */
CHECK(mr_dir_book_init(NULL, root, 720, fake_load, fake_unload, NULL) == -1, "NULL db");
CHECK(mr_dir_book_init(&bad, root, 720, NULL, fake_unload, NULL) == -1, "NULL load");
CHECK(mr_dir_book_init(&bad, root, 720, fake_load, NULL, NULL) == -1, "NULL unload");
CHECK(mr_dir_book_init(&bad, "", 720, fake_load, fake_unload, NULL) == -1, "empty dirname");
rm_rf(root);
}
/* ============================================================ */
/* R4 方向语义 */
/* ============================================================ */
static void test_direction_nav(void)
{
const int W = 480;
CHECK(mr_nav_tap_zone(0, W) == MR_ZONE_LEFT, "zone 0");
CHECK(mr_nav_tap_zone(159, W) == MR_ZONE_LEFT, "zone 159");
CHECK(mr_nav_tap_zone(160, W) == MR_ZONE_MIDDLE, "zone 160");
CHECK(mr_nav_tap_zone(319, W) == MR_ZONE_MIDDLE, "zone 319");
CHECK(mr_nav_tap_zone(320, W) == MR_ZONE_RIGHT, "zone 320");
CHECK(mr_nav_tap_zone(479, W) == MR_ZONE_RIGHT, "zone 479");
CHECK(mr_nav_tap_zone(10, 0) == MR_ZONE_MIDDLE, "zone bad width");
/* RTL(默认):屏左=下一页,屏右=上一页 */
CHECK(mr_nav_tap_delta(1, MR_ZONE_LEFT) == 1, "RTL left=next");
CHECK(mr_nav_tap_delta(1, MR_ZONE_RIGHT) == -1, "RTL right=prev");
CHECK(mr_nav_tap_delta(1, MR_ZONE_MIDDLE) == 0, "RTL middle=chrome");
/* LTR:屏左=上一页,屏右=下一页 */
CHECK(mr_nav_tap_delta(0, MR_ZONE_LEFT) == -1, "LTR left=prev");
CHECK(mr_nav_tap_delta(0, MR_ZONE_RIGHT) == 1, "LTR right=next");
CHECK(mr_nav_tap_delta(0, MR_ZONE_MIDDLE) == 0, "LTR middle=chrome");
/* 拖动:向右(dx>0)露出左侧那页 */
CHECK(mr_nav_swipe_delta(1, 120) == 1, "RTL drag right=next");
CHECK(mr_nav_swipe_delta(1, -120) == -1, "RTL drag left=prev");
CHECK(mr_nav_swipe_delta(0, 120) == -1, "LTR drag right=prev");
CHECK(mr_nav_swipe_delta(0, -120) == 1, "LTR drag left=next");
CHECK(mr_nav_swipe_delta(1, 0) == 0, "drag 0 no-op");
CHECK(mr_nav_clamp_page(-5, 10) == 0, "clamp low");
CHECK(mr_nav_clamp_page(20, 10) == 9, "clamp high");
CHECK(mr_nav_clamp_page(3, 10) == 3, "clamp inside");
CHECK(mr_nav_clamp_page(0, 0) == -1, "clamp empty");
CHECK(mr_nav_commit_dist(480) == 120, "commit dist 480 -> 120");
CHECK(mr_nav_commit_dist(200) == 60, "commit dist 200 -> 60");
CHECK(mr_nav_commit_dist(2000) == 160, "commit dist 2000 -> 160");
CHECK(mr_nav_commit(119, 480) == 0, "119 not committed");
CHECK(mr_nav_commit(120, 480) == 1, "120 committed");
CHECK(mr_nav_commit(-120, 480) == 1, "-120 committed");
/* 进场方向:RTL 下一页从左侧进,LTR 下一页从右侧进 */
CHECK(mr_nav_slide_from(1, 1, W) == -W, "RTL next enters left");
CHECK(mr_nav_slide_from(1, -1, W) == W, "RTL prev enters right");
CHECK(mr_nav_slide_from(0, 1, W) == W, "LTR next enters right");
CHECK(mr_nav_slide_from(0, -1, W) == -W, "LTR prev enters left");
CHECK(mr_nav_slide_from(1, 0, W) == 0, "slide 0");
}
/* ============================================================ */
/* R5 fit / 缩放 / 平移 */
/* ============================================================ */
static void test_fit_zoom_pan(void)
{
int w = 0, h = 0;
/* 竖图 1000×1500,视口 480×720 */
CHECK(mr_nav_fit_size(MR_FIT_WIDTH, 1000, 1500, 480, 720, &w, &h) == 0 &&
w == 480 && h == 720, "fit width %dx%d", w, h);
CHECK(mr_nav_fit_size(MR_FIT_HEIGHT, 1000, 1500, 480, 720, &w, &h) == 0 &&
w == 480 && h == 720, "fit height %dx%d", w, h);
CHECK(mr_nav_fit_size(MR_FIT_ORIGINAL, 1000, 1500, 480, 720, &w, &h) == 0 &&
w == 1000 && h == 1500, "fit original %dx%d", w, h);
/* 横图 1500×1000:适宽缩到 480×320,适高放大到 1080×720(可左右平移) */
CHECK(mr_nav_fit_size(MR_FIT_WIDTH, 1500, 1000, 480, 720, &w, &h) == 0 &&
w == 480 && h == 320, "landscape fit width %dx%d", w, h);
CHECK(mr_nav_fit_size(MR_FIT_HEIGHT, 1500, 1000, 480, 720, &w, &h) == 0 &&
w == 1080 && h == 720, "landscape fit height %dx%d", w, h);
CHECK(mr_nav_fit_size(MR_FIT_WIDTH, 0, 100, 480, 720, &w, &h) == -1, "fit bad src");
CHECK(mr_nav_fit_size(MR_FIT_WIDTH, 100, 100, 0, 720, &w, &h) == -1, "fit bad viewport");
CHECK(mr_nav_fit_size(MR_FIT_WIDTH, 100, 100, 480, 720, NULL, &h) == -1, "fit NULL out");
CHECK(mr_nav_zoom_size(480, 720, 200, &w, &h) == 0 && w == 960 && h == 1440,
"zoom 200 %dx%d", w, h);
CHECK(mr_nav_zoom_size(480, 720, 150, &w, &h) == 0 && w == 720 && h == 1080,
"zoom 150 %dx%d", w, h);
CHECK(mr_nav_zoom_size(0, 720, 200, &w, &h) == -1, "zoom bad size");
int lx = -1, ly = -1;
mr_nav_pan_limit(960, 1440, 480, 720, &lx, &ly);
CHECK(lx == 240 && ly == 360, "pan limit %d,%d", lx, ly);
mr_nav_pan_limit(480, 720, 480, 720, &lx, &ly);
CHECK(lx == 0 && ly == 0, "no overflow no pan");
int px = 9999, py = -9999;
mr_nav_pan_clamp(960, 1440, 480, 720, &px, &py);
CHECK(px == 240 && py == -360, "pan clamp %d,%d", px, py);
px = 10; py = 20;
mr_nav_pan_clamp(480, 720, 480, 720, &px, &py);
CHECK(px == 0 && py == 0, "pan clamp no overflow");
CHECK(mr_nav_fit_next(MR_FIT_WIDTH) == MR_FIT_HEIGHT, "fit cycle w->h");
CHECK(mr_nav_fit_next(MR_FIT_HEIGHT) == MR_FIT_ORIGINAL, "fit cycle h->o");
CHECK(mr_nav_fit_next(MR_FIT_ORIGINAL) == MR_FIT_WIDTH, "fit cycle o->w");
CHECK(mr_nav_fit_next(99) == MR_FIT_WIDTH, "fit cycle bad");
}
/* ============================================================ */
/* R3 图片头探测 */
/* ============================================================ */
static void test_probe(void)
{
char tmpl[] = "./mr_reader_probe_XXXXXX";
char *root = mkdtemp(tmpl);
CHECK(root != NULL, "mkdtemp probe");
if (!root) return;
char p[MR_PATH_MAX];
int w = 0, h = 0;
snprintf(p, sizeof p, "%s/a.png", root);
write_png(p, 1234, 5678);
CHECK(mr_probe_size(p, &w, &h) == 1 && w == 1234 && h == 5678, "probe png %dx%d", w, h);
snprintf(p, sizeof p, "%s/a.jpg", root);
write_jpeg(p, 1600, 2400);
CHECK(mr_probe_size(p, &w, &h) == 1 && w == 1600 && h == 2400, "probe jpeg %dx%d", w, h);
snprintf(p, sizeof p, "%s/a.bmp", root);
write_bmp(p, 640, 480);
CHECK(mr_probe_size(p, &w, &h) == 1 && w == 640 && h == 480, "probe bmp %dx%d", w, h);
snprintf(p, sizeof p, "%s/notes.txt", root);
touch_file(p);
CHECK(mr_probe_size(p, &w, &h) == 0 && w == 0 && h == 0, "probe unknown");
CHECK(mr_probe_size("/no/such/file.png", &w, &h) == 0, "probe missing");
CHECK(mr_probe_size(NULL, &w, &h) == 0, "probe NULL");
rm_rf(root);
}
/* ============================================================ */
/* R6 阅读方向状态 */
/* ============================================================ */
static void test_direction_state(void)
{
mr_reader_set_direction(MR_DIR_RTL);
CHECK(mr_reader_get_direction() == MR_DIR_RTL, "default RTL");
mr_reader_set_direction(MR_DIR_LTR);
CHECK(mr_reader_get_direction() == MR_DIR_LTR, "switch LTR");
mr_reader_set_direction(42);
CHECK(mr_reader_get_direction() == MR_DIR_LTR, "invalid ignored");
mr_reader_set_direction(MR_DIR_RTL);
}
int main(void)
{
test_contract();
test_empty_and_bad();
test_probe();
test_direction_nav();
test_fit_zoom_pan();
test_direction_state();
printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail);
return g_fail ? 1 : 0;
}