feat(reader): M27 书架按目录分组(ui.shelf_group=folder|format,默认 format 不变)

- 新增 shelf_model.c/.h:扫描/行序列从 shelf.c 拆出(纯 C,宿主可测);shelf_book 加 group[96](相对 Books/ 的目录路径,根下空串),path 语义/内容完全不变(进度键仍为 rdprog_hash(path))
- folder 模式:目录段头(根下=未分类),未分类置顶、其余按目录名排序;组内沿用最近阅读优先/其次书名;空目录不产生段头
- format 模式逐行保持旧行为;新增 reader.conf ui.shelf_group,缺键默认 format
- 上限 256→2048(SHELF_MAX_ROWS=2N=4096,段头≤书数),超限置可读提示,不再静默截断
- 顶栏加「目录/格式」切换,设置页加「书架分组」;UTF-8 安全截断长中文目录名
- 单测 test/shelf_group_smoke.c(38 断言)+ 计时 test/shelf_scan_bench.c
- build_reader.sh:链 meow_ui.a 解析冒烟 stbi_*;无 qemu 时跳过执行而非失败
This commit is contained in:
AskaEth committed 2026-11-09 21:55:33 +08:00
1 parent db672cbe8f
commit 409fd1edf7
11 files changed
+800 -129

No files matched your search

