Files
MeowOS-SDK/tuku/test/test_browse_ui_host.c
T
AskaEth 4befbfe14c 图库: 书库点图改按文件名定位(修 P1 截断错位)+ 册间衔接根锁定(M18 评审 r1)
P1-1(会静默开错图):取消「书库从列表推导图片下标」。
- 新增 mr_reader_open_dir_at_name(dir, name):阅读器打开目录册后,在**自己刚枚举出的
  全量目录册**(mr_dir_book,与翻页同一份枚举)里按 basename 定位起始页(先精确,
  再大小写不敏感兜底;找不到退回续读/首页)。书库点击只传文件名。
- 删除 mr_library_image_index(不再有跨枚举的隐式契约)。
- 截断提示文案改为「仅显示前 2000 项(图片可在阅读器中翻到)」。
- 修复前(cb02722 探针,2100 张 4 位名夹具):rows=2000/truncated=1/
  library_images=2000/reader_pages=2100/**mismatched=590**(首行 p1412 → 打开 p1411);
  评审夹具为 1995/2000。修复后:2000 个可见图片行按名定位 **0 mismatch**,
  2100 页全量可翻;26 次真实 UI 点开(首尾各 3 + 20 个确定性伪随机)逐张命中。

连带处理(评审 r1):
1. 删掉 reader_page_enter 的 c->view 死写(reopen 判定用 activity active/depth);
   顺手删掉 library_page_create 里同款死写。
2. 阅读器册间衔接根锁定:新增 mr_path_within_root + mr_book_adjacent_rooted,
   book_switch 用浏览根(默认 MR_ROOT_DIR,测试可注入)钳制相邻册;
   根外的兄弟册按「没有相邻册」处理。test_cont_adjacent A7 验证「明文 _ex 会越根、
   rooted 拒绝、根内翻册不受影响」;test_mode_switch_host H6 验证条漫末页不越根。
3. test_reader_perf 不再依赖顶栏 tap 命中时序:模式切换改走测试钩子
   mr_reader_test_toggle_mode()(与顶栏动作同一个 mode_set),负载下稳定。
4. page_cache 注释去掉「缩略图缓存/缩略图线程」过时描述(只改文字)。

回归:run_tests.sh(library 81 / nav 74 / lazy 57 / perf 25 / … / open_book 含 2100 用例)
与 run_webtoon_tests.sh(含 A7/H6)全 PASS;交叉构建零警告。
2026-11-05 20:26:40 +08:00

426 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_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 <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);
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;
}