feat(reader): M33 目录模式逐级下钻 — 子目录/书分层显示(416 本不再平铺)

- folder 模式从「按第一级目录分段」改为当前目录视图:子目录在前(带子树书数),
  直接书在后;进入/返回只过滤已扫平的 g_books,不 readdir、不重扫(M32 索引不动)。
- 纯函数 shelf_build_rows_at(books,n,folder,cwd,...) + nav 工具(enter/up/breadcrumb/
  count)全部宿主可测;format 模式行序列/副题逐字不变,path 仍是完整路径(进度键不变)。
- 交互:点目录进入;顶栏返回箭头/左缘返回手势回上一级,根层再返回退出 App;顶栏标题=
  当前目录名(按像素宽截断),副题行=面包屑全路径+当前层书数;list/grid 均支持导航。
- 状态:folder↔format、list↔grid 切换保留 cwd;刷新重扫后 cwd 失效则收缩到有书的祖先。
- 边界:空目录不显示(导航只读 g_books,不额外 readdir);深层目录逐级进入;真实名为
  「未分类」的目录按普通目录处理,不与根层书合并。
- 测试:shelf_group_smoke 63→99 断言;M27 的按第一级分组断言改写为「逐级进入后可见该书」
  (同等强度),新增进入/返回/面包屑/计数/空目录/纯内存/rowcap/UTF-8 截断断言。
- 交付 0.1.7/code8(M32 已占用任务书里的 0.1.6/code7);真机 0.1.4 未被覆盖。
This commit is contained in:
AskaEth committed 2026-11-10 19:26:44 +08:00
1 parent 190041cd67
commit 2881ce908a
7 files changed
+609 -141

No files matched your search

