// Draait de echte Alpine-component uit index.html en toetst de logica van
// het invulmenu. Wordt aangeroepen door tests/test_ui_logica.py; los draaien
// kan met: node tests/js/menu.test.js
const fs = require('fs');
const html = fs.readFileSync(__dirname + '/../../server-up/templates/index.html', 'utf8');
// Alleen de component-functie uitsnijden en uitvoeren.
const start = html.indexOf('function app() {');
const eind = html.indexOf('', start);
const bron = html.slice(start, eind);
global.localStorage = { getItem: () => null, setItem: () => {} };
global.location = { hash: '', search: '' };
global.window = { matchMedia: () => ({matches:false, addEventListener(){}}),
addEventListener(){}, innerWidth: 1200 };
global.document = { documentElement: {classList:{add(){},remove(){}}},
addEventListener(){}, querySelector: () => null };
global.navigator = { clipboard: { writeText: async () => {} }, language: 'nl' };
global.crypto = require('crypto').webcrypto;
eval(bron);
const c = app();
c.t = (k, v) => k; // vertaling niet nodig voor de logica
c.bpFieldVisible = () => true;
c.rpc = async () => ({ok: true, preview_files: {}, env_preview: ''});
c.toast = () => {};
function veld(naam, stap, extra = {}) {
return Object.assign({name: naam, title: naam, step: stap, type: 'str',
group: 'Algemeen', required: false, advanced: false}, extra);
}
let fouten = 0;
const check = (naam, waar) => { if (!waar) { fouten++; console.log(' ✖ ' + naam); }
else console.log(' ✓ ' + naam); };
// ── Eenvoudige app: alleen basisvelden ──────────────────────────────────────
c.install = {format:'boilerplate', loading:false, fields:[
veld('service_name','basis'), veld('port','basis'), veld('appdata_dir','basis'),
], values:{}, step:0};
c.onePage = false; c.showAdvanced = false;
let s = c.wizardSteps().map(x => x.id);
check('eenvoudige app: basis, verbinden, controleren',
JSON.stringify(s) === '["basis","verbinden","controleren"]');
// ── App met geheimen ────────────────────────────────────────────────────────
c.install.fields.push(veld('db_password','geheimen',{required:true, secret:true}));
s = c.wizardSteps().map(x => x.id);
check('geheimenstap verschijnt', s.includes('geheimen'));
// ── Lege stap wordt overgeslagen ────────────────────────────────────────────
check('geen lege instellingenstap', !s.includes('instellingen'));
// ── Geavanceerde velden ─────────────────────────────────────────────────────
c.install.fields.push(veld('retention','instellingen',{advanced:true}));
check('geavanceerd veld verborgen', c.stepFields('instellingen').length === 0);
check('stap zonder zichtbare velden verdwijnt',
!c.wizardSteps().map(x=>x.id).includes('instellingen'));
c.showAdvanced = true;
check('geavanceerd veld zichtbaar na aanzetten', c.stepFields('instellingen').length === 1);
check('stap komt terug', c.wizardSteps().map(x=>x.id).includes('instellingen'));
check('teller klopt', c.advancedCount() === 1);
// ── Verplicht veld blokkeert Volgende ───────────────────────────────────────
c.install.step = c.wizardSteps().findIndex(x => x.id === 'geheimen');
check('leeg verplicht veld wordt gemeld', c.stepMissing().length === 1);
c.install.values.db_password = 'ietsGeheims';
check('ingevuld: niets meer te melden', c.stepMissing().length === 0);
// ── Groepsschakelaar uit: veld telt niet mee ────────────────────────────────
c.install.fields.push(veld('opt_pass','geheimen',
{required:true, secret:true, group:'Extra', group_toggle:'gebruik_extra'}));
c.install.values.gebruik_extra = false;
check('uitgeschakelde groep blokkeert niet', c.stepMissing().length === 0);
c.install.values.gebruik_extra = true;
check('ingeschakelde groep blokkeert wel', c.stepMissing().length === 1);
// ── Navigatie ───────────────────────────────────────────────────────────────
c.install.values.opt_pass = 'x';
c.install.step = 0;
check('niet op de laatste stap', !c.opLaatsteStap());
c.install.step = c.wizardSteps().length - 1;
check('wel op de laatste stap', c.opLaatsteStap());
check('laatste stap is controleren',
c.wizardSteps()[c.install.step].id === 'controleren');
// ── Alles op één pagina ─────────────────────────────────────────────────────
c.onePage = true;
check('wizard uit bij één pagina', !c.wizardActief());
// ── Groepskoppen ────────────────────────────────────────────────────────────
c.onePage = false;
c.install.fields = [veld('a','basis'), veld('b','basis')];
check('één groep: geen kop', c.stepGroups('basis')[0].showHeader === false);
c.install.fields.push(veld('c','basis',{group:'Tweede'}));
check('twee groepen: wel koppen', c.stepGroups('basis').every(g => g.showHeader));
// ── Leesbare namen ──────────────────────────────────────────────────────────
// Het auditlog bewaart ruwe id's; die horen niet in beeld te staan.
c.T = {audit_cat_store: 'App Store', audit_act_reconfigure: 'Opnieuw ingesteld'};
check('categorie wordt vertaald', c.auditNaam('cat', 'store') === 'App Store');
check('actie wordt vertaald', c.auditNaam('act', 'reconfigure') === 'Opnieuw ingesteld');
check('onbekende waarde blijft leesbaar zoals hij is',
c.auditNaam('cat', 'eigenmodule') === 'eigenmodule');
check('lege waarde geeft niets', c.auditNaam('act', '') === '');
c.modules = [{id: 'mijnmodule', name: 'Mijn Module'}];
c.page = 'mod:mijnmodule';
check('modulepagina toont de naam, niet mod:id', c.paginaTitel === 'Mijn Module');
c.page = 'mod:onbekend';
check('onbekende module valt terug op de id zonder voorvoegsel',
c.paginaTitel === 'onbekend');
c.T = {}; c.page = 'stacks';
// ── Menu per stack op de telefoon ───────────────────────────────────────────
const acties = c.stackMenuItems({name: 's', editable: false, connected: true});
check('het menu heeft alle acties', acties.length === 9);
check('elke actie heeft een naam', acties.every(a => a.label && a.label.length));
check('instellingen uit als de stack niet bewerkbaar is',
acties.find(a => a.id === 'settings').uit === true);
check('koppelen uit als hij al gekoppeld is',
acties.find(a => a.id === 'connect').uit === true);
check('verwijderen is als gevaarlijk gemarkeerd',
acties.find(a => a.id === 'remove').gevaar === true);
// ── Vanuit de store naar een geïnstalleerde stack ───────────────────────────
c.stackQuery = 'iets anders'; c.stackCat = 'media'; c.page = 'app_store';
c.goto = (p) => { c.page = p; };
c.gaNaarStack('mijn-sonarr');
check('doorklikken zet de zoekterm', c.stackQuery === 'mijn-sonarr');
check('doorklikken wist het categoriefilter', c.stackCat === '');
check('doorklikken opent de stackspagina', c.page === 'stacks');
// ── Filtermenu van de app store ─────────────────────────────────────────────
// De labelrij was afgekapt op veertien, waardoor je de rest niet kon kiezen. Het
// menu zoekt nu in de volledige lijst.
c.storeStacks = { r: [
{dir:'sonarr', name:'Sonarr', categories:['media'], tags:['media','series']},
{dir:'radarr', name:'Radarr', categories:['media'], tags:['media','films']},
{dir:'loki', name:'Loki', categories:['monitoring'], tags:['logs']},
{dir:'zeldzaam',name:'Zeldzaam',categories:['overig'], tags:['heelzeldzaam']},
]};
c.categories = [
{id:'media', name:'Media', color:'#8b5cf6', icon:'mdi-play'},
{id:'monitoring', name:'Monitoring', color:'#f97316', icon:'mdi-chart-line'},
{id:'overig', name:'Overig', color:'#94a3b8', icon:'mdi-shape'},
];
c.storeQuery = ''; c.storeCat = ''; c.storeTag = ''; c.filterTagQuery = '';
check('geen filter: alles telt mee', c.storeResultaat === 4);
check('geen actieve filters', c.filterCount === 0);
check('labels hebben een aantal',
c.storeTags[0].tag === 'media' && c.storeTags[0].count === 2);
check('alle labels blijven beschikbaar', c.storeTags.length === 5);
c.storeCat = 'media';
check('categorie filtert', c.storeResultaat === 2);
check('teller telt de categorie', c.filterCount === 1);
c.storeTag = 'films';
check('categorie en label samen', c.storeResultaat === 1);
check('teller telt allebei', c.filterCount === 2);
c.wisFilters();
check('wissen zet alles terug', c.storeResultaat === 4 && c.filterCount === 0);
c.filterTagQuery = 'zeld';
check('zoeken vindt een zeldzaam label',
c.zichtbareTags.length === 1 && c.zichtbareTags[0].tag === 'heelzeldzaam');
check('bij zoeken niets verborgen', c.verborgenTags === 0);
c.filterTagQuery = 'bestaatniet';
check('geen treffers geeft een lege lijst', c.zichtbareTags.length === 0);
// Een gekozen label mag niet uit beeld vallen omdat het zeldzaam is.
c.filterTagQuery = '';
c.storeTag = 'heelzeldzaam';
check('gekozen label blijft zichtbaar',
c.zichtbareTags.some(t => t.tag === 'heelzeldzaam'));
// ── Afhankelijkheden ────────────────────────────────────────────────────────
// De melding hoort alleen te verschijnen voor wat er níét draait, en te
// verdwijnen zodra het er wel is.
c.install = {format:'boilerplate', loading:false, fields:[], values:{}, step:0,
depends_on: [
{app:'mosquitto', reason:'MQTT-broker', installed:false},
{app:'loki', reason:'logopslag', installed:true, stacks:['loki']},
]};
check('alleen het ontbrekende wordt gemeld', c.ontbrekendeAfhankelijkheden().length === 1);
check('en dat is mosquitto', c.ontbrekendeAfhankelijkheden()[0].app === 'mosquitto');
c.install.depends_on[0].installed = true;
check('niets meer te melden zodra alles draait',
c.ontbrekendeAfhankelijkheden().length === 0);
c.install.depends_on = [];
check('een app zonder afhankelijkheden meldt niets',
c.ontbrekendeAfhankelijkheden().length === 0);
// ── Sleutelformaten ─────────────────────────────────────────────────────────
// Een willekeurige string is niet genoeg: Laravel wil `base64:` ervoor, Homarr
// en LibreChat willen hexadecimaal van een vaste lengte. In het verkeerde
// formaat start de container niet, met een foutmelding die nergens naar de
// sleutel wijst.
const standaard = c.veldSecret({});
check('standaard is url-veilig', /^[A-Za-z0-9_-]+$/.test(standaard));
check('standaard is 32 tekens', standaard.length === 32);
const hex = c.veldSecret({secret_format: 'hex', secret_bytes: 32});
check('hex is 64 tekens', hex.length === 64);
check('hex bevat alleen hexcijfers', /^[0-9a-f]{64}$/.test(hex));
check('hex van 16 bytes is 32 tekens',
c.veldSecret({secret_format: 'hex', secret_bytes: 16}).length === 32);
const lar = c.veldSecret({secret_format: 'laravel', secret_bytes: 32});
check('laravel begint met base64:', lar.startsWith('base64:'));
check('laravel is standaard-base64 van 32 bytes',
Buffer.from(lar.slice(7), 'base64').length === 32);
check('twee sleutels zijn niet gelijk', c.veldSecret({}) !== c.veldSecret({}));
// ── Stap Onderdelen ─────────────────────────────────────────────────────────
// Een stack als de ARR-stack heeft vijftig schakelbare onderdelen. Die horen in
// één eigen stap, gegroepeerd per kopje — en nergens anders nóg een keer.
function schakelaar(naam, sectie, aan = false) {
return veld('enable_' + naam, 'onderdelen', {
type: 'bool', is_group_toggle: true, group_toggle: 'enable_' + naam,
group: naam, section: sectie, default: aan});
}
c.install = {format:'boilerplate', loading:false, values:{}, step:0, ips:{}, services:[],
fields:[
veld('service_name','basis'),
schakelaar('sonarr','Bibliotheken'), schakelaar('radarr','Bibliotheken'),
schakelaar('prowlarr','Indexers'),
veld('port_sonarr','instellingen',{type:'int', group:'Sonarr',
group_toggle:'enable_sonarr', advanced:true}),
veld('port_radarr','instellingen',{type:'int', group:'Radarr',
group_toggle:'enable_radarr', advanced:true}),
]};
c.showAdvanced = true;
s = c.wizardSteps().map(x => x.id);
check('onderdelen is de eerste stap', s[0] === 'onderdelen');
check('drie schakelaars in de stap', c.stepFields('onderdelen').length === 3);
const secties = c.stepSections('onderdelen');
check('twee kopjes', secties.length === 2);
check('kopjes kloppen',
secties.map(x => x.name).join() === 'Bibliotheken,Indexers');
check('nog niets gekozen', c.gekozenOnderdelen() === 0);
c.zetSectie(secties[0], true);
check('sectie in één keer aan', c.gekozenOnderdelen() === 2);
c.zetSectie(secties[0], false);
check('en weer uit', c.gekozenOnderdelen() === 0);
// De schakelaar hoorde nergens anders op te duiken. Stond hij ook in de kop van
// zijn eigen groep, dan zag je hem twee keer in twee verschillende stappen.
c.install.values.enable_sonarr = true;
const groepen = c.stepGroups('instellingen');
check('alleen ingeschakelde onderdelen krijgen een kaart', groepen.length === 1);
check('kaart is van Sonarr', groepen[0].name === 'Sonarr');
check('geen tweede schakelaar in de groepskop', groepen[0].toggle === null);
// ── Eigen IP per container ──────────────────────────────────────────────────
check('zonder voorvertoning geen containers', c.ipContainers().length === 0);
c.install.services = ['arr-sonarr', 'arr-radarr'];
check('containers komen uit de voorvertoning', c.ipContainers().length === 2);
c.zetContainerIp('arr-sonarr', true);
check('container aangezet', 'arr-sonarr' in c.install.ips);
check('andere container blijft op de host', !('arr-radarr' in c.install.ips));
c.install.ips['arr-sonarr'] = '192.168.1.240';
c.zetContainerIp('arr-sonarr', false);
check('en weer uitgezet', !('arr-sonarr' in c.install.ips));
console.log(fouten === 0 ? '\nAlle menucontroles geslaagd' : `\n${fouten} FOUTEN`);
process.exit(fouten ? 1 : 0);