From 63ead3ec1ebb0172843715ff433e99a68b82219d Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 22:16:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(reader):=20M27=20r2=20=E2=80=94=20=E6=89=AB?= =?UTF-8?q?=E6=8F=8F=E6=B7=B1=E5=BA=A6=E6=8F=90=E5=88=B0=206=20+=20?= =?UTF-8?q?=E6=8C=89=20Books=20=E7=AC=AC=E4=B8=80=E7=BA=A7=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E5=88=86=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机真实布局暴露两处问题(评审 P1): - Books/400+本高质量完本合集/<分类>/<作品>/<书名>.txt 在 depth 3-4,旧 depth≤2 让 413 本 3.8GB 不可见 → SHELF_SCAN_MAX_DEPTH=2→6;report 新增 depth_skipped,超深书籍文件计数并在 g_notice 可见提示 (有 N 个文件因目录层数超过 6 层未显示),不再静默 - 按完整相对路径分组会把 413 本书炸成 410 个每条 1 书的段头 → 分组键改为 Books/ 第一级目录(shelf_book.top);完整相对路径仍在 group[], 第一级之后的子路径(分类/作品)放进副题 sub(如 01、玄幻/01、斗破 · TXT · 2.1MB) → 真机书库只剩 3 组:400+本高质量完本合集 / kh / 轻小说 - 单测 50 断言:depth3/4 收录、depth7 计入 depth_skipped、四种真实路径的组名、 a/b 与 a/c 同组 a、副题含子路径、format 回归、根组未分类、path/进度不变 --- patch_v2/reader_app/app/shelf.c | 12 +- patch_v2/reader_app/app/shelf_model.c | 99 +++++++++---- patch_v2/reader_app/app/shelf_model.h | 33 +++-- patch_v2/reader_app/test/shelf_group_smoke.c | 138 +++++++++++++------ patch_v2/reader_app/test/shelf_scan_bench.c | 11 +- 5 files changed, 211 insertions(+), 82 deletions(-) diff --git a/patch_v2/reader_app/app/shelf.c b/patch_v2/reader_app/app/shelf.c index 1c0e729..1883ac7 100644 --- a/patch_v2/reader_app/app/shelf.c +++ b/patch_v2/reader_app/app/shelf.c @@ -9,7 +9,8 @@ * - 封面:MVP 无封面提取(engine F1);无则圆角占位(surfaceContainerHigh + 格式徽标)。 * * M27 分组(`ui.shelf_group`,默认 format 保持旧行为): - * format = 按格式分段(TXT/FB2/EPUB/MOBI/CBZ),folder = 按 Books/ 下的目录分段(根下=未分类)。 + * format = 按格式分段(TXT/FB2/EPUB/MOBI/CBZ); + * folder = 按 Books/ 下的「第一级目录」分段(根下=未分类),更深子路径(分类/作品)进副题。 * 扫描/行序列在 app/shelf_model.c(纯 C,宿主可测);本文件只做控件装配与绘制。 */ #include "../reader_internal.h" @@ -60,10 +61,19 @@ static void scan_books(void) if (rep.root_missing) { snprintf(g_notice, sizeof g_notice, "未找到 %s 目录,请放入书籍(TXT)", dir); g_notice_ms = 4000; + } else if (rep.truncated && rep.depth_skipped > 0) { + snprintf(g_notice, sizeof g_notice, + "书籍超过 %d 本(只显示前 %d 本);另有 %d 个文件目录超过 %d 层未显示", + SHELF_MAX_BOOKS, SHELF_MAX_BOOKS, rep.depth_skipped, SHELF_SCAN_MAX_DEPTH); + g_notice_ms = 4000; } else if (rep.truncated) { snprintf(g_notice, sizeof g_notice, "书籍超过 %d 本,已只显示前 %d 本", SHELF_MAX_BOOKS, SHELF_MAX_BOOKS); g_notice_ms = 4000; + } else if (rep.depth_skipped > 0) { + snprintf(g_notice, sizeof g_notice, "有 %d 个文件因目录层数超过 %d 层未显示", + rep.depth_skipped, SHELF_SCAN_MAX_DEPTH); + g_notice_ms = 4000; } } diff --git a/patch_v2/reader_app/app/shelf_model.c b/patch_v2/reader_app/app/shelf_model.c index dd5b4d8..fa39cef 100644 --- a/patch_v2/reader_app/app/shelf_model.c +++ b/patch_v2/reader_app/app/shelf_model.c @@ -7,8 +7,6 @@ #include #include -#define SHELF_SCAN_MAX_DEPTH 2 /* 子目录深度上限(Books/a/b 有书,Books/a/b/c 不扫) */ - int shelf_fmt_from_ext(const char *name) { const char *d = strrchr(name, '.'); @@ -69,11 +67,33 @@ typedef struct scan_ctx { int cap; int n; int truncated; + int depth_skipped; shelf_ts_fn ts_fn; } scan_ctx; -/* 递归收集:rel = 相对 root 的目录路径(root 自身为 "")。 */ -static void scan_dir(scan_ctx *c, const char *dir, int depth, const char *rel) +/* 已超过深度上限的目录:只数其中还能识别的书籍文件,不出书(不静默跳过)。 + * 自身再留 8 层硬上限,防符号链接/异常深目录把计数也拖死。 */ +static void count_books_under(scan_ctx *c, const char *dir, int depth) +{ + if (depth > SHELF_SCAN_MAX_DEPTH + 8) return; + DIR *dp = opendir(dir); + if (!dp) return; + struct dirent *de; + while ((de = readdir(dp))) { + if (de->d_name[0] == '.') continue; + char path[400]; + if (snprintf(path, sizeof path, "%s/%s", dir, de->d_name) >= (int)sizeof path) continue; + struct stat st; + if (stat(path, &st) != 0) continue; + if (S_ISDIR(st.st_mode)) { count_books_under(c, path, depth + 1); continue; } + if (shelf_fmt_from_ext(de->d_name) >= 0) c->depth_skipped++; + } + closedir(dp); +} + +/* 递归收集。rel = 相对 root 的完整目录路径(root 自身为 ""), + * top = 第一级目录名(root 自身/根下文件为 "")。 */ +static void scan_dir(scan_ctx *c, const char *dir, int depth, const char *rel, const char *top) { if (depth > SHELF_SCAN_MAX_DEPTH) return; /* 防深嵌套/循环 */ if (c->n >= c->cap) return; /* 已满:不再进新目录 */ @@ -87,10 +107,14 @@ static void scan_dir(scan_ctx *c, const char *dir, int depth, const char *rel) struct stat st; if (stat(path, &st) != 0) continue; if (S_ISDIR(st.st_mode)) { - char subrel[SHELF_GROUP_MAX * 4]; - if (snprintf(subrel, sizeof subrel, "%s%s%s", rel, rel[0] ? "/" : "", de->d_name) - >= (int)sizeof subrel) continue; - scan_dir(c, path, depth + 1, subrel); + if (depth + 1 > SHELF_SCAN_MAX_DEPTH) { + count_books_under(c, path, depth + 1); /* 超深:计数→可见提示 */ + continue; + } + char subrel[SHELF_GROUP_MAX * 16]; + snprintf(subrel, sizeof subrel, "%s%s%s", rel, rel[0] ? "/" : "", de->d_name); + /* 第一级目录名 = top;更深层沿用。de->d_name 在递归返回前有效。 */ + scan_dir(c, path, depth + 1, subrel, depth == 0 ? de->d_name : top); continue; } int f = shelf_fmt_from_ext(de->d_name); @@ -102,13 +126,24 @@ static void scan_dir(scan_ctx *c, const char *dir, int depth, const char *rel) char *dot = strrchr(b->title, '.'); if (dot) *dot = 0; /* 标题去扩展名 */ shelf_copy_utf8(b->group, (int)sizeof b->group, rel); + shelf_copy_utf8(b->top, (int)sizeof b->top, top); b->fmt = f; b->size = (long)st.st_size; b->ts = c->ts_fn ? c->ts_fn(b->path) : 0; char sz[24]; shelf_fmt_size(sz, sizeof sz, b->size); - snprintf(b->sub, sizeof b->sub, "%s · %s%s", shelf_fmt_name(f), sz, - b->ts ? " · 读" : ""); + /* 副题:第一级之后的子路径(分类/作品)· 格式 · 大小 [· 读] */ + const char *rest = strchr(rel, '/'); + if (rest) rest++; + if (rest && rest[0]) { + char pre[100]; + shelf_copy_utf8(pre, (int)sizeof pre, rest); + snprintf(b->sub, sizeof b->sub, "%s · %s · %s%s", pre, shelf_fmt_name(f), sz, + b->ts ? " · 读" : ""); + } else { + snprintf(b->sub, sizeof b->sub, "%s · %s%s", shelf_fmt_name(f), sz, + b->ts ? " · 读" : ""); + } c->n++; } closedir(dp); @@ -117,7 +152,7 @@ static void scan_dir(scan_ctx *c, const char *dir, int depth, const char *rel) int shelf_scan_books(const char *root, shelf_book *books, int cap, shelf_ts_fn ts_fn, shelf_scan_report *rep) { - if (rep) { rep->nbooks = 0; rep->truncated = 0; rep->root_missing = 0; } + if (rep) { rep->nbooks = 0; rep->truncated = 0; rep->depth_skipped = 0; rep->root_missing = 0; } if (!root || !books || cap <= 0) { if (rep) rep->root_missing = 1; return 0; @@ -130,22 +165,40 @@ int shelf_scan_books(const char *root, shelf_book *books, int cap, closedir(dp); scan_ctx c; - c.books = books; c.cap = cap; c.n = 0; c.truncated = 0; c.ts_fn = ts_fn; - scan_dir(&c, root, 0, ""); + c.books = books; c.cap = cap; c.n = 0; c.truncated = 0; c.depth_skipped = 0; c.ts_fn = ts_fn; + scan_dir(&c, root, 0, "", ""); qsort(books, (size_t)c.n, sizeof(shelf_book), cmp_recent_then_name); - if (rep) { rep->nbooks = c.n; rep->truncated = c.truncated; } + if (rep) { + rep->nbooks = c.n; + rep->truncated = c.truncated; + rep->depth_skipped = c.depth_skipped; + } return c.n; } /* ---- 行序列 ---- */ -/* 段头排序:根组(group=="")恒置顶,其余按目录名不区分大小写排序。 */ +/* 分组键 = top(Books 第一级目录名)。长度截到第一个 '/',排序时忽略大小写。 */ +static int top_len(const char *s) +{ + const char *p = strchr(s, '/'); + return p ? (int)(p - s) : (int)strlen(s); +} +static int top_cmp(const char *a, const char *b) +{ + int la = top_len(a), lb = top_len(b); + if (!la != !lb) return la ? 1 : -1; /* 根组 "" 恒置顶 */ + int m = la < lb ? la : lb; + int c = strncasecmp(a, b, (size_t)m); + if (c) return c; + return la - lb; +} +static int top_eq(const char *a, const char *b) { return top_cmp(a, b) == 0; } + +/* 段头排序:根组(top=="")恒置顶,其余按第一级目录名不区分大小写排序。 */ static int cmp_group_key(const void *pa, const void *pb) { - const char *a = *(const char *const *)pa; - const char *b = *(const char *const *)pb; - if (!a[0] != !b[0]) return a[0] ? 1 : -1; /* "" 在前 */ - return strcasecmp(a, b); + return top_cmp(*(const char *const *)pa, *(const char *const *)pb); } static int build_rows_format(const shelf_book *books, int nbooks, @@ -178,14 +231,14 @@ static int build_rows_format(const shelf_book *books, int nbooks, static int build_rows_folder(const shelf_book *books, int nbooks, shelf_row *rows, int rowcap) { - /* 去重目录键(""=根组)。键是指向 books[].group 的字符串,books 生存期覆盖 rows。 */ + /* 去重分组键(books[].top,""=根组)。键指向 books 内字符串,生存期覆盖 rows。 */ static const char *keys[SHELF_MAX_BOOKS]; int nkeys = 0; for (int i = 0; i < nbooks; i++) { - const char *k = books[i].group; + const char *k = books[i].top; int seen = 0; for (int j = 0; j < nkeys; j++) { - if (strcmp(keys[j], k) == 0) { seen = 1; break; } + if (top_eq(keys[j], k)) { seen = 1; break; } } if (!seen) keys[nkeys++] = k; } @@ -200,7 +253,7 @@ static int build_rows_folder(const shelf_book *books, int nbooks, rows[nrows].label = keys[ki][0] ? keys[ki] : SHELF_GROUP_ROOT; nrows++; for (int i = 0; i < nbooks; i++) { - if (strcmp(books[i].group, keys[ki]) != 0) continue; + if (!top_eq(books[i].top, keys[ki])) continue; if (nrows >= rowcap) break; rows[nrows].section = 0; rows[nrows].fmt = books[i].fmt; diff --git a/patch_v2/reader_app/app/shelf_model.h b/patch_v2/reader_app/app/shelf_model.h index 7e35fdc..403f059 100644 --- a/patch_v2/reader_app/app/shelf_model.h +++ b/patch_v2/reader_app/app/shelf_model.h @@ -3,11 +3,13 @@ * 从 shelf.c 拆出,供 shelf.c(控件装配)、宿主单测(test/shelf_group_smoke.c)与 * 扫描计时(test/shelf_scan_bench.c)共用。 * - * 目录归属(M27):folder 模式按「相对 Books/ 的目录路径」分组,如 "轻小说"、"科幻/长篇"; - * Books/ 根下的书 group="",显示段头 SHELF_GROUP_ROOT。分组键是相对路径(不是目录名), - * 故 Books/a/b 与 Books/b 不会混。 + * 目录归属(M27 r2):folder 模式按「Books/ 下的第一级目录」分组(如 "轻小说"、 + * "400+本高质量完本合集");更深的相对子路径("01、玄幻/01、斗破")进该书的副题, + * 不再每组一本书把书架炸成上百个段头。Books/ 根下的书 top="",显示段头 SHELF_GROUP_ROOT。 + * 分组键是「第一级目录名」,完整相对路径保留在 group[] 供副题/调试用。 + * a/b 与 a/c 同属 a 组;a/b 与 b 仍是两个组(不会因为只取末级目录名而混)。 * - * 硬约束:path 永远是书的完整路径(打开书 + 进度键 rdprog_hash(path)),分组只写 group, + * 硬约束:path 永远是书的完整路径(打开书 + 进度键 rdprog_hash(path)),分组只写 top/group, * 绝不改写 path;两个分组模式间切换不影响进度。 */ #ifndef SHELF_MODEL_H @@ -15,10 +17,14 @@ #include -/* 书籍上限:真实场景 180 本(Books/轻小说),2048 给 11× 余量。 +/* 书籍上限:真机真实书库 646 文件/约 595 本候选,2048 给 3×+ 余量。 * 超限不再静默停扫:report->truncated=1,由 UI 出提示。 */ #define SHELF_MAX_BOOKS 2048 #define SHELF_GROUP_MAX 96 /* 目录字段容量(含 NUL):95 字节 ≈ 31 个汉字 */ +/* 扫描深度上限(root=0;文件所在目录 depth ≤ 该值才会被收录)。 + * 真机真实布局 Books/400+本高质量完本合集/<分类>/<作品>/<书名>.txt 最深 4 层,取 6 留余量。 + * 超过上限的书籍文件计入 report->depth_skipped(不静默跳过)。 */ +#define SHELF_SCAN_MAX_DEPTH 6 /* 行数上限:每本书最多贡献 1 行书 + 1 行段头(段头数 ≤ 书数),故 2N 必够。 */ #define SHELF_MAX_ROWS (SHELF_MAX_BOOKS * 2) #define SHELF_GROUP_ROOT "未分类" /* Books/ 根下书籍的段头 */ @@ -26,8 +32,9 @@ typedef struct shelf_book { char path[400]; /* 完整路径:打开书 + 进度键;分组不得改写 */ char title[128]; - char sub[160]; - char group[SHELF_GROUP_MAX]; /* 相对 Books/ 的目录路径;根下="" */ + char sub[160]; /* 副题:更深子路径 · 格式 · 大小 [· 读] */ + char group[SHELF_GROUP_MAX]; /* 相对 Books/ 的完整目录路径(供副题);根下="" */ + char top[SHELF_GROUP_MAX]; /* 分组键:相对路径的第一级目录名;根下="" */ int fmt; /* 0 txt,1 fb2,2 epub,3 mobi,4 cbz */ long size; uint64_t ts; /* 最近阅读 unix;无=0 */ @@ -37,7 +44,7 @@ typedef struct shelf_row { int section; /* 1=段头行 */ int fmt; /* 段头/书行格式;段头无 label 时显示格式名 */ int book; /* 书行=books 下标;段头=-1 */ - const char *label; /* 段头标题;NULL=格式名(指向 books[].group 或静态串) */ + const char *label; /* 段头标题;NULL=格式名(指向 books[].top 或静态串) */ } shelf_row; /* 进度时间戳查询(生产=rdprog_load;测试/基线=NULL,全部 ts=0)。 */ @@ -46,18 +53,20 @@ typedef uint64_t (*shelf_ts_fn)(const char *path); typedef struct shelf_scan_report { int nbooks; int truncated; /* 1=有书因 SHELF_MAX_BOOKS 上限被丢弃 */ + int depth_skipped; /* 因目录深度 > SHELF_SCAN_MAX_DEPTH 未收录的书籍文件数 */ int root_missing; /* 1=root 打不开(无卡/无 Books 目录) */ } shelf_scan_report; -/* 递归扫描 root(子目录深度 ≤2,与旧行为一致),按 ts 降序/书名升序排序后写 books(容量 cap)。 - * 返回写入本数。目录为空不产生任何记录(段头由 build_rows 按书生成,天然无空段头)。 */ +/* 递归扫描 root(目录深度 ≤ SHELF_SCAN_MAX_DEPTH,超限文件计数但不出书), + * 按 ts 降序/书名升序排序后写 books(容量 cap)。 + * 返回写入本数。空目录不产生任何记录(段头由 build_rows 按书生成,天然无空段头)。 */ int shelf_scan_books(const char *root, shelf_book *books, int cap, shelf_ts_fn ts_fn, shelf_scan_report *rep); /* 生成行序列: * folder_mode=0:按格式分段(TXT/FB2/EPUB/MOBI/CBZ,空格式不出段头)——与 M27 前逐行一致; - * folder_mode=1:先按目录分组,根目录组「未分类」置顶,其余组按目录名排序; - * 组内沿用 books[] 的全局顺序(最近阅读优先、其次书名)。 + * folder_mode=1:按 books[].top(Books/ 第一级目录)分组,根组「未分类」置顶, + * 其余组按组名排序;组内沿用 books[] 全局顺序(最近阅读优先、其次书名)。 * 返回行数;超过 rowcap 时只写 rowcap 行并返回 rowcap。 */ int shelf_build_rows(const shelf_book *books, int nbooks, int folder_mode, shelf_row *rows, int rowcap); diff --git a/patch_v2/reader_app/test/shelf_group_smoke.c b/patch_v2/reader_app/test/shelf_group_smoke.c index 43835dd..bd99674 100644 --- a/patch_v2/reader_app/test/shelf_group_smoke.c +++ b/patch_v2/reader_app/test/shelf_group_smoke.c @@ -1,10 +1,11 @@ /* test/shelf_group_smoke.c — M27 书架目录分组宿主单测(纯 C,gcc 直接跑,不依赖设备/SD 卡)。 * * 覆盖: - * G1 扫描:格式识别 / 标题去扩展名 / 目录归属(相对 Books/ 的路径,根下="") + * G1 扫描:格式识别 / 标题去扩展名 / 目录归属(group=完整相对路径,top=第一级目录) * G2 format 模式 = 改动前行为(格式段头 TXT/FB2/EPUB/MOBI/CBZ,最近阅读优先、其次书名) - * G3 folder 模式:目录段头顺序(未分类置顶,其余按目录名)、组内排序、嵌套目录 B/sub - * G4 空目录不产生段头;深度 >2 的目录不扫(与旧行为一致) + * G3 folder 模式:按 Books/ 第一级目录分段(未分类置顶,其余按组名排序), + * 更深子路径(分类/作品)进副题;a/b 与 a/c 同组 a + * G4 空目录不产生段头;depth 3/4 收录,depth > SHELF_SCAN_MAX_DEPTH 计数且不出书 * G5 Books/ 根下的书归「未分类」;字面名为「未分类」的目录与根组是两个不同键 * G6 UTF-8 安全截断(中文目录名不切出半个字符) * G7 超上限不静默:truncated=1;行数上限(段头 ≤ 书数 ⇒ 2N 必够) @@ -98,67 +99,103 @@ static int utf8_complete(const char *s) static void test_scan_and_modes(const char *root, const char *tmproot) { - /* ---- 造树 ---- - * Books/A/1.txt (ts100), Books/A/2.epub (ts50), Books/B/3.txt, - * Books/4.txt(根), Books/B/sub/5.mobi(深度 2), Books/C/(空), - * Books/A/notes.md(不支持), Books/D/E/F/6.txt(深度 3,不扫) */ + /* ---- 造树(贴合真机真实布局)---- + * Books/轻小说/1.txt (ts100)、Books/轻小说/2.epub (ts50)、 + * Books/kh/3.txt、Books/根下.txt(根)、Books/B/sub/5.mobi(depth2)、 + * Books/D/E/F/6.txt(depth3)、Books/F2/a/b/c/9.txt(depth4)、 + * Books/400+本高质量完本合集/01、玄幻/01、斗破/《斗破》.txt(depth3 真实三级布局)、 + * Books/C/(空)、Books/轻小说/notes.md(不支持)、 + * Books/G/H/I/J/K/L/M/{7,8}.txt(depth7,超上限 → 只计数)。 */ char p[400]; - snprintf(p, sizeof p, "%s/A", root); mkdirs(p); + snprintf(p, sizeof p, "%s/轻小说", root); mkdirs(p); + snprintf(p, sizeof p, "%s/kh", root); mkdirs(p); snprintf(p, sizeof p, "%s/B/sub", root); mkdirs(p); snprintf(p, sizeof p, "%s/C", root); mkdirs(p); snprintf(p, sizeof p, "%s/D/E/F", root); mkdirs(p); - snprintf(p, sizeof p, "%s/A/1.txt", root); write_file(p, "one"); - snprintf(p, sizeof p, "%s/A/2.epub", root); write_file(p, "two"); - snprintf(p, sizeof p, "%s/B/3.txt", root); write_file(p, "three"); - snprintf(p, sizeof p, "%s/4.txt", root); write_file(p, "four"); + snprintf(p, sizeof p, "%s/F2/a/b/c", root); mkdirs(p); + snprintf(p, sizeof p, "%s/400+本高质量完本合集/01、玄幻/01、斗破", root); mkdirs(p); + snprintf(p, sizeof p, "%s/G/H/I/J/K/L/M", root); mkdirs(p); + snprintf(p, sizeof p, "%s/轻小说/1.txt", root); write_file(p, "one"); + snprintf(p, sizeof p, "%s/轻小说/2.epub", root); write_file(p, "two"); + snprintf(p, sizeof p, "%s/kh/3.txt", root); write_file(p, "three"); + snprintf(p, sizeof p, "%s/根下.txt", root); write_file(p, "root"); snprintf(p, sizeof p, "%s/B/sub/5.mobi", root); write_file(p, "five"); - snprintf(p, sizeof p, "%s/A/notes.md", root); write_file(p, "ignored"); - snprintf(p, sizeof p, "%s/D/E/F/6.txt", root); write_file(p, "too-deep"); + snprintf(p, sizeof p, "%s/D/E/F/6.txt", root); write_file(p, "six"); + snprintf(p, sizeof p, "%s/F2/a/b/c/9.txt", root); write_file(p, "nine"); + snprintf(p, sizeof p, "%s/400+本高质量完本合集/01、玄幻/01、斗破/《斗破》.txt", root); write_file(p, "doupo"); + snprintf(p, sizeof p, "%s/轻小说/notes.md", root); write_file(p, "ignored"); + snprintf(p, sizeof p, "%s/G/H/I/J/K/L/M/7.txt", root); write_file(p, "too-deep"); + snprintf(p, sizeof p, "%s/G/H/I/J/K/L/M/8.txt", root); write_file(p, "too-deep2"); shelf_book books[64]; shelf_scan_report rep; int n = shelf_scan_books(root, books, 64, ts_of, &rep); - CHECK(n == 5, "扫描到 5 本(.md 忽略、深度 3 不扫)"); + CHECK(n == 8, "扫描到 8 本(.md 忽略;depth3/4 收录;depth7 不收录)"); CHECK(rep.truncated == 0, "未触发上限"); + CHECK(rep.depth_skipped == 2, "depth7 的 2 个文件计入 depth_skipped(不静默)"); CHECK(rep.root_missing == 0, "root 存在"); - /* G1 目录归属与 path */ - const shelf_book *b1 = find_book(books, n, "1"); - const shelf_book *b2 = find_book(books, n, "2"); - const shelf_book *b3 = find_book(books, n, "3"); - const shelf_book *b4 = find_book(books, n, "4"); - const shelf_book *b5 = find_book(books, n, "5"); - CHECK(b1 && strcmp(b1->group, "A") == 0, "A/1.txt 归属 A"); - CHECK(b2 && strcmp(b2->group, "A") == 0, "A/2.epub 归属 A"); - CHECK(b3 && strcmp(b3->group, "B") == 0, "B/3.txt 归属 B"); - CHECK(b4 && b4->group[0] == 0, "根下 4.txt 目录字段为空"); - CHECK(b5 && strcmp(b5->group, "B/sub") == 0, "嵌套 B/sub/5.mobi 归属 B/sub(相对路径)"); - CHECK(find_book(books, n, "6") == NULL, "深度 3 的 6.txt 不扫"); + /* G1 目录归属(group=完整相对路径;top=第一级分组键)与 path */ + const shelf_book *b1 = find_book(books, n, "1"); + const shelf_book *b2 = find_book(books, n, "2"); + const shelf_book *b3 = find_book(books, n, "3"); + const shelf_book *b4 = find_book(books, n, "根下"); + const shelf_book *b5 = find_book(books, n, "5"); + const shelf_book *b6 = find_book(books, n, "6"); + const shelf_book *b9 = find_book(books, n, "9"); + const shelf_book *bdp = find_book(books, n, "《斗破》"); + CHECK(b1 && strcmp(b1->group, "轻小说") == 0 && strcmp(b1->top, "轻小说") == 0, + "轻小说/1.txt → group/top=轻小说"); + CHECK(b2 && strcmp(b2->top, "轻小说") == 0, "轻小说/2.epub → top=轻小说"); + CHECK(b3 && strcmp(b3->top, "kh") == 0, "kh/3.txt → top=kh(四种情况之三)"); + CHECK(b4 && b4->group[0] == 0 && b4->top[0] == 0, "Books/根下.txt → group/top 空"); + CHECK(b5 && strcmp(b5->group, "B/sub") == 0 && strcmp(b5->top, "B") == 0, + "B/sub/5.mobi → group=B/sub、top=B"); + CHECK(b6 && strcmp(b6->top, "D") == 0, "depth3 的 D/E/F/6.txt 被扫到"); + CHECK(b9 && strcmp(b9->top, "F2") == 0, "depth4 的 F2/a/b/c/9.txt 被扫到"); + CHECK(bdp && strcmp(bdp->top, "400+本高质量完本合集") == 0, + "真实三级布局 → top=400+本高质量完本合集(四种情况之二)"); + CHECK(bdp && strcmp(bdp->group, "400+本高质量完本合集/01、玄幻/01、斗破") == 0, + "完整相对路径保留在 group[]"); + CHECK(find_book(books, n, "7") == NULL && find_book(books, n, "8") == NULL, + "depth7 的文件不产生书"); if (b1) { char want[400]; - snprintf(want, sizeof want, "%s/A/1.txt", root); + snprintf(want, sizeof want, "%s/轻小说/1.txt", root); CHECK(strcmp(b1->path, want) == 0, "path 仍是完整绝对路径"); } - /* G2 format 模式(改动前行为):格式段头 + 最近阅读优先 + 组内/全局书名排序 */ + /* 副题:第一级之后的子路径(分类/作品)必须可见 */ + CHECK(bdp && strstr(bdp->sub, "01、玄幻/01、斗破") != NULL, "副题含 01、玄幻/01、斗破"); + CHECK(b5 && strstr(b5->sub, "sub") != NULL, "副题含 sub"); + CHECK(b3 && strncmp(b3->sub, "TXT", 3) == 0, "一级目录书的副题无多余子路径"); + CHECK(b4 && strncmp(b4->sub, "TXT", 3) == 0, "根下书副题无多余子路径"); + + /* G2 format 模式(改动前行为):格式段头 + 最近阅读优先 + 全局书名排序 */ shelf_row rows[128]; int nf = shelf_build_rows(books, n, 0, rows, 128); - char got[2048]; + char got[4096]; dump_rows(rows, nf, books, got, sizeof got); - CHECK(strcmp(got, "S:TXT|B:1(A)|B:3(B)|B:4()|S:EPUB|B:2(A)|S:MOBI|B:5(B/sub)|") == 0, - "format 模式段头/顺序与旧行为一致"); - CHECK(nf == 8, "format 行数 = 3 段头 + 5 书"); + CHECK(strcmp(got, + "S:TXT|B:1(轻小说)|B:3(kh)|B:6(D/E/F)|B:9(F2/a/b/c)|" + "B:《斗破》(400+本高质量完本合集/01、玄幻/01、斗破)|B:根下()|" + "S:EPUB|B:2(轻小说)|S:MOBI|B:5(B/sub)|") == 0, + "format 模式段头/顺序与旧行为一致"); + CHECK(nf == 11, "format 行数 = 3 段头 + 8 书"); - /* G3 folder 模式:未分类置顶 → A → B → B/sub;组内 ts/书名序 */ + /* G3 folder 模式:按第一级目录分组(未分类置顶 → 400+ → B → D → F2 → kh → 轻小说) */ int nd = shelf_build_rows(books, n, 1, rows, 128); dump_rows(rows, nd, books, got, sizeof got); - CHECK(strcmp(got, "S:未分类|B:4()|S:A|B:1(A)|B:2(A)|S:B|B:3(B)|S:B/sub|B:5(B/sub)|") == 0, - "folder 模式段头顺序/归属正确"); - CHECK(nd == 9, "folder 行数 = 4 段头 + 5 书"); + CHECK(strcmp(got, + "S:未分类|B:根下()|" + "S:400+本高质量完本合集|B:《斗破》(400+本高质量完本合集/01、玄幻/01、斗破)|" + "S:B|B:5(B/sub)|S:D|B:6(D/E/F)|S:F2|B:9(F2/a/b/c)|S:kh|B:3(kh)|" + "S:轻小说|B:1(轻小说)|B:2(轻小说)|") == 0, + "folder 模式按第一级目录分组、段头顺序正确"); + CHECK(nd == 15, "folder 行数 = 7 段头 + 8 书"); - /* G4 空目录不产生段头(C 不在序列里);也无 6(深度 3) */ + /* G4 空目录不产生段头(C 不在序列里) */ CHECK(strstr(got, "S:C|") == NULL, "空目录 C 无段头"); - CHECK(strstr(got, "B:6(") == NULL, "深度 3 的书未出现"); /* G7 行数上限:段头 ≤ 书数,2N 足够;rowcap 小时只写 rowcap 行 */ CHECK(SHELF_MAX_ROWS >= SHELF_MAX_BOOKS * 2, "SHELF_MAX_ROWS ≥ 2×书上限"); @@ -177,7 +214,26 @@ static void test_scan_and_modes(const char *root, const char *tmproot) CHECK(shelf_scan_books(nope, small, 2, NULL, &rep3) == 0 && rep3.root_missing == 1, "根不存在时 root_missing=1"); - /* G5 字面名「未分类」的目录与根组是两个独立键(按相对路径分组,不按显示名合并) */ + /* G5.1 a/b 与 a/c 必须归同一组 a(不是两个段头) */ + char root3[400]; + snprintf(root3, sizeof root3, "%s/mini2", tmproot); + snprintf(p, sizeof p, "%s/a/b", root3); mkdirs(p); + snprintf(p, sizeof p, "%s/a/c", root3); mkdirs(p); + snprintf(p, sizeof p, "%s/a/b/1.txt", root3); write_file(p, "b"); + snprintf(p, sizeof p, "%s/a/c/2.txt", root3); write_file(p, "c"); + shelf_book mb2[8]; + shelf_scan_report rep5; + int nm2 = shelf_scan_books(root3, mb2, 8, NULL, &rep5); + int nd2 = shelf_build_rows(mb2, nm2, 1, rows, 128); + dump_rows(rows, nd2, mb2, got, sizeof got); + CHECK(strcmp(got, "S:a|B:1(a/b)|B:2(a/c)|") == 0, "a/b 与 a/c 同组 a(一个段头)"); + CHECK(nd2 == 3, "a/b + a/c = 1 段头 + 2 书"); + CHECK(find_book(mb2, nm2, "1") && strstr(find_book(mb2, nm2, "1")->sub, "b") != NULL, + "a/b 的副题含 b"); + CHECK(find_book(mb2, nm2, "2") && strstr(find_book(mb2, nm2, "2")->sub, "c") != NULL, + "a/c 的副题含 c"); + + /* G5.2 字面名「未分类」的目录与根组是两个独立键(不按显示名合并) */ char root2[400], mini[420]; snprintf(root2, sizeof root2, "%s/mini", tmproot); snprintf(mini, sizeof mini, "%s/未分类", root2); @@ -210,7 +266,7 @@ static void test_progress_stability(const char *root) { /* G8:写一份进度,切换分组模式前后 rdprog_load 必须同值,path 不变。 */ char path[400]; - snprintf(path, sizeof path, "%s/A/1.txt", root); + snprintf(path, sizeof path, "%s/轻小说/1.txt", root); rdprog p; memset(&p, 0, sizeof p); diff --git a/patch_v2/reader_app/test/shelf_scan_bench.c b/patch_v2/reader_app/test/shelf_scan_bench.c index 55b4a15..9bf0e64 100644 --- a/patch_v2/reader_app/test/shelf_scan_bench.c +++ b/patch_v2/reader_app/test/shelf_scan_bench.c @@ -52,7 +52,7 @@ int main(int argc, char **argv) const char *env = getenv("MEOW_DATA_DIR"); snprintf(g_cfgdir, sizeof g_cfgdir, "%s", (env && env[0]) ? env : "/tmp/m27bench_data"); - int n = 0, rows_f = 0, rows_d = 0, trunc = 0; + int n = 0, rows_f = 0, rows_d = 0, trunc = 0, skipped = 0; int64_t best_scan = INT64_MAX, best_build = INT64_MAX, sum_scan = 0; for (int r = 0; r < reps; r++) { shelf_scan_report rep; @@ -64,15 +64,16 @@ int main(int argc, char **argv) int64_t t2 = now_us(); int64_t s = t1 - t0, b = t2 - t1; trunc = rep.truncated; + skipped = rep.depth_skipped; sum_scan += s; if (s < best_scan) best_scan = s; if (b < best_build) best_build = b; - printf("run %d: scan %lld us, build_rows(format+folder) %lld us, books=%d rows_f=%d rows_d=%d trunc=%d\n", - r + 1, (long long)s, (long long)b, n, rows_f, rows_d, rep.truncated); + printf("run %d: scan %lld us, build_rows(format+folder) %lld us, books=%d rows_f=%d rows_d=%d trunc=%d skip=%d\n", + r + 1, (long long)s, (long long)b, n, rows_f, rows_d, rep.truncated, rep.depth_skipped); } printf("RESULT root=%s mode=%s reps=%d books=%d trunc=%d rows_format=%d rows_folder=%d " - "scan_min=%lldus scan_avg=%lldus build_min=%lldus\n", + "scan_min=%lldus scan_avg=%lldus build_min=%lldus depth_skipped=%d\n", root, use_prog ? "progress" : "noop", reps, n, trunc, rows_f, rows_d, - (long long)best_scan, (long long)(sum_scan / reps), (long long)best_build); + (long long)best_scan, (long long)(sum_scan / reps), (long long)best_build, skipped); return 0; }