/* test_cont_adjacent.c — 册间顺序边界 + 书库续读角标宿主测试(无 SDK/硬件)。 * * 覆盖: * A1 「直接含图」判定:含图目录=书;只有子目录含图/空目录/仅非图文件=不是书; * A2 兄弟册排序:同父目录、书库自然序(vol2 < vol10);首/末册无相邻返回 0; * A3 边界:末页前进→下一册、首页后退→上一册(调用方语义);路径末尾斜杠等价; * A4 排除:隐藏/系统目录、符号链接、普通文件不参与兄弟册; * A5 参数:delta 非 ±1、NULL/小缓冲、不存在的目录; * A6 书库行模型(M18):目录+图片+档案册同列、目录优先;副标题扫描期生成, * 不再有「· 续读 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 \ * test/settings_reader_stub.c -pthread -o test/test_cont_adjacent * 或 test/run_webtoon_tests.sh。 */ #include "logic/library.h" #include "logic/reader.h" #include "test/settings_reader_stub.h" #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; int ok = fwrite("x", 1, 1, f) == 1; fclose(f); return ok ? 0 : -1; } 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 void test_has_images(const char *root) { char p[MR_PATH_MAX]; CHECK(mr_library_dir_has_images(root) == 1, "root has a loose image file (scan/adjacency only treats subdirs as books)"); joinp(p, sizeof p, root, "vol1"); CHECK(mr_library_dir_has_images(p) == 1, "vol1 is a book"); joinp(p, sizeof p, root, "dirs_only"); CHECK(mr_library_dir_has_images(p) == 0, "dirs_only is not a book"); joinp(p, sizeof p, root, "empty"); CHECK(mr_library_dir_has_images(p) == 0, "empty is not a book"); joinp(p, sizeof p, root, "txonly"); CHECK(mr_library_dir_has_images(p) == 0, "text-only dir is not a book"); CHECK(mr_library_dir_has_images("/no/such/dir/xyz") == 0, "missing dir"); CHECK(mr_library_dir_has_images(NULL) == 0 && mr_library_dir_has_images("") == 0, "NULL/empty safe"); } static void expect_neighbor(const char *dir, int delta, const char *want, const char *tag) { char out[MR_PATH_MAX]; int r = mr_book_adjacent(dir, delta, out, (int)sizeof out); if (!want) { CHECK(r == 0 && out[0] == '\0', "%s: no neighbor (r=%d out=%s)", tag, r, out); } else { CHECK(r == 1 && strcmp(out, want) == 0, "%s: got %s (want %s)", tag, out, want); } } static void test_adjacent(const char *root) { char vol1[MR_PATH_MAX], vol2[MR_PATH_MAX], vol10[MR_PATH_MAX]; char series_a[MR_PATH_MAX], series_b[MR_PATH_MAX]; char dirs_only[MR_PATH_MAX], out[MR_PATH_MAX]; joinp(vol1, sizeof vol1, root, "vol1"); joinp(vol2, sizeof vol2, root, "vol2"); joinp(vol10, sizeof vol10, root, "vol10"); joinp(series_a, sizeof series_a, root, "series/a"); joinp(series_b, sizeof series_b, root, "series/b"); joinp(dirs_only, sizeof dirs_only, root, "dirs_only"); /* 自然序:vol1 < vol2 < vol10(vol0 是符号链接,不参与) */ expect_neighbor(vol1, 1, vol2, "vol1 next"); expect_neighbor(vol2, 1, vol10, "vol2 next"); expect_neighbor(vol10, 1, NULL, "vol10 next (end)"); expect_neighbor(vol1, -1, NULL, "vol1 prev (start)"); expect_neighbor(vol2, -1, vol1, "vol2 prev"); expect_neighbor(vol10, -1, vol2, "vol10 prev"); /* 末尾斜杠等价 */ char slash[MR_PATH_MAX]; snprintf(slash, sizeof slash, "%s/", vol2); expect_neighbor(slash, 1, vol10, "trailing slash next"); expect_neighbor(slash, -1, vol1, "trailing slash prev"); /* 嵌套父目录:series/a <-> series/b */ expect_neighbor(series_a, 1, series_b, "series a next"); expect_neighbor(series_b, -1, series_a, "series b prev"); expect_neighbor(series_b, 1, NULL, "series b next (end)"); /* 不是书的目录:不在兄弟表中 */ expect_neighbor(dirs_only, 1, NULL, "dirs_only not a book"); expect_neighbor(dirs_only, -1, NULL, "dirs_only not a book prev"); /* 路径参数:delta/缓冲/不存在 */ CHECK(mr_book_adjacent(vol1, 0, out, (int)sizeof out) == -1, "delta 0 invalid"); CHECK(mr_book_adjacent(vol1, 2, out, (int)sizeof out) == -1, "delta 2 invalid"); CHECK(mr_book_adjacent(vol1, -2, out, (int)sizeof out) == -1, "delta -2 invalid"); CHECK(mr_book_adjacent(NULL, 1, out, (int)sizeof out) == -1, "NULL dir"); CHECK(mr_book_adjacent(vol1, 1, NULL, 32) == -1, "NULL out"); CHECK(mr_book_adjacent(vol1, 1, out, 4) == 0, "small out -> no fit (0)"); CHECK(mr_book_adjacent("/no/such/book", 1, out, (int)sizeof out) == 0, "missing parent -> 0"); CHECK(mr_book_adjacent("/", 1, out, (int)sizeof out) == -1, "root path invalid"); } /* A6 文件管理器行模型:扫描即出最终行(无占位/无后台补位/无续读角标); * 目录优先;图片/档案册按类型成行。 */ static void test_library_rows(const char *root) { mr_test_cfg_clear(); char vol1[MR_PATH_MAX]; joinp(vol1, sizeof vol1, root, "vol1"); /* vol1 有续读记录(第 5 页)——书库行不应出现角标。 */ mr_cont_save(vol1, 4, MR_MODE_WEBTOON); mr_library lib; memset(&lib, 0, sizeof lib); int n = mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH); CHECK(n > 0, "library scan n=%d", n); if (n > 0) CHECK(lib.items[0].kind == MR_LIB_KIND_DIR, "dirs first: %s", lib.items[0].name); int seen1 = 0, seen_img = 0; for (int i = 0; i < lib.count; i++) { const mr_library_entry *e = &lib.items[i]; CHECK(e->subtitle[0] && strcmp(e->subtitle, "…") != 0, "%s subtitle pre-generated: %s", e->name, e->subtitle); CHECK(strstr(e->subtitle, "续读") == NULL, "%s has no badge: %s", e->name, e->subtitle); if (strcmp(e->name, "vol1") == 0) { seen1 = 1; CHECK(e->kind == MR_LIB_KIND_DIR, "vol1 is a dir row"); } if (strcmp(e->name, "loose.png") == 0) { seen_img = 1; CHECK(e->kind == MR_LIB_KIND_IMAGE, "loose.png is an image row"); } } CHECK(seen1 == 1, "vol1 listed"); CHECK(seen_img == 1, "loose image listed"); 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"; char *root = mkdtemp(tmpl); CHECK(root != NULL, "mkdtemp"); if (!root) return 1; char p[MR_PATH_MAX]; const char *books[] = { "vol1", "vol2", "vol10", "series/a", "series/b", "dirs_only/inside" }; for (unsigned i = 0; i < sizeof books / sizeof books[0]; i++) { joinp(p, sizeof p, root, books[i]); CHECK(mkdir_p(p) == 0, "mkdir %s", p); char img[MR_PATH_MAX]; joinp(img, sizeof img, p, "001.png"); CHECK(touch_file(img) == 0, "image %s", img); } joinp(p, sizeof p, root, "empty"); mkdir_p(p); joinp(p, sizeof p, root, "txonly"); mkdir_p(p); joinp(p, sizeof p, root, "txonly/readme.txt"); touch_file(p); joinp(p, sizeof p, root, ".hidden"); mkdir_p(p); joinp(p, sizeof p, root, ".hidden/001.png"); touch_file(p); joinp(p, sizeof p, root, "MeowOS"); mkdir_p(p); joinp(p, sizeof p, root, "MeowOS/001.png"); touch_file(p); joinp(p, sizeof p, root, "loose.png"); touch_file(p); char vol1[MR_PATH_MAX], vol0[MR_PATH_MAX]; joinp(vol1, sizeof vol1, root, "vol1"); joinp(vol0, sizeof vol0, root, "vol0"); if (symlink(vol1, vol0) == 0) { /* 有权限才测;无权限时 symlink 失败也不影响上面的断言 */ } 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); return g_fail ? 1 : 0; }