feat(tuku): M7 条漫模式与续读——模式一键切换/每册记忆、视口±1 懒解码、滑块跳页、册间衔接

- 条漫:meow_scroll 跟手+fling,按滚动位置懒解码视口±1 页(驻留上限 4 页);
  排版用 mr_book_meta 累加适宽高度(nav.c 纯逻辑,宿主可测)
- 模式:阅读 chrome 图标一键切翻页/条漫;每册模式与 last page 经 meow_config 持久化
  (reader.mode/page.<FNV1a64(册路径)>),重进该册恢复模式与页码
- 续读:离开/翻页/滚动停止节流落盘;书库扫描给含图册副标题追加「· 续读 Pn」
- 册间衔接:末页前进→同父目录下一含图册首页,首页后退→上一册末页;无相邻册轻提示不退出
- 跳页:chrome 内 meow_slider,目标页预解码完成后落位(2.5s 超时降级提示)
- 解码:改走 M12 预算解码 meow_img_load_file_dim;超大 PNG/BMP 先 probe 预判,
  超 40MiB 原始 RGBA 跳过并显示「图片无法显示」,12MiB 以上记 WARN(如实保留残余风险)
- 测试:新增条漫排版/跳页状态/模式续读持久化/册间边界宿主测试,以及真实 SDK 源码树上的
  阅读器集成测试(模式切换/跳页落位/续读/跨册),test/run_webtoon_tests.sh 一键运行
This commit is contained in:
AskaEth committed 2026-11-04 21:49:33 +08:00
1 parent 8cbb240931
commit ff97f3cee7
14 files changed
+2539 -93

No files matched your search

