import { describe, it, beforeEach, afterEach, expect, $, click, sleep, closeAll } from './runner.js?v=4'; // ── Helpers ── function openTim() { window._timToggle?.(); } function closeTim() { const overlay = $('#tim-overlay'); if (overlay && !overlay.classList.contains('hidden')) window._timToggle?.(); } function isOpen() { return !$('#tim-overlay').classList.contains('hidden'); } function setInput(id, value) { const el = $('#' + id); el.value = value; el.dispatchEvent(new Event('input', { bubbles: true })); } function activateTab(name) { const tab = document.querySelector(`.tim-tab[data-tim-tab="${name}"]`); tab.click(); } // ── Registration ── describe('Tim Plugin: Registration', () => { it('plugin is registered with hyprcrd', () => { const meta = window.hyprcrd.plugins.find(p => p.name === 'tim'); expect(meta).toExist(); }); it('tim editor command is registered', () => { expect(window.hyprcrd.editor.getCommand('tim')).toExist(); }); it('toolbar clock button is injected', () => { const btn = document.querySelector('#md-toolbar [data-md="tim"]'); expect(btn).toExist(); }); it('window._timToggle is exposed', () => { expect(typeof window._timToggle).toBe('function'); }); }); // ── Structure ── describe('Tim Plugin: Structure', () => { it('overlay and panel exist in DOM', () => { expect($('#tim-overlay')).toExist(); expect(document.querySelector('.tim-panel')).toExist(); }); it('overlay is hidden initially', () => { expect($('#tim-overlay').classList.contains('hidden')).toBe(true); }); it('all three tabs exist', () => { expect(document.querySelector('.tim-tab[data-tim-tab="duration"]')).toExist(); expect(document.querySelector('.tim-tab[data-tim-tab="add"]')).toExist(); expect(document.querySelector('.tim-tab[data-tim-tab="workday"]')).toExist(); }); it('all inputs + result fields exist', () => { const ids = [ 'tim-dur-start', 'tim-dur-end', 'tim-dur-result', 'tim-add-time', 'tim-add-dur', 'tim-add-result', 'tim-wd-start', 'tim-wd-break-from', 'tim-wd-break-back', 'tim-wd-target', 'tim-wd-result', ]; for (const id of ids) expect($('#' + id)).toExist(); }); it('target input has no default value (placeholder only)', () => { expect($('#tim-wd-target').value).toBe(''); expect($('#tim-wd-target').placeholder).toBe('0800'); }); }); // ── Open / Close ── describe('Tim Plugin: Open & Close', () => { afterEach(async () => { closeTim(); await closeAll(); }); it('toolbar button opens tim', async () => { const btn = document.querySelector('#md-toolbar [data-md="tim"]'); btn.click(); await sleep(30); expect(isOpen()).toBe(true); }); it('_timToggle toggles visibility', async () => { openTim(); await sleep(30); expect(isOpen()).toBe(true); openTim(); await sleep(30); expect(isOpen()).toBe(false); }); it('close button hides overlay', async () => { openTim(); await sleep(30); click('#tim-close'); await sleep(30); expect(isOpen()).toBe(false); }); it('Escape closes overlay', async () => { openTim(); await sleep(30); window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await sleep(30); expect(isOpen()).toBe(false); }); it('clicking backdrop closes overlay', async () => { openTim(); await sleep(30); const overlay = $('#tim-overlay'); overlay.dispatchEvent(new MouseEvent('click', { bubbles: true })); // Backdrop-click requires e.target === overlay, which happens naturally // for a click on the overlay itself. await sleep(30); expect(isOpen()).toBe(false); }); }); // ── Tab switching ── describe('Tim Plugin: Tabs', () => { afterEach(async () => { closeTim(); await closeAll(); }); it('duration tab is active on load', async () => { openTim(); await sleep(30); const tab = document.querySelector('.tim-tab[data-tim-tab="duration"]'); expect(tab.classList.contains('active')).toBe(true); }); it('clicking Add tab activates it and shows its panel', async () => { openTim(); await sleep(30); activateTab('add'); await sleep(20); expect(document.querySelector('.tim-tab[data-tim-tab="add"]').classList.contains('active')).toBe(true); expect(document.querySelector('[data-tim-panel="add"]').classList.contains('hidden')).toBe(false); expect(document.querySelector('[data-tim-panel="duration"]').classList.contains('hidden')).toBe(true); }); it('switching to Workday reveals all four workday inputs', async () => { openTim(); await sleep(30); activateTab('workday'); await sleep(20); const panel = document.querySelector('[data-tim-panel="workday"]'); expect(panel.classList.contains('hidden')).toBe(false); expect(panel.querySelectorAll('input').length).toBe(4); }); }); // ── Duration calculation ── describe('Tim Plugin: Duration Calc', () => { afterEach(async () => { closeTim(); await closeAll(); }); it('0830 → 1715 is 8h 45m', async () => { openTim(); await sleep(30); setInput('tim-dur-start', '0830'); setInput('tim-dur-end', '1715'); expect($('#tim-dur-result').textContent).toBe('8h 45m'); }); it('wraps past midnight (2200 → 0200 = 4h 00m)', async () => { openTim(); await sleep(30); setInput('tim-dur-start', '2200'); setInput('tim-dur-end', '0200'); expect($('#tim-dur-result').textContent).toBe('4h 00m'); }); it('colon format (08:30) is accepted', async () => { openTim(); await sleep(30); setInput('tim-dur-start', '08:30'); setInput('tim-dur-end', '09:00'); expect($('#tim-dur-result').textContent).toBe('0h 30m'); }); it('empty input clears the result', async () => { openTim(); await sleep(30); setInput('tim-dur-start', '0800'); setInput('tim-dur-end', '0900'); expect($('#tim-dur-result').textContent).toBe('1h 00m'); setInput('tim-dur-end', ''); expect($('#tim-dur-result').textContent).toBe(''); }); it('invalid input (minutes >= 60) clears the result', async () => { openTim(); await sleep(30); setInput('tim-dur-start', '0880'); setInput('tim-dur-end', '1000'); expect($('#tim-dur-result').textContent).toBe(''); }); }); // ── Add calculation ── describe('Tim Plugin: Add Calc', () => { afterEach(async () => { closeTim(); await closeAll(); }); it('0830 + 0130 = 1000', async () => { openTim(); await sleep(30); activateTab('add'); await sleep(10); setInput('tim-add-time', '0830'); setInput('tim-add-dur', '0130'); expect($('#tim-add-result').textContent).toBe('1000'); }); it('wraps around 24h (2330 + 0100 = 0030)', async () => { openTim(); await sleep(30); activateTab('add'); await sleep(10); setInput('tim-add-time', '2330'); setInput('tim-add-dur', '0100'); expect($('#tim-add-result').textContent).toBe('0030'); }); }); // ── Workday calculation ── describe('Tim Plugin: Workday Calc', () => { afterEach(async () => { closeTim(); await closeAll(); }); it('start 0800, break 1200→1245, target default 8h → 1645', async () => { openTim(); await sleep(30); activateTab('workday'); await sleep(10); setInput('tim-wd-start', '0800'); setInput('tim-wd-break-from', '1200'); setInput('tim-wd-break-back', '1245'); expect($('#tim-wd-result').textContent).toContain('End at 1645'); }); it('custom target of 0400 → ends earlier', async () => { openTim(); await sleep(30); activateTab('workday'); await sleep(10); setInput('tim-wd-start', '0800'); setInput('tim-wd-break-from', '1000'); setInput('tim-wd-break-back', '1015'); setInput('tim-wd-target', '0400'); // 2h before break + 2h after = 4h; 10:15 + 2h = 1215 expect($('#tim-wd-result').textContent).toContain('End at 1215'); }); it('break before start shows an error message', async () => { openTim(); await sleep(30); activateTab('workday'); await sleep(10); setInput('tim-wd-start', '1000'); setInput('tim-wd-break-from', '0900'); setInput('tim-wd-break-back', '0930'); expect($('#tim-wd-result').textContent).toContain('Break must be after start'); expect($('#tim-wd-result').classList.contains('err')).toBe(true); }); it('target already reached before break → reports the finish time', async () => { openTim(); await sleep(30); activateTab('workday'); await sleep(10); setInput('tim-wd-start', '0800'); setInput('tim-wd-break-from', '1800'); // more than 8h before break setInput('tim-wd-break-back', '1815'); setInput('tim-wd-target', '0800'); expect($('#tim-wd-result').textContent).toContain('Target already reached'); }); }); // ── Focus selection + Tab cycling ── describe('Tim Plugin: Focus & Tab Cycling', () => { // Previous describes (Add Calc / Workday Calc) leave their tab active, // which leaves the duration panel `.hidden` and breaks our Tab-cycle // handler's closest('.tim-tab-body:not(.hidden)') lookup. Reset first. beforeEach(() => { document.querySelectorAll('#tim-overlay input').forEach(i => { i.value = ''; }); activateTab('duration'); }); afterEach(async () => { closeTim(); await closeAll(); }); // Spies HTMLInputElement.prototype.focus so we can detect which input // the handler *tried* to focus without depending on document.activeElement // (which is unreliable in some synthetic-event runs). function withFocusSpy(fn) { const orig = HTMLInputElement.prototype.focus; let last = null; HTMLInputElement.prototype.focus = function() { last = this; return orig.apply(this, arguments); }; try { return fn(() => last); } finally { HTMLInputElement.prototype.focus = orig; } } it('focusing an input with value selects the text', async () => { openTim(); await sleep(30); const el = $('#tim-dur-start'); el.value = '0830'; el.focus(); el.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); expect(el.selectionStart).toBe(0); expect(el.selectionEnd).toBe(el.value.length); }); it('Tab from last input wraps to the first within the active panel', async () => { openTim(); await sleep(30); const first = $('#tim-dur-start'); const last = $('#tim-dur-end'); withFocusSpy(getLast => { last.focus(); last.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })); expect(getLast()).toBe(first); }); }); it('Shift+Tab from first wraps to the last within the active panel', async () => { openTim(); await sleep(30); const first = $('#tim-dur-start'); const last = $('#tim-dur-end'); withFocusSpy(getLast => { first.focus(); first.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true })); expect(getLast()).toBe(last); }); }); it('Tab cycling stays within the active tab (workday has 4 inputs)', async () => { openTim(); await sleep(30); activateTab('workday'); await sleep(10); const inputs = [...document.querySelectorAll('[data-tim-panel="workday"] input')]; expect(inputs.length).toBe(4); withFocusSpy(getLast => { inputs[3].focus(); inputs[3].dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })); expect(getLast()).toBe(inputs[0]); }); }); });