diff --git a/.codebuddy/skills/text-block-timer-dev-pipeline/SKILL.md b/.codebuddy/skills/text-block-timer-dev-pipeline/SKILL.md index 8bb51ae..8bef057 100644 --- a/.codebuddy/skills/text-block-timer-dev-pipeline/SKILL.md +++ b/.codebuddy/skills/text-block-timer-dev-pipeline/SKILL.md @@ -23,7 +23,7 @@ text-block-timer-dev-skill/ | **技术栈** | TypeScript 5、esbuild、CodeMirror 6、Obsidian API、ECharts 5、IndexedDB | | **数据层** | 三层同步:Markdown HTML span ↔ timer-db.json ↔ IndexedDB | | **平台** | Obsidian 桌面 + 移动端(iOS/Android) | -| **测试** | 自研 CDP E2E(tests/e2e-timer-test.mjs) | +| **测试** | 自研 CDP E2E(tests/e2e-timer-test.mjs + tests/chains/*.mjs,模块化测试链架构) | | **国际化** | 5 种语言(en/zh/zhTW/ja/ko) | ### 通用约束 @@ -47,7 +47,7 @@ text-block-timer-dev-skill/ | 技术栈 | TypeScript 5、esbuild、CodeMirror 6、Obsidian API、ECharts 5、IndexedDB | | 入口文件 | `src/main.ts` | | 构建命令 | `npm run dev`(watch)/ `npm run build`(production) | -| 测试框架 | 自研 CDP E2E(`tests/e2e-timer-test.mjs`,基于 Chrome DevTools Protocol) | +| 测试框架 | 自研 CDP E2E(主入口 `tests/e2e-timer-test.mjs` + 链模块 `tests/chains/*.mjs`,基于 Chrome DevTools Protocol,自动启动 Obsidian) | | 数据存储 | Markdown 行内 HTML span + `timer-db.json`(JSON)+ IndexedDB(`TimerPluginDB`) | ### 源码目录结构 @@ -672,7 +672,8 @@ M1 阶段名称 | 工具 | 用途 | | ---------- | ----------------------------------------------------- | | read_file | 读取 PRD 验收标准 + 技术方案 | -| read_file | 读取 `tests/e2e-timer-test.mjs` 了解现有测试模式 | +| read_file | 读取 `tests/e2e-timer-test.mjs`(主入口)了解测试框架基础设施 | +| read_file | 读取 `tests/chains/*.mjs` 了解现有测试链模式 | | edit_file | 将测试用例写入 `doc/{{feature_name}}/stage7-test-cases.md` | ### Steps @@ -775,11 +776,40 @@ await this.idb.upsertTimer(idbRecord); **E2E 测试脚本规范**: ``` -- 测试文件: tests/e2e-timer-test.mjs -- 以 chain(测试链)组织,每个 chain 是一组必须连续执行的测试 -- 新增 chain 必须在 CHAIN_REGISTRY 注册并加入 ALL_CHAINS +- 主入口文件: tests/e2e-timer-test.mjs(CDP 连接、TestRunner、IDB 辅助函数、链注册/调度、Obsidian 自动启动) +- 链模块目录: tests/chains/.mjs(每个测试链独立一个文件) +- 每个链文件从主入口 import 共享的 sleep/assert/log/常量/IDB 辅助函数/shared 状态 +- 每个链文件 export 一个 async function chain_xxx(runner) 函数 +- 新增 chain 步骤: + 1. 创建 tests/chains/.mjs 文件 + 2. import 所需的共享函数和常量 + 3. export async function chain_(runner) { ... } + 4. 在主入口的 CHAIN_REGISTRY 注册新链 + 5. 在主入口顶部添加 import 语句 - 每个 chain 结束必须清理测试数据 -- Date monkey-patch(跨天测试)参考现有 chain_crossday +- Date monkey-patch(跨天测试)参考现有 chains/crossday.mjs +- 所有计时器操作必须使用 toggle-timer 命令,禁止手动 startTimer/stopTimer +- 主入口自动检测 Obsidian 是否运行,未运行则通过 Start-Process 启动并等待 CDP 就绪 +``` + +**可用测试链清单**: +``` +- preflight: 插件加载、编辑器、IDB 就绪检查 +- basic: CRUD 生命周期、侧边栏列表/摘要、IDB 一致性 +- adjust: 手动设置时长 + IDB adjustDailyDur +- seed: seedIndexedDB / clearAll 清除过期数据 +- delete: 删除计时器、侧边栏移除、最终一致性 +- crossday: 跨午夜计时器(Date monkey-patch) +- crossday_adjust: 多天计时器 toggle start/pause + 手动增减分天分配 +- sidebar_tabs: 作用域切换、筛选/排序、摘要、图表数据、统计开关 +- readonly: 预览/阅读模式下计时器继续 tick +- passive_delete: 被动删除(编辑器中删除 span)的数据清理 +- checkbox: 复选框勾选触发暂停/恢复 +- crash_recovery: 崩溃恢复(running 状态持久化 + 恢复) +- settings_behavior: 设置行为(timerTextPosition 等) +- restore_behavior: autoStopTimers 恢复行为(quit/close/never) +- onunload_behavior: onunload 刷新 running timer 数据到 JSON +- cleanup_basic: basic/adjust/seed/delete 链的共享清理 ``` **测试操作方式规范(强制)**: @@ -831,22 +861,21 @@ await this.idb.upsertTimer(idbRecord); ### Obsidian 调试模式启动 -> 执行 E2E 测试前,**必须**先确保 Obsidian 以远程调试模式启动: +> 测试框架会**自动检测**并启动 Obsidian 调试模式。无需手动启动。 +> 主入口文件 `tests/e2e-timer-test.mjs` 的 `main()` 函数会: +> 1. 检查 CDP 端口(127.0.0.1:9222)是否已有 Obsidian 在运行 +> 2. 如未运行,通过 `Start-Process` 自动启动 Obsidian 并附带 `--remote-debugging-port=9222` +> 3. 轮询等待最多 60 秒直到 CDP 就绪 > +> 如果需要手动启动: > ``` -> "C:\Users\frankthwang\AppData\Local\Programs\Obsidian\Obsidian.exe" --remote-debugging-port=9222 +> Start-Process "C:\Users\frankthwang\AppData\Local\Programs\Obsidian\Obsidian.exe" -ArgumentList "--remote-debugging-port=9222" > ``` -> -> 测试脚本通过 CDP(Chrome DevTools Protocol)连接到此端口进行自动化操作。 -> 如果 Obsidian 未以调试模式运行,测试将无法连接。 ### Steps ``` -0. LAUNCH: 通过 terminal 启动 Obsidian 调试模式(如果尚未启动): - "C:\Users\frankthwang\AppData\Local\Programs\Obsidian\Obsidian.exe" --remote-debugging-port=9222 - 等待 Obsidian 完全加载后再继续。 -1. BUILD: 调用 terminal 执行 npm run build +1. BUILD: 调用 terminal 执行 npm run build(Obsidian 自动启动由测试框架处理) 2. TEST: 调用 terminal 执行 node tests/e2e-timer-test.mjs [chain_name] 3. ANALYZE: 分析测试结果 IF all_passed: @@ -906,4 +935,5 @@ await this.idb.upsertTimer(idbRecord); - `src/core/TimerIndexedDB.ts` — IndexedDB 数据库 - `src/ui/TimerSidebarView.ts` — 侧边栏视图 - `src/i18n/translations.ts` — 国际化 -- `tests/e2e-timer-test.mjs` — E2E 测试脚本 +- `tests/e2e-timer-test.mjs` — E2E 测试主入口(基础设施 + 链注册 + 调度器 + Obsidian 自动启动) +- `tests/chains/*.mjs` — E2E 测试链模块(每个测试链独立文件,共 16 个) diff --git a/doc/core-timer/stage1-PRD.md b/doc/core-timer/stage1-PRD.md new file mode 100644 index 0000000..1912f6c --- /dev/null +++ b/doc/core-timer/stage1-PRD.md @@ -0,0 +1,351 @@ +# 📋 PRD: Core Timer + +**文档版本**: v1.0 | **创建日期**: 2026-03-02 | **状态**: 补充归档 | **优先级**: P0 + +> 本文档为已上线功能的补充归档 PRD,描述 Text Block Timer 插件的核心计时器功能——即 Sidebar 和 Delete Enhancement 之外的所有功能。 + +--- + +## 一、背景与目标 + +### 1.1 需求来源 + +Obsidian 用户在日常笔记中记录任务、会议、学习等活动,需要一种**轻量级、零侵入**的方式来追踪各项活动的耗时。现有的计时工具要么是独立应用(需要在 Obsidian 外操作),要么使用独立数据库(数据与笔记割裂)。用户希望**计时数据直接嵌入笔记文本**,随笔记一同迁移、版本控制。 + +### 1.2 核心痛点 + +| 痛点 | 描述 | +|------|------| +| 工具割裂 | 计时工具与笔记工具分离,需频繁切换窗口 | +| 数据孤岛 | 计时数据存储在独立系统中,与笔记内容脱节 | +| 操作繁琐 | 每次计时需手动记录开始/结束时间再计算 | +| 缺乏持久化 | Obsidian 关闭或崩溃后计时数据丢失 | +| 格式不一 | 手动记录的时间格式不统一,难以汇总 | + +### 1.3 目标 + +1. **零侵入计时**:计时器以 HTML span 形式内嵌 Markdown,不影响笔记的纯文本可读性 +2. **多触发方式**:支持命令面板(快捷键)、右键菜单、Checkbox 状态联动三种触发方式 +3. **实时显示**:运行中计时器每秒更新时长,编辑器内直观可见 +4. **三层持久化**:Markdown HTML span ↔ JSON 数据库 ↔ IndexedDB,三层数据保持一致 +5. **崩溃安全**:Obsidian 异常退出后,下次启动自动恢复/结算运行中计时器 +6. **跨天支持**:运行中计时器跨越午夜时自动拆分日统计 +7. **高度可配**:插入位置、时间格式、显示样式、颜色、图标等均可自定义 +8. **国际化**:支持 en/zh/zhTW/ja/ko 五种语言 +9. **向后兼容**:自动升级 v1 旧格式计时器至 v2 新格式 + +--- + +## 二、用户故事 + +| ID | 角色 | 故事 | 验收标准 | +|----|------|------|----------| +| US-01 | 日常用户 | 我希望在任意文本行上启动一个计时器 | 光标定位到目标行,按快捷键/右键菜单,计时器立即出现并开始计时 | +| US-02 | 日常用户 | 我希望暂停正在运行的计时器 | 在运行中的计时器行上按快捷键/右键菜单,计时器暂停,时长冻结 | +| US-03 | 日常用户 | 我希望恢复已暂停的计时器 | 在暂停的计时器行上按快捷键/右键菜单,计时器恢复运行 | +| US-04 | Tasks 用户 | 我希望通过切换 Checkbox 状态来控制计时器 | 将任务从 `[ ]` 改为 `[/]` 时自动启动计时,改为 `[x]` 时自动暂停 | +| US-05 | 日常用户 | 我希望调整已暂停计时器的时长 | 右键菜单选择"调整耗时",弹出时间选择器,可自由设定时/分/秒 | +| US-06 | 日常用户 | 我希望 Obsidian 关闭后计时器不会丢失数据 | 根据 autoStopTimers 设置,重新打开后计时器要么恢复运行要么自动暂停 | +| US-07 | 日常用户 | 我希望计时器的外观可以自定义 | 设置面板中可修改运行/暂停图标、颜色、显示风格(徽章/纯文本)、时间格式 | +| US-08 | 日常用户 | 我希望 Obsidian 崩溃后不丢失计时数据 | 崩溃恢复机制自动结算之前运行中的计时器 | +| US-09 | 多语言用户 | 我希望插件界面显示我的语言 | 插件自动跟随 Obsidian 语言设置,支持中/英/繁中/日/韩 | +| US-10 | 日常用户 | 我希望在状态栏看到运行中计时器信息 | 底部状态栏显示运行中计时器数量和时长 | +| US-11 | 旧版用户 | 我希望升级后旧计时器自动兼容 | 打开包含 v1 格式计时器的文件时,自动升级为 v2 格式 | +| US-12 | 日常用户 | 我希望跨天工作时时长按天正确统计 | 计时器运行跨越午夜时,每日统计数据自动按天拆分 | + +--- + +## 三、功能范围 + +### 3.1 In Scope(核心计时器功能) + +- ✅ **计时器生命周期**:创建(init)→ 运行(running tick)→ 暂停(pause)→ 继续(continue)→ 运行 +- ✅ **三种触发方式**:命令面板(toggle-timer)、右键编辑器菜单、Checkbox 状态联动 +- ✅ **时间调整**:已暂停计时器可通过 TimePickerModal 手动设定时长 +- ✅ **文件打开恢复**:根据 autoStopTimers 设置(never/quit/close)决定恢复策略 +- ✅ **崩溃恢复**:启动时检测 JSON 数据库中 state=running 的记录,自动结算 +- ✅ **跨天处理**:运行中计时器跨越午夜时,daily_dur 按天拆分统计 +- ✅ **CM6 Widget**:span 折叠为可交互 Widget、光标逃逸、键盘导航(Backspace/Delete/Arrow) +- ✅ **三层数据同步**:Markdown HTML span ↔ timer-db.json ↔ IndexedDB +- ✅ **旧格式升级**:v1(timer-btn class)→ v2(timer-r/timer-p class)自动升级 +- ✅ **预览模式支持**:预览模式下 Checkbox 点击也能触发计时器 +- ✅ **Checkbox 路径控制**:文件组模式(whitelist/blacklist),支持正则和前缀匹配 +- ✅ **设置面板**:基础设置、外观设置(图标/颜色/样式/时间格式)、Checkbox 设置、状态栏设置 +- ✅ **状态栏**:显示运行中计时器数量和时长(最长/总计可选) +- ✅ **i18n**:en/zh/zhTW/ja/ko 五种语言全覆盖 + +### 3.2 Out of Scope(其他功能,已单独建文档) + +- ❌ Timer Sidebar(独立 PRD: `doc/timer-sidebar/`) +- ❌ Delete Timer Enhancement 含 Passive Delete/Restore(独立 PRD: `doc/delete-timer-enhancement/`) + +--- + +## 四、功能详细设计 + +### 4.1 计时器数据结构 + +计时器以 HTML span 形式内嵌在 Markdown 行内: + +```html +【⏳01:00:00 】 +【💐01:00:00 】 +``` + +| 属性 | 说明 | +|------|------| +| `class` | `timer-r`(运行中)或 `timer-p`(已暂停) | +| `id` | Base62 压缩的时间戳 ID,唯一标识计时器 | +| `data-dur` | 累计时长(秒) | +| `data-ts` | 最后一次状态变更的 Unix 时间戳(秒) | +| `data-project` | 可选,项目标签 | + +### 4.2 计时器状态机 + +```mermaid +stateDiagram-v2 + [*] --> Running: init(创建新计时器) + Running --> Paused: pause(用户暂停) + Paused --> Running: continue(用户继续) + Running --> Running: update(每秒 tick 更新 dur/ts) + Paused --> Running: restore(文件打开时恢复) + Running --> ForcePaused: forcepause(文件打开时强制暂停) + Paused --> ForcePaused: forcepause + Paused --> Paused: setDuration(调整时长) +``` + +### 4.3 触发方式 + +#### 4.3.1 命令面板(toggle-timer) +- 光标所在行有 `timer-r` → 暂停 +- 光标所在行有 `timer-p` → 继续 +- 光标所在行无计时器 → 创建新计时器 + +#### 4.3.2 右键编辑器菜单 +- 根据行内计时器状态动态显示:开始/暂停/继续/删除/调整耗时 +- "调整耗时"仅对已暂停计时器生效,运行中时禁用并显示 tooltip + +#### 4.3.3 Checkbox 状态联动 +- 监听 CM6 EditorView.updateListener 中的文档变更 +- 检测 Checkbox 状态字符变化(如 ` ` → `/`) +- 根据 `runningCheckboxState`(如 `/`)和 `pausedCheckboxState`(如 `-xX`)配置决定触发动作 +- 预览模式下通过 `pointerdown` DOM 事件监听 Checkbox 点击,轮询文件变更 + +#### 4.3.4 路径控制(文件组模式) +- `checkboxPathGroups` 为空 → 对所有文件生效 +- 非空时,文件路径匹配**任意一个**组即可启用(组内 blacklist 优先于 whitelist) +- 支持正则模式(`/pattern/`)和前缀匹配模式 +- 旧版 whitelist/blacklist 自动迁移为名为 "Migrated" 的文件组 + +### 4.4 运行 Tick 机制 + +- `TimerManager` 使用 `setInterval(1000ms)` 驱动每秒 tick +- 每次 tick: + 1. 检查页面可见性(后台超过 1s 跳过 tick,避免性能问题) + 2. 防止重叠执行(`runningTicks: Set`) + 3. `TimerDataUpdater.calculate('update')` 计算新的 dur/ts + 4. `TimerFileManager.updateTimerByIdWithSearch()` 写入 Markdown(先用缓存位置,失败则全文搜索) + 5. 更新 IndexedDB(tickUpdate) + 6. 更新 JSON 内存 + 7. 检查跨天边界 + +### 4.5 文件打开恢复 + +- 首次文件打开(`fileFirstOpen=true`):扫描所有已打开标签页 +- 后续文件打开:仅扫描新打开的文件 +- 扫描逻辑:逐行解析,找到 `timer-r` 的 span + - `autoStopTimers === 'never'`:恢复运行 + - `autoStopTimers === 'quit'`:仅恢复本次会话启动过的计时器(`startedIds`),其余强制暂停 + - `autoStopTimers === 'close'`:全部强制暂停 + +### 4.6 崩溃恢复 + +- 启动时 `TimerDatabase.recoverCrashedTimers()` +- 检测 JSON 中 `state=running` 的记录(说明上次异常退出未正常 onunload) +- 计算 crash session 时长,累加到 daily_dur +- 将这些记录状态改为 `paused` +- 将崩溃恢复的 daily_dur 同步到 IndexedDB + +### 4.7 跨天处理 + +- `TimerDatabase.checkDayBoundary()` 在每次 tick 时检查 +- 如果当前 tick 的日期 ≠ session 开始日期,拆分为每日 segments +- 每个 segment 记入对应日期的 daily_dur +- IDB 层由 `tickUpdate` 自然按 today 写入,天然支持跨天 + +### 4.8 CM6 Widget + +- `timerFoldingField`(StateField):将 timer span 替换为 `TimerWidget` 装饰 +- `TimerWidget`:显示 `【⏳01:00:00 】` 文本,带 running/paused 状态 CSS class +- `timerCursorEscape`:光标落入 span 内部时自动弹出到最近边界 +- `timerWidgetKeymap`: + - Backspace:删除左侧紧邻的 timer span + - Delete:删除右侧紧邻的 timer span + - ArrowLeft/ArrowRight:跳过 timer span + +### 4.9 时间调整(Time Adjustment) + +- `TimePickerModal`:iPhone 风格滚轮选择器(时/分/秒) +- 仅对 `timer-p` 生效 +- 修改后同步: + 1. Markdown span 的 data-dur 更新 + 2. JSON 的 total_dur_sec 和 daily_dur 调整(增量加到今天/减量从最近日期 LIFO 扣除) + 3. IDB 同步 + +### 4.10 设置面板 + +**基础设置**: +| 设置项 | 默认值 | 说明 | +|--------|--------|------| +| autoStopTimers | quit | 自动停止策略 | +| timerInsertLocation | head | 计时器插入位置(行首/行尾) | + +**Checkbox 设置**: +| 设置项 | 默认值 | 说明 | +|--------|--------|------| +| enableCheckboxToTimer | true | 是否启用 Checkbox 联动 | +| runningCheckboxState | / | 触发运行的 Checkbox 符号 | +| pausedCheckboxState | -xX | 触发暂停的 Checkbox 符号 | +| checkboxPathGroups | [] | 路径控制文件组 | + +**外观设置**: +| 设置项 | 默认值 | 说明 | +|--------|--------|------| +| timeDisplayFormat | full | 时间格式(full/smart) | +| timerDisplayStyle | badge | 显示风格(badge/plain) | +| runningIcon | ⏱ | 运行中图标 | +| pausedIcon | 💐 | 暂停图标 | +| runningTextColor | #10b981 | 运行中文字色 | +| runningBgColor | rgba(16,185,129,0.15) | 运行中背景色 | +| pausedTextColor | #6b7280 | 暂停文字色 | +| pausedBgColor | rgba(107,114,128,0.12) | 暂停背景色 | + +**状态栏设置**: +| 设置项 | 默认值 | 说明 | +|--------|--------|------| +| showStatusBar | true | 是否显示状态栏 | +| statusBarMode | max | 显示最长计时器/总时长 | + +### 4.11 状态栏 + +- 有运行中计时器:`⏱ N running · HH:MM:SS` +- 无运行中计时器:`💐 No running timers` +- 点击打开 Sidebar +- 每秒与 tick 同步更新 + +--- + +## 五、数据需求 + +### 5.1 Markdown 层 +- 行内 HTML span,格式如 4.1 节所述 +- 每秒 tick 更新 `data-dur` 和 `data-ts` 及显示文本 + +### 5.2 JSON 层(timer-db.json) +- `timers` 表:timer_id、file_path、line_num、line_text、project、state、total_dur_sec、last_ts、created_at、updated_at +- `daily_dur` 表:`{ [date]: { [timerId]: seconds } }` +- `session_starts` 表:`{ [timerId]: startTs }` 运行中 session 开始时间 + +### 5.3 IndexedDB 层(TimerPluginDB) +- `timers` store:与 JSON 层 timers 表对应 +- `daily_dur` store:key=`timerId|date`,支持按 timer_id / stat_date 索引 + +### 5.4 数据一致性要求 +- 任何状态变更必须同步三层 +- 运行中 tick 仅更新 IDB 和内存(不 flush JSON,避免性能问题) +- 暂停/继续等状态变更触发 JSON flush +- 插件 onunload 时同步 flush 所有 running timers 到 JSON + +--- + +## 六、移动端适配要求 + +### 6.1 布局差异 +- 预览模式下 Checkbox 点击需通过 DOM pointerdown 事件处理 +- CM6 Widget 在移动端同样生效 + +### 6.2 交互差异 +- 触摸操作:TimePickerModal 支持 touch 滑动手势 +- 无右键菜单:移动端依赖命令面板和 Checkbox 联动 + +### 6.3 性能约束 +- 后台节流:页面不可见时跳过 tick +- 不使用 native 模块 + +--- + +## 七、国际化要求 + +所有用户可见文本均通过 `translations.ts` 支持 5 种语言: + +| 类别 | i18n key 示例 | +|------|---------------| +| 命令名称 | command_name.toggle / delete / timeAdjust | +| 操作标签 | action_start / action_paused / action_continue | +| 时间调整弹窗 | timeBackfill.* | +| 设置面板 | settings.* (含所有子项) | +| 状态栏 | settings.statusBar.* | + +--- + +## 八、非功能需求 + +### 8.1 性能 +- tick 延迟 < 100ms(主线程) +- 单个 writeTimer 操作 < 5ms +- 10000+ 计时器的 JSON 加载 < 500ms + +### 8.2 兼容性 +- Obsidian 最低版本:1.5+ +- 支持桌面(Windows/Mac/Linux)和移动端(iOS/Android) +- 深色/浅色主题自动适配 + +### 8.3 可访问性 +- 键盘可操作(快捷键、方向键导航 timer span) +- 状态通过颜色 + 图标双重表达 + +--- + +## 九、验收标准总表 + +| 编号 | 场景 | 预期结果 | 优先级 | +|------|------|----------|--------| +| AC-01 | 命令面板启动计时器 | 行内出现 timer-r span,每秒递增 | P0 | +| AC-02 | 命令面板暂停计时器 | span 变为 timer-p,时长冻结 | P0 | +| AC-03 | 命令面板继续计时器 | span 变为 timer-r,继续递增 | P0 | +| AC-04 | Checkbox 联动启动 | checkbox 状态改为 running symbol,计时器自动创建/继续 | P0 | +| AC-05 | Checkbox 联动暂停 | checkbox 状态改为 paused symbol,计时器自动暂停 | P0 | +| AC-06 | 运行中 tick | data-dur 每秒递增,显示文本同步更新 | P0 | +| AC-07 | 三层数据一致 | 暂停后 Markdown dur == JSON total_dur_sec == IDB total_dur_sec | P0 | +| AC-08 | 崩溃恢复 | 模拟崩溃后重启,之前的 running timer 被恢复为 paused,daily_dur 正确结算 | P0 | +| AC-09 | 文件打开恢复(quit 模式) | 本次会话启动过的 timer 恢复运行,其余 timer 强制暂停 | P0 | +| AC-10 | 时间调整(增加) | 暂停 timer 时长从 1h 改为 2h,delta 加到 today 的 daily_dur | P1 | +| AC-11 | 时间调整(减少) | 暂停 timer 时长减少,从最近日期 LIFO 扣除 | P1 | +| AC-12 | 旧格式升级 | 打开含 v1 timer 的文件,span 自动升级为 v2 格式 | P1 | +| AC-13 | 跨天拆分 | timer 运行跨越午夜,两天的 daily_dur 各自正确 | P0 | +| AC-14 | CM6 Widget 折叠 | timer span 在编辑器中显示为 Widget,非原始 HTML | P1 | +| AC-15 | 键盘导航 | Backspace/Delete 删除 timer span,Arrow 键跳过 | P1 | +| AC-16 | 光标逃逸 | 鼠标点击 timer span 内部,光标自动弹出到边界 | P1 | +| AC-17 | 状态栏显示 | 有 running timer 时状态栏显示数量和时长 | P1 | +| AC-18 | 设置面板 | 所有设置项正常显示、修改、持久化 | P1 | +| AC-19 | i18n | 切换 Obsidian 语言,插件界面跟随切换 | P2 | +| AC-20 | Checkbox 路径控制 | 文件组 whitelist/blacklist 正确控制哪些文件启用 Checkbox 联动 | P1 | +| AC-21 | 后台节流 | 页面不可见时 tick 被跳过 | P2 | +| AC-22 | onunload 结算 | Obsidian 正常关闭时所有 running timer 被结算到 JSON | P0 | + +--- + +## 十、技术实现要点(产品视角) + +1. 计时器数据以 HTML span 形式内嵌 Markdown,这是核心设计约束 +2. 状态机为纯函数(`TimerDataUpdater`),无副作用,易于测试 +3. `TimerManager` 管理内存中的 setInterval tick,需防后台节流和 tick 重叠 +4. `TimerFileManager` 负责 Markdown 读写,支持编辑模式和预览模式两套路径 +5. 三层数据(Markdown/JSON/IDB)必须在每次状态变更时同步 +6. CM6 Widget 由 Obsidian 宿主提供 CodeMirror,不可自行 bundle + +--- + +## 十一、开放问题 + +无(已上线功能归档文档)。 diff --git a/doc/core-timer/stage2-ux-review.md b/doc/core-timer/stage2-ux-review.md new file mode 100644 index 0000000..700ec7f --- /dev/null +++ b/doc/core-timer/stage2-ux-review.md @@ -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 设计成熟,交互路径短、状态反馈明确、移动端适配良好。无需修改。 diff --git a/doc/core-timer/stage3-data-schema.md b/doc/core-timer/stage3-data-schema.md new file mode 100644 index 0000000..f729dec --- /dev/null +++ b/doc/core-timer/stage3-data-schema.md @@ -0,0 +1,193 @@ +# 📊 数据设计文档: Core Timer + +**文档版本**: v1.0 | **创建日期**: 2026-03-02 | **状态**: 补充归档 | **对应 PRD**: stage1-PRD.md + +> 本文档为已上线核心计时器功能的数据 schema 归档,描述三层数据存储的实际结构。 + +--- + +## 一、现有 Schema 分析 + +### 1.1 Markdown HTML Span(源数据层) + +```html +【⏳01:00:00 】 +【💐01:00:00 】 +``` + +| 属性 | 类型 | 说明 | +|------|------|------| +| `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; // timer_id → entry + daily_dur: Record>; // date → { timer_id → seconds } +} +``` + +**timers 表**: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `timer_id` | string | ✅ | 主键,Base62 压缩 ID | +| `file_path` | string | ✅ | 相对于 vault 根目录的文件路径 | +| `line_num` | number | ✅ | 0-indexed 行号 | +| `line_text` | string | ✅ | 行文本摘要(去除 timer span 后的纯文本) | +| `project` | string \| null | — | 项目标签 | +| `state` | enum | ✅ | `running` / `paused` / `deleted` / `lost` | +| `total_dur_sec` | number | ✅ | 累计总时长(秒) | +| `last_ts` | number | ✅ | 最近状态变更 Unix 时间戳 | +| `created_at` | number | ✅ | 创建时间 Unix 时间戳 | +| `updated_at` | number | ✅ | 最近更新时间 Unix 时间戳 | + +**daily_dur 表**: + +```json +{ + "2026-03-01": { "LzHk3a": 3600, "Abc123": 1800 }, + "2026-03-02": { "LzHk3a": 900 } +} +``` + +- 外层 key:日期 `YYYY-MM-DD` +- 内层 key:timer_id +- value:该 timer 在该日期的累计秒数 + +**内存辅助结构**: + +| 结构 | 类型 | 说明 | +|------|------|------| +| `timersByFile` | `Map>` | 按文件路径索引 timers,加速 removeFile/renameFile | +| `sessionStartDate` | `Map` | 当前 running session 的开始日期(用于跨天检测) | +| `sessionStartTs` | `Map` | 当前 running session 的开始时间戳 | + +### 1.3 IndexedDB 层(TimerPluginDB) + +**数据库名**:`text-block-timer`,版本:1 + +**timers store**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `timer_id` | string | keyPath(主键) | +| `file_path` | string | 文件路径 | +| `line_num` | number | 行号 | +| `line_text` | string | 行文本摘要 | +| `project` | string \| null | 项目标签 | +| `state` | enum | running / paused / deleted / lost | +| `total_dur_sec` | number | 累计总时长(秒) | +| `last_ts` | number | 最近状态变更时间戳 | +| `created_at` | number | 创建时间 | +| `updated_at` | number | 更新时间 | + +**daily_dur store**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `key` | string | keyPath,格式 `${timer_id}\|${stat_date}` | +| `timer_id` | string | 关联 timers.timer_id | +| `stat_date` | string | YYYY-MM-DD | +| `duration_sec` | number | 累计秒数 | + +**索引**: +- `by_timer`:`timer_id`(非唯一),用于按 timer 查询 +- `by_date`:`stat_date`(非唯一),用于按日期聚合 + +--- + +## 二、三层同步策略 + +### 2.1 写入频率 + +| 操作 | Markdown | JSON | IndexedDB | +|------|----------|------|-----------| +| 创建(init) | ✅ 立即写入 span | ✅ scheduleFlush(100ms) | ✅ async fire-and-forget | +| 继续(continue) | ✅ 立即 | ✅ scheduleFlush | ✅ async | +| 暂停(pause) | ✅ 立即 | ✅ scheduleFlush | ✅ async | +| 每秒 tick | ✅ 每秒更新 span | ❌ 仅内存更新 | ✅ tickUpdate(原子事务) | +| 时间调整 | ✅ 立即 | ✅ flush | ✅ async | +| onunload | ❌ 不写 | ✅ flushSync | ❌ 异步不可靠 | + +### 2.2 权威性 + +| 场景 | 权威来源 | +|------|----------| +| 运行中实时 dur | Markdown span(每秒更新) | +| 持久化状态 | JSON(onunload 时 flushSync) | +| 实时查询(sidebar/status bar) | IndexedDB(每秒 tickUpdate) | +| daily_dur | IndexedDB(tick 级精度)= JSON(state change 级精度) | + +### 2.3 启动时数据流 + +```mermaid +sequenceDiagram + participant JSON as timer-db.json + participant IDB as IndexedDB + participant MEM as Memory + + Note over JSON, MEM: Plugin onload + JSON->>MEM: load() — 解析 JSON + MEM->>MEM: recoverCrashedTimers() + MEM->>JSON: flush() — 持久化恢复结果 + MEM->>IDB: seedIndexedDB() — clearAll + bulkPut + Note over JSON, MEM: 三层一致 +``` + +--- + +## 三、数据迁移方案 + +### 3.1 v1 → v2 格式升级(Markdown span) + +| 项目 | v1 | v2 | +|------|----|----| +| class | `timer-btn` | `timer-r` / `timer-p` | +| ID 属性 | `timerId` 或 `data-timerId` | `id` | +| 状态 | `Status="Running"/"Paused"` | class 值区分 | +| 时长 | `AccumulatedTime` 或 `data-AccumulatedTime` | `data-dur` | +| 时间戳 | `currentStartTimeStamp` | `data-ts` | + +**升级逻辑**:`TimerFileManager.upgradeOldTimers()` 在文件打开时自动扫描 v1 span,重新生成 Base62 ID 并替换为 v2 格式。 + +### 3.2 legacy sessions 迁移(JSON) + +旧版 JSON 使用 `sessions[]` 数组,新版使用 `daily_dur` 结构。`TimerDatabase.load()` 在检测到 `sessions` 字段时自动迁移到 `daily_dur`,然后删除 `sessions`。 + +### 3.3 Checkbox 路径控制迁移 + +旧版使用 `checkboxToTimerPathRestriction`(disable/whitelist/blacklist)+ `pathRestrictionPaths`。新版使用 `checkboxPathGroups`(文件组数组)。`checkPathRestriction()` 在首次调用时检测旧格式并自动迁移为名为 "Migrated" 的文件组。 + +--- + +## 四、查询场景分析 + +| 场景 | 查询条件 | 数据来源 | 期望响应时间 | +|------|----------|----------|-------------| +| Sidebar 当前文件计时器 | filePath + state ∈ {running, paused} | 实时扫描 Markdown | < 50ms | +| Sidebar 当前会话计时器 | 多个 filePath + state | 实时扫描 Markdown | < 100ms | +| Sidebar 全库计时器 | state ∈ {running, paused} | JSON timers 表 | < 200ms | +| 状态栏更新 | 所有 running timers | Memory (TimerManager) | < 5ms | +| 每秒 tick 更新 | 单个 timerId | Memory + IDB | < 10ms | +| daily_dur 图表 | timerId + date range | IDB by_timer 索引 | < 50ms | + +--- + +## 五、异常恢复方案 + +| 异常 | 恢复策略 | +|------|----------| +| Obsidian 崩溃 | `recoverCrashedTimers()` 检测 JSON 中 state=running 的记录,计算 crash 期间时长,结算到 daily_dur | +| JSON 文件损坏 | load() catch → 创建空数据库 | +| IDB 不可用 | 降级为纯 JSON 模式,侧边栏/图表功能受限 | +| 文件被外部修改 | `findTimerGlobally()` 全文搜索兜底 | diff --git a/doc/core-timer/stage4-tech-design.md b/doc/core-timer/stage4-tech-design.md new file mode 100644 index 0000000..1b5731b --- /dev/null +++ b/doc/core-timer/stage4-tech-design.md @@ -0,0 +1,414 @@ +# 🔧 技术方案: Core Timer + +**文档版本**: v1.0 | **创建日期**: 2026-03-02 | **状态**: 补充归档(基于实际代码) + +> 本文档为已上线核心计时器功能的技术方案归档,完全基于实际代码结构描述。 + +--- + +## 一、架构概览 + +### 1.1 分层架构 + +```mermaid +graph TB + subgraph ENTRY["入口层"] + TP["TimerPlugin (main.ts)
协调者 Orchestrator"] + end + + subgraph CORE["核心层 (core/)"] + TDU["TimerDataUpdater
纯函数状态机"] + TM["TimerManager
内存 timer 管理"] + TDB["TimerDatabase
JSON 持久化"] + IDB["TimerIndexedDB
IDB 热缓存"] + end + + subgraph IO["IO 层 (io/)"] + TFM["TimerFileManager
文件读写 + 位置追踪"] + TPR["TimerParser
HTML 解析"] + TR["TimerRenderer
HTML 渲染"] + TF["TimeFormatter
时间格式化"] + end + + subgraph UI["UI 层 (ui/)"] + TW["TimerWidget
CM6 Widget"] + TPM["TimePickerModal
时间选择弹窗"] + TST["TimerSettingTab
设置面板"] + 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` — 所有运行中的 timer +- `startedIds: Set` — 本次 onload session 启动过的 timer(用于 quit 模式) +- `runningTicks: Set` — 当前正在执行 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>` — O(1) 按文件查询 +- `sessionStartDate / sessionStartTs` — 跨天检测辅助 + +**关键功能**: +- `recoverCrashedTimers()`: 检测 state=running 的遗留记录,计算 crash 时长,结算到 daily_dur +- `checkDayBoundary()`: 检测跨天,拆分 segments(每日一条) +- `adjustDailyDurForSetDuration()`: 时间调整的 LIFO 扣除 + +### 2.5 TimerIndexedDB — IDB 热缓存 + +**文件**: `src/core/TimerIndexedDB.ts` + +- `tickUpdate()`: 原子事务同时更新 timers store(dur/ts)和 daily_dur store(delta) +- `adjustDailyDurForSetDuration()`: 与 JSON 层同算法的 IDB 版本 +- 按 `by_timer` / `by_date` 索引查询 + +### 2.6 TimerFileManager — 文件读写 + +**文件**: `src/io/TimerFileManager.ts` + +**位置缓存**: `locations: Map` +- 创建/恢复/继续时缓存 +- 每次 tick 时先用缓存位置更新,失败则 fallback 全文搜索 + +**双模式写入**: +- 编辑模式: `editor.replaceRange()` — CM6 dispatch +- 预览模式: `vault.read()` + `vault.modify()` — Vault API + +**upgradeOldTimers()**: +- 扫描当前文件,找到 v1 格式 span(`.timer-btn`) +- 生成新 Base62 ID,用 v2 格式替换 +- 同时更新 JSON/IDB + +### 2.7 TimerParser / TimerRenderer — 解析与渲染 + +**TimerParser** (`src/io/TimerParser.ts`): +- 使用 `document.createElement('template')` + DOM querySelector 解析 HTML +- 支持 v1(timer-btn class)和 v2(timer-r/timer-p class)双格式 +- 返回 TimerData 含 `beforeIndex` / `afterIndex` 用于精确替换 + +**TimerRenderer** (`src/io/TimerRenderer.ts`): +- 纯函数,输入 TimerData + Settings → 输出 HTML string +- 格式: `【⏳HH:MM:SS 】` +- 支持自定义图标、时间格式(full/smart) + +### 2.8 CM6 Widget 系统 + +**文件**: `src/ui/TimerWidget.ts` + +**三个 CM6 扩展**: + +1. **timerFoldingField** (StateField + DecorationSet): + - 正则匹配行内 timer span + - 替换为 `TimerWidget` Decoration(Decoration.replace) + - 随 editor state 更新自动重建 + +2. **timerCursorEscape** (EditorView.updateListener): + - 检测光标位置是否落入 timer span 范围内 + - 如果是,自动将光标移动到最近的外边界(左/右) + +3. **timerWidgetKeymap** (keymap): + - `Backspace`: 检测左侧紧邻 timer span → 删除整个 span + - `Delete`: 检测右侧紧邻 timer span → 删除整个 span + - `ArrowLeft/ArrowRight`: 检测相邻 timer span → 跳过 + +### 2.9 TimePickerModal — 时间调整弹窗 + +**文件**: `src/ui/TimePickerModal.ts` + +- iPhone 风格滚轮选择器,3 列(时/分/秒) +- 支持 mouse wheel / touch drag / click 三种交互 +- 惯性动画(velocity tracking + momentum) +- 选中项视觉高亮(放大 + 粗体 + 高对比度) +- 确认回调返回总秒数,由 handleSetDuration 处理同步 + +### 2.10 Checkbox 联动 + +**实现位置**: `src/main.ts` 中 `toggleTimerbyCheckboxState()` + +**编辑模式**: +- EditorView.updateListener 检测文档变更 +- 逐个 ChangeSet 检查 checkbox 行 +- 提取旧/新 checkbox 状态字符(`/`、`x`、`-` 等) +- 匹配 `runningCheckboxState` / `pausedCheckboxState` 配置 + +**预览模式**: +- 注册 `pointerdown` DOM 事件 +- 检测点击目标是否为 checkbox input +- 轮询文件内容变更(Vault API),确认状态变化后触发 + +**路径控制**: +- `checkPathRestriction(filePath)`: 遍历 `checkboxPathGroups` +- 每个组包含 whitelist/blacklist patterns +- pattern 支持正则(`/pattern/`)和前缀匹配 +- 文件匹配**任意一个组**即可启用 + +### 2.11 设置面板 + +**文件**: `src/ui/TimerSettingTab.ts` + +- 使用 Obsidian `PluginSettingTab` API +- 4 个分区:基础 / Checkbox / 外观 / 状态栏 +- 文件组管理:折叠/展开卡片 + 动态增删 pattern 输入框 +- 颜色选择器:使用原生 `` +- 修改立即 `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 diff --git a/doc/core-timer/stage5-arch-review.md b/doc/core-timer/stage5-arch-review.md new file mode 100644 index 0000000..7a8354b --- /dev/null +++ b/doc/core-timer/stage5-arch-review.md @@ -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 自动补偿 | + +--- + +## 三、技术方案修正 + +无修正。技术方案基于实际代码编写,与实现一致。 + +--- + +## 四、结论 + +核心计时器的架构设计成熟稳健,职责分离清晰,数据一致性保障到位,性能优化措施全面。审查通过。 diff --git a/doc/core-timer/stage6-implementation-plan.md b/doc/core-timer/stage6-implementation-plan.md new file mode 100644 index 0000000..026df44 --- /dev/null +++ b/doc/core-timer/stage6-implementation-plan.md @@ -0,0 +1,99 @@ +# 📦 实施计划: Core Timer + +**文档版本**: v1.0 | **创建日期**: 2026-03-02 | **状态**: 补充归档 + +> 本文档为已上线核心计时器功能的实施计划归档,描述实际的模块划分和文件分布。 + +--- + +## 一、模块分拆 + +### Module 1: 核心状态机与数据模型 + +| 文件 | 内容 | +|------|------| +| `src/core/TimerDataUpdater.ts` | 纯函数状态机(init/continue/pause/update/restore/forcepause/setDuration) | +| `src/core/TimerManager.ts` | 内存 timer 管理(Map + setInterval + 可见性监控 + tick 防重叠) | +| `src/core/constants.ts` | 全局常量(UPDATE_INTERVAL、DEBUG 等) | +| `src/core/utils.ts` | 工具函数(compressId 等) | + +### Module 2: 持久化层 + +| 文件 | 内容 | +|------|------| +| `src/core/TimerDatabase.ts` | JSON 文件持久化(CRUD、daily_dur、session tracking、崩溃恢复、跨天检测) | +| `src/core/TimerIndexedDB.ts` | IndexedDB 热缓存(timers + daily_dur stores、tickUpdate 原子事务) | + +### Module 3: IO 层 + +| 文件 | 内容 | +|------|------| +| `src/io/TimerParser.ts` | HTML span 解析(v1/v2 双格式、DOM 解析) | +| `src/io/TimerRenderer.ts` | HTML span 渲染(TimerData → HTML string) | +| `src/io/TimerFileManager.ts` | 文件读写(位置缓存、编辑/预览双模式、旧格式升级、插入位置计算) | +| `src/io/TimeFormatter.ts` | 时间格式化(full/smart 两种格式) | + +### Module 4: UI 层 + +| 文件 | 内容 | +|------|------| +| `src/ui/TimerWidget.ts` | CM6 Widget(folding field + cursor escape + keymap) | +| `src/ui/TimePickerModal.ts` | 时间选择弹窗(iPhone 风格滚轮) | +| `src/ui/TimerSettingTab.ts` | 设置面板(基础/Checkbox/外观/状态栏 4 个分区) | + +### Module 5: 国际化 + +| 文件 | 内容 | +|------|------| +| `src/i18n/translations.ts` | 翻译表(en/zh/zhTW/ja/ko)+ getTranslation() | + +### Module 6: 主协调者 + +| 文件 | 内容 | +|------|------| +| `src/main.ts` | TimerPlugin 主类(命令注册、事件监听、业务流程协调、Checkbox 联动、恢复/崩溃恢复、状态栏) | + +--- + +## 二、模块依赖图 + +```mermaid +graph LR + M6[main.ts] --> M1[核心状态机] + M6 --> M2[持久化层] + M6 --> M3[IO 层] + M6 --> M4[UI 层] + M6 --> M5[国际化] + M3 --> M1 + M4 --> M5 +``` + +--- + +## 三、测试覆盖计划 + +| 模块 | 测试类型 | 现有覆盖 | 待补充 | +|------|----------|----------|--------| +| Module 1 (状态机) | E2E | ✅ basic chain | 无 | +| Module 2 (持久化) | E2E | ✅ basic/adjust/seed/crossday | ✅ 需补充 IDB 验证 | +| Module 3 (IO) | E2E | ✅ basic(写入验证) | 无 | +| Module 4 (UI) | E2E | ⚠️ Widget 未测 | ✅ 需补充 Widget/设置面板 | +| Module 5 (i18n) | — | ❌ 未测 | ✅ 待补充 | +| Module 6 (协调者) | E2E | ✅ 多 chain 覆盖 | ✅ 需补充 Checkbox 联动/恢复 | + +--- + +## 四、已有 E2E 测试链 + +| 测试链 | 覆盖范围 | 归属功能 | +|--------|----------|----------| +| preflight | 连接验证、环境准备 | 基础设施 | +| basic | 创建/暂停/继续/tick/JSON 验证 | ✅ Core Timer | +| adjust | 时间调整(增加/减少/daily_dur 联动) | ✅ Core Timer | +| seed | IDB seeding/daily_dur 验证 | ✅ Core Timer | +| crossday | 跨天边界检测 | ✅ Core Timer | +| crossday_adjust | 跨天 + 时间调整 LIFO | ✅ Core Timer | +| passive_delete | 被动删除/恢复/span class 一致性 | Delete Enhancement | +| delete | 主动删除 | Delete Enhancement | +| sidebar_tabs | Sidebar 标签页 | Sidebar | +| readonly | 只读模式 | Sidebar | diff --git a/doc/core-timer/stage7-test-cases.md b/doc/core-timer/stage7-test-cases.md new file mode 100644 index 0000000..821d6e6 --- /dev/null +++ b/doc/core-timer/stage7-test-cases.md @@ -0,0 +1,157 @@ +# 🧪 测试用例: Core Timer + +**文档版本**: v1.0 | **创建日期**: 2026-03-02 | **状态**: 补充归档 + +--- + +## 一、现有测试覆盖(已实现) + +### Chain: preflight(环境验证) + +| 用例 | 描述 | 验证点 | +|------|------|--------| +| PRE-01 | Plugin is loaded | 插件实例存在 | +| PRE-02 | Active file is open in editor | 有打开的编辑器 | +| PRE-03 | IDB is initialized | IndexedDB 已打开 | + +### Chain: basic(基础生命周期 + 三层数据一致性) + +| 用例 | 描述 | 验证点 | +|------|------|--------| +| B-01 | START timer on test line | Markdown 行内出现 timer-r span | +| B-02 | IDB: timer entry exists with state=running | IDB timers store 有 running 记录 | +| B-03 | IDB tickDelta: daily_dur accumulates ~1s/tick | 等待 3s,daily_dur delta ≈ 3s(±1s) | +| B-04 | Status bar: shows running timer info | 状态栏显示 running 信息 | +| B-05 | PAUSE timer | span 变为 timer-p,时长冻结 | +| B-06 | IDB: timer state=paused after PAUSE | IDB state 为 paused | +| B-07 | IDB: daily_dur not double-written on pause | pause 不会重复写 daily_dur | +| B-08 | Cross-layer: JSON total_dur ≈ IDB total_dur (±2s) | JSON 和 IDB 时长一致 | +| B-09 | Cross-layer: JSON daily_dur ≈ IDB daily_dur (±2s) | JSON 和 IDB 日统计一致 | +| B-10 | CONTINUE timer | span 变为 timer-r,继续计时 | +| B-11 | IDB: state=running after CONTINUE | IDB state 为 running | +| B-12 | PAUSE again for duration adjustment test | 再次暂停 | + +### Chain: adjust(时间调整) + +| 用例 | 描述 | 验证点 | +|------|------|--------| +| ADJ-01 | Record pre-adjustment IDB state | 记录调整前 total_dur 和 daily_dur | +| ADJ-02 | Manual set duration: decrease via handleSetDuration | 调用 handleSetDuration 减少时长 | +| ADJ-03 | IDB: daily_dur adjusted correctly after decrease | daily_dur LIFO 扣除正确 | +| ADJ-04 | CONTINUE for accumulation retest | 继续,验证减少后的基础正确 | +| ADJ-05 | PAUSE after accumulation | 暂停,验证累计正确 | +| ADJ-06 | IDB: no double-write after continue→pause cycle | 无重复写入 | + +### Chain: seed(IDB seeding) + +| 用例 | 描述 | 验证点 | +|------|------|--------| +| SEED-01 | seedIndexedDB: clearAll removes stale data on reload | 重新 seed 后 IDB 数据与 JSON 一致 | + +### Chain: crossday(跨天处理) + +| 用例 | 描述 | 验证点 | +|------|------|--------| +| CD-01 | Patch Date to yesterday 23:59:55 | 时间补丁成功 | +| CD-02 | START timer at yesterday 23:59:55 | 计时器在昨天启动 | +| CD-03 | IDB has yesterday daily_dur from ticks | 昨天的 daily_dur > 0 | +| CD-04 | Patched time has crossed to today | 时间自然跨过午夜 | +| CD-05 | checkDayBoundary detected — JSON has yesterday entry | JSON daily_dur 有昨天的条目 | +| CD-06 | IDB daily_dur has BOTH yesterday and today entries | IDB 有两天的记录 | +| CD-07 | Sidebar chart auto-splits BEFORE pause | Sidebar 图表自动拆分 | +| CD-08 | PAUSE and verify total = sum of daily_dur | 总时长 = 各天之和 | +| CD-09 | Sidebar chart still correct after pause | 暂停后图表不变 | +| CD-10 | Restore original Date | 时间恢复 | +| CD-11 | Cleanup — delete timer | 清理 | + +### Chain: crossday_adjust(跨天 + 时间调整) + +| 用例 | 描述 | 验证点 | +|------|------|--------| +| CDA-01 | Patch to day1 noon, start timer | 3 天前启动 | +| CDA-02 | Cross day1→day2: checkDayBoundary splits | 跨第一天,day1 有记录 | +| CDA-03 | Cross day2→day3: second boundary | 跨第二天,day2 有记录 | +| CDA-04 | Pause: total = sum(day1+day2+day3) | 三天总和正确 | +| CDA-05 | Decrease: LIFO deducts from day3 first | LIFO 从最近日期扣除 | +| CDA-06 | Increase: delta added to today | 增加量加到 today | +| CDA-07 | Cleanup | 清理 | + +--- + +## 二、待补充测试用例 + +### Chain: checkbox(Checkbox 联动 — 新增) + +| 用例 | 描述 | 验证点 | 优先级 | +|------|------|--------|--------| +| CB-01 | Checkbox `[ ]` → `[/]` 自动启动计时器 | 行内出现 timer-r span | P0 | +| CB-02 | Checkbox `[/]` → `[x]` 自动暂停计时器 | span 变为 timer-p | P0 | +| CB-03 | Checkbox `[x]` → `[/]` 恢复计时器 | span 变为 timer-r | P0 | +| CB-04 | Checkbox `[ ]` → `[x]` 不创建计时器 | 行内无 timer span | P1 | +| CB-05 | 非 Checkbox 行编辑不触发 | 已有 timer 状态不变 | P1 | +| CB-06 | enableCheckboxToTimer=false 时 Checkbox 不触发 | 行内无 timer span | P1 | + +### Chain: restore(文件打开恢复 — 新增) + +| 用例 | 描述 | 验证点 | 优先级 | +|------|------|--------|--------| +| RST-01 | autoStopTimers='never': 运行中 timer 恢复运行 | manager.hasTimer()=true | P0 | +| RST-02 | autoStopTimers='quit': 本次 session 启动的恢复 | startedIds 中的 timer 恢复 | P0 | +| RST-03 | autoStopTimers='quit': 非本次 session 的强制暂停 | span 变为 timer-p | P0 | +| RST-04 | autoStopTimers='close': 全部强制暂停 | 所有 timer span 为 timer-p | P0 | +| RST-05 | 恢复后三层数据一致 | JSON/IDB/Markdown dur 一致 | P0 | + +### Chain: crash_recovery(崩溃恢复 — 新增) + +| 用例 | 描述 | 验证点 | 优先级 | +|------|------|--------|--------| +| CR-01 | 模拟崩溃:JSON 中 state=running 的 timer | recoverCrashedTimers 检测到 | P0 | +| CR-02 | 崩溃恢复后 state 变为 paused | JSON entry.state === 'paused' | P0 | +| CR-03 | 崩溃期间时长正确结算到 daily_dur | daily_dur[date][timerId] > 0 | P0 | +| CR-04 | IDB 同步崩溃恢复结果 | IDB entry.state === 'paused' | P1 | + +### Chain: settings(设置面板 — 新增) + +| 用例 | 描述 | 验证点 | 优先级 | +|------|------|--------|--------| +| SET-01 | 修改 timerInsertLocation='tail' 后新建 timer | span 出现在行尾 | P1 | +| SET-02 | 修改 timerInsertLocation='head' 后新建 timer | span 出现在行首(checkbox 后) | P1 | +| SET-03 | 修改 runningIcon 后启动 timer | Widget 显示新图标 | P2 | +| SET-04 | 修改 pausedIcon 后暂停 timer | Widget 显示新图标 | P2 | +| SET-05 | 修改 timeDisplayFormat='smart' | Widget 时间格式改变 | P2 | + +### Chain: onunload(正常退出结算 — 新增) + +| 用例 | 描述 | 验证点 | 优先级 | +|------|------|--------|--------| +| UNL-01 | 调用 onunload 前有 running timer | manager.timers.size > 0 | P0 | +| UNL-02 | onunload 后 JSON 中 state 仍为 running(不暂停) | JSON entry.state === 'running' | P0 | +| UNL-03 | onunload 后 JSON 已 flushSync 到磁盘 | JSON 文件内容与内存一致 | P0 | + +--- + +## 三、测试基础设施 + +### E2E 测试框架 + +- **连接方式**: Chrome DevTools Protocol (CDP) +- **远程调试端口**: 9222 +- **运行命令**: `node tests/e2e-timer-test.mjs [chain_name...]` +- **测试文件**: 使用 `_e2e_timer_test.md` 作为测试用 Markdown 文件 +- **超时**: 默认 10s per test case +- **结果格式**: ✅ PASS / ❌ FAIL + 详细日志 + +### 辅助工具 + +- `runner.eval(expr)`: 在 Obsidian 上下文中执行 JS +- `runner.run(name, fn)`: 运行单个测试用例 +- `runner.executeCommand(id)`: 执行 Obsidian 命令 +- `runner.setCursorToLine(n)`: 设置光标位置 +- `patchDate(offset)`: 模拟时间偏移(跨天测试用) +- `sleep(ms)`: 异步等待 + +### 测试隔离 + +- 每个 chain 使用独立测试行(动态创建) +- chain 间通过 `deps` 和 `cleanup` 保证执行顺序和清理 +- `cleanup_basic` chain 负责删除 basic chain 创建的测试行 diff --git a/doc/delete-timer-enhancement/stage1-PRD.md b/doc/delete-timer-enhancement/stage1-PRD.md index e69de29..7879b2c 100644 --- a/doc/delete-timer-enhancement/stage1-PRD.md +++ b/doc/delete-timer-enhancement/stage1-PRD.md @@ -0,0 +1,207 @@ + +# 📋 PRD:删除计时器功能扩展(Delete Timer Enhancement) + +**文档版本**: v1.0 | **创建日期**: 2026-03-02 | **状态**: 已审查 + +--- + +## 一、需求背景 + +Text Block Timer 插件目前已支持通过命令/右键菜单主动删除计时器(`delete-timer` 命令)。然而,用户在编辑 Markdown 文档时,可能通过以下方式**无意间删除**包含计时器的文本行: + +- 选中多行后按 Delete/Backspace +- 剪切(Ctrl+X)包含计时器的行 +- 在编辑过程中误删整行 + +这些"被动删除"场景下,计时器的 span 从文档中消失,但插件数据库中的记录仍然保留,导致**数据不一致**:Sidebar 仍显示已不存在的计时器,StatusBar 计数不准确。 + +同时,用户可能通过 Ctrl+Z(Undo)或粘贴(Ctrl+V)将被删除的计时器文本恢复回来,此时需要自动检测并恢复计时器的数据库状态。 + +## 二、需求目标 + +1. **自动检测被动删除**:当用户编辑导致 timer span 从文档中消失时,插件自动将该计时器标记为 `deleted` 状态 +2. **自动检测被动恢复**:当用户通过 Undo/粘贴使已删除的 timer span 重新出现时,插件自动将该计时器恢复为 `paused` 状态 +3. **Running 计时器结算**:如果被删除的计时器处于 running 状态,需先结算已累积的时长到 daily_dur +4. **UI 即时同步**:删除/恢复后 Sidebar 和 StatusBar 立即更新 + +## 三、用户故事 + +### US-01: 编辑器被动删除检测 +> 作为用户,当我在编辑器中删除包含计时器的文本行时,我希望插件自动检测到这个变化,并将计时器标记为删除状态,使 Sidebar 和 StatusBar 同步更新。 + +### US-02: 编辑器被动恢复检测 +> 作为用户,当我通过 Ctrl+Z 撤销删除后,我希望计时器自动恢复为暂停状态(paused),而不是自动恢复为运行状态,以避免意外计时。 + +### US-03: Running 计时器删除时时长结算 +> 作为用户,当我删除一个正在运行的计时器时,我希望已经累积的计时时长被正确结算并记录到每日统计中,不会丢失任何已计的时间。 + +### US-04: 剪切粘贴不丢数据 +> 作为用户,当我剪切包含计时器的行并粘贴到同一文件的另一位置时,我希望计时器数据完整保留,位置信息更新为新位置。 + +## 四、功能范围 + +### In Scope + +| 功能项 | 说明 | +|--------|------| +| CM6 被动删除检测 | 通过 `EditorView.updateListener` 检测 timer span 从文档中消失 | +| CM6 被动恢复检测 | 通过 `EditorView.updateListener` 检测 deleted 状态的 timer span 重新出现 | +| Running 计时器时长结算 | 被动删除 running 计时器前,调用 `computeRunningSessionSegments` 结算跨天时长 | +| 三层数据同步 | JSON 内存层 → IDB 异步层 → UI 回调层,保持数据一致 | +| Sidebar 即时同步 | 删除时移除卡片,恢复时添加卡片 | +| StatusBar 即时同步 | 更新运行中计时器计数 | +| 幂等性保障 | 所有删除/恢复操作前先检查当前 state,避免重复处理 | + +### Out of Scope + +| 排除项 | 理由 | +|--------|------| +| 新的 UI 组件 | 本需求为纯后台逻辑增强,不引入任何新 UI | +| 删除确认弹窗 | 被动删除不应中断编辑流,不弹窗确认 | +| Sidebar 删除/恢复过渡动画 | Nice to Have,推迟到后续统一优化 | +| 恢复后 Notice 提示 | Nice to Have,推迟到后续统一优化 | +| 跨文件剪切粘贴恢复 | 当前全量扫描机制已可处理,不在本期被动检测范围 | +| 预览模式 | 预览模式下不编辑,不会触发被动删除 | +| 外部修改 .md 文件 | 插件仅监控编辑器内的文档变更 | + +## 五、功能详情 + +### 5.1 被动删除检测 + +**触发条件**:用户在编辑器中进行操作导致 timer span 文本从文档中消失。 + +**检测机制**: +- 利用 CM6 `EditorView.updateListener` + `update.changes.iterChanges` 获取变更行范围,比较旧文档/新文档中的 timer span 差异 +- 仅扫描变更行范围,不做全文档扫描 + +**处理逻辑**: +1. 对于 **paused** 计时器:直接标记为 `deleted`,更新三层数据,同步 UI +2. 对于 **running** 计时器: + - 先调用 `computeRunningSessionSegments` 结算跨天时长 + - 将时长写入 `daily_dur`(JSON + IDB) + - 清除 session 状态 + - 停止 TimerManager 中的计时器 + - 标记为 `deleted`,同步 UI + +### 5.2 被动恢复检测 + +**触发条件**:用户通过 Ctrl+Z(Undo)或粘贴使得已 `deleted` 状态的 timer span 重新出现在文档中。 + +**处理逻辑**: +- 恢复为 **paused** 状态(不自动恢复为 running,避免意外计时) +- 从 span 的 `data-dur` 和 `data-ts` 属性读取时长和时间戳作为权威数据 +- 更新 `file_path` 和 `line_num` 为当前位置 +- 不写入文件(span 已在文档中) +- 同步 Sidebar 和 StatusBar + +### 5.3 幂等性保障 + +所有删除/恢复操作前先检查 JSON 内存层中的当前 `state`: +- 已是 `deleted` → 跳过删除操作 +- 不是 `deleted` → 跳过恢复操作 +- timer 不存在 → 跳过 + +### 5.4 三层数据同步 + +遵循现有的三层同步策略: +1. **JSON 内存层**:同步更新(`updateEntry`) +2. **IDB 异步层**:异步 fire-and-forget(`patchTimer` / `addDailyDur`) +3. **UI 层**:同步回调(`notifySidebar*` / `updateStatusBar`) + +**顺序约束**:JSON 内存层必须先更新,因为幂等检查依赖 JSON 内存层的同步读取。 + +## 六、验收标准 + +### AC-01: Paused 计时器被动删除 +- 编辑器中删除包含 paused timer span 的文本行后: + - JSON state = `deleted` + - IDB state = `deleted` + - Sidebar 卡片消失 + - StatusBar 更新 + +### AC-02: Running 计时器被动删除 + 时长结算 +- 编辑器中删除包含 running timer span 的文本行后: + - JSON state = `deleted`,`total_dur_sec > 0` + - IDB state = `deleted` + - daily_dur 有今天的时长记录 + - TimerManager 中该 timer 不再运行 + +### AC-03: Ctrl+Z 恢复 paused 计时器 +- 被动删除 paused timer 后执行 Undo: + - JSON state = `paused` + - IDB state = `paused` + - Sidebar 卡片重新出现 + - 行文本包含 timer span + +### AC-04: Ctrl+Z 恢复 running 计时器为 paused +- 被动删除 running timer 后执行 Undo: + - JSON state = `paused`(**不是** running) + - TimerManager 中该 timer 不在运行 + +### AC-05: 剪切粘贴同文件 +- 剪切包含 timer 的行 → 剪切后 state = `deleted` +- 粘贴到同文件其他位置 → 粘贴后 state = `paused`,`line_num` 更新为新位置 + +### AC-06: 多个计时器同时被动删除 +- 选中包含多个 timer 的多行文本并删除后,所有 timer 均为 `deleted` + +### AC-07: 幂等性 +- 对已 `deleted` 的 timer 再次触发被动删除:无副作用,daily_dur 无新增 +- 对非 `deleted` 的 timer 触发被动恢复:无副作用 + +### AC-08: 非计时器行编辑无影响 +- 编辑不包含 timer 的行:所有 timer 状态不变,无异常 + +## 七、非功能需求 + +### 7.1 性能要求 + +| 指标 | 目标值 | +|------|--------| +| updateListener 单次执行 | < 2ms | +| 被动删除总耗时 | < 10ms(JSON 同步 + IDB 异步) | +| 被动恢复总耗时 | < 5ms | +| 内存增长 | 0(无新的持久化数据结构) | + +### 7.2 兼容性 + +- 与现有 `handleDelete`(主动删除)和 `handleRestore`(文件恢复)完全独立,互不影响 +- 与现有 checkbox-to-timer updateListener 共存,互不干扰 +- Obsidian 桌面端 + 移动端均支持 + +### 7.3 数据安全 + +- JSON 内存层同步更新确保崩溃后 `seedFromJSON` 可重建 IDB +- `recoverCrashedTimers` 处理残留的 running 状态记录 + +## 八、UI/UX 约束 + +1. **不引入任何新 UI 组件**(无弹窗、无 Notice、无新按钮) +2. **不中断编辑流**:被动删除静默处理,不弹出确认框 +3. **恢复后为 paused**:避免意外计时,用户需手动 continue +4. 现有 Sidebar / StatusBar 的即时同步机制足以反馈状态变化 + +## 九、数据方案概述 + +**无 Schema 变更**。完全复用现有数据结构: +- `TimerEntry.state` 已支持 `'deleted'` 枚举值 +- `daily_dur` 已支持按 timer_id 和日期写入时长 +- IDB store 无需新增/修改 + +## 十、技术实现要点 + +1. **检测机制**:利用 CM6 `EditorView.updateListener` + `update.changes.iterChanges` 获取变更行范围,比较旧文档/新文档中的 timer span 差异 +2. **轻量提取**:使用正则表达式(而非 DOM 解析)从文本中提取 timer ID,避免 updateListener 热路径的性能开销 +3. **恢复机制**:不复用现有 `handleRestore`(该方法恢复为 running 且需要 view 参数),实现独立的被动恢复方法 +4. **幂等保障**:所有删除/恢复操作前先检查 JSON 内存层中的当前 state +5. **三层同步**:遵循 JSON → IDB → UI 的现有同步策略 +6. **仅扩展不修改**:不修改任何现有方法签名或行为,仅在 `TimerPlugin` 中新增方法 + +## 十一、风险与缓解 + +| 风险 | 影响 | 缓解措施 | +|------|------|----------| +| 大段文本粘贴/删除性能 | 低 | 正则扫描 1000 行文本 < 1ms | +| 高频 Undo/Redo | 低 | 幂等检查确保不重复处理,每次 < 5ms | +| IDB 写入失败 | 低 | 静默降级,下次启动 seedFromJSON 重建 | +| 多面板重复触发 | 低 | 幂等检查基于 JSON 同步读取 | diff --git a/tests/chains/adjust.mjs b/tests/chains/adjust.mjs new file mode 100644 index 0000000..f3622cc --- /dev/null +++ b/tests/chains/adjust.mjs @@ -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(']*id="' + timerId + '"[^>]*>.*?'); + 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`); + }); +} + diff --git a/tests/chains/basic.mjs b/tests/chains/basic.mjs new file mode 100644 index 0000000..e56d87e --- /dev/null +++ b/tests/chains/basic.mjs @@ -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}`); + }); +} + diff --git a/tests/chains/checkbox.mjs b/tests/chains/checkbox.mjs new file mode 100644 index 0000000..aa4011b --- /dev/null +++ b/tests/chains/checkbox.mjs @@ -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'); +} + diff --git a/tests/chains/cleanup_basic.mjs b/tests/chains/cleanup_basic.mjs new file mode 100644 index 0000000..05e42a9 --- /dev/null +++ b/tests/chains/cleanup_basic.mjs @@ -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'); + }); +} + diff --git a/tests/chains/crash_recovery.mjs b/tests/chains/crash_recovery.mjs new file mode 100644 index 0000000..6588d3d --- /dev/null +++ b/tests/chains/crash_recovery.mjs @@ -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'); +} + diff --git a/tests/chains/crossday.mjs b/tests/chains/crossday.mjs new file mode 100644 index 0000000..ba63616 --- /dev/null +++ b/tests/chains/crossday.mjs @@ -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'); + }); +} + diff --git a/tests/chains/crossday_adjust.mjs b/tests/chains/crossday_adjust.mjs new file mode 100644 index 0000000..2a3711d --- /dev/null +++ b/tests/chains/crossday_adjust.mjs @@ -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(']*id="' + timerId + '"[^>]*>.*?'); + 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'); + }); +} + diff --git a/tests/chains/delete.mjs b/tests/chains/delete.mjs new file mode 100644 index 0000000..c3cf6ff --- /dev/null +++ b/tests/chains/delete.mjs @@ -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)`); + }); +} + diff --git a/tests/chains/onunload_behavior.mjs b/tests/chains/onunload_behavior.mjs new file mode 100644 index 0000000..a4128fe --- /dev/null +++ b/tests/chains/onunload_behavior.mjs @@ -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'); +} + diff --git a/tests/chains/passive_delete.mjs b/tests/chains/passive_delete.mjs new file mode 100644 index 0000000..5a65e0f --- /dev/null +++ b/tests/chains/passive_delete.mjs @@ -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'); + }); +} + diff --git a/tests/chains/preflight.mjs b/tests/chains/preflight.mjs new file mode 100644 index 0000000..ed2eb3e --- /dev/null +++ b/tests/chains/preflight.mjs @@ -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`); +} + diff --git a/tests/chains/readonly.mjs b/tests/chains/readonly.mjs new file mode 100644 index 0000000..12ad37f --- /dev/null +++ b/tests/chains/readonly.mjs @@ -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'); +} + diff --git a/tests/chains/restore_behavior.mjs b/tests/chains/restore_behavior.mjs new file mode 100644 index 0000000..d714481 --- /dev/null +++ b/tests/chains/restore_behavior.mjs @@ -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'); +} + diff --git a/tests/chains/seed.mjs b/tests/chains/seed.mjs new file mode 100644 index 0000000..841af9f --- /dev/null +++ b/tests/chains/seed.mjs @@ -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})`); + }); +} + diff --git a/tests/chains/settings_behavior.mjs b/tests/chains/settings_behavior.mjs new file mode 100644 index 0000000..0c9eb67 --- /dev/null +++ b/tests/chains/settings_behavior.mjs @@ -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(' { + // 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('- { + 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'); +} + diff --git a/tests/chains/sidebar_tabs.mjs b/tests/chains/sidebar_tabs.mjs new file mode 100644 index 0000000..3a2c13c --- /dev/null +++ b/tests/chains/sidebar_tabs.mjs @@ -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}`); + } + }); +} + diff --git a/tests/e2e-timer-test.mjs b/tests/e2e-timer-test.mjs index dd5226e..73c0c08 100644 --- a/tests/e2e-timer-test.mjs +++ b/tests/e2e-timer-test.mjs @@ -24,11 +24,11 @@ // ─── Configuration ─────────────────────────────────────────────────────────── -const CDP_HOST = 'localhost'; -const CDP_PORT = 9222; -const WAIT_AFTER_COMMAND = 2000; // ms to wait after each command for effects to settle -const WAIT_TICK_ACCUMULATE = 6000; // ms to let a running timer accumulate some seconds -const TICK_TOLERANCE = 2; // seconds tolerance for tick-based assertions +export const CDP_HOST = 'localhost'; +export const CDP_PORT = 9222; +export const WAIT_AFTER_COMMAND = 2000; // ms to wait after each command for effects to settle +export const WAIT_TICK_ACCUMULATE = 6000; // ms to let a running timer accumulate some seconds +export const TICK_TOLERANCE = 2; // seconds tolerance for tick-based assertions // ─── CDP Helper ────────────────────────────────────────────────────────────── @@ -99,11 +99,11 @@ class CDPClient { // ─── Utility ───────────────────────────────────────────────────────────────── -function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } +export function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } -function assert(cond, msg) { if (!cond) throw new Error(`ASSERTION FAILED: ${msg}`); } +export function assert(cond, msg) { if (!cond) throw new Error(`ASSERTION FAILED: ${msg}`); } -function log(emoji, msg) { console.log(`${emoji} ${msg}`); } +export function log(emoji, msg) { console.log(`${emoji} ${msg}`); } // ─── Discover Obsidian WebSocket URL ───────────────────────────────────────── @@ -173,7 +173,7 @@ class TestRunner { // These helpers call the plugin's idb instance from within Obsidian's runtime. /** Read a single timer from IDB */ -function idbGetTimer(timerId) { +export function idbGetTimer(timerId) { return ` const idb = app.plugins.plugins['text-block-timer'].idb; const entry = await idb.getTimer('${timerId}'); @@ -182,7 +182,7 @@ function idbGetTimer(timerId) { } /** Read all daily_dur records for a timer from IDB */ -function idbGetDailyByTimer(timerId) { +export function idbGetDailyByTimer(timerId) { return ` const idb = app.plugins.plugins['text-block-timer'].idb; const recs = await idb.getDailyDurByTimer('${timerId}'); @@ -191,7 +191,7 @@ function idbGetDailyByTimer(timerId) { } /** Read all daily_dur records for today from IDB */ -function idbGetDailyByDate(date) { +export function idbGetDailyByDate(date) { return ` const idb = app.plugins.plugins['text-block-timer'].idb; const recs = await idb.getDailyDurByDate('${date}'); @@ -200,7 +200,7 @@ function idbGetDailyByDate(date) { } /** Read all daily_dur records from IDB */ -function idbGetAllDaily() { +export function idbGetAllDaily() { return ` const idb = app.plugins.plugins['text-block-timer'].idb; const recs = await idb.getAllDailyDur(); @@ -209,7 +209,7 @@ function idbGetAllDaily() { } /** Read all timers from IDB */ -function idbGetAllTimers() { +export function idbGetAllTimers() { return ` const idb = app.plugins.plugins['text-block-timer'].idb; const entries = await idb.getAllTimers(); @@ -218,7 +218,7 @@ function idbGetAllTimers() { } /** Read timers by state from IDB */ -function idbGetTimersByState(states) { +export function idbGetTimersByState(states) { const statesStr = JSON.stringify(states); return ` const idb = app.plugins.plugins['text-block-timer'].idb; @@ -255,7 +255,7 @@ function idbGetTimersByState(states) { // ═════════════════════════════════════════════════════════════════════════════ // Shared state across chains (populated by preflight/basic) -const shared = { +export const shared = { today: null, testLineNum: null, createdTimerId: null, @@ -265,2715 +265,26 @@ const shared = { preAdjustDailySum: 0, }; -// ─── Chain: preflight ──────────────────────────────────────────────────────── -async function chain_preflight(runner) { - shared.today = new Date().toLocaleDateString('sv'); // YYYY-MM-DD +// ─── Chain Imports ─────────────────────────────────────────────────────────── + +import { chain_preflight } from './chains/preflight.mjs'; +import { chain_basic } from './chains/basic.mjs'; +import { chain_adjust } from './chains/adjust.mjs'; +import { chain_seed } from './chains/seed.mjs'; +import { chain_delete } from './chains/delete.mjs'; +import { chain_crossday } from './chains/crossday.mjs'; +import { chain_sidebar_tabs } from './chains/sidebar_tabs.mjs'; +import { chain_readonly } from './chains/readonly.mjs'; +import { chain_crossday_adjust } from './chains/crossday_adjust.mjs'; +import { chain_passive_delete } from './chains/passive_delete.mjs'; +import { chain_checkbox } from './chains/checkbox.mjs'; +import { chain_crash_recovery } from './chains/crash_recovery.mjs'; +import { chain_settings_behavior } from './chains/settings_behavior.mjs'; +import { chain_restore_behavior } from './chains/restore_behavior.mjs'; +import { chain_onunload_behavior } from './chains/onunload_behavior.mjs'; +import { chain_cleanup_basic } from './chains/cleanup_basic.mjs'; - // ══════════════════════════════════════════════════════════════════════════ - // 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`); -} - -// ─── Chain: basic ──────────────────────────────────────────────────────────── - -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}`); - }); -} - -// ─── Chain: adjust ──────────────────────────────────────────────────────────── - -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(']*id="' + timerId + '"[^>]*>.*?'); - 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`); - }); -} - -// ─── Chain: seed ────────────────────────────────────────────────────────────── - -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})`); - }); -} - -// ─── Chain: delete ──────────────────────────────────────────────────────────── - -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)`); - }); -} - -// ─── 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! -// ───────────────────────────────────────────────────────────────────────────── - -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'); - }); -} - -// ─── Chain: sidebar_tabs ────────────────────────────────────────────────────── -// Tests sidebar scope switching, filter/sort controls, summary correctness, -// and statistics chart data loading for all scopes. - -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}`); - } - }); -} - -// ─── Chain: readonly ────────────────────────────────────────────────────────── -// Tests that a running timer continues ticking correctly when the editor -// switches to reading/preview mode (non-source mode). - -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'); -} - -// ─── Chain: crossday_adjust ─────────────────────────────────────────────────── -// Creates a timer that spans 3 days via successive Date monkey-patches, -// then tests manual increase/decrease duration allocation across days. -// -// Day layout (example): -// day1 (2 days ago): timer runs ~3s → daily_dur ≈ 3 -// day2 (yesterday): timer runs ~3s → daily_dur ≈ 3 -// day3 (today): timer runs ~3s → daily_dur ≈ 3 -// total ≈ 9 -// -// Then we PAUSE and test: -// 1. Increase by +5 → all goes to today → day1=3, day2=3, day3=3+5=8, total=14 -// 2. Decrease to 4 → LIFO: deduct from newest first -// day1=3 (preserved), day2=1 (partial), day3=0 (zeroed), total=4 -// ───────────────────────────────────────────────────────────────────────────── - -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 (same as crossday chain) - 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; - })()`); - } - - // 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 12:00:00, start timer, let it run ~3s ───────── - - const day1Noon = new Date(day1 + 'T12:00:00').getTime(); - const offset1 = day1Noon - realNowMs; - - await runner.run('CDA: patch to day1 noon and start 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: ${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}`); - }); - - // Let it tick ~3s on day1 - log('⏳', 'Letting timer run ~3s on day1...'); - await sleep(3000); - - await runner.run('CDA: verify day1 ticks in IDB', async() => { - 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`); - }); - - // ── Step 2: Patch to day2 12:00:00, let it run ~3s ────────────────────── - - const day2Noon = new Date(day2 + 'T12:00:00').getTime(); - const offset2 = day2Noon - realNowMs; - - await runner.run('CDA: patch to day2 noon (time jump)', async() => { - // Before jumping, we need to reset oldTs in the timer manager to avoid - // a huge tickDelta from the time jump - await runner.eval(`(() => { - const p = app.plugins.plugins['text-block-timer']; - const td = p.manager.getTimerData('${cdaTimerId}'); - if (td) { - const nowSec = Math.floor((window.__OrigDate || Date).now() / 1000); - td.ts = nowSec; - } - return true; - })()`); - - await patchDate(offset2); - const fakeDate = await runner.eval(`new Date().toLocaleDateString('sv')`); - assert(fakeDate === day2, `Expected ${day2}, got ${fakeDate}`); - log('🕐', `Patched to day2: ${fakeDate}`); - - // Reset oldTs again after patch to match the new patched time - await runner.eval(`(() => { - const p = app.plugins.plugins['text-block-timer']; - const td = p.manager.getTimerData('${cdaTimerId}'); - if (td) td.ts = Math.floor(Date.now() / 1000); - return true; - })()`); - }); - - // Let it tick ~3s on day2 - log('⏳', 'Letting timer run ~3s on day2...'); - await sleep(3000); - - await runner.run('CDA: verify day2 ticks in IDB', async() => { - 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`); - }); - - // ── Step 3: Patch to day3 (today) 12:00:00, let it run ~3s ────────────── - - const day3Noon = new Date(day3 + 'T12:00:00').getTime(); - const offset3 = day3Noon - realNowMs; - - await runner.run('CDA: patch to day3 (today) noon', async() => { - await runner.eval(`(() => { - const p = app.plugins.plugins['text-block-timer']; - const td = p.manager.getTimerData('${cdaTimerId}'); - if (td) { - const nowSec = Math.floor((window.__OrigDate || Date).now() / 1000); - td.ts = nowSec; - } - return true; - })()`); - - await patchDate(offset3); - const fakeDate = await runner.eval(`new Date().toLocaleDateString('sv')`); - assert(fakeDate === day3, `Expected ${day3}, got ${fakeDate}`); - log('🕐', `Patched to day3: ${fakeDate}`); - - await runner.eval(`(() => { - const p = app.plugins.plugins['text-block-timer']; - const td = p.manager.getTimerData('${cdaTimerId}'); - if (td) td.ts = Math.floor(Date.now() / 1000); - return true; - })()`); - }); - - // Let it tick ~3s on day3 - log('⏳', 'Letting timer run ~3s on day3...'); - await sleep(3000); - - await runner.run('CDA: verify all 3 days in IDB', async() => { - const dailyRaw = await runner.evalAsync(idbGetDailyByTimer(cdaTimerId)); - const recs = JSON.parse(dailyRaw); - log('📊', 'IDB daily_dur across 3 days:'); - for (const rec of recs) { - log('📊', ` ${rec.stat_date}: ${rec.duration_sec}s`); - } - assert(recs.length >= 3, `Expected >= 3 day records, got ${recs.length}`); - const d1 = recs.find(r => r.stat_date === day1); - const d2 = recs.find(r => r.stat_date === day2); - const d3 = recs.find(r => r.stat_date === day3); - assert(d1 && d1.duration_sec >= 2, `day1 dur should be >= 2s`); - assert(d2 && d2.duration_sec >= 2, `day2 dur should be >= 2s`); - assert(d3 && d3.duration_sec >= 2, `day3 dur should be >= 2s`); - }); - - // ── Step 4: Restore Date and PAUSE ────────────────────────────────────── - - await runner.run('CDA: restore Date and PAUSE timer', async() => { - await restoreDate(); - const realTime = await runner.eval(`new Date().toISOString()`); - log('🕐', `Date restored: ${realTime}`); - - // Reset oldTs to real time before pause - await runner.eval(`(() => { - const p = app.plugins.plugins['text-block-timer']; - const td = p.manager.getTimerData('${cdaTimerId}'); - if (td) td.ts = Math.floor(Date.now() / 1000); - return true; - })()`); - - 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'); - }); - - // Record pre-adjustment state - let preState; - await runner.run('CDA: record pre-adjustment daily_dur state', async() => { - 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); - - preState = { - total: timer.total_dur_sec, - dailySum, - dailyMap, - day1Dur: dailyMap[day1] || 0, - day2Dur: dailyMap[day2] || 0, - day3Dur: dailyMap[day3] || 0, - }; - - log('📊', `Pre-adjust: total=${preState.total}s, sum=${preState.dailySum}s`); - log('📊', ` day1(${day1})=${preState.day1Dur}s, day2(${day2})=${preState.day2Dur}s, day3(${day3})=${preState.day3Dur}s`); - - // Verify consistency: daily_sum should ≈ total - const drift = Math.abs(preState.dailySum - preState.total); - assert(drift <= TICK_TOLERANCE, - `Pre-adjust daily_sum (${preState.dailySum}) vs total (${preState.total}) drift=${drift}s`); - }); - - // ── Step 5: Manual INCREASE (+5s) — delta goes to today ───────────────── - - await runner.run('CDA: increase duration by +5s — should go to today', async() => { - // Re-read preState fresh from IDB to avoid stale values - const freshTimerRaw = await runner.evalAsync(idbGetTimer(cdaTimerId)); - const freshTimer = JSON.parse(freshTimerRaw); - const freshDailyRaw = await runner.evalAsync(idbGetDailyByTimer(cdaTimerId)); - const freshRecs = JSON.parse(freshDailyRaw); - const freshMap = {}; - for (const rec of freshRecs) freshMap[rec.stat_date] = rec.duration_sec; - preState.total = freshTimer.total_dur_sec; - preState.day1Dur = freshMap[day1] || 0; - preState.day2Dur = freshMap[day2] || 0; - preState.day3Dur = freshMap[day3] || 0; - preState.dailySum = freshRecs.reduce((s, r) => s + r.duration_sec, 0); - - const newDur = preState.total + 5; - 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(']*id="' + timerId + '"[^>]*>.*?'); - 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); - - // Verify IDB - const timerRaw = await runner.evalAsync(idbGetTimer(cdaTimerId)); - const timer = JSON.parse(timerRaw); - assert(timer.total_dur_sec === newDur, - `Expected total=${newDur}, got ${timer.total_dur_sec}`); - - const dailyRaw = await runner.evalAsync(idbGetDailyByTimer(cdaTimerId)); - const recs = JSON.parse(dailyRaw); - const afterMap = {}; - for (const rec of recs) afterMap[rec.stat_date] = rec.duration_sec; - const afterSum = recs.reduce((s, r) => s + r.duration_sec, 0); - - log('📊', `After +5s increase:`); - for (const rec of recs) log('📊', ` IDB: ${rec.stat_date} = ${rec.duration_sec}s`); - log('📊', ` total=${timer.total_dur_sec}s, daily_sum=${afterSum}s`); - - // day1 and day2 should be UNCHANGED - assert(afterMap[day1] === preState.day1Dur, - `day1 should be unchanged: expected ${preState.day1Dur}, got ${afterMap[day1]}`); - assert(afterMap[day2] === preState.day2Dur, - `day2 should be unchanged: expected ${preState.day2Dur}, got ${afterMap[day2]}`); - - // today (day3) should have increased by 5 - const expectedDay3 = preState.day3Dur + 5; - assert(afterMap[day3] === expectedDay3, - `day3 should be ${expectedDay3} (was ${preState.day3Dur} + 5), got ${afterMap[day3]}`); - - // daily_sum should match total (±1s tolerance for tick timing) - const sumDrift = Math.abs(afterSum - timer.total_dur_sec); - assert(sumDrift <= 1, - `daily_sum (${afterSum}) should match total (${timer.total_dur_sec}), drift=${sumDrift}s`); - - // Update preState for next test - preState.total = timer.total_dur_sec; - preState.dailySum = afterSum; - preState.day1Dur = afterMap[day1] || 0; - preState.day2Dur = afterMap[day2] || 0; - preState.day3Dur = afterMap[day3] || 0; - }); - - // ── Step 6: Manual DECREASE (to 4s) — LIFO: deduct from newest first ──── - // Pre-state after increase: day1=~3, day2=~3, day3=~8, total=~14 - // Decrease to 4: sorted ascending [day1, day2, day3] - // remaining=4: day1=3 (preserved, remaining=1), day2=1 (partial), day3=0 (zeroed) - - await runner.run('CDA: decrease duration to 4s — LIFO deduction from newest dates', async() => { - // Re-read preState fresh from IDB before the decrease - const freshTimerRaw2 = await runner.evalAsync(idbGetTimer(cdaTimerId)); - const freshTimer2 = JSON.parse(freshTimerRaw2); - const freshDailyRaw2 = await runner.evalAsync(idbGetDailyByTimer(cdaTimerId)); - const freshRecs2 = JSON.parse(freshDailyRaw2); - const freshMap2 = {}; - for (const rec of freshRecs2) freshMap2[rec.stat_date] = rec.duration_sec; - preState.total = freshTimer2.total_dur_sec; - preState.day1Dur = freshMap2[day1] || 0; - preState.day2Dur = freshMap2[day2] || 0; - preState.day3Dur = freshMap2[day3] || 0; - log('📊', `Pre-decrease state: day1=${preState.day1Dur}, day2=${preState.day2Dur}, day3=${preState.day3Dur}, total=${preState.total}`); - - const newDur = 4; - 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(']*id="' + timerId + '"[^>]*>.*?'); - 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); - - // Verify IDB - const timerRaw = await runner.evalAsync(idbGetTimer(cdaTimerId)); - const timer = JSON.parse(timerRaw); - assert(timer.total_dur_sec === newDur, - `Expected total=${newDur}, got ${timer.total_dur_sec}`); - - const dailyRaw = await runner.evalAsync(idbGetDailyByTimer(cdaTimerId)); - const recs = JSON.parse(dailyRaw); - const afterMap = {}; - for (const rec of recs) afterMap[rec.stat_date] = rec.duration_sec; - const afterSum = recs.reduce((s, r) => s + r.duration_sec, 0); - - log('📊', `After decrease to ${newDur}s (LIFO from newest):`); - for (const rec of recs) log('📊', ` IDB: ${rec.stat_date} = ${rec.duration_sec}s`); - log('📊', ` total=${timer.total_dur_sec}s, daily_sum=${afterSum}s`); - - // daily_sum should equal 4 - assert(afterSum === newDur, - `daily_sum (${afterSum}) should equal newDur (${newDur})`); - - // LIFO deduction: traverse ascending (oldest first), preserve oldest with remaining. - // Algorithm: remaining=newDur, for each date oldest→newest: - // if remaining >= dur → preserve, remaining -= dur - // if remaining < dur → partial = remaining, remaining = 0 - // if remaining == 0 → zeroed - // - // Simulate the expected outcome - const sortedDays = [ - { date: day1, dur: preState.day1Dur }, - { date: day2, dur: preState.day2Dur }, - { date: day3, dur: preState.day3Dur }, - ].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 = afterMap[day1] ?? 0; - const d2After = afterMap[day2] ?? 0; - const d3After = afterMap[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: total = day1 + day2 + day3 - assert(d1After + d2After + d3After === newDur, - `Sum ${d1After}+${d2After}+${d3After}=${d1After + d2After + d3After} should equal ${newDur}`); - }); - - // ── Step 7: Verify JSON daily_dur matches IDB (cross-layer) ───────────── - - 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)]); - for (const date of[...allDates].sort()) { - const j = jsonResult[date] ?? 0; - const i = idbMap[date] ?? 0; - log('📊', ` ${date}: JSON=${j}s, IDB=${i}s`); - assert(j === i, - `${date}: JSON(${j}) !== IDB(${i})`); - } - }); - - // ── Step 8: Increase from reduced state — should go to today ──────────── - - await runner.run('CDA: increase from 4s to 10s — +6s should go to today', async() => { - const newDur = 10; - 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(']*id="' + timerId + '"[^>]*>.*?'); - 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); - - const dailyRaw = await runner.evalAsync(idbGetDailyByTimer(cdaTimerId)); - const recs = JSON.parse(dailyRaw); - const afterMap = {}; - for (const rec of recs) afterMap[rec.stat_date] = rec.duration_sec; - const afterSum = recs.reduce((s, r) => s + r.duration_sec, 0); - - log('📊', `After increase from 4→10s:`); - for (const rec of recs) log('📊', ` IDB: ${rec.stat_date} = ${rec.duration_sec}s`); - - assert(afterSum === newDur, - `daily_sum (${afterSum}) should equal ${newDur}`); - - // The +6 delta should go to today (day3) - // After previous decrease: day1=preState.day1Dur, day2=4-preState.day1Dur, day3=0 - // After +6: day1=unchanged, day2=unchanged, day3=0+6=6 - assert((afterMap[day3] ?? 0) === 6, - `day3 should be 6 (0 + delta 6), got ${afterMap[day3] ?? 0}`); - }); - - // ── 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'); - }); -} - -// ─── 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. -// ───────────────────────────────────────────────────────────────────────────── - -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'); - }); -} - -// ─── Chain: cleanup_basic ──────────────────────────────────────────────────── -// Cleanup for the basic/adjust/seed/delete chains (removes E2E_SIDEBAR_TEST_ line) - -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'); - }); -} - -// ═════════════════════════════════════════════════════════════════════════════ // CHAIN REGISTRY & DISPATCHER // ═════════════════════════════════════════════════════════════════════════════ @@ -2990,11 +301,16 @@ const CHAIN_REGISTRY = { crossday: { fn: chain_crossday, deps: ['preflight'], cleanup: null }, crossday_adjust: { fn: chain_crossday_adjust, deps: ['preflight'], cleanup: null }, passive_delete: { fn: chain_passive_delete, deps: ['preflight'], cleanup: null }, + checkbox: { fn: chain_checkbox, deps: ['preflight'], cleanup: null }, + crash_recovery: { fn: chain_crash_recovery, deps: ['preflight'], cleanup: null }, + settings_behavior: { fn: chain_settings_behavior, deps: ['preflight'], cleanup: null }, + restore_behavior: { fn: chain_restore_behavior, deps: ['preflight'], cleanup: null }, + onunload_behavior: { fn: chain_onunload_behavior, deps: ['preflight'], cleanup: null }, cleanup_basic: { fn: chain_cleanup_basic, deps: [], cleanup: null }, }; // Default run order when no chains specified (= run everything) -const ALL_CHAINS = ['preflight', 'basic', 'sidebar_tabs', 'readonly', 'adjust', 'seed', 'delete', 'passive_delete', 'crossday', 'crossday_adjust', 'cleanup_basic']; +const ALL_CHAINS = ['preflight', 'basic', 'sidebar_tabs', 'readonly', 'adjust', 'seed', 'delete', 'passive_delete', 'checkbox', 'crash_recovery', 'settings_behavior', 'restore_behavior', 'onunload_behavior', 'crossday', 'crossday_adjust', 'cleanup_basic']; function resolveChains(requestedNames) { const ordered = []; @@ -3029,6 +345,36 @@ function resolveChains(requestedNames) { // ─── Main ──────────────────────────────────────────────────────────────────── async function main() { + // ── Auto-start Obsidian with remote debugging ────────────────────── + const OBSIDIAN_EXE = 'C:\\Users\\frankthwang\\AppData\\Local\\Programs\\Obsidian\\Obsidian.exe'; + const OBSIDIAN_PORT = 9222; + + // Check if Obsidian is already running with debug port + let obsidianReady = false; + try { + const checkResp = await fetch(`http://${CDP_HOST}:${CDP_PORT}/json`); + if (checkResp.ok) obsidianReady = true; + } catch { /* not running */ } + + if (!obsidianReady) { + log('🚀', 'Starting Obsidian with remote debugging...'); + const { execSync } = await import('child_process'); + execSync(`Start-Process "${OBSIDIAN_EXE}" -ArgumentList "--remote-debugging-port=${OBSIDIAN_PORT}"`, { shell: 'powershell.exe' }); + // Wait for Obsidian to start and CDP to become available + for (let attempt = 0; attempt < 30; attempt++) { + await new Promise(r => setTimeout(r, 2000)); + try { + const resp = await fetch(`http://${CDP_HOST}:${CDP_PORT}/json`); + if (resp.ok) { obsidianReady = true; break; } + } catch { /* retry */ } + if (attempt % 5 === 4) log('⏳', `Waiting for Obsidian... (${attempt + 1}/30)`); + } + if (!obsidianReady) throw new Error('Obsidian failed to start within 60 seconds'); + log('✅', 'Obsidian is ready'); + } else { + log('✅', 'Obsidian already running with debug port'); + } + // Parse command-line arguments for chain selection const args = process.argv.slice(2); let requestedChains; @@ -3175,4 +521,4 @@ async function main() { main().catch(e => { console.error('Fatal error:', e); process.exit(2); -}); \ No newline at end of file +});