Versie 0.3.3

This commit is contained in:
Atreides Assets committed 2026-10-30 12:28:48 +01:00
1 parent 1cf6eeed29
commit 6e741a51fe
57 files changed
+1362 -2135

No files matched your search

+7 -1
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:3914f1ddc9004d83b8401e7c0b8dbbc55835029676e9b46f33978bfb59e5b5ee
// Generated, do not edit. sha256:b4e0276b7d4bc8c4728c77282dde12358131e2103dd3182aa9dbf27cc334764b
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
@@ -261,6 +261,12 @@ describe('seedPreferences', () => {
assert.equal(PREFERENCES.app.market_spot.initial, 'off', 'de terugval van een bestaande portefeuille');
});
it('zet de AI-koppeling uit in een verse database, en laat de terugval aan', () => {
seedPreferences();
assert.equal(waarde('ai', 'enabled'), 0);
assert.equal(PREFERENCES.ai.enabled.initial, 1, 'de terugval van een bestaande portefeuille');
});
it('laat de andere voorkeuren op hun initial', () => {
seedPreferences();
assert.equal(waarde('app', 'result_signs'), PREFERENCES.app.result_signs.initial);
+51
View File
@@ -0,0 +1,51 @@
// Generated, do not edit. sha256:4bf1820de70357b7a7e29ccee6fc599f970ee70dfc5b2f00ce791c38d33bc92e
import { describe, it, mock, afterEach } from 'node:test';
import assert from 'node:assert/strict';
const { fetchJson } = await import('../../client/price/btc-explorer.js');
const echteFetch = globalThis.fetch;
afterEach(() => { globalThis.fetch = echteFetch; mock.timers.reset(); });
async function uitzitten(belofte) {
let klaar = false;
const uit = belofte.then(v => ({ v }), e => ({ e })).finally(() => { klaar = true; });
while (!klaar) {
await new Promise(r => setImmediate(r));
mock.timers.runAll();
}
return uit;
}
const ok = body => ({ ok: true, status: 200, json: async () => body });
const status = s => ({ ok: false, status: s, json: async () => ({}) });
describe('fetchJson: opnieuw proberen', () => {
it('zit drie netwerkfouten uit en geeft dan het antwoord', async () => {
mock.timers.enable({ apis: ['setTimeout'] });
let n = 0;
globalThis.fetch = async () => { if (++n <= 3) throw new TypeError('Failed to fetch'); return ok({ x: 1 }); };
const { v, e } = await uitzitten(fetchJson('https://x/api', 'X'));
assert.equal(e, undefined);
assert.deepEqual(v, { x: 1 });
assert.equal(n, 4);
});
it('geeft na de vierde mislukte poging de fout van de laatste', async () => {
mock.timers.enable({ apis: ['setTimeout'] });
let n = 0;
globalThis.fetch = async () => { n++; return status(429); };
const { e } = await uitzitten(fetchJson('https://x/api', 'X'));
assert.ok(e);
assert.equal(n, 4);
});
it('probeert een 404 niet opnieuw', async () => {
mock.timers.enable({ apis: ['setTimeout'] });
let n = 0;
globalThis.fetch = async () => { n++; return status(404); };
const { e } = await uitzitten(fetchJson('https://x/api', 'X'));
assert.ok(e);
assert.equal(n, 1);
});
});
+16 -17
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:38c4b07d1566cda74ef6cac4d031f49fd52004b6938fdee8f13e3e7678fc12c7
// Generated, do not edit. sha256:13b6b32cbbf6b8c4a6a6069af37a7be33560a8abfc2af81ae2b4009e9692d7c6
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
@@ -22,6 +22,7 @@ mock.module('../../server/license.mjs', {
const { createAsset, updateAsset, deleteAsset, listAssets } = await import('../../server/data/assets.js');
const { createAccount, listAccounts } = await import('../../server/data/accounts.js');
const { mergeAccounts } = await import('../../server/data/accounts-merge.js');
const { createValuation, listValuations } = await import('../../server/data/valuations.js');
const { saveConnection, listConnections } = await import('../../server/data/connections.js');
const { setPriceSourceRank } = await import('../../server/data/price-source.js');
@@ -148,23 +149,10 @@ describe('de gratis stand op balansen', () => {
rateAt: `${dag}T00:00:00Z`, source: 'manual',
});
it('laat ÉÉN balans toe en weigert de tweede', async () => {
it('kent geen grens: ook de gratis stand legt balans na balans vast', async () => {
const asset = await createAsset(EEN);
await createValuation(balans(asset, '2026-01-01'));
await assert.rejects(() => createValuation(balans(asset, '2026-02-01')), (fout) => {
assert.equal(fout.key, 'valuations.maxFree');
assert.equal(fout.params.max, 1);
return true;
});
assert.equal(listValuations().length, 1);
});
it('laat de tweede WEL toe in de betaalde stand', async () => {
const asset = await createAsset(EEN);
await createValuation(balans(asset, '2026-01-01'));
stand = { level: 'full', enforced: true, why: 'licensed' };
await createValuation(balans(asset, '2026-02-01'));
assert.equal(listValuations().length, 2);
for (const dag of ['2026-01-01', '2026-02-01', '2026-03-01']) await createValuation(balans(asset, dag));
assert.equal(listValuations().length, 3);
});
});
@@ -212,4 +200,15 @@ describe('de features van de gratis stand', () => {
await assert.rejects(() => setPriceSourceRank(asset, 'spot', 1),
(f) => f.key === 'integrations.matrixPaid');
});
it('weigert accounts samenvoegen en laat beide accounts staan; betaald gaat het door', async () => {
const wallet = (name) => ({ name, kind: 'wallet', isOwnCustody: true, scanEnabled: false, taxCategory: null });
const a = await createAccount(wallet('A'));
const b = await createAccount(wallet('B'));
await assert.rejects(() => mergeAccounts(a, b), (f) => f.key === 'accounts.mergePaid');
assert.equal(listAccounts().length, 2);
stand = { level: 'full', enforced: true, why: 'licensed' };
await mergeAccounts(a, b);
assert.deepEqual(listAccounts().map(r => r.id), [a]);
});
});
-302
View File
@@ -1,302 +0,0 @@
// Generated, do not edit. sha256:ba6482c6f8221f445c935cfd65e76c09e943da662ff5dab04e50d8a76e004316
import { describe, it, beforeEach, mock } from 'node:test';
import { toSearchName } from '../../shared/search-name.js';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
import { ACCOUNT_SOURCE_KINDS } from '../../shared/config.js';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const { mergeAccounts, mergeCheck } = await import('../../server/data/accounts-merge.js');
const { eventsNetByAccount } = await import('../../server/data/events.js');
let btc;
beforeEach(() => {
harness.recreate();
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES ('BTC','Bitcoin','bitcoin','crypto','BTC','sat',8,'unit')`);
btc = harness.exports.lastId();
});
function account(naam, kind = 'wallet') {
const eigen = ACCOUNT_SOURCE_KINDS[kind].length > 0 ? 1 : 0;
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES (?,?,?,?,?,1,NULL)`, [naam, toSearchName(naam), kind, eigen, 0]);
return harness.exports.lastId();
}
function event({ type = 'buy', from = null, to = null, amount = 1000, at = '2024-01-01T00:00:00', unitFee = null }) {
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, unitFee, fromAccountId, toAccountId, source, createdAt)
VALUES (?,?,?,?,?,?,?,?,'manual',?)`,
[btc, type, at, amount, type === 'buy' || type === 'sell' ? 1 : null, unitFee, from, to, at]);
return harness.exports.lastId();
}
function waardering({ acc, onDate, balance, rateAt = null }) {
harness.exports.run(
`INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source)
VALUES (?,?,?,?,?,?, 'manual')`,
[btc, onDate, acc, balance, 50000, rateAt ?? `${onDate}T00:00:00`]);
return harness.exports.lastId();
}
const tel = (tabel, kolom, id) =>
harness.exports.one(`SELECT COUNT(*) AS n FROM ${tabel} WHERE ${kolom} = ?`, [id]).n;
const bestaatAccount = id => harness.exports.one(`SELECT 1 AS x FROM Account WHERE id=?`, [id]) != null;
describe('mergeCheck: compatibiliteit', () => {
it('vraagt om twee accounts', () => {
const a = account('A');
assert.equal(mergeCheck(a, null).reason, 'pickTwo');
assert.equal(mergeCheck(null, a).reason, 'pickTwo');
assert.equal(mergeCheck(a, null).counts, null);
});
it('weigert een account met zichzelf', () => {
const a = account('A');
assert.equal(mergeCheck(a, a).reason, 'sameAccount');
assert.equal(mergeCheck(String(a), a).reason, 'sameAccount',
'ook als het id als string binnenkomt (uit een <select>)');
});
it('weigert een onbekend account', () => {
const a = account('A');
assert.equal(mergeCheck(a, 9999).reason, 'notFound');
});
it('weigert custodial × trackable', () => {
const beurs = account('Beurs', 'exchange');
const wallet = account('Wallet', 'wallet');
assert.equal(mergeCheck(beurs, wallet).reason, 'sourceMismatch');
assert.equal(mergeCheck(wallet, beurs).reason, 'sourceMismatch',
'de regel geldt in beide richtingen');
});
it('weigert een overlever die de bron-soorten van de opheffer niet kan dragen', () => {
const wallet = account('Wallet', 'wallet');
const node = account('Node', 'lightning');
assert.equal(mergeCheck(wallet, node).reason, 'kindCannotCarry');
});
it('staat de andere richting wél toe', () => {
const wallet = account('Wallet', 'wallet');
const node = account('Node', 'lightning');
assert.equal(mergeCheck(node, wallet).ok, true,
'lightning kan alles dragen wat wallet kan');
});
it('staat twee custodial soorten toe', () => {
const bank = account('Bank', 'bank');
const kluis = account('Kluis', 'vault');
assert.equal(mergeCheck(bank, kluis).ok, true);
});
it('telt wat er zou verhuizen, en kijkt naar beide event-kanten', () => {
const s = account('S');
const v = account('V');
event({ type: 'buy', to: v });
event({ type: 'sell', from: v });
event({ type: 'buy', to: s });
harness.exports.run(`INSERT INTO WalletSource (accountId, kind, value, createdAt)
VALUES (?, 'address', 'bc1qtest', '2024-01-01T00:00:00')`, [v]);
waardering({ acc: v, onDate: '2024-02-01', balance: 1000 });
const chk = mergeCheck(s, v);
assert.equal(chk.ok, true);
assert.deepEqual(chk.counts, { sources: 1, events: 2, schedules: 0, valuations: 1 });
assert.equal(chk.survivorName, 'S');
assert.equal(chk.victimName, 'V');
});
it('verandert niets aan de database', () => {
const s = account('S');
const v = account('V');
event({ type: 'buy', to: v });
mergeCheck(s, v);
assert.equal(tel('Event', 'toAccountId', v), 1, 'een preview hoort niets te muteren');
assert.ok(bestaatAccount(v));
});
});
describe('mergeAccounts: verhuizing van kinddata', () => {
it('weigert een incompatibele merge en laat alles staan', async () => {
const beurs = account('Beurs', 'exchange');
const wallet = account('Wallet', 'wallet');
event({ type: 'buy', to: wallet });
await assert.rejects(() => mergeAccounts(beurs, wallet), (e) => e.key === 'accounts.mergeReason_sourceMismatch');
assert.ok(bestaatAccount(wallet), 'de opheffer bestaat nog');
assert.equal(tel('Event', 'toAccountId', wallet), 1);
});
it('verhuist alle acht soorten kinddata en verwijdert de opheffer', async () => {
const s = account('Overlever');
const v = account('Opheffer');
event({ type: 'buy', to: v });
event({ type: 'sell', from: v });
harness.exports.run(`INSERT INTO WalletSource (accountId, kind, value, createdAt)
VALUES (?, 'address', 'bc1qbron', '2024-01-01T00:00:00')`, [v]);
harness.exports.run(`INSERT INTO WalletAddress (accountId, address) VALUES (?, 'bc1qadres')`, [v]);
harness.exports.run(`INSERT INTO WalletTransaction (accountId, layer, txid, direction, valueSats, createdAt)
VALUES (?, 'onchain', 'tx1', 'in', 100, '2024-01-01T00:00:00')`, [v]);
harness.exports.run(`INSERT INTO ScanLog (accountId, scannedAt, status)
VALUES (?, '2024-01-01T00:00:00', 'success')`, [v]);
harness.exports.run(`INSERT INTO ReconcileProposal
(accountId, assetId, amount, direction, suggestedType, status, detectedAt)
VALUES (?,?, 100, 'in', 'transfer', 'pending', '2024-01-01T00:00:00')`, [v, btc]);
harness.exports.run(`INSERT INTO ImportBatch (accountId, filename, importedAt, rowCount)
VALUES (?, 'x.csv', '2024-01-01T00:00:00', 3)`, [v]);
harness.exports.run(`INSERT INTO InterestSchedule
(accountId, rateKind, payoutAssetId, startsOn, compounding, createdAt)
VALUES (?, 'variable', ?, '2024-01-01', 0, '2024-01-01T00:00:00')`, [v, btc]);
const counts = await mergeAccounts(s, v);
assert.equal(tel('Event', 'toAccountId', s), 1);
assert.equal(tel('Event', 'fromAccountId', s), 1);
assert.equal(tel('WalletSource', 'accountId', s), 1);
assert.equal(tel('WalletAddress', 'accountId', s), 1);
assert.equal(tel('WalletTransaction', 'accountId', s), 1);
assert.equal(tel('ScanLog', 'accountId', s), 1);
assert.equal(tel('ReconcileProposal', 'accountId', s), 1);
assert.equal(tel('ImportBatch', 'accountId', s), 1);
assert.equal(tel('InterestSchedule', 'accountId', s), 1);
for (const [tabel, kolom] of [['Event', 'toAccountId'], ['Event', 'fromAccountId'],
['WalletSource', 'accountId'], ['WalletAddress', 'accountId'], ['WalletTransaction', 'accountId'],
['ScanLog', 'accountId'], ['ReconcileProposal', 'accountId'], ['ImportBatch', 'accountId'],
['InterestSchedule', 'accountId']]) {
assert.equal(tel(tabel, kolom, v), 0, `${tabel}.${kolom} verwijst nog naar de opheffer`);
}
assert.equal(bestaatAccount(v), false, 'de opheffer hoort verdwenen te zijn');
assert.ok(bestaatAccount(s));
assert.equal(counts.events, 2, 'de teruggegeven counts komen uit de voorbeschouwing');
});
it("laat de bron-id's ongemoeid, zodat NWC-secrets geldig blijven", async () => {
const s = account('S');
const v = account('V');
harness.exports.run(`INSERT INTO WalletSource (accountId, kind, value, createdAt)
VALUES (?, 'address', 'bc1qbron', '2024-01-01T00:00:00')`, [v]);
const bronId = harness.exports.lastId();
await mergeAccounts(s, v);
const bron = harness.exports.one(`SELECT id, accountId FROM WalletSource`);
assert.equal(bron.id, bronId, 'het bron-id mag niet wijzigen');
assert.equal(bron.accountId, s);
});
});
describe('mergeAccounts: waarderingen', () => {
const verschil = (valId) => {
const v = harness.exports.one(`SELECT accountId, assetId, rateAt, balance FROM Valuation WHERE id=?`, [valId]);
return v.balance - eventsNetByAccount(v.accountId, v.assetId, v.rateAt);
};
it('houdt het verschil van elke waardering gelijk', async () => {
const s = account('S');
const v = account('V');
event({ type: 'buy', to: v, amount: 1000, at: '2024-01-01T00:00:00' });
event({ type: 'buy', to: s, amount: 500, at: '2024-01-01T00:00:00' });
const valV = waardering({ acc: v, onDate: '2024-02-01', balance: 1200 });
const valS = waardering({ acc: s, onDate: '2024-03-01', balance: 500 });
assert.equal(verschil(valV), 200);
assert.equal(verschil(valS), 0);
await mergeAccounts(s, v);
assert.equal(verschil(valV), 200, 'een bestaand verschil moet behouden blijven');
assert.equal(verschil(valS), 0, 'reconciled (0) moet 0 blijven');
});
it('corrigeert de balance daarvoor, ook van de overlever zelf', async () => {
const s = account('S');
const v = account('V');
event({ type: 'buy', to: v, amount: 1000, at: '2024-01-01T00:00:00' });
event({ type: 'buy', to: s, amount: 500, at: '2024-01-01T00:00:00' });
const valV = waardering({ acc: v, onDate: '2024-02-01', balance: 1200 });
const valS = waardering({ acc: s, onDate: '2024-03-01', balance: 500 });
await mergeAccounts(s, v);
const balans = id => harness.exports.one(`SELECT balance FROM Valuation WHERE id=?`, [id]).balance;
assert.equal(balans(valV), 1700, '1500 grootboek + 200 verschil');
assert.equal(balans(valS), 1500, '1500 grootboek + 0 verschil, dus herschreven van 500');
});
it('verhuist een niet-botsende waardering naar de overlever', async () => {
const s = account('S');
const v = account('V');
const val = waardering({ acc: v, onDate: '2024-02-01', balance: 10 });
await mergeAccounts(s, v);
assert.equal(harness.exports.one(`SELECT accountId FROM Valuation WHERE id=?`, [val]).accountId, s);
});
it('houdt bij een botsing precies één waardering over', async () => {
const s = account('S');
const v = account('V');
waardering({ acc: s, onDate: '2024-02-01', balance: 500 });
waardering({ acc: v, onDate: '2024-02-01', balance: 1200 });
await mergeAccounts(s, v);
const over = harness.exports.all(`SELECT accountId, balance FROM Valuation`);
assert.equal(over.length, 1, 'geen dubbele rij, geen UNIQUE-fout');
assert.equal(over[0].accountId, s, 'de overgebleven rij hangt aan de overlever');
});
it('wist bij een botsing de rij van de opheffer, niet die van de overlever', async () => {
const s = account('S');
const v = account('V');
const valS = waardering({ acc: s, onDate: '2024-02-01', balance: 500 });
const valV = waardering({ acc: v, onDate: '2024-02-01', balance: 1200 });
await mergeAccounts(s, v);
assert.ok(harness.exports.one(`SELECT 1 AS x FROM Valuation WHERE id=?`, [valS]),
'de waardering van de overlever blijft');
assert.equal(harness.exports.one(`SELECT 1 AS x FROM Valuation WHERE id=?`, [valV]), null,
'de botsende rij van de opheffer wijkt');
assert.equal(harness.exports.one(`SELECT balance FROM Valuation`).balance, 500,
'en dus blijft de gemeten balans van het behouden account staan');
});
it('botst alleen op dezelfde datum, niet op hetzelfde asset alleen', async () => {
const s = account('S');
const v = account('V');
waardering({ acc: s, onDate: '2024-02-01', balance: 500 });
waardering({ acc: v, onDate: '2024-02-02', balance: 1200 });
await mergeAccounts(s, v);
assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM Valuation`).n, 2);
});
it('rekent het verschil met transfers en fees mee', async () => {
const s = account('S');
const v = account('V');
event({ type: 'buy', to: v, amount: 1000, at: '2024-01-01T00:00:00' });
event({ type: 'transfer', from: v, to: null, amount: 300, unitFee: 20, at: '2024-01-15T00:00:00' });
const val = waardering({ acc: v, onDate: '2024-02-01', balance: 700 });
assert.equal(verschil(val), 20);
await mergeAccounts(s, v);
assert.equal(verschil(val), 20);
});
it('negeert waarderingen ná het rateAt-moment bij de correctie', async () => {
const s = account('S');
const v = account('V');
event({ type: 'buy', to: v, amount: 1000, at: '2024-01-01T00:00:00' });
event({ type: 'buy', to: s, amount: 9999, at: '2024-06-01T00:00:00' });
const val = waardering({ acc: v, onDate: '2024-02-01', balance: 1000 });
assert.equal(verschil(val), 0);
await mergeAccounts(s, v);
assert.equal(verschil(val), 0, 'de late aankoop hoort buiten dit venster te vallen');
assert.equal(harness.exports.one(`SELECT balance FROM Valuation WHERE id=?`, [val]).balance, 1000,
'balance blijft 1000: het grootboek t/m die datum is niet veranderd');
});
});
+10 -1
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:e7c1f8e0ef8ff3927bf8ff8a92bc2028aac07446bbf50cb1b0842db32ca8e5f3
// Generated, do not edit. sha256:500c3d5e31a736de36a900bebc7682d080b1ebb8cda99a94331c6ce11a3fab67
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
@@ -16,6 +16,7 @@ mock.module('../../server/license.mjs', {
});
const { listPortfolios } = await import('../../server/portfolios.mjs');
const { CONFIG } = await import('../../shared/config.js');
const { createPortfolio, archivePortfolio } = await import('../../server/portfolios-admin.mjs');
let DIR;
@@ -50,6 +51,14 @@ describe('de gratis stand op portefeuilles', () => {
assert.deepEqual(listPortfolios().map((p) => p.naam), ['Eerste']);
});
it('telt de demo niet mee: naast de demo past nog een eigen portefeuille', async () => {
stand = { level: 'full', enforced: true, why: 'licensed' };
await createPortfolio(CONFIG.PORTFOLIO.DEMO_NAME);
stand = { level: 'free', enforced: true, why: 'none' };
await createPortfolio('Eigen');
await assert.rejects(() => createPortfolio('Tweede'), (fout) => fout.key === 'portfolios.maxFree');
});
it('laat de tweede WEL toe in de betaalde stand', async () => {
await createPortfolio('Eerste');
stand = { level: 'full', enforced: true, why: 'licensed' };
+8 -5
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:4b1eef4e0860bce440fbf0875965c32cd7b13b237d0ffc92ef0255b11fe9a950
// Generated, do not edit. sha256:687aaa4f407ca89e2e9f7cb5d0e2f1e02a1d00b4fdf4b9f0890c187e9171588f
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
@@ -30,10 +30,13 @@ const wijsNaar = (naam) => writeFile(join(dir, 'active.json'), JSON.stringify({
before(async () => {
dir = await mkdtemp(join(tmpdir(), 'assetapp-mcp-wissel-'));
proc = spawn(process.execPath, [STDIO], {
env: { ...process.env, ASSETAPP_DATA_DIR: dir },
stdio: ['pipe', 'pipe', 'pipe'],
});
const env = { ...process.env, ASSETAPP_DATA_DIR: dir };
const eerste = spawn(process.execPath, [STDIO], { env, stdio: 'ignore' });
await new Promise((klaar) => eerste.once('exit', klaar));
const db = new DatabaseSync(join(dir, 'MyAssets', 'db.sqlite'));
db.exec(`UPDATE Preference SET value = '1' WHERE scope = 'ai' AND key = 'enabled'`);
db.close();
proc = spawn(process.execPath, [STDIO], { env, stdio: ['pipe', 'pipe', 'pipe'] });
proc.stdout.on('data', (brok) => {
buffer += brok.toString();
let n;
+11 -7
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:7bb4b4b51bf14bf2fa32107d316282a41f1c7586c33fc94c43098199bfa75953
// Generated, do not edit. sha256:d705db22a94bcceb849648fab7596bd5a84e34d8b58a472ec9dc677c8db0cc0a
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
@@ -11,7 +11,7 @@ import { fileURLToPath } from 'node:url';
const WORTEL = join(fileURLToPath(import.meta.url), '..', '..', '..');
const STDIO = join(WORTEL, 'server', 'mcp', 'stdio.mjs');
let dir, proc, buffer = '', wachtenden = [];
let dir, dbPad, proc, buffer = '', wachtenden = [];
function stuur(bericht) {
return new Promise((resolve, reject) => {
@@ -24,10 +24,14 @@ function stuur(bericht) {
before(async () => {
dir = await mkdtemp(join(tmpdir(), 'assetapp-mcp-stdio-'));
proc = spawn(process.execPath, [STDIO], {
env: { ...process.env, ASSETAPP_DATA_DIR: dir, ASSETAPP_DB_FILE: join(dir, 'db.sqlite') },
stdio: ['pipe', 'pipe', 'pipe'],
});
dbPad = join(dir, 'MyAssets', 'db.sqlite');
const env = { ...process.env, ASSETAPP_DATA_DIR: dir, ASSETAPP_DB_FILE: dbPad };
const eerste = spawn(process.execPath, [STDIO], { env, stdio: 'ignore' });
assert.equal(await new Promise((klaar) => eerste.once('exit', klaar)), 0, 'een verse installatie stopt met de koppeling uit');
const db = new DatabaseSync(dbPad);
db.exec(`UPDATE Preference SET value = '1' WHERE scope = 'ai' AND key = 'enabled'`);
db.close();
proc = spawn(process.execPath, [STDIO], { env, stdio: ['pipe', 'pipe', 'pipe'] });
proc.stdout.on('data', (brok) => {
buffer += brok.toString();
let n;
@@ -74,7 +78,7 @@ describe('mcp-stdio: het proces beantwoordt bericht na bericht', () => {
describe('mcp-stdio: de schuif "Actief" zet dit proces uit', () => {
it('stopt bij het eerstvolgende bericht zodra de schuif uit gaat', async () => {
const db = new DatabaseSync(join(dir, 'db.sqlite'));
const db = new DatabaseSync(dbPad);
db.exec(`PRAGMA busy_timeout = 5000`);
db.exec(`INSERT INTO Preference (scope, key, value) VALUES ('ai', 'enabled', '0')
ON CONFLICT (scope, key) DO UPDATE SET value = '0'`);
+2 -2
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:e9def29bc2006594fdeb8199e56fcff107612198b70d50be3a6d86402660dd84
// Generated, do not edit. sha256:27b79f8183ca4a0e1556b615e64322487b0abdd54fa1269b5d1f2751a7c279af
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
@@ -15,7 +15,7 @@ mock.module('../../client/data/ai-audit.js', { namedExports: { countAiToolCallsS
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/license.js', { namedExports: { licenseChangedSinceSeen: async () => false, locked: () => false } });
mock.module('../../client/app-settings.js', { namedExports: { appSetting: () => null, baseCurrency: () => 'EUR' } });
mock.module('../../client/price/providers.js', { namedExports: { rateSourceFor: async () => null } });
+12 -7
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:0c8f95dec7394e0dad71a6a790d7768a2d482ac976a2a68218282ae506402032
// Generated, do not edit. sha256:ac1fa206a688dd1bdc09c884947e72f6ac770db7b7ba6d03716b15de2b2b3551
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
@@ -63,10 +63,15 @@ describe('activityItems', () => {
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', () => {
it('loopt per dag van de dag vóór het eerste item tot de dag na 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]);
assert.deepEqual(points.map(p => p[0]), [1, 2, 3, 4, 5, 6].map(d => dag(`2026-03-0${d}`)));
assert.deepEqual(points.map(p => p[1]), [0, 1, 0, 0, 0, 0]);
});
it('zet een streepje van vandaag niet op de rand, waar de stip half wegviel', () => {
const { points } = heartbeat([item('2026-03-05', 0)], '2026-03-05T12:00:00Z');
assert.notEqual(points[points.length - 1][0], dag('2026-03-05'), 'vandaag is niet het laatste punt');
});
it('wijst omhoog bij meer erin, omlaag bij meer eruit, en omhoog bij evenveel', () => {
@@ -88,16 +93,16 @@ describe('heartbeat', () => {
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(points.length, 4);
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', () => {
it('geeft niets zonder items, en drie 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.length, 3);
assert.equal(points[1][0] - points[0][0], DAG);
});
});
+2 -2
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:45c8de8a885eb07b9f327fbd56b57d092c058152ae89dc307ade593f6035be32
// Generated, do not edit. sha256:9ad90176a0537a5dae796d00139a843d9f56aa6cd2de67fc75215c16300b5802
import { describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
@@ -241,7 +241,7 @@ describe('een balans en een portefeuille op de kaart', () => {
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, /Nieuw portfolio/);
assert.match(html, /AI-demonstratie/);
assert.match(html, /Leeg/);
assert.match(html, /wisselen|niet geopend/i, 'dat hij niet geopend wordt');
+54
View File
@@ -0,0 +1,54 @@
// Generated, do not edit. sha256:1928a498401313a668bc3ca99074cab18fed0b4d4c803c1532d4ea964f94743c
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { installDom } from '../helpers/mini-dom.mjs';
import { CONFIG } from '../../shared/config.js';
const dom = installDom();
const INSTELLING = { blur: 0, auto_blur_min: CONFIG.AUTO_BLUR.NEVER_MIN };
mock.module('../../client/app-settings.js', {
namedExports: {
appSetting: k => INSTELLING[k],
setAppSetting: async (k, v) => { INSTELLING[k] = v; },
},
});
const { initPrivacyBlur } = await import('../../client/ui/blur.js');
initPrivacyBlur(dom.host());
const stand = () => dom.document.documentElement.dataset.blur;
beforeEach(() => {
INSTELLING.blur = 0;
dom.document.documentElement.dataset.blur = 'off';
for (const el of dom.document.querySelectorAll('.nav-edit-toggle, button.focus')) el.remove();
});
describe('de spatiebalk en de vervaging', () => {
it('wisselt heen en terug als niets de focus heeft, en houdt de pagina stil', () => {
const doorgelaten = dom.key(dom.document.body, ' ');
assert.equal(stand(), 'on');
assert.equal(doorgelaten, false, 'geen scrollsprong van de spatie');
dom.key(dom.document.body, ' ');
assert.equal(stand(), 'off');
});
it('laat een knop met de focus zijn eigen spatie houden', () => {
const knop = dom.document.createElement('button');
knop.className = 'focus';
dom.document.body.appendChild(knop);
assert.equal(dom.key(knop, ' '), true);
assert.equal(stand(), 'off');
});
it('doet niets in bewerkmodus of met een modifier', () => {
dom.key(dom.document.body, ' ', { shiftKey: true });
assert.equal(stand(), 'off');
const schakel = dom.document.createElement('span');
schakel.className = 'nav-edit-toggle is-on';
dom.document.body.appendChild(schakel);
dom.key(dom.document.body, ' ');
assert.equal(stand(), 'off');
});
});
-429
View File
@@ -1,429 +0,0 @@
// 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/);
});
});
+21 -1
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:f04b67a8f0dbd9eb295b842585345e76f56ef4fd377d5010897bcf893dbd0395
// Generated, do not edit. sha256:0196b96345c180a741780bad0241a094640e956c6972ad08f5b4f400181c36c5
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { installDom } from '../helpers/mini-dom.mjs';
@@ -98,6 +98,26 @@ describe('a panel that would scroll gets wider on opening', () => {
assert.equal(panel.classList.contains('is-wide'), false);
});
it('a click on the icon pins the panel, and a click outside, Escape or the icon again closes it', () => {
configureHint();
setPageHint(page, { title: 'x', rows: ['<a href="#">link</a>'] });
const hint = () => mount.querySelector('.hint');
const btn = () => mount.querySelector('.hint-btn');
dom.click(btn());
assert.ok(hint().classList.contains('is-open'));
assert.equal(btn().getAttribute('aria-expanded'), 'true');
dom.click(mount.querySelector('.hint-panel a'));
assert.ok(hint().classList.contains('is-open'), 'a click inside the panel keeps it open');
dom.click(page);
assert.equal(hint().classList.contains('is-open'), false);
dom.click(btn());
dom.key(dom.document.body, 'Escape');
assert.equal(hint().classList.contains('is-open'), false);
dom.click(btn());
dom.click(btn());
assert.equal(hint().classList.contains('is-open'), false);
});
it('binds its listeners once per mount, however often a page announces', () => {
const before = mount.listeners.length;
setPageHint(page, { title: 'a', rows: [] });
+18 -2
View File
@@ -1,7 +1,7 @@
// Generated, do not edit. sha256:3b34cf97cc5aa20ba08ae8a6246f0235b943e5e85fc0d31e21707e06b2c2c371
// Generated, do not edit. sha256:d235c44eb19e911ab77974e75ddb595cbc8249ee72a4b4a543123c373e4e6b5f
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { periodChoices, startPeriod, returnLine, idsForSelection, contributorsPerDay } from '../../client/wealth/return-view.js';
import { periodChoices, startPeriod, returnLine, idsForSelection, contributorsPerDay, flatLine } from '../../client/wealth/return-view.js';
import { dagreeks, seriesFor, meetVenster } from '../../client/wealth/series.js';
import { windowTwr } from '../../client/wealth/performance.js';
@@ -94,6 +94,22 @@ describe('returnLine', () => {
});
});
describe('flatLine', () => {
it('geeft nul over de periode, eindigend op vandaag, oudste eerst', () => {
const f = flatLine(7, '2026-10-29');
assert.equal(f.amount, 0);
assert.equal(f.pct, 0);
assert.equal(f.labels.length, 7);
assert.equal(f.labels[0], '2026-10-23');
assert.equal(f.labels.at(-1), '2026-10-29');
assert.ok(f.points.every(p => p.cumulative === 0));
});
it('heeft minstens twee punten, ook voor één dag', () => {
assert.deepEqual(flatLine(1, '2026-10-29').labels, ['2026-10-28', '2026-10-29']);
});
});
describe('seriesFor met een groep', () => {
it('is met alle assets erin hetzelfde als het totaal', () => {
const r = reeks();