Merge branch 'feat/m27-reader-shelf-folder-grouping'

This commit is contained in:
AskaEth committed 2026-11-09 22:52:43 +08:00
commit 288c133efc
13 files changed
+1019 -134

No files matched your search

+3
View File
@@ -6,6 +6,9 @@
/test/loaders_smoke_host
/test/seg_smoke_host
/test/chapter_smoke_host
/test/shelf_group_smoke_host
/test/shelf_scan_bench_host
/test/shelf_scan_bench_mipsel
/test/rdft_smoke_mipsel
/test/book_smoke_mipsel
/test/formats_smoke_mipsel
+1
View File
@@ -41,6 +41,7 @@ static void app_on_tick(void)
{
uint64_t now = meow_ui_time_ms();
if (reader_is_active()) { reader_on_tick(now); return; }
if (shelf_is_active()) { shelf_on_tick(now); return; }
}
static void app_on_touch_down(int x, int y)
+1 -1
View File
@@ -1 +1 @@
{"name":"Reader","exec":"/usr/data/mnt/sd_0/MeowAPPs/os.meow.reader/reader","package":"os.meow.reader","version":"0.1","version_code":1,"framework":1,"fonts":"common","author":"MeowOS","icon":"icon.png","volume_page":true}
{"name":"Reader","exec":"/usr/data/mnt/sd_0/MeowAPPs/os.meow.reader/reader","package":"os.meow.reader","version":"0.1.1","version_code":2,"framework":1,"fonts":"common","author":"MeowOS","icon":"icon.png","volume_page":true}
+6
View File
@@ -44,6 +44,7 @@ static void conf_defaults(rdconf *c)
strcpy(c->bg, "black"); /* B 定案:默认纯黑 */
strcpy(c->theme, "auto");
strcpy(c->shelf, "list"); /* A 定案:默认 list */
strcpy(c->shelf_group, "format");/* M27:默认按格式,升级不改变现有用户书架 */
c->header = 1;
c->footer = 1;
c->font[0] = 0;
@@ -72,6 +73,7 @@ rdconf *rdconf_get(void)
*eq = 0;
const char *k = line, *v = eq + 1;
if (!strcmp(k, "ui.shelf_layout")) { strncpy(g.shelf, v, sizeof g.shelf - 1); g.shelf[sizeof g.shelf - 1] = 0; }
else if (!strcmp(k, "ui.shelf_group")) { strncpy(g.shelf_group, v, sizeof g.shelf_group - 1); g.shelf_group[sizeof g.shelf_group - 1] = 0; }
else if (!strcmp(k, "rd.fs")) { int t = atoi(v); if (t >= 14 && t <= 40) g.fs = t; }
else if (!strcmp(k, "rd.ls")) { int t = atoi(v); if (t >= 100 && t <= 200) g.ls = t; }
else if (!strcmp(k, "rd.bg")) { strncpy(g.bg, v, sizeof g.bg - 1); g.bg[sizeof g.bg - 1] = 0; }
@@ -84,11 +86,14 @@ rdconf *rdconf_get(void)
}
/* 校验合法取值(越界回默认/回退),避免脏档破坏 UI */
if (strcmp(g.shelf, "grid") && strcmp(g.shelf, "list")) strcpy(g.shelf, "list");
if (strcmp(g.shelf_group, "folder") && strcmp(g.shelf_group, "format"))
strcpy(g.shelf_group, "format");
if (strcmp(g.bg, "black") && strcmp(g.bg, "white") &&
strcmp(g.bg, "sepia") && strcmp(g.bg, "auto")) strcpy(g.bg, "black");
if (strcmp(g.theme, "light") && strcmp(g.theme, "dark") &&
strcmp(g.theme, "auto")) strcpy(g.theme, "auto");
clamp_str(g.shelf, sizeof g.shelf);
clamp_str(g.shelf_group, sizeof g.shelf_group);
clamp_str(g.bg, sizeof g.bg);
clamp_str(g.theme, sizeof g.theme);
clamp_str(g.font, sizeof g.font);
@@ -105,6 +110,7 @@ int rdconf_persist(void)
FILE *f = fopen(path, "wb");
if (!f) return -5;
fprintf(f, "ui.shelf_layout=%s\n", c->shelf);
fprintf(f, "ui.shelf_group=%s\n", c->shelf_group);
fprintf(f, "rd.fs=%d\n", c->fs);
fprintf(f, "rd.ls=%d\n", c->ls);
fprintf(f, "rd.bg=%s\n", c->bg);
+2
View File
@@ -5,6 +5,7 @@
*
* 键(本模块常量):
* ui.shelf_layout = list|grid 书架默认布局(shelf.c 读)
* ui.shelf_group = format|folder 书架分段:按格式(默认)或按目录(M27)
* rd.fs = int (14..40) 全局默认字号(无进度书时 reader_open 用)
* rd.ls = int (100..200) 全局默认行距
* rd.bg = auto|black|white|sepia 阅读页底色预设(默认 black;B 定案)
@@ -28,6 +29,7 @@ typedef struct rdconf {
char bg[8]; /* auto|black|white|sepia */
char theme[8]; /* auto|light|dark */
char shelf[8]; /* list|grid */
char shelf_group[10]; /* format|folder(默认 format=旧行为) */
int header; /* 1=阅读页顶条 */
int footer; /* 1=阅读页底条 */
char font[400]; /* 字体路径(空=自动) */
+21 -4
View File
@@ -1,7 +1,7 @@
/* app/settings.c — 全局设置页(C,书架顶栏「设置」入口 → 独立页 ACT_SETTINGS)。
*
* 配置项(MVP,见 READER_UI_DESIGN §4.3):默认字号/默认行距/默认背景(黑白米黄)/主题(亮暗跟随
* 全局)/书架布局(list-grid)/页头条/页底条。全部存 App 私有 reader.conf(rdconf),不进系统
* 全局)/书架布局(list-grid)/书架分组(格式-目录)/页头条/页底条。全部存 App 私有 reader.conf(rdconf),不进系统
* settings.conf。UI:meow_topappbar(返回+标题) + meow_listview 行(每行 label + 当前值尾部箭头,
* 点行开 meow_dialog(SELECT) 单选;值即改即存)。
*/
@@ -23,13 +23,14 @@ static int g_active;
/* 行项枚举(顺序 = 列表顺序)。ITEM_BASE = 固定的全局项数;重解析本书章节仅在
* 已打开一本「支持章节解析」(TXT+seg 模式)的书时追加(见 settings_on_enter)。 */
enum {
ITEM_FS = 0, ITEM_LS, ITEM_BG, ITEM_THEME, ITEM_SHELF, ITEM_HDR, ITEM_FTR,
ITEM_FS = 0, ITEM_LS, ITEM_BG, ITEM_THEME, ITEM_SHELF, ITEM_GROUP, ITEM_HDR, ITEM_FTR,
ITEM_REPARSE, ITEM_COUNT
};
#define ITEM_BASE ITEM_REPARSE /* 7:不含重解析的固定项数 */
#define ITEM_BASE ITEM_REPARSE /* 8:不含重解析的固定项数 */
static int g_nitems = ITEM_BASE;
static const char *const g_labels[ITEM_COUNT] = {
"默认字号", "默认行距", "默认背景", "主题", "书架布局", "页头条", "页底条", "重解析本书章节"
"默认字号", "默认行距", "默认背景", "主题", "书架布局", "书架分组",
"页头条", "页底条", "重解析本书章节"
};
static void settings_redraw(void);
@@ -53,6 +54,11 @@ static const char *shelf_label(const rdconf *c)
if (!c) return "列表";
return (!strcmp(c->shelf, "grid")) ? "宫格" : "列表";
}
static const char *group_label(const rdconf *c)
{
if (!c) return "格式";
return (!strcmp(c->shelf_group, "folder")) ? "目录" : "格式";
}
/* 当前行值(subtitle)。 */
static void item_value(int item, char *out, int outsz)
@@ -65,6 +71,7 @@ static void item_value(int item, char *out, int outsz)
case ITEM_BG: snprintf(out, (size_t)outsz, "%s", bg_label(c)); break;
case ITEM_THEME: snprintf(out, (size_t)outsz, "%s", theme_label(c)); break;
case ITEM_SHELF: snprintf(out, (size_t)outsz, "%s", shelf_label(c)); break;
case ITEM_GROUP: snprintf(out, (size_t)outsz, "%s", group_label(c)); break;
case ITEM_HDR: snprintf(out, (size_t)outsz, "%s", c->header ? "显示" : "隐藏"); break;
case ITEM_FTR: snprintf(out, (size_t)outsz, "%s", c->footer ? "显示" : "隐藏"); break;
case ITEM_REPARSE: snprintf(out, (size_t)outsz, "%s", "点按重新扫描章节"); break;
@@ -105,6 +112,10 @@ static int idx_for(int item, const char *const *opts, int n, const rdconf *c)
for (int i = 0; i < n; i++) if (!strcmp(opts[i], shelf_label(c))) return i;
return 0;
}
if (item == ITEM_GROUP) {
for (int i = 0; i < n; i++) if (!strcmp(opts[i], group_label(c))) return i;
return 0;
}
if (item == ITEM_HDR) return c->header ? 0 : 1;
if (item == ITEM_FTR) return c->footer ? 0 : 1;
return 0;
@@ -144,6 +155,9 @@ static void apply_choice(int item, int sel)
case ITEM_SHELF:
shelf_set_view(sel ? 1 : 0); /* 写 rdconf + 重建书架 */
return;
case ITEM_GROUP:
shelf_set_group(sel ? 1 : 0); /* 写 ui.shelf_group + 重建书架(不改 path) */
return;
case ITEM_HDR: c->header = sel ? 0 : 1; break; /* 0 显示 1 隐藏 */
case ITEM_FTR: c->footer = sel ? 0 : 1; break;
default: return;
@@ -175,6 +189,9 @@ static void settings_open_choice(int item)
} else if (item == ITEM_SHELF) {
static const char *s[2] = { "列表", "宫格" };
for (int i = 0; i < 2; i++) opts[n++] = s[i];
} else if (item == ITEM_GROUP) {
static const char *g[2] = { "格式", "目录" };
for (int i = 0; i < 2; i++) opts[n++] = g[i];
} else if (item == ITEM_HDR || item == ITEM_FTR) {
static const char *h[2] = { "显示", "隐藏" };
for (int i = 0; i < 2; i++) opts[n++] = h[i];
+109 -123
View File
@@ -4,151 +4,99 @@
* 布局(README §3.2,用户 2026-09-04 拍板):
* - 默认 list(meow_listview_set_container(on) MD3 圆角卡),可切 grid(meow_lazygrid 4 列封面卡);
* 布局存 reader.conf `ui.shelf_layout`(rdconf_get()->shelf);
* - 顶栏 meow_topappbar:标题「书架」+ 动作(布局切换 / 刷新 / App 设置入口);
* - 顶栏 meow_topappbar:标题「书架」+ 动作(布局切换 / 分组切换 / 刷新 / App 设置入口);
* - 无书也渲染完整骨架(顶栏 + 内容区 + 居中空态卡 + 指引 + 支持格式);
* - 封面:MVP 无封面提取(engine F1);无则圆角占位(surfaceContainerHigh + 格式徽标)。
*
* M27 分组(`ui.shelf_group`,默认 format 保持旧行为):
* format = 按格式分段(TXT/FB2/EPUB/MOBI/CBZ);
* folder = 按 Books/ 下的「第一级目录」分段(根下=未分类),更深子路径(分类/作品)进副题。
* 扫描/行序列在 app/shelf_model.c(纯 C,宿主可测);本文件只做控件装配与绘制。
*/
#include "../reader_internal.h"
#include "progress.h"
#include "reader_conf.h"
#include "shelf_model.h"
#include "meow_ui_internal.h" /* meow_gfx_fill_round(宫格卡圆角填充) */
#include <dirent.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#define SHELF_APPBAR_H 56
#define SHELF_LIST_X 0
#define SHELF_LIST_W 480
typedef struct shelf_book {
char path[400];
char title[128];
char sub[160];
int fmt; /* 0 txt,1 fb2,2 epub,3 mobi,4 cbz */
long size;
uint64_t ts; /* 最近阅读 unix;无=0 */
} shelf_book;
static shelf_book g_books[256];
static shelf_book g_books[SHELF_MAX_BOOKS];
static int g_nbooks;
static int g_active;
static meow_widget *g_list; /* listview(布局=list) */
static meow_lazygrid *g_grid; /* lazygrid(布局=grid) */
static meow_widget *g_appbar; /* meow_topappbar */
static int g_layout; /* 0=list 1=grid(读 rdconf->shelf) */
static int g_group_folder;/* 0=format 1=folder(读 rdconf->shelf_group) */
static uint32_t g_bg, g_fg, g_dim;
static char g_notice[160]; /* 提示(opendir 失败等) */
static int g_notice_ms; /* 剩余显示 ms(0=无) */
static char g_notice[160]; /* 提示第 1 行(opendir 失败/超上限截断等) */
static char g_notice2[160]; /* 提示第 2 行(超量+超深并存时才用;空=单行) */
static int g_notice_ms; /* 1=有提示(0=无) */
static uint64_t g_notice_until; /* 提示到期时刻(meow_ui_time_ms;6s 后自动消失) */
/* 段头行(list 模式):把扫描结果排队为 [段头(fmt), 书, 书, ..., 段头, ...]。
* 书量小用段头(README §3.2),格式分组(TXT/FB2/EPUB/MOBI/CBZ)。 */
#define SHELF_MAX_ROWS 520
typedef struct shelf_row { char section; int fmt; int book; } shelf_row;
/* 段头行(list 模式):[段头, 书, 书, ..., 段头, ...];SHELF_MAX_ROWS=2×书上限(见 shelf_model.h)。 */
static shelf_row g_rows[SHELF_MAX_ROWS];
static int g_nrows;
static const char *fmt_name(int f)
static const char *fmt_name(int f) { return shelf_fmt_name(f); }
/* 进度时间戳(分组/排序用;不改 path,故进度键不受分组影响)。 */
static uint64_t shelf_ts_of(const char *path)
{
switch (f) { case 0: return "TXT"; case 1: return "FB2"; case 2: return "EPUB";
case 3: return "MOBI"; case 4: return "CBZ"; default: return "?"; }
}
static int fmt_from_ext(const char *name)
{
const char *d = strrchr(name, '.');
if (!d) return -1;
if (strcasecmp(d, ".txt") == 0) return 0;
if (strcasecmp(d, ".fb2") == 0) return 1;
if (strcasecmp(d, ".epub") == 0) return 2;
if (strcasecmp(d, ".mobi") == 0) return 3;
if (strcasecmp(d, ".cbz") == 0) return 4;
return -1;
}
static void fmt_size(char *out, int outsz, long s)
{
if (s >= 1 << 20) snprintf(out, outsz, "%.1fMB", (double)s / (1 << 20));
else if (s >= 1 << 10) snprintf(out, outsz, "%.0fKB", (double)s / (1 << 10));
else snprintf(out, outsz, "%ldB", s);
rdprog p;
return (rdprog_load(path, &p) == 1) ? p.ts : 0;
}
static int cmp_recent_then_name(const void *a, const void *b)
static void notice_set(const char *line1, const char *line2)
{
const shelf_book *x = (const shelf_book *)a, *y = (const shelf_book *)b;
if (x->ts != y->ts) return (x->ts < y->ts) ? 1 : -1; /* 最近在前 */
return strcasecmp(x->title, y->title);
}
/* 递归收集 Books/ 下(含子目录,深度 ≤2)的书。只在 scan_books 里调用。 */
static void scan_dir(const char *dir, int depth)
{
if (depth > 2) return; /* 防深嵌套/循环 */
DIR *dp = opendir(dir);
if (!dp) return;
struct dirent *de;
while ((de = readdir(dp)) && g_nbooks < 256) {
if (de->d_name[0] == '.') continue;
char path[256];
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)) { scan_dir(path, depth + 1); continue; }
int f = fmt_from_ext(de->d_name);
if (f < 0) continue;
shelf_book *b = &g_books[g_nbooks];
snprintf(b->path, sizeof b->path, "%s", path);
strncpy(b->title, de->d_name, sizeof b->title - 1); b->title[sizeof b->title - 1] = 0;
char *dot = strrchr(b->title, '.');
if (dot) *dot = 0; /* 标题去扩展名 */
b->fmt = f;
b->size = (long)st.st_size;
rdprog p;
b->ts = (rdprog_load(b->path, &p) == 1) ? p.ts : 0;
char sz[24]; fmt_size(sz, sizeof sz, b->size);
snprintf(b->sub, sizeof b->sub, "%s · %s%s", fmt_name(f), sz,
b->ts ? " · 读" : "");
g_nbooks++;
}
closedir(dp);
snprintf(g_notice, sizeof g_notice, "%s", line1 ? line1 : "");
snprintf(g_notice2, sizeof g_notice2, "%s", line2 ? line2 : "");
g_notice_ms = 1;
g_notice_until = meow_ui_time_ms() + 6000; /* 6s 后由 shelf_on_tick 清掉,不常驻压列表 */
}
static void scan_books(void)
{
const char *dir = READER_SD_ROOT "/Books";
g_nbooks = 0;
DIR *dp = opendir(dir);
if (!dp) {
snprintf(g_notice, sizeof g_notice, "未找到 %s 目录,请放入书籍(TXT)", dir);
g_notice_ms = 4000;
return;
g_notice[0] = 0;
g_notice2[0] = 0;
g_notice_ms = 0;
shelf_scan_report rep;
g_nbooks = shelf_scan_books(dir, g_books, SHELF_MAX_BOOKS, shelf_ts_of, &rep);
if (rep.root_missing) {
char line[160];
snprintf(line, sizeof line, "未找到 %s 目录,请放入书籍(TXT)", dir);
notice_set(line, NULL);
} else if (rep.truncated && rep.depth_skipped > 0) {
/* 长句拆两行(480px 单行会被切掉) */
char l1[160], l2[160];
snprintf(l1, sizeof l1, "书籍超过 %d 本,已只显示前 %d 本", SHELF_MAX_BOOKS, SHELF_MAX_BOOKS);
snprintf(l2, sizeof l2, "另有 %d 个文件目录超过 %d 层未显示",
rep.depth_skipped, SHELF_SCAN_MAX_DEPTH);
notice_set(l1, l2);
} else if (rep.truncated) {
char line[160];
snprintf(line, sizeof line, "书籍超过 %d 本,已只显示前 %d 本",
SHELF_MAX_BOOKS, SHELF_MAX_BOOKS);
notice_set(line, NULL);
} else if (rep.depth_skipped > 0) {
char line[160];
snprintf(line, sizeof line, "有 %d 个文件因目录层数超过 %d 层未显示",
rep.depth_skipped, SHELF_SCAN_MAX_DEPTH);
notice_set(line, NULL);
}
closedir(dp);
scan_dir(dir, 0); /* 递归:含 Books/<子目录>/ 下书籍(用户可能按目录组织) */
qsort(g_books, (size_t)g_nbooks, sizeof(shelf_book), cmp_recent_then_name);
}
/* 排队段头(按格式):为 list 模式生成 [段头(fmt) | 书] 序列。 */
/* 排队行:folder 模式按目录分段,否则按格式(默认)。 */
static void build_rows(void)
{
g_nrows = 0;
static const int fmts[] = { 0, 1, 2, 3, 4 };
for (size_t fi = 0; fi < sizeof fmts / sizeof fmts[0]; fi++) {
int fmt = fmts[fi];
int have = 0;
if (g_nrows >= SHELF_MAX_ROWS) break;
for (int i = 0; i < g_nbooks; i++) {
if (g_books[i].fmt != fmt) continue;
if (!have) {
if (g_nrows >= SHELF_MAX_ROWS) break;
g_rows[g_nrows].section = 1; g_rows[g_nrows].fmt = fmt; g_rows[g_nrows].book = -1;
g_nrows++;
have = 1;
}
if (g_nrows >= SHELF_MAX_ROWS) break;
g_rows[g_nrows].section = 0; g_rows[g_nrows].fmt = fmt; g_rows[g_nrows].book = i;
g_nrows++;
}
}
g_nrows = shelf_build_rows(g_books, g_nbooks, g_group_folder, g_rows, SHELF_MAX_ROWS);
}
/* ---- list 数据源(vsource + 段头;container on) ---- */
@@ -168,13 +116,13 @@ static void shelf_list_bind(int idx, meow_lv_item *item, void *ctx)
if (idx < 0 || idx >= g_nrows) return;
if (g_rows[idx].section) {
item->section = 1;
item->title = fmt_name(g_rows[idx].fmt);
item->title = g_rows[idx].label ? g_rows[idx].label : fmt_name(g_rows[idx].fmt);
item->enabled = 0;
} else {
int bi = g_rows[idx].book;
if (bi < 0 || bi >= g_nbooks) return;
item->title = g_books[bi].title;
item->subtitle = g_books[bi].sub;
item->subtitle = g_group_folder ? g_books[bi].sub_folder : g_books[bi].sub;
item->icon_kind = MEOW_LIST_ICON_FILE;
item->right = MEOW_LIST_RIGHT_ARROW;
}
@@ -283,18 +231,19 @@ static void shelf_build_content(void)
(void)th;
}
/* 顶栏动作:0=布局切换 1=刷新 2=设置。布局切换动作标签 = 将切换到的布局。 */
/* 顶栏动作:0=布局切换 1=分组切换 2=刷新 3=设置。动作标签 = 将切换到的模式。 */
static void shelf_appbar_build(void)
{
int sbh = meow_ui_statusbar_height();
if (g_appbar) { meow_widget_free(g_appbar); g_appbar = NULL; }
g_appbar = meow_topappbar_create(0, sbh, 480, SHELF_APPBAR_H, "书架");
if (!g_appbar) return;
meow_topappbar_add_action(g_appbar, g_layout ? "列表" : "宫格"); /* 0 布局切换 */
meow_topappbar_add_action(g_appbar, "刷新"); /* 1 刷新 */
meow_topappbar_add_action(g_appbar, "设置"); /* 2 设置 */
meow_topappbar_set_item_icon(g_appbar, 1, MEOW_ICON_REFRESH);
meow_topappbar_set_item_icon(g_appbar, 2, MEOW_ICON_SETTINGS);
meow_topappbar_add_action(g_appbar, g_layout ? "列表" : "宫格"); /* 0 布局切换 */
meow_topappbar_add_action(g_appbar, g_group_folder ? "格式" : "目录"); /* 1 分组切换 */
meow_topappbar_add_action(g_appbar, "刷新"); /* 2 刷新 */
meow_topappbar_add_action(g_appbar, "设置"); /* 3 设置 */
meow_topappbar_set_item_icon(g_appbar, 2, MEOW_ICON_REFRESH);
meow_topappbar_set_item_icon(g_appbar, 3, MEOW_ICON_SETTINGS);
}
/* view 生命周期 */
@@ -303,15 +252,15 @@ static void shelf_on_enter(meow_view *v, void *ctx)
{
(void)v; (void)ctx;
g_active = 1;
/* 先读配置(build_rows 依赖分组模式),再扫描/排队。 */
const rdconf *c = rdconf_get();
g_layout = (c && c->shelf[0] && strcmp(c->shelf, "grid") == 0) ? 1 : 0;
g_group_folder = (c && strcmp(c->shelf_group, "folder") == 0) ? 1 : 0;
scan_books(); /* 回到书架即重扫(新增/最近读置顶刷新) */
build_rows();
const meow_theme *th = meow_theme_get();
g_bg = th->bg; g_fg = th->fg; g_dim = th->dim;
/* 布局:读 reader.conf ui.shelf_layout(A) */
const rdconf *c = rdconf_get();
g_layout = (c && c->shelf[0] && strcmp(c->shelf, "grid") == 0) ? 1 : 0;
shelf_appbar_build();
shelf_build_content();
shelf_redraw();
@@ -334,6 +283,16 @@ meow_view *shelf_view_create(void)
int shelf_is_active(void) { return g_active; }
/* 书架 tick(app.c 在书架为前台页时调用):提示条 6s 到期自动消失。 */
void shelf_on_tick(uint64_t now)
{
if (g_notice_ms > 0 && now >= g_notice_until) {
g_notice_ms = 0;
g_notice_until = 0;
shelf_redraw();
}
}
/* 切换 list/grid(书架顶栏「布局切换」动作;改 reader.conf 并重建)。 */
void shelf_set_view(int grid)
{
@@ -349,6 +308,23 @@ void shelf_set_view(int grid)
}
}
/* 切换 format/folder(顶栏「分组」动作 / 设置页「书架分组」;改 reader.conf 并重建)。
* 只改分组模式:不碰 books[].path,故进度键 rdprog_hash(path) 不变。 */
void shelf_set_group(int folder)
{
rdconf *c = rdconf_get();
if (!c) return;
strcpy(c->shelf_group, folder ? "folder" : "format");
rdconf_persist();
g_group_folder = folder ? 1 : 0;
if (g_active) {
build_rows();
shelf_appbar_build();
shelf_build_content();
shelf_redraw();
}
}
void shelf_redraw(void)
{
const meow_theme *th = meow_theme_get();
@@ -356,9 +332,12 @@ void shelf_redraw(void)
meow_ui_clear(g_bg);
if (g_appbar) meow_widget_draw(g_appbar);
/* 副题:Books/ · N 本(有书时显示格式说明;无书并入空态卡) */
/* 副题:Books/ · N 本 [· 按目录](分组只作用于列表;宫格仍是平铺封面墙) */
if (g_nbooks > 0) {
char sub[96]; snprintf(sub, sizeof sub, "Books/ · %d 本", g_nbooks);
char sub[96];
if (g_group_folder && g_layout == 0)
snprintf(sub, sizeof sub, "Books/ · %d 本 · 按目录", g_nbooks);
else snprintf(sub, sizeof sub, "Books/ · %d 本", g_nbooks);
int sbh = meow_ui_statusbar_height();
meow_ui_text(24, sbh + SHELF_APPBAR_H + 3, sub, 16, g_dim);
}
@@ -383,8 +362,12 @@ void shelf_redraw(void)
meow_ui_text_center(480 / 2, cy + 150, "Books/ 目录", 20, th->primary);
}
if (g_notice_ms > 0) {
meow_ui_fill_rect(0, 620, 480, 44, g_bg);
meow_ui_text(20, 630, g_notice, 20, th->primary);
int two = g_notice2[0] != 0;
int top = two ? 610 : 620;
int h = two ? 58 : 44;
meow_ui_fill_rect(0, top, 480, h, g_bg);
meow_ui_text(20, top + 4, g_notice, 20, th->primary);
if (two) meow_ui_text(20, top + 30, g_notice2, 20, th->primary);
}
meow_ui_flush();
}
@@ -397,12 +380,15 @@ int shelf_on_tap(int x, int y)
if (bi == 0) { /* 布局切换 */
shelf_set_view(g_layout ? 0 : 1);
return 1;
} else if (bi == 1) { /* 刷新:重扫 */
} else if (bi == 1) { /* 分组切换:format ↔ folder */
shelf_set_group(g_group_folder ? 0 : 1);
return 1;
} else if (bi == 2) { /* 刷新:重扫 */
scan_books(); build_rows();
shelf_build_content();
shelf_redraw();
return 1;
} else if (bi == 2) { /* App 设置入口 */
} else if (bi == 3) { /* App 设置入口 */
meow_view_page_push(ACT_SETTINGS);
return 1;
}
+285
View File
@@ -0,0 +1,285 @@
/* app/shelf_model.c — 书架数据模型实现(见 app/shelf_model.h)。 */
#include "shelf_model.h"
#include <dirent.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
int shelf_fmt_from_ext(const char *name)
{
const char *d = strrchr(name, '.');
if (!d) return -1;
if (strcasecmp(d, ".txt") == 0) return 0;
if (strcasecmp(d, ".fb2") == 0) return 1;
if (strcasecmp(d, ".epub") == 0) return 2;
if (strcasecmp(d, ".mobi") == 0) return 3;
if (strcasecmp(d, ".cbz") == 0) return 4;
return -1;
}
const char *shelf_fmt_name(int f)
{
switch (f) {
case 0: return "TXT";
case 1: return "FB2";
case 2: return "EPUB";
case 3: return "MOBI";
case 4: return "CBZ";
default: return "?";
}
}
void shelf_fmt_size(char *out, int outsz, long size)
{
if (!out || outsz <= 0) return;
if (size >= 1 << 20) snprintf(out, (size_t)outsz, "%.1fMB", (double)size / (1 << 20));
else if (size >= 1 << 10) snprintf(out, (size_t)outsz, "%.0fKB", (double)size / (1 << 10));
else snprintf(out, (size_t)outsz, "%ldB", size);
}
int shelf_copy_utf8(char *dst, int cap, const char *src)
{
if (!dst || cap <= 0) return 0;
if (!src) { dst[0] = 0; return 0; }
int limit = cap - 1;
int n = 0;
while (src[n] && n < limit) n++;
if (src[n]) {
/* 截断:若 src[n] 是续字节(10xxxxxx),回退到该字符首字节之前,整段丢弃。 */
while (n > 0 && ((unsigned char)src[n] & 0xC0) == 0x80) n--;
}
memcpy(dst, src, (size_t)n);
dst[n] = 0;
return n;
}
static int cmp_recent_then_name(const void *a, const void *b)
{
const shelf_book *x = (const shelf_book *)a, *y = (const shelf_book *)b;
if (x->ts != y->ts) return (x->ts < y->ts) ? 1 : -1; /* 最近在前 */
return strcasecmp(x->title, y->title);
}
typedef struct scan_ctx {
shelf_book *books;
int cap;
int n;
int truncated;
int depth_skipped;
shelf_ts_fn ts_fn;
} scan_ctx;
/* 统计 dir 子树里还能识别的书籍文件数(只数不收集)。自身留 8 层硬上限,
* 防符号链接/异常深目录把计数也拖死。返回书籍文件数。 */
static int count_books_under(const char *dir, int depth)
{
if (depth > SHELF_SCAN_MAX_DEPTH + 8) return 0;
int n = 0;
DIR *dp = opendir(dir);
if (!dp) return 0;
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)) { n += count_books_under(path, depth + 1); continue; }
if (shelf_fmt_from_ext(de->d_name) >= 0) n++;
}
closedir(dp);
return n;
}
/* 递归收集。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; /* 已满:不再进新目录 */
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)) {
if (depth + 1 > SHELF_SCAN_MAX_DEPTH) {
c->depth_skipped += count_books_under(path, depth + 1); /* 超深:计数→可见提示 */
continue;
}
if (c->n >= c->cap) {
/* 上限卡在目录边界:探一下这个子树是否真有书,有书才算被截断。
* 只探空目录不置位,避免“正好装满且后面全是空目录”误报。 */
if (!c->truncated && count_books_under(path, depth + 1) > 0) c->truncated = 1;
if (c->truncated) break; /* 已确认丢书,后面的目录/文件不必再跑 */
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);
if (f < 0) continue;
if (c->n >= c->cap) { c->truncated = 1; break; } /* 超限:可见提示,不静默 */
shelf_book *b = &c->books[c->n];
shelf_copy_utf8(b->path, (int)sizeof b->path, path);
shelf_copy_utf8(b->title, (int)sizeof b->title, de->d_name);
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);
/* 默认副题:与 M27 前逐字一致(format 模式/宫格都用它)。 */
snprintf(b->sub, sizeof b->sub, "%s · %s%s", shelf_fmt_name(f), sz,
b->ts ? " · 读" : "");
/* folder 副题:第一级之后的子路径(分类/作品)前置,其余同默认副题。 */
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_folder, sizeof b->sub_folder, "%s · %s", pre, b->sub);
} else {
snprintf(b->sub_folder, sizeof b->sub_folder, "%s", b->sub);
}
c->n++;
}
closedir(dp);
}
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->depth_skipped = 0; rep->root_missing = 0; }
if (!root || !books || cap <= 0) {
if (rep) rep->root_missing = 1;
return 0;
}
DIR *dp = opendir(root);
if (!dp) {
if (rep) rep->root_missing = 1;
return 0;
}
closedir(dp);
scan_ctx c;
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;
rep->depth_skipped = c.depth_skipped;
}
return c.n;
}
/* ---- 行序列 ---- */
/* 分组键 = 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)
{
return top_cmp(*(const char *const *)pa, *(const char *const *)pb);
}
static int build_rows_format(const shelf_book *books, int nbooks,
shelf_row *rows, int rowcap)
{
static const int fmts[] = { 0, 1, 2, 3, 4 };
int nrows = 0;
for (size_t fi = 0; fi < sizeof fmts / sizeof fmts[0]; fi++) {
int fmt = fmts[fi];
int have = 0;
if (nrows >= rowcap) break;
for (int i = 0; i < nbooks; i++) {
if (books[i].fmt != fmt) continue;
if (!have) {
if (nrows >= rowcap) break;
rows[nrows].section = 1; rows[nrows].fmt = fmt;
rows[nrows].book = -1; rows[nrows].label = NULL;
nrows++;
have = 1;
}
if (nrows >= rowcap) break;
rows[nrows].section = 0; rows[nrows].fmt = fmt;
rows[nrows].book = i; rows[nrows].label = NULL;
nrows++;
}
}
return nrows;
}
static int build_rows_folder(const shelf_book *books, int nbooks,
shelf_row *rows, int rowcap)
{
/* 去重分组键(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].top;
int seen = 0;
for (int j = 0; j < nkeys; j++) {
if (top_eq(keys[j], k)) { seen = 1; break; }
}
if (!seen) keys[nkeys++] = k;
}
qsort(keys, (size_t)nkeys, sizeof keys[0], cmp_group_key);
int nrows = 0;
for (int ki = 0; ki < nkeys; ki++) {
if (nrows >= rowcap) break;
rows[nrows].section = 1;
rows[nrows].fmt = -1;
rows[nrows].book = -1;
rows[nrows].label = keys[ki][0] ? keys[ki] : SHELF_GROUP_ROOT;
nrows++;
for (int i = 0; i < nbooks; i++) {
if (!top_eq(books[i].top, keys[ki])) continue;
if (nrows >= rowcap) break;
rows[nrows].section = 0;
rows[nrows].fmt = books[i].fmt;
rows[nrows].book = i;
rows[nrows].label = NULL;
nrows++;
}
}
return nrows;
}
int shelf_build_rows(const shelf_book *books, int nbooks, int folder_mode,
shelf_row *rows, int rowcap)
{
if (!books || !rows || nbooks <= 0 || rowcap <= 0) return 0;
if (nbooks > SHELF_MAX_BOOKS) nbooks = SHELF_MAX_BOOKS;
return folder_mode ? build_rows_folder(books, nbooks, rows, rowcap)
: build_rows_format(books, nbooks, rows, rowcap);
}
+83
View File
@@ -0,0 +1,83 @@
/* app/shelf_model.h — 书架数据模型:扫描 Books/ + 生成行序列(纯 C,不依赖 SDK/设备)。
*
* 从 shelf.c 拆出,供 shelf.c(控件装配)、宿主单测(test/shelf_group_smoke.c)与
* 扫描计时(test/shelf_scan_bench.c)共用。
*
* 目录归属(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)),分组只写 top/group,
* 绝不改写 path;两个分组模式间切换不影响进度。
*/
#ifndef SHELF_MODEL_H
#define SHELF_MODEL_H
#include <stdint.h>
/* 书籍上限:真机真实书库 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/ 根下书籍的段头 */
typedef struct shelf_book {
char path[400]; /* 完整路径:打开书 + 进度键;分组不得改写 */
char title[128];
char sub[160]; /* 默认(format/宫格)副题:格式 · 大小 [· 读],与 M27 前逐字一致 */
char sub_folder[160]; /* folder 模式副题:更深子路径 · 格式 · 大小 [· 读] */
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 */
} shelf_book;
typedef struct shelf_row {
int section; /* 1=段头行 */
int fmt; /* 段头/书行格式;段头无 label 时显示格式名 */
int book; /* 书行=books 下标;段头=-1 */
const char *label; /* 段头标题;NULL=格式名(指向 books[].top 或静态串) */
} shelf_row;
/* 进度时间戳查询(生产=rdprog_load;测试/基线=NULL,全部 ts=0)。 */
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(目录深度 ≤ 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[].top(Books/ 第一级目录)分组,根组「未分类」置顶,
* 其余组按组名排序;组内沿用 books[] 全局顺序(最近阅读优先、其次书名)。
* 返回行数;超过 rowcap 时只写 rowcap 行并返回 rowcap。 */
int shelf_build_rows(const shelf_book *books, int nbooks, int folder_mode,
shelf_row *rows, int rowcap);
/* 工具(单测直接调用)。 */
int shelf_fmt_from_ext(const char *name);
const char *shelf_fmt_name(int fmt);
void shelf_fmt_size(char *out, int outsz, long size);
/* UTF-8 安全截断:最多写 cap-1 字节;若截断点落在多字节序列中间,整段丢弃该残缺字符。
* 返回写入字节数(不含 NUL)。 */
int shelf_copy_utf8(char *dst, int cap, const char *src);
#endif /* SHELF_MODEL_H */
+26 -6
View File
@@ -21,6 +21,17 @@ SYSROOT="$ROOT/.tools/mipsel-linux-muslsf-cross/mipsel-linux-muslsf"
HOST_CC="${HOST_CC:-gcc}"
VENDOR="$PWD/vendor"
OBJ="$VENDOR/build"
# qemu-mipsel 运行时冒烟:宿主没装 qemu(如 Termux 无 mipsel user-mode 包)时,
# 仍编译 mipsel 冒烟二进制,只跳过执行;设 QEMU_BIN=<路径> 可指定其它模拟器。
QEMU_BIN="${QEMU_BIN:-qemu-mipsel}"
run_qemu() {
if [ "${SKIP_QEMU:-0}" = 1 ] || ! command -v "$QEMU_BIN" >/dev/null 2>&1; then
shift
echo " SKIP qemu 执行(未找到 $QEMU_BIN):$*"
return 0
fi
timeout "$@"
}
mkdir -p "$OBJ"
# ---------- 1) vendor 拉取/对齐 ----------
@@ -101,6 +112,13 @@ echo "==> host chapter test (R3.3)"
$HOST_CC -O2 -Wall -Wextra -Icore test/chapter_smoke.c core/rdchapter.c core/seg.c core/rdenc.c -o test/chapter_smoke_host -lm
./test/chapter_smoke_host
echo "==> host shelf group test (M27)"
$HOST_CC -O2 -Wall -Wextra -Iapp -I../sdk test/shelf_group_smoke.c app/shelf_model.c app/progress.c app/reader_conf.c -o test/shelf_group_smoke_host
./test/shelf_group_smoke_host
echo "==> host shelf scan bench (M27,只编不跑;用法 ./test/shelf_scan_bench_host <Books目录> [progress|noop] [轮数])"
$HOST_CC -O2 -Wall -Wextra -Iapp -I../sdk test/shelf_scan_bench.c app/shelf_model.c app/progress.c -o test/shelf_scan_bench_host
echo "==> host loaders test"
HOBJ="$OBJ/host"; mkdir -p "$HOBJ"
for f in vendor/miniz/miniz_tinfl.c vendor/miniz/miniz_tdef.c vendor/miniz/miniz_zip.c vendor/miniz/miniz.c; do
@@ -120,28 +138,30 @@ $HOST_CC -O2 -Wall -Wextra $COREINC test/loaders_smoke.c "$HOBJ"/*.o -lz -lm -o
# ---------- 5) qemu-mipsel FreeType 冒烟 ----------
echo "==> mipsel ft smoke"
$CC -Os -static -Wall -Wextra -Iengine $FT_INCS test/rdft_smoke.c engine/rdft.c "$FT_BUILD/libfreetype.a" -o test/rdft_smoke_mipsel
timeout 120 qemu-mipsel ./test/rdft_smoke_mipsel "${FONT:-$ROOT/font/src/MiSans-Regular.otf}"
run_qemu 120 "$QEMU_BIN" ./test/rdft_smoke_mipsel "${FONT:-$ROOT/font/src/MiSans-Regular.otf}"
# ---------- 6) qemu-mipsel 书桥冒烟(TXT + 四格式) ----------
# rdimg.o 引用 stbi_*,实现只在 sdk/meow_ui.a 里(见步骤 3 注释);冒烟不链 UI 主体,
# 但必须链该归档才能解析 stbi_*(归档按需只拉 stb_image.o)。
echo "==> mipsel book smoke (TXT)"
$CC -Os -static -Wall -Wextra -Iapp -Icore -Iengine $FT_INCS $COREINC test/book_smoke.c \
app/book.c core/rdenc.c core/rdlayout.c core/rdtxt.c core/seg.c core/rdchapter.c core/rdxml.c core/rdhtml.c core/rdsb.c core/rdzip.c \
core/rdsrc.c core/rdepub.c core/rdfb2.c core/rdmobi.c core/rdcbz.c engine/rdft.c \
"$OBJ"/mz_*.o "$OBJ"/lm_*.o "$OBJ"/rdimg.o "$SYSROOT/lib/libz.a" "$FT_BUILD/libfreetype.a" -o test/book_smoke_mipsel
timeout 120 qemu-mipsel ./test/book_smoke_mipsel "${FONT:-$ROOT/font/src/MiSans-Regular.otf}"
"$OBJ"/mz_*.o "$OBJ"/lm_*.o "$OBJ"/rdimg.o ../sdk/meow_ui.a "$SYSROOT/lib/libz.a" "$FT_BUILD/libfreetype.a" -o test/book_smoke_mipsel
run_qemu 120 "$QEMU_BIN" ./test/book_smoke_mipsel "${FONT:-$ROOT/font/src/MiSans-Regular.otf}"
echo "==> mipsel formats smoke"
$CC -Os -static -Wall -Wextra -Iapp -Icore -Iengine $FT_INCS $COREINC test/formats_smoke.c \
app/book.c core/rdenc.c core/rdlayout.c core/rdtxt.c core/seg.c core/rdchapter.c core/rdxml.c core/rdhtml.c core/rdsb.c core/rdzip.c \
core/rdsrc.c core/rdepub.c core/rdfb2.c core/rdmobi.c core/rdcbz.c engine/rdft.c \
"$OBJ"/mz_*.o "$OBJ"/lm_*.o "$OBJ"/rdimg.o "$SYSROOT/lib/libz.a" "$FT_BUILD/libfreetype.a" -o test/formats_smoke_mipsel
timeout 150 qemu-mipsel ./test/formats_smoke_mipsel "${FONT:-$ROOT/font/src/MiSans-Regular.otf}" /tmp/rd_samples
"$OBJ"/mz_*.o "$OBJ"/lm_*.o "$OBJ"/rdimg.o ../sdk/meow_ui.a "$SYSROOT/lib/libz.a" "$FT_BUILD/libfreetype.a" -o test/formats_smoke_mipsel
run_qemu 150 "$QEMU_BIN" ./test/formats_smoke_mipsel "${FONT:-$ROOT/font/src/MiSans-Regular.otf}" /tmp/rd_samples
# ---------- 7) reader 二进制(mipsel 静态) ----------
echo "==> reader binary"
CORE_L="core/rdenc.c core/rdlayout.c core/rdtxt.c core/seg.c core/rdchapter.c core/rdxml.c core/rdhtml.c core/rdsb.c core/rdzip.c core/rdsrc.c core/rdepub.c core/rdfb2.c core/rdmobi.c core/rdcbz.c"
ENGINE_L="engine/rdft.c"
APP_L="app.c app/shelf.c app/reader.c app/book.c app/progress.c app/settings.c app/reader_conf.c app/reader_ipc.c"
APP_L="app.c app/shelf.c app/shelf_model.c app/reader.c app/book.c app/progress.c app/settings.c app/reader_conf.c app/reader_ipc.c"
$CC -Os -static -Wall -Wextra -I../sdk -I../meow_launcherd -I. -Icore -Iapp -Iengine $FT_INCS $COREINC \
$CORE_L $APP_L $ENGINE_L "$OBJ"/mz_*.o "$OBJ"/lm_*.o "$OBJ"/rdimg.o \
../sdk/meow_ui.a "$SYSROOT/lib/libz.a" "$FT_BUILD/libfreetype.a" -lpthread -o reader
+2
View File
@@ -20,6 +20,8 @@ int shelf_on_tap(int x, int y);
void shelf_on_swipe(int dir, int dx, int dy);
void shelf_redraw(void);
void shelf_set_view(int grid); /* 1=grid 0=list;读 reader.conf ui.shelf_layout(A) */
void shelf_set_group(int folder); /* 1=folder 0=format;写 ui.shelf_group(M27) */
void shelf_on_tick(uint64_t now); /* 提示条 6s 到期消失(书架前台时由 app.c 调) */
/* ---- 阅读页(app/reader.c)---- */
meow_view *reader_view_create(void);
@@ -0,0 +1,394 @@
/* test/shelf_group_smoke.c — M27 书架目录分组宿主单测(纯 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/ 根下的书归「未分类」;字面名为「未分类」的目录与根组是两个不同键
* G6 UTF-8 安全截断(中文目录名不切出半个字符)
* G7 超上限不静默:truncated=1(含「cap 卡在目录边界」与「恰好装满不误报」两种);
* 行数上限(段头 ≤ 书数 ⇒ 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/轻小说/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/轻小说", 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/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/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 == 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 目录归属(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/轻小说/1.txt", root);
CHECK(strcmp(b1->path, want) == 0, "path 仍是完整绝对路径");
}
/* 副题:format 模式必须与 base 逐字一致;子路径只在 folder 副题里出现 */
CHECK(bdp && strcmp(bdp->sub, "TXT · 5B") == 0 && strstr(bdp->sub, "/") == NULL,
"format 副题与 base 逐字一致(不含子路径)");
CHECK(bdp && strstr(bdp->sub_folder, "01、玄幻/01、斗破") != NULL,
"folder 副题含 01、玄幻/01、斗破");
CHECK(b5 && strcmp(b5->sub, "MOBI · 4B") == 0, "MOBI 副题与 base 逐字一致");
CHECK(b5 && strstr(b5->sub_folder, "sub") != NULL, "folder 副题含 sub");
CHECK(b3 && strcmp(b3->sub, "TXT · 5B") == 0 && strcmp(b3->sub_folder, b3->sub) == 0,
"一级目录书两种副题相同(无多余子路径)");
CHECK(b4 && strcmp(b4->sub, "TXT · 4B") == 0 && strcmp(b4->sub_folder, b4->sub) == 0,
"根下书两种副题相同");
/* G2 format 模式(改动前行为):格式段头 + 最近阅读优先 + 全局书名排序 */
shelf_row rows[128];
int nf = shelf_build_rows(books, n, 0, rows, 128);
char got[4096];
dump_rows(rows, nf, books, got, sizeof got);
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 模式:按第一级目录分组(未分类置顶 → 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:根下()|"
"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 不在序列里) */
CHECK(strstr(got, "S:C|") == NULL, "空目录 C 无段头");
/* 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(不静默)");
/* P2-1:cap 正好卡在目录边界(A/d1 填满,A/d2 被丢)也必须置 truncated */
char rootc[400];
snprintf(rootc, sizeof rootc, "%s/capbound", tmproot);
snprintf(p, sizeof p, "%s/A/d1", rootc); mkdirs(p);
snprintf(p, sizeof p, "%s/A/d2", rootc); mkdirs(p);
snprintf(p, sizeof p, "%s/A/d1/0.txt", rootc); write_file(p, "0");
snprintf(p, sizeof p, "%s/A/d1/1.txt", rootc); write_file(p, "1");
snprintf(p, sizeof p, "%s/A/d2/0.txt", rootc); write_file(p, "0");
snprintf(p, sizeof p, "%s/A/d2/1.txt", rootc); write_file(p, "1");
shelf_book cb[2];
shelf_scan_report rep6;
int ncb = shelf_scan_books(rootc, cb, 2, NULL, &rep6);
CHECK(ncb == 2 && rep6.truncated == 1, "cap 卡在目录边界时 truncated=1(不静默)");
/* 反向:恰好装满且后面没有书(只剩空目录)不能误报 truncated */
char rootd[400];
snprintf(rootd, sizeof rootd, "%s/capok", tmproot);
snprintf(p, sizeof p, "%s/A", rootd); mkdirs(p);
snprintf(p, sizeof p, "%s/A/0.txt", rootd); write_file(p, "0");
snprintf(p, sizeof p, "%s/A/1.txt", rootd); write_file(p, "1");
snprintf(p, sizeof p, "%s/Empty", rootd); mkdirs(p);
shelf_book cb2[2];
shelf_scan_report rep7;
int ncb2 = shelf_scan_books(rootd, cb2, 2, NULL, &rep7);
CHECK(ncb2 == 2 && rep7.truncated == 0, "恰好装满且后续为空目录 → 不误报 truncated");
/* 不存在的根: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.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_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,
"a/c 的 folder 副题含 c");
CHECK(find_book(mb2, nm2, "1") && strcmp(find_book(mb2, nm2, "1")->sub, "TXT · 1B") == 0,
"a/b 的 format 副题仍与 base 一致");
/* G5.2 字面名「未分类」的目录与根组是两个独立键(不按显示名合并) */
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/轻小说/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,86 @@
/* 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/depth_skipped。
*
* ⚠ 计时精度:本工具用 /proc/uptime(整数、10ms 分辨率),因为 zig mipsel musl 的
* time64/浮点 ABI 在真机上不可用(见 patch_v2/sdk/CLOCK_ABI.md)。因此:
* - 真机:适合(扫描 595 本几十 ms 量级);
* - 宿主:只能看到 0us / 10ms 的粗值,不可用于亚毫秒结论;宿主请改用 clock_gettime
* 另写临时程序,或直接读函数的返回值。(M27 r1 报文里的 0.4ms/1.3ms 是切换前用
* clock_gettime+double 的宿主临时版本测的,不是本工具输出的。)
*/
#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, 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;
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;
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 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 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, skipped);
return 0;
}