/* test_browse_ui_host.c — 书库浏览页宿主集成测试(可选,需 SDK 源码树)。 * * 目的(M18 文件管理器模型): * U1 行模型:目录 + 图片 + 档案册混排(目录优先+自然序),行副标题是扫描期生成的 * 类型·大小(不再有占位/后台补位),图标/右侧箭头按类型区分; * U2 行合成零 IO:反复取可见行不触发 opendir/readdir/lstat(渲染期无文件访问); * U3 点击语义:点目录=进入(空目录也进);点图片=从该图开始读 * (mr_reader_open_dir_at_name(当前目录, 文件名);阅读器自己定位下标); * 点档案册=打开档案册; * 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 #include #include #include #include #include #include /* 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); void mr_reader_open_dir_at_name(const char *dir, const char *name); static char g_opened[MR_PATH_MAX]; static char g_opened_name[MR_NAME_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_name[0] = '\0'; g_opened_start = start_index; g_opened_n++; } void mr_reader_open_dir_at_name(const char *dir, const char *name) { snprintf(g_opened, sizeof g_opened, "%s", dir ? dir : ""); snprintf(g_opened_name, sizeof g_opened_name, "%s", name ? name : ""); g_opened_start = MR_READER_START_RESUME; 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()); /* 点图片:把**文件名**交给阅读器(阅读器用自己的全量目录册定位起始页; * 书库列表可能被 MR_LIB_SCAN_MAX 截断,所以不再传下标)。 */ const char *target = "img10.png"; int ti = row_index_for(target); CHECK(ti >= 0, "target image row present"); 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(strcmp(g_opened_name, target) == 0, "reader gets file name (%s)", g_opened_name); CHECK(g_opened_start == MR_READER_START_RESUME, "name-based open does not pass an index"); 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; }