Files
AtreidesAssetsFree/tests/ui/gedeelde-machinerie.test.mjs
T

429 lines
20 KiB
JavaScript
Raw Permalink Normal View History

2026-10-29 11:57:29 +01:00
// 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/);
});
});