图库: 书库改文件管理器模型 + 关闭缩略图 + 根锁定/无卡容错(M18)
用户反馈四件事一起改: - 「目录里有一张图就被当成最终目录」→ 取消「目录含图=一本书」全部链路 (探测/页数/封面/续读角标/懒算 worker/sr2 补位),列表 = 目录 + 图片 + .cbz/.zip(目录优先、组内自然序)。点目录=进入(空目录也进),点图片= mr_reader_open_dir_at(当前目录, 图片自然序下标),点档案册=打开档案册。 - 「文件夹浏览特别卡」→ 删除缩略图线程/磁盘 bmp 缓存/LRU/解码路径与行内 缩略图;首帧只做一次 opendir/readdir+lstat,副标题(类型·大小)扫描期 生成一次,行合成/滚动/点击零 IO 零格式化;去掉逐行 snap(自由滚动手感)。 2000 项目录扫描 ~2.8ms(opendir=1, lstat=2000),行合成 2.78ms/200 万次 (宿主实测;旧实现 2000 项 entry 含 512B cover,内存 2.7MB→1.7MB)。 - 「不要访问 SD 卡上级目录」→ 浏览根锁定 MR_ROOT_DIR:删除越根的顶栏 「上级」/library_go_parent,根隐藏顶栏返回,左缘返回到根=退出 App。 - 「没有 SD 卡不要报错」→ 根不可访问(未插卡/挂载失败/根是文件)时友好空态 「未检测到 SD 卡」+ 顶栏刷新/重进页面重试;子目录不可读才弹提示横幅。 集成陷阱:书库图片下标与阅读器目录册共享同一套过滤器(隐藏项/图片扩展名/ 普通文件/符号链接)与 mr_natural_cmp,test_browse_lazy 直接对比 mr_dir_book_init 顺序,test_open_book_host 断言「点 img10 就开 img10」。 reader 侧 M17 遗留:mr_reader_open_dir_at 在阅读页已激活时先 pop 再 push, 走完整 leave/enter(SDK 对同 view 重复 push 是 no-op 且会压重复 activity)。 回归:run_tests.sh / run_webtoon_tests.sh 全 PASS;交叉构建零警告。
This commit is contained in:
1 parent
d07b99056f
commit
64336f95d9
16 files changed
+940
-1927
No files matched your search
@@ -7,6 +7,19 @@
|
||||
./pack_tuku.sh # 打包 → os.meow.picbrowser.mpk(根布局;未签名)
|
||||
```
|
||||
|
||||
## 浏览交互(文件管理器模型,M18)
|
||||
|
||||
- 列表 = 当前目录一层的**子目录 + 受支持图片(png/jpg/jpeg/bmp)+ 档案册(.cbz/.zip)**;
|
||||
目录优先,组内自然序(大小写不敏感,同键按原始字节兜底,`A.jpg`/`a.jpg` 不抖动)。
|
||||
- 点目录 = 进入(空目录也进);点图片 = 从该图开始读(下标与阅读器目录册同一套过滤器/比较器);
|
||||
点档案册 = 打开档案册(第 1 页/续读)。
|
||||
- 缩略图功能已关闭:行内只有类型图标 + 文件名 + 类型·大小;首帧只做单层
|
||||
`opendir/readdir + lstat`,滚动/点击路径不解码、不访问文件、不重排字符串。
|
||||
- 浏览根锁定 TF 卡根(`/usr/data/mnt/sd_0`):根目录不显示返回箭头,左缘返回 = 退出 App;
|
||||
**不允许上溯到 SD 卡的上级目录**。
|
||||
- 根目录不可访问(未插卡/挂载失败)→ 友好空态「未检测到 SD 卡」;点顶栏刷新或重进页面即重扫,
|
||||
不弹错、不崩溃。
|
||||
|
||||
## 一键签名出包
|
||||
|
||||
```sh
|
||||
|
||||
+1
-2
@@ -5,8 +5,7 @@
|
||||
* - 页面切换统一走 meow_view_page_push/pop(activity 栈 + on_enter/on_leave 唯一入口);
|
||||
* - 物理键不映射返回/确认(on_key 仅日志);屏底上滑由 SDK 系统手势接管,App 不拦截;
|
||||
* - 左缘返回:reader/settings=pop;lock 页自行消费;书库逐级上移,
|
||||
* **TF 根=退出 App**(系统返回语义,不再上溯系统目录);TF 根以外的上级浏览
|
||||
* 走顶栏显式「上级」动作。
|
||||
* **到 TF 卡根=退出 App**(浏览根锁定,不再上溯系统目录;顶栏也没有越根的「上级」)。
|
||||
*
|
||||
* 主题同步:注册 meow_theme_add_listener;明暗/风格族切换时更新页面底色并整帧 invalidate,
|
||||
* 各页 on_draw / SDK 控件下一帧即用新语义色重绘(不缓存颜色)。
|
||||
|
||||
+64
-322
@@ -1,28 +1,19 @@
|
||||
/* logic/library.c — 书库目录浏览:单层枚举 + 后台懒判定(纯 C)。
|
||||
/* logic/library.c — 书库文件管理器模型:单层枚举 + 纯内存行合成(纯 C)。
|
||||
*
|
||||
* 扫描(mr_library_scan / mr_library_scan_ex)与 os.meow.files 的 fsx_scan_dir 同构:
|
||||
* - 一次 opendir/readdir,逐项 lstat(只认目录与档案文件;符号链接跳过防环);
|
||||
* - 隐藏项("." 开头,含 . ..)跳过;系统目录/空目录/无图目录一律列出;
|
||||
* - 目录名/档案文件名按 mr_natural_cmp 自然升序;条目到 MR_LIB_SCAN_MAX 后仍有条目 →
|
||||
* 截断标注。不再对子目录做任何递归/探测——首帧成本只与本层条目数线性相关。
|
||||
* 扫描(mr_library_scan)与 os.meow.files 的 fsx_scan_dir 同构:
|
||||
* - 一次 opendir/readdir,逐项 lstat(只认目录、受支持图片、.cbz/.zip;符号链接跳过防环);
|
||||
* - 隐藏项("." 开头,含 . ..)跳过;空目录/无图目录/系统目录照常列出;
|
||||
* - 排序:目录优先,组内按 mr_natural_cmp 自然升序;
|
||||
* - 副标题(类型 · 大小)在扫描期生成一次,渲染期不再做任何格式化/IO;
|
||||
* - 条目到 MR_LIB_SCAN_MAX 后仍有条目 → 截断标注。
|
||||
*
|
||||
* 懒判定(probe):
|
||||
* - mr_library_probe_dir 只读一层(图片项 lstat 校验普通文件),给出直接图片数与自然序首图;
|
||||
* - mr_library_probe_path 按行类型探测:目录走上面的单层扫描;档案册调调用方注入的
|
||||
* archive_pages(M11,logic 不依赖 zip)。两者共用 mtime(秒+纳秒)+size 失效的 256 项
|
||||
* LRU 缓存 + 互斥锁,可被后台线程并发调用;探测期间目录/档案发生变化则不写缓存。
|
||||
* - mr_library_apply_probe 把结果补回行;副标题由 mr_library_format_subtitle 统一生成,
|
||||
* 书行再拼 M7 的「· 续读 Pn」角标。
|
||||
*
|
||||
* M11 档案册:注入 archive_pages 时 .cbz/.zip 直接子文件也成行(懒探测页数);
|
||||
* 未注入时只列目录(M7 书库语义)。册间衔接的兄弟表 = 含图目录 ∪ 含图片页的档案册。
|
||||
* 阅读器辅助(与列表模型解耦):mr_library_dir_has_images 单层判据 +
|
||||
* mr_book_adjacent(_ex) 兄弟册衔接(M7/M11 阅读器翻到相邻册用)。
|
||||
*/
|
||||
#include "library.h"
|
||||
#include "logic/reader.h" /* mr_cont_badge:书库行续读角标(M7) */
|
||||
|
||||
#include <ctype.h>
|
||||
#include <dirent.h>
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
@@ -138,244 +129,39 @@ int mr_natural_cmp(const char *a, const char *b)
|
||||
return strcmp(a, b) < 0 ? -1 : (strcmp(a, b) > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
/* 档案册探测(UI 注入;NULL=纯目录书库,档案文件不成行)。 */
|
||||
static int archive_pages_at(mr_lib_archive_fn probe, void *ud, const char *path)
|
||||
{
|
||||
if (!probe || !path || !path[0]) return 0;
|
||||
int n = probe(path, ud);
|
||||
return n > 0 ? n : 0;
|
||||
}
|
||||
|
||||
/* ============================================================ */
|
||||
/* 单层探测 + mtime/size 失效缓存 */
|
||||
/* 扫描 / 排序 / 行合成 */
|
||||
/* ============================================================ */
|
||||
int mr_library_probe_dir(const char *dir, mr_library_probe *out)
|
||||
static void fmt_size(long long size, char *out, int outsz)
|
||||
{
|
||||
if (!out) return -1;
|
||||
memset(out, 0, sizeof *out);
|
||||
if (!dir || !dir[0]) return -1;
|
||||
|
||||
IO_OPEN();
|
||||
DIR *d = opendir(dir);
|
||||
if (!d) return -1;
|
||||
|
||||
char best[MR_NAME_MAX];
|
||||
best[0] = '\0';
|
||||
out->empty = 1;
|
||||
struct dirent *de;
|
||||
while ((de = readdir(d)) != NULL) {
|
||||
IO_READDIR();
|
||||
const char *name = de->d_name;
|
||||
if (name[0] == '.') continue; /* 隐藏项不算内容 */
|
||||
out->empty = 0;
|
||||
if (!mr_path_is_image(name)) continue;
|
||||
|
||||
char full[MR_PATH_MAX];
|
||||
if (join_path(full, (int)sizeof full, dir, name) != 0) continue;
|
||||
IO_LSTAT();
|
||||
struct stat st;
|
||||
if (lstat(full, &st) != 0 || !S_ISREG(st.st_mode)) continue;
|
||||
|
||||
out->pages++;
|
||||
if (best[0] == '\0' || mr_natural_cmp(name, best) < 0) {
|
||||
copy_str(best, (int)sizeof best, name);
|
||||
copy_str(out->cover, (int)sizeof out->cover, full);
|
||||
}
|
||||
}
|
||||
closedir(d);
|
||||
return 0;
|
||||
if (size < 0) size = 0;
|
||||
static const char *const unit[] = { "B", "KB", "MB", "GB" };
|
||||
double v = (double)size;
|
||||
int i = 0;
|
||||
while (v >= 1024.0 && i < 3) { v /= 1024.0; i++; }
|
||||
if (i == 0) snprintf(out, (size_t)outsz, "%lld B", size);
|
||||
else snprintf(out, (size_t)outsz, "%.1f %s", v, unit[i]);
|
||||
}
|
||||
|
||||
#define MR_PROBE_CACHE_MAX 256
|
||||
|
||||
typedef struct probe_cache_entry {
|
||||
char path[MR_PATH_MAX];
|
||||
long long mtime_ns;
|
||||
long long size;
|
||||
mr_library_probe probe;
|
||||
unsigned long long last_use;
|
||||
} probe_cache_entry;
|
||||
|
||||
static probe_cache_entry *g_cache[MR_PROBE_CACHE_MAX];
|
||||
static unsigned long long g_cache_clock;
|
||||
static pthread_mutex_t g_cache_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
static long long stat_mtime_ns(const struct stat *st)
|
||||
/* 副标题只在扫描期生成一次(渲染期零格式化)。 */
|
||||
static void format_subtitle(mr_library_entry *e)
|
||||
{
|
||||
#if defined(__APPLE__)
|
||||
return (long long)st->st_mtimespec.tv_sec * 1000000000LL + st->st_mtimespec.tv_nsec;
|
||||
#else
|
||||
return (long long)st->st_mtim.tv_sec * 1000000000LL + st->st_mtim.tv_nsec;
|
||||
#endif
|
||||
}
|
||||
|
||||
static probe_cache_entry *cache_find_locked(const char *path)
|
||||
{
|
||||
for (int i = 0; i < MR_PROBE_CACHE_MAX; i++) {
|
||||
if (!g_cache[i]) continue;
|
||||
if (strcmp(g_cache[i]->path, path) == 0) return g_cache[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void cache_put_locked(const char *path, long long mtime_ns, long long size,
|
||||
const mr_library_probe *p)
|
||||
{
|
||||
probe_cache_entry *e = cache_find_locked(path);
|
||||
if (!e) {
|
||||
int slot = -1;
|
||||
for (int i = 0; i < MR_PROBE_CACHE_MAX; i++)
|
||||
if (!g_cache[i]) { slot = i; break; }
|
||||
if (slot < 0) { /* 满:淘汰最久未用项 */
|
||||
int lru = 0;
|
||||
unsigned long long oldest = g_cache[0]->last_use;
|
||||
for (int i = 1; i < MR_PROBE_CACHE_MAX; i++) {
|
||||
if (g_cache[i]->last_use < oldest) {
|
||||
oldest = g_cache[i]->last_use;
|
||||
lru = i;
|
||||
}
|
||||
}
|
||||
free(g_cache[lru]);
|
||||
g_cache[lru] = NULL;
|
||||
slot = lru;
|
||||
}
|
||||
e = (probe_cache_entry *)malloc(sizeof *e);
|
||||
if (!e) return; /* 缓存只是加速:失败就每次现算 */
|
||||
if (copy_str(e->path, (int)sizeof e->path, path) != 0) {
|
||||
free(e);
|
||||
return;
|
||||
}
|
||||
g_cache[slot] = e;
|
||||
}
|
||||
e->mtime_ns = mtime_ns;
|
||||
e->size = size;
|
||||
e->probe = *p;
|
||||
e->last_use = ++g_cache_clock;
|
||||
}
|
||||
|
||||
void mr_library_probe_cache_clear(void)
|
||||
{
|
||||
pthread_mutex_lock(&g_cache_lock);
|
||||
for (int i = 0; i < MR_PROBE_CACHE_MAX; i++) {
|
||||
free(g_cache[i]);
|
||||
g_cache[i] = NULL;
|
||||
}
|
||||
g_cache_clock = 0;
|
||||
pthread_mutex_unlock(&g_cache_lock);
|
||||
}
|
||||
|
||||
/* 无缓存探测:目录=单层目录扫描;档案册=注入回调取页数。 */
|
||||
static int probe_path_uncached(const char *path, int is_archive,
|
||||
mr_lib_archive_fn archive_pages, void *ud,
|
||||
mr_library_probe *out)
|
||||
{
|
||||
struct stat st;
|
||||
if (stat(path, &st) != 0) return -1;
|
||||
if (is_archive) {
|
||||
if (!archive_pages || !S_ISREG(st.st_mode)) return -1;
|
||||
out->pages = archive_pages_at(archive_pages, ud, path);
|
||||
out->empty = 0;
|
||||
out->cover[0] = '\0';
|
||||
return 0;
|
||||
}
|
||||
if (!S_ISDIR(st.st_mode)) return -1;
|
||||
return mr_library_probe_dir(path, out);
|
||||
}
|
||||
|
||||
int mr_library_probe_path(const char *path, int is_archive,
|
||||
mr_lib_archive_fn archive_pages, void *ud,
|
||||
mr_library_probe *out)
|
||||
{
|
||||
if (!out) return -1;
|
||||
memset(out, 0, sizeof *out);
|
||||
if (!path || !path[0]) return -1;
|
||||
|
||||
struct stat st;
|
||||
if (stat(path, &st) != 0) return -1;
|
||||
if (is_archive ? !S_ISREG(st.st_mode) : !S_ISDIR(st.st_mode)) return -1;
|
||||
|
||||
long long mtime_ns = stat_mtime_ns(&st);
|
||||
long long size = (long long)st.st_size;
|
||||
|
||||
pthread_mutex_lock(&g_cache_lock);
|
||||
probe_cache_entry *hit = cache_find_locked(path);
|
||||
if (hit && hit->mtime_ns == mtime_ns && hit->size == size) {
|
||||
*out = hit->probe;
|
||||
hit->last_use = ++g_cache_clock;
|
||||
pthread_mutex_unlock(&g_cache_lock);
|
||||
return 0;
|
||||
}
|
||||
pthread_mutex_unlock(&g_cache_lock);
|
||||
|
||||
mr_library_probe p;
|
||||
memset(&p, 0, sizeof p);
|
||||
if (probe_path_uncached(path, is_archive, archive_pages, ud, &p) != 0) return -1;
|
||||
|
||||
/* 评审 P2:只把「探测前后完全一致」的结果写入缓存。
|
||||
* 探测期间目录/档案的 mtime 或 size 变了(新增/替换文件),说明结果可能不完整,
|
||||
* 不能绑定到新 mtime 上当「新鲜」——本次直接返回,下次调用重新探测。 */
|
||||
struct stat st2;
|
||||
if (stat(path, &st2) == 0 && stat_mtime_ns(&st2) == mtime_ns &&
|
||||
(long long)st2.st_size == size) {
|
||||
pthread_mutex_lock(&g_cache_lock);
|
||||
cache_put_locked(path, mtime_ns, size, &p);
|
||||
pthread_mutex_unlock(&g_cache_lock);
|
||||
}
|
||||
|
||||
*out = p;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int mr_library_probe_cached(const char *dir, mr_library_probe *out)
|
||||
{
|
||||
return mr_library_probe_path(dir, 0, NULL, NULL, out);
|
||||
}
|
||||
|
||||
/* ============================================================ */
|
||||
/* 行状态:副标题 / 补位 / 行合成 */
|
||||
/* ============================================================ */
|
||||
/* 基础副标题(不含 M7 续读角标)。 */
|
||||
static void entry_subtitle(mr_library_entry *e)
|
||||
{
|
||||
switch (e->probe_state) {
|
||||
case MR_LIB_READY:
|
||||
if (e->pages > 0)
|
||||
snprintf(e->subtitle, sizeof e->subtitle, "%d page%s", e->pages,
|
||||
e->pages == 1 ? "" : "s");
|
||||
else if (!e->is_archive && e->empty)
|
||||
snprintf(e->subtitle, sizeof e->subtitle, "空目录");
|
||||
else
|
||||
snprintf(e->subtitle, sizeof e->subtitle, "无图片");
|
||||
char sz[32];
|
||||
switch (e->kind) {
|
||||
case MR_LIB_KIND_DIR:
|
||||
snprintf(e->subtitle, sizeof e->subtitle, "文件夹");
|
||||
break;
|
||||
case MR_LIB_FAILED:
|
||||
snprintf(e->subtitle, sizeof e->subtitle, "无法读取");
|
||||
case MR_LIB_KIND_ARCHIVE:
|
||||
fmt_size(e->size, sz, (int)sizeof sz);
|
||||
snprintf(e->subtitle, sizeof e->subtitle, "档案册 · %s", sz);
|
||||
break;
|
||||
default:
|
||||
snprintf(e->subtitle, sizeof e->subtitle, "…");
|
||||
fmt_size(e->size, sz, (int)sizeof sz);
|
||||
snprintf(e->subtitle, sizeof e->subtitle, "图片 · %s", sz);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* 续读角标(M7):有 last page 记录的书行,在副标题后缀「· 续读 Pn」。
|
||||
* 档案册以档案路径为册标识,同一套逻辑复用。
|
||||
* config 读失败/无记录时 mr_cont_badge 返回 0,副标题保持原样。 */
|
||||
static void entry_badge(mr_library_entry *e)
|
||||
{
|
||||
if (e->has_images != 1) return;
|
||||
char badge[24];
|
||||
if (!mr_cont_badge(e->path, badge, (int)sizeof badge)) return;
|
||||
int used = (int)strlen(e->subtitle);
|
||||
if (used < (int)sizeof e->subtitle - 2)
|
||||
snprintf(e->subtitle + used, sizeof e->subtitle - (size_t)used, " · %s", badge);
|
||||
}
|
||||
|
||||
void mr_library_format_subtitle(mr_library_entry *e)
|
||||
{
|
||||
if (!e) return;
|
||||
entry_subtitle(e);
|
||||
entry_badge(e);
|
||||
}
|
||||
|
||||
void mr_library_free(mr_library *lib)
|
||||
{
|
||||
if (!lib) return;
|
||||
@@ -400,59 +186,32 @@ static int lib_add(mr_library *lib, const mr_library_entry *e)
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* 目录优先;组内自然序(大小写不敏感 + 原始字节兜底,结果确定)。 */
|
||||
static int entry_cmp(const void *pa, const void *pb)
|
||||
{
|
||||
const mr_library_entry *a = (const mr_library_entry *)pa;
|
||||
const mr_library_entry *b = (const mr_library_entry *)pb;
|
||||
int ad = (a->kind == MR_LIB_KIND_DIR), bd = (b->kind == MR_LIB_KIND_DIR);
|
||||
if (ad != bd) return ad ? -1 : 1;
|
||||
return mr_natural_cmp(a->name, b->name);
|
||||
}
|
||||
|
||||
/* 文件夹册行(M13):一律列出、初始待探测;是否「书」由后台 probe 决定。 */
|
||||
static int add_dir_entry(mr_library *lib, const char *name, const char *full)
|
||||
static int add_entry(mr_library *lib, const char *name, const char *full,
|
||||
int kind, const struct stat *st)
|
||||
{
|
||||
mr_library_entry e;
|
||||
memset(&e, 0, sizeof e);
|
||||
if (copy_str(e.name, (int)sizeof e.name, name) != 0) return 0;
|
||||
if (copy_str(e.path, (int)sizeof e.path, full) != 0) return 0;
|
||||
e.pages = -1;
|
||||
e.has_images = -1;
|
||||
e.probe_state = MR_LIB_PENDING;
|
||||
mr_library_format_subtitle(&e);
|
||||
e.kind = kind;
|
||||
e.size = (kind == MR_LIB_KIND_DIR) ? 0 : (long long)st->st_size;
|
||||
format_subtitle(&e);
|
||||
return lib_add(lib, &e);
|
||||
}
|
||||
|
||||
/* 档案册(.cbz/.zip)行:文件即一本书,点入直接阅读。
|
||||
* pages<0=待后台探测(M13 懒算);>=0=已知页数(扫描期注入的旧路径/测试用)。
|
||||
* cover 留空——缩略图由 UI 解压档内首图(mr_zip_cover_path)生成。 */
|
||||
static int add_archive_entry(mr_library *lib, const char *name, const char *full,
|
||||
int pages)
|
||||
int mr_library_scan(mr_library *lib, const char *dir, int max_depth)
|
||||
{
|
||||
mr_library_entry e;
|
||||
memset(&e, 0, sizeof e);
|
||||
if (copy_str(e.name, (int)sizeof e.name, name) != 0) return 0;
|
||||
if (copy_str(e.path, (int)sizeof e.path, full) != 0) return 0;
|
||||
e.is_archive = 1;
|
||||
if (pages < 0) {
|
||||
e.pages = -1;
|
||||
e.has_images = -1;
|
||||
e.probe_state = MR_LIB_PENDING;
|
||||
} else {
|
||||
e.pages = pages;
|
||||
e.has_images = (pages > 0) ? 1 : 0;
|
||||
e.probe_state = MR_LIB_READY;
|
||||
}
|
||||
mr_library_format_subtitle(&e);
|
||||
return lib_add(lib, &e);
|
||||
}
|
||||
|
||||
/* ============================================================ */
|
||||
/* 单层扫描 */
|
||||
/* ============================================================ */
|
||||
int mr_library_scan_ex(mr_library *lib, const char *dir, int max_depth,
|
||||
mr_lib_archive_fn archive_pages, void *ud)
|
||||
{
|
||||
(void)max_depth; /* 兼容旧签名:扫描不再递归 */
|
||||
(void)ud;
|
||||
(void)max_depth; /* 兼容旧签名:扫描不递归 */
|
||||
if (!lib) return -1;
|
||||
mr_library_free(lib);
|
||||
if (!dir || !dir[0]) return -1;
|
||||
@@ -475,15 +234,15 @@ int mr_library_scan_ex(mr_library *lib, const char *dir, int max_depth,
|
||||
if (lstat(full, &st) != 0) continue;
|
||||
if (S_ISLNK(st.st_mode)) continue;
|
||||
|
||||
int is_dir = S_ISDIR(st.st_mode);
|
||||
int is_arch = (!is_dir && archive_pages && S_ISREG(st.st_mode) &&
|
||||
mr_path_is_archive(name));
|
||||
if (!is_dir && !is_arch) continue;
|
||||
if (lib->count >= MR_LIB_SCAN_MAX) { lib->truncated = 1; break; }
|
||||
int kind;
|
||||
if (S_ISDIR(st.st_mode)) kind = MR_LIB_KIND_DIR;
|
||||
else if (!S_ISREG(st.st_mode)) continue;
|
||||
else if (mr_path_is_image(name)) kind = MR_LIB_KIND_IMAGE;
|
||||
else if (mr_path_is_archive(name)) kind = MR_LIB_KIND_ARCHIVE;
|
||||
else continue;
|
||||
|
||||
int rc = is_dir ? add_dir_entry(lib, name, full)
|
||||
: add_archive_entry(lib, name, full, -1);
|
||||
if (rc != 0) {
|
||||
if (lib->count >= MR_LIB_SCAN_MAX) { lib->truncated = 1; break; }
|
||||
if (add_entry(lib, name, full, kind, &st) != 0) {
|
||||
closedir(d);
|
||||
mr_library_free(lib);
|
||||
return -1;
|
||||
@@ -496,37 +255,6 @@ int mr_library_scan_ex(mr_library *lib, const char *dir, int max_depth,
|
||||
return lib->count;
|
||||
}
|
||||
|
||||
int mr_library_scan(mr_library *lib, const char *dir, int max_depth)
|
||||
{
|
||||
return mr_library_scan_ex(lib, dir, max_depth, NULL, NULL);
|
||||
}
|
||||
|
||||
int mr_library_apply_probe(mr_library *lib, int idx, const mr_library_probe *p)
|
||||
{
|
||||
if (!lib || !p || idx < 0 || idx >= lib->count) return 0;
|
||||
mr_library_entry *e = &lib->items[idx];
|
||||
e->pages = p->pages;
|
||||
e->empty = p->empty;
|
||||
e->has_images = (p->pages > 0) ? 1 : 0;
|
||||
e->probe_state = MR_LIB_READY;
|
||||
copy_str(e->cover, (int)sizeof e->cover, p->cover);
|
||||
mr_library_format_subtitle(e);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int mr_library_mark_error(mr_library *lib, int idx)
|
||||
{
|
||||
if (!lib || idx < 0 || idx >= lib->count) return 0;
|
||||
mr_library_entry *e = &lib->items[idx];
|
||||
e->pages = -1;
|
||||
e->empty = 0;
|
||||
e->has_images = 0;
|
||||
e->probe_state = MR_LIB_FAILED;
|
||||
e->cover[0] = '\0';
|
||||
mr_library_format_subtitle(e);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int mr_library_row_at(const mr_library *lib, int idx, mr_library_row *out)
|
||||
{
|
||||
static char trunc_sub[48];
|
||||
@@ -551,12 +279,21 @@ int mr_library_row_at(const mr_library *lib, int idx, mr_library_row *out)
|
||||
memset(out, 0, sizeof *out);
|
||||
out->title = e->name;
|
||||
out->subtitle = e->subtitle;
|
||||
out->is_book = (e->has_images == 1);
|
||||
out->is_archive = e->is_archive;
|
||||
out->kind = e->kind;
|
||||
out->index = idx;
|
||||
return 1;
|
||||
}
|
||||
|
||||
int mr_library_image_index(const mr_library *lib, int idx)
|
||||
{
|
||||
if (!lib || idx < 0 || idx >= lib->count) return -1;
|
||||
if (lib->items[idx].kind != MR_LIB_KIND_IMAGE) return -1;
|
||||
int n = 0;
|
||||
for (int i = 0; i < idx; i++)
|
||||
if (lib->items[i].kind == MR_LIB_KIND_IMAGE) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
int mr_path_parent(const char *path, char *out, int outsz)
|
||||
{
|
||||
if (!path || !out || outsz <= 0) return -1;
|
||||
@@ -582,16 +319,19 @@ int mr_path_parent(const char *path, char *out, int outsz)
|
||||
int mr_library_dir_has_images(const char *dir)
|
||||
{
|
||||
if (!dir || !dir[0]) return 0;
|
||||
IO_OPEN();
|
||||
DIR *d = opendir(dir);
|
||||
if (!d) return 0;
|
||||
|
||||
int found = 0;
|
||||
struct dirent *de;
|
||||
while ((de = readdir(d)) != NULL) {
|
||||
IO_READDIR();
|
||||
const char *name = de->d_name;
|
||||
if (name[0] == '.' || !mr_path_is_image(name)) continue;
|
||||
char full[MR_PATH_MAX];
|
||||
if (join_path(full, (int)sizeof full, dir, name) != 0) continue;
|
||||
IO_LSTAT();
|
||||
struct stat st;
|
||||
if (lstat(full, &st) != 0 || !S_ISREG(st.st_mode)) continue;
|
||||
found = 1;
|
||||
@@ -618,7 +358,9 @@ static int sibling_is_book(const char *full, const struct stat *st,
|
||||
const char *slash = strrchr(full, '/');
|
||||
const char *name = slash ? slash + 1 : full;
|
||||
if (!mr_path_is_archive(name)) return 0;
|
||||
return archive_pages_at(probe, ud, full) > 0;
|
||||
if (!probe) return 0;
|
||||
int n = probe(full, ud);
|
||||
return n > 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
+46
-106
@@ -1,25 +1,18 @@
|
||||
/* logic/library.h — 书库目录浏览(纯 C,不依赖 meow_ui.h)。
|
||||
/* logic/library.h — 书库文件管理器模型(纯 C,不依赖 meow_ui.h)。
|
||||
*
|
||||
* 分层契约同 examples/hello:logic 层只含文件系统与纯数据,宿主 gcc 可直接裸测。
|
||||
*
|
||||
* M13 浏览语义(用户要求:性能 + 访问放开;参考 os.meow.files 的 fsx_scan_dir):
|
||||
* - mr_library_scan / mr_library_scan_ex 只枚举**当前目录一层**:一次 opendir/readdir +
|
||||
* 逐项 lstat,列出全部子目录(自然升序),不做任何递归「子树含图」探测;
|
||||
* - 访问放开:系统目录(MeowAPPs/MeowData/MeowOS/lost+found)不再隐藏,
|
||||
* 空目录/无图目录一律列出并可进入;仅跳过 "."/".." 与其它 "." 开头的隐藏项、
|
||||
* 符号链接(防环);
|
||||
* - 「直接含图目录=一本书」的判定改为**后台懒算**:mr_library_probe_dir 只读一层,
|
||||
* mr_library_probe_path 按行类型(目录/档案册)走同一套 mtime 失效缓存(线程安全),
|
||||
* 结果经 mr_library_apply_probe 补回行(页数/首图/副标题)。首帧/滚动不做子目录 IO;
|
||||
* - 单目录条目上限 MR_LIB_SCAN_MAX(默认 2000):到上限后若还有条目则截断并置
|
||||
* lib->truncated=1(UI 用 mr_library_row_at 末尾的提示行标注)。
|
||||
* M18 浏览语义(用户要求「完全像文件管理器」):
|
||||
* - 列表 = 当前目录一层的「子目录 + 受支持图片(png/jpg/jpeg/bmp) + 档案册(.cbz/.zip)」;
|
||||
* 目录优先,组内自然序;其它普通文件不列;隐藏项("." 开头)与符号链接跳过;
|
||||
* - **没有**「目录含图=一本书」的判定:不探测、不算页数、不取封面、不拼续读角标;
|
||||
* 点目录就是进目录(空目录也进),点图片从该图开始读,点档案册打开档案册;
|
||||
* - 扫描只做一次 opendir/readdir + 逐项 lstat;副标题(类型 · 大小)在扫描时生成一次,
|
||||
* 渲染期零 IO、零字符串格式化(UI row 回调直接取指针);
|
||||
* - 单目录条目上限 MR_LIB_SCAN_MAX:到上限后若还有条目则截断并置 lib->truncated=1。
|
||||
*
|
||||
* M11 档案册(.cbz/.zip):注入 archive_pages 回调时,当前层的档案文件也成行(与目录同列
|
||||
* 自然序混排);档案册的页数同样走懒探测(mr_library_probe_path + 注入回调),logic 层
|
||||
* 不依赖 zip 容器。未注入时(archive_pages==NULL)只列目录,等价 M7 书库。
|
||||
*
|
||||
* M7 续读角标 / 册间衔接:mr_library_format_subtitle 在探测完成后拼「· 续读 Pn」
|
||||
* (mr_cont_badge);mr_library_dir_has_images / mr_book_adjacent(_ex) 语义不变。
|
||||
* 仍保留的阅读器辅助:mr_library_dir_has_images / mr_book_adjacent(_ex)
|
||||
* (阅读到册末时翻到同父目录的相邻册,与书库列表模型无关)。
|
||||
*/
|
||||
#ifndef MR_LIBRARY_H
|
||||
#define MR_LIBRARY_H
|
||||
@@ -30,149 +23,96 @@ extern "C" {
|
||||
|
||||
#define MR_PATH_MAX 512
|
||||
#define MR_NAME_MAX 256
|
||||
#define MR_LIB_MAX_DEPTH 8 /* 兼容旧调用:扫描已不递归,该参数被忽略 */
|
||||
#define MR_LIB_MAX_DEPTH 8 /* 兼容旧调用:扫描不递归,该参数被忽略 */
|
||||
|
||||
/* 单目录列出的条目上限(超大目录截断 + UI 提示;可用 -D 覆写便于宿主测试)。 */
|
||||
#ifndef MR_LIB_SCAN_MAX
|
||||
#define MR_LIB_SCAN_MAX 2000
|
||||
#endif
|
||||
|
||||
/* 行懒判定状态:0=待探测(副标题占位「…」)/1=已探测/2=探测失败(不可读)。 */
|
||||
#define MR_LIB_PENDING 0
|
||||
#define MR_LIB_READY 1
|
||||
#define MR_LIB_FAILED 2
|
||||
/* 行类型(列表图标/点击行为由 UI 按此分派)。 */
|
||||
#define MR_LIB_KIND_DIR 0 /* 子目录:点=进入 */
|
||||
#define MR_LIB_KIND_IMAGE 1 /* 受支持图片:点=从该图开始阅读 */
|
||||
#define MR_LIB_KIND_ARCHIVE 2 /* .cbz/.zip 档案册:点=打开档案册 */
|
||||
|
||||
typedef struct mr_library_entry {
|
||||
char name[MR_NAME_MAX]; /* 目录名/档案文件名(行主标题) */
|
||||
char path[MR_PATH_MAX]; /* 完整路径(导航/进 reader/探测用) */
|
||||
char cover[MR_PATH_MAX]; /* 后台探测填:自然序第一张直接子图(档案册留空,UI 解压首图) */
|
||||
char subtitle[48]; /* 占位「…」;探测后 "N pages"/"空目录"/"无图片"/"无法读取" */
|
||||
int pages; /* -1=未判定;>=0=直接图片数(档案册=档内图片页数) */
|
||||
int subdirs; /* 兼容字段(M11 及旧读者):新扫描不统计,恒 0 */
|
||||
int has_images; /* 1=书;0=非书;-1=未判定 */
|
||||
int probe_state; /* MR_LIB_PENDING / MR_LIB_READY / MR_LIB_FAILED */
|
||||
int empty; /* 1=空目录(目录且 probe_state==READY 后有效) */
|
||||
int is_archive; /* 1=.cbz/.zip 档案册行 */
|
||||
char name[MR_NAME_MAX]; /* 文件名/目录名(行主标题) */
|
||||
char path[MR_PATH_MAX]; /* 完整路径 */
|
||||
char subtitle[64]; /* 扫描时预生成一次:「文件夹」/「图片 · 1.2 MB」/「档案册 · 1.2 MB」 */
|
||||
long long size; /* 普通文件字节数;目录=0 */
|
||||
int kind; /* MR_LIB_KIND_* */
|
||||
} mr_library_entry;
|
||||
|
||||
typedef struct mr_library {
|
||||
mr_library_entry *items;
|
||||
int count; /* 当前层列出的条目数 */
|
||||
int count; /* 当前层列出的条目数 */
|
||||
int cap;
|
||||
int truncated; /* 1=达到 MR_LIB_SCAN_MAX 被截断 */
|
||||
int truncated; /* 1=达到 MR_LIB_SCAN_MAX 被截断 */
|
||||
} mr_library;
|
||||
|
||||
/* 单行探测结果(目录=本层直接图片数;档案册=档内图片页数;均不递归)。 */
|
||||
typedef struct mr_library_probe {
|
||||
int pages; /* 受支持图片数(png/jpg/jpeg/bmp;档案册=档内页数) */
|
||||
int empty; /* 1=目录没有可见子项(档案册恒 0) */
|
||||
char cover[MR_PATH_MAX]; /* 目录的自然序第一张图(档案册留空) */
|
||||
} mr_library_probe;
|
||||
|
||||
/* 行数据(纯内存合成;UI 的每帧 src 回调直接取用,零 IO)。 */
|
||||
/* 行数据(纯内存合成;UI 的每帧 src 回调直接取用,零 IO、零格式化)。 */
|
||||
typedef struct mr_library_row {
|
||||
const char *title; /* 主标题 */
|
||||
const char *subtitle; /* 副标题(可空串) */
|
||||
int is_book; /* 1=书(显示页数/缩略图,点入阅读,无进入箭头) */
|
||||
int is_archive; /* 1=档案册行 */
|
||||
int disabled; /* 1=提示行(不可点,如截断标注) */
|
||||
int index; /* 对应 mr_library.items 下标;提示行=-1 */
|
||||
const char *title; /* 主标题 */
|
||||
const char *subtitle; /* 副标题(预生成,永不为 NULL) */
|
||||
int kind; /* MR_LIB_KIND_* */
|
||||
int disabled; /* 1=提示行(不可点,如截断标注) */
|
||||
int index; /* 对应 mr_library.items 下标;提示行=-1 */
|
||||
} mr_library_row;
|
||||
|
||||
/* 扩展名判断:png/jpg/jpeg/bmp(大小写不敏感)。1=受支持图片。 */
|
||||
int mr_path_is_image(const char *name);
|
||||
|
||||
/* 档案册扩展名判断:cbz / zip(大小写不敏感)。1=档案册。
|
||||
* 注意这只是路径判据;能不能当书读还要看档案里有没有图片页(探测回调)。 */
|
||||
/* 档案册扩展名判断:cbz / zip(大小写不敏感)。1=档案册。 */
|
||||
int mr_path_is_archive(const char *name);
|
||||
|
||||
/* 自然排序比较:数字段按数值比(page2 < page10),数字相等时前导零少的在前;
|
||||
* 非数字段 ASCII 大小写不敏感,完全等价时按原始字节序定序。 */
|
||||
* 非数字段 ASCII 大小写不敏感,完全等价时按原始字节序定序(结果确定,无抖动)。 */
|
||||
int mr_natural_cmp(const char *a, const char *b);
|
||||
|
||||
/* 扫描 dir 的**直接子目录**(单层;不读子目录内容),写入 lib(先释放 lib 旧内容)。
|
||||
/* 扫描 dir 的**直接子项**(单层):子目录 + 受支持图片 + 档案册,写入 lib(先释放旧内容)。
|
||||
* 返回条目数(>=0);dir 不可读/不存在/不是目录返回 -1(lib 仍为空且可安全 free)。
|
||||
* max_depth 仅为兼容旧调用保留,已忽略(不再递归)。 */
|
||||
int mr_library_scan(mr_library *lib, const char *dir, int max_depth);
|
||||
|
||||
/* ============================================================ */
|
||||
/* 档案册(M11):.cbz/.zip 当一本书,与文件夹册同列 */
|
||||
/* ------------------------------------------------------------
|
||||
* logic 层不依赖 zip 容器(保持宿主裸测/链接面干净):档案册探测由 UI 注入。
|
||||
* archive_pages(path, ud) 返回档内图片页数(>0=是一本书),0=不是/坏档。
|
||||
* 传 NULL 时行为与 M7 完全一致(纯目录书库,档案文件不成行)。
|
||||
* 档案册行:name=文件名、path=档案路径、is_archive=1、cover 留空(UI 解压首图)、
|
||||
* 页数/书标记由后台 probe 经同一注入回调懒算。
|
||||
* ============================================================ */
|
||||
typedef int (*mr_lib_archive_fn)(const char *path, void *ud);
|
||||
|
||||
int mr_library_scan_ex(mr_library *lib, const char *dir, int max_depth,
|
||||
mr_lib_archive_fn archive_pages, void *ud);
|
||||
|
||||
/* 释放条目数组并清零(可重复调用)。 */
|
||||
void mr_library_free(mr_library *lib);
|
||||
|
||||
/* 取父目录路径(去掉末尾 '/' 后截断最后一段)。根 "/" 返回 "/"。
|
||||
* 成功 0;参数非法/输出缓冲不足 -1。可一路向上到文件系统 '/'。 */
|
||||
* 成功 0;参数非法/输出缓冲不足 -1。纯路径工具,不做根目录限制(限制在 UI 导航层)。 */
|
||||
int mr_path_parent(const char *path, char *out, int outsz);
|
||||
|
||||
/* ---- 后台懒判定(单层探测;可由工作线程调用) ---- */
|
||||
|
||||
/* 单层探测目录 dir:直接图片数 + 自然序首图 + 是否空目录。不递归、不跟随符号链接。
|
||||
* 0=成功;-1=参数非法/不可读(out 清零)。 */
|
||||
int mr_library_probe_dir(const char *dir, mr_library_probe *out);
|
||||
|
||||
/* 带缓存探测目录:按目录 mtime(秒+纳秒)+ size 失效;内部互斥锁,可多线程并发调用。
|
||||
* 0=成功(可能来自缓存);-1=不可读(失败不缓存)。 */
|
||||
int mr_library_probe_cached(const char *dir, mr_library_probe *out);
|
||||
|
||||
/* 按行探测(目录或档案册),带同一套缓存:
|
||||
* is_archive=0 → 目录(等价 mr_library_probe_cached);
|
||||
* is_archive=1 → 调 archive_pages(path,ud) 取档内页数(cover 留空、empty=0);
|
||||
* archive_pages 为 NULL 时返回 -1。
|
||||
* 0=成功;-1=参数非法/不可读。探测期间 mtime/size 变化则不写入缓存(避免假新鲜)。 */
|
||||
int mr_library_probe_path(const char *path, int is_archive,
|
||||
mr_lib_archive_fn archive_pages, void *ud,
|
||||
mr_library_probe *out);
|
||||
|
||||
/* 清空探测缓存(目录整体切换/测试用)。 */
|
||||
void mr_library_probe_cache_clear(void);
|
||||
|
||||
/* 把探测结果补回第 idx 行(pages/has_images/cover/subtitle/empty)。
|
||||
* 1=已应用;0=参数非法/越界。 */
|
||||
int mr_library_apply_probe(mr_library *lib, int idx, const mr_library_probe *p);
|
||||
|
||||
/* 标记第 idx 行探测失败(不可读):副标题「无法读取」,视为非书。1=已应用;0=非法。 */
|
||||
int mr_library_mark_error(mr_library *lib, int idx);
|
||||
|
||||
/* 按行当前状态重算副标题(占位/页数/空目录/无图片/无法读取),
|
||||
* 并在书行(has_images==1)后缀「· 续读 Pn」(M7 角标)。 */
|
||||
void mr_library_format_subtitle(mr_library_entry *e);
|
||||
|
||||
/* 行合成(纯内存、零 IO、O(1);UI 线程每帧调用):idx 越界返回 0。
|
||||
* idx==count 且 lib->truncated 时输出末尾提示行(disabled=1)。
|
||||
* 返回的 title/subtitle 指向 lib 内部或函数内静态缓冲(截断提示行),调用方不要长期持有。 */
|
||||
int mr_library_row_at(const mr_library *lib, int idx, mr_library_row *out);
|
||||
|
||||
/* 目录是否**直接**含受支持图片(= 书库语义里的「一本书」,可直接进 reader)。
|
||||
* 1=有;0=没有/不可读。 */
|
||||
/* 图片行在该目录图片自然序中的下标(0 基);idx 不是图片行/越界返回 -1。
|
||||
*
|
||||
* ⚠️ 这是书库→阅读器的契约:阅读器的目录册(logic/reader/page_source.c)只枚举
|
||||
* 「非隐藏 + mr_path_is_image + 普通文件」并按 mr_natural_cmp 排序。书库扫描用的是
|
||||
* 同一套过滤器与比较器,因此本函数数出的下标就是 mr_reader_open_dir_at() 的起始页。 */
|
||||
int mr_library_image_index(const mr_library *lib, int idx);
|
||||
|
||||
/* 目录是否**直接**含受支持图片。1=有;0=没有/不可读。
|
||||
* 仅用于阅读器的「相邻册」判定,不再是书库行语义。 */
|
||||
int mr_library_dir_has_images(const char *dir);
|
||||
|
||||
/* 册间衔接(M7):同父目录下按书库自然序排列的「直接含图」兄弟册。
|
||||
/* 册间衔接(M7):同父目录下按自然序排列的「直接含图」兄弟册。
|
||||
* delta=+1 下一册 / -1 上一册。1=找到并写入 out 完整路径;
|
||||
* 0=没有相邻册(当前册是首/末册,或当前册不在兄弟表中);-1=参数非法。
|
||||
* 语义与书库一致:跳过隐藏/系统目录与符号链接,排序同 mr_natural_cmp。
|
||||
* 语义:跳过隐藏/系统目录与符号链接,排序同 mr_natural_cmp。
|
||||
* 只算文件夹册(直接含图目录)——M7 口径不变。 */
|
||||
int mr_book_adjacent(const char *dir, int delta, char *out, int outsz);
|
||||
|
||||
/* 册间衔接 + 档案册(M11):兄弟表 = 「直接含图目录」∪「含图片页的 .cbz/.zip」,
|
||||
* 两者同列按名称自然序混排(跨册衔接按同父目录自然序)。
|
||||
* archive_pages 传 NULL 时与 mr_book_adjacent 完全等价。 */
|
||||
typedef int (*mr_lib_archive_fn)(const char *path, void *ud);
|
||||
int mr_book_adjacent_ex(const char *dir, int delta, char *out, int outsz,
|
||||
mr_lib_archive_fn archive_pages, void *ud);
|
||||
|
||||
#ifdef MR_LIBRARY_TEST_HOOKS
|
||||
/* 宿主测试用 IO 计数(scan/probe_dir 的 opendir/readdir/lstat 次数;线程安全不保证)。 */
|
||||
/* 宿主测试用 IO 计数(scan/dir_has_images 的 opendir/readdir/lstat 次数;线程安全不保证)。 */
|
||||
typedef struct mr_library_io_stats {
|
||||
long opendirs;
|
||||
long readdirs;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#!/bin/bash
|
||||
# run_tests.sh — 宿主 gcc 构建并运行图库测试(不链 SDK 静态库、不需要设备)。
|
||||
# - test_library:单层目录扫描/自然排序/父目录/阅读方向
|
||||
# - test_browse_nav:访问放开(系统/空/无图目录可进入)、上溯到 /、不可读目录、截断
|
||||
# - test_browse_lazy:单层探测/懒判定补位/mtime 失效缓存/行合成 O(1)/M7 册间语义
|
||||
# - test_browse_perf:500 目录×8 层嵌套+2000 文件夹具,断言扫描单层且有界
|
||||
# - test_library:单层目录扫描(目录+图片+档案册)/自然排序/图片下标/父目录/阅读方向
|
||||
# - test_browse_nav:访问放开(系统/空/无图目录可进入)、图片与档案册成行、截断、不可读
|
||||
# - test_browse_lazy:行合成零 IO/副标题预生成/图片下标 == 阅读器目录册下标(集成陷阱)
|
||||
# - test_browse_perf:500 目录×8 层嵌套 + 2000 项大目录,断言单层扫描与行合成成本
|
||||
# - test_settings_reader_persist:阅读方向 meow_config 持久化(默认/落盘/重启恢复/
|
||||
# 坏值/非法值/只读一次/写失败只告警)
|
||||
# - test_pin:启动 PIN 格式/设置/校验/修改/禁用/残缺配置/落盘失败(logic/pin.c 为纯 C,
|
||||
@@ -33,7 +33,8 @@ gcc -Wall -Wextra -O1 -DMR_LIBRARY_TEST_HOOKS -I. -I"$SDK" \
|
||||
"$OUT"/test_browse_nav
|
||||
|
||||
gcc -Wall -Wextra -O1 -DMR_LIBRARY_TEST_HOOKS -I. -I"$SDK" \
|
||||
test/test_browse_lazy.c logic/library.c logic/reader.c test/settings_reader_stub.c \
|
||||
test/test_browse_lazy.c logic/library.c logic/reader.c \
|
||||
logic/reader/page_source.c test/settings_reader_stub.c \
|
||||
-pthread -o "$OUT/test_browse_lazy"
|
||||
"$OUT"/test_browse_lazy
|
||||
|
||||
@@ -73,8 +74,9 @@ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then
|
||||
-pthread -lm -o "$OUT"/test_settings_lock_host
|
||||
"$OUT"/test_settings_lock_host
|
||||
|
||||
# 书库浏览页集成(src2 懒算补位/行语义/上溯至 / /不可读提示);同样需要 SDK 源码树。
|
||||
# 书库浏览页集成(文件管理器行模型/点击语义/根锁定/无 SD 卡空态/零 IO 行合成);同样需要 SDK 源码树。
|
||||
gcc -Wall -Wextra -O1 -DMEOW_UI_HOST_TEST -DMR_LIBRARY_UI_TEST_HOOKS \
|
||||
-DMR_LIBRARY_TEST_HOOKS \
|
||||
-I. -I"$SDK" -I"$SDK/include" \
|
||||
test/test_browse_ui_host.c ui/library/library.c logic/library.c \
|
||||
logic/reader.c logic/reader/page_source.c logic/reader/archive_source.c \
|
||||
|
||||
+90
-306
@@ -1,24 +1,22 @@
|
||||
/* test_browse_lazy.c — 单层探测 / 懒判定 / mtime 失效缓存 / 行合成(纯 C,无 SDK)。
|
||||
/* test_browse_lazy.c — 浏览行模型 / 零 IO 行合成 / 图片下标一致性(纯 C,无 SDK)。
|
||||
*
|
||||
* 覆盖:
|
||||
* L1 单层探测:直接图片数、自然序首图、空目录标记;不递归(只有子目录含图的目录不算书);
|
||||
* L2 懒判定接线:scan 后行全部 PENDING(占位副标题)→ apply_probe 后页数/首图/副标题/
|
||||
* 书标记;空目录「空目录」;无图目录「无图片」;探测失败「无法读取」且视为非书;
|
||||
* L3 缓存:mtime 失效(同 mtime 命中不读盘;目录变化后重扫并刷新结果);并发调用线程安全;
|
||||
* L3b 评审 P2:探测期间 mtime/size 变化的结果不写缓存(避免「不完整结果绑定新 mtime」);
|
||||
* L4 行合成 O(1):mr_library_row_at 纯内存(IO 计数不变)、截断提示行;
|
||||
* L5 M7 兼容语义:「直接含图兄弟册」+ 自然序册间顺序(M7 mr_book_adjacent 的口径)。
|
||||
* 文件名沿用历史(旧的「懒判定」已随 M18 文件管理器模型删除);本测试现在覆盖:
|
||||
* L1 行合成 O(1):mr_library_row_at 纯内存(IO 计数不变)、截断提示行;
|
||||
* L2 **书库→阅读器下标记号契约**:mr_library_image_index 数出的下标 == 阅读器目录册
|
||||
* (logic/reader/page_source.c: mr_dir_book_init)里同一张图的下标。
|
||||
* 夹具含大小写混排(A.jpg/a.jpg)、数字序号(img2/img10)、大写扩展名(IMG3.JPG)、
|
||||
* 前导零(page01/page1)、夹在图片之间的档案册与普通文件;
|
||||
* L3 扫描期副标题:每行都有内容(不再是占位「…」,渲染期不做格式化)。
|
||||
*
|
||||
* 编译(在 tuku/ 下):
|
||||
* gcc -Wall -Wextra -O1 -I. -I../sdk -DMR_LIBRARY_TEST_HOOKS \
|
||||
* test/test_browse_lazy.c logic/library.c logic/reader.c test/settings_reader_stub.c \
|
||||
* test/test_browse_lazy.c logic/library.c logic/reader/page_source.c \
|
||||
* -pthread -o build/host/test_browse_lazy
|
||||
*/
|
||||
#include "logic/library.h"
|
||||
#include "logic/reader/page_source.h"
|
||||
|
||||
#include <dirent.h>
|
||||
#include <fcntl.h>
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
@@ -81,255 +79,47 @@ static void joinp(char *out, int outsz, const char *a, const char *b)
|
||||
snprintf(out, (size_t)outsz, "%s/%s", a, b);
|
||||
}
|
||||
|
||||
/* 把目录 mtime 显式推后 3 秒(避免同秒内变化被缓存误命中)。 */
|
||||
static void bump_mtime(const char *path)
|
||||
static long long now_us(void)
|
||||
{
|
||||
struct timespec ts[2];
|
||||
ts[0].tv_sec = time(NULL) + 3;
|
||||
ts[0].tv_nsec = 0;
|
||||
ts[1] = ts[0];
|
||||
utimensat(AT_FDCWD, path, ts, 0);
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
return (long long)ts.tv_sec * 1000000LL + ts.tv_nsec / 1000LL;
|
||||
}
|
||||
|
||||
static const mr_library_entry *find_entry(const mr_library *lib, const char *name)
|
||||
/* 阅读器目录册用的假解码回调(本测试不打开任何页)。 */
|
||||
static meow_img *fake_load(const char *path, void *ud, int maxdim)
|
||||
{
|
||||
for (int i = 0; i < lib->count; i++)
|
||||
if (strcmp(lib->items[i].name, name) == 0) return &lib->items[i];
|
||||
(void)path; (void)ud; (void)maxdim;
|
||||
return NULL;
|
||||
}
|
||||
static void fake_unload(meow_img *img) { (void)img; }
|
||||
|
||||
static int find_index(const mr_library *lib, const char *name, int *out)
|
||||
{
|
||||
for (int i = 0; i < lib->count; i++)
|
||||
if (strcmp(lib->items[i].name, name) == 0) { *out = i; return 1; }
|
||||
*out = -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ---- L1 + L2 ---- */
|
||||
static void test_probe_and_apply(const char *root)
|
||||
{
|
||||
char book[MR_PATH_MAX], noimg[MR_PATH_MAX], empty[MR_PATH_MAX], dirs_only[MR_PATH_MAX];
|
||||
char deep[MR_PATH_MAX];
|
||||
joinp(book, sizeof book, root, "book");
|
||||
joinp(noimg, sizeof noimg, root, "noimg");
|
||||
joinp(empty, sizeof empty, root, "empty");
|
||||
joinp(dirs_only, sizeof dirs_only, root, "dirs_only");
|
||||
joinp(deep, sizeof deep, root, "dirs_only/deep");
|
||||
|
||||
mr_library_probe p;
|
||||
CHECK(mr_library_probe_dir(book, &p) == 0, "probe book");
|
||||
CHECK(p.pages == 2, "book pages=%d", p.pages);
|
||||
CHECK(p.empty == 0, "book not empty");
|
||||
CHECK(strstr(p.cover, "page2.png") != NULL, "cover natural-first: %s", p.cover);
|
||||
|
||||
CHECK(mr_library_probe_dir(noimg, &p) == 0 && p.pages == 0 && p.empty == 0,
|
||||
"noimg: 0 pages, not empty");
|
||||
CHECK(mr_library_probe_dir(empty, &p) == 0 && p.pages == 0 && p.empty == 1,
|
||||
"empty dir flagged");
|
||||
CHECK(mr_library_probe_dir(dirs_only, &p) == 0 && p.pages == 0 && p.empty == 0,
|
||||
"dirs_only has no direct images (no recursion)");
|
||||
CHECK(mr_library_probe_dir(deep, &p) == 0 && p.pages == 1, "deep is a book");
|
||||
|
||||
char slash[MR_PATH_MAX];
|
||||
snprintf(slash, sizeof slash, "%s/", book);
|
||||
CHECK(mr_library_probe_dir(slash, &p) == 0 && p.pages == 2, "trailing slash");
|
||||
|
||||
CHECK(mr_library_probe_dir("/no/such/dir", &p) == -1, "missing dir -> -1");
|
||||
CHECK(mr_library_probe_dir(NULL, &p) == -1, "NULL -> -1");
|
||||
|
||||
mr_library lib;
|
||||
memset(&lib, 0, sizeof lib);
|
||||
CHECK(mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH) == 5, "scan 5 dirs (got %d)", lib.count);
|
||||
|
||||
const mr_library_entry *e = find_entry(&lib, "book");
|
||||
CHECK(e && e->probe_state == MR_LIB_PENDING && e->pages == -1,
|
||||
"book pending after scan");
|
||||
|
||||
int idx_book, idx_noimg, idx_empty, idx_dirs;
|
||||
CHECK(find_index(&lib, "book", &idx_book), "book index");
|
||||
CHECK(find_index(&lib, "noimg", &idx_noimg), "noimg index");
|
||||
CHECK(find_index(&lib, "empty", &idx_empty), "empty index");
|
||||
CHECK(find_index(&lib, "dirs_only", &idx_dirs), "dirs_only index");
|
||||
if (idx_book < 0 || idx_noimg < 0 || idx_empty < 0 || idx_dirs < 0) {
|
||||
mr_library_free(&lib);
|
||||
return;
|
||||
}
|
||||
|
||||
CHECK(mr_library_probe_dir(lib.items[idx_book].path, &p) == 0, "probe row book");
|
||||
CHECK(mr_library_apply_probe(&lib, idx_book, &p) == 1, "apply book");
|
||||
e = &lib.items[idx_book];
|
||||
CHECK(e->has_images == 1 && e->pages == 2 && e->probe_state == MR_LIB_READY,
|
||||
"book ready");
|
||||
CHECK(strcmp(e->subtitle, "2 pages") == 0, "book subtitle=%s", e->subtitle);
|
||||
CHECK(strstr(e->cover, "page2.png") != NULL, "book row cover=%s", e->cover);
|
||||
|
||||
CHECK(mr_library_probe_dir(lib.items[idx_noimg].path, &p) == 0 &&
|
||||
mr_library_apply_probe(&lib, idx_noimg, &p) == 1, "apply noimg");
|
||||
e = &lib.items[idx_noimg];
|
||||
CHECK(e->has_images == 0 && strcmp(e->subtitle, "无图片") == 0,
|
||||
"noimg subtitle=%s", e->subtitle);
|
||||
|
||||
CHECK(mr_library_probe_dir(lib.items[idx_empty].path, &p) == 0 &&
|
||||
mr_library_apply_probe(&lib, idx_empty, &p) == 1, "apply empty");
|
||||
e = &lib.items[idx_empty];
|
||||
CHECK(e->has_images == 0 && e->empty == 1 && strcmp(e->subtitle, "空目录") == 0,
|
||||
"empty subtitle=%s", e->subtitle);
|
||||
|
||||
CHECK(mr_library_mark_error(&lib, idx_dirs) == 1, "mark error");
|
||||
e = &lib.items[idx_dirs];
|
||||
CHECK(e->probe_state == MR_LIB_FAILED && e->has_images == 0 &&
|
||||
strcmp(e->subtitle, "无法读取") == 0, "failed subtitle=%s", e->subtitle);
|
||||
|
||||
CHECK(mr_library_apply_probe(&lib, 999, &p) == 0, "apply out of range");
|
||||
CHECK(mr_library_mark_error(&lib, -1) == 0, "mark_error out of range");
|
||||
|
||||
mr_library_free(&lib);
|
||||
}
|
||||
|
||||
/* 档案册探测回调(P2 用例):可注入「探测期间文件变化」。 */
|
||||
typedef struct arch_probe_ctx {
|
||||
int calls;
|
||||
int bump_times; /* 前 N 次调用里改动文件 mtime(模拟探测期间变化) */
|
||||
} arch_probe_ctx;
|
||||
|
||||
static int arch_probe(const char *path, void *ud)
|
||||
{
|
||||
arch_probe_ctx *c = (arch_probe_ctx *)ud;
|
||||
c->calls++;
|
||||
if (c->calls <= c->bump_times) bump_mtime(path);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* ---- L3b:评审 P2——探测期间 mtime 变化不得写入缓存(消除假新鲜) ---- */
|
||||
static void test_cache_race(const char *root)
|
||||
{
|
||||
char arc[MR_PATH_MAX];
|
||||
joinp(arc, sizeof arc, root, "race.cbz");
|
||||
CHECK(touch_file(arc) == 0, "create race.cbz");
|
||||
|
||||
mr_library_probe_cache_clear();
|
||||
mr_library_probe p;
|
||||
|
||||
/* 稳定文件:第二次走缓存,回调只调一次。 */
|
||||
arch_probe_ctx stable = { 0, 0 };
|
||||
CHECK(mr_library_probe_path(arc, 1, arch_probe, &stable, &p) == 0 && p.pages == 1,
|
||||
"archive probe");
|
||||
CHECK(mr_library_probe_path(arc, 1, arch_probe, &stable, &p) == 0 && p.pages == 1,
|
||||
"archive probe (cached)");
|
||||
CHECK(stable.calls == 1, "stable archive cached (calls=%d)", stable.calls);
|
||||
|
||||
/* 探测期间文件变化:本次结果不得入缓存 → 下一次仍重新探测。 */
|
||||
mr_library_probe_cache_clear();
|
||||
arch_probe_ctx race = { 0, 1 };
|
||||
CHECK(mr_library_probe_path(arc, 1, arch_probe, &race, &p) == 0 && p.pages == 1,
|
||||
"racing archive probe");
|
||||
CHECK(race.calls == 1, "racing probe called once (calls=%d)", race.calls);
|
||||
CHECK(mr_library_probe_path(arc, 1, arch_probe, &race, &p) == 0 && p.pages == 1,
|
||||
"racing archive probe again");
|
||||
CHECK(race.calls == 2, "changed-during-probe result NOT cached (calls=%d)", race.calls);
|
||||
/* 第二次稳定后落缓存,第三次命中。 */
|
||||
CHECK(mr_library_probe_path(arc, 1, arch_probe, &race, &p) == 0 && p.pages == 1,
|
||||
"racing archive probe third");
|
||||
CHECK(race.calls == 2, "stable result cached after third call (calls=%d)", race.calls);
|
||||
mr_library_probe_cache_clear();
|
||||
}
|
||||
|
||||
/* ---- L3:mtime 失效缓存 + 并发 ---- */
|
||||
#define THREADS 6
|
||||
typedef struct th_arg {
|
||||
const char *dir;
|
||||
int want;
|
||||
int *fail;
|
||||
pthread_mutex_t *mu;
|
||||
} th_arg;
|
||||
|
||||
static void *probe_thread(void *arg)
|
||||
{
|
||||
th_arg *a = (th_arg *)arg;
|
||||
for (int i = 0; i < 200; i++) {
|
||||
mr_library_probe p;
|
||||
int ok = (mr_library_probe_cached(a->dir, &p) == 0 && p.pages == a->want);
|
||||
if (!ok) {
|
||||
pthread_mutex_lock(a->mu);
|
||||
(*a->fail)++;
|
||||
pthread_mutex_unlock(a->mu);
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void test_cache(const char *root)
|
||||
{
|
||||
char book[MR_PATH_MAX];
|
||||
joinp(book, sizeof book, root, "book");
|
||||
|
||||
mr_library_probe_cache_clear();
|
||||
mr_library_io_stats st;
|
||||
mr_library_probe p;
|
||||
|
||||
mr_library_test_io_reset();
|
||||
CHECK(mr_library_probe_cached(book, &p) == 0 && p.pages == 2, "cache miss probe");
|
||||
mr_library_test_io_get(&st);
|
||||
CHECK(st.opendirs == 1, "cache miss opens once (got %ld)", st.opendirs);
|
||||
|
||||
mr_library_test_io_reset();
|
||||
CHECK(mr_library_probe_cached(book, &p) == 0 && p.pages == 2, "cache hit");
|
||||
mr_library_test_io_get(&st);
|
||||
CHECK(st.opendirs == 0 && st.lstats == 0, "cache hit does no IO (open=%ld stat=%ld)",
|
||||
st.opendirs, st.lstats);
|
||||
|
||||
char img[MR_PATH_MAX];
|
||||
joinp(img, sizeof img, book, "page30.png");
|
||||
CHECK(touch_file(img) == 0, "add image");
|
||||
bump_mtime(book);
|
||||
mr_library_test_io_reset();
|
||||
CHECK(mr_library_probe_cached(book, &p) == 0 && p.pages == 3,
|
||||
"cache invalidated by mtime (pages=%d)", p.pages);
|
||||
mr_library_test_io_get(&st);
|
||||
CHECK(st.opendirs == 1, "invalidation re-opens (got %ld)", st.opendirs);
|
||||
|
||||
CHECK(mr_library_probe_cached("/no/such/dir", &p) == -1, "missing dir -> -1");
|
||||
|
||||
pthread_t th[THREADS];
|
||||
th_arg args[THREADS];
|
||||
int failures = 0;
|
||||
pthread_mutex_t mu = PTHREAD_MUTEX_INITIALIZER;
|
||||
int started = 0;
|
||||
for (int t = 0; t < THREADS; t++) {
|
||||
args[t].dir = book;
|
||||
args[t].want = 3;
|
||||
args[t].fail = &failures;
|
||||
args[t].mu = μ
|
||||
if (pthread_create(&th[t], NULL, probe_thread, &args[t]) == 0) started++;
|
||||
}
|
||||
for (int t = 0; t < started; t++) pthread_join(th[t], NULL);
|
||||
CHECK(started == THREADS, "threads started (%d)", started);
|
||||
CHECK(failures == 0, "concurrent probe failures=%d", failures);
|
||||
pthread_mutex_destroy(&mu);
|
||||
}
|
||||
|
||||
/* ---- L4:行合成 O(1) + 截断提示行 ---- */
|
||||
static void test_rows(const char *root)
|
||||
/* ---- L1 + L3:行合成零 IO / 副标题预生成 ---- */
|
||||
static void test_rows_zero_io(const char *root)
|
||||
{
|
||||
mr_library lib;
|
||||
memset(&lib, 0, sizeof lib);
|
||||
mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH);
|
||||
CHECK(mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH) > 0, "scan fixture");
|
||||
|
||||
for (int i = 0; i < lib.count; i++) {
|
||||
mr_library_row row;
|
||||
CHECK(mr_library_row_at(&lib, i, &row) == 1, "row %d", i);
|
||||
CHECK(row.title && row.title[0], "row %d title", i);
|
||||
CHECK(row.subtitle && row.subtitle[0] && strcmp(row.subtitle, "…") != 0,
|
||||
"row %d subtitle pre-generated (%s)", i, row.subtitle ? row.subtitle : "");
|
||||
}
|
||||
|
||||
mr_library_io_stats st;
|
||||
mr_library_test_io_reset();
|
||||
struct timespec t0, t1;
|
||||
clock_gettime(CLOCK_MONOTONIC, &t0);
|
||||
long long t0 = now_us();
|
||||
int rows = 0;
|
||||
for (int r = 0; r < 2000; r++)
|
||||
for (int i = 0; i < lib.count; i++) {
|
||||
mr_library_row row;
|
||||
if (mr_library_row_at(&lib, i, &row)) rows++;
|
||||
}
|
||||
clock_gettime(CLOCK_MONOTONIC, &t1);
|
||||
long long us = now_us() - t0;
|
||||
mr_library_test_io_get(&st);
|
||||
long long us = (long long)(t1.tv_sec - t0.tv_sec) * 1000000LL +
|
||||
(t1.tv_nsec - t0.tv_nsec) / 1000LL;
|
||||
CHECK(rows == 2000 * lib.count, "rows synthesized (%d)", rows);
|
||||
CHECK(st.opendirs == 0 && st.readdirs == 0 && st.lstats == 0,
|
||||
"row synth is pure memory (open=%ld rd=%ld stat=%ld)",
|
||||
@@ -343,99 +133,93 @@ static void test_rows(const char *root)
|
||||
"truncation info row");
|
||||
CHECK(strcmp(row.title, "已截断") == 0, "truncation title=%s", row.title);
|
||||
CHECK(strstr(row.subtitle, "2000") != NULL, "truncation subtitle=%s", row.subtitle);
|
||||
|
||||
mr_library_free(&lib);
|
||||
}
|
||||
|
||||
/* ---- L5:M7 兼容语义(直接含图兄弟册 + 自然序) ---- */
|
||||
static void collect_books(const char *parent, char names[][MR_NAME_MAX], int max, int *out_n)
|
||||
/* ---- L2:书库图片下标 == 阅读器目录册下标 ---- */
|
||||
static void test_image_index_matches_reader(const char *dir)
|
||||
{
|
||||
mr_library lib;
|
||||
memset(&lib, 0, sizeof lib);
|
||||
int n = 0;
|
||||
if (mr_library_scan(&lib, parent, MR_LIB_MAX_DEPTH) > 0) {
|
||||
for (int i = 0; i < lib.count && n < max; i++) {
|
||||
mr_library_probe p;
|
||||
if (mr_library_probe_cached(lib.items[i].path, &p) == 0 && p.pages > 0) {
|
||||
snprintf(names[n], MR_NAME_MAX, "%s", lib.items[i].name);
|
||||
n++;
|
||||
}
|
||||
}
|
||||
CHECK(mr_library_scan(&lib, dir, MR_LIB_MAX_DEPTH) > 0, "scan mixed dir");
|
||||
|
||||
mr_dir_book db;
|
||||
memset(&db, 0, sizeof db);
|
||||
int pages = mr_dir_book_init(&db, dir, 720, fake_load, fake_unload, NULL);
|
||||
CHECK(pages > 0, "reader dir book pages=%d", pages);
|
||||
|
||||
/* 书库里的图片行按出现顺序,其下标必须依次是 0..n-1(无空洞/无重排)。 */
|
||||
int expect = 0;
|
||||
for (int i = 0; i < lib.count; i++) {
|
||||
if (lib.items[i].kind != MR_LIB_KIND_IMAGE) continue;
|
||||
int idx = mr_library_image_index(&lib, i);
|
||||
CHECK(idx == expect, "image row %s index=%d (want %d)",
|
||||
lib.items[i].name, idx, expect);
|
||||
expect++;
|
||||
}
|
||||
CHECK(expect == pages, "library images=%d vs reader pages=%d", expect, pages);
|
||||
if (expect != pages) {
|
||||
mr_library_free(&lib);
|
||||
mr_dir_book_free(&db);
|
||||
return;
|
||||
}
|
||||
|
||||
/* 逐张对齐:书库第 k 张图 == 阅读器目录册第 k 页(下标即起始页)。 */
|
||||
int k = 0;
|
||||
for (int i = 0; i < lib.count; i++) {
|
||||
if (lib.items[i].kind != MR_LIB_KIND_IMAGE) continue;
|
||||
char page_path[MR_PATH_MAX];
|
||||
mr_dir_book_path(&db, k, page_path, (int)sizeof page_path);
|
||||
CHECK(strcmp(page_path, lib.items[i].path) == 0,
|
||||
"page[%d]: library=%s reader=%s", k, lib.items[i].path, page_path);
|
||||
k++;
|
||||
}
|
||||
|
||||
/* 档案册/目录不占图片下标。 */
|
||||
for (int i = 0; i < lib.count; i++) {
|
||||
if (lib.items[i].kind == MR_LIB_KIND_IMAGE) continue;
|
||||
CHECK(mr_library_image_index(&lib, i) == -1,
|
||||
"non-image %s has no image index", lib.items[i].name);
|
||||
}
|
||||
|
||||
mr_dir_book_free(&db);
|
||||
mr_library_free(&lib);
|
||||
*out_n = n;
|
||||
}
|
||||
|
||||
static int book_index(char names[][MR_NAME_MAX], int n, const char *cur)
|
||||
{
|
||||
for (int i = 0; i < n; i++)
|
||||
if (strcmp(names[i], cur) == 0) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
static void test_m7_semantics(const char *root)
|
||||
{
|
||||
char parent[MR_PATH_MAX];
|
||||
joinp(parent, sizeof parent, root, "series");
|
||||
|
||||
char names[16][MR_NAME_MAX];
|
||||
int n = 0;
|
||||
collect_books(parent, names, 16, &n);
|
||||
CHECK(n == 3, "series books=%d (vol1/vol2/vol10; vol6 is not a book)", n);
|
||||
if (n == 3) {
|
||||
CHECK(strcmp(names[0], "vol1") == 0, "book[0]=%s", names[0]);
|
||||
CHECK(strcmp(names[1], "vol2") == 0, "book[1]=%s", names[1]);
|
||||
CHECK(strcmp(names[2], "vol10") == 0, "book[2]=%s", names[2]);
|
||||
}
|
||||
CHECK(book_index(names, n, "vol6") < 0, "dirs-only vol6 skipped (direct images only)");
|
||||
CHECK(book_index(names, n, ".hidden") < 0, "hidden skipped");
|
||||
CHECK(book_index(names, n, "MeowOS") < 0, "system dir without direct images skipped");
|
||||
|
||||
int i2 = book_index(names, n, "vol2");
|
||||
CHECK(i2 >= 0 && i2 + 1 < n && strcmp(names[i2 + 1], "vol10") == 0,
|
||||
"vol2 next = vol10 (natural order, vol6 skipped)");
|
||||
CHECK(book_index(names, n, "vol10") == n - 1, "vol10 is last (no next)");
|
||||
CHECK(book_index(names, n, "vol1") == 0, "vol1 is first (no prev)");
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
char tmpl[] = "./mr_lazy_XXXXXX";
|
||||
char tmpl[] = "./mr_rows_XXXXXX";
|
||||
char *root = mkdtemp(tmpl);
|
||||
CHECK(root != NULL, "mkdtemp");
|
||||
if (!root) return 1;
|
||||
|
||||
char p[MR_PATH_MAX];
|
||||
const char *dirs[] = { "book", "noimg", "empty", "dirs_only/deep", "series" };
|
||||
const char *dirs[] = { "book", "mixed", "mixed/sub", "empty" };
|
||||
for (unsigned i = 0; i < sizeof dirs / sizeof dirs[0]; i++) {
|
||||
joinp(p, sizeof p, root, dirs[i]);
|
||||
CHECK(mkdir_p(p) == 0, "mkdir %s", p);
|
||||
}
|
||||
|
||||
joinp(p, sizeof p, root, "book/page2.png"); CHECK(touch_file(p) == 0, "page2");
|
||||
joinp(p, sizeof p, root, "book/page10.jpg"); CHECK(touch_file(p) == 0, "page10");
|
||||
joinp(p, sizeof p, root, "book/notes.txt"); CHECK(touch_file(p) == 0, "notes");
|
||||
joinp(p, sizeof p, root, "noimg/readme.txt"); CHECK(touch_file(p) == 0, "readme");
|
||||
joinp(p, sizeof p, root, "dirs_only/deep/a.png"); CHECK(touch_file(p) == 0, "deep a");
|
||||
joinp(p, sizeof p, root, "mixed/sub/zz.png");CHECK(touch_file(p) == 0, "nested");
|
||||
|
||||
/* M7 语义夹具:vol1/vol2/vol10 直接含图;vol6 只有子目录含图;.hidden / MeowOS 干扰项 */
|
||||
const char *mk[] = { "series/vol1/001.png", "series/vol2/001.png",
|
||||
"series/vol10/001.png", "series/vol6/sub/001.png",
|
||||
"series/.hidden/001.png", "series/MeowOS/deep/001.png" };
|
||||
for (unsigned i = 0; i < sizeof mk / sizeof mk[0]; i++) {
|
||||
joinp(p, sizeof p, root, mk[i]);
|
||||
/* 先建父目录,再落文件 */
|
||||
char dir[MR_PATH_MAX];
|
||||
snprintf(dir, sizeof dir, "%s", p);
|
||||
char *slash = strrchr(dir, '/');
|
||||
if (slash) { *slash = '\0'; CHECK(mkdir_p(dir) == 0, "mkdir %s", dir); }
|
||||
/* 大小写混排 + 数字序号 + 大写扩展名 + 前导零 + 夹档案/普通文件。 */
|
||||
const char *mixed[] = {
|
||||
"A.jpg", "a.jpg", "img2.png", "IMG3.JPG", "img10.png",
|
||||
"page01.bmp", "page1.jpeg", "cover.cbz", "notes.txt", ".hidden.png"
|
||||
};
|
||||
for (unsigned i = 0; i < sizeof mixed / sizeof mixed[0]; i++) {
|
||||
joinp(p, sizeof p, root, "mixed");
|
||||
size_t dl = strlen(p);
|
||||
snprintf(p + dl, sizeof p - dl, "/%s", mixed[i]);
|
||||
CHECK(touch_file(p) == 0, "image %s", p);
|
||||
}
|
||||
|
||||
test_probe_and_apply(root);
|
||||
test_cache(root);
|
||||
test_cache_race(root);
|
||||
test_rows(root);
|
||||
test_m7_semantics(root);
|
||||
test_rows_zero_io(root);
|
||||
joinp(p, sizeof p, root, "mixed");
|
||||
test_image_index_matches_reader(p);
|
||||
|
||||
rm_rf(root);
|
||||
printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail);
|
||||
|
||||
+41
-26
@@ -1,17 +1,16 @@
|
||||
/* test_browse_nav.c — 浏览访问放开 / 上溯父目录 / 截断标注(纯 C,无 SDK)。
|
||||
/* test_browse_nav.c — 浏览模型 / 进入目录 / 截断(纯 C,无 SDK)。
|
||||
*
|
||||
* 覆盖(M13 用户要求②):
|
||||
* N1 访问放开:系统目录(MeowAPPs/MeowData/MeowOS/lost+found)不再隐藏,
|
||||
* 空目录/无图目录都列出且可进入(scan 返回 0 而不是 -1);
|
||||
* N2 隐藏项/普通文件/符号链接仍不列(防环、防噪声);
|
||||
* N3 不可读目录/不存在/不是目录:scan 返回 -1,调用方据此提示并停留;
|
||||
* N4 上溯父目录:mr_path_parent 可一路走到文件系统 "/"(不再以 TF 卡根为界);
|
||||
* N5 单目录条目上限:达到 MR_LIB_SCAN_MAX 后截断并置 truncated(行合成给出提示行)。
|
||||
* 覆盖(M18 文件管理器模型):
|
||||
* N1 单层列表:目录 + 受支持图片 + 档案册(cbz/zip)都成行,目录优先+自然序;
|
||||
* 系统目录/空目录/无图目录都列出且可进入;隐藏项/普通文件/符号链接不列;
|
||||
* N2 不可读目录/不存在/不是目录:scan 返回 -1,调用方据此显示空态或提示;
|
||||
* N3 父目录路径推导:mr_path_parent 是纯路径工具(可算到文件系统 "/";
|
||||
* 浏览越界的限制在 UI 导航层,见 test_browse_ui_host 的根锁定用例);
|
||||
* N4 单目录条目上限:达到 MR_LIB_SCAN_MAX 后截断并置 truncated(行合成给出提示行)。
|
||||
*
|
||||
* 编译(在 tuku/ 下):
|
||||
* gcc -Wall -Wextra -O1 -I. -I../sdk -DMR_LIBRARY_TEST_HOOKS \
|
||||
* test/test_browse_nav.c logic/library.c logic/reader.c test/settings_reader_stub.c \
|
||||
* -pthread -o build/host/test_browse_nav
|
||||
* test/test_browse_nav.c logic/library.c -pthread -o build/host/test_browse_nav
|
||||
*/
|
||||
#include "logic/library.h"
|
||||
|
||||
@@ -85,19 +84,20 @@ static const mr_library_entry *find_entry(const mr_library *lib, const char *nam
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* ---- N1/N2 ---- */
|
||||
/* ---- N1 ---- */
|
||||
static void test_open_access(const char *root)
|
||||
{
|
||||
mr_library lib;
|
||||
memset(&lib, 0, sizeof lib);
|
||||
int n = mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH);
|
||||
CHECK(n == 7, "root lists 7 dirs (got %d)", n);
|
||||
CHECK(n == 9, "root lists 9 rows (got %d)", n);
|
||||
CHECK(lib.truncated == 0, "root not truncated");
|
||||
|
||||
static const char *want[7] = {
|
||||
"book", "empty", "lost+found", "MeowAPPs", "MeowData", "MeowOS", "noimg"
|
||||
static const char *want[9] = {
|
||||
"book", "empty", "lost+found", "MeowAPPs", "MeowData", "MeowOS", "noimg",
|
||||
"album.cbz", "loose.png"
|
||||
};
|
||||
for (int i = 0; i < 7 && i < lib.count; i++)
|
||||
for (int i = 0; i < 9 && i < lib.count; i++)
|
||||
CHECK(strcmp(lib.items[i].name, want[i]) == 0,
|
||||
"sort[%d]=%s got %s", i, want[i], lib.items[i].name);
|
||||
|
||||
@@ -105,11 +105,13 @@ static void test_open_access(const char *root)
|
||||
CHECK(find_entry(&lib, want[i]) != NULL, "system/empty/noimg dir listed: %s", want[i]);
|
||||
CHECK(find_entry(&lib, ".hidden") == NULL, "hidden dir excluded");
|
||||
CHECK(find_entry(&lib, "linkdir") == NULL, "symlink dir excluded");
|
||||
CHECK(find_entry(&lib, "loose.png") == NULL, "file excluded");
|
||||
CHECK(find_entry(&lib, "loose.png") != NULL, "loose image listed (clickable)");
|
||||
CHECK(find_entry(&lib, "album.cbz") != NULL, "archive listed");
|
||||
CHECK(find_entry(&lib, "readme.txt") == NULL, "plain file excluded");
|
||||
|
||||
/* 可进入:每个列出的目录都能被 scan(0 项也算成功,不等于 -1)。 */
|
||||
char p[MR_PATH_MAX];
|
||||
for (int i = 0; i < lib.count; i++) {
|
||||
if (lib.items[i].kind != MR_LIB_KIND_DIR) continue;
|
||||
mr_library child;
|
||||
memset(&child, 0, sizeof child);
|
||||
CHECK(mr_library_scan(&child, lib.items[i].path, MR_LIB_MAX_DEPTH) >= 0,
|
||||
@@ -117,11 +119,20 @@ static void test_open_access(const char *root)
|
||||
mr_library_free(&child);
|
||||
}
|
||||
|
||||
char p[MR_PATH_MAX];
|
||||
joinp(p, sizeof p, root, "book");
|
||||
mr_library book;
|
||||
memset(&book, 0, sizeof book);
|
||||
CHECK(mr_library_scan(&book, p, MR_LIB_MAX_DEPTH) == 1 &&
|
||||
find_entry(&book, "001.png") != NULL, "dir with images lists the image");
|
||||
mr_library_free(&book);
|
||||
|
||||
joinp(p, sizeof p, root, "MeowOS");
|
||||
mr_library syslib;
|
||||
memset(&syslib, 0, sizeof syslib);
|
||||
CHECK(mr_library_scan(&syslib, p, MR_LIB_MAX_DEPTH) == 1 &&
|
||||
find_entry(&syslib, "sub") != NULL, "system dir browsable");
|
||||
CHECK(mr_library_scan(&syslib, p, MR_LIB_MAX_DEPTH) == 2 &&
|
||||
find_entry(&syslib, "sub") != NULL &&
|
||||
find_entry(&syslib, "001.png") != NULL, "system dir browsable");
|
||||
mr_library_free(&syslib);
|
||||
|
||||
joinp(p, sizeof p, root, "empty");
|
||||
@@ -133,13 +144,13 @@ static void test_open_access(const char *root)
|
||||
mr_library_free(&lib);
|
||||
}
|
||||
|
||||
/* ---- N3 ---- */
|
||||
/* ---- N2 ---- */
|
||||
static void test_unreadable(const char *root)
|
||||
{
|
||||
mr_library lib;
|
||||
char p[MR_PATH_MAX];
|
||||
memset(&lib, 0, sizeof lib);
|
||||
CHECK(mr_library_scan(&lib, "/no/such/dir/m13", MR_LIB_MAX_DEPTH) == -1, "missing -> -1");
|
||||
CHECK(mr_library_scan(&lib, "/no/such/dir/m18", MR_LIB_MAX_DEPTH) == -1, "missing -> -1");
|
||||
CHECK(lib.count == 0 && lib.truncated == 0, "failed scan leaves empty lib");
|
||||
mr_library_free(&lib);
|
||||
|
||||
@@ -160,8 +171,8 @@ static void test_unreadable(const char *root)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- N4 ---- */
|
||||
static void test_up_to_root(void)
|
||||
/* ---- N3 ---- */
|
||||
static void test_parent_path(void)
|
||||
{
|
||||
char cur[MR_PATH_MAX];
|
||||
snprintf(cur, sizeof cur, "/usr/data/mnt/sd_0/a/b/c");
|
||||
@@ -183,10 +194,10 @@ static void test_up_to_root(void)
|
||||
"root parent stays /");
|
||||
CHECK(mr_path_parent("/usr/data/mnt/sd_0", up, (int)sizeof up) == 0 &&
|
||||
strcmp(up, "/usr/data/mnt") == 0,
|
||||
"TF root can go above mount point: %s", up);
|
||||
"path helper is root-agnostic: %s", up);
|
||||
}
|
||||
|
||||
/* ---- N5 ---- */
|
||||
/* ---- N4 ---- */
|
||||
static void test_truncation(void)
|
||||
{
|
||||
CHECK(MR_LIB_SCAN_MAX == 2000, "default cap is 2000 (got %d)", MR_LIB_SCAN_MAX);
|
||||
@@ -241,6 +252,10 @@ int main(void)
|
||||
touch_file(p);
|
||||
joinp(p, sizeof p, root, "loose.png");
|
||||
touch_file(p);
|
||||
joinp(p, sizeof p, root, "album.cbz");
|
||||
touch_file(p);
|
||||
joinp(p, sizeof p, root, "readme.txt");
|
||||
touch_file(p);
|
||||
/* 符号链接目录不参与浏览(有权限才建;建不了不影响其余断言)。 */
|
||||
char target[MR_PATH_MAX], link[MR_PATH_MAX];
|
||||
joinp(target, sizeof target, root, "book");
|
||||
@@ -249,7 +264,7 @@ int main(void)
|
||||
|
||||
test_open_access(root);
|
||||
test_unreadable(root);
|
||||
test_up_to_root();
|
||||
test_parent_path();
|
||||
|
||||
rm_rf(root);
|
||||
test_truncation();
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
/* test_browse_perf.c — 单层扫描性能/有界性验收(纯 C,无 SDK)。
|
||||
/* test_browse_perf.c — 单层扫描 / 行合成性能与有界性验收(纯 C,无 SDK)。
|
||||
*
|
||||
* 夹具:500 个顶层目录 × 8 层嵌套(4000 个子目录)+ 根目录 2000 个文件。
|
||||
* 断言(对应 M13 任务⑤):
|
||||
* P1 首帧扫描只做单层:opendir 次数 == 1(旧实现会递归打开数千个目录);
|
||||
* lstat 次数 == 本层条目数;耗时有界;
|
||||
* P2 后台探测同样只读一层:每个目录一次 opendir,不进入其嵌套子树;
|
||||
* P3 行合成(UI src/bind 的数据面)纯内存、零 IO、O(1);
|
||||
* P4 深目录直接扫描也只打开它自己。
|
||||
* 夹具:500 个顶层目录 × 8 层嵌套(4000 个子目录)+ 根目录 2000 个普通文件
|
||||
* + 一个 big/ 目录(2000 张图片,验证 2000 项大目录)。
|
||||
* 断言(对应 M18 任务③):
|
||||
* P1 首帧扫描只做单层:opendir 次数 == 1,lstat 次数 == 本层条目数,耗时有界;
|
||||
* P2 2000 项大目录:一次扫描出全部 2000 行,opendir=1、lstat=2000,耗时有界;
|
||||
* P3 行合成(UI src 的数据面)纯内存、零 IO、耗时有界(2000 行 × 1000 遍);
|
||||
* P4 进入深层/普通子目录同样只打开它自己(进入目录成本 = 该目录一层)。
|
||||
*
|
||||
* 编译(在 tuku/ 下):
|
||||
* gcc -Wall -Wextra -O1 -I. -I../sdk -DMR_LIBRARY_TEST_HOOKS \
|
||||
* test/test_browse_perf.c logic/library.c logic/reader.c test/settings_reader_stub.c \
|
||||
* -pthread -o build/host/test_browse_perf
|
||||
* test/test_browse_perf.c logic/library.c -pthread -o build/host/test_browse_perf
|
||||
*/
|
||||
#include "logic/library.h"
|
||||
|
||||
@@ -26,6 +25,7 @@
|
||||
#define PERF_DIRS 500
|
||||
#define PERF_DEPTH 8
|
||||
#define PERF_FILES 2000
|
||||
#define PERF_BIG 2000
|
||||
|
||||
static int g_pass, g_fail;
|
||||
#define CHECK(cond, ...) do { \
|
||||
@@ -92,7 +92,7 @@ int main(void)
|
||||
if (!root) return 1;
|
||||
|
||||
char p[MR_PATH_MAX];
|
||||
/* 根目录:2000 个文件 + 500 个顶层目录(每个顶层目录内是 8 层嵌套 + 1 张图)。 */
|
||||
/* 根目录:2000 个普通文件(不成行)+ 500 个顶层目录(每个顶层目录内是 8 层嵌套 + 1 张图)。 */
|
||||
for (int f = 0; f < PERF_FILES; f++) {
|
||||
snprintf(p, sizeof p, "%s/f%04d.txt", root, f);
|
||||
if (touch_file(p) != 0) { CHECK(0, "file %s", p); break; }
|
||||
@@ -101,7 +101,7 @@ int main(void)
|
||||
char deep[MR_PATH_MAX];
|
||||
snprintf(deep, sizeof deep, "%s/d%04d", root, d);
|
||||
if (mkdir_p(deep) != 0) { CHECK(0, "mkdir %s", deep); break; }
|
||||
snprintf(p, sizeof p, "%s/001.png", deep); /* 顶层目录自身直接含图(=一本书) */
|
||||
snprintf(p, sizeof p, "%s/001.png", deep);
|
||||
if (touch_file(p) != 0) { CHECK(0, "touched top image"); break; }
|
||||
for (int l = 1; l <= PERF_DEPTH; l++) {
|
||||
char sub[MR_PATH_MAX];
|
||||
@@ -113,6 +113,15 @@ int main(void)
|
||||
if (touch_file(p) != 0) { CHECK(0, "touched deep image"); break; }
|
||||
}
|
||||
|
||||
/* 2000 项大目录(图片)。 */
|
||||
char big[MR_PATH_MAX];
|
||||
snprintf(big, sizeof big, "%s/big", root);
|
||||
CHECK(mkdir_p(big) == 0, "mkdir big");
|
||||
for (int i = 0; i < PERF_BIG; i++) {
|
||||
snprintf(p, sizeof p, "%s/img%04d.png", big, i);
|
||||
if (touch_file(p) != 0) { CHECK(0, "big image %s", p); break; }
|
||||
}
|
||||
|
||||
/* ---- P1:首帧扫描只做单层 ---- */
|
||||
mr_library lib;
|
||||
memset(&lib, 0, sizeof lib);
|
||||
@@ -123,61 +132,67 @@ int main(void)
|
||||
mr_library_io_stats st;
|
||||
mr_library_test_io_get(&st);
|
||||
|
||||
CHECK(n == PERF_DIRS, "scan lists %d dirs (got %d)", PERF_DIRS, n);
|
||||
CHECK(n == PERF_DIRS + 1, "scan lists %d dirs (got %d)", PERF_DIRS + 1, n);
|
||||
CHECK(lib.truncated == 0, "not truncated");
|
||||
CHECK(st.opendirs == 1, "single-layer scan: opendir=%ld (want 1)", st.opendirs);
|
||||
CHECK(st.lstats == PERF_FILES + PERF_DIRS,
|
||||
"lstat only current level: %ld (want %d)", st.lstats, PERF_FILES + PERF_DIRS);
|
||||
CHECK(st.lstats == PERF_FILES + PERF_DIRS + 1,
|
||||
"lstat only current level: %ld (want %d)", st.lstats, PERF_FILES + PERF_DIRS + 1);
|
||||
CHECK(scan_us < 1500000LL, "scan bounded: %lldus", scan_us);
|
||||
if (lib.count > 1) {
|
||||
CHECK(strcmp(lib.items[0].name, "d0000") == 0, "first=%s", lib.items[0].name);
|
||||
if (lib.count == PERF_DIRS + 1) {
|
||||
CHECK(strcmp(lib.items[0].name, "big") == 0, "first=%s", lib.items[0].name);
|
||||
CHECK(strcmp(lib.items[lib.count - 1].name, "d0499") == 0,
|
||||
"last=%s", lib.items[lib.count - 1].name);
|
||||
}
|
||||
printf(" scan: %lldus, %d dirs, opendir=%ld lstat=%ld\n",
|
||||
printf(" root scan: %lldus, %d rows, opendir=%ld lstat=%ld\n",
|
||||
scan_us, n, st.opendirs, st.lstats);
|
||||
mr_library_free(&lib);
|
||||
|
||||
/* ---- P2:后台探测每目录只开一次,不进入嵌套子树 ---- */
|
||||
/* ---- P2:2000 项大目录(进入目录 = 单层扫描) ---- */
|
||||
mr_library biglib;
|
||||
memset(&biglib, 0, sizeof biglib);
|
||||
mr_library_test_io_reset();
|
||||
long long p0 = now_us();
|
||||
int bad = 0;
|
||||
for (int i = 0; i < lib.count; i++) {
|
||||
mr_library_probe probe;
|
||||
if (mr_library_probe_cached(lib.items[i].path, &probe) != 0 || probe.pages != 1)
|
||||
bad++;
|
||||
}
|
||||
long long probe_us = now_us() - p0;
|
||||
long long b0 = now_us();
|
||||
int bn = mr_library_scan(&biglib, big, MR_LIB_MAX_DEPTH);
|
||||
long long big_us = now_us() - b0;
|
||||
mr_library_test_io_get(&st);
|
||||
CHECK(bad == 0, "all top dirs have 1 direct image (bad=%d)", bad);
|
||||
CHECK(st.opendirs == PERF_DIRS,
|
||||
"probe stays shallow: opendir=%ld (want %d, not %d)",
|
||||
st.opendirs, PERF_DIRS, PERF_DIRS * (PERF_DEPTH + 1));
|
||||
CHECK(st.lstats == PERF_DIRS,
|
||||
"probe lstats only image candidates: %ld (want %d)", st.lstats, PERF_DIRS);
|
||||
CHECK(probe_us < 5000000LL, "probe bounded: %lldus", probe_us);
|
||||
printf(" probe: %lldus, opendir=%ld lstat=%ld\n", probe_us, st.opendirs, st.lstats);
|
||||
CHECK(bn == PERF_BIG, "big dir rows=%d (want %d)", bn, PERF_BIG);
|
||||
CHECK(biglib.truncated == 0, "big dir not truncated at cap");
|
||||
CHECK(st.opendirs == 1, "big dir scan: opendir=%ld", st.opendirs);
|
||||
CHECK(st.lstats == PERF_BIG, "big dir scan: lstat=%ld (want %d)", st.lstats, PERF_BIG);
|
||||
CHECK(big_us < 1500000LL, "big dir scan bounded: %lldus", big_us);
|
||||
if (biglib.count == PERF_BIG) {
|
||||
CHECK(strcmp(biglib.items[0].name, "img0000.png") == 0, "first=%s", biglib.items[0].name);
|
||||
CHECK(strcmp(biglib.items[PERF_BIG - 1].name, "img1999.png") == 0,
|
||||
"last=%s", biglib.items[PERF_BIG - 1].name);
|
||||
CHECK(biglib.items[PERF_BIG - 1].kind == MR_LIB_KIND_IMAGE, "last is image");
|
||||
CHECK(mr_library_image_index(&biglib, PERF_BIG - 1) == PERF_BIG - 1,
|
||||
"image index at end");
|
||||
}
|
||||
printf(" big dir scan: %lldus, %d rows, opendir=%ld lstat=%ld\n",
|
||||
big_us, bn, st.opendirs, st.lstats);
|
||||
|
||||
/* ---- P3:行合成(bind/src 数据面)纯内存 O(1) ---- */
|
||||
/* ---- P3:行合成(UI src 数据面)纯内存、2000 行 × 1000 遍 ---- */
|
||||
mr_library_test_io_reset();
|
||||
long long r0 = now_us();
|
||||
int rows = 0;
|
||||
for (int r = 0; r < 1000; r++)
|
||||
for (int i = 0; i < lib.count; i++) {
|
||||
for (int i = 0; i < biglib.count; i++) {
|
||||
mr_library_row row;
|
||||
if (mr_library_row_at(&lib, i, &row)) rows++;
|
||||
if (mr_library_row_at(&biglib, i, &row)) rows++;
|
||||
}
|
||||
long long row_us = now_us() - r0;
|
||||
mr_library_test_io_get(&st);
|
||||
CHECK(rows == 1000 * lib.count, "rows synthesized (%d)", rows);
|
||||
CHECK(rows == 1000 * biglib.count, "rows synthesized (%d)", rows);
|
||||
CHECK(st.opendirs == 0 && st.readdirs == 0 && st.lstats == 0,
|
||||
"row synthesis does no IO (open=%ld rd=%ld stat=%ld)",
|
||||
st.opendirs, st.readdirs, st.lstats);
|
||||
CHECK(row_us < 1000000LL, "row synthesis bounded: %lldus", row_us);
|
||||
printf(" rows: %lldus for %d rows, zero IO\n", row_us, rows);
|
||||
mr_library_free(&biglib);
|
||||
|
||||
/* ---- P4:直接扫描深层目录同样只打开它自己 ---- */
|
||||
/* ---- P4:进入子目录只打开它自己(不递归子树) ---- */
|
||||
char deep[MR_PATH_MAX];
|
||||
snprintf(deep, sizeof deep, "%s", lib.items[0].path);
|
||||
snprintf(deep, sizeof deep, "%s/d0000", root);
|
||||
for (int l = 1; l <= PERF_DEPTH; l++) {
|
||||
char sub[MR_PATH_MAX];
|
||||
snprintf(sub, sizeof sub, "%s/l%d", deep, l);
|
||||
@@ -186,13 +201,22 @@ int main(void)
|
||||
mr_library_test_io_reset();
|
||||
mr_library deep_lib;
|
||||
memset(&deep_lib, 0, sizeof deep_lib);
|
||||
CHECK(mr_library_scan(&deep_lib, deep, MR_LIB_MAX_DEPTH) == 0, "deep leaf has no subdir");
|
||||
CHECK(mr_library_scan(&deep_lib, deep, MR_LIB_MAX_DEPTH) == 1, "deep leaf has one image");
|
||||
mr_library_test_io_get(&st);
|
||||
CHECK(st.opendirs == 1, "deep scan opens once (got %ld)", st.opendirs);
|
||||
CHECK(st.lstats == 1, "deep scan lstats only its image (got %ld)", st.lstats);
|
||||
mr_library_free(&deep_lib);
|
||||
|
||||
mr_library_free(&lib);
|
||||
/* 顶层目录:一个子目录 + 一张图 = 2 行,只打开它自己。 */
|
||||
snprintf(deep, sizeof deep, "%s/d0000", root);
|
||||
memset(&deep_lib, 0, sizeof deep_lib);
|
||||
mr_library_test_io_reset();
|
||||
CHECK(mr_library_scan(&deep_lib, deep, MR_LIB_MAX_DEPTH) == 2, "top dir rows");
|
||||
mr_library_test_io_get(&st);
|
||||
CHECK(st.opendirs == 1 && st.lstats == 2, "top dir scan: open=%ld stat=%ld",
|
||||
st.opendirs, st.lstats);
|
||||
mr_library_free(&deep_lib);
|
||||
|
||||
rm_rf(root);
|
||||
printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail);
|
||||
return g_fail ? 1 : 0;
|
||||
|
||||
+222
-195
@@ -1,33 +1,35 @@
|
||||
/* test_browse_ui_host.c — 书库浏览页宿主集成测试(可选,需 SDK 源码树)。
|
||||
*
|
||||
* 目的(M13):
|
||||
* U1 列表用 src2 模式(每帧重取行):扫描后行副标题是占位「…」,后台探测完成、
|
||||
* on_tick 补位后同一行立即显示页数——验证懒算结果能上屏(固定池 vsource 做不到);
|
||||
* U2 行语义:书行显示 "N pages"(无进入箭头),空目录「空目录」,无图目录「无图片」;
|
||||
* U3 导航:可逐级上溯父目录;文件系统 "/" 处 library_go_up 返回 0(app.c 据此退出);
|
||||
* 「TF卡」回根动作回到书库根;
|
||||
* U4 不可读目录:load 失败 → 停留原目录 + 顶部提示横幅;
|
||||
* U5 启动根不可读:create 时仍建空列表(不是只有横幅);
|
||||
* U6 缩略图 LRU 跳过解压页缓存(M11 P2-1 回归):cache_prune 只统计/删除缩略图,
|
||||
* mrpage_*(page_cache 的解压页)必须保留且不计入 64 张额度。
|
||||
* 目的(M18 文件管理器模型):
|
||||
* U1 行模型:目录 + 图片 + 档案册混排(目录优先+自然序),行副标题是扫描期生成的
|
||||
* 类型·大小(不再有占位/后台补位),图标/右侧箭头按类型区分;
|
||||
* U2 行合成零 IO:反复取可见行不触发 opendir/readdir/lstat(渲染期无文件访问);
|
||||
* U3 点击语义:点目录=进入(空目录也进);点图片=从该图开始读
|
||||
* (mr_reader_open_dir_at(当前目录, 图片下标));点档案册=打开档案册;
|
||||
* U4 根目录锁定:根不显示顶栏返回、library_go_up 在根返回 0(app.c 退出);
|
||||
* 子目录返回=回根;不存在越过浏览根的导航;
|
||||
* U5 无 SD 卡容错:浏览根不可访问(不存在/不是目录)→ 友好空态「未检测到 SD 卡」,
|
||||
* 不弹错误横幅;顶栏刷新/重扫可重试成功;
|
||||
* U6 子目录不可读:停留原目录 + 顶部提示横幅;
|
||||
* U7 书库不再碰缓存目录:CBZ 解压页缓存 mrpage_* 原样保留,且不写任何 bmp。
|
||||
*
|
||||
* 编译:见 test/run_tests.sh(与 test_settings_lock_host 同口径:链 SDK 源码树 + 打桩;
|
||||
* 只发布 meow_ui.a 的交付环境自动 SKIP)。ui/library/library.c 以
|
||||
* -DMR_LIBRARY_UI_TEST_HOOKS 编译出测试接缝;本测试另打桩 mr_reader_open_dir/mr_nav_report。
|
||||
* 编译:见 test/run_tests.sh(链 SDK 源码树 + 打桩;只发布 meow_ui.a 的环境自动 SKIP)。
|
||||
* ui/library/library.c 以 -DMR_LIBRARY_UI_TEST_HOOKS 编译出测试接缝;logic/library.c
|
||||
* 以 -DMR_LIBRARY_TEST_HOOKS 编译出 IO 计数。
|
||||
*/
|
||||
#include "tuku_internal.h"
|
||||
#include "logic/library.h"
|
||||
#include "logic/reader/page_cache.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <fcntl.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
#include <dirent.h>
|
||||
|
||||
/* ---- ui/library/library.c 的测试接缝(仅 -DMR_LIBRARY_UI_TEST_HOOKS 时定义) ---- */
|
||||
/* ui/library/library.c 的测试接缝(仅 -DMR_LIBRARY_UI_TEST_HOOKS 时定义) */
|
||||
int library_test_load(const char *dir);
|
||||
void library_test_set_root(const char *root);
|
||||
const char *library_test_root(void);
|
||||
@@ -37,14 +39,26 @@ int library_test_rows(void);
|
||||
int library_test_src(int idx, meow_list_item *item);
|
||||
int library_test_hint_active(void);
|
||||
const char *library_test_hint(void);
|
||||
int library_test_go_root(void);
|
||||
int library_test_topbar_back(void);
|
||||
const char *library_test_empty_text(void);
|
||||
int library_test_sd_missing(void);
|
||||
int library_test_retry(void);
|
||||
int library_test_go_up(void);
|
||||
int library_test_go_parent(void);
|
||||
int library_test_drag_active(void);
|
||||
int library_test_has_list(void);
|
||||
const char *library_test_cache_dir(void);
|
||||
int library_test_cache_on(void);
|
||||
void library_test_cache_prune(void);
|
||||
|
||||
/* 阅读器入口打桩:只记录被打开的册路径与起始页(本测试不建 reader 页)。 */
|
||||
#define MR_READER_START_RESUME (-1)
|
||||
void mr_reader_open_dir_at(const char *dir, int start_index);
|
||||
static char g_opened[MR_PATH_MAX];
|
||||
static int g_opened_start;
|
||||
static int g_opened_n;
|
||||
void mr_reader_open_dir_at(const char *dir, int start_index)
|
||||
{
|
||||
snprintf(g_opened, sizeof g_opened, "%s", dir ? dir : "");
|
||||
g_opened_start = start_index;
|
||||
g_opened_n++;
|
||||
}
|
||||
|
||||
static int g_fail;
|
||||
#define CHECK(cond, ...) do { \
|
||||
@@ -55,15 +69,6 @@ static int g_fail;
|
||||
/* app.c 的导航上报在测试里不需要网络侧实现。 */
|
||||
void mr_nav_report(void) {}
|
||||
|
||||
/* reader 打桩:只记录被打开的册路径(本测试不建 reader 页)。 */
|
||||
static char g_opened[MR_PATH_MAX];
|
||||
static int g_opened_n;
|
||||
void mr_reader_open_dir(const char *path)
|
||||
{
|
||||
snprintf(g_opened, sizeof g_opened, "%s", path ? path : "");
|
||||
g_opened_n++;
|
||||
}
|
||||
|
||||
static int mkdir_p(const char *path)
|
||||
{
|
||||
char tmp[MR_PATH_MAX];
|
||||
@@ -125,16 +130,15 @@ static int row_index_for(const char *name)
|
||||
return -1;
|
||||
}
|
||||
|
||||
static void set_mtime(const char *path, time_t t)
|
||||
static void tap_row(const char *name)
|
||||
{
|
||||
struct timespec ts[2];
|
||||
ts[0].tv_sec = t;
|
||||
ts[0].tv_nsec = 0;
|
||||
ts[1] = ts[0];
|
||||
utimensat(AT_FDCWD, path, ts, 0);
|
||||
int idx = row_index_for(name);
|
||||
if (idx < 0) { CHECK(0, "row %s not found", name); return; }
|
||||
int list_top = meow_ui_statusbar_height() + 56;
|
||||
library_on_tap(120, list_top + idx * 56 + 28);
|
||||
}
|
||||
|
||||
/* 统计缓存目录里的缩略图(*.bmp 且非 mrpage_*)与解压页(mrpage_*.bmp)。 */
|
||||
/* 统计缓存目录里的 bmp(缩略图/解压页)。 */
|
||||
static int count_bmp(const char *dir, int *pages)
|
||||
{
|
||||
DIR *d = opendir(dir);
|
||||
@@ -144,7 +148,7 @@ static int count_bmp(const char *dir, int *pages)
|
||||
while ((de = readdir(d)) != NULL) {
|
||||
size_t len = strlen(de->d_name);
|
||||
if (len <= 4 || strcmp(de->d_name + len - 4, ".bmp") != 0) continue;
|
||||
if (strncmp(de->d_name, "mrpage_", 7) == 0) pg++;
|
||||
if (mr_cache_is_page_name(de->d_name)) pg++;
|
||||
else thumbs++;
|
||||
}
|
||||
closedir(d);
|
||||
@@ -152,71 +156,6 @@ static int count_bmp(const char *dir, int *pages)
|
||||
return thumbs;
|
||||
}
|
||||
|
||||
static void wait_ready(int ms)
|
||||
{
|
||||
for (int t = 0; t < ms / 10; t++) {
|
||||
library_on_tick();
|
||||
int ready = 1;
|
||||
int rows = library_test_rows();
|
||||
for (int i = 0; i < rows; i++) {
|
||||
meow_list_item it;
|
||||
if (!library_test_src(i, &it)) continue;
|
||||
if (it.subtitle && strcmp(it.subtitle, "…") == 0) { ready = 0; break; }
|
||||
}
|
||||
if (ready) return;
|
||||
usleep(10000);
|
||||
}
|
||||
}
|
||||
|
||||
/* U6:缩略图 LRU 必须跳过解压页缓存(M11 P2-1 回归;MR_CACHE_MAX_FILES=64)。 */
|
||||
static void test_cache_prune_skips_pages(void)
|
||||
{
|
||||
const char *dir = library_test_cache_dir();
|
||||
CHECK(library_test_cache_on() && dir && dir[0], "thumbnail cache enabled: %s",
|
||||
dir ? dir : "");
|
||||
if (!dir || !dir[0]) return;
|
||||
|
||||
/* 清空之前可能写入的 bmp(本测试独占 MEOW_DATA_DIR)。 */
|
||||
DIR *d = opendir(dir);
|
||||
if (d) {
|
||||
struct dirent *de;
|
||||
while ((de = readdir(d)) != NULL) {
|
||||
size_t len = strlen(de->d_name);
|
||||
if (len > 4 && strcmp(de->d_name + len - 4, ".bmp") == 0) {
|
||||
char full[MR_PATH_MAX + 96];
|
||||
snprintf(full, sizeof full, "%s/%s", dir, de->d_name);
|
||||
unlink(full);
|
||||
}
|
||||
}
|
||||
closedir(d);
|
||||
}
|
||||
|
||||
char page[MR_PATH_MAX + 96], thumb[MR_PATH_MAX + 96];
|
||||
snprintf(page, sizeof page, "%s/mrpage_0123456789abcdef.bmp", dir);
|
||||
CHECK(touch_file(page) == 0, "create page cache file");
|
||||
set_mtime(page, time(NULL) - 7200); /* 最旧:若被误删一定先删它 */
|
||||
|
||||
time_t base = time(NULL) - 3600;
|
||||
for (int i = 0; i < 65; i++) { /* 65 张缩略图 > MR_CACHE_MAX_FILES */
|
||||
snprintf(thumb, sizeof thumb, "%s/%016x_%ld_%d.bmp", dir,
|
||||
(unsigned)i, (long)(base + i), i);
|
||||
CHECK(touch_file(thumb) == 0, "create thumbnail %d", i);
|
||||
set_mtime(thumb, base + i);
|
||||
}
|
||||
|
||||
int pages_before = 0;
|
||||
CHECK(count_bmp(dir, &pages_before) == 65, "precondition 65 thumbnails");
|
||||
CHECK(pages_before == 1, "precondition 1 page (got %d)", pages_before);
|
||||
|
||||
library_test_cache_prune();
|
||||
|
||||
int pages_after = 0;
|
||||
int thumbs_after = count_bmp(dir, &pages_after);
|
||||
CHECK(access(page, R_OK) == 0, "mrpage_* survives thumbnail LRU");
|
||||
CHECK(pages_after == 1, "mrpage_* not counted as thumbnail (got %d)", pages_after);
|
||||
CHECK(thumbs_after == 64, "thumbnails trimmed to 64 (got %d)", thumbs_after);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
const char *base = getenv("TMPDIR");
|
||||
@@ -227,129 +166,192 @@ int main(void)
|
||||
if (!data) { printf("SKIP: mkdtemp failed\n"); return 0; }
|
||||
setenv("MEOW_DATA_DIR", data, 1);
|
||||
|
||||
char root[MR_PATH_MAX];
|
||||
char root[MR_PATH_MAX], p[MR_PATH_MAX];
|
||||
snprintf(root, sizeof root, "%s/library", data);
|
||||
char p[MR_PATH_MAX];
|
||||
const char *dirs[] = { "book", "empty", "noimg", "deep/sub", "MeowOS/sub" };
|
||||
const char *dirs[] = { "album", "empty", "noimg", "deep/sub", "Pictures", "Pictures/sub" };
|
||||
for (unsigned i = 0; i < sizeof dirs / sizeof dirs[0]; i++) {
|
||||
joinp(p, sizeof p, root, dirs[i]);
|
||||
if (mkdir_p(p) != 0) { printf("SKIP: mkdir %s failed\n", p); return 0; }
|
||||
}
|
||||
joinp(p, sizeof p, root, "book/page2.png"); touch_file(p);
|
||||
joinp(p, sizeof p, root, "book/page10.jpg"); touch_file(p);
|
||||
joinp(p, sizeof p, root, "album/001.png"); touch_file(p);
|
||||
joinp(p, sizeof p, root, "album/002.png"); touch_file(p);
|
||||
joinp(p, sizeof p, root, "noimg/readme.txt"); touch_file(p);
|
||||
joinp(p, sizeof p, root, "deep/sub/001.png"); touch_file(p);
|
||||
joinp(p, sizeof p, root, "MeowOS/sub/m.png"); touch_file(p);
|
||||
joinp(p, sizeof p, root, "root.png"); touch_file(p);
|
||||
joinp(p, sizeof p, root, "book.cbz"); touch_file(p);
|
||||
|
||||
/* Pictures:大小写混排 + 数字序号 + 大写扩展名 + 档案册(图片排序契约夹具)。 */
|
||||
const char *pics[] = { "A.jpg", "a.jpg", "img2.png", "IMG3.JPG", "img10.png",
|
||||
"page01.bmp", "page1.jpeg", "cover.cbz", "notes.txt" };
|
||||
for (unsigned i = 0; i < sizeof pics / sizeof pics[0]; i++) {
|
||||
joinp(p, sizeof p, root, "Pictures");
|
||||
size_t dl = strlen(p);
|
||||
snprintf(p + dl, sizeof p - dl, "/%s", pics[i]);
|
||||
touch_file(p);
|
||||
}
|
||||
|
||||
meow_ui_opts o = { 480, 720 };
|
||||
if (meow_ui_init(&o) != 0) { printf("SKIP: meow_ui_init failed (no host backend)\n"); return 0; }
|
||||
|
||||
/* U5:先指向一个不存在的根,再 create —— 真实走「启动时根目录不可读」分支。 */
|
||||
const char *bad_root = "/no/such/tuku/m13_root";
|
||||
/* ---- U5:浏览根不可访问 —— 先指向不存在的根,再 create ---- */
|
||||
const char *bad_root = "/no/such/tuku/m18_root";
|
||||
library_test_set_root(bad_root);
|
||||
CHECK(strcmp(library_test_root(), bad_root) == 0, "bad root installed");
|
||||
meow_view *lib = library_view_create();
|
||||
CHECK(library_test_hint_active(), "startup unreadable root shows hint: %s",
|
||||
library_test_hint());
|
||||
CHECK(library_test_rows() == 0, "startup empty list rows=%d", library_test_rows());
|
||||
CHECK(library_test_has_list(), "startup built listview for empty list");
|
||||
CHECK(library_test_sd_missing() == 1, "unreadable root -> SD missing state");
|
||||
CHECK(library_test_rows() == 0, "empty list rows=%d", library_test_rows());
|
||||
CHECK(library_test_has_list(), "startup still builds a listview");
|
||||
CHECK(strcmp(library_test_dir(), bad_root) == 0, "startup dir=%s", library_test_dir());
|
||||
CHECK(strstr(library_test_empty_text(), "SD") != NULL,
|
||||
"friendly empty state: %s", library_test_empty_text());
|
||||
CHECK(library_test_hint_active() == 0, "no error banner for missing SD");
|
||||
CHECK(library_test_topbar_back() == 0, "no back arrow at storage root");
|
||||
CHECK(library_test_retry() == 0 && library_test_sd_missing() == 1,
|
||||
"retry while still missing stays in empty state (no crash)");
|
||||
|
||||
/* 根路径不是目录(普通文件)同样走 SD 空态。 */
|
||||
char notadir[MR_PATH_MAX];
|
||||
joinp(notadir, sizeof notadir, data, "notadir");
|
||||
touch_file(notadir);
|
||||
library_test_set_root(notadir);
|
||||
library_test_set_dir(notadir);
|
||||
CHECK(library_test_retry() == 0 && library_test_sd_missing() == 1,
|
||||
"root pointing at a file -> SD missing state");
|
||||
|
||||
/* 插卡/挂载恢复:同一路径变成可读目录后,刷新即恢复列表。 */
|
||||
library_test_set_root(root);
|
||||
CHECK(strcmp(library_test_root(), root) == 0, "test root installed");
|
||||
library_test_set_dir(root);
|
||||
CHECK(library_test_retry() > 0, "retry after storage available loads rows");
|
||||
CHECK(library_test_sd_missing() == 0, "SD missing state cleared");
|
||||
CHECK(strcmp(library_test_dir(), root) == 0, "now browsing root");
|
||||
CHECK(library_test_topbar_back() == 0, "still no back arrow at root");
|
||||
|
||||
meow_view_page_push(ACT_LIBRARY);
|
||||
CHECK(meow_ui_activity_active() == ACT_LIBRARY, "library active");
|
||||
|
||||
/* ---- U1:src2 行 + 懒算补位 ---- */
|
||||
CHECK(library_test_load(root) == 1, "load fixture");
|
||||
CHECK(library_test_rows() == 5, "5 rows (got %d)", library_test_rows());
|
||||
|
||||
static const char *want[5] = { "book", "deep", "empty", "MeowOS", "noimg" };
|
||||
for (int i = 0; i < 5; i++) {
|
||||
/* ---- U1:行模型(目录优先 + 自然序 + 类型图标/箭头) ---- */
|
||||
CHECK(library_test_load(root) == 1, "load fixture root");
|
||||
CHECK(library_test_rows() == 7, "7 rows (got %d)", library_test_rows());
|
||||
static const char *want[7] = {
|
||||
"album", "deep", "empty", "noimg", "Pictures", "book.cbz", "root.png"
|
||||
};
|
||||
for (int i = 0; i < 7; i++) {
|
||||
meow_list_item it;
|
||||
CHECK(library_test_src(i, &it) == 1, "src row %d", i);
|
||||
CHECK(it.title && strcmp(it.title, want[i]) == 0, "row[%d]=%s (want %s)",
|
||||
i, it.title ? it.title : "(null)", want[i]);
|
||||
CHECK(it.subtitle && strcmp(it.subtitle, "…") == 0,
|
||||
"row[%d] starts with placeholder (got %s)", i, it.subtitle ? it.subtitle : "");
|
||||
CHECK(it.right == MEOW_LIST_RIGHT_ARROW, "row[%d] has enter arrow while pending", i);
|
||||
CHECK(it.subtitle && it.subtitle[0] && strcmp(it.subtitle, "…") != 0,
|
||||
"row[%d] subtitle pre-generated (%s)", i, it.subtitle ? it.subtitle : "");
|
||||
if (i < 5) {
|
||||
CHECK(it.icon_kind == MEOW_LIST_ICON_FOLDER && it.right == MEOW_LIST_RIGHT_ARROW,
|
||||
"row[%d] is a folder row with enter arrow", i);
|
||||
} else {
|
||||
CHECK(it.icon_kind == MEOW_LIST_ICON_FILE && it.right == MEOW_LIST_RIGHT_NONE,
|
||||
"row[%d] is a file row without child arrow", i);
|
||||
}
|
||||
}
|
||||
|
||||
wait_ready(3000);
|
||||
/* U2:补位后的行语义 */
|
||||
for (int i = 0; i < 5; i++) {
|
||||
meow_list_item it;
|
||||
library_test_src(i, &it);
|
||||
CHECK(it.subtitle && strcmp(it.subtitle, "…") != 0,
|
||||
"row[%d] subtitle filled (got %s)", i, it.subtitle ? it.subtitle : "");
|
||||
}
|
||||
int ib = row_index_for("book");
|
||||
int ie = row_index_for("empty");
|
||||
int in = row_index_for("noimg");
|
||||
int id = row_index_for("deep");
|
||||
int ic = row_index_for("book.cbz");
|
||||
meow_list_item it;
|
||||
CHECK(ib >= 0 && library_test_src(ib, &it) && strcmp(it.subtitle, "2 pages") == 0,
|
||||
"book subtitle=%s", it.subtitle ? it.subtitle : "");
|
||||
CHECK(it.right == MEOW_LIST_RIGHT_NONE, "book row has no enter arrow (opens reader)");
|
||||
CHECK(ie >= 0 && library_test_src(ie, &it) && strcmp(it.subtitle, "空目录") == 0,
|
||||
"empty subtitle=%s", it.subtitle ? it.subtitle : "");
|
||||
CHECK(in >= 0 && library_test_src(in, &it) && strcmp(it.subtitle, "无图片") == 0,
|
||||
"noimg subtitle=%s", it.subtitle ? it.subtitle : "");
|
||||
CHECK(id >= 0 && library_test_src(id, &it) && strcmp(it.subtitle, "无图片") == 0,
|
||||
"deep (images only in subdir) is not a book: %s", it.subtitle ? it.subtitle : "");
|
||||
CHECK(ic >= 0 && library_test_src(ic, &it) &&
|
||||
strncmp(it.subtitle, "档案册 · ", strlen("档案册 · ")) == 0,
|
||||
"archive subtitle=%s", it.subtitle ? it.subtitle : "");
|
||||
int ir = row_index_for("root.png");
|
||||
CHECK(ir >= 0 && library_test_src(ir, &it) &&
|
||||
strncmp(it.subtitle, "图片 · ", strlen("图片 · ")) == 0,
|
||||
"image subtitle=%s", it.subtitle ? it.subtitle : "");
|
||||
|
||||
/* ---- 行点击:书 → reader;空目录 → 进入 ---- */
|
||||
int list_top = meow_ui_statusbar_height() + 56;
|
||||
/* ---- U2:行合成零 IO(渲染/滚动路径不访问文件系统) ---- */
|
||||
mr_library_test_io_reset();
|
||||
for (int pass = 0; pass < 500; pass++)
|
||||
for (int i = 0; i < library_test_rows(); i++) {
|
||||
meow_list_item tmp;
|
||||
library_test_src(i, &tmp);
|
||||
}
|
||||
mr_library_io_stats io;
|
||||
mr_library_test_io_get(&io);
|
||||
CHECK(io.opendirs == 0 && io.readdirs == 0 && io.lstats == 0,
|
||||
"row source does no IO (open=%ld rd=%ld stat=%ld)",
|
||||
io.opendirs, io.readdirs, io.lstats);
|
||||
|
||||
/* ---- U3:点击语义 ---- */
|
||||
char pics_dir[MR_PATH_MAX];
|
||||
joinp(pics_dir, sizeof pics_dir, root, "Pictures");
|
||||
CHECK(library_test_load(pics_dir) == 1, "entered Pictures");
|
||||
CHECK(strcmp(library_test_dir(), pics_dir) == 0, "current dir=%s", library_test_dir());
|
||||
|
||||
/* 图片下标 = 该目录里排在该图之前的图片行数(阅读器目录册同序)。 */
|
||||
const char *target = "img10.png";
|
||||
int ti = row_index_for(target);
|
||||
CHECK(ti >= 0, "target image row present");
|
||||
int expect_idx = 0;
|
||||
for (int i = 0; i < ti; i++) {
|
||||
meow_list_item tmp;
|
||||
if (!library_test_src(i, &tmp)) continue;
|
||||
if (tmp.subtitle && strncmp(tmp.subtitle, "图片 · ", strlen("图片 · ")) == 0)
|
||||
expect_idx++;
|
||||
}
|
||||
g_opened_n = 0;
|
||||
library_on_tap(120, list_top + ib * 56 + 28);
|
||||
CHECK(g_opened_n == 1 && strstr(g_opened, "/book") != NULL,
|
||||
"tap book opens reader (%s)", g_opened);
|
||||
CHECK(strcmp(library_test_dir(), root) == 0, "tap book does not navigate");
|
||||
tap_row(target);
|
||||
CHECK(g_opened_n == 1 && strcmp(g_opened, pics_dir) == 0,
|
||||
"tap image opens reader for current dir (%s)", g_opened);
|
||||
CHECK(g_opened_start == expect_idx, "start index=%d (want %d)", g_opened_start, expect_idx);
|
||||
CHECK(strcmp(library_test_dir(), pics_dir) == 0, "tap image does not navigate");
|
||||
|
||||
library_on_tap(120, list_top + ie * 56 + 28);
|
||||
char empty_path[MR_PATH_MAX];
|
||||
joinp(empty_path, sizeof empty_path, root, "empty");
|
||||
CHECK(strcmp(library_test_dir(), empty_path) == 0,
|
||||
"tap empty dir enters it (%s)", library_test_dir());
|
||||
g_opened_n = 0;
|
||||
tap_row("cover.cbz");
|
||||
char archive[MR_PATH_MAX];
|
||||
joinp(archive, sizeof archive, pics_dir, "cover.cbz");
|
||||
CHECK(g_opened_n == 1 && strcmp(g_opened, archive) == 0,
|
||||
"tap archive opens archive book (%s)", g_opened);
|
||||
CHECK(g_opened_start == MR_READER_START_RESUME, "archive uses resume start (%d)",
|
||||
g_opened_start);
|
||||
|
||||
/* 点目录 = 进入;空目录也进(不打开阅读器)。 */
|
||||
char sub_dir[MR_PATH_MAX];
|
||||
joinp(sub_dir, sizeof sub_dir, pics_dir, "sub");
|
||||
g_opened_n = 0;
|
||||
tap_row("sub");
|
||||
CHECK(g_opened_n == 0, "tap dir does not open reader");
|
||||
CHECK(strcmp(library_test_dir(), sub_dir) == 0, "entered empty sub dir (%s)",
|
||||
library_test_dir());
|
||||
CHECK(library_test_rows() == 0, "empty dir shows empty list");
|
||||
CHECK(strcmp(library_test_empty_text(), "空文件夹") == 0,
|
||||
"empty dir state: %s", library_test_empty_text());
|
||||
|
||||
/* ---- U3:导航(逐级上溯 / 到 / 退出 / 回 TF 根) ---- */
|
||||
CHECK(library_test_go_up() == 1, "go_up from empty consumed");
|
||||
CHECK(strcmp(library_test_dir(), root) == 0, "go_up to fixture root");
|
||||
/* ---- U4:根目录锁定 ---- */
|
||||
char album_dir[MR_PATH_MAX];
|
||||
joinp(album_dir, sizeof album_dir, root, "album");
|
||||
CHECK(library_test_load(album_dir) == 1, "load album");
|
||||
CHECK(library_test_topbar_back() == 1, "back arrow shown in subdir");
|
||||
CHECK(library_test_go_up() == 1, "go_up from subdir consumed");
|
||||
CHECK(strcmp(library_test_dir(), root) == 0, "back to root");
|
||||
|
||||
char deep[MR_PATH_MAX];
|
||||
joinp(deep, sizeof deep, root, "deep/sub");
|
||||
CHECK(library_test_load(deep) == 1, "load deep/sub");
|
||||
CHECK(library_test_go_up() == 1, "go_up from deep/sub");
|
||||
joinp(p, sizeof p, root, "deep");
|
||||
CHECK(strcmp(library_test_dir(), p) == 0, "now at deep");
|
||||
/* 根:系统返回 = 退出(返回 0),并保持列表不变。 */
|
||||
CHECK(library_test_go_up() == 0, "go_up at root returns 0 (app exits)");
|
||||
CHECK(strcmp(library_test_dir(), root) == 0, "still at root after exit gesture");
|
||||
CHECK(library_test_topbar_back() == 0, "no back arrow at root");
|
||||
|
||||
/* 「TF卡」回根 */
|
||||
CHECK(library_test_load(deep) == 1, "reload deep/sub");
|
||||
CHECK(library_test_go_root() == 1 && strcmp(library_test_dir(), root) == 0,
|
||||
"TF卡 action returns to library root");
|
||||
/* 顶栏返回:子目录点击返回箭头 = 回根;根上该区域不响应。 */
|
||||
CHECK(library_test_load(album_dir) == 1, "load album again");
|
||||
library_on_tap(20, meow_ui_statusbar_height() + 28);
|
||||
CHECK(strcmp(library_test_dir(), root) == 0, "topbar back returns to root");
|
||||
int root_rows_before = library_test_rows();
|
||||
library_on_tap(20, meow_ui_statusbar_height() + 28);
|
||||
CHECK(strcmp(library_test_dir(), root) == 0 && library_test_rows() == root_rows_before,
|
||||
"topbar back is inert at root");
|
||||
|
||||
/* 上溯到文件系统 /:go_up 在 / 返回 0(app.c 据此退出)。
|
||||
* 宿主(Termux)读不了 "/":从 /usr 返回时 load("/") 失败,仍应切到 "/" 的空列表
|
||||
* (否则会卡在 /usr 无法退出),下一次返回=退出 App。 */
|
||||
library_test_set_dir("/usr");
|
||||
CHECK(library_test_go_up() == 1, "go_up toward / consumed");
|
||||
CHECK(strcmp(library_test_dir(), "/") == 0, "reached / (unreadable root fallback)");
|
||||
CHECK(library_test_go_up() == 0, "go_up at / returns 0 (app exits)");
|
||||
CHECK(strcmp(library_test_dir(), "/") == 0, "still at / after exit gesture");
|
||||
/* 嵌套:deep/sub 两级返回。 */
|
||||
tap_row("deep");
|
||||
tap_row("sub");
|
||||
char deep_sub[MR_PATH_MAX], deep_dir[MR_PATH_MAX];
|
||||
joinp(deep_dir, sizeof deep_dir, root, "deep");
|
||||
joinp(deep_sub, sizeof deep_sub, root, "deep/sub");
|
||||
CHECK(strcmp(library_test_dir(), deep_sub) == 0, "entered deep/sub (%s)", library_test_dir());
|
||||
CHECK(library_test_go_up() == 1 && strcmp(library_test_dir(), deep_dir) == 0, "up to deep");
|
||||
CHECK(library_test_go_up() == 1 && strcmp(library_test_dir(), root) == 0, "up to root");
|
||||
|
||||
/* ---- U3b:系统返回语义 = TF 根退出 App;顶栏「上级」保留越界浏览 ---- */
|
||||
CHECK(library_test_load(root) == 1, "load fixture root");
|
||||
CHECK(library_test_go_up() == 0, "go_up at TF root returns 0 (app exits)");
|
||||
CHECK(strcmp(library_test_dir(), root) == 0, "still at TF root after exit gesture");
|
||||
|
||||
/* 顶栏显式「上级」:访问放开,TF 根 → 其父目录(fixture root 的 parent = data)。 */
|
||||
CHECK(library_test_go_parent() == 1, "explicit up from TF root consumed");
|
||||
CHECK(strcmp(library_test_dir(), data) == 0,
|
||||
"explicit up goes above TF root (%s)", library_test_dir());
|
||||
CHECK(library_test_load(root) == 1, "back to fixture root");
|
||||
|
||||
/* ---- U3c:左缘 24px 系统返回手势带不启动列表拖拽 ---- */
|
||||
/* ---- U3c:左缘 24px / 屏底热区不启动列表拖拽 ---- */
|
||||
int list_top = meow_ui_statusbar_height() + 56;
|
||||
int row_y = list_top + 28;
|
||||
library_on_touch_down(120, row_y);
|
||||
CHECK(library_test_drag_active() == 1, "touch inside list starts drag");
|
||||
@@ -362,11 +364,10 @@ int main(void)
|
||||
library_on_touch_down(120, meow_ui_height() - 4);
|
||||
CHECK(library_test_drag_active() == 0, "bottom hotzone does not start drag");
|
||||
|
||||
/* ---- U4:不可读目录 → 停留 + 提示 ---- */
|
||||
CHECK(library_test_load(root) == 1, "back to fixture root");
|
||||
char noread[MR_PATH_MAX];
|
||||
joinp(noread, sizeof noread, root, "noread");
|
||||
/* ---- U6:不可读子目录 → 停留 + 提示 ---- */
|
||||
if (geteuid() != 0) {
|
||||
char noread[MR_PATH_MAX];
|
||||
joinp(noread, sizeof noread, root, "noread");
|
||||
CHECK(mkdir(noread, 0755) == 0, "mkdir noread");
|
||||
chmod(noread, 0000);
|
||||
int ok = library_test_load(noread);
|
||||
@@ -381,7 +382,33 @@ int main(void)
|
||||
printf(" SKIP unreadable-dir case (running as root)\n");
|
||||
}
|
||||
|
||||
test_cache_prune_skips_pages();
|
||||
/* ---- U7:书库不碰缓存目录(CBZ 解压页缓存原样保留,不写 bmp) ---- */
|
||||
CHECK(mr_cache_init(data) == 0, "page cache init");
|
||||
const char *cdir = mr_cache_dir();
|
||||
CHECK(cdir && cdir[0], "page cache dir: %s", cdir ? cdir : "");
|
||||
if (cdir && cdir[0]) {
|
||||
char page[MR_PATH_MAX + 96];
|
||||
snprintf(page, sizeof page, "%s/mrpage_0123456789abcdef.bmp", cdir);
|
||||
CHECK(touch_file(page) == 0, "create page cache file");
|
||||
int pages_before = 0;
|
||||
count_bmp(cdir, &pages_before);
|
||||
CHECK(pages_before == 1, "precondition 1 page (got %d)", pages_before);
|
||||
|
||||
CHECK(library_test_load(root) == 1, "reload root");
|
||||
for (int i = 0; i < library_test_rows(); i++) {
|
||||
meow_list_item tmp;
|
||||
library_test_src(i, &tmp);
|
||||
}
|
||||
tap_row("album");
|
||||
tap_row("001.png"); /* 打开阅读器入口(打桩) */
|
||||
library_test_go_up();
|
||||
|
||||
int pages_after = 0;
|
||||
int thumbs_after = count_bmp(cdir, &pages_after);
|
||||
CHECK(access(page, R_OK) == 0, "mrpage_* survives book browsing");
|
||||
CHECK(pages_after == 1 && thumbs_after == 0,
|
||||
"library writes no bmp to cache (pages=%d thumbs=%d)", pages_after, thumbs_after);
|
||||
}
|
||||
|
||||
meow_view_free(lib);
|
||||
meow_ui_shutdown();
|
||||
|
||||
+32
-94
@@ -15,13 +15,12 @@
|
||||
* T6 坏档容错:非 zip/截断/无图片页/声明 96MiB 的 zip 炸弹 → 打开失败或 0 页,不崩。
|
||||
* T7 统一开册契约:目录册与档案册走同一个 mr_book(count/meta/open/close/destroy),
|
||||
* 越界 open 返回 NULL,close_auto 幂等。
|
||||
* T8 书库列档(M13 单层 + 懒判定):.cbz 与文件夹册同列自然序(bookA < box < ... <
|
||||
* Vol10.cbz);扫描期只成行、副标题占位,探测补位后才有页数/书标记;
|
||||
* 坏档/无图片页档案仍列出但为非书(访问放开,不再隐藏);
|
||||
* 不注入探测回调时档案文件不成行(M7 语义),目录仍按单层列出。
|
||||
* T8 书库列档(M18 文件管理器模型):目录 + .cbz/.zip 同列(目录优先、组内自然序,
|
||||
* bookA < box < … < Vol10.cbz);扫描期一次成行,副标题是「文件夹/档案册 · 大小」,
|
||||
* 不再探测页数/封面/续读角标;坏档/无图片页档案也照常列出(点开时才由 reader 判)。
|
||||
* T9 册间衔接:同父目录自然序混排(bookA → vol2.cbz → Vol10.cbz),
|
||||
* 首/末册返回 0;不注入探测回调时档案册不参与(M7 语义)。
|
||||
* T10 续读角标(M13 两段式):档案路径作为册标识,probe 补位后副标题后缀「· 续读 Pn」。
|
||||
* T10 封面解压 + 无续读角标:档案首图仍可解到缓存;书库行不再带「· 续读 Pn」。
|
||||
*
|
||||
* 编译(在 tuku/ 下):
|
||||
* gcc -Wall -Wextra -Werror -O1 -I. test/test_cbz.c logic/library.c logic/reader.c \
|
||||
@@ -716,28 +715,15 @@ static const mr_library_entry *lib_find(const mr_library *lib, const char *name)
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* M13:模拟 UI 后台探测补位(目录单层探测 / 档案册注入页数回调)。 */
|
||||
static void lib_probe_all(mr_library *lib)
|
||||
{
|
||||
for (int i = 0; i < lib->count; i++) {
|
||||
mr_library_probe p;
|
||||
if (mr_library_probe_path(lib->items[i].path, lib->items[i].is_archive,
|
||||
archive_pages_probe, NULL, &p) == 0)
|
||||
mr_library_apply_probe(lib, i, &p);
|
||||
else
|
||||
mr_library_mark_error(lib, i);
|
||||
}
|
||||
}
|
||||
|
||||
static void test_library(void)
|
||||
{
|
||||
mr_test_cfg_clear();
|
||||
|
||||
/* M13:扫描只做单层枚举——目录(全部列出,含无图的 box)与档案文件(.cbz/.zip)
|
||||
* 都先成行、副标题占位;书/非书与页数由后台 probe 补位。 */
|
||||
/* 文件管理器模型:目录 + 档案册同列(目录优先、组内自然序),扫描期一次成行;
|
||||
* 档案册只按扩展名成行,不探测页数/封面(打开时才由 reader 解档)。 */
|
||||
mr_library lib;
|
||||
memset(&lib, 0, sizeof lib);
|
||||
int n = mr_library_scan_ex(&lib, g_lib, MR_LIB_MAX_DEPTH, archive_pages_probe, NULL);
|
||||
int n = mr_library_scan(&lib, g_lib, MR_LIB_MAX_DEPTH);
|
||||
CHECK(n == 8, "library rows=%d (want 2 dirs + 6 archives)", n);
|
||||
static const char *want[8] = {
|
||||
"bookA", "box", "broken.cbz", "emptypages.cbz",
|
||||
@@ -746,68 +732,33 @@ static void test_library(void)
|
||||
for (int i = 0; i < 8 && i < lib.count; i++) {
|
||||
CHECK(strcmp(lib.items[i].name, want[i]) == 0,
|
||||
"row[%d]=%s (want %s)", i, lib.items[i].name, want[i]);
|
||||
CHECK(lib.items[i].probe_state == MR_LIB_PENDING && lib.items[i].pages == -1,
|
||||
"%s pending after scan", lib.items[i].name);
|
||||
CHECK(strcmp(lib.items[i].subtitle, "…") == 0,
|
||||
"%s placeholder subtitle=%s", lib.items[i].name, lib.items[i].subtitle);
|
||||
}
|
||||
|
||||
/* 后台补位(等价 UI 的 on_tick):目录走单层目录探测,档案走注入的 zip 页数回调。 */
|
||||
for (int i = 0; i < lib.count; i++) {
|
||||
mr_library_probe p;
|
||||
int ok = (mr_library_probe_path(lib.items[i].path, lib.items[i].is_archive,
|
||||
archive_pages_probe, NULL, &p) == 0);
|
||||
CHECK(ok, "%s probed", lib.items[i].name);
|
||||
if (ok) mr_library_apply_probe(&lib, i, &p);
|
||||
}
|
||||
for (int i = 0; i < 2 && i < lib.count; i++)
|
||||
CHECK(lib.items[i].kind == MR_LIB_KIND_DIR &&
|
||||
strcmp(lib.items[i].subtitle, "文件夹") == 0,
|
||||
"%s dir row subtitle=%s", lib.items[i].name, lib.items[i].subtitle);
|
||||
for (int i = 2; i < 8 && i < lib.count; i++)
|
||||
CHECK(lib.items[i].kind == MR_LIB_KIND_ARCHIVE &&
|
||||
strncmp(lib.items[i].subtitle, "档案册 · ", strlen("档案册 · ")) == 0,
|
||||
"%s archive row subtitle=%s", lib.items[i].name, lib.items[i].subtitle);
|
||||
|
||||
const mr_library_entry *e = lib_find(&lib, "bookA");
|
||||
CHECK(e && e->pages == 2 && e->has_images == 1 && e->subdirs == 0, "bookA fields");
|
||||
CHECK(e && strcmp(e->subtitle, "2 pages") == 0, "bookA subtitle=%s",
|
||||
e ? e->subtitle : "");
|
||||
CHECK(e && strstr(e->cover, "001.png") != NULL, "bookA cover=%s",
|
||||
e ? e->cover : "");
|
||||
|
||||
/* box 只含档案册:目录本身不直接含图 → 不是书(M13 不做递归/子表统计)。 */
|
||||
e = lib_find(&lib, "box");
|
||||
CHECK(e && e->has_images == 0 && e->subdirs == 0 && e->is_archive == 0,
|
||||
"box fields");
|
||||
CHECK(e && strcmp(e->subtitle, "无图片") == 0, "box subtitle=%s",
|
||||
e ? e->subtitle : "");
|
||||
CHECK(e && e->cover[0] == '\0', "box cover should be empty");
|
||||
CHECK(e && e->kind == MR_LIB_KIND_DIR && e->size == 0, "bookA dir entry");
|
||||
|
||||
e = lib_find(&lib, "vol2.cbz");
|
||||
CHECK(e && e->pages == 3 && e->has_images == 1 && e->subdirs == 0 &&
|
||||
e->is_archive == 1, "vol2.cbz fields");
|
||||
CHECK(e && strcmp(e->subtitle, "3 pages") == 0, "vol2.cbz subtitle=%s",
|
||||
e ? e->subtitle : "");
|
||||
CHECK(e && e->cover[0] == '\0', "archive cover should be empty (UI extracts)");
|
||||
CHECK(e && strstr(e->path, "vol2.cbz") != NULL, "vol2.cbz path=%s",
|
||||
e ? e->path : "");
|
||||
CHECK(e && e->kind == MR_LIB_KIND_ARCHIVE && e->size > 0 &&
|
||||
strstr(e->path, "vol2.cbz") != NULL,
|
||||
"vol2.cbz archive entry (size=%lld)", e ? e->size : -1);
|
||||
|
||||
e = lib_find(&lib, "Vol10.cbz");
|
||||
CHECK(e && e->pages == 1 && strcmp(e->subtitle, "1 page") == 0,
|
||||
"Vol10.cbz pages/subtitle");
|
||||
|
||||
/* 坏档/无图片页档案:列出但探测为非书(M13 访问放开,不再隐藏)。 */
|
||||
static const char *not_book[] = { "notabook.zip", "emptypages.cbz",
|
||||
/* 坏档/无图片页档案也照常列出(书库不预判,点开时 reader 才判可读性)。 */
|
||||
static const char *all_arch[] = { "notabook.zip", "emptypages.cbz",
|
||||
"broken.cbz", "truncated.cbz" };
|
||||
for (unsigned i = 0; i < sizeof not_book / sizeof not_book[0]; i++) {
|
||||
e = lib_find(&lib, not_book[i]);
|
||||
CHECK(e && e->is_archive == 1 && e->has_images == 0 && e->pages == 0 &&
|
||||
strcmp(e->subtitle, "无图片") == 0,
|
||||
"%s: not a book (subtitle=%s)", not_book[i], e ? e->subtitle : "(missing)");
|
||||
for (unsigned i = 0; i < sizeof all_arch / sizeof all_arch[0]; i++) {
|
||||
e = lib_find(&lib, all_arch[i]);
|
||||
CHECK(e && e->kind == MR_LIB_KIND_ARCHIVE, "%s listed as archive", all_arch[i]);
|
||||
}
|
||||
CHECK(lib_find(&lib, ".hidden.cbz") == NULL, "hidden cbz not listed");
|
||||
mr_library_free(&lib);
|
||||
|
||||
/* 不注入探测回调:M7 纯目录书库(档案文件不成行,目录仍按 M13 单层列出)。 */
|
||||
memset(&lib, 0, sizeof lib);
|
||||
CHECK(mr_library_scan(&lib, g_lib, MR_LIB_MAX_DEPTH) == 2,
|
||||
"legacy scan rows=%d (want 2: bookA, box)", lib.count);
|
||||
CHECK(lib.count >= 2 && strcmp(lib.items[0].name, "bookA") == 0 &&
|
||||
strcmp(lib.items[1].name, "box") == 0, "legacy scan rows");
|
||||
mr_library_free(&lib);
|
||||
}
|
||||
|
||||
/* ============================================================ */
|
||||
@@ -863,7 +814,7 @@ static void test_adjacent(void)
|
||||
}
|
||||
|
||||
/* ============================================================ */
|
||||
/* T10 续读角标 + 封面 */
|
||||
/* T10 封面解压 + 无续读角标 */
|
||||
/* ============================================================ */
|
||||
static void test_badge_cover(void)
|
||||
{
|
||||
@@ -873,31 +824,18 @@ static void test_badge_cover(void)
|
||||
mr_test_cfg_clear();
|
||||
mr_cont_save(vol2, 2, MR_MODE_WEBTOON);
|
||||
|
||||
/* M13 两段式:扫描期只有占位副标题;后台 probe 补位后才出现页数与续读角标。 */
|
||||
/* 文件管理器模型:书库行只有「档案册 · 大小」,不再有「· 续读 Pn」角标
|
||||
* (续读仍由 reader 打开该册时恢复,见 test_mode_switch_host)。 */
|
||||
mr_library lib;
|
||||
memset(&lib, 0, sizeof lib);
|
||||
CHECK(mr_library_scan_ex(&lib, g_lib, MR_LIB_MAX_DEPTH, archive_pages_probe,
|
||||
NULL) == 8, "rescan for badge (single-layer rows)");
|
||||
CHECK(mr_library_scan(&lib, g_lib, MR_LIB_MAX_DEPTH) == 8, "rescan rows");
|
||||
const mr_library_entry *e = lib_find(&lib, "vol2.cbz");
|
||||
CHECK(e && strcmp(e->subtitle, "…") == 0, "vol2 placeholder before probe: %s",
|
||||
e ? e->subtitle : "");
|
||||
lib_probe_all(&lib);
|
||||
e = lib_find(&lib, "vol2.cbz");
|
||||
CHECK(e && strstr(e->subtitle, "续读 P3") != NULL, "badge subtitle=%s",
|
||||
e ? e->subtitle : "");
|
||||
CHECK(e && strstr(e->subtitle, "3 pages") != NULL, "badge keeps page text: %s",
|
||||
CHECK(e && strstr(e->subtitle, "续读") == NULL,
|
||||
"no continuation badge in file-manager rows: %s", e ? e->subtitle : "");
|
||||
CHECK(e && strstr(e->subtitle, "档案册") != NULL, "archive subtitle=%s",
|
||||
e ? e->subtitle : "");
|
||||
e = lib_find(&lib, "Vol10.cbz");
|
||||
CHECK(e && strstr(e->subtitle, "续读") == NULL, "Vol10 should have no badge");
|
||||
mr_library_free(&lib);
|
||||
|
||||
/* 清除记录后角标消失(重扫 + 再探测;探测缓存只存页数,角标按当前配置重算)。 */
|
||||
mr_cont_save(vol2, 0, MR_MODE_WEBTOON);
|
||||
memset(&lib, 0, sizeof lib);
|
||||
mr_library_scan_ex(&lib, g_lib, MR_LIB_MAX_DEPTH, archive_pages_probe, NULL);
|
||||
lib_probe_all(&lib);
|
||||
e = lib_find(&lib, "vol2.cbz");
|
||||
CHECK(e && strstr(e->subtitle, "续读") == NULL, "cleared badge gone");
|
||||
CHECK(e && strstr(e->subtitle, "续读") == NULL, "Vol10 no badge either");
|
||||
mr_library_free(&lib);
|
||||
|
||||
/* 封面 = 档内自然序首图(ch01/p2.png),解压到缓存 */
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* A3 边界:末页前进→下一册、首页后退→上一册(调用方语义);路径末尾斜杠等价;
|
||||
* A4 排除:隐藏/系统目录、符号链接、普通文件不参与兄弟册;
|
||||
* A5 参数:delta 非 ±1、NULL/小缓冲、不存在的目录;
|
||||
* A6 书库扫描接线(M13 两段式):scan 只列单层、副标题占位「…」;
|
||||
* probe/on_tick 补位后出现页数与「· 续读 Pn」角标。
|
||||
* A6 书库行模型(M18):目录+图片+档案册同列、目录优先;副标题扫描期生成,
|
||||
* 不再有「· 续读 Pn」角标(角标随「目录自动当书」链路删除,续读仍由 reader 恢复)。
|
||||
*
|
||||
* 编译(在 tuku/ 下):
|
||||
* gcc -Wall -Wextra -O1 -I. test/test_cont_adjacent.c logic/library.c logic/reader.c \
|
||||
@@ -159,84 +159,43 @@ static void test_adjacent(const char *root)
|
||||
CHECK(mr_book_adjacent("/", 1, out, (int)sizeof out) == -1, "root path invalid");
|
||||
}
|
||||
|
||||
/* 模拟 UI 后台探测:逐行单层 probe 并补位(page>0 的书行由 format_subtitle 拼续读角标)。 */
|
||||
static void probe_all_rows(mr_library *lib)
|
||||
{
|
||||
for (int i = 0; i < lib->count; i++) {
|
||||
mr_library_probe p;
|
||||
if (mr_library_probe_cached(lib->items[i].path, &p) == 0)
|
||||
mr_library_apply_probe(lib, i, &p);
|
||||
else
|
||||
mr_library_mark_error(lib, i);
|
||||
}
|
||||
}
|
||||
|
||||
/* A6 两段式(M13):scan 只做单层枚举、行副标题是占位「…」;后台 probe/on_tick 补位后
|
||||
* 才有页数与「· 续读 Pn」角标。 */
|
||||
static void test_library_badge(const char *root)
|
||||
/* A6 文件管理器行模型:扫描即出最终行(无占位/无后台补位/无续读角标);
|
||||
* 目录优先;当前层的图片行带图片下标(点图 → reader 起始页)。 */
|
||||
static void test_library_rows(const char *root)
|
||||
{
|
||||
mr_test_cfg_clear();
|
||||
char vol1[MR_PATH_MAX], vol2[MR_PATH_MAX];
|
||||
char vol1[MR_PATH_MAX];
|
||||
joinp(vol1, sizeof vol1, root, "vol1");
|
||||
joinp(vol2, sizeof vol2, root, "vol2");
|
||||
|
||||
/* vol1 有续读记录(第 5 页),vol2 没有 */
|
||||
/* vol1 有续读记录(第 5 页)——书库行不应出现角标。 */
|
||||
mr_cont_save(vol1, 4, MR_MODE_WEBTOON);
|
||||
|
||||
mr_library lib;
|
||||
memset(&lib, 0, sizeof lib);
|
||||
int n = mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH);
|
||||
CHECK(n > 0, "library scan n=%d", n);
|
||||
if (n > 0)
|
||||
CHECK(lib.items[0].kind == MR_LIB_KIND_DIR, "dirs first: %s", lib.items[0].name);
|
||||
|
||||
/* 第一段:扫描期不判定,占位副标题。 */
|
||||
int seen1 = 0, seen2 = 0;
|
||||
int seen1 = 0, seen_img = 0;
|
||||
for (int i = 0; i < lib.count; i++) {
|
||||
mr_library_entry *e = &lib.items[i];
|
||||
const mr_library_entry *e = &lib.items[i];
|
||||
CHECK(e->subtitle[0] && strcmp(e->subtitle, "…") != 0,
|
||||
"%s subtitle pre-generated: %s", e->name, e->subtitle);
|
||||
CHECK(strstr(e->subtitle, "续读") == NULL, "%s has no badge: %s",
|
||||
e->name, e->subtitle);
|
||||
if (strcmp(e->name, "vol1") == 0) {
|
||||
seen1 = 1;
|
||||
CHECK(e->probe_state == MR_LIB_PENDING && e->pages == -1,
|
||||
"vol1 pending after scan");
|
||||
CHECK(strcmp(e->subtitle, "…") == 0, "vol1 placeholder subtitle=%s", e->subtitle);
|
||||
CHECK(e->kind == MR_LIB_KIND_DIR, "vol1 is a dir row");
|
||||
}
|
||||
if (strcmp(e->name, "vol2") == 0) {
|
||||
seen2 = 1;
|
||||
CHECK(e->probe_state == MR_LIB_PENDING && strcmp(e->subtitle, "…") == 0,
|
||||
"vol2 placeholder subtitle=%s", e->subtitle);
|
||||
if (strcmp(e->name, "loose.png") == 0) {
|
||||
seen_img = 1;
|
||||
CHECK(e->kind == MR_LIB_KIND_IMAGE, "loose.png is an image row");
|
||||
CHECK(mr_library_image_index(&lib, i) == 0, "loose.png is image #0");
|
||||
}
|
||||
}
|
||||
CHECK(seen1 == 1, "vol1 listed");
|
||||
CHECK(seen2 == 1, "vol2 listed");
|
||||
|
||||
/* 第二段:后台探测补位后,页数与续读角标出现。 */
|
||||
probe_all_rows(&lib);
|
||||
seen1 = seen2 = 0;
|
||||
for (int i = 0; i < lib.count; i++) {
|
||||
mr_library_entry *e = &lib.items[i];
|
||||
if (strcmp(e->name, "vol1") == 0) {
|
||||
seen1 = 1;
|
||||
CHECK(e->has_images == 1 && e->pages == 1, "vol1 probed pages=%d", e->pages);
|
||||
CHECK(strstr(e->subtitle, "page") != NULL, "vol1 keeps pages text: %s", e->subtitle);
|
||||
CHECK(strstr(e->subtitle, "续读 P5") != NULL, "vol1 subtitle=%s", e->subtitle);
|
||||
}
|
||||
if (strcmp(e->name, "vol2") == 0) {
|
||||
seen2 = 1;
|
||||
CHECK(e->has_images == 1 && e->pages == 1, "vol2 probed pages=%d", e->pages);
|
||||
CHECK(strstr(e->subtitle, "续读") == NULL, "vol2 no badge: %s", e->subtitle);
|
||||
CHECK(strcmp(e->subtitle, "1 page") == 0, "vol2 subtitle=%s", e->subtitle);
|
||||
}
|
||||
}
|
||||
CHECK(seen1 == 1, "vol1 listed after probe");
|
||||
CHECK(seen2 == 1, "vol2 listed after probe");
|
||||
mr_library_free(&lib);
|
||||
|
||||
/* 记录清除后角标消失(重扫 + 再探测;探测缓存只存页数,角标按当前配置重算)。 */
|
||||
mr_cont_save(vol1, 0, MR_MODE_WEBTOON);
|
||||
memset(&lib, 0, sizeof lib);
|
||||
CHECK(mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH) > 0, "rescan");
|
||||
probe_all_rows(&lib);
|
||||
for (int i = 0; i < lib.count; i++)
|
||||
if (strcmp(lib.items[i].name, "vol1") == 0)
|
||||
CHECK(strstr(lib.items[i].subtitle, "续读") == NULL, "cleared badge gone");
|
||||
CHECK(seen_img == 1, "loose image listed");
|
||||
mr_library_free(&lib);
|
||||
}
|
||||
|
||||
@@ -280,7 +239,7 @@ int main(void)
|
||||
|
||||
test_has_images(root);
|
||||
test_adjacent(root);
|
||||
test_library_badge(root);
|
||||
test_library_rows(root);
|
||||
|
||||
rm_rf(root);
|
||||
printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail);
|
||||
|
||||
+84
-27
@@ -1,11 +1,11 @@
|
||||
/* test_library.c — 图库 logic 层宿主测试(纯 C,无 SDK/硬件)。
|
||||
*
|
||||
* 覆盖:
|
||||
* T1 自然排序:数字段数值序(page2<page10)、前导零、数字/字母混合、大小写;
|
||||
* T2 扩展名过滤:png/jpg/jpeg/bmp 大小写不敏感,gif/txt 不支持;
|
||||
* T3 目录扫描(M13 单层语义):只列当前层直接子目录(自然升序),不递归探测子树;
|
||||
* 系统目录/空目录/无图目录都列出;隐藏项/普通文件/符号链接不列;
|
||||
* max_depth 兼容参数被忽略;行初始为「待探测」占位;父目录路径推导;
|
||||
* 覆盖(M18 文件管理器模型):
|
||||
* T1 自然排序:数字段数值序(page2<page10)、前导零、数字/字母混合、大小写确定性;
|
||||
* T2 扩展名过滤:图片 png/jpg/jpeg/bmp 与档案册 cbz/zip 大小写不敏感;gif/txt 不支持;
|
||||
* T3 单层扫描:目录 + 受支持图片 + 档案册混排(目录优先、组内自然序);
|
||||
* 其它普通文件/隐藏项/符号链接不列;副标题扫描期生成一次(无占位/无懒判定);
|
||||
* 图片行下标(mr_library_image_index)与行类型;不可读路径 -> -1;max_depth 忽略;
|
||||
* T4 阅读方向状态(logic/reader.c)。
|
||||
*
|
||||
* 编译(在 tuku/ 下):
|
||||
@@ -80,6 +80,13 @@ static const mr_library_entry *find_entry(const mr_library *lib, const char *nam
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int find_index(const mr_library *lib, const char *name)
|
||||
{
|
||||
for (int i = 0; i < lib->count; i++)
|
||||
if (strcmp(lib->items[i].name, name) == 0) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
static void test_natural(void)
|
||||
{
|
||||
CHECK(mr_natural_cmp("page2", "page10") < 0, "page2 < page10");
|
||||
@@ -91,6 +98,7 @@ static void test_natural(void)
|
||||
CHECK(mr_natural_cmp("a", "a1") < 0, "prefix shorter first");
|
||||
CHECK(mr_natural_cmp("abc", "abc") == 0, "identical equal");
|
||||
CHECK(mr_natural_cmp("ABC", "abc") != 0, "case-only tie is deterministic");
|
||||
CHECK(mr_natural_cmp("a.jpg", "A.jpg") > 0, "case tie falls back to raw bytes");
|
||||
CHECK(mr_natural_cmp("A2", "a10") < 0, "case-insensitive primary then numeric");
|
||||
}
|
||||
|
||||
@@ -104,6 +112,10 @@ static void test_ext(void)
|
||||
CHECK(mr_path_is_image("a.txt") == 0, "txt unsupported");
|
||||
CHECK(mr_path_is_image("png") == 0, "no extension");
|
||||
CHECK(mr_path_is_image(".png") == 0, "dotfile-ish no stem");
|
||||
CHECK(mr_path_is_archive("a.cbz") == 1, "cbz");
|
||||
CHECK(mr_path_is_archive("a.ZIP") == 1, "ZIP uppercase");
|
||||
CHECK(mr_path_is_archive("a.7z") == 0, "7z unsupported");
|
||||
CHECK(mr_path_is_archive("cbz") == 0, "no extension archive");
|
||||
}
|
||||
|
||||
static void test_parent(void)
|
||||
@@ -150,54 +162,99 @@ static void test_scan(void)
|
||||
snprintf(p, sizeof p, "%s/MeowAPPs", root); mkdir_p(p);
|
||||
snprintf(p, sizeof p, "%s/MeowAPPs/m.png", root); touch_file(p);
|
||||
|
||||
snprintf(p, sizeof p, "%s/root.png", root); touch_file(p); /* 普通文件不成为行 */
|
||||
/* 当前层文件:图片与档案册都成行;txt/隐藏文件不成行。 */
|
||||
snprintf(p, sizeof p, "%s/root.png", root); touch_file(p);
|
||||
snprintf(p, sizeof p, "%s/cover.JPG", root); touch_file(p);
|
||||
snprintf(p, sizeof p, "%s/book.cbz", root); touch_file(p);
|
||||
snprintf(p, sizeof p, "%s/bundle.zip", root);touch_file(p);
|
||||
snprintf(p, sizeof p, "%s/notes.txt", root); touch_file(p);
|
||||
snprintf(p, sizeof p, "%s/.hidden.png", root); touch_file(p);
|
||||
|
||||
char link_path[MR_PATH_MAX], link_target[MR_PATH_MAX];
|
||||
snprintf(link_target, sizeof link_target, "%s/root.png", root);
|
||||
snprintf(link_path, sizeof link_path, "%s/link.png", root);
|
||||
symlink(link_target, link_path); /* 有权限才建;不列 */
|
||||
|
||||
mr_library lib;
|
||||
memset(&lib, 0, sizeof lib);
|
||||
int n = mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH);
|
||||
CHECK(n == 7, "scan count=%d (want 7: all direct subdirs)", n);
|
||||
CHECK(n == 11, "scan count=%d (want 11: 7 dirs + 4 files)", n);
|
||||
CHECK(lib.count == n, "count matches");
|
||||
CHECK(lib.truncated == 0, "not truncated");
|
||||
if (lib.count == 7) {
|
||||
static const char *want[7] = {
|
||||
"Empty", "MeowAPPs", "Nested", "Series 2", "Series 02", "Series 3", "Series 10"
|
||||
if (lib.count == 11) {
|
||||
static const char *want[11] = {
|
||||
"Empty", "MeowAPPs", "Nested", "Series 2", "Series 02", "Series 3", "Series 10",
|
||||
"book.cbz", "bundle.zip", "cover.JPG", "root.png"
|
||||
};
|
||||
for (int i = 0; i < 7; i++)
|
||||
for (int i = 0; i < 11; i++)
|
||||
CHECK(strcmp(lib.items[i].name, want[i]) == 0,
|
||||
"sort[%d]=%s got %s", i, want[i], lib.items[i].name);
|
||||
for (int i = 0; i < 7; i++)
|
||||
CHECK(lib.items[i].kind == MR_LIB_KIND_DIR, "dirs first: %s", lib.items[i].name);
|
||||
}
|
||||
|
||||
/* 访问放开:系统目录/空目录/无图目录都列出;隐藏项/文件不列。 */
|
||||
CHECK(find_entry(&lib, "MeowAPPs") != NULL, "system dir listed");
|
||||
CHECK(find_entry(&lib, "Empty") != NULL, "empty dir listed");
|
||||
CHECK(find_entry(&lib, "Series 3") != NULL, "no-image dir listed");
|
||||
CHECK(find_entry(&lib, "Nested") != NULL, "intermediate dir listed");
|
||||
/* 行类型与副标题(扫描期生成一次,不是占位)。 */
|
||||
const mr_library_entry *e = find_entry(&lib, "Empty");
|
||||
CHECK(e && e->kind == MR_LIB_KIND_DIR && strcmp(e->subtitle, "文件夹") == 0,
|
||||
"dir subtitle=%s", e ? e->subtitle : "");
|
||||
e = find_entry(&lib, "root.png");
|
||||
CHECK(e && e->kind == MR_LIB_KIND_IMAGE && e->size == 1 &&
|
||||
strncmp(e->subtitle, "图片 · ", strlen("图片 · ")) == 0,
|
||||
"image subtitle=%s size=%lld", e ? e->subtitle : "", e ? e->size : -1);
|
||||
e = find_entry(&lib, "book.cbz");
|
||||
CHECK(e && e->kind == MR_LIB_KIND_ARCHIVE &&
|
||||
strncmp(e->subtitle, "档案册 · ", strlen("档案册 · ")) == 0,
|
||||
"archive subtitle=%s", e ? e->subtitle : "");
|
||||
|
||||
/* 排除项:普通文件/隐藏项/隐藏目录/符号链接。 */
|
||||
CHECK(find_entry(&lib, "notes.txt") == NULL, "plain text file excluded");
|
||||
CHECK(find_entry(&lib, ".hidden") == NULL, "hidden dir excluded");
|
||||
CHECK(find_entry(&lib, "root.png") == NULL, "loose image file excluded");
|
||||
CHECK(find_entry(&lib, ".hidden.png") == NULL, "hidden file excluded");
|
||||
CHECK(find_entry(&lib, "link.png") == NULL, "symlink excluded");
|
||||
CHECK(find_entry(&lib, "a.png") == NULL, "no recursion into subdirs");
|
||||
CHECK(find_entry(&lib, "Deeper") == NULL, "no recursion into subdirs (deeper)");
|
||||
|
||||
/* 首帧不做懒判定:所有行待探测(副标题占位)。 */
|
||||
for (int i = 0; i < lib.count; i++) {
|
||||
CHECK(lib.items[i].pages == -1, "%s pages pending", lib.items[i].name);
|
||||
CHECK(lib.items[i].has_images == -1, "%s book state pending", lib.items[i].name);
|
||||
CHECK(lib.items[i].probe_state == MR_LIB_PENDING, "%s probe pending", lib.items[i].name);
|
||||
CHECK(strcmp(lib.items[i].subtitle, "…") == 0, "%s subtitle placeholder", lib.items[i].name);
|
||||
}
|
||||
/* 图片下标 = 本层图片自然序(书库点图 → 阅读器起始页的契约)。 */
|
||||
int i_cover = find_index(&lib, "cover.JPG");
|
||||
int i_root = find_index(&lib, "root.png");
|
||||
CHECK(mr_library_image_index(&lib, i_cover) == 0, "cover.JPG is image #0");
|
||||
CHECK(mr_library_image_index(&lib, i_root) == 1, "root.png is image #1");
|
||||
CHECK(mr_library_image_index(&lib, find_index(&lib, "book.cbz")) == -1,
|
||||
"archive has no image index");
|
||||
CHECK(mr_library_image_index(&lib, find_index(&lib, "Empty")) == -1,
|
||||
"dir has no image index");
|
||||
CHECK(mr_library_image_index(&lib, -1) == -1 && mr_library_image_index(&lib, 99) == -1,
|
||||
"out-of-range image index");
|
||||
|
||||
/* 行合成:纯内存,返回扫描期生成的字符串。 */
|
||||
mr_library_row row;
|
||||
CHECK(mr_library_row_at(&lib, 0, &row) == 1 && row.index == 0 &&
|
||||
row.kind == MR_LIB_KIND_DIR && row.title && row.subtitle &&
|
||||
strcmp(row.subtitle, "文件夹") == 0, "row 0 = first dir");
|
||||
CHECK(mr_library_row_at(&lib, lib.count, &row) == 0, "no extra row when not truncated");
|
||||
CHECK(mr_library_row_at(&lib, -1, &row) == 0, "row -1 invalid");
|
||||
|
||||
/* max_depth 参数只为兼容旧调用保留:不再递归,结果一致。 */
|
||||
mr_library lib1;
|
||||
memset(&lib1, 0, sizeof lib1);
|
||||
CHECK(mr_library_scan(&lib1, root, 1) == 7, "max_depth ignored (single layer)");
|
||||
CHECK(mr_library_scan(&lib1, root, 1) == 11, "max_depth ignored (single layer)");
|
||||
mr_library_free(&lib1);
|
||||
|
||||
mr_library_free(&lib);
|
||||
|
||||
/* 不可读目录:返回 -1,lib 可安全 free。 */
|
||||
/* 不可读目录 / 不是目录:返回 -1,lib 可安全 free。 */
|
||||
mr_library bad;
|
||||
memset(&bad, 0, sizeof bad);
|
||||
CHECK(mr_library_scan(&bad, "/no/such/dir/tuku", MR_LIB_MAX_DEPTH) == -1, "missing dir -> -1");
|
||||
CHECK(bad.count == 0, "missing dir empty");
|
||||
mr_library_free(&bad);
|
||||
|
||||
snprintf(p, sizeof p, "%s/root.png", root);
|
||||
memset(&bad, 0, sizeof bad);
|
||||
CHECK(mr_library_scan(&bad, p, MR_LIB_MAX_DEPTH) == -1, "file (not dir) -> -1");
|
||||
CHECK(bad.count == 0, "file scan empty");
|
||||
mr_library_free(&bad);
|
||||
|
||||
rm_rf(root);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
/* test_open_book_host.c — 书库点击 → 阅读器打开的宿主集成回归(需 SDK 源码树)。
|
||||
*
|
||||
* 复现用户报告:「父文件夹有封面图片、子文件夹也有图片,点开父文件夹报无法打开此册」。
|
||||
* 用合成夹具(真实 BMP 当封面/子图,不读用户相册)走完整链路:
|
||||
* library 列表 → 行点击 → mr_reader_open_dir → reader on_enter → mr_book_open_auto
|
||||
* 并断言父目录以「书」打开(count=1)且没有进入 open_err。
|
||||
* M18 文件管理器模型:点目录=进入;点图片=从该图开始读(mr_reader_open_dir_at(目录, 下标));
|
||||
* 点档案册=打开档案册。本测试用合成夹具(真实 BMP,不读用户相册)走完整链路:
|
||||
* library 列表 → 点图片行 → mr_reader_open_dir_at → reader on_enter → mr_book_open_auto
|
||||
* 并断言「点哪张就开哪张」:阅读器当前页 == 该书库行在目录图片自然序里的下标。
|
||||
*
|
||||
* 另覆盖:封面图很大时打开仍成功(打开只枚举,不解码);同目录二次打开/离开重开。
|
||||
* 另覆盖:
|
||||
* - 目录行只进目录,不打开阅读器;
|
||||
* - 阅读页已在本页时再次 mr_reader_open_dir_at 能换册/换页重进(SDK 忽略重复 push 的坑);
|
||||
* - 离开阅读器回书库后再次点图(同目录二次打开)。
|
||||
*
|
||||
* 编译:见 test/run_tests.sh(与 test_mode_switch_host 同口径)。
|
||||
*/
|
||||
@@ -25,7 +28,6 @@ void library_test_set_root(const char *root);
|
||||
const char *library_test_dir(void);
|
||||
int library_test_rows(void);
|
||||
int library_test_src(int idx, meow_list_item *item);
|
||||
int library_test_worker_on(void);
|
||||
|
||||
/* ui/reader/reader.c 的测试接缝(-DMR_READER_HOST_TEST) */
|
||||
int mr_reader_ui_mode(void);
|
||||
@@ -116,22 +118,6 @@ static int rm_rf(const char *path)
|
||||
return rmdir(path);
|
||||
}
|
||||
|
||||
static void wait_ready(int ms)
|
||||
{
|
||||
for (int t = 0; t < ms / 10; t++) {
|
||||
library_on_tick();
|
||||
int ready = 1;
|
||||
int rows = library_test_rows();
|
||||
for (int i = 0; i < rows; i++) {
|
||||
meow_list_item it;
|
||||
if (!library_test_src(i, &it)) continue;
|
||||
if (it.subtitle && strcmp(it.subtitle, "…") == 0) { ready = 0; break; }
|
||||
}
|
||||
if (ready) return;
|
||||
usleep(10000);
|
||||
}
|
||||
}
|
||||
|
||||
static int row_index_for(const char *name)
|
||||
{
|
||||
int rows = library_test_rows();
|
||||
@@ -147,10 +133,25 @@ static int row_index_for(const char *name)
|
||||
static void tap_row(const char *name)
|
||||
{
|
||||
int idx = row_index_for(name);
|
||||
if (idx < 0) { CHECK(0, "row %s not found", name); return; }
|
||||
int list_top = meow_ui_statusbar_height() + 56;
|
||||
library_on_tap(120, list_top + idx * 56 + 28);
|
||||
}
|
||||
|
||||
/* 某张图片行在本目录图片自然序里的下标 = 排在前面的「图片 · 」行数。 */
|
||||
static int expected_image_index(const char *name)
|
||||
{
|
||||
int ti = row_index_for(name);
|
||||
if (ti < 0) return -1;
|
||||
int n = 0;
|
||||
for (int i = 0; i < ti; i++) {
|
||||
meow_list_item it;
|
||||
if (!library_test_src(i, &it)) continue;
|
||||
if (it.subtitle && strncmp(it.subtitle, "图片 · ", strlen("图片 · ")) == 0) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
const char *base = getenv("TMPDIR");
|
||||
@@ -161,25 +162,27 @@ int main(void)
|
||||
if (!data) { printf("SKIP: mkdtemp failed\n"); return 0; }
|
||||
setenv("MEOW_DATA_DIR", data, 1);
|
||||
|
||||
char root[MR_PATH_MAX], parent[MR_PATH_MAX], sub[MR_PATH_MAX], p[MR_PATH_MAX];
|
||||
char root[MR_PATH_MAX], pics[MR_PATH_MAX], other[MR_PATH_MAX], p[MR_PATH_MAX];
|
||||
snprintf(root, sizeof root, "%s/library", data);
|
||||
snprintf(parent, sizeof parent, "%s/Pictures", root);
|
||||
if (mkdir_p(parent) != 0) { printf("SKIP: mkdir failed\n"); return 0; }
|
||||
snprintf(pics, sizeof pics, "%s/Pictures", root);
|
||||
snprintf(other, sizeof other, "%s/Other", root);
|
||||
if (mkdir_p(pics) != 0 || mkdir_p(other) != 0) { printf("SKIP: mkdir failed\n"); return 0; }
|
||||
|
||||
/* 父目录:一张封面图 + 两个含图子目录(用户报告的结构)。 */
|
||||
snprintf(p, sizeof p, "%s/cover.jpg", parent); /* 扩展名即可;打开只枚举不解码 */
|
||||
FILE *f = fopen(p, "wb");
|
||||
if (!f) { printf("SKIP: cover write failed\n"); return 0; }
|
||||
fputs("x", f);
|
||||
fclose(f);
|
||||
snprintf(sub, sizeof sub, "%s/2023", parent);
|
||||
mkdir_p(sub);
|
||||
snprintf(p, sizeof p, "%s/a.bmp", sub);
|
||||
write_bmp24(p, 40, 60);
|
||||
snprintf(sub, sizeof sub, "%s/2024", parent);
|
||||
mkdir_p(sub);
|
||||
snprintf(p, sizeof p, "%s/b.bmp", sub);
|
||||
write_bmp24(p, 40, 60);
|
||||
/* Pictures:6 张图(数字序号/大小写/前导零混排)+ 1 个普通文件(不成行)。 */
|
||||
const char *imgs[] = { "A.bmp", "a.bmp", "img2.bmp", "img10.bmp",
|
||||
"page3.bmp", "Page03.bmp" };
|
||||
for (unsigned i = 0; i < sizeof imgs / sizeof imgs[0]; i++) {
|
||||
snprintf(p, sizeof p, "%s/%s", pics, imgs[i]);
|
||||
if (write_bmp24(p, 40, 60) != 0) { printf("SKIP: image write failed\n"); return 0; }
|
||||
}
|
||||
snprintf(p, sizeof p, "%s/notes.txt", pics);
|
||||
{ FILE *f = fopen(p, "wb"); if (f) { fputs("x", f); fclose(f); } }
|
||||
|
||||
/* Other:4 张图,用于「已在本页时换册重进」。 */
|
||||
for (int i = 0; i < 4; i++) {
|
||||
snprintf(p, sizeof p, "%s/o%d.bmp", other, i + 1);
|
||||
if (write_bmp24(p, 40, 60) != 0) { printf("SKIP: image write failed\n"); return 0; }
|
||||
}
|
||||
|
||||
meow_ui_opts o = { 480, 720 };
|
||||
if (meow_ui_init(&o) != 0) { printf("SKIP: meow_ui_init failed (no host backend)\n"); return 0; }
|
||||
@@ -190,46 +193,52 @@ int main(void)
|
||||
meow_view_page_push(ACT_LIBRARY);
|
||||
|
||||
CHECK(library_test_load(root) == 1, "load fixture root");
|
||||
wait_ready(3000);
|
||||
|
||||
int ip = row_index_for("Pictures");
|
||||
meow_list_item it;
|
||||
CHECK(ip >= 0 && library_test_src(ip, &it), "Pictures row present");
|
||||
CHECK(it.subtitle && strcmp(it.subtitle, "1 page") == 0,
|
||||
"Pictures probed as book with direct cover (subtitle=%s)",
|
||||
it.subtitle ? it.subtitle : "");
|
||||
|
||||
/* 点击父目录 → 进阅读器;cover 是唯一直接子图 → count=1,不能是 open_err。 */
|
||||
/* 点目录 = 进入目录,不打开阅读器。 */
|
||||
tap_row("Pictures");
|
||||
CHECK(meow_ui_activity_active() == ACT_READER, "tap opens reader");
|
||||
CHECK(strcmp(mr_reader_dir(), parent) == 0, "reader dir=%s", mr_reader_dir());
|
||||
CHECK(mr_reader_ui_open_err() == 0, "parent folder opens without open_err");
|
||||
CHECK(mr_reader_ui_count() == 1, "parent book pages=%d (want 1)",
|
||||
mr_reader_ui_count());
|
||||
CHECK(library_test_worker_on() == 0,
|
||||
"library background decode stopped while reader is active");
|
||||
for (int i = 0; i < 30; i++) { usleep(10 * 1000); }
|
||||
CHECK(meow_ui_activity_active() == ACT_LIBRARY, "tap dir stays in library");
|
||||
CHECK(strcmp(library_test_dir(), pics) == 0, "entered Pictures (%s)", library_test_dir());
|
||||
CHECK(library_test_rows() == 6, "Pictures rows=%d (6 images, txt excluded)",
|
||||
library_test_rows());
|
||||
|
||||
/* 点 img10.bmp:阅读器从该图开始(count=6, page=它在该目录的自然序下标)。 */
|
||||
int want = expected_image_index("img10.bmp");
|
||||
CHECK(want == 3, "img10.bmp natural image index=%d (want 3)", want);
|
||||
tap_row("img10.bmp");
|
||||
CHECK(meow_ui_activity_active() == ACT_READER, "tap image opens reader");
|
||||
CHECK(strcmp(mr_reader_dir(), pics) == 0, "reader dir=%s", mr_reader_dir());
|
||||
CHECK(mr_reader_ui_open_err() == 0, "open has no error");
|
||||
CHECK(mr_reader_ui_count() == 6, "reader pages=%d (want 6)", mr_reader_ui_count());
|
||||
CHECK(mr_reader_ui_page() == want, "reader starts at selected image: page=%d (want %d)",
|
||||
mr_reader_ui_page(), want);
|
||||
|
||||
/* 让首帧解码 tick 跑一遍,确认不会因坏夹具进入 open_err。 */
|
||||
for (int i = 0; i < 20; i++) usleep(10 * 1000);
|
||||
CHECK(mr_reader_ui_open_err() == 0, "still no open_err after decode tick");
|
||||
|
||||
/* 返回书库 → 后台线程恢复,再次点开(同目录二次打开 + 续读恢复)。 */
|
||||
/* 已在本页:直接换册重进(M17 遗留修复,SDK 会忽略重复 push)。 */
|
||||
mr_reader_open_dir_at(other, 2);
|
||||
CHECK(meow_ui_activity_active() == ACT_READER, "switch keeps reader active");
|
||||
CHECK(strcmp(mr_reader_dir(), other) == 0, "switched reader dir=%s", mr_reader_dir());
|
||||
CHECK(mr_reader_ui_count() == 4, "switched book pages=%d (want 4)", mr_reader_ui_count());
|
||||
CHECK(mr_reader_ui_page() == 2, "switched book page=%d (want 2)", mr_reader_ui_page());
|
||||
|
||||
/* 返回书库 → 再点另一张图(同目录二次打开 + 显式起始页优先于续读)。 */
|
||||
meow_view_page_pop();
|
||||
CHECK(meow_ui_activity_active() == ACT_LIBRARY, "back to library");
|
||||
CHECK(library_test_worker_on() == 1, "library background decode restarted on return");
|
||||
tap_row("Pictures");
|
||||
CHECK(mr_reader_ui_open_err() == 0, "reopen parent without open_err");
|
||||
CHECK(mr_reader_ui_count() == 1, "reopen pages=%d", mr_reader_ui_count());
|
||||
CHECK(strcmp(library_test_dir(), pics) == 0, "library still in Pictures");
|
||||
|
||||
tap_row("page3.bmp");
|
||||
CHECK(meow_ui_activity_active() == ACT_READER, "reopen reader");
|
||||
CHECK(mr_reader_ui_open_err() == 0, "reopen no error");
|
||||
CHECK(mr_reader_ui_count() == 6, "reopen pages=%d", mr_reader_ui_count());
|
||||
int want2 = expected_image_index("page3.bmp");
|
||||
CHECK(want2 == 4, "page3.bmp natural image index=%d (want 4)", want2);
|
||||
CHECK(mr_reader_ui_page() == want2, "reopen starts at page=%d (want %d)",
|
||||
mr_reader_ui_page(), want2);
|
||||
meow_view_page_pop();
|
||||
|
||||
/* 封面是大图也不影响打开(打开只枚举文件名)。 */
|
||||
snprintf(p, sizeof p, "%s/cover.bmp", parent);
|
||||
CHECK(write_bmp24(p, 400, 600) == 0, "write large cover");
|
||||
CHECK(library_test_load(root) == 1, "reload after cover change");
|
||||
wait_ready(3000);
|
||||
tap_row("Pictures");
|
||||
CHECK(mr_reader_ui_open_err() == 0, "open with large cover has no open_err");
|
||||
CHECK(mr_reader_ui_count() == 2, "cover.jpg + cover.bmp pages=%d (want 2)",
|
||||
mr_reader_ui_count());
|
||||
meow_view_page_pop();
|
||||
/* 档案册路径(无 zip 夹具时只验证入口不被当成目录):点 txt 不存在行;跳过。 */
|
||||
|
||||
meow_view_free(reader);
|
||||
meow_view_free(lib);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* 页面:ACT_LIBRARY(0) 根页(保留状态栏)、ACT_READER(1) 全屏、ACT_SETTINGS(2)、ACT_LOCK(3)。
|
||||
* 并行开发接缝(后续任务只填自己的文件,不改 app.c/internal.h):
|
||||
* - M4 阅读器:ui/reader/reader.c 实现,方向状态用 logic/reader 的 mr_reader_get/set_direction;
|
||||
* 书库点入含图目录经 mr_reader_open_dir(path) 进入本页。
|
||||
* 书库列表点图片/档案册经 mr_reader_open_dir_at()(ui/reader/reader.h)进入本页。
|
||||
* - M5 锁屏/PIN:ui/lock/lock.c 实现 lock_view_should_show();本批桩返回 0(不锁)。
|
||||
*/
|
||||
#ifndef TUKU_INTERNAL_H
|
||||
@@ -27,7 +27,7 @@
|
||||
#define ACT_SETTINGS 2
|
||||
#define ACT_LOCK 3
|
||||
|
||||
#define MR_ROOT_DIR "/usr/data/mnt/sd_0" /* 书库根 = TF 卡根 */
|
||||
#define MR_ROOT_DIR "/usr/data/mnt/sd_0" /* 书库浏览根 = TF 卡根(不允许上溯到其上级) */
|
||||
|
||||
/* ---- 视图控制器(biz-UI 绑定点) ---- */
|
||||
typedef struct mr_page_ctrl {
|
||||
@@ -35,16 +35,15 @@ typedef struct mr_page_ctrl {
|
||||
int active;
|
||||
} mr_page_ctrl;
|
||||
|
||||
/* ---- 书库页(ui/library/library.c) ---- */
|
||||
/* ---- 书库页(ui/library/library.c,文件管理器模型) ---- */
|
||||
meow_view *library_view_create(void);
|
||||
int library_at_root(void); /* 1=当前在书库根(生产=MR_ROOT_DIR,宿主测试可改根) */
|
||||
int library_go_up(void); /* 系统返回语义:逐级上移;TF 根/"/" 返回 0(app.c 据此退出) */
|
||||
int library_go_parent(void); /* 顶栏显式「上级」:访问放开,可越过 TF 根到 '/' */
|
||||
int library_at_root(void); /* 1=当前在浏览根(生产=MR_ROOT_DIR,宿主测试可改根) */
|
||||
int library_go_up(void); /* 系统返回语义:逐级上移;浏览根返回 0(app.c 据此退出) */
|
||||
void library_on_tap(int x, int y);
|
||||
void library_on_swipe(int dir, int dist); /* dist=SDK on_swipe 的位移量(UP/DOWN 用) */
|
||||
void library_on_touch_down(int x, int y);
|
||||
void library_on_touch_move(int x, int y);
|
||||
void library_on_tick(void); /* 收下后台缩略图结果并局部补位 */
|
||||
void library_on_tick(void); /* 16ms tick:提示横幅到期清除 */
|
||||
|
||||
/* ---- 阅读器页(ui/reader/reader.c,M4 填充) ---- */
|
||||
meow_view *reader_view_create(void);
|
||||
|
||||
+155
-659
File diff suppressed because it is too large.
Load diff
@@ -2040,6 +2040,7 @@ static void reader_state_reset(void)
|
||||
static void reader_page_enter(meow_view *v, void *ctx)
|
||||
{
|
||||
mr_page_ctrl *c = (mr_page_ctrl *)ctx;
|
||||
c->view = v;
|
||||
c->active = 1;
|
||||
g_quitting = 0;
|
||||
|
||||
@@ -2162,6 +2163,14 @@ const char *mr_reader_dir(void)
|
||||
void mr_reader_open_dir_at(const char *path, int start_index)
|
||||
{
|
||||
if (!path) return;
|
||||
/* 已在本页(书库再次点另一张图/另一本册时可能仍是 ACT_READER):
|
||||
* SDK 对同一 view 的重复 push 是 no-op(view_enter 见 ENTERED 直接返回),
|
||||
* 只改 g_dir 会串到旧册状态。先真正 pop(回收当前册 + 续读落盘),
|
||||
* 换目标后再 push,走完整的 leave/enter 生命周期。 */
|
||||
int reopen = (meow_ui_activity_active() == ACT_READER) &&
|
||||
(meow_ui_activity_depth() > 1);
|
||||
if (reopen) meow_view_page_pop();
|
||||
|
||||
snprintf(g_dir, sizeof g_dir, "%s", path);
|
||||
/* -1=续读(旧入口语义);其它负数钳到 0;上界在 open_book 里按页数钳。 */
|
||||
g_start_at = start_index == MR_READER_START_RESUME ? MR_READER_START_RESUME
|
||||
|
||||
Reference in new issue
Block a user