@@ -0,0 +1,303 @@
/* test/shelf_group_smoke.c — M27 书架目录分组宿主单测(纯 C,gcc 直接跑,不依赖设备/SD 卡)。
*
* 覆盖:
* G1 扫描:格式识别 / 标题去扩展名 / 目录归属(相对 Books/ 的路径,根下="")
* G2 format 模式 = 改动前行为(格式段头 TXT/FB2/EPUB/MOBI/CBZ,最近阅读优先、其次书名)
* G3 folder 模式:目录段头顺序(未分类置顶,其余按目录名)、组内排序、嵌套目录 B/sub
* G4 空目录不产生段头;深度 >2 的目录不扫(与旧行为一致)
* G5 Books/ 根下的书归「未分类」;字面名为「未分类」的目录与根组是两个不同键
* G6 UTF-8 安全截断(中文目录名不切出半个字符)
* G7 超上限不静默:truncated=1;行数上限(段头 ≤ 书数 ⇒ 2N 必够)
* G8 进度不受分组影响:path 不变、rdprog_load 前后同值、ts 两种模式一致
*/
#include "shelf_model.h"
#include "progress.h"
#include "reader_conf.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
static int g_pass = 0, g_fail = 0;
#define CHECK(cond, msg) do { \
if (cond) g_pass++; \
else { g_fail++; printf("FAIL %s:%d %s\n", __FILE__, __LINE__, msg); } \
} while (0)
/* progress.c 的目录来源:测试里指向临时目录(见 main)。 */
static char g_cfgdir[400];
const char *meow_config_dir(void) { return g_cfgdir; }
static void mkdirs(const char *path)
{
char t[400];
snprintf(t, sizeof t, "%s", path);
for (char *p = t + 1; *p; p++) {
if (*p == '/') { *p = 0; mkdir(t, 0755); *p = '/'; }
}
mkdir(t, 0755);
}
static void write_file(const char *path, const char *body)
{
FILE *f = fopen(path, "wb");
if (!f) { printf("无法创建 %s\n", path); exit(2); }
fputs(body, f);
fclose(f);
}
/* 按文件名给阅读时间:1.txt=100, 2.epub=50, 其它=0(验证 ts 排序 + 组内排序)。 */
static uint64_t ts_of(const char *path)
{
const char *b = strrchr(path, '/');
b = b ? b + 1 : path;
if (strcmp(b, "1.txt") == 0) return 100;
if (strcmp(b, "2.epub") == 0) return 50;
return 0;
}
/* 把行序列打成一串便于整体比对:S:<段头>|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) {
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 {
const shelf_book *b = &books[rows[i].book];
off += snprintf(out + off, (size_t)(outsz - off), "B:%s(%s)|", b->title, b->group);
}
}
}
static const shelf_book *find_book(const shelf_book *books, int n, const char *title)
{
for (int i = 0; i < n; i++) if (strcmp(books[i].title, title) == 0) return &books[i];
return NULL;
}
static int utf8_complete(const char *s)
{
const unsigned char *p = (const unsigned char *)s;
while (*p) {
int n;
if (*p < 0x80) n = 1;
else if ((*p & 0xE0) == 0xC0) n = 2;
else if ((*p & 0xF0) == 0xE0) n = 3;
else if ((*p & 0xF8) == 0xF0) n = 4;
else return 0;
for (int i = 1; i < n; i++) if (!p[i] || (p[i] & 0xC0) != 0x80) return 0;
p += n;
}
return 1;
}
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,不扫) */
char p[400];
snprintf(p, sizeof p, "%s/A", 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/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");
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(rep.truncated == 0, "未触发上限");
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 不扫");
if (b1) {
char want[400];
snprintf(want, sizeof want, "%s/A/1.txt", root);
CHECK(strcmp(b1->path, want) == 0, "path 仍是完整绝对路径");
}
/* G2 format 模式(改动前行为):格式段头 + 最近阅读优先 + 组内/全局书名排序 */
shelf_row rows[128];
int nf = shelf_build_rows(books, n, 0, rows, 128);
char got[2048];
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 书");
/* G3 folder 模式:未分类置顶 → A → B → B/sub;组内 ts/书名序 */
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 书");
/* G4 空目录不产生段头(C 不在序列里);也无 6(深度 3) */
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×书上限");
int nc = shelf_build_rows(books, n, 1, rows, 3);
CHECK(nc == 3, "rowcap=3 时返回 3(不越界写)");
/* G7 超上限可见:cap=2 时 truncated=1 */
shelf_book small[2];
shelf_scan_report rep2;
int ns = shelf_scan_books(root, small, 2, ts_of, &rep2);
CHECK(ns == 2 && rep2.truncated == 1, "超上限时 truncated=1(不静默)");
/* 不存在的根:root_missing=1 */
shelf_scan_report rep3;
char nope[400]; snprintf(nope, sizeof nope, "%s/no_such_dir", tmproot);
CHECK(shelf_scan_books(nope, small, 2, NULL, &rep3) == 0 && rep3.root_missing == 1,
"根不存在时 root_missing=1");
/* G5 字面名「未分类」的目录与根组是两个独立键(按相对路径分组,不按显示名合并) */
char root2[400], mini[420];
snprintf(root2, sizeof root2, "%s/mini", tmproot);
snprintf(mini, sizeof mini, "%s/未分类", root2);
mkdirs(mini);
snprintf(p, sizeof p, "%s/x.txt", root2); write_file(p, "x");
snprintf(p, sizeof p, "%s/未分类/7.txt", root2); write_file(p, "seven");
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);
dump_rows(rows, ndm, mb, got, sizeof got);
CHECK(strcmp(got, "S:未分类|B:x()|S:未分类|B:7(未分类)|") == 0,
"根组与名为「未分类」的目录不合并(两个段头)");
/* G6 UTF-8 安全截断 */
char src[200], dst[96];
int k = 0;
for (int i = 0; i < 40; i++) { memcpy(src + k, "轻", 3); k += 3; }
src[k] = 0; /* 120 字节 */
int ncpy = shelf_copy_utf8(dst, 96, src);
CHECK(ncpy == 93 && strlen(dst) == 93, "长中文目录名截到 93 字节(31 字,整字丢弃)");
CHECK(strncmp(dst, src, 93) == 0 && utf8_complete(dst), "截断后仍是合法 UTF-8 前缀");
CHECK(shelf_copy_utf8(dst, 3, "轻x") == 0 && dst[0] == 0, "cap=3 放不下 3 字节汉字时整段丢弃");
CHECK(shelf_copy_utf8(dst, 4, "轻x") == 3 && strcmp(dst, "轻") == 0, "cap=4 恰好放下一个汉字");
char ascii[128]; memset(ascii, 'a', 100); ascii[100] = 0;
CHECK(shelf_copy_utf8(dst, 96, ascii) == 95, "ASCII 90+ 字节按 cap-1 截断");
}
static void test_progress_stability(const char *root)
{
/* G8:写一份进度,切换分组模式前后 rdprog_load 必须同值,path 不变。 */
char path[400];
snprintf(path, sizeof path, "%s/A/1.txt", root);
rdprog p;
memset(&p, 0, sizeof p);
p.valid = 1; p.seg = 7; p.ch = 123; p.fs = 22; p.ls = 140; p.enc = 1; p.ts = 100;
CHECK(rdprog_save(path, &p) == 0, "写入测试进度");
shelf_book books[64];
shelf_scan_report rep;
int n = shelf_scan_books(root, books, 64, ts_of, &rep);
const shelf_book *b = find_book(books, n, "1");
CHECK(b && strcmp(b->path, path) == 0, "扫描后 path 未变");
CHECK(b && b->ts == 100, "扫描用 rdprog_load 的时间戳");
/* 快照所有 path,跑两种模式的 build_rows,再逐条比对。 */
char before[64][400];
for (int i = 0; i < n; i++) snprintf(before[i], sizeof before[i], "%s", books[i].path);
shelf_row rows[128];
shelf_build_rows(books, n, 0, rows, 128);
rdprog after_f;
int rf = rdprog_load(path, &after_f);
shelf_build_rows(books, n, 1, rows, 128);
rdprog after_d;
int rd = rdprog_load(path, &after_d);
CHECK(rf == 1 && rd == 1, "两种模式下进度都存在");
CHECK(after_f.seg == p.seg && after_f.ch == p.ch && after_f.fs == p.fs &&
after_f.ls == p.ls && after_f.enc == p.enc && after_f.ts == p.ts &&
after_d.seg == p.seg && after_d.ch == p.ch && after_d.ts == p.ts,
"切换 format/folder 后 rdprog_load 同值");
int same = 1;
for (int i = 0; i < n; i++) if (strcmp(before[i], books[i].path) != 0) same = 0;
CHECK(same, "build_rows 前后所有 path 不变(进度键稳定)");
/* 两种模式下 ts 一致(排序输入相同) */
shelf_book books2[64];
int n2 = shelf_scan_books(root, books2, 64, ts_of, &rep);
const shelf_book *b2 = find_book(books2, n2, "1");
CHECK(n2 == n && b2 && b2->ts == 100, "重扫(另一模式无差别)ts 一致");
}
static void test_conf_default(const char *tmproot)
{
/* 旧用户 reader.conf 没有 ui.shelf_group:必须默认 format(书架不变样),persist 后补写 format。 */
char cpath[400];
snprintf(cpath, sizeof cpath, "%s/reader.conf", tmproot); /* g_cfgdir=<tmp>/data → base=<tmp> */
write_file(cpath, "ui.shelf_layout=list\nrd.fs=20\nrd.ls=130\n");
const rdconf *c = rdconf_get();
CHECK(c && strcmp(c->shelf_group, "format") == 0, "无 ui.shelf_group 键 → 默认 format");
CHECK(c && strcmp(c->shelf, "list") == 0 && c->fs == 20, "其它配置项照常读取");
CHECK(rdconf_persist() == 0, "reader.conf 写回成功");
FILE *f = fopen(cpath, "rb");
CHECK(f != NULL, "写回后可读");
if (f) {
char buf[1024];
size_t n = fread(buf, 1, sizeof buf - 1, f);
fclose(f);
buf[n] = 0;
CHECK(strstr(buf, "ui.shelf_group=format\n") != NULL, "写回文件含 ui.shelf_group=format");
}
}
int main(void)
{
const char *base = getenv("TMPDIR");
if (!base || !base[0]) base = "/tmp";
char tmpl[400];
snprintf(tmpl, sizeof tmpl, "%s/shelf_group_XXXXXX", base);
char *tmp = mkdtemp(tmpl);
if (!tmp) { printf("mkdtemp 失败\n"); return 2; }
snprintf(g_cfgdir, sizeof g_cfgdir, "%s/data", tmp); /* progress/reader.conf 落临时目录 */
mkdirs(g_cfgdir);
char root[400];
snprintf(root, sizeof root, "%s/Books", tmp);
mkdirs(root);
test_scan_and_modes(root, tmp);
test_progress_stability(root);
test_conf_default(tmp);
printf("shelf_group_smoke: %d pass, %d fail\n", g_pass, g_fail);
/* 清理临时树(保留失败现场意义不大,测试可重复) */
char cmd[512];
snprintf(cmd, sizeof cmd, "rm -rf '%s'", tmp);
if (system(cmd) != 0) { /* 忽略 */ }
return g_fail ? 1 : 0;
}
@@ -0,0 +1,78 @@
/* test/shelf_scan_bench.c — M27 扫描/排队计时(宿主 + 设备通用,纯 C)。
*
* 用法:./shelf_scan_bench <Books目录> [progress|noop] [轮数]
* progress = 每本书调 rdprog_load(生产路径,含 rdprog_dir 的 mkdir 循环 + fopen);
* noop = 不读进度(基线,只测 readdir/stat/快照)。
* 进度目录由 MEOW_DATA_DIR 决定(同 App:剥 /data → .../progress);未设则 /tmp/m27bench_data。
* 输出每轮 scan / build_rows(format+folder) 毫秒,末行 RESULT 汇总 min/avg。
*/
#include "shelf_model.h"
#include "progress.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
static char g_cfgdir[400];
const char *meow_config_dir(void) { return g_cfgdir; }
static shelf_book g_books[SHELF_MAX_BOOKS];
static shelf_row g_rows[SHELF_MAX_ROWS];
static uint64_t ts_of(const char *path)
{
rdprog p;
return rdprog_load(path, &p) == 1 ? p.ts : 0;
}
/* 取时用 /proc/uptime(整数、10ms 分辨率):zig mipsel musl 的 time64 ABI 在真机上
* 会让 clock_gettime/gettimeofday 的 timespec/timeval 布局失配(见 sdk/CLOCK_ABI.md),
* 且静态链接下浮点打印也不可靠 —— 全程只用整数微秒。 */
static int64_t now_us(void)
{
FILE *f = fopen("/proc/uptime", "r");
if (!f) return 0;
long sec = 0, cs = 0;
if (fscanf(f, "%ld.%ld", &sec, &cs) != 2) cs = 0;
fclose(f);
return (int64_t)sec * 1000000 + (int64_t)cs * 10000;
}
int main(int argc, char **argv)
{
if (argc < 2) {
fprintf(stderr, "用法: %s <Books目录> [progress|noop] [轮数]\n", argv[0]);
return 2;
}
const char *root = argv[1];
int use_prog = (argc > 2 && strcmp(argv[2], "progress") == 0);
int reps = (argc > 3) ? atoi(argv[3]) : 5;
if (reps < 1) reps = 1;
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;
int64_t best_scan = INT64_MAX, best_build = INT64_MAX, sum_scan = 0;
for (int r = 0; r < reps; r++) {
shelf_scan_report rep;
int64_t t0 = now_us();
n = shelf_scan_books(root, g_books, SHELF_MAX_BOOKS, use_prog ? ts_of : NULL, &rep);
int64_t t1 = now_us();
rows_f = shelf_build_rows(g_books, n, 0, g_rows, SHELF_MAX_ROWS);
rows_d = shelf_build_rows(g_books, n, 1, g_rows, SHELF_MAX_ROWS);
int64_t t2 = now_us();
int64_t s = t1 - t0, b = t2 - t1;
trunc = rep.truncated;
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("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",
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);
return 0;
}