Shopping lists:
- Multiple lists per tenant: picker uses checkboxes instead of
radios. Relay schema migrated to composite PK (tenant_id,
card_uid) with a position column for display ordering.
- Picker rows are drag-and-drop reorderable (grip handle +
accent drop indicator). Order persists in localStorage and is
pushed to the relay as position integers — phone renders lists
in that order (ORDER BY position, title).
- Each picker row has a goto-arrow that closes the popup,
switches to the card's bucket if needed, and opens the card.
- Relay DELETE /api/shopping removes deactivated lists. Client
proxy forwards DELETE. Deactivating a list in the picker
immediately removes it from the relay.
- Phone UI renders multiple lists as stacked boxes with headers.
Section headings (# h1–h6) get 14px top padding for breathing
room. CSS selectors fixed to match div-based headings inside
the new .shopping-list-box containers.
- Help text updated for multi-list wording.
- pullOne preserves the synced position so a pull doesn't cause
a spurious re-push of the same order.
Test fixes:
- Line-numbers gutter height tolerance bumped 50%→60% — OS text
shaping can push the divergence past 50%.
- Find/replace flaky: replaced sleep(200) with waitFor on the
confirm dialog so slow renders don't miss the OK click.
- Shopping picker test updated: checkboxes instead of radios.
Bumps to 1.9.25.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
103 lines
4.9 KiB
JavaScript
103 lines
4.9 KiB
JavaScript
import { describe, it, expect, $, click, sleep, csrf } from './runner.js?v=4';
|
|
|
|
// Shopping-plugin tests. Everything ships through the shopping plugin
|
|
// (backend.php registers /api/shopping*, shopping.js injects the
|
|
// header button and the picker dialog on load). Without a configured
|
|
// relay the backend still returns {ok:false,error:"relay not
|
|
// configured"} so we can assert shape.
|
|
|
|
describe('Shopping: plugin registration', () => {
|
|
it('registers itself in window.hyprcrd.plugins', () => {
|
|
const entries = window.hyprcrd?.plugins || [];
|
|
const entry = entries.find(p => p?.name === 'shopping');
|
|
expect(entry).toExist();
|
|
});
|
|
});
|
|
|
|
describe('Shopping: API proxy', () => {
|
|
it('GET /api/shopping returns an ok field', async () => {
|
|
const r = await fetch('api/shopping').then(x => x.json());
|
|
expect(typeof r.ok).toBe('boolean');
|
|
});
|
|
|
|
it('POST /api/shopping/check returns a JSON object', async () => {
|
|
const r = await fetch('api/shopping/check', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
body: JSON.stringify({ line: '- [ ] nope', checked: true }),
|
|
}).then(x => x.json());
|
|
expect(r).toExist();
|
|
// The check endpoint is session-auth on the relay (phone-only),
|
|
// so hitting it via the client proxy returns an `error` field
|
|
// from the relay's unauthorized branch. Without a configured
|
|
// relay, it's `ok:false`. Either way it's a JSON object.
|
|
expect(typeof r.ok === 'boolean' || typeof r.error === 'string').toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Shopping: header button', () => {
|
|
it('Shopping button is injected into the header', () => {
|
|
const btn = $('#shopping-btn');
|
|
expect(btn).toExist();
|
|
// Lives inside the main header, not bolted onto the body.
|
|
expect(btn.closest('#main-header')).toExist();
|
|
});
|
|
|
|
it('clicking opens the picker dialog with save/close controls', async () => {
|
|
document.querySelector('.shopping-picker-overlay')?.remove();
|
|
click('#shopping-btn');
|
|
await sleep(60);
|
|
const overlay = document.querySelector('.shopping-picker-overlay');
|
|
expect(overlay).toExist();
|
|
expect(overlay.querySelector('.shopping-picker-save')).toExist();
|
|
expect(overlay.querySelector('.shopping-picker-cancel')).toExist();
|
|
overlay.querySelector('.shopping-picker-cancel').click();
|
|
await sleep(40);
|
|
expect(document.querySelector('.shopping-picker-overlay')).toBe(null);
|
|
});
|
|
});
|
|
|
|
describe('Shopping: picker tag matching', () => {
|
|
// Creates one card per trigger tag + one untagged control, then
|
|
// opens the picker and asserts the list. Both #einkauf and
|
|
// #shopping must qualify; untagged card must not.
|
|
it('lists both #einkauf and #shopping cards, excludes others', async () => {
|
|
const token = csrf();
|
|
const h = { 'Content-Type': 'application/json', 'X-CSRF-Token': token };
|
|
const hDel = { 'X-CSRF-Token': token };
|
|
let eink, shop, other;
|
|
try {
|
|
eink = await fetch('api/cards', { method: 'POST', headers: h, body: JSON.stringify({ title: '__test_shop_einkauf__', body: '- [ ] milk #einkauf' }) }).then(r => r.json());
|
|
shop = await fetch('api/cards', { method: 'POST', headers: h, body: JSON.stringify({ title: '__test_shop_shopping__', body: '- [ ] bread #shopping' }) }).then(r => r.json());
|
|
other = await fetch('api/cards', { method: 'POST', headers: h, body: JSON.stringify({ title: '__test_shop_control__', body: 'plain card' }) }).then(r => r.json());
|
|
await window._reloadAll();
|
|
|
|
// Close any leftover overlay, then open the picker fresh.
|
|
document.querySelector('.shopping-picker-overlay')?.remove();
|
|
click('#shopping-btn');
|
|
await sleep(80);
|
|
|
|
const overlay = document.querySelector('.shopping-picker-overlay');
|
|
expect(overlay).toExist();
|
|
const listText = overlay.querySelector('#shopping-picker-list').textContent;
|
|
expect(listText).toContain('__test_shop_einkauf__');
|
|
expect(listText).toContain('__test_shop_shopping__');
|
|
// Non-tagged control must be absent.
|
|
const controlShown = listText.includes('__test_shop_control__');
|
|
expect(controlShown).toBe(false);
|
|
|
|
// Picker uses checkboxes so multiple lists can be active.
|
|
const checkboxes = overlay.querySelectorAll('input[type="checkbox"]');
|
|
expect(checkboxes.length).toBeGreaterThan(0);
|
|
|
|
overlay.querySelector('.shopping-picker-cancel').click();
|
|
await sleep(40);
|
|
} finally {
|
|
for (const c of [eink, shop, other]) {
|
|
if (c?.id) await fetch(`api/cards/${c.id}`, { method: 'DELETE', headers: hDel });
|
|
}
|
|
await window._reloadAll();
|
|
}
|
|
});
|
|
});
|