feat: modularize E2E tests into chain files, write delete-timer-enhancement PRD, update SKILL.md, add core-timer docs

This commit is contained in:
franklin WTH 2026-03-02 20:59:44 +08:00
parent b4973bd000
commit f0fe81af0a
26 changed files with 4992 additions and 2741 deletions

View file

@ -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 E2Etests/e2e-timer-test.mjs |
| **测试** | 自研 CDP E2Etests/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.mjsCDP 连接、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"
> ```
>
> 测试脚本通过 CDPChrome 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 buildObsidian 自动启动由测试框架处理)
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 个)

View 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
- ✅ **旧格式升级**v1timer-btn class→ v2timer-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. 更新 IndexedDBtickUpdate
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` storekey=`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 被恢复为 pauseddaily_dur 正确结算 | P0 |
| AC-09 | 文件打开恢复quit 模式) | 本次会话启动过的 timer 恢复运行,其余 timer 强制暂停 | P0 |
| AC-10 | 时间调整(增加) | 暂停 timer 时长从 1h 改为 2hdelta 加到 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 spanArrow 键跳过 | 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
---
## 十一、开放问题
无(已上线功能归档文档)。

View 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 设计成熟,交互路径短、状态反馈明确、移动端适配良好。无需修改。

View 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`
- 内层 keytimer_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每秒更新 |
| 持久化状态 | JSONonunload 时 flushSync |
| 实时查询sidebar/status bar | IndexedDB每秒 tickUpdate |
| daily_dur | IndexedDBtick 级精度)= JSONstate 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()` 全文搜索兜底 |

View 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 storedur/ts和 daily_dur storedelta
- `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
- 支持 v1timer-btn class和 v2timer-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` DecorationDecoration.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

View 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 自动补偿 |
---
## 三、技术方案修正
无修正。技术方案基于实际代码编写,与实现一致。
---
## 四、结论
核心计时器的架构设计成熟稳健,职责分离清晰,数据一致性保障到位,性能优化措施全面。审查通过。

View 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 Widgetfolding 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 |

View 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 | 等待 3sdaily_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: seedIDB 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: checkboxCheckbox 联动 — 新增)
| 用例 | 描述 | 验证点 | 优先级 |
|------|------|--------|--------|
| 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 创建的测试行

View file

@ -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+ZUndo或粘贴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+ZUndo或粘贴使得已 `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 |
| 被动删除总耗时 | < 10msJSON 同步 + 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
View 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
View 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
View 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');
}

View 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');
});
}

View 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
View 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');
});
}

View 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
View 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)`);
});
}

View 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');
}

View 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');
});
}

View 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
View 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');
}

View 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
View 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})`);
});
}

View 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');
}

View 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