Files
MeowOS-For-R3P2/patch_v2/reader_app/test/shelf_group_smoke.c
T
AskaEth 409fd1edf7 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 时跳过执行而非失败
2026-11-09 21:55:33 +08:00

304 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/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;
}