Files
hyprcrd/public/plugins/tests/tim.test.js
T
thandClaude Opus 4.6 d50f9fe789 relay UI overhaul, favicon, settings, admin last-seen, fixes
Relay:
  - Inbox/shared/sent split into own view-inbox (nav button + poll).
    Compose is now single-column, centered.
  - Header logo links to compose. Inbox badge stays fresh on all
    views via a quiet loadInbox on every view switch.
  - Mobile menu: full-height sheet below header, larger buttons
    (15px font, 14px padding), full width.
  - Login form wrapped in <form> with name attributes + for/id on
    labels so password managers can autofill.
  - Admin user list shows "last seen <relative time>" per user.
    last_seen_at column on tenants, updated on every authenticated
    request (session + client API).
  - Cache-busting on style.css + app.js via filemtime.
  - View persistence in localStorage (survives reload).
  - Shared-bucket cards show title only (preview line removed).

Favicon:
  - favicon.svg added to project root + copied to public/, relay/,
    website/. Linked via <link rel="icon" type="image/svg+xml"> in
    all three <head> sections.

Settings:
  - New toggle: "Show shopping list items in task collector"
    (default off). collectTasks skips #einkauf/#shopping cards when
    disabled.

Fixes:
  - card-spinner gets display:inline-block so it rotates outside
    flex containers (shopping save spinner).
  - Tim tab test opens the plugin before checking active state.
  - Compose view centered with margin:auto on desktop.

Bumps to 1.9.29.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 13:16:23 +02:00

333 lines
12 KiB
JavaScript

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