Erweitere VoTiAn NG um Fachfunktionen und verbessere die Listenoberfläche
Ergänze Auftragsbearbeitung, Disposition, Stammdaten, Kunden- und Transporteurportale, Lagerverwaltung, Abrechnung, Exporte und Statistik. Füge Kontoeinstellungen, Mitteilungen, Automatikdienste und Betriebsstatus sowie die zugehörigen Tests und PHP-Abgleichsdokumentation hinzu. Vergrößere die persönlichen Auftragslisten auf einheitliche Höhen und richte Statusanzeigen sowie die Ranking-Vorschau-Steuerelemente bündig aus. Validierung: 44 Frontendtests und Produktionsbuild erfolgreich. Backend funktional unverändert gegenüber dem vollständigen Lauf mit 1.624 erfolgreichen und drei übersprungenen Tests. Layout im Browser und lokale Bereitstellung geprüft.
This commit is contained in:
1 parent
363bc27c26
commit
781a2b82dc
1165 files changed
+74986
-1028
No files matched your search
@@ -0,0 +1,16 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {contactEmails,isContactEmail} from '../src/contactEmails.mjs';
|
||||
|
||||
test('each recipient gets a separate vCard mail value',()=>{
|
||||
const raw=' eins@example.test, zwei+tag@example.org, dritt@[127.0.0.1] ';
|
||||
assert.deepEqual(contactEmails(raw),['eins@example.test','zwei+tag@example.org','dritt@[127.0.0.1]']);
|
||||
assert.equal(raw,' eins@example.test, zwei+tag@example.org, dritt@[127.0.0.1] ');
|
||||
});
|
||||
test('empty mail values add no empty vCard property',()=>{
|
||||
for(const raw of [null,undefined,'',' ', ', ,'])assert.deepEqual(contactEmails(raw),[]);
|
||||
});
|
||||
test('mail aliases include generic fields and exclude ordinary notes',()=>{
|
||||
for(const type of ['mail','email','mail_business','mail_private','email_business','email_private'])assert.equal(isContactEmail(type),true);
|
||||
for(const type of ['text','phone_business','mail_note'])assert.equal(isContactEmail(type),false);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import {test} from 'node:test';import assert from 'node:assert/strict';import {contactLinks} from '../src/contactLinks.mjs';
|
||||
test('mail aliases produce one safe link per recipient and keep invalid old text visible',()=>{
|
||||
assert.deepEqual(contactLinks('email_private','a+tag@example.test, b@example.org'),[{label:'a+tag@example.test',href:'mailto:a%2Btag%40example.test'},{label:'b@example.org',href:'mailto:b%40example.org'}]);
|
||||
assert.deepEqual(contactLinks('mail_business','Altwert'),[{label:'Altwert',href:null}]);
|
||||
assert.equal(contactLinks('email','a@example.test\r\nBcc:x@example.org')[0].href,null);
|
||||
});
|
||||
test('website fields open only absolute HTTP URLs without user information',()=>{
|
||||
assert.equal(contactLinks('url_business','https://example.test/path?q=x')[0].href,'https://example.test/path?q=x');
|
||||
for(const value of ['javascript:alert(1)','data:text/html,test','https://user:secret@example.test','https://example.test\r\n/','www.example.test'])assert.equal(contactLinks('url_business',value)[0].href,null);
|
||||
});
|
||||
test('phone aliases preserve dialable legacy separators without changing exponent-like numbers',()=>{
|
||||
assert.equal(contactLinks('fax','+49 (30) 123/45')[0].href,'tel:+493012345');
|
||||
for(const value of ['1e3','Infinity','0x20','kein Telefon'])assert.equal(contactLinks('phone_business',value)[0].href,null);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';import {customerHistorySeries} from '../src/customerHistory.mjs';
|
||||
test('empty and all-zero periods have finite chart coordinates',()=>{for(const rows of [[],[{month:'2026-01',amount:0,orders:0}]]){const s=customerHistorySeries(rows,'amount');assert.equal(s.zero,170);assert.ok(s.points.every(p=>Number.isFinite(p.x)&&Number.isFinite(p.y)));}});
|
||||
test('credits plot below zero and amounts are not clamped',()=>{const s=customerHistorySeries([{month:'2025-12',amount:-50,orders:1},{month:'2026-01',amount:100,orders:2}],'amount');assert.deepEqual(s.points.map(p=>p.value),[-50,100]);assert.ok(s.points[0].y>s.zero&&s.points[1].y<s.zero);assert.deepEqual(s.points.map(p=>p.x),[40,700]);});
|
||||
test('order counts use their own scale and invalid values are rejected',()=>{const s=customerHistorySeries([{month:'2026-01',amount:10000,orders:3}],'orders');assert.equal(s.max,3);assert.equal(s.points[0].y,25);assert.throws(()=>customerHistorySeries([{month:'x',amount:NaN,orders:1}],'amount'),/Monatswert/);});
|
||||
@@ -0,0 +1,14 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';
|
||||
import {headquartersDropAllowed as allowed} from '../src/dispositionDropTime.mjs';
|
||||
const base={day:'2026-10-19',units:6,serverNow:'2026-10-19T10:00:00',slot:60};
|
||||
test('HQ drops preserve exact time boundary and advance while the board remains open',()=>{
|
||||
assert.equal(allowed(base),true);assert.equal(allowed({...base,slot:59}),false);assert.equal(allowed({...base,elapsedMs:1}),false);assert.equal(allowed({...base,slot:61,elapsedMs:600000}),true);assert.equal(allowed({...base,slot:61,elapsedMs:600001}),false);
|
||||
});
|
||||
test('database date crosses midnight independently of browser time zone',()=>{
|
||||
assert.equal(allowed({...base,day:'2026-10-20',slot:0}),true);assert.equal(allowed({...base,day:'2026-10-18',slot:143}),false);
|
||||
assert.equal(allowed({...base,serverNow:'2026-10-19T23:59:59',day:'2026-10-20',slot:0,elapsedMs:1000}),true);
|
||||
assert.equal(allowed({...base,serverNow:'2026-10-19T23:59:59',day:'2026-10-20',slot:0,elapsedMs:1001}),false);
|
||||
});
|
||||
test('configured override permits elapsed time; missing server time does not invent a current clock',()=>{
|
||||
assert.equal(allowed({...base,slot:0,allowPastDrops:true}),true);assert.equal(allowed({...base,serverNow:undefined}),false);assert.equal(allowed({...base,serverNow:'invalid'}),false);assert.equal(allowed({...base,units:0}),false);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';
|
||||
import {timelineWindow,visibleTimelineSegment,timelineGrabOffset,timelineDropSlot,timelineHourTicks} from '../src/dispositionTimelineRange.mjs';
|
||||
const range={startHour:8,endHourExclusive:19,configured:true,warning:''},display=timelineWindow(range);
|
||||
test('configured hours and full-day switch share the unchanged24hour domain',()=>{assert.deepEqual(display,{start:8,end:19,hours:11});assert.deepEqual(timelineWindow(range,true),{start:0,end:24,hours:24});assert.deepEqual(timelineWindow({startHour:20,endHourExclusive:8}),timelineWindow());});
|
||||
test('segments crossing the visible boundaries are clipped without changing their original bounds',()=>{const job={start:6*6,end:10*6};const clip=visibleTimelineSegment(job,display,6);assert.equal(clip.start,48);assert.equal(clip.end,60);assert.equal(clip.left,0);assert.equal(clip.width,100*2/11);assert.deepEqual(job,{start:36,end:60});assert.equal(visibleTimelineSegment({start:0,end:48},display,6),null);assert.equal(visibleTimelineSegment({start:114,end:120},display,6),null);});
|
||||
test('grab offset includes the hidden beginning of a clipped job',()=>{const job={start:36,end:60};assert.equal(timelineGrabOffset(job,display,6,0),12);assert.equal(timelineGrabOffset(job,display,6,.5),18);assert.equal(timelineDropSlot(display,6,3/11,18),48);});
|
||||
test('drop time follows displayed hours and remains bounded by actual day',()=>{assert.equal(timelineDropSlot(display,6,1/11,0),54);assert.equal(timelineDropSlot(display,6,1,0),114);assert.equal(timelineDropSlot(display,6,0,100),0);assert.equal(timelineDropSlot(timelineWindow(),6,1,0),143);});
|
||||
test('hour labels include both display boundaries without a distorted last interval',()=>{assert.deepEqual(timelineHourTicks(display),[8,10,12,14,16,18,19]);assert.deepEqual(timelineHourTicks({start:23,end:24,hours:1}),[23,24]);});
|
||||
+314
@@ -0,0 +1,314 @@
|
||||
[
|
||||
{
|
||||
"country": "AD",
|
||||
"bank": "77777777",
|
||||
"account": "ZZZZZZZZZZZZ",
|
||||
"iban": "AD2477777777ZZZZZZZZZZZZ"
|
||||
},
|
||||
{
|
||||
"country": "AL",
|
||||
"bank": "77777777",
|
||||
"account": "ZZZZZZZZZZZZZZZZ",
|
||||
"iban": "AL6577777777ZZZZZZZZZZZZZZZZ"
|
||||
},
|
||||
{
|
||||
"country": "AT",
|
||||
"bank": "77777",
|
||||
"account": "77777777777",
|
||||
"iban": "AT957777777777777777"
|
||||
},
|
||||
{
|
||||
"country": "BA",
|
||||
"bank": "777777",
|
||||
"account": "7777777777",
|
||||
"iban": "BA467777777777777777"
|
||||
},
|
||||
{
|
||||
"country": "BE",
|
||||
"bank": "777",
|
||||
"account": "777777777",
|
||||
"iban": "BE92777777777777"
|
||||
},
|
||||
{
|
||||
"country": "BG",
|
||||
"bank": "AAAA7777",
|
||||
"account": "77ZZZZZZZZ",
|
||||
"iban": "BG35AAAA777777ZZZZZZZZ"
|
||||
},
|
||||
{
|
||||
"country": "CH",
|
||||
"bank": "77777",
|
||||
"account": "ZZZZZZZZZZZZ",
|
||||
"iban": "CH7877777ZZZZZZZZZZZZ"
|
||||
},
|
||||
{
|
||||
"country": "CY",
|
||||
"bank": "77777777",
|
||||
"account": "ZZZZZZZZZZZZZZZZ",
|
||||
"iban": "CY0877777777ZZZZZZZZZZZZZZZZ"
|
||||
},
|
||||
{
|
||||
"country": "CZ",
|
||||
"bank": "7777",
|
||||
"account": "7777777777777777",
|
||||
"iban": "CZ2277777777777777777777"
|
||||
},
|
||||
{
|
||||
"country": "DE",
|
||||
"bank": "77777777",
|
||||
"account": "7777777777",
|
||||
"iban": "DE31777777777777777777"
|
||||
},
|
||||
{
|
||||
"country": "DK",
|
||||
"bank": "7777",
|
||||
"account": "7777777777",
|
||||
"iban": "DK9077777777777777"
|
||||
},
|
||||
{
|
||||
"country": "EE",
|
||||
"bank": "77",
|
||||
"account": "77777777777777",
|
||||
"iban": "EE077777777777777777"
|
||||
},
|
||||
{
|
||||
"country": "ES",
|
||||
"bank": "77777777",
|
||||
"account": "777777777777",
|
||||
"iban": "ES2577777777777777777777"
|
||||
},
|
||||
{
|
||||
"country": "FI",
|
||||
"bank": "777777",
|
||||
"account": "77777777",
|
||||
"iban": "FI7877777777777777"
|
||||
},
|
||||
{
|
||||
"country": "FO",
|
||||
"bank": "7777",
|
||||
"account": "7777777777",
|
||||
"iban": "FO6077777777777777"
|
||||
},
|
||||
{
|
||||
"country": "FR",
|
||||
"bank": "7777777777",
|
||||
"account": "ZZZZZZZZZZZ77",
|
||||
"iban": "FR247777777777ZZZZZZZZZZZ77"
|
||||
},
|
||||
{
|
||||
"country": "GB",
|
||||
"bank": "AAAA777777",
|
||||
"account": "77777777",
|
||||
"iban": "GB87AAAA77777777777777"
|
||||
},
|
||||
{
|
||||
"country": "GI",
|
||||
"bank": "AAAA",
|
||||
"account": "ZZZZZZZZZZZZZZZ",
|
||||
"iban": "GI85AAAAZZZZZZZZZZZZZZZ"
|
||||
},
|
||||
{
|
||||
"country": "GL",
|
||||
"bank": "7777",
|
||||
"account": "7777777777",
|
||||
"iban": "GL6077777777777777"
|
||||
},
|
||||
{
|
||||
"country": "GR",
|
||||
"bank": "7777777",
|
||||
"account": "ZZZZZZZZZZZZZZZZ",
|
||||
"iban": "GR937777777ZZZZZZZZZZZZZZZZ"
|
||||
},
|
||||
{
|
||||
"country": "HR",
|
||||
"bank": "7777777",
|
||||
"account": "7777777777",
|
||||
"iban": "HR5777777777777777777"
|
||||
},
|
||||
{
|
||||
"country": "HU",
|
||||
"bank": "7777777",
|
||||
"account": "77777777777777777",
|
||||
"iban": "HU47777777777777777777777777"
|
||||
},
|
||||
{
|
||||
"country": "IE",
|
||||
"bank": "AAAA777777",
|
||||
"account": "77777777",
|
||||
"iban": "IE60AAAA77777777777777"
|
||||
},
|
||||
{
|
||||
"country": "IL",
|
||||
"bank": "777777",
|
||||
"account": "7777777777777",
|
||||
"iban": "IL227777777777777777777"
|
||||
},
|
||||
{
|
||||
"country": "IS",
|
||||
"bank": "7777",
|
||||
"account": "777777777777777777",
|
||||
"iban": "IS277777777777777777777777"
|
||||
},
|
||||
{
|
||||
"country": "IT",
|
||||
"bank": "A7777777777",
|
||||
"account": "ZZZZZZZZZZZZ",
|
||||
"iban": "IT21A7777777777ZZZZZZZZZZZZ"
|
||||
},
|
||||
{
|
||||
"country": "LI",
|
||||
"bank": "77777",
|
||||
"account": "ZZZZZZZZZZZZ",
|
||||
"iban": "LI9177777ZZZZZZZZZZZZ"
|
||||
},
|
||||
{
|
||||
"country": "LT",
|
||||
"bank": "77777",
|
||||
"account": "77777777777",
|
||||
"iban": "LT937777777777777777"
|
||||
},
|
||||
{
|
||||
"country": "LU",
|
||||
"bank": "777",
|
||||
"account": "ZZZZZZZZZZZZZ",
|
||||
"iban": "LU36777ZZZZZZZZZZZZZ"
|
||||
},
|
||||
{
|
||||
"country": "LV",
|
||||
"bank": "AAAA",
|
||||
"account": "ZZZZZZZZZZZZZ",
|
||||
"iban": "LV41AAAAZZZZZZZZZZZZZ"
|
||||
},
|
||||
{
|
||||
"country": "MC",
|
||||
"bank": "7777777777",
|
||||
"account": "ZZZZZZZZZZZ77",
|
||||
"iban": "MC067777777777ZZZZZZZZZZZ77"
|
||||
},
|
||||
{
|
||||
"country": "ME",
|
||||
"bank": "777",
|
||||
"account": "777777777777777",
|
||||
"iban": "ME47777777777777777777"
|
||||
},
|
||||
{
|
||||
"country": "MK",
|
||||
"bank": "777",
|
||||
"account": "ZZZZZZZZZZ77",
|
||||
"iban": "MK77777ZZZZZZZZZZ77"
|
||||
},
|
||||
{
|
||||
"country": "MT",
|
||||
"bank": "AAAA77777",
|
||||
"account": "ZZZZZZZZZZZZZZZZZZ",
|
||||
"iban": "MT92AAAA77777ZZZZZZZZZZZZZZZZZZ"
|
||||
},
|
||||
{
|
||||
"country": "MU",
|
||||
"bank": "AAAA7777",
|
||||
"account": "777777777777777AAA",
|
||||
"iban": "MU17AAAA7777777777777777777AAA"
|
||||
},
|
||||
{
|
||||
"country": "NL",
|
||||
"bank": "AAAA",
|
||||
"account": "7777777777",
|
||||
"iban": "NL16AAAA7777777777"
|
||||
},
|
||||
{
|
||||
"country": "NO",
|
||||
"bank": "7777",
|
||||
"account": "7777777",
|
||||
"iban": "NO2677777777777"
|
||||
},
|
||||
{
|
||||
"country": "PL",
|
||||
"bank": "77777777",
|
||||
"account": "7777777777777777",
|
||||
"iban": "PL02777777777777777777777777"
|
||||
},
|
||||
{
|
||||
"country": "PT",
|
||||
"bank": "77777777",
|
||||
"account": "7777777777777",
|
||||
"iban": "PT94777777777777777777777"
|
||||
},
|
||||
{
|
||||
"country": "RO",
|
||||
"bank": "AAAA",
|
||||
"account": "ZZZZZZZZZZZZZZZZ",
|
||||
"iban": "RO72AAAAZZZZZZZZZZZZZZZZ"
|
||||
},
|
||||
{
|
||||
"country": "RS",
|
||||
"bank": "777",
|
||||
"account": "777777777777777",
|
||||
"iban": "RS57777777777777777777"
|
||||
},
|
||||
{
|
||||
"country": "SA",
|
||||
"bank": "77",
|
||||
"account": "ZZZZZZZZZZZZZZZZZZ",
|
||||
"iban": "SA9877ZZZZZZZZZZZZZZZZZZ"
|
||||
},
|
||||
{
|
||||
"country": "SE",
|
||||
"bank": "777",
|
||||
"account": "77777777777777777",
|
||||
"iban": "SE3877777777777777777777"
|
||||
},
|
||||
{
|
||||
"country": "SI",
|
||||
"bank": "77777",
|
||||
"account": "7777777777",
|
||||
"iban": "SI32777777777777777"
|
||||
},
|
||||
{
|
||||
"country": "SK",
|
||||
"bank": "7777",
|
||||
"account": "7777777777777777",
|
||||
"iban": "SK2077777777777777777777"
|
||||
},
|
||||
{
|
||||
"country": "SM",
|
||||
"bank": "A7777777777",
|
||||
"account": "ZZZZZZZZZZZZ",
|
||||
"iban": "SM49A7777777777ZZZZZZZZZZZZ"
|
||||
},
|
||||
{
|
||||
"country": "TN",
|
||||
"bank": "77777",
|
||||
"account": "777777777777777",
|
||||
"iban": "TN0277777777777777777777"
|
||||
},
|
||||
{
|
||||
"country": "TR",
|
||||
"bank": "77777",
|
||||
"account": "ZZZZZZZZZZZZZZZZZ",
|
||||
"iban": "TR8677777ZZZZZZZZZZZZZZZZZ"
|
||||
},
|
||||
{
|
||||
"country": "DE",
|
||||
"bank": "37040044",
|
||||
"account": "532013000",
|
||||
"iban": "DE89370400440532013000"
|
||||
},
|
||||
{
|
||||
"country": "AT",
|
||||
"bank": "19043",
|
||||
"account": "234573201",
|
||||
"iban": "AT611904300234573201"
|
||||
},
|
||||
{
|
||||
"country": "GB",
|
||||
"bank": "WEST123456",
|
||||
"account": "98765432",
|
||||
"iban": "GB82WEST12345698765432"
|
||||
},
|
||||
{
|
||||
"country": "FR",
|
||||
"bank": "2004101005",
|
||||
"account": "0500013M02606",
|
||||
"iban": "FR1420041010050500013M02606"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';
|
||||
import {mapCourierState,mapContextRows} from '../src/fleetContext.mjs';
|
||||
const c={id:1,online:true,occupied:false,postalCode:'10115'};
|
||||
test('rejection is pink while occupied, offline and invalid postal positions retain precedence',()=>{
|
||||
assert.equal(mapCourierState(c,true).color,'#be185d');assert.equal(mapCourierState(c,false).color,'#15803d');
|
||||
assert.equal(mapCourierState({...c,occupied:true},true).color,'#be123c');
|
||||
assert.equal(mapCourierState(c,true,{occupied:true}).color,'#be123c');
|
||||
assert.equal(mapCourierState({...c,online:false},true).color,'#64748b');
|
||||
assert.equal(mapCourierState({...c,postalCode:'n/a'},true).color,'#b45309');
|
||||
});
|
||||
test('map intersects current positions with eligible stable identities in ranking order',()=>{
|
||||
const rows=[c,{...c,id:2},{...c,id:3}],context={assignedCourier:3,items:[{courierId:2,rank:1,blockers:[]},{courierId:3,rank:2,blockers:['Gesperrt']},{courierId:1,rank:4,blockers:[]},{courierId:99,rank:5,blockers:[]}]};
|
||||
assert.deepEqual(mapContextRows(rows,context,'CANDIDATES').map(c=>c.id),[2,1]);
|
||||
assert.deepEqual(mapContextRows(rows,context,'ASSIGNED').map(c=>c.id),[3]);
|
||||
assert.deepEqual(mapContextRows(rows,null,'CANDIDATES'),[]);assert.equal(mapContextRows(rows,null,'ALL'),rows);
|
||||
assert.deepEqual(rows.map(c=>c.id),[1,2,3]);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import {test} from 'node:test';import assert from 'node:assert/strict';import {translate} from '../src/languageDictionary.mjs';
|
||||
const dictionary={common:{Name:'Name',Speichern:'Save'},sources:{'admin/menu.php':{Aufträge:'Jobs',Leer:''},'admin/other.php':{Aufträge:'Orders',Leer:'Other'}}};
|
||||
test('page meanings take priority; missing and explicitly empty translations preserve German',()=>{
|
||||
assert.equal(translate(dictionary,'Aufträge',['admin/menu.php']),'Jobs');assert.equal(translate(dictionary,'Aufträge',['admin/other.php']),'Orders');assert.equal(translate(dictionary,'Aufträge'),'Aufträge');assert.equal(translate(dictionary,'Leer',['admin/menu.php']),'Leer');assert.equal(translate(dictionary,'Speichern',['missing']),'Save');assert.equal(translate(null,'Speichern'),'Speichern');
|
||||
});
|
||||
test('inline whitespace and object-looking labels remain literal',()=>{
|
||||
assert.equal(translate(dictionary,' Speichern\u00a0'),' Save\u00a0');assert.equal(translate(dictionary,'constructor'),'constructor');assert.equal(translate(dictionary,'__proto__'),'__proto__');assert.equal(translate(dictionary,'Kundenname Speichern'),'Kundenname Speichern');assert.equal(translate(dictionary,'Save'),'Save');
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';import {readFile} from 'node:fs/promises';
|
||||
import {createIban,ibanError,bicError,compactIban,formatIban,changedBankError} from '../src/masterBank.mjs';
|
||||
const examples=JSON.parse(await readFile(new URL('./fixtures/master-bank-php.json',import.meta.url),'utf8'));
|
||||
test('IBAN calculation matches 52 independently executed current PHP-script cases across all 48 country formats',()=>{
|
||||
assert.equal(examples.length,52);assert.equal(new Set(examples.map(e=>e.country)).size,48);
|
||||
for(const e of examples){assert.equal(createIban(e.country,e.bank,e.account),e.iban,e.country);assert.equal(ibanError(e.iban),'',e.country);}
|
||||
});
|
||||
test('known IBAN keeps leading zeroes and grouping without numeric precision loss',()=>{
|
||||
assert.equal(createIban('DE','37040044','532013000'),'DE89370400440532013000');assert.equal(formatIban('DE89370400440532013000'),'DE89 3704 0044 0532 0130 00');
|
||||
assert.equal(ibanError('IBAN DE89 3704 0044 0532 0130 00'),'');assert.equal(ibanError('de89\t37040044 0532013000'),'');assert.equal(compactIban(null),'');
|
||||
});
|
||||
test('invalid bank/account lengths, alphabetic positions and unsupported countries never produce proposals',()=>{
|
||||
for(const args of [['DE','123','1'],['DE','3704004A','1'],['DE','37040044',''],['DE','37040044','12345678901'],['DE','37040044','a'],['GB','1234123456','12345678'],['XX','12345678','1']])assert.throws(()=>createIban(...args));
|
||||
});
|
||||
test('checksum and structural faults are distinguished from blank optional bank fields',()=>{
|
||||
assert.equal(ibanError(''),'');assert.equal(ibanError(null),'');
|
||||
for(const invalid of ['DE88370400440532013000','DE8937040044053201300','DEAB370400440532013000','DE8937040_440532013000','XX89370400440532013000'])assert.notEqual(ibanError(invalid),'',invalid);
|
||||
});
|
||||
test('BIC supports eight or eleven letters/digits with alphabetic bank/country codes',()=>{
|
||||
for(const ok of ['',null,'COBADEFF','cobadeffxxx','DABAIE2DXXX'])assert.equal(bicError(ok),'');
|
||||
for(const bad of ['COBADE','COB1DEFF','COBA1EFF','COBADEFFXX','COBA DEFF','COBADEFF!XX'])assert.notEqual(bicError(bad),'');
|
||||
});
|
||||
test('legacy bank fields remain editable without forcing unrelated corrections; changed invalid values block saving',()=>{
|
||||
const old={cmp_iban:'LEGACY',cmp_swift:'OLD',cmp_comp:'Vorher'};assert.equal(changedBankError({...old,cmp_comp:'Nachher'},old),'');
|
||||
assert.notEqual(changedBankError({...old,cmp_iban:'BAD'},old),'');assert.notEqual(changedBankError({...old,cmp_swift:'BAD'},old),'');
|
||||
assert.equal(changedBankError({...old,cmp_iban:'',cmp_swift:''},old),'');assert.notEqual(changedBankError(old,{}),'');
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {statisticsAmountColumns,statisticsAmountOptions} from '../src/statisticsAmountColumns.mjs';
|
||||
import {statisticsMetric} from '../src/statisticsChartData.mjs';
|
||||
|
||||
test('each PHP amount choice keeps only its amount column',()=>{
|
||||
for(const key of Object.keys(statisticsAmountOptions).filter(k=>k!=='ALL'))assert.deepEqual(statisticsAmountColumns({displayMetric:key}).map(c=>c.key),[key]);
|
||||
assert.equal(statisticsAmountColumns({displayMetric:'ratio'})[0].kind,'percent');
|
||||
assert.equal(statisticsAmountColumns({displayMetric:'kilometers'})[0].kind,'number');
|
||||
assert.equal(statisticsAmountColumns({displayMetric:'co2eKg'})[0].kind,'number');
|
||||
});
|
||||
test('all values and older results include EAU and BSG in detail rows',()=>{
|
||||
const keys=['revenue','courierRevenue','margin','ratio','serviceRevenue','fuelMarkup','courierService','kilometers','co2eKg'];
|
||||
for(const displayMetric of [undefined,'ALL','unknown'])assert.deepEqual(statisticsAmountColumns({displayMetric}).map(c=>c.key),keys);
|
||||
});
|
||||
test('separate fuel columns follow the selected price basis without duplicates',()=>{
|
||||
for(const [displayMetric,extra] of [['revenue','fuelMarkup'],['courierRevenue','courierFuelMarkup'],['margin','fuelMargin']])assert.deepEqual(statisticsAmountColumns({displayMetric,separateFuel:true}).map(c=>c.key),[displayMetric,extra]);
|
||||
const all=statisticsAmountColumns({displayMetric:'ALL',separateFuel:true}).map(c=>c.key);assert.equal(new Set(all).size,all.length);
|
||||
assert.deepEqual(statisticsAmountColumns({displayMetric:'kilometers',separateFuel:true}).map(c=>c.key),['kilometers']);
|
||||
});
|
||||
test('amount cells preserve unknown values and use aggregate BSG instead of averaging row ratios',()=>{
|
||||
const rows=[{revenue:100,courierRevenue:50},{revenue:900,courierRevenue:810}];
|
||||
assert.ok(Math.abs(statisticsMetric({revenue:1000,courierRevenue:860},'ratio')-14)<1e-10);
|
||||
assert.notEqual((statisticsMetric(rows[0],'ratio')+statisticsMetric(rows[1],'ratio'))/2,14);
|
||||
assert.equal(statisticsMetric({revenue:100,courierRevenue:null},'margin'),null);
|
||||
assert.equal(statisticsMetric({revenue:0,courierRevenue:0},'ratio'),null);
|
||||
assert.equal(statisticsMetric({co2eKg:0},'co2eKg'),0);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';
|
||||
import {statisticsMetric,statisticsChartData,statisticsChartProblem,normalizedChartData} from '../src/statisticsChartData.mjs';
|
||||
const result={splits:['CUSTOMER'],periods:[{interval:{from:'2028-01-01',to:'2028-12-31'},rows:[{period:'2028-01',split0:'A',orders:2,revenue:100,courierRevenue:50},{period:'2028-02',split0:'A',orders:1,revenue:300,courierRevenue:240},{period:'2028-02',split0:'B',orders:1,revenue:-20,courierRevenue:null}]},{interval:{from:'2027-01-01',to:'2027-12-31'},rows:[{period:'2027-01',split0:'B',orders:3,revenue:60,courierRevenue:30}]}]};
|
||||
test('comparison aligns exact groups across independent intervals and includes absent groups as zero',()=>{const data=statisticsChartData(result,'revenue','comparison',{CUSTOMER:'Kunde'});assert.deepEqual(data.series,[{name:'Kunde: A',values:[400,0]},{name:'Kunde: B',values:[-20,60]}]);assert.equal(data.categories.length,2);});
|
||||
test('percentage is recalculated from totals, never summed; unknown remuneration stays unknown',()=>{const data=statisticsChartData(result,'ratio','comparison',{});assert.ok(Math.abs(data.series[0].values[0]-27.5)<1e-9);assert.equal(data.series[1].values[0],null);assert.equal(statisticsMetric({revenue:0,courierRevenue:0},'ratio'),null);});
|
||||
test('single-period rows retain dates, groups and negative values',()=>{const data=statisticsChartData(result,'revenue','0',{CUSTOMER:'Kunde'});assert.deepEqual(data.series[0].values,[100,300,-20]);assert.equal(data.categories[0],'2028-01 · Kunde: A');assert.match(statisticsChartProblem(data,'PIE'),/nichtnegative/);assert.equal(statisticsChartProblem(data,'BAR'),'');});
|
||||
test('normalized values retain zero totals; missing and excessive datasets are explicit',()=>{const data={categories:['A','B'],series:[{name:'X',values:[3,0]},{name:'Y',values:[1,0]}]};assert.deepEqual(normalizedChartData(data).series.map(s=>s.values),[[75,0],[25,0]]);assert.match(statisticsChartProblem({...data,series:[{name:'X',values:[null,1]}]},'NORMALIZED'),/vollständige/);assert.match(statisticsChartProblem({...data,categories:Array(121).fill('A')},'BAR'),/eingrenzen/);});
|
||||
test('surcharge margin uses surcharge values and propagates unknown remuneration across periods',()=>{const data={splits:['CUSTOMER'],periods:[{interval:{from:'2028-01-01',to:'2028-12-31'},rows:[{period:'01',split0:'A',fuelMarkup:20,courierFuelMarkup:15,revenue:999},{period:'02',split0:'A',fuelMarkup:30,courierFuelMarkup:40}]},{interval:{from:'2027-01-01',to:'2027-12-31'},rows:[{period:'01',split0:'A',fuelMarkup:10,courierFuelMarkup:null}]}]};assert.deepEqual(statisticsChartData(data,'fuelMargin','comparison').series[0].values,[-5,null]);assert.equal(statisticsMetric({fuelMarkup:0,courierFuelMarkup:0},'fuelMargin'),0);});
|
||||
@@ -0,0 +1,23 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {courierDetailColumns,statisticsDetailValue} from '../src/statisticsCourierDetails.mjs';
|
||||
import {statisticsAmountColumns} from '../src/statisticsAmountColumns.mjs';
|
||||
|
||||
test('courier master fees are visible in all values and never enter aggregate amount columns',()=>{
|
||||
const result={courierDetails:true,displayMetric:'ALL',gross:true};
|
||||
assert.deepEqual(courierDetailColumns(result).map(([key])=>key),['courierCompany','systemFee','advertisingAllowance','courierDetailsNotice']);
|
||||
assert.ok(statisticsAmountColumns(result).every(c=>!['systemFee','advertisingAllowance'].includes(c.key)));
|
||||
assert.deepEqual(courierDetailColumns({...result,displayMetric:'revenue'}),[['courierCompany','Firma UN']]);
|
||||
assert.deepEqual(courierDetailColumns({...result,calculation:true}),[['courierCompany','Firma UN']]);
|
||||
});
|
||||
test('customer and pickup reports do not expose vehicle fees',()=>{
|
||||
assert.deepEqual(courierDetailColumns({}),[]);
|
||||
assert.deepEqual(courierDetailColumns({courierDetails:true,pickup:true}),[]);
|
||||
assert.deepEqual(courierDetailColumns({courierDetails:true,pickupTimes:true}),[]);
|
||||
});
|
||||
test('zero master values stay zero and unknown values stay unknown',()=>{
|
||||
assert.match(statisticsDetailValue({systemFee:0},'systemFee'),/^0,00\s€$/);
|
||||
assert.match(statisticsDetailValue({advertisingAllowance:12.5},'advertisingAllowance'),/^12,50\s€$/);
|
||||
assert.equal(statisticsDetailValue({systemFee:null},'systemFee'),'–');
|
||||
assert.equal(statisticsDetailValue({courierCompany:'Beispiel & Partner'},'courierCompany'),'Beispiel & Partner');
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';import vm from 'node:vm';import {readFileSync} from 'node:fs';import ts from 'typescript';
|
||||
const code=ts.transpileModule(readFileSync(new URL('../src/api.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText;
|
||||
const home={userId:11,username:'synthetic',headquartersId:7,originalHeadquartersId:7,roles:[]};
|
||||
const json=(data,status=200)=>new Response(JSON.stringify(data),{status,headers:{'Content-Type':'application/json'}});
|
||||
function deferred(){let resolve;const promise=new Promise(r=>resolve=r);return {promise,resolve};}
|
||||
function client(stored){const entries=new Map(stored?[['votian.workspace',JSON.stringify(stored)]]:[]),storage={getItem:k=>entries.get(k)||null,setItem:(k,v)=>entries.set(k,v),removeItem:k=>entries.delete(k)};const calls=[];let intercept;
|
||||
const context={exports:{},Headers,URLSearchParams,Response,URL,setTimeout,sessionStorage:storage,window:{sessionStorage:storage,localStorage:{removeItem(){}}},fetch:async(path,options)=>{calls.push({path,options});if(intercept){const v=intercept(path,options);if(v)return await v;}if(path==='/api/me'||path==='/api/login')return json(home);if(path==='/api/csrf')return json({token:'isolated',headerName:'X-CSRF-TOKEN'});if(path.startsWith('/api/account/headquarters/'))return json({...home,headquartersId:Number(path.split('/').pop())});if(path==='/api/logout')return new Response(null,{status:204});return json({ok:true});}};
|
||||
vm.runInNewContext(code,context,{filename:'api.js'});return {api:context.exports,calls,entries,intercept:fn=>intercept=fn};}
|
||||
const header=call=>call.options.headers.get('X-Workspace-Headquarters');
|
||||
test('restores only a preference bound to the same user and home; recovery endpoints stay unscoped',async()=>{const c=client({userId:11,homeId:7,target:8});assert.equal((await c.api.getSession()).headquartersId,8);assert(c.calls.every(x=>header(x)===null));await c.api.request('/customers');assert.equal(header(c.calls.at(-1)),'8');await c.api.request('/account/headquarters');assert.equal(header(c.calls.at(-1)),null);await c.api.logout();assert.equal(header(c.calls.find(x=>x.path==='/api/logout')),null);assert(!c.entries.has('votian.workspace'));const other=client({userId:99,homeId:7,target:8});assert.equal((await other.api.getSession()).headquartersId,7);assert(!other.calls.some(x=>x.path.endsWith('/headquarters/8')));});
|
||||
test('failed selection retains the previous scope; denied saved selection recovers to home',async()=>{const c=client();await c.api.getSession();await c.api.changeWorkspace(8);c.intercept(p=>p.endsWith('/headquarters/9')?json({message:'denied'},403):null);await assert.rejects(c.api.changeWorkspace(9),/denied/);await c.api.request('/customers');assert.equal(header(c.calls.at(-1)),'8');const restored=client({userId:11,homeId:7,target:8});restored.intercept(p=>p.endsWith('/headquarters/8')?json({message:'revoked'},403):null);const u=await restored.api.getSession();assert.equal(u.headquartersId,7);assert.match(u.workspaceNotice,/revoked/);assert(!restored.entries.has('votian.workspace'));});
|
||||
test('a response from an old headquarters cannot enter the next workspace',async()=>{const c=client();await c.api.getSession();await c.api.changeWorkspace(8);const read=deferred();c.intercept(p=>p==='/api/orders'?read.promise:null);const pending=c.api.request('/orders');const rejection=assert.rejects(pending,/beendeten Sitzung/);await c.api.changeWorkspace(7);read.resolve(json({old:true}));await rejection;assert.equal(header(c.calls.find(x=>x.path==='/api/orders')),'8');await c.api.request('/customers');assert.equal(header(c.calls.at(-1)),null);});
|
||||
test('switching waits for the full write response and blocks new writes during preflight',async()=>{const c=client();await c.api.getSession();const sent=deferred(),write=deferred();c.intercept(p=>{if(p==='/api/change'){sent.resolve();return write.promise;}});const saving=c.api.put('/change',{value:'synthetic'});await sent.promise;await assert.rejects(c.api.changeWorkspace(8),/Änderung läuft/);write.resolve(json({completed:true}));await saving;const selecting=deferred();c.intercept(p=>p.endsWith('/headquarters/8')?selecting.promise:null);const switching=c.api.changeWorkspace(8);await assert.rejects(c.api.put('/change',{value:'second'}),/Niederlassung wird gewechselt/);selecting.resolve(json({...home,headquartersId:8}));await switching;});
|
||||
test('two tabs keep separate contexts and caller headers cannot override the selection',async()=>{const a=client(),b=client();await a.api.getSession();await b.api.getSession();await a.api.changeWorkspace(8);await a.api.request('/customers',{headers:{'X-Workspace-Headquarters':'99','X-Order-Headquarters':'18'}});assert.equal(header(a.calls.at(-1)),'8');assert.equal(a.calls.at(-1).options.headers.get('X-Order-Headquarters'),'18');await b.api.request('/customers');assert.equal(header(b.calls.at(-1)),null);});
|
||||
Reference in new issue
Block a user