Versie 0.2.0

This commit is contained in:
Atreides Assets committed 2026-10-29 11:57:29 +01:00
commit e77d8fd373
495 files changed
+81026

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
// Generated, do not edit. sha256:e9def29bc2006594fdeb8199e56fcff107612198b70d50be3a6d86402660dd84
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
for (const mod of ['providers', 'price-source']) {
mock.module(`../../client/data/${mod}.js`, { namedExports: await import(`../../server/data/${mod}.js`) });
}
const leeg = async () => [];
mock.module('../../client/data/notifications.js', { namedExports: { currentNotifications: leeg } });
mock.module('../../client/data/ai-audit.js', { namedExports: { countAiToolCallsSince: async () => 0 } });
mock.module('../../client/data/preferences.js', { namedExports: { getPref: async () => null } });
mock.module('../../client/data/price-history.js', { namedExports: { listPriceGaps: async () => ({ rows: [], total: 0 }) } });
mock.module('../../client/data/api-metrics.js', { namedExports: { apiCallsByProvider: leeg } });
mock.module('../../client/license.js', { namedExports: { licenseChangedSinceSeen: async () => false } });
mock.module('../../client/app-settings.js', { namedExports: { appSetting: () => null, baseCurrency: () => 'EUR' } });
mock.module('../../client/price/providers.js', { namedExports: { rateSourceFor: async () => null } });
const producers = [];
mock.module('../../client/ui/attention.js', { namedExports: { registerAttention: p => producers.push(p) } });
const { registerAttentionSources } = await import('../../client/ui/attention-sources.js');
registerAttentionSources();
const zonderKoers = producers.find(p => p.name === 'sourcesWithoutRate');
const { run, lastId } = harness.exports;
let bron;
beforeEach(() => {
harness.recreate();
run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
VALUES ('XAU','Goud','goud','metal','XAU','cent',2,'unit')`);
const goud = lastId();
run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired,
isActive, isSeed, isSystem, createdAt)
VALUES ('dollar','Dollarbron','dollarbron','https://voorbeeld.test','none',0,1,1,0,'2026-10-16T10:00:00.000Z')`);
bron = lastId();
run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'XAU')`, [bron]);
run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, quoteCurrency)
VALUES (?, 'spot', 'GET', '/p', 'USD')`, [bron]);
run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status) VALUES (?, 'spot', ?, 1, 'confirmed')`, [goud, bron]);
});
describe('aandacht: een bronkeuze in een munt zonder wisselkoers', () => {
it('meldt een actieve, gekozen bron als probleem', async () => {
const uit = await zonderKoers();
assert.deepEqual(uit.map(a => [a.id, a.severity]), [['fx:dollar:USD', 'problem']]);
});
it('meldt niets voor een bron die uit staat', async () => {
run(`UPDATE Provider SET isActive = 0 WHERE id = ?`, [bron]);
assert.deepEqual(await zonderKoers(), []);
});
it('meldt niets voor een gewiste keuze', async () => {
run(`UPDATE PriceSource SET rank = NULL`);
assert.deepEqual(await zonderKoers(), []);
});
});
+103
View File
@@ -0,0 +1,103 @@
// Generated, do not edit. sha256:0c8f95dec7394e0dad71a6a790d7768a2d482ac976a2a68218282ae506402032
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { activityItems, heartbeat } from '../../client/ui/activity.js';
const DAG = 86400000;
const dag = s => Date.UTC(Number(s.slice(0, 4)), Number(s.slice(5, 7)) - 1, Number(s.slice(8, 10)));
describe('activityItems', () => {
it('geeft een aankoop omhoog, een verkoop omlaag en rente omhoog, met de tabel van de rij', () => {
const items = activityItems({
accountIds: [1],
trades: [
{ id: 10, type: 'buy', toAccountId: 1, assetId: 5, occurredAt: '2026-03-01T12:00:00Z' },
{ id: 11, type: 'sell', fromAccountId: 1, assetId: 5, occurredAt: '2026-03-02T12:00:00Z' },
],
interest: [{ id: 12, toAccountId: 1, assetId: 5, occurredAt: '2026-03-03T12:00:00Z' }],
});
assert.deepEqual(items.map(i => [i.kind, i.dir, i.table, i.id]),
[['buy', 1, 'trades', 10], ['sell', -1, 'trades', 11], ['interest', 1, 'interest', 12]]);
});
it('telt een verplaatsing aan beide kanten, en alleen voor de gevraagde accounts', () => {
const transfers = [{ id: 20, fromAccountId: 1, toAccountId: 2, assetId: 5, occurredAt: '2026-03-01T12:00:00Z' }];
assert.deepEqual(activityItems({ accountIds: [1], transfers }).map(i => [i.kind, i.dir]), [['transferOut', -1]]);
assert.deepEqual(activityItems({ accountIds: [2], transfers }).map(i => [i.kind, i.dir]), [['transferIn', 1]]);
assert.equal(activityItems({ accountIds: [1, 2], transfers }).length, 2);
assert.equal(activityItems({ accountIds: [3], transfers }).length, 0, 'een ander account ziet niets');
});
it('maakt van een balans, een afspraak en het account een streepje zonder piek', () => {
const items = activityItems({
accountIds: [1],
valuations: [{ id: 30, accountId: 1, assetId: 5, rateAt: '2026-03-01T12:00:00Z' }],
schedules: [{ accountId: 1, startsOn: '2026-01-01', endsOn: '2026-06-30' }],
accounts: [{ id: 1, createdAt: '2025-12-01T12:00:00Z', updatedAt: '2026-02-01T12:00:00Z', closedAt: null }],
});
assert.ok(items.every(i => i.dir === 0));
assert.deepEqual(items.map(i => i.kind),
['balance', 'agreementStart', 'agreementEnd', 'accountOpened', 'accountChanged']);
assert.deepEqual(items.find(i => i.kind === 'balance'), { x: dag('2026-03-01'), accountId: 1, kind: 'balance',
dir: 0, table: 'valuations', id: 30, assetId: 5 }, 'een balans is wel aan te klikken');
assert.equal(items.find(i => i.kind === 'agreementStart').table, null, 'een afspraak heeft geen rij');
});
it('maakt van een reservering en een komende rentebijschrijving een streepje, op het juiste account', () => {
const items = activityItems({
accountIds: [1],
reservations: [
{ id: 40, fromAccountId: 1, toAccountId: null, assetId: 5, occurredAt: '2026-12-01T12:00:00Z' },
{ id: 41, fromAccountId: null, toAccountId: 1, assetId: 5, occurredAt: '2026-12-15T12:00:00Z' },
{ id: 42, fromAccountId: 2, toAccountId: null, assetId: 5, occurredAt: '2026-12-20T12:00:00Z' },
],
interestDue: [{ accountId: 1, periodEnd: '2027-01-01' }, { accountId: 2, periodEnd: '2027-01-01' }],
});
assert.deepEqual(items.map(i => [i.kind, i.dir, i.table, i.id]),
[['reservation', 0, 'reservations', 40], ['reservation', 0, 'reservations', 41], ['interestDue', 0, null, null]]);
assert.equal(items.at(-1).x, dag('2027-01-01'));
});
});
describe('heartbeat', () => {
const item = (s, dir) => ({ x: dag(s), dir });
it('loopt per dag van de dag vóór het eerste item tot vandaag, op nul waar niets gebeurde', () => {
const { points } = heartbeat([item('2026-03-02', 1)], '2026-03-05T12:00:00Z');
assert.deepEqual(points.map(p => p[0]), [1, 2, 3, 4, 5].map(d => dag(`2026-03-0${d}`)));
assert.deepEqual(points.map(p => p[1]), [0, 1, 0, 0, 0]);
});
it('wijst omhoog bij meer erin, omlaag bij meer eruit, en omhoog bij evenveel', () => {
const { points, byDay } = heartbeat([
item('2026-03-02', 1), item('2026-03-02', 1), item('2026-03-02', 1), item('2026-03-02', 1),
item('2026-03-03', -1),
item('2026-03-04', 1), item('2026-03-04', -1),
item('2026-03-05', 0),
], '2026-03-05T12:00:00Z');
const op = s => points.find(p => p[0] === dag(s))[1];
assert.equal(op('2026-03-02'), 2, 'vier erin: de wortel, zodat één drukke dag de rest niet plat drukt');
assert.equal(op('2026-03-03'), -1);
assert.equal(op('2026-03-04'), 1);
assert.equal(op('2026-03-05'), 0, 'een streepje is geen piek');
assert.equal(byDay.get(dag('2026-03-02')).length, 4);
});
it('begint op het startjaar en laat weg wat daarvóór lag', () => {
const { points, byDay } = heartbeat([item('1994-06-01', 1), item('2026-03-02', 1)],
'2026-03-03T12:00:00Z', '2026-03-01T12:00:00Z');
assert.equal(points[0][0], dag('2026-03-01'), 'de lijn begint op de startdag, niet op de dag ervoor');
assert.equal(points.length, 3);
assert.equal(byDay.has(dag('1994-06-01')), false, 'de oude boeking is er niet');
assert.equal(heartbeat([item('1994-06-01', 1)], '2026-03-03T12:00:00Z', '2026-03-01T12:00:00Z'), null,
'niets ná de start: geen lijn');
});
it('geeft niets zonder items, en twee punten als alles vandaag gebeurde', () => {
assert.equal(heartbeat([], '2026-03-05T12:00:00Z'), null);
const { points } = heartbeat([item('2026-03-05', -1)], '2026-03-05T12:00:00Z');
assert.equal(points.length, 2);
assert.equal(points[1][0] - points[0][0], DAG);
});
});
+439
View File
@@ -0,0 +1,439 @@
// Generated, do not edit. sha256:45c8de8a885eb07b9f327fbd56b57d092c058152ae89dc307ade593f6035be32
import { describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
mock.module('../../client/app-settings.js', {
namedExports: {
appSetting: () => 1,
baseCurrency: () => 'EUR',
timerSetting: () => 60,
loadAppSettings: async () => ({}),
},
});
let VOORSTELLEN = [];
let ACTIEF = true;
const GEDAAN = [];
let UITKOMST = [];
mock.module('../../client/data/ai-proposals.js', {
namedExports: {
aiProposalsStatus: async () => ({ enabled: ACTIEF, pending: VOORSTELLEN }),
listPendingAiProposals: async () => VOORSTELLEN,
acceptAiProposal: async (id) => { GEDAAN.push(['accept', id]); },
acceptAiProposals: async (ids) => { GEDAAN.push(['acceptAll', ids]); return UITKOMST; },
rejectAiProposal: async (id) => { GEDAAN.push(['reject', id]); },
rejectAiProposals: async (ids) => { GEDAAN.push(['rejectAll', ids]); return UITKOMST; },
},
});
let ANTWOORD = true;
const GEVRAAGD = [];
mock.module('../../client/ui/dialog.js', {
namedExports: {
confirm: async (o) => { GEVRAAGD.push(o); return ANTWOORD; },
alert: async (o) => { GEVRAAGD.push(o); },
form: async () => null,
},
});
const LUISTERAARS = [];
globalThis.document = {
addEventListener(ev, fn) { LUISTERAARS.push([ev, fn]); },
};
const luisteraar = ev => (LUISTERAARS.find(([naam]) => naam === ev) || [])[1];
const { renderAiProposals } = await import('../../client/actions/ai-proposals-widget.js');
const { CONFIG } = await import('../../shared/config.js');
function nepHost() {
const perSelector = new Map();
return {
innerHTML: '',
isConnected: true,
querySelector(sel) {
if (!perSelector.has(sel)) perSelector.set(sel, {});
return perSelector.get(sel);
},
querySelectorAll() { return []; },
knop(sel) { return perSelector.get(sel); },
};
}
async function teken(rijen, actief = true) {
return (await tekenOp(rijen, actief)).innerHTML;
}
async function tekenOp(rijen, actief = true) {
VOORSTELLEN = rijen;
ACTIEF = actief;
GEDAAN.length = 0;
GEVRAAGD.length = 0;
ANTWOORD = true;
UITKOMST = [];
const host = nepHost();
await renderAiProposals(host, () => {});
return host;
}
const BTC = {
asset_name: 'Bitcoin', asset_symbol: 'BTC', asset_kind: 'crypto',
asset_decimals: 8, asset_unit: 'BTC', asset_subunit: 'sat', asset_display_unit: 'unit',
};
const LEEG_ASSET = {
asset_name: null, asset_symbol: null, asset_kind: null,
asset_decimals: null, asset_unit: null, asset_subunit: null, asset_display_unit: null,
};
const BASIS = {
id: 1, note: null, detail: null, from_name: null, to_name: null,
accountId: null, account_name: null, account_kind: null,
name: null, accountKind: null, isOwnCustody: null, eventType: null, amount: null,
fiatPrice: null, observedAt: null, fromAccountId: null, toAccountId: null, assetId: null,
};
const koerspunt = (extra = {}) => ({ ...BASIS, ...BTC, kind: 'price_point', fiatPrice: 50000, observedAt: '2026-03-01T00:00:00Z', ...extra });
const rekening = (extra = {}) => ({ ...BASIS, ...LEEG_ASSET, kind: 'account', name: 'Kraken', accountKind: 'exchange', isOwnCustody: 0, ...extra });
const boeking = (extra = {}) => ({ ...BASIS, ...BTC, kind: 'event', eventType: 'buy', amount: 25_000_000, fiatPrice: 50000, observedAt: '2026-03-01T00:00:00Z', to_name: 'Kraken', ...extra });
const verwijdering = (extra = {}) => ({ ...BASIS, ...LEEG_ASSET, kind: 'account_delete', account_name: 'Oude beurs', account_kind: 'exchange', ...extra });
const balans = (extra = {}) => ({
...BASIS, ...BTC, kind: 'valuation', balance: 50_000_000, observedAt: '2026-03-01T00:00:00Z',
account_name: 'Kraken', ledger_balance: 40_000_000, events_before: 1, ...extra,
});
const portefeuille = (extra = {}) => ({ ...BASIS, ...LEEG_ASSET, kind: 'portfolio', name: 'AI-demonstratie', currency: 'EUR', ...extra });
describe('de lege stand, en dat zijn er TWEE', () => {
it('tekent een kader met een vinkje als de koppeling aan staat en er niets openstaat', async () => {
const html = await teken([], true);
assert.match(html, /task-card-empty/, 'hetzelfde lege kader als de drie widgets ernaast');
assert.match(html, /Voorstellen van de AI/, 'met de kop erboven, anders is het een leeg vlak');
});
it('tekent helemaal niets als de koppeling uit staat', async () => {
assert.equal(await teken([], false), '');
});
it('tekent ook niets als de koppeling uit staat terwijl er iets openstaat', async () => {
assert.equal(await teken([koerspunt()], false), '');
});
});
describe('de drie soorten in mensentaal', () => {
it('toont een koerspunt met de asset-naam en het bedrag in euro', async () => {
const html = await teken([koerspunt()]);
assert.match(html, /Bitcoin/, 'de asset-naam, niet alleen het symbool');
assert.match(html, /BTC/);
assert.match(html, /50\.000,00/, 'het bedrag hoort in euro-opmaak te staan');
});
it('houdt de asset-naam ook heel bij een boeking', async () => {
const html = await teken([boeking()]);
assert.match(html, /Bitcoin/, 'de asset-naam, niet alleen het symbool');
});
it('toont een rekening-voorstel met de naam, de soort en de custody-stand', async () => {
const html = await teken([rekening()]);
assert.match(html, /Kraken/);
assert.match(html, /Exchange/, 'de soort komt uit de enum-vertaling');
assert.match(html, /derde partij/);
});
it('toont bij een boeking de hoeveelheid, de asset en de rekening', async () => {
const html = await teken([boeking()]);
assert.match(html, /Aankoop/, 'de soort komt uit de enum-vertaling');
assert.match(html, /0,25 BTC/, 'de hoeveelheid hoort in de hoofdeenheid te staan, niet in sats');
assert.match(html, /Kraken/);
});
it('zet de eenheid ÉÉN keer neer en niet twee', async () => {
const labelSymbool = /class="t-muted-cell">BTC</;
const boekingHtml = await teken([boeking()]);
assert.match(boekingHtml, /0,25 BTC/);
assert.match(boekingHtml, /asset-name">Bitcoin</, 'de naam blijft staan, want die zegt wél iets nieuws');
assert.ok(!labelSymbool.test(boekingHtml), boekingHtml);
const balansHtml = await teken([balans()]);
assert.match(balansHtml, /0,5 BTC/);
assert.match(balansHtml, /grootboek/);
assert.match(balansHtml, /asset-name">Bitcoin</);
assert.ok(!labelSymbool.test(balansHtml), balansHtml);
});
it('houdt het symbool bij een asset dat in SATS wordt getoond', async () => {
const satBtc = { ...BTC, asset_display_unit: 'subunit' };
const html = await teken([balans({ ...satBtc, balance: 25_000_000, ledger_balance: 25_000_000 })]);
assert.match(html, /25\.000\.000 sat/, html);
assert.match(html, /class="t-muted-cell">BTC</, 'het symbool hoort hier juist wél in het label');
});
it('houdt bij METAAL het symbool wél, want daar zegt de eenheid niet wélk metaal', async () => {
const goud = {
asset_name: 'Proefgoud', asset_symbol: 'XAU', asset_kind: 'metal',
asset_decimals: 4, asset_unit: 'g', asset_subunit: null, asset_display_unit: 'unit',
};
const html = await teken([balans({ ...goud, balance: 3_000_000, ledger_balance: 3_000_000 })]);
assert.match(html, /XAU/, html);
assert.match(html, /Proefgoud/);
});
it('rekent een metaalkoers terug naar de eenheid waarin hij is voorgesteld', async () => {
const GOUD = {
asset_name: 'Goud', asset_symbol: 'XAU', asset_kind: 'metal',
asset_decimals: 7, asset_unit: 'ozt', asset_subunit: 'g', asset_display_unit: 'unit',
};
const perGram = 2150 / CONFIG.METALS.UNITS.ozt;
const html = await teken([koerspunt({ ...GOUD, fiatPrice: perGram })]);
assert.match(html, /2\.150,00/, 'de koers hoort per ozt te staan, niet per gram');
assert.match(html, /per ozt/, 'zonder eenheid is een metaalkoers niet te beoordelen');
assert.ok(!/69,12/.test(html), 'de opslagwaarde per gram hoort hier niet te staan');
});
it('laat een crypto-koers ongemoeid en noemt de eenheid erbij', async () => {
const html = await teken([koerspunt()]);
assert.match(html, /50\.000,00/);
assert.match(html, /per BTC/, 'ook hier hoort de eenheid erbij, en dat is de HOOFDeenheid');
});
it('noemt bij een asset dat in sats getoond wordt nog steeds de koers per BTC', async () => {
const html = await teken([koerspunt({ asset_display_unit: 'subunit' })]);
assert.match(html, /per BTC/);
});
it('zet bij een verplaatsing allebei de rekeningen met een pijl ertussen', async () => {
const html = await teken([boeking({ eventType: 'transfer', fiatPrice: null, from_name: 'Kraken', to_name: 'Ledger' })]);
assert.match(html, /Kraken → Ledger/);
assert.match(html, /Verplaatsing/);
});
it('noemt een onbekende soort in plaats van een lege regel te tonen', async () => {
const html = await teken([{ ...BASIS, ...LEEG_ASSET, kind: 'iets_nieuws' }]);
assert.match(html, /Onbekend soort voorstel/);
assert.match(html, /iets_nieuws/);
});
});
describe('een balans en een portefeuille op de kaart', () => {
it('zet het grootboek en het verschil naast de voorgestelde stand', async () => {
const html = await teken([balans()]);
assert.match(html, /Balans/);
assert.match(html, /0,5 BTC/, 'de voorgestelde stand');
assert.match(html, /grootboek.*0,4 BTC/s, 'en wat het grootboek op dat moment zegt');
assert.match(html, /verschil \+<span[^>]*>0,1 BTC/);
assert.match(html, /Kraken/);
});
it('laat de verschil-regel WEG als er geen verschil is', async () => {
const html = await teken([balans({ ledger_balance: 50_000_000 })]);
assert.match(html, /grootboek/);
assert.ok(!/verschil/.test(html), html);
});
it('WAARSCHUWT als er geen boeking vóór de peildatum staat', async () => {
const html = await teken([balans({ events_before: 0, ledger_balance: 0 })]);
assert.match(html, /OPENINGSBALANS/i);
assert.match(html, /t-neg/, 'en die regel hoort op te vallen, net als een mislukte poging');
});
it('waarschuwt NIET als er wel een boeking vóór de peildatum staat', async () => {
const html = await teken([balans({ events_before: 2 })]);
assert.ok(!/OPENINGSBALANS/i.test(html), html);
});
it('toont een portefeuille-voorstel met de naam en wat het wél en niet doet', async () => {
const html = await teken([portefeuille()]);
assert.match(html, /Nieuwe portefeuille/);
assert.match(html, /AI-demonstratie/);
assert.match(html, /Leeg/);
assert.match(html, /wisselen|niet geopend/i, 'dat hij niet geopend wordt');
});
it('noemt de rekenvaluta, want die ligt na goedkeuring vast', async () => {
const html = await teken([portefeuille({ currency: 'USD' })]);
assert.match(html, /Amerikaanse dollar \(USD\)/);
});
it('escapet de naam van een portefeuille', async () => {
const html = await teken([portefeuille({ name: '<img src=x onerror=alert(1)>' })]);
assert.ok(!html.includes('<img'), html);
assert.match(html, /&lt;img/);
});
});
describe('een instelling', () => {
const voorkeur = (extra = {}) => ({
...BASIS, ...LEEG_ASSET, kind: 'preference', scope: 'app', key: 'currency', value: '"usd"', ...extra,
});
it('toont de sleutel, de nieuwe waarde EN de huidige', async () => {
const html = await teken([voorkeur()]);
assert.match(html, /Instelling wijzigen/);
assert.match(html, /app\.currency/);
assert.match(html, /usd/);
assert.match(html, /\(nu /, 'de huidige waarde hoort ernaast te staan');
});
it('leest een schakelaar als aan of uit en niet als 1 of 0', async () => {
const html = await teken([voorkeur({ key: 'blur', value: '1' })]);
assert.match(html, /aan/);
assert.ok(!/→ 1/.test(html), html);
});
it('valt terug op de kale tekst als de waarde geen JSON is', async () => {
const html = await teken([voorkeur({ value: 'kapot' })]);
assert.match(html, /kapot/);
});
it('escapet de sleutel en de waarde', async () => {
const html = await teken([voorkeur({ key: '<img src=x>', value: '"<b>vet</b>"' })]);
assert.ok(!html.includes('<img'), html);
assert.ok(!html.includes('<b>vet'), html);
});
});
describe('de toelichting en een mislukte poging', () => {
it('toont de toelichting van de AI onder de regel', async () => {
const html = await teken([koerspunt({ note: 'koers volgens de muntwinkel' })]);
assert.match(html, /koers volgens de muntwinkel/);
});
it('toont de reden waarom een eerdere poging afketste', async () => {
const html = await teken([koerspunt({ detail: 'Onbekende koersbron: manual.' })]);
assert.match(html, /Vorige poging mislukt/);
assert.match(html, /Onbekende koersbron/);
});
});
describe('escapen', () => {
it('escapet de voorgestelde rekeningnaam', async () => {
const html = await teken([rekening({ name: '<img src=x onerror=alert(1)>' })]);
assert.ok(!html.includes('<img'), `de naam is niet geëscaped: ${html}`);
assert.match(html, /&lt;img/);
});
it('escapet de toelichting en de mislukkingsreden', async () => {
const html = await teken([koerspunt({ note: '<script>1</script>', detail: '<b>stuk</b>' })]);
assert.ok(!html.includes('<script>'), 'de toelichting is niet geëscaped');
assert.ok(!html.includes('<b>stuk</b>'), 'de reden is niet geëscaped');
});
it('escapet de rekeningnamen van een verplaatsing', async () => {
const html = await teken([boeking({ eventType: 'transfer', fiatPrice: null, from_name: '<b>A</b>', to_name: 'B' })]);
assert.ok(!html.includes('<b>A</b>'), 'een rekeningnaam is niet geëscaped');
});
});
describe('een onomkeerbaar voorstel krijgt een zwaardere vorm (§7)', () => {
it('toont de rekening die weg zou gaan, met naam en soort', async () => {
const html = await teken([verwijdering()]);
assert.match(html, /Account verwijderen/);
assert.match(html, /Oude beurs/);
assert.match(html, /Exchange/);
});
it('markeert de regel en geeft hem een rode knop', async () => {
const html = await teken([verwijdering()]);
assert.match(html, /data-aidanger="1"/);
assert.match(html, /btn-danger/);
assert.match(html, /Onomkeerbaar/);
});
it('laat een gewoon voorstel juist NIET als gevaarlijk markeren', async () => {
const html = await teken([koerspunt()]);
assert.match(html, /data-aidanger="0"/);
assert.ok(!html.includes('btn-danger'), 'een koerspunt hoort geen rode knop te krijgen');
});
it('zegt bovenaan dat "Alles goedkeuren" hem overslaat', async () => {
const html = await teken([koerspunt(), verwijdering({ id: 2 }), verwijdering({ id: 3 })]);
assert.match(html, /2 voorstel\(len\) verwijderen iets/);
});
it('zwijgt over overslaan als er niets te verwijderen valt', async () => {
const html = await teken([koerspunt()]);
assert.ok(!html.includes('worden overgeslagen'), 'geen waarschuwing zonder aanleiding');
});
it('SLAAT DE VERWIJDERING ECHT OVER bij "Alles goedkeuren"', async () => {
const host = await tekenOp([koerspunt({ id: 1 }), verwijdering({ id: 2 }), boeking({ id: 3 })]);
await host.knop('#aiprop-accept-all').onclick();
assert.deepEqual(GEDAAN, [['acceptAll', [1, 3]]], 'voorstel 2 verwijdert iets en hoort er niet bij');
});
it('haalt de reden van een mislukt stuk door t(), en niet de Engelse terugval', async () => {
const host = await tekenOp([koerspunt({ id: 1 }), boeking({ id: 3 })]);
UITKOMST = [
{ id: 1, ok: true, reden: null },
{ id: 3, ok: false, reden: 'Proposal 3 no longer exists.', key: 'aiProposals.unknownProposal', params: { id: 3 } },
];
await host.knop('#aiprop-accept-all').onclick();
const melding = GEVRAAGD.at(-1);
assert.match(melding.message, /Voorstel 3 bestaat niet meer/);
assert.ok(!melding.message.includes('no longer exists'), 'de Engelse terugval hoort niet op het scherm');
});
it('valt op de terugval terug als de sleutel niet bestaat', async () => {
const host = await tekenOp([koerspunt({ id: 1 })]);
UITKOMST = [{ id: 1, ok: false, reden: 'Something broke.', key: 'aiProposals.bestaatNiet', params: null }];
await host.knop('#aiprop-accept-all').onclick();
assert.match(GEVRAAGD.at(-1).message, /Something broke\./);
});
it('wijst met "Alles afwijzen" WÉL alles af, verwijderingen incluis', async () => {
const host = await tekenOp([koerspunt({ id: 1 }), verwijdering({ id: 2 })]);
await host.knop('#aiprop-reject-all').onclick();
assert.deepEqual(GEDAAN[0][0], 'rejectAll');
assert.deepEqual(GEDAAN[0][1], [1, 2]);
});
it('doet niets en zegt dat, als er alleen verwijderingen openstaan', async () => {
const host = await tekenOp([verwijdering({ id: 2 })]);
await host.knop('#aiprop-accept-all').onclick();
assert.deepEqual(GEDAAN, [], 'er hoort niets goedgekeurd te zijn');
assert.equal(GEVRAAGD.length, 1, 'er hoort wél een melding te komen');
});
});
describe('de knoppen voor een reeks', () => {
it('staan er zodra er iets openstaat', async () => {
const html = await teken([koerspunt()]);
assert.match(html, /Alles goedkeuren/);
assert.match(html, /Alles afwijzen/);
assert.match(html, /Goedkeuren</);
assert.match(html, /Afwijzen</);
});
it('zegt uitdrukkelijk dat er nog niets gewijzigd is', async () => {
const html = await teken([koerspunt()]);
assert.match(html, /nog niets gewijzigd/);
});
});
describe('de kaart werkt zichzelf bij als de AI iets neerlegt (27-08-2026)', () => {
it('tekent opnieuw op het signaal, met wat er dán openstaat', async () => {
const host = await tekenOp([koerspunt()]);
assert.ok(!/02-03-2026/.test(host.innerHTML), 'het tweede voorstel bestaat nog niet');
VOORSTELLEN = [koerspunt(), koerspunt({ id: 2, observedAt: '2026-03-02T00:00:00Z' })];
await luisteraar('ai-proposals-changed')();
assert.match(host.innerHTML, /02-03-2026/, 'het nieuwe voorstel hoort er zonder verversing te staan');
});
it('doet niets als er weg genavigeerd is', async () => {
const host = await tekenOp([koerspunt()]);
host.isConnected = false;
const voor = host.innerHTML;
VOORSTELLEN = [koerspunt(), koerspunt({ id: 2, observedAt: '2026-03-02T00:00:00Z' })];
await luisteraar('ai-proposals-changed')();
assert.equal(host.innerHTML, voor, 'de host hoort onaangeroerd te blijven');
});
it('bindt hoogstens één luisteraar, hoe vaak de pagina ook hertekent', async () => {
await tekenOp([koerspunt()]);
await tekenOp([koerspunt()]);
const aantal = LUISTERAARS.filter(([naam]) => naam === 'ai-proposals-changed').length;
assert.equal(aantal, 1);
});
});
+65
View File
@@ -0,0 +1,65 @@
// Generated, do not edit. sha256:c59345089e27fb5a471d676d1783b760f5131e156f381aef6e04e76979a27c21
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
const { alertHtml } = await import('../../client/ui/alert.js');
describe('alertHtml: the four kinds', () => {
it('renders kind, icon, title and body in the shape the stylesheet expects', () => {
const html = alertHtml({ kind: 'error', title: 'Mislukt', body: 'Bestand onleesbaar.' });
assert.match(html, /^<div class="alert alert-error"><span class="alert-icon">⚠️<\/span>/);
assert.match(html, /<div class="alert-content"><div class="alert-title">Mislukt<\/div>/);
assert.match(html, /<div class="alert-body">Bestand onleesbaar\.<\/div><\/div><\/div>$/);
});
it('gives each kind its own icon, and neutral none', () => {
assert.match(alertHtml({ kind: 'info', body: 'x' }), /alert-icon">ℹ️</);
assert.match(alertHtml({ kind: 'success', body: 'x' }), /alert-icon">✓</);
assert.match(alertHtml({ kind: 'error', body: 'x' }), /alert-icon">⚠️</);
assert.doesNotMatch(alertHtml({ kind: 'neutral', body: 'x' }), /alert-icon/);
});
it('lets a caller override the icon, and suppress it with null', () => {
assert.match(alertHtml({ kind: 'info', icon: '✨', body: 'x' }), /alert-icon">✨</);
assert.doesNotMatch(alertHtml({ kind: 'error', icon: null, body: 'x' }), /alert-icon/);
});
it('REFUSES a kind that does not exist', () => {
assert.throws(() => alertHtml({ kind: 'warning', body: 'x' }), /Unknown alert kind/);
});
});
describe('alertHtml: what is left out leaves nothing behind', () => {
it('writes no title element without a title', () => {
assert.doesNotMatch(alertHtml({ kind: 'error', body: 'x' }), /alert-title/);
});
it('writes no body element without a body', () => {
assert.doesNotMatch(alertHtml({ kind: 'error', title: 'x' }), /alert-body/);
});
it('still renders the content wrapper, because the flex layout needs it', () => {
assert.match(alertHtml({ kind: 'neutral', body: 'x' }), /<div class="alert-content">/);
});
it('adds an extra class only when asked', () => {
assert.match(alertHtml({ kind: 'info', className: 'mt-lg', body: 'x' }), /"alert alert-info mt-lg"/);
assert.match(alertHtml({ kind: 'info', body: 'x' }), /"alert alert-info"/);
});
});
describe('alertHtml: title and body are text, extra is markup', () => {
it('escapes the title and the body', () => {
const html = alertHtml({ kind: 'error', title: '<b>t</b>', body: '<img src=x onerror=alert(1)>' });
assert.doesNotMatch(html, /<b>|<img/);
});
it('escapes a database message put in the body', () => {
assert.doesNotMatch(alertHtml({ kind: 'error', body: 'no such column: <script>' }), /<script>/);
});
it('does NOT escape extra', () => {
assert.match(alertHtml({ kind: 'info', body: 'x', extra: '<button id="go">Go</button>' }),
/<button id="go">Go<\/button>/);
});
});
+28
View File
@@ -0,0 +1,28 @@
// Generated, do not edit. sha256:2279e64001daa1e87e0c647c05e7e5f220c0c5891b9fbe759b3bce4ec335aca0
import { describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
mock.module('../../client/data/preferences.js', { namedExports: {
getSettings: async () => ({ theme: 'dark', version: 104, baseCurrency: 'USD' }),
getPref: async () => 30,
setPref: async () => {},
} });
const { baseCurrency, loadAppSettings } = await import('../../client/app-settings.js');
const { isBaseAsset } = await import('../../client/ui/format.js');
describe('baseCurrency', () => {
it('faalt hard zolang er niets geladen is', () => {
assert.throws(() => baseCurrency(), /not loaded yet/);
});
it('levert na het laden de munt uit getSettings', async () => {
await loadAppSettings();
assert.equal(baseCurrency(), 'USD');
});
it('bepaalt wat isBaseAsset het reken-asset noemt', () => {
assert.equal(isBaseAsset({ symbol: 'USD' }), true);
assert.equal(isBaseAsset({ symbol: 'EUR' }), false);
});
});
+36
View File
@@ -0,0 +1,36 @@
// Generated, do not edit. sha256:5a77c077506d2707d2736f39b88eecc950595e5f584434a26aa51da671343df6
import { describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
const INSTELLING = { asset_show_name: 1, asset_show_symbol: 0 };
mock.module('../../client/app-settings.js', { namedExports: { appSetting: k => INSTELLING[k], baseCurrency: () => 'EUR' } });
const { byKindAndLabel } = await import('../../client/ui/asset-label.js');
const a = (symbol, name, kind) => ({ symbol, name, kind });
const XRP = a('XRP', 'Ripple', 'crypto');
const BTC = a('BTC', 'Bitcoin', 'crypto');
const ETH = a('ETH', 'Ether', 'crypto');
const XAU = a('XAU', 'Goud', 'metal');
const USD = a('USD', 'Amerikaanse dollar', 'fiat');
describe('byKindAndLabel', () => {
it('zet alle crypto bij elkaar vóór de metalen, ook een asset dat later is aangemaakt', () => {
const volgorde = [XAU, BTC, XRP, USD, ETH].sort(byKindAndLabel).map(x => x.symbol);
assert.deepEqual(volgorde, ['BTC', 'ETH', 'XRP', 'XAU', 'USD']);
});
it('sorteert op het label dat je ziet: de naam, of het symbool als dat is ingesteld', () => {
INSTELLING.asset_show_name = 1; INSTELLING.asset_show_symbol = 0;
assert.deepEqual([XRP, ETH].sort(byKindAndLabel).map(x => x.symbol), ['ETH', 'XRP'], 'Ether vóór Ripple');
const zelfdeNaam = [a('ZZZ', 'Alfa', 'crypto'), a('AAA', 'Zulu', 'crypto')];
INSTELLING.asset_show_name = 0; INSTELLING.asset_show_symbol = 1;
assert.deepEqual([...zelfdeNaam].sort(byKindAndLabel).map(x => x.symbol), ['AAA', 'ZZZ'], 'op symbool');
INSTELLING.asset_show_name = 1;
assert.deepEqual([...zelfdeNaam].sort(byKindAndLabel).map(x => x.symbol), ['ZZZ', 'AAA'], 'op naam');
INSTELLING.asset_show_symbol = 0;
});
it('zet een onbekende soort achteraan', () => {
assert.deepEqual([a('Q', 'Q', 'onbekend'), BTC].sort(byKindAndLabel).map(x => x.symbol), ['BTC', 'Q']);
});
});
+66
View File
@@ -0,0 +1,66 @@
// Generated, do not edit. sha256:3aaa6e524933ad9658661ecb7b45494d44629620bf29811a6f9255c236d13e47
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
const { attentionDotHtml, attentionHasProblem, attentionDimmed } =
await import('../../client/ui/attention.js');
const item = (extra = {}) => ({ id: 'x', severity: 'attention', label: 'iets', fade: 'full', ...extra });
describe('de kleur van het bolletje', () => {
it('is accent bij een indicatie', () => {
const html = attentionDotHtml([item()]);
assert.match(html, /class="nav-attention"/, 'geen extra klasse betekent de basis, en die is accent');
});
it('is rood zodra er iets stuk is', () => {
assert.match(attentionDotHtml([item({ severity: 'problem' })]), /is-problem/);
});
it('laat één probleem de hele dot kleuren, ook naast indicaties', () => {
const html = attentionDotHtml([item(), item({ id: 'y', severity: 'problem' }), item({ id: 'z' })]);
assert.match(html, /is-problem/);
});
it('behandelt een ontbrekende ernst als indicatie en niet als probleem', () => {
assert.doesNotMatch(attentionDotHtml([{ id: 'x', label: 'iets', fade: 'full' }]), /is-problem/);
});
});
describe('de vervaging', () => {
it('dimt pas als er niets meer vol is', () => {
assert.match(attentionDotHtml([item({ fade: 'dim' })]), /is-dim/);
assert.doesNotMatch(attentionDotHtml([item({ fade: 'dim' }), item({ id: 'y' })]), /is-dim/,
'één vol item houdt de dot vol');
});
});
describe('de gedeelde regel, los van het element', () => {
it('noemt een leeg of ontbrekend stel niet vervaagd en niet stuk', () => {
for (const leeg of [[], null, undefined]) {
assert.equal(attentionHasProblem(leeg), false);
assert.equal(attentionDimmed(leeg), false, 'niets is niet hetzelfde als vervaagd');
}
});
it('kleurt op de zwaarste en dimt op de zwakste', () => {
const vol = item();
const vaag = item({ id: 'y', fade: 'dim' });
const stuk = item({ id: 'z', severity: 'problem' });
assert.equal(attentionHasProblem([vol, stuk]), true, 'één probleem is genoeg');
assert.equal(attentionDimmed([vol, vaag]), false, 'één vol item houdt het geheel vol');
assert.equal(attentionDimmed([vaag]), true);
});
});
describe('de rand van het geval', () => {
it('tekent niets zonder items', () => {
assert.equal(attentionDotHtml([]), '');
assert.equal(attentionDotHtml(null), '');
});
it('zet de labels als hint, en escapet ze', () => {
const html = attentionDotHtml([item({ label: 'een "raar" label' })]);
assert.match(html, /title="een &quot;raar&quot; label"/);
});
});
+65
View File
@@ -0,0 +1,65 @@
// Generated, do not edit. sha256:24b0358e2ad3c4daacdef6e28f3515514103cb02f09c0d3fa792252b376261f0
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
const prefs = new Map();
mock.module('../../client/data/preferences.js', {
namedExports: {
getPref: async (scope, key, fallback) => (prefs.has(`${scope}.${key}`) ? prefs.get(`${scope}.${key}`) : fallback),
setPref: async (scope, key, value) => { prefs.set(`${scope}.${key}`, value); },
},
});
globalThis.location = { hash: '#/home' };
const { registerAttention, refreshAttention, attentionForRoute } = await import('../../client/ui/attention.js');
let items = [];
registerAttention(() => items);
const limiet = severity => ({ id: 'usage:goldapi', severity, route: 'system', tab: 'integrations/providers', label: 'GoldAPI' });
const fade = () => attentionForRoute('system').map(a => a.fade);
const ga = async hash => { location.hash = hash; await refreshAttention(); };
beforeEach(async () => {
items = [];
await ga('#/home');
});
describe('een gezien bolletje dimt', () => {
it('is vol zolang je er niet geweest bent, en dimt zodra je op de plek staat', async () => {
items = [limiet('problem')];
await ga('#/home');
assert.deepEqual(fade(), ['full'], 'buiten de pagina ontstaan: vol');
await ga('#/system/integrations/providers');
assert.deepEqual(fade(), ['dim'], 'gezien');
await ga('#/home');
assert.deepEqual(fade(), ['dim'], 'en dat blijft zo als je weggaat');
});
it('een andere ernst is een nieuwe toestand en begint weer vol', async () => {
items = [limiet('attention')];
await ga('#/home');
await ga('#/system/integrations/providers');
assert.deepEqual(fade(), ['dim']);
items = [limiet('problem')];
await ga('#/home');
assert.deepEqual(fade(), ['full']);
});
it('een oorzaak die verdwijnt en terugkomt, begint opnieuw', async () => {
items = [limiet('problem')];
await ga('#/home');
await ga('#/system/integrations/providers');
items = [];
await ga('#/home');
items = [limiet('problem')];
await ga('#/home');
assert.deepEqual(fade(), ['full']);
});
it('een ander tabblad van dezelfde pagina telt niet als gezien', async () => {
items = [limiet('problem')];
await ga('#/home');
await ga('#/system/integrations/sources');
assert.deepEqual(fade(), ['full']);
});
});
+138
View File
@@ -0,0 +1,138 @@
// Generated, do not edit. sha256:98473478a698610a37641ee7064cdcff85c79c660b5b718e10f48d841ac3bc61
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { CONFIG, ENUMS } from '../../shared/config.js';
import { motionMode, nextDelayMs, driftTransform, nextCore, tempoOf, showerMs, bandTile, BAND_LAYERS } from '../../client/ui/aurora.js';
const vast = v => () => v;
describe('motionMode', () => {
const glas = { profile: 'glass', glow: 'on' };
it('volgt de voorkeur in Glas met de gloed aan', () => {
assert.equal(motionMode({ ...glas, motion: 'step' }), 'step');
assert.equal(motionMode({ ...glas, motion: 'always' }), 'always');
assert.equal(motionMode({ ...glas, motion: 'off' }), 'off');
});
it('staat stil bij minder beweging, een verborgen tabblad, Zakelijk of de gloed uit', () => {
assert.equal(motionMode({ ...glas, motion: 'always', reducedMotion: true }), 'off');
assert.equal(motionMode({ ...glas, motion: 'always', hidden: true }), 'off');
assert.equal(motionMode({ profile: 'business', glow: 'on', motion: 'always' }), 'off');
assert.equal(motionMode({ profile: 'glass', glow: 'off', motion: 'always' }), 'off');
});
it('valt op stil terug bij een onbekende waarde', () => {
assert.equal(motionMode({ ...glas, motion: 'wild' }), 'off');
});
});
describe('nextDelayMs', () => {
it('is de verplaatsing plus een pauze binnen de grenzen van het tempo', () => {
for (const key of ENUMS.GLOW_TEMPO) {
const t = CONFIG.GLOW_TEMPO[key];
assert.equal(nextDelayMs(t, vast(0)), (t.MOVE_S + t.PAUSE_MIN_S) * 1000, key);
assert.equal(nextDelayMs(t, vast(0.999999)), Math.round((t.MOVE_S + t.PAUSE_MIN_S + 0.999999 * (t.PAUSE_MAX_S - t.PAUSE_MIN_S)) * 1000), key);
}
});
it('heeft een tempo voor elke waarde van de voorkeur, en Rustig als terugval', () => {
assert.deepEqual(Object.keys(CONFIG.GLOW_TEMPO), [...ENUMS.GLOW_TEMPO]);
assert.equal(tempoOf('onzin'), CONFIG.GLOW_TEMPO.calm);
assert.deepEqual({ ...CONFIG.GLOW_TEMPO.calm }, { MOVE_S: 24, PAUSE_MIN_S: 45, PAUSE_MAX_S: 90, ALWAYS_MULT: 2 });
});
});
describe('driftTransform', () => {
it('blijft binnen het gebied van de bol', () => {
const gebied = { x: [-45, -5], y: [-40, -5] };
assert.match(driftTransform(gebied, vast(0)), /^translate\(-45\.0vw,-40\.0vh\) rotate\(-16\.0deg\) scale\(0\.850,0\.850\)$/);
assert.match(driftTransform(gebied, vast(1)), /^translate\(-5\.0vw,-5\.0vh\) rotate\(16\.0deg\) scale\(1\.200,1\.200\)$/);
});
});
describe('driftTransform met wegzakken', () => {
const gebied = { x: [-38, 38], y: [-38, -4], sink: 0.3 };
it('zakt weg als de eerste trekking onder de kans valt', () => {
assert.match(driftTransform(gebied, vast(0.1)), /^translate\(-30\.4vw,80\.0vh\)/);
});
it('blijft binnen zijn gebied als hij niet wegzakt', () => {
assert.match(driftTransform(gebied, vast(0.5)), /^translate\(0\.0vw,-21\.0vh\)/);
});
});
describe('nextCore', () => {
it('maakt van een kern weer een vlek', () => {
assert.equal(nextCore([true, false, false], 0, vast(0)), false);
});
it('geeft nooit een tweede kern zolang er een is', () => {
assert.equal(nextCore([false, true, false], 0, vast(0)), false);
assert.equal(nextCore([false, false, true], 1, vast(0)), false);
});
it('geeft een vlek soms een kern, met de kans uit de config', () => {
const kans = CONFIG.GLOW_CORE_CHANCE;
assert.equal(nextCore([false, false, false], 1, vast(kans - 0.01)), true);
assert.equal(nextCore([false, false, false], 1, vast(kans)), false);
});
});
describe('showerMs', () => {
it('is bij een bui twee verplaatsingen lang, en daarna een pauze binnen de grenzen van het tempo', () => {
const t = CONFIG.GLOW_TEMPO.calm;
assert.equal(showerMs(t, true, vast(0.5)), t.MOVE_S * 2 * 1000);
assert.equal(showerMs(t, false, vast(0)), t.PAUSE_MIN_S * 1000);
assert.equal(showerMs(t, false, vast(1)), t.PAUSE_MAX_S * 1000);
});
});
describe('bandTile', () => {
const rects = spec => {
const svg = decodeURIComponent(bandTile(spec).slice('url("data:image/svg+xml;utf8,'.length, -2));
return [...svg.matchAll(/<rect x="([\d.-]+)" y="([\d.-]+)" width="([\d.]+)" height="([\d.]+)"/g)]
.map(m => ({ x: Number(m[1]), y: Number(m[2]), w: Number(m[3]), h: Number(m[4]) }));
};
it('herhaalt naadloos: een baan waarvan de waas over de boven- of onderrand loopt, staat ook aan de andere kant', () => {
for (const spec of BAND_LAYERS) {
const all = rects(spec), halo = spec.blur * 3;
const at = (x, y) => all.some(r => r.x === x && Math.abs(r.y - y) < 0.2);
for (const r of all.filter(r => r.y >= 0 && r.y < spec.h)) {
if (r.y + r.h + halo > spec.h) assert.ok(at(r.x, r.y - spec.h), `seed ${spec.seed}: geen kopie boven voor x=${r.x}`);
if (r.y - halo < 0) assert.ok(at(r.x, r.y + spec.h), `seed ${spec.seed}: geen kopie onder voor x=${r.x}`);
}
}
});
it('houdt de waas binnen de zijkanten van de tegel', () => {
for (const spec of BAND_LAYERS) {
for (const r of rects(spec)) {
assert.ok(r.x >= spec.blur * 3 && r.x + r.w <= spec.w - spec.blur * 3, `seed ${spec.seed}: baan op x=${r.x} raakt de zijkant`);
}
}
});
it('tekent elke laag bij elke start hetzelfde, met het aantal banen uit de laag', () => {
for (const spec of BAND_LAYERS) {
assert.equal(bandTile(spec), bandTile(spec));
assert.equal(rects(spec).filter(r => r.y >= 0 && r.y < spec.h).length, spec.count);
}
});
it('heeft geen heldere onderkant: een baan dooft aan beide uiteinden uit', () => {
const svg = decodeURIComponent(bandTile(BAND_LAYERS[0]));
assert.match(svg, /<stop offset="0" stop-color="#fff" stop-opacity="0"\/>/);
assert.match(svg, /<stop offset="1" stop-color="#fff" stop-opacity="0"\/>/);
});
it('valt uit de rechterbovenhoek, en wordt naar voren breder en onscherper', () => {
for (const spec of BAND_LAYERS) assert.ok(spec.tilt > 0, `seed ${spec.seed} valt niet vanuit rechts`);
for (let i = 1; i < BAND_LAYERS.length; i++) {
assert.ok(BAND_LAYERS[i].width[0] > BAND_LAYERS[i - 1].width[1], 'een voorste laag is niet breder');
assert.ok(BAND_LAYERS[i].blur > BAND_LAYERS[i - 1].blur, 'een voorste laag is niet onscherper');
}
});
});
+75
View File
@@ -0,0 +1,75 @@
// Generated, do not edit. sha256:415ae19f3cfe2e0ac5a4506d3d7d7f1611e2eb531e32c2abb96255b051ae85dd
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { durationParts, exchangeStatus, localClock, wallToInstant } from '../../client/widgets/exchange-times.js';
import { CLOCKS } from '../../shared/config.js';
const NYSE = CLOCKS.EXCHANGES.find(e => e.key === 'nyse');
const TSE = CLOCKS.EXCHANGES.find(e => e.key === 'tse');
const iso = (d) => d.toISOString().slice(0, 16);
const at = (s) => new Date(`${s}:00Z`);
describe('wallToInstant', () => {
it('rekent de muurtijd van een zone om naar een tijdstip, met en zonder zomertijd', () => {
assert.equal(iso(wallToInstant('America/New_York', 2026, 3, 2, 9, 30)), '2026-03-02T14:30', 'EST, UTC-5');
assert.equal(iso(wallToInstant('America/New_York', 2026, 3, 16, 9, 30)), '2026-03-16T13:30', 'EDT, UTC-4');
assert.equal(iso(wallToInstant('Asia/Tokyo', 2026, 7, 1, 9, 0)), '2026-07-01T00:00', 'Tokio kent geen zomertijd');
});
});
describe('exchangeStatus', () => {
it('is dicht vóór de opening en noemt het tijdstip', () => {
const s = exchangeStatus(NYSE, at('2026-03-16T13:00'));
assert.equal(s.isOpen, false);
assert.equal(iso(s.opensAt), '2026-03-16T13:30');
});
it('is open tijdens de sessie en noemt opening en sluiting', () => {
const s = exchangeStatus(NYSE, at('2026-03-16T15:00'));
assert.equal(s.isOpen, true);
assert.equal(iso(s.openedAt), '2026-03-16T13:30');
assert.equal(iso(s.closesAt), '2026-03-16T20:00');
assert.equal(s.opensAt, null);
});
it('springt na de sluiting op vrijdag en in het weekend naar maandag', () => {
assert.equal(iso(exchangeStatus(NYSE, at('2026-03-20T21:00')).opensAt), '2026-03-23T13:30', 'vrijdag na de sluiting');
assert.equal(iso(exchangeStatus(NYSE, at('2026-03-21T15:00')).opensAt), '2026-03-23T13:30', 'zaterdag');
});
it('neemt de kalenderdag van de BEURS en niet die van UTC', () => {
const s = exchangeStatus(TSE, at('2026-03-15T21:00'));
assert.equal(s.isOpen, false);
assert.equal(iso(s.opensAt), '2026-03-16T00:00');
});
});
describe('localClock: de opening in een andere zone, per dag omgerekend', () => {
it('zet New York in Amsterdam op 15:30, behalve in de weken dat alleen de VS al zomertijd heeft', () => {
const nu = at('2026-01-01T00:00');
const ams = (instant) => localClock(instant, nu, 'Europe/Amsterdam').time;
assert.equal(ams(exchangeStatus(NYSE, at('2026-03-02T12:00')).opensAt), '15:30', 'begin maart: allebei wintertijd');
assert.equal(ams(exchangeStatus(NYSE, at('2026-03-16T12:00')).opensAt), '14:30', 'half maart: alleen de VS op zomertijd');
assert.equal(ams(exchangeStatus(NYSE, at('2026-04-06T12:00')).opensAt), '15:30', 'april: allebei zomertijd');
});
it('zegt of het tijdstip op een andere dag valt, met de weekdag erbij', () => {
const nu = at('2026-03-21T15:00');
const c = localClock(exchangeStatus(NYSE, nu).opensAt, nu, 'Europe/Amsterdam');
assert.equal(c.otherDay, true);
assert.match(c.weekday, /^ma/);
assert.equal(localClock(at('2026-03-21T16:00'), nu, 'Europe/Amsterdam').otherDay, false);
});
});
describe('durationParts', () => {
it('kiest de eenheid naar de lengte, en rondt naar boven af op de minuut', () => {
const m = (n) => n * 60 * 1000;
assert.deepEqual(durationParts(30 * 1000), { unit: 'min', n: 1 }, 'nooit "0 min" zolang hij dicht is');
assert.deepEqual(durationParts(m(40)), { unit: 'min', n: 40 });
assert.deepEqual(durationParts(m(100)), { unit: 'hourMin', n: 1, m: 40 }, '"1 uur" voor 1:40 zou misleiden');
assert.deepEqual(durationParts(m(200)), { unit: 'hours', n: 3 });
assert.deepEqual(durationParts(m(26 * 60)), { unit: 'days', n: 1 });
});
});
+107
View File
@@ -0,0 +1,107 @@
// Generated, do not edit. sha256:bd5c51ed994108f6b5595572b397c7a62becd0a64fa6991da6b87ed3970cd81b
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
let antwoord = { cursor: 0, names: [], reset: false };
let fout = null;
const gevraagd = [];
const gedispatcht = [];
mock.module('../../client/data/_rpc.js', {
namedExports: {
async rpc(module, functie, ...args) {
assert.equal(module, 'bus');
assert.equal(functie, 'changesSince');
gevraagd.push(args[0]);
if (fout) throw fout;
return antwoord;
},
dispatchChanges(namen) { gedispatcht.push(namen); return namen; },
},
});
const { pollChanges } = await import('../../client/changes.js');
beforeEach(() => {
antwoord = { cursor: 0, names: [], reset: false };
fout = null;
gevraagd.length = 0;
gedispatcht.length = 0;
});
const zetStandOp = async (n) => {
antwoord = { cursor: n, names: [], reset: false };
await pollChanges();
gevraagd.length = 0;
gedispatcht.length = 0;
};
describe('changes: de cursor', () => {
it('vraagt de allereerste keer met null, zodat er geen historie binnenkomt', async () => {
antwoord = { cursor: 7, names: [], reset: false };
await pollChanges();
assert.deepEqual(gevraagd, [null]);
});
it('onthoudt de stand en vraagt de volgende ronde daarmee', async () => {
await zetStandOp(7);
antwoord = { cursor: 9, names: ['providers-changed'], reset: false };
await pollChanges();
await pollChanges();
assert.deepEqual(gevraagd, [7, 9], 'elke ronde met wat de vorige teruggaf');
});
it('houdt zijn cursor als het antwoord er geen bevat', async () => {
await zetStandOp(4);
antwoord = { names: ['iets'] };
await pollChanges();
await pollChanges();
assert.deepEqual(gevraagd, [4, 4]);
});
});
describe('changes: wat er gedispatcht wordt', () => {
it('geeft de namen door aan het gedeelde dispatch-punt', async () => {
antwoord = { cursor: 2, names: ['providers-changed', 'connections-changed'], reset: false };
await pollChanges();
assert.deepEqual(gedispatcht, [['providers-changed', 'connections-changed']]);
});
it('dispatcht bij reset "ververs alles" en niet de namen', async () => {
antwoord = { cursor: 12, names: [], reset: true };
await pollChanges();
assert.deepEqual(gedispatcht, [['app-refresh']]);
});
it('neemt bij reset de stand wél over, zodat het niet elke ronde opnieuw gebeurt', async () => {
await zetStandOp(3);
antwoord = { cursor: 12, names: [], reset: true };
await pollChanges();
antwoord = { cursor: 12, names: [], reset: false };
await pollChanges();
assert.deepEqual(gevraagd, [3, 12]);
});
});
describe('changes: het niet-gelukkige pad', () => {
it('faalt zacht als de server weg is', async () => {
fout = new Error('Geen verbinding met de server');
await assert.doesNotReject(() => pollChanges());
assert.deepEqual(gedispatcht, [], 'en er wordt niets verzonnen om te dispatchen');
});
it('houdt de cursor vast na een mislukte ronde', async () => {
await zetStandOp(5);
fout = new Error('weg');
await pollChanges();
fout = null;
await pollChanges();
assert.deepEqual(gevraagd, [5, 5], 'de mislukte ronde heeft de stand niet weggegooid');
});
it('valt niet om op een antwoord dat geen object is', async () => {
antwoord = 'onzin';
await assert.doesNotReject(() => pollChanges());
assert.deepEqual(gedispatcht, []);
});
});
+38
View File
@@ -0,0 +1,38 @@
// Generated, do not edit. sha256:dee69ed94d0c46ad53fcee0939fbd4ffebc4fa1ade845e5f22219f49d6164da7
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { installDom } from '../helpers/mini-dom.mjs';
const dom = installDom();
const { loadCharting, readPalette } = await import('../../client/ui/chart-setup.js');
describe('chart-setup: het palet van Assets', () => {
it('heeft het gedeelde palet plus de lijnladder en de signaalkleuren', () => {
const root = dom.document.documentElement;
root.style.setProperty('--accent', '#FF6B00');
root.style.setProperty('--sig-red', '#e00');
root.style.setProperty('--ring-seam', '2');
root.style.setProperty('--ring-sheen', '1');
const p = readPalette();
assert.equal(p.accent, '#FF6B00');
assert.equal(p.accentMid, 'rgba(255,107,0,0.725)');
assert.equal(p.accentFaint, 'rgba(255,107,0,0.45)');
assert.equal(p.sigRed, '#e00');
assert.equal(p.red, '#e00');
assert.equal(p.ringSeam, 2);
assert.equal(p.ringSheen, true);
assert.equal(typeof p.series, 'function', 'het gedeelde deel komt mee');
});
});
describe('chart-setup: laden', () => {
it('laadt de bibliotheken uit CONFIG en meldt een laadfout in het Nederlands', async () => {
delete window.Chart;
const p = loadCharting();
await new Promise(r => setImmediate(r));
const script = dom.document.head.querySelector('script');
assert.match(script.getAttribute('src'), /^vendor\/chartjs\//);
script.onerror();
await assert.rejects(p, err => !/Cannot load/.test(err.message) && err.message.includes(script.getAttribute('src')));
});
});
+101
View File
@@ -0,0 +1,101 @@
// Generated, do not edit. sha256:69f75542e06d5cb195d0f60e5c04a612f5f451eb2e372d74f50aab610eb7789a
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { installDom } from '../helpers/mini-dom.mjs';
const dom = installDom();
const { loadCharting, hexToRgb, tintSeries, readPalette } = await import('../../client/ui/charting.js');
describe('hexToRgb', () => {
it('reads the long and the short form, with or without a hash', () => {
assert.deepEqual(hexToRgb('#FF6B00'), { r: 255, g: 107, b: 0 });
assert.deepEqual(hexToRgb('#888'), { r: 136, g: 136, b: 136 });
assert.deepEqual(hexToRgb('a3a3a3'), { r: 163, g: 163, b: 163 });
});
it('gives null for nonsense instead of throwing: a theme is a place where anything can be typed', () => {
for (const odd of ['', null, undefined, '#12', 'rgb(1,2,3)', '#gggggg']) assert.equal(hexToRgb(odd), null, `input: ${odd}`);
});
});
describe('tintSeries', () => {
it('starts full and ends on the minimum', () => {
const r = tintSeries('#FF6B00', 5);
assert.equal(r.length, 5);
assert.equal(r[0], 'rgba(255,107,0,1)');
assert.equal(r[4], 'rgba(255,107,0,0.28)');
});
it('reaches the full range whatever the count', () => {
for (const n of [2, 3, 7, 20]) {
const r = tintSeries('#FF6B00', n);
assert.equal(r.length, n);
assert.match(r[0], /,1\)$/, `n=${n}: first is full`);
assert.match(r[n - 1], /,0\.28\)$/, `n=${n}: last is the minimum`);
}
});
it('one segment is the full colour, none is an empty series', () => {
assert.deepEqual(tintSeries('#FF6B00', 1), ['rgba(255,107,0,1)']);
assert.deepEqual(tintSeries('#FF6B00', 0), []);
});
it('falls back to grey when the colour is unreadable', () => {
assert.deepEqual(tintSeries('not-a-colour', 1), ['rgba(128,128,128,1)']);
});
it('takes the range as a parameter; the line ladder of the charts uses a higher floor', () => {
assert.deepEqual(tintSeries('#000000', 2, { min: 0.5, max: 0.9 }), ['rgba(0,0,0,0.9)', 'rgba(0,0,0,0.5)']);
assert.deepEqual(tintSeries('#FF6B00', 3, { min: 0.45 }), ['rgba(255,107,0,1)', 'rgba(255,107,0,0.725)', 'rgba(255,107,0,0.45)']);
});
});
describe('loadCharting', () => {
const LIBS = { CHARTJS: '/v/chart.js', HAMMER: '/v/hammer.js', ZOOM: '/v/zoom.js' };
const scripts = () => dom.document.head.querySelectorAll('script').map(s => s.getAttribute('src'));
const settle = () => new Promise(r => setImmediate(r));
it('adds the three scripts in order, once, and resolves to window.Chart', async () => {
dom.document.head.innerHTML = '';
delete window.Chart; delete window.Hammer;
const p = loadCharting(LIBS);
assert.equal(loadCharting(LIBS), p, 'one load per session');
for (const src of ['/v/chart.js', '/v/hammer.js', '/v/zoom.js']) {
await settle();
const s = dom.document.head.querySelectorAll('script').find(x => x.getAttribute('src') === src);
if (src === '/v/chart.js') window.Chart = { ok: true };
s.onload();
}
assert.deepEqual(await p, { ok: true });
assert.deepEqual(scripts(), ['/v/chart.js', '/v/hammer.js', '/v/zoom.js']);
});
it('a failed load rejects with the app\'s message, and the next call tries again', async () => {
const failing = { CHARTJS: '/x/chart.js', HAMMER: '/x/hammer.js', ZOOM: '/x/zoom.js' };
window.Chart = undefined;
dom.document.head.innerHTML = '';
const mod = await import(`../../client/ui/charting.js?fail=${Date.now()}`);
const p = mod.loadCharting(failing, { failed: src => `Kan ${src} niet laden.` });
await settle();
dom.document.head.querySelector('script').onerror();
await assert.rejects(p, /Kan \/x\/chart\.js niet laden\./);
await settle();
const again = mod.loadCharting(failing);
assert.notEqual(again, p, 'not the remembered failure');
again.catch(() => {});
});
});
describe('readPalette', () => {
it('reads the tokens of the theme, and series(n) tints the accent with the line floor', () => {
const root = dom.document.documentElement;
root.style.setProperty('--accent', '#FF6B00');
root.style.setProperty('--text-primary', '#111');
root.style.setProperty('--border', '#ccc');
const p = readPalette();
assert.equal(p.accent, '#FF6B00');
assert.equal(p.text, '#111');
assert.equal(p.grid, '#ccc');
assert.deepEqual(p.series(3), ['rgba(255,107,0,1)', 'rgba(255,107,0,0.725)', 'rgba(255,107,0,0.45)']);
});
});
+253
View File
@@ -0,0 +1,253 @@
// Generated, do not edit. sha256:768721f9fb6138508b5d90c78fcf36c2c24f85c154f27b169a0cb0109b7e699c
import { describe, it, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { installDom } from '../helpers/mini-dom.mjs';
const dom = installDom();
const dlg = await import('../../client/ui/dialog.js');
const overlays = () => dom.document.querySelectorAll('.dialog-overlay');
const top = () => overlays().at(-1);
const button = kind => top().querySelector(`[data-dlg="${kind}"]`);
const escape = () => dom.key(dom.document.activeElement || dom.document.body, 'Escape');
afterEach(() => {
for (let i = 0; i < 3 && overlays().length; i++) escape();
dlg.configureDialog();
});
describe('confirm, alert, notice', () => {
it('confirm resolves true on OK and false on cancel; OK has the focus', async () => {
const p = dlg.confirm({ title: 'Delete?', message: 'Sure <b>?</b>' });
assert.ok(dom.document.activeElement === button('ok'), 'OK has the focus');
assert.equal(top().querySelector('.dialog-body').textContent, 'Sure <b>?</b>');
dom.click(button('ok'));
assert.equal(await p, true);
const q = dlg.confirm({ message: 'x' });
dom.click(button('cancel'));
assert.equal(await q, false);
assert.equal(overlays().length, 0);
});
it('danger makes the confirm button red; labels can be given per call', async () => {
const p = dlg.confirm({ message: 'x', danger: true, confirmLabel: 'Weg', cancelLabel: 'Nee' });
assert.ok(button('ok').classList.contains('btn-danger'));
assert.equal(button('ok').textContent, 'Weg');
assert.equal(button('cancel').textContent, 'Nee');
escape();
assert.equal(await p, false);
});
it('a dialog WITH a button does not close on the backdrop; one without does', async () => {
const p = dlg.alert({ message: 'x' });
dom.click(top());
assert.equal(overlays().length, 1);
dom.click(button('ok'));
await p;
const n = dlg.notice({ message: 'restarting' });
assert.equal(top().querySelector('.dialog-actions'), null, 'no empty action block');
dom.click(top());
assert.equal(await n, false);
});
it('a second dialog replaces the first, which resolves false', async () => {
const p = dlg.confirm({ message: 'one' });
const q = dlg.confirm({ message: 'two' });
assert.equal(await p, false);
assert.equal(overlays().length, 1);
escape();
await q;
});
});
describe('the page behind', () => {
it('stops scrolling while a dialog is open, keeps the scrollbar width, and gets its own values back', async () => {
const root = dom.document.documentElement;
root.style.overflow = 'auto';
Object.defineProperty(root, 'clientWidth', { value: 1265, configurable: true });
const p = dlg.alert({ message: 'x' });
assert.equal(root.style.overflow, 'hidden');
assert.equal(root.style.paddingRight, '15px');
dom.click(button('ok'));
await p;
assert.equal(root.style.overflow, 'auto');
assert.equal(root.style.paddingRight, '');
delete root.clientWidth;
});
});
describe('form', () => {
const fields = [
{ key: 'name', label: 'Naam', value: 'a"b' },
{ key: 'kind', label: 'Soort', type: 'select', value: 'y', options: [{ value: 'x', label: 'X' }, { value: 'y', label: 'Y' }] },
{ key: 'note', label: 'Notitie', type: 'textarea' },
];
it('gives the values on OK, with the first field focused and values escaped into the markup', async () => {
const p = dlg.form({ title: 'New', fields });
const name = top().querySelector('[data-dlg-field="name"]');
assert.ok(dom.document.activeElement === name);
assert.equal(name.value, 'a"b');
assert.equal(top().querySelector('[data-dlg-field="kind"]').value, 'y');
name.value = 'c';
dom.click(button('ok'));
assert.deepEqual(await p, { name: 'c', kind: 'y', note: '' });
});
it('Enter in an input confirms; Enter in a textarea does not; cancel gives a falsy value', async () => {
const p = dlg.form({ fields });
dom.key(top().querySelector('textarea'), 'Enter');
assert.equal(overlays().length, 1);
dom.key(top().querySelector('input'), 'Enter');
assert.ok(await p);
const q = dlg.form({ fields });
dom.click(top());
assert.equal(overlays().length, 1, 'the backdrop does not throw the input away');
dom.click(button('cancel'));
assert.equal(await q, null);
});
});
describe('detail and what stacks on it', () => {
it('a wide panel with a close cross, no actions, and the markup as given', async () => {
const p = dlg.detail({ title: 'Asset', html: '<p class="x">body</p>', size: 'xl', asideHtml: '<em>tag</em>' });
const d = top().querySelector('.dialog');
assert.ok(d.classList.contains('dialog--wide') && d.classList.contains('dialog--xl'));
assert.ok(d.querySelector('.dialog-close'));
assert.equal(d.querySelector('.dialog-actions'), null);
assert.equal(d.querySelector('p.x').textContent, 'body');
assert.equal(d.querySelector('[data-dialog-aside] em').textContent, 'tag');
dom.click(top());
assert.equal(overlays().length, 1, 'the cross is a way out, so the backdrop does not close');
dom.click(d.querySelector('.dialog-close'));
assert.equal(await p, false);
});
it('a confirmation from inside stacks on top; Escape closes only the top one', async () => {
const p = dlg.detail({ title: 'Asset', html: '' });
const q = dlg.confirm({ message: 'Delete this row?' });
assert.equal(overlays().length, 2);
assert.ok(top().classList.contains('dialog-overlay--stacked'));
assert.equal(dlg.isDialogOpen(overlays()[0].querySelector('.dialog')), true, 'the stacked one is in the way');
escape();
assert.equal(await q, false);
assert.equal(overlays().length, 1);
escape();
assert.equal(await p, false);
});
it('alert and form stack too, and a stacked form gives its values', async () => {
const p = dlg.detail({ title: 'x', html: '' });
const a = dlg.alert({ message: 'm' });
dom.click(button('ok'));
assert.equal(await a, true);
const f = dlg.form({ fields: [{ key: 'k', label: 'K', value: 'v' }] });
dom.key(top().querySelector('input'), 'Enter');
assert.deepEqual(await f, { k: 'v' });
assert.equal(overlays().length, 1);
escape();
await p;
});
it('Escape in a field of a detail panel belongs to that field, the next one closes the panel', async () => {
const p = dlg.detail({ title: 'x', html: '<input class="f">' });
const f = top().querySelector('input.f');
dom.key(f, 'Escape');
assert.equal(overlays().length, 1);
dom.key(top().querySelector('.dialog'), 'Escape');
assert.equal(await p, false);
});
it('isDialogOpen: an element inside the open dialog is not under it, one outside is', async () => {
assert.equal(dlg.isDialogOpen(), false);
const p = dlg.detail({ title: 'x', html: '<span class="in"></span>' });
assert.equal(dlg.isDialogOpen(top().querySelector('.in')), false);
assert.equal(dlg.isDialogOpen(dom.document.body), true);
escape();
await p;
});
it('onDetail gets the dialog element and the promise of its closing', async () => {
let got = null;
dlg.configureDialog({ onDetail: (d, done) => { got = { cls: d.className, done }; d.querySelector('.dialog-title').append(dom.document.createElement('b')); } });
const p = dlg.detail({ title: 'x', html: '' });
assert.equal(got.cls, 'dialog dialog--wide');
assert.ok(top().querySelector('.dialog-title b'));
assert.equal(got.done, p);
escape();
await p;
});
it('confirmOver without an open dialog is an ordinary confirm', async () => {
const p = dlg.confirmOver({ message: 'x' });
assert.equal(overlays().length, 1);
dom.click(button('ok'));
assert.equal(await p, true);
});
});
describe('stepper', () => {
const steps = [
{ title: 'Kind', html: () => '<button data-step-set="kind" data-value="btc">btc</button><button data-step-set="kind" data-value="all">all</button>', ready: s => !!s.kind },
{ title: 'Only bitcoin?', html: () => '<button data-step-toggle="tags" data-value="a">a</button><button data-step-toggle="tags" data-value="b">b</button><button data-step-toggle="tags" data-value="c">c</button>', skip: s => s.kind !== 'btc' },
{ title: 'Done', html: () => '<p>ok</p>' },
];
const next = () => top().querySelector('[data-step="next"]');
it('next stays disabled until the step is ready; a set choice fills the state', async () => {
const p = dlg.stepper({ title: 'Setup', steps });
assert.ok(next().hasAttribute('disabled'));
dom.click(next());
assert.equal(top().querySelector('.stepper-title').textContent, 'Kind', 'a disabled next does nothing');
dom.click(top().querySelector('[data-value="btc"]'));
assert.equal(next().hasAttribute('disabled'), false);
dom.click(next());
assert.equal(top().querySelector('.stepper-title').textContent, 'Only bitcoin?');
for (const v of ['a', 'b', 'c', 'a']) dom.click(top().querySelector(`[data-step-toggle][data-value="${v}"]`));
dom.click(next());
assert.equal(next().textContent, 'Finish');
dom.click(next());
assert.deepEqual(await p, { kind: 'btc', tags: ['b', 'c'] }, 'a toggle adds, a second click takes it out');
});
it('draws the hero once: a step change keeps the same element, so its animation does not replay', async () => {
const p = dlg.stepper({ steps, hero: '<div class="logo"></div>' });
const hero = top().querySelector('.stepper-hero .logo');
assert.ok(hero);
dom.click(top().querySelector('[data-value="btc"]'));
dom.click(next());
assert.equal(top().querySelector('.stepper-hero .logo'), hero);
dom.click(top().querySelector('[data-step="skip"]'));
assert.equal(await p, false);
});
it('a skipped step is left out, back goes back, and skip gives false', async () => {
const p = dlg.stepper({ steps, state: { kind: 'all' } });
dom.click(next());
assert.equal(top().querySelector('.stepper-title').textContent, 'Done');
dom.click(top().querySelector('[data-step="back"]'));
assert.equal(top().querySelector('.stepper-title').textContent, 'Kind');
dom.click(top().querySelector('[data-step="skip"]'));
assert.equal(await p, false);
});
});
describe('configureDialog', () => {
it('sets the button texts and the close icon; unset fields keep the default', async () => {
dlg.configureDialog({ labels: { ok: 'Goed', cancel: 'Annuleren', close: 'Sluiten' }, closeIcon: '<i class="x"></i>' });
const p = dlg.confirm({ message: 'x' });
assert.equal(button('ok').textContent, 'Goed');
assert.equal(button('cancel').textContent, 'Annuleren');
escape();
await p;
const d = dlg.detail({ title: 'x', html: '' });
assert.equal(top().querySelector('.dialog-close').getAttribute('aria-label'), 'Sluiten');
assert.ok(top().querySelector('.dialog-close i.x'));
escape();
await d;
const s = dlg.stepper({ steps: [{ html: () => '' }] });
assert.equal(top().querySelector('[data-step="skip"]').textContent, 'Skip');
escape();
await s;
});
});
+58
View File
@@ -0,0 +1,58 @@
// Generated, do not edit. sha256:a7ea6b1b573eee126d96f50fe1a254b6a09a05cea53026a6d40a986ee55bbb21
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
const { emptyStateHtml } = await import('../../client/ui/empty-state.js');
describe('emptyStateHtml: the three parts', () => {
it('renders the bare form as one line, without a frame or an icon', () => {
const html = emptyStateHtml('Nog geen trades.');
assert.equal(html, '<div class="empty-state">Nog geen trades.</div>');
assert.doesNotMatch(html, /alert|icon|svg/);
});
it('puts the reason behind the message, muted', () => {
const html = emptyStateHtml('Nog geen trades.', { why: 'Voeg eerst een asset toe.' });
assert.match(html, /Nog geen trades\. <span class="empty-state-why">Voeg eerst een asset toe\.<\/span>/);
});
it('adds the link last, so you read what is missing before where to fix it', () => {
const html = emptyStateHtml('Nog geen trades.', { href: '#/system/assets', linkText: 'Assets beheren' });
assert.match(html, /Nog geen trades\. <a href="#\/system\/assets">Assets beheren<\/a><\/div>$/);
});
});
describe('emptyStateHtml: what is left out leaves nothing behind', () => {
it('writes no span when there is no reason', () => {
assert.doesNotMatch(emptyStateHtml('Leeg.'), /span/);
});
it('writes no anchor when only one half of the link is given', () => {
assert.doesNotMatch(emptyStateHtml('Leeg.', { href: '#/x' }), /<a /);
assert.doesNotMatch(emptyStateHtml('Leeg.', { linkText: 'Ga' }), /<a /);
});
it('survives being called with no options at all', () => {
assert.equal(emptyStateHtml('Leeg.'), '<div class="empty-state">Leeg.</div>');
});
});
describe('emptyStateHtml: everything is text', () => {
it('escapes the message', () => {
assert.doesNotMatch(emptyStateHtml('<img src=x onerror=alert(1)>'), /<img/);
});
it('escapes the reason', () => {
assert.doesNotMatch(emptyStateHtml('x', { why: '<script>alert(1)</script>' }), /<script/);
});
it('escapes the link text', () => {
assert.doesNotMatch(emptyStateHtml('x', { href: '#/a', linkText: '<b>go</b>' }), /<b>/);
});
it('escapes the href, quote included', () => {
const html = emptyStateHtml('x', { href: '#/a" onmouseover="alert(1)', linkText: 'go' });
assert.doesNotMatch(html, /onmouseover="/);
assert.match(html, /&quot;/);
});
});
+40
View File
@@ -0,0 +1,40 @@
// Generated, do not edit. sha256:d90f8fe12862c6daa6656fef5bf8ba13b2a3cb9d3b9cb9d68c08cde20d6f7420
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { esc } from '../../client/ui/escape.js';
describe('esc', () => {
it('escapes the five characters that can make HTML structure', () => {
assert.equal(esc('&'), '&amp;');
assert.equal(esc('<'), '&lt;');
assert.equal(esc('>'), '&gt;');
assert.equal(esc('"'), '&quot;');
assert.equal(esc("'"), '&#39;');
});
it('a script payload no longer becomes a tag', () => {
assert.equal(esc('<img src=x onerror="alert(1)">'), '&lt;img src=x onerror=&quot;alert(1)&quot;&gt;');
});
it('does not break out of a quoted attribute value', () => {
const html = `<div title="${esc('" onmouseover="alert(1)')}"></div>`;
assert.equal(html, '<div title="&quot; onmouseover=&quot;alert(1)"></div>');
assert.equal(html.split('"').length - 1, 2, 'only the two intended quotes');
});
it('escapes & first, so no double entities appear', () => {
assert.equal(esc('<'), '&lt;');
assert.equal(esc('&lt;'), '&amp;lt;');
});
it('gives empty text for null and undefined, and keeps 0 and false', () => {
assert.equal(esc(null), '');
assert.equal(esc(undefined), '');
assert.equal(esc(0), '0');
assert.equal(esc(false), 'false');
});
it('leaves ordinary text alone', () => {
assert.equal(esc('Rekening Café Spaar (2019)'), 'Rekening Café Spaar (2019)');
});
});
+106
View File
@@ -0,0 +1,106 @@
// Generated, do not edit. sha256:3ce212b10cde11e15f4155e3be716fe82cb375098c6eff82a6ff3030ea67f950
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
let opgeslagen;
let bewaardeWaarde;
let leesFout = false;
mock.module('../../client/data/preferences.js', {
namedExports: {
async getPref(scope, key, fallback) {
if (leesFout) throw new Error('database weg');
return bewaardeWaarde === undefined ? fallback : bewaardeWaarde;
},
async setPref(scope, key, value) { opgeslagen = { scope, key, value }; },
},
});
const { expanders } = await import('../../client/ui/expand.js');
beforeEach(() => { opgeslagen = undefined; bewaardeWaarde = undefined; leesFout = false; });
describe('expanders: de standaard en de keuze', () => {
it('volgt de standaard zolang de gebruiker niets koos', async () => {
const e = await expanders('ai', { connect: true, dryRun: false });
assert.equal(e.open('connect'), true);
assert.equal(e.open('dryRun'), false);
});
it('laat de keuze van de gebruiker vóór de standaard gaan', async () => {
bewaardeWaarde = { connect: false, dryRun: true };
const e = await expanders('ai', { connect: true, dryRun: false });
assert.equal(e.open('connect'), false, 'de gebruiker klapte dit kader dicht');
assert.equal(e.open('dryRun'), true, 'en dit kader open');
});
it('vult aan met de standaard voor een kader dat nog niet bewaard is', async () => {
bewaardeWaarde = { connect: false };
const e = await expanders('ai', { connect: true, dryRun: true });
assert.equal(e.open('dryRun'), true);
});
it('valt terug op de standaard als het lezen mislukt', async () => {
leesFout = true;
const e = await expanders('ai', { connect: true });
assert.equal(e.open('connect'), true, 'view-state mag een pagina niet tegenhouden');
});
it('negeert een bewaarde waarde die geen object is', async () => {
bewaardeWaarde = 'kapot';
const e = await expanders('ai', { connect: true });
assert.equal(e.open('connect'), true);
});
});
describe('expanders: de attributen voor de markup', () => {
it('zet open alleen bij een open kader', async () => {
bewaardeWaarde = { connect: true, dryRun: false };
const e = await expanders('ai', {});
assert.equal(e.attr('connect'), 'data-expand="connect" open');
assert.equal(e.attr('dryRun'), 'data-expand="dryRun"');
});
it('draagt de naam mee, want daarop wordt de stand onthouden', async () => {
const e = await expanders('ai', {});
assert.match(e.attr('watDanOok'), /data-expand="watDanOok"/);
});
});
describe('expanders: het wegschrijven', () => {
function nepKader(naam, open) {
const luisteraars = [];
return {
dataset: { expand: naam },
open,
addEventListener(soort, fn) { if (soort === 'toggle') luisteraars.push(fn); },
klap(nieuw) { this.open = nieuw; luisteraars.forEach(fn => fn()); },
};
}
const nepHost = kaders => ({ querySelectorAll: () => kaders });
it('schrijft de hele stand weg onder de paginasleutel', async () => {
const e = await expanders('ai', { connect: true, dryRun: false });
const kader = nepKader('dryRun', false);
e.bind(nepHost([kader]));
kader.klap(true);
assert.equal(opgeslagen.scope, 'expand');
assert.equal(opgeslagen.key, 'ai');
assert.deepEqual(opgeslagen.value, { connect: true, dryRun: true },
'de andere kaders horen mee te gaan, anders wist elke klap de rest');
});
it('luistert op toggle en niet op een klik, zodat het toetsenbord ook telt', async () => {
const e = await expanders('ai', {});
const kader = nepKader('connect', false);
let soorten = [];
kader.addEventListener = (soort, fn) => { soorten.push(soort); if (soort === 'toggle') fn(); };
e.bind(nepHost([kader]));
assert.deepEqual(soorten, ['toggle']);
});
it('doet niets bij een host die er niet is', async () => {
const e = await expanders('ai', {});
assert.doesNotThrow(() => e.bind(null));
});
});
+96
View File
@@ -0,0 +1,96 @@
// Generated, do not edit. sha256:c600398d0ae11406268c5d41b736927a6f99cb317255efb1ff1d262f6e741c31
import { describe, it, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { installDom } from '../helpers/mini-dom.mjs';
const dom = installDom();
const { resolveSelection, create, configureFilter } = await import('../../client/ui/filter.js');
const items = [{ id: 1 }, { id: 2 }, { id: 3 }];
const sorted = set => [...set].sort((a, b) => a - b);
describe('resolveSelection: the initial state', () => {
it('a stored choice wins', () => {
assert.deepEqual(sorted(resolveSelection({ saved: [2, 3], items })), [2, 3]);
});
it('a DELIBERATELY empty choice stays empty', () => {
assert.deepEqual(sorted(resolveSelection({ saved: [], items })), []);
});
it('a stored choice that EMPTIES OUT falls back on the initial state', () => {
assert.deepEqual(sorted(resolveSelection({ saved: [98, 99], items })), [1, 2, 3]);
});
it('unknown ids are filtered out, the rest is kept', () => {
assert.deepEqual(sorted(resolveSelection({ saved: [2, 99], items })), [2]);
});
it('the initial predicate when nothing is stored, and everything when it yields nothing', () => {
assert.deepEqual(sorted(resolveSelection({ saved: null, items, initial: i => i.slice(0, 1).map(x => x.id) })), [1]);
assert.deepEqual(sorted(resolveSelection({ saved: null, items, initial: () => [] })), [1, 2, 3]);
assert.deepEqual(sorted(resolveSelection({ saved: null, items, initial: () => [99] })), [1, 2, 3]);
assert.deepEqual(sorted(resolveSelection({ saved: null, items })), [1, 2, 3]);
});
});
describe('create: the chips', () => {
afterEach(() => configureFilter());
const chipItems = [{ id: 1, label: 'Een' }, { id: 2, label: '<Twee>', title: 'De tweede' }];
it('adds its classes to the host instead of replacing them, and draws the toggle plus a chip per item', () => {
const host = dom.host();
host.className = 'own';
create(host, { items: chipItems, selected: new Set([1]), className: 'fbar--tctl', onChange() {} });
assert.equal(host.className, 'own fbar fbar--chip fbar--tctl');
assert.equal(host.querySelectorAll('[data-fi]').length, 2);
assert.ok(host.querySelector('[data-fi="0"]').classList.contains('is-on'));
assert.equal(host.querySelector('[data-fi="1"]').textContent, '<Twee>');
assert.equal(host.querySelector('[data-fi="1"]').title, 'De tweede');
});
it('the toggle names what the click does: all when not everything is on, none when it is', () => {
const changes = [];
const host = dom.host();
create(host, { items: chipItems, selected: new Set([1]), onChange: s => changes.push(sorted(s)) });
assert.equal(host.querySelector('[data-fact]').textContent, 'All');
dom.click(host.querySelector('[data-fact]'));
create(host, { items: chipItems, selected: new Set([1, 2]), onChange: s => changes.push(sorted(s)) });
assert.equal(host.querySelector('[data-fact]').textContent, 'None');
dom.click(host.querySelector('[data-fact]'));
assert.deepEqual(changes, [[1, 2], []]);
});
it('a chip toggles its id, and the id keeps its own type', () => {
let got = null;
const host = dom.host();
create(host, { items: chipItems, selected: new Set([1]), onChange: s => { got = s; } });
dom.click(host.querySelector('[data-fi="1"]'));
assert.deepEqual([...got], [1, 2]);
assert.equal(typeof [...got][1], 'number');
});
it('with onCycle a chip cycles instead of toggling; collapsed chips are dimmed', () => {
const cycled = [];
const host = dom.host();
create(host, { items: chipItems, selected: new Set([1, 2]), collapsedIds: new Set([2]), onCycle: id => cycled.push(id), onChange() { throw new Error('no'); } });
assert.ok(host.querySelector('[data-fi="1"]').classList.contains('is-collapsed'));
dom.click(host.querySelector('[data-fi="1"]'));
assert.deepEqual(cycled, [2]);
});
it('disabled: every button disabled, and a click does nothing', () => {
const host = dom.host();
create(host, { items: chipItems, selected: new Set(), disabled: true, onChange() { throw new Error('no'); } });
assert.ok(host.querySelectorAll('button').every(b => b.hasAttribute('disabled')));
dom.click(host.querySelector('[data-fi="0"]'));
});
it('configureFilter sets the texts of the toggle, escaped', () => {
configureFilter({ labels: { all: 'Alles <x>', none: 'Niets' } });
const host = dom.host();
create(host, { items: chipItems, selected: new Set(), onChange() {} });
assert.equal(host.querySelector('[data-fact]').textContent, 'Alles <x>');
assert.equal(host.querySelector('[data-fact] x'), null);
});
});
+264
View File
@@ -0,0 +1,264 @@
// Generated, do not edit. sha256:615ef8a5d598742e85ea7f2f5b84abfb3e5a2087d59fe201265eaa5faea109f0
import { describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
import { vasteRekenvaluta } from '../helpers/rekenvaluta.mjs';
await vasteRekenvaluta(mock, 'EUR');
const { fmtUnit, fmtAmount, fmtPct, pctTone, fmtBaseSigned, colouredSigns, fmtPrice, fmtMoney, currencyName, isBaseAsset, subline, toBaseByFactor } = await import('../../client/ui/format.js');
const { setAppSetting } = await import('../../client/app-settings.js');
import { CONFIG } from '../../shared/config.js';
const EUR = { symbol: 'EUR', kind: 'fiat', decimals: 2, unit: 'EUR', subunit: 'cent', displayUnit: 'unit' };
const BTC = { kind: 'crypto', decimals: 8, unit: 'BTC', subunit: 'sat', displayUnit: 'unit' };
const GOUD = { kind: 'metal', unit: 'ozt', subunit: 'g', displayUnit: 'unit' };
describe('fmtUnit bij geld', () => {
it('houdt de decimalen van het asset vast, ook als het nullen zijn', () => {
assert.equal(fmtUnit(130_000, EUR), '1.300,00 EUR');
assert.equal(fmtUnit(100_530, EUR), '1.005,30 EUR');
assert.equal(fmtUnit(0, EUR), '0,00 EUR');
});
it('laat een negatief bedrag heel (een boeking mag omlaag)', () => {
assert.equal(fmtUnit(-2_882_490, EUR), '-28.824,90 EUR');
});
it('volgt het asset en niet de aanname dat geld twee decimalen heeft', () => {
const zonderCenten = { kind: 'fiat', decimals: 0, unit: 'JPY', subunit: null, displayUnit: 'unit' };
assert.equal(fmtUnit(1_300, zonderCenten), '1.300 JPY');
});
it('telt in de ondereenheid gewoon hele centen', () => {
assert.equal(fmtUnit(100_530, { ...EUR, displayUnit: 'subunit' }), '100.530 cent');
});
});
describe('fmtUnit bij crypto en metaal blijft ongewijzigd', () => {
it('laat overtollige nullen bij crypto juist wél weg', () => {
assert.equal(fmtUnit(50_000_000, BTC), '0,5 BTC');
assert.equal(fmtUnit(100_000_000, BTC), '1 BTC');
assert.equal(fmtUnit(1, BTC), '0,00000001 BTC');
});
it('laat het metaal-pad ongemoeid (dat rekent met factoren, niet met 10^decimals)', () => {
assert.equal(fmtUnit(toBaseByFactor(1, 'ozt'), GOUD), '1 ozt');
assert.equal(fmtUnit(toBaseByFactor(2.5, 'ozt'), GOUD), '2,5 ozt');
});
});
describe('fmtAmount volgt dezelfde regel', () => {
it('geeft bij geld een hoofdbedrag met vaste decimalen en de centen ernaast', () => {
assert.deepEqual(fmtAmount(130_000, EUR), { primary: '1.300,00 EUR', secondary: '130.000 cent' });
});
it('en bij crypto niet', () => {
assert.deepEqual(fmtAmount(50_000_000, BTC), { primary: '0,5 BTC', secondary: '50.000.000 sat' });
});
});
describe('isBaseAsset: is dit de eenheid waarin de app rekent', () => {
it('herkent het reken-asset op zijn symbool', () => {
assert.equal(isBaseAsset(EUR), true);
assert.equal(isBaseAsset(BTC), false);
});
it('kijkt NIET naar de asset-soort', () => {
const USD = { symbol: 'USD', kind: 'fiat', decimals: 2, unit: 'USD', subunit: 'cent', displayUnit: 'unit' };
assert.equal(isBaseAsset(USD), false);
});
it('is onwaar zonder asset', () => {
assert.equal(isBaseAsset(undefined), false);
assert.equal(isBaseAsset(null), false);
});
});
describe('subline: de tweede regel onder een waarde', () => {
it('rendert niets als er niets te tonen is', () => {
assert.equal(subline(''), '');
assert.equal(subline(null), '');
assert.equal(subline(undefined), '');
});
it('rendert de regel wél als er inhoud is', () => {
assert.equal(subline('<span class="val">€ 1,00</span>'),
'<div class="t-muted-cell t-subline"><span class="val">€ 1,00</span></div>');
});
});
describe('fmtPrice: de decimalen volgen de grootte', () => {
it('houdt een grote prijs op twee decimalen', () => {
assert.equal(fmtPrice(74884.62115428, 'eur'), '€ 74.884,62');
});
it('houdt twee decimalen vast als er niets achter de komma staat', () => {
assert.equal(fmtPrice(40000, 'eur'), '€ 40.000,00');
});
it('geeft een kleine prijs zijn cijfers', () => {
assert.equal(fmtPrice(0.00012345, 'eur'), '€ 0,00012345');
assert.equal(fmtPrice(1.23456789, 'eur'), '€ 1,2346');
});
it('weigert een onbekende munt in plaats van iets te verzinnen', () => {
assert.throws(() => fmtPrice(1, 'xyz'), /Unknown currency/);
});
});
describe('fmtMoney: symbool en decimalen per munt', () => {
it('laat euro en dollar ongewijzigd', () => {
assert.equal(fmtMoney(1234.5, 'eur'), '€ 1.234,50');
assert.equal(fmtMoney(1234.5, 'USD'), '$ 1.234,50');
assert.equal(fmtMoney(0.001, 'eur'), '€ 0,001', 'onder een halve cent komen er cijfers bij');
});
it('volgt de decimalen van de munt: een yen heeft er geen', () => {
assert.equal(fmtMoney(1234.5, 'JPY'), '¥ 1.235');
assert.equal(fmtPrice(1234.5, 'JPY'), '¥ 1.234,5');
});
it('kent elke munt uit de lijst, en weigert een code daarbuiten', () => {
for (const code of CONFIG.PORTFOLIO.BASE_CURRENCIES) assert.doesNotThrow(() => fmtMoney(1, code), code);
assert.throws(() => fmtMoney(1, 'XYZ'), /Unknown currency/);
});
});
describe('currencyName', () => {
it('geeft elke munt uit de lijst een Nederlandse naam met hoofdletter', () => {
assert.equal(currencyName('eur'), 'Euro');
for (const code of CONFIG.PORTFOLIO.BASE_CURRENCIES) {
const naam = currencyName(code);
assert.match(naam, /^[A-Z]/, `${code}: '${naam}'`);
assert.notEqual(naam, code, `${code} heeft geen naam van Intl gekregen`);
}
});
it('weigert een code buiten de lijst, net als fmtMoney', () => {
assert.throws(() => currencyName('XYZ'), /Unknown currency/);
});
});
describe('fmtPct', () => {
it('schrijft Nederlands: een komma en één decimaal', () => {
assert.equal(fmtPct(0.306), '+30,6%');
assert.equal(fmtPct(0.1), '+10,0%');
});
it('gebruikt het echte minteken en niet een koppelteken', () => {
assert.equal(fmtPct(-0.008), '−0,8%');
assert.ok(!fmtPct(-0.008).includes('-'), 'een koppelteken hoort er niet in te staan');
});
it('geeft een waarde die op nul afrondt GEEN teken, ook als hij echt negatief is', () => {
assert.equal(fmtPct(0), '0,0%');
assert.equal(fmtPct(-0.000167), '0,0%');
assert.equal(fmtPct(0.0004), '0,0%');
});
it('fine: twee decimalen alleen als één decimaal de beweging verbergt', () => {
assert.equal(fmtPct(0.0004, { fine: true }), '+0,04%');
assert.equal(fmtPct(-0.000167, { fine: true }), '−0,02%');
assert.equal(fmtPct(0.014, { fine: true }), '+1,4%', 'een gewone dagbeweging houdt één decimaal');
assert.equal(fmtPct(0.0006, { fine: true }), '+0,1%', 'wat op één decimaal te zien is, blijft zo');
assert.equal(fmtPct(0.00004, { fine: true }), '0,0%', 'ook op twee decimalen nul: dan blijft het 0,0%');
});
it('pctTone: rood bij −0,1%, geen kleur bij wat als 0,0% staat', () => {
assert.equal(pctTone(-0.001), 't-neg');
assert.equal(pctTone(0.001), 't-pos');
assert.equal(pctTone(-0.0004), '', 'staat als 0,0%');
assert.equal(pctTone(-0.0004, { fine: true }), 't-neg', 'met twee decimalen staat er −0,04%');
});
it('signs: geen, alleen negatief of allebei; de kleur blijft in elke stand', () => {
assert.equal(fmtPct(0.306), '+30,6%', 'zonder kleur beide tekens');
assert.equal(fmtPct(0.306, { signs: 'negative' }), '30,6%');
assert.equal(fmtPct(-0.008, { signs: 'negative' }), '−0,8%');
assert.equal(fmtPct(-0.008, { signs: 'none' }), '0,8%');
assert.equal(fmtPct(0.306, { signs: 'both' }), '+30,6%');
assert.equal(fmtPct(0.0004, { fine: true, signs: 'negative' }), '0,04%');
assert.equal(pctTone(-0.001, { signs: 'none' }), 't-neg');
assert.equal(pctTone(0.001, { signs: 'none' }), 't-pos');
});
it('fmtBaseSigned: euroteken, spatie, teken, bedrag; onder een cent geen teken', () => {
assert.equal(fmtBaseSigned(-4.77, 'negative'), '€ −4,77');
assert.equal(fmtBaseSigned(31.51, 'negative'), '€ 31,51');
assert.equal(fmtBaseSigned(31.51, 'both'), '€ +31,51');
assert.equal(fmtBaseSigned(-4.77, 'none'), '€ 4,77');
assert.equal(fmtBaseSigned(-1234.5, 'both'), '€ −1.234,50');
assert.equal(fmtBaseSigned(-0.004, 'both'), '€ 0,00');
});
it('colouredSigns leest de instelling', async () => {
await setAppSetting('result_signs', 'none');
assert.equal(colouredSigns(), 'none');
await setAppSetting('result_signs', 'both');
assert.equal(colouredSigns(), 'both');
});
it('houdt het teken zodra er wél iets te zien is', () => {
assert.equal(fmtPct(0.0006), '+0,1%');
assert.equal(fmtPct(-0.0006), '−0,1%');
});
it('rondt af op één decimaal en niet meer', () => {
assert.equal(fmtPct(0.12345), '+12,3%');
assert.equal(fmtPct(0.12678), '+12,7%');
});
it('geeft een lege tekst als er geen getal is, want dat is iets anders dan nul', () => {
assert.equal(fmtPct(null), '');
assert.equal(fmtPct(undefined), '');
assert.equal(fmtPct(NaN), '');
assert.equal(fmtPct(Infinity), '');
});
it('valt niet over een groot percentage met een duizendtalscheiding', () => {
assert.equal(fmtPct(10), '+1.000,0%');
assert.equal(fmtPct(20000), '+2.000.000,0%');
});
});
const { parseLocaleNumber } = await import('../../client/ui/format.js');
const { setLocale } = await import('../../client/i18n/index.js');
describe('opmaak en invoer volgen de taal', () => {
const inTaal = (code, fn) => { setLocale(code); try { fn(); } finally { setLocale('nl'); } };
it('leest de eigen opmaak terug, in het Nederlands en in het Engels', () => {
assert.equal(parseLocaleNumber('1.000,50'), 1000.5);
assert.equal(parseLocaleNumber('100.000.000'), 1e8);
inTaal('en', () => {
assert.equal(parseLocaleNumber('1,000.50'), 1000.5);
assert.equal(parseLocaleNumber('100,000,000'), 1e8);
});
});
it('neemt één scheidingsteken zonder drie cijfers erachter als decimaalteken, in elke taal', () => {
assert.equal(parseLocaleNumber('3.5'), 3.5);
assert.equal(parseLocaleNumber('0.500'), 0.5, 'na een kale nul is het geen duizendtal');
assert.equal(parseLocaleNumber('3,5'), 3.5);
inTaal('en', () => {
assert.equal(parseLocaleNumber('3,5'), 3.5);
assert.equal(parseLocaleNumber('1,000'), 1000);
});
});
it('geeft NaN zonder cijfers, zodat de aanroeper zelf kiest', () => {
assert.ok(Number.isNaN(parseLocaleNumber('')));
assert.ok(Number.isNaN(parseLocaleNumber('€ -')));
assert.equal(parseLocaleNumber('-1.234,5'), -1234.5);
});
it('formatteert in de actieve taal, ook na een wissel terug', () => {
inTaal('en', () => {
assert.equal(fmtMoney(1234.5, 'EUR'), '€ 1,234.50');
assert.equal(fmtPct(10), '+1,000.0%');
assert.equal(currencyName('USD'), 'US Dollar');
});
assert.equal(fmtMoney(1234.5, 'EUR'), '€ 1.234,50');
assert.equal(currencyName('USD'), 'Amerikaanse dollar');
});
});
+429
View File
@@ -0,0 +1,429 @@
// Generated, do not edit. sha256:43e27327deb8801c6e640676df136f116a9043c3e6d4e1f65b6469a9f90a6fa6
import { describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
const INSTELLING = { asset_show_name: 1, asset_show_symbol: 1 };
mock.module('../../client/app-settings.js', {
namedExports: { appSetting: (k) => INSTELLING[k], baseCurrency: () => 'EUR', setAppSetting: async () => {} },
});
const { fiatAtPrice, fiatValue, fiatHistorical } = await import('../../client/ui/fiat.js');
const { assetLabel, assetLabelHtml } = await import('../../client/ui/asset-label.js');
const { RANGE_DAYS, SPOT_RANGE_DAYS, rangeSeries, intradayStart, rangeCycleButton, wireRangeCycle, rangePointLabel, rangePointSources, namedDaySources } = await import('../../client/ui/range-cycle.js');
const { serviceOrder, matrixCardHtml } = await import('../../client/ui/source-matrix.js');
describe('fiatAtPrice: van kleinste eenheid naar een bedrag', () => {
it('rekent met de decimalen van het asset', () => {
assert.match(fiatAtPrice(100000, 8, 50000), /50,00/);
});
it('gebruikt acht decimalen als er geen meegegeven zijn', () => {
assert.equal(fiatAtPrice(100000000, undefined, 7), fiatAtPrice(100000000, 8, 7));
});
it('geeft een lege string als er geen prijs bekend is', () => {
assert.equal(fiatAtPrice(100, 8, null), '');
assert.equal(fiatAtPrice(100, 8, undefined), '');
});
it('rekent nul gewoon uit in plaats van hem als onbekend te behandelen', () => {
assert.match(fiatAtPrice(0, 8, 50000), /0,00/);
});
});
describe('fiatValue en fiatHistorical: de twee wegen naar leeg', () => {
it('geeft niets voor het reken-asset', () => {
assert.equal(fiatValue(12345, { id: 1, symbol: 'EUR', decimals: 2 }, 'eur'), '');
});
it('geeft niets zonder koers in de cache, en zonder asset', () => {
assert.equal(fiatValue(100, { id: 999, symbol: 'BTC', decimals: 8 }, 'eur'), '');
assert.equal(fiatValue(100, null, 'eur'), '');
assert.equal(fiatHistorical(100, null, '2026-03-01'), '');
assert.equal(fiatHistorical(100, { id: 999, decimals: 8 }, '2026-03-01'), '');
});
});
describe('assetLabel: de twee weergaveschuiven', () => {
const btc = { name: 'Bitcoin', symbol: 'BTC' };
it('toont naam en symbool samen', () => {
Object.assign(INSTELLING, { asset_show_name: 1, asset_show_symbol: 1 });
assert.equal(assetLabel(btc), 'Bitcoin (BTC)');
});
it('toont alleen de naam, of alleen het symbool', () => {
Object.assign(INSTELLING, { asset_show_name: 1, asset_show_symbol: 0 });
assert.equal(assetLabel(btc), 'Bitcoin');
Object.assign(INSTELLING, { asset_show_name: 0, asset_show_symbol: 1 });
assert.equal(assetLabel(btc), 'BTC');
});
it('valt terug op wat er wél is als de gekozen helft ontbreekt', () => {
Object.assign(INSTELLING, { asset_show_name: 1, asset_show_symbol: 0 });
assert.equal(assetLabel({ symbol: 'XAU' }), 'XAU');
Object.assign(INSTELLING, { asset_show_name: 0, asset_show_symbol: 1 });
assert.equal(assetLabel({ name: 'Goud' }), 'Goud');
});
it('leest de alias-velden van een JOIN-rij', () => {
Object.assign(INSTELLING, { asset_show_name: 1, asset_show_symbol: 1 });
assert.equal(assetLabel({ asset_name: 'Goud', asset_symbol: 'XAU' }), 'Goud (XAU)');
});
it('geeft voorrang aan het eigen veld boven de alias, ook als dat leeg is', () => {
Object.assign(INSTELLING, { asset_show_name: 1, asset_show_symbol: 0 });
assert.equal(assetLabel({ name: null, asset_name: 'Bitcoin', symbol: 'BTC' }), 'BTC');
});
it('geeft een lege string voor niets', () => {
assert.equal(assetLabel(null), '');
assert.equal(assetLabelHtml(null), '');
});
it('laat met `withoutSymbol` het symbool weg, want de hoeveelheid droeg het al', () => {
Object.assign(INSTELLING, { asset_show_name: 1, asset_show_symbol: 1 });
const met = assetLabelHtml({ asset_name: 'Proef zonder koers', asset_symbol: 'PRFN' });
const zonder = assetLabelHtml({ asset_name: 'Proef zonder koers', asset_symbol: 'PRFN' }, { withoutSymbol: true });
assert.match(met, /PRFN/, 'zonder de vlag blijft het gedrag ongewijzigd');
assert.match(zonder, /Proef zonder koers/);
assert.ok(!zonder.includes('PRFN'), zonder);
});
it('toont een naam die gelijk is aan het symbool één keer, ook met beide schuiven aan', () => {
Object.assign(INSTELLING, { asset_show_name: 1, asset_show_symbol: 1 });
assert.equal(assetLabel({ name: 'XRP', symbol: 'XRP' }), 'XRP');
assert.equal(assetLabel({ name: 'Xrp', symbol: 'XRP' }), 'Xrp');
assert.equal(assetLabel({ name: 'XRP Ledger', symbol: 'XRP' }), 'XRP Ledger (XRP)');
const h = assetLabelHtml({ name: 'XRP', symbol: 'XRP' });
assert.equal(h.match(/XRP/g).length, 1, h);
assert.ok(!h.includes('t-muted-cell'), h);
});
it('valt met `withoutSymbol` terug op het symbool als er geen naam te tonen is', () => {
Object.assign(INSTELLING, { asset_show_name: 1, asset_show_symbol: 1 });
assert.match(assetLabelHtml({ asset_symbol: 'PRFN' }, { withoutSymbol: true }), /PRFN/);
Object.assign(INSTELLING, { asset_show_name: 0, asset_show_symbol: 1 });
assert.match(assetLabelHtml({ asset_name: 'Bitcoin', asset_symbol: 'BTC' }, { withoutSymbol: true }), /BTC/);
});
});
describe('assetLabelHtml: bouwt HTML en escapet zelf', () => {
it('zet naam en symbool in hun eigen span', () => {
Object.assign(INSTELLING, { asset_show_name: 1, asset_show_symbol: 1 });
const h = assetLabelHtml({ name: 'Bitcoin', symbol: 'BTC' });
assert.match(h, /<span class="asset-name">Bitcoin<\/span>/);
assert.match(h, /<span class="t-muted-cell">BTC<\/span>/);
});
it('escapet een naam uit de database', () => {
Object.assign(INSTELLING, { asset_show_name: 1, asset_show_symbol: 0 });
const h = assetLabelHtml({ name: '<script>x</script> & co' });
assert.equal(h, '<span class="asset-name">&lt;script>x&lt;/script> &amp; co</span>');
assert.equal(h.includes('<script>'), false, 'de tag mag niet als tag overleven');
});
it('escapet GEEN aanhalingsteken, en dat is een grens en geen fout', () => {
Object.assign(INSTELLING, { asset_show_name: 1, asset_show_symbol: 0 });
assert.match(assetLabelHtml({ name: 'zeg "hallo"' }), /zeg "hallo"/,
'als dit ooit verandert: controleer of de aanroepers nog steeds alleen elementinhoud zijn');
});
});
describe('range-cycle: de gedeelde tijdvakknop', () => {
it('kent drie tijdvakken met hun dagen', () => {
assert.deepEqual(RANGE_DAYS, { week: 7, month: 30, year: 365 });
});
it('cyclet week naar maand naar jaar en weer terug', () => {
const gezien = [];
const wrap = {};
let huidig = 'week';
wireRangeCycle(wrap, () => huidig, (r) => { huidig = r; gezien.push(r); });
const klik = { target: { closest: (sel) => (sel === '.range-cycle-btn' ? {} : null) } };
wrap.onclick(klik); wrap.onclick(klik); wrap.onclick(klik);
assert.deepEqual(gezien, ['month', 'year', 'week']);
});
it('doet niets bij een klik naast de knop', () => {
let geroepen = 0;
const wrap = {};
wireRangeCycle(wrap, () => 'week', () => { geroepen++; });
wrap.onclick({ target: { closest: () => null } });
assert.equal(geroepen, 0);
});
it('levert een knop met het label van het huidige tijdvak', () => {
const h = rangeCycleButton('month');
assert.match(h, /range-cycle-btn/);
assert.match(h, /aria-label="/, 'een icoonknop hoort een toegankelijke naam te hebben');
});
it('de widgets met een live spot cyclen dag, week, maand, jaar', () => {
const gezien = [];
const wrap = {};
let huidig = 'day';
wireRangeCycle(wrap, () => huidig, (r) => { huidig = r; gezien.push(r); }, SPOT_RANGE_DAYS);
const klik = { target: { closest: (sel) => (sel === '.range-cycle-btn' ? {} : null) } };
for (let i = 0; i < 4; i++) wrap.onclick(klik);
assert.deepEqual(gezien, ['week', 'month', 'year', 'day']);
});
});
describe('rangeSeries', () => {
const DAG = 86400000;
const nu = Date.parse('2026-10-09T13:00:00Z');
const middernacht = Math.floor(nu / DAG) * DAG;
const maand = Array.from({ length: 30 }, (_, i) => [middernacht - (30 - i) * DAG, 100 + i]);
it('dag: precies de slotkoers van gisteren en de spot', () => {
assert.deepEqual(rangeSeries(maand, 'day', 200, nu), [[middernacht - DAG, 129], [middernacht, 200]]);
});
it('dag zonder spot: de laatste twee slotkoersen', () => {
assert.deepEqual(rangeSeries(maand, 'day', null, nu), maand.slice(-2));
});
it('week: de dagen binnen zeven dagen, met de spot als vandaag', () => {
const uit = rangeSeries(maand, 'week', 200, nu);
assert.equal(uit.length, 7);
assert.deepEqual(uit.at(-1), [middernacht, 200]);
});
it('te weinig punten in het tijdvak: de hele reeks', () => {
const kort = [[middernacht - 40 * DAG, 90], [middernacht - 35 * DAG, 95]];
assert.equal(rangeSeries(kort, 'week', null, nu), kort);
});
const binnen = [[middernacht + 3600_000, 130], [middernacht + 7200_000, 135]];
it('dag met reeks: slotkoers aan het eind van zijn dag, de reeks, de spot op nu', () => {
assert.deepEqual(rangeSeries(maand, 'day', 200, nu, binnen),
[[middernacht, 129], ...binnen, [nu, 200]]);
});
it('dag met reeks: punten vóór het einde van de slotdag of na nu vallen weg', () => {
const rommel = [[middernacht - 60_000, 1], ...binnen, [nu + 60_000, 2]];
assert.deepEqual(rangeSeries(maand, 'day', 200, nu, rommel), [[middernacht, 129], ...binnen, [nu, 200]]);
});
it('dag met een lege of onbruikbare reeks: de rechte lijn van nu', () => {
const rechte = [[middernacht - DAG, 129], [middernacht, 200]];
assert.deepEqual(rangeSeries(maand, 'day', 200, nu, []), rechte);
assert.deepEqual(rangeSeries(maand, 'day', 200, nu, [[nu + 1, 5]]), rechte);
});
it('dag met reeks maar zonder spot: geen reeks, want er is geen eindpunt', () => {
assert.deepEqual(rangeSeries(maand, 'day', null, nu, binnen), maand.slice(-2));
});
it('de reeks raakt de andere tijdvakken niet', () => {
assert.deepEqual(rangeSeries(maand, 'week', 200, nu, binnen), rangeSeries(maand, 'week', 200, nu));
});
});
describe('rangePointLabel: de hint bij een punt', () => {
const datum = { day: 'numeric', month: 'short', year: 'numeric' };
it('binnen de dag: de prijs met de lokale datum en tijd', () => {
const ms = Date.UTC(2026, 9, 16, 13, 7);
const lokaal = new Date(ms).toLocaleString('nl-NL', { ...datum, hour: '2-digit', minute: '2-digit' });
assert.equal(rangePointLabel('day', '€ 72.386', ms), `€ 72.386\n${lokaal}`);
assert.match(lokaal, /2026/);
});
it('in een ander tijdvak: de kalenderdag van het punt, zonder tijd', () => {
const middernacht = Date.UTC(2026, 9, 16);
const dag = new Date(middernacht).toLocaleDateString('nl-NL', { ...datum, timeZone: 'UTC' });
assert.match(dag, /^16 okt\.? 2026$/);
for (const range of Object.keys(RANGE_DAYS)) assert.equal(rangePointLabel(range, '€ 72.386', middernacht), `€ 72.386\n${dag}`);
});
it('ook ten westen van Greenwich, waar middernacht UTC nog de vorige dag is', () => {
const oud = process.env.TZ;
process.env.TZ = 'America/New_York';
try {
assert.match(rangePointLabel('week', '€ 1', Date.UTC(2026, 9, 16)), /16 okt\.? 2026$/);
} finally {
if (oud === undefined) delete process.env.TZ; else process.env.TZ = oud;
}
});
it('zonder moment: alleen de prijs', () => {
assert.equal(rangePointLabel('day', '€ 72.386', undefined), '€ 72.386');
assert.equal(rangePointLabel('week', '€ 72.386', NaN), '€ 72.386');
});
it('met een bron: die komt als laatste, en zonder bron blijft de hint zoals hij was', () => {
const middernacht = Date.UTC(2026, 9, 16);
const dag = new Date(middernacht).toLocaleDateString('nl-NL', { ...datum, timeZone: 'UTC' });
assert.equal(rangePointLabel('week', '€ 1', middernacht, 'CoinGecko'), `€ 1\n${dag}\nCoinGecko`);
assert.equal(rangePointLabel('week', '€ 1', middernacht, null), `€ 1\n${dag}`);
assert.equal(rangePointLabel('week', '€ 1', NaN, 'CoinGecko'), '€ 1\nCoinGecko');
});
it('zet elk deel op een eigen regel, de prijs bovenaan', () => {
const regels = rangePointLabel('week', '€ 1', Date.UTC(2026, 9, 16), 'CoinGecko').split('\n');
assert.equal(regels.length, 3);
assert.equal(regels[0], '€ 1');
});
});
describe('rangePointSources: de bron per punt', () => {
const DAG = 86400000;
const d0 = Date.UTC(2026, 9, 12);
const nu = Date.UTC(2026, 9, 16, 14, 30);
const vandaag = Date.UTC(2026, 9, 16);
const grid = [[d0, 10], [d0 + DAG, 11], [d0 + 2 * DAG, 11], [d0 + 3 * DAG, 11]];
const days = new Map([[d0, 'CoinGecko'], [d0 + DAG, 'Atreides Services']]);
const dagTekst = ms => new Date(ms).toLocaleDateString('nl-NL', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' });
it('een week: eigen dagen bij naam, doorgetrokken dagen met de dag van de koers, de spot als laatste', () => {
const use = rangeSeries(grid, 'week', 12, nu);
const bron = rangePointSources(use, { range: 'week', spot: 12, nowMs: nu, live: 'Atreides Services (Bitvavo)', days });
assert.equal(use[use.length - 1][0], vandaag);
assert.equal(bron.get(d0), 'CoinGecko');
assert.equal(bron.get(d0 + DAG), 'Atreides Services');
assert.equal(bron.get(d0 + 2 * DAG), `Atreides Services, koers van ${dagTekst(d0 + DAG)}`);
assert.equal(bron.get(vandaag), 'Atreides Services (Bitvavo)');
});
it('zonder spot is het laatste punt geen actuele koers', () => {
const use = rangeSeries(grid, 'week', null, nu);
const bron = rangePointSources(use, { range: 'week', spot: null, nowMs: nu, live: 'Bitvavo', days });
assert.equal(bron.get(d0 + 3 * DAG), `Atreides Services, koers van ${dagTekst(d0 + DAG)}`);
assert.ok(![...bron.values()].includes('Bitvavo'));
});
it('binnen de dag: een minuutpunt met een eigen bron draagt die, de rest de bron van de reeks', () => {
const intraday = [[vandaag + 3600000, 11.5], [vandaag + 7200000, 11.8]];
const use = rangeSeries(grid, 'day', 12, nu, intraday);
const intradayAt = new Map([[vandaag + 3600000, 'Atreides Services (bitvavo)']]);
const bron = rangePointSources(use, { range: 'day', spot: 12, nowMs: nu, live: 'Spot', intraday: 'Minuten', intradayAt, days });
assert.equal(bron.get(vandaag + 3600000), 'Atreides Services (bitvavo)');
assert.equal(bron.get(vandaag + 7200000), 'Minuten');
assert.equal(bron.get(nu), 'Spot', 'de spot blijft de spot, ook met een kaart per punt');
});
it('binnen de dag: de minuutreeks tussen de slotkoers en de spot', () => {
const intraday = [[vandaag + 3600000, 11.5], [vandaag + 7200000, 11.8]];
const use = rangeSeries(grid, 'day', 12, nu, intraday);
const bron = rangePointSources(use, { range: 'day', spot: 12, nowMs: nu, live: 'Spot', intraday: 'Minuten', days });
assert.equal(use.length, 4);
assert.equal(bron.get(use[0][0]), `Atreides Services, koers van ${dagTekst(d0 + DAG)}`);
assert.equal(bron.get(use[1][0]), 'Minuten');
assert.equal(bron.get(use[2][0]), 'Minuten');
assert.equal(bron.get(nu), 'Spot');
});
it('binnen de dag zonder reeks: twee punten, en geen van beide heet minuutreeks', () => {
const use = rangeSeries(grid, 'day', 12, nu, null);
const bron = rangePointSources(use, { range: 'day', spot: 12, nowMs: nu, live: 'Spot', intraday: 'Minuten', days });
assert.equal(use.length, 2);
assert.equal(bron.get(vandaag), 'Spot');
assert.notEqual(bron.get(use[0][0]), 'Minuten');
});
it('zonder iets te zeggen: geen bron, ook niet voor een dag vóór de eerste eigen rij', () => {
assert.equal(rangePointSources([], { range: 'week' }).size, 0);
assert.equal(rangePointSources(null, { range: 'week' }).size, 0);
const bron = rangePointSources([[d0 - DAG, 9], [d0, 10]], { range: 'week', nowMs: nu, days });
assert.equal(bron.has(d0 - DAG), false);
assert.equal(bron.get(d0), 'CoinGecko');
});
it('namedDaySources: ids naar namen, en een onbekende provider valt weg in plaats van verkeerd te heten', () => {
const dagBron = (providerId, sourceName = null) => ({ providerId, sourceName });
const byDay = new Map([[d0, dagBron(1)], [d0 + DAG, dagBron(2)], [d0 + 2 * DAG, dagBron(99)]]);
const named = namedDaySources(byDay, [{ id: 1, name: 'CoinGecko' }, { id: 2, name: 'Atreides Services' }]);
assert.deepEqual([...named], [[d0, 'CoinGecko'], [d0 + DAG, 'Atreides Services']]);
assert.equal(namedDaySources(byDay, null).size, 0);
});
it('namedDaySources: de bron achter een dagkoers van de dienst staat erbij, zoals bij elke koers van de dienst', () => {
const byDay = new Map([[d0, { providerId: 2, sourceName: 'Bitvavo' }]]);
const named = namedDaySources(byDay, [{ id: 2, name: 'Atreides Services' }]);
assert.equal(named.get(d0), 'Atreides Services (Bitvavo)');
});
});
describe('intradayStart: waar de reeks binnen de dag begint', () => {
const DAG = 86400000;
const nu = Date.parse('2026-10-09T13:00:00Z');
const middernacht = Math.floor(nu / DAG) * DAG;
it('het eind van de laatste slotdag vóór vandaag', () => {
assert.equal(intradayStart([[middernacht - 2 * DAG, 1], [middernacht - DAG, 2]], nu), middernacht);
});
it('een opgeslagen waarde van vandaag telt niet als slotkoers', () => {
assert.equal(intradayStart([[middernacht - DAG, 2], [middernacht, 3]], nu), middernacht);
});
it('na een weekend zonder slot: vanaf het eind van de laatste', () => {
assert.equal(intradayStart([[middernacht - 3 * DAG, 1]], nu), middernacht - 2 * DAG);
});
it('zonder reeks: vandaag middernacht', () => {
assert.equal(intradayStart([], nu), middernacht);
assert.equal(intradayStart(null, nu), middernacht);
});
});
describe('serviceOrder: de plek van de dienst in de keten', () => {
const app = (providerId, rank) => ({ providerId, rank, own: false });
const zelf = (providerId, rank) => ({ providerId, rank, own: true });
it('eerst: zelf gekozen, dan de dienst, dan de keuze van de app', () => {
const { ranks, service } = serviceOrder([app(1, 1), zelf(2, 2), app(3, 3)], 'first');
assert.deepEqual([...ranks], [[2, 1], [1, 3], [3, 4]]);
assert.equal(service, 2);
});
it('eerst zonder eigen keuze: de dienst bovenaan', () => {
const { ranks, service } = serviceOrder([app(1, 1)], 'first');
assert.equal(service, 1);
assert.equal(ranks.get(1), 2);
});
it('als terugval: alle gekozen bronnen, dan de dienst', () => {
const { ranks, service } = serviceOrder([zelf(2, 2), app(1, 1)], 'fallback');
assert.deepEqual([...ranks], [[1, 1], [2, 2]]);
assert.equal(service, 3);
});
});
describe('matrixCardHtml: de dienst als vaste kolom', () => {
const bron = { id: 7, name: 'Bitvavo', isActive: 1, coverage: ['BTC'], endpoints: [{ capability: 'spot' }] };
const btc = { id: 1, symbol: 'BTC', kind: 'crypto', name: 'Bitcoin' };
const ctx = (mode, own, covered = [1]) => ({
alle: [bron], providers: [bron], rateSource: new Map(),
cell: new Map([['1|spot|7', { rank: 1, proposed: false, own }]]),
service: { mode, covered: new Set(covered) },
});
const nummers = html => [...html.matchAll(/class="mx-rank[^"]*">(\d+)</g)].map(m => Number(m[1]));
it('stand eerst, Bitvavo door de app gekozen: de dienst op 1, Bitvavo op 2', () => {
const html = matrixCardHtml(btc, ctx('first', false));
assert.match(html, /Atreides Services/);
assert.deepEqual(nummers(html), [2, 1]);
assert.match(html, /mx-svc/);
});
it('stand eerst, Bitvavo zelf gekozen: Bitvavo op 1, de dienst op 2', () => {
assert.deepEqual(nummers(matrixCardHtml(btc, ctx('first', true))), [1, 2]);
});
it('de dienstkolom is niet klikbaar', () => {
const html = matrixCardHtml(btc, ctx('first', false));
assert.doesNotMatch(html, /class="mx-cell"[^>]*>[^<]*<span class="mx-rank[^"]*is-service/);
});
it('stand uit, of een asset dat de dienst niet dekt: geen kolom', () => {
assert.doesNotMatch(matrixCardHtml(btc, ctx('off', false)), /Atreides Services/);
assert.doesNotMatch(matrixCardHtml(btc, ctx('first', false, [])), /Atreides Services/);
});
});
+73
View File
@@ -0,0 +1,73 @@
// Generated, do not edit. sha256:aba0586264c0c6a96c6db4f231f8de0168b2d96c5e9e95d8a830ca04a1e9dd99
import { describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
mock.module('../../client/app-settings.js', { namedExports: { appSetting: k => (k === 'asset_show_name' ? 1 : 0), baseCurrency: () => 'EUR' } });
const { originSummaryLines, originLines, originsHtml } = await import('../../client/ui/price-origin.js');
const asset = (id, symbol, name, kind = 'crypto') => ({ id, symbol, name, kind, decimals: 8, unit: symbol, subunit: null, displayUnit: 'unit' });
const BTC = asset(1, 'BTC', 'Bitcoin');
const ETH = asset(2, 'ETH', 'Ether');
const GOUD = { ...asset(3, 'XAU', 'Goud', 'metal'), unit: 'ozt', subunit: 'g' };
const EUR = { ...asset(4, 'EUR', 'Euro', 'fiat'), unit: 'EUR' };
const o = (kind, extra = {}) => ({ day: '2025-01-01', fiatPrice:100, kind, ageDays: 0, deviation: null, source: 'Bron', sourceDay: '2025-01-01', moment: null, ...extra });
describe('originSummaryLines', () => {
it('telt per soort en noemt de zwakste, en die bepaalt de nauwkeurigheid', () => {
const r = originSummaryLines([
{ origin: o('close'), asset: BTC },
{ origin: o('spot'), asset: ETH },
{ origin: o('carried', { sourceDay: '2024-12-29', ageDays: 3 }), asset: GOUD },
]);
assert.equal(r[0], 'Rust op 3 koersen');
assert.equal(r[1], '1 slotkoers, 1 afgeleid, 1 doorgetrokken');
assert.match(r[2], /^Zwakste: Goud, doorgetrokken van 29 dec\.? 2024 \(Bron\), 3 dagen oud$/);
assert.equal(r.at(-1), 'Nauwkeurigheid: laag');
});
it('noemt geen zwakste als alles sterk is', () => {
const r = originSummaryLines([{ origin: o('close'), asset: BTC }, { origin: o('live'), asset: ETH }]);
assert.deepEqual(r, ['Rust op 2 koersen', '1 slotkoers, 1 vandaag', 'Nauwkeurigheid: hoog']);
});
it('kiest van twee doorgetrokken koersen de oudste als zwakste', () => {
const r = originSummaryLines([
{ origin: o('carried', { ageDays: 2 }), asset: BTC },
{ origin: o('carried', { ageDays: 9 }), asset: ETH },
]);
assert.match(r[2], /^Zwakste: Ether, /);
assert.equal(r[1], '2 doorgetrokken');
});
it('telt hetzelfde asset op twee rekeningen als één koers, en geeft dan de gewone herkomst', () => {
const r = originSummaryLines([{ origin: o('close'), asset: BTC }, { origin: o('close'), asset: BTC }]);
assert.deepEqual(r, originLines(o('close'), BTC));
});
it('laat de euro weg en geeft niets als er niets is', () => {
assert.equal(originSummaryLines([{ origin: o('close'), asset: EUR }]).length, 0);
assert.equal(originsHtml([]), '');
});
it('noemt een post zonder koers naast één met, en zegt bij welke asset de koers hoort', () => {
const r = originSummaryLines([{ origin: o('close'), asset: GOUD }, { origin: null, asset: ETH }]);
assert.match(r[0], /^Goud: Koers /);
assert.equal(r[1], 'Slotkoers van Bron');
assert.equal(r.at(-2), 'Geen koers voor Ether: telt als nul mee');
assert.equal(r.at(-1), 'Nauwkeurigheid: onvolledig');
assert.ok(!r.includes('Nauwkeurigheid: hoog'), 'geen tweede, tegenstrijdige nauwkeurigheid');
});
it('noemt een ontbrekende post ook naast meerdere koersen, en ook als er geen enkele koers is', () => {
const r = originSummaryLines([{ origin: o('close'), asset: BTC }, { origin: o('close'), asset: GOUD },
{ origin: { ...o('close'), fiatPrice:null }, asset: ETH }, { origin: null, asset: ETH }]);
assert.deepEqual(r, ['Rust op 2 koersen', '2 slotkoersen', 'Geen koers voor Ether: telt als nul mee', 'Nauwkeurigheid: onvolledig']);
assert.deepEqual(originSummaryLines([{ origin: null, asset: ETH }]),
['Geen koers voor Ether: telt als nul mee', 'Nauwkeurigheid: onvolledig']);
});
it('maakt de i een waarschuwing als er een koers ontbreekt, en alleen dan', () => {
assert.match(originsHtml([{ origin: o('close'), asset: GOUD }, { origin: null, asset: ETH }]), /class="po-btn is-warn"/);
assert.match(originsHtml([{ origin: o('close'), asset: GOUD }]), /class="po-btn"/);
});
});
+100
View File
@@ -0,0 +1,100 @@
// Generated, do not edit. sha256:e6372fe91ca8e6ef9c9213910791d9f4680d2b61f799f7e5cca275bcd9a9d482
import { describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
import { vasteRekenvaluta } from '../helpers/rekenvaluta.mjs';
await vasteRekenvaluta(mock, 'EUR');
const { originLines, originHtml, bookingOriginLines } = await import('../../client/ui/price-origin.js');
const BTC = { kind: 'crypto', decimals: 8, unit: 'BTC', subunit: 'sat', displayUnit: 'unit' };
const GOUD = { kind: 'metal', unit: 'ozt', subunit: 'g', displayUnit: 'unit' };
const basis = { day: '2025-01-01', fiatPrice:90000, ageDays: 0, deviation: null, source: 'Bitvavo', sourceDay: '2025-01-01', moment: null };
describe('originLines: per soort de zin die de gebruiker leest', () => {
it('een slotkoers noemt de bron en is sterk', () => {
const r = originLines({ ...basis, kind: 'close' }, BTC);
assert.match(r[0], /^Koers .*90\.000.* per BTC op 1 jan\.? 2025$/);
assert.equal(r[1], 'Slotkoers van Bitvavo');
assert.equal(r.at(-1), 'Nauwkeurigheid: hoog');
});
it('een afgeleide koers noemt het moment en de afwijking aan beide kanten', () => {
const r = originLines({ ...basis, kind: 'spot', moment: '2025-01-01T09:12:00.000Z', deviation: { before: 1.8, after: -0.4 } }, BTC);
assert.match(r[1], /^Afgeleid uit de spot van \d\d:\d\d bij Bitvavo, geen slotkoers$/);
assert.equal(r[2], '+1,8% naast de slotkoers ervoor, -0,4% naast die erna');
assert.equal(r.at(-1), 'Nauwkeurigheid: lager');
});
it('een afgeleide koers zonder slotkoers erna noemt alleen de kant die er is', () => {
const r = originLines({ ...basis, kind: 'spot', deviation: { before: 2, after: null } }, BTC);
assert.equal(r[2], '+2% naast de slotkoers ervoor');
});
it('een doorgetrokken koers noemt zijn brondag en leeftijd, en is zwak', () => {
const r = originLines({ ...basis, day: '2025-01-01', kind: 'carried', sourceDay: '2024-12-29', ageDays: 3 }, BTC);
assert.match(r[1], /^Doorgetrokken van 29 dec\.? 2024 \(Bitvavo\), 3 dagen oud$/);
assert.equal(r.at(-1), 'Nauwkeurigheid: laag');
});
it('met de hand en live zeggen dat, en metaal staat per ozt', () => {
assert.equal(originLines({ ...basis, kind: 'manual' }, GOUD)[1], 'Met de hand ingevoerd');
assert.match(originLines({ ...basis, kind: 'manual' }, GOUD)[0], / per ozt op /);
assert.equal(originLines({ ...basis, kind: 'live', source: null }, BTC)[1], 'Actuele koers, die nog beweegt');
assert.equal(originLines({ ...basis, kind: 'live', source: null }, BTC).at(-1), 'Nauwkeurigheid: hoog');
});
it('noemt nooit een percentage als nauwkeurigheid', () => {
for (const kind of ['close', 'manual', 'live', 'spot', 'carried']) {
const laatste = originLines({ ...basis, kind }, BTC).at(-1);
assert.doesNotMatch(laatste, /%/, `${kind}: ${laatste}`);
}
});
it('geeft niets zonder herkomst of zonder koers', () => {
assert.deepEqual(originLines(null, BTC), []);
assert.deepEqual(originLines({ ...basis, kind: 'close', fiatPrice:null }, BTC), []);
assert.equal(originHtml(null, BTC), '');
});
});
describe('bookingOriginLines: de prijs van een boeking', () => {
const boeking = extra => ({ fiatPrice: 90000, occurredAt: '2025-03-04T14:32:00.000Z', priceOrigin: 'paid', ...extra });
it('een betaalde prijs is hoog', () => {
const r = bookingOriginLines(boeking(), BTC);
assert.match(r[0], /^Prijs .*90\.000.* per BTC$/);
assert.equal(r[1], 'Betaald: de prijs die je zelf invoerde');
assert.equal(r.at(-1), 'Nauwkeurigheid: hoog');
});
it('een opgezochte dagkoers zegt dat hij geen koers van het moment is, en is lager', () => {
const r = bookingOriginLines(boeking({ priceOrigin: 'market_day' }), BTC);
assert.match(r[1], /^Dagkoers van 4 mrt\.? 2025, opgezocht bij het boeken$/);
assert.equal(r[2], 'Een dagkoers is niet de koers van het moment zelf');
assert.equal(r.at(-1), 'Nauwkeurigheid: lager');
});
it('noemt de dagkoers uit de historie als die er nog precies zo staat, en een doorgetrokken is laag', () => {
const dagHerkomst = { ...basis, day: '2025-03-04', kind: 'carried', sourceDay: '2025-03-01', ageDays: 3 };
const r = bookingOriginLines(boeking({ priceOrigin: 'market_day' }), BTC, dagHerkomst);
assert.match(r[1], /^Dagkoers van 4 mrt\.? 2025: doorgetrokken van 1 mrt\.? 2025 \(Bitvavo\), 3 dagen oud$/);
assert.equal(r.at(-1), 'Nauwkeurigheid: laag');
});
it('een gift heeft geen prijs en dus geen hover, een onbekende herkomst zegt dat', () => {
assert.deepEqual(bookingOriginLines(boeking({ priceOrigin: 'none', fiatPrice: 0 }), BTC), []);
const r = bookingOriginLines(boeking({ priceOrigin: null }), BTC);
assert.equal(r[1], 'Niet vastgelegd of deze prijs betaald of opgezocht is');
assert.equal(r.at(-1), 'Nauwkeurigheid: onbekend');
});
});
describe('originHtml', () => {
it('escapet een bronnaam met markup in het attribuut, en de tekst gaat mee', () => {
const html = originHtml({ ...basis, kind: 'close', source: '"><img src=x>' }, BTC);
assert.doesNotMatch(html, /<img/);
assert.match(html, /data-origin-lines="\[/);
assert.match(html, /title=""/, 'onderdrukt de title van de cel eromheen');
});
});
+107
View File
@@ -0,0 +1,107 @@
// Generated, do not edit. sha256:f04b67a8f0dbd9eb295b842585345e76f56ef4fd377d5010897bcf893dbd0395
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { installDom } from '../helpers/mini-dom.mjs';
const dom = installDom();
const { setPageHint, clearPageHint, configureHint, SAMPLES } = await import('../../client/ui/hint.js');
const mount = dom.document.createElement('div');
mount.id = 'page-hint';
dom.document.body.appendChild(mount);
const page = dom.host();
const gone = dom.document.createElement('div');
describe('setPageHint', () => {
beforeEach(() => { configureHint(); mount.innerHTML = ''; });
it('puts down one icon with its panel', () => {
setPageHint(page, { title: 'What you see', rows: ['A row.'] });
assert.equal(mount.querySelectorAll('.hint-btn').length, 1);
assert.equal(mount.querySelector('.hint-panel .hint-row').textContent, 'A row.');
});
it('ignores a page that is no longer on screen, and a call without a host', () => {
setPageHint(gone, { title: 'Of the previous page', rows: ['Does not belong here.'] });
setPageHint(undefined, { title: 'No host', rows: [] });
assert.equal(mount.innerHTML, '');
});
it('escapes the title, in the attribute and in the heading', () => {
setPageHint(page, { title: 'A "title" & <more>', rows: [] });
assert.equal(mount.querySelector('.hint-btn').getAttribute('aria-label'), 'A "title" & <more>');
assert.equal(mount.querySelector('.hint-panel > .t-label').textContent, 'A "title" & <more>');
assert.equal(mount.querySelector('more'), null);
});
it('a row without a sample keeps the indent of a row with one', () => {
setPageHint(page, { title: 'x', rows: ['Bare.', { sample: SAMPLES.dot, text: 'With a dot.' }] });
assert.equal(mount.querySelectorAll('.hint-row .hint-sample').length, 2);
assert.ok(mount.querySelectorAll('.hint-row')[1].querySelector('.hint-dot'));
});
it('a heading is a heading and not a row', () => {
setPageHint(page, { title: 'x', rows: [{ heading: 'Box 3' }, 'A row.'] });
assert.equal(mount.querySelector('.hint-heading').textContent, 'Box 3');
assert.equal(mount.querySelectorAll('.hint-row').length, 1);
});
it('without rows there is still a panel', () => {
setPageHint(page, { title: 'x' });
assert.ok(mount.querySelector('.hint-panel'));
});
});
describe('clearPageHint', () => {
it('without configuration: empty', () => {
configureHint();
setPageHint(page, { title: 'x', rows: ['A row.'] });
clearPageHint();
assert.equal(mount.innerHTML, '');
});
});
describe('configureHint', () => {
beforeEach(() => { mount.innerHTML = ''; });
it('puts the lead on every legend, right under the title and before the rows', () => {
configureHint({ lead: '<div class="hint-row hint-warn">Lead</div>' });
setPageHint(page, { title: 'x', rows: ['A row.'] });
const panel = mount.querySelector('.hint-panel');
assert.deepEqual(panel.children.map(c => c.className), ['t-label', 'hint-row hint-warn', 'hint-row']);
configureHint();
});
it('with an empty legend configured, clearing leaves that legend with only the lead', () => {
configureHint({ lead: '<div class="hint-warn">Lead</div>', empty: { title: 'Missing', className: 'is-fallback' } });
setPageHint(page, { title: 'x', rows: ['A row.'] });
clearPageHint();
assert.ok(mount.querySelector('.hint.is-fallback'));
assert.equal(mount.querySelector('.hint-btn').getAttribute('aria-label'), 'Missing');
assert.ok(mount.querySelector('.hint-warn'));
assert.equal(mount.querySelectorAll('.hint-row').length, 0);
configureHint();
});
});
describe('a panel that would scroll gets wider on opening', () => {
it('is-wide on opening when the content is taller than the panel, and off again when it fits', () => {
configureHint();
setPageHint(page, { title: 'x', rows: ['A row.'] });
const panel = mount.querySelector('.hint-panel');
Object.defineProperty(panel, 'scrollHeight', { value: 500, configurable: true });
Object.defineProperty(panel, 'clientHeight', { value: 300, configurable: true });
dom.fire(mount, 'pointerenter');
assert.ok(panel.classList.contains('is-wide'));
Object.defineProperty(panel, 'scrollHeight', { value: 300, configurable: true });
dom.fire(mount, 'focusin');
assert.equal(panel.classList.contains('is-wide'), false);
});
it('binds its listeners once per mount, however often a page announces', () => {
const before = mount.listeners.length;
setPageHint(page, { title: 'a', rows: [] });
setPageHint(page, { title: 'b', rows: [] });
assert.equal(mount.listeners.length, before);
});
});
+93
View File
@@ -0,0 +1,93 @@
// Generated, do not edit. sha256:62bcb62c65034fb71f6266f0193d341686cd42445f45be42aee861e04fc09615
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { groupStanding, standingGroupingOf, standingToday, standingShowsToday } from '../../client/widgets/standing-groups.js';
const beurs = { id: 1, name: 'Bitvavo', kind: 'exchange' };
const kluis = { id: 2, name: 'Kluis', kind: 'vault' };
const koude = { id: 3, name: 'Trezor', kind: 'wallet' };
const geen = { id: null, name: 'Zonder account', isUnassigned: true };
const btc = { id: 10, kind: 'crypto' };
const eth = { id: 11, kind: 'crypto' };
const goud = { id: 12, kind: 'metal' };
const RIJEN = [
{ account: beurs, asset: btc, value: 100, origin: 'spot' },
{ account: beurs, asset: eth, value: 50, origin: 'spot' },
{ account: koude, asset: btc, value: 300, origin: 'spot' },
{ account: kluis, asset: goud, value: 200, origin: 'day' },
{ account: geen, asset: goud, value: 20, origin: 'day' },
{ account: koude, asset: eth, value: null, origin: null },
];
const TOTAAL = 670;
const som = groepen => groepen.reduce((s, g) => s + g.value, 0);
const waarden = groepen => groepen.map(g => [g.key, g.value]);
describe('groupStanding', () => {
it('per account, hoogste eerst, met de rij zonder account als eigen groep', () => {
const g = groupStanding(RIJEN, 'account');
assert.deepEqual(waarden(g), [['a3', 300], ['a2', 200], ['a1', 150], ['none', 20]]);
assert.equal(som(g), TOTAAL);
});
it('per accountsoort: twee beursrijen worden één, zonder account blijft apart', () => {
const g = groupStanding(RIJEN, 'accountKind');
assert.deepEqual(waarden(g), [['wallet', 300], ['vault', 200], ['exchange', 150], ['none', 20]]);
assert.equal(som(g), TOTAAL);
});
it('per asset: dezelfde munt op twee accounts telt op', () => {
const g = groupStanding(RIJEN, 'asset');
assert.deepEqual(waarden(g), [['s10', 400], ['s12', 220], ['s11', 50]]);
assert.equal(som(g), TOTAAL);
});
it('per assetsoort', () => {
const g = groupStanding(RIJEN, 'assetKind');
assert.deepEqual(waarden(g), [['crypto', 450], ['metal', 220]]);
assert.equal(som(g), TOTAAL);
});
it('draagt de herkomst van elke koers mee, ook van een rij zonder koers', () => {
const crypto = groupStanding(RIJEN, 'assetKind').find(g => g.key === 'crypto');
assert.equal(crypto.origins.length, 4);
});
it('valt bij een onbekende of ontbrekende groepering terug op per account', () => {
assert.equal(standingGroupingOf(undefined), 'account');
assert.equal(standingGroupingOf('perSoort'), 'account');
assert.deepEqual(waarden(groupStanding(RIJEN, 'onzin')), waarden(groupStanding(RIJEN, 'account')));
});
});
describe('standingToday', () => {
const VANDAAG = { total: 17, perAsset: new Map([[10, 12], [11, -5], [12, 10]]) };
it('per asset: elk asset één keer, hoe veel accounts het ook heeft', () => {
assert.deepEqual([...standingToday(RIJEN, 'asset', VANDAAG)], [['s10', 12], ['s11', -5], ['s12', 10]]);
});
it('per assetsoort: de assets van die soort opgeteld', () => {
assert.deepEqual([...standingToday(RIJEN, 'assetKind', VANDAAG)], [['crypto', 7], ['metal', 10]]);
});
it('per account niets, want de dagreeks kent geen accounts', () => {
assert.equal(standingToday(RIJEN, 'account', VANDAAG).size, 0);
assert.equal(standingToday(RIJEN, 'accountKind', VANDAAG).size, 0);
});
it('niets zonder resultaat van vandaag', () => {
assert.equal(standingToday(RIJEN, 'asset', null).size, 0);
});
});
describe('standingShowsToday', () => {
it('staat aan zonder instelling, ook bij een widget zonder parameters', () => {
assert.equal(standingShowsToday(null), true);
assert.equal(standingShowsToday({ groupBy: 'asset' }), true);
});
it('staat alleen uit als de instelling false is', () => {
assert.equal(standingShowsToday({ showToday: false }), false);
assert.equal(standingShowsToday({ showToday: true }), true);
});
});
+40
View File
@@ -0,0 +1,40 @@
// Generated, do not edit. sha256:c99f1dcd61aab43d76698a5a706d042e6be9c41ba3337187a7c2f0efa590c086
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { glowChoice, glowPatch } from '../../client/pages/settings.js';
describe('glowChoice', () => {
it('toont in Glas de beweging als keuze', () => {
assert.equal(glowChoice({ profile: 'glass', glow: 'on', motion: 'step' }), 'step');
assert.equal(glowChoice({ profile: 'glass', glow: 'on', motion: 'always' }), 'always');
assert.equal(glowChoice({ profile: 'glass', glow: 'on', motion: 'off' }), 'still');
});
it('toont Uit als de gloed uit staat, wat de beweging ook is', () => {
assert.equal(glowChoice({ profile: 'glass', glow: 'off', motion: 'always' }), 'off');
assert.equal(glowChoice({ profile: 'business', glow: 'off' }), 'off');
});
it('kent in Zakelijk alleen aan en uit', () => {
assert.equal(glowChoice({ profile: 'business', glow: 'on', motion: 'always' }), 'on');
});
});
describe('glowPatch', () => {
it('zet in Glas gloed en beweging samen', () => {
assert.deepEqual(glowPatch('glass', 'still'), { glow: 'on', motion: 'off' });
assert.deepEqual(glowPatch('glass', 'always'), { glow: 'on', motion: 'always' });
assert.deepEqual(glowPatch('glass', 'off'), { glow: 'off' });
});
it('schrijft in Zakelijk nooit een beweging', () => {
assert.deepEqual(glowPatch('business', 'on'), { glow: 'on' });
assert.equal(glowPatch('business', 'always'), null);
});
it('is omkeerbaar: de patch van een keuze toont weer die keuze', () => {
for (const keuze of ['off', 'still', 'step', 'always']) {
assert.equal(glowChoice({ profile: 'glass', motion: 'step', ...glowPatch('glass', keuze) }), keuze, keuze);
}
});
});
+45
View File
@@ -0,0 +1,45 @@
// Generated, do not edit. sha256:fab8822364846f02f8b385ca8840c17f7abfa1ebbb404d9f6b628a1d6b098476
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { compoundsViaBalance, looksLikeDepositWithoutPrincipal } from '../../client/actions/interest-compounding.js';
const rij = (extra) => ({ rateKind: 'fixed', principalAssetId: 1, payoutAssetId: 1, principalAmount: null,
endsOn: '2029-08-20', ...extra });
const invoer = (extra) => ({ rateKind: 'fixed', principalAssetId: '1', payoutAssetId: '1', principalAmount: '',
endsOn: '2029-08-20', ...extra });
describe('compoundsViaBalance', () => {
it('ziet het in een rij en in de waarden van de bewerkrij', () => {
assert.equal(compoundsViaBalance(rij()), true);
assert.equal(compoundsViaBalance(invoer()), true);
});
it('niet met een vaste hoofdsom', () => {
assert.equal(compoundsViaBalance(rij({ principalAmount: 1072628 })), false);
assert.equal(compoundsViaBalance(invoer({ principalAmount: '10726,28' })), false);
});
it('niet als de rente in een ander asset uitkeert, want dan groeit de hoofdsom er niet door', () => {
assert.equal(compoundsViaBalance(rij({ payoutAssetId: 2 })), false);
});
it('niet bij handmatig of import, want daar rekent de app niets', () => {
assert.equal(compoundsViaBalance(rij({ rateKind: 'variable', principalAssetId: null })), false);
});
});
describe('looksLikeDepositWithoutPrincipal', () => {
it('waarschuwt met een einddatum', () => {
assert.equal(looksLikeDepositWithoutPrincipal(invoer()), true);
});
it('waarschuwt niet zonder einddatum, want dat is een spaarrekening', () => {
assert.equal(looksLikeDepositWithoutPrincipal(invoer({ endsOn: '' })), false);
assert.equal(looksLikeDepositWithoutPrincipal(rij({ endsOn: null })), false);
});
it('waarschuwt niet met een hoofdsom', () => {
assert.equal(looksLikeDepositWithoutPrincipal(invoer({ principalAmount: '10726,28' })), false);
});
});
+63
View File
@@ -0,0 +1,63 @@
// Generated, do not edit. sha256:6a1216df5bf39806d948913415bd20789617aa1ad15c9edd5dfc0d910d79365a
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { byPeriod, bookableDues, dueKey } from '../../client/actions/interest-order.js';
const d = (scheduleId, accountId, periodStart, periodEnd) => ({ scheduleId, accountId, periodStart, periodEnd });
describe('byPeriod', () => {
it('zet de oudste bijschrijving vooraan', () => {
const gesorteerd = byPeriod([
d(2, 1, '2025-06-01', '2025-07-01'),
d(1, 1, '2024-01-01', '2024-02-01'),
d(1, 1, '2024-02-01', '2024-03-01'),
]);
assert.deepEqual(gesorteerd.map(x => x.periodEnd), ['2024-02-01', '2024-03-01', '2025-07-01']);
});
it('laat de invoer ongemoeid', () => {
const invoer = [d(1, 1, '2024-02-01', '2024-03-01'), d(1, 1, '2024-01-01', '2024-02-01')];
byPeriod(invoer);
assert.equal(invoer[0].periodEnd, '2024-03-01', 'de originele lijst is niet omgezet');
});
});
describe('bookableDues', () => {
it('laat per account alleen de oudste boeken', () => {
const due = [
d(1, 1, '2024-01-01', '2024-02-01'),
d(1, 1, '2024-02-01', '2024-03-01'),
d(1, 1, '2024-03-01', '2024-04-01'),
];
const boekbaar = bookableDues(due);
assert.deepEqual([...boekbaar], [dueKey(due[0])]);
});
it('rekent per account en niet over de rekeningen heen', () => {
const oudA = d(1, 1, '2024-01-01', '2024-02-01');
const oudB = d(2, 2, '2025-01-01', '2025-02-01');
const nieuwB = d(2, 2, '2025-02-01', '2025-03-01');
const boekbaar = bookableDues([oudA, oudB, nieuwB]);
assert.ok(boekbaar.has(dueKey(oudA)));
assert.ok(boekbaar.has(dueKey(oudB)), 'de oudste van rekening 2 mag óók, ondanks de oudere op 1');
assert.equal(boekbaar.has(dueKey(nieuwB)), false);
});
it('laat bij een gelijke crediteringsdag allebei door', () => {
const een = d(1, 1, '2024-01-01', '2024-02-01');
const twee = d(2, 1, '2023-12-01', '2024-02-01');
const boekbaar = bookableDues([een, twee]);
assert.equal(boekbaar.size, 2);
});
it('is leeg bij een lege lijst', () => {
assert.equal(bookableDues([]).size, 0);
});
it('geeft de volgende vrij zodra de oudste uit de lijst is', () => {
const due = [d(1, 1, '2024-01-01', '2024-02-01'), d(1, 1, '2024-02-01', '2024-03-01')];
assert.equal(bookableDues(due.slice(1)).has(dueKey(due[1])), true);
});
});
+180
View File
@@ -0,0 +1,180 @@
// Generated, do not edit. sha256:18ea78362b2006de67f8de24f1ba22509bacedd2360fbc539ff44c9c147a971f
import { describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
let gemeld = [];
mock.module('../../client/ui/dialog.js', { namedExports: { alert: (o) => { gemeld.push(o); } } });
const { labelComboMarkup, labelComboMount, labelComboRead } =
await import('../../client/ui/label-combobox.js');
function maakHost() {
const hidden = { value: '' };
const input = { value: '', gefocust: 0, focus() { this.gefocust++; } };
const datalist = { innerHTML: '' };
const addBtn = {};
const delBtn = { disabled: false };
const map = {
'[data-label-id]': hidden,
'[data-label-input]': input,
datalist,
'[data-label-add]': addBtn,
'[data-label-del]': delBtn,
};
return { querySelector: (sel) => map[sel] ?? null, hidden, input, datalist, addBtn, delBtn };
}
function maakApi(labels = [{ id: 1, name: 'Salaris' }, { id: 2, name: 'Spaargeld' }]) {
const rijen = [...labels];
let volgend = Math.max(0, ...rijen.map(l => l.id)) + 1;
return {
lijst: rijen,
aanroepen: { list: 0, create: 0 },
async listLabels() { this.aanroepen.list++; return [...rijen]; },
async createLabel({ name }) {
this.aanroepen.create++;
const id = volgend++;
rijen.push({ id, name });
return id;
},
};
}
describe('labelComboMarkup', () => {
it('levert de twee knoppen en het verborgen id-veld', () => {
const html = labelComboMarkup('labelId', 7);
assert.match(html, /data-label-add/);
assert.match(html, /data-label-del/);
assert.match(html, /data-label-id value="7"/);
});
it('escapet de veldnaam en laat een leeg id leeg', () => {
assert.match(labelComboMarkup('a"b', null), /data-field="a&quot;b"/);
assert.match(labelComboMarkup('labelId', null), /data-label-id value=""/);
});
});
describe('labelComboRead', () => {
it('geeft null bij leeg en een getal bij een gekozen label', () => {
const host = maakHost();
host.hidden.value = '';
assert.equal(labelComboRead(host), null);
host.hidden.value = '3';
assert.equal(labelComboRead(host), 3);
assert.equal(labelComboRead(null), null);
});
});
describe('labelComboMount met een ASYNCHRONE labelApi', () => {
it('vult de datalist en zet het huidige label in het veld', async () => {
const host = maakHost();
const api = maakApi();
host.hidden.value = '2';
await labelComboMount(host, api, () => {});
assert.match(host.datalist.innerHTML, /Salaris/);
assert.match(host.datalist.innerHTML, /Spaargeld/);
assert.equal(host.input.value, 'Spaargeld', 'het gekoppelde label hoort in het veld te staan');
assert.equal(host.delBtn.disabled, false, 'ontkoppelen kan, want er hángt een label aan');
});
it('zet de ontkoppel-knop uit als er geen label hangt', async () => {
const host = maakHost();
await labelComboMount(host, maakApi(), () => {});
assert.equal(host.input.value, '');
assert.equal(host.delBtn.disabled, true);
});
it('koppelt met + een BESTAAND label zonder er een nieuw bij te maken', async () => {
const host = maakHost();
const api = maakApi();
const gekozen = [];
await labelComboMount(host, api, id => gekozen.push(id));
host.input.value = ' salaris ';
await host.addBtn.onclick();
assert.deepEqual(gekozen, [1], 'de naam hoort op te lossen naar het bestaande label');
assert.equal(api.aanroepen.create, 0, 'geen tweede label met dezelfde naam');
assert.equal(host.hidden.value, 1);
});
it('maakt met + een NIEUW label en verwerkt het meteen in de lijst', async () => {
const host = maakHost();
const api = maakApi();
const gekozen = [];
await labelComboMount(host, api, id => gekozen.push(id));
host.input.value = 'Vakantiegeld';
await host.addBtn.onclick();
assert.equal(api.aanroepen.create, 1);
assert.deepEqual(gekozen, [3]);
assert.equal(host.input.value, 'Vakantiegeld');
assert.match(host.datalist.innerHTML, /Vakantiegeld/);
});
it('doet met + bij een leeg veld niets dan focussen', async () => {
const host = maakHost();
const api = maakApi();
const gekozen = [];
await labelComboMount(host, api, id => gekozen.push(id));
host.input.value = ' ';
await host.addBtn.onclick();
assert.equal(api.aanroepen.create, 0);
assert.deepEqual(gekozen, []);
assert.equal(host.input.gefocust, 1);
});
it('meldt een fout van createLabel en koppelt dan niets', async () => {
gemeld = [];
const host = maakHost();
const api = maakApi();
api.createLabel = async () => { throw new Error('naam bestaat al'); };
const gekozen = [];
await labelComboMount(host, api, id => gekozen.push(id));
host.input.value = 'Nieuw';
await host.addBtn.onclick();
assert.equal(gemeld.length, 1);
assert.match(gemeld[0].message, /naam bestaat al/);
assert.deepEqual(gekozen, [], 'een mislukte aanmaak mag geen koppeling doorgeven');
});
it('ontkoppelt met − zonder het label zelf te raken', async () => {
const host = maakHost();
const api = maakApi();
host.hidden.value = '1';
const gekozen = [];
await labelComboMount(host, api, id => gekozen.push(id));
host.delBtn.onclick();
assert.deepEqual(gekozen, [null]);
assert.equal(host.hidden.value, '');
assert.equal(api.lijst.length, 2, 'het label blijft bestaan');
});
it('koppelt bij het typen van een bestaande naam, en wacht bij een nieuwe op +', async () => {
const host = maakHost();
const gekozen = [];
await labelComboMount(host, maakApi(), id => gekozen.push(id));
host.input.value = 'Spaargeld';
host.input.onchange();
assert.deepEqual(gekozen, [2]);
host.input.value = 'Bestaat nog niet';
host.input.onchange();
assert.deepEqual(gekozen, [2], 'een onbekende naam koppelt niets tot je op + drukt');
assert.equal(host.hidden.value, '');
assert.equal(host.delBtn.disabled, true);
host.input.value = '';
host.input.onchange();
assert.deepEqual(gekozen, [2, null], 'het veld leegmaken ontkoppelt');
});
});
+96
View File
@@ -0,0 +1,96 @@
// Generated, do not edit. sha256:6129a8bbffc98b951f97ab835b37848de841b315ab86079ee5c45996f7fe3fe5
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
const opslag = new Map();
const sleutel = (scope, key) => `${scope}::${key}`;
let stand = null;
mock.module('../../client/data/_rpc.js', {
namedExports: {
async rpc(module, functie, ...args) {
if (module === 'license-status' && functie === 'licenseStatus') return stand;
if (module === 'preferences' && functie === 'getPref') {
const [scope, key, fallback] = args;
return opslag.has(sleutel(scope, key)) ? opslag.get(sleutel(scope, key)) : fallback;
}
if (module === 'preferences' && functie === 'setPref') {
const [scope, key, waarde] = args;
opslag.set(sleutel(scope, key), waarde);
return null;
}
throw new Error(`onverwachte aanroep in deze test: ${module}.${functie}`);
},
decodeer: v => v,
setRowGuard() {},
rowGuardAan: () => false,
},
});
const { loadLicense, licenseFingerprint, licenseChangedSinceSeen, markLicenseSeen } =
await import('../../client/license.js');
const GRATIS = {
level: 'free', why: 'none', enforced: true, version: '1.0.0', installed: null,
installedCovers: false, buyer: null, issued: null, max: { assets: 1 }, features: { ai: false },
};
const BETAALD = {
level: 'full', why: 'licensed', enforced: true, version: '1.0.0',
installed: { buyer: 'npub1abc', issued: '2026-09-21', majorFrom: 1, majorTo: 'unlimited' },
installedCovers: true, buyer: 'npub1abc', issued: '2026-09-21', max: null, features: null,
};
beforeEach(() => { opslag.clear(); stand = GRATIS; });
describe('de vingerafdruk van de licentie', () => {
it('is gelijk voor twee keer dezelfde stand', () => {
assert.equal(licenseFingerprint(GRATIS), licenseFingerprint({ ...GRATIS }));
});
it('verschilt als de stand omslaat', () => {
assert.notEqual(licenseFingerprint(GRATIS), licenseFingerprint(BETAALD));
});
it('verschilt als een ANDERE licentie op dezelfde stand staat', () => {
const ander = { ...BETAALD, buyer: 'npub1xyz', installed: { ...BETAALD.installed, buyer: 'npub1xyz' } };
assert.notEqual(licenseFingerprint(BETAALD), licenseFingerprint(ander));
});
it('verschilt als het bereik verschuift, ook bij dezelfde koper', () => {
const smaller = { ...BETAALD, installed: { ...BETAALD.installed, majorTo: 1 } };
assert.notEqual(licenseFingerprint(BETAALD), licenseFingerprint(smaller));
});
it('verschilt als de REDEN verandert bij dezelfde stand', () => {
assert.notEqual(licenseFingerprint(GRATIS), licenseFingerprint({ ...GRATIS, why: 'invalid' }));
});
it('geeft een lege afdruk zonder momentopname, en gooit niet', () => {
assert.equal(licenseFingerprint(null), '');
});
});
describe('is er iets te melden', () => {
it('MELDT NIETS op de eerste ronde en legt de stand stil vast', async () => {
assert.equal(await licenseChangedSinceSeen(), false);
assert.equal(opslag.get(sleutel('license', 'seen')), undefined, 'er is nog niets geladen');
await loadLicense();
assert.equal(await licenseChangedSinceSeen(), false, 'de eerste ronde meldt niets');
assert.equal(opslag.get(sleutel('license', 'seen')), licenseFingerprint(GRATIS),
'en hij legt de stand vast, anders meldt de tweede ronde het alsnog');
assert.equal(await licenseChangedSinceSeen(), false, 'de tweede ronde ook niet');
});
it('meldt het zodra de stand omslaat, en zwijgt weer als je gekeken hebt', async () => {
await loadLicense();
await licenseChangedSinceSeen();
stand = BETAALD;
await loadLicense();
assert.equal(await licenseChangedSinceSeen(), true, 'een nieuwe licentie is nieuws');
await markLicenseSeen();
assert.equal(await licenseChangedSinceSeen(), false);
});
});
+98
View File
@@ -0,0 +1,98 @@
// Generated, do not edit. sha256:6eb57396047a29d93c920e8603271aba5ed6b08a4da1cf79b7fe590db222f6fc
import { describe, it, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { installDom } from '../helpers/mini-dom.mjs';
const dom = installDom();
const loading = await import('../../client/ui/loading.js');
describe('loading: CSP-strict', () => {
it('no primitive writes a style attribute', () => {
const all = [loading.block(), loading.line('40%'), loading.circle(), loading.card(), loading.table(), loading.cards(), loading.skeleton([loading.line()])].join('');
assert.doesNotMatch(all, /style=/);
});
it('show() sets the sizes through the CSSOM', () => {
const host = dom.host();
loading.show(host, 'card');
const circle = host.querySelector('.skel-row-l .skel-block');
assert.equal(circle.style.getPropertyValue('width'), '1.6rem');
assert.equal(circle.style.getPropertyValue('height'), '1.6rem');
assert.equal(circle.style.getPropertyValue('border-radius'), '50%');
assert.equal(circle.style.getPropertyValue('flex'), 'none');
assert.equal(host.querySelector('.skel-card-body .skel-line').style.getPropertyValue('width'), '55%');
});
it('markup put in by the caller gets its sizes from sizeSkeleton', () => {
const host = dom.host();
host.innerHTML = loading.block('3rem', '1.1rem');
const b = host.querySelector('.skel-block');
assert.equal(b.style.getPropertyValue('width'), '');
loading.sizeSkeleton(host);
assert.equal(b.style.getPropertyValue('width'), '3rem');
assert.equal(b.style.getPropertyValue('height'), '1.1rem');
assert.equal(b.style.getPropertyValue('border-radius'), 'var(--radius-sm)');
});
it('a size is escaped into its attribute', () => {
const host = dom.host();
host.innerHTML = loading.line('10%" onmouseover="x');
assert.equal(host.querySelector('.skel-line').getAttribute('onmouseover'), null);
});
});
describe('loading: the shapes', () => {
it('table: a header row plus the asked rows, each with the asked columns', () => {
const host = dom.host();
loading.show(host, 'table', { rows: 2, cols: 3 });
const rows = host.querySelectorAll('.skel-trow');
assert.equal(rows.length, 3);
assert.ok(rows.every(r => r.querySelectorAll('.skel-td').length === 3));
});
it('cards: the asked number; lines: three by default', () => {
const host = dom.host();
loading.show(host, 'cards', { count: 4 });
assert.equal(host.querySelectorAll('.skel-card').length, 4);
loading.show(host);
assert.equal(host.querySelectorAll('.skel-line').length, 3);
});
it('the waiting state is heard: aria-busy and a screen-reader label, the blocks hidden', () => {
const host = dom.host();
loading.show(host, 'card');
assert.equal(host.querySelector('.skel-wrap').getAttribute('aria-busy'), 'true');
assert.equal(host.querySelector('.skel-wrap .sr-only').textContent, 'Loading…');
assert.equal(host.querySelector('.skel-wrap > [aria-hidden="true"] .skel-card') !== null, true);
});
it('no host: nothing happens', () => {
loading.show(null);
loading.spinner(null);
loading.error(null, 'x');
});
});
describe('loading: the texts', () => {
afterEach(() => loading.configureLoading());
it('spinner and error escape their text at the sink', () => {
const host = dom.host();
loading.spinner(host, '<b>wacht</b>');
assert.equal(host.querySelector('.t-muted').textContent, '<b>wacht</b>');
loading.error(host, 'no such column: <script>');
assert.match(host.querySelector('[role="alert"]').textContent, /no such column: <script>/);
assert.equal(host.querySelector('script'), null);
});
it('configureLoading sets the default texts', () => {
loading.configureLoading({ labels: { loading: 'Laden…', loadError: 'Laden mislukt.' } });
const host = dom.host();
loading.spinner(host);
assert.equal(host.querySelector('.t-muted').textContent, 'Laden…');
loading.error(host);
assert.match(host.textContent, /Laden mislukt\./);
loading.show(host);
assert.equal(host.querySelector('.sr-only').textContent, 'Laden…');
});
});
+37
View File
@@ -0,0 +1,37 @@
// Generated, do not edit. sha256:021a29aa8101638bfe1883f78698f1f023a09f6b14b39dbb9fc4e8d2968c37ae
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { spreidOverKolommen } from '../../client/ui/masonry.js';
describe('spreidOverKolommen', () => {
it('kiest steeds de kortste kolom', () => {
assert.deepEqual(spreidOverKolommen([100, 50, 40, 20, 30], 2), [0, 1, 1, 1, 0]);
});
it('is gewoon round-robin als alles even hoog is', () => {
assert.deepEqual(spreidOverKolommen([10, 10, 10, 10, 10, 10], 3), [0, 1, 2, 0, 1, 2]);
});
it('zet alles in één kolom als er één kolom is', () => {
assert.deepEqual(spreidOverKolommen([5, 1, 9], 1), [0, 0, 0]);
});
it('houdt de kolommen dichter bij elkaar dan round-robin zou doen', () => {
const hoogtes = [120, 10, 10, 10];
const uit = spreidOverKolommen(hoogtes, 3);
const som = [0, 0, 0];
uit.forEach((k, i) => { som[k] += hoogtes[i]; });
assert.deepEqual(uit, [0, 1, 2, 1]);
assert.deepEqual(som, [120, 20, 10]);
});
it('geeft een lege lijst terug voor geen kaarten', () => {
assert.deepEqual(spreidOverKolommen([], 3), []);
});
it('behandelt een onzinnig kolomaantal als één kolom in plaats van om te vallen', () => {
for (const raar of [0, -3, NaN, undefined]) {
assert.deepEqual(spreidOverKolommen([1, 2, 3], raar), [0, 0, 0], `kolommen: ${raar}`);
}
});
});
+173
View File
@@ -0,0 +1,173 @@
// Generated, do not edit. sha256:23dd50a7ad5448e3230fa33ba9e025d2fdfec896be66303dade6c4b550502fa8
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { installDom, parseSelector } from '../helpers/mini-dom.mjs';
const dom = installDom();
const html = s => { const el = dom.host(); el.innerHTML = s; return el; };
describe('mini-dom: parsing and serialising', () => {
it('builds elements, text and attributes, and decodes what esc() writes', () => {
const el = html('<div class="a b" data-x="1 &amp; 2" title="&quot;q&quot; &#39;s&#39;">x &lt; y</div>');
const d = el.querySelector('div');
assert.equal(d.getAttribute('data-x'), '1 & 2');
assert.equal(d.title, `"q" 's'`);
assert.equal(d.textContent, 'x < y');
assert.equal(el.innerHTML, '<div class="a b" data-x="1 &amp; 2" title="&quot;q&quot; \'s\'">x &lt; y</div>');
});
it('knows void elements, self-closing svg and a raw textarea', () => {
const el = html('<input data-field="a" value="v"><svg><line x1="1"/></svg><textarea data-field="t">a <b> &amp; c</textarea><span>na</span>');
assert.equal(el.children.length, 4);
assert.equal(el.querySelector('svg').children[0].localName, 'line');
assert.equal(el.querySelector('textarea').value, 'a <b> & c');
assert.equal(el.querySelector('span').textContent, 'na');
});
it('a > inside a quoted attribute does not end the tag', () => {
const el = html('<button title="a > b" data-k="1">x</button>');
assert.equal(el.querySelector('button').title, 'a > b');
assert.equal(el.querySelector('button').textContent, 'x');
});
it('a stray closing tag is ignored, and replacing innerHTML detaches the old nodes', () => {
const el = html('<p>a</span>b</p>');
assert.equal(el.querySelector('p').textContent, 'ab');
const p = el.querySelector('p');
el.innerHTML = '<i>new</i>';
assert.equal(p.isConnected, false);
assert.equal(el.querySelector('i').isConnected, true);
});
});
describe('mini-dom: selectors', () => {
const el = html(`<div class="table-wrap has-exp"><table><tbody>
<tr data-row-id="1" class="row-alt"><td class="num">1</td></tr>
<tr class="row-expansion" data-exp-id="1"><td>drawer</td></tr>
<tr data-row-id="2"><td><input data-field="a" disabled><input data-field="b" type="hidden"><input data-field="c" type="date"></td></tr>
</tbody></table></div>`);
it('tag, id, class, attribute with and without value, and :not', () => {
assert.equal(el.querySelectorAll('tr').length, 3);
assert.equal(el.querySelectorAll('tr[data-row-id]').length, 2);
assert.equal(el.querySelector('tr[data-row-id="2"]').dataset.rowId, '2');
assert.equal(el.querySelector('tr.row-expansion[data-exp-id="1"] td').textContent, 'drawer');
assert.equal(el.querySelectorAll('.table-wrap.has-exp').length, 1);
const free = el.querySelectorAll('input:not([disabled]):not([type="hidden"]), select:not([disabled])');
assert.deepEqual(free.map(i => i.dataset.field), ['c']);
});
it('descendant and child combinators are not the same', () => {
assert.equal(el.querySelectorAll('tbody td').length, 3);
assert.equal(el.querySelectorAll('table > td').length, 0);
assert.equal(el.querySelectorAll('tr > td').length, 3);
});
it('closest walks up and includes the element itself; matches tests one element', () => {
const td = el.querySelector('tr[data-row-id="1"] td');
assert.equal(td.closest('tr[data-row-id]').dataset.rowId, '1');
assert.equal(td.closest('td'), td);
assert.equal(td.closest('[data-save]'), null);
assert.equal(td.matches('.num'), true);
});
it('a selector it does not understand throws instead of matching nothing', () => {
assert.throws(() => parseSelector('tr:first-child'), /unsupported/);
assert.throws(() => parseSelector('a ~ b'), /unsupported/);
assert.throws(() => parseSelector('[x'), /unclosed/);
});
});
describe('mini-dom: form state', () => {
it('a select reads the selected option, then what was set', () => {
const el = html('<select><option value="a">A</option><option value="b" selected>B</option></select><select><option value="x">X</option></select>');
const [s1, s2] = el.querySelectorAll('select');
assert.equal(s1.value, 'b');
assert.equal(s2.value, 'x');
s1.value = 'a';
assert.equal(s1.value, 'a');
});
it('an input reads its attribute until a value is set; a checkbox its checked', () => {
const el = html('<input value="1"><input type="checkbox" checked><input type="checkbox">');
const [t, c1, c2] = el.querySelectorAll('input');
assert.equal(t.value, '1');
t.value = '2';
assert.equal(t.value, '2');
assert.equal(t.getAttribute('value'), '1');
assert.equal(c1.checked, true);
assert.equal(c2.checked, false);
assert.equal(c1.type, 'checkbox');
assert.equal(t.type, 'text');
});
it('there is a head for scripts, and getComputedStyle reads the inline style', () => {
assert.equal(dom.document.head.parentNode, dom.document.documentElement);
dom.document.documentElement.style.setProperty('--accent', '#ff6b00');
assert.equal(getComputedStyle(dom.document.documentElement).getPropertyValue('--accent'), '#ff6b00');
});
it('focus moves activeElement, and contains sees it', () => {
const el = html('<input data-field="a">');
el.querySelector('input').focus();
assert.equal(dom.document.activeElement, el.querySelector('input'));
assert.equal(el.contains(dom.document.activeElement), true);
});
});
describe('mini-dom: events', () => {
it('bubbles through on-properties and listeners, and stopPropagation stops it', () => {
const el = html('<div id="outer"><div id="inner"><button>b</button></div></div>');
const log = [];
el.querySelector('#outer').onclick = () => log.push('outer');
el.querySelector('#inner').addEventListener('click', e => { log.push('inner'); e.stopPropagation(); });
dom.click(el.querySelector('button'));
assert.deepEqual(log, ['inner']);
});
it('a capture listener on the document runs first, and target is the clicked element', () => {
const el = html('<span>x</span>');
const log = [];
const cap = e => log.push(['doc', e.target.localName]);
dom.document.addEventListener('click', cap, true);
el.onclick = () => log.push(['host']);
dom.click(el.querySelector('span'));
dom.document.removeEventListener('click', cap, true);
assert.deepEqual(log, [['doc', 'span'], ['host']]);
});
it('dispatchEvent with a platform Event works, and keydown carries key and modifiers', () => {
const el = html('<input>');
let got = null;
el.oninput = e => { got = e.type; };
el.querySelector('input').dispatchEvent(new Event('input', { bubbles: true }));
assert.equal(got, 'input');
let k = null;
el.onkeydown = e => { k = [e.key, e.ctrlKey]; e.preventDefault(); };
assert.equal(dom.key(el.querySelector('input'), 'Enter', { ctrlKey: true }), false);
assert.deepEqual(k, ['Enter', true]);
});
it('the document dispatches too, and a CustomEvent keeps its detail', () => {
let got = null;
const fn = e => { got = e.detail; };
dom.document.addEventListener('blur-changed', fn);
dom.document.dispatchEvent(new CustomEvent('blur-changed', { detail: { on: true } }));
dom.document.removeEventListener('blur-changed', fn);
assert.deepEqual(got, { on: true });
});
it('append adds several children in order', () => {
const el = html('');
el.append(dom.document.createElement('b'), dom.document.createElement('i'));
assert.deepEqual(el.children.map(c => c.localName), ['b', 'i']);
});
it('animation frames wait until they are flushed', () => {
let ran = false;
requestAnimationFrame(() => { ran = true; });
assert.equal(ran, false);
dom.flushFrames();
assert.equal(ran, true);
});
});
+64
View File
@@ -0,0 +1,64 @@
// Generated, do not edit. sha256:6be31fc22a74a523d2b0d59999ce972ba090fd8c02f866e6f13929e3137c6c1b
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { schnorr } from '@noble/curves/secp256k1.js';
import { conversationKey, messageKeys, paddedLength, encrypt, decrypt } from '../../client/nostr/nip44.js';
const V = JSON.parse(readFileSync(new URL('../fixtures/nip44.vectors.json', import.meta.url), 'utf8')).v2;
const hex = (b) => Buffer.from(b).toString('hex');
const sha = (s) => createHash('sha256').update(s, 'utf8').digest('hex');
const pubOf = (secHex) => hex(schnorr.getPublicKey(Buffer.from(secHex, 'hex')));
describe('NIP-44: de geldige vectoren', () => {
it('leidt elke conversatiesleutel af zoals de spec', () => {
for (const v of V.valid.get_conversation_key) assert.equal(hex(conversationKey(v.sec1, v.pub2)), v.conversation_key, v.note ?? v.sec1);
});
it('leidt de drie sleutels per bericht af zoals de spec', () => {
const { conversation_key: ck, keys } = V.valid.get_message_keys;
for (const k of keys) {
const m = messageKeys(ck, k.nonce);
assert.deepEqual([hex(m.chachaKey), hex(m.chachaNonce), hex(m.hmacKey)], [k.chacha_key, k.chacha_nonce, k.hmac_key]);
}
});
it('vult elke lengte op zoals de spec', () => {
for (const [len, padded] of V.valid.calc_padded_len) assert.equal(paddedLength(len), padded, String(len));
});
it('versleutelt en ontsleutelt elk voorbeeld byte voor byte gelijk', () => {
for (const v of V.valid.encrypt_decrypt) {
const ck = conversationKey(v.sec1, pubOf(v.sec2));
assert.equal(hex(ck), v.conversation_key);
assert.equal(encrypt(v.plaintext, ck, v.nonce), v.payload);
assert.equal(decrypt(v.payload, ck), v.plaintext);
}
});
it('kan ook de lange berichten, tot 65535 bytes', () => {
for (const v of V.valid.encrypt_decrypt_long_msg) {
const tekst = v.pattern.repeat(v.repeat);
assert.equal(sha(tekst), v.plaintext_sha256);
const payload = encrypt(tekst, v.conversation_key, v.nonce);
assert.equal(sha(payload), v.payload_sha256);
assert.equal(decrypt(payload, v.conversation_key), tekst);
}
});
});
describe('NIP-44: de ongeldige vectoren', () => {
it('weigert een bericht van een ongeldige lengte', () => {
const ck = V.valid.encrypt_decrypt[0].conversation_key;
for (const len of V.invalid.encrypt_msg_lengths) assert.throws(() => encrypt('a'.repeat(len), ck), Error, String(len));
});
it('weigert een ongeldige sleutel', () => {
for (const v of V.invalid.get_conversation_key) assert.throws(() => conversationKey(v.sec1, v.pub2), Error, v.note);
});
it('weigert elke vervalste of verminkte payload', () => {
for (const v of V.invalid.decrypt) assert.throws(() => decrypt(v.payload, v.conversation_key), Error, v.note);
});
});
+68
View File
@@ -0,0 +1,68 @@
// Generated, do not edit. sha256:f81905187f7271d470129be5251b18f9ab41f86bd73038bb85cd73f795065c11
import { describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
let MELDINGEN = [];
let DETAIL = null;
mock.module('../../client/data/notifications.js', {
namedExports: {
currentNotifications: async () => MELDINGEN,
notificationDetail: async () => DETAIL,
dismissNotification: async () => {},
},
});
const { renderNotifications } = await import('../../client/actions/notifications-widget.js');
const { NOTIFICATION_PRIORITY } = await import('../../shared/config.js');
const melding = (kind, id) => ({ id, kind, count: 2, highWater: 1, createdAt: '2026-10-20T10:00:00.000Z' });
async function teken(rijen, detail = null) {
MELDINGEN = rijen;
DETAIL = detail;
const host = { innerHTML: '', querySelectorAll() { return []; } };
await renderNotifications(host, () => {});
return host.innerHTML;
}
const volgorde = html => [...html.matchAll(/data-kind="([a-z_]+)"/g)].map(m => m[1]);
describe('meldingenwidget: volgorde van belangrijkheid', () => {
it('zet de rijen in de rangorde, ook als de server ze andersom levert', async () => {
const omgekeerd = [...NOTIFICATION_PRIORITY].reverse().map((kind, i) => melding(kind, i + 1));
assert.deepEqual(volgorde(await teken(omgekeerd)), [...NOTIFICATION_PRIORITY]);
});
it('zet de belangrijkste bovenaan bij een greep uit de soorten', async () => {
const rijen = [melding('schedule_ended', 9), melding('interest', 5), melding('valuation', 2)];
assert.deepEqual(volgorde(await teken(rijen)), ['valuation', 'interest', 'schedule_ended']);
});
});
describe('meldingenwidget: dezelfde informatie per rij', () => {
it('toont aantal, soort, hint, datum en de verbergknop', async () => {
const html = await teken([melding('interest', 7)]);
assert.match(html, /Rente te boeken/);
assert.match(html, /Bijschrijvingen klaar om te boeken/);
assert.match(html, /class="notify-date">20-10-2026</);
});
it('zet het aantal in de verbergknop, met het id van de melding', async () => {
const html = await teken([melding('interest', 7)]);
assert.match(html, /<button[^>]*class="notify-count"[^>]*data-dismiss="7"[^>]*><span class="notify-n">2<\/span>/);
assert.equal((html.match(/data-dismiss=/g) || []).length, 1, 'één verbergknop per rij');
});
it('escapet het detail van een afgelopen afspraak, want daar staan namen van de gebruiker in', async () => {
const html = await teken([melding('schedule_ended', 1)], { account: '<b>Bank</b>', asset: 'BTC', extra: 1 });
assert.ok(!html.includes('<b>Bank</b>'), 'de accountnaam mag niet als markup in de rij staan');
assert.match(html, /&lt;b&gt;Bank&lt;\/b&gt; · BTC \+1/);
assert.match(html, /notify-row is-info/);
});
it('toont zonder meldingen de lege stand en geen lijst', async () => {
const html = await teken([]);
assert.ok(!html.includes('notify-list'));
assert.match(html, /task-card-empty/);
});
});
+33
View File
@@ -0,0 +1,33 @@
// Generated, do not edit. sha256:d03e761dc5e1d78cdc30e118e106e4e15cddaef982c7d3ba457cc600c6565b81
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { installDom } from '../helpers/mini-dom.mjs';
const dom = installDom();
const { setPageHint, clearPageHint, SAMPLES } = await import('../../client/ui/page-hint.js');
const vak = dom.document.createElement('div');
vak.id = 'page-hint';
dom.document.body.appendChild(vak);
const pagina = dom.host();
describe('page-hint: de legenda van Assets', () => {
it('draagt op elke legenda de regel over een ontbrekende koers, onder de titel en vóór de regels', () => {
setPageHint(pagina, { title: 'x', rows: ['Een regel.'] });
const paneel = vak.querySelector('.hint-panel');
assert.deepEqual(paneel.children.map(c => c.className), ['t-label', 'hint-row hint-warn', 'hint-row']);
assert.match(vak.querySelector('.hint-warn').textContent, /ontbreekt bij minstens één bedrag een koers/);
});
it('laat na het wissen een legenda met alleen die regel achter, voor een pagina zonder eigen legenda', () => {
setPageHint(pagina, { title: 'x', rows: ['Een regel.'] });
clearPageHint();
assert.ok(vak.querySelector('.hint.is-fallback'));
assert.ok(vak.querySelector('.hint-warn'));
assert.equal(vak.textContent.includes('Een regel.'), false);
});
it('geeft de monsters door die de pagina\'s van Assets gebruiken', () => {
for (const naam of ['line', 'dashed', 'dot', 'dotOpen', 'ring']) assert.equal(typeof SAMPLES[naam], 'string', naam);
});
});
+44
View File
@@ -0,0 +1,44 @@
// Generated, do not edit. sha256:13b31c48ff5328daf97de910b42c1da69200bfe8eddfa660e66eddb391551419
import { describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
mock.module('../../client/data/license-status.js', {
namedExports: { licenseStatus: async () => ({ features: { tax: false, portfolios: false } }) },
});
const { ROUTE_KEYS, SYSTEM_PAGE, TARGETS } = await import('../../shared/config.js');
const { loadLicense } = await import('../../client/license.js');
const { registerRoute, placeAvailable } = await import('../../client/routes.js');
const { registerSystemTab } = await import('../../client/pages/system-tabs.js');
await loadLicense();
const pagina = { title: () => '', render: () => {} };
registerRoute(ROUTE_KEYS.HOME, pagina);
registerRoute(ROUTE_KEYS.SYSTEM, pagina);
registerRoute(ROUTE_KEYS.TAXES, pagina, { feature: 'tax' });
registerSystemTab({ id: SYSTEM_PAGE.TABS.HEALTH, render: () => {} });
registerSystemTab({ id: SYSTEM_PAGE.TABS.PORTFOLIOS, feature: 'portfolios', render: () => {} });
registerSystemTab({ id: SYSTEM_PAGE.TABS.INTEGRATIONS, subs: ['connections'], render: () => {} });
describe('placeAvailable', () => {
it('een geregistreerde route wel, een niet-geregistreerde niet', () => {
assert.equal(placeAvailable(TARGETS.HOME), true);
assert.equal(placeAvailable(TARGETS.WEALTH), false);
assert.equal(placeAvailable(null), false);
});
it('een route of tabblad achter een dichte vlag niet', () => {
assert.equal(placeAvailable(TARGETS.TAXES), false);
assert.equal(placeAvailable(TARGETS.PORTFOLIOS), false);
assert.equal(placeAvailable(TARGETS.HEALTH), true);
});
it('een tabblad dat zijn subtabs noemt, heeft alleen die', () => {
assert.equal(placeAvailable(TARGETS.CONNECTIONS), true);
assert.equal(placeAvailable(TARGETS.SOURCE_PROPOSALS), false);
});
it('een tabblad dat niet geregistreerd is, niet', () => {
assert.equal(placeAvailable(TARGETS.AI_ACTIVITY), false);
});
});
+89
View File
@@ -0,0 +1,89 @@
// Generated, do not edit. sha256:2430e966fcc2cdaaba7ffd7cbd0b894b46925aefa4dabedcc39e93d69f099382
import { describe, it, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { installDom } from '../helpers/mini-dom.mjs';
const dom = installDom();
const { openPopover, closePopover, isPopoverOpen } = await import('../../client/ui/popover.js');
const anchor = () => { const b = dom.document.createElement('button'); dom.document.body.appendChild(b); return b; };
const tick = () => new Promise(r => setTimeout(r, 0));
describe('openPopover', () => {
afterEach(() => closePopover());
it('appends one element with the class to the body and reports it open', () => {
const { el } = openPopover(anchor(), { className: 'popover new-menu' });
assert.equal(el.parentNode, dom.document.body);
assert.equal(el.className, 'popover new-menu');
assert.equal(isPopoverOpen(), true);
});
it('a second one closes the first, and calls its onClose', () => {
let closed = 0;
const first = openPopover(anchor(), { onClose: () => { closed++; } });
openPopover(anchor());
assert.equal(first.el.isConnected, false);
assert.equal(closed, 1);
assert.equal(dom.document.querySelectorAll('.popover').length, 1);
});
it('Escape closes it', () => {
const { el } = openPopover(anchor());
dom.key(dom.document.body, 'Escape');
assert.equal(el.isConnected, false);
assert.equal(isPopoverOpen(), false);
});
it('an outside click closes it, but only from the next tick; a click inside or on the anchor does not', async () => {
const a = anchor();
const { el } = openPopover(a);
el.innerHTML = '<span>in</span>';
dom.click(dom.document.body);
assert.equal(el.isConnected, true);
await tick();
dom.click(el.querySelector('span'));
dom.click(a);
assert.equal(el.isConnected, true);
dom.click(dom.document.body);
assert.equal(el.isConnected, false);
});
it('a popover closed within the same tick leaves no outside-click listener behind', async () => {
const before = dom.document.listeners.length;
openPopover(anchor());
closePopover();
await tick();
assert.equal(dom.document.listeners.length, before);
const { el } = openPopover(anchor());
el.innerHTML = '<span>in</span>';
await tick();
dom.click(el.querySelector('span'));
assert.equal(el.isConnected, true);
});
it('inside an open dialog it goes into the top overlay, not behind it in the body', () => {
const o1 = dom.document.createElement('div'); o1.className = 'dialog-overlay';
const o2 = dom.document.createElement('div'); o2.className = 'dialog-overlay';
dom.document.body.append(o1, o2);
const { el } = openPopover(anchor());
assert.equal(el.parentNode, o2);
o1.remove(); o2.remove();
});
it('position() keeps it inside the window', () => {
const a = anchor();
a.getBoundingClientRect = () => ({ left: 1270, top: 790, right: 1280, bottom: 800, width: 10, height: 10 });
const { el, position } = openPopover(a);
Object.defineProperty(el, 'offsetWidth', { value: 200 });
Object.defineProperty(el, 'offsetHeight', { value: 100 });
position();
assert.equal(el.style.left, `${1280 - 8 - 200}px`);
assert.equal(el.style.top, `${790 - 100 - 6}px`);
});
it('closePopover without one open does nothing', () => {
closePopover();
assert.equal(isPopoverOpen(), false);
});
});
+48
View File
@@ -0,0 +1,48 @@
// Generated, do not edit. sha256:ab76d1a13f8b51c7388dcb1a3adb5921cbdbf5323b484b6f86a9d79addf631f0
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { invoiceQrText, qrMatrix, qrSvg } from '../../client/ui/qr.js';
const ALFABET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
const INVOICE = `lnbc210u1p${Array.from({ length: 300 }, (_, i) => ALFABET[(i * 7) % 32]).join('')}`;
function finderOp(m, r0, c0) {
for (let r = 0; r < 7; r++) {
for (let c = 0; c < 7; c++) {
const rand = r === 0 || r === 6 || c === 0 || c === 6;
const midden = r >= 2 && r <= 4 && c >= 2 && c <= 4;
if (m[r0 + r][c0 + c] !== (rand || midden)) return false;
}
}
return true;
}
describe('de QR-code van een invoice', () => {
it('zet de invoice in hoofdletters achter LIGHTNING:', () => {
assert.equal(invoiceQrText('lnbc1abc'), 'LIGHTNING:LNBC1ABC');
});
it('is in hoofdletters duidelijk kleiner dan in kleine letters, want dat is de alfanumerieke modus', () => {
const groot = qrMatrix(`lightning:${INVOICE}`).length;
const klein = qrMatrix(invoiceQrText(INVOICE)).length;
assert.ok(klein < groot, `hoofdletters ${klein} modules, kleine letters ${groot}`);
});
it('is een vierkante matrix met de drie finder-patronen op hun plek', () => {
const m = qrMatrix(invoiceQrText(INVOICE));
const n = m.length;
assert.equal((n - 17) % 4, 0, 'een QR-versie heeft 17 + 4v modules');
assert.ok(m.every(rij => rij.length === n));
assert.ok(finderOp(m, 0, 0) && finderOp(m, 0, n - 7) && finderOp(m, n - 7, 0), 'een finder-patroon ontbreekt');
});
it('tekent de code met een stille zone van vier modules en een vaste maat', () => {
const n = qrMatrix(invoiceQrText(INVOICE)).length;
const svg = qrSvg(invoiceQrText(INVOICE), { label: 'a"b' });
assert.match(svg, new RegExp(`viewBox="0 0 ${n + 8} ${n + 8}"`));
assert.match(svg, /width="240" height="240"/);
assert.match(svg, /aria-label="a&quot;b"/, 'het label is niet geëscaped');
assert.match(svg, /<path d="M4 4h1v1h-1z/, 'de eerste donkere module staat niet op 4,4');
assert.doesNotMatch(svg, /style=/);
});
});
+35
View File
@@ -0,0 +1,35 @@
// Generated, do not edit. sha256:01bde5d75892fc5a6a8c97160161ea34bf3bdb955dc532c9061e112dd8b33d86
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { bulkPlan, bulkCounts } from '../../client/actions/reconcile-bulk.js';
const ln = (id, extra = {}) => ({ id, tx_layer: 'lightning', tx_kind: null, transactionHash: `hash-${id}`, direction: 'out', ...extra });
describe('bulkPlan', () => {
it('een gewone lightning-betaling wordt een aankoop of verkoop', () => {
assert.deepEqual(bulkPlan([ln(1), ln(2, { direction: 'in' })]).map(s => s.action), ['trade', 'trade']);
});
it('een betaling aan uzelf en een kanaal (ook on-chain) blijven binnen het account', () => {
const plan = bulkPlan([ln(1, { tx_kind: 'self' }), { id: 2, tx_layer: 'onchain', tx_kind: 'channel', transactionHash: 'ab' }]);
assert.deepEqual(plan.map(s => s.action), ['internal', 'internal']);
});
it('een forward wordt rente, en een on-chain txid die toevallig zo begint niet', () => {
const plan = bulkPlan([ln(1, { transactionHash: 'fwd:1:2:3', direction: 'in' }),
{ id: 2, tx_layer: 'onchain', tx_kind: null, transactionHash: 'fwd:x' }]);
assert.deepEqual(plan.map(s => [s.action, s.reason]), [['interest', undefined], ['skip', 'onchain']]);
});
it('slaat een gewone on-chain beweging over, met de reden', () => {
const plan = bulkPlan([{ id: 2, tx_layer: 'onchain', tx_kind: null, transactionHash: 'ab' }]);
assert.deepEqual(plan.map(s => [s.action, s.reason]), [['skip', 'onchain']]);
});
it('telt per soort voor de bevestiging', () => {
const plan = bulkPlan([ln(1), ln(2), ln(3, { tx_kind: 'self' }), ln(4, { transactionHash: 'fwd:x' }),
{ id: 5, tx_layer: 'onchain', tx_kind: null }]);
assert.deepEqual(bulkCounts(plan), { internal: 1, interest: 1, trade: 2, onchain: 1 });
});
});
+31
View File
@@ -0,0 +1,31 @@
// Generated, do not edit. sha256:4c48c91a39b75539bd8099fc6fb3aa256248b03273f564c0ab58cb33136afb24
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { createRenderTurns } from '../../client/ui/render-turn.js';
describe('createRenderTurns', () => {
it('een ingehaalde beurt is niet meer aan de beurt, de nieuwste wel', () => {
const beurten = createRenderTurns();
const oud = beurten.take();
const nieuw = beurten.take();
assert.equal(beurten.isCurrent(oud), false);
assert.equal(beurten.isCurrent(nieuw), true);
});
it('een beurt zonder opvolger blijft aan de beurt, ook als hij lang duurt', () => {
const beurten = createRenderTurns();
const enige = beurten.take();
assert.equal(beurten.isCurrent(enige), true);
assert.equal(beurten.isCurrent(enige), true);
});
it('twee tellers lopen los van elkaar', () => {
const a = createRenderTurns();
const b = createRenderTurns();
const beurtA = a.take();
b.take();
b.take();
assert.equal(a.isCurrent(beurtA), true);
});
});
+30
View File
@@ -0,0 +1,30 @@
// Generated, do not edit. sha256:873b90a4bc5945aaec1e66516f50565126971fb28d2a0e30e1d4f91ed16001a1
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
const WORTEL = path.resolve(import.meta.dirname, '..', '..');
const lees = (...delen) => readFileSync(path.join(WORTEL, ...delen), 'utf8');
const CHART = lees('client', 'wealth', 'chart.js');
const CSS = lees('client', 'css', 'styles.css').replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, ' ');
const glasLijst = decl => [...CSS.matchAll(/:root\[data-profile="glass"\] :is\(([^)]*)\) \{([^}]*)\}/g)]
.filter(m => m[1].includes('.po-pop') && m[2].includes(decl))
.map(m => m[1]);
describe('de tooltip van de doughnuts', () => {
it('laat Chart.js hem niet op het canvas tekenen', () => {
assert.match(CHART, /tooltip: \{\s*enabled: false,\s*external:/);
});
it('staat bij de zwevende lagen die in Glas matglas worden', () => {
const lijsten = glasLijst('backdrop-filter:blur');
assert.ok(lijsten.length >= 1, 'de glasregel van de zwevende lagen hoort te bestaan');
assert.ok(lijsten.some(l => l.includes('.ring-tip')), '.ring-tip hoort in die lijst');
});
it('zet het bedrag in een .val, zodat de privacystand het vervaagt', () => {
assert.match(CHART, /className = 'val';\s*bedrag\.textContent = fmtBase\(/);
});
});
+36
View File
@@ -0,0 +1,36 @@
// Generated, do not edit. sha256:c6a688b05a3ee0ffe4f9c168acd72ea6bb6492b16f1c36d4741af1eab39eecdd
import { describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
import { ROUTE_KEYS } from '../../shared/config.js';
let dicht = new Set();
mock.module('../../client/license.js', { namedExports: { locked: f => dicht.has(f) } });
const { registerRoute, routePage } = await import('../../client/routes.js');
const thuis = { render: () => {} };
const belasting = { render: () => {} };
registerRoute(ROUTE_KEYS.HOME, thuis);
registerRoute(ROUTE_KEYS.TAXES, belasting, { feature: 'tax' });
describe('routePage', () => {
it('geeft de pagina van een geregistreerde route', () => {
dicht = new Set();
assert.equal(routePage(ROUTE_KEYS.HOME), thuis);
assert.equal(routePage(ROUTE_KEYS.TAXES), belasting);
});
it('geeft niets voor een route die de licentie uitzet, ook niet via het adres', () => {
dicht = new Set(['tax']);
assert.equal(routePage(ROUTE_KEYS.TAXES), null);
assert.equal(routePage(ROUTE_KEYS.HOME), thuis, 'een route zonder vlag blijft staan');
});
it('geeft niets voor een route die niet geregistreerd is (een andere editie)', () => {
assert.equal(routePage(ROUTE_KEYS.CHARTS), null);
});
it('weigert een onbekende route en een tweede registratie', () => {
assert.throws(() => registerRoute('bestaat-niet', thuis), /Unknown route/);
assert.throws(() => registerRoute(ROUTE_KEYS.HOME, thuis), /registered twice/);
});
});
+67
View File
@@ -0,0 +1,67 @@
// Generated, do not edit. sha256:39bdc4d7a5d894186d06fb91a6bd592c985897d148807099ceaa0916d6fa3d0d
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
const opslag = new Map();
globalThis.sessionStorage = {
getItem: k => (opslag.has(k) ? opslag.get(k) : null),
setItem: (k, v) => { opslag.set(k, String(v)); },
removeItem: k => { opslag.delete(k); },
};
const { setRowFocus, takeRowFocus } = await import('../../client/ui/row-focus.js');
const { CONFIG } = await import('../../shared/config.js');
beforeEach(() => opslag.clear());
describe('een focus doorgeven', () => {
it('geeft het id terug aan de tabel die hij noemt', () => {
setRowFocus('transfers', 184);
assert.equal(takeRowFocus('transfers'), 184);
});
it('geeft niets aan een ANDERE tabel, en laat de focus staan', () => {
setRowFocus('accounts', 3);
assert.equal(takeRowFocus('assets'), null);
assert.equal(takeRowFocus('accounts'), 3, 'de focus is niet opgegeten door de verkeerde tabel');
});
it('werkt maar één keer', () => {
setRowFocus('trades', 7);
assert.equal(takeRowFocus('trades'), 7);
assert.equal(takeRowFocus('trades'), null, 'anders springt de tabel bij elk volgend bezoek');
});
it('slaat niets op zonder tabel of zonder id', () => {
setRowFocus('', 3);
setRowFocus('assets', null);
assert.equal(opslag.get(CONFIG.FOCUS.ROW), undefined);
});
});
describe('een focus die te oud is', () => {
it('vervalt en wordt opgeruimd', () => {
opslag.set(CONFIG.FOCUS.ROW, JSON.stringify({
table: 'assets', id: 9, at: Date.now() - CONFIG.FOCUS.ROW_TTL_MS - 1,
}));
assert.equal(takeRowFocus('assets'), null);
assert.equal(opslag.get(CONFIG.FOCUS.ROW), undefined, 'blijft niet liggen tot het volgende bezoek');
});
it('een lading zonder tijdstempel vervalt ook', () => {
opslag.set(CONFIG.FOCUS.ROW, JSON.stringify({ table: 'assets', id: 9 }));
assert.equal(takeRowFocus('assets'), null);
});
});
describe('een onleesbare lading', () => {
it('geeft niets terug en blijft niet liggen', () => {
opslag.set(CONFIG.FOCUS.ROW, '{kapot');
assert.equal(takeRowFocus('assets'), null);
assert.equal(opslag.get(CONFIG.FOCUS.ROW), undefined);
});
it('geeft niets terug als er helemaal niets staat', () => {
assert.equal(takeRowFocus('assets'), null);
});
});
+88
View File
@@ -0,0 +1,88 @@
// Generated, do not edit. sha256:bd286c1811389fc704e1fb7d0c5403e1bc83b9a7d21ab777de72f7aae17ec2e2
import { describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
import { installDom } from '../helpers/mini-dom.mjs';
const dom = installDom();
const INSTELLING = { blur: 1, auto_blur_min: 999999 };
const geschreven = [];
mock.module('../../client/app-settings.js', {
namedExports: {
appSetting: k => INSTELLING[k],
setAppSetting: async (k, v) => { INSTELLING[k] = v; geschreven.push([k, v]); },
baseCurrency: () => 'EUR',
},
});
const { configureSharedUi } = await import('../../client/ui/shared-setup.js');
const dlg = await import('../../client/ui/dialog.js');
const { setLocale } = await import('../../client/i18n/index.js');
const { create } = await import('../../client/ui/tabs.js');
const boven = () => dom.document.querySelectorAll('.dialog-overlay').at(-1);
const escape = () => dom.key(dom.document.body, 'Escape');
describe('shared-setup: de dialogen', () => {
it('de knoppen staan in het Nederlands', async () => {
const p = dlg.confirm({ message: 'x' });
assert.equal(boven().querySelector('[data-dlg="ok"]').textContent, 'OK');
assert.equal(boven().querySelector('[data-dlg="cancel"]').textContent, 'Annuleren');
escape();
await p;
const d = dlg.detail({ title: 'x', html: '' });
assert.equal(boven().querySelector('.dialog-close').getAttribute('aria-label'), 'Sluiten');
escape();
await d;
});
it('volgt de taal: opnieuw zetten na setLocale geeft de knoppen in die taal', async () => {
setLocale('en');
try {
configureSharedUi();
const p = dlg.stepper({ steps: [{ html: () => '' }] });
assert.equal(boven().querySelector('[data-step="skip"]').textContent, 'Skip');
escape();
await p;
} finally {
setLocale('nl');
configureSharedUi();
}
});
it('een detailpaneel krijgt het oog als de bedragen vervaagd zijn; een klik haalt de vervaging weg en het oog met haar', async () => {
INSTELLING.blur = 1;
const p = dlg.detail({ title: 'Asset', html: '' });
const oog = boven().querySelector('.dialog-title .dialog-eye');
assert.ok(oog);
assert.equal(oog.getAttribute('aria-label'), 'Bedragen tonen');
dom.click(oog);
await new Promise(r => setImmediate(r));
assert.deepEqual(geschreven.at(-1), ['blur', 0]);
assert.equal(boven().querySelector('.dialog-eye'), null);
escape();
await p;
});
it('zonder vervaging geen oog, en het verschijnt als de vervaging intussen invalt', async () => {
INSTELLING.blur = 0;
const p = dlg.detail({ title: 'Asset', html: '' });
assert.equal(boven().querySelector('.dialog-eye'), null);
dom.document.dispatchEvent(new CustomEvent('blur-changed', { detail: { on: true } }));
assert.ok(boven().querySelector('.dialog-eye'));
escape();
await p;
});
});
describe('shared-setup: de tabbladen', () => {
it('een paneel dat faalt toont de foutkaart van de app, met de melding', () => {
const e = console.error;
console.error = () => {};
try {
const t = create(dom.host(), { items: [{ id: 'a', label: 'A' }], render: () => { throw new Error('kapot'); } });
assert.match(t.panel.textContent, /Er ging iets mis bij het laden van deze sectie/);
assert.match(t.panel.textContent, /kapot/);
} finally { console.error = e; }
});
});
+23
View File
@@ -0,0 +1,23 @@
// Generated, do not edit. sha256:87d3b6ea14cc0b9b6b0443632d1447185ac128d9de9561db729adecb3b059a8e
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { sourceBadgeText } from '../../client/ui/source-badge.js';
import { t } from '../../client/i18n/index.js';
describe('sourceBadgeText', () => {
it('een NWC- en een LND-bron heten geen los adres', () => {
assert.equal(sourceBadgeText({ kind: 'nwc' }).label, t('tracking.badgeNwc'));
assert.equal(sourceBadgeText({ kind: 'lnd' }).label, t('tracking.badgeLnd'));
for (const kind of ['nwc', 'lnd']) {
assert.notEqual(sourceBadgeText({ kind }).label, t('accounts.address'), `${kind} heet een los adres`);
assert.ok(sourceBadgeText({ kind }).title, `${kind} zonder uitleg bij het label`);
}
});
it('een xpub krijgt zijn scripttype, en een los adres heet zo', () => {
assert.equal(sourceBadgeText({ kind: 'xpub', scriptType: 'p2wpkh' }).label, 'SegWit');
assert.equal(sourceBadgeText({ kind: 'xpub' }).label, 'xpub');
assert.equal(sourceBadgeText({ kind: 'address' }).label, t('accounts.address'));
});
});
+259
View File
@@ -0,0 +1,259 @@
// Generated, do not edit. sha256:81c29b0b17dff467d076992c914cba7f0090a65799888be3dc9347a850d7de68
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { trapPunten, magnetischeIndex, withSpot, mountSparkline, changePctHtml, smoothPath, labelLeft } from '../../client/ui/sparkline.js';
const p = (px, py) => ({ px, py });
describe('trapPunten', () => {
it('laat een vlakke reeks ongemoeid', () => {
const in_ = [p(0, 50), p(10, 50), p(20, 50)];
assert.deepEqual(trapPunten(in_), in_);
});
it('zet bij een stap een hoekpunt op de x van de NIEUWE waarde', () => {
const uit = trapPunten([p(0, 80), p(10, 40)]);
assert.deepEqual(uit, [p(0, 80), p(10, 80), p(10, 40)]);
});
it('werkt net zo omlaag, want een verkoop verlaagt de inleg', () => {
const uit = trapPunten([p(0, 40), p(10, 80)]);
assert.deepEqual(uit, [p(0, 40), p(10, 40), p(10, 80)]);
});
it('doet niets bij een verschil onder de drempel', () => {
const in_ = [p(0, 50), p(10, 50.005)];
assert.deepEqual(trapPunten(in_), in_);
});
it('houdt de eerste en de laatste waarde op hun plek', () => {
const uit = trapPunten([p(0, 90), p(5, 60), p(10, 20)]);
assert.deepEqual(uit[0], p(0, 90));
assert.deepEqual(uit[uit.length - 1], p(10, 20));
assert.equal(uit.length, 5, 'drie punten met twee stappen worden vijf hoekpunten');
});
it('valt niet om op een lege of eenpuntige reeks', () => {
assert.deepEqual(trapPunten([]), []);
assert.deepEqual(trapPunten([p(0, 1)]), [p(0, 1)]);
});
});
describe('labelLeft: het label blijft binnen de grafiek', () => {
it('staat op het punt als er ruimte is', () => {
assert.equal(labelLeft(115, 100, 230), 115);
});
it('schuift naar binnen aan beide randen, met 4 px afstand', () => {
assert.equal(labelLeft(5, 200, 230), 104);
assert.equal(labelLeft(225, 200, 230), 126);
});
it('staat in het midden als het label breder is dan de grafiek', () => {
assert.equal(labelLeft(10, 260, 230), 115);
});
});
describe('magnetischeIndex', () => {
const reeks = Array.from({ length: 11 }, (_, i) => ({ fx: i / 10 }));
it('geeft het dichtstbijzijnde punt als er geen markering in de buurt is', () => {
assert.equal(magnetischeIndex(reeks, [8], 0.22, 0.03), 2);
});
it('klikt op de markering zodra die binnen de tolerantie ligt', () => {
assert.equal(magnetischeIndex(reeks, [5], 0.52, 0.03), 5);
assert.equal(magnetischeIndex(reeks, [5], 0.48, 0.03), 5);
});
it('laat de markering NIET winnen buiten de tolerantie', () => {
assert.equal(magnetischeIndex(reeks, [5], 0.30, 0.03), 3);
});
it('kiest de dichtstbijzijnde markering als er twee in de buurt liggen', () => {
assert.equal(magnetischeIndex(reeks, [4, 5], 0.49, 0.05), 5);
});
it('wint binnen de tolerantie ook van een punt dat NÓG dichterbij ligt', () => {
assert.equal(magnetischeIndex(reeks, [5], 0.401, 0.11), 5);
});
it('valt terug op het dichtstbijzijnde punt zonder markeringen', () => {
assert.equal(magnetischeIndex(reeks, [], 0.71, 0.03), 7);
});
it('negeert een markering die buiten de reeks wijst', () => {
assert.equal(magnetischeIndex(reeks, [99], 0.31, 0.5), 3);
});
});
describe('withSpot', () => {
const DAG = 86400000;
const dag = iso => Date.parse(`${iso}T00:00:00Z`);
const nu = dag('2026-09-04') + 13 * 3600000;
const week = [
[dag('2026-09-01'), 100],
[dag('2026-09-02'), 110],
[dag('2026-09-03'), 120],
[dag('2026-09-04'), 130],
];
it('vervangt de waarde van vandaag en maakt de reeks niet langer', () => {
const uit = withSpot(week, 140, nu);
assert.equal(uit.length, week.length);
assert.deepEqual(uit[uit.length - 1], [dag('2026-09-04'), 140]);
assert.deepEqual(uit.slice(0, -1), week.slice(0, -1));
});
it('houdt het punt op middernacht van die dag en zet er geen tijdstip in', () => {
assert.equal(withSpot(week, 140, nu).at(-1)[0] % DAG, 0);
});
it('voegt vandaag toe als de reeks bij gisteren ophoudt', () => {
const totGisteren = week.slice(0, -1);
const uit = withSpot(totGisteren, 140, nu);
assert.equal(uit.length, totGisteren.length + 1);
assert.deepEqual(uit.at(-1), [dag('2026-09-04'), 140]);
});
it('laat een reeks die voorbij vandaag loopt ongemoeid', () => {
const toekomst = [...week, [dag('2026-09-05'), 150]];
assert.equal(withSpot(toekomst, 140, nu), toekomst);
});
it('geeft de reeks onveranderd terug zonder bruikbare prijs', () => {
for (const prijs of [null, undefined, NaN, 0, -1, '140']) {
assert.equal(withSpot(week, prijs, nu), week);
}
});
it('geeft een lege of ontbrekende reeks onveranderd terug', () => {
assert.deepEqual(withSpot([], 140, nu), []);
assert.equal(withSpot(null, 140, nu), null);
});
});
describe('mountSparkline', () => {
const DAG = 86400000;
const punten = [[0, 100], [DAG, 110], [2 * DAG, 105]];
function bouw(opties = {}) {
const leeg = () => ({ className: '', innerHTML: '', appendChild() {}, querySelector: () => ({}), addEventListener() {} });
globalThis.document = { createElement: leeg };
const host = { innerHTML: '', appendChild(kind) { this.kind = kind; } };
mountSparkline(host, punten, opties);
return host.kind.innerHTML;
}
it('zet de markeringen, de hover-lijn en het puntje ín de kliplaag', () => {
const html = bouw({ markers: [{ x: 0, variant: 'buy' }] });
const clip = html.indexOf('<div class="spark-mini-clip">');
const label = html.indexOf('<div class="spark-mini-label"');
assert.ok(clip >= 0 && label > clip, 'de kliplaag hoort te bestaan en vóór het label te staan');
const binnen = html.slice(clip, label);
for (const deel of ['spark-mini-mark', 'spark-mini-vline', 'spark-mini-dot']) {
assert.ok(binnen.includes(deel), `${deel} hoort in de kliplaag te zitten`);
}
assert.equal((binnen.match(/<div/g) || []).length, (binnen.match(/<\/div>/g) || []).length,
'de kliplaag hoort gesloten te zijn vóór het prijslabel');
});
it('houdt het prijslabel BUITEN de kliplaag', () => {
const html = bouw();
const label = html.indexOf('<div class="spark-mini-label"');
assert.ok(label > 0);
assert.equal(html.slice(label).includes('spark-mini-dot'), false);
assert.equal(html.slice(label).includes('spark-mini-clip'), false);
});
it('geeft een markering op het eerste punt zijn echte x en schuift hem niet naar binnen', () => {
const html = bouw({ markers: [{ x: 0, variant: 'buy' }] });
assert.match(html, /spark-mini-mark is-buy[^>]*--spark-x:0\.00%/);
});
it('draagt de sleutel van een markering mee als `data-mark`', () => {
const html = bouw({ markers: [{ x: DAG, variant: 'sell', key: 't42' }] });
assert.match(html, /data-mark="t42"/);
});
it('maakt het vak leeg bij te weinig punten', () => {
const leeg = () => ({ className: '', innerHTML: '', appendChild() {}, querySelector: () => ({}), addEventListener() {} });
globalThis.document = { createElement: leeg };
const host = { innerHTML: 'oud', appendChild() { throw new Error('er hoort niets gemount te worden'); } };
mountSparkline(host, [[0, 100]]);
assert.equal(host.innerHTML, '');
});
});
describe('changePctHtml: de verandering als pil', () => {
it('geeft een stijging een groene pil, met het teken uit de instelling', () => {
assert.equal(changePctHtml([[0, 100], [1, 110]]), '<span class="spark-change delta t-pos">10,0%</span>');
assert.match(changePctHtml([[0, 100], [1, 110]], '', { signs: 'both' }), />\+10,0%</);
assert.match(changePctHtml([[0, 100], [1, 90]], '', { signs: 'none' }), /class="spark-change delta t-neg">10,0%</);
});
it('geeft een daling een rode pil, met komma en minteken zoals elk ander percentage', () => {
assert.match(changePctHtml([[0, 100], [1, 90]]), /class="spark-change delta t-neg">−10,0%</);
});
it('geeft niets bij te weinig punten of een ongeldig begin', () => {
assert.equal(changePctHtml([[0, 100]]), '');
assert.equal(changePctHtml([[0, 0], [1, 5]]), '');
});
it('zet een staart ín de pil, achter het getal', () => {
assert.equal(changePctHtml([[0, 100], [1, 110]], '<i></i>'), '<span class="spark-change delta t-pos">10,0%<i></i></span>');
});
it('fine: een tweede decimaal als één decimaal de beweging verbergt', () => {
assert.match(changePctHtml([[0, 10000], [1, 10003]], '', { fine: true }), />0,03%</);
assert.match(changePctHtml([[0, 10000], [1, 10003]]), />0,0%</);
});
});
describe('smoothPath', () => {
const p = ys => ys.map((py, i) => ({ px: i * 10, py }));
const segmenten = d => [...d.matchAll(/C[\d.-]+,([\d.-]+) [\d.-]+,([\d.-]+) [\d.-]+,([\d.-]+)/g)]
.map(m => m.slice(1).map(Number));
it('loopt door elk punt', () => {
const ys = [50, 20, 80, 10, 60];
const d = smoothPath(p(ys));
assert.match(d, /^M0\.00,50\.00 /);
assert.deepEqual(segmenten(d).map(s => s[2]), ys.slice(1));
});
it('schiet nergens door: de controlepunten blijven binnen hun segment', () => {
const ys = [50, 20, 80, 10, 60, 61, 95, 94, 3];
segmenten(smoothPath(p(ys))).forEach(([c1, c2], i) => {
const lo = Math.min(ys[i], ys[i + 1]) - 1e-9, hi = Math.max(ys[i], ys[i + 1]) + 1e-9;
assert.ok(c1 >= lo && c1 <= hi && c2 >= lo && c2 <= hi, `segment ${i}: ${c1}, ${c2} buiten [${lo}, ${hi}]`);
});
});
it('een top of dal krijgt een vlakke raaklijn', () => {
const [s0, s1] = segmenten(smoothPath(p([50, 20, 80])));
assert.equal(s0[1], 20);
assert.equal(s1[0], 20);
});
it('twee punten zijn een rechte lijn, en geen punten geen pad', () => {
assert.equal(smoothPath(p([10, 20])), 'M0.00,10.00 L10.00,20.00');
assert.equal(smoothPath([]), '');
});
it('mountSparkline tekent standaard rond (overal, gebruiker 08-10-2026), en recht met `smooth: false`', () => {
const leeg = () => ({ className: '', innerHTML: '', appendChild() {}, querySelector: () => ({}), addEventListener() {} });
globalThis.document = { createElement: leeg };
const teken = opties => {
const host = { innerHTML: '', appendChild(kind) { this.kind = kind; } };
mountSparkline(host, [[0, 100], [1, 110], [2, 105]], opties);
return host.kind.innerHTML;
};
assert.match(teken({}), /<path class="spark-mini-line" d="M[^"]* C/);
assert.match(teken({}), /<path class="spark-mini-area" d="M[^"]* C[^"]*Z"/);
assert.match(teken({ smooth: false }), /<polyline class="spark-mini-line"/);
assert.match(teken({ vergelijk: [[0, 90], [1, 90], [2, 95]] }), /<polyline class="spark-mini-cmp"/);
});
});
+227
View File
@@ -0,0 +1,227 @@
// Generated, do not edit. sha256:05fac95c720d81fea977fcda4544cbb3cf0d29e4d449a955de2c4a1154d3e9ab
import { describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
mock.module('../../client/app-settings.js', {
namedExports: { appSetting: () => 1, baseCurrency: () => 'EUR' },
});
let ASSETS = [];
let BEREIK = null;
mock.module('../../client/data/assets.js', { namedExports: { listAssets: async () => ASSETS } });
mock.module('../../client/data/preferences.js', {
namedExports: { getPref: async (_s, _k, d) => BEREIK ?? d, setPref: async () => {} },
});
let RIJEN = [];
let SPOT = null;
mock.module('../../client/data/price-history.js', { namedExports: { listPriceHistory: async () => RIJEN } });
const KOERS = { USD: 0.8 };
mock.module('../../client/price/providers.js', {
namedExports: {
liveSpotFor: async () => (SPOT == null ? null : { price: SPOT }),
basePriceOf: s => (s && Number.isFinite(s.price) ? s.price : null),
currentRate: async code => KOERS[code] ?? null,
lastSpotSource: () => null,
},
});
mock.module('../../client/data/providers.js', {
namedExports: { listProviders: async () => [{ id: 7, name: 'CoinGecko' }, { id: 9, name: 'Atreides Services' }] },
});
let GETEKEND = null;
let HINT = null;
let STAART = null;
let FIJN = null;
mock.module('../../client/ui/sparkline.js', {
namedExports: {
mountSparkline: (_host, punten, opts = {}) => { GETEKEND = punten; HINT = opts.formatValue || null; },
withSpot: (punten, prijs) => (prijs == null ? punten : [...punten, [Date.now(), prijs]]),
sliceTone: () => 'up',
changePctHtml: (_punten, staart = '', opts = {}) => { STAART = staart; FIJN = !!opts.fine; return ''; },
},
});
globalThis.document = { addEventListener() {} };
const { renderSpotWidget } = await import('../../client/widgets/spot.js');
const { fmtMoney } = await import('../../client/ui/format.js');
const { CONFIG } = await import('../../shared/config.js');
const { recordSpot, clearMomentum } = await import('../../client/price/momentum.js');
const OZT = CONFIG.METALS.UNITS.ozt;
function nepHost() {
const perSelector = new Map();
return {
innerHTML: '',
isConnected: true,
querySelector(sel) {
if (!perSelector.has(sel)) perSelector.set(sel, { classList: { remove() {}, toggle() {} } });
return perSelector.get(sel);
},
el(sel) { return perSelector.get(sel); },
};
}
async function teken(asset, { spot = null, rijen = [], params = {} } = {}) {
ASSETS = [asset];
SPOT = spot;
RIJEN = rijen;
GETEKEND = null;
const host = nepHost();
await renderSpotWidget(host, { params: { assetId: asset.id, ...params } });
await new Promise(r => setImmediate(r));
return host;
}
const vandaag = new Date().toISOString().slice(0, 10);
const gisteren = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
const geschiedenis = (a, b) => [
{ observedAt: `${gisteren}T00:00:00Z`, fiatPrice: a },
{ observedAt: `${vandaag}T00:00:00Z`, fiatPrice: b },
];
const GOUD = { id: 7, symbol: 'XAU', name: 'Goud', kind: 'metal', unit: 'ozt', subunit: 'g', decimals: 7, displayUnit: 'unit' };
const BTC = { id: 1, symbol: 'BTC', name: 'Bitcoin', kind: 'crypto', unit: 'BTC', subunit: 'sat', decimals: 8, displayUnit: 'unit' };
describe('spotwidget: de koers in de weergave-eenheid van het asset', () => {
it('goud in ozt: de spot per troy ounce, zonder eenheid erachter', async () => {
const host = await teken(GOUD, { spot: 100, rijen: geschiedenis(90, 95) });
assert.equal(host.el('#spot-price').textContent, fmtMoney(100 * OZT, 'EUR', { decimals: 0 }));
assert.doesNotMatch(host.innerHTML, /\/ozt/);
});
it('goud in ozt: de sparklijn in dezelfde eenheid als het getal', async () => {
await teken(GOUD, { spot: 100, rijen: geschiedenis(90, 95) });
const waarden = GETEKEND.map(p => p[1]);
assert.ok(Math.abs(waarden[0] - 90 * OZT) < 1e-6, `eerste punt ${waarden[0]}`);
assert.ok(Math.abs(waarden[waarden.length - 1] - 100 * OZT) < 1e-6, 'het laatste punt is de spot');
});
it('zonder spot: de laatste slotkoers, ook in ozt', async () => {
const host = await teken(GOUD, { spot: null, rijen: geschiedenis(90, 95) });
assert.equal(host.el('#spot-price').textContent, fmtMoney(95 * OZT, 'EUR', { decimals: 0 }));
});
it('goud in gram als weergave-eenheid: geen omrekening', async () => {
const host = await teken({ ...GOUD, displayUnit: 'subunit' }, { spot: 100 });
assert.equal(host.el('#spot-price').textContent, fmtMoney(100, 'EUR', { decimals: 2 }));
});
it('tweede valuta: de getoonde koers (per ozt) gedeeld door de wisselkoers', async () => {
const host = await teken(GOUD, { spot: 100, params: { second: 'USD' } });
assert.equal(host.el('#spot-second').textContent, fmtMoney(100 * OZT / 0.8, 'USD', { decimals: 0 }));
});
it('tweede valuta: zonder keuze blijft hij leeg', async () => {
const host = await teken(GOUD, { spot: 100 });
assert.equal(host.el('#spot-second')?.textContent, undefined);
});
it('crypto: per munt, zonder omrekening', async () => {
const host = await teken(BTC, { spot: 60000 });
assert.equal(host.el('#spot-price').textContent, fmtMoney(60000, 'EUR', { decimals: 0 }));
});
});
describe('spotwidget: het tijdvak dag', () => {
const dagen = n => new Date(Date.now() - n * 86400000).toISOString().slice(0, 10);
const maandTotGisteren = () => Array.from({ length: 30 }, (_, i) => ({
observedAt: `${dagen(30 - i)}T00:00:00Z`, fiatPrice: 100 + i,
}));
it('tekent precies twee punten: de slotkoers van gisteren en de spot', async () => {
BEREIK = 'day';
try {
await teken(BTC, { spot: 200, rijen: maandTotGisteren() });
assert.equal(GETEKEND.length, 2);
assert.deepEqual(GETEKEND.map(p => p[1]), [129, 200]);
assert.equal(FIJN, true);
} finally { BEREIK = null; }
});
it('een week blijft een week, zonder fijne decimaal', async () => {
BEREIK = 'week';
try {
await teken(BTC, { spot: 200, rijen: maandTotGisteren() });
assert.ok(GETEKEND.length >= 7, `${GETEKEND.length} punten`);
assert.equal(FIJN, false);
} finally { BEREIK = null; }
});
});
describe('spotwidget: de bron in de hint van een punt', () => {
const dag = n => new Date(Date.now() - n * 86400000).toISOString().slice(0, 10);
const ms = n => Date.parse(`${dag(n)}T00:00:00Z`);
it('noemt de provider van een eigen dag, en bij een doorgetrokken dag de dag van de koers', async () => {
BEREIK = 'week';
try {
await teken(BTC, { spot: null, rijen: [
{ observedAt: `${dag(5)}T00:00:00Z`, fiatPrice: 100, providerId: 7 },
{ observedAt: `${dag(2)}T00:00:00Z`, fiatPrice: 110, providerId: 9 },
] });
assert.match(HINT(100, ms(5)), /\nCoinGecko$/);
assert.match(HINT(110, ms(2)), /\nAtreides Services$/);
assert.match(HINT(100, ms(4)), /\nCoinGecko, koers van /);
} finally { BEREIK = null; }
});
it('een rij van een onbekende provider: geen bron in plaats van een verkeerde', async () => {
BEREIK = 'week';
try {
await teken(BTC, { spot: null, rijen: [
{ observedAt: `${dag(3)}T00:00:00Z`, fiatPrice: 100, providerId: 42 },
{ observedAt: `${dag(2)}T00:00:00Z`, fiatPrice: 110, providerId: 7 },
] });
assert.doesNotMatch(HINT(100, ms(3)), /CoinGecko|Atreides/);
} finally { BEREIK = null; }
});
});
describe('spotwidget: de stip van de korte trend', () => {
async function dalend(assetId) {
clearMomentum();
const nu = Date.now();
for (let i = 0; i <= 3; i++) await recordSpot(assetId, 100 - i, nu - (3 - i) * 60000);
}
it('rood in de pil na dalingen', async () => {
await dalend(BTC.id);
await teken(BTC, { spot: 60000, rijen: geschiedenis(59000, 59500) });
assert.match(STAART, /class="momentum-dot is-down"/);
});
it('de hint noemt het aandeel van de kant van de kleur', async () => {
await dalend(BTC.id);
await teken(BTC, { spot: 60000, rijen: geschiedenis(59000, 59500) });
assert.match(STAART, /title="100% van de laatste 3 koersbewegingen omlaag"/);
});
it('twee op en één neer: 67% omhoog', async () => {
clearMomentum();
const nu = Date.now();
for (const [i, p] of [100, 101, 102, 101].entries()) await recordSpot(BTC.id, p, nu - (3 - i) * 60000);
await teken(BTC, { spot: 60000, rijen: geschiedenis(59000, 59500) });
assert.match(STAART, /class="momentum-dot is-up" title="67% van de laatste 3 koersbewegingen omhoog"/);
});
it('niet als de schakelaar uit staat', async () => {
await dalend(BTC.id);
await teken(BTC, { spot: 60000, rijen: geschiedenis(59000, 59500), params: { momentum: false } });
assert.equal(STAART, '');
});
it('niet zonder live spot: de lijn eindigt dan op een slotkoers', async () => {
await dalend(BTC.id);
await teken(BTC, { spot: null, rijen: geschiedenis(59000, 59500) });
assert.equal(STAART, '');
});
it('niet bij een ander asset', async () => {
await dalend(GOUD.id);
await teken(BTC, { spot: 60000, rijen: geschiedenis(59000, 59500) });
assert.equal(STAART, '');
});
});
+39
View File
@@ -0,0 +1,39 @@
// Generated, do not edit. sha256:3e7be8fe91199a27e369fbae661fe27d142febd9a3610db0e0958e0e9a82d5ec
import { describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
import { SYSTEM_PAGE } from '../../shared/config.js';
let dicht = new Set();
mock.module('../../client/license.js', { namedExports: { locked: f => dicht.has(f) } });
const { registerSystemTab, systemTabs } = await import('../../client/pages/system-tabs.js');
const T = SYSTEM_PAGE.TABS;
const leeg = () => {};
registerSystemTab({ id: T.LICENSE, render: leeg });
registerSystemTab({ id: T.INTEGRATIONS, feature: 'sourceMatrix', render: leeg });
registerSystemTab({ id: T.SETTINGS, render: leeg });
registerSystemTab({ id: T.INTEGRATIONS, label: 'nav.connections', render: leeg });
registerSystemTab({ id: T.DATA, feature: 'data', render: leeg });
const ids = () => systemTabs().map(x => x.id);
const integraties = () => systemTabs().find(x => x.id === T.INTEGRATIONS);
describe('systemTabs', () => {
it('volledig: de betaalde tabs, in de volgorde van SYSTEM_PAGE.TABS', () => {
dicht = new Set();
assert.deepEqual(ids(), [T.SETTINGS, T.INTEGRATIONS, T.DATA, T.LICENSE]);
assert.equal(integraties().feature, 'sourceMatrix', 'de betaalde registratie wint van de vervanger');
assert.equal(integraties().label, 'nav.integrations');
});
it('gratis: een betaalde tab zonder vervanger verdwijnt, een met vervanger wordt die vervanger', () => {
dicht = new Set(['sourceMatrix', 'data']);
assert.deepEqual(ids(), [T.SETTINGS, T.INTEGRATIONS, T.LICENSE]);
assert.equal(integraties().feature, null);
assert.equal(integraties().label, 'nav.connections');
});
it('weigert een tab die SYSTEM_PAGE niet kent', () => {
assert.throws(() => registerSystemTab({ id: 'bestaat-niet', render: leeg }), /Unknown system tab/);
});
});
+59
View File
@@ -0,0 +1,59 @@
// Generated, do not edit. sha256:ade336b7bb05d651a613138854c9a018ee8f1d1702e7f4ffe8b66a1fb63b515b
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { seedValues, normalizeOptions, loadOptions } from '../../client/ui/table-edit-values.js';
const columns = [
{ key: 'name', edit: { type: 'text' } },
{ key: 'amount', edit: { type: 'number', value: r => (r ? r.amount * 2 : 'leeg') } },
{ key: 'shown' },
{ key: 'kind', edit: { type: 'select', options: ['a', { value: 'b', label: 'B' }] } },
{ key: 'acc', edit: { type: 'select', options: async vals => [{ value: vals.kind, label: 'from ' + vals.kind }] } },
];
describe('seedValues', () => {
it('an existing row: edit.value over the raw field, only editable columns, no defaults', () => {
assert.deepEqual(seedValues(columns, { name: 'n', amount: 2, shown: 's', kind: 'a', acc: 1 }, { name: 'default' }),
{ name: 'n', amount: 4, kind: 'a', acc: 1 });
});
it('a new row: edit.value gets null, the rest undefined, and newDefaults go on top', () => {
const v = seedValues(columns, null, { kind: 'b', extra: 1 });
assert.deepEqual(v, { name: undefined, amount: 'leeg', kind: 'b', acc: undefined, extra: 1 });
});
it('a new row without defaults', () => {
assert.deepEqual(Object.keys(seedValues(columns, null)), ['name', 'amount', 'kind', 'acc']);
});
});
describe('normalizeOptions', () => {
it('a plain value becomes value and label; an object stays as it is; nothing is an empty list', () => {
assert.deepEqual(normalizeOptions(['x', 2, { value: 'y', label: 'Y' }]), [
{ value: 'x', label: 'x' }, { value: 2, label: 2 }, { value: 'y', label: 'Y' },
]);
assert.deepEqual(normalizeOptions(null), []);
assert.deepEqual(normalizeOptions(undefined), []);
});
});
describe('loadOptions', () => {
it('fills the cache for every column with options, awaiting a callback with the current values', async () => {
const cache = await loadOptions(columns, { kind: 'z' }, new Map());
assert.deepEqual([...cache.keys()], ['kind', 'acc']);
assert.deepEqual(cache.get('kind'), [{ value: 'a', label: 'a' }, { value: 'b', label: 'B' }]);
assert.deepEqual(cache.get('acc'), [{ value: 'z', label: 'from z' }]);
});
it('empties the cache first, so a list of an earlier row does not linger', async () => {
const cache = new Map([['old', [{ value: 1, label: 1 }]], ['kind', []]]);
await loadOptions(columns, {}, cache);
assert.equal(cache.has('old'), false);
assert.equal(cache.get('kind').length, 2);
});
it('a callback that returns nothing gives an empty list, not a missing one', async () => {
const cache = await loadOptions([{ key: 'k', edit: { options: async () => null } }], {}, new Map());
assert.deepEqual(cache.get('k'), []);
});
});
+109
View File
@@ -0,0 +1,109 @@
// Generated, do not edit. sha256:1850735074515476989419d5951d7734c561fc372a890ebd08e743f8ba996291
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { installDom } from '../helpers/mini-dom.mjs';
import { editInputHtml, editCellHtml, expandRowHtml, captureValues } from '../../client/ui/table-edit.js';
const dom = installDom();
const parse = html => { const el = dom.host(); el.innerHTML = html; return el; };
const convert = { label: 'Omrekenen', canConvert: a => !!(a && a.subunit), icon: '<i class="swap"></i>' };
describe('editInputHtml', () => {
it('a select marks the current value, compared as text', () => {
const el = parse(editInputHtml({ key: 'k', edit: { type: 'select' } }, { k: 2 }, { options: [{ value: '1', label: 'A' }, { value: '2', label: '<B>' }] }));
assert.equal(el.querySelector('select').value, '2');
assert.equal(el.querySelector('option[value="2"]').textContent, '<B>');
});
it('a checkbox is checked by a truthy value; a textarea gets its class and no style', () => {
assert.equal(parse(editInputHtml({ key: 'c', edit: { type: 'checkbox' } }, { c: 1 })).querySelector('input').checked, true);
const html = editInputHtml({ key: 't', edit: { type: 'textarea' } }, { t: 'a<b' });
assert.doesNotMatch(html, /style=/);
assert.equal(parse(html).querySelector('textarea.tbl-textarea').value, 'a<b');
});
it('the input type follows the edit type, and an unknown type is text', () => {
for (const [type, want] of [['number', 'number'], ['date', 'date'], ['time', 'time'], ['text', 'text'], [undefined, 'text']]) {
assert.equal(parse(editInputHtml({ key: 'x', edit: { type } }, {})).querySelector('input').type, want);
}
});
it('placeholder as a function of the values, attrs escaped, value escaped, null as empty', () => {
const input = parse(editInputHtml({ key: 'x', edit: { placeholder: v => `voor ${v.kind}`, attrs: { 'data-x': 'a"b' } } }, { kind: 'k', x: null })).querySelector('input');
assert.equal(input.getAttribute('placeholder'), 'voor k');
assert.equal(input.getAttribute('data-x'), 'a"b');
assert.equal(input.value, '');
});
it('the convert trigger only when the chosen asset can be converted, and only for a column that converts', () => {
const col = { key: 'amount', edit: { type: 'number', convert: v => v.asset } };
const yes = parse(editInputHtml(col, { asset: { subunit: 'sat' } }, { convert }));
assert.equal(yes.querySelector('[data-convert="amount"]').title, 'Omrekenen');
assert.ok(yes.querySelector('.conv-field i.swap'));
assert.equal(parse(editInputHtml(col, { asset: { subunit: '' } }, { convert })).querySelector('[data-convert]'), null);
assert.equal(parse(editInputHtml({ key: 'n', edit: { type: 'number' } }, {}, { convert })).querySelector('[data-convert]'), null);
});
it('a custom control renders itself from the values', () => {
assert.equal(editInputHtml({ key: 'x', edit: { render: v => `<b data-field="x">${v.x}</b>` } }, { x: 5 }), '<b data-field="x">5</b>');
});
});
describe('editCellHtml', () => {
it('an empty cell for a hidden field and for a field in the drawer, without data-label', () => {
const hidden = parse(`<table><tbody><tr>${editCellHtml({ key: 'a', label: 'A', edit: { visible: () => false } }, {})}</tr></tbody></table>`).querySelector('td');
const drawer = parse(`<table><tbody><tr>${editCellHtml({ key: 'a', label: 'A', edit: { expand: true } }, {})}</tr></tbody></table>`).querySelector('td');
for (const td of [hidden, drawer]) { assert.equal(td.children.length, 0); assert.equal(td.dataset.label, undefined); }
const shown = parse(`<table><tbody><tr>${editCellHtml({ key: 'a', label: 'A', edit: {} }, { a: 1 })}</tr></tbody></table>`).querySelector('td');
assert.equal(shown.dataset.label, 'A');
assert.equal(shown.querySelector('[data-field="a"]').value, '1');
});
});
describe('expandRowHtml', () => {
const cols = [
{ key: 'note', label: 'Notitie', edit: { type: 'textarea', expand: true } },
{ key: 'ref', label: 'Ref', edit: { expand: true } },
{ key: 'gone', label: 'Weg', edit: { expand: true, visible: () => false } },
{ key: 'inline', label: 'In', edit: {} },
];
const optsFor = () => ({});
it('the visible drawer fields, a textarea full width, and the error escaped', () => {
const html = expandRowHtml({ editableCols: cols, vals: {}, error: '<bad>', colCount: 5, optsFor });
assert.doesNotMatch(html, /style=/);
const el = parse(`<table><tbody>${html}</tbody></table>`);
assert.equal(el.querySelector('td').getAttribute('colspan'), '5');
assert.deepEqual(el.querySelectorAll('.edit-expand-field').map(f => f.classList.contains('is-full')), [true, false]);
assert.equal(el.querySelector('[data-field="gone"]'), null);
assert.equal(el.querySelector('.edit-expand-error').textContent, '<bad>');
});
it('nothing at all without drawer fields, an error or actions', () => {
assert.equal(expandRowHtml({ editableCols: [cols[3]], vals: {}, error: '', colCount: 2, optsFor }), '');
});
it('the actions alone make a drawer, and they come after the fields and the error', () => {
const alone = parse(`<table><tbody>${expandRowHtml({ editableCols: [cols[3]], vals: {}, error: '', colCount: 2, optsFor, actions: '<div class="edit-actions"></div>' })}</tbody></table>`);
assert.ok(alone.querySelector('.edit-expand-row .edit-actions'));
assert.equal(alone.querySelector('.edit-expand-fields'), null, 'no empty fields block');
const all = parse(`<table><tbody>${expandRowHtml({ editableCols: cols, vals: {}, error: 'e', colCount: 5, optsFor, actions: '<div class="edit-actions"></div>' })}</tbody></table>`);
assert.deepEqual(all.querySelector('td').children.map(c => c.className.split(' ').pop()), ['edit-expand-fields', 'edit-expand-error', 'edit-actions']);
});
});
describe('captureValues', () => {
const cols = [
{ key: 'a', edit: {} },
{ key: 'on', edit: { type: 'checkbox' } },
{ key: 'hidden', edit: {} },
{ key: 'own', edit: { read: host => host.dataset.v + '!' } },
];
it('reads values, a checkbox as a boolean, a custom control through its read, and keeps a hidden field', () => {
const scope = parse('<input data-field="a" value="x"><input type="checkbox" data-field="on"><span data-field="own" data-v="z"></span>');
scope.querySelector('[data-field="on"]').checked = true;
const vals = captureValues(scope, cols, { hidden: 'kept' });
assert.deepEqual(vals, { a: 'x', on: true, hidden: 'kept', own: 'z!' });
});
});
+107
View File
@@ -0,0 +1,107 @@
// Generated, do not edit. sha256:2fdb7171652c771313af8580672b3d94500711ca104d4251cc8f2c61d162cd16
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { installDom } from '../helpers/mini-dom.mjs';
const dom = installDom();
const { captureFocus, restoreFocus, focusFirstField } = await import('../../client/ui/table-focus.js');
const ROW = '<table><tbody><tr data-edit-row="1">'
+ '<td><input data-field="name" value="abcdef"></td>'
+ '<td><input type="date" data-field="day" value="2026-10-18"></td>'
+ '<td><div class="conv-field"><input data-field="amount"><button data-convert="amount">c</button></div></td>'
+ '<td><button data-save="1">s</button></td>'
+ '</tr></tbody></table>';
const table = (html = ROW) => { const el = dom.host(); el.innerHTML = html; return el; };
describe('captureFocus', () => {
it('a text field: its selector and the caret', () => {
const el = table();
const f = el.querySelector('[data-field="name"]');
f.focus();
f.setSelectionRange(2, 4);
assert.deepEqual(captureFocus(el, true), { sel: '[data-field="name"]', start: 2, end: 4 });
});
it('a date field: the selector without a caret, which a browser would refuse to read', () => {
const el = table();
el.querySelector('[data-field="day"]').focus();
assert.deepEqual(captureFocus(el, true), { sel: '[data-field="day"]', start: null, end: null });
});
it('the convert button next to an amount is an anchor of its own, and so is save', () => {
const el = table();
el.querySelector('[data-convert]').focus();
assert.deepEqual(captureFocus(el, true), { sel: '[data-convert="amount"]', start: null, end: null });
el.querySelector('[data-save]').focus();
assert.equal(captureFocus(el, true).sel, '[data-save="1"]');
});
it('nothing outside editing, nothing when the focus is in another table, nothing without an anchor', () => {
const el = table();
el.querySelector('[data-field="name"]').focus();
assert.equal(captureFocus(el, false), null);
const other = table();
assert.equal(captureFocus(other, true), null);
const plain = table('<span tabindex="0">x</span>');
plain.querySelector('span').focus();
assert.equal(captureFocus(plain, true), null);
});
});
describe('restoreFocus', () => {
it('finds the field back in the NEW markup and restores the caret', () => {
const el = table();
const f = el.querySelector('[data-field="name"]');
f.focus();
f.setSelectionRange(1, 3);
const snap = captureFocus(el, true);
el.innerHTML = ROW;
restoreFocus(el, snap);
const again = el.querySelector('[data-field="name"]');
assert.ok(dom.document.activeElement === again, 'focus on the new field');
assert.deepEqual([again.selectionStart, again.selectionEnd], [1, 3]);
});
it('a field that disappeared, or no snapshot, leaves the focus alone', () => {
const el = table();
const before = dom.document.activeElement;
restoreFocus(el, { sel: '[data-field="gone"]', start: null, end: null });
restoreFocus(el, null);
assert.ok(dom.document.activeElement === before);
});
it('a field whose type changed gets the focus without the caret, and does not throw', () => {
const el = table();
const f = el.querySelector('[data-field="name"]');
f.focus();
const snap = captureFocus(el, true);
el.innerHTML = '<input type="date" data-field="name">';
restoreFocus(el, snap);
assert.equal(dom.document.activeElement.getAttribute('type'), 'date');
});
});
describe('focusFirstField', () => {
it('the first enabled, visible field of the edit row', () => {
const el = table('<table><tbody><tr data-edit-row="1"><td><input type="hidden" data-field="h"><input disabled data-field="d"><select data-field="s"></select></td></tr></tbody></table>');
focusFirstField(el);
assert.equal(dom.document.activeElement.dataset.field, 's');
});
it('falls back to the drawer row only when the row itself has nothing', () => {
const el = table('<table><tbody><tr data-edit-row="1"><td></td></tr><tr class="edit-expand-row"><td><textarea data-field="note"></textarea></td></tr></tbody></table>');
focusFirstField(el);
assert.equal(dom.document.activeElement.dataset.field, 'note');
const both = table('<table><tbody><tr data-edit-row="1"><td><input data-field="a"></td></tr><tr class="edit-expand-row"><td><textarea data-field="note"></textarea></td></tr></tbody></table>');
focusFirstField(both);
assert.equal(dom.document.activeElement.dataset.field, 'a');
});
it('no edit row: nothing happens', () => {
const el = table('<p>x</p>');
const before = dom.document.activeElement;
focusFirstField(el);
assert.ok(dom.document.activeElement === before);
});
});
+79
View File
@@ -0,0 +1,79 @@
// Generated, do not edit. sha256:f8c363b3e2be566adaeb7e774a99be7727789e6454a0a1b4badaa78e72fc6bcd
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { groupSections } from '../../client/ui/table-groups.js';
const rows = [
{ id: 1, kind: 'a', place: 'x', year: 2024 },
{ id: 2, kind: 'b', place: 'x', year: 2025 },
{ id: 3, kind: 'a', place: 'y', year: 2025 },
{ id: 4, kind: 'a', place: 'x', year: 2025 },
];
const columns = [
{ key: 'kind', group: { value: r => r.kind, label: v => `Kind ${v}` } },
{ key: 'place', group: { value: r => r.place } },
{ key: 'year', group: { value: r => r.year, order: 'desc' } },
];
const shape = sections => sections.map(s => [s.label, s.items.map(r => r.id), s.collapsed]);
describe('groupSections', () => {
it('one level: a section per value in ascending order, rows in the order given', () => {
assert.deepEqual(shape(groupSections(rows, { columns, groupKey: 'kind' })), [
['Kind a', [1, 3, 4], false],
['Kind b', [2], false],
]);
});
it('the token is the string of the first-level value, the value itself stays', () => {
const [s] = groupSections(rows, { columns, groupKey: 'year' });
assert.equal(s.token, '2025');
assert.equal(s.value, 2025);
});
it('group.order desc turns the order, and a sort on the group column overrules it', () => {
assert.deepEqual(groupSections(rows, { columns, groupKey: 'year' }).map(s => s.token), ['2025', '2024']);
assert.deepEqual(groupSections(rows, { columns, groupKey: 'year', sort: { key: 'year', dir: 'asc' } }).map(s => s.token), ['2024', '2025']);
assert.deepEqual(groupSections(rows, { columns, groupKey: 'year', sort: { key: 'kind', dir: 'asc' } }).map(s => s.token), ['2025', '2024']);
});
it('two and three levels: combined labels, the outer level decides first', () => {
assert.deepEqual(shape(groupSections(rows, { columns, groupKey: 'kind', groupKey2: 'place' })), [
['Kind a - x', [1, 4], false],
['Kind a - y', [3], false],
['Kind b - x', [2], false],
]);
assert.deepEqual(shape(groupSections(rows, { columns, groupKey: 'kind', groupKey2: 'place', groupKey3: 'year' })), [
['Kind a - x - 2025', [4], false],
['Kind a - x - 2024', [1], false],
['Kind a - y - 2025', [3], false],
['Kind b - x - 2025', [2], false],
]);
});
it('a collapsed group stands ONCE, with the first-level label and all its items across deeper levels', () => {
const s = groupSections(rows, { columns, groupKey: 'kind', groupKey2: 'place', collapsed: new Set(['a']) });
assert.deepEqual(shape(s), [
['Kind a', [1, 4, 3], true],
['Kind b - x', [2], false],
]);
});
it('collapsed tokens are ignored without the set (a table that cannot collapse)', () => {
assert.ok(groupSections(rows, { columns, groupKey: 'kind', collapsed: null }).every(s => !s.collapsed));
});
it('a value without a label function is used as it is, also on a deeper level', () => {
const cols = [{ key: 'kind', group: { value: r => r.kind } }, columns[2]];
assert.deepEqual(groupSections(rows, { columns: cols, groupKey: 'kind', groupKey2: 'year' }).map(s => s.label), ['a - 2025', 'a - 2024', 'b - 2025']);
});
it('a null group value is its own section, last, with token "null"', () => {
const r = [...rows, { id: 5, kind: null, place: 'x', year: 2025 }];
const s = groupSections(r, { columns, groupKey: 'kind' });
assert.deepEqual(s.map(x => x.token), ['a', 'b', 'null']);
});
it('no rows: no sections', () => {
assert.deepEqual(groupSections([], { columns, groupKey: 'kind' }), []);
});
});
+168
View File
@@ -0,0 +1,168 @@
// Generated, do not edit. sha256:2927734ac98d4252a0f01084377081712c0c677a75cd3c6e74946090a662fa75
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { installDom } from '../helpers/mini-dom.mjs';
import {
cellClasses, cellHtml, expCellHtml, headRowHtml, controlsHtml, actionsCellHtml, editActionsHtml, groupHeaderRowHtml, frameHtml,
} from '../../client/ui/table-markup.js';
const dom = installDom();
const labels = { new: 'New', save: 'Save', cancel: 'Cancel', none: 'Ungrouped', search: 'Search', count: 'Records', edit: 'Edit' };
const parse = html => { const el = dom.host(); el.innerHTML = html; return el; };
const row = cells => parse(`<table><tbody><tr>${cells}</tr></tbody></table>`);
describe('cells', () => {
it('classes: alignment and text, in that order, and nothing for a plain column', () => {
assert.deepEqual(cellClasses({ align: 'num', text: true }), ['num', 'text']);
assert.deepEqual(cellClasses({ align: 'center' }), ['center']);
assert.deepEqual(cellClasses({}), []);
});
it('a value without render is escaped; a render is markup; a text column clamps; data-label is escaped', () => {
const td = row(cellHtml({ key: 'n', label: 'A "b"', text: true }, { n: '<i>x</i>' })).querySelector('td');
assert.equal(td.querySelector('.cell-clamp').textContent, '<i>x</i>');
assert.equal(td.querySelector('i'), null);
assert.equal(td.dataset.label, 'A "b"');
const r = row(cellHtml({ key: 'n', render: v => `<b>${v.n}</b>` }, { n: 1 })).querySelector('td b');
assert.equal(r.textContent, '1');
});
it('a missing value is empty, not "undefined" or "null"', () => {
assert.equal(row(cellHtml({ key: 'n' }, { n: null })).querySelector('td').textContent, '');
assert.equal(row(cellHtml({ key: 'n' }, {})).querySelector('td').textContent, '');
});
it('the chevron cell only in a table with a drawer, turned when open', () => {
assert.equal(expCellHtml(false, true, 'v'), '');
const b = row(expCellHtml(true, true, '<i></i>')).querySelector('.chev-btn');
assert.ok(b.classList.contains('is-open'));
assert.equal(b.getAttribute('aria-hidden'), 'true');
});
});
describe('the head', () => {
const columns = [{ key: 'a', label: 'A', sortable: true, align: 'num' }, { key: 'b', label: 'B' }];
it('sortable headers carry data-sort and th-sort; the active one an arrow per direction', () => {
const head = parse(`<table><thead>${headRowHtml({ columns, sort: { key: 'a', dir: 'desc' }, hasExpansion: false, hasActions: false })}</thead></table>`);
const th = head.querySelector('th[data-sort="a"]');
assert.deepEqual(th.className.split(' '), ['num', 'th-sort']);
assert.equal(th.querySelector('.sort-arrow').textContent, '▼');
assert.equal(head.querySelectorAll('th[data-sort]').length, 1);
});
it('an empty header cell for the chevron and for the actions, only when there are', () => {
const with_ = parse(`<table><thead>${headRowHtml({ columns, sort: null, hasExpansion: true, hasActions: true })}</thead></table>`);
assert.equal(with_.querySelectorAll('th').length, 4);
const without = parse(`<table><thead>${headRowHtml({ columns, sort: null, hasExpansion: false, hasActions: false })}</thead></table>`);
assert.equal(without.querySelectorAll('th').length, 2);
});
});
describe('the controls', () => {
const groupCols = [{ key: 'g', label: 'G', group: {} }, { key: 'h', label: 'H', group: {} }, { key: 'i', label: 'I', group: {} }];
const base = { cfg: { rows: [1, 2], columns: groupCols }, labels, canCreate: true, groupCols, groupKey: null, groupKey2: null, groupKey3: null, search: '', editing: false };
const ctl = extra => parse(controlsHtml({ ...base, ...extra, cfg: { ...base.cfg, ...(extra.cfg || {}) } }));
it('the plus, the search box and the count; no group select deeper than chosen', () => {
const c = ctl({});
assert.ok(c.querySelector('[data-tbl-new]'));
assert.equal(c.querySelector('[data-tbl-new]').title, 'New');
assert.equal(c.querySelector('[data-tbl-search]').getAttribute('placeholder'), 'Search');
assert.equal(c.querySelector('.tbl-count').title, 'Records');
assert.equal(c.querySelector('[id]'), null, 'no ids: a page may hold two tables');
assert.equal(c.querySelector('.tbl-count').textContent, '2');
assert.equal(c.querySelectorAll('.tbl-group-select').length, 1);
assert.equal(c.querySelector('[data-gfilter]'), null);
});
it('without canCreate no plus, and a toolbarNote function is read and escaped', () => {
const c = ctl({ canCreate: false, cfg: { toolbarNote: () => '1 <van> 1' } });
assert.equal(c.querySelector('[data-tbl-new]'), null);
assert.equal(c.querySelector('.tbl-note').textContent, '1 <van> 1');
});
it('three levels: the second excludes the first, the third excludes both, and the filter slot appears', () => {
const c = ctl({ groupKey: 'g', groupKey2: 'h' });
assert.deepEqual(c.querySelector('[data-tbl-group="2"]').querySelectorAll('option').map(o => o.value), ['', 'h', 'i']);
assert.deepEqual(c.querySelector('[data-tbl-group="3"]').querySelectorAll('option').map(o => o.value), ['', 'i']);
assert.ok(c.querySelector('[data-gfilter]'));
});
it('groupChoice false: no select and no filter slot', () => {
const c = ctl({ groupKey: 'g', cfg: { groupChoice: false } });
assert.equal(c.querySelector('select'), null);
assert.equal(c.querySelector('[data-gfilter]'), null);
});
it('while editing everything is disabled; no style attribute anywhere', () => {
const html = controlsHtml({ ...base, groupKey: 'g', editing: true, cfg: { ...base.cfg, toolbarActions: [{ id: 'x', label: 'X' }] } });
const c = parse(html);
assert.ok(c.querySelectorAll('button, select, input').every(e => e.hasAttribute('disabled')));
assert.doesNotMatch(html, /style=/);
});
it('a toolbar action with an icon has the shape of the plus; without one it is a ghost button', () => {
const c = ctl({ cfg: { toolbarActions: [{ id: 'a', label: 'A', icon: '<svg></svg>' }, { id: 'b', label: '<B>' }] } });
assert.ok(c.querySelector('[data-tbl-act="a"].btn-new svg'));
assert.equal(c.querySelector('[data-tbl-act="b"].btn-ghost').textContent, '<B>');
});
});
describe('the action cell', () => {
const cfg = { onSave() {}, onDelete() {}, extraActions: [{ key: 'x', label: 'X' }] };
const cellOf = args => row(actionsCellHtml({ cfg, labels, idKey: 'id', editing: false, isEditRow: false, ...args })).querySelector('td');
it('the edit row: an empty cell, save and cancel stand in the drawer (user, 26-10-2026)', () => {
assert.equal(cellOf({ row: null, isEditRow: true }).children.length, 0);
const bar = parse(editActionsHtml({ cfg: { saveLabel: '<S>' }, labels }));
assert.equal(bar.querySelector('[data-save]').textContent, '<S>');
assert.equal(bar.querySelector('[data-cancel]').textContent, 'Cancel');
});
it('empty on a row that is not selected, and on every row while another is edited', () => {
assert.equal(cellOf({ row: { id: 1 }, selectedId: 2 }).children.length, 0);
assert.equal(cellOf({ row: { id: 1 }, selectedId: 1, editing: true }).children.length, 0);
});
it('ids compare as text: a numeric selected id matches a string row id', () => {
assert.ok(cellOf({ row: { id: '1' }, selectedId: 1 }).querySelector('[data-edit="1"]'));
});
it('the selected row: extras, edit and delete, each only when the page allows it for this row', () => {
const td = cellOf({ row: { id: 1 }, selectedId: 1 });
assert.ok(td.querySelector('[data-action="x"]') && td.querySelector('[data-edit="1"]') && td.querySelector('[data-del="1"]'));
const locked = row(actionsCellHtml({ cfg: { ...cfg, rowEditable: () => false, rowDeletable: () => false }, labels, idKey: 'id', row: { id: 1 }, selectedId: 1, editing: false, isEditRow: false })).querySelector('td');
assert.equal(locked.querySelector('[data-edit]'), null);
assert.equal(locked.querySelector('[data-del]'), null);
});
});
describe('the group header and the frame', () => {
it('escapes the label, keeps the summary as markup, and is a button only when collapsible', () => {
const tr = parse(`<table><tbody>${groupHeaderRowHtml({ token: 'a"b', label: '<Bank>', count: 3, summaryHtml: '<em>s</em>', isCollapsed: true, collapsible: true, colCount: 4, chevron: '' })}</tbody></table>`).querySelector('tr');
assert.ok(tr.classList.contains('is-collapsed'));
assert.equal(tr.querySelector('td').getAttribute('colspan'), '4');
assert.equal(tr.querySelector('.group-header > span').textContent, '<Bank>');
assert.equal(tr.querySelector('.group-summary em').textContent, 's');
assert.equal(tr.querySelector('[data-gcollapse]').dataset.gcollapse, 'a"b');
const plain = parse(`<table><tbody>${groupHeaderRowHtml({ token: 'a', label: 'A', count: 1, collapsible: false, colCount: 2, chevron: '' })}</tbody></table>`);
assert.equal(plain.querySelector('[data-gcollapse]'), null);
});
it('the frame: controls bar above the scrolling table, has-exp only with a drawer', () => {
const f = parse(frameHtml({ controls: '<i></i>', head: '<tr></tr>', body: '', hasExpansion: true }));
assert.ok(f.querySelector('.table-frame > .table-controls-bar > i'));
assert.ok(f.querySelector('.table-wrap.sticky-sel.has-exp > table > thead'));
assert.equal(parse(frameHtml({ controls: '', head: '', body: '', hasExpansion: false })).querySelector('.has-exp'), null);
});
it('heads the bar with an escaped title above the controls, and draws nothing without one', () => {
const html = frameHtml({ controls: '<i></i>', head: '', body: '', hasExpansion: false, title: 'Coins & <bars>' });
const bar = parse(html).querySelector('.table-controls-bar');
assert.ok(html.indexOf('tbl-title') < html.indexOf('<i>'), 'the title comes first, the controls under it');
assert.equal(bar.querySelector('.tbl-title').textContent, 'Coins & <bars>');
assert.equal(bar.querySelector('.tbl-title b, .tbl-title bars'), null, 'markup in a title is text');
assert.equal(parse(frameHtml({ controls: '', head: '', body: '', hasExpansion: false })).querySelector('.tbl-title'), null);
});
});
+166
View File
@@ -0,0 +1,166 @@
// Generated, do not edit. sha256:8f28252b7948ae2f830773204716ef793d6d67b8fb265b595cb86cfd46f43718
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
const { compare, sortRows, rowText, filterRows, groupValues, toggleToken, cycleGroupToken } =
await import('../../client/ui/table-rows.js');
const KOLOMMEN = [
{ key: 'naam' },
{ key: 'bedrag' },
{ key: 'rekening', render: r => `<span class="x">${r.rekening}</span>` },
{ key: 'jaar', group: { value: r => r.jaar, label: v => `Jaar ${v}` } },
];
const RIJ = (naam, bedrag, rekening, jaar) => ({ naam, bedrag, rekening, jaar });
describe('compare', () => {
it('sorteert getallen als getallen en tekst met de Nederlandse collator', () => {
assert.ok(compare(2, 10, 'asc') < 0);
assert.ok(compare('asset 2', 'asset 10', 'asc') < 0, 'numeric: 2 komt vóór 10, niet erna');
});
it('ZET LEEG ACHTERAAN, in béide richtingen', () => {
assert.ok(compare(null, 5, 'asc') > 0);
assert.ok(compare(null, 5, 'desc') > 0, 'ook omgekeerd blijft leeg onderaan');
assert.ok(compare(5, null, 'desc') < 0);
assert.equal(compare(null, null, 'asc'), 0);
});
it('behandelt undefined net als null', () => {
assert.ok(compare(undefined, 5, 'asc') > 0);
});
});
describe('sortRows', () => {
const rijen = [RIJ('b', 2), RIJ('a', 10), RIJ('c', null)];
it('geeft een KOPIE terug en laat de invoer met rust', () => {
const kopie = [...rijen];
sortRows(rijen, KOLOMMEN, { key: 'naam', dir: 'asc' });
assert.deepEqual(rijen, kopie, 'sorteren mag de meegegeven array niet omgooien');
});
it('sorteert op de gevraagde kolom', () => {
const uit = sortRows(rijen, KOLOMMEN, { key: 'naam', dir: 'asc' });
assert.deepEqual(uit.map(r => r.naam), ['a', 'b', 'c']);
});
it('gebruikt sortValue als de kolom die heeft', () => {
const kols = [{ key: 'naam', sortValue: r => r.bedrag }];
const uit = sortRows(rijen, kols, { key: 'naam', dir: 'asc' });
assert.deepEqual(uit.map(r => r.naam), ['b', 'a', 'c'], '2, 10, leeg');
});
it('LAAT DE RIJEN STAAN als er op een kolom gesorteerd wordt die niet meer bestaat', () => {
const uit = sortRows(rijen, KOLOMMEN, { key: 'verdwenen', dir: 'asc' });
assert.deepEqual(uit.map(r => r.naam), ['b', 'a', 'c']);
});
it('doet niets zonder sorteerstand', () => {
assert.deepEqual(sortRows(rijen, KOLOMMEN, null).map(r => r.naam), ['b', 'a', 'c']);
});
});
describe('rowText', () => {
it('neemt de GETOONDE waarde en niet de rauwe', () => {
const tekst = rowText(RIJ('a', 1, 'Kraken', 2026), KOLOMMEN);
assert.match(tekst, /kraken/);
});
it('vervangt markup door een SPATIE en niet door niets', () => {
const kols = [{ key: 'x', render: () => 'de<br>bank' }];
assert.match(rowText({ x: 1 }, kols), /de bank/);
});
it('overleeft een lege cel', () => {
assert.equal(rowText({ naam: null, bedrag: undefined }, [{ key: 'naam' }, { key: 'bedrag' }]).trim(), '');
});
});
describe('filterRows', () => {
const rijen = [RIJ('appel', 1, 'Kraken', 2025), RIJ('peer', 2, 'Bitvavo', 2026)];
it('filtert hoofdletterongevoelig op de zoektekst', () => {
assert.deepEqual(filterRows(rijen, KOLOMMEN, { search: 'APPEL' }).map(r => r.naam), ['appel']);
});
it('geeft alles terug bij een lege of witruimte-zoektekst', () => {
assert.equal(filterRows(rijen, KOLOMMEN, { search: ' ' }).length, 2);
assert.equal(filterRows(rijen, KOLOMMEN, {}).length, 2);
});
it('verbergt de groepen die op de verbergen-lijst staan', () => {
const uit = filterRows(rijen, KOLOMMEN, { groupKey: 'jaar', hidden: new Set(['2025']) });
assert.deepEqual(uit.map(r => r.naam), ['peer']);
});
it('NEGEERT EEN GROEPFILTER OP EEN KOLOM DIE NIET MEER BESTAAT', () => {
const uit = filterRows(rijen, KOLOMMEN, { groupKey: 'verdwenen', hidden: new Set(['x']) });
assert.equal(uit.length, 2);
});
it('combineert zoeken en verbergen', () => {
const uit = filterRows(rijen, KOLOMMEN, { search: 'e', groupKey: 'jaar', hidden: new Set(['2026']) });
assert.deepEqual(uit.map(r => r.naam), ['appel']);
});
});
describe('groupValues', () => {
const col = KOLOMMEN[3];
const rijen = [RIJ('a', 1, 'x', 2026), RIJ('b', 1, 'x', 2025), RIJ('c', 1, 'x', 2026)];
it('geeft elke waarde één keer, met zijn label', () => {
const uit = groupValues(rijen, col, 'asc');
assert.deepEqual(uit.map(g => g.token), ['2025', '2026']);
assert.deepEqual(uit.map(g => g.label), ['Jaar 2025', 'Jaar 2026']);
});
it('volgt de gevraagde richting', () => {
assert.deepEqual(groupValues(rijen, col, 'desc').map(g => g.token), ['2026', '2025']);
});
it('valt terug op de waarde zelf als de kolom geen label-functie heeft', () => {
const kaal = { group: { value: r => r.jaar } };
assert.deepEqual(groupValues(rijen, kaal, 'asc').map(g => g.label), [2025, 2026]);
});
});
describe('cycleGroupToken: drie standen', () => {
it('loopt de cyclus rond en komt terug waar hij begon', () => {
let s = cycleGroupToken('a', [], []);
assert.deepEqual(s, { hidden: [], collapsed: ['a'] }, 'eerste klik klapt in');
s = cycleGroupToken('a', s.hidden, s.collapsed);
assert.deepEqual(s, { hidden: ['a'], collapsed: [] }, 'tweede klik verbergt');
s = cycleGroupToken('a', s.hidden, s.collapsed);
assert.deepEqual(s, { hidden: [], collapsed: [] }, 'derde klik brengt hem helemaal terug');
});
it('BRENGT EEN VERBORGEN GROEP TERUG, ook als hij ook nog ingeklapt stond', () => {
const s = cycleGroupToken('a', ['a'], ['a']);
assert.deepEqual(s, { hidden: [], collapsed: [] });
});
it('laat andere tokens ongemoeid', () => {
const s = cycleGroupToken('a', ['b'], ['c']);
assert.deepEqual(s.hidden.sort(), ['b']);
assert.deepEqual(s.collapsed.sort(), ['a', 'c']);
});
it('geeft nieuwe arrays terug en muteert de meegegeven lijsten niet', () => {
const hidden = ['b'], collapsed = ['c'];
cycleGroupToken('a', hidden, collapsed);
assert.deepEqual(hidden, ['b']);
assert.deepEqual(collapsed, ['c']);
});
});
describe('toggleToken', () => {
it('zet erin en haalt eruit', () => {
assert.deepEqual(toggleToken('a', []), ['a']);
assert.deepEqual(toggleToken('a', ['a']), []);
});
it('overleeft een ontbrekende lijst', () => {
assert.deepEqual(toggleToken('a', undefined), ['a']);
});
});
+96
View File
@@ -0,0 +1,96 @@
// Generated, do not edit. sha256:455789d5a2acadd3972e4561a3135c6234ab395e2d111780c60328e8b4850468
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { initialState, chooseGroup, revealGroup } from '../../client/ui/table-state.js';
const columns = [
{ key: 'name' },
{ key: 'kind', group: { value: r => r.kind } },
{ key: 'place', group: { value: r => r.place } },
{ key: 'year', group: { value: r => r.year } },
];
describe('initialState', () => {
it('without stored state: no sort, no grouping, empty maps, no sticky', () => {
assert.deepEqual(initialState({ columns }), {
sort: null, groupKey: null, groupKey2: null, groupKey3: null, groupFilter: {}, groupCollapsed: {}, sticky: null,
});
});
it('a stored sort on a column that is gone falls back on defaultSort, and that one is checked too', () => {
assert.deepEqual(initialState({ columns, sort: { key: 'gone', dir: 'asc' }, defaultSort: { key: 'name', dir: 'desc' } }).sort, { key: 'name', dir: 'desc' });
assert.equal(initialState({ columns, sort: { key: 'gone' }, defaultSort: { key: 'also-gone' } }).sort, null);
});
it('a stored group on a column that is gone or cannot group is ignored, per level', () => {
const s = initialState({ columns, group: 'name', group2: 'gone', group3: 'year' });
assert.equal(s.groupKey, null);
assert.equal(s.groupKey2, null);
assert.equal(s.groupKey3, 'year');
});
it('copies what it takes, so the configuration is never changed through the state', () => {
const cfg = { columns, sort: { key: 'name', dir: 'asc' }, groupFilter: { kind: ['a'] }, groupCollapsed: { kind: ['b'] }, sticky: { name: 'x' } };
const s = initialState(cfg);
s.sort.dir = 'desc';
s.groupFilter.kind = [];
s.groupCollapsed.place = ['p'];
s.sticky.name = 'y';
assert.deepEqual(cfg.sort, { key: 'name', dir: 'asc' });
assert.deepEqual(cfg.groupFilter, { kind: ['a'] });
assert.deepEqual(cfg.groupCollapsed, { kind: ['b'] });
assert.deepEqual(cfg.sticky, { name: 'x' });
});
});
describe('chooseGroup', () => {
const all = { groupKey: 'kind', groupKey2: 'place', groupKey3: 'year' };
it('level 1 to none drops the deeper levels', () => {
assert.deepEqual(chooseGroup(all, 1, ''), { groupKey: null, groupKey2: null, groupKey3: null });
assert.deepEqual(chooseGroup(all, 1, null), { groupKey: null, groupKey2: null, groupKey3: null });
});
it('level 1 onto a column used deeper takes it from that level, and keeps the other', () => {
assert.deepEqual(chooseGroup(all, 1, 'place'), { groupKey: 'place', groupKey2: null, groupKey3: 'year' });
assert.deepEqual(chooseGroup(all, 1, 'year'), { groupKey: 'year', groupKey2: 'place', groupKey3: null });
});
it('level 2 to none drops level 3; level 2 onto level 3 clears level 3', () => {
assert.deepEqual(chooseGroup(all, 2, ''), { groupKey: 'kind', groupKey2: null, groupKey3: null });
assert.deepEqual(chooseGroup(all, 2, 'year'), { groupKey: 'kind', groupKey2: 'year', groupKey3: null });
});
it('level 3 only sets level 3, and the input is not modified', () => {
const keys = { ...all };
assert.deepEqual(chooseGroup(keys, 3, ''), { groupKey: 'kind', groupKey2: 'place', groupKey3: null });
assert.deepEqual(keys, all);
});
});
describe('revealGroup', () => {
it('takes the token out of the filter AND unfolds it, and says it changed', () => {
const r = revealGroup({ groupKey: 'kind', groupFilter: { kind: ['a', 'b'] }, groupCollapsed: { kind: ['a'] } }, 'a');
assert.deepEqual(r, { groupFilter: { kind: ['b'] }, groupCollapsed: { kind: [] }, changed: true });
});
it('only collapsed, or only hidden, is a change too', () => {
assert.equal(revealGroup({ groupKey: 'kind', groupFilter: {}, groupCollapsed: { kind: ['a'] } }, 'a').changed, true);
assert.equal(revealGroup({ groupKey: 'kind', groupFilter: { kind: ['a'] }, groupCollapsed: {} }, 'a').changed, true);
});
it('a visible group, or no grouping at all, changes nothing and returns the same maps', () => {
const groupFilter = { kind: ['b'] };
const groupCollapsed = {};
const r = revealGroup({ groupKey: 'kind', groupFilter, groupCollapsed }, 'a');
assert.equal(r.changed, false);
assert.equal(r.groupFilter, groupFilter);
assert.equal(revealGroup({ groupKey: null, groupFilter: { kind: ['a'] }, groupCollapsed: {} }, 'a').changed, false);
});
it('does not modify the maps it was given', () => {
const groupFilter = { kind: ['a'], place: ['x'] };
revealGroup({ groupKey: 'kind', groupFilter, groupCollapsed: {} }, 'a');
assert.deepEqual(groupFilter, { kind: ['a'], place: ['x'] });
});
});
+84
View File
@@ -0,0 +1,84 @@
// Generated, do not edit. sha256:da1d7ac61eaec602c87625209323742726a3b8d623b0c8ecbe617aa82923969a
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { seedSticky, harvestSticky } from '../../client/ui/table-sticky.js';
const cols = [
{ key: 'accountId', edit: { type: 'select', sticky: true } },
{ key: 'assetId', edit: { type: 'select', sticky: true } },
{ key: 'occurredAt', edit: { type: 'date', sticky: true } },
{ key: 'amount', edit: { type: 'number' } },
{ key: 'note', edit: { type: 'text' } },
];
const options = new Map([
['accountId', [{ value: 1, label: 'Bank' }, { value: 3, label: 'Raisin' }]],
['assetId', [{ value: 7, label: 'Goud' }]],
]);
describe('seedSticky: what a new row starts with', () => {
it('carries the remembered values over the page defaults', () => {
const out = seedSticky({
columns: cols,
seeded: { accountId: '', assetId: '', occurredAt: '2026-09-06', amount: '', note: '' },
remembered: { accountId: 3, assetId: 7, occurredAt: '2026-03-31' },
options,
});
assert.equal(out.accountId, 3);
assert.equal(out.occurredAt, '2026-03-31');
assert.equal(out.amount, '');
});
it('leaves the seeded values alone when nothing was remembered', () => {
const seeded = { accountId: '', amount: '' };
assert.deepEqual(seedSticky({ columns: cols, seeded, remembered: null, options }), seeded);
assert.deepEqual(seedSticky({ columns: cols, seeded, remembered: {}, options }), seeded);
});
it('does not modify the object it was given', () => {
const seeded = { accountId: '' };
seedSticky({ columns: cols, seeded, remembered: { accountId: 3 }, options });
assert.equal(seeded.accountId, '');
});
it('drops a remembered choice that is no longer in the option list', () => {
const out = seedSticky({ columns: cols, seeded: { accountId: '' }, remembered: { accountId: 99 }, options });
assert.equal(out.accountId, '', 'a stale choice falls back to the default');
});
it('compares a number against a string option value, both ways', () => {
assert.equal(seedSticky({ columns: cols, seeded: {}, remembered: { accountId: '3' }, options }).accountId, '3');
assert.equal(seedSticky({ columns: cols, seeded: {}, remembered: { accountId: 3 }, options }).accountId, 3);
});
it('ignores a remembered key that has no column any more, and a column that is not sticky', () => {
assert.equal(seedSticky({ columns: cols, seeded: { accountId: '' }, remembered: { accountId: 3, goneColumn: 'x' }, options }).goneColumn, undefined);
assert.equal(seedSticky({ columns: cols, seeded: { amount: '' }, remembered: { amount: '250' }, options }).amount, '');
});
it('keeps a page marker that is not a column', () => {
const out = seedSticky({ columns: cols, seeded: { _new: 1, accountId: '' }, remembered: { accountId: 3 }, options });
assert.equal(out._new, 1);
});
it('keeps a value whose column has no option list, also when the lists failed to load', () => {
assert.equal(seedSticky({ columns: cols, seeded: {}, remembered: { occurredAt: '2026-03-31' }, options }).occurredAt, '2026-03-31');
assert.equal(seedSticky({ columns: cols, seeded: {}, remembered: { accountId: 3 }, options: undefined }).accountId, 3);
});
});
describe('harvestSticky: what gets remembered', () => {
it('keeps the sticky columns and nothing else', () => {
assert.deepEqual(harvestSticky(cols, { accountId: 3, assetId: 7, occurredAt: '2026-03-31', amount: '250', note: 'x' }),
{ accountId: 3, assetId: 7, occurredAt: '2026-03-31' });
});
it('keeps a deliberately empty value, skips a hidden field, never lets a non-column through', () => {
assert.deepEqual(harvestSticky(cols, { accountId: '' }), { accountId: '' });
assert.deepEqual(harvestSticky(cols, { accountId: 3, assetId: undefined }), { accountId: 3 });
assert.deepEqual(harvestSticky(cols, { _new: 1, accountId: 3 }), { accountId: 3 });
});
it('survives being called without values', () => {
assert.deepEqual(harvestSticky(cols, null), {});
});
});
+84
View File
@@ -0,0 +1,84 @@
// Generated, do not edit. sha256:343dcdbc15ae356dbb8783f015151f9b7adeb88cf1b2d498ee256dd978f6bd8e
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
const opslag = new Map();
const sleutel = (scope, key) => `${scope}::${key}`;
mock.module('../../client/data/_rpc.js', {
namedExports: {
async rpc(module, functie, ...args) {
if (module !== 'preferences') throw new Error(`onverwachte module in deze test: ${module}`);
if (functie === 'getPref') {
const [scope, key, fallback] = args;
return opslag.has(sleutel(scope, key)) ? opslag.get(sleutel(scope, key)) : fallback;
}
if (functie === 'setPref') {
const [scope, key, waarde] = args;
opslag.set(sleutel(scope, key), waarde);
return null;
}
throw new Error(`onverwachte functie in deze test: ${functie}`);
},
decodeer: v => v,
setRowGuard() {},
rowGuardAan: () => false,
},
});
const { tableViewCfg } = await import('../../client/data/preferences.js');
beforeEach(() => opslag.clear());
describe('tableViewCfg', () => {
it('geeft de meegegeven standaardsortering als er nog niets bewaard is', async () => {
const cfg = await tableViewCfg('trades', { key: 'occurredAt', dir: 'desc' });
assert.deepEqual(cfg.sort, { key: 'occurredAt', dir: 'desc' });
assert.equal(cfg.group, null);
assert.deepEqual(cfg.groupFilter, {});
assert.deepEqual(cfg.groupCollapsed, {});
});
it('vult de nieuwere sleutels aan die een oudere bewaarde stand nog niet had', async () => {
opslag.set(sleutel('table.trades', 'view'),
{ sort: { key: 'amount', dir: 'asc' }, group: 'kind', groupFilter: { kind: ['buy'] } });
const cfg = await tableViewCfg('trades', { key: 'occurredAt', dir: 'desc' });
assert.deepEqual(cfg.sort, { key: 'amount', dir: 'asc' }, 'de bewaarde sortering wint');
assert.equal(cfg.group, 'kind');
assert.deepEqual(cfg.groupFilter, { kind: ['buy'] });
assert.deepEqual(cfg.groupCollapsed, {}, 'de ontbrekende sleutel wordt aangevuld');
assert.equal(cfg.group2, null);
assert.equal(cfg.group3, null);
});
it('geeft de standaardsortering MEE, ook als er een bewaarde stand is', async () => {
opslag.set(sleutel('table.trades', 'view'), { sort: { key: 'weg', dir: 'asc' }, group: null });
const cfg = await tableViewCfg('trades', { key: 'occurredAt', dir: 'desc' });
assert.deepEqual(cfg.defaultSort, { key: 'occurredAt', dir: 'desc' });
});
it('schrijft een wijziging terug via onStateChange', async () => {
const cfg = await tableViewCfg('trades', null);
await cfg.onStateChange({ sort: null, group: 'kind', group2: null, group3: null, groupFilter: {}, groupCollapsed: { kind: ['buy'] } });
assert.deepEqual(opslag.get(sleutel('table.trades', 'view')).groupCollapsed, { kind: ['buy'] });
});
it('negeert met een vaste groepering de bewaarde stand en schrijft niets terug', async () => {
opslag.set(sleutel('table.trades', 'view'),
{ sort: { key: 'amount', dir: 'asc' }, group: 'kind', groupFilter: { kind: ['buy'] } });
opslag.set(sleutel('table.trades', 'sticky'), { loc: 3 });
const cfg = await tableViewCfg('trades', { key: 'occurredAt', dir: 'desc' }, 'assetId');
assert.deepEqual(cfg.sort, { key: 'occurredAt', dir: 'desc' }, 'de standaard, niet de bewaarde');
assert.equal(cfg.group, 'assetId');
assert.equal(cfg.groupChoice, false);
assert.equal(cfg.groupFilter, undefined, 'geen bewaard filter');
assert.equal(cfg.onStateChange, undefined, 'er wordt niets teruggeschreven');
assert.deepEqual(cfg.sticky, { loc: 3 }, 'de laatst gebruikte waarden van een nieuwe regel blijven');
});
it('laat een mislukte schrijfactie de tabel niet breken', async () => {
const cfg = await tableViewCfg('trades', null);
const stuk = { get sort() { throw new Error('kapot'); } };
await assert.doesNotReject(() => Promise.resolve(cfg.onStateChange(stuk)));
});
});
+866
View File
@@ -0,0 +1,866 @@
// Generated, do not edit. sha256:a809a4a473181ef4f5b8a9d543bc95794a28448e3a837c586993056997db7bcb
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { installDom } from '../helpers/mini-dom.mjs';
const dom = installDom();
const { renderTable, configureTable, keyAction } = await import('../../client/ui/table.js');
let REPEAT_KEY = 'ctrl';
const converted = [];
configureTable({
repeatKey: () => REPEAT_KEY,
convert: { canConvert: a => !!(a && a.subunit), open: (trigger, o) => converted.push(o), icon: '<i class="swap"></i>' },
});
const tick = () => new Promise(r => setImmediate(r));
const ROWS = () => [
{ id: 1, name: 'Bob', kind: 'a', amount: 3, note: 'first' },
{ id: 2, name: '<Al>', kind: 'b', amount: 10, note: 'second' },
{ id: 3, name: 'Cy', kind: 'a', amount: null, note: 'third' },
];
const COLUMNS = () => [
{ key: 'name', label: 'Naam', sortable: true, text: true, edit: { type: 'text' } },
{ key: 'kind', label: 'Soort', group: { value: r => r.kind, label: v => `Soort ${v}` } },
{ key: 'amount', label: 'Bedrag', align: 'num', sortable: true, edit: { type: 'number' } },
];
function mount(extra = {}) {
const host = dom.host();
const cfg = { columns: COLUMNS(), rows: ROWS(), ...extra };
const api = renderTable(host, cfg);
return { host, api, cfg };
}
const ids = host => host.querySelectorAll('tr[data-row-id]').map(r => r.dataset.rowId);
const row = (host, id) => host.querySelector(`tr[data-row-id="${id}"]`);
const editRow = host => host.querySelector('[data-edit-row]');
const field = (host, key) => host.querySelector(`[data-field="${key}"]`);
const groups = host => host.querySelectorAll('.group-header').map(g => g.querySelector('span').textContent);
describe('renderTable: the frame and the cells', () => {
it('builds the frame, the header, one row per record and the record count', () => {
const { host } = mount();
assert.ok(host.querySelector('.table-frame > .table-controls-bar > .table-controls'));
assert.ok(host.querySelector('.table-frame > .table-wrap > table > thead'));
assert.deepEqual(host.querySelectorAll('thead th').map(th => th.textContent), ['Naam', 'Soort', 'Bedrag']);
assert.deepEqual(ids(host), ['1', '2', '3']);
assert.equal(host.querySelector('.tbl-count').textContent, '3');
});
it('escapes a value without render, and uses the markup of a render as it is', () => {
const cols = COLUMNS();
cols[2].render = r => `<b>${r.amount ?? '-'}</b>`;
const { host } = mount({ columns: cols });
const cell = row(host, 2).querySelectorAll('td')[0];
assert.equal(cell.textContent, '<Al>');
assert.equal(cell.querySelector('al'), null);
assert.equal(row(host, 2).querySelector('b').textContent, '10');
});
it('a text column wraps its content in .cell-clamp, a num column gets .num, every cell a data-label', () => {
const { host } = mount();
const tds = row(host, 1).querySelectorAll('td');
assert.ok(tds[0].classList.contains('text'));
assert.ok(tds[0].querySelector('.cell-clamp'));
assert.ok(tds[2].classList.contains('num'));
assert.equal(tds[2].querySelector('.cell-clamp'), null);
assert.deepEqual(tds.map(td => td.dataset.label), ['Naam', 'Soort', 'Bedrag']);
});
it('alternates a tint over DATA rows only', () => {
const { host } = mount();
assert.deepEqual(host.querySelectorAll('tr[data-row-id]').map(r => r.classList.contains('row-alt')), [false, true, false]);
});
it('no rows: one empty spacer row over all columns, and no message', () => {
const { host } = mount({ rows: [], onDelete: () => {} });
const spacer = host.querySelectorAll('tbody tr');
assert.equal(spacer.length, 1);
assert.ok(spacer[0].classList.contains('tbl-empty-spacer'));
assert.equal(spacer[0].querySelector('td').getAttribute('colspan'), '4');
});
it('an action column exists only when there is something to act on', () => {
assert.equal(mount().host.querySelector('th.actions-cell'), null);
assert.ok(mount({ onDelete: () => {} }).host.querySelector('th.actions-cell'));
assert.ok(mount({ extraActions: [{ key: 'x', label: 'X' }] }).host.querySelector('th.actions-cell'));
});
});
describe('renderTable: sorting', () => {
it('a click on a sortable header sorts ascending, a second click descending; empty stays last', () => {
const states = [];
const raw = [];
const { host } = mount({ onStateChange: s => { states.push({ ...s.sort }); raw.push(s.sort); } });
assert.equal(host.querySelector('th[data-sort="name"]') !== null, true);
assert.equal(host.querySelectorAll('th[data-sort]').length, 2);
dom.click(host.querySelector('th[data-sort="amount"]'));
assert.deepEqual(ids(host), ['1', '2', '3']);
assert.equal(host.querySelector('th[data-sort="amount"] .sort-arrow').textContent, '▲');
dom.click(host.querySelector('th[data-sort="amount"]'));
assert.deepEqual(ids(host), ['2', '1', '3']);
assert.equal(host.querySelector('th[data-sort="amount"] .sort-arrow').textContent, '▼');
assert.deepEqual(states, [{ key: 'amount', dir: 'asc' }, { key: 'amount', dir: 'desc' }]);
assert.notEqual(raw[0], raw[1]);
assert.equal(raw[0].dir, 'asc');
});
it('a stored sort on a column that no longer exists falls back on the default, and then on none', () => {
assert.deepEqual(ids(mount({ sort: { key: 'gone', dir: 'asc' }, defaultSort: { key: 'name', dir: 'desc' } }).host), ['3', '1', '2']);
assert.deepEqual(ids(mount({ sort: { key: 'gone', dir: 'asc' } }).host), ['1', '2', '3']);
});
it('sortValue decides the order instead of the raw value', () => {
const cols = COLUMNS();
cols[0].sortValue = r => -r.id;
assert.deepEqual(ids(mount({ columns: cols, sort: { key: 'name', dir: 'asc' } }).host), ['3', '2', '1']);
});
});
describe('renderTable: searching', () => {
it('filters on the rendered text, redraws only the body, and keeps the count on all records', () => {
const cols = COLUMNS();
cols[2].render = r => `<i>EUR ${r.amount}</i>`;
const { host } = mount({ columns: cols });
const box = host.querySelector('[data-tbl-search]');
box.focus();
dom.type(box, 'eur 10');
assert.deepEqual(ids(host), ['2']);
assert.equal(host.querySelector('[data-tbl-search]'), box, 'the search box is the same element');
assert.equal(host.querySelector('.tbl-count').textContent, '3');
dom.type(box, '');
assert.deepEqual(ids(host), ['1', '2', '3']);
});
it('a search that finds nothing leaves the empty spacer', () => {
const { host } = mount();
dom.type(host.querySelector('[data-tbl-search]'), 'zzz');
assert.ok(host.querySelector('tr.tbl-empty-spacer'));
});
});
describe('renderTable: grouping', () => {
it('a stored group draws a header per value, with escaped label and count', () => {
const cols = COLUMNS();
cols[1].group.label = v => `<${v}>`;
const { host } = mount({ columns: cols, group: 'kind' });
assert.deepEqual(groups(host), ['<a>', '<b>']);
assert.deepEqual(host.querySelectorAll('.group-count').map(c => c.textContent), ['2', '1']);
assert.deepEqual(ids(host), ['1', '3', '2']);
assert.equal(host.querySelector('[data-tbl-group="1"]').value, 'kind');
});
it('group.order desc turns the sections, and a sort on the group column decides instead', () => {
const cols = COLUMNS();
cols[1].group.order = 'desc';
assert.deepEqual(groups(mount({ columns: cols, group: 'kind' }).host), ['Soort b', 'Soort a']);
cols[1].sortable = true;
assert.deepEqual(groups(mount({ columns: cols, group: 'kind', sort: { key: 'kind', dir: 'asc' } }).host), ['Soort a', 'Soort b']);
});
it('three levels: the third select appears only when there is a column left for it', () => {
const cols = COLUMNS();
cols[0].group = { value: r => r.name[0] };
cols[2].group = { value: r => (r.amount == null ? 'leeg' : 'vol') };
const { host } = mount({ columns: cols, group: 'kind', group2: 'name', group3: 'amount' });
assert.ok(host.querySelector('[data-tbl-group="3"]'));
assert.deepEqual(groups(host), ['Soort a - B - vol', 'Soort a - C - leeg', 'Soort b - < - vol']);
});
it('a stored group on a column that cannot group is ignored', () => {
const { host } = mount({ group: 'name' });
assert.equal(host.querySelectorAll('.group-row').length, 0);
});
it('choosing in the select groups and stores; choosing none ungroups and drops the deeper levels', () => {
const states = [];
const cols = COLUMNS();
cols[0].group = { value: r => r.name[0] };
const { host } = mount({ columns: cols, onStateChange: s => states.push({ g: s.group, g2: s.group2 }) });
dom.choose(host.querySelector('[data-tbl-group="1"]'), 'kind');
assert.deepEqual(groups(host), ['Soort a', 'Soort b']);
dom.choose(host.querySelector('[data-tbl-group="2"]'), 'name');
assert.deepEqual(groups(host), ['Soort a - B', 'Soort a - C', 'Soort b - <']);
dom.choose(host.querySelector('[data-tbl-group="1"]'), '');
assert.equal(host.querySelectorAll('.group-row').length, 0);
assert.deepEqual(states.at(-1), { g: null, g2: null });
});
it('groupChoice false: grouped, but no select and no filter', () => {
const { host } = mount({ group: 'kind', groupChoice: false });
assert.equal(host.querySelectorAll('.group-row').length, 2);
assert.equal(host.querySelector('[data-tbl-group="1"]'), null);
assert.equal(host.querySelector('[data-gfilter]'), null);
});
it('the group filter hides a group, stores the HIDDEN token, and its chip stays', () => {
const states = [];
const { host } = mount({ group: 'kind', onStateChange: s => states.push(s.groupFilter) });
const chips = host.querySelectorAll('[data-gfilter] [data-fi]');
assert.equal(chips.length, 2);
dom.click(chips[1]);
assert.deepEqual(ids(host), ['1', '3']);
assert.deepEqual(states.at(-1), { kind: ['b'] });
assert.equal(host.querySelectorAll('[data-gfilter] [data-fi]').length, 2);
});
it('collapsible groups: a header click folds the rows away and shows the summary over all items', () => {
let summed = null;
const { host } = mount({ group: 'kind', collapsibleGroups: true, groupSummary: (items, v) => { summed = [v, items.length]; return '<em>sum</em>'; } });
dom.click(host.querySelector('[data-gcollapse="a"]'));
assert.deepEqual(ids(host), ['2']);
assert.ok(host.querySelector('.group-row.is-collapsed'));
assert.deepEqual(summed, ['a', 2]);
assert.equal(host.querySelector('.group-summary em').textContent, 'sum');
dom.click(host.querySelector('[data-gcollapse="a"]'));
assert.deepEqual(ids(host), ['1', '3', '2']);
});
it('a chip in a collapsible table cycles: open, collapsed, hidden, open', () => {
const { host } = mount({ group: 'kind', collapsibleGroups: true });
const chipA = () => host.querySelectorAll('[data-gfilter] [data-fi]')[0];
dom.click(chipA());
assert.deepEqual(ids(host), ['2']);
assert.ok(host.querySelector('[data-gcollapse="a"]'));
dom.click(chipA());
assert.equal(host.querySelector('[data-gcollapse="a"]'), null);
dom.click(chipA());
assert.deepEqual(ids(host), ['1', '3', '2']);
});
});
describe('renderTable: selecting and the row actions', () => {
it('a click selects a row and shows its actions; a second click deselects', () => {
const { host } = mount({ onSave: () => {}, onDelete: () => {}, extraActions: [{ key: 'x', label: 'X' }] });
dom.click(row(host, 2).querySelector('td'));
assert.ok(row(host, 2).classList.contains('row-selected'));
const acts = row(host, 2).querySelectorAll('.row-actions button');
assert.deepEqual(acts.map(b => b.dataset.action || (b.dataset.edit ? 'edit' : 'del')), ['x', 'edit', 'del']);
assert.equal(row(host, 1).querySelectorAll('.row-actions button').length, 0);
dom.click(row(host, 2).querySelector('td'));
assert.equal(row(host, 2).classList.contains('row-selected'), false);
});
it('rowEditable and rowDeletable suppress the buttons per row', () => {
const { host } = mount({ onSave: () => {}, onDelete: () => {}, rowEditable: r => r.id !== 1, rowDeletable: r => r.id !== 1 });
dom.click(row(host, 1).querySelector('td'));
assert.equal(row(host, 1).querySelectorAll('.row-actions button').length, 0);
});
it('delete and an extra action call back with a NUMBER id', () => {
const calls = [];
const { host } = mount({ onDelete: id => calls.push(['del', id]), onAction: (k, id) => calls.push([k, id]), extraActions: [{ key: 'x', label: 'X' }] });
dom.click(row(host, 3).querySelector('td'));
dom.click(host.querySelector('[data-action="x"]'));
dom.click(host.querySelector('[data-del]'));
assert.deepEqual(calls, [['x', 3], ['del', 3]]);
assert.equal(typeof calls[0][1], 'number');
});
it('a drawer: a chevron on every row, content on select, and the mount runs again after a redraw', () => {
const mounts = [];
const { host } = mount({ rowExpansion: r => `<p>drawer ${r.id}</p>`, onExpansionMount: (r, el) => mounts.push([r.id, el.localName]) });
assert.equal(host.querySelectorAll('td.exp-cell .chev-btn').length, 3);
assert.ok(host.querySelector('.table-wrap.has-exp.sticky-sel'));
dom.click(row(host, 1).querySelectorAll('td')[1]);
assert.equal(host.querySelector('tr.row-expansion[data-exp-id="1"] p').textContent, 'drawer 1');
assert.ok(row(host, 1).querySelector('.chev-btn.is-open'));
dom.click(host.querySelector('th[data-sort="name"]'));
assert.deepEqual(mounts, [[1, 'td'], [1, 'td']]);
});
});
describe('renderTable: editing an existing row', () => {
async function editing(extra = {}) {
const saved = [];
const t = mount({ onSave: async (id, v) => { saved.push([id, { ...v }]); }, ...extra });
dom.click(row(t.host, 1).querySelector('td'));
dom.click(t.host.querySelector('[data-edit]'));
await tick();
return { ...t, saved };
}
it('opens an edit row seeded from the record, focuses the first field, and locks the rest', async () => {
const { host } = await editing();
const er = editRow(host);
assert.ok(er);
assert.equal(field(host, 'name').value, 'Bob');
assert.equal(field(host, 'amount').value, '3');
assert.equal(dom.document.activeElement, field(host, 'name'));
assert.ok(host.querySelector('[data-tbl-search]').hasAttribute('disabled'));
dom.click(row(host, 2).querySelector('td'));
assert.equal(row(host, 2).classList.contains('row-selected'), false);
});
it('save hands the typed values to onSave and closes the row', async () => {
const { host, saved } = await editing();
field(host, 'name').value = 'Bobby';
dom.click(host.querySelector('[data-save]'));
await tick();
assert.deepEqual(saved, [[1, { name: 'Bobby', amount: '3' }]]);
assert.equal(editRow(host), null);
});
it('a failing save keeps the row open and shows the message, escaped', async () => {
const { host } = await editing({ onSave: async () => { throw new Error('<nope>'); } });
dom.click(host.querySelector('[data-save]'));
await tick();
assert.ok(editRow(host));
assert.equal(host.querySelector('.edit-expand-row .form-error').textContent, '<nope>');
});
it('save and cancel stand in the drawer under the fields, not in the edit row (user, 26-10-2026)', async () => {
const { host } = await editing();
assert.equal(editRow(host).querySelector('button'), null);
const drawer = host.querySelector('tr.edit-expand-row');
assert.ok(drawer, 'a drawer under the edit row, also without drawer fields');
assert.ok(drawer.querySelector('.edit-actions [data-save]') && drawer.querySelector('.edit-actions [data-cancel]'));
});
it('cancel closes without saving', async () => {
const { host, saved } = await editing();
dom.click(host.querySelector('[data-cancel]'));
assert.equal(editRow(host), null);
assert.deepEqual(saved, []);
});
it('edit.value seeds instead of the raw field', async () => {
const cols = COLUMNS();
cols[0].edit.value = r => r.name.toUpperCase();
const { host } = await editing({ columns: cols });
assert.equal(field(host, 'name').value, 'BOB');
});
});
describe('renderTable: the edit controls', () => {
async function newRow(cols, extra = {}) {
const created = [];
const t = mount({ columns: cols, onCreate: async v => { created.push({ ...v }); }, ...extra });
dom.click(t.host.querySelector('[data-tbl-new]'));
await tick();
return { ...t, created };
}
it('a select gets its options from an async callback, and shows the current value', async () => {
const cols = COLUMNS();
cols[1].edit = { type: 'select', options: async vals => [{ value: 'a', label: 'A' }, 'b'].concat(vals.kind ? [] : []) };
const { host } = await newRow(cols, { newDefaults: { kind: 'b' } });
const sel = field(host, 'kind');
assert.deepEqual(sel.querySelectorAll('option').map(o => [o.value, o.textContent]), [['a', 'A'], ['b', 'b']]);
assert.equal(sel.value, 'b');
});
it('a checkbox is read as a boolean', async () => {
const cols = [...COLUMNS(), { key: 'on', label: 'Aan', edit: { type: 'checkbox' } }];
const { host, created } = await newRow(cols);
field(host, 'on').checked = true;
dom.click(host.querySelector('[data-save]'));
await tick();
assert.equal(created[0].on, true);
});
it('a hidden field (edit.visible) keeps its value, and an expand field sits in the drawer row', async () => {
const cols = COLUMNS();
cols[2].edit.visible = () => false;
cols.push({ key: 'note', label: 'Notitie', edit: { type: 'textarea', expand: true } });
const { host, created } = await newRow(cols, { newDefaults: { amount: 7 } });
assert.equal(field(host, 'amount'), null);
assert.ok(host.querySelector('.edit-expand-row [data-field="note"]'));
host.querySelector('[data-field="note"]').value = 'n';
dom.click(host.querySelector('[data-save]'));
await tick();
assert.equal(created[0].amount, 7);
assert.equal(created[0].note, 'n');
});
it('a reactive change hands a copy, the field and the touched set to onEditChange, and applies its patch', async () => {
const seen = [];
const cols = COLUMNS();
cols[0].edit.reactive = true;
const { host } = await newRow(cols, {
onEditChange: (vals, key, touched) => { seen.push([vals.name, key, [...touched]]); return { amount: vals.name.length }; },
});
const name = field(host, 'name');
name.value = 'abcd';
dom.fire(name, 'input');
dom.fire(name, 'change');
await tick();
assert.deepEqual(seen, [['abcd', 'name', ['name']]]);
assert.equal(field(host, 'amount').value, '4');
});
it('options as plain strings become value and label (the integrations pages pass HTTP methods that way)', async () => {
const cols = COLUMNS();
cols[1].edit = { type: 'select', options: ['GET', 'POST'] };
const { host } = await newRow(cols);
assert.deepEqual(field(host, 'kind').querySelectorAll('option').map(o => [o.value, o.textContent]), [['GET', 'GET'], ['POST', 'POST']]);
});
it('a placeholder may be a function of the values, and attrs land escaped on the control', async () => {
const cols = COLUMNS();
cols[0].edit = { type: 'text', placeholder: v => `voor ${v.kind}`, attrs: { maxlength: '5', 'data-x': 'a"b' } };
const { host } = await newRow(cols, { newDefaults: { kind: 'k' } });
const f = field(host, 'name');
assert.equal(f.getAttribute('placeholder'), 'voor k');
assert.equal(f.getAttribute('maxlength'), '5');
assert.equal(f.getAttribute('data-x'), 'a"b');
});
it('edit.render supplies its own control, and edit.read reads it back', async () => {
const cols = COLUMNS();
cols[0].edit = { render: v => `<span data-field="name"><input class="own" value="${v.name ?? ''}"></span>`, read: host => host.querySelector('.own').value + '!' };
const { host, created } = await newRow(cols, { newDefaults: { name: 'x' } });
host.querySelector('.own').value = 'y';
dom.click(host.querySelector('[data-save]'));
await tick();
assert.equal(created[0].name, 'y!');
});
it('a convert trigger appears only when the chosen asset has a subunit', async () => {
const cols = COLUMNS();
cols[2].edit = { type: 'number', convert: v => (v.kind === 'btc' ? { subunit: 'sat' } : { subunit: '' }) };
const withSub = await newRow(cols, { newDefaults: { kind: 'btc' }, convertLabel: 'Omrekenen' });
assert.ok(withSub.host.querySelector('.conv-field [data-convert="amount"]'));
assert.equal(withSub.host.querySelector('[data-convert]').title, 'Omrekenen');
const without = await newRow(cols, { newDefaults: { kind: 'eur' } });
assert.equal(without.host.querySelector('[data-convert]'), null);
});
it('the focus survives the redraw of a reactive change', async () => {
const cols = COLUMNS();
cols[2].edit.reactive = true;
const { host } = await newRow(cols);
const amount = field(host, 'amount');
amount.focus();
dom.choose(amount, '5');
await tick();
assert.ok(field(host, 'amount') !== amount, 'redrawn');
assert.ok(dom.document.activeElement === field(host, 'amount'), 'the focus is on the new amount field');
});
});
describe('renderTable: new records', () => {
it('the plus opens an edit row on top with the defaults, and save calls onCreate', async () => {
const created = [];
const { host } = mount({ onCreate: async v => { created.push({ ...v }); }, newDefaults: { amount: 1 } });
dom.click(host.querySelector('[data-tbl-new]'));
await tick();
assert.equal(host.querySelector('tbody tr'), editRow(host));
assert.equal(field(host, 'amount').value, '1');
field(host, 'name').value = 'Dee';
dom.click(host.querySelector('[data-save]'));
await tick();
assert.deepEqual(created, [{ name: 'Dee', amount: '1' }]);
});
it('createHidden as a function: the plus goes once the last allowed row is saved, and repeat opens nothing', async () => {
let used = 0;
const { host } = mount({ onCreate: async () => { used++; }, createHidden: () => used >= 1 });
dom.click(host.querySelector('[data-tbl-new]'));
await tick();
field(host, 'name').value = 'Dee';
dom.key(field(host, 'name'), 'Enter', { ctrlKey: true });
await tick();
await tick();
assert.equal(used, 1);
assert.equal(editRow(host), null, 'na de laatste toegestane rij gaat er geen nieuwe open');
assert.equal(host.querySelector('[data-tbl-new]'), null);
});
it('newOptions: the plus opens a menu, and a choice opens a row with its own defaults', async () => {
const { host } = mount({ onCreate: async () => {}, newOptions: [{ label: 'Eén', defaults: { amount: 1 } }, { label: 'Twee', defaults: { amount: 2 } }] });
dom.click(host.querySelector('[data-tbl-new]'));
const menu = dom.document.querySelector('.new-menu');
assert.deepEqual(menu.querySelectorAll('[data-new-opt]').map(b => b.textContent), ['Eén', 'Twee']);
dom.click(menu.querySelectorAll('[data-new-opt]')[1]);
await tick();
assert.equal(field(host, 'amount').value, '2');
assert.equal(dom.document.querySelector('.new-menu'), null);
});
it('a sticky column remembers the value of the last CREATED record, and reports it', async () => {
const reported = [];
const cols = COLUMNS();
cols[0].edit.sticky = true;
const { host } = mount({ columns: cols, onCreate: async () => {}, onStickyChange: v => reported.push(v) });
dom.click(host.querySelector('[data-tbl-new]'));
await tick();
field(host, 'name').value = 'Eve';
dom.click(host.querySelector('[data-save]'));
await tick();
assert.deepEqual(reported, [{ name: 'Eve' }]);
dom.click(host.querySelector('[data-tbl-new]'));
await tick();
assert.equal(field(host, 'name').value, 'Eve');
assert.equal(field(host, 'amount').value, '');
});
it('a sticky column starts from cfg.sticky; a failing create remembers nothing', async () => {
const reported = [];
const cols = COLUMNS();
cols[0].edit.sticky = true;
const { host } = mount({ columns: cols, sticky: { name: 'Kept' }, onCreate: async () => { throw new Error('x'); }, onStickyChange: v => reported.push(v) });
dom.click(host.querySelector('[data-tbl-new]'));
await tick();
assert.equal(field(host, 'name').value, 'Kept');
field(host, 'name').value = 'Other';
dom.click(host.querySelector('[data-save]'));
await tick();
assert.deepEqual(reported, []);
});
});
describe('renderTable: the keyboard in the edit row', () => {
async function newRowWith(extra = {}) {
const created = [];
const t = mount({ onCreate: async v => { created.push({ ...v }); }, ...extra });
dom.click(t.host.querySelector('[data-tbl-new]'));
await tick();
return { ...t, created };
}
it('Enter saves and closes; Escape cancels', async () => {
const a = await newRowWith();
field(a.host, 'name').value = 'K';
dom.key(field(a.host, 'name'), 'Enter');
await tick();
assert.equal(a.created.length, 1);
assert.equal(editRow(a.host), null);
const b = await newRowWith();
dom.key(field(b.host, 'name'), 'Escape');
assert.equal(editRow(b.host), null);
assert.equal(b.created.length, 0);
});
it('Ctrl+Enter saves and opens the next row; a refresh with locate from inside onCreate does not interfere', async () => {
let api;
const created = [];
const t = mount({ onCreate: async v => { created.push({ ...v }); api.refresh([...ROWS(), { id: 9, name: v.name, kind: 'a', amount: 1 }], { locate: 9 }); } });
api = t.api;
dom.click(t.host.querySelector('[data-tbl-new]'));
await tick();
field(t.host, 'name').value = 'R';
dom.document.scrolledIntoView.length = 0;
dom.key(field(t.host, 'name'), 'Enter', { ctrlKey: true });
await tick();
await tick();
dom.flushFrames();
assert.deepEqual(dom.document.scrolledIntoView.map(s => s.el.dataset.rowId), []);
assert.equal(created.length, 1);
assert.ok(editRow(t.host), 'a fresh edit row is open');
assert.equal(field(t.host, 'name').value, '');
assert.equal(t.host.querySelector('.row-selected'), null);
assert.ok(row(t.host, 9), 'the rows were taken');
});
it('with repeat_entry_key "enter" the two Enters swap', async () => {
REPEAT_KEY = 'enter';
try {
const t = await newRowWith();
dom.key(field(t.host, 'name'), 'Enter');
await tick();
await tick();
assert.equal(t.created.length, 1);
assert.ok(editRow(t.host));
} finally { REPEAT_KEY = 'ctrl'; }
});
it('keys outside the edit row do nothing', () => {
const { host } = mount({ onCreate: async () => {} });
assert.equal(dom.key(host.querySelector('[data-tbl-search]'), 'Enter'), true, 'not prevented');
});
});
describe('renderTable: the toolbar', () => {
it('a note, and extra actions with and without an icon that call back with their button', () => {
const calls = [];
const { host } = mount({
toolbarNote: '1 van <1>',
toolbarActions: [{ id: 'imp', label: 'Import', icon: '<svg></svg>' }, { id: 'exp', label: 'Export' }],
onToolbarAction: (id, b) => calls.push([id, b.localName]),
});
assert.equal(host.querySelector('.tbl-note').textContent, '1 van <1>');
assert.ok(host.querySelector('[data-tbl-act="imp"].btn-new svg'));
assert.equal(host.querySelector('[data-tbl-act="exp"]').textContent, 'Export');
dom.click(host.querySelector('[data-tbl-act="exp"]'));
assert.deepEqual(calls, [['exp', 'button']]);
});
it('while editing, the toolbar is disabled and its actions do nothing', async () => {
const calls = [];
const { host } = mount({ onCreate: async () => {}, toolbarActions: [{ id: 'exp', label: 'Export' }], onToolbarAction: id => calls.push(id) });
dom.click(host.querySelector('[data-tbl-new]'));
await tick();
assert.ok(host.querySelector('[data-tbl-act="exp"]').hasAttribute('disabled'));
dom.click(host.querySelector('[data-tbl-act="exp"]'));
assert.deepEqual(calls, []);
});
});
describe('renderTable: refresh, focusGroup, focusRow, clearHighlight', () => {
it('refresh takes new rows and redraws', () => {
const { host, api } = mount();
api.refresh([{ id: 7, name: 'Zed', kind: 'c', amount: 1 }]);
assert.deepEqual(ids(host), ['7']);
});
it('refresh with locate selects the record, clears the search, opens its hidden group and stores that, then scrolls', () => {
const states = [];
const { host, api } = mount({ group: 'kind', groupFilter: { kind: ['b'] }, onStateChange: s => states.push(s.groupFilter) });
dom.type(host.querySelector('[data-tbl-search]'), 'bob');
dom.document.scrolledIntoView.length = 0;
api.refresh(null, { locate: 2 });
assert.ok(row(host, 2).classList.contains('row-selected'));
assert.equal(host.querySelector('[data-tbl-search]').value, '');
assert.deepEqual(states.at(-1), { kind: [] });
dom.flushFrames();
assert.equal(dom.document.scrolledIntoView.at(-1).el.dataset.rowId, '2');
});
it('focusGroup groups on the column and opens the group of the matching row, WITHOUT storing', () => {
const states = [];
const { host, api } = mount({ groupFilter: { kind: ['b'] }, onStateChange: s => states.push(s) });
assert.equal(api.focusGroup('kind', r => r.id === 2), true);
assert.deepEqual(groups(host), ['Soort a', 'Soort b']);
assert.ok(row(host, 2));
assert.deepEqual(states, []);
assert.equal(api.focusGroup('kind', () => false), false);
assert.equal(api.focusGroup('name', () => true), false, 'not a group column');
});
it('focusRow selects one record, also through a string id, WITHOUT storing; an unknown id changes nothing', () => {
const states = [];
const { host, api } = mount({ onStateChange: s => states.push(s) });
assert.equal(api.focusRow('3'), true);
assert.ok(row(host, 3).classList.contains('row-selected'));
assert.equal(api.focusRow(99), false);
assert.ok(row(host, 3).classList.contains('row-selected'));
assert.equal(api.focusRow(null), false);
assert.deepEqual(states, []);
});
it('clearHighlight deselects and closes an edit row', async () => {
const { host, api } = mount({ onCreate: async () => {} });
dom.click(row(host, 1).querySelector('td'));
api.clearHighlight();
assert.equal(host.querySelector('.row-selected'), null);
dom.click(host.querySelector('[data-tbl-new]'));
await tick();
api.clearHighlight();
assert.equal(editRow(host), null);
});
});
describe('renderTable: the options of Services', () => {
const store = new Map();
globalThis.localStorage = { getItem: k => store.get(k) ?? null, setItem: (k, v) => store.set(k, String(v)) };
it('a button, link or field in a cell is that control\'s: a click on it selects nothing (OPEN punt 7)', () => {
const cols = COLUMNS();
cols[0].render = r => `<span class="plain">${r.name}</span><button class="own">b</button><a class="own-link" href="#x">a</a><input class="own-field">`;
const { host } = mount({ columns: cols, onDelete() {}, rowExpansion: r => `<p>${r.id}</p>` });
for (const sel of ['.own', '.own-link', '.own-field']) {
dom.click(row(host, 1).querySelector(sel));
assert.equal(host.querySelector('.row-selected'), null, `a click on ${sel} selected the row`);
}
dom.click(row(host, 1).querySelector('.plain'));
assert.ok(row(host, 1).classList.contains('row-selected'), 'a click on the rest of the row selects');
dom.click(row(host, 1).querySelector('.chev-btn'));
assert.equal(host.querySelector('.row-selected'), null, 'the chevron is the row\'s hint and toggles it');
});
it('a row is selectable only when that does something: actions or a drawer', () => {
const { host } = mount();
dom.click(row(host, 1).querySelector('td'));
assert.equal(host.querySelector('.row-selected'), null);
});
it('stateKey keeps sort and grouping in localStorage, and a later table starts from it', () => {
configureTable({ repeatKey: () => REPEAT_KEY, storagePrefix: 'test.' });
const first = mount({ stateKey: 'k1' });
dom.click(first.host.querySelector('th[data-sort="name"]'));
dom.choose(first.host.querySelector('[data-tbl-group="1"]'), 'kind');
assert.deepEqual(JSON.parse(store.get('test.k1')), { sort: { key: 'name', dir: 'asc' }, group: 'kind' });
const second = mount({ stateKey: 'k1' });
assert.deepEqual(groups(second.host), ['Soort a', 'Soort b']);
assert.equal(second.host.querySelector('th[data-sort="name"]').getAttribute('aria-sort'), 'ascending');
});
it('a stored group of null means ungrouped on purpose, over the configured group', () => {
store.set('test.k2', JSON.stringify({ sort: null, group: null }));
assert.equal(mount({ stateKey: 'k2', group: 'kind' }).host.querySelectorAll('.group-row').length, 0);
store.set('test.k3', JSON.stringify({ sort: { key: 'gone', dir: 'asc' }, group: 'gone' }));
const fallback = mount({ stateKey: 'k3', group: 'kind', sort: { key: 'amount', dir: 'desc' } });
assert.equal(fallback.host.querySelectorAll('.group-row').length, 2, 'a stored group that does not fit falls back');
assert.equal(fallback.host.querySelector('th[data-sort="amount"] .sort-arrow').textContent, '▼');
configureTable({ repeatKey: () => REPEAT_KEY });
});
it('rowClass adds its class; onDraw runs after every draw and every search', () => {
let draws = 0;
const { host } = mount({ rowClass: r => (r.id === 2 ? 'is-muted' : ''), onDraw: () => { draws++; } });
assert.ok(row(host, 2).classList.contains('is-muted'));
assert.equal(row(host, 1).classList.contains('is-muted'), false);
dom.type(host.querySelector('[data-tbl-search]'), 'bob');
assert.equal(draws, 2);
});
it('the texts: countLabel, groupLabel and searchLabel per table, escaped', () => {
const { host } = mount({ countLabel: 'Rijen', groupLabel: 'Groeperen <op>', searchLabel: 'Zoek "x"' });
assert.equal(host.querySelector('.tbl-count').title, 'Rijen');
assert.equal(host.querySelector('[data-tbl-group="1"]').getAttribute('aria-label'), 'Groeperen <op>');
assert.equal(host.querySelector('[data-tbl-search]').getAttribute('placeholder'), 'Zoek "x"');
});
it('the keyboard: Enter on a sortable header sorts, Enter or Space on a row with a drawer opens it', () => {
const { host } = mount({ rowExpansion: r => `<p>${r.id}</p>` });
assert.equal(row(host, 1).getAttribute('tabindex'), '0');
dom.key(host.querySelector('th[data-sort="amount"]'), 'Enter');
assert.equal(host.querySelector('th[data-sort="amount"]').getAttribute('aria-sort'), 'ascending');
dom.key(row(host, 2), ' ');
assert.equal(row(host, 2).getAttribute('aria-expanded'), 'true');
assert.ok(host.querySelector('tr.row-expansion[data-exp-id="2"]'));
});
it('string ids, also with a quote in them, select and open their drawer; callbacks get the id as the row has it', () => {
const rows = [{ id: 'a"1', name: 'X', kind: 'a', amount: 1 }, { id: 'b', name: 'Y', kind: 'b', amount: 2 }];
const mounted = [];
const deleted = [];
const { host } = mount({ rows, rowExpansion: () => '<p>d</p>', onExpansionMount: r => mounted.push(r.id), onDelete: id => deleted.push(id) });
dom.click(host.querySelectorAll('tr[data-row-id]')[0].querySelectorAll('td')[1]);
assert.deepEqual(mounted, ['a"1']);
dom.click(host.querySelector('[data-del]'));
assert.deepEqual(deleted, ['a"1']);
});
it('a refresh without the selected row drops the selection', () => {
const { host, api } = mount({ onDelete() {} });
dom.click(row(host, 2).querySelector('td'));
api.refresh(ROWS().filter(r => r.id !== 2));
api.refresh(ROWS());
assert.equal(host.querySelector('.row-selected'), null);
});
it('an empty table shows no text, also with emptyText (decided 19-10-2026)', () => {
const { host } = mount({ rows: [], emptyText: 'Nog niets.' });
assert.ok(host.querySelector('tr.tbl-empty-spacer'));
assert.doesNotMatch(host.textContent, /Nog niets/);
});
it('labels are text: a column label with markup is shown, not parsed', () => {
const cols = COLUMNS();
cols[0].label = '<b>Naam</b>';
const { host } = mount({ columns: cols });
assert.equal(host.querySelector('th[data-sort="name"]').textContent, '<b>Naam</b>');
});
});
describe('renderTable: configureTable', () => {
it('the default texts are English, and an app sets its own', () => {
configureTable({ repeatKey: () => REPEAT_KEY });
const en = mount({ onCreate: async () => {}, group: 'kind' });
assert.equal(en.host.querySelector('[data-tbl-new]').title, 'New');
assert.equal(en.host.querySelector('[data-tbl-group="1"] option').textContent, 'Ungrouped');
configureTable({ repeatKey: () => REPEAT_KEY, labels: { new: 'Nieuw', none: 'Ongegroepeerd' } });
const nl = mount({ onCreate: async () => {}, group: 'kind' });
assert.equal(nl.host.querySelector('[data-tbl-new]').title, 'Nieuw');
assert.equal(nl.host.querySelector('[data-tbl-group="1"] option').textContent, 'Ongegroepeerd');
configureTable({
repeatKey: () => REPEAT_KEY,
convert: { canConvert: a => !!(a && a.subunit), open: (trigger, o) => converted.push(o), icon: '<i class="swap"></i>' },
});
});
it('the convert trigger opens the app\'s conversion with the asset and the value, and applies its answer', async () => {
const cols = COLUMNS();
cols[2].edit = { type: 'number', convert: () => ({ subunit: 'sat' }) };
const { host } = mount({ columns: cols, onCreate: async () => {} });
dom.click(host.querySelector('[data-tbl-new]'));
await tick();
field(host, 'amount').value = '5';
converted.length = 0;
dom.click(host.querySelector('[data-convert="amount"]'));
assert.equal(converted.length, 1);
assert.equal(converted[0].value, '5');
assert.deepEqual(converted[0].asset, { subunit: 'sat' });
converted[0].onApply('0.00000005');
assert.equal(field(host, 'amount').value, '0.00000005');
});
it('without a convert hook there is no trigger', async () => {
configureTable({ repeatKey: () => REPEAT_KEY });
const cols = COLUMNS();
cols[2].edit = { type: 'number', convert: () => ({ subunit: 'sat' }) };
const { host } = mount({ columns: cols, onCreate: async () => {} });
dom.click(host.querySelector('[data-tbl-new]'));
await tick();
assert.equal(host.querySelector('[data-convert]'), null);
configureTable({
repeatKey: () => REPEAT_KEY,
convert: { canConvert: a => !!(a && a.subunit), open: (trigger, o) => converted.push(o), icon: '<i class="swap"></i>' },
});
});
});
describe('keyAction', () => {
const ctx = (over = {}) => ({ key: 'Enter', ctrl: false, tag: 'INPUT', isButton: false, popoverOpen: false, dialogOpen: false, repeatKey: 'ctrl_enter', ...over });
it('Enter saves, Ctrl+Enter saves and opens the next row, and the preference swaps them', () => {
assert.equal(keyAction(ctx()), 'save');
assert.equal(keyAction(ctx({ tag: 'SELECT' })), 'save');
assert.equal(keyAction(ctx({ ctrl: true })), 'saveNext');
assert.equal(keyAction(ctx({ repeatKey: 'enter' })), 'saveNext');
assert.equal(keyAction(ctx({ ctrl: true, repeatKey: 'enter' })), 'save');
});
it('an unreadable preference means the default, never a repeat by accident', () => {
for (const repeatKey of [undefined, null, '', 'nonsense']) {
assert.equal(keyAction(ctx({ repeatKey })), 'save');
assert.equal(keyAction(ctx({ ctrl: true, repeatKey })), 'saveNext');
}
});
it('Escape cancels; every other key leaves the table alone', () => {
assert.equal(keyAction(ctx({ key: 'Escape' })), 'cancel');
assert.equal(keyAction(ctx({ key: 'Escape', tag: 'TEXTAREA' })), 'cancel');
for (const key of ['Tab', 'a', 'ArrowDown', ' ', 'Backspace', 'F5']) assert.equal(keyAction(ctx({ key })), null, key);
});
it('a bare Enter in a textarea stays a line break; Ctrl+Enter acts there', () => {
assert.equal(keyAction(ctx({ tag: 'TEXTAREA' })), null);
assert.equal(keyAction(ctx({ tag: 'TEXTAREA', ctrl: true })), 'saveNext');
assert.equal(keyAction(ctx({ tag: 'TEXTAREA', ctrl: true, repeatKey: 'enter' })), 'save');
});
it('a bare Enter on a button does nothing (the browser clicks it); Ctrl+Enter there does act', () => {
assert.equal(keyAction(ctx({ tag: 'BUTTON', isButton: true })), null);
assert.equal(keyAction(ctx({ key: 'Escape', tag: 'BUTTON', isButton: true })), 'cancel');
assert.equal(keyAction(ctx({ tag: 'BUTTON', isButton: true, ctrl: true })), 'saveNext');
assert.equal(keyAction(ctx({ tag: 'BUTTON', isButton: true, ctrl: true, popoverOpen: true })), null);
});
it('an open popover or dialog comes first: it closes, the row stays', () => {
for (const over of [{ popoverOpen: true }, { dialogOpen: true }]) {
assert.equal(keyAction(ctx({ key: 'Escape', ...over })), null);
assert.equal(keyAction(ctx({ key: 'Enter', ...over })), null);
assert.equal(keyAction(ctx({ key: 'Enter', ctrl: true, ...over })), null);
}
});
});
describe('renderTable: a table inside a drawer', () => {
it('a click in the inner table does not select a row of the outer one', () => {
let inner = null;
const { host } = mount({
rowExpansion: () => '<div class="inner"></div>',
onExpansionMount: (_r, el) => { inner = renderTable(el.querySelector('.inner'), { columns: COLUMNS(), rows: [{ id: 50, name: 'In', kind: 'z', amount: 0 }], onDelete() {} }); },
});
dom.click(row(host, 1).querySelectorAll('td')[1]);
assert.ok(inner);
dom.click(host.querySelector('tr[data-row-id="50"] td'));
assert.ok(host.querySelector('tr[data-row-id="50"]').classList.contains('row-selected'));
assert.ok(row(host, 1).classList.contains('row-selected'), 'the outer selection stays');
});
});
+200
View File
@@ -0,0 +1,200 @@
// Generated, do not edit. sha256:15b2f4e3c6894b2e23f02bc74d0bb5d5cabbc5c333e56f9b6ed448d6ac275271
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { installDom } from '../helpers/mini-dom.mjs';
const dom = installDom();
const { create, configureTabs, rowLayout } = await import('../../client/ui/tabs.js');
const items = () => [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }, { id: 'c', label: 'C', when: () => false }];
const tabIds = t => t.bar.querySelectorAll('[data-tab]').map(b => b.dataset.tab);
const tick = () => new Promise(r => setImmediate(r));
describe('tabs: building and switching', () => {
it('a bar and a panel, the active tab marked, a filtered tab left out', () => {
const rendered = [];
const t = create(dom.host(), { items: items(), active: 'b', render: (id, p) => { rendered.push(id); p.textContent = id; } });
assert.deepEqual(tabIds(t), ['a', 'b']);
assert.equal(t.bar.querySelector('.is-on').dataset.tab, 'b');
assert.equal(t.bar.querySelector('[data-tab="b"]').getAttribute('aria-selected'), 'true');
assert.equal(t.panel.textContent, 'b');
assert.deepEqual(rendered, ['b']);
});
it('an unknown or filtered active tab falls back on the first', () => {
assert.equal(create(dom.host(), { items: items(), active: 'c' }).active, 'a');
assert.equal(create(dom.host(), { items: items(), active: 'zz' }).active, 'a');
});
it('a click switches, redraws the panel and reports; the same tab again does nothing', () => {
const changes = [];
let renders = 0;
const t = create(dom.host(), { items: items(), render: () => { renders++; }, onChange: id => changes.push(id) });
dom.click(t.bar.querySelector('[data-tab="b"]'));
dom.click(t.bar.querySelector('[data-tab="b"]'));
assert.deepEqual(changes, ['b']);
assert.equal(renders, 2);
t.select('zz');
assert.equal(t.active, 'b', 'an unknown id is ignored');
});
it('size lg, and the bar in another host when asked', () => {
const host = dom.host();
const side = dom.host();
const t = create(host, { items: items(), size: 'lg', barHost: side });
assert.equal(t.bar.className, 'tabset tabset--lg');
assert.equal(t.bar.parentNode, side);
assert.equal(t.panel.parentNode, host);
});
});
describe('tabs: the live dots', () => {
it('a dot function runs on every bar draw; a sync event redraws the bar and not the panel', () => {
let n = 0;
let renders = 0;
const t = create(dom.host(), {
items: [{ id: 'a', label: 'A', dot: () => `<i>${++n}</i>` }],
render: () => { renders++; }, syncOn: ['attention-changed'],
});
dom.fire(dom.document, 'attention-changed');
assert.equal(t.bar.querySelector('i').textContent, '2');
assert.equal(renders, 1);
t.refresh();
assert.equal(t.bar.querySelector('i').textContent, '3');
t.destroy();
});
it('a bar that left the document unbinds itself on the next event', () => {
const host = dom.host();
const t = create(host, { items: items(), syncOn: ['attention-changed'] });
const before = dom.document.listeners.length;
host.remove();
dom.fire(dom.document, 'attention-changed');
assert.equal(dom.document.listeners.length, before - 1);
assert.equal(t.bar.isConnected, false);
});
it('a second create on the same host removes the listeners of the first', () => {
const host = dom.host();
const before = dom.document.listeners.length;
create(host, { items: items(), syncOn: ['x-event'] });
create(host, { items: items(), syncOn: ['x-event'] });
assert.equal(dom.document.listeners.length, before + 1);
});
});
describe('tabs: two rows for a large bar that overflows', () => {
it('one row while the tabs fit, up to and including the last pixel', () => {
assert.deepEqual(rowLayout(11, 900, 1000), { rows: 1, cols: 11 });
assert.deepEqual(rowLayout(11, 1000, 1000), { rows: 1, cols: 11 });
});
it('two rows from one pixel over, the first row the longer one', () => {
assert.deepEqual(rowLayout(11, 1001, 1000), { rows: 2, cols: 6 });
assert.deepEqual(rowLayout(10, 1500, 1000), { rows: 2, cols: 5 });
});
it('never more than two rows, and a single tab is not split', () => {
assert.deepEqual(rowLayout(11, 5000, 1000), { rows: 2, cols: 6 });
assert.deepEqual(rowLayout(1, 1500, 1000), { rows: 1, cols: 1 });
});
class FakeResizeObserver {
constructor(cb) { this.cb = cb; this.targets = []; FakeResizeObserver.last = this; }
observe(el) { this.targets.push(el); }
disconnect() { this.targets = []; }
fire(width) { this.cb([{ contentRect: { width } }]); }
}
let layout = { reach: 0, room: 0 };
const sized = (reach, room) => { layout = { reach, room }; };
const withObserver = fn => {
const prev = globalThis.ResizeObserver;
globalThis.ResizeObserver = FakeResizeObserver;
FakeResizeObserver.last = null;
let proto = Object.getPrototypeOf(document.createElement('div'));
while (!Object.hasOwn(proto, 'getBoundingClientRect')) proto = Object.getPrototypeOf(proto);
const rect = proto.getBoundingClientRect;
proto.getBoundingClientRect = function () {
const right = this.classList.contains('tabset') ? layout.room : layout.reach;
return { left: 0, top: 0, right, bottom: 0, width: right, height: 0 };
};
try { return fn(); } finally {
globalThis.ResizeObserver = prev;
proto.getBoundingClientRect = rect;
layout = { reach: 0, room: 0 };
}
};
const eleven = () => Array.from({ length: 11 }, (_, i) => ({ id: `t${i}`, label: `T${i}` }));
it('a resize that makes the bar overflow switches it to rows with its column count, and back', () => withObserver(() => {
const t = create(dom.host(), { items: eleven(), size: 'lg' });
const ro = FakeResizeObserver.last;
assert.deepEqual(ro.targets, [t.bar]);
sized(1200, 1000);
ro.fire(1000);
assert.ok(t.bar.classList.contains('tabset--rows'));
assert.equal(t.bar.style.getPropertyValue('--tabset-cols'), '6');
sized(1200, 1600);
ro.fire(1600);
assert.equal(t.bar.classList.contains('tabset--rows'), false);
}));
it('a height change alone does not re-measure: that is the switch to two rows itself', () => withObserver(() => {
const t = create(dom.host(), { items: eleven(), size: 'lg' });
const ro = FakeResizeObserver.last;
sized(1200, 1000);
ro.fire(1000);
sized(900, 1000);
ro.fire(1000);
assert.ok(t.bar.classList.contains('tabset--rows'));
}));
it('a redraw of the bar re-measures, since a dot changes the width of a tab', () => withObserver(() => {
const t = create(dom.host(), { items: eleven(), size: 'lg' });
sized(1200, 1000);
t.refresh();
assert.ok(t.bar.classList.contains('tabset--rows'));
}));
it('a compact bar is never observed, and destroy stops observing', () => withObserver(() => {
create(dom.host(), { items: eleven() });
assert.equal(FakeResizeObserver.last, null);
const t = create(dom.host(), { items: eleven(), size: 'lg' });
t.destroy();
assert.deepEqual(FakeResizeObserver.last.targets, []);
}));
});
describe('tabs: the error net', () => {
const quiet = fn => { const e = console.error; console.error = () => {}; try { return fn(); } finally { console.error = e; } };
it('a throwing render shows the message, escaped, by default', () => {
const t = quiet(() => create(dom.host(), { items: items(), render: () => { throw new Error('<boom>'); } }));
assert.equal(t.panel.querySelector('.alert-error').textContent, '<boom>');
});
it('an async render that rejects is caught too', async () => {
const e = console.error;
console.error = () => {};
try {
const t = create(dom.host(), { items: items(), render: async () => { throw new Error('later'); } });
await tick();
assert.equal(t.panel.querySelector('.alert-error')?.textContent, 'later');
} finally { console.error = e; }
});
it('configureTabs sets how a failed panel shows, with the tab id; null restores the default', () => {
const seen = [];
configureTabs({ onError: (panel, err, id) => { seen.push([err.message, id]); panel.textContent = 'own'; } });
const t = create(dom.host(), { items: items(), active: 'b', render: () => { throw new Error('x'); } });
assert.deepEqual(seen, [['x', 'b']]);
assert.equal(t.panel.textContent, 'own');
configureTabs({ onError: null });
const u = quiet(() => create(dom.host(), { items: items(), render: () => { throw new Error('y'); } }));
assert.ok(u.panel.querySelector('.alert-error'));
});
it('without a host there is no control', () => {
assert.equal(create(null, { items: items() }), null);
});
});
+74
View File
@@ -0,0 +1,74 @@
// Generated, do not edit. sha256:da5a18d6a0bc54d00b68ce5ce60b1415a99e474e0a0bf30235954a3ad3d1cb69
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
const { taskCardHtml, nothingPendingHtml } = await import('../../client/ui/task-card.js');
describe('taskCardHtml: the shell', () => {
it('wraps title and body in one card', () => {
const html = taskCardHtml({ title: 'Meldingen', body: '<p>drie</p>' });
assert.match(html, /^<div class="card">/);
assert.match(html, /<div class="t-label">Meldingen<\/div>/);
assert.match(html, /<p>drie<\/p>/);
assert.match(html, /<\/div>$/);
});
it('leaves out the header row when there are no actions', () => {
const html = taskCardHtml({ title: 'Meldingen', body: 'x' });
assert.doesNotMatch(html, /task-card-head/);
});
it('adds the header row as soon as there are actions, with the label inside it', () => {
const html = taskCardHtml({ title: 'Afstemmen', actions: '<button>Opnieuw</button>', body: 'x' });
assert.match(html, /<div class="task-card-head"><div class="t-label">Afstemmen<\/div><button>Opnieuw<\/button><\/div>/);
});
it('puts className on the card and keeps the base class', () => {
const html = taskCardHtml({ title: 'Dubbele boekingen', className: 'health-finding health-warn' });
assert.match(html, /^<div class="card health-finding health-warn">/);
});
it('adds no stray space when there is no className', () => {
assert.match(taskCardHtml({ title: 'x' }), /^<div class="card">/);
});
it('survives an empty body and empty actions', () => {
const html = taskCardHtml({ title: 'Leeg' });
assert.match(html, /<div class="card"><div class="t-label">Leeg<\/div><\/div>/);
});
});
describe('taskCardHtml: text and markup do not swap places', () => {
it('escapes the title', () => {
const html = taskCardHtml({ title: '<img src=x onerror=alert(1)>', body: '' });
assert.doesNotMatch(html, /<img/, 'the title is text and never markup');
assert.match(html, /&lt;img/);
});
it('escapes a quote in the title as well', () => {
const html = taskCardHtml({ title: 'Say "hi"', body: '' });
assert.doesNotMatch(html, /"hi"/);
assert.match(html, /&quot;hi&quot;/);
});
it('does NOT escape actions and body', () => {
const html = taskCardHtml({
title: 'x', actions: '<button id="go">Go</button>', body: '<div class="rec-item">rij</div>',
});
assert.match(html, /<button id="go">Go<\/button>/);
assert.match(html, /<div class="rec-item">rij<\/div>/);
});
});
describe('nothingPendingHtml', () => {
it('is a check icon in its own block', () => {
const html = nothingPendingHtml();
assert.match(html, /class="task-card-empty"/);
assert.match(html, /class="task-card-empty-icon"/);
assert.match(html, /<svg/);
});
it('keeps its icon, because it is not the same thing as an empty page', () => {
assert.match(nothingPendingHtml(), /<svg/);
});
});
+63
View File
@@ -0,0 +1,63 @@
// Generated, do not edit. sha256:0810eb8c4c62fd3109b5429626a785af591e318584e97a2a5a912991b30c9a95
import { describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
let BASIS = 'EUR';
mock.module('../../client/app-settings.js', {
namedExports: { appSetting: () => 1, baseCurrency: () => BASIS },
});
const fiat = (symbol, id) => ({ id, symbol, name: symbol, kind: 'fiat' });
let ASSETS = [];
mock.module('../../client/data/assets.js', { namedExports: { listAssets: async () => ASSETS } });
let KOERS = {};
mock.module('../../client/price/providers.js', {
namedExports: { currentRate: async code => (code === BASIS ? 1 : KOERS[code] ?? null) },
});
const { secondCurrencyOf, secondCurrencyChoices, secondAmount } = await import('../../client/widgets/second-currency.js');
const { fmtMoney } = await import('../../client/ui/format.js');
describe('secondCurrencyOf: de keuze van een widget', () => {
it('leest de keuze uit params', () => {
assert.equal(secondCurrencyOf({ params: { second: 'USD' } }), 'USD');
});
it('valt terug op de oude voorkeur als de widget nooit gekozen heeft', () => {
assert.equal(secondCurrencyOf({ params: null }, 'USD'), 'USD');
assert.equal(secondCurrencyOf({ params: { assetId: 3 } }, 'USD'), 'USD');
});
it('houdt een bewuste "geen" vast, ook als de oude voorkeur USD zegt', () => {
assert.equal(secondCurrencyOf({ params: { second: null } }, 'USD'), null);
});
it('telt de rekenvaluta als geen tweede valuta', () => {
BASIS = 'USD';
try { assert.equal(secondCurrencyOf({ params: { second: 'USD' } }), null); }
finally { BASIS = 'EUR'; }
});
});
describe('secondCurrencyChoices: welke munten er te kiezen zijn', () => {
it('alleen fiat met een koers, zonder de rekenvaluta, op alfabet', async () => {
ASSETS = [fiat('USD', 1), fiat('EUR', 2), fiat('GBP', 3), fiat('JPY', 4), { id: 5, symbol: 'BTC', kind: 'crypto' }];
KOERS = { USD: 0.8, GBP: 1.2 };
assert.deepEqual(await secondCurrencyChoices(), ['GBP', 'USD']);
});
});
describe('secondAmount: het bedrag in de tweede valuta', () => {
it('deelt door de koers van die munt', async () => {
KOERS = { USD: 0.8 };
assert.equal(await secondAmount(800, 'USD', { decimals: 0 }), fmtMoney(1000, 'USD', { decimals: 0 }));
});
it('geeft niets zonder koers, zonder munt of zonder bedrag', async () => {
KOERS = {};
assert.equal(await secondAmount(800, 'USD'), '');
assert.equal(await secondAmount(800, null), '');
assert.equal(await secondAmount(null, 'USD'), '');
});
});
+22
View File
@@ -0,0 +1,22 @@
// Generated, do not edit. sha256:92455022533d94af2d52395e44cf486657d20b65fb083e71739944b000a7e846
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { val, fmtUnit } from '../../client/ui/format.js';
describe('val: privacy-wrap als keuzepunt', () => {
it('wrapt in .val zodat de blur-laag het kan vervagen', () => {
assert.equal(val('€ 1.234,56'), '<span class="val">€ 1.234,56</span>');
});
it('escapet zijn invoer, want die gaat naar innerHTML', () => {
assert.equal(val('<b>x</b>'), '<span class="val">&lt;b&gt;x&lt;/b&gt;</span>');
});
it('houdt een label uit de asset-data onschadelijk (de reden dat val() escapet)', () => {
const boosAsset = { kind: 'crypto', decimals: 0, unit: '<img src=x onerror=alert(1)>', displayUnit: 'unit' };
const html = val(fmtUnit(5, boosAsset));
assert.ok(!html.includes('<img'), `payload kwam als tag door: ${html}`);
assert.ok(html.includes('&lt;img src=x onerror=alert(1)&gt;'), html);
});
});
+57
View File
@@ -0,0 +1,57 @@
// Generated, do not edit. sha256:b83eae2452e831916c5adb829943694b1474213183f0c8ae01a34491f818b8de
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { feeCandidates, withFee } from '../../client/actions/valuation-fee.js';
const ACC = 1, ANDER = 2, BTC = 10, ETH = 11;
const ev = (o) => ({ id: o.id, assetId: BTC, type: 'sell', fromAccountId: ACC, toAccountId: null,
amount: 1000, unitFee: null, note: null, occurredAt: '2026-03-01T10:00:00Z', ...o });
const balans = (o) => ({ id: o.id, accountId: ACC, assetId: BTC, rateAt: '2026-04-01T00:00:00Z', ...o });
describe('feeCandidates', () => {
const v = balans({ id: 2 });
const vorige = balans({ id: 1, rateAt: '2026-02-01T00:00:00Z' });
it('geeft de uitgaande boekingen sinds de vorige balans, nieuwste eerst', () => {
const events = [
ev({ id: 10, occurredAt: '2026-02-15T00:00:00Z' }),
ev({ id: 11, type: 'transfer', toAccountId: ANDER, occurredAt: '2026-03-15T00:00:00Z' }),
ev({ id: 12, type: 'transfer', toAccountId: ACC, occurredAt: '2026-03-20T00:00:00Z' }),
];
assert.deepEqual(feeCandidates({ events, valuations: [vorige, v], v }).map(e => e.id), [12, 11, 10]);
});
it('laat weg wat de fee niet kan dragen of niet bij dit gat hoort', () => {
const events = [
ev({ id: 20, type: 'buy', fromAccountId: null, toAccountId: ACC }),
ev({ id: 21, type: 'interest', fromAccountId: null, toAccountId: ACC }),
ev({ id: 22, fromAccountId: ANDER }),
ev({ id: 23, assetId: ETH }),
ev({ id: 24, type: 'transfer', fromAccountId: ANDER, toAccountId: ACC }),
ev({ id: 25, occurredAt: '2026-01-15T00:00:00Z' }),
ev({ id: 26, occurredAt: '2026-04-02T00:00:00Z' }),
];
assert.deepEqual(feeCandidates({ events, valuations: [vorige, v], v }), []);
});
it('kijkt zonder vorige balans terug tot het begin, en een balans van een ander account telt niet', () => {
const events = [ev({ id: 30, occurredAt: '2025-01-01T00:00:00Z' })];
const ander = balans({ id: 3, accountId: ANDER, rateAt: '2026-02-01T00:00:00Z' });
assert.deepEqual(feeCandidates({ events, valuations: [ander, v], v }).map(e => e.id), [30]);
});
});
describe('withFee', () => {
it('telt het gat op bij een bestaande fee, als positief getal', () => {
assert.equal(withFee(ev({ id: 1, unitFee: 50 }), -200).unitFee, 250);
assert.equal(withFee(ev({ id: 1 }), -200).unitFee, 200);
});
it('laat de rest van de boeking staan en zet de notitie erachter', () => {
const uit = withFee(ev({ id: 1, note: 'betaling' }), -5, 'kosten aangevuld');
assert.equal(uit.amount, 1000);
assert.equal(uit.note, 'betaling · kosten aangevuld');
assert.equal(withFee(ev({ id: 1 }), -5, 'x').note, 'x');
});
});
+80
View File
@@ -0,0 +1,80 @@
// Generated, do not edit. sha256:1729f1063610ec96b3aae049d2a0af2d802f2e19ff07d46ca27d855e2891faac
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { freeRunAt, fittingCatalog, placeInRun } from '../../client/dashboard/free-space.js';
import { DASHBOARD } from '../../shared/config.js';
const LAYOUT = [
{ x: 0, y: 0, w: 3, h: 3 },
{ x: 6, y: 0, w: 4, h: 2 },
{ x: 6, y: 2, w: 4, h: 5 },
];
const hoogte = it => it.h;
describe('freeRunAt', () => {
it('geeft het vrije stuk tussen twee widgets, met de kolom onder de muis', () => {
assert.deepEqual(freeRunAt(LAYOUT, 4, 0, hoogte), { x: 3, y: 0, w: 3, col: 4 });
});
it('loopt door tot de rand van het raster', () => {
assert.deepEqual(freeRunAt(LAYOUT, 11, 1, hoogte), { x: 10, y: 1, w: 2, col: 11 });
});
it('telt de hoogte van een widget mee: rij 4 is links vrij, maar niet over de hoge widget heen', () => {
assert.deepEqual(freeRunAt(LAYOUT, 0, 4, hoogte), { x: 0, y: 4, w: 6, col: 0 });
});
it('geeft niets op een bezette plek, ook niet in de laatste rij van een widget', () => {
assert.equal(freeRunAt(LAYOUT, 1, 2, hoogte), null);
assert.equal(freeRunAt(LAYOUT, 7, 6, hoogte), null);
});
it('onder de hele indeling is de rij helemaal vrij', () => {
assert.deepEqual(freeRunAt(LAYOUT, 5, 7, hoogte), { x: 0, y: 7, w: DASHBOARD.GRID_COLUMNS, col: 5 });
});
it('geeft niets buiten het raster', () => {
assert.equal(freeRunAt(LAYOUT, -1, 0, hoogte), null);
assert.equal(freeRunAt(LAYOUT, DASHBOARD.GRID_COLUMNS, 0, hoogte), null);
assert.equal(freeRunAt(LAYOUT, 4, -1, hoogte), null);
});
});
describe('fittingCatalog', () => {
const typen = Object.entries(DASHBOARD.WIDGETS).sort((a, b) => a[1].defaultSpan - b[1].defaultSpan);
const [smal, smalDef] = typen[0];
const [breed, breedDef] = typen[typen.length - 1];
const CATALOGUS = [{ type: smal }, { type: breed }, { type: 'bestaat-niet' }];
it('biedt alleen aan wat in de vrije breedte past', () => {
assert.ok(smalDef.defaultSpan < breedDef.defaultSpan, 'de registry heeft geen twee breedtes meer');
const run = { x: 0, y: 0, w: smalDef.defaultSpan, col: 0 };
assert.deepEqual(fittingCatalog(CATALOGUS, run).map(c => c.type), [smal]);
});
it('precies even breed past nog', () => {
const run = { x: 0, y: 0, w: breedDef.defaultSpan, col: 0 };
assert.deepEqual(fittingCatalog(CATALOGUS, run).map(c => c.type), [smal, breed]);
});
it('laat een type weg dat de registry niet kent', () => {
const run = { x: 0, y: 0, w: DASHBOARD.GRID_COLUMNS, col: 0 };
assert.ok(!fittingCatalog(CATALOGUS, run).some(c => c.type === 'bestaat-niet'));
});
});
describe('placeInRun', () => {
const run = { x: 2, y: 5, w: 6, col: 4 };
it('zet de widget onder de muis', () => {
assert.deepEqual(placeInRun(run, 2), { x: 4, y: 5 });
});
it('schuift hem terug als hij rechts over het vrije stuk heen zou gaan', () => {
assert.deepEqual(placeInRun({ ...run, col: 7 }, 3), { x: 5, y: 5 });
});
it('blijft binnen het stuk, ook als de muis links ervan stond', () => {
assert.deepEqual(placeInRun({ ...run, col: 0 }, 3), { x: 2, y: 5 });
});
});
+117
View File
@@ -0,0 +1,117 @@
// Generated, do not edit. sha256:1af8cbc83fbd3f045e8613c13a26b6a23e68ea741a3545d1ba4e3de41d66fee7
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
const { sameSelection, selectionFromStored } = await import('../../client/wealth/selection.js');
describe('sameSelection: is dit hetzelfde?', () => {
it('herkent dezelfde soort en dezelfde assets', () => {
assert.equal(sameSelection({ kind: 'crypto' }, { kind: 'crypto' }), true);
assert.equal(sameSelection({ assetIds: [1, 2] }, { assetIds: [1, 2] }), true);
});
it('ziet een andere volgorde als iets ánders', () => {
assert.equal(sameSelection({ assetIds: [1, 2] }, { assetIds: [2, 1] }), false);
});
it('SCHEIDT EEN SOORT VAN EEN LIJST ASSETS, ook als die soort precies die assets bevat', () => {
assert.equal(sameSelection({ kind: 'crypto' }, { assetIds: [1] }), false);
assert.equal(sameSelection({ assetIds: [1] }, { kind: 'crypto' }), false);
});
it('behandelt null als een echte stand', () => {
assert.equal(sameSelection(null, null), true);
assert.equal(sameSelection(null, { kind: 'crypto' }), false);
assert.equal(sameSelection({ kind: 'crypto' }, null), false);
});
});
describe('selectionFromStored: wat er uit de voorkeuren komt', () => {
it('leest de twee geldige vormen', () => {
assert.deepEqual(selectionFromStored({ kind: 'metal' }), { kind: 'metal' });
assert.deepEqual(selectionFromStored({ assetIds: [3, 4] }), { assetIds: [3, 4] });
});
it('MAAKT ER GETALLEN VAN, ook als er tekst opgeslagen stond', () => {
assert.deepEqual(selectionFromStored({ assetIds: ['3', '4'] }), { assetIds: [3, 4] });
});
it('geeft null bij een lege of onbruikbare lijst', () => {
assert.equal(selectionFromStored({ assetIds: [] }), null);
assert.equal(selectionFromStored({ assetIds: ['x', null] }), null, 'niets dat een getal wordt');
});
it('houdt over wat wél een getal is', () => {
assert.deepEqual(selectionFromStored({ assetIds: [3, 'x'] }), { assetIds: [3] });
});
it('GEEFT NULL BIJ ALLES WAT GEEN SELECTIE IS, in plaats van te struikelen', () => {
for (const rommel of [null, undefined, 0, '', 'crypto', 42, [], { }, { assetIds: 'crypto' }])
assert.equal(selectionFromStored(rommel), null, `bij ${JSON.stringify(rommel)}`);
});
it('laat de soort vóór de assets gaan als er allebei iets staat', () => {
assert.deepEqual(selectionFromStored({ kind: 'crypto', assetIds: [1] }), { kind: 'crypto' });
});
});
const { hiddenFromStored, hiddenAfterFilter, splitHidden, hiddenKindsFromStored, accountKindItems, accountKindOf, NO_ACCOUNT_KIND } =
await import('../../client/wealth/selection.js');
describe('verborgen kaarten', () => {
it('leest alleen positieve gehele ids terug', () => {
assert.deepEqual([...hiddenFromStored([3, '5', null, 0, -1, 'x'])], [3, 5]);
assert.equal(hiddenFromStored({ ids: [1] }).size, 0, 'geen lijst: niets verborgen');
});
it('laat bij accounts de 0 door, de kaart zonder account', () => {
assert.deepEqual([...hiddenFromStored([0, 4, -1], { allowZero: true })], [0, 4]);
});
it('bewaart na de filterbalk wat uit staat, gesorteerd', () => {
assert.deepEqual(hiddenAfterFilter(new Set(), [1, 2, 3], new Set([1, 3])), [2]);
assert.deepEqual(hiddenAfterFilter(new Set([2]), [1, 2, 3], new Set([1, 2, 3])), [], 'weer aan is weer zichtbaar');
});
it('houdt een verborgen id zonder chip vast, zodat een andere periode hem niet terugzet', () => {
assert.deepEqual(hiddenAfterFilter(new Set([9, 2]), [1, 2], new Set([1])), [2, 9]);
});
it('verdeelt de kaarten in twee stapels die samen alles zijn, in de oorspronkelijke volgorde', () => {
const k = id => ({ asset: { id } });
const { shown, hidden } = splitHidden([k(1), k(2), k(3)], new Set([2]));
assert.deepEqual(shown.map(x => x.asset.id), [1, 3]);
assert.deepEqual(hidden.map(x => x.asset.id), [2]);
});
it('filtert per account op soort, met de kaart zonder account als eigen soort', () => {
const kaarten = [{ account: { kind: 'exchange' } }, { account: { kind: 'wallet' } }, { account: { kind: 'exchange' } }, { account: null }];
assert.deepEqual(accountKindItems(kaarten), ['exchange', 'wallet', NO_ACCOUNT_KIND], 'elke soort één keer, in de volgorde van de kaarten');
const verborgen = hiddenKindsFromStored(['wallet', 'verdwenen', 7, NO_ACCOUNT_KIND], ['exchange', 'wallet']);
assert.deepEqual([...verborgen].sort(), [NO_ACCOUNT_KIND, 'wallet']);
const { shown } = splitHidden(kaarten, verborgen, accountKindOf);
assert.equal(shown.length, 2, 'alleen de twee exchanges blijven staan');
assert.deepEqual(hiddenAfterFilter(new Set(['wallet']), ['exchange', 'wallet'], new Set(['wallet'])), ['exchange'],
'een soort weer aan is weer zichtbaar, een andere uit');
});
it('splitst accountkaarten op hun eigen sleutel', () => {
const c = id => ({ accountId: id });
const { shown } = splitHidden([c(10), c(null)], new Set([0]), x => x.accountId ?? 0);
assert.deepEqual(shown.map(x => x.accountId), [10]);
});
});
describe('selectie van een accountkaart', () => {
it('leest het account terug, ook de kaart zonder account en zonder assets', () => {
assert.deepEqual(selectionFromStored({ account: 7, assetIds: [1] }), { account: 7, assetIds: [1] });
assert.deepEqual(selectionFromStored({ account: 0, assetIds: [] }), { account: 0, assetIds: [] });
assert.equal(selectionFromStored({ account: -1, assetIds: [] }), null);
});
it('ziet twee accounts met dezelfde assets als twee selecties', () => {
assert.equal(sameSelection({ account: 1, assetIds: [5] }, { account: 2, assetIds: [5] }), false);
assert.equal(sameSelection({ account: 1, assetIds: [5] }, { account: 1, assetIds: [5] }), true);
assert.equal(sameSelection({ account: 1, assetIds: [5] }, { assetIds: [5] }), false);
});
});
+36
View File
@@ -0,0 +1,36 @@
// Generated, do not edit. sha256:15dbf19377a08dba5f9a565fd3575f87341d62828d1e85351115ec01a421c123
import { describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
let dicht = new Set();
mock.module('../../client/license.js', { namedExports: { locked: f => dicht.has(f) } });
const { registerWidget, availableWidgets } = await import('../../client/widgets/registry.js');
const klok = () => {};
const klokInstelling = () => {};
const signaal = () => {};
const signaalInstelling = () => {};
registerWidget('clocks', { render: klok, settings: klokInstelling });
registerWidget('signal', { render: signaal, settings: signaalInstelling, feature: 'signals' });
describe('availableWidgets', () => {
it('volledig: elke widget met zijn instellingen', () => {
dicht = new Set();
const { renderers, settings } = availableWidgets();
assert.deepEqual(Object.keys(renderers).sort(), ['clocks', 'signal']);
assert.equal(settings.signal, signaalInstelling);
});
it('gratis: een betaalde widget verdwijnt, ook zijn instellingen', () => {
dicht = new Set(['signals']);
const { renderers, settings } = availableWidgets();
assert.deepEqual(Object.keys(renderers), ['clocks']);
assert.equal(settings.signal, undefined);
assert.equal(settings.clocks, klokInstelling, 'een widget zonder vlag blijft staan');
});
it('weigert een onbekend type en een tweede registratie', () => {
assert.throws(() => registerWidget('bestaat-niet', { render: klok }), /Unknown widget type/);
assert.throws(() => registerWidget('clocks', { render: klok }), /registered twice/);
});
});
+72
View File
@@ -0,0 +1,72 @@
// Generated, do not edit. sha256:c2c50da68059eb5b91507e98e3bdb52f6ec85d33f1dfafa7fb651d37c5365fc5
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
const { ALL_YEARS, choiceToText, choiceFromText, withoutYear, yearsIn } =
await import('../../client/wealth/year-range.js');
const NU = new Date().getFullYear();
describe('choiceToText / choiceFromText: heen en terug', () => {
it('schrijft één jaar als één getal en een reeks met een streepje', () => {
assert.equal(choiceToText({ van: 2026, tot: 2026 }), '2026');
assert.equal(choiceToText({ van: 2022, tot: 2024 }), '2022-2024');
assert.equal(choiceToText(ALL_YEARS), 'alles');
});
it('leest zijn eigen vorm weer terug', () => {
for (const k of [ALL_YEARS, { van: 2026, tot: 2026 }, { van: 2022, tot: 2024 }])
assert.deepEqual(choiceFromText(choiceToText(k)), k);
});
it('ZET EEN OMGEKEERDE REEKS RECHT', () => {
assert.deepEqual(choiceFromText('2024-2022'), { van: 2022, tot: 2024 });
});
it('VALT TERUG OP HET LOPENDE JAAR bij iets onleesbaars, in plaats van te struikelen', () => {
for (const rommel of ['', 'onzin', '20xx', '202', null, undefined, '2026-'])
assert.deepEqual(choiceFromText(rommel), { van: NU, tot: NU }, `bij ${JSON.stringify(rommel)}`);
});
it('leest een getal net zo goed als een tekst', () => {
assert.deepEqual(choiceFromText(2026), { van: 2026, tot: 2026 });
});
});
describe('withoutYear: een jaar wegdrukken', () => {
it('haalt het aangeklikte jaar eruit ÉN alles wat ouder is', () => {
assert.deepEqual(withoutYear({ van: 2020, tot: 2026 }, 2022), { van: 2023, tot: 2026 });
});
it('GEEFT "ALLES" bij het nieuwste jaar en niet een lege keuze', () => {
assert.equal(withoutYear({ van: 2020, tot: 2026 }, 2026), ALL_YEARS);
assert.equal(withoutYear({ van: 2026, tot: 2026 }, 2026), ALL_YEARS, 'ook als het het enige jaar is');
});
it('geeft "alles" bij een jaar dat nog nieuwer is dan de reeks', () => {
assert.equal(withoutYear({ van: 2020, tot: 2024 }, 2030), ALL_YEARS);
});
it('laat "alles" met rust en overleeft een onzinnig jaar', () => {
assert.equal(withoutYear(ALL_YEARS, 2024), ALL_YEARS);
assert.deepEqual(withoutYear({ van: 2020, tot: 2026 }, NaN), { van: 2020, tot: 2026 });
assert.deepEqual(withoutYear({ van: 2020, tot: 2026 }, undefined), { van: 2020, tot: 2026 });
});
it('muteert de meegegeven keuze niet', () => {
const k = { van: 2020, tot: 2026 };
withoutYear(k, 2022);
assert.deepEqual(k, { van: 2020, tot: 2026 });
});
});
describe('yearsIn', () => {
it('somt de jaren oplopend op', () => {
assert.deepEqual(yearsIn({ van: 2022, tot: 2024 }), [2022, 2023, 2024]);
assert.deepEqual(yearsIn({ van: 2026, tot: 2026 }), [2026]);
});
it('geeft er GEEN bij "alles"', () => {
assert.deepEqual(yearsIn(ALL_YEARS), []);
});
});