mirror of
https://github.com/wth461694678/text-block-timer.git
synced 2026-07-22 05:46:04 +00:00
feat: modularize E2E tests into chain files, write delete-timer-enhancement PRD, update SKILL.md, add core-timer docs
This commit is contained in:
parent
b4973bd000
commit
f0fe81af0a
26 changed files with 4992 additions and 2741 deletions
|
|
@ -23,7 +23,7 @@ text-block-timer-dev-skill/
|
|||
| **技术栈** | TypeScript 5、esbuild、CodeMirror 6、Obsidian API、ECharts 5、IndexedDB |
|
||||
| **数据层** | 三层同步:Markdown HTML span ↔ timer-db.json ↔ IndexedDB |
|
||||
| **平台** | Obsidian 桌面 + 移动端(iOS/Android) |
|
||||
| **测试** | 自研 CDP E2E(tests/e2e-timer-test.mjs) |
|
||||
| **测试** | 自研 CDP E2E(tests/e2e-timer-test.mjs + tests/chains/*.mjs,模块化测试链架构) |
|
||||
| **国际化** | 5 种语言(en/zh/zhTW/ja/ko) |
|
||||
|
||||
### 通用约束
|
||||
|
|
@ -47,7 +47,7 @@ text-block-timer-dev-skill/
|
|||
| 技术栈 | TypeScript 5、esbuild、CodeMirror 6、Obsidian API、ECharts 5、IndexedDB |
|
||||
| 入口文件 | `src/main.ts` |
|
||||
| 构建命令 | `npm run dev`(watch)/ `npm run build`(production) |
|
||||
| 测试框架 | 自研 CDP E2E(`tests/e2e-timer-test.mjs`,基于 Chrome DevTools Protocol) |
|
||||
| 测试框架 | 自研 CDP E2E(主入口 `tests/e2e-timer-test.mjs` + 链模块 `tests/chains/*.mjs`,基于 Chrome DevTools Protocol,自动启动 Obsidian) |
|
||||
| 数据存储 | Markdown 行内 HTML span + `timer-db.json`(JSON)+ IndexedDB(`TimerPluginDB`) |
|
||||
|
||||
### 源码目录结构
|
||||
|
|
@ -672,7 +672,8 @@ M1 阶段名称
|
|||
| 工具 | 用途 |
|
||||
| ---------- | ----------------------------------------------------- |
|
||||
| read_file | 读取 PRD 验收标准 + 技术方案 |
|
||||
| read_file | 读取 `tests/e2e-timer-test.mjs` 了解现有测试模式 |
|
||||
| read_file | 读取 `tests/e2e-timer-test.mjs`(主入口)了解测试框架基础设施 |
|
||||
| read_file | 读取 `tests/chains/*.mjs` 了解现有测试链模式 |
|
||||
| edit_file | 将测试用例写入 `doc/{{feature_name}}/stage7-test-cases.md` |
|
||||
|
||||
### Steps
|
||||
|
|
@ -775,11 +776,40 @@ await this.idb.upsertTimer(idbRecord);
|
|||
|
||||
**E2E 测试脚本规范**:
|
||||
```
|
||||
- 测试文件: tests/e2e-timer-test.mjs
|
||||
- 以 chain(测试链)组织,每个 chain 是一组必须连续执行的测试
|
||||
- 新增 chain 必须在 CHAIN_REGISTRY 注册并加入 ALL_CHAINS
|
||||
- 主入口文件: tests/e2e-timer-test.mjs(CDP 连接、TestRunner、IDB 辅助函数、链注册/调度、Obsidian 自动启动)
|
||||
- 链模块目录: tests/chains/<chain_name>.mjs(每个测试链独立一个文件)
|
||||
- 每个链文件从主入口 import 共享的 sleep/assert/log/常量/IDB 辅助函数/shared 状态
|
||||
- 每个链文件 export 一个 async function chain_xxx(runner) 函数
|
||||
- 新增 chain 步骤:
|
||||
1. 创建 tests/chains/<chain_name>.mjs 文件
|
||||
2. import 所需的共享函数和常量
|
||||
3. export async function chain_<name>(runner) { ... }
|
||||
4. 在主入口的 CHAIN_REGISTRY 注册新链
|
||||
5. 在主入口顶部添加 import 语句
|
||||
- 每个 chain 结束必须清理测试数据
|
||||
- Date monkey-patch(跨天测试)参考现有 chain_crossday
|
||||
- Date monkey-patch(跨天测试)参考现有 chains/crossday.mjs
|
||||
- 所有计时器操作必须使用 toggle-timer 命令,禁止手动 startTimer/stopTimer
|
||||
- 主入口自动检测 Obsidian 是否运行,未运行则通过 Start-Process 启动并等待 CDP 就绪
|
||||
```
|
||||
|
||||
**可用测试链清单**:
|
||||
```
|
||||
- preflight: 插件加载、编辑器、IDB 就绪检查
|
||||
- basic: CRUD 生命周期、侧边栏列表/摘要、IDB 一致性
|
||||
- adjust: 手动设置时长 + IDB adjustDailyDur
|
||||
- seed: seedIndexedDB / clearAll 清除过期数据
|
||||
- delete: 删除计时器、侧边栏移除、最终一致性
|
||||
- crossday: 跨午夜计时器(Date monkey-patch)
|
||||
- crossday_adjust: 多天计时器 toggle start/pause + 手动增减分天分配
|
||||
- sidebar_tabs: 作用域切换、筛选/排序、摘要、图表数据、统计开关
|
||||
- readonly: 预览/阅读模式下计时器继续 tick
|
||||
- passive_delete: 被动删除(编辑器中删除 span)的数据清理
|
||||
- checkbox: 复选框勾选触发暂停/恢复
|
||||
- crash_recovery: 崩溃恢复(running 状态持久化 + 恢复)
|
||||
- settings_behavior: 设置行为(timerTextPosition 等)
|
||||
- restore_behavior: autoStopTimers 恢复行为(quit/close/never)
|
||||
- onunload_behavior: onunload 刷新 running timer 数据到 JSON
|
||||
- cleanup_basic: basic/adjust/seed/delete 链的共享清理
|
||||
```
|
||||
|
||||
**测试操作方式规范(强制)**:
|
||||
|
|
@ -831,22 +861,21 @@ await this.idb.upsertTimer(idbRecord);
|
|||
|
||||
### Obsidian 调试模式启动
|
||||
|
||||
> 执行 E2E 测试前,**必须**先确保 Obsidian 以远程调试模式启动:
|
||||
> 测试框架会**自动检测**并启动 Obsidian 调试模式。无需手动启动。
|
||||
> 主入口文件 `tests/e2e-timer-test.mjs` 的 `main()` 函数会:
|
||||
> 1. 检查 CDP 端口(127.0.0.1:9222)是否已有 Obsidian 在运行
|
||||
> 2. 如未运行,通过 `Start-Process` 自动启动 Obsidian 并附带 `--remote-debugging-port=9222`
|
||||
> 3. 轮询等待最多 60 秒直到 CDP 就绪
|
||||
>
|
||||
> 如果需要手动启动:
|
||||
> ```
|
||||
> "C:\Users\frankthwang\AppData\Local\Programs\Obsidian\Obsidian.exe" --remote-debugging-port=9222
|
||||
> Start-Process "C:\Users\frankthwang\AppData\Local\Programs\Obsidian\Obsidian.exe" -ArgumentList "--remote-debugging-port=9222"
|
||||
> ```
|
||||
>
|
||||
> 测试脚本通过 CDP(Chrome DevTools Protocol)连接到此端口进行自动化操作。
|
||||
> 如果 Obsidian 未以调试模式运行,测试将无法连接。
|
||||
|
||||
### Steps
|
||||
|
||||
```
|
||||
0. LAUNCH: 通过 terminal 启动 Obsidian 调试模式(如果尚未启动):
|
||||
"C:\Users\frankthwang\AppData\Local\Programs\Obsidian\Obsidian.exe" --remote-debugging-port=9222
|
||||
等待 Obsidian 完全加载后再继续。
|
||||
1. BUILD: 调用 terminal 执行 npm run build
|
||||
1. BUILD: 调用 terminal 执行 npm run build(Obsidian 自动启动由测试框架处理)
|
||||
2. TEST: 调用 terminal 执行 node tests/e2e-timer-test.mjs [chain_name]
|
||||
3. ANALYZE: 分析测试结果
|
||||
IF all_passed:
|
||||
|
|
@ -906,4 +935,5 @@ await this.idb.upsertTimer(idbRecord);
|
|||
- `src/core/TimerIndexedDB.ts` — IndexedDB 数据库
|
||||
- `src/ui/TimerSidebarView.ts` — 侧边栏视图
|
||||
- `src/i18n/translations.ts` — 国际化
|
||||
- `tests/e2e-timer-test.mjs` — E2E 测试脚本
|
||||
- `tests/e2e-timer-test.mjs` — E2E 测试主入口(基础设施 + 链注册 + 调度器 + Obsidian 自动启动)
|
||||
- `tests/chains/*.mjs` — E2E 测试链模块(每个测试链独立文件,共 16 个)
|
||||
|
|
|
|||
351
doc/core-timer/stage1-PRD.md
Normal file
351
doc/core-timer/stage1-PRD.md
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
# 📋 PRD: Core Timer
|
||||
|
||||
**文档版本**: v1.0 | **创建日期**: 2026-03-02 | **状态**: 补充归档 | **优先级**: P0
|
||||
|
||||
> 本文档为已上线功能的补充归档 PRD,描述 Text Block Timer 插件的核心计时器功能——即 Sidebar 和 Delete Enhancement 之外的所有功能。
|
||||
|
||||
---
|
||||
|
||||
## 一、背景与目标
|
||||
|
||||
### 1.1 需求来源
|
||||
|
||||
Obsidian 用户在日常笔记中记录任务、会议、学习等活动,需要一种**轻量级、零侵入**的方式来追踪各项活动的耗时。现有的计时工具要么是独立应用(需要在 Obsidian 外操作),要么使用独立数据库(数据与笔记割裂)。用户希望**计时数据直接嵌入笔记文本**,随笔记一同迁移、版本控制。
|
||||
|
||||
### 1.2 核心痛点
|
||||
|
||||
| 痛点 | 描述 |
|
||||
|------|------|
|
||||
| 工具割裂 | 计时工具与笔记工具分离,需频繁切换窗口 |
|
||||
| 数据孤岛 | 计时数据存储在独立系统中,与笔记内容脱节 |
|
||||
| 操作繁琐 | 每次计时需手动记录开始/结束时间再计算 |
|
||||
| 缺乏持久化 | Obsidian 关闭或崩溃后计时数据丢失 |
|
||||
| 格式不一 | 手动记录的时间格式不统一,难以汇总 |
|
||||
|
||||
### 1.3 目标
|
||||
|
||||
1. **零侵入计时**:计时器以 HTML span 形式内嵌 Markdown,不影响笔记的纯文本可读性
|
||||
2. **多触发方式**:支持命令面板(快捷键)、右键菜单、Checkbox 状态联动三种触发方式
|
||||
3. **实时显示**:运行中计时器每秒更新时长,编辑器内直观可见
|
||||
4. **三层持久化**:Markdown HTML span ↔ JSON 数据库 ↔ IndexedDB,三层数据保持一致
|
||||
5. **崩溃安全**:Obsidian 异常退出后,下次启动自动恢复/结算运行中计时器
|
||||
6. **跨天支持**:运行中计时器跨越午夜时自动拆分日统计
|
||||
7. **高度可配**:插入位置、时间格式、显示样式、颜色、图标等均可自定义
|
||||
8. **国际化**:支持 en/zh/zhTW/ja/ko 五种语言
|
||||
9. **向后兼容**:自动升级 v1 旧格式计时器至 v2 新格式
|
||||
|
||||
---
|
||||
|
||||
## 二、用户故事
|
||||
|
||||
| ID | 角色 | 故事 | 验收标准 |
|
||||
|----|------|------|----------|
|
||||
| US-01 | 日常用户 | 我希望在任意文本行上启动一个计时器 | 光标定位到目标行,按快捷键/右键菜单,计时器立即出现并开始计时 |
|
||||
| US-02 | 日常用户 | 我希望暂停正在运行的计时器 | 在运行中的计时器行上按快捷键/右键菜单,计时器暂停,时长冻结 |
|
||||
| US-03 | 日常用户 | 我希望恢复已暂停的计时器 | 在暂停的计时器行上按快捷键/右键菜单,计时器恢复运行 |
|
||||
| US-04 | Tasks 用户 | 我希望通过切换 Checkbox 状态来控制计时器 | 将任务从 `[ ]` 改为 `[/]` 时自动启动计时,改为 `[x]` 时自动暂停 |
|
||||
| US-05 | 日常用户 | 我希望调整已暂停计时器的时长 | 右键菜单选择"调整耗时",弹出时间选择器,可自由设定时/分/秒 |
|
||||
| US-06 | 日常用户 | 我希望 Obsidian 关闭后计时器不会丢失数据 | 根据 autoStopTimers 设置,重新打开后计时器要么恢复运行要么自动暂停 |
|
||||
| US-07 | 日常用户 | 我希望计时器的外观可以自定义 | 设置面板中可修改运行/暂停图标、颜色、显示风格(徽章/纯文本)、时间格式 |
|
||||
| US-08 | 日常用户 | 我希望 Obsidian 崩溃后不丢失计时数据 | 崩溃恢复机制自动结算之前运行中的计时器 |
|
||||
| US-09 | 多语言用户 | 我希望插件界面显示我的语言 | 插件自动跟随 Obsidian 语言设置,支持中/英/繁中/日/韩 |
|
||||
| US-10 | 日常用户 | 我希望在状态栏看到运行中计时器信息 | 底部状态栏显示运行中计时器数量和时长 |
|
||||
| US-11 | 旧版用户 | 我希望升级后旧计时器自动兼容 | 打开包含 v1 格式计时器的文件时,自动升级为 v2 格式 |
|
||||
| US-12 | 日常用户 | 我希望跨天工作时时长按天正确统计 | 计时器运行跨越午夜时,每日统计数据自动按天拆分 |
|
||||
|
||||
---
|
||||
|
||||
## 三、功能范围
|
||||
|
||||
### 3.1 In Scope(核心计时器功能)
|
||||
|
||||
- ✅ **计时器生命周期**:创建(init)→ 运行(running tick)→ 暂停(pause)→ 继续(continue)→ 运行
|
||||
- ✅ **三种触发方式**:命令面板(toggle-timer)、右键编辑器菜单、Checkbox 状态联动
|
||||
- ✅ **时间调整**:已暂停计时器可通过 TimePickerModal 手动设定时长
|
||||
- ✅ **文件打开恢复**:根据 autoStopTimers 设置(never/quit/close)决定恢复策略
|
||||
- ✅ **崩溃恢复**:启动时检测 JSON 数据库中 state=running 的记录,自动结算
|
||||
- ✅ **跨天处理**:运行中计时器跨越午夜时,daily_dur 按天拆分统计
|
||||
- ✅ **CM6 Widget**:span 折叠为可交互 Widget、光标逃逸、键盘导航(Backspace/Delete/Arrow)
|
||||
- ✅ **三层数据同步**:Markdown HTML span ↔ timer-db.json ↔ IndexedDB
|
||||
- ✅ **旧格式升级**:v1(timer-btn class)→ v2(timer-r/timer-p class)自动升级
|
||||
- ✅ **预览模式支持**:预览模式下 Checkbox 点击也能触发计时器
|
||||
- ✅ **Checkbox 路径控制**:文件组模式(whitelist/blacklist),支持正则和前缀匹配
|
||||
- ✅ **设置面板**:基础设置、外观设置(图标/颜色/样式/时间格式)、Checkbox 设置、状态栏设置
|
||||
- ✅ **状态栏**:显示运行中计时器数量和时长(最长/总计可选)
|
||||
- ✅ **i18n**:en/zh/zhTW/ja/ko 五种语言全覆盖
|
||||
|
||||
### 3.2 Out of Scope(其他功能,已单独建文档)
|
||||
|
||||
- ❌ Timer Sidebar(独立 PRD: `doc/timer-sidebar/`)
|
||||
- ❌ Delete Timer Enhancement 含 Passive Delete/Restore(独立 PRD: `doc/delete-timer-enhancement/`)
|
||||
|
||||
---
|
||||
|
||||
## 四、功能详细设计
|
||||
|
||||
### 4.1 计时器数据结构
|
||||
|
||||
计时器以 HTML span 形式内嵌在 Markdown 行内:
|
||||
|
||||
```html
|
||||
<span class="timer-r" id="LzHk3a" data-dur="3600" data-ts="1740456240">【⏳01:00:00 】</span>
|
||||
<span class="timer-p" id="LzHk3a" data-dur="3600" data-ts="1740456240" data-project="alpha">【💐01:00:00 】</span>
|
||||
```
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `class` | `timer-r`(运行中)或 `timer-p`(已暂停) |
|
||||
| `id` | Base62 压缩的时间戳 ID,唯一标识计时器 |
|
||||
| `data-dur` | 累计时长(秒) |
|
||||
| `data-ts` | 最后一次状态变更的 Unix 时间戳(秒) |
|
||||
| `data-project` | 可选,项目标签 |
|
||||
|
||||
### 4.2 计时器状态机
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Running: init(创建新计时器)
|
||||
Running --> Paused: pause(用户暂停)
|
||||
Paused --> Running: continue(用户继续)
|
||||
Running --> Running: update(每秒 tick 更新 dur/ts)
|
||||
Paused --> Running: restore(文件打开时恢复)
|
||||
Running --> ForcePaused: forcepause(文件打开时强制暂停)
|
||||
Paused --> ForcePaused: forcepause
|
||||
Paused --> Paused: setDuration(调整时长)
|
||||
```
|
||||
|
||||
### 4.3 触发方式
|
||||
|
||||
#### 4.3.1 命令面板(toggle-timer)
|
||||
- 光标所在行有 `timer-r` → 暂停
|
||||
- 光标所在行有 `timer-p` → 继续
|
||||
- 光标所在行无计时器 → 创建新计时器
|
||||
|
||||
#### 4.3.2 右键编辑器菜单
|
||||
- 根据行内计时器状态动态显示:开始/暂停/继续/删除/调整耗时
|
||||
- "调整耗时"仅对已暂停计时器生效,运行中时禁用并显示 tooltip
|
||||
|
||||
#### 4.3.3 Checkbox 状态联动
|
||||
- 监听 CM6 EditorView.updateListener 中的文档变更
|
||||
- 检测 Checkbox 状态字符变化(如 ` ` → `/`)
|
||||
- 根据 `runningCheckboxState`(如 `/`)和 `pausedCheckboxState`(如 `-xX`)配置决定触发动作
|
||||
- 预览模式下通过 `pointerdown` DOM 事件监听 Checkbox 点击,轮询文件变更
|
||||
|
||||
#### 4.3.4 路径控制(文件组模式)
|
||||
- `checkboxPathGroups` 为空 → 对所有文件生效
|
||||
- 非空时,文件路径匹配**任意一个**组即可启用(组内 blacklist 优先于 whitelist)
|
||||
- 支持正则模式(`/pattern/`)和前缀匹配模式
|
||||
- 旧版 whitelist/blacklist 自动迁移为名为 "Migrated" 的文件组
|
||||
|
||||
### 4.4 运行 Tick 机制
|
||||
|
||||
- `TimerManager` 使用 `setInterval(1000ms)` 驱动每秒 tick
|
||||
- 每次 tick:
|
||||
1. 检查页面可见性(后台超过 1s 跳过 tick,避免性能问题)
|
||||
2. 防止重叠执行(`runningTicks: Set`)
|
||||
3. `TimerDataUpdater.calculate('update')` 计算新的 dur/ts
|
||||
4. `TimerFileManager.updateTimerByIdWithSearch()` 写入 Markdown(先用缓存位置,失败则全文搜索)
|
||||
5. 更新 IndexedDB(tickUpdate)
|
||||
6. 更新 JSON 内存
|
||||
7. 检查跨天边界
|
||||
|
||||
### 4.5 文件打开恢复
|
||||
|
||||
- 首次文件打开(`fileFirstOpen=true`):扫描所有已打开标签页
|
||||
- 后续文件打开:仅扫描新打开的文件
|
||||
- 扫描逻辑:逐行解析,找到 `timer-r` 的 span
|
||||
- `autoStopTimers === 'never'`:恢复运行
|
||||
- `autoStopTimers === 'quit'`:仅恢复本次会话启动过的计时器(`startedIds`),其余强制暂停
|
||||
- `autoStopTimers === 'close'`:全部强制暂停
|
||||
|
||||
### 4.6 崩溃恢复
|
||||
|
||||
- 启动时 `TimerDatabase.recoverCrashedTimers()`
|
||||
- 检测 JSON 中 `state=running` 的记录(说明上次异常退出未正常 onunload)
|
||||
- 计算 crash session 时长,累加到 daily_dur
|
||||
- 将这些记录状态改为 `paused`
|
||||
- 将崩溃恢复的 daily_dur 同步到 IndexedDB
|
||||
|
||||
### 4.7 跨天处理
|
||||
|
||||
- `TimerDatabase.checkDayBoundary()` 在每次 tick 时检查
|
||||
- 如果当前 tick 的日期 ≠ session 开始日期,拆分为每日 segments
|
||||
- 每个 segment 记入对应日期的 daily_dur
|
||||
- IDB 层由 `tickUpdate` 自然按 today 写入,天然支持跨天
|
||||
|
||||
### 4.8 CM6 Widget
|
||||
|
||||
- `timerFoldingField`(StateField):将 timer span 替换为 `TimerWidget` 装饰
|
||||
- `TimerWidget`:显示 `【⏳01:00:00 】` 文本,带 running/paused 状态 CSS class
|
||||
- `timerCursorEscape`:光标落入 span 内部时自动弹出到最近边界
|
||||
- `timerWidgetKeymap`:
|
||||
- Backspace:删除左侧紧邻的 timer span
|
||||
- Delete:删除右侧紧邻的 timer span
|
||||
- ArrowLeft/ArrowRight:跳过 timer span
|
||||
|
||||
### 4.9 时间调整(Time Adjustment)
|
||||
|
||||
- `TimePickerModal`:iPhone 风格滚轮选择器(时/分/秒)
|
||||
- 仅对 `timer-p` 生效
|
||||
- 修改后同步:
|
||||
1. Markdown span 的 data-dur 更新
|
||||
2. JSON 的 total_dur_sec 和 daily_dur 调整(增量加到今天/减量从最近日期 LIFO 扣除)
|
||||
3. IDB 同步
|
||||
|
||||
### 4.10 设置面板
|
||||
|
||||
**基础设置**:
|
||||
| 设置项 | 默认值 | 说明 |
|
||||
|--------|--------|------|
|
||||
| autoStopTimers | quit | 自动停止策略 |
|
||||
| timerInsertLocation | head | 计时器插入位置(行首/行尾) |
|
||||
|
||||
**Checkbox 设置**:
|
||||
| 设置项 | 默认值 | 说明 |
|
||||
|--------|--------|------|
|
||||
| enableCheckboxToTimer | true | 是否启用 Checkbox 联动 |
|
||||
| runningCheckboxState | / | 触发运行的 Checkbox 符号 |
|
||||
| pausedCheckboxState | -xX | 触发暂停的 Checkbox 符号 |
|
||||
| checkboxPathGroups | [] | 路径控制文件组 |
|
||||
|
||||
**外观设置**:
|
||||
| 设置项 | 默认值 | 说明 |
|
||||
|--------|--------|------|
|
||||
| timeDisplayFormat | full | 时间格式(full/smart) |
|
||||
| timerDisplayStyle | badge | 显示风格(badge/plain) |
|
||||
| runningIcon | ⏱ | 运行中图标 |
|
||||
| pausedIcon | 💐 | 暂停图标 |
|
||||
| runningTextColor | #10b981 | 运行中文字色 |
|
||||
| runningBgColor | rgba(16,185,129,0.15) | 运行中背景色 |
|
||||
| pausedTextColor | #6b7280 | 暂停文字色 |
|
||||
| pausedBgColor | rgba(107,114,128,0.12) | 暂停背景色 |
|
||||
|
||||
**状态栏设置**:
|
||||
| 设置项 | 默认值 | 说明 |
|
||||
|--------|--------|------|
|
||||
| showStatusBar | true | 是否显示状态栏 |
|
||||
| statusBarMode | max | 显示最长计时器/总时长 |
|
||||
|
||||
### 4.11 状态栏
|
||||
|
||||
- 有运行中计时器:`⏱ N running · HH:MM:SS`
|
||||
- 无运行中计时器:`💐 No running timers`
|
||||
- 点击打开 Sidebar
|
||||
- 每秒与 tick 同步更新
|
||||
|
||||
---
|
||||
|
||||
## 五、数据需求
|
||||
|
||||
### 5.1 Markdown 层
|
||||
- 行内 HTML span,格式如 4.1 节所述
|
||||
- 每秒 tick 更新 `data-dur` 和 `data-ts` 及显示文本
|
||||
|
||||
### 5.2 JSON 层(timer-db.json)
|
||||
- `timers` 表:timer_id、file_path、line_num、line_text、project、state、total_dur_sec、last_ts、created_at、updated_at
|
||||
- `daily_dur` 表:`{ [date]: { [timerId]: seconds } }`
|
||||
- `session_starts` 表:`{ [timerId]: startTs }` 运行中 session 开始时间
|
||||
|
||||
### 5.3 IndexedDB 层(TimerPluginDB)
|
||||
- `timers` store:与 JSON 层 timers 表对应
|
||||
- `daily_dur` store:key=`timerId|date`,支持按 timer_id / stat_date 索引
|
||||
|
||||
### 5.4 数据一致性要求
|
||||
- 任何状态变更必须同步三层
|
||||
- 运行中 tick 仅更新 IDB 和内存(不 flush JSON,避免性能问题)
|
||||
- 暂停/继续等状态变更触发 JSON flush
|
||||
- 插件 onunload 时同步 flush 所有 running timers 到 JSON
|
||||
|
||||
---
|
||||
|
||||
## 六、移动端适配要求
|
||||
|
||||
### 6.1 布局差异
|
||||
- 预览模式下 Checkbox 点击需通过 DOM pointerdown 事件处理
|
||||
- CM6 Widget 在移动端同样生效
|
||||
|
||||
### 6.2 交互差异
|
||||
- 触摸操作:TimePickerModal 支持 touch 滑动手势
|
||||
- 无右键菜单:移动端依赖命令面板和 Checkbox 联动
|
||||
|
||||
### 6.3 性能约束
|
||||
- 后台节流:页面不可见时跳过 tick
|
||||
- 不使用 native 模块
|
||||
|
||||
---
|
||||
|
||||
## 七、国际化要求
|
||||
|
||||
所有用户可见文本均通过 `translations.ts` 支持 5 种语言:
|
||||
|
||||
| 类别 | i18n key 示例 |
|
||||
|------|---------------|
|
||||
| 命令名称 | command_name.toggle / delete / timeAdjust |
|
||||
| 操作标签 | action_start / action_paused / action_continue |
|
||||
| 时间调整弹窗 | timeBackfill.* |
|
||||
| 设置面板 | settings.* (含所有子项) |
|
||||
| 状态栏 | settings.statusBar.* |
|
||||
|
||||
---
|
||||
|
||||
## 八、非功能需求
|
||||
|
||||
### 8.1 性能
|
||||
- tick 延迟 < 100ms(主线程)
|
||||
- 单个 writeTimer 操作 < 5ms
|
||||
- 10000+ 计时器的 JSON 加载 < 500ms
|
||||
|
||||
### 8.2 兼容性
|
||||
- Obsidian 最低版本:1.5+
|
||||
- 支持桌面(Windows/Mac/Linux)和移动端(iOS/Android)
|
||||
- 深色/浅色主题自动适配
|
||||
|
||||
### 8.3 可访问性
|
||||
- 键盘可操作(快捷键、方向键导航 timer span)
|
||||
- 状态通过颜色 + 图标双重表达
|
||||
|
||||
---
|
||||
|
||||
## 九、验收标准总表
|
||||
|
||||
| 编号 | 场景 | 预期结果 | 优先级 |
|
||||
|------|------|----------|--------|
|
||||
| AC-01 | 命令面板启动计时器 | 行内出现 timer-r span,每秒递增 | P0 |
|
||||
| AC-02 | 命令面板暂停计时器 | span 变为 timer-p,时长冻结 | P0 |
|
||||
| AC-03 | 命令面板继续计时器 | span 变为 timer-r,继续递增 | P0 |
|
||||
| AC-04 | Checkbox 联动启动 | checkbox 状态改为 running symbol,计时器自动创建/继续 | P0 |
|
||||
| AC-05 | Checkbox 联动暂停 | checkbox 状态改为 paused symbol,计时器自动暂停 | P0 |
|
||||
| AC-06 | 运行中 tick | data-dur 每秒递增,显示文本同步更新 | P0 |
|
||||
| AC-07 | 三层数据一致 | 暂停后 Markdown dur == JSON total_dur_sec == IDB total_dur_sec | P0 |
|
||||
| AC-08 | 崩溃恢复 | 模拟崩溃后重启,之前的 running timer 被恢复为 paused,daily_dur 正确结算 | P0 |
|
||||
| AC-09 | 文件打开恢复(quit 模式) | 本次会话启动过的 timer 恢复运行,其余 timer 强制暂停 | P0 |
|
||||
| AC-10 | 时间调整(增加) | 暂停 timer 时长从 1h 改为 2h,delta 加到 today 的 daily_dur | P1 |
|
||||
| AC-11 | 时间调整(减少) | 暂停 timer 时长减少,从最近日期 LIFO 扣除 | P1 |
|
||||
| AC-12 | 旧格式升级 | 打开含 v1 timer 的文件,span 自动升级为 v2 格式 | P1 |
|
||||
| AC-13 | 跨天拆分 | timer 运行跨越午夜,两天的 daily_dur 各自正确 | P0 |
|
||||
| AC-14 | CM6 Widget 折叠 | timer span 在编辑器中显示为 Widget,非原始 HTML | P1 |
|
||||
| AC-15 | 键盘导航 | Backspace/Delete 删除 timer span,Arrow 键跳过 | P1 |
|
||||
| AC-16 | 光标逃逸 | 鼠标点击 timer span 内部,光标自动弹出到边界 | P1 |
|
||||
| AC-17 | 状态栏显示 | 有 running timer 时状态栏显示数量和时长 | P1 |
|
||||
| AC-18 | 设置面板 | 所有设置项正常显示、修改、持久化 | P1 |
|
||||
| AC-19 | i18n | 切换 Obsidian 语言,插件界面跟随切换 | P2 |
|
||||
| AC-20 | Checkbox 路径控制 | 文件组 whitelist/blacklist 正确控制哪些文件启用 Checkbox 联动 | P1 |
|
||||
| AC-21 | 后台节流 | 页面不可见时 tick 被跳过 | P2 |
|
||||
| AC-22 | onunload 结算 | Obsidian 正常关闭时所有 running timer 被结算到 JSON | P0 |
|
||||
|
||||
---
|
||||
|
||||
## 十、技术实现要点(产品视角)
|
||||
|
||||
1. 计时器数据以 HTML span 形式内嵌 Markdown,这是核心设计约束
|
||||
2. 状态机为纯函数(`TimerDataUpdater`),无副作用,易于测试
|
||||
3. `TimerManager` 管理内存中的 setInterval tick,需防后台节流和 tick 重叠
|
||||
4. `TimerFileManager` 负责 Markdown 读写,支持编辑模式和预览模式两套路径
|
||||
5. 三层数据(Markdown/JSON/IDB)必须在每次状态变更时同步
|
||||
6. CM6 Widget 由 Obsidian 宿主提供 CodeMirror,不可自行 bundle
|
||||
|
||||
---
|
||||
|
||||
## 十一、开放问题
|
||||
|
||||
无(已上线功能归档文档)。
|
||||
69
doc/core-timer/stage2-ux-review.md
Normal file
69
doc/core-timer/stage2-ux-review.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# 🎨 UX Review: Core Timer
|
||||
|
||||
**审查日期**: 2026-03-02 | **状态**: 已上线功能回顾 | **对应 PRD**: stage1-PRD.md
|
||||
|
||||
> 本文档为已上线核心计时器功能的 UX 回顾审查,基于实际代码和用户反馈评估交互设计。
|
||||
|
||||
---
|
||||
|
||||
## 一、UX 审查清单
|
||||
|
||||
### 桌面端
|
||||
|
||||
- [x] 信息层级清晰:Widget 显示 `【⏳HH:MM:SS 】`,状态(图标+颜色)> 时长 > 包裹括号
|
||||
- [x] 操作路径最短:快捷键 1 步即可 toggle timer
|
||||
- [x] 空状态有引导:状态栏显示 "No running timers"
|
||||
- [x] 与 Obsidian 原生 UI 风格一致:使用 CSS 变量、Setting API
|
||||
- [x] 快捷键/命令面板集成:toggle-timer、delete-timer、time-adjustment 三个命令
|
||||
- [x] 深色/浅色主题兼容:颜色通过 CSS 变量 + 用户自定义
|
||||
|
||||
### 移动端专项
|
||||
|
||||
- [x] 触摸目标尺寸:TimePickerModal 滑轮支持 touch 事件
|
||||
- [x] 无 Obsidian 原生手势冲突:timer Widget 不拦截滑动
|
||||
- [x] 虚拟键盘弹出时布局正确:Modal 使用 Obsidian Modal API,自动适配
|
||||
|
||||
### 交互状态
|
||||
|
||||
- [x] 加载态/空态/错误态:状态栏有空态文案
|
||||
- [x] 危险操作有防护:"调整耗时"仅对暂停 timer 生效,运行中禁用并显示 tooltip
|
||||
- [x] 输入框有校验:Checkbox 符号输入自动过滤 `[]` 字符
|
||||
|
||||
---
|
||||
|
||||
## 二、已有 UX 优化记录
|
||||
|
||||
### [UX-优化] TimePickerModal 交互
|
||||
|
||||
- iPhone 风格滚轮选择器,支持鼠标拖拽、touch 滑动、滚轮、点击
|
||||
- 惯性动画(velocity + momentum),操作流畅
|
||||
- 当前选中项视觉高亮(大字体、粗体、高对比度),远离项渐隐
|
||||
|
||||
### [UX-优化] CM6 Widget 键盘导航
|
||||
|
||||
- 光标逃逸:避免用户误编辑 HTML span 源码
|
||||
- Arrow 键跳过:与 Obsidian 原生内联元素行为一致
|
||||
- Backspace/Delete 整体删除 span:符合直觉
|
||||
|
||||
### [UX-优化] 右键菜单动态项
|
||||
|
||||
- 根据行内 timer 状态动态显示操作项(开始/暂停/继续/删除/调整耗时)
|
||||
- 禁用项有 hover tooltip 解释原因
|
||||
|
||||
### [UX-优化] 设置面板文件组管理
|
||||
|
||||
- 折叠/展开卡片式 UI,信息密度适中
|
||||
- 每条 pattern 独立正则开关(`.*`),直观切换
|
||||
- 实时保存,无需手动确认
|
||||
|
||||
---
|
||||
|
||||
## 三、PRD 修正
|
||||
|
||||
由于是已上线功能归档,PRD 内容与实际代码一致,无需修正。
|
||||
|
||||
---
|
||||
|
||||
## 四、结论
|
||||
|
||||
核心计时器功能的 UX 设计成熟,交互路径短、状态反馈明确、移动端适配良好。无需修改。
|
||||
193
doc/core-timer/stage3-data-schema.md
Normal file
193
doc/core-timer/stage3-data-schema.md
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
# 📊 数据设计文档: Core Timer
|
||||
|
||||
**文档版本**: v1.0 | **创建日期**: 2026-03-02 | **状态**: 补充归档 | **对应 PRD**: stage1-PRD.md
|
||||
|
||||
> 本文档为已上线核心计时器功能的数据 schema 归档,描述三层数据存储的实际结构。
|
||||
|
||||
---
|
||||
|
||||
## 一、现有 Schema 分析
|
||||
|
||||
### 1.1 Markdown HTML Span(源数据层)
|
||||
|
||||
```html
|
||||
<span class="timer-r" id="LzHk3a" data-dur="3600" data-ts="1740456240">【⏳01:00:00 】</span>
|
||||
<span class="timer-p" id="LzHk3a" data-dur="3600" data-ts="1740456240" data-project="alpha">【💐01:00:00 】</span>
|
||||
```
|
||||
|
||||
| 属性 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `class` | `timer-r` \| `timer-p` | 运行状态 |
|
||||
| `id` | string | Base62 压缩时间戳 ID |
|
||||
| `data-dur` | number | 累计时长(秒) |
|
||||
| `data-ts` | number | 最后状态变更 Unix 时间戳(秒) |
|
||||
| `data-project` | string \| undefined | 可选,项目标签 |
|
||||
|
||||
### 1.2 JSON 层(timer-db.json)
|
||||
|
||||
**文件路径**:`{vault}/.obsidian/plugins/text-block-timer/timer-db.json`
|
||||
|
||||
```typescript
|
||||
interface TimerDbFile {
|
||||
version: number; // 当前版本 2
|
||||
lastFullScan: string; // 最近全量扫描时间(ISO 格式)
|
||||
timers: Record<string, TimerEntry>; // timer_id → entry
|
||||
daily_dur: Record<string, Record<string, number>>; // date → { timer_id → seconds }
|
||||
}
|
||||
```
|
||||
|
||||
**timers 表**:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `timer_id` | string | ✅ | 主键,Base62 压缩 ID |
|
||||
| `file_path` | string | ✅ | 相对于 vault 根目录的文件路径 |
|
||||
| `line_num` | number | ✅ | 0-indexed 行号 |
|
||||
| `line_text` | string | ✅ | 行文本摘要(去除 timer span 后的纯文本) |
|
||||
| `project` | string \| null | — | 项目标签 |
|
||||
| `state` | enum | ✅ | `running` / `paused` / `deleted` / `lost` |
|
||||
| `total_dur_sec` | number | ✅ | 累计总时长(秒) |
|
||||
| `last_ts` | number | ✅ | 最近状态变更 Unix 时间戳 |
|
||||
| `created_at` | number | ✅ | 创建时间 Unix 时间戳 |
|
||||
| `updated_at` | number | ✅ | 最近更新时间 Unix 时间戳 |
|
||||
|
||||
**daily_dur 表**:
|
||||
|
||||
```json
|
||||
{
|
||||
"2026-03-01": { "LzHk3a": 3600, "Abc123": 1800 },
|
||||
"2026-03-02": { "LzHk3a": 900 }
|
||||
}
|
||||
```
|
||||
|
||||
- 外层 key:日期 `YYYY-MM-DD`
|
||||
- 内层 key:timer_id
|
||||
- value:该 timer 在该日期的累计秒数
|
||||
|
||||
**内存辅助结构**:
|
||||
|
||||
| 结构 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `timersByFile` | `Map<filePath, Set<timerId>>` | 按文件路径索引 timers,加速 removeFile/renameFile |
|
||||
| `sessionStartDate` | `Map<timerId, string>` | 当前 running session 的开始日期(用于跨天检测) |
|
||||
| `sessionStartTs` | `Map<timerId, number>` | 当前 running session 的开始时间戳 |
|
||||
|
||||
### 1.3 IndexedDB 层(TimerPluginDB)
|
||||
|
||||
**数据库名**:`text-block-timer`,版本:1
|
||||
|
||||
**timers store**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `timer_id` | string | keyPath(主键) |
|
||||
| `file_path` | string | 文件路径 |
|
||||
| `line_num` | number | 行号 |
|
||||
| `line_text` | string | 行文本摘要 |
|
||||
| `project` | string \| null | 项目标签 |
|
||||
| `state` | enum | running / paused / deleted / lost |
|
||||
| `total_dur_sec` | number | 累计总时长(秒) |
|
||||
| `last_ts` | number | 最近状态变更时间戳 |
|
||||
| `created_at` | number | 创建时间 |
|
||||
| `updated_at` | number | 更新时间 |
|
||||
|
||||
**daily_dur store**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `key` | string | keyPath,格式 `${timer_id}\|${stat_date}` |
|
||||
| `timer_id` | string | 关联 timers.timer_id |
|
||||
| `stat_date` | string | YYYY-MM-DD |
|
||||
| `duration_sec` | number | 累计秒数 |
|
||||
|
||||
**索引**:
|
||||
- `by_timer`:`timer_id`(非唯一),用于按 timer 查询
|
||||
- `by_date`:`stat_date`(非唯一),用于按日期聚合
|
||||
|
||||
---
|
||||
|
||||
## 二、三层同步策略
|
||||
|
||||
### 2.1 写入频率
|
||||
|
||||
| 操作 | Markdown | JSON | IndexedDB |
|
||||
|------|----------|------|-----------|
|
||||
| 创建(init) | ✅ 立即写入 span | ✅ scheduleFlush(100ms) | ✅ async fire-and-forget |
|
||||
| 继续(continue) | ✅ 立即 | ✅ scheduleFlush | ✅ async |
|
||||
| 暂停(pause) | ✅ 立即 | ✅ scheduleFlush | ✅ async |
|
||||
| 每秒 tick | ✅ 每秒更新 span | ❌ 仅内存更新 | ✅ tickUpdate(原子事务) |
|
||||
| 时间调整 | ✅ 立即 | ✅ flush | ✅ async |
|
||||
| onunload | ❌ 不写 | ✅ flushSync | ❌ 异步不可靠 |
|
||||
|
||||
### 2.2 权威性
|
||||
|
||||
| 场景 | 权威来源 |
|
||||
|------|----------|
|
||||
| 运行中实时 dur | Markdown span(每秒更新) |
|
||||
| 持久化状态 | JSON(onunload 时 flushSync) |
|
||||
| 实时查询(sidebar/status bar) | IndexedDB(每秒 tickUpdate) |
|
||||
| daily_dur | IndexedDB(tick 级精度)= JSON(state change 级精度) |
|
||||
|
||||
### 2.3 启动时数据流
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant JSON as timer-db.json
|
||||
participant IDB as IndexedDB
|
||||
participant MEM as Memory
|
||||
|
||||
Note over JSON, MEM: Plugin onload
|
||||
JSON->>MEM: load() — 解析 JSON
|
||||
MEM->>MEM: recoverCrashedTimers()
|
||||
MEM->>JSON: flush() — 持久化恢复结果
|
||||
MEM->>IDB: seedIndexedDB() — clearAll + bulkPut
|
||||
Note over JSON, MEM: 三层一致
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、数据迁移方案
|
||||
|
||||
### 3.1 v1 → v2 格式升级(Markdown span)
|
||||
|
||||
| 项目 | v1 | v2 |
|
||||
|------|----|----|
|
||||
| class | `timer-btn` | `timer-r` / `timer-p` |
|
||||
| ID 属性 | `timerId` 或 `data-timerId` | `id` |
|
||||
| 状态 | `Status="Running"/"Paused"` | class 值区分 |
|
||||
| 时长 | `AccumulatedTime` 或 `data-AccumulatedTime` | `data-dur` |
|
||||
| 时间戳 | `currentStartTimeStamp` | `data-ts` |
|
||||
|
||||
**升级逻辑**:`TimerFileManager.upgradeOldTimers()` 在文件打开时自动扫描 v1 span,重新生成 Base62 ID 并替换为 v2 格式。
|
||||
|
||||
### 3.2 legacy sessions 迁移(JSON)
|
||||
|
||||
旧版 JSON 使用 `sessions[]` 数组,新版使用 `daily_dur` 结构。`TimerDatabase.load()` 在检测到 `sessions` 字段时自动迁移到 `daily_dur`,然后删除 `sessions`。
|
||||
|
||||
### 3.3 Checkbox 路径控制迁移
|
||||
|
||||
旧版使用 `checkboxToTimerPathRestriction`(disable/whitelist/blacklist)+ `pathRestrictionPaths`。新版使用 `checkboxPathGroups`(文件组数组)。`checkPathRestriction()` 在首次调用时检测旧格式并自动迁移为名为 "Migrated" 的文件组。
|
||||
|
||||
---
|
||||
|
||||
## 四、查询场景分析
|
||||
|
||||
| 场景 | 查询条件 | 数据来源 | 期望响应时间 |
|
||||
|------|----------|----------|-------------|
|
||||
| Sidebar 当前文件计时器 | filePath + state ∈ {running, paused} | 实时扫描 Markdown | < 50ms |
|
||||
| Sidebar 当前会话计时器 | 多个 filePath + state | 实时扫描 Markdown | < 100ms |
|
||||
| Sidebar 全库计时器 | state ∈ {running, paused} | JSON timers 表 | < 200ms |
|
||||
| 状态栏更新 | 所有 running timers | Memory (TimerManager) | < 5ms |
|
||||
| 每秒 tick 更新 | 单个 timerId | Memory + IDB | < 10ms |
|
||||
| daily_dur 图表 | timerId + date range | IDB by_timer 索引 | < 50ms |
|
||||
|
||||
---
|
||||
|
||||
## 五、异常恢复方案
|
||||
|
||||
| 异常 | 恢复策略 |
|
||||
|------|----------|
|
||||
| Obsidian 崩溃 | `recoverCrashedTimers()` 检测 JSON 中 state=running 的记录,计算 crash 期间时长,结算到 daily_dur |
|
||||
| JSON 文件损坏 | load() catch → 创建空数据库 |
|
||||
| IDB 不可用 | 降级为纯 JSON 模式,侧边栏/图表功能受限 |
|
||||
| 文件被外部修改 | `findTimerGlobally()` 全文搜索兜底 |
|
||||
414
doc/core-timer/stage4-tech-design.md
Normal file
414
doc/core-timer/stage4-tech-design.md
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
# 🔧 技术方案: Core Timer
|
||||
|
||||
**文档版本**: v1.0 | **创建日期**: 2026-03-02 | **状态**: 补充归档(基于实际代码)
|
||||
|
||||
> 本文档为已上线核心计时器功能的技术方案归档,完全基于实际代码结构描述。
|
||||
|
||||
---
|
||||
|
||||
## 一、架构概览
|
||||
|
||||
### 1.1 分层架构
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph ENTRY["入口层"]
|
||||
TP["TimerPlugin (main.ts)<br>协调者 Orchestrator"]
|
||||
end
|
||||
|
||||
subgraph CORE["核心层 (core/)"]
|
||||
TDU["TimerDataUpdater<br>纯函数状态机"]
|
||||
TM["TimerManager<br>内存 timer 管理"]
|
||||
TDB["TimerDatabase<br>JSON 持久化"]
|
||||
IDB["TimerIndexedDB<br>IDB 热缓存"]
|
||||
end
|
||||
|
||||
subgraph IO["IO 层 (io/)"]
|
||||
TFM["TimerFileManager<br>文件读写 + 位置追踪"]
|
||||
TPR["TimerParser<br>HTML 解析"]
|
||||
TR["TimerRenderer<br>HTML 渲染"]
|
||||
TF["TimeFormatter<br>时间格式化"]
|
||||
end
|
||||
|
||||
subgraph UI["UI 层 (ui/)"]
|
||||
TW["TimerWidget<br>CM6 Widget"]
|
||||
TPM["TimePickerModal<br>时间选择弹窗"]
|
||||
TST["TimerSettingTab<br>设置面板"]
|
||||
end
|
||||
|
||||
TP --> TDU
|
||||
TP --> TM
|
||||
TP --> TDB
|
||||
TP --> IDB
|
||||
TP --> TFM
|
||||
TFM --> TPR
|
||||
TFM --> TR
|
||||
TFM --> TF
|
||||
TP --> TW
|
||||
TP --> TPM
|
||||
TP --> TST
|
||||
```
|
||||
|
||||
### 1.2 核心设计原则
|
||||
|
||||
| 原则 | 实现 |
|
||||
|------|------|
|
||||
| 纯函数状态机 | `TimerDataUpdater.calculate()` 无副作用,仅输入→输出 |
|
||||
| 三层数据分离 | Markdown(源)↔ JSON(冷持久化)↔ IDB(热缓存) |
|
||||
| 协调者模式 | `TimerPlugin` 不持有业务状态,仅协调各模块 |
|
||||
| 位置缓存 + 全文兜底 | `TimerFileManager.locations` 缓存行号,失效时全文搜索 |
|
||||
| 后台节流 | `document.visibilitychange` 监听,后台 > 1s 跳过 tick |
|
||||
|
||||
---
|
||||
|
||||
## 二、核心模块设计
|
||||
|
||||
### 2.1 TimerPlugin — 主协调者
|
||||
|
||||
**文件**: `src/main.ts`
|
||||
**职责**: 注册命令/菜单/事件,协调各模块完成业务流程
|
||||
|
||||
**关键方法**:
|
||||
|
||||
| 方法 | 触发场景 | 核心逻辑 |
|
||||
|------|----------|----------|
|
||||
| `handleStart(editor, file)` | 命令面板/右键菜单 | TimerDataUpdater.calculate('init') → fileManager.writeTimer() → manager.startTimer() → db.updateEntry() → idb.putTimer() |
|
||||
| `handlePause(editor, file, timerId)` | 同上 | calculate('pause') → writeTimer() → manager.stopTimer() → db.updateEntry() + addDailyDur → idb.patchTimer() + addDailyDur |
|
||||
| `handleContinue(editor, file, timerId)` | 同上 | calculate('continue') → writeTimer() → manager.startTimer() → db.updateEntry() → idb.patchTimer() |
|
||||
| `handleSetDuration(timerId)` | 右键菜单 | TimePickerModal → calculate('setDuration') → writeTimer() → db.updateEntry() + adjustDailyDur → idb同步 |
|
||||
| `onTick(timerId)` | setInterval 每秒 | shouldSkipTick() → calculate('update') → fileManager.updateTimerByIdWithSearch() → idb.tickUpdate() → db.updateEntryInMemory() → checkDayBoundary() |
|
||||
| `restoreTimers()` | 文件打开 | 扫描所有 tab → parse timer span → 按 autoStopTimers 策略 restore/forcepause |
|
||||
| `toggleTimerbyCheckboxState()` | EditorView.updateListener | 检测 checkbox 状态字符变更 → 路径控制检查 → handleStart/handlePause/handleContinue |
|
||||
|
||||
**onload 启动序列**:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant P as TimerPlugin
|
||||
participant DB as TimerDatabase
|
||||
participant IDB as TimerIndexedDB
|
||||
participant M as TimerManager
|
||||
participant FM as TimerFileManager
|
||||
|
||||
P->>DB: load() — 加载 timer-db.json
|
||||
P->>DB: recoverCrashedTimers() — 崩溃恢复
|
||||
P->>IDB: open() + seedFromJSON() — IDB 同步
|
||||
P->>P: registerCommands() — 注册命令
|
||||
P->>P: registerMenuItems() — 注册右键菜单
|
||||
P->>P: registerEditorExtensions() — CM6 Widget + updateListener
|
||||
P->>P: registerLayoutReady() — 等 workspace 就绪后 restoreTimers()
|
||||
```
|
||||
|
||||
### 2.2 TimerDataUpdater — 纯函数状态机
|
||||
|
||||
**文件**: `src/core/TimerDataUpdater.ts`
|
||||
|
||||
```typescript
|
||||
interface TimerData {
|
||||
class: 'timer-r' | 'timer-p';
|
||||
timerId: string;
|
||||
dur: number; // 累计秒数
|
||||
ts: number; // Unix timestamp (秒)
|
||||
project?: string | null;
|
||||
beforeIndex?: number; // 解析位置
|
||||
afterIndex?: number;
|
||||
}
|
||||
|
||||
type TimerAction = 'init' | 'continue' | 'pause' | 'update' | 'restore' | 'forcepause' | 'setDuration';
|
||||
```
|
||||
|
||||
**状态转换矩阵**:
|
||||
|
||||
| Action | 输入状态 | class | dur 计算 | ts 计算 |
|
||||
|--------|---------|-------|----------|---------|
|
||||
| init | — | timer-r | 0 | now |
|
||||
| continue | timer-p | timer-r | 不变 | now |
|
||||
| pause | timer-r | timer-p | old.dur + (now - old.ts) | now |
|
||||
| update | timer-r | timer-r | old.dur + (now - old.ts) | now |
|
||||
| restore | timer-p | timer-r | old.dur + (now - old.ts) | now |
|
||||
| forcepause | any | timer-p | 不变 | 不变 |
|
||||
| setDuration | timer-p | timer-p | newDur | 不变 |
|
||||
|
||||
### 2.3 TimerManager — 内存 Timer 管理
|
||||
|
||||
**文件**: `src/core/TimerManager.ts`
|
||||
|
||||
- `timers: Map<timerId, {intervalId, data}>` — 所有运行中的 timer
|
||||
- `startedIds: Set<timerId>` — 本次 onload session 启动过的 timer(用于 quit 模式)
|
||||
- `runningTicks: Set<timerId>` — 当前正在执行 tick 的 timer(防重叠)
|
||||
- 页面可见性监控:`document.visibilitychange` → `isVisible` / `lastVisibleTime`
|
||||
- `shouldSkipTick()`: 后台 > 1s 返回 true
|
||||
|
||||
### 2.4 TimerDatabase — JSON 持久化
|
||||
|
||||
**文件**: `src/core/TimerDatabase.ts`
|
||||
|
||||
**写入策略**:
|
||||
- `updateEntry()`: scheduleFlush(100ms debounce) — 状态变更
|
||||
- `updateEntryInMemory()`: 仅内存更新 — tick 时使用
|
||||
- `flushSync()`: 同步写文件系统 — onunload 使用
|
||||
- `addDailyDurInMemory()`: 累加到 daily_dur — pause/tick 时
|
||||
|
||||
**内存索引**:
|
||||
- `timersByFile: Map<filePath, Set<timerId>>` — O(1) 按文件查询
|
||||
- `sessionStartDate / sessionStartTs` — 跨天检测辅助
|
||||
|
||||
**关键功能**:
|
||||
- `recoverCrashedTimers()`: 检测 state=running 的遗留记录,计算 crash 时长,结算到 daily_dur
|
||||
- `checkDayBoundary()`: 检测跨天,拆分 segments(每日一条)
|
||||
- `adjustDailyDurForSetDuration()`: 时间调整的 LIFO 扣除
|
||||
|
||||
### 2.5 TimerIndexedDB — IDB 热缓存
|
||||
|
||||
**文件**: `src/core/TimerIndexedDB.ts`
|
||||
|
||||
- `tickUpdate()`: 原子事务同时更新 timers store(dur/ts)和 daily_dur store(delta)
|
||||
- `adjustDailyDurForSetDuration()`: 与 JSON 层同算法的 IDB 版本
|
||||
- 按 `by_timer` / `by_date` 索引查询
|
||||
|
||||
### 2.6 TimerFileManager — 文件读写
|
||||
|
||||
**文件**: `src/io/TimerFileManager.ts`
|
||||
|
||||
**位置缓存**: `locations: Map<timerId, {view, file, lineNum}>`
|
||||
- 创建/恢复/继续时缓存
|
||||
- 每次 tick 时先用缓存位置更新,失败则 fallback 全文搜索
|
||||
|
||||
**双模式写入**:
|
||||
- 编辑模式: `editor.replaceRange()` — CM6 dispatch
|
||||
- 预览模式: `vault.read()` + `vault.modify()` — Vault API
|
||||
|
||||
**upgradeOldTimers()**:
|
||||
- 扫描当前文件,找到 v1 格式 span(`.timer-btn`)
|
||||
- 生成新 Base62 ID,用 v2 格式替换
|
||||
- 同时更新 JSON/IDB
|
||||
|
||||
### 2.7 TimerParser / TimerRenderer — 解析与渲染
|
||||
|
||||
**TimerParser** (`src/io/TimerParser.ts`):
|
||||
- 使用 `document.createElement('template')` + DOM querySelector 解析 HTML
|
||||
- 支持 v1(timer-btn class)和 v2(timer-r/timer-p class)双格式
|
||||
- 返回 TimerData 含 `beforeIndex` / `afterIndex` 用于精确替换
|
||||
|
||||
**TimerRenderer** (`src/io/TimerRenderer.ts`):
|
||||
- 纯函数,输入 TimerData + Settings → 输出 HTML string
|
||||
- 格式: `<span class="timer-r" id="X" data-dur="N" data-ts="N">【⏳HH:MM:SS 】</span>`
|
||||
- 支持自定义图标、时间格式(full/smart)
|
||||
|
||||
### 2.8 CM6 Widget 系统
|
||||
|
||||
**文件**: `src/ui/TimerWidget.ts`
|
||||
|
||||
**三个 CM6 扩展**:
|
||||
|
||||
1. **timerFoldingField** (StateField + DecorationSet):
|
||||
- 正则匹配行内 timer span
|
||||
- 替换为 `TimerWidget` Decoration(Decoration.replace)
|
||||
- 随 editor state 更新自动重建
|
||||
|
||||
2. **timerCursorEscape** (EditorView.updateListener):
|
||||
- 检测光标位置是否落入 timer span 范围内
|
||||
- 如果是,自动将光标移动到最近的外边界(左/右)
|
||||
|
||||
3. **timerWidgetKeymap** (keymap):
|
||||
- `Backspace`: 检测左侧紧邻 timer span → 删除整个 span
|
||||
- `Delete`: 检测右侧紧邻 timer span → 删除整个 span
|
||||
- `ArrowLeft/ArrowRight`: 检测相邻 timer span → 跳过
|
||||
|
||||
### 2.9 TimePickerModal — 时间调整弹窗
|
||||
|
||||
**文件**: `src/ui/TimePickerModal.ts`
|
||||
|
||||
- iPhone 风格滚轮选择器,3 列(时/分/秒)
|
||||
- 支持 mouse wheel / touch drag / click 三种交互
|
||||
- 惯性动画(velocity tracking + momentum)
|
||||
- 选中项视觉高亮(放大 + 粗体 + 高对比度)
|
||||
- 确认回调返回总秒数,由 handleSetDuration 处理同步
|
||||
|
||||
### 2.10 Checkbox 联动
|
||||
|
||||
**实现位置**: `src/main.ts` 中 `toggleTimerbyCheckboxState()`
|
||||
|
||||
**编辑模式**:
|
||||
- EditorView.updateListener 检测文档变更
|
||||
- 逐个 ChangeSet 检查 checkbox 行
|
||||
- 提取旧/新 checkbox 状态字符(`/`、`x`、`-` 等)
|
||||
- 匹配 `runningCheckboxState` / `pausedCheckboxState` 配置
|
||||
|
||||
**预览模式**:
|
||||
- 注册 `pointerdown` DOM 事件
|
||||
- 检测点击目标是否为 checkbox input
|
||||
- 轮询文件内容变更(Vault API),确认状态变化后触发
|
||||
|
||||
**路径控制**:
|
||||
- `checkPathRestriction(filePath)`: 遍历 `checkboxPathGroups`
|
||||
- 每个组包含 whitelist/blacklist patterns
|
||||
- pattern 支持正则(`/pattern/`)和前缀匹配
|
||||
- 文件匹配**任意一个组**即可启用
|
||||
|
||||
### 2.11 设置面板
|
||||
|
||||
**文件**: `src/ui/TimerSettingTab.ts`
|
||||
|
||||
- 使用 Obsidian `PluginSettingTab` API
|
||||
- 4 个分区:基础 / Checkbox / 外观 / 状态栏
|
||||
- 文件组管理:折叠/展开卡片 + 动态增删 pattern 输入框
|
||||
- 颜色选择器:使用原生 `<input type="color">`
|
||||
- 修改立即 `saveSettings()` + `app.workspace.trigger('css-change')`
|
||||
|
||||
---
|
||||
|
||||
## 三、关键流程详解
|
||||
|
||||
### 3.1 创建计时器
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant P as TimerPlugin
|
||||
participant TDU as TimerDataUpdater
|
||||
participant FM as TimerFileManager
|
||||
participant M as TimerManager
|
||||
participant DB as TimerDatabase
|
||||
participant IDB as TimerIndexedDB
|
||||
|
||||
U->>P: toggle-timer (行内无 timer)
|
||||
P->>TDU: calculate('init')
|
||||
TDU-->>P: {class:'timer-r', timerId:'Xyz', dur:0, ts:now}
|
||||
P->>FM: writeTimer(id, data, view, file, lineNum)
|
||||
FM->>FM: render(data) → HTML span
|
||||
FM->>FM: editor.replaceRange() + addToHistory(false)
|
||||
FM->>FM: locations.set(id, {view,file,lineNum})
|
||||
P->>M: startTimer(id, data, onTick)
|
||||
P->>DB: updateEntry(id, {state:'running', ...})
|
||||
P->>DB: recordSessionStart(id)
|
||||
P->>IDB: putTimer(entry)
|
||||
```
|
||||
|
||||
### 3.2 暂停计时器
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant P as TimerPlugin
|
||||
participant TDU as TimerDataUpdater
|
||||
participant M as TimerManager
|
||||
participant DB as TimerDatabase
|
||||
participant IDB as TimerIndexedDB
|
||||
|
||||
P->>TDU: calculate('pause', oldData)
|
||||
TDU-->>P: {class:'timer-p', dur: old+elapsed, ts:now}
|
||||
P->>FM: writeTimer() — 更新 span
|
||||
P->>M: stopTimer(id) — 清除 setInterval
|
||||
P->>DB: updateEntry({state:'paused', total_dur_sec:newDur})
|
||||
P->>DB: computeRunningSessionSegments(id)
|
||||
DB-->>P: [{date, deltaSec}, ...]
|
||||
P->>DB: addDailyDurInMemory(id, date, delta)
|
||||
P->>DB: clearSessionStart(id)
|
||||
P->>IDB: patchTimer({state,total_dur_sec,last_ts})
|
||||
P->>IDB: addDailyDur(id, date, delta) — per segment
|
||||
```
|
||||
|
||||
### 3.3 每秒 Tick
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant M as TimerManager
|
||||
participant P as TimerPlugin
|
||||
participant TDU as TimerDataUpdater
|
||||
participant FM as TimerFileManager
|
||||
participant IDB as TimerIndexedDB
|
||||
participant DB as TimerDatabase
|
||||
|
||||
M->>P: onTick(timerId)
|
||||
P->>M: shouldSkipTick()?
|
||||
alt 后台 > 1s
|
||||
P-->>P: skip (return)
|
||||
end
|
||||
P->>M: runningTicks.has(id)?
|
||||
alt 已在执行
|
||||
P-->>P: skip (return)
|
||||
end
|
||||
P->>M: runningTicks.add(id)
|
||||
P->>TDU: calculate('update', oldData)
|
||||
P->>FM: updateTimerByIdWithSearch(id, newData)
|
||||
P->>IDB: tickUpdate(id, newDur, now, today, deltaSec)
|
||||
P->>DB: updateEntryInMemory(id, {total_dur_sec, last_ts})
|
||||
P->>DB: checkDayBoundary(id)
|
||||
alt 跨天
|
||||
P->>DB: addDailyDurInMemory(id, date, delta) — per segment
|
||||
P->>IDB: addDailyDur(id, date, delta)
|
||||
end
|
||||
P->>P: updateStatusBar()
|
||||
P->>M: runningTicks.delete(id)
|
||||
```
|
||||
|
||||
### 3.4 崩溃恢复
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant P as TimerPlugin
|
||||
participant DB as TimerDatabase
|
||||
participant IDB as TimerIndexedDB
|
||||
|
||||
Note over P: Plugin onload
|
||||
P->>DB: load()
|
||||
P->>DB: recoverCrashedTimers()
|
||||
DB->>DB: 扫描 state='running' 的 entries
|
||||
DB->>DB: crashDur = now - entry.last_ts
|
||||
DB->>DB: addDailyDurInMemory(id, date, crashDur)
|
||||
DB->>DB: entry.state = 'paused'
|
||||
DB-->>P: [{timerId, date, deltaSec}, ...]
|
||||
P->>DB: flush()
|
||||
P->>IDB: seedFromJSON()
|
||||
```
|
||||
|
||||
### 3.5 文件打开恢复
|
||||
|
||||
```
|
||||
restoreTimers():
|
||||
for each open tab:
|
||||
for each line in file:
|
||||
parsed = TimerParser.parse(line)
|
||||
if parsed.class === 'timer-r':
|
||||
switch autoStopTimers:
|
||||
'never' → handleRestore(id) // 恢复运行
|
||||
'quit' → if startedIds.has(id): handleRestore(id)
|
||||
else: handleForcePause(id)
|
||||
'close' → handleForcePause(id)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、性能优化策略
|
||||
|
||||
| 策略 | 实现 |
|
||||
|------|------|
|
||||
| 位置缓存 | `fileManager.locations` Map 缓存 timerId → lineNum,避免每秒全文搜索 |
|
||||
| 全文搜索兜底 | 缓存失效时 `findTimerGlobally()` 搜索所有打开标签页 |
|
||||
| tick 防重叠 | `runningTicks: Set` 阻止同一 timer 的并发 tick |
|
||||
| 后台节流 | `visibilitychange` + 1s 阈值 |
|
||||
| JSON 内存更新 | tick 只调用 `updateEntryInMemory()`,不触发 disk flush |
|
||||
| IDB 原子事务 | `tickUpdate()` 一个事务同时写 timers + daily_dur |
|
||||
| debounce flush | `scheduleFlush(100ms)` 合并多次写入 |
|
||||
| CM6 decoration 增量更新 | StateField 仅在文档/viewport 变更时重建 DecorationSet |
|
||||
|
||||
---
|
||||
|
||||
## 五、错误处理
|
||||
|
||||
| 场景 | 处理 |
|
||||
|------|------|
|
||||
| Markdown 解析失败 | TimerParser 返回 null → 调用方跳过 |
|
||||
| 缓存位置失效 | updateTimerByIdWithSearch fallback 到 findTimerGlobally |
|
||||
| 全文搜索仍未找到 | 记 lost 状态,不 crash |
|
||||
| JSON 文件损坏 | load() catch → 创建空数据库 |
|
||||
| IDB 不可用 | 所有 IDB 操作 catch → 静默降级 |
|
||||
| 预览模式无 editor | 走 vault.read/modify 路径 |
|
||||
|
||||
---
|
||||
|
||||
## 六、测试策略
|
||||
|
||||
- **纯函数单元测试**: TimerDataUpdater 的 7 种 action
|
||||
- **E2E 测试**: 基于 CDP 协议连接 Obsidian,模拟用户操作
|
||||
- **测试链**: basic → adjust → seed → delete → passive_delete → crossday → crossday_adjust
|
||||
57
doc/core-timer/stage5-arch-review.md
Normal file
57
doc/core-timer/stage5-arch-review.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# 🏛️ 架构 Review: Core Timer
|
||||
|
||||
**审查日期**: 2026-03-02 | **状态**: 补充归档 | **对应技术方案**: stage4-tech-design.md
|
||||
|
||||
---
|
||||
|
||||
## 一、审查清单
|
||||
|
||||
### 模块设计
|
||||
|
||||
- [x] **单一职责**: 每个模块职责明确 — TimerDataUpdater 只做计算,TimerManager 只管内存状态,TimerFileManager 只管文件 IO
|
||||
- [x] **依赖方向**: 单向依赖,核心层不依赖 UI 层/IO 层
|
||||
- [x] **接口清晰**: TimerData 接口贯穿全流程,模块间通过标准接口通信
|
||||
- [x] **无循环依赖**: TimerPlugin 作为协调者,其余模块互不直接依赖
|
||||
|
||||
### 数据架构
|
||||
|
||||
- [x] **三层一致性**: 状态变更同步三层(Markdown/JSON/IDB)
|
||||
- [x] **tick 性能优化**: 每秒只写 IDB + 内存,不触发 JSON flush
|
||||
- [x] **onunload 安全**: flushSync 确保正常关闭不丢数据
|
||||
- [x] **崩溃恢复**: recoverCrashedTimers 补偿异常退出期间的时长
|
||||
|
||||
### 扩展性
|
||||
|
||||
- [x] **设置可扩展**: TimerSettings 接口可增加字段,DEFAULT_SETTINGS 合并保障向后兼容
|
||||
- [x] **格式可升级**: v1→v2 迁移路径已验证
|
||||
- [x] **i18n 可扩展**: 增加新语言只需在 TRANSLATIONS 中添加 key
|
||||
|
||||
### 性能
|
||||
|
||||
- [x] **tick 并发安全**: runningTicks Set 防止重叠执行
|
||||
- [x] **后台节流**: visibilitychange 监听,后台跳过 tick
|
||||
- [x] **位置缓存**: 避免每秒全文搜索,降低 O(n) 到 O(1)
|
||||
- [x] **IDB 原子事务**: tickUpdate 一个事务完成两个 store 的更新
|
||||
|
||||
---
|
||||
|
||||
## 二、已知风险
|
||||
|
||||
| 风险 | 严重程度 | 缓解措施 |
|
||||
|------|----------|----------|
|
||||
| 位置缓存失效(用户编辑行号变化) | 中 | findTimerGlobally 全文搜索兜底 |
|
||||
| flushSync 使用 Node.js fs 模块 | 低 | 仅在 onunload 时调用,移动端降级为 async flush |
|
||||
| HTML 内嵌 Markdown 的兼容性 | 低 | 无法避免,是核心设计约束 |
|
||||
| 长时间后台运行 ts 漂移 | 低 | 后台跳过 tick,恢复前台时 ts 自动补偿 |
|
||||
|
||||
---
|
||||
|
||||
## 三、技术方案修正
|
||||
|
||||
无修正。技术方案基于实际代码编写,与实现一致。
|
||||
|
||||
---
|
||||
|
||||
## 四、结论
|
||||
|
||||
核心计时器的架构设计成熟稳健,职责分离清晰,数据一致性保障到位,性能优化措施全面。审查通过。
|
||||
99
doc/core-timer/stage6-implementation-plan.md
Normal file
99
doc/core-timer/stage6-implementation-plan.md
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# 📦 实施计划: Core Timer
|
||||
|
||||
**文档版本**: v1.0 | **创建日期**: 2026-03-02 | **状态**: 补充归档
|
||||
|
||||
> 本文档为已上线核心计时器功能的实施计划归档,描述实际的模块划分和文件分布。
|
||||
|
||||
---
|
||||
|
||||
## 一、模块分拆
|
||||
|
||||
### Module 1: 核心状态机与数据模型
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| `src/core/TimerDataUpdater.ts` | 纯函数状态机(init/continue/pause/update/restore/forcepause/setDuration) |
|
||||
| `src/core/TimerManager.ts` | 内存 timer 管理(Map + setInterval + 可见性监控 + tick 防重叠) |
|
||||
| `src/core/constants.ts` | 全局常量(UPDATE_INTERVAL、DEBUG 等) |
|
||||
| `src/core/utils.ts` | 工具函数(compressId 等) |
|
||||
|
||||
### Module 2: 持久化层
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| `src/core/TimerDatabase.ts` | JSON 文件持久化(CRUD、daily_dur、session tracking、崩溃恢复、跨天检测) |
|
||||
| `src/core/TimerIndexedDB.ts` | IndexedDB 热缓存(timers + daily_dur stores、tickUpdate 原子事务) |
|
||||
|
||||
### Module 3: IO 层
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| `src/io/TimerParser.ts` | HTML span 解析(v1/v2 双格式、DOM 解析) |
|
||||
| `src/io/TimerRenderer.ts` | HTML span 渲染(TimerData → HTML string) |
|
||||
| `src/io/TimerFileManager.ts` | 文件读写(位置缓存、编辑/预览双模式、旧格式升级、插入位置计算) |
|
||||
| `src/io/TimeFormatter.ts` | 时间格式化(full/smart 两种格式) |
|
||||
|
||||
### Module 4: UI 层
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| `src/ui/TimerWidget.ts` | CM6 Widget(folding field + cursor escape + keymap) |
|
||||
| `src/ui/TimePickerModal.ts` | 时间选择弹窗(iPhone 风格滚轮) |
|
||||
| `src/ui/TimerSettingTab.ts` | 设置面板(基础/Checkbox/外观/状态栏 4 个分区) |
|
||||
|
||||
### Module 5: 国际化
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| `src/i18n/translations.ts` | 翻译表(en/zh/zhTW/ja/ko)+ getTranslation() |
|
||||
|
||||
### Module 6: 主协调者
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| `src/main.ts` | TimerPlugin 主类(命令注册、事件监听、业务流程协调、Checkbox 联动、恢复/崩溃恢复、状态栏) |
|
||||
|
||||
---
|
||||
|
||||
## 二、模块依赖图
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
M6[main.ts] --> M1[核心状态机]
|
||||
M6 --> M2[持久化层]
|
||||
M6 --> M3[IO 层]
|
||||
M6 --> M4[UI 层]
|
||||
M6 --> M5[国际化]
|
||||
M3 --> M1
|
||||
M4 --> M5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、测试覆盖计划
|
||||
|
||||
| 模块 | 测试类型 | 现有覆盖 | 待补充 |
|
||||
|------|----------|----------|--------|
|
||||
| Module 1 (状态机) | E2E | ✅ basic chain | 无 |
|
||||
| Module 2 (持久化) | E2E | ✅ basic/adjust/seed/crossday | ✅ 需补充 IDB 验证 |
|
||||
| Module 3 (IO) | E2E | ✅ basic(写入验证) | 无 |
|
||||
| Module 4 (UI) | E2E | ⚠️ Widget 未测 | ✅ 需补充 Widget/设置面板 |
|
||||
| Module 5 (i18n) | — | ❌ 未测 | ✅ 待补充 |
|
||||
| Module 6 (协调者) | E2E | ✅ 多 chain 覆盖 | ✅ 需补充 Checkbox 联动/恢复 |
|
||||
|
||||
---
|
||||
|
||||
## 四、已有 E2E 测试链
|
||||
|
||||
| 测试链 | 覆盖范围 | 归属功能 |
|
||||
|--------|----------|----------|
|
||||
| preflight | 连接验证、环境准备 | 基础设施 |
|
||||
| basic | 创建/暂停/继续/tick/JSON 验证 | ✅ Core Timer |
|
||||
| adjust | 时间调整(增加/减少/daily_dur 联动) | ✅ Core Timer |
|
||||
| seed | IDB seeding/daily_dur 验证 | ✅ Core Timer |
|
||||
| crossday | 跨天边界检测 | ✅ Core Timer |
|
||||
| crossday_adjust | 跨天 + 时间调整 LIFO | ✅ Core Timer |
|
||||
| passive_delete | 被动删除/恢复/span class 一致性 | Delete Enhancement |
|
||||
| delete | 主动删除 | Delete Enhancement |
|
||||
| sidebar_tabs | Sidebar 标签页 | Sidebar |
|
||||
| readonly | 只读模式 | Sidebar |
|
||||
157
doc/core-timer/stage7-test-cases.md
Normal file
157
doc/core-timer/stage7-test-cases.md
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
# 🧪 测试用例: Core Timer
|
||||
|
||||
**文档版本**: v1.0 | **创建日期**: 2026-03-02 | **状态**: 补充归档
|
||||
|
||||
---
|
||||
|
||||
## 一、现有测试覆盖(已实现)
|
||||
|
||||
### Chain: preflight(环境验证)
|
||||
|
||||
| 用例 | 描述 | 验证点 |
|
||||
|------|------|--------|
|
||||
| PRE-01 | Plugin is loaded | 插件实例存在 |
|
||||
| PRE-02 | Active file is open in editor | 有打开的编辑器 |
|
||||
| PRE-03 | IDB is initialized | IndexedDB 已打开 |
|
||||
|
||||
### Chain: basic(基础生命周期 + 三层数据一致性)
|
||||
|
||||
| 用例 | 描述 | 验证点 |
|
||||
|------|------|--------|
|
||||
| B-01 | START timer on test line | Markdown 行内出现 timer-r span |
|
||||
| B-02 | IDB: timer entry exists with state=running | IDB timers store 有 running 记录 |
|
||||
| B-03 | IDB tickDelta: daily_dur accumulates ~1s/tick | 等待 3s,daily_dur delta ≈ 3s(±1s) |
|
||||
| B-04 | Status bar: shows running timer info | 状态栏显示 running 信息 |
|
||||
| B-05 | PAUSE timer | span 变为 timer-p,时长冻结 |
|
||||
| B-06 | IDB: timer state=paused after PAUSE | IDB state 为 paused |
|
||||
| B-07 | IDB: daily_dur not double-written on pause | pause 不会重复写 daily_dur |
|
||||
| B-08 | Cross-layer: JSON total_dur ≈ IDB total_dur (±2s) | JSON 和 IDB 时长一致 |
|
||||
| B-09 | Cross-layer: JSON daily_dur ≈ IDB daily_dur (±2s) | JSON 和 IDB 日统计一致 |
|
||||
| B-10 | CONTINUE timer | span 变为 timer-r,继续计时 |
|
||||
| B-11 | IDB: state=running after CONTINUE | IDB state 为 running |
|
||||
| B-12 | PAUSE again for duration adjustment test | 再次暂停 |
|
||||
|
||||
### Chain: adjust(时间调整)
|
||||
|
||||
| 用例 | 描述 | 验证点 |
|
||||
|------|------|--------|
|
||||
| ADJ-01 | Record pre-adjustment IDB state | 记录调整前 total_dur 和 daily_dur |
|
||||
| ADJ-02 | Manual set duration: decrease via handleSetDuration | 调用 handleSetDuration 减少时长 |
|
||||
| ADJ-03 | IDB: daily_dur adjusted correctly after decrease | daily_dur LIFO 扣除正确 |
|
||||
| ADJ-04 | CONTINUE for accumulation retest | 继续,验证减少后的基础正确 |
|
||||
| ADJ-05 | PAUSE after accumulation | 暂停,验证累计正确 |
|
||||
| ADJ-06 | IDB: no double-write after continue→pause cycle | 无重复写入 |
|
||||
|
||||
### Chain: seed(IDB seeding)
|
||||
|
||||
| 用例 | 描述 | 验证点 |
|
||||
|------|------|--------|
|
||||
| SEED-01 | seedIndexedDB: clearAll removes stale data on reload | 重新 seed 后 IDB 数据与 JSON 一致 |
|
||||
|
||||
### Chain: crossday(跨天处理)
|
||||
|
||||
| 用例 | 描述 | 验证点 |
|
||||
|------|------|--------|
|
||||
| CD-01 | Patch Date to yesterday 23:59:55 | 时间补丁成功 |
|
||||
| CD-02 | START timer at yesterday 23:59:55 | 计时器在昨天启动 |
|
||||
| CD-03 | IDB has yesterday daily_dur from ticks | 昨天的 daily_dur > 0 |
|
||||
| CD-04 | Patched time has crossed to today | 时间自然跨过午夜 |
|
||||
| CD-05 | checkDayBoundary detected — JSON has yesterday entry | JSON daily_dur 有昨天的条目 |
|
||||
| CD-06 | IDB daily_dur has BOTH yesterday and today entries | IDB 有两天的记录 |
|
||||
| CD-07 | Sidebar chart auto-splits BEFORE pause | Sidebar 图表自动拆分 |
|
||||
| CD-08 | PAUSE and verify total = sum of daily_dur | 总时长 = 各天之和 |
|
||||
| CD-09 | Sidebar chart still correct after pause | 暂停后图表不变 |
|
||||
| CD-10 | Restore original Date | 时间恢复 |
|
||||
| CD-11 | Cleanup — delete timer | 清理 |
|
||||
|
||||
### Chain: crossday_adjust(跨天 + 时间调整)
|
||||
|
||||
| 用例 | 描述 | 验证点 |
|
||||
|------|------|--------|
|
||||
| CDA-01 | Patch to day1 noon, start timer | 3 天前启动 |
|
||||
| CDA-02 | Cross day1→day2: checkDayBoundary splits | 跨第一天,day1 有记录 |
|
||||
| CDA-03 | Cross day2→day3: second boundary | 跨第二天,day2 有记录 |
|
||||
| CDA-04 | Pause: total = sum(day1+day2+day3) | 三天总和正确 |
|
||||
| CDA-05 | Decrease: LIFO deducts from day3 first | LIFO 从最近日期扣除 |
|
||||
| CDA-06 | Increase: delta added to today | 增加量加到 today |
|
||||
| CDA-07 | Cleanup | 清理 |
|
||||
|
||||
---
|
||||
|
||||
## 二、待补充测试用例
|
||||
|
||||
### Chain: checkbox(Checkbox 联动 — 新增)
|
||||
|
||||
| 用例 | 描述 | 验证点 | 优先级 |
|
||||
|------|------|--------|--------|
|
||||
| CB-01 | Checkbox `[ ]` → `[/]` 自动启动计时器 | 行内出现 timer-r span | P0 |
|
||||
| CB-02 | Checkbox `[/]` → `[x]` 自动暂停计时器 | span 变为 timer-p | P0 |
|
||||
| CB-03 | Checkbox `[x]` → `[/]` 恢复计时器 | span 变为 timer-r | P0 |
|
||||
| CB-04 | Checkbox `[ ]` → `[x]` 不创建计时器 | 行内无 timer span | P1 |
|
||||
| CB-05 | 非 Checkbox 行编辑不触发 | 已有 timer 状态不变 | P1 |
|
||||
| CB-06 | enableCheckboxToTimer=false 时 Checkbox 不触发 | 行内无 timer span | P1 |
|
||||
|
||||
### Chain: restore(文件打开恢复 — 新增)
|
||||
|
||||
| 用例 | 描述 | 验证点 | 优先级 |
|
||||
|------|------|--------|--------|
|
||||
| RST-01 | autoStopTimers='never': 运行中 timer 恢复运行 | manager.hasTimer()=true | P0 |
|
||||
| RST-02 | autoStopTimers='quit': 本次 session 启动的恢复 | startedIds 中的 timer 恢复 | P0 |
|
||||
| RST-03 | autoStopTimers='quit': 非本次 session 的强制暂停 | span 变为 timer-p | P0 |
|
||||
| RST-04 | autoStopTimers='close': 全部强制暂停 | 所有 timer span 为 timer-p | P0 |
|
||||
| RST-05 | 恢复后三层数据一致 | JSON/IDB/Markdown dur 一致 | P0 |
|
||||
|
||||
### Chain: crash_recovery(崩溃恢复 — 新增)
|
||||
|
||||
| 用例 | 描述 | 验证点 | 优先级 |
|
||||
|------|------|--------|--------|
|
||||
| CR-01 | 模拟崩溃:JSON 中 state=running 的 timer | recoverCrashedTimers 检测到 | P0 |
|
||||
| CR-02 | 崩溃恢复后 state 变为 paused | JSON entry.state === 'paused' | P0 |
|
||||
| CR-03 | 崩溃期间时长正确结算到 daily_dur | daily_dur[date][timerId] > 0 | P0 |
|
||||
| CR-04 | IDB 同步崩溃恢复结果 | IDB entry.state === 'paused' | P1 |
|
||||
|
||||
### Chain: settings(设置面板 — 新增)
|
||||
|
||||
| 用例 | 描述 | 验证点 | 优先级 |
|
||||
|------|------|--------|--------|
|
||||
| SET-01 | 修改 timerInsertLocation='tail' 后新建 timer | span 出现在行尾 | P1 |
|
||||
| SET-02 | 修改 timerInsertLocation='head' 后新建 timer | span 出现在行首(checkbox 后) | P1 |
|
||||
| SET-03 | 修改 runningIcon 后启动 timer | Widget 显示新图标 | P2 |
|
||||
| SET-04 | 修改 pausedIcon 后暂停 timer | Widget 显示新图标 | P2 |
|
||||
| SET-05 | 修改 timeDisplayFormat='smart' | Widget 时间格式改变 | P2 |
|
||||
|
||||
### Chain: onunload(正常退出结算 — 新增)
|
||||
|
||||
| 用例 | 描述 | 验证点 | 优先级 |
|
||||
|------|------|--------|--------|
|
||||
| UNL-01 | 调用 onunload 前有 running timer | manager.timers.size > 0 | P0 |
|
||||
| UNL-02 | onunload 后 JSON 中 state 仍为 running(不暂停) | JSON entry.state === 'running' | P0 |
|
||||
| UNL-03 | onunload 后 JSON 已 flushSync 到磁盘 | JSON 文件内容与内存一致 | P0 |
|
||||
|
||||
---
|
||||
|
||||
## 三、测试基础设施
|
||||
|
||||
### E2E 测试框架
|
||||
|
||||
- **连接方式**: Chrome DevTools Protocol (CDP)
|
||||
- **远程调试端口**: 9222
|
||||
- **运行命令**: `node tests/e2e-timer-test.mjs [chain_name...]`
|
||||
- **测试文件**: 使用 `_e2e_timer_test.md` 作为测试用 Markdown 文件
|
||||
- **超时**: 默认 10s per test case
|
||||
- **结果格式**: ✅ PASS / ❌ FAIL + 详细日志
|
||||
|
||||
### 辅助工具
|
||||
|
||||
- `runner.eval(expr)`: 在 Obsidian 上下文中执行 JS
|
||||
- `runner.run(name, fn)`: 运行单个测试用例
|
||||
- `runner.executeCommand(id)`: 执行 Obsidian 命令
|
||||
- `runner.setCursorToLine(n)`: 设置光标位置
|
||||
- `patchDate(offset)`: 模拟时间偏移(跨天测试用)
|
||||
- `sleep(ms)`: 异步等待
|
||||
|
||||
### 测试隔离
|
||||
|
||||
- 每个 chain 使用独立测试行(动态创建)
|
||||
- chain 间通过 `deps` 和 `cleanup` 保证执行顺序和清理
|
||||
- `cleanup_basic` chain 负责删除 basic chain 创建的测试行
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
|
||||
# 📋 PRD:删除计时器功能扩展(Delete Timer Enhancement)
|
||||
|
||||
**文档版本**: v1.0 | **创建日期**: 2026-03-02 | **状态**: 已审查
|
||||
|
||||
---
|
||||
|
||||
## 一、需求背景
|
||||
|
||||
Text Block Timer 插件目前已支持通过命令/右键菜单主动删除计时器(`delete-timer` 命令)。然而,用户在编辑 Markdown 文档时,可能通过以下方式**无意间删除**包含计时器的文本行:
|
||||
|
||||
- 选中多行后按 Delete/Backspace
|
||||
- 剪切(Ctrl+X)包含计时器的行
|
||||
- 在编辑过程中误删整行
|
||||
|
||||
这些"被动删除"场景下,计时器的 span 从文档中消失,但插件数据库中的记录仍然保留,导致**数据不一致**:Sidebar 仍显示已不存在的计时器,StatusBar 计数不准确。
|
||||
|
||||
同时,用户可能通过 Ctrl+Z(Undo)或粘贴(Ctrl+V)将被删除的计时器文本恢复回来,此时需要自动检测并恢复计时器的数据库状态。
|
||||
|
||||
## 二、需求目标
|
||||
|
||||
1. **自动检测被动删除**:当用户编辑导致 timer span 从文档中消失时,插件自动将该计时器标记为 `deleted` 状态
|
||||
2. **自动检测被动恢复**:当用户通过 Undo/粘贴使已删除的 timer span 重新出现时,插件自动将该计时器恢复为 `paused` 状态
|
||||
3. **Running 计时器结算**:如果被删除的计时器处于 running 状态,需先结算已累积的时长到 daily_dur
|
||||
4. **UI 即时同步**:删除/恢复后 Sidebar 和 StatusBar 立即更新
|
||||
|
||||
## 三、用户故事
|
||||
|
||||
### US-01: 编辑器被动删除检测
|
||||
> 作为用户,当我在编辑器中删除包含计时器的文本行时,我希望插件自动检测到这个变化,并将计时器标记为删除状态,使 Sidebar 和 StatusBar 同步更新。
|
||||
|
||||
### US-02: 编辑器被动恢复检测
|
||||
> 作为用户,当我通过 Ctrl+Z 撤销删除后,我希望计时器自动恢复为暂停状态(paused),而不是自动恢复为运行状态,以避免意外计时。
|
||||
|
||||
### US-03: Running 计时器删除时时长结算
|
||||
> 作为用户,当我删除一个正在运行的计时器时,我希望已经累积的计时时长被正确结算并记录到每日统计中,不会丢失任何已计的时间。
|
||||
|
||||
### US-04: 剪切粘贴不丢数据
|
||||
> 作为用户,当我剪切包含计时器的行并粘贴到同一文件的另一位置时,我希望计时器数据完整保留,位置信息更新为新位置。
|
||||
|
||||
## 四、功能范围
|
||||
|
||||
### In Scope
|
||||
|
||||
| 功能项 | 说明 |
|
||||
|--------|------|
|
||||
| CM6 被动删除检测 | 通过 `EditorView.updateListener` 检测 timer span 从文档中消失 |
|
||||
| CM6 被动恢复检测 | 通过 `EditorView.updateListener` 检测 deleted 状态的 timer span 重新出现 |
|
||||
| Running 计时器时长结算 | 被动删除 running 计时器前,调用 `computeRunningSessionSegments` 结算跨天时长 |
|
||||
| 三层数据同步 | JSON 内存层 → IDB 异步层 → UI 回调层,保持数据一致 |
|
||||
| Sidebar 即时同步 | 删除时移除卡片,恢复时添加卡片 |
|
||||
| StatusBar 即时同步 | 更新运行中计时器计数 |
|
||||
| 幂等性保障 | 所有删除/恢复操作前先检查当前 state,避免重复处理 |
|
||||
|
||||
### Out of Scope
|
||||
|
||||
| 排除项 | 理由 |
|
||||
|--------|------|
|
||||
| 新的 UI 组件 | 本需求为纯后台逻辑增强,不引入任何新 UI |
|
||||
| 删除确认弹窗 | 被动删除不应中断编辑流,不弹窗确认 |
|
||||
| Sidebar 删除/恢复过渡动画 | Nice to Have,推迟到后续统一优化 |
|
||||
| 恢复后 Notice 提示 | Nice to Have,推迟到后续统一优化 |
|
||||
| 跨文件剪切粘贴恢复 | 当前全量扫描机制已可处理,不在本期被动检测范围 |
|
||||
| 预览模式 | 预览模式下不编辑,不会触发被动删除 |
|
||||
| 外部修改 .md 文件 | 插件仅监控编辑器内的文档变更 |
|
||||
|
||||
## 五、功能详情
|
||||
|
||||
### 5.1 被动删除检测
|
||||
|
||||
**触发条件**:用户在编辑器中进行操作导致 timer span 文本从文档中消失。
|
||||
|
||||
**检测机制**:
|
||||
- 利用 CM6 `EditorView.updateListener` + `update.changes.iterChanges` 获取变更行范围,比较旧文档/新文档中的 timer span 差异
|
||||
- 仅扫描变更行范围,不做全文档扫描
|
||||
|
||||
**处理逻辑**:
|
||||
1. 对于 **paused** 计时器:直接标记为 `deleted`,更新三层数据,同步 UI
|
||||
2. 对于 **running** 计时器:
|
||||
- 先调用 `computeRunningSessionSegments` 结算跨天时长
|
||||
- 将时长写入 `daily_dur`(JSON + IDB)
|
||||
- 清除 session 状态
|
||||
- 停止 TimerManager 中的计时器
|
||||
- 标记为 `deleted`,同步 UI
|
||||
|
||||
### 5.2 被动恢复检测
|
||||
|
||||
**触发条件**:用户通过 Ctrl+Z(Undo)或粘贴使得已 `deleted` 状态的 timer span 重新出现在文档中。
|
||||
|
||||
**处理逻辑**:
|
||||
- 恢复为 **paused** 状态(不自动恢复为 running,避免意外计时)
|
||||
- 从 span 的 `data-dur` 和 `data-ts` 属性读取时长和时间戳作为权威数据
|
||||
- 更新 `file_path` 和 `line_num` 为当前位置
|
||||
- 不写入文件(span 已在文档中)
|
||||
- 同步 Sidebar 和 StatusBar
|
||||
|
||||
### 5.3 幂等性保障
|
||||
|
||||
所有删除/恢复操作前先检查 JSON 内存层中的当前 `state`:
|
||||
- 已是 `deleted` → 跳过删除操作
|
||||
- 不是 `deleted` → 跳过恢复操作
|
||||
- timer 不存在 → 跳过
|
||||
|
||||
### 5.4 三层数据同步
|
||||
|
||||
遵循现有的三层同步策略:
|
||||
1. **JSON 内存层**:同步更新(`updateEntry`)
|
||||
2. **IDB 异步层**:异步 fire-and-forget(`patchTimer` / `addDailyDur`)
|
||||
3. **UI 层**:同步回调(`notifySidebar*` / `updateStatusBar`)
|
||||
|
||||
**顺序约束**:JSON 内存层必须先更新,因为幂等检查依赖 JSON 内存层的同步读取。
|
||||
|
||||
## 六、验收标准
|
||||
|
||||
### AC-01: Paused 计时器被动删除
|
||||
- 编辑器中删除包含 paused timer span 的文本行后:
|
||||
- JSON state = `deleted`
|
||||
- IDB state = `deleted`
|
||||
- Sidebar 卡片消失
|
||||
- StatusBar 更新
|
||||
|
||||
### AC-02: Running 计时器被动删除 + 时长结算
|
||||
- 编辑器中删除包含 running timer span 的文本行后:
|
||||
- JSON state = `deleted`,`total_dur_sec > 0`
|
||||
- IDB state = `deleted`
|
||||
- daily_dur 有今天的时长记录
|
||||
- TimerManager 中该 timer 不再运行
|
||||
|
||||
### AC-03: Ctrl+Z 恢复 paused 计时器
|
||||
- 被动删除 paused timer 后执行 Undo:
|
||||
- JSON state = `paused`
|
||||
- IDB state = `paused`
|
||||
- Sidebar 卡片重新出现
|
||||
- 行文本包含 timer span
|
||||
|
||||
### AC-04: Ctrl+Z 恢复 running 计时器为 paused
|
||||
- 被动删除 running timer 后执行 Undo:
|
||||
- JSON state = `paused`(**不是** running)
|
||||
- TimerManager 中该 timer 不在运行
|
||||
|
||||
### AC-05: 剪切粘贴同文件
|
||||
- 剪切包含 timer 的行 → 剪切后 state = `deleted`
|
||||
- 粘贴到同文件其他位置 → 粘贴后 state = `paused`,`line_num` 更新为新位置
|
||||
|
||||
### AC-06: 多个计时器同时被动删除
|
||||
- 选中包含多个 timer 的多行文本并删除后,所有 timer 均为 `deleted`
|
||||
|
||||
### AC-07: 幂等性
|
||||
- 对已 `deleted` 的 timer 再次触发被动删除:无副作用,daily_dur 无新增
|
||||
- 对非 `deleted` 的 timer 触发被动恢复:无副作用
|
||||
|
||||
### AC-08: 非计时器行编辑无影响
|
||||
- 编辑不包含 timer 的行:所有 timer 状态不变,无异常
|
||||
|
||||
## 七、非功能需求
|
||||
|
||||
### 7.1 性能要求
|
||||
|
||||
| 指标 | 目标值 |
|
||||
|------|--------|
|
||||
| updateListener 单次执行 | < 2ms |
|
||||
| 被动删除总耗时 | < 10ms(JSON 同步 + IDB 异步) |
|
||||
| 被动恢复总耗时 | < 5ms |
|
||||
| 内存增长 | 0(无新的持久化数据结构) |
|
||||
|
||||
### 7.2 兼容性
|
||||
|
||||
- 与现有 `handleDelete`(主动删除)和 `handleRestore`(文件恢复)完全独立,互不影响
|
||||
- 与现有 checkbox-to-timer updateListener 共存,互不干扰
|
||||
- Obsidian 桌面端 + 移动端均支持
|
||||
|
||||
### 7.3 数据安全
|
||||
|
||||
- JSON 内存层同步更新确保崩溃后 `seedFromJSON` 可重建 IDB
|
||||
- `recoverCrashedTimers` 处理残留的 running 状态记录
|
||||
|
||||
## 八、UI/UX 约束
|
||||
|
||||
1. **不引入任何新 UI 组件**(无弹窗、无 Notice、无新按钮)
|
||||
2. **不中断编辑流**:被动删除静默处理,不弹出确认框
|
||||
3. **恢复后为 paused**:避免意外计时,用户需手动 continue
|
||||
4. 现有 Sidebar / StatusBar 的即时同步机制足以反馈状态变化
|
||||
|
||||
## 九、数据方案概述
|
||||
|
||||
**无 Schema 变更**。完全复用现有数据结构:
|
||||
- `TimerEntry.state` 已支持 `'deleted'` 枚举值
|
||||
- `daily_dur` 已支持按 timer_id 和日期写入时长
|
||||
- IDB store 无需新增/修改
|
||||
|
||||
## 十、技术实现要点
|
||||
|
||||
1. **检测机制**:利用 CM6 `EditorView.updateListener` + `update.changes.iterChanges` 获取变更行范围,比较旧文档/新文档中的 timer span 差异
|
||||
2. **轻量提取**:使用正则表达式(而非 DOM 解析)从文本中提取 timer ID,避免 updateListener 热路径的性能开销
|
||||
3. **恢复机制**:不复用现有 `handleRestore`(该方法恢复为 running 且需要 view 参数),实现独立的被动恢复方法
|
||||
4. **幂等保障**:所有删除/恢复操作前先检查 JSON 内存层中的当前 state
|
||||
5. **三层同步**:遵循 JSON → IDB → UI 的现有同步策略
|
||||
6. **仅扩展不修改**:不修改任何现有方法签名或行为,仅在 `TimerPlugin` 中新增方法
|
||||
|
||||
## 十一、风险与缓解
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|----------|
|
||||
| 大段文本粘贴/删除性能 | 低 | 正则扫描 1000 行文本 < 1ms |
|
||||
| 高频 Undo/Redo | 低 | 幂等检查确保不重复处理,每次 < 5ms |
|
||||
| IDB 写入失败 | 低 | 静默降级,下次启动 seedFromJSON 重建 |
|
||||
| 多面板重复触发 | 低 | 幂等检查基于 JSON 同步读取 |
|
||||
167
tests/chains/adjust.mjs
Normal file
167
tests/chains/adjust.mjs
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import { sleep, assert, log, WAIT_AFTER_COMMAND, TICK_TOLERANCE, idbGetTimer, idbGetDailyByTimer, shared } from '../e2e-timer-test.mjs';
|
||||
|
||||
// ─── Chain: adjust ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function chain_adjust(runner) {
|
||||
const today = shared.today;
|
||||
const testLineNum = shared.testLineNum;
|
||||
const createdTimerId = shared.createdTimerId;
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// PHASE 12: PAUSE again → test manual set duration + IDB adjustDailyDur
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
await runner.run('PAUSE again for duration adjustment test', async() => {
|
||||
await runner.setCursorToLine(testLineNum);
|
||||
await sleep(200);
|
||||
await runner.executeCommand('text-block-timer:toggle-timer');
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
const lineText = await runner.eval(`app.workspace.activeEditor.editor.getLine(${testLineNum})`);
|
||||
assert(lineText?.includes('class="timer-p"'), 'Expected timer-p');
|
||||
});
|
||||
|
||||
// Use shared for pre-adjust state
|
||||
|
||||
await runner.run('Record pre-adjustment IDB state', async() => {
|
||||
const timerRaw = await runner.evalAsync(idbGetTimer(createdTimerId));
|
||||
const timer = JSON.parse(timerRaw);
|
||||
shared.preAdjustDur = timer.total_dur_sec;
|
||||
|
||||
const dailyRaw = await runner.evalAsync(idbGetDailyByTimer(createdTimerId));
|
||||
const recs = JSON.parse(dailyRaw);
|
||||
shared.preAdjustDailySum = recs.reduce((s, r) => s + r.duration_sec, 0);
|
||||
|
||||
log('📊', `Pre-adjust: total_dur=${shared.preAdjustDur}s, daily_sum=${shared.preAdjustDailySum}s`);
|
||||
assert(shared.preAdjustDur > 0, 'Timer should have accumulated some duration');
|
||||
});
|
||||
|
||||
await runner.run('Manual set duration: decrease via plugin.handleSetDuration', async() => {
|
||||
const newDur = 3;
|
||||
// Call handleSetDuration directly in Obsidian runtime.
|
||||
// We inline-parse the timer span since TimerParser is not exposed on the plugin instance.
|
||||
await runner.evalAsync(`
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
const view = app.workspace.activeEditor;
|
||||
const editor = view.editor;
|
||||
const lineText = editor.getLine(${testLineNum});
|
||||
|
||||
// Inline parse: extract timer span data from HTML
|
||||
const tpl = document.createElement('template');
|
||||
tpl.innerHTML = lineText.trim();
|
||||
const el = tpl.content.querySelector('.timer-r, .timer-p');
|
||||
if (!el) throw new Error('No timer span found in line: ' + lineText);
|
||||
|
||||
const timerId = el.id;
|
||||
const cls = el.className;
|
||||
const dur = parseInt(el.dataset.dur || '0', 10);
|
||||
const ts = parseInt(el.dataset.ts || '0', 10);
|
||||
const project = el.dataset.project || null;
|
||||
|
||||
const regex = new RegExp('<span[^>]*id="' + timerId + '"[^>]*>.*?</span>');
|
||||
const match = lineText.match(regex);
|
||||
if (!match) throw new Error('Span regex match failed');
|
||||
|
||||
const parsed = {
|
||||
class: cls,
|
||||
timerId: timerId,
|
||||
dur: dur,
|
||||
ts: ts,
|
||||
project: project,
|
||||
beforeIndex: match.index,
|
||||
afterIndex: match.index + match[0].length
|
||||
};
|
||||
|
||||
p.handleSetDuration(view, ${testLineNum}, parsed, ${newDur});
|
||||
// Wait for async IDB writes to settle
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
`);
|
||||
await sleep(1000);
|
||||
});
|
||||
await runner.run('IDB: daily_dur adjusted correctly after decrease', async() => {
|
||||
const timerRaw = await runner.evalAsync(idbGetTimer(createdTimerId));
|
||||
const timer = JSON.parse(timerRaw);
|
||||
const dailyRaw = await runner.evalAsync(idbGetDailyByTimer(createdTimerId));
|
||||
const recs = JSON.parse(dailyRaw);
|
||||
const dailySum = recs.reduce((s, r) => s + r.duration_sec, 0);
|
||||
|
||||
log('📊', `Post-adjust: total_dur=${timer.total_dur_sec}s, daily_sum=${dailySum}s`);
|
||||
// total_dur should be 3
|
||||
assert(timer.total_dur_sec === 3, `Expected total_dur=3, got ${timer.total_dur_sec}`);
|
||||
// daily_sum should also be 3 (adjusted to match)
|
||||
assert(dailySum === 3, `Expected daily_sum=3 after adjustment, got ${dailySum}`);
|
||||
});
|
||||
|
||||
await runner.run('Sidebar: shows adjusted duration after manual set', async() => {
|
||||
// Trigger sidebar re-render
|
||||
await runner.evalAsync(`
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (leaves.length) {
|
||||
const view = leaves[0].view;
|
||||
view.onTimerDurChanged('${createdTimerId}', 3);
|
||||
}
|
||||
`);
|
||||
await sleep(500);
|
||||
|
||||
const cardDurText = await runner.eval(`(() => {
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (!leaves.length) return '';
|
||||
const c = leaves[0].view.containerEl;
|
||||
const cards = c.querySelectorAll('.timer-card');
|
||||
for (const card of cards) {
|
||||
const fileEl = card.querySelector('.timer-card-file-source');
|
||||
if (fileEl?.textContent?.includes(':${testLineNum + 1}')) {
|
||||
return card.querySelector('.timer-card-duration')?.textContent ?? '';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
})()`);
|
||||
log('📊', `Sidebar card dur text after adjust: "${cardDurText}"`);
|
||||
assert(cardDurText.length > 0, 'Timer card duration text should be visible');
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// PHASE 13: CONTINUE → PAUSE → verify IDB daily_dur accumulates correctly
|
||||
// (not double-counted because tick handles it)
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
await runner.run('CONTINUE for accumulation retest', async() => {
|
||||
await runner.setCursorToLine(testLineNum);
|
||||
await sleep(200);
|
||||
await runner.executeCommand('text-block-timer:toggle-timer');
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
const lineText = await runner.eval(`app.workspace.activeEditor.editor.getLine(${testLineNum})`);
|
||||
assert(lineText?.includes('class="timer-r"'), 'Expected timer-r');
|
||||
});
|
||||
|
||||
log('⏳', 'Waiting 4s for tick accumulation...');
|
||||
await sleep(4000);
|
||||
|
||||
await runner.run('PAUSE after accumulation', async() => {
|
||||
await runner.setCursorToLine(testLineNum);
|
||||
await sleep(200);
|
||||
await runner.executeCommand('text-block-timer:toggle-timer');
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
const lineText = await runner.eval(`app.workspace.activeEditor.editor.getLine(${testLineNum})`);
|
||||
assert(lineText?.includes('class="timer-p"'), 'Expected timer-p');
|
||||
});
|
||||
|
||||
await runner.run('IDB: no double-write after continue→pause cycle', async() => {
|
||||
const timerRaw = await runner.evalAsync(idbGetTimer(createdTimerId));
|
||||
const timer = JSON.parse(timerRaw);
|
||||
const dailyRaw = await runner.evalAsync(idbGetDailyByTimer(createdTimerId));
|
||||
const recs = JSON.parse(dailyRaw);
|
||||
const dailySum = recs.reduce((s, r) => s + r.duration_sec, 0);
|
||||
|
||||
const drift = Math.abs(dailySum - timer.total_dur_sec);
|
||||
log('📊', `After cycle: total_dur=${timer.total_dur_sec}s, daily_sum=${dailySum}s, drift=${drift}s`);
|
||||
// daily_sum should track total_dur closely — if double-write occurred, daily >> total
|
||||
assert(drift <= TICK_TOLERANCE,
|
||||
`daily_dur sum (${dailySum}) drifts from total_dur (${timer.total_dur_sec}) by ${drift}s. ` +
|
||||
`Drift >> 2 indicates double-write on pause.`);
|
||||
// total_dur should have increased from the 3s we set it to
|
||||
assert(timer.total_dur_sec > 3,
|
||||
`total_dur should have increased from 3s after running, got ${timer.total_dur_sec}s`);
|
||||
});
|
||||
}
|
||||
|
||||
432
tests/chains/basic.mjs
Normal file
432
tests/chains/basic.mjs
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
import { sleep, assert, log, WAIT_AFTER_COMMAND, WAIT_TICK_ACCUMULATE, TICK_TOLERANCE, idbGetTimer, idbGetDailyByTimer, idbGetDailyByDate, idbGetTimersByState, shared } from '../e2e-timer-test.mjs';
|
||||
|
||||
// ─── Chain: basic ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function chain_basic(runner) {
|
||||
const today = shared.today;
|
||||
const initial = shared.initial;
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// PHASE 1: Insert test line & create timer
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
shared.testLineNum = await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
const last = ed.lineCount() - 1;
|
||||
ed.replaceRange('\\nE2E_SIDEBAR_TEST_' + Date.now(), { line: last, ch: ed.getLine(last).length });
|
||||
const n = ed.lineCount() - 1;
|
||||
ed.setCursor({ line: n, ch: 0 });
|
||||
return n;
|
||||
})()`);
|
||||
const testLineNum = shared.testLineNum;
|
||||
log('📝', `Inserted test line at L${testLineNum}`);
|
||||
await sleep(500);
|
||||
|
||||
await runner.run('START timer on test line', async() => {
|
||||
await runner.setCursorToLine(testLineNum);
|
||||
await sleep(200);
|
||||
await runner.executeCommand('text-block-timer:toggle-timer');
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
const lineText = await runner.eval(`app.workspace.activeEditor.editor.getLine(${testLineNum})`);
|
||||
assert(lineText?.includes('class="timer-r"'), `Expected timer-r span, got: "${lineText}"`);
|
||||
|
||||
const match = lineText.match(/id="([^"]+)"/);
|
||||
assert(match, 'Timer ID not found');
|
||||
shared.createdTimerId = match[1];
|
||||
log('🆔', `Timer ID: ${shared.createdTimerId}`);
|
||||
});
|
||||
|
||||
const createdTimerId = shared.createdTimerId;
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// PHASE 2: IDB seeded correctly after START
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
await runner.run('IDB: timer entry exists with state=running after START', async() => {
|
||||
const raw = await runner.evalAsync(idbGetTimer(createdTimerId));
|
||||
assert(raw, 'Timer not found in IDB');
|
||||
const entry = JSON.parse(raw);
|
||||
assert(entry.state === 'running', `Expected state=running, got ${entry.state}`);
|
||||
assert(entry.file_path === initial.filePath, `file_path mismatch`);
|
||||
assert(entry.total_dur_sec >= 0, `total_dur_sec should be >= 0`);
|
||||
log('📊', `IDB timer: state=${entry.state}, dur=${entry.total_dur_sec}s`);
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// PHASE 3: Tick accumulation — IDB tickDelta correctness (core fix)
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
await runner.run('IDB tickDelta: daily_dur accumulates ~1s per tick', async() => {
|
||||
// Read IDB daily_dur before waiting
|
||||
const beforeRaw = await runner.evalAsync(idbGetDailyByTimer(createdTimerId));
|
||||
const beforeRecs = JSON.parse(beforeRaw);
|
||||
const beforeDur = beforeRecs.find(r => r.stat_date === today)?.duration_sec ?? 0;
|
||||
|
||||
// Also read IDB timer total_dur_sec
|
||||
const beforeTimerRaw = await runner.evalAsync(idbGetTimer(createdTimerId));
|
||||
const beforeTimer = JSON.parse(beforeTimerRaw);
|
||||
const beforeTotalDur = beforeTimer.total_dur_sec;
|
||||
|
||||
// Wait for ticks
|
||||
const waitSec = WAIT_TICK_ACCUMULATE / 1000;
|
||||
log('⏳', `Waiting ${waitSec}s for tick accumulation...`);
|
||||
await sleep(WAIT_TICK_ACCUMULATE);
|
||||
|
||||
// Read IDB daily_dur after waiting
|
||||
const afterRaw = await runner.evalAsync(idbGetDailyByTimer(createdTimerId));
|
||||
const afterRecs = JSON.parse(afterRaw);
|
||||
const afterDur = afterRecs.find(r => r.stat_date === today)?.duration_sec ?? 0;
|
||||
|
||||
// Read IDB timer total_dur_sec after
|
||||
const afterTimerRaw = await runner.evalAsync(idbGetTimer(createdTimerId));
|
||||
const afterTimer = JSON.parse(afterTimerRaw);
|
||||
const afterTotalDur = afterTimer.total_dur_sec;
|
||||
|
||||
const dailyIncrease = afterDur - beforeDur;
|
||||
const totalIncrease = afterTotalDur - beforeTotalDur;
|
||||
const minExpected = waitSec - TICK_TOLERANCE;
|
||||
|
||||
log('📊', `IDB daily_dur increase: ${dailyIncrease}s (expected ~${waitSec}s)`);
|
||||
log('📊', `IDB total_dur increase: ${totalIncrease}s (expected ~${waitSec}s)`);
|
||||
|
||||
assert(dailyIncrease >= minExpected,
|
||||
`daily_dur increase ${dailyIncrease}s too small (expected >= ${minExpected}s). ` +
|
||||
`This was the core tickDelta bug — if ~0, the fix is not working.`);
|
||||
assert(totalIncrease >= minExpected,
|
||||
`total_dur increase ${totalIncrease}s too small (expected >= ${minExpected}s)`);
|
||||
|
||||
// daily_dur should approximately match total_dur increase
|
||||
const drift = Math.abs(dailyIncrease - totalIncrease);
|
||||
assert(drift <= TICK_TOLERANCE,
|
||||
`daily_dur/total_dur drift: ${drift}s (tolerance ${TICK_TOLERANCE}s)`);
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// PHASE 4: Sidebar list — data from IDB (running timer visible)
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
await runner.run('Sidebar list: running timer card visible with correct data', async() => {
|
||||
const state = JSON.parse(await runner.eval(`(() => {
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (!leaves.length) return JSON.stringify({ exists: false });
|
||||
const c = leaves[0].view.containerEl;
|
||||
const cards = c.querySelectorAll('.timer-card');
|
||||
const runCards = c.querySelectorAll('.timer-card-running');
|
||||
const ourEl = c.querySelector('[data-timer-id="${createdTimerId}"]');
|
||||
return JSON.stringify({
|
||||
exists: true,
|
||||
totalCards: cards.length,
|
||||
runningCards: runCards.length,
|
||||
ourTimerVisible: !!ourEl,
|
||||
ourDurText: ourEl?.textContent ?? ''
|
||||
});
|
||||
})()`));
|
||||
assert(state.exists, 'Sidebar not found');
|
||||
assert(state.ourTimerVisible, 'Our timer card not found in sidebar (data-timer-id element missing)');
|
||||
assert(state.runningCards >= 1, `Expected >= 1 running card, got ${state.runningCards}`);
|
||||
log('📊', `Sidebar: ${state.totalCards} total cards, ${state.runningCards} running, dur="${state.ourDurText}"`);
|
||||
});
|
||||
|
||||
await runner.run('Sidebar summary: running count >= 1', async() => {
|
||||
const state = JSON.parse(await runner.eval(`(() => {
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (!leaves.length) return JSON.stringify({});
|
||||
const c = leaves[0].view.containerEl;
|
||||
return JSON.stringify({
|
||||
count: c.querySelector('[data-summary-count]')?.textContent ?? '0',
|
||||
running: c.querySelector('[data-summary-running-count]')?.textContent ?? '0',
|
||||
paused: c.querySelector('[data-summary-paused-count]')?.textContent ?? '0',
|
||||
totalDur: c.querySelector('[data-summary-total]')?.textContent ?? '',
|
||||
runningDur: c.querySelector('[data-summary-running]')?.textContent ?? ''
|
||||
});
|
||||
})()`));
|
||||
assert(parseInt(state.running) >= 1, `Running count should be >= 1, got ${state.running}`);
|
||||
assert(parseInt(state.count) >= 1, `Total count should be >= 1, got ${state.count}`);
|
||||
log('📊', `Summary: total=${state.count}, running=${state.running}, paused=${state.paused}`);
|
||||
log('📊', `Summary dur: total="${state.totalDur}", running="${state.runningDur}"`);
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// PHASE 5: Sidebar list dur matches IDB dur (real-time consistency)
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
await runner.run('Sidebar card dur matches IDB timer.total_dur_sec (±2s)', async() => {
|
||||
// Read IDB timer dur
|
||||
const idbRaw = await runner.evalAsync(idbGetTimer(createdTimerId));
|
||||
const idbEntry = JSON.parse(idbRaw);
|
||||
const idbDur = idbEntry.total_dur_sec;
|
||||
|
||||
// Read sidebar card dur via TimerManager (sidebar patches from manager)
|
||||
const managerDur = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
const d = p.manager.getTimerData('${createdTimerId}');
|
||||
return d ? d.dur : -1;
|
||||
})()`);
|
||||
|
||||
const diff = Math.abs(idbDur - managerDur);
|
||||
log('📊', `IDB dur=${idbDur}s, Manager dur=${managerDur}s, diff=${diff}s`);
|
||||
assert(diff <= TICK_TOLERANCE,
|
||||
`IDB/Manager dur mismatch: IDB=${idbDur}, Manager=${managerDur}, diff=${diff}s`);
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// PHASE 6: Status bar shows running timer info
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
await runner.run('Status bar: shows running timer info', async() => {
|
||||
const text = await runner.eval(`
|
||||
app.plugins.plugins['text-block-timer'].statusBarItem?.textContent ?? ''
|
||||
`);
|
||||
assert(text.length > 0, 'Status bar is empty');
|
||||
log('📊', `Status bar: "${text}"`);
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// PHASE 7: PAUSE timer → verify IDB state + sidebar updates
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
|
||||
|
||||
await runner.run('PAUSE timer', async() => {
|
||||
await runner.setCursorToLine(testLineNum);
|
||||
await sleep(200);
|
||||
await runner.executeCommand('text-block-timer:toggle-timer');
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
const lineText = await runner.eval(`app.workspace.activeEditor.editor.getLine(${testLineNum})`);
|
||||
assert(lineText?.includes('class="timer-p"'), `Expected timer-p, got: "${lineText}"`);
|
||||
|
||||
const durMatch = lineText.match(/data-dur="(\d+)"/);
|
||||
assert(durMatch, 'data-dur not found');
|
||||
shared.pausedDur = parseInt(durMatch[1], 10);
|
||||
assert(shared.pausedDur > 0, `Expected dur > 0, got ${shared.pausedDur}`);
|
||||
log('⏱', `Paused with dur=${shared.pausedDur}s`);
|
||||
});
|
||||
|
||||
await runner.run('IDB: timer state=paused after PAUSE', async() => {
|
||||
const raw = await runner.evalAsync(idbGetTimer(createdTimerId));
|
||||
assert(raw, 'Timer not in IDB after pause');
|
||||
const entry = JSON.parse(raw);
|
||||
assert(entry.state === 'paused', `Expected paused, got ${entry.state}`);
|
||||
// IDB total_dur_sec should match editor dur (±2s because tick timing)
|
||||
const diff = Math.abs(entry.total_dur_sec - shared.pausedDur);
|
||||
assert(diff <= TICK_TOLERANCE,
|
||||
`IDB dur ${entry.total_dur_sec} vs editor dur ${shared.pausedDur}, diff=${diff}s`);
|
||||
log('📊', `IDB after pause: state=${entry.state}, dur=${entry.total_dur_sec}s`);
|
||||
});
|
||||
|
||||
await runner.run('IDB: daily_dur not double-written on pause (no pause addDailyDur)', async() => {
|
||||
// After pause, IDB daily_dur should be consistent with total_dur_sec
|
||||
const dailyRaw = await runner.evalAsync(idbGetDailyByTimer(createdTimerId));
|
||||
const dailyRecs = JSON.parse(dailyRaw);
|
||||
const dailySum = dailyRecs.reduce((s, r) => s + r.duration_sec, 0);
|
||||
|
||||
const timerRaw = await runner.evalAsync(idbGetTimer(createdTimerId));
|
||||
const timer = JSON.parse(timerRaw);
|
||||
|
||||
const drift = Math.abs(dailySum - timer.total_dur_sec);
|
||||
log('📊', `IDB daily_sum=${dailySum}s, total_dur=${timer.total_dur_sec}s, drift=${drift}s`);
|
||||
assert(drift <= TICK_TOLERANCE,
|
||||
`IDB daily_dur sum (${dailySum}) drifts from total_dur (${timer.total_dur_sec}) by ${drift}s. ` +
|
||||
`If dailySum >> total, pause likely double-wrote daily_dur.`);
|
||||
});
|
||||
|
||||
await runner.run('Sidebar list: timer card changed to paused', async() => {
|
||||
const state = JSON.parse(await runner.eval(`(() => {
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (!leaves.length) return JSON.stringify({});
|
||||
const c = leaves[0].view.containerEl;
|
||||
const runningEl = c.querySelector('[data-timer-id="${createdTimerId}"]');
|
||||
const pausedCards = c.querySelectorAll('.timer-card-paused');
|
||||
return JSON.stringify({
|
||||
runningElGone: !runningEl,
|
||||
pausedCount: pausedCards.length
|
||||
});
|
||||
})()`));
|
||||
assert(state.runningElGone, 'Running data-timer-id element should disappear after pause');
|
||||
assert(state.pausedCount >= 1, `Expected >= 1 paused card, got ${state.pausedCount}`);
|
||||
});
|
||||
|
||||
await runner.run('Sidebar dur EXACTLY matches document dur after pause (0s tolerance)', async() => {
|
||||
// Read document dur
|
||||
const docDur = await runner.eval(`(() => {
|
||||
const lineText = app.workspace.activeEditor.editor.getLine(${testLineNum});
|
||||
const match = lineText.match(/data-dur="(\\d+)"/);
|
||||
return match ? parseInt(match[1], 10) : -1;
|
||||
})()`);
|
||||
assert(docDur > 0, 'Document dur should be > 0');
|
||||
|
||||
// Read sidebar in-memory timerList dur for our timer
|
||||
const sidebarDur = await runner.eval(`(() => {
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (!leaves.length) return -1;
|
||||
const view = leaves[0].view;
|
||||
const timer = view.timerList ? view.timerList.find(t => t.timerId === '${createdTimerId}') : null;
|
||||
return timer ? timer.dur : -1;
|
||||
})()`);
|
||||
|
||||
// Read IDB dur
|
||||
const idbRaw = await runner.evalAsync(idbGetTimer(createdTimerId));
|
||||
const idbDur = idbRaw ? JSON.parse(idbRaw).total_dur_sec : -1;
|
||||
|
||||
// Read JSON dur
|
||||
const jsonDur = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
const e = p.database.data?.timers?.['${createdTimerId}'];
|
||||
return e ? e.total_dur_sec : -1;
|
||||
})()`);
|
||||
|
||||
log('📊', `After pause exact: doc=${docDur}, sidebar=${sidebarDur}, IDB=${idbDur}, JSON=${jsonDur}`);
|
||||
|
||||
// Sidebar dur must EXACTLY match document dur (this is the fix for the 1s drift)
|
||||
assert(sidebarDur === docDur,
|
||||
`Sidebar dur (${sidebarDur}) must exactly match document dur (${docDur}). ` +
|
||||
`If off by 1s, the fix for passing newDur through onTimerStateChanged is not working.`);
|
||||
|
||||
// JSON dur must also exactly match document dur
|
||||
assert(jsonDur === docDur,
|
||||
`JSON dur (${jsonDur}) must exactly match document dur (${docDur}).`);
|
||||
});
|
||||
|
||||
await runner.run('Sidebar summary: running count decreased after pause', async() => {
|
||||
const running = await runner.eval(`(() => {
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (!leaves.length) return '-1';
|
||||
return leaves[0].view.containerEl.querySelector('[data-summary-running-count]')?.textContent ?? '-1';
|
||||
})()`);
|
||||
log('📊', `Running count after pause: ${running}`);
|
||||
// We don't know how many other timers exist, but count should be consistent
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// PHASE 8: JSON ↔ IDB cross-layer consistency after PAUSE
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
await runner.run('Cross-layer: JSON total_dur ≈ IDB total_dur (±2s)', async() => {
|
||||
await sleep(500); // wait for flush
|
||||
|
||||
const jsonDur = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
const e = p.database.data?.timers?.['${createdTimerId}'];
|
||||
return e ? e.total_dur_sec : -1;
|
||||
})()`);
|
||||
|
||||
const idbRaw = await runner.evalAsync(idbGetTimer(createdTimerId));
|
||||
const idbEntry = JSON.parse(idbRaw);
|
||||
|
||||
const diff = Math.abs(jsonDur - idbEntry.total_dur_sec);
|
||||
log('📊', `JSON dur=${jsonDur}s, IDB dur=${idbEntry.total_dur_sec}s, diff=${diff}s`);
|
||||
assert(diff <= TICK_TOLERANCE,
|
||||
`JSON/IDB total_dur mismatch: JSON=${jsonDur}, IDB=${idbEntry.total_dur_sec}`);
|
||||
});
|
||||
|
||||
await runner.run('Cross-layer: JSON daily_dur ≈ IDB daily_dur (±2s)', async() => {
|
||||
const jsonDailyDur = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
const dd = p.database.data?.daily_dur ?? {};
|
||||
let sum = 0;
|
||||
for (const [date, timerMap] of Object.entries(dd)) {
|
||||
if (timerMap['${createdTimerId}'] !== undefined) {
|
||||
sum += timerMap['${createdTimerId}'];
|
||||
}
|
||||
}
|
||||
return sum;
|
||||
})()`);
|
||||
|
||||
const idbDailyRaw = await runner.evalAsync(idbGetDailyByTimer(createdTimerId));
|
||||
const idbDailyRecs = JSON.parse(idbDailyRaw);
|
||||
const idbDailySum = idbDailyRecs.reduce((s, r) => s + r.duration_sec, 0);
|
||||
|
||||
const diff = Math.abs(jsonDailyDur - idbDailySum);
|
||||
log('📊', `JSON daily_sum=${jsonDailyDur}s, IDB daily_sum=${idbDailySum}s, diff=${diff}s`);
|
||||
assert(diff <= TICK_TOLERANCE,
|
||||
`JSON/IDB daily_dur sum mismatch: JSON=${jsonDailyDur}, IDB=${idbDailySum}`);
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// PHASE 9: CONTINUE timer → verify IDB + sidebar
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
await runner.run('CONTINUE timer', async() => {
|
||||
await runner.setCursorToLine(testLineNum);
|
||||
await sleep(200);
|
||||
await runner.executeCommand('text-block-timer:toggle-timer');
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
const lineText = await runner.eval(`app.workspace.activeEditor.editor.getLine(${testLineNum})`);
|
||||
assert(lineText?.includes('class="timer-r"'), `Expected timer-r after continue`);
|
||||
});
|
||||
|
||||
await runner.run('IDB: state=running after CONTINUE', async() => {
|
||||
const raw = await runner.evalAsync(idbGetTimer(createdTimerId));
|
||||
const entry = JSON.parse(raw);
|
||||
assert(entry.state === 'running', `Expected running, got ${entry.state}`);
|
||||
});
|
||||
|
||||
await runner.run('Sidebar: running card reappears after CONTINUE', async() => {
|
||||
const visible = await runner.eval(`(() => {
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (!leaves.length) return false;
|
||||
return !!leaves[0].view.containerEl.querySelector('[data-timer-id="${createdTimerId}"]');
|
||||
})()`);
|
||||
assert(visible, 'Timer card should reappear as running in sidebar');
|
||||
});
|
||||
|
||||
// Let it run a bit more to accumulate more daily_dur
|
||||
log('⏳', 'Waiting 3s for more tick accumulation...');
|
||||
await sleep(3000);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// PHASE 10: Sidebar chart data correctness (reads from IDB)
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
await runner.run('Sidebar chart: IDB daily_dur has today entry with dur > 0', async() => {
|
||||
const raw = await runner.evalAsync(idbGetDailyByDate(today));
|
||||
const recs = JSON.parse(raw);
|
||||
const ourRec = recs.find(r => r.timer_id === createdTimerId);
|
||||
assert(ourRec, `No IDB daily_dur record for today (${today}) for our timer`);
|
||||
assert(ourRec.duration_sec > 0, `Expected duration_sec > 0, got ${ourRec.duration_sec}`);
|
||||
log('📊', `IDB daily_dur today: ${ourRec.duration_sec}s`);
|
||||
});
|
||||
|
||||
await runner.run('Sidebar chart: chartDataCache reflects IDB data', async() => {
|
||||
// Force chart re-render by toggling statistics
|
||||
const chartData = await runner.evalAsync(`
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (!leaves.length) return null;
|
||||
const view = leaves[0].view;
|
||||
if (view.chartDataCache) {
|
||||
return JSON.stringify({
|
||||
projects: view.chartDataCache.projects,
|
||||
dates: view.chartDataCache.dates,
|
||||
hasTodayDate: view.chartDataCache.dates.includes('${today}')
|
||||
});
|
||||
}
|
||||
return null;
|
||||
`);
|
||||
if (chartData) {
|
||||
const data = JSON.parse(chartData);
|
||||
log('📊', `Chart cache: projects=${JSON.stringify(data.projects)}, dates=${JSON.stringify(data.dates)}`);
|
||||
// Today should be in the dates array if our timer has daily_dur data
|
||||
assert(data.hasTodayDate, `Chart dates should include today (${today})`);
|
||||
} else {
|
||||
log('⚠️', 'Chart data cache not available — statistics may be hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// PHASE 11: Sidebar loadAllData reads from IDB (scope=all)
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
await runner.run('Sidebar loadAllData: IDB getTimersByState returns our timer', async() => {
|
||||
const raw = await runner.evalAsync(idbGetTimersByState(['running', 'paused']));
|
||||
const entries = JSON.parse(raw);
|
||||
const ours = entries.find(e => e.timer_id === createdTimerId);
|
||||
assert(ours, 'Our timer not found in IDB getTimersByState(running,paused)');
|
||||
assert(ours.state === 'running', `Expected running, got ${ours.state}`);
|
||||
log('📊', `IDB getTimersByState: ${entries.length} entries, our state=${ours.state}`);
|
||||
});
|
||||
}
|
||||
|
||||
207
tests/chains/checkbox.mjs
Normal file
207
tests/chains/checkbox.mjs
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import { sleep, assert, log, WAIT_AFTER_COMMAND } from '../e2e-timer-test.mjs';
|
||||
|
||||
// ─── Chain: checkbox ─────────────────────────────────────────────────────────
|
||||
// Tests Checkbox toggle → timer start/pause/continue automation
|
||||
|
||||
export async function chain_checkbox(runner) {
|
||||
let cbTimerId = null;
|
||||
|
||||
// Create a fresh test line with a checkbox
|
||||
const cbTestLine = await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
const last = ed.lineCount() - 1;
|
||||
ed.replaceRange('\\n- [ ] E2E_CB_TEST_' + Date.now(), { line: last, ch: ed.getLine(last).length });
|
||||
const n = ed.lineCount() - 1;
|
||||
ed.setCursor({ line: n, ch: 0 });
|
||||
return n;
|
||||
})()`);
|
||||
log('📝', `Checkbox test line at L${cbTestLine}`);
|
||||
await sleep(500);
|
||||
|
||||
// Ensure checkbox-to-timer is enabled with default symbols
|
||||
await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
p.settings.enableCheckboxToTimer = true;
|
||||
p.settings.runningCheckboxState = '/';
|
||||
p.settings.pausedCheckboxState = '-xX';
|
||||
p.settings.checkboxPathGroups = [];
|
||||
return true;
|
||||
})()`);
|
||||
|
||||
// CB-01: Checkbox [ ] → [/] should auto-start a timer
|
||||
await runner.run('CB-01: Checkbox [ ]→[/] auto-starts timer', async() => {
|
||||
// Simulate changing checkbox from [ ] to [/]
|
||||
await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
const line = ed.getLine(${cbTestLine});
|
||||
const newLine = line.replace('- [ ] ', '- [/] ');
|
||||
ed.replaceRange(newLine, { line: ${cbTestLine}, ch: 0 }, { line: ${cbTestLine}, ch: line.length });
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
const lineText = await runner.eval(`app.workspace.activeEditor.editor.getLine(${cbTestLine})`);
|
||||
assert(lineText.includes('class="timer-r"'), `Expected timer-r span after [/], got: "${lineText?.substring(0, 100)}"`);
|
||||
|
||||
const match = lineText.match(/id="([^"]+)"/);
|
||||
assert(match, 'Timer ID not found');
|
||||
cbTimerId = match[1];
|
||||
log('🆔', `Checkbox timer ID: ${cbTimerId}`);
|
||||
});
|
||||
|
||||
// CB-02: Checkbox [/] → [x] should auto-pause the timer
|
||||
await runner.run('CB-02: Checkbox [/]→[x] auto-pauses timer', async() => {
|
||||
await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
const line = ed.getLine(${cbTestLine});
|
||||
const newLine = line.replace('- [/] ', '- [x] ');
|
||||
ed.replaceRange(newLine, { line: ${cbTestLine}, ch: 0 }, { line: ${cbTestLine}, ch: line.length });
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
const lineText = await runner.eval(`app.workspace.activeEditor.editor.getLine(${cbTestLine})`);
|
||||
assert(lineText.includes('class="timer-p"'), `Expected timer-p span after [x], got: "${lineText?.substring(0, 100)}"`);
|
||||
log('✅', 'Timer paused by checkbox [x]');
|
||||
});
|
||||
|
||||
// CB-03: Checkbox [x] → [/] should resume the timer
|
||||
await runner.run('CB-03: Checkbox [x]→[/] resumes timer', async() => {
|
||||
await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
const line = ed.getLine(${cbTestLine});
|
||||
const newLine = line.replace('- [x] ', '- [/] ');
|
||||
ed.replaceRange(newLine, { line: ${cbTestLine}, ch: 0 }, { line: ${cbTestLine}, ch: line.length });
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
const lineText = await runner.eval(`app.workspace.activeEditor.editor.getLine(${cbTestLine})`);
|
||||
assert(lineText.includes('class="timer-r"'), `Expected timer-r span after resume [/], got: "${lineText?.substring(0, 100)}"`);
|
||||
log('✅', 'Timer resumed by checkbox [/]');
|
||||
});
|
||||
|
||||
// Pause for next test
|
||||
await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
const line = ed.getLine(${cbTestLine});
|
||||
const newLine = line.replace('- [/] ', '- [x] ');
|
||||
ed.replaceRange(newLine, { line: ${cbTestLine}, ch: 0 }, { line: ${cbTestLine}, ch: line.length });
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
// CB-04: Checkbox [ ] → [x] should NOT create a timer (paused symbol, no existing timer on new line)
|
||||
await runner.run('CB-04: Checkbox [ ]→[x] on new line does not create timer', async() => {
|
||||
const newLine2 = await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
const last = ed.lineCount() - 1;
|
||||
ed.replaceRange('\\n- [ ] E2E_CB_NOOP_' + Date.now(), { line: last, ch: ed.getLine(last).length });
|
||||
return ed.lineCount() - 1;
|
||||
})()`);
|
||||
await sleep(300);
|
||||
|
||||
// Change [ ] directly to [x]
|
||||
await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
const line = ed.getLine(${newLine2});
|
||||
const newLine = line.replace('- [ ] ', '- [x] ');
|
||||
ed.replaceRange(newLine, { line: ${newLine2}, ch: 0 }, { line: ${newLine2}, ch: line.length });
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
const lineText = await runner.eval(`app.workspace.activeEditor.editor.getLine(${newLine2})`);
|
||||
assert(!lineText.includes('class="timer-r"') && !lineText.includes('class="timer-p"'),
|
||||
`Expected no timer span on direct [x], got: "${lineText?.substring(0, 100)}"`);
|
||||
log('✅', 'No timer created for direct [ ]→[x]');
|
||||
|
||||
// Cleanup noop line
|
||||
await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
for (let i = ed.lineCount() - 1; i >= 0; i--) {
|
||||
if (ed.getLine(i).includes('E2E_CB_NOOP_')) {
|
||||
const from = Math.max(0, i - 1);
|
||||
const fromCh = i > 0 ? ed.getLine(from).length : 0;
|
||||
ed.replaceRange('', { line: from, ch: fromCh }, { line: i, ch: ed.getLine(i).length });
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})()`);
|
||||
});
|
||||
|
||||
// CB-05: enableCheckboxToTimer=false disables all checkbox automation
|
||||
await runner.run('CB-05: enableCheckboxToTimer=false disables automation', async() => {
|
||||
// Disable
|
||||
await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
p.settings.enableCheckboxToTimer = false;
|
||||
return true;
|
||||
})()`);
|
||||
|
||||
const newLine3 = await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
const last = ed.lineCount() - 1;
|
||||
ed.replaceRange('\\n- [ ] E2E_CB_DISABLED_' + Date.now(), { line: last, ch: ed.getLine(last).length });
|
||||
return ed.lineCount() - 1;
|
||||
})()`);
|
||||
await sleep(300);
|
||||
|
||||
// Change [ ] → [/] with checkbox disabled
|
||||
await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
const line = ed.getLine(${newLine3});
|
||||
const newLine = line.replace('- [ ] ', '- [/] ');
|
||||
ed.replaceRange(newLine, { line: ${newLine3}, ch: 0 }, { line: ${newLine3}, ch: line.length });
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
const lineText = await runner.eval(`app.workspace.activeEditor.editor.getLine(${newLine3})`);
|
||||
assert(!lineText.includes('class="timer-r"') && !lineText.includes('class="timer-p"'),
|
||||
`Expected no timer when disabled, got: "${lineText?.substring(0, 100)}"`);
|
||||
log('✅', 'No timer created when enableCheckboxToTimer=false');
|
||||
|
||||
// Re-enable and cleanup
|
||||
await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
p.settings.enableCheckboxToTimer = true;
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
for (let i = ed.lineCount() - 1; i >= 0; i--) {
|
||||
if (ed.getLine(i).includes('E2E_CB_DISABLED_')) {
|
||||
const from = Math.max(0, i - 1);
|
||||
const fromCh = i > 0 ? ed.getLine(from).length : 0;
|
||||
ed.replaceRange('', { line: from, ch: fromCh }, { line: i, ch: ed.getLine(i).length });
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})()`);
|
||||
});
|
||||
|
||||
// Cleanup: delete checkbox timer and remove test line
|
||||
if (cbTimerId) {
|
||||
await runner.eval(`(async () => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
await p.database.updateEntry('${cbTimerId}', { state: 'deleted' });
|
||||
await p.idb.patchTimer('${cbTimerId}', { state: 'deleted' });
|
||||
return true;
|
||||
})()`);
|
||||
}
|
||||
await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
for (let i = ed.lineCount() - 1; i >= 0; i--) {
|
||||
if (ed.getLine(i).includes('E2E_CB_TEST_')) {
|
||||
const from = Math.max(0, i - 1);
|
||||
const fromCh = i > 0 ? ed.getLine(from).length : 0;
|
||||
ed.replaceRange('', { line: from, ch: fromCh }, { line: i, ch: ed.getLine(i).length });
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(500);
|
||||
log('🧹', 'Checkbox test line cleaned up');
|
||||
}
|
||||
|
||||
35
tests/chains/cleanup_basic.mjs
Normal file
35
tests/chains/cleanup_basic.mjs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { sleep, log } from '../e2e-timer-test.mjs';
|
||||
|
||||
// ─── Chain: cleanup_basic ────────────────────────────────────────────────────
|
||||
// Cleanup for the basic/adjust/seed/delete chains (removes E2E_SIDEBAR_TEST_ line)
|
||||
|
||||
export async function chain_cleanup_basic(runner) {
|
||||
// Restore background tick throttle
|
||||
await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
if (p.manager._origBgThreshold !== undefined) {
|
||||
p.manager.backgroundThreshold = p.manager._origBgThreshold;
|
||||
delete p.manager._origBgThreshold;
|
||||
}
|
||||
return true;
|
||||
})()`);
|
||||
log('⚙️', 'Restored background tick throttle');
|
||||
|
||||
await runner.run('Cleanup: remove test line', async() => {
|
||||
await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
for (let i = ed.lineCount() - 1; i >= 0; i--) {
|
||||
if (ed.getLine(i).includes('E2E_SIDEBAR_TEST_')) {
|
||||
const from = Math.max(0, i - 1);
|
||||
const fromCh = i > 0 ? ed.getLine(from).length : 0;
|
||||
ed.replaceRange('', { line: from, ch: fromCh }, { line: i, ch: ed.getLine(i).length });
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(500);
|
||||
log('🧹', 'Test line removed');
|
||||
});
|
||||
}
|
||||
|
||||
89
tests/chains/crash_recovery.mjs
Normal file
89
tests/chains/crash_recovery.mjs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { assert, log } from '../e2e-timer-test.mjs';
|
||||
|
||||
// ─── Chain: crash_recovery ───────────────────────────────────────────────────
|
||||
// Tests crash recovery: simulates crashed state in JSON, then triggers recovery
|
||||
|
||||
export async function chain_crash_recovery(runner) {
|
||||
const crTestId = 'E2ECR' + Date.now().toString(36).slice(-4);
|
||||
const crDate = new Date().toLocaleDateString('sv');
|
||||
|
||||
// CR-01: Inject a fake "crashed" timer entry into JSON (state=running)
|
||||
await runner.run('CR-01: Inject fake crashed timer (state=running) into JSON', async() => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
// Inject a timer that was "running" 10 seconds ago (simulating crash)
|
||||
const crashTs = now - 10;
|
||||
await runner.evalAsync(`
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
await p.database.updateEntry('${crTestId}', {
|
||||
timer_id: '${crTestId}',
|
||||
file_path: 'fake/crash-test.md',
|
||||
line_num: 0,
|
||||
line_text: 'crash test',
|
||||
project: null,
|
||||
state: 'running',
|
||||
total_dur_sec: 100,
|
||||
last_ts: ${crashTs},
|
||||
created_at: ${crashTs - 100},
|
||||
updated_at: ${crashTs}
|
||||
});
|
||||
await p.database.flush();
|
||||
return true;
|
||||
`);
|
||||
|
||||
const entry = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
const e = p.database.getEntry('${crTestId}');
|
||||
return e ? JSON.stringify(e) : null;
|
||||
})()`);
|
||||
assert(entry, 'Crashed timer should exist in JSON');
|
||||
const parsed = JSON.parse(entry);
|
||||
assert(parsed.state === 'running', `Expected state=running, got ${parsed.state}`);
|
||||
log('📊', `Injected crashed timer: id=${crTestId}, state=${parsed.state}, last_ts=${parsed.last_ts}`);
|
||||
});
|
||||
|
||||
// CR-02: Call recoverCrashedTimers and verify state changes to paused
|
||||
await runner.run('CR-02: recoverCrashedTimers changes state to paused', async() => {
|
||||
const recoveries = await runner.evalAsync(`
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
const result = p.database.recoverCrashedTimers();
|
||||
await p.database.flush();
|
||||
return JSON.stringify(result);
|
||||
`);
|
||||
const parsed = JSON.parse(recoveries);
|
||||
const ours = parsed.find(r => r.timerId === crTestId);
|
||||
assert(ours, 'Our timer should be in recovered list');
|
||||
assert(ours.deltaSec >= 9 && ours.deltaSec <= 30,
|
||||
`Expected crash delta ~10s, got ${ours.deltaSec}s`);
|
||||
log('📊', `Crash recovery: deltaSec=${ours.deltaSec}, date=${ours.date}`);
|
||||
|
||||
// Verify state is now paused
|
||||
const entry = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
const e = p.database.getEntry('${crTestId}');
|
||||
return e ? JSON.stringify(e) : null;
|
||||
})()`);
|
||||
const entryParsed = JSON.parse(entry);
|
||||
assert(entryParsed.state === 'paused', `Expected state=paused after recovery, got ${entryParsed.state}`);
|
||||
});
|
||||
|
||||
// CR-03: daily_dur has crash duration recorded
|
||||
await runner.run('CR-03: daily_dur has crash duration recorded', async() => {
|
||||
const dailyDur = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
const dd = p.database.getDailyDur();
|
||||
return dd?.['${crDate}']?.['${crTestId}'] ?? 0;
|
||||
})()`);
|
||||
assert(dailyDur >= 9, `Expected daily_dur >= 9s for crash recovery, got ${dailyDur}s`);
|
||||
log('📊', `Crash recovery daily_dur: ${dailyDur}s`);
|
||||
});
|
||||
|
||||
// Cleanup: remove fake timer
|
||||
await runner.evalAsync(`
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
await p.database.updateEntry('${crTestId}', { state: 'deleted' });
|
||||
await p.database.flush();
|
||||
return true;
|
||||
`);
|
||||
log('🧹', 'Crash recovery test cleaned up');
|
||||
}
|
||||
|
||||
335
tests/chains/crossday.mjs
Normal file
335
tests/chains/crossday.mjs
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
import { sleep, assert, log, WAIT_AFTER_COMMAND, TICK_TOLERANCE, idbGetTimer, idbGetDailyByTimer } from '../e2e-timer-test.mjs';
|
||||
|
||||
// ─── Chain: crossday ─────────────────────────────────────────────────────────
|
||||
// Uses Date monkey-patch to shift time to yesterday 23:59:55.
|
||||
// Since the offset is fixed and added to real Date.now(), real wall-clock
|
||||
// passage of ~8s naturally advances the patched time past midnight into today.
|
||||
// No second Date patch needed!
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function chain_crossday(runner) {
|
||||
|
||||
let crossDayTimerId = null;
|
||||
const crossDayTestLineNum = await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
const last = ed.lineCount() - 1;
|
||||
ed.replaceRange('\\nE2E_CROSSDAY_TEST_' + Date.now(), { line: last, ch: ed.getLine(last).length });
|
||||
const n = ed.lineCount() - 1;
|
||||
ed.setCursor({ line: n, ch: 0 });
|
||||
return n;
|
||||
})()`);
|
||||
log('📝', `Cross-day test line at L${crossDayTestLineNum}`);
|
||||
await sleep(500);
|
||||
|
||||
// Helper: install Date monkey-patch in Obsidian runtime
|
||||
// offsetMs is a FIXED value added to real Date.now(), so real wall-clock
|
||||
// passage naturally advances the patched time as well.
|
||||
async function patchDate(offsetMs) {
|
||||
await runner.eval(`(() => {
|
||||
if (!window.__OrigDate) window.__OrigDate = Date;
|
||||
const OD = window.__OrigDate;
|
||||
const offset = ${offsetMs};
|
||||
function FakeDate(...args) {
|
||||
if (args.length === 0) return new OD(OD.now() + offset);
|
||||
return new OD(...args);
|
||||
}
|
||||
FakeDate.now = () => OD.now() + offset;
|
||||
FakeDate.parse = OD.parse.bind(OD);
|
||||
FakeDate.UTC = OD.UTC.bind(OD);
|
||||
FakeDate.prototype = OD.prototype;
|
||||
Date = FakeDate;
|
||||
return true;
|
||||
})()`);
|
||||
}
|
||||
|
||||
// Helper: restore original Date
|
||||
async function restoreDate() {
|
||||
await runner.eval(`(() => {
|
||||
if (window.__OrigDate) { Date = window.__OrigDate; delete window.__OrigDate; }
|
||||
return true;
|
||||
})()`);
|
||||
}
|
||||
|
||||
// Calculate fixed offset to place patched time at yesterday 23:59:55.
|
||||
// As real seconds pass, patched time naturally advances:
|
||||
// +0s → yesterday 23:59:55
|
||||
// +5s → today 00:00:00 (midnight!)
|
||||
// +8s → today 00:00:03
|
||||
const nowRealMs = Date.now();
|
||||
const realToday = new Date().toLocaleDateString('sv');
|
||||
const realYesterday = new Date(Date.now() - 86400000).toLocaleDateString('sv');
|
||||
const targetYesterday235955 = new Date(realYesterday + 'T23:59:55').getTime();
|
||||
const offsetMs = targetYesterday235955 - nowRealMs;
|
||||
|
||||
log('🕐', `Fixed offset: ${offsetMs}ms (places patched time at ${realYesterday} 23:59:55)`);
|
||||
log('🕐', `Yesterday: ${realYesterday}, Today: ${realToday}`);
|
||||
log('🕐', `Plan: patch once → start timer → real ~5s → midnight crosses → real ~5s more → verify both days`);
|
||||
|
||||
await runner.run('Cross-day: patch Date to yesterday 23:59:55 (single patch)', async() => {
|
||||
await patchDate(offsetMs);
|
||||
const fakeTime = await runner.eval(`new Date().toISOString()`);
|
||||
const fakeDate = await runner.eval(`new Date().toLocaleDateString('sv')`);
|
||||
log('🕐', `Patched time: ${fakeTime}, date: ${fakeDate}`);
|
||||
assert(fakeDate === realYesterday,
|
||||
`Expected date to be ${realYesterday}, got ${fakeDate}`);
|
||||
});
|
||||
|
||||
await runner.run('Cross-day: START timer at yesterday 23:59:55', async() => {
|
||||
await runner.setCursorToLine(crossDayTestLineNum);
|
||||
await sleep(200);
|
||||
await runner.executeCommand('text-block-timer:toggle-timer');
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
const lineText = await runner.eval(`app.workspace.activeEditor.editor.getLine(${crossDayTestLineNum})`);
|
||||
assert(lineText.includes('class="timer-r"'), 'Expected timer-r span');
|
||||
const match = lineText.match(/id="([^"]+)"/);
|
||||
assert(match, 'Timer ID not found');
|
||||
crossDayTimerId = match[1];
|
||||
log('🆔', `Cross-day timer ID: ${crossDayTimerId}`);
|
||||
});
|
||||
|
||||
// Let timer tick for ~3s while patched date is still "yesterday"
|
||||
log('⏳', 'Letting timer run ~3s in "yesterday" time...');
|
||||
await sleep(3000);
|
||||
|
||||
await runner.run('Cross-day: verify IDB has yesterday daily_dur from ticks', async() => {
|
||||
const dailyRaw = await runner.evalAsync(idbGetDailyByTimer(crossDayTimerId));
|
||||
const recs = JSON.parse(dailyRaw);
|
||||
log('📊', `IDB daily_dur records after yesterday ticks: ${JSON.stringify(recs)}`);
|
||||
|
||||
const yesterdayRec = recs.find(r => r.stat_date === realYesterday);
|
||||
assert(yesterdayRec, `IDB should have yesterday (${realYesterday}) record from ticks`);
|
||||
assert(yesterdayRec.duration_sec >= 2,
|
||||
`Yesterday IDB dur should be >= 2s, got ${yesterdayRec.duration_sec}s`);
|
||||
});
|
||||
|
||||
// Now just WAIT for real wall-clock to pass midnight in the patched time.
|
||||
// We started at 23:59:55, so after ~5s real time → patched time crosses midnight.
|
||||
// We need ~5s more after that for today ticks to accumulate.
|
||||
// Total extra wait from start: already ~3s (above) + ~2s (toggle wait) = ~5s already.
|
||||
// So patched time is now ~23:59:55 + 5 = ~00:00:00. Need ~5s more for today ticks.
|
||||
log('⏳', 'Waiting ~5s for patched time to cross midnight and accumulate today ticks...');
|
||||
await sleep(5000);
|
||||
|
||||
// Verify the patched time is now "today"
|
||||
await runner.run('Cross-day: verify patched time has naturally crossed to today', async() => {
|
||||
const fakeDate = await runner.eval(`new Date().toLocaleDateString('sv')`);
|
||||
const fakeTime = await runner.eval(`new Date().toISOString()`);
|
||||
log('🕐', `Current patched time: ${fakeTime}, date: ${fakeDate}`);
|
||||
assert(fakeDate === realToday,
|
||||
`Expected patched date to naturally advance to ${realToday}, but got ${fakeDate}. ` +
|
||||
`The fixed-offset approach means real time passage should cross midnight automatically.`);
|
||||
});
|
||||
|
||||
await runner.run('Cross-day: checkDayBoundary detected — JSON has yesterday entry', async() => {
|
||||
const result = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
const dd = p.database.data?.daily_dur ?? {};
|
||||
const yesterdayDur = dd['${realYesterday}']?.['${crossDayTimerId}'] ?? 0;
|
||||
const todayDur = dd['${realToday}']?.['${crossDayTimerId}'] ?? 0;
|
||||
return JSON.stringify({ yesterdayDur, todayDur });
|
||||
})()`);
|
||||
const data = JSON.parse(result);
|
||||
log('📊', `JSON daily_dur: yesterday(${realYesterday})=${data.yesterdayDur}s, today(${realToday})=${data.todayDur}s`);
|
||||
assert(data.yesterdayDur > 0,
|
||||
`Yesterday JSON daily_dur should be > 0, got ${data.yesterdayDur}s`);
|
||||
// Note: today JSON daily_dur may be 0 until handlePause writes it
|
||||
log('📊', `Today JSON daily_dur = ${data.todayDur}s (may be 0 until pause writes it)`);
|
||||
});
|
||||
|
||||
await runner.run('Cross-day: IDB daily_dur has BOTH yesterday and today entries', async() => {
|
||||
const dailyRaw = await runner.evalAsync(idbGetDailyByTimer(crossDayTimerId));
|
||||
const recs = JSON.parse(dailyRaw);
|
||||
log('📊', `IDB daily_dur records after midnight crossing:`);
|
||||
for (const rec of recs) {
|
||||
log('📊', ` ${rec.stat_date}: ${rec.duration_sec}s`);
|
||||
}
|
||||
|
||||
const yesterdayRec = recs.find(r => r.stat_date === realYesterday);
|
||||
const todayRec = recs.find(r => r.stat_date === realToday);
|
||||
|
||||
assert(yesterdayRec, `IDB should have yesterday (${realYesterday}) record`);
|
||||
assert(yesterdayRec.duration_sec >= 2,
|
||||
`Yesterday IDB dur should be >= 2s, got ${yesterdayRec.duration_sec}s`);
|
||||
assert(todayRec, `IDB should have today (${realToday}) record`);
|
||||
assert(todayRec.duration_sec >= 2,
|
||||
`Today IDB dur should be >= 2s, got ${todayRec.duration_sec}s`);
|
||||
});
|
||||
|
||||
// ── Verify chart auto-splits across days WHILE timer is still running ──
|
||||
// This verifies the fix: refreshChartRunningTimers() now triggers a full
|
||||
// re-render when today is a new date not in chartDataCache.dates.
|
||||
await runner.run('Cross-day: sidebar chart auto-splits BEFORE pause (running timer)', async() => {
|
||||
// Trigger a full sidebar refresh so refreshChartRunningTimers gets called
|
||||
await runner.evalAsync(`
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (leaves.length) {
|
||||
const view = leaves[0].view;
|
||||
view.refreshRunningTimers();
|
||||
}
|
||||
`);
|
||||
// Wait for async chart re-render to complete
|
||||
await sleep(2000);
|
||||
|
||||
const chartInfo = await runner.eval(`(() => {
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (!leaves.length) return JSON.stringify({ skip: true });
|
||||
const view = leaves[0].view;
|
||||
if (!view.chartDataCache) return JSON.stringify({ skip: true, reason: 'no cache' });
|
||||
return JSON.stringify({
|
||||
skip: false,
|
||||
dates: view.chartDataCache.dates,
|
||||
hasYesterday: view.chartDataCache.dates.includes('${realYesterday}'),
|
||||
hasToday: view.chartDataCache.dates.includes('${realToday}'),
|
||||
projects: view.chartDataCache.projects
|
||||
});
|
||||
})()`);
|
||||
if (chartInfo) {
|
||||
const info = JSON.parse(chartInfo);
|
||||
if (!info.skip) {
|
||||
log('📊', `Chart dates (BEFORE pause, timer still running): ${JSON.stringify(info.dates)}`);
|
||||
assert(info.hasYesterday, `Chart should include yesterday (${realYesterday}) even before pause`);
|
||||
assert(info.hasToday,
|
||||
`Chart should include today (${realToday}) even before pause — ` +
|
||||
`if this fails, refreshChartRunningTimers() is not re-rendering on new day`);
|
||||
} else {
|
||||
log('⚠️', `Chart data not available (${info.reason ?? 'statistics hidden'}) — skipping`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await runner.run('Cross-day: PAUSE and verify total = sum of daily_dur across days', async() => {
|
||||
await runner.setCursorToLine(crossDayTestLineNum);
|
||||
await sleep(200);
|
||||
await runner.executeCommand('text-block-timer:toggle-timer');
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
const lineText = await runner.eval(`app.workspace.activeEditor.editor.getLine(${crossDayTestLineNum})`);
|
||||
assert(lineText.includes('class="timer-p"'), 'Expected timer-p after pause');
|
||||
|
||||
const durMatch = lineText.match(/data-dur="(\d+)"/);
|
||||
const docDur = durMatch ? parseInt(durMatch[1], 10) : -1;
|
||||
|
||||
// Get IDB timer total
|
||||
const timerRaw = await runner.evalAsync(idbGetTimer(crossDayTimerId));
|
||||
const idbTimer = JSON.parse(timerRaw);
|
||||
|
||||
// Get IDB daily_dur sum across all days
|
||||
const dailyRaw = await runner.evalAsync(idbGetDailyByTimer(crossDayTimerId));
|
||||
const dailyRecs = JSON.parse(dailyRaw);
|
||||
const idbDailySum = dailyRecs.reduce((s, r) => s + r.duration_sec, 0);
|
||||
|
||||
// Get JSON daily_dur sum across all days
|
||||
const jsonDailySum = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
const dd = p.database.data?.daily_dur ?? {};
|
||||
let sum = 0;
|
||||
for (const [date, timerMap] of Object.entries(dd)) {
|
||||
if (timerMap['${crossDayTimerId}'] !== undefined) {
|
||||
sum += timerMap['${crossDayTimerId}'];
|
||||
}
|
||||
}
|
||||
return sum;
|
||||
})()`);
|
||||
|
||||
// Detailed per-date breakdown for debugging
|
||||
for (const rec of dailyRecs) {
|
||||
log('📊', ` IDB daily_dur: ${rec.stat_date} = ${rec.duration_sec}s`);
|
||||
}
|
||||
const jsonPerDate = JSON.parse(await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
const dd = p.database.data?.daily_dur ?? {};
|
||||
const result = {};
|
||||
for (const [date, timerMap] of Object.entries(dd)) {
|
||||
if (timerMap['${crossDayTimerId}'] !== undefined) {
|
||||
result[date] = timerMap['${crossDayTimerId}'];
|
||||
}
|
||||
}
|
||||
return JSON.stringify(result);
|
||||
})()`));
|
||||
for (const [date, dur] of Object.entries(jsonPerDate)) {
|
||||
log('📊', ` JSON daily_dur: ${date} = ${dur}s`);
|
||||
}
|
||||
|
||||
log('📊', `Cross-day totals: doc=${docDur}s, IDB total=${idbTimer.total_dur_sec}s, IDB daily_sum=${idbDailySum}s, JSON daily_sum=${jsonDailySum}s`);
|
||||
|
||||
// IDB total should match doc dur
|
||||
const totalDrift = Math.abs(idbTimer.total_dur_sec - docDur);
|
||||
assert(totalDrift <= TICK_TOLERANCE,
|
||||
`IDB total (${idbTimer.total_dur_sec}) vs doc (${docDur}), drift=${totalDrift}s`);
|
||||
|
||||
// IDB daily_sum should match total (no double-counting)
|
||||
const idbDrift = Math.abs(idbDailySum - idbTimer.total_dur_sec);
|
||||
assert(idbDrift <= TICK_TOLERANCE,
|
||||
`IDB daily_sum (${idbDailySum}) vs total (${idbTimer.total_dur_sec}), drift=${idbDrift}s — if large, double-counting bug!`);
|
||||
|
||||
// JSON daily_sum should roughly match doc dur
|
||||
const jsonDrift = Math.abs(jsonDailySum - docDur);
|
||||
assert(jsonDrift <= TICK_TOLERANCE,
|
||||
`JSON daily_sum (${jsonDailySum}) vs doc (${docDur}), drift=${jsonDrift}s`);
|
||||
|
||||
// Must have records for 2 different dates
|
||||
assert(dailyRecs.length >= 2,
|
||||
`Expected IDB daily_dur for >= 2 dates, got ${dailyRecs.length}`);
|
||||
});
|
||||
|
||||
await runner.run('Cross-day: sidebar chart still correct after pause', async() => {
|
||||
const chartInfo = await runner.eval(`(() => {
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (!leaves.length) return JSON.stringify({ skip: true });
|
||||
const view = leaves[0].view;
|
||||
if (!view.chartDataCache) return JSON.stringify({ skip: true });
|
||||
return JSON.stringify({
|
||||
skip: false,
|
||||
dates: view.chartDataCache.dates,
|
||||
hasYesterday: view.chartDataCache.dates.includes('${realYesterday}'),
|
||||
hasToday: view.chartDataCache.dates.includes('${realToday}')
|
||||
});
|
||||
})()`);
|
||||
if (chartInfo) {
|
||||
const info = JSON.parse(chartInfo);
|
||||
if (!info.skip) {
|
||||
log('📊', `Chart dates (after pause): ${JSON.stringify(info.dates)}`);
|
||||
assert(info.hasYesterday, `Chart should include yesterday (${realYesterday})`);
|
||||
assert(info.hasToday, `Chart should include today (${realToday})`);
|
||||
} else {
|
||||
log('⚠️', 'Chart data not available — statistics may be hidden');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// IMPORTANT: Restore Date before cleanup to avoid contaminating other operations
|
||||
await runner.run('Cross-day: restore original Date', async() => {
|
||||
await restoreDate();
|
||||
const realTime = await runner.eval(`new Date().toISOString()`);
|
||||
log('🕐', `Date restored: ${realTime}`);
|
||||
const diff = Math.abs(Date.now() - new Date(realTime).getTime());
|
||||
assert(diff < 5000, `Restored time should be close to real time, diff=${diff}ms`);
|
||||
});
|
||||
|
||||
// Clean up cross-day timer
|
||||
await runner.run('Cross-day: cleanup — delete timer', async() => {
|
||||
await runner.setCursorToLine(crossDayTestLineNum);
|
||||
await sleep(200);
|
||||
await runner.executeCommand('text-block-timer:delete-timer');
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
});
|
||||
|
||||
await runner.run('Cross-day: cleanup — remove test line', async() => {
|
||||
await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
for (let i = ed.lineCount() - 1; i >= 0; i--) {
|
||||
if (ed.getLine(i).includes('E2E_CROSSDAY_TEST_')) {
|
||||
const from = Math.max(0, i - 1);
|
||||
const fromCh = i > 0 ? ed.getLine(from).length : 0;
|
||||
ed.replaceRange('', { line: from, ch: fromCh }, { line: i, ch: ed.getLine(i).length });
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(500);
|
||||
log('🧹', 'Cross-day test line removed');
|
||||
});
|
||||
}
|
||||
|
||||
450
tests/chains/crossday_adjust.mjs
Normal file
450
tests/chains/crossday_adjust.mjs
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
import { sleep, assert, log, WAIT_AFTER_COMMAND, TICK_TOLERANCE, idbGetTimer, idbGetDailyByTimer } from '../e2e-timer-test.mjs';
|
||||
|
||||
// ─── Chain: crossday_adjust ───────────────────────────────────────────────────
|
||||
// Creates a timer that spans 3 days via toggle start/pause on each day,
|
||||
// then tests manual increase/decrease duration allocation across days.
|
||||
//
|
||||
// Day layout (example):
|
||||
// day1 (2 days ago): toggle start → 3s → toggle pause → daily_dur ≈ 3
|
||||
// day2 (yesterday): toggle resume → 3s → toggle pause → daily_dur ≈ 3
|
||||
// day3 (today): paused, manual adjust only → daily_dur ≈ 0
|
||||
// total ≈ 6
|
||||
//
|
||||
// Then we test:
|
||||
// 1. Increase by +10 → all goes to today → day1≈3, day2≈3, day3≈10, total≈16
|
||||
// 2. Decrease by -14 (total→≈2) → LIFO: deduct from newest first
|
||||
// day3 zeroed, day2 reduced, day1 preserved/reduced, total≈2
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function chain_crossday_adjust(runner) {
|
||||
|
||||
let cdaTimerId = null;
|
||||
const cdaTestLineNum = await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
const last = ed.lineCount() - 1;
|
||||
ed.replaceRange('\\nE2E_CDA_TEST_' + Date.now(), { line: last, ch: ed.getLine(last).length });
|
||||
const n = ed.lineCount() - 1;
|
||||
ed.setCursor({ line: n, ch: 0 });
|
||||
return n;
|
||||
})()`);
|
||||
log('📝', `Cross-day-adjust test line at L${cdaTestLineNum}`);
|
||||
await sleep(500);
|
||||
|
||||
// Date helpers
|
||||
async function patchDate(offsetMs) {
|
||||
await runner.eval(`(() => {
|
||||
if (!window.__OrigDate) window.__OrigDate = Date;
|
||||
const OD = window.__OrigDate;
|
||||
const offset = ${offsetMs};
|
||||
function FakeDate(...args) {
|
||||
if (args.length === 0) return new OD(OD.now() + offset);
|
||||
return new OD(...args);
|
||||
}
|
||||
FakeDate.now = () => OD.now() + offset;
|
||||
FakeDate.parse = OD.parse.bind(OD);
|
||||
FakeDate.UTC = OD.UTC.bind(OD);
|
||||
FakeDate.prototype = OD.prototype;
|
||||
Date = FakeDate;
|
||||
return true;
|
||||
})()`);
|
||||
}
|
||||
|
||||
async function restoreDate() {
|
||||
await runner.eval(`(() => {
|
||||
if (window.__OrigDate) { Date = window.__OrigDate; delete window.__OrigDate; }
|
||||
return true;
|
||||
})()`);
|
||||
}
|
||||
|
||||
// Helper: verify JSON and IDB internal consistency (total == sum of daily_dur)
|
||||
async function verifyDbConsistency(label) {
|
||||
// ── JSON consistency ──
|
||||
const jsonConsistency = JSON.parse(await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
const timerId = '${cdaTimerId}';
|
||||
const totalDur = p.database.data?.timers?.[timerId]?.total_dur_sec ?? -1;
|
||||
const dd = p.database.data?.daily_dur ?? {};
|
||||
let dailySum = 0;
|
||||
for (const [date, timerMap] of Object.entries(dd)) {
|
||||
if (timerMap[timerId] !== undefined) {
|
||||
dailySum += timerMap[timerId];
|
||||
}
|
||||
}
|
||||
return JSON.stringify({ totalDur, dailySum });
|
||||
})()`));
|
||||
const jsonDrift = Math.abs(jsonConsistency.totalDur - jsonConsistency.dailySum);
|
||||
log('📊', `[${label}] JSON consistency: total=${jsonConsistency.totalDur}s, daily_sum=${jsonConsistency.dailySum}s, drift=${jsonDrift}s`);
|
||||
assert(jsonDrift <= TICK_TOLERANCE,
|
||||
`[${label}] JSON internal inconsistency: total(${jsonConsistency.totalDur}) vs daily_sum(${jsonConsistency.dailySum}), drift=${jsonDrift}s`);
|
||||
|
||||
// ── IDB consistency ──
|
||||
const idbTimerRaw = await runner.evalAsync(idbGetTimer(cdaTimerId));
|
||||
const idbTimer = JSON.parse(idbTimerRaw);
|
||||
const idbDailyRaw = await runner.evalAsync(idbGetDailyByTimer(cdaTimerId));
|
||||
const idbRecs = JSON.parse(idbDailyRaw);
|
||||
const idbDailySum = idbRecs.reduce((s, r) => s + r.duration_sec, 0);
|
||||
const idbDrift = Math.abs(idbTimer.total_dur_sec - idbDailySum);
|
||||
log('📊', `[${label}] IDB consistency: total=${idbTimer.total_dur_sec}s, daily_sum=${idbDailySum}s, drift=${idbDrift}s`);
|
||||
assert(idbDrift <= TICK_TOLERANCE,
|
||||
`[${label}] IDB internal inconsistency: total(${idbTimer.total_dur_sec}) vs daily_sum(${idbDailySum}), drift=${idbDrift}s`);
|
||||
|
||||
return { jsonConsistency, idbTotal: idbTimer.total_dur_sec, idbDailySum };
|
||||
}
|
||||
|
||||
// Helper: read IDB state for the timer
|
||||
async function readIdbState() {
|
||||
const timerRaw = await runner.evalAsync(idbGetTimer(cdaTimerId));
|
||||
const timer = JSON.parse(timerRaw);
|
||||
const dailyRaw = await runner.evalAsync(idbGetDailyByTimer(cdaTimerId));
|
||||
const recs = JSON.parse(dailyRaw);
|
||||
const dailyMap = {};
|
||||
for (const rec of recs) dailyMap[rec.stat_date] = rec.duration_sec;
|
||||
const dailySum = recs.reduce((s, r) => s + r.duration_sec, 0);
|
||||
return { total: timer.total_dur_sec, dailyMap, dailySum, recs };
|
||||
}
|
||||
|
||||
// Helper: call handleSetDuration to adjust total to newDur
|
||||
async function adjustDuration(newDur) {
|
||||
await runner.evalAsync(`
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
const view = app.workspace.activeEditor;
|
||||
const editor = view.editor;
|
||||
const lineText = editor.getLine(${cdaTestLineNum});
|
||||
|
||||
const tpl = document.createElement('template');
|
||||
tpl.innerHTML = lineText.trim();
|
||||
const el = tpl.content.querySelector('.timer-r, .timer-p');
|
||||
if (!el) throw new Error('No timer span found');
|
||||
|
||||
const timerId = el.id;
|
||||
const cls = el.className;
|
||||
const dur = parseInt(el.dataset.dur || '0', 10);
|
||||
const ts = parseInt(el.dataset.ts || '0', 10);
|
||||
const project = el.dataset.project || null;
|
||||
|
||||
const regex = new RegExp('<span[^>]*id="' + timerId + '"[^>]*>.*?</span>');
|
||||
const match = lineText.match(regex);
|
||||
if (!match) throw new Error('Span regex match failed');
|
||||
|
||||
const parsed = {
|
||||
class: cls,
|
||||
timerId: timerId,
|
||||
dur: dur,
|
||||
ts: ts,
|
||||
project: project,
|
||||
beforeIndex: match.index,
|
||||
afterIndex: match.index + match[0].length
|
||||
};
|
||||
|
||||
p.handleSetDuration(view, ${cdaTestLineNum}, parsed, ${newDur});
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
`);
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
// Compute the 3 dates
|
||||
const realNowMs = Date.now();
|
||||
const day3 = new Date().toLocaleDateString('sv'); // today
|
||||
const day2 = new Date(Date.now() - 86400000).toLocaleDateString('sv'); // yesterday
|
||||
const day1 = new Date(Date.now() - 2 * 86400000).toLocaleDateString('sv'); // 2 days ago
|
||||
|
||||
log('📅', `Multi-day plan: day1=${day1}, day2=${day2}, day3=${day3}`);
|
||||
|
||||
// ── Step 1: Patch to day1 noon, toggle start, 3s, toggle pause ──────────
|
||||
|
||||
const day1Noon = new Date(day1 + 'T12:00:00').getTime();
|
||||
const offset1 = day1Noon - realNowMs;
|
||||
|
||||
await runner.run('CDA: day1 — toggle start new timer', async() => {
|
||||
await patchDate(offset1);
|
||||
const fakeDate = await runner.eval(`new Date().toLocaleDateString('sv')`);
|
||||
assert(fakeDate === day1, `Expected ${day1}, got ${fakeDate}`);
|
||||
log('🕐', `Patched to day1 noon: ${fakeDate}`);
|
||||
|
||||
await runner.setCursorToLine(cdaTestLineNum);
|
||||
await sleep(200);
|
||||
await runner.executeCommand('text-block-timer:toggle-timer');
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
const lineText = await runner.eval(`app.workspace.activeEditor.editor.getLine(${cdaTestLineNum})`);
|
||||
assert(lineText.includes('class="timer-r"'), 'Expected timer-r span');
|
||||
const match = lineText.match(/id="([^"]+)"/);
|
||||
assert(match, 'Timer ID not found');
|
||||
cdaTimerId = match[1];
|
||||
log('🆔', `CDA timer ID: ${cdaTimerId}`);
|
||||
});
|
||||
|
||||
log('⏳', 'Letting timer run ~3s on day1...');
|
||||
await sleep(3000);
|
||||
|
||||
await runner.run('CDA: day1 — toggle pause', async() => {
|
||||
await runner.setCursorToLine(cdaTestLineNum);
|
||||
await sleep(200);
|
||||
await runner.executeCommand('text-block-timer:toggle-timer');
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
const lineText = await runner.eval(`app.workspace.activeEditor.editor.getLine(${cdaTestLineNum})`);
|
||||
assert(lineText.includes('class="timer-p"'), 'Expected timer-p after pause');
|
||||
|
||||
// Verify day1 IDB record exists
|
||||
const dailyRaw = await runner.evalAsync(idbGetDailyByTimer(cdaTimerId));
|
||||
const recs = JSON.parse(dailyRaw);
|
||||
const day1Rec = recs.find(r => r.stat_date === day1);
|
||||
assert(day1Rec, `IDB should have day1 (${day1}) record`);
|
||||
assert(day1Rec.duration_sec >= 2, `day1 dur should be >= 2s, got ${day1Rec.duration_sec}s`);
|
||||
log('📊', `day1 IDB dur: ${day1Rec.duration_sec}s`);
|
||||
|
||||
await restoreDate();
|
||||
});
|
||||
|
||||
// ── Step 2: Patch to day2 noon, toggle resume, 3s, toggle pause ─────────
|
||||
|
||||
const day2Noon = new Date(day2 + 'T12:00:00').getTime();
|
||||
const offset2 = day2Noon - realNowMs;
|
||||
|
||||
await runner.run('CDA: day2 — toggle resume', async() => {
|
||||
await patchDate(offset2);
|
||||
const fakeDate = await runner.eval(`new Date().toLocaleDateString('sv')`);
|
||||
assert(fakeDate === day2, `Expected ${day2}, got ${fakeDate}`);
|
||||
log('🕐', `Patched to day2 noon: ${fakeDate}`);
|
||||
|
||||
await runner.setCursorToLine(cdaTestLineNum);
|
||||
await sleep(200);
|
||||
await runner.executeCommand('text-block-timer:toggle-timer');
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
const lineText = await runner.eval(`app.workspace.activeEditor.editor.getLine(${cdaTestLineNum})`);
|
||||
assert(lineText.includes('class="timer-r"'), 'Expected timer-r after resume');
|
||||
});
|
||||
|
||||
log('⏳', 'Letting timer run ~3s on day2...');
|
||||
await sleep(3000);
|
||||
|
||||
await runner.run('CDA: day2 — toggle pause', async() => {
|
||||
await runner.setCursorToLine(cdaTestLineNum);
|
||||
await sleep(200);
|
||||
await runner.executeCommand('text-block-timer:toggle-timer');
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
const lineText = await runner.eval(`app.workspace.activeEditor.editor.getLine(${cdaTestLineNum})`);
|
||||
assert(lineText.includes('class="timer-p"'), 'Expected timer-p after pause');
|
||||
|
||||
// Verify day2 IDB record exists
|
||||
const dailyRaw = await runner.evalAsync(idbGetDailyByTimer(cdaTimerId));
|
||||
const recs = JSON.parse(dailyRaw);
|
||||
const day2Rec = recs.find(r => r.stat_date === day2);
|
||||
assert(day2Rec, `IDB should have day2 (${day2}) record`);
|
||||
assert(day2Rec.duration_sec >= 2, `day2 dur should be >= 2s, got ${day2Rec.duration_sec}s`);
|
||||
log('📊', `day2 IDB dur: ${day2Rec.duration_sec}s`);
|
||||
|
||||
await restoreDate();
|
||||
});
|
||||
|
||||
// ── Step 3: Record pre-adjustment state & verify consistency ─────────────
|
||||
|
||||
await runner.run('CDA: record pre-adjustment state', async() => {
|
||||
const state = await readIdbState();
|
||||
log('📊', `Pre-adjust: total=${state.total}s, daily_sum=${state.dailySum}s`);
|
||||
log('📊', ` day1(${day1})=${state.dailyMap[day1] || 0}s, day2(${day2})=${state.dailyMap[day2] || 0}s, day3(${day3})=${state.dailyMap[day3] || 0}s`);
|
||||
|
||||
// total should be ~6 (3s on day1 + 3s on day2)
|
||||
assert(state.total >= 4 && state.total <= 10,
|
||||
`Pre-adjust total should be ~6s (got ${state.total}s)`);
|
||||
|
||||
// Verify JSON & IDB internal consistency
|
||||
await verifyDbConsistency('pre-adjust');
|
||||
});
|
||||
|
||||
// ── Step 4: Patch to today noon, increase by +10s ───────────────────────
|
||||
|
||||
const day3Noon = new Date(day3 + 'T12:00:00').getTime();
|
||||
const offset3 = day3Noon - realNowMs;
|
||||
|
||||
await runner.run('CDA: today — increase by +10s', async() => {
|
||||
await patchDate(offset3);
|
||||
|
||||
const preSt = await readIdbState();
|
||||
const newDur = preSt.total + 10;
|
||||
log('📊', `Increasing total from ${preSt.total}s to ${newDur}s (+10s)`);
|
||||
|
||||
await adjustDuration(newDur);
|
||||
|
||||
// Verify IDB
|
||||
const afterSt = await readIdbState();
|
||||
log('📊', `After +10s:`);
|
||||
for (const rec of afterSt.recs) log('📊', ` IDB: ${rec.stat_date} = ${rec.duration_sec}s`);
|
||||
log('📊', ` total=${afterSt.total}s, daily_sum=${afterSt.dailySum}s`);
|
||||
|
||||
// total should be newDur
|
||||
assert(afterSt.total === newDur,
|
||||
`Expected total=${newDur}, got ${afterSt.total}`);
|
||||
|
||||
// day1 and day2 should be UNCHANGED (increase goes to today)
|
||||
assert(afterSt.dailyMap[day1] === preSt.dailyMap[day1],
|
||||
`day1 should be unchanged: expected ${preSt.dailyMap[day1]}, got ${afterSt.dailyMap[day1]}`);
|
||||
assert(afterSt.dailyMap[day2] === preSt.dailyMap[day2],
|
||||
`day2 should be unchanged: expected ${preSt.dailyMap[day2]}, got ${afterSt.dailyMap[day2]}`);
|
||||
|
||||
// today (day3) should have increased by 10
|
||||
const expectedDay3 = (preSt.dailyMap[day3] || 0) + 10;
|
||||
assert((afterSt.dailyMap[day3] || 0) === expectedDay3,
|
||||
`day3 should be ${expectedDay3}, got ${afterSt.dailyMap[day3] || 0}`);
|
||||
|
||||
// daily_sum should equal total
|
||||
const sumDrift = Math.abs(afterSt.dailySum - afterSt.total);
|
||||
assert(sumDrift <= 1,
|
||||
`IDB daily_sum (${afterSt.dailySum}) should match total (${afterSt.total}), drift=${sumDrift}s`);
|
||||
|
||||
// Verify JSON & IDB internal consistency
|
||||
await verifyDbConsistency('after +10s increase');
|
||||
|
||||
await restoreDate();
|
||||
});
|
||||
|
||||
// ── Step 5: Decrease by -14s (total → total-14) ─────────────────────────
|
||||
// Pre-state after increase: day1≈3, day2≈3, day3≈10, total≈16
|
||||
// total - 14 ≈ 2 → LIFO: deduct from newest first
|
||||
// day3 zeroed (10→0), remaining deduction = 4
|
||||
// day2 reduced (3→0), remaining deduction = 1
|
||||
// day1 reduced (3→2)
|
||||
|
||||
await runner.run('CDA: decrease by -14s — LIFO deduction', async() => {
|
||||
const preSt = await readIdbState();
|
||||
const newDur = preSt.total - 14;
|
||||
log('📊', `Decreasing total from ${preSt.total}s to ${newDur}s (-14s)`);
|
||||
log('📊', `Pre-decrease: day1=${preSt.dailyMap[day1] || 0}, day2=${preSt.dailyMap[day2] || 0}, day3=${preSt.dailyMap[day3] || 0}`);
|
||||
|
||||
assert(newDur >= 0, `newDur should be >= 0, got ${newDur}`);
|
||||
|
||||
await adjustDuration(newDur);
|
||||
|
||||
// Verify IDB
|
||||
const afterSt = await readIdbState();
|
||||
log('📊', `After -14s (target ${newDur}s):`);
|
||||
for (const rec of afterSt.recs) log('📊', ` IDB: ${rec.stat_date} = ${rec.duration_sec}s`);
|
||||
log('📊', ` total=${afterSt.total}s, daily_sum=${afterSt.dailySum}s`);
|
||||
|
||||
// total should be newDur
|
||||
assert(afterSt.total === newDur,
|
||||
`Expected total=${newDur}, got ${afterSt.total}`);
|
||||
|
||||
// daily_sum should equal newDur
|
||||
assert(afterSt.dailySum === newDur,
|
||||
`daily_sum (${afterSt.dailySum}) should equal ${newDur}`);
|
||||
|
||||
// LIFO deduction simulation: traverse ascending (oldest first), preserve oldest with remaining
|
||||
const sortedDays = [
|
||||
{ date: day1, dur: preSt.dailyMap[day1] || 0 },
|
||||
{ date: day2, dur: preSt.dailyMap[day2] || 0 },
|
||||
{ date: day3, dur: preSt.dailyMap[day3] || 0 },
|
||||
].sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
let expectedRemaining = newDur;
|
||||
const expected = {};
|
||||
for (const { date, dur } of sortedDays) {
|
||||
if (expectedRemaining <= 0) {
|
||||
expected[date] = 0;
|
||||
} else if (expectedRemaining >= dur) {
|
||||
expected[date] = dur;
|
||||
expectedRemaining -= dur;
|
||||
} else {
|
||||
expected[date] = expectedRemaining;
|
||||
expectedRemaining = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const d1After = afterSt.dailyMap[day1] ?? 0;
|
||||
const d2After = afterSt.dailyMap[day2] ?? 0;
|
||||
const d3After = afterSt.dailyMap[day3] ?? 0;
|
||||
|
||||
log('📊', `Expected: day1=${expected[day1]}, day2=${expected[day2]}, day3=${expected[day3]}`);
|
||||
log('📊', `Actual: day1=${d1After}, day2=${d2After}, day3=${d3After}`);
|
||||
|
||||
assert(d1After === expected[day1],
|
||||
`day1: expected ${expected[day1]}, got ${d1After}`);
|
||||
assert(d2After === expected[day2],
|
||||
`day2: expected ${expected[day2]}, got ${d2After}`);
|
||||
assert(d3After === expected[day3],
|
||||
`day3: expected ${expected[day3]}, got ${d3After}`);
|
||||
|
||||
// Sanity: sum should equal newDur
|
||||
assert(d1After + d2After + d3After === newDur,
|
||||
`Sum ${d1After}+${d2After}+${d3After}=${d1After + d2After + d3After} should equal ${newDur}`);
|
||||
|
||||
// Verify JSON & IDB internal consistency
|
||||
await verifyDbConsistency('after -14s decrease');
|
||||
});
|
||||
|
||||
// ── Step 6: Cross-layer check — JSON daily_dur matches IDB ──────────────
|
||||
|
||||
await runner.run('CDA: JSON daily_dur matches IDB after adjustments', async() => {
|
||||
const jsonResult = JSON.parse(await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
const dd = p.database.data?.daily_dur ?? {};
|
||||
const result = {};
|
||||
for (const [date, timerMap] of Object.entries(dd)) {
|
||||
if (timerMap['${cdaTimerId}'] !== undefined) {
|
||||
result[date] = timerMap['${cdaTimerId}'];
|
||||
}
|
||||
}
|
||||
return JSON.stringify(result);
|
||||
})()`));
|
||||
|
||||
const idbRaw = await runner.evalAsync(idbGetDailyByTimer(cdaTimerId));
|
||||
const idbRecs = JSON.parse(idbRaw);
|
||||
const idbMap = {};
|
||||
for (const rec of idbRecs) idbMap[rec.stat_date] = rec.duration_sec;
|
||||
|
||||
log('📊', 'Cross-layer comparison after adjustments:');
|
||||
const allDates = new Set([...Object.keys(jsonResult), ...Object.keys(idbMap)]);
|
||||
let mismatchCount = 0;
|
||||
for (const date of [...allDates].sort()) {
|
||||
const j = jsonResult[date] ?? 0;
|
||||
const i = idbMap[date] ?? 0;
|
||||
log('📊', ` ${date}: JSON=${j}s, IDB=${i}s${j !== i ? ' ⚠️' : ''}`);
|
||||
if (j !== i) mismatchCount++;
|
||||
}
|
||||
|
||||
if (mismatchCount > 0) {
|
||||
log('⚠️', `JSON vs IDB mismatch on ${mismatchCount} dates`);
|
||||
}
|
||||
|
||||
// Verify IDB self-consistency
|
||||
const idbSum = idbRecs.reduce((s, r) => s + r.duration_sec, 0);
|
||||
const timerRaw = await runner.evalAsync(idbGetTimer(cdaTimerId));
|
||||
const timer = JSON.parse(timerRaw);
|
||||
const drift = Math.abs(idbSum - timer.total_dur_sec);
|
||||
assert(drift <= TICK_TOLERANCE,
|
||||
`IDB daily_sum (${idbSum}) vs total (${timer.total_dur_sec}), drift=${drift}s`);
|
||||
|
||||
// Verify JSON self-consistency
|
||||
await verifyDbConsistency('cross-layer final');
|
||||
});
|
||||
|
||||
// ── Cleanup ─────────────────────────────────────────────────────────────
|
||||
|
||||
await runner.run('CDA: cleanup — delete timer', async() => {
|
||||
await runner.setCursorToLine(cdaTestLineNum);
|
||||
await sleep(200);
|
||||
await runner.executeCommand('text-block-timer:delete-timer');
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
});
|
||||
|
||||
await runner.run('CDA: cleanup — remove test line', async() => {
|
||||
await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
for (let i = ed.lineCount() - 1; i >= 0; i--) {
|
||||
if (ed.getLine(i).includes('E2E_CDA_TEST_')) {
|
||||
const from = Math.max(0, i - 1);
|
||||
const fromCh = i > 0 ? ed.getLine(from).length : 0;
|
||||
ed.replaceRange('', { line: from, ch: fromCh }, { line: i, ch: ed.getLine(i).length });
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(500);
|
||||
log('🧹', 'CDA test line removed');
|
||||
});
|
||||
}
|
||||
|
||||
105
tests/chains/delete.mjs
Normal file
105
tests/chains/delete.mjs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { sleep, assert, log, WAIT_AFTER_COMMAND, idbGetTimer, idbGetTimersByState, shared } from '../e2e-timer-test.mjs';
|
||||
|
||||
// ─── Chain: delete ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function chain_delete(runner) {
|
||||
const today = shared.today;
|
||||
const testLineNum = shared.testLineNum;
|
||||
const createdTimerId = shared.createdTimerId;
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// PHASE 15: DELETE timer → verify IDB + sidebar
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
await runner.run('DELETE timer', async() => {
|
||||
await runner.setCursorToLine(testLineNum);
|
||||
await sleep(200);
|
||||
await runner.executeCommand('text-block-timer:delete-timer');
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
const lineText = await runner.eval(`app.workspace.activeEditor.editor.getLine(${testLineNum})`);
|
||||
assert(!lineText.includes('class="timer-'), `Timer span should be removed`);
|
||||
assert(lineText.includes('E2E_SIDEBAR_TEST_'), 'Original text should remain');
|
||||
});
|
||||
|
||||
await runner.run('IDB: timer state=deleted after DELETE', async() => {
|
||||
const raw = await runner.evalAsync(idbGetTimer(createdTimerId));
|
||||
assert(raw, 'Timer should still exist in IDB with deleted state');
|
||||
const entry = JSON.parse(raw);
|
||||
assert(entry.state === 'deleted', `Expected deleted, got ${entry.state}`);
|
||||
});
|
||||
|
||||
await runner.run('Sidebar: timer card removed after DELETE', async() => {
|
||||
const hasTimer = await runner.eval(`(() => {
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (!leaves.length) return false;
|
||||
const c = leaves[0].view.containerEl;
|
||||
const cards = c.querySelectorAll('.timer-card');
|
||||
for (const card of cards) {
|
||||
const fileEl = card.querySelector('.timer-card-file-source');
|
||||
if (fileEl?.textContent?.includes(':${testLineNum + 1}')) return true;
|
||||
}
|
||||
return false;
|
||||
})()`);
|
||||
assert(!hasTimer, 'Timer card should be removed from sidebar after delete');
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// PHASE 16: IDB getTimersByState excludes deleted timers
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
await runner.run('IDB: getTimersByState([running,paused]) excludes deleted timer', async() => {
|
||||
const raw = await runner.evalAsync(idbGetTimersByState(['running', 'paused']));
|
||||
const entries = JSON.parse(raw);
|
||||
const ours = entries.find(e => e.timer_id === createdTimerId);
|
||||
assert(!ours, 'Deleted timer should not appear in getTimersByState(running,paused)');
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// PHASE 17: Final cross-layer consistency check
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
await runner.run('Final: JSON and IDB both reflect deleted state', async() => {
|
||||
await sleep(500);
|
||||
const jsonState = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
return p.database.data?.timers?.['${createdTimerId}']?.state ?? 'missing';
|
||||
})()`);
|
||||
const idbRaw = await runner.evalAsync(idbGetTimer(createdTimerId));
|
||||
const idbState = idbRaw ? JSON.parse(idbRaw).state : 'missing';
|
||||
|
||||
log('📊', `Final: JSON state=${jsonState}, IDB state=${idbState}`);
|
||||
assert(jsonState === 'deleted', `JSON should be deleted, got ${jsonState}`);
|
||||
assert(idbState === 'deleted', `IDB should be deleted, got ${idbState}`);
|
||||
});
|
||||
|
||||
await runner.run('Final: Sidebar summary counts are consistent with visible cards', async() => {
|
||||
const result = JSON.parse(await runner.eval(`(() => {
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (!leaves.length) return JSON.stringify({ skip: true });
|
||||
const c = leaves[0].view.containerEl;
|
||||
return JSON.stringify({
|
||||
skip: false,
|
||||
runningCards: c.querySelectorAll('.timer-card-running').length,
|
||||
pausedCards: c.querySelectorAll('.timer-card-paused').length,
|
||||
summaryTotal: parseInt(c.querySelector('[data-summary-count]')?.textContent ?? '0'),
|
||||
summaryRunning: parseInt(c.querySelector('[data-summary-running-count]')?.textContent ?? '0'),
|
||||
summaryPaused: parseInt(c.querySelector('[data-summary-paused-count]')?.textContent ?? '0')
|
||||
});
|
||||
})()`));
|
||||
if (result.skip) {
|
||||
log('⚠️', 'Sidebar not available — skipping');
|
||||
return;
|
||||
}
|
||||
const visibleTotal = result.runningCards + result.pausedCards;
|
||||
assert(result.summaryTotal >= visibleTotal,
|
||||
`Summary total (${result.summaryTotal}) should be >= visible (${visibleTotal})`);
|
||||
assert(result.summaryRunning >= result.runningCards,
|
||||
`Summary running (${result.summaryRunning}) should be >= running cards (${result.runningCards})`);
|
||||
assert(result.summaryPaused >= result.pausedCards,
|
||||
`Summary paused (${result.summaryPaused}) should be >= paused cards (${result.pausedCards})`);
|
||||
log('📊', `Summary: ${result.summaryTotal} total (${result.summaryRunning}R + ${result.summaryPaused}P), ` +
|
||||
`Cards: ${visibleTotal} (${result.runningCards}R + ${result.pausedCards}P)`);
|
||||
});
|
||||
}
|
||||
|
||||
118
tests/chains/onunload_behavior.mjs
Normal file
118
tests/chains/onunload_behavior.mjs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { sleep, assert, log, WAIT_AFTER_COMMAND } from '../e2e-timer-test.mjs';
|
||||
|
||||
// ─── Chain: onunload_behavior ────────────────────────────────────────────────
|
||||
// Tests that onunload properly flushes running timer data
|
||||
|
||||
export async function chain_onunload_behavior(runner) {
|
||||
let unlTimerId = null;
|
||||
|
||||
const unlTestLine = await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
const last = ed.lineCount() - 1;
|
||||
ed.replaceRange('\\nE2E_UNL_TEST_' + Date.now(), { line: last, ch: ed.getLine(last).length });
|
||||
const n = ed.lineCount() - 1;
|
||||
ed.setCursor({ line: n, ch: 0 });
|
||||
return n;
|
||||
})()`);
|
||||
await sleep(500);
|
||||
|
||||
// Start a timer
|
||||
await runner.run('UNL-01: Start timer for onunload test', async() => {
|
||||
await runner.setCursorToLine(unlTestLine);
|
||||
await sleep(200);
|
||||
await runner.executeCommand('text-block-timer:toggle-timer');
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
const lineText = await runner.eval(`app.workspace.activeEditor.editor.getLine(${unlTestLine})`);
|
||||
assert(lineText.includes('class="timer-r"'), 'Expected timer-r span');
|
||||
const match = lineText.match(/id="([^"]+)"/);
|
||||
assert(match, 'Timer ID not found');
|
||||
unlTimerId = match[1];
|
||||
|
||||
// Verify it's in manager
|
||||
const inManager = await runner.eval(`app.plugins.plugins['text-block-timer'].manager.hasTimer('${unlTimerId}')`);
|
||||
assert(inManager, 'Timer should be in manager');
|
||||
log('🆔', `Onunload test timer: ${unlTimerId}`);
|
||||
});
|
||||
|
||||
// Let it run for a bit
|
||||
await sleep(3000);
|
||||
|
||||
// Test: simulate onunload flush behavior (without actually unloading)
|
||||
await runner.run('UNL-02: flushSync persists running timer data to JSON', async() => {
|
||||
// Get current in-memory state before flush
|
||||
const memDur = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
const e = p.database.getEntry('${unlTimerId}');
|
||||
return e ? e.total_dur_sec : -1;
|
||||
})()`);
|
||||
assert(memDur > 0, `Expected positive total_dur in memory, got ${memDur}`);
|
||||
|
||||
// Call flushSync (the critical onunload operation)
|
||||
await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
p.database.flushSync();
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(500);
|
||||
|
||||
// Read back the JSON file and verify it matches memory
|
||||
const jsonDur = await runner.evalAsync(`
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
const adapter = app.vault.adapter;
|
||||
const dbPath = app.vault.configDir + '/plugins/text-block-timer/timer-db.json';
|
||||
const raw = await adapter.read(dbPath);
|
||||
const data = JSON.parse(raw);
|
||||
const entry = data.timers['${unlTimerId}'];
|
||||
return entry ? entry.total_dur_sec : -1;
|
||||
`);
|
||||
|
||||
log('📊', `flushSync: memDur=${memDur}, jsonFileDur=${jsonDur}`);
|
||||
assert(jsonDur >= memDur - 1, `JSON file dur (${jsonDur}) should be >= memory dur (${memDur}) - 1`);
|
||||
assert(jsonDur > 0, `JSON file should have positive dur, got ${jsonDur}`);
|
||||
});
|
||||
|
||||
// UNL-03: Verify JSON entry state is still 'running' (onunload doesn't change state)
|
||||
await runner.run('UNL-03: JSON state remains running after flushSync', async() => {
|
||||
const state = await runner.evalAsync(`
|
||||
const adapter = app.vault.adapter;
|
||||
const dbPath = app.vault.configDir + '/plugins/text-block-timer/timer-db.json';
|
||||
const raw = await adapter.read(dbPath);
|
||||
const data = JSON.parse(raw);
|
||||
return data.timers['${unlTimerId}']?.state ?? 'missing';
|
||||
`);
|
||||
assert(state === 'running', `Expected state=running in JSON file, got ${state}`);
|
||||
log('📊', `JSON file state after flushSync: ${state}`);
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
if (unlTimerId) {
|
||||
await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
p.manager.stopTimer('${unlTimerId}');
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(200);
|
||||
await runner.setCursorToLine(unlTestLine);
|
||||
await sleep(200);
|
||||
await runner.executeCommand('text-block-timer:toggle-timer'); // pause
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
await runner.executeCommand('text-block-timer:delete-timer');
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
}
|
||||
await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
for (let i = ed.lineCount() - 1; i >= 0; i--) {
|
||||
if (ed.getLine(i).includes('E2E_UNL_TEST_')) {
|
||||
const from = Math.max(0, i - 1);
|
||||
const fromCh = i > 0 ? ed.getLine(from).length : 0;
|
||||
ed.replaceRange('', { line: from, ch: fromCh }, { line: i, ch: ed.getLine(i).length });
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(500);
|
||||
log('🧹', 'Onunload test cleaned up');
|
||||
}
|
||||
|
||||
387
tests/chains/passive_delete.mjs
Normal file
387
tests/chains/passive_delete.mjs
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
import { sleep, assert, log, WAIT_AFTER_COMMAND, idbGetTimer, idbGetDailyByTimer, shared } from '../e2e-timer-test.mjs';
|
||||
|
||||
// ─── Chain: passive_delete ───────────────────────────────────────────────────
|
||||
// Tests passive deletion detection (user deletes timer span in editor) and
|
||||
// passive restoration (Ctrl+Z / paste restores deleted timer span).
|
||||
// Self-contained: creates its own test lines and cleans up after itself.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function chain_passive_delete(runner) {
|
||||
const today = shared.today;
|
||||
|
||||
// ── Setup: create a test line with a paused timer ────────────────────────
|
||||
|
||||
const pdTestLine = await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
const last = ed.lineCount() - 1;
|
||||
ed.replaceRange('\\nE2E_PASSIVE_DELETE_' + Date.now(), { line: last, ch: ed.getLine(last).length });
|
||||
const n = ed.lineCount() - 1;
|
||||
ed.setCursor({ line: n, ch: 0 });
|
||||
return n;
|
||||
})()`);
|
||||
log('📝', `Passive delete test line at L${pdTestLine}`);
|
||||
await sleep(500);
|
||||
|
||||
// Create a timer (running) then pause it
|
||||
await runner.setCursorToLine(pdTestLine);
|
||||
await sleep(200);
|
||||
await runner.executeCommand('text-block-timer:toggle-timer');
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
let lineText = await runner.eval(`app.workspace.activeEditor.editor.getLine(${pdTestLine})`);
|
||||
const pdTimerId = lineText.match(/id="([^"]+)"/)?.[1];
|
||||
assert(pdTimerId, 'Failed to create timer for passive delete test');
|
||||
log('🆔', `Passive delete timer ID: ${pdTimerId}`);
|
||||
|
||||
// Pause it
|
||||
await runner.setCursorToLine(pdTestLine);
|
||||
await sleep(200);
|
||||
await runner.executeCommand('text-block-timer:toggle-timer');
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
// Verify it's paused
|
||||
const stateBeforeDelete = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
return p.database.getEntry('${pdTimerId}')?.state ?? 'missing';
|
||||
})()`);
|
||||
assert(stateBeforeDelete === 'paused', `Expected paused before delete, got ${stateBeforeDelete}`);
|
||||
|
||||
// Save the paused line text for restore tests
|
||||
const pdOriginalLineText = await runner.eval(`app.workspace.activeEditor.editor.getLine(${pdTestLine})`);
|
||||
log('📝', `Original line text saved (${pdOriginalLineText.length} chars)`);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// TC-01: Passive delete paused timer
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
await runner.run('TC-01: Passive delete paused timer', async() => {
|
||||
// Delete line content via CM6 dispatch (ensures undo history is recorded)
|
||||
await runner.eval(`(() => {
|
||||
const view = app.workspace.activeEditor.editor.cm;
|
||||
const line = view.state.doc.line(${pdTestLine} + 1);
|
||||
view.dispatch({ changes: { from: line.from, to: line.to, insert: '' } });
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
// Verify JSON state
|
||||
const jsonState = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
return p.database.getEntry('${pdTimerId}')?.state ?? 'missing';
|
||||
})()`);
|
||||
assert(jsonState === 'deleted', `JSON state should be deleted, got ${jsonState}`);
|
||||
|
||||
// Verify IDB state
|
||||
const idbRaw = await runner.evalAsync(idbGetTimer(pdTimerId));
|
||||
assert(idbRaw, 'Timer should still exist in IDB');
|
||||
const idbEntry = JSON.parse(idbRaw);
|
||||
assert(idbEntry.state === 'deleted', `IDB state should be deleted, got ${idbEntry.state}`);
|
||||
|
||||
log('📊', `TC-01: JSON=${jsonState}, IDB=${idbEntry.state}`);
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// TC-03: Ctrl+Z restore paused timer
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
await runner.run('TC-03: Restore passively deleted timer to paused (simulated undo)', async() => {
|
||||
// Restore the original line text via CM6 dispatch to simulate undo/paste-back
|
||||
// This triggers updateListener which should detect the timer span reappearing
|
||||
await runner.eval(`(() => {
|
||||
const view = app.workspace.activeEditor.editor.cm;
|
||||
const line = view.state.doc.line(${pdTestLine} + 1);
|
||||
view.dispatch({ changes: { from: line.from, to: line.to, insert: ${JSON.stringify(pdOriginalLineText)} } });
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
// Check state
|
||||
const jsonState = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
return p.database.getEntry('${pdTimerId}')?.state ?? 'missing';
|
||||
})()`);
|
||||
assert(jsonState === 'paused', `JSON state should be paused after restore, got ${jsonState}`);
|
||||
|
||||
// Verify IDB state
|
||||
const idbRaw = await runner.evalAsync(idbGetTimer(pdTimerId));
|
||||
const idbEntry = JSON.parse(idbRaw);
|
||||
assert(idbEntry.state === 'paused', `IDB state should be paused after restore, got ${idbEntry.state}`);
|
||||
|
||||
// Verify span is back in the line
|
||||
const restoredLine = await runner.eval(`app.workspace.activeEditor.editor.getLine(${pdTestLine})`);
|
||||
assert(restoredLine.includes(`id="${pdTimerId}"`), 'Timer span should be restored in editor');
|
||||
|
||||
log('📊', `TC-03: JSON=${jsonState}, IDB=${idbEntry.state}, span restored`);
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// TC-07: Idempotency — calling handlePassiveDelete on already deleted timer
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
await runner.run('TC-07: Idempotency — handlePassiveDelete on deleted timer is no-op', async() => {
|
||||
// First, passive delete again
|
||||
await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
const lineLen = ed.getLine(${pdTestLine}).length;
|
||||
ed.replaceRange('', { line: ${pdTestLine}, ch: 0 }, { line: ${pdTestLine}, ch: lineLen });
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
const stateBefore = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
return p.database.getEntry('${pdTimerId}')?.state ?? 'missing';
|
||||
})()`);
|
||||
assert(stateBefore === 'deleted', `Should be deleted before idempotency test, got ${stateBefore}`);
|
||||
|
||||
// Get daily_dur count before
|
||||
const dailyBefore = await runner.evalAsync(idbGetDailyByTimer(pdTimerId));
|
||||
const dailyBeforeCount = JSON.parse(dailyBefore).length;
|
||||
|
||||
// Call handlePassiveDelete directly — should be no-op
|
||||
await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
p.handlePassiveDelete('${pdTimerId}');
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(500);
|
||||
|
||||
// Verify state unchanged
|
||||
const stateAfter = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
return p.database.getEntry('${pdTimerId}')?.state ?? 'missing';
|
||||
})()`);
|
||||
assert(stateAfter === 'deleted', `State should still be deleted, got ${stateAfter}`);
|
||||
|
||||
// Verify daily_dur unchanged
|
||||
const dailyAfter = await runner.evalAsync(idbGetDailyByTimer(pdTimerId));
|
||||
const dailyAfterCount = JSON.parse(dailyAfter).length;
|
||||
assert(dailyAfterCount === dailyBeforeCount, `daily_dur count changed: ${dailyBeforeCount} → ${dailyAfterCount}`);
|
||||
|
||||
log('📊', `TC-07: Idempotent — state=${stateAfter}, daily_dur count unchanged (${dailyAfterCount})`);
|
||||
});
|
||||
|
||||
// Restore for TC-05 (write back original text via CM6 dispatch)
|
||||
await runner.eval(`(() => {
|
||||
const view = app.workspace.activeEditor.editor.cm;
|
||||
const line = view.state.doc.line(${pdTestLine} + 1);
|
||||
view.dispatch({ changes: { from: line.from, to: line.to, insert: ${JSON.stringify(pdOriginalLineText)} } });
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// TC-05: Cut and paste in same file
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
await runner.run('TC-05: Cut-paste same file — delete then restore', async() => {
|
||||
// Save the line content
|
||||
const savedLine = await runner.eval(`app.workspace.activeEditor.editor.getLine(${pdTestLine})`);
|
||||
assert(savedLine.includes(`id="${pdTimerId}"`), 'Timer should be present before cut');
|
||||
|
||||
// Cut (delete the line content) via CM6 dispatch
|
||||
await runner.eval(`(() => {
|
||||
const view = app.workspace.activeEditor.editor.cm;
|
||||
const line = view.state.doc.line(${pdTestLine} + 1);
|
||||
view.dispatch({ changes: { from: line.from, to: line.to, insert: '' } });
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
// Verify deleted after cut
|
||||
const cutState = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
return p.database.getEntry('${pdTimerId}')?.state ?? 'missing';
|
||||
})()`);
|
||||
assert(cutState === 'deleted', `After cut, state should be deleted, got ${cutState}`);
|
||||
|
||||
// Paste back the saved line content via CM6 dispatch
|
||||
await runner.eval(`(() => {
|
||||
const view = app.workspace.activeEditor.editor.cm;
|
||||
const line = view.state.doc.line(${pdTestLine} + 1);
|
||||
view.dispatch({ changes: { from: line.from, to: line.to, insert: ${JSON.stringify(pdOriginalLineText)} } });
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
// Verify restored after paste/undo
|
||||
const pasteState = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
return p.database.getEntry('${pdTimerId}')?.state ?? 'missing';
|
||||
})()`);
|
||||
assert(pasteState === 'paused', `After paste, state should be paused, got ${pasteState}`);
|
||||
|
||||
log('📊', `TC-05: Cut state=${cutState}, Paste state=${pasteState}`);
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// TC-08: Editing non-timer line does not trigger passive delete
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
await runner.run('TC-08: Editing non-timer line does not affect timer state', async() => {
|
||||
const stateBefore = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
return p.database.getEntry('${pdTimerId}')?.state ?? 'missing';
|
||||
})()`);
|
||||
|
||||
// Edit a different line (line 0)
|
||||
await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
const line0 = ed.getLine(0);
|
||||
ed.replaceRange(' ', { line: 0, ch: line0.length });
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(1000);
|
||||
|
||||
const stateAfter = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
return p.database.getEntry('${pdTimerId}')?.state ?? 'missing';
|
||||
})()`);
|
||||
assert(stateAfter === stateBefore, `Timer state changed from ${stateBefore} to ${stateAfter} after editing unrelated line`);
|
||||
|
||||
// Clean up the extra space
|
||||
await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
const line0 = ed.getLine(0);
|
||||
ed.replaceRange('', { line: 0, ch: line0.length - 1 }, { line: 0, ch: line0.length });
|
||||
return true;
|
||||
})()`);
|
||||
|
||||
log('📊', `TC-08: State unchanged (${stateAfter})`);
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// TC-02: Passive delete running timer + duration settlement
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// First, make the timer running again
|
||||
await runner.setCursorToLine(pdTestLine);
|
||||
await sleep(200);
|
||||
await runner.executeCommand('text-block-timer:toggle-timer');
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
// Verify it's running
|
||||
const runState = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
return p.database.getEntry('${pdTimerId}')?.state ?? 'missing';
|
||||
})()`);
|
||||
assert(runState === 'running', `Expected running before TC-02, got ${runState}`);
|
||||
|
||||
// Save the running line text for restore test (TC-04)
|
||||
const pdRunningLineText = await runner.eval(`app.workspace.activeEditor.editor.getLine(${pdTestLine})`);
|
||||
|
||||
// Wait a bit so there's some accumulated time
|
||||
log('⏳', 'Waiting for timer to accumulate time...');
|
||||
await sleep(3000);
|
||||
|
||||
await runner.run('TC-02: Passive delete running timer — settles duration', async() => {
|
||||
// Record daily_dur before delete
|
||||
const dailyBefore = await runner.evalAsync(idbGetDailyByTimer(pdTimerId));
|
||||
const dailyBeforeRecs = JSON.parse(dailyBefore);
|
||||
const dailyBeforeDur = dailyBeforeRecs.find(r => r.stat_date === today)?.duration_sec ?? 0;
|
||||
|
||||
// Delete the line via CM6 dispatch
|
||||
await runner.eval(`(() => {
|
||||
const view = app.workspace.activeEditor.editor.cm;
|
||||
const line = view.state.doc.line(${pdTestLine} + 1);
|
||||
view.dispatch({ changes: { from: line.from, to: line.to, insert: '' } });
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
// Verify state = deleted
|
||||
const jsonState = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
return p.database.getEntry('${pdTimerId}')?.state ?? 'missing';
|
||||
})()`);
|
||||
assert(jsonState === 'deleted', `JSON state should be deleted, got ${jsonState}`);
|
||||
|
||||
// Verify total_dur_sec > 0
|
||||
const totalDur = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
return p.database.getEntry('${pdTimerId}')?.total_dur_sec ?? 0;
|
||||
})()`);
|
||||
assert(totalDur > 0, `total_dur_sec should be > 0, got ${totalDur}`);
|
||||
|
||||
// Verify TimerManager no longer has this timer
|
||||
const managerHas = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
return p.manager.getTimerData('${pdTimerId}') !== null;
|
||||
})()`);
|
||||
assert(!managerHas, 'TimerManager should not have the timer after passive delete');
|
||||
|
||||
// Verify daily_dur was written
|
||||
await sleep(500); // Wait for IDB async
|
||||
const dailyAfter = await runner.evalAsync(idbGetDailyByTimer(pdTimerId));
|
||||
const dailyAfterRecs = JSON.parse(dailyAfter);
|
||||
const dailyAfterDur = dailyAfterRecs.find(r => r.stat_date === today)?.duration_sec ?? 0;
|
||||
assert(dailyAfterDur >= dailyBeforeDur, `daily_dur should not decrease: ${dailyBeforeDur} → ${dailyAfterDur}`);
|
||||
|
||||
log('📊', `TC-02: state=${jsonState}, totalDur=${totalDur}s, daily: ${dailyBeforeDur}→${dailyAfterDur}s`);
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// TC-04: Ctrl+Z restore running timer → becomes paused (not running)
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
await runner.run('TC-04: Restore running timer as paused (not running)', async() => {
|
||||
// Restore via CM6 dispatch (simulating undo/paste-back)
|
||||
await runner.eval(`(() => {
|
||||
const view = app.workspace.activeEditor.editor.cm;
|
||||
const line = view.state.doc.line(${pdTestLine} + 1);
|
||||
view.dispatch({ changes: { from: line.from, to: line.to, insert: ${JSON.stringify(pdRunningLineText)} } });
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
// Verify state = paused (NOT running)
|
||||
const jsonState = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
return p.database.getEntry('${pdTimerId}')?.state ?? 'missing';
|
||||
})()`);
|
||||
assert(jsonState === 'paused', `After restore, state should be paused (not running), got ${jsonState}`);
|
||||
|
||||
// Verify TimerManager does NOT have it running
|
||||
const managerHas = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
return p.manager.getTimerData('${pdTimerId}') !== null;
|
||||
})()`);
|
||||
assert(!managerHas, 'TimerManager should not auto-resume timer after restore');
|
||||
|
||||
// Verify span is back
|
||||
const restoredLine = await runner.eval(`app.workspace.activeEditor.editor.getLine(${pdTestLine})`);
|
||||
assert(restoredLine.includes(`id="${pdTimerId}"`), 'Timer span should be restored after dispatch');
|
||||
|
||||
// Verify span class consistency: timer-r should have been patched to timer-p
|
||||
// The async span fix runs via setTimeout(0), wait a bit for it to apply
|
||||
await sleep(500);
|
||||
const fixedLine = await runner.eval(`app.workspace.activeEditor.editor.getLine(${pdTestLine})`);
|
||||
const hasTimerP = fixedLine.includes('class="timer-p"');
|
||||
const hasTimerR = fixedLine.includes('class="timer-r"');
|
||||
log('🔍', `TC-04 span class check: timer-p=${hasTimerP}, timer-r=${hasTimerR}`);
|
||||
assert(hasTimerP && !hasTimerR, `Span class should be timer-p after restore, got line: ${fixedLine.substring(0, 120)}`);
|
||||
|
||||
log('📊', `TC-04: state=${jsonState}, managerRunning=${managerHas}, spanClass=timer-p ✓`);
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// Cleanup: remove passive delete test lines
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
await runner.run('Cleanup: remove passive delete test lines', async() => {
|
||||
await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
for (let i = ed.lineCount() - 1; i >= 0; i--) {
|
||||
if (ed.getLine(i).includes('E2E_PASSIVE_DELETE_')) {
|
||||
const from = Math.max(0, i - 1);
|
||||
const fromCh = i > 0 ? ed.getLine(from).length : 0;
|
||||
ed.replaceRange('', { line: from, ch: fromCh }, { line: i, ch: ed.getLine(i).length });
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(500);
|
||||
log('🧹', 'Passive delete test lines removed');
|
||||
});
|
||||
}
|
||||
|
||||
47
tests/chains/preflight.mjs
Normal file
47
tests/chains/preflight.mjs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { assert, log, shared } from '../e2e-timer-test.mjs';
|
||||
|
||||
// ─── Chain: preflight ────────────────────────────────────────────────────────
|
||||
|
||||
export async function chain_preflight(runner) {
|
||||
shared.today = new Date().toLocaleDateString('sv'); // YYYY-MM-DD
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// PHASE 0: Pre-flight
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
await runner.run('Plugin is loaded', async() => {
|
||||
const loaded = await runner.eval(`!!app.plugins.plugins['text-block-timer']`);
|
||||
assert(loaded, 'text-block-timer plugin is not loaded');
|
||||
});
|
||||
|
||||
await runner.run('Active file is open in editor', async() => {
|
||||
const has = await runner.eval(`!!app.workspace.activeEditor?.editor`);
|
||||
assert(has, 'No active editor — please open a markdown file');
|
||||
});
|
||||
|
||||
await runner.run('IDB is initialized', async() => {
|
||||
const ok = await runner.eval(`!!app.plugins.plugins['text-block-timer'].idb`);
|
||||
assert(ok, 'IndexedDB instance (plugin.idb) is not available');
|
||||
});
|
||||
|
||||
// Disable background tick throttle
|
||||
await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
p.manager._origBgThreshold = p.manager.backgroundThreshold;
|
||||
p.manager.backgroundThreshold = 600000;
|
||||
return true;
|
||||
})()`);
|
||||
log('⚙️', 'Disabled background tick throttle');
|
||||
|
||||
const initial = JSON.parse(await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
return JSON.stringify({
|
||||
lineCount: ed.lineCount(),
|
||||
filePath: app.workspace.getActiveFile()?.path ?? ''
|
||||
});
|
||||
})()`));
|
||||
shared.initial = initial;
|
||||
log('📋', `File: ${initial.filePath}, ${initial.lineCount} lines`);
|
||||
}
|
||||
|
||||
172
tests/chains/readonly.mjs
Normal file
172
tests/chains/readonly.mjs
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { sleep, assert, log, WAIT_AFTER_COMMAND, idbGetTimer, shared } from '../e2e-timer-test.mjs';
|
||||
|
||||
// ─── Chain: readonly ──────────────────────────────────────────────────────────
|
||||
// Tests that a running timer continues ticking correctly when the editor
|
||||
// switches to reading/preview mode (non-source mode).
|
||||
|
||||
export async function chain_readonly(runner) {
|
||||
const testLineNum = shared.testLineNum;
|
||||
const createdTimerId = shared.createdTimerId;
|
||||
|
||||
// Ensure timer is running
|
||||
const currentState = await runner.eval(`(() => {
|
||||
const lineText = app.workspace.activeEditor.editor.getLine(${testLineNum});
|
||||
if (lineText.includes('class="timer-r"')) return 'running';
|
||||
if (lineText.includes('class="timer-p"')) return 'paused';
|
||||
return 'none';
|
||||
})()`);
|
||||
if (currentState === 'paused') {
|
||||
await runner.setCursorToLine(testLineNum);
|
||||
await sleep(200);
|
||||
await runner.executeCommand('text-block-timer:toggle-timer');
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
log('⚙️', 'Resumed timer for readonly tests');
|
||||
}
|
||||
assert(currentState !== 'none', 'Timer should exist before readonly tests');
|
||||
|
||||
// Read initial dur before switching mode
|
||||
const initialDur = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
const d = p.manager.getTimerData('${createdTimerId}');
|
||||
return d ? d.dur : -1;
|
||||
})()`);
|
||||
log('📊', `Initial dur before mode switch: ${initialDur}s`);
|
||||
|
||||
// ── Switch to preview/reading mode ──────────────────────────────────────
|
||||
|
||||
await runner.run('Read-only mode: switch to preview mode', async() => {
|
||||
// Use Obsidian command to toggle to preview mode
|
||||
const switched = await runner.evalAsync(`
|
||||
const view = app.workspace.activeEditor;
|
||||
if (view && view.currentMode && typeof view.currentMode.set === 'function') {
|
||||
// Try Obsidian's internal mode switch
|
||||
const state = view.getState();
|
||||
state.mode = 'preview';
|
||||
await view.setState(state, { history: false });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
`);
|
||||
await sleep(1000);
|
||||
|
||||
const mode = await runner.eval(`(() => {
|
||||
const view = app.workspace.activeEditor;
|
||||
if (!view) return 'no-view';
|
||||
if (typeof view.getMode === 'function') return view.getMode();
|
||||
return 'unknown';
|
||||
})()`);
|
||||
log('📊', `Current mode after switch: ${mode}`);
|
||||
// The mode should be 'preview' — but some Obsidian versions may differ
|
||||
// Even if mode switch fails, we still test that the timer keeps ticking
|
||||
});
|
||||
|
||||
// ── Timer continues ticking in preview mode ─────────────────────────────
|
||||
|
||||
await runner.run('Read-only mode: timer continues ticking (dur increases)', async() => {
|
||||
const durBefore = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
const d = p.manager.getTimerData('${createdTimerId}');
|
||||
return d ? d.dur : -1;
|
||||
})()`);
|
||||
|
||||
log('⏳', 'Waiting 4s for ticks in preview mode...');
|
||||
await sleep(4000);
|
||||
|
||||
const durAfter = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
const d = p.manager.getTimerData('${createdTimerId}');
|
||||
return d ? d.dur : -1;
|
||||
})()`);
|
||||
|
||||
const increase = durAfter - durBefore;
|
||||
log('📊', `Preview mode: dur before=${durBefore}s, after=${durAfter}s, increase=${increase}s`);
|
||||
assert(increase >= 2,
|
||||
`Timer should continue ticking in preview mode, but increase was only ${increase}s`);
|
||||
});
|
||||
|
||||
await runner.run('Read-only mode: IDB is updated during preview mode ticks', async() => {
|
||||
const raw = await runner.evalAsync(idbGetTimer(createdTimerId));
|
||||
assert(raw, 'Timer should exist in IDB during preview mode');
|
||||
const entry = JSON.parse(raw);
|
||||
assert(entry.state === 'running', `Expected running, got ${entry.state}`);
|
||||
log('📊', `IDB in preview mode: state=${entry.state}, dur=${entry.total_dur_sec}s`);
|
||||
});
|
||||
|
||||
await runner.run('Read-only mode: sidebar still shows running timer', async() => {
|
||||
const state = JSON.parse(await runner.eval(`(() => {
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (!leaves.length) return JSON.stringify({ skip: true });
|
||||
const view = leaves[0].view;
|
||||
const has = view.timerList.some(t => t.timerId === '${createdTimerId}' && t.state === 'timer-r');
|
||||
return JSON.stringify({ skip: false, hasRunning: has });
|
||||
})()`));
|
||||
|
||||
if (!state.skip) {
|
||||
assert(state.hasRunning, 'Sidebar should still show our timer as running in preview mode');
|
||||
}
|
||||
});
|
||||
|
||||
// ── Switch back to source mode ──────────────────────────────────────────
|
||||
|
||||
await runner.run('Read-only mode: switch back to source mode', async() => {
|
||||
await runner.evalAsync(`
|
||||
const view = app.workspace.activeEditor;
|
||||
if (view) {
|
||||
const state = view.getState();
|
||||
state.mode = 'source';
|
||||
await view.setState(state, { history: false });
|
||||
}
|
||||
`);
|
||||
await sleep(1000);
|
||||
|
||||
const mode = await runner.eval(`(() => {
|
||||
const view = app.workspace.activeEditor;
|
||||
if (!view) return 'no-view';
|
||||
if (typeof view.getMode === 'function') return view.getMode();
|
||||
return 'unknown';
|
||||
})()`);
|
||||
log('📊', `Mode after switch back: ${mode}`);
|
||||
});
|
||||
|
||||
await runner.run('Read-only mode: timer HTML span intact after returning to source mode', async() => {
|
||||
await sleep(500);
|
||||
const lineText = await runner.eval(`app.workspace.activeEditor.editor.getLine(${testLineNum})`);
|
||||
assert(lineText.includes('class="timer-r"') || lineText.includes('class="timer-p"'),
|
||||
`Timer span should be intact after mode round-trip, got: "${lineText.substring(0, 100)}..."`);
|
||||
log('📊', `Line text after mode round-trip: timer span present ✓`);
|
||||
});
|
||||
|
||||
await runner.run('Read-only mode: dur increased during preview round-trip', async() => {
|
||||
const finalDur = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
const d = p.manager.getTimerData('${createdTimerId}');
|
||||
return d ? d.dur : -1;
|
||||
})()`);
|
||||
const totalIncrease = finalDur - initialDur;
|
||||
log('📊', `Total dur increase over preview round-trip: ${totalIncrease}s (initial=${initialDur}s, final=${finalDur}s)`);
|
||||
assert(totalIncrease >= 3,
|
||||
`Expected >= 3s increase during preview round-trip, got ${totalIncrease}s`);
|
||||
});
|
||||
|
||||
// ── Ensure editor is fully ready for subsequent chains ──────────────────
|
||||
// After preview → source switch, the editor reference may need extra time.
|
||||
await runner.evalAsync(`
|
||||
const mdLeaves = app.workspace.getLeavesOfType('markdown');
|
||||
if (mdLeaves.length > 0) {
|
||||
app.workspace.setActiveLeaf(mdLeaves[0], { focus: true });
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
`);
|
||||
// Wait and verify editor is accessible
|
||||
await sleep(500);
|
||||
const editorReady = await runner.eval(`(() => {
|
||||
const v = app.workspace.activeEditor;
|
||||
return !!(v && v.editor);
|
||||
})()`);
|
||||
if (!editorReady) {
|
||||
log('⚠️', 'Editor not ready after readonly chain, waiting extra 2s...');
|
||||
await sleep(2000);
|
||||
}
|
||||
log('⚙️', 'Editor re-focused after readonly tests');
|
||||
}
|
||||
|
||||
165
tests/chains/restore_behavior.mjs
Normal file
165
tests/chains/restore_behavior.mjs
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { sleep, assert, log, WAIT_AFTER_COMMAND, TICK_TOLERANCE, idbGetTimer } from '../e2e-timer-test.mjs';
|
||||
|
||||
// ─── Chain: restore_behavior ─────────────────────────────────────────────────
|
||||
// Tests autoStopTimers behavior (close mode — forcepause all timers on file reopen)
|
||||
|
||||
export async function chain_restore_behavior(runner) {
|
||||
// This test verifies the `close` mode of autoStopTimers:
|
||||
// When a file is re-opened with autoStopTimers='close', all running timers should be force-paused.
|
||||
|
||||
let rstTimerId = null;
|
||||
|
||||
const rstTestLine = await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
const last = ed.lineCount() - 1;
|
||||
ed.replaceRange('\\nE2E_RST_TEST_' + Date.now(), { line: last, ch: ed.getLine(last).length });
|
||||
const n = ed.lineCount() - 1;
|
||||
ed.setCursor({ line: n, ch: 0 });
|
||||
return n;
|
||||
})()`);
|
||||
await sleep(500);
|
||||
|
||||
// Start a timer
|
||||
await runner.run('RST-01: Start timer for restore test', async() => {
|
||||
await runner.setCursorToLine(rstTestLine);
|
||||
await sleep(200);
|
||||
await runner.executeCommand('text-block-timer:toggle-timer');
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
const lineText = await runner.eval(`app.workspace.activeEditor.editor.getLine(${rstTestLine})`);
|
||||
assert(lineText.includes('class="timer-r"'), 'Expected timer-r span');
|
||||
const match = lineText.match(/id="([^"]+)"/);
|
||||
assert(match, 'Timer ID not found');
|
||||
rstTimerId = match[1];
|
||||
log('🆔', `Restore test timer ID: ${rstTimerId}`);
|
||||
});
|
||||
|
||||
// Test autoStopTimers='close' via restoreTimers
|
||||
await runner.run('RST-02: autoStopTimers=close forcepause-s running timer on restoreTimers', async() => {
|
||||
// Set autoStopTimers to 'close'
|
||||
await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
p.settings.autoStopTimers = 'close';
|
||||
return true;
|
||||
})()`);
|
||||
|
||||
// Stop the timer in manager first (simulates plugin restart — manager is empty)
|
||||
await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
p.manager.stopTimer('${rstTimerId}');
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(300);
|
||||
|
||||
// Call restoreTimers (simulates file re-open) — needs active view as argument
|
||||
await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
const view = app.workspace.activeEditor;
|
||||
p.restoreTimers(view);
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
// Verify timer is force-paused in Markdown
|
||||
const lineText = await runner.eval(`app.workspace.activeEditor.editor.getLine(${rstTestLine})`);
|
||||
assert(lineText.includes('class="timer-p"'), `Expected timer-p after close restore, got: "${lineText?.substring(0, 100)}"`);
|
||||
|
||||
// Verify not in TimerManager
|
||||
const managerHas = await runner.eval(`app.plugins.plugins['text-block-timer'].manager.hasTimer('${rstTimerId}')`);
|
||||
assert(!managerHas, 'Timer should NOT be in manager after forcepause');
|
||||
log('✅', 'Timer force-paused by autoStopTimers=close');
|
||||
});
|
||||
|
||||
// Test autoStopTimers='never' restores the timer
|
||||
await runner.run('RST-03: autoStopTimers=never restores running timer', async() => {
|
||||
// First make the span timer-r again (simulate it was running before restore)
|
||||
await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
const line = ed.getLine(${rstTestLine});
|
||||
const newLine = line.replace('class="timer-p"', 'class="timer-r"');
|
||||
ed.replaceRange(newLine, { line: ${rstTestLine}, ch: 0 }, { line: ${rstTestLine}, ch: line.length });
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(300);
|
||||
|
||||
// Set autoStopTimers to 'never'
|
||||
await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
p.settings.autoStopTimers = 'never';
|
||||
return true;
|
||||
})()`);
|
||||
|
||||
await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
const view = app.workspace.activeEditor;
|
||||
p.restoreTimers(view);
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
// Verify timer is restored (running in manager)
|
||||
const managerHas = await runner.eval(`app.plugins.plugins['text-block-timer'].manager.hasTimer('${rstTimerId}')`);
|
||||
assert(managerHas, 'Timer should be in manager after never-mode restore');
|
||||
log('✅', 'Timer restored by autoStopTimers=never');
|
||||
});
|
||||
|
||||
// RST-04: Cross-layer consistency after restore
|
||||
await runner.run('RST-04: Three-layer consistency after restore', async() => {
|
||||
// Get Markdown dur
|
||||
const lineText = await runner.eval(`app.workspace.activeEditor.editor.getLine(${rstTestLine})`);
|
||||
const mdDurMatch = lineText.match(/data-dur="(\d+)"/);
|
||||
const mdDur = mdDurMatch ? parseInt(mdDurMatch[1]) : -1;
|
||||
|
||||
// Get JSON dur
|
||||
const jsonDur = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
const e = p.database.getEntry('${rstTimerId}');
|
||||
return e ? e.total_dur_sec : -1;
|
||||
})()`);
|
||||
|
||||
// Get IDB dur
|
||||
const idbRaw = await runner.evalAsync(idbGetTimer(rstTimerId));
|
||||
const idbEntry = idbRaw ? JSON.parse(idbRaw) : null;
|
||||
const idbDur = idbEntry ? idbEntry.total_dur_sec : -1;
|
||||
|
||||
log('📊', `Cross-layer dur: MD=${mdDur}, JSON=${jsonDur}, IDB=${idbDur}`);
|
||||
assert(Math.abs(mdDur - jsonDur) <= TICK_TOLERANCE, `MD (${mdDur}) vs JSON (${jsonDur}) diff > ${TICK_TOLERANCE}s`);
|
||||
assert(Math.abs(jsonDur - idbDur) <= TICK_TOLERANCE, `JSON (${jsonDur}) vs IDB (${idbDur}) diff > ${TICK_TOLERANCE}s`);
|
||||
});
|
||||
|
||||
// Cleanup: stop timer, delete entry, remove test line
|
||||
if (rstTimerId) {
|
||||
await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
p.manager.stopTimer('${rstTimerId}');
|
||||
return true;
|
||||
})()`);
|
||||
await runner.evalAsync(`
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
await p.database.updateEntry('${rstTimerId}', { state: 'deleted' });
|
||||
await p.idb.patchTimer('${rstTimerId}', { state: 'deleted' });
|
||||
return true;
|
||||
`);
|
||||
}
|
||||
// Restore autoStopTimers to default
|
||||
await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
p.settings.autoStopTimers = 'quit';
|
||||
return true;
|
||||
})()`);
|
||||
await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
for (let i = ed.lineCount() - 1; i >= 0; i--) {
|
||||
if (ed.getLine(i).includes('E2E_RST_TEST_')) {
|
||||
const from = Math.max(0, i - 1);
|
||||
const fromCh = i > 0 ? ed.getLine(from).length : 0;
|
||||
ed.replaceRange('', { line: from, ch: fromCh }, { line: i, ch: ed.getLine(i).length });
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(500);
|
||||
log('🧹', 'Restore test cleaned up');
|
||||
}
|
||||
|
||||
66
tests/chains/seed.mjs
Normal file
66
tests/chains/seed.mjs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { sleep, assert, log, idbGetTimer, idbGetAllTimers, shared } from '../e2e-timer-test.mjs';
|
||||
|
||||
// ─── Chain: seed ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function chain_seed(runner) {
|
||||
const createdTimerId = shared.createdTimerId;
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// PHASE 14: seedIndexedDB + clearAll — reload plugin and verify clean seed
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
await runner.run('seedIndexedDB: clearAll removes stale data on reload', async() => {
|
||||
// Count IDB entries before reload
|
||||
const beforeAllRaw = await runner.evalAsync(idbGetAllTimers());
|
||||
const beforeAll = JSON.parse(beforeAllRaw);
|
||||
const beforeCount = beforeAll.length;
|
||||
|
||||
// Inject a "stale" timer directly into IDB to simulate leftover data
|
||||
await runner.evalAsync(`
|
||||
const idb = app.plugins.plugins['text-block-timer'].idb;
|
||||
await idb.putTimer({
|
||||
timer_id: 'STALE_TEST_TIMER_XYZ',
|
||||
file_path: 'nonexistent.md',
|
||||
line_num: 0,
|
||||
line_text: 'stale',
|
||||
project: null,
|
||||
state: 'paused',
|
||||
total_dur_sec: 999,
|
||||
last_ts: 0,
|
||||
created_at: 0,
|
||||
updated_at: 0
|
||||
});
|
||||
`);
|
||||
|
||||
// Verify stale entry exists
|
||||
const staleRaw = await runner.evalAsync(idbGetTimer('STALE_TEST_TIMER_XYZ'));
|
||||
assert(staleRaw, 'Stale timer should exist in IDB before reload');
|
||||
|
||||
// Trigger seedIndexedDB (simulates plugin reload)
|
||||
await runner.evalAsync(`
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
// Call the private seedIndexedDB through the plugin instance
|
||||
if (typeof p.seedIndexedDB === 'function') {
|
||||
await p.seedIndexedDB();
|
||||
} else {
|
||||
// Access through prototype or direct property
|
||||
const proto = Object.getPrototypeOf(p);
|
||||
const method = proto.seedIndexedDB?.bind(p);
|
||||
if (method) await method();
|
||||
}
|
||||
`);
|
||||
await sleep(1000);
|
||||
|
||||
// Verify stale entry is gone
|
||||
const afterStaleRaw = await runner.evalAsync(idbGetTimer('STALE_TEST_TIMER_XYZ'));
|
||||
assert(!afterStaleRaw, 'Stale timer should be cleared by seedIndexedDB (clearAll called)');
|
||||
|
||||
// Our real timer should still be present (re-seeded from JSON)
|
||||
const ourRaw = await runner.evalAsync(idbGetTimer(createdTimerId));
|
||||
assert(ourRaw, 'Our timer should be re-seeded from JSON');
|
||||
const ours = JSON.parse(ourRaw);
|
||||
assert(ours.state === 'paused', `Expected paused after reseed, got ${ours.state}`);
|
||||
log('📊', `After reseed: stale removed ✓, our timer preserved ✓ (state=${ours.state})`);
|
||||
});
|
||||
}
|
||||
|
||||
152
tests/chains/settings_behavior.mjs
Normal file
152
tests/chains/settings_behavior.mjs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { sleep, assert, log, WAIT_AFTER_COMMAND } from '../e2e-timer-test.mjs';
|
||||
|
||||
// ─── Chain: settings_behavior ────────────────────────────────────────────────
|
||||
// Tests that settings changes affect timer behavior
|
||||
|
||||
export async function chain_settings_behavior(runner) {
|
||||
// SET-01 & SET-02: timerInsertLocation
|
||||
let settingsTimerId = null;
|
||||
|
||||
// Save original settings
|
||||
const origSettings = await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
return JSON.stringify({
|
||||
timerInsertLocation: p.settings.timerInsertLocation,
|
||||
runningIcon: p.settings.runningIcon,
|
||||
pausedIcon: p.settings.pausedIcon,
|
||||
timeDisplayFormat: p.settings.timeDisplayFormat
|
||||
});
|
||||
})()`);
|
||||
const orig = JSON.parse(origSettings);
|
||||
log('⚙️', `Original settings: insertLocation=${orig.timerInsertLocation}`);
|
||||
|
||||
// Create test line for tail insertion
|
||||
const setTestLine = await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
const last = ed.lineCount() - 1;
|
||||
ed.replaceRange('\\nE2E_SETTINGS_TEST_' + Date.now(), { line: last, ch: ed.getLine(last).length });
|
||||
const n = ed.lineCount() - 1;
|
||||
ed.setCursor({ line: n, ch: 0 });
|
||||
return n;
|
||||
})()`);
|
||||
await sleep(500);
|
||||
|
||||
await runner.run('SET-01: timerInsertLocation=tail inserts timer at end of line', async() => {
|
||||
// Set to tail
|
||||
await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
p.settings.timerInsertLocation = 'tail';
|
||||
return true;
|
||||
})()`);
|
||||
|
||||
await runner.setCursorToLine(setTestLine);
|
||||
await sleep(200);
|
||||
await runner.executeCommand('text-block-timer:toggle-timer');
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
const lineText = await runner.eval(`app.workspace.activeEditor.editor.getLine(${setTestLine})`);
|
||||
assert(lineText.includes('class="timer-r"'), 'Expected timer-r span');
|
||||
|
||||
// Timer should be at the end: line text starts with 'E2E_SETTINGS_TEST_'
|
||||
assert(lineText.startsWith('E2E_SETTINGS_TEST_'), `Expected line to start with test prefix, got: "${lineText?.substring(0, 40)}"`);
|
||||
// Extract timer span position
|
||||
const spanIdx = lineText.indexOf('<span');
|
||||
const textBeforeSpan = lineText.substring(0, spanIdx).trim();
|
||||
assert(textBeforeSpan.startsWith('E2E_SETTINGS_TEST_'), 'Timer span should be at tail');
|
||||
log('📊', `Tail insert verified: spanIdx=${spanIdx}, lineLen=${lineText.length}`);
|
||||
|
||||
const match = lineText.match(/id="([^"]+)"/);
|
||||
settingsTimerId = match?.[1];
|
||||
});
|
||||
|
||||
// Pause and delete for next test
|
||||
if (settingsTimerId) {
|
||||
await runner.setCursorToLine(setTestLine);
|
||||
await sleep(200);
|
||||
await runner.executeCommand('text-block-timer:toggle-timer');
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
await runner.executeCommand('text-block-timer:delete-timer');
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
}
|
||||
|
||||
await runner.run('SET-02: timerInsertLocation=head inserts timer at start (after checkbox/list prefix)', async() => {
|
||||
// Set to head
|
||||
await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
p.settings.timerInsertLocation = 'head';
|
||||
return true;
|
||||
})()`);
|
||||
|
||||
// Create a line with a list prefix
|
||||
const headTestLine = await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
const last = ed.lineCount() - 1;
|
||||
ed.replaceRange('\\n- E2E_HEAD_TEST_' + Date.now(), { line: last, ch: ed.getLine(last).length });
|
||||
const n = ed.lineCount() - 1;
|
||||
ed.setCursor({ line: n, ch: 0 });
|
||||
return n;
|
||||
})()`);
|
||||
await sleep(500);
|
||||
|
||||
await runner.setCursorToLine(headTestLine);
|
||||
await sleep(200);
|
||||
await runner.executeCommand('text-block-timer:toggle-timer');
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
|
||||
const lineText = await runner.eval(`app.workspace.activeEditor.editor.getLine(${headTestLine})`);
|
||||
assert(lineText.includes('class="timer-r"'), 'Expected timer-r span');
|
||||
|
||||
// Timer should be right after the "- " prefix
|
||||
assert(lineText.startsWith('- <span'), `Expected line to start with '- <span', got: "${lineText?.substring(0, 40)}"`);
|
||||
log('📊', `Head insert verified: line starts with "- <span"`);
|
||||
|
||||
// Cleanup: pause and delete
|
||||
const match = lineText.match(/id="([^"]+)"/);
|
||||
if (match) {
|
||||
await runner.setCursorToLine(headTestLine);
|
||||
await sleep(200);
|
||||
await runner.executeCommand('text-block-timer:toggle-timer');
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
await runner.executeCommand('text-block-timer:delete-timer');
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
}
|
||||
|
||||
// Remove test line
|
||||
await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
for (let i = ed.lineCount() - 1; i >= 0; i--) {
|
||||
if (ed.getLine(i).includes('E2E_HEAD_TEST_')) {
|
||||
const from = Math.max(0, i - 1);
|
||||
const fromCh = i > 0 ? ed.getLine(from).length : 0;
|
||||
ed.replaceRange('', { line: from, ch: fromCh }, { line: i, ch: ed.getLine(i).length });
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})()`);
|
||||
});
|
||||
|
||||
// Restore original settings
|
||||
await runner.eval(`(() => {
|
||||
const p = app.plugins.plugins['text-block-timer'];
|
||||
p.settings.timerInsertLocation = '${orig.timerInsertLocation || 'head'}';
|
||||
return true;
|
||||
})()`);
|
||||
|
||||
// Cleanup settings test line
|
||||
await runner.eval(`(() => {
|
||||
const ed = app.workspace.activeEditor.editor;
|
||||
for (let i = ed.lineCount() - 1; i >= 0; i--) {
|
||||
if (ed.getLine(i).includes('E2E_SETTINGS_TEST_')) {
|
||||
const from = Math.max(0, i - 1);
|
||||
const fromCh = i > 0 ? ed.getLine(from).length : 0;
|
||||
ed.replaceRange('', { line: from, ch: fromCh }, { line: i, ch: ed.getLine(i).length });
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(500);
|
||||
log('🧹', 'Settings test cleaned up');
|
||||
}
|
||||
|
||||
401
tests/chains/sidebar_tabs.mjs
Normal file
401
tests/chains/sidebar_tabs.mjs
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
import { sleep, assert, log, WAIT_AFTER_COMMAND, shared } from '../e2e-timer-test.mjs';
|
||||
|
||||
// ─── Chain: sidebar_tabs ──────────────────────────────────────────────────────
|
||||
// Tests sidebar scope switching, filter/sort controls, summary correctness,
|
||||
// and statistics chart data loading for all scopes.
|
||||
|
||||
export async function chain_sidebar_tabs(runner) {
|
||||
const today = shared.today;
|
||||
const createdTimerId = shared.createdTimerId;
|
||||
const testLineNum = shared.testLineNum;
|
||||
|
||||
// Make sure our timer is running for these tests
|
||||
const currentState = await runner.eval(`(() => {
|
||||
const lineText = app.workspace.activeEditor.editor.getLine(${testLineNum});
|
||||
if (lineText.includes('class="timer-r"')) return 'running';
|
||||
if (lineText.includes('class="timer-p"')) return 'paused';
|
||||
return 'none';
|
||||
})()`);
|
||||
if (currentState === 'paused') {
|
||||
await runner.setCursorToLine(testLineNum);
|
||||
await sleep(200);
|
||||
await runner.executeCommand('text-block-timer:toggle-timer');
|
||||
await sleep(WAIT_AFTER_COMMAND);
|
||||
log('⚙️', 'Resumed timer for sidebar_tabs tests');
|
||||
}
|
||||
await sleep(2000); // let ticks accumulate
|
||||
|
||||
// ── Scope: active-file ──────────────────────────────────────────────────
|
||||
|
||||
await runner.run('Sidebar scope: active-file shows timer from current file', async() => {
|
||||
// Switch to active-file scope
|
||||
await runner.evalAsync(`
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (leaves.length) {
|
||||
const view = leaves[0].view;
|
||||
view.currentScope = 'active-file';
|
||||
await view.loadData();
|
||||
view.render();
|
||||
}
|
||||
`);
|
||||
await sleep(500);
|
||||
|
||||
const result = JSON.parse(await runner.eval(`(() => {
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (!leaves.length) return JSON.stringify({ skip: true });
|
||||
const view = leaves[0].view;
|
||||
const list = view.timerList;
|
||||
const hasOurs = list.some(t => t.timerId === '${createdTimerId}');
|
||||
return JSON.stringify({
|
||||
skip: false,
|
||||
scope: view.currentScope,
|
||||
timerCount: list.length,
|
||||
hasOurs: hasOurs
|
||||
});
|
||||
})()`));
|
||||
|
||||
if (!result.skip) {
|
||||
log('📊', `active-file scope: ${result.timerCount} timers, hasOurs=${result.hasOurs}`);
|
||||
assert(result.scope === 'active-file', `Expected scope active-file, got ${result.scope}`);
|
||||
assert(result.hasOurs, 'Our timer should be visible in active-file scope');
|
||||
}
|
||||
});
|
||||
|
||||
// ── Scope: open-tabs ────────────────────────────────────────────────────
|
||||
|
||||
await runner.run('Sidebar scope: open-tabs shows timer from open tabs', async() => {
|
||||
await runner.evalAsync(`
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (leaves.length) {
|
||||
const view = leaves[0].view;
|
||||
view.currentScope = 'open-tabs';
|
||||
await view.loadData();
|
||||
view.render();
|
||||
}
|
||||
`);
|
||||
await sleep(500);
|
||||
|
||||
const result = JSON.parse(await runner.eval(`(() => {
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (!leaves.length) return JSON.stringify({ skip: true });
|
||||
const view = leaves[0].view;
|
||||
const list = view.timerList;
|
||||
const hasOurs = list.some(t => t.timerId === '${createdTimerId}');
|
||||
return JSON.stringify({
|
||||
skip: false,
|
||||
scope: view.currentScope,
|
||||
timerCount: list.length,
|
||||
hasOurs: hasOurs
|
||||
});
|
||||
})()`));
|
||||
|
||||
if (!result.skip) {
|
||||
log('📊', `open-tabs scope: ${result.timerCount} timers, hasOurs=${result.hasOurs}`);
|
||||
assert(result.scope === 'open-tabs', `Expected scope open-tabs, got ${result.scope}`);
|
||||
assert(result.hasOurs, 'Our timer should be visible in open-tabs scope');
|
||||
}
|
||||
});
|
||||
|
||||
// ── Scope: all ──────────────────────────────────────────────────────────
|
||||
|
||||
await runner.run('Sidebar scope: all shows timer from IDB', async() => {
|
||||
await runner.evalAsync(`
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (leaves.length) {
|
||||
const view = leaves[0].view;
|
||||
view.currentScope = 'all';
|
||||
await view.loadData();
|
||||
view.render();
|
||||
}
|
||||
`);
|
||||
await sleep(500);
|
||||
|
||||
const result = JSON.parse(await runner.eval(`(() => {
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (!leaves.length) return JSON.stringify({ skip: true });
|
||||
const view = leaves[0].view;
|
||||
const list = view.timerList;
|
||||
const hasOurs = list.some(t => t.timerId === '${createdTimerId}');
|
||||
return JSON.stringify({
|
||||
skip: false,
|
||||
scope: view.currentScope,
|
||||
timerCount: list.length,
|
||||
hasOurs: hasOurs
|
||||
});
|
||||
})()`));
|
||||
|
||||
if (!result.skip) {
|
||||
log('📊', `all scope: ${result.timerCount} timers, hasOurs=${result.hasOurs}`);
|
||||
assert(result.scope === 'all', `Expected scope all, got ${result.scope}`);
|
||||
assert(result.hasOurs, 'Our timer should be visible in all scope');
|
||||
assert(result.timerCount >= 1, `Expected >= 1 timers in all scope, got ${result.timerCount}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Filter: running vs paused ───────────────────────────────────────────
|
||||
|
||||
await runner.run('Sidebar filter: running filter shows only running timers', async() => {
|
||||
// Switch back to open-tabs and apply running filter
|
||||
await runner.evalAsync(`
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (leaves.length) {
|
||||
const view = leaves[0].view;
|
||||
view.currentScope = 'open-tabs';
|
||||
view.currentFilter = 'running';
|
||||
await view.loadData();
|
||||
view.render();
|
||||
}
|
||||
`);
|
||||
await sleep(500);
|
||||
|
||||
const result = JSON.parse(await runner.eval(`(() => {
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (!leaves.length) return JSON.stringify({ skip: true });
|
||||
const c = leaves[0].view.containerEl;
|
||||
const runCards = c.querySelectorAll('.timer-card-running').length;
|
||||
const pausedCards = c.querySelectorAll('.timer-card-paused').length;
|
||||
return JSON.stringify({
|
||||
skip: false,
|
||||
runCards: runCards,
|
||||
pausedCards: pausedCards
|
||||
});
|
||||
})()`));
|
||||
|
||||
if (!result.skip) {
|
||||
log('📊', `Running filter: ${result.runCards} running cards, ${result.pausedCards} paused cards`);
|
||||
assert(result.pausedCards === 0,
|
||||
`Running filter should hide paused cards, got ${result.pausedCards}`);
|
||||
assert(result.runCards >= 1,
|
||||
`Running filter should show >= 1 running cards, got ${result.runCards}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Filter: reset to all ────────────────────────────────────────────────
|
||||
|
||||
await runner.run('Sidebar filter: "all" filter shows both running and paused', async() => {
|
||||
await runner.evalAsync(`
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (leaves.length) {
|
||||
const view = leaves[0].view;
|
||||
view.currentFilter = 'all';
|
||||
view.render();
|
||||
}
|
||||
`);
|
||||
await sleep(500);
|
||||
|
||||
const result = JSON.parse(await runner.eval(`(() => {
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (!leaves.length) return JSON.stringify({ skip: true });
|
||||
const c = leaves[0].view.containerEl;
|
||||
const totalCards = c.querySelectorAll('.timer-card').length;
|
||||
return JSON.stringify({ skip: false, totalCards });
|
||||
})()`));
|
||||
|
||||
if (!result.skip) {
|
||||
log('📊', `All filter: ${result.totalCards} total cards`);
|
||||
assert(result.totalCards >= 1, `Expected >= 1 cards, got ${result.totalCards}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Sort: dur-desc vs dur-asc ───────────────────────────────────────────
|
||||
|
||||
await runner.run('Sidebar sort: dur-desc orders longest first', async() => {
|
||||
// Use loadData + render to ensure timerList is re-sorted
|
||||
await runner.evalAsync(`
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (leaves.length) {
|
||||
const view = leaves[0].view;
|
||||
view.currentSort = 'dur-desc';
|
||||
await view.loadData();
|
||||
view.render();
|
||||
}
|
||||
`);
|
||||
await sleep(500);
|
||||
|
||||
// Read durations from rendered DOM cards (which reflect the actual display order)
|
||||
const durs = JSON.parse(await runner.eval(`(() => {
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (!leaves.length) return '[]';
|
||||
const c = leaves[0].view.containerEl;
|
||||
const cards = c.querySelectorAll('.timer-card');
|
||||
const result = [];
|
||||
for (const card of cards) {
|
||||
const durEl = card.querySelector('.timer-card-duration');
|
||||
if (durEl) {
|
||||
// Parse HH:MM:SS or MM:SS format to seconds
|
||||
const text = durEl.textContent.trim();
|
||||
const parts = text.split(':').map(Number);
|
||||
let secs = 0;
|
||||
if (parts.length === 3) secs = parts[0]*3600 + parts[1]*60 + parts[2];
|
||||
else if (parts.length === 2) secs = parts[0]*60 + parts[1];
|
||||
else secs = parts[0];
|
||||
result.push(secs);
|
||||
}
|
||||
}
|
||||
return JSON.stringify(result);
|
||||
})()`));
|
||||
|
||||
log('📊', `dur-desc order: [${durs.join(', ')}]`);
|
||||
if (durs.length >= 2) {
|
||||
for (let i = 1; i < durs.length; i++) {
|
||||
assert(durs[i - 1] >= durs[i],
|
||||
`dur-desc: ${durs[i - 1]} should be >= ${durs[i]} at position ${i}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Summary row data-attributes ─────────────────────────────────────────
|
||||
|
||||
await runner.run('Sidebar summary: data-attributes are consistent with timerList', async() => {
|
||||
// Reset sort/scope to default
|
||||
await runner.evalAsync(`
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (leaves.length) {
|
||||
const view = leaves[0].view;
|
||||
view.currentScope = 'open-tabs';
|
||||
view.currentSort = 'status';
|
||||
view.currentFilter = 'all';
|
||||
await view.loadData();
|
||||
view.render();
|
||||
}
|
||||
`);
|
||||
await sleep(500);
|
||||
|
||||
const result = JSON.parse(await runner.eval(`(() => {
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (!leaves.length) return JSON.stringify({ skip: true });
|
||||
const view = leaves[0].view;
|
||||
const c = view.containerEl;
|
||||
const list = view.timerList;
|
||||
const running = list.filter(t => t.state === 'timer-r').length;
|
||||
const paused = list.filter(t => t.state === 'timer-p').length;
|
||||
const total = list.length;
|
||||
|
||||
const summaryTotal = parseInt(c.querySelector('[data-summary-count]')?.textContent ?? '-1');
|
||||
const summaryRunning = parseInt(c.querySelector('[data-summary-running-count]')?.textContent ?? '-1');
|
||||
const summaryPaused = parseInt(c.querySelector('[data-summary-paused-count]')?.textContent ?? '-1');
|
||||
|
||||
return JSON.stringify({
|
||||
skip: false,
|
||||
listTotal: total, listRunning: running, listPaused: paused,
|
||||
summaryTotal, summaryRunning, summaryPaused
|
||||
});
|
||||
})()`));
|
||||
|
||||
if (!result.skip) {
|
||||
log('📊', `Summary: list(${result.listTotal}R=${result.listRunning},P=${result.listPaused}) ` +
|
||||
`vs DOM(${result.summaryTotal}R=${result.summaryRunning},P=${result.summaryPaused})`);
|
||||
assert(result.summaryTotal === result.listTotal,
|
||||
`Summary count ${result.summaryTotal} should match timerList ${result.listTotal}`);
|
||||
assert(result.summaryRunning === result.listRunning,
|
||||
`Summary running ${result.summaryRunning} should match timerList ${result.listRunning}`);
|
||||
assert(result.summaryPaused === result.listPaused,
|
||||
`Summary paused ${result.summaryPaused} should match timerList ${result.listPaused}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Statistics chart: data loaded from IDB ──────────────────────────────
|
||||
|
||||
await runner.run('Sidebar chart: chartDataCache has today with positive duration', async() => {
|
||||
const result = JSON.parse(await runner.eval(`(() => {
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (!leaves.length) return JSON.stringify({ skip: true });
|
||||
const view = leaves[0].view;
|
||||
if (!view.chartDataCache) return JSON.stringify({ skip: true, reason: 'no cache' });
|
||||
const cache = view.chartDataCache;
|
||||
const todayDur = {};
|
||||
for (const proj of cache.projects) {
|
||||
todayDur[proj] = (cache.matrix[proj] ?? {})['${today}'] ?? 0;
|
||||
}
|
||||
return JSON.stringify({
|
||||
skip: false,
|
||||
dates: cache.dates,
|
||||
projects: cache.projects,
|
||||
todayDur: todayDur,
|
||||
hasToday: cache.dates.includes('${today}')
|
||||
});
|
||||
})()`));
|
||||
|
||||
if (!result.skip) {
|
||||
log('📊', `Chart: dates=${JSON.stringify(result.dates)}, projects=${JSON.stringify(result.projects)}`);
|
||||
log('📊', `Chart today dur: ${JSON.stringify(result.todayDur)}`);
|
||||
assert(result.hasToday, `Chart dates should include today (${today})`);
|
||||
// At least one project should have positive duration today
|
||||
const anyPositive = Object.values(result.todayDur).some(v => v > 0);
|
||||
assert(anyPositive, 'At least one project should have > 0 duration for today in chart');
|
||||
} else {
|
||||
log('⚠️', `Chart data not available (${result.reason ?? ''}) — skipping`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Statistics toggle ───────────────────────────────────────────────────
|
||||
|
||||
await runner.run('Sidebar chart: statistics toggle hides/shows chart', async() => {
|
||||
// Toggle off
|
||||
await runner.evalAsync(`
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (leaves.length) {
|
||||
const view = leaves[0].view;
|
||||
view.showStatistics = false;
|
||||
view.render();
|
||||
}
|
||||
`);
|
||||
await sleep(300);
|
||||
|
||||
const hiddenChart = await runner.eval(`(() => {
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (!leaves.length) return null;
|
||||
const view = leaves[0].view;
|
||||
return view.chartInstance === null;
|
||||
})()`);
|
||||
assert(hiddenChart === true, 'Chart instance should be null when statistics hidden');
|
||||
|
||||
// Toggle on
|
||||
await runner.evalAsync(`
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (leaves.length) {
|
||||
const view = leaves[0].view;
|
||||
view.showStatistics = true;
|
||||
view.render();
|
||||
}
|
||||
`);
|
||||
await sleep(500);
|
||||
|
||||
const visibleChart = await runner.eval(`(() => {
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (!leaves.length) return null;
|
||||
const view = leaves[0].view;
|
||||
return view.chartInstance !== null;
|
||||
})()`);
|
||||
assert(visibleChart === true, 'Chart instance should exist when statistics shown');
|
||||
});
|
||||
|
||||
// ── Timer card click → jump to file ─────────────────────────────────────
|
||||
|
||||
await runner.run('Sidebar card: file-source element shows correct file:line', async() => {
|
||||
const result = JSON.parse(await runner.eval(`(() => {
|
||||
const leaves = app.workspace.getLeavesOfType('timer-sidebar');
|
||||
if (!leaves.length) return JSON.stringify({ skip: true });
|
||||
const c = leaves[0].view.containerEl;
|
||||
const cards = c.querySelectorAll('.timer-card');
|
||||
for (const card of cards) {
|
||||
const fileEl = card.querySelector('.timer-card-file-source');
|
||||
if (fileEl?.textContent?.includes(':${testLineNum + 1}')) {
|
||||
return JSON.stringify({
|
||||
skip: false,
|
||||
text: fileEl.textContent,
|
||||
hasLine: true
|
||||
});
|
||||
}
|
||||
}
|
||||
return JSON.stringify({ skip: false, hasLine: false, text: '' });
|
||||
})()`));
|
||||
|
||||
if (!result.skip) {
|
||||
log('📊', `File source: "${result.text}"`);
|
||||
assert(result.hasLine,
|
||||
`Card file-source should contain line number :${testLineNum + 1}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue