Files
AtreidesAssetsFree/tests/ui/ai-proposals-widget.test.mjs
2026-10-30 12:28:48 +01:00

440 lines
18 KiB
JavaScript

// Generated, do not edit. sha256:9ad90176a0537a5dae796d00139a843d9f56aa6cd2de67fc75215c16300b5802
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, /Nieuw portfolio/);
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);
});
});