+273
View File
@@ -0,0 +1,273 @@
/* test_mode_switch_host.c — 阅读器模式/续读/跳页/册间衔接的宿主集成测试(可选,需 SDK 源码树)。
*
* 只做纯逻辑测试覆盖不到的接缝:真实 SDK view/activity/widget + 真实图片解码 + 真实
* meow_scroll/meow_anim 节拍。用 40x60 BMP 当页图(stb 可解,无外部依赖),在临时目录
* 建两本相邻册,然后模拟用户操作:
* H1 进入:默认翻页模式、页码 0、控件归属 ACT_READER(顶栏在 on_enter 建);
* H2 chrome 图标一键切模式:翻页 ⇄ 条漫,切换立即写 meow_config(每册记忆);
* H3 跳页滑块:展开 → 拖到末页 → 目标页解码完成后落位(不是先白屏再跳);
* H4 离开/重进:恢复上次页 + 模式(续读);
* H5 册间衔接:条漫末页上拉 → 下一册首页;首页下拉 → 上一册末页;首册再无上一册时
* 停在原地(不退出、不崩)。
*
* 编译:test/run_webtoon_tests.sh(需 SDK 源码树;只发布 meow_ui.a 时自动 SKIP)。
*/
#include "tuku_internal.h"
#include "ui/reader/reader.h"
#include "include/meow_ui_anim.h"
#include "meow_ui_internal.h" /* MEOW_EV_TAP / MEOW_EV_SWIPE(宿主测试内部头) */
#include <dirent.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
static int g_fail;
#define CHECK(cond, ...) do { \
if (cond) { printf(" PASS\n"); } \
else { g_fail++; printf(" FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \
} while (0)
/* app.c 的导航上报在测试里不需要网络侧实现。 */
void mr_nav_report(void) {}
static int g_lib_enters;
static void lib_enter(meow_view *v, void *c) { (void)v; (void)c; g_lib_enters++; }
static void lib_leave(meow_view *v, void *c) { (void)v; (void)c; }
/* ---- 临时目录 + 最小 BMP24(stb 可解) ---- */
static void put16(unsigned char *p, unsigned v) { p[0] = (unsigned char)v; p[1] = (unsigned char)(v >> 8); }
static void put32(unsigned char *p, unsigned v)
{
p[0] = (unsigned char)v; p[1] = (unsigned char)(v >> 8);
p[2] = (unsigned char)(v >> 16); p[3] = (unsigned char)(v >> 24);
}
static int write_bmp24(const char *path, int w, int h)
{
int stride = ((w * 3 + 3) / 4) * 4;
int imgsz = stride * h;
unsigned char fh[14] = { 0 }, ih[40] = { 0 };
fh[0] = 'B'; fh[1] = 'M';
put32(fh + 2, (unsigned)(54 + imgsz));
put32(fh + 10, 54);
put32(ih + 0, 40);
put32(ih + 4, (unsigned)w);
put32(ih + 8, (unsigned)h);
put16(ih + 12, 1);
put16(ih + 14, 24);
put32(ih + 20, (unsigned)imgsz);
FILE *f = fopen(path, "wb");
if (!f) return -1;
int ok = fwrite(fh, 1, sizeof fh, f) == sizeof fh &&
fwrite(ih, 1, sizeof ih, f) == sizeof ih;
unsigned char *row = (unsigned char *)calloc(1, (size_t)stride);
if (!row) ok = 0;
for (int y = 0; ok && y < h; y++)
if (fwrite(row, 1, (size_t)stride, f) != (size_t)stride) ok = 0;
free(row);
if (fclose(f) != 0) ok = 0;
return ok ? 0 : -1;
}
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 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);
}
/* 驱动 SDK 动画节拍(宿主没有 meow_ui_run 主循环)。 */
static void pump_ms(int ms)
{
for (int i = 0; i < ms / 10; i++) {
usleep(10 * 1000);
meow_anim_tick();
}
}
/* 模拟 app.c 的点击路由:on_touch_down → on_tap(阅读页 tap 不在 view on_event 里)。 */
static int tap(int x, int y)
{
reader_on_touch_down(x, y);
reader_on_tap(x, y);
return 1;
}
static int swipe(int dir, int dist)
{
return meow_view_dispatch_event(MEOW_EV_SWIPE, dir, dist);
}
int main(void)
{
const char *base = getenv("TMPDIR");
if (!base || !base[0]) base = ".";
char tmpl[512];
snprintf(tmpl, sizeof tmpl, "%s/mr_mode_host_XXXXXX", base);
char *data = mkdtemp(tmpl);
if (!data) { printf("SKIP: mkdtemp failed\n"); return 0; }
setenv("MEOW_DATA_DIR", data, 1);
char root[MR_PATH_MAX], book1[MR_PATH_MAX], book2[MR_PATH_MAX];
snprintf(root, sizeof root, "./mr_mode_books_%ld", (long)getpid());
snprintf(book1, sizeof book1, "%s/vol1", root);
snprintf(book2, sizeof book2, "%s/vol2", root);
mkdir_p(book1);
mkdir_p(book2);
for (int b = 0; b < 2; b++) {
const char *dir = b ? book2 : book1;
for (int i = 0; i < 3; i++) {
char p[MR_PATH_MAX];
snprintf(p, sizeof p, "%s/p%d.bmp", dir, i + 1);
if (write_bmp24(p, 40, 60) != 0) { printf("SKIP: bmp write failed\n"); return 0; }
}
}
meow_ui_opts o = { 480, 720 };
if (meow_ui_init(&o) != 0) { printf("SKIP: meow_ui_init failed (no host backend)\n"); return 0; }
static const meow_view_hooks lh = { .on_enter = lib_enter, .on_leave = lib_leave };
meow_view *lib = meow_view_create(ACT_LIBRARY, MEOW_VIEW_ACTIVITY, &lh, NULL);
meow_view *reader = reader_view_create();
meow_view_page_push(ACT_LIBRARY);
/* TopAppBar 动作格(位图):右起每格 48px;动作 0=模式 / 1=跳页 / 2=fit。 */
int w = meow_ui_width();
int mode_x = w - 10 - 48 * 3 + 24;
int jump_x = w - 10 - 48 * 2 + 24;
int bar_y = 28;
int slider_y = 56 + 4 + 20;
int sx = 24, sw = w - 48;
/* ---- H1 进入:翻页模式、第 1 页 ---- */
mr_reader_open_dir(book1);
CHECK(meow_ui_activity_active() == ACT_READER, "reader active after open");
CHECK(mr_reader_ui_count() == 3, "book pages=%d", mr_reader_ui_count());
CHECK(mr_reader_ui_mode() == MR_MODE_PAGED, "default mode is paged");
CHECK(mr_reader_ui_page() == 0, "start at page 0");
pump_ms(300);
/* ---- H2 模式切换:chrome 图标一键切,立即持久化 ---- */
CHECK(tap(mode_x, bar_y) == 1, "mode icon tap consumed");
CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "switched to webtoon");
CHECK(mr_cont_get_mode(book1, MR_MODE_PAGED) == MR_MODE_WEBTOON,
"webtoon mode persisted per book");
CHECK(tap(mode_x, bar_y) == 1, "mode icon tap consumed (back)");
CHECK(mr_reader_ui_mode() == MR_MODE_PAGED, "switched back to paged");
CHECK(mr_cont_get_mode(book1, MR_MODE_PAGED) == MR_MODE_PAGED, "paged mode persisted");
CHECK(tap(mode_x, bar_y) == 1, "mode icon tap consumed (again)");
CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "webtoon again for continuity");
/* ---- H3 跳页:滑块展开 → 拖到末页 → 预解码完成后落位 ---- */
CHECK(tap(jump_x, bar_y) == 1, "jump icon tap consumed");
CHECK(mr_reader_ui_jump_open() == 1, "slider open");
reader_on_touch_down(sx + sw, slider_y);
reader_on_tap(sx + sw, slider_y);
CHECK(mr_reader_ui_jump_open() == 0, "slider closed after pick");
CHECK(mr_reader_ui_jump_pending() == 1 || mr_reader_ui_page() == 2,
"jump pending (waiting for target decode)");
pump_ms(600);
CHECK(mr_reader_ui_jump_pending() == 0, "jump landed");
CHECK(mr_reader_ui_page() == 2, "jumped to last page (got %d)", mr_reader_ui_page());
/* ---- H4 续读:离开保存 page+mode,重进恢复 ---- */
CHECK(swipe(MEOW_SWIPE_LEFT, 200) == 0, "reader leaves back-swipe to app.c");
meow_view_page_pop();
CHECK(meow_ui_activity_active() == ACT_LIBRARY, "back to library");
CHECK(mr_cont_get_page(book1, -1) == 2, "last page saved (got %d)",
mr_cont_get_page(book1, -1));
CHECK(mr_cont_get_mode(book1, MR_MODE_PAGED) == MR_MODE_WEBTOON, "mode saved");
CHECK(g_lib_enters >= 2, "library re-entered");
mr_reader_open_dir(book1);
CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "mode restored on reopen");
CHECK(mr_reader_ui_page() == 2, "page restored on reopen (got %d)", mr_reader_ui_page());
pump_ms(300);
/* ---- H5 册间衔接:条漫末页上拉 → 下一册首页 ---- */
reader_on_touch_down(240, 600);
for (int i = 1; i <= 6; i++) {
reader_on_touch_move(240, 600 - i * 40);
usleep(5 * 1000);
}
CHECK(swipe(MEOW_SWIPE_UP, 240) == 1, "webtoon up-swipe consumed");
CHECK(strcmp(mr_reader_dir(), book2) == 0, "advanced to next book (got %s)", mr_reader_dir());
CHECK(mr_reader_ui_page() == 0, "next book starts at page 0");
CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "mode carried into fresh book");
pump_ms(300);
/* 首页下拉 → 上一册末页 */
reader_on_touch_down(240, 300);
for (int i = 1; i <= 6; i++) {
reader_on_touch_move(240, 300 + i * 40);
usleep(5 * 1000);
}
CHECK(swipe(MEOW_SWIPE_DOWN, 240) == 1, "webtoon down-swipe consumed");
CHECK(strcmp(mr_reader_dir(), book1) == 0, "back to previous book (got %s)", mr_reader_dir());
CHECK(mr_reader_ui_page() == 2, "previous book lands at last page (got %d)",
mr_reader_ui_page());
pump_ms(300);
/* 跳到首页后,再往上一册:没有相邻册,停在原地并提示(不退出/不崩)。 */
CHECK(tap(jump_x, bar_y) == 1, "jump icon tap consumed (to first page)");
CHECK(mr_reader_ui_jump_open() == 1, "slider open again");
reader_on_touch_down(sx + 2, slider_y);
reader_on_tap(sx + 2, slider_y);
pump_ms(500);
CHECK(mr_reader_ui_page() == 0, "jumped to first page (got %d)", mr_reader_ui_page());
reader_on_touch_down(240, 300);
for (int i = 1; i <= 6; i++) {
reader_on_touch_move(240, 300 + i * 40);
usleep(5 * 1000);
}
CHECK(swipe(MEOW_SWIPE_DOWN, 240) == 1, "edge down-swipe consumed");
CHECK(strcmp(mr_reader_dir(), book1) == 0, "no previous book: stay put");
CHECK(meow_ui_activity_active() == ACT_READER, "no previous book: still in reader (no exit)");
/* ---- 收尾 ---- */
meow_view_page_pop();
CHECK(meow_ui_activity_active() == ACT_LIBRARY, "popped back to library");
meow_view_free(reader);
meow_view_free(lib);
meow_ui_shutdown();
rm_rf(root);
rm_rf(data);
printf("%s: %s\n", g_fail ? "FAIL" : "PASS",
g_fail ? "see failures above" : "reader mode/continuity integration checks passed");
return g_fail ? 1 : 0;
}