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;交叉构建零警告。
324 lines
13 KiB
C
324 lines
13 KiB
C
/* 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 <dirent.h>
|
||
#include <stdio.h>
|
||
#include <stdlib.h>
|
||
#include <string.h>
|
||
#include <sys/stat.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;
|
||
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;
|
||
}
|