Files
MeowOS-SDK/tuku/test/test_browse_ui_host.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

420 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_browse_ui_host.c — 书库浏览页宿主集成测试(可选,需 SDK 源码树)。
*
* 目的(M18 文件管理器模型):
* U1 行模型:目录 + 图片 + 档案册混排(目录优先+自然序),行副标题是扫描期生成的
* 类型·大小(不再有占位/后台补位),图标/右侧箭头按类型区分;
* U2 行合成零 IO:反复取可见行不触发 opendir/readdir/lstat(渲染期无文件访问);
* U3 点击语义:点目录=进入(空目录也进);点图片=从该图开始读
* (mr_reader_open_dir_at(当前目录, 图片下标));点档案册=打开档案册;
* U4 根目录锁定:根不显示顶栏返回、library_go_up 在根返回 0(app.c 退出);
* 子目录返回=回根;不存在越过浏览根的导航;
* U5 无 SD 卡容错:浏览根不可访问(不存在/不是目录)→ 友好空态「未检测到 SD 卡」,
* 不弹错误横幅;顶栏刷新/重扫可重试成功;
* U6 子目录不可读:停留原目录 + 顶部提示横幅;
* U7 书库不再碰缓存目录:CBZ 解压页缓存 mrpage_* 原样保留,且不写任何 bmp。
*
* 编译:见 test/run_tests.sh(链 SDK 源码树 + 打桩;只发布 meow_ui.a 的环境自动 SKIP)。
* ui/library/library.c 以 -DMR_LIBRARY_UI_TEST_HOOKS 编译出测试接缝;logic/library.c
* 以 -DMR_LIBRARY_TEST_HOOKS 编译出 IO 计数。
*/
#include "tuku_internal.h"
#include "logic/library.h"
#include "logic/reader/page_cache.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <dirent.h>
/* ui/library/library.c 的测试接缝(仅 -DMR_LIBRARY_UI_TEST_HOOKS 时定义) */
int library_test_load(const char *dir);
void library_test_set_root(const char *root);
const char *library_test_root(void);
const char *library_test_dir(void);
void library_test_set_dir(const char *dir);
int library_test_rows(void);
int library_test_src(int idx, meow_list_item *item);
int library_test_hint_active(void);
const char *library_test_hint(void);
int library_test_topbar_back(void);
const char *library_test_empty_text(void);
int library_test_sd_missing(void);
int library_test_retry(void);
int library_test_go_up(void);
int library_test_drag_active(void);
int library_test_has_list(void);
/* 阅读器入口打桩:只记录被打开的册路径与起始页(本测试不建 reader 页)。 */
#define MR_READER_START_RESUME (-1)
void mr_reader_open_dir_at(const char *dir, int start_index);
static char g_opened[MR_PATH_MAX];
static int g_opened_start;
static int g_opened_n;
void mr_reader_open_dir_at(const char *dir, int start_index)
{
snprintf(g_opened, sizeof g_opened, "%s", dir ? dir : "");
g_opened_start = start_index;
g_opened_n++;
}
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 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 touch_file(const char *path)
{
FILE *f = fopen(path, "wb");
if (!f) return -1;
fputs("x", f);
return fclose(f);
}
static int rm_rf(const char *path)
{
struct stat st;
if (lstat(path, &st) != 0) return -1;
if (!S_ISDIR(st.st_mode)) { chmod(path, 0700); return unlink(path); }
chmod(path, 0700);
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 void joinp(char *out, int outsz, const char *a, const char *b)
{
snprintf(out, (size_t)outsz, "%s/%s", a, b);
}
static int row_index_for(const char *name)
{
int rows = library_test_rows();
for (int i = 0; i < rows; i++) {
meow_list_item it;
if (!library_test_src(i, &it)) continue;
if (it.title && strcmp(it.title, name) == 0) return i;
}
return -1;
}
static void tap_row(const char *name)
{
int idx = row_index_for(name);
if (idx < 0) { CHECK(0, "row %s not found", name); return; }
int list_top = meow_ui_statusbar_height() + 56;
library_on_tap(120, list_top + idx * 56 + 28);
}
/* 统计缓存目录里的 bmp(缩略图/解压页)。 */
static int count_bmp(const char *dir, int *pages)
{
DIR *d = opendir(dir);
if (!d) return -1;
int thumbs = 0, pg = 0;
struct dirent *de;
while ((de = readdir(d)) != NULL) {
size_t len = strlen(de->d_name);
if (len <= 4 || strcmp(de->d_name + len - 4, ".bmp") != 0) continue;
if (mr_cache_is_page_name(de->d_name)) pg++;
else thumbs++;
}
closedir(d);
if (pages) *pages = pg;
return thumbs;
}
int main(void)
{
const char *base = getenv("TMPDIR");
if (!base || !base[0]) base = ".";
char tmpl[512];
snprintf(tmpl, sizeof tmpl, "%s/mr_browse_ui_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], p[MR_PATH_MAX];
snprintf(root, sizeof root, "%s/library", data);
const char *dirs[] = { "album", "empty", "noimg", "deep/sub", "Pictures", "Pictures/sub" };
for (unsigned i = 0; i < sizeof dirs / sizeof dirs[0]; i++) {
joinp(p, sizeof p, root, dirs[i]);
if (mkdir_p(p) != 0) { printf("SKIP: mkdir %s failed\n", p); return 0; }
}
joinp(p, sizeof p, root, "album/001.png"); touch_file(p);
joinp(p, sizeof p, root, "album/002.png"); touch_file(p);
joinp(p, sizeof p, root, "noimg/readme.txt"); touch_file(p);
joinp(p, sizeof p, root, "deep/sub/001.png"); touch_file(p);
joinp(p, sizeof p, root, "root.png"); touch_file(p);
joinp(p, sizeof p, root, "book.cbz"); touch_file(p);
/* Pictures:大小写混排 + 数字序号 + 大写扩展名 + 档案册(图片排序契约夹具)。 */
const char *pics[] = { "A.jpg", "a.jpg", "img2.png", "IMG3.JPG", "img10.png",
"page01.bmp", "page1.jpeg", "cover.cbz", "notes.txt" };
for (unsigned i = 0; i < sizeof pics / sizeof pics[0]; i++) {
joinp(p, sizeof p, root, "Pictures");
size_t dl = strlen(p);
snprintf(p + dl, sizeof p - dl, "/%s", pics[i]);
touch_file(p);
}
meow_ui_opts o = { 480, 720 };
if (meow_ui_init(&o) != 0) { printf("SKIP: meow_ui_init failed (no host backend)\n"); return 0; }
/* ---- U5:浏览根不可访问 —— 先指向不存在的根,再 create ---- */
const char *bad_root = "/no/such/tuku/m18_root";
library_test_set_root(bad_root);
CHECK(strcmp(library_test_root(), bad_root) == 0, "bad root installed");
meow_view *lib = library_view_create();
CHECK(library_test_sd_missing() == 1, "unreadable root -> SD missing state");
CHECK(library_test_rows() == 0, "empty list rows=%d", library_test_rows());
CHECK(library_test_has_list(), "startup still builds a listview");
CHECK(strcmp(library_test_dir(), bad_root) == 0, "startup dir=%s", library_test_dir());
CHECK(strstr(library_test_empty_text(), "SD") != NULL,
"friendly empty state: %s", library_test_empty_text());
CHECK(library_test_hint_active() == 0, "no error banner for missing SD");
CHECK(library_test_topbar_back() == 0, "no back arrow at storage root");
CHECK(library_test_retry() == 0 && library_test_sd_missing() == 1,
"retry while still missing stays in empty state (no crash)");
/* 根路径不是目录(普通文件)同样走 SD 空态。 */
char notadir[MR_PATH_MAX];
joinp(notadir, sizeof notadir, data, "notadir");
touch_file(notadir);
library_test_set_root(notadir);
library_test_set_dir(notadir);
CHECK(library_test_retry() == 0 && library_test_sd_missing() == 1,
"root pointing at a file -> SD missing state");
/* 插卡/挂载恢复:同一路径变成可读目录后,刷新即恢复列表。 */
library_test_set_root(root);
library_test_set_dir(root);
CHECK(library_test_retry() > 0, "retry after storage available loads rows");
CHECK(library_test_sd_missing() == 0, "SD missing state cleared");
CHECK(strcmp(library_test_dir(), root) == 0, "now browsing root");
CHECK(library_test_topbar_back() == 0, "still no back arrow at root");
meow_view_page_push(ACT_LIBRARY);
CHECK(meow_ui_activity_active() == ACT_LIBRARY, "library active");
/* ---- U1:行模型(目录优先 + 自然序 + 类型图标/箭头) ---- */
CHECK(library_test_load(root) == 1, "load fixture root");
CHECK(library_test_rows() == 7, "7 rows (got %d)", library_test_rows());
static const char *want[7] = {
"album", "deep", "empty", "noimg", "Pictures", "book.cbz", "root.png"
};
for (int i = 0; i < 7; i++) {
meow_list_item it;
CHECK(library_test_src(i, &it) == 1, "src row %d", i);
CHECK(it.title && strcmp(it.title, want[i]) == 0, "row[%d]=%s (want %s)",
i, it.title ? it.title : "(null)", want[i]);
CHECK(it.subtitle && it.subtitle[0] && strcmp(it.subtitle, "…") != 0,
"row[%d] subtitle pre-generated (%s)", i, it.subtitle ? it.subtitle : "");
if (i < 5) {
CHECK(it.icon_kind == MEOW_LIST_ICON_FOLDER && it.right == MEOW_LIST_RIGHT_ARROW,
"row[%d] is a folder row with enter arrow", i);
} else {
CHECK(it.icon_kind == MEOW_LIST_ICON_FILE && it.right == MEOW_LIST_RIGHT_NONE,
"row[%d] is a file row without child arrow", i);
}
}
int ic = row_index_for("book.cbz");
meow_list_item it;
CHECK(ic >= 0 && library_test_src(ic, &it) &&
strncmp(it.subtitle, "档案册 · ", strlen("档案册 · ")) == 0,
"archive subtitle=%s", it.subtitle ? it.subtitle : "");
int ir = row_index_for("root.png");
CHECK(ir >= 0 && library_test_src(ir, &it) &&
strncmp(it.subtitle, "图片 · ", strlen("图片 · ")) == 0,
"image subtitle=%s", it.subtitle ? it.subtitle : "");
/* ---- U2:行合成零 IO(渲染/滚动路径不访问文件系统) ---- */
mr_library_test_io_reset();
for (int pass = 0; pass < 500; pass++)
for (int i = 0; i < library_test_rows(); i++) {
meow_list_item tmp;
library_test_src(i, &tmp);
}
mr_library_io_stats io;
mr_library_test_io_get(&io);
CHECK(io.opendirs == 0 && io.readdirs == 0 && io.lstats == 0,
"row source does no IO (open=%ld rd=%ld stat=%ld)",
io.opendirs, io.readdirs, io.lstats);
/* ---- U3:点击语义 ---- */
char pics_dir[MR_PATH_MAX];
joinp(pics_dir, sizeof pics_dir, root, "Pictures");
CHECK(library_test_load(pics_dir) == 1, "entered Pictures");
CHECK(strcmp(library_test_dir(), pics_dir) == 0, "current dir=%s", library_test_dir());
/* 图片下标 = 该目录里排在该图之前的图片行数(阅读器目录册同序)。 */
const char *target = "img10.png";
int ti = row_index_for(target);
CHECK(ti >= 0, "target image row present");
int expect_idx = 0;
for (int i = 0; i < ti; i++) {
meow_list_item tmp;
if (!library_test_src(i, &tmp)) continue;
if (tmp.subtitle && strncmp(tmp.subtitle, "图片 · ", strlen("图片 · ")) == 0)
expect_idx++;
}
g_opened_n = 0;
tap_row(target);
CHECK(g_opened_n == 1 && strcmp(g_opened, pics_dir) == 0,
"tap image opens reader for current dir (%s)", g_opened);
CHECK(g_opened_start == expect_idx, "start index=%d (want %d)", g_opened_start, expect_idx);
CHECK(strcmp(library_test_dir(), pics_dir) == 0, "tap image does not navigate");
g_opened_n = 0;
tap_row("cover.cbz");
char archive[MR_PATH_MAX];
joinp(archive, sizeof archive, pics_dir, "cover.cbz");
CHECK(g_opened_n == 1 && strcmp(g_opened, archive) == 0,
"tap archive opens archive book (%s)", g_opened);
CHECK(g_opened_start == MR_READER_START_RESUME, "archive uses resume start (%d)",
g_opened_start);
/* 点目录 = 进入;空目录也进(不打开阅读器)。 */
char sub_dir[MR_PATH_MAX];
joinp(sub_dir, sizeof sub_dir, pics_dir, "sub");
g_opened_n = 0;
tap_row("sub");
CHECK(g_opened_n == 0, "tap dir does not open reader");
CHECK(strcmp(library_test_dir(), sub_dir) == 0, "entered empty sub dir (%s)",
library_test_dir());
CHECK(library_test_rows() == 0, "empty dir shows empty list");
CHECK(strcmp(library_test_empty_text(), "空文件夹") == 0,
"empty dir state: %s", library_test_empty_text());
/* ---- U4:根目录锁定 ---- */
char album_dir[MR_PATH_MAX];
joinp(album_dir, sizeof album_dir, root, "album");
CHECK(library_test_load(album_dir) == 1, "load album");
CHECK(library_test_topbar_back() == 1, "back arrow shown in subdir");
CHECK(library_test_go_up() == 1, "go_up from subdir consumed");
CHECK(strcmp(library_test_dir(), root) == 0, "back to root");
/* 根:系统返回 = 退出(返回 0),并保持列表不变。 */
CHECK(library_test_go_up() == 0, "go_up at root returns 0 (app exits)");
CHECK(strcmp(library_test_dir(), root) == 0, "still at root after exit gesture");
CHECK(library_test_topbar_back() == 0, "no back arrow at root");
/* 顶栏返回:子目录点击返回箭头 = 回根;根上该区域不响应。 */
CHECK(library_test_load(album_dir) == 1, "load album again");
library_on_tap(20, meow_ui_statusbar_height() + 28);
CHECK(strcmp(library_test_dir(), root) == 0, "topbar back returns to root");
int root_rows_before = library_test_rows();
library_on_tap(20, meow_ui_statusbar_height() + 28);
CHECK(strcmp(library_test_dir(), root) == 0 && library_test_rows() == root_rows_before,
"topbar back is inert at root");
/* 嵌套:deep/sub 两级返回。 */
tap_row("deep");
tap_row("sub");
char deep_sub[MR_PATH_MAX], deep_dir[MR_PATH_MAX];
joinp(deep_dir, sizeof deep_dir, root, "deep");
joinp(deep_sub, sizeof deep_sub, root, "deep/sub");
CHECK(strcmp(library_test_dir(), deep_sub) == 0, "entered deep/sub (%s)", library_test_dir());
CHECK(library_test_go_up() == 1 && strcmp(library_test_dir(), deep_dir) == 0, "up to deep");
CHECK(library_test_go_up() == 1 && strcmp(library_test_dir(), root) == 0, "up to root");
/* ---- U3c:左缘 24px / 屏底热区不启动列表拖拽 ---- */
int list_top = meow_ui_statusbar_height() + 56;
int row_y = list_top + 28;
library_on_touch_down(120, row_y);
CHECK(library_test_drag_active() == 1, "touch inside list starts drag");
library_on_touch_down(5, row_y);
CHECK(library_test_drag_active() == 0, "left-edge touch does not start drag");
library_on_touch_down(23, row_y);
CHECK(library_test_drag_active() == 0, "left-edge (23px) does not start drag");
library_on_touch_down(24, row_y);
CHECK(library_test_drag_active() == 1, "just outside left band starts drag");
library_on_touch_down(120, meow_ui_height() - 4);
CHECK(library_test_drag_active() == 0, "bottom hotzone does not start drag");
/* ---- U6:不可读子目录 → 停留 + 提示 ---- */
if (geteuid() != 0) {
char noread[MR_PATH_MAX];
joinp(noread, sizeof noread, root, "noread");
CHECK(mkdir(noread, 0755) == 0, "mkdir noread");
chmod(noread, 0000);
int ok = library_test_load(noread);
CHECK(ok == 0, "unreadable dir load fails");
CHECK(strcmp(library_test_dir(), root) == 0, "stays in previous dir (%s)",
library_test_dir());
CHECK(library_test_hint_active(), "hint banner shown: %s", library_test_hint());
CHECK(strstr(library_test_hint(), "无法读取") != NULL,
"hint text mentions unreadable: %s", library_test_hint());
chmod(noread, 0700);
} else {
printf(" SKIP unreadable-dir case (running as root)\n");
}
/* ---- U7:书库不碰缓存目录(CBZ 解压页缓存原样保留,不写 bmp) ---- */
CHECK(mr_cache_init(data) == 0, "page cache init");
const char *cdir = mr_cache_dir();
CHECK(cdir && cdir[0], "page cache dir: %s", cdir ? cdir : "");
if (cdir && cdir[0]) {
char page[MR_PATH_MAX + 96];
snprintf(page, sizeof page, "%s/mrpage_0123456789abcdef.bmp", cdir);
CHECK(touch_file(page) == 0, "create page cache file");
int pages_before = 0;
count_bmp(cdir, &pages_before);
CHECK(pages_before == 1, "precondition 1 page (got %d)", pages_before);
CHECK(library_test_load(root) == 1, "reload root");
for (int i = 0; i < library_test_rows(); i++) {
meow_list_item tmp;
library_test_src(i, &tmp);
}
tap_row("album");
tap_row("001.png"); /* 打开阅读器入口(打桩) */
library_test_go_up();
int pages_after = 0;
int thumbs_after = count_bmp(cdir, &pages_after);
CHECK(access(page, R_OK) == 0, "mrpage_* survives book browsing");
CHECK(pages_after == 1 && thumbs_after == 0,
"library writes no bmp to cache (pages=%d thumbs=%d)", pages_after, thumbs_after);
}
meow_view_free(lib);
meow_ui_shutdown();
rm_rf(data);
printf("%s: %s\n", g_fail ? "FAIL" : "PASS",
g_fail ? "see failures above" : "browse UI checks passed");
return g_fail ? 1 : 0;
}