Files
ink-note-app/test/e2e-database-formulas.test.cjs
2026-10-15 11:57:09 +02:00

138 lines
10 KiB
JavaScript

// End-to-end check of formula properties and the totals row of a database table.
const { app, BrowserWindow, dialog } = require('electron');
const path = require('path');
const os = require('os');
const fs = require('fs');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-e2e-formulas-'));
const vault = path.join(tmp, 'Vault');
fs.mkdirSync(path.join(vault, 'Shelf'), { recursive: true });
const day = (n) => { const d = new Date(Date.now() + n * 864e5); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; };
const schema = {
properties: [
{ key: 'price', name: 'Price', type: 'number', format: 'euro' },
{ key: 'qty', name: 'Qty', type: 'number' },
{ key: 'total', name: 'Total', type: 'formula', expression: 'prop("Price") * prop("Qty")', format: 'euro' },
{ key: 'expiry', name: 'Expiry', type: 'date' },
{ key: 'left', name: 'Days left', type: 'formula', expression: 'if(prop("Expiry"), dateBetween(prop("Expiry"), today(), "days"), "")' }
],
views: [{ id: 'v1', name: 'Table', type: 'table', filters: [], hidden: [], sort: null, widths: {}, calc: {} }]
};
const shelfMd = `---\nink-database: basic\nicon: table\n---\n\n%% ink-database\n${JSON.stringify(schema, null, 2)}\n%%\n`;
fs.writeFileSync(path.join(vault, 'Shelf.md'), shelfMd);
fs.writeFileSync(path.join(vault, 'Shelf', 'Serum.md'), `---\nprice: 10\nqty: 3\nexpiry: ${day(12)}\n---\n`);
fs.writeFileSync(path.join(vault, 'Shelf', 'Toner.md'), `---\nprice: 2.5\nqty: 4\n---\n`);
fs.writeFileSync(path.join(vault, 'Shelf', 'Mask.md'), '---\nprice: 7\n---\n');
app.setPath('userData', tmp);
fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Shelf', bounds: { width: 1500, height: 900 } }));
const exported = path.join(tmp, 'out.csv');
dialog.showSaveDialog = async () => ({ canceled: false, filePath: exported });
require('../electron/main.cjs');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const failures = [];
const check = (name, ok, extra = '') => {
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`);
if (!ok) failures.push(name);
};
app.whenReady().then(async () => {
let win;
for (let i = 0; i < 80 && !(win = BrowserWindow.getAllWindows()[0]); i++) await sleep(100);
const wc = win.webContents;
if (wc.isLoading()) await new Promise((r) => wc.once('did-finish-load', r));
const js = (code) => wc.executeJavaScript(`(async () => { ${code} })()`);
const centerOf = (sel) => js(`const e = ${sel}; if (!e) return null; const r = e.getBoundingClientRect(); return [Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)]`);
const click = async (sel) => {
const c = await centerOf(sel);
if (!c) throw new Error('not found: ' + sel);
wc.sendInputEvent({ type: 'mouseMove', x: c[0], y: c[1] });
wc.sendInputEvent({ type: 'mouseDown', x: c[0], y: c[1], button: 'left', clickCount: 1 });
wc.sendInputEvent({ type: 'mouseUp', x: c[0], y: c[1], button: 'left', clickCount: 1 });
await sleep(450);
};
const fm = (f) => fs.readFileSync(path.join(vault, f), 'utf8');
const schemaOf = () => { const t = fm('Shelf.md'); return JSON.parse(t.slice(t.indexOf('{'), t.lastIndexOf('}') + 1)); };
const cell = (rec, key) => js(`const th = [...document.querySelectorAll('.dbt thead th')]; const i = th.findIndex((t) => t.textContent.includes(${JSON.stringify(key)})); const td = document.querySelector('[data-rec="Shelf/${rec}"]').children[i]; return td.textContent.trim()`);
const header = (name) => `[...document.querySelectorAll('.dbt thead .db-th')].find((b) => b.textContent.includes('${name}'))`;
try {
await sleep(1800);
check('a formula is computed for every record', (await cell('Serum', 'Total')) === '€30.00' && (await cell('Toner', 'Total')) === '€10.00', `${await cell('Serum', 'Total')} ${await cell('Toner', 'Total')}`);
check('...an empty value counts as 0', (await cell('Mask', 'Total')) === '€0.00', await cell('Mask', 'Total'));
check('...and a date formula counts the days', (await cell('Serum', 'Days left')) === '12', await cell('Serum', 'Days left'));
check('...and is empty where its if() says so', (await cell('Toner', 'Days left')) === '', JSON.stringify(await cell('Toner', 'Days left')));
check('a formula is never written into the record', !/total|left/.test(fm('Shelf/Serum.md')), fm('Shelf/Serum.md'));
// it follows edits
await js(`const i = document.querySelector('[data-rec="Shelf/Mask"] .db-numfmt'); i.click(); await new Promise((r) => setTimeout(r, 200)); const n = document.querySelector('[data-rec="Shelf/Mask"] input[type=number]'); n.value = '5'; n.dispatchEvent(new Event('change', { bubbles: true })); n.blur()`);
await js(`const q = [...document.querySelectorAll('[data-rec="Shelf/Mask"] input[type=number]')].pop(); q.value = '2'; q.dispatchEvent(new Event('change', { bubbles: true }))`);
await sleep(900);
check('changing a value updates the formulas at once', (await cell('Mask', 'Total')) === '€10.00', await cell('Mask', 'Total'));
// totals row
await click(`[...document.querySelectorAll('.db-calcrow .db-calc')][3]`); // Total column: select, name, price, qty, total
await click(`[...document.querySelectorAll('.db-menu .mi')].find((m) => m.textContent.trim() === 'Sum')`);
await sleep(900);
const sumText = await js(`return document.querySelectorAll('.db-calcrow .db-calc')[3]?.textContent || ''`);
check('the totals row sums a formula column in its format', /Sum/i.test(sumText) && sumText.includes('€50.00'), sumText);
check('...and the view keeps it', schemaOf().views[0].calc?.total === 'sum', JSON.stringify(schemaOf().views[0].calc));
await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))`);
// the editor
await click(header('Total'));
check('a formula property\'s menu has an editor with a live result', !!(await js(`return document.querySelector('.db-formula-in')`)) && /€10\.00/.test(await js(`return document.querySelector('.db-formula-preview')?.textContent || ''`)), await js(`return document.querySelector('.db-formula-preview')?.textContent`));
await js(`const t = document.querySelector('.db-formula-in'); t.value = 'prop("Price") * 100'; t.dispatchEvent(new Event('input', { bubbles: true }))`);
await sleep(200);
check('...which previews as you type', /€500\.00/.test(await js(`return document.querySelector('.db-formula-preview')?.textContent || ''`)), await js(`return document.querySelector('.db-formula-preview')?.textContent`));
await js(`document.querySelector('.db-formula-in').dispatchEvent(new Event('change', { bubbles: true }))`);
await sleep(900);
check('the new formula is used and saved in the schema', schemaOf().properties.find((p) => p.key === 'total').expression === 'prop("Price") * 100' && (await cell('Serum', 'Total')) === '€1,000.00', await cell('Serum', 'Total'));
// a mistake shows as an error, not as nonsense
await js(`const t = document.querySelector('.db-formula-in'); t.value = 'prop("Price") * '; t.dispatchEvent(new Event('input', { bubbles: true }))`);
await sleep(200);
check('a broken formula says what is wrong while typing', /ends too soon/.test(await js(`return document.querySelector('.db-formula-preview')?.textContent || ''`)));
await js(`document.querySelector('.db-formula-in').dispatchEvent(new Event('change', { bubbles: true }))`);
await sleep(900);
check('...and shows Error in the cells', (await cell('Serum', 'Total')) === 'Error');
await js(`const t = document.querySelector('.db-formula-in'); t.value = 'prop("Price") * prop("Qty")'; t.dispatchEvent(new Event('input', { bubbles: true })); t.dispatchEvent(new Event('change', { bubbles: true }))`);
await sleep(900);
await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))`);
// a new formula property
await js(`document.querySelector('.db-th.add').scrollIntoView({ inline: 'center' })`);
await sleep(300);
await click(`document.querySelector('.db-th.add')`);
await click(`[...document.querySelectorAll('.db-menu .mi')].find((m) => m.textContent.trim() === 'Formula')`);
await sleep(600);
check('adding a Formula property opens its editor', !!(await js(`return document.querySelector('.db-formula-in')`)));
await js(`const t = document.querySelector('.db-formula-in'); t.value = 'concat("Qty: ", prop("Qty"))'; t.dispatchEvent(new Event('input', { bubbles: true })); t.dispatchEvent(new Event('change', { bubbles: true }))`);
await sleep(900);
check('...and it works at once', (await cell('Serum', 'Formula')) === 'Qty: 3', await cell('Serum', 'Formula'));
await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))`);
// a record's own page
await js(`document.querySelector('[data-rec="Shelf/Serum"] .db-ico').click()`);
await sleep(1000);
check('a record\'s page shows its formulas, read-only', await js(`const row = [...document.querySelectorAll('.prop')].find((p) => p.textContent.includes('Total')); return !!row && row.textContent.includes('€30.00') && !row.querySelector('input')`));
await js(`document.querySelector('.crumb').click()`);
await sleep(900);
// export
await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true }))`);
await sleep(300);
await js(`const i = document.querySelector('.overlay input'); i.value = 'Export this database as CSV'; i.dispatchEvent(new Event('input', { bubbles: true }))`);
await sleep(300);
await js(`document.querySelector('.overlay input').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))`);
await sleep(1200);
const csv = fs.existsSync(exported) ? fs.readFileSync(exported, 'utf8') : '';
check('the CSV export has the computed values', /Serum,10,3,30,/.test(csv.replace(/"/g, '')) || /Serum,10,3,30/.test(csv), JSON.stringify(csv.split('\r\n').slice(0, 3)));
} catch (err) {
check('harness', false, err.stack);
}
fs.rmSync(tmp, { recursive: true, force: true });
console.log(failures.length ? `\n${failures.length} check(s) failed` : '\nformulas e2e passed!');
app.exit(failures.length ? 1 : 0);
});