/* 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 #include #include #include #include #include #include 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; }