From 4befbfe14c6afac066c4975a6a91036f5e52c107 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 20:04:41 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BE=E5=BA=93:=20=E4=B9=A6=E5=BA=93?= =?UTF-8?q?=E7=82=B9=E5=9B=BE=E6=94=B9=E6=8C=89=E6=96=87=E4=BB=B6=E5=90=8D?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D=EF=BC=88=E4=BF=AE=20P1=20=E6=88=AA=E6=96=AD?= =?UTF-8?q?=E9=94=99=E4=BD=8D=EF=BC=89+=20=E5=86=8C=E9=97=B4=E8=A1=94?= =?UTF-8?q?=E6=8E=A5=E6=A0=B9=E9=94=81=E5=AE=9A=EF=BC=88M18=20=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=20r1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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;交叉构建零警告。 --- tuku/README.md | 4 +- tuku/logic/library.c | 41 +++++++--- tuku/logic/library.h | 17 +++-- tuku/logic/reader/page_cache.c | 2 +- tuku/logic/reader/page_cache.h | 13 ++-- tuku/test/run_tests.sh | 4 +- tuku/test/test_browse_lazy.c | 121 +++++++++++++++++++++++------- tuku/test/test_browse_perf.c | 2 - tuku/test/test_browse_ui_host.c | 28 ++++--- tuku/test/test_cont_adjacent.c | 82 +++++++++++++++++++- tuku/test/test_library.c | 41 +++++----- tuku/test/test_mode_switch_host.c | 32 +++++++- tuku/test/test_open_book_host.c | 113 ++++++++++++++++++++++++++++ tuku/test/test_reader_perf.c | 18 +++-- tuku/ui/library/library.c | 24 ++++-- tuku/ui/reader/reader.c | 80 +++++++++++++++++--- tuku/ui/reader/reader.h | 13 ++++ 17 files changed, 523 insertions(+), 112 deletions(-) diff --git a/tuku/README.md b/tuku/README.md index e3dc6a2..6cd3a95 100644 --- a/tuku/README.md +++ b/tuku/README.md @@ -11,8 +11,10 @@ - 列表 = 当前目录一层的**子目录 + 受支持图片(png/jpg/jpeg/bmp)+ 档案册(.cbz/.zip)**; 目录优先,组内自然序(大小写不敏感,同键按原始字节兜底,`A.jpg`/`a.jpg` 不抖动)。 -- 点目录 = 进入(空目录也进);点图片 = 从该图开始读(下标与阅读器目录册同一套过滤器/比较器); +- 点目录 = 进入(空目录也进);点图片 = 从该图开始读(按**文件名**交给阅读器, + 由阅读器的全量目录册定位起始页——列表被 `MR_LIB_SCAN_MAX` 截断时也不会点错图); 点档案册 = 打开档案册(第 1 页/续读)。 +- 单目录超过 2000 项时列表只显示前 2000 项并给出「已截断」提示行;图片仍可进入阅读器翻到。 - 缩略图功能已关闭:行内只有类型图标 + 文件名 + 类型·大小;首帧只做单层 `opendir/readdir + lstat`,滚动/点击路径不解码、不访问文件、不重排字符串。 - 浏览根锁定 TF 卡根(`/usr/data/mnt/sd_0`):根目录不显示返回箭头,左缘返回 = 退出 App; diff --git a/tuku/logic/library.c b/tuku/logic/library.c index be142d7..a8582fc 100644 --- a/tuku/logic/library.c +++ b/tuku/logic/library.c @@ -257,13 +257,16 @@ int mr_library_scan(mr_library *lib, const char *dir, int max_depth) int mr_library_row_at(const mr_library *lib, int idx, mr_library_row *out) { - static char trunc_sub[48]; + static char trunc_sub[96]; static int trunc_ready; if (!lib || !out) return 0; if (idx == lib->count && lib->truncated) { if (!trunc_ready) { - snprintf(trunc_sub, sizeof trunc_sub, "仅显示前 %d 项", MR_LIB_SCAN_MAX); + /* 截断只是列表显示上限:图片仍能从任一可见图片进入阅读器后翻到, + * 文案不要让人误以为「没有更多图片」。 */ + snprintf(trunc_sub, sizeof trunc_sub, + "仅显示前 %d 项(图片可在阅读器中翻到)", MR_LIB_SCAN_MAX); trunc_ready = 1; } memset(out, 0, sizeof *out); @@ -284,14 +287,15 @@ int mr_library_row_at(const mr_library *lib, int idx, mr_library_row *out) return 1; } -int mr_library_image_index(const mr_library *lib, int idx) +int mr_path_within_root(const char *path, const char *root) { - if (!lib || idx < 0 || idx >= lib->count) return -1; - if (lib->items[idx].kind != MR_LIB_KIND_IMAGE) return -1; - int n = 0; - for (int i = 0; i < idx; i++) - if (lib->items[i].kind == MR_LIB_KIND_IMAGE) n++; - return n; + if (!path || !path[0] || !root || !root[0]) return 0; + size_t rl = strlen(root); + while (rl > 1 && root[rl - 1] == '/') rl--; + if (rl == 1 && root[0] == '/') return path[0] == '/'; + if (strncmp(path, root, rl) != 0) return 0; + char c = path[rl]; + return c == '\0' || c == '/'; } int mr_path_parent(const char *path, char *out, int outsz) @@ -433,6 +437,25 @@ int mr_book_adjacent_ex(const char *dir, int delta, char *out, int outsz, return rc; } +int mr_book_adjacent_rooted(const char *dir, int delta, const char *root, + mr_lib_archive_fn archive_pages, void *ud, + char *out, int outsz) +{ + if (!out || outsz <= 0) return -1; + out[0] = '\0'; + if (!dir || !dir[0] || !root || !root[0]) return -1; + /* 只允许在浏览根内翻册:当前册或相邻册越根一律当「没有相邻册」。 */ + if (!mr_path_within_root(dir, root)) return 0; + + int r = mr_book_adjacent_ex(dir, delta, out, outsz, archive_pages, ud); + if (r != 1) return r; + if (!mr_path_within_root(out, root)) { + out[0] = '\0'; + return 0; + } + return 1; +} + int mr_book_adjacent(const char *dir, int delta, char *out, int outsz) { return mr_book_adjacent_ex(dir, delta, out, outsz, NULL, NULL); diff --git a/tuku/logic/library.h b/tuku/logic/library.h index a4fd7d0..b51966f 100644 --- a/tuku/logic/library.h +++ b/tuku/logic/library.h @@ -86,12 +86,9 @@ int mr_path_parent(const char *path, char *out, int outsz); * 返回的 title/subtitle 指向 lib 内部或函数内静态缓冲(截断提示行),调用方不要长期持有。 */ int mr_library_row_at(const mr_library *lib, int idx, mr_library_row *out); -/* 图片行在该目录图片自然序中的下标(0 基);idx 不是图片行/越界返回 -1。 - * - * ⚠️ 这是书库→阅读器的契约:阅读器的目录册(logic/reader/page_source.c)只枚举 - * 「非隐藏 + mr_path_is_image + 普通文件」并按 mr_natural_cmp 排序。书库扫描用的是 - * 同一套过滤器与比较器,因此本函数数出的下标就是 mr_reader_open_dir_at() 的起始页。 */ -int mr_library_image_index(const mr_library *lib, int idx); +/* path 是否落在浏览根内(等于 root 或以 "root/" 开头)。1=在内;0=越界/参数非法。 + * 根锁定用:书库导航与阅读器「相邻册」都不得越过 SD 卡根。 */ +int mr_path_within_root(const char *path, const char *root); /* 目录是否**直接**含受支持图片。1=有;0=没有/不可读。 * 仅用于阅读器的「相邻册」判定,不再是书库行语义。 */ @@ -111,6 +108,14 @@ typedef int (*mr_lib_archive_fn)(const char *path, void *ud); int mr_book_adjacent_ex(const char *dir, int delta, char *out, int outsz, mr_lib_archive_fn archive_pages, void *ud); +/* 册间衔接(根锁定版):在 mr_book_adjacent_ex 之上拒绝浏览根之外的相邻册。 + * dir 必须在 root 内;找到的相邻册也必须在 root 内,否则按「没有相邻册」返回 0。 + * 1=找到并写入 out;0=没有相邻册或越根被拒(out 为空串);-1=参数非法。 + * 阅读器用它保证「读到末页翻册」不会跳到 SD 卡根之外(例如挂载点以上的其它目录)。 */ +int mr_book_adjacent_rooted(const char *dir, int delta, const char *root, + mr_lib_archive_fn archive_pages, void *ud, + char *out, int outsz); + #ifdef MR_LIBRARY_TEST_HOOKS /* 宿主测试用 IO 计数(scan/dir_has_images 的 opendir/readdir/lstat 次数;线程安全不保证)。 */ typedef struct mr_library_io_stats { diff --git a/tuku/logic/reader/page_cache.c b/tuku/logic/reader/page_cache.c index 4cf403b..9c813ce 100644 --- a/tuku/logic/reader/page_cache.c +++ b/tuku/logic/reader/page_cache.c @@ -323,7 +323,7 @@ int mr_cache_store(const char *path, const void *data, size_t len) int i = idx_find_locked(path); if (i >= 0) idx_remove_locked(i); make_room_locked((long long)len); - pthread_mutex_unlock(&g_lock); /* 写盘不持锁:大页不阻塞缩略图线程 */ + pthread_mutex_unlock(&g_lock); /* 写盘不持锁:大页不阻塞并发读者 */ FILE *f = fopen(tmp, "wb"); int ok = f && fwrite(data, 1, len, f) == len; diff --git a/tuku/logic/reader/page_cache.h b/tuku/logic/reader/page_cache.h index e0c4bda..96e5171 100644 --- a/tuku/logic/reader/page_cache.h +++ b/tuku/logic/reader/page_cache.h @@ -5,21 +5,20 @@ * 解码回调按路径解码——两种阅读模式(翻页/条漫)与目录册走完全相同的代码路径。 * * 目录与上限(纪律): - * - 目录 = <数据目录>/cache(数据目录形如 .../MeowData/os.meow.picbrowser/data, - * 与缩略图缓存同目录;文件名统一前缀 mrpage_,与缩略图 *.bmp 互不影响); + * - 目录 = <数据目录>/cache(数据目录形如 .../MeowData/os.meow.picbrowser/data); + * 文件名统一前缀 mrpage_,与其它可能落在这目录的临时文件互不影响; * - 上限 = 64 页 或 32MiB,先到者触发 LRU 淘汰(按最近使用计数); * - **单条目溢出**:某页解压后本身就超过 32MiB 时允许一次性溢出——只保留该条、 * 其它条目全部淘汰,占用上限受页源单页上限 64MiB(MR_ZIP_PAGE_MAX_BYTES)约束。 * 这样超大页仍可阅读,不会因为预算拒绝落缓存而变成永久空白页; - * - mr_cache_init() 首次成功时清掉上一进程遗留的 mrpage_*(孤儿页) - * (注意:缩略图 LRU 也必须在同一目录里跳过 mrpage_*,否则会互删); + * - mr_cache_init() 首次成功时清掉上一进程遗留的 mrpage_*(孤儿页); * - 写入走 ".part" + rename,进程崩溃不会留下半个缓存文件。 * * 键 = 档案路径 + 档案 mtime/size + 条目名 + 页下标:档案被替换(mtime/size 变)或 * 同名条目重复出现时不会命中旧内容。文件名保留图片扩展名(解码入口按扩展名区分 * JPEG 的「解码期缩解」路径)。 * - * 线程:内部互斥量;可在解码线程/缩略图线程/主线程并发调用。 + * 线程:内部互斥量;可在解码线程/主线程并发调用。 * 纯 C:宿主测试可直接编译(见 tuku/test/test_cbz.c)。 */ #ifndef MR_PAGE_CACHE_H @@ -42,8 +41,8 @@ extern "C" { * 成功 0;参数非法/缓冲不足 -1。 */ int mr_cache_dir_from_data(const char *data_dir, char *out, int outsz); -/* 文件名是否属于解压页缓存(mrpage_ 前缀)。缩略图 LRU 与启动清孤儿共用这一判据, - * 保证两套缓存同目录时互不误删(P2-1)。1=解压页缓存。 */ +/* 文件名是否属于解压页缓存(mrpage_ 前缀)。启动清孤儿用这一判据, + * 保证同目录的其它文件不被误删。1=解压页缓存。 */ int mr_cache_is_page_name(const char *name); /* 建立/切换缓存目录并清孤儿。0=可用;-1=不可用(之后 store/touch 均失败)。 diff --git a/tuku/test/run_tests.sh b/tuku/test/run_tests.sh index dbdde77..2c35f8a 100755 --- a/tuku/test/run_tests.sh +++ b/tuku/test/run_tests.sh @@ -1,8 +1,8 @@ #!/bin/bash # run_tests.sh — 宿主 gcc 构建并运行图库测试(不链 SDK 静态库、不需要设备)。 -# - test_library:单层目录扫描(目录+图片+档案册)/自然排序/图片下标/父目录/阅读方向 +# - test_library:单层目录扫描(目录+图片+档案册)/自然排序/父目录/根前缀判据/阅读方向 # - test_browse_nav:访问放开(系统/空/无图目录可进入)、图片与档案册成行、截断、不可读 -# - test_browse_lazy:行合成零 IO/副标题预生成/图片下标 == 阅读器目录册下标(集成陷阱) +# - test_browse_lazy:行合成零 IO/副标题预生成/书库行与阅读器目录册同序(按名定位契约) # - test_browse_perf:500 目录×8 层嵌套 + 2000 项大目录,断言单层扫描与行合成成本 # - test_settings_reader_persist:阅读方向 meow_config 持久化(默认/落盘/重启恢复/ # 坏值/非法值/只读一次/写失败只告警) diff --git a/tuku/test/test_browse_lazy.c b/tuku/test/test_browse_lazy.c index 731048a..3441527 100644 --- a/tuku/test/test_browse_lazy.c +++ b/tuku/test/test_browse_lazy.c @@ -1,12 +1,15 @@ -/* test_browse_lazy.c — 浏览行模型 / 零 IO 行合成 / 图片下标一致性(纯 C,无 SDK)。 +/* test_browse_lazy.c — 浏览行模型 / 零 IO 行合成 / 书库行与阅读器目录册同序(纯 C,无 SDK)。 * * 文件名沿用历史(旧的「懒判定」已随 M18 文件管理器模型删除);本测试现在覆盖: * L1 行合成 O(1):mr_library_row_at 纯内存(IO 计数不变)、截断提示行; - * L2 **书库→阅读器下标记号契约**:mr_library_image_index 数出的下标 == 阅读器目录册 - * (logic/reader/page_source.c: mr_dir_book_init)里同一张图的下标。 + * L2 **书库行与阅读器目录册同源**:书库图片行的名称序列 == 阅读器目录册 + * (logic/reader/page_source.c: mr_dir_book_init)的页名序列(同过滤器/同比较器)。 + * 点图现在由阅读器按文件名自己定位下标,书库不再推导下标;本用例守住「两边枚举一致」。 * 夹具含大小写混排(A.jpg/a.jpg)、数字序号(img2/img10)、大写扩展名(IMG3.JPG)、 * 前导零(page01/page1)、夹在图片之间的档案册与普通文件; - * L3 扫描期副标题:每行都有内容(不再是占位「…」,渲染期不做格式化)。 + * L3 扫描期副标题:每行都有内容(不再是占位「…」,渲染期不做格式化); + * L4 截断安全(评审 P1-1):2100 张图(默认 cap=2000)时,2000 个可见图片行按文件名 + * 都能定位到同一文件(0 mismatch),被截掉的图仍在阅读器页表里可翻到。 * * 编译(在 tuku/ 下): * gcc -Wall -Wextra -O1 -I. -I../sdk -DMR_LIBRARY_TEST_HOOKS \ @@ -137,8 +140,8 @@ static void test_rows_zero_io(const char *root) mr_library_free(&lib); } -/* ---- L2:书库图片下标 == 阅读器目录册下标 ---- */ -static void test_image_index_matches_reader(const char *dir) +/* ---- L2:书库图片行名称序列 == 阅读器目录册页名序列 ---- */ +static void test_library_rows_match_reader(const char *dir) { mr_library lib; memset(&lib, 0, sizeof lib); @@ -149,25 +152,15 @@ static void test_image_index_matches_reader(const char *dir) int pages = mr_dir_book_init(&db, dir, 720, fake_load, fake_unload, NULL); CHECK(pages > 0, "reader dir book pages=%d", pages); - /* 书库里的图片行按出现顺序,其下标必须依次是 0..n-1(无空洞/无重排)。 */ - int expect = 0; - for (int i = 0; i < lib.count; i++) { - if (lib.items[i].kind != MR_LIB_KIND_IMAGE) continue; - int idx = mr_library_image_index(&lib, i); - CHECK(idx == expect, "image row %s index=%d (want %d)", - lib.items[i].name, idx, expect); - expect++; - } - CHECK(expect == pages, "library images=%d vs reader pages=%d", expect, pages); - if (expect != pages) { - mr_library_free(&lib); - mr_dir_book_free(&db); - return; - } + int images = 0; + for (int i = 0; i < lib.count; i++) + if (lib.items[i].kind == MR_LIB_KIND_IMAGE) images++; + CHECK(images == pages, "library images=%d vs reader pages=%d", images, pages); - /* 逐张对齐:书库第 k 张图 == 阅读器目录册第 k 页(下标即起始页)。 */ + /* 逐张对齐:书库第 k 张图 == 阅读器目录册第 k 页(同过滤器/同自然序)。 + * 点图时阅读器按文件名重新定位下标,所以「名字集合 + 顺序」一致就是契约。 */ int k = 0; - for (int i = 0; i < lib.count; i++) { + for (int i = 0; i < lib.count && k < pages; i++) { if (lib.items[i].kind != MR_LIB_KIND_IMAGE) continue; char page_path[MR_PATH_MAX]; mr_dir_book_path(&db, k, page_path, (int)sizeof page_path); @@ -176,17 +169,90 @@ static void test_image_index_matches_reader(const char *dir) k++; } - /* 档案册/目录不占图片下标。 */ + /* 目录/档案册不是页:它们的名字不能出现在阅读器页名里。 */ for (int i = 0; i < lib.count; i++) { if (lib.items[i].kind == MR_LIB_KIND_IMAGE) continue; - CHECK(mr_library_image_index(&lib, i) == -1, - "non-image %s has no image index", lib.items[i].name); + int found = 0; + for (int j = 0; j < pages; j++) + if (db.names[j] && strcmp(db.names[j], lib.items[i].name) == 0) found = 1; + CHECK(found == 0, "non-image %s is not a reader page", lib.items[i].name); } mr_dir_book_free(&db); mr_library_free(&lib); } +/* ---- L4:2100 张图(默认 cap)——列表截断但按名定位 2000/2000 不错位 ---- */ +static void test_cap_name_lookup(const char *root) +{ + char big[MR_PATH_MAX], p[MR_PATH_MAX]; + joinp(big, sizeof big, root, "big"); + CHECK(mkdir_p(big) == 0, "mkdir big"); + for (int i = 1; i <= 2100; i++) { + snprintf(p, sizeof p, "%s/p%04d.jpg", big, i); + if (touch_file(p) != 0) { CHECK(0, "big image %s", p); return; } + } + + mr_library lib; + memset(&lib, 0, sizeof lib); + int n = mr_library_scan(&lib, big, MR_LIB_MAX_DEPTH); + CHECK(n == MR_LIB_SCAN_MAX, "big dir lists cap rows (%d)", n); + CHECK(lib.truncated == 1 && lib.count == MR_LIB_SCAN_MAX, "big dir truncated at cap"); + + mr_dir_book db; + memset(&db, 0, sizeof db); + CHECK(mr_dir_book_init(&db, big, 720, fake_load, fake_unload, NULL) == 2100, + "reader enumerates all 2100 pages"); + + /* 书库不再推导下标;点击传文件名。这里验证 2000 个可见图片行按名定位 + * 都能命中**同一个文件**(0 mismatch),被截掉的 100 张也仍在页表里。 */ + int checked = 0, mismatch = 0; + for (int i = 0; i < lib.count; i++) { + if (lib.items[i].kind != MR_LIB_KIND_IMAGE) continue; + checked++; + int idx = -1; + for (int j = 0; j < db.count; j++) + if (db.names[j] && strcmp(db.names[j], lib.items[i].name) == 0) { idx = j; break; } + char page[MR_PATH_MAX]; + mr_dir_book_path(&db, idx, page, (int)sizeof page); + if (idx < 0 || strcmp(page, lib.items[i].path) != 0) mismatch++; + } + CHECK(checked == MR_LIB_SCAN_MAX, "checked %d library image rows", checked); + CHECK(mismatch == 0, "name lookup mismatch=%d (want 0)", mismatch); + + /* 被截掉的图(列表外的那 100 张)在阅读器页表里仍存在(可翻到)。 + * 截断发生在 readdir 顺序(非自然序),不假定是哪 100 张,按集合差验证。 */ + int missing = 0, missing_idx = -1; + char missing_name[MR_NAME_MAX] = ""; + for (int j = 0; j < db.count; j++) { + int in_list = 0; + for (int i = 0; i < lib.count; i++) + if (strcmp(lib.items[i].name, db.names[j]) == 0) { in_list = 1; break; } + if (!in_list) { + if (missing == 0) { + missing_idx = j; + snprintf(missing_name, sizeof missing_name, "%s", db.names[j]); + } + missing++; + } + } + CHECK(missing == 2100 - MR_LIB_SCAN_MAX, "truncated-out images=%d (want 100)", missing); + char want_path[MR_PATH_MAX], page[MR_PATH_MAX]; + snprintf(want_path, sizeof want_path, "%s/%s", big, missing_name); + mr_dir_book_path(&db, missing_idx, page, (int)sizeof page); + CHECK(missing_idx >= 0 && strcmp(page, want_path) == 0, + "truncated-out %s is still reader page %d", missing_name, missing_idx); + + mr_library_row row; + CHECK(mr_library_row_at(&lib, lib.count, &row) == 1 && row.disabled == 1 && + strstr(row.subtitle, "2000") && strstr(row.subtitle, "阅读器"), + "truncation row keeps images discoverable: %s", + row.subtitle ? row.subtitle : ""); + + mr_dir_book_free(&db); + mr_library_free(&lib); +} + int main(void) { char tmpl[] = "./mr_rows_XXXXXX"; @@ -219,7 +285,8 @@ int main(void) test_rows_zero_io(root); joinp(p, sizeof p, root, "mixed"); - test_image_index_matches_reader(p); + test_library_rows_match_reader(p); + test_cap_name_lookup(root); rm_rf(root); printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); diff --git a/tuku/test/test_browse_perf.c b/tuku/test/test_browse_perf.c index 6c52e6b..25236a3 100644 --- a/tuku/test/test_browse_perf.c +++ b/tuku/test/test_browse_perf.c @@ -165,8 +165,6 @@ int main(void) CHECK(strcmp(biglib.items[PERF_BIG - 1].name, "img1999.png") == 0, "last=%s", biglib.items[PERF_BIG - 1].name); CHECK(biglib.items[PERF_BIG - 1].kind == MR_LIB_KIND_IMAGE, "last is image"); - CHECK(mr_library_image_index(&biglib, PERF_BIG - 1) == PERF_BIG - 1, - "image index at end"); } printf(" big dir scan: %lldus, %d rows, opendir=%ld lstat=%ld\n", big_us, bn, st.opendirs, st.lstats); diff --git a/tuku/test/test_browse_ui_host.c b/tuku/test/test_browse_ui_host.c index 57f64e4..cf56ba6 100644 --- a/tuku/test/test_browse_ui_host.c +++ b/tuku/test/test_browse_ui_host.c @@ -5,7 +5,8 @@ * 类型·大小(不再有占位/后台补位),图标/右侧箭头按类型区分; * U2 行合成零 IO:反复取可见行不触发 opendir/readdir/lstat(渲染期无文件访问); * U3 点击语义:点目录=进入(空目录也进);点图片=从该图开始读 - * (mr_reader_open_dir_at(当前目录, 图片下标));点档案册=打开档案册; + * (mr_reader_open_dir_at_name(当前目录, 文件名);阅读器自己定位下标); + * 点档案册=打开档案册; * U4 根目录锁定:根不显示顶栏返回、library_go_up 在根返回 0(app.c 退出); * 子目录返回=回根;不存在越过浏览根的导航; * U5 无 SD 卡容错:浏览根不可访问(不存在/不是目录)→ 友好空态「未检测到 SD 卡」, @@ -47,18 +48,28 @@ int library_test_go_up(void); int library_test_drag_active(void); int library_test_has_list(void); -/* 阅读器入口打桩:只记录被打开的册路径与起始页(本测试不建 reader 页)。 */ +/* 阅读器入口打桩:记录被打开的册路径 / 起始页 / 按名定位的文件名(本测试不建 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 { \ @@ -279,22 +290,17 @@ int main(void) 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"); - 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(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; diff --git a/tuku/test/test_cont_adjacent.c b/tuku/test/test_cont_adjacent.c index 170c7d5..cf2392a 100644 --- a/tuku/test/test_cont_adjacent.c +++ b/tuku/test/test_cont_adjacent.c @@ -7,7 +7,9 @@ * A4 排除:隐藏/系统目录、符号链接、普通文件不参与兄弟册; * A5 参数:delta 非 ±1、NULL/小缓冲、不存在的目录; * A6 书库行模型(M18):目录+图片+档案册同列、目录优先;副标题扫描期生成, - * 不再有「· 续读 Pn」角标(角标随「目录自动当书」链路删除,续读仍由 reader 恢复)。 + * 不再有「· 续读 Pn」角标(角标随「目录自动当书」链路删除,续读仍由 reader 恢复); + * A7 册间衔接根锁定(M18 评审 P2-1):相邻册越过浏览根时按「没有相邻册」处理, + * 根内正常翻册不受影响(mr_book_adjacent_rooted)。 * * 编译(在 tuku/ 下): * gcc -Wall -Wextra -O1 -I. test/test_cont_adjacent.c logic/library.c logic/reader.c \ @@ -160,7 +162,7 @@ static void test_adjacent(const char *root) } /* A6 文件管理器行模型:扫描即出最终行(无占位/无后台补位/无续读角标); - * 目录优先;当前层的图片行带图片下标(点图 → reader 起始页)。 */ + * 目录优先;图片/档案册按类型成行。 */ static void test_library_rows(const char *root) { mr_test_cfg_clear(); @@ -191,7 +193,6 @@ static void test_library_rows(const char *root) if (strcmp(e->name, "loose.png") == 0) { seen_img = 1; CHECK(e->kind == MR_LIB_KIND_IMAGE, "loose.png is an image row"); - CHECK(mr_library_image_index(&lib, i) == 0, "loose.png is image #0"); } } CHECK(seen1 == 1, "vol1 listed"); @@ -199,6 +200,80 @@ static void test_library_rows(const char *root) mr_library_free(&lib); } +/* A7 册间衔接的根锁定:相邻册必须在浏览根内,否则按「没有相邻册」处理。 + * 夹具:base/{aaa_outside, root}/{...};root 自己也是「册」(直接含图), + * 明文 _ex 会给出 root 的兄弟 aaa_outside,rooted 必须拒绝(不越 SD 卡根)。 */ +static void test_rooted_adjacent(void) +{ + char tmpl[] = "./mr_rooted_XXXXXX"; + char *base = mkdtemp(tmpl); + CHECK(base != NULL, "mkdtemp rooted fixture"); + if (!base) return; + + char root[MR_PATH_MAX], outside[MR_PATH_MAX], in1[MR_PATH_MAX], in2[MR_PATH_MAX]; + joinp(root, sizeof root, base, "root"); + joinp(outside, sizeof outside, base, "aaa_outside"); + joinp(in1, sizeof in1, root, "aaa_inside1"); + joinp(in2, sizeof in2, root, "aaa_inside2"); + CHECK(mkdir_p(outside) == 0 && mkdir_p(in1) == 0 && mkdir_p(in2) == 0, + "mkdir rooted fixture"); + + char p[MR_PATH_MAX]; + joinp(p, sizeof p, root, "000.png"); CHECK(touch_file(p) == 0, "root image"); + joinp(p, sizeof p, outside, "001.png"); CHECK(touch_file(p) == 0, "outside image"); + joinp(p, sizeof p, in1, "001.png"); CHECK(touch_file(p) == 0, "inside1 image"); + joinp(p, sizeof p, in2, "001.png"); CHECK(touch_file(p) == 0, "inside2 image"); + + char out[MR_PATH_MAX]; + + /* 明文衔接:root 的兄弟 aaa_outside 在浏览根之外,_ex 会返回它。 */ + memset(out, 0, sizeof out); + CHECK(mr_book_adjacent_ex(root, -1, out, (int)sizeof out, NULL, NULL) == 1 && + strcmp(out, outside) == 0, + "plain adjacency reaches outside root (%s)", out); + + /* 根锁定版:同样的调用必须拒绝越根。 */ + memset(out, 0x7f, sizeof out); + out[0] = '\0'; + CHECK(mr_book_adjacent_rooted(root, -1, root, NULL, NULL, + out, (int)sizeof out) == 0 && out[0] == '\0', + "rooted adjacency refuses book outside root (%s)", out); + CHECK(mr_book_adjacent_rooted(root, 1, root, NULL, NULL, + out, (int)sizeof out) == 0 && out[0] == '\0', + "rooted adjacency: no next inside root"); + + /* 根内正常翻册仍然可用(不能被误锁死)。 */ + memset(out, 0, sizeof out); + CHECK(mr_book_adjacent_rooted(in1, 1, root, NULL, NULL, + out, (int)sizeof out) == 1 && + strcmp(out, in2) == 0, "rooted next inside root (%s)", out); + CHECK(mr_book_adjacent_rooted(in2, -1, root, NULL, NULL, + out, (int)sizeof out) == 1 && + strcmp(out, in1) == 0, "rooted prev inside root (%s)", out); + CHECK(mr_book_adjacent_rooted(in1, -1, root, NULL, NULL, + out, (int)sizeof out) == 0, + "rooted: no book before inside1 (outside is out of root)"); + CHECK(mr_book_adjacent_rooted(in2, 1, root, NULL, NULL, + out, (int)sizeof out) == 0, + "rooted: no book after inside2"); + + /* 当前册本身在根外 → 视为没有相邻册。 */ + CHECK(mr_book_adjacent_rooted(outside, 1, root, NULL, NULL, + out, (int)sizeof out) == 0, "current dir outside root"); + + /* 参数边界。 */ + CHECK(mr_book_adjacent_rooted(root, 1, NULL, NULL, NULL, + out, (int)sizeof out) == -1, "rooted NULL root"); + CHECK(mr_book_adjacent_rooted(NULL, 1, root, NULL, NULL, + out, (int)sizeof out) == -1, "rooted NULL dir"); + CHECK(mr_book_adjacent_rooted(root, 1, root, NULL, NULL, NULL, 32) == -1, + "rooted NULL out"); + CHECK(mr_book_adjacent_rooted(root, 1, "", NULL, NULL, + out, (int)sizeof out) == -1, "rooted empty root"); + + rm_rf(base); +} + int main(void) { char tmpl[] = "./mr_cont_adj_XXXXXX"; @@ -240,6 +315,7 @@ int main(void) test_has_images(root); test_adjacent(root); test_library_rows(root); + test_rooted_adjacent(); rm_rf(root); printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); diff --git a/tuku/test/test_library.c b/tuku/test/test_library.c index 1fcb9a3..bf04cc9 100644 --- a/tuku/test/test_library.c +++ b/tuku/test/test_library.c @@ -5,7 +5,7 @@ * T2 扩展名过滤:图片 png/jpg/jpeg/bmp 与档案册 cbz/zip 大小写不敏感;gif/txt 不支持; * T3 单层扫描:目录 + 受支持图片 + 档案册混排(目录优先、组内自然序); * 其它普通文件/隐藏项/符号链接不列;副标题扫描期生成一次(无占位/无懒判定); - * 图片行下标(mr_library_image_index)与行类型;不可读路径 -> -1;max_depth 忽略; + * 行类型;不可读路径 -> -1;max_depth 忽略;浏览根前缀判据 mr_path_within_root; * T4 阅读方向状态(logic/reader.c)。 * * 编译(在 tuku/ 下): @@ -80,13 +80,6 @@ static const mr_library_entry *find_entry(const mr_library *lib, const char *nam return NULL; } -static int find_index(const mr_library *lib, const char *name) -{ - for (int i = 0; i < lib->count; i++) - if (strcmp(lib->items[i].name, name) == 0) return i; - return -1; -} - static void test_natural(void) { CHECK(mr_natural_cmp("page2", "page10") < 0, "page2 < page10"); @@ -214,17 +207,27 @@ static void test_scan(void) CHECK(find_entry(&lib, "a.png") == NULL, "no recursion into subdirs"); CHECK(find_entry(&lib, "Deeper") == NULL, "no recursion into subdirs (deeper)"); - /* 图片下标 = 本层图片自然序(书库点图 → 阅读器起始页的契约)。 */ - int i_cover = find_index(&lib, "cover.JPG"); - int i_root = find_index(&lib, "root.png"); - CHECK(mr_library_image_index(&lib, i_cover) == 0, "cover.JPG is image #0"); - CHECK(mr_library_image_index(&lib, i_root) == 1, "root.png is image #1"); - CHECK(mr_library_image_index(&lib, find_index(&lib, "book.cbz")) == -1, - "archive has no image index"); - CHECK(mr_library_image_index(&lib, find_index(&lib, "Empty")) == -1, - "dir has no image index"); - CHECK(mr_library_image_index(&lib, -1) == -1 && mr_library_image_index(&lib, 99) == -1, - "out-of-range image index"); + /* 浏览根前缀判据(书库导航/阅读器册间衔接的根锁定;纯路径工具)。 */ + CHECK(mr_path_within_root("/usr/data/mnt/sd_0/a/b", "/usr/data/mnt/sd_0") == 1, + "nested path inside root"); + CHECK(mr_path_within_root("/usr/data/mnt/sd_0", "/usr/data/mnt/sd_0") == 1, + "root itself is inside"); + CHECK(mr_path_within_root("/usr/data/mnt/sd_0/", "/usr/data/mnt/sd_0") == 1, + "root with trailing slash is inside"); + CHECK(mr_path_within_root("/usr/data/mnt/sd_0evil", "/usr/data/mnt/sd_0") == 0, + "prefix-lookalike rejected"); + CHECK(mr_path_within_root("/usr/data/mnt/sd_00", "/usr/data/mnt/sd_0") == 0, + "prefix-lookalike with digits rejected"); + CHECK(mr_path_within_root("/usr/data/mnt", "/usr/data/mnt/sd_0") == 0, + "parent of root rejected"); + CHECK(mr_path_within_root("/usr/data/mnt/sd_0x/a", "/usr/data/mnt/sd_0") == 0, + "sibling-prefixed path rejected"); + CHECK(mr_path_within_root("/a/b", "/a/") == 1, "root trailing slash tolerated"); + CHECK(mr_path_within_root("/a", "/") == 1, "filesystem root accepts abs path"); + CHECK(mr_path_within_root("/", "/") == 1, "filesystem root itself"); + CHECK(mr_path_within_root("rel/path", "/") == 0, "relative path not inside /"); + CHECK(mr_path_within_root(NULL, "/") == 0 && mr_path_within_root("/a", NULL) == 0 && + mr_path_within_root("/a", "") == 0, "NULL/empty safe"); /* 行合成:纯内存,返回扫描期生成的字符串。 */ mr_library_row row; diff --git a/tuku/test/test_mode_switch_host.c b/tuku/test/test_mode_switch_host.c index 1a7d9d8..a77aa1d 100644 --- a/tuku/test/test_mode_switch_host.c +++ b/tuku/test/test_mode_switch_host.c @@ -8,7 +8,8 @@ * H3 跳页滑块:展开 → 拖到末页 → 目标页解码完成后落位(不是先白屏再跳); * H4 离开/重进:恢复上次页 + 模式(续读); * H5 册间衔接:条漫末页上拉 → 下一册首页;首页下拉 → 上一册末页;首册再无上一册时 - * 停在原地(不退出、不崩)。 + * 停在原地(不退出、不崩); + * H6 册间衔接根锁定:浏览根内没有相邻册时,不跨到根外的兄弟册(M18 评审观察项)。 * * 编译:test/run_webtoon_tests.sh(需 SDK 源码树;只发布 meow_ui.a 时自动 SKIP)。 */ @@ -164,6 +165,10 @@ int main(void) meow_view *reader = reader_view_create(); meow_view_page_push(ACT_LIBRARY); + /* 阅读器册间衔接的浏览根:测试夹具根(生产默认 MR_ROOT_DIR)。 */ + mr_reader_test_set_root(root); + CHECK(strcmp(mr_reader_test_root(), root) == 0, "reader browse root installed"); + /* TopAppBar 动作格(位图):右起每格 48px;动作 0=模式 / 1=跳页 / 2=fit。 */ int w = meow_ui_width(); int mode_x = w - 10 - 48 * 3 + 24; @@ -258,6 +263,31 @@ int main(void) 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)"); + /* ---- H6 册间衔接根锁定:浏览根=book2 时,兄弟册 book1 在根外,不得翻过去 ---- */ + meow_view_page_pop(); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "back to library for root-lock case"); + mr_reader_test_set_root(book2); + CHECK(strcmp(mr_reader_test_root(), book2) == 0, "reader root narrowed to book2"); + mr_reader_open_dir_at(book2, 0); /* 显式首页,避开续读页差异 */ + CHECK(meow_ui_activity_active() == ACT_READER, "reopened book2"); + CHECK(strcmp(mr_reader_dir(), book2) == 0, "at book2"); + if (mr_reader_ui_mode() != MR_MODE_WEBTOON) mr_reader_test_toggle_mode(); + CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "webtoon for root-lock case"); + CHECK(mr_reader_ui_page() == 0, "root-lock case at first page (got %d)", + mr_reader_ui_page()); + + /* 首页下拉 → book1 是 book2 的上一册,但在浏览根外:必须原地不动。 */ + 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, "root-lock down-swipe consumed"); + CHECK(strcmp(mr_reader_dir(), book2) == 0, + "adjacent book outside browse root is refused (dir=%s)", mr_reader_dir()); + CHECK(meow_ui_activity_active() == ACT_READER, "root-lock: still in reader"); + mr_reader_test_set_root(root); /* 复原夹具根 */ + /* ---- 收尾 ---- */ meow_view_page_pop(); CHECK(meow_ui_activity_active() == ACT_LIBRARY, "popped back to library"); diff --git a/tuku/test/test_open_book_host.c b/tuku/test/test_open_book_host.c index dc3a503..f9f82fa 100644 --- a/tuku/test/test_open_book_host.c +++ b/tuku/test/test_open_book_host.c @@ -14,6 +14,7 @@ */ #include "tuku_internal.h" #include "ui/reader/reader.h" +#include "logic/reader/page_source.h" #include #include @@ -28,12 +29,14 @@ void library_test_set_root(const char *root); const char *library_test_dir(void); int library_test_rows(void); int library_test_src(int idx, meow_list_item *item); +int library_test_open_row(int idx); /* ui/reader/reader.c 的测试接缝(-DMR_READER_HOST_TEST) */ int mr_reader_ui_mode(void); int mr_reader_ui_page(void); int mr_reader_ui_count(void); int mr_reader_ui_open_err(void); +void mr_reader_open_dir_at_name(const char *dir, const char *name); static int g_fail; #define CHECK(cond, ...) do { \ @@ -152,6 +155,22 @@ static int expected_image_index(const char *name) return n; } +/* 阅读器目录册用的假解码回调(本测试不经过它们取页)。 */ +static meow_img *cap_fake_load(const char *path, void *ud, int maxdim) +{ + (void)path; (void)ud; (void)maxdim; + return NULL; +} +static void cap_fake_unload(meow_img *img) { (void)img; } + +/* 在阅读器的全量目录册里按文件名取页下标(与阅读器打开时同一份枚举)。 */ +static int reader_page_of_name(const mr_dir_book *db, const char *name) +{ + for (int i = 0; i < db->count; i++) + if (db->names[i] && strcmp(db->names[i], name) == 0) return i; + return -1; +} + int main(void) { const char *base = getenv("TMPDIR"); @@ -184,6 +203,16 @@ int main(void) if (write_bmp24(p, 40, 60) != 0) { printf("SKIP: image write failed\n"); return 0; } } + /* Big:2100 张图(超过默认 MR_LIB_SCAN_MAX=2000,复现评审 P1-1 截断错位场景)。 + * 1x1 BMP(stb 可解)以控制夹具体积/建盘时间。 */ + char big[MR_PATH_MAX]; + snprintf(big, sizeof big, "%s/Big", root); + if (mkdir_p(big) != 0) { printf("SKIP: mkdir Big failed\n"); return 0; } + for (int i = 1; i <= 2100; i++) { + snprintf(p, sizeof p, "%s/p%04d.bmp", big, i); + if (write_bmp24(p, 1, 1) != 0) { printf("SKIP: big image 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; } @@ -238,6 +267,90 @@ int main(void) mr_reader_ui_page(), want2); meow_view_page_pop(); + /* ---- P1-1 回归:单目录 2100 张图(超过默认 cap=2000)不得「点 A 开 B」 ---- */ + CHECK(library_test_load(root) == 1, "load root for cap regression"); + tap_row("Big"); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "tap Big dir stays in library"); + CHECK(strcmp(library_test_dir(), big) == 0, "entered Big (%s)", library_test_dir()); + CHECK(library_test_rows() == MR_LIB_SCAN_MAX + 1, + "Big rows=%d (cap + truncation row)", library_test_rows()); + + /* 截断提示行仍在,且文案不让人误以为「没有更多图」。 */ + meow_list_item trunc_it; + CHECK(library_test_src(library_test_rows() - 1, &trunc_it) == 1 && + trunc_it.enabled == 0 && trunc_it.title && strcmp(trunc_it.title, "已截断") == 0, + "truncation row present"); + CHECK(trunc_it.subtitle && strstr(trunc_it.subtitle, "2000") && + strstr(trunc_it.subtitle, "阅读器"), + "truncation text keeps images discoverable: %s", + trunc_it.subtitle ? trunc_it.subtitle : ""); + + /* 阅读器目录册是**全量** 2100 页(书库 cap 只是列表显示上限)。 */ + mr_dir_book db; + memset(&db, 0, sizeof db); + CHECK(mr_dir_book_init(&db, big, 720, cap_fake_load, cap_fake_unload, NULL) == 2100, + "reader sees all 2100 pages"); + + /* 首 3 + 尾 3 + 20 个确定性伪随机行,逐一点开断言「点哪张开哪张」。 */ + int picks[26], np = 0; + picks[np++] = 0; + picks[np++] = 1; + picks[np++] = 2; + picks[np++] = MR_LIB_SCAN_MAX - 3; + picks[np++] = MR_LIB_SCAN_MAX - 2; + picks[np++] = MR_LIB_SCAN_MAX - 1; + unsigned seed = 12345u; + for (int i = 0; i < 20; i++) { + seed = seed * 1103515245u + 12345u; + picks[np++] = (int)((seed >> 16) % MR_LIB_SCAN_MAX); + } + + int mismatched = 0; + for (int i = 0; i < np; i++) { + int row = picks[i]; + meow_list_item it; + memset(&it, 0, sizeof it); + CHECK(library_test_src(row, &it) == 1 && it.title, "pick %d: row source", row); + if (!it.title) continue; + int want_page = reader_page_of_name(&db, it.title); + CHECK(want_page >= 0, "pick %d: %s is a reader page", row, it.title); + CHECK(library_test_open_row(row) == 1, "open row %d", row); + CHECK(meow_ui_activity_active() == ACT_READER, "pick %d opens reader", row); + CHECK(strcmp(mr_reader_dir(), big) == 0, "pick %d reader dir", row); + CHECK(mr_reader_ui_count() == 2100, "pick %d reader pages=%d", row, + mr_reader_ui_count()); + if (mr_reader_ui_page() != want_page) mismatched++; + CHECK(mr_reader_ui_page() == want_page, + "pick %d: clicked %s -> page %d (want %d)", row, it.title, + mr_reader_ui_page(), want_page); + meow_view_page_pop(); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "pick %d back to library", row); + } + CHECK(mismatched == 0, "no click/page mismatch across %d picks", np); + + /* 被书库截掉的图(第 2001 张起)在阅读器里仍可打开/翻到。 */ + CHECK(reader_page_of_name(&db, "p2099.bmp") == 2098, "p2099 lives at page 2098"); + mr_reader_open_dir_at_name(big, "p2099.bmp"); + CHECK(meow_ui_activity_active() == ACT_READER, "truncated-out image opens reader"); + CHECK(mr_reader_ui_count() == 2100, "truncated-out image: all pages present"); + CHECK(mr_reader_ui_page() == 2098, "truncated-out image opens at its page (%d)", + mr_reader_ui_page()); + meow_view_page_pop(); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "back to library after truncated image"); + CHECK(library_test_rows() == MR_LIB_SCAN_MAX + 1, "library list unchanged after reads"); + mr_dir_book_free(&db); + + /* 名字找不到(文件已改名/删除):退回续读/首页,不崩、不串册。 */ + mr_reader_open_dir_at_name(pics, "does-not-exist.bmp"); + CHECK(meow_ui_activity_active() == ACT_READER, "unknown name still opens reader"); + CHECK(strcmp(mr_reader_dir(), pics) == 0, "unknown name keeps target dir"); + CHECK(mr_reader_ui_count() == 6 && mr_reader_ui_open_err() == 0, + "unknown name falls back safely (pages=%d err=%d)", + mr_reader_ui_count(), mr_reader_ui_open_err()); + CHECK(mr_reader_ui_page() >= 0 && mr_reader_ui_page() < 6, + "unknown name fallback page in range (%d)", mr_reader_ui_page()); + meow_view_page_pop(); + /* 档案册路径(无 zip 夹具时只验证入口不被当成目录):点 txt 不存在行;跳过。 */ meow_view_free(reader); diff --git a/tuku/test/test_reader_perf.c b/tuku/test/test_reader_perf.c index 866481a..5a84afc 100644 --- a/tuku/test/test_reader_perf.c +++ b/tuku/test/test_reader_perf.c @@ -146,6 +146,14 @@ static void double_tap(int x, int y) pump_ms(120); } +/* 直接切模式(绕过顶栏 tap 的命中时序):本文件只关心绘制/解码,不验证 chrome 命中; + * 顶栏动作本身的回归在 test_mode_switch_host 覆盖。M18 评审:负载下该用例曾偶发。 */ +static void mode_toggle(void) +{ + mr_reader_test_toggle_mode(); + pump_ms(100); +} + static void draw_once(void) { meow_ui_paint_full(); } int main(void) @@ -239,7 +247,7 @@ int main(void) "paged drag: zero resample (scaled %d -> %d)", st, mr_reader_ui_scaled_draws()); /* ---- P4 条漫模式:切换后滚动,长宽比 1.6 页也必须零重采样 ---- */ - tap(mode_x, bar_y); + mode_toggle(); CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "switched to webtoon"); pump_ms(800); draw_once(); @@ -260,14 +268,14 @@ int main(void) mr_reader_ui_scaled_draws()); /* ---- P5 「先适高、再切条漫」:strip 恒按屏宽排版,解码必须跟随 ---- */ - tap(mode_x, bar_y); /* 回翻页 */ + mode_toggle(); /* 回翻页 */ CHECK(mr_reader_ui_mode() == MR_MODE_PAGED, "back to paged"); pump_ms(500); tap(fit_x, bar_y); /* 适宽 → 适高 */ CHECK(mr_reader_ui_fit() == MR_FIT_HEIGHT, "fit-height selected (got %d)", mr_reader_ui_fit()); pump_ms(700); - tap(mode_x, bar_y); /* 适高档 → 条漫 */ + mode_toggle(); /* 适高档 → 条漫 */ CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "webtoon after fit-height"); pump_ms(900); draw_once(); @@ -290,7 +298,7 @@ int main(void) mr_reader_ui_scaled_draws()); /* ---- P6 双击放大再缩回(评审 P2-1:缩回后必须按新几何重解)---- */ - tap(mode_x, bar_y); /* 回翻页 */ + mode_toggle(); /* 回翻页 */ CHECK(mr_reader_ui_mode() == MR_MODE_PAGED, "paged for zoom test"); pump_ms(600); draw_once(); @@ -338,7 +346,7 @@ int main(void) CHECK(mr_reader_ui_blit_draws() > bt && mr_reader_ui_scaled_draws() == st, "800x1280 paged drag: zero resample (blit +%d, scaled +%d)", mr_reader_ui_blit_draws() - bt, mr_reader_ui_scaled_draws() - st); - tap(mode_x, bar_y); /* 条漫 */ + mode_toggle(); /* 条漫 */ CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "800x1280 webtoon"); pump_ms(800); draw_once(); diff --git a/tuku/ui/library/library.c b/tuku/ui/library/library.c index f7f7322..41bac1e 100644 --- a/tuku/ui/library/library.c +++ b/tuku/ui/library/library.c @@ -4,7 +4,8 @@ * * 浏览语义(M18,用户要求「完全像文件管理器」): * - 列表 = 当前目录一层的「子目录 + 受支持图片 + .cbz/.zip 档案册」,目录优先、组内自然序; - * 点目录=进入(空目录也进),点图片=从该图开始读(mr_reader_open_dir_at(目录, 图片下标)), + * 点目录=进入(空目录也进),点图片=从该图开始读(mr_reader_open_dir_at_name(目录, 文件名), + * 由阅读器在全量目录册里定位起始页;列表被截断也不会点错图), * 点档案册=打开档案册(第 1 页/续读); * - 首帧只做**单层**枚举(一次 opendir/readdir+lstat):没有「目录含图=一本书」的探测、 * 页数、封面、角标,也没有懒算线程/缩略图解码——行副标题在扫描期生成一次; @@ -268,8 +269,7 @@ int library_go_up(void) char parent[MR_PATH_MAX]; if (mr_path_parent(g_cur_dir, parent, (int)sizeof parent) != 0) return 0; /* 防御:父目录若越出浏览根(理论上不会发生),钳回根。 */ - size_t rl = strlen(g_root_dir); - if (strlen(parent) < rl || strncmp(parent, g_root_dir, rl) != 0) + if (!mr_path_within_root(parent, g_root_dir)) snprintf(parent, sizeof parent, "%s", g_root_dir); if (!load_dir(parent)) { /* 父目录不可读 → 提示并停留(不越根、不丢当前列表)。 */ @@ -289,9 +289,9 @@ static void library_open_entry(int idx) return; } if (e->kind == MR_LIB_KIND_IMAGE) { - /* 下标由 logic 用与阅读器目录册相同的过滤器/比较器数出,保证「点 A 开 A」。 */ - int start = mr_library_image_index(&g_lib, idx); - mr_reader_open_dir_at(g_cur_dir, start < 0 ? 0 : start); + /* 按文件名定位起始页:由阅读器用自己的全量目录册算下标。 + * 书库列表可能被 MR_LIB_SCAN_MAX 截断,绝不能从列表推导下标(会点 A 开 B)。 */ + mr_reader_open_dir_at_name(g_cur_dir, e->name); return; } if (e->kind == MR_LIB_KIND_ARCHIVE) { @@ -401,8 +401,8 @@ void library_on_tick(void) /* ============================================================ */ static void library_page_create(meow_view *v, void *ctx) { - mr_page_ctrl *c = (mr_page_ctrl *)ctx; - c->view = v; + (void)v; + (void)ctx; int w = meow_ui_width(); int sb = meow_ui_statusbar_height(); @@ -525,6 +525,14 @@ int library_test_src(int idx, meow_list_item *item) return lib_src(idx, item, NULL); } +/* 直接打开第 idx 行(等价点击行体;测试里避免滚动/坐标干扰)。 */ +int library_test_open_row(int idx) +{ + if (idx < 0 || idx >= g_lib.count) return 0; + library_open_entry(idx); + return 1; +} + int library_test_hint_active(void) { return g_hint[0] != '\0'; diff --git a/tuku/ui/reader/reader.c b/tuku/ui/reader/reader.c index 1fba5ef..96bb0d8 100644 --- a/tuku/ui/reader/reader.c +++ b/tuku/ui/reader/reader.c @@ -21,7 +21,7 @@ * * 模式(M7):翻页/条漫是一等公民。阅读 chrome 的图标按钮一键切换;每册的模式与 last page * 经 meow_config 持久化(键 reader.mode/page.<册标识>,见 logic/reader.c),下次打开同一册 - * 自动恢复。书库行角标由 logic/library.c 扫描时读取同一份状态(副标题后缀「续读 Pn」)。 + * 自动恢复。书库列表是文件管理器模型(M18),不再显示续读角标。 * * 跳页:chrome 内 meow_slider 选目标页,松手后先把目标页解码进缓存再落位(不白屏不闪烁; * 2.5s 超时则降级落位并提示)。 @@ -86,6 +86,11 @@ static mr_page_ctrl g_ctrl; static char g_dir[MR_PATH_MAX]; static int g_start_at = MR_READER_START_RESUME; /* 下次 on_enter 的显式起始页(-1=续读) */ +/* 下次 on_enter 的「按文件名定位起始页」(书库点图;只对目录册生效,open_book 消费后清空)。 + * 用文件名而不是下标:书库列表可能被 MR_LIB_SCAN_MAX 截断,下标不可信。 */ +static char g_start_name[MR_NAME_MAX]; +/* 浏览根(生产=MR_ROOT_DIR,宿主测试可改):册间衔接不得越过它。 */ +static char g_root[MR_PATH_MAX] = MR_ROOT_DIR; static mr_book_handle g_handle; /* 目录册或档案册(M11 统一开册) */ static mr_book g_book; @@ -1433,10 +1438,28 @@ static void open_book_reset_state(void) g_meta = NULL; } -/* 打开一本册并恢复模式/起始页。start_page<0 = 末页(上一册衔接)。1=成功。 */ +/* 在目录册里按文件名定位页下标:先精确,再大小写不敏感(书库展示名与磁盘名一致, + * 兜底只为容错)。-1=没找到。 */ +static int dir_book_find_index(const mr_dir_book *db, const char *name) +{ + if (!db || !name || !name[0] || db->count <= 0) return -1; + for (int i = 0; i < db->count; i++) + if (db->names[i] && strcmp(db->names[i], name) == 0) return i; + for (int i = 0; i < db->count; i++) + if (db->names[i] && strcasecmp(db->names[i], name) == 0) return i; + return -1; +} + +/* 打开一本册并恢复模式/起始页。start_page<0 = 末页(上一册衔接)。1=成功。 + * 若 g_start_name 非空(书库点图),优先用**刚枚举出来的目录册**按 basename 定位起始页: + * 枚举源与翻页完全一致,且不受书库 MR_LIB_SCAN_MAX 截断影响。 */ static int open_book(const char *dir, int start_page, int mode) { if (!dir || !dir[0]) return 0; + char want_name[MR_NAME_MAX]; + snprintf(want_name, sizeof want_name, "%s", g_start_name); + g_start_name[0] = '\0'; /* 只在本次打开生效 */ + open_book_reset_state(); /* reader_page_enter 传入的 dir 就是 g_dir:自拷贝是 snprintf 的未定义行为, * 其它调用(册间衔接)才需要复制。 */ @@ -1472,6 +1495,12 @@ static int open_book(const char *dir, int start_page, int mode) meow_log(MEOW_LOG_WARN, "reader meta cache unavailable (%d pages)", g_count); int page = start_page < 0 ? g_count - 1 : start_page; + /* 书库点图:按文件名在刚打开的目录册里定位(与翻页同一份枚举/比较器)。 + * 找不到(文件已改名/删除)才退回 start_page/续读。 */ + if (want_name[0] && g_handle.kind == MR_BOOK_KIND_DIR) { + int idx = dir_book_find_index(&g_handle.u.dir, want_name); + if (idx >= 0) page = idx; + } page = mr_nav_clamp_page(page, g_count); if (page < 0) page = 0; @@ -1525,8 +1554,9 @@ static int book_switch(int delta) if (delta == 0) return 0; if (g_count <= 0) return 0; char next[MR_PATH_MAX]; - int r = mr_book_adjacent_ex(g_dir, delta > 0 ? 1 : -1, next, (int)sizeof next, - cbz_pages_probe, NULL); + /* 根锁定:相邻册必须仍在浏览根内,绝不跳到 SD 卡挂载点以上。 */ + int r = mr_book_adjacent_rooted(g_dir, delta > 0 ? 1 : -1, g_root, + cbz_pages_probe, NULL, next, (int)sizeof next); if (r != 1) { toast(delta > 0 ? "已是最后一册" : "已是第一册"); return 0; @@ -2040,7 +2070,6 @@ static void reader_state_reset(void) static void reader_page_enter(meow_view *v, void *ctx) { mr_page_ctrl *c = (mr_page_ctrl *)ctx; - c->view = v; c->active = 1; g_quitting = 0; @@ -2120,6 +2149,7 @@ static void reader_page_leave(meow_view *v, void *ctx) free(g_meta); g_meta = NULL; g_start_at = MR_READER_START_RESUME; + g_start_name[0] = '\0'; /* 未消费的文件名定位不跨页残留 */ mr_book_close_auto(&g_book, &g_handle); if (g_slider) { meow_widget_free(g_slider); g_slider = NULL; } @@ -2160,7 +2190,9 @@ const char *mr_reader_dir(void) return g_dir; } -void mr_reader_open_dir_at(const char *path, int start_index) +/* 打开入口共用部分:已在本页时先 pop(完整 leave/enter 生命周期),再设目标并 push。 + * start_name 非空 = 按文件名定位(只对目录册),start_index 忽略。 */ +static void reader_open_common(const char *path, int start_index, const char *start_name) { if (!path) return; /* 已在本页(书库再次点另一张图/另一本册时可能仍是 ACT_READER): @@ -2172,13 +2204,31 @@ void mr_reader_open_dir_at(const char *path, int start_index) if (reopen) meow_view_page_pop(); snprintf(g_dir, sizeof g_dir, "%s", path); - /* -1=续读(旧入口语义);其它负数钳到 0;上界在 open_book 里按页数钳。 */ - g_start_at = start_index == MR_READER_START_RESUME ? MR_READER_START_RESUME - : (start_index < 0 ? 0 : start_index); - meow_log(MEOW_LOG_INFO, "reader open: %s (start=%d)", g_dir, g_start_at); + g_start_name[0] = '\0'; + if (start_name && start_name[0]) { + /* 按文件名定位:由 open_book 在刚枚举出的目录册里找下标(不受列表截断影响)。 */ + snprintf(g_start_name, sizeof g_start_name, "%s", start_name); + g_start_at = MR_READER_START_RESUME; + meow_log(MEOW_LOG_INFO, "reader open: %s (name=%s)", g_dir, g_start_name); + } else { + /* -1=续读(旧入口语义);其它负数钳到 0;上界在 open_book 里按页数钳。 */ + g_start_at = start_index == MR_READER_START_RESUME ? MR_READER_START_RESUME + : (start_index < 0 ? 0 : start_index); + meow_log(MEOW_LOG_INFO, "reader open: %s (start=%d)", g_dir, g_start_at); + } meow_view_page_push(ACT_READER); } +void mr_reader_open_dir_at(const char *path, int start_index) +{ + reader_open_common(path, start_index, NULL); +} + +void mr_reader_open_dir_at_name(const char *dir, const char *name) +{ + reader_open_common(dir, MR_READER_START_RESUME, name); +} + void mr_reader_open_dir(const char *path) { mr_reader_open_dir_at(path, MR_READER_START_RESUME); @@ -2205,4 +2255,14 @@ int mr_reader_ui_scaled_draws(void) { return g_scaled_draws; } int mr_reader_ui_decode_count(void) { return g_decode_count; } int mr_reader_ui_fit(void) { return g_fit; } int mr_reader_ui_chrome(void) { return g_chrome; } +/* 直接切模式(避开顶栏 tap 命中时序):与顶栏动作走同一个 mode_set。 */ +void mr_reader_test_toggle_mode(void) +{ + mode_set(g_mode == MR_MODE_PAGED ? MR_MODE_WEBTOON : MR_MODE_PAGED, -1); +} +void mr_reader_test_set_root(const char *root) +{ + if (root && root[0]) snprintf(g_root, sizeof g_root, "%s", root); +} +const char *mr_reader_test_root(void) { return g_root; } #endif diff --git a/tuku/ui/reader/reader.h b/tuku/ui/reader/reader.h index f841316..e7562a0 100644 --- a/tuku/ui/reader/reader.h +++ b/tuku/ui/reader/reader.h @@ -19,6 +19,14 @@ void reader_on_touch_move(int x, int y); * 显式下标优先于续读记录:用户在列表里点了某张图,就不该被上次的页码带走。 */ #define MR_READER_START_RESUME (-1) void mr_reader_open_dir_at(const char *dir, int start_index); + +/* 书库 → 阅读器(按文件名定位起始页;M18 评审 P1-1 的首选入口): + * 打开 dir 后,由阅读器自己的目录册枚举(logic/reader/page_source.c: mr_dir_book_init, + * 与翻页用的是同一份全量枚举)按 basename 找起始页(先大小写敏感,再大小写不敏感兜底)。 + * 找不到则退回续读记录/首页。这样书库不需要(也不能)从可能被 MR_LIB_SCAN_MAX 截断的 + * 列表里推导下标——目录条目超过 2000 时也不会「点 A 开 B」。name 为 NULL/空则等价续读。 */ +void mr_reader_open_dir_at_name(const char *dir, const char *name); + /* 保留旧入口:等价于 mr_reader_open_dir_at(dir, MR_READER_START_RESUME)。 */ void mr_reader_open_dir(const char *dir); @@ -36,6 +44,11 @@ int mr_reader_ui_scaled_draws(void);/* 累计重采样绘页次数(每帧重 int mr_reader_ui_decode_count(void);/* 累计解码次数(查重解抖动/活锁) */ int mr_reader_ui_fit(void); /* 当前 fit 档(MR_FIT_*) */ int mr_reader_ui_chrome(void); /* 1=顶栏可见 */ +/* 直接切模式(绕过顶栏 tap 的命中时序;测试用,产品路径仍走顶栏动作)。 */ +void mr_reader_test_toggle_mode(void); +/* 浏览根(生产=MR_ROOT_DIR):册间衔接不得越过它。测试可指向合成夹具根。 */ +void mr_reader_test_set_root(const char *root); +const char *mr_reader_test_root(void); #endif #ifdef __cplusplus