+174 -24
View File
@@ -1,18 +1,21 @@
/* test/shelf_group_smoke.c — M27 书架目录分组宿主单测(纯 C,gcc 直接跑,不依赖设备/SD 卡)。
/* test/shelf_group_smoke.c — M27/M33 书架目录模型宿主单测(纯 C,gcc 直接跑,不依赖设备/SD 卡)。
*
* 覆盖:
* G1 扫描:格式识别 / 标题去扩展名 / 目录归属(group=完整相对路径,top=第一级目录)
* G2 format 模式 = 改动前行为(格式段头 TXT/FB2/EPUB/MOBI/CBZ,最近阅读优先、其次书名)
* G3 folder 模式:按 Books/ 第一级目录分段(未分类置顶,其余按组名排序),
* 更深子路径(分类/作品)进副题;a/b 与 a/c 同组 a
* G4 空目录不产生段头;depth 3/4 收录,depth > SHELF_SCAN_MAX_DEPTH 计数且不出书
* G5 Books/ 根下的书归「未分类」;字面名为「未分类」的目录与根组是两个不同键
* G3(M33 改写)folder 模式 = 逐级进入:根层只列子目录(排序/计数)+ 根下书;
* 进 400+ → 01、玄幻 → 01、斗破 才能看到书(不再把深层书平铺在顶级)
* G4 空目录不产生导航行;depth 3/4 收录,depth > SHELF_SCAN_MAX_DEPTH 计数且不出书
* G5.1(M33 改写)a/b 与 a/c 合并为目录 a(2),逐级进入后可见书 1/2;
* 组件边界(a 不含 ab/c);G5.2 字面名「未分类」的目录与根层书是两个独立行
* G6 UTF-8 安全截断(中文目录名不切出半个字符)
* G7 超上限不静默:truncated=1(含「cap 卡在目录边界」与「恰好装满不误报」两种);
* 行数上限(段头 ≤ 书数 ⇒ 2N 必够)
* G8 进度不受分组影响:path 不变、rdprog_load 前后同值、ts 两种模式一致
* G9 副题格式化的唯一实现 shelf_fmt_sub:扫描与「刚读完置顶」共用
* (ts 变化后重算 → 「· 读」立即出现;folder 子路径前缀随 group 重建)
* G10(M33)导航纯函数:虚拟目录(磁盘不存在)也能出行序列(不 readdir);
* 进出/面包屑/目录名/rowcap/UTF-8 截断
*/
#include "shelf_model.h"
#include "progress.h"
@@ -61,14 +64,17 @@ static uint64_t ts_of(const char *path)
return 0;
}
/* 把行序列打成一串便于整体比对:S:<段头>|B:<书名>(<目录>)| */
/* 把行序列打成一串便于整体比对:S:<段头>|D:<目录>(<书数>)|B:<书名>(<目录>)| */
static void dump_rows(const shelf_row *rows, int nrows, const shelf_book *books,
char *out, int outsz)
{
int off = 0;
out[0] = 0;
for (int i = 0; i < nrows && off < outsz; i++) {
if (rows[i].section) {
if (rows[i].dir) {
off += snprintf(out + off, (size_t)(outsz - off), "D:%s(%d)|",
rows[i].name, rows[i].count);
} else if (rows[i].section) {
const char *label = rows[i].label ? rows[i].label : shelf_fmt_name(rows[i].fmt);
off += snprintf(out + off, (size_t)(outsz - off), "S:%s|", label);
} else {
@@ -192,19 +198,79 @@ static void test_scan_and_modes(const char *root, const char *tmproot)
"format 模式段头/顺序与旧行为一致");
CHECK(nf == 11, "format 行数 = 3 段头 + 8 书");
/* G3 folder 模式:按第一级目录分组(未分类置顶 → 400+ → B → D → F2 → kh → 轻小说) */
/* G3(M33 改写)folder 模式 = Books 根层导航:子目录在前(排序、带子树书数),
* 根下书在后。M27 的「第一级目录段头」语义已被逐级进入取代(断言同等甚至更强:
* 不仅可见该书,还锁定了它所在的目录层级与计数)。 */
int nd = shelf_build_rows(books, n, 1, rows, 128);
dump_rows(rows, nd, books, got, sizeof got);
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 书");
"D:400+本高质量完本合集(1)|D:B(1)|D:D(1)|D:F2(1)|D:kh(1)|D:轻小说(2)|B:根下()|") == 0,
"folder 根层:子目录在前(排序+子树书数),根下书在后");
CHECK(nd == 7, "folder 根层行数 = 6 目录 + 1 根下书");
/* G4 空目录不产生段头(C 不在序列里) */
CHECK(strstr(got, "S:C|") == NULL, "空目录 C 无段头");
/* G4 空目录不产生导航行(C 不在序列里) */
CHECK(strstr(got, "D:C") == NULL, "空目录 C 不出现在根层");
/* G3.1 逐级进入:400+ 系列(三级分类/作品/书名)不再把 1 本书平铺在顶级,
* 而是每层只看到下一层目录;最终进入作品目录才看到书。 */
char cwd[SHELF_GROUP_MAX], next[SHELF_GROUP_MAX];
cwd[0] = 0;
CHECK(shelf_nav_count_under(books, n, "") == 8, "根层子树共 8 本");
CHECK(shelf_nav_count_under(books, n, "轻小说") == 2, "轻小说子树 2 本");
CHECK(shelf_nav_count_under(books, n, "400+本高质量完本合集") == 1, "400+ 子树 1 本");
CHECK(shelf_nav_count_under(books, n, "不存在") == 0, "不存在的目录 0 本");
CHECK(shelf_nav_enter("", "400+本高质量完本合集", next, sizeof next) == 1 &&
strcmp(next, "400+本高质量完本合集") == 0, "从根进入 400+ 目录");
snprintf(cwd, sizeof cwd, "%s", next);
int n1 = shelf_build_rows_at(books, n, 1, cwd, rows, 128);
dump_rows(rows, n1, books, got, sizeof got);
CHECK(strcmp(got, "D:01、玄幻(1)|") == 0,
"进 400+ → 只看到分类目录 01、玄幻(不是 1 本书平铺)");
snprintf(next, sizeof next, "%s", cwd);
CHECK(shelf_nav_enter(next, "01、玄幻", cwd, sizeof cwd) == 1, "进入 01、玄幻");
int n2 = shelf_build_rows_at(books, n, 1, cwd, rows, 128);
dump_rows(rows, n2, books, got, sizeof got);
CHECK(strcmp(got, "D:01、斗破(1)|") == 0, "进 01、玄幻 → 看到作品目录 01、斗破");
snprintf(next, sizeof next, "%s", cwd);
CHECK(shelf_nav_enter(next, "01、斗破", cwd, sizeof cwd) == 1, "进入 01、斗破");
int n3 = shelf_build_rows_at(books, n, 1, cwd, rows, 128);
dump_rows(rows, n3, books, got, sizeof got);
CHECK(strcmp(got, "B:《斗破》(400+本高质量完本合集/01、玄幻/01、斗破)|") == 0,
"进 01、斗破 → 看到书《斗破》(导航后可见该书)");
CHECK(n3 == 1 && rows[0].book >= 0 && strcmp(books[rows[0].book].title, "《斗破》") == 0,
"书行指向正确的 books[] 下标(点书能打开正确路径)");
/* 返回逐级正确(与左缘手势/顶栏返回箭头同一函数) */
CHECK(shelf_nav_up(cwd, next, sizeof next) == 1 &&
strcmp(next, "400+本高质量完本合集/01、玄幻") == 0, "返回一级:01、斗破 → 01、玄幻");
CHECK(shelf_nav_up(next, cwd, sizeof cwd) == 1 &&
strcmp(cwd, "400+本高质量完本合集") == 0, "再返回:01、玄幻 → 400+");
CHECK(shelf_nav_up(cwd, next, sizeof next) == 1 && next[0] == 0, "再返回:400+ → Books 根");
CHECK(shelf_nav_up("", next, sizeof next) == 0 && next[0] == 0, "根层再返回 = 不动作(退出交给 App)");
/* 扁平目录直接出书;只有一层子目录没有书时仍显示该子目录 */
int nq = shelf_build_rows_at(books, n, 1, "轻小说", rows, 128);
dump_rows(rows, nq, books, got, sizeof got);
CHECK(strcmp(got, "B:1(轻小说)|B:2(轻小说)|") == 0, "轻小说(扁平)→ 直接看到 2 本书");
int nb = shelf_build_rows_at(books, n, 1, "B", rows, 128);
dump_rows(rows, nb, books, got, sizeof got);
CHECK(strcmp(got, "D:sub(1)|") == 0, "B 层没有书、只有子目录 sub → 仍显示 sub");
int nbs = shelf_build_rows_at(books, n, 1, "B/sub", rows, 128);
dump_rows(rows, nbs, books, got, sizeof got);
CHECK(strcmp(got, "B:5(B/sub)|") == 0, "继续进入 B/sub → 看到书 5");
/* 面包屑 / 目录名文案 */
char bc[64], dn[64];
CHECK(shelf_breadcrumb("", bc, sizeof bc) == 6 && strcmp(bc, "Books/") == 0,
"根层面包屑 = Books/");
CHECK(shelf_breadcrumb("轻小说", bc, sizeof bc) == 15 &&
strcmp(bc, "Books/轻小说") == 0, "子目录面包屑 = Books/<cwd>");
CHECK(shelf_dir_name("", dn, sizeof dn) == 0, "根层无目录名");
CHECK(shelf_dir_name("400+本高质量完本合集/01、玄幻", dn, sizeof dn) > 0 &&
strcmp(dn, "01、玄幻") == 0, "目录名取最后一级");
/* G7 行数上限:段头 ≤ 书数,2N 足够;rowcap 小时只写 rowcap 行 */
CHECK(SHELF_MAX_ROWS >= SHELF_MAX_BOOKS * 2, "SHELF_MAX_ROWS ≥ 2×书上限");
@@ -249,7 +315,9 @@ 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.1 a/b 与 a/c 必须归同一组 a(不是两个段头) */
/* G5.1(M33 改写)a/b 与 a/c 在根层合并为同一个目录行 a(计数=2);
* 进入 a 才分别看到 b、c,进入 b/c 才看到书——旧断言「同属组 a」被换成
* 「导航后可见该书」,并额外锁定目录计数。 */
char root3[400];
snprintf(root3, sizeof root3, "%s/mini2", tmproot);
snprintf(p, sizeof p, "%s/a/b", root3); mkdirs(p);
@@ -259,10 +327,19 @@ static void test_scan_and_modes(const char *root, const char *tmproot)
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);
int nd2 = shelf_build_rows_at(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(strcmp(got, "D:a(2)|") == 0, "a/b 与 a/c 在根层合并为目录 a(计数 2)");
CHECK(nd2 == 1, "根层只有 1 行目录(不再按书平铺)");
int nd2a = shelf_build_rows_at(mb2, nm2, 1, "a", rows, 128);
dump_rows(rows, nd2a, mb2, got, sizeof got);
CHECK(strcmp(got, "D:b(1)|D:c(1)|") == 0, "进入 a → 子目录 b、c 各 1 本");
int nd2b = shelf_build_rows_at(mb2, nm2, 1, "a/b", rows, 128);
dump_rows(rows, nd2b, mb2, got, sizeof got);
CHECK(strcmp(got, "B:1(a/b)|") == 0, "进入 a/b → 导航后可见书 1");
int nd2c = shelf_build_rows_at(mb2, nm2, 1, "a/c", rows, 128);
dump_rows(rows, nd2c, mb2, got, sizeof got);
CHECK(strcmp(got, "B:2(a/c)|") == 0, "进入 a/c → 导航后可见书 2");
CHECK(find_book(mb2, nm2, "1") && strstr(find_book(mb2, nm2, "1")->sub_folder, "b · TXT") != NULL,
"a/b 的 folder 副题含 b");
CHECK(find_book(mb2, nm2, "2") && strstr(find_book(mb2, nm2, "2")->sub_folder, "c · TXT") != NULL,
@@ -270,7 +347,24 @@ static void test_scan_and_modes(const char *root, const char *tmproot)
CHECK(find_book(mb2, nm2, "1") && strcmp(find_book(mb2, nm2, "1")->sub, "TXT · 1B") == 0,
"a/b 的 format 副题仍与 base 一致");
/* G5.2 字面名「未分类」的目录与根组是两个独立键(不按显示名合并) */
/* G5.1b 组件边界:a 的子树不包含 ab/c(a/b 与 ab/c 不能因前缀混淆) */
shelf_book bnd[3];
memset(bnd, 0, sizeof bnd);
snprintf(bnd[0].group, sizeof bnd[0].group, "a/b");
snprintf(bnd[0].title, sizeof bnd[0].title, "one");
snprintf(bnd[1].group, sizeof bnd[1].group, "ab/c");
snprintf(bnd[1].title, sizeof bnd[1].title, "two");
snprintf(bnd[2].group, sizeof bnd[2].group, "a");
snprintf(bnd[2].title, sizeof bnd[2].title, "three");
bnd[0].fmt = bnd[1].fmt = bnd[2].fmt = 0;
int nbnd = shelf_build_rows_at(bnd, 3, 1, "a", rows, 128);
dump_rows(rows, nbnd, bnd, got, sizeof got);
CHECK(strcmp(got, "D:b(1)|B:three(a)|") == 0,
"cwd=a 只含 a/b(不含 ab/c)且列出直接书 three");
CHECK(shelf_nav_count_under(bnd, 3, "a") == 2, "count_under(a)=2(a/b + 直接书,不含 ab/c)");
/* G5.2(M33 改写)字面名「未分类」是真实目录,与根层书是两个不同实体:
* 根层同时出现目录行「未分类」和书 x;进入该目录看到 7(旧断言不合并 → 同等强度)。 */
char root2[400], mini[420];
snprintf(root2, sizeof root2, "%s/mini", tmproot);
snprintf(mini, sizeof mini, "%s/未分类", root2);
@@ -280,10 +374,16 @@ static void test_scan_and_modes(const char *root, const char *tmproot)
shelf_book mb[8];
shelf_scan_report rep4;
int nm = shelf_scan_books(root2, mb, 8, NULL, &rep4);
int ndm = shelf_build_rows(mb, nm, 1, rows, 128);
int ndm = shelf_build_rows_at(mb, nm, 1, "", rows, 128);
dump_rows(rows, ndm, mb, got, sizeof got);
CHECK(strcmp(got, "S:未分类|B:x()|S:未分类|B:7(未分类)|") == 0,
"根组与名为「未分类」的目录不合并(两个段头)");
CHECK(strcmp(got, "D:未分类(1)|B:x()|") == 0,
"根层书 x 与名为「未分类」的真实目录并存(不按显示名合并)");
CHECK(ndm == 2 && rows[0].dir && strcmp(rows[0].name, "未分类") == 0 &&
!rows[1].dir && rows[1].book >= 0 && strcmp(mb[rows[1].book].title, "x") == 0,
"目录行「未分类」与根层书 x 是两个独立行");
int ndm2 = shelf_build_rows_at(mb, nm, 1, "未分类", rows, 128);
dump_rows(rows, ndm2, mb, got, sizeof got);
CHECK(strcmp(got, "B:7(未分类)|") == 0, "进入「未分类」目录 → 看到书 7(未被当特殊值吞掉)");
/* G6 UTF-8 安全截断 */
char src[200], dst[96];
@@ -341,6 +441,55 @@ static void test_fmt_sub(void)
"一级目录书无子路径前缀");
}
/* G10(M33):导航纯函数——只在 books[].group 上做前缀过滤,目录在磁盘上**不存在**
* 也能出正确行序列(证明导航不引入 opendir/readdir/stat)。 */
static void test_nav_pure(void)
{
shelf_book fake[4];
memset(fake, 0, sizeof fake);
snprintf(fake[0].group, sizeof fake[0].group, "ghost/a/b");
snprintf(fake[0].title, sizeof fake[0].title, "A");
snprintf(fake[1].group, sizeof fake[1].group, "ghost/a/c");
snprintf(fake[1].title, sizeof fake[1].title, "B");
snprintf(fake[2].group, sizeof fake[2].group, "ghost/d");
snprintf(fake[2].title, sizeof fake[2].title, "C");
snprintf(fake[3].group, sizeof fake[3].group, "ghost");
snprintf(fake[3].title, sizeof fake[3].title, "D");
for (int i = 0; i < 4; i++) fake[i].fmt = 0;
shelf_row rows[16];
char got[512];
int n0 = shelf_build_rows_at(fake, 4, 1, "", rows, 16);
dump_rows(rows, n0, fake, got, sizeof got);
CHECK(strcmp(got, "D:ghost(4)|") == 0, "虚拟目录(磁盘不存在)也能出导航行:根层 ghost(4)");
int n1 = shelf_build_rows_at(fake, 4, 1, "ghost", rows, 16);
dump_rows(rows, n1, fake, got, sizeof got);
CHECK(strcmp(got, "D:a(2)|D:d(1)|B:D(ghost)|") == 0,
"ghost 层:子目录按名排序 + 直接书在后");
int n2 = shelf_build_rows_at(fake, 4, 1, "ghost/a", rows, 16);
dump_rows(rows, n2, fake, got, sizeof got);
CHECK(strcmp(got, "D:b(1)|D:c(1)|") == 0, "ghost/a 层:b、c 各 1 本");
int ncap = shelf_build_rows_at(fake, 4, 1, "ghost", rows, 2);
CHECK(ncap == 2, "rowcap=2 时只写 2 行且不越界");
/* 面包屑超长:UTF-8 安全、保留末尾、前缀 "…" */
char longcwd[256], out[32];
int k = 0;
for (int i = 0; i < 60; i++) { memcpy(longcwd + k, "轻", 3); k += 3; }
longcwd[k] = 0;
int bn = shelf_breadcrumb(longcwd, out, (int)sizeof out);
CHECK(bn > 0 && bn < (int)sizeof out && utf8_complete(out), "超长面包屑仍为合法 UTF-8 且不越界");
CHECK(strncmp(out, "…", 3) == 0, "超长面包屑以省略号前缀保留当前目录末尾");
/* 进入目录超长:拒绝且不写 out */
char tiny[8] = "keep";
CHECK(shelf_nav_enter("abcdefgh", "ij", tiny, (int)sizeof tiny) == 0 &&
strcmp(tiny, "keep") == 0, "拼接超长时 shelf_nav_enter 返回 0 且不改 out");
}
static void test_progress_stability(const char *root)
{
/* G8:写一份进度,切换分组模式前后 rdprog_load 必须同值,path 不变。 */
@@ -427,6 +576,7 @@ int main(void)
test_scan_and_modes(root, tmp);
test_fmt_sub();
test_nav_pure();
test_progress_stability(root);
test_conf_default(tmp);