wth461694678_text-block-timer/tests/chains/basic.mjs

432 lines
25 KiB
JavaScript

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