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;交叉构建零警告。
295 lines
12 KiB
C
295 lines
12 KiB
C
/* test_browse_lazy.c — 浏览行模型 / 零 IO 行合成 / 书库行与阅读器目录册同序(纯 C,无 SDK)。
|
||
*
|
||
* 文件名沿用历史(旧的「懒判定」已随 M18 文件管理器模型删除);本测试现在覆盖:
|
||
* L1 行合成 O(1):mr_library_row_at 纯内存(IO 计数不变)、截断提示行;
|
||
* L2 **书库行与阅读器目录册同源**:书库图片行的名称序列 == 阅读器目录册
|
||
* (logic/reader/page_source.c: mr_dir_book_init)的页名序列(同过滤器/同比较器)。
|
||
* 点图现在由阅读器按文件名自己定位下标,书库不再推导下标;本用例守住「两边枚举一致」。
|
||
* 夹具含大小写混排(A.jpg/a.jpg)、数字序号(img2/img10)、大写扩展名(IMG3.JPG)、
|
||
* 前导零(page01/page1)、夹在图片之间的档案册与普通文件;
|
||
* L3 扫描期副标题:每行都有内容(不再是占位「…」,渲染期不做格式化);
|
||
* L4 截断安全(评审 P1-1):2100 张图(默认 cap=2000)时,2000 个可见图片行按文件名
|
||
* 都能定位到同一文件(0 mismatch),被截掉的图仍在阅读器页表里可翻到。
|
||
*
|
||
* 编译(在 tuku/ 下):
|
||
* gcc -Wall -Wextra -O1 -I. -I../sdk -DMR_LIBRARY_TEST_HOOKS \
|
||
* test/test_browse_lazy.c logic/library.c logic/reader/page_source.c \
|
||
* -pthread -o build/host/test_browse_lazy
|
||
*/
|
||
#include "logic/library.h"
|
||
#include "logic/reader/page_source.h"
|
||
|
||
#include <dirent.h>
|
||
#include <stdio.h>
|
||
#include <stdlib.h>
|
||
#include <string.h>
|
||
#include <sys/stat.h>
|
||
#include <time.h>
|
||
#include <unistd.h>
|
||
|
||
static int g_pass, g_fail;
|
||
#define CHECK(cond, ...) do { \
|
||
if (cond) { g_pass++; } \
|
||
else { g_fail++; printf("FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \
|
||
} while (0)
|
||
|
||
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)) return unlink(path);
|
||
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 long long now_us(void)
|
||
{
|
||
struct timespec ts;
|
||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||
return (long long)ts.tv_sec * 1000000LL + ts.tv_nsec / 1000LL;
|
||
}
|
||
|
||
/* 阅读器目录册用的假解码回调(本测试不打开任何页)。 */
|
||
static meow_img *fake_load(const char *path, void *ud, int maxdim)
|
||
{
|
||
(void)path; (void)ud; (void)maxdim;
|
||
return NULL;
|
||
}
|
||
static void fake_unload(meow_img *img) { (void)img; }
|
||
|
||
/* ---- L1 + L3:行合成零 IO / 副标题预生成 ---- */
|
||
static void test_rows_zero_io(const char *root)
|
||
{
|
||
mr_library lib;
|
||
memset(&lib, 0, sizeof lib);
|
||
CHECK(mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH) > 0, "scan fixture");
|
||
|
||
for (int i = 0; i < lib.count; i++) {
|
||
mr_library_row row;
|
||
CHECK(mr_library_row_at(&lib, i, &row) == 1, "row %d", i);
|
||
CHECK(row.title && row.title[0], "row %d title", i);
|
||
CHECK(row.subtitle && row.subtitle[0] && strcmp(row.subtitle, "…") != 0,
|
||
"row %d subtitle pre-generated (%s)", i, row.subtitle ? row.subtitle : "");
|
||
}
|
||
|
||
mr_library_io_stats st;
|
||
mr_library_test_io_reset();
|
||
long long t0 = now_us();
|
||
int rows = 0;
|
||
for (int r = 0; r < 2000; r++)
|
||
for (int i = 0; i < lib.count; i++) {
|
||
mr_library_row row;
|
||
if (mr_library_row_at(&lib, i, &row)) rows++;
|
||
}
|
||
long long us = now_us() - t0;
|
||
mr_library_test_io_get(&st);
|
||
CHECK(rows == 2000 * lib.count, "rows synthesized (%d)", rows);
|
||
CHECK(st.opendirs == 0 && st.readdirs == 0 && st.lstats == 0,
|
||
"row synth is pure memory (open=%ld rd=%ld stat=%ld)",
|
||
st.opendirs, st.readdirs, st.lstats);
|
||
CHECK(us < 2000000, "row synth bounded (%lldus)", us);
|
||
|
||
mr_library_row row;
|
||
CHECK(mr_library_row_at(&lib, lib.count, &row) == 0, "no extra row when not truncated");
|
||
lib.truncated = 1;
|
||
CHECK(mr_library_row_at(&lib, lib.count, &row) == 1 && row.disabled == 1,
|
||
"truncation info row");
|
||
CHECK(strcmp(row.title, "已截断") == 0, "truncation title=%s", row.title);
|
||
CHECK(strstr(row.subtitle, "2000") != NULL, "truncation subtitle=%s", row.subtitle);
|
||
|
||
mr_library_free(&lib);
|
||
}
|
||
|
||
/* ---- L2:书库图片行名称序列 == 阅读器目录册页名序列 ---- */
|
||
static void test_library_rows_match_reader(const char *dir)
|
||
{
|
||
mr_library lib;
|
||
memset(&lib, 0, sizeof lib);
|
||
CHECK(mr_library_scan(&lib, dir, MR_LIB_MAX_DEPTH) > 0, "scan mixed dir");
|
||
|
||
mr_dir_book db;
|
||
memset(&db, 0, sizeof db);
|
||
int pages = mr_dir_book_init(&db, dir, 720, fake_load, fake_unload, NULL);
|
||
CHECK(pages > 0, "reader dir book pages=%d", pages);
|
||
|
||
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 页(同过滤器/同自然序)。
|
||
* 点图时阅读器按文件名重新定位下标,所以「名字集合 + 顺序」一致就是契约。 */
|
||
int k = 0;
|
||
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);
|
||
CHECK(strcmp(page_path, lib.items[i].path) == 0,
|
||
"page[%d]: library=%s reader=%s", k, lib.items[i].path, page_path);
|
||
k++;
|
||
}
|
||
|
||
/* 目录/档案册不是页:它们的名字不能出现在阅读器页名里。 */
|
||
for (int i = 0; i < lib.count; i++) {
|
||
if (lib.items[i].kind == MR_LIB_KIND_IMAGE) continue;
|
||
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";
|
||
char *root = mkdtemp(tmpl);
|
||
CHECK(root != NULL, "mkdtemp");
|
||
if (!root) return 1;
|
||
|
||
char p[MR_PATH_MAX];
|
||
const char *dirs[] = { "book", "mixed", "mixed/sub", "empty" };
|
||
for (unsigned i = 0; i < sizeof dirs / sizeof dirs[0]; i++) {
|
||
joinp(p, sizeof p, root, dirs[i]);
|
||
CHECK(mkdir_p(p) == 0, "mkdir %s", p);
|
||
}
|
||
joinp(p, sizeof p, root, "book/page2.png"); CHECK(touch_file(p) == 0, "page2");
|
||
joinp(p, sizeof p, root, "book/page10.jpg"); CHECK(touch_file(p) == 0, "page10");
|
||
joinp(p, sizeof p, root, "book/notes.txt"); CHECK(touch_file(p) == 0, "notes");
|
||
joinp(p, sizeof p, root, "mixed/sub/zz.png");CHECK(touch_file(p) == 0, "nested");
|
||
|
||
/* 大小写混排 + 数字序号 + 大写扩展名 + 前导零 + 夹档案/普通文件。 */
|
||
const char *mixed[] = {
|
||
"A.jpg", "a.jpg", "img2.png", "IMG3.JPG", "img10.png",
|
||
"page01.bmp", "page1.jpeg", "cover.cbz", "notes.txt", ".hidden.png"
|
||
};
|
||
for (unsigned i = 0; i < sizeof mixed / sizeof mixed[0]; i++) {
|
||
joinp(p, sizeof p, root, "mixed");
|
||
size_t dl = strlen(p);
|
||
snprintf(p + dl, sizeof p - dl, "/%s", mixed[i]);
|
||
CHECK(touch_file(p) == 0, "image %s", p);
|
||
}
|
||
|
||
test_rows_zero_io(root);
|
||
joinp(p, sizeof p, root, "mixed");
|
||
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);
|
||
return g_fail ? 1 : 0;
|
||
}
|