- ui/library/library.c cache_prune:rebase 时丢失了 M11 c775f85 的必修保护,
恢复 `if (mr_cache_is_page_name(de->d_name)) continue;`(在 .bmp 过滤之前)。
解压页缓存与缩略图同目录,mrpage_* 归 page_cache 自己的 64 页/32MiB LRU 管理;
否则档案页被缩略图 LRU 统计并删除 → 每翻页重解压,且「touch 命中→解码」窗口内
可能被删导致解码失败。
- 回归测试(MR_LIBRARY_UI_TEST_HOOKS 接缝):test_browse_ui_host 新增 U6——
预置 1 个最旧 mrpage_*.bmp + 65 个较新缩略图,调用 library_test_cache_prune(),
断言 mrpage_* 保留且不计入 64 张额度、缩略图被裁到 64;已验证去掉修复时该用例
会失败(防止再次在 rebase 中丢失)。
- 顺手补齐/校正:
① test_browse_ui_host 的启动路径改为 create 前就把根指向不存在的目录,真实覆盖
「启动根不可读仍建空列表」(断言 has_list/rows==0/横幅/当前目录),不再只是声明;
② tuku_internal.h 的 library_at_root 注释改为「书库根(生产=MR_ROOT_DIR,测试可改根)」。
- 复验:run_tests.sh 全 PASS(73/69/90/18/108/19/85 + settings 集成 + browse UI 集成
+ zip 106 + cbz 325);run_webtoon_tests.sh 全 PASS(76/36/71/57 + 集成);
sdk/build_sdk.sh + build_tuku.sh 零警告 MIPS32r2 静态 ELF;UI host 测试 ASan/UBSan 全绿。
367 lines
15 KiB
C
367 lines
15 KiB
C
/* test_browse_ui_host.c — 书库浏览页宿主集成测试(可选,需 SDK 源码树)。
|
||
*
|
||
* 目的(M13):
|
||
* U1 列表用 src2 模式(每帧重取行):扫描后行副标题是占位「…」,后台探测完成、
|
||
* on_tick 补位后同一行立即显示页数——验证懒算结果能上屏(固定池 vsource 做不到);
|
||
* U2 行语义:书行显示 "N pages"(无进入箭头),空目录「空目录」,无图目录「无图片」;
|
||
* U3 导航:可逐级上溯父目录;文件系统 "/" 处 library_go_up 返回 0(app.c 据此退出);
|
||
* 「TF卡」回根动作回到书库根;
|
||
* U4 不可读目录:load 失败 → 停留原目录 + 顶部提示横幅;
|
||
* U5 启动根不可读:create 时仍建空列表(不是只有横幅);
|
||
* U6 缩略图 LRU 跳过解压页缓存(M11 P2-1 回归):cache_prune 只统计/删除缩略图,
|
||
* mrpage_*(page_cache 的解压页)必须保留且不计入 64 张额度。
|
||
*
|
||
* 编译:见 test/run_tests.sh(与 test_settings_lock_host 同口径:链 SDK 源码树 + 打桩;
|
||
* 只发布 meow_ui.a 的交付环境自动 SKIP)。ui/library/library.c 以
|
||
* -DMR_LIBRARY_UI_TEST_HOOKS 编译出测试接缝;本测试另打桩 mr_reader_open_dir/mr_nav_report。
|
||
*/
|
||
#include "tuku_internal.h"
|
||
#include "logic/library.h"
|
||
|
||
#include <stdio.h>
|
||
#include <stdlib.h>
|
||
#include <string.h>
|
||
#include <sys/stat.h>
|
||
#include <fcntl.h>
|
||
#include <time.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_go_root(void);
|
||
int library_test_go_up(void);
|
||
int library_test_has_list(void);
|
||
const char *library_test_cache_dir(void);
|
||
int library_test_cache_on(void);
|
||
void library_test_cache_prune(void);
|
||
|
||
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) {}
|
||
|
||
/* reader 打桩:只记录被打开的册路径(本测试不建 reader 页)。 */
|
||
static char g_opened[MR_PATH_MAX];
|
||
static int g_opened_n;
|
||
void mr_reader_open_dir(const char *path)
|
||
{
|
||
snprintf(g_opened, sizeof g_opened, "%s", path ? path : "");
|
||
g_opened_n++;
|
||
}
|
||
|
||
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 set_mtime(const char *path, time_t t)
|
||
{
|
||
struct timespec ts[2];
|
||
ts[0].tv_sec = t;
|
||
ts[0].tv_nsec = 0;
|
||
ts[1] = ts[0];
|
||
utimensat(AT_FDCWD, path, ts, 0);
|
||
}
|
||
|
||
/* 统计缓存目录里的缩略图(*.bmp 且非 mrpage_*)与解压页(mrpage_*.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 (strncmp(de->d_name, "mrpage_", 7) == 0) pg++;
|
||
else thumbs++;
|
||
}
|
||
closedir(d);
|
||
if (pages) *pages = pg;
|
||
return thumbs;
|
||
}
|
||
|
||
static void wait_ready(int ms)
|
||
{
|
||
for (int t = 0; t < ms / 10; t++) {
|
||
library_on_tick();
|
||
int ready = 1;
|
||
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.subtitle && strcmp(it.subtitle, "…") == 0) { ready = 0; break; }
|
||
}
|
||
if (ready) return;
|
||
usleep(10000);
|
||
}
|
||
}
|
||
|
||
/* U6:缩略图 LRU 必须跳过解压页缓存(M11 P2-1 回归;MR_CACHE_MAX_FILES=64)。 */
|
||
static void test_cache_prune_skips_pages(void)
|
||
{
|
||
const char *dir = library_test_cache_dir();
|
||
CHECK(library_test_cache_on() && dir && dir[0], "thumbnail cache enabled: %s",
|
||
dir ? dir : "");
|
||
if (!dir || !dir[0]) return;
|
||
|
||
/* 清空之前可能写入的 bmp(本测试独占 MEOW_DATA_DIR)。 */
|
||
DIR *d = opendir(dir);
|
||
if (d) {
|
||
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) {
|
||
char full[MR_PATH_MAX + 96];
|
||
snprintf(full, sizeof full, "%s/%s", dir, de->d_name);
|
||
unlink(full);
|
||
}
|
||
}
|
||
closedir(d);
|
||
}
|
||
|
||
char page[MR_PATH_MAX + 96], thumb[MR_PATH_MAX + 96];
|
||
snprintf(page, sizeof page, "%s/mrpage_0123456789abcdef.bmp", dir);
|
||
CHECK(touch_file(page) == 0, "create page cache file");
|
||
set_mtime(page, time(NULL) - 7200); /* 最旧:若被误删一定先删它 */
|
||
|
||
time_t base = time(NULL) - 3600;
|
||
for (int i = 0; i < 65; i++) { /* 65 张缩略图 > MR_CACHE_MAX_FILES */
|
||
snprintf(thumb, sizeof thumb, "%s/%016x_%ld_%d.bmp", dir,
|
||
(unsigned)i, (long)(base + i), i);
|
||
CHECK(touch_file(thumb) == 0, "create thumbnail %d", i);
|
||
set_mtime(thumb, base + i);
|
||
}
|
||
|
||
int pages_before = 0;
|
||
CHECK(count_bmp(dir, &pages_before) == 65, "precondition 65 thumbnails");
|
||
CHECK(pages_before == 1, "precondition 1 page (got %d)", pages_before);
|
||
|
||
library_test_cache_prune();
|
||
|
||
int pages_after = 0;
|
||
int thumbs_after = count_bmp(dir, &pages_after);
|
||
CHECK(access(page, R_OK) == 0, "mrpage_* survives thumbnail LRU");
|
||
CHECK(pages_after == 1, "mrpage_* not counted as thumbnail (got %d)", pages_after);
|
||
CHECK(thumbs_after == 64, "thumbnails trimmed to 64 (got %d)", thumbs_after);
|
||
}
|
||
|
||
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];
|
||
snprintf(root, sizeof root, "%s/library", data);
|
||
char p[MR_PATH_MAX];
|
||
const char *dirs[] = { "book", "empty", "noimg", "deep/sub", "MeowOS/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, "book/page2.png"); touch_file(p);
|
||
joinp(p, sizeof p, root, "book/page10.jpg"); 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, "MeowOS/sub/m.png"); 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/m13_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_hint_active(), "startup unreadable root shows hint: %s",
|
||
library_test_hint());
|
||
CHECK(library_test_rows() == 0, "startup empty list rows=%d", library_test_rows());
|
||
CHECK(library_test_has_list(), "startup built listview for empty list");
|
||
CHECK(strcmp(library_test_dir(), bad_root) == 0, "startup dir=%s", library_test_dir());
|
||
|
||
library_test_set_root(root);
|
||
CHECK(strcmp(library_test_root(), root) == 0, "test root installed");
|
||
meow_view_page_push(ACT_LIBRARY);
|
||
CHECK(meow_ui_activity_active() == ACT_LIBRARY, "library active");
|
||
|
||
/* ---- U1:src2 行 + 懒算补位 ---- */
|
||
CHECK(library_test_load(root) == 1, "load fixture");
|
||
CHECK(library_test_rows() == 5, "5 rows (got %d)", library_test_rows());
|
||
|
||
static const char *want[5] = { "book", "deep", "empty", "MeowOS", "noimg" };
|
||
for (int i = 0; i < 5; 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 && strcmp(it.subtitle, "…") == 0,
|
||
"row[%d] starts with placeholder (got %s)", i, it.subtitle ? it.subtitle : "");
|
||
CHECK(it.right == MEOW_LIST_RIGHT_ARROW, "row[%d] has enter arrow while pending", i);
|
||
}
|
||
|
||
wait_ready(3000);
|
||
/* U2:补位后的行语义 */
|
||
for (int i = 0; i < 5; i++) {
|
||
meow_list_item it;
|
||
library_test_src(i, &it);
|
||
CHECK(it.subtitle && strcmp(it.subtitle, "…") != 0,
|
||
"row[%d] subtitle filled (got %s)", i, it.subtitle ? it.subtitle : "");
|
||
}
|
||
int ib = row_index_for("book");
|
||
int ie = row_index_for("empty");
|
||
int in = row_index_for("noimg");
|
||
int id = row_index_for("deep");
|
||
meow_list_item it;
|
||
CHECK(ib >= 0 && library_test_src(ib, &it) && strcmp(it.subtitle, "2 pages") == 0,
|
||
"book subtitle=%s", it.subtitle ? it.subtitle : "");
|
||
CHECK(it.right == MEOW_LIST_RIGHT_NONE, "book row has no enter arrow (opens reader)");
|
||
CHECK(ie >= 0 && library_test_src(ie, &it) && strcmp(it.subtitle, "空目录") == 0,
|
||
"empty subtitle=%s", it.subtitle ? it.subtitle : "");
|
||
CHECK(in >= 0 && library_test_src(in, &it) && strcmp(it.subtitle, "无图片") == 0,
|
||
"noimg subtitle=%s", it.subtitle ? it.subtitle : "");
|
||
CHECK(id >= 0 && library_test_src(id, &it) && strcmp(it.subtitle, "无图片") == 0,
|
||
"deep (images only in subdir) is not a book: %s", it.subtitle ? it.subtitle : "");
|
||
|
||
/* ---- 行点击:书 → reader;空目录 → 进入 ---- */
|
||
int list_top = meow_ui_statusbar_height() + 56;
|
||
g_opened_n = 0;
|
||
library_on_tap(120, list_top + ib * 56 + 28);
|
||
CHECK(g_opened_n == 1 && strstr(g_opened, "/book") != NULL,
|
||
"tap book opens reader (%s)", g_opened);
|
||
CHECK(strcmp(library_test_dir(), root) == 0, "tap book does not navigate");
|
||
|
||
library_on_tap(120, list_top + ie * 56 + 28);
|
||
char empty_path[MR_PATH_MAX];
|
||
joinp(empty_path, sizeof empty_path, root, "empty");
|
||
CHECK(strcmp(library_test_dir(), empty_path) == 0,
|
||
"tap empty dir enters it (%s)", library_test_dir());
|
||
CHECK(library_test_rows() == 0, "empty dir shows empty list");
|
||
|
||
/* ---- U3:导航(逐级上溯 / 到 / 退出 / 回 TF 根) ---- */
|
||
CHECK(library_test_go_up() == 1, "go_up from empty consumed");
|
||
CHECK(strcmp(library_test_dir(), root) == 0, "go_up to fixture root");
|
||
|
||
char deep[MR_PATH_MAX];
|
||
joinp(deep, sizeof deep, root, "deep/sub");
|
||
CHECK(library_test_load(deep) == 1, "load deep/sub");
|
||
CHECK(library_test_go_up() == 1, "go_up from deep/sub");
|
||
joinp(p, sizeof p, root, "deep");
|
||
CHECK(strcmp(library_test_dir(), p) == 0, "now at deep");
|
||
|
||
/* 「TF卡」回根 */
|
||
CHECK(library_test_load(deep) == 1, "reload deep/sub");
|
||
CHECK(library_test_go_root() == 1 && strcmp(library_test_dir(), root) == 0,
|
||
"TF卡 action returns to library root");
|
||
|
||
/* 上溯到文件系统 /:go_up 在 / 返回 0(app.c 据此退出)。
|
||
* 宿主(Termux)读不了 "/":从 /usr 返回时 load("/") 失败,仍应切到 "/" 的空列表
|
||
* (否则会卡在 /usr 无法退出),下一次返回=退出 App。 */
|
||
library_test_set_dir("/usr");
|
||
CHECK(library_test_go_up() == 1, "go_up toward / consumed");
|
||
CHECK(strcmp(library_test_dir(), "/") == 0, "reached / (unreadable root fallback)");
|
||
CHECK(library_test_go_up() == 0, "go_up at / returns 0 (app exits)");
|
||
CHECK(strcmp(library_test_dir(), "/") == 0, "still at / after exit gesture");
|
||
|
||
/* ---- U4:不可读目录 → 停留 + 提示 ---- */
|
||
CHECK(library_test_load(root) == 1, "back to fixture root");
|
||
char noread[MR_PATH_MAX];
|
||
joinp(noread, sizeof noread, root, "noread");
|
||
if (geteuid() != 0) {
|
||
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");
|
||
}
|
||
|
||
test_cache_prune_skips_pages();
|
||
|
||
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;
|
||
}
|