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

387 lines
21 KiB
JavaScript

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