Files
MeowOS-SDK/tuku/test/test_cont_adjacent.c
T
AskaEth 4befbfe14c 图库: 书库点图改按文件名定位(修 P1 截断错位)+ 册间衔接根锁定(M18 评审 r1)
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;交叉构建零警告。
2026-11-05 20:26:40 +08:00

324 lines
13 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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;
}