diff --git a/.superpowers/sdd/2026-08-20-plan-xer-p6-lezer/task-X6-wiring-report.md b/.superpowers/sdd/2026-08-20-plan-xer-p6-lezer/task-X6-wiring-report.md new file mode 100644 index 000000000..c0d30445c --- /dev/null +++ b/.superpowers/sdd/2026-08-20-plan-xer-p6-lezer/task-X6-wiring-report.md @@ -0,0 +1,130 @@ +# X6 — resources en toewijzingen + +## Afbakening + +Deze wijziging bedraadt uitsluitend de actuele X6-kern in de XER-leesroute. RSRC, RSRCRATE, +ROLERATE, RSRCCURVDATA en TASKRSRC worden één keer uit de geparste tabelset gelezen. X4b- +meerdocumentselectie en baseline-uitsluiting blijven ongewijzigd; X5 SCHEDOPTIONS blijft +ongewijzigd. Er is geen X8-wiring en er is geen X9-IFC-, recovery- of exportserialisatieclaim. +De retained XER-metadata is uitsluitend in-memory bronbewijs voor een latere X9-overdracht. + +## Gewijzigde bestanden + +- `src/services/xer/xerResourceTypes.ts`: retained bronvormen, fouten en read-context. +- `src/services/xer/xerResourceCurves.ts`: 21 rauwe curvepunten en niet-destructieve best-fit. +- `src/services/xer/xerResourceAssignments.ts`: lineaire TASKRSRC-partitionering en projectview. +- `src/services/xer/xerResources.ts`: bestandsbrede resource-/rol-/ratecatalogus en mutable + projectmaterialisatie. +- `src/services/xer/xerReader.ts`: catalogus/index éénmaal maken en per X4b-project projecteren. +- `src/services/xer/xerTables.ts`: rows en cells na parsing runtime-bevriezen. +- `src/services/xer/xerMultiProject.ts`: catalogus/raw rows referentie-identiek herstellen na + de bestaande mutable projectclone. +- `src/services/importTypes.ts`: optionele retained X6-metadata onder `xer`. +- `tests/planning/check-xer-resources.ts`: X6-contract voor calendar/rates/curves, + task.resourceIds, X4b-projectfiltering, baselineretentie, catalogusalias en clone-isolatie. +- `tests/planning/check-xer-resource-corpus.ts`: onafhankelijke bytescan met anonieme hashes, + twee publieke corpuspins en parser-/kernperformance/heap-poorten. +- `tests/planning/run.sh`: beide X6-checks éénmaal buiten de tijdzonematrix; de corpuscheck met + `--expose-gc`. +- `tests/planning/check-xer-schedule-options.ts`, `src/utils/wbs.ts` en + `src/state/slices/librarySlice.ts`: type- en instrumentatieaanpassingen die nodig waren nu + parserrows bevroren zijn; geen nieuwe X5- of library-semantiek. + +## Invarianten en mutatiebewijs + +- Eén `parseXerTables()` voedt één immutable catalogus; de catalogus deelt de ruwe `XerRow` + referenties met iedere projectview. +- TASKRSRC wordt éénmaal naar `proj_id` gepartitioneerd. Een project materialiseert alleen zijn + rijen; ongescope en baseline-rijen blijven uitsluitend in de catalogus voor X9. +- Per project worden resources gekloond en assignments nieuw geprojecteerd. De X4b-test muteert + P1-resource en -assignment en bewijst dat P2 ongewijzigd blijft, terwijl de catalogus gedeeld + en bevroren blijft. +- Arbeidsfractie blijft `1 = 100%`; materiaal per uur wordt met de resourcekalender naar per dag + omgerekend. De tijdelijke productiemutatie `rawRate * 100` maakte de verse X6-bundel rood met + exitcode 1: `0,5 -> 50` en de onafhankelijke P2-waarde `2 -> 200`. De regel is daarna met + `apply_patch` exact hersteld; de verse contracttest is weer groen. + +## Onafhankelijke orakels en performance + +De corpuscheck importeert geen productiemodule voor zijn eerste meting. Hij scant de XER-bytes +zelf, telt tabellen/typen en vergelijkt de anonieme SHA-16-pins voor Roads en rehab-2. Daarna +controleert hij de productiecatalogus en lineaire projectprojectie tegen die onafhankelijke scan. + +De geslaagde rerun mat: + +| corpuspin | parser | parserheap | X6-kern | kernheap | +| --- | ---: | ---: | ---: | ---: | +| `a2ef7b35c00d8cf8` | 81,6 ms | 20.391.440 B | 59,6 ms | 2.919.512 B | +| `2c1dce175b9f0781` | 977,5 ms | 243.986.592 B | 778,9 ms | 41.203.632 B | + +Beide bleven onder de vastgelegde grenzen. De bronlog staat lokaal in +`task-X6-planning-rerun.log`; die log is bewust genegeerd en niet onderdeel van de commit. + +## Uitgevoerde poorten + +- Gerichte X6-contracttest na herstel: exitcode 0, 7 checks. +- Gerichte X6-corpuscheck met openbare `OPS_XER_CORPUS`: exitcode 0, 14 checks. +- `npm run typecheck`: exitcode 0. +- Volledige `npm run test:planning` met `OPS_XER_CORPUS` en `OPS_P6_COMPARISON`: exitcode 0, + 560/560 en UTC, America/New_York, Pacific/Midway, Pacific/Auckland en Atlantic/Azores groen. +- Volledige `npm run verify` met dezelfde publieke corpuscontext: exitcode 0; inclusief lint, + planning/library/MCP/dev-server, examples, docs, i18n, cycles en audit (0 vulnerabilities). +- `git diff --check`: exitcode 0. + +## X9-overdracht + +X9 moet de retained `xer.resources`-catalogus en projectassignment-view door het +documentcontract, IFC-writer/-reader en recovery-bronserialisatie voeren. Deze X6-commit bewaart +alleen de in-memory raw rows met referentie-identiteit; zij claimt expliciet geen save/reload- +round-trip, export of MCP-leespaddocumentatie. + +## Reviewfix X6 — 2026-08-25 + +De bestandsbrede catalogus is nu runtime-diep bevroren: nested resources, sourceprojecties, +rate-entiteiten/-tuples, curve-tuples en issues zijn immutable. De doorloop werkt in-place en +stopt bij de reeds bevroren parserrijen, dus raw `XerRow`- en celidentiteit wordt nooit gekloond. +Documentprojecties blijven bewust mutable en geïsoleerd. + +`duplicateDocument()` kloont XER-metadata gericht: catalogus plus raw rows worden gedeeld, maar +projectassignmentbronnen (entity, quantities, costs, rawCurves) en issues zijn nieuwe objecten. +Hiermee wordt een catalogus met 52.640 retained TASKRSRC-rijen niet meer JSON-gekloneerd. + +De corpuspoort start met een directe byte-/tabelscanner voor presence/tellingen, exacte rates en +ingangsdatums, resourcekalenders, role-only assignments, 21 curvepunten/best-fit, projectpartitie +en raw-rowidentiteit. Zij rapporteert afzonderlijk parser, catalogusbouw, materialisatie en de +volledige `readXER`-route. Heap is een live-delta vanaf één nulmeting per corpuscase, omdat alle +resultaten voor het onafhankelijke orakel terecht live blijven. + +Laatste openbare corpusrun: Roads 90,6 / 6,6 / 72,4 / 246,1 ms; rehab-2 1.079,2 / 13,1 / +1.066,6 / 2.707,8 ms (parser / catalogusbouw / materialisatie / end-to-end), 40 checks, +exitcode 0. Rode mutaties (ieder exitcode 1, direct hersteld): rate ×100, verkeerde kalender, +20 i.p.v. 21 curvepunten, resourceIds-fan-out weg, projectmisrouting en catalogusfilter met +baseline/unscoped-verlies. De voorafgaande RED-tests bewezen ook shallow freeze en rawmetadata- +klonen bij duplicateDocument(). + +## Herreviewfix X6 — 2026-08-25 (twee Important-restpunten) + +De runtime-freeze is nu ook statisch afgedwongen. `XerReadonly` is de ene canonieke, +tuple-behoudende diep-readonlycompositie voor de catalogusgrens; `XerResourceCatalog` exposeert +alle resources, identiteiten, rows, rates, entities, kostentuples, curve-tuples, issues en raw +rows als readonly. De bestaande `Xer*`-bronvormen blijven de expliciet mutabele, +documentgebonden projectie beschrijven. De compile-time contracttest zet `@ts-expect-error` op +catalogusmutaties; wanneer een daarvan weer compileert wordt die directive ongebruikt en faalt +de typecheck. Dezelfde proef bevestigt dat entity, assignedRole, quantities, costs en rawCurves +in een `XerResourceReadResult` wél mutabel blijven. + +Materialisatie kloont naast resources nu ook catalogusidentiteiten, issues en bronwrappers voor +resources, roles, rates en curves. Alleen `rawRow` blijft per ontwerp referentie-identiek aan de +catalogus/parserrij. De X6-contracttest vergelijkt voor resource-, role- en +assignmentprojecties afzonderlijk de objectidentiteit met catalogus/peerprojectie en vervolgens +de isolatie na mutatie. Een tijdelijke vervanging van `structuredClone(catalog.resources)` door +de catalogusreferentie gaf exitcode 1 met twee gerichte contractverschillen (identiteit en +“gematerialiseerde projectie is niet mutable”), zonder stacktrace; de tijdelijke mutatie is +direct hersteld. + +Zelf gereproduceerd op `50799884`: vóór de fix waren acht `@ts-expect-error`-directives +ongebruikt (`npm run typecheck`, exitcode 2), dus catalogusmutaties compileerden. Dezelfde +tijdelijke materialisatieclone-regressie beëindigde de oude test met een ongehanteerde +`TypeError` en exitcode 1. Na de fix waren de gerichte resource-, duplicateDocument- en +openbare corpuschecks respectievelijk groen met 38, 7 en 40 checks (alle exitcode 0); de +volledige poorten staan onder de commit vastgelegd. diff --git a/CLAUDE.md b/CLAUDE.md index 1e5e6856d..f895dcc80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,7 @@ npm run test:library # los: bibliotheek/IFC/i18n-checks npm run test:mcp # los: MCP-tools npm run test:dev-server # los: node:test-units + integratietest van de dev-serverpoort/-locks npm run test:browser # los: echte gebruikersflows in Chromium via Playwright +npm run test:browser:x11 # lokaal headed; vereist OPS_XER_CORPUS + desktopdisplay, vervangt de corpusloze CI-poort niet npm run verify:examples # los: de gebundelde voorbeelden laden/rekenen door zoals verwacht npm run verify:docs # los: in-app gidsen — nl+en hard vereist, overige 12 talen indien aanwezig npm run verify:i18n # los: ontbrekende vertaalsleutels t.o.v. nl (CLDR-pluralcategorieën meegerekend) @@ -105,6 +106,46 @@ eigenschappenpaneel (`TaskTimephasedNotice.tsx`); beide linken via `openHelpArti `tests/planning/check-mpp-*.ts`-batterijen (import/relations/calendars/summary-relations) voor de rest van de regressiedekking. +### De XER-lezer (Primavera P6): bronarchief in het IFC, P6-getrouwheid in drie lagen + +`src/services/xer/` leest het native `.xer`-uitwisselingsformaat van Primavera P6 rechtstreeks +(geen Rust, geen externe bibliotheek): `xerTables.ts` (tabelparser, getalnotatie uit `CURRTYPE`, +tekencodering BOM → UTF-8 → Windows-1252), `xerReader.ts` (entry point `readXER()`, hetzelfde +`ImportResult`-contract als de andere lezers, via `formatRegistry.ts` achter een dynamic import), +`xerMultiProject.ts` (één bestand ⇒ meerdere documenten; baselineprojecten worden bij hun huidige +project gematerialiseerd), `xerCalendarData.ts` (de `clndr_data`-decoder incl. herstelcodes), +`xerScheduleOptions.ts` (SCHEDOPTIONS → `SchedulingOptions`, X5), `xerRecordedTimes.ts` (zie +hieronder) en de resource-/metadata-lezers. Het volledige plan met eigenaarsbesluiten, dossiers en +de overdrachtsstand staat in `docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md`; lees §4.1 +(de veldwhitelist met vier "bakken") vóór je een TASK-kolom aanraakt. + +Drie architectuurregels die je moet kennen. (1) **Het bronarchief.** De volledige oorspronkelijke +`.xer`-bytes reizen als `xerSourceArchive` (+ `xerImportMetadata`, `xerSourceProjectId`) mee in +`DOCUMENT_FIELDS` én in `IFC_SAVE_KEYS` (`src/services/xerSourceArchive.ts`): het IFC-projectbestand +en elke crashherstel-snapshot dragen het archief, zonder bovengrens — gemeten 17,7 MB `.xer` ⇒ ±50 MB +IFC, ±3,6 s hoofdthread per auto-save-tick (`docs/xer-recovery-guardrails.md`; eigenaarsbesluit, +plan §10.f). De MCP-tool `planner_xer_provenance` en de extensie-API `data.getImportSource*` (achter +de permissie `importSource`, default-deny) lezen eruit. (2) **Bak 4 — opgeslagen rekenuitvoer is +weergave, nooit solverinvoer.** De zes P6-uitvoerkolommen (`early_/late_start/end_date`, +`total_/free_float_hr_cnt`) mogen uitsluitend via `xerRecordedTimes.ts` gelezen worden, als +`ImportResult.recordedTimes`; ze voeden de weergave "Datums zoals opgeslagen" (issue #63, hierboven +onder *State*) en de X12-meetlat, nooit `Task.time` als invoer. Bak 2 (`restart_date`, `driving_path_flag`, +…) mag de lezer nooit lezen. `tests/planning/check-xer-field-whitelist.ts` grept beide grenzen +corpusloos over heel `src/`; de X12-non-interferentie in `check-xer-product-fidelity-x12.ts` bewijst +het per mutatie. (3) **P6-gedrag is fail-closed op `p6Source`.** Elke P6-specifieke solvertak +(`CPMSolver.ts`, `p6Completed*.ts`, `p6OpenLoe*.ts`) staat achter +`schedulingOptions.p6Source === 'XER'`; de XER-lezer zet die vlag, het IFC round-tript haar (met +`sanitizeSchedulingOptions` als type-/sleutelpoort in `ifcReader.ts`), andere formaten blijven +byte-identiek — de corpusloze planningssuite bewaakt dat. Uitzondering: `lagCalendar` is sinds X5 een +werkende instelling voor élk formaat (was een dode UI-keuze). + +Meten gaat via het corpus (`OPS_XER_CORPUS`, plan §10.a; niet in de repo, geen CI-poort): +`check-xer-product-fidelity-x12.ts` telt zesassige afwijkingen tegen P6's eigen uitvoer +(`OPS_XER_FIDELITY_REPORT=summary|detail|baseline`), gepind in `xer-product-fidelity-baseline-v2.json` +en corpusloos in `check-xer-corpusless-fidelity-gate.ts`. Het nuldoel van plan §1 is niet gehaald +(15.056 op 2026-09-07); mét corpus staat de suite daarom by design rood op precies die drie regels. +Gebruikersgidsen: `public/docs/{nl,en}/gids-xer-import.md` en `datums-zoals-opgeslagen.md`. + ### De contour-engine: werkverdeling-per-dag als data, de curve-formule als terugval `src/engine/contour/contourEngine.ts` (puur, 2026-09) is de rekenkern voor **resource-contouring**: @@ -248,10 +289,10 @@ bedrading — álle `@tauri-apps/*`-imports dynamisch achter `isTauri()`, zodat bouwen), `dispatcher.ts`, `schemaValidate.ts` (schema's worden in de dispatcher afgedwongen, óók binnen `planner_batch` — een draaiboek mag de poort niet omzeilen), `toolRegistry.ts`/`toolIndex.ts`, `staleGuard.ts` (`ensureFreshSchedule`), `backup.ts` (AI-backups per document in `appDataDir`, -`MAX_PER_DOC = 10`) en `activityLog.ts` (ring-buffer achter het AI-activiteitenpaneel). De 39 +`MAX_PER_DOC = 10`) en `activityLog.ts` (ring-buffer achter het AI-activiteitenpaneel). De 40 `planner_*`-tools staan in `src/services/mcp/tools/` (taken, relaties, resources, kalender, project, -baselines, documenten/bestanden, leestools, en `planner_batch` als transactionele executor met -temp-id-resolutie). +baselines, documenten/bestanden, leestools, XER/P6-bronprovenance, en `planner_batch` als +transactionele executor met temp-id-resolutie). Veiligheid is state, geen conventie: `ui.aiMode` (de hele AI-tab en bridge verschijnen pas hierdoor), `ui.aiPaused`, `ui.aiReadOnly` en `ui.aiServerStatus` leven in `uiSlice`; de per-request `McpContext` diff --git a/README.md b/README.md index 279c067b2..d305f33bc 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ src/ extensions/ # Extensiesysteem (types, api, loader, service) i18n/ # Vertalingen, 14 talen × 4 namespaces hooks/ types/ utils/ styles/ -public/docs/ # In-app handleiding: 32 artikelen × 14 talen (voedt ook de wiki) +public/docs/ # In-app handleiding: 33 artikelen × 14 talen (voedt ook de wiki) examples/ # Voorbeeldplanningen in IFC tests/ # planning · library · mcp · dev-server · browser src-tauri/ # De Rust-schil (dun: precies één native command) diff --git a/docs/TODO.md b/docs/TODO.md index 48d3ef333..35824a954 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -360,6 +360,13 @@ deze lijst verwijderd — wat klaar is, staat in de changelog en git-historie. - [ ] **Uur-modus-dagslot is een benadering.** De engine deelt de as in slots van `hoursPerDay × 60`; een werkdag met afwijkende bandlengte (korte vrijdag) telt daardoor als een deel-slot — dezelfde benadering als `enumerateTaskWorkDays`, dus consistent, maar geen echte per-dag-bandtelling. +- [ ] **XER-opgeslagen werk (`target_qty`/`remain_qty`/`act_reg_qty`) blijft bewust in het + bronarchief.** Geen nieuw modelveld vanuit XER deze etappe — de taaktypes-etappe definieert het + eersteklasveld op `ResourceAssignment`; XER zet het pas dán over, en uitsluitend wanneer + `target_qty` afwijkt van `target_drtn_hr_cnt × target_qty_per_hr` (anders blijft het veld + afwezig, byte-identiek). Meetlatbestanden: `HarbourPointe_AssistedLiving` (98 afwijkende rijen, + factor 3), `Harbour Point DCP-03` (factor 4; `remain_qty` zonder resttarief), `p6_torture_test_v1` + (duur 0 met werk); resttarief wijkt in `rehab-2` in 27,5% van de rijen af. ### Resourcekalender-semantiek — taak volgt resourcekalender als keuze (besluit eigenaar 2026-09-04) - [ ] **Overallocatie op een vrije dag van de resource is bewust gedrag, geen bug** (`ResourceLoad.ts` @@ -726,6 +733,56 @@ tag-push de `.snap` als release-asset. Geverifieerd via een `workflow_dispatch`- ### Kwaliteit & verificatie +- [x] **De per-cel-poort `cellTransitions.previouslyExact` meet sinds de X12-v2-meetlat niets meer** + — HERSTELD 2026-09-07 (etappe X12, herpin): v1 heeft zijn eigen bestand terug + (`xer-product-fidelity-baseline.json`, twee openbare corpusbestanden), de harness geeft + `progressMode`/`schedulingOptions` weer door en de overgangshistorie is herstart vanaf de huidige + exacte set (44+16 cellen, 0 verbeteringen; zie de `reason`-velden). De poort meet weer: een cel + die exact was en fout wordt, is op die twee bestanden mechanisch rood. Voor de overige 32 entries + blijft de v2-karakterisering (`check-xer-corpusless-fidelity-gate.ts`, in-bron pin per as) de + enige bewaking — per as, niet per cel. +- [ ] **XER: projecteinde valt terug op de projectSTART bij `sched_use_project_end_date_for_float=Y` + zonder `plan_end_date`** (her-review 7a, 2026-09-07; `xerReader.ts` `taskDerivedProjectEnd = + finishes[last] ?? projectStart`). Op de echte P6-export van de dertien casussen + (`cases-import.xer`: geen enkele `target_end_date`, geen `plan_end_date`) verankert de hele late + zijde daardoor op de start: 77/160 P6-cellen zoals gelezen, 156/160 met de optie uit. Gepind in + sectie 7 van `check-p6-verified-cases-engine.ts`. Fix-kandidaat: zonder bruikbaar einde + (`plan_end_date` leeg én geen taakeinde) de optie gerapporteerd uitzetten — met blastradius- + meting op het corpus (39 van 50 SCHEDOPTIONS-rijen dragen `Y`), niet als zijklus. +- [ ] **Meetlat per formaat (nul afwijkingen zoals XER §1), als aparte etappe ná de + etappe "datums zoals opgeslagen voor alle formaten"** (eigenaarsbesluit 2026-09-09, optie 3; + die etappe zelf wordt gebouwd en staat daarom niet hier maar in plan §10.f). Nu: alleen XER (93 bestanden, + zes assen) en `.mpp` (216 bestanden, alleen start/einde) hebben een corpus + gepinde baseline; + MSPDI en P6 XML zijn nooit gemeten. Materiaal: `ops-xer-corpus/pmxml-samples/` (9 P6 XML) en + de publieke MPXJ-junit-data (148 XML, deels MSPDI, deels P6 XML). Per formaat: corpus, + zesassige meting, gepinde baseline, suite rood zolang niet nul. Beginnen met P6 XML (meeste + materiaal, grootste overlap met XER); `.mpp` uitbreiden van twee naar zes assen. CSV krijgt + geen meetlat (geen bronpakket dat het antwoord geeft). +- [ ] **XER: het bronarchief heeft geen bytegrens en gaat mee in élke auto-save-serialisatie** + (eindreview 2026-09-07, bevinding 1). Gemeten op `rehab-2.xer` (17,7 MB): IFC 50 MB, volledige + herstelronde 73 s / 3,1 GB piek-RSS, ±3,6 s hoofdthread per 10-secondentick; bij twaalf documenten + uit één bestand 26× amplificatie (OZB). Geen cap, geen opt-out, geen worker. Eigenaarsbesluit + (plan §10.f): bytegrens waarboven het archief niet in de recovery-snapshot meegaat, óf het + immutabele archief één keer apart schrijven, plus een budgetpoort in + `check-xer-archive-recovery-corpus.ts`. De gids benoemt de prijs nu wel. +- [ ] **XER: "niet vastgelegd" bestaat in tabel, CSV en MCP, maar niet in de rapporten, de PDF, het + printvoorbeeld en de renderer** (eindreview bevinding 6). In de modus schrijft + `applyRecordedTimesToTasks` `totalFloat ?? 0`/`isCritical ?? false` in `task.time`; de rapporten + presenteren dat als cijfer (corpus: 122 taken zonder volledig late-paar, 290 zonder + `total_float_hr_cnt`). Nu: één melding bovenaan elk rapport (`tableReports.recordedDatesNote`). + Volledig: `unrecordedExportGate` door `ReportContext` en de kolomspecs heen. +- [ ] **XER: de exportverliesmelding komt ná het schrijven** (eindreview bevinding 12) — + `detectXerExportLoss` draait vóór de dialoog, de `info`-melding pas ná `saveFileDialog`. Overweeg + de waarschuwing vóór de dialoog wanneer de categorieën het exact-source-bytes-verlies bevatten. +- [ ] **XER: geen bovengrens op het aantal documenten uit één bestand** (eindreview bevinding 13, + VERMOED): elk document draagt zijn eigen archiefkopie; het corpus haalt maximaal 15 projecten. Te + bevestigen met een synthetisch bestand van ~100 projecten door `readXER` + auto-save. +- [ ] **`lagCalendar` is sinds X5 effectief voor élk formaat** (eindreview bevinding 5): een + bestaand document waarin ooit 'successor'/'24hour'/'projectDefault' is gekozen plant na de + volgende release anders. Regel in de releasenotities van die versie; eventueel migratienoot. +- [ ] **XER: `rem_target_link_flag=Y` maakt de vroege start van een bezig zijnde taak de reststart** + waar P6 de werkelijke start opneemt (casus 08 A, casus 10 B: ES én LS, vier cellen). Zie plan §9. + - [ ] **Geen enkele poort raakt het Tauri-asset-protocol — een hele klasse desktopbugs is structureel onzichtbaar.** Aangetoond 2026-07-28: in de uitgeleverde `.deb` v2026.7.13 toonde Backstage → Help bij élk artikel "Artikel niet gevonden", terwijl alle 354 artikelen gewoon in de diff --git a/docs/extensions.md b/docs/extensions.md index b3cd0690e..7a32c16c8 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -38,10 +38,12 @@ Gebruik `currentColor` voor `fill`/`stroke` zodat het icoon met het thema meekle | `ribbon` | **hard** — ontbreekt ⇒ `api.ui.addRibbonButton` gooit | Een knop in de ribbon plaatsen. | | `backstage` | **warn** (overgangsregime) — ontbreekt ⇒ `api.importers.*` werkt nog, maar logt een waarschuwing | Een importer registreren (verschijnt in Bestand → Importeren). | | `pdf-fonts` | **hard** — ontbreekt ⇒ `api.pdfFonts.register` gooit | Een font-provider registreren voor de vector-PDF-export (bv. CJK-glyf-bytes). | +| `importSource` | **hard, default-deny** — ontbreekt ⇒ `api.data.getImportSourceInfo`/`getImportSourceChunk`/`getImportSourceCatalogPage` gooien vóórdat er ook maar één byte gelezen wordt | De **volledige oorspronkelijke bronbytes** van een geïmporteerd bestand (vandaag: XER) lezen, inclusief velden die de importlaag bewust niet in het projectmodel materialiseert. Zie de aparte paragraaf verderop. | | `filesystem` | informatief | Geen API-oppervlak; puur getoonde intentie bij installatie — **geen** sandbox-garantie (extensie-code heeft technisch gewoon toegang). | | `network` | informatief | Idem — getoonde intentie, geen technische grens. | -`data.*`, `settings.*`, `assets.*` en `ui.showNotification` zijn **kern-API**: altijd beschikbaar, geen permissie nodig. +`data.*` is verder **kern-API** — behalve de drie `getImportSource*`-methoden hierboven — net als +`settings.*`, `assets.*` en `ui.showNotification`: altijd beschikbaar, geen permissie nodig. De afdwinging is gecentraliseerd in `src/extensions/permissions.ts` (één tabel pad → permissie). @@ -203,7 +205,7 @@ module.exports = { | Onderdeel | Functies | |---|---| | `api.importers` | `register(def)`, `unregister(id)` | -| `api.data` | `getProject/getCalendar/getTasks/getSequences/getResources/getAssignments`, `addTask`, `updateTask`, `addSequence`, `loadProject(result)`, `recalculate()`, `batch(fn)` | +| `api.data` | `getProject/getCalendar/getTasks/getSequences/getResources/getAssignments`, `getImportSourceInfo/getImportSourceChunk/getImportSourceCatalogPage` (permissie `importSource`, zie hieronder), `addTask`, `updateTask`, `addSequence`, `loadProject(result)`, `recalculate()`, `batch(fn)` | | `api.events` | `on/off/emit` (permissie `events`) | | `api.ui` | `addRibbonButton(reg)` (permissie `ribbon`), `showNotification(msg, type?)` | | `api.settings` | `get(key, default)`, `set(key, value)` — per extensie geprefixt in localStorage | @@ -261,6 +263,80 @@ module.exports = { }; ```` +### Read-only XER-bronroute (permissie `importSource`, `apiVersion` ≥ 1.1) + +Naast de gemapte `data.*`-DTO's (afgeleid, genormaliseerd, altijd beschikbaar) kan een extensie met +de permissie `importSource` ook bij de **oorspronkelijke, ongewijzigde brondata** van het huidige +document — vandaag alleen voor een geopend `.xer`-bestand (Primavera P6). Zonder deze permissie +gooien alle drie de methoden vóórdat er ook maar één byte gelezen wordt; er lekt dus niets via een +gedeeltelijke aanroep of een foutpad. Deze drie methoden bestaan sinds contractversie `1.1.0` (zie +*Twee versievelden, twee vragen* hierboven) — declareer `"apiVersion": "1.1"` of hoger in je +manifest als je erop rekent; een host ouder dan 1.1 kent de methoden simpelweg niet. + +**Waarom een aparte permissie en geen kern-API.** De rest van `api.data.*` levert het interne +projectmodel: taken, kalender, relaties — precies wat de importer ervan gemaakt heeft. De +bronroute geeft de **volledige originele bytes en tabellen** terug, inclusief kolommen die de +importlaag bewust nooit in het projectmodel materialiseert (audit-/herkomstvelden als +`create_user`/`update_date`, kosten, review-/locatievelden, ongebruikte UDF's, …). Dat is een +wezenlijk grotere blootstelling dan "de app leest dit bestand" — elke geïnstalleerde extensie zou +anders, ook zonder enige andere permissie, de rauwe brontekst van elk geopend project kunnen lezen +en doorsturen. Vandaar: default-deny, expliciet gedeclareerd in `manifest.json`. + +```js +// manifest.json → "permissions": ["importSource"] +const info = api.data.getImportSourceInfo(); // null buiten een XER-document +if (info) { + console.log(info.sourceFormat, info.archive.byteLength, info.catalogs.taskSourceRows.totalRows); +} +``` + +- **`getImportSourceInfo()`** → `ExtImportSourceInfo | null`. Een kleine, samengestelde samenvatting + (bronformaat, archief-identiteit inclusief `sha256`/`byteLength`/`chunkCount`, getalnotatie, + diagnostiek-tellingen, het importrapport, de schedule-options-herkomst en catalogustellingen). + Geen record-inhoud. **`null`** wanneer het actieve document geen retained XER-bron heeft (elk + niet-XER-document, of een XER-document van vóór deze functie). +- **`getImportSourceChunk(index)`** → `Uint8Array | null`. Eén losse, verse kopie van een stuk van + de oorspronkelijke bestandsbytes (`archive.chunkSize`/`archive.chunkCount` uit `getImportSourceInfo()` + geven de indeling). Concateneer alle chunks 0..`chunkCount - 1` in volgorde om de **exacte** + oorspronkelijke bytes te reconstrueren — vergelijk de `sha256` uit `getImportSourceInfo()` om dat + te bevestigen. Een ongeldige index (negatief, fractioneel, of buiten bereik) gooit een + `RangeError`; buiten een XER-document levert de methode `null`. +- **`getImportSourceCatalogPage(collection, options?)`** → `ExtImportSourceCatalogPage | null`. + Gepagineerde, per record gekopieerde toegang tot de retained brontabellen (task-bronrijen, + resource-/rol-/tarief-/curve-/toewijzingsrijen, activiteitscodes, custom-field-definities, + UDF-waarden, schedule-options-bronrijen, …) — zie `ExtImportSourceCollection` in `extTypes.ts` + voor de volledige lijst. `options.offset` (default 0) en `options.limit` (default 100, **maximaal + 500 per pagina**) sturen de paginering; een niet-safe-integer of negatieve waarde gooit een + `RangeError`, net als een onbekende `collection`. Een `offset` voorbij het einde van de collectie + gooit **niet** — hij wordt gecanoniseerd naar `total` en levert zo een lege, geldige laatste + pagina (`items: []`) in plaats van een fout of een numeriek onveilige slice. Buiten een + XER-document levert de methode `null`. + +**Documentbinding, selector en documentdrift tijdens pagineren.** Alle drie de methoden werken op +het **actieve document**: bij het wisselen van document (`switchDocument`) volgen ze automatisch +mee naar de bronroute (of het ontbreken daarvan) van het nieuw actieve document. `info.selector`/ +`info.sourceProjectId` identificeert welk P6-project binnen het (mogelijk multi-project) +XER-bestand dit document vertegenwoordigt — een `.xer`-bestand kan meerdere documenten openen (één +per project), en elk document draagt zijn eigen bronselector. + +Dat "automatisch meevolgen" is handig voor een enkele aanroep, maar een **risico bij pagineren**: +pagineren is per definitie meerdere aanroepen na elkaar, en er is geen paginasessie die aan één +document vastzit. Wisselt de gebruiker tussen twee `getImportSourceCatalogPage`-aanroepen van +document (`switchDocument`), dan levert de tweede aanroep zonder verdere maatregelen gewoon een +pagina van het **nieuwe** actieve document — bijvoorbeeld een lege pagina omdat dat project minder +records heeft, wat een naïeve extensie laat concluderen "klaar" terwijl in werkelijkheid twee +projecten door elkaar zijn gehaald. Geef daarom `options.expectedSourceProjectId` mee met het +`sourceProjectId` dat je van een eerdere aanroep kreeg: wijkt de actieve bronselector af (ook als +het actieve document inmiddels helemaal geen XER-bron meer heeft), dan gooit de aanroep een +`ExtImportSourceDriftError` in plaats van stilzwijgend door te gaan. Zonder deze optie is er **geen** +driftbewaking. Dezelfde risicoklasse geldt in mindere mate voor het na elkaar opvragen van meerdere +`getImportSourceChunk`-indexen om de bronbytes te reconstrueren — vergelijk daar `sourceProjectId` +(of de `sha256`) tussen chunks als je niet zeker weet dat het document niet wisselt. + +**Verse kopieën, geen aliasing.** Zoals de rest van `api.data.*` levert elke aanroep een nieuwe, +onafhankelijke kopie: muteren van een teruggegeven `info`, cataloguspagina-item of chunk raakt het +retained archief niet, en twee aanroepen na elkaar geven nooit hetzelfde object terug. + ### Datacontract: de `Ext*`-typen Alles wat via `api.data.*`, de importer-handlers en `sdk.factory.*` de extensie in- en uitgaat, gebruikt **stabiele extensie-typen** (`ExtProject`, `ExtCalendar`, `ExtTask`, `ExtTaskTime`, `ExtSequence`, `ExtResource`, `ExtAssignment`, `ExtImportResult`; gedefinieerd in `src/extensions/extTypes.ts`). Dit is het **publieke contract** — bewust losgekoppeld van het interne domeinmodel, zodat een interne refactor jouw extensie niet breekt. @@ -343,6 +419,6 @@ Bij een los `.js`-bestand mag het manifest als commentaarblok bovenaan: ## Beperkingen -- Er is geen JavaScript-sandbox: extensie-code draait via `new Function(...)` en heeft toegang tot `window`, `document` en `fetch`. Permissies worden hard afgedwongen voor `ribbon`/`events`, in warn-modus voor `backstage`, en zijn voor `filesystem`/`network` puur informatief (geen technische grens). Installeer alleen extensies die je vertrouwt. +- Er is geen JavaScript-sandbox: extensie-code draait via `new Function(...)` en heeft toegang tot `window`, `document` en `fetch`. Permissies worden hard afgedwongen (default-deny) voor `ribbon`/`events`/`pdf-fonts`/`importSource`, in warn-modus voor `backstage`, en zijn voor `filesystem`/`network` puur informatief (geen technische grens). Installeer alleen extensies die je vertrouwt. - Objecten uit `api.data.get*()` zijn **verse, muteerbare `Ext*`-kopieën** — muteren raakt de store niet; schrijf terug via de muterende API-functies. - Het `@manifest`-commentaarblok in een los .js-bestand moet een plat JSON-object zijn (geen geneste objecten). diff --git a/docs/library.md b/docs/library.md index 7d046b0f9..554b3406d 100644 --- a/docs/library.md +++ b/docs/library.md @@ -389,6 +389,15 @@ Alle drie komen voort uit dezelfde wortel — **er is geen gedeelde opslag tusse beperkt tot wat in deze programma-instantie bekend is — de weergave zelf meldt die grens als permanente voetnoot. + Sinds XER-laag 3 ("datums zoals opgeslagen") is er een tweede, subtielere grens. Een document in + die weergave wordt in het bezettingsoverzicht als **gepind** behandeld (`pinnedReason: + 'dates-as-recorded'`, `services/library/distribute.ts`) en dus NOOIT efemeer doorgerekend — het + toont immers de datums van het bronbestand, niet onze berekening, en die stil overschrijven zou + precies de belofte van die weergave breken. Een verse `.xer`-import met restverschillen zet die + weergave zelf aan, dus dat gepinde geval is geen randgeval meer maar de normale toestand van een + zojuist geopend P6-bestand: de bezetting van zo'n document weerspiegelt Primavera's planning + totdat je het document herberekent (**F5** of een bewerking). + 3. **Twee tabbladen, zelfde machine.** De bibliotheek leeft app-breed in-memory en wordt bij elke wijziging weggeschreven; twee open tabbladen (of twee vensters) op dezelfde machine overschrijven elkaars laatste schrijfactie stilzwijgend — zelfde wortel als punt 1 hierboven, alleen dan zonder de diff --git a/docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md b/docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md new file mode 100644 index 000000000..7d07ef541 --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md @@ -0,0 +1,875 @@ +# Etappe 2 — Primavera XER lezen, getrouw aan P6 op vier assen + +*Levend etappeplan. Concept 2026-08-20 na drie verkenningsrondes; herwerkt dezelfde dag na de +hyperkritische planreview (verdict: herwerken — alle blokkerende en zware punten zijn in deze +versie verwerkt; de review mat vrijwel alle corpusgetallen zelf na en de gecorrigeerde cijfers +hieronder zijn de zijne). Eigenaar van dit document is de orkestrator; besluiten en bevindingen +worden hier bijgeschreven zoals bij `2026-08-17-plan-mpp-nul-afwijkingen.md`. Overgenomen +2026-09-04 door de Claude-hoofdsessie na het einde van de Codex-thread; besluiten X-O6 t/m X-O8 +en de laagbijstelling stammen uit die overname.* + +## §1 Doel + +**Open Planner Studio opent Primavera XER-bestanden (.xer) native, en is daarbij getrouw aan +P6's eigen opgeslagen rekenuitvoer op vier assen: early start, early finish, late start en late +finish — plus de totale en vrije float.** Over alle leesbare, door P6 doorgerekende bestanden +van het XER-corpus geldt na import + herberekening (`runCPM`): exact nul afwijkingen, per as +geteld over de taken waar die as meetbaar is. De baseline bestaat uitsluitend uit nullen, zonder +één reason-pin — het `GOAL_ZERO_DEVIATIONS`-model van etappe 1, uitgebreid met per as een +afwijkings- én een meetbaar-teller. + +**Granulariteit (planreview B1 — dit beslist alles):** 98,9% van de orakeldatums draagt een +echte tijd (73.408 van 74.212 gemeten datumwaarden staan niet op middernacht; 08:00, 16:00 en +17:00 domineren). De meetlat is dus **minuut-exact**, wat betekent dat élk orakelbestand in +uurmodus gelezen moet worden — anders landt elke taak in `sameday`, en sameday moet nul zijn. +De kalenderdecoder en de uurmodus-promotie (X3) zijn daarmee niet een tussenstap maar de +kritieke taak van de etappe. + +**Float-precisie (planreview V2), als formule:** P6 slaat float op in uren +(`total_float_hr_cnt`/`free_float_hr_cnt`; 70 van de ±18.400 gevulde waarden zijn fractioneel, +dus "hele uren" is geen geldige aanname). Wij rekenen float in werkdagen. De vergelijking is: +`ons_float_in_minuten === round(p6_float_uren × 60)`, waarbij onze werkdag→minuten-omrekening +loopt via de taak-effectieve kalender — dezelfde uren-per-dag-bron als de duren, inclusief de +afleiding-uit-weekuren wanneer P6's eigen uren-per-dag-veld leeg is (dat is het hóófdpad: in +het rijkste corpusbestand is dat veld voor alle 124 kalenders leeg). Exact, geen tolerantie; +blijkt een klasse fractionele gevallen structureel onbeslisbaar, dan is dat een X-O3-escalatie, +geen stille afronding. + +**Wat er níét in deze etappe zit**: XER schríjven (export) — eigen latere etappe (MPXJ's +`PrimaveraXERFileWriter` als referentie; TODO-registratie in X12). De taaktypes/effort-driven- +mótor blijft de aparte etappe uit `2026-08-18-spec-taaktypes-effort-driven.md`; P6's duration- +en activiteitstypes worden hier wél gelezen en bewaard als data. X0 legt daarbij vast dat de +latere motor-etappe `mspTaskType` en de nieuwe P6-typevelden naar één interne superset mapt — +twee opslagvelden nu, één rekenmodel straks, geen twee eilanden. + +## §2 Waarom dit de juiste volgende stap is + +- `docs/TODO.md` (issue #17): *"Primavera XER import/export — tekstformaat, native in TS + haalbaar (geen JVM); samen met ons bestaande PMXML dekt dit de P6-wereld. Hoogste + interop-prioriteit."* In de bouwpraktijk is de .xer vaak het enige dat een aannemer krijgt. +- Het fundament ligt er: de format-registry maakt een `.xer`-entry een patroonvolgend haakje + (lazy chunk, dialoogfilters, i18n-fouten — het .mpp-stramien). De solver leest + `schedulingOptions` volledig; `mppReader.ts` en `mspdiReader.ts` vullen ze al gedeeltelijk — + XER's SCHEDOPTIONS-tabel wordt de derde vuller en de eerste met P6's eigen + instellingsbegrippen (planreview F1: het plan claimde eerder "de eerste" — onjuist). +- Het corpus is publiek: 93 crawl-bestanden (P6 5.0 t/m 24.x). **Eerlijke telling van de + meetmassa (planreview B3/licht):** 18.489 taken dragen de vier datum-assen; 17.963 dragen + álle zes assen. Let op de duplicaten (her-check-meting): byte-dedup (md5) houdt 84 unieke + bestanden over met 17.600 orakeltaken in 23 unieke orakelbestanden — maar het gróótste + duplicaat vangt een inhoudshash juist níét: de twee Hotel-bestanden (samen 47% van het + orakel) dragen dezelfde vier projecten en dezelfde 4.236 taken in nét verschillende bytes. + De pinning dedupliceert daarom tweeledig: byte-hash voor de exacte kopieën (twee Harbour + Point-bestanden staan er 4× in, drie 2×) én een schema-vingerafdruk (proj_id-set + + taakcodes + orakelwaarden) die het Hotel-paar als bekend duplicaat markeert. Het unieke + orakel is dan 13.383 taken over 22 bestanden — nog altijd ~4× het .mpp-corpus, zonder + bedrijfsdata: bestandsnamen mogen gewoon in tests en commits. + +## §3 De twee meetlatten + +1. **Corpus-orakel (bulk).** P6 bewaart zijn laatste rekenuitvoer per taak in de TASK-tabel. + Een eigen `xerGroundTruth` leest die velden met een **onafhankelijke, minimale tabelscan** — + bewust een tweede parser naast de echte lezer (F7-les uit etappe 1: gedeelde veldkaarten + zijn common-mode; hier blijft zelfs de tokenizer gescheiden, en de "veldkaart" is per + bestand de eigen `%F`-regel). Statussemantiek hoort bij de meetlat: voltooide taken meten op + `act_`-datums; per as geldt een meetbaar-aantal naast het afwijkingental, want de dekking is + scheef (her-check-meting, gevulde cellen over de 60 orakelbestanden: ES/EF in 48 + bestanden, LS/LF in 36, TF ±18.400 cellen, FF ±18.000 — er bestaan bestanden met alleen + float en geen datums, en andersom). + **Beide tellers worden gepind** (planreview M1): een lezer die stilletjes minder gaat meten + maakt de suite net zo rood als een lezer die fout meet. + Daarnaast rapporteert (buiten de nul-poort) een zevende as: `driving_path_flag` — P6's eigen + kritiek/driving-oordeel, gevuld in 18.321 cellen over 30 bestanden, de goedkoopste externe + toets op onze kritiek-padlogica die er bestaat (planreview M3). Promotie van die as tot + poort is een expliciet latere afweging. +2. **P6-geverifieerde scenario's (scherp).** De `p6-comparison`-map bevat 13 kleine cases met + per case ES/EF/LS/LF/TF/FF zoals **echt P6 23.12** ze produceerde. Alleen de `*_p6`-kolommen + zijn meetlat — mét hun tijden. De `*_engine`-kolommen en de PASS-oordelen van dat raamwerk + zijn géén referentie: hun "PASS" is geveld ná een normalisatie die de tijd volledig laat + vallen én een exclusief-vs-inclusief-finish-conventie overbrugt (gemeten in case 01: + `EF_engine=2026-01-12` vs `EF_p6=2026-01-09 17:00` telt daar als gelijk), en hun README + meldt zelf dat de engine op deze capture is nagefit (planreview M4). Wij nemen uitsluitend + P6's kolom over, onvertaald, en bouwen daar `cases-p6-verified.json` uit (herkomst: alleen + de data; de engine-code van dat project is geen referentie). De twee cases die het raamwerk + buiten zijn matrix hield (fractional-lag, dangling-relationship) worden gedocumenteerde + niet-in-P6-reproduceerbaar-randgevallen, geen poort. + +Niet-orakel-bestanden (de ongerekende generator-fixtures onder `xer-corpus/cases/` — planreview +M5: die dragen géén orakelvelden, hun TASK-header stopt bij constraints en actuals — de +robuustheidsbestanden en het 8-byte-DROID-skelet) tellen niet in de fidelity-poort maar in de +**parser-poort**: leesbaar-of-nette-typed-fout, nooit een crash of een stil half project. + +## §4 Harde regels (geërfd uit etappe 1, plus de XER-eigen) + +1. **Het opgeslagen antwoord is meetlat, nooit uitkomst — als WHITELIST (planreview M2, + dichtgemaakt na de her-check).** De lezer mag uit de TASK-tabel uitsluitend deze + invoervelden importeren: identiteit/structuur (`task_id`, `task_code`, `task_name`, + `wbs_id`, `clndr_id`, `proj_id`, `guid`, `rsrc_id`), `status_code`, `task_type`, + `duration_type`, `priority_type`; voortgang: `complete_pct_type`, `complete_pct`, + `phys_complete_pct` (dé kolom voor de CP_Phys-taken, 18.607 gevulde cellen), + `act_start_date`/`act_end_date`; duren: `target_drtn_hr_cnt`/`remain_drtn_hr_cnt`; + geplande datums: `target_start_date`/`target_end_date`; werk-hoeveelheden (als data): + `target_work_qty`/`remain_work_qty`/`act_work_qty`/`act_this_per_work_qty` en de + `*_equip_qty`-familie; constraints: `cstr_type`/`cstr_date` (+`2`); + `suspend_date`/`resume_date`; `task_notes` (échte notitiedata, X8); `constraint_type` + (dialect-alias van `cstr_type` in de ProjectLens-bestanden); en `expect_end_date` + (uitsluitend samen met de bijbehorende SCHEDOPTIONS-vlag, zie X5). **Alles wat niet op + deze lijst staat is verboden terrein voor de lezer** — expliciet inclusief álle rekenuitvoer die er als gewone velden uitziet: + `restart_date`/`reend_date` (15.328/15.329 gevulde cellen), + `rem_late_start_date`/`rem_late_end_date` (10.193 elk), `driving_path_flag`, + `float_path`(`_order`), + `external_early_start_date`/`external_late_end_date` (die onder X-O1 relevant lijken maar + uitvoer zijn), `old_restart_date`/`old_reend_date`/`old_remain_drtn_hr_cnt`, + `crt_path_num`, `critical_drtn_hr_cnt`, `act_drtn_hr_cnt` en + `plan_start_date`/`plan_end_date`. Naast deze twee lijsten is er een + **derde bak: "genegeerd — geen planningsdata"** (delta-check: 32 corpuskolommen vielen + buiten beide lijsten en de poortregel was daarmee op dag één onvervulbaar): audittrail + (`create_date`/`update_date`/`create_user`/`update_user`), vlaggen + (`rev_fdbk_flag`/`lock_plan_flag`/`auto_compute_act_flag`), kosten + (`remain_cost`/`plan_cost`/`act_cost`), review/locatie + (`review_type`/`review_end_date`/`location_id`), `est_wt`, `tmpl_guid`, + `target_qty_per_hr`/`act_reg_qty`/`act_ot_qty`, `plan_start_date`/`plan_end_date`-achtige + restvelden voor zover niet al verboden, de pseudo-XER-kolommen uit bestanden die X2 toch + weigert, en de lege kolomnaam die een afsluitende tab op een `%F`-regel oplevert + (tokenizer-nota in X2). Voorbehoud: blijkt tijdens de bouw dat een bak-3-veld tóch + planningsinvoer draagt (kandidaten: `est_wt`, `review_type`, `tmpl_guid` — op naam + ingedeeld, niet doorgelezen), dan verhuist het expliciet naar bak 1, nooit stilzwijgend. + Naast deze drie bakken is er een **vierde bak: "uitsluitend weergave/meetlat, nooit + solverinvoer"** (X-O7, §5): `early_start_date`, `early_end_date`, `late_start_date`, + `late_end_date`, `total_float_hr_cnt`, `free_float_hr_cnt`. De lezer draagt ze in een apart, + waardedragend contractveld (werknaam `ImportResult.recordedTimes`), nooit in `Task.time` en + nooit in de solverinvoer — mutatiebewijs in X12-stijl: gemuteerde opgeslagen uitvoer + verplaatst de solve niet, maar verplaatst de weergavemodus (het issue-#63-mechanisme van + X-O7.3) wél. + **Een corpus-%F-kolom die in géén van de vier bakken staat is een X0-poortfout, geen vrije + keuze.** De X12-sluiproute-scan grept tegen de whitelist. Drie afgekeurde pogingen in + etappe 1 zijn het precedent. +7. **De enum-tokenregel (delta-check — twee mechanismen, niet één):** (a) hoofdletter- + varianten van bekende tokens worden case-insensitief gematcht (het corpus draagt + `RCAL_SUCCESSOR` naast `rcal_Successor` en `TT_mile` naast `TT_Mile`); (b) een token dat + ook ná case-fold onbekend is — zoals `ST_TotalFloat`, een PMXML-vorm in een XER die géén + case-variant van de XER-tokens is — wordt een **gerapporteerde** terugval naar de default, + nooit een stille. Implementatie in X4a/X5 (de semantische lagen; de X2-tokenizer kent geen + enums), elk met een mutatiebewijs: case-fold uit ⇒ de `RCAL_SUCCESSOR`-fixture ROOD; + rapportage uit ⇒ de `ST_TotalFloat`-fixture ROOD. +2. **De veld-als-signaal-regel** (etappe-1-registratie): veld-aanwezigheid op `Task` ís + semantiek-signaal. De XER-lezer zet een bestaand veld alleen als de P6-betekenis aantoonbaar + identiek is; elke afwijkende semantiek wordt een **bron-vlag** naar het O6-patroon (default + uit ⇒ byte-identiek, uitsluitend door de betreffende lezer gezet). Verwachte kandidaten: + de lag-kalender, retained logic vs. progress override, de Z10/Z11-relatieregels, en de + suspend/resume-semantiek (X7). + **Opgeleverde O6-bronvlaggen (`SchedulingOptions`, alle alleen actief onder + `p6Source === 'XER'`)**: `p6PreserveActualInstants`, `p6UseRemainingStartForProgress`, + `p6PreserveZeroDurationConstraintInstants`, `p6FinishMilestoneBoundaryWindow` en — sinds + 2026-09-05, X-O7 laag 1 klasse (i) — `p6CompletedLateFromRemainingWindow`. Die laatste wijkt + op één punt af van het stramien: hij staat in de XER-defaultset standaard **aan**, maar + `deriveXerScheduleOptions` zet hem weer **uit** zodra de bron `sched_progress_override = Y` + declareert, omdat alle corpusbewijs uitsluitend RETAINED_LOGIC is. Zie het docblok bij het veld + in `src/types/project.ts` voor de volledige bewijsbasis. +3. **Corpus is publiek**; bedrijfs-XER-bestanden zouden hash-only zijn, maar het corpus bevat + ze niet. +4. **MPXJ (LGPL-2.1) uitsluitend lezen-om-te-begrijpen**; onafhankelijk herimplementeren, + herkomstvermelding per bestand. Zelfde regel voor het cpp-cpm-engine-raamwerk: alleen de + `*_p6`-data is meetlat, hun code is geen referentie. +5. **Exitcode is de poort, nooit de tail**; blast-radius meten vóór verbreden; regels op de + invoer; diagnose op bladniveau; elke nieuwe decodeerregel een corpusloze fixture naast zijn + corpuspin; élke taak minstens één mutatiebewijs in zijn acceptatie (planreview: X4/X5/X6 + misten dat in het concept — hersteld hieronder). +6. **Reviewpijplijn**: verse Sonnet-implementer per taak → review via de + `hyperkritische-review`-skill (tier 2/Opus voor motor-, meetlat- en grammatica-werk, tier + 1/Sonnet voor mechanisch werk) → fixronde bij dezelfde implementer → her-check bij dezelfde + reviewer; [BEVESTIGD]/[VERMOED]-labels blijven intact in de doorgeleiding. Mergen één taak + per keer met de tellers vóór/ná in het merge-commit. + +## §5 Openstaande eigenaarsbesluiten + +- **X-O1 — BESLOTEN (eigenaar, 2026-08-20): álles wordt geïmporteerd.** De leidende regel: + wie het bestand hier opent, ziet hetzelfde als in Primavera. Elk project in het bestand + wordt een eigen document in het bestaande multi-documentmodel; het project met de meeste + taken wordt het actieve tabblad (de export-vlag discrimineert niet — gemeten 15/15 en 4/4 + 'Y' — en "het eerste project" draagt in de Hotel-bestanden nul taken). Cross-project- + relaties worden `externalLinks` tussen de geopende documenten — let wel (her-check): + cross-project-relaties komen in het corpus exact nul keer voor, dus deze tak krijgt een + synthetische fixture en `externalLinks` is data, geen solverinvoer. De openingsmelding + benoemt hoeveel projecten er geopend zijn. **Lege projecten** (gemeten: 3 van de 15 in het + OZB-bestand en 2 van de 4 in het Hotel-bestand dragen géén taken) worden overgeslagen en + geteld in de melding — geen lege tabbladen (orkestratorbesluit, eigenaar kan overrulen). + Uitzondering: een project dat door een ánder project in hetzelfde bestand als baseline + wordt aangewezen (X-O2) opent níét als los document — in Primavera is een baseline ook + geen open project. **Begrenzing (her-check)**: die uitsluiting mag de verzameling nooit + leegmaken — bij wederzijdse of zelfverwijzing, of wanneer álle projecten als baseline zijn + aangewezen, opent alles alsnog als gewoon document mét melding; en de + meeste-taken-heuristiek voor het actieve tabblad telt uitsluitend de daadwerkelijk + geopende projecten. Gevolg voor de meetlat: het volledige orakel is bereikbaar; de + fidelity meet per project en pint per bestand de som. +- **X-O2 — BESLOTEN (eigenaar, 2026-08-20): baselines blijven gewoon bewaard.** Een + gekoppeld baselineproject (`sum_base_proj_id` → aanwezige PROJECT-rij) wordt als + OPS-baseline op het hoofdproject gematerialiseerd — **verplichte deliverable van de + etappe** (blijft buiten de nul-poort: baselines dragen geen orakelvelden voor de vier + assen). Dangling-tak verplicht: 9 van de 10 gemeten corpuskoppelingen verwijzen naar een + proj_id dat níét in het bestand zit — dangling wordt genegeerd én geteld in de + openingsmelding, nooit een crash of stil half project. `stack_data_center_baseline.xer` is + de positieve testcase. +- **X-O3 — BESLOTEN (eigenaar, 2026-08-20, conform voorstel).** TF/FF tellen volwaardig mee, met de formule uit + §1 en per as de dubbele teller (afwijkingen + meetbaar). Legt de residu-iteratie een + principieel P6-float-definitieverschil bloot dat niet via `schedulingOptions` te vangen is, + dan is dat een eigenaarsbeslispunt — nooit stilzwijgend versoepelen. +- **X-O4 — BESLOTEN (eigenaar, 2026-08-20, conform voorstel; gecorrigeerd na planreview).** Het corpus bevat géén enkel BOM-dragend bestand — de + BOM-tak is een formaliteit, **de heuristiek draagt alles**: geldige-UTF-8-toets over het + hele bestand, bij falen Windows-1252 (gemeten: 22 bestanden met high-bit-bytes, 12 daarvan + geen geldige UTF-8). Voor kleine bestanden is de toets principieel onbeslisbaar (3 + high-bit-bytes kunnen toevallig geldige UTF-8 zijn); daarom is de vermelding van de gemaakte + keuze in de openingsmelding **de eigenlijke mitigatie**, geen extraatje. Geen + gebruikersinstelling in deze etappe. +- **X-O5 — BESLOTEN (eigenaar, 2026-08-20): getalnotatie uit CURRTYPE, conform voorstel, + mét documentatie-eis.** De decimaal/duizend-tekens komen uit het bestand zelf en zijn niet + altijd letterlijk: naast `.`/`,` bestaan symbolische tokens (`ds_Period`, `dg_Comma`). + Regel: bekende tokens decoderen; ontbrekende CURRTYPE-tabel (62 van de 93 bestanden!) ⇒ + default punt-decimaal; een aantoonbaar komma-decimaal-bestand zonder CURRTYPE ⇒ typed fout + ("dit bestand kan ik niet betrouwbaar lezen") boven een stil verkeerd geparsed getal. Dit + raakt élk getal — duren en floats incluis — en krijgt een eigen fixture-batterij in X2. + **Documentatie-eis (eigenaar)**: dit gedrag wordt op drie plekken vastgelegd — een + docblok-uitleg bij de CURRTYPE-tweepas in `xerTables.ts` (X2), de typed-foutmelding zelf in + alle 14 talen (X2 werpt de getypeerde foutcode, X4a mapt hem naar de vertaalde tekst — + exact het bestaande `mppCode`-patroon), en een eigen paragraaf in de .xer-gids (X10) die in + gebruikerstaal uitlegt wat het bestand wel/niet zegt over zijn getalnotatie en wanneer de + app weigert te gokken. `verify:docs` bewaakt de gidsparagraaf zoals altijd. +- **X-O6 — BESLOTEN (eigenaar, 2026-09-04): main wordt gemergd in de etappebranch, geen + rebase.** De commit-identiteiten van de branch blijven intact (reviewrapporten en het + X11-browserbewijs pinnen erop). Alle vier de openstaande zijbranches + (`codex/xer-mcp-read`, `codex/xer-ext-read`, `codex/xer-x12-v2-rebased`, + `codex/xer-schedoptions-provenance`) blijven onderdeel van deze etappe. +- **X-O7 — BESLOTEN (eigenaar, 2026-09-04): P6-getrouwheid in drie lagen, bijgesteld na de + kalibratiemeting van dezelfde dag.** Primavera is per definitie de referentie voor wat een + XER-project toont. + 1. *Primavera-gedrag nabouwen* — **het zwaartepunt na de bijstelling**: conventieverschillen + (bv. LS/LF van voltooide taken = werkelijke datums) worden nagebouwd achter een bron-vlag + die uitsluitend de XER-lezer zet (O6-patroon); de motor verandert niet voor IFC/MSP- + projecten. Dossier `rehab-2.xer` (14.812 cellen) is hierbij motorsemantiek, geen + ontbrekende invoer: (i) late zijde + float van voltooide activiteiten, 5.620 cellen — P6 + rekent voltooide activiteiten door tot de statusdatum en geeft ze echte float; (ii) late + zijde van niet-gestarte activiteiten, 7.056 cellen — wij systematisch later, 140 + verschillende delta's, anker klopt. Samen 69% van het corpustotaal (18.398 gemeten + cellen), beide achter een XER-bron-vlag. Echte eigen rekenfouten die daarbij boven komen + worden voor alle projecten gefixt, met eigen test. + + **Stand 2026-09-05 — klasse (i) OPGELEVERD** (baan 7a, vlag + `p6CompletedLateFromRemainingWindow`). Corpusbreed gemeten: zesassige afwijkingen + 18.398 → 16.261, ls 4.791 → 3.901, lf 4.781 → 3.891, tf 4.592 → 4.235; alle beweging zit in + één bestand (rehab-2), de overige 33 zijn byte-identiek. Vier dingen die je moet weten vóór + je hier verder bouwt: + - De winst hangt aan klasse (ii). Per cel op rehab-2: 890 ls- en 890 lf-cellen worden exact, + **0** verslechteren; tf wint er 572 en verliest er **215**. Alle 215 zijn voltooide + taken waar P6 `tf = 0` geeft en onze afgeleide LS nog van een zélf foute opvolger-LS komt. + Vóór deze etappe was hun `tf = 0` degeneratie (LS = de historische actual-start), dus per + ongeluk goed. Een fixpuntoplossing op P6's eigen opvolgerwaarden dekt 98,8%, op de onze + 44,4% — klasse (ii) is dus de vervolgstap, niet een losstaand dossier. + - `drivingPath` verliest 6 cellen (`87418, 87419, 87420, 87421, 87422, 87426`). Dat is geen + ruis: het zijn zes OPEN taken waarvan ls/lf/tf nu exact P6 worden, inclusief `tf = 0`, + waarna onze `isCritical = tf ≤ drempel` ze kritiek maakt terwijl P6's `driving_path_flag` + `false` zegt. Een systematisch gat tussen OPS-kritiek en P6's driving-padbegrip, blootgelegd + dóór de verbetering; eigen vervolgetappe. + - De bewijsbasis is één bestand. Corpusbreed komen 2.042 voltooide taken door de poort, 2.040 + daarvan in rehab-2. Wat de rest beschermt is de nauwte van de poort (`DT_FixedDUR2` + + `rem_target_link_flag=Y` + expliciet targetvenster + `CP_Drtn`), niet de regel. + - De poort staat óók tussen deze regel en de dertien P6-23.12-casussen. In + `cases-p6-verified.json` casus `09-completed-successor` (open A → FS → voltooide B) geeft + P6 `LS = ES`, `TF = 0`; wij ook — maar alleen omdat de poort daar dicht staat op + `missingExplicitTargetWindow` (de echte bron `cases-import.xer` declareert wél + `rem_target_link_flag=Y` en `DT_FixedDrtn`, maar geen `target_end_date`). Gaat de poort op + diezelfde topologie open (sectie 3 van de check), dan geeft de regel A tf −5 waar P6 0 zegt. + `check-p6-verified-cases-engine.ts` haalt alle dertien casussen brongetrouw door de motor en + pint dat cel voor cel: 156 van 160 cellen eens met P6; de vier afwijkingen (casus 08 A en + casus 10 B, ES én LS) hebben één oorzaak buiten deze regel — onder `rem_target_link_flag=Y` + wordt de vroege start van een bezig zijnde taak de reststart, waar P6 de werkelijke start + opneemt (het orakel draagt daar het `A`-suffix); zie §9. Op de echte bytes zijn het 77/160 + door de projecteinde-fout (`docs/TODO.md`), 156/160 zonder die optie. + **Bewijsstatus klasse (i) (her-review 7a, 2026-09-07).** De poort van + `p6CompletedLateFromRemainingWindow` (`DT_FixedDUR2` + `rem_target_link_flag=Y` + expliciet + targetvenster + `CP_Drtn`) is CORRELATIONEEL, geen P6-mechanisme: de winst is echt en per cel + gemeten (rehab-2: ls/lf −969, tf −427 netto), maar het énige directe P6-bewijs voor de + topologie "open voorganger → voltooide opvolger" — casus 09 en 10 van `cases-p6-verified.json` + (echt P6 23.12) — spreekt de regel tegen zodra de poort daar opengaat (P6 geeft de open + voorganger tf 0, de regel tf −5). Op de echte bron (`cases-import.xer`) blijft de poort dicht + op `missingExplicitTargetWindow`; dat is een toevallige nauwte, geen verzoening. Wie de poort + verruimt, landt met deze regel op honderden ongevalideerde taken. Dossier, geen residu. + 2. *Ontbrekende instellingen afleiden* — **vervalt** (kalibratiemeting 2026-09-04, meting, + geen aanname): 384 combinaties van échte P6-instellingen over 34 orakelbestanden gaven een + beste denkbare winst van 1.033 van 18.398 cellen (5,6%), **0 bestanden met een uniek + optimum**, en op 2 van 9 valideerbare bestanden sprak de afleiding de gedeclareerde + SCHEDOPTIONS tegen. De vaste defaults uit §X5(b) blijven de weg; kalibratie wordt niet + gebouwd. + 3. *Opgeslagen datums tonen* (issue #63-mechanisme): voor XER staat "Datums zoals + opgeslagen" standaard aan zodra er restverschillen zijn; de melding noemt het aantal + afwijkende taken en die taken krijgen een markering in tabel en eigenschappenpaneel. + Bewerken of F5 verlaat de modus zoals nu. Vereist een §4.1-uitbreiding — het laag-3- + besluit: de zes orakelkolommen (`early_start_date`, `early_end_date`, `late_start_date`, + `late_end_date`, `total_float_hr_cnt`, `free_float_hr_cnt`) verhuizen uit de verboden bak + naar de vierde bak "uitsluitend weergave/meetlat, nooit solverinvoer" (zie §4.1); uren → + werkdagen via de taak-effectieve kalender, geen `?? rec.start`/`?? 0`-terugval voor XER + maar een "niet vastgelegd"-representatie. Dekking gemeten: 18.388 van 18.398 cellen. + Poort: nul verschil na laag 1 voor bestanden mét SCHEDOPTIONS; de rest per bestand gepind met + aantal en oorzaak. Meetbaar-tellers blijven gepind (M1). +- **X-O8 — VERVALLEN (eigenaar, 2026-09-04, na de kalibratiemeting): afgeleide instellingen + toepassen en melden.** Was bedoeld voor laag 2 van X-O7 (ontbrekende SCHEDOPTIONS afleiden + uit een begrensd rooster van échte P6-instellingen, met een opening die de afgeleide waarden + toont en linkt naar de projectinstellingen). Doordat die laag verviel — geen enkel bestand + met een uniek optimum — is X-O8 zonder object en vervalt eveneens. + +### Afspraken met de etappe taaktypes / opgeslagen werk (2026-09-04) + +- **Volgorde**: de taaktypes-etappe start op een main waar XER al in zit; de tweede merge + (origin/main ná f16bfff7, incl. contour-engine PR #95) volgt direct na de eerste. +- **Curves**: na de tweede merge vervangt `normalizeCurveValues` + `matchCurveValues` + (`contourEngine.ts`) de eigen `bestFitXerCurve`, met terugval op `P6_NAME_TO_CURVE` op naam; + de 21 punten gaan naar `ResourceAssignment.curveValues`. Corpusfeit: RSRCCURV bestaat niet, + alleen RSRCCURVDATA (kolommen `pct_usage_0`..`pct_usage_20`, lineair); `curv_id` is + corpusbreed 2× gevuld. Het pad hoeft correct te zijn, niet rijk. +- **Taaktype**: `Task.p6DurationType` (pset `OPS_P6Progress`, property `DurationType`) is de + opslag waar de taaktypes-spec naar verwijst; de spec definieert alleen de vertaling van + `mspTaskType`+`effortDriven` naar dezelfde vier keuzes. Het neutrale documentveld bouwt de + taaktypes-etappe, niet XER. `p6xmlReader` leest `` nog niet: meenemen zodat + beide P6-paden gelijk zijn. Corpusfeit: `DT_FixedRate` 0× in het corpus, `DT_FixedQty` 153× + in 2 bestanden. +- **Opgeslagen werk**: geen nieuw modelveld vanuit XER. De spec definieert het eersteklasveld + op `ResourceAssignment`; XER zet het daarna over uit het bronarchief — alleen wanneer + `target_qty` afwijkt van `target_drtn_hr_cnt × target_qty_per_hr`, anders blijft het veld + afwezig (byte-identiek). Meetlat: HarbourPointe_AssistedLiving (98 afwijkende rijen, factor + 3), Harbour Point DCP-03 (factor 4; `remain_qty` zonder resttarief), p6_torture_test_v1 + (duur 0 met werk), plus 263 rijen zonder `target_qty_per_hr` in 5 bestanden. Corpusbreed: 176 + afwijkende werkrijen in 5 bestanden; het resttarief wijkt af in 27,5% van rehab-2. +- **Additief blijven**: `types/task.ts`, `types/resource.ts`, `taskSlice`, `resourceSlice`, + `documentContract`, `ifcPsets`, `taskColumnRegistry`, `fieldCoverage`. TODO-registratie in de + sectie "Contour-engine (2026-09)". + +- **X-O7 — VASTGELEGD (2026-09-05, met één genoteerde uitzondering): een fidelity-stap mag geen + as slechter maken.** De regel bestaat om te voorkomen dat een totaalcijfer gekocht wordt door + fouten van de ene as naar de andere te verschuiven. Hij verbiedt níét elke stap die een + *compensatiefout* blootlegt. + + **Uitzondering, etappe 7b (weekend-klemherstel in `xerCalendarData.ts`).** Deze stap verbetert + ls/lf/tf/ff met 1.426 cellen en verslechtert es/ef met 449 (es 618→940, ef 813→940 op + `rehab-2.xer`; 344 resp. 327 taken nieuw fout, 281× één en 56× twee werkdagen te laat, vrijwel + alle op de 842-kalendergroep). De uitzondering is toegestaan omdat de BRON de kalender bevestigt + en de rest als dossier is geregistreerd: + + 1. P6 zet nul ES/EF/LS/LF ín alle negen gereconstrueerde blokken tegen 811 in de drie dagen + eromheen, en die 811 zit volledig op de vier blokken binnen de projectperiode + (167/214/178/252) terwijl de overige vijf 0 ín én 0 in de rand hebben en dus niets bewijzen; + 2. P6's eigen opgeslagen vensters tellen op de OUDE kalender 10,00 werkdagen voor een taak van 7 + (`V3109400`) en 24,00 voor een taak van 21 (`V3109300`) — intern inconsistent — en op de + gereconstrueerde kalender exact 7,00 en 21,00. + + Daaruit volgt dat een deel van de eerdere es-treffers een compensatiefout was tussen een te korte + kalender en een te vroeg startanker. Zulke fidelity is niet beschermenswaardig, en een regel die + deze stap blokkeert maakt de volgorde onmogelijk: het ankergat is niet te diagnosticeren zolang de + kalender de fout in de vensterlengte verstopt. **Een geschonden planregel zonder geschreven + uitzondering is een tijdbom voor de volgende reviewer — vandaar deze notitie.** + +## §6 Banen en taken + +Vier banen, elk een eigen worktree (`.claude/worktrees/xer-{meetlat,lezer,motor,data}`). +X-nummering; volgorde binnen een baan is dwingend, banen parallel na X0/X1. + +### SERIEEL VOORAF + +**X0 — Typen, harness-skelet, superset-registratie.** Task-/ImportResult-velden als +compile-gedekte typen: P6-duration-type en -activiteitstype als eigen opgeslagen velden naast +`mspTaskType` (veld-als-signaal geldt ook voor typen), mét de vastgelegde afspraak dat de +latere taaktypes-etappe beide naar één interne superset mapt; suspend/resume-herkomstvlag +(X7); corpusscan-tooling (`OPS_XER_CORPUS`). Geen registry-entry (die komt bij X4). +**Acceptatie**: typecheck-poorten; lege-lezer-run produceert een lege maar welgevormde +baseline; mutatiebewijs: een veld uit de typelijst verwijderen ⇒ compile-fout in het +harness-skelet. + +**X1 — De meetlat éérst (baan M).** Eerst de meetkern eerlijk maken (planreview F2): +`measureFidelity` roept vandaag hard `solveMppBytes`/`scanGroundTruthTasks` aan — X1 begint +met het uitfactoriseren van het formaat-agnostische deel (`classify()`, de rijvorm, de +delta-administratie) naar een gedeelde kern, mutatie-bewezen byte-identiek voor de bestaande +.mpp-suite. Daarop: `xerGroundTruth.ts` (onafhankelijke %T/%F/%R-scan, zes assen + status + +act-datums + `driving_path_flag`, eigen encoding-afhandeling), `xerFidelity.ts`, +`check-xer-fidelity.ts` met **per-project-meting binnen het bestand** (X-O1: één bestand +kan meerdere projecten dragen; de grondwaarheidsscan leest álle taken, een opgelost document +draagt er één project van — de meetkern krijgt dus een expliciete per-project-lus in plaats +van de bestaande alles-of-niets-assertie op de taaktelling) en per-bestand-pinning van de +som, gededupliceerd per §2 (byte-hash + schema-vingerafdruk), per as afwijkings- én +meetbaar-tellers, +`OPS_XER_FIDELITY_REPORT`-modi, reason-verplichting bij elke niet-nul-pin. Plus de +p6-comparison-extractie naar `cases-p6-verified.json`: uitsluitend de `*_p6`-kolommen, mét +tijden, met een generator-script en een herkomstkop die de M4-voorbehouden documenteert. +**Acceptatie**: mutatie-bewezen (meetlatveld verleggen ⇒ rood; meetbaar-teller verlagen ⇒ +rood); de .mpp-suite draait byte-identiek op de uitgefactoriseerde kern. + +### BAAN F — formaat en lezer + +**X2 — XER-grammatica.** `src/services/xer/xerTables.ts`: ERMHDR (versie + veld 9 = +default-valuta), %T/%F/%R/%E, tabs zonder escaping, `""`-quotes, DEL-DEL-multiline in +notitievelden (incl. BOM/NUL-strip — herkomst MPXJ `NotesHelper`), de lege-eerste-token- +continuatieregel, onbekende tabellen overslaan, en de **CURRTYPE-tweepas** conform X-O5 +(inclusief token-decodering en de geen-CURRTYPE-default). Encoding per X-O4. Fout-tolerantie +als bewuste keuze: kapotte rijen verzamelen in een import-rapportstructuur (geen stille skip, +geen harde crash); de openingsmelding (X10) toont het aantal. De enum-tokenregel +(§4.7) geldt in de semantische lagen (X4a/X5), niet hier — de tokenizer kent geen enums; +X2 levert alleen de rauwe tokens plus de lege-kolomnaam-afhandeling (afsluitende tab). **Failure-mode-model +(gecorrigeerd, planreview)**: de echte poort is *verplichte P6-kolommen ontbreken ⇒ typed +fout* — het corpus bevat namelijk pseudo-XER-bestanden mét `%F`-headers maar niet-P6-kolommen +(`p6xer-basic.xer`: `Task_ID`/`Start_Date`/`Duration`); die mogen nooit als leeg-maar-geldig +project openen. **Acceptatie**: de robuustheids- en `kedular-*`-bestanden gepind op hun +verwachte rapportinhoud (NB: `p6xer-encodings.xer` is géén kapot bestand — geldige UTF-8 met +verzonnen tabelnamen; pin hem als "onbekende tabellen overgeslagen"); synthetische fixtures +per grammaticaregel; mutatiebewijs: CURRTYPE-tweepas uitschakelen ⇒ het komma-decimaal-fixture +ROOD. + +**X3 — De kalenderdecoder (DE KRITIEKE TAAK, zie §1-granulariteit).** +`src/services/xer/xerCalendarData.ts`: de structured-text-grammatica +(`(nr||naam(veld|waarde|…)(kinderen…))`, DEL-DEL-gescheiden) als eigen tokenizer; DaysOfWeek +(dag 1-7, s/f-uurblokken, 24-uurs én AM/PM-notatie), Exceptions (`d|n` = dagen sinds +1899-12-30, mét of zónder afwijkende uren), lege kalender ⇒ P6-default ma-vr 08:00-16:00, +`base_clndr_id`-hiërarchie in een tweede pas, `clndr_type`, en de uren-per-periode-velden met +de afleiding-uit-weekuren als **hoofdpad** (gemeten: in `rehab-2.xer` is `day_hr_cnt` voor +alle 124 kalenders leeg). **De XER-uurmodus-discriminator wordt hier expliciet uitgeschreven +en apart geaccepteerd (planreview V1)**: de bestaande promotieregels (a) >1 band/dag, +(b) middernacht-wrap, (b2) ≥1440 min dekken een één-bands-kalender 08:00-16:00 niet, terwijl +het orakel wél 16:00-tijden draagt — de XER-lezer krijgt een eigen (c)-anker +("XER-kalenderbanden dragen kloktijden ⇒ promoveerbaar"), met blast-radius-meting over het +corpus en een pin op het aantal gepromoveerde kalenders per bestand. **Acceptatie**: +corpusloze fixtures per grammatica-element (incl. AM/PM en de epoch-conversie); corpuspin op +het 124-kalender-monster; pariteitstest tegen de P6-XML-kalenderroute op een equivalent paar — +waarvoor `parseP6StandardWorkWeek` (nu niet-geëxporteerd, `p6xmlReader.ts:96`) geëxporteerd +wordt of de toets via `readP6XML` loopt; mutatiebewijs: de weekuren-afleiding uitschakelen ⇒ +de rehab-pin ROOD. + +**X4a — Kern-mapping + registry-entry (enkelproject).** `src/services/xer/xerReader.ts`, +eerst voor het geval één (niet-leeg) project — dit deblokkeert baan S en D. De PROJECT-rij +van dat ene project hoort hier (delta-check: die viel bij de knip tussen wal en schip): +projectnaam, datadatum, projectkalender-verwijzing en default-valuta; de *selectie* uit +meerdere projecten is X4b. PROJWBS (sorteren op `(parent_wbs_id, seq_num)`; +**WBS-rijen worden verzameltaken in onze bestaande structuur, nooit extra bladtaken — de +taaktelling moet 1:1 op het orakel passen, anders klapt elke meting** — planreview V8), TASK +(statussen; milestones uit het activiteitstype, **case-insensitief** — het corpus bevat +`TT_mile`/`TT_finmile`-kleine-lettervarianten; `TT_LOE` → `isHammock`; `TT_Rsrc` (2 corpusrijen) +en `TT_WBS` (1 rij) worden **als data gelezen, als gewone taak gepland en in de melding +genoemd** — geen eigen rekenmodel deze etappe, wél elk een synthetische fixture; duration- en +activiteitstype als opgeslagen data), TASKPRED (`PR_*` én de prefixloze variant uit 3 echte +bestanden — 5 dragers, waarvan 2 pseudo-XER die X2 weigert), constraints (`CS_*` incl. mandatory → `hard`), `ExternalRelation` voor +cross-project-randen. Format-registry-entry (`kind: 'text'`, lazy chunk, `canBeSaveTarget` +blijft IFC-only), i18n-foutmeldingen 14 talen. **Acceptatie**: eerste fidelity-nulmeting +draait en pint; `crawl-xer/p6diff-baseline.xer` (8 taken, alle zes assen) en +`crawl-xer-extra/p6difftool/sample-target.xer` exact op de datum-assen (planreview M5: de +`xer-corpus/cases/*`-fixtures dragen géén orakel en zijn parser-poort, geen fidelity-poort); +mutatiebewijzen: de prefixloze-`PR_`-tak uit ⇒ de **3** echte bestanden met een prefixloos +`pred_type` ROOD (her-check: 5 dragers waarvan 2 pseudo-XER die X2 weigert); de +case-insensitieve typematch uit ⇒ de kleine-letter-fixture ROOD. + +**X4b — Meervoudige import en baselines (X-O1 + X-O2 — dit ís de X-O2-taak).** Het +meervoudig-`ImportResult`-pad in de open-pijplijn (het eerste formaat dat één bestand tot +meerdere documenten opent): projectselectie en lege-project-regel per X-O1, +baseline-materialisatie per X-O2 (gekoppeld baselineproject → OPS-baseline op het +hoofdproject; dangling genegeerd + geteld; de begrenzingsregel dat de uitsluiting de +verzameling nooit leegmaakt, incl. cyclus- en zelfverwijzing), `externalLinks` met +synthetische fixture (nul corpusdekking), en een open-tijd-meting op het +15-projecten-bestand. **Acceptatie**: `stack_data_center_baseline.xer` levert één document +mét OPS-baseline; het OZB-bestand opent 12 documenten (3 lege overgeslagen en gemeld, 9 +dangling-baselines gemeld); mutatiebewijzen: de begrenzingsregel uit ⇒ de +zelfverwijzings-fixture opent niets ⇒ ROOD; baseline-materialisatie uit ⇒ de +stack-case ROOD. + +### BAAN S — motor en semantiek + +**X5 — SCHEDOPTIONS en de defaults-vraag.** Twee helften, beide verplicht: +*(a) de tabel lezen* — `sched_calendar_on_relationship_lag` → `lagCalendar` (P6-default: +predecessor), retained logic/progress override → `progressMode`, critical-definitie en +float-modus; **elke kolom uit de corpus-union wordt belegd als gemapt / genegeerd-met-reden / +TODO** (planreview V7 — o.a. `sched_use_project_end_date_for_float` en +`sched_lag_early_start_flag` raken float en hebben geen tegenhanger in `SchedulingOptions`; +die worden op zijn minst geregistreerd), en de mapping verhoudt zich expliciet tot wat +`mppReader`/`mspdiReader` al zetten én tot de `OPS_SchedulingOptions`-IFC-round-trip. +*(b) de defaults-paragraaf (planreview B2 — het meerderheidsgeval!)*: 36 van de 60 +orakelbestanden hebben géén SCHEDOPTIONS, waaronder `rehab-2.xer` (60% van het orakel). Voor +die populatie geldt een expliciet vastgelegde default-set, gefundeerd op **de gemeten +meerderheid in de SCHEDOPTIONS-dragende bestanden** (her-check: `sched_float_type` = `FT_FF` +in 41/50 rijen ⇒ finish float — een échte gedragsomslag t.o.v. onze 'smallest'-huisdefault; +retained logic 48/50 aan; open-eindes-niet-kritiek en lag-op-voorgangerskalender sporen met +onze defaults), per default blast-radius-gemeten over het corpus en gepind. **Acceptatie**: de +in-progress/retained-logic- en completed-successor-cases uit `cases-p6-verified.json` groen; +de 36-zonder-SCHEDOPTIONS-populatie per default-keuze gemeten en gepind; mutatiebewijs: +`lagCalendar` naar successor forceren ⇒ de multi-kalender-case ROOD. + +**X6 — Resources en toewijzingen** *(parallel aan X5 — geen afhankelijkheid, planreview §5)*. +RSRC/RSRCRATE/TASKRSRC: rollen-vs-resources-ID-naamruimten, units-schalen (1.000.000- en +×100-conventies per veld — herkomst MPXJ, onafhankelijk geverifieerd tegen corpuswaarden), +resourcekalender-koppeling. Curves-dossier klein en afgebakend: 2 corpusrijen met `curv_id` +plus RSRCCURVDATA (21-punts verdeling) — best-fit naar onze curve-typen, met de rauwe 21 +punten als opgeslagen data (eigenaarsprincipe; `timephasedContours`-patroon). **Acceptatie**: +corpuspins op `Roads_Project_TEC.xer` en `rehab-2.xer`; geen datumbeweging op bestanden zonder +resources; mutatiebewijs: de units-schaal op ×100 zetten ⇒ de resourcecase ROOD. + +**X7 — Suspend/resume en voortgang.** P6's suspend/resume ↔ `TaskTime.stop`/`resume`: het +veld bestaat, maar de solver-semantiek eromheen is de MSP-conventie uit etappe 1 — eerst meten +(22 corpustaken met suspend), dan per verschil een bron-vlag (O6-patroon). Voortgang per +`complete_pct_type` met **een expliciet criterium per variant (planreview V5)**: `CP_Drtn` +(16.813 taken — percentage stuurt restduur), `CP_Phys` (1.492 — percentage stuurt de datums +NIET; restduur komt uit `remain_drtn_hr_cnt`) en `CP_Units` (8 — idem, units-gedreven; als +data gelezen, gedrag gelijk aan CP_Phys deze etappe, geregistreerd). `expect_end_date` (246 +cellen, 3 bestanden) uitsluitend gehonoreerd wanneer `sched_use_expect_end_flag` het zegt +(planreview V6) — anders als data bewaard. **Acceptatie**: het out-of-sequence-scenario uit de +P6-geverifieerde cases groen; de suspend-dragende bestanden gepind; mutatiebewijs op elke +nieuwe vlag-tak én op de CP_Phys-scheiding (percentage wijzigen ⇒ datums bewegen NIET). + +### BAAN D — data en randen + +**X8 — Activity codes, UDF's, notities.** ACTVTYPE/ACTVCODE/TASKACTV → `activityCodeTypes` +(119.878 corpus-koppelingen — prestatie meten op `rehab-2.xer` en het Hotel-schema); +UDFTYPE/UDFVALUE → `customFieldDefs`; memo-tabellen → taaknotities (DEL-DEL uit X2). +**Acceptatie**: IFC-round-trip aangetoond; tellingen gepind; mutatiebewijs: de +TASKACTV-koppeltabel overslaan ⇒ de telling-pin ROOD. + +**X9 — Documentcontract, round-trip, exportranden én de P6-XML-drift.** Alle nieuwe velden +door documentcontract en IFC-round-trip; exportranden warnen (patroon etappe 1); +`moveProject`-verdicten; MCP-leeskant conform het etappe-1-besluit. **Nieuw (planreview V3)**: +na deze etappe leest een `.xer` méér P6-data dan onze eigen `.xml`-lezer (activity codes, +UDF's, typen, schedulingOptions) — die asymmetrie wordt niet stil: een geregistreerd besluit +plus TODO ("p6xmlReader bijtrekken tot pariteit") én een pariteits-smoketest die de asymmetrie +expliciet documenteert in plaats van hem te laten verrassen. **Acceptatie**: het +Z14-mutatiestramien (property weg ⇒ rood op precies dat veld; byte-identieke examples). + +**X10 — Melding en gidsen.** Openingsmelding naar het Z16-model: echte tellingen (kapotte +rijen; geopende projecten, overgeslagen lege projecten en als baseline gematerialiseerde +projecten (X-O1/X-O2); dangling baselines; encoding-keuze bij niet-ASCII (X-O4)), severity +info, 14 talen, CLDR-pluralen. Gidsen (nl+en): "Primavera P6 +(.xer) openen" naar het model van de .mpp-gids — elke claim code-/testverwezen, mét de +X-O5-getalnotatie-paragraaf (de derde documentatieplek uit dat besluit), en eerlijk over wat +(nog) niet meekomt (TT_Rsrc/TT_WBS-rekenmodel, curves-als-verdeling, P6-XML-asymmetrie — +multi-document komt per X-O1 juist wél mee en staat in het geopende-projecten-deel van de +melding en de gids). **Acceptatie**: de Z16-mutatiebewijzen (melding/i18n/manifest). + +### SERIEEL — afronding + +**X11 — Browser-gebruikstest** (aparte agent, tier 1; mag parallel aan X12's residu-iteratie): +de dossierselectie openen (`p6diff-baseline`, `rehab-2.xer`, multi-kalender, negatieve float, +het torture-bestand, en het 15-projecten-bestand als multi-document-stresstest: 12 documenten +in één keer — raakt de auto-save (één recovery-snapshot per document), de documenttabbalk bij +12+ tabbladen en de Ctrl/⌘ 1–9-navigatie die maar tot negen reikt), IFC-opslaan/heropenen met +veldbehoud, +F5-stabiliteit, meldingen en gidslinks, taalwissel, undo/documentwissel — het Z18-draaiboek, +plus: blijft de app vlot op het 119k-koppelingen-bestand. + +**X12 — Residu naar nul en de eindpoort.** Detail-rapportage per as → classificeren op +bladniveau → echte fout fixen met bewijs, of escaleren (X-O3); "pinnen met reden" bestaat niet +als uitweg. Daarna `GOAL_ZERO_DEVIATIONS_XER` aan: nul op alle afwijkingstellers, per as +`gemetenExact === meetbaar` én het **gepinde meetbaar-aantal** zelf (planreview M1 — een as +die stil naar nul meetbaar zakt is rood), reason-verbod, en de tweeledige dedup-bewaking (byte-hash + schema-vingerafdruk, §2). +TODO-registraties (XER-export; p6xml-pariteit; driving-path-as als poortkandidaat). Hyperkritische Opus-eindreview over de volledige etappe-diff, inclusief de +whitelist-sluiproute-scan van §4.1. + +## §7 Parallellisering + +``` +X0 ─ X1 (serieel; X1 bevat de meetkern-uitfactorisering + per-project-lus) + ├── baan F: X2 → X3 → X4a → X4b ─┐ X3 = kritieke taak (uurmodus beslist alles) + ├── baan S: (na X4a) X5 ─┐ │ + │ (na X4a) X6 ─┤ → X7 ─┤ + ├── baan D: (na X4a) X8 → X9 ──────┤ + │ (na X9 én X4b) X10 ──┤ + └──────────────── X11 ∥ X12 (X11 mag parallel aan de residu-iteratie) +``` +X4b (meervoudige import) loopt parallel aan de banen S en D; X10 wacht op X9 én X4b (de +meldingstellingen over geopende/lege/baseline-projecten bestaan pas met X4b), en X11 wacht +op X4b voor de 12-documenten-stresstest. +X5 en X6 zijn onderling onafhankelijk en lopen parallel; X7 wacht op beide (voortgang leunt op +actuals-invarianten én toewijzingen). De meetlat (X1) staat vóór alles — wie eerst bouwt en +dan meet, meet zijn eigen aannames. + +## §8 Risico's, eerlijk benoemd + +1. **De float-assen zijn onontgonnen terrein**, en het defaults-gat maakt het scherper: voor + 60% van de orakelbestanden (36 van 60, incl. de grootste massa) bepalen ónze + default-aannames de late datums en de float. X5(b) is daarom geen administratie maar een + meetprogramma. Negatieve float zit in **zes** bestanden (45/6/6/3/2/1 taken — planreview + B4; het concept zei twee), waarvan vijf zonder SCHEDOPTIONS — dat wordt een dossier. +2. **`clndr_data` is de bytepuzzel én de kritieke taak** — de uurmodus-promotie (§1) en de + uren-per-dag-afleiding (hoofdpad, niet randgeval) bepalen de meetbaarheid van élk bestand. +3. **Encoding en getalnotatie zitten ín het bestand** (X-O4/X-O5); fouten hier zijn stil. + Vandaar eigen fixture-batterijen in X2 en de meldings-mitigatie. +4. **Schaal**: `rehab-2.xer` (6.976 orakeltaken, 52.640 toewijzingen, 81k code-koppelingen) is + ~2× het grootste bestand dat de app ooit las; X11 meet het expliciet. +5. **Scope**: multi-document-import en baseline-materialisatie zijn per eigenaarsbesluit + onderdeel van de etappe geworden (X-O1/X-O2) — dat is de bewuste verzwaring; XER-export, + het TT_Rsrc-rekenmodel en p6xml-pariteit blijven begrensde vervolgtrajecten. De goal is + lezen-getrouw-op-vier-assen; de multi-documentroute is er de gebruikerszichtbare helft van. + +## §9 Dossiers uit de etappe + +### 7b-4 — forward-anker na gereconstrueerde kalenderblokken + +**Status:** geregistreerd, niet gebouwd. Ontstaan bij etappe 7b (weekend-klemherstel), her-check +2026-09-05. + +**Wat er staat.** Op `rehab-2.xer` verschuiven 344 ES- en 327 EF-cellen van goed naar fout +(es 618→940, ef 813→940), 343 daarvan op de 842-kalendergroep; 281× één werkdag te laat, 56× twee, +2× zes en 3× elf. Ze lopen van 2008-11 t/m 2010-03, dus vanaf direct ná het oktoberblok. + +**Wat het NIET is.** Geen kalenderfout. Gemeten op de gereconstrueerde kalender telt ons ES→EF-venster +voor alle vijf de getraceerde taken exact evenveel werkminuten als dat van P6: + +| taak | duur | P6-venster op de OUDE kalender | op de NIEUWE kalender | ons venster (nieuw) | +|---|---|---|---|---| +| `V3109400` | 7 d | 10,00 werkdagen | 7,00 | 7,00 | +| `V3109300` | 21 d | 24,00 werkdagen | 21,00 | 21,00 | +| `V3109420` | 7 d | 7,00 | 7,00 | 7,00 | +| `V3109220` | 3 d | 3,00 | 3,00 | 3,00 | +| `V3109480` | 7 d | 7,00 | 7,00 | 7,00 | + +De duurwandeling klopt dus; alleen het STARTANKER ligt één tot twee werkdagen te laat. Concreet: +P6 zet `V3109400` op 2008-12-04 → 12-24, wij op 12-06 → 12-25 — hetzelfde venster van zeven +werkdagen, twee werkdagen naar rechts. + +**Waar te beginnen.** Die vijf taken, en de vraag wat P6 aan de forwardkant doet dat wij niet doen +zodra een keten een meerdaags vrij blok kruist (kandidaten: retained-logic/voortgangssemantiek rond +de statusdatum, of de `ownAnchor`-vloer voor taken met een voorganger vlak vóór een blok). + +**n=1-basis.** Het weekend-klemherstel zelf is afgeleid uit één bestand van 93 +(`crawl-xer-extra/jailaff-xer-splitter/rehab-2.xer`, P6 6.0-export, kalenderdata gedeeld door 119 van +de 124 kalenders). De reconstructie wordt binnen dat bestand door de bron bevestigd (zie de twee +metingen bij X-O7 hierboven). Wat er níét is: een tweede bestand. MPXJ, de referentie-implementatie, +kent het verschijnsel niet en leest de `Exceptions`-lijst letterlijk +(`TableContextReader.processCalendarExceptions`). De poort is daarom bewust record-lokaal en +conservatief — drie eisen op het record zelf, corpusloos gepind in `check-xer-calendar-data.ts` +sectie 22a–22g. Duikt er een tweede bestand op, dan bevestigt of ontkracht dat de regel; het mag er +niet stil op meeliften. + +**Meting 2026-09-07 (volledig corpus, per cel, pre-7b `0b8dbeb3^` → nu).** 7b maakte op rehab-2 +exact 344 ES- en 327 EF-cellen fout die daarvóór exact waren (bevestigt de tabel hierboven). Van +die 344 taken houden 249 hun venster (ES en EF verschuiven identiek) en liggen 247 één tot twee +werkdagen naar rechts (237× één kalenderdag, 10× twee) — de 7b-4-signatuur; de overige 95 hebben +een niet-identieke ES/EF-verschuiving en horen dus niet (alleen) bij dit dossier. Over ÁLLE huidige +ES/EF-afwijkingen van rehab-2 (940 taken, 1.880 cellen) dragen 345 taken die signatuur: dossier +7b-4 verklaart dus ten hoogste **690 cellen** (2 × 345), waarvan 494 door 7b zelf foutgemaakt. +Bouwbesluit is aan de eigenaar (§10.f); niets gebouwd. + +### rem_target_link_flag=Y en de vroege start van bezig zijnde taken (her-review 7a, 2026-09-07) + +**Status:** geregistreerd, niet gebouwd. Bron: `check-p6-verified-cases-engine.ts` op de +brongetrouwe transcriptie én de echte `cases-import.xer` (156/160 zonder de projecteinde-fout). +De vier resterende cellen (casus 08 A en casus 10 B: ES én LS) hebben één oorzaak: onder +`rem_target_link_flag = Y` zet de lezer `p6UseRemainingStartForProgress`, en dan wordt de VROEGE +START van een bezig zijnde taak de reststart (statusdatum, of ná de voorganger), terwijl P6 23.12 +in `early_start_date` de WERKELIJKE start opneemt en de reststart apart in `restart_date` (bak 2) +bewaart. Klasse (ii)-materiaal (bezig zijnde taken rond de statusdatum, X5-vlag), geen 7a. + +### Projecteinde valt terug op de projectstart bij leeg `plan_end_date` (her-review 7a, 2026-09-07) + +**Status:** geregistreerd in `docs/TODO.md`, niet gefixt. `sched_use_project_end_date_for_float = Y` +zonder `plan_end_date` en zonder één `target_end_date` (de echte `cases-import.xer`) ⇒ het +taak-afgeleide projecteinde is de projectSTART en de hele late zijde verankert daarop: 77/160 +P6-cellen zoals gelezen, 156/160 met de optie uit. Gepind in sectie 7 van de engine-check. + +### De ONBEKENDE categorieën, gemeten (2026-09-07, volledig corpus) + +- **Sameday** (zelfde dag, ander tijdstip): 415 cellen corpusbreed, ongewijzigd sinds de + v2-baseline: `groupdocs-conversion/sample.xer` 280 (70 per as, alle vier de datumassen), + `Roads_Project_TEC.xer` 67 (ls 58, lf 9), `northstar-previous`/`riverside-previous` 24 elk + (es/ef 12), `Hotel_Construction_TEC.xer` 13, `Sample_Construction_TEC.xer` 4, + `planning-risk-intelligent` 2, `Harbour Point DCP-03` 1. Plan §1 eist nul; open categorie. +- **Driving path** (rapportage-as, geen poort): 417 afwijkende cellen op 13.596 meetbare — + 301× P6 `false`/wij `true`, 116× andersom — over 15 bestanden: Hotel 88, rehab-2 86, + groupdocs 70, TERMINAL BUILDING-AIRPORT 35, OZB 29, gimmer-crag 24, meridianiq 20+18, ashspace + 10, p6diff 8+7, HarbourPointe 7, Harbour Point DCP-03 7, sample-target 4, stack_data_center 4. + Of die as poort wordt is een eigenaarsbesluit (§3). + +## §10 Overdrachtsstand 2026-09-07 — herzien na de integratie (avond) + +*Herschreven door de Claude-sessie die op 2026-09-07 de etappe overnam, 7a en laag 3 landde en de +PR naar main voorbereidde. De ochtendversie van §10 (kop `1206e010`) is vervangen; wat daar +stond en nog geldt is hier opgenomen. Labels: ZEKER (zelf gemeten of in git controleerbaar), +AFGELEID (uit een subagentrapport, niet zelf nagemeten), ONBEKEND.* + +### 10.0 Waar het werk staat + +- **ZEKER.** Etappebranch `claude/file-formats-support-phase-3-a0ebe2`; bevat origin/main t/m + `d808fdec` (PR #107). Gelande banen sinds de ochtend, in deze volgorde en telkens met de X12- + tellers vóór/ná in het merge-commit: `claude/xer-7a` (kop `63901793`, = `bd1a7ab3` + fixronde op + de her-review), `claude/xer-6` (kop `dd8331a1`, = `6f6013e6` + fixronde op de her-check; daarna + ronde 2 `213d6f5a` op de etappebranch), origin/main (`365ff274`), één herpin (`c6f4ac2a`), docs + (`15e76f23`), 7a ronde 2 (`1339d9b9`). De zijbranches `claude/xer-6`/`-7a` op origin dragen de + oude koppen; de fixcommits staan alleen lokaal en in de merges — historie, geen werk meer. +- **ZEKER.** Reviewspoor van deze dag (rapporten in de sessie-scratchpad, niet in de repo): 7a + her-review → LANDEN-MET-FIXES (7 must-fixes) → fixronde → her-check LANDEN-MET-FIXES (2 doc- + punten + 3 kleine) → ronde 2; laag 3 her-check → LANDEN-MET-FIXES (5 zwaar) → fixronde → her-check + LANDEN-MET-FIXES (R1 blokkerend + R2–R5) → ronde 2. De letterlijke bevindingenlijsten staan in de + commitberichten van `63901793`, `dd8331a1`, `213d6f5a`, `1339d9b9`. + +### 10.a Het corpus + +- **ZEKER.** Volledig reproduceerbaar op een verse machine: de privé-repo + `OpenAEC-Foundation/ops-xer-corpus` (kop `e141664`) draagt `crawl-xer/`, `crawl-xer-extra/` (met + `MANIFEST.md`), `pmxml-samples/`, `CORPUS-OVERZICHT.md` en `MANIFEST.md`; de zes git-clones uit de + ochtendversie (cpp-cpm-engine `c279a5c`, delay-analysis-toolkit `ecab947`, mpxj `68d36e9` — alleen + de `.xer`-fixtures zijn nodig —, P6-Viewer `c32dc4c`, ProjectLens `fe33382`, p6flow `637c12e`) + ernaast in dezelfde corpusroot (zonder `.git`) geeft **93/93 sha256-treffers, 45/45 orakel, 0 + extra `.xer` buiten het manifest**. `OPS_XER_CORPUS` wijst naar die root. De corpusgebonden checks + eisen het VOLLEDIGE manifest (`buildXerTargetBaseline` gooit bij elk ontbrekend bestand); een + partieel corpus meet dus alleen via een eigen omweg — die is na 93/93 weggegooid. +- **ZEKER (nog steeds).** De corpus-`.xer`-bestanden liggen buiten de repo; CI draait corpusloos. + +### 10.b De stand van X12 (productfidelity) + +- **ZEKER.** Eén herpin (`c6f4ac2a`), volledig corpus (34 entries/47 projecten/13.982 taken): + **18.398** (v2-baseline, vóór 7b) → **17.421** (kop `1206e010`, ná 7b) → **15.056** (nu; 7a+7b, + laag 3 en main veranderen daar niets aan — gemeten, niet aangenomen). Alle beweging in rehab-2; + de overige 33 entries zijn byte-identiek aan de v2-baseline. Per as: 7b es +322/ef +127 slechter + (gedocumenteerde uitzondering), ls/lf/tf/ff beter; 7a ls −969, lf −969 (diff→exact, 0 andersom), + tf netto −427 (642 beter, 215 slechter — alle 215 `TK_Complete`/`DT_FixedDUR2` met P6 tf 0), + drivingPath 80→86. Herpind: v2-baseline, in-bron `EXPECTED` van de corpusloze poort (met de + attributie als toelichting), blast-radius-baseline, task-replay-pin, en de v1-herbasis (harness + gaf tot 7a de P6-opties niet door; productsolve op die twee bestanden ongewijzigd). +- **ZEKER (sameday).** Corpusbreed 415 cellen, ongewijzigd sinds de v2-baseline (es 96, ef 97, + ls 129, lf 93, tf 0, ff 0); per bestand in §9. Open categorie — plan §1 eist nul. +- **ZEKER (driving path).** 417 afwijkende cellen op 13.596 meetbare (301× P6 false/wij true, + 116× andersom), 15 bestanden; per bestand in §9. Rapportage-as; promotie tot poort is een + eigenaarsbesluit dat niet is genomen. +- **ZEKER (7b-4).** Per cel gemeten (§9): 7b maakte 344 ES + 327 EF fout op rehab-2; 247 taken + dragen de 7b-4-signatuur (venster behouden, 1–2 werkdagen naar rechts); over alle huidige ES/EF- + afwijkingen van rehab-2 verklaart het dossier ten hoogste 690 van 1.880 cellen. Niet gebouwd. +- **ZEKER.** Mét corpus is de planningssuite rood op uitsluitend de drie X12-nuldoel-regels + (by design zolang het nuldoel niet gehaald is); de vierde regel ("baseline is de verse meting") + is sinds de herpin groen. + +### 10.c De stand van X11 (browsergebruikstest) + +- **ZEKER.** Nieuw: `tests/browser/recorded-dates.spec.ts` (Playwright, echte bestandskiezer): + XER met restverschillen opent in de modus, strook noemt Primavera, `lateStart`/`totalFloat` + tonen "Niet vastgelegd" mét de product-datumnotatie, badge zegt "deels niet vastgelegd" resp. + Primavera-tekst, Herberekenen verlaat de modus. Groen gedraaid (1 passed). +- **ZEKER (gebruikstest met de gids als meetlat, dev-build in Chromium, 2026-09-07).** + `ProjectLens/docs/demo/northstar-current.xer`: opent in de modus; strook "You're viewing the + schedule as Primavera recorded it; recalculating would shift 21 tasks"; openingsmelding "XER file + opened: 1 project document" + "21 tasks show the dates as Primavera recorded them (not + recalculated)"; kolom **Late start** via de kolomkiezer toont "Not recorded" op alle bladtaken, + kolom **Recorded-dates source** toont "Partly unrecorded"; eigenschappenpaneel-badge "Recorded + data is partly incomplete — see the late/float columns" + Read more; **Recalculate** haalt strook, + "Not recorded" en de herkomstkolom weg en zet berekende datums. `rehab-2.xer`: opent in de modus + ("recalculating would shift 940 tasks" = de 940 ES-afwijkingen); ná Recalculate tonen 1.679 van + 2.037 voltooide taken een totale speling > 0 (7a-effect; bv. V3101090: werkelijk einde 15-08-2007, + late start 04-08-2008, totale speling 59 dagen in het eigenschappenpaneel) — conform de gidstekst + "een voltooide activiteit toont voortaan een echte totale speling". +- **ZEKER (waarneming, niet gefixt).** (1) In de modus tonen SAMENVATTINGSRIJEN (XER-WBS) een + opgerolde late start (afgeleid uit de `?? rec.start`-terugval van hun kinderen) terwijl diezelfde + kinderen "Niet vastgelegd" zeggen; de herkomstkolom toont daar "—". Ontwerpkeuze uit her-check + R1 (oprollen), eigenaarsbesluit of een samenvatting daar "Niet vastgelegd" hoort te tonen. (2) + rehab-2's Arabische taaknamen renderen als Windows-1252-mojibake: het bestand is geen geldige + UTF-8 en de X-O4-heuristiek kent alleen 1252 als terugval; de openingsmelding noemt de keuze. + Pre-existent (X-O4), niet deze etappe; een 1256-/CURRTYPE-taal-hint is een eigen afweging. +- **X11-evidence (`npm run test:browser:x11`) — ZEKER: niet uitvoerbaar vanuit een verse clone.** + Het script pint `PHASE_2A_BASE = 790d6cd8…` en die commit bestaat in geen enkele ref of tag op + origin (volledige fetch, 1.948 commits); het git-bewijs stopt dan met "fase-2A-basis niet + leesbaar" in alle vier de scenario's (small-a, multidoc-help, multidoc-recovery, + large-resources; Xvfb, Chromium 1194 via `OPS_CHROMIUM_PATH`). De X11-uitkomsten in de + ochtendversie van deze paragraaf zijn dus alleen op de machine van de eigenaar reproduceerbaar + (AFGELEID). Fix hoort bij het script: een basis kiezen die op origin bestaat, of de basis uit + een gepushte tag lezen. + +### 10.d Dossier 7b-4 + +- **ZEKER.** Gemeten, niet gebouwd — §9 draagt de cijfers. Bouwbesluit bij de eigenaar. + +### 10.e Poorten op de kop van de branch + +- **ZEKER.** Corpusloos `npm run verify` op `1339d9b9`: typecheck, lint, planning 560/560 + 5-TZ- + matrix, library, mcp 39, dev-server groen; `test:browser` 119 passed / 2 failed — beide + `just-updated-dialog.spec.ts` op `net::ERR_CERT_AUTHORITY_INVALID` (de TLS-proxy van de + sessieomgeving vóór de GitHub Releases-API; spec en updater zijn in deze etappe niet geraakt), + waardoor `verify` daar stopt; de resterende poorten (examples, docs, i18n, store-/gantt- + boundaries, cycles) los gedraaid: groen. Mét corpus: planning rood op uitsluitend de drie + nuldoel-regels (10.b). Ná `1339d9b9` kwamen alleen docs en de merge van PR #108 (typecheck, + lint, i18n, docs, X12 15.056 opnieuw gemeten). +- **ZEKER.** Op deze machine (Playwright 1.62.1 verwacht headless-shell 1234, geïnstalleerd is 1194) + is `test:browser` alleen te draaien met een alias van de verwachte buildmap naar de geïnstalleerde + — een omgevingsdetail, niets in de repo. + + +**Eindreview (2026-09-07, hele diff t.o.v. `origin/main` `d808fdec`, incl. bak-4-sluiproutescan).** +Oordeel: landen-met-fixes. Vier blokkers, verwerkt in de fixcommit ná `f94122d3`: (1) de +whitelist-sluiproute-grep dekte bak 2 niet — mutatiebewijs M-B (`restart_date` achter +`task_type === 'TT_Rsrc'`) kwam door álle corpusloze poorten; nu grept +`check-xer-field-whitelist.ts` bak 2 over heel `src/` met twee gepinde andere-tabel-uitzonderingen +(`PROJECT.plan_end_date`, `SCHEDOPTIONS.critical_drtn_hr_cnt`) en herkent aanroepargumenten op elke +positie; (2) `extractSchedulingOptions` deed `JSON.parse` + cast — nu `sanitizeSchedulingOptions` +(bekende sleutels, enums, eindige getallen), en het commentaar bij `p6SourceActive` in `CPMSolver.ts` +zegt niet meer dat de XER-lezer de enige schrijver is (het IFC round-tript `p6Source` legitiem); +(3) de projecteinde-fout (36 van 39 corpus-SCHEDOPTIONS-rijen met `Y` hebben een leeg +`plan_end_date`, 16 daarvan ook geen `target_end_date`) landt NIET gefixt — een solverwijziging +vraagt een nieuwe corpusmeting en herpin, en de etappe had er één — maar nu wél benoemd in +`gids-xer-import.md` (nl+en) als bekende fout; (4) `check-mpp-fidelity.ts` kon de reviewer niet draaien (geen `OPS_MPP_CORPUS`/`OPS_MPP_CRAWL`); +daarna alsnog gedraaid tegen een sparse clone van `joniles/mpxj` (`junit/data`, 609 `.mpp`, 445 +overgeslagen als MPP_LEGACY/MPP_ENCRYPTED): 164 van de 213 gepinde bestanden gezien — de 49 +OzBuild-workshopbestanden zijn niet publiek — en op die 164 "0 verbeterd, 0 verslechterd, 164 +ongewijzigd"; de enige rode regel is de telling 164 ≠ 213 (bedrijfs-/OzBuild-deel niet +beschikbaar). Het gedeelde relatie-/solverwerk van deze etappe raakt de MPP-datums op het publieke +deel dus niet; de 49 OzBuild-pins blijven ONBEKEND tot de eigenaar ze draait. Kleinere bevindingen, verwerkt: dode `lagCalendar.ts` weg en drie stale docblokken +bijgetrokken; drie Nederlandse holidaynamen in het IFC ⇒ Engels (`'Calendar exception'`, +`'Calendar exception (weekend reconstruction)'`; digest van het 124-dossier herpind om die naam +alleen); het weekend-klemherstel laat nu een herstelcode `WEEKEND_CLAMP_RECONSTRUCTED` / +`XER_CALENDAR_WEEKEND_CLAMP_RECONSTRUCTED` achter (telt mee in de kalenderbevindingen van de +openingsmelding; gids nl+en); de rapporten/PDF krijgen in de modus één melding bovenaan +(`tableReports.recordedDatesNote`, 14 talen) in plaats van de "niet vastgelegd"-poort zelf (die +blijft open, zie 10.f); `importSource` krijgt in de consentdialoog een vertaalde toelichting; +`check-xer-archive-scale.ts` bewaakt de serialisatieverhouding (≤ 1,6 IFC-tekens per bronbyte, +gemeten 1,33) in plaats van "eindig en positief"; CLAUDE.md heeft een XER-sectie. Niet verwerkt +(eigenaar): de onbegrensde archiefretentie (bevinding 1; nu benoemd in de gids), het moment van de +exportverliesmelding (ná het schrijven), een bovengrens op documenten per bestand (VERMOED). + +### 10.f Wat niet in het plan staat + +- **Valse sporen (nieuw).** (5) "Einde vóór start weigeren" in de vastlegging (her-check laag 3, + bevinding 6): gemeten en verworpen — P6's voltooid-conventie, 2.049 van 11.953 taken, docblok + `readXerRecordedTimes`. (6) "Poort dicht in casus 09" als verzoening: de transcriptie was + onbrongetrouw; brongetrouw is de poort dicht op `missingExplicitTargetWindow` en het bewijs + correlationeel (§5). +- **Besluiten van de orkestrator die de eigenaar nog moet bevestigen** (ongewijzigd uit de + ochtend, plus twee nieuwe): heropen-beleid `'xer'` vs `'xer-archive'`; crashherstel behoudt de + modus alleen met de manifestvlag (v4); CSV/MCP geven bij een niet-vastgelegde as leeg/`null`; + MCP-provenance toont `name`/`code` zonder opt-in; 7b's ES/EF-regressie onder de X-O7-uitzondering; + NIEUW: samenvattingen rollen in de modus op uit vastgelegde kinderen (R1; vastgelegde + samenvattingen blijven staan) en `isCritical` is "niet vastgelegd" onder longest-path-kritiek + of niet-omrekenbare speling. + BEVESTIGD DOOR DE EIGENAAR (2026-09-09), na uitleg per vraag: (1) heropen-beleid wordt + optie B — een heropend eigen IFC gaat automatisch in de modus zolang het document sinds de + import niet is bewerkt, anders alleen aanbieden (te bouwen, `docs/TODO.md`); daarbovenop het + bredere besluit dat ELK formaat zich als XER moet gedragen (automatisch de modus aan bij + afwijkingen, met melding en strook; "vergelijk wat er is" per formaat, ook CSV) en dat de + nul-afwijkingen-meetlat per formaat als aparte etappe volgt (optie 3, `docs/TODO.md`). Het + mechanisme zelf wordt direct gebouwd (verduidelijking eigenaar 2026-09-09: alleen de meetlat + gaat naar de TODO): lezers MSPDI/P6 XML/`.mpp`/CSV leveren `recordedTimes` op de assen die het + bestand draagt (ontbrekende assen "niet vastgelegd", nooit vulling), laadbeleid "automatisch aan + bij afwijkingen" voor elk formaat incl. IFC uit een ander pakket, eigen IFC via optie B (kenmerk + "ongewijzigd sinds import"), per formaat een planningscheck en een browsertest, gidsen nl+en. + Volgorde P6 XML → MSPDI → `.mpp` → CSV → IFC-optie B. + De eigenaar bevestigde tevens dat het nuldoel (§1) de lat blijft en dat deze PR een gemeten + tussenstand is, geen eindpunt. + UIT DE EINDREVIEW (2026-09-07), vier nieuwe: (a) onbegrensde bronretentie in projectbestand én + auto-save (17,7 MB `.xer` ⇒ 50 MB IFC, 73 s / 3,1 GB per herstelronde) — begrenzen, één keer + schrijven, of bewust accepteren; (b) uitleveren mét de projecteinde-fout (`docs/TODO.md`, + nu in de gids benoemd) — de fix is drie regels in `deriveXerScheduleOptions` maar vraagt een + corpusmeting en herpin; (c) `lagCalendar` is effectief geworden voor bestaande niet-XER- + documenten waarin ooit 'successor'/'24hour'/'projectDefault' is gekozen (stille herplanning bij + upgrade; releasenotitie nodig); (d) de kalenderdecoder reconstrueert vrije weekenddagen op een + n=1-basis — nu mét herstelcode en gidstekst, maar de heuristiek zelf blijft een keuze. Plus: + de "niet vastgelegd"-poort ontbreekt in de tien rapporten/PDF/renderer (0,7–1,6% van de + corpustaken heeft geen volledig late-paar of geen `total_float_hr_cnt`); in de modus staat er nu + één melding bovenaan elk rapport, de cel zelf toont nog leeg/0. +- **Bekende losse eindjes.** (1) Zes main-bestanden met `childIds.length > 0` i.p.v. + `isSummaryTask()` — niet aangeraakt, niet stil om te zetten. (2) `readIFCWithXerReconstruction` + vs de 7b-kalender op al opgeslagen documenten — niet uitgezocht. (3) rehab-2 heropenen uit IFC + ±21 s. (4) `DT_FixedQty` alleen in twee bestanden. (5) Dossiers uit de her-reviews die als + vervolg zijn geregistreerd: bandrand-asymmetrie SS/SF vs FS/FF met lag (gepind, niet + gesymmetriseerd), de statusdatumklem die ook voorgangers een ruimere LS aanreikt (VERMOED), de + kalendermix `progressCalendarFor` vs `calendarFor` in de floatformule (VERMOED), bibliotheek- + refresh in de modus verliest zijn stale-signaal (VERMOED), `applyBackwardBound`/projecteinde + worden in de completed-late-tak overgeslagen (bewust, nergens opgeschreven behalve hier), + ongepind duurtype was dicht (nu gepind, 3b). (6) `docs/TODO.md`: projecteinde-fout en + reststart-ES (§9). +- **Wat ik de opvolger zou zeggen.** PR #101 (taaktypes) merget ná deze PR. Het nuldoel is niet + gehaald en dat is zichtbaar rood mét corpus; wie verder wil: 7b-4 (690 cellen bovengrens), de + 215 tf-cellen van klasse (ii), sameday (415, waarvan 280 in één bestand met een tijdstip- + conventie), en de driving-path-as als eventuele poort. Alles wat hier "gemeten" heet staat in + een commitbericht of in §9 met het commando erbij. diff --git a/docs/wiki/Extensions-Authoring.md b/docs/wiki/Extensions-Authoring.md index a09ab0935..e9c2832e3 100644 --- a/docs/wiki/Extensions-Authoring.md +++ b/docs/wiki/Extensions-Authoring.md @@ -30,13 +30,15 @@ Categories: `Import/Export`, `Planning`, `Reporting`, `Utility`, `Fonts`, `Other | `ribbon` | **hard** — missing ⇒ `api.ui.addRibbonButton` throws | Add a button to the ribbon. | | `backstage` | **warn** — missing ⇒ `api.importers.*` still works, but logs a warning | Register an importer (appears under File → Import). | | `pdf-fonts` | **hard** — missing ⇒ `api.pdfFonts.register` throws | Register a font provider for the vector PDF export (e.g. CJK glyph bytes). | +| `importSource` | **hard, default-deny** — missing ⇒ `api.data.getImportSourceInfo`/`getImportSourceChunk`/`getImportSourceCatalogPage` throw before a single byte is read | Read the **full original source bytes** of an imported file (today: XER), including fields the import layer deliberately never materializes into the project model. See the section below. | | `filesystem` | informational | No API surface; a declared intent shown at install time — **no** sandbox guarantee. | | `network` | informational | Likewise — declared intent, not a technical boundary. | -`data.*`, `settings.*`, `assets.*` and `ui.showNotification` are **core API**: always available, no -permission required. Enforcement is centralized in `src/extensions/permissions.ts`. `minAppVersion` is -also enforced: on an older app the extension refuses to activate. Unknown permissions are filtered out -with a warning in the debug terminal. +`data.*` is otherwise **core API** — except for the three `getImportSource*` methods above — same +as `settings.*`, `assets.*` and `ui.showNotification`: always available, no permission required. +Enforcement is centralized in `src/extensions/permissions.ts`. `minAppVersion` is also enforced: on +an older app the extension refuses to activate. Unknown permissions are filtered out with a warning +in the debug terminal. ## main.js @@ -75,7 +77,7 @@ module.exports = { | Area | Functions | |---|---| | `api.importers` | `register(def)`, `unregister(id)` | -| `api.data` | `getProject/getCalendar/getTasks/getSequences/getResources/getAssignments`, `addTask`, `updateTask`, `addSequence`, `loadProject(result)`, `recalculate()` | +| `api.data` | `getProject/getCalendar/getTasks/getSequences/getResources/getAssignments`, `getImportSourceInfo/getImportSourceChunk/getImportSourceCatalogPage` (permission `importSource`, see below), `addTask`, `updateTask`, `addSequence`, `loadProject(result)`, `recalculate()`, `batch(fn)` | | `api.events` | `on/off/emit` (permission `events`) | | `api.ui` | `addRibbonButton(reg)` (permission `ribbon`), `showNotification(msg, type?)` | | `api.settings` | `get(key, default)`, `set(key, value)` — prefixed per extension in localStorage | @@ -110,6 +112,65 @@ module.exports = { }; ``` +### Read-only XER source route (permission `importSource`, `apiVersion` ≥ 1.1) + +Besides the mapped `data.*` DTOs (derived, normalized, always available), an extension holding the +`importSource` permission can also reach the **original, unmodified source data** of the current +document — today only for an opened `.xer` file (Primavera P6). Without this permission all three +methods throw before a single byte is read; nothing leaks through a partial call or an error path. +These three methods exist since contract version `1.1.0` — declare `"apiVersion": "1.1"` or higher +if you rely on them; an older host simply doesn't have them. + +**Why a separate permission instead of core API.** The rest of `api.data.*` exposes the internal +project model — tasks, calendar, relations — exactly what the importer made of it. The source route +returns the **full original bytes and tables**, including columns the import layer deliberately +never materializes into the project model (provenance fields such as `create_user`/`update_date`, +costs, review/location fields, unused UDFs, …). That is a materially larger exposure than "the app +reads this file" — every installed extension would otherwise be able to read and forward the raw +source text of every opened project, even without any other permission. Hence: default-deny, +explicitly declared in `manifest.json`. + +```js +// manifest.json → "permissions": ["importSource"] +const info = api.data.getImportSourceInfo(); // null outside an XER document +if (info) { + console.log(info.sourceFormat, info.archive.byteLength, info.catalogs.taskSourceRows.totalRows); +} +``` + +- **`getImportSourceInfo()`** → a small summary (source format, archive identity including + `sha256`/`byteLength`/`chunkCount`, number formatting, diagnostics counts, the import report, the + schedule-options provenance and catalog counts). No record contents. **`null`** when the active + document has no retained XER source (any non-XER document). +- **`getImportSourceChunk(index)`** → a fresh copy of one piece of the original file bytes. + Concatenate all chunks `0..chunkCount - 1` in order to reconstruct the **exact** original bytes — + compare against the `sha256` from `getImportSourceInfo()` to confirm. An invalid index throws a + `RangeError`; outside an XER document the method returns `null`. +- **`getImportSourceCatalogPage(collection, options?)`** → paginated, per-record-copied access to + the retained source tables (task source rows, resource/role/rate/curve/assignment rows, activity + codes, custom field definitions, UDF values, schedule-options source rows, …). `options.offset` + (default 0) and `options.limit` (default 100, **maximum 500 per page**) drive pagination; an + invalid value throws a `RangeError`. An `offset` past the end of the collection does not throw — + it is canonicalized to `total`, yielding an empty but valid last page. + +All three methods are bound to the **active document**: switching documents follows the source +route (or its absence) of the newly active document automatically. Every call returns a fresh, +independent copy — mutating a returned `info`, page item or chunk never touches the retained +archive. + +**Document drift while paginating.** "Follows automatically" is convenient for a single call, but a +**risk when paginating**: pagination is inherently multiple calls over time, and there is no +per-document page session. If the user switches documents (`switchDocument`) between two +`getImportSourceCatalogPage` calls, the second call simply returns a page from the **new** active +document — possibly an empty page that a naive extension reads as "done", while it actually just +mixed two projects together. Pass `options.expectedSourceProjectId` with the `sourceProjectId` you +got from an earlier call: if the active source selector no longer matches (including when the +active document no longer has any XER source at all), the call throws an +`ExtImportSourceDriftError` instead of silently continuing. Without this option there is **no** +drift protection. The same risk applies, to a lesser extent, to fetching a sequence of +`getImportSourceChunk` indices to reconstruct the source bytes — compare `sourceProjectId` (or the +`sha256`) between chunks if you cannot guarantee the document won't switch. + ### Data contract: the `Ext*` types Everything crossing the extension boundary via `api.data.*`, importer handlers and `sdk.factory.*` uses @@ -180,8 +241,9 @@ For a standalone `.js` file the manifest may be a comment block at the top: ## Limitations - The sandbox is light: extension code runs via `new Function(...)` and has access to `window`, - `document` and `fetch`. Permissions are enforced hard for `ribbon`/`events`, in warn mode for - `backstage`, and are purely informational for `filesystem`/`network`. Only install extensions you trust. + `document` and `fetch`. Permissions are enforced hard (default-deny) for `ribbon`/`events`/ + `pdf-fonts`/`importSource`, in warn mode for `backstage`, and are purely informational for + `filesystem`/`network`. Only install extensions you trust. - Objects from `api.data.get*()` are fresh, mutable `Ext*` copies — mutating them does not touch the store; write back via the mutating API functions. - The `@manifest` comment block in a standalone `.js` file must be a flat JSON object (no nested objects). diff --git a/docs/xer-recovery-guardrails.md b/docs/xer-recovery-guardrails.md new file mode 100644 index 000000000..bb554668a --- /dev/null +++ b/docs/xer-recovery-guardrails.md @@ -0,0 +1,53 @@ +# XER-bronarchief en crashherstel — technische vangrails + +Deze notitie beschrijft de opslaggrens van de X9-implementatie. Het doel is niet om de actuele +machineprestaties als productspecificatie vast te leggen, maar om te voorkomen dat een latere +optimalisatie opnieuw alle open documenten serialiseert of een half gecommitte herstelset kan +publiceren. + +## Harde, machine-onafhankelijke grenzen + +- De recoverydelta vergelijkt de volledige `IFCSaveSource` via `sameIFCSource`. `isDirty`, het + actieve tabblad, bestandspaden én de weergavestand "datums zoals opgeslagen" + (`datesAsRecorded`) zijn manifestmetadata; ze zijn geen inhoudsrevisie. Een wissel van die + weergavestand is dus wél een manifestcommit — anders zou de vlag pas bij de volgende + inhoudswijziging op schijf landen, precies de ronde waarin een crash hem kwijt zou zijn. +- Eén inhoudsbewerking serialiseert precies één keer met `writeIFC` en levert precies één volledige + IFC-upsert. De overige open documenten houden hun bestaande snapshot. +- Een actieve-documentwissel is metadata-only: nul IFC-upserts, één manifestcommit. +- Tauri gebruikt manifestversie 4 (v3 + `datesAsRecorded` per document). Nieuwe documentinhoud krijgt een immutable generatienaam. Eerst + worden de volledige IFC-generaties via temp+rename gepubliceerd; daarna is de atomaire rename van + het manifest het commitpunt; oude eigen generaties worden pas daarna opgeruimd. +- De webbackend schrijft document-upserts, manifest en verwijderingen in één strikte IndexedDB- + `readwrite`-transactie. Een fout mag de persisted basis van de delta-tracker niet bevorderen. +- Recoverymanifesten van versie 1, 2 en 3 blijven leesbaar; een manifest zonder + `datesAsRecorded` levert `false` — het gewone #63-aanbod, nooit stilzwijgend de modus. Schema-1 en schema-2 XER-bronarchieven + blijven eveneens leesbaar; schema 2 wordt in een koud proces via + `readIFCWithXerReconstruction` uit uitsluitend de opgeslagen bronbytes herbouwd. +- Crashherstel zet "datums zoals opgeslagen" terug uit die metadata — niet uit een heuristiek over + de opgeslagen datums, en voor ALLE herstelde documenten, ook de slapende. Een slapend document + wordt bewust niet doorgerekend en krijgt daarom de modus zonder verschiltellertje (zie + `applyRestoredRecordedMode`). +- De OZB-corpusfixture opent en herstelt twaalf niet-lege documenten. Eén edit herschrijft daarvan + slechts één IFC-snapshot. De rehab-fixture herstelt haar bronbytes checksum-exact. + +Deze grenzen worden afgedwongen door `check-recovery-delta.ts`, +`measure-xer-recovery-write-amplification.ts`, `check-recovery-isolation.ts`, +`check-xer-archive-cold-read.ts` en `check-xer-archive-recovery-corpus.ts`. + +## Informatieve schaalmeting + +Op 2026-08-28 gaf één Linux/Node 22-run de volgende waarnemingen: + +- rehab-2, zelfstandige compacte IFC-ronde: bron 18.592.333 bytes, IFC 50.212.986 tekens, + 25,4 s walltime en 1.559.372 KiB peak RSS; +- rehab-2, volledige recoveryronde: 74,1 s walltime en 2.829.496 KiB peak RSS, met één document- + upsert en één manifest-put voor de edit; +- OZB, twaalf documenten: gezamenlijk 4.630.032 IFC-tekens, 4,8 s walltime en 315.400 KiB peak RSS, + eveneens met één document-upsert en één manifest-put. + +Deze tijd- en RSS-cijfers zijn bewust **geen pass/fail-drempels**. CPU, beschikbare RAM, garbage +collection, kernel/page-cache en CI-host verschillen te sterk. De corpuscheck eist wel dat de +metingen positief en eindig zijn, zodat een kapotte of overgeslagen probe niet groen kan lijken. +Regressies worden primair op de structurele schrijfvermenigvuldiging en checksum-exact herstel +gepoord; tijd en RSS blijven zichtbaar voor trendvergelijking. diff --git a/package.json b/package.json index 8ad3ab52a..76c6b63a7 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "test:mcp": "bash tests/mcp/run.sh", "test:dev-server": "node --test tests/dev-server/*.test.mjs && bash tests/dev-server/integration.sh", "test:browser": "node scripts/run-browser-tests.mjs", + "test:browser:x11": "node tests/browser/x11-xer-evidence.mjs", "verify": "npm run typecheck && npm run lint && npm test && npm run verify:examples && npm run verify:docs && npm run verify:i18n && npm run verify:store-boundaries && npm run verify:gantt-boundaries && npm run verify:cycles", "preview": "vite preview", "tauri": "tauri", diff --git a/public/docs/ar/gids-import-export.md b/public/docs/ar/gids-import-export.md index 64f13e4b4..a29266b70 100644 --- a/public/docs/ar/gids-import-export.md +++ b/public/docs/ar/gids-import-export.md @@ -61,6 +61,8 @@ MSPDI أغنى بكثير من CSV: تُرافق الموارد والتخصيص ملف `.mpp` (الصيغة الأصلية لـ Microsoft Project، من Project 2010 إلى 2021) يسلك مسارًا منفصلاً: هذا الاستيراد **للقراءة فقط** — لا يوجد تصدير بصيغة `.mpp`، لذا فإن إعادة التصدير إلى MS Project يمر عبر MSPDI XML. راجع دليل [فتح MS Project (.mpp)](docs://gids-msproject-import) لمعرفة ما يُنقَل وما هي القيود. +ملف `.xer` هو صيغة تبادل Primavera P6. يُستورد مباشرة ويُحفظ كملف IFC بعد التحرير؛ راجع [فتح Primavera P6 (.xer)](docs://gids-xer-import). + ## استيراد الإضافات إلى جانب الصيغ الثابتة أعلاه، يمكن للإضافات المثبَّتة إضافة أدوات استيراد خاصة بها — مثلًا لصيغة غير مدعومة افتراضيًا. تظهر تلك تحت **Backstage ← استيراد**، كل منها باسمه ووصفه وامتدادات الملفات المطابقة؛ بدون أي إضافات استيراد مثبَّتة، يكون ذلك القسم فارغًا. تحقّق من **Backstage ← الإضافات** لرؤية ما هو متاح. diff --git a/public/docs/de/gids-import-export.md b/public/docs/de/gids-import-export.md index 8f885a134..aba784b4f 100644 --- a/public/docs/de/gids-import-export.md +++ b/public/docs/de/gids-import-export.md @@ -61,6 +61,8 @@ Diese Warnungen sind keine Schlamperei — sie sind eine bewusste, ausdrücklich Eine `.mpp`-Datei (das native Microsoft-Project-Format, Project 2010 bis 2021) ist ein eigener Weg: Dieser Import ist **nur lesend** — es gibt keinen `.mpp`-Export, ein Re-Export nach MS Project läuft daher über MSPDI-XML. Siehe die Anleitung [MS Project (.mpp) öffnen](docs://gids-msproject-import) für das, was mitkommt, und die Einschränkungen. +Eine `.xer`-Datei ist das Austauschformat von Primavera P6. Sie wird direkt importiert und nach einer Bearbeitung als IFC gespeichert; siehe [Primavera P6 (.xer) öffnen](docs://gids-xer-import). + ## Erweiterungs-Importer Über die festen Formate hinaus können installierte Erweiterungen eigene Importer hinzufügen — zum Beispiel für ein Format, das standardmäßig nicht unterstützt wird. Diese erscheinen unter **Backstage → Importieren**, jeweils mit eigenem Namen, Beschreibung und passenden Dateierweiterungen; ohne installierte Import-Erweiterungen ist dieser Abschnitt leer. Prüfen Sie **Backstage → Erweiterungen**, um zu sehen, was verfügbar ist. diff --git a/public/docs/en/datums-zoals-opgeslagen.md b/public/docs/en/datums-zoals-opgeslagen.md index a94feeaab..599f8d830 100644 --- a/public/docs/en/datums-zoals-opgeslagen.md +++ b/public/docs/en/datums-zoals-opgeslagen.md @@ -70,9 +70,50 @@ Save while you're viewing the recorded dates, and the app writes those dates — version. That way you never accidentally overwrite a colleague's schedule, or the source package's schedule, with an outcome the app made up on its own. +## Primavera P6 (.xer): this view can turn on by itself + +For a `.xer` file this works slightly differently from the other formats above. Primavera P6 doesn't +just deliver dates — it also delivers its own calculation of them, not bare dates without any logic. +If residual differences remain against Open Planner Studio's own recalculation after opening, the app +switches itself straight into this view, without you first clicking **Show recorded dates**. In that +case the notice above the schedule immediately states the number of activities that would shift, and +points to the standing notice that stays visible while the view is active. + +That automatic switch only applies when you open the `.xer` file itself: if you then save the project +and reopen it later, the view is merely offered — you click **Show recorded dates** yourself — so that +a schedule you have edited and saved in the meantime is never silently shown with Primavera's old +dates again. + +Tasks inside this view are also recognisable in the table — column **Recorded-dates source** — and +with a badge in the properties panel of the selected task. **F5** and editing a task leave this view +in exactly the same way as with any other format (see above); the calculation itself never uses +Primavera's recorded dates as input, only as a view. See +[Opening Primavera P6 (.xer)](docs://gids-xer-import) for the rest of what a XER import brings along. + +## "Not recorded" + +Primavera doesn't record all four axes — late start, late finish, total float and free float — for +every activity; an activity might have an early date but no float, for example. When such an axis is +missing from the source file, the relevant column in the table shows "Not recorded" instead of a +number. That's not an error: it only means the file itself said nothing on that point, so Open Planner +Studio doesn't invent anything either. This applies only while you are looking at this view: as +soon as the app shows its own calculation — outside this view, or after recalculating with **F5** — +that column simply holds the calculated number. + +The same honesty applies off screen: export to CSV while this view is on and the cell for an +unrecorded axis stays empty instead of showing a `0`, and the AI assistant sees `null` for such an +axis, together with the list of axes the file did not record. + +The reports (the Report tab, including the PDF and the print preview) do not have that blank cell +yet: there a not-recorded axis shows an empty field or a `0`. While this view is active, one notice +therefore appears above every report saying that you are looking at the dates from the file and where +those zeros come from. + ## Further reading - More on which formats you can import and what does and doesn't come along — read the [Import/export](docs://gids-import-export) guide. - Float and the critical path in detail, including what "driving" actually means — read the [Critical path & advanced analysis](docs://gids-kritiek-pad-analyse) guide. +- Everything a `.xer` import brings along — read the + [Opening Primavera P6 (.xer)](docs://gids-xer-import) guide. diff --git a/public/docs/en/gids-import-export.md b/public/docs/en/gids-import-export.md index 72abad341..40743390b 100644 --- a/public/docs/en/gids-import-export.md +++ b/public/docs/en/gids-import-export.md @@ -127,11 +127,17 @@ then shows exactly which items were dropped or simplified, and how many. ## Importing -**File → Open** (or **Backstage → Open**) accepts `.ifc`, `.csv`, `.xml` and `.mpp` files. For an +**File → Open** (or **Backstage → Open**) accepts `.ifc`, `.csv`, `.xml`, `.mpp` and `.xer` files. For an `.xml` file, the app detects on its own whether it's a Primavera P6 or an MS Project file, based on -the content. As described above: a CSV or P6 import produces a project **without baselines** (there +the content. As described above: a CSV or Primavera P6 XML import produces a project **without baselines** (there weren't any in the source), while IFC and MSPDI bring baselines along. +A `.xer` file is Primavera P6's own exchange format. The app reads it directly but does not write +`.xer` back: after editing, save as IFC. One XER can contain several current projects and baseline +projects; current projects open as separate documents and matching baselines remain attached to +their project. See [Opening Primavera P6 (.xer)](docs://gids-xer-import) for project selection, +text encoding, P6 number notation and retained source data. + A `.mpp` file (Microsoft Project's native format, Project 2010 through 2021) is a separate path: that import is **read-only** — there is no `.mpp` export, so exporting back to MS Project runs through MSPDI XML. See the guide [Opening MS Project (.mpp)](docs://gids-msproject-import) for @@ -153,7 +159,7 @@ section is empty. Check **Backstage → Extensions** to see what's available. ## Further reading -- Baselines only come along via IFC and MS Project XML, not via CSV or P6 — read the guide +- Baselines only come along via IFC and MS Project XML, not via CSV or Primavera P6 XML — read the guide [Baselines & progress](docs://gids-baselines-voortgang) for how to record a baseline. - Resources, assignments and loading curves — read the guide [Resources, histogram & leveling](docs://gids-resources-histogram) for how those are built before diff --git a/public/docs/en/gids-xer-import.md b/public/docs/en/gids-xer-import.md new file mode 100644 index 000000000..de7afe066 --- /dev/null +++ b/public/docs/en/gids-xer-import.md @@ -0,0 +1,102 @@ +# Opening Primavera P6 (.xer) + +A `.xer` file is Primavera P6's exchange format. Open Planner Studio can open it directly; no P6 XML conversion or external converter is required. This guide explains what the import does, which data remains available, and where the current P6 model has limits. + +## What you'll learn here + +- How one XER file can open several project documents. +- How current projects, empty projects and baseline projects are handled. +- Which calendar, resource, progress and metadata information is read. +- How text encoding and P6 number notation are determined safely. +- What happens when the recalculation deviates from the dates Primavera itself had already recorded. +- What saving as IFC means and which P6 features do not yet have their own scheduling model. + +## Opening and documents + +Open a `.xer` file through **File → Open** or **Ctrl+O**. One export can contain several P6 projects. Open Planner Studio opens every non-empty current project as a separate document; the document with the most activities becomes active. Empty projects do not create a pointless tab. + +After one file action, one informational notification appears, even when many documents open. It reports the actual projects found and opened, empty projects, baselines and any fallbacks. A later XER file action receives its own notification. + +A P6 baseline project is not opened as a separate schedulable document. When it belongs to an open current project, it is retained as that document's baseline. A baseline reference whose target is not present in the file is not invented: it stays out of the baseline collection and is counted in the notification. A self-reference, a cycle of baseline references, or a selection that would otherwise open no document safely reverses the exclusion: those projects then open as ordinary current documents. This prevents a silently empty screen and makes the fallback visible. + +Relations between two different P6 projects are retained as external source links. The app does not schedule them as ordinary relations, because every opened document is an independent schedule. + +## What comes from P6 + +The import reads, among other things: + +- **Projects, WBS, activities and milestones**, including P6 activity and duration types. +- **Relations, lags, constraints, progress and actuals**, plus P6 suspend/resume dates where the source file contains them. +- **Project and resource calendars**, working times and exceptions. Hours and clock times remain properties of the calendar rather than a project-wide guess. +- **Resources, rates and assignments**. +- **Activity codes, UDFs and notes**, including their source structure and activity links. + +One calendar rule deserves a separate mention. Some P6 exports clamp a contiguous non-working block onto the Monday–Friday axis: a non-working Saturday then appears as a duplicate record on the Friday before, a non-working Sunday on the Monday after. When the reader sees that pattern on a calendar that does work on Saturday or Sunday, it makes that weekend day non-working after all. Such a reconstructed day is not a record in the file; it is named "Calendar exception (weekend reconstruction)" in the calendar and counts towards the calendar findings in the opening notification, so you can always see that the app derived something here. The rule was derived from a single file and only fires on a multi-day block with evidence on the record itself; on an ordinary Monday–Friday calendar nothing changes. + +The raw P6 source data that Open Planner Studio reads remains part of the document. It survives tab switching, undo, recovery and saving. That is different from claiming that every P6 feature already has an equivalent editing or scheduling model: when such a model is missing, source data is retained rather than silently discarded. + +## Completed activities get real float + +Primavera treats a completed activity, for its entire calculation, as a task with zero remaining work on the data date — on the late side too. Since September 2026 Open Planner Studio mirrors that, but only for projects that came from a `.xer` file. As a result a completed activity now shows real total float instead of always zero, and it exerts backward pressure on its own predecessors like any other task. Your data does not change: the actual start and finish dates stay exactly as the file recorded them. + +The rule applies only where the source file supports it: activities of the "fixed duration and units" type with duration-based percent complete, a recorded planned window, and a project that links remaining work to the plan. If the file also declares that it was scheduled with *progress override* rather than *retained logic*, the previous behaviour stays. Projects from IFC, MS Project or Primavera P6 XML are unaffected. + +## Text encoding and numbers + +XER does not reliably declare its text encoding in the file. A UTF BOM is followed; without one, the reader uses valid UTF-8 and otherwise falls back to Windows-1252. If that non-ASCII choice is needed, the opening notification states the encoding used. The app does not guess individual rows or describe them as "skipped". + +P6 can store decimal and thousands separators in the `CURRTYPE` table, either as literal characters or symbolic tokens such as `ds_Period` and `dg_Comma`. That notation is read before durations, work and float are converted. If `CURRTYPE` is absent, a dot is the safe default. If a value looks like a comma decimal while this source information is absent, import stops with a specific error instead of opening a potentially wrong schedule. + +## Dates as Primavera recorded them + +Open Planner Studio always recalculates an opened schedule itself — a `.xer` file included. On most +files that recalculation matches exactly what Primavera itself had recorded. When differences remain, +the app automatically switches itself into the **dates as recorded** view as soon as it opens: you +then see Primavera's own result on screen, not our recalculation. That is a different order than for +other formats — there, the app only offers this view through a button; for a `.xer` file with residual +differences it turns on immediately, because the file brings along a trustworthy calculation of its +own rather than just dates without any scheduling logic. + +The same single notification as above appears on open, extended with the number of activities whose +dates a recalculation would shift. Tasks inside this view are recognisable in the table — column +**Recorded-dates source** — and in the properties panel of the selected task. If the late start, late +finish, total float or free float is missing for a task in the source file, the relevant column shows +"Not recorded" instead of an invented number. + +Editing a task, or pressing **F5**, leaves this view and recalculates as usual — exactly as with any +other format. The recalculation itself never uses Primavera's recorded dates as input: they travel +along as separate, read-only source data and are used only to show what the file said, never to drive +what the app calculates. Saving as IFC stores Primavera's recorded dates in +the project file — including which axes the source file left unrecorded. Opening that IFC file again +does not switch this view back on by itself: you get the notice with a **Show recorded dates** button +and decide for yourself. That way a schedule you have edited and saved in the meantime can never come +back on screen with the old dates unasked. + +See [Dates as recorded](docs://datums-zoals-opgeslagen) for the full explanation of this view, +including what you do and don't see while it is active and how to leave it manually. + +## Saving and exchange + +An XER import is an **import**, not an XER editor or XER exporter. When you save afterwards, Open Planner Studio writes an IFC file. IFC is the app's native project file and retains the XER source data alongside the data the app uses. The original `.xer` file is never silently overwritten. + +That retained source data has a cost with large files. The complete original `.xer` file travels along in the project file and in every crash-recovery snapshot, without an upper bound. Measured on the largest test file (a 17.7 MB `.xer` with over 2,000 activities): the IFC project file becomes about 50 MB, saving takes tens of seconds, and crash recovery rewrites that file every ten seconds while you edit. With an export containing many projects this multiplies: every opened document carries its own copy. For most schedules you will not notice; if you work with an export of tens of megabytes, expect slow saves and a large project folder. + +For exchange with Primavera, use the existing **Primavera P6 XML** export. It is a different format with its own limits; see [Import/export](docs://gids-import-export). Keep the IFC file as well when you want to reopen an edited project later. + +## Limits that stay visible + +Some P6 concepts are already retained but do not yet have a fully equivalent scheduling model: + +- **`TT_Rsrc`** (resource-dependent activity) and **`TT_WBS`** are retained as P6 source types. The solver does not yet have a separate P6 scheduling mode for these types. +- A P6 resource curve with 21 points is retained as source distribution. A recognisable shape can be mapped to the nearest built-in curve for the histogram, but the original 21-point shape is not yet recalculated after an edit. +- The existing **P6 XML** reader and this XER reader do not yet cover the same full field set. XER can therefore contain data that P6 XML in the app does not yet read or write. +- **Project finish as float anchor without a finish date.** If the file has the P6 option "compute total float against the project finish" switched on, but the project has no *Must Finish By* date and no activity has a planned finish date, the app's project finish falls back to the project start. All late dates then anchor on it and almost every activity shows negative float and is critical. In the test material this combination occurs in P6 exports of small, bare projects. The early dates and the **dates as recorded** view are correct; only the recalculated late side is unusable then, and there is no switch yet to turn the option off. This is registered as a known defect. + +These limits do not remove source data from the IFC project file. When XER-specific source data is present and you export to CSV, MS Project XML or Primavera P6 XML, that source information cannot fit completely in the target format. After a successful export, one informational notification appears with a link to this guide. If you cancel the export or saving fails, that notification does not appear. Exporting to IFC retains the XER source data; the other exports include only the data their own format supports. The original `.xer` file is not overwritten. + +## Further reading + +- [Calendars & hour planning](docs://gids-kalenders-uren) explains how working times and exceptions shape a schedule. +- [Resources, histogram & leveling](docs://gids-resources-histogram) covers resources, assignments and loading in Open Planner Studio. +- [Baselines & progress](docs://gids-baselines-voortgang) explains how to use baselines after import. +- [Import/export](docs://gids-import-export) compares IFC, CSV, MS Project XML and Primavera P6 XML. diff --git a/public/docs/es/gids-import-export.md b/public/docs/es/gids-import-export.md index cba64bc4a..324cf4e73 100644 --- a/public/docs/es/gids-import-export.md +++ b/public/docs/es/gids-import-export.md @@ -101,6 +101,8 @@ importación es **de solo lectura** — no existe una exportación `.mpp`, así pasa por MSPDI XML. Consulte la guía [Abrir MS Project (.mpp)](docs://gids-msproject-import) para saber qué se conserva y cuáles son las limitaciones. +Un archivo `.xer` es el formato de intercambio de Primavera P6. Se importa directamente y, después de editarlo, se guarda como IFC; consulta [Abrir Primavera P6 (.xer)](docs://gids-xer-import). + ## Importadores de extensiones Más allá de los formatos fijos anteriores, las extensiones instaladas pueden añadir sus propios importadores — por ejemplo para un diff --git a/public/docs/fa/gids-import-export.md b/public/docs/fa/gids-import-export.md index f550204dc..98b3ef9d6 100644 --- a/public/docs/fa/gids-import-export.md +++ b/public/docs/fa/gids-import-export.md @@ -99,6 +99,8 @@ MSPDI به‌طور قابل‌توجهی غنی‌تر از CSV است: منا MS Project از راه MSPDI XML انجام می‌شود. برای دیدن اینکه چه چیزی همراه می‌آید و محدودیت‌ها چیستند، به راهنمای [باز کردن MS Project (.mpp)](docs://gids-msproject-import) مراجعه کنید. +فایل `.xer` قالب تبادل Primavera P6 است. مستقیماً وارد می‌شود و پس از ویرایش به‌صورت IFC ذخیره می‌شود؛ [باز کردن Primavera P6 (.xer)](docs://gids-xer-import) را ببینید. + ## درون‌ریزکننده‌های افزونه‌ای جدا از قالب‌های ثابت بالا، افزونه‌های نصب‌شده می‌توانند درون‌ریزکننده‌های خودشان را اضافه کنند — برای مثال برای diff --git a/public/docs/fr/gids-import-export.md b/public/docs/fr/gids-import-export.md index c8933ed50..9f422ed84 100644 --- a/public/docs/fr/gids-import-export.md +++ b/public/docs/fr/gids-import-export.md @@ -61,6 +61,8 @@ Ces avertissements ne sont pas de la négligence — c'est un choix délibéré Un fichier `.mpp` (le format natif de Microsoft Project, Project 2010 à 2021) suit un chemin séparé : cet import est **en lecture seule** — il n'existe pas d'export `.mpp`, donc réexporter vers MS Project passe par MSPDI XML. Voir le guide [Ouvrir MS Project (.mpp)](docs://gids-msproject-import) pour savoir ce qui est conservé et quelles sont les limites. +Un fichier `.xer` est le format d'échange de Primavera P6. Il est importé directement et, après modification, enregistré au format IFC ; voir [Ouvrir Primavera P6 (.xer)](docs://gids-xer-import). + ## Importateurs d'extension Au-delà des formats fixes ci-dessus, les extensions installées peuvent ajouter leurs propres importateurs — par exemple pour un format qui n'est pas pris en charge par défaut. Ceux-ci apparaissent sous **Backstage → Importer**, chacun avec son propre nom, sa description et ses extensions de fichier correspondantes ; sans extension d'import installée, cette section est vide. Consultez **Backstage → Extensions** pour voir ce qui est disponible. diff --git a/public/docs/it/gids-import-export.md b/public/docs/it/gids-import-export.md index 347c6a04d..d1c7334c4 100644 --- a/public/docs/it/gids-import-export.md +++ b/public/docs/it/gids-import-export.md @@ -108,6 +108,8 @@ riesportare verso MS Project passa per MSPDI XML. Vedi la guida [Aprire MS Project (.mpp)](docs://gids-msproject-import) per sapere cosa viene incluso e quali sono i limiti. +Un file `.xer` è il formato di scambio di Primavera P6. Viene importato direttamente e, dopo la modifica, salvato come IFC; vedi [Aprire Primavera P6 (.xer)](docs://gids-xer-import). + ## Importatori tramite estensioni Oltre ai formati fissi sopra descritti, le estensioni installate possono aggiungere i propri diff --git a/public/docs/ja/gids-import-export.md b/public/docs/ja/gids-import-export.md index be67ff9e8..a78b8ddc7 100644 --- a/public/docs/ja/gids-import-export.md +++ b/public/docs/ja/gids-import-export.md @@ -61,6 +61,8 @@ MSPDI と同種のトレードオフがあり、いくつか P6 固有の癖も `.mpp` ファイル(Microsoft Project のネイティブ形式、Project 2010〜2021)は別の経路をたどります。このインポートは**読み取り専用**です —`.mpp` のエクスポートは存在しないため、MS Project への再エクスポートは MSPDI XML 経由になります。何が引き継がれ、どのような制限があるかについては、ガイド[MS Project(.mpp)を開く](docs://gids-msproject-import)を参照してください。 +`.xer` は Primavera P6 の交換形式です。直接インポートし、編集後は IFC として保存します。詳しくは[Primavera P6(.xer)を開く](docs://gids-xer-import)をご覧ください。 + ## 拡張機能インポーター 上記の固定形式に加えて、インストールされた拡張機能は独自のインポーターを追加できます — 例えば既定ではサポートされていない形式のためです。それらは **Backstage → インポート** の下に、それぞれ名前、説明、対応するファイル拡張子とともに表示されます。インポート用の拡張機能が1つもインストールされていない場合、そのセクションは空です。利用可能な拡張機能については **Backstage → 拡張機能** を確認してください。 diff --git a/public/docs/ko/gids-import-export.md b/public/docs/ko/gids-import-export.md index 890f1576d..cfbdab07b 100644 --- a/public/docs/ko/gids-import-export.md +++ b/public/docs/ko/gids-import-export.md @@ -98,6 +98,8 @@ MSPDI와 같은 종류의 트레이드오프이며, P6 특유의 몇 가지 특 내보내려면 MSPDI XML을 거칩니다. 무엇이 함께 오고 어떤 제한이 있는지는 [MS Project(.mpp) 열기](docs://gids-msproject-import) 가이드를 참고하세요. +`.xer`은 Primavera P6의 교환 형식입니다. 직접 가져오며 편집 후 IFC로 저장합니다. [Primavera P6(.xer) 열기](docs://gids-xer-import)를 참조하세요. + ## 확장 프로그램 가져오기 도구 위의 고정된 형식 외에도, 설치된 확장 프로그램은 기본적으로 지원되지 않는 형식 등을 위해 자체 diff --git a/public/docs/manifest.json b/public/docs/manifest.json index 1afee11ab..bf22bb62c 100644 --- a/public/docs/manifest.json +++ b/public/docs/manifest.json @@ -231,6 +231,27 @@ "layer": "gidsen", "cluster": "msproject-import" }, + { + "id": "gids-xer-import", + "title": { + "nl": "Primavera P6 (.xer) openen", + "en": "Opening Primavera P6 (.xer)", + "de": "Primavera P6 (.xer) öffnen", + "fr": "Ouvrir Primavera P6 (.xer)", + "es": "Abrir Primavera P6 (.xer)", + "zh": "打开 Primavera P6(.xer)", + "it": "Aprire Primavera P6 (.xer)", + "pt": "Abrir Primavera P6 (.xer)", + "pl": "Otwieranie Primavera P6 (.xer)", + "tr": "Primavera P6 (.xer) dosyasını açma", + "ar": "فتح Primavera P6 (.xer)", + "ja": "Primavera P6(.xer)を開く", + "ko": "Primavera P6(.xer) 열기", + "fa": "باز کردن Primavera P6 (.xer)" + }, + "layer": "gidsen", + "cluster": "xer-import" + }, { "id": "datums-zoals-opgeslagen", "title": { diff --git a/public/docs/nl/datums-zoals-opgeslagen.md b/public/docs/nl/datums-zoals-opgeslagen.md index ff13edfed..72da71360 100644 --- a/public/docs/nl/datums-zoals-opgeslagen.md +++ b/public/docs/nl/datums-zoals-opgeslagen.md @@ -73,9 +73,50 @@ Sla je op terwijl je de opgeslagen datums bekijkt, dan schrijft de app die datum doorgerekende versie. Zo overschrijf je nooit per ongeluk de planning van een collega of van het bronpakket met een uitkomst die de app er zelf bij heeft bedacht. +## Primavera P6 (.xer): deze weergave kan vanzelf aangaan + +Bij een `.xer`-bestand werkt dit net iets anders dan bij de andere formaten hierboven. Primavera P6 +levert namelijk niet alleen datums, maar ook zíjn eigen berekening daarvan — geen losse datums zonder +logica. Blijven er na het openen restverschillen over met de herberekening van Open Planner Studio, +dan zet de app zichzelf meteen in deze weergave, zonder dat je eerst op **Opgeslagen datums tonen** +hoeft te klikken. De melding boven de planning noemt in dat geval meteen het aantal activiteiten dat +zou verschuiven, en verwijst naar de vaste blijvende melding zodra de weergave actief is. + +Dat automatisch aangaan geldt alleen bij het openen van het `.xer`-bestand zelf: sla je het project +daarna op en open je het later opnieuw, dan wordt de weergave alleen nog aangeboden — je klikt dan +zelf op **Opgeslagen datums tonen** — zodat een planning die je intussen hebt bewerkt en opgeslagen +niet ongevraagd weer de oude datums van Primavera laat zien. + +De taken die in deze weergave zitten, zijn ook te herkennen in de tabel — kolom **Herkomst (opgeslagen +datums)** — en met een badge in het eigenschappenpaneel van de geselecteerde taak. **F5** en het +bewerken van een taak verlaten deze weergave op precies dezelfde manier als bij elk ander formaat (zie +hierboven); de berekening zelf gebruikt Primavera's opgeslagen datums nooit als invoer, alleen als +weergave. Zie [Primavera P6 (.xer) openen](docs://gids-xer-import) voor de rest van wat een XER-import +meebrengt. + +## "Niet vastgelegd" + +Primavera legt niet voor elke activiteit alle vier de assen laatste start, laatste einde, totale +speling en vrije speling vast — een activiteit kan bijvoorbeeld wel een vroege datum hebben, maar geen +speling. Mist zo'n as in het bronbestand, dan toont de betreffende kolom in de tabel "Niet vastgelegd" +in plaats van een getal. Dat is geen foutmelding: het betekent alleen dat het bestand zelf op dat punt +niets zei, en Open Planner Studio verzint daar dus ook niets bij. Dit geldt alleen zolang je déze weergave bekijkt: +zodra de app zijn eigen berekening toont — buiten deze weergave, of na herberekenen met **F5** — +staat in die kolom gewoon het berekende getal. + +Diezelfde eerlijkheid geldt buiten het scherm: exporteer je naar CSV terwijl deze weergave aanstaat, +dan blijft de cel voor een niet-vastgelegde as leeg in plaats van een `0` te tonen, en de +AI-assistent krijgt voor zo'n as `null` te zien met de opsomming van welke assen het bestand niet +vastlegde. De rapporten (het Rapport-tabblad, inclusief de PDF en het printvoorbeeld) kennen die +lege cel nog niet: daar staat voor een niet-vastgelegde as een leeg veld of een `0`. Zolang deze +weergave aanstaat, staat daarom boven elk rapport één melding die zegt dat je naar de datums uit het +bestand kijkt en waar die nullen vandaan komen. + ## Verder lezen - Meer over welke formaten je kunt importeren en wat daarbij wel en niet meekomt — lees de gids [Im-/export](docs://gids-import-export). - Speling en kritiek pad in detail, inclusief wat "bepalend" precies betekent — lees de gids [Kritiek pad & geavanceerde analyse](docs://gids-kritiek-pad-analyse). +- Alles over wat een `.xer`-import meebrengt — lees de gids + [Primavera P6 (.xer) openen](docs://gids-xer-import). diff --git a/public/docs/nl/gids-import-export.md b/public/docs/nl/gids-import-export.md index 580f34cc3..d6952a02a 100644 --- a/public/docs/nl/gids-import-export.md +++ b/public/docs/nl/gids-import-export.md @@ -128,12 +128,19 @@ ontwikkelaarsconsole toont dan exact welke items zijn weggelaten of vereenvoudig ## Importeren -**Bestand → Openen** (of **Backstage → Openen**) accepteert `.ifc`-, `.csv`-, `.xml`- en -`.mpp`-bestanden. Bij een `.xml`-bestand herkent de app zelf of het een Primavera P6- of een MS -Project-bestand is, aan de hand van de inhoud. Zoals hierboven beschreven: een CSV- of P6-import +**Bestand → Openen** (of **Backstage → Openen**) accepteert `.ifc`-, `.csv`-, `.xml`-, `.mpp`- en +`.xer`-bestanden. Bij een `.xml`-bestand herkent de app zelf of het een Primavera P6- of een MS +Project-bestand is, aan de hand van de inhoud. Zoals hierboven beschreven: een CSV- of Primavera P6 XML-import levert een project op **zonder baselines** (die stonden er niet in), terwijl IFC en MSPDI baselines wél meebrengen. +Een `.xer`-bestand is Primavera P6's eigen uitwisselingsformaat. De app leest het rechtstreeks, +maar schrijft geen `.xer` terug: na een bewerking sla je op als IFC. Eén XER kan meerdere huidige +projecten en baselineprojecten bevatten; de huidige projecten openen als afzonderlijke documenten +en bijbehorende baselines blijven aan hun project gekoppeld. Zie +[Primavera P6 (.xer) openen](docs://gids-xer-import) voor de projectselectie, tekencodering, +P6-getalnotatie en de bewaarde brondata. + Een `.mpp`-bestand (het native Microsoft Project-formaat, Project 2010 t/m 2021) is een aparte route: die import is **alleen-lezen** — er bestaat geen `.mpp`-export, dus terugexporteren naar MS Project loopt via MSPDI-XML. Zie de gids [MS Project (.mpp) openen](docs://gids-msproject-import) @@ -157,7 +164,7 @@ extensies beschikbaar zijn. ## Verder lezen -- Baselines gaan alleen mee via IFC en MS Project XML, niet via CSV of P6 — lees de gids +- Baselines gaan alleen mee via IFC en MS Project XML, niet via CSV of Primavera P6 XML — lees de gids [Baselines & voortgang](docs://gids-baselines-voortgang) voor hoe je een baseline vastlegt. - Resources, toewijzingen en belastingscurves — lees de gids [Resources, histogram & nivellering](docs://gids-resources-histogram) voor hoe die tot stand komen diff --git a/public/docs/nl/gids-xer-import.md b/public/docs/nl/gids-xer-import.md new file mode 100644 index 000000000..6d1da48d5 --- /dev/null +++ b/public/docs/nl/gids-xer-import.md @@ -0,0 +1,103 @@ +# Primavera P6 (.xer) openen + +Een `.xer`-bestand is het uitwisselingsformaat van Primavera P6. Open Planner Studio kan zo'n bestand rechtstreeks openen; een tussenstap via P6 XML of een externe omzetter is niet nodig. Deze gids legt uit wat de import doet, welke gegevens bewaard blijven en waar de grenzen van het huidige P6-model liggen. + +## Wat je hier leert + +- Hoe één XER-bestand meerdere projectdocumenten kan openen. +- Hoe huidige projecten, lege projecten en baselineprojecten worden behandeld. +- Welke kalender-, resource-, voortgangs- en metadata-informatie wordt ingelezen. +- Hoe tekencodering en de P6-getalnotatie veilig worden bepaald. +- Wat er gebeurt als de herberekening afwijkt van de datums die Primavera zelf al had opgeslagen. +- Wat opslaan als IFC betekent en welke P6-functies nog geen eigen rekenmodel hebben. + +## Openen en documenten + +Open een `.xer`-bestand via **Bestand → Openen** of **Ctrl+O**. Eén export kan meerdere P6-projecten bevatten. Open Planner Studio opent ieder niet-leeg huidig project als een afzonderlijk document; het document met de meeste activiteiten wordt actief. Lege projecten krijgen geen zinloos tabblad. + +Na één bestandsactie verschijnt één informatieve melding, ook wanneer er veel documenten openen. Die melding noemt de werkelijk gevonden en geopende projecten, lege projecten, baselines en eventuele terugvallen. Bij een volgende XER-bestandsactie krijg je opnieuw één eigen melding. + +Een P6-baselineproject wordt niet als los, planbaar document geopend. Als het bij een geopend huidig project hoort, wordt het als baseline bij dat document bewaard. Een verwijzing naar een baseline die niet in het bestand staat wordt niet verzonnen: die blijft buiten de baselineverzameling en wordt in de melding geteld. Een zelfverwijzing, een kring van baselineverwijzingen of een selectie die anders geen enkel document zou openen, schakelt de uitsluiting veilig terug: de betrokken projecten openen dan gewoon als huidige documenten. Dat beschermt je tegen een stil leeg scherm en maakt de terugval zichtbaar. + +Relaties tussen twee verschillende P6-projecten worden als externe bronlinks bewaard. De app rekent ze niet door als gewone relaties, omdat elk geopend document een zelfstandige planning is. + +## Wat er uit P6 meekomt + +De import leest onder meer: + +- **Projecten, WBS, activiteiten en mijlpalen**, inclusief P6-activiteits- en duurtypen. +- **Relaties, lags, constraints, voortgang en actuals**, plus P6-suspend/resume-datums waar ze in het bronbestand staan. +- **Project- en resourcekalenders**, werktijden en uitzonderingen. Uren en kloktijden blijven daarbij gegevens van de kalender in plaats van een projectbrede gok. +- **Resources, tarieven en toewijzingen**. +- **Activity codes, UDF's en notities**, inclusief hun bronstructuur en koppelingen aan activiteiten. + +Eén kalenderregel verdient een aparte vermelding. Sommige P6-exports klemmen een aaneengesloten vrij blok op de ma–vr-as: een vrije zaterdag staat dan als dubbel record op de vrijdag ervóór, een vrije zondag op de maandag erná. Ziet de lezer dat patroon op een kalender die op zaterdag of zondag wél werkt, dan maakt hij die weekenddag alsnog vrij. Zo'n gereconstrueerde dag staat niet als record in het bestand; hij heet in de kalender "Calendar exception (weekend reconstruction)" en telt mee in de kalenderbevindingen van de openingsmelding, zodat je altijd kunt zien dát de app hier iets heeft afgeleid. De regel is afgeleid uit één bestand en slaat alleen aan bij een meerdaags blok met bewijs op het record zelf; op een gewone ma–vr-kalender verandert er niets. + +De rauwe P6-brongegevens die Open Planner Studio leest, blijven onderdeel van het document. Ze reizen mee door tabwissels, undo, herstel en opslaan. Dat is iets anders dan beloven dat iedere P6-functie al een gelijkwaardig bewerk- of rekenmodel heeft: waar zo'n motor ontbreekt, bewaren we de brondata in plaats van haar stil weg te gooien. + +## Voltooide activiteiten krijgen echte speling + +Primavera zet een voltooide activiteit voor zijn hele berekening neer als een taak met nul restwerk op de statusdatum — ook aan de late kant. Open Planner Studio doet dat sinds september 2026 na, maar uitsluitend voor projecten die uit een `.xer`-bestand komen. Gevolg: een voltooide activiteit toont voortaan een echte totale speling in plaats van altijd nul, en ze legt net als elke andere taak druk op haar eigen voorgangers. Dat is geen wijziging van je gegevens: de werkelijke start- en einddatums blijven staan zoals ze in het bestand stonden. + +De regel geldt alleen waar het bronbestand hem ondersteunt: activiteiten van het type "vaste duur en eenheden" met een voortgangspercentage op duurbasis, een vastgelegd geplande venster, en een project dat resterend werk aan het plan koppelt. Verklaart het bestand bovendien dat het met *progress override* gerekend is in plaats van *retained logic*, dan blijft het oude gedrag staan. Projecten uit IFC, MS Project of Primavera P6 XML veranderen niet. + +## Tekencodering en getallen + +XER noemt zijn tekencodering niet betrouwbaar in het bestand. Een UTF-BOM wordt gevolgd; zonder BOM gebruikt de lezer geldige UTF-8 en valt hij anders terug op Windows-1252. Is zo'n niet-ASCII-keuze nodig, dan staat de gebruikte codering in de openingsmelding. De app probeert geen regels te raden of als "overgeslagen" voor te stellen. + +P6 kan de decimaal- en duizendtallenscheiding in de `CURRTYPE`-tabel vastleggen, zowel als letterlijk teken als met symbolische tokens zoals `ds_Period` en `dg_Comma`. Die notatie wordt gelezen vóór duren, werk en float worden omgezet. Ontbreekt `CURRTYPE`, dan is punt de veilige standaard. Lijkt een waarde een komma-decimaal terwijl die broninformatie ontbreekt, dan stopt de import met een gerichte fout in plaats van een mogelijk verkeerd schema te openen. + +## Datums zoals Primavera ze opsloeg + +Open Planner Studio herberekent een geopende planning altijd zelf — ook een `.xer`-bestand. Op de +meeste bestanden komt die herberekening exact overeen met wat Primavera zelf had opgeslagen. Blijven +er verschillen over, dan zet de app zichzelf bij het openen automatisch in de weergave **datums zoals +opgeslagen**: je ziet dan Primavera's eigen resultaat op het scherm, niet onze herberekening. Dat is +een andere volgorde dan bij andere formaten — daar biedt de app deze weergave alleen aan via een knop; +bij een `.xer`-bestand met restverschillen staat hij meteen aan, omdat het bestand een betrouwbare, +eigen berekening meebrengt in plaats van alleen datums zonder rekenlogica. + +Bij het openen verschijnt dezelfde ene melding als hierboven, aangevuld met het aantal activiteiten +waarvan een herberekening de datums zou verschuiven. Taken die in deze weergave zitten, zijn herkenbaar +in de tabel — kolom **Herkomst (opgeslagen datums)** — en in het eigenschappenpaneel van de +geselecteerde taak. Ontbreekt voor een taak de laatste start, het laatste einde, de totale speling of +de vrije speling in het bronbestand, dan toont de betreffende kolom "Niet vastgelegd" in plaats van een +verzonnen getal. + +Bewerk je een taak, of druk je op **F5**, dan verlaat de app deze weergave en rekent hij gewoon weer +door — precies zoals bij elk ander formaat. De herberekening zelf gebruikt Primavera's opgeslagen +datums nooit als invoer: ze reizen als aparte, alleen-lezen brondata mee en worden uitsluitend gebruikt +om te tonen wat het bestand zei, nooit om te sturen wat de app berekent. Sla je op als IFC, dan gaan Primavera's +opgeslagen datums mee het projectbestand in — inclusief welke assen het bronbestand niet vastlegde. +Bij het openen van dat IFC-bestand zet de app deze weergave niet uit zichzelf weer aan: je krijgt de +melding met een knop **Opgeslagen datums tonen** en kiest zelf. Zo kan een planning die je intussen +hebt bewerkt en opgeslagen nooit ongevraagd weer met de oude datums op het scherm komen. + +Zie [Datums zoals opgeslagen](docs://datums-zoals-opgeslagen) voor de volledige uitleg van deze +weergave, inclusief wat je wel en niet ziet zolang hij actief is en hoe je er handmatig weer uit stapt. + +## Opslaan en uitwisselen + +Een XER-import is een **import**, geen XER-editor of XER-exporter. Wanneer je daarna opslaat, schrijft Open Planner Studio een IFC-bestand. Dat IFC is het eigen projectbestand en bewaart de gelezen XER-brondata naast de gegevens waarmee de app werkt. Het oorspronkelijke `.xer`-bestand wordt nooit stil overschreven. + +Die bewaarde brondata heeft een prijs bij grote bestanden. Het volledige oorspronkelijke `.xer`-bestand reist mee in het projectbestand én in elke crashherstel-snapshot, zonder bovengrens. Gemeten op het grootste testbestand (een `.xer` van 17,7 MB met ruim 2.000 activiteiten): het IFC-projectbestand wordt ongeveer 50 MB, opslaan duurt tientallen seconden en het crashherstel schrijft dat bestand elke tien seconden opnieuw zolang je bewerkt. Bij een export met veel projecten vermenigvuldigt dat: elk geopend document draagt zijn eigen kopie. Voor de meeste planningen merk je hier niets van; werk je met een export van tientallen megabytes, houd dan rekening met een traag opslaan en een grote projectmap. + +Voor uitwisseling naar Primavera bestaat de bestaande **Primavera P6 XML**-export. Dat is een ander formaat met eigen beperkingen; zie [Im-/export](docs://gids-import-export). Bewaar daarom altijd ook het IFC-bestand wanneer je een bewerkt project later opnieuw wilt openen. + +## Grenzen die zichtbaar blijven + +Een paar P6-begrippen zijn al opgeslagen, maar hebben nog geen volledig gelijkwaardig rekenmodel: + +- **`TT_Rsrc`** (resource-dependent activity) en **`TT_WBS`** worden als P6-brontype bewaard. De solver heeft nog geen afzonderlijke P6-rekenmodus voor deze typen. +- Een P6-resourcecurve met 21 punten wordt als bronverdeling bewaard. Een herkenbare vorm kan voor het histogram naar de dichtstbijzijnde ingebouwde curve worden vertaald, maar de oorspronkelijke 21-puntsvorm wordt na een bewerking nog niet opnieuw berekend. +- De bestaande **P6 XML**-lezer en deze XER-lezer hebben nog niet dezelfde volledige veldendekking. XER kan daarom gegevens bevatten die P6 XML in de app nog niet leest of schrijft. +- **Projecteinde als spelingsanker zonder einddatum.** Staat in het bestand de P6-optie "bereken totale speling ten opzichte van het projecteinde" aan, maar heeft het project géén *Must Finish By*-datum en geen enkele activiteit een geplande einddatum, dan valt het projecteinde in de app terug op de projectstart. Alle laatste datums verankeren dan daarop en vrijwel elke activiteit toont negatieve speling en staat kritiek. In het testmateriaal komt die combinatie voor in P6-exports van kleine, kaal aangemaakte projecten. De vroege datums en de weergave **datums zoals opgeslagen** kloppen wél; alleen de herberekende late kant is dan niet bruikbaar, en er is nog geen schakelaar om de optie uit te zetten. Dit staat als bekende fout geregistreerd. + +Deze grenzen verwijderen geen brongegevens uit het IFC-projectbestand. Als XER-specifieke brondata aanwezig is en je exporteert naar CSV, MS Project XML of Primavera P6 XML, past die broninformatie niet volledig in het doelformaat. Na een geslaagde export verschijnt daarom één informatieve melding met een link naar deze gids. Annuleer je de export of mislukt het opslaan, dan verschijnt die melding niet. De export naar IFC bewaart de XER-brondata; de andere exports nemen alleen de gegevens mee die hun eigen formaat ondersteunt. Het oorspronkelijke `.xer`-bestand wordt niet overschreven. + +## Verder lezen + +- [Kalenders & uren-planning](docs://gids-kalenders-uren) legt uit hoe werktijden en uitzonderingen de planning sturen. +- [Resources, histogram & nivellering](docs://gids-resources-histogram) behandelt resources, toewijzingen en belasting in Open Planner Studio. +- [Baselines & voortgang](docs://gids-baselines-voortgang) legt het gebruik van baselines na import uit. +- [Im-/export](docs://gids-import-export) vergelijkt IFC, CSV, MS Project XML en Primavera P6 XML. diff --git a/public/docs/pl/gids-import-export.md b/public/docs/pl/gids-import-export.md index eb38a4599..b47a88b7c 100644 --- a/public/docs/pl/gids-import-export.md +++ b/public/docs/pl/gids-import-export.md @@ -101,6 +101,8 @@ Plik `.mpp` (natywny format Microsoft Project, Project 2010–2021) to osobna ś MSPDI XML. Zobacz przewodnik [Otwieranie MS Project (.mpp)](docs://gids-msproject-import), aby dowiedzieć się, co jest przenoszone i jakie są ograniczenia. +Plik `.xer` jest formatem wymiany Primavera P6. Jest importowany bezpośrednio, a po edycji zapisywany jako IFC; zobacz [Otwieranie Primavera P6 (.xer)](docs://gids-xer-import). + ## Importery z rozszerzeń Poza powyższymi stałymi formatami, zainstalowane rozszerzenia mogą dodawać własne importery — na przykład dla diff --git a/public/docs/pt/gids-import-export.md b/public/docs/pt/gids-import-export.md index 42e6287cc..81da5a4a8 100644 --- a/public/docs/pt/gids-import-export.md +++ b/public/docs/pt/gids-import-export.md @@ -103,6 +103,8 @@ para o MS Project passa pelo MSPDI XML. Veja o guia [Abrir o MS Project (.mpp)](docs://gids-msproject-import) para saber o que é trazido e quais são as limitações. +Um ficheiro `.xer` é o formato de intercâmbio do Primavera P6. É importado diretamente e, depois de editado, guardado como IFC; consulte [Abrir o Primavera P6 (.xer)](docs://gids-xer-import). + ## Importadores de extensões Além dos formatos fixos acima, as extensões instaladas podem adicionar os seus próprios importadores — por exemplo para um diff --git a/public/docs/tr/gids-import-export.md b/public/docs/tr/gids-import-export.md index 7dbb5c326..eeeb9424f 100644 --- a/public/docs/tr/gids-import-export.md +++ b/public/docs/tr/gids-import-export.md @@ -61,6 +61,8 @@ Bu uyarılar özensizlik değildir — kasıtlı, açık bir seçimdir: düşür Bir `.mpp` dosyası (Microsoft Project'in yerel biçimi, Project 2010–2021) ayrı bir yol izler: bu içe aktarma **salt okunurdur** — bir `.mpp` dışa aktarımı yoktur, bu yüzden MS Project'e yeniden dışa aktarma MSPDI XML üzerinden yapılır. Neyin geldiğini ve sınırlamaların neler olduğunu görmek için [MS Project (.mpp) dosyasını açma](docs://gids-msproject-import) kılavuzuna bakın. +`.xer` dosyası Primavera P6'nın değişim biçimidir. Doğrudan içe aktarılır ve düzenlemeden sonra IFC olarak kaydedilir; bkz. [Primavera P6 (.xer) dosyasını açma](docs://gids-xer-import). + ## Uzantı içe aktarıcıları Yukarıdaki sabit biçimlerin ötesinde, yüklü uzantılar kendi içe aktarıcılarını ekleyebilir — örneğin varsayılan olarak desteklenmeyen bir biçim için. Bunlar **Backstage → İçe aktar** altında, her biri kendi adı, açıklaması ve eşleşen dosya uzantılarıyla görünür; hiçbir içe aktarma uzantısı yüklü değilken bu bölüm boştur. Nelerin mevcut olduğunu görmek için **Backstage → Uzantılar**'ı kontrol edin. diff --git a/public/docs/zh/gids-import-export.md b/public/docs/zh/gids-import-export.md index 385ac2d57..413d1b280 100644 --- a/public/docs/zh/gids-import-export.md +++ b/public/docs/zh/gids-import-export.md @@ -61,6 +61,8 @@ MSPDI 比 CSV 丰富得多:资源、分配(包括其负荷曲线)、日历 `.mpp` 文件(Microsoft Project 的原生格式,Project 2010 至 2021)走的是另一条路径:该导入是**只读**的——不存在 `.mpp` 导出格式,因此重新导出到 MS Project 要走 MSPDI XML。参见指南[打开 MS Project(.mpp)](docs://gids-msproject-import)了解都保留了哪些内容以及有哪些限制。 +`.xer` 是 Primavera P6 的交换格式。应用可直接导入它;编辑后保存为 IFC。请参阅[打开 Primavera P6(.xer)](docs://gids-xer-import)。 + ## 扩展导入器 除了上述固定格式外,已安装的扩展可以添加自己的导入器——例如针对默认不支持的格式。它们会显示在 **Backstage → 导入**下,各自带有自己的名称、描述和匹配的文件扩展名;未安装任何导入扩展时,该部分为空。查看 **Backstage → 扩展**了解有哪些可用。 diff --git a/scripts/README.md b/scripts/README.md index 5f3623930..6ed6060b0 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -43,6 +43,13 @@ aangeroepen: - `example-resources.ts` — de resourcepool - `example-topologies.json` — de relatienetwerken (117 kB data, geen code) +## Meetlatdata genereren + +`node scripts/generate-p6-verified-cases.mjs ` schrijft +`tests/planning/cases-p6-verified.json` opnieuw uit de publieke P6 23.12-capture. Alleen +`activity_code` en de `*_p6`-kolommen komen mee; kloktijden en actual-suffixen blijven onvertaald. +De `*_engine`-kolommen en PASS-oordelen zijn uitdrukkelijk geen brondata voor deze generator. + ## Release en publicatie | script | aangeroepen door | doet | diff --git a/scripts/generate-p6-verified-cases.mjs b/scripts/generate-p6-verified-cases.mjs new file mode 100644 index 000000000..2f990e8c2 --- /dev/null +++ b/scripts/generate-p6-verified-cases.mjs @@ -0,0 +1,144 @@ +#!/usr/bin/env node + +/** + * Extraheert uitsluitend de door P6 23.12 vastgelegde kolommen uit het publieke + * cpp-cpm-engine-vergelijkingsraamwerk. De enginekolommen en verdicts worden niet ingelezen in het + * resultaat: hun vergelijking gooide kloktijden weg, normaliseerde exclusieve/inclusieve finishes + * en de bronengine is na de capture op deze cases nagefit. Ze zijn daarom geen onafhankelijke + * meetlat voor Open Planner Studio. + * + * Gebruik: + * node scripts/generate-p6-verified-cases.mjs [uitvoer-json] + * + * De bronmap mag ook via OPS_P6_COMPARISON worden gezet. Er staat bewust geen lokaal corpuspad in + * dit script. + */ + +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const sourceRoot = process.argv[2] ?? process.env.OPS_P6_COMPARISON; +const outputPath = process.argv[3] + ?? fileURLToPath(new URL('../tests/planning/cases-p6-verified.json', import.meta.url)); + +if (!sourceRoot || !existsSync(join(sourceRoot, 'cases'))) { + console.error('Geef een bestaande p6-comparison-map via argument 1 of OPS_P6_COMPARISON.'); + process.exit(1); +} + +function parseCsv(text) { + const rows = []; + let row = []; + let cell = ''; + let quoted = false; + for (let index = 0; index < text.length; index++) { + const char = text[index]; + if (quoted) { + if (char === '"' && text[index + 1] === '"') { + cell += '"'; + index++; + } else if (char === '"') { + quoted = false; + } else { + cell += char; + } + continue; + } + if (char === '"') quoted = true; + else if (char === ',') { + row.push(cell); + cell = ''; + } else if (char === '\n') { + row.push(cell.endsWith('\r') ? cell.slice(0, -1) : cell); + rows.push(row); + row = []; + cell = ''; + } else { + cell += char; + } + } + if (cell.length > 0 || row.length > 0) { + row.push(cell); + rows.push(row); + } + return rows; +} + +const selectedColumns = ['activity_code', 'ES_p6', 'EF_p6', 'LS_p6', 'LF_p6', 'TF_p6', 'FF_p6']; +const expectedCases = { + '01-fs-chain': { digest: '9d06cd88cfdce11a33d853ca5410088d9ab0a1a82aa384237147cd9a08ac70fb', activities: ['A', 'B', 'C'] }, + '02-ss-with-lag': { digest: '7719cb13f2bfd5297dc5302f9f2ab672376d7b6e7cafcbfa5c52cbbf64c45cae', activities: ['A', 'B'] }, + '03-ff-with-lag': { digest: 'bb8f76a2bdb926bdd0e7177f28c94da117cd8c55b110882f84ae958ed028d2da', activities: ['A', 'B'] }, + '04-sf-edge-case': { digest: 'be04f8ccd8430726635b87654b2039d64fb70779f12562f260bdf3e72d3eb654', activities: ['A', 'B'] }, + '05-negative-float': { digest: '463f4ea2ee5e973340d82b26e2adbf0305b0815161f8b78dc074f381140d22b1', activities: ['A', 'B'] }, + '06-multiple-calendars': { digest: '8623f28be05dfcbee390746c5ece5aacfb5c1501edbee3e74ffee92ac1730432', activities: ['A', 'B'] }, + '07-ontario-holidays': { digest: '23ec9703eab963f8f1300ecdc7bfadd902668314d3008e172048a7adb5042026', activities: ['A'] }, + '08-in-progress-retained-logic': { digest: '67c9811d83cdeace67fe7b410aa59b8550ae30cee287d6ee2bda7cd4347e255d', activities: ['A', 'B'] }, + '09-completed-successor': { digest: '0307a19fb51a61f1ad7b6445cbd6f2a20618ee4408912bc2cd320aca6ed1416b', activities: ['A', 'B'] }, + '10-out-of-sequence-progress': { digest: '2f598578d4539a95ea80698ab2979fd28a3cb007d8fe09e679a1173f00f5c5f6', activities: ['A', 'B'] }, + '11-mandatory-start-finish': { digest: 'b4353c7fcdfff89ae1740cfa1e85a5c72d7c9493a53eea37a5cab246b828453b', activities: ['A', 'B'] }, + '12-snet-fnlt': { digest: '42480cf535150559b1ed5ed11d5e7604ee3c6ae1d666b9a555ab84b4936f0ee6', activities: ['A', 'B'] }, + '13-alap': { digest: '6ed931a167215ec05a8410c0751a02e429026b62410f0fed4f8768746f6a37bc', activities: ['A', 'B', 'C'] }, +}; +const expectedCaseIds = Object.keys(expectedCases); +const caseDirs = readdirSync(join(sourceRoot, 'cases'), { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => entry.name) + .sort(); +if (JSON.stringify(caseDirs) !== JSON.stringify(expectedCaseIds)) { + throw new Error(`onverwachte caseset: verwacht ${expectedCaseIds.join(', ')}, kreeg ${caseDirs.join(', ')}`); +} + +const cases = caseDirs.map(id => { + const csvPath = join(sourceRoot, 'cases', id, 'comparison.csv'); + if (!existsSync(csvPath)) throw new Error(`${id}: comparison.csv ontbreekt`); + const sourceBytes = readFileSync(csvPath); + const digest = createHash('sha256').update(sourceBytes).digest('hex'); + if (digest !== expectedCases[id].digest) { + throw new Error(`${id}: bron-SHA-256 verwacht ${expectedCases[id].digest}, kreeg ${digest}`); + } + const [header, ...rows] = parseCsv(sourceBytes.toString('utf-8')); + const indexes = selectedColumns.map(column => { + const matches = header.map((value, index) => value === column ? index : -1).filter(index => index >= 0); + if (matches.length !== 1) throw new Error(`${id}: kolom ${column} moet exact eenmaal voorkomen`); + return matches[0]; + }); + const activities = rows + .filter(row => row.some(cellValue => cellValue !== '')) + .map((row, rowIndex) => { + const activity = Object.fromEntries(selectedColumns.map((column, index) => [column, row[indexes[index]] ?? ''])); + for (const field of ['ES_p6', 'EF_p6', 'LS_p6', 'LF_p6']) { + if (activity[field] !== '' && !/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?: A)?$/.test(activity[field])) { + throw new Error(`${id}/rij ${rowIndex + 2}/${field}: ongeldige P6-datum ${JSON.stringify(activity[field])}`); + } + } + for (const field of ['TF_p6', 'FF_p6']) { + if (activity[field] !== '' && !/^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$/.test(activity[field])) { + throw new Error(`${id}/rij ${rowIndex + 2}/${field}: ongeldige P6-float ${JSON.stringify(activity[field])}`); + } + } + return activity; + }); + const activityCodes = activities.map(activity => activity.activity_code); + if (new Set(activityCodes).size !== activityCodes.length) throw new Error(`${id}: dubbele activity_code`); + if (JSON.stringify(activityCodes) !== JSON.stringify(expectedCases[id].activities)) { + throw new Error(`${id}: activiteitset verwacht ${expectedCases[id].activities.join(', ')}, kreeg ${activityCodes.join(', ')}`); + } + return { id, sourceSha256: digest, activities }; +}); + +const output = { + provenance: { + repository: 'https://github.com/danafitkowski/cpp-cpm-engine.git', + sourceCommit: 'c279a5c4ff204ba763a6f9726aa6383574b50475', + capture: 'Menselijke P6 23.12-capture van 2026-08-11; het primaire capturesheet is niet meegecommit.', + scope: 'Uitsluitend activity_code en de onvertaalde *_p6-kolommen zijn overgenomen; kloktijden en actual-suffixen blijven staan.', + warning: 'De *_engine-kolommen en PASS-oordelen zijn geen meetlat: daarbij is tijd weggegooid, het exclusief/inclusief-finishverschil genormaliseerd en de bronengine na deze capture nagefit.', + }, + cases, +}; + +writeFileSync(outputPath, `${JSON.stringify(output, null, 2)}\n`, 'utf-8'); +console.log(`P6-vergelijkingsdata geschreven: ${cases.length} cases naar ${outputPath}`); diff --git a/scripts/i18n-diff.mjs b/scripts/i18n-diff.mjs index 3a165ad01..7cb5d1a84 100644 --- a/scripts/i18n-diff.mjs +++ b/scripts/i18n-diff.mjs @@ -84,11 +84,37 @@ const baseFiles = fs.readdirSync(path.join(localesDir, base)).filter((f) => f.en let totalMissing = 0; const report = {}; +const basePluralReport = {}; for (const file of baseFiles) { const baseData = JSON.parse(fs.readFileSync(path.join(localesDir, base, file), 'utf8')); const basePaths = collectPaths(baseData); + // De bronlocale is óók een echte locale. Zonder deze check kan iemand bijvoorbeeld nl/_other + // verwijderen: de 13 doelvertalingen vergelijken dan nog steeds met de verminkte bron en de + // poort blijft ten onrechte groen. Verzamel pluralen per stam, zodat alle CLDR-vormen van het + // Nederlands precies eenmaal vereist zijn, net als verderop voor elke doelvertaling. + const foundByStem = new Map(); + for (const p of basePaths) { + const match = p.match(PLURAL_SUFFIX); + if (!match) continue; + const stem = p.slice(0, -match[0].length); + const found = foundByStem.get(stem) || new Set(); + found.add(match[1]); + foundByStem.set(stem, found); + } + const baseCategories = categoriesFor(base); + for (const [stem, found] of foundByStem) { + const missing = [...baseCategories].filter(category => !found.has(category)); + const extra = [...found].filter(category => !baseCategories.has(category)); + if (!missing.length && !extra.length) continue; + const problems = []; + if (missing.length) problems.push(`${stem}: ontbreekt ${missing.join(', ')}`); + if (extra.length) problems.push(`${stem}: overbodig ${extra.join(', ')}`); + basePluralReport[file] = [...(basePluralReport[file] || []), ...problems]; + totalMissing += missing.length + extra.length; + } + for (const locale of others) { const expected = expectedPathsFor(basePaths, locale); const filePath = path.join(localesDir, locale, file); @@ -111,10 +137,19 @@ for (const file of baseFiles) { const jsonMode = process.argv.includes('--json'); if (jsonMode) { // Rapportagemodus: bedoeld om doorgesluisd te worden, dus geen exitcode-poort. - console.log(JSON.stringify(report, null, 2)); + console.log(JSON.stringify({ ...(Object.keys(basePluralReport).length ? { [base]: basePluralReport } : {}), ...report }, null, 2)); process.exit(0); } +if (Object.keys(basePluralReport).length) { + const count = Object.values(basePluralReport).reduce((total, paths) => total + paths.length, 0); + console.log(`\n=== ${base}: ${count} ongeldige CLDR-pluralvorm(en) ===`); + for (const [file, paths] of Object.entries(basePluralReport)) { + console.log(` ${file}:`); + for (const problem of paths) console.log(` - ${problem}`); + } +} + for (const locale of others) { const files = report[locale]; const count = files ? Object.values(files).reduce((a, b) => a + b.length, 0) : 0; diff --git a/src/App.tsx b/src/App.tsx index 747aae154..1cdf9d57d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -36,6 +36,7 @@ import { StructureLockedNotice } from '@/components/layout/StructureLockedNotice import { DependencyModeNotice } from '@/components/layout/DependencyModeNotice'; import { RecordedDatesNotice } from '@/components/layout/RecordedDatesNotice'; import { NotificationHost } from '@/components/layout/NotificationHost'; +import { DOCUMENT_TABPANEL_ID, documentTabId } from '@/components/layout/DocumentChrome/documentTabNavigation'; // Code-splitting (pakket E2): componenten die pas achter een `ui.show*`-vlag, een ribbontab of een // overlay renderen worden lazy geladen, zodat hun code niet in de eager first-load-bundel zit maar @@ -104,6 +105,7 @@ function AppContent() { const uiFontFamily = useAppStore(s => s.ui.uiFontFamily); const uiFontScale = useAppStore(s => s.ui.uiFontScale); const documentChromeStyle = useAppStore(s => s.ui.documentChromeStyle); + const activeDocumentId = useAppStore(s => s.activeDocumentId); // Recovery-restore bij opstarten (Tauri én web): detectie + RecoveryDialog-callbacks; levert ook @@ -276,6 +278,11 @@ function AppContent() {
{isFullPanel ? ( // Full panel views (Table, IFC, Report) — eigen kaart diff --git a/src/components/backstage/Backstage.tsx b/src/components/backstage/Backstage.tsx index bd1d878bc..517d9072a 100644 --- a/src/components/backstage/Backstage.tsx +++ b/src/components/backstage/Backstage.tsx @@ -266,7 +266,7 @@ function ExamplesSection() { const res = await fetch(`${import.meta.env.BASE_URL}examples/${ex.file}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const content = await res.text(); - openExampleFromString(content, ex.name, buildImportLabels(tCommon)); + await openExampleFromString(content, ex.name, buildImportLabels(tCommon)); // Showcase-voorbeelden delen één demo-resourcebibliotheek (issue #19, user-verzoek). De // laadgrens heeft al gerekend; het linken herleidt zelf de resourcebelasting opnieuw. if (ex.category === 'showcase') applyDemoLibraryToShowcaseProject(); diff --git a/src/components/backstage/HelpPanel.tsx b/src/components/backstage/HelpPanel.tsx index 4c5d63fb1..4f133c834 100644 --- a/src/components/backstage/HelpPanel.tsx +++ b/src/components/backstage/HelpPanel.tsx @@ -187,7 +187,7 @@ export function HelpPanel() { const res = await fetch(`${import.meta.env.BASE_URL}examples/${file}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const content = await res.text(); - openExampleFromString(content, file, buildImportLabels(tCommon)); + await openExampleFromString(content, file, buildImportLabels(tCommon)); // Showcase-voorbeelden delen één demo-resourcebibliotheek (issue #19, user-verzoek): zelfde // volgorde als Backstage → Voorbeelden (`ExamplesSection.handleOpen`). Deze aanroeper kent // alleen de bestandsnaam (geen manifest-`category`) — de showcase-bestanden dragen allemaal het diff --git a/src/components/canvas/ContextMenu.tsx b/src/components/canvas/ContextMenu.tsx index 3b74e163a..fdfe699d9 100644 --- a/src/components/canvas/ContextMenu.tsx +++ b/src/components/canvas/ContextMenu.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { useClickOutside } from '@/hooks/useClickOutside'; import { Task } from '@/types/task'; import type { WorkCalendar } from '@/types/calendar'; +import { isSummaryTask } from '@/utils/taskHierarchy'; export interface ContextMenuGroupInfo { key: string; @@ -129,7 +130,7 @@ export function ContextMenu({ // (anders valt de flyout goeddeels buiten beeld). const flipSub = pos.left > window.innerWidth - 380; - const isSummary = task ? task.childIds.length > 0 : false; + const isSummary = isSummaryTask(task ?? undefined); const closeAll = () => { setOpenSub(null); onClose(); }; return ( diff --git a/src/components/dialogs/ExtensionConsentDialog.tsx b/src/components/dialogs/ExtensionConsentDialog.tsx index b4b1f2227..eb424d74f 100644 --- a/src/components/dialogs/ExtensionConsentDialog.tsx +++ b/src/components/dialogs/ExtensionConsentDialog.tsx @@ -14,6 +14,12 @@ import { resolveExtensionConsent } from '@/extensions/consentBridge'; * extensie is hiertoe beperkt" en daarmee een garantie suggereren die er niet is — erger dan geen * dialoog. De declaratie staat er wél, expliciet gelabeld als *voorgenomen* gebruik. * + * Eén nuance sinds de XER-etappe (eindreview bevinding 9): `importSource` is de enige permissie met + * een echte privacybetekenis — hij geeft de rauwe bronbytes van elk geïmporteerd bestand vrij en is + * op de ondersteunde API wél hard afgedwongen (default-deny, `permissions.ts`). Dat maakt hem geen + * sandbox-grens (dezelfde realm blijft), maar het is wel het enige chipje dat een gebruiker moet + * kunnen lezen zonder de broncode te kennen; daarom krijgt het als enige een vertaalde toelichting. + * * De vraag gaat over één ding: dit is code van iemand anders, met dezelfde rechten als de app. * Daarom staan er drie dingen die je nodig hebt om te beslissen — wie het schreef, waar het vandaan * komt (en of die download geverifieerd is), en wat dat concreet betekent op dit platform. @@ -106,6 +112,11 @@ export function ExtensionConsentDialog() { ))}
+ {pending.declared.includes('importSource') && ( +

+ {t('extConsent.permImportSource')} +

+ )} diff --git a/src/components/dialogs/PoolImportDialog.tsx b/src/components/dialogs/PoolImportDialog.tsx index 2da921700..8157d604a 100644 --- a/src/components/dialogs/PoolImportDialog.tsx +++ b/src/components/dialogs/PoolImportDialog.tsx @@ -92,7 +92,7 @@ export function PoolImportDialog() { const res = await openFileDialog([{ name: 'IFC', extensions: ['ifc'] }]); if (!res) return; try { - const pool = readPoolIFC(res.content); + const pool = await readPoolIFC(res.content); setImported(pool); // Voorselectie (issue #19, kern van de fix): gedelegeerd aan de PURE `resolvePoolImportPreselection` // (critreview F1 — direct headless testbaar, spiegelt de reviewer zijn eigen diff --git a/src/components/layout/DocumentChrome/CloseDocumentDialog.tsx b/src/components/layout/DocumentChrome/CloseDocumentDialog.tsx index 56b77a303..7abbbb8d4 100644 --- a/src/components/layout/DocumentChrome/CloseDocumentDialog.tsx +++ b/src/components/layout/DocumentChrome/CloseDocumentDialog.tsx @@ -1,6 +1,13 @@ +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useAppStore } from '@/state/appStore'; import { documentTitle } from '@/utils/documents'; +import { CloseDocumentDialogControl } from './CloseDocumentDialogControl'; +import { + createCloseDocumentActionGate, + createCloseDocumentDialogActions, + type CloseDocumentActionGate, +} from './closeDocumentActions'; /** * Sluit-bevestiging met drie keuzes bij een document met niet-opgeslagen @@ -21,7 +28,24 @@ export function CloseDocumentDialog() { const setUI = useAppStore((s) => s.setUI); const closeDocument = useAppStore((s) => s.closeDocument); const switchDocument = useAppStore((s) => s.switchDocument); - const saveFile = useAppStore((s) => s.saveFile); + const saveFileForDocument = useAppStore((s) => s.saveFileForDocument); + const openerRef = useRef(null); + const cancelButtonRef = useRef(null); + const actionGateRef = useRef(null); + const [savePending, setSavePending] = useState(false); + if (!actionGateRef.current) actionGateRef.current = createCloseDocumentActionGate(); + + useEffect(() => { + if (!pendingId) { + actionGateRef.current = createCloseDocumentActionGate(); + setSavePending(false); + return; + } + if (actionGateRef.current?.started) return; + const activeElement = document.activeElement; + openerRef.current = activeElement instanceof HTMLElement ? activeElement : null; + cancelButtonRef.current?.focus(); + }, [pendingId]); if (!pendingId) return null; @@ -30,55 +54,34 @@ export function CloseDocumentDialog() { const fp = pendingId === activeId ? filePath : entry?.payload?.filePath ?? null; const name = documentTitle(fp, proj?.name ?? '') || t('project.untitled'); - const cancel = () => setUI({ pendingCloseDocId: null }); - const dontSave = () => { closeDocument(pendingId); setUI({ pendingCloseDocId: null }); }; - const save = async () => { - if (pendingId !== useAppStore.getState().activeDocumentId) switchDocument(pendingId); - try { - await saveFile(); - // Alleen sluiten als het opslaan ook echt lukte (bij een geannuleerde 'Opslaan als…' of een - // schrijffout blijft isDirty staan → document open laten, geen werk verliezen). - if (!useAppStore.getState().isDirty) closeDocument(pendingId); - } finally { - // Ongeacht de afloop moet de bevestiging weg, anders blokkeert een mislukte opslag de hele - // app. Blijft nodig óók nu `saveFile` zelf vangt — verdedigingslinie tegen een throw hogerop. - setUI({ pendingCloseDocId: null }); - } + const restoreOpenerFocus = () => { + const opener = openerRef.current; + if (opener?.isConnected) opener.focus({ preventScroll: true }); }; + const actions = createCloseDocumentDialogActions({ + gate: actionGateRef.current, + pendingId, + getActiveDocumentId: () => useAppStore.getState().activeDocumentId, + switchDocument, + closeDocument, + saveFile: () => saveFileForDocument(pendingId), + clearPending: () => { setUI({ pendingCloseDocId: null }); }, + restoreOpenerFocus, + onSavePendingChange: setSavePending, + }); return ( -
-
e.stopPropagation()} - style={{ - width: 420, maxWidth: '90%', background: 'var(--theme-surface-elevated)', - border: '1px solid var(--theme-border)', borderRadius: 'var(--radius-lg)', - boxShadow: 'var(--shadow-pop)', padding: 20, - }} - > -

- {t('documents.closeTitle')} -

-

- {t('documents.closeBody', { name })} -

-
- - - -
-
-
+ { void actions.save(); }} + /> ); } diff --git a/src/components/layout/DocumentChrome/CloseDocumentDialogControl.tsx b/src/components/layout/DocumentChrome/CloseDocumentDialogControl.tsx new file mode 100644 index 000000000..4f4cb96a0 --- /dev/null +++ b/src/components/layout/DocumentChrome/CloseDocumentDialogControl.tsx @@ -0,0 +1,87 @@ +import type { MouseEvent, Ref } from 'react'; + +interface CloseDocumentDialogControlProps { + title: string; + body: string; + cancelLabel: string; + discardLabel: string; + saveLabel: string; + busy: boolean; + cancelButtonRef: Ref; + onCancel: () => void; + onDiscard: () => void; + onSave: () => void; +} + +/** Prop-gedreven dialooglaag, zodat de echte backdrop- en knopwiring gericht uitvoerbaar is. */ +export function CloseDocumentDialogControl({ + title, + body, + cancelLabel, + discardLabel, + saveLabel, + busy, + cancelButtonRef, + onCancel, + onDiscard, + onSave, +}: CloseDocumentDialogControlProps) { + return ( +
+
) => event.stopPropagation()} + style={{ + width: 420, maxWidth: '90%', background: 'var(--theme-surface-elevated)', + border: '1px solid var(--theme-border)', borderRadius: 'var(--radius-lg)', + boxShadow: 'var(--shadow-pop)', padding: 20, + }} + > +

+ {title} +

+

+ {body} +

+
+ + + +
+
+
+ ); +} diff --git a/src/components/layout/DocumentChrome/DocumentChrome.css b/src/components/layout/DocumentChrome/DocumentChrome.css index c03eccfff..e1b973fc3 100644 --- a/src/components/layout/DocumentChrome/DocumentChrome.css +++ b/src/components/layout/DocumentChrome/DocumentChrome.css @@ -23,15 +23,25 @@ padding: 0 6px; gap: 3px; } .ops-tabstrip-menu { align-self: center; width: 30px; height: 26px; margin-right: 2px; } +.ops-tabstrip-scroll-button { align-self: center; width: 24px; height: 26px; flex: none; } +.ops-tabstrip-viewport { + align-self: stretch; flex: 1 1 auto; min-width: 0; overflow-x: auto; overflow-y: hidden; + scrollbar-width: thin; scroll-behavior: smooth; +} +.ops-tabstrip-tabs { display: flex; align-items: stretch; gap: 3px; min-width: max-content; height: 100%; } +.ops-tab-group { + position: relative; align-self: flex-end; flex: none; min-width: 158px; max-width: 230px; height: 100%; +} .ops-tab { - position: relative; display: flex; align-items: center; gap: 8px; - padding: 0 8px 0 11px; min-width: 158px; max-width: 230px; align-self: flex-end; + position: relative; display: flex; align-items: center; gap: 8px; flex: none; + width: 100%; padding: 0 32px 0 11px; min-width: 0; max-width: none; align-self: flex-end; height: 31px; border-radius: 7px 7px 0 0; border: 1px solid transparent; - border-bottom: none; background: transparent; cursor: pointer; overflow: hidden; + border-bottom: none; background: transparent; cursor: pointer; overflow: hidden; outline: none; font-family: var(--font-body); } .ops-tab:hover { background: var(--theme-hover); } .ops-tab.active { background: var(--theme-ribbon-content-bg); border-color: var(--theme-border); } +.ops-tab:focus-visible { box-shadow: inset 0 0 0 2px var(--theme-accent); } .ops-tab.active::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px; background: var(--theme-accent); } @@ -43,11 +53,12 @@ .ops-tab.active .ops-tab-name { font-weight: 600; color: var(--theme-text); } .ops-dirty-dot { width: 7px; height: 7px; border-radius: 9999px; flex: none; background: var(--theme-warning-text); } .ops-tab-close { + position: absolute; right: 7px; bottom: 6px; z-index: 1; width: 18px; height: 18px; flex: none; display: flex; align-items: center; justify-content: center; background: transparent; border: none; border-radius: 5px; cursor: pointer; color: var(--theme-text-muted); } .ops-tab-close:hover { background: var(--theme-hover); color: var(--theme-text); } -.ops-tabstrip-add { align-self: flex-end; width: 30px; height: 31px; border-radius: 7px 7px 0 0; } +.ops-tabstrip-add { align-self: flex-end; width: 30px; height: 31px; border-radius: 7px 7px 0 0; flex: none; } /* ---------- B: Projectbalk (rail) ---------- */ .ops-rail { diff --git a/src/components/layout/DocumentChrome/DocumentTabBar.tsx b/src/components/layout/DocumentChrome/DocumentTabBar.tsx index 3f638e980..e030636a7 100644 --- a/src/components/layout/DocumentChrome/DocumentTabBar.tsx +++ b/src/components/layout/DocumentChrome/DocumentTabBar.tsx @@ -1,13 +1,52 @@ +import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { Menu, Plus, X } from 'lucide-react'; +import { Menu, Plus } from 'lucide-react'; +import { useAppStore } from '@/state/appStore'; import { useDocumentCards, useDocumentActions } from './useDocumentCards'; +import { DocumentTabList } from './DocumentTabList'; +import { + documentTabCloseFocusTarget, + focusDocumentTab, + revealDocumentTab, +} from './documentTabNavigation'; +import { documentTabFocusTargetOutsideOverview } from './projectOverviewFocus'; import './DocumentChrome.css'; /** A · Documenttabs — horizontale tabstrip onder het lint. */ export function DocumentTabBar() { - const { t } = useTranslation('common'); + const { t, i18n } = useTranslation('common'); const cards = useDocumentCards(); const { switchTo, closeWithGuard, chooseNewOrOpenProject, openOverview } = useDocumentActions(); + const projectOverviewOpen = useAppStore((state) => state.ui.showProjectOverview); + const tabRefs = useRef(new Map()); + const previousDocumentIds = useRef(cards.map(card => card.id)); + const activeId = cards.find(card => card.isActive)?.id; + const direction = i18n.dir() === 'rtl' ? 'rtl' : 'ltr'; + + // Focus verschuift uitsluitend na een echte verwijdering uit de gecommitteerde kaartenlijst. Dit + // dekt schoon sluiten én de twee effectieve dirty-routes (opslaan/niet opslaan), ook wanneer een + // sluiting vanuit het projectoverzicht komt; annuleren laat de bestaande focus ongemoeid. + useLayoutEffect(() => { + const currentDocumentIds = cards.map(card => card.id); + const requestedDocumentId = previousDocumentIds.current.find(id => !currentDocumentIds.includes(id)) ?? null; + const focusTarget = documentTabFocusTargetOutsideOverview( + projectOverviewOpen, + documentTabCloseFocusTarget(previousDocumentIds.current, cards, requestedDocumentId), + ); + previousDocumentIds.current = currentDocumentIds; + if (focusTarget) tabRefs.current.get(focusTarget)?.focus({ preventScroll: true }); + }, [cards, projectOverviewOpen]); + + // Iedere route kan een document activeren; alleen hier kennen we de horizontale viewport. + useEffect(() => { + if (!activeId) return; + const tab = tabRefs.current.get(activeId); + if (tab) revealDocumentTab(tab); + }, [activeId]); + + const focusTab = useCallback((id: string) => { + focusDocumentTab(tabRefs.current, id); + }, []); return (
@@ -19,28 +58,21 @@ export function DocumentTabBar() { - {cards.map((card) => ( -
switchTo(card.id)} - data-ops-tab={card.id} - data-testid="document-tab" - > - - {card.title} - {card.isDirty && } - -
- ))} +
+ { + if (element) tabRefs.current.set(documentId, element); + else tabRefs.current.delete(documentId); + }} + switchTo={switchTo} + focusTab={focusTab} + closeWithGuard={closeWithGuard} + /> +
+ +
+ ); +} diff --git a/src/components/layout/DocumentChrome/DocumentTabList.tsx b/src/components/layout/DocumentChrome/DocumentTabList.tsx new file mode 100644 index 000000000..fa8721d98 --- /dev/null +++ b/src/components/layout/DocumentChrome/DocumentTabList.tsx @@ -0,0 +1,50 @@ +import type { Ref } from 'react'; +import type { DocumentCard } from './useDocumentCards'; +import { DocumentTabControl } from './DocumentTabControl'; +import { + handleDocumentTabKeyDown, + type DocumentTabDirection, +} from './documentTabNavigation'; + +interface DocumentTabListProps { + cards: DocumentCard[]; + direction: DocumentTabDirection; + tablistLabel: string; + closeLabel: string; + tabRef: (documentId: string, element: HTMLButtonElement | null) => void; + switchTo: (documentId: string) => void; + focusTab: (documentId: string) => void; + closeWithGuard: (card: Pick) => void; +} + +/** De werkelijk gebruikte, prop-gedreven eventroute voor alle zichtbare documenttabs. */ +export function DocumentTabList({ + cards, + direction, + tablistLabel, + closeLabel, + tabRef, + switchTo, + focusTab, + closeWithGuard, +}: DocumentTabListProps) { + const documentIds = cards.map(card => card.id); + return ( +
+ {cards.map((card, index) => ( + { tabRef(card.id, element); }) as Ref} + onSelect={(event) => { switchTo(card.id); event.currentTarget.focus(); }} + onKeyDown={(event) => { + handleDocumentTabKeyDown(event, documentIds, card.id, direction, { switchTo, focusTab }); + }} + onClose={(event) => { event.stopPropagation(); closeWithGuard(card); }} + /> + ))} +
+ ); +} diff --git a/src/components/layout/DocumentChrome/ProjectOverview.tsx b/src/components/layout/DocumentChrome/ProjectOverview.tsx index b22563c80..2c1929d0b 100644 --- a/src/components/layout/DocumentChrome/ProjectOverview.tsx +++ b/src/components/layout/DocumentChrome/ProjectOverview.tsx @@ -1,7 +1,9 @@ +import { useLayoutEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Plus, X } from 'lucide-react'; import { useAppStore } from '@/state/appStore'; import { useDocumentCards, useDocumentActions, type DocumentCard } from './useDocumentCards'; +import { projectOverviewCloseButtonId, projectOverviewCloseFocusTarget } from './projectOverviewFocus'; import './DocumentChrome.css'; /** @@ -14,6 +16,37 @@ export function ProjectOverview() { const open = useAppStore((s) => s.ui.showProjectOverview); const cards = useDocumentCards(); const { switchTo, closeWithGuard, chooseNewOrOpenProject, closeOverview } = useDocumentActions(); + const closeButtonRefs = useRef(new Map()); + const previousDocumentIds = useRef(cards.map(card => card.id)); + const wasOpen = useRef(false); + + useLayoutEffect(() => { + const currentDocumentIds = cards.map(card => card.id); + if (!open) { + previousDocumentIds.current = currentDocumentIds; + wasOpen.current = false; + return; + } + + if (!wasOpen.current) { + wasOpen.current = true; + const initialTarget = cards.find(card => card.isActive)?.id ?? currentDocumentIds[0] ?? null; + if (initialTarget) closeButtonRefs.current.get(initialTarget)?.focus({ preventScroll: true }); + previousDocumentIds.current = currentDocumentIds; + return; + } + + const removedDocumentId = previousDocumentIds.current.find( + documentId => !currentDocumentIds.includes(documentId), + ) ?? null; + const focusTarget = projectOverviewCloseFocusTarget( + previousDocumentIds.current, + currentDocumentIds, + removedDocumentId, + ); + previousDocumentIds.current = currentDocumentIds; + if (focusTarget) closeButtonRefs.current.get(focusTarget)?.focus({ preventScroll: true }); + }, [cards, open]); if (!open) return null; @@ -98,8 +131,14 @@ export function ProjectOverview() { )} + + ); +} diff --git a/src/engine/moveProject.ts b/src/engine/moveProject.ts index a711bf7cb..cb7b953d6 100644 --- a/src/engine/moveProject.ts +++ b/src/engine/moveProject.ts @@ -7,6 +7,7 @@ import type { CustomFieldDef } from '@/types/structure'; import { parseInstant, formatInstant, addCalendarDays, diffDays, parseDate, type DateMode, } from '@/utils/dateUtils'; +import { reconcileP6SuspendResume } from '@/utils/p6SuspendResume'; /** * "Project verplaatsen" (pakket D1) — PURE domeintransformatie, geen store-import. @@ -107,7 +108,14 @@ const TASK_VERDICTS = { // geen datum erin — zelfde taxonomie als splitGaps hierboven mspTaskType: 'n/a', // Z14b: MSP-eigen enum, geen datum effortDriven: 'n/a', // Z14b: vlag, geen datum - parentId: 'n/a', childIds: 'n/a', + p6DurationType: 'n/a', // X0 (XER-etappeplan): P6-eigen enum, geen datum + p6ActivityType: 'n/a', // X0 (XER-etappeplan): P6-eigen enum, geen datum + p6ProjectId: 'n/a', p6TaskId: 'n/a', p6ExplicitTargetWindow: 'n/a', p6CompletePctType: 'n/a', + p6ExpectedFinish: 'shift', // X7: opgeslagen absolute P6-brondatum verschuift mee + p6SuspendResume: 'n/a', // X0 (XER-etappeplan): herkomstvlag, geen datum zelf — de + // datums eronder (time.resume/stop) schuiven al mee via + // TASK_TIME_VERDICTS + parentId: 'n/a', childIds: 'n/a', isSummary: 'n/a', time: 'shift', // zie TASK_TIME_VERDICTS resourceIds: 'n/a', color: 'n/a', activityCodes: 'n/a', customFields: 'keep', // §1.7 — een 'date'-gebruikersveld heeft onbekende semantiek @@ -164,6 +172,7 @@ const AVAILABILITY_STEP_VERDICTS = { const BASELINE_VERDICTS = { id: 'n/a', name: 'n/a', + sourceProjectId: 'n/a', createdAt: 'keep', // archiefdatum — ook NIET bij shiftBaselines:true tasks: 'shift', // alleen bij shiftBaselines:true projectEnd: 'shift', // idem @@ -171,7 +180,7 @@ const BASELINE_VERDICTS = { } satisfies Record; const BASELINE_TASK_VERDICTS = { - taskId: 'n/a', + taskId: 'n/a', sourceTaskId: 'n/a', sourceTaskCode: 'n/a', start: 'shift', finish: 'shift', duration: 'n/a', isMilestone: 'n/a', milestoneKind: 'n/a', } satisfies Record; @@ -266,6 +275,9 @@ export function shiftTask(task: Task, delta: number): Task { if (task.constraint) next.constraint = shiftConstraint(task.constraint, delta); if (task.constraint2) next.constraint2 = shiftConstraint(task.constraint2, delta); if (task.deadline !== undefined) next.deadline = shiftIso(task.deadline, delta); + if (task.p6ExpectedFinish !== undefined) { + next.p6ExpectedFinish = shiftIso(task.p6ExpectedFinish, delta); + } // Z8 (gemelde uitzondering op de bestandseigendom): de verdicts hierboven markeren deze twee als // 'shift' — dat is puur documentatie zonder DEZE regels, zie de moduleheader-waarschuwing bij // TASK_VERDICTS ("'keep'-voorschrift... betekent dat je de shift ook echt met de hand moet @@ -289,6 +301,7 @@ export function shiftTask(task: Task, delta: number): Task { // "anker verouderd"; misbruiken zou de ghost-weergave vervuilen. })); } + reconcileP6SuspendResume(next); return next; } diff --git a/src/engine/renderer/GanttRenderer.ts b/src/engine/renderer/GanttRenderer.ts index 4c64a3702..58eb784a6 100644 --- a/src/engine/renderer/GanttRenderer.ts +++ b/src/engine/renderer/GanttRenderer.ts @@ -22,6 +22,7 @@ import { readGanttPalette, type GanttPalette } from './themePalette'; import { xToDayOffset, type GanttAxis } from './timeAxis'; import { resolveGanttAxis, isCompressedEffective } from './workdayAxis'; import { computeSplitSegments } from './splitBarGeometry'; +import { isLeafTask, isSummaryTask } from '@/utils/taskHierarchy'; import { classifyTraceTask, isRelationOutsideTrace, type TaskTrace } from '@/engine/taskGrid/trace'; export interface GanttRenderOptions { @@ -675,7 +676,7 @@ export class GanttRenderer { // Alleen echte leaf-taken (geen samenvatting/mijlpaal/band) stulpen uit. M3 (Opus-review // T15-iteratie-2): `isZeroDurationMilestone` — een mijlpaal-met-duur tekent als gewone balk // (regel ~940 hierboven) en krijgt dus ook haar eigen statusdatum-uitstulping. - if (task && !isZeroDurationMilestone(task) && task.childIds.length === 0) { + if (task && !isZeroDurationMilestone(task) && isLeafTask(task)) { const geo = this.barGeometry(task); const c = Math.max(0, Math.min(1, task.time.completion || 0)); // Dagniveau-vergelijking t.o.v. de statusdatum (ook voor uur-taken: alleen de @@ -1005,7 +1006,7 @@ export class GanttRenderer { let resourceAccentHeight = 0; if (isZeroDurationMilestone(task)) { this.drawMilestone(task, y, barHeight, isSelected, overrideColor); - } else if (task.childIds.length > 0) { + } else if (isSummaryTask(task)) { this.drawSummaryBar(task, y, barHeight, isSelected, overrideColor); } else if (task.isHammock) { this.drawHammockBar(task, y, barHeight, isSelected, overrideColor); @@ -2022,7 +2023,7 @@ export class GanttRenderer { const row = this.rows[rowIndex]; if (row?.kind !== 'task') return null; const task = row.task; - if (task.childIds.length > 0 || isZeroDurationMilestone(task)) return null; + if (isSummaryTask(task) || isZeroDurationMilestone(task)) return null; if (!(task.time.earlyStart || task.time.scheduleStart) || !(task.time.earlyFinish || task.time.scheduleFinish)) { return null; } @@ -2045,7 +2046,7 @@ export class GanttRenderer { // M3 (Opus-review T15-iteratie-2): `isZeroDurationMilestone` — een mijlpaal-met-duur tekent als // gewone balk (regel ~940) en moet dus ook gewoon sleep-/resize-baar zijn, zoals elke andere // taak met een echte duur. - if (!task || task.childIds.length > 0 || isZeroDurationMilestone(task)) return null; + if (!task || isSummaryTask(task) || isZeroDurationMilestone(task)) return null; // Datumloos-guard (TODO 2026-07-28): barGeometry tekent voor zo'n taak een terugval-stub op de // viewstart, maar die mag geen sleep/resize armen — de drag-hooks zouden met undefined // originalStart/originalFinish rekenen. diff --git a/src/engine/scheduler/CPMSolver.ts b/src/engine/scheduler/CPMSolver.ts index d22d2c1d8..2168b6d9f 100644 --- a/src/engine/scheduler/CPMSolver.ts +++ b/src/engine/scheduler/CPMSolver.ts @@ -14,6 +14,19 @@ import { taskDurationUnit, } from './duration'; import { computeScheduleResults } from './scheduleAnalysis'; +import { hasValidP6SuspendResume } from '@/utils/p6SuspendResume'; +import { + explainP6CompletedDataDateWindow, + type P6CompletedWindowDecision, +} from '@/utils/p6CompletedTargetWindow'; +import { + explainBackwardActualPinEligibility, + explainCompletedXerLoeActualFinishEligibility, + explainP6CompletedLateRemainingWindowEligibility, + type CpmBackwardActualPinDecision, + type CpmDisplayActualLateDecision, +} from './p6CompletedRouteTrace'; +import { explainOpenXerLoeTargetSpanEligibility } from './p6OpenLoeTargetSpanTrace'; import { forwardConstraint, forwardFinishFloor, backwardConstraint, MS_PER_MIN, MS_PER_DAY, type RelationDeps, } from './relationMath'; @@ -21,6 +34,14 @@ import { export interface CPMResult { tasks: Map; criticalPath: string[]; + /** Optionele, niet-persistente XER-diagnose van de backward/float-beslissingen. */ + backwardFloatTrace?: CpmBackwardFloatTrace; + /** + * Optionele, niet-persistente XER-diagnose van de geplande-startvloer. Alleen gevuld voor + * ingelezen P6-activiteiten waarvoor de bronoptie actief is en het volledige doelvenster + * beschikbaar is. Dit zijspoor beschrijft de beslissing; het neemt er niet aan deel. + */ + plannedFloorTraceByTaskId?: Readonly>; /** * Ids van driving relaties (P6-definitie): de door de relatie gegenereerde grens ís de * aangenomen early-datum van de opvolger (relationship free float = 0). Gelijkspel is @@ -75,12 +96,58 @@ export interface CPMResult { cycleTaskIds?: string[]; } +export type CpmProjectEndSource = + | 'maxEarlyFinish' + | 'completedDisplayWindow' + | 'useProjectEndDateForFloat'; + +export type CpmLateFinishSource = + | 'projectEnd' + | 'successorConstraint' + | 'completedRemainingWindow'; + +export type CpmLateStartSource = + | 'subDuration' + | 'subRemainingDuration' + | 'completedRemainingWindow'; + +export type CpmFreeFloatSource = + | 'derivedFromSuccessor' + | 'projectEndFinishMilestoneBoundary' + | 'clampedZero'; + +export interface CpmTaskBackwardFloatTrace { + lateFinishSource: CpmLateFinishSource; + lateStartSource: CpmLateStartSource; + freeFloatSource: CpmFreeFloatSource; + displayActualLate: boolean; + completedWindow: P6CompletedWindowDecision; + backwardActualPin: CpmBackwardActualPinDecision; + displayActualLateDecision: CpmDisplayActualLateDecision; +} + +export interface CpmBackwardFloatTrace { + projectEndSource: CpmProjectEndSource; + byTaskId: Record; +} + +export interface CPMPlannedFloorTrace { + preFloorEarlyStart: string; + preFloorEarlyFinish: string; + targetStart: string; + targetFinish: string; + plannedWindowIsLater: boolean; + boundarySource: 'project-start' | 'relationship' | 'relationship:p6-predecessor-finish-boundary'; + boundarySequenceId?: string; + boundaryPredecessorTaskCode?: string; +} + /** Voortgangs-opties (fase 2.6). Leeg ⇒ geen statusdatum-gedrag (byte-identiek aan vóór 2.6). */ export interface CPMOptions { dataDate?: string; // ISO date; undefined ⇒ geen statusdatum-gedrag progressMode?: 'RETAINED_LOGIC' | 'PROGRESS_OVERRIDE'; // default RETAINED_LOGIC - /** Project-scoped reken-opties (fase 2.9, §3.4). Afwezig ⇒ elke default ⇒ byte-identiek. In golf 0 - * wordt dit blok alleen doorgegeven; de solver leest het nog nergens gedragswijzigend. */ + /** Project-scoped rekenopties. Afwezig ⇒ elke brongebonden uitbreiding blijft uit en het + * algemene solvergedrag blijft byte-identiek. */ schedulingOptions?: SchedulingOptions; /** De geconfigureerde PROJECTSTARTDATUM (`Project.startDate`, ISO-datum), gebruikstest-bevinding * 2026-08: ondergrens voor de early-start-berekening van ELKE taak MET voorganger (en @@ -100,6 +167,9 @@ export interface CPMOptions { * (en hammocks) hierboven; zie de docstrings van `rootFloor`/`ownAnchor` in `CPMSolver` voor de * volledige motivatie. */ projectStartDate?: string; + /** Geconfigureerde projecteinddatum. Alleen actief wanneer de brongebonden + * `useProjectEndDateForFloat`-optie aan staat; anders blijft max(EF) leidend. */ + projectEndDate?: string; } /** @@ -122,7 +192,8 @@ export function snapWorkInstantOnOrAfter(eng: CalendarEngine, from: Date): Date * (relatietabel-waarschuwingen) zodat er één definitie bestaat. * * `hoursPerDay` (optioneel, fase 2.10) = de dag↔minuut-factor van de kalender waarin de lag telt — - * de VOORGANGER-kalender voor WORKTIME (`LAG_CALENDAR='predecessor'`), 24 voor ELAPSEDTIME. Alleen + * de lag-kalender voor WORKTIME (`schedulingOptions.lagCalendar`, default de VOORGANGER-kalender), + * 24 voor ELAPSEDTIME. Alleen * mét die factor kan een lag die uitsluitend als `lagMinutes` bestaat (`lagDays = 0`) in DAGEN * uitgedrukt worden. Zonder de factor (UI-aanroepers) is de functie byte-identiek aan vóór 2.10. */ @@ -166,6 +237,28 @@ export interface CPMTaskResult { floatPath?: number; } +const TWENTY_FOUR_HOUR_LAG_CALENDAR: WorkCalendar = { + id: 'ops-p6-24hour-lag', + name: '24 uur', + description: 'Interne P6-relatielagkalender', + workDays: [1, 2, 3, 4, 5, 6, 7], + workStartHour: 0, + workEndHour: 24, + hoursPerDay: 24, + holidays: [], + workTime: { + byWeekday: { + 1: [{ start: 0, end: 1440 }], + 2: [{ start: 0, end: 1440 }], + 3: [{ start: 0, end: 1440 }], + 4: [{ start: 0, end: 1440 }], + 5: [{ start: 0, end: 1440 }], + 6: [{ start: 0, end: 1440 }], + 7: [{ start: 0, end: 1440 }], + }, + }, +}; + /** * Leeg `CPMResult` voor de degradatiepaden in `solve()` (cyclus, kalender zonder werkdagen, * onparseerbare startdatum): alle verzamelingen leeg, alleen de foutmelding verschilt per pad. @@ -202,6 +295,7 @@ export class CPMSolver { private projectCal: WorkCalendar; private registry: WorkCalendar[]; private projectEngine: CalendarEngine; + private readonly twentyFourHourLagEngine = new CalendarEngine(TWENTY_FOUR_HOUR_LAG_CALENDAR); private engineCache = new Map(); // Adjacency lists @@ -226,6 +320,10 @@ export class CPMSolver { // blok). Verzameld in de forward pass, zacht gerapporteerd als `cappedTaskIds`. Geen error, geen // rollback — de kalenderwijziging is legitiem; de waarschuwing wijst de te repareren taak aan. private cappedTaskIds: string[] = []; + // Test-/replaydiagnose van de P6-planned-floor-beslissing. Nooit teruggeschreven naar Task/IFC. + private plannedFloorTraceByTaskId: Record = {}; + // Test-/replaydiagnose van completed backward/float-bronnen. Alleen XER, nooit Task/IFC. + private backwardFloatTrace: CpmBackwardFloatTrace | undefined; private options: CPMOptions; // Werkdag-gesnapte statusdatum (fase 2.6), of null ⇒ geen statusdatum-gedrag. Gezet in solve(). @@ -239,6 +337,9 @@ export class CPMSolver { // constructie (zie de guard hieronder). Constant per instance (afgeleid uit de constructor- // input), dus NIET onderdeel van de idempotentie-reset in solve(). private readonly droppedSequenceIds: string[]; + /** Opvolgers van een door de XER-reader bewezen gedeelde FS-finish/startgrens. Afgeleid uit de + * relaties bij iedere solve; andere formaten hebben geen vlag en blijven volledig onaangeraakt. */ + private readonly p6FinishBoundaryStartTaskIds: Set; // M7 (CPM-review): dedup-sleutel voor de dropped-relaties-waarschuwing hieronder, STATIC (gedeeld // over alle instanties) — `ResourceLeveler` bouwt binnen één nivelleeraanroep O(taken) solvers, // typisch allemaal met DEZELFDE gedropte relatieset (die hangt af van welke taak-ids bestaan, niet @@ -258,7 +359,7 @@ export class CPMSolver { // Guard (T8-rooktest, 870d339f60603f71 — hash-only §8): de aanroepers (`runCPM`/`levelResources` in // `scheduleSlice.ts`, de leveler in `ResourceLeveler.ts`, `benchmark/runner.ts`) geven hier - // opzettelijk alleen BLADTAKEN aan mee (`childIds.length === 0`) — een samenvattingstaak krijgt + // opzettelijk alleen semantische BLADTAKEN aan mee — een samenvattingstaak krijgt // zijn datums via de rollup in `applyCpmResult`, niet als eigen CPM-knoop. `sequences` komt // ONGEFILTERD binnen: in MS Project is een relatie op een samenvattingstaak legaal (mspdiReader/ // ifcReader/mppReader lezen 'm gewoon in), maar deze solver kent geen samenvattingstaken. Vóór @@ -285,8 +386,25 @@ export class CPMSolver { dropped.push(seq.id); } } - this.sequences = kept; + // X12: relationele P6/XER-ankers vormen één brongepoorte grens. RelationMath en alle solver- + // paden zien uitsluitend deze effectieve relaties; een LOSSE relatievlag zonder projectniveau- + // `p6Source` kan daarmee geen P6-gedrag activeren. Wat deze poort NIET is (eindreview + // bevinding 4): een bescherming tegen een vervalst projectbestand — `p6Source` round-tript + // bewust door het `OPS_SchedulingOptions`-pset van het IFC (een uit XER geïmporteerd project moet + // na opslaan/heropenen dezelfde P6-opties dragen), dus de IFC-lezer is náást de XER-lezer een + // legitieme schrijver. Die lezer valideert sleutels en typen (`sanitizeSchedulingOptions`), + // meer niet; wie een IFC met `p6Source: 'XER'` opent, kiest daarmee voor P6-semantiek. + const p6SourceActive = options.schedulingOptions?.p6Source === 'XER'; + this.sequences = p6SourceActive + ? kept + : kept.map(sequence => sequence.p6StartAtPredecessorFinishBoundary === true + ? { ...sequence, p6StartAtPredecessorFinishBoundary: undefined } + : sequence); this.droppedSequenceIds = dropped; + this.p6FinishBoundaryStartTaskIds = new Set( + this.sequences.filter(sequence => sequence.p6StartAtPredecessorFinishBoundary === true) + .map(sequence => sequence.successorId), + ); if (dropped.length > 0) { // M7: alleen loggen als de gedropte SET (niet de instantie) daadwerkelijk is veranderd sinds // de vorige constructie — zie de toelichting bij `lastDroppedWarningSignature`. @@ -343,6 +461,44 @@ export class CPMSolver { ); } + private p6XerOption(value: boolean | undefined): boolean { + return this.options.schedulingOptions?.p6Source === 'XER' && value === true; + } + + private p6CompletedDataDateWindowDecision(task: Task): P6CompletedWindowDecision { + return explainP6CompletedDataDateWindow(task, this.dataDate, this.options.schedulingOptions); + } + + private recordBackwardFloatTrace( + taskId: string, + update: Partial, + ): void { + if (!this.backwardFloatTrace) return; + const previous = this.backwardFloatTrace.byTaskId[taskId] ?? { + lateFinishSource: 'projectEnd', + lateStartSource: 'subDuration', + freeFloatSource: 'derivedFromSuccessor', + displayActualLate: false, + completedWindow: { eligible: false, reason: 'notXerSource' } as const, + backwardActualPin: { eligible: false, reason: 'missingDataDate' } as const, + displayActualLateDecision: { eligible: false, reason: 'missingDataDate' } as const, + }; + this.backwardFloatTrace.byTaskId[taskId] = { ...previous, ...update }; + } + + /** De voortgangstakken gebruiken soms de resourcekalender van exact één walk als effectieve klok. */ + private progressCalendarFor(task: Task, fallback = this.calendarFor(task)): CalendarEngine { + let progressCal = fallback; + if (task.timephasedDurationWalks && task.timephasedDurationWalks.length === 1) { + const candidate = this.engineForCal( + resolveCalendar(task.timephasedDurationWalks[0].resourceCalendarId, this.registry, this.projectCal), + taskDurationUnit(task) === 'hours', + ); + if (candidate.isHourMode) progressCal = candidate; + } + return progressCal; + } + // ═══════════════════════════════════════════════════════════════════════════ // Fase 2.8b (golf 2) — MODUS-BEWUSTE rekenkern (§5). Elke helper reduceert in // DAG-modus tot exact de bestaande dag-expressie (byte-identiek); alleen een @@ -414,7 +570,12 @@ export class CPMSolver { * al een geldige `(start,end]`-instant" (band-interieur of band-eind) — precies de conventie * waarmee `finishFromStart` een `ef` bouwt; bij een niet-milestone of een dag-kalender reduceert * dit byte-identiek tot de kale `snapOnOrAfter`. */ - private snapSuccessorEarlyStart(eng: CalendarEngine, d: Date, task: Task): Date { + private snapSuccessorEarlyStart( + eng: CalendarEngine, + d: Date, + task: Task, + preserveP6FinishBoundary = false, + ): Date { // T8-review-BLOCKER (Opus-hercheck 72486257): een ELAPSEDTIME-opvolger krijgt hier GEEN // werk-instant-snap. Orkestratorbesluit op de semantische vraag ("mag een 24/7-taak op een // niet-werk-instant starten?"): JA — MS Project plant elapsed-taken puur in kalendertijd, de @@ -431,11 +592,48 @@ export class CPMSolver { // stil uit — precies de H1-schending hierboven (FF-relatie-schending, opvolger op een // zaterdag-einde geduwd) herleeft dan voor die taak, ook al is ze voor de PLANNING geen mijlpaal. if (!isZeroDurationMilestone(task) && task.time.durationType === 'ELAPSEDTIME') return d; - if (eng.isHourMode && isZeroDurationMilestone(task) && task.milestoneKind === 'FINISH') { + if (preserveP6FinishBoundary && eng.isHourMode + && this.snapOnOrBefore(eng, d).getTime() === d.getTime()) return d; + const p6FinishBoundary = this.p6ZeroDurationUsesFinishBoundary(task, eng); + if (eng.isHourMode && isZeroDurationMilestone(task) + && (task.milestoneKind === 'FINISH' || p6FinishBoundary)) { if (this.snapOnOrBefore(eng, d).getTime() === d.getTime()) return d; } return this.snapOnOrAfter(eng, d); } + + /** P6 bewaart TT_Mile als één enum, maar de geplande TASK-grens maakt het operationele verschil: + * start van een band ⇒ volgende werkstart; einde van een band ⇒ voorgangerfinish blijft geldig. */ + private p6ZeroDurationUsesFinishBoundary(task: Task, eng: CalendarEngine): boolean { + if (!this.p6XerOption(this.options.schedulingOptions?.p6ZeroDurationUsesPlannedBoundary) + || !eng.isHourMode || !isZeroDurationMilestone(task)) return false; + const planned = parseInstant(task.time.scheduleStart); + if (Number.isNaN(planned.getTime())) return false; + const minute = planned.getUTCHours() * 60 + planned.getUTCMinutes(); + return eng.effectiveBandsOn(planned).some(band => band.end === minute); + } + /** P6 bewaart ook een gewone Task-/Resource Dependent-activiteit met nul duur soms als een + * aangrenzend grensPAAR: start op de volgende bandstart, finish op het vorige bandeinde. Het + * geïnverteerde geplande venster is het invoersignaal; zonder XER-vlag of op andere taaktypen + * blijft de algemene nulduursemantiek ongewijzigd. */ + private p6ZeroDurationActivityUsesBoundaryPair(task: Task, eng: CalendarEngine): boolean { + if (!this.p6XerOption(this.options.schedulingOptions?.p6ZeroDurationUsesPlannedBoundary) + || !eng.isHourMode || task.time.scheduleDuration !== 0 + || (task.p6ActivityType !== 'TT_Task' && task.p6ActivityType !== 'TT_Rsrc')) return false; + const plannedStart = parseInstant(task.time.scheduleStart); + const plannedFinish = parseInstant(task.time.scheduleFinish); + return !Number.isNaN(plannedStart.getTime()) && !Number.isNaN(plannedFinish.getTime()) + && plannedFinish < plannedStart; + } + /** Finishzijde van P6's gewone nulduur-grenspaar. Dit is de VORIGE KALENDERDAG op de klok van + * het geplande finishanker, ook wanneer die dag voor de effectieve kalender niet werkt. P6's + * hb-intel-vensters tonen daardoor bijvoorbeeld zondag 17:00 naast maandag 08:00 op een + * vijfdaagse kalender; `prevWorkInstant` zou ten onrechte naar vrijdag teruglopen. */ + private p6PreviousCalendarFinishBoundary(task: Task, start: Date): Date { + const plannedFinish = parseInstant(task.time.scheduleFinish); + const clockMs = (plannedFinish.getUTCHours() * 60 + plannedFinish.getUTCMinutes()) * 60_000; + return new Date(this.startOfDay(start).getTime() - CPMSolver.MS_PER_DAY + clockMs); + } private modeOf(eng: CalendarEngine): DateMode { return eng.isHourMode ? 'hour' : 'day'; } @@ -576,6 +774,15 @@ export class CPMSolver { * krijgt aangereikt. Ze blijven hier gedefinieerd (delen de dag↔uur-reductie met de rest van de * solver); `forwardConstraint`/`backwardConstraint` draaien de FS/SS/FF/SF-formules erop. */ private readonly relDeps: RelationDeps = { + lagEngine: (predEng, succEng) => { + switch (this.options.schedulingOptions?.lagCalendar) { + case 'successor': return succEng; + case 'projectDefault': return this.projectEngine; + case '24hour': return this.twentyFourHourLagEngine; + case 'predecessor': + default: return predEng; + } + }, resolveLag: (seq, predTask, predEng) => this.resolveLag(seq, predTask, predEng), resolveEffectiveLagDays: (seq, predTask, predEng) => resolveEffectiveLagDays(seq, predTask, predEng.hoursPerDay), @@ -620,6 +827,13 @@ export class CPMSolver { } if (eng.isHourMode && taskDurationUnit(task) === 'hours') { const totalMinutes = splitTotalSpanMinutes(task.splitGaps, durationMinutesOf(task, eng)); + if (totalMinutes > 0 && this.p6FinishBoundaryStartTaskIds.has(task.id) + && this.isExactBandEnd(eng, start)) { + return { + date: eng.addWorkMinutes(eng.nextWorkInstantAfter(start), totalMinutes), + capped: false, + }; + } // Z13 (dossier "rauw anker zonder constraint"): `start` exact op een band-eind (`ownAnchor` // hierboven laat zo'n wortel-anker sinds deze fix bewust RAUW) — INVOERBEWIJS (corpusbestand // `timephased-prorated-cost-resource.mpp`, 4 taken, identieke duur/kalender): MSP's eigen @@ -684,6 +898,12 @@ export class CPMSolver { if (eng.isHourMode && taskDurationUnit(task) === 'hours') { const totalMinutes = splitTotalSpanMinutes(task.splitGaps, durationMinutesOf(task, eng)); const natural = eng.subtractWorkMinutes(end, totalMinutes); + if (totalMinutes > 0 && this.p6FinishBoundaryStartTaskIds.has(task.id)) { + const naturalDayStart = this.dayFirstBandStart(eng, natural); + if (naturalDayStart?.getTime() === natural.getTime()) { + return eng.prevWorkInstantBefore(natural); + } + } // Z13 (backward-spiegel van `addDurationChecked`s band-eind-wacht): voor een WORTEL-taak // (geen voorganger) wier eigen `ownAnchor` het rauwe band-eind-anker behoudt (zie die // functie), telt `addDurationChecked` de EIGEN kalenderdag van dat anker mee als volledig @@ -748,6 +968,34 @@ export class CPMSolver { return eng.subtractWorkDays(end, totalDays); } + /** P6-XER late-passspiegel voor een lopende taak: alleen het resterende werk ligt nog op de + * Early/Late-as. Actual Start en het voltooide duurdeel zijn historie. De berekening spiegelt + * exact de resterende-duurtak uit `forwardPass`, inclusief het nog niet verbruikte deel van + * splitgaten; ze leest uitsluitend taakvoortgang, duur en kalenderinvoer. */ + private subRemainingDuration(eng: CalendarEngine, end: Date, task: Task): Date { + const t = task.time; + const totalSpan = eng.isHourMode ? durationMinutesOf(task, eng) : t.scheduleDuration; + const remaining = eng.isHourMode + ? Math.max(0, t.remainingMinutes ?? Math.round(totalSpan * (1 - t.completion))) + : Math.max(0, t.remainingTime ?? Math.round(totalSpan * (1 - t.completion))); + if (!isZeroDurationMilestone(task) && t.durationType === 'ELAPSEDTIME') { + return subtractElapsedMinutes(end, eng.isHourMode ? remaining : remaining * 24 * 60); + } + let remainingWithGaps = remaining; + if (task.splitGaps && task.splitGaps.length > 0) { + const totalSpanMinutes = eng.isHourMode ? totalSpan : totalSpan * eng.hoursPerDay * 60; + const remainingMinutes = eng.isHourMode ? remaining : remaining * eng.hoursPerDay * 60; + const completedSpanMinutes = Math.max(0, totalSpanMinutes - remainingMinutes); + remainingWithGaps = ( + splitTotalSpanMinutes(task.splitGaps, totalSpanMinutes) + - splitTotalSpanMinutes(task.splitGaps, completedSpanMinutes) + ) / (eng.isHourMode ? 1 : eng.hoursPerDay * 60); + } + return eng.isHourMode + ? eng.subtractWorkMinutes(end, remainingWithGaps) + : eng.subtractWorkDays(end, remainingWithGaps); + } + /** Verschuift `date` met de nivelleer-vertraging van `task` (fase 2.5 §5.6; Z6 uur-/minuut- * precisie + elapsed-bewustheid; Z6-fixronde). `sign=1` (forward, `forwardPass`s eigen early * start van `task` zelf) of `sign=-1` (backward-DOORGIFTE, `backwardPass`s constraint-druk @@ -802,14 +1050,31 @@ export class CPMSolver { if (typeof seq.lagMinutes === 'number' && Number.isFinite(seq.lagMinutes)) return seq.lagMinutes; return resolveEffectiveLagDays(seq, predTask) * 24 * 60; } - /** Verschuif `base` met de relatie-lag in de VOORGANGER-engine (`LAG_CALENDAR='predecessor'`, §5.2). + /** Verschuif `base` met de relatie-lag in de meegegeven lag-engine (`predEng` is de naam uit de tijd + * dat de voorgangerskalender een constante was; elke aanroeper geeft nu `lagEng` uit + * `relDeps.lagEngine` door — `schedulingOptions.lagCalendar`, default voorganger; §5.2). * Uur-pred ⇒ minuten via `addWorkingMinutesSigned`; dag-pred ⇒ dagen via `addWorkingDaysSigned` * (dag-lag blijft exact als nu). `sign` = +1 voorwaarts, −1 achterwaarts (spiegel). */ private shiftLagPred( predEng: CalendarEngine, base: Date, seq: Sequence, predTask: Task, sign: 1 | -1, ): Date { if (predEng.isHourMode) { - return predEng.addWorkingMinutesSigned(base, sign * this.resolveLagMinutes(seq, predTask, predEng)); + const minutes = this.resolveLagMinutes(seq, predTask, predEng); + if (sign < 0 && minutes > 0) { + const projected = predEng.subtractWorkMinutes(base, minutes); + // P6 WORKTIME-lag is een grensafstand. Vanaf een finishgrens moet een aftrek die exact op + // een bandstart landt daarom de complementaire vorige finishgrens teruggeven: wo 17:00 + // min twee werkdagen = ma 17:00, niet di 08:00. Alleen de XER-resultaatprojectie krijgt + // deze conventie; duur-aftrek en de generieke kalenderprimitieven blijven fysiek/invers. + const projectedDayStart = this.dayFirstBandStart(predEng, projected); + if (this.options.schedulingOptions?.p6Source === 'XER' + && this.isExactBandEnd(predEng, base) + && projectedDayStart?.getTime() === projected.getTime()) { + return predEng.prevWorkInstantBefore(projected); + } + return projected; + } + return predEng.addWorkingMinutesSigned(base, sign * minutes); } // Dag-voorganger: WORKTIME-lag in dagen; `hoursPerDay` van de voorganger-kalender vertaalt een // lag die alleen als `lagMinutes` bestaat (fase 2.10, zie `resolveEffectiveLagDays`). @@ -838,7 +1103,14 @@ export class CPMSolver { return subtractElapsedMinutes(finish, elapsedMinutesOf(task, eng)); } const totalMinutes = splitTotalSpanMinutes(task.splitGaps, durationMinutesOf(task, eng)); - return eng.subtractWorkMinutes(finish, totalMinutes); + const natural = eng.subtractWorkMinutes(finish, totalMinutes); + if (totalMinutes > 0 && this.p6FinishBoundaryStartTaskIds.has(task.id)) { + const naturalDayStart = this.dayFirstBandStart(eng, natural); + if (naturalDayStart?.getTime() === natural.getTime()) { + return eng.prevWorkInstantBefore(natural); + } + } + return natural; } if (eng.isHourMode) { if (isZeroDurationMilestone(task)) return new Date(finish.getTime()); @@ -874,7 +1146,11 @@ export class CPMSolver { return addElapsedMinutes(start, elapsedMinutesOf(task, eng)); } const totalMinutes = splitTotalSpanMinutes(task.splitGaps, durationMinutesOf(task, eng)); - return eng.addWorkMinutes(start, totalMinutes); + const walkStart = totalMinutes > 0 && this.p6FinishBoundaryStartTaskIds.has(task.id) + && this.isExactBandEnd(eng, start) + ? eng.nextWorkInstantAfter(start) + : start; + return eng.addWorkMinutes(walkStart, totalMinutes); } if (eng.isHourMode) { if (isZeroDurationMilestone(task)) return new Date(start.getTime()); @@ -916,6 +1192,10 @@ export class CPMSolver { this.hardPinViolatedIds = []; this.hammockNoFinishDriverIds = []; this.cappedTaskIds = []; + this.plannedFloorTraceByTaskId = {}; + this.backwardFloatTrace = this.options.schedulingOptions?.p6Source === 'XER' + ? { projectEndSource: 'maxEarlyFinish', byTaskId: {} } + : undefined; this.dataDate = null; // wordt hieronder herzet; zo blijft hij ook over guard-returns heen nooit stale this.projectStartRaw = null; // idem — herzet vóór elke solve, nooit stale over guard-returns heen @@ -1002,11 +1282,13 @@ export class CPMSolver { hammockNoFinishDriverIds: this.hammockNoFinishDriverIds, projectEngine: this.projectEngine, calendarFor: (t) => this.calendarFor(t), + progressCalendarFor: (t) => this.progressCalendarFor(t), signedFloat: (a, b, eng, task) => this.signedFloat(a, b, eng, task), constraintInstant: (c, eng) => this.constraintInstant(c, eng), snapOnOrAfter: (eng, d) => this.snapOnOrAfter(eng, d), snapOnOrBefore: (eng, d) => this.snapOnOrBefore(eng, d), modeOf: (eng) => this.modeOf(eng), + backwardFloatTrace: this.backwardFloatTrace, }); // Zachte WP7-waarschuwing: alleen bij een echt onwerkbaar venster het veld zetten, zodat een // normale solve byte-identiek blijft (veld afwezig ⇒ geen wijziging aan bestaande consumenten). @@ -1015,6 +1297,15 @@ export class CPMSolver { // erna), kan dezelfde `taskId` twee keer gepusht worden als BEIDE tegen de onwerkbaar-venster-cap // lopen — vóór T9 kon een taak hoogstens via één pad hier terechtkomen, dus dit kon niet. if (this.cappedTaskIds.length > 0) result.cappedTaskIds = [...new Set(this.cappedTaskIds)]; + if (Object.keys(this.plannedFloorTraceByTaskId).length > 0) { + result.plannedFloorTraceByTaskId = { ...this.plannedFloorTraceByTaskId }; + } + if (this.backwardFloatTrace) { + result.backwardFloatTrace = { + projectEndSource: this.backwardFloatTrace.projectEndSource, + byTaskId: { ...this.backwardFloatTrace.byTaskId }, + }; + } // T8-rooktest: idem voor relaties die een niet-bladtaak raakten en al bij de constructie // genegeerd zijn (zie de guard in de constructor) — byte-identiek default zolang dat niet gebeurt. if (this.droppedSequenceIds.length > 0) result.droppedSequenceIds = [...this.droppedSequenceIds]; @@ -1147,9 +1438,44 @@ export class CPMSolver { // FF/SF-voorganger-bounds (ondergrens ES). De AFGELEIDE duur (span ES→EF) wordt naar // precies één afgeleide duurbron (`scheduleDuration` of `durationMinutes`) geschreven; eigen duur-invoer // wordt genegeerd. `isHammock` afwezig ⇒ deze tak draait niet (byte-identiek). - if (task.isHammock) { - const es = this.hammockEarlyStart(task, preds, results, projectStart, cal); - const { ef, hasFinishDriver } = this.hammockEarlyFinish(task, preds, results, es, cal); + // Gebruik hier bewust de rauwe XER-datadatum, vóór kalendersnap. De smalle guard vergelijkt + // haar met het opgeslagen actual-finish-instant; een naar de volgende werkband gesnapte datum + // zou een actualFinish ná P6's datadatum ten onrechte toelaten. + const completedXerLoeActualFinish = explainCompletedXerLoeActualFinishEligibility( + task, + this.options.dataDate ? parseInstant(this.options.dataDate) : null, + this.options.schedulingOptions, + preds, + this.successors.get(taskId) || [], + ); + if (task.isHammock && !completedXerLoeActualFinish.eligible) { + const relationalEarlyStart = this.hammockEarlyStart(task, preds, results, projectStart, cal); + // De duurmeting is een uurkalender-primitief en hoort pas ná de expliciete XER-bronpoort + // bereikbaar te zijn. Generieke hammocks en een onvolledige XER-dagkalender sluiten hier + // fail-closed via NaN; `explainOpenXerLoeTargetSpanEligibility` leest dat alleen nadat alle + // voorafgaande bron-/provenance-/taakpoorten zijn gepasseerd. + const targetWindowWorkMinutes = this.options.schedulingOptions?.p6Source === 'XER' + && cal.isHourMode + ? cal.workMinutesBetween( + parseInstant(task.time.scheduleStart), + parseInstant(task.time.scheduleFinish), + ) + : Number.NaN; + const openXerLoeTargetSpan = explainOpenXerLoeTargetSpanEligibility( + task, + this.options.schedulingOptions, + preds, + this.successors.get(taskId) || [], + relationalEarlyStart, + targetWindowWorkMinutes, + cal.hoursPerDay * 60, + ); + const es = openXerLoeTargetSpan.eligible + ? parseInstant(task.time.scheduleStart) + : relationalEarlyStart; + const { ef, hasFinishDriver } = openXerLoeTargetSpan.eligible + ? { ef: parseInstant(task.time.scheduleFinish), hasFinishDriver: true } + : this.hammockEarlyFinish(task, preds, results, es, cal); if (!hasFinishDriver) this.hammockNoFinishDriverIds.push(taskId); // T8 (T10-reviewtoevoeging): een ELAPSEDTIME-hammock drukt zijn afgeleide span uit in KLOK- // tijd, niet in WERKtijd — `cal.workMinutesBetween`/`workDaysBetween` tellen alleen tijd @@ -1365,6 +1691,7 @@ export class CPMSolver { if (!predResult || !predTask) continue; const constraintDate = forwardConstraint( this.relDeps, predResult, predTask, seq, task, this.calendarFor(predTask), cal, + this.p6ZeroDurationUsesFinishBoundary(task, cal), ); this.seqConstraint.set(seq.id, constraintDate); if (!rawMax || constraintDate > rawMax) rawMax = constraintDate; @@ -1376,9 +1703,62 @@ export class CPMSolver { // no-op zonder SF-voorganger. const finishFloor = forwardFinishFloor( this.relDeps, predResult, predTask, seq, task, this.calendarFor(predTask), cal, + this.p6ZeroDurationUsesFinishBoundary(task, cal), ); if (finishFloor && (!sfFinishFloor || finishFloor > sfFinishFloor)) sfFinishFloor = finishFloor; } + // P6-bronsemantiek: target_start is alleen een aanvullende geplande vloer wanneer zowel + // het geplande begin als einde meer dan één kalenderdag ná het netwerkvenster liggen. Dat + // dubbele criterium voorkomt dat een lang targetvenster of een gewone volgende-bandstart + // als impliciete constraint wordt behandeld. Relatiedruk die later ligt blijft altijd winnen. + if (this.p6XerOption(this.options.schedulingOptions?.p6UseTaskPlannedStartFloor)) { + const plannedFloor = this.ownAnchor(cal, task.time.scheduleStart, task); + const networkFinish = this.finishFromStart(cal, earlyStart, task); + const plannedFinish = this.parseIn(cal, task.time.scheduleFinish); + const p6InvertedFinishBoundary = isZeroDurationMilestone(task) + && task.milestoneKind === 'FINISH' && plannedFinish < plannedFloor; + const plannedWindowIsLater = p6InvertedFinishBoundary + ? plannedFloor > earlyStart + : plannedFloor.getTime() - earlyStart.getTime() > MS_PER_DAY + && plannedFinish.getTime() - networkFinish.getTime() > MS_PER_DAY; + if ( + this.options.schedulingOptions?.p6Source === 'XER' + && task.p6ActivityType !== undefined + && task.p6ExplicitTargetWindow === true + && !Number.isNaN(plannedFloor.getTime()) + && !Number.isNaN(plannedFinish.getTime()) + && !Number.isNaN(networkFinish.getTime()) + ) { + const drivingSequences = preds.filter(sequence => + this.seqConstraint.get(sequence.id)?.getTime() === earlyStart.getTime()); + const drivingSequence = drivingSequences.length === 1 ? drivingSequences[0] : undefined; + const projectStartIsBoundary = drivingSequences.length === 0 + && projectStart?.getTime() === earlyStart.getTime(); + const drivingPredecessor = drivingSequence + ? this.tasks.get(drivingSequence.predecessorId) + : undefined; + const boundarySource = drivingSequence + ? drivingSequence.p6StartAtPredecessorFinishBoundary + ? 'relationship:p6-predecessor-finish-boundary' as const + : 'relationship' as const + : 'project-start' as const; + // Bij meerdere gelijke relatiedrivers is er geen unieke voorgangerbron. Laat de trace + // dan geheel weg in plaats van een willekeurige relatie of projectstart te rapporteren. + if (drivingSequence || projectStartIsBoundary) { + this.plannedFloorTraceByTaskId[taskId] = { + preFloorEarlyStart: earlyStart.toISOString().slice(0, 16), + preFloorEarlyFinish: networkFinish.toISOString().slice(0, 16), + targetStart: plannedFloor.toISOString().slice(0, 16), + targetFinish: plannedFinish.toISOString().slice(0, 16), + plannedWindowIsLater, + boundarySource, + ...(drivingSequence ? { boundarySequenceId: drivingSequence.id } : {}), + ...(drivingPredecessor ? { boundaryPredecessorTaskCode: drivingPredecessor.wbsCode } : {}), + }; + } + } + if (plannedWindowIsLater) earlyStart = plannedFloor; + } // Vloer-afkap: wilde óók de strengste relatie de taak nog vóór het projectbegin trekken, // markeer dan de bindende lead(s) als afgekapt — de gebruiker moet kunnen zien dat een // lead niet volledig benut wordt. Gedomineerde leads zijn gewoon non-driving, geen afkap. @@ -1399,7 +1779,15 @@ export class CPMSolver { } // `rawMax` (voorganger-druk) voedt de harde-pin-logicaschending-detectie (§4.2). earlyStart = this.applyForwardConstraints(task, earlyStart, rawMax, cal); - earlyStart = this.snapSuccessorEarlyStart(cal, earlyStart, task); + const preserveP6FinishBoundary = preds.some(sequence => + sequence.p6StartAtPredecessorFinishBoundary === true + && this.seqConstraint.get(sequence.id)?.getTime() === earlyStart.getTime()); + earlyStart = this.snapSuccessorEarlyStart( + cal, + earlyStart, + task, + preserveP6FinishBoundary, + ); } // Nivelleer-vertraging (fase 2.5, §5.6; Z6 — uur-/minuutprecisie + elapsed-bewustheid; @@ -1492,14 +1880,7 @@ export class CPMSolver { // gedeeltelijke wandeling. Afwezig/niet-activeerbaar (>1 toewijzing, dag-modus-resourcekalender, // geen echte afwijking) ⇒ `progressCal = cal`, BYTE-IDENTIEK — dat is de overgrote meerderheid // van alle taken, inclusief de volledige Z12-/Z6-/Z7-populaties (regressiewacht hieronder). - let progressCal = cal; - if (task.timephasedDurationWalks && task.timephasedDurationWalks.length === 1) { - const candidate = this.engineForCal( - resolveCalendar(task.timephasedDurationWalks[0].resourceCalendarId, this.registry, this.projectCal), - taskDurationUnit(task) === 'hours', - ); - if (candidate.isHourMode) progressCal = candidate; - } + const progressCal = this.progressCalendarFor(task, cal); { const t = task.time; if (t.actualFinish && t.completion >= 1) { @@ -1508,16 +1889,24 @@ export class CPMSolver { // BINNEN dezelfde dag (bv. 07:00 → 08:00), maar verplaatst nooit naar een andere dag (bv. // zaterdag → maandag). Zie die functie se docblock voor de twee tegenstrijdige // corpusmetingen die dit reconcilieert. - let es = this.snapActualForward(progressCal, this.parseIn(progressCal, t.actualStart ?? t.actualFinish)); + const preserveP6ActualInstants = task.p6ProjectId !== undefined + && this.p6XerOption(this.options.schedulingOptions?.p6PreserveActualInstants); + const actualStart = this.parseIn(progressCal, t.actualStart ?? t.actualFinish); + let es = preserveP6ActualInstants + ? actualStart + : this.snapActualForward(progressCal, actualStart); // Milestone: start én finish landen op dezelfde werk(dag)-grens (snap op-of-ná, niet -vóór). // H3 (Opus-review T15-iteratie-2): `isZeroDurationMilestone` i.p.v. de kale vlag — een // VOLTOOIDE mijlpaal-met-duur (T15) is voor de PLANNING een gewone taak en hoort dus de // NORMALE `snapOnOrBefore`-tak te volgen (haar `actualFinish` kan legitiem dagen ná haar // `actualStart` liggen); met de kale vlag zou `snapOnOrAfter` haar EF stelselmatig vóór of // op haar ES kunnen duwen — "ze eindigt ná haar eigen actualFinish" (reviewer-meting). - let ef = isZeroDurationMilestone(task) - ? this.snapOnOrAfter(progressCal, this.parseIn(progressCal, t.actualFinish)) - : this.snapOnOrBefore(progressCal, this.parseIn(progressCal, t.actualFinish)); + const actualFinish = this.parseIn(progressCal, t.actualFinish); + let ef = preserveP6ActualInstants + ? actualFinish + : isZeroDurationMilestone(task) + ? this.snapOnOrAfter(progressCal, actualFinish) + : this.snapOnOrBefore(progressCal, actualFinish); // Inversie-randgeval: het HELE geregistreerde venster valt in onwerkbare tijd (weekend, // bouwvak, feestdagenblok) — dan snapt de start vóóruit tot ná de finish, die achteruit // snapte. Er bestaat dan geen werkdag binnen het feit, dus één van beide moet wijken. @@ -1537,6 +1926,12 @@ export class CPMSolver { // `timephasedFinishFloor`/`timephasedDurationWalks` NOOIT meer op een taak met // `completion >= 1`, dus een raadpleging hier zou toch altijd `null` opleveren — bewust // weggelaten in plaats van dode code te laten staan. + // XER/P6-bronsemantiek: een voltooide opvolger is historisch en levert daarom ook geen + // relatievrije-speling/driving-grens voor een nog open voorganger. De algemene solver- + // default blijft ongewijzigd; alleen de expliciete bronvlag verwijdert deze grenzen. + if (this.options.schedulingOptions?.preserveActualDatesInBackwardPass === true) { + for (const seq of preds) this.seqConstraint.delete(seq.id); + } results.set(taskId, { es, ef }); continue; } @@ -1556,7 +1951,12 @@ export class CPMSolver { // snapte een IN-PROGRESS-taak se weekend-actualStart altijd naar de eerstvolgende werkdag // terwijl een VOLTOOIDE taak se weekend-actualStart dat niet deed — dezelfde soort taak // kreeg zo een ANDER antwoord al naargelang completion toevallig <1 of ===1 stond. - const actualES = t.actualStart ? this.snapActualForward(progressCal, this.parseIn(progressCal, t.actualStart)) : earlyStart; + const actualES = t.actualStart + ? task.p6ProjectId !== undefined + && this.p6XerOption(this.options.schedulingOptions?.p6PreserveActualInstants) + ? this.parseIn(progressCal, t.actualStart) + : this.snapActualForward(progressCal, this.parseIn(progressCal, t.actualStart)) + : earlyStart; // Restwerk volgt de blijvende TAAK-eenheid, nooit de kalenderidentiteit: uur ⇒ // `remainingMinutes ?? durationMinutes × (1−completion)`; dag ⇒ werkdagen (§5.3). const progressInHours = taskDurationUnit(task) === 'hours'; @@ -1684,7 +2084,13 @@ export class CPMSolver { // vlag (default UIT) en haar hele RETAINED_LOGIC-vloer (de `else`-tak hieronder) zijn door // deze wijziging GEEN letter geraakt — alleen de `resumeOverride`-voorwaarde zelf kreeg de // extra `&&`-clausule. - const resumeOverride = t.resume && task.resourceIds.length <= 1 ? this.parseIn(progressCal, t.resume) : null; + // `time.resume` is universele brondata. Het MPP-pad blijft veldgedreven, maar een + // XER-taak mag nooit door kale veld-aanwezigheid als MPP behandeld worden: P6 activeert + // zijn eigen route alleen na de gevalideerde suspend/resume-opt-in. + const mayUseResume = task.p6ProjectId ? hasValidP6SuspendResume(task) : true; + const resumeOverride = mayUseResume && t.resume && task.resourceIds.length <= 1 + ? this.parseIn(progressCal, t.resume) + : null; if (resumeOverride && !isNaN(resumeOverride.getTime())) { remStart = resumeOverride; usedResumeOverride = true; @@ -1923,12 +2329,40 @@ export class CPMSolver { // mechanisme afgeleide waarde). Normale niet-override-pad: `remStart` is per constructie // altijd ≥ `actualES`, dus deze wacht is daar een no-op. if (usedResumeOverride && ef < actualES) ef = actualES; + // X7: de verwachte einddatum is bewaarde P6-brondata totdat de X5-projectvlag hem + // expliciet activeert. Daardoor blijft dezelfde taak bij MSP/IFC en bij een uitgeschakelde + // P6-vlag volledig op de gewone restduurroute. + if (task.p6ProjectId && this.options.schedulingOptions?.useExpectedFinishDates === true + && task.p6ExpectedFinish) { + const parsedExpected = this.parseIn(progressCal, task.p6ExpectedFinish); + // X7: date-only Expected Finish heeft FINISH-dagprecisie, ook wanneer een ANDER X7-veld + // de taakkalender naar uurmodus promoveerde. Zoek vanaf de volgende daggrens begrensd + // terug naar het laatste effectieve band-einde op of vóór de bedoelde kalenderdag. + // `null` betekent: binnen CalendarEngine.MAX_SCAN bestaat aantoonbaar geen bandpunt; + // dan blijft de al berekende finish staan i.p.v. een middernachtanker te verzinnen. + const expected = progressCal.isHourMode && !task.p6ExpectedFinish.includes('T') + ? progressCal.prevWorkInstantOrNull(new Date( + this.startOfDay(parsedExpected).getTime() + MS_PER_DAY, + )) + : parsedExpected; + if (expected && !isNaN(expected.getTime())) ef = expected; + } // Z8-HERWERKRONDE (LAAG 2 van de gelaagde beslistabel): een IN-PROGRESS-taak plant op haar // bestaande resume-/actuals-pad hierboven — GEEN Z8-venster-raadpleging. De EERSTE Z8-versie // deed dat nog wél; de herwerkronde liet die aanroep hier bewust vervallen (spiegelt de // VOLTOOID-tak hierboven) — `mppReader.ts` zet de Z8-velden nooit meer op een taak met // `0 < completion < 1`, dus de aanroep zou toch altijd `null` opleveren. - results.set(taskId, { es: actualES, ef }); + // XER exporteert op de zesassige Early Start-as het begin van het RESTERENDE werk, niet + // de historische Actual Start. `remStart` is hierboven uitsluitend uit invoersemantiek + // opgebouwd (statusdatum, relatiegrens en eventueel gevalideerde suspend/resume); er + // wordt geen P6 early/late-uitvoer gelezen. Andere formaten houden hun bestaande + // actual-startweergave doordat alleen het XER-pad deze vlag zet. + const displayedEarlyStart = this.p6XerOption( + this.options.schedulingOptions?.p6UseRemainingStartForProgress, + ) + ? remStart + : actualES; + results.set(taskId, { es: displayedEarlyStart, ef }); continue; } // B1 (eindreview T16c, dossier (c)4-herdiagnose): deze vloer is P6-eigen RETAINED_LOGIC- @@ -1995,6 +2429,20 @@ export class CPMSolver { // tegen een nog niet waargenomen bestand-vorm), corpusloos mutatiebewijs in cases-advanced-cpm.json. if (tf && earlyFinish < earlyStart) earlyFinish = earlyStart; + // XER/P6-grensvenster voor de zeldzame TT_FinMile-vorm waarin scheduleStart de eerste + // bandstart en scheduleFinish het vorige bandeinde draagt. Alleen toepassen zolang de + // netwerkuitkomst exact op dat geplande startanker staat; latere relatiedruk wint gewoon. + if (this.p6XerOption(this.options.schedulingOptions?.p6FinishMilestoneBoundaryWindow) + && isZeroDurationMilestone(task) && task.milestoneKind === 'FINISH') { + const plannedStart = this.parseIn(cal, task.time.scheduleStart); + const plannedFinish = this.parseIn(cal, task.time.scheduleFinish); + if (plannedFinish < plannedStart && earlyStart.getTime() === plannedStart.getTime()) { + earlyFinish = plannedFinish; + } + } else if (this.p6ZeroDurationActivityUsesBoundaryPair(task, cal)) { + earlyFinish = this.p6PreviousCalendarFinishBoundary(task, earlyStart); + } + results.set(taskId, { es: earlyStart, ef: earlyFinish }); } @@ -2026,6 +2474,7 @@ export class CPMSolver { if (!predResult || !predTask) continue; const c = forwardConstraint( this.relDeps, predResult, predTask, seq, task, this.calendarFor(predTask), cal, + this.p6ZeroDurationUsesFinishBoundary(task, cal), ); if (c > es) es = c; } @@ -2052,6 +2501,7 @@ export class CPMSolver { if (!predResult || !predTask) continue; const startEquiv = forwardConstraint( this.relDeps, predResult, predTask, seq, task, this.calendarFor(predTask), cal, + this.p6ZeroDurationUsesFinishBoundary(task, cal), ); const finishBound = this.finishFromStart(cal, startEquiv, task); if (!ef || finishBound > ef) ef = finishBound; @@ -2348,7 +2798,11 @@ export class CPMSolver { private backwardBoundOf(task: Task, c: TaskConstraint | undefined, eng: CalendarEngine): Date | null { const d = this.constraintInstant(c, eng); if (!c || !d) return null; - const dW = this.snapOnOrBefore(eng, d); + const preserveMilestoneInstant = this.p6XerOption( + this.options.schedulingOptions?.p6PreserveZeroDurationConstraintInstants, + ) + && isZeroDurationMilestone(task) && eng.isHourMode && c.date?.includes('T'); + const dW = preserveMilestoneInstant ? d : this.snapOnOrBefore(eng, d); if (c.type === 'FNLT' || c.type === 'MFO') return dW; if (c.type === 'SNLT' || c.type === 'MSO') return this.finishFromStart(eng, dW, task); return null; @@ -2558,7 +3012,10 @@ export class CPMSolver { if (succTask.manuallyScheduled) continue; // Z9b, uitsluiting (2) — zie moduleheader hierboven. this.seqConstraint.set( seq.id, - forwardConstraint(this.relDeps, early, task, seq, succTask, cal, this.calendarFor(succTask)), + forwardConstraint( + this.relDeps, early, task, seq, succTask, cal, this.calendarFor(succTask), + this.p6ZeroDurationUsesFinishBoundary(succTask, this.calendarFor(succTask)), + ), ); } } @@ -2582,16 +3039,199 @@ export class CPMSolver { // Find project end date (latest early finish) let projectEnd = new Date(0); - for (const { ef } of earlyDates.values()) { - if (ef > projectEnd) projectEnd = ef; + for (const taskId of order) { + const early = earlyDates.get(taskId); + const task = this.tasks.get(taskId); + if (!early || !task) continue; + const completedWindow = this.p6CompletedDataDateWindowDecision(task); + const usesCompletedDisplayWindow = completedWindow.eligible; + let candidateEf = early.ef; + if (usesCompletedDisplayWindow) { + const progressCal = this.progressCalendarFor(task); + const projectedEs = this.snapOnOrAfter(progressCal, this.dataDate!); + candidateEf = progressCal.prevWorkInstant(projectedEs); + } + if (candidateEf > projectEnd) { + projectEnd = candidateEf; + if (this.backwardFloatTrace) { + this.backwardFloatTrace.projectEndSource = usesCompletedDisplayWindow + ? 'completedDisplayWindow' + : 'maxEarlyFinish'; + } + } + } + if (this.options.schedulingOptions?.useProjectEndDateForFloat === true) { + const configuredProjectEnd = parseInstant(this.options.projectEndDate ?? ''); + if (!isNaN(configuredProjectEnd.getTime())) { + projectEnd = this.snapOnOrBefore(this.projectEngine, configuredProjectEnd); + if (this.backwardFloatTrace) this.backwardFloatTrace.projectEndSource = 'useProjectEndDateForFloat'; + } } // Backward pass in reverse topological order const reversed = [...order].reverse(); - + const preserveActualDates = this.dataDate !== null + && this.options.schedulingOptions?.preserveActualDatesInBackwardPass === true; for (const taskId of reversed) { const task = this.tasks.get(taskId)!; const succs = this.successors.get(taskId) || []; + const completedWindow = this.p6CompletedDataDateWindowDecision(task); + const backwardActualPin = explainBackwardActualPinEligibility( + task, + this.dataDate, + this.options.schedulingOptions, + ); + this.recordBackwardFloatTrace(taskId, { completedWindow, backwardActualPin }); + + // Brongebonden XER-beleid: een voltooide activiteit houdt haar bestaande completed-pin aan + // de late zijde. De smalle data-date-route verandert uitsluitend de forwarddatums; opgeslagen + // XER early/late/float-uitkomsten zijn op geen van beide paden solverinvoer. + if (backwardActualPin.eligible) { + // Review-bevinding 4 (poortdivergentie): dezelfde gedeelde poort als `scheduleAnalysis` + // (`explainP6CompletedLateRemainingWindowEligibility`) — inclusief de `backwardActualPin`- + // voorwaarde die hierboven al gold, zodat een taak nooit "tussen de twee poorten in" kan + // vallen (bv. `TK_Complete` zonder `act_end_date`: wel completedWindow-eligible, niet + // backwardActualPin-eligible — de weergave mag dan niet stilzwijgend meebewegen terwijl + // deze tak overgeslagen wordt). + if (explainP6CompletedLateRemainingWindowEligibility( + task, this.dataDate, this.options.schedulingOptions, + ).eligible) { + // Diagnose laag 1, klasse (i) (rehab-2, 2.036 voltooide taken, 99,9% dekking): een + // voltooide activiteit staat aan de late zijde óók op nul restduur op de statusdatum — + // `LF = prevWorkInstant(LS)` op de taak-eigen (voortgangs)kalender, `LS` = de vroegste + // van de door haar opvolgers toegestane late finishen, geklemd op de statusdatum. Geen + // opvolger ⇒ `LS` = de statusdatumklem zelf (dezelfde `nextWorkInstant`-grens als de + // forward completed-display-window hierboven). + // + // Bewust gepoort op `completedWindow.eligible` (dezelfde `explainP6CompletedDataDateWindow` + // die ook de FORWARD-display stuurt): dat is precies de asymmetrie uit de diagnose — ES + // toont daar al het statusdatumvenster, dus LS hoort dat ook te doen. Een voltooide taak + // die niet door die (nauwe) poort komt — de CP_Phys-route of de LOE/hammock-actual-finish- + // uitzondering (`explainCompletedXerLoeActualFinishEligibility`, expliciet ZONDER + // uitgaande relatie) — heeft geen zinvol statusdatumvenster aan de vroege kant en moet dus + // ook aan de late kant op haar bestaande actual-pin blijven staan; anders raakt LS/LF los + // van de eigen (niet-venster-)ES/EF van diezelfde taak. + const progressCal = this.progressCalendarFor(task); + const windowEs = this.snapOnOrAfter(progressCal, this.dataDate!); + let candidateLs: Date | null = null; + for (const seq of succs) { + const succResult = results.get(seq.successorId); + const succTask = this.tasks.get(seq.successorId); + if (!succResult || !succTask) continue; + if (succTask.isHammock) continue; + const succCal = this.calendarFor(succTask); + const succRawCompleted = !!succTask.time.actualFinish && succTask.time.completion >= 1; + // Zelfde gedeelde poort als hierboven/verderop (review-bevinding 4) — niet alleen + // `completedWindow.eligible`: een opvolger zonder `act_end_date` kan wél door de + // (bron-onafhankelijke) completedWindow-poort komen maar zelf NIET door + // `backwardActualPin`, en heeft dan géén door deze tak berekende ls/lf om op terug te + // rekenen — dat zou anders precies de poortdivergentie uit de review reproduceren. + const succUsesRemainingWindow = explainP6CompletedLateRemainingWindowEligibility( + succTask, this.dataDate, this.options.schedulingOptions, + ).eligible; + // Een voltooide opvolger die zelf niet door de gedeelde poort kwam (CP_Phys, de + // LOE/hammock-actual-finish-uitzondering, of ontbrekende `act_end_date`) draagt nog + // steeds haar ongewijzigde actual-pin — pure historie, net als in de generieke tak + // hierboven — en mag deze taak dus niet terugtrekken. + if (succRawCompleted && !succUsesRemainingWindow) continue; + // Gemeten uitzondering: de relatie-lag telt NIET mee tussen twee voltooide + // activiteiten (R1 zonder lag: 2.033/2.036), maar WEL zolang de opvolger nog restwerk + // heeft (R2 mét lag altijd: slechts 1.796/2.036 — de drie R1-uitzonderingen hebben + // stuk voor stuk een NIET-voltooide maatgevende opvolger). + // Her-review bevinding 4: een PROCENTUELE lag wordt tegen de voorgangerduur opgelost, en + // de nulrestduur-kloon hieronder zet die duur voor SS/SF op 0 — de lag verdween daar dus + // stil (FS/FF hielden 'm wél). Daarom hier eerst tegen de ONGEWIJZIGDE taak vastzetten, + // in de eenheid die de relatiewiskunde voor deze lag-soort leest (WORKTIME: minuten in de + // lag-kalender én dagen; ELAPSEDTIME: alleen dagen — `resolveElapsedMinutes` zou een + // minutenwaarde als klokminuten lezen), en `lagPercent` wissen. Daarna is de kloon een + // zuivere duur-neutralisatie die de lag niet meer raakt. + const hasPercentLag = typeof seq.lagPercent === 'number' && Number.isFinite(seq.lagPercent); + const lagEng = this.relDeps.lagEngine(progressCal, succCal); + const percentResolvedSeq: Sequence = !hasPercentLag + ? seq + : seq.lagUnit === 'ELAPSEDTIME' + // `lagMinutes` blijft staan: `resolveElapsedMinutes` geeft hem voorrang, en dat moet + // hier niet omkeren (her-review N4) — alleen de PROCENT-tak wordt tegen de echte duur + // in dagen vastgezet. + ? { ...seq, lagDays: resolveEffectiveLagDays(seq, task), lagPercent: undefined } + : { + ...seq, + lagDays: resolveEffectiveLagDays(seq, task, lagEng.hoursPerDay), + lagMinutes: this.resolveLagMinutes(seq, task, lagEng), + lagPercent: undefined, + }; + const effectiveSeq = succUsesRemainingWindow + ? { ...seq, lagDays: 0, lagMinutes: 0, lagPercent: undefined } + : percentResolvedSeq; + const delayShiftedSuccResult = { + ls: this.shiftByLevelingDelay(succCal, succTask, succResult.ls, -1), + lf: this.shiftByLevelingDelay(succCal, succTask, succResult.lf, -1), + }; + // Review-bevinding 3: voor START_START/START_FINISH geeft `backwardConstraint` de late + // START van de voorganger terug via `finishFromStart(pe, predLS, predTask)` — d.w.z. + // `predLS` plus de VOLLE geplande duur van de voorganger (spiegel van de forward- + // duurtoepassing, normaal correct). Voor een voltooide taak met nul restduur is die + // duur-optelling dubbelop: de klem hieronder behandelt het resultaat toch al als een + // 0-duur-finish. Een voorganger met `scheduleDuration`/`durationMinutes` op 0 (en + // `isMilestone: true`, zodat ook `relationBoundaryFlags`/`isZeroDurationMilestone` + // consistent nul-duur zien) maakt `finishFromStart` voor die twee typen een no-op, zodat + // `constraintFinish` de late START zelf teruggeeft — precies de nulrestduur-aanname die + // deze hele tak al maakt, nu voor ALLE vier de relatietypen op één plek. FS/FF blijven + // ongewijzigd: `backwardConstraint` geeft daar al een echte LF terug, onafhankelijk van + // de voorgangerduur. + const isStartSideRelation = seq.type === 'START_START' || seq.type === 'START_FINISH'; + // `milestoneKind: undefined`: `relationBoundaryFlags` leidt `predStartsNextDay` af uit + // `scheduleDuration <= 0` PLUS `milestoneKind === 'FINISH'`. Door de duur op 0 te zetten + // wordt de eerste helft altijd waar, dus zonder deze regel zou een voltooide EINDmijlpaal- + // met-duur (T15-vorm) hier een extra werkdaggrens-sprong krijgen die een gewone voltooide + // taak niet krijgt. Onder de aanname van deze tak — nul restduur, LS is het anker, + // `LF = prevWorkInstant(LS)` — bestaat die aparte finishgrens niet. + // EERLIJK GELABELD (her-review bevinding 6): dit is DEFENSIEF en via de XER-lezer + // ONBEREIKBAAR — `xerReader` zet `milestoneKind` alleen voor `TT_Mile`/`TT_FinMile`, en + // die weigert de gedeelde poort al met `wrongActivityType`. Het pad bestaat alleen via een + // IFC-round-trip plus handmatig markeren als mijlpaal; er is geen test die het raakt en de + // regel weglaten laat de suite groen. Geen gefixte bug dus, wel een gesloten deur. + const zeroRemainingPredTask: Task = isStartSideRelation + ? { + ...task, + isMilestone: true, + milestoneKind: undefined, + time: { ...task.time, scheduleDuration: 0, durationMinutes: 0 }, + } + : task; + const constraintFinish = backwardConstraint( + this.relDeps, delayShiftedSuccResult, effectiveSeq, zeroRemainingPredTask, succTask, + progressCal, succCal, this.p6ZeroDurationUsesFinishBoundary(succTask, succCal), + ); + // FS/FF: `constraintFinish` is een echte late FINISH van de voorganger — de + // nulrestduur-conversie naar een late START loopt via `nextWorkInstant` (spiegel van + // `prevWorkInstant(LS)` verderop). SS/SF: dankzij `zeroRemainingPredTask` hierboven is + // `constraintFinish` de late START zelf al (`finishFromStart` deed niets) — die nogmaals + // door `nextWorkInstant` halen zou 'm een vol bandsprong verder duwen zodra de opvolger + // exact op een bandgrens eindigt (mutatiebewijs: SF-fixture, `check-xer-completed-late`). + const candidateLsFromSeq = isStartSideRelation + ? constraintFinish + : progressCal.nextWorkInstant(constraintFinish); + if (candidateLs === null || candidateLsFromSeq < candidateLs) candidateLs = candidateLsFromSeq; + } + const ls = candidateLs === null || candidateLs < windowEs ? windowEs : candidateLs; + const lf = progressCal.prevWorkInstant(ls); + this.recordBackwardFloatTrace(taskId, { + lateFinishSource: 'completedRemainingWindow', + lateStartSource: 'completedRemainingWindow', + }); + results.set(taskId, { ls, lf }); + continue; + } + const ed = earlyDates.get(taskId)!; + this.recordBackwardFloatTrace(taskId, { + lateStartSource: this.p6XerOption( + this.options.schedulingOptions?.p6UseRemainingStartForProgress, + ) ? 'subRemainingDuration' : 'subDuration', + }); + results.set(taskId, { ls: new Date(ed.es.getTime()), lf: new Date(ed.ef.getTime()) }); + continue; + } // Hammock (§4.4, normatief): een gevolg, geen oorzaak. GEEN backward-`min`-doorgifte; per // definitie `LS = ES` en `LF = EF` (⇒ tf=ff=0, kritiek-neutraal — geforceerd in computeResults). @@ -2644,16 +3284,51 @@ export class CPMSolver { continue; } + // P6/XER: een verbonden open TT_FinMile is een zelfstandig contracteindpunt. Zijn vroege + // start-/vorige-finishgrens is daarom óók zijn late grens en legt vanaf daar backward-druk op + // de voorgangerketen, zelfs wanneer een andere open taak het latere projecteinde bepaalt. + // Dit is niet de expliciete PROJECT.plan_end-variant: wanneer die bronoptie aan staat, blijft + // dat projecteinde juist de late-passgrens (Terminal-fixture). De voorgangerpoort houdt een + // volledig geïsoleerde finishmijlpaal buiten deze regel. Alleen de bestaande XER-vlag kan de + // tak activeren; alle andere formaten blijven byte-identiek. + if (this.p6XerOption(this.options.schedulingOptions?.p6FinishMilestoneBoundaryWindow) + && this.options.schedulingOptions?.useProjectEndDateForFloat !== true + && succs.length === 0 + && (this.predecessors.get(taskId)?.length ?? 0) > 0 + && task.milestoneKind === 'FINISH' && isZeroDurationMilestone(task)) { + const ed = earlyDates.get(taskId)!; + this.recordBackwardFloatTrace(taskId, { lateFinishSource: 'projectEnd', lateStartSource: 'subDuration' }); + results.set(taskId, { ls: new Date(ed.es.getTime()), lf: new Date(ed.ef.getTime()) }); + continue; + } + // Niets kan ná het projecteinde eindigen — dat is de bovengrens voor élke taak. Opvolger- // constraints kunnen de late finish alleen verder naar voren halen. (Voorheen kon een // Start-Start-opvolger een late finish ná het projecteinde opleveren, waardoor de // voorganger ten onrechte speling/niet-kritiek kreeg.) const predCal = this.calendarFor(task); let lateFinish = projectEnd; + let lateFinishSource: CpmLateFinishSource = 'projectEnd'; for (const seq of succs) { const succResult = results.get(seq.successorId); const succTask = this.tasks.get(seq.successorId); if (!succResult || !succTask) continue; + // Een voltooide P6-opvolger beschrijft historie en mag de late finish van een nog open + // voorganger niet door haar historische actual finish terugtrekken — TENZIJ de opvolger + // zelf door de completedWindow-poort kwam (de tak hierboven, `p6CompletedLateFromRemainingWindow`): + // dan draagt die opvolger al een zinvolle, statusdatum-gebaseerde late kant en hoort ze, + // net als elke andere opvolger, gewone backward-druk te leggen op haar eigen voorgangers. + // Een voltooide opvolger die niet door die (nauwe) poort kwam — CP_Phys of de LOE/hammock- + // actual-finish-uitzondering — blijft wel uitgesloten: haar ls/lf zijn dan nog steeds de + // rauwe actual-pin (ongewijzigd hierboven), niet iets zinvols om op terug te rekenen. + const succCompletedHistoric = preserveActualDates && !!succTask.time.actualFinish + && succTask.time.completion >= 1; + // Zelfde gedeelde poort als hierboven (review-bevinding 4): alleen een opvolger die er zelf + // door komt draagt een zinvolle late kant om op terug te rekenen. + const succUsesRemainingWindow = explainP6CompletedLateRemainingWindowEligibility( + succTask, this.dataDate, this.options.schedulingOptions, + ).eligible; + if (succCompletedHistoric && !succUsesRemainingWindow) continue; // Een hammock is een gevolg, geen oorzaak (§4.4): hij legt GEEN backward-druk op zijn // voorgangers (drivers). Een strakke opvolger van de hammock kan zo nooit via de hammock heen // negatieve float op de start-/finish-driver leggen — de driver ziet alleen zijn eigen @@ -2685,17 +3360,53 @@ export class CPMSolver { }; const constraintDate = backwardConstraint( this.relDeps, delayShiftedSuccResult, seq, task, succTask, predCal, succCal, + this.p6ZeroDurationUsesFinishBoundary(succTask, succCal), ); if (constraintDate < lateFinish) { lateFinish = constraintDate; + lateFinishSource = 'successorConstraint'; } } // Late-zijde datum-constraints + deadline (fase 2.3) als extra bovengrens. lateFinish = this.applyBackwardBound(task, lateFinish, predCal); + if (this.p6ZeroDurationActivityUsesBoundaryPair(task, predCal)) { + // Een opvolgergrens kan als volgende bandSTART binnenkomen. P6 toont voor deze + // geïnverteerde nulduurvorm de complementaire finishrand; op een echt bandeinde is deze + // operatie idempotent. + lateFinish = predCal.prevWorkInstant(lateFinish); + } - const lateStart = this.subDuration(predCal, lateFinish, task, earlyDates.get(taskId)?.es ?? null); + const useP6RemainingProgress = this.p6XerOption( + this.options.schedulingOptions?.p6UseRemainingStartForProgress, + ) + && task.time.actualStart !== undefined && task.time.completion > 0 && task.time.completion < 1; + const computedLateStart = useP6RemainingProgress + ? this.subRemainingDuration(predCal, lateFinish, task) + : this.subDuration(predCal, lateFinish, task, earlyDates.get(taskId)?.es ?? null); + // Bij P6-voortgang is een geregistreerde actual start ook de getoonde LS; LF blijft uit het + // resterende netwerk volgen. Zonder bronvlag blijft de berekende late start leidend. + let lateStart = preserveActualDates && task.time.actualStart && task.time.completion < 1 + && !this.p6XerOption(this.options.schedulingOptions?.p6UseRemainingStartForProgress) + ? new Date(earlyDates.get(taskId)!.es.getTime()) + : computedLateStart; + if (this.p6XerOption(this.options.schedulingOptions?.p6FinishMilestoneBoundaryWindow) + && isZeroDurationMilestone(task) && task.milestoneKind === 'FINISH') { + const plannedStart = this.parseIn(predCal, task.time.scheduleStart); + const plannedFinish = this.parseIn(predCal, task.time.scheduleFinish); + if (plannedFinish < plannedStart && lateFinish.getTime() === plannedFinish.getTime()) { + lateStart = plannedStart; + } + } else if (this.p6ZeroDurationActivityUsesBoundaryPair(task, predCal)) { + // De backward-spiegel van het forward-grenspaar: LF is een finishrand en LS de eerste + // start-rand op-of-na die grens. Een exact bandeinde springt zo naar de volgende bandstart. + lateStart = predCal.nextWorkInstant(lateFinish); + } + this.recordBackwardFloatTrace(taskId, { + lateFinishSource, + lateStartSource: useP6RemainingProgress ? 'subRemainingDuration' : 'subDuration', + }); results.set(taskId, { ls: lateStart, lf: lateFinish }); } diff --git a/src/engine/scheduler/CalendarEngine.ts b/src/engine/scheduler/CalendarEngine.ts index 715633bd8..274e24dc5 100644 --- a/src/engine/scheduler/CalendarEngine.ts +++ b/src/engine/scheduler/CalendarEngine.ts @@ -1,4 +1,4 @@ -import { holidayEndDate, WorkCalendar } from '@/types/calendar'; +import { holidayEndDate, WorkCalendar } from '@/types/calendar'; import { parseDate, isoDayOfWeek, addCalendarDays, formatDate, diffCalendarDays } from '@/utils/dateUtils'; /** Eén gematerialiseerd werk-interval, absolute UTC-ms, half-open `[start, end)` (fase 2.8b, §4.2). */ @@ -113,7 +113,7 @@ export class CalendarEngine { private buildHolidaySet(): void { for (const holiday of this.calendar.holidays) { const start = parseDate(holiday.startDate); - const end = parseDate(holidayEndDate(holiday)); + const end = parseDate(holidayEndDate(holiday)); const days = diffCalendarDays(start, end); for (let i = 0; i <= days; i++) { const d = addCalendarDays(start, i); @@ -643,7 +643,7 @@ export class CalendarEngine { /** Laatste band-eind ≤ `tMs` (of strikt < bij `strict`), in ms. Dag-scan achteruit; stopt zodra * geen eerdere dag het beste resultaat nog kan verbeteren (max mogelijke eind = dagstart+2880m). */ - private prevBandEndBound(tMs: number, strict: boolean): number { + private prevBandEndBound(tMs: number, strict: boolean): number | null { let best = Number.NEGATIVE_INFINITY; let dayMs = this.dayStartMsOf(tMs); let scan = 0; @@ -657,7 +657,7 @@ export class CalendarEngine { dayMs -= CalendarEngine.MS_PER_DAY; scan++; } - return best === Number.NEGATIVE_INFINITY ? tMs : best; + return best === Number.NEGATIVE_INFINITY ? null : best; } // ── Instant-vinders (§4.1) ───────────────────────────────────────────────── @@ -684,21 +684,66 @@ export class CalendarEngine { prevWorkInstant(t: Date): Date { const tMs = t.getTime(); if (this.findContaining(tMs, true)) return new Date(tMs); - return new Date(this.prevBandEndBound(tMs, false)); + return new Date(this.prevBandEndBound(tMs, false) ?? tMs); + } + + /** Begrensde variant voor aanroepers die "geen band gevonden" semantisch moeten onderscheiden + * van de bestaande best-effort-terugval op `t`. Dezelfde MAX_SCAN-zoektocht, nooit een tweede lus. */ + prevWorkInstantOrNull(t: Date): Date | null { + const tMs = t.getTime(); + if (this.findContaining(tMs, true)) return new Date(tMs); + const found = this.prevBandEndBound(tMs, false); + return found === null ? null : new Date(found); } /** Het laatste band-eind STRIKT < t (§4.1). */ prevWorkInstantBefore(t: Date): Date { - return new Date(this.prevBandEndBound(t.getTime(), true)); + const tMs = t.getTime(); + return new Date(this.prevBandEndBound(tMs, true) ?? tMs); } // ── Minuut-lussen (§4.2) ─────────────────────────────────────────────────── + // + // SNAP-REGEL OP NIET-WERK-INSTANTS — waarom `addWorkMinutes` en `subtractWorkMinutes` elkaars + // exacte spiegel zijn, en waar die spiegel schijnbaar (maar niet werkelijk) breekt. + // + // Beide lussen normaliseren hun aangrijpingspunt eerst naar een werk-instant, elk IN DE RICHTING + // VAN DE EIGEN WANDELING: `addWorkMinutes` gebruikt `nextWorkInstant` (vooruit), en + // `subtractWorkMinutes` gebruikt `prevWorkInstant` (achteruit). Daaruit volgen drie regels die je + // moet kennen vóór je hier iets aanraakt: + // + // 1. Voor elk WERK-instant `t` geldt `subtractWorkMinutes(addWorkMinutes(t, n), n) === t` op de + // milliseconde. Dat is geen toevallige eigenschap maar de invariant waarop de backward-pass + // van de solver leunt: `LS..LF` moet exact evenveel werktijd overspannen als `ES..EF`. + // 2. Voor een `t` die GEEN werk-instant is (midden in een weekend, een feestdag of een + // aaneengesloten vrij blok van dagen) is er geen ronde-reis-identiteit, en dat is correct: + // `add` snapt naar de eerstvolgende bandstart, `sub` naar het laatste band-eind ervóór. De + // twee snappunten liggen per definitie aan weerszijden van hetzelfde gat. Een aanroeper die + // een niet-werk-instant aanlevert vraagt om een richtingsafhankelijk antwoord en krijgt het. + // 3. Op een BANDGRENS zijn twee verschillende instants hetzelfde punt op de werk-as: het eind van + // de ene band en het begin van de volgende hebben nul werkminuten tussen zich. `sub` levert + // daarom een bandstart waar `add` een band-eind levert, zónder dat er werktijd verschilt. De + // juiste gelijkheidstest tussen twee posities op de werk-as is `workMinutesBetween(a, b) === 0`, + // NIET `a.getTime() === b.getTime()`. + // + // Er is in dit bestand GEEN brongebonden uitzondering meer op die drie regels. Tot etappe 7b-2 + // droeg deze klasse een `subtractP6XerProjectedWorkMinutes`-projectie achter `p6Source === 'XER'` + // die de achterwaartse wandeling bewust asymmetrisch maakte; die is verwijderd nadat de + // XER-decoder de werkelijk bedoelde vrije dagen kon reconstrueren en de projectie op elk + // penaltydragend corpusbestand nul cellen bleek te verklaren (zie `xerCalendarData.ts`). + // `tests/planning/check-calendar-mirror.ts` pint deze drie regels vast, over een aaneengesloten + // niet-werkblok van tien dagen, in dag-modus en in uur-modus met 1, 2 en 3 banden per dag. /** Tel `minutes` werkminuten op vanaf `startInstant` (§4.2): verbruik over opeenvolgende banden, * spring bij een bandgrens naar de volgende bandstart. Een verbruik dat exact op een band-eind * landt geeft die eindgrens terug (legitiem finish-moment). `minutes ≤ 0` ⇒ start ongewijzigd * (spiegelt `addWorkDays`' `≤0`-tak, voor mijlpalen). */ addWorkMinutes(startInstant: Date, minutes: number): Date { + return this.addPhysicalWorkMinutes(startInstant, minutes); + } + + /** De fysieke bandwandeling. */ + private addPhysicalWorkMinutes(startInstant: Date, minutes: number): Date { if (minutes <= 0) return new Date(startInstant.getTime()); let remaining = Math.min(minutes, CalendarEngine.MAX_MINUTES); let curMs = this.nextWorkInstant(startInstant).getTime(); @@ -722,6 +767,11 @@ export class CalendarEngine { /** Trek `minutes` werkminuten af van `endInstant` (spiegel van `addWorkMinutes`, §4.2/§5.2). Een * landing exact op een bandstart is legitiem (rand `(start,end]`). */ subtractWorkMinutes(endInstant: Date, minutes: number): Date { + return this.subtractPhysicalWorkMinutes(endInstant, minutes); + } + + /** De fysieke achterwaartse bandwandeling. */ + private subtractPhysicalWorkMinutes(endInstant: Date, minutes: number): Date { if (minutes <= 0) return new Date(endInstant.getTime()); let remaining = Math.min(minutes, CalendarEngine.MAX_MINUTES); let curMs = this.prevWorkInstant(endInstant).getTime(); @@ -745,6 +795,11 @@ export class CalendarEngine { /** Getekende werkminuten in `[a,b)` (§5.5, voor vrije speling). Positief als b>a, negatief als * b 0) continue; + if (!task || task.isMilestone || isSummaryTask(task)) continue; const dur = task.time.scheduleDuration; if (dur <= 0) continue; const arr = assignmentDayUnits(task, a, engineForTask(task).hoursPerDay * 60, contourOf(task, a)); diff --git a/src/engine/scheduler/ResourceLoad.ts b/src/engine/scheduler/ResourceLoad.ts index e155d53f3..6c0903031 100644 --- a/src/engine/scheduler/ResourceLoad.ts +++ b/src/engine/scheduler/ResourceLoad.ts @@ -3,6 +3,7 @@ // functie die zowel het histogram als, straks, de nivelleerder voedt) en `computeResourceLoad` // (dag-granulaire belasting/capaciteit/overallocatie over alle resources+toewijzingen). import type { Resource, ResourceAssignment, ResourceCurve } from '@/types/resource'; +import { isLeafTask, isSummaryTask } from '@/utils/taskHierarchy'; import type { Task, TaskTimephasedContour } from '@/types/task'; import type { Sequence } from '@/types/sequence'; import type { WorkCalendar } from '@/types/calendar'; @@ -337,7 +338,7 @@ export function computeResourceLoad( // 1. Leaf-only, geen mijlpalen (dubbele bewaking t.o.v. resourceSlice.assignResource, §2.4). const validAssignments = assignments.filter(a => { const task = taskById.get(a.taskId); - return !!task && !task.isMilestone && task.childIds.length === 0; + return !!task && isLeafTask(task) && !task.isMilestone; }); // 2-3. Verdeel + accumuleer per resource per dag. Contour-engine (2026-09): de verdeling komt uit @@ -508,7 +509,7 @@ export function computeHistogramReport(input: HistogramInput): HistogramReport { const contourOf = contourLookup(assignments); for (const a of assignments) { const task = taskById.get(a.taskId); - if (!task || task.isMilestone || task.childIds.length > 0) continue; + if (!task || task.isMilestone || isSummaryTask(task)) continue; const taskEngine = engineForTask(task, taskEngineCache, projectEngine, calendars, calendar); const dist = assignmentDayUnits(task, a, taskEngine.hoursPerDay * 60, contourOf(task, a)); if (dist.length === 0) continue; diff --git a/src/engine/scheduler/applyCpmResult.ts b/src/engine/scheduler/applyCpmResult.ts index 43f94ab7e..c106c543e 100644 --- a/src/engine/scheduler/applyCpmResult.ts +++ b/src/engine/scheduler/applyCpmResult.ts @@ -1,5 +1,6 @@ import type { Task } from '@/types/task'; import type { WorkCalendar } from '@/types/calendar'; +import { isLeafTask } from '@/utils/taskHierarchy'; import type { CPMResult } from './CPMSolver'; import { parseInstant, formatInstant } from '@/utils/dateUtils'; import { taskDurationUnit } from './duration'; @@ -60,15 +61,38 @@ export function applyCpmResult(tasks: Task[], result: CPMResult, _cals: ApplyCpm } } - // Verzameltaken: datums oprollen uit de kinderen. + rollupSummaryTasks(tasks); +} + +/** + * Verzameltaken: datums oprollen uit de kinderen. Uitgefactoriseerd uit `applyCpmResult` (her-check + * laag 3, bevinding 3) zodat "datums zoals opgeslagen" (`applyRecordedTimesToTasks`, + * `recordedDates.ts`) DEZELFDE rollup draait: P6 legt zijn zes uitvoerkolommen alleen op TASK-rijen + * vast, nooit op PROJWBS-rijen, dus een XER-WBS-rij heeft nooit een eigen vastlegging — zonder deze + * rollup hield zo'n samenvattingsbalk de datums van de solve die de modus zojuist verwierp (gemeten: + * hoofd-WBS een half jaar ná de `projectEnd` die dezelfde modus rapporteert). Gedrag byte-identiek + * aan de inline-versie van vóór de uitfactorisering; alleen de aanroepplek is erbij gekomen. + */ +export function rollupSummaryTasks( + tasks: Task[], + options?: { + /** Her-check laag 3, R1: een samenvatting die het bestand ZÉLF vastlegde (de #63-IFC-route + * draagt op fasen gewoon een `IfcTaskTime`) blijft staan zoals het bestand haar gaf — de rollup + * mag daar niet overheen schrijven, anders zegt de modus iets wat het bestand niet zei. De + * kinderen eronder worden nog wél bezocht (die kunnen zelf weer onvastgelegde samenvattingen + * zijn). Afwezig ⇒ elke samenvatting rolt op, byte-identiek aan `applyCpmResult`. */ + skip?: (task: Task) => boolean; + }, +): void { // A4 (prestatie): één vooraf gebouwde id→taak-Map i.p.v. `find` per taak én per kind (recursief) — // dat was O(n²) op de rollup. const byId = new Map(tasks.map(t => [t.id, t])); const updateSummary = (taskId: string): void => { const task = byId.get(taskId); - if (!task || task.childIds.length === 0) return; + if (!task || isLeafTask(task)) return; for (const childId of task.childIds) updateSummary(childId); + if (options?.skip?.(task)) return; const children = task.childIds .map(cid => byId.get(cid)) diff --git a/src/engine/scheduler/expandSummaryRelations.ts b/src/engine/scheduler/expandSummaryRelations.ts index b92e537cd..1d84a7fbe 100644 --- a/src/engine/scheduler/expandSummaryRelations.ts +++ b/src/engine/scheduler/expandSummaryRelations.ts @@ -1,10 +1,11 @@ import type { Task } from '@/types/task'; import type { Sequence } from '@/types/sequence'; import type { CPMResult } from './CPMSolver'; +import { isLeafTask } from '@/utils/taskHierarchy'; /** * Volledige samenvattingsrelatie-propagatie (vervolg op 489a9ef2). `CPMSolver` kent alleen - * bladtaken (`childIds.length === 0`) als knopen; een relatie die een WBS-samenvattingstaak raakt + * semantische bladtaken als knopen; een relatie die een WBS-samenvattingstaak raakt * kan de solver dus niet zelf verwerken. 489a9ef2 loste de crash op door zulke relaties te droppen * (`CPMResult.droppedSequenceIds`) — een bewuste tussenoplossing die op een echt corpusbestand * (870d339f60603f71, hash-only §8) 28 van de 105 relaties liet vallen: een kwart van de logica. @@ -113,7 +114,7 @@ export function expandSummaryRelations( if (cached) return cached; const root = byId.get(rootId); if (!root) return NONE; - if (root.childIds.length === 0) { + if (isLeafTask(root)) { const self = [rootId]; leafDescCache.set(rootId, self); return self; @@ -127,7 +128,7 @@ export function expandSummaryRelations( visited.add(id); const t = byId.get(id); if (!t) continue; // verweesd kind-id — geen bladtaak om te representeren - if (t.childIds.length === 0) { + if (isLeafTask(t)) { out.push(id); } else { stack.push(...t.childIds); @@ -164,8 +165,8 @@ export function expandSummaryRelations( outSeqs.push(seq); continue; } - const predIsLeaf = predTask.childIds.length === 0; - const succIsLeaf = succTask.childIds.length === 0; + const predIsLeaf = isLeafTask(predTask); + const succIsLeaf = isLeafTask(succTask); if (predIsLeaf && succIsLeaf) { outSeqs.push(seq); // geen samenvatting aan weerskant — niets te expanderen continue; diff --git a/src/engine/scheduler/lagCalendar.ts b/src/engine/scheduler/lagCalendar.ts deleted file mode 100644 index 279d80945..000000000 --- a/src/engine/scheduler/lagCalendar.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * P6-default: "Calendar for scheduling Relationship Lag" = predecessor-kalender (fase 2.8a, §5.2). - * De instelbare scheduling option is bewust fase 2.9 (TODO.md r217). - * - * Bron-onzekerheid (§12.1): het research-rapport vond tegenstrijdige bronnen over de fabrieksdefault - * (Ten Six + planner-fora: predecessor; één zoekresultaat: successor). Beste inschatting = predecessor; - * hier als constante vastgelegd, in 2.9 te ontsluiten als keuze. De CPM-testbatterij legt de gekozen - * tak vast met de counterfactual ernaast (cases-kalenders.json, scenario 2). - */ -export const LAG_CALENDAR: 'predecessor' | 'successor' = 'predecessor'; diff --git a/src/engine/scheduler/p6CompletedRouteTrace.ts b/src/engine/scheduler/p6CompletedRouteTrace.ts new file mode 100644 index 000000000..b16c8a2ef --- /dev/null +++ b/src/engine/scheduler/p6CompletedRouteTrace.ts @@ -0,0 +1,218 @@ +import type { SchedulingOptions } from '@/types/project'; +import type { Sequence } from '@/types/sequence'; +import type { Task } from '@/types/task'; +import { parseInstant } from '@/utils/dateUtils'; +import { + explainP6CompletedDataDateWindow, + type P6CompletedWindowReason, +} from '@/utils/p6CompletedTargetWindow'; + +export type CompletedXerLoeActualFinishReason = + | 'eligible' + | 'missingDataDate' + | 'invalidDataDate' + | 'notXerSource' + | 'remainingStartOff' + | 'preserveActualDatesOff' + | 'preserveActualInstantsOff' + | 'missingProjectProvenance' + | 'missingTaskProvenance' + | 'wrongActivityType' + | 'wrongCompletePctType' + | 'wrongDurationType' + | 'missingExplicitTargetWindow' + | 'notCompleted' + | 'invalidCompletion' + | 'hasSuspendResume' + | 'milestoneOrZeroDuration' + | 'missingScheduleStart' + | 'missingScheduleFinish' + | 'missingActualFinish' + | 'invalidScheduleStart' + | 'invalidScheduleFinish' + | 'invalidActualFinish' + | 'targetWindowInverted' + | 'actualFinishBeforeTargetFinish' + | 'actualFinishAfterDataDate' + | 'missingIncomingStartStart' + | 'incomingNotOnlyStartStart' + | 'hasOutgoingRelation'; + +export interface CompletedXerLoeActualFinishDecision { + eligible: boolean; + reason: CompletedXerLoeActualFinishReason; +} + +export type CpmBackwardActualPinReason = + | 'eligible' + | 'missingDataDate' + | 'preserveActualDatesOff' + | 'missingActualFinish' + | 'notCompleted'; + +export interface CpmBackwardActualPinDecision { + eligible: boolean; + reason: CpmBackwardActualPinReason; +} + +export type CpmDisplayActualLateReason = + | 'eligible' + | 'missingDataDate' + | 'notCompleted' + | 'preserveActualDatesOff'; + +export interface CpmDisplayActualLateDecision { + eligible: boolean; + reason: CpmDisplayActualLateReason; +} + +/** + * Smalle, pure en fail-closed diagnose voor één bewezen P6-XER-vorm: een voltooide LOE met + * uitsluitend inkomende SS-relaties en zonder opvolger. Alleen deze vorm mag in `forwardPass` + * door de generieke hammock-route vallen zodat de bestaande completed-actualroute haar concrete + * actualFinish gebruikt. De helper leest uitsluitend toegestane invoer/bronprovenance, nooit P6's + * opgeslagen early/late/float-uitkomst. + */ +export function explainCompletedXerLoeActualFinishEligibility( + task: Task, + dataDate: Date | null, + schedulingOptions: SchedulingOptions | undefined, + incoming: readonly Sequence[], + outgoing: readonly Sequence[], +): CompletedXerLoeActualFinishDecision { + if (dataDate === null) return { eligible: false, reason: 'missingDataDate' }; + if (!Number.isFinite(dataDate.getTime())) return { eligible: false, reason: 'invalidDataDate' }; + if (schedulingOptions?.p6Source !== 'XER') return { eligible: false, reason: 'notXerSource' }; + if (schedulingOptions.p6UseRemainingStartForProgress !== true) { + return { eligible: false, reason: 'remainingStartOff' }; + } + if (schedulingOptions.preserveActualDatesInBackwardPass !== true) { + return { eligible: false, reason: 'preserveActualDatesOff' }; + } + if (schedulingOptions.p6PreserveActualInstants !== true) { + return { eligible: false, reason: 'preserveActualInstantsOff' }; + } + // De forward-route valt daarna door naar de bestaande completed-actualverwerking. Die + // bewaart XER-instanten alleen voor echte P6-taken; zonder beide herkomstsleutels zou deze + // uitzondering dus een andere route openen dan de gemeten Harbour-vorm. + if (task.p6ProjectId === undefined || task.p6ProjectId === '') { + return { eligible: false, reason: 'missingProjectProvenance' }; + } + if (task.p6TaskId === undefined || task.p6TaskId === '') { + return { eligible: false, reason: 'missingTaskProvenance' }; + } + if (task.p6ActivityType !== 'TT_LOE') return { eligible: false, reason: 'wrongActivityType' }; + if (task.p6CompletePctType !== 'CP_Drtn') return { eligible: false, reason: 'wrongCompletePctType' }; + if (task.p6DurationType !== 'DT_FixedDrtn') return { eligible: false, reason: 'wrongDurationType' }; + if (task.p6ExplicitTargetWindow !== true) { + return { eligible: false, reason: 'missingExplicitTargetWindow' }; + } + if (task.status !== 'COMPLETED' || task.time.completion < 1) { + return { eligible: false, reason: 'notCompleted' }; + } + if (!Number.isFinite(task.time.completion)) return { eligible: false, reason: 'invalidCompletion' }; + if (task.time.stop !== undefined || task.time.resume !== undefined || task.p6SuspendResume === true) { + return { eligible: false, reason: 'hasSuspendResume' }; + } + // Een LOE die als mijlpaal of met een niet-positieve duur binnenkomt is niet de gemeten + // Harbour-vorm. Ook wanneer het targetvenster toevallig een span toont, openen we die + // tegenstrijdige bronvorm niet met deze uitzondering. + if (task.isMilestone || !Number.isFinite(task.time.scheduleDuration) || task.time.scheduleDuration <= 0) { + return { eligible: false, reason: 'milestoneOrZeroDuration' }; + } + if (!task.time.scheduleStart) return { eligible: false, reason: 'missingScheduleStart' }; + if (!task.time.scheduleFinish) return { eligible: false, reason: 'missingScheduleFinish' }; + if (!task.time.actualFinish) return { eligible: false, reason: 'missingActualFinish' }; + const scheduleStart = parseInstant(task.time.scheduleStart); + const scheduleFinish = parseInstant(task.time.scheduleFinish); + const actualFinish = parseInstant(task.time.actualFinish); + if (!Number.isFinite(scheduleStart.getTime())) return { eligible: false, reason: 'invalidScheduleStart' }; + if (!Number.isFinite(scheduleFinish.getTime())) return { eligible: false, reason: 'invalidScheduleFinish' }; + if (!Number.isFinite(actualFinish.getTime())) return { eligible: false, reason: 'invalidActualFinish' }; + if (scheduleStart > scheduleFinish) return { eligible: false, reason: 'targetWindowInverted' }; + if (actualFinish < scheduleFinish) { + return { eligible: false, reason: 'actualFinishBeforeTargetFinish' }; + } + if (actualFinish > dataDate) return { eligible: false, reason: 'actualFinishAfterDataDate' }; + if (incoming.length === 0) return { eligible: false, reason: 'missingIncomingStartStart' }; + if (incoming.some(sequence => sequence.type !== 'START_START')) { + return { eligible: false, reason: 'incomingNotOnlyStartStart' }; + } + if (outgoing.length > 0) return { eligible: false, reason: 'hasOutgoingRelation' }; + return { eligible: true, reason: 'eligible' }; +} + +/** + * Pure, fail-closed diagnose van de completed actual-pin in `CPMSolver.backwardPass`. + * Guardvolgorde volgt bewust exact de bestaande runtimepoort: dataDate → preserve → actualFinish + * → completion. De eerste afwijzing is de enige gerapporteerde reden. + */ +export function explainBackwardActualPinEligibility( + task: Task, + dataDate: Date | null, + schedulingOptions: SchedulingOptions | undefined, +): CpmBackwardActualPinDecision { + if (dataDate === null) return { eligible: false, reason: 'missingDataDate' }; + if (schedulingOptions?.preserveActualDatesInBackwardPass !== true) { + return { eligible: false, reason: 'preserveActualDatesOff' }; + } + if (!task.time.actualFinish) return { eligible: false, reason: 'missingActualFinish' }; + if (task.time.completion < 1) return { eligible: false, reason: 'notCompleted' }; + return { eligible: true, reason: 'eligible' }; +} + +/** + * Pure, fail-closed diagnose van de zichtbare late-datumprojectie in `scheduleAnalysis`. + * Guardvolgorde volgt exact de bestaande runtimepoort: dataDate → completion → preserve. + */ +export function explainDisplayActualLateEligibility( + task: Task, + dataDate: Date | null, + schedulingOptions: SchedulingOptions | undefined, +): CpmDisplayActualLateDecision { + if (dataDate === null) return { eligible: false, reason: 'missingDataDate' }; + if (task.time.completion < 1) return { eligible: false, reason: 'notCompleted' }; + if (schedulingOptions?.preserveActualDatesInBackwardPass !== true) { + return { eligible: false, reason: 'preserveActualDatesOff' }; + } + return { eligible: true, reason: 'eligible' }; +} + +export type P6CompletedLateRemainingWindowReason = + | 'eligible' + | CpmBackwardActualPinReason + | 'notXerSource' + | 'flagOff' + | P6CompletedWindowReason; + +export interface P6CompletedLateRemainingWindowDecision { + eligible: boolean; + reason: P6CompletedLateRemainingWindowReason; +} + +/** + * Review-bevinding 4 (poortdivergentie): `CPMSolver.backwardPass` en `scheduleAnalysis` moeten + * PRECIES dezelfde voorwaarde gebruiken om de P6-restwerkregel voor voltooide activiteiten te + * activeren — anders kan een taak "tussen de poorten in vallen" (bv. `TK_Complete` zonder + * `act_end_date`: wel `completedWindow.eligible`, niet `backwardActualPin.eligible`) en verandert + * de getoonde late datum/float wél terwijl de solvertak zelf niet draait. Eén functie, drie + * bestaande, los getoetste bouwstenen in vaste volgorde: eerst de completed-actual-pin-poort + * (dataDate/preserve/actualFinish/completion — dezelfde als de oude, brede backward-pin), dan de + * XER-brongebonden vlag zelf, dan de nauwe statusdatumvenster-poort (dezelfde als de forward- + * display). De eerste afwijzing is de enige gerapporteerde reden. + */ +export function explainP6CompletedLateRemainingWindowEligibility( + task: Task, + dataDate: Date | null, + schedulingOptions: SchedulingOptions | undefined, +): P6CompletedLateRemainingWindowDecision { + const pin = explainBackwardActualPinEligibility(task, dataDate, schedulingOptions); + if (!pin.eligible) return { eligible: false, reason: pin.reason }; + if (schedulingOptions?.p6Source !== 'XER') return { eligible: false, reason: 'notXerSource' }; + if (schedulingOptions.p6CompletedLateFromRemainingWindow !== true) { + return { eligible: false, reason: 'flagOff' }; + } + const window = explainP6CompletedDataDateWindow(task, dataDate, schedulingOptions); + if (!window.eligible) return { eligible: false, reason: window.reason }; + return { eligible: true, reason: 'eligible' }; +} diff --git a/src/engine/scheduler/p6OpenLoeTargetSpanTrace.ts b/src/engine/scheduler/p6OpenLoeTargetSpanTrace.ts new file mode 100644 index 000000000..c7ce1fe3d --- /dev/null +++ b/src/engine/scheduler/p6OpenLoeTargetSpanTrace.ts @@ -0,0 +1,116 @@ +import type { SchedulingOptions } from '@/types/project'; +import type { Sequence } from '@/types/sequence'; +import type { Task } from '@/types/task'; +import { parseInstant } from '@/utils/dateUtils'; + +export type OpenXerLoeTargetSpanReason = + | 'eligible' + | 'notXerSource' + | 'missingProjectProvenance' + | 'missingTaskProvenance' + | 'wrongActivityType' + | 'notHammock' + | 'wrongCompletePctType' + | 'wrongDurationType' + | 'notNotStarted' + | 'invalidCompletion' + | 'hasActuals' + | 'hasSuspendResume' + | 'milestoneOrZeroDuration' + | 'missingExplicitTargetWindow' + | 'missingScheduleStart' + | 'missingScheduleFinish' + | 'invalidScheduleStart' + | 'invalidScheduleFinish' + | 'targetWindowNotPositive' + | 'targetWindowDurationMismatch' + | 'targetStartConflictsWithRelation' + | 'missingIncomingStartStart' + | 'incomingNotOnlyStartStart' + | 'missingOutgoingFinishFinish' + | 'outgoingNotOnlyFinishFinish' + | 'nonZeroRelationLag'; + +export interface OpenXerLoeTargetSpanDecision { + eligible: boolean; + reason: OpenXerLoeTargetSpanReason; +} + +function hasOnlyZeroLag(sequences: readonly Sequence[]): boolean { + return sequences.every(sequence => sequence.lagDays === 0 + && (sequence.lagMinutes === undefined || sequence.lagMinutes === 0) + && sequence.lagPercent === undefined); +} + +/** + * Pure, fail-closed diagnose voor precies de bewezen Ashspace-vorm: een niet-gestarte XER-LOE + * met een volledig, positief targetvenster, uitsluitend nul-lag SS-ingang en uitsluitend nul-lag + * FF-uitgang. De relationele startdruk wordt als al-berekende productinvoer meegegeven; zo mag + * target_start_date hem nooit overschrijven. P6 early/late/float/driving-uitvoer wordt niet gelezen. + */ +export function explainOpenXerLoeTargetSpanEligibility( + task: Task, + schedulingOptions: SchedulingOptions | undefined, + incoming: readonly Sequence[], + outgoing: readonly Sequence[], + relationalEarlyStart: Date, + targetWindowWorkMinutes: number, + targetWindowToleranceMinutes: number, +): OpenXerLoeTargetSpanDecision { + if (schedulingOptions?.p6Source !== 'XER') return { eligible: false, reason: 'notXerSource' }; + if (task.p6ProjectId === undefined || task.p6ProjectId === '') { + return { eligible: false, reason: 'missingProjectProvenance' }; + } + if (task.p6TaskId === undefined || task.p6TaskId === '') { + return { eligible: false, reason: 'missingTaskProvenance' }; + } + if (task.p6ActivityType !== 'TT_LOE') return { eligible: false, reason: 'wrongActivityType' }; + if (task.isHammock !== true) return { eligible: false, reason: 'notHammock' }; + if (task.p6CompletePctType !== 'CP_Drtn') return { eligible: false, reason: 'wrongCompletePctType' }; + if (task.p6DurationType !== 'DT_FixedDUR2') return { eligible: false, reason: 'wrongDurationType' }; + if (task.status !== 'NOT_STARTED') return { eligible: false, reason: 'notNotStarted' }; + if (!Number.isFinite(task.time.completion) || task.time.completion !== 0) { + return { eligible: false, reason: 'invalidCompletion' }; + } + if (task.time.actualStart !== undefined || task.time.actualFinish !== undefined) { + return { eligible: false, reason: 'hasActuals' }; + } + if (task.time.stop !== undefined || task.time.resume !== undefined || task.p6SuspendResume === true) { + return { eligible: false, reason: 'hasSuspendResume' }; + } + const durationMinutes = task.time.durationMinutes; + if (task.isMilestone || !Number.isFinite(task.time.scheduleDuration) || task.time.scheduleDuration <= 0 + || durationMinutes === undefined || !Number.isFinite(durationMinutes) || durationMinutes <= 0) { + return { eligible: false, reason: 'milestoneOrZeroDuration' }; + } + if (task.p6ExplicitTargetWindow !== true) return { eligible: false, reason: 'missingExplicitTargetWindow' }; + if (!task.time.scheduleStart) return { eligible: false, reason: 'missingScheduleStart' }; + if (!task.time.scheduleFinish) return { eligible: false, reason: 'missingScheduleFinish' }; + const targetStart = parseInstant(task.time.scheduleStart); + const targetFinish = parseInstant(task.time.scheduleFinish); + if (!Number.isFinite(targetStart.getTime())) return { eligible: false, reason: 'invalidScheduleStart' }; + if (!Number.isFinite(targetFinish.getTime())) return { eligible: false, reason: 'invalidScheduleFinish' }; + if (targetFinish <= targetStart) return { eligible: false, reason: 'targetWindowNotPositive' }; + if (!Number.isFinite(relationalEarlyStart.getTime()) || targetStart.getTime() !== relationalEarlyStart.getTime()) { + return { eligible: false, reason: 'targetStartConflictsWithRelation' }; + } + // P6 bewaart een targetvenster met inclusieve eindgrens: op de normale 5×8-kalender kan de + // bronduur daarom precies één werkdag groter zijn dan `workMinutesBetween(start, finish)`. + // Buiten die kleine, meetbare representatieband sluit de route; een willekeurige taakduur mag + // dus geen spanroute openen. + if (!Number.isFinite(targetWindowWorkMinutes) || targetWindowWorkMinutes <= 0 + || !Number.isFinite(targetWindowToleranceMinutes) || targetWindowToleranceMinutes < 0 + || Math.abs(durationMinutes - Math.round(targetWindowWorkMinutes)) > targetWindowToleranceMinutes) { + return { eligible: false, reason: 'targetWindowDurationMismatch' }; + } + if (incoming.length === 0) return { eligible: false, reason: 'missingIncomingStartStart' }; + if (incoming.some(sequence => sequence.type !== 'START_START')) { + return { eligible: false, reason: 'incomingNotOnlyStartStart' }; + } + if (outgoing.length === 0) return { eligible: false, reason: 'missingOutgoingFinishFinish' }; + if (outgoing.some(sequence => sequence.type !== 'FINISH_FINISH')) { + return { eligible: false, reason: 'outgoingNotOnlyFinishFinish' }; + } + if (!hasOnlyZeroLag([...incoming, ...outgoing])) return { eligible: false, reason: 'nonZeroRelationLag' }; + return { eligible: true, reason: 'eligible' }; +} diff --git a/src/engine/scheduler/projectStartAnchorClamp.ts b/src/engine/scheduler/projectStartAnchorClamp.ts index 9c0bfd6bc..fd4a72b3f 100644 --- a/src/engine/scheduler/projectStartAnchorClamp.ts +++ b/src/engine/scheduler/projectStartAnchorClamp.ts @@ -20,6 +20,7 @@ import type { Task, TaskConstraint } from '@/types/task'; import type { Sequence } from '@/types/sequence'; import type { WorkCalendar } from '@/types/calendar'; +import { isSummaryTask } from '@/utils/taskHierarchy'; import { CalendarEngine } from './CalendarEngine'; import { resolveCalendar } from './resolveCalendar'; import { snapWorkInstantOnOrAfter } from './CPMSolver'; @@ -70,7 +71,7 @@ export function clampProjectStartAnchors(input: ClampProjectStartAnchorsInput): let clamped = 0; for (const t of input.tasks) { - if (t.childIds.length > 0) continue; // alleen bladtaken (CPMSolver-precedent) + if (isSummaryTask(t)) continue; // alleen bladtaken (CPMSolver-precedent) if (t.isHammock) continue; // L2: de solver negeert een hammock-anker toch if (hasPredecessor.has(t.id)) continue; // heeft een voorganger — geen wortel-anker if (hasForwardConstraint(t.constraint)) continue; // L1: expliciete forward-constraint wint diff --git a/src/engine/scheduler/recordedDates.ts b/src/engine/scheduler/recordedDates.ts index d6939c850..6e14c0975 100644 --- a/src/engine/scheduler/recordedDates.ts +++ b/src/engine/scheduler/recordedDates.ts @@ -1,4 +1,6 @@ import type { Task, TaskTimeComputed, TaskTimeInput } from '@/types/task'; +import { rollupSummaryTasks } from './applyCpmResult'; +import { isLeafTask } from '@/utils/taskHierarchy'; import type { WorkCalendar } from '@/types/calendar'; import type { CPMResult, CPMTaskResult } from './CPMSolver'; import { CalendarEngine } from './CalendarEngine'; @@ -65,43 +67,84 @@ export interface RecordedDates { * taak 4) bouwt zelf `{ ...captureRecordedDates(...), shifted: countShiftedTasks(...) }` — * `documentContract.ts` draagt alleen het type in `DOCUMENT_FIELDS`, niet de samenstelling ervan. */ export interface RecordedDatesState extends RecordedDates { - /** Aantal taken waarvan de herberekening de datums verschoof — de teller in de melding. */ - shifted: number; + /** Aantal taken waarvan de herberekening de datums verschoof — de teller in de melding. + * OPTIONEEL (her-check laag 3, bevinding 4): een SLAPEND hersteld document staat in de modus + * zonder ooit gesolved te zijn (`applyRestoredRecordedMode`, `documentActivation.ts`), dus daar + * bestáát de teller niet. Afwezig ⇒ de strook valt terug op zijn tellerloze tekst; alles wat aan + * "in de modus" hangt (export-poort, "niet vastgelegd"-kolommen, badge) blijft wél gewoon werken, + * want dat hangt aan `times`, niet aan deze teller. Een verzonnen `shifted: 0` blijft verboden. */ + shifted?: number; + /** Bronformaat van de vastlegging (spiegelt `ImportResult.recordedTimesOrigin`, XER-etappe laag + * 3, taak T4/T5) — bewust GEEN import van dat type hier: de engine-laag kent geen formaten, + * alleen deze twee letterlijke waarden. Stuurt uitsluitend WOORDKEUZE in de strook + * (`RecordedDatesNotice.tsx` noemt "Primavera" alleen wanneer dit gezet is); de modus- + * beslissing zelf ligt al vast in `datesAsRecorded` tegen de tijd dat dit veld gelezen wordt. + * `undefined` ⇒ de bestaande, formaatneutrale #63-route (IFC/CSV/MSPDI/MPP/P6XML zonder + * bron-orakel). */ + origin?: 'xer' | 'xer-archive'; } /** * Leg vast wat het bestand zei. ROEP DIT AAN VÓÓR `runCPM`: de store deelt de taak-objecten met het * parse-resultaat, dus na de solve zijn de oorspronkelijke waarden overschreven. * - * DE LAAGKEUZE (kwaliteitsreview MOET 1 + MOET 4) wordt ÉÉN KEER PER TAAK gemaakt, niet per veld: - * - Beide early-slots (`earlyStart` ÉN `earlyFinish`) aanwezig ⇒ de early-laag ("berekend, maar het - * bestand droeg het resultaat"). - * - Anders, en alleen als beide schedule-slots (`scheduleStart` ÉN `scheduleFinish`) aanwezig zijn - * ⇒ de schedule-laag ("zoals opgeslagen" in de zin van issue #63 — een P6-export die alleen - * ScheduleStart/ScheduleFinish vult). - * - Geen van beide paren compleet ⇒ GEEN uitspraak over deze taak; hij wordt overgeslagen (landt - * niet in `times`, telt niet mee in `total`). Dit is het pad dat een IFCTASK zonder IfcTaskTime - * (of met `$` op ScheduleStart) raakt: zonder deze guard zou `t.scheduleStart` de "vandaag"- - * fallback van `createDefaultTaskTime`/`parseDateFromIFC` zijn — een verzonnen datum die er als - * een echte opgeslagen waarde uit zou zien. - * Een HALF paar (bv. alleen `earlyStart` aanwezig) wordt NOOIT aangevuld met de andere laag: zo'n - * samengesteld paar heeft het bestand nooit gezegd, en kan zelfs finish-vóór-start opleveren. + * DE LAAGKEUZE — DRIE LAGEN, IN DEZE VOLGORDE (XER-etappeplan laag 3, §3.3, bovenop kwaliteitsreview + * MOET 1 + MOET 4): + * 0. **Bron-orakel** (`recordedTimes`, derde parameter): heeft de AANROEPER al een kant-en-klare + * `Record` (XER's bak 4 — `ImportResult.recordedTimes`, uitsluitend + * gevuld door `readXER`), dan is DÁT het antwoord — ongefilterd op de laagkeuze hieronder, wél + * gefilterd op taken die daadwerkelijk in `tasks` zitten (zelfde regel als de andere twee lagen: + * een vastgelegde id die niet meer bestaat telt niet mee). `recordedFields` wordt in dat geval + * GENEGEERD. De twee kanalen worden NOOIT gemengd: een XER-import heeft geen `recordedFields`, + * een IFC-import (nog) geen `recordedTimes`. + * 1. **Early-laag**: geen `recordedTimes` gegeven, en beide early-slots (`earlyStart` ÉN + * `earlyFinish`) van `recordedFields` aanwezig ⇒ "berekend, maar het bestand droeg het + * resultaat". + * 2. **Schedule-laag**: geen van beide bovenstaande, en beide schedule-slots (`scheduleStart` ÉN + * `scheduleFinish`) aanwezig ⇒ "zoals opgeslagen" in de zin van issue #63 — een P6-export die + * alleen ScheduleStart/ScheduleFinish vult. + * Geen van de drie van toepassing ⇒ GEEN uitspraak over deze taak; hij wordt overgeslagen (landt + * niet in `times`, telt niet mee in `total`). Dit is (voor lagen 1/2) het pad dat een IFCTASK zonder + * IfcTaskTime (of met `$` op ScheduleStart) raakt: zonder deze guard zou `t.scheduleStart` de + * "vandaag"-fallback van `createDefaultTaskTime`/`parseDateFromIFC` zijn — een verzonnen datum die + * er als een echte opgeslagen waarde uit zou zien. + * Een HALF paar (bv. alleen `earlyStart` aanwezig, laag 1/2) wordt NOOIT aangevuld met de andere + * laag: zo'n samengesteld paar heeft het bestand nooit gezegd, en kan zelfs finish-vóór-start + * opleveren. (Laag 0 kent dit onderscheid niet: `RecordedTime.start`/`finish` zijn daar al verplicht + * — de lezer die het orakel vulde, koos zelf al of een taak een early-paar had, zie + * `readXerRecordedTimes`.) * - * `late*`/`totalFloat`/`freeFloat`/`isCritical` staan LOS van de laagkeuze: die vijf worden — zoals - * altijd — individueel meegenomen zodra hun eigen slot aanwezig is, ongeacht welke laag voor - * start/finish werd gekozen. + * `late*`/`totalFloat`/`freeFloat`/`isCritical` staan LOS van de laagkeuze binnen lagen 1/2: die + * vijf worden — zoals altijd — individueel meegenomen zodra hun eigen slot aanwezig is, ongeacht + * welke laag voor start/finish werd gekozen. Binnen laag 0 draagt `RecordedTime` deze vijf al kant- + * en-klaar (aanwezig of `undefined`, door de aanroepende lezer bepaald). * * `recordedFields` komt uit `ImportResult.recordedFields` (`src/services/ifc/ifcTaskSlots.ts`, * `RECORDED_SLOT_KEYS` + `RECORDED_INPUT_SLOT_KEYS`) — de engine mag niet uit de services-laag * importeren, dus dit neemt bewust het structurele unietype via `@/types/task` (MOET 2: * `keyof TaskTimeComputed | keyof TaskTimeInput` i.p.v. een ongecontroleerd `string`), zodat een * hernoemd CPM-veld hier een compile-fout geeft in plaats van een `has.has(...)` die stil nooit meer - * waar wordt. + * waar wordt. `recordedTimes` komt overeenkomstig uit `ImportResult.recordedTimes` — ook hier geen + * services-import, `RecordedTime` is al een engine-eigen type (hierboven in dit bestand). */ export function captureRecordedDates( tasks: Task[], recordedFields: Record | undefined, + recordedTimes?: Record, ): RecordedDates { + if (recordedTimes) { + // Laag 0 — bron-orakel, MET VOORRANG boven `recordedFields`. Filteren op bestaande taken houdt + // dezelfde regel aan als de andere twee lagen ("een vastgelegde id die niet meer bestaat telt + // niet mee"); `recordedFields` wordt hier bewust NIET geraadpleegd (geen menging van kanalen). + const knownIds = new Set(tasks.map((t) => t.id)); + const times: Record = {}; + for (const [id, rec] of Object.entries(recordedTimes)) { + if (!knownIds.has(id)) continue; + times[id] = rec; + } + return { times, total: Object.keys(times).length }; + } + const times: Record = {}; if (!recordedFields) return { times, total: 0 }; @@ -244,6 +287,35 @@ export function cpmResultFromRecorded( if (rec.finish > projectEnd) projectEnd = rec.finish; } + // Her-check laag 3, R1: samenvattingen ZONDER eigen vastlegging waaronder wél vastgelegde taken + // hangen, zijn zojuist door `applyRecordedTimesToTasks` opgerold uit die kinderen; hun `task.time` + // is dus de afgeleide van de vastlegging. Neem ze mee in het resultaat, anders spreken de twee + // oppervlakken (Gantt/taakraster op `task.time`, rapporten/`projectEnd` op `cpmResult`) elkaar + // tegen voor precies die rijen. Alleen voorouders VAN vastgelegde taken — een tak zonder enige + // vastlegging blijft, net als de bladtaken erin, onaangeroerd en buiten dit resultaat. + const byId = new Map(tasks.map((t) => [t.id, t])); + const ancestorsOfRecorded = new Set(); + for (const task of recorded) { + let parentId = task.parentId; + while (parentId && !ancestorsOfRecorded.has(parentId)) { + ancestorsOfRecorded.add(parentId); + parentId = byId.get(parentId)?.parentId ?? null; + } + } + for (const id of ancestorsOfRecorded) { + const summary = byId.get(id); + if (!summary || times[id] || out.has(id)) continue; + out.set(id, { + earlyStart: summary.time.earlyStart, + earlyFinish: summary.time.earlyFinish, + lateStart: summary.time.lateStart, + lateFinish: summary.time.lateFinish, + totalFloat: summary.time.totalFloat, + freeFloat: summary.time.freeFloat, + isCritical: summary.time.isCritical, + }); + } + // Werkdagen tellen is geen solve — de kalender kan de span gewoon uitrekenen. `parseInstant`, NIET // `new Date(...)`: in uur-modus zijn earlyStart/earlyFinish van de vorm "YYYY-MM-DDTHH:mm" ZONDER // tijdzone. `new Date(...)` leest zo'n string per ES2015 als LOKALE tijd, terwijl de hele engine @@ -276,3 +348,64 @@ export function cpmResultFromRecorded( projectDuration, }; } + +/** + * Schrijf de vastlegging in de taken en lever het gereconstrueerde `CPMResult` — ÉÉN implementatie + * voor `showRecordedDates` (`scheduleSlice.ts`) en de standaard-aan-route bij het laden + * (`fileSlice.applyLoadedProject`, taak T4). Muteert `tasks` IN-PLACE (een Immer-draft óf een + * payload-kloon — de aanroeper bepaalt welke) en geeft daarna hetzelfde `CPMResult` terug als + * `cpmResultFromRecorded(times, tasks, calendar)` op diezelfde, nu-bijgewerkte taken zou geven. + * + * DE TERUGVALLEN BINNEN DEZE KERN ZIJN BEWUST ONGEWIJZIGD (plan §3.4): `TaskTime.lateStart` is een + * verplichte `string`, `totalFloat`/`freeFloat` verplichte `number`s (`TaskTimeComputed`, + * `src/types/task.ts`) — optioneel maken heeft een blast radius over renderer, taakraster, rapport, + * MCP en export, dus deze functie raakt dat type NIET aan. In plaats daarvan blijven de bestaande + * `?? rec.start`/`?? rec.finish`/`?? 0`/`?? false`-terugvallen hier als VELDWAARDE staan; "niet + * vastgelegd" leeft uitsluitend in `times[id].lateStart === undefined` enz. (al bestaande, + * gepersisteerde documentstate) en wordt pas in de UI als weergave afgedwongen (taak T6) — dit is + * een bewust compromis, zie plan §5/§6. + * + * Wist daarnaast per geraakte taak `interferingFloat`/`isNearCritical`/`floatPath`: die drie + * analyse-afgeleiden komen uit de zojuist weggegooide solve en zouden een planning beschrijven die + * niet meer op het scherm staat (`applyCpmResult` hanteert dezelfde regel voor uitgezette opties: + * afwezig ⇒ het veld wordt gewist). Een taak zónder vastlegging (`times[task.id]` ontbreekt) blijft + * volledig onaangeroerd. + */ +export function applyRecordedTimesToTasks( + tasks: Task[], + times: Record, + calendar: WorkCalendar, +): CPMResult { + for (const task of tasks) { + const rec = times[task.id]; + if (!rec) continue; + task.time.earlyStart = rec.start; + task.time.earlyFinish = rec.finish; + task.time.lateStart = rec.lateStart ?? rec.start; + task.time.lateFinish = rec.lateFinish ?? rec.finish; + task.time.totalFloat = rec.totalFloat ?? 0; + task.time.freeFloat = rec.freeFloat ?? 0; + task.time.isCritical = rec.isCritical ?? false; + task.time.interferingFloat = undefined; + task.time.isNearCritical = undefined; + task.time.floatPath = undefined; + } + // Her-check laag 3, bevinding 3: samenvattingen (XER-WBS-rijen, IFC-fasen) hebben nooit een eigen + // vastlegging — P6 schrijft de zes kolommen alleen in TASK — en hielden dus de datums van de solve + // die deze modus net verwierp. Dezelfde rollup als ná een echte solve (`applyCpmResult`), zodat + // een fasebalk in de Gantt de vastgelegde kinderen omspant en niet een weggegooide berekening. + // De rollup leest de terugvallen hierboven (`?? rec.start`, `?? 0`) als kindwaarden; een + // samenvatting heeft geen "niet vastgelegd"-markering per as (geen eigen `times[id]`), dus haar + // late zijde/speling zijn afgeleid van wat de kinderen op het scherm tonen. De drie analyse- + // afgeleiden worden ook op de samenvattingen gewist, om dezelfde reden als op de bladtaken. + // Her-check R1: een samenvatting MET eigen vastlegging (de #63-IFC-route) blijft staan zoals het + // bestand haar gaf — de rollup slaat haar over; alleen samenvattingen zónder vastlegging rollen op. + rollupSummaryTasks(tasks, { skip: task => times[task.id] !== undefined }); + for (const task of tasks) { + if (isLeafTask(task) || times[task.id]) continue; + task.time.interferingFloat = undefined; + task.time.isNearCritical = undefined; + task.time.floatPath = undefined; + } + return cpmResultFromRecorded(times, tasks, calendar); +} diff --git a/src/engine/scheduler/relationMath.ts b/src/engine/scheduler/relationMath.ts index 34bd220ac..476b4cd09 100644 --- a/src/engine/scheduler/relationMath.ts +++ b/src/engine/scheduler/relationMath.ts @@ -1,7 +1,6 @@ import type { Task } from '@/types/task'; import type { Sequence, LagUnit } from '@/types/sequence'; import type { CalendarEngine } from './CalendarEngine'; -import { LAG_CALENDAR } from './lagCalendar'; import { addCalendarDays } from '@/utils/dateUtils'; import { isZeroDurationMilestone } from './duration'; @@ -52,7 +51,11 @@ export interface RelationBoundaryFlags { } /** Bereken de mijlpaal-grensvlaggen voor het (voorganger, opvolger)-paar (één bron van waarheid). */ -export function relationBoundaryFlags(predTask: Task, succTask: Task): RelationBoundaryFlags { +export function relationBoundaryFlags( + predTask: Task, + succTask: Task, + p6ZeroDurationSuccessorAtFinish = false, +): RelationBoundaryFlags { // H3 (Opus-review T15-iteratie-2): `predTask.isMilestone ||` verwijderd — voor ELKE bestaande // ECHTE (0-duur) mijlpaal was `scheduleDuration <= 0` al waar, dus de kale vlag toevoegen was // altijd redundant vóór T15 (mijlpaal-met-duur bestond toen nog niet). Ná T15 maakte diezelfde @@ -84,11 +87,14 @@ export function relationBoundaryFlags(predTask: Task, succTask: Task): RelationB // corpus-/synthetische case raakt dit), maar niet langer alleen een hypothetisch MCP-pad. const predIsMilestone = predTask.time.scheduleDuration <= 0; const predKind = predTask.isMilestone ? predTask.milestoneKind : undefined; + const p6FinishBoundary = p6ZeroDurationSuccessorAtFinish && isZeroDurationMilestone(succTask); return { predEndsBeginOfDay: predIsMilestone && predKind !== 'FINISH', predStartsNextDay: predIsMilestone && predKind === 'FINISH', - succIsFinishMs: succTask.isMilestone && succTask.milestoneKind === 'FINISH', - succIsStartMs: succTask.isMilestone && succTask.milestoneKind === 'START', + succIsFinishMs: p6FinishBoundary + || (succTask.isMilestone && succTask.milestoneKind === 'FINISH'), + succIsStartMs: !p6FinishBoundary + && succTask.isMilestone && succTask.milestoneKind === 'START', }; } @@ -173,6 +179,8 @@ export function calendarsAgreeOnSharedWorkdayBands( * gedefinieerd (ze delen daar de dag↔uur-reductie met de rest van de solver) en worden hier * geïnjecteerd, zodat de relatie-wiskunde puur en op één plek staat zonder de helpers te dupliceren. */ export interface RelationDeps { + /** Selecteert de projectgebonden kalender waarin WORKTIME-relatielag telt. */ + lagEngine(predEng: CalendarEngine, succEng: CalendarEngine): CalendarEngine; /** `predEng` is nodig voor de dag↔minuut-factor (`hoursPerDay`) waarmee een `lagMinutes`-lag * zónder `lagDays` in een DAG-voorganger wordt opgelost (§5.2; zonder die factor viel zo'n lag * stil weg — P6-XML/MSPDI schrijven precies die combinatie voor een uur-opvolger). */ @@ -203,8 +211,9 @@ export function forwardConstraint( successor: Task, predEng: CalendarEngine, succEng: CalendarEngine, + p6ZeroDurationSuccessorAtFinish = false, ): Date { - const flags = relationBoundaryFlags(predTask, successor); + const flags = relationBoundaryFlags(predTask, successor, p6ZeroDurationSuccessorAtFinish); if (predEng.isHourMode || succEng.isHourMode) { return forwardHour(deps, predResult, predTask, seq, successor, predEng, succEng, flags); } @@ -279,9 +288,10 @@ export function forwardFinishFloor( successor: Task, predEng: CalendarEngine, succEng: CalendarEngine, + p6ZeroDurationSuccessorAtFinish = false, ): Date | null { if (seq.type !== 'START_FINISH') return null; - const flags = relationBoundaryFlags(predTask, successor); + const flags = relationBoundaryFlags(predTask, successor, p6ZeroDurationSuccessorAtFinish); if (predEng.isHourMode || succEng.isHourMode) { return sfReqFinishHour(deps, predResult, predTask, seq, successor, predEng, succEng, flags); } @@ -300,8 +310,9 @@ export function backwardConstraint( succTask: Task, predEng: CalendarEngine, succEng: CalendarEngine, + p6ZeroDurationSuccessorAtFinish = false, ): Date { - const flags = relationBoundaryFlags(predTask, succTask); + const flags = relationBoundaryFlags(predTask, succTask, p6ZeroDurationSuccessorAtFinish); if (predEng.isHourMode || succEng.isHourMode) { return backwardHour(deps, succResult, seq, predTask, predEng, succEng, flags); } @@ -325,13 +336,14 @@ function forwardDay( // Lag in dagen; positief = uitloop, negatief = lead (overlap), 0 = direct aansluitend. // Werkdag-lag (WORKTIME, default) stapt over werkdagen; kalenderdag-lag (ELAPSEDTIME) // telt 24/7 en snapt daarna vooruit naar een werkdag (ondergrens: "niet eerder dan…"). - const { days: lag, unit } = deps.resolveLag(seq, predTask, predEng); - const elapsed = unit === 'ELAPSEDTIME'; - // De relatie-lag telt in de kalender van de VOORGANGER (P6-default, §5.2). De succBack-aftrek en - // successor-mijlpaal-snaps tellen in de successor-kalender; de FS-finishgrens-snap in de voorganger. + // De relatie-lag telt in de geselecteerde bronkalender (P6-default: voorganger). De succBack- + // aftrek en successor-mijlpaal-snaps tellen in de successor-kalender; de FS-finishgrens-snap in + // de voorganger. const pe = predEng; const se = succEng; - const lagEng = LAG_CALENDAR === 'predecessor' ? predEng : succEng; + const lagEng = deps.lagEngine(predEng, succEng); + const { days: lag, unit } = deps.resolveLag(seq, predTask, lagEng); + const elapsed = unit === 'ELAPSEDTIME'; // T8-review H1: FF/SF hieronder leidden de "werkdagen tussen start en finish van de opvolger" // vroeger inline af (`succDur>0?succDur-1:0`) en stapten daarmee UNCONDITIONEEL in WERKdagen // terug (`se.addWorkingDaysSigned`). Nu via `deps.startFromFinish` — dezelfde helper als T8's @@ -397,7 +409,7 @@ function forwardDay( // (anders schuift een tussengevoegde mijlpaal de hele keten een dag op). Een // eindmijlpaal-opvolger ankert juist op de finish-grens zelf (zelfde daglabel). // De finish-grens-snap (nextWorkDayAfter) telt in de VOORGANGER-kalender; de lag daarna - // in de lag-kalender (voorganger, §5.2). Elapsed telt vanaf de finish-grens 24/7. + // in de geselecteerde lagkalender. Elapsed telt vanaf de finish-grens 24/7. if (elapsed) { const plus = succIsFinishMs || predEndsBeginOfDay ? lag : lag + 1; return addCalendarDays(predResult.ef, plus); @@ -452,9 +464,9 @@ function sfReqFinishDay( se: CalendarEngine, flags: RelationBoundaryFlags, ): Date { - const { days: lag, unit } = deps.resolveLag(seq, predTask, pe); + const lagEng = deps.lagEngine(pe, se); + const { days: lag, unit } = deps.resolveLag(seq, predTask, lagEng); const elapsed = unit === 'ELAPSEDTIME'; - const lagEng = LAG_CALENDAR === 'predecessor' ? pe : se; const succElapsed = !isZeroDurationMilestone(successor) && successor.time.durationType === 'ELAPSEDTIME'; const { predStartsNextDay } = flags; return elapsed @@ -479,13 +491,13 @@ function backwardDay( // Spiegel van forwardDay: geef de laatst toegestane FINISH van de voorganger. // Kalenderdag-lag snapt hier áchteruit (bovengrens: "niet later dan…") — exact symmetrisch // met de vooruit-snap in de forward-pass, zodat een lead geen fantoomfloat oplevert. - const { days: lag, unit } = deps.resolveLag(seq, predTask, predEng); - const elapsed = unit === 'ELAPSEDTIME'; - // Spiegel van forwardDay (§5.2): de lag telt terug in de VOORGANGER-kalender; de + // Spiegel van forwardDay: de lag telt terug in de geselecteerde lagkalender; de // FS-gap-spiegel (prevWorkDayBefore) eveneens; de successor-zijde-datums in de successor-kalender. const pe = predEng; const se = succEng; - const lagEng = LAG_CALENDAR === 'predecessor' ? predEng : succEng; + const lagEng = deps.lagEngine(predEng, succEng); + const { days: lag, unit } = deps.resolveLag(seq, predTask, lagEng); + const elapsed = unit === 'ELAPSEDTIME'; // T8-review H1/H2-spiegelpaar: SS/SF hieronder leidden "predLS → predLF" vroeger inline af // (`predDur>0?predDur-1:0`, WERKdagen via `pe.addWorkingDaysSigned`). Nu via `deps.finishFromStart` // — dezelfde helper als `startFromFinish` hierboven (H1) en al elders in deze solver (T8) — die @@ -513,7 +525,7 @@ function backwardDay( // Forward: succ.start = pred.start(-moment) + lag ⇒ pred.start ≤ succ.lateStart − lag; // het startmoment van een eindmijlpaal-voorganger ligt een werkdag vóór die grens. const predLS = elapsed - ? lagEng.prevWorkDay(addCalendarDays(succResult.ls, -(predStartsNextDay ? lag + 1 : lag))) + ? pe.prevWorkDay(addCalendarDays(succResult.ls, -(predStartsNextDay ? lag + 1 : lag))) : predStartsNextDay ? pe.prevWorkDayBefore(lagEng.addWorkingDaysSigned(succResult.ls, -lag)) : lagEng.addWorkingDaysSigned(succResult.ls, -lag); @@ -526,13 +538,13 @@ function backwardDay( ? se.prevWorkDayBefore(succResult.lf) : succResult.lf; return elapsed - ? lagEng.prevWorkDay(addCalendarDays(succLf, -lag)) + ? pe.prevWorkDay(addCalendarDays(succLf, -lag)) : lagEng.addWorkingDaysSigned(succLf, -lag); } case 'START_FINISH': { // Forward: succ.finish = pred.start(-moment) + lag ⇒ pred.start ≤ succ.lateFinish − lag. const predLS = elapsed - ? lagEng.prevWorkDay(addCalendarDays(succResult.lf, -(predStartsNextDay ? lag + 1 : lag))) + ? pe.prevWorkDay(addCalendarDays(succResult.lf, -(predStartsNextDay ? lag + 1 : lag))) : predStartsNextDay ? pe.prevWorkDayBefore(lagEng.addWorkingDaysSigned(succResult.lf, -lag)) : lagEng.addWorkingDaysSigned(succResult.lf, -lag); @@ -545,7 +557,7 @@ function backwardDay( // Terug-inverteren; de FS-gap-spiegel (prevWorkDayBefore) telt in de VOORGANGER-kalender. if (elapsed) { const plus = succIsFinishMs || predEndsBeginOfDay ? lag : lag + 1; - return lagEng.prevWorkDay(addCalendarDays(succResult.ls, -plus)); + return pe.prevWorkDay(addCalendarDays(succResult.ls, -plus)); } const target = lagEng.addWorkingDaysSigned(succResult.ls, -lag); if (succIsFinishMs || predEndsBeginOfDay) return target; @@ -567,7 +579,7 @@ function backwardDay( // UUR-/CROSS-modus (engaged zodra minstens één zijde uur-modus is; §4.3/§5.2). // Spiegelt de dag-formules met instant-primitieven: `prevWorkInstantBefore` ↔ // `nextWorkInstantAfter`, `subtractWorkMinutes` ↔ `addWorkMinutes`, lag terug in de -// VOORGANGER-engine (`LAG_CALENDAR='predecessor'`). +// lag-engine uit `deps.lagEngine` (`schedulingOptions.lagCalendar`, default de voorganger). // ═══════════════════════════════════════════════════════════════════════════ function forwardHour( @@ -581,6 +593,7 @@ function forwardHour( flags: RelationBoundaryFlags, ): Date { const elapsed = seq.lagUnit === 'ELAPSEDTIME'; + const lagEng = deps.lagEngine(pe, se); const { predEndsBeginOfDay, predStartsNextDay, succIsFinishMs, succIsStartMs } = flags; const elapsedMin = () => deps.resolveElapsedMinutes(seq, predTask) * MS_PER_MIN; // T8-review H1: zie forwardDay se `succElapsed` — dezelfde definitie, hier voor de FF/SF-uurtak. @@ -624,8 +637,8 @@ function forwardHour( // T8-review-BLOCKER (vervolg op H1, uur-analoog van forwardDay's SS hierboven): de // `deps.snapOnOrAfter(se, …)`-omhulling forceert een werk-instant van de OPVOLGER-kalender — // voor een ELAPSEDTIME-opvolger juist niet gewenst (de `shiftLagPred`/`elapsedMin`-uitkomst - // zelf blijft ongemoeid: die interpreteert de LAG in de VOORGANGER-kalender, een orthogonale - // vraag). `succElapsed`: geef de ongesnapte waarde terug. + // zelf blijft ongemoeid: die interpreteert de LAG in de geselecteerde lagkalender, een + // orthogonale vraag). `succElapsed`: geef de ongesnapte waarde terug. if (elapsed) { const raw = new Date(base.getTime() + elapsedMin()); return succElapsed ? raw : deps.snapOnOrAfter(se, raw); @@ -643,7 +656,7 @@ function forwardHour( // FF/SF se al zorgvuldig afgestemde gedrag potentieel mee. Smal genoeg (vereist een ELAPSEDTIME // voorganger MET een volledig buiten-band liggende eigen positie, bv. een heel weekend zonder // enige band) om als apart, gedocumenteerd gat te laten staan i.p.v. in deze fixronde te lossen. - const lagged = deps.shiftLagPred(pe, base, seq, predTask, 1); + const lagged = deps.shiftLagPred(lagEng, base, seq, predTask, 1); return succElapsed ? lagged : deps.snapOnOrAfter(se, lagged); } case 'FINISH_FINISH': { @@ -659,9 +672,9 @@ function forwardHour( ? new Date(predResult.ef.getTime() + elapsedMin()) : deps.snapOnOrAfter(se, new Date(predResult.ef.getTime() + elapsedMin())); } else { - const lagged = deps.shiftLagPred(pe, predResult.ef, seq, predTask, 1); + const lagged = deps.shiftLagPred(lagEng, predResult.ef, seq, predTask, 1); if ((succIsFinishMs || succElapsed) && pe.isHourMode - && lagged.getTime() === pe.nextWorkInstant(predResult.ef).getTime()) { + && lagged.getTime() === deps.snapOnOrAfter(lagEng, predResult.ef).getTime()) { reqFinish = landRawInstant(predResult.ef, succElapsed); } else { reqFinish = lagged; @@ -701,15 +714,21 @@ function forwardHour( } return se.availableStart(target); } + // P6/XER bewaart bij een expliciet bronpatroon de gedeelde finish/startgrens als de + // opvolger-ES: geplande opvolgerstart == geplande voorgangerfinish, nul-lag FS, exact op een + // kalenderbandeinde. De reader zet de vlag uitsluitend uit toegestane invoervelden; zonder + // vlag blijft de algemene halfopen-bandsemantiek hieronder byte-identiek. + if (seq.p6StartAtPredecessorFinishBoundary) return predResult.ef; const predDone = (succIsFinishMs || predEndsBeginOfDay) ? predResult.ef // mijlpaal-grens: geen dag-boundary-+1 (dag-conceptueel) : pe.predDoneAt(predResult.ef); - const lagged = deps.shiftLagPred(pe, predDone, seq, predTask, 1); + const lagged = deps.shiftLagPred(lagEng, predDone, seq, predTask, 1); // `lagIsZero` (T6, hieronder al gebruikt vóór Z11): "de geshifte waarde == wat een kale // nul-lag-normalisatie in de VOORGANGER zou geven" ⇒ er is geen échte lag toegepast. Vereist // `pe.isHourMode`: `nextWorkInstant` is een uur-modus-primitief (non-null assertion op // `workTime`) — bij een DAG-voorganger (cross-modus) crasht de aanroep, vandaar de wacht. - const lagIsZero = pe.isHourMode && lagged.getTime() === pe.nextWorkInstant(predDone).getTime(); + const lagIsZero = pe.isHourMode + && lagged.getTime() === deps.snapOnOrAfter(lagEng, predDone).getTime(); // Z11 (kruis-kalender-FS-asymmetrie, uur-analoog van de dag-fix in `forwardDay` hierboven in // dit bestand): bij lag=0 met IDENTIEKE effectieve landingsdag-band (de discriminator // hierboven, `calendarsAgreeOnSharedWorkdayBands` — datum-effectief sinds de fixronde, punt 1) @@ -793,6 +812,7 @@ function sfReqFinishHour( flags: RelationBoundaryFlags, ): Date { const elapsed = seq.lagUnit === 'ELAPSEDTIME'; + const lagEng = deps.lagEngine(pe, se); const { predStartsNextDay, succIsFinishMs } = flags; const elapsedMin = () => deps.resolveElapsedMinutes(seq, predTask) * MS_PER_MIN; const succElapsed = !isZeroDurationMilestone(successor) && successor.time.durationType === 'ELAPSEDTIME'; @@ -805,9 +825,9 @@ function sfReqFinishHour( ? new Date(startMoment.getTime() + elapsedMin()) : deps.snapOnOrAfter(se, new Date(startMoment.getTime() + elapsedMin())); } - const lagged = deps.shiftLagPred(pe, startMoment, seq, predTask, 1); + const lagged = deps.shiftLagPred(lagEng, startMoment, seq, predTask, 1); if ((succIsFinishMs || succElapsed) && pe.isHourMode - && lagged.getTime() === pe.nextWorkInstant(startMoment).getTime()) { + && lagged.getTime() === deps.snapOnOrAfter(lagEng, startMoment).getTime()) { return landRawInstant(startMoment, succElapsed); } return lagged; @@ -823,6 +843,7 @@ function backwardHour( flags: RelationBoundaryFlags, ): Date { const elapsed = seq.lagUnit === 'ELAPSEDTIME'; + const lagEng = deps.lagEngine(pe, se); const { predEndsBeginOfDay, predStartsNextDay, succIsFinishMs, succIsStartMs } = flags; const elapsedMin = () => deps.resolveElapsedMinutes(seq, predTask) * MS_PER_MIN; // T8-review H2: zie backwardDay se `predElapsed` — dezelfde definitie, hier voor de uur-FS-tak @@ -837,7 +858,7 @@ function backwardHour( case 'START_START': { const shifted = elapsed ? new Date(succResult.ls.getTime() - elapsedMin()) - : deps.shiftLagPred(pe, succResult.ls, seq, predTask, -1); + : deps.shiftLagPred(lagEng, succResult.ls, seq, predTask, -1); const predStart = predStartsNextDay ? deps.snapStrictBefore(pe, shifted) : elapsed ? deps.snapOnOrBefore(pe, shifted) : shifted; return deps.finishFromStart(pe, predStart, predTask); @@ -847,12 +868,12 @@ function backwardHour( if (elapsed) { return deps.snapOnOrBefore(pe, new Date(succLf.getTime() - elapsedMin())); } - return deps.shiftLagPred(pe, succLf, seq, predTask, -1); // pred.LF + return deps.shiftLagPred(lagEng, succLf, seq, predTask, -1); // pred.LF } case 'START_FINISH': { const shifted = elapsed ? new Date(succResult.lf.getTime() - elapsedMin()) - : deps.shiftLagPred(pe, succResult.lf, seq, predTask, -1); + : deps.shiftLagPred(lagEng, succResult.lf, seq, predTask, -1); const predStart = predStartsNextDay ? deps.snapStrictBefore(pe, shifted) : elapsed ? deps.snapOnOrBefore(pe, shifted) : shifted; return deps.finishFromStart(pe, predStart, predTask); @@ -863,6 +884,9 @@ function backwardHour( // Klok-minuten terug vanaf succ.LS, dan achteruit-snap in de voorganger. return deps.snapOnOrBefore(pe, new Date(succResult.ls.getTime() - elapsedMin())); } + // Spiegel van de XER/P6-forwardgrens hierboven: de late finish van de voorganger mag exact + // op de late start van de opvolger liggen; een `prevWorkInstant` zou één band terugtrekken. + if (seq.p6StartAtPredecessorFinishBoundary) return succResult.ls; const succDayStart = () => deps.startOfDay(succResult.ls); if (pe.isHourMode && se.isHourMode) { // hour-hour: pred.LF = prevWorkInstant( succ.LS ⊖ lag ) (scenario 1-6 backward). @@ -875,8 +899,8 @@ function backwardHour( // normalisatie (zie `addWorkingMinutesSigned`, m===0). Dat geval houdt bewust het // ONgenormaliseerde `succ.LS`-anker aan — het dagbegin-mijlpaal-gedrag dat hieronder // beschreven staat en dat de zusjes-cases rr-fs-pred-startms(-dagpariteit) vastleggen. - const lagged = deps.shiftLagPred(pe, succResult.ls, seq, predTask, -1); - const lagIsZero = lagged.getTime() === pe.nextWorkInstant(succResult.ls).getTime(); + const lagged = deps.shiftLagPred(lagEng, succResult.ls, seq, predTask, -1); + const lagIsZero = lagged.getTime() === deps.snapOnOrAfter(lagEng, succResult.ls).getTime(); const target = (predEndsBeginOfDay || succIsFinishMs) && lagIsZero ? succResult.ls : lagged; @@ -906,7 +930,7 @@ function backwardHour( // (`CalendarEngine.predDoneAt`, mode 'hour' ⇒ `new Date(ef)`). Beide takken van die ternaire // leveren dus dezelfde instant; er is forward niets te onderdrukken en backward niets te // spiegelen. Vlaggen hier alsnog toevoegen zou werkend gedrag breken. - const target = deps.shiftLagPred(pe, succDayStart(), seq, predTask, -1); + const target = deps.shiftLagPred(lagEng, succDayStart(), seq, predTask, -1); // H2 (cross-modus): zelfde `prevWorkInstant`-over-knijp als de hour-hour-arm hierboven. if (predElapsed) return target; return pe.prevWorkInstant(target); @@ -936,14 +960,15 @@ function backwardHour( // pariteitsbreuk). Mét speling wordt de fout niet geabsorbeerd maar eet hij er precies één // werkdag van op (zie de case rr-fs-crossmode-daypred-hourms-slack: tf 1 stond op 0). const noDayBoundary = succIsFinishMs || predEndsBeginOfDay; - const lagDays = deps.resolveEffectiveLagDays(seq, predTask, pe); let d = succDayStart(); for (let scan = 0; scan <= HOUR_SCAN; scan++) { if (pe.isWorkDay(d)) { const predDone = noDayBoundary ? new Date(d.getTime()) // mijlpaal-grens: dagbegin-anker : new Date(d.getTime() + MS_PER_DAY); // (d+1)@00:00 - const shifted = pe.addWorkingDaysSigned(predDone, lagDays); // lag in dag-pred + // Spiegel exact de forward-pass: de geselecteerde XER-lagkalender verschuift het anker, + // ook wanneer voorganger en opvolger verschillende kalenderprecisies gebruiken. + const shifted = deps.shiftLagPred(lagEng, predDone, seq, predTask, 1); if (se.nextWorkInstant(shifted).getTime() <= succResult.ls.getTime()) return d; } d = addCalendarDays(d, -1); diff --git a/src/engine/scheduler/relationRules.ts b/src/engine/scheduler/relationRules.ts index a12a88221..ccc67ec4e 100644 --- a/src/engine/scheduler/relationRules.ts +++ b/src/engine/scheduler/relationRules.ts @@ -1,4 +1,5 @@ import type { Task } from '@/types/task'; +import { isSummaryTask as taskIsSummary } from '@/utils/taskHierarchy'; export interface RelationEndpoints { predecessorId: string; @@ -9,8 +10,11 @@ export type RelationRejection = 'self' | 'unknown-task' | 'ancestor' | 'duplicat export type RelationVerdict = { ok: true } | { ok: false; reason: RelationRejection }; export type TaskLookup = (id: string) => Task | undefined; +/** Delegeert naar de gedeelde WBS-semantiek: een taak met kinderen én een expliciet gemarkeerde + * lege WBS-taak (P6 PROJWBS) zijn allebei samenvattingen. Zo gebruiken scheduler, renderer, + * resourcepaden en IFC exact hetzelfde begrip. */ export function isSummaryTask(task: Task | undefined): boolean { - return (task?.childIds.length ?? 0) > 0; + return taskIsSummary(task); } function isAncestor(lookup: TaskLookup, maybeAncestorId: string, id: string): boolean { diff --git a/src/engine/scheduler/scheduleAnalysis.ts b/src/engine/scheduler/scheduleAnalysis.ts index f686769d6..793824def 100644 --- a/src/engine/scheduler/scheduleAnalysis.ts +++ b/src/engine/scheduler/scheduleAnalysis.ts @@ -2,10 +2,16 @@ import type { Task, TaskConstraint } from '@/types/task'; import type { SchedulingOptions } from '@/types/project'; import type { Sequence } from '@/types/sequence'; import type { CalendarEngine } from './CalendarEngine'; -import type { CPMResult, CPMTaskResult } from './CPMSolver'; +import type { CpmBackwardFloatTrace, CpmFreeFloatSource, CPMResult, CPMTaskResult } from './CPMSolver'; import { parseDate, formatInstant, type DateMode } from '@/utils/dateUtils'; import { traceFrom } from './graphWalk'; import { projectDurationOf } from './projectDuration'; +import { isZeroDurationMilestone } from './duration'; +import { explainP6CompletedDataDateWindow } from '@/utils/p6CompletedTargetWindow'; +import { + explainDisplayActualLateEligibility, + explainP6CompletedLateRemainingWindowEligibility, +} from './p6CompletedRouteTrace'; /** * Invoer voor de resultaat-post-pass (`computeScheduleResults`). Puur data + een handvol @@ -35,13 +41,17 @@ export interface ScheduleAnalysisInput { projectEngine: CalendarEngine; // ── Aan de solver gebonden, stateless kalender-helpers (modus-bewust, §5) ── calendarFor: (task: Task) => CalendarEngine; + progressCalendarFor: (task: Task) => CalendarEngine; /** `task` optioneel (T8): ELAPSEDTIME ⇒ kale klok-span i.p.v. werkdag-telling, zie * `CPMSolver.signedFloat`/`duration.ts`'s `signedElapsedSpan`. */ signedFloat: (a: Date, b: Date, eng: CalendarEngine, task?: Task) => number; + /** Formaatgebonden projectie voor relationship free float; generieke kalenderalgebra blijft fysiek. */ constraintInstant: (c: TaskConstraint | undefined, eng: CalendarEngine) => Date | null; snapOnOrAfter: (eng: CalendarEngine, d: Date) => Date; snapOnOrBefore: (eng: CalendarEngine, d: Date) => Date; modeOf: (eng: CalendarEngine) => DateMode; + /** Optionele XER-diagnose die de post-pass op haar eigen beslisplekken aanvult. */ + backwardFloatTrace?: CpmBackwardFloatTrace; } /** @@ -56,7 +66,9 @@ export function computeScheduleResults(input: ScheduleAnalysisInput): CPMResult schedulingOptions, dataDate, truncatedLeadIds, hardPinViolatedIds, hammockNoFinishDriverIds, projectEngine, - calendarFor, signedFloat, constraintInstant, snapOnOrAfter, snapOnOrBefore, modeOf, + calendarFor, progressCalendarFor, signedFloat, + constraintInstant, snapOnOrAfter, snapOnOrBefore, modeOf, + backwardFloatTrace, } = input; const taskResults = new Map(); @@ -126,11 +138,12 @@ export function computeScheduleResults(input: ScheduleAnalysisInput): CPMResult // tak staat strak achter zijn optie-conditie; afwezig ⇒ exact de bestaande expressie (byte- // identiek: de 333 cases kennen `schedulingOptions` nergens). const so = schedulingOptions; - const tfMode = so?.totalFloatMode ?? 'smallest'; + const tfMode = so?.totalFloatMode; const makeOpenEndedCritical = so?.makeOpenEndedCritical === true; const nearCriticalThreshold = so?.nearCriticalThreshold; const critDef = so?.criticalDefinition; const critThreshold = critDef?.threshold ?? 0; + const critThresholdHours = critDef?.thresholdHours; const useLongestPath = critDef?.mode === 'longestPath'; // Longest-path-kritiek (§4.6, normatief): de Free-Float-peel van pad 1 — de driving-keten(s) // vanaf de taak/taken met de grootste EF; bij ties (meerdere eindtaken met dezelfde grootste EF) @@ -147,6 +160,13 @@ export function computeScheduleResults(input: ScheduleAnalysisInput): CPMResult // moduleheader daar) — zo'n relatie kan dus nooit in `drivingSequenceIds` belanden en `traceFrom` // kan nooit "doorheen" een manual taak terugtracen via een relatie die ze feitelijk negeert. const longestPathCritical = new Set(); + // Eenmalige relatie-index voor bronregels die onderscheid maken tussen een echt netwerkeinde + // en een volledig geïsoleerde mijlpaal. Geen per-taak-scan over `sequences` (O(T+E), niet O(T×E)). + const tasksWithPredecessor = new Set(sequences.map(sequence => sequence.successorId)); + let scheduleProjectEnd = new Date(0); + for (const { ef } of earlyDates.values()) { + if (ef > scheduleProjectEnd) scheduleProjectEnd = ef; + } if (useLongestPath) { let maxEf = -Infinity; for (const { ef } of earlyDates.values()) { @@ -178,6 +198,7 @@ export function computeScheduleResults(input: ScheduleAnalysisInput): CPMResult // FS-finishdag-correctie); voor kalenderdag- en procent-lag volgt de juiste waarde // automatisch uit dezelfde bron als de planningsberekening zelf. let freeFloat = Infinity; + let freeFloatSource: CpmFreeFloatSource = 'derivedFromSuccessor'; const succs = successors.get(taskId) || []; if (succs.length === 0) { // Eindtaak: vrije speling = totale-speling-equivalent (finish kan opschuiven tot @@ -191,24 +212,86 @@ export function computeScheduleResults(input: ScheduleAnalysisInput): CPMResult } } if (freeFloat === Infinity) freeFloat = 0; + // De late grens van een verbonden open P6-finishmijlpaal is zijn eigen vroege grens, maar zijn + // vrije float blijft de ruimte tot het (door een andere open tak bepaalde) projecteinde. Houd + // die twee P6-betekenissen dus apart: TF komt verderop uit de verankerde LS/LF; FF uit dezelfde + // project-eindruimte die vóór de late-ankerfix al werd gerapporteerd. Expliciete PROJECT-end- + // float heeft hieronder zijn eigen, smallere nulregel en valt niet in deze variant. + if (so?.p6Source === 'XER' && so.p6FinishMilestoneBoundaryWindow === true + && so.useProjectEndDateForFloat !== true && succs.length === 0 + && tasksWithPredecessor.has(taskId) + && taskObj.milestoneKind === 'FINISH' && isZeroDurationMilestone(taskObj)) { + freeFloat = signedFloat(early.ef, scheduleProjectEnd, cal, taskObj); + freeFloatSource = 'projectEndFinishMilestoneBoundary'; + } + // P6's expliciete PROJECT-einddatum is voor een door het netwerk bereikte, open TT_FinMile + // een late-pass-/TF-anker, geen echte opvolger. Zo'n eindmijlpaal kan wel totale float hebben, + // maar geen vrije float: zonder opvolger is er geen opvolgerdatum die hij vrij kan opsouperen. + // Nauwe bronregel voor precies die P6-taaksoort; gewone open activiteiten en volledig + // geïsoleerde finishmijlpalen houden hun bestaande freeFloat=totalFloat-equivalent (de brede + // variant verslechterde 19 publieke taken, de zonder-predecessor-variant nog één). + if (so?.useProjectEndDateForFloat === true && succs.length === 0 + && tasksWithPredecessor.has(taskId) + && taskObj.milestoneKind === 'FINISH' && isZeroDurationMilestone(taskObj)) { + freeFloat = 0; + freeFloatSource = 'clampedZero'; + } // Totale speling: getekend (fase 2.3 — negatieve float bij geschonden late-zijde- // constraints/deadlines), MSP-veilig als min van finish- en start-float (die kunnen // verschillen wanneer een SNLT alleen de late start kapt). Kritiek = tf ≤ 0. const tt = taskObj.time; - // Voortgang (fase 2.6, §4.5): voor in-progress/voltooide taken is de start-zijde-float - // betekenisloos (de ES is een actual in het verleden) ⇒ alleen finish-zijde (LF−EF). - const hasProgress = !!dataDate && (!!tt.actualStart || tt.completion > 0); const completed = !!dataDate && tt.completion >= 1; - const finishFloat = signedFloat(early.ef, late.lf, cal, taskObj); - const startFloat = signedFloat(early.es, late.ls, cal, taskObj); - // TF-berekeningswijze (§3.4): default 'smallest' = min(finish,start) ⇒ byte-identiek. Een taak - // met voortgang houdt zijn finish-zijde-float (bestaande invariant, §4.5), ongeacht de modus. - let tf = hasProgress + const completedWindowDecision = explainP6CompletedDataDateWindow(taskObj, dataDate, so); + const completedDisplayWindow = completedWindowDecision.eligible + ? (() => { + const progressCal = progressCalendarFor(taskObj); + const es = snapOnOrAfter(progressCal, dataDate!); + return { + es, + ef: progressCal.prevWorkInstant(es), + mode: modeOf(progressCal), + }; + })() + : null; + // p6CompletedLateFromRemainingWindow (diagnose laag 1, klasse (i)): zodra `CPMSolver` de late + // zijde van deze taak via de restwerkregel berekent (dezelfde `completedWindow.eligible`-poort + // als daar), moet de TF/FF-formule tegen hetzelfde statusdatumvenster meten, niet tegen de + // rauwe historische `early.es`/`early.ef` — anders combineert de formule een gewindowd late- + // punt met een niet-gewindowd early-punt en ontstaat een spookfloat die uitsluitend de + // historische afstand tussen twee taken se eigen actual-vensters meet (bv. A/B hierboven zouden + // dan een verschillende TF krijgen puur omdat B later heeft gewerkt dan A, terwijl beide + // dezelfde late-ankerketen erven). Zonder de vlag blijft dit exact `early.es`/`early.ef` — + // byte-identiek, want dan is `late.ls`/`late.lf` ook nog de ongewijzigde actual-pin. + // + // Review-bevinding 4 (poortdivergentie): deze poort MOET letterlijk dezelfde functie zijn als + // die in `CPMSolver.backwardPass`. Stond hier alleen `completedWindowDecision.eligible && + // p6Source === 'XER' && vlag`, dan viel een `TK_Complete` ZONDER `act_end_date` ertussen: wel + // window-eligible (de CP_Drtn-route eist geen actualFinish), maar NIET + // `backwardActualPin`-eligible, dus de solver liet zijn late zijde ongemoeid terwijl deze + // weergavelaag `pinLateToActualWindow` toch uitschakelde en de float tegen het venster ging + // meten. Gemeten gevolg vóór deze fix (fixture `check-xer-completed-late-gate-parity.ts`): + // ls/lf/tf van zo'n taak veranderden terwijl de solvertak niets deed. + const useCompletedRemainingWindow = explainP6CompletedLateRemainingWindowEligibility( + taskObj, dataDate, so, + ).eligible; + const floatEarlyEs = useCompletedRemainingWindow ? completedDisplayWindow!.es : early.es; + const floatEarlyEf = useCompletedRemainingWindow ? completedDisplayWindow!.ef : early.ef; + const finishFloat = signedFloat(floatEarlyEf, late.lf, cal, taskObj); + const startFloat = signedFloat(floatEarlyEs, late.ls, cal, taskObj); + // Een EXPLICIETE P6-modus geldt ook voor lopende taken: start = LS−ES, finish = LF−EF en + // smallest = min(beide). Ontbreekt de bronoptie, dan blijft de oudere OPS-invariant behouden: + // een lopende taak gebruikt finish-float en een overige taak de kleinste — zo blijven verse, + // MSPDI-, MPP- en P6XML-projecten zonder deze bronwaarde byte-identiek. + let tf = tfMode === 'finish' ? finishFloat - : tfMode === 'finish' ? finishFloat - : tfMode === 'start' ? startFloat - : Math.min(finishFloat, startFloat); + : tfMode === 'start' + ? startFloat + : tfMode === 'smallest' + ? Math.min(finishFloat, startFloat) + : (!!dataDate && (!!tt.actualStart || tt.completion > 0)) + ? finishFloat + : Math.min(finishFloat, startFloat); // Open-ended kritiek (§3.4): alleen bij `makeOpenEndedCritical` krijgt een taak zonder opvolger // tf=ff=0 (P6: LF=EF ⇒ kritiek). Default (optie afwezig) ⇒ ongewijzigd. if (makeOpenEndedCritical && succs.length === 0 && !completed) { @@ -240,6 +323,23 @@ export function computeScheduleResults(input: ScheduleAnalysisInput): CPMResult tf = 0; freeFloat = 0; } + // P6/XER toont bij voltooid werk wel het historische actual-venster als LS/LF, maar TF blijft + // de backward-recurrentie van nog open downstream werk volgen. FF is voor historie nul: een + // voltooide activiteit kan haar opvolger niet meer vrij verschuiven. De floats hierboven zijn + // daarom bewust uit de netwerk-late-datums berekend; alleen de uiteindelijke datumweergave + // hieronder wordt op actuals teruggezet. + if (completed && so?.preserveActualDatesInBackwardPass === true) { + freeFloat = 0; + freeFloatSource = 'clampedZero'; + } + // P6/XER houdt vrije float op nul wanneer een late constraint de totale float negatief maakt. + // De publieke P6-meetmassa bevat wel 63 negatieve TF-cellen maar geen negatieve FF-cel; case 05 + // bevestigt hetzelfde op een FNLT-eindtaak. Brongebonden, zodat de algemene getekende OPS- + // semantiek zonder vlag ongewijzigd blijft. + if (so?.clampNegativeFreeFloat === true && freeFloat < 0) { + freeFloat = 0; + freeFloatSource = 'clampedZero'; + } // Kritiek-definitie (§4.6): hammock ⇒ NOOIT kritiek (P6: LOE is een gevolg, geen oorzaak); // voltooid ⇒ nooit kritiek (P6, opvolgers wél); longestPath ⇒ op een driving-keten naar de // laatste finish (tf-onafhankelijk); anders tf ≤ drempel (default 0 = het huidige tf≤0). @@ -254,7 +354,9 @@ export function computeScheduleResults(input: ScheduleAnalysisInput): CPMResult ? false : useLongestPath ? longestPathCritical.has(taskId) - : tf <= critThreshold; + : critThresholdHours !== undefined + ? tf * cal.hoursPerDay <= critThresholdHours + : tf <= critThreshold; if (isCritical) criticalPath.push(taskId); // Interfererende speling (§4.6): ALTIJD berekend, getekend (fractioneel in uur-modus, erft @@ -295,12 +397,39 @@ export function computeScheduleResults(input: ScheduleAnalysisInput): CPMResult // Serialisatie (§2.4/§5): de MODUS van de eigen kalender is de enige discriminator — dag-taak ⇒ // `formatDate` (byte-identiek), uur-taak ⇒ `YYYY-MM-DDTHH:mm`. - const mode = modeOf(cal); + const mode = completedDisplayWindow?.mode ?? modeOf(cal); + const displayActualLateDecision = explainDisplayActualLateEligibility(taskObj, dataDate, so); + const displayActualLate = displayActualLateDecision.eligible; + // Diagnose laag 1, klasse (i): zónder de nieuwe vlag pint de LATE zijde van een voltooide taak + // op haar rauwe actual-venster (`early.es`/`early.ef`, dezelfde bron als de bestaande + // `backwardActualPin` in `CPMSolver`) — asymmetrisch met de EARLY zijde hierboven, die al wél + // het statusdatumvenster toont zodra `completedDisplayWindow` eligible is. Mét de vlag heeft + // `CPMSolver.backwardPass` voor deze taak al de P6-restwerkregel toegepast (`late.ls`/`late.lf` + // dragen dan een zinvol, niet-gedegenereerd statusdatumvenster inclusief float) — die uitkomst + // hoort dan getoond te worden i.p.v. de rauwe actual-pin. + const pinLateToActualWindow = displayActualLate && !useCompletedRemainingWindow; + if (backwardFloatTrace) { + const prior = backwardFloatTrace.byTaskId[taskId] ?? { + lateFinishSource: 'projectEnd' as const, + lateStartSource: 'subDuration' as const, + freeFloatSource: 'derivedFromSuccessor' as const, + displayActualLate: false, + completedWindow: completedWindowDecision, + backwardActualPin: { eligible: false, reason: 'missingDataDate' } as const, + displayActualLateDecision: { eligible: false, reason: 'missingDataDate' } as const, + }; + backwardFloatTrace.byTaskId[taskId] = { + ...prior, + freeFloatSource, + displayActualLate, + displayActualLateDecision, + }; + } taskResults.set(taskId, { - earlyStart: formatInstant(early.es, mode), - earlyFinish: formatInstant(early.ef, mode), - lateStart: formatInstant(late.ls, mode), - lateFinish: formatInstant(late.lf, mode), + earlyStart: formatInstant(completedDisplayWindow?.es ?? early.es, mode), + earlyFinish: formatInstant(completedDisplayWindow?.ef ?? early.ef, mode), + lateStart: formatInstant(pinLateToActualWindow ? early.es : late.ls, mode), + lateFinish: formatInstant(pinLateToActualWindow ? early.ef : late.lf, mode), totalFloat: tf, freeFloat, isCritical, diff --git a/src/engine/scheduler/solveProject.ts b/src/engine/scheduler/solveProject.ts index d6ffe43f1..0108fd072 100644 --- a/src/engine/scheduler/solveProject.ts +++ b/src/engine/scheduler/solveProject.ts @@ -22,6 +22,7 @@ import type { ProgressMode, SchedulingOptions } from '@/types/project'; import { CPMSolver, type CPMResult } from './CPMSolver'; import { applyCpmResult } from './applyCpmResult'; import { expandSummaryRelations, foldSyntheticSequenceIds } from './expandSummaryRelations'; +import { isLeafTask } from '@/utils/taskHierarchy'; /** Invoer van één doorrekening — plain data, geen store. */ export interface SolveProjectInput { @@ -47,13 +48,15 @@ export interface SolveProjectInput { * zónder voorganger — die start op zijn eigen, ingelezen `scheduleStart` (`ownAnchor`), ook als * die vóór de projectstart ligt; "een ingelezen anker wordt nooit door de vloer overruled". */ projectStartDate?: string; + /** `project.endDate`; alleen bronsemantisch actief via useProjectEndDateForFloat. */ + projectEndDate?: string; } /** * Reken de planning door en schrijf het resultaat terug op `input.tasks`. * * Exact de keten die `runCPM` altijd al draaide, ongewijzigd van volgorde en semantiek: - * 1. leaf-filter (`childIds.length === 0`) — de solver rekent op bladen; + * 1. semantische leaf-filter — de solver rekent niet op WBS-samenvattingen, ook niet als ze leeg zijn; * 2. `new CPMSolver(...).solve()` met dataDate/progressMode/schedulingOptions; * 3. bij `result.error` (cyclus e.d.): NIET terugschrijven — het resultaat wordt teruggegeven met * de fout erin, de taken blijven op hun oude datums staan; @@ -64,7 +67,7 @@ export interface SolveProjectInput { export function solveProject(input: SolveProjectInput): CPMResult { // Per-taak-kalender (fase 2.8a, §5.1): de solver krijgt de projectdefault + de bibliotheek en // bouwt zelf een engine-cache; taken zonder eigen calendarId rekenen in de projectkalender. - const leafTasks = input.tasks.filter(t => t.childIds.length === 0); + const leafTasks = input.tasks.filter(isLeafTask); // Samenvattingsrelatie-propagatie (MS Project-semantiek): relaties die een WBS-samenvattingstaak // raken worden herschreven naar equivalente bladtaak-relaties vóórdat de solver ze ziet — de // solver kent alleen bladtaken. Dit hoort in de kern (niet in `runCPM`), zodat óók het @@ -78,6 +81,7 @@ export function solveProject(input: SolveProjectInput): CPMResult { // gedragswijzigend (afwezig/leeg ⇒ byte-identiek); de latere golven activeren ze. schedulingOptions: input.schedulingOptions, projectStartDate: input.projectStartDate, + projectEndDate: input.projectEndDate, }); const result = solver.solve(); // I2 (CPM-review): de solver rekende op de GEËXPANDEERDE (synthetische) relatie-set, dus zijn diff --git a/src/engine/taskGrid/fieldCoverage.ts b/src/engine/taskGrid/fieldCoverage.ts index de7e9f229..c167099d8 100644 --- a/src/engine/taskGrid/fieldCoverage.ts +++ b/src/engine/taskGrid/fieldCoverage.ts @@ -69,6 +69,16 @@ export const TASK_FIELD_COVERAGE = { manuallyScheduled: field('technical', 'task.manuallyScheduled'), mspTaskType: field('technical', 'task.mspTaskType'), effortDriven: field('technical', 'task.effortDriven'), + // XER/Primavera-herkomst: acht bronvelden, één gebundelde technische kolom (zie registry). + p6DurationType: field('technical', 'task.p6Provenance'), + p6ActivityType: field('technical', 'task.p6Provenance'), + p6ProjectId: field('technical', 'task.p6Provenance'), + p6TaskId: field('technical', 'task.p6Provenance'), + p6ExplicitTargetWindow: field('technical', 'task.p6Provenance'), + p6CompletePctType: field('technical', 'task.p6Provenance'), + p6ExpectedFinish: field('technical', 'task.p6Provenance'), + p6SuspendResume: field('technical', 'task.p6Provenance'), + isSummary: field('technical', 'task.isSummary'), parentId: field('technical', 'task.parentId'), childIds: field('technical', 'task.childIds'), time: field('composite', ...TASK_TIME_COLUMNS), @@ -127,6 +137,8 @@ export const SEQUENCE_FIELD_COVERAGE = { lagMinutes: field('composite', 'relation.predecessors', 'relation.successors'), lagUnit: field('composite', 'relation.predecessors', 'relation.successors'), lagPercent: field('composite', 'relation.predecessors', 'relation.successors'), + // P6-grensregel op de relatie zelf: rijdt mee in dezelfde relatiekolommen. + p6StartAtPredecessorFinishBoundary: field('composite', 'relation.predecessors', 'relation.successors'), } satisfies Record; export const EXTERNAL_LINK_FIELD_COVERAGE = { @@ -158,6 +170,9 @@ export const BASELINE_TASK_FIELD_COVERAGE = { duration: dynamic('direct', 'baseline'), isMilestone: dynamic('technical', 'baseline'), milestoneKind: dynamic('technical', 'baseline'), + // XER-baselineherkomst: geen eigen vaste kolom, maar per baseline dynamisch net als de rest. + sourceTaskId: dynamic('technical', 'baseline'), + sourceTaskCode: dynamic('technical', 'baseline'), } satisfies Record; export const SPLIT_GAP_FIELD_COVERAGE = { diff --git a/src/engine/taskGrid/taskColumnRegistry.ts b/src/engine/taskGrid/taskColumnRegistry.ts index 28c2624d8..a0e105cbd 100644 --- a/src/engine/taskGrid/taskColumnRegistry.ts +++ b/src/engine/taskGrid/taskColumnRegistry.ts @@ -8,6 +8,7 @@ import type { CellEditRoute, GridResult, GridWriteIntent, + RecordedTaskAxis, TaskAssignmentToken, TaskColumnCategory, TaskColumnContext, @@ -121,6 +122,14 @@ function formatScalar(value: unknown): string { return String(value); } +/** Celtekst van de kolom `recorded.source` — gedeeld door `format` en `copy`, zodat het klembord + * nooit uit elkaar kan lopen met wat er op het scherm staat. */ +function recordedSourceText(value: unknown, ctx: TaskColumnContext): string { + if (value === 'deviates') return ctx.labelForText?.('recordedDates.markDeviates') ?? 'deviates'; + if (value === 'partly-unrecorded') return ctx.labelForText?.('recordedDates.markPartlyUnrecorded') ?? 'partly-unrecorded'; + return '—'; +} + function copyScalar(value: unknown): string { if (value === undefined || value === null) return ''; if (typeof value === 'boolean') return value ? 'true' : 'false'; @@ -128,6 +137,52 @@ function copyScalar(value: unknown): string { return String(value); } +/** + * "Datums zoals opgeslagen" (issue #63, XER-etappeplan laag 3, T6) — `format`-tak voor de vier + * optionele late-/floatkolommen (`task.time.lateStart`/`lateFinish`/`totalFloat`/`freeFloat`). + * `applyRecordedTimesToTasks` (`recordedDates.ts` §3.4) laat de bestaande `?? rec.start`/`?? 0`- + * terugvallen bewust als VELDWAARDE staan — "niet vastgelegd" leeft uitsluitend in + * `recordedDates.times[id]` en wordt hier, als WEERGAVE, afgedwongen: zonder deze tak zou een as + * die het bestand nooit gaf gewoon als een echt getal (vaak een verzonnen `0`) op het scherm staan. + * `ctx.recordedUnrecordedAxes` is `undefined` zonder vastlegging (niet-XER-documenten, of geen + * restverschillen) — dan valt dit terug op de gewone `formatScalar`, byte-identiek aan vóór T6. + */ +function recordedAxisFormat(axis: RecordedTaskAxis): Formatter { + return (value, task, ctx) => ctx.recordedUnrecordedAxes?.(task).includes(axis) + ? (ctx.labelForText?.('recordedDates.notRecorded') ?? '—') + : formatScalar(value); +} + +/** + * Her-check laag 3, bevindingen 1 en 2: de `format`-tak alleen was NIET genoeg. `taskGridAdapter. + * getCell` slaat `descriptor.format` over zodra `domain.dateNotation` gezet is (dat is in het + * product ALTIJD zo — `UIState.dateNotation` heeft een default) en de waarde een datumstring is: + * dan gaat de cel via `copyGridEditorValue` en toonde `task.time.lateStart` — de `?? rec.start`- + * terugval — als een echte, verzonnen late datum. En `copyScalar(read(...))` zette diezelfde + * terugval (of een verzonnen `0` speling) in het klembord terwijl de cel "Niet vastgelegd" toonde. + * + * Daarom is "niet vastgelegd" nu een eigenschap van de LEESWAARDE zelf: `read` levert `undefined` + * voor een as die het bestand niet gaf, zodat élke afnemer van de descriptor (celtekst, klembord, + * titel, sortering) hetzelfde ziet — er bestaat dan geen string meer die de adapter per ongeluk als + * datum kan opmaken. `format` blijft de tekst "Niet vastgelegd" leveren en `copy` een lege + * klembordcel (zelfde regel als `recorded.source` hieronder: klembord = wat de cel zegt, en een + * spreadsheet-plakactie verwacht leeg, geen em-dash). Buiten de modus (`recordedUnrecordedAxes` + * ontbreekt) is dit byte-identiek aan de kale `read`. + */ +function recordedAxisRead( + axis: RecordedTaskAxis, + read: (task: Task) => T, +): (task: Task, ctx: TaskColumnContext) => T | undefined { + return (task, ctx) => ctx.recordedUnrecordedAxes?.(task).includes(axis) ? undefined : read(task); +} + +function recordedAxisCopy( + axis: RecordedTaskAxis, + read: (task: Task) => T, +): (task: Task, ctx: TaskColumnContext) => string { + return (task, ctx) => ctx.recordedUnrecordedAxes?.(task).includes(axis) ? '' : copyScalar(read(task)); +} + function readonlyColumn(config: ReadonlyColumnConfig): TaskColumnDescriptor { const id = typeof config.id === 'string' ? taskColumnId(config.id) : config.id; const format = config.format ?? ((value: unknown) => formatScalar(value)); @@ -602,6 +657,21 @@ function fixedTaskColumns(input: TaskColumnRegistryInput): TaskColumnDescriptor[ readonlyColumn({ id: 'task.manuallyScheduled', labelKey: 'taskGrid.columns.manuallyScheduled', category: 'technical', valueKind: 'boolean', read: task => task.manuallyScheduled }), readonlyColumn({ id: 'task.mspTaskType', labelKey: 'taskGrid.columns.mspTaskType', category: 'technical', valueKind: 'enum', read: task => task.mspTaskType }), readonlyColumn({ id: 'task.effortDriven', labelKey: 'taskGrid.columns.effortDriven', category: 'technical', valueKind: 'boolean', read: task => task.effortDriven }), + // XER/Primavera-herkomst: acht bronvelden die de XER-lezer op de taak zet en die door IFC + // round-trippen. Ze zijn puur provenance (geen solverinvoer deze etappe), dus één readonly + // technische kolom bundelt ze — zoals `task.activityCodes.technical` dat voor codes doet. + readonlyColumn({ + id: 'task.p6Provenance', labelKey: 'taskGrid.columns.p6Provenance', category: 'technical', valueKind: 'technical', + read: task => p6ProvenanceOf(task), + format: value => { + const entries = value && typeof value === 'object' ? Object.entries(value as Record) : []; + return entries.length ? entries.map(([key, item]) => `${key}: ${String(item)}`).join(', ') : '—'; + }, + copy: task => canonicalGridJson(p6ProvenanceOf(task)), + }), + // Expliciete, kinderloze WBS-samenvatting (P6 PROJWBS). Alleen-lezen: de marker komt uit de + // import en de hiërarchie zelf blijft via parentId/childIds bewerkbaar. + readonlyColumn({ id: 'task.isSummary', labelKey: 'taskGrid.columns.explicitSummary', category: 'technical', valueKind: 'boolean', read: task => task.isSummary }), readonlyColumn({ id: 'task.parentId', labelKey: 'taskGrid.columns.parentId', category: 'technical', valueKind: 'text', read: task => task.parentId }), readonlyColumn({ id: 'task.childIds', labelKey: 'taskGrid.columns.childIds', category: 'technical', valueKind: 'technical', read: task => task.childIds, format: value => Array.isArray(value) && value.length ? value.join(', ') : '—', copy: task => canonicalGridJson(task.childIds) }), readonlyColumn({ id: 'task.resourceIds', labelKey: 'taskGrid.columns.resourceIds', category: 'technical', valueKind: 'technical', read: task => task.resourceIds, format: value => Array.isArray(value) && value.length ? value.join(', ') : '—', copy: task => canonicalGridJson(task.resourceIds) }), @@ -653,14 +723,33 @@ function fixedTimeColumns(): TaskColumnDescriptor[] { readonlyColumn({ id: 'task.time.stop', labelKey: 'taskGrid.columns.stop', category: 'progress', valueKind: 'datetime', read: task => task.time.stop }), readonlyColumn({ id: 'task.time.earlyStart', labelKey: 'taskGrid.columns.earlyStart', category: 'computed', valueKind: 'datetime', read: task => task.time.earlyStart }), readonlyColumn({ id: 'task.time.earlyFinish', labelKey: 'taskGrid.columns.earlyFinish', category: 'computed', valueKind: 'datetime', read: task => task.time.earlyFinish }), - readonlyColumn({ id: 'task.time.lateStart', labelKey: 'taskGrid.columns.lateStart', category: 'computed', valueKind: 'datetime', read: task => task.time.lateStart }), - readonlyColumn({ id: 'task.time.lateFinish', labelKey: 'taskGrid.columns.lateFinish', category: 'computed', valueKind: 'datetime', read: task => task.time.lateFinish }), - readonlyColumn({ id: 'task.time.freeFloat', labelKey: 'taskGrid.columns.freeFloat', category: 'computed', valueKind: 'duration', read: task => task.time.freeFloat }), - readonlyColumn({ id: 'task.time.totalFloat', labelKey: 'taskGrid.columns.totalFloat', category: 'computed', valueKind: 'duration', read: task => task.time.totalFloat }), + readonlyColumn({ id: 'task.time.lateStart', labelKey: 'taskGrid.columns.lateStart', category: 'computed', valueKind: 'datetime', read: recordedAxisRead('ls', task => task.time.lateStart), format: recordedAxisFormat('ls'), copy: recordedAxisCopy('ls', task => task.time.lateStart) }), + readonlyColumn({ id: 'task.time.lateFinish', labelKey: 'taskGrid.columns.lateFinish', category: 'computed', valueKind: 'datetime', read: recordedAxisRead('lf', task => task.time.lateFinish), format: recordedAxisFormat('lf'), copy: recordedAxisCopy('lf', task => task.time.lateFinish) }), + readonlyColumn({ id: 'task.time.freeFloat', labelKey: 'taskGrid.columns.freeFloat', category: 'computed', valueKind: 'duration', read: recordedAxisRead('ff', task => task.time.freeFloat), format: recordedAxisFormat('ff'), copy: recordedAxisCopy('ff', task => task.time.freeFloat) }), + readonlyColumn({ id: 'task.time.totalFloat', labelKey: 'taskGrid.columns.totalFloat', category: 'computed', valueKind: 'duration', read: recordedAxisRead('tf', task => task.time.totalFloat), format: recordedAxisFormat('tf'), copy: recordedAxisCopy('tf', task => task.time.totalFloat) }), readonlyColumn({ id: 'task.time.isCritical', labelKey: 'taskGrid.columns.critical', category: 'computed', valueKind: 'boolean', read: task => task.time.isCritical }), readonlyColumn({ id: 'task.time.interferingFloat', labelKey: 'taskGrid.columns.interferingFloat', category: 'computed', valueKind: 'duration', read: task => task.time.interferingFloat }), readonlyColumn({ id: 'task.time.isNearCritical', labelKey: 'taskGrid.columns.nearCritical', category: 'computed', valueKind: 'boolean', read: task => task.time.isNearCritical }), readonlyColumn({ id: 'task.time.floatPath', labelKey: 'taskGrid.columns.floatPath', category: 'computed', valueKind: 'number', read: task => task.time.floatPath }), + // "Datums zoals opgeslagen" (issue #63, XER-etappeplan laag 3, T6) — badge die toont of DEZE + // taak een vastlegging heeft die afwijkt van de herberekening, of onvolledig is. Bestaat + // uitsluitend op documenten met een vastlegging (`ctx.recordedMark !== undefined` ⇒ + // `recordedDates !== null`, zie `FullTaskGrid.tsx`) — op elk ander document is deze kolom + // onzichtbaar, dus geen ruis op IFC/CSV/MSPDI/MPP-documenten zonder issue-#63-vastlegging. + readonlyColumn({ + id: 'recorded.source', labelKey: 'taskGrid.columns.recordedSource', category: 'computed', valueKind: 'text', + available: ctx => ctx.recordedMark !== undefined, + read: (task, ctx) => ctx.recordedMark?.(task), + format: (value, _task, ctx) => recordedSourceText(value, ctx), + // Zonder eigen `copy` levert `copyScalar` het RAUWE token (`deviates`) in het klembord + // terwijl de cel "Wijkt af" toont (critreview laag 3, bevinding 11). Dezelfde tekst als de + // cel dus — en de lege markering blijft een lege klembordcel in plaats van een em-dash, want + // dat is wat een plakactie in een spreadsheet verwacht. + copy: (task, ctx) => { + const value = ctx.recordedMark?.(task); + return value === undefined ? '' : recordedSourceText(value, ctx); + }, + }), editableColumn({ id: 'task.time.actualStart', labelKey: 'taskGrid.columns.actualStart', category: 'progress', valueKind: 'datetime', editorKind: 'datetime', route: 'task-progress', read: task => task.time.actualStart, parse: parseDate, validate: validateDate }), editableColumn({ id: 'task.time.actualFinish', labelKey: 'taskGrid.columns.actualFinish', category: 'progress', valueKind: 'datetime', editorKind: 'datetime', route: 'task-progress', read: task => task.time.actualFinish, parse: parseDate, validate: validateDate }), editableColumn({ id: 'task.time.actualDuration', labelKey: 'taskGrid.columns.actualDuration', category: 'progress', valueKind: 'duration', editorKind: 'duration', route: 'task-progress', read: task => task.time.actualDuration, parse: parseTaskDuration, validate: validateOptionalDuration }), @@ -1011,6 +1100,20 @@ function baselineColumns(input: TaskColumnRegistryInput): TaskColumnDescriptor[] return result; } +/** De acht XER/Primavera-bronvelden van een taak, zonder de afwezige. */ +function p6ProvenanceOf(task: Task): Record { + const out: Record = {}; + if (task.p6ProjectId !== undefined) out.p6ProjectId = task.p6ProjectId; + if (task.p6TaskId !== undefined) out.p6TaskId = task.p6TaskId; + if (task.p6ActivityType !== undefined) out.p6ActivityType = task.p6ActivityType; + if (task.p6DurationType !== undefined) out.p6DurationType = task.p6DurationType; + if (task.p6CompletePctType !== undefined) out.p6CompletePctType = task.p6CompletePctType; + if (task.p6ExpectedFinish !== undefined) out.p6ExpectedFinish = task.p6ExpectedFinish; + if (task.p6ExplicitTargetWindow !== undefined) out.p6ExplicitTargetWindow = task.p6ExplicitTargetWindow; + if (task.p6SuspendResume !== undefined) out.p6SuspendResume = task.p6SuspendResume; + return out; +} + /** Bouwt de volledige headless registry. De categorie-sortering is stabiel; binnen een categorie * blijft de declaratie/projectvolgorde behouden. Dubbele ids zijn een programmeerfout en stoppen * de bouw onmiddellijk in plaats van stil een descriptor te overschrijven. */ diff --git a/src/engine/taskGrid/taskGridAdapter.ts b/src/engine/taskGrid/taskGridAdapter.ts index 1a5614215..c165b267d 100644 --- a/src/engine/taskGrid/taskGridAdapter.ts +++ b/src/engine/taskGrid/taskGridAdapter.ts @@ -15,6 +15,8 @@ import type { CellValidationError, GridIntent, GridResult, + RecordedTaskAxis, + RecordedTaskMark, TaskColumnCategory, TaskColumnContext, TaskColumnDescriptor, @@ -115,6 +117,8 @@ export interface CreateTaskGridAdapterDomainInput { textDirection?: 'ltr' | 'rtl'; effectiveHoursPerDay?: (task: Task) => number; signedWorkDaysBetween?: (fromIso: string, toIso: string) => number; + recordedMark?: (task: Task) => RecordedTaskMark; + recordedUnrecordedAxes?: (task: Task) => readonly RecordedTaskAxis[]; dateNotation?: DateNotation; calendarOptions?: readonly { value: string; label: string }[]; } @@ -240,6 +244,8 @@ export function createTaskGridAdapterDomain( wbsAutoNumber: input.wbsAutoNumber, effectiveHoursPerDay: input.effectiveHoursPerDay, signedWorkDaysBetween: input.signedWorkDaysBetween, + recordedMark: input.recordedMark, + recordedUnrecordedAxes: input.recordedUnrecordedAxes, }; const descriptors = buildTaskColumnRegistry({ projectId: input.projectId, diff --git a/src/engine/variance.ts b/src/engine/variance.ts index 96211dec3..5ad2a2c16 100644 --- a/src/engine/variance.ts +++ b/src/engine/variance.ts @@ -1,4 +1,5 @@ import type { Task } from '@/types/task'; +import { isLeafTask } from '@/utils/taskHierarchy'; import type { Baseline, BaselineTask } from '@/types/baseline'; import type { CalendarEngine } from '@/engine/scheduler/CalendarEngine'; import { parseDate } from '@/utils/dateUtils'; @@ -60,7 +61,7 @@ export function computeVariance( ): VarianceResult { if (!baseline) return { rows: [] }; - const leaves = tasks.filter((t) => t.childIds.length === 0); + const leaves = tasks.filter(isLeafTask); const baseMap = new Map(); for (const bt of baseline.tasks) baseMap.set(bt.taskId, bt); const currentIds = new Set(leaves.map((t) => t.id)); diff --git a/src/engine/view/dropTarget.ts b/src/engine/view/dropTarget.ts index c2ad0382e..17fc3b097 100644 --- a/src/engine/view/dropTarget.ts +++ b/src/engine/view/dropTarget.ts @@ -6,6 +6,7 @@ import type { Task } from '@/types/task'; import type { ViewRow } from './visibleRows'; +import { isLeafTask } from '@/utils/taskHierarchy'; export interface DropTarget { parentId: string | null; @@ -20,7 +21,7 @@ export interface DropTarget { * valt het doel dus niet naar root/grootouder uit (het bekende gat in ontwerp B §3.2 dat hiermee * gedicht wordt), want `childIndex` wordt geklemd op de kindlijst-lengte van diezelfde ouder. * - `'nest'` — de rij-taak wordt de nieuwe ouder; het gesleepte item wordt diens LAATSTE kind. - * Alleen geldig als de rij-taak een summary is (`childIds.length > 0`) — mijlpalen en gewone + * Alleen geldig als de rij-taak semantisch een summary is — mijlpalen en gewone * leaves zijn geen nest-doel. * - Groepsrijen (`kind: 'group'`) en een leeg/ongeldig `rowIndex` hebben geen structurele * betekenis ⇒ altijd `null`. @@ -42,7 +43,7 @@ export function resolveDropTarget( const refTask = row.task; if (zone === 'nest') { - if (refTask.childIds.length === 0) return null; // mijlpaal/leaf: geen nest-doel + if (isLeafTask(refTask)) return null; // mijlpaal/leaf: geen nest-doel return { parentId: refTask.id, childIndex: refTask.childIds.length }; } diff --git a/src/engine/view/visibleRows.ts b/src/engine/view/visibleRows.ts index 3a76cca22..9ae4b722a 100644 --- a/src/engine/view/visibleRows.ts +++ b/src/engine/view/visibleRows.ts @@ -8,6 +8,7 @@ import type { FieldRef, FilterNode, GroupLevel, SortLevel, ViewState, } from '@/state/slices/types'; import { evaluate, resolveField, resourceNames, type FieldValue, type ViewContext } from './filterEval'; +import { isLeafTask } from '@/utils/taskHierarchy'; export type { ViewContext } from './filterEval'; @@ -170,7 +171,7 @@ export function computeViewRows(tasks: Task[], opts: ViewRowOpts, ctx: ViewConte for (const t of tasks) { visible.add(t.id); dimmed.set(t.id, false); } } else { for (const leaf of tasks) { - if (leaf.childIds.length !== 0) continue; + if (!isLeafTask(leaf)) continue; if (!evaluate(filter, leaf, ctx)) continue; visible.add(leaf.id); dimmed.set(leaf.id, false); @@ -185,7 +186,7 @@ export function computeViewRows(tasks: Task[], opts: ViewRowOpts, ctx: ViewConte // Stap 2 — gegroepeerde modus (§4.2/§7): platte banden op zichtbare bladeren. if (group.length > 0) { const rows: ViewRow[] = []; - const visibleLeaves = tasks.filter(t => t.childIds.length === 0 && visible.has(t.id)); + const visibleLeaves = tasks.filter(t => isLeafTask(t) && visible.has(t.id)); const walk = (leaves: Task[], levelIndex: number, path: string[]) => { if (levelIndex >= group.length) { for (const leaf of sortTasks(leaves, sort, ctx)) { diff --git a/src/extensions/apiVersion.ts b/src/extensions/apiVersion.ts index 8a55258f1..deb7b577d 100644 --- a/src/extensions/apiVersion.ts +++ b/src/extensions/apiVersion.ts @@ -22,7 +22,7 @@ * versienummer, dus 1.0.0 betekent "de vorm zoals die op het moment van invoering was". Elke * volgende wijziging aan `extTypes.ts`/`types.ts` hoort hier een bump te krijgen. */ -export const EXTENSION_API_VERSION = '1.0.0'; +export const EXTENSION_API_VERSION = '1.1.0'; export interface ApiCompatibility { ok: boolean; diff --git a/src/extensions/extImportSource.ts b/src/extensions/extImportSource.ts new file mode 100644 index 000000000..a3971aea8 --- /dev/null +++ b/src/extensions/extImportSource.ts @@ -0,0 +1,353 @@ +/** + * Publieke read-only XER-bronroute. Deze module vertaalt de immutable interne bronretentie naar + * kleine, losse ext-facing DTO's. De summary kloont geen grote catalogi; pagina's klonen alleen hun + * eigen records en chunks decoderen steeds naar een nieuwe Uint8Array. + */ +import { + EXT_IMPORT_SOURCE_PAGE_SIZE_MAX, + type ExtImportSourceCatalogPage, + type ExtImportSourceCatalogCounts, + type ExtImportSourceCollection, + type ExtImportSourceDiagnosticsSummary, + type ExtImportSourceInfo, + type ExtImportSourceNumberFormat, + type ExtImportSourcePageOptions, + type ExtImportSourceRecord, + type ExtImportSourceReport, + type ExtImportSourceScheduleOptionsSummary, +} from './extTypes'; +import type { XerImportMetadata, XerImportReport } from '@/services/importTypes'; +import type { + XerArchiveDocumentViewV1, + XerSourceArchive, +} from '@/services/xerSourceArchive'; +import { XER_SOURCE_ARCHIVE_CHUNK_BYTES } from '@/services/xerSourceArchive'; + +const DEFAULT_PAGE_SIZE = 100; + +function copyRecord(value: object): ExtImportSourceRecord { + return structuredClone(value) as ExtImportSourceRecord; +} + +function copyReport(report: XerImportReport): ExtImportSourceReport { + return { + projectsSeen: report.projectsSeen, + documentsOpened: report.documentsOpened, + emptyProjectsSkipped: report.emptyProjectsSkipped, + baselineProjectsExcluded: report.baselineProjectsExcluded, + baselinesMaterialized: report.baselinesMaterialized, + danglingBaselineReferences: report.danglingBaselineReferences, + externalLinksPreserved: report.externalLinksPreserved, + baselineExclusionReverted: report.baselineExclusionReverted, + baselineFallbackReasons: [...report.baselineFallbackReasons], + }; +} + +function validatePageOptions(options?: ExtImportSourcePageOptions): { offset: number; limit: number } { + const offset = options?.offset ?? 0; + const limit = options?.limit ?? DEFAULT_PAGE_SIZE; + if (!Number.isSafeInteger(offset) || offset < 0) { + throw new RangeError('Broncatalogus-offset moet een niet-negatief safe integer zijn.'); + } + if (!Number.isSafeInteger(limit) || limit < 1 || limit > EXT_IMPORT_SOURCE_PAGE_SIZE_MAX) { + throw new RangeError(`Broncatalogus-limit moet 1..${EXT_IMPORT_SOURCE_PAGE_SIZE_MAX} zijn.`); + } + return { offset, limit }; +} + +/** + * Canoniseer `offset` tegen de werkelijke collectiegrootte, VÓÓR de slice (reviewbevinding P2). + * + * `validatePageOptions` bewijst alleen dat `offset` zelf een safe integer is — niet dat + * `offset + limit` dat ook blijft: met `offset` tot aan `Number.MAX_SAFE_INTEGER` en `limit` tot + * `EXT_IMPORT_SOURCE_PAGE_SIZE_MAX` (500) kon die som het safe integer-bereik verlaten, en + * `records.slice(offset, offset + limit)` liet zo'n offset gewoon door in plaats van fail-closed + * te weigeren. Elke echte collectie telt hooguit een paar duizend records, dus de juiste grens is + * niet "offset ligt onder MAX_SAFE_INTEGER - limit" maar "offset ligt hoogstens op het einde van de + * collectie": begrens `offset` op `total` — een lege, geldige laatste pagina in plaats van een fout — + * en de daaropvolgende `offset + limit` blijft daarmee altijd ruim binnen het safe integer-bereik, + * want `total` is per definitie het aantal records dat al in het geheugen staat. + */ +function resolvePageOffset(offset: number, total: number): number { + // `+ 0` normaliseert een `-0`-offset naar `+0` (reviewbevinding P3): `Number.isSafeInteger(-0)` + // is `true` en `-0 < 0` is `false`, dus `validatePageOptions` liet 'm ongewijzigd door. Cosmetisch + // voor JSON (`JSON.stringify(-0) === '0'`), maar een extensie die `Object.is()` gebruikt of de + // offset als objectsleutel neemt zou anders `-0` kunnen zien. + return (offset > total ? total : offset) + 0; +} + +/** + * Fail-closed documentdriftbewaking (her-review 2, P2). `getImportSourceCatalogPage` werkt op het + * ACTIEVE document — er is geen intern paginasessie-object dat aan één document vastzit. Pagineren + * is per definitie meerdere aanroepen over tijd; wisselt de gebruiker tussen twee van die aanroepen + * van document (`switchDocument`), dan combineert een extensie zonder deze bewaking in stilte + * pagina 1 van project A met pagina 2 van project B — of ziet een lege pagina en concludeert ten + * onrechte "klaar". `getImportSourceChunk`/`getImportSourceInfo` hebben dezelfde documentbinding, + * maar zijn doorgaans één aanroep; pagineren met meerdere aanroepen is de nieuwe risicoklasse. + * + * Vergelijkbaar met het `expectedDocId`-driftanker in `src/services/mcp/tools/runtime.ts`, maar + * hier expliciet door de AANROEPER meegegeven (geen sessiestate in de host) omdat de extensie-API + * stateless is per aanroep. + */ +export class ExtImportSourceDriftError extends Error { + constructor(expected: string, actual: string | null) { + super( + `Actief XER-brondocument is gewijzigd: verwacht bronproject '${expected}', nu ` + + (actual === null ? 'geen XER-document' : `'${actual}'`) + + ' — lees getImportSourceInfo() opnieuw en begin de paginering opnieuw.', + ); + this.name = 'ExtImportSourceDriftError'; + } +} + +/** Gooit `ExtImportSourceDriftError` wanneer de aanroeper een `expectedSourceProjectId` opgaf die + * niet overeenkomt met de bronselector van het huidige actieve document (`actual`, of `null` als + * het actieve document geen retained XER-bron heeft). Geen opgegeven verwachting ⇒ geen check. */ +export function assertNoImportSourceDrift(expected: string | undefined, actual: string | null): void { + if (expected !== undefined && expected !== actual) { + throw new ExtImportSourceDriftError(expected, actual); + } +} + +function documentViewFor( + archive: XerSourceArchive, + metadata: XerImportMetadata | null | undefined, + sourceProjectId: string, +): XerArchiveDocumentViewV1 | undefined { + return archive.diagnostics.documentViews[sourceProjectId] + ?? (metadata?.sourceProjectId === sourceProjectId ? { + sourceProjectId, + defaultCurrencyCode: metadata.defaultCurrencyCode, + tableReport: metadata.tableReport, + calendarIssues: metadata.calendarIssues, + enumFallbacks: metadata.enumFallbacks, + scheduleOptions: { + source: metadata.scheduleOptions.source, + retainedSource: metadata.scheduleOptions.retainedSource, + fallbacks: metadata.scheduleOptions.fallbacks, + diagnostics: metadata.scheduleOptions.diagnostics, + sourceRowIndexes: metadata.scheduleOptions.sourceRowIndexes, + }, + externalRelations: metadata.externalRelations, + externalLinks: metadata.externalLinks, + report: metadata.report, + ...(metadata.resources ? { + resources: { assignments: metadata.resources.assignments, issues: metadata.resources.issues }, + } : {}), + } : undefined); +} + +function selectedScheduleRows( + archive: XerSourceArchive, + view: XerArchiveDocumentViewV1 | undefined, +): readonly object[] { + const indexes = view?.scheduleOptions.sourceRowIndexes ?? []; + return indexes.flatMap((index) => { + const row = archive.readModel.scheduleOptionsSourceArchive.rows[index]; + return row ? [row] : []; + }); +} + +function sourceDataCounts(sourceData: Record): Record { + return Object.fromEntries(Object.entries(sourceData).map(([key, values]) => [key, values.length])); +} + +function catalogCounts( + archive: XerSourceArchive, + sourceProjectId: string, +): ExtImportSourceCatalogCounts { + const schedule = archive.readModel.scheduleOptionsSourceArchive; + const resources = archive.readModel.resourceCatalog; + const metadata = archive.readModel.metadataCatalog; + const taskRows = archive.readModel.taskSourceRowsByProject; + const currentProjectTaskProjections = metadata.taskProjectionsByProject[sourceProjectId]?.length ?? 0; + const totalTaskRows = Object.values(taskRows).reduce((total, rows) => total + rows.length, 0); + return { + scheduleOptions: { + sourceRows: schedule.rows.length, + unmatchedRows: schedule.unmatchedScheduleOptionsRowIndexes.length, + diagnostics: schedule.diagnostics.length, + }, + resources: { + resources: resources.resources.length, + identities: resources.identities.length, + rows: { + resources: resources.rows.resources.length, + roles: resources.rows.roles.length, + rates: resources.rows.rates.length, + curves: resources.rows.curves.length, + assignments: resources.rows.assignments.length, + }, + issues: resources.issues.length, + }, + metadata: { + activityCodeTypes: metadata.activityCodeTypes.length, + customFieldDefs: metadata.customFieldDefs.length, + taskProjections: metadata.taskProjections.length, + currentProjectTaskProjections, + issues: metadata.issues.length, + issueCounts: { ...metadata.issueCounts }, + sourceData: sourceDataCounts(metadata.sourceData), + }, + taskSourceRows: { + projectCount: Object.keys(taskRows).length, + totalRows: totalTaskRows, + currentProjectRows: taskRows[sourceProjectId]?.length ?? 0, + }, + }; +} + +function diagnosticsSummary( + archive: XerSourceArchive, + view: XerArchiveDocumentViewV1 | undefined, +): ExtImportSourceDiagnosticsSummary { + const file = archive.diagnostics.file; + return { + file: { + tableReport: { + encoding: file.tableReport.encoding, + endMarkerSeen: file.tableReport.endMarkerSeen, + issueCount: file.tableReport.issues.length, + unknownTableCount: file.tableReport.unknownTables.length, + unknownFieldCount: file.tableReport.unknownFields?.length ?? 0, + }, + scheduleOptionsDiagnosticCount: file.scheduleOptions.length, + relationResolutionIssueCount: file.relationResolutionIssues.length, + resourceCatalogIssueCount: file.resourceCatalogIssues.length, + metadataCatalogIssueCount: file.metadataCatalogIssues.length, + }, + document: { + calendarIssueCount: view?.calendarIssues.length ?? 0, + enumFallbackCount: view?.enumFallbacks.length ?? 0, + scheduleOptionsFallbackCount: view?.scheduleOptions.fallbacks.length ?? 0, + scheduleOptionsDiagnosticCount: view?.scheduleOptions.diagnostics.length ?? 0, + externalRelationCount: view?.externalRelations.length ?? 0, + externalLinkCount: view?.externalLinks.length ?? 0, + resourceAssignmentCount: view?.resources?.assignments.length ?? 0, + resourceIssueCount: view?.resources?.issues.length ?? 0, + }, + }; +} + +function scheduleOptionsSummary( + archive: XerSourceArchive, + view: XerArchiveDocumentViewV1 | undefined, +): ExtImportSourceScheduleOptionsSummary { + const schedule = archive.readModel.scheduleOptionsSourceArchive; + return { + source: view?.scheduleOptions.source ?? 'xer-defaults', + retainedSource: { ...(view?.scheduleOptions.retainedSource ?? {}) }, + fallbackCount: view?.scheduleOptions.fallbacks.length ?? 0, + diagnosticCount: view?.scheduleOptions.diagnostics.length ?? 0, + sourceRowCount: view?.scheduleOptions.sourceRowIndexes.length ?? 0, + unmatchedSourceRowCount: schedule.unmatchedScheduleOptionsRowIndexes.length, + }; +} + +export function toExtImportSourceInfo( + archive: XerSourceArchive, + metadata: XerImportMetadata | null | undefined, + sourceProjectId: string | null | undefined, +): ExtImportSourceInfo | null { + const selector = sourceProjectId ?? metadata?.sourceProjectId; + if (!selector) return null; + const view = documentViewFor(archive, metadata, selector); + const report = view?.report ?? archive.diagnostics.file.importReport; + const numberFormat: ExtImportSourceNumberFormat = { ...archive.readModel.numberFormat }; + return { + sourceFormat: archive.format, + sourceProjectId: selector, + selector: { kind: 'sourceProjectId', value: selector }, + archive: { + schemaVersion: archive.schemaVersion, + byteLength: archive.byteLength, + sha256: archive.sha256, + encoding: archive.encoding, + bom: archive.bom, + newline: archive.newline, + chunkSize: XER_SOURCE_ARCHIVE_CHUNK_BYTES, + chunkCount: archive.byteChunks.length, + }, + numberFormat, + diagnostics: diagnosticsSummary(archive, view), + importReport: copyReport(report), + scheduleOptions: scheduleOptionsSummary(archive, view), + catalogs: catalogCounts(archive, selector), + }; +} + +export function getExtImportSourceChunk(archive: XerSourceArchive, index: number): Uint8Array { + if (!Number.isSafeInteger(index) || index < 0 || index >= archive.byteChunks.length) { + throw new RangeError(`XER-bronchunk-index valt buiten 0..${Math.max(archive.byteChunks.length - 1, 0)}.`); + } + const encoded = archive.byteChunks[index]; + const binary = atob(encoded); + const result = new Uint8Array(binary.length); + for (let offset = 0; offset < binary.length; offset += 1) result[offset] = binary.charCodeAt(offset); + return result; +} + +function collectionRecords( + archive: XerSourceArchive, + sourceProjectId: string, + view: XerArchiveDocumentViewV1 | undefined, + collection: ExtImportSourceCollection, +): readonly object[] { + const readModel = archive.readModel; + const resourceRows = readModel.resourceCatalog.rows; + const metadataSource = readModel.metadataCatalog.sourceData; + const scheduleRows = selectedScheduleRows(archive, view); + switch (collection) { + case 'scheduleOptionsSourceRows': return scheduleRows; + case 'resourceCatalogResources': return readModel.resourceCatalog.resources; + case 'resourceCatalogIdentities': return readModel.resourceCatalog.identities; + case 'resourceCatalogResourceRows': return resourceRows.resources; + case 'resourceCatalogRoleRows': return resourceRows.roles; + case 'resourceCatalogRateRows': return resourceRows.rates; + case 'resourceCatalogCurveRows': return resourceRows.curves; + case 'resourceCatalogAssignmentRows': return resourceRows.assignments; + case 'resourceCatalogIssues': return readModel.resourceCatalog.issues; + case 'metadataActivityCodeTypes': return readModel.metadataCatalog.activityCodeTypes; + case 'metadataCustomFieldDefs': return readModel.metadataCatalog.customFieldDefs; + case 'metadataTaskProjections': return readModel.metadataCatalog.taskProjections; + case 'metadataIssues': return readModel.metadataCatalog.issues; + case 'metadataSourceActvtypeRows': return metadataSource.ACTVTYPE; + case 'metadataSourceActvcodeRows': return metadataSource.ACTVCODE; + case 'metadataSourceTaskactvRows': return metadataSource.TASKACTV; + case 'metadataSourceUdfTypeRows': return metadataSource.UDFTYPE; + case 'metadataSourceUdfValueRows': return metadataSource.UDFVALUE; + case 'metadataSourceMemotypeRows': return metadataSource.MEMOTYPE; + case 'metadataSourceTasknoteRows': return metadataSource.TASKNOTE; + case 'metadataSourceTaskmemoRows': return metadataSource.TASKMEMO; + case 'metadataSourceTaskNotesRows': return metadataSource.TASK_NOTES; + case 'metadataSourceDeferredUdfValueRows': return metadataSource.deferredUdfValues; + case 'metadataSourceUnknownUdfTypeRows': return metadataSource.unknownUdfTypes; + case 'taskSourceRows': return archive.readModel.taskSourceRowsByProject[sourceProjectId] ?? []; + default: throw new RangeError(`Onbekende XER-broncatalogus: ${String(collection)}`); + } +} + +export function getExtImportSourceCatalogPage( + archive: XerSourceArchive, + metadata: XerImportMetadata | null | undefined, + sourceProjectId: string | null | undefined, + collection: ExtImportSourceCollection, + options?: ExtImportSourcePageOptions, +): ExtImportSourceCatalogPage | null { + const selector = sourceProjectId ?? metadata?.sourceProjectId; + assertNoImportSourceDrift(options?.expectedSourceProjectId, selector ?? null); + if (!selector) return null; + const { offset: rawOffset, limit } = validatePageOptions(options); + const view = documentViewFor(archive, metadata, selector); + const records = collectionRecords(archive, selector, view, collection); + const total = records.length; + const offset = resolvePageOffset(rawOffset, total); + return { + collection, + sourceProjectId: selector, + offset, + limit, + total, + items: records.slice(offset, offset + limit).map(copyRecord), + }; +} diff --git a/src/extensions/extMappers.ts b/src/extensions/extMappers.ts index 15e1bb479..f64a43e9c 100644 --- a/src/extensions/extMappers.ts +++ b/src/extensions/extMappers.ts @@ -47,14 +47,20 @@ import type { // ── Kleine helpers (diepe kopie van geneste, mogelijk bevroren, waarden) ── -/** `SchedulingOptions`/`ExtSchedulingOptions` zijn structureel gelijk; één helper dekt beide - * richtingen. De geneste `criticalDefinition`/`floatPaths` MOETEN mee-gekopieerd worden — - * een kale spread zou daar bevroren store-referenties doorgeven (reviewbevinding pakket N). */ -function copySchedulingOptions(o: T): T { - const copy = { ...o }; - if (copy.criticalDefinition) copy.criticalDefinition = { ...copy.criticalDefinition }; - if (copy.floatPaths) copy.floatPaths = { ...copy.floatPaths }; - return copy; +/** Publieke scheduling-options worden veld-voor-veld gereconstrueerd. Dit is tegelijk een + * mutabiliteitsgrens en een beveiligingsgrens: runtime-objecten van JS-extensies kunnen extra + * interne `p6*`-sleutels dragen die niet in `ExtSchedulingOptions` staan. Een spread zou die + * ongemerkt als solverinvoer activeren. De native XER-lezer zet zulke bronopties rechtstreeks op + * het interne model en loopt dus niet door deze generieke extensie-invoer. */ +function publicSchedulingOptions(o: ExtSchedulingOptions): ExtSchedulingOptions { + return { + lagCalendar: o.lagCalendar, + criticalDefinition: o.criticalDefinition ? { ...o.criticalDefinition } : undefined, + totalFloatMode: o.totalFloatMode, + makeOpenEndedCritical: o.makeOpenEndedCritical, + nearCriticalThreshold: o.nearCriticalThreshold, + floatPaths: o.floatPaths ? { ...o.floatPaths } : undefined, + }; } function copyConstraint(c: TaskConstraint): ExtTaskConstraint { @@ -163,7 +169,7 @@ export function toExtProject(p: Project): ExtProject { statusDate: p.statusDate, progressMode: p.progressMode, defaultTaskDurationUnit: p.defaultTaskDurationUnit, - schedulingOptions: p.schedulingOptions ? copySchedulingOptions(p.schedulingOptions) : undefined, + schedulingOptions: p.schedulingOptions ? publicSchedulingOptions(p.schedulingOptions) : undefined, }; } @@ -183,7 +189,7 @@ export function fromExtProject(p: ExtProject): Project { statusDate: p.statusDate, progressMode: p.progressMode, defaultTaskDurationUnit: p.defaultTaskDurationUnit, - schedulingOptions: p.schedulingOptions ? copySchedulingOptions(p.schedulingOptions) : undefined, + schedulingOptions: p.schedulingOptions ? publicSchedulingOptions(p.schedulingOptions) : undefined, }; } @@ -204,6 +210,8 @@ export function toExtCalendar(c: WorkCalendar): ExtCalendar { workTime: c.workTime ? copyWorkTime(c.workTime) : undefined, shift: c.shift, workingExceptions: c.workingExceptions ? c.workingExceptions.map(copyWorkingException) : undefined, + p6Source: c.p6Source, + p6NonWorkPenaltyDates: c.p6NonWorkPenaltyDates ? [...c.p6NonWorkPenaltyDates] : undefined, }; } @@ -333,12 +341,23 @@ export function toExtTask(t: Task, customTaskType?: { id: string; name: string } // create-/update-paden (`fromExtTaskInput`) en de MCP-zetbaarheid (`taskFields.ts` REJECT_HINTS). mspTaskType: t.mspTaskType, effortDriven: t.effortDriven, + // X0/X12: P6/XER-herkomst is READ-ONLY voor extensies. `toExtTask` toont de bronvelden voor + // analyse; `fromExtTask` hieronder accepteert ze bewust niet als generieke invoer. + p6DurationType: t.p6DurationType, + p6ActivityType: t.p6ActivityType, + p6ProjectId: t.p6ProjectId, + p6TaskId: t.p6TaskId, + p6ExplicitTargetWindow: t.p6ExplicitTargetWindow, + p6CompletePctType: t.p6CompletePctType, + p6ExpectedFinish: t.p6ExpectedFinish, + p6SuspendResume: t.p6SuspendResume, timephasedContours: t.timephasedContours ? t.timephasedContours.map(c => ({ resourceUid: c.resourceUid, ...(c.resourceId !== undefined ? { resourceId: c.resourceId } : {}), periods: c.periods.map(p => ({ ...p })) })) : undefined, timephasedFinishFloor: t.timephasedFinishFloor, timephasedStartAnchor: t.timephasedStartAnchor, timephasedDurationWalks: t.timephasedDurationWalks ? t.timephasedDurationWalks.map(w => ({ ...w })) : undefined, parentId: t.parentId, childIds: [...t.childIds], + isSummary: t.isSummary, time: toExtTaskTime(t.time), resourceIds: [...t.resourceIds], color: t.color, @@ -379,12 +398,16 @@ export function fromExtTask(t: ExtTask): Task { // (`fromExtTaskInput`, extensie-API) blijven hier bewust buiten (leeskant-alleen-besluit F5). mspTaskType: t.mspTaskType, effortDriven: t.effortDriven, + // X12-herreview: de zeven P6/XER-velden zijn bronprovenance, geen publieke generieke invoer. + // De native XER-reader en het IFC-round-trippad materialiseren ze rechtstreeks op `Task`; + // een ongetypeerde extensiepayload mag via deze mapper geen P6-solverroute activeren. timephasedContours: t.timephasedContours ? t.timephasedContours.map(c => ({ resourceUid: c.resourceUid, ...(c.resourceId !== undefined ? { resourceId: c.resourceId } : {}), periods: c.periods.map(p => ({ ...p })) })) : undefined, timephasedFinishFloor: t.timephasedFinishFloor, timephasedStartAnchor: t.timephasedStartAnchor, timephasedDurationWalks: t.timephasedDurationWalks ? t.timephasedDurationWalks.map(w => ({ ...w })) : undefined, parentId: t.parentId, childIds: [...t.childIds], + isSummary: t.isSummary, time: fromExtTaskTime(t.time), resourceIds: [...t.resourceIds], color: t.color, @@ -433,6 +456,7 @@ export function fromExtTaskInput( if (input.manuallyScheduled !== undefined) out.manuallyScheduled = input.manuallyScheduled; if (input.parentId !== undefined) out.parentId = input.parentId; if (input.childIds !== undefined) out.childIds = [...input.childIds]; + if (input.isSummary !== undefined) out.isSummary = input.isSummary; if (input.time !== undefined) out.time = fromExtTaskTime(input.time); if (input.resourceIds !== undefined) out.resourceIds = [...input.resourceIds]; if (input.color !== undefined) out.color = input.color; @@ -542,6 +566,7 @@ export function fromExtTaskUpdates(updates: Partial): Partial { if (updates.manuallyScheduled !== undefined) out.manuallyScheduled = updates.manuallyScheduled; if (updates.parentId !== undefined) out.parentId = updates.parentId; if (updates.childIds !== undefined) out.childIds = [...updates.childIds]; + if (updates.isSummary !== undefined) out.isSummary = updates.isSummary; // T14b-vervolg: `fromExtTaskTimePatch`, NIET `fromExtTaskTime` — zie de docstring daarboven. `out.time` // is hier op TS-niveau een volledige `TaskTime`, maar dat is dezelfde bewuste afwijking als // `addTask`'s `partial.time`: de echte volledigheid wordt pas door `taskSlice.updateTask`'s @@ -573,6 +598,7 @@ export function toExtSequence(s: Sequence): ExtSequence { lagMinutes: s.lagMinutes, lagUnit: s.lagUnit, lagPercent: s.lagPercent, + p6StartAtPredecessorFinishBoundary: s.p6StartAtPredecessorFinishBoundary, }; } diff --git a/src/extensions/extTypes.ts b/src/extensions/extTypes.ts index aff85bda4..1ca8245c4 100644 --- a/src/extensions/extTypes.ts +++ b/src/extensions/extTypes.ts @@ -102,6 +102,11 @@ export interface ExtCalendar { shift?: 'FIRST' | 'SECOND' | 'THIRD' | 'USERDEFINED'; /** Dag-uitzonderingen die een dag WERKEND maken (fase 3.8, T2/T13). Afwezig ⇒ geen. */ workingExceptions?: ExtWorkingException[]; + /** Alleen-lezen P6/XER-herkomststempel. `toExtCalendar` toont hem voor analyse; + * `fromExtCalendar` accepteert hem nooit als generieke solverinvoer. */ + p6Source?: 'XER'; + /** Alleen-lezen P6/XER-vrije-dagprojecties. Een extensie-import kan ze niet activeren. */ + p6NonWorkPenaltyDates?: string[]; } // ── Taak ── @@ -215,6 +220,21 @@ export interface ExtTask { mspTaskType?: 'FIXED_UNITS' | 'FIXED_DURATION' | 'FIXED_WORK'; /** Z14b — MSP's "Effort Driven"-vlag bij .mpp-import. Puur data; voor de vertaal-/zetbaarheidsnuance zie `mspTaskType`. */ effortDriven?: boolean; + /** X0/X12 — P6's eigen Duration Type bij .xer-import. Uitsluitend read-model voor extensies: + * `toExtTask` toont het, maar geen enkel generiek from-extensionpad mag het terugschrijven. */ + p6DurationType?: 'DT_FixedDrtn' | 'DT_FixedDUR2' | 'DT_FixedRate' | 'DT_FixedQty'; + /** X0/X12 — P6's eigen Activity Type bij .xer-import. Zelfde read-onlygrens als `p6DurationType`. */ + p6ActivityType?: 'TT_Task' | 'TT_Rsrc' | 'TT_LOE' | 'TT_Mile' | 'TT_FinMile' | 'TT_WBS'; + p6ProjectId?: string; + p6TaskId?: string; + /** Read-only XER-provenance: de bron-TASK droeg een expliciet target start-/finishvenster. */ + p6ExplicitTargetWindow?: boolean; + p6CompletePctType?: 'CP_Drtn' | 'CP_Phys' | 'CP_Units'; + p6ExpectedFinish?: string; + /** X0/X12 — read-only herkomstvlag voor `time.resume`/`time.stop`: signaleert P6-suspend/ + * resume-herkomst (XER `suspend_date`/`resume_date`) i.p.v. de MSP-conventie. Alleen native + * XER-/IFC-paden zetten haar intern. Spiegelt {@link import('@/types/task').Task}.p6SuspendResume. */ + p6SuspendResume?: boolean; /** Z14b (eigenaarsprincipe 2026-08-18) — rauwe, gedecodeerde .mpp-contourperiodes; de bron ONDER * `splitGaps`, blijft ALTIJD staan (ook ná een bewerking die het Z8-venster invalideert). Puur * data; voor de vertaal-/zetbaarheidsnuance zie `mspTaskType`. Spiegelt {@link import('@/types/task'). @@ -233,6 +253,10 @@ export interface ExtTask { parentId: string | null; /** WBS-kinderen. */ childIds: string[]; + /** Expliciete samenvattingsidentiteit voor een lege WBS. `true` maakt ook zonder kinderen een + * summary; `false` verwijdert alleen die expliciete marker (taken mét kinderen blijven summary). + * Afwezig bij create/update laat de bestaande waarde en child-afleiding ongemoeid. */ + isSummary?: boolean; time: ExtTaskTime; resourceIds: string[]; color?: string; @@ -266,6 +290,9 @@ export interface ExtSequence { lagUnit?: 'WORKTIME' | 'ELAPSEDTIME'; /** Procentuele lag (% van voorgangerduur). Sluit lagDays uit. */ lagPercent?: number; + /** P6/XER-nul-lag-FS behoudt een expliciete voorganger-finishgrens als startrepresentatie. + * Leesmetadata: gewone extensies mogen dit niet via import of mutatie activeren. */ + readonly p6StartAtPredecessorFinishBoundary?: boolean; } // ── Resource ── @@ -353,6 +380,180 @@ export interface ExtFontProvider { // ── Importresultaat ── +/** Maximaal aantal records dat één broncataloguspagina teruggeeft. */ +export const EXT_IMPORT_SOURCE_PAGE_SIZE_MAX = 500; + +export type ExtImportSourceCollection = + | 'scheduleOptionsSourceRows' + | 'resourceCatalogResources' + | 'resourceCatalogIdentities' + | 'resourceCatalogResourceRows' + | 'resourceCatalogRoleRows' + | 'resourceCatalogRateRows' + | 'resourceCatalogCurveRows' + | 'resourceCatalogAssignmentRows' + | 'resourceCatalogIssues' + | 'metadataActivityCodeTypes' + | 'metadataCustomFieldDefs' + | 'metadataTaskProjections' + | 'metadataIssues' + | 'metadataSourceActvtypeRows' + | 'metadataSourceActvcodeRows' + | 'metadataSourceTaskactvRows' + | 'metadataSourceUdfTypeRows' + | 'metadataSourceUdfValueRows' + | 'metadataSourceMemotypeRows' + | 'metadataSourceTasknoteRows' + | 'metadataSourceTaskmemoRows' + | 'metadataSourceTaskNotesRows' + | 'metadataSourceDeferredUdfValueRows' + | 'metadataSourceUnknownUdfTypeRows' + | 'taskSourceRows'; + +/** Een record in een broncataloguspagina. De vorm is collection-specifiek en blijft een publieke + * DTO: er zit geen intern store-object of class-instance achter. */ +export type ExtImportSourceRecord = Readonly>; + +export interface ExtImportSourcePageOptions { + /** Nulgebaseerde positie; default 0. */ + offset?: number; + /** Aantal records; default 100, maximum `EXT_IMPORT_SOURCE_PAGE_SIZE_MAX`. */ + limit?: number; + /** + * Fail-closed documentdriftbewaking (her-review 2, P2): geef het `sourceProjectId` mee dat je + * van een eerdere `getImportSourceInfo()`/`getImportSourceCatalogPage()`-aanroep kreeg. Wijkt de + * bronselector van het ACTIEVE document af — bijvoorbeeld omdat de gebruiker tussen twee + * paginaverzoeken met `switchDocument` gewisseld is — dan gooit de aanroep een + * `ExtImportSourceDriftError` in plaats van stil een lege of verkeerde pagina terug te geven. + * Zonder deze optie is er GEEN driftbewaking: elke aanroep leest gewoon het actieve document, + * en een pagineersessie die "klaar" lijkt (een lege pagina) kan in werkelijkheid halverwege naar + * een ander project zijn gewisseld. + */ + expectedSourceProjectId?: string; +} + +export interface ExtImportSourceCatalogPage { + collection: ExtImportSourceCollection; + sourceProjectId: string; + offset: number; + limit: number; + total: number; + items: readonly ExtImportSourceRecord[]; +} + +export interface ExtImportSourceArchiveSummary { + schemaVersion: number; + byteLength: number; + sha256: string; + encoding: 'utf-8' | 'utf-16le' | 'utf-16be' | 'windows-1252'; + bom: 'utf-8' | 'utf-16le' | 'utf-16be' | 'none'; + newline: 'crlf' | 'lf' | 'cr' | 'mixed' | 'none'; + chunkSize: number; + chunkCount: number; +} + +export interface ExtImportSourceNumberFormat { + decimal: '.' | ','; + group: '.' | ',' | null; + source: 'currtype' | 'default'; + currencyCode: string; +} + +export interface ExtImportSourceReport { + projectsSeen: number; + documentsOpened: number; + emptyProjectsSkipped: number; + baselineProjectsExcluded: number; + baselinesMaterialized: number; + danglingBaselineReferences: number; + externalLinksPreserved: number; + baselineExclusionReverted: boolean; + baselineFallbackReasons: readonly ('self-reference' | 'cycle' | 'all-projects-baselines')[]; +} + +export interface ExtImportSourceDiagnosticsSummary { + file: { + tableReport: { + encoding: ExtImportSourceArchiveSummary['encoding']; + endMarkerSeen: boolean; + issueCount: number; + unknownTableCount: number; + unknownFieldCount: number; + }; + scheduleOptionsDiagnosticCount: number; + relationResolutionIssueCount: number; + resourceCatalogIssueCount: number; + metadataCatalogIssueCount: number; + }; + document: { + calendarIssueCount: number; + enumFallbackCount: number; + scheduleOptionsFallbackCount: number; + scheduleOptionsDiagnosticCount: number; + externalRelationCount: number; + externalLinkCount: number; + resourceAssignmentCount: number; + resourceIssueCount: number; + }; +} + +export interface ExtImportSourceScheduleOptionsSummary { + source: 'schedoptions' | 'xer-defaults'; + retainedSource: Readonly>; + fallbackCount: number; + diagnosticCount: number; + sourceRowCount: number; + unmatchedSourceRowCount: number; +} + +export interface ExtImportSourceCatalogCounts { + scheduleOptions: { + sourceRows: number; + unmatchedRows: number; + diagnostics: number; + }; + resources: { + resources: number; + identities: number; + rows: { + resources: number; + roles: number; + rates: number; + curves: number; + assignments: number; + }; + issues: number; + }; + metadata: { + activityCodeTypes: number; + customFieldDefs: number; + taskProjections: number; + currentProjectTaskProjections: number; + issues: number; + issueCounts: Readonly>; + sourceData: Readonly>; + }; + taskSourceRows: { + projectCount: number; + totalRows: number; + currentProjectRows: number; + }; +} + +/** Read-only XER-bronroute. Samenvatting en cataloguspagina's zijn verse DTO-kopieën; voor exact + * herstel gebruikt een extensie `getImportSourceChunk` met de digest uit `archive`. */ +export interface ExtImportSourceInfo { + sourceFormat: 'primavera-p6-xer'; + sourceProjectId: string; + selector: { kind: 'sourceProjectId'; value: string }; + archive: ExtImportSourceArchiveSummary; + numberFormat: ExtImportSourceNumberFormat; + diagnostics: ExtImportSourceDiagnosticsSummary; + importReport: ExtImportSourceReport; + scheduleOptions: ExtImportSourceScheduleOptionsSummary; + catalogs: ExtImportSourceCatalogCounts; +} + /** * Ext-facing importresultaat — wat een importer-handler oplevert en wat `api.data.loadProject` * verwacht. Alleen de kernvelden; de host mapt dit naar zijn interne (rijkere) `ImportResult` diff --git a/src/extensions/extensionApi.ts b/src/extensions/extensionApi.ts index 59fc897e2..364d5fa29 100644 --- a/src/extensions/extensionApi.ts +++ b/src/extensions/extensionApi.ts @@ -22,6 +22,12 @@ import { type ExtEventListener, } from '@/services/extensionEvents'; import { applyPermissionGuards } from './permissions'; +import { + assertNoImportSourceDrift, + getExtImportSourceCatalogPage, + getExtImportSourceChunk, + toExtImportSourceInfo, +} from './extImportSource'; import { toExtProject, toExtCalendar, @@ -119,6 +125,31 @@ export function createExtensionApi( getSequences: () => document.store.getState().sequences.map(toExtSequence), getResources: () => document.store.getState().resources.map(toExtResource), getAssignments: () => document.store.getState().assignments.map(toExtAssignment), + getImportSourceInfo: () => { + const state = document.store.getState(); + return state.xerSourceArchive + ? toExtImportSourceInfo(state.xerSourceArchive, state.xerImportMetadata, state.xerSourceProjectId) + : null; + }, + getImportSourceChunk: (index) => { + const archive = document.store.getState().xerSourceArchive; + return archive ? getExtImportSourceChunk(archive, index) : null; + }, + getImportSourceCatalogPage: (collection, options) => { + const state = document.store.getState(); + // `getExtImportSourceCatalogPage` bewaakt de drift zelf zodra er een archief is — maar als + // het actieve document NA een `switchDocument` helemaal geen XER-bron meer heeft, wordt die + // functie hier onder nooit aangeroepen (er is geen `archive` om aan door te geven). Zonder + // deze losse check zou een `expectedSourceProjectId` dan stil een `null` terugkrijgen i.p.v. + // de bedoelde `ExtImportSourceDriftError` — dezelfde stille-modus die de fix net oplost. + if (!state.xerSourceArchive) { + assertNoImportSourceDrift(options?.expectedSourceProjectId, null); + return null; + } + return getExtImportSourceCatalogPage( + state.xerSourceArchive, state.xerImportMetadata, state.xerSourceProjectId, collection, options, + ); + }, addTask: (task) => { const materialize = customTaskTypeToMaterialize(task.customTaskType); if (!materialize) return document.store.getState().addTask(fromExtTaskInput(task)); @@ -245,8 +276,9 @@ export function createExtensionApi( }; // Centrale permissie-afdwinging: wikkel de guarded methodes (events.*, ui.addRibbonButton, - // importers.*, pdfFonts.register) in checks volgens de tabel in permissions.ts. Kern-API - // (data.*, settings.*, assets.get, ui.showNotification) blijft ongewijzigd. + // importers.*, pdfFonts.register, data.getImportSource*) in checks volgens de tabel in + // permissions.ts. De rest van data.*, settings.*, assets.get en ui.showNotification blijven + // ongewijzigd kern-API. applyPermissionGuards(api as unknown as Record, extensionId, permissions); return api; diff --git a/src/extensions/permissions.ts b/src/extensions/permissions.ts index 4481543d8..643437667 100644 --- a/src/extensions/permissions.ts +++ b/src/extensions/permissions.ts @@ -30,7 +30,13 @@ export interface PermissionCheck { * • importers.* → 'backstage' (WARN) — compat: de gepubliceerde referentie-extensie * registreert een importer zonder 'backstage' te declareren. Hard afdwingen zou bestaande, * geïnstalleerde extensies breken; daarom nu warn-modus (deprecatiepad). Zie docs/extensions.md. - * • data.*, settings.*, ui.showNotification → null (kern-API, gedocumenteerd). + * • data.getImportSource* → 'importSource' (hard, DEFAULT-DENY) — deze drie methoden geven de + * volledige oorspronkelijke bronbytes van een geïmporteerd bestand terug (bv. de rauwe XER), + * inclusief velden die de importlaag bewust niet in het projectmodel materialiseert. Dat is + * wezenlijk breder dan de rest van `data.*` en dus expliciet GEEN kern-API — zie de + * privacyparagraaf in docs/extensions.md. Zonder de permissie gooit de guard vóórdat de + * onderliggende functie ooit wordt aangeroepen: er wordt geen enkele byte gelezen. + * • overige data.*, settings.*, ui.showNotification → null (kern-API, gedocumenteerd). * * 'filesystem'/'network' staan bewust NIET in deze tabel: ze hebben geen API-oppervlak en zijn in * same-context JS niet technisch afdwingbaar. Ze zijn puur installatie-informatief (getoonde intentie), @@ -70,6 +76,11 @@ export const API_PERMISSIONS: Record = { 'data.recalculate': null, 'data.batch': null, + // Read-only XER-bronroute — hard, DEFAULT-DENY (P1-privacyfix; zie de uitleg hierboven). + 'data.getImportSourceInfo': { perm: 'importSource', mode: 'throw' }, + 'data.getImportSourceChunk': { perm: 'importSource', mode: 'throw' }, + 'data.getImportSourceCatalogPage': { perm: 'importSource', mode: 'throw' }, + // Settings — kern-API. 'settings.get': null, 'settings.set': null, @@ -83,6 +94,7 @@ export const KNOWN_PERMISSIONS: readonly ExtensionPermission[] = [ 'filesystem', 'network', 'pdf-fonts', + 'importSource', ]; /** diff --git a/src/extensions/sdk.ts b/src/extensions/sdk.ts index 621af938d..74d4230c8 100644 --- a/src/extensions/sdk.ts +++ b/src/extensions/sdk.ts @@ -86,6 +86,7 @@ function buildInternalTask(partial: Partial & { name: string }): Task { priority: partial.priority ?? 0, parentId: partial.parentId ?? null, childIds: partial.childIds ?? [], + isSummary: partial.isSummary, time: partial.time ?? createDefaultTaskTime(start, partial.isMilestone ? 0 : 5), resourceIds: partial.resourceIds ?? [], color: partial.color, diff --git a/src/extensions/types.ts b/src/extensions/types.ts index f590838b8..d3cea470e 100644 --- a/src/extensions/types.ts +++ b/src/extensions/types.ts @@ -12,6 +12,10 @@ import type { ExtResource, ExtAssignment, ExtImportResult, + ExtImportSourceCatalogPage, + ExtImportSourceCollection, + ExtImportSourceInfo, + ExtImportSourcePageOptions, ExtRibbonTab, ExtFontProvider, } from './extTypes'; @@ -36,6 +40,12 @@ export type ExtensionCategory = * * • 'pdf-fonts' → api.pdfFonts.register (hard afgedwongen) — een CJK/glyf-font-provider voor de * vector-PDF-export registreren (zie src/services/pdf/fontRegistry.ts). + * • 'importSource' → api.data.getImportSourceInfo/getImportSourceChunk/getImportSourceCatalogPage + * (hard afgedwongen, DEFAULT-DENY) — geeft de VOLLEDIGE oorspronkelijke bronbytes van een + * geïmporteerd bestand (bv. de rauwe XER) terug, inclusief velden die de importlaag bewust niet + * in het projectmodel materialiseert (audit-/herkomstvelden, kosten, review-/locatievelden, …). + * Dat is wezenlijk breder dan de rest van `data.*` en dus expliciet GEEN kern-API — zie de + * privacyparagraaf in docs/extensions.md. * * NB: 'commands' bestond hiervoor maar had nooit een API-oppervlak en is per audit P16 verwijderd. * Manifesten die het (of een andere onbekende waarde) nog noemen, worden bij het activeren @@ -47,7 +57,8 @@ export type ExtensionPermission = | 'events' | 'filesystem' | 'network' - | 'pdf-fonts'; + | 'pdf-fonts' + | 'importSource'; export type ExtensionStatus = 'enabled' | 'disabled' | 'error' | 'loading'; @@ -171,6 +182,25 @@ export interface ExtensionApi { getSequences(): ExtSequence[]; getResources(): ExtResource[]; getAssignments(): ExtAssignment[]; + /** + * Kleine read-only XER-bronsamenvatting; null voor een niet-XER-document. Permissie + * `importSource` vereist — dit is GEEN kern-API-methode: zonder de permissie gooit deze + * methode vóórdat er data gelezen wordt. Zie de permissie-uitleg hierboven en docs/extensions.md. + */ + getImportSourceInfo(): ExtImportSourceInfo | null; + /** + * Eén verse kopie van een retained XER-bronchunk; null voor een niet-XER-document. Permissie + * `importSource` vereist. + */ + getImportSourceChunk(index: number): Uint8Array | null; + /** + * Pagineerbare, gekopieerde retained XER-catalogusdata; null voor een niet-XER-document. + * Permissie `importSource` vereist. + */ + getImportSourceCatalogPage( + collection: ExtImportSourceCollection, + options?: ExtImportSourcePageOptions, + ): ExtImportSourceCatalogPage | null; addTask(task: Partial & { name: string }): string; updateTask(id: string, updates: Partial): void; /** Retourneert het nieuwe relatie-id, of `null` wanneer de relatie geweigerd is. */ diff --git a/src/extensions/validation.ts b/src/extensions/validation.ts index 450cdb4f4..26610714e 100644 --- a/src/extensions/validation.ts +++ b/src/extensions/validation.ts @@ -8,6 +8,7 @@ import type { ParseResult, ReadyStoredExtension, } from './types'; +import { KNOWN_PERMISSIONS } from './permissions'; export type ManifestParseMode = 'fresh' | 'stored-legacy'; @@ -35,14 +36,12 @@ const EXTENSION_CATEGORIES: readonly ExtensionCategory[] = [ 'Other', ]; -const EXTENSION_PERMISSIONS: readonly ExtensionPermission[] = [ - 'ribbon', - 'backstage', - 'events', - 'filesystem', - 'network', - 'pdf-fonts', -]; +// GEEN eigen permissielijst hier: `permissions.ts` is de ene bron van waarheid (regressiebevinding +// her-review 2 — een tweede, handgekopieerde lijst hier liet 'importSource' onbereikbaar voor élke +// extensie, want elk installatiepad parseert met mode 'fresh' en `parsePermissions` weigert een +// niet-erkende permissie). `permissions.ts` importeert alleen `./types` en `appLog`, dus dit vormt +// geen cyclus. +const EXTENSION_PERMISSIONS: readonly ExtensionPermission[] = KNOWN_PERMISSIONS; const VERSION_PATTERN = /^[0-9]+(?:\.[0-9]+){0,3}$/; const EXTENSION_ID_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,127})$/; diff --git a/src/hooks/recoveryPaths.ts b/src/hooks/recoveryPaths.ts index 03208f973..ec274c591 100644 --- a/src/hooks/recoveryPaths.ts +++ b/src/hooks/recoveryPaths.ts @@ -4,8 +4,10 @@ // een plain/productie-build houdt de canonieke naam. // // Multi-document: er is één manifest (.documents.json) dat alle open documenten -// opsomt, elk met een eigen IFC-snapshot (..ifc). De oude losse -// .ifc wordt bij het opstarten nog herkend (terugval) en daarna opgeruimd. +// opsomt. Versie 1/2 verwees naar overschreven IFC-snapshots +// (..ifc); versie 3 verwijst naar IMMUTABLE generaties +// (.snapshot...ifc). De oude losse .ifc wordt bij +// het opstarten nog herkend (terugval) en daarna opgeruimd. export const recoveryBase = __OPS_DEV_INSTANCE__ ? `recovery.${__OPS_DEV_INSTANCE__}` : 'recovery'; /** Achtervoegsel van het halffabricaat van een atomaire schrijfactie (zie `saveTauri`). */ @@ -22,10 +24,14 @@ export interface RecoveryNames { manifest: string; /** Oude losse enkel-document-snapshot; wordt alleen nog gelezen. */ legacy: string; - /** Bestandsnaam van de snapshot van één document. */ + /** v1/v2-bestandsnaam van de overschreven snapshot van één document. */ ifcName(docId: string): string; + /** v3-bestandsnaam van één immutable documentgeneratie. */ + generationIfcName(docId: string, generation: string): string; /** Doc-id als `name` EXACT een snapshot van deze base is, anders `null`. */ snapshotDocId(name: string): string | null; + /** Doc-id alleen voor een v1/v2-stabiele snapshot (voor manifestloze terugval). */ + stableSnapshotDocId(name: string): string | null; /** Hoort deze bestandsnaam bij deze base? (snapshot, manifest, legacy of hun `.tmp`) */ isOwnFile(name: string): boolean; } @@ -53,20 +59,28 @@ export interface RecoveryNames { */ export function recoveryNames(base: string): RecoveryNames { const snapshotRe = new RegExp(`^${escapeRe(base)}\\.([^.]+)\\.ifc$`); + const generationSnapshotRe = new RegExp(`^${escapeRe(base)}\\.snapshot\\.([^.]+)\\.([^.]+)\\.ifc$`); const manifest = `${base}.documents.json`; const legacy = `${base}.ifc`; - const snapshotDocId = (name: string): string | null => { + const stableSnapshotDocId = (name: string): string | null => { const m = snapshotRe.exec(name); return m ? m[1] : null; }; + const snapshotDocId = (name: string): string | null => { + const generation = generationSnapshotRe.exec(name); + if (generation) return generation[1] ?? null; + return stableSnapshotDocId(name); + }; return { base, manifest, legacy, ifcName: (docId: string) => `${base}.${docId}.ifc`, + generationIfcName: (docId: string, generation: string) => `${base}.snapshot.${docId}.${generation}.ifc`, snapshotDocId, + stableSnapshotDocId, isOwnFile: (name: string) => { const bare = name.endsWith(recoveryTmpSuffix) ? name.slice(0, -recoveryTmpSuffix.length) @@ -89,15 +103,30 @@ export interface RecoveryManifestDoc { ifc: string; filePath: string | null; isDirty: boolean; + /** + * v4: stond dit document bij het schrijven van de snapshot in "datums zoals opgeslagen" + * (issue #63 / XER laag 3)? Crashherstel is het HERVATTEN van een sessie, geen heropening, dus + * die weergavestand hoort terug te komen zoals hij was — en dat is een FEIT dat je opschrijft, + * geen heuristiek die je achteraf uit de datums probeert af te leiden (critreview laag 3, + * bevindingen 2 en 3). + * + * OPTIONEEL getypeerd, met dezelfde regel als `ownerId`/`heartbeatAt`: een v1–v3-manifest staat + * op de schijf van iedereen die de vorige versie draaide en MOET leesbaar blijven. Ontbreekt het + * veld, dan geldt `false` — het bestaande #63-aanbod, niet de modus. + */ + datesAsRecorded?: boolean; } export interface RecoveryManifest { /** * 1 = zonder eigenaarschapsvelden (t/m de multi-document-release), 2 = met `ownerId`/ - * `heartbeatAt`. Een v1-manifest MOET leesbaar blijven: het staat op de schijf van iedereen die - * de vorige versie draaide, en dat weigeren betekent dataverlies bij de eerste start na de - * update. Beide velden zijn daarom optioneel getypeerd en het versienummer wordt nergens als - * poort gebruikt — alleen als informatie. + * `heartbeatAt`, 3 = immutable generatie-snapshots met het manifest als commitpoint, 4 = per + * document de modusvlag `datesAsRecorded` als manifestmetadata (XER-etappe laag 3; een oud + * manifest zonder vlag leest als `false` = alleen aanbieden). + * Een v1/v2-manifest MOET leesbaar blijven: het staat op de schijf van iedereen die de vorige + * versie draaide, en dat weigeren betekent dataverlies bij de eerste start na de update. + * Beide eigenaarschapsvelden zijn daarom optioneel getypeerd en het versienummer wordt nergens + * als leespoort gebruikt — alleen als informatie. */ version: number; activeDocumentId: string | null; diff --git a/src/hooks/useAutoSave.ts b/src/hooks/useAutoSave.ts index 4b527f351..24eb84328 100644 --- a/src/hooks/useAutoSave.ts +++ b/src/hooks/useAutoSave.ts @@ -3,7 +3,8 @@ import { useAppStore } from '@/state/appStore'; import { isTauri } from '@/utils/platform'; import { writeIFC } from '@/services/ifc/ifcWriter'; import { buildWriteIFCInput } from '@/state/ifcSaveInput'; -import { saveRecovery, type RecoveryDocContent } from '@/services/recovery/recoveryStore'; +import { RecoveryDeltaTracker, type RecoverySourceDocument } from '@/services/recovery/recoveryDelta'; +import { saveRecovery } from '@/services/recovery/recoveryStore'; import { canWriteToRefWithoutPrompt, saveToRefWithoutPrompt, type FileRef } from '@/services/fileAccess'; import { actualAutoSaveDelay, createActualAutoSaveController, type ActualAutoSaveCandidate } from '@/services/actualAutosave/actualAutoSave'; import { isProjectFileWriteBusy, runProjectFileWrite } from '@/services/fileAccess/writeCoordinator'; @@ -26,9 +27,10 @@ export function useAutoSave(autoSaveEnabled: MutableRefObject): void { useEffect(() => { let saving = false; let pending = false; - // Cache van de laatst-geserialiseerde IFC per document-id, meegaand over de hele hook-levensduur. - // Zo hoeven niet-gewijzigde documenten hun (dure) writeIFC niet elke tick opnieuw te draaien. - const ifcCache = new Map(); + // `RecoveryDeltaTracker` bewaart serialisatie los van de gepersisteerde basis: een mislukte + // storage-transaction mag hergebruik van IFC-tekst toelaten, maar nooit doen alsof de bron al + // veilig op schijf staat. + const recoveryDelta = new RecoveryDeltaTracker(); const runAutoSave = async () => { // Wacht tot de recovery-keuze gemaakt is: anders zou deze schrijfactie de @@ -39,28 +41,34 @@ export function useAutoSave(autoSaveEnabled: MutableRefObject): void { if (saving) { pending = true; return; } const state = useAppStore.getState(); const docs = state.getOpenDocumentPayloads(); - if (!docs.some((d) => d.payload.isDirty)) return; + // Een verse, volledig schone sessie hoeft geen crashsnapshot te maken. Vanaf de eerste + // echte save blijven actieve-tab- en metadatawijzigingen echter wél bewaard, ook als geen + // document op dat moment dirty is. `isDirty` is dus hoogstens een eerste-eligibilityflag, + // nooit de identiteit van IFC-inhoud. + if (!docs.some((d) => d.payload.isDirty) && !recoveryDelta.hasPersistedSnapshot) return; saving = true; try { - // Bouw het options-object via de gedeelde helper (pakket R1) zodat dit pad niet - // opnieuw uit de pas loopt met de andere state→IFC-callsites. - const openIds = new Set(docs.map((d) => d.id)); - const recDocs: RecoveryDocContent[] = docs.map(({ id, payload }) => { - let ifc = ifcCache.get(id); - // Herserialiseer alleen als het document gewijzigd is (isDirty) of nog nooit geserialiseerd is. - // Niet-gewijzigde, al-gecachte documenten hergebruiken hun IFC — hun inhoud is per definitie - // ongewijzigd, dus de recovery-snapshot blijft correct (saveRecovery schrijft nog steeds álle - // documenten, dus de herstel-correctheid verandert niet; alleen de dure writeIFC wordt overgeslagen). - if (payload.isDirty || ifc === undefined) { - ifc = writeIFC(buildWriteIFCInput(payload)); - ifcCache.set(id, ifc); - } - return { id, ifc, filePath: payload.filePath, isDirty: payload.isDirty }; - }); - // Ruim cache-entries op van documenten die niet meer open zijn (voorkomt onbegrensde groei). - for (const cachedId of ifcCache.keys()) if (!openIds.has(cachedId)) ifcCache.delete(cachedId); - // Backend-keuze (Tauri-bestanden of IndexedDB) zit in recoveryStore. - await saveRecovery(state.activeDocumentId, recDocs); + // Vergelijk de echte IFC-bronvelden (Immer-referenties) en niet `isDirty`: zo krijgt één + // taakbewerking één volledige IFC-upsert, terwijl een actieve-tabwissel manifest-only is. + const recoveryDocs: RecoverySourceDocument[] = docs.map(({ id, payload }) => ({ + id, + source: payload, + filePath: payload.filePath, + isDirty: payload.isDirty, + // "Datums zoals opgeslagen" is een weergavestand, geen IFC-inhoud: hij reist als + // manifestmetadata mee zodat crashherstel hem terugzet i.p.v. hem te raden. + datesAsRecorded: payload.datesAsRecorded, + })); + const recoverySave = recoveryDelta.prepare( + state.activeDocumentId, + recoveryDocs, + (source) => writeIFC(buildWriteIFCInput(source)), + ); + if (!recoverySave) return; + // Backend-keuze (Tauri-bestanden of IndexedDB) zit in recoveryStore. De volledige + // documentlijst is manifestmetadata; alleen `upserts` bevat zware IFC-teksten. + await saveRecovery(recoverySave); + recoveryDelta.commit(state.activeDocumentId, recoveryDocs); } catch (err) { console.error('Auto-save failed:', err); // dedupeKey is hier niet optioneel: de auto-save probeert het elke ~10 s opnieuw, dus diff --git a/src/hooks/useRecoveryRestore.ts b/src/hooks/useRecoveryRestore.ts index aae6b013b..f726fb287 100644 --- a/src/hooks/useRecoveryRestore.ts +++ b/src/hooks/useRecoveryRestore.ts @@ -1,7 +1,7 @@ import { useEffect, useRef, useState, type MutableRefObject } from 'react'; import { useTranslation } from 'react-i18next'; import { useAppStore } from '@/state/appStore'; -import { readIFC } from '@/services/ifc/ifcReader'; +import { readIFCWithXerReconstruction } from '@/services/formatRegistry'; import { documentTitle } from '@/utils/documents'; import type { RecoveryEntry } from '@/components/dialogs/RecoveryDialog'; import { recoveryInputFromParsed, type RecoveryDocInput } from '@/state/documentContract'; @@ -69,13 +69,16 @@ export function useRecoveryRestore(): RecoveryRestore { let failed = 0; for (const d of loaded.docs) { try { - const parsed = readIFC(d.ifc, buildImportLabels(startupTRef.current)); + const parsed = await readIFCWithXerReconstruction( + d.ifc, buildImportLabels(startupTRef.current), + ); // Welke velden bij crashherstel meegaan bepaalt `recoveryInputFromParsed` (bevinding // K3) — deze hook houdt bewust geen veldkennis. restored.push(recoveryInputFromParsed(parsed, { id: d.id, filePath: d.filePath, isDirty: d.isDirty, + datesAsRecorded: d.datesAsRecorded, })); entries.push({ id: d.id, diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index 0a5749e67..4f44c96ea 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -6,6 +6,108 @@ "notifications": { "dismiss": "إغلاق الإشعار", "openFailed": "تعذّر فتح الملف", + "xerInvalidInput": "يتطلب استيراد XER وحدات البايت الأصلية للملف.", + "xerInvalidFile": "هذا ليس ملف XER صالحًا أو مدعومًا.", + "xerInvalidEncoding": "ترميز النص في ملف XER هذا غير صالح أو مبتور.", + "xerDuplicateTable": "يحتوي ملف XER على جدول مكرر.", + "xerMissingRequiredColumns": "يفتقد جدول XER إلى أعمدة مطلوبة.", + "xerMissingRequiredValue": "يفتقد سجل XER إلى قيمة مطلوبة.", + "xerAmbiguousDecimal": "لا يمكن تحديد صيغة الأرقام العشرية في ملف XER هذا بشكل موثوق.", + "xerInvalidNumberFormat": "تنسيق الأرقام في ملف XER هذا غير صالح.", + "xerInvalidNumber": "يحتوي ملف XER على رقم غير صالح.", + "xerSingleProjectRequired": "يدعم هذا الاستيراد مشروع P6 واحدًا فقط لكل ملف XER.", + "xerEmptyProject": "لا يحتوي مشروع P6 في ملف XER هذا على أنشطة.", + "xerDuplicateId": "يحتوي ملف XER على معرّف مكرر لـ WBS أو نشاط أو علاقة.", + "xerAmbiguousLocalRelation": "لا يمكن إسناد علاقة XER غير محددة النطاق بشكل قاطع إلى مشروع محلي واحد.", + "xerDanglingLocalRelation": "تشير علاقة XER محلية إلى نشاط مفقود.", + "xerEnumFallback": "XER {{family}}: الرمز «{{token}}» غير معروف؛ استُخدمت القيمة «{{fallback}}».", + "xerImportOpened_zero": "تم فتح ملف XER: لا توجد مستندات مشروع.", + "xerImportOpened_one": "تم فتح ملف XER: مستند مشروع واحد.", + "xerImportOpened_two": "تم فتح ملف XER: مستندا مشروع.", + "xerImportOpened_few": "تم فتح ملف XER: {{count}} مستندات مشروع.", + "xerImportOpened_many": "تم فتح ملف XER: {{count}} مستند مشروع.", + "xerImportOpened_other": "تم فتح ملف XER: {{count}} من مستندات المشروع.", + "xerImportProjectsSeen_zero": "لم يُعثر على أي مشروع.", + "xerImportProjectsSeen_one": "عُثر على مشروع واحد.", + "xerImportProjectsSeen_two": "عُثر على مشروعين.", + "xerImportProjectsSeen_few": "عُثر على {{count}} مشاريع.", + "xerImportProjectsSeen_many": "عُثر على {{count}} مشروعًا.", + "xerImportProjectsSeen_other": "عُثر على {{count}} مشروع.", + "xerImportEmptyProjectsSkipped_zero": "لم يتم تجاوز أي مشروع فارغ.", + "xerImportEmptyProjectsSkipped_one": "تم تجاوز مشروع فارغ واحد.", + "xerImportEmptyProjectsSkipped_two": "تم تجاوز مشروعين فارغين.", + "xerImportEmptyProjectsSkipped_few": "تم تجاوز {{count}} مشاريع فارغة.", + "xerImportEmptyProjectsSkipped_many": "تم تجاوز {{count}} مشروعًا فارغًا.", + "xerImportEmptyProjectsSkipped_other": "تم تجاوز {{count}} مشروع فارغ.", + "xerImportBaselineProjectsExcluded_zero": "لم يُستبعد أي مشروع خط أساس.", + "xerImportBaselineProjectsExcluded_one": "استُبعد مشروع خط أساس واحد.", + "xerImportBaselineProjectsExcluded_two": "استُبعد مشروعا خط أساس.", + "xerImportBaselineProjectsExcluded_few": "استُبعدت {{count}} مشاريع خط أساس.", + "xerImportBaselineProjectsExcluded_many": "استُبعد {{count}} مشروع خط أساس.", + "xerImportBaselineProjectsExcluded_other": "استُبعد {{count}} مشروع خط أساس.", + "xerImportBaselinesMaterialized_zero": "لم يُكوّن أي خط أساس.", + "xerImportBaselinesMaterialized_one": "كُوّن خط أساس واحد.", + "xerImportBaselinesMaterialized_two": "كُوّن خطا أساس.", + "xerImportBaselinesMaterialized_few": "كُوّنت {{count}} خطوط أساس.", + "xerImportBaselinesMaterialized_many": "كُوّن {{count}} خط أساس.", + "xerImportBaselinesMaterialized_other": "كُوّن {{count}} خط أساس.", + "xerImportDanglingBaselineReferences_zero": "لم يُتجاهل أي مرجع معلق لخط الأساس.", + "xerImportDanglingBaselineReferences_one": "تُجاهل مرجع معلق واحد لخط الأساس.", + "xerImportDanglingBaselineReferences_two": "تُجاهل مرجعان معلقان لخط الأساس.", + "xerImportDanglingBaselineReferences_few": "تُجاهلت {{count}} مراجع معلقة لخط الأساس.", + "xerImportDanglingBaselineReferences_many": "تُجاهل {{count}} مرجع معلق لخط الأساس.", + "xerImportDanglingBaselineReferences_other": "تُجاهل {{count}} مرجع معلق لخط الأساس.", + "xerImportBaselineFallback": "استُخدم مسار احتياطي وقائي لخط الأساس.", + "xerImportExternalLinks_zero": "لم يُحفظ أي رابط خارجي.", + "xerImportExternalLinks_one": "حُفظ رابط خارجي واحد.", + "xerImportExternalLinks_two": "حُفظ رابطان خارجيان.", + "xerImportExternalLinks_few": "حُفظت {{count}} روابط خارجية.", + "xerImportExternalLinks_many": "حُفظ {{count}} رابط خارجي.", + "xerImportExternalLinks_other": "حُفظ {{count}} رابط خارجي.", + "xerImportEncoding": "ترميز النص المحدد: {{encoding}}.", + "xerImportParserIssues_zero": "لا توجد ملاحظات للمحلل.", + "xerImportParserIssues_one": "ملاحظة واحدة للمحلل.", + "xerImportParserIssues_two": "ملاحظتان للمحلل.", + "xerImportParserIssues_few": "{{count}} ملاحظات للمحلل.", + "xerImportParserIssues_many": "{{count}} ملاحظة للمحلل.", + "xerImportParserIssues_other": "{{count}} ملاحظة للمحلل.", + "xerImportCalendarIssues_zero": "لا توجد ملاحظات للتقويم.", + "xerImportCalendarIssues_one": "ملاحظة تقويم واحدة.", + "xerImportCalendarIssues_two": "ملاحظتا تقويم.", + "xerImportCalendarIssues_few": "{{count}} ملاحظات للتقويم.", + "xerImportCalendarIssues_many": "{{count}} ملاحظة للتقويم.", + "xerImportCalendarIssues_other": "{{count}} ملاحظة للتقويم.", + "xerImportNumberIssues_zero": "لا توجد مشكلات في تنسيق الأرقام.", + "xerImportNumberIssues_one": "مشكلة واحدة في تنسيق الأرقام.", + "xerImportNumberIssues_two": "مشكلتا تنسيق أرقام.", + "xerImportNumberIssues_few": "{{count}} مشكلات في تنسيق الأرقام.", + "xerImportNumberIssues_many": "{{count}} مشكلة في تنسيق الأرقام.", + "xerImportNumberIssues_other": "{{count}} مشكلة في تنسيق الأرقام.", + "xerImportEnumFallbacks_zero": "لا يوجد رجوع في التعدادات.", + "xerImportEnumFallbacks_one": "رجوع واحد في التعدادات.", + "xerImportEnumFallbacks_two": "رجوعان في التعدادات.", + "xerImportEnumFallbacks_few": "{{count}} حالات رجوع في التعدادات.", + "xerImportEnumFallbacks_many": "{{count}} حالة رجوع في التعدادات.", + "xerImportEnumFallbacks_other": "{{count}} حالة رجوع في التعدادات.", + "xerImportUnsupportedSemantics_zero": "لم تستخدم أي إعدادات P6 بديلًا آمنًا.", + "xerImportUnsupportedSemantics_one": "إعداد تخطيط P6 واحد استخدم بديلًا آمنًا.", + "xerImportUnsupportedSemantics_two": "إعدادا تخطيط P6 استخدما بديلًا آمنًا.", + "xerImportUnsupportedSemantics_few": "{{count}} إعدادات تخطيط P6 استخدمت بدائل آمنة.", + "xerImportUnsupportedSemantics_many": "{{count}} إعداد تخطيط P6 استخدم بدائل آمنة.", + "xerImportUnsupportedSemantics_other": "{{count}} إعداد تخطيط P6 استخدم بدائل آمنة.", + "xerImportDatesAsRecorded_zero": "لا توجد مهام تعرض التواريخ كما سجّلها Primavera.", + "xerImportDatesAsRecorded_one": "مهمة واحدة تعرض التاريخ كما سجّله Primavera (لم تُعَد الحسابات).", + "xerImportDatesAsRecorded_two": "مهمتان تعرضان التواريخ كما سجّلها Primavera (لم تُعَد الحسابات).", + "xerImportDatesAsRecorded_few": "{{count}} مهام تعرض التواريخ كما سجّلها Primavera (لم تُعَد الحسابات).", + "xerImportDatesAsRecorded_many": "{{count}} مهمة تعرض التواريخ كما سجّلها Primavera (لم تُعَد الحسابات).", + "xerImportDatesAsRecorded_other": "{{count}} مهمة تعرض التواريخ كما سجّلها Primavera (لم تُعَد الحسابات).", + "xerImportDatesAsRecordedOffer_zero": "لا توجد مهام تختلف عن التواريخ المسجلة في الملف.", + "xerImportDatesAsRecordedOffer_one": "مهمة واحدة تختلف عن التواريخ المسجلة في الملف — يمكنك عرضها.", + "xerImportDatesAsRecordedOffer_two": "مهمتان تختلفان عن التواريخ المسجلة في الملف — يمكنك عرضها.", + "xerImportDatesAsRecordedOffer_few": "{{count}} مهام تختلف عن التواريخ المسجلة في الملف — يمكنك عرضها.", + "xerImportDatesAsRecordedOffer_many": "{{count}} مهمة تختلف عن التواريخ المسجلة في الملف — يمكنك عرضها.", + "xerImportDatesAsRecordedOffer_other": "{{count}} مهمة تختلف عن التواريخ المسجلة في الملف — يمكنك عرضها.", + "xerExportLoss": "يؤدي التصدير إلى {{format}} إلى فقدان معلومات مصدر XER.", "mppLegacy": "يستخدم ملف .mpp هذا تنسيقًا قديمًا (Project 2007 أو أقدم). صدّره من MS Project بصيغة XML (ملف ← حفظ باسم ← XML) ثم افتح ذلك الملف.", "mppEncrypted": "ملف .mpp هذا محمي بكلمة مرور. صدّره من MS Project بصيغة XML (ملف ← حفظ باسم ← XML) ثم افتح ذلك الملف.", "saveFailed": "تعذّر الحفظ", @@ -838,9 +940,24 @@ "offer_few": "نقلت إعادة الحساب {{count}} من أصل {{total}} مهمة عن التواريخ المسجلة في الملف.", "offer_many": "نقلت إعادة الحساب {{count}} من أصل {{total}} مهمة عن التواريخ المسجلة في الملف.", "offer_other": "نقلت إعادة الحساب {{count}} من أصل {{total}} مهمة عن التواريخ المسجلة في الملف.", + "activeCount_zero": "تشاهد الجدول كما سجّله Primavera؛ لن تتغير أي مهمة عند إعادة الحساب.", + "activeCount_one": "تشاهد الجدول كما سجّله Primavera؛ ستنتقل مهمة واحدة عند إعادة الحساب.", + "activeCount_two": "تشاهد الجدول كما سجّله Primavera؛ ستنتقل مهمتان عند إعادة الحساب.", + "activeCount_few": "تشاهد الجدول كما سجّله Primavera؛ ستنتقل {{count}} مهام عند إعادة الحساب.", + "activeCount_many": "تشاهد الجدول كما سجّله Primavera؛ ستنتقل {{count}} مهمة عند إعادة الحساب.", + "activeCount_other": "تشاهد الجدول كما سجّله Primavera؛ ستنتقل {{count}} مهمة عند إعادة الحساب.", + "activeCountNeutral_zero": "تشاهد التواريخ كما سُجّلت في الملف؛ لن تتغير أي مهمة عند إعادة الحساب.", + "activeCountNeutral_one": "تشاهد التواريخ كما سُجّلت في الملف؛ ستنتقل مهمة واحدة عند إعادة الحساب.", + "activeCountNeutral_two": "تشاهد التواريخ كما سُجّلت في الملف؛ ستنتقل مهمتان عند إعادة الحساب.", + "activeCountNeutral_few": "تشاهد التواريخ كما سُجّلت في الملف؛ ستنتقل {{count}} مهام عند إعادة الحساب.", + "activeCountNeutral_many": "تشاهد التواريخ كما سُجّلت في الملف؛ ستنتقل {{count}} مهمة عند إعادة الحساب.", + "activeCountNeutral_other": "تشاهد التواريخ كما سُجّلت في الملف؛ ستنتقل {{count}} مهمة عند إعادة الحساب.", "show": "عرض التواريخ المسجلة", "active": "أنت تشاهد التواريخ كما سُجّلت في الملف. لم تتم إعادة أي حساب.", - "recalculate": "إعادة الحساب" + "recalculate": "إعادة الحساب", + "markDeviates": "يختلف", + "markPartlyUnrecorded": "غير مسجل جزئيًا", + "notRecorded": "غير مسجل" }, "duration": { "suffixDay": "ي", @@ -1043,6 +1160,7 @@ "declaredHeading": "ما يصرّح هذا الامتداد باستخدامه", "declaredNote": "هذا إقرار من المؤلف وليس قيدًا — تستطيع الشيفرة أن تفعل أكثر على أي حال.", "declaredNone": "لا شيء مُصرّح به.", + "permImportSource": "importSource — بايتات المصدر الأصلية الكاملة لكل ملف مستورد (مثلاً ملف Primavera الخام)، بما فيها الحقول التي لا تصل إلى المشروع أبداً.", "cancel": "عدم التثبيت", "confirm": "تثبيت" } diff --git a/src/i18n/locales/ar/report.json b/src/i18n/locales/ar/report.json index d804eb60f..73687f57e 100644 --- a/src/i18n/locales/ar/report.json +++ b/src/i18n/locales/ar/report.json @@ -147,6 +147,7 @@ "notCalculatedNote": "لم يُحسب بعد — اضغط احسب (F5) للحصول على التواريخ والفوائض.", "staleNote": "تغيّر الجدول منذ آخر حساب — اضغط احسب (F5) للحصول على القيم الحالية.", "statusDateMissingNote": "لم يُحدَّد تاريخ الحالة — يستخدم التقرير تاريخ اليوم ({{date}}).", + "recordedDatesNote": "عرض «التواريخ كما حُفظت»: يعرض هذا التقرير التواريخ من الملف المصدر وليس نتيجة حساب. أواخر البدء/الانتهاء والفوائض التي لم يسجّلها الملف تظهر هنا فارغة أو 0.", "period": "الفترة", "statusDate": "تاريخ الحالة", "common": { diff --git a/src/i18n/locales/ar/task.json b/src/i18n/locales/ar/task.json index 2ea68a238..d4a87f1cd 100644 --- a/src/i18n/locales/ar/task.json +++ b/src/i18n/locales/ar/task.json @@ -76,7 +76,7 @@ "durationType": "نوع المدة", "earlyFinish": "النهاية المبكرة", "earlyStart": "البداية المبكرة", - "effortDriven": "مدفوع بالجهد", + "effortDriven": "مدفوع بالجهد", "explicitSummary": "مهمة تلخيص صريحة", "externalRelationData": "بيانات العلاقات الخارجية", "floatPath": "مسار الفائض", "freeFloat": "الفائض الحر", @@ -97,8 +97,10 @@ "nearCritical": "شبه حرج", "noteData": "بيانات الملاحظات", "notes": "ملاحظات", + "p6Provenance": "مصدر Primavera P6", "parentId": "معرّف المهمة الأصلية", "priority": "أولوية تسوية الموارد", + "recordedSource": "المصدر (التواريخ المسجلة)", "remainingMinutes": "المتبقي (بالدقائق)", "remainingTime": "المتبقي (أيام عمل)", "resourceIds": "معرّفات الموارد", @@ -340,6 +342,10 @@ "timephasedActive": "تتبع توزيع الساعات من MS Project", "timephasedLost": "لم يعد توزيع الساعات من MS Project مطبقًا بعد التعديل — يبقى التوزيع الأصلي محفوظًا في الملف", "timephasedContoured": "توزيع ساعات خاص", + "recordedDatesActive": "يعرض التواريخ الخاصة التي سجّلها Primavera لهذه المهمة", + "recordedDatesActiveNeutral": "يعرض التواريخ كما سُجّلت في الملف لهذه المهمة", + "recordedDatesDeviates": "يختلف عن التواريخ المسجلة", + "recordedDatesPartlyUnrecorded": "التسجيل غير مكتمل جزئيًا — راجع أعمدة التأخر/الفائض", "completion": "التقدم (%)", "progress": { "actualStart": "البداية الفعلية", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 324243024..26024c567 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -6,6 +6,52 @@ "notifications": { "dismiss": "Benachrichtigung schließen", "openFailed": "Datei konnte nicht geöffnet werden", + "xerInvalidInput": "Der XER-Import benötigt die ursprünglichen Dateibytes.", + "xerInvalidFile": "Dies ist keine gültige oder unterstützte XER-Datei.", + "xerInvalidEncoding": "Die Textkodierung dieser XER-Datei ist ungültig oder abgeschnitten.", + "xerDuplicateTable": "Die XER-Datei enthält eine doppelte Tabelle.", + "xerMissingRequiredColumns": "In einer XER-Tabelle fehlen erforderliche Spalten.", + "xerMissingRequiredValue": "In einem XER-Datensatz fehlt ein erforderlicher Wert.", + "xerAmbiguousDecimal": "Die Dezimalschreibweise dieser XER-Datei kann nicht zuverlässig bestimmt werden.", + "xerInvalidNumberFormat": "Diese XER-Datei verwendet ein ungültiges Zahlenformat.", + "xerInvalidNumber": "Die XER-Datei enthält eine ungültige Zahl.", + "xerSingleProjectRequired": "Dieser Import unterstützt genau ein P6-Projekt pro XER-Datei.", + "xerEmptyProject": "Das P6-Projekt in dieser XER-Datei enthält keine Aktivitäten.", + "xerDuplicateId": "Die XER-Datei enthält eine doppelte WBS-, Vorgangs- oder Beziehungs-ID.", + "xerAmbiguousLocalRelation": "Eine nicht zugeordnete XER-Beziehung kann keinem lokalen Projekt eindeutig zugewiesen werden.", + "xerDanglingLocalRelation": "Eine lokale XER-Beziehung verweist auf einen fehlenden Vorgang.", + "xerEnumFallback": "XER {{family}}: unbekanntes Token „{{token}}“; Ersatzwert „{{fallback}}“.", + "xerImportOpened_one": "XER-Datei geöffnet: {{count}} Projektdokument.", + "xerImportOpened_other": "XER-Datei geöffnet: {{count}} Projektdokumente.", + "xerImportProjectsSeen_one": "{{count}} Projekt gefunden.", + "xerImportProjectsSeen_other": "{{count}} Projekte gefunden.", + "xerImportEmptyProjectsSkipped_one": "{{count}} leeres Projekt übersprungen.", + "xerImportEmptyProjectsSkipped_other": "{{count}} leere Projekte übersprungen.", + "xerImportBaselineProjectsExcluded_one": "{{count}} Baseline-Projekt ausgeschlossen.", + "xerImportBaselineProjectsExcluded_other": "{{count}} Baseline-Projekte ausgeschlossen.", + "xerImportBaselinesMaterialized_one": "{{count}} Baseline materialisiert.", + "xerImportBaselinesMaterialized_other": "{{count}} Baselines materialisiert.", + "xerImportDanglingBaselineReferences_one": "{{count}} verwaister Baseline-Verweis ignoriert.", + "xerImportDanglingBaselineReferences_other": "{{count}} verwaiste Baseline-Verweise ignoriert.", + "xerImportBaselineFallback": "Ein schützender Baseline-Rückfall wurde verwendet.", + "xerImportExternalLinks_one": "{{count}} externe Verknüpfung beibehalten.", + "xerImportExternalLinks_other": "{{count}} externe Verknüpfungen beibehalten.", + "xerImportEncoding": "Textkodierung gewählt: {{encoding}}.", + "xerImportParserIssues_one": "{{count}} Parser-Hinweis.", + "xerImportParserIssues_other": "{{count}} Parser-Hinweise.", + "xerImportCalendarIssues_one": "{{count}} Kalender-Hinweis.", + "xerImportCalendarIssues_other": "{{count}} Kalender-Hinweise.", + "xerImportNumberIssues_one": "{{count}} Zahlenformat-Hinweis.", + "xerImportNumberIssues_other": "{{count}} Zahlenformat-Hinweise.", + "xerImportEnumFallbacks_one": "{{count}} Enum-Rückfall.", + "xerImportEnumFallbacks_other": "{{count}} Enum-Rückfälle.", + "xerImportUnsupportedSemantics_one": "{{count}} P6-Planungseinstellung mit sicherem Rückfall.", + "xerImportUnsupportedSemantics_other": "{{count}} P6-Planungseinstellungen mit sicherem Rückfall.", + "xerImportDatesAsRecorded_one": "{{count}} Vorgang zeigt die von Primavera gespeicherten Termine (nicht neu berechnet).", + "xerImportDatesAsRecorded_other": "{{count}} Vorgänge zeigen die von Primavera gespeicherten Termine (nicht neu berechnet).", + "xerImportDatesAsRecordedOffer_one": "{{count}} Vorgang weicht von den Terminen in der Datei ab — Sie können sie anzeigen.", + "xerImportDatesAsRecordedOffer_other": "{{count}} Vorgänge weichen von den Terminen in der Datei ab — Sie können sie anzeigen.", + "xerExportLoss": "Beim Export nach {{format}} gehen XER-Quellinformationen verloren.", "mppLegacy": "Diese .mpp-Datei verwendet ein altes Format (Project 2007 oder älter). Exportieren Sie sie in MS Project als XML (Datei → Speichern unter → XML) und öffnen Sie diese Datei.", "mppEncrypted": "Diese .mpp-Datei ist passwortgeschützt. Exportieren Sie sie in MS Project als XML (Datei → Speichern unter → XML) und öffnen Sie diese Datei.", "saveFailed": "Speichern fehlgeschlagen", @@ -786,9 +832,16 @@ "recordedDates": { "offer_one": "Die Neuberechnung hat {{count}} von {{total}} Vorgängen gegenüber den Terminen in der Datei verschoben.", "offer_other": "Die Neuberechnung hat {{count}} von {{total}} Vorgängen gegenüber den Terminen in der Datei verschoben.", + "activeCount_one": "Sie sehen den Terminplan so, wie Primavera ihn gespeichert hat; eine Neuberechnung würde {{count}} Vorgang verschieben.", + "activeCount_other": "Sie sehen den Terminplan so, wie Primavera ihn gespeichert hat; eine Neuberechnung würde {{count}} Vorgänge verschieben.", + "activeCountNeutral_one": "Sie sehen die Termine so, wie sie in der Datei aufgezeichnet sind; eine Neuberechnung würde {{count}} Vorgang verschieben.", + "activeCountNeutral_other": "Sie sehen die Termine so, wie sie in der Datei aufgezeichnet sind; eine Neuberechnung würde {{count}} Vorgänge verschieben.", "show": "Aufgezeichnete Termine anzeigen", "active": "Sie sehen die Termine so, wie sie in der Datei aufgezeichnet sind. Es wurde nicht neu berechnet.", - "recalculate": "Neu berechnen" + "recalculate": "Neu berechnen", + "markDeviates": "Weicht ab", + "markPartlyUnrecorded": "Teilweise nicht erfasst", + "notRecorded": "Nicht erfasst" }, "duration": { "suffixDay": "T", @@ -971,6 +1024,7 @@ "declaredHeading": "Was diese Erweiterung zu nutzen angibt", "declaredNote": "Das ist die Angabe des Autors, keine Beschränkung — der Code kann ohnehin mehr.", "declaredNone": "Nichts angegeben.", + "permImportSource": "importSource — die vollständigen ursprünglichen Quellbytes jeder importierten Datei (z. B. eine rohe Primavera-Datei), auch Felder, die nie ins Projekt gelangen.", "cancel": "Nicht installieren", "confirm": "Installieren" } diff --git a/src/i18n/locales/de/report.json b/src/i18n/locales/de/report.json index 47889af76..dd3f2cf03 100644 --- a/src/i18n/locales/de/report.json +++ b/src/i18n/locales/de/report.json @@ -135,6 +135,7 @@ "notCalculatedNote": "Noch nicht berechnet — drücken Sie Berechnen (F5) für Termine und Puffer.", "staleNote": "Der Terminplan wurde seit der letzten Berechnung geändert — drücken Sie Berechnen (F5) für aktuelle Werte.", "statusDateMissingNote": "Kein Statusdatum gesetzt — der Bericht rechnet mit heute ({{date}}).", + "recordedDatesNote": "Ansicht „Termine wie gespeichert“: Dieser Bericht zeigt die Termine aus der Quelldatei, keine Berechnung. Späteste Anfänge/Enden und Puffer, die die Datei nicht festhielt, erscheinen hier leer oder als 0.", "period": "Zeitraum", "statusDate": "Statusdatum", "common": { diff --git a/src/i18n/locales/de/task.json b/src/i18n/locales/de/task.json index 318774bab..79fbfbfa4 100644 --- a/src/i18n/locales/de/task.json +++ b/src/i18n/locales/de/task.json @@ -53,11 +53,12 @@ "calendar": "Kalender", "childIds": "IDs untergeordneter Aufgaben", "color": "Farbe", "completion": "Fortschritt", "constraint2Date": "Datum der sekundären Einschränkung", "constraint2Type": "Typ der sekundären Einschränkung", "constraintDate": "Einschränkungsdatum", "constraintHard": "Harte Einschränkung", "constraintType": "Einschränkungstyp", "critical": "Kritisch", "customFieldData": "Benutzerfelddaten", "deadline": "Stichtag", "description": "Beschreibung", "duration": "Dauer", "durationMinutes": "Dauer (Minuten)", "durationType": "Dauertyp", - "earlyFinish": "Frühestes Ende", "earlyStart": "Frühester Anfang", "effortDriven": "Aufwandsgesteuert", "externalRelationData": "Daten externer Beziehungen", + "earlyFinish": "Frühestes Ende", "earlyStart": "Frühester Anfang", "effortDriven": "Aufwandsgesteuert", "explicitSummary": "Expliziter Sammelvorgang", "externalRelationData": "Daten externer Beziehungen", "floatPath": "Pufferpfad", "freeFloat": "Freier Puffer", "hammock": "Hammock (abgeleitete Dauer)", "interferingFloat": "Störender Puffer", "internalRelationData": "Daten interner Beziehungen", "lateFinish": "Spätestes Ende", "lateStart": "Spätester Anfang", "levelingDelay": "Kapazitätsabgleichsverzögerung", "levelingDelayElapsed": "Verstrichene Abgleichsverzögerung", "levelingDelayMinutes": "Abgleichsverzögerung (Minuten)", "mandatoryMilestone": "Verbindlicher Meilenstein", "manuallyScheduled": "Manuell geplant", "milestone": "Meilenstein", "milestoneKind": "Meilensteintyp", "mspTaskType": "Microsoft-Project-Aufgabentyp", - "name": "Aufgabenname", "nearCritical": "Nahezu kritisch", "noteData": "Notizdaten", "notes": "Notizen", "parentId": "ID der übergeordneten Aufgabe", "priority": "Abgleichspriorität", + "name": "Aufgabenname", "nearCritical": "Nahezu kritisch", "noteData": "Notizdaten", "notes": "Notizen", "p6Provenance": "Primavera-P6-Herkunft", "parentId": "ID der übergeordneten Aufgabe", "priority": "Abgleichspriorität", + "recordedSource": "Herkunft (gespeicherte Termine)", "remainingMinutes": "Verbleibend (Minuten)", "remainingTime": "Verbleibend (Arbeitstage)", "resourceIds": "Ressourcen-IDs", "resume": "Fortsetzungsdatum", "scheduleFinish": "Geplantes Ende", "scheduleStart": "Geplanter Anfang", "splitGaps": "Unterbrechungen", "status": "Status", "stop": "Stoppdatum", "taskId": "Aufgaben-ID", "taskType": "Aufgabentyp", "timephasedContours": "Zeitphasenverläufe", "timephasedDurationWalks": "Zeitphasen-Dauersegmente", "timephasedFinishFloor": "Untergrenze des Zeitphasenendes", "timephasedStartAnchor": "Anker des Zeitphasenanfangs", @@ -268,6 +269,10 @@ "timephasedActive": "Folgt der Stundenverteilung aus MS Project", "timephasedLost": "Stundenverteilung aus MS Project nach der Bearbeitung nicht mehr angewendet — die ursprüngliche Verteilung bleibt in der Datei erhalten", "timephasedContoured": "Eigene Stundenverteilung", + "recordedDatesActive": "Zeigt die von Primavera gespeicherten Termine für diese Aufgabe", + "recordedDatesActiveNeutral": "Zeigt die in der Datei gespeicherten Termine für diese Aufgabe", + "recordedDatesDeviates": "Weicht von den gespeicherten Terminen ab", + "recordedDatesPartlyUnrecorded": "Erfassung teilweise unvollständig — siehe die Spalten für spätesten Termin/Puffer", "completion": "Fortschritt (%)", "progress": { "actualStart": "Ist-Anfang", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index e8b616369..6359d0813 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -11,6 +11,52 @@ "notifications": { "dismiss": "Close notification", "openFailed": "Failed to open file", + "xerInvalidInput": "XER import requires the original file bytes.", + "xerInvalidFile": "This is not a valid or supported XER file.", + "xerInvalidEncoding": "This XER file has invalid or truncated text encoding.", + "xerDuplicateTable": "The XER file contains a duplicate table.", + "xerMissingRequiredColumns": "An XER table is missing required columns.", + "xerMissingRequiredValue": "An XER record is missing a required value.", + "xerAmbiguousDecimal": "The decimal notation in this XER file cannot be determined reliably.", + "xerInvalidNumberFormat": "This XER file has invalid number formatting.", + "xerInvalidNumber": "The XER file contains an invalid number.", + "xerSingleProjectRequired": "This import supports exactly one P6 project per XER file.", + "xerEmptyProject": "The P6 project in this XER file contains no activities.", + "xerDuplicateId": "The XER file contains a duplicate WBS, activity, or relation ID.", + "xerAmbiguousLocalRelation": "An unscoped XER relation cannot be assigned unambiguously to one local project.", + "xerDanglingLocalRelation": "A local XER relation refers to a missing activity.", + "xerEnumFallback": "XER {{family}}: unknown token ‘{{token}}’; fell back to ‘{{fallback}}’.", + "xerImportOpened_one": "XER file opened: {{count}} project document.", + "xerImportOpened_other": "XER file opened: {{count}} project documents.", + "xerImportProjectsSeen_one": "{{count}} project found.", + "xerImportProjectsSeen_other": "{{count}} projects found.", + "xerImportEmptyProjectsSkipped_one": "{{count}} empty project skipped.", + "xerImportEmptyProjectsSkipped_other": "{{count}} empty projects skipped.", + "xerImportBaselineProjectsExcluded_one": "{{count}} baseline project excluded.", + "xerImportBaselineProjectsExcluded_other": "{{count}} baseline projects excluded.", + "xerImportBaselinesMaterialized_one": "{{count}} baseline materialized.", + "xerImportBaselinesMaterialized_other": "{{count}} baselines materialized.", + "xerImportDanglingBaselineReferences_one": "{{count}} dangling baseline reference ignored.", + "xerImportDanglingBaselineReferences_other": "{{count}} dangling baseline references ignored.", + "xerImportBaselineFallback": "A protective baseline fallback was used.", + "xerImportExternalLinks_one": "{{count}} external link preserved.", + "xerImportExternalLinks_other": "{{count}} external links preserved.", + "xerImportEncoding": "Text encoding selected: {{encoding}}.", + "xerImportParserIssues_one": "{{count}} parser finding.", + "xerImportParserIssues_other": "{{count}} parser findings.", + "xerImportCalendarIssues_one": "{{count}} calendar finding.", + "xerImportCalendarIssues_other": "{{count}} calendar findings.", + "xerImportNumberIssues_one": "{{count}} number-format finding.", + "xerImportNumberIssues_other": "{{count}} number-format findings.", + "xerImportEnumFallbacks_one": "{{count}} enum fallback.", + "xerImportEnumFallbacks_other": "{{count}} enum fallbacks.", + "xerImportUnsupportedSemantics_one": "{{count}} P6 scheduling setting used a safe fallback.", + "xerImportUnsupportedSemantics_other": "{{count}} P6 scheduling settings used safe fallbacks.", + "xerImportDatesAsRecorded_one": "{{count}} task shows the dates as Primavera recorded them (not recalculated).", + "xerImportDatesAsRecorded_other": "{{count}} tasks show the dates as Primavera recorded them (not recalculated).", + "xerImportDatesAsRecordedOffer_one": "{{count}} task differs from the dates in the file — you can show them.", + "xerImportDatesAsRecordedOffer_other": "{{count}} tasks differ from the dates in the file — you can show them.", + "xerExportLoss": "Exporting to {{format}} loses XER source information.", "mppLegacy": "This .mpp file uses an older format (Project 2007 or earlier). In MS Project, export it as XML (File → Save As → XML) and open that file.", "mppEncrypted": "This .mpp file is password-protected. In MS Project, export it as XML (File → Save As → XML) and open that file.", "saveFailed": "Failed to save", @@ -809,9 +855,16 @@ "recordedDates": { "offer_one": "Recalculation moved {{count}} of {{total}} tasks away from the dates in the file.", "offer_other": "Recalculation moved {{count}} of {{total}} tasks away from the dates in the file.", + "activeCount_one": "You're viewing the schedule as Primavera recorded it; recalculating would shift {{count}} task.", + "activeCount_other": "You're viewing the schedule as Primavera recorded it; recalculating would shift {{count}} tasks.", + "activeCountNeutral_one": "You're viewing the dates as recorded in the file; recalculating would shift {{count}} task.", + "activeCountNeutral_other": "You're viewing the dates as recorded in the file; recalculating would shift {{count}} tasks.", "show": "Show recorded dates", "active": "You're viewing the dates as recorded in the file. Nothing has been recalculated.", - "recalculate": "Recalculate" + "recalculate": "Recalculate", + "markDeviates": "Deviates", + "markPartlyUnrecorded": "Partly unrecorded", + "notRecorded": "Not recorded" }, "duration": { "suffixDay": "d", @@ -994,6 +1047,7 @@ "declaredHeading": "What this extension says it uses", "declaredNote": "This is the author's declaration, not a restriction — the code can do more regardless.", "declaredNone": "Nothing declared.", + "permImportSource": "importSource — the complete original source bytes of every imported file (for example a raw Primavera file), including fields that never reach the project.", "cancel": "Don't install", "confirm": "Install" } diff --git a/src/i18n/locales/en/report.json b/src/i18n/locales/en/report.json index 7c35ec79d..a77aed876 100644 --- a/src/i18n/locales/en/report.json +++ b/src/i18n/locales/en/report.json @@ -135,6 +135,7 @@ "notCalculatedNote": "Not calculated yet — press Calculate (F5) for dates and float.", "staleNote": "The schedule changed since the last calculation — press Calculate (F5) for current values.", "statusDateMissingNote": "No status date set — the report uses today ({{date}}).", + "recordedDatesNote": "'Dates as recorded' view: this report shows the dates from the source file, not a calculation. Late start/finish and float the file did not record appear here as blank or 0.", "period": "Period", "statusDate": "Status date", "common": { diff --git a/src/i18n/locales/en/task.json b/src/i18n/locales/en/task.json index 98fedfb51..e519a1962 100644 --- a/src/i18n/locales/en/task.json +++ b/src/i18n/locales/en/task.json @@ -77,6 +77,7 @@ "earlyFinish": "Early finish", "earlyStart": "Early start", "effortDriven": "Effort driven", + "explicitSummary": "Explicit summary task", "externalRelationData": "External relationship data", "floatPath": "Float path", "freeFloat": "Free float", @@ -97,8 +98,10 @@ "nearCritical": "Near critical", "noteData": "Note data", "notes": "Notes", + "p6Provenance": "Primavera P6 provenance", "parentId": "Parent task ID", "priority": "Leveling priority", + "recordedSource": "Recorded-dates source", "remainingMinutes": "Remaining (minutes)", "remainingTime": "Remaining (work days)", "resourceIds": "Resource IDs", @@ -375,6 +378,10 @@ "timephasedActive": "Follows the hour distribution from MS Project", "timephasedLost": "MS Project date window no longer applied after editing — the hour distribution itself still applies and stays in the file", "timephasedContoured": "Own hour distribution", + "recordedDatesActive": "Shows Primavera’s own recorded dates for this task", + "recordedDatesActiveNeutral": "Shows the dates as recorded in the file for this task", + "recordedDatesDeviates": "Deviates from the recorded dates", + "recordedDatesPartlyUnrecorded": "Recorded data is partly incomplete — see the late/float columns", "completion": "Progress (%)", "progress": { "actualStart": "Actual start", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index e087d9d4b..d4922455e 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -6,6 +6,66 @@ "notifications": { "dismiss": "Cerrar notificación", "openFailed": "No se pudo abrir el archivo", + "xerInvalidInput": "La importación XER requiere los bytes originales del archivo.", + "xerInvalidFile": "Este archivo XER no es válido o compatible.", + "xerInvalidEncoding": "La codificación de texto de este archivo XER es inválida o está truncada.", + "xerDuplicateTable": "El archivo XER contiene una tabla duplicada.", + "xerMissingRequiredColumns": "Faltan columnas obligatorias en una tabla XER.", + "xerMissingRequiredValue": "Falta un valor obligatorio en un registro XER.", + "xerAmbiguousDecimal": "No se puede determinar con fiabilidad la notación decimal de este archivo XER.", + "xerInvalidNumberFormat": "El formato numérico de este archivo XER no es válido.", + "xerInvalidNumber": "El archivo XER contiene un número no válido.", + "xerSingleProjectRequired": "Esta importación admite exactamente un proyecto P6 por archivo XER.", + "xerEmptyProject": "El proyecto P6 de este archivo XER no contiene actividades.", + "xerDuplicateId": "El archivo XER contiene un ID de EDT, actividad o relación duplicado.", + "xerAmbiguousLocalRelation": "Una relación XER sin ámbito no se puede asignar de forma inequívoca a un único proyecto local.", + "xerDanglingLocalRelation": "Una relación XER local hace referencia a una actividad inexistente.", + "xerEnumFallback": "XER {{family}}: token desconocido «{{token}}»; se usó «{{fallback}}».", + "xerImportOpened_one": "Archivo XER abierto: {{count}} documento de proyecto.", + "xerImportOpened_many": "Archivo XER abierto: {{count}} documentos de proyecto.", + "xerImportOpened_other": "Archivo XER abierto: {{count}} documentos de proyecto.", + "xerImportProjectsSeen_one": "{{count}} proyecto encontrado.", + "xerImportProjectsSeen_many": "{{count}} proyectos encontrados.", + "xerImportProjectsSeen_other": "{{count}} proyectos encontrados.", + "xerImportEmptyProjectsSkipped_one": "{{count}} proyecto vacío omitido.", + "xerImportEmptyProjectsSkipped_many": "{{count}} proyectos vacíos omitidos.", + "xerImportEmptyProjectsSkipped_other": "{{count}} proyectos vacíos omitidos.", + "xerImportBaselineProjectsExcluded_one": "{{count}} proyecto de línea base excluido.", + "xerImportBaselineProjectsExcluded_many": "{{count}} proyectos de línea base excluidos.", + "xerImportBaselineProjectsExcluded_other": "{{count}} proyectos de línea base excluidos.", + "xerImportBaselinesMaterialized_one": "{{count}} línea base materializada.", + "xerImportBaselinesMaterialized_many": "{{count}} líneas base materializadas.", + "xerImportBaselinesMaterialized_other": "{{count}} líneas base materializadas.", + "xerImportDanglingBaselineReferences_one": "{{count}} referencia de línea base sin destino ignorada.", + "xerImportDanglingBaselineReferences_many": "{{count}} referencias de línea base sin destino ignoradas.", + "xerImportDanglingBaselineReferences_other": "{{count}} referencias de línea base sin destino ignoradas.", + "xerImportBaselineFallback": "Se usó una alternativa protectora para la línea base.", + "xerImportExternalLinks_one": "{{count}} vínculo externo conservado.", + "xerImportExternalLinks_many": "{{count}} vínculos externos conservados.", + "xerImportExternalLinks_other": "{{count}} vínculos externos conservados.", + "xerImportEncoding": "Codificación de texto elegida: {{encoding}}.", + "xerImportParserIssues_one": "{{count}} hallazgo del analizador.", + "xerImportParserIssues_many": "{{count}} hallazgos del analizador.", + "xerImportParserIssues_other": "{{count}} hallazgos del analizador.", + "xerImportCalendarIssues_one": "{{count}} hallazgo del calendario.", + "xerImportCalendarIssues_many": "{{count}} hallazgos del calendario.", + "xerImportCalendarIssues_other": "{{count}} hallazgos del calendario.", + "xerImportNumberIssues_one": "{{count}} problema de formato numérico.", + "xerImportNumberIssues_many": "{{count}} problemas de formato numérico.", + "xerImportNumberIssues_other": "{{count}} problemas de formato numérico.", + "xerImportEnumFallbacks_one": "{{count}} alternativa de enumeración.", + "xerImportEnumFallbacks_many": "{{count}} alternativas de enumeración.", + "xerImportEnumFallbacks_other": "{{count}} alternativas de enumeración.", + "xerImportUnsupportedSemantics_one": "{{count}} ajuste de planificación P6 usó una alternativa segura.", + "xerImportUnsupportedSemantics_many": "{{count}} ajustes de planificación P6 usaron alternativas seguras.", + "xerImportUnsupportedSemantics_other": "{{count}} ajustes de planificación P6 usaron alternativas seguras.", + "xerImportDatesAsRecorded_one": "{{count}} tarea muestra las fechas tal como las registró Primavera (sin recalcular).", + "xerImportDatesAsRecorded_many": "{{count}} tareas muestran las fechas tal como las registró Primavera (sin recalcular).", + "xerImportDatesAsRecorded_other": "{{count}} tareas muestran las fechas tal como las registró Primavera (sin recalcular).", + "xerImportDatesAsRecordedOffer_one": "{{count}} tarea difiere de las fechas registradas en el archivo — puedes mostrarlas.", + "xerImportDatesAsRecordedOffer_many": "{{count}} tareas difieren de las fechas registradas en el archivo — puedes mostrarlas.", + "xerImportDatesAsRecordedOffer_other": "{{count}} tareas difieren de las fechas registradas en el archivo — puedes mostrarlas.", + "xerExportLoss": "Al exportar a {{format}} se pierde información de origen XER.", "mppLegacy": "Este archivo .mpp usa un formato antiguo (Project 2007 o anterior). Expórtalo en MS Project como XML (Archivo → Guardar como → XML) y abre ese archivo.", "mppEncrypted": "Este archivo .mpp está protegido con contraseña. Expórtalo en MS Project como XML (Archivo → Guardar como → XML) y abre ese archivo.", "saveFailed": "No se pudo guardar", @@ -799,9 +859,18 @@ "offer_one": "El recálculo desplazó {{count}} de {{total}} tareas respecto a las fechas del archivo.", "offer_many": "El recálculo desplazó {{count}} de {{total}} tareas respecto a las fechas del archivo.", "offer_other": "El recálculo desplazó {{count}} de {{total}} tareas respecto a las fechas del archivo.", + "activeCount_one": "Estás viendo la planificación tal como la registró Primavera; recalcular desplazaría {{count}} tarea.", + "activeCount_many": "Estás viendo la planificación tal como la registró Primavera; recalcular desplazaría {{count}} tareas.", + "activeCount_other": "Estás viendo la planificación tal como la registró Primavera; recalcular desplazaría {{count}} tareas.", + "activeCountNeutral_one": "Estás viendo las fechas tal como están registradas en el archivo; recalcular desplazaría {{count}} tarea.", + "activeCountNeutral_many": "Estás viendo las fechas tal como están registradas en el archivo; recalcular desplazaría {{count}} tareas.", + "activeCountNeutral_other": "Estás viendo las fechas tal como están registradas en el archivo; recalcular desplazaría {{count}} tareas.", "show": "Mostrar fechas registradas", "active": "Estás viendo las fechas tal como están registradas en el archivo. No se ha recalculado nada.", - "recalculate": "Recalcular" + "recalculate": "Recalcular", + "markDeviates": "Difiere", + "markPartlyUnrecorded": "Parcialmente no registrado", + "notRecorded": "No registrado" }, "duration": { "suffixDay": "d", @@ -989,6 +1058,7 @@ "declaredHeading": "Lo que esta extensión declara usar", "declaredNote": "Es la declaración del autor, no una restricción: el código puede hacer más de todos modos.", "declaredNone": "No declara nada.", + "permImportSource": "importSource — los bytes de origen completos de cada archivo importado (por ejemplo un archivo Primavera sin procesar), incluidos campos que nunca llegan al proyecto.", "cancel": "No instalar", "confirm": "Instalar" } diff --git a/src/i18n/locales/es/report.json b/src/i18n/locales/es/report.json index 35d6bb7e6..deb86770a 100644 --- a/src/i18n/locales/es/report.json +++ b/src/i18n/locales/es/report.json @@ -138,6 +138,7 @@ "notCalculatedNote": "Aún no calculado — pulse Calcular (F5) para fechas y holguras.", "staleNote": "El cronograma cambió desde el último cálculo — pulse Calcular (F5) para valores actuales.", "statusDateMissingNote": "Sin fecha de estado — el informe usa hoy ({{date}}).", + "recordedDatesNote": "Vista «fechas tal como se guardaron»: este informe muestra las fechas del archivo de origen, no un cálculo. Los inicios/fines tardíos y las holguras que el archivo no registró aparecen vacíos o en 0.", "period": "Periodo", "statusDate": "Fecha de estado", "common": { diff --git a/src/i18n/locales/es/task.json b/src/i18n/locales/es/task.json index 5fc8dad53..f0eb0e4f1 100644 --- a/src/i18n/locales/es/task.json +++ b/src/i18n/locales/es/task.json @@ -53,11 +53,12 @@ "calendar": "Calendario", "childIds": "Id. de tareas secundarias", "color": "Color", "completion": "Progreso", "constraint2Date": "Fecha de restricción secundaria", "constraint2Type": "Tipo de restricción secundaria", "constraintDate": "Fecha de restricción", "constraintHard": "Restricción estricta", "constraintType": "Tipo de restricción", "critical": "Crítica", "customFieldData": "Datos de campos personalizados", "deadline": "Fecha límite", "description": "Descripción", "duration": "Duración", "durationMinutes": "Duración (minutos)", "durationType": "Tipo de duración", - "earlyFinish": "Fin temprano", "earlyStart": "Inicio temprano", "effortDriven": "Condicionada por el esfuerzo", "externalRelationData": "Datos de relaciones externas", + "earlyFinish": "Fin temprano", "earlyStart": "Inicio temprano", "effortDriven": "Condicionada por el esfuerzo", "explicitSummary": "Tarea de resumen explícita", "externalRelationData": "Datos de relaciones externas", "floatPath": "Ruta de holgura", "freeFloat": "Holgura libre", "hammock": "Hamaca (duración derivada)", "interferingFloat": "Holgura interferente", "internalRelationData": "Datos de relaciones internas", "lateFinish": "Fin tardío", "lateStart": "Inicio tardío", "levelingDelay": "Retraso de redistribución", "levelingDelayElapsed": "Retraso de redistribución transcurrido", "levelingDelayMinutes": "Retraso de redistribución (minutos)", "mandatoryMilestone": "Hito obligatorio", "manuallyScheduled": "Programada manualmente", "milestone": "Hito", "milestoneKind": "Tipo de hito", "mspTaskType": "Tipo de tarea de Microsoft Project", - "name": "Nombre de tarea", "nearCritical": "Casi crítica", "noteData": "Datos de notas", "notes": "Notas", "parentId": "Id. de tarea principal", "priority": "Prioridad de redistribución", + "name": "Nombre de tarea", "nearCritical": "Casi crítica", "noteData": "Datos de notas", "notes": "Notas", "p6Provenance": "Procedencia de Primavera P6", "parentId": "Id. de tarea principal", "priority": "Prioridad de redistribución", + "recordedSource": "Origen (fechas registradas)", "remainingMinutes": "Restante (minutos)", "remainingTime": "Restante (días laborables)", "resourceIds": "Id. de recursos", "resume": "Fecha de reanudación", "scheduleFinish": "Fin programado", "scheduleStart": "Inicio programado", "splitGaps": "Interrupciones", "status": "Estado", "stop": "Fecha de detención", "taskId": "Id. de tarea", "taskType": "Tipo de tarea", "timephasedContours": "Contornos por fases temporales", "timephasedDurationWalks": "Segmentos de duración por fases temporales", "timephasedFinishFloor": "Límite inferior de fin por fases temporales", "timephasedStartAnchor": "Anclaje de inicio por fases temporales", @@ -272,6 +273,10 @@ "timephasedActive": "Sigue la distribución de horas de MS Project", "timephasedLost": "La distribución de horas de MS Project ya no se aplica tras la edición — la distribución original se conserva en el archivo", "timephasedContoured": "Distribución de horas propia", + "recordedDatesActive": "Muestra las fechas propias registradas por Primavera para esta tarea", + "recordedDatesActiveNeutral": "Muestra las fechas tal como se guardaron en el archivo para esta tarea", + "recordedDatesDeviates": "Difiere de las fechas registradas", + "recordedDatesPartlyUnrecorded": "El registro está parcialmente incompleto — véanse las columnas de fin tardío/holgura", "completion": "Progreso (%)", "progress": { "actualStart": "Inicio real", diff --git a/src/i18n/locales/fa/common.json b/src/i18n/locales/fa/common.json index 858b1f904..37b2dde4c 100644 --- a/src/i18n/locales/fa/common.json +++ b/src/i18n/locales/fa/common.json @@ -6,6 +6,52 @@ "notifications": { "dismiss": "بستن اعلان", "openFailed": "باز کردن فایل ناموفق بود", + "xerInvalidInput": "درون‌ریزی XER به بایت‌های اصلی فایل نیاز دارد.", + "xerInvalidFile": "این فایل XER معتبر یا پشتیبانی‌شده نیست.", + "xerInvalidEncoding": "رمزگذاری متن این فایل XER نامعتبر یا ناقص است.", + "xerDuplicateTable": "فایل XER دارای جدول تکراری است.", + "xerMissingRequiredColumns": "یک جدول XER ستون‌های الزامی را ندارد.", + "xerMissingRequiredValue": "یک رکورد XER مقدار الزامی را ندارد.", + "xerAmbiguousDecimal": "نحوهٔ نمایش اعشار در این فایل XER با اطمینان قابل تشخیص نیست.", + "xerInvalidNumberFormat": "قالب اعداد در این فایل XER نامعتبر است.", + "xerInvalidNumber": "فایل XER دارای عدد نامعتبر است.", + "xerSingleProjectRequired": "این درون‌ریزی دقیقاً از یک پروژهٔ P6 در هر فایل XER پشتیبانی می‌کند.", + "xerEmptyProject": "پروژهٔ P6 در این فایل XER هیچ فعالیتی ندارد.", + "xerDuplicateId": "فایل XER دارای شناسهٔ تکراری WBS، فعالیت یا رابطه است.", + "xerAmbiguousLocalRelation": "یک رابطهٔ XER بدون محدوده را نمی‌توان به‌طور یکتا به یک پروژهٔ محلی نسبت داد.", + "xerDanglingLocalRelation": "یک رابطهٔ محلی XER به فعالیتی ناموجود اشاره می‌کند.", + "xerEnumFallback": "XER {{family}}: نشانهٔ «{{token}}» ناشناخته است؛ «{{fallback}}» استفاده شد.", + "xerImportOpened_one": "فایل XER باز شد: {{count}} سند پروژه.", + "xerImportOpened_other": "فایل XER باز شد: {{count}} سند پروژه.", + "xerImportProjectsSeen_one": "{{count}} پروژه پیدا شد.", + "xerImportProjectsSeen_other": "{{count}} پروژه پیدا شد.", + "xerImportEmptyProjectsSkipped_one": "{{count}} پروژه خالی رد شد.", + "xerImportEmptyProjectsSkipped_other": "{{count}} پروژه خالی رد شد.", + "xerImportBaselineProjectsExcluded_one": "{{count}} پروژهٔ خط مبنا کنار گذاشته شد.", + "xerImportBaselineProjectsExcluded_other": "{{count}} پروژهٔ خط مبنا کنار گذاشته شد.", + "xerImportBaselinesMaterialized_one": "{{count}} خط مبنا ساخته شد.", + "xerImportBaselinesMaterialized_other": "{{count}} خط مبنا ساخته شد.", + "xerImportDanglingBaselineReferences_one": "{{count}} ارجاع معلق خط مبنا نادیده گرفته شد.", + "xerImportDanglingBaselineReferences_other": "{{count}} ارجاع معلق خط مبنا نادیده گرفته شد.", + "xerImportBaselineFallback": "از بازگشت محافظتی خط مبنا استفاده شد.", + "xerImportExternalLinks_one": "{{count}} پیوند خارجی حفظ شد.", + "xerImportExternalLinks_other": "{{count}} پیوند خارجی حفظ شد.", + "xerImportEncoding": "کدگذاری متن انتخاب شد: {{encoding}}.", + "xerImportParserIssues_one": "{{count}} یافتهٔ تجزیه‌گر.", + "xerImportParserIssues_other": "{{count}} یافتهٔ تجزیه‌گر.", + "xerImportCalendarIssues_one": "{{count}} یافتهٔ تقویم.", + "xerImportCalendarIssues_other": "{{count}} یافتهٔ تقویم.", + "xerImportNumberIssues_one": "{{count}} مشکل قالب عدد.", + "xerImportNumberIssues_other": "{{count}} مشکل قالب عدد.", + "xerImportEnumFallbacks_one": "{{count}} بازگشت شمارشی.", + "xerImportEnumFallbacks_other": "{{count}} بازگشت شمارشی.", + "xerImportUnsupportedSemantics_one": "{{count}} تنظیم زمان‌بندی P6 از جایگزین امن استفاده کرد.", + "xerImportUnsupportedSemantics_other": "{{count}} تنظیم زمان‌بندی P6 از جایگزین‌های امن استفاده کردند.", + "xerImportDatesAsRecorded_one": "{{count}} فعالیت تاریخ‌های ثبت‌شده توسط Primavera را نشان می‌دهد (بدون محاسبه مجدد).", + "xerImportDatesAsRecorded_other": "{{count}} فعالیت تاریخ‌های ثبت‌شده توسط Primavera را نشان می‌دهند (بدون محاسبه مجدد).", + "xerImportDatesAsRecordedOffer_one": "{{count}} فعالیت با تاریخ‌های ثبت‌شده در فایل تفاوت دارد — می‌توانید آن‌ها را نمایش دهید.", + "xerImportDatesAsRecordedOffer_other": "{{count}} فعالیت با تاریخ‌های ثبت‌شده در فایل تفاوت دارند — می‌توانید آن‌ها را نمایش دهید.", + "xerExportLoss": "خروجی به {{format}} باعث از دست رفتن اطلاعات منبع XER می‌شود.", "mppLegacy": "این فایل .mpp از قالبی قدیمی (Project 2007 یا قدیمی‌تر) استفاده می‌کند. آن را در MS Project به‌صورت XML صادر کنید (فایل ← ذخیره به‌عنوان ← XML) و آن فایل را باز کنید.", "mppEncrypted": "این فایل .mpp با رمز عبور محافظت شده است. آن را در MS Project به‌صورت XML صادر کنید (فایل ← ذخیره به‌عنوان ← XML) و آن فایل را باز کنید.", "saveFailed": "ذخیره‌سازی ناموفق بود", @@ -786,9 +832,16 @@ "recordedDates": { "offer_one": "محاسبه مجدد {{count}} مورد از {{total}} فعالیت را نسبت به تاریخ‌های ثبت‌شده در فایل جابه‌جا کرد.", "offer_other": "محاسبه مجدد {{count}} مورد از {{total}} فعالیت را نسبت به تاریخ‌های ثبت‌شده در فایل جابه‌جا کرد.", + "activeCount_one": "شما برنامه را همان‌طور که Primavera ثبت کرده مشاهده می‌کنید؛ محاسبه مجدد {{count}} فعالیت را جابه‌جا می‌کند.", + "activeCount_other": "شما برنامه را همان‌طور که Primavera ثبت کرده مشاهده می‌کنید؛ محاسبه مجدد {{count}} فعالیت را جابه‌جا می‌کند.", + "activeCountNeutral_one": "شما تاریخ‌ها را همان‌طور که در فایل ثبت شده مشاهده می‌کنید؛ محاسبه مجدد {{count}} فعالیت را جابه‌جا می‌کند.", + "activeCountNeutral_other": "شما تاریخ‌ها را همان‌طور که در فایل ثبت شده مشاهده می‌کنید؛ محاسبه مجدد {{count}} فعالیت را جابه‌جا می‌کند.", "show": "نمایش تاریخ‌های ثبت‌شده", "active": "شما تاریخ‌ها را همان‌طور که در فایل ثبت شده مشاهده می‌کنید. محاسبه‌ای دوباره انجام نشده است.", - "recalculate": "محاسبه مجدد" + "recalculate": "محاسبه مجدد", + "markDeviates": "متفاوت", + "markPartlyUnrecorded": "تا حدی ثبت‌نشده", + "notRecorded": "ثبت‌نشده" }, "duration": { "suffixDay": "ر", @@ -971,6 +1024,7 @@ "declaredHeading": "آنچه این افزونه اعلام می‌کند به کار می‌برد", "declaredNote": "این اعلام سازنده است، نه محدودیت — کد در هر حال می‌تواند بیشتر انجام دهد.", "declaredNone": "چیزی اعلام نشده است.", + "permImportSource": "importSource — بایت‌های مبدأ کامل و اصلی هر فایل واردشده (مثلاً یک فایل خام Primavera)، از جمله فیلدهایی که هرگز به پروژه نمی‌رسند.", "cancel": "نصب نشود", "confirm": "نصب" } diff --git a/src/i18n/locales/fa/report.json b/src/i18n/locales/fa/report.json index 8e7ee3d87..463a5f736 100644 --- a/src/i18n/locales/fa/report.json +++ b/src/i18n/locales/fa/report.json @@ -136,6 +136,7 @@ "notCalculatedNote": "هنوز محاسبه نشده — برای تاریخ‌ها و شناوری‌ها، محاسبه (F5) را بزنید.", "staleNote": "برنامه از آخرین محاسبه تغییر کرده — برای مقادیر به‌روز، محاسبه (F5) را بزنید.", "statusDateMissingNote": "تاریخ وضعیت تعیین نشده — گزارش از امروز ({{date}}) استفاده می‌کند.", + "recordedDatesNote": "نمای «تاریخ‌ها همان‌طور که ذخیره شده»: این گزارش تاریخ‌های فایل مبدأ را نشان می‌دهد، نه یک محاسبه. دیرترین شروع/پایان و شناوری‌هایی که فایل ثبت نکرده، اینجا خالی یا 0 نمایش داده می‌شوند.", "period": "دوره", "statusDate": "تاریخ وضعیت", "common": { diff --git a/src/i18n/locales/fa/task.json b/src/i18n/locales/fa/task.json index 2ac8aa0b2..1bb4861d9 100644 --- a/src/i18n/locales/fa/task.json +++ b/src/i18n/locales/fa/task.json @@ -48,7 +48,7 @@ "taskGrid": { "columns": { "customTaskType": "نوع وظیفه سفارشی", - "activityCodeData":"داده‌های کد فعالیت","actualDuration":"مدت واقعی","actualFinish":"پایان واقعی","actualStart":"شروع واقعی","assignedResources":"منابع تخصیص‌یافته","assignmentCurve":"منحنی تخصیص","assignmentId":"شناسه تخصیص","assignmentResourceId":"شناسه منبع تخصیص","assignmentTaskId":"شناسه وظیفه تخصیص","assignmentUnits":"واحدهای تخصیص در روز","calendar":"تقویم","childIds":"شناسه وظیفه‌های فرزند","color":"رنگ","completion":"پیشرفت","constraint2Date":"تاریخ محدودیت ثانویه","constraint2Type":"نوع محدودیت ثانویه","constraintDate":"تاریخ محدودیت","constraintHard":"محدودیت سخت","constraintType":"نوع محدودیت","critical":"بحرانی","customFieldData":"داده‌های فیلد سفارشی","deadline":"مهلت نهایی","description":"توضیحات","duration":"مدت","durationMinutes":"مدت (دقیقه)","durationType":"نوع مدت","earlyFinish":"پایان زودهنگام","earlyStart":"شروع زودهنگام","effortDriven":"مبتنی بر تلاش","externalRelationData":"داده‌های رابطه خارجی","floatPath":"مسیر شناوری","freeFloat":"شناوری آزاد","hammock":"وظیفه هموک (مدت مشتق‌شده)","interferingFloat":"شناوری مزاحم","internalRelationData":"داده‌های رابطه داخلی","lateFinish":"پایان دیرهنگام","lateStart":"شروع دیرهنگام","levelingDelay":"تأخیر تسطیح منابع","levelingDelayElapsed":"تأخیر سپری‌شده تسطیح","levelingDelayMinutes":"تأخیر تسطیح (دقیقه)","mandatoryMilestone":"نقطه عطف الزامی","manuallyScheduled":"زمان‌بندی دستی","milestone":"نقطه عطف","milestoneKind":"نوع نقطه عطف","mspTaskType":"نوع وظیفه Microsoft Project","name":"نام وظیفه","nearCritical":"نزدیک به بحرانی","noteData":"داده‌های یادداشت","notes":"یادداشت‌ها","parentId":"شناسه وظیفه والد","priority":"اولویت تسطیح","remainingMinutes":"باقی‌مانده (دقیقه)","remainingTime":"باقی‌مانده (روز کاری)","resourceIds":"شناسه‌های منبع","resume":"تاریخ ازسرگیری","scheduleFinish":"پایان برنامه‌ریزی‌شده","scheduleStart":"شروع برنامه‌ریزی‌شده","splitGaps":"وقفه‌ها","status":"وضعیت","stop":"تاریخ توقف","taskId":"شناسه وظیفه","taskType":"نوع وظیفه","timephasedContours":"منحنی‌های زمان‌بندی‌شده","timephasedDurationWalks":"بخش‌های مدت زمان‌بندی‌شده","timephasedFinishFloor":"حد پایین پایان زمان‌بندی‌شده","timephasedStartAnchor":"لنگر شروع زمان‌بندی‌شده","totalFloat":"شناوری کل","wbs":"ساختار شکست کار (WBS)","workWindowFinish":"پایان بازه کاری","workWindowStart":"شروع بازه کاری" + "activityCodeData":"داده‌های کد فعالیت","actualDuration":"مدت واقعی","actualFinish":"پایان واقعی","actualStart":"شروع واقعی","assignedResources":"منابع تخصیص‌یافته","assignmentCurve":"منحنی تخصیص","assignmentId":"شناسه تخصیص","assignmentResourceId":"شناسه منبع تخصیص","assignmentTaskId":"شناسه وظیفه تخصیص","assignmentUnits":"واحدهای تخصیص در روز","calendar":"تقویم","childIds":"شناسه وظیفه‌های فرزند","color":"رنگ","completion":"پیشرفت","constraint2Date":"تاریخ محدودیت ثانویه","constraint2Type":"نوع محدودیت ثانویه","constraintDate":"تاریخ محدودیت","constraintHard":"محدودیت سخت","constraintType":"نوع محدودیت","critical":"بحرانی","customFieldData":"داده‌های فیلد سفارشی","deadline":"مهلت نهایی","description":"توضیحات","duration":"مدت","durationMinutes":"مدت (دقیقه)","durationType":"نوع مدت","earlyFinish":"پایان زودهنگام","earlyStart":"شروع زودهنگام","effortDriven":"مبتنی بر تلاش","explicitSummary":"وظیفه خلاصه صریح","externalRelationData":"داده‌های رابطه خارجی","floatPath":"مسیر شناوری","freeFloat":"شناوری آزاد","hammock":"وظیفه هموک (مدت مشتق‌شده)","interferingFloat":"شناوری مزاحم","internalRelationData":"داده‌های رابطه داخلی","lateFinish":"پایان دیرهنگام","lateStart":"شروع دیرهنگام","levelingDelay":"تأخیر تسطیح منابع","levelingDelayElapsed":"تأخیر سپری‌شده تسطیح","levelingDelayMinutes":"تأخیر تسطیح (دقیقه)","mandatoryMilestone":"نقطه عطف الزامی","manuallyScheduled":"زمان‌بندی دستی","milestone":"نقطه عطف","milestoneKind":"نوع نقطه عطف","mspTaskType":"نوع وظیفه Microsoft Project","name":"نام وظیفه","nearCritical":"نزدیک به بحرانی","noteData":"داده‌های یادداشت","notes":"یادداشت‌ها","p6Provenance":"منشأ Primavera P6","parentId":"شناسه وظیفه والد","priority":"اولویت تسطیح","recordedSource":"منبع (تاریخ‌های ثبت‌شده)","remainingMinutes":"باقی‌مانده (دقیقه)","remainingTime":"باقی‌مانده (روز کاری)","resourceIds":"شناسه‌های منبع","resume":"تاریخ ازسرگیری","scheduleFinish":"پایان برنامه‌ریزی‌شده","scheduleStart":"شروع برنامه‌ریزی‌شده","splitGaps":"وقفه‌ها","status":"وضعیت","stop":"تاریخ توقف","taskId":"شناسه وظیفه","taskType":"نوع وظیفه","timephasedContours":"منحنی‌های زمان‌بندی‌شده","timephasedDurationWalks":"بخش‌های مدت زمان‌بندی‌شده","timephasedFinishFloor":"حد پایین پایان زمان‌بندی‌شده","timephasedStartAnchor":"لنگر شروع زمان‌بندی‌شده","totalFloat":"شناوری کل","wbs":"ساختار شکست کار (WBS)","workWindowFinish":"پایان بازه کاری","workWindowStart":"شروع بازه کاری" }, "category": { "task": "وظیفه", "planning": "برنامه‌ریزی", "constraints": "محدودیت‌ها", "relations": "روابط", "resources": "منابع", @@ -255,6 +255,10 @@ "timephasedActive": "توزیع ساعات MS Project را دنبال می‌کند", "timephasedLost": "توزیع ساعات MS Project پس از ویرایش دیگر اعمال نمی‌شود — توزیع اصلی در فایل نگه داشته می‌شود", "timephasedContoured": "توزیع ساعات مخصوص", + "recordedDatesActive": "تاریخ‌های ثبت‌شده خود Primavera را برای این فعالیت نشان می‌دهد", + "recordedDatesActiveNeutral": "تاریخ‌های ثبت‌شده در پرونده را برای این فعالیت نشان می‌دهد", + "recordedDatesDeviates": "با تاریخ‌های ثبت‌شده تفاوت دارد", + "recordedDatesPartlyUnrecorded": "ثبت داده تا حدی ناقص است — به ستون‌های دیرترین/شناوری مراجعه کنید", "completion": "پیشرفت (%)", "progress": { "actualStart": "شروع واقعی", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index cf1d75505..d016b4354 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -6,6 +6,66 @@ "notifications": { "dismiss": "Fermer la notification", "openFailed": "Échec de l'ouverture du fichier", + "xerInvalidInput": "L’import XER nécessite les octets d’origine du fichier.", + "xerInvalidFile": "Ce fichier XER n’est pas valide ou pris en charge.", + "xerInvalidEncoding": "L’encodage de ce fichier XER est invalide ou tronqué.", + "xerDuplicateTable": "Le fichier XER contient une table en double.", + "xerMissingRequiredColumns": "Des colonnes obligatoires manquent dans une table XER.", + "xerMissingRequiredValue": "Une valeur obligatoire manque dans un enregistrement XER.", + "xerAmbiguousDecimal": "La notation décimale de ce fichier XER ne peut pas être déterminée de façon fiable.", + "xerInvalidNumberFormat": "Le format numérique de ce fichier XER est invalide.", + "xerInvalidNumber": "Le fichier XER contient un nombre invalide.", + "xerSingleProjectRequired": "Cet import prend en charge exactement un projet P6 par fichier XER.", + "xerEmptyProject": "Le projet P6 de ce fichier XER ne contient aucune activité.", + "xerDuplicateId": "Le fichier XER contient un identifiant WBS, d’activité ou de relation en double.", + "xerAmbiguousLocalRelation": "Une relation XER sans portée ne peut pas être attribuée sans ambiguïté à un seul projet local.", + "xerDanglingLocalRelation": "Une relation XER locale fait référence à une activité manquante.", + "xerEnumFallback": "XER {{family}} : jeton inconnu « {{token}} » ; valeur de repli « {{fallback}} ».", + "xerImportOpened_one": "Fichier XER ouvert : {{count}} document de projet.", + "xerImportOpened_many": "Fichier XER ouvert : {{count}} documents de projet.", + "xerImportOpened_other": "Fichier XER ouvert : {{count}} documents de projet.", + "xerImportProjectsSeen_one": "{{count}} projet trouvé.", + "xerImportProjectsSeen_many": "{{count}} projets trouvés.", + "xerImportProjectsSeen_other": "{{count}} projets trouvés.", + "xerImportEmptyProjectsSkipped_one": "{{count}} projet vide ignoré.", + "xerImportEmptyProjectsSkipped_many": "{{count}} projets vides ignorés.", + "xerImportEmptyProjectsSkipped_other": "{{count}} projets vides ignorés.", + "xerImportBaselineProjectsExcluded_one": "{{count}} projet de référence exclu.", + "xerImportBaselineProjectsExcluded_many": "{{count}} projets de référence exclus.", + "xerImportBaselineProjectsExcluded_other": "{{count}} projets de référence exclus.", + "xerImportBaselinesMaterialized_one": "{{count}} référence matérialisée.", + "xerImportBaselinesMaterialized_many": "{{count}} références matérialisées.", + "xerImportBaselinesMaterialized_other": "{{count}} références matérialisées.", + "xerImportDanglingBaselineReferences_one": "{{count}} référence de référence orpheline ignorée.", + "xerImportDanglingBaselineReferences_many": "{{count}} références de référence orphelines ignorées.", + "xerImportDanglingBaselineReferences_other": "{{count}} références de référence orphelines ignorées.", + "xerImportBaselineFallback": "Une solution de repli protectrice a été utilisée.", + "xerImportExternalLinks_one": "{{count}} lien externe conservé.", + "xerImportExternalLinks_many": "{{count}} liens externes conservés.", + "xerImportExternalLinks_other": "{{count}} liens externes conservés.", + "xerImportEncoding": "Encodage de texte choisi : {{encoding}}.", + "xerImportParserIssues_one": "{{count}} constat de l’analyseur.", + "xerImportParserIssues_many": "{{count}} constats de l’analyseur.", + "xerImportParserIssues_other": "{{count}} constats de l’analyseur.", + "xerImportCalendarIssues_one": "{{count}} constat de calendrier.", + "xerImportCalendarIssues_many": "{{count}} constats de calendrier.", + "xerImportCalendarIssues_other": "{{count}} constats de calendrier.", + "xerImportNumberIssues_one": "{{count}} problème de format numérique.", + "xerImportNumberIssues_many": "{{count}} problèmes de format numérique.", + "xerImportNumberIssues_other": "{{count}} problèmes de format numérique.", + "xerImportEnumFallbacks_one": "{{count}} repli d’énumération.", + "xerImportEnumFallbacks_many": "{{count}} replis d’énumération.", + "xerImportEnumFallbacks_other": "{{count}} replis d’énumération.", + "xerImportUnsupportedSemantics_one": "{{count}} paramètre de planification P6 a utilisé un repli sûr.", + "xerImportUnsupportedSemantics_many": "{{count}} paramètres de planification P6 ont utilisé des replis sûrs.", + "xerImportUnsupportedSemantics_other": "{{count}} paramètres de planification P6 ont utilisé des replis sûrs.", + "xerImportDatesAsRecorded_one": "{{count}} tâche affiche les dates telles qu'enregistrées par Primavera (non recalculées).", + "xerImportDatesAsRecorded_many": "{{count}} tâches affichent les dates telles qu'enregistrées par Primavera (non recalculées).", + "xerImportDatesAsRecorded_other": "{{count}} tâches affichent les dates telles qu'enregistrées par Primavera (non recalculées).", + "xerImportDatesAsRecordedOffer_one": "{{count}} tâche diffère des dates enregistrées dans le fichier — vous pouvez les afficher.", + "xerImportDatesAsRecordedOffer_many": "{{count}} tâches diffèrent des dates enregistrées dans le fichier — vous pouvez les afficher.", + "xerImportDatesAsRecordedOffer_other": "{{count}} tâches diffèrent des dates enregistrées dans le fichier — vous pouvez les afficher.", + "xerExportLoss": "L’export vers {{format}} entraîne la perte d’informations source XER.", "mppLegacy": "Ce fichier .mpp utilise un ancien format (Project 2007 ou antérieur). Exportez-le depuis MS Project au format XML (Fichier → Enregistrer sous → XML) et ouvrez ce fichier.", "mppEncrypted": "Ce fichier .mpp est protégé par un mot de passe. Exportez-le depuis MS Project au format XML (Fichier → Enregistrer sous → XML) et ouvrez ce fichier.", "saveFailed": "Échec de l'enregistrement", @@ -799,9 +859,18 @@ "offer_one": "Le recalcul a déplacé {{count}} tâches sur {{total}} par rapport aux dates du fichier.", "offer_many": "Le recalcul a déplacé {{count}} tâches sur {{total}} par rapport aux dates du fichier.", "offer_other": "Le recalcul a déplacé {{count}} tâches sur {{total}} par rapport aux dates du fichier.", + "activeCount_one": "Vous consultez le planning tel qu'enregistré par Primavera ; un recalcul déplacerait {{count}} tâche.", + "activeCount_many": "Vous consultez le planning tel qu'enregistré par Primavera ; un recalcul déplacerait {{count}} tâches.", + "activeCount_other": "Vous consultez le planning tel qu'enregistré par Primavera ; un recalcul déplacerait {{count}} tâches.", + "activeCountNeutral_one": "Vous consultez les dates telles qu'enregistrées dans le fichier ; un recalcul déplacerait {{count}} tâche.", + "activeCountNeutral_many": "Vous consultez les dates telles qu'enregistrées dans le fichier ; un recalcul déplacerait {{count}} tâches.", + "activeCountNeutral_other": "Vous consultez les dates telles qu'enregistrées dans le fichier ; un recalcul déplacerait {{count}} tâches.", "show": "Afficher les dates enregistrées", "active": "Vous voyez les dates telles qu'elles sont enregistrées dans le fichier. Aucun recalcul n'a été effectué.", - "recalculate": "Recalculer" + "recalculate": "Recalculer", + "markDeviates": "Diverge", + "markPartlyUnrecorded": "Partiellement non enregistré", + "notRecorded": "Non enregistré" }, "duration": { "suffixDay": "j", @@ -989,6 +1058,7 @@ "declaredHeading": "Ce que cette extension déclare utiliser", "declaredNote": "C'est la déclaration de l'auteur, pas une restriction — le code peut faire davantage.", "declaredNone": "Rien de déclaré.", + "permImportSource": "importSource — les octets source complets de chaque fichier importé (par exemple un fichier Primavera brut), y compris les champs qui n'atteignent jamais le projet.", "cancel": "Ne pas installer", "confirm": "Installer" } diff --git a/src/i18n/locales/fr/report.json b/src/i18n/locales/fr/report.json index 21687d68b..dc90f6d1d 100644 --- a/src/i18n/locales/fr/report.json +++ b/src/i18n/locales/fr/report.json @@ -138,6 +138,7 @@ "notCalculatedNote": "Pas encore calculé — appuyez sur Calculer (F5) pour les dates et les marges.", "staleNote": "Le planning a changé depuis le dernier calcul — appuyez sur Calculer (F5) pour des valeurs à jour.", "statusDateMissingNote": "Aucune date d'état définie — le rapport utilise aujourd'hui ({{date}}).", + "recordedDatesNote": "Vue « dates telles qu'enregistrées » : ce rapport affiche les dates du fichier source, pas un calcul. Les débuts/fins au plus tard et les marges non enregistrés dans le fichier apparaissent vides ou à 0.", "period": "Période", "statusDate": "Date d'état", "common": { diff --git a/src/i18n/locales/fr/task.json b/src/i18n/locales/fr/task.json index 063de3da4..00bdc08c0 100644 --- a/src/i18n/locales/fr/task.json +++ b/src/i18n/locales/fr/task.json @@ -48,7 +48,7 @@ "taskGrid": { "columns": { "customTaskType": "Type de tâche personnalisé", - "activityCodeData":"Données des codes d’activité","actualDuration":"Durée réelle","actualFinish":"Fin réelle","actualStart":"Début réel","assignedResources":"Ressources affectées","assignmentCurve":"Courbe d’affectation","assignmentId":"ID d’affectation","assignmentResourceId":"ID de ressource de l’affectation","assignmentTaskId":"ID de tâche de l’affectation","assignmentUnits":"Unités d’affectation par jour","calendar":"Calendrier","childIds":"ID des tâches enfants","color":"Couleur","completion":"Avancement","constraint2Date":"Date de contrainte secondaire","constraint2Type":"Type de contrainte secondaire","constraintDate":"Date de contrainte","constraintHard":"Contrainte stricte","constraintType":"Type de contrainte","critical":"Critique","customFieldData":"Données des champs personnalisés","deadline":"Échéance","description":"Description","duration":"Durée","durationMinutes":"Durée (minutes)","durationType":"Type de durée","earlyFinish":"Fin au plus tôt","earlyStart":"Début au plus tôt","effortDriven":"Piloté par l’effort","externalRelationData":"Données des relations externes","floatPath":"Chemin de marge","freeFloat":"Marge libre","hammock":"Hamac (durée dérivée)","interferingFloat":"Marge d’interférence","internalRelationData":"Données des relations internes","lateFinish":"Fin au plus tard","lateStart":"Début au plus tard","levelingDelay":"Retard de nivellement","levelingDelayElapsed":"Retard de nivellement écoulé","levelingDelayMinutes":"Retard de nivellement (minutes)","mandatoryMilestone":"Jalon obligatoire","manuallyScheduled":"Planifiée manuellement","milestone":"Jalon","milestoneKind":"Type de jalon","mspTaskType":"Type de tâche Microsoft Project","name":"Nom de tâche","nearCritical":"Presque critique","noteData":"Données des notes","notes":"Notes","parentId":"ID de la tâche parente","priority":"Priorité de nivellement","remainingMinutes":"Restant (minutes)","remainingTime":"Restant (jours ouvrés)","resourceIds":"ID des ressources","resume":"Date de reprise","scheduleFinish":"Fin planifiée","scheduleStart":"Début planifié","splitGaps":"Interruptions","status":"État","stop":"Date d’arrêt","taskId":"ID de tâche","taskType":"Type de tâche","timephasedContours":"Profils chronologiques","timephasedDurationWalks":"Segments de durée chronologiques","timephasedFinishFloor":"Plancher de fin chronologique","timephasedStartAnchor":"Ancrage de début chronologique","totalFloat":"Marge totale","wbs":"Structure de découpage du projet (SDP)","workWindowFinish":"Fin de fenêtre de travail","workWindowStart":"Début de fenêtre de travail" + "activityCodeData":"Données des codes d’activité","actualDuration":"Durée réelle","actualFinish":"Fin réelle","actualStart":"Début réel","assignedResources":"Ressources affectées","assignmentCurve":"Courbe d’affectation","assignmentId":"ID d’affectation","assignmentResourceId":"ID de ressource de l’affectation","assignmentTaskId":"ID de tâche de l’affectation","assignmentUnits":"Unités d’affectation par jour","calendar":"Calendrier","childIds":"ID des tâches enfants","color":"Couleur","completion":"Avancement","constraint2Date":"Date de contrainte secondaire","constraint2Type":"Type de contrainte secondaire","constraintDate":"Date de contrainte","constraintHard":"Contrainte stricte","constraintType":"Type de contrainte","critical":"Critique","customFieldData":"Données des champs personnalisés","deadline":"Échéance","description":"Description","duration":"Durée","durationMinutes":"Durée (minutes)","durationType":"Type de durée","earlyFinish":"Fin au plus tôt","earlyStart":"Début au plus tôt","effortDriven":"Piloté par l’effort","explicitSummary":"Tâche récapitulative explicite","externalRelationData":"Données des relations externes","floatPath":"Chemin de marge","freeFloat":"Marge libre","hammock":"Hamac (durée dérivée)","interferingFloat":"Marge d’interférence","internalRelationData":"Données des relations internes","lateFinish":"Fin au plus tard","lateStart":"Début au plus tard","levelingDelay":"Retard de nivellement","levelingDelayElapsed":"Retard de nivellement écoulé","levelingDelayMinutes":"Retard de nivellement (minutes)","mandatoryMilestone":"Jalon obligatoire","manuallyScheduled":"Planifiée manuellement","milestone":"Jalon","milestoneKind":"Type de jalon","mspTaskType":"Type de tâche Microsoft Project","name":"Nom de tâche","nearCritical":"Presque critique","noteData":"Données des notes","notes":"Notes","p6Provenance":"Provenance Primavera P6","parentId":"ID de la tâche parente","priority":"Priorité de nivellement","recordedSource":"Origine (dates enregistrées)","remainingMinutes":"Restant (minutes)","remainingTime":"Restant (jours ouvrés)","resourceIds":"ID des ressources","resume":"Date de reprise","scheduleFinish":"Fin planifiée","scheduleStart":"Début planifié","splitGaps":"Interruptions","status":"État","stop":"Date d’arrêt","taskId":"ID de tâche","taskType":"Type de tâche","timephasedContours":"Profils chronologiques","timephasedDurationWalks":"Segments de durée chronologiques","timephasedFinishFloor":"Plancher de fin chronologique","timephasedStartAnchor":"Ancrage de début chronologique","totalFloat":"Marge totale","wbs":"Structure de découpage du projet (SDP)","workWindowFinish":"Fin de fenêtre de travail","workWindowStart":"Début de fenêtre de travail" }, "category": { "task": "Tâche", "planning": "Planification", "constraints": "Contraintes", "relations": "Relations", "resources": "Ressources", @@ -259,6 +259,10 @@ "timephasedActive": "Suit la répartition horaire de MS Project", "timephasedLost": "Répartition horaire de MS Project non appliquée après modification — la répartition d'origine reste conservée dans le fichier", "timephasedContoured": "Répartition des heures propre", + "recordedDatesActive": "Affiche les dates propres enregistrées par Primavera pour cette tâche", + "recordedDatesActiveNeutral": "Affiche les dates telles qu’enregistrées dans le fichier pour cette tâche", + "recordedDatesDeviates": "Diverge des dates enregistrées", + "recordedDatesPartlyUnrecorded": "Enregistrement partiellement incomplet — voir les colonnes fin au plus tard/marge", "completion": "Avancement (%)", "progress": { "actualStart": "Début réel", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 5ffcdd1b6..f01763f8b 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -6,6 +6,66 @@ "notifications": { "dismiss": "Chiudi notifica", "openFailed": "Apertura del file non riuscita", + "xerInvalidInput": "L’importazione XER richiede i byte originali del file.", + "xerInvalidFile": "Questo file XER non è valido o supportato.", + "xerInvalidEncoding": "La codifica di testo di questo file XER non è valida o è troncata.", + "xerDuplicateTable": "Il file XER contiene una tabella duplicata.", + "xerMissingRequiredColumns": "In una tabella XER mancano colonne obbligatorie.", + "xerMissingRequiredValue": "In un record XER manca un valore obbligatorio.", + "xerAmbiguousDecimal": "La notazione decimale di questo file XER non può essere determinata in modo affidabile.", + "xerInvalidNumberFormat": "Il formato numerico di questo file XER non è valido.", + "xerInvalidNumber": "Il file XER contiene un numero non valido.", + "xerSingleProjectRequired": "Questa importazione supporta esattamente un progetto P6 per file XER.", + "xerEmptyProject": "Il progetto P6 in questo file XER non contiene attività.", + "xerDuplicateId": "Il file XER contiene un ID WBS, attività o relazione duplicato.", + "xerAmbiguousLocalRelation": "Una relazione XER senza ambito non può essere assegnata in modo univoco a un solo progetto locale.", + "xerDanglingLocalRelation": "Una relazione XER locale fa riferimento a un’attività mancante.", + "xerEnumFallback": "XER {{family}}: token sconosciuto «{{token}}»; usato «{{fallback}}».", + "xerImportOpened_one": "File XER aperto: {{count}} documento di progetto.", + "xerImportOpened_many": "File XER aperto: {{count}} documenti di progetto.", + "xerImportOpened_other": "File XER aperto: {{count}} documenti di progetto.", + "xerImportProjectsSeen_one": "{{count}} progetto trovato.", + "xerImportProjectsSeen_many": "{{count}} progetti trovati.", + "xerImportProjectsSeen_other": "{{count}} progetti trovati.", + "xerImportEmptyProjectsSkipped_one": "{{count}} progetto vuoto ignorato.", + "xerImportEmptyProjectsSkipped_many": "{{count}} progetti vuoti ignorati.", + "xerImportEmptyProjectsSkipped_other": "{{count}} progetti vuoti ignorati.", + "xerImportBaselineProjectsExcluded_one": "{{count}} progetto di baseline escluso.", + "xerImportBaselineProjectsExcluded_many": "{{count}} progetti di baseline esclusi.", + "xerImportBaselineProjectsExcluded_other": "{{count}} progetti di baseline esclusi.", + "xerImportBaselinesMaterialized_one": "{{count}} baseline materializzata.", + "xerImportBaselinesMaterialized_many": "{{count}} baseline materializzate.", + "xerImportBaselinesMaterialized_other": "{{count}} baseline materializzate.", + "xerImportDanglingBaselineReferences_one": "{{count}} riferimento di baseline sospeso ignorato.", + "xerImportDanglingBaselineReferences_many": "{{count}} riferimenti di baseline sospesi ignorati.", + "xerImportDanglingBaselineReferences_other": "{{count}} riferimenti di baseline sospesi ignorati.", + "xerImportBaselineFallback": "È stato usato un ripiego protettivo per la baseline.", + "xerImportExternalLinks_one": "{{count}} collegamento esterno conservato.", + "xerImportExternalLinks_many": "{{count}} collegamenti esterni conservati.", + "xerImportExternalLinks_other": "{{count}} collegamenti esterni conservati.", + "xerImportEncoding": "Codifica del testo scelta: {{encoding}}.", + "xerImportParserIssues_one": "{{count}} riscontro del parser.", + "xerImportParserIssues_many": "{{count}} riscontri del parser.", + "xerImportParserIssues_other": "{{count}} riscontri del parser.", + "xerImportCalendarIssues_one": "{{count}} riscontro del calendario.", + "xerImportCalendarIssues_many": "{{count}} riscontri del calendario.", + "xerImportCalendarIssues_other": "{{count}} riscontri del calendario.", + "xerImportNumberIssues_one": "{{count}} problema di formato numerico.", + "xerImportNumberIssues_many": "{{count}} problemi di formato numerico.", + "xerImportNumberIssues_other": "{{count}} problemi di formato numerico.", + "xerImportEnumFallbacks_one": "{{count}} ripiego enum.", + "xerImportEnumFallbacks_many": "{{count}} ripieghi enum.", + "xerImportEnumFallbacks_other": "{{count}} ripieghi enum.", + "xerImportUnsupportedSemantics_one": "{{count}} impostazione di pianificazione P6 con fallback sicuro.", + "xerImportUnsupportedSemantics_many": "{{count}} impostazioni di pianificazione P6 con fallback sicuro.", + "xerImportUnsupportedSemantics_other": "{{count}} impostazioni di pianificazione P6 con fallback sicuro.", + "xerImportDatesAsRecorded_one": "{{count}} attività mostra le date come registrate da Primavera (non ricalcolate).", + "xerImportDatesAsRecorded_many": "{{count}} attività mostrano le date come registrate da Primavera (non ricalcolate).", + "xerImportDatesAsRecorded_other": "{{count}} attività mostrano le date come registrate da Primavera (non ricalcolate).", + "xerImportDatesAsRecordedOffer_one": "{{count}} attività differisce dalle date registrate nel file — puoi mostrarle.", + "xerImportDatesAsRecordedOffer_many": "{{count}} attività differiscono dalle date registrate nel file — puoi mostrarle.", + "xerImportDatesAsRecordedOffer_other": "{{count}} attività differiscono dalle date registrate nel file — puoi mostrarle.", + "xerExportLoss": "L’esportazione in {{format}} perde informazioni di origine XER.", "mppLegacy": "Questo file .mpp utilizza un formato precedente (Project 2007 o versioni precedenti). Esportalo da MS Project come XML (File → Salva con nome → XML) e apri quel file.", "mppEncrypted": "Questo file .mpp è protetto da password. Esportalo da MS Project come XML (File → Salva con nome → XML) e apri quel file.", "saveFailed": "Salvataggio non riuscito", @@ -799,9 +859,18 @@ "offer_one": "Il ricalcolo ha spostato {{count}} attività su {{total}} rispetto alle date del file.", "offer_many": "Il ricalcolo ha spostato {{count}} attività su {{total}} rispetto alle date del file.", "offer_other": "Il ricalcolo ha spostato {{count}} attività su {{total}} rispetto alle date del file.", + "activeCount_one": "Stai visualizzando la pianificazione come registrata da Primavera; un ricalcolo sposterebbe {{count}} attività.", + "activeCount_many": "Stai visualizzando la pianificazione come registrata da Primavera; un ricalcolo sposterebbe {{count}} attività.", + "activeCount_other": "Stai visualizzando la pianificazione come registrata da Primavera; un ricalcolo sposterebbe {{count}} attività.", + "activeCountNeutral_one": "Stai visualizzando le date come registrate nel file; un ricalcolo sposterebbe {{count}} attività.", + "activeCountNeutral_many": "Stai visualizzando le date come registrate nel file; un ricalcolo sposterebbe {{count}} attività.", + "activeCountNeutral_other": "Stai visualizzando le date come registrate nel file; un ricalcolo sposterebbe {{count}} attività.", "show": "Mostra le date registrate", "active": "Stai visualizzando le date come registrate nel file. Non è stato eseguito alcun ricalcolo.", - "recalculate": "Ricalcola" + "recalculate": "Ricalcola", + "markDeviates": "Diverge", + "markPartlyUnrecorded": "Parzialmente non registrato", + "notRecorded": "Non registrato" }, "duration": { "suffixDay": "g", @@ -989,6 +1058,7 @@ "declaredHeading": "Ciò che questa estensione dichiara di usare", "declaredNote": "È la dichiarazione dell'autore, non una restrizione: il codice può comunque fare di più.", "declaredNone": "Nulla di dichiarato.", + "permImportSource": "importSource — i byte di origine completi di ogni file importato (ad esempio un file Primavera grezzo), compresi i campi che non arrivano mai nel progetto.", "cancel": "Non installare", "confirm": "Installa" } diff --git a/src/i18n/locales/it/report.json b/src/i18n/locales/it/report.json index 636b05d50..30c175888 100644 --- a/src/i18n/locales/it/report.json +++ b/src/i18n/locales/it/report.json @@ -138,6 +138,7 @@ "notCalculatedNote": "Non ancora calcolato — premi Calcola (F5) per date e slack.", "staleNote": "Il programma è cambiato dall'ultimo calcolo — premi Calcola (F5) per valori aggiornati.", "statusDateMissingNote": "Nessuna data di stato impostata — il rapporto usa oggi ({{date}}).", + "recordedDatesNote": "Vista «date come salvate»: questo rapporto mostra le date del file di origine, non un calcolo. Inizi/fini al più tardi e scorrimenti non registrati nel file compaiono vuoti o a 0.", "period": "Periodo", "statusDate": "Data di stato", "common": { diff --git a/src/i18n/locales/it/task.json b/src/i18n/locales/it/task.json index fd7427e11..366232f0e 100644 --- a/src/i18n/locales/it/task.json +++ b/src/i18n/locales/it/task.json @@ -48,7 +48,7 @@ "taskGrid": { "columns": { "customTaskType": "Tipo di attività personalizzato", - "activityCodeData":"Dati dei codici attività","actualDuration":"Durata effettiva","actualFinish":"Fine effettiva","actualStart":"Inizio effettivo","assignedResources":"Risorse assegnate","assignmentCurve":"Curva di assegnazione","assignmentId":"ID assegnazione","assignmentResourceId":"ID risorsa dell’assegnazione","assignmentTaskId":"ID attività dell’assegnazione","assignmentUnits":"Unità di assegnazione al giorno","calendar":"Calendario","childIds":"ID attività figlie","color":"Colore","completion":"Avanzamento","constraint2Date":"Data vincolo secondario","constraint2Type":"Tipo vincolo secondario","constraintDate":"Data vincolo","constraintHard":"Vincolo rigido","constraintType":"Tipo vincolo","critical":"Critica","customFieldData":"Dati dei campi personalizzati","deadline":"Scadenza","description":"Descrizione","duration":"Durata","durationMinutes":"Durata (minuti)","durationType":"Tipo di durata","earlyFinish":"Fine anticipata","earlyStart":"Inizio anticipato","effortDriven":"Basata sull’impegno","externalRelationData":"Dati delle relazioni esterne","floatPath":"Percorso margine","freeFloat":"Margine libero","hammock":"Hammock (durata derivata)","interferingFloat":"Margine interferente","internalRelationData":"Dati delle relazioni interne","lateFinish":"Fine posticipata","lateStart":"Inizio posticipato","levelingDelay":"Ritardo di livellamento","levelingDelayElapsed":"Ritardo di livellamento trascorso","levelingDelayMinutes":"Ritardo di livellamento (minuti)","mandatoryMilestone":"Cardine obbligatorio","manuallyScheduled":"Pianificata manualmente","milestone":"Cardine","milestoneKind":"Tipo di cardine","mspTaskType":"Tipo attività Microsoft Project","name":"Nome attività","nearCritical":"Quasi critica","noteData":"Dati delle note","notes":"Note","parentId":"ID attività padre","priority":"Priorità di livellamento","remainingMinutes":"Rimanente (minuti)","remainingTime":"Rimanente (giorni lavorativi)","resourceIds":"ID risorse","resume":"Data di ripresa","scheduleFinish":"Fine pianificata","scheduleStart":"Inizio pianificato","splitGaps":"Interruzioni","status":"Stato","stop":"Data di arresto","taskId":"ID attività","taskType":"Tipo attività","timephasedContours":"Profili rapportati alla scala cronologica","timephasedDurationWalks":"Segmenti di durata cronologici","timephasedFinishFloor":"Limite inferiore fine cronologica","timephasedStartAnchor":"Ancoraggio inizio cronologico","totalFloat":"Margine totale","wbs":"Struttura di scomposizione del lavoro (WBS)","workWindowFinish":"Fine finestra di lavoro","workWindowStart":"Inizio finestra di lavoro" + "activityCodeData":"Dati dei codici attività","actualDuration":"Durata effettiva","actualFinish":"Fine effettiva","actualStart":"Inizio effettivo","assignedResources":"Risorse assegnate","assignmentCurve":"Curva di assegnazione","assignmentId":"ID assegnazione","assignmentResourceId":"ID risorsa dell’assegnazione","assignmentTaskId":"ID attività dell’assegnazione","assignmentUnits":"Unità di assegnazione al giorno","calendar":"Calendario","childIds":"ID attività figlie","color":"Colore","completion":"Avanzamento","constraint2Date":"Data vincolo secondario","constraint2Type":"Tipo vincolo secondario","constraintDate":"Data vincolo","constraintHard":"Vincolo rigido","constraintType":"Tipo vincolo","critical":"Critica","customFieldData":"Dati dei campi personalizzati","deadline":"Scadenza","description":"Descrizione","duration":"Durata","durationMinutes":"Durata (minuti)","durationType":"Tipo di durata","earlyFinish":"Fine anticipata","earlyStart":"Inizio anticipato","effortDriven":"Basata sull’impegno","explicitSummary":"Attività di riepilogo esplicita","externalRelationData":"Dati delle relazioni esterne","floatPath":"Percorso margine","freeFloat":"Margine libero","hammock":"Hammock (durata derivata)","interferingFloat":"Margine interferente","internalRelationData":"Dati delle relazioni interne","lateFinish":"Fine posticipata","lateStart":"Inizio posticipato","levelingDelay":"Ritardo di livellamento","levelingDelayElapsed":"Ritardo di livellamento trascorso","levelingDelayMinutes":"Ritardo di livellamento (minuti)","mandatoryMilestone":"Cardine obbligatorio","manuallyScheduled":"Pianificata manualmente","milestone":"Cardine","milestoneKind":"Tipo di cardine","mspTaskType":"Tipo attività Microsoft Project","name":"Nome attività","nearCritical":"Quasi critica","noteData":"Dati delle note","notes":"Note","p6Provenance":"Provenienza Primavera P6","parentId":"ID attività padre","priority":"Priorità di livellamento","recordedSource":"Origine (date registrate)","remainingMinutes":"Rimanente (minuti)","remainingTime":"Rimanente (giorni lavorativi)","resourceIds":"ID risorse","resume":"Data di ripresa","scheduleFinish":"Fine pianificata","scheduleStart":"Inizio pianificato","splitGaps":"Interruzioni","status":"Stato","stop":"Data di arresto","taskId":"ID attività","taskType":"Tipo attività","timephasedContours":"Profili rapportati alla scala cronologica","timephasedDurationWalks":"Segmenti di durata cronologici","timephasedFinishFloor":"Limite inferiore fine cronologica","timephasedStartAnchor":"Ancoraggio inizio cronologico","totalFloat":"Margine totale","wbs":"Struttura di scomposizione del lavoro (WBS)","workWindowFinish":"Fine finestra di lavoro","workWindowStart":"Inizio finestra di lavoro" }, "category": { "task": "Attività", "planning": "Pianificazione", "constraints": "Vincoli", "relations": "Relazioni", "resources": "Risorse", @@ -259,6 +259,10 @@ "timephasedActive": "Segue la distribuzione oraria di MS Project", "timephasedLost": "Distribuzione oraria di MS Project non più applicata dopo la modifica — la distribuzione originale resta conservata nel file", "timephasedContoured": "Distribuzione ore propria", + "recordedDatesActive": "Mostra le date registrate da Primavera per questa attività", + "recordedDatesActiveNeutral": "Mostra le date così come registrate nel file per questa attività", + "recordedDatesDeviates": "Diverge dalle date registrate", + "recordedDatesPartlyUnrecorded": "Registrazione parzialmente incompleta — vedere le colonne di fine tardiva/margine", "completion": "Progresso (%)", "progress": { "actualStart": "Inizio effettivo", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 803c4f393..f4c31e03a 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -6,6 +6,38 @@ "notifications": { "dismiss": "通知を閉じる", "openFailed": "ファイルを開けませんでした", + "xerInvalidInput": "XER のインポートには元のファイルバイトが必要です。", + "xerInvalidFile": "これは有効または対応済みの XER ファイルではありません。", + "xerInvalidEncoding": "この XER ファイルの文字エンコーディングは無効または途中で切れています。", + "xerDuplicateTable": "XER ファイルに重複したテーブルがあります。", + "xerMissingRequiredColumns": "XER テーブルに必須列がありません。", + "xerMissingRequiredValue": "XER レコードに必須値がありません。", + "xerAmbiguousDecimal": "この XER ファイルの小数表記を確実に判定できません。", + "xerInvalidNumberFormat": "この XER ファイルの数値形式は無効です。", + "xerInvalidNumber": "XER ファイルに無効な数値があります。", + "xerSingleProjectRequired": "このインポートは XER ファイルごとに 1 つの P6 プロジェクトのみ対応します。", + "xerEmptyProject": "この XER ファイルの P6 プロジェクトにはアクティビティがありません。", + "xerDuplicateId": "XER ファイルに重複した WBS、アクティビティ、または関係 ID があります。", + "xerAmbiguousLocalRelation": "スコープのない XER 関係を 1 つのローカルプロジェクトに一意に割り当てられません。", + "xerDanglingLocalRelation": "ローカル XER 関係が存在しないアクティビティを参照しています。", + "xerEnumFallback": "XER {{family}}: 不明なトークン「{{token}}」のため「{{fallback}}」を使用しました。", + "xerImportOpened_other": "XER ファイルを開きました: プロジェクト文書 {{count}} 件。", + "xerImportProjectsSeen_other": "検出したプロジェクト: {{count}}", + "xerImportEmptyProjectsSkipped_other": "空のプロジェクトをスキップ: {{count}}", + "xerImportBaselineProjectsExcluded_other": "除外したベースラインプロジェクト: {{count}}", + "xerImportBaselinesMaterialized_other": "具体化したベースライン: {{count}}", + "xerImportDanglingBaselineReferences_other": "無効なベースライン参照を無視: {{count}}", + "xerImportBaselineFallback": "保護的なベースラインのフォールバックを使用しました。", + "xerImportExternalLinks_other": "外部リンクを保持: {{count}}", + "xerImportEncoding": "選択したテキストエンコーディング: {{encoding}}。", + "xerImportParserIssues_other": "パーサーの検出事項: {{count}}", + "xerImportCalendarIssues_other": "カレンダーの検出事項: {{count}}", + "xerImportNumberIssues_other": "数値形式の問題: {{count}}", + "xerImportEnumFallbacks_other": "列挙値のフォールバック: {{count}}", + "xerImportUnsupportedSemantics_other": "安全なフォールバックを使った P6 計画設定: {{count}}", + "xerImportDatesAsRecorded_other": "Primavera が記録した日付をそのまま表示しているタスク: {{count}} 件(再計算していません)", + "xerImportDatesAsRecordedOffer_other": "ファイルに記録された日付と異なるタスク: {{count}} 件(表示できます)", + "xerExportLoss": "{{format}} へのエクスポートでは XER のソース情報が失われます。", "mppLegacy": "この .mpp ファイルは古い形式(Project 2007 以前)を使用しています。MS Project で XML としてエクスポートし(ファイル → 名前を付けて保存 → XML)、そのファイルを開いてください。", "mppEncrypted": "この .mpp ファイルはパスワードで保護されています。MS Project で XML としてエクスポートし(ファイル → 名前を付けて保存 → XML)、そのファイルを開いてください。", "saveFailed": "保存できませんでした", @@ -773,9 +805,14 @@ }, "recordedDates": { "offer_other": "再計算により、{{total}} 件中 {{count}} 件のタスクがファイル内の日付からずれました。", + "activeCount_other": "Primavera が記録したスケジュールを表示しています。再計算すると {{count}} 件のタスクがずれます。", + "activeCountNeutral_other": "ファイルに記録されている日付を表示しています。再計算すると {{count}} 件のタスクがずれます。", "show": "記録された日付を表示", "active": "ファイルに記録されている日付をそのまま表示しています。再計算は行われていません。", - "recalculate": "再計算" + "recalculate": "再計算", + "markDeviates": "乖離あり", + "markPartlyUnrecorded": "一部未記録", + "notRecorded": "未記録" }, "duration": { "suffixDay": "日", @@ -953,6 +990,7 @@ "declaredHeading": "この拡張機能が使用すると申告している項目", "declaredNote": "これは作成者による申告であり、制限ではありません。コードはそれ以上のことも実行できます。", "declaredNone": "申告なし。", + "permImportSource": "importSource — インポートした各ファイルの完全な元のソースバイト (例: 生の Primavera ファイル)。プロジェクトに取り込まれないフィールドも含みます。", "cancel": "インストールしない", "confirm": "インストール" } diff --git a/src/i18n/locales/ja/report.json b/src/i18n/locales/ja/report.json index e450d556d..1588b52ab 100644 --- a/src/i18n/locales/ja/report.json +++ b/src/i18n/locales/ja/report.json @@ -132,6 +132,7 @@ "notCalculatedNote": "未計算です — 日付とフロートを得るには「計算」(F5) を押してください。", "staleNote": "前回の計算以降にスケジュールが変更されました — 最新の値を得るには「計算」(F5) を押してください。", "statusDateMissingNote": "ステータス日が未設定です — レポートは今日 ({{date}}) を基準にします。", + "recordedDatesNote": "「保存された日付」表示: このレポートは計算結果ではなく、元ファイルの日付を示します。ファイルに記録されていない最遅開始/終了とフロートは空欄または 0 で表示されます。", "period": "期間", "statusDate": "ステータス日", "common": { diff --git a/src/i18n/locales/ja/task.json b/src/i18n/locales/ja/task.json index 2424524c7..67ef0962c 100644 --- a/src/i18n/locales/ja/task.json +++ b/src/i18n/locales/ja/task.json @@ -48,7 +48,7 @@ "taskGrid": { "columns": { "customTaskType": "カスタムタスクタイプ", - "activityCodeData":"アクティビティコードデータ","actualDuration":"実績期間","actualFinish":"実績終了日","actualStart":"実績開始日","assignedResources":"割り当て済みリソース","assignmentCurve":"割り当て曲線","assignmentId":"割り当て ID","assignmentResourceId":"割り当てリソース ID","assignmentTaskId":"割り当てタスク ID","assignmentUnits":"1日あたりの割り当て単位","calendar":"カレンダー","childIds":"子タスク ID","color":"色","completion":"進捗","constraint2Date":"第2制約日","constraint2Type":"第2制約タイプ","constraintDate":"制約日","constraintHard":"強制制約","constraintType":"制約タイプ","critical":"クリティカル","customFieldData":"カスタムフィールドデータ","deadline":"期限","description":"説明","duration":"期間","durationMinutes":"期間(分)","durationType":"期間タイプ","earlyFinish":"最早終了日","earlyStart":"最早開始日","effortDriven":"作業時間主導","externalRelationData":"外部依存関係データ","floatPath":"余裕パス","freeFloat":"自由余裕","hammock":"ハンモック(派生期間)","interferingFloat":"干渉余裕","internalRelationData":"内部依存関係データ","lateFinish":"最遅終了日","lateStart":"最遅開始日","levelingDelay":"平準化遅延","levelingDelayElapsed":"経過時間による平準化遅延","levelingDelayMinutes":"平準化遅延(分)","mandatoryMilestone":"必須マイルストーン","manuallyScheduled":"手動スケジュール","milestone":"マイルストーン","milestoneKind":"マイルストーンタイプ","mspTaskType":"Microsoft Project タスクタイプ","name":"タスク名","nearCritical":"準クリティカル","noteData":"メモデータ","notes":"メモ","parentId":"親タスク ID","priority":"平準化優先度","remainingMinutes":"残り(分)","remainingTime":"残り(稼働日)","resourceIds":"リソース ID","resume":"再開日","scheduleFinish":"予定終了日","scheduleStart":"予定開始日","splitGaps":"中断期間","status":"状態","stop":"停止日","taskId":"タスク ID","taskType":"タスクタイプ","timephasedContours":"タイムフェーズ輪郭","timephasedDurationWalks":"タイムフェーズ期間セグメント","timephasedFinishFloor":"タイムフェーズ終了下限","timephasedStartAnchor":"タイムフェーズ開始アンカー","totalFloat":"総余裕","wbs":"作業分解構成(WBS)","workWindowFinish":"作業枠の終了","workWindowStart":"作業枠の開始" + "activityCodeData":"アクティビティコードデータ","actualDuration":"実績期間","actualFinish":"実績終了日","actualStart":"実績開始日","assignedResources":"割り当て済みリソース","assignmentCurve":"割り当て曲線","assignmentId":"割り当て ID","assignmentResourceId":"割り当てリソース ID","assignmentTaskId":"割り当てタスク ID","assignmentUnits":"1日あたりの割り当て単位","calendar":"カレンダー","childIds":"子タスク ID","color":"色","completion":"進捗","constraint2Date":"第2制約日","constraint2Type":"第2制約タイプ","constraintDate":"制約日","constraintHard":"強制制約","constraintType":"制約タイプ","critical":"クリティカル","customFieldData":"カスタムフィールドデータ","deadline":"期限","description":"説明","duration":"期間","durationMinutes":"期間(分)","durationType":"期間タイプ","earlyFinish":"最早終了日","earlyStart":"最早開始日","effortDriven":"作業時間主導","explicitSummary":"明示的なサマリータスク","externalRelationData":"外部依存関係データ","floatPath":"余裕パス","freeFloat":"自由余裕","hammock":"ハンモック(派生期間)","interferingFloat":"干渉余裕","internalRelationData":"内部依存関係データ","lateFinish":"最遅終了日","lateStart":"最遅開始日","levelingDelay":"平準化遅延","levelingDelayElapsed":"経過時間による平準化遅延","levelingDelayMinutes":"平準化遅延(分)","mandatoryMilestone":"必須マイルストーン","manuallyScheduled":"手動スケジュール","milestone":"マイルストーン","milestoneKind":"マイルストーンタイプ","mspTaskType":"Microsoft Project タスクタイプ","name":"タスク名","nearCritical":"準クリティカル","noteData":"メモデータ","notes":"メモ","p6Provenance":"Primavera P6 の出所","parentId":"親タスク ID","priority":"平準化優先度","recordedSource":"出所(記録済みの日付)","remainingMinutes":"残り(分)","remainingTime":"残り(稼働日)","resourceIds":"リソース ID","resume":"再開日","scheduleFinish":"予定終了日","scheduleStart":"予定開始日","splitGaps":"中断期間","status":"状態","stop":"停止日","taskId":"タスク ID","taskType":"タスクタイプ","timephasedContours":"タイムフェーズ輪郭","timephasedDurationWalks":"タイムフェーズ期間セグメント","timephasedFinishFloor":"タイムフェーズ終了下限","timephasedStartAnchor":"タイムフェーズ開始アンカー","totalFloat":"総余裕","wbs":"作業分解構成(WBS)","workWindowFinish":"作業枠の終了","workWindowStart":"作業枠の開始" }, "category": { "task": "タスク", "planning": "計画", "constraints": "制約", "relations": "依存関係", "resources": "リソース", @@ -251,6 +251,10 @@ "timephasedActive": "MS Project の時間配分に従っています", "timephasedLost": "編集後、MS Project の時間配分は適用されなくなりました — 元の配分はファイルに保存されたままです", "timephasedContoured": "独自の時間配分", + "recordedDatesActive": "このタスクについて Primavera 自身が記録した日付を表示します", + "recordedDatesActiveNeutral": "このタスクについてファイルに記録された日付を表示します", + "recordedDatesDeviates": "記録された日付と乖離しています", + "recordedDatesPartlyUnrecorded": "記録データが一部不完全です — 最遅/余裕列を参照してください", "completion": "進捗(%)", "progress": { "actualStart": "実績開始", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 75d855eb1..648534630 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -6,6 +6,38 @@ "notifications": { "dismiss": "알림 닫기", "openFailed": "파일을 열 수 없습니다", + "xerInvalidInput": "XER 가져오기는 원본 파일 바이트가 필요합니다.", + "xerInvalidFile": "유효하거나 지원되는 XER 파일이 아닙니다.", + "xerInvalidEncoding": "이 XER 파일의 문자 인코딩이 잘못되었거나 잘렸습니다.", + "xerDuplicateTable": "XER 파일에 중복된 테이블이 있습니다.", + "xerMissingRequiredColumns": "XER 테이블에 필수 열이 없습니다.", + "xerMissingRequiredValue": "XER 레코드에 필수 값이 없습니다.", + "xerAmbiguousDecimal": "이 XER 파일의 소수 표기법을 확실하게 확인할 수 없습니다.", + "xerInvalidNumberFormat": "이 XER 파일의 숫자 형식이 잘못되었습니다.", + "xerInvalidNumber": "XER 파일에 잘못된 숫자가 있습니다.", + "xerSingleProjectRequired": "이 가져오기는 XER 파일당 정확히 하나의 P6 프로젝트를 지원합니다.", + "xerEmptyProject": "이 XER 파일의 P6 프로젝트에 활동이 없습니다.", + "xerDuplicateId": "XER 파일에 중복된 WBS, 활동 또는 관계 ID가 있습니다.", + "xerAmbiguousLocalRelation": "범위가 없는 XER 관계를 하나의 로컬 프로젝트에 명확하게 할당할 수 없습니다.", + "xerDanglingLocalRelation": "로컬 XER 관계가 누락된 활동을 참조합니다.", + "xerEnumFallback": "XER {{family}}: 알 수 없는 토큰 ‘{{token}}’; ‘{{fallback}}’을 사용했습니다.", + "xerImportOpened_other": "XER 파일을 열었습니다: 프로젝트 문서 {{count}}개.", + "xerImportProjectsSeen_other": "찾은 프로젝트: {{count}}", + "xerImportEmptyProjectsSkipped_other": "빈 프로젝트 건너뜀: {{count}}", + "xerImportBaselineProjectsExcluded_other": "기준선 프로젝트 제외: {{count}}", + "xerImportBaselinesMaterialized_other": "구체화한 기준선: {{count}}", + "xerImportDanglingBaselineReferences_other": "끊어진 기준선 참조 무시: {{count}}", + "xerImportBaselineFallback": "보호용 기준선 대체 경로를 사용했습니다.", + "xerImportExternalLinks_other": "외부 연결 보존: {{count}}", + "xerImportEncoding": "선택한 텍스트 인코딩: {{encoding}}.", + "xerImportParserIssues_other": "파서 발견 사항: {{count}}", + "xerImportCalendarIssues_other": "달력 발견 사항: {{count}}", + "xerImportNumberIssues_other": "숫자 형식 문제: {{count}}", + "xerImportEnumFallbacks_other": "열거형 대체: {{count}}", + "xerImportUnsupportedSemantics_other": "안전한 대체를 사용한 P6 일정 설정: {{count}}", + "xerImportDatesAsRecorded_other": "Primavera에 기록된 날짜를 그대로 표시하는 작업: {{count}}개(다시 계산하지 않음)", + "xerImportDatesAsRecordedOffer_other": "파일에 기록된 날짜와 다른 작업: {{count}}개(표시할 수 있습니다)", + "xerExportLoss": "{{format}} 형식으로 내보내면 XER 원본 정보가 손실됩니다.", "mppLegacy": "이 .mpp 파일은 이전 형식(Project 2007 이하)을 사용합니다. MS Project에서 XML로 내보낸 후(파일 → 다른 이름으로 저장 → XML) 해당 파일을 여세요.", "mppEncrypted": "이 .mpp 파일은 암호로 보호되어 있습니다. MS Project에서 XML로 내보낸 후(파일 → 다른 이름으로 저장 → XML) 해당 파일을 여세요.", "saveFailed": "저장하지 못했습니다", @@ -773,9 +805,14 @@ }, "recordedDates": { "offer_other": "다시 계산으로 인해 {{total}}개 작업 중 {{count}}개가 파일에 기록된 날짜에서 벗어났습니다.", + "activeCount_other": "Primavera에 기록된 일정을 보고 있습니다. 다시 계산하면 {{count}}개의 작업이 이동합니다.", + "activeCountNeutral_other": "파일에 기록된 날짜를 보고 있습니다. 다시 계산하면 {{count}}개의 작업이 이동합니다.", "show": "기록된 날짜 표시", "active": "파일에 기록된 날짜를 그대로 보고 있습니다. 다시 계산되지 않았습니다.", - "recalculate": "다시 계산" + "recalculate": "다시 계산", + "markDeviates": "차이 있음", + "markPartlyUnrecorded": "일부 미기록", + "notRecorded": "미기록" }, "duration": { "suffixDay": "일", @@ -953,6 +990,7 @@ "declaredHeading": "이 확장이 사용한다고 밝힌 항목", "declaredNote": "작성자의 신고일 뿐 제한이 아닙니다 — 코드는 그보다 더 많은 일을 할 수 있습니다.", "declaredNone": "신고된 항목 없음.", + "permImportSource": "importSource — 가져온 모든 파일의 완전한 원본 소스 바이트(예: 원시 Primavera 파일). 프로젝트에 포함되지 않는 필드까지 포함합니다.", "cancel": "설치 안 함", "confirm": "설치" } diff --git a/src/i18n/locales/ko/report.json b/src/i18n/locales/ko/report.json index 7a279fdb9..47f15649f 100644 --- a/src/i18n/locales/ko/report.json +++ b/src/i18n/locales/ko/report.json @@ -132,6 +132,7 @@ "notCalculatedNote": "아직 계산되지 않았습니다 — 날짜와 여유를 보려면 계산(F5)을 누르세요.", "staleNote": "마지막 계산 이후 일정이 변경되었습니다 — 최신 값을 보려면 계산(F5)을 누르세요.", "statusDateMissingNote": "상태 기준일이 설정되지 않았습니다 — 보고서는 오늘({{date}})을 기준으로 합니다.", + "recordedDatesNote": "'저장된 날짜' 보기: 이 보고서는 계산 결과가 아니라 원본 파일의 날짜를 표시합니다. 파일에 기록되지 않은 가장 늦은 시작/완료와 여유 시간은 비어 있거나 0으로 표시됩니다.", "period": "기간", "statusDate": "상태 기준일", "common": { diff --git a/src/i18n/locales/ko/task.json b/src/i18n/locales/ko/task.json index ed07a8c34..3857cc1a7 100644 --- a/src/i18n/locales/ko/task.json +++ b/src/i18n/locales/ko/task.json @@ -48,7 +48,7 @@ "taskGrid": { "columns": { "customTaskType": "사용자 지정 작업 유형", - "activityCodeData":"활동 코드 데이터","actualDuration":"실제 기간","actualFinish":"실제 완료","actualStart":"실제 시작","assignedResources":"배정된 리소스","assignmentCurve":"배정 곡선","assignmentId":"배정 ID","assignmentResourceId":"배정 리소스 ID","assignmentTaskId":"배정 작업 ID","assignmentUnits":"일일 배정 단위","calendar":"달력","childIds":"하위 작업 ID","color":"색상","completion":"진행률","constraint2Date":"보조 제약 날짜","constraint2Type":"보조 제약 유형","constraintDate":"제약 날짜","constraintHard":"강제 제약","constraintType":"제약 유형","critical":"주공정","customFieldData":"사용자 지정 필드 데이터","deadline":"기한","description":"설명","duration":"기간","durationMinutes":"기간(분)","durationType":"기간 유형","earlyFinish":"빠른 완료","earlyStart":"빠른 시작","effortDriven":"작업량 기준","externalRelationData":"외부 관계 데이터","floatPath":"여유 경로","freeFloat":"자유 여유","hammock":"해먹(파생 기간)","interferingFloat":"간섭 여유","internalRelationData":"내부 관계 데이터","lateFinish":"늦은 완료","lateStart":"늦은 시작","levelingDelay":"평준화 지연","levelingDelayElapsed":"경과 평준화 지연","levelingDelayMinutes":"평준화 지연(분)","mandatoryMilestone":"필수 마일스톤","manuallyScheduled":"수동 일정","milestone":"마일스톤","milestoneKind":"마일스톤 유형","mspTaskType":"Microsoft Project 작업 유형","name":"작업 이름","nearCritical":"준주공정","noteData":"메모 데이터","notes":"메모","parentId":"상위 작업 ID","priority":"평준화 우선순위","remainingMinutes":"남음(분)","remainingTime":"남음(근무일)","resourceIds":"리소스 ID","resume":"재개 날짜","scheduleFinish":"예정 완료","scheduleStart":"예정 시작","splitGaps":"중단 구간","status":"상태","stop":"중지 날짜","taskId":"작업 ID","taskType":"작업 유형","timephasedContours":"시간대별 분포","timephasedDurationWalks":"시간대별 기간 구간","timephasedFinishFloor":"시간대별 완료 하한","timephasedStartAnchor":"시간대별 시작 기준점","totalFloat":"총 여유","wbs":"작업 분류 체계(WBS)","workWindowFinish":"작업 창 종료","workWindowStart":"작업 창 시작" + "activityCodeData":"활동 코드 데이터","actualDuration":"실제 기간","actualFinish":"실제 완료","actualStart":"실제 시작","assignedResources":"배정된 리소스","assignmentCurve":"배정 곡선","assignmentId":"배정 ID","assignmentResourceId":"배정 리소스 ID","assignmentTaskId":"배정 작업 ID","assignmentUnits":"일일 배정 단위","calendar":"달력","childIds":"하위 작업 ID","color":"색상","completion":"진행률","constraint2Date":"보조 제약 날짜","constraint2Type":"보조 제약 유형","constraintDate":"제약 날짜","constraintHard":"강제 제약","constraintType":"제약 유형","critical":"주공정","customFieldData":"사용자 지정 필드 데이터","deadline":"기한","description":"설명","duration":"기간","durationMinutes":"기간(분)","durationType":"기간 유형","earlyFinish":"빠른 완료","earlyStart":"빠른 시작","effortDriven":"작업량 기준","explicitSummary":"명시적 요약 작업","externalRelationData":"외부 관계 데이터","floatPath":"여유 경로","freeFloat":"자유 여유","hammock":"해먹(파생 기간)","interferingFloat":"간섭 여유","internalRelationData":"내부 관계 데이터","lateFinish":"늦은 완료","lateStart":"늦은 시작","levelingDelay":"평준화 지연","levelingDelayElapsed":"경과 평준화 지연","levelingDelayMinutes":"평준화 지연(분)","mandatoryMilestone":"필수 마일스톤","manuallyScheduled":"수동 일정","milestone":"마일스톤","milestoneKind":"마일스톤 유형","mspTaskType":"Microsoft Project 작업 유형","name":"작업 이름","nearCritical":"준주공정","noteData":"메모 데이터","notes":"메모","p6Provenance":"Primavera P6 출처","parentId":"상위 작업 ID","priority":"평준화 우선순위","recordedSource":"출처(기록된 날짜)","remainingMinutes":"남음(분)","remainingTime":"남음(근무일)","resourceIds":"리소스 ID","resume":"재개 날짜","scheduleFinish":"예정 완료","scheduleStart":"예정 시작","splitGaps":"중단 구간","status":"상태","stop":"중지 날짜","taskId":"작업 ID","taskType":"작업 유형","timephasedContours":"시간대별 분포","timephasedDurationWalks":"시간대별 기간 구간","timephasedFinishFloor":"시간대별 완료 하한","timephasedStartAnchor":"시간대별 시작 기준점","totalFloat":"총 여유","wbs":"작업 분류 체계(WBS)","workWindowFinish":"작업 창 종료","workWindowStart":"작업 창 시작" }, "category": { "task": "작업", "planning": "계획", "constraints": "제약 조건", "relations": "관계", "resources": "리소스", @@ -251,6 +251,10 @@ "timephasedActive": "MS Project의 시간 분배를 따릅니다", "timephasedLost": "편집 후 MS Project의 시간 분배가 더 이상 적용되지 않습니다 — 원래 분배는 파일에 그대로 보존됩니다", "timephasedContoured": "고유 시간 분배", + "recordedDatesActive": "이 작업에 대해 Primavera가 기록한 고유 날짜를 표시합니다", + "recordedDatesActiveNeutral": "이 작업에 대해 파일에 기록된 날짜를 표시합니다", + "recordedDatesDeviates": "기록된 날짜와 차이가 있습니다", + "recordedDatesPartlyUnrecorded": "기록이 일부 불완전합니다 — 최지연/여유 열을 참조하세요", "completion": "진행률 (%)", "progress": { "actualStart": "실제 시작", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 2e07b9821..9f2398bd4 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -11,6 +11,52 @@ "notifications": { "dismiss": "Melding sluiten", "openFailed": "Bestand openen mislukt", + "xerInvalidInput": "XER-import vereist de oorspronkelijke bestandsbytes.", + "xerInvalidFile": "Dit bestand is geen geldig of ondersteund XER-bestand.", + "xerInvalidEncoding": "De tekencodering van dit XER-bestand is ongeldig of afgekapt.", + "xerDuplicateTable": "Het XER-bestand bevat een dubbele tabel.", + "xerMissingRequiredColumns": "Een XER-tabel mist verplichte kolommen.", + "xerMissingRequiredValue": "Een XER-record mist een verplichte waarde.", + "xerAmbiguousDecimal": "De decimaalnotatie van dit XER-bestand is niet betrouwbaar vast te stellen.", + "xerInvalidNumberFormat": "De getalnotatie van dit XER-bestand is ongeldig.", + "xerInvalidNumber": "Het XER-bestand bevat een ongeldig getal.", + "xerSingleProjectRequired": "Deze import ondersteunt precies één P6-project per XER-bestand.", + "xerEmptyProject": "Het P6-project in dit XER-bestand bevat geen activiteiten.", + "xerDuplicateId": "Het XER-bestand bevat een dubbele WBS-, activiteit- of relatie-id.", + "xerAmbiguousLocalRelation": "Een ongescopeerde XER-relatie is niet eenduidig aan één lokaal project toe te wijzen.", + "xerDanglingLocalRelation": "Een lokale XER-relatie verwijst naar een ontbrekende activiteit.", + "xerEnumFallback": "XER {{family}}: onbekend token ‘{{token}}’; teruggevallen op ‘{{fallback}}’.", + "xerImportOpened_one": "XER-bestand geopend: {{count}} projectdocument.", + "xerImportOpened_other": "XER-bestand geopend: {{count}} projectdocumenten.", + "xerImportProjectsSeen_one": "{{count}} project gezien.", + "xerImportProjectsSeen_other": "{{count}} projecten gezien.", + "xerImportEmptyProjectsSkipped_one": "{{count}} leeg project overgeslagen.", + "xerImportEmptyProjectsSkipped_other": "{{count}} lege projecten overgeslagen.", + "xerImportBaselineProjectsExcluded_one": "{{count}} baselineproject uitgesloten.", + "xerImportBaselineProjectsExcluded_other": "{{count}} baselineprojecten uitgesloten.", + "xerImportBaselinesMaterialized_one": "{{count}} baseline gematerialiseerd.", + "xerImportBaselinesMaterialized_other": "{{count}} baselines gematerialiseerd.", + "xerImportDanglingBaselineReferences_one": "{{count}} losse baselineverwijzing genegeerd.", + "xerImportDanglingBaselineReferences_other": "{{count}} losse baselineverwijzingen genegeerd.", + "xerImportBaselineFallback": "Beschermende baseline-terugval gebruikt.", + "xerImportExternalLinks_one": "{{count}} externe koppeling bewaard.", + "xerImportExternalLinks_other": "{{count}} externe koppelingen bewaard.", + "xerImportEncoding": "Tekstcodering vastgesteld als {{encoding}}.", + "xerImportParserIssues_one": "{{count}} parserbevinding.", + "xerImportParserIssues_other": "{{count}} parserbevindingen.", + "xerImportCalendarIssues_one": "{{count}} kalenderbevinding.", + "xerImportCalendarIssues_other": "{{count}} kalenderbevindingen.", + "xerImportNumberIssues_one": "{{count}} getalnotatieprobleem.", + "xerImportNumberIssues_other": "{{count}} getalnotatieproblemen.", + "xerImportEnumFallbacks_one": "{{count}} enum-terugval.", + "xerImportEnumFallbacks_other": "{{count}} enum-terugvallen.", + "xerImportUnsupportedSemantics_one": "{{count}} P6-planningsinstelling met veilige terugval.", + "xerImportUnsupportedSemantics_other": "{{count}} P6-planningsinstellingen met veilige terugval.", + "xerImportDatesAsRecorded_one": "{{count}} taak toont de datums zoals Primavera ze opsloeg (niet herberekend).", + "xerImportDatesAsRecorded_other": "{{count}} taken tonen de datums zoals Primavera ze opsloeg (niet herberekend).", + "xerImportDatesAsRecordedOffer_one": "{{count}} taak wijkt af van de datums in het bestand — je kunt ze tonen.", + "xerImportDatesAsRecordedOffer_other": "{{count}} taken wijken af van de datums in het bestand — je kunt ze tonen.", + "xerExportLoss": "Bij export naar {{format}} gaat XER-broninformatie verloren.", "mppLegacy": "Dit .mpp-bestand gebruikt een oud formaat (Project 2007 of ouder). Exporteer het in MS Project als XML (Bestand → Opslaan als → XML) en open dat bestand.", "mppEncrypted": "Dit .mpp-bestand is met een wachtwoord beveiligd. Exporteer het in MS Project als XML (Bestand → Opslaan als → XML) en open dat bestand.", "saveFailed": "Opslaan mislukt", @@ -809,9 +855,16 @@ "recordedDates": { "offer_one": "Herberekening verschoof {{count}} van de {{total}} taken ten opzichte van de datums in het bestand.", "offer_other": "Herberekening verschoof {{count}} van de {{total}} taken ten opzichte van de datums in het bestand.", + "activeCount_one": "Je ziet de planning zoals Primavera hem opsloeg; bij herberekenen wijkt {{count}} taak af.", + "activeCount_other": "Je ziet de planning zoals Primavera hem opsloeg; bij herberekenen wijken {{count}} taken af.", + "activeCountNeutral_one": "Je ziet de datums zoals ze in het bestand staan; bij herberekenen wijkt {{count}} taak af.", + "activeCountNeutral_other": "Je ziet de datums zoals ze in het bestand staan; bij herberekenen wijken {{count}} taken af.", "show": "Opgeslagen datums tonen", "active": "Je ziet de datums zoals ze in het bestand staan. Er is niet herberekend.", - "recalculate": "Herberekenen" + "recalculate": "Herberekenen", + "markDeviates": "Wijkt af", + "markPartlyUnrecorded": "Deels niet vastgelegd", + "notRecorded": "Niet vastgelegd" }, "duration": { "suffixDay": "d", @@ -994,6 +1047,7 @@ "declaredHeading": "Wat deze extensie zegt te gebruiken", "declaredNote": "Dit is de opgave van de maker, geen beperking — de code kan hoe dan ook meer.", "declaredNone": "Niets opgegeven.", + "permImportSource": "importSource — de volledige oorspronkelijke bronbytes van elk geïmporteerd bestand (bijvoorbeeld een rauw Primavera-bestand), ook velden die niet in het project terechtkomen.", "cancel": "Niet installeren", "confirm": "Installeren" } diff --git a/src/i18n/locales/nl/report.json b/src/i18n/locales/nl/report.json index b1ad2e18c..f07e82a01 100644 --- a/src/i18n/locales/nl/report.json +++ b/src/i18n/locales/nl/report.json @@ -135,6 +135,7 @@ "notCalculatedNote": "Nog niet berekend — druk op Bereken (F5) voor datums en speling.", "staleNote": "De planning is gewijzigd sinds de laatste berekening — druk op Bereken (F5) voor actuele waarden.", "statusDateMissingNote": "Geen statusdatum ingesteld — het rapport rekent met vandaag ({{date}}).", + "recordedDatesNote": "Weergave 'datums zoals opgeslagen': dit rapport toont de datums uit het bronbestand, niet een berekening. Laatste start/einde en speling die het bestand niet vastlegde staan hier leeg of op 0.", "period": "Periode", "statusDate": "Statusdatum", "common": { diff --git a/src/i18n/locales/nl/task.json b/src/i18n/locales/nl/task.json index 6195ae17d..3a8b76c13 100644 --- a/src/i18n/locales/nl/task.json +++ b/src/i18n/locales/nl/task.json @@ -77,6 +77,7 @@ "earlyFinish": "Vroegste einde", "earlyStart": "Vroegste start", "effortDriven": "Inspanningsgestuurd", + "explicitSummary": "Expliciete verzameltaak", "externalRelationData": "Externe-relatiedata", "floatPath": "Spelingpad", "freeFloat": "Vrije speling", @@ -97,8 +98,10 @@ "nearCritical": "Bijna kritiek", "noteData": "Aantekeningendata", "notes": "Aantekeningen", + "p6Provenance": "Primavera P6-herkomst", "parentId": "Bovenliggende taak-id", "priority": "Nivelleerprioriteit", + "recordedSource": "Herkomst (opgeslagen datums)", "remainingMinutes": "Resterend (minuten)", "remainingTime": "Resterend (werkdagen)", "resourceIds": "Resource-id’s", @@ -375,6 +378,10 @@ "timephasedActive": "Volgt de urenverdeling uit MS Project", "timephasedLost": "Datumvenster uit MS Project niet meer toegepast na bewerking — de urenverdeling zelf blijft gelden en bewaard", "timephasedContoured": "Eigen urenverdeling", + "recordedDatesActive": "Toont Primavera’s eigen opgeslagen datums voor deze taak", + "recordedDatesActiveNeutral": "Toont de datums zoals ze in het bestand staan voor deze taak", + "recordedDatesDeviates": "Wijkt af van de opgeslagen datums", + "recordedDatesPartlyUnrecorded": "Vastlegging deels onvolledig — zie de late-/spelingkolommen", "completion": "Voortgang (%)", "progress": { "actualStart": "Werkelijke start", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index d9ebdf5a6..9d07421fe 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -6,6 +6,80 @@ "notifications": { "dismiss": "Zamknij powiadomienie", "openFailed": "Nie udało się otworzyć pliku", + "xerInvalidInput": "Import XER wymaga oryginalnych bajtów pliku.", + "xerInvalidFile": "Ten plik XER jest nieprawidłowy lub nieobsługiwany.", + "xerInvalidEncoding": "Kodowanie tekstu w tym pliku XER jest nieprawidłowe lub ucięte.", + "xerDuplicateTable": "Plik XER zawiera zduplikowaną tabelę.", + "xerMissingRequiredColumns": "W tabeli XER brakuje wymaganych kolumn.", + "xerMissingRequiredValue": "W rekordzie XER brakuje wymaganej wartości.", + "xerAmbiguousDecimal": "Nie można wiarygodnie ustalić zapisu dziesiętnego w tym pliku XER.", + "xerInvalidNumberFormat": "Ten plik XER zawiera nieprawidłowy format liczb.", + "xerInvalidNumber": "Plik XER zawiera nieprawidłową liczbę.", + "xerSingleProjectRequired": "Ten import obsługuje dokładnie jeden projekt P6 na plik XER.", + "xerEmptyProject": "Projekt P6 w tym pliku XER nie zawiera działań.", + "xerDuplicateId": "Plik XER zawiera zduplikowany identyfikator WBS, działania lub relacji.", + "xerAmbiguousLocalRelation": "Relacji XER bez zakresu nie można jednoznacznie przypisać do jednego projektu lokalnego.", + "xerDanglingLocalRelation": "Lokalna relacja XER odwołuje się do brakującego działania.", + "xerEnumFallback": "XER {{family}}: nieznany token „{{token}}”; użyto „{{fallback}}”.", + "xerImportOpened_one": "Otwarto plik XER: {{count}} dokument projektu.", + "xerImportOpened_few": "Otwarto plik XER: {{count}} dokumenty projektu.", + "xerImportOpened_many": "Otwarto plik XER: {{count}} dokumentów projektu.", + "xerImportOpened_other": "Otwarto plik XER: {{count}} dokumentu projektu.", + "xerImportProjectsSeen_one": "Znaleziono {{count}} projekt.", + "xerImportProjectsSeen_few": "Znaleziono {{count}} projekty.", + "xerImportProjectsSeen_many": "Znaleziono {{count}} projektów.", + "xerImportProjectsSeen_other": "Znaleziono {{count}} projektu.", + "xerImportEmptyProjectsSkipped_one": "Pominięto {{count}} pusty projekt.", + "xerImportEmptyProjectsSkipped_few": "Pominięto {{count}} puste projekty.", + "xerImportEmptyProjectsSkipped_many": "Pominięto {{count}} pustych projektów.", + "xerImportEmptyProjectsSkipped_other": "Pominięto {{count}} pustego projektu.", + "xerImportBaselineProjectsExcluded_one": "Wykluczono {{count}} projekt bazowy.", + "xerImportBaselineProjectsExcluded_few": "Wykluczono {{count}} projekty bazowe.", + "xerImportBaselineProjectsExcluded_many": "Wykluczono {{count}} projektów bazowych.", + "xerImportBaselineProjectsExcluded_other": "Wykluczono {{count}} projektu bazowego.", + "xerImportBaselinesMaterialized_one": "Utworzono {{count}} linię bazową.", + "xerImportBaselinesMaterialized_few": "Utworzono {{count}} linie bazowe.", + "xerImportBaselinesMaterialized_many": "Utworzono {{count}} linii bazowych.", + "xerImportBaselinesMaterialized_other": "Utworzono {{count}} linii bazowej.", + "xerImportDanglingBaselineReferences_one": "Zignorowano {{count}} oderwane odwołanie do linii bazowej.", + "xerImportDanglingBaselineReferences_few": "Zignorowano {{count}} oderwane odwołania do linii bazowych.", + "xerImportDanglingBaselineReferences_many": "Zignorowano {{count}} oderwanych odwołań do linii bazowych.", + "xerImportDanglingBaselineReferences_other": "Zignorowano {{count}} oderwanego odwołania do linii bazowej.", + "xerImportBaselineFallback": "Użyto ochronnego rozwiązania awaryjnego dla linii bazowej.", + "xerImportExternalLinks_one": "Zachowano {{count}} zewnętrzne połączenie.", + "xerImportExternalLinks_few": "Zachowano {{count}} zewnętrzne połączenia.", + "xerImportExternalLinks_many": "Zachowano {{count}} zewnętrznych połączeń.", + "xerImportExternalLinks_other": "Zachowano {{count}} zewnętrznego połączenia.", + "xerImportEncoding": "Wybrane kodowanie tekstu: {{encoding}}.", + "xerImportParserIssues_one": "{{count}} ustalenie parsera.", + "xerImportParserIssues_few": "{{count}} ustalenia parsera.", + "xerImportParserIssues_many": "{{count}} ustaleń parsera.", + "xerImportParserIssues_other": "{{count}} ustalenia parsera.", + "xerImportCalendarIssues_one": "{{count}} ustalenie kalendarza.", + "xerImportCalendarIssues_few": "{{count}} ustalenia kalendarza.", + "xerImportCalendarIssues_many": "{{count}} ustaleń kalendarza.", + "xerImportCalendarIssues_other": "{{count}} ustalenia kalendarza.", + "xerImportNumberIssues_one": "{{count}} problem formatu liczbowego.", + "xerImportNumberIssues_few": "{{count}} problemy formatu liczbowego.", + "xerImportNumberIssues_many": "{{count}} problemów formatu liczbowego.", + "xerImportNumberIssues_other": "{{count}} problemy formatu liczbowego.", + "xerImportEnumFallbacks_one": "{{count}} rozwiązanie awaryjne enum.", + "xerImportEnumFallbacks_few": "{{count}} rozwiązania awaryjne enum.", + "xerImportEnumFallbacks_many": "{{count}} rozwiązań awaryjnych enum.", + "xerImportEnumFallbacks_other": "{{count}} rozwiązania awaryjnego enum.", + "xerImportUnsupportedSemantics_one": "{{count}} ustawienie planowania P6 użyło bezpiecznej wartości zastępczej.", + "xerImportUnsupportedSemantics_few": "{{count}} ustawienia planowania P6 użyły bezpiecznych wartości zastępczych.", + "xerImportUnsupportedSemantics_many": "{{count}} ustawień planowania P6 użyło bezpiecznych wartości zastępczych.", + "xerImportUnsupportedSemantics_other": "{{count}} ustawienia planowania P6 użyły bezpiecznych wartości zastępczych.", + "xerImportDatesAsRecorded_one": "{{count}} zadanie pokazuje daty tak, jak zapisał je Primavera (bez ponownego przeliczenia).", + "xerImportDatesAsRecorded_few": "{{count}} zadania pokazują daty tak, jak zapisał je Primavera (bez ponownego przeliczenia).", + "xerImportDatesAsRecorded_many": "{{count}} zadań pokazuje daty tak, jak zapisał je Primavera (bez ponownego przeliczenia).", + "xerImportDatesAsRecorded_other": "{{count}} zadania pokazują daty tak, jak zapisał je Primavera (bez ponownego przeliczenia).", + "xerImportDatesAsRecordedOffer_one": "{{count}} zadanie różni się od dat zapisanych w pliku — możesz je pokazać.", + "xerImportDatesAsRecordedOffer_few": "{{count}} zadania różnią się od dat zapisanych w pliku — możesz je pokazać.", + "xerImportDatesAsRecordedOffer_many": "{{count}} zadań różni się od dat zapisanych w pliku — możesz je pokazać.", + "xerImportDatesAsRecordedOffer_other": "{{count}} zadania różnią się od dat zapisanych w pliku — możesz je pokazać.", + "xerExportLoss": "Eksport do {{format}} powoduje utratę informacji źródłowych XER.", "mppLegacy": "Ten plik .mpp używa starszego formatu (Project 2007 lub starszy). Wyeksportuj go w MS Project jako XML (Plik → Zapisz jako → XML) i otwórz ten plik.", "mppEncrypted": "Ten plik .mpp jest zabezpieczony hasłem. Wyeksportuj go w MS Project jako XML (Plik → Zapisz jako → XML) i otwórz ten plik.", "saveFailed": "Nie udało się zapisać", @@ -812,9 +886,20 @@ "offer_few": "Ponowne przeliczenie przesunęło {{count}} z {{total}} zadań względem dat w pliku.", "offer_many": "Ponowne przeliczenie przesunęło {{count}} z {{total}} zadań względem dat w pliku.", "offer_other": "Ponowne przeliczenie przesunęło {{count}} z {{total}} zadań względem dat w pliku.", + "activeCount_one": "Widzisz harmonogram tak, jak zapisał go Primavera; ponowne przeliczenie przesunęłoby {{count}} zadanie.", + "activeCount_few": "Widzisz harmonogram tak, jak zapisał go Primavera; ponowne przeliczenie przesunęłoby {{count}} zadania.", + "activeCount_many": "Widzisz harmonogram tak, jak zapisał go Primavera; ponowne przeliczenie przesunęłoby {{count}} zadań.", + "activeCount_other": "Widzisz harmonogram tak, jak zapisał go Primavera; ponowne przeliczenie przesunęłoby {{count}} zadania.", + "activeCountNeutral_one": "Widzisz daty takie, jak zostały zapisane w pliku; ponowne przeliczenie przesunęłoby {{count}} zadanie.", + "activeCountNeutral_few": "Widzisz daty takie, jak zostały zapisane w pliku; ponowne przeliczenie przesunęłoby {{count}} zadania.", + "activeCountNeutral_many": "Widzisz daty takie, jak zostały zapisane w pliku; ponowne przeliczenie przesunęłoby {{count}} zadań.", + "activeCountNeutral_other": "Widzisz daty takie, jak zostały zapisane w pliku; ponowne przeliczenie przesunęłoby {{count}} zadania.", "show": "Pokaż zapisane daty", "active": "Widzisz daty takie, jak zostały zapisane w pliku. Nie wykonano ponownego przeliczenia.", - "recalculate": "Przelicz" + "recalculate": "Przelicz", + "markDeviates": "Odbiega", + "markPartlyUnrecorded": "Częściowo niezapisane", + "notRecorded": "Niezapisane" }, "duration": { "suffixDay": "d", @@ -1007,6 +1092,7 @@ "declaredHeading": "Co to rozszerzenie deklaruje", "declaredNote": "To deklaracja autora, a nie ograniczenie — kod i tak może więcej.", "declaredNone": "Nic nie zadeklarowano.", + "permImportSource": "importSource — pełne oryginalne bajty źródłowe każdego zaimportowanego pliku (np. surowy plik Primavera), łącznie z polami, które nigdy nie trafiają do projektu.", "cancel": "Nie instaluj", "confirm": "Zainstaluj" } diff --git a/src/i18n/locales/pl/report.json b/src/i18n/locales/pl/report.json index 2ae71ad70..9465d457f 100644 --- a/src/i18n/locales/pl/report.json +++ b/src/i18n/locales/pl/report.json @@ -141,6 +141,7 @@ "notCalculatedNote": "Jeszcze nie obliczono — naciśnij Oblicz (F5), aby uzyskać daty i zapasy.", "staleNote": "Harmonogram zmienił się od ostatniego obliczenia — naciśnij Oblicz (F5), aby zobaczyć aktualne wartości.", "statusDateMissingNote": "Nie ustawiono daty statusu — raport używa dnia dzisiejszego ({{date}}).", + "recordedDatesNote": "Widok „daty jak zapisano”: ten raport pokazuje daty z pliku źródłowego, a nie obliczenie. Najpóźniejsze rozpoczęcia/zakończenia i zapasy, których plik nie zapisał, są tu puste lub równe 0.", "period": "Okres", "statusDate": "Data statusu", "common": { diff --git a/src/i18n/locales/pl/task.json b/src/i18n/locales/pl/task.json index 56deab809..17c3a01bd 100644 --- a/src/i18n/locales/pl/task.json +++ b/src/i18n/locales/pl/task.json @@ -48,7 +48,7 @@ "taskGrid": { "columns": { "customTaskType": "Niestandardowy typ zadania", - "activityCodeData":"Dane kodów czynności","actualDuration":"Rzeczywisty czas trwania","actualFinish":"Rzeczywiste zakończenie","actualStart":"Rzeczywiste rozpoczęcie","assignedResources":"Przydzielone zasoby","assignmentCurve":"Krzywa przydziału","assignmentId":"Identyfikator przydziału","assignmentResourceId":"Identyfikator zasobu przydziału","assignmentTaskId":"Identyfikator zadania przydziału","assignmentUnits":"Jednostki przydziału dziennie","calendar":"Kalendarz","childIds":"Identyfikatory zadań podrzędnych","color":"Kolor","completion":"Postęp","constraint2Date":"Data ograniczenia dodatkowego","constraint2Type":"Typ ograniczenia dodatkowego","constraintDate":"Data ograniczenia","constraintHard":"Twarde ograniczenie","constraintType":"Typ ograniczenia","critical":"Krytyczne","customFieldData":"Dane pól niestandardowych","deadline":"Termin ostateczny","description":"Opis","duration":"Czas trwania","durationMinutes":"Czas trwania (minuty)","durationType":"Typ czasu trwania","earlyFinish":"Najwcześniejsze zakończenie","earlyStart":"Najwcześniejsze rozpoczęcie","effortDriven":"Sterowane nakładem pracy","externalRelationData":"Dane relacji zewnętrznych","floatPath":"Ścieżka zapasu","freeFloat":"Zapas swobodny","hammock":"Hammock (czas pochodny)","interferingFloat":"Zapas zakłócający","internalRelationData":"Dane relacji wewnętrznych","lateFinish":"Najpóźniejsze zakończenie","lateStart":"Najpóźniejsze rozpoczęcie","levelingDelay":"Opóźnienie bilansowania","levelingDelayElapsed":"Upłynięte opóźnienie bilansowania","levelingDelayMinutes":"Opóźnienie bilansowania (minuty)","mandatoryMilestone":"Obowiązkowy kamień milowy","manuallyScheduled":"Planowane ręcznie","milestone":"Kamień milowy","milestoneKind":"Typ kamienia milowego","mspTaskType":"Typ zadania Microsoft Project","name":"Nazwa zadania","nearCritical":"Prawie krytyczne","noteData":"Dane notatek","notes":"Notatki","parentId":"Identyfikator zadania nadrzędnego","priority":"Priorytet bilansowania","remainingMinutes":"Pozostało (minuty)","remainingTime":"Pozostało (dni robocze)","resourceIds":"Identyfikatory zasobów","resume":"Data wznowienia","scheduleFinish":"Planowane zakończenie","scheduleStart":"Planowane rozpoczęcie","splitGaps":"Przerwy","status":"Stan","stop":"Data zatrzymania","taskId":"Identyfikator zadania","taskType":"Typ zadania","timephasedContours":"Kontury okresowe","timephasedDurationWalks":"Okresowe segmenty czasu trwania","timephasedFinishFloor":"Dolna granica okresowego zakończenia","timephasedStartAnchor":"Kotwica okresowego rozpoczęcia","totalFloat":"Zapas całkowity","wbs":"Struktura podziału pracy (SPP)","workWindowFinish":"Koniec okna pracy","workWindowStart":"Początek okna pracy" + "activityCodeData":"Dane kodów czynności","actualDuration":"Rzeczywisty czas trwania","actualFinish":"Rzeczywiste zakończenie","actualStart":"Rzeczywiste rozpoczęcie","assignedResources":"Przydzielone zasoby","assignmentCurve":"Krzywa przydziału","assignmentId":"Identyfikator przydziału","assignmentResourceId":"Identyfikator zasobu przydziału","assignmentTaskId":"Identyfikator zadania przydziału","assignmentUnits":"Jednostki przydziału dziennie","calendar":"Kalendarz","childIds":"Identyfikatory zadań podrzędnych","color":"Kolor","completion":"Postęp","constraint2Date":"Data ograniczenia dodatkowego","constraint2Type":"Typ ograniczenia dodatkowego","constraintDate":"Data ograniczenia","constraintHard":"Twarde ograniczenie","constraintType":"Typ ograniczenia","critical":"Krytyczne","customFieldData":"Dane pól niestandardowych","deadline":"Termin ostateczny","description":"Opis","duration":"Czas trwania","durationMinutes":"Czas trwania (minuty)","durationType":"Typ czasu trwania","earlyFinish":"Najwcześniejsze zakończenie","earlyStart":"Najwcześniejsze rozpoczęcie","effortDriven":"Sterowane nakładem pracy","explicitSummary":"Jawne zadanie sumaryczne","externalRelationData":"Dane relacji zewnętrznych","floatPath":"Ścieżka zapasu","freeFloat":"Zapas swobodny","hammock":"Hammock (czas pochodny)","interferingFloat":"Zapas zakłócający","internalRelationData":"Dane relacji wewnętrznych","lateFinish":"Najpóźniejsze zakończenie","lateStart":"Najpóźniejsze rozpoczęcie","levelingDelay":"Opóźnienie bilansowania","levelingDelayElapsed":"Upłynięte opóźnienie bilansowania","levelingDelayMinutes":"Opóźnienie bilansowania (minuty)","mandatoryMilestone":"Obowiązkowy kamień milowy","manuallyScheduled":"Planowane ręcznie","milestone":"Kamień milowy","milestoneKind":"Typ kamienia milowego","mspTaskType":"Typ zadania Microsoft Project","name":"Nazwa zadania","nearCritical":"Prawie krytyczne","noteData":"Dane notatek","notes":"Notatki","p6Provenance":"Pochodzenie Primavera P6","parentId":"Identyfikator zadania nadrzędnego","priority":"Priorytet bilansowania","recordedSource":"Źródło (zapisane daty)","remainingMinutes":"Pozostało (minuty)","remainingTime":"Pozostało (dni robocze)","resourceIds":"Identyfikatory zasobów","resume":"Data wznowienia","scheduleFinish":"Planowane zakończenie","scheduleStart":"Planowane rozpoczęcie","splitGaps":"Przerwy","status":"Stan","stop":"Data zatrzymania","taskId":"Identyfikator zadania","taskType":"Typ zadania","timephasedContours":"Kontury okresowe","timephasedDurationWalks":"Okresowe segmenty czasu trwania","timephasedFinishFloor":"Dolna granica okresowego zakończenia","timephasedStartAnchor":"Kotwica okresowego rozpoczęcia","totalFloat":"Zapas całkowity","wbs":"Struktura podziału pracy (SPP)","workWindowFinish":"Koniec okna pracy","workWindowStart":"Początek okna pracy" }, "category": { "task": "Zadanie", "planning": "Planowanie", "constraints": "Ograniczenia", "relations": "Relacje", "resources": "Zasoby", @@ -263,6 +263,10 @@ "timephasedActive": "Podąża za rozkładem godzin z MS Project", "timephasedLost": "Rozkład godzin z MS Project nie jest już stosowany po edycji — pierwotny rozkład pozostaje zachowany w pliku", "timephasedContoured": "Własny rozkład godzin", + "recordedDatesActive": "Pokazuje własne daty zapisane przez Primaverę dla tego zadania", + "recordedDatesActiveNeutral": "Pokazuje daty zapisane w pliku dla tego zadania", + "recordedDatesDeviates": "Odbiega od zapisanych dat", + "recordedDatesPartlyUnrecorded": "Zapis jest częściowo niekompletny — zobacz kolumny późnego zakończenia/zapasu", "completion": "Postęp (%)", "progress": { "actualStart": "Rzeczywisty początek", diff --git a/src/i18n/locales/pt/common.json b/src/i18n/locales/pt/common.json index a998df418..e6f492977 100644 --- a/src/i18n/locales/pt/common.json +++ b/src/i18n/locales/pt/common.json @@ -6,6 +6,66 @@ "notifications": { "dismiss": "Fechar notificação", "openFailed": "Falha ao abrir o arquivo", + "xerInvalidInput": "A importação XER requer os bytes originais do arquivo.", + "xerInvalidFile": "Este não é um arquivo XER válido ou compatível.", + "xerInvalidEncoding": "A codificação de texto deste arquivo XER é inválida ou está truncada.", + "xerDuplicateTable": "O arquivo XER contém uma tabela duplicada.", + "xerMissingRequiredColumns": "Uma tabela XER não contém colunas obrigatórias.", + "xerMissingRequiredValue": "Um registro XER não contém um valor obrigatório.", + "xerAmbiguousDecimal": "Não é possível determinar com segurança a notação decimal deste arquivo XER.", + "xerInvalidNumberFormat": "Este arquivo XER tem uma formatação numérica inválida.", + "xerInvalidNumber": "O arquivo XER contém um número inválido.", + "xerSingleProjectRequired": "Esta importação suporta exatamente um projeto P6 por arquivo XER.", + "xerEmptyProject": "O projeto P6 neste arquivo XER não contém atividades.", + "xerDuplicateId": "O arquivo XER contém um ID de EAP, atividade ou relação duplicado.", + "xerAmbiguousLocalRelation": "Uma relação XER sem âmbito não pode ser atribuída inequivocamente a um único projeto local.", + "xerDanglingLocalRelation": "Uma relação XER local referencia uma atividade ausente.", + "xerEnumFallback": "XER {{family}}: token desconhecido “{{token}}”; usado “{{fallback}}”.", + "xerImportOpened_one": "Arquivo XER aberto: {{count}} documento de projeto.", + "xerImportOpened_many": "Arquivo XER aberto: {{count}} documentos de projeto.", + "xerImportOpened_other": "Arquivo XER aberto: {{count}} documentos de projeto.", + "xerImportProjectsSeen_one": "{{count}} projeto encontrado.", + "xerImportProjectsSeen_many": "{{count}} projetos encontrados.", + "xerImportProjectsSeen_other": "{{count}} projetos encontrados.", + "xerImportEmptyProjectsSkipped_one": "{{count}} projeto vazio ignorado.", + "xerImportEmptyProjectsSkipped_many": "{{count}} projetos vazios ignorados.", + "xerImportEmptyProjectsSkipped_other": "{{count}} projetos vazios ignorados.", + "xerImportBaselineProjectsExcluded_one": "{{count}} projeto de linha de base excluído.", + "xerImportBaselineProjectsExcluded_many": "{{count}} projetos de linha de base excluídos.", + "xerImportBaselineProjectsExcluded_other": "{{count}} projetos de linha de base excluídos.", + "xerImportBaselinesMaterialized_one": "{{count}} linha de base materializada.", + "xerImportBaselinesMaterialized_many": "{{count}} linhas de base materializadas.", + "xerImportBaselinesMaterialized_other": "{{count}} linhas de base materializadas.", + "xerImportDanglingBaselineReferences_one": "{{count}} referência de linha de base pendente ignorada.", + "xerImportDanglingBaselineReferences_many": "{{count}} referências de linha de base pendentes ignoradas.", + "xerImportDanglingBaselineReferences_other": "{{count}} referências de linha de base pendentes ignoradas.", + "xerImportBaselineFallback": "Foi usada uma alternativa protetora para a linha de base.", + "xerImportExternalLinks_one": "{{count}} vínculo externo preservado.", + "xerImportExternalLinks_many": "{{count}} vínculos externos preservados.", + "xerImportExternalLinks_other": "{{count}} vínculos externos preservados.", + "xerImportEncoding": "Codificação de texto escolhida: {{encoding}}.", + "xerImportParserIssues_one": "{{count}} ocorrência do analisador.", + "xerImportParserIssues_many": "{{count}} ocorrências do analisador.", + "xerImportParserIssues_other": "{{count}} ocorrências do analisador.", + "xerImportCalendarIssues_one": "{{count}} ocorrência de calendário.", + "xerImportCalendarIssues_many": "{{count}} ocorrências de calendário.", + "xerImportCalendarIssues_other": "{{count}} ocorrências de calendário.", + "xerImportNumberIssues_one": "{{count}} problema de formato numérico.", + "xerImportNumberIssues_many": "{{count}} problemas de formato numérico.", + "xerImportNumberIssues_other": "{{count}} problemas de formato numérico.", + "xerImportEnumFallbacks_one": "{{count}} alternativa de enumeração.", + "xerImportEnumFallbacks_many": "{{count}} alternativas de enumeração.", + "xerImportEnumFallbacks_other": "{{count}} alternativas de enumeração.", + "xerImportUnsupportedSemantics_one": "{{count}} definição de planeamento P6 usou uma alternativa segura.", + "xerImportUnsupportedSemantics_many": "{{count}} definições de planeamento P6 usaram alternativas seguras.", + "xerImportUnsupportedSemantics_other": "{{count}} definições de planeamento P6 usaram alternativas seguras.", + "xerImportDatesAsRecorded_one": "{{count}} tarefa mostra as datas tal como registadas pelo Primavera (sem recalcular).", + "xerImportDatesAsRecorded_many": "{{count}} tarefas mostram as datas tal como registadas pelo Primavera (sem recalcular).", + "xerImportDatesAsRecorded_other": "{{count}} tarefas mostram as datas tal como registadas pelo Primavera (sem recalcular).", + "xerImportDatesAsRecordedOffer_one": "{{count}} tarefa difere das datas registadas no ficheiro — pode mostrá-las.", + "xerImportDatesAsRecordedOffer_many": "{{count}} tarefas diferem das datas registadas no ficheiro — pode mostrá-las.", + "xerImportDatesAsRecordedOffer_other": "{{count}} tarefas diferem das datas registadas no ficheiro — pode mostrá-las.", + "xerExportLoss": "A exportação para {{format}} perde informações de origem XER.", "mppLegacy": "Este arquivo .mpp usa um formato antigo (Project 2007 ou anterior). Exporte-o no MS Project como XML (Arquivo → Salvar como → XML) e abra esse arquivo.", "mppEncrypted": "Este arquivo .mpp está protegido por senha. Exporte-o no MS Project como XML (Arquivo → Salvar como → XML) e abra esse arquivo.", "saveFailed": "Falha ao salvar", @@ -799,9 +859,18 @@ "offer_one": "O recálculo moveu {{count}} de {{total}} tarefas em relação às datas do ficheiro.", "offer_many": "O recálculo moveu {{count}} de {{total}} tarefas em relação às datas do ficheiro.", "offer_other": "O recálculo moveu {{count}} de {{total}} tarefas em relação às datas do ficheiro.", + "activeCount_one": "Está a ver o planeamento tal como o Primavera o registou; recalcular deslocaria {{count}} tarefa.", + "activeCount_many": "Está a ver o planeamento tal como o Primavera o registou; recalcular deslocaria {{count}} tarefas.", + "activeCount_other": "Está a ver o planeamento tal como o Primavera o registou; recalcular deslocaria {{count}} tarefas.", + "activeCountNeutral_one": "Está a ver as datas tal como estão registadas no ficheiro; recalcular deslocaria {{count}} tarefa.", + "activeCountNeutral_many": "Está a ver as datas tal como estão registadas no ficheiro; recalcular deslocaria {{count}} tarefas.", + "activeCountNeutral_other": "Está a ver as datas tal como estão registadas no ficheiro; recalcular deslocaria {{count}} tarefas.", "show": "Mostrar datas registadas", "active": "Está a ver as datas tal como estão registadas no ficheiro. Não foi feito nenhum recálculo.", - "recalculate": "Recalcular" + "recalculate": "Recalcular", + "markDeviates": "Diverge", + "markPartlyUnrecorded": "Parcialmente não registado", + "notRecorded": "Não registado" }, "duration": { "suffixDay": "d", @@ -989,6 +1058,7 @@ "declaredHeading": "O que esta extensão declara usar", "declaredNote": "É a declaração do autor, não uma restrição — o código pode fazer mais de qualquer forma.", "declaredNone": "Nada declarado.", + "permImportSource": "importSource — os bytes de origem completos de cada ficheiro importado (por exemplo um ficheiro Primavera em bruto), incluindo campos que nunca chegam ao projeto.", "cancel": "Não instalar", "confirm": "Instalar" } diff --git a/src/i18n/locales/pt/report.json b/src/i18n/locales/pt/report.json index 9559e5cab..5df7c8900 100644 --- a/src/i18n/locales/pt/report.json +++ b/src/i18n/locales/pt/report.json @@ -138,6 +138,7 @@ "notCalculatedNote": "Ainda não calculado — prima Calcular (F5) para datas e folgas.", "staleNote": "O cronograma mudou desde o último cálculo — prima Calcular (F5) para valores atuais.", "statusDateMissingNote": "Sem data de estado — o relatório usa hoje ({{date}}).", + "recordedDatesNote": "Vista «datas tal como gravadas»: este relatório mostra as datas do ficheiro de origem, não um cálculo. Inícios/fins tardios e folgas que o ficheiro não registou aparecem vazios ou a 0.", "period": "Período", "statusDate": "Data de estado", "common": { diff --git a/src/i18n/locales/pt/task.json b/src/i18n/locales/pt/task.json index d19c4674b..9c713dbb7 100644 --- a/src/i18n/locales/pt/task.json +++ b/src/i18n/locales/pt/task.json @@ -48,7 +48,7 @@ "taskGrid": { "columns": { "customTaskType": "Tipo de tarefa personalizado", - "activityCodeData":"Dados dos códigos de atividade","actualDuration":"Duração real","actualFinish":"Fim real","actualStart":"Início real","assignedResources":"Recursos atribuídos","assignmentCurve":"Curva de atribuição","assignmentId":"ID da atribuição","assignmentResourceId":"ID do recurso da atribuição","assignmentTaskId":"ID da tarefa da atribuição","assignmentUnits":"Unidades de atribuição por dia","calendar":"Calendário","childIds":"IDs das tarefas subordinadas","color":"Cor","completion":"Progresso","constraint2Date":"Data da restrição secundária","constraint2Type":"Tipo da restrição secundária","constraintDate":"Data da restrição","constraintHard":"Restrição rígida","constraintType":"Tipo de restrição","critical":"Crítica","customFieldData":"Dados dos campos personalizados","deadline":"Prazo","description":"Descrição","duration":"Duração","durationMinutes":"Duração (minutos)","durationType":"Tipo de duração","earlyFinish":"Fim antecipado","earlyStart":"Início antecipado","effortDriven":"Orientada pelo esforço","externalRelationData":"Dados das relações externas","floatPath":"Caminho de folga","freeFloat":"Folga livre","hammock":"Hammock (duração derivada)","interferingFloat":"Folga interferente","internalRelationData":"Dados das relações internas","lateFinish":"Fim tardio","lateStart":"Início tardio","levelingDelay":"Atraso de nivelamento","levelingDelayElapsed":"Atraso de nivelamento decorrido","levelingDelayMinutes":"Atraso de nivelamento (minutos)","mandatoryMilestone":"Marco obrigatório","manuallyScheduled":"Agendada manualmente","milestone":"Marco","milestoneKind":"Tipo de marco","mspTaskType":"Tipo de tarefa do Microsoft Project","name":"Nome da tarefa","nearCritical":"Quase crítica","noteData":"Dados das notas","notes":"Notas","parentId":"ID da tarefa principal","priority":"Prioridade de nivelamento","remainingMinutes":"Restante (minutos)","remainingTime":"Restante (dias úteis)","resourceIds":"IDs dos recursos","resume":"Data de retoma","scheduleFinish":"Fim planeado","scheduleStart":"Início planeado","splitGaps":"Interrupções","status":"Estado","stop":"Data de paragem","taskId":"ID da tarefa","taskType":"Tipo de tarefa","timephasedContours":"Contornos faseados no tempo","timephasedDurationWalks":"Segmentos de duração faseados no tempo","timephasedFinishFloor":"Limite inferior do fim faseado","timephasedStartAnchor":"Âncora do início faseado","totalFloat":"Folga total","wbs":"Estrutura analítica do projeto (EAP)","workWindowFinish":"Fim da janela de trabalho","workWindowStart":"Início da janela de trabalho" + "activityCodeData":"Dados dos códigos de atividade","actualDuration":"Duração real","actualFinish":"Fim real","actualStart":"Início real","assignedResources":"Recursos atribuídos","assignmentCurve":"Curva de atribuição","assignmentId":"ID da atribuição","assignmentResourceId":"ID do recurso da atribuição","assignmentTaskId":"ID da tarefa da atribuição","assignmentUnits":"Unidades de atribuição por dia","calendar":"Calendário","childIds":"IDs das tarefas subordinadas","color":"Cor","completion":"Progresso","constraint2Date":"Data da restrição secundária","constraint2Type":"Tipo da restrição secundária","constraintDate":"Data da restrição","constraintHard":"Restrição rígida","constraintType":"Tipo de restrição","critical":"Crítica","customFieldData":"Dados dos campos personalizados","deadline":"Prazo","description":"Descrição","duration":"Duração","durationMinutes":"Duração (minutos)","durationType":"Tipo de duração","earlyFinish":"Fim antecipado","earlyStart":"Início antecipado","effortDriven":"Orientada pelo esforço","explicitSummary":"Tarefa de resumo explícita","externalRelationData":"Dados das relações externas","floatPath":"Caminho de folga","freeFloat":"Folga livre","hammock":"Hammock (duração derivada)","interferingFloat":"Folga interferente","internalRelationData":"Dados das relações internas","lateFinish":"Fim tardio","lateStart":"Início tardio","levelingDelay":"Atraso de nivelamento","levelingDelayElapsed":"Atraso de nivelamento decorrido","levelingDelayMinutes":"Atraso de nivelamento (minutos)","mandatoryMilestone":"Marco obrigatório","manuallyScheduled":"Agendada manualmente","milestone":"Marco","milestoneKind":"Tipo de marco","mspTaskType":"Tipo de tarefa do Microsoft Project","name":"Nome da tarefa","nearCritical":"Quase crítica","noteData":"Dados das notas","notes":"Notas","p6Provenance":"Proveniência do Primavera P6","parentId":"ID da tarefa principal","priority":"Prioridade de nivelamento","recordedSource":"Origem (datas registadas)","remainingMinutes":"Restante (minutos)","remainingTime":"Restante (dias úteis)","resourceIds":"IDs dos recursos","resume":"Data de retoma","scheduleFinish":"Fim planeado","scheduleStart":"Início planeado","splitGaps":"Interrupções","status":"Estado","stop":"Data de paragem","taskId":"ID da tarefa","taskType":"Tipo de tarefa","timephasedContours":"Contornos faseados no tempo","timephasedDurationWalks":"Segmentos de duração faseados no tempo","timephasedFinishFloor":"Limite inferior do fim faseado","timephasedStartAnchor":"Âncora do início faseado","totalFloat":"Folga total","wbs":"Estrutura analítica do projeto (EAP)","workWindowFinish":"Fim da janela de trabalho","workWindowStart":"Início da janela de trabalho" }, "category": { "task": "Tarefa", "planning": "Planeamento", "constraints": "Restrições", "relations": "Relações", "resources": "Recursos", @@ -259,6 +259,10 @@ "timephasedActive": "Segue a distribuição de horas do MS Project", "timephasedLost": "Distribuição de horas do MS Project não é mais aplicada após a edição — a distribuição original permanece guardada no arquivo", "timephasedContoured": "Distribuição de horas própria", + "recordedDatesActive": "Mostra as datas próprias registadas pelo Primavera para esta tarefa", + "recordedDatesActiveNeutral": "Mostra as datas tal como registadas no ficheiro para esta tarefa", + "recordedDatesDeviates": "Diverge das datas registadas", + "recordedDatesPartlyUnrecorded": "Registo parcialmente incompleto — ver as colunas de fim tardio/folga", "completion": "Progresso (%)", "progress": { "actualStart": "Início real", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 0abd8323c..da39e4f23 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -6,6 +6,52 @@ "notifications": { "dismiss": "Bildirimi kapat", "openFailed": "Dosya açılamadı", + "xerInvalidInput": "XER içe aktarma işlemi özgün dosya baytlarını gerektirir.", + "xerInvalidFile": "Bu, geçerli veya desteklenen bir XER dosyası değil.", + "xerInvalidEncoding": "Bu XER dosyasının metin kodlaması geçersiz veya eksik.", + "xerDuplicateTable": "XER dosyasında yinelenen bir tablo var.", + "xerMissingRequiredColumns": "Bir XER tablosunda gerekli sütunlar eksik.", + "xerMissingRequiredValue": "Bir XER kaydında gerekli bir değer eksik.", + "xerAmbiguousDecimal": "Bu XER dosyasındaki ondalık gösterim güvenilir biçimde belirlenemiyor.", + "xerInvalidNumberFormat": "Bu XER dosyasındaki sayı biçimi geçersiz.", + "xerInvalidNumber": "XER dosyasında geçersiz bir sayı var.", + "xerSingleProjectRequired": "Bu içe aktarma işlemi her XER dosyasında tam olarak bir P6 projesini destekler.", + "xerEmptyProject": "Bu XER dosyasındaki P6 projesinde etkinlik yok.", + "xerDuplicateId": "XER dosyasında yinelenen bir WBS, etkinlik veya ilişki kimliği var.", + "xerAmbiguousLocalRelation": "Kapsamı belirtilmemiş bir XER ilişkisi tek bir yerel projeye kesin olarak atanamıyor.", + "xerDanglingLocalRelation": "Yerel bir XER ilişkisi eksik bir etkinliğe başvuruyor.", + "xerEnumFallback": "XER {{family}}: bilinmeyen ‘{{token}}’ belirteci; ‘{{fallback}}’ kullanıldı.", + "xerImportOpened_one": "XER dosyası açıldı: {{count}} proje belgesi.", + "xerImportOpened_other": "XER dosyası açıldı: {{count}} proje belgesi.", + "xerImportProjectsSeen_one": "{{count}} proje bulundu.", + "xerImportProjectsSeen_other": "{{count}} proje bulundu.", + "xerImportEmptyProjectsSkipped_one": "{{count}} boş proje atlandı.", + "xerImportEmptyProjectsSkipped_other": "{{count}} boş proje atlandı.", + "xerImportBaselineProjectsExcluded_one": "{{count}} temel proje dışarıda bırakıldı.", + "xerImportBaselineProjectsExcluded_other": "{{count}} temel proje dışarıda bırakıldı.", + "xerImportBaselinesMaterialized_one": "{{count}} temel plan oluşturuldu.", + "xerImportBaselinesMaterialized_other": "{{count}} temel plan oluşturuldu.", + "xerImportDanglingBaselineReferences_one": "{{count}} boşta kalan temel plan başvurusu yok sayıldı.", + "xerImportDanglingBaselineReferences_other": "{{count}} boşta kalan temel plan başvurusu yok sayıldı.", + "xerImportBaselineFallback": "Koruyucu temel plan geri dönüşü kullanıldı.", + "xerImportExternalLinks_one": "{{count}} harici bağlantı korundu.", + "xerImportExternalLinks_other": "{{count}} harici bağlantı korundu.", + "xerImportEncoding": "Metin kodlaması seçildi: {{encoding}}.", + "xerImportParserIssues_one": "{{count}} ayrıştırıcı bulgusu.", + "xerImportParserIssues_other": "{{count}} ayrıştırıcı bulgusu.", + "xerImportCalendarIssues_one": "{{count}} takvim bulgusu.", + "xerImportCalendarIssues_other": "{{count}} takvim bulgusu.", + "xerImportNumberIssues_one": "{{count}} sayı biçimi sorunu.", + "xerImportNumberIssues_other": "{{count}} sayı biçimi sorunu.", + "xerImportEnumFallbacks_one": "{{count}} enum geri dönüşü.", + "xerImportEnumFallbacks_other": "{{count}} enum geri dönüşü.", + "xerImportUnsupportedSemantics_one": "{{count}} P6 planlama ayarı güvenli geri dönüş kullandı.", + "xerImportUnsupportedSemantics_other": "{{count}} P6 planlama ayarı güvenli geri dönüş kullandı.", + "xerImportDatesAsRecorded_one": "{{count}} görev, Primavera'nın kaydettiği tarihleri gösteriyor (yeniden hesaplanmadı).", + "xerImportDatesAsRecorded_other": "{{count}} görev, Primavera'nın kaydettiği tarihleri gösteriyor (yeniden hesaplanmadı).", + "xerImportDatesAsRecordedOffer_one": "{{count}} görev, dosyada kayıtlı tarihlerden farklı — bunları gösterebilirsiniz.", + "xerImportDatesAsRecordedOffer_other": "{{count}} görev, dosyada kayıtlı tarihlerden farklı — bunları gösterebilirsiniz.", + "xerExportLoss": "{{format}} biçimine dışa aktarma XER kaynak bilgilerini kaybeder.", "mppLegacy": "Bu .mpp dosyası eski bir biçim kullanıyor (Project 2007 veya daha eski). MS Project'te XML olarak dışa aktarın (Dosya → Farklı Kaydet → XML) ve o dosyayı açın.", "mppEncrypted": "Bu .mpp dosyası parola ile korunuyor. MS Project'te XML olarak dışa aktarın (Dosya → Farklı Kaydet → XML) ve o dosyayı açın.", "saveFailed": "Kaydetme başarısız", @@ -786,9 +832,16 @@ "recordedDates": { "offer_one": "Yeniden hesaplama, dosyadaki tarihlere göre {{total}} görevden {{count}} tanesini kaydırdı.", "offer_other": "Yeniden hesaplama, dosyadaki tarihlere göre {{total}} görevden {{count}} tanesini kaydırdı.", + "activeCount_one": "Planı Primavera'nın kaydettiği şekilde görüntülüyorsunuz; yeniden hesaplama {{count}} görevi kaydırır.", + "activeCount_other": "Planı Primavera'nın kaydettiği şekilde görüntülüyorsunuz; yeniden hesaplama {{count}} görevi kaydırır.", + "activeCountNeutral_one": "Tarihleri dosyada kaydedildiği şekilde görüntülüyorsunuz; yeniden hesaplama {{count}} görevi kaydırır.", + "activeCountNeutral_other": "Tarihleri dosyada kaydedildiği şekilde görüntülüyorsunuz; yeniden hesaplama {{count}} görevi kaydırır.", "show": "Kaydedilen tarihleri göster", "active": "Tarihleri dosyada kaydedildiği şekilde görüntülüyorsunuz. Herhangi bir yeniden hesaplama yapılmadı.", - "recalculate": "Yeniden hesapla" + "recalculate": "Yeniden hesapla", + "markDeviates": "Farklılaşıyor", + "markPartlyUnrecorded": "Kısmen kaydedilmedi", + "notRecorded": "Kaydedilmedi" }, "duration": { "suffixDay": "g", @@ -971,6 +1024,7 @@ "declaredHeading": "Bu uzantının kullandığını belirttikleri", "declaredNote": "Bu, yazarın beyanıdır, bir kısıtlama değil — kod yine de fazlasını yapabilir.", "declaredNone": "Hiçbir şey belirtilmemiş.", + "permImportSource": "importSource — içe aktarılan her dosyanın eksiksiz özgün kaynak baytları (örneğin ham bir Primavera dosyası), projeye hiç ulaşmayan alanlar dahil.", "cancel": "Kurma", "confirm": "Kur" } diff --git a/src/i18n/locales/tr/report.json b/src/i18n/locales/tr/report.json index 2a98c30a8..647ef7991 100644 --- a/src/i18n/locales/tr/report.json +++ b/src/i18n/locales/tr/report.json @@ -135,6 +135,7 @@ "notCalculatedNote": "Henüz hesaplanmadı — tarihler ve bolluklar için Hesapla (F5) tuşuna basın.", "staleNote": "Program son hesaplamadan beri değişti — güncel değerler için Hesapla (F5) tuşuna basın.", "statusDateMissingNote": "Durum tarihi ayarlanmadı — rapor bugünü kullanıyor ({{date}}).", + "recordedDatesNote": "'Kaydedildiği gibi tarihler' görünümü: bu rapor bir hesaplamayı değil, kaynak dosyadaki tarihleri gösterir. Dosyanın kaydetmediği en geç başlangıç/bitiş ve bolluk değerleri burada boş veya 0 görünür.", "period": "Dönem", "statusDate": "Durum tarihi", "common": { diff --git a/src/i18n/locales/tr/task.json b/src/i18n/locales/tr/task.json index b1249530c..8c42b5189 100644 --- a/src/i18n/locales/tr/task.json +++ b/src/i18n/locales/tr/task.json @@ -48,7 +48,7 @@ "taskGrid": { "columns": { "customTaskType": "Özel görev türü", - "activityCodeData":"Aktivite kodu verileri","actualDuration":"Gerçekleşen süre","actualFinish":"Gerçekleşen bitiş","actualStart":"Gerçekleşen başlangıç","assignedResources":"Atanan kaynaklar","assignmentCurve":"Atama eğrisi","assignmentId":"Atama kimliği","assignmentResourceId":"Atama kaynak kimliği","assignmentTaskId":"Atama görev kimliği","assignmentUnits":"Günlük atama birimleri","calendar":"Takvim","childIds":"Alt görev kimlikleri","color":"Renk","completion":"İlerleme","constraint2Date":"İkincil kısıtlama tarihi","constraint2Type":"İkincil kısıtlama türü","constraintDate":"Kısıtlama tarihi","constraintHard":"Kesin kısıtlama","constraintType":"Kısıtlama türü","critical":"Kritik","customFieldData":"Özel alan verileri","deadline":"Son tarih","description":"Açıklama","duration":"Süre","durationMinutes":"Süre (dakika)","durationType":"Süre türü","earlyFinish":"Erken bitiş","earlyStart":"Erken başlangıç","effortDriven":"Efor odaklı","externalRelationData":"Dış ilişki verileri","floatPath":"Bolluk yolu","freeFloat":"Serbest bolluk","hammock":"Hammock (türetilmiş süre)","interferingFloat":"Engelleyici bolluk","internalRelationData":"İç ilişki verileri","lateFinish":"Geç bitiş","lateStart":"Geç başlangıç","levelingDelay":"Kaynak dengeleme gecikmesi","levelingDelayElapsed":"Geçen dengeleme gecikmesi","levelingDelayMinutes":"Dengeleme gecikmesi (dakika)","mandatoryMilestone":"Zorunlu kilometre taşı","manuallyScheduled":"Elle planlandı","milestone":"Kilometre taşı","milestoneKind":"Kilometre taşı türü","mspTaskType":"Microsoft Project görev türü","name":"Görev adı","nearCritical":"Kritiğe yakın","noteData":"Not verileri","notes":"Notlar","parentId":"Üst görev kimliği","priority":"Dengeleme önceliği","remainingMinutes":"Kalan (dakika)","remainingTime":"Kalan (iş günü)","resourceIds":"Kaynak kimlikleri","resume":"Sürdürme tarihi","scheduleFinish":"Planlanan bitiş","scheduleStart":"Planlanan başlangıç","splitGaps":"Kesintiler","status":"Durum","stop":"Durdurma tarihi","taskId":"Görev kimliği","taskType":"Görev türü","timephasedContours":"Zaman aşamalı dağılımlar","timephasedDurationWalks":"Zaman aşamalı süre parçaları","timephasedFinishFloor":"Zaman aşamalı bitiş alt sınırı","timephasedStartAnchor":"Zaman aşamalı başlangıç çıpası","totalFloat":"Toplam bolluk","wbs":"İş kırılım yapısı (WBS)","workWindowFinish":"Çalışma penceresi bitişi","workWindowStart":"Çalışma penceresi başlangıcı" + "activityCodeData":"Aktivite kodu verileri","actualDuration":"Gerçekleşen süre","actualFinish":"Gerçekleşen bitiş","actualStart":"Gerçekleşen başlangıç","assignedResources":"Atanan kaynaklar","assignmentCurve":"Atama eğrisi","assignmentId":"Atama kimliği","assignmentResourceId":"Atama kaynak kimliği","assignmentTaskId":"Atama görev kimliği","assignmentUnits":"Günlük atama birimleri","calendar":"Takvim","childIds":"Alt görev kimlikleri","color":"Renk","completion":"İlerleme","constraint2Date":"İkincil kısıtlama tarihi","constraint2Type":"İkincil kısıtlama türü","constraintDate":"Kısıtlama tarihi","constraintHard":"Kesin kısıtlama","constraintType":"Kısıtlama türü","critical":"Kritik","customFieldData":"Özel alan verileri","deadline":"Son tarih","description":"Açıklama","duration":"Süre","durationMinutes":"Süre (dakika)","durationType":"Süre türü","earlyFinish":"Erken bitiş","earlyStart":"Erken başlangıç","effortDriven":"Efor odaklı","explicitSummary":"Açık özet görev","externalRelationData":"Dış ilişki verileri","floatPath":"Bolluk yolu","freeFloat":"Serbest bolluk","hammock":"Hammock (türetilmiş süre)","interferingFloat":"Engelleyici bolluk","internalRelationData":"İç ilişki verileri","lateFinish":"Geç bitiş","lateStart":"Geç başlangıç","levelingDelay":"Kaynak dengeleme gecikmesi","levelingDelayElapsed":"Geçen dengeleme gecikmesi","levelingDelayMinutes":"Dengeleme gecikmesi (dakika)","mandatoryMilestone":"Zorunlu kilometre taşı","manuallyScheduled":"Elle planlandı","milestone":"Kilometre taşı","milestoneKind":"Kilometre taşı türü","mspTaskType":"Microsoft Project görev türü","name":"Görev adı","nearCritical":"Kritiğe yakın","noteData":"Not verileri","notes":"Notlar","p6Provenance":"Primavera P6 kaynağı","parentId":"Üst görev kimliği","priority":"Dengeleme önceliği","recordedSource":"Kaynak (kaydedilen tarihler)","remainingMinutes":"Kalan (dakika)","remainingTime":"Kalan (iş günü)","resourceIds":"Kaynak kimlikleri","resume":"Sürdürme tarihi","scheduleFinish":"Planlanan bitiş","scheduleStart":"Planlanan başlangıç","splitGaps":"Kesintiler","status":"Durum","stop":"Durdurma tarihi","taskId":"Görev kimliği","taskType":"Görev türü","timephasedContours":"Zaman aşamalı dağılımlar","timephasedDurationWalks":"Zaman aşamalı süre parçaları","timephasedFinishFloor":"Zaman aşamalı bitiş alt sınırı","timephasedStartAnchor":"Zaman aşamalı başlangıç çıpası","totalFloat":"Toplam bolluk","wbs":"İş kırılım yapısı (WBS)","workWindowFinish":"Çalışma penceresi bitişi","workWindowStart":"Çalışma penceresi başlangıcı" }, "category": { "task": "Görev", "planning": "Planlama", "constraints": "Kısıtlamalar", "relations": "İlişkiler", "resources": "Kaynaklar", @@ -255,6 +255,10 @@ "timephasedActive": "MS Project'ten gelen saat dağılımını izliyor", "timephasedLost": "Düzenlemeden sonra MS Project'ten gelen saat dağılımı artık uygulanmıyor — özgün dağılım dosyada saklı kalıyor", "timephasedContoured": "Özel saat dağılımı", + "recordedDatesActive": "Bu görev için Primavera’nın kaydettiği kendi tarihlerini gösterir", + "recordedDatesActiveNeutral": "Bu görev için dosyada kayıtlı tarihleri gösterir", + "recordedDatesDeviates": "Kaydedilen tarihlerden farklılaşıyor", + "recordedDatesPartlyUnrecorded": "Kayıt kısmen eksik — geç bitiş/bolluk sütunlarına bakın", "completion": "İlerleme (%)", "progress": { "actualStart": "Gerçek başlangıç", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 761924590..51029d462 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -6,6 +6,38 @@ "notifications": { "dismiss": "关闭通知", "openFailed": "打开文件失败", + "xerInvalidInput": "导入 XER 需要原始文件字节。", + "xerInvalidFile": "这不是有效或受支持的 XER 文件。", + "xerInvalidEncoding": "此 XER 文件的文本编码无效或已截断。", + "xerDuplicateTable": "XER 文件包含重复的表。", + "xerMissingRequiredColumns": "XER 表缺少必填列。", + "xerMissingRequiredValue": "XER 记录缺少必填值。", + "xerAmbiguousDecimal": "无法可靠确定此 XER 文件的小数表示法。", + "xerInvalidNumberFormat": "此 XER 文件的数字格式无效。", + "xerInvalidNumber": "XER 文件包含无效数字。", + "xerSingleProjectRequired": "此导入仅支持每个 XER 文件包含一个 P6 项目。", + "xerEmptyProject": "此 XER 文件中的 P6 项目不包含作业。", + "xerDuplicateId": "XER 文件包含重复的 WBS、作业或关系 ID。", + "xerAmbiguousLocalRelation": "未指定范围的 XER 关系无法明确分配给唯一的本地项目。", + "xerDanglingLocalRelation": "本地 XER 关系引用了缺失的作业。", + "xerEnumFallback": "XER {{family}}:未知标记“{{token}}”;已回退为“{{fallback}}”。", + "xerImportOpened_other": "已打开 XER 文件:{{count}} 个项目文档。", + "xerImportProjectsSeen_other": "找到的项目:{{count}}", + "xerImportEmptyProjectsSkipped_other": "已跳过的空项目:{{count}}", + "xerImportBaselineProjectsExcluded_other": "已排除的基线项目:{{count}}", + "xerImportBaselinesMaterialized_other": "已具体化的基线:{{count}}", + "xerImportDanglingBaselineReferences_other": "已忽略的悬空基线引用:{{count}}", + "xerImportBaselineFallback": "已使用保护性基线回退。", + "xerImportExternalLinks_other": "已保留的外部链接:{{count}}", + "xerImportEncoding": "选定的文本编码:{{encoding}}。", + "xerImportParserIssues_other": "解析器发现:{{count}}", + "xerImportCalendarIssues_other": "日历发现:{{count}}", + "xerImportNumberIssues_other": "数字格式问题:{{count}}", + "xerImportEnumFallbacks_other": "枚举回退:{{count}}", + "xerImportUnsupportedSemantics_other": "使用安全回退的 P6 排程设置:{{count}}", + "xerImportDatesAsRecorded_other": "显示 Primavera 记录日期(未重新计算)的任务:{{count}} 个", + "xerImportDatesAsRecordedOffer_other": "与文件中记录的日期不同的任务:{{count}} 个(可以显示)", + "xerExportLoss": "导出为 {{format}} 会丢失 XER 源信息。", "mppLegacy": "此 .mpp 文件使用较旧的格式(Project 2007 或更早版本)。请在 MS Project 中将其导出为 XML(文件 → 另存为 → XML),然后打开该文件。", "mppEncrypted": "此 .mpp 文件受密码保护。请在 MS Project 中将其导出为 XML(文件 → 另存为 → XML),然后打开该文件。", "saveFailed": "保存失败", @@ -773,9 +805,14 @@ }, "recordedDates": { "offer_other": "重新计算使 {{total}} 个任务中的 {{count}} 个偏离了文件中记录的日期。", + "activeCount_other": "你正在查看 Primavera 记录的进度计划;重新计算将使 {{count}} 个任务偏移。", + "activeCountNeutral_other": "你正在查看文件中记录的日期;重新计算将使 {{count}} 个任务偏移。", "show": "显示记录的日期", "active": "你正在查看文件中记录的日期,尚未重新计算。", - "recalculate": "重新计算" + "recalculate": "重新计算", + "markDeviates": "偏离", + "markPartlyUnrecorded": "部分未记录", + "notRecorded": "未记录" }, "duration": { "suffixDay": "天", @@ -953,6 +990,7 @@ "declaredHeading": "此扩展声明会使用的功能", "declaredNote": "这是作者的声明,而非限制——代码无论如何都能做得更多。", "declaredNone": "未声明任何内容。", + "permImportSource": "importSource — 每个导入文件的完整原始源字节(例如原始 Primavera 文件),包括从未进入项目的字段。", "cancel": "不安装", "confirm": "安装" } diff --git a/src/i18n/locales/zh/report.json b/src/i18n/locales/zh/report.json index 9ec39b0ae..aeecd3a52 100644 --- a/src/i18n/locales/zh/report.json +++ b/src/i18n/locales/zh/report.json @@ -132,6 +132,7 @@ "notCalculatedNote": "尚未计算——按“计算”(F5) 以获取日期和浮时。", "staleNote": "自上次计算以来进度计划已更改——按“计算”(F5) 以获取最新值。", "statusDateMissingNote": "未设置状态日期——报告以今天 ({{date}}) 为准。", + "recordedDatesNote": "“按保存日期”视图:本报告显示源文件中的日期,而非计算结果。文件未记录的最迟开始/完成和浮动时间在此显示为空或 0。", "period": "期间", "statusDate": "状态日期", "common": { diff --git a/src/i18n/locales/zh/task.json b/src/i18n/locales/zh/task.json index dca7daa33..ce437adc8 100644 --- a/src/i18n/locales/zh/task.json +++ b/src/i18n/locales/zh/task.json @@ -48,7 +48,7 @@ "taskGrid": { "columns": { "customTaskType": "自定义任务类型", - "activityCodeData":"活动代码数据","actualDuration":"实际工期","actualFinish":"实际完成日期","actualStart":"实际开始日期","assignedResources":"已分配资源","assignmentCurve":"分配曲线","assignmentId":"分配 ID","assignmentResourceId":"分配资源 ID","assignmentTaskId":"分配任务 ID","assignmentUnits":"每日分配单位","calendar":"日历","childIds":"子任务 ID","color":"颜色","completion":"进度","constraint2Date":"次要约束日期","constraint2Type":"次要约束类型","constraintDate":"约束日期","constraintHard":"强制约束","constraintType":"约束类型","critical":"关键","customFieldData":"自定义字段数据","deadline":"截止日期","description":"描述","duration":"工期","durationMinutes":"工期(分钟)","durationType":"工期类型","earlyFinish":"最早完成","earlyStart":"最早开始","effortDriven":"投入量驱动","externalRelationData":"外部关系数据","floatPath":"浮时路径","freeFloat":"自由浮时","hammock":"吊床任务(派生工期)","interferingFloat":"干扰浮时","internalRelationData":"内部关系数据","lateFinish":"最晚完成","lateStart":"最晚开始","levelingDelay":"资源调配延迟","levelingDelayElapsed":"已用时间调配延迟","levelingDelayMinutes":"资源调配延迟(分钟)","mandatoryMilestone":"强制里程碑","manuallyScheduled":"手动计划","milestone":"里程碑","milestoneKind":"里程碑类型","mspTaskType":"Microsoft Project 任务类型","name":"任务名称","nearCritical":"接近关键","noteData":"备注数据","notes":"备注","parentId":"父任务 ID","priority":"资源调配优先级","remainingMinutes":"剩余(分钟)","remainingTime":"剩余(工作日)","resourceIds":"资源 ID","resume":"恢复日期","scheduleFinish":"计划完成日期","scheduleStart":"计划开始日期","splitGaps":"中断间隔","status":"状态","stop":"停止日期","taskId":"任务 ID","taskType":"任务类型","timephasedContours":"时间分段分布","timephasedDurationWalks":"时间分段工期区段","timephasedFinishFloor":"时间分段完成下限","timephasedStartAnchor":"时间分段开始锚点","totalFloat":"总浮时","wbs":"工作分解结构(WBS)","workWindowFinish":"工作窗口结束","workWindowStart":"工作窗口开始" + "activityCodeData":"活动代码数据","actualDuration":"实际工期","actualFinish":"实际完成日期","actualStart":"实际开始日期","assignedResources":"已分配资源","assignmentCurve":"分配曲线","assignmentId":"分配 ID","assignmentResourceId":"分配资源 ID","assignmentTaskId":"分配任务 ID","assignmentUnits":"每日分配单位","calendar":"日历","childIds":"子任务 ID","color":"颜色","completion":"进度","constraint2Date":"次要约束日期","constraint2Type":"次要约束类型","constraintDate":"约束日期","constraintHard":"强制约束","constraintType":"约束类型","critical":"关键","customFieldData":"自定义字段数据","deadline":"截止日期","description":"描述","duration":"工期","durationMinutes":"工期(分钟)","durationType":"工期类型","earlyFinish":"最早完成","earlyStart":"最早开始","effortDriven":"投入量驱动","explicitSummary":"显式摘要任务","externalRelationData":"外部关系数据","floatPath":"浮时路径","freeFloat":"自由浮时","hammock":"吊床任务(派生工期)","interferingFloat":"干扰浮时","internalRelationData":"内部关系数据","lateFinish":"最晚完成","lateStart":"最晚开始","levelingDelay":"资源调配延迟","levelingDelayElapsed":"已用时间调配延迟","levelingDelayMinutes":"资源调配延迟(分钟)","mandatoryMilestone":"强制里程碑","manuallyScheduled":"手动计划","milestone":"里程碑","milestoneKind":"里程碑类型","mspTaskType":"Microsoft Project 任务类型","name":"任务名称","nearCritical":"接近关键","noteData":"备注数据","notes":"备注","p6Provenance":"Primavera P6 来源","parentId":"父任务 ID","priority":"资源调配优先级","recordedSource":"来源(记录的日期)","remainingMinutes":"剩余(分钟)","remainingTime":"剩余(工作日)","resourceIds":"资源 ID","resume":"恢复日期","scheduleFinish":"计划完成日期","scheduleStart":"计划开始日期","splitGaps":"中断间隔","status":"状态","stop":"停止日期","taskId":"任务 ID","taskType":"任务类型","timephasedContours":"时间分段分布","timephasedDurationWalks":"时间分段工期区段","timephasedFinishFloor":"时间分段完成下限","timephasedStartAnchor":"时间分段开始锚点","totalFloat":"总浮时","wbs":"工作分解结构(WBS)","workWindowFinish":"工作窗口结束","workWindowStart":"工作窗口开始" }, "category": { "task": "任务", "planning": "计划", "constraints": "约束", "relations": "关系", "resources": "资源", @@ -251,6 +251,10 @@ "timephasedActive": "跟随 MS Project 的工时分布", "timephasedLost": "编辑后不再应用 MS Project 的工时分布——原始分布仍保留在文件中", "timephasedContoured": "自定义工时分布", + "recordedDatesActive": "显示 Primavera 为此任务记录的日期", + "recordedDatesActiveNeutral": "显示文件中记录的此任务日期", + "recordedDatesDeviates": "偏离记录的日期", + "recordedDatesPartlyUnrecorded": "记录数据部分不完整——请查看最迟/时差列", "completion": "进度(%)", "progress": { "actualStart": "实际开始", diff --git a/src/services/benchmark/generateProject.ts b/src/services/benchmark/generateProject.ts index 2c54492d8..38cfe7811 100644 --- a/src/services/benchmark/generateProject.ts +++ b/src/services/benchmark/generateProject.ts @@ -14,6 +14,7 @@ import type { WorkCalendar, Holiday } from '@/types/calendar'; import type { Project } from '@/types/project'; import { createDefaultTaskTime } from '@/utils/taskDefaults'; import { parseDate, formatDate, addCalendarDays } from '@/utils/dateUtils'; +import { isLeafTask } from '@/utils/taskHierarchy'; /** De aangeboden benchmark-groottes (aantal taken totaal, incl. mijlpalen + verzameltaken). */ export const BENCHMARK_SIZES = [100, 500, 1000, 2500, 5000] as const; @@ -257,7 +258,7 @@ export function generateBenchmarkProject(size: number, opts: BenchmarkOptions = } // NB — waarom `leafTaskIds` hier gelijk is aan "alles wat de solver als leaf ziet". Het criterium - // van de solver is `childIds.length === 0`, niet "als verzameltaak bedoeld". Een verzameltaak die + // van de solver volgt `isLeafTask`, inclusief expliciete lege samenvattingen. Een verzameltaak die // door de loting geen enkel kind kreeg zou dus een leaf zijn die buiten het netwerk valt. Dat kan // niet meer: de twee round-robins hierboven geven élke top-fase een subfase en élke subfase een // blad, vóórdat er geloot wordt. Hier stond een reparatielus voor dat geval; die bleek @@ -368,6 +369,6 @@ export function generateBenchmarkProject(size: number, opts: BenchmarkOptions = // `runner.ts` rekent (audit-punt 5). Dit telt ook een eventuele verzameltaak die (door de // willekeurige verdeling) geen kinderen kreeg, zodat het gerapporteerde aantal en het aantal // dat de CPM-fase daadwerkelijk verwerkt niet meer uiteenlopen (geen "91 vs 90"). - leafCount: tasks.filter(t => t.childIds.length === 0).length, + leafCount: tasks.filter(isLeafTask).length, }; } diff --git a/src/services/benchmark/runner.ts b/src/services/benchmark/runner.ts index 3218fbf30..553618062 100644 --- a/src/services/benchmark/runner.ts +++ b/src/services/benchmark/runner.ts @@ -13,7 +13,7 @@ import { expandSummaryRelations } from '@/engine/scheduler/expandSummaryRelation // de app doet — precies het soort meting waar je beslissingen op baseert. import { applyCpmResult } from '@/engine/scheduler/applyCpmResult'; import { writeIFC } from '@/services/ifc/ifcWriter'; -import { readIFC } from '@/services/ifc/ifcReader'; +import { readIFCWithXerReconstruction } from '@/services/formatRegistry'; import { GanttRenderer, type GanttRenderOptions } from '@/engine/renderer/GanttRenderer'; import { computeViewRows, type ViewRow } from '@/engine/view/visibleRows'; import type { ViewContext } from '@/engine/view/filterEval'; @@ -21,6 +21,7 @@ import type { ViewState } from '@/types/view'; import type { CPMResult } from '@/engine/scheduler/CPMSolver'; import { generateBenchmarkProject, type GeneratedProject } from './generateProject'; import { formatBytes } from '@/utils/formatBytes'; +import { isLeafTask } from '@/utils/taskHierarchy'; export type PhaseId = 'generate' | 'cpm' | 'ifcWrite' | 'ifcRead' | 'render'; export const PHASE_ORDER: PhaseId[] = ['generate', 'cpm', 'ifcWrite', 'ifcRead', 'render']; @@ -134,7 +135,7 @@ export async function runBenchmark({ size, version, resourceCount, onProgress }: // Leaf-taken = taken zonder kinderen; dit is EXACT het criterium dat de generator ook voor // `data.leafCount` gebruikt (audit-punt 5), zodat het gerapporteerde aantal overeenkomt met // wat de CPM-fase daadwerkelijk verwerkt. - const leafTasks = data.tasks.filter((t) => t.childIds.length === 0); + const leafTasks = data.tasks.filter(isLeafTask); // Samenvattingsrelatie-propagatie (zie `scheduleSlice.runCPM`): de generator maakt vandaag alleen // bladtaak-naar-bladtaak relaties (`generateProject.ts`), dus dit is hier een no-op passthrough — // maar de benchmark-fase moet, net als elke andere CPMSolver-aanroeper, door dezelfde poort gaan @@ -186,7 +187,7 @@ export async function runBenchmark({ size, version, resourceCount, onProgress }: const t0 = performance.now(); // Geen `labels`: dienstlaag zonder `t(...)` — de benchmark leest zijn eigen zojuist geschreven IFC. `readIFC` valt dan terug op de Engelse // default voor een bestand zonder IFCPROJECT (zie ImportLabels). - const parsed = readIFC(ifc); + const parsed = await readIFCWithXerReconstruction(ifc); readSamples.push(performance.now() - t0); parsedTasks = parsed.tasks.length; await yieldToUi(); diff --git a/src/services/csv/csvWriter.ts b/src/services/csv/csvWriter.ts index de30e9b9a..4ff137315 100644 --- a/src/services/csv/csvWriter.ts +++ b/src/services/csv/csvWriter.ts @@ -1,4 +1,5 @@ import { Task } from '@/types/task'; +import type { UnrecordedExportField } from '@/state/recordedDatesSelectors'; import { Sequence, SequenceType } from '@/types/sequence'; import { Resource, ResourceAssignment } from '@/types/resource'; import { Project } from '@/types/project'; @@ -43,6 +44,17 @@ export function writeCSV( _resources: Resource[], _assignments: ResourceAssignment[], customTaskTypes: readonly CustomTaskType[] = [], + /** + * "Datums zoals opgeslagen" (critreview laag 3, bevinding 6): staat de modus aan, dan draagt + * `task.time` de vastlegging van het bronbestand — mét de bewuste terugvallen voor assen die het + * bestand NIET vastlegde (`totalFloat ?? 0`, `isCritical ?? false`). In de tabel toont die naad + * "Niet vastgelegd"; hier is het equivalent een LEGE cel, want een CSV-lezer kan een verzonnen + * `0` niet van een echte nulspeling onderscheiden. `undefined` (het gewone geval, en elk + * document buiten de modus) ⇒ byte-identieke export als voorheen. + * De aanroeper bouwt deze functie met `unrecordedExportGate` (`state/recordedDatesSelectors`); + * deze module blijft store-vrij en krijgt hem als parameter. + */ + unrecordedExportFieldsOf?: (task: Task) => readonly UnrecordedExportField[], ): string { // H5 (eindreview T16c): de "Duration (days)"-kolom kent geen elapsed-notatie (anders dan de // relatie-lag hierboven, die "ed"/"e%" al schrijft) — een taak met ELAPSEDTIME-duur (T8, 24/7- @@ -87,6 +99,7 @@ export function writeCSV( for (const task of tasks) { const predecessors = predMap.get(task.id)?.join(', ') || ''; const completion = Math.round(task.time.completion * 100); + const unrecorded = unrecordedExportFieldsOf?.(task); const row = [ escapeCSV(task.wbsCode), @@ -101,8 +114,8 @@ export function writeCSV( completion.toString(), task.time.actualStart || '', task.time.actualFinish || '', - task.time.isCritical ? 'Yes' : 'No', - task.time.totalFloat.toString(), + unrecorded?.includes('isCritical') ? '' : (task.time.isCritical ? 'Yes' : 'No'), + unrecorded?.includes('totalFloat') ? '' : task.time.totalFloat.toString(), escapeCSV(task.description), ]; rows.push(row.join(DELIMITER)); diff --git a/src/services/fileAccess/tauriBackend.ts b/src/services/fileAccess/tauriBackend.ts index b941b9e15..452326f35 100644 --- a/src/services/fileAccess/tauriBackend.ts +++ b/src/services/fileAccess/tauriBackend.ts @@ -3,22 +3,36 @@ import { ensureExtension, extensionOf } from '@/utils/filePath'; const basename = (p: string): string => p.split(/[\\/]/).pop() || p; -export async function openFileDialogTauri(filters: FileFilter[], opts?: OpenDialogOpts): Promise { - const { open } = await import('@tauri-apps/plugin-dialog'); - const selected = await open({ multiple: false, filters }); - if (!selected) return null; - const path = selected as string; +export interface TauriOpenIO { + readTextFile(path: string): Promise; + readFile(path: string): Promise; +} + +/** Injecteerbare kern van de Tauri-openroute: bewaakt vooral dat binaire bronformaten nooit via + * `readTextFile` lopen en hun oorspronkelijke bytes dus behouden. */ +export async function readOpenedTauriPath( + path: string, + opts: OpenDialogOpts | undefined, + io: TauriOpenIO, +): Promise { const isBinary = (opts?.binaryExtensions ?? []).includes(extensionOf(path)); if (isBinary) { - const { readFile } = await import('@tauri-apps/plugin-fs'); - const bytes = await readFile(path); + const bytes = await io.readFile(path); return { name: basename(path), content: '', bytes, ref: { kind: 'path', path } }; } - const { readTextFile } = await import('@tauri-apps/plugin-fs'); - const content = await readTextFile(path); + const content = await io.readTextFile(path); return { name: basename(path), content, ref: { kind: 'path', path } }; } +export async function openFileDialogTauri(filters: FileFilter[], opts?: OpenDialogOpts): Promise { + const { open } = await import('@tauri-apps/plugin-dialog'); + const selected = await open({ multiple: false, filters }); + if (!selected) return null; + const path = selected as string; + const { readTextFile, readFile } = await import('@tauri-apps/plugin-fs'); + return readOpenedTauriPath(path, opts, { readTextFile, readFile }); +} + export async function saveFileDialogTauri(defaultName: string, content: string, filters: FileFilter[]): Promise { const { save } = await import('@tauri-apps/plugin-dialog'); const { writeTextFile } = await import('@tauri-apps/plugin-fs'); diff --git a/src/services/formatRegistry.ts b/src/services/formatRegistry.ts index 1c4782fc8..3c58fa8ee 100644 --- a/src/services/formatRegistry.ts +++ b/src/services/formatRegistry.ts @@ -4,10 +4,11 @@ // gedrag ongewijzigd (fase 3.8 etappe 1, taak T1). import { readIFC } from '@/services/ifc/ifcReader'; +import { XER_SOURCE_ARCHIVE_COMPACT_STORAGE_FORMAT } from '@/services/xerSourceArchive'; import { readCSV } from '@/services/csv/csvReader'; import { readMSPDI } from '@/services/msproject/mspdiReader'; import { readP6XML } from '@/services/p6/p6xmlReader'; -import type { ImportLabels, ImportResult } from '@/services/importTypes'; +import type { ImportLabels, ImportResult, OpenedImport } from '@/services/importTypes'; import type { FileFilter, FileRef } from '@/services/fileAccess'; import { extensionOf } from '@/utils/filePath'; @@ -28,14 +29,14 @@ export interface ReadFormat { * Vervangt de eerdere `id === 'ifc'`/`format === 'IFC' && !isBinary`-vergelijkingen in * `fileSlice.ts` en `fileTools.ts` — één vlag, één plek. */ canBeSaveTarget?: boolean; - read(input: FormatInput, labels?: ImportLabels): Promise; + read(input: FormatInput, labels?: ImportLabels): Promise; } /** Interne subdispatch voor de xml-entry van `READ_FORMATS`: kies de juiste XML-reader op basis * van inhoudsmarkers (P6 vóór MS Project). Gooit bij een onbekend formaat i.p.v. stil als MSPDI * te parsen. Niet geëxporteerd (T1-restpunt): geen afnemer buiten deze module — de enige * aanroeper is de xml-entry hieronder. */ -function parseProjectXml(content: string): ImportResult { +function parseProjectXml(content: string): OpenedImport { const isP6 = content.includes('APIBusinessObjects') || content.includes('Primavera'); const isMsProject = content.includes('schemas.microsoft.com/project') || content.includes(' { + if (content.includes(XER_SOURCE_ARCHIVE_COMPACT_STORAGE_FORMAT)) { + const { reconstructXerSourceFromBytes } = await import('@/services/xer/xerReader'); + return readIFC(content, labels, { reconstructXerArchive: reconstructXerSourceFromBytes }); + } + return readIFC(content, labels); +} + /** Default-formaat bij een onbekende extensie (bestaand gedrag: de else-tak van alle vijf * kopieën). Een APARTE, benoemde const-entry (T1-restpunt) i.p.v. `READ_FORMATS.find(...)!` — * zo kan de default nooit "zoek 'm op en forceer met `!`" zijn (een niet-gevonden id zou dat stil @@ -51,10 +67,10 @@ function parseProjectXml(content: string): ImportResult { * dus herordenen wisselt 'm nooit stilzwijgend. */ const IFC_FORMAT: ReadFormat = { id: 'ifc', extensions: ['ifc'], kind: 'text', filterName: 'IFC Files', canBeSaveTarget: true, - read: async (i, labels) => readIFC(i.text ?? '', labels), + read: async (i, labels) => readIFCWithXerReconstruction(i.text ?? '', labels), }; -// Volgorde = bestaande filtervolgorde in openFile ('All Supported' met ifc,csv,xml,mpp). +// Volgorde = filtervolgorde in openFile ('All Supported' met ifc,csv,xml,mpp,xer). const READ_FORMATS: ReadFormat[] = [ IFC_FORMAT, { id: 'csv', extensions: ['csv'], kind: 'text', filterName: 'CSV Files', @@ -68,6 +84,13 @@ const READ_FORMATS: ReadFormat[] = [ const { readMPP } = await import('@/services/mpp/mppReader'); return readMPP(i.bytes, labels); } }, + { id: 'xer', extensions: ['xer'], kind: 'binary', filterName: 'Primavera XER Files', + read: async (i) => { + if (!i.bytes) throw new Error('XER requires original binary content'); + // Dynamic import: encodingdetectie en de semantische reader blijven buiten de main chunk. + const { readXER } = await import('@/services/xer/xerReader'); + return readXER(i.bytes); + } }, ]; /** Extensie-match; onbekende extensie ⇒ de default (IFC). */ @@ -94,7 +117,7 @@ export function allReadFormats(): readonly ReadFormat[] { return READ_FORMATS; } -export function parseOpenedFile(input: FormatInput, labels?: ImportLabels): Promise { +export function parseOpenedFile(input: FormatInput, labels?: ImportLabels): Promise { return readFormatForFile(input.name).read(input, labels); } @@ -144,10 +167,46 @@ export function saveTargetFor( * laag (services/) niet van state/ afhangt. */ export function importErrorMessageKey( err: unknown, -): 'notifications.openFailed' | 'notifications.mppEncrypted' | 'notifications.mppLegacy' { - const code = (err as { mppCode?: string } | null | undefined)?.mppCode; - if (code === 'MPP_ENCRYPTED') return 'notifications.mppEncrypted'; - if (code === 'MPP_LEGACY') return 'notifications.mppLegacy'; +): + | 'notifications.openFailed' + | 'notifications.mppEncrypted' + | 'notifications.mppLegacy' + | 'notifications.xerInvalidInput' + | 'notifications.xerInvalidFile' + | 'notifications.xerInvalidEncoding' + | 'notifications.xerDuplicateTable' + | 'notifications.xerMissingRequiredColumns' + | 'notifications.xerMissingRequiredValue' + | 'notifications.xerAmbiguousDecimal' + | 'notifications.xerInvalidNumberFormat' + | 'notifications.xerInvalidNumber' + | 'notifications.xerSingleProjectRequired' + | 'notifications.xerEmptyProject' + | 'notifications.xerDuplicateId' + | 'notifications.xerAmbiguousLocalRelation' + | 'notifications.xerDanglingLocalRelation' { + const typed = err as { mppCode?: string; xerCode?: string } | null | undefined; + if (typed?.mppCode === 'MPP_ENCRYPTED') return 'notifications.mppEncrypted'; + if (typed?.mppCode === 'MPP_LEGACY') return 'notifications.mppLegacy'; + const xerKeys = { + XER_INVALID_INPUT: 'notifications.xerInvalidInput', + XER_INVALID_FILE: 'notifications.xerInvalidFile', + XER_INVALID_ENCODING: 'notifications.xerInvalidEncoding', + XER_DUPLICATE_TABLE: 'notifications.xerDuplicateTable', + XER_MISSING_REQUIRED_COLUMNS: 'notifications.xerMissingRequiredColumns', + XER_MISSING_REQUIRED_VALUE: 'notifications.xerMissingRequiredValue', + XER_AMBIGUOUS_DECIMAL: 'notifications.xerAmbiguousDecimal', + XER_INVALID_NUMBER_FORMAT: 'notifications.xerInvalidNumberFormat', + XER_INVALID_NUMBER: 'notifications.xerInvalidNumber', + XER_SINGLE_PROJECT_REQUIRED: 'notifications.xerSingleProjectRequired', + XER_EMPTY_PROJECT: 'notifications.xerEmptyProject', + XER_DUPLICATE_ID: 'notifications.xerDuplicateId', + XER_AMBIGUOUS_LOCAL_RELATION: 'notifications.xerAmbiguousLocalRelation', + XER_DANGLING_LOCAL_RELATION: 'notifications.xerDanglingLocalRelation', + } as const; + if (typed?.xerCode && typed.xerCode in xerKeys) { + return xerKeys[typed.xerCode as keyof typeof xerKeys]; + } return 'notifications.openFailed'; } diff --git a/src/services/ifc/ifcErrors.ts b/src/services/ifc/ifcErrors.ts index cd39adbf0..015ff4385 100644 --- a/src/services/ifc/ifcErrors.ts +++ b/src/services/ifc/ifcErrors.ts @@ -20,7 +20,9 @@ export type IfcParseErrorReason = /** Kop én sluitmarkering aanwezig, maar er is geen `DATA;`-sectiegrens te vinden. Zonder die * grens valt er niets te parsen; stil een leeg project teruggeven zou het bestand van de * gebruiker onder een leeg document begraven (het pad blijft immers gekoppeld). */ - | 'no-data-section'; + | 'no-data-section' + /** Aanwezig XER-bronarchief is structureel of cryptografisch corrupt — geen legacy fallback. */ + | 'xer-source-archive'; export class IfcParseError extends Error { readonly reason: IfcParseErrorReason; diff --git a/src/services/ifc/ifcPsets.ts b/src/services/ifc/ifcPsets.ts index dddb2ec2d..39141b9cf 100644 --- a/src/services/ifc/ifcPsets.ts +++ b/src/services/ifc/ifcPsets.ts @@ -1,6 +1,8 @@ import type { Task, ConstraintType, TaskSplitGap, TaskTimephasedContour, TimephasedContourPeriod, MspTaskType, + P6CompletePctType, P6DurationType, P6ActivityType, } from '@/types/task'; +import { hasValidP6SuspendResume } from '@/utils/p6SuspendResume'; /** * IFC-pset-registry (fase 3, tweede helft van P11 uit docs/superpowers/modulariteit-audit.md, @@ -65,6 +67,10 @@ export const PSET = { /** MSP's eigen Task Type + Effort-Driven-vlag (`Task.mspTaskType`/`effortDriven`) — puur data, * geen rekengedrag (eigenaarsbesluit 2026-08-18, punt 1). */ MspTaskType: 'OPS_MspTaskType', + /** X7: P6-bronidentiteit, voortgangsfamilie, verwacht einde en suspend/resume-firewall. */ + P6Progress: 'OPS_P6Progress', + /** Expliciete samenvattingsidentiteit voor een WBS-taak zonder kinderen. */ + Summary: 'OPS_Summary', // Structuur/waarden op project- of taak-niveau (afwijkende vorm — alleen naam gedeeld). ProjectSettings: 'OPS_ProjectSettings', StructureMeta: 'OPS_StructureMeta', @@ -82,6 +88,14 @@ export const PSET = { // Op de IfcWorkSchedule (autoritaire JSON-blob — alleen naam gedeeld). Baselines: 'OPS_Baselines', SchedulingOptions: 'OPS_SchedulingOptions', + /** X9: één projectcontainer met de exacte oorspronkelijke XER-bytes. */ + XerSourceArchive: 'OPS_XerSourceArchive', + /** X9: selector welk XER-PROJECT het zelfstandige IFC-document vertegenwoordigt. */ + XerDocument: 'OPS_XerDocument', + /** Relatie-eigen XER-herkomst, als een geldige pset op de IfcWorkSchedule met relationele GUIDs. + * IFC laat een IfcPropertySet niet rechtstreeks aan IfcRelSequence hangen; de GUIDs maken de + * koppeling toch exact, zonder een schema-ongeldige relatie te serialiseren. */ + Sequences: 'OPS_Sequences', // Per kalender (afwijkende vorm — alleen naam gedeeld). Calendar: 'OPS_Calendar', // Bedrijfsbibliotheek-pool als autoritatief JSON-blob op het IfcProject (spec B1, §4). @@ -469,6 +483,58 @@ export const PER_TASK_PSETS: PerTaskPset[] = [ } }, }, + // 15. X7 — precies de vijf velden die P6-voortgang en de resume-firewall na save/reload + // betekenisvast houden. Dit is een smalle per-taak-pset; de bredere X9 raw-archive-etappe + // blijft buiten scope. Golden rule: een taak zonder één van deze velden schrijft geen pset. + { + name: PSET.P6Progress, psetSeed: 'pset_p6prog_', relSeed: 'rel_p6prog_', + write(task) { + const props: PropSpec[] = []; + if (task.p6ProjectId) props.push({ name: 'ProjectId', value: `IFCTEXT(${ifcStr(task.p6ProjectId)})` }); + if (task.p6TaskId) props.push({ name: 'TaskId', value: `IFCTEXT(${ifcStr(task.p6TaskId)})` }); + if (task.p6ExplicitTargetWindow === true) props.push({ name: 'ExplicitTargetWindow', value: 'IFCBOOLEAN(.T.)' }); + if (task.p6CompletePctType) props.push({ name: 'CompletePctType', value: `IFCLABEL(${ifcStr(task.p6CompletePctType)})` }); + if (task.p6ExpectedFinish) props.push({ name: 'ExpectedFinish', value: `IFCTEXT(${ifcStr(task.p6ExpectedFinish)})` }); + if (task.p6DurationType) props.push({ name: 'DurationType', value: `IFCLABEL(${ifcStr(task.p6DurationType)})` }); + if (task.p6ActivityType) props.push({ name: 'ActivityType', value: `IFCLABEL(${ifcStr(task.p6ActivityType)})` }); + if (task.p6SuspendResume === false) props.push({ name: 'SuspendResume', value: 'IFCBOOLEAN(.F.)' }); + else if (hasValidP6SuspendResume(task)) props.push({ name: 'SuspendResume', value: 'IFCBOOLEAN(.T.)' }); + return props.length > 0 ? props : null; + }, + apply(task, props) { + const completePctTypes: readonly P6CompletePctType[] = ['CP_Drtn', 'CP_Phys', 'CP_Units']; + const durationTypes: readonly P6DurationType[] = ['DT_FixedDrtn', 'DT_FixedDUR2', 'DT_FixedRate', 'DT_FixedQty']; + const activityTypes: readonly P6ActivityType[] = ['TT_Task', 'TT_Rsrc', 'TT_LOE', 'TT_Mile', 'TT_FinMile', 'TT_WBS']; + for (const { name, value } of props) { + if (name === 'ExplicitTargetWindow') { if (value === true) task.p6ExplicitTargetWindow = true; continue; } + if (name === 'SuspendResume') { if (typeof value === 'boolean') task.p6SuspendResume = value; continue; } + if (typeof value !== 'string' || !value) continue; + if (name === 'ProjectId') task.p6ProjectId = value; + else if (name === 'TaskId') task.p6TaskId = value; + else if (name === 'ExpectedFinish') task.p6ExpectedFinish = value; + else if (name === 'DurationType' && (durationTypes as readonly string[]).includes(value)) task.p6DurationType = value as P6DurationType; + else if (name === 'ActivityType' && (activityTypes as readonly string[]).includes(value)) task.p6ActivityType = value as P6ActivityType; + else if (name === 'CompletePctType' && (completePctTypes as readonly string[]).includes(value)) { + task.p6CompletePctType = value as P6CompletePctType; + } + } + }, + }, + // 16. Expliciete WBS-identiteit — nodig om een lege PROJWBS-samenvatting door IFC te bewaren. + // Alleen `true` schrijft iets; alle bestaande taken blijven byte-identiek. + { + name: PSET.Summary, psetSeed: 'pset_sum_', relSeed: 'rel_sum_', + write(task) { + return task.isSummary === true + ? [{ name: 'IsSummary', value: 'IFCBOOLEAN(.T.)' }] + : null; + }, + apply(task, props) { + for (const { name, value } of props) { + if (name === 'IsSummary' && value === true) task.isSummary = true; + } + }, + }, ]; /** Naam → descriptor, voor de reader-dispatch in `extractStructure`. */ diff --git a/src/services/ifc/ifcReader.ts b/src/services/ifc/ifcReader.ts index cf92f3d9b..69aeac3a8 100644 --- a/src/services/ifc/ifcReader.ts +++ b/src/services/ifc/ifcReader.ts @@ -25,6 +25,18 @@ import { type RecordedFieldKey, type TaskTimeReadHelpers, } from './ifcTaskSlots'; import { normalizeImportedProgress } from '@/services/importNormalize'; +import { reconcileP6SuspendResume } from '@/utils/p6SuspendResume'; +import type { XerImportMetadata } from '@/services/importTypes'; +import { + bindXerImportMetadataToArchive, createXerSourceArchiveFromOwnedMetadata, decodeXerBase64Chunk, + parseXerArchiveMetadataPayload, sha256Hex, + XER_SOURCE_ARCHIVE_CHUNK_BYTES, XER_SOURCE_ARCHIVE_COMPACT_STORAGE_FORMAT, + XER_SOURCE_ARCHIVE_COMPACT_STORAGE_SCHEMA_VERSION, + XER_SOURCE_ARCHIVE_SCHEMA_VERSION, type XerSourceArchive, type XerSourceArchiveBom, + type XerSourceArchiveEncoding, type XerSourceArchiveNewline, type XerArchiveMetadataPayloadV1, + type XerSourceReconstruction, +} from '@/services/xerSourceArchive'; +import { sanitizeSchedulingOptions } from '@/services/ifc/schedulingOptionsRead'; import { canonicalizeBands, clockToMinutes, getCalendarBands, hasNonAnchorTime, isoDurationToMinutes, isSubDayMinutes, promoteHourCalendar, registerCalendarBands, @@ -36,6 +48,46 @@ import { const VALID_CURVES: ResourceCurve[] = ['UNIFORM', 'FRONT_LOADED', 'BACK_LOADED', 'BELL', 'EARLY_PEAK', 'LATE_PEAK', 'DOUBLE_PEAK', 'TURTLE']; +/** + * Expliciete injectienaad: de compacte schema-2-envelope bewaart alleen bronbytes; de zware, + * lazy XER-reader levert de afleiding daarvan uitsluitend via de officiële async ingang. + * + * ONTWERPKEUZE T5 — "datums zoals opgeslagen" (issue #63, XER-laag 3) overleeft een IFC-opslag en + * -heropening via DEZE naad, niet via een eigen `OPS_`-pset; het etappeplan §3.8 hield beide routes + * open. Afweging: + * + * - *Geen tweede afleiding.* De reconstructie draait al een volledige `readXER` over bytes die + * hieronder op sha256 zijn geverifieerd. De bak-4-vastlegging die daaruit komt is per + * constructie identiek aan die van het oorspronkelijke openen — zelfde kalenderpromotie + * (`promoteHourCalendar`), zelfde dag/uur-representatie (`sourceInstant`), zelfde getalnotatie + * (`parseXerNumber`). Zelf herrekenen uit `readModel.taskSourceRowsByProject` zou die drie in de + * IFC-laag moeten NABOOTSEN op gereconstrueerde kalenders — precies de stille faalmodus die plan + * §5.2 aanwijst (één representatieverschil ⇒ élke taak telt als "verschoven"). + * - *De chunkgrens blijft heel.* `parseXerNumber` woont in de tokenizer (`xerTables.ts`); die + * hier statisch importeren trekt de hele XER-parser de hoofdbundel in. + * - *Id-matching is al opgelost.* XER-taak-id's ZIJN de rauwe `task_id`-cellen, en + * `OPS_TaskIdentity` draagt exact die id's door de opslag heen (zie `stableIfcTaskId`). + * Baselinetaken hangen aan een `.BASELINE.`-IfcWorkSchedule en worden in `extractTasks` + * overgeslagen, dus hun eigen GUID-remap (`extractBaselines`) raakt deze koppeling niet. + * - *Werkt óók bij opslaan buiten de modus.* Een pset had `recordedDates` moeten meeschrijven — + * maar `runCPM` WIST dat veld bij het verlaten van de modus, dus opslaan ná een herberekening zou + * de vastlegging verliezen. De bronroute is herkomstgedreven en daarmee modus-onafhankelijk. + * - *Kosten:* geen contractwijziging (`DOCUMENT_FIELDS`, `IFC_SAVE_KEYS` en daarmee + * `sameIFCSource`/`isDirty` blijven ongemoeid) en geen extra parse — die `readXER` liep al. + * + * BEKENDE GRENS: historische schema-1-archieven (niet-compact) krijgen géén `recordedTimes` terug. + * `readIFCWithXerReconstruction` geeft voor die vorm bewust geen reconstructor mee (dat pad blijft + * synchroon, zonder XER-chunk) en het schema-1-leesmodel draagt de vastlegging niet zelf. Alleen + * pre-schema-2-builds schreven die vorm; de huidige writer schrijft uitsluitend schema 2. + */ +export type XerArchiveReconstructor = (bytes: Uint8Array) => XerSourceReconstruction; + +export interface IfcReadOptions { + /** Alleen `readIFCWithXerReconstruction` vult dit. De lage sync-lezer mag schema-2 nooit + * afhankelijk maken van een toevallig eerder geïmporteerde module. */ + reconstructXerArchive?: XerArchiveReconstructor; +} + interface StepEntity { id: string; // STEP entity ID (may include letters, e.g. "300T") type: string; @@ -95,7 +147,11 @@ export const DEFAULT_IMPORTED_PROJECT_NAME = 'Imported project'; * alleen de projectnaam voor een bestand zónder `IFCPROJECT`. Weglaten is toegestaan en levert de * Engelse default; zie `ImportLabels`. */ -export function readIFC(content: string, labels: ImportLabels = {}): ImportResult { +export function readIFC( + content: string, + labels: ImportLabels = {}, + options: IfcReadOptions = {}, +): ImportResult { // Eerst de integriteitspoort: liever een expliciete fout dan een stil half project (K4). assertIfcIntegrity(content); const entities = parseSTEP(content); @@ -113,6 +169,19 @@ export function readIFC(content: string, labels: ImportLabels = {}): ImportResul // Extract project const project = extractProject(entities, entityMap, labels); + const xerSource = extractXerSourceArchive(entities, entityMap, options.reconstructXerArchive); + const xerSourceArchive = xerSource?.archive; + const xerSourceProjectId = extractXerSourceProjectId(entities, entityMap, xerSourceArchive); + const xer = extractXerImportMetadata(xerSourceArchive, xerSourceProjectId); + // T5 — "datums zoals opgeslagen" over een IFC-opslag/heropening heen. GEEN eigen pset en geen + // eigen afleiding: dit is letterlijk de map die `readXER` over dezelfde, sha256-geverifieerde + // bronbytes maakte (zie `XerArchiveReconstructor` hierboven voor de volledige afweging). De + // selector `OPS_XerDocument` kiest het project; een bestand zonder XER-archief, met een onbekende + // selector of uit een historische schema-1-envelope houdt `recordedTimes` afwezig en gedraagt + // zich daarmee byte-identiek aan vóór T5. + const recordedTimes = xerSourceProjectId + ? xerSource?.recordedTimesByProject[xerSourceProjectId] + : undefined; const calendar = extractCalendar(entities, entityMap); // Taken die aan een `.BASELINE.`-IfcWorkSchedule hangen zijn baseline-snapshots, geen live // taken (fase 2.6, §8.3) — sla ze over (robuust tegen externe tools; OPS zelf hangt er geen op). @@ -120,7 +189,10 @@ export function readIFC(content: string, labels: ImportLabels = {}): ImportResul const { tasks, taskStepIdMap, taskTimeEntities, recordedFields } = extractTasks( entities, entityMap, baselineTaskStepIds, taskIdentityByStepId, ); - const sequences = extractSequences(entities, entityMap, taskStepIdMap); + const p6BoundarySequenceGuids = extractP6BoundarySequenceGuids( + entities, entityMap, new Set(taskStepIdMap.keys()), + ); + const sequences = extractSequences(entities, entityMap, taskStepIdMap, p6BoundarySequenceGuids); extractNesting(entities, entityMap, tasks, taskStepIdMap); const { resources, resourceStepIdMap, resourceGuidMap } = extractResources(entities, entityMap); extractResourceMeta(entities, entityMap, resources, resourceStepIdMap, resourceGuidMap); @@ -151,6 +223,7 @@ export function readIFC(content: string, labels: ImportLabels = {}): ImportResul const { activityCodeTypes, customFieldDefs } = extractStructure( entities, entityMap, project, tasks, taskStepIdMap, libraryPoolOut, projectStartRecorded, ); + for (const task of tasks) reconcileP6SuspendResume(task); const customTaskTypes = extractTaskTypeMeta(entities, entityMap, tasks, taskStepIdMap); // Z14b (Z8-nataak, F1-fixronde) — LAAG-4-kalenderwandelingen, eigen pset (zie de functie se // moduleheader voor waarom dit niet via de PER_TASK_PSETS-registry loopt): GUID→id-vertaling, dus @@ -201,9 +274,267 @@ export function readIFC(content: string, labels: ImportLabels = {}): ImportResul baselines, activeBaselineId, libraryPool: libraryPoolOut.value, recordedFields, + // Heropen-beleid (orkestratorbesluit, XER-etappe laag 3, 2026-09-05): 'xer-archive', NIET 'xer' + // — deze route is een HEROPENING, geen verse import. `applyRecordedDatesOnLoad` zet de modus + // alleen automatisch aan bij 'xer'; 'xer-archive' krijgt uitsluitend het #63-AANBOD, want een + // intussen bewerkte en opgeslagen planning mag bij heropenen niet stilzwijgend P6's oude datums + // tonen. Zie `importTypes.ts` (`recordedTimesOrigin`) voor het volledige onderscheid. + ...(recordedTimes ? { recordedTimes, recordedTimesOrigin: 'xer-archive' as const } : {}), + ...(xerSourceArchive ? { xerSourceArchive } : {}), + ...(xerSourceProjectId ? { xerSourceProjectId } : {}), + ...(xer ? { xer } : {}), }; } +function extractXerImportMetadata( + archive: XerSourceArchive | undefined, sourceProjectId: string | undefined, +): XerImportMetadata | undefined { + if (!archive || !sourceProjectId) return undefined; + try { + return bindXerImportMetadataToArchive(archive, sourceProjectId); + } catch (error) { + xerArchiveError(error instanceof Error ? error.message : 'selectorview is ongeldig'); + } +} + +function extractXerSourceProjectId( + entities: StepEntity[], entityMap: Map, archive: XerSourceArchive | undefined, +): string | undefined { + const props = archiveProps(entities, entityMap, PSET.XerDocument); + if (!props) { + if (archive) xerArchiveError('OPS_XerDocument-selector ontbreekt'); + return undefined; + } + if (!archive) xerArchiveError('OPS_XerDocument bestaat zonder OPS_XerSourceArchive'); + if (JSON.stringify([...props.keys()]) !== JSON.stringify(['ArchiveSha256', 'SourceProjectId'])) { + xerArchiveError('OPS_XerDocument-properties zijn niet exact en deterministisch geordend'); + } + if (requiredString(props, 'ArchiveSha256') !== archive.sha256) xerArchiveError('selector ArchiveSha256 wijst niet naar het archief'); + return requiredString(props, 'SourceProjectId'); +} + +function xerArchiveError(message: string): never { + throw new IfcParseError('xer-source-archive', `Ongeldig OPS_XerSourceArchive: ${message}`); +} + +function archiveProps(entities: StepEntity[], entityMap: Map, psetName: string): Map | undefined { + const sets = entities.filter(entity => entity.type === 'IFCPROPERTYSET' && stripQuotes(entity.args[2] || '') === psetName); + if (sets.length === 0) return undefined; + if (sets.length !== 1) xerArchiveError(`Pset '${psetName}' komt ${sets.length} keer voor`); + const projects = entities.filter(entity => entity.type === 'IFCPROJECT'); + if (projects.length !== 1) xerArchiveError(`Pset '${psetName}' vereist exact één IFCPROJECT; gevonden: ${projects.length}`); + const project = projects[0]!; + const attachments = entities.filter(entity => + entity.type === 'IFCRELDEFINESBYPROPERTIES' + && parseRef(entity.args[5] || '') === sets[0]!.id, + ); + if (!project || attachments.length !== 1 + || JSON.stringify(parseRefs(attachments[0]!.args[4] || '')) !== JSON.stringify([project.id])) { + xerArchiveError(`Pset '${psetName}' hangt niet één-op-één aan IFCPROJECT`); + } + const values = new Map(); + for (const ref of parseRefs(sets[0]!.args[4] || '')) { + const prop = entityMap.get(ref); + if (!prop || prop.type !== 'IFCPROPERTYSINGLEVALUE') xerArchiveError(`property '${ref}' ontbreekt of is geen single value`); + const name = stripQuotes(prop.args[0] || ''); + if (!name || values.has(name)) xerArchiveError(`property '${name || ref}' ontbreekt of is dubbel`); + values.set(name, parseTypedValue(prop.args[2] || '')); + } + return values; +} + +function validateArchivePropertyOrder( + props: Map, manifestNames: readonly string[], chunkCount: number, diagnosticsCount: number, +): void { + const propertyBudget = props.size - manifestNames.length; + if (propertyBudget < 0 + || chunkCount > propertyBudget + || diagnosticsCount > propertyBudget - chunkCount) { + xerArchiveError('chunkcounts overschrijden het werkelijk aanwezige propertybudget'); + } + if (chunkCount + diagnosticsCount !== propertyBudget) { + xerArchiveError('chunkcounts passen niet exact bij het werkelijk aanwezige propertybudget'); + } + let position = 0; + for (const actual of props.keys()) { + let expected: string; + if (position < manifestNames.length) { + expected = manifestNames[position]!; + } else if (position < manifestNames.length + chunkCount) { + expected = `ByteChunk${String(position - manifestNames.length).padStart(6, '0')}`; + } else { + expected = `DiagnosticsChunk${String(position - manifestNames.length - chunkCount).padStart(6, '0')}`; + } + if (actual !== expected) xerArchiveError('properties zijn niet uniek en deterministisch geordend'); + position += 1; + } +} + +function validateCompactArchivePropertyOrder( + props: Map, manifestNames: readonly string[], chunkCount: number, +): void { + const propertyBudget = props.size - manifestNames.length; + if (propertyBudget < 0 || chunkCount !== propertyBudget) { + xerArchiveError('chunkcount past niet exact bij het werkelijk aanwezige propertybudget'); + } + let position = 0; + for (const actual of props.keys()) { + const expected = position < manifestNames.length + ? manifestNames[position]! + : `ByteChunk${String(position - manifestNames.length).padStart(6, '0')}`; + if (actual !== expected) xerArchiveError('properties zijn niet uniek en deterministisch geordend'); + position += 1; + } +} + +function nonNegativeSafeInteger(value: unknown, name: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) xerArchiveError(`${name} is geen niet-negatief safe integer`); + return value; +} + +function requiredString(props: Map, name: string): string { + const value = props.get(name); + if (typeof value !== 'string' || !value) xerArchiveError(`${name} ontbreekt of is geen tekenreeks`); + return value; +} + +function concatArchiveChunks(props: Map, prefix: string, count: number, expectedLength: number): Uint8Array { + const chunks: Uint8Array[] = []; + for (let index = 0; index < count; index++) { + const name = `${prefix}${String(index).padStart(6, '0')}`; + const raw = requiredString(props, name); + if (index < count - 1 && raw.includes('=')) xerArchiveError(`${name} bevat verboden base64-padding vóór de laatste chunk`); + let decoded: Uint8Array; + try { decoded = decodeXerBase64Chunk(raw); } catch { xerArchiveError(`${name} bevat ongeldige base64`); } + const expectedChunkLength = index === count - 1 ? expectedLength - index * XER_SOURCE_ARCHIVE_CHUNK_BYTES : XER_SOURCE_ARCHIVE_CHUNK_BYTES; + if (decoded.length !== expectedChunkLength) xerArchiveError(`${name} heeft ${decoded.length} i.p.v. ${expectedChunkLength} bytes`); + chunks.push(decoded); + } + for (const name of props.keys()) { + if (!name.startsWith(prefix)) continue; + if (name === `${prefix}Size` || name === `${prefix}Count`) continue; + const suffix = name.slice(prefix.length); + if (!/^\d{6}$/.test(suffix) || Number(suffix) >= count) xerArchiveError(`${name} ligt buiten de aaneengesloten chunkreeks`); + } + let output: Uint8Array; + try { output = new Uint8Array(expectedLength); } catch { xerArchiveError('byteLength kan op dit platform niet worden gealloceerd'); } + let offset = 0; + for (const chunk of chunks) { output.set(chunk, offset); offset += chunk.length; } + return output; +} + +/** Lees en valideer vóór allocatie de self-contained X9-container; afwezig blijft legacy-compatibel. + * Levert sinds T5 de volledige `XerSourceReconstruction`; de schema-1-tak draagt geen vastlegging + * (zie de bekende grens bij `XerArchiveReconstructor`) en geeft daar een lege map bij. */ +function extractXerSourceArchive( + entities: StepEntity[], + entityMap: Map, + reconstructXerArchive: XerArchiveReconstructor | undefined, +): XerSourceReconstruction | undefined { + const props = archiveProps(entities, entityMap, PSET.XerSourceArchive); + if (!props) return undefined; + const schemaVersion = nonNegativeSafeInteger(props.get('SchemaVersion'), 'SchemaVersion'); + if (schemaVersion === XER_SOURCE_ARCHIVE_COMPACT_STORAGE_SCHEMA_VERSION) { + return extractCompactXerSourceArchive(props, reconstructXerArchive); + } + if (schemaVersion !== XER_SOURCE_ARCHIVE_SCHEMA_VERSION) xerArchiveError(`onbekend SchemaVersion ${schemaVersion}`); + if (requiredString(props, 'Format') !== 'primavera-p6-xer') xerArchiveError('Format is niet primavera-p6-xer'); + const byteLength = nonNegativeSafeInteger(props.get('ByteLength'), 'ByteLength'); + const chunkSize = nonNegativeSafeInteger(props.get('ByteChunkSize'), 'ByteChunkSize'); + if (chunkSize !== XER_SOURCE_ARCHIVE_CHUNK_BYTES) xerArchiveError(`ByteChunkSize is niet ${XER_SOURCE_ARCHIVE_CHUNK_BYTES}`); + const chunkCount = nonNegativeSafeInteger(props.get('ByteChunkCount'), 'ByteChunkCount'); + if (chunkCount !== Math.ceil(byteLength / chunkSize)) xerArchiveError('ByteChunkCount past niet bij ByteLength'); + const diagnosticsLength = nonNegativeSafeInteger(props.get('DiagnosticsByteLength'), 'DiagnosticsByteLength'); + const diagnosticsCount = nonNegativeSafeInteger(props.get('DiagnosticsChunkCount'), 'DiagnosticsChunkCount'); + if (diagnosticsCount !== Math.ceil(diagnosticsLength / chunkSize)) xerArchiveError('DiagnosticsChunkCount past niet bij DiagnosticsByteLength'); + const manifestNames = [ + 'SchemaVersion', 'Format', 'ByteLength', 'Sha256', 'Encoding', 'Bom', 'Newline', + 'ByteChunkSize', 'ByteChunkCount', 'DiagnosticsByteLength', 'DiagnosticsSha256', 'DiagnosticsChunkCount', + ]; + validateArchivePropertyOrder(props, manifestNames, chunkCount, diagnosticsCount); + const sourceBytes = concatArchiveChunks(props, 'ByteChunk', chunkCount, byteLength); + const diagnosticBytes = concatArchiveChunks(props, 'DiagnosticsChunk', diagnosticsCount, diagnosticsLength); + const sourceHash = requiredString(props, 'Sha256'); + const diagnosticsHash = requiredString(props, 'DiagnosticsSha256'); + if (!/^[0-9a-f]{64}$/.test(sourceHash) || sha256Hex(sourceBytes) !== sourceHash) xerArchiveError('Sha256 is ongeldig of past niet bij de bytes'); + if (!/^[0-9a-f]{64}$/.test(diagnosticsHash) || sha256Hex(diagnosticBytes) !== diagnosticsHash) xerArchiveError('DiagnosticsSha256 is ongeldig of past niet bij de diagnostics'); + let archiveMetadata: XerArchiveMetadataPayloadV1; + try { + const parsed: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(diagnosticBytes)); + archiveMetadata = parseXerArchiveMetadataPayload(parsed); + } catch (error) { + if (error instanceof IfcParseError) throw error; + xerArchiveError(`diagnostics/readmodel is ongeldig: ${error instanceof Error ? error.message : 'geen geldige JSON'}`); + } + const encoding = requiredString(props, 'Encoding'); + const bom = requiredString(props, 'Bom'); + const newline = requiredString(props, 'Newline'); + if (!(['utf-8', 'utf-16le', 'utf-16be', 'windows-1252'] as readonly string[]).includes(encoding)) xerArchiveError('Encoding is onbekend'); + if (!(['none', 'utf-8', 'utf-16le', 'utf-16be'] as readonly string[]).includes(bom)) xerArchiveError('Bom is onbekend'); + if (!(['lf', 'crlf', 'cr', 'mixed', 'none'] as readonly string[]).includes(newline)) xerArchiveError('Newline is onbekend'); + try { + // Schema 1 draagt geen bak-4-vastlegging: het leesmodel bewaart de TASK-bronrijen wél, maar de + // omrekening ervan vraagt de XER-kalender-/getallaag, en dit pad loopt bewust ZONDER die chunk. + return { + archive: createXerSourceArchiveFromOwnedMetadata(sourceBytes, { + schemaVersion, + encoding: encoding as XerSourceArchiveEncoding, + bom: bom as XerSourceArchiveBom, + newline: newline as XerSourceArchiveNewline, + diagnostics: archiveMetadata.diagnostics, + readModel: archiveMetadata.readModel, + }), + recordedTimesByProject: {}, + }; + } catch (error) { + xerArchiveError(`diagnostics/readmodel kon niet worden opgebouwd: ${error instanceof Error ? error.message : String(error)}`); + } +} + +/** Schema 2 bevat alleen de bronbytes. Alle afleidbare X9-caches herleven uit die bron. */ +function extractCompactXerSourceArchive( + props: Map, + reconstructXerArchive: XerArchiveReconstructor | undefined, +): XerSourceReconstruction { + if (requiredString(props, 'Format') !== 'primavera-p6-xer') xerArchiveError('Format is niet primavera-p6-xer'); + if (requiredString(props, 'StorageFormat') !== XER_SOURCE_ARCHIVE_COMPACT_STORAGE_FORMAT) { + xerArchiveError('StorageFormat is onbekend'); + } + const byteLength = nonNegativeSafeInteger(props.get('ByteLength'), 'ByteLength'); + const chunkSize = nonNegativeSafeInteger(props.get('ByteChunkSize'), 'ByteChunkSize'); + if (chunkSize !== XER_SOURCE_ARCHIVE_CHUNK_BYTES) xerArchiveError(`ByteChunkSize is niet ${XER_SOURCE_ARCHIVE_CHUNK_BYTES}`); + const chunkCount = nonNegativeSafeInteger(props.get('ByteChunkCount'), 'ByteChunkCount'); + if (chunkCount !== Math.ceil(byteLength / chunkSize)) xerArchiveError('ByteChunkCount past niet bij ByteLength'); + const manifestNames = [ + 'SchemaVersion', 'Format', 'StorageFormat', 'ByteLength', 'Sha256', 'ByteChunkSize', 'ByteChunkCount', + ]; + validateCompactArchivePropertyOrder(props, manifestNames, chunkCount); + const sourceBytes = concatArchiveChunks(props, 'ByteChunk', chunkCount, byteLength); + const sourceHash = requiredString(props, 'Sha256'); + if (!/^[0-9a-f]{64}$/.test(sourceHash) || sha256Hex(sourceBytes) !== sourceHash) { + xerArchiveError('Sha256 is ongeldig of past niet bij de bytes'); + } + try { + if (!reconstructXerArchive) { + xerArchiveError( + 'compacte bron vereist readIFCWithXerReconstruction; de lage synchrone readIFC-ingang ' + + 'laadt de XER-reader bewust niet zelf', + ); + } + const reconstruction = reconstructXerArchive(sourceBytes); + const archive = reconstruction.archive; + if (archive.sha256 !== sourceHash || archive.byteLength !== byteLength) { + xerArchiveError('gereconstrueerd archief past niet bij de canonieke bronbytes'); + } + // De hashpoort hierboven geldt daarmee ook voor `recordedTimesByProject`: die map komt uit + // dezelfde `readXER` over dezelfde, geverifieerde bytes. + return reconstruction; + } catch (error) { + if (error instanceof IfcParseError) throw error; + xerArchiveError(`compacte bron kon niet worden gereconstrueerd: ${error instanceof Error ? error.message : String(error)}`); + } +} + // ── STEP-tekstscan: één quote-bewuste toestandsmachine voor álle lagen (bevinding K2) ─────────── // De parser was string-ONVEILIG in drie lagen, elk met een eigen quote-BLINDE truc: // 1. sectie-split `content.split('DATA;')[1]?.split('ENDSEC;')[0]` @@ -983,6 +1314,7 @@ function extractSequences( entities: StepEntity[], entityMap: Map, taskStepIdMap: Map, + p6BoundarySequenceGuids: ReadonlySet, ): Sequence[] { const seqEntities = entities.filter(e => e.type === 'IFCRELSEQUENCE'); const sequences: Sequence[] = []; @@ -1071,12 +1403,73 @@ function extractSequences( if (lagUnit) seq.lagUnit = lagUnit; if (lagPercent !== undefined) seq.lagPercent = lagPercent; if (lagMinutes !== undefined) seq.lagMinutes = lagMinutes; + if (p6BoundarySequenceGuids.has(stripQuotes(se.args[0] || ''))) { + seq.p6StartAtPredecessorFinishBoundary = true; + } sequences.push(seq); } return sequences; } +/** + * X12: lees de relationele P6-grensmetadata. De pset hangt schema-geldig op de IfcWorkSchedule; + * de payload bevat daarom IfcRelSequence-GlobalIds in plaats van vluchtige OPS-relatie-id's. + * Corrupt/ongeldig metadata blijft inert: alleen een volledige string-array activeert een vlag. + */ +function extractP6BoundarySequenceGuids( + entities: StepEntity[], + entityMap: Map, + liveTaskStepIds: ReadonlySet, +): Set { + // Alleen een niet-baseline schema dat de daadwerkelijk ingelezen live taken bestuurt/nest, + // is voor deze import het relevante IfcWorkSchedule. Een gelijknamige losse pset is geen bewijs. + const relevantScheduleIds = new Set(); + for (const entity of entities) { + if (entity.type === 'IFCRELNESTS') { + const scheduleId = parseRef(entity.args[4] || ''); + const schedule = scheduleId ? entityMap.get(scheduleId) : undefined; + if (schedule?.type === 'IFCWORKSCHEDULE' && !(schedule.args[14] || '').includes('BASELINE') + && parseRefs(entity.args[5] || '').some(id => liveTaskStepIds.has(id))) { + relevantScheduleIds.add(scheduleId!); + } + } else if (entity.type === 'IFCRELASSIGNSTOCONTROL') { + const scheduleId = parseRef(entity.args[6] || ''); + const schedule = scheduleId ? entityMap.get(scheduleId) : undefined; + if (schedule?.type === 'IFCWORKSCHEDULE' && !(schedule.args[14] || '').includes('BASELINE') + && parseRefs(entity.args[4] || '').some(id => liveTaskStepIds.has(id))) { + relevantScheduleIds.add(scheduleId!); + } + } + } + + const accepted: Set[] = []; + for (const rel of entities) { + if (rel.type !== 'IFCRELDEFINESBYPROPERTIES') continue; + const owners = parseRefs(rel.args[4] || ''); + if (owners.length !== 1 || !relevantScheduleIds.has(owners[0])) continue; + const entity = entityMap.get(parseRef(rel.args[5] || '') || ''); + if (!entity || entity.type !== 'IFCPROPERTYSET' + || stripQuotes(entity.args[2] || '') !== PSET.Sequences) continue; + for (const propRef of parseRefs(entity.args[4] || '')) { + const prop = entityMap.get(propRef); + if (!prop || prop.type !== 'IFCPROPERTYSINGLEVALUE') continue; + if (stripQuotes(prop.args[0] || '') !== 'P6StartAtPredecessorFinishBoundarySequenceGuids') continue; + const raw = parseTypedValue(prop.args[2] || ''); + if (typeof raw !== 'string') continue; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.every(value => typeof value === 'string' && value.length > 0)) { + accepted.push(new Set(parsed)); + } + } catch { /* corrupt relationeel bronmetadata blijft inert */ } + } + } + // Meer dan één geldige bron voor dezelfde semantiek is ambigu en faalt gesloten. Een orphan of + // pset op een ander schema telt niet mee en kan een latere geldige koppeling dus niet maskeren. + return accepted.length === 1 ? accepted[0] : new Set(); +} + /** Parse een getypeerd NominalValue zoals IFCTEXT('x'), IFCREAL(1.5), IFCBOOLEAN(.T.), * IFCDATE('2026-01-01'), IFCINTEGER(2), IFCMONETARYMEASURE(3.5). */ function parseTypedValue(s: string): CustomFieldValue | undefined { @@ -1643,11 +2036,16 @@ function extractCalendarHourMode( * property ⇒ `undefined` — de aanroeper valt dan terug op "alles in ExceptionTimes is een * feestdag", het conservatieve pre-T5-gedrag voor bestanden zonder deze markering. */ -function extractWorkingExceptionStepIds( +function extractCalendarExceptionMetadata( calStepId: string, entities: StepEntity[], entityMap: Map, -): Set | undefined { +): { + workingExceptionIds?: Set; + p6Source?: 'XER'; + p6NonWorkPenaltyDates?: string[]; + p6NonWorkPenaltyDatesState?: import('@/types/calendar').P6NonWorkPenaltyDatesState; +} { for (const rel of entities) { if (rel.type !== 'IFCRELDEFINESBYPROPERTIES') continue; const objectRefs = parseRefs(rel.args[4] || ''); @@ -1659,19 +2057,66 @@ function extractWorkingExceptionStepIds( .map(r => entityMap.get(r)) .filter((p): p is StepEntity => !!p && p.type === 'IFCPROPERTYSINGLEVALUE'); + const result: { + workingExceptionIds?: Set; + p6Source?: 'XER'; + p6NonWorkPenaltyDates?: string[]; + p6NonWorkPenaltyDatesState?: import('@/types/calendar').P6NonWorkPenaltyDatesState; + } = {}; + let p6SourceSeen = false; + let rejectedDiagnosticSeen = false; + let penaltyState: import('@/types/calendar').P6NonWorkPenaltyDatesState = 'ABSENT'; + let candidatePenaltyDates: string[] | undefined; for (const prop of props) { - if (stripQuotes(prop.args[0] || '') !== 'WorkingExceptionIds') continue; + const name = stripQuotes(prop.args[0] || ''); + if (name !== 'WorkingExceptionIds' && name !== 'P6Source' + && name !== 'P6NonWorkPenaltyDates' && name !== 'P6NonWorkPenaltyDatesState') continue; const value = parseTypedValue(prop.args[2] || ''); if (typeof value !== 'string' || !value) continue; + if (name === 'P6NonWorkPenaltyDatesState') { + if (value === 'REJECTED') rejectedDiagnosticSeen = true; + continue; + } + if (name === 'P6Source') { + if (value === 'XER') p6SourceSeen = true; + continue; + } try { const parsed = JSON.parse(value); - if (Array.isArray(parsed) && parsed.every(x => typeof x === 'string')) { - return new Set(parsed); + if (name === 'WorkingExceptionIds' + && Array.isArray(parsed) && parsed.every(x => typeof x === 'string')) { + result.workingExceptionIds = new Set(parsed); + } else if (name === 'P6NonWorkPenaltyDates' && Array.isArray(parsed)) { + const dates = parsed.filter((candidate): candidate is string => { + if (typeof candidate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(candidate)) return false; + const date = new Date(`${candidate}T00:00:00Z`); + return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === candidate; + }); + if (dates.length === parsed.length) { + candidatePenaltyDates = [...new Set(dates)]; + penaltyState = candidatePenaltyDates.length === 0 ? 'VALID_EMPTY' : 'VALID_VALUES'; + } else { + penaltyState = 'REJECTED'; + } + } else if (name === 'P6NonWorkPenaltyDates') { + penaltyState = 'REJECTED'; } - } catch { /* corrupte JSON: negeren — valt terug op "alles is feestdag" */ } + } catch { + if (name === 'P6NonWorkPenaltyDates') penaltyState = 'REJECTED'; + } } + if (!rejectedDiagnosticSeen && p6SourceSeen + && (penaltyState === 'VALID_EMPTY' || penaltyState === 'VALID_VALUES')) { + result.p6Source = 'XER'; + result.p6NonWorkPenaltyDates = candidatePenaltyDates ?? []; + result.p6NonWorkPenaltyDatesState = penaltyState; + } else if (rejectedDiagnosticSeen || p6SourceSeen) { + // All-or-nothing: ontbrekende of corrupte lijst mag de XER-stempel niet half actief laten. + result.p6NonWorkPenaltyDatesState = rejectedDiagnosticSeen ? 'REJECTED' : penaltyState; + } + return result; } - return undefined; + return {}; } /** Bouwt een `WorkCalendar` uit een `IFCWORKCALENDAR`-entiteit: naam/omschrijving/feestdagen @@ -1762,7 +2207,8 @@ function buildCalendarFromEntity( // pset-check als WERKDAG worden ingelezen — een regressie t.o.v. het conservatieve pre-T5-gedrag. // Geen markering (eigen bestand van vóór deze herziening, of extern) ⇒ alles in ExceptionTimes // is een feestdag, óók met een gevulde recurrence-ref. - const workingExceptionIds = extractWorkingExceptionStepIds(cal.id, entities, entityMap); + const calendarExceptionMetadata = extractCalendarExceptionMetadata(cal.id, entities, entityMap); + const workingExceptionIds = calendarExceptionMetadata.workingExceptionIds; const exceptionRefs = parseRefs(cal.args[6] || ''); const holidays: Holiday[] = []; const workingExceptions: WorkingException[] = []; @@ -1821,6 +2267,13 @@ function buildCalendarFromEntity( // een expliciete lege array — beide zijn overal `?? []`-equivalent, dus geen gedragsverschil. calendar.holidays = holidays; if (workingExceptions.length > 0) calendar.workingExceptions = workingExceptions; + if (calendarExceptionMetadata.p6Source === 'XER') { + calendar.p6Source = 'XER'; + calendar.p6NonWorkPenaltyDates = calendarExceptionMetadata.p6NonWorkPenaltyDates ?? []; + } + if (calendarExceptionMetadata.p6NonWorkPenaltyDatesState) { + calendar.p6NonWorkPenaltyDatesState = calendarExceptionMetadata.p6NonWorkPenaltyDatesState; + } // §4.3/§8.2 golden rule: createDefaultCalendar() zet altijd `generation` (nieuwe projecten zijn // per definitie gegenereerd) — een uit IFC gelezen kalender is dat NIET tenzij de OPS_Calendar- @@ -2330,6 +2783,8 @@ function extractTimephasedDurationWalksMeta( * Fase 2.9 (§3.4/§6) — scheduling-options teruglezen uit het autoritatieve `OPS_SchedulingOptions`- * JSON op de `IfcWorkSchedule` (spiegel van `writeSchedulingOptionsMeta`, exact het extractBaselines- * patroon). Afwezig/corrupt ⇒ `undefined` (default-inert; alle solver-defaults blijven staan). + * Het geparste object gaat door `sanitizeSchedulingOptions` (eindreview bevinding 4): onbekende + * sleutels en verkeerd getypeerde waarden vallen weg in plaats van ongefilterd de solver in te gaan. */ function extractSchedulingOptions( entities: StepEntity[], @@ -2344,10 +2799,7 @@ function extractSchedulingOptions( const raw = parseTypedValue(prop.args[2] || ''); if (typeof raw !== 'string' || !raw) continue; try { - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - return parsed as SchedulingOptions; - } + return sanitizeSchedulingOptions(JSON.parse(raw)); } catch { /* corrupte JSON — negeer, opties blijven op default */ } } } diff --git a/src/services/ifc/ifcWriter.ts b/src/services/ifc/ifcWriter.ts index efe0a35d7..f8c9728fb 100644 --- a/src/services/ifc/ifcWriter.ts +++ b/src/services/ifc/ifcWriter.ts @@ -16,7 +16,12 @@ import { IFC_TIME_ANCHOR, FIELD_MEASURE, RESOURCE_TYPE_TO_IFC, } from './ifcConstants'; import { PSET, PER_TASK_PSETS, ifcStr } from './ifcPsets'; +import { isSummaryTask } from '@/utils/taskHierarchy'; import { projectFileBase } from '@/utils/documents'; +import { + XER_SOURCE_ARCHIVE_CHUNK_BYTES, XER_SOURCE_ARCHIVE_COMPACT_STORAGE_FORMAT, + XER_SOURCE_ARCHIVE_COMPACT_STORAGE_SCHEMA_VERSION, type XerSourceArchive, +} from '@/services/xerSourceArchive'; import { IFC_TASK_SLOTS, IFC_TASKTIME_SLOTS, type TaskTimeWriteCtx, type TaskWriteCtx, } from './ifcTaskSlots'; @@ -139,6 +144,9 @@ export function writeIFC(input: WriteIFCInput): string { baselines = [], activeBaselineId = null, libraryPool = undefined, + xerSourceArchive = undefined, + xer = undefined, + xerSourceProjectId = undefined, } = input; const ctx: WriteContext = { lines: [], nextId: 1, idMap: new Map(), guids: new Map(), usedGuids: new Set() }; const now = new Date().toISOString().split('.')[0]; @@ -204,6 +212,7 @@ export function writeIFC(input: WriteIFCInput): string { // Project. Description (arg 3) draagt project.description (fase 3, H2) — de reader leest 'm terug // uit de IFCWORKPLAN.Description-slot, met terugval op deze. addLine(ctx, '_project', `IFCPROJECT(${ifcStr(guidOf(ctx, project.id))},#${ownerHistId},${ifcStr(project.name)},${ifcStr(project.description)},$,$,$,(#${ctxId}),#${unitAssId})`); + writeXerSourceArchive(ctx, ownerHistId, xerSourceArchive, xer?.sourceProjectId ?? xerSourceProjectId); // Calendar (projectkalender — altijd de EERSTE IFCWORKCALENDAR in het bestand; vaste conventie // die de reader aanhoudt om 'm van de bibliotheek-kalenders hieronder te onderscheiden, §8.2). @@ -269,6 +278,7 @@ export function writeIFC(input: WriteIFCInput): string { for (const seq of sequences) { writeSequence(ctx, seq, ownerHistId); } + writeSequenceMeta(ctx, workSchedId, sequences, ownerHistId); // Resources for (const res of resources) { @@ -326,6 +336,35 @@ export function writeIFC(input: WriteIFCInput): string { return header + ctx.lines.join('\n') + footer; } +/** X9 — één self-contained Pset met manifest én deterministisch geordende bytes. */ +function writeXerSourceArchive( + ctx: WriteContext, ownerHistId: number, archive: XerSourceArchive | undefined, sourceProjectId: string | undefined, +): void { + if (!archive) return; + if (!sourceProjectId) throw new Error('XER-bronarchief kan niet zonder OPS_XerDocument-selector worden opgeslagen.'); + if (!archive.diagnostics.documentViews[sourceProjectId]) { + throw new Error('XER-bronarchief kan niet zonder geldige OPS_XerDocument-selector worden opgeslagen.'); + } + const props: number[] = []; + const property = (name: string, value: string) => props.push(addLine(ctx, `xerarchive_prop_${name}`, `IFCPROPERTYSINGLEVALUE(${ifcStr(name)},$,${value},$)`)); + property('SchemaVersion', `IFCINTEGER(${XER_SOURCE_ARCHIVE_COMPACT_STORAGE_SCHEMA_VERSION})`); + property('Format', `IFCLABEL(${ifcStr(archive.format)})`); + property('StorageFormat', `IFCLABEL(${ifcStr(XER_SOURCE_ARCHIVE_COMPACT_STORAGE_FORMAT)})`); + property('ByteLength', `IFCINTEGER(${archive.byteLength})`); + property('Sha256', `IFCTEXT(${ifcStr(archive.sha256)})`); + property('ByteChunkSize', `IFCINTEGER(${XER_SOURCE_ARCHIVE_CHUNK_BYTES})`); + property('ByteChunkCount', `IFCINTEGER(${archive.byteChunks.length})`); + archive.byteChunks.forEach((chunk, index) => property(`ByteChunk${String(index).padStart(6, '0')}`, `IFCTEXT(${ifcStr(chunk)})`)); + const setId = addLine(ctx, 'pset_xerarchive', `IFCPROPERTYSET(${ifcStr(guidOf(ctx, 'pset_xerarchive'))},#${ownerHistId},${ifcStr(PSET.XerSourceArchive)},$,(${props.map(id => `#${id}`).join(',')}))`); + addLine(ctx, 'rel_xerarchive', `IFCRELDEFINESBYPROPERTIES(${ifcStr(guidOf(ctx, 'rel_xerarchive'))},#${ownerHistId},$,$,(${ref(ctx, '_project')}),#${setId})`); + const selectorProps: number[] = []; + const selector = (name: string, value: string) => selectorProps.push(addLine(ctx, `xerdoc_prop_${name}`, `IFCPROPERTYSINGLEVALUE(${ifcStr(name)},$,${value},$)`)); + selector('ArchiveSha256', `IFCTEXT(${ifcStr(archive.sha256)})`); + selector('SourceProjectId', `IFCTEXT(${ifcStr(sourceProjectId)})`); + const selectorSet = addLine(ctx, 'pset_xerdocument', `IFCPROPERTYSET(${ifcStr(guidOf(ctx, 'pset_xerdocument'))},#${ownerHistId},${ifcStr(PSET.XerDocument)},$,(${selectorProps.map(id => `#${id}`).join(',')}))`); + addLine(ctx, 'rel_xerdocument', `IFCRELDEFINESBYPROPERTIES(${ifcStr(guidOf(ctx, 'rel_xerdocument'))},#${ownerHistId},$,$,(${ref(ctx, '_project')}),#${selectorSet})`); +} + /** Eigen taaktypen blijven IFC-geldig: de taak zelf is `.USERDEFINED.` met een ObjectType-label; * deze project-pset bewaart alleen de stabiele OPS-id en projectkopie. */ function writeTaskTypeMeta( @@ -816,12 +855,15 @@ function writeCalendarGenerationMeta( const derivedHoursPerDay = cal.workEndHour - cal.workStartHour; const needsHoursPerDayOverride = !cal.workTime && cal.hoursPerDay !== derivedHoursPerDay; const hasWorkingExceptions = workingExceptionStepIds.length > 0; + const hasP6Source = cal.p6Source === 'XER'; + const hasRejectedPenaltyDiagnostic = cal.p6NonWorkPenaltyDatesState === 'REJECTED'; const hasSimpleBreak = !cal.workTime && (cal.simpleBreakStartMinute !== undefined || cal.simpleBreakDurationMinutes !== undefined); // Een enkele 08:00–16:00-band is aan de IFC-kant niet te onderscheiden van een dagkalender met // dezelfde scalar-uren. De OPS-markering bewaart daarom de kalenderidentiteit ook zonder urentaak. const isHourCalendar = cal.workTime !== undefined; - if (!gen && !cal.libraryOrigin && !needsHoursPerDayOverride && !hasWorkingExceptions && !hasSimpleBreak && !isHourCalendar) return; + if (!gen && !cal.libraryOrigin && !needsHoursPerDayOverride && !hasWorkingExceptions + && !hasSimpleBreak && !isHourCalendar && !hasP6Source && !hasRejectedPenaltyDiagnostic) return; const props: number[] = []; if (gen) { props.push(addLine(ctx, `_opscal_ruleset_${cal.id}`, @@ -868,6 +910,18 @@ function writeCalendarGenerationMeta( props.push(addLine(ctx, `_opscal_wexc_${cal.id}`, `IFCPROPERTYSINGLEVALUE('WorkingExceptionIds',$,IFCTEXT(${ifcStr(idJson)}),$)`)); } + if (hasP6Source) { + props.push(addLine(ctx, `_opscal_p6source_${cal.id}`, + `IFCPROPERTYSINGLEVALUE('P6Source',$,IFCLABEL('XER'),$)`)); + } + if (hasP6Source) { + props.push(addLine(ctx, `_opscal_p6penalty_${cal.id}`, + `IFCPROPERTYSINGLEVALUE('P6NonWorkPenaltyDates',$,IFCTEXT(${ifcStr(JSON.stringify(cal.p6NonWorkPenaltyDates ?? []))}),$)`)); + } + if (hasRejectedPenaltyDiagnostic) { + props.push(addLine(ctx, `_opscal_p6penaltystate_${cal.id}`, + `IFCPROPERTYSINGLEVALUE('P6NonWorkPenaltyDatesState',$,IFCLABEL('REJECTED'),$)`)); + } const setId = addLine(ctx, `_pset_opscal_${cal.id}`, `IFCPROPERTYSET(${ifcStr(guidOf(ctx, 'pset_opscal_' + cal.id))},#${ownerHistId},${ifcStr(PSET.Calendar)},$,(${props.map(i => `#${i}`).join(',')}))`); addLine(ctx, `_rel_opscal_${cal.id}`, @@ -972,7 +1026,7 @@ function writeTask( function writeWBSNesting(ctx: WriteContext, tasks: Task[], ownerHistId: number): void { for (const task of tasks) { - if (task.childIds.length === 0) continue; + if (!isSummaryTask(task)) continue; const childRefs = task.childIds .map(cid => ref(ctx, `task_${cid}`)) .filter(r => r !== '#0') @@ -1019,6 +1073,30 @@ function writeSequence(ctx: WriteContext, seq: Sequence, ownerHistId: number): v `IFCRELSEQUENCE(${ifcStr(guidOf(ctx, seq.id))},#${ownerHistId},$,$,${ref(ctx, `task_${seq.predecessorId}`)},${ref(ctx, `task_${seq.successorId}`)},${lagRef},.${seq.type}.,$)`); } +/** + * X12: relatie-eigen P6/XER-ankerdata. IFC 4.3 staat geen `IfcPropertySet` rechtstreeks op een + * `IfcRelSequence` toe (die is geen IfcObjectDefinition). Daarom is dit een geldige pset op de + * IfcWorkSchedule, met de werkelijk geschreven IFC-GlobalId van iedere gemarkeerde relatie als + * sleutel. Dat bewaart de semantiek relationeel én blijft interoperabel STEP. + */ +function writeSequenceMeta( + ctx: WriteContext, + workSchedId: number, + sequences: readonly Sequence[], + ownerHistId: number, +): void { + const boundarySequenceGuids = sequences + .filter(sequence => sequence.p6StartAtPredecessorFinishBoundary === true) + .map(sequence => guidOf(ctx, sequence.id)); + if (boundarySequenceGuids.length === 0) return; + const propId = addLine(ctx, '_ps_seq_boundary', + `IFCPROPERTYSINGLEVALUE('P6StartAtPredecessorFinishBoundarySequenceGuids',$,IFCTEXT(${ifcStr(JSON.stringify(boundarySequenceGuids))}),$)`); + const setId = addLine(ctx, '_pset_sequences', + `IFCPROPERTYSET(${ifcStr(guidOf(ctx, 'pset_sequences'))},#${ownerHistId},${ifcStr(PSET.Sequences)},$,(#${propId}))`); + addLine(ctx, '_rel_sequences', + `IFCRELDEFINESBYPROPERTIES(${ifcStr(guidOf(ctx, 'rel_sequences'))},#${ownerHistId},$,$,(#${workSchedId}),#${setId})`); +} + function writeResource(ctx: WriteContext, res: Resource, ownerHistId: number): void { // Entiteitnaam uit de gedeelde RESOURCE_TYPE_TO_IFC-map (reader leidt de inverse eruit af). // Fallback op MATERIAL is byte-identiek aan de vroegere switch-`default`. diff --git a/src/services/ifc/schedulingOptionsRead.ts b/src/services/ifc/schedulingOptionsRead.ts new file mode 100644 index 000000000..3efaced87 --- /dev/null +++ b/src/services/ifc/schedulingOptionsRead.ts @@ -0,0 +1,86 @@ +import type { SchedulingOptions } from '@/types/project'; + +/** + * Eindreview XER-etappe, bevinding 4 — het `OPS_SchedulingOptions`-JSON uit een IFC is BUITEN- + * invoer: een willekeurig IFC-bestand kon vóór deze poort `p6Source: 'XER'`, alle p6-vlaggen en een + * onzin-`criticalDefinition` (niet-bestaande `mode`, een string als `thresholdHours`) ongefilterd + * in `project.schedulingOptions` zetten (`JSON.parse` + cast, probe gedraaid). Deze functie is de + * saaie whitelist-/typevalidator: bekende sleutels, bekende enum-waarden, eindige getallen voor de + * getallen, booleans voor de vlaggen. Alles wat daar niet aan voldoet wordt WEGGELATEN (het veld + * blijft op zijn solver-default), nooit "gerepareerd" — een half geldig object mag geen ander + * gedrag activeren dan het bestand letterlijk beschrijft. + * + * Wat deze poort bewust NIET doet: `p6Source: 'XER'` weigeren. Een uit XER geïmporteerd project + * round-tript zijn P6-opties via ditzelfde pset (`ifcWriter.ts`, `writeSchedulingOptionsMeta`), dus + * de IFC-lezer is — naast de XER-lezer — een legitieme schrijver van `p6Source`. De + * `CPMSolver`-hardening op de sequences (X12, `p6StartAtPredecessorFinishBoundary` wordt gestript + * zonder `p6Source`) beschermt tegen een LOSSE relatievlag zonder projectniveau-bron; ze beschermt + * niet tegen een vervalst projectbestand, en beweert dat sinds deze bijstelling ook niet meer. + * + * Leesmigratie-vrij: sleutels die de app niet (meer) kent verdwijnen stil; dat is hetzelfde + * gedrag als voorheen voor onbekende psets. Headless getest in `check-ifc-roundtrip.ts`. + */ +const BOOLEAN_KEYS = [ + 'makeOpenEndedCritical', 'useExpectedFinishDates', 'preserveActualDatesInBackwardPass', + 'clampNegativeFreeFloat', 'p6ZeroDurationUsesPlannedBoundary', 'p6UseTaskPlannedStartFloor', + 'p6FinishMilestoneBoundaryWindow', 'p6PreserveActualInstants', 'p6UseRemainingStartForProgress', + 'p6PreserveZeroDurationConstraintInstants', 'useProjectEndDateForFloat', 'resumeFromActualElapsed', + 'unstartedIgnoresStatusDate', 'p6CompletedLateFromRemainingWindow', +] as const satisfies ReadonlyArray; + +const LAG_CALENDARS = ['predecessor', 'successor', '24hour', 'projectDefault'] as const; +const TOTAL_FLOAT_MODES = ['start', 'finish', 'smallest'] as const; +const CRITICAL_MODES = ['totalFloat', 'longestPath'] as const; +const FLOAT_PATH_METHODS = ['FREE_FLOAT', 'TOTAL_FLOAT'] as const; + +const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); +const oneOf = (value: unknown, allowed: readonly T[]): value is T => + typeof value === 'string' && (allowed as readonly string[]).includes(value); +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** Compile-time: elke sleutel van `SchedulingOptions` moet hieronder een tak hebben. */ +type HandledKeys = + | (typeof BOOLEAN_KEYS)[number] + | 'p6Source' | 'lagCalendar' | 'criticalDefinition' | 'totalFloatMode' + | 'nearCriticalThreshold' | 'floatPaths'; +type MissingKeys = Exclude; +const _allKeysHandled: MissingKeys extends never ? true : MissingKeys = true; +void _allKeysHandled; + +export function sanitizeSchedulingOptions(input: unknown): SchedulingOptions | undefined { + if (!isRecord(input)) return undefined; + const out: SchedulingOptions = {}; + // Sleutelvolgorde van de INVOER behouden: de round-trip-checks vergelijken via JSON.stringify, + // en een geschreven blok moet na lezen byte-identiek terugkomen — de poort filtert, herordent niet. + for (const key of Object.keys(input)) { + const value = input[key]; + switch (key) { + case 'p6Source': if (value === 'XER') out.p6Source = 'XER'; break; + case 'lagCalendar': if (oneOf(value, LAG_CALENDARS)) out.lagCalendar = value; break; + case 'totalFloatMode': if (oneOf(value, TOTAL_FLOAT_MODES)) out.totalFloatMode = value; break; + case 'nearCriticalThreshold': if (isFiniteNumber(value)) out.nearCriticalThreshold = value; break; + case 'criticalDefinition': + if (isRecord(value) && oneOf(value.mode, CRITICAL_MODES)) { + out.criticalDefinition = { + mode: value.mode, + ...(isFiniteNumber(value.threshold) ? { threshold: value.threshold } : {}), + ...(isFiniteNumber(value.thresholdHours) ? { thresholdHours: value.thresholdHours } : {}), + }; + } + break; + case 'floatPaths': + if (isRecord(value) && typeof value.enabled === 'boolean' + && oneOf(value.method, FLOAT_PATH_METHODS) && isFiniteNumber(value.maxPaths)) { + out.floatPaths = { enabled: value.enabled, method: value.method, maxPaths: value.maxPaths }; + } + break; + default: + if ((BOOLEAN_KEYS as readonly string[]).includes(key) && typeof value === 'boolean') { + out[key as (typeof BOOLEAN_KEYS)[number]] = value; + } + // Onbekende sleutel: weglaten. + } + } + return Object.keys(out).length > 0 ? out : undefined; +} diff --git a/src/services/importTypes.ts b/src/services/importTypes.ts index 17c051f0b..8fcfd2ffe 100644 --- a/src/services/importTypes.ts +++ b/src/services/importTypes.ts @@ -8,6 +8,170 @@ import type { CustomTaskType } from '@/types/taskType'; import type { Baseline } from '@/types/baseline'; import type { CompanyPool } from '@/types/library'; import type { RecordedFieldKey } from '@/services/ifc/ifcTaskSlots'; +import type { RecordedTime } from '@/engine/scheduler/recordedDates'; +import type { XerResourceCatalog } from './xer/xerResources'; +import type { XerResourceIssue, XerTaskResourceSource } from './xer/xerResourceTypes'; +import type { XerMetadataCatalog } from './xer/xerMetadataTypes'; +import type { XerSourceArchive } from './xerSourceArchive'; + +export type XerSourceEncoding = 'utf-8' | 'utf-16le' | 'utf-16be' | 'windows-1252'; + +export interface XerTableReportMetadata { + encoding: XerSourceEncoding; + endMarkerSeen: boolean; + issues: Array<{ + code: string; + line: number; + table?: string; + expected?: number; + actual?: number; + field?: string; + currencyCode?: string; + ignoredRecords?: number; + ignoredLines?: number; + }>; + unknownTables: Array<{ name: string; rows: number }>; + /** Additief sinds XER-exportverlies fixronde 2; afwezig in oudere ingebedde bronarchieven. */ + unknownFields?: Array<{ table: string; name: string; rows: number }>; +} + +export interface XerCalendarIssueMetadata { + code: string; + calendarId: string; + line: number; + reason: string; + resolution: 'RECOVERED' | 'REJECTED' | 'UNLINKED'; +} + +export interface XerEnumFallback { + family: 'activityType' | 'durationType' | 'completePctType' | 'status' | 'priority' | 'constraint' | 'relation'; + token: string; + fallback: string; + table: 'PROJECT' | 'TASK' | 'TASKPRED'; + field: string; + line: number; +} + +export interface XerScheduleOptionFallback { + field: string; + token: string; + fallback: string; + line: number; +} + +export interface XerScheduleOptionsSourceRow { + table: 'PROJECT' | 'SCHEDOPTIONS'; + line: number; + cells: Record; +} + +export interface XerScheduleOptionsDiagnostic { + code: 'XER_DUPLICATE_SCHEDOPTIONS_PROJ_ID'; + projectId: string; + /** Indexen in `XerScheduleOptionsSourceArchive.rows`; zo blijven de raw rijen één bronkopie. */ + rowIndexes: number[]; + lines: number[]; +} + +/** + * Bestandsbreed XER-bronarchief voor X5 en de geplande X9-native opslag. PROJECT- en + * SCHEDOPTIONS-rijen worden precies eenmaal gekopieerd. Projectmetadata verwijst met indexen naar + * deze ene bron; verweesde SCHEDOPTIONS-rijen blijven daardoor zichtbaar zonder aan een verkeerd + * project te worden toegeschreven. + */ +export interface XerScheduleOptionsSourceArchive { + rows: XerScheduleOptionsSourceRow[]; + unmatchedScheduleOptionsRowIndexes: number[]; + diagnostics: XerScheduleOptionsDiagnostic[]; +} + +/** Neutraal documentcontract voor X5-bronbewijs. Dit staat bewust buiten de lazy XER-chunk: + * algemene document-/recoverycode mag het type kennen zonder de reader statisch te laden. */ +export interface XerScheduleOptionsMetadata { + source: 'schedoptions' | 'xer-defaults'; + retainedSource: { + sched_use_project_end_date_for_float?: boolean; + }; + fallbacks: XerScheduleOptionFallback[]; + diagnostics: XerScheduleOptionsDiagnostic[]; + sourceArchive: XerScheduleOptionsSourceArchive; + /** Projectgebonden view-indexen in het bestandsbrede archief; bevat bij duplicaten alle rijen. */ + sourceRowIndexes: number[]; + /** Compatibele projectview; de rijobjecten zijn referenties naar `sourceArchive.rows`. */ + sourceRows: XerScheduleOptionsSourceRow[]; +} + +export interface XerExternalRelation { + id: string; + localProjectId: string; + localTaskId: string; + externalProjectId: string; + externalTaskId: string; + direction: 'predecessor' | 'successor'; + type: 'FS' | 'SS' | 'FF' | 'SF'; + lagMinutes: number; +} + +export type XerBaselineFallbackReason = + | 'self-reference' + | 'cycle' + | 'all-projects-baselines'; + +/** Eén gededupliceerde relatie tussen twee werkelijk geopende XER-projectdocumenten. */ +export interface XerDocumentExternalLink { + id: string; + predecessor: { projectId: string; taskId: string }; + successor: { projectId: string; taskId: string }; + type: 'FS' | 'SS' | 'FF' | 'SF'; + lagMinutes: number; +} + +/** Uniform XER-openingsverslag; aanwezig bij zowel één als meerdere PROJECT-rijen. */ +export interface XerImportReport { + projectsSeen: number; + documentsOpened: number; + emptyProjectsSkipped: number; + baselineProjectsExcluded: number; + baselinesMaterialized: number; + danglingBaselineReferences: number; + externalLinksPreserved: number; + baselineExclusionReverted: boolean; + baselineFallbackReasons: XerBaselineFallbackReason[]; +} + +/** X6-bronbewijs. De catalogus is één bestandsbreed, immutable object; per document blijft alleen + * de gefilterde TASKRSRC-view over. X9 bepaalt later de exacte serialisatie naar IFC/recovery. */ +export interface XerResourceMetadata { + catalog: XerResourceCatalog; + assignments: XerTaskResourceSource[]; + issues: XerResourceIssue[]; +} + +/** X8-bronbewijs: één readonly catalogus per geopend XER-bestand; projectvelden zijn losse views. */ +export interface XerMetadataMetadata { + catalog: XerMetadataCatalog; +} + +/** Documentgebonden XER-brondata. Externe relaties zijn nadrukkelijk geen solverrelaties. */ +export interface XerImportMetadata { + /** PROJECT.proj_id dat dit document binnen het gedeelde XER-bronarchief vertegenwoordigt. */ + sourceProjectId?: string; + defaultCurrencyCode: string; + tableReport: XerTableReportMetadata; + calendarIssues: XerCalendarIssueMetadata[]; + enumFallbacks: XerEnumFallback[]; + /** X5: afleidingsbron, terugvallen en retained/TODO-waarden van precies dit project. */ + scheduleOptions: XerScheduleOptionsMetadata; + externalRelations: XerExternalRelation[]; + /** Canonieke cross-documentlinks waarbij dit document een eindpunt is; nooit solverinvoer. */ + externalLinks: XerDocumentExternalLink[]; + /** Bestandsbreed verslag, bewust ook documentgebonden zodat X10 het na openen kan consumeren. */ + report: XerImportReport; + /** X6 retained-data; baseline- en unscoped TASKRSRC-rijen blijven uitsluitend catalogusdata voor X9. */ + resources?: XerResourceMetadata; + /** X8 retained-data; X9 bepaalt de definitieve documentcontract-/IFC-serialisatie. */ + metadata?: XerMetadataMetadata; +} /** * Eén gedeelde payload-vorm voor een ingelezen project (audit P1). De vier readers (`readIFC`, @@ -102,4 +266,59 @@ export interface ImportResult { * onderscheiden. Een taak-id ZONDER IfcTaskTime krijgt een lege array (niet: ontbrekende sleutel) * — "geen enkel slot gevuld" is een uitspraak, "onbekend" niet. */ recordedFields?: Record; + /** BAK 4 (XER-etappeplan §4.1, bijgesteld 2026-09-04 — X-O7 laag 3) — uitsluitend + * weergave/meetlat, NOOIT solverinvoer. Per taak-id de rekenuitvoer die de BRON zelf opsloeg: + * voor XER de zes kolommen `early_start_date`/`early_end_date`/`late_start_date`/ + * `late_end_date`/`total_float_hr_cnt`/`free_float_hr_cnt` (`xerRecordedTimes.ts`), omgerekend + * naar dezelfde `RecordedTime`-vorm als de IFC-route (`src/engine/scheduler/recordedDates.ts`) + * gebruikt — dat type wordt hier HERGEBRUIKT, niet gedupliceerd. Alleen `readXER` vult dit + * vooralsnog (en straks de archief-reconstructie in `readIFC`, taak T5); nooit gelezen door + * `solveProject`, nooit geschreven naar `Task.time` door een lezer. `captureRecordedDates` + * gebruikt dit kanaal — indien aanwezig — MET VOORRANG boven `recordedFields` hierboven; de + * twee kanalen worden nooit gemengd (een XER-import heeft geen `recordedFields`, een + * IFC-import geen `recordedTimes`). */ + recordedTimes?: Record; + /** Herkomst van `recordedTimes` — stuurt het standaard-aan-beleid voor "datums zoals opgeslagen" + * (O6-patroon: alleen de XER-route zet dit; andere formaten blijven byte-identiek doordat dit + * veld afwezig blijft). + * - `'xer'`: verse XER-import (`readXER`). Standaard AAN zodra er restverschillen zijn. + * - `'xer-archive'`: heropende IFC met XER-bronarchief (`readIFC`'s T5-archiefreconstructie). + * Alleen AANBOD, nooit automatisch AAN — een intussen bewerkte en opgeslagen planning mag bij + * heropenen niet stilzwijgend P6's oude datums tonen (orkestratorbesluit, XER-etappe laag 3, + * heropen-beleid 2026-09-05). `applyRecordedDatesOnLoad` (`src/state/documentActivation.ts`) + * is de enige plek die op dit onderscheid let. */ + recordedTimesOrigin?: 'xer' | 'xer-archive'; + /** Alleen XER: bronmetadata en solverloze cross-projectrelaties voor het geladen document. */ + xer?: XerImportMetadata; + /** Alleen XER: exact, gedeeld en immutable bronarchief; nooit solverinvoer. */ + xerSourceArchive?: XerSourceArchive; + /** Selector uit OPS_XerDocument; bronproject binnen een self-contained IFC. */ + xerSourceProjectId?: string; +} + +/** + * Eén bronbestand kan uitzonderlijk meerdere zelfstandige projectdocumenten opleveren. De + * individuele payloads blijven het bestaande `ImportResult`-contract volgen; alleen de openroute + * krijgt hier de extra informatie welke tab na het openen actief hoort te zijn. Zo blijven alle + * enkelvoudige readers en hun bestaande laadpaden structureel ongewijzigd. + */ +export interface MultiDocumentImport { + kind: 'multi-document'; + results: ImportResult[]; + activeDocumentIndex: number; +} + +/** Het resultaat van een reader op de centrale open-pijplijn. */ +export type OpenedImport = ImportResult | MultiDocumentImport; + +export function isMultiDocumentImport(value: OpenedImport): value is MultiDocumentImport { + return 'kind' in value && value.kind === 'multi-document'; +} + +/** De primaire payload voor read-only consumenten die per ontwerp slechts één project kennen. */ +export function activeImportResult(value: OpenedImport): ImportResult { + if (!isMultiDocumentImport(value)) return value; + const active = value.results[value.activeDocumentIndex]; + if (!active) throw new Error('Meervoudige import bevat geen actief document'); + return active; } diff --git a/src/services/library/libraryIfc.ts b/src/services/library/libraryIfc.ts index c1e8f47c0..9e511891a 100644 --- a/src/services/library/libraryIfc.ts +++ b/src/services/library/libraryIfc.ts @@ -4,7 +4,7 @@ * (verliesloos, incl. ids/versie). Delegeert aan de bestaande writeIFC/readIFC. */ import { writeIFC } from '@/services/ifc/ifcWriter'; -import { readIFC } from '@/services/ifc/ifcReader'; +import { readIFCWithXerReconstruction } from '@/services/formatRegistry'; import { createDefaultProject } from '@/state/slices/projectSlice'; import { createDefaultCalendar } from '@/engine/calendar/defaultCalendar'; import { normalizePoolShape } from './libraryOps'; @@ -47,10 +47,10 @@ export function writePoolIFC(pool: CompanyPool): string { * het GEKOZEN doelbedrijf zodra de import bevestigd wordt; dit leespunt hoeft alleen een crash-vrije * SHAPE te garanderen voor de preview. */ -export function readPoolIFC(content: string): CompanyPool { +export async function readPoolIFC(content: string): Promise { // Geen `labels`: dienstlaag zonder `t(...)` — pool-bestanden schrijft OPS zelf en hebben altijd een IFCPROJECT. `readIFC` valt dan terug op de Engelse // default voor een bestand zonder IFCPROJECT (zie ImportLabels). - const result = readIFC(content); + const result = await readIFCWithXerReconstruction(content); if (!result.libraryPool) { throw new Error('Dit IFC-bestand bevat geen bedrijfsbibliotheek (OPS_Library).'); } diff --git a/src/services/mcp/toolRegistry.ts b/src/services/mcp/toolRegistry.ts index 9e742a1e9..f6f4f6a45 100644 --- a/src/services/mcp/toolRegistry.ts +++ b/src/services/mcp/toolRegistry.ts @@ -23,6 +23,7 @@ import { baselineTools } from './tools/baselineTools'; import { documentTools } from './tools/documentTools'; import { fileTools } from './tools/fileTools'; import { batchTools } from './tools/batchTool'; +import { xerProvenanceTools } from './tools/xerProvenanceTools'; export { TOOL_PREFIX, registerToolModules, getTool, getTools } from './toolIndex'; @@ -30,6 +31,7 @@ export { TOOL_PREFIX, registerToolModules, getTool, getTools } from './toolIndex // Batch staat bewust achteraan: hij dispatcht de andere tools, dus lezen we hem als sluitstuk. const MODULES: McpToolDef[][] = [ readTools, + xerProvenanceTools, taskTools, // Direct ná taskTools: samen vormen add_/update_/remove_dependencies de volledige levensloop van // een relatie, en zo staan ze ook naast elkaar in `tools/list` (waar de AI ze leest). diff --git a/src/services/mcp/tools/calendarResourceTools.ts b/src/services/mcp/tools/calendarResourceTools.ts index fe4eb7bb0..51ac736f0 100644 --- a/src/services/mcp/tools/calendarResourceTools.ts +++ b/src/services/mcp/tools/calendarResourceTools.ts @@ -40,6 +40,7 @@ import type { CalendarGeneration, Holiday, WorkCalendar, WorkTimeBands } from '@ import type { ResourceCurve } from '@/types/resource'; import type { Project } from '@/types/project'; import type { LevelingOptions, LevelingResult } from '@/engine/scheduler/ResourceLeveler'; +import { isSummaryTask } from '@/utils/taskHierarchy'; const STD_ANNOT = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }; @@ -1096,7 +1097,7 @@ function classifyAssignments( rejections.push({ id: act.assignmentId, reason: `doeltaak '${act.taskId}' bestaat niet` }); return; } - if (target.isMilestone || target.childIds.length > 0) { + if (target.isMilestone || isSummaryTask(target)) { rejections.push({ id: act.assignmentId, reason: `doeltaak '${act.taskId}' is een mijlpaal/verzameltaak; die dragen geen resources` }); return; } diff --git a/src/services/mcp/tools/fileTools.ts b/src/services/mcp/tools/fileTools.ts index 53185b101..279dc47fe 100644 --- a/src/services/mcp/tools/fileTools.ts +++ b/src/services/mcp/tools/fileTools.ts @@ -31,11 +31,10 @@ import { isTauri } from '@/utils/platform'; import { writeIFC } from '@/services/ifc/ifcWriter'; -import { isActivePristine } from '@/state/slices/fileSlice'; import { parseOpenedFile, readFormatForFile, readFormatInput, type FormatInput } from '@/services/formatRegistry'; import { buildWriteIFCInput } from '@/state/ifcSaveInput'; import { extensionOf } from '@/utils/filePath'; -import type { ImportResult } from '@/services/importTypes'; +import type { OpenedImport } from '@/services/importTypes'; import { bindExpectedDoc, buildEnvelope, guardNonTransactional, toolError } from './runtime'; import { guardBridgeFlags } from './documentTools'; import type { McpToolAnnotations, McpToolDef, McpToolResult } from '../contracts'; @@ -155,10 +154,11 @@ export function checkScope(home: string, input: string): ScopeCheck { /** Leesbaar formaatlabel op basis van de extensie (de XML-variant wordt op inhoud gesnifft). * `MPP14` (T8): de enige binaire indeling die dit pad kent — `.mpp` (MS Project 2010-2021, * alleen-lezen native lezer, zie `src/services/mpp/`). */ -function formatOf(path: string, content: string): 'IFC' | 'CSV' | 'P6-XML' | 'MSPDI-XML' | 'MPP14' { +function formatOf(path: string, content: string): 'IFC' | 'CSV' | 'P6-XML' | 'MSPDI-XML' | 'MPP14' | 'XER' { const ext = extensionOf(path); if (ext === 'csv') return 'CSV'; if (ext === 'mpp') return 'MPP14'; + if (ext === 'xer') return 'XER'; if (ext === 'xml') { return content.includes('APIBusinessObjects') || content.includes('Primavera') ? 'P6-XML' : 'MSPDI-XML'; } @@ -282,7 +282,8 @@ export const fileTools: McpToolDef[] = [ description: 'Lees een planningsbestand van schijf en open het als DOCUMENT (tabblad). Ondersteund: .ifc ' + '(native, volledig), .xml (Primavera P6 of MS Project MSPDI — het formaat wordt op inhoud ' + - 'herkend), .csv en .mpp (MS Project 2010-2021, MPP14, alleen-lezen — oudere formaten en ' + + 'herkend), .csv, .xer (Primavera P6, alleen-lezen) en .mpp (MS Project 2010-2021, MPP14, ' + + 'alleen-lezen — oudere formaten en ' + 'wachtwoordbestanden geven een fout die vraagt om eerst als XML te exporteren). Er wordt NIET ' + 'samengevoegd met het huidige plan: een leeg-en-ongewijzigd ' + 'actief tabblad wordt hergebruikt, anders komt er een nieuw tabblad bij; het resultaat wordt ' + @@ -293,7 +294,8 @@ export const fileTools: McpToolDef[] = [ 'fields; MSPDI is het rijkst na IFC. ' + 'Na een CSV-/XML-/MPP-import heeft het document nog GEEN opslagdoel (opslaan schrijft altijd ' + 'IFC, dus het bronbestand wordt nooit overschreven); alleen een IFC-import neemt het bronpad over. ' + - 'Gebruik altijd het `documentId` UIT DE RESPONS voor vervolgstappen — of het bestand in het ' + + 'Gebruik de `documents`-inventaris UIT DE RESPONS voor alle geopende documenten; `documentId` ' + + 'blijft voor compatibiliteit het actieve document aanwijzen. Of het eerste project in het ' + 'bestaande tabblad of in een nieuw tabblad landde hangt af van de staat van de app. ' + 'Kalender-id\'s zijn per document: herbouw een kalender in het importdocument met ' + 'update_calendar — geef een kalenderobject uit planner_get_calendars van het masterdocument ' + @@ -309,7 +311,7 @@ export const fileTools: McpToolDef[] = [ inputSchema: { type: 'object', properties: { - path: { type: 'string', description: 'Absoluut bronpad binnen de home-map (.ifc / .xml / .csv / .mpp; ~ mag)' }, + path: { type: 'string', description: 'Absoluut bronpad binnen de home-map (.ifc / .xml / .csv / .mpp / .xer; ~ mag)' }, }, required: ['path'], additionalProperties: false, @@ -356,7 +358,7 @@ export const fileTools: McpToolDef[] = [ } const content = input.text ?? ''; - let parsed: ImportResult; + let parsed: OpenedImport; try { // Geen `labels`: dienstlaag zonder `t(...)` — de MCP-laag is AI-facing en kent geen UI-taal. // `readIFC` valt dan terug op de Engelse default voor een bestand zonder IFCPROJECT (zie @@ -366,11 +368,8 @@ export const fileTools: McpToolDef[] = [ return toolError(ctx, 'VALIDATION', `'${path}' kon niet worden gelezen als planning: ${e instanceof Error ? e.message : String(e)}`); } - // Exact het bestaande laadpatroon (fileSlice.openFile): pristine tabblad hergebruiken, anders - // een nieuw document — er is bewust geen merge. - const store = ctx.app.store.getState(); - const reusedActiveTab = isActivePristine(store); - if (!reusedActiveTab) store.newDocument(); + // Exact het bestaande laadpatroon (fileSlice.openFile), inclusief X4b's meervoudige XER-vorm: + // `applyOpenedImport` hergebruikt zelf een pristine tabblad en opent anders nieuwe documenten. const format = formatOf(path, content); // OPSLAGDOEL alleen bij een formaat dat `canBeSaveTarget` draagt (T11 — vóór deze fix: `format // === 'IFC' && !isBinary`, twee losse classificaties die uit elkaar konden lopen). Opslaan @@ -381,7 +380,7 @@ export const fileTools: McpToolDef[] = [ // wat je wilt. `formatOf` blijft puur het AI-facing label (`format` hieronder, voor de respons // en de notices) — de opslagdoel-beslissing leest voortaan uitsluitend `readFormat. // canBeSaveTarget`, dezelfde registry-vlag als `fileSlice.ts`. - ctx.app.store.getState().applyLoadedProject(parsed, { + const opened = ctx.app.store.getState().applyOpenedImport(parsed, { filePath: readFormat.canBeSaveTarget ? path : null, fileHandle: null, recompute: true, @@ -393,6 +392,23 @@ export const fileTools: McpToolDef[] = [ bindExpectedDoc(ctx); const after = ctx.app.store.getState(); + const payloadById = new Map(after.getOpenDocumentPayloads().map(document => [ + document.id, + document.payload, + ])); + const documents = opened.documentIds.map(documentId => { + const payload = payloadById.get(documentId); + if (!payload) throw new Error(`Geopend document '${documentId}' ontbreekt na import`); + return { + documentId, + projectId: payload.project.id, + projectName: payload.project.name, + tasks: payload.tasks.length, + sequences: payload.sequences.length, + resources: payload.resources.length, + filePath: payload.filePath, + }; + }); const notices: string[] = []; if (format === 'CSV') { notices.push('CSV bevat geen kalender (het document draait nu op de STANDAARDkalender — datums kunnen afwijken) en geen resources/toewijzingen.'); @@ -400,6 +416,12 @@ export const fileTools: McpToolDef[] = [ notices.push('P6-XML: Nonlabor-resources zijn als EQUIPMENT geïmporteerd.'); } else if (format === 'MPP14') { notices.push('MPP-import is alleen-lezen (best effort; baselines en custom fields komen niet mee). Opslaan schrijft IFC; export naar MS Project = MSPDI-XML.'); + } else if (format === 'XER') { + notices.push( + `XER-import is alleen-lezen; deze stap importeert ${documents.length} niet-lege ` + + `P6-project${documents.length === 1 ? '' : 'en'} als ${documents.length} ` + + `document${documents.length === 1 ? '' : 'en'}. Opslaan schrijft IFC.`, + ); } if (format !== 'IFC') { notices.push('Het document heeft nog GEEN opslagdoel: opslaan schrijft IFC, dus het bronbestand wordt niet overschreven — de gebruiker kiest bij opslaan een pad.'); @@ -409,9 +431,11 @@ export const fileTools: McpToolDef[] = [ envelope: buildEnvelope(ctx), data: { documentId: after.activeDocumentId, + documentsOpened: documents.length, + documents, path, format, - reusedActiveTab, + reusedActiveTab: opened.reusedActiveTab, /** Opslagdoel van het document: het bronpad bij IFC, anders null (zie hierboven). */ filePath: after.filePath, tasks: after.tasks.length, diff --git a/src/services/mcp/tools/readTools.ts b/src/services/mcp/tools/readTools.ts index 56bdb78c7..f41d6ec70 100644 --- a/src/services/mcp/tools/readTools.ts +++ b/src/services/mcp/tools/readTools.ts @@ -44,6 +44,8 @@ import { CalendarEngine } from '@/engine/scheduler/CalendarEngine'; import { resolveCalendar } from '@/engine/scheduler/resolveCalendar'; // Zelfde twee bronnen als het slot in `ResourcePanel` en de weigering in `resourceTools` — één lijst. import { RESOURCE_DIFF_FIELDS, isResourceFieldLocked } from '@/services/library/libraryOps'; +import { isLeafTask, isSummaryTask } from '@/utils/taskHierarchy'; +import { unrecordedExportGate } from '@/state/recordedDatesSelectors'; // ── Lokale leestool-wikkel + nette fout ────────────────────────────────────────────────────────── @@ -262,10 +264,16 @@ function calendarSummary(cal: WorkCalendar) { function getProjectInfo(s: AppState) { const tasks = s.tasks; - const leaves = tasks.filter((t) => t.childIds.length === 0); - const summaries = tasks.filter((t) => t.childIds.length > 0); + const leaves = tasks.filter(isLeafTask); + const summaries = tasks.filter(isSummaryTask); const milestones = tasks.filter((t) => t.isMilestone); - const criticalCount = tasks.filter((t) => t.time.isCritical).length; + // "Datums zoals opgeslagen" (her-check laag 3, bevinding 10): in de modus is `isCritical` van + // een taak zonder vastgelegde speling de `?? false`-terugval — die telt hier niet als "niet + // kritiek" maar als onbekend, apart gerapporteerd zodat een AI-client geen "0 kritieke taken" + // uit een verzwegen as leest. Buiten de modus is `unrecordedOf` undefined ⇒ byte-identiek. + const unrecordedOf = unrecordedExportGate(s.recordedDates, s.datesAsRecorded); + const criticalUnknown = tasks.filter((t) => unrecordedOf?.(t).includes('isCritical')).length; + const criticalCount = tasks.filter((t) => t.time.isCritical && !unrecordedOf?.(t).includes('isCritical')).length; const p = s.project; return { project: { @@ -288,6 +296,7 @@ function getProjectInfo(s: AppState) { resources: s.resources.length, assignments: s.assignments.length, criticalTasks: criticalCount, + ...(criticalUnknown > 0 ? { criticalUnrecordedTasks: criticalUnknown } : {}), }, schedule: { scheduleStale: s.scheduleStale, @@ -314,6 +323,10 @@ function getProjectOverview(s: AppState) { if (arr) arr.push(seq); else outByPred.set(seq.predecessorId, [seq]); } + const unrecordedOverview = unrecordedExportGate(s.recordedDates, s.datesAsRecorded); + const critUnrecorded = unrecordedOverview + ? (t: Task) => unrecordedOverview(t).includes('isCritical') + : undefined; const rows = tasks.map((t) => { const rels = (outByPred.get(t.id) ?? []).map((seq) => relShort(taskById, seq)); const row: Record = { @@ -331,7 +344,10 @@ function getProjectOverview(s: AppState) { if (t.parentId) row.parent = taskById.get(t.parentId)?.wbsCode ?? t.parentId; const p = pct(t.time.completion); if (p > 0) row.prog = p; - if (t.time.isCritical) row.crit = true; + // Zelfde poort als `getTask` (her-check laag 3, bevinding 10): een niet-vastgelegde + // kritiekas in de modus is onbekend, geen `false` — dan `crit: null` i.p.v. weglaten. + if (critUnrecorded?.(t)) row.crit = null; + else if (t.time.isCritical) row.crit = true; if (t.isMilestone) row.ms = true; if (rels.length > 0) row.rels = rels; return row; @@ -371,8 +387,11 @@ function listTasks(s: AppState, args: ListTasksArgs) { const inSeq = idsInAnySequence(s.sequences); let filtered = s.tasks; - if (args.kritiek === true) filtered = filtered.filter((t) => t.time.isCritical); - if (args.kritiek === false) filtered = filtered.filter((t) => !t.time.isCritical); + // Onbekende kritiekas (modus "datums zoals opgeslagen", her-check bevinding 10): hoort bij + // GEEN van beide filters — niet-vastgelegd is niet hetzelfde als niet-kritiek. + const critUnknown = unrecordedExportGate(s.recordedDates, s.datesAsRecorded); + if (args.kritiek === true) filtered = filtered.filter((t) => t.time.isCritical && !critUnknown?.(t).includes('isCritical')); + if (args.kritiek === false) filtered = filtered.filter((t) => !t.time.isCritical && !critUnknown?.(t).includes('isCritical')); if (typeof args.status === 'string') { const st = args.status; filtered = filtered.filter((t) => t.status === st); @@ -389,10 +408,11 @@ function listTasks(s: AppState, args: ListTasksArgs) { // Wees-detectie: alléén LEAF-taken die in geen enkele relatie voorkomen. Verzameltaken hebben per // definitie geen relaties en zijn dus geen "wezen" — die worden hier bewust uitgesloten. if (args.zonder_relaties === true) { - filtered = filtered.filter((t) => t.childIds.length === 0 && !inSeq.has(t.id)); + filtered = filtered.filter((t) => isLeafTask(t) && !inSeq.has(t.id)); } const paged = paginate(filtered, args); + const critUnrecorded = critUnknown ? (t: Task) => critUnknown(t).includes('isCritical') : undefined; const rows = paged.items.map((t) => { const row: Record = { id: t.id, @@ -406,9 +426,12 @@ function listTasks(s: AppState, args: ListTasksArgs) { }; const p = pct(t.time.completion); if (p > 0) row.prog = p; - if (t.time.isCritical) row.crit = true; + // Zelfde poort als `getTask` (her-check laag 3, bevinding 10): een niet-vastgelegde + // kritiekas in de modus is onbekend, geen `false` — dan `crit: null` i.p.v. weglaten. + if (critUnrecorded?.(t)) row.crit = null; + else if (t.time.isCritical) row.crit = true; if (t.isMilestone) row.ms = true; - if (t.childIds.length > 0) row.summary = true; + if (isSummaryTask(t)) row.summary = true; return row; }); return { @@ -455,6 +478,9 @@ function getTask(s: AppState, args: GetTaskArgs) { wbs: wbsOf(taskById, seq.predecessorId), type: seqAbbrev(seq.type), lag: lagLabel(seq), + ...(seq.p6StartAtPredecessorFinishBoundary !== undefined + ? { p6StartAtPredecessorFinishBoundary: seq.p6StartAtPredecessorFinishBoundary } + : {}), })); const successors = s.sequences .filter((seq) => seq.predecessorId === task.id) @@ -464,12 +490,16 @@ function getTask(s: AppState, args: GetTaskArgs) { wbs: wbsOf(taskById, seq.successorId), type: seqAbbrev(seq.type), lag: lagLabel(seq), + ...(seq.p6StartAtPredecessorFinishBoundary !== undefined + ? { p6StartAtPredecessorFinishBoundary: seq.p6StartAtPredecessorFinishBoundary } + : {}), })); // Effectieve kalender (§5): taak-kalender uit de bibliotheek, anders de projectkalender. const effCal = resolveCalendar(task.calendarId, s.calendars, s.calendar); const tt = task.time; + const unrecorded = unrecordedExportGate(s.recordedDates, s.datesAsRecorded)?.(task); return { id: task.id, wbs: task.wbsCode, @@ -501,19 +531,37 @@ function getTask(s: AppState, args: GetTaskArgs) { ...(task.mspTaskType ? { mspTaskType: task.mspTaskType } : {}), ...(task.effortDriven ? { effortDriven: true } : {}), ...(task.timephasedContours && task.timephasedContours.length > 0 ? { timephasedContours: task.timephasedContours } : {}), + ...(task.p6DurationType !== undefined ? { p6DurationType: task.p6DurationType } : {}), + ...(task.p6ActivityType !== undefined ? { p6ActivityType: task.p6ActivityType } : {}), + ...(task.p6ProjectId !== undefined ? { p6ProjectId: task.p6ProjectId } : {}), + ...(task.p6TaskId !== undefined ? { p6TaskId: task.p6TaskId } : {}), + ...(task.p6ExplicitTargetWindow !== undefined ? { p6ExplicitTargetWindow: task.p6ExplicitTargetWindow } : {}), + ...(task.p6CompletePctType !== undefined ? { p6CompletePctType: task.p6CompletePctType } : {}), + ...(task.p6ExpectedFinish !== undefined ? { p6ExpectedFinish: task.p6ExpectedFinish } : {}), + ...(task.p6SuspendResume !== undefined ? { p6SuspendResume: task.p6SuspendResume } : {}), parentId: task.parentId, childIds: task.childIds, duration: nativeDuration(task), durationUnit: taskDurationUnit(task), durationType: tt.durationType, + // "Datums zoals opgeslagen" (critreview laag 3, bevinding 6): staat de modus aan, dan draagt + // `task.time` de vastlegging van het bronbestand, mét de bewuste terugvallen voor assen die het + // bestand NIET vastlegde (`lateStart ?? rec.start`, `totalFloat ?? 0`, `isCritical ?? false`). + // In de tabel staat daar "Niet vastgelegd"; hier is `null` het equivalent. Zonder dit leest een + // AI-client een verzonnen nulspeling als feit — en anders dan een gebruiker ziet hij de strook + // boven de planning niet. `unrecorded` is `undefined` buiten de modus ⇒ byte-identieke respons. schedule: { earlyStart: tt.earlyStart, earlyFinish: tt.earlyFinish, - lateStart: tt.lateStart, - lateFinish: tt.lateFinish, - totalFloat: tt.totalFloat, - freeFloat: tt.freeFloat, - isCritical: tt.isCritical, + lateStart: unrecorded?.includes('lateStart') ? null : tt.lateStart, + lateFinish: unrecorded?.includes('lateFinish') ? null : tt.lateFinish, + totalFloat: unrecorded?.includes('totalFloat') ? null : tt.totalFloat, + freeFloat: unrecorded?.includes('freeFloat') ? null : tt.freeFloat, + isCritical: unrecorded?.includes('isCritical') ? null : tt.isCritical, + ...(unrecorded && unrecorded.length > 0 ? { + // Expliciet, want `null` alleen is dubbelzinnig ("onbekend" vs "leeg gelaten"). + datesAsRecordedUnrecordedFields: unrecorded, + } : {}), }, progress: { completion: pct(tt.completion), @@ -572,16 +620,20 @@ function getCriticalPath(s: AppState) { const critSet = new Set(cpm.criticalPath); // Kritieke taken in TOPO-volgorde (cpm.criticalPath is opgebouwd in de solver-order). + // Zelfde poort als in `getTask` (critreview bevinding 6): in "datums zoals opgeslagen" is de + // speling van een taak zonder vastgelegde `totalFloat` een `?? 0`-terugval, geen meting. + const unrecordedOf = unrecordedExportGate(s.recordedDates, s.datesAsRecorded); const criticalTasks = cpm.criticalPath.map((id) => { const t = taskById.get(id); const r = cpm.tasks.get(id); + const unrecorded = t ? unrecordedOf?.(t) : undefined; return { id, wbs: t?.wbsCode ?? id, name: t?.name ?? '', start: r?.earlyStart ?? '', end: r?.earlyFinish ?? '', - totalFloat: r?.totalFloat ?? 0, + totalFloat: unrecorded?.includes('totalFloat') ? null : (r?.totalFloat ?? 0), }; }); @@ -1030,7 +1082,11 @@ export const readTools: McpToolDef[] = [ 'aanwezig: READ-ONLY `manuallyScheduled` (handmatig gepland), ' + 'READ-ONLY `splitGaps` (werkonderbrekingen), `levelingDelayMinutes`, `mspTaskType` (MSP Task ' + 'Type: FIXED_UNITS/FIXED_DURATION/FIXED_WORK), `effortDriven` en `timephasedContours` (rauwe ' + - 'contourperiodes — puur data, geen rekengedrag). Onbekend id ⇒ nette NOT_FOUND. ' + + 'contourperiodes — puur data, geen rekengedrag). Bij XER/P6 zijn, indien aanwezig, ook de acht ' + + 'read-only bronvelden `p6DurationType`, `p6ActivityType`, `p6ProjectId`, `p6TaskId`, ' + + '`p6ExplicitTargetWindow` (ook false), `p6CompletePctType`, `p6ExpectedFinish` en ' + + '`p6SuspendResume` zichtbaar. Relatie-objecten tonen daarnaast, indien aanwezig, ' + + '`p6StartAtPredecessorFinishBoundary`. Onbekend id ⇒ nette NOT_FOUND. ' + 'NAAMDRIFT LEZEN↔SCHRIJVEN: `wbs` heet bij het schrijven `wbsCode`, en `calendar.effectiveId` ' + 'heet daar `calendarId` (let op: `effectiveId` kan de PROJECTkalender zijn — dan staat er geen ' + 'eigen `calendarId` op de taak, zie `calendar.isProjectDefault`). ' + diff --git a/src/services/mcp/tools/taskFields.ts b/src/services/mcp/tools/taskFields.ts index 06c83009e..b1e5a17e8 100644 --- a/src/services/mcp/tools/taskFields.ts +++ b/src/services/mcp/tools/taskFields.ts @@ -152,6 +152,16 @@ const REJECT_HINTS: Record = { mspTaskType: 'MSP\'s eigen Task Type is via de bridge niet zetbaar (puur .mpp-importdata, geen rekengedrag — zie planner_get_task)', effortDriven: 'MSP\'s "Effort Driven"-vlag is via de bridge niet zetbaar (puur .mpp-importdata, geen rekengedrag — zie planner_get_task)', timephasedContours: 'de rauwe contourperiodes zijn via de bridge niet zetbaar (afgeleid uit een .mpp-import, geen agent-invoervorm — zie planner_get_task)', + // X0 (XER-etappeplan, 2026-08-20): drie nieuwe .xer-importvelden, zelfde "read-only, geen + // agent-invoervorm"-redenering als mspTaskType/effortDriven hierboven. + p6DurationType: 'P6\'s eigen Duration Type is via de bridge niet zetbaar (puur .xer-importdata, geen rekengedrag — zie planner_get_task)', + p6ActivityType: 'P6\'s eigen Activity Type is via de bridge niet zetbaar (puur .xer-importdata, geen rekengedrag — zie planner_get_task)', + p6ExplicitTargetWindow: 'de P6-XER-provenance voor een expliciet targetvenster is via de bridge niet zetbaar (puur .xer-importdata, geen agent-invoervorm — zie planner_get_task)', + p6SuspendResume: 'de P6-suspend/resume-herkomstvlag is via de bridge niet zetbaar (puur .xer-importdata, geen agent-invoervorm — zie planner_get_task)', + p6ProjectId: 'P6\'s bronproject-id is via de bridge niet zetbaar (puur .xer-importdata, geen agent-invoervorm — zie planner_get_task)', + p6TaskId: 'P6\'s bronactiviteit-id is via de bridge niet zetbaar (puur .xer-importdata, geen agent-invoervorm — zie planner_get_task)', + p6CompletePctType: 'P6\'s Completion Percent Type is via de bridge niet zetbaar (puur .xer-importdata, geen agent-invoervorm — zie planner_get_task)', + p6ExpectedFinish: 'P6\'s Expected Finish is via de bridge niet zetbaar (puur .xer-importdata, geen agent-invoervorm — zie planner_get_task)', }; /** Uitkomst van de veldvalidatie. */ diff --git a/src/services/mcp/tools/taskTools.ts b/src/services/mcp/tools/taskTools.ts index 77d24be62..15c2d5dfd 100644 --- a/src/services/mcp/tools/taskTools.ts +++ b/src/services/mcp/tools/taskTools.ts @@ -37,6 +37,7 @@ import { } from './taskFields'; import type { SequenceType } from '@/types/sequence'; import type { Task } from '@/types/task'; +import { isSummaryTask } from '@/utils/taskHierarchy'; import { isAncestorRelation } from '@/state/relationRules'; // De relatie-NOTATIE (type-aliassen, lag-vormen, schema-fragmenten) woont in de gedeelde veldlaag // `sequenceFields.ts` — één implementatie voor `add_dependencies` hier, `update_dependencies` in @@ -94,7 +95,7 @@ function fieldContext( ): TaskFieldContext { return { currentIsMilestone: task?.isMilestone ?? false, - hasChildren: (task?.childIds.length ?? 0) > 0, + hasChildren: isSummaryTask(task), hasAssignments: task ? s.assignments.some((a) => a.taskId === task.id) : false, // De projectkalender-id telt mee: op een vers document staat die alleen als cache in `s.calendar` // (`calendars` is dan leeg), maar hij is wel degelijk een geldige taak-kalender. diff --git a/src/services/mcp/tools/xerProvenanceTools.ts b/src/services/mcp/tools/xerProvenanceTools.ts new file mode 100644 index 000000000..ee24860e1 --- /dev/null +++ b/src/services/mcp/tools/xerProvenanceTools.ts @@ -0,0 +1,799 @@ +// MCP-bridge — begrensde, alleen-lezen inspectie van retained XER/P6-bronsemantiek. +// +// Deze tool leest uitsluitend het reeds opgeslagen `xerSourceArchive` en de actieve +// `xerImportMetadata`-view. Hij parset geen bytes opnieuw en raakt de store nooit. Summary is +// bewust vrij van raw rows en raw bytes; bronrijen en bytes zijn aparte, expliciet gepagineerde +// secties. +// +// DE EIGENSCHAP (review2-3d.md, ronde 3): niet "drie paden dichtzetten" en ook niet "drie +// sleutelnamen blokkeren" — een blocklist van vrije-tekstvelden vergeet onvermijdelijk een synoniem +// (`text`/`comment`/`memo`/`remark`/`title`/`longName` glipten er in ronde 2 allemaal doorheen, en +// `taskProjections.notes` — een objectarray, geen string — helemaal, omdat de blocklist alleen op de +// buitenste sleutel keek). `sanitizeProvenanceValue` hieronder is nu DENY-BY-DEFAULT: hij loopt +// recursief over WAT een sectiefunctie ook teruggeeft, herkent een XER-bronrij STRUCTUREEL (elk plain +// object met een numerieke `line` en een `cells`-veld van louter strings — dus ook op plekken waar +// niemand een allowlist-regel voor had geschreven, en ook als de rij méér dan die twee velden draagt, +// zoals `XerScheduleOptionsSourceRow`'s `table`) en classificeert elke LOSSE string op sleutelnaam +// tegen `SAFE_LABEL_KEYS`: staat de sleutel er niet expliciet in — ook een onbekende toekomstige +// sleutel — dan is de waarde zonder `includeRawRows` VOLLEDIG onzichtbaar en met opt-in afgekapt op +// 2.000 tekens. Staat de sleutel er wél in (id's, codes, korte labels, enum-/tokenvelden), dan blijft +// hij altijd zichtbaar maar hard afgekapt op 200 tekens. Cel-/veldNAMEN (de `%F`-kolomkop van het +// bronbestand) lopen door dezelfde afkap- en budgetlogica als celWAARDEN — anders ontsnapt een +// aanvaller-gecontroleerde kolomnaam aan zowel de zichtbaarheidsgrens als de responsbegroting. +// `summary` (de default-sectie) loopt door DEZELFDE poort plus dezelfde responsgrens; er is geen +// aparte, ongesaneerde vorm meer. + +import type { AppState } from '@/state/appStore'; +import type { McpContext, McpErrorCode, McpToolDef, McpToolResult } from '../contracts'; +import { runReadTool, toolError } from './runtime'; +import { + XER_SOURCE_ARCHIVE_CHUNK_BYTES, + type XerSourceArchive, +} from '@/services/xerSourceArchive'; + +const SECTIONS = ['summary', 'resourceCatalog', 'metadataCatalog', 'taskSourceRowsByProject', 'diagnostics', 'rawSource'] as const; +type Section = typeof SECTIONS[number]; + +const RESOURCE_COLLECTIONS = [ + 'resources', 'identities', 'resourceSources', 'roleSources', 'rates', 'curves', 'assignmentSources', 'issues', +] as const; +const METADATA_COLLECTIONS = [ + 'activityCodeTypes', 'customFieldDefs', 'taskProjections', 'issues', + 'ACTVTYPE', 'ACTVCODE', 'TASKACTV', 'UDFTYPE', 'UDFVALUE', 'MEMOTYPE', 'TASKNOTE', 'TASKMEMO', + 'TASK_NOTES', 'deferredUdfValues', 'unknownUdfTypes', +] as const; +const DIAGNOSTIC_COLLECTIONS = [ + 'tableIssues', 'unknownTables', 'unknownFields', 'scheduleOptions', 'relationResolutionIssues', + 'resourceCatalogIssues', 'metadataCatalogIssues', 'documentViews', 'importReport', +] as const; +const ALL_COLLECTIONS = [...RESOURCE_COLLECTIONS, ...METADATA_COLLECTIONS, ...DIAGNOSTIC_COLLECTIONS] as const; + +type Collection = typeof ALL_COLLECTIONS[number]; + +interface XerProvenanceArgs { + section?: unknown; + collection?: unknown; + projectId?: unknown; + limit?: unknown; + offset?: unknown; + includeRawSource?: unknown; + includeRawRows?: unknown; +} + +/** Sections die door de generieke bronrij-/vrije-tekstpoort lopen. `diagnostics` zit erbij sinds + * review2-3d.md #1: `documentViews` draagt via `resources.assignments[].rawRow` dezelfde vrije + * XER-cellen als de andere drie secties en hoorde dus niet buiten deze lijst te vallen. */ +const RAW_ROWS_SECTIONS: readonly Section[] = ['resourceCatalog', 'metadataCatalog', 'taskSourceRowsByProject', 'diagnostics']; + +/** Lagere paginalimiet zodra `includeRawRows` echte celwaarden ontgrendelt — spiegelt de aparte, + * strakkere grens die `rawSource` al had voor ruwe bytes. */ +const RAW_ROWS_OPT_IN_MAX_LIMIT = 100; +/** Per-cel/per-string afkapgrens voor VRIJE tekst (rawRow-cellen, notities, customFields-waarden). */ +const RAW_CELL_MAX_CHARS = 2000; +/** Per-string afkapgrens voor korte LABEL-achtige velden (namen, omschrijvingen buiten `rawRow`) die + * altijd zichtbaar blijven — review2-3d.md #2: zonder deze cap kon een misbruikt `name`-veld + * ongehinderd megabytes meesturen omdat "genormaliseerd" werd gelezen als "veilig". */ +const LABEL_MAX_CHARS = 200; +/** Cap op het aantal cellen/velden per bronrij of vrije-tekstkaart — review2-3d.md #6: zonder deze + * cap bepaalt de `%F`-kolomkop van het bronbestand ongehinderd hoeveel cellen één rij draagt (een + * aanvaller-gecontroleerd bestand kan er 20.000 declareren). */ +const MAX_CELLS_PER_ROW = 200; +/** Harde bovengrens op de totale geserialiseerde paginarespons, in ECHTE UTF-8-bytes + * (review2-3d.md #4: `String.length` telt UTF-16-code-units, geen bytes — voor CJK/Arabisch/emoji + * zit daar een factor 2–3 tussen). */ +const MAX_SECTION_RESPONSE_BYTES = 256 * 1024; + +/** DENY-BY-DEFAULT allowlist (review2-3d.md ronde 3, N1/N2): id-/code-/labelachtige sleutels die + * zonder opt-in zichtbaar mogen blijven (afgekapt op 200 tekens). Alles wat hier niet in staat — + * `text`/`comment`/`memo`/`remark`/`title`/`longName`, een taaknotitie, een onbekende toekomstige + * sleutel — is zonder `includeRawRows` volledig onzichtbaar. Bewust een allowlist en geen blocklist: + * een vergeten sleutel hier betekent "verbergen" (fail-safe), een vergeten sleutel in een blocklist + * betekent "lekken" (fail-open) — precies het verschil dat ronde 2 fout deed gaan. + * + * GEACCEPTEERD RISICO (review2-3d.md ronde 4, R6): `name` en `code` blijven zichtbaar (afgekapt op + * 200 tekens) omdat een provenance-inspectie zonder namen nutteloos is — maar een P6-resourcenaam + * is in de praktijk routinematig een persoonsnaam. Dit is dus bewust GEEN AVG-schone lijst. Moet + * deze tool ooit persoonsgegevensvrij zijn, dan hoort `name` alsnog achter `includeRawRows`. + * + * Elke sleutel hieronder is nagelopen tegen de daadwerkelijk blootgestelde grafiek (resourceCatalog, + * metadataCatalog, diagnostics, taskSourceRowsByProject, summary) — geen sleutel "voor het geval + * dat". `unit`/`currShortName` zijn om die reden verwijderd (kwamen nergens exposed voor; + * `curr_short_name` is alleen een interne XER-kolomnaam voor numberFormat-detectie, geen + * responsveld). `unitOfMeasure` staat er wél echt (Resource.unitOfMeasure, `xerResources.ts`). */ +const SAFE_LABEL_KEYS = new Set([ + // id's/verwijzingen + 'id', 'sourceId', 'internalId', 'taskId', 'predecessorTaskId', 'projectId', 'sourceProjectId', + 'currentProjectId', 'resourceId', 'roleId', 'curveId', 'parentId', 'parentSourceId', 'calendarId', + 'calendarSourceId', 'defaultRoleSourceId', 'unitSourceId', 'taskSourceId', 'companyId', + 'libraryItemId', 'syncedHash', + // korte labels/codes + 'name', 'shortName', 'code', 'taskCode', 'wbsCode', 'unitOfMeasure', 'currencyCode', + 'defaultCurrencyCode', + // structurele/enum-/tokenvelden + 'kind', 'type', 'rawType', 'table', 'field', 'reason', 'fallback', 'bestFit', 'encoding', + 'newline', 'bom', 'format', 'source', 'algorithm', 'status', 'from', 'token', 'decimal', 'group', + 'progressMode', 'mappedProgressMode', 'effectiveDate', + // vaste, systeemeigen waarden (geen bronvrije tekst). `value` is vandaag uitsluitend + // `summary.source.digest.value` (de SHA-256) — de generiekste naam hier en dus de eerste kandidaat + // om ooit per ongeluk vrije tekst te dragen; bij twijfel eerst hercontroleren tegen de grafiek. + 'sha256', 'value', 'availableProjectIds', 'baselineFallbackReasons', +]); +/** Vrije-tekstkáárten (UDF-/activiteitswaarden e.d.): net als een rawRow geeft dit zonder opt-in + * alleen een `fieldCount` (transparant dát er iets verborgen is), i.p.v. een leeg object — dat zou + * de generieke deny-by-default-recursie anders ook al doen, maar dan zonder die transparantie. */ +const FREE_TEXT_MAP_KEYS = new Set(['customFields']); + +class XerProvenanceError extends Error { + constructor(public readonly code: McpErrorCode, message: string) { + super(message); + } +} + +/** Codepoint-veilig afkappen (review2-3d.md #7): `slice(0, n)` op code-units kan een surrogaatpaar + * doormidden knippen (bv. een emoji), wat een well-formed maar kapotte string oplevert voor de + * client. Schuif de grens één code-unit terug zodra hij op een eenzame high surrogate uitkomt. */ +function truncateAt(value: string, maxChars: number): string { + if (value.length <= maxChars) return value; + let end = maxChars; + const codeUnit = value.charCodeAt(end - 1); + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) end -= 1; + return `${value.slice(0, end)}…(afgekapt op ${maxChars} tekens)`; +} + +function truncateCell(value: string): string { + return truncateAt(value, RAW_CELL_MAX_CHARS); +} + +function truncateLabel(value: string): string { + return truncateAt(value, LABEL_MAX_CHARS); +} + +function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Structurele herkenning van een XER-bronrij: een numerieke `line` plus een `cells`-kaart van + * louter strings. Bewust op VORM, niet op een exact sleutelaantal (review2-3d.md ronde 3, N5): de + * oorspronkelijke "exact twee sleutels"-eis faalde OPEN zodra een rijvorm een derde veld droeg + * (`XerScheduleOptionsSourceRow` = `{table, line, cells}`) — zo'n rij viel dan door naar de + * generieke objecttak, waar `cells` een gewoon record werd en elke celwaarde als LABEL zichtbaar + * kwam, zonder opt-in. Overige velden naast `line`/`cells` (zoals `table`) projecteert + * `projectSourceRow` gewoon mee via dezelfde poort — fail-CLOSED in plaats van fail-open. */ +function isRawSourceRowLike(value: Record): value is Record & { line: number; cells: Record } { + if (typeof value.line !== 'number' || !isPlainRecord(value.cells)) return false; + return Object.values(value.cells).every((cell) => typeof cell === 'string'); +} + +/** Voegt `key` toe aan `used` zonder een bestaande sleutel te overschrijven — nodig omdat + * afgekapte kolomnamen (`truncateLabel`) op elkaar kunnen samenvallen (review2-3d.md ronde 3, N4). */ +function uniqueTruncatedKey(candidate: string, used: Set): string { + let key = candidate; + let suffix = 1; + while (used.has(key)) { + key = `${candidate}#${suffix}`; + suffix += 1; + } + used.add(key); + return key; +} + +/** Houdt de opgebouwde UTF-8-bytegrootte van een pagina bij TERWIJL cellen/labels geprojecteerd + * worden, en breekt meteen af zodra het budget vol is — vóór de dure, autoritatieve + * `JSON.stringify`-meting in `finalizeBounded` (review2-3d.md #6: "begroot vóór serialisatie"). Dit + * voorkomt dat één rij met bv. 20.000 cellen alsnog een tientallen-MB-tussenstring opbouwt: de + * teller breekt al af halverwege díé ene rij. */ +interface ByteBudget { + charge(text: string): void; +} +function createByteBudget(maxBytes: number): ByteBudget { + const encoder = new TextEncoder(); + let used = 0; + return { + charge(text: string): void { + used += encoder.encode(text).byteLength; + if (used > maxBytes) { + throw new XerProvenanceError( + 'VALIDATION', + `Deze pagina overschrijdt de responsgrens van ${MAX_SECTION_RESPONSE_BYTES} bytes — verlaag \`limit\` of gebruik \`offset\` om te pagineren.`, + ); + } + }, + }; +} + +/** Projecteert één bronrij (`line` + `cells`, plus eventuele overige velden zoals `table`). Zonder + * `includeRawRows`: alleen `line` + het WERKELIJKE celaantal + de overige velden (die zelf weer door + * de generieke poort gaan — geen kortsluiting). Met opt-in: tot `MAX_CELLS_PER_ROW` cellen; zowel de + * celNAAM als de celWAARDE wordt afgekapt én budget-gecharged (review2-3d.md ronde 3, N4: een + * aanvaller-gecontroleerde kolomnaam van 60.001 tekens kwam voorheen verbatim mee en telde als nul + * in het budget). Botsende afgekapte kolomnamen krijgen een `#N`-suffix i.p.v. elkaar stil te + * overschrijven. Méér cellen dan de cap krijgen een expliciete `cellsTruncatedAt`-marker. */ +function projectSourceRow( + row: Record & { line: number; cells: Record }, + includeRawRows: boolean, + budget: ByteBudget, +): unknown { + const { line, cells, ...rest } = row; + const fieldNames = Object.keys(cells); + + const restProjected: Record = {}; + for (const [restKey, restValue] of Object.entries(rest)) { + const sanitized = sanitizeProvenanceValue(restValue, restKey, includeRawRows, budget); + if (sanitized !== undefined) restProjected[restKey] = sanitized; + } + + if (!includeRawRows) { + return { line, fieldCount: fieldNames.length, ...restProjected }; + } + + const limited = fieldNames.slice(0, MAX_CELLS_PER_ROW); + const projectedCells: Record = {}; + const usedCellKeys = new Set(); + for (const field of limited) { + const truncatedField = uniqueTruncatedKey(truncateLabel(field), usedCellKeys); + budget.charge(truncatedField); + const truncatedValue = truncateCell(cells[field]); + budget.charge(truncatedValue); + projectedCells[truncatedField] = truncatedValue; + } + const projected: Record = { line, cells: projectedCells, ...restProjected }; + if (fieldNames.length > MAX_CELLS_PER_ROW) { + projected.fieldCount = fieldNames.length; + projected.cellsTruncatedAt = MAX_CELLS_PER_ROW; + } + return projected; +} + +/** Zelfde cap/afkap-/opt-in-/sleutelbegroting-regime als `projectSourceRow`, voor een vrije- + * tekstkáárt (`customFields` e.d.) die geen `{line,cells}`-vorm heeft maar dezelfde + * privacy-eigenschap draagt. */ +function projectFreeTextMap(map: Record, includeRawRows: boolean, budget: ByteBudget): unknown { + const keys = Object.keys(map); + if (!includeRawRows) return { fieldCount: keys.length }; + const limited = keys.slice(0, MAX_CELLS_PER_ROW); + const out: Record = {}; + const usedKeys = new Set(); + for (const key of limited) { + const truncatedKey = uniqueTruncatedKey(truncateLabel(key), usedKeys); + budget.charge(truncatedKey); + const raw = map[key]; + const text = typeof raw === 'string' ? raw : JSON.stringify(raw); + const truncatedValue = truncateCell(text); + budget.charge(truncatedValue); + out[truncatedKey] = truncatedValue; + } + const projected: Record = { ...out }; + if (keys.length > MAX_CELLS_PER_ROW) projected.__truncated = { fieldCount: keys.length, cellsTruncatedAt: MAX_CELLS_PER_ROW }; + return projected; +} + +/** + * DE ENE POORT (review2-3d.md, ronde 3 — deny-by-default): elke waarde die een sectiefunctie + * teruggeeft loopt hierdoor vóór hij de respons in gaat. Regels, in volgorde: + * 1. een string onder een sleutel die NIET in `SAFE_LABEL_KEYS` staat is zonder opt-in VOLLEDIG + * onzichtbaar (de sleutel verdwijnt uit het resultaat), met opt-in afgekapt op 2.000 tekens — + * dit is het deny-by-default-hoofdpad en dekt zo ook geneste vrije tekst (`notes[].text`) zonder + * dat de context expliciet hoeft te worden doorgegeven: de LEAF-sleutel (`text`) bepaalt het lot; + * 2. een string onder een `SAFE_LABEL_KEYS`-sleutel blijft ALTIJD zichtbaar maar hard afgekapt op + * 200 tekens (labels/namen/ids/codes/enum-tokens); + * 3. een object dat STRUCTUREEL een XER-bronrij is (`line`+string-`cells`, zie `isRawSourceRowLike`) + * gaat via `projectSourceRow`, ongeacht waar in de boom hij zit en ongeacht overige velden; + * 4. een object onder een vrije-tekstkáárt-sleutel (`customFields`) gaat via `projectFreeTextMap`; + * 5. arrays en overige objecten recurseren; getallen/booleans/`null` gaan ongewijzigd mee. + * Nooit een alias naar de bevroren archiefstate: elke tak bouwt een vers object/array op, dus er is + * ook geen `structuredClone` meer nodig zoals de oude `page()` die had. + */ +function sanitizeProvenanceValue(value: unknown, key: string | null, includeRawRows: boolean, budget: ByteBudget): unknown { + if (typeof value === 'string') { + const isSafeLabel = key !== null && SAFE_LABEL_KEYS.has(key); + if (!isSafeLabel) { + if (!includeRawRows) return undefined; + const truncated = truncateCell(value); + budget.charge(truncated); + return truncated; + } + const truncated = truncateLabel(value); + budget.charge(truncated); + return truncated; + } + if (Array.isArray(value)) { + // review2-3d.md ronde 4, R7: een verborgen string-element mapt anders naar `undefined` → `null` + // in JSON (`[null, null]`) — geen lek, maar onduidelijk voor de lezer. Verborgen elementen eruit + // filteren i.p.v. ze als `null` te laten staan. + return value + .map((item) => sanitizeProvenanceValue(item, key, includeRawRows, budget)) + .filter((item) => item !== undefined); + } + if (isPlainRecord(value)) { + if (isRawSourceRowLike(value)) { + return projectSourceRow(value, includeRawRows, budget); + } + if (key !== null && FREE_TEXT_MAP_KEYS.has(key)) { + return projectFreeTextMap(value, includeRawRows, budget); + } + // review2-3d.md ronde 4, R8: dezelfde afkap-/budgetbehandeling voor de SLEUTEL als voor de + // waarde — anders overleeft een bronvrije sleutel (bv. een 80.000-tekens proj_id die als + // objectsleutel wordt gebruikt, zoals `catalogCounts.taskSourceRowsByProject`) altijd zodra zijn + // waarde overleeft, want een getal overleeft altijd. + const out: Record = {}; + const usedKeys = new Set(); + for (const [childKey, childValue] of Object.entries(value)) { + const sanitized = sanitizeProvenanceValue(childValue, childKey, includeRawRows, budget); + if (sanitized === undefined) continue; + const truncatedKey = uniqueTruncatedKey(truncateLabel(childKey), usedKeys); + budget.charge(truncatedKey); + out[truncatedKey] = sanitized; + } + return out; + } + return value; +} + +/** Autoritatieve, echte-bytes backstop (review2-3d.md #4): meet de UITEINDELIJKE geserialiseerde + * respons met `TextEncoder`, niet `String.length`. De budget-tijdens-projectie hierboven vangt de + * dominante kosten al vroeg af; dit is de correctheidsgarantie voor de rest (JSON-structuuroverhead, + * velden die niet via de budget-charge liepen). */ +function finalizeBounded>(result: T): T { + const byteLength = new TextEncoder().encode(JSON.stringify(result)).length; + if (byteLength > MAX_SECTION_RESPONSE_BYTES) { + throw new XerProvenanceError( + 'VALIDATION', + `Deze pagina overschrijdt de responsgrens van ${MAX_SECTION_RESPONSE_BYTES} bytes geserialiseerd — verlaag \`limit\` of gebruik \`offset\` om te pagineren.`, + ); + } + return result; +} + +function readTool(ctx: McpContext, fn: (state: AppState) => unknown): McpToolResult { + const captured: { error: XerProvenanceError | null } = { error: null }; + const result = runReadTool(ctx, (state) => { + try { + return fn(state); + } catch (error) { + if (error instanceof XerProvenanceError) { + captured.error = error; + return undefined; + } + throw error; + } + }); + return captured.error + ? toolError(ctx, captured.error.code, captured.error.message) + : result; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function requireOnlyKeys(args: unknown): XerProvenanceArgs { + if (args === undefined || args === null) return {}; + if (!isObject(args)) throw new XerProvenanceError('VALIDATION', 'inspect_xer_provenance verwacht een object met argumenten.'); + const allowed = ['section', 'collection', 'projectId', 'limit', 'offset', 'includeRawSource', 'includeRawRows']; + for (const key of Object.keys(args)) { + if (!allowed.includes(key)) { + throw new XerProvenanceError('VALIDATION', `onbekend argument \`${key}\` voor inspect_xer_provenance; toegestaan: ${allowed.join(', ')}.`); + } + } + return args; +} + +interface PageOptions { + /** Verlaagt de generieke bovengrens van 1000; gebruikt voor `rawSource` (8 chunks) en de + * `includeRawRows`-opt-in (100 rijen). */ + maxLimit?: number; + /** Naam voor de foutmelding zodra `limit` de verlaagde grens overschrijdt. */ + label?: string; + /** Eenheid in de foutmelding ("chunks" voor rawSource, "rijen" voor raw rows). */ + unit?: string; +} + +function requirePage(args: XerProvenanceArgs, options: PageOptions = {}): { limit: number; offset: number } { + const maxLimit = options.maxLimit ?? 1000; + const limit = args.limit === undefined ? 50 : args.limit; + const offset = args.offset === undefined ? 0 : args.offset; + if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1 || limit > maxLimit) { + if (options.label) { + // Een expliciete raw-data-call mag nooit ongemerkt een onbeperkte respons worden. Boven deze + // grens moet de client nog een pagina opvragen. + throw new XerProvenanceError( + 'VALIDATION', + `\`${options.label}\` accepteert maximaal ${maxLimit} ${options.unit ?? 'items'} per antwoord; gebruik offset voor volgende pagina's.`, + ); + } + throw new XerProvenanceError('VALIDATION', `\`limit\` moet een geheel getal van 1 t/m ${maxLimit} zijn.`); + } + if (typeof offset !== 'number' || !Number.isInteger(offset) || offset < 0) { + throw new XerProvenanceError('VALIDATION', '`offset` moet een geheel getal ≥ 0 zijn.'); + } + return { limit, offset }; +} + +function requireString(value: unknown, name: string): string { + if (typeof value !== 'string' || value.trim() === '') { + throw new XerProvenanceError('VALIDATION', `\`${name}\` moet een niet-lege string zijn.`); + } + return value; +} + +interface Paginated { + readonly slice: readonly T[]; + readonly total: number; + readonly offset: number; +} + +/** Slice EERST (op de ongesaneerde bron), sanitize DAARNA alleen de teruggegeven pagina — nooit + * andersom. Werk is zo ∝ `limit`, niet ∝ collectiegrootte, en de budget-tijdens-projectie hierboven + * charged alleen wat ook echt de respons in gaat. */ +function paginateRaw(items: readonly T[], args: XerProvenanceArgs, options: PageOptions = {}): Paginated { + const { limit, offset } = requirePage(args, options); + return { slice: items.slice(offset, offset + limit), total: items.length, offset }; +} + +function envelope(paged: Paginated, items: unknown[]): { + items: unknown[]; + total: number; + has_more: boolean; + next_offset: number | null; +} { + const next = paged.offset + items.length; + return { + items, + total: paged.total, + has_more: next < paged.total, + next_offset: next < paged.total ? next : null, + }; +} + +function collectionOf(section: Section, collection: unknown, allowed: readonly string[], value: T): T { + if (typeof collection !== 'string' || !allowed.includes(collection)) { + throw new XerProvenanceError( + 'VALIDATION', + `${section} vereist \`collection\` uit: ${allowed.join(', ')}.`, + ); + } + return value; +} + +/** ÉÉN selectorbron (review2-3d.md #5): de unie van `documentViews` (daadwerkelijk geopende + * projecten) en `taskSourceRowsByProject` (elk project met TASK-rijen, óók leeg/baseline- + * uitgesloten). Vóór deze fix keurde de generieke `projectId`-precheck alleen `documentViews` goed, + * terwijl `summary.catalogCounts.taskSourceRowsByProject` bredere projecten adverteerde die de tool + * vervolgens zelf met NOT_FOUND weigerde. */ +function projectIds(archive: XerSourceArchive): string[] { + const ids = new Set([ + ...Object.keys(archive.diagnostics.documentViews), + ...Object.keys(archive.readModel.taskSourceRowsByProject), + ]); + return Array.from(ids).sort(); +} + +function requireArchive(state: AppState): XerSourceArchive { + if (!state.xerSourceArchive) { + throw new XerProvenanceError('NOT_FOUND', 'Het actieve document bevat geen retained XER-bron.'); + } + return state.xerSourceArchive; +} + +function validateProjectSelector(archive: XerSourceArchive, projectId: unknown): string | undefined { + if (projectId === undefined) return undefined; + const selected = requireString(projectId, 'projectId'); + if (!projectIds(archive).includes(selected)) { + throw new XerProvenanceError('NOT_FOUND', `Onbekende XER-projectselector: ${selected}.`); + } + return selected; +} + +function summary(state: AppState, archive: XerSourceArchive | null): unknown { + if (!archive) { + // review2-3d.md ronde 4, R9: `state.xerSourceProjectId` is GEEN statische tekst — het is een + // documentveld dat uit het bestand komt en de IFC-round-trip overleeft, dus hoort net als elke + // andere bronstring door de poort + responsgrens. Alleen de hardcoded systeemmelding (`note`) is + // echt statisch; die wordt bewust NA het saneren toegevoegd (anders zou de sleutel `note` — niet + // op `SAFE_LABEL_KEYS`, met reden — zichzelf verbergen). + const budget = createByteBudget(MAX_SECTION_RESPONSE_BYTES); + const sanitized = sanitizeProvenanceValue( + { sourcePresent: false, source: null, selector: { currentProjectId: state.xerSourceProjectId, availableProjectIds: [] } }, + null, + false, + budget, + ) as Record; + sanitized.note = 'Er is voor dit document geen retained XER-bronarchief beschikbaar.'; + return finalizeBounded(sanitized); + } + const readModel = archive.readModel; + const file = archive.diagnostics.file; + const table = file.tableReport; + const metadata = readModel.metadataCatalog; + const resources = readModel.resourceCatalog; + const schedule = state.xerImportMetadata?.scheduleOptions; + const ids = projectIds(archive); + const raw = { + sourcePresent: true, + source: { + format: archive.format, + schemaVersion: archive.schemaVersion, + byteLength: archive.byteLength, + sha256: archive.sha256, + digest: { algorithm: 'SHA-256', value: archive.sha256 }, + encoding: archive.encoding, + bom: archive.bom, + newline: archive.newline, + byteChunkCount: archive.byteChunks.length, + }, + selector: { + currentProjectId: state.xerSourceProjectId ?? state.xerImportMetadata?.sourceProjectId ?? null, + availableProjectIds: ids, + }, + numberFormat: readModel.numberFormat, + schedoptions: { + source: schedule?.source ?? 'xer-defaults', + retainedSource: schedule?.retainedSource ?? {}, + mappedProgressMode: state.project.progressMode, + mappedSchedulingOptions: state.project.schedulingOptions ?? null, + sourceRowCount: schedule?.sourceRows.length ?? 0, + fallbackCount: schedule?.fallbacks.length ?? 0, + diagnosticCount: schedule?.diagnostics.length ?? 0, + }, + importReport: file.importReport, + diagnostics: { + tableIssueCount: table.issues.length, + unknownTableCount: table.unknownTables.length, + unknownFieldCount: table.unknownFields?.length ?? 0, + scheduleOptionsCount: file.scheduleOptions.length, + relationResolutionIssueCount: file.relationResolutionIssues.length, + resourceCatalogIssueCount: file.resourceCatalogIssues.length, + metadataCatalogIssueCount: file.metadataCatalogIssues.length, + }, + catalogCounts: { + resourceCatalog: { + resources: resources.resources.length, + identities: resources.identities.length, + resourceSources: resources.rows.resources.length, + roleSources: resources.rows.roles.length, + rates: resources.rows.rates.length, + curves: resources.rows.curves.length, + assignmentSources: resources.rows.assignments.length, + issues: resources.issues.length, + }, + metadataCatalog: { + activityCodeTypes: metadata.activityCodeTypes.length, + customFieldDefs: metadata.customFieldDefs.length, + taskProjections: metadata.taskProjections.length, + issues: metadata.issues.length, + sourceData: Object.fromEntries(Object.entries(metadata.sourceData).map(([name, rows]) => [name, rows.length])), + }, + taskSourceRowsByProject: Object.fromEntries( + Object.entries(readModel.taskSourceRowsByProject).map(([projectId, rows]) => [projectId, rows.length]), + ), + }, + }; + // Review2-3d.md ronde 3, N3: `summary` (de default-sectie!) liep door GEEN van beide poorten — + // `numberFormat.currencyCode` kwam onafgekapt mee en `importReport` was een LEVENDE ALIAS naar het + // bevroren archief (het kopcommentaar van `sanitizeProvenanceValue` belooft "nooit een alias"; dit + // was de ene plek die dat niet waarmaakte). `summary` zit niet in `RAW_ROWS_SECTIONS`, dus + // `includeRawRows` is hier altijd `false` — vrije tekst is dus altijd volledig onzichtbaar, nooit + // slechts afgekapt, precies zoals de sectie altijd al beloofde ("bewust vrij van raw rows"). + const budget = createByteBudget(MAX_SECTION_RESPONSE_BYTES); + const sanitized = sanitizeProvenanceValue(raw, null, false, budget) as Record; + return finalizeBounded(sanitized); +} + +function resourceCatalog(archive: XerSourceArchive, args: XerProvenanceArgs): unknown { + const catalog = archive.readModel.resourceCatalog; + const collection = collectionOf('resourceCatalog', args.collection, RESOURCE_COLLECTIONS, args.collection as Collection); + const values: Record = { + resources: catalog.resources, + identities: catalog.identities, + resourceSources: catalog.rows.resources, + roleSources: catalog.rows.roles, + rates: catalog.rows.rates, + curves: catalog.rows.curves, + assignmentSources: catalog.rows.assignments, + issues: catalog.issues, + } as Record; + const includeRawRows = args.includeRawRows === true; + const pageOptions: PageOptions = includeRawRows + ? { maxLimit: RAW_ROWS_OPT_IN_MAX_LIMIT, label: 'includeRawRows', unit: 'rijen' } + : {}; + const paged = paginateRaw(values[collection], args, pageOptions); + const budget = createByteBudget(MAX_SECTION_RESPONSE_BYTES); + const items = paged.slice.map((item) => sanitizeProvenanceValue(item, null, includeRawRows, budget)); + return finalizeBounded({ section: 'resourceCatalog', collection, ...envelope(paged, items) }); +} + +function metadataCatalog(archive: XerSourceArchive, args: XerProvenanceArgs): unknown { + const catalog = archive.readModel.metadataCatalog; + const collection = collectionOf('metadataCatalog', args.collection, METADATA_COLLECTIONS, args.collection as Collection); + const values: Record = { + activityCodeTypes: catalog.activityCodeTypes, + customFieldDefs: catalog.customFieldDefs, + taskProjections: catalog.taskProjections, + issues: catalog.issues, + ...catalog.sourceData, + }; + const includeRawRows = args.includeRawRows === true; + const pageOptions: PageOptions = includeRawRows + ? { maxLimit: RAW_ROWS_OPT_IN_MAX_LIMIT, label: 'includeRawRows', unit: 'rijen' } + : {}; + const paged = paginateRaw(values[collection] ?? [], args, pageOptions); + const budget = createByteBudget(MAX_SECTION_RESPONSE_BYTES); + const items = paged.slice.map((item) => sanitizeProvenanceValue(item, null, includeRawRows, budget)); + return finalizeBounded({ section: 'metadataCatalog', collection, ...envelope(paged, items) }); +} + +function diagnostics(archive: XerSourceArchive, args: XerProvenanceArgs): unknown { + const file = archive.diagnostics.file; + const collection = collectionOf('diagnostics', args.collection, DIAGNOSTIC_COLLECTIONS, args.collection as Collection); + const includeRawRows = args.includeRawRows === true; + if (collection === 'importReport') { + if (args.limit !== undefined || args.offset !== undefined) { + throw new XerProvenanceError('VALIDATION', '`limit` en `offset` horen niet bij diagnostics/importReport.'); + } + // Review2-3d.md #3/#6: ook het scalaire importReport-pad loopt nu door de poort + responsgrens — + // structureel onbegrensd blijven is fout, ongeacht wat het type vandaag toevallig bevat. + const budget = createByteBudget(MAX_SECTION_RESPONSE_BYTES); + const report = sanitizeProvenanceValue(file.importReport, null, includeRawRows, budget); + return finalizeBounded({ section: 'diagnostics', collection, report }); + } + const values: Record = { + tableIssues: file.tableReport.issues, + unknownTables: file.tableReport.unknownTables, + unknownFields: file.tableReport.unknownFields ?? [], + scheduleOptions: file.scheduleOptions, + relationResolutionIssues: file.relationResolutionIssues, + resourceCatalogIssues: file.resourceCatalogIssues, + metadataCatalogIssues: file.metadataCatalogIssues, + // Draagt via `resources.assignments[].rawRow` dezelfde vrije XER-cellen als de andere secties + // (review2-3d.md #1) — `sanitizeProvenanceValue` vindt die structureel, ook zonder dat deze + // regel er iets specifieks voor doet. + documentViews: Object.entries(archive.diagnostics.documentViews).sort(([left], [right]) => left.localeCompare(right)).map(([projectId, view]) => ({ projectId, view })), + }; + const pageOptions: PageOptions = includeRawRows + ? { maxLimit: RAW_ROWS_OPT_IN_MAX_LIMIT, label: 'includeRawRows', unit: 'rijen' } + : {}; + const paged = paginateRaw(values[collection], args, pageOptions); + const budget = createByteBudget(MAX_SECTION_RESPONSE_BYTES); + const items = paged.slice.map((item) => sanitizeProvenanceValue(item, null, includeRawRows, budget)); + return finalizeBounded({ + section: 'diagnostics', + collection, + ...(collection === 'tableIssues' ? { encoding: file.tableReport.encoding, endMarkerSeen: file.tableReport.endMarkerSeen } : {}), + ...envelope(paged, items), + }); +} + +function taskSourceRows(archive: XerSourceArchive, args: XerProvenanceArgs): unknown { + const projectId = requireString(args.projectId, 'projectId'); + if (!Object.prototype.hasOwnProperty.call(archive.readModel.taskSourceRowsByProject, projectId)) { + throw new XerProvenanceError('NOT_FOUND', `Onbekende XER-projectselector: ${projectId}.`); + } + const includeRawRows = args.includeRawRows === true; + const rows = archive.readModel.taskSourceRowsByProject[projectId] ?? []; + const pageOptions: PageOptions = includeRawRows + ? { maxLimit: RAW_ROWS_OPT_IN_MAX_LIMIT, label: 'includeRawRows', unit: 'rijen' } + : {}; + const paged = paginateRaw(rows, args, pageOptions); + const budget = createByteBudget(MAX_SECTION_RESPONSE_BYTES); + const items = paged.slice.map((row) => sanitizeProvenanceValue(row, null, includeRawRows, budget)); + return finalizeBounded({ section: 'taskSourceRowsByProject', projectId, ...envelope(paged, items) }); +} + +function rawSource(archive: XerSourceArchive, args: XerProvenanceArgs): unknown { + if (args.includeRawSource !== true) { + throw new XerProvenanceError('VALIDATION', 'rawSource vereist `includeRawSource: true`; bronbytes kunnen namen en vrije notities bevatten.'); + } + const paged = paginateRaw(archive.byteChunks, args, { maxLimit: 8, label: 'rawSource', unit: 'chunks' }); + const next = paged.offset + paged.slice.length; + return { + section: 'rawSource', + privacy: 'expliciet aangevraagd; base64-brondata kan vrije projectinformatie bevatten', + byteLength: archive.byteLength, + sha256: archive.sha256, + encoding: 'base64', + chunkSizeBytes: XER_SOURCE_ARCHIVE_CHUNK_BYTES, + totalChunks: archive.byteChunks.length, + chunks: paged.slice.map((base64, index) => ({ index: paged.offset + index, base64 })), + total: paged.total, + has_more: next < paged.total, + next_offset: next < paged.total ? next : null, + }; +} + +function inspect(state: AppState, rawArgs: unknown): unknown { + const args = requireOnlyKeys(rawArgs); + if (args.section !== undefined && (typeof args.section !== 'string' || !(SECTIONS as readonly string[]).includes(args.section))) { + throw new XerProvenanceError('VALIDATION', `\`section\` moet één van ${SECTIONS.join(', ')} zijn.`); + } + if (args.includeRawSource !== undefined && typeof args.includeRawSource !== 'boolean') { + throw new XerProvenanceError('VALIDATION', '`includeRawSource` moet een boolean zijn.'); + } + if (args.includeRawRows !== undefined && typeof args.includeRawRows !== 'boolean') { + throw new XerProvenanceError('VALIDATION', '`includeRawRows` moet een boolean zijn.'); + } + const section = (args.section ?? 'summary') as Section; + const archive = state.xerSourceArchive; + if (args.projectId !== undefined && (typeof args.projectId !== 'string' || args.projectId.trim() === '')) { + throw new XerProvenanceError('VALIDATION', '`projectId` moet een niet-lege string zijn.'); + } + if (section !== 'rawSource' && args.includeRawSource !== undefined) { + throw new XerProvenanceError('VALIDATION', '`includeRawSource` hoort alleen bij section `rawSource`.'); + } + if (args.includeRawRows !== undefined && !RAW_ROWS_SECTIONS.includes(section)) { + throw new XerProvenanceError( + 'VALIDATION', + '`includeRawRows` hoort alleen bij section `resourceCatalog`, `metadataCatalog`, `taskSourceRowsByProject` of `diagnostics`.', + ); + } + if (section === 'summary') { + if (args.collection !== undefined) throw new XerProvenanceError('VALIDATION', '`collection` hoort niet bij section `summary`.'); + if (args.limit !== undefined || args.offset !== undefined) throw new XerProvenanceError('VALIDATION', '`limit` en `offset` horen niet bij section `summary`.'); + if (args.projectId !== undefined && archive) validateProjectSelector(archive, args.projectId); + return summary(state, archive); + } + if (args.projectId !== undefined && archive) validateProjectSelector(archive, args.projectId); + const selected = requireArchive(state); + if (section === 'rawSource') { + if (args.collection !== undefined || args.projectId !== undefined) throw new XerProvenanceError('VALIDATION', '`rawSource` neemt alleen includeRawSource, limit en offset.'); + return rawSource(selected, args); + } + if (section === 'taskSourceRowsByProject') { + if (args.collection !== undefined) throw new XerProvenanceError('VALIDATION', '`collection` hoort niet bij section `taskSourceRowsByProject`.'); + return taskSourceRows(selected, args); + } + if (section === 'resourceCatalog') return resourceCatalog(selected, args); + if (section === 'metadataCatalog') return metadataCatalog(selected, args); + if (section === 'diagnostics') return diagnostics(selected, args); + throw new XerProvenanceError('VALIDATION', `Onbekende section: ${section}.`); +} + +const inputSchema = { + type: 'object', + properties: { + section: { type: 'string', enum: [...SECTIONS], description: 'Inspectieonderdeel; default summary.' }, + collection: { type: 'string', enum: [...ALL_COLLECTIONS], description: 'Gepagineerde collectie binnen resourceCatalog, metadataCatalog of diagnostics.' }, + projectId: { type: 'string', description: 'Verplichte expliciete XER-projectselector voor taskSourceRowsByProject.' }, + limit: { type: 'number', description: 'Aantal items/chunks; default 50, rawSource maximaal 8, includeRawRows maximaal 100.' }, + offset: { type: 'number', description: 'Startindex; default 0.' }, + includeRawSource: { type: 'boolean', description: 'Verplicht true voor de expliciete rawSource-sectie.' }, + includeRawRows: { + type: 'boolean', + description: + 'Alleen bij resourceCatalog/metadataCatalog/taskSourceRowsByProject/diagnostics: ontgrendelt ' + + 'vrije-tekstvelden (notities, customFields, willekeurige XER-kolommen) i.p.v. ze weg te laten, ' + + 'met een lagere paginalimiet (100), een cap van 200 cellen/velden per rij en per-cel-afkapping ' + + 'op 2.000 tekens. Korte label-/naamvelden blijven ALTIJD zichtbaar maar afgekapt op 200 tekens,' + + ' met of zonder deze opt-in.', + }, + }, + additionalProperties: false, +} as const; + +export const xerProvenanceTools: McpToolDef[] = [{ + name: 'planner_inspect_xer_provenance', + description: + 'Bounded read-only inspectie van retained Primavera P6/XER-bronsemantiek. `section` is summary ' + + '(bronaanwezigheid, byteLength, SHA-256, chunk count, selector, number format, SCHEDOPTIONS-, ' + + 'import- en diagnostiektellingen), resourceCatalog, metadataCatalog, taskSourceRowsByProject, ' + + 'diagnostics of rawSource. Cataloguscollecties zijn expliciet benoemd en gepagineerd met `limit`, ' + + '`offset`, `total`, `has_more` en `next_offset`. `selector.availableProjectIds` (summary) is de ' + + 'unie van geopende documentviews en elk project met TASK-rijen (óók leeg/baseline-uitgesloten) — ' + + 'exact de projecten die `taskSourceRowsByProject` accepteert. DENY-BY-DEFAULT voor elke string: ' + + 'alleen expliciet als id/code/label/enum-token herkende velden (namen, ids, codes, eenheden, ' + + 'currencyCode, …) blijven zonder `includeRawRows:true` zichtbaar, en dan hard afgekapt op 200 ' + + 'tekens. Elke andere string — ruwe XER-cellen, notities, `customFields`-waarden, en ook onbekende ' + + 'of geneste vrije tekst zoals een taaknotitie-array — is zonder de opt-in VOLLEDIG ONZICHTBAAR, ' + + 'met opt-in afgekapt op 2.000 tekens. Dit geldt voor élke string in élke sectie, inclusief ' + + '`summary` zelf en `diagnostics/documentViews`. Met opt-in gelden een lagere paginalimiet (100) en ' + + 'een cap van 200 cellen/velden per rij; zowel celWAARDEN als celNAMEN (kolomkoppen uit het ' + + 'bronbestand) zijn afgekapt en tellen mee in de responsbegroting. Elke pagina — óók summary — ' + + 'kent een harde responsgrens (256 kB, gemeten in echte UTF-8-bytes). rawSource vereist expliciet ' + + '`includeRawSource:true`, geeft maximaal acht vaste base64-chunks per antwoord en meldt de ' + + 'privacygrens. De tool gebruikt alleen retained state, muteert de store niet, voert geen CPM uit ' + + 'en ondersteunt geen schrijfpad. Niet batchable: roep hem los aan, nooit als stap in `planner_batch`.', + kind: 'read', + batchable: false, + inputSchema, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + handler: (args, ctx) => readTool(ctx, (state) => inspect(state, args)), +}]; diff --git a/src/services/mpp/mppReader.ts b/src/services/mpp/mppReader.ts index 1b4d6d9ae..f42251066 100644 --- a/src/services/mpp/mppReader.ts +++ b/src/services/mpp/mppReader.ts @@ -141,6 +141,7 @@ import { tenthsOfMinutesToDays } from '@/services/importDurations'; import { mspCodeToConstraint } from '@/services/msproject/mspdiReader'; import { hasNonAnchorTime, isSubDayMinutes } from '@/services/subdayIo'; import { CalendarEngine } from '@/engine/scheduler/CalendarEngine'; +import { isSummaryTask } from '@/utils/taskHierarchy'; import { CfbFile } from './cfb'; import { assertReadable, detectApplicationVersion, Props } from './mppContainer'; import { @@ -1668,7 +1669,7 @@ export function deriveSplitGapsForTasks( if (!task?.time?.scheduleStart) continue; // Z4-fixronde punt 4: MPXJ toont nooit splits op een samenvattingstaak // (`Task.calculateWorkSplits`: `if (getSummary()) return emptyList()`) — spiegelt dat exact. - if (task.childIds.length > 0) continue; + if (isSummaryTask(task)) continue; const taskStart = parseInstant(task.time.scheduleStart); const engine = engineFor(taskCalendar(task, calResult)); @@ -1743,7 +1744,7 @@ export function deriveTimephasedContoursForTasks( if (!link) continue; const task = taskById.get(link.taskId); if (!task?.time?.scheduleStart) continue; - if (task.childIds.length > 0) continue; // spiegelt deriveSplitGapsForTasks + if (isSummaryTask(task)) continue; // spiegelt deriveSplitGapsForTasks const taskStart = parseInstant(task.time.scheduleStart); const engine = engineFor(taskCalendar(task, calResult)); @@ -1835,7 +1836,7 @@ export function deriveTimephasedContoursForTasks( // verdeelt het werk zich over >1 gelijktijdige toewijzing van DEZELFDE taak), niet "wat is de // datum" — geen tegenspraak met deze weerlegging. // -// SAMENVATTINGSTAKEN: zelfde uitsluiting als Z4 (`task.childIds.length > 0`) — MSP toont geen +// SAMENVATTINGSTAKEN: zelfde semantische uitsluiting als Z4 — MSP toont geen // contour-eigen venster op een WBS-samenvattingstaak, haar datums komen uit de kinderrollup. export interface TimephasedWindowResult { finishFloor: Date | null; // laag 3 @@ -2029,7 +2030,7 @@ export function deriveTimephasedWindowsForTasks( const link = linkByUid.get(uid); if (!link) continue; const task = taskById.get(link.taskId); - if (!task || task.childIds.length > 0) continue; // samenvattingstaak: zelfde uitsluiting als Z4 + if (!task || isSummaryTask(task)) continue; // samenvattingstaak: zelfde uitsluiting als Z4 if (!task.time.scheduleStart) continue; const completion = task.time.completion ?? 0; diff --git a/src/services/mpp/mppTimephased.ts b/src/services/mpp/mppTimephased.ts index ef6c162f5..c17ad7dd7 100644 --- a/src/services/mpp/mppTimephased.ts +++ b/src/services/mpp/mppTimephased.ts @@ -173,7 +173,7 @@ * SAMENVATTINGSTAKEN (Z4-fixronde, punt 4 — gratis geborgd uit de MPXJ-bron): `Task.java`'s * `calculateWorkSplits` begint met `if (getSummary()) return Collections.emptyList();` — "In MS * Project, summary tasks do not show splits" (letterlijke code-commentaar, hier vertaald, niet - * gekopieerd). `mppReader.ts`'s koppelcode filtert daarom `task.childIds.length > 0` uit vóórdat ze + * gekopieerd). `mppReader.ts`'s koppelcode filtert daarom semantische samenvattingen uit vóórdat ze * `Task.splitGaps` zet — spiegelt exact deze MPXJ-regel, niet een eigen aanname. * * TAAK-AS, NIET TOEWIJZINGS-AS (Z4-fixronde, punt 2+3 — CORRECTIE): een eerdere versie van deze diff --git a/src/services/msproject/mspdiWriter.ts b/src/services/msproject/mspdiWriter.ts index 71dc6e36f..80bba370c 100644 --- a/src/services/msproject/mspdiWriter.ts +++ b/src/services/msproject/mspdiWriter.ts @@ -9,6 +9,7 @@ import { projectFileBase } from '@/utils/documents'; import { effectiveCalendarByTask, minutesToClock, minutesToIsoDuration, taskDurationUnitForIo, taskMinutesForWrite, } from '@/services/subdayIo'; +import { isSummaryTask } from '@/utils/taskHierarchy'; import { effectiveWorkTimeBands, calendarForEngine } from '@/utils/effectiveWorkTime'; import { CalendarEngine } from '@/engine/scheduler/CalendarEngine'; import { resolveCalendar } from '@/engine/scheduler/resolveCalendar'; @@ -450,7 +451,7 @@ export function writeMSPDI( for (let i = 0; i < tasks.length; i++) { const task = tasks[i]; const uid = i + 1; - const isSummary = task.childIds.length > 0; + const isSummary = isSummaryTask(task); const isMilestone = task.isMilestone || task.time.scheduleDuration === 0; // Fase 2.8b (§7.3): uur-taak ⇒ Duration als `PT{h}H{m}M0S` uit de minuten; dag-taak ⇒ het diff --git a/src/services/p6/p6xmlReader.ts b/src/services/p6/p6xmlReader.ts index 1b5b4325a..4be3057a6 100644 --- a/src/services/p6/p6xmlReader.ts +++ b/src/services/p6/p6xmlReader.ts @@ -1,4 +1,5 @@ import { Task, TaskConstraint, ConstraintType } from '@/types/task'; +import type { P6DurationType } from '@/types/task'; import { createDefaultTaskTime } from '@/utils/taskDefaults'; import { Sequence, SequenceType } from '@/types/sequence'; import { Resource, ResourceAssignment, ResourceType, ResourceCurve } from '@/types/resource'; @@ -115,13 +116,40 @@ function p6CodeToConstraint(code: string): { type: ConstraintType; hard?: boolea } } +// P6-XML (PMXML) draagt `` als Engels label, niet als het XER-token +// (`DT_FixedDrtn`/`DT_FixedDUR2`/`DT_FixedRate`/`DT_FixedQty`) — zelfde vier canonieke P6-waarden, +// andere schrijfwijze per bronformaat. Labels geverifieerd tegen MPXJ `DurationTypeHelper` +// (LGPL-2.1, lezen-om-te-begrijpen, §4 harde regel) en Oracle's PMXML-schemadocumentatie. +const P6_XML_DURATION_TYPE_BY_LABEL: Readonly> = { + 'Fixed Duration and Units': 'DT_FixedDrtn', + 'Fixed Duration and Units/Time': 'DT_FixedDUR2', + 'Fixed Units/Time': 'DT_FixedRate', + 'Fixed Units': 'DT_FixedQty', +}; + +/** + * Contour-engine-etappe (2026-09), taaktypes-vervolgafspraak: `` is puur data — géén + * solverstap leest dit veld, zelfde eigenaarsbesluit als de XER-lezer's `p6DurationType` + * (`task.ts`'s docblok). Onbekend/leeg label ⇒ veld AFWEZIG (byte-identiek), nooit een aanname; + * de onbekende waarde wordt gerapporteerd zodat een nieuw of vreemd PMXML-label zichtbaar blijft + * in plaats van stil te verdwijnen. + */ +function p6DurationTypeFromXml(raw: string): P6DurationType | undefined { + const label = raw.trim(); + if (!label) return undefined; + const known = P6_XML_DURATION_TYPE_BY_LABEL[label]; + if (known) return known; + console.warn(`P6-XML-import: onbekende -waarde '${label}' — p6DurationType blijft afwezig.`); + return undefined; +} + /** Werkweek teruglezen (fase 2.8a, §8.3, spiegel van `writeStandardWorkWeek`): per * `` de dagnaam terugmappen naar een ISO-dagnummer via `P6_DAY_NAMES`; een dag * telt als werkdag zodra hij een ``-blok heeft. `workStartHour`/`workEndHour` komen van * het LAATST gevonden werktijdblok (één scalar per kalender, bestaande aanname). Golden rule: * geen `` (ander tool / oud bestand) ⇒ lege workDays, aanroeper valt terug op * de `createDefaultCalendar()`-defaults. */ -function parseP6StandardWorkWeek(calEl: Element): { +export function parseP6StandardWorkWeek(calEl: Element): { workDays: number[]; workStartHour?: number; workEndHour?: number; rawByWeekday: Partial>; } { @@ -445,6 +473,7 @@ export function readP6XML(content: string): ImportResult { const name = getElementText(actEl, 'Name') || 'Activity'; const p6Type = getElementText(actEl, 'Type'); const p6Status = getElementText(actEl, 'Status'); + const p6DurationType = p6DurationTypeFromXml(getElementText(actEl, 'DurationType')); const plannedDuration = getElementFloat(actEl, 'PlannedDuration'); const plannedStartRaw = getElementText(actEl, 'PlannedStartDate'); const plannedFinishRaw = getElementText(actEl, 'PlannedFinishDate'); @@ -531,6 +560,7 @@ export function readP6XML(content: string): ImportResult { wbsCode: actId, taskType: customTaskType ? 'USERDEFINED' : 'CONSTRUCTION', ...(customTaskType ? { customTaskTypeId: customTaskType.id } : {}), + ...(p6DurationType ? { p6DurationType } : {}), status, isMilestone, ...(milestoneKind ? { milestoneKind } : {}), diff --git a/src/services/p6/p6xmlWriter.ts b/src/services/p6/p6xmlWriter.ts index 17e84d50a..ac4cdef89 100644 --- a/src/services/p6/p6xmlWriter.ts +++ b/src/services/p6/p6xmlWriter.ts @@ -10,6 +10,7 @@ import { holidayEndDate, WorkCalendar } from '@/types/calendar'; import { effectiveCalendarByTask, minutesToClock, taskMinutesForWrite } from '@/services/subdayIo'; import { effectiveWorkTimeBands } from '@/utils/effectiveWorkTime'; import { projectFileBase } from '@/utils/documents'; +import { isLeafTask, isSummaryTask } from '@/utils/taskHierarchy'; import type { CustomTaskType } from '@/types/taskType'; const OPS_CUSTOM_TASK_TYPE_UDF_TITLE = 'OPS Custom Task Type'; @@ -133,7 +134,7 @@ function taskTypeToP6(task: Task): string { // dode code: mijlpalen hebben altijd duur 0. return task.milestoneKind === 'FINISH' ? 'Finish Milestone' : 'Start Milestone'; } - if (task.childIds.length > 0) return 'WBS Summary'; + if (isSummaryTask(task)) return 'WBS Summary'; return 'Task Dependent'; } @@ -292,6 +293,11 @@ export function writeP6XML( console.warn(`P6-export: ${resumeStopCount} taak/taken met resume/stop (uit-volgorde-hervatting) weggelaten — niet uitdrukbaar in P6-XML (§6).`); } + // X9-besluit: deze adapter is doelbewust asymmetrisch met XER. Een XER-import bewaart zijn + // P6-velden en exacte bron alleen via IFC; dit XML-profiel claimt daarvoor geen equivalent. + // TODO(X9/P6XML): pas na een gevalideerde Oracle-schema-/corpusmapping eventueel individuele + // DurationType/ActivityType/progressvelden lezen/schrijven, met een nieuwe parity-test. + lines.push(''); lines.push(''); @@ -328,8 +334,8 @@ export function writeP6XML( })); // WBS elements (parent tasks) - const wbsTasks = tasks.filter(t => t.childIds.length > 0); - const leafTasks = tasks.filter(t => t.childIds.length === 0); + const wbsTasks = tasks.filter(isSummaryTask); + const leafTasks = tasks.filter(isLeafTask); // Project lines.push(`${indent(1)}`); diff --git a/src/services/print/printPreview.ts b/src/services/print/printPreview.ts index 1896ccd8a..a8982fe72 100644 --- a/src/services/print/printPreview.ts +++ b/src/services/print/printPreview.ts @@ -13,6 +13,7 @@ import { PRINT_PALETTE as PRINT_COLORS } from '@/engine/renderer/themePalette'; import { isCompressedEffective, resolveGanttAxis } from '@/engine/renderer/workdayAxis'; import { computeSplitSegments } from '@/engine/renderer/splitBarGeometry'; import { snapToChoice } from '@/utils/numberChoice'; +import { isLeafTask, isSummaryTask } from '@/utils/taskHierarchy'; // Balkkleurmodi (#21 punt 1-nieuw): pure adviesmodule — de printlaag vertaalt alleen naar // fill/segmenten/outline-aanroepen en houdt zelf geen kleurlogica. import { @@ -844,7 +845,7 @@ export function renderReport( const rowMid = rowTop + m.rowHeight / 2; let px = statusLineX!; const row = printRows[i]; - if (row.kind === 'task' && row.task && !row.task.isMilestone && row.task.childIds.length === 0) { + if (row.kind === 'task' && row.task && !row.task.isMilestone && isLeafTask(row.task)) { const s = parseDate(row.task.time.earlyStart || row.task.time.scheduleStart); const f = parseDate(row.task.time.earlyFinish || row.task.time.scheduleFinish); const bx1 = dateToX(s); @@ -963,7 +964,7 @@ export function renderReport( if (options.showTaskNames) { barLabelJobs.push({ name: task.name, barRightX: x + size, barLeftX: x - size, y: cy + m.s(3), bold: false }); } - } else if (task.childIds.length > 0) { + } else if (isSummaryTask(task)) { // Summary bracket bar const start = parseDate(task.time.earlyStart || task.time.scheduleStart); const end = parseDate(task.time.earlyFinish || task.time.scheduleFinish); @@ -1822,7 +1823,7 @@ function drawTaskTable( // Inspringing per hiërarchieniveau schaalt mee: de naamkolom is breder geworden, dus een vaste // 12 px zou de boomstructuur bij een grote letter optisch platslaan. const indent = depth * m.s(NAME_INDENT_PER_LEVEL); - const isSummary = task.childIds.length > 0; + const isSummary = isSummaryTask(task); // WBS d2d.fillStyle = PRINT_COLORS.textSecondary; diff --git a/src/services/recovery/recoveryDelta.ts b/src/services/recovery/recoveryDelta.ts new file mode 100644 index 000000000..e50fd9abd --- /dev/null +++ b/src/services/recovery/recoveryDelta.ts @@ -0,0 +1,125 @@ +import { sameIFCSource, type IFCSaveSource } from '@/state/ifcSaveInput'; +import type { RecoveryDocContent, RecoveryDocMetadata, RecoverySaveInput } from './recoveryStore'; + +/** Eén open document, precies zoals de auto-save het aan de recoverylaag aanbiedt. */ +export interface RecoverySourceDocument extends RecoveryDocMetadata { + source: IFCSaveSource; +} + +/** De manifeststaat die na een geslaagde opslagronde werkelijk gepersisteerd is. */ +export interface PersistedRecoveryState { + activeDocumentId: string | null; + documents: RecoveryDocMetadata[]; + sources: ReadonlyMap; +} + +export interface RecoveryDelta { + /** Alleen deze documenten moeten opnieuw met `writeIFC` worden geserialiseerd. */ + changedDocuments: RecoverySourceDocument[]; + /** Een actieve tab-, pad- of dirty-metadatawijziging vraagt alleen een manifestschrijf. */ + manifestChanged: boolean; + /** Geen inhouds- én geen metadatawijziging betekent: helemaal geen I/O. */ + needsPersist: boolean; +} + +function sameMetadata(a: readonly RecoveryDocMetadata[], b: readonly RecoveryDocMetadata[]): boolean { + return a.length === b.length && a.every((value, index) => { + const other = b[index]; + return other !== undefined + && value.id === other.id + && value.filePath === other.filePath + && value.isDirty === other.isDirty + // De modusvlag hoort bij de manifestmetadata (critreview laag 3): een documentwissel in of + // uit "datums zoals opgeslagen" moet ook zónder inhoudswijziging een manifestschrijf geven. + && value.datesAsRecorded === other.datesAsRecorded; + }); +} + +/** + * Bepaal de recoverydelta uitsluitend uit de IFC-bronvelden. `isDirty` zit in manifestmetadata + * voor de herstelweergave, maar is uitdrukkelijk GEEN revisie-id: een ongewijzigd document kan + * dirty blijven en een actieve-tabwissel heeft geen nieuwe IFC nodig. + */ +export function planRecoveryDelta( + activeDocumentId: string | null, + documents: readonly RecoverySourceDocument[], + persisted: PersistedRecoveryState | null, +): RecoveryDelta { + const metadata = documents.map(({ id, filePath, isDirty, datesAsRecorded }) => ({ + id, filePath, isDirty, datesAsRecorded, + })); + const changedDocuments = documents.filter((document) => { + const previous = persisted?.sources.get(document.id); + return previous === undefined || !sameIFCSource(previous, document.source); + }); + const manifestChanged = persisted === null + || persisted.activeDocumentId !== activeDocumentId + || !sameMetadata(persisted.documents, metadata); + return { + changedDocuments, + manifestChanged, + needsPersist: manifestChanged || changedDocuments.length > 0, + }; +} + +/** Maak pas na een geslaagde opslagronde een nieuwe persistentiebasis. */ +export function persistedRecoveryState( + activeDocumentId: string | null, + documents: readonly RecoverySourceDocument[], +): PersistedRecoveryState { + return { + activeDocumentId, + documents: documents.map(({ id, filePath, isDirty, datesAsRecorded }) => ({ + id, filePath, isDirty, datesAsRecorded, + })), + sources: new Map(documents.map((document) => [document.id, document.source])), + }; +} + +/** + * Stateful adapter voor de auto-save-hook. `prepare` mag IFC-teksten cachen, maar alleen + * `commit` verschuift de persistentiebasis. De hook roept `commit` pas ná een geslaagde + * `saveRecovery` aan; een quota-/storagefout laat de gewijzigde bron dus verplicht in de volgende + * delta staan in plaats van hem stil als al opgeslagen te behandelen. + */ +export class RecoveryDeltaTracker { + private persisted: PersistedRecoveryState | null = null; + private readonly serialized = new Map(); + + get hasPersistedSnapshot(): boolean { + return this.persisted !== null; + } + + prepare( + activeDocumentId: string | null, + documents: readonly RecoverySourceDocument[], + serialize: (source: IFCSaveSource) => string, + ): RecoverySaveInput | null { + const delta = planRecoveryDelta(activeDocumentId, documents, this.persisted); + if (!delta.needsPersist) return null; + const upserts: RecoveryDocContent[] = delta.changedDocuments.map((document) => { + const cached = this.serialized.get(document.id); + const ifc = cached && sameIFCSource(cached.source, document.source) + ? cached.ifc + : serialize(document.source); + this.serialized.set(document.id, { source: document.source, ifc }); + return { + id: document.id, ifc, filePath: document.filePath, isDirty: document.isDirty, + datesAsRecorded: document.datesAsRecorded, + }; + }); + return { + activeDocumentId, + documents: documents.map(({ id, filePath, isDirty, datesAsRecorded }) => ({ + id, filePath, isDirty, datesAsRecorded, + })), + upserts, + }; + } + + commit(activeDocumentId: string | null, documents: readonly RecoverySourceDocument[]): void { + this.persisted = persistedRecoveryState(activeDocumentId, documents); + const openIds = new Set(documents.map((document) => document.id)); + for (const id of this.serialized.keys()) if (!openIds.has(id)) this.serialized.delete(id); + } +} diff --git a/src/services/recovery/recoveryStore.ts b/src/services/recovery/recoveryStore.ts index 6bde5d7f8..23756d5a4 100644 --- a/src/services/recovery/recoveryStore.ts +++ b/src/services/recovery/recoveryStore.ts @@ -1,5 +1,5 @@ import { isTauri } from '@/utils/platform'; -import { idbGetAll, idbPut, idbDelete } from '@/utils/idb'; +import { idbGetAll, openIdb } from '@/utils/idb'; import { ownRecoveryNames, recoveryTmpSuffix, type RecoveryNames, type RecoveryManifest, type RecoveryManifestDoc, @@ -11,6 +11,47 @@ export interface RecoveryDocContent { ifc: string; filePath: string | null; isDirty: boolean; + /** Zie `RecoveryDocMetadata.datesAsRecorded`. */ + datesAsRecorded: boolean; +} + +/** Metadata die bij élke recoveryronde in het manifest hoort, ook zonder nieuwe IFC-payload. */ +export interface RecoveryDocMetadata { + id: string; + filePath: string | null; + isDirty: boolean; + /** + * Stond dit document in "datums zoals opgeslagen" toen de snapshot werd geschreven? Expliciet + * meegeschreven feit i.p.v. een terugleesheuristiek (critreview laag 3, bevindingen 2/3): de + * datums in de snapshot kunnen toevallig gelijk zijn aan de vastlegging zónder dat de modus + * aanstond — bijvoorbeeld wanneer een bewerking de modus verliet en de auto-save vóór de + * uitgestelde herberekening viel. Zie `RecoveryManifestDoc.datesAsRecorded` voor de + * leesbaarheid van oudere manifesten. + */ + datesAsRecorded: boolean; +} + +/** + * De opslaggrens van recovery: de volledige open-documentlijst is manifestmetadata; `upserts` + * bevat UITSLUITEND de nieuwe of inhoudelijk gewijzigde, volledige IFC-snapshots. Daarmee kan + * een actieve-tab-wissel een manifest-only schrijfactie zijn en herschrijft één taakbewerking + * niet de IFC-payloads van de overige documenten. + */ +export interface RecoverySaveInput { + activeDocumentId: string | null; + documents: RecoveryDocMetadata[]; + upserts: RecoveryDocContent[]; +} + +/** Gebruik uitsluitend in tests en voor expliciete compatibiliteitsmigraties: alles is nieuw. */ +export function fullRecoverySave(activeDocumentId: string | null, docs: RecoveryDocContent[]): RecoverySaveInput { + return { + activeDocumentId, + documents: docs.map(({ id, filePath, isDirty, datesAsRecorded }) => ({ + id, filePath, isDirty, datesAsRecorded, + })), + upserts: docs, + }; } /** Geladen record incl. weergave-mtime (Tauri: bestand-mtime; web: addedAt). */ @@ -32,13 +73,12 @@ export interface LoadedRecovery { const names = ownRecoveryNames; const manifestName = names.manifest; const legacyFile = names.legacy; -const ifcName = names.ifcName; /** Achtervoegsel van het halffabricaat van een atomaire schrijfactie (zie `saveTauri`). */ const TMP_SUFFIX = recoveryTmpSuffix; /** Manifestversie mét eigenaarschapsvelden. Zie `RecoveryManifest` voor de migratieregel. */ -export const RECOVERY_MANIFEST_VERSION = 2; +export const RECOVERY_MANIFEST_VERSION = 4; /** * Id van DEZE app-instantie (proces/realm). Wordt in het manifest gezet zodat een volgende @@ -174,7 +214,10 @@ export function planRecoveryCleanup(input: RecoveryCleanupInput): RecoveryCleanu if (!d || typeof d.ifc !== 'string' || typeof d.id !== 'string') continue; if (keepSet.has(d.ifc)) continue; // zelfde document — onze eigen regel wint if (!present.has(d.ifc)) continue; // snapshot is er niet (meer) - carryOver.push({ id: d.id, ifc: d.ifc, filePath: d.filePath ?? null, isDirty: d.isDirty ?? true }); + carryOver.push({ + id: d.id, ifc: d.ifc, filePath: d.filePath ?? null, isDirty: d.isDirty ?? true, + datesAsRecorded: d.datesAsRecorded ?? false, + }); } } @@ -211,7 +254,82 @@ export function planRecoveryClear( return [...out]; } -async function saveTauri(activeId: string, docs: RecoveryDocContent[]): Promise { +function checkedUpserts(input: RecoverySaveInput): Map { + const documentIds = new Set(); + for (const document of input.documents) { + if (!document.id || documentIds.has(document.id)) { + throw new Error(`Recovery: ongeldige of dubbele manifestdocument-id ${JSON.stringify(document.id)}.`); + } + documentIds.add(document.id); + } + const upserts = new Map(); + for (const document of input.upserts) { + if (!documentIds.has(document.id) || upserts.has(document.id)) { + throw new Error(`Recovery: upsert ${JSON.stringify(document.id)} hoort niet uniek bij het manifest.`); + } + const metadata = input.documents.find((candidate) => candidate.id === document.id)!; + if (document.filePath !== metadata.filePath || document.isDirty !== metadata.isDirty + || document.datesAsRecorded !== metadata.datesAsRecorded) { + throw new Error(`Recovery: upsertmetadata voor ${JSON.stringify(document.id)} wijkt af van het manifest.`); + } + upserts.set(document.id, document); + } + return upserts; +} + +interface TauriSnapshotWrite { + name: string; + ifc: string; +} + +/** + * Maak de v3-manifestvorm vóór er bytes naar schijf gaan. Ongewijzigde documenten houden hun + * vorige snapshotnaam; nieuwe inhoud krijgt altijd een nieuwe immutable generatie. Als een + * document nog geen vorige snapshot heeft, MOET de aanroeper een volledige upsert leveren — een + * manifest dat naar niet-bestaande inhoud wijst is slechter dan geen nieuwe manifestcommit. + */ +export function planTauriV3RecoverySave( + previous: RecoveryManifest | null, + input: RecoverySaveInput, + generation: string, + recoveryNames: RecoveryNames, +): { documents: RecoveryManifestDoc[]; writes: TauriSnapshotWrite[] } { + const upserts = checkedUpserts(input); + const previousById = new Map((previous?.documents ?? []).map((document) => [document.id, document])); + const documents: RecoveryManifestDoc[] = []; + const writes: TauriSnapshotWrite[] = []; + + for (const metadata of input.documents) { + const changed = upserts.get(metadata.id); + if (changed) { + const ifc = recoveryNames.generationIfcName(metadata.id, generation); + documents.push({ + id: metadata.id, ifc, filePath: metadata.filePath, isDirty: metadata.isDirty, + datesAsRecorded: metadata.datesAsRecorded, + }); + writes.push({ name: ifc, ifc: changed.ifc }); + continue; + } + const previousDocument = previousById.get(metadata.id); + if (!previousDocument) { + throw new Error(`Recovery: ${JSON.stringify(metadata.id)} heeft geen vorige snapshot en geen upsert.`); + } + documents.push({ + id: metadata.id, ifc: previousDocument.ifc, filePath: metadata.filePath, + isDirty: metadata.isDirty, datesAsRecorded: metadata.datesAsRecorded, + }); + } + + return { documents, writes }; +} + +let generationCounter = 0; +function nextRecoveryGeneration(): string { + generationCounter += 1; + return `${Date.now().toString(36)}-${generationCounter.toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +async function saveTauri(input: RecoverySaveInput): Promise { const { writeTextFile, readDir, readTextFile, exists, remove, rename, mkdir } = await import('@tauri-apps/plugin-fs'); const { appDataDir, join } = await import('@tauri-apps/api/path'); const dir = await appDataDir(); @@ -254,13 +372,15 @@ async function saveTauri(activeId: string, docs: RecoveryDocContent[]): Promise< console.error('Recovery: kon het bestaande manifest niet lezen vóór het opslaan:', err); } - const keep: string[] = []; - for (const d of docs) { - const name = ifcName(d.id); - await writeAtomic(name, d.ifc); - ownWritten.add(name); - keep.push(name); + // Fase 1 van één generatiecommit: alleen gewijzigde IFC-payloads krijgen een NIEUWE, + // immutable naam. Het oude manifest wijst nog naar de vorige complete generatie zolang dit + // lukt of faalt; een crash kan dus geen half nieuwe documentverzameling publiceren. + const snapshotPlan = planTauriV3RecoverySave(prev, input, nextRecoveryGeneration(), names); + for (const write of snapshotPlan.writes) { + await writeAtomic(write.name, write.ifc); + ownWritten.add(write.name); } + const keep = snapshotPlan.documents.map((document) => document.ifc); let listing: string[] = []; try { @@ -281,16 +401,22 @@ async function saveTauri(activeId: string, docs: RecoveryDocContent[]): Promise< } for (const d of plan.carryOver) adoptedIfc.add(d.ifc); + const currentIds = new Set(snapshotPlan.documents.map((document) => document.id)); const manifest: RecoveryManifest = { version: RECOVERY_MANIFEST_VERSION, - activeDocumentId: activeId, + activeDocumentId: input.activeDocumentId, documents: [ - ...docs.map((d) => ({ id: d.id, ifc: ifcName(d.id), filePath: d.filePath, isDirty: d.isDirty })), - ...plan.carryOver, + ...snapshotPlan.documents, + // Een vreemde instantie kan een doc-id hebben die intussen in onze actieve set voorkomt. + // Onze eigen, expliciete metadata wint dan; dubbele manifestregels zouden loadRecovery + // anders één document tweemaal kunnen herstellen. + ...plan.carryOver.filter((document) => !currentIds.has(document.id)), ], ownerId: instanceId, heartbeatAt: Date.now(), }; + // Fase 2 = het commitpoint. `rename` vervangt het manifest atomair: pas vanaf hier wordt de + // nieuwe verzameling generaties zichtbaar voor loadTauri. Opruimen staat bewust erná. await writeAtomic(manifestName, JSON.stringify(manifest)); for (const name of plan.remove) { @@ -337,14 +463,18 @@ async function scanTauriSnapshots(): Promise { // EXACTE naamvorm (K5): met de oude prefix-vergelijking pikte een productiebuild hier ook de // `recovery...ifc` van elke dev-worktree op. Het legacy-bestand valt hier // automatisch buiten (dat heeft geen doc-id-segment) en heeft zijn eigen terugval hieronder. - const id = name ? names.snapshotDocId(name) : null; + // Alleen stabiele v1/v2-snapshots mogen zonder manifest worden teruggevonden. Een v3- + // generatie die vóór de manifest-rename is geschreven, is nog NIET gecommit en mag bij een + // corrupt/ontbrekend manifest niet per ongeluk als halfnieuwe herstelset verschijnen. + const id = name ? names.stableSnapshotDocId(name) : null; if (!name || !id) continue; try { const path = await join(dir, name); const ifc = await readTextFile(path); let mtime: Date | null = null; try { mtime = (await stat(path)).mtime; } catch { /* geen mtime — laat null */ } - docs.push({ id, ifc, filePath: null, isDirty: true, mtime }); + // Manifestloze scan: geen metadata, dus geen modus — het bestaande #63-aanbod blijft over. + docs.push({ id, ifc, filePath: null, isDirty: true, datesAsRecorded: false, mtime }); } catch (err) { console.error('Recovery: kon gescande snapshot niet lezen:', name, err); } @@ -373,7 +503,11 @@ async function loadTauri(): Promise { const ifc = await readTextFile(ifcPath); let mtime: Date | null = null; try { mtime = (await stat(ifcPath)).mtime; } catch { /* geen mtime — laat null */ } - docs.push({ id: d.id, ifc, filePath: d.filePath ?? null, isDirty: d.isDirty ?? true, mtime }); + docs.push({ + id: d.id, ifc, filePath: d.filePath ?? null, isDirty: d.isDirty ?? true, + // v1–v3-manifest kent het veld niet ⇒ `false` (aanbod, geen modus). + datesAsRecorded: d.datesAsRecorded ?? false, mtime, + }); } catch (err) { console.error('Recovery: kon documentsnapshot niet lezen:', d.id, err); } @@ -397,7 +531,10 @@ async function loadTauri(): Promise { const ifc = await readTextFile(legacyPath); let mtime: Date | null = null; try { mtime = (await stat(legacyPath)).mtime; } catch { /* geen mtime */ } - return { activeDocumentId: 'legacy', docs: [{ id: 'legacy', ifc, filePath: null, isDirty: true, mtime }] }; + return { + activeDocumentId: 'legacy', + docs: [{ id: 'legacy', ifc, filePath: null, isDirty: true, datesAsRecorded: false, mtime }], + }; } return { activeDocumentId: null, docs: [] }; @@ -528,16 +665,21 @@ interface WebDocRecord { sessionId: string; docId: string; ifc: string; - filePath: string | null; - isDirty: boolean; addedAt: number; + /** v1/v2-compatibiliteit: metadata woonde toen nog in elk documentrecord. */ + filePath?: string | null; + isDirty?: boolean; } interface WebManifestRecord { id: string; // `${sid}::manifest` kind: 'manifest'; sessionId: string; activeDocumentId: string | null; - docIds: string[]; + /** v3: alle weergave- en documentmetadata, los van de IFC-payloadrecords. */ + documents?: RecoveryManifestDoc[]; + /** v1/v2-compatibiliteit. */ + docIds?: string[]; + version?: number; addedAt: number; } type WebRecord = WebDocRecord | WebManifestRecord; @@ -545,39 +687,89 @@ type WebRecord = WebDocRecord | WebManifestRecord; const docKey = (sid: string, docId: string): string => `${sid}::doc::${docId}`; const manifestKey = (sid: string): string => `${sid}::manifest`; -async function saveWeb(activeId: string, docs: RecoveryDocContent[]): Promise { +/** + * Schrijf de héle webdelta in ÉÉN strikte IndexedDB readwrite-transactie. Eerst lezen we de + * bestaande records in dezelfde transactie, daarna valideren we alle referenties, schrijven we + * uitsluitend de upserts en ten slotte het manifest en eventuele deletes. Een fout abort de + * transactie; de auto-save promoot zijn in-memory persistentiebasis pas nadat deze promise + * succesvol terugkeert. + */ +async function saveWeb(input: RecoverySaveInput): Promise { const sid = await sessionId(); const now = Date.now(); - const all = await idbGetAll(WEB_DB, WEB_STORE); + const db = await openIdb(WEB_DB, WEB_STORE); + await new Promise((resolve, reject) => { + const tx = db.transaction(WEB_STORE, 'readwrite'); + const store = tx.objectStore(WEB_STORE); + let settled = false; + const fail = (err: unknown) => { + if (settled) return; + settled = true; + reject(err instanceof Error ? err : new Error(String(err))); + }; + tx.oncomplete = () => { + if (!settled) { + settled = true; + resolve(); + } + }; + tx.onerror = () => fail(tx.error ?? new Error('IndexedDB-recoverytransactie mislukt.')); + tx.onabort = () => fail(tx.error ?? new Error('IndexedDB-recoverytransactie afgebroken.')); + + const allRequest = store.getAll(); + allRequest.onerror = () => fail(allRequest.error ?? new Error('IndexedDB-recoveryrecords niet leesbaar.')); + allRequest.onsuccess = () => { + try { + const all = allRequest.result as WebRecord[]; + const upserts = checkedUpserts(input); + const existing = new Set(all + .filter((record): record is WebDocRecord => record.kind === 'doc' && record.sessionId === sid) + .map((record) => record.docId)); + for (const document of input.documents) { + if (!upserts.has(document.id) && !existing.has(document.id)) { + throw new Error(`Recovery: ${JSON.stringify(document.id)} heeft geen webrecord en geen upsert.`); + } + } - // Ruim verweesde vreemde sessies op (ouder dan 7 dagen). - for (const r of all) { - if (r.sessionId !== sid && now - r.addedAt > MAX_AGE_MS) { - await idbDelete(WEB_DB, WEB_STORE, r.id); - } - } + // Alleen nieuwe of inhoudelijk gewijzigde IFC-payloads worden aangeraakt. + for (const document of upserts.values()) { + const record: WebDocRecord = { + id: docKey(sid, document.id), kind: 'doc', sessionId: sid, docId: document.id, + ifc: document.ifc, addedAt: now, + }; + store.put(record); + } - // Schrijf de huidige docs van deze sessie. - for (const d of docs) { - const rec: WebDocRecord = { - id: docKey(sid, d.id), kind: 'doc', sessionId: sid, docId: d.id, - ifc: d.ifc, filePath: d.filePath, isDirty: d.isDirty, addedAt: now, + // Alle metadata hoort bij het manifest. Daardoor is een actieve-tabwissel of alleen een + // pad-/dirtywijziging exact één manifest-write, nooit een serie grote IFC-copies. + const manifest: WebManifestRecord = { + id: manifestKey(sid), kind: 'manifest', sessionId: sid, + version: RECOVERY_MANIFEST_VERSION, + activeDocumentId: input.activeDocumentId, + documents: input.documents.map((document) => ({ + id: document.id, + ifc: docKey(sid, document.id), + filePath: document.filePath, + isDirty: document.isDirty, + datesAsRecorded: document.datesAsRecorded, + })), + addedAt: now, + }; + store.put(manifest); + + const keep = new Set(input.documents.map((document) => docKey(sid, document.id))); + for (const record of all) { + if (record.sessionId !== sid && now - record.addedAt > MAX_AGE_MS) store.delete(record.id); + if (record.sessionId === sid && record.kind === 'doc' && !keep.has(record.id)) store.delete(record.id); + } + } catch (err) { + // Geen gedeeltelijke promotie: alle writes hierboven horen bij dezelfde transactie en + // verdwijnen bij abort. Onze headless dubbel kent geen `abort`, daarom defensief optioneel. + (tx as unknown as { abort?: () => void }).abort?.(); + fail(err); + } }; - await idbPut(WEB_DB, WEB_STORE, rec); - } - const manifest: WebManifestRecord = { - id: manifestKey(sid), kind: 'manifest', sessionId: sid, - activeDocumentId: activeId, docIds: docs.map((d) => d.id), addedAt: now, - }; - await idbPut(WEB_DB, WEB_STORE, manifest); - - // Ruim doc-records van DEZE sessie op die niet meer open zijn. - const keep = new Set(docs.map((d) => docKey(sid, d.id))); - for (const r of all) { - if (r.sessionId === sid && r.kind === 'doc' && !keep.has(r.id)) { - await idbDelete(WEB_DB, WEB_STORE, r.id); - } - } + }); } async function loadWeb(): Promise { @@ -586,28 +778,55 @@ async function loadWeb(): Promise { const manifest = all.find((r) => r.kind === 'manifest' && r.sessionId === sid) as WebManifestRecord | undefined; if (!manifest) return { activeDocumentId: null, docs: [] }; const docs: LoadedRecoveryDoc[] = []; - for (const docId of manifest.docIds) { + const metadata = Array.isArray(manifest.documents) + ? manifest.documents + : (manifest.docIds ?? []).map((id) => ({ + id, ifc: docKey(sid, id), filePath: null, isDirty: true, datesAsRecorded: false, + })); + for (const document of metadata) { + const docId = document.id; const rec = all.find((r) => r.kind === 'doc' && r.id === docKey(sid, docId)) as WebDocRecord | undefined; if (!rec) continue; - docs.push({ id: rec.docId, ifc: rec.ifc, filePath: rec.filePath, isDirty: rec.isDirty, mtime: new Date(rec.addedAt) }); + docs.push({ + id: rec.docId, + ifc: rec.ifc, + filePath: document.filePath ?? rec.filePath ?? null, + isDirty: document.isDirty ?? rec.isDirty ?? true, + // v1–v3-webrecord kent het veld niet ⇒ `false` (aanbod, geen modus). + datesAsRecorded: document.datesAsRecorded ?? false, + mtime: new Date(rec.addedAt), + }); } return { activeDocumentId: manifest.activeDocumentId, docs }; } async function clearWeb(): Promise { const sid = await sessionId(); - const all = await idbGetAll(WEB_DB, WEB_STORE); - for (const r of all) { - if (r.sessionId === sid) await idbDelete(WEB_DB, WEB_STORE, r.id); - } + const db = await openIdb(WEB_DB, WEB_STORE); + await new Promise((resolve, reject) => { + const tx = db.transaction(WEB_STORE, 'readwrite'); + const store = tx.objectStore(WEB_STORE); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + const allRequest = store.getAll(); + allRequest.onerror = () => reject(allRequest.error); + allRequest.onsuccess = () => { + const ours = (allRequest.result as WebRecord[]).filter((record) => record.sessionId === sid); + for (const record of ours) store.delete(record.id); + // Echte IndexedDB committeert ook een lege transactie; de kleine headless dubbel plant dan + // geen `oncomplete`. Alleen voor dat lege, write-loze geval lossen we lokaal op — nooit een + // tweede, losse transaction openen: dat zou de atomische grens breken. + if (ours.length === 0) queueMicrotask(resolve); + }; + }); } // --------------------------------------------------------------------------- // Publieke API — backend-keuze bij runtime. // --------------------------------------------------------------------------- -export function saveRecovery(activeId: string, docs: RecoveryDocContent[]): Promise { - return isTauri() ? saveTauri(activeId, docs) : saveWeb(activeId, docs); +export function saveRecovery(input: RecoverySaveInput): Promise { + return isTauri() ? saveTauri(input) : saveWeb(input); } export function loadRecovery(): Promise { diff --git a/src/services/xer/xerAssignmentProvenance.ts b/src/services/xer/xerAssignmentProvenance.ts new file mode 100644 index 000000000..1f2b84096 --- /dev/null +++ b/src/services/xer/xerAssignmentProvenance.ts @@ -0,0 +1,38 @@ +/** + * Parservrije TASKRSRC-provenanceregels die de X6-producent en X9-archiefvalidator delen. + * De volgorde is gedrag: resource vóór role vóór task, exact zoals P6-assignments worden gelezen. + */ + +export type XerAssignmentSkipReason = + | 'XER_ASSIGNMENT_RESOURCE_MISSING' + | 'XER_ASSIGNMENT_ROLE_MISSING' + | 'XER_ASSIGNMENT_TASK_MISSING'; + +export type XerAssignmentSkipExpectation = XerAssignmentSkipReason | 'LEGACY_TASK_UNKNOWN' | null; + +export function xerAssignmentSourceId( + cells: Readonly>, + line: number, +): string { + return cells.taskrsrc_id?.trim() || `line-${line}`; +} + +export function deriveXerAssignmentSkipExpectation( + cells: Readonly>, + resourceSourceIds: ReadonlySet, + roleSourceIds: ReadonlySet, + taskSourceIds: ReadonlySet | undefined, +): XerAssignmentSkipExpectation { + const resourceSourceId = cells.rsrc_id?.trim() || undefined; + const roleSourceId = cells.role_id?.trim() || undefined; + if (resourceSourceId && !resourceSourceIds.has(resourceSourceId)) { + return 'XER_ASSIGNMENT_RESOURCE_MISSING'; + } + if (!resourceSourceId && !roleSourceIds.has(roleSourceId ?? '')) { + return 'XER_ASSIGNMENT_ROLE_MISSING'; + } + if (!taskSourceIds) return 'LEGACY_TASK_UNKNOWN'; + return taskSourceIds.has(cells.task_id?.trim() ?? '') + ? null + : 'XER_ASSIGNMENT_TASK_MISSING'; +} diff --git a/src/services/xer/xerCalendarData.ts b/src/services/xer/xerCalendarData.ts new file mode 100644 index 000000000..d3cfd2fbf --- /dev/null +++ b/src/services/xer/xerCalendarData.ts @@ -0,0 +1,1009 @@ +/** + * Decoder voor Primavera XER-kalenderdata, zelfstandig hergeïmplementeerd. + * + * Voor begrip van de structured-text-recordvorm en P6-kalendersemantiek is MPXJ geraadpleegd: + * https://github.com/joniles/mpxj (`StructuredTextParser` en `TableContextReader`, LGPL-2.1, + * Jon Iles e.a.). Er is geen MPXJ-code overgenomen; tokenizer, typen en mapping hieronder zijn eigen. + */ + +import { + canonicalizeBands, + promoteHourCalendar, + registerCalendarBands, +} from '@/services/subdayIo'; +import type { Holiday, WorkingException, WorkCalendar, WorkTimeBands } from '@/types/calendar'; +import { + parseXerNumber, + type XerEncoding, + type XerImportReport, + type XerRow, + type XerTables, +} from './xerTables'; + +export interface XerStructuredRecord { + role: XerStructuredRecordRole; + number: string; + name: string; + fields: Record; + children: XerStructuredRecord[]; +} + +export type XerStructuredRecordRole = + | 'ROOT' + | 'CONTAINER' + | 'DAY' + | 'EXCEPTION' + | 'BAND' + | 'RECORD'; + +export interface DecodedXerCalendarData { + bands: WorkTimeBands; + holidays: Holiday[]; + workingExceptions: WorkingException[]; + p6NonWorkPenaltyDates: string[]; + hasExplicitClockBands: boolean; + deviates: boolean; + recoveries: XerCalendarRecovery[]; +} + +export type XerCalendarRecovery = + | 'COMPACT_RECORD' + | 'SURPLUS_CLOSE' + | 'MULTIPLE_EXCEPTIONS' + | 'DUPLICATE_EXCEPTION' + /** Weekend-klemherstel (7b-1): minstens één vrije weekenddag is gereconstrueerd die NIET als + * record in het bestand staat. Eindreview bevinding 7: zonder deze code had de reconstructie + * geen spoor naar de gebruiker, anders dan de holidaynaam. */ + | 'WEEKEND_CLAMP_RECONSTRUCTED'; + +export type XerCalendarIssueCode = + | 'XER_CALENDAR_COMPACT_RECORD_RECOVERED' + | 'XER_CALENDAR_SURPLUS_CLOSE_RECOVERED' + | 'XER_CALENDAR_INVALID_STRUCTURE' + | 'XER_CALENDAR_INVALID_COMPACT_CONTEXT' + | 'XER_CALENDAR_ODD_FIELD_COUNT' + | 'XER_CALENDAR_INVALID_FIELD_PAIR' + | 'XER_CALENDAR_DUPLICATE_FIELD' + | 'XER_CALENDAR_INVALID_DAY' + | 'XER_CALENDAR_DUPLICATE_DAY' + | 'XER_CALENDAR_INVALID_CLOCK' + | 'XER_CALENDAR_NONZERO_SECONDS' + | 'XER_CALENDAR_OVERLAPPING_BANDS' + | 'XER_CALENDAR_INVALID_EPOCH' + | 'XER_CALENDAR_INVALID_PERIOD_HOURS' + | 'XER_CALENDAR_MULTIPLE_EXCEPTIONS_MERGED' + | 'XER_CALENDAR_DUPLICATE_EXCEPTION' + | 'XER_CALENDAR_WEEKEND_CLAMP_RECONSTRUCTED' + | 'XER_CALENDAR_DANGLING_BASE' + | 'XER_CALENDAR_SELF_BASE' + | 'XER_CALENDAR_BASE_CYCLE'; + +export interface XerCalendarIssue { + code: XerCalendarIssueCode; + calendarId: string; + line: number; + reason: string; + resolution: 'RECOVERED' | 'REJECTED' | 'UNLINKED'; +} + +export type XerCalendarType = 'GLOBAL' | 'PROJECT' | 'RESOURCE' | 'UNKNOWN'; + +export interface XerCalendar extends WorkCalendar { + calendarType: XerCalendarType; + rawCalendarType: string; + hoursPerWeek: number; + hoursPerMonth: number; + hoursPerYear: number; + /** Serialiseerbare bron van waarheid voor de optionele basiskalender. */ + baseCalendarId?: string; + /** + * Afgeleide gemakskoppeling binnen een leesresultaat. `readXerCalendars` installeert deze + * bewust niet-enumerable: overdracht en persistentie gebruiken uitsluitend `baseCalendarId`. + */ + baseCalendar?: XerCalendar; + hourModeSource?: 'SHARED' | 'XER_CLOCK'; +} + +export interface XerCalendarReadResult { + calendars: XerCalendar[]; + byId: Map; + issues: XerCalendarIssue[]; + encoding: XerEncoding; + report: XerImportReport; + promotion: { + sharedRules: number; + withXerClockRule: number; + }; +} + +const WEEKDAYS = [1, 2, 3, 4, 5, 6, 7] as const; + +function defaultXerBands(): WorkTimeBands { + return canonicalizeBands({ + 1: [{ start: 480, end: 960 }], + 2: [{ start: 480, end: 960 }], + 3: [{ start: 480, end: 960 }], + 4: [{ start: 480, end: 960 }], + 5: [{ start: 480, end: 960 }], + }).bands; +} + +function p6DayToIso(day: number): 1 | 2 | 3 | 4 | 5 | 6 | 7 { + return (day === 1 ? 7 : day - 1) as 1 | 2 | 3 | 4 | 5 | 6 | 7; +} + +class XerCalendarDataError extends Error { + constructor(readonly code: XerCalendarIssueCode, message: string) { + super(message); + this.name = 'XerCalendarDataError'; + } +} + +function parseXerClock(value: string, position: 'start' | 'finish'): number { + const match = value.trim().match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)?$/i); + if (!match) { + throw new XerCalendarDataError( + 'XER_CALENDAR_INVALID_CLOCK', + 'Een aanwezige kalenderband bevat geen geldige klokwaarde.', + ); + } + let hour = Number(match[1]); + const minute = Number(match[2]); + const second = Number(match[3] ?? '0'); + const meridiem = match[4]?.toUpperCase(); + if (second !== 0) { + throw new XerCalendarDataError( + 'XER_CALENDAR_NONZERO_SECONDS', + 'Niet-nul seconden worden niet stil naar hele minuten afgerond.', + ); + } + if (minute > 59) { + throw new XerCalendarDataError( + 'XER_CALENDAR_INVALID_CLOCK', + 'Een aanwezige kalenderband bevat geen geldige klokwaarde.', + ); + } + if (meridiem) { + if (hour < 1 || hour > 12) { + throw new XerCalendarDataError( + 'XER_CALENDAR_INVALID_CLOCK', + 'Een aanwezige kalenderband bevat geen geldige klokwaarde.', + ); + } + hour = hour % 12 + (meridiem === 'PM' ? 12 : 0); + } else if (hour > 24 || (hour === 24 && (minute !== 0 || position === 'start'))) { + throw new XerCalendarDataError( + 'XER_CALENDAR_INVALID_CLOCK', + 'Een aanwezige kalenderband bevat geen geldige klokwaarde.', + ); + } + return hour * 60 + minute; +} + +function bandsFromRecords(records: readonly XerStructuredRecord[]): { start: number; end: number }[] { + const result: { start: number; end: number }[] = []; + for (const record of records) { + const start = parseXerClock(record.fields.s ?? '', 'start'); + const end = parseXerClock(record.fields.f ?? '', 'finish'); + result.push({ start, end }); + } + return result; +} + +function assertNoBandOverlap(bands: WorkTimeBands): void { + for (const day of WEEKDAYS) { + const dayBands = bands.byWeekday[day]; + for (let index = 1; index < dayBands.length; index++) { + if (dayBands[index - 1].end <= dayBands[index].start) continue; + throw new XerCalendarDataError( + 'XER_CALENDAR_OVERLAPPING_BANDS', + 'Overlappende kalenderbanden worden niet stil doorgegeven.', + ); + } + } +} + +function dateFromP6Epoch(raw: string): string { + if (!/^-?\d+$/.test(raw.trim())) { + throw new XerCalendarDataError( + 'XER_CALENDAR_INVALID_EPOCH', + 'Een aanwezige kalenderuitzondering bevat geen geldige P6-epochdag.', + ); + } + const days = Number(raw); + if (!Number.isSafeInteger(days)) { + throw new XerCalendarDataError( + 'XER_CALENDAR_INVALID_EPOCH', + 'Een aanwezige kalenderuitzondering bevat geen geldige P6-epochdag.', + ); + } + const date = new Date(Date.UTC(1899, 11, 30)); + date.setUTCDate(date.getUTCDate() + days); + if (!Number.isFinite(date.getTime())) { + throw new XerCalendarDataError( + 'XER_CALENDAR_INVALID_EPOCH', + 'Een aanwezige kalenderuitzondering bevat geen geldige P6-epochdag.', + ); + } + return date.toISOString().slice(0, 10); +} + +/** ISO-weekdag (1=ma … 7=zo) van een `YYYY-MM-DD`-datum. */ +function isoWeekdayOf(date: string): 1 | 2 | 3 | 4 | 5 | 6 | 7 { + const day = new Date(`${date}T00:00:00Z`).getUTCDay(); + return (day === 0 ? 7 : day) as 1 | 2 | 3 | 4 | 5 | 6 | 7; +} + +/** `date` verschoven met `offset` kalenderdagen, weer als `YYYY-MM-DD`. */ +function shiftIsoDate(date: string, offset: number): string { + return new Date(Date.parse(`${date}T00:00:00Z`) + offset * 86_400_000).toISOString().slice(0, 10); +} + +/** + * WEEKEND-KLEMHERSTEL (etappe 7b-1) — welke ECHTE vrije dag zit er achter een redundant + * vrije-uitzonderingsrecord, en wanneer mag je dat concluderen? + * + * HET VERSCHIJNSEL (corpus `crawl-xer-extra/jailaff-xer-splitter/rehab-2.xer`, kalenderdata gedeeld + * door 44 kalenders met `842` `R111 - 1` en 75 met `896` `B25 - Cal`: ma-do + za + zo werkend, + * vrijdag vrij). De negen feestdagenblokken zijn aaneengesloten vrije reeksen, maar de opgeslagen + * epochdagen missen elke zaterdag en zondag en herhalen in plaats daarvan de aangrenzende vrijdag of + * maandag. Blok 2008-09-29 … 2008-10-09 staat er letterlijk als `39720 39721 39722 39723 39724 + * 39724 39727 39727 39728 39729 39730` — 10-04 (za) en 10-05 (zo) ontbreken, terwijl 10-03 (vr) en + * 10-06 (ma) er twee keer staan. De schrijver heeft de blokdagen op de MA-VR-as geklemd: + * **zaterdag → de vrijdag ervóór, zondag → de maandag erná.** + * + * DE BRONTOETS. P6's eigen opgeslagen datums bevestigen dat. Meetdefinitie: tel over de 6.548 taken + * die op deze kalenderdata staan alle vier de datumassen (ES, EF, LS én LF), waarbij "IN het blok" + * de kalenderdagen van de GERECONSTRUEERDE blokspanne zijn en "de rand" de drie kalenderdagen direct + * vóór de eerste blokdag plus de drie direct ná de laatste. Uitkomst: + * + * blok (gereconstrueerde spanne) IN RAND (unieke taken) + * 2008-09-29 … 2008-10-09 0 167 (153) + * 2008-12-07 … 2008-12-18 0 214 (193) + * 2009-09-18 … 2009-09-24 0 178 (157) + * 2009-11-26 … 2009-12-05 0 252 (204) + * de vijf blokken buiten de projectperiode + * 0 0 (niet-informatief, en nul effect op de fidelity) + * + * Nul datums ín alle negen blokken tegen 811 in de rand — en die 811 zit volledig op de vier blokken + * binnen de projectperiode (167/214/178/252), terwijl de overige vijf blokken 0 ín én 0 in de rand + * hebben en dus niets bewijzen: de weekenddagen ín de vier actieve blokken zijn aantoonbaar + * niet-werkend. (De 14 EF's die op 2008-12-14 te vinden zijn horen bij kalender `893` `R111 - 2`, + * een 7-daagse kalender zonder uitzonderingen — die wordt hier niet geraakt.) + * + * Tweede, onafhankelijke bevestiging: P6's eigen ES→EF-vensters. Op de OUDE kalender overspande + * `V3109400` (7 dagen duur) 10,00 werkdagen en `V3109300` (21 dagen) 24,00 — intern inconsistent. + * Op de gereconstrueerde kalender tellen ze exact 7,00 en 21,00. + * + * n=1. Deze regel is afgeleid uit één bestand van 93. MPXJ, de referentie-implementatie, kent het + * verschijnsel niet en leest de `Exceptions`-lijst letterlijk + * (`TableContextReader.processCalendarExceptions`). De poort hieronder is daarom bewust + * record-lokaal en conservatief; een tweede bestand moet de regel bevestigen of ontkrachten, niet + * er stil op meeliften. Zie dossier 7b-4 in `docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md`. + * + * DE POORT IS PER RECORD, NIET PER KALENDER. Een klemherstel mag alleen volgen uit bewijs op het + * record zelf, anders maakt één toevallig dubbel record ergens in de lijst stilzwijgend verre + * weekenddagen vrij. Er zijn precies twee lokale bewijsvormen, en beide eisen dat het blok aan de + * andere kant van het weekend doorloopt: + * + * 1. **Sprong** — het record staat op een vrijdag en er is óók een uitzondering op de maandag + * erná (of andersom). Het blok loopt dan over het weekend heen terwijl za/zo niet opgeslagen + * zijn: precies de klemvorm. Dit dekt ook de twee blokken waarvan de openings-vrijdag géén + * duplicaat draagt (2006-12-29 en 2009-09-18). + * 2. **Duplicaat mét blokvervolg** — het record is een aangrenzend duplicaat (twee opeenvolgende + * records met dezelfde epochdag, iets wat in het hele corpus van 93 bestanden alleen in dit + * bestand voorkomt) én het blok loopt aan de binnenzijde door (de dag vóór een vrijdag, of de + * dag ná een maandag, draagt zelf ook een uitzondering). Dit dekt de blokranden waar het + * weekend buiten de opgeslagen reeks valt (2008-12-07 vóór het blok, 2009-12-05 erná). + * + * Een op zichzelf staand dubbel record — de gewone P6-invoerfout waar deze decoder al een + * `DUPLICATE_EXCEPTION`-herstel voor kent — draagt géén blokvervolg en levert dus niets op. Zie de + * negatieve fixtures in `tests/planning/check-xer-calendar-data.ts` sectie 22. + * + * Daarnaast eist `weekendClampTarget` dat het klemdoel (a) daadwerkelijk een zaterdag of zondag is, + * (b) op déze kalender een WERKdag is — anders verandert vrij maken niets — en (c) niet zelf al een + * uitzonderingsrecord heeft. + */ +function weekendClampTarget( + date: string, bands: WorkTimeBands, exceptionDates: ReadonlySet, +): string | null { + const weekday = isoWeekdayOf(date); + // Vrijdag draagt de geklemde zaterdag; maandag draagt de geklemde zondag. + const target = weekday === 5 ? shiftIsoDate(date, 1) + : weekday === 1 ? shiftIsoDate(date, -1) + : null; + if (target === null) return null; + const targetWeekday = isoWeekdayOf(target); + if (targetWeekday !== 6 && targetWeekday !== 7) return null; + if (bands.byWeekday[targetWeekday].length === 0) return null; + if (exceptionDates.has(target)) return null; + return target; +} + +/** + * Hoeveel LOSSE uitzonderingsdatums telt de aaneengesloten reeks rond `date`? Een gat van hoogstens + * drie kalenderdagen breekt de reeks niet — dat gat is nu juist het geklemde weekend. Maximaal + * `LIMIT` stappen per richting, zodat een kalender met honderden losse feestdagen deze telling niet + * tot een volledige scan maakt; het antwoord wordt alleen tegen een ondergrens van 3 getoetst. + */ +function exceptionRunLength(date: string, exceptionDates: ReadonlySet): number { + const LIMIT = 8; + let total = 1; + for (const direction of [-1, 1]) { + let current = date; + for (let step = 0; step < LIMIT; step++) { + const next = [1, 2, 3] + .map(gap => shiftIsoDate(current, direction * gap)) + .find(candidate => exceptionDates.has(candidate)); + if (next === undefined) break; + total++; + current = next; + } + } + return total; +} + +/** + * Draagt DIT record zelf het bewijs van een geklemde weekenddag? Zie `weekendClampTarget`. + * + * Drie eisen, alle drie lokaal op het record en zijn directe omgeving: + * + * 1. **Meerdaags blok.** Een geklemd weekend zit per definitie ín een meerdaags vrij blok; de reeks + * rond dit record telt minstens drie losse uitzonderingsdatums. Zonder deze eis maakt een + * tweedaags feestdagblokje met een per ongeluk verdubbeld vrijdagrecord al een zaterdag vrij + * (fixture 22g), en twee losse feestdagen die toevallig drie dagen uit elkaar liggen ook + * (fixture 22f). In `rehab-2.xer` tellen alle negen blokken 5 tot 11 datums. + * 2. **Blokvervolg aan de van-het-weekend-AFGEKEERDE zijde** — de dag vóór een vrijdag, of de dag + * ná een maandag, draagt zelf ook een uitzondering. Bij de sprongvorm volstaat het dat één van + * de twee sprongpartners dat doet: blok 2006-12-29 … 2007-01-13 opent met een vrijdag zonder + * voorganger maar zijn maandagpartner 2007-01-01 heeft 01-02 naast zich, en blok + * 2009-09-18 … 09-24 idem via maandag 09-21. + * 3. **Een van de twee bewijsvormen**: de sprong over het weekend (vrijdag ↔ maandag, drie + * kalenderdagen uit elkaar), of een aangrenzend duplicaat op het record zelf. + * + * WAT DEZE DRIE EISEN NIET DOEN: ze sluiten de twee GEMETEN false positives (fixtures 22f en 22g), + * niet de hele klasse. Voeg aan 22f één dag toe — vrijdag 2009-12-04, maandag 12-07 én dinsdag + * 12-08 als losse feestdagen op een za-do-kalender — en de reeks haalt de drempel van drie, de + * maandag heeft blokvervolg aan zijn afgekeerde zijde, en zaterdag 12-05 wordt alsnog vrijgemaakt + * (zondag 12-06 niet, want een maandagrecord op deze kalender is geen redundant record). Het risico + * is empirisch klein — nul treffers in 93 corpusbestanden buiten `rehab-2.xer` — maar het is geen + * nul, en de poort is een filter op bewijskracht, geen sluitend bewijs. + */ +function hasWeekendClampEvidence( + date: string, exceptionDates: ReadonlySet, adjacentDuplicates: ReadonlySet, +): boolean { + const weekday = isoWeekdayOf(date); + if (weekday !== 1 && weekday !== 5) return false; + if (exceptionRunLength(date, exceptionDates) < 3) return false; + const continuesAwayFromWeekend = (candidate: string): boolean => + exceptionDates.has(shiftIsoDate(candidate, isoWeekdayOf(candidate) === 5 ? -1 : 1)); + // Bewijsvorm 1 — de sprong over het weekend, mits het blok aan minstens één afgekeerde zijde doorloopt. + const partner = shiftIsoDate(date, weekday === 5 ? 3 : -3); + if (exceptionDates.has(partner) + && (continuesAwayFromWeekend(date) || continuesAwayFromWeekend(partner))) return true; + // Bewijsvorm 2 — aangrenzend duplicaat, mits het blok aan de eigen afgekeerde zijde doorloopt. + return adjacentDuplicates.has(date) && continuesAwayFromWeekend(date); +} + +function calendarType(raw: string): XerCalendarType { + switch (raw.trim().toLowerCase()) { + case 'ca_base': return 'GLOBAL'; + case 'ca_project': return 'PROJECT'; + case 'ca_rsrc': return 'RESOURCE'; + default: return 'UNKNOWN'; + } +} + +function periodHours(row: XerRow, field: string, tables: XerTables): number | undefined { + const raw = row.cells[field]?.trim() ?? ''; + if (!raw) return undefined; + try { + const value = parseXerNumber(raw, tables.numberFormat); + if (value !== null && value >= 0) return value; + } catch { + // De kalenderlaag vertaalt getalfouten naar één rijgebonden semantisch issue. + } + throw new XerCalendarDataError( + 'XER_CALENDAR_INVALID_PERIOD_HOURS', + 'Aanwezige uren-per-periode zijn ongeldig en worden niet stil afgeleid.', + ); +} + +function actualWeekHours(bands: WorkTimeBands): { week: number; workingDays: number } { + let minutes = 0; + let workingDays = 0; + for (const day of WEEKDAYS) { + const dayBands = bands.byWeekday[day]; + if (dayBands.length === 0) continue; + workingDays++; + minutes += dayBands.reduce((sum, band) => sum + band.end - band.start, 0); + } + return { week: minutes / 60, workingDays }; +} + +function scalarBounds(bands: WorkTimeBands): { start: number; end: number } { + for (const day of WEEKDAYS) { + const dayBands = bands.byWeekday[day]; + if (dayBands.length === 0) continue; + return { + start: Math.floor(dayBands[0].start / 60), + end: Math.floor((dayBands[dayBands.length - 1].end % 1440) / 60), + }; + } + return { start: 8, end: 16 }; +} + +type XerStructuredParseContext = + | 'ROOT' + | 'ROOT_CHILD' + | 'DAY' + | 'EXCEPTION' + | 'BAND' + | 'GENERIC'; + +class StructuredTextTokenizer { + private index = 0; + usedCompactRecord = false; + + constructor( + private readonly text: string, + private readonly allowCompactRecords = false, + ) {} + + parse(): XerStructuredRecord { + this.skipFormatting(); + const record = this.readRecord('ROOT'); + this.skipFormatting(); + if (this.index !== this.text.length) { + throw new Error('Ongeldige XER-kalenderdata: onverwachte tekst na het hoofdrecord.'); + } + return record; + } + + private readRecord(context: XerStructuredParseContext): XerStructuredRecord { + this.expect('('); + if (this.allowCompactRecords && this.text.startsWith('s|', this.index)) { + if (context !== 'BAND') this.invalidCompactContext(); + this.usedCompactRecord = true; + this.expect('s|'); + const start = this.readUntil('|'); + this.expect('|f|'); + const finish = this.readUntil(')'); + this.expect(')'); + return { role: 'BAND', number: '', name: '', fields: { s: start, f: finish }, children: [] }; + } + const number = this.readWhile(char => /\d/.test(char)); + if (!number) throw new Error('Ongeldige XER-kalenderdata: recordnummer ontbreekt.'); + this.expect('||'); + if (this.allowCompactRecords && this.text.startsWith('d|', this.index)) { + if (context !== 'EXCEPTION') this.invalidCompactContext(); + this.usedCompactRecord = true; + this.expect('d|'); + const epoch = this.readUntil('('); + this.expect('('); + const children: XerStructuredRecord[] = []; + this.skipFormatting(); + while (this.peek() === '(') { + children.push(this.readRecord('BAND')); + this.skipFormatting(); + } + this.expect(')'); + this.expect(')'); + return { role: 'EXCEPTION', number, name: '', fields: { d: epoch }, children }; + } + const name = this.readUntil('('); + this.expect('('); + const fields = this.readFields(); + this.expect(')'); + this.skipFormatting(); + this.expect('('); + const children: XerStructuredRecord[] = []; + const childContext: XerStructuredParseContext = context === 'ROOT' + ? 'ROOT_CHILD' + : context === 'ROOT_CHILD' && name === 'DaysOfWeek' + ? 'DAY' + : context === 'ROOT_CHILD' && name === 'Exceptions' + ? 'EXCEPTION' + : context === 'DAY' || context === 'EXCEPTION' + ? 'BAND' + : 'GENERIC'; + this.skipFormatting(); + while (this.peek() === '(') { + children.push(this.readRecord(childContext)); + this.skipFormatting(); + } + this.expect(')'); + this.expect(')'); + if (this.allowCompactRecords && context === 'EXCEPTION' + && !Object.prototype.hasOwnProperty.call(fields, 'd')) { + this.invalidCompactContext(); + } + const role: XerStructuredRecordRole = context === 'ROOT' + ? 'ROOT' + : context === 'ROOT_CHILD' && (name === 'DaysOfWeek' || name === 'Exceptions') + ? 'CONTAINER' + : context === 'DAY' || context === 'EXCEPTION' || context === 'BAND' + ? context + : 'RECORD'; + return { role, number, name, fields, children }; + } + + private invalidCompactContext(): never { + throw new XerCalendarDataError( + 'XER_CALENDAR_INVALID_COMPACT_CONTEXT', + 'Een compact kalenderrecord staat niet op zijn toegestane grammaticale positie.', + ); + } + + private readFields(): Record { + const result: Record = {}; + if (this.peek() === ')') return result; + const tokens: string[] = []; + while (true) { + tokens.push(this.readUntil('|', ')')); + if (this.peek() === ')') break; + this.expect('|'); + } + if (tokens.length % 2 !== 0) { + throw new XerCalendarDataError( + 'XER_CALENDAR_ODD_FIELD_COUNT', + 'Een kalenderrecord bevat geen volledige key/value-paren.', + ); + } + for (let index = 0; index + 1 < tokens.length; index += 2) { + const field = tokens[index]; + if (!field) { + throw new XerCalendarDataError( + 'XER_CALENDAR_INVALID_FIELD_PAIR', + 'Een kalenderrecord bevat een key/value-paar zonder sleutel.', + ); + } + if (Object.prototype.hasOwnProperty.call(result, field)) { + throw new XerCalendarDataError( + 'XER_CALENDAR_DUPLICATE_FIELD', + 'Een kalenderrecord bevat dezelfde veldsleutel meer dan eenmaal.', + ); + } + result[field] = tokens[index + 1]; + } + return result; + } + + private readUntil(...stops: string[]): string { + const start = this.index; + while (this.index < this.text.length && !stops.includes(this.text[this.index])) this.index++; + if (this.index >= this.text.length) { + throw new Error('Ongeldige XER-kalenderdata: onverwacht einde van structured text.'); + } + return this.text.slice(start, this.index).trim(); + } + + private readWhile(predicate: (char: string) => boolean): string { + const start = this.index; + while (this.index < this.text.length && predicate(this.text[this.index])) this.index++; + return this.text.slice(start, this.index); + } + + private skipFormatting(): void { + while (this.index < this.text.length) { + const code = this.text.charCodeAt(this.index); + if (!/\s/.test(this.text[this.index]) && code > 31 && code !== 127) break; + this.index++; + } + } + + private peek(): string { + return this.text[this.index] ?? ''; + } + + private expect(token: string): void { + if (!this.text.startsWith(token, this.index)) { + throw new Error(`Ongeldige XER-kalenderdata: ${JSON.stringify(token)} verwacht.`); + } + this.index += token.length; + } +} + +/** Tokenize en parse één Primavera structured-text-hoofdrecord. */ +export function parseXerStructuredText(text: string): XerStructuredRecord { + return new StructuredTextTokenizer(text).parse(); +} + +function parseCalendarDataWithRecovery(text: string): { + root: XerStructuredRecord; + recoveries: XerCalendarRecovery[]; +} { + try { + return { root: parseXerStructuredText(text), recoveries: [] }; + } catch (strictError) { + if (strictError instanceof XerCalendarDataError) throw strictError; + const compact = new StructuredTextTokenizer(text, true); + try { + const root = compact.parse(); + if (compact.usedCompactRecord) return { root, recoveries: ['COMPACT_RECORD'] }; + } catch (compactError) { + if (compactError instanceof XerCalendarDataError) throw compactError; + // De compacte herstelroute is exact begrensd; de oorspronkelijke fout blijft leidend. + } + const surplusCloseMatches = Array.from(text.matchAll(/\)(\s*)(?=\(0\|\|Exceptions\()/g)); + if (surplusCloseMatches.length === 1) { + const matchIndex = surplusCloseMatches[0].index; + const recovered = `${text.slice(0, matchIndex)}${text.slice(matchIndex + 1)}`; + try { + return { root: parseXerStructuredText(recovered), recoveries: ['SURPLUS_CLOSE'] }; + } catch { + // Alleen exact één surplus-haak die daarna volledig strikt parseert, wordt hersteld. + } + } + throw new XerCalendarDataError( + 'XER_CALENDAR_INVALID_STRUCTURE', + strictError instanceof Error ? strictError.message : 'Ongeldige XER-kalenderdata.', + ); + } +} + +/** Decodeer de structured-text-inhoud van één `CALENDAR.clndr_data`-cel. */ +export function decodeXerCalendarData(text: string): DecodedXerCalendarData { + if (!text.trim()) { + return { + bands: defaultXerBands(), + holidays: [], + workingExceptions: [], + p6NonWorkPenaltyDates: [], + hasExplicitClockBands: false, + deviates: false, + recoveries: [], + }; + } + + const { root, recoveries } = parseCalendarDataWithRecovery(text); + const dayContainers = root.children.filter(record => record.name === 'DaysOfWeek'); + if (dayContainers.length !== 1) { + throw new XerCalendarDataError( + 'XER_CALENDAR_INVALID_STRUCTURE', + 'Aanwezige kalenderdata mist precies één DaysOfWeek-record.', + ); + } + const days = dayContainers[0]; + const raw: Partial> = {}; + let hasExplicitClockBands = false; + const seenDays = new Set(); + for (const day of days.children) { + if (!/^\d+$/.test(day.name)) { + throw new XerCalendarDataError( + 'XER_CALENDAR_INVALID_DAY', + 'Een aanwezige weekdag is geen P6-dagnummer van 1 tot en met 7.', + ); + } + const p6Day = Number(day.name); + if (!WEEKDAYS.includes(p6Day as (typeof WEEKDAYS)[number])) { + throw new XerCalendarDataError( + 'XER_CALENDAR_INVALID_DAY', + 'Een aanwezige weekdag is geen P6-dagnummer van 1 tot en met 7.', + ); + } + if (seenDays.has(p6Day)) { + throw new XerCalendarDataError( + 'XER_CALENDAR_DUPLICATE_DAY', + 'Een P6-weekdag komt meer dan eenmaal voor in dezelfde kalender.', + ); + } + seenDays.add(p6Day); + const dayBands = bandsFromRecords(day.children); + if (dayBands.length > 0) hasExplicitClockBands = true; + raw[p6DayToIso(p6Day)] = dayBands; + } + const canonical = canonicalizeBands(raw); + const bands = canonical.bands; + assertNoBandOverlap(bands); + + const holidaysByDate = new Map(); + const workingByDate = new Map(); + const p6NonWorkPenaltyDates = new Set(); + const exceptionDates = new Set(); + const adjacentDuplicateDates = new Set(); + let duplicateException = false; + let previousNonWorkDate: string | undefined; + const exceptionContainers = root.children.filter(record => record.name === 'Exceptions'); + if (exceptionContainers.length > 1) recoveries.push('MULTIPLE_EXCEPTIONS'); + for (const exception of exceptionContainers.flatMap(container => container.children)) { + const date = dateFromP6Epoch(exception.fields.d ?? ''); + exceptionDates.add(date); + const canonicalException = canonicalizeBands({ 1: bandsFromRecords(exception.children) }).bands; + assertNoBandOverlap(canonicalException); + const exceptionBands = canonicalException.byWeekday[1]; + const nonWork = exceptionBands.length === 0; + if (nonWork) { + const day = new Date(`${date}T00:00:00Z`).getUTCDay(); + const isoDay = (day === 0 ? 7 : day) as 1 | 2 | 3 | 4 | 5 | 6 | 7; + // P6 6.x/7.x rekent twee redundante recordvormen als één extra niet-werkdag: een vrije + // uitzondering op een weekdag die al geen banden draagt, en een DIRECT aangrenzende vrije + // herhaling. Een later opnieuw voorkomende datum telt niet: rehab-2 bevat zo'n herhaald + // overlappend exceptionblok en P6 telt uitsluitend de aangrenzende duplicaten daarin. + if (bands.byWeekday[isoDay].length === 0 || previousNonWorkDate === date) { + p6NonWorkPenaltyDates.add(date); + } + if (previousNonWorkDate === date) adjacentDuplicateDates.add(date); + previousNonWorkDate = date; + } else { + previousNonWorkDate = undefined; + } + if (holidaysByDate.has(date) || workingByDate.has(date)) duplicateException = true; + if (exceptionBands.length > 0) { + holidaysByDate.delete(date); + if (!workingByDate.has(date)) { + workingByDate.set(date, { + name: 'Calendar exception', startDate: date, endDate: date, bands: exceptionBands, + }); + } + } else if (!workingByDate.has(date) && !holidaysByDate.has(date)) { + holidaysByDate.set(date, { name: 'Calendar exception', startDate: date, endDate: date }); + } + } + // Een werkende uitzondering wint ook voor de brongebonden P6-straf. De datum draagt dan echte + // banden en is geen redundante vrije-dagrecord meer. + for (const date of workingByDate.keys()) p6NonWorkPenaltyDates.delete(date); + // Weekend-klemherstel (7b-1), poort per RECORD. Zie `weekendClampTarget`/`hasWeekendClampEvidence` + // voor de twee lokale bewijsvormen en de brontoets. Zonder bewijs op het record zelf gebeurt er + // niets, dus 92 van de 93 corpusbestanden zijn hier byte-identiek. + let weekendClampReconstructed = false; + for (const date of [...p6NonWorkPenaltyDates]) { + if (!hasWeekendClampEvidence(date, exceptionDates, adjacentDuplicateDates)) continue; + const target = weekendClampTarget(date, bands, exceptionDates); + if (target === null) continue; + holidaysByDate.set(target, { + name: 'Calendar exception (weekend reconstruction)', startDate: target, endDate: target, + }); + weekendClampReconstructed = true; + // De dag is nu ECHT vrij; hem daarnaast nog als virtuele extra niet-werkdag laten meetellen zou + // dezelfde afwezigheid twee keer verrekenen in elke duur- en floatwandeling. + p6NonWorkPenaltyDates.delete(date); + } + if (duplicateException) recoveries.push('DUPLICATE_EXCEPTION'); + if (weekendClampReconstructed) recoveries.push('WEEKEND_CLAMP_RECONSTRUCTED'); + + return { + bands, + holidays: Array.from(holidaysByDate.values()), + workingExceptions: Array.from(workingByDate.values()), + p6NonWorkPenaltyDates: Array.from(p6NonWorkPenaltyDates).sort(), + hasExplicitClockBands, + deviates: hasExplicitClockBands && canonical.deviates, + recoveries, + }; +} + +function cyclicCalendarIds( + calendars: readonly XerCalendar[], + byId: ReadonlyMap, +): Set { + const state = new Map(); + const cycles = new Set(); + for (const start of calendars) { + if (state.get(start.id) === 'DONE') continue; + const path: XerCalendar[] = []; + const pathIndex = new Map(); + let current: XerCalendar | undefined = start; + while (current) { + const currentState = state.get(current.id); + if (currentState === 'DONE') break; + if (currentState === 'VISITING') { + const cycleStart = pathIndex.get(current.id); + if (cycleStart !== undefined) { + for (let index = cycleStart; index < path.length; index++) { + cycles.add(path[index].id); + } + } + break; + } + state.set(current.id, 'VISITING'); + pathIndex.set(current.id, path.length); + path.push(current); + const baseId: string | undefined = current.baseCalendarId; + current = baseId && baseId !== current.id ? byId.get(baseId) : undefined; + } + for (const calendar of path) state.set(calendar.id, 'DONE'); + } + return cycles; +} + +function linkBaseCalendar(calendar: XerCalendar, base: XerCalendar): void { + Object.defineProperty(calendar, 'baseCalendar', { + configurable: true, + enumerable: false, + value: base, + writable: true, + }); +} + +/** Bouw XER-kalenders uitsluitend uit X2's reeds gedecodeerde `CALENDAR`-tabel. */ +export function readXerCalendars(tables: XerTables): XerCalendarReadResult { + const rows = tables.tables.get('CALENDAR')?.rows ?? []; + const calendars: XerCalendar[] = []; + const lineByCalendarId = new Map(); + const issues: XerCalendarIssue[] = []; + for (const row of rows) { + const calendarId = row.cells.clndr_id.trim(); + let decoded: DecodedXerCalendarData; + try { + decoded = decodeXerCalendarData(row.cells.clndr_data ?? ''); + } catch (error) { + if (!(error instanceof XerCalendarDataError)) throw error; + issues.push({ + code: error.code, + calendarId, + line: row.line, + reason: error.message, + resolution: 'REJECTED', + }); + continue; + } + const actual = actualWeekHours(decoded.bands); + let explicitDay: number | undefined; + let explicitWeek: number | undefined; + let explicitMonth: number | undefined; + let explicitYear: number | undefined; + try { + explicitDay = periodHours(row, 'day_hr_cnt', tables); + explicitWeek = periodHours(row, 'week_hr_cnt', tables); + explicitMonth = periodHours(row, 'month_hr_cnt', tables); + explicitYear = periodHours(row, 'year_hr_cnt', tables); + } catch (error) { + if (!(error instanceof XerCalendarDataError)) throw error; + issues.push({ + code: error.code, + calendarId, + line: row.line, + reason: error.message, + resolution: 'REJECTED', + }); + continue; + } + const hoursPerWeek = explicitWeek ?? actual.week; + const hoursPerDay = explicitDay + ?? (actual.workingDays > 0 ? actual.week / actual.workingDays : 8); + const hoursPerMonth = explicitMonth ?? hoursPerWeek * 4; + const hoursPerYear = explicitYear ?? hoursPerMonth * 12; + const bounds = scalarBounds(decoded.bands); + const workDays = WEEKDAYS.filter(day => decoded.bands.byWeekday[day].length > 0); + const baseCalendarId = row.cells.base_clndr_id?.trim() || undefined; + const rawCalendarType = row.cells.clndr_type?.trim() ?? ''; + const calendar: XerCalendar = { + id: row.cells.clndr_id.trim(), + name: row.cells.clndr_name?.trim() || 'Kalender', + description: '', + workDays: [...workDays], + workStartHour: bounds.start, + workEndHour: bounds.end, + hoursPerDay, + holidays: decoded.holidays, + ...(decoded.workingExceptions.length > 0 + ? { workingExceptions: decoded.workingExceptions } + : {}), + ...(explicitDay === undefined && decoded.p6NonWorkPenaltyDates.length > 0 + ? { + p6Source: 'XER' as const, + p6NonWorkPenaltyDates: decoded.p6NonWorkPenaltyDates, + p6NonWorkPenaltyDatesState: 'VALID_VALUES' as const, + } + : {}), + calendarType: calendarType(rawCalendarType), + rawCalendarType, + hoursPerWeek, + hoursPerMonth, + hoursPerYear, + ...(baseCalendarId ? { baseCalendarId } : {}), + }; + const bandInfo = { canonical: decoded.bands, deviates: decoded.deviates }; + registerCalendarBands(calendar, bandInfo); + const hourModeSource = decoded.deviates + ? 'SHARED' + : decoded.hasExplicitClockBands + ? 'XER_CLOCK' + : undefined; + if (hourModeSource && promoteHourCalendar(calendar, bandInfo, true, false)) { + calendar.hourModeSource = hourModeSource; + // Periodevelden in XER zijn leidend; promotie mag alleen de uurweergave toevoegen. + calendar.hoursPerDay = hoursPerDay; + } + calendars.push(calendar); + lineByCalendarId.set(calendarId, row.line); + if (decoded.recoveries.includes('COMPACT_RECORD')) { + issues.push({ + code: 'XER_CALENDAR_COMPACT_RECORD_RECOVERED', + calendarId, + line: row.line, + reason: 'De exact ondersteunde compacte band- en exceptionrecords zijn hersteld.', + resolution: 'RECOVERED', + }); + } + if (decoded.recoveries.includes('SURPLUS_CLOSE')) { + issues.push({ + code: 'XER_CALENDAR_SURPLUS_CLOSE_RECOVERED', + calendarId, + line: row.line, + reason: 'Precies één surplus-sluithaak vóór het Exceptions-record is verwijderd.', + resolution: 'RECOVERED', + }); + } + if (decoded.recoveries.includes('DUPLICATE_EXCEPTION')) { + issues.push({ + code: 'XER_CALENDAR_DUPLICATE_EXCEPTION', + calendarId, + line: row.line, + reason: 'Een werkende uitzondering wint van een vrije dag; binnen dezelfde klasse blijft de eerste staan.', + resolution: 'RECOVERED', + }); + } + if (decoded.recoveries.includes('MULTIPLE_EXCEPTIONS')) { + issues.push({ + code: 'XER_CALENDAR_MULTIPLE_EXCEPTIONS_MERGED', + calendarId, + line: row.line, + reason: 'Meerdere Exceptions-containers zijn deterministisch in bronvolgorde samengevoegd.', + resolution: 'RECOVERED', + }); + } + if (decoded.recoveries.includes('WEEKEND_CLAMP_RECONSTRUCTED')) { + issues.push({ + code: 'XER_CALENDAR_WEEKEND_CLAMP_RECONSTRUCTED', + calendarId, + line: row.line, + reason: 'Vrije weekenddagen die de P6-schrijver op de vrijdag ervóór of de maandag erná klemde, zijn als vrije dag gereconstrueerd; ze staan niet als record in het bestand.', + resolution: 'RECOVERED', + }); + } + } + + const byId = new Map(calendars.map(calendar => [calendar.id, calendar])); + const cycleIds = cyclicCalendarIds(calendars, byId); + for (const calendar of calendars) { + const baseId = calendar.baseCalendarId; + if (!baseId) continue; + const line = lineByCalendarId.get(calendar.id) ?? 0; + if (baseId === calendar.id) { + issues.push({ + code: 'XER_CALENDAR_SELF_BASE', + calendarId: calendar.id, + line, + reason: 'Een kalender kan zichzelf niet als basiskalender koppelen.', + resolution: 'UNLINKED', + }); + continue; + } + const base = byId.get(baseId); + if (!base) { + issues.push({ + code: 'XER_CALENDAR_DANGLING_BASE', + calendarId: calendar.id, + line, + reason: 'De opgegeven basiskalender ontbreekt in het gelezen kalenderresultaat.', + resolution: 'UNLINKED', + }); + continue; + } + if (cycleIds.has(calendar.id)) { + issues.push({ + code: 'XER_CALENDAR_BASE_CYCLE', + calendarId: calendar.id, + line, + reason: 'De basiskalenderverwijzing maakt deel uit van een cyclus en blijft ongekoppeld.', + resolution: 'UNLINKED', + }); + continue; + } + linkBaseCalendar(calendar, base); + } + + return { + calendars, + byId, + issues, + encoding: tables.report.encoding, + report: tables.report, + promotion: { + sharedRules: calendars.filter(calendar => calendar.hourModeSource === 'SHARED').length, + withXerClockRule: calendars.filter(calendar => calendar.workTime !== undefined).length, + }, + }; +} diff --git a/src/services/xer/xerInstant.ts b/src/services/xer/xerInstant.ts new file mode 100644 index 000000000..3673b27a6 --- /dev/null +++ b/src/services/xer/xerInstant.ts @@ -0,0 +1,22 @@ +import { formatInstant, parseInstant } from '@/utils/dateUtils'; + +/** + * Parseer een rauwe XER-datum-/tijdcel naar dezelfde canonieke instant-representatie als de rest + * van de motor: `YYYY-MM-DD` in dagmodus, `YYYY-MM-DDTHH:mm` in uurmodus. Een lege of onparseerbare + * cel levert `undefined` — nooit een verzonnen datum. + * + * Verhuisd uit `xerReader.ts` naar een eigen, afhankelijkheidsloze module zodat zowel de + * hoofdlezer als `xerRecordedTimes.ts` (BAK 4 — de zes P6-rekenuitvoerkolommen, uitsluitend + * weergave/meetlat, nooit solverinvoer, XER-etappeplan §4.1-bijstelling 2026-09-04) exact dezelfde + * functie gebruiken zonder dat `xerRecordedTimes.ts` uit `xerReader.ts` — die op zijn beurt + * `xerRecordedTimes.ts` aanroept — hoeft te importeren. Dat zou anders een cyclus tussen die twee + * modules zijn (`npm run verify:cycles`). + */ +export function sourceInstant(raw: string, hourMode: boolean): string | undefined { + const value = raw.trim(); + if (!value) return undefined; + const normalized = value.replace(' ', 'T'); + const parsed = parseInstant(normalized); + if (Number.isNaN(parsed.getTime())) return undefined; + return formatInstant(parsed, hourMode ? 'hour' : 'day'); +} diff --git a/src/services/xer/xerMetadata.ts b/src/services/xer/xerMetadata.ts new file mode 100644 index 000000000..d6023f5d0 --- /dev/null +++ b/src/services/xer/xerMetadata.ts @@ -0,0 +1,371 @@ +/** + * Bestandsbrede X8-mapping voor P6-activitycodes, UDF's en taaknotities. + * + * De catalogus wordt precies één keer uit X2-tabellen afgeleid vóór X4b zijn documenten maakt. + * De projectview hieronder doet daarna uitsluitend lookup en kleine, mutable documentkopieën. + */ +import type { ActivityCodeType, CustomFieldDef, CustomFieldType, CustomFieldValue } from '@/types/structure'; +import { formatDate, parseInstant } from '@/utils/dateUtils'; +import { parseXerNumber, type XerRow, type XerTables } from './xerTables'; +import type { + XerMetadataCatalog, XerMetadataIssue, XerMetadataIssueCode, XerMetadataProjectView, + XerMetadataTaskProjection, XerTaskMetadata, +} from './xerMetadataTypes'; + +export type { + XerMetadataCatalog, XerMetadataIssue, XerMetadataIssueCode, XerMetadataProjectView, + XerMetadataTaskProjection, XerTaskMetadata, +} from './xerMetadataTypes'; + +const ISSUE_CODES = [ + 'XER_ACTIVITY_CODE_MISSING_TYPE_ID', 'XER_ACTIVITY_CODE_MISSING_VALUE_ID', + 'XER_ACTIVITY_CODE_DUPLICATE_TYPE_ID', 'XER_ACTIVITY_CODE_DUPLICATE_VALUE_ID', + 'XER_ACTIVITY_CODE_DUPLICATE_LINK', 'XER_ACTIVITY_CODE_DANGLING_TYPE_PARENT', + 'XER_ACTIVITY_CODE_DANGLING_VALUE_PARENT', 'XER_ACTIVITY_CODE_DANGLING_TASK', + 'XER_ACTIVITY_CODE_DANGLING_TYPE', 'XER_ACTIVITY_CODE_DANGLING_VALUE', + 'XER_UDF_MISSING_TYPE_ID', 'XER_UDF_DUPLICATE_TYPE_ID', 'XER_UDF_DUPLICATE_VALUE', + 'XER_UDF_DANGLING_TYPE', 'XER_UDF_DANGLING_ENTITY', 'XER_UDF_AMBIGUOUS_TASK', + 'XER_UDF_UNKNOWN_DATA_TYPE', 'XER_UDF_INVALID_VALUE', 'XER_UDF_DEFERRED_ENTITY', + 'XER_NOTE_DUPLICATE_MEMO_ID', 'XER_NOTE_DANGLING_MEMO_TYPE', 'XER_NOTE_DANGLING_TASK', 'XER_NOTE_AMBIGUOUS_TASK', +] as const satisfies readonly XerMetadataIssueCode[]; + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} +function rowKey(row: XerRow): string { + return Object.entries(row.cells).sort(([a], [b]) => compareText(a, b)) + .map(([field, value]) => `${field}\u001f${value}`).join('\u001e'); +} +function compareRows(left: XerRow, right: XerRow): number { + return compareText(rowKey(left), rowKey(right)) || left.line - right.line; +} +function sequence(row: XerRow): number { + const value = Number(row.cells.seq_num); + return Number.isFinite(value) ? value : 0; +} +function issueState(): Record { + return Object.fromEntries(ISSUE_CODES.map(code => [code, 0])) as Record; +} +function addIssue( + issues: XerMetadataIssue[], counts: Record, issue: XerMetadataIssue, +): void { + issues.push(issue); + counts[issue.code]++; +} + +function stableHierarchy(rows: readonly XerRow[], idField: string, parentField: string): XerRow[] { + const byId = new Map(rows.map(row => [row.cells[idField]!, row])); + const children = new Map(); + const roots: XerRow[] = []; + for (const row of rows) { + const parent = row.cells[parentField]?.trim() ?? ''; + if (!parent || !byId.has(parent)) roots.push(row); + else { + const value = children.get(parent) ?? []; + value.push(row); children.set(parent, value); + } + } + const compare = (a: XerRow, b: XerRow) => sequence(a) - sequence(b) + || compareText(a.cells[idField]!, b.cells[idField]!); + const result: XerRow[] = []; + const visit = (row: XerRow) => { + result.push(row); + for (const child of (children.get(row.cells[idField]!) ?? []).sort(compare)) visit(child); + }; + for (const root of roots.sort(compare)) visit(root); + const seen = new Set(result.map(row => row.cells[idField]!)); + result.push(...rows.filter(row => !seen.has(row.cells[idField]!)).sort(compare)); + return result; +} + +interface TaskOwner { projectId: string; taskId: string; row: XerRow; } +function taskOwners(tables: XerTables): ReadonlyMap { + const byTaskId = new Map(); + for (const row of tables.tables.get('TASK')?.rows ?? []) { + const projectId = row.cells.proj_id?.trim() ?? ''; + const taskId = row.cells.task_id?.trim() ?? ''; + if (!projectId || !taskId) continue; + const owners = byTaskId.get(taskId) ?? []; + owners.push({ projectId, taskId, row }); byTaskId.set(taskId, owners); + } + return byTaskId; +} + +function resolveTask( + row: XerRow, taskId: string, owners: ReadonlyMap, + issueCode: 'XER_ACTIVITY_CODE_DANGLING_TASK' | 'XER_UDF_DANGLING_ENTITY' | 'XER_NOTE_DANGLING_TASK', + ambiguousCode: 'XER_UDF_AMBIGUOUS_TASK' | 'XER_NOTE_AMBIGUOUS_TASK' | undefined, + table: XerMetadataIssue['table'], issues: XerMetadataIssue[], counts: Record, +): TaskOwner | undefined { + const candidates = owners.get(taskId) ?? []; + const explicitProject = row.cells.proj_id?.trim(); + if (explicitProject) { + const exact = candidates.find(candidate => candidate.projectId === explicitProject); + if (exact) return exact; + } else if (candidates.length === 1) return candidates[0]; + if (candidates.length === 0 || !ambiguousCode) addIssue(issues, counts, { code: issueCode, table, line: row.line }); + else addIssue(issues, counts, { code: ambiguousCode, table, line: row.line }); + return undefined; +} + +function ensureProjection( + projections: Map, owner: TaskOwner, +): XerMetadataTaskProjection { + const key = `${owner.projectId}\u001f${owner.taskId}`; + const existing = projections.get(key); + if (existing) return existing; + const created: XerMetadataTaskProjection = { projectId: owner.projectId, taskId: owner.taskId }; + projections.set(key, created); + return created; +} + +function uniqueById( + rows: readonly XerRow[], idField: string, table: 'ACTVTYPE' | 'ACTVCODE' | 'UDFTYPE', + missing: XerMetadataIssueCode, duplicate: XerMetadataIssueCode, + issues: XerMetadataIssue[], counts: Record, +): Map { + const selected = new Map(); + for (const row of rows) { + const id = row.cells[idField]?.trim() ?? ''; + if (!id) { addIssue(issues, counts, { code: missing, table, line: row.line }); continue; } + const previous = selected.get(id); + if (!previous) { selected.set(id, row); continue; } + selected.set(id, compareRows(previous, row) <= 0 ? previous : row); + addIssue(issues, counts, { code: duplicate, table, line: row.line, lines: [previous.line, row.line].sort((a, b) => a - b) }); + } + return selected; +} + +function mapActivityCodes( + tables: XerTables, owners: ReadonlyMap, + projections: Map, issues: XerMetadataIssue[], counts: Record, +): ActivityCodeType[] { + const types = uniqueById(tables.tables.get('ACTVTYPE')?.rows ?? [], 'actv_code_type_id', 'ACTVTYPE', + 'XER_ACTIVITY_CODE_MISSING_TYPE_ID', 'XER_ACTIVITY_CODE_DUPLICATE_TYPE_ID', issues, counts); + const values = uniqueById(tables.tables.get('ACTVCODE')?.rows ?? [], 'actv_code_id', 'ACTVCODE', + 'XER_ACTIVITY_CODE_MISSING_VALUE_ID', 'XER_ACTIVITY_CODE_DUPLICATE_VALUE_ID', issues, counts); + const valuesByType = new Map(); + for (const row of types.values()) { + const parent = row.cells.parent_actv_code_type_id?.trim() ?? ''; + if (parent && !types.has(parent)) addIssue(issues, counts, { code: 'XER_ACTIVITY_CODE_DANGLING_TYPE_PARENT', table: 'ACTVTYPE', line: row.line }); + } + for (const row of values.values()) { + const parent = row.cells.parent_actv_code_id?.trim() ?? ''; + if (parent && !values.has(parent)) addIssue(issues, counts, { code: 'XER_ACTIVITY_CODE_DANGLING_VALUE_PARENT', table: 'ACTVCODE', line: row.line }); + const typeId = row.cells.actv_code_type_id?.trim() ?? ''; + if (!types.has(typeId)) { addIssue(issues, counts, { code: 'XER_ACTIVITY_CODE_DANGLING_TYPE', table: 'ACTVCODE', line: row.line }); continue; } + const grouped = valuesByType.get(typeId) ?? []; + grouped.push(row); valuesByType.set(typeId, grouped); + } + const selected = new Map>(); + for (const row of tables.tables.get('TASKACTV')?.rows ?? []) { + const taskId = row.cells.task_id?.trim() ?? ''; + const owner = resolveTask(row, taskId, owners, 'XER_ACTIVITY_CODE_DANGLING_TASK', undefined, 'TASKACTV', issues, counts); + if (!owner) continue; + const typeId = row.cells.actv_code_type_id?.trim() ?? ''; + const valueId = row.cells.actv_code_id?.trim() ?? ''; + if (!types.has(typeId)) { addIssue(issues, counts, { code: 'XER_ACTIVITY_CODE_DANGLING_TYPE', table: 'TASKACTV', line: row.line }); continue; } + if (values.get(valueId)?.cells.actv_code_type_id?.trim() !== typeId) { + addIssue(issues, counts, { code: 'XER_ACTIVITY_CODE_DANGLING_VALUE', table: 'TASKACTV', line: row.line }); continue; + } + const key = `${owner.projectId}\u001f${owner.taskId}`; + const forTask = selected.get(key) ?? new Map(); + const previous = forTask.get(typeId); + if (previous) { + addIssue(issues, counts, { code: 'XER_ACTIVITY_CODE_DUPLICATE_LINK', table: 'TASKACTV', line: row.line, lines: [previous.row.line, row.line].sort((a, b) => a - b) }); + if (compareText(valueId, previous.valueId) < 0) forTask.set(typeId, { valueId, row }); + } else forTask.set(typeId, { valueId, row }); + selected.set(key, forTask); + } + for (const [key, links] of selected) { + const [projectId, taskId] = key.split('\u001f'); + const projection = ensureProjection(projections, { projectId: projectId!, taskId: taskId!, row: {} as XerRow }); + projection.activityCodes = Object.fromEntries([...links.entries()].sort(([a], [b]) => compareText(a, b)).map(([type, link]) => [type, link.valueId])); + } + return stableHierarchy([...types.values()], 'actv_code_type_id', 'parent_actv_code_type_id').map(type => ({ + id: type.cells.actv_code_type_id!, name: type.cells.actv_code_type || type.cells.actv_code_type_name || type.cells.actv_code_type_id!, + values: stableHierarchy(valuesByType.get(type.cells.actv_code_type_id!) ?? [], 'actv_code_id', 'parent_actv_code_id') + .map(value => ({ id: value.cells.actv_code_id!, code: value.cells.short_name || value.cells.actv_code_name || value.cells.actv_code_id!, ...(value.cells.actv_code_name ? { description: value.cells.actv_code_name } : {}) })), + })); +} + +function udfType(raw: string): CustomFieldType | undefined { + const token = raw.trim().toUpperCase().replace(/[\s-]+/g, '_'); + if (['FT_TEXT', 'FT_STATICTYPE', 'TEXT', 'STRING'].includes(token)) return 'text'; + if (['FT_FLOAT', 'FT_FLOAT_2_DECIMALS', 'FT_NUMBER', 'FLOAT', 'DOUBLE', 'NUMBER', 'NUMERIC', 'DECIMAL'].includes(token)) return 'number'; + if (['FT_INT', 'FT_INTEGER', 'INTEGER', 'INT'].includes(token)) return 'integer'; + if (['FT_COST', 'FT_MONEY', 'MONEY', 'CURRENCY', 'COST'].includes(token)) return 'cost'; + if (['FT_DATE', 'FT_START_DATE', 'FT_END_DATE', 'DATE', 'DATETIME'].includes(token)) return 'date'; + if (['FT_BOOLEAN', 'FT_INDICATOR', 'BOOLEAN', 'BOOL', 'INDICATOR'].includes(token)) return 'boolean'; + return undefined; +} +function udfValue(tables: XerTables, row: XerRow, type: CustomFieldType): CustomFieldValue | undefined { + if (type === 'text') return row.cells.udf_text === '' ? undefined : row.cells.udf_text; + if (type === 'boolean') { + const raw = (row.cells.udf_text || row.cells.udf_number || '').trim().toUpperCase(); + return ['Y', 'YES', 'TRUE', '1'].includes(raw) ? true : ['N', 'NO', 'FALSE', '0'].includes(raw) ? false : undefined; + } + if (type === 'date') { + const raw = row.cells.udf_date?.trim() ?? ''; + const parsed = parseInstant(raw.replace(' ', 'T')); + return raw && !Number.isNaN(parsed.getTime()) ? formatDate(parsed) : undefined; + } + try { + const value = parseXerNumber(row.cells.udf_number ?? '', tables.numberFormat); + return type !== 'integer' || value === null || Number.isInteger(value) ? value ?? undefined : undefined; + } catch { return undefined; } +} + +function mapUdfs( + tables: XerTables, owners: ReadonlyMap, projections: Map, + issues: XerMetadataIssue[], counts: Record, +): { definitions: CustomFieldDef[]; deferred: XerRow[]; unknown: XerRow[] } { + const typeRows = uniqueById(tables.tables.get('UDFTYPE')?.rows ?? [], 'udf_type_id', 'UDFTYPE', + 'XER_UDF_MISSING_TYPE_ID', 'XER_UDF_DUPLICATE_TYPE_ID', issues, counts); + const known = new Map(); + const unknown: XerRow[] = []; + for (const [id, row] of typeRows) { + const type = udfType(row.cells.logical_data_type || row.cells.udf_type || ''); + if (type) known.set(id, { row, type }); + else { unknown.push(row); addIssue(issues, counts, { code: 'XER_UDF_UNKNOWN_DATA_TYPE', table: 'UDFTYPE', line: row.line }); } + } + const deferred: XerRow[] = []; + const selected = new Map(); + for (const row of tables.tables.get('UDFVALUE')?.rows ?? []) { + const typeId = row.cells.udf_type_id?.trim() ?? ''; + const definition = typeRows.get(typeId); + if (!definition) { addIssue(issues, counts, { code: 'XER_UDF_DANGLING_TYPE', table: 'UDFVALUE', line: row.line }); continue; } + const entity = definition.cells.table_name?.trim().toUpperCase() || 'UNKNOWN'; + const entityId = (row.cells.fk_id || row.cells.task_id || '').trim(); + const key = `${typeId}\u001f${entity}\u001f${row.cells.proj_id?.trim() ?? ''}\u001f${entityId}`; + const previous = selected.get(key); + if (previous) addIssue(issues, counts, { code: 'XER_UDF_DUPLICATE_VALUE', table: 'UDFVALUE', line: row.line, lines: [previous.line, row.line].sort((a, b) => a - b) }); + if (!previous || compareRows(row, previous) < 0) selected.set(key, row); + } + for (const row of selected.values()) { + const typeId = row.cells.udf_type_id!.trim(); + const info = known.get(typeId); + const entity = typeRows.get(typeId)?.cells.table_name?.trim().toUpperCase() || 'UNKNOWN'; + if (entity !== 'TASK') { deferred.push(row); addIssue(issues, counts, { code: 'XER_UDF_DEFERRED_ENTITY', table: 'UDFVALUE', line: row.line }); continue; } + if (!info) continue; + const owner = resolveTask(row, (row.cells.fk_id || row.cells.task_id || '').trim(), owners, + 'XER_UDF_DANGLING_ENTITY', 'XER_UDF_AMBIGUOUS_TASK', 'UDFVALUE', issues, counts); + if (!owner) continue; + const value = udfValue(tables, row, info.type); + if (value === undefined && (row.cells.udf_text || row.cells.udf_number || row.cells.udf_date)) { + addIssue(issues, counts, { code: 'XER_UDF_INVALID_VALUE', table: 'UDFVALUE', line: row.line }); continue; + } + if (value !== undefined) { + const projection = ensureProjection(projections, owner); + projection.customFields = { ...(projection.customFields ?? {}), [typeId]: value }; + } + } + // Het bestaande OPS-taakmodel kent alleen taakvelden; alle andere definities/waarden blijven + // volledig in de catalogus tot X9 ze op hun eigen entiteit kan round-trippen. + const definitions = [...known.entries()].filter(([, info]) => info.row.cells.table_name?.trim().toUpperCase() === 'TASK') + .sort(([a], [b]) => compareText(a, b)).map(([id, info]) => ({ id, name: info.row.cells.udf_type_label || info.row.cells.udf_type_name || id, type: info.type })); + return { definitions, deferred, unknown }; +} + +function mapNotes( + tables: XerTables, owners: ReadonlyMap, projections: Map, + issues: XerMetadataIssue[], counts: Record, +): void { + const append = (owner: TaskOwner, note: NonNullable[number]) => { + const projection = ensureProjection(projections, owner); + projection.notes = [...(projection.notes ?? []), note]; + }; + // De notitievolgorde is een bestandscontract: TASK.task_notes eerst, daarna TASKNOTE en dan + // TASKMEMO; binnen elke brontabel blijft de fysieke %R-volgorde onaangeroerd. Zo is geen + // seq_num-interpretatie of deduplicatie nodig om gebruikersnotities stil te herschrijven. + for (const row of tables.tables.get('TASK')?.rows ?? []) { + if (!row.cells.task_notes) continue; + const owner = resolveTask(row, row.cells.task_id?.trim() ?? '', owners, 'XER_NOTE_DANGLING_TASK', 'XER_NOTE_AMBIGUOUS_TASK', 'TASK', issues, counts); + if (!owner) continue; + append(owner, { id: `xer-note:task:${owner.taskId}`, text: row.cells.task_notes, done: false }); + } + for (const row of tables.tables.get('TASKNOTE')?.rows ?? []) { + if (!row.cells.task_notes) continue; + const owner = resolveTask(row, row.cells.task_id?.trim() ?? '', owners, 'XER_NOTE_DANGLING_TASK', 'XER_NOTE_AMBIGUOUS_TASK', 'TASKNOTE', issues, counts); + if (!owner) continue; + append(owner, { id: `xer-note:tasknote:${row.line}`, text: row.cells.task_notes, done: false }); + } + const memoTypes = new Set((tables.tables.get('MEMOTYPE')?.rows ?? []).map(row => row.cells.memo_type_id?.trim()).filter(Boolean)); + const firstMemoById = new Map(); + for (const row of tables.tables.get('TASKMEMO')?.rows ?? []) { + const id = row.cells.memo_id?.trim() || `line-${row.line}`; + const previous = firstMemoById.get(id); + if (previous) addIssue(issues, counts, { code: 'XER_NOTE_DUPLICATE_MEMO_ID', table: 'TASKMEMO', line: row.line, lines: [previous.line, row.line].sort((a, b) => a - b) }); + else firstMemoById.set(id, row); + } + for (const row of tables.tables.get('TASKMEMO')?.rows ?? []) { + if (!row.cells.task_memo) continue; + if (!memoTypes.has(row.cells.memo_type_id?.trim() ?? '')) { + addIssue(issues, counts, { code: 'XER_NOTE_DANGLING_MEMO_TYPE', table: 'TASKMEMO', line: row.line }); + } + const owner = resolveTask(row, row.cells.task_id?.trim() ?? '', owners, 'XER_NOTE_DANGLING_TASK', 'XER_NOTE_AMBIGUOUS_TASK', 'TASKMEMO', issues, counts); + if (!owner) continue; + append(owner, { id: `xer-note:memo:${row.cells.memo_id?.trim() || 'line'}:${row.line}`, text: row.cells.task_memo, done: false }); + } +} + +function freezeCatalog(value: T, seen = new Set()): T { + if (value === null || typeof value !== 'object' || Object.isFrozen(value)) return value; + const object = value as object; + if (seen.has(object)) return value; + seen.add(object); + for (const key of Reflect.ownKeys(object)) freezeCatalog((object as Record)[key], seen); + Object.freeze(object); + return value; +} + +/** Bouw eenmaal de gehele X8-catalogus, inclusief baselineprojecttaken die X4b niet als document opent. */ +export function buildXerMetadataCatalog(tables: XerTables): XerMetadataCatalog { + const issues: XerMetadataIssue[] = []; + const counts = issueState(); + const projections = new Map(); + const owners = taskOwners(tables); + const activityCodeTypes = mapActivityCodes(tables, owners, projections, issues, counts); + const udf = mapUdfs(tables, owners, projections, issues, counts); + mapNotes(tables, owners, projections, issues, counts); + const taskProjections = [...projections.values()].sort((a, b) => compareText(a.projectId, b.projectId) || compareText(a.taskId, b.taskId)); + const taskProjectionsByProject = Object.create(null) as Record; + for (const projection of taskProjections) { + (taskProjectionsByProject[projection.projectId] ??= []).push(projection); + } + const catalog: XerMetadataCatalog = { + activityCodeTypes, customFieldDefs: udf.definitions, + taskProjections, taskProjectionsByProject, + issues, issueCounts: counts, + sourceData: { + ACTVTYPE: tables.tables.get('ACTVTYPE')?.rows ?? [], ACTVCODE: tables.tables.get('ACTVCODE')?.rows ?? [], + TASKACTV: tables.tables.get('TASKACTV')?.rows ?? [], UDFTYPE: tables.tables.get('UDFTYPE')?.rows ?? [], + UDFVALUE: tables.tables.get('UDFVALUE')?.rows ?? [], MEMOTYPE: tables.tables.get('MEMOTYPE')?.rows ?? [], + TASKNOTE: tables.tables.get('TASKNOTE')?.rows ?? [], + TASKMEMO: tables.tables.get('TASKMEMO')?.rows ?? [], + TASK_NOTES: (tables.tables.get('TASK')?.rows ?? []).filter(row => Boolean(row.cells.task_notes)), + deferredUdfValues: udf.deferred, unknownUdfTypes: udf.unknown, + }, + }; + return freezeCatalog(catalog); +} + +/** Maak kleine, muteerbare documentdata zonder ooit de bestandsbrede raw-catalogus te kopiëren. */ +export function materializeXerMetadata(catalog: XerMetadataCatalog, projectId: string): XerMetadataProjectView { + const taskMetadata = new Map(); + const projectProjections = catalog.taskProjectionsByProject[projectId] ?? []; + let visitedTaskProjectionCount = 0; + for (const projection of projectProjections) { + visitedTaskProjectionCount++; + taskMetadata.set(projection.taskId, { + ...(projection.activityCodes ? { activityCodes: { ...projection.activityCodes } } : {}), + ...(projection.customFields ? { customFields: { ...projection.customFields } } : {}), + ...(projection.notes ? { notes: projection.notes.map(note => ({ ...note })) } : {}), + }); + } + return { + activityCodeTypes: catalog.activityCodeTypes.map(type => ({ ...type, values: type.values.map(value => ({ ...value })) })), + customFieldDefs: catalog.customFieldDefs.map(definition => ({ ...definition })), taskMetadata, + visitedTaskProjectionCount, + }; +} diff --git a/src/services/xer/xerMetadataTypes.ts b/src/services/xer/xerMetadataTypes.ts new file mode 100644 index 000000000..7247f14ce --- /dev/null +++ b/src/services/xer/xerMetadataTypes.ts @@ -0,0 +1,86 @@ +import type { ActivityCodeType, CustomFieldDef, CustomFieldValue } from '@/types/structure'; +import type { Task } from '@/types/task'; +import type { XerRow } from './xerTables'; +import type { XerReadonly } from './xerResourceTypes'; + +export type XerMetadataIssueCode = + | 'XER_ACTIVITY_CODE_MISSING_TYPE_ID' + | 'XER_ACTIVITY_CODE_MISSING_VALUE_ID' + | 'XER_ACTIVITY_CODE_DUPLICATE_TYPE_ID' + | 'XER_ACTIVITY_CODE_DUPLICATE_VALUE_ID' + | 'XER_ACTIVITY_CODE_DUPLICATE_LINK' + | 'XER_ACTIVITY_CODE_DANGLING_TYPE_PARENT' + | 'XER_ACTIVITY_CODE_DANGLING_VALUE_PARENT' + | 'XER_ACTIVITY_CODE_DANGLING_TASK' + | 'XER_ACTIVITY_CODE_DANGLING_TYPE' + | 'XER_ACTIVITY_CODE_DANGLING_VALUE' + | 'XER_UDF_MISSING_TYPE_ID' + | 'XER_UDF_DUPLICATE_TYPE_ID' + | 'XER_UDF_DUPLICATE_VALUE' + | 'XER_UDF_DANGLING_TYPE' + | 'XER_UDF_DANGLING_ENTITY' + | 'XER_UDF_AMBIGUOUS_TASK' + | 'XER_UDF_UNKNOWN_DATA_TYPE' + | 'XER_UDF_INVALID_VALUE' + | 'XER_UDF_DEFERRED_ENTITY' + | 'XER_NOTE_DUPLICATE_MEMO_ID' + | 'XER_NOTE_DANGLING_MEMO_TYPE' + | 'XER_NOTE_DANGLING_TASK' + | 'XER_NOTE_AMBIGUOUS_TASK'; + +export interface XerMetadataIssue { + code: XerMetadataIssueCode; + table: 'ACTVTYPE' | 'ACTVCODE' | 'TASKACTV' | 'UDFTYPE' | 'UDFVALUE' | 'TASK' | 'TASKNOTE' | 'TASKMEMO'; + line: number; + lines?: number[]; +} + +export interface XerTaskMetadata { + activityCodes?: Record; + customFields?: Record; + notes?: NonNullable; +} + +export interface XerMetadataTaskProjection extends XerTaskMetadata { + projectId: string; + taskId: string; +} + +/** + * Eén immutable, bestandsbreed X8-catalogusobject. De raw tabellen zijn rechtstreeks de reeds + * bevroren X2-rijarrays: geen tweede celkopie, ook niet voor rehab-2's TASKACTV-massa. X9 wordt + * eigenaar van de uiteindelijke byte-archivering; X8 bewaart hier de huidige broninformatie. + */ +interface XerMetadataCatalogShape { + activityCodeTypes: ActivityCodeType[]; + customFieldDefs: CustomFieldDef[]; + taskProjections: XerMetadataTaskProjection[]; + /** Diep bevroren projectindex; materialisatie bezoekt uitsluitend deze projectgroep. */ + taskProjectionsByProject: Record; + issues: XerMetadataIssue[]; + issueCounts: Record; + sourceData: { + ACTVTYPE: XerRow[]; + ACTVCODE: XerRow[]; + TASKACTV: XerRow[]; + UDFTYPE: XerRow[]; + UDFVALUE: XerRow[]; + MEMOTYPE: XerRow[]; + TASKNOTE: XerRow[]; + TASKMEMO: XerRow[]; + TASK_NOTES: XerRow[]; + deferredUdfValues: XerRow[]; + unknownUdfTypes: XerRow[]; + }; +} + +/** X6's ene canonieke recursieve readonly-grens, nu over de volledige X8-catalogusgrafiek. */ +export type XerMetadataCatalog = XerReadonly; + +export interface XerMetadataProjectView { + activityCodeTypes: ActivityCodeType[]; + customFieldDefs: CustomFieldDef[]; + taskMetadata: ReadonlyMap; + /** Deterministische complexiteitspin: exact het aantal bezochte projecties uit de projectindex. */ + visitedTaskProjectionCount: number; +} diff --git a/src/services/xer/xerMultiProject.ts b/src/services/xer/xerMultiProject.ts new file mode 100644 index 000000000..b92015827 --- /dev/null +++ b/src/services/xer/xerMultiProject.ts @@ -0,0 +1,352 @@ +import type { XerReadResult } from './xerReader'; +import type { + MultiDocumentImport, + XerBaselineFallbackReason, + XerDocumentExternalLink, + XerImportReport, +} from '@/services/importTypes'; +import { XerImportError, type XerRow, type XerTables } from './xerTables'; +import type { Baseline } from '@/types/baseline'; +import type { Task } from '@/types/task'; +import { isLeafTask } from '@/utils/taskHierarchy'; + +export interface XerMultiProjectDocument { + projectId: string; + result: XerReadResult; +} + +export type XerMultiProjectReport = XerImportReport; +export type { XerBaselineFallbackReason, XerDocumentExternalLink } from '@/services/importTypes'; + +export interface XerMultiProjectImport extends MultiDocumentImport { + documents: XerMultiProjectDocument[]; + /** Alle niet-lege, door de productreader gemapte taakprojecten, óók projecten die X-O2 alleen + * als baseline materialiseert en daarom niet als document openen. Dit is echte solverinvoer, + * geen P6-uitvoer/orakelprojectie; productfidelity kan zo iedere taakdragende bron exact één + * keer oplossen zonder de gebruikerszichtbare documentselectie te veranderen. */ + taskProjects: XerMultiProjectDocument[]; + activeProjectId: string | null; + /** Cross-projectbrondata tussen geopende documenten; bewust geen Sequence/Task.externalLinks. */ + externalLinks: XerDocumentExternalLink[]; + report: XerMultiProjectReport; +} + +export type XerProjectMapper = (projectId: string) => XerReadResult; + +interface BaselineDecision { + excludedProjectIds: Set; + reverted: boolean; + reasons: XerBaselineFallbackReason[]; +} + +/** Clone de projectpayload zonder het bestandsbrede raw-archief P keer te materialiseren. */ +function cloneMappedProject(result: XerReadResult): XerReadResult { + const scheduleOptions = result.xer.scheduleOptions; + const archive = scheduleOptions.sourceArchive; + const resourceMetadata = result.xer.resources; + const metadata = result.xer.metadata; + const { resources: _resources, metadata: _metadata, ...xerWithoutCatalogs } = result.xer; + const clone = structuredClone({ + ...result, + xer: { + ...xerWithoutCatalogs, + scheduleOptions: { + ...scheduleOptions, + sourceArchive: { rows: [], unmatchedScheduleOptionsRowIndexes: [], diagnostics: [] }, + sourceRows: [], + }, + }, + }) as XerReadResult; + clone.xer.scheduleOptions.sourceArchive = archive; + clone.xer.scheduleOptions.sourceRows = clone.xer.scheduleOptions.sourceRowIndexes + .map(rowIndex => archive.rows[rowIndex]); + // Raw TASKRSRC kan in rehab-2 52.640 rijen beslaan. Catalogus en raw projectview zijn bewust + // referentie-identiek; structuredClone is uitsluitend voor de mutable documentprojectie. + if (resourceMetadata) clone.xer.resources = resourceMetadata; + if (metadata) clone.xer.metadata = metadata; + return clone; +} + +function assertUniqueProjectIds(projectRows: readonly XerRow[]): void { + const firstLineById = new Map(); + for (const projectRow of projectRows) { + const projectId = projectRow.cells.proj_id.trim(); + const firstLine = firstLineById.get(projectId); + if (firstLine !== undefined) { + throw new XerImportError( + 'XER_DUPLICATE_ID', + `PROJECT.proj_id bevat dubbele id '${projectId}' op regels ${firstLine} en ${projectRow.line}.`, + { + table: 'PROJECT', + field: 'proj_id', + line: projectRow.line, + lines: [firstLine, projectRow.line], + }, + ); + } + firstLineById.set(projectId, projectRow.line); + } +} + +function hasNonSelfCycle(targetByProject: ReadonlyMap): boolean { + const done = new Set(); + for (const startProjectId of targetByProject.keys()) { + if (done.has(startProjectId)) continue; + const path = new Set(); + let projectId: string | undefined = startProjectId; + while (projectId && !done.has(projectId)) { + if (path.has(projectId)) return true; + path.add(projectId); + const target = targetByProject.get(projectId); + projectId = target && target !== projectId ? target : undefined; + } + for (const visitedProjectId of path) done.add(visitedProjectId); + } + return false; +} + +/** + * Beslis welke aanwezige baselineprojecten als gewone documenten worden uitgesloten. + * + * Alleen niet-lege projecten kunnen eigenaar van een baselinekoppeling zijn: een PROJECT-rij die + * zelf wordt overgeslagen mag geen ander, niet-leeg project uit de openlijst drukken. De aldus + * begrensde uitsluiting is alleen veilig voor een acyclische hoofdproject→baselinegraaf die + * minstens één niet-leeg gewoon document overlaat. Een zelfverwijzing, een wederzijdse/langere + * cyclus of een verzameling waarin elk niet-leeg project als baseline is aangewezen neemt daarom + * de HELE uitsluiting voor dit bestand terug. Zo kan baselineherkenning nooit nul documenten + * produceren; alle niet-lege projecten openen dan gewoon en het verslag draagt elke reden. + */ +function decideBaselineExclusion( + projectRows: readonly XerRow[], + nonEmptyProjectIds: ReadonlySet, +): BaselineDecision { + const projectIds = new Set(projectRows.map(projectRow => projectRow.cells.proj_id)); + const targetByProject = new Map(); + for (const projectRow of projectRows) { + const ownerProjectId = projectRow.cells.proj_id; + // Een overgeslagen lege PROJECT-rij is geen geopend hoofdproject en kan dus ook geen + // baseline-eigenaar zijn. Zijn verwijzing blijft wel als bronfeit meetellen (dangling e.d.), + // maar mag nooit een niet-leeg project uit de documentselectie drukken. + if (!nonEmptyProjectIds.has(ownerProjectId)) continue; + const target = projectRow.cells.sum_base_proj_id?.trim() ?? ''; + if (target && projectIds.has(target)) targetByProject.set(ownerProjectId, target); + } + const excludedProjectIds = new Set(targetByProject.values()); + const reasons: XerBaselineFallbackReason[] = []; + if ([...targetByProject].some(([projectId, target]) => projectId === target)) { + reasons.push('self-reference'); + } + if (hasNonSelfCycle(targetByProject)) reasons.push('cycle'); + if (nonEmptyProjectIds.size > 0 + && [...nonEmptyProjectIds].every(projectId => excludedProjectIds.has(projectId))) { + reasons.push('all-projects-baselines'); + } + return reasons.length > 0 + ? { excludedProjectIds: new Set(), reverted: true, reasons } + : { excludedProjectIds, reverted: false, reasons }; +} + +function uniqueTaskByCode(tasks: readonly Task[]): Map { + const grouped = new Map(); + for (const task of tasks) { + if (!task.wbsCode) continue; + const matches = grouped.get(task.wbsCode) ?? []; + matches.push(task); + grouped.set(task.wbsCode, matches); + } + return new Map( + [...grouped] + .filter(([, matches]) => matches.length === 1) + .map(([code, matches]) => [code, matches[0]]), + ); +} + +function materializeBaseline( + ownerProjectId: string, + owner: XerReadResult, + sourceProjectId: string, + sourceProjectRow: XerRow, + source: XerReadResult | undefined, +): Baseline { + const ownerLeaves = owner.tasks.filter(isLeafTask); + const ownerById = new Map(ownerLeaves.map(task => [task.id, task])); + const ownerByCode = uniqueTaskByCode(ownerLeaves); + const tasks = (source?.tasks ?? []).filter(isLeafTask).map(task => { + const matched = ownerById.get(task.id) ?? ownerByCode.get(task.wbsCode); + return { + taskId: matched?.id ?? task.id, + sourceTaskId: task.id, + sourceTaskCode: task.wbsCode, + start: task.time.earlyStart || task.time.scheduleStart, + finish: task.time.earlyFinish || task.time.scheduleFinish, + duration: task.time.scheduleDuration, + isMilestone: task.isMilestone, + ...(task.milestoneKind ? { milestoneKind: task.milestoneKind } : {}), + }; + }); + const finishes = tasks.map(task => task.finish).filter(Boolean).sort(); + return { + id: `xer-baseline:${ownerProjectId}:${sourceProjectId}`, + sourceProjectId, + name: source?.project.name || sourceProjectRow.cells.proj_short_name || sourceProjectId, + createdAt: source?.project.modifiedAt || source?.project.createdAt + || owner.project.modifiedAt || owner.project.createdAt, + tasks, + projectEnd: finishes[finishes.length - 1] ?? '', + projectDuration: 0, + }; +} + +function collectExternalLinks(documents: readonly XerMultiProjectDocument[]): XerDocumentExternalLink[] { + const tasksByProject = new Map(documents.map(document => [ + document.projectId, + new Set(document.result.tasks.map(task => task.id)), + ])); + const seen = new Set(); + const links: XerDocumentExternalLink[] = []; + for (const document of documents) { + for (const relation of document.result.xer.externalRelations) { + const predecessor = relation.direction === 'predecessor' + ? { projectId: relation.externalProjectId, taskId: relation.externalTaskId } + : { projectId: relation.localProjectId, taskId: relation.localTaskId }; + const successor = relation.direction === 'predecessor' + ? { projectId: relation.localProjectId, taskId: relation.localTaskId } + : { projectId: relation.externalProjectId, taskId: relation.externalTaskId }; + if (!tasksByProject.get(predecessor.projectId)?.has(predecessor.taskId) + || !tasksByProject.get(successor.projectId)?.has(successor.taskId)) continue; + const key = [ + relation.id, + predecessor.projectId, + predecessor.taskId, + successor.projectId, + successor.taskId, + relation.type, + relation.lagMinutes, + ].join('\u0000'); + if (seen.has(key)) continue; + seen.add(key); + links.push({ + id: relation.id, + predecessor, + successor, + type: relation.type, + lagMinutes: relation.lagMinutes, + }); + } + } + return links; +} + +/** Bouw de documentselectie uit X2's tabellen en X4a's geïnjecteerde per-projectmapping. */ +export function assembleXerMultiProjectImport( + tables: XerTables, + mapProject: XerProjectMapper, +): XerMultiProjectImport { + const projectRows = tables.tables.get('PROJECT')?.rows ?? []; + const taskRows = tables.tables.get('TASK')?.rows ?? []; + assertUniqueProjectIds(projectRows); + const projectIds = new Set(projectRows.map(projectRow => projectRow.cells.proj_id)); + const baselineReferences = projectRows + .map(projectRow => projectRow.cells.sum_base_proj_id?.trim() ?? '') + .filter(Boolean); + const danglingBaselineReferences = baselineReferences + .filter(projectId => !projectIds.has(projectId)) + .length; + const taskCountByProject = new Map(); + for (const taskRow of taskRows) { + const projectId = taskRow.cells.proj_id; + taskCountByProject.set(projectId, (taskCountByProject.get(projectId) ?? 0) + 1); + } + + const nonEmptyProjectRows = projectRows + .filter(projectRow => (taskCountByProject.get(projectRow.cells.proj_id) ?? 0) > 0); + const baselineDecision = decideBaselineExclusion( + projectRows, + new Set(nonEmptyProjectRows.map(projectRow => projectRow.cells.proj_id)), + ); + const mappedByProject = new Map( + nonEmptyProjectRows.map(projectRow => [ + projectRow.cells.proj_id, + // Eén bestand kan readers intern dezelfde kalender-/resourcearrays laten delen. Elke + // documentpayload krijgt daarom vóór baselineverrijking zijn eigen volledige objectgraaf. + cloneMappedProject(mapProject(projectRow.cells.proj_id)), + ]), + ); + const documents = nonEmptyProjectRows + .filter(projectRow => !baselineDecision.excludedProjectIds.has(projectRow.cells.proj_id)) + .map(projectRow => ({ + projectId: projectRow.cells.proj_id, + result: mappedByProject.get(projectRow.cells.proj_id)!, + })); + + let baselinesMaterialized = 0; + for (const document of baselineDecision.reverted ? [] : documents) { + const projectRow = projectRows.find(row => row.cells.proj_id === document.projectId); + const baselineProjectId = projectRow?.cells.sum_base_proj_id?.trim() ?? ''; + const baselineProjectRow = projectRows.find(row => row.cells.proj_id === baselineProjectId); + if (!baselineProjectRow) continue; + const source = mappedByProject.get(baselineProjectId); + const baseline = materializeBaseline( + document.projectId, + document.result, + baselineProjectId, + baselineProjectRow, + source, + ); + document.result.baselines = [...(document.result.baselines ?? []), baseline]; + document.result.activeBaselineId = baseline.id; + baselinesMaterialized++; + } + const externalLinks = collectExternalLinks(documents); + + let activeProjectId: string | null = null; + let mostLeafTasks = -1; + for (const document of documents) { + const leafTasks = document.result.tasks.filter(isLeafTask).length; + if (leafTasks > mostLeafTasks) { + mostLeafTasks = leafTasks; + activeProjectId = document.projectId; + } + } + + const report: XerMultiProjectReport = { + projectsSeen: projectRows.length, + documentsOpened: documents.length, + emptyProjectsSkipped: projectRows.length - nonEmptyProjectRows.length, + baselineProjectsExcluded: nonEmptyProjectRows + .filter(projectRow => baselineDecision.excludedProjectIds.has(projectRow.cells.proj_id)).length, + baselinesMaterialized, + danglingBaselineReferences, + externalLinksPreserved: externalLinks.length, + baselineExclusionReverted: baselineDecision.reverted, + baselineFallbackReasons: baselineDecision.reasons, + }; + + // `externalLinks` alleen op de meervoudige wrapper bewaren is onvoldoende: zodra fileSlice de + // resultaten over documenten verdeelt verdwijnt die wrapper. Hang daarom aan ieder betrokken + // document een eigen kopie. Dit veld zit via `xerImportMetadata` in DOCUMENT_FIELDS en overleeft + // documentwissel/undo/recovery-input. X9 blijft verantwoordelijk voor de IFC-serialisatie ervan. + for (const document of documents) { + document.result.xer.externalLinks = externalLinks + .filter(link => link.predecessor.projectId === document.projectId + || link.successor.projectId === document.projectId) + .map(link => structuredClone(link)); + document.result.xer.report = structuredClone(report); + } + + return { + kind: 'multi-document', + results: documents.map(document => document.result), + activeDocumentIndex: activeProjectId === null + ? -1 + : documents.findIndex(document => document.projectId === activeProjectId), + documents, + taskProjects: nonEmptyProjectRows.map(projectRow => ({ + projectId: projectRow.cells.proj_id, + result: mappedByProject.get(projectRow.cells.proj_id)!, + })), + activeProjectId, + externalLinks, + report, + }; +} diff --git a/src/services/xer/xerReader.ts b/src/services/xer/xerReader.ts new file mode 100644 index 000000000..7983e24dc --- /dev/null +++ b/src/services/xer/xerReader.ts @@ -0,0 +1,1134 @@ +/** + * Semantische XER-project-/taaklezer voor de begrensde X4a-scope. + * + * Voor begrip van P6's tokenvocabulaire en de betekenis van PROJECT/PROJWBS/TASK/TASKPRED is + * MPXJ geraadpleegd: https://github.com/joniles/mpxj (Primavera-reader, LGPL-2.1, Jon Iles e.a.). + * Er is geen MPXJ-code overgenomen. De mapping, fout-/rapportvorm, WBS-opbouw en kalenderkeuzes + * hieronder zijn zelfstandig voor Open Planner Studio geïmplementeerd en door eigen fixtures + * vastgelegd. + */ + +import type { + ImportResult, XerEnumFallback, XerExternalRelation, XerImportMetadata, +} from '@/services/importTypes'; +import { CalendarEngine } from '@/engine/scheduler/CalendarEngine'; +import { + canonicalizeBands, + getCalendarBands, + promoteHourCalendar, + registerCalendarBands, +} from '@/services/subdayIo'; +import type { WorkCalendar } from '@/types/calendar'; +import type { Sequence, SequenceType } from '@/types/sequence'; +import type { + P6ActivityType, + P6CompletePctType, + P6DurationType, + Task, + TaskConstraint, + TaskStatus, +} from '@/types/task'; +import { createDefaultTaskTime } from '@/utils/taskDefaults'; +import { formatInstant, parseInstant } from '@/utils/dateUtils'; +import { hasValidP6SuspendResume } from '@/utils/p6SuspendResume'; +import { + archiveDocumentViewFromOwnedReaderMetadata, + bindXerImportMetadataToArchive, + createXerSourceArchiveFromOwnedMetadata, + detectXerSourcePresentation, + type XerSourceReconstruction, +} from '@/services/xerSourceArchive'; +import type { RecordedTime } from '@/engine/scheduler/recordedDates'; +import { readXerCalendars } from './xerCalendarData'; +import { sourceInstant } from './xerInstant'; +import { readXerRecordedTimes } from './xerRecordedTimes'; +import { buildXerMetadataCatalog, materializeXerMetadata, type XerMetadataCatalog } from './xerMetadata'; +import { indexXerTaskResourceRows } from './xerResourceAssignments'; +import { + buildXerResourceCatalog, + materializeXerResources, + type XerResourceCatalog, +} from './xerResources'; +import { assembleXerMultiProjectImport, type XerMultiProjectImport } from './xerMultiProject'; +import { + deriveXerScheduleOptions, + indexXerScheduleOptions, + type XerScheduleOptionsIndex, +} from './xerScheduleOptions'; +import { + parseXerNumber, + parseXerTables, + XerImportError, + type XerRow, + type XerTables, +} from './xerTables'; + +export interface XerReadResult extends ImportResult { + /** Importtijd-metadata; externe relaties blijven hier brondata en sturen de solver niet. */ + xer: XerImportMetadata; +} + +/** X4b: één XER kan nu één payload óf een geordende verzameling documentpayloads opleveren. */ +export type XerOpenResult = XerReadResult | XerMultiProjectImport; + +/** Eén bestandsbrede partitionering vóór per-projectmaterialisatie. */ +export interface XerProjectRowsIndex { + tasksByProject: ReadonlyMap; + wbsByProject: ReadonlyMap; + relationsByProject: ReadonlyMap; + /** Testbaar operatiebewijs: iedere relevante bronrij precies éénmaal bezocht. */ + visitCount: number; + /** Ongescopeerde relaties die zonder gok niet aan precies één lokaal project zijn toe te wijzen. */ + relationResolutionIssues: readonly { + reason: 'ambiguous' | 'dangling'; + line: number; + field: 'task_id' | 'pred_task_id' | 'proj_id'; + taskId: string; + predecessorTaskId: string; + }[]; +} + +export function indexXerProjectRows(tables: XerTables): XerProjectRowsIndex { + const tasksByProject = new Map(); + const wbsByProject = new Map(); + const relationsByProject = new Map(); + const projectsByTaskId = new Map>(); + const relationResolutionIssues: Array = []; + const unscopedRelationResolution = new Map(); + let visitCount = 0; + const append = (map: Map, projectId: string, row: XerRow): void => { + const rows = map.get(projectId) ?? []; + rows.push(row); + map.set(projectId, rows); + }; + for (const row of tables.tables.get('TASK')?.rows ?? []) { + visitCount++; + append(tasksByProject, row.cells.proj_id, row); + const taskProjects = projectsByTaskId.get(row.cells.task_id) ?? new Set(); + taskProjects.add(row.cells.proj_id); + projectsByTaskId.set(row.cells.task_id, taskProjects); + } + for (const row of tables.tables.get('PROJWBS')?.rows ?? []) { visitCount++; append(wbsByProject, row.cells.proj_id, row); } + for (const row of tables.tables.get('TASKPRED')?.rows ?? []) { + visitCount++; + const successorProjectId = (row.cells.proj_id ?? '').trim(); + const predecessorProjectId = (row.cells.pred_proj_id ?? '').trim(); + if (successorProjectId || predecessorProjectId) { + // Eén expliciete projectscope maakt een ontbrekende andere scope lokaal; twee verschillende + // scopes materialiseren dezelfde externe relatie aan beide documentzijden. + const projects = new Set([successorProjectId || predecessorProjectId, predecessorProjectId || successorProjectId]); + for (const projectId of projects) append(relationsByProject, projectId, row); + continue; + } + + const cacheKey = `${row.cells.task_id}\u0000${row.cells.pred_task_id}`; + let resolution = unscopedRelationResolution.get(cacheKey); + if (!resolution) { + const successorProjects = projectsByTaskId.get(row.cells.task_id); + const predecessorProjects = projectsByTaskId.get(row.cells.pred_task_id); + if (!successorProjects?.size || !predecessorProjects?.size) { + resolution = { + reason: 'dangling', + field: !successorProjects?.size ? 'task_id' : 'pred_task_id', + }; + } else { + // Doorsnede wordt per endpointpaar hoogstens éénmaal berekend. Daarmee kosten de 30 + // herhaalde A→B-relaties in de schaalfixture één resolutie en 29 O(1)-cachehits, nooit + // 30×12 projectscans of 360 materialisaties. + const [smallest, other] = successorProjects.size <= predecessorProjects.size + ? [successorProjects, predecessorProjects] + : [predecessorProjects, successorProjects]; + let onlyProjectId: string | undefined; + let matches = 0; + for (const projectId of smallest) { + if (!other.has(projectId)) continue; + onlyProjectId = projectId; + matches++; + if (matches > 1) break; + } + resolution = matches === 1 + ? { projectId: onlyProjectId } + : { reason: 'ambiguous', field: 'proj_id' }; + } + unscopedRelationResolution.set(cacheKey, resolution); + } + if (!resolution.projectId) { + relationResolutionIssues.push({ + reason: resolution.reason ?? 'ambiguous', + line: row.line, + field: resolution.field ?? 'proj_id', + taskId: row.cells.task_id, + predecessorTaskId: row.cells.pred_task_id, + }); + continue; + } + append(relationsByProject, resolution.projectId, row); + } + return { tasksByProject, wbsByProject, relationsByProject, visitCount, relationResolutionIssues }; +} + +const ACTIVITY_TYPES: readonly P6ActivityType[] = [ + 'TT_Task', 'TT_Rsrc', 'TT_LOE', 'TT_Mile', 'TT_FinMile', 'TT_WBS', +]; +const DURATION_TYPES: readonly P6DurationType[] = [ + 'DT_FixedDrtn', 'DT_FixedDUR2', 'DT_FixedRate', 'DT_FixedQty', +]; +const COMPLETE_PCT_TYPES: readonly P6CompletePctType[] = ['CP_Drtn', 'CP_Phys', 'CP_Units']; + +function canonicalToken(raw: string, values: readonly T[]): T | undefined { + const folded = raw.trim().toLowerCase(); + return values.find(value => value.toLowerCase() === folded); +} + +function fallback( + sink: XerEnumFallback[], + family: XerEnumFallback['family'], + token: string, + value: string, + table: XerEnumFallback['table'], + field: string, + line: number, +): void { + if (!token.trim()) return; + sink.push({ family, token, fallback: value, table, field, line }); +} + +function numberOf( + tables: XerTables, + row: XerRow, + field: string, + table: 'TASK' | 'TASKPRED' = 'TASK', +): number | null { + const raw = row.cells[field] ?? ''; + try { + return parseXerNumber(raw, tables.numberFormat); + } catch (error) { + if (error instanceof XerImportError) { + throw new XerImportError(error.xerCode, error.message, { + table, field, line: row.line, encoding: tables.report.encoding, + }); + } + throw error; + } +} + +function hasClock(raw: string): boolean { + return /^\d{4}-\d{2}-\d{2}[ T]\d{1,2}:\d{2}/.test(raw.trim()); +} + +/** + * `last_recalc_date` is de canonieke XER-invoer voor de projectstatusdatum. Sommige compatibele + * PROJECT-dialecten bieden uitsluitend `data_date`; lees die cel alleen als de canonieke *kolom* + * ontbreekt. Een lege of ongeldige canonieke waarde is bewust geen reden om data_date te lezen. + */ +function projectStatusDate(tables: XerTables, projectRow: XerRow, hourMode: boolean): string | undefined { + const fields = tables.tables.get('PROJECT')?.fields ?? []; + const raw = fields.includes('last_recalc_date') + ? projectRow.cells.last_recalc_date ?? '' + : fields.includes('data_date') + ? projectRow.cells.data_date ?? '' + : ''; + return sourceInstant(raw, hourMode); +} + +function clockMinute(raw: string): number | undefined { + const match = raw.trim().match(/^\d{4}-\d{2}-\d{2}[ T](\d{1,2}):(\d{2})/); + if (!match) return undefined; + const hour = Number(match[1]); + const minute = Number(match[2]); + if (hour > 23 || minute > 59) return undefined; + return hour * 60 + minute; +} + +/** + * Een lege `CALENDAR.clndr_data` verliest de bandvorm, terwijl P6 de toegestane geplande + * `target_start_date`/`target_end_date` en `target_drtn_hr_cnt` wel bewaart. Voor activiteiten + * met een geheel aantal kalenderdagen is het modale start/eind-klokpaar daardoor bronbewijs voor + * de standaardwerkdag van die effectieve kalender. Alleen twee beslisbare vormen worden hersteld: + * een aaneengesloten band (klokspan === netto daguren) en P6's standaard lunchpauze 12:00-13:00. + * Geen early/late/float-output komt in deze afleiding voor. + */ +function inferBlankCalendarBands( + tables: XerTables, + calendar: WorkCalendar, + activityRows: readonly XerRow[], + projectCalendarId: string, +): boolean { + const sourceRow = (tables.tables.get('CALENDAR')?.rows ?? []) + .find(row => row.cells.clndr_id.trim() === calendar.id); + if (!sourceRow || (sourceRow.cells.clndr_data ?? '').trim() !== '') return false; + + const pairCounts = new Map(); + for (const row of activityRows) { + if ((row.cells.clndr_id || projectCalendarId) !== calendar.id) continue; + const durationHours = numberOf(tables, row, 'target_drtn_hr_cnt') ?? 0; + const dayCount = durationHours / calendar.hoursPerDay; + if (!(durationHours > 0) || Math.abs(dayCount - Math.round(dayCount)) > 1e-9) continue; + const start = clockMinute(row.cells.target_start_date ?? ''); + const finish = clockMinute(row.cells.target_end_date ?? ''); + if (start === undefined || finish === undefined || finish <= start) continue; + const key = `${start}:${finish}`; + pairCounts.set(key, (pairCounts.get(key) ?? 0) + 1); + } + let best: { start: number; finish: number; count: number } | undefined; + for (const [key, count] of pairCounts) { + const [start, finish] = key.split(':').map(Number); + if (!best || count > best.count || (count === best.count && key < `${best.start}:${best.finish}`)) { + best = { start, finish, count }; + } + } + if (!best) return false; + + const netMinutes = calendar.hoursPerDay * 60; + const spanMinutes = best.finish - best.start; + let dayBands: { start: number; end: number }[] | undefined; + if (spanMinutes === netMinutes) { + dayBands = [{ start: best.start, end: best.finish }]; + } else { + const gapMinutes = spanMinutes - netMinutes; + const lunchStart = 12 * 60; + const lunchEnd = lunchStart + gapMinutes; + if (gapMinutes > 0 && best.start < lunchStart && lunchEnd < best.finish) { + dayBands = [ + { start: best.start, end: lunchStart }, + { start: lunchEnd, end: best.finish }, + ]; + } + } + if (!dayBands) return false; + const byDay = Object.fromEntries(calendar.workDays.map(day => [day, dayBands])); + const canonical = canonicalizeBands(byDay).bands; + registerCalendarBands(calendar, { canonical, deviates: dayBands.length > 1 }); + calendar.workStartHour = best.start / 60; + calendar.workEndHour = best.finish / 60; + return true; +} + +/** X7-broninstant: de vorm hoort bij dit ene veld, niet bij de kalender die een buurveld promoveert. */ +function sourceX7Instant(raw: string): string | undefined { + return sourceInstant(raw, hasClock(raw)); +} + +function activityTypeOf( + raw: string, + row: XerRow, + issues: XerEnumFallback[], +): P6ActivityType { + const known = canonicalToken(raw, ACTIVITY_TYPES); + if (known) return known; + fallback(issues, 'activityType', raw, 'TT_Task', 'TASK', 'task_type', row.line); + return 'TT_Task'; +} + +function durationTypeOf( + raw: string, + defaultType: P6DurationType, + row: XerRow, + issues: XerEnumFallback[], + table: 'PROJECT' | 'TASK' = 'TASK', +): P6DurationType { + const known = canonicalToken(raw, DURATION_TYPES); + if (known) return known; + fallback( + issues, + 'durationType', + raw, + defaultType, + table, + table === 'PROJECT' ? 'def_duration_type' : 'duration_type', + row.line, + ); + return defaultType; +} + +function statusOf(raw: string, row: XerRow, issues: XerEnumFallback[]): TaskStatus { + const token = raw.trim().toLowerCase(); + if (!token || token === 'tk_notstart') return 'NOT_STARTED'; + if (token === 'tk_active') return 'STARTED'; + if (token === 'tk_complete') return 'COMPLETED'; + fallback(issues, 'status', raw, 'NOT_STARTED', 'TASK', 'status_code', row.line); + return 'NOT_STARTED'; +} + +function completePctTypeOf(raw: string, row: XerRow, issues: XerEnumFallback[]): P6CompletePctType { + const known = canonicalToken(raw, COMPLETE_PCT_TYPES); + if (known) return known; + fallback(issues, 'completePctType', raw, 'CP_Drtn', 'TASK', 'complete_pct_type', row.line); + return 'CP_Drtn'; +} + +function priorityOf(raw: string, row: XerRow, issues: XerEnumFallback[]): number { + const priorities: Record = { + pt_lowest: 0, + pt_verylow: 250, + pt_low: 400, + pt_normal: 500, + pt_high: 750, + pt_veryhigh: 900, + pt_top: 1000, + }; + const token = raw.trim().toLowerCase(); + if (!token) return 500; + const known = priorities[token]; + if (known !== undefined) return known; + fallback(issues, 'priority', raw, '500', 'TASK', 'priority_type', row.line); + return 500; +} + +function constraintOf( + raw: string, + dateRaw: string, + hourMode: boolean, + row: XerRow, + issues: XerEnumFallback[], + secondary: boolean, +): TaskConstraint | undefined { + const token = raw.trim().toUpperCase(); + if (!token) return undefined; + const mapping: Record = { + CS_ALAP: { type: 'ALAP' }, + CS_MSO: { type: 'MSO' }, + CS_MSOA: { type: 'SNET' }, + CS_MSOB: { type: 'SNLT' }, + CS_MEO: { type: 'MFO' }, + CS_MEOA: { type: 'FNET' }, + CS_MEOB: { type: 'FNLT' }, + CS_MANDSTART: { type: 'MSO', hard: true }, + CS_MANSTART: { type: 'MSO', hard: true }, + CS_MANDFIN: { type: 'MFO', hard: true }, + CS_MANFINISH: { type: 'MFO', hard: true }, + }; + const known = mapping[token]; + if (!known) { + fallback(issues, 'constraint', raw, 'ASAP', 'TASK', secondary ? 'cstr_type2' : 'cstr_type', row.line); + return undefined; + } + const date = sourceInstant(dateRaw, hourMode); + return { + type: known.type, + ...(date ? { date } : {}), + ...(!secondary && known.hard ? { hard: true } : {}), + }; +} + +function relationTypeOf( + raw: string, + row: XerRow, + issues: XerEnumFallback[], +): { sequence: SequenceType; source: 'FS' | 'SS' | 'FF' | 'SF' } { + const token = raw.trim().toUpperCase(); + const mapping: Record = { + PR_FS: { sequence: 'FINISH_START', source: 'FS' }, + PR_SS: { sequence: 'START_START', source: 'SS' }, + PR_FF: { sequence: 'FINISH_FINISH', source: 'FF' }, + PR_SF: { sequence: 'START_FINISH', source: 'SF' }, + FS: { sequence: 'FINISH_START', source: 'FS' }, + SS: { sequence: 'START_START', source: 'SS' }, + }; + const known = mapping[token]; + if (known) return known; + fallback(issues, 'relation', raw, 'FS', 'TASKPRED', 'pred_type', row.line); + return mapping.PR_FS; +} + +function wbsTaskId(projectId: string, wbsId: string): string { + return `xer-wbs:${projectId}:${wbsId}`; +} + +/** Unicode-codepointvolgorde, onafhankelijk van hostlocale en ICU-versie. */ +function compareCodePoints(left: string, right: string): number { + const a = Array.from(left, char => char.codePointAt(0) ?? 0); + const b = Array.from(right, char => char.codePointAt(0) ?? 0); + const length = Math.min(a.length, b.length); + for (let index = 0; index < length; index++) { + if (a[index] !== b[index]) return a[index] - b[index]; + } + return a.length - b.length; +} + +function stableWbsRows(rows: readonly XerRow[], projectId: string): XerRow[] { + return rows + .filter(row => row.cells.proj_id === projectId) + .sort((left, right) => { + const parentOrder = compareCodePoints( + left.cells.parent_wbs_id ?? '', right.cells.parent_wbs_id ?? '', + ); + const leftSeq = Number(left.cells.seq_num) || 0; + const rightSeq = Number(right.cells.seq_num) || 0; + return parentOrder + || leftSeq - rightSeq + || compareCodePoints(left.cells.wbs_id ?? '', right.cells.wbs_id ?? '') + || left.line - right.line; + }); +} + +function assertUniqueId( + rows: readonly XerRow[], table: 'PROJWBS' | 'TASK' | 'TASKPRED', field: string, +): void { + const firstLineById = new Map(); + for (const row of rows) { + const id = row.cells[field]?.trim() ?? ''; + // TASKPRED-id is in sommige exports leeg; de regelgebonden fallback blijft intrinsiek uniek. + if (!id) continue; + const firstLine = firstLineById.get(id); + if (firstLine !== undefined) { + throw new XerImportError( + 'XER_DUPLICATE_ID', + `${table}.${field} bevat dubbele id '${id}' op regels ${firstLine} en ${row.line}.`, + { table, field, line: row.line, lines: [firstLine, row.line] }, + ); + } + firstLineById.set(id, row.line); + } +} + +/** Map precies één reeds getokenized, niet-leeg P6-project. X4b roept deze kern één keer per + * PROJECT-rij aan; zo blijft de X4a-mapping zelf één implementatie. */ +function readXerProject( + tables: XerTables, + scheduleOptionsIndex: XerScheduleOptionsIndex, + projectId: string, + resourceCatalog: XerResourceCatalog, + metadataCatalog: XerMetadataCatalog, + taskResourceRowsByProject: ReadonlyMap, + projectRowsIndex: XerProjectRowsIndex, +): XerReadResult { + const projectRow = scheduleOptionsIndex.projectRowsById.get(projectId)?.row; + if (!projectRow) { + throw new XerImportError( + 'XER_MISSING_REQUIRED_VALUE', + `PROJECT.proj_id '${projectId}' bestaat niet in de getokenized XER-invoer.`, + { table: 'PROJECT', field: 'proj_id' }, + ); + } + const activityRows = projectRowsIndex.tasksByProject.get(projectId) ?? []; + if (activityRows.length === 0) { + throw new XerImportError( + 'XER_EMPTY_PROJECT', + `XER-project ${projectId} bevat geen activiteiten.`, + { table: 'TASK' }, + ); + } + const rawWbsRows = projectRowsIndex.wbsByProject.get(projectId) ?? []; + const relationRows = projectRowsIndex.relationsByProject.get(projectId) ?? []; + + // Identiteit en lokale eindpunten moeten vaststaan vóór maps, WBS-boom of relaties ontstaan. + assertUniqueId(rawWbsRows, 'PROJWBS', 'wbs_id'); + assertUniqueId(activityRows, 'TASK', 'task_id'); + assertUniqueId(relationRows, 'TASKPRED', 'task_pred_id'); + const localTaskIds = new Set(activityRows.map(row => row.cells.task_id)); + for (const row of relationRows) { + const successorProjectId = row.cells.proj_id || projectId; + const predecessorProjectId = row.cells.pred_proj_id || successorProjectId; + const missingField = successorProjectId === projectId && !localTaskIds.has(row.cells.task_id) + ? 'task_id' + : predecessorProjectId === projectId && !localTaskIds.has(row.cells.pred_task_id) + ? 'pred_task_id' + : undefined; + if (missingField) { + throw new XerImportError( + 'XER_DANGLING_LOCAL_RELATION', + `TASKPRED op regel ${row.line} verwijst via ${missingField} naar een ontbrekende lokale activiteit.`, + { table: 'TASKPRED', field: missingField, line: row.line }, + ); + } + } + + const calendars = readXerCalendars(tables); + const calendarList: WorkCalendar[] = [...calendars.calendars]; + // P6 kan een geldige project-/taakexport zonder CALENDAR-tabel leveren. De projectverwijzing + // blijft dan bruikbaar als id en krijgt de smalle X3-default ma-vr 08:00-16:00; geen lokale + // appinstelling of feestdagenbron mag zo'n import machine-afhankelijk maken. + if (calendarList.length === 0) { + calendarList.push({ + id: projectRow.cells.clndr_id || `xer-calendar:${projectId}`, + name: 'P6 Standard Calendar', + description: '', + workDays: [1, 2, 3, 4, 5], + workStartHour: 8, + workEndHour: 16, + hoursPerDay: 8, + holidays: [], + }); + } + const calendarById = new Map(calendarList.map(calendar => [calendar.id, calendar])); + const fallbackCalendar = calendarList[0]; + const projectCalendar = calendarById.get(projectRow.cells.clndr_id) ?? fallbackCalendar; + + // Een expliciet TASK-tijdstip is bronbewijs voor uurmodus. Dit gebeurt vóór datum-/duurmapping, + // zodat geen minuut wordt afgerond en lege clndr_data via de bestaande kalenderbanden promoveert. + const hourSignalCalendarIds = new Set(); + for (const row of activityRows) { + const calendarId = row.cells.clndr_id || projectCalendar.id; + if ([ + row.cells.target_start_date, + row.cells.target_end_date, + row.cells.act_start_date, + row.cells.act_end_date, + row.cells.cstr_date, + row.cells.cstr_date2, + row.cells.suspend_date, + row.cells.resume_date, + row.cells.expect_end_date, + ].some(value => hasClock(value ?? ''))) { + hourSignalCalendarIds.add(calendarId); + } + } + const plannedWindowCalendarIds = new Set(); + for (const calendar of calendarList) { + if (inferBlankCalendarBands(tables, calendar, activityRows, projectCalendar.id)) { + plannedWindowCalendarIds.add(calendar.id); + } + promoteHourCalendar( + calendar, + getCalendarBands(calendar), + hourSignalCalendarIds.has(calendar.id), + false, + ); + } + const calendarEngines = new Map(calendarList.map(calendar => [ + calendar.id, + new CalendarEngine(calendar), + ] as const)); + + + const enumFallbacks: XerEnumFallback[] = []; + const projectDefaultDuration = durationTypeOf( + projectRow.cells.def_duration_type ?? '', + 'DT_FixedDUR2', + projectRow, + enumFallbacks, + 'PROJECT', + ); + const mappedActivities: Task[] = []; + for (const row of activityRows) { + const effectiveCalendar = calendarById.get(row.cells.clndr_id) ?? projectCalendar; + const hourMode = effectiveCalendar.workTime !== undefined; + const explicitTargetStart = sourceInstant(row.cells.target_start_date ?? '', hourMode); + const explicitTargetFinish = sourceInstant(row.cells.target_end_date ?? '', hourMode); + const hasExplicitTargetWindow = explicitTargetStart !== undefined && explicitTargetFinish !== undefined; + let start = explicitTargetStart + ?? sourceInstant(projectRow.cells.last_recalc_date ?? '', hourMode) + ?? '1970-01-01'; + let finish = explicitTargetFinish ?? start; + // P6 XER kan een TT_FinMile op de eerste minuut ná een werkbandgrens serialiseren met gelijke + // target start/finish. Voor deze nulduur-activiteit is die minuut een grenscodering, geen werk: + // de mijlpaal hoort bij de bandstart en de finishzijde bij het vorige werkbandeinde. De regel is + // bewust gesloten op TT_FinMile + start===finish + exact bandstart+1; een gewone activiteit, + // een andere minuut of een reeds exacte bandgrens blijft letterlijk zoals aangeleverd. + if (hourMode && start === finish + && row.cells.task_type?.trim().toLowerCase() === 'tt_finmile') { + const engine = calendarEngines.get(effectiveCalendar.id); + const raw = parseInstant(start); + const bands = engine?.effectiveBandsOn(raw) ?? []; + const minute = raw.getUTCHours() * 60 + raw.getUTCMinutes(); + if (bands.length > 0 && minute === bands[0].start + 1) { + const bandStart = new Date(raw.getTime() - 60_000); + start = formatInstant(bandStart, 'hour'); + finish = formatInstant(engine!.prevWorkInstant(bandStart), 'hour'); + } + } + const durationHours = numberOf(tables, row, 'target_drtn_hr_cnt') ?? 0; + const remainingHours = numberOf(tables, row, 'remain_drtn_hr_cnt'); + const sourceDurationMinutes = Math.round(durationHours * 60); + const hoursPerDay = effectiveCalendar.hoursPerDay > 0 ? effectiveCalendar.hoursPerDay : 8; + const activityType = activityTypeOf(row.cells.task_type ?? '', row, enumFallbacks); + const durationType = durationTypeOf( + row.cells.duration_type ?? '', projectDefaultDuration, row, enumFallbacks, + ); + const rawStatus = row.cells.status_code ?? ''; + const status = statusOf(rawStatus, row, enumFallbacks); + const rawCompletePctType = row.cells.complete_pct_type ?? ''; + const completePctType = rawCompletePctType.trim() === '' + ? undefined + : completePctTypeOf(rawCompletePctType, row, enumFallbacks); + const statusToken = rawStatus.trim().toLowerCase(); + const statusForcesCompletion = statusToken === 'tk_notstart' || statusToken === 'tk_complete'; + // Status is de leidende P6-toestand. Percentagekolommen verklaren uitsluitend de voortgang + // van een actieve taak; ze mogen een expliciete TK_NotStart nooit de solver-in-progressroute + // in trekken. CP_Drtn en CP_Units lezen complete_pct, CP_Phys leest uitsluitend + // phys_complete_pct — geen familie valt terug op de percentagekolom van een andere familie. + // Lees uitsluitend de percentagefamilie die de expliciete P6-status en -methode werkelijk + // gebruiken. Anders kan bijvoorbeeld een corrupte `complete_pct` een CP_Phys-taak (waar + // uitsluitend `phys_complete_pct` betekenis heeft) ten onrechte onopenbaar maken. + const durationCompletionPercent = !statusForcesCompletion + && (completePctType === 'CP_Drtn' || completePctType === 'CP_Units') + ? numberOf(tables, row, 'complete_pct') + : null; + const completionPercent = statusForcesCompletion + ? 0 + : completePctType === 'CP_Drtn' || completePctType === 'CP_Units' + ? durationCompletionPercent + ?? (completePctType === 'CP_Drtn' && remainingHours !== null && durationHours > 0 + ? (1 - remainingHours / durationHours) * 100 + : 0) + : numberOf(tables, row, 'phys_complete_pct') ?? 0; + const completion = statusToken === 'tk_complete' + ? 1 + : statusToken === 'tk_notstart' + ? 0 + : Math.max(0, Math.min(1, completionPercent / 100)); + const parentId = row.cells.wbs_id ? wbsTaskId(projectId, row.cells.wbs_id) : null; + const plannedWindowMinutes = hourMode && plannedWindowCalendarIds.has(effectiveCalendar.id) + ? calendarEngines.get(effectiveCalendar.id)?.workMinutesBetween( + parseInstant(start), parseInstant(finish), + ) + : undefined; + // Alleen bij een lege bronkalender mag het target-venster de door ontbrekende banddata verloren + // fractionele slotdag herstellen. De expliciete targetduur blijft autoritair zodra vensterwerk + // en bronduur meer dan één effectieve dag verschillen: target_start/target_end mogen immers + // planningsruimte omvatten en zijn dan geen alternatieve duurkolom. Zo is de positieve vorm + // kalender-afleidbaar en de negatieve vorm gesloten zonder stored P6-rekenuitvoer te lezen. + const plannedWindowRepairsFractionalDay = plannedWindowMinutes !== undefined + && plannedWindowMinutes > 0 + && Math.abs(plannedWindowMinutes - sourceDurationMinutes) <= hoursPerDay * 60; + const durationMinutes = plannedWindowRepairsFractionalDay + ? plannedWindowMinutes + : sourceDurationMinutes; + // Main's duurmodel is TAAKgestuurd, niet kalendergestuurd: `taskDurationUnit` leest + // `time.durationUnit` en valt alleen bij ONTBREKEND veld terug op `durationMinutes`. + // `createDefaultTaskTime` zet standaard 'days', dus zonder deze expliciete eenheid zou + // een uur-native XER-taak door de dag-tak van CPMSolver lopen en op de werkdaggrens + // eindigen (gemeten: 16:00 i.p.v. 10:00). Zelfde patroon als mppReader/p6xmlReader. + // LET OP: `createDefaultTaskTime` leest zijn tweede argument als UREN zodra de eenheid + // 'hours' is, en zet dan zelf `durationMinutes = dat getal * 60`. Wij geven hier bewust + // DAGEN mee omdat `scheduleDuration` dagen hoort te zijn; de daaruit volgende foutieve + // `durationMinutes` wordt hieronder in dezelfde `if (hourMode)`-tak overschreven met de + // echte bronminuten. Die overschrijving is dus dragend — haal 'm niet weg. + const time = createDefaultTaskTime( + start, durationMinutes / (hoursPerDay * 60), hourMode ? 'hours' : 'days', + ); + time.scheduleFinish = finish; + time.earlyStart = start; + time.earlyFinish = finish; + time.lateStart = start; + time.lateFinish = finish; + time.completion = completion; + const p6RemainingHours = status === 'STARTED' && completePctType === 'CP_Drtn' + && durationCompletionPercent !== null + ? durationHours * (1 - completion) + : remainingHours; + if (hourMode) { + time.durationMinutes = durationMinutes; + if (p6RemainingHours !== null) { + time.remainingMinutes = Math.round(p6RemainingHours * 60); + } + } else if (p6RemainingHours !== null) { + time.remainingTime = p6RemainingHours / hoursPerDay; + } + const actualStart = sourceInstant(row.cells.act_start_date ?? '', hourMode); + const actualFinish = sourceInstant(row.cells.act_end_date ?? '', hourMode); + if (actualStart) time.actualStart = actualStart; + if (actualFinish) time.actualFinish = actualFinish; + const stop = sourceX7Instant(row.cells.suspend_date ?? ''); + const resume = sourceX7Instant(row.cells.resume_date ?? ''); + if (stop) time.stop = stop; + if (resume) time.resume = resume; + const validSuspendResume = hasValidP6SuspendResume({ + p6ProjectId: projectId, + p6SuspendResume: true, + time, + }); + const expectedFinish = sourceX7Instant(row.cells.expect_end_date ?? ''); + + const isStartMilestone = activityType === 'TT_Mile'; + const isFinishMilestone = activityType === 'TT_FinMile'; + mappedActivities.push({ + id: row.cells.task_id, + name: row.cells.task_name || row.cells.task_code || 'Activity', + description: '', + wbsCode: row.cells.task_code, + taskType: 'CONSTRUCTION', + status, + isMilestone: isStartMilestone || isFinishMilestone, + ...((isStartMilestone || isFinishMilestone) + ? { milestoneKind: isFinishMilestone ? 'FINISH' as const : 'START' as const } + : {}), + priority: priorityOf(row.cells.priority_type ?? '', row, enumFallbacks), + parentId, + childIds: [], + time, + resourceIds: [], + ...(row.cells.clndr_id && row.cells.clndr_id !== projectCalendar.id + ? { calendarId: row.cells.clndr_id } + : {}), + p6DurationType: durationType, + p6ActivityType: activityType, + p6ProjectId: projectId, + p6TaskId: row.cells.task_id, + ...(hasExplicitTargetWindow ? { p6ExplicitTargetWindow: true } : {}), + ...(completePctType ? { p6CompletePctType: completePctType } : {}), + ...(expectedFinish ? { p6ExpectedFinish: expectedFinish } : {}), + ...(validSuspendResume ? { p6SuspendResume: true } : {}), + ...(activityType === 'TT_LOE' ? { isHammock: true } : {}), + ...(() => { + const constraint = constraintOf( + row.cells.cstr_type ?? '', row.cells.cstr_date ?? '', hourMode, row, enumFallbacks, false, + ); + const constraint2 = constraintOf( + row.cells.cstr_type2 ?? '', row.cells.cstr_date2 ?? '', hourMode, row, enumFallbacks, true, + ); + return { ...(constraint ? { constraint } : {}), ...(constraint2 ? { constraint2 } : {}) }; + })(), + }); + } + + const projectHourMode = projectCalendar.workTime !== undefined; + const derivedSchedule = deriveXerScheduleOptions(scheduleOptionsIndex, projectId, { + hoursPerDay: projectCalendar.hoursPerDay, + taskCount: mappedActivities.length, + }); + const { + progressMode, + schedulingOptions, + } = derivedSchedule; + // BAK 4 (XER-etappeplan §4.1-bijstelling 2026-09-04, X-O7 laag 3) — uitsluitend weergave/meetlat, + // nooit solverinvoer. Onafhankelijk van de taakmapping hierboven: leest dezelfde `activityRows`, + // maar schrijft nergens in `Task`/`Task.time`. Zie `xerRecordedTimes.ts` voor de laagkeuze. + // Staat ná `deriveXerScheduleOptions` omdat de kritiekafleiding dezelfde `criticalDefinition` + // gebruikt als de solver krijgt (her-check laag 3, bevinding 7). + const recordedTimes = readXerRecordedTimes(activityRows, { + numberOf: (row, field) => numberOf(tables, row, field), + effectiveCalendarOf: (row) => { + const effectiveCalendar = calendarById.get(row.cells.clndr_id) ?? projectCalendar; + const engine = calendarEngines.get(effectiveCalendar.id)!; + return { + id: effectiveCalendar.id, + hourMode: effectiveCalendar.workTime !== undefined, + minutesPerDay: engine.hoursPerDay * 60, + }; + }, + taskIdOf: (row) => row.cells.task_id, + criticalDefinition: schedulingOptions.criticalDefinition, + }); + // De solver krijgt alleen de finale opties; de documentmetadata krijgt uitsluitend de + // bestaande, archive/IFC-compatibele provenancevelden. Zo kan een later toegevoegd intern + // afleidingsveld niet per ongeluk als opgeslagen P6-invoer worden bewaard of meegestuurd. + const scheduleOptionsMetadata: XerImportMetadata['scheduleOptions'] = { + source: derivedSchedule.source, + retainedSource: derivedSchedule.retainedSource, + fallbacks: derivedSchedule.fallbacks, + diagnostics: derivedSchedule.diagnostics, + sourceArchive: derivedSchedule.sourceArchive, + sourceRowIndexes: derivedSchedule.sourceRowIndexes, + sourceRows: derivedSchedule.sourceRows, + }; + const starts = mappedActivities.map(task => task.time.scheduleStart).filter(Boolean).sort(); + const finishes = mappedActivities.map(task => task.time.scheduleFinish).filter(Boolean).sort(); + const projectStart = starts[0]; + const taskDerivedProjectEnd = finishes[finishes.length - 1] ?? projectStart; + const sourceProjectEnd = sourceInstant(projectRow.cells.plan_end_date ?? '', projectHourMode); + // PROJECT.plan_end_date is allowed project input, but changes the late pass only when P6's + // corresponding SCHEDOPTIONS switch is explicitly Y. Without that switch, the historical + // task-derived project range remains byte-identical for XER and every other format. + const projectEnd = schedulingOptions.useProjectEndDateForFloat && sourceProjectEnd + ? sourceProjectEnd + : taskDerivedProjectEnd; + const statusDate = projectStatusDate(tables, projectRow, projectHourMode); + + const wbsRows = stableWbsRows(rawWbsRows, projectId); + const wbsTasks: Task[] = wbsRows.map(row => { + const id = wbsTaskId(projectId, row.cells.wbs_id); + const parentId = row.cells.parent_wbs_id + ? wbsTaskId(projectId, row.cells.parent_wbs_id) + : null; + return { + id, + name: row.cells.wbs_name || row.cells.wbs_short_name || 'WBS', + description: '', + wbsCode: row.cells.wbs_short_name ?? '', + taskType: 'CONSTRUCTION', + status: 'NOT_STARTED', + isMilestone: false, + isSummary: true, + priority: 500, + parentId, + childIds: [], + time: createDefaultTaskTime(projectStart, 0), + resourceIds: [], + }; + }); + const allTasks = [...wbsTasks, ...mappedActivities]; + const taskById = new Map(allTasks.map(task => [task.id, task])); + for (const task of allTasks) { + if (!task.parentId) continue; + const parent = taskById.get(task.parentId); + if (parent && !parent.childIds.includes(task.id)) parent.childIds.push(task.id); + } + + // X8 projecteert uitsluitend na de bestandsbrede mapping. Daardoor kunnen identieke task_id's + // uit verschillende PROJECT-rijen nooit metadata naar elkaar lekken; de catalogus zelf blijft + // als readonly bronreferentie voor X9 gedeeld. + const metadata = materializeXerMetadata(metadataCatalog, projectId); + for (const [taskId, taskMetadata] of metadata.taskMetadata) { + const task = taskById.get(taskId); + if (!task) continue; + if (taskMetadata.activityCodes) task.activityCodes = taskMetadata.activityCodes; + if (taskMetadata.customFields) task.customFields = taskMetadata.customFields; + if (taskMetadata.notes) task.notes = taskMetadata.notes; + } + + // X6: projectresources zijn bewust mutable kopieën; de raw catalogus en TASKRSRC-cellen blijven + // één maal geparseerde, bevroren bestandsdata. Dit voorkomt P×52.640 structuredClone-kopieën. + const resourceResult = materializeXerResources(resourceCatalog, tables, { + projectId, + projectCalendarId: projectCalendar.id, + projectHoursPerDay: projectCalendar.hoursPerDay, + availableCalendarIds: new Set(calendarList.map(calendar => calendar.id)), + calendarHoursPerDay: new Map(calendarList.map(calendar => [calendar.id, calendar.hoursPerDay])), + taskIds: new Set(mappedActivities.map(task => task.id)), + }, taskResourceRowsByProject.get(projectId) ?? []); + for (const assignment of resourceResult.assignments) { + const task = taskById.get(assignment.taskId); + if (task && !task.resourceIds.includes(assignment.resourceId)) task.resourceIds.push(assignment.resourceId); + } + + const sequences: Sequence[] = []; + const externalRelations: XerExternalRelation[] = []; + for (const row of relationRows) { + const successorProjectId = row.cells.proj_id || projectId; + const predecessorProjectId = row.cells.pred_proj_id || successorProjectId; + const relationType = relationTypeOf(row.cells.pred_type, row, enumFallbacks); + const lagMinutes = Math.round((numberOf(tables, row, 'lag_hr_cnt', 'TASKPRED') ?? 0) * 60); + const relationId = row.cells.task_pred_id || `xer-rel:${row.line}`; + const predecessorLocal = predecessorProjectId === projectId && taskById.has(row.cells.pred_task_id); + const successorLocal = successorProjectId === projectId && taskById.has(row.cells.task_id); + if (predecessorLocal && successorLocal) { + const predecessor = taskById.get(row.cells.pred_task_id)!; + const successor = taskById.get(row.cells.task_id)!; + const predecessorCalendar = calendarEngines.get( + predecessor.calendarId ?? projectCalendar.id, + ) ?? calendarEngines.get(projectCalendar.id)!; + const plannedPredecessorFinish = parseInstant(predecessor.time.scheduleFinish); + const plannedSuccessorStart = parseInstant(successor.time.scheduleStart); + const finishMinute = plannedPredecessorFinish.getUTCHours() * 60 + + plannedPredecessorFinish.getUTCMinutes(); + const p6StartAtPredecessorFinishBoundary = relationType.sequence === 'FINISH_START' + && lagMinutes === 0 + && predecessorCalendar.isHourMode + && plannedPredecessorFinish.getTime() === plannedSuccessorStart.getTime() + && predecessorCalendar.effectiveBandsOn(plannedPredecessorFinish) + .some(band => band.end === finishMinute); + sequences.push({ + id: relationId, + predecessorId: row.cells.pred_task_id, + successorId: row.cells.task_id, + type: relationType.sequence, + lagMinutes, + lagDays: lagMinutes / (projectCalendar.hoursPerDay * 60), + ...(p6StartAtPredecessorFinishBoundary + ? { p6StartAtPredecessorFinishBoundary: true } + : {}), + }); + } else if (successorLocal && predecessorProjectId !== projectId) { + externalRelations.push({ + id: relationId, + localProjectId: projectId, + localTaskId: row.cells.task_id, + externalProjectId: predecessorProjectId, + externalTaskId: row.cells.pred_task_id, + direction: 'predecessor', + type: relationType.source, + lagMinutes, + }); + } else if (predecessorLocal && successorProjectId !== projectId) { + externalRelations.push({ + id: relationId, + localProjectId: projectId, + localTaskId: row.cells.pred_task_id, + externalProjectId: successorProjectId, + externalTaskId: row.cells.task_id, + direction: 'successor', + type: relationType.source, + lagMinutes, + }); + } + } + + return { + project: { + id: projectId, + name: projectRow.cells.proj_short_name || projectId, + description: '', + startDate: projectStart, + endDate: projectEnd, + calendarId: projectCalendar.id, + createdAt: projectStart, + modifiedAt: statusDate ?? projectStart, + author: '', + company: '', + ...(statusDate ? { statusDate } : {}), + progressMode, + schedulingOptions, + }, + calendar: projectCalendar, + resourceCalendars: calendarList.filter(calendar => calendar.id !== projectCalendar.id), + tasks: allTasks, + sequences, + resources: resourceResult.resources, + assignments: resourceResult.assignments, + activityCodeTypes: metadata.activityCodeTypes, + customFieldDefs: metadata.customFieldDefs, + recordedTimes, + recordedTimesOrigin: 'xer', + xer: { + sourceProjectId: projectId, + defaultCurrencyCode: tables.header.defaultCurrencyCode, + tableReport: tables.report, + calendarIssues: calendars.issues, + enumFallbacks, + scheduleOptions: scheduleOptionsMetadata, + externalRelations, + externalLinks: [], + // `assembleXerMultiProjectImport` vervangt dit vóór de reader retourneert door het echte, + // bestandsbrede verslag. De verplichte vorm voorkomt dat een XER-document zonder X10-data + // door een nieuwe codeweg kan ontsnappen. + report: { + projectsSeen: 1, + documentsOpened: 1, + emptyProjectsSkipped: 0, + baselineProjectsExcluded: 0, + baselinesMaterialized: 0, + danglingBaselineReferences: 0, + externalLinksPreserved: 0, + baselineExclusionReverted: false, + baselineFallbackReasons: [], + }, + resources: { + catalog: resourceCatalog, + assignments: resourceResult.sources.assignments, + issues: resourceResult.issues, + }, + metadata: { catalog: metadataCatalog }, + }, + }; +} + +/** + * Lees de oorspronkelijke XER-bytes. Eén PROJECT behoudt de enkelvoudige X4a-returnvorm, maar + * krijgt hetzelfde X4b-rapportcontract; meerdere PROJECT-rijen waaieren uit naar losse payloads. + * De baselinebeslissing zit vóór de openroute: die krijgt dus uitsluitend documenten die echt als + * tab geopend mogen worden. + */ +export function readXER(bytes: Uint8Array): XerOpenResult { + const tables = parseXerTables(bytes); + const scheduleOptionsIndex = indexXerScheduleOptions(tables); + const availableCalendarIds = new Set((tables.tables.get('CALENDAR')?.rows ?? []) + .map(row => row.cells.clndr_id?.trim()).filter((id): id is string => Boolean(id))); + for (const row of tables.tables.get('PROJECT')?.rows ?? []) { + const calendarId = row.cells.clndr_id?.trim(); + if (calendarId) availableCalendarIds.add(calendarId); + } + const resourceCatalog = buildXerResourceCatalog(tables, availableCalendarIds); + const metadataCatalog = buildXerMetadataCatalog(tables); + const taskResourceRowsByProject = indexXerTaskResourceRows(tables); + const projectRowsIndex = indexXerProjectRows(tables); + const unresolvedRelation = projectRowsIndex.relationResolutionIssues[0]; + if (unresolvedRelation) { + if (unresolvedRelation.reason === 'dangling') { + throw new XerImportError( + 'XER_DANGLING_LOCAL_RELATION', + `Ongescopeerde TASKPRED op regel ${unresolvedRelation.line} verwijst naar een ontbrekend endpoint via ${unresolvedRelation.field}.`, + { table: 'TASKPRED', field: unresolvedRelation.field, line: unresolvedRelation.line }, + ); + } + throw new XerImportError( + 'XER_AMBIGUOUS_LOCAL_RELATION', + `Ongescopeerde TASKPRED op regel ${unresolvedRelation.line} is ambigu: activiteiten '${unresolvedRelation.predecessorTaskId}' en '${unresolvedRelation.taskId}' identificeren niet één uniek lokaal project.`, + { table: 'TASKPRED', field: 'proj_id', line: unresolvedRelation.line }, + ); + } + const projectRows = tables.tables.get('PROJECT')?.rows ?? []; + const assembled = assembleXerMultiProjectImport( + tables, + projectId => readXerProject( + tables, + scheduleOptionsIndex, + projectId, + resourceCatalog, + metadataCatalog, + taskResourceRowsByProject, + projectRowsIndex, + ), + ); + // Precies één frozen bytearchief per ingelezen bestand. De projectmappers leveren alleen views; + // hier, ná alle projectdiagnostics, delen alle werkelijk geopende documenten dezelfde referentie. + const presentation = detectXerSourcePresentation(bytes); + const documentViews = Object.fromEntries(assembled.documents.map(document => [ + document.projectId, + archiveDocumentViewFromOwnedReaderMetadata(document.result.xer), + ])); + const taskSourceRowsByProject = Object.fromEntries( + [...projectRowsIndex.tasksByProject].map(([projectId, rows]) => [projectId, rows]), + ); + const archive = createXerSourceArchiveFromOwnedMetadata(bytes, { + ...presentation, + encoding: tables.report.encoding, + diagnostics: { + schemaVersion: 1, + file: { + tableReport: tables.report, + scheduleOptions: scheduleOptionsIndex.sourceArchive.diagnostics, + relationResolutionIssues: projectRowsIndex.relationResolutionIssues, + resourceCatalogIssues: resourceCatalog.issues, + metadataCatalogIssues: metadataCatalog.issues, + importReport: assembled.report, + }, + documentViews, + }, + readModel: { + schemaVersion: 1, + numberFormat: tables.numberFormat, + scheduleOptionsSourceArchive: scheduleOptionsIndex.sourceArchive, + resourceCatalog, + metadataCatalog, + taskSourceRowsByProject, + }, + }); + for (const document of assembled.documents) { + document.result.xerSourceArchive = archive; + document.result.xer = bindXerImportMetadataToArchive(archive, document.projectId); + } + if (assembled.results.length > 0) { + // De openvorm blijft compatibel: één PROJECT levert nog altijd één ImportResult. Alleen de + // rapportberekening loopt uniform door dezelfde X4b-kern als een meervoudig bestand. + return projectRows.length === 1 ? assembled.documents[0].result : assembled; + } + throw new XerImportError( + 'XER_EMPTY_PROJECT', + 'Geen enkel XER-project bevat activiteiten om te openen.', + { table: 'TASK' }, + ); +} + +/** + * Herbouw de volledige X9-runtimegrafiek uit uitsluitend de canonieke XER-bronbytes. + * + * T5 (laag 3, §3.8): levert naast het archief óók de bak-4-vastlegging per project. Dat is GRATIS — + * deze functie draaide al een volledige `readXER` en gooide `recordedTimes` alleen weg — en het is + * de enige vorm die per constructie identiek is aan het oorspronkelijke openen (zelfde bytes, + * zelfde code). Er wordt hier NIETS opnieuw afgeleid; zie `XerSourceReconstruction`. + * + * Bij een meerprojectenbestand krijgt élk project zijn eigen entry (de IFC-lezer kiest daaruit met + * `OPS_XerDocument`'s selector). Het archief zelf is bestandsbreed en per definitie voor alle + * resultaten dezelfde referentie. + */ +export function reconstructXerSourceFromBytes(bytes: Uint8Array): XerSourceReconstruction { + const opened = readXER(bytes); + const results = 'kind' in opened ? opened.results : [opened]; + const first = results[0]; + if (!first?.xerSourceArchive) { + throw new Error('De XER-bron leverde geen reconstruerbaar bronarchief op.'); + } + const recordedTimesByProject: Record> = {}; + for (const result of results) { + const projectId = result.xer?.sourceProjectId; + if (!projectId || !result.recordedTimes) continue; + recordedTimesByProject[projectId] = result.recordedTimes; + } + return { archive: first.xerSourceArchive, recordedTimesByProject }; +} diff --git a/src/services/xer/xerRecordedTimes.ts b/src/services/xer/xerRecordedTimes.ts new file mode 100644 index 000000000..99ba1eede --- /dev/null +++ b/src/services/xer/xerRecordedTimes.ts @@ -0,0 +1,129 @@ +import type { RecordedTime } from '@/engine/scheduler/recordedDates'; +import type { XerRow } from './xerTables'; +import { sourceInstant } from './xerInstant'; + +/** + * BAK 4 (XER-etappeplan §4.1, bijgesteld 2026-09-04 — eigenaarsbesluit X-O7 laag 3): "uitsluitend + * weergave/meetlat, nooit solverinvoer". Deze context abstraheert de bron-toegang die de aanroeper + * (`readXerProject` in `xerReader.ts`) al opbouwde — géén tweede kalender-/getalimplementatie hier. + */ +export interface XerRecordedTimesCtx { + /** Dezelfde tabel-genormaliseerde getalparser als de rest van de lezer voor de TASK-tabel. */ + numberOf: (row: XerRow, field: string) => number | null; + /** De taak-EFFECTIEVE kalender (na `clndr_id`-terugval én uurmodus-promotie). */ + effectiveCalendarOf: (row: XerRow) => { id: string; hourMode: boolean; minutesPerDay: number }; + /** Dezelfde id-vorming als de taakmapper (in de praktijk: de rauwe `task_id`-cel). */ + taskIdOf: (row: XerRow) => string; + /** De kritiekdefinitie die de lezer óók aan de solver geeft (`deriveXerScheduleOptions`: + * `critical_path_type` + `critical_drtn_hr_cnt`). Her-check laag 3, bevinding 7: `isCritical` + * werd afgeleid als `totalFloat <= 0`, terwijl dezelfde lezer een drempel > 0 of longest-path + * aan de solver gaf — in de modus kleurde dan een andere set balken kritiek dan erbuiten. + * Ontbreekt (oude aanroepers/tests) ⇒ de P6-default: totale speling, drempel 0. */ + criticalDefinition?: { mode: 'totalFloat' | 'longestPath'; thresholdHours?: number }; +} + +/** Uren (kan fractioneel zijn) → dagen, via `minutesPerDay` van de taak-effectieve kalender. Zelfde + * omrekening als `floatMinutesPerDay` in `tests/planning/check-xer-product-fidelity-x12.ts` / + * `xerTaskReplayProduct.ts`, in omgekeerde richting van `parseFloatMinutes` + * (`tests/planning/xerGroundTruth.ts`). Geen betrouwbare dagconversie zonder een positieve + * `minutesPerDay` (hardening: geen deling door 0/negatief uit een ongevalideerde kalenderwaarde) + * ⇒ as ontbreekt. */ +/** `isCritical` als AFLEIDING van de vastgelegde totale speling, met dezelfde definitie als de + * solver krijgt: totale speling ≤ drempel (P6's `critical_drtn_hr_cnt`, default 0). Longest-path- + * kritiek is uit een float niet af te leiden (P6 markeert daar de langste keten, ongeacht speling) + * ⇒ `undefined`: "niet vastgelegd", nooit een gok. Vergelijking in UREN, zodat een drempel uit het + * bestand niet eerst door de taakkalender hoeft. */ +function recordedIsCritical( + totalFloatHours: number | null, + definition: XerRecordedTimesCtx['criticalDefinition'], +): boolean | undefined { + if (totalFloatHours === null) return undefined; + if (definition?.mode === 'longestPath') return undefined; + return totalFloatHours <= (definition?.thresholdHours ?? 0); +} + +function hoursToDays(hours: number | null, minutesPerDay: number): number | undefined { + if (hours === null || !(minutesPerDay > 0)) return undefined; + const minutes = Math.round(hours * 60); + return minutes / minutesPerDay; +} + +/** + * Lees UITSLUITEND de zes P6-rekenuitvoerkolommen die de bron zelf opsloeg — `early_start_date`, + * `early_end_date`, `late_start_date`, `late_end_date`, `total_float_hr_cnt`, `free_float_hr_cnt` + * — uit de TASK-rijen van één project. Nooit solverinvoer, nooit geschreven naar `Task.time` door + * een lezer: het resultaat gaat uitsluitend naar `ImportResult.recordedTimes` + * (`src/services/importTypes.ts`) plus `recordedTimesOrigin: 'xer'`, en wordt door + * `captureRecordedDates` (`src/engine/scheduler/recordedDates.ts`) met voorrang gebruikt boven de + * IFC-`recordedFields`-route — nooit gemengd met die route. + * + * ISOLATIE (mutatiebewijs (a), `check-xer-field-whitelist.ts`): dit is de ENIGE plek in + * `src/services/xer/` waar deze zes kolomnamen als LEESPAD voorkomen. Eén bewuste nuance, die de + * testscan ook kent: `xerTables.ts` noemt `total_float_hr_cnt`/`free_float_hr_cnt` in + * `XER_DECIMAL_FIELDS` — dat is de getalnormalisatie van de tokenizer (bak-onafhankelijk), geen + * lezer van de waarde. Elke andere treffer binnen de map is een sluiproute. + * + * LAAGKEUZE — bewust GEEN terugval (X-O7 laag 3, §4.1-bijstelling): alleen `early_start_date` ÉN + * `early_end_date` bepalen of een taak een uitspraak krijgt. Ontbreekt of is één van beide + * onparseerbaar, dan wordt de taak VOLLEDIG overgeslagen — geen samengesteld paar uit + * verschillende assen, en geen terugval op `target_start_date`/`target_end_date`: die twee zijn + * XER-WHITELIST-INVOER (bak 1), geen P6-uitvoer, en zouden hier een verkeerde bewering doen over + * "wat P6 zelf berekende". `late_start_date`/`late_end_date`/`total_float_hr_cnt`/ + * `free_float_hr_cnt` staan LOS van die keuze: elk wordt individueel meegenomen zodra zijn eigen + * cel aanwezig én parseerbaar is. Ontbreekt zo'n cel, dan blijft het veld `undefined` — "niet + * vastgelegd" — nooit een verzonnen 0 en nooit gelijkgesteld aan de vroege kant. + * + * BEWUST GEEN ORDETOETS op `early_end_date < early_start_date` (her-check laag 3, bevinding 6 — + * gemeten en verworpen, 2026-09-07): dat is P6's EIGEN conventie voor voltooide activiteiten, geen + * scheve bron. Corpusmeting over 25 orakelbestanden: 2.049 van 11.953 taken met early-paar hebben + * einde vóór start, 2.036 daarvan `TK_Complete` (rehab-2: 2.042 van 6.976) — P6 zet `early_start` + * van een voltooide activiteit op de statusdatum en `early_end` op het werkelijke einde, precies wat + * de X12-fixture "Completed fidelity" en de statusdatumroute van de solver (laag 1) nabouwen. Een + * guard zou dus 29% van rehab-2's vastlegging weggooien en de modus laten liegen. Die voltooid- + * conventie dekt 97,4% van de inversies; de rest (54 van 2.090 corpusbreed: 50 `TK_NotStart`, + * 4 `TK_Active`, o.a. zes in rehab-2 zelf) zijn nul-duur-/mijlpaalrijen en scheve bronbestanden. + * Ook die tonen we zoals ze zijn: "nooit verzinnen" betekent hier "nooit corrigeren". Wat het + * bestand zegt, wordt getoond; de renderer ziet die inversie buiten de modus al even vaak. + * + * `isCritical` is een AFLEIDING, geen gelezen kolom: totale speling ≤ de kritiekdrempel uit + * `ctx.criticalDefinition` (default 0) zodra die speling vastgelegd is; onder longest-path-kritiek + * blijft hij `undefined` (zie `recordedIsCritical`). `driving_path_flag` blijft bak 2 (verboden + * terrein voor de lezer) — promotie tot eigen kolom is een latere, aparte afweging (plan §3). + * Zonder vastgelegde float blijft `isCritical` `undefined`. + */ +export function readXerRecordedTimes( + activityRows: readonly XerRow[], + ctx: XerRecordedTimesCtx, +): Record { + const times: Record = {}; + + for (const row of activityRows) { + const { hourMode, minutesPerDay } = ctx.effectiveCalendarOf(row); + + const start = sourceInstant(row.cells.early_start_date ?? '', hourMode); + const finish = sourceInstant(row.cells.early_end_date ?? '', hourMode); + if (start === undefined || finish === undefined) continue; // geen uitspraak — MOET geen terugval + + const lateStart = sourceInstant(row.cells.late_start_date ?? '', hourMode); + const lateFinish = sourceInstant(row.cells.late_end_date ?? '', hourMode); + const totalFloatHours = ctx.numberOf(row, 'total_float_hr_cnt'); + const totalFloat = hoursToDays(totalFloatHours, minutesPerDay); + const freeFloat = hoursToDays(ctx.numberOf(row, 'free_float_hr_cnt'), minutesPerDay); + // Her-check R3: zonder vastgelegde (omrekenbare) speling géén kritiekoordeel — ook wanneer de + // `minutesPerDay`-hardening de as liet vallen; anders zegt de tabel "niet vastgelegd" waar CSV/MCP + // een harde `false` naar buiten dragen. + const isCritical = totalFloat === undefined ? undefined : recordedIsCritical(totalFloatHours, ctx.criticalDefinition); + + times[ctx.taskIdOf(row)] = { + start, + finish, + ...(lateStart !== undefined ? { lateStart } : {}), + ...(lateFinish !== undefined ? { lateFinish } : {}), + ...(totalFloat !== undefined ? { totalFloat } : {}), + ...(freeFloat !== undefined ? { freeFloat } : {}), + ...(isCritical !== undefined ? { isCritical } : {}), + }; + } + + return times; +} diff --git a/src/services/xer/xerResourceAssignments.ts b/src/services/xer/xerResourceAssignments.ts new file mode 100644 index 000000000..5f3fd99f0 --- /dev/null +++ b/src/services/xer/xerResourceAssignments.ts @@ -0,0 +1,226 @@ +/** TASKRSRC-mapping met veldspecifieke P6-schalen en bron-id-indexen. */ + +import type { Resource, ResourceAssignment, ResourceCurve } from '@/types/resource'; +import type { + XerAssignmentCostsSource, XerAssignmentQuantitiesSource, XerAssignmentUnitScale, + XerReadonly, XerResourceCurveSource, XerResourceIssue, XerResourceRateSource, + XerResourceReadContext, XerResourceSource, XerRoleSource, XerTaskResourceSource, +} from './xerResourceTypes'; +import { parseXerNumber, XerImportError, type XerRow, type XerTables } from './xerTables'; +import { + deriveXerAssignmentSkipExpectation, + xerAssignmentSourceId, +} from './xerAssignmentProvenance'; +import { isFlatCurveValues, matchCurveValues, normalizeCurveValues } from '@/engine/contour/contourEngine'; +import { P6_NAME_TO_CURVE } from '@/services/p6/p6xmlWriter'; + +// MPXJ's XerUnitsHelper gebruikt 1.000.000 als afrondingsprecisie voor P6-werkhoeveelheden. +// Dit is geen deler voor *_qty_per_hr: OPS gebruikt daar net als XER 1 = 100%. +const P6_WORK_QUANTITY_PRECISION = 1_000_000; + +function numberOf(tables: XerTables, row: XerRow, field: string): number | null { + try { return parseXerNumber(row.cells[field] ?? '', tables.numberFormat); } catch (error) { + if (error instanceof XerImportError) { + throw new XerImportError(error.xerCode, error.message, + { table: 'TASKRSRC', field, line: row.line, encoding: tables.report.encoding }); + } + throw error; + } +} + +function presentNumber(target: Record, key: string, value: number | null): void { + if (value !== null) target[key] = value; +} +function workQuantityOf(tables: XerTables, row: XerRow, field: string): number | null { + const value = numberOf(tables, row, field); + return value === null ? null : Math.round(value * P6_WORK_QUANTITY_PRECISION) / P6_WORK_QUANTITY_PRECISION; +} +function quantitiesOf(tables: XerTables, row: XerRow): XerAssignmentQuantitiesSource { + const result: Record = {}; + presentNumber(result, 'remaining', workQuantityOf(tables, row, 'remain_qty')); + presentNumber(result, 'target', workQuantityOf(tables, row, 'target_qty')); + presentNumber(result, 'actualRegular', workQuantityOf(tables, row, 'act_reg_qty')); + presentNumber(result, 'actualOvertime', workQuantityOf(tables, row, 'act_ot_qty')); + presentNumber(result, 'thisPeriod', workQuantityOf(tables, row, 'act_this_per_qty')); + presentNumber(result, 'remainingPerHour', numberOf(tables, row, 'remain_qty_per_hr')); + presentNumber(result, 'targetPerHour', numberOf(tables, row, 'target_qty_per_hr')); + return result; +} +function costsOf(tables: XerTables, row: XerRow): XerAssignmentCostsSource { + const result: Record = {}; + presentNumber(result, 'perQuantity', numberOf(tables, row, 'cost_per_qty')); + presentNumber(result, 'target', numberOf(tables, row, 'target_cost')); + presentNumber(result, 'remaining', numberOf(tables, row, 'remain_cost')); + presentNumber(result, 'actualRegular', numberOf(tables, row, 'act_reg_cost')); + presentNumber(result, 'actualOvertime', numberOf(tables, row, 'act_ot_cost')); + presentNumber(result, 'thisPeriod', numberOf(tables, row, 'act_this_per_cost')); + return result; +} +function rawCurvesOf(row: XerRow): XerTaskResourceSource['rawCurves'] { + const result: XerTaskResourceSource['rawCurves'] = {}; + if (row.cells.target_crv?.trim()) result.target = row.cells.target_crv; + if (row.cells.remain_crv?.trim()) result.remaining = row.cells.remain_crv; + if (row.cells.actual_crv?.trim()) result.actual = row.cells.actual_crv; + return result; +} +function assertUniqueAssignmentIds(rows: readonly XerRow[]): void { + const firstLineById = new Map(); + for (const row of rows) { + const sourceId = row.cells.taskrsrc_id?.trim(); + if (!sourceId) continue; + const firstLine = firstLineById.get(sourceId); + if (firstLine !== undefined) { + throw new XerImportError('XER_DUPLICATE_ID', + `TASKRSRC.taskrsrc_id bevat dubbele id '${sourceId}' op regels ${firstLine} en ${row.line}.`, + { table: 'TASKRSRC', field: 'taskrsrc_id', line: row.line, lines: [firstLine, row.line] }); + } + firstLineById.set(sourceId, row.line); + } +} +/** + * OPS-curve + exacte 21-punts data voor een toewijzing, uit haar gekoppelde RSRCCURVDATA-bron — + * zelfde contract als `p6xmlReader.ts`'s ``-afhandeling: de contour-engine is de + * ENIGE curvemapping (geen eigen XER-best-fit meer). `matchCurveValues` doet de exacte tabelmatch; + * `P6_NAME_TO_CURVE[curv_name]` is de terugval voor een P6-curve die geen van de acht OPS-tabellen + * exact raakt (afgeronde/eigen P6-punten met een herkenbare P6-naam). Een vlakke curve is geen + * curve (spiegelt `p6xmlReader.ts`): geen `curve`, geen `curveValues`. + */ +function curveOf(curve: XerReadonly | undefined): { curve?: ResourceCurve; curveValues?: number[] } { + if (!curve?.numericPoints) return {}; + const values = normalizeCurveValues(curve.numericPoints); + if (!values || isFlatCurveValues(values)) return {}; + const matched = matchCurveValues(values) ?? P6_NAME_TO_CURVE[curve.name]; + return { ...(matched ? { curve: matched } : {}), curveValues: values }; +} + +function assignmentRate(quantities: XerAssignmentQuantitiesSource): number { + if ((quantities.targetPerHour ?? 0) > 0) return quantities.targetPerHour as number; + return quantities.remainingPerHour ?? quantities.targetPerHour ?? 0; +} +function unitsPerDay(rawRate: number, resource: Resource, context: XerResourceReadContext): { + value: number; scale: XerAssignmentUnitScale; +} { + if (resource.type === 'MATERIAL') { + const hoursPerDay = resource.calendarId + ? context.calendarHoursPerDay.get(resource.calendarId) ?? context.projectHoursPerDay + : context.projectHoursPerDay; + return { value: rawRate * hoursPerDay, scale: 'MATERIAL_PER_HOUR' }; + } + return { value: rawRate, scale: 'DIRECT_FRACTION' }; +} + +/** Eén lineaire partitionering; rijen zonder proj_id blijven alleen in de retained catalogus. */ +export function indexXerTaskResourceRows(tables: XerTables): ReadonlyMap { + const byProject = new Map(); + for (const row of tables.tables.get('TASKRSRC')?.rows ?? []) { + const projectId = row.cells.proj_id?.trim(); + if (!projectId) continue; + const current = byProject.get(projectId) ?? []; + current.push(row); + byProject.set(projectId, current); + } + for (const rows of byProject.values()) Object.freeze(rows); + return byProject; +} + +export function readXerResourceAssignments( + tables: XerTables, context: XerResourceReadContext, resources: readonly Resource[], + resourceSources: readonly XerReadonly[], roles: readonly XerReadonly[], + rates: readonly XerReadonly[], curves: readonly XerReadonly[], + rows: readonly XerRow[] = [], +): { assignments: ResourceAssignment[]; roleResources: Resource[]; sources: XerTaskResourceSource[]; issues: XerResourceIssue[] } { + assertUniqueAssignmentIds(rows); + const resourceSourceById = new Map(resourceSources.map(source => [source.sourceId, source])); + const resourceByInternalId = new Map(resources.map(resource => [resource.id, resource])); + const roleById = new Map(roles.map(role => [role.sourceId, role])); + const availableResourceSourceIds = new Set(resourceSources + .filter(source => resourceByInternalId.has(source.internalId)) + .map(source => source.sourceId)); + const availableRoleSourceIds = new Set(roleById.keys()); + const roleRatesById = new Map[]>(); + for (const rate of rates) { + if (rate.entity.kind !== 'ROLE') continue; + const current = roleRatesById.get(rate.entity.sourceId) ?? []; + current.push(rate); roleRatesById.set(rate.entity.sourceId, current); + } + const curveById = new Map(curves.map(curve => [curve.sourceId, curve])); + const roleResourceById = new Map(); + const assignments: ResourceAssignment[] = []; + const sources: XerTaskResourceSource[] = []; + const issues: XerResourceIssue[] = []; + for (const row of rows) { + const projectSourceId = row.cells.proj_id?.trim() || undefined; + const sourceId = xerAssignmentSourceId(row.cells, row.line); + const taskSourceId = row.cells.task_id.trim(); + const resourceSourceId = row.cells.rsrc_id?.trim() || undefined; + const roleSourceId = row.cells.role_id?.trim() || undefined; + const entitySourceId = resourceSourceId ?? roleSourceId ?? ''; + const entityKind = resourceSourceId ? 'RESOURCE' as const : 'ROLE' as const; + const entityInternalId = `${entityKind === 'RESOURCE' ? 'xer-resource' : 'xer-role'}:${entitySourceId}`; + const skipExpectation = deriveXerAssignmentSkipExpectation( + row.cells, + availableResourceSourceIds, + availableRoleSourceIds, + context.taskIds, + ); + const quantities = quantitiesOf(tables, row); + const assignedRole = resourceSourceId && roleSourceId ? roleById.get(roleSourceId) : undefined; + if (resourceSourceId && roleSourceId && !assignedRole) { + issues.push({ code: 'XER_ASSIGNMENT_ASSIGNED_ROLE_MISSING', table: 'TASKRSRC', line: row.line, + sourceId, fallback: 'RELATION_OMITTED' }); + } + const sourceBase = { rawRow: row, sourceId, internalId: `xer-assignment:${sourceId}`, taskSourceId, + ...(projectSourceId ? { projectSourceId } : {}), line: row.line, + entity: { kind: entityKind, sourceId: entitySourceId, internalId: entityInternalId }, + ...(assignedRole ? { assignedRole: { kind: 'ROLE' as const, sourceId: assignedRole.sourceId, internalId: assignedRole.internalId } } : {}), + quantities, ...(row.cells.curv_id?.trim() ? { curveSourceId: row.cells.curv_id.trim() } : {}), + rawCurves: rawCurvesOf(row), costs: costsOf(tables, row), + ...(row.cells.rate_type?.trim() ? { rateType: row.cells.rate_type.trim() } : {}), + ...(row.cells.cost_per_qty_source_type?.trim() ? { costSourceType: row.cells.cost_per_qty_source_type.trim() } : {}), + ...(row.cells.rsrc_type?.trim() ? { rawResourceType: row.cells.rsrc_type.trim() } : {}), + }; + let resource: Resource | undefined; + if (entityKind === 'RESOURCE') { + const source = resourceSourceById.get(entitySourceId); + resource = source ? resourceByInternalId.get(source.internalId) : undefined; + if (skipExpectation === 'XER_ASSIGNMENT_RESOURCE_MISSING') { + sources.push({ ...sourceBase, unitScale: 'DIRECT_FRACTION' }); + issues.push({ code: 'XER_ASSIGNMENT_RESOURCE_MISSING', table: 'TASKRSRC', line: row.line, sourceId, fallback: 'SKIPPED' }); + continue; + } + if (!resource) throw new Error(`XER-resource-index mist '${entitySourceId}' na provenancevalidatie.`); + } else { + const role = roleById.get(entitySourceId); + if (skipExpectation === 'XER_ASSIGNMENT_ROLE_MISSING') { + sources.push({ ...sourceBase, unitScale: 'DIRECT_FRACTION' }); + issues.push({ code: 'XER_ASSIGNMENT_ROLE_MISSING', table: 'TASKRSRC', line: row.line, sourceId, fallback: 'SKIPPED' }); + continue; + } + if (!role) throw new Error(`XER-role-index mist '${entitySourceId}' na provenancevalidatie.`); + resource = roleResourceById.get(role.internalId); + if (!resource) { + const roleRates = roleRatesById.get(role.sourceId) ?? []; + const flatRate = roleRates.find(rate => rate.costs[0] !== null); + const availabilitySteps = roleRates.flatMap(rate => rate.effectiveDate && rate.maxUnitsPerTime !== null && rate.maxUnitsPerTime >= 0 + ? [{ from: rate.effectiveDate, maxUnits: rate.maxUnitsPerTime }] : []); + resource = { id: role.internalId, name: role.name || role.shortName || role.sourceId, type: 'LABOR', description: role.description, + maxUnits: 0, ...(flatRate?.costs[0] !== null && flatRate?.costs[0] !== undefined ? { costPerHour: flatRate.costs[0] } : {}), + ...(availabilitySteps.length > 0 ? { availabilitySteps } : {}), ...(role.parentSourceId ? { parentId: `xer-role:${role.parentSourceId}` } : {}) }; + roleResourceById.set(role.internalId, resource); + } + } + const scaled = unitsPerDay(assignmentRate(quantities), resource, context); + sources.push({ ...sourceBase, unitScale: scaled.scale }); + if (skipExpectation === 'XER_ASSIGNMENT_TASK_MISSING') { + issues.push({ code: 'XER_ASSIGNMENT_TASK_MISSING', table: 'TASKRSRC', line: row.line, sourceId, fallback: 'SKIPPED' }); + continue; + } + const curveSourceId = row.cells.curv_id?.trim() || undefined; + const curve = curveSourceId ? curveById.get(curveSourceId) : undefined; + if (curveSourceId && !curve) issues.push({ code: 'XER_ASSIGNMENT_CURVE_MISSING', table: 'TASKRSRC', line: row.line, sourceId, fallback: 'UNIFORM' }); + const { curve: opsCurve, curveValues } = curveOf(curve); + assignments.push({ id: `xer-assignment:${sourceId}`, taskId: taskSourceId, resourceId: resource.id, unitsPerDay: scaled.value, + ...(opsCurve ? { curve: opsCurve } : {}), ...(curveValues ? { curveValues } : {}) }); + } + return { assignments, roleResources: Array.from(roleResourceById.values()), sources, issues }; +} diff --git a/src/services/xer/xerResourceCurves.ts b/src/services/xer/xerResourceCurves.ts new file mode 100644 index 000000000..2e8fbaa0e --- /dev/null +++ b/src/services/xer/xerResourceCurves.ts @@ -0,0 +1,64 @@ +/** XER-resourcecurves: rauwe 21-puntsbrondata (RSRCCURVDATA `pct_usage_0..pct_usage_20`), niet- + * destructief bewaard. De OPS-curve/`curveValues` van een toewijzing worden pas in + * `xerResourceAssignments.ts` afgeleid, via de contour-engine (`normalizeCurveValues`/ + * `matchCurveValues`) — deze module levert alleen de gevalideerde rauwe punten. */ + +import type { XerCurvePoints, XerResourceCurveSource, XerResourceIssue } from './xerResourceTypes'; +import { normalizeCurveValues } from '@/engine/contour/contourEngine'; +import { parseXerNumber, XerImportError, type XerRow, type XerTables } from './xerTables'; + +const CURVE_FIELDS = Array.from({ length: 21 }, (_, index) => `pct_usage_${index}`); + +function asCurveTuple(points: readonly T[]): XerCurvePoints { + return points as XerCurvePoints; +} + +function assertUniqueCurveIds(rows: readonly XerRow[]): void { + const firstLineById = new Map(); + for (const row of rows) { + const sourceId = row.cells.curv_id?.trim() ?? ''; + if (!sourceId) { + throw new XerImportError('XER_MISSING_REQUIRED_VALUE', + `RSRCCURVDATA.curv_id bevat een lege bronidentiteit op regel ${row.line}.`, + { table: 'RSRCCURVDATA', field: 'curv_id', missingValues: ['curv_id'], line: row.line }); + } + const firstLine = firstLineById.get(sourceId); + if (firstLine !== undefined) { + throw new XerImportError('XER_DUPLICATE_ID', + `RSRCCURVDATA.curv_id bevat dubbele id '${sourceId}' op regels ${firstLine} en ${row.line}.`, + { table: 'RSRCCURVDATA', field: 'curv_id', line: row.line, lines: [firstLine, row.line] }); + } + firstLineById.set(sourceId, row.line); + } +} + +export function readXerResourceCurves(tables: XerTables): { + sources: XerResourceCurveSource[]; + issues: XerResourceIssue[]; +} { + const rows = tables.tables.get('RSRCCURVDATA')?.rows ?? []; + assertUniqueCurveIds(rows); + const sources: XerResourceCurveSource[] = []; + const issues: XerResourceIssue[] = []; + for (const row of rows) { + const sourceId = row.cells.curv_id.trim(); + const rawPoints = asCurveTuple(CURVE_FIELDS.map(field => row.cells[field] ?? '')); + let numericPoints: XerCurvePoints | undefined; + try { + const parsed = rawPoints.map(point => parseXerNumber(point, tables.numberFormat)); + if (parsed.every((point): point is number => point !== null) && normalizeCurveValues(parsed)) { + numericPoints = asCurveTuple(parsed); + } + } catch { + // De curvelaag bewaart de tokens en rapporteert één rijgebonden fallback. + } + sources.push({ rawRow: row, sourceId, internalId: `xer-curve:${sourceId}`, line: row.line, + name: row.cells.curv_name?.trim() ?? '', rawPoints, + ...(numericPoints ? { numericPoints } : {}) }); + if (!numericPoints) { + issues.push({ code: 'XER_CURVE_INVALID_POINTS', table: 'RSRCCURVDATA', line: row.line, + sourceId, fallback: 'UNIFORM' }); + } + } + return { sources, issues }; +} diff --git a/src/services/xer/xerResourceTypes.ts b/src/services/xer/xerResourceTypes.ts new file mode 100644 index 000000000..aab96b219 --- /dev/null +++ b/src/services/xer/xerResourceTypes.ts @@ -0,0 +1,167 @@ +import type { Resource, ResourceAssignment } from '@/types/resource'; +import type { XerRow } from './xerTables'; + +/** + * Catalogusweergave zonder tweede type-eiland: behoudt tuplevormen en maakt + * elke laag van de gedeelde bestandsbrede grafiek readonly. De gewone Xer*-typen + * hieronder blijven juist de mutable per-projectprojectie beschrijven. + */ +export type XerReadonly = T extends readonly unknown[] + ? { readonly [Key in keyof T]: XerReadonly } + : T extends object + ? { readonly [Key in keyof T]: XerReadonly } + : T; + +/** Dezelfde parserrij, readonly aangeboden: volledige XER-brondata zonder tweede celobject. */ +export type XerSourceRow = Readonly<{ + line: XerRow['line']; + cells: Readonly; +}>; + +export interface XerResourceReadContext { + projectId: string; + projectCalendarId: string; + projectHoursPerDay: number; + availableCalendarIds: ReadonlySet; + calendarHoursPerDay: ReadonlyMap; + taskIds: ReadonlySet; +} + +export interface XerEntityIdentity { + kind: 'RESOURCE' | 'ROLE'; + sourceId: string; + internalId: string; + line: number; +} + +export interface XerResourceSource { + readonly rawRow: XerSourceRow; + sourceId: string; + internalId: string; + line: number; + rawType: string; + parentSourceId?: string; + calendarSourceId?: string; + defaultRoleSourceId?: string; + unitSourceId?: string; +} + +export interface XerRoleSource { + readonly rawRow: XerSourceRow; + sourceId: string; + internalId: string; + line: number; + name: string; + shortName: string; + description: string; + parentSourceId?: string; +} + +export interface XerResourceRateSource { + readonly rawRow: XerSourceRow; + sourceId: string; + internalId: string; + entity: XerAssignmentEntitySource; + line: number; + effectiveDate?: string; + maxUnitsPerTime: number | null; + costs: [number | null, number | null, number | null, number | null, number | null]; +} + +export type XerCurvePoints = [ + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, +]; + +export interface XerResourceCurveSource { + readonly rawRow: XerSourceRow; + sourceId: string; + internalId: string; + line: number; + name: string; + rawPoints: XerCurvePoints; + numericPoints?: XerCurvePoints; +} + +export type XerAssignmentUnitScale = 'DIRECT_FRACTION' | 'MATERIAL_PER_HOUR'; + +export interface XerAssignmentEntitySource { + kind: 'RESOURCE' | 'ROLE'; + sourceId: string; + internalId: string; +} + +export interface XerAssignmentQuantitiesSource { + remaining?: number; + target?: number; + actualRegular?: number; + actualOvertime?: number; + thisPeriod?: number; + remainingPerHour?: number; + targetPerHour?: number; +} + +export interface XerAssignmentCostsSource { + perQuantity?: number; + target?: number; + remaining?: number; + actualRegular?: number; + actualOvertime?: number; + thisPeriod?: number; +} + +export interface XerTaskResourceSource { + readonly rawRow: XerSourceRow; + sourceId: string; + internalId: string; + taskSourceId: string; + projectSourceId?: string; + line: number; + entity: XerAssignmentEntitySource; + assignedRole?: XerAssignmentEntitySource; + unitScale: XerAssignmentUnitScale; + quantities: XerAssignmentQuantitiesSource; + curveSourceId?: string; + rawCurves: { target?: string; remaining?: string; actual?: string }; + costs: XerAssignmentCostsSource; + rateType?: string; + costSourceType?: string; + rawResourceType?: string; +} + +export type XerResourceIssueCode = + | 'XER_RESOURCE_CALENDAR_MISSING' + | 'XER_RESOURCE_NONLABOR_FALLBACK' + | 'XER_RESOURCE_TYPE_FALLBACK' + | 'XER_RESOURCE_PARENT_MISSING' + | 'XER_RESOURCE_DEFAULT_ROLE_MISSING' + | 'XER_ROLE_PARENT_MISSING' + | 'XER_RESOURCE_RATE_OWNER_MISSING' + | 'XER_ROLE_RATE_OWNER_MISSING' + | 'XER_CURVE_INVALID_POINTS' + | 'XER_ASSIGNMENT_CURVE_MISSING' + | 'XER_ASSIGNMENT_RESOURCE_MISSING' + | 'XER_ASSIGNMENT_ROLE_MISSING' + | 'XER_ASSIGNMENT_ASSIGNED_ROLE_MISSING' + | 'XER_ASSIGNMENT_TASK_MISSING'; + +export interface XerResourceIssue { + code: XerResourceIssueCode; + table: 'RSRC' | 'ROLES' | 'RSRCRATE' | 'ROLERATE' | 'RSRCCURVDATA' | 'TASKRSRC'; + line: number; + sourceId: string; + fallback: 'PROJECT_CALENDAR' | 'EQUIPMENT' | 'LABOR' | 'UNIFORM' | 'SKIPPED' | 'RELATION_OMITTED'; +} + +export interface XerResourceReadResult { + resources: Resource[]; + assignments: ResourceAssignment[]; + identities: XerEntityIdentity[]; + sources: { + resources: XerResourceSource[]; + roles: XerRoleSource[]; + rates: XerResourceRateSource[]; + curves: XerResourceCurveSource[]; + assignments: XerTaskResourceSource[]; + }; + issues: XerResourceIssue[]; +} diff --git a/src/services/xer/xerResources.ts b/src/services/xer/xerResources.ts new file mode 100644 index 000000000..90a4e34ed --- /dev/null +++ b/src/services/xer/xerResources.ts @@ -0,0 +1,213 @@ +/** Zelfstandige XER-resourcekern. Broncatalogus is bestandsbreed; projectprojecties blijven mutable. */ + +import type { Resource, ResourceType } from '@/types/resource'; +import type { + XerCurvePoints, XerEntityIdentity, XerReadonly, XerResourceCurveSource, XerResourceIssue, + XerResourceRateSource, XerResourceReadContext, XerResourceReadResult, XerResourceSource, + XerRoleSource, +} from './xerResourceTypes'; +import { parseXerNumber, XerImportError, type XerRow, type XerTables } from './xerTables'; +import { readXerResourceCurves } from './xerResourceCurves'; +import { readXerResourceAssignments } from './xerResourceAssignments'; + +export type { + XerEntityIdentity, XerResourceIssue, XerResourceCurveSource, XerResourceRateSource, + XerResourceReadContext, XerResourceReadResult, XerResourceSource, XerRoleSource, XerTaskResourceSource, +} from './xerResourceTypes'; + +export interface XerResourceCatalog { + readonly resources: readonly XerReadonly[]; + readonly identities: readonly XerReadonly[]; + readonly rows: { + readonly resources: readonly XerReadonly[]; + readonly roles: readonly XerReadonly[]; + readonly rates: readonly XerReadonly[]; + readonly curves: readonly XerReadonly[]; + readonly assignments: readonly XerReadonly[]; + }; + readonly issues: readonly XerReadonly[]; +} + +function resourceInternalId(sourceId: string): string { return `xer-resource:${sourceId}`; } +function roleInternalId(sourceId: string): string { return `xer-role:${sourceId}`; } +function compareText(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; } +function numberOf(tables: XerTables, row: XerRow, table: 'RSRC' | 'RSRCRATE' | 'ROLERATE', field: string): number | null { + try { return parseXerNumber(row.cells[field] ?? '', tables.numberFormat); } catch (error) { + if (error instanceof XerImportError) { + throw new XerImportError(error.xerCode, error.message, + { table, field, line: row.line, encoding: tables.report.encoding }); + } + throw error; + } +} +function assertUniqueSourceIds(rows: readonly XerRow[], table: 'RSRC' | 'ROLES', field: string): void { + const firstLineById = new Map(); + for (const row of rows) { + const sourceId = row.cells[field]?.trim() ?? ''; + if (!sourceId) throw new XerImportError('XER_MISSING_REQUIRED_VALUE', + `${table}.${field} bevat een lege bronidentiteit op regel ${row.line}.`, + { table, field, missingValues: [field], line: row.line }); + const firstLine = firstLineById.get(sourceId); + if (firstLine !== undefined) throw new XerImportError('XER_DUPLICATE_ID', + `${table}.${field} bevat dubbele id '${sourceId}' op regels ${firstLine} en ${row.line}.`, + { table, field, line: row.line, lines: [firstLine, row.line] }); + firstLineById.set(sourceId, row.line); + } +} +function effectiveDate(raw: string): string | undefined { return raw.trim().match(/^(\d{4}-\d{2}-\d{2})(?:[ T]|$)/)?.[1]; } +function resourceType(row: XerRow, sourceId: string, issues: XerResourceIssue[]): ResourceType { + const raw = row.cells.rsrc_type?.trim().toLowerCase() ?? ''; + if (raw === 'rt_labor') return 'LABOR'; + if (raw === 'rt_mat') return 'MATERIAL'; + if (raw === 'rt_equip') { + issues.push({ code: 'XER_RESOURCE_NONLABOR_FALLBACK', table: 'RSRC', line: row.line, sourceId, fallback: 'EQUIPMENT' }); + return 'EQUIPMENT'; + } + issues.push({ code: 'XER_RESOURCE_TYPE_FALLBACK', table: 'RSRC', line: row.line, sourceId, fallback: 'LABOR' }); + return 'LABOR'; +} +function mapRoles(rows: readonly XerRow[], roleIds: ReadonlySet, issues: XerResourceIssue[]): { + identities: XerEntityIdentity[]; sources: XerRoleSource[]; +} { + const identities: XerEntityIdentity[] = []; + const sources: XerRoleSource[] = []; + for (const row of rows) { + const sourceId = row.cells.role_id.trim(); + const parentSourceId = row.cells.parent_role_id?.trim() || undefined; + if (parentSourceId && !roleIds.has(parentSourceId)) issues.push({ code: 'XER_ROLE_PARENT_MISSING', table: 'ROLES', line: row.line, sourceId, fallback: 'RELATION_OMITTED' }); + identities.push({ kind: 'ROLE', sourceId, internalId: roleInternalId(sourceId), line: row.line }); + sources.push({ rawRow: row, sourceId, internalId: roleInternalId(sourceId), line: row.line, + name: row.cells.role_name?.trim() ?? '', shortName: row.cells.role_short_name?.trim() ?? '', description: row.cells.role_descr ?? '', + ...(parentSourceId && roleIds.has(parentSourceId) ? { parentSourceId } : {}) }); + } + return { identities, sources }; +} +function readRatesForKind(tables: XerTables, kind: 'RESOURCE' | 'ROLE', ownerIds: ReadonlySet, issues: XerResourceIssue[]): XerResourceRateSource[] { + const table = kind === 'RESOURCE' ? 'RSRCRATE' as const : 'ROLERATE' as const; + const idField = kind === 'RESOURCE' ? 'rsrc_rate_id' : 'role_rate_id'; + const entityField = kind === 'RESOURCE' ? 'rsrc_id' : 'role_id'; + return (tables.tables.get(table)?.rows ?? []).map(row => { + const sourceId = row.cells[idField]?.trim() || `line-${row.line}`; + const entitySourceId = row.cells[entityField]?.trim() ?? ''; + if (!ownerIds.has(entitySourceId)) issues.push({ code: kind === 'RESOURCE' ? 'XER_RESOURCE_RATE_OWNER_MISSING' : 'XER_ROLE_RATE_OWNER_MISSING', table, line: row.line, sourceId, fallback: 'RELATION_OMITTED' }); + const costs: XerResourceRateSource['costs'] = [ + numberOf(tables, row, table, 'cost_per_qty'), numberOf(tables, row, table, 'cost_per_qty2'), + numberOf(tables, row, table, 'cost_per_qty3'), numberOf(tables, row, table, 'cost_per_qty4'), numberOf(tables, row, table, 'cost_per_qty5'), + ]; + const date = effectiveDate(row.cells.start_date ?? ''); + return { rawRow: row, sourceId, internalId: `xer-${kind === 'RESOURCE' ? 'resource' : 'role'}-rate:${sourceId}:${row.line}`, + entity: { kind, sourceId: entitySourceId, internalId: kind === 'RESOURCE' ? resourceInternalId(entitySourceId) : roleInternalId(entitySourceId) }, + line: row.line, ...(date ? { effectiveDate: date } : {}), maxUnitsPerTime: numberOf(tables, row, table, 'max_qty_per_hr'), costs }; + }); +} +/** + * Bevriest de volledige catalogusgrafiek in-place. De parser levert XerRow-objecten al bevroren; + * deze doorloop behoudt die referenties dus precies zoals zij uit de tabelparser kwamen en maakt + * geen tweede (potentieel zeer grote) raw-row-kopie. + */ +function deepFreeze(value: T, seen = new Set()): T { + if (value === null || typeof value !== 'object') return value; + const object = value as object; + // Parserrijen en hun cellmaps zijn al runtime-bevroren. Niet opnieuw door hun volledige, + // potentieel tienduizenden rijen tellende objectgraaf lopen is essentieel voor rehab-2. + if (Object.isFrozen(object)) return value; + if (seen.has(object)) return value; + seen.add(object); + for (const key of Reflect.ownKeys(object)) { + deepFreeze((object as Record)[key], seen); + } + Object.freeze(object); + return value; +} + +/** Bouw één bestandsbrede, immutable broncatalogus. TASKRSRC-rijen worden niet hier geprojecteerd. */ +export function buildXerResourceCatalog(tables: XerTables, availableCalendarIds: ReadonlySet): XerResourceCatalog { + const resourceRows = tables.tables.get('RSRC')?.rows ?? []; + const roleRows = tables.tables.get('ROLES')?.rows ?? []; + assertUniqueSourceIds(resourceRows, 'RSRC', 'rsrc_id'); + assertUniqueSourceIds(roleRows, 'ROLES', 'role_id'); + const issues: XerResourceIssue[] = []; + const resourceIds = new Set(resourceRows.map(row => row.cells.rsrc_id.trim())); + const roleIds = new Set(roleRows.map(row => row.cells.role_id.trim())); + const rates = [...readRatesForKind(tables, 'RESOURCE', resourceIds, issues), ...readRatesForKind(tables, 'ROLE', roleIds, issues)] + .sort((left, right) => compareText(left.entity.kind, right.entity.kind) || compareText(left.entity.sourceId, right.entity.sourceId) || compareText(left.effectiveDate ?? '', right.effectiveDate ?? '') || left.line - right.line); + const ratesByResource = new Map(); + for (const rate of rates) if (rate.entity.kind === 'RESOURCE') ratesByResource.set(rate.entity.sourceId, [...(ratesByResource.get(rate.entity.sourceId) ?? []), rate]); + const unitById = new Map((tables.tables.get('UMEASURE')?.rows ?? []).map(row => [row.cells.unit_id?.trim() ?? '', row.cells.unit_abbrev?.trim() || row.cells.unit_name?.trim() || ''])); + const identities: XerEntityIdentity[] = []; + const sources: XerResourceSource[] = []; + const resources: Resource[] = []; + for (const row of resourceRows) { + const sourceId = row.cells.rsrc_id.trim(); const internalId = resourceInternalId(sourceId); + identities.push({ kind: 'RESOURCE', sourceId, internalId, line: row.line }); + const parentSourceId = row.cells.parent_rsrc_id?.trim() || undefined; + const defaultRoleSourceId = row.cells.role_id?.trim() || undefined; + const calendarSourceId = row.cells.clndr_id?.trim() || undefined; + if (parentSourceId && !resourceIds.has(parentSourceId)) issues.push({ code: 'XER_RESOURCE_PARENT_MISSING', table: 'RSRC', line: row.line, sourceId, fallback: 'RELATION_OMITTED' }); + if (defaultRoleSourceId && !roleIds.has(defaultRoleSourceId)) issues.push({ code: 'XER_RESOURCE_DEFAULT_ROLE_MISSING', table: 'RSRC', line: row.line, sourceId, fallback: 'RELATION_OMITTED' }); + if (calendarSourceId && !availableCalendarIds.has(calendarSourceId)) issues.push({ code: 'XER_RESOURCE_CALENDAR_MISSING', table: 'RSRC', line: row.line, sourceId, fallback: 'PROJECT_CALENDAR' }); + const relatedRates = ratesByResource.get(sourceId) ?? []; + const flatRate = relatedRates.find(rate => rate.costs[0] !== null); + const sourceMaxUnits = numberOf(tables, row, 'RSRC', 'def_qty_per_hr'); + const availabilitySteps = relatedRates.flatMap(rate => rate.effectiveDate && rate.maxUnitsPerTime !== null && rate.maxUnitsPerTime >= 0 ? [{ from: rate.effectiveDate, maxUnits: rate.maxUnitsPerTime }] : []); + const unitSourceId = row.cells.unit_id?.trim() || undefined; + const unitOfMeasure = unitSourceId ? unitById.get(unitSourceId) : undefined; + resources.push({ id: internalId, name: row.cells.rsrc_name?.trim() || row.cells.rsrc_short_name?.trim() || sourceId, + type: resourceType(row, sourceId, issues), description: row.cells.rsrc_notes ?? '', maxUnits: sourceMaxUnits !== null && sourceMaxUnits >= 0 ? sourceMaxUnits : 0, + ...(calendarSourceId && availableCalendarIds.has(calendarSourceId) ? { calendarId: calendarSourceId } : {}), ...(unitOfMeasure ? { unitOfMeasure } : {}), + ...(flatRate?.costs[0] !== null && flatRate?.costs[0] !== undefined ? { costPerHour: flatRate.costs[0] } : {}), ...(availabilitySteps.length ? { availabilitySteps } : {}), + ...(parentSourceId && resourceIds.has(parentSourceId) ? { parentId: resourceInternalId(parentSourceId) } : {}) }); + sources.push({ rawRow: row, sourceId, internalId, line: row.line, rawType: row.cells.rsrc_type?.trim() ?? '', + ...(parentSourceId && resourceIds.has(parentSourceId) ? { parentSourceId } : {}), ...(calendarSourceId ? { calendarSourceId } : {}), + ...(defaultRoleSourceId && roleIds.has(defaultRoleSourceId) ? { defaultRoleSourceId } : {}), ...(unitSourceId ? { unitSourceId } : {}) }); + } + const roles = mapRoles(roleRows, roleIds, issues); + const curves = readXerResourceCurves(tables); + issues.push(...curves.issues); + return deepFreeze({ + resources, + identities: [...identities, ...roles.identities], + rows: { + resources: sources, + roles: roles.sources, + rates, + curves: curves.sources, + assignments: [...(tables.tables.get('TASKRSRC')?.rows ?? [])], + }, + issues, + }) as XerResourceCatalog; +} + +function cloneResourceSource(source: XerReadonly): XerResourceSource { + return { ...source }; +} +function cloneRoleSource(source: XerReadonly): XerRoleSource { + return { ...source }; +} +function cloneRateSource(source: XerReadonly): XerResourceRateSource { + return { ...source, entity: { ...source.entity }, costs: [...source.costs] as XerResourceRateSource['costs'] }; +} +function cloneCurveSource(source: XerReadonly): XerResourceCurveSource { + const { rawPoints, numericPoints, ...withoutPoints } = source; + return { + ...withoutPoints, + rawPoints: [...rawPoints] as XerCurvePoints, + ...(numericPoints ? { numericPoints: [...numericPoints] as XerCurvePoints } : {}), + }; +} + +/** Projectbound: kopieert uitsluitend de mutable projectprojectie en leest alleen zijn lineair gevonden TASKRSRC-rijen. */ +export function materializeXerResources(catalog: XerResourceCatalog, tables: XerTables, context: XerResourceReadContext, assignmentRows: readonly XerRow[]): XerResourceReadResult { + const resources = structuredClone(catalog.resources) as Resource[]; + const assignments = readXerResourceAssignments(tables, context, resources, catalog.rows.resources, catalog.rows.roles, catalog.rows.rates, catalog.rows.curves, assignmentRows); + return { resources: [...resources, ...assignments.roleResources], assignments: assignments.assignments, + identities: catalog.identities.map(identity => ({ ...identity })), + sources: { + resources: catalog.rows.resources.map(cloneResourceSource), + roles: catalog.rows.roles.map(cloneRoleSource), + rates: catalog.rows.rates.map(cloneRateSource), + curves: catalog.rows.curves.map(cloneCurveSource), + assignments: assignments.sources, + }, + issues: [...catalog.issues.map(issue => ({ ...issue })), ...assignments.issues] }; +} diff --git a/src/services/xer/xerScheduleOptions.ts b/src/services/xer/xerScheduleOptions.ts new file mode 100644 index 000000000..8711efdab --- /dev/null +++ b/src/services/xer/xerScheduleOptions.ts @@ -0,0 +1,458 @@ +/** + * Pure XER-SCHEDOPTIONS-afleiding. `xerReader` roept deze module één keer per geopend project aan; + * de mapping blijft hier zelfstandig zodat projectselectie en schedulingssemantiek niet mengen. + * + * Voor de betekenis van de P6-enumtokens is MPXJ als gedragsreferentie geraadpleegd + * (https://github.com/joniles/mpxj, LGPL-2.1, Jon Iles e.a.). Er is geen MPXJ-code overgenomen; + * mapping, defaults, kolommatrix en terugvalrapportage zijn hier zelfstandig geïmplementeerd. + */ +import type { ProgressMode, SchedulingOptions } from '@/types/project'; +import type { + XerScheduleOptionFallback, + XerScheduleOptionsDiagnostic, + XerScheduleOptionsMetadata, + XerScheduleOptionsSourceArchive, + XerScheduleOptionsSourceRow, +} from '../importTypes'; +import { parseXerNumber, type XerRow, type XerTables } from './xerTables'; + +export type { + XerScheduleOptionFallback, + XerScheduleOptionsDiagnostic, + XerScheduleOptionsMetadata, + XerScheduleOptionsSourceArchive, + XerScheduleOptionsSourceRow, +} from '../importTypes'; + +export interface XerScheduleOptionsResult extends XerScheduleOptionsMetadata { + progressMode: ProgressMode; + schedulingOptions: SchedulingOptions; +} + +interface IndexedSourceRow { + row: XerRow; + sourceRowIndex: number; +} + +/** Eenmalige bestandsindex: afleiding per project doet hierna uitsluitend Map-lookups. */ +export interface XerScheduleOptionsIndex { + numberFormat: XerTables['numberFormat']; + projectRowsById: ReadonlyMap; + scheduleRowsById: ReadonlyMap; + sourceRowIndexesByProject: ReadonlyMap; + diagnosticsByProject: ReadonlyMap; + sourceArchive: XerScheduleOptionsSourceArchive; +} + +export type XerScheduleOptionColumnDisposition = + | { field: string; status: 'mapped'; target: string } + | { field: string; status: 'ignored' | 'todo'; reason: string }; + +const resourceLevelingReason = 'De CPM-solver voert geen resource-nivellering uit; er is in X5 geen veilige mapping.'; + +/** Exhaustieve bestemming van de 27 kolommen uit de openbare corpus-union. */ +export const XER_SCHEDOPTIONS_COLUMN_DISPOSITIONS: readonly XerScheduleOptionColumnDisposition[] = [ + { field: 'enable_multiple_longest_path_calc', status: 'mapped', target: 'schedulingOptions.floatPaths.enabled' }, + { + field: 'key_activity_for_multiple_longest_paths', + status: 'todo', + reason: 'OPS kan meerdere floatpaden berekenen maar heeft nog geen eindactiviteit-anker in het model.', + }, + { field: 'level_all_rsrc_flag', status: 'ignored', reason: resourceLevelingReason }, + { field: 'level_float_thrs_cnt', status: 'ignored', reason: resourceLevelingReason }, + { field: 'level_keep_sched_date_flag', status: 'ignored', reason: resourceLevelingReason }, + { field: 'level_outer_assign_flag', status: 'ignored', reason: resourceLevelingReason }, + { field: 'level_outer_assign_priority', status: 'ignored', reason: resourceLevelingReason }, + { field: 'level_over_alloc_pct', status: 'ignored', reason: resourceLevelingReason }, + { field: 'level_within_float_flag', status: 'ignored', reason: resourceLevelingReason }, + { field: 'levelprioritylist', status: 'ignored', reason: resourceLevelingReason }, + { field: 'limit_multiple_longest_path_calc', status: 'mapped', target: 'schedulingOptions.floatPaths.maxPaths' }, + { field: 'max_multiple_longest_path', status: 'mapped', target: 'schedulingOptions.floatPaths.maxPaths' }, + { field: 'proj_id', status: 'mapped', target: 'SCHEDOPTIONS-rijselectie per project' }, + { field: 'sched_calendar_on_relationship_lag', status: 'mapped', target: 'schedulingOptions.lagCalendar' }, + { field: 'sched_float_type', status: 'mapped', target: 'schedulingOptions.totalFloatMode' }, + { + field: 'sched_lag_early_start_flag', + status: 'todo', + reason: 'De N-semantiek raakt voortgang en actuals; X7 moet die taakvelden eerst aan de solver leveren.', + }, + { field: 'sched_open_critical_flag', status: 'mapped', target: 'schedulingOptions.makeOpenEndedCritical' }, + { + field: 'sched_outer_depend_type', + status: 'todo', + reason: 'Externe-relatieplanning vereist het X4b-multi-projectpad en valt buiten de X5-schrijfgrens.', + }, + { field: 'sched_progress_override', status: 'mapped', target: 'project.progressMode' }, + { field: 'sched_retained_logic', status: 'mapped', target: 'project.progressMode' }, + { + field: 'sched_setplantoforecast', + status: 'todo', + reason: 'OPS heeft nog geen afzonderlijke planned-versus-forecast-taakdatums die dit veilig kunnen consumeren.', + }, + { field: 'sched_use_expect_end_flag', status: 'mapped', target: 'schedulingOptions.useExpectedFinishDates' }, + { + field: 'sched_use_project_end_date_for_float', + status: 'mapped', + target: 'schedulingOptions.useProjectEndDateForFloat', + }, + { field: 'schedhash', status: 'ignored', reason: 'Technische bronhash; geen planningssemantiek of stabiele OPS-identiteit.' }, + { field: 'schedoptions_id', status: 'ignored', reason: 'Technische rij-identiteit; proj_id is de projectbinding.' }, + { field: 'use_total_float', status: 'mapped', target: 'schedulingOptions.floatPaths.method (dialectalias)' }, + { + field: 'use_total_float_multiple_longest_paths', + status: 'mapped', + target: 'schedulingOptions.floatPaths.method', + }, +] as const; + +/** XER-eigen defaults; worden nooit als algemene OPS-projectdefaults toegepast. */ +export const XER_SCHEDULING_DEFAULTS = { + progressMode: 'RETAINED_LOGIC', + schedulingOptions: { + p6Source: 'XER', + lagCalendar: 'predecessor', + criticalDefinition: { mode: 'totalFloat', thresholdHours: 0 }, + totalFloatMode: 'finish', + makeOpenEndedCritical: false, + useExpectedFinishDates: true, + preserveActualDatesInBackwardPass: true, + clampNegativeFreeFloat: true, + p6ZeroDurationUsesPlannedBoundary: true, + p6UseTaskPlannedStartFloor: true, + p6FinishMilestoneBoundaryWindow: true, + p6PreserveActualInstants: true, + p6UseRemainingStartForProgress: false, + p6PreserveZeroDurationConstraintInstants: true, + p6CompletedLateFromRemainingWindow: true, + }, +} as const satisfies { progressMode: ProgressMode; schedulingOptions: SchedulingOptions }; + +function freshDefaults(): { progressMode: ProgressMode; schedulingOptions: SchedulingOptions } { + return { + progressMode: XER_SCHEDULING_DEFAULTS.progressMode, + schedulingOptions: { + ...XER_SCHEDULING_DEFAULTS.schedulingOptions, + criticalDefinition: { ...XER_SCHEDULING_DEFAULTS.schedulingOptions.criticalDefinition }, + }, + }; +} + +function reportFallback( + fallbacks: XerScheduleOptionFallback[], + row: XerRow, + field: string, + token: string, + fallback: string, +): void { + fallbacks.push({ field, token, fallback, line: row.line }); +} + +function enumValue( + row: XerRow, + field: string, + mapping: Readonly>, + fallback: T, + fallbacks: XerScheduleOptionFallback[], +): T { + const token = row.cells[field]?.trim() ?? ''; + if (!token) return fallback; + const value = mapping[token.toUpperCase()]; + if (value !== undefined) return value; + reportFallback(fallbacks, row, field, token, fallback); + return fallback; +} + +function booleanValue( + row: XerRow, + field: string, + fallback: boolean, + fallbacks: XerScheduleOptionFallback[], +): boolean { + const token = row.cells[field]?.trim() ?? ''; + if (!token) return fallback; + if (token.toUpperCase() === 'Y') return true; + if (token.toUpperCase() === 'N') return false; + reportFallback(fallbacks, row, field, token, fallback ? 'true' : 'false'); + return fallback; +} + +function retainedBooleanValue( + row: XerRow, + field: string, + fallbacks: XerScheduleOptionFallback[], +): boolean | undefined { + const token = row.cells[field]?.trim() ?? ''; + if (!token) return undefined; + if (token.toUpperCase() === 'Y') return true; + if (token.toUpperCase() === 'N') return false; + reportFallback(fallbacks, row, field, token, 'niet bewaard'); + return undefined; +} + +function projectRemainingStartValue( + row: XerRow | undefined, + fallbacks: XerScheduleOptionFallback[], +): boolean { + if (!row) return false; + const token = row.cells.rem_target_link_flag?.trim() ?? ''; + if (!token) return false; + if (token.toUpperCase() === 'Y') return true; + if (token.toUpperCase() === 'N') return false; + reportFallback(fallbacks, row, 'rem_target_link_flag', token, 'false'); + return false; +} + +/** "Aantoonbaar retained logic" voor de completed-late-klem: geen declaratie (P6-default) of + * exact `sched_retained_logic=Y`/`sched_progress_override=N` — elk half of afwijkend paar (N/N + * "Actual Dates", Y/Y, N/Y, onbekende tokens) telt als NIET aantoonbaar. Bewust strenger dan + * `progressModeValue`, die voor de solver zelf op RETAINED_LOGIC terugvalt. */ +function declaresRetainedLogic(row: XerRow): boolean { + const retained = row.cells.sched_retained_logic?.trim().toUpperCase() ?? ''; + const override = row.cells.sched_progress_override?.trim().toUpperCase() ?? ''; + return (retained === '' || retained === 'Y') && (override === '' || override === 'N'); +} + +function progressModeValue( + row: XerRow, + fallbacks: XerScheduleOptionFallback[], +): ProgressMode { + const retainedToken = row.cells.sched_retained_logic?.trim() ?? ''; + const overrideToken = row.cells.sched_progress_override?.trim() ?? ''; + if (!retainedToken && !overrideToken) return 'RETAINED_LOGIC'; + + const retained = booleanValue(row, 'sched_retained_logic', true, fallbacks); + const override = booleanValue(row, 'sched_progress_override', false, fallbacks); + if (retained && !override) return 'RETAINED_LOGIC'; + if (!retained && override) return 'PROGRESS_OVERRIDE'; + + // P6 kent naast Retained Logic en Progress Override ook Actual Dates (N/N). Het huidige OPS- + // model heeft daarvoor nog geen derde modus. Ook een tegenstrijdige Y/Y-combinatie is niet + // eenduidig. Beide vallen zichtbaar terug; lege velden hierboven blijven gewone defaults. + reportFallback( + fallbacks, + row, + 'sched_retained_logic/sched_progress_override', + `${retainedToken || '(leeg)'}/${overrideToken || '(leeg)'}`, + 'RETAINED_LOGIC', + ); + return 'RETAINED_LOGIC'; +} + +function projectCriticalDefinition( + index: XerScheduleOptionsIndex, + projectId: string, + fallbacks: XerScheduleOptionFallback[], +): SchedulingOptions['criticalDefinition'] { + const row = index.projectRowsById.get(projectId)?.row; + if (!row) return { ...XER_SCHEDULING_DEFAULTS.schedulingOptions.criticalDefinition }; + const token = row.cells.critical_path_type?.trim() ?? ''; + if (token && token.toUpperCase() === 'CT_DRIVPATH') return { mode: 'longestPath' }; + if (token && token.toUpperCase() !== 'CT_TOTFLOAT') { + reportFallback(fallbacks, row, 'critical_path_type', token, 'totalFloat'); + } + const thresholdHours = parseXerNumber(row.cells.critical_drtn_hr_cnt ?? '', index.numberFormat) ?? 0; + return { mode: 'totalFloat', thresholdHours }; +} + +function appendSourceRows( + rows: readonly XerRow[], + table: XerScheduleOptionsSourceRow['table'], + sourceRows: XerScheduleOptionsSourceRow[], + indexesByProject: Map, + indexedRowsByProject: Map, +): void { + for (const row of rows) { + const sourceRowIndex = sourceRows.length; + const sourceRow: XerScheduleOptionsSourceRow = { table, line: row.line, cells: { ...row.cells } }; + sourceRows.push(sourceRow); + const projectId = sourceRow.cells.proj_id?.trim() ?? ''; + const indexes = indexesByProject.get(projectId) ?? []; + indexes.push(sourceRowIndex); + indexesByProject.set(projectId, indexes); + const indexed = indexedRowsByProject.get(projectId) ?? []; + indexed.push({ row, sourceRowIndex }); + indexedRowsByProject.set(projectId, indexed); + } +} + +/** + * Indexeer PROJECT en SCHEDOPTIONS elk precies eenmaal. Een dubbele SCHEDOPTIONS-proj_id wordt + * niet stil gekozen: alle raw rijen blijven bewaard, een typed diagnose wordt uitgegeven en de + * onzekere SCHEDOPTIONS-semantiek wordt voor dat project niet toegepast. + */ +export function indexXerScheduleOptions(tables: XerTables): XerScheduleOptionsIndex { + const sourceRows: XerScheduleOptionsSourceRow[] = []; + const sourceRowIndexesByProject = new Map(); + const projectCandidates = new Map(); + const scheduleCandidates = new Map(); + appendSourceRows( + tables.tables.get('PROJECT')?.rows ?? [], + 'PROJECT', + sourceRows, + sourceRowIndexesByProject, + projectCandidates, + ); + appendSourceRows( + tables.tables.get('SCHEDOPTIONS')?.rows ?? [], + 'SCHEDOPTIONS', + sourceRows, + sourceRowIndexesByProject, + scheduleCandidates, + ); + + const projectRowsById = new Map(); + for (const [projectId, rows] of projectCandidates) { + if (rows.length === 1) projectRowsById.set(projectId, rows[0]); + } + const scheduleRowsById = new Map(); + const diagnosticsByProject = new Map(); + const diagnostics: XerScheduleOptionsDiagnostic[] = []; + for (const [projectId, rows] of scheduleCandidates) { + if (rows.length === 1) { + scheduleRowsById.set(projectId, rows[0]); + continue; + } + const diagnostic: XerScheduleOptionsDiagnostic = { + code: 'XER_DUPLICATE_SCHEDOPTIONS_PROJ_ID', + projectId, + rowIndexes: rows.map(item => item.sourceRowIndex), + lines: rows.map(item => item.row.line), + }; + diagnostics.push(diagnostic); + diagnosticsByProject.set(projectId, [diagnostic]); + } + const projectIds = new Set(projectCandidates.keys()); + const unmatchedScheduleOptionsRowIndexes = [...scheduleCandidates] + .filter(([projectId]) => !projectIds.has(projectId)) + .flatMap(([, rows]) => rows.map(item => item.sourceRowIndex)); + + return { + numberFormat: tables.numberFormat, + projectRowsById, + scheduleRowsById, + sourceRowIndexesByProject, + diagnosticsByProject, + sourceArchive: { rows: sourceRows, unmatchedScheduleOptionsRowIndexes, diagnostics }, + }; +} + +export function deriveXerScheduleOptions( + index: XerScheduleOptionsIndex, + projectId: string, + context: { hoursPerDay?: number; taskCount?: number } = {}, +): XerScheduleOptionsResult { + const defaults = freshDefaults(); + const projectRow = index.projectRowsById.get(projectId)?.row; + const fallbacks: XerScheduleOptionFallback[] = []; + // PROJECT.rem_target_link_flag is het documentgedragen P6-signaal dat remaining en target + // gekoppeld blijven. Alleen dan beschrijven de XER Early/Late Start-assen bij een lopende taak + // het resterende werkvenster; ontbrekend/N behoudt de historische Actual Start. De afleiding + // gebruikt uitsluitend PROJECT-invoer en nooit early/late/float-orakelcellen. + defaults.schedulingOptions.p6UseRemainingStartForProgress = projectRemainingStartValue( + projectRow, + fallbacks, + ); + const sourceRowIndexes = [...(index.sourceRowIndexesByProject.get(projectId) ?? [])]; + const retainedRows = sourceRowIndexes.map(rowIndex => index.sourceArchive.rows[rowIndex]); + const diagnostics = [...(index.diagnosticsByProject.get(projectId) ?? [])]; + defaults.schedulingOptions.criticalDefinition = projectCriticalDefinition( + index, + projectId, + fallbacks, + ); + + const row = index.scheduleRowsById.get(projectId)?.row; + if (!row) { + return { + source: 'xer-defaults', + progressMode: defaults.progressMode, + schedulingOptions: defaults.schedulingOptions, + retainedSource: {}, + fallbacks, + diagnostics, + sourceArchive: index.sourceArchive, + sourceRowIndexes, + sourceRows: retainedRows, + }; + } + + const schedulingOptions: SchedulingOptions = { + ...defaults.schedulingOptions, + lagCalendar: enumValue>( + row, 'sched_calendar_on_relationship_lag', { + RCAL_PREDECESSOR: 'predecessor', + RCAL_SUCCESSOR: 'successor', + RCAL_24HOUR: '24hour', + RCAL_PROJDEFAULT: 'projectDefault', + }, 'predecessor', fallbacks), + totalFloatMode: enumValue>( + row, 'sched_float_type', { + FT_SS: 'start', + FT_FF: 'finish', + FT_MIN: 'smallest', + }, 'finish', fallbacks), + makeOpenEndedCritical: booleanValue(row, 'sched_open_critical_flag', false, fallbacks), + useExpectedFinishDates: booleanValue(row, 'sched_use_expect_end_flag', true, fallbacks), + }; + + const retainedProjectEndValue = retainedBooleanValue( + row, + 'sched_use_project_end_date_for_float', + fallbacks, + ); + const retainedSource = retainedProjectEndValue === undefined + ? {} + : { sched_use_project_end_date_for_float: retainedProjectEndValue }; + if (retainedProjectEndValue !== undefined) { + schedulingOptions.useProjectEndDateForFloat = retainedProjectEndValue; + } + + const progressMode = progressModeValue(row, fallbacks); + // X-O7 laag 1, klasse (i) (review-bevinding 6, aangescherpt in de her-review, bevinding 3): de + // bewijsbasis voor `p6CompletedLateFromRemainingWindow` is uitsluitend RETAINED_LOGIC-corpus + // (rehab-2, geen enkel gemeten bestand declareert iets anders) — zie het docblok bij het veld in + // `types/project.ts`. De klem is daarom fail-closed op "aantoonbaar retained logic": de vlag + // blijft alleen aan wanneer de bron géén van beide velden declareert (P6-default, rehab-2) of + // exact Y/N zegt. Niet alleen `sched_progress_override=Y` (N/Y) zet 'm uit, maar óók P6's + // "Actual Dates" (N/N) en de tegenstrijdige Y/Y — die vielen in `progressModeValue` zichtbaar + // terug op RETAINED_LOGIC en hielden de vlag stil aan, terwijl juist Actual Dates de modus is + // waarin P6 voltooid werk anders behandelt en daar geen enkele meting van bestaat. + if (!declaresRetainedLogic(row)) { + schedulingOptions.p6CompletedLateFromRemainingWindow = false; + } + + const floatPathFields = [ + 'enable_multiple_longest_path_calc', + 'use_total_float_multiple_longest_paths', + 'use_total_float', + 'limit_multiple_longest_path_calc', + 'max_multiple_longest_path', + ]; + if (floatPathFields.some(field => (row.cells[field]?.trim() ?? '') !== '')) { + const enabled = booleanValue(row, 'enable_multiple_longest_path_calc', false, fallbacks); + const methodField = row.cells.use_total_float_multiple_longest_paths?.trim() + ? 'use_total_float_multiple_longest_paths' + : 'use_total_float'; + const totalFloat = booleanValue(row, methodField, false, fallbacks); + const limited = booleanValue(row, 'limit_multiple_longest_path_calc', true, fallbacks); + const parsedMaximum = parseXerNumber(row.cells.max_multiple_longest_path ?? '', index.numberFormat); + const taskCount = Math.max(1, Math.floor(context.taskCount ?? Number.MAX_SAFE_INTEGER)); + schedulingOptions.floatPaths = { + enabled, + method: totalFloat ? 'TOTAL_FLOAT' : 'FREE_FLOAT', + maxPaths: limited ? Math.max(1, Math.floor(parsedMaximum ?? 10)) : taskCount, + }; + } + + return { + source: 'schedoptions', + progressMode, + schedulingOptions, + retainedSource, + fallbacks, + diagnostics, + sourceArchive: index.sourceArchive, + sourceRowIndexes, + sourceRows: retainedRows, + }; +} diff --git a/src/services/xer/xerTables.ts b/src/services/xer/xerTables.ts new file mode 100644 index 000000000..e8d5cdf43 --- /dev/null +++ b/src/services/xer/xerTables.ts @@ -0,0 +1,762 @@ +/** + * XER-tabelgrammatica, zelfstandig hergeimplementeerd voor Open Planner Studio. + * + * Voor begrip van de recordstructuur en de tweepas rond CURRTYPE is MPXJ geraadpleegd: + * https://github.com/joniles/mpxj (Primavera `XerFile`, LGPL-2.1, Jon Iles e.a.). Er is geen + * MPXJ-code overgenomen; deze parser heeft een eigen tokenizer, rapportvorm en getalparser. + */ + +export type XerEncoding = 'utf-8' | 'utf-16le' | 'utf-16be' | 'windows-1252'; +/** + * XER is inhoudelijk tekst, maar encodingdetectie vereist de oorspronkelijke bytes. X4a moet de + * registry daarom onder het huidige tweewaardige contract als `binary` bedraden en mag een reeds + * gedecodeerde string nooit opnieuw encoderen. + */ +export const XER_TRANSPORT_KIND = 'binary' as const; +export type XerByteInput = Uint8Array; +export type XerImportErrorCode = + | 'XER_INVALID_INPUT' + | 'XER_INVALID_FILE' + | 'XER_INVALID_ENCODING' + | 'XER_DUPLICATE_TABLE' + | 'XER_MISSING_REQUIRED_COLUMNS' + | 'XER_MISSING_REQUIRED_VALUE' + | 'XER_AMBIGUOUS_DECIMAL' + | 'XER_INVALID_NUMBER_FORMAT' + | 'XER_INVALID_NUMBER' + | 'XER_SINGLE_PROJECT_REQUIRED' + | 'XER_EMPTY_PROJECT' + | 'XER_DUPLICATE_ID' + | 'XER_AMBIGUOUS_LOCAL_RELATION' + | 'XER_DANGLING_LOCAL_RELATION'; + +export class XerImportError extends Error { + readonly xerCode: XerImportErrorCode; + readonly table?: string; + readonly field?: string; + readonly missingColumns?: string[]; + readonly missingValues?: string[]; + readonly line?: number; + readonly lines?: number[]; + readonly encoding?: XerEncoding; + + constructor( + xerCode: XerImportErrorCode, + message: string, + context?: { + table?: string; + field?: string; + missingColumns?: string[]; + missingValues?: string[]; + line?: number; + lines?: number[]; + encoding?: XerEncoding; + }, + ) { + super(message); + this.name = 'XerImportError'; + this.xerCode = xerCode; + this.table = context?.table; + this.field = context?.field; + this.missingColumns = context?.missingColumns; + this.missingValues = context?.missingValues; + this.line = context?.line; + this.lines = context?.lines; + this.encoding = context?.encoding; + } +} + +export interface XerHeader { + version: string; + defaultCurrencyCode: string; +} + +export interface XerRow { + readonly line: number; + readonly cells: Readonly>; +} + +interface MutableXerRow { + line: number; + cells: Record; +} + +export interface XerTable { + name: string; + fields: string[]; + rows: XerRow[]; +} + +interface MutableXerTable { + name: string; + fields: string[]; + rows: MutableXerRow[]; +} + +export type XerImportIssueCode = + | 'XER_MISSING_END_MARKER' + | 'XER_TRAILING_RECORDS_AFTER_END' + | 'XER_CURRENCY_NOT_FOUND' + | 'XER_ROW_FIELD_COUNT_MISMATCH' + | 'XER_DATA_WITHOUT_FIELDS' + | 'XER_ORPHAN_CONTINUATION' + | 'XER_DUPLICATE_FIELD' + | 'XER_UNKNOWN_RECORD'; + +export interface XerImportIssue { + code: XerImportIssueCode; + line: number; + table?: string; + expected?: number; + actual?: number; + field?: string; + currencyCode?: string; + ignoredRecords?: number; + ignoredLines?: number; +} + +export interface XerImportReport { + encoding: XerEncoding; + endMarkerSeen: boolean; + issues: XerImportIssue[]; + unknownTables: Array<{ name: string; rows: number }>; + unknownFields: Array<{ table: string; name: string; rows: number }>; +} + +export interface XerTables { + header: XerHeader; + tables: Map; + report: XerImportReport; + numberFormat: XerNumberFormat; +} + +export interface XerNumberFormat { + decimal: '.' | ','; + group: '.' | ',' | null; + source: 'currtype' | 'default'; + currencyCode: string; +} + +// MPXJ's afzonderlijke NotesHelper-aanroepen zijn alleen als gedragsreferentie gebruikt. Deze +// expliciete tabel/veldmatrix voorkomt dat DEL-DEL in kalenderdata of andere structured text wijzigt. +const P6_NOTE_FIELDS = new Set([ + 'ACCOUNT.acct_descr', + 'ROLES.role_descr', + 'RSRC.rsrc_notes', + 'TASK.task_notes', + 'TASKNOTE.task_notes', + 'TASKMEMO.task_memo', + 'TASKPROC.proc_descr', + 'WBSMEMO.wbs_memo', +]); + +/** Decodeer uitsluitend P6-notitievervuiling en de DEL-DEL-regelovergang. */ +export function decodeXerNoteText(raw: string): string { + const withoutContamination = Array.from(raw) + .filter(char => ![0, 0xfeff, 0xfffe].includes(char.charCodeAt(0))) + .join(''); + return withoutContamination.split('\u007f\u007f').join('\n'); +} + +function decodeTextCell(table: string, field: string, raw: string): string { + const unquoted = raw.replace(/""/g, '"'); + return P6_NOTE_FIELDS.has(`${table}.${field}`) ? decodeXerNoteText(unquoted) : unquoted; +} + +/** + * Gezaghebbende parser/readergrens: per ingelezen tabel de kolommen waaraan OPS daadwerkelijk + * betekenis geeft. De tabelselectie wordt uit dezelfde inventaris afgeleid, zodat een tabel nooit + * wel geparseerd maar buiten de bekende-veldencontrole kan vallen. Een lege lijst is bewust: de + * tabelrijen worden retained voor brongetrouwheid, maar nog geen veld wordt door de live lezer + * geïnterpreteerd. Een gevuld onbekend veld is dan dus echte niet-gerepresenteerde informatie. + */ +export const XER_KNOWN_FIELDS_BY_TABLE: Readonly> = { + PROJECT: [ + 'proj_id', 'sum_base_proj_id', 'clndr_id', 'proj_short_name', 'proj_name', + 'last_recalc_date', 'data_date', 'plan_end_date', 'def_duration_type', + 'critical_path_type', 'critical_drtn_hr_cnt', 'rem_target_link_flag', + ], + CALENDAR: [ + 'clndr_id', 'base_clndr_id', 'clndr_name', 'clndr_type', 'clndr_data', + 'day_hr_cnt', 'week_hr_cnt', 'month_hr_cnt', 'year_hr_cnt', + ], + RSRC: [ + 'rsrc_id', 'parent_rsrc_id', 'role_id', 'clndr_id', 'unit_id', 'rsrc_name', + 'rsrc_short_name', 'rsrc_notes', 'rsrc_type', 'def_qty_per_hr', + ], + RSRCRATE: [ + 'rsrc_rate_id', 'rsrc_id', 'start_date', 'max_qty_per_hr', + 'cost_per_qty', 'cost_per_qty2', 'cost_per_qty3', 'cost_per_qty4', 'cost_per_qty5', + ], + PROJWBS: ['wbs_id', 'proj_id', 'parent_wbs_id', 'wbs_name', 'wbs_short_name', 'seq_num'], + TASK: [ + 'task_id', 'proj_id', 'task_code', 'task_name', 'task_notes', 'wbs_id', 'clndr_id', + 'task_type', 'duration_type', 'status_code', 'complete_pct_type', 'complete_pct', + 'phys_complete_pct', 'priority_type', 'target_start_date', 'target_end_date', + 'target_drtn_hr_cnt', 'remain_drtn_hr_cnt', 'act_start_date', 'act_end_date', + 'suspend_date', 'resume_date', 'expect_end_date', 'cstr_type', 'cstr_date', + 'cstr_type2', 'cstr_date2', + ], + TASKPRED: [ + 'task_pred_id', 'proj_id', 'task_id', 'pred_proj_id', 'pred_task_id', 'pred_type', + 'lag_hr_cnt', + ], + TASKRSRC: [ + 'taskrsrc_id', 'proj_id', 'task_id', 'rsrc_id', 'role_id', 'rsrc_type', 'curv_id', + 'rate_type', 'cost_per_qty_source_type', 'remain_qty', 'target_qty', 'act_reg_qty', + 'act_ot_qty', 'act_this_per_qty', 'remain_qty_per_hr', 'target_qty_per_hr', + 'cost_per_qty', 'target_cost', 'remain_cost', 'act_reg_cost', 'act_ot_cost', + 'act_this_per_cost', 'target_crv', 'remain_crv', 'actual_crv', + ], + CURRTYPE: [ + 'curr_short_name', 'decimal_symbol', 'decimal_symbol_type', + 'digit_group_symbol', 'digit_group_symbol_type', + ], + UDFTYPE: [ + 'udf_type_id', 'table_name', 'logical_data_type', 'udf_type', + 'udf_type_label', 'udf_type_name', + ], + UDFVALUE: ['udf_type_id', 'fk_id', 'task_id', 'proj_id', 'udf_text', 'udf_number', 'udf_date'], + SCHEDOPTIONS: [ + 'enable_multiple_longest_path_calc', 'key_activity_for_multiple_longest_paths', + 'level_all_rsrc_flag', 'level_float_thrs_cnt', 'level_keep_sched_date_flag', + 'level_outer_assign_flag', 'level_outer_assign_priority', 'level_over_alloc_pct', + 'level_within_float_flag', 'levelprioritylist', 'limit_multiple_longest_path_calc', + 'max_multiple_longest_path', 'proj_id', 'sched_calendar_on_relationship_lag', + 'sched_float_type', 'sched_lag_early_start_flag', 'sched_open_critical_flag', + 'sched_outer_depend_type', 'sched_progress_override', 'sched_retained_logic', + 'sched_setplantoforecast', 'sched_use_expect_end_flag', + 'sched_use_project_end_date_for_float', 'schedhash', 'schedoptions_id', + 'use_total_float', 'use_total_float_multiple_longest_paths', + ], + ACTVTYPE: [ + 'actv_code_type_id', 'parent_actv_code_type_id', 'actv_code_type', + 'actv_code_type_name', 'seq_num', + ], + ACTVCODE: [ + 'actv_code_id', 'actv_code_type_id', 'parent_actv_code_id', + 'short_name', 'actv_code_name', 'seq_num', + ], + TASKACTV: ['task_id', 'proj_id', 'actv_code_type_id', 'actv_code_id'], + COSTTYPE: [], + ACCOUNT: [], + PROJCOST: [], + MEMOTYPE: ['memo_type_id'], + WBSMEMO: [], + TASKNOTE: ['task_id', 'proj_id', 'task_notes'], + TASKMEMO: ['memo_id', 'memo_type_id', 'task_id', 'proj_id', 'task_memo'], + ROLES: ['role_id', 'parent_role_id', 'role_name', 'role_short_name', 'role_descr'], + ROLERATE: [ + 'role_rate_id', 'role_id', 'start_date', 'max_qty_per_hr', + 'cost_per_qty', 'cost_per_qty2', 'cost_per_qty3', 'cost_per_qty4', 'cost_per_qty5', + ], + RSRCCURVDATA: [ + 'curv_id', 'curv_name', + ...Array.from({ length: 21 }, (_, index) => `pct_usage_${index}`), + ], + TASKPROC: [], + LOCATION: [], + UMEASURE: ['unit_id', 'unit_abbrev', 'unit_name'], + SHIFT: [], + SHIFTPER: [], + RSRCROLE: [], + PCATTYPE: [], + PCATVAL: [], + PROJPCAT: [], + RCATTYPE: [], + RCATVAL: [], + RSRCRCAT: [], + ROLECATTYPE: [], + ROLECATVAL: [], + ROLERCAT: [], + ASGNMNTCATTYPE: [], + ASGNMNTCATVAL: [], + ASGNMNTACAT: [], +}; + +const READ_TABLES = new Set(Object.keys(XER_KNOWN_FIELDS_BY_TABLE)); +const KNOWN_FIELD_SETS = new Map(Object.entries(XER_KNOWN_FIELDS_BY_TABLE) + .map(([table, fields]) => [table, new Set(fields)] as const)); + +function decodeBomPayload( + bytes: Uint8Array, + offset: number, + encoding: 'utf-8' | 'utf-16le' | 'utf-16be', +): { text: string; encoding: XerEncoding } { + try { + return { + text: new TextDecoder(encoding, { fatal: true }).decode(bytes.subarray(offset)), + encoding, + }; + } catch { + throw new XerImportError( + 'XER_INVALID_ENCODING', + `XER-bestand heeft een ${encoding}-BOM maar een ongeldige of afgeknotte payload.`, + { encoding }, + ); + } +} + +function decodeXerBytes(bytes: Uint8Array): { text: string; encoding: XerEncoding } { + if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) { + return decodeBomPayload(bytes, 3, 'utf-8'); + } + if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) { + return decodeBomPayload(bytes, 2, 'utf-16le'); + } + if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) { + return decodeBomPayload(bytes, 2, 'utf-16be'); + } + try { + return { + text: new TextDecoder('utf-8', { fatal: true }).decode(bytes), + encoding: 'utf-8', + }; + } catch { + return { + text: new TextDecoder('windows-1252').decode(bytes), + encoding: 'windows-1252', + }; + } +} + +function assertRequiredColumns(tables: ReadonlyMap): void { + const requirements: ReadonlyArray = [ + ['PROJECT', ['proj_id']], + ['CALENDAR', ['clndr_id']], + ['PROJWBS', ['wbs_id', 'proj_id']], + ['TASK', ['task_id', 'proj_id', 'task_code']], + ['TASKPRED', ['task_id', 'pred_task_id', 'pred_type']], + ['RSRC', ['rsrc_id']], + ['TASKRSRC', ['task_id', 'rsrc_id']], + ]; + for (const [tableName, required] of requirements) { + const table = tables.get(tableName); + if (!table) continue; + const missingColumns = required.filter(field => !table.fields.includes(field)); + if (missingColumns.length === 0) continue; + throw new XerImportError( + 'XER_MISSING_REQUIRED_COLUMNS', + `XER-tabel ${tableName} mist verplichte kolommen: ${missingColumns.join(', ')}.`, + { table: tableName, missingColumns }, + ); + } +} + +function assertRequiredIdentity(tables: ReadonlyMap): void { + const requirements: ReadonlyArray = [ + ['PROJECT', ['proj_id']], + ['CALENDAR', ['clndr_id']], + ['PROJWBS', ['wbs_id', 'proj_id']], + ['TASK', ['task_id', 'proj_id', 'task_code']], + ['TASKPRED', ['task_id', 'pred_task_id', 'pred_type']], + ['RSRC', ['rsrc_id']], + ['TASKRSRC', ['task_id']], + ]; + for (const [tableName, required] of requirements) { + const table = tables.get(tableName); + if (!table) continue; + for (const row of table.rows) { + const missingValues = required.filter(field => !row.cells[field]?.trim()); + if (tableName === 'TASKRSRC' + && !row.cells.rsrc_id?.trim() + && !row.cells.role_id?.trim()) { + missingValues.push('rsrc_id', 'role_id'); + } + if (missingValues.length === 0) continue; + throw new XerImportError( + 'XER_MISSING_REQUIRED_VALUE', + `XER-tabel ${tableName} heeft op regel ${row.line} lege verplichte identiteit: ` + + `${missingValues.join(', ')}.`, + { table: tableName, missingValues, line: row.line }, + ); + } + } +} + +/** + * XER-getallen moeten in twee passen worden behandeld. De eerste pas bewaart alle cellen als + * letterlijke tokens, ongeacht de tabelvolgorde. Pas nadat ook CURRTYPE bekend is, kiest deze + * tweede pas de rij van ERMHDR-veld 9 en legt decimaal- en groepsteken vast. Zonder CURRTYPE is + * punt-decimaal de enige veilige default; aantoonbare komma-decimalen worden verderop geweigerd + * in plaats van locale-afhankelijk te gokken. Dit formaat geldt daarna voor elk numeriek veld, + * dus ook uren, duren en floats. + */ +function determineNumberFormat( + tables: ReadonlyMap, + currencyCode: string, + issues: XerImportIssue[], +): XerNumberFormat { + const currencyRows = tables.get('CURRTYPE')?.rows ?? []; + if (currencyRows.length === 0) { + return { decimal: '.', group: null, source: 'default', currencyCode }; + } + const normalizedCurrency = currencyCode.trim().toLowerCase(); + const matchingRows = normalizedCurrency + ? currencyRows.filter(row => + row.cells.curr_short_name?.trim().toLowerCase() === normalizedCurrency) + : []; + if (matchingRows.length === 0) { + issues.push({ + code: 'XER_CURRENCY_NOT_FOUND', + line: 1, + table: 'CURRTYPE', + currencyCode, + }); + return { decimal: '.', group: null, source: 'default', currencyCode }; + } + + const formatError = (message: string, line: number, lines = [line]): XerImportError => + new XerImportError('XER_INVALID_NUMBER_FORMAT', message, { + table: 'CURRTYPE', + line, + lines, + }); + const decodeToken = ( + raw: string, + family: 'decimal' | 'group', + line: number, + ): '.' | ',' => { + const value = raw.trim().toLowerCase(); + if (value === '.' || value === 'period') return '.'; + if (value === ',' || value === 'comma') return ','; + if (value === 'ds_period' && family === 'decimal') return '.'; + if (value === 'ds_comma' && family === 'decimal') return ','; + if (value === 'dg_period' && family === 'group') return '.'; + if (value === 'dg_comma' && family === 'group') return ','; + throw formatError( + 'CURRTYPE bevat geen betrouwbare combinatie van decimaal- en groepsteken.', + line, + ); + }; + const resolveRepresentations = ( + values: readonly string[], + family: 'decimal' | 'group', + line: number, + ): '.' | ',' | null => { + const decoded = values.filter(value => value.trim()).map(value => decodeToken(value, family, line)); + if (new Set(decoded).size > 1) { + throw formatError( + 'CURRTYPE bevat tegenstrijdige separatorrepresentaties.', + line, + ); + } + return decoded[0] ?? null; + }; + const formats = matchingRows.map(row => { + const decimal = resolveRepresentations([ + row.cells.decimal_symbol ?? '', + row.cells.decimal_symbol_type ?? '', + ], 'decimal', row.line); + const group = resolveRepresentations([ + row.cells.digit_group_symbol ?? '', + row.cells.digit_group_symbol_type ?? '', + ], 'group', row.line); + if (decimal === null || group === decimal) { + throw formatError( + 'CURRTYPE bevat geen betrouwbare combinatie van decimaal- en groepsteken.', + row.line, + ); + } + return { decimal, group, line: row.line }; + }); + const selected = formats[0]; + const conflict = formats.find(format => + format.decimal !== selected.decimal || format.group !== selected.group); + if (conflict) { + throw formatError( + 'CURRTYPE bevat meerdere matching valutaregels met tegenstrijdige getalsemantiek.', + conflict.line, + [selected.line, conflict.line], + ); + } + return { + decimal: selected.decimal, + group: selected.group, + source: 'currtype', + currencyCode, + }; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** Parse een numerieke XER-token strikt volgens de in de tweepas bepaalde bestandsnotatie. */ +export function parseXerNumber(raw: string, format: XerNumberFormat): number | null { + const value = raw.trim(); + if (!value) return null; + const decimal = escapeRegExp(format.decimal); + const group = format.group === null ? null : escapeRegExp(format.group); + const integer = group === null ? '\\d+' : `(?:\\d+|\\d{1,3}(?:${group}\\d{3})+)`; + const pattern = new RegExp(`^[+-]?${integer}(?:${decimal}\\d+)?$`); + if (!pattern.test(value)) { + throw new XerImportError('XER_INVALID_NUMBER', `Ongeldig XER-getal: ${JSON.stringify(value)}.`); + } + let normalized = value; + if (format.group !== null) normalized = normalized.split(format.group).join(''); + if (format.decimal === ',') normalized = normalized.replace(',', '.'); + const parsed = Number(normalized); + if (!Number.isFinite(parsed)) { + throw new XerImportError('XER_INVALID_NUMBER', `Ongeldig XER-getal: ${JSON.stringify(value)}.`); + } + return parsed; +} + +// MPXJ's XerFile.FIELD_TYPE_MAP is geraadpleegd om alle NUMERIC/DURATION/CURRENCY-veldnamen te +// inventariseren. Alleen de veldclassificatie is zelfstandig overgenomen, geen parsercode. De map +// is veldnaamgebaseerd (met target_qty als eveneens numerieke tabelvariant), zodat vrije tekst en +// INTEGER-identiteit nooit door een suffixgok als komma-decimaalbewijs gelden. +const XER_DECIMAL_FIELDS = new Set([ + 'act_cost', 'act_equip_qty', 'act_ot_cost', 'act_ot_qty', 'act_reg_cost', 'act_reg_qty', + 'act_work_qty', 'asgnmnt_catg_id', 'asgnmnt_catg_short_len', 'asgnmnt_catg_type_id', + 'base_exch_rate', 'complete_pct', 'cost_per_qty', 'cost_per_qty2', 'cost_per_qty3', + 'cost_per_qty4', 'cost_per_qty5', 'critical_drtn_hr_cnt', 'curv_id', 'day_hr_cnt', + 'def_qty_per_hr', 'est_wt', 'free_float_hr_cnt', 'indep_remain_total_cost', + 'indep_remain_work_qty', 'lag_hr_cnt', 'latitude', 'longitude', 'max_qty_per_hr', + 'month_hr_cnt', 'orig_cost', 'parent_asgnmnt_catg_id', + 'pct_usage_0', 'pct_usage_1', 'pct_usage_2', 'pct_usage_3', 'pct_usage_4', 'pct_usage_5', + 'pct_usage_6', 'pct_usage_7', 'pct_usage_8', 'pct_usage_9', 'pct_usage_10', 'pct_usage_11', + 'pct_usage_12', 'pct_usage_13', 'pct_usage_14', 'pct_usage_15', 'pct_usage_16', + 'pct_usage_17', 'pct_usage_18', 'pct_usage_19', 'pct_usage_20', 'phys_complete_pct', + 'proc_wt', 'remain_cost', 'remain_drtn_hr_cnt', 'remain_equip_qty', 'remain_qty', + 'remain_qty_per_hr', 'remain_work_qty', 'target_cost', 'target_drtn_hr_cnt', + 'target_equip_qty', 'target_lag_drtn_hr_cnt', 'target_qty', 'target_qty_per_hr', + 'target_work_qty', 'total_float_hr_cnt', 'udf_number', 'week_hr_cnt', 'year_hr_cnt', + + // Aanvullende P6-velden die de bestaande X2-catalogus al als decimaaldragend behandelde. + 'act_drtn_hr_cnt', 'act_this_per_equip_qty', 'act_this_per_work_qty', 'actual_value', + 'cost_load_value', 'cost_value', 'last_recalc_priority', 'old_remain_drtn_hr_cnt', 'ot_factor', + 'plan_cost', 'remain_lag_drtn_hr_cnt', 'remain_value', 'target_rate', +]); + +interface XerDecimalEvidence { + table: string; + field: string; + line: number; +} + +function findProvableCommaDecimal( + tables: ReadonlyMap, +): XerDecimalEvidence | null { + const plainCommaDecimal = /^[+-]?\d+,\d+$/; + const groupedCommaDecimal = /^[+-]?\d{1,3}(?:\.\d{3})+,\d+$/; + const commaGroupedInteger = /^[+-]?\d{1,3}(?:,\d{3})+$/; + for (const table of tables.values()) { + for (const row of table.rows) { + for (const field of table.fields) { + if (!XER_DECIMAL_FIELDS.has(field)) continue; + const value = row.cells[field]?.trim() ?? ''; + if ((plainCommaDecimal.test(value) || groupedCommaDecimal.test(value)) + && !commaGroupedInteger.test(value)) { + return { table: table.name, field, line: row.line }; + } + } + } + } + return null; +} + +function collectUnknownFields( + tables: ReadonlyMap, +): Array<{ table: string; name: string; rows: number }> { + const result: Array<{ table: string; name: string; rows: number }> = []; + for (const table of tables.values()) { + const known = KNOWN_FIELD_SETS.get(table.name) ?? new Set(); + for (const field of new Set(table.fields)) { + if (known.has(field)) continue; + const rows = table.rows.reduce((count, row) => + count + ((row.cells[field]?.trim() ?? '') !== '' ? 1 : 0), 0); + if (rows > 0) result.push({ table: table.name, name: field, rows }); + } + } + return result; +} + +/** Sluit parseropbouw af zonder rij- of celkopieën; iedere latere lezer deelt dezelfde bronrij. */ +function freezeXerRows(tables: ReadonlyMap): void { + for (const table of tables.values()) { + for (const row of table.rows) { + Object.freeze(row.cells); + Object.freeze(row); + } + } +} + +/** Parse uitsluitend de oorspronkelijke bestandsbytes; een stringingang is ook runtime ongeldig. */ +export function parseXerTables(bytes: XerByteInput): XerTables { + if (!(bytes instanceof Uint8Array)) { + throw new XerImportError( + 'XER_INVALID_INPUT', + 'XER-parser verwacht oorspronkelijke bestandsbytes, geen reeds gedecodeerde tekst.', + ); + } + const { text, encoding } = decodeXerBytes(bytes); + const lines = text.split('\n').map(line => line.endsWith('\r') ? line.slice(0, -1) : line); + const tables = new Map(); + const issues: XerImportIssue[] = []; + const unknownTables: Array<{ name: string; rows: number }> = []; + const knownTableHeaderLines = new Map(); + const headerCells = lines[0]?.split('\t') ?? []; + if (headerCells[0] !== 'ERMHDR' || !(headerCells[1] ?? '').trim()) { + throw new XerImportError( + 'XER_INVALID_FILE', + 'Ongeldig XER-bestand: ERMHDR met versie ontbreekt.', + ); + } + let current: MutableXerTable | undefined; + let currentUnknown: { + report: { name: string; rows: number }; + rowStarted: boolean; + rowCounted: boolean; + } | undefined; + let endMarkerSeen = false; + + for (let index = 1; index < lines.length; index++) { + const values = lines[index].split('\t'); + const marker = values[0].trim(); + if (marker === '%T') { + const name = (values[1] ?? '').trim().toUpperCase(); + currentUnknown = undefined; + if (READ_TABLES.has(name)) { + const firstHeaderLine = knownTableHeaderLines.get(name); + if (firstHeaderLine !== undefined) { + const secondHeaderLine = index + 1; + throw new XerImportError( + 'XER_DUPLICATE_TABLE', + `Bekende XER-tabel ${name} begint vóór de eerste %E meer dan één keer.`, + { + table: name, + line: secondHeaderLine, + lines: [firstHeaderLine, secondHeaderLine], + }, + ); + } + knownTableHeaderLines.set(name, index + 1); + current = { name, fields: [], rows: [] }; + tables.set(name, current); + } else { + current = undefined; + const report = { name, rows: 0 }; + currentUnknown = { report, rowStarted: false, rowCounted: false }; + unknownTables.push(report); + } + } else if (marker === '%F' && current) { + current.fields = values.slice(1).map(field => field.trim().toLowerCase()); + const seen = new Set(); + for (const field of current.fields) { + if (seen.has(field)) { + issues.push({ + code: 'XER_DUPLICATE_FIELD', + line: index + 1, + table: current.name, + field, + }); + } + seen.add(field); + } + } else if (marker === '%F' && currentUnknown) { + // De kolomvorm van een bewust overgeslagen tabel is niet onze grammaticale verantwoordelijkheid. + } else if (marker === '' && current && current.fields.length > 0 && current.rows.length > 0) { + const previous = current.rows[current.rows.length - 1]; + values.slice(1).forEach((raw, fieldIndex) => { + const field = current?.fields[fieldIndex]; + if (field === undefined || raw === '') return; + const continuation = decodeTextCell(current?.name ?? '', field, raw); + previous.cells[field] = `${previous.cells[field] ?? ''}\n${continuation}`; + }); + } else if (marker === '' && values.length > 1 && current) { + issues.push({ code: 'XER_ORPHAN_CONTINUATION', line: index + 1, table: current.name }); + } else if (marker === '' && currentUnknown?.rowStarted) { + const hasContent = values.slice(1).some(value => value.trim() !== ''); + if (hasContent && !currentUnknown.rowCounted) { + currentUnknown.report.rows++; + currentUnknown.rowCounted = true; + } + } else if (marker === '%R' && current && current.fields.length > 0) { + const table = current; + const rowValues = values.slice(1); + if (rowValues.length !== table.fields.length) { + issues.push({ + code: 'XER_ROW_FIELD_COUNT_MISMATCH', + line: index + 1, + table: table.name, + expected: table.fields.length, + actual: rowValues.length, + }); + } + const cells: Record = {}; + table.fields.forEach((field, fieldIndex) => { + cells[field] = decodeTextCell(table.name, field, rowValues[fieldIndex] ?? ''); + }); + table.rows.push({ line: index + 1, cells }); + } else if (marker === '%R' && current) { + issues.push({ code: 'XER_DATA_WITHOUT_FIELDS', line: index + 1, table: current.name }); + } else if (marker === '%R' && currentUnknown) { + // Een onbekende tabel is pas retained informatiewaarde als minstens één cel inhoud draagt. + // Een lege `%R` kan die inhoud nog op een opvolgende continuation-regel krijgen. + const hasContent = values.slice(1).some(value => value.trim() !== ''); + currentUnknown.rowStarted = true; + currentUnknown.rowCounted = hasContent; + if (hasContent) currentUnknown.report.rows++; + } else if (marker === '%E') { + endMarkerSeen = true; + const ignoredTail = lines.slice(index + 1); + const ignoredRecords = ignoredTail.filter(line => line.trim()).length; + if (ignoredRecords > 0) { + const firstIgnoredOffset = ignoredTail.findIndex(line => line.trim()); + const syntheticEndToken = ignoredTail[ignoredTail.length - 1] === '' ? 1 : 0; + issues.push({ + code: 'XER_TRAILING_RECORDS_AFTER_END', + line: index + 2 + firstIgnoredOffset, + ignoredRecords, + ignoredLines: ignoredTail.length - syntheticEndToken, + }); + } + break; + } else if (marker !== '') { + issues.push({ + code: 'XER_UNKNOWN_RECORD', + line: index + 1, + ...(current ? { table: current.name } : {}), + }); + } + } + + const immutableTables = tables as unknown as Map; + assertRequiredColumns(immutableTables); + assertRequiredIdentity(immutableTables); + const header: XerHeader = { + version: headerCells[1] ?? '', + defaultCurrencyCode: headerCells[8] ?? '', + }; + const numberFormat = determineNumberFormat(immutableTables, header.defaultCurrencyCode, issues); + const commaDecimalEvidence = numberFormat.source === 'default' + ? findProvableCommaDecimal(immutableTables) + : null; + if (commaDecimalEvidence) { + throw new XerImportError( + 'XER_AMBIGUOUS_DECIMAL', + 'XER-bestand gebruikt aantoonbaar komma-decimalen maar bevat geen bruikbare CURRTYPE-tabel.', + commaDecimalEvidence, + ); + } + + const unknownFields = collectUnknownFields(immutableTables); + freezeXerRows(immutableTables); + return { + header, + tables: immutableTables, + numberFormat, + report: { + encoding, + endMarkerSeen, + issues: endMarkerSeen ? issues : [ + ...issues, + { code: 'XER_MISSING_END_MARKER', line: lines.length }, + ], + unknownTables, + unknownFields, + }, + }; +} diff --git a/src/services/xerExportLoss.ts b/src/services/xerExportLoss.ts new file mode 100644 index 000000000..f2b967bd4 --- /dev/null +++ b/src/services/xerExportLoss.ts @@ -0,0 +1,262 @@ +import type { ExportFormat } from './formatRegistry'; +import type { XerImportMetadata } from './importTypes'; +import type { XerSourceArchive } from './xerSourceArchive'; +import type { Project, SchedulingOptions } from '@/types/project'; +import type { Task } from '@/types/task'; +import type { Sequence } from '@/types/sequence'; +import type { ResourceAssignment } from '@/types/resource'; +import type { ActivityCodeType, CustomFieldDef } from '@/types/structure'; +import type { Baseline } from '@/types/baseline'; + +export type XerLossyExportFormat = Exclude | 'mpp'; +export type XerExportLossCategory = + | 'exact-source-bytes' + | 'unknown-tables-and-fields' + | 'typed-diagnostics' + | 'baselines' + | 'udfs' + | 'activity-codes' + | 'notes' + | 'raw-curves-and-assignment-quantities' + | 'external-links' + | 'schedule-options-and-provenance' + | 'p6-relation-lag-degradation'; + +export interface XerExportLossWarning { + readonly code: 'XER_ONLY_DATA_NOT_EXPRESSIBLE'; + readonly format: XerLossyExportFormat; + /** MPP staat hier expliciet op unsupported: OPS heeft geen native MPP-exportadapter. */ + readonly availability: 'supported-lossy' | 'unsupported'; + readonly categories: readonly XerExportLossCategory[]; +} + +/** Alleen de velden die de verliesdetector werkelijk inspecteert; geen tweede AppState-contract. */ +export interface XerExportLossInput { + readonly sourceArchive: XerSourceArchive | null; + readonly importMetadata: XerImportMetadata | null; + readonly project: Pick; + readonly tasks: readonly Pick[]; + readonly sequences: readonly Pick[]; + readonly assignments: readonly Pick[]; + readonly activityCodeTypes: readonly ActivityCodeType[]; + readonly customFieldDefs: readonly CustomFieldDef[]; + readonly baselines: readonly Baseline[]; + readonly activeBaselineId: string | null; +} + +interface ExportCapabilities { + /** MSPDI projecteert alleen actieve taakwaarden; geen writer round-tript het OPS-baselineobject. */ + readonly baselineProjection: 'none' | 'active-task-values'; + /** Writer projecteert de live units/curve; retained XER-bronwaarden blijven altijd apart verlies. */ + readonly projectedAssignments: boolean; + readonly percentLag: boolean; + readonly elapsedLag: boolean; + readonly schedulingOptions: 'none' | 'critical-slack-limit'; +} + +/** + * Gemeten tegen de drie writerimplementaties. Dit is een capabilitymatrix, geen vaste verlieslijst: + * een categorie ontstaat pas wanneer de bijbehorende retained/live data werkelijk aanwezig is. + */ +const EXPORT_CAPABILITIES: Readonly, ExportCapabilities>> = { + csv: { + baselineProjection: 'none', + projectedAssignments: false, + percentLag: true, + elapsedLag: true, + schedulingOptions: 'none', + }, + mspdi: { + baselineProjection: 'active-task-values', + projectedAssignments: true, + percentLag: true, + elapsedLag: true, + schedulingOptions: 'critical-slack-limit', + }, + p6: { + baselineProjection: 'none', + projectedAssignments: true, + percentLag: false, + elapsedLag: false, + schedulingOptions: 'none', + }, +}; + +export function xerExportTargetVerdict( + format: ExportFormat | 'mpp', +): 'lossless' | 'supported-lossy' | 'unsupported' { + if (format === 'ifc') return 'lossless'; + if (format === 'mpp') return 'unsupported'; + return 'supported-lossy'; +} + +const hasObjectValues = (value: object | undefined): boolean => + value !== undefined && Object.values(value).some(item => item !== undefined && item !== ''); + +function hasTypedDiagnostics(archive: XerSourceArchive | null, metadata: XerImportMetadata | null): boolean { + const file = archive?.diagnostics.file; + if (file && ( + file.tableReport.issues.length > 0 + || file.scheduleOptions.length > 0 + || file.relationResolutionIssues.length > 0 + || file.resourceCatalogIssues.length > 0 + || file.metadataCatalogIssues.length > 0 + )) return true; + + if (archive && Object.values(archive.diagnostics.documentViews).some(view => + view.tableReport.issues.length > 0 + || view.calendarIssues.length > 0 + || view.enumFallbacks.length > 0 + || view.scheduleOptions.fallbacks.length > 0 + || view.scheduleOptions.diagnostics.length > 0 + || (view.resources?.issues.length ?? 0) > 0)) return true; + + return Boolean(metadata && ( + metadata.tableReport.issues.length > 0 + || metadata.calendarIssues.length > 0 + || metadata.enumFallbacks.length > 0 + || metadata.scheduleOptions.fallbacks.length > 0 + || metadata.scheduleOptions.diagnostics.length > 0 + || (metadata.resources?.issues.length ?? 0) > 0 + || (metadata.metadata?.catalog.issues.length ?? 0) > 0 + )); +} + +function hasUnknownTablesOrFields(archive: XerSourceArchive | null, metadata: XerImportMetadata | null): boolean { + return Boolean( + archive?.diagnostics.file.tableReport.unknownTables.some(table => table.rows > 0) + || archive?.diagnostics.file.tableReport.unknownFields?.some(field => field.rows > 0) + || Object.values(archive?.diagnostics.documentViews ?? {}).some(view => + view.tableReport.unknownTables.some(table => table.rows > 0) + || view.tableReport.unknownFields?.some(field => field.rows > 0)) + || metadata?.tableReport.unknownTables.some(table => table.rows > 0) + || metadata?.tableReport.unknownFields?.some(field => field.rows > 0), + ); +} + +function hasBaselineLoss(capabilities: ExportCapabilities, input: XerExportLossInput): boolean { + if (input.baselines.length === 0) return false; + // Ook `active-task-values` is bewust lossy: MSPDI-slot 0 draagt Start/Finish/Duration per taak, + // maar niet id/sourceProjectId/name/createdAt/projectEnd/projectDuration of brontaakidentiteit. + switch (capabilities.baselineProjection) { + case 'none': + case 'active-task-values': + return true; + } +} + +function hasRetainedAssignmentDetails(metadata: XerImportMetadata | null): boolean { + return Boolean(metadata?.resources?.assignments.some(source => + hasObjectValues(source.quantities) + || hasObjectValues(source.rawCurves) + || hasObjectValues(source.costs) + || source.assignedRole !== undefined + // `curveSourceId` verwijst naar de retained 21-punts curve in de bestandsbrede catalogus. + // Geen doelwriter schrijft deze BRONIDENTITEIT (curv_id/curv_name) terug — de P6-writer + // (p6xmlWriter.ts's `curveObjIdFor`, ná de contour-engine-etappe 2026-09) genereert bij export + // een NIEUW `` en een synthetische naam (`Curve N`) voor + // `ResourceAssignment.curveValues`; de 21 waarden zelf komen voor het P6-doel dus wél + // schema-natief terug, maar niet de oorspronkelijke P6-curve-identiteit/-naam, en MSPDI/CSV + // kennen dat 21-puntsformaat sowieso niet (MSPDI kent alleen de 8 vaste `WorkContour`-vormen). + // De overige archiefvelden hieronder (kwantiteiten, kostenopbouw, target_crv/remain_crv/ + // actual_crv-spreidingsstrings, rate-/kostentype) blijven sowieso ongeschreven, voor elk doel. + || Boolean(source.curveSourceId?.trim()) + || Boolean(source.rateType?.trim()) + || Boolean(source.costSourceType?.trim()) + || Boolean(source.rawResourceType?.trim()))); +} + +function hasAssignmentLoss( + capabilities: ExportCapabilities, + input: XerExportLossInput, +): boolean { + if (hasRetainedAssignmentDetails(input.importMetadata)) return true; + return !capabilities.projectedAssignments && input.assignments.length > 0; +} + +function isMspdiCriticalSlackLimit(options: SchedulingOptions): boolean { + const definition = options.criticalDefinition; + if (!definition + || definition.mode !== 'totalFloat' + || typeof definition.threshold !== 'number' + || !Number.isInteger(definition.threshold) + || definition.threshold < 0 + || definition.thresholdHours !== undefined) return false; + return Object.entries(options).every(([key, value]) => + value === undefined || key === 'criticalDefinition'); +} + +function hasScheduleProvenance(input: XerExportLossInput): boolean { + const metadata = input.importMetadata; + const archiveSource = input.sourceArchive?.readModel.scheduleOptionsSourceArchive; + return Boolean( + (archiveSource?.rows.length ?? 0) > 0 + || (archiveSource?.unmatchedScheduleOptionsRowIndexes.length ?? 0) > 0 + || (archiveSource?.diagnostics.length ?? 0) > 0 + || (metadata?.scheduleOptions.sourceRows.length ?? 0) > 0 + || (metadata?.scheduleOptions.sourceRowIndexes.length ?? 0) > 0 + || (metadata?.scheduleOptions.fallbacks.length ?? 0) > 0 + || (metadata?.scheduleOptions.diagnostics.length ?? 0) > 0 + || (metadata && metadata.scheduleOptions.source === 'schedoptions') + || hasObjectValues(metadata?.scheduleOptions.retainedSource) + ); +} + +function hasScheduleLoss(capabilities: ExportCapabilities, input: XerExportLossInput): boolean { + if (hasScheduleProvenance(input) || input.project.progressMode !== undefined) return true; + const options = input.project.schedulingOptions; + if (!options || Object.values(options).every(value => value === undefined)) return false; + return capabilities.schedulingOptions === 'none' || !isMspdiCriticalSlackLimit(options); +} + +function categoriesFor( + format: Exclude, + input: XerExportLossInput, +): XerExportLossCategory[] { + const capabilities = EXPORT_CAPABILITIES[format]; + const categories: XerExportLossCategory[] = []; + if (input.sourceArchive && input.sourceArchive.byteLength > 0) categories.push('exact-source-bytes'); + if (hasUnknownTablesOrFields(input.sourceArchive, input.importMetadata)) { + categories.push('unknown-tables-and-fields'); + } + if (hasTypedDiagnostics(input.sourceArchive, input.importMetadata)) categories.push('typed-diagnostics'); + if (hasBaselineLoss(capabilities, input)) categories.push('baselines'); + if (input.customFieldDefs.length > 0 + || input.tasks.some(task => hasObjectValues(task.customFields))) categories.push('udfs'); + if (input.activityCodeTypes.length > 0 + || input.tasks.some(task => hasObjectValues(task.activityCodes))) categories.push('activity-codes'); + if (input.tasks.some(task => (task.notes?.length ?? 0) > 0)) categories.push('notes'); + if (hasAssignmentLoss(capabilities, input)) { + categories.push('raw-curves-and-assignment-quantities'); + } + if (input.tasks.some(task => (task.externalLinks?.length ?? 0) > 0) + || (input.importMetadata?.externalLinks.length ?? 0) > 0 + || (input.importMetadata?.externalRelations.length ?? 0) > 0) categories.push('external-links'); + if (hasScheduleLoss(capabilities, input)) categories.push('schedule-options-and-provenance'); + if ((!capabilities.percentLag && input.sequences.some(sequence => + typeof sequence.lagPercent === 'number' && Number.isFinite(sequence.lagPercent))) + || (!capabilities.elapsedLag && input.sequences.some(sequence => sequence.lagUnit === 'ELAPSEDTIME'))) { + categories.push('p6-relation-lag-degradation'); + } + return categories; +} + +/** + * X9-dienstcontract voor verliesdetectie. Dit levert uitsluitend getypeerde feiten in de bestaande + * exportAs-return-envelope; X10 bepaalt later vertaling, dedupe, toast en Lees-meer-link. + */ +export function detectXerExportLoss( + format: ExportFormat | 'mpp', + input: XerExportLossInput, +): readonly XerExportLossWarning[] { + if (format === 'ifc' || (!input.sourceArchive && !input.importMetadata)) return []; + const target = format === 'mpp' ? 'csv' : format; + const categories = categoriesFor(target, input); + if (categories.length === 0) return []; + return [{ + code: 'XER_ONLY_DATA_NOT_EXPRESSIBLE', + format, + availability: format === 'mpp' ? 'unsupported' : 'supported-lossy', + categories, + }]; +} diff --git a/src/services/xerSourceArchive.ts b/src/services/xerSourceArchive.ts new file mode 100644 index 000000000..34ef68cc7 --- /dev/null +++ b/src/services/xerSourceArchive.ts @@ -0,0 +1,1483 @@ +/** + * De onveranderde brontekst van een geïmporteerd XER-bestand. Dit is bewust + * een algemene service: de IFC-laag moet het archief kunnen verwerken zonder + * de lazy XER-parserchunk te laden. + */ + +import type { + XerImportMetadata, + XerImportReport, + XerScheduleOptionsDiagnostic, + XerScheduleOptionsMetadata, + XerScheduleOptionsSourceArchive, + XerTableReportMetadata, +} from './importTypes'; +import type { RecordedTime } from '@/engine/scheduler/recordedDates'; +import type { XerMetadataCatalog } from './xer/xerMetadataTypes'; +import type { XerResourceCatalog } from './xer/xerResources'; +import type { XerResourceIssue, XerTaskResourceSource } from './xer/xerResourceTypes'; +import { + deriveXerAssignmentSkipExpectation, + xerAssignmentSourceId, + type XerAssignmentSkipExpectation, + type XerAssignmentSkipReason, +} from './xer/xerAssignmentProvenance'; + +export const XER_SOURCE_ARCHIVE_SCHEMA_VERSION = 1; +export const XER_SOURCE_ARCHIVE_CHUNK_BYTES = 196_608; +/** + * IFC-envelopeversie. De runtime-archivevorm blijft schema 1: alleen de persistente container + * verandert. Schema 2 bewaart geen projecteerbare metadata, maar reconstrueert die uit de bytes. + */ +export const XER_SOURCE_ARCHIVE_COMPACT_STORAGE_SCHEMA_VERSION = 2; +export const XER_SOURCE_ARCHIVE_COMPACT_STORAGE_FORMAT = 'raw-source-reconstruction-v1'; + +export type XerSourceArchiveEncoding = 'utf-8' | 'utf-16le' | 'utf-16be' | 'windows-1252'; +export type XerSourceArchiveBom = 'utf-8' | 'utf-16le' | 'utf-16be' | 'none'; +export type XerSourceArchiveNewline = 'crlf' | 'lf' | 'cr' | 'mixed' | 'none'; + +export interface XerNumberFormatMetadata { + readonly decimal: '.' | ','; + readonly group: '.' | ',' | null; + readonly source: 'currtype' | 'default'; + readonly currencyCode: string; +} + +export interface XerArchiveSourceRowV1 { + readonly line: number; + readonly cells: Readonly>; +} + +export interface XerArchiveReadModelV1 { + readonly schemaVersion: 1; + readonly numberFormat: XerNumberFormatMetadata; + /** X5: de enige file-wide PROJECT/SCHEDOPTIONS-broncache. */ + readonly scheduleOptionsSourceArchive: XerScheduleOptionsSourceArchive; + readonly resourceCatalog: XerResourceCatalog; + readonly metadataCatalog: XerMetadataCatalog; + /** TASK-cellen blijven met lege/letterlijke tokens beschikbaar nadat de semantische taak is genormaliseerd. */ + readonly taskSourceRowsByProject: Readonly>; +} + +export type XerArchiveScheduleOptionsViewV1 = Omit< + XerScheduleOptionsMetadata, + 'sourceArchive' | 'sourceRows' +>; + +export type XerArchiveDocumentViewV1 = Omit< + XerImportMetadata, + 'resources' | 'metadata' | 'scheduleOptions' +> & { + sourceProjectId: string; + scheduleOptions: XerArchiveScheduleOptionsViewV1; + resources?: { + assignments: XerTaskResourceSource[]; + issues: XerResourceIssue[]; + }; +}; + +export interface XerArchiveDiagnosticsV1 { + readonly schemaVersion: 1; + readonly file: { + readonly tableReport: XerTableReportMetadata; + readonly scheduleOptions: readonly XerScheduleOptionsDiagnostic[]; + readonly relationResolutionIssues: readonly { + reason: 'ambiguous' | 'dangling'; + line: number; + field: 'task_id' | 'pred_task_id' | 'proj_id'; + taskId: string; + predecessorTaskId: string; + }[]; + readonly resourceCatalogIssues: readonly XerResourceIssue[]; + readonly metadataCatalogIssues: XerMetadataCatalog['issues']; + readonly importReport: XerImportReport; + }; + readonly documentViews: Readonly>; + /** Alleen voor extensies; kern/UI leest dit nooit zonder een eigen versiegebonden validator. */ + readonly opaqueExtensions?: Readonly>; +} + +export interface XerArchiveMetadataPayloadV1 { + readonly schemaVersion: 1; + readonly diagnostics: XerArchiveDiagnosticsV1; + readonly readModel: XerArchiveReadModelV1; +} + +export class XerSourceArchiveValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'XerSourceArchiveValidationError'; + } +} + +export interface XerSourceArchive { + readonly schemaVersion: typeof XER_SOURCE_ARCHIVE_SCHEMA_VERSION; + readonly format: 'primavera-p6-xer'; + readonly byteLength: number; + readonly sha256: string; + readonly encoding: XerSourceArchiveEncoding; + readonly bom: XerSourceArchiveBom; + readonly newline: XerSourceArchiveNewline; + readonly byteChunks: readonly string[]; + readonly diagnostics: XerArchiveDiagnosticsV1; + readonly readModel: XerArchiveReadModelV1; +} + +/** + * Wat de (lazy geladen) XER-lezer uit de canonieke bronbytes teruggeeft aan de IFC-lezer. + * + * T5 (XER-etappeplan laag 3, §3.8) verbreedt deze naad van "alleen het archief" naar "alles wat + * uitsluitend uit de bron af te leiden is". Reden: de reconstructie draaide al een VOLLEDIGE + * `readXER` over dezelfde, sha256-geverifieerde bytes — die uitkomst bevatte de bak-4-vastlegging + * (`ImportResult.recordedTimes`) al en gooide 'm alleen weg. Door 'm hier mee te geven is de + * "datums zoals opgeslagen"-vastlegging van een heropend XER-document per constructie IDENTIEK aan + * die van het oorspronkelijke openen: zelfde code, zelfde bytes, zelfde kalenderpromotie, zelfde + * getalnotatie. Er is geen tweede afleiding in de IFC-laag — en dus ook geen tweede waarheid die + * uit de pas kan lopen (zie de ontwerpnotitie boven `XerArchiveReconstructor` in `ifcReader.ts`). + */ +export interface XerSourceReconstruction { + readonly archive: XerSourceArchive; + /** + * `proj_id` → taak-id → wat de bron zelf opsloeg. De taak-id's zijn de rauwe `task_id`-cellen, + * precies de id's die `OPS_TaskIdentity` door een IFC-opslag/heropening heen vasthoudt; taken die + * na de import zijn toegevoegd matchen simpelweg niet en krijgen dus geen uitspraak + * (`captureRecordedDates` filtert op de taken die het document daadwerkelijk draagt). Een project + * zonder enige vastlegging staat er met een lege map in; een onbekend project ontbreekt. + */ + readonly recordedTimesByProject: Readonly>>; +} + +export interface XerSourceArchivePresentation { + readonly schemaVersion?: typeof XER_SOURCE_ARCHIVE_SCHEMA_VERSION; + readonly format?: 'primavera-p6-xer'; + readonly encoding: XerSourceArchiveEncoding; + readonly bom: XerSourceArchiveBom; + readonly newline: XerSourceArchiveNewline; + readonly diagnostics: XerArchiveDiagnosticsV1; + readonly readModel: XerArchiveReadModelV1; +} + +export interface XerChunkedBytes { + readonly byteLength: number; + readonly sha256: string; + readonly byteChunks: readonly string[]; +} + +export function chunkXerArchiveBytes(bytes: Uint8Array): XerChunkedBytes { + const byteChunks: string[] = []; + for (let offset = 0; offset < bytes.byteLength; offset += XER_SOURCE_ARCHIVE_CHUNK_BYTES) { + byteChunks.push(bytesToBase64(bytes.subarray(offset, offset + XER_SOURCE_ARCHIVE_CHUNK_BYTES))); + } + return { byteLength: bytes.byteLength, sha256: sha256Hex(bytes), byteChunks }; +} + +/** + * Maakt één gedeeld, diep onveranderlijk archief. De diagnostics worden eerst + * gekopieerd zodat een invoeralias de bewaarde importdiagnostiek nooit later + * kan wijzigen. + */ +export function createXerSourceArchive( + bytes: Uint8Array, + presentation: XerSourceArchivePresentation, +): XerSourceArchive { + return buildXerSourceArchive(bytes, presentation, true); +} + +/** + * Ownership-transfer voor readers die diagnostics/readmodel in dezelfde call vers hebben gebouwd + * en geen referentie buiten het resultaat publiceren. Dit voorkomt bij grote X6/X8-catalogi een + * tweede volledige heapkopie. Algemene aanroepers gebruiken altijd `createXerSourceArchive`, dat + * invoeraliasing door een defensive clone uitsluit. + */ +export function createXerSourceArchiveFromOwnedMetadata( + bytes: Uint8Array, + presentation: XerSourceArchivePresentation, +): XerSourceArchive { + return buildXerSourceArchive(bytes, presentation, false); +} + +function buildXerSourceArchive( + bytes: Uint8Array, + presentation: XerSourceArchivePresentation, + copyMetadata: boolean, +): XerSourceArchive { + // Valideer vóór de base64-chunks worden gereserveerd. De structurele validator maakt bij een + // groot X8-readmodel kortlevende pad-/sleutelwaarden; als de blijvende chunks dan al live zijn, + // moet V8 daarvoor extra heap-pagina's openen en blijft de verse reader onnodig veel RSS houden. + validateXerArchiveDiagnosticsV1(presentation.diagnostics); + validateXerArchiveReadModelV1(presentation.readModel); + validateArchiveMetadataRelations(presentation.diagnostics, presentation.readModel); + const copiedDiagnostics = copyMetadata + ? structuredClone(presentation.diagnostics) + : presentation.diagnostics; + const copiedReadModel = copyMetadata + ? structuredClone(presentation.readModel) + : presentation.readModel; + const chunked = chunkXerArchiveBytes(bytes); + return deepFreeze({ + schemaVersion: presentation.schemaVersion ?? XER_SOURCE_ARCHIVE_SCHEMA_VERSION, + format: 'primavera-p6-xer' as const, + byteLength: chunked.byteLength, + sha256: chunked.sha256, + encoding: presentation.encoding, + bom: presentation.bom, + newline: presentation.newline, + byteChunks: chunked.byteChunks, + diagnostics: copiedDiagnostics, + readModel: copiedReadModel, + }); +} + +export function createEmptyXerArchiveDiagnostics(): XerArchiveDiagnosticsV1 { + return { + schemaVersion: 1, + file: { + tableReport: { + encoding: 'utf-8', endMarkerSeen: true, issues: [], unknownTables: [], unknownFields: [], + }, + scheduleOptions: [], + relationResolutionIssues: [], + resourceCatalogIssues: [], + metadataCatalogIssues: [], + importReport: { + projectsSeen: 0, + documentsOpened: 0, + emptyProjectsSkipped: 0, + baselineProjectsExcluded: 0, + baselinesMaterialized: 0, + danglingBaselineReferences: 0, + externalLinksPreserved: 0, + baselineExclusionReverted: false, + baselineFallbackReasons: [], + }, + }, + documentViews: {}, + }; +} + +export function createEmptyXerArchiveDocumentView(sourceProjectId: string): XerArchiveDocumentViewV1 { + return { + sourceProjectId, + defaultCurrencyCode: '', + tableReport: { + encoding: 'utf-8', endMarkerSeen: true, issues: [], unknownTables: [], unknownFields: [], + }, + calendarIssues: [], + enumFallbacks: [], + scheduleOptions: { + source: 'xer-defaults', retainedSource: {}, fallbacks: [], diagnostics: [], + sourceRowIndexes: [], + }, + externalRelations: [], + externalLinks: [], + report: { + projectsSeen: 0, documentsOpened: 0, emptyProjectsSkipped: 0, + baselineProjectsExcluded: 0, baselinesMaterialized: 0, danglingBaselineReferences: 0, + externalLinksPreserved: 0, baselineExclusionReverted: false, baselineFallbackReasons: [], + }, + }; +} + +export function createEmptyXerArchiveReadModel(): XerArchiveReadModelV1 { + return { + schemaVersion: 1, + numberFormat: { decimal: '.', group: null, source: 'default', currencyCode: '' }, + scheduleOptionsSourceArchive: { rows: [], unmatchedScheduleOptionsRowIndexes: [], diagnostics: [] }, + resourceCatalog: { + resources: [], identities: [], + rows: { resources: [], roles: [], rates: [], curves: [], assignments: [] }, + issues: [], + }, + metadataCatalog: { + activityCodeTypes: [], customFieldDefs: [], taskProjections: [], taskProjectionsByProject: {}, + issues: [], issueCounts: { + XER_ACTIVITY_CODE_MISSING_TYPE_ID: 0, XER_ACTIVITY_CODE_MISSING_VALUE_ID: 0, + XER_ACTIVITY_CODE_DUPLICATE_TYPE_ID: 0, XER_ACTIVITY_CODE_DUPLICATE_VALUE_ID: 0, + XER_ACTIVITY_CODE_DUPLICATE_LINK: 0, XER_ACTIVITY_CODE_DANGLING_TYPE_PARENT: 0, + XER_ACTIVITY_CODE_DANGLING_VALUE_PARENT: 0, XER_ACTIVITY_CODE_DANGLING_TASK: 0, + XER_ACTIVITY_CODE_DANGLING_TYPE: 0, XER_ACTIVITY_CODE_DANGLING_VALUE: 0, + XER_UDF_MISSING_TYPE_ID: 0, XER_UDF_DUPLICATE_TYPE_ID: 0, XER_UDF_DUPLICATE_VALUE: 0, + XER_UDF_DANGLING_TYPE: 0, XER_UDF_DANGLING_ENTITY: 0, XER_UDF_AMBIGUOUS_TASK: 0, + XER_UDF_UNKNOWN_DATA_TYPE: 0, XER_UDF_INVALID_VALUE: 0, XER_UDF_DEFERRED_ENTITY: 0, + XER_NOTE_DUPLICATE_MEMO_ID: 0, XER_NOTE_DANGLING_MEMO_TYPE: 0, + XER_NOTE_DANGLING_TASK: 0, XER_NOTE_AMBIGUOUS_TASK: 0, + }, + sourceData: { + ACTVTYPE: [], ACTVCODE: [], TASKACTV: [], UDFTYPE: [], UDFVALUE: [], MEMOTYPE: [], + TASKNOTE: [], TASKMEMO: [], TASK_NOTES: [], deferredUdfValues: [], unknownUdfTypes: [], + }, + }, + taskSourceRowsByProject: {}, + }; +} + +/** Seriële envelope achter de gehashte DiagnosticsChunk-reeks. */ +export function encodeXerArchiveMetadataPayload(archive: XerSourceArchive): Uint8Array { + return new TextEncoder().encode(JSON.stringify({ + schemaVersion: 1, + diagnostics: archive.diagnostics, + readModel: archive.readModel, + } satisfies XerArchiveMetadataPayloadV1)); +} + +export function parseXerArchiveMetadataPayload(value: unknown): XerArchiveMetadataPayloadV1 { + const payload = objectOf(value, 'archive-metadata'); + exactKeys(payload, ['schemaVersion', 'diagnostics', 'readModel'], 'archive-metadata'); + if (payload.schemaVersion !== 1) invalid('archive-metadata.schemaVersion is onbekend'); + const diagnostics = payload.diagnostics; + const readModel = payload.readModel; + validateXerArchiveDiagnosticsV1(diagnostics); + validateXerArchiveReadModelV1(readModel); + validateArchiveMetadataRelations(diagnostics, readModel); + return { schemaVersion: 1, diagnostics, readModel }; +} + +function buildArchiveDocumentView( + source: XerImportMetadata, + transferOwnedProvenance: boolean, +): XerArchiveDocumentViewV1 { + const { + resources, + metadata: _metadata, + scheduleOptions: { sourceArchive: _sourceArchive, sourceRows: _sourceRows, ...scheduleOptions }, + ...documentFields + } = source; + if (!source.sourceProjectId) invalid('documentview mist sourceProjectId'); + return { + ...structuredClone(documentFields), + sourceProjectId: source.sourceProjectId, + scheduleOptions: structuredClone(scheduleOptions), + ...(resources ? { + resources: transferOwnedProvenance + ? { assignments: resources.assignments, issues: resources.issues } + : structuredClone({ assignments: resources.assignments, issues: resources.issues }), + } : {}), + }; +} + +/** Defensieve publieke grens: caller-owned provenance wordt nooit bevroren of als alias bewaard. */ +export function archiveDocumentView(source: XerImportMetadata): XerArchiveDocumentViewV1 { + return buildArchiveDocumentView(source, false); +} + +/** + * Interne reader-ownershiptransfer. Alleen `readXER` gebruikt dit op vers gematerialiseerde + * projectmetadata die vóór deze call nergens als publiek resultaat is uitgegeven. + */ +export function archiveDocumentViewFromOwnedReaderMetadata( + source: XerImportMetadata, +): XerArchiveDocumentViewV1 { + return buildArchiveDocumentView(source, true); +} + +/** Herbouw één documentspecifieke runtimeview uitsluitend uit de canonieke archiefgrafiek. */ +export function bindXerImportMetadataToArchive( + archive: XerSourceArchive, + sourceProjectId: string, +): XerImportMetadata { + const view = archive.diagnostics.documentViews[sourceProjectId]; + if (!view) invalid('selector wijst naar ontbrekende documentview'); + const sourceArchive = archive.readModel.scheduleOptionsSourceArchive; + const sourceRows = view.scheduleOptions.sourceRowIndexes.map((index, position) => { + const row = sourceArchive.rows[index]; + if (!row) invalid(`documentview sourceRowIndexes[${position}] wijst buiten de X5-filecache`); + return row; + }); + Object.freeze(sourceRows); + const { resources, ...documentFields } = view; + return { + ...structuredClone(documentFields), + scheduleOptions: { + ...structuredClone(view.scheduleOptions), + sourceArchive, + sourceRows, + }, + ...(resources ? { + resources: { + catalog: archive.readModel.resourceCatalog, + assignments: resources.assignments, + issues: resources.issues, + }, + } : {}), + metadata: { catalog: archive.readModel.metadataCatalog }, + }; +} + +export function withXerArchiveDocumentView( + archive: XerSourceArchive, source: XerImportMetadata, +): XerSourceArchive { + const view = archiveDocumentView(source); + if (archive.diagnostics.documentViews[view.sourceProjectId]) return archive; + const diagnostics: XerArchiveDiagnosticsV1 = { + ...archive.diagnostics, + documentViews: { + ...archive.diagnostics.documentViews, + [view.sourceProjectId]: view, + }, + }; + validateXerArchiveDiagnosticsV1(diagnostics); + validateArchiveMetadataRelations(diagnostics, archive.readModel); + return deepFreeze({ ...archive, diagnostics }); +} + +export function decodeXerSourceArchive(archive: XerSourceArchive): Uint8Array { + const chunks = archive.byteChunks.map(decodeXerBase64Chunk); + const result = new Uint8Array(archive.byteLength); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.byteLength; + } + if (offset !== archive.byteLength) { + throw new Error('XER-bronarchief heeft een ongeldige byteLength.'); + } + return result; +} + +export function decodeXerBase64Chunk(value: string): Uint8Array { + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { + throw new Error('XER-bronarchief bevat ongeldige base64.'); + } + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + +export function detectXerSourcePresentation(bytes: Uint8Array): XerSourceArchivePresentation { + const hasUtf8Bom = bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf; + const hasUtf16LeBom = bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe; + const hasUtf16BeBom = bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff; + const encoding: XerSourceArchiveEncoding = hasUtf8Bom ? 'utf-8' + : hasUtf16LeBom ? 'utf-16le' + : hasUtf16BeBom ? 'utf-16be' + : 'windows-1252'; + const bom: XerSourceArchiveBom = hasUtf8Bom ? 'utf-8' + : hasUtf16LeBom ? 'utf-16le' + : hasUtf16BeBom ? 'utf-16be' + : 'none'; + const offset = hasUtf8Bom ? 3 : hasUtf16LeBom || hasUtf16BeBom ? 2 : 0; + const text = new TextDecoder(encoding === 'windows-1252' ? 'windows-1252' : encoding) + .decode(bytes.subarray(offset)); + const crlf = /\r\n/.test(text); + const lf = /(^|[^\r])\n/.test(text); + const cr = /\r(?!\n)/.test(text); + const lineKinds = Number(crlf) + Number(lf) + Number(cr); + return { + encoding, + bom, + newline: lineKinds === 0 ? 'none' : lineKinds > 1 ? 'mixed' : crlf ? 'crlf' : lf ? 'lf' : 'cr', + diagnostics: createEmptyXerArchiveDiagnostics(), + readModel: createEmptyXerArchiveReadModel(), + }; +} + +/** + * Synchrone SHA-256 zonder omgevingsafhankelijke crypto-import. Deze functie + * is klein maar volledig; XER-archivering moet in browser en Node identiek + * zijn. + */ +export function sha256Hex(input: Uint8Array): string { + const hash = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, + ]; + const constants = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, + ]; + const rotateRight = (value: number, bits: number) => (value >>> bits) | (value << (32 - bits)); + const processBlock = (block: Uint8Array) => { + const schedule = new Uint32Array(64); + for (let index = 0; index < 16; index += 1) { + const offset = index * 4; + schedule[index] = ((block[offset] << 24) | (block[offset + 1] << 16) | (block[offset + 2] << 8) | block[offset + 3]) >>> 0; + } + for (let index = 16; index < 64; index += 1) { + const a = schedule[index - 15]; + const b = schedule[index - 2]; + schedule[index] = (((rotateRight(a, 7) ^ rotateRight(a, 18) ^ (a >>> 3)) + schedule[index - 7] + + (rotateRight(b, 17) ^ rotateRight(b, 19) ^ (b >>> 10)) + schedule[index - 16]) | 0); + } + let [a, b, c, d, e, f, g, h] = hash; + for (let index = 0; index < 64; index += 1) { + const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25); + const choose = (e & f) ^ (~e & g); + const temporary1 = (h + sum1 + choose + constants[index] + schedule[index]) | 0; + const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22); + const majority = (a & b) ^ (a & c) ^ (b & c); + const temporary2 = (sum0 + majority) | 0; + h = g; g = f; f = e; e = (d + temporary1) | 0; + d = c; c = b; b = a; a = (temporary1 + temporary2) | 0; + } + hash[0] = (hash[0] + a) | 0; hash[1] = (hash[1] + b) | 0; + hash[2] = (hash[2] + c) | 0; hash[3] = (hash[3] + d) | 0; + hash[4] = (hash[4] + e) | 0; hash[5] = (hash[5] + f) | 0; + hash[6] = (hash[6] + g) | 0; hash[7] = (hash[7] + h) | 0; + }; + let offset = 0; + while (offset + 64 <= input.byteLength) { + processBlock(input.subarray(offset, offset + 64)); + offset += 64; + } + const tail = new Uint8Array(input.byteLength - offset + 128); + tail.set(input.subarray(offset)); + tail[input.byteLength - offset] = 0x80; + const bitLength = BigInt(input.byteLength) * 8n; + const lengthOffset = input.byteLength - offset < 56 ? 56 : 120; + for (let index = 0; index < 8; index += 1) { + tail[lengthOffset + 7 - index] = Number((bitLength >> BigInt(index * 8)) & 0xffn); + } + processBlock(tail.subarray(0, 64)); + if (lengthOffset === 120) processBlock(tail.subarray(64, 128)); + return hash.map((value) => (value >>> 0).toString(16).padStart(8, '0')).join(''); +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +function invalid(message: string): never { + throw new XerSourceArchiveValidationError(message); +} + +function objectOf(value: unknown, path: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value) + || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)) { + invalid(`${path} is geen gewoon object`); + } + return value as Record; +} + +function arrayOf(value: unknown, path: string): unknown[] { + if (!Array.isArray(value)) invalid(`${path} is geen array`); + return value; +} + +function exactKeys(value: Record, allowed: readonly string[], path: string): void { + const extras = Object.keys(value).filter(key => !allowed.includes(key)); + if (extras.length > 0) invalid(`${path} bevat onbekende velden: ${extras.join(', ')}`); +} + +function stringOf(value: unknown, path: string, allowEmpty = true): string { + if (typeof value !== 'string' || (!allowEmpty && value.length === 0)) invalid(`${path} is geen geldige tekenreeks`); + return value; +} + +function booleanOf(value: unknown, path: string): boolean { + if (typeof value !== 'boolean') invalid(`${path} is geen boolean`); + return value; +} + +function nonNegativeInteger(value: unknown, path: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) invalid(`${path} is geen niet-negatief safe integer`); + return value; +} + +function finiteNumber(value: unknown, path: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) invalid(`${path} is geen eindig getal`); + return value; +} + +function nullableFiniteNumber(value: unknown, path: string): number | null { + return value === null ? null : finiteNumber(value, path); +} + +function optionalStringFields( + value: Record, keys: readonly string[], path: string, +): void { + for (const key of keys) if (value[key] !== undefined) stringOf(value[key], `${path}.${key}`); +} + +function optionalFiniteNumberFields( + value: Record, keys: readonly string[], path: string, +): void { + for (const key of keys) if (value[key] !== undefined) finiteNumber(value[key], `${path}.${key}`); +} + +function oneOf(value: unknown, choices: readonly T[], path: string): T { + if (typeof value !== 'string' || !(choices as readonly string[]).includes(value)) invalid(`${path} bevat onbekende enumwaarde`); + return value as T; +} + +function jsonValue(value: unknown, path: string): void { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return; + if (typeof value === 'number') { + if (!Number.isFinite(value)) invalid(`${path} bevat geen eindig getal`); + return; + } + if (Array.isArray(value)) { + value.forEach((item, index) => jsonValue(item, `${path}[${index}]`)); + return; + } + const object = objectOf(value, path); + for (const [key, child] of Object.entries(object)) jsonValue(child, `${path}.${key}`); +} + +function validateSourceRow(value: unknown, path: string): void { + const row = objectOf(value, path); + exactKeys(row, ['line', 'cells'], path); + nonNegativeInteger(row.line, `${path}.line`); + const cells = objectOf(row.cells, `${path}.cells`); + for (const [field, cell] of Object.entries(cells)) { + // Ruwe bronrijen zijn een lossless cache naast de exacte bytes. Legacy XER kan een lege + // kolomkop bevatten; die celnaam mag daarom niet genormaliseerd of afgewezen worden. + stringOf(cell, `${path}.cells.${field || ''}`); + } +} + +function validateTableReport(value: unknown, path: string): void { + const report = objectOf(value, path); + exactKeys(report, ['encoding', 'endMarkerSeen', 'issues', 'unknownTables', 'unknownFields'], path); + oneOf(report.encoding, ['utf-8', 'utf-16le', 'utf-16be', 'windows-1252'], `${path}.encoding`); + booleanOf(report.endMarkerSeen, `${path}.endMarkerSeen`); + for (const [index, issueValue] of arrayOf(report.issues, `${path}.issues`).entries()) { + const issue = objectOf(issueValue, `${path}.issues[${index}]`); + exactKeys(issue, ['code', 'line', 'table', 'expected', 'actual', 'field', 'currencyCode', 'ignoredRecords', 'ignoredLines'], `${path}.issues[${index}]`); + oneOf(issue.code, [ + 'XER_MISSING_END_MARKER', 'XER_TRAILING_RECORDS_AFTER_END', 'XER_CURRENCY_NOT_FOUND', + 'XER_ROW_FIELD_COUNT_MISMATCH', 'XER_DATA_WITHOUT_FIELDS', 'XER_ORPHAN_CONTINUATION', + 'XER_DUPLICATE_FIELD', 'XER_UNKNOWN_RECORD', + ], `${path}.issues[${index}].code`); + nonNegativeInteger(issue.line, `${path}.issues[${index}].line`); + for (const key of ['table', 'field', 'currencyCode'] as const) if (issue[key] !== undefined) stringOf(issue[key], `${path}.issues[${index}].${key}`); + for (const key of ['expected', 'actual', 'ignoredRecords', 'ignoredLines'] as const) if (issue[key] !== undefined) nonNegativeInteger(issue[key], `${path}.issues[${index}].${key}`); + } + for (const [index, tableValue] of arrayOf(report.unknownTables, `${path}.unknownTables`).entries()) { + const table = objectOf(tableValue, `${path}.unknownTables[${index}]`); + exactKeys(table, ['name', 'rows'], `${path}.unknownTables[${index}]`); + stringOf(table.name, `${path}.unknownTables[${index}].name`, false); + nonNegativeInteger(table.rows, `${path}.unknownTables[${index}].rows`); + } + if (report.unknownFields !== undefined) { + for (const [index, fieldValue] of arrayOf(report.unknownFields, `${path}.unknownFields`).entries()) { + const field = objectOf(fieldValue, `${path}.unknownFields[${index}]`); + exactKeys(field, ['table', 'name', 'rows'], `${path}.unknownFields[${index}]`); + stringOf(field.table, `${path}.unknownFields[${index}].table`, false); + // Een lege legacy-%F-kolomnaam is toegestaan; de bronrijvalidator bewaart die vorm ook. + stringOf(field.name, `${path}.unknownFields[${index}].name`); + nonNegativeInteger(field.rows, `${path}.unknownFields[${index}].rows`); + } + } +} + +const RESOURCE_ISSUE_CODES = [ + 'XER_RESOURCE_CALENDAR_MISSING', 'XER_RESOURCE_NONLABOR_FALLBACK', 'XER_RESOURCE_TYPE_FALLBACK', + 'XER_RESOURCE_PARENT_MISSING', 'XER_RESOURCE_DEFAULT_ROLE_MISSING', 'XER_ROLE_PARENT_MISSING', + 'XER_RESOURCE_RATE_OWNER_MISSING', 'XER_ROLE_RATE_OWNER_MISSING', 'XER_CURVE_INVALID_POINTS', + 'XER_ASSIGNMENT_CURVE_MISSING', 'XER_ASSIGNMENT_RESOURCE_MISSING', 'XER_ASSIGNMENT_ROLE_MISSING', + 'XER_ASSIGNMENT_ASSIGNED_ROLE_MISSING', 'XER_ASSIGNMENT_TASK_MISSING', +] as const; +const RESOURCE_TABLES = ['RSRC', 'ROLES', 'RSRCRATE', 'ROLERATE', 'RSRCCURVDATA', 'TASKRSRC'] as const; +const RESOURCE_FALLBACKS = ['PROJECT_CALENDAR', 'EQUIPMENT', 'LABOR', 'UNIFORM', 'SKIPPED', 'RELATION_OMITTED'] as const; + +function validateResourceIssue(value: unknown, path: string): void { + const issue = objectOf(value, path); + exactKeys(issue, ['code', 'table', 'line', 'sourceId', 'fallback'], path); + oneOf(issue.code, RESOURCE_ISSUE_CODES, `${path}.code`); + oneOf(issue.table, RESOURCE_TABLES, `${path}.table`); + nonNegativeInteger(issue.line, `${path}.line`); + stringOf(issue.sourceId, `${path}.sourceId`); + oneOf(issue.fallback, RESOURCE_FALLBACKS, `${path}.fallback`); +} + +const METADATA_ISSUE_CODES = [ + 'XER_ACTIVITY_CODE_MISSING_TYPE_ID', 'XER_ACTIVITY_CODE_MISSING_VALUE_ID', + 'XER_ACTIVITY_CODE_DUPLICATE_TYPE_ID', 'XER_ACTIVITY_CODE_DUPLICATE_VALUE_ID', + 'XER_ACTIVITY_CODE_DUPLICATE_LINK', 'XER_ACTIVITY_CODE_DANGLING_TYPE_PARENT', + 'XER_ACTIVITY_CODE_DANGLING_VALUE_PARENT', 'XER_ACTIVITY_CODE_DANGLING_TASK', + 'XER_ACTIVITY_CODE_DANGLING_TYPE', 'XER_ACTIVITY_CODE_DANGLING_VALUE', + 'XER_UDF_MISSING_TYPE_ID', 'XER_UDF_DUPLICATE_TYPE_ID', 'XER_UDF_DUPLICATE_VALUE', + 'XER_UDF_DANGLING_TYPE', 'XER_UDF_DANGLING_ENTITY', 'XER_UDF_AMBIGUOUS_TASK', + 'XER_UDF_UNKNOWN_DATA_TYPE', 'XER_UDF_INVALID_VALUE', 'XER_UDF_DEFERRED_ENTITY', + 'XER_NOTE_DUPLICATE_MEMO_ID', 'XER_NOTE_DANGLING_MEMO_TYPE', 'XER_NOTE_DANGLING_TASK', + 'XER_NOTE_AMBIGUOUS_TASK', +] as const; +const METADATA_TABLES = ['ACTVTYPE', 'ACTVCODE', 'TASKACTV', 'UDFTYPE', 'UDFVALUE', 'TASK', 'TASKNOTE', 'TASKMEMO'] as const; + +function validateMetadataIssue(value: unknown, path: string): void { + const issue = objectOf(value, path); + exactKeys(issue, ['code', 'table', 'line', 'lines'], path); + oneOf(issue.code, METADATA_ISSUE_CODES, `${path}.code`); + oneOf(issue.table, METADATA_TABLES, `${path}.table`); + nonNegativeInteger(issue.line, `${path}.line`); + if (issue.lines !== undefined) arrayOf(issue.lines, `${path}.lines`).forEach((line, index) => nonNegativeInteger(line, `${path}.lines[${index}]`)); +} + +function validateImportReport(value: unknown, path: string): void { + const report = objectOf(value, path); + exactKeys(report, [ + 'projectsSeen', 'documentsOpened', 'emptyProjectsSkipped', 'baselineProjectsExcluded', + 'baselinesMaterialized', 'danglingBaselineReferences', 'externalLinksPreserved', + 'baselineExclusionReverted', 'baselineFallbackReasons', + ], path); + for (const key of [ + 'projectsSeen', 'documentsOpened', 'emptyProjectsSkipped', 'baselineProjectsExcluded', + 'baselinesMaterialized', 'danglingBaselineReferences', 'externalLinksPreserved', + ] as const) nonNegativeInteger(report[key], `${path}.${key}`); + booleanOf(report.baselineExclusionReverted, `${path}.baselineExclusionReverted`); + arrayOf(report.baselineFallbackReasons, `${path}.baselineFallbackReasons`).forEach((reason, index) => + oneOf(reason, ['self-reference', 'cycle', 'all-projects-baselines'], `${path}.baselineFallbackReasons[${index}]`)); +} + +function validateScheduleOptionsDiagnostic(value: unknown, path: string): void { + const diagnostic = objectOf(value, path); + exactKeys(diagnostic, ['code', 'projectId', 'rowIndexes', 'lines'], path); + oneOf(diagnostic.code, ['XER_DUPLICATE_SCHEDOPTIONS_PROJ_ID'], `${path}.code`); + stringOf(diagnostic.projectId, `${path}.projectId`); + for (const key of ['rowIndexes', 'lines'] as const) { + arrayOf(diagnostic[key], `${path}.${key}`).forEach((item, index) => + nonNegativeInteger(item, `${path}.${key}[${index}]`)); + } +} + +function validateScheduleOptionsSourceArchive(value: unknown, path: string): void { + const archive = objectOf(value, path); + exactKeys(archive, ['rows', 'unmatchedScheduleOptionsRowIndexes', 'diagnostics'], path); + arrayOf(archive.rows, `${path}.rows`).forEach((row, index) => { + const item = objectOf(row, `${path}.rows[${index}]`); + exactKeys(item, ['table', 'line', 'cells'], `${path}.rows[${index}]`); + oneOf(item.table, ['PROJECT', 'SCHEDOPTIONS'], `${path}.rows[${index}].table`); + validateSourceRow({ line: item.line, cells: item.cells }, `${path}.rows[${index}]`); + }); + arrayOf(archive.unmatchedScheduleOptionsRowIndexes, `${path}.unmatchedScheduleOptionsRowIndexes`) + .forEach((item, index) => nonNegativeInteger(item, `${path}.unmatchedScheduleOptionsRowIndexes[${index}]`)); + arrayOf(archive.diagnostics, `${path}.diagnostics`) + .forEach((item, index) => validateScheduleOptionsDiagnostic(item, `${path}.diagnostics[${index}]`)); +} + +function validateScheduleOptionsView(value: unknown, path: string): void { + const options = objectOf(value, path); + exactKeys(options, ['source', 'retainedSource', 'fallbacks', 'diagnostics', 'sourceRowIndexes'], path); + oneOf(options.source, ['schedoptions', 'xer-defaults'], `${path}.source`); + const retained = objectOf(options.retainedSource, `${path}.retainedSource`); + exactKeys(retained, ['sched_use_project_end_date_for_float'], `${path}.retainedSource`); + if (retained.sched_use_project_end_date_for_float !== undefined) booleanOf(retained.sched_use_project_end_date_for_float, `${path}.retainedSource.sched_use_project_end_date_for_float`); + arrayOf(options.fallbacks, `${path}.fallbacks`).forEach((fallback, index) => { + const item = objectOf(fallback, `${path}.fallbacks[${index}]`); + exactKeys(item, ['field', 'token', 'fallback', 'line'], `${path}.fallbacks[${index}]`); + stringOf(item.field, `${path}.fallbacks[${index}].field`); + stringOf(item.token, `${path}.fallbacks[${index}].token`); + stringOf(item.fallback, `${path}.fallbacks[${index}].fallback`); + nonNegativeInteger(item.line, `${path}.fallbacks[${index}].line`); + }); + arrayOf(options.diagnostics, `${path}.diagnostics`).forEach((item, index) => + validateScheduleOptionsDiagnostic(item, `${path}.diagnostics[${index}]`)); + arrayOf(options.sourceRowIndexes, `${path}.sourceRowIndexes`).forEach((item, index) => + nonNegativeInteger(item, `${path}.sourceRowIndexes[${index}]`)); +} + +function validateResource(value: unknown, path: string): void { + const resource = objectOf(value, path); + exactKeys(resource, [ + 'id', 'name', 'type', 'description', 'costPerHour', 'availability', 'maxUnits', 'calendarId', + 'availabilitySteps', 'unitOfMeasure', 'parentId', 'libraryOrigin', + ], path); + for (const key of ['id', 'name', 'description'] as const) stringOf(resource[key], `${path}.${key}`); + oneOf(resource.type, ['LABOR', 'EQUIPMENT', 'MATERIAL', 'SUBCONTRACTOR', 'CREW'], `${path}.type`); + finiteNumber(resource.maxUnits, `${path}.maxUnits`); + optionalFiniteNumberFields(resource, ['costPerHour', 'availability'], path); + optionalStringFields(resource, ['calendarId', 'unitOfMeasure', 'parentId'], path); + if (resource.availabilitySteps !== undefined) { + arrayOf(resource.availabilitySteps, `${path}.availabilitySteps`).forEach((stepValue, index) => { + const stepPath = `${path}.availabilitySteps[${index}]`; + const step = objectOf(stepValue, stepPath); + exactKeys(step, ['from', 'maxUnits'], stepPath); + stringOf(step.from, `${stepPath}.from`, false); + finiteNumber(step.maxUnits, `${stepPath}.maxUnits`); + }); + } + if (resource.libraryOrigin !== undefined) { + const origin = objectOf(resource.libraryOrigin, `${path}.libraryOrigin`); + exactKeys(origin, ['companyId', 'libraryItemId', 'poolVersion', 'syncedHash'], `${path}.libraryOrigin`); + stringOf(origin.companyId, `${path}.libraryOrigin.companyId`, false); + stringOf(origin.libraryItemId, `${path}.libraryOrigin.libraryItemId`, false); + nonNegativeInteger(origin.poolVersion, `${path}.libraryOrigin.poolVersion`); + if (origin.syncedHash !== undefined) stringOf(origin.syncedHash, `${path}.libraryOrigin.syncedHash`); + } +} + +function validateEntityIdentity(value: unknown, path: string): void { + const identity = objectOf(value, path); + exactKeys(identity, ['kind', 'sourceId', 'internalId', 'line'], path); + oneOf(identity.kind, ['RESOURCE', 'ROLE'], `${path}.kind`); + stringOf(identity.sourceId, `${path}.sourceId`); + stringOf(identity.internalId, `${path}.internalId`); + nonNegativeInteger(identity.line, `${path}.line`); +} + +function validateEntitySource(value: unknown, path: string): void { + const entity = objectOf(value, path); + exactKeys(entity, ['kind', 'sourceId', 'internalId'], path); + oneOf(entity.kind, ['RESOURCE', 'ROLE'], `${path}.kind`); + stringOf(entity.sourceId, `${path}.sourceId`); + stringOf(entity.internalId, `${path}.internalId`); +} + +function validateResourceSource(value: unknown, path: string): void { + const source = objectOf(value, path); + exactKeys(source, [ + 'rawRow', 'sourceId', 'internalId', 'line', 'rawType', 'parentSourceId', 'calendarSourceId', + 'defaultRoleSourceId', 'unitSourceId', + ], path); + validateSourceRow(source.rawRow, `${path}.rawRow`); + for (const key of ['sourceId', 'internalId', 'rawType'] as const) stringOf(source[key], `${path}.${key}`); + nonNegativeInteger(source.line, `${path}.line`); + optionalStringFields(source, ['parentSourceId', 'calendarSourceId', 'defaultRoleSourceId', 'unitSourceId'], path); +} + +function validateRoleSource(value: unknown, path: string): void { + const source = objectOf(value, path); + exactKeys(source, ['rawRow', 'sourceId', 'internalId', 'line', 'name', 'shortName', 'description', 'parentSourceId'], path); + validateSourceRow(source.rawRow, `${path}.rawRow`); + for (const key of ['sourceId', 'internalId', 'name', 'shortName', 'description'] as const) stringOf(source[key], `${path}.${key}`); + nonNegativeInteger(source.line, `${path}.line`); + optionalStringFields(source, ['parentSourceId'], path); +} + +function validateRateSource(value: unknown, path: string): void { + const source = objectOf(value, path); + exactKeys(source, ['rawRow', 'sourceId', 'internalId', 'entity', 'line', 'effectiveDate', 'maxUnitsPerTime', 'costs'], path); + validateSourceRow(source.rawRow, `${path}.rawRow`); + stringOf(source.sourceId, `${path}.sourceId`); + stringOf(source.internalId, `${path}.internalId`); + validateEntitySource(source.entity, `${path}.entity`); + nonNegativeInteger(source.line, `${path}.line`); + if (source.effectiveDate !== undefined) stringOf(source.effectiveDate, `${path}.effectiveDate`); + nullableFiniteNumber(source.maxUnitsPerTime, `${path}.maxUnitsPerTime`); + const costs = arrayOf(source.costs, `${path}.costs`); + if (costs.length !== 5) invalid(`${path}.costs is geen tuple van lengte 5`); + costs.forEach((cost, index) => nullableFiniteNumber(cost, `${path}.costs[${index}]`)); +} + +function validateCurveSource(value: unknown, path: string): void { + const source = objectOf(value, path); + exactKeys(source, ['rawRow', 'sourceId', 'internalId', 'line', 'name', 'rawPoints', 'numericPoints'], path); + validateSourceRow(source.rawRow, `${path}.rawRow`); + for (const key of ['sourceId', 'internalId', 'name'] as const) stringOf(source[key], `${path}.${key}`); + nonNegativeInteger(source.line, `${path}.line`); + const rawPoints = arrayOf(source.rawPoints, `${path}.rawPoints`); + if (rawPoints.length !== 21) invalid(`${path}.rawPoints is geen tuple van lengte 21`); + rawPoints.forEach((point, index) => stringOf(point, `${path}.rawPoints[${index}]`)); + if (source.numericPoints !== undefined) { + const numericPoints = arrayOf(source.numericPoints, `${path}.numericPoints`); + if (numericPoints.length !== 21) invalid(`${path}.numericPoints is geen tuple van lengte 21`); + numericPoints.forEach((point, index) => finiteNumber(point, `${path}.numericPoints[${index}]`)); + } +} + +function validateTaskResourceSource(value: unknown, path: string): void { + const source = objectOf(value, path); + exactKeys(source, [ + 'rawRow', 'sourceId', 'internalId', 'taskSourceId', 'projectSourceId', 'line', 'entity', + 'assignedRole', 'unitScale', 'quantities', 'curveSourceId', 'rawCurves', 'costs', 'rateType', + 'costSourceType', 'rawResourceType', + ], path); + validateSourceRow(source.rawRow, `${path}.rawRow`); + for (const key of ['sourceId', 'internalId', 'taskSourceId'] as const) stringOf(source[key], `${path}.${key}`); + optionalStringFields(source, ['projectSourceId', 'curveSourceId', 'rateType', 'costSourceType', 'rawResourceType'], path); + nonNegativeInteger(source.line, `${path}.line`); + validateEntitySource(source.entity, `${path}.entity`); + if (source.assignedRole !== undefined) validateEntitySource(source.assignedRole, `${path}.assignedRole`); + oneOf(source.unitScale, ['DIRECT_FRACTION', 'MATERIAL_PER_HOUR'], `${path}.unitScale`); + const quantities = objectOf(source.quantities, `${path}.quantities`); + exactKeys(quantities, ['remaining', 'target', 'actualRegular', 'actualOvertime', 'thisPeriod', 'remainingPerHour', 'targetPerHour'], `${path}.quantities`); + optionalFiniteNumberFields(quantities, Object.keys(quantities), `${path}.quantities`); + const curves = objectOf(source.rawCurves, `${path}.rawCurves`); + exactKeys(curves, ['target', 'remaining', 'actual'], `${path}.rawCurves`); + optionalStringFields(curves, ['target', 'remaining', 'actual'], `${path}.rawCurves`); + const costs = objectOf(source.costs, `${path}.costs`); + exactKeys(costs, ['perQuantity', 'target', 'remaining', 'actualRegular', 'actualOvertime', 'thisPeriod'], `${path}.costs`); + optionalFiniteNumberFields(costs, Object.keys(costs), `${path}.costs`); +} + +function validateExternalRelation(value: unknown, path: string): void { + const relation = objectOf(value, path); + exactKeys(relation, ['id', 'localProjectId', 'localTaskId', 'externalProjectId', 'externalTaskId', 'direction', 'type', 'lagMinutes'], path); + for (const key of ['id', 'localProjectId', 'localTaskId', 'externalProjectId', 'externalTaskId'] as const) stringOf(relation[key], `${path}.${key}`); + oneOf(relation.direction, ['predecessor', 'successor'], `${path}.direction`); + oneOf(relation.type, ['FS', 'SS', 'FF', 'SF'], `${path}.type`); + finiteNumber(relation.lagMinutes, `${path}.lagMinutes`); +} + +function validateExternalLink(value: unknown, path: string): void { + const link = objectOf(value, path); + exactKeys(link, ['id', 'predecessor', 'successor', 'type', 'lagMinutes'], path); + stringOf(link.id, `${path}.id`); + for (const endpoint of ['predecessor', 'successor'] as const) { + const item = objectOf(link[endpoint], `${path}.${endpoint}`); + exactKeys(item, ['projectId', 'taskId'], `${path}.${endpoint}`); + stringOf(item.projectId, `${path}.${endpoint}.projectId`); + stringOf(item.taskId, `${path}.${endpoint}.taskId`); + } + oneOf(link.type, ['FS', 'SS', 'FF', 'SF'], `${path}.type`); + finiteNumber(link.lagMinutes, `${path}.lagMinutes`); +} + +function validateDocumentView(value: unknown, projectId: string, path: string): void { + const view = objectOf(value, path); + exactKeys(view, [ + 'sourceProjectId', 'defaultCurrencyCode', 'tableReport', 'calendarIssues', 'enumFallbacks', + 'scheduleOptions', 'externalRelations', 'externalLinks', 'report', 'resources', + ], path); + if (stringOf(view.sourceProjectId, `${path}.sourceProjectId`, false) !== projectId) invalid(`${path}.sourceProjectId past niet bij projectselector`); + stringOf(view.defaultCurrencyCode, `${path}.defaultCurrencyCode`); + validateTableReport(view.tableReport, `${path}.tableReport`); + arrayOf(view.calendarIssues, `${path}.calendarIssues`).forEach((issueValue, index) => { + const issue = objectOf(issueValue, `${path}.calendarIssues[${index}]`); + exactKeys(issue, ['code', 'calendarId', 'line', 'reason', 'resolution'], `${path}.calendarIssues[${index}]`); + stringOf(issue.code, `${path}.calendarIssues[${index}].code`, false); + stringOf(issue.calendarId, `${path}.calendarIssues[${index}].calendarId`); + nonNegativeInteger(issue.line, `${path}.calendarIssues[${index}].line`); + stringOf(issue.reason, `${path}.calendarIssues[${index}].reason`); + oneOf(issue.resolution, ['RECOVERED', 'REJECTED', 'UNLINKED'], `${path}.calendarIssues[${index}].resolution`); + }); + arrayOf(view.enumFallbacks, `${path}.enumFallbacks`).forEach((fallbackValue, index) => { + const fallback = objectOf(fallbackValue, `${path}.enumFallbacks[${index}]`); + exactKeys(fallback, ['family', 'token', 'fallback', 'table', 'field', 'line'], `${path}.enumFallbacks[${index}]`); + oneOf(fallback.family, ['activityType', 'durationType', 'completePctType', 'status', 'priority', 'constraint', 'relation'], `${path}.enumFallbacks[${index}].family`); + oneOf(fallback.table, ['PROJECT', 'TASK', 'TASKPRED'], `${path}.enumFallbacks[${index}].table`); + for (const key of ['token', 'fallback', 'field'] as const) stringOf(fallback[key], `${path}.enumFallbacks[${index}].${key}`); + nonNegativeInteger(fallback.line, `${path}.enumFallbacks[${index}].line`); + }); + validateScheduleOptionsView(view.scheduleOptions, `${path}.scheduleOptions`); + arrayOf(view.externalRelations, `${path}.externalRelations`).forEach((item, index) => + validateExternalRelation(item, `${path}.externalRelations[${index}]`)); + arrayOf(view.externalLinks, `${path}.externalLinks`).forEach((item, index) => + validateExternalLink(item, `${path}.externalLinks[${index}]`)); + validateImportReport(view.report, `${path}.report`); + if (view.resources !== undefined) { + const resources = objectOf(view.resources, `${path}.resources`); + exactKeys(resources, ['assignments', 'issues'], `${path}.resources`); + arrayOf(resources.assignments, `${path}.resources.assignments`).forEach((item, index) => + validateTaskResourceSource(item, `${path}.resources.assignments[${index}]`)); + arrayOf(resources.issues, `${path}.resources.issues`).forEach((item, index) => validateResourceIssue(item, `${path}.resources.issues[${index}]`)); + } +} + +export function validateXerArchiveDiagnosticsV1(value: unknown): asserts value is XerArchiveDiagnosticsV1 { + const diagnostics = objectOf(value, 'diagnostics'); + exactKeys(diagnostics, ['schemaVersion', 'file', 'documentViews', 'opaqueExtensions'], 'diagnostics'); + if (diagnostics.schemaVersion !== 1) invalid('diagnostics.schemaVersion is onbekend'); + const file = objectOf(diagnostics.file, 'diagnostics.file'); + exactKeys(file, ['tableReport', 'scheduleOptions', 'relationResolutionIssues', 'resourceCatalogIssues', 'metadataCatalogIssues', 'importReport'], 'diagnostics.file'); + validateTableReport(file.tableReport, 'diagnostics.file.tableReport'); + arrayOf(file.scheduleOptions, 'diagnostics.file.scheduleOptions').forEach((item, index) => + validateScheduleOptionsDiagnostic(item, `diagnostics.file.scheduleOptions[${index}]`)); + arrayOf(file.relationResolutionIssues, 'diagnostics.file.relationResolutionIssues').forEach((issueValue, index) => { + const issue = objectOf(issueValue, `diagnostics.file.relationResolutionIssues[${index}]`); + exactKeys(issue, ['reason', 'line', 'field', 'taskId', 'predecessorTaskId'], `diagnostics.file.relationResolutionIssues[${index}]`); + oneOf(issue.reason, ['ambiguous', 'dangling'], `diagnostics.file.relationResolutionIssues[${index}].reason`); + oneOf(issue.field, ['task_id', 'pred_task_id', 'proj_id'], `diagnostics.file.relationResolutionIssues[${index}].field`); + nonNegativeInteger(issue.line, `diagnostics.file.relationResolutionIssues[${index}].line`); + stringOf(issue.taskId, `diagnostics.file.relationResolutionIssues[${index}].taskId`); + stringOf(issue.predecessorTaskId, `diagnostics.file.relationResolutionIssues[${index}].predecessorTaskId`); + }); + arrayOf(file.resourceCatalogIssues, 'diagnostics.file.resourceCatalogIssues').forEach((item, index) => validateResourceIssue(item, `diagnostics.file.resourceCatalogIssues[${index}]`)); + arrayOf(file.metadataCatalogIssues, 'diagnostics.file.metadataCatalogIssues').forEach((item, index) => validateMetadataIssue(item, `diagnostics.file.metadataCatalogIssues[${index}]`)); + validateImportReport(file.importReport, 'diagnostics.file.importReport'); + const views = objectOf(diagnostics.documentViews, 'diagnostics.documentViews'); + for (const [projectId, view] of Object.entries(views)) { + if (!projectId) invalid('diagnostics.documentViews bevat een lege projectselector'); + validateDocumentView(view, projectId, `diagnostics.documentViews.${projectId}`); + } + if (diagnostics.opaqueExtensions !== undefined) jsonValue(objectOf(diagnostics.opaqueExtensions, 'diagnostics.opaqueExtensions'), 'diagnostics.opaqueExtensions'); +} + +function validateActivityCodeType(value: unknown, path: string): void { + const type = objectOf(value, path); + exactKeys(type, ['id', 'name', 'values'], path); + stringOf(type.id, `${path}.id`); + stringOf(type.name, `${path}.name`); + arrayOf(type.values, `${path}.values`).forEach((valueItem, index) => { + const valuePath = `${path}.values[${index}]`; + const code = objectOf(valueItem, valuePath); + exactKeys(code, ['id', 'code', 'description', 'color'], valuePath); + stringOf(code.id, `${valuePath}.id`); + stringOf(code.code, `${valuePath}.code`); + optionalStringFields(code, ['description', 'color'], valuePath); + }); +} + +function validateCustomFieldDef(value: unknown, path: string): void { + const field = objectOf(value, path); + exactKeys(field, ['id', 'name', 'type'], path); + stringOf(field.id, `${path}.id`); + stringOf(field.name, `${path}.name`); + oneOf(field.type, ['text', 'number', 'integer', 'cost', 'date', 'boolean'], `${path}.type`); +} + +function validateTaskProjection(value: unknown, path: string): void { + const projection = objectOf(value, path); + exactKeys(projection, ['projectId', 'taskId', 'activityCodes', 'customFields', 'notes'], path); + stringOf(projection.projectId, `${path}.projectId`); + stringOf(projection.taskId, `${path}.taskId`); + if (projection.activityCodes !== undefined) { + const codes = objectOf(projection.activityCodes, `${path}.activityCodes`); + for (const [key, code] of Object.entries(codes)) stringOf(code, `${path}.activityCodes.${key || ''}`); + } + if (projection.customFields !== undefined) { + const fields = objectOf(projection.customFields, `${path}.customFields`); + for (const [key, field] of Object.entries(fields)) { + if (typeof field === 'number') finiteNumber(field, `${path}.customFields.${key || ''}`); + else if (typeof field !== 'string' && typeof field !== 'boolean') invalid(`${path}.customFields.${key || ''} heeft een ongeldige waarde`); + } + } + if (projection.notes !== undefined) { + arrayOf(projection.notes, `${path}.notes`).forEach((noteValue, index) => { + const notePath = `${path}.notes[${index}]`; + const note = objectOf(noteValue, notePath); + exactKeys(note, ['id', 'text', 'done'], notePath); + stringOf(note.id, `${notePath}.id`); + stringOf(note.text, `${notePath}.text`); + booleanOf(note.done, `${notePath}.done`); + }); + } +} + +export function validateXerArchiveReadModelV1(value: unknown): asserts value is XerArchiveReadModelV1 { + const model = objectOf(value, 'readModel'); + exactKeys(model, ['schemaVersion', 'numberFormat', 'scheduleOptionsSourceArchive', 'resourceCatalog', 'metadataCatalog', 'taskSourceRowsByProject'], 'readModel'); + if (model.schemaVersion !== 1) invalid('readModel.schemaVersion is onbekend'); + const numberFormat = objectOf(model.numberFormat, 'readModel.numberFormat'); + exactKeys(numberFormat, ['decimal', 'group', 'source', 'currencyCode'], 'readModel.numberFormat'); + const decimal = oneOf(numberFormat.decimal, ['.', ','], 'readModel.numberFormat.decimal'); + const group = numberFormat.group === null ? null : oneOf(numberFormat.group, ['.', ','], 'readModel.numberFormat.group'); + if (group === decimal) invalid('readModel.numberFormat.group is gelijk aan decimal'); + oneOf(numberFormat.source, ['currtype', 'default'], 'readModel.numberFormat.source'); + stringOf(numberFormat.currencyCode, 'readModel.numberFormat.currencyCode'); + validateScheduleOptionsSourceArchive(model.scheduleOptionsSourceArchive, 'readModel.scheduleOptionsSourceArchive'); + + const resources = objectOf(model.resourceCatalog, 'readModel.resourceCatalog'); + exactKeys(resources, ['resources', 'identities', 'rows', 'issues'], 'readModel.resourceCatalog'); + arrayOf(resources.resources, 'readModel.resourceCatalog.resources').forEach((item, index) => + validateResource(item, `readModel.resourceCatalog.resources[${index}]`)); + arrayOf(resources.identities, 'readModel.resourceCatalog.identities').forEach((item, index) => + validateEntityIdentity(item, `readModel.resourceCatalog.identities[${index}]`)); + const resourceRows = objectOf(resources.rows, 'readModel.resourceCatalog.rows'); + exactKeys(resourceRows, ['resources', 'roles', 'rates', 'curves', 'assignments'], 'readModel.resourceCatalog.rows'); + arrayOf(resourceRows.resources, 'readModel.resourceCatalog.rows.resources').forEach((item, index) => + validateResourceSource(item, `readModel.resourceCatalog.rows.resources[${index}]`)); + arrayOf(resourceRows.roles, 'readModel.resourceCatalog.rows.roles').forEach((item, index) => + validateRoleSource(item, `readModel.resourceCatalog.rows.roles[${index}]`)); + arrayOf(resourceRows.rates, 'readModel.resourceCatalog.rows.rates').forEach((item, index) => + validateRateSource(item, `readModel.resourceCatalog.rows.rates[${index}]`)); + arrayOf(resourceRows.curves, 'readModel.resourceCatalog.rows.curves').forEach((item, index) => + validateCurveSource(item, `readModel.resourceCatalog.rows.curves[${index}]`)); + arrayOf(resourceRows.assignments, 'readModel.resourceCatalog.rows.assignments').forEach((row, index) => validateSourceRow(row, `readModel.resourceCatalog.rows.assignments[${index}]`)); + arrayOf(resources.issues, 'readModel.resourceCatalog.issues').forEach((item, index) => validateResourceIssue(item, `readModel.resourceCatalog.issues[${index}]`)); + + const metadata = objectOf(model.metadataCatalog, 'readModel.metadataCatalog'); + exactKeys(metadata, ['activityCodeTypes', 'customFieldDefs', 'taskProjections', 'taskProjectionsByProject', 'issues', 'issueCounts', 'sourceData'], 'readModel.metadataCatalog'); + arrayOf(metadata.activityCodeTypes, 'readModel.metadataCatalog.activityCodeTypes').forEach((item, index) => + validateActivityCodeType(item, `readModel.metadataCatalog.activityCodeTypes[${index}]`)); + arrayOf(metadata.customFieldDefs, 'readModel.metadataCatalog.customFieldDefs').forEach((item, index) => + validateCustomFieldDef(item, `readModel.metadataCatalog.customFieldDefs[${index}]`)); + arrayOf(metadata.taskProjections, 'readModel.metadataCatalog.taskProjections').forEach((item, index) => + validateTaskProjection(item, `readModel.metadataCatalog.taskProjections[${index}]`)); + const byProject = objectOf(metadata.taskProjectionsByProject, 'readModel.metadataCatalog.taskProjectionsByProject'); + for (const [projectId, projections] of Object.entries(byProject)) { + arrayOf(projections, `readModel.metadataCatalog.taskProjectionsByProject.${projectId}`).forEach((item, index) => + validateTaskProjection(item, `readModel.metadataCatalog.taskProjectionsByProject.${projectId}[${index}]`)); + } + arrayOf(metadata.issues, 'readModel.metadataCatalog.issues').forEach((item, index) => validateMetadataIssue(item, `readModel.metadataCatalog.issues[${index}]`)); + const counts = objectOf(metadata.issueCounts, 'readModel.metadataCatalog.issueCounts'); + for (const code of METADATA_ISSUE_CODES) nonNegativeInteger(counts[code], `readModel.metadataCatalog.issueCounts.${code}`); + if (Object.keys(counts).length !== METADATA_ISSUE_CODES.length) invalid('readModel.metadataCatalog.issueCounts bevat onbekende codes'); + const sourceData = objectOf(metadata.sourceData, 'readModel.metadataCatalog.sourceData'); + const sourceKeys = ['ACTVTYPE', 'ACTVCODE', 'TASKACTV', 'UDFTYPE', 'UDFVALUE', 'MEMOTYPE', 'TASKNOTE', 'TASKMEMO', 'TASK_NOTES', 'deferredUdfValues', 'unknownUdfTypes'] as const; + exactKeys(sourceData, sourceKeys, 'readModel.metadataCatalog.sourceData'); + for (const key of sourceKeys) arrayOf(sourceData[key], `readModel.metadataCatalog.sourceData.${key}`).forEach((row, index) => validateSourceRow(row, `readModel.metadataCatalog.sourceData.${key}[${index}]`)); + + const taskRows = objectOf(model.taskSourceRowsByProject, 'readModel.taskSourceRowsByProject'); + for (const [projectId, rows] of Object.entries(taskRows)) { + arrayOf(rows, `readModel.taskSourceRowsByProject.${projectId}`).forEach((row, index) => validateSourceRow(row, `readModel.taskSourceRowsByProject.${projectId}[${index}]`)); + } +} + +function validateArchiveMetadataRelations( + diagnostics: XerArchiveDiagnosticsV1, + readModel: XerArchiveReadModelV1, +): void { + const sameValue = (left: unknown, right: unknown): boolean => { + if (Object.is(left, right)) return true; + if (Array.isArray(left) || Array.isArray(right)) { + return Array.isArray(left) && Array.isArray(right) + && left.length === right.length + && left.every((item, index) => sameValue(item, right[index])); + } + if (!left || !right || typeof left !== 'object' || typeof right !== 'object') return false; + const leftObject = left as Record; + const rightObject = right as Record; + const leftKeys = Object.keys(leftObject).sort(); + const rightKeys = Object.keys(rightObject).sort(); + return leftKeys.length === rightKeys.length + && leftKeys.every((key, index) => key === rightKeys[index] + && sameValue(leftObject[key], rightObject[key])); + }; + const sameIntegerSequence = (actual: readonly number[], expected: readonly number[], path: string) => { + if (actual.length !== expected.length || actual.some((value, index) => value !== expected[index])) { + invalid(`${path} is geen volledige canonieke indexreeks`); + } + }; + + // TASK is de gezaghebbende lokale identiteit voor alle latere projecties. Baselineprojecten + // mogen hier bewust bestaan zonder documentview; een lege legacyselector blijft eveneens een + // geldige, verliesvrije groep. Alleen aanwezige rijen moeten selectorvast, uniek en geordend zijn. + const taskIdsByProject = new Map>(); + for (const [projectId, rows] of Object.entries(readModel.taskSourceRowsByProject)) { + const taskIds = new Set(); + let previousLine = -1; + for (const [index, row] of rows.entries()) { + const rowProjectId = row.cells.proj_id?.trim() ?? ''; + const taskId = row.cells.task_id?.trim() ?? ''; + if (rowProjectId !== projectId) invalid(`readModel.taskSourceRowsByProject.${projectId}[${index}] kruist de projectselector`); + if (!taskId || taskIds.has(taskId)) invalid(`readModel.taskSourceRowsByProject.${projectId} bevat een lege of dubbele taakidentiteit`); + if (row.line <= previousLine) invalid(`readModel.taskSourceRowsByProject.${projectId} staat niet in bronvolgorde`); + taskIds.add(taskId); + previousLine = row.line; + } + taskIdsByProject.set(projectId, taskIds); + } + + // X8 bewaart één canonieke, op projectId+taskId gesorteerde lijst. De byProject-index is geen + // tweede waarheid: hij moet die lijst volledig opdelen én elke projectie moet naar TASK wijzen. + const expectedProjections = new Map(); + const projectionIdentities = new Set(); + let previousProjection: { projectId: string; taskId: string } | undefined; + for (const projection of readModel.metadataCatalog.taskProjections) { + const identity = `${projection.projectId}\u0000${projection.taskId}`; + if (projectionIdentities.has(identity)) invalid(`readModel.metadataCatalog.taskProjections bevat dubbele identiteit ${projection.projectId}/${projection.taskId}`); + const projectionTaskIds = taskIdsByProject.get(projection.projectId); + if (projectionTaskIds && !projectionTaskIds.has(projection.taskId)) { + invalid(`readModel.metadataCatalog.taskProjections wijst naar ontbrekende TASK-identiteit ${projection.projectId}/${projection.taskId}`); + } + projectionIdentities.add(identity); + if (previousProjection && (previousProjection.projectId > projection.projectId + || (previousProjection.projectId === projection.projectId && previousProjection.taskId > projection.taskId))) { + invalid('readModel.metadataCatalog.taskProjections staat niet in canonieke project-/taakvolgorde'); + } + previousProjection = projection; + const group = expectedProjections.get(projection.projectId) ?? []; + group.push(projection); + expectedProjections.set(projection.projectId, group); + } + const actualProjectionKeys = Object.keys(readModel.metadataCatalog.taskProjectionsByProject); + if (actualProjectionKeys.length !== expectedProjections.size + || actualProjectionKeys.some(projectId => !expectedProjections.has(projectId))) { + invalid('readModel.metadataCatalog.taskProjectionsByProject partitioneert de canonieke projecties niet volledig'); + } + for (const [projectId, expected] of expectedProjections) { + const actual = readModel.metadataCatalog.taskProjectionsByProject[projectId]; + if (!actual || actual.length !== expected.length + || actual.some((projection, index) => projection.projectId !== projectId + || !sameValue(projection, expected[index]))) { + invalid(`readModel.metadataCatalog.taskProjectionsByProject.${projectId} is geen canonieke projectpartitie`); + } + } + + const sourceArchive = readModel.scheduleOptionsSourceArchive; + const rowCount = sourceArchive.rows.length; + const assertIndex = (index: number, path: string) => { + if (index >= rowCount) invalid(`${path} wijst buiten de X5-filecache`); + }; + const projectIndexes = new Map(); + const scheduleIndexes = new Map(); + const sourceIndexesByProject = new Map(); + let scheduleTableStarted = false; + let previousProjectLine = -1; + let previousScheduleLine = -1; + sourceArchive.rows.forEach((row, index) => { + const projectId = row.cells.proj_id?.trim() ?? ''; + if (row.table === 'SCHEDOPTIONS') { + scheduleTableStarted = true; + if (row.line <= previousScheduleLine) invalid('readModel.scheduleOptionsSourceArchive.SCHEDOPTIONS staat niet in oorspronkelijke bronvolgorde'); + previousScheduleLine = row.line; + } else { + if (scheduleTableStarted) invalid('readModel.scheduleOptionsSourceArchive.rows doorbreekt PROJECT-voor-SCHEDOPTIONS-volgorde'); + if (row.line <= previousProjectLine) invalid('readModel.scheduleOptionsSourceArchive.PROJECT staat niet in oorspronkelijke bronvolgorde'); + previousProjectLine = row.line; + } + const target = row.table === 'PROJECT' ? projectIndexes : scheduleIndexes; + const indexes = target.get(projectId) ?? []; + indexes.push(index); + target.set(projectId, indexes); + const sourceIndexes = sourceIndexesByProject.get(projectId) ?? []; + sourceIndexes.push(index); + sourceIndexesByProject.set(projectId, sourceIndexes); + }); + const expectedUnmatched = [...scheduleIndexes] + .filter(([projectId]) => !projectIndexes.has(projectId)) + .flatMap(([, indexes]) => indexes); + sameIntegerSequence(sourceArchive.unmatchedScheduleOptionsRowIndexes, expectedUnmatched, + 'readModel.scheduleOptionsSourceArchive.unmatchedScheduleOptionsRowIndexes'); + const expectedScheduleDiagnostics = [...scheduleIndexes] + .filter(([, indexes]) => indexes.length > 1) + .map(([projectId, rowIndexes]) => ({ + code: 'XER_DUPLICATE_SCHEDOPTIONS_PROJ_ID' as const, + projectId, + rowIndexes, + lines: rowIndexes.map(index => sourceArchive.rows[index]!.line), + })); + if (!sameValue(sourceArchive.diagnostics, expectedScheduleDiagnostics)) { + invalid('readModel.scheduleOptionsSourceArchive.diagnostics is geen canonieke duplicaatdiagnostiek'); + } + if (!sameValue(diagnostics.file.scheduleOptions, sourceArchive.diagnostics)) { + invalid('diagnostics.file.scheduleOptions wijkt af van de ene X5-filecache'); + } + sourceArchive.diagnostics.forEach((diagnostic, diagnosticIndex) => + diagnostic.rowIndexes.forEach((index, position) => assertIndex(index, + `readModel.scheduleOptionsSourceArchive.diagnostics[${diagnosticIndex}].rowIndexes[${position}]`))); + + const canonicalAssignmentRows = readModel.resourceCatalog.rows.assignments; + let previousAssignmentLine = -1; + for (const [index, row] of canonicalAssignmentRows.entries()) { + if (row.line <= previousAssignmentLine) { + invalid(`readModel.resourceCatalog.rows.assignments[${index}] staat niet in oorspronkelijke TASKRSRC-bronvolgorde`); + } + previousAssignmentLine = row.line; + } + const resourceSourceIds = new Set(readModel.resourceCatalog.rows.resources.map(source => source.sourceId)); + const roleSourceIds = new Set(readModel.resourceCatalog.rows.roles.map(source => source.sourceId)); + const expectedAssignmentSkip = ( + row: XerArchiveReadModelV1['resourceCatalog']['rows']['assignments'][number], + projectId: string, + ): XerAssignmentSkipExpectation => deriveXerAssignmentSkipExpectation( + row.cells, + resourceSourceIds, + roleSourceIds, + taskIdsByProject.get(projectId), + ); + + type ExternalLink = XerArchiveDocumentViewV1['externalLinks'][number]; + const linkKey = (link: ExternalLink): string => [ + link.id, link.predecessor.projectId, link.predecessor.taskId, + link.successor.projectId, link.successor.taskId, link.type, link.lagMinutes, + ].join('\u0000'); + const sourceLinks = new Map(); + const assertOpenEndpointTask = ( + endpointProjectId: string, + endpointTaskId: string, + path: string, + endpointKind: 'lokaal' | 'extern', + ): void => { + if (!diagnostics.documentViews[endpointProjectId]) return; + const endpointTaskIds = taskIdsByProject.get(endpointProjectId); + // Een vroege schema-1-documentview kan de volledige TASK-groep missen. Alleen dat werkelijk + // afwezige bronsignaal is legacy-onbeslisbaar; een aanwezige lege of gemuteerde groep is hard. + if (!endpointTaskIds) return; + if (!endpointTaskIds.has(endpointTaskId)) { + invalid(`${path} wijst naar ontbrekend ${endpointKind} TASK-eindpunt ${endpointProjectId}/${endpointTaskId}`); + } + }; + for (const [projectId, view] of Object.entries(diagnostics.documentViews)) { + // Vroege schema-1-containerfixtures konden al een documentview bewaren terwijl de later + // toegevoegde TASK-bronindex voor dat project volledig ontbrak. Zo'n legacyview blijft + // leesbaar; zodra de selector wél een canonieke TASK-groep heeft is de binding hard. + if (!taskIdsByProject.has(projectId)) continue; + for (const [index, relation] of view.externalRelations.entries()) { + if (relation.localProjectId !== projectId) { + invalid(`diagnostics.documentViews.${projectId}.externalRelations[${index}] kruist de projectselector`); + } + if (!taskIdsByProject.get(projectId)?.has(relation.localTaskId)) { + invalid(`diagnostics.documentViews.${projectId}.externalRelations[${index}] wijst naar ontbrekend lokaal TASK-eindpunt`); + } + assertOpenEndpointTask( + relation.externalProjectId, + relation.externalTaskId, + `diagnostics.documentViews.${projectId}.externalRelations[${index}]`, + 'extern', + ); + const link: ExternalLink = relation.direction === 'predecessor' ? { + id: relation.id, + predecessor: { projectId: relation.externalProjectId, taskId: relation.externalTaskId }, + successor: { projectId: relation.localProjectId, taskId: relation.localTaskId }, + type: relation.type, + lagMinutes: relation.lagMinutes, + } : { + id: relation.id, + predecessor: { projectId: relation.localProjectId, taskId: relation.localTaskId }, + successor: { projectId: relation.externalProjectId, taskId: relation.externalTaskId }, + type: relation.type, + lagMinutes: relation.lagMinutes, + }; + const key = linkKey(link); + const known = sourceLinks.get(key); + if (known && !sameValue(known, link)) invalid(`diagnostics.documentViews.externalRelations bevat botsende bronrelatie ${relation.id}`); + sourceLinks.set(key, link); + } + } + + const linksByKey = new Map }>(); + for (const [projectId, view] of Object.entries(diagnostics.documentViews)) { + view.scheduleOptions.sourceRowIndexes.forEach((index, position) => assertIndex(index, + `diagnostics.documentViews.${projectId}.scheduleOptions.sourceRowIndexes[${position}]`)); + const expectedSourceIndexes = sourceIndexesByProject.get(projectId) ?? []; + sameIntegerSequence(view.scheduleOptions.sourceRowIndexes, expectedSourceIndexes, + `diagnostics.documentViews.${projectId}.scheduleOptions.sourceRowIndexes`); + if (rowCount > 0 && (projectIndexes.get(projectId)?.length ?? 0) !== 1) { + invalid(`diagnostics.documentViews.${projectId} heeft niet exact één PROJECT-bronrij`); + } + const projectScheduleIndexes = scheduleIndexes.get(projectId) ?? []; + const expectedSource = projectScheduleIndexes.length === 1 ? 'schedoptions' : 'xer-defaults'; + if (view.scheduleOptions.source !== expectedSource) { + invalid(`diagnostics.documentViews.${projectId}.scheduleOptions.source past niet bij de X5-bronrijen`); + } + const expectedDiagnostics = sourceArchive.diagnostics.filter(item => item.projectId === projectId); + if (!sameValue(view.scheduleOptions.diagnostics, expectedDiagnostics)) { + invalid(`diagnostics.documentViews.${projectId}.scheduleOptions.diagnostics kruist de projectselector`); + } + + const expectedAssignmentRows = canonicalAssignmentRows.filter(row => + (row.cells.proj_id?.trim() ?? '') === projectId); + const assignments = view.resources?.assignments ?? []; + if (assignments.length !== expectedAssignmentRows.length) { + invalid(`diagnostics.documentViews.${projectId}.resources.assignments is geen volledige projectpartitie`); + } + const assignmentIds = new Set(); + assignments.forEach((assignment, index) => { + const rawProjectId = assignment.rawRow.cells.proj_id?.trim() ?? ''; + if (assignment.projectSourceId !== projectId || rawProjectId !== projectId) { + invalid(`diagnostics.documentViews.${projectId}.resources.assignments[${index}] kruist de projectselector`); + } + if (assignmentIds.has(assignment.sourceId)) { + invalid(`diagnostics.documentViews.${projectId}.resources.assignments bevat dubbele identiteit ${assignment.sourceId}`); + } + assignmentIds.add(assignment.sourceId); + if (!sameValue(assignment.rawRow, expectedAssignmentRows[index])) { + invalid(`diagnostics.documentViews.${projectId}.resources.assignments[${index}] staat niet in canonieke bronvolgorde`); + } + const rawRow = expectedAssignmentRows[index]!; + const sourceId = xerAssignmentSourceId(rawRow.cells, rawRow.line); + const taskSourceId = rawRow.cells.task_id?.trim() ?? ''; + const resourceSourceId = rawRow.cells.rsrc_id?.trim() || undefined; + const roleSourceId = rawRow.cells.role_id?.trim() || undefined; + const entityKind = resourceSourceId ? 'RESOURCE' : 'ROLE'; + const entitySourceId = resourceSourceId ?? roleSourceId ?? ''; + const entityInternalId = `${entityKind === 'RESOURCE' ? 'xer-resource' : 'xer-role'}:${entitySourceId}`; + if (assignment.sourceId !== sourceId + || assignment.internalId !== `xer-assignment:${sourceId}` + || assignment.line !== rawRow.line + || assignment.taskSourceId !== taskSourceId + || assignment.entity.kind !== entityKind + || assignment.entity.sourceId !== entitySourceId + || assignment.entity.internalId !== entityInternalId) { + invalid(`diagnostics.documentViews.${projectId}.resources.assignments[${index}] wijkt af van de canonieke TASKRSRC-identiteit`); + } + for (const [field, rawField] of [ + ['curveSourceId', 'curv_id'], ['rateType', 'rate_type'], + ['costSourceType', 'cost_per_qty_source_type'], ['rawResourceType', 'rsrc_type'], + ] as const) { + const expected = rawRow.cells[rawField]?.trim() || undefined; + if (assignment[field] !== expected) { + invalid(`diagnostics.documentViews.${projectId}.resources.assignments[${index}].${field} wijkt af van TASKRSRC.${rawField}`); + } + } + }); + const skipCodes = new Set([ + 'XER_ASSIGNMENT_RESOURCE_MISSING', + 'XER_ASSIGNMENT_ROLE_MISSING', + 'XER_ASSIGNMENT_TASK_MISSING', + ]); + const actualSkipIssues = (view.resources?.issues ?? []).filter( + (issue): issue is XerResourceIssue & { code: XerAssignmentSkipReason } => + skipCodes.has(issue.code as XerAssignmentSkipReason), + ); + const expectedByIdentity = new Map(); + for (const row of expectedAssignmentRows) { + const sourceId = xerAssignmentSourceId(row.cells, row.line); + expectedByIdentity.set(`${sourceId}\u0000${row.line}`, expectedAssignmentSkip(row, projectId)); + } + const seenSkipIssueIdentities = new Set(); + for (const issue of actualSkipIssues) { + const identity = `${issue.sourceId}\u0000${issue.line}`; + const expected = expectedByIdentity.get(identity); + const exactEnvelope = issue.table === 'TASKRSRC' && issue.fallback === 'SKIPPED'; + const exactCode = issue.code === expected; + const optionalLegacyTaskIssue = expected === 'LEGACY_TASK_UNKNOWN' + && issue.code === 'XER_ASSIGNMENT_TASK_MISSING'; + if (!exactEnvelope || (!exactCode && !optionalLegacyTaskIssue) + || seenSkipIssueIdentities.has(identity)) { + invalid(`diagnostics.documentViews.${projectId}.resources.issues is geen exacte TASKRSRC-skipdiagnostiek`); + } + seenSkipIssueIdentities.add(identity); + } + for (const [identity, expected] of expectedByIdentity) { + if (expected && expected !== 'LEGACY_TASK_UNKNOWN' && !seenSkipIssueIdentities.has(identity)) { + invalid(`diagnostics.documentViews.${projectId}.resources.issues is geen exacte TASKRSRC-skipdiagnostiek`); + } + } + + const linkKeys = new Set(); + for (const [index, link] of view.externalLinks.entries()) { + const key = linkKey(link); + if (linkKeys.has(key)) invalid(`diagnostics.documentViews.${projectId}.externalLinks bevat dubbele relatie ${link.id}`); + linkKeys.add(key); + if (link.predecessor.projectId !== projectId && link.successor.projectId !== projectId) { + invalid(`diagnostics.documentViews.${projectId}.externalLinks[${index}] heeft geen lokaal eindpunt`); + } + assertOpenEndpointTask( + link.predecessor.projectId, + link.predecessor.taskId, + `diagnostics.documentViews.${projectId}.externalLinks[${index}].predecessor`, + link.predecessor.projectId === projectId ? 'lokaal' : 'extern', + ); + assertOpenEndpointTask( + link.successor.projectId, + link.successor.taskId, + `diagnostics.documentViews.${projectId}.externalLinks[${index}].successor`, + link.successor.projectId === projectId ? 'lokaal' : 'extern', + ); + if (taskIdsByProject.has(projectId) && !sourceLinks.has(key)) { + invalid(`diagnostics.documentViews.${projectId}.externalLinks[${index}] is niet afgeleid uit een bewaarde TASKPRED-bronrelatie`); + } + const known = linksByKey.get(key); + if (known && !sameValue(known.link, link)) invalid(`diagnostics.documentViews.externalLinks bevat botsende relatie ${link.id}`); + if (known) known.owners.add(projectId); + else linksByKey.set(key, { link, owners: new Set([projectId]) }); + } + } + for (const [key, { link, owners }] of linksByKey) { + for (const endpoint of [link.predecessor.projectId, link.successor.projectId]) { + if (diagnostics.documentViews[endpoint] && !owners.has(endpoint)) { + invalid(`diagnostics.documentViews.externalLinks.${link.id} ontbreekt in endpointproject ${endpoint}`); + } + } + const hasCanonicalOwner = [...owners].some(projectId => taskIdsByProject.has(projectId)); + if (hasCanonicalOwner && !sourceLinks.has(key)) { + invalid(`diagnostics.documentViews.externalLinks.${link.id} mist zijn TASKPRED-bronrelatie`); + } + } + for (const [key, link] of sourceLinks) { + const predecessorIsOpen = diagnostics.documentViews[link.predecessor.projectId] + && taskIdsByProject.get(link.predecessor.projectId)?.has(link.predecessor.taskId); + const successorIsOpen = diagnostics.documentViews[link.successor.projectId] + && taskIdsByProject.get(link.successor.projectId)?.has(link.successor.taskId); + if (!predecessorIsOpen || !successorIsOpen) continue; + const derived = linksByKey.get(key); + if (!derived) invalid(`diagnostics.documentViews.externalLinks mist afleiding voor bronrelatie ${link.id}`); + for (const endpoint of [link.predecessor.projectId, link.successor.projectId]) { + if (!derived.owners.has(endpoint)) { + invalid(`diagnostics.documentViews.externalLinks.${link.id} ontbreekt in bronendpoint ${endpoint}`); + } + } + } +} + +function deepFreeze(value: T): T { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + for (const child of Object.values(value as Record)) deepFreeze(child); + Object.freeze(value); + } + return value; +} diff --git a/src/state/documentActivation.ts b/src/state/documentActivation.ts index 61411d6d4..6e3e24633 100644 --- a/src/state/documentActivation.ts +++ b/src/state/documentActivation.ts @@ -1,8 +1,16 @@ import { computeReliableResourceLoad, type ResourceLoadResult } from '@/engine/scheduler/ResourceLoad'; import { cloneTasksForSolve, solveProject } from '@/engine/scheduler/solveProject'; +import { + applyRecordedTimesToTasks, + captureRecordedDates, + countShiftedTasks, + type RecordedTime, +} from '@/engine/scheduler/recordedDates'; import { computeViewRows, type ViewContext, type ViewRow, type ViewRowOpts } from '@/engine/view/visibleRows'; import { getNoneLabelValue } from '@/utils/noneLabel'; import type { Company, CompanyPool } from '@/types/library'; +import type { ImportResult } from '@/services/importTypes'; +import type { Task } from '@/types/task'; import { applyCalendarUpdate, applyResourceUpdate, @@ -200,3 +208,113 @@ export function prepareLoadedPayload( payload.scheduleStale = false; return payload; } + +/** + * "Datums zoals opgeslagen" (issue #63) — het laadpadgedrag, gedeeld door het verse open-pad + * (`applyLoadedProject`, `fileSlice.ts`) en crashherstel (`restoreDocuments`, `documentSlice.ts`). + * Eén functie met één expliciete beslisparameter in plaats van twee bijna-identieke varianten + * (critreview laag 3, bevinding 12). + * + * `rawTasks` MOET de taken van VÓÓR de solve zijn (bv. de `.tasks` van de payload die aan + * `prepareLoadedPayload` werd gegeven — NIET `prepared.tasks`): `prepareLoadedPayload` kloont de + * taken pas vlak vóór de solve (`cloneTasksForSolve`, ondiep met een verse `time`-kopie per taak), + * dus de ORIGINELE taakobjecten blijven de rauwe, ongesolvede leeswaarden dragen terwijl + * `prepared.tasks` de zojuist berekende uitkomst draagt — precies het contrast dat + * `captureRecordedDates`/`countShiftedTasks` nodig hebben. `prepareLoadedPayload` zelf muteert zijn + * `input`-argument niet (het bouwt intern een eigen kopie via `activationPayload`), dus de + * aanroeper kan `payload.tasks` gerust bewaren en er ná `prepareLoadedPayload(payload, ...)` nog + * steeds naar verwijzen. + * + * WIE ZET DE MODUS AAN — precies twee gevallen, en geen enkele heuristiek: + * + * 1. `restoredMode` is meegegeven (crashherstel): dán telt uitsluitend die vlag. Hij komt uit de + * recovery-metadata (`RecoveryManifestDoc.datesAsRecorded`), dus uit een OPGESCHREVEN feit. + * Crashherstel is het hervatten van een onderbroken sessie, geen heropening: aan blijft aan, + * uit blijft uit, en er wordt niet opnieuw beslist. Eerder stond hier een terugleesheuristiek + * ("0 verschoven op de rauwe taken ⇒ de modus stond aan"); die was aantoonbaar vals-positief — + * een bewerking verlaat de modus en zet `scheduleStale`, maar de herberekening staat pas op + * `setTimeout(0)`, dus een auto-save in dat gat schreef P6's datums weg zónder modus + * (critreview laag 3, bevinding 2). + * 2. Anders: alleen een VERSE XER-import (`recordedTimesOrigin === 'xer'`) mét restverschillen. + * Al het overige BIEDT de modus alleen aan — `recordedDates` gevuld, `datesAsRecorded` blijft + * `false`: IFC/CSV/MSPDI/MPP/P6XML zonder bron-orakel, én een HEROPENDE IFC met XER-archief + * (`'xer-archive'`), want een intussen bewerkte en opgeslagen planning mag bij heropenen niet + * stilzwijgend P6's oude datums tonen (heropen-beleid, taak T8). + * + * `scheduleStale` gaat in de modus expliciet op `false` — nooit rechtstreeks op `true`, dus de + * invariant in `state/scheduleStale.ts` blijft heel. + * + * GEEN undo-snapshot: dit is een LAADPAD, geen mutator. De aanroeper draait vlak hiervoor/hierna + * `removeSessionHistoryForDocumentFromState` — er is geen geschiedenis waarin een pre-load-toestand + * kan opduiken. De contract-invariant "élke MUTATOR van `datesAsRecorded` pusht een snapshot" + * (`showRecordedDates`/`runCPM`) blijft onverkort gelden; dit is er geen. + * + * Muteert `prepared` in place, net als de rest van dit bestand (`prepareLoadedPayload`, + * `materializeBehindOnlyRefresh`). + */ +export function applyRecordedDatesOnLoad( + rawTasks: Task[], + prepared: DocumentPayload, + parsed: Pick, + restoredMode?: boolean, +): void { + const recorded = captureRecordedDates(rawTasks, parsed.recordedFields, parsed.recordedTimes); + if (recorded.total === 0) return; + const shifted = countShiftedTasks(prepared.tasks, recorded.times); + const enterMode = restoredMode ?? (parsed.recordedTimesOrigin === 'xer' && shifted > 0); + // Niets verschoven én geen modus om te herstellen ⇒ er valt niets te melden: de herberekening + // kwam exact uit op wat het bestand zei. + if (shifted === 0 && !enterMode) return; + prepared.recordedDates = { ...recorded, shifted, origin: parsed.recordedTimesOrigin }; + if (enterMode) enterRecordedDatesMode(prepared, recorded.times); +} + +/** + * Zet één payload ín de modus: P6's/het bestands vastlegging in `task.time`, het `cpmResult` als + * reconstructie (niet als solve), en `scheduleStale` uit. Gedeeld door het laadpad hierboven en de + * SLAPENDE herstelde documenten hieronder, zodat "in de modus" op beide plekken exact hetzelfde + * betekent. + */ +function enterRecordedDatesMode(payload: DocumentPayload, times: Record): void { + payload.cpmResult = applyRecordedTimesToTasks(payload.tasks, times, payload.calendar); + payload.datesAsRecorded = true; + // `prepareLoadedPayload` zette hem bij een geslaagde solve al zo; expliciet houden (plan §5 + // risico 1) — nooit een rechtstreekse `= true` ELDERS, alleen deze bewuste `= false`. + payload.scheduleStale = false; +} + +/** + * Crashherstel van een SLAPEND document dat in "datums zoals opgeslagen" stond + * (critreview laag 3, bevinding 3). + * + * Slapende herstelde documenten worden bewust NIET doorgerekend (`payloadFromInput` zet + * `scheduleStale = true`, `switchDocument` roept nooit `runCPM`). Zonder deze functie kwam zo'n + * document terug met P6's datums ín `task.time`, zónder modus, zónder markering en mét een + * verouderd-waarschuwing: staat **Automatisch berekenen** aan, dan gumt + * `recalculateStaleSleepingDocuments` die datums meteen weg; staat het uit, dan verschuift de + * eerste F5 ze zonder uitleg. + * + * Wat hier wél kan zonder solve: de vastlegging vastleggen (`captureRecordedDates` leest alleen het + * bestand), de modus zetten en het `cpmResult` reconstrueren. Wat hier NIET kan: de teller + * `shifted` — die is per definitie een vergelijking mét een herberekening, en die is er niet. De + * payload krijgt daarom een `recordedDates` ZONDER `shifted`: de strook valt in de modus terug op + * zijn tellerloze tekst (`recordedDates.active` in `RecordedDatesNotice`), terwijl export-poort, + * "niet vastgelegd"-kolommen en badge gewoon werken (die hangen aan `times`). Een verzonnen + * `shifted: 0` zou de gebruiker vertellen dat herberekenen niets verandert — precies de leugen die + * `RecordedDates` (zie `recordedDates.ts`) uit zijn eigen contract weerde. + */ +export function applyRestoredRecordedMode( + payload: DocumentPayload, + parsed: Pick, +): void { + const recorded = captureRecordedDates(payload.tasks, parsed.recordedFields, parsed.recordedTimes); + if (recorded.total === 0) return; + // Her-check laag 3, bevinding 4: de vastlegging WEL zetten, alleen zonder `shifted` (optioneel + // sinds die bevinding). Zonder `recordedDates` hingen de export-poort (`unrecordedExportGate`), + // de "niet vastgelegd"-kolommen (`recordedGridBinding`) en de badge (`recordedTaskMark`) alle + // drie in de lucht: het document stond in de modus, maar `lateStart ?? rec.start` en + // `totalFloat ?? 0` reisden gewoon naar CSV en `planner_get_task`, afhankelijk van welk tabblad + // bij de crash toevallig actief was. Nu betekent "in de modus" op beide herstelpaden hetzelfde. + payload.recordedDates = { ...recorded, origin: parsed.recordedTimesOrigin }; + enterRecordedDatesMode(payload, recorded.times); +} diff --git a/src/state/documentContract.ts b/src/state/documentContract.ts index 7b269dc98..4d4f1ee88 100644 --- a/src/state/documentContract.ts +++ b/src/state/documentContract.ts @@ -10,6 +10,8 @@ import type { RecordedDatesState } from '@/engine/scheduler/recordedDates'; import type { ResourceLoadResult } from '@/engine/scheduler/ResourceLoad'; import type { Baseline } from '@/types/baseline'; import type { ImportResult } from '@/services/importTypes'; +import type { XerImportMetadata } from '@/services/importTypes'; +import type { XerSourceArchive } from '@/services/xerSourceArchive'; import type { ColumnConfig, ViewState } from './slices/types'; import type { AppState } from './appStore'; import { createDefaultProject } from './defaults'; @@ -78,6 +80,14 @@ export interface DocumentPayload { /** Persoonlijke sessiekeuze: echte bestands-AutoSave, geen IFC-data en geen crashherstel. */ autoSaveToFile: boolean; isDirty: boolean; + /** XER-bronmetadata per document; geen onderdeel van undo omdat bewerkingen dit niet muteren. + * X9 archiveert deze selectorgebonden provenance via IFC-diagnostics; X4b bewaart haar al + * door documentwissel en de recovery-inputlaag. */ + xerImportMetadata: XerImportMetadata | null; + /** X9: exacte immutable bronbytes; bewust gedeeld en buiten undo/redo. */ + xerSourceArchive: XerSourceArchive | null; + /** Selector van dit document binnen xerSourceArchive; semantiek is documentgebonden. */ + xerSourceProjectId: string | null; } /** Per-document projectdata + metadata om bij crash-recovery te herstellen. @@ -95,6 +105,8 @@ export type RecoveryDocInput = ImportResult & { id: string; filePath: string | null; isDirty: boolean; + /** Zie `RecoveryDocMeta.datesAsRecorded`. */ + datesAsRecorded: boolean; }; /** Document-identiteit rond een herstelde snapshot: alles wat NIET uit de IFC komt maar uit de @@ -103,6 +115,12 @@ export interface RecoveryDocMeta { id: string; filePath: string | null; isDirty: boolean; + /** + * Stond het document in "datums zoals opgeslagen" toen de snapshot geschreven werd? Komt uit + * de recovery-metadata (`RecoveryManifestDoc.datesAsRecorded`), niet uit de IFC: de weergave- + * stand is geen projectdata. Oudere manifesten kennen het veld niet en leveren `false`. + */ + datesAsRecorded: boolean; } /** @@ -229,6 +247,9 @@ export const DOCUMENT_FIELDS = [ // Runtime-only: rijdt mee tussen open tabbladen, maar nooit door IFC of recoverymetadata. field({ key: 'autoSaveToFile', get: (s) => s.autoSaveToFile, set: (s, v) => { s.autoSaveToFile = v; }, fresh: () => false, snapshot: 'none', fromPayload: (p) => p.autoSaveToFile ?? false }), field({ key: 'isDirty', get: (s) => s.isDirty, set: (s, v) => { s.isDirty = v; }, fresh: () => false, snapshot: 'none' }), + field({ key: 'xerImportMetadata', get: (s) => s.xerImportMetadata, set: (s, v) => { s.xerImportMetadata = v; }, fresh: () => null, snapshot: 'none', fromPayload: (p) => p.xerImportMetadata ?? null }), + field({ key: 'xerSourceArchive', get: (s) => s.xerSourceArchive, set: (s, v) => { s.xerSourceArchive = v; }, fresh: () => null, snapshot: 'none', fromPayload: (p) => p.xerSourceArchive ?? null }), + field({ key: 'xerSourceProjectId', get: (s) => s.xerSourceProjectId, set: (s, v) => { s.xerSourceProjectId = v; }, fresh: () => null, snapshot: 'none', fromPayload: (p) => p.xerSourceProjectId ?? null }), ]; // Compile-time volledigheidscheck: elke DocumentPayload-key MOET in DOCUMENT_FIELDS staan. Voeg je @@ -381,6 +402,14 @@ export function payloadFromInput(d: RecoveryDocInput): DocumentPayload { return { ...payloadFromImport(d, d.filePath), isDirty: d.isDirty, scheduleStale: true }; } +/** + * Verschil 3 met de import-kant, en de reden dat `payloadFromInput` de modusvlag NIET zelf zet: + * `datesAsRecorded: true` met `scheduleStale: true` is een verboden combinatie (de invariant uit + * `state/scheduleStale.ts`, bewaakt door check-recorded-dates 11d–11g). De modus komt er daarom + * pas ná de payloadbouw op, samen met het bijbehorende `cpmResult` en `scheduleStale = false` — + * zie `applyRecordedDatesOnLoad`/`applyRestoredRecordedMode` in `documentActivation.ts`. + */ + /** Verse payload uit een ingelezen project (IFC/CSV/MSPDI/P6). Alleen de IFC-round-trip-velden * worden overgenomen; selectie/cpm/history/scheduleStale starten vers. `view`/`collapsedTaskIds` * vult de aanroeper (`applyLoadedProject` behoudt die van het huidige document — load-semantiek). */ @@ -399,6 +428,9 @@ export function payloadFromImport(parsed: ImportResult, filePath: string | null) customTaskTypes: parsed.customTaskTypes ?? [], baselines: parsed.baselines ?? [], activeBaselineId: parsed.activeBaselineId ?? null, + xerImportMetadata: parsed.xer ?? null, + xerSourceArchive: parsed.xerSourceArchive ?? null, + xerSourceProjectId: parsed.xer?.sourceProjectId ?? parsed.xerSourceProjectId ?? null, filePath, isDirty: false, }; diff --git a/src/state/gridTransaction.ts b/src/state/gridTransaction.ts index 8fb6c4200..2fb777678 100644 --- a/src/state/gridTransaction.ts +++ b/src/state/gridTransaction.ts @@ -14,6 +14,7 @@ import { } from '@/engine/taskGrid/assignmentPlan'; import { isHourCalendar } from '@/services/subdayIo'; import { effectiveCalendarOf, effHoursPerDay } from '@/utils/taskDuration'; +import { recordedGridBinding } from './recordedDatesSelectors'; import { createSnapshot, restoreSnapshot, type Snapshot } from './snapshot'; import { recordDocumentDataHistoryDelta } from './sessionHistory'; import { notifyTimephasedLoss } from './timephasedLossNotice'; @@ -113,6 +114,8 @@ interface GridColumnRuntime { } function buildGridColumnRuntime(state: Readonly): GridColumnRuntime { + const recordedDates = state.recordedDates; + const datesAsRecorded = state.datesAsRecorded; const assignmentsByTaskId = new Map(); for (const assignment of state.assignments) { const values = assignmentsByTaskId.get(assignment.taskId); @@ -131,6 +134,12 @@ function buildGridColumnRuntime(state: Readonly): GridColumnRuntime { effectiveHoursPerDay: task => effHoursPerDay(effectiveCalendarOf( task, state.calendar, state.calendars, )), + // Alleen `recordedMark` — die stuurt de `available()`-gate van de kolom `recorded.source` + // (taskColumnRegistry.ts). Zonder deze naad zou een paste die toevallig over die kolom heen + // strijkt hard falen (`plannerNotAvailable`) in plaats van de bestaande skip-readonly-route + // te nemen, op elk document waar de kolom via FullTaskGrid wél zichtbaar is. `format`/ + // `recordedUnrecordedAxes` raken alleen weergave, niet het schrijfpad — die blijven hier weg. + recordedMark: recordedGridBinding(recordedDates, datesAsRecorded).recordedMark, }; const descriptors = buildTaskColumnRegistry({ projectId: state.project.id, diff --git a/src/state/ifcSaveInput.ts b/src/state/ifcSaveInput.ts index 86ba8cd08..c21b85a76 100644 --- a/src/state/ifcSaveInput.ts +++ b/src/state/ifcSaveInput.ts @@ -21,7 +21,7 @@ export type IFCSaveSource = Pick< | 'calendars' | 'baselines' | 'activeBaselineId' ->; +> & Partial>; /** * Bouw de VOLLEDIGE `writeIFC`-invoer uit de state/payload. Eén plek bepaalt welke velden @@ -44,6 +44,9 @@ export function buildWriteIFCInput(src: IFCSaveSource): WriteIFCInput { resourceCalendars: src.calendars, baselines: src.baselines, activeBaselineId: src.activeBaselineId, + xer: src.xerImportMetadata ?? undefined, + xerSourceArchive: src.xerSourceArchive ?? undefined, + xerSourceProjectId: src.xerSourceProjectId ?? undefined, }; } @@ -52,6 +55,7 @@ export function buildWriteIFCInput(src: IFCSaveSource): WriteIFCInput { const IFC_SAVE_KEYS = [ 'project', 'calendar', 'tasks', 'sequences', 'resources', 'assignments', 'activityCodeTypes', 'customFieldDefs', 'customTaskTypes', 'calendars', 'baselines', 'activeBaselineId', + 'xerImportMetadata', 'xerSourceArchive', 'xerSourceProjectId', ] as const; type MissingSaveKey = Exclude; diff --git a/src/state/mcpValidation.ts b/src/state/mcpValidation.ts index 2c5eb1bcc..60ca5324d 100644 --- a/src/state/mcpValidation.ts +++ b/src/state/mcpValidation.ts @@ -22,6 +22,7 @@ import type { AppState } from './appStore'; import type { Task } from '@/types/task'; import { applyProgressInvariants } from './slices/taskSlice'; import { detectCycleInEdges } from '@/engine/scheduler/graphWalk'; +import { isSummaryTask } from '@/utils/taskHierarchy'; /** Per-item-fout: het aangesproken id + een leesbare reden (voor de per-item-rapportage van de * tool-laag). */ @@ -103,7 +104,7 @@ export const validate = { const task = state.tasks.find((t) => t.id === taskId); if (!task) return { ok: false, reason: `taak '${taskId}' bestaat niet` }; if (task.isMilestone) return { ok: false, reason: `taak '${taskId}' is een mijlpaal; een mijlpaal draagt geen resources` }; - if (task.childIds.length > 0) return { ok: false, reason: `taak '${taskId}' is een verzameltaak (summary); wijs resources toe aan de bladtaken` }; + if (isSummaryTask(task)) return { ok: false, reason: `taak '${taskId}' is een verzameltaak (summary); wijs resources toe aan de bladtaken` }; if (!isValidUnits(units)) return { ok: false, reason: `ongeldige eenheden/dag ${String(units)} (strikt positief vereist)` }; if (state.assignments.some((a) => a.taskId === taskId && a.resourceId === resourceId)) { return { ok: false, reason: `resource '${resourceId}' is al toegewezen aan taak '${taskId}' (een tweede toewijzing zou de last dubbel tellen)` }; @@ -223,7 +224,7 @@ export const progress = { } // (9) voortgang op een verzameltaak (heeft kinderen) ⇒ weigering. - if (task.childIds.length > 0) { + if (isSummaryTask(task)) { return { applied: false, reason: `taak '${taskId}' is een verzameltaak (heeft kinderen); voortgang wordt afgeleid uit de kinderen, niet direct gezet` }; } diff --git a/src/state/recordedDatesSelectors.ts b/src/state/recordedDatesSelectors.ts new file mode 100644 index 000000000..c44d82a3e --- /dev/null +++ b/src/state/recordedDatesSelectors.ts @@ -0,0 +1,164 @@ +import type { RecordedDatesState, RecordedTime } from '@/engine/scheduler/recordedDates'; +import type { Task } from '@/types/task'; +import type { RecordedTaskAxis, RecordedTaskMark } from '@/types/taskGrid'; + +/** + * "Datums zoals opgeslagen" (issue #63, XER-etappeplan laag 3 §3.6) — de PURE afgeleide over + * bestaande documentstate (`recordedDates`/`datesAsRecorded`), gedeeld door de taaktabel-kolom + * (`taskColumnRegistry.ts`, via `TaskColumnContext.recordedMark`/`recordedUnrecordedAxes`) en het + * eigenschappenpaneel (`TaskRecordedDatesNotice.tsx`). Bladmodule: importeert alleen types, geen + * nieuw `Task`-veld en geen `DOCUMENT_FIELDS`-wijziging — de informatie zit al in de state. + * + * `RecordedTaskMark`/`RecordedTaskAxis` staan in `@/types/taskGrid.ts` (niet hier) omdat + * `TaskColumnContext` ze nodig heeft en `types/` bewust geen afhankelijkheid richting `state/` + * krijgt (elders in de codebase importeert niets uit `src/types/*.ts` van `@/state/*`) — dit + * bestand hergebruikt ze als type-only import, dit bestand is de eigenaar van het GEDRAG, niet + * van de typenaam. + */ +export type { RecordedTaskAxis, RecordedTaskMark }; + +/** + * Welke van de vier OPTIONELE assen (laat-start/-einde, totale/vrije speling) het bestand niet + * vastlegde voor deze taak. Start/einde staan hier bewust niet in: `RecordedTime.start`/`finish` + * zijn verplicht (zie `recordedDates.ts`) — een taak zonder vroege datums landt nooit in + * `RecordedDatesState.times`, dus deze functie krijgt zo'n taak nooit te zien. + * + * `undefined` als invoer (geen vastlegging voor deze taak) levert een lege lijst — hetzelfde als + * "alle vier aanwezig" voor een aanroeper die alleen let op `.includes(...)`, maar de aanroepers + * hieronder en in de kolomregistratie filteren altijd eerst op het bestaan van de vastlegging zelf + * (`recordedMark`/de kolom se `available`-gate), dus dat onderscheid komt daar nooit verkeerd uit. + */ +export function unrecordedAxes(rec: RecordedTime | undefined): readonly RecordedTaskAxis[] { + if (!rec) return []; + const axes: RecordedTaskAxis[] = []; + if (rec.lateStart === undefined) axes.push('ls'); + if (rec.lateFinish === undefined) axes.push('lf'); + if (rec.totalFloat === undefined) axes.push('tf'); + if (rec.freeFloat === undefined) axes.push('ff'); + return axes; +} + +/** + * Dezelfde vraag als `unrecordedAxes`, maar voor de UITGANGEN buiten de taaktabel (CSV-export en + * de MCP-leestools) — daarom in `Task['time']`-veldnamen en mét `isCritical`, dat geen tabelkolom + * met een "niet vastgelegd"-tekst heeft maar wél door dezelfde `?? false` naar buiten zou reizen. + * + * Critreview laag 3, bevinding 6: zolang "datums zoals opgeslagen" een KNOP was, was het tonen van + * de terugvallen (`lateStart ?? rec.start`, `totalFloat ?? 0`, `isCritical ?? false`) een bewuste + * gebruikershandeling. Met standaard-aan op elke XER met restverschillen is het de standaard- + * toestand, en dan mag een verzonnen 0 of een verzonnen late datum niet stilzwijgend een + * CSV-export of een AI-antwoord in — daar is geen strook of badge die de stand toelicht. + */ +export type UnrecordedExportField = 'lateStart' | 'lateFinish' | 'totalFloat' | 'freeFloat' | 'isCritical'; + +export function unrecordedExportFields(rec: RecordedTime | undefined): readonly UnrecordedExportField[] { + if (!rec) return []; + const fields: UnrecordedExportField[] = []; + if (rec.lateStart === undefined) fields.push('lateStart'); + if (rec.lateFinish === undefined) fields.push('lateFinish'); + if (rec.totalFloat === undefined) fields.push('totalFloat'); + if (rec.freeFloat === undefined) fields.push('freeFloat'); + if (rec.isCritical === undefined) fields.push('isCritical'); + return fields; +} + +/** + * De poort voor die uitgangen: `undefined` zodra de modus UIT staat (dan staat er onze eigen, + * echte berekening en valt er niets te verzwijgen) of het document geen vastlegging heeft. Zelfde + * regel als `recordedGridBinding`, en bewust dezelfde vorm — één begrip, twee consumenten. + */ +export function unrecordedExportGate( + recorded: RecordedDatesState | null, + datesAsRecorded: boolean, +): ((task: Task) => readonly UnrecordedExportField[]) | undefined { + if (!recorded || !datesAsRecorded) return undefined; + return task => unrecordedExportFields(recorded.times[task.id]); +} + +/** + * Markering voor de taaktabel-kolom `recorded.source` (categorie `computed`). Twee gevallen, met + * voorrang in deze volgorde: + * + * 1. `'deviates'` — de taak heeft een vastlegging (`recorded.times[task.id]`) waarvan de vroege + * start/einde AFWIJKT van wat er nu op het scherm staat (`task.time.earlyStart`/`earlyFinish`). + * Alleen betekenisvol BUITEN de modus (`!datesAsRecorded`): IN de modus staat het scherm per + * constructie gelijk aan de vastlegging (`applyRecordedTimesToTasks` schrijft `rec.start`/ + * `rec.finish` rechtstreeks naar `task.time.early*`), dus "wijkt af" zou daar altijd `false` + * zijn — geen zinvol signaal, dus niet apart gemeld. + * 2. `'partly-unrecorded'` — de vastlegging zelf is onvolledig (`unrecordedAxes(rec).length > 0`): + * één of meer van de vier optionele assen ontbreken. UITSLUITEND IN DE MODUS (critreview + * laag 3, bevinding 1): de markering is een uitspraak over WAT ER OP HET SCHERM STAAT, niet + * over het bestand. Buiten de modus staat onze eigen, zojuist berekende late-/spelinguitvoer + * in die kolommen — daar is niets "niet vastgelegd" aan, en de badge zou de gebruiker een + * echt getal laten wantrouwen (of, via `recordedUnrecordedAxes`, zelfs verbergen). + * 3. `undefined` — geen vastlegging voor deze taak, of een volledige vastlegging die (buiten de + * modus) niet afwijkt van de herberekening. + * + * Geen vastlegging (`rec` ontbreekt) levert altijd `undefined`, ongeacht `datesAsRecorded` — een + * taak die niet in het bestand stond (bv. toegevoegd ná het openen) heeft niets om over te melden. + */ +export function recordedTaskMark( + recorded: RecordedDatesState | null, + datesAsRecorded: boolean, + task: Task, +): RecordedTaskMark { + const rec = recorded?.times[task.id]; + if (!rec) return undefined; + if (!datesAsRecorded) { + return task.time.earlyStart !== rec.start || task.time.earlyFinish !== rec.finish + ? 'deviates' + : undefined; + } + return unrecordedAxes(rec).length > 0 ? 'partly-unrecorded' : undefined; +} + +/** + * De badge in het eigenschappenpaneel (`TaskRecordedDatesNotice.tsx`) — dezelfde bron als de + * tabelkolom, zodat de twee oppervlakken over dezelfde taak nooit iets anders kunnen zeggen. + * + * - buiten de modus: precies `recordedTaskMark` (dus `'deviates'` of niets); + * - in de modus: `'partly-unrecorded'` zodra de vastlegging assen mist — dát is daar het + * informatieve signaal, en het is exact wat de kolom `recorded.source` dan óók toont — anders + * `'active'` ("deze taak toont de datums uit het bestand"). + */ +export function recordedNoticeState( + recorded: RecordedDatesState | null, + datesAsRecorded: boolean, + task: Task, +): 'active' | RecordedTaskMark { + const mark = recordedTaskMark(recorded, datesAsRecorded, task); + if (!datesAsRecorded) return mark; + return recorded?.times[task.id] ? (mark ?? 'active') : undefined; +} + +/** + * De naad tussen de documentstate en een taakgrid-`TaskColumnContext` — één plek, zodat elk + * rasteroppervlak (`FullTaskGrid` voor Gantt-taakraster én het tabblad Tabel, en het schrijfpad in + * `gridTransaction.ts`) dezelfde poort gebruikt. + * + * De poort is `datesAsRecorded`, NIET `recordedDates !== null` (critreview laag 3, bevinding 1 — + * een gemeten regressie op de bestaande #63-route, niet iets XER-specifieks). `recordedDates !== null + * && !datesAsRecorded` is namelijk de AANBOD-stand: er is gewoon gesolved, `task.time.lateStart` en + * de floats zijn echte CPM-uitvoer, en `recordedAxisFormat` zou ze door "Niet vastgelegd" vervangen + * — permanent, want `runCPM` wist de vastlegging alleen bij het VERLATEN van de modus, dus ook F5 + * haalde het niet weg. Een IFC vult zelden `LateStart`/`TotalFloat`, dus dat trof zo goed als elk + * document met een #63-aanbod. + * + * `recordedMark` hangt bewust wél aan `recordedDates` alleen: "wijkt af" is juist buiten de modus + * het zinvolle signaal, en die kolom vervangt geen enkele berekende waarde — hij zet er een + * markering náást. + */ +export function recordedGridBinding( + recorded: RecordedDatesState | null, + datesAsRecorded: boolean, +): { + recordedMark: ((task: Task) => RecordedTaskMark) | undefined; + recordedUnrecordedAxes: ((task: Task) => readonly RecordedTaskAxis[]) | undefined; +} { + return { + recordedMark: recorded ? (task => recordedTaskMark(recorded, datesAsRecorded, task)) : undefined, + recordedUnrecordedAxes: recorded && datesAsRecorded + ? (task => unrecordedAxes(recorded.times[task.id])) + : undefined, + }; +} diff --git a/src/state/runtime/createMcpTransactions.ts b/src/state/runtime/createMcpTransactions.ts index 882503320..e939dc8ce 100644 --- a/src/state/runtime/createMcpTransactions.ts +++ b/src/state/runtime/createMcpTransactions.ts @@ -23,6 +23,8 @@ import type { Project } from '@/types/project'; import type { CustomTaskType } from '@/types/taskType'; import type { LevelingResult } from '@/engine/scheduler/ResourceLeveler'; import { clampProjectStartAnchors } from '@/engine/scheduler/projectStartAnchorClamp'; +import { isSummaryTask } from '@/utils/taskHierarchy'; +import { reconcileP6SuspendResume } from '@/utils/p6SuspendResume'; export type McpTransactionResult = | { ok: true; value: T; timephasedGuidanceLost: number } @@ -133,6 +135,7 @@ function createMcpDraft( priority: partial.priority ?? 500, parentId, childIds: [], + isSummary: partial.isSummary, // T14b (gebruikstestbevinding, ernst hoog — dataverlies): zie taskSlice.ts addTask — zelfde // veld-voor-veld-merge, MCP-pad. Een ongemerged meegegeven `time` liet writeIFC crashen op // een ontbrekend `completion` (`time.completion.toFixed(1)` in ifcTaskSlots.ts). @@ -363,6 +366,7 @@ function createMcpDraft( if (time) s.tasks[idx].time = mergeTaskTime(s.tasks[idx].time, time); // Contour-engine (2026-09) — tweeling van taskSlice.ts's `updateTask`: herschaal de contour. if (timeUpdateTouchesTimephasedWindow(time)) rescaleTaskContours(s.tasks[idx], oldWorkMinutes, contourHpd); + reconcileP6SuspendResume(s.tasks[idx]); // Z14b (eigenaarsprincipe 2026-08-18) — gedocumenteerde tweeling van taskSlice.ts's // `updateTask`: zelfde triggerset/uitleg in `taskDefaults.ts`. if (('calendarId' in rest) || timeUpdateTouchesTimephasedWindow(time)) { @@ -412,6 +416,7 @@ function createMcpDraft( } // Contour-engine (2026-09) — zelfde herschaling als `updateTaskFields` hierboven. if (timeTouched) rescaleTaskContours(task, oldWorkMinutes, contourHpd); + reconcileP6SuspendResume(task); // Z14b (eigenaarsprincipe 2026-08-18) — zelfde triggerset als `updateTaskFields`, zie // `taskDefaults.ts`. `timePatch` heeft een eigen, smallere vorm (allowlist-gedreven) dan een // volledige `Partial`, dus hier direct de sleutel-aanwezigheid bijhouden i.p.v. @@ -598,7 +603,7 @@ function createMcpDraft( store.setState((s) => { const task = s.tasks.find((t) => t.id === taskId); if (!task) throw new Error(`draft.assignResource: onbekende taskId '${taskId}'`); - if (task.isMilestone || task.childIds.length > 0) { + if (task.isMilestone || isSummaryTask(task)) { throw new Error(`draft.assignResource: kan geen resource toewijzen aan een mijlpaal/samenvattingstaak '${taskId}'`); } if (!isValidUnits(unitsPerDay)) { @@ -675,7 +680,7 @@ function createMcpDraft( if (!assignment) throw new Error(`draft.moveAssignment: onbekende assignmentId '${assignmentId}'`); const newTask = s.tasks.find((t) => t.id === newTaskId); if (!newTask) throw new Error(`draft.moveAssignment: onbekende taskId '${newTaskId}'`); - if (newTask.isMilestone || newTask.childIds.length > 0) { + if (newTask.isMilestone || isSummaryTask(newTask)) { throw new Error(`draft.moveAssignment: doeltaak '${newTaskId}' is een mijlpaal/samenvattingstaak`); } const alreadyOnTarget = s.assignments.some( diff --git a/src/state/slices/baselineSlice.ts b/src/state/slices/baselineSlice.ts index 2ca3c8fdc..284c627b5 100644 --- a/src/state/slices/baselineSlice.ts +++ b/src/state/slices/baselineSlice.ts @@ -1,6 +1,7 @@ import type { Baseline } from '@/types/baseline'; import { generateId } from '@/utils/id'; import type { AppSliceFactory } from './types'; +import { isLeafTask } from '@/utils/taskHierarchy'; export interface BaselineSlice { baselines: Baseline[]; @@ -25,7 +26,7 @@ export const createBaselineSlice: AppSliceFactory = (runtime) => runtime.beginUndoable(s); // Snapshot de CPM-early-datums (= de balk zoals getekend, §2.1) per leaf-taak; fallback op // de schedule-datums voor het geval er nog nooit een runCPM is geweest. - const leaves = s.tasks.filter((t) => t.childIds.length === 0); + const leaves = s.tasks.filter(isLeafTask); s.baselines.push({ id, name, diff --git a/src/state/slices/documentSlice.ts b/src/state/slices/documentSlice.ts index 323462181..8f308ba01 100644 --- a/src/state/slices/documentSlice.ts +++ b/src/state/slices/documentSlice.ts @@ -1,4 +1,5 @@ import type { Project } from '@/types/project'; +import { castDraft } from 'immer'; import type { AppState } from '../appStore'; import type { AppSliceFactory } from './types'; import { generateId } from '@/utils/id'; @@ -13,6 +14,12 @@ import { import { emitExtensionEvent, HOST_EVENTS } from '@/services/extensionEvents'; import { documentTitle, untitledOrdinals } from '@/utils/documents'; import { solveProject, cloneTasksForSolve } from '@/engine/scheduler/solveProject'; +import type { XerImportMetadata, XerResourceMetadata } from '@/services/importTypes'; +import { + bindXerImportMetadataToArchive, + XerSourceArchiveValidationError, + type XerSourceArchive, +} from '@/services/xerSourceArchive'; import { invalidateUndoneHistoryForScopes, removeSessionHistoryForDocumentFromState, @@ -20,6 +27,8 @@ import { type HistoryScopeKey, } from '../sessionHistory'; import { + applyRecordedDatesOnLoad, + applyRestoredRecordedMode, materializeLibraryBoundary, prepareLoadedPayload, type DocumentActivationMaterialization, @@ -173,6 +182,102 @@ function deepClone(v: T): T { return JSON.parse(JSON.stringify(v)) as T; } +/** + * X6 bewaart een bestandsbrede, immutable resourcecatalogus met de oorspronkelijke TASKRSRC- + * rijen. Een documentkopie krijgt een nieuwe, mutable projectview, maar mag die catalogus nooit + * JSON-klonen: rehab-2 alleen al bevat 52.640 retained rijen. + */ +function cloneXerResourceMetadata(source: XerResourceMetadata): XerResourceMetadata { + return { + catalog: source.catalog, + assignments: source.assignments.map(assignment => ({ + ...assignment, + entity: { ...assignment.entity }, + ...(assignment.assignedRole ? { assignedRole: { ...assignment.assignedRole } } : {}), + quantities: { ...assignment.quantities }, + rawCurves: { ...assignment.rawCurves }, + costs: { ...assignment.costs }, + })), + issues: source.issues.map(issue => ({ ...issue })), + }; +} + +function cloneXerImportMetadata( + source: XerImportMetadata, + archive: XerSourceArchive | null, +): XerImportMetadata { + if (archive && source.sourceProjectId) { + return bindXerImportMetadataToArchive(archive, source.sourceProjectId); + } + const { resources, metadata, ...withoutCatalogs } = source; + const clone = deepClone(withoutCatalogs); + // X6/X8-catalogi zijn bestandsbreed, readonly brondata. Een documentduplicaat krijgt zijn eigen + // mutable projectmetadata maar nooit een tweede kopie van grote TASKRSRC/TASKACTV-catalogi. + return { + ...clone, + ...(resources ? { resources: cloneXerResourceMetadata(resources) } : {}), + ...(metadata ? { metadata } : {}), + }; +} + +/** Herstel leest zelfstandige IFC's; identieke gevalideerde bronarchieven worden daarna één ref. */ +function shareRecoveredXerArchives(docs: readonly RecoveryDocInput[]): RecoveryDocInput[] { + const canonicalByDigest = new Map(); + const metadataCache = new WeakMap(); + const canonicalMetadata = (archive: XerSourceArchive): string => { + const cached = metadataCache.get(archive); + if (cached !== undefined) return cached; + const stable = (value: unknown): string => { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`; + return `{${Object.entries(value as Record) + .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0) + .map(([key, child]) => `${JSON.stringify(key)}:${stable(child)}`).join(',')}}`; + }; + const result = stable({ diagnostics: archive.diagnostics, readModel: archive.readModel }); + metadataCache.set(archive, result); + return result; + }; + const sameArchive = (left: XerSourceArchive, right: XerSourceArchive): boolean => + left.schemaVersion === right.schemaVersion + && left.format === right.format + && left.byteLength === right.byteLength + && left.sha256 === right.sha256 + && left.encoding === right.encoding + && left.bom === right.bom + && left.newline === right.newline + && left.byteChunks.length === right.byteChunks.length + && left.byteChunks.every((chunk, index) => chunk === right.byteChunks[index]) + && canonicalMetadata(left) === canonicalMetadata(right); + const bindDocumentToArchive = ( + doc: RecoveryDocInput, + archive: XerSourceArchive, + ): RecoveryDocInput => { + const selector = doc.xerSourceProjectId ?? doc.xer?.sourceProjectId; + if (!selector) { + throw new XerSourceArchiveValidationError('XER-recovery mist een documentselector'); + } + const metadata = bindXerImportMetadataToArchive(archive, selector); + return { + ...doc, + xerSourceArchive: archive, + xerSourceProjectId: selector, + xer: metadata, + }; + }; + return docs.map(doc => { + const archive = doc.xerSourceArchive; + if (!archive) return doc; + const key = `${archive.byteLength}:${archive.sha256}`; + const candidates = canonicalByDigest.get(key) ?? []; + const canonical = candidates.find(candidate => sameArchive(candidate, archive)); + if (canonical) return bindDocumentToArchive(doc, canonical); + candidates.push(archive); + canonicalByDigest.set(key, candidates); + return bindDocumentToArchive(doc, archive); + }); +} + /** `"Basis (variant 3)"` → `"Basis"`; een naam zonder variant-suffix blijft ongewijzigd. Zo blijft de * basisnaam stabiel wanneer je een variant-document opnieuw dupliceert (varianten-van-varianten). */ const VARIANT_RE = /^(.*) \(variant (\d+)\)$/; @@ -216,7 +321,7 @@ export const createDocumentSlice: AppSliceFactory = (runtime) => }); set((s) => { const cur = s.documents.find((d) => d.id === s.activeDocumentId); - if (cur) cur.payload = outgoing; + if (cur) cur.payload = castDraft(outgoing); s.documents.push({ id: newId, payload: null }); s.activeDocumentId = newId; // Een vers leeg document heeft geen open-boundary (er is niets aan gekoppeld), dus zonder @@ -275,6 +380,13 @@ export const createDocumentSlice: AppSliceFactory = (runtime) => fileHandle: null, autoSaveToFile: false, isDirty: true, + xerImportMetadata: src.xerImportMetadata + ? cloneXerImportMetadata(src.xerImportMetadata, src.xerSourceArchive) + : null, + // De originele XER-bytes zijn immutable en worden doelbewust NIET gekloond: één runtimeobject + // voor bron, twaalf tabs en varianten; elke IFC-save embedt later wél een eigen container. + xerSourceArchive: src.xerSourceArchive, + xerSourceProjectId: src.xerSourceProjectId, }; const activation = materializeLibraryBoundary({ payload: copy, companies: source.companies, pools: source.pools, mode: 'silent-switch', @@ -282,7 +394,7 @@ export const createDocumentSlice: AppSliceFactory = (runtime) => set((s) => { const cur = s.documents.find((d) => d.id === s.activeDocumentId); - if (cur) cur.payload = src; // bron parkeren (per referentie, net als newDocument/switchDocument) + if (cur) cur.payload = castDraft(src); // bron parkeren (per referentie, net als newDocument/switchDocument) s.documents.push({ id: newId, payload: null }); s.activeDocumentId = newId; resetDocumentScopedUI(s); @@ -311,7 +423,7 @@ export const createDocumentSlice: AppSliceFactory = (runtime) => }); set((s) => { const cur = s.documents.find((d) => d.id === s.activeDocumentId); - if (cur) cur.payload = outgoing; + if (cur) cur.payload = castDraft(outgoing); const inc = s.documents.find((d) => d.id === id); if (inc) inc.payload = null; s.activeDocumentId = id; @@ -427,6 +539,15 @@ export const createDocumentSlice: AppSliceFactory = (runtime) => restoreDocuments: (docs, activeId) => { if (docs.length === 0) return { skippedIds: [] }; + // X6/X8-herstel leest per document een ZELFSTANDIG IFC; identieke gevalideerde XER-bron- + // archieven worden hier weer één gedeelde referentie vóór er payloads van gemaakt worden + // (rehab-2 alleen al draagt 52.640 retained TASKRSRC-rijen). Deze stap GOOIT bewust bij een + // ongeldig archief (`XerSourceArchiveValidationError`) en wordt NIET afgevangen: een + // bronarchief zonder vindbare documentselector is geen "één kapot document" maar een kapot + // leesmodel, en check 10b van `check-xer-archive-readmodel.ts` pint die harde, getypeerde + // weigering vast. De per-document `try/catch` hieronder dekt het andere geval — een geldig + // gelezen document dat pas op de solve/rollup stukloopt. + const sharedDocs = shareRecoveredXerArchives(docs); const state = get(); const skippedIds: string[] = []; @@ -439,15 +560,24 @@ export const createDocumentSlice: AppSliceFactory = (runtime) => // document niet de rest van het herstel blokkeert. De oorspronkelijk actieve kandidaat gaat // als eerste, zodat een geslaagd herstel dezelfde `activeDocumentId` behoudt als voorheen. const tryOrder = [ - ...docs.filter((d) => d.id === activeId), - ...docs.filter((d) => d.id !== activeId), + ...sharedDocs.filter((d) => d.id === activeId), + ...sharedDocs.filter((d) => d.id !== activeId), ]; let active: RecoveryDocInput | null = null; let prepared: DocumentPayload | null = null; let activation: DocumentActivationMaterialization | null = null; for (const candidate of tryOrder) { try { - const p = prepareLoadedPayload(payloadFromInput(candidate), { recompute: true }); + const rawPayload = payloadFromInput(candidate); + const p = prepareLoadedPayload(rawPayload, { recompute: true }); + // XER-etappeplan §3.5/§4-T4, risico §5.4, heropen-beleid (T8) + critreview laag 3 + // (bevinding 2): crashherstel herstelt het bestaande #63-aanbod (`recordedFields`, elk + // formaat) dat het tot nu toe stilzwijgend wegliet, EN de modusvlag van vóór de crash — + // uit de recovery-metadata (`candidate.datesAsRecorded`), dus een OPGESCHREVEN feit en + // geen heuristiek: crashherstel is het hervatten van een sessie, geen heropening, en mag + // dus niet opnieuw beslissen. `rawPayload.tasks` is bewust de PRE-solve array — + // `prepareLoadedPayload` muteert zijn `input`-argument niet. + applyRecordedDatesOnLoad(rawPayload.tasks, p, candidate, candidate.datesAsRecorded); const a = materializeLibraryBoundary({ payload: p, companies: state.companies, pools: state.pools, mode: 'open-boundary', }); @@ -465,11 +595,18 @@ export const createDocumentSlice: AppSliceFactory = (runtime) => // garantie geldt: één document dat zich niet naar een payload laat vormen mag de rest niet // meeslepen. const sleepingById = new Map(); - for (const d of docs) { + for (const d of sharedDocs) { if (active && d.id === active.id) continue; if (skippedIds.includes(d.id)) continue; try { - sleepingById.set(d.id, payloadFromInput(d)); + const sleeping = payloadFromInput(d); + // Critreview laag 3, bevinding 3: ook een SLAPEND document moet zijn weergavestand + // terugkrijgen. Zonder dit kwam het terug met P6's datums in `task.time`, zonder modus en + // mét `scheduleStale` — waarna automatisch berekenen (of de eerste F5) ze stil wegrekende. + // Geen solve hier (dat is de hele reden dat slapende documenten stale zijn), dus ook geen + // `shifted`-teller; zie `applyRestoredRecordedMode`. + if (d.datesAsRecorded) applyRestoredRecordedMode(sleeping, d); + sleepingById.set(d.id, sleeping); } catch (err) { console.error('Recovery: hersteld document kon niet worden voorbereid — overgeslagen:', d.id, err); skippedIds.push(d.id); @@ -498,12 +635,14 @@ export const createDocumentSlice: AppSliceFactory = (runtime) => set((s) => { replaceSessionHistoryState(s, [], 1); - s.documents = docs + // `castDraft`: een payload kan een readonly XER-bronarchief/-catalogus dragen, die Immer's + // `Draft<>` anders afwijst (X6). + s.documents = castDraft(sharedDocs .filter((d) => !skippedIds.includes(d.id)) .map((d) => ({ id: d.id, payload: d.id === activeDoc.id ? null : (sleepingById.get(d.id) ?? null), - })); + }))); s.activeDocumentId = activeDoc.id; resetDocumentScopedUI(s); if (activation2.invalidateRedoScope) invalidateActivationRedo(s, activeDoc.id); @@ -559,6 +698,8 @@ export const createDocumentSlice: AppSliceFactory = (runtime) => dataDate: payload.project.statusDate, progressMode: payload.project.progressMode, schedulingOptions: payload.project.schedulingOptions, + projectStartDate: payload.project.startDate, + projectEndDate: payload.project.endDate, }); // Cyclus/solverfout: dit document volledig ONAANGERAAKT laten (het vangnet van §4.3 blijft // dan gelden — het overzicht toont zijn boeking ongeteld met de ⚠) en doorgaan met de rest. @@ -600,7 +741,7 @@ export const createDocumentSlice: AppSliceFactory = (runtime) => for (const u of updates) { const entry = s.documents.find((d) => d.id === u.id); if (!entry || entry.payload === null) continue; // tussentijds gesloten/geactiveerd. - entry.payload = u.payload; + entry.payload = castDraft(u.payload); } }); return updates.length; diff --git a/src/state/slices/fileSlice.ts b/src/state/slices/fileSlice.ts index 214bfc6d9..32c9bf5e3 100644 --- a/src/state/slices/fileSlice.ts +++ b/src/state/slices/fileSlice.ts @@ -1,26 +1,26 @@ import { writeIFC } from '@/services/ifc/ifcWriter'; -import { readIFC } from '@/services/ifc/ifcReader'; import { writeCSV } from '@/services/csv/csvWriter'; import { writeMSPDI } from '@/services/msproject/mspdiWriter'; import { writeP6XML } from '@/services/p6/p6xmlWriter'; import { openFileDialog, saveFileDialog, saveToRef, readFromRef, readBytesFromRef, type FileRef, type SaveOutcome } from '@/services/fileAccess'; -import { openDialogFilters, binaryExtensions, readFormatForFile, parseOpenedFile, importErrorMessageKey, saveTargetFor, readFormatInput, type ExportFormat } from '@/services/formatRegistry'; +import { openDialogFilters, binaryExtensions, readFormatForFile, parseOpenedFile, importErrorMessageKey, saveTargetFor, readFormatInput, readIFCWithXerReconstruction, type ExportFormat } from '@/services/formatRegistry'; import { loadRecents, addRecent, removeRecent, type RecentEntry } from '@/services/fileAccess/recentFiles'; import { emitExtensionEvent, HOST_EVENTS } from '@/services/extensionEvents'; -import type { AppSliceFactory } from './types'; +import type { AppSliceFactory, NotifyInput, NotificationDetailLine } from './types'; import type { AppState } from '../appStore'; import { isTauri } from '@/utils/platform'; import type { Task } from '@/types/task'; -import type { ImportLabels, ImportResult } from '@/services/importTypes'; -import { hydratePayload, payloadFromImport } from '../documentContract'; -import { materializeLibraryBoundary, prepareLoadedPayload } from '../documentActivation'; -import { captureRecordedDates, countShiftedTasks } from '@/engine/scheduler/recordedDates'; +import { activeImportResult, isMultiDocumentImport, type ImportLabels, type ImportResult, type OpenedImport } from '@/services/importTypes'; +import { hydratePayload, payloadFromImport, type DocumentPayload } from '../documentContract'; +import { applyRecordedDatesOnLoad, materializeLibraryBoundary, prepareLoadedPayload } from '../documentActivation'; +import { unrecordedExportGate } from '../recordedDatesSelectors'; import { buildWriteIFCInput, sameIFCSource } from '../ifcSaveInput'; import { fileHasHourData } from '@/services/subdayIo'; import { projectFileBase } from '@/utils/documents'; import { refreshExternalAnchors, type ExternalSourceDoc } from '@/engine/externalLinks'; import { normalizeExternalSourcePath } from '@/engine/taskGrid/relationFormat'; import { expandSummaryRelations } from '@/engine/scheduler/expandSummaryRelations'; +import { detectXerExportLoss, type XerExportLossWarning } from '@/services/xerExportLoss'; import { runProjectFileWrite } from '@/services/fileAccess/writeCoordinator'; import { invalidateUndoneHistoryForScopes, @@ -50,6 +50,100 @@ export function isActivePristine(s: AppState): boolean { ); } +/** De in-app gids achter de ene bestandsbrede XER-openingsmelding. */ +export const XER_IMPORT_HELP_ARTICLE_ID = 'gids-xer-import'; + +/** + * Vorm één gebruikerszichtbaar verslag uit uitsluitend de feiten die de XER-lezer bestandsbreed + * bewaart. Deze helper zit nadrukkelijk buiten `applyLoadedProject`: één XER kan twaalf documenten + * openen, maar is nog steeds één bestandsactie en dus één melding. + */ +export function xerImportNotice( + results: readonly ImportResult[], + /** XER-etappeplan §3.7 (taak T4): som van `recordedDates.shifted` over alle zojuist geopende + * documenten van dit bestand, GESPLITST naar wat er werkelijk gebeurde. Bewust aparte + * parameters i.p.v. iets uit `results` zelf afgeleid: het aantal afwijkende taken bestaat pas + * ná de solve op elk document (`applyLoadedProject` → `applyRecordedDatesOnLoad`), en + * `ImportResult` draagt zelf geen `shifted`-veld (dat is documentstate, geen import-resultaat). + * + * De SPLITSING is critreview laag 3, bevinding 4: `xerImportDatesAsRecorded` zegt letterlijk + * "niet herberekend", en dat mag alleen staan wanneer de modus daadwerkelijk aanging. Een + * heropende IFC met XER-archief draagt óók `xer`-metadata, maar krijgt per heropen-beleid + * alleen het AANBOD — daar is wél herberekend, dus die telt in `offerTotal` en krijgt zijn + * eigen, aanbiedende regel. Beide `0` ⇒ geen detailregel. */ + datesAsRecordedShiftedTotal = 0, + /** Som van `recordedDates.shifted` over de documenten die de modus alléén AANBIEDEN. */ + datesAsRecordedOfferTotal = 0, +): NotifyInput | undefined { + const xers = results.flatMap(result => result.xer ? [result.xer] : []); + const xer = xers[0]; + if (!xer) return undefined; + + // X9 bewaart de file-wide diagnostics precies eenmaal in het immutable bronarchief. De + // documentviews bevatten daarnaast de werkelijk projectgebonden keuzes (enum- en + // scheduling-terugvallen). Gebruik het archief zodra het er is, maar blijf ook bruikbaar voor + // een directe X4b-resultaatview vóórdat die in een archief is gebonden. + const archive = results.find(result => result.xerSourceArchive)?.xerSourceArchive; + const fileDiagnostics = archive?.diagnostics.file; + const report = fileDiagnostics?.importReport ?? xer.report; + const tableReport = fileDiagnostics?.tableReport ?? xer.tableReport; + const enumFallbacks = xers.reduce((total, item) => total + item.enumFallbacks.length, 0); + const scheduleFallbacks = xers.reduce((total, item) => total + item.scheduleOptions.fallbacks.length, 0); + const calendarIssues = xer.calendarIssues.length; + const numberIssues = tableReport.issues.filter(issue => /(?:NUMBER|DECIMAL)/.test(issue.code)).length + + xer.calendarIssues.filter(issue => issue.code === 'XER_CALENDAR_INVALID_PERIOD_HOURS').length; + const parserIssues = tableReport.issues.length - tableReport.issues + .filter(issue => /(?:NUMBER|DECIMAL)/.test(issue.code)).length; + // Deze regel is bewust smaller dan alle overige importdiagnostiek: alleen de P6- + // schedulingopties die naar een veilige semantische default terugvallen horen onder + // "unsupported semantics". Resource-/metadatacatalogusissues zijn afzonderlijke + // bron- of koppelproblemen, geen planningsemantiek, en mogen deze teller niet opblazen. + const unsupportedSemantics = scheduleFallbacks; + const detailLines: NotificationDetailLine[] = [ + { messageKey: 'notifications.xerImportProjectsSeen', params: { count: report.projectsSeen } }, + ]; + const addCount = (count: number, messageKey: NotificationDetailLine['messageKey']) => { + if (count > 0) detailLines.push({ messageKey, params: { count } }); + }; + + addCount(report.emptyProjectsSkipped, 'notifications.xerImportEmptyProjectsSkipped'); + addCount(report.baselineProjectsExcluded, 'notifications.xerImportBaselineProjectsExcluded'); + addCount(report.baselinesMaterialized, 'notifications.xerImportBaselinesMaterialized'); + addCount(report.danglingBaselineReferences, 'notifications.xerImportDanglingBaselineReferences'); + if (report.baselineExclusionReverted || report.baselineFallbackReasons.length > 0) { + detailLines.push({ messageKey: 'notifications.xerImportBaselineFallback' }); + } + addCount(report.externalLinksPreserved, 'notifications.xerImportExternalLinks'); + + // UTF-8 zonder BOM is de standaard. Iedere andere gerapporteerde keuze is een daadwerkelijk + // detecteerbare bronfeit (BOM of Windows-1252-terugval) en verdient daarom uitleg; we beweren + // nadrukkelijk niet dat afzonderlijke rijen werden overgeslagen. + if (tableReport.encoding !== 'utf-8') { + detailLines.push({ + messageKey: 'notifications.xerImportEncoding', + params: { encoding: tableReport.encoding }, + }); + } + addCount(parserIssues, 'notifications.xerImportParserIssues'); + addCount(calendarIssues, 'notifications.xerImportCalendarIssues'); + addCount(numberIssues, 'notifications.xerImportNumberIssues'); + addCount(enumFallbacks, 'notifications.xerImportEnumFallbacks'); + addCount(unsupportedSemantics, 'notifications.xerImportUnsupportedSemantics'); + // XER-etappeplan §3.7 (taak T4, X-O7 laag 3): "datums zoals opgeslagen" staat standaard aan zodra + // er restverschillen zijn. Eén regel voor het HELE bestand, ook bij twaalf documenten — de teller + // is de som over alle zojuist geopende documenten (zie `applyOpenedImport`), niet per document. + addCount(datesAsRecordedShiftedTotal, 'notifications.xerImportDatesAsRecorded'); + addCount(datesAsRecordedOfferTotal, 'notifications.xerImportDatesAsRecordedOffer'); + + return { + severity: 'info', + messageKey: 'notifications.xerImportOpened', + params: { count: report.documentsOpened }, + detailLines, + helpArticleId: XER_IMPORT_HELP_ARTICLE_ID, + }; +} + // `ExportFormat` woont nu in de formatRegistry (T1); hier her-exporteren zodat bestaande // importeurs (Backstage, via appStore) ongewijzigd blijven werken. export { type ExportFormat }; @@ -57,7 +151,9 @@ export { type ExportFormat }; /** Resultaat van `exportAs` (K7): bij een cyclische planning wordt de export afgebroken vóór de * opslaan-dialoog en de CPM-cyclusfout (`cpmResult.error`) als boodschap meegegeven, zodat de * aanroeper die kan tonen i.p.v. stilletjes niets te doen. */ -export type ExportResult = { ok: true } | { ok: false; error: string }; +export type ExportResult = + | { ok: true; warnings: readonly XerExportLossWarning[] } + | { ok: false; error: string }; /** Opties voor `applyLoadedProject` — de één gedeelde "vul de actieve document-state met een * geparsed project"-implementatie (audit P5/F6). Elke variant (de drie open-paden + `loadState`) @@ -87,11 +183,20 @@ export interface ApplyLoadedProjectOpts { viewStartDate?: string; } +/** Feitelijke uitkomst van de centrale opennaad; gebruikt door DevBridge en MCP voor eerlijke ids. */ +export interface AppliedOpenedImport { + documentIds: string[]; + activeDocumentId: string; + reusedActiveTab: boolean; +} + export interface FileSlice { /** `labels` — vertaalde teksten die de UI aanlevert omdat de store-laag geen `t(...)` heeft (zie * `ImportLabels`); weglaten geeft de Engelse default. Geldt voor elk laadpad hieronder. */ openFile: (labels?: ImportLabels) => Promise; saveFile: () => Promise; + /** Bewaar exact het bedoelde document en meld of zijn ongewijzigde snapshot is vastgelegd. */ + saveFileForDocument: (documentId: string) => Promise; saveFileAs: () => Promise; exportAs: (format: ExportFormat) => Promise; /** Exporteer het project + (spec §4) schrijf de gebonden bedrijfs-pool als tweede, LOS bestand @@ -115,12 +220,18 @@ export interface FileSlice { /** Open een meegeleverd voorbeeldproject uit een IFC-string als NIEUW document * (geen filePath — opslaan wordt opslaan-als; isDirty=false). Werkt in web én * Tauri; het bestand wordt door de aanroeper via fetch('/examples/…') geladen. */ - openExampleFromString: (content: string, name: string, labels?: ImportLabels) => void; + openExampleFromString: (content: string, name: string, labels?: ImportLabels) => Promise; /** Eén gedeelde load-implementatie (audit P5/F6): vul de ACTIEVE document-state met een geparsed * project en voer de opt-afhankelijke nastappen uit (runCPM/fit/uur-melding/extensie-event). * Neemt géén besluit over een nieuw tabblad — dat blijft bij de aanroeper vóór de load. * `loadState` en de drie open-paden lopen hier allemaal doorheen. */ applyLoadedProject: (parsed: ImportResult, opts: ApplyLoadedProjectOpts) => void; + /** + * Centrale open-naad voor één of meer geparste projectpayloads. Alleen XER gebruikt vandaag de + * meervoudige vorm; de individuele documenten lopen daarna letterlijk door hetzelfde + * applyLoadedProject-pad als IFC/CSV/MSPDI/MPP/P6XML. + */ + applyOpenedImport: (parsed: OpenedImport, opts: ApplyLoadedProjectOpts) => AppliedOpenedImport; } export const createFileSlice: AppSliceFactory = (runtime) => (set, get) => { @@ -149,6 +260,74 @@ export const createFileSlice: AppSliceFactory = (runtime) => (set, ge }); }; + const saveSourceForDocument = (state: AppState, documentId: string): AppState | DocumentPayload | null => { + if (state.activeDocumentId === documentId) return state; + return state.documents.find(document => document.id === documentId)?.payload ?? null; + }; + + const commitSavedDocument = ( + documentId: string, + savedSource: AppState, + destination?: { filePath: string; fileHandle: FileSystemFileHandle | null }, + ): boolean => { + const currentSource = saveSourceForDocument(get(), documentId); + if (!currentSource) return false; + const unchanged = sameIFCSource(savedSource, currentSource); + set((state) => { + if (state.activeDocumentId === documentId) { + if (destination) { + state.filePath = destination.filePath; + state.fileHandle = destination.fileHandle; + } + if (unchanged) state.isDirty = false; + return; + } + const entry = state.documents.find(document => document.id === documentId); + if (!entry?.payload) return; + if (destination) { + entry.payload.filePath = destination.filePath; + entry.payload.fileHandle = destination.fileHandle; + } + if (unchanged) entry.payload.isDirty = false; + }); + return unchanged; + }; + + const saveActiveDocument = (expectedDocumentId?: string): Promise => runProjectFileWrite(async () => { + const state = get(); + const documentId = state.activeDocumentId; + if (expectedDocumentId && expectedDocumentId !== documentId) return false; + const content = writeIFC(buildWriteIFCInput(state)); + + try { + const ref: FileRef | null = state.fileHandle + ? { kind: 'handle', handle: state.fileHandle } + : (isTauri() && state.filePath ? { kind: 'path', path: state.filePath } : null); + + if (ref && await saveToRef(ref, content)) { + return commitSavedDocument(documentId, state); + } + + const outcome = await saveFileDialog( + `${projectFileBase(state.project.name)}.ifc`, + content, + [{ name: 'IFC Files', extensions: ['ifc'] }], + ); + if (!outcome) return false; + const saved = commitSavedDocument(documentId, state, { + filePath: outcome.ref?.kind === 'path' ? outcome.ref.path : outcome.name, + fileHandle: outcome.ref?.kind === 'handle' ? outcome.ref.handle : null, + }); + await pushRecent(outcome.ref, outcome.name); + noticeIfDownloaded(outcome); + return saved; + } catch (err) { + console.error('Save failed:', err); + get().notify({ severity: 'error', messageKey: 'notifications.saveFailed', detail: (err as Error).message }); + return false; + } + }); + return { applyLoadedProject: (parsed, opts) => { const current = get(); @@ -172,13 +351,15 @@ export const createFileSlice: AppSliceFactory = (runtime) => (set, ge payload.calendar = payload.calendars.find(calendar => calendar.id === payload.project.calendarId) ?? payload.calendar; } - const recorded = opts.recompute - ? captureRecordedDates(payload.tasks, parsed.recordedFields) - : null; const prepared = prepareLoadedPayload(payload, { recompute: !!opts.recompute }); - if (recorded && recorded.total > 0) { - const shifted = countShiftedTasks(prepared.tasks, recorded.times); - if (shifted > 0) prepared.recordedDates = { ...recorded, shifted }; + if (opts.recompute) { + // XER-etappeplan §3.5 (taak T4), heropen-beleid (taak T5, 2026-09-05): alleen een VERSE + // XER-import (parsed.recordedTimesOrigin === 'xer') zet de modus meteen aan; een heropende + // IFC met XER-archief (recordedTimesOrigin === 'xer-archive') en overige formaten bieden + // hem alleen aan — ongewijzigd #63-gedrag. `payload.tasks` is hier bewust de PRE-solve + // array (zie de docstring van `applyRecordedDatesOnLoad`): `prepareLoadedPayload` muteert + // zijn `input`-argument niet. + applyRecordedDatesOnLoad(payload.tasks, prepared, parsed); } const activation = materializeLibraryBoundary({ payload: prepared, @@ -263,16 +444,57 @@ export const createFileSlice: AppSliceFactory = (runtime) => (set, ge }); }, + applyOpenedImport: (parsed, opts) => { + const results = isMultiDocumentImport(parsed) ? parsed.results : [parsed]; + if (results.length === 0) throw new Error('Openroute ontving geen projectdocumenten'); + + const reusedActiveTab = isActivePristine(get()); + const openedDocumentIds: string[] = []; + // XER-etappeplan §3.7 (taak T4): som van `recordedDates.shifted` over alle zojuist geopende + // documenten — per document eigen vlag/eigen vastlegging (elk document leest zijn eigen + // `recordedTimes` uit zijn eigen `ImportResult`), maar de MELDING blijft er één per bestand. + let datesAsRecordedShiftedTotal = 0; + // Critreview bevinding 4: apart tellen, want de twee uitkomsten zijn verschillende + // beweringen. Alleen een document waar de modus ECHT aanging is "niet herberekend". + let datesAsRecordedOfferTotal = 0; + for (const result of results) { + // De eerste payload mag het lege starttabblad hergebruiken; elk volgend project krijgt + // gegarandeerd een eigen tab. Dit leest de actuele state per iteratie, want de vorige load + // heeft die state bewust niet-pristine gemaakt. + if (!isActivePristine(get())) get().newDocument(); + openedDocumentIds.push(get().activeDocumentId); + // `applyLoadedProject` draait de open-/bibliotheekgrens zelf (materializeLibraryBoundary), + // dus elk document krijgt hem — geen aparte runOpenBoundary-aanroep meer nodig. + get().applyLoadedProject(result, opts); + // Lees DIRECT ná deze aanroep: `applyLoadedProject` maakt het zojuist geladen document + // actief, dus `get().recordedDates` is op dit punt exact dát document z'n eigen vastlegging. + const shifted = get().recordedDates?.shifted ?? 0; + if (get().datesAsRecorded) datesAsRecordedShiftedTotal += shifted; + else datesAsRecordedOfferTotal += shifted; + } + + // X10: de rapportage is bestandsbreed en identiek op iedere XER-resultaatview. Plaats deze + // pas ná de volledige lus, anders ontstaat er één toast per nieuw document. Andere formats + // leveren geen `xer`-metadata en houden hun bestaande, stille openpad. + const notice = xerImportNotice(results, datesAsRecordedShiftedTotal, datesAsRecordedOfferTotal); + if (notice) get().notify(notice); + + const activeIndex = isMultiDocumentImport(parsed) ? parsed.activeDocumentIndex : 0; + const activeId = openedDocumentIds[activeIndex]; + if (activeId && activeId !== get().activeDocumentId) get().switchDocument(activeId); + return { + documentIds: openedDocumentIds, + activeDocumentId: get().activeDocumentId, + reusedActiveTab, + }; + }, + openFile: async (labels) => { try { const opened = await openFileDialog(openDialogFilters(), { binaryExtensions: binaryExtensions() }); if (!opened) return; const parsed = await parseOpenedFile({ name: opened.name, text: opened.content, bytes: opened.bytes }, labels); - // Multi-document: open het bestand in een eigen tabblad. Hergebruik het - // actieve tabblad alleen als dat nog leeg en ongewijzigd is. - if (!isActivePristine(get())) get().newDocument(); - // Opslagdoel-guard (T8, stap 5a; verbreed T8-spec-review F4; T11: via `canBeSaveTarget` op // de registry-entry i.p.v. een `id === 'ifc'`-vergelijking hier). Opslaan schrijft altijd // IFC-TEKST, dus élk ANDER bronformaat (csv/xml/mpp — niet uitsluitend binaire formaten) @@ -281,9 +503,9 @@ export const createFileSlice: AppSliceFactory = (runtime) => (set, ge // MCP-kant (`fileTools.ts` leest óók `canBeSaveTarget`). const target = saveTargetFor(readFormatForFile(opened.name), opened.ref, opened.name); - // Gedeelde load-implementatie; open-pad-semantiek: identiteit + opslaan-doel zetten, - // direct doorrekenen + fitten en de uur-melding evalueren. - get().applyLoadedProject(parsed, { + // De centrale naad splitst alleen een expliciete multi-import; enkelvoudige formaten + // volgen byte-identiek hetzelfde laadpad, inclusief pristine-tab-hergebruik. + get().applyOpenedImport(parsed, { filePath: target.filePath, fileHandle: target.fileHandle, recompute: true, @@ -302,57 +524,12 @@ export const createFileSlice: AppSliceFactory = (runtime) => (set, ge } }, - saveFile: async () => runProjectFileWrite(async () => { - const state = get(); - // Gedeelde helper (pakket R1): één plek voor het state→IFC-options-object, zodat dit - // pad niet opnieuw velden kan laten vallen. - const content = writeIFC(buildWriteIFCInput(state)); - // `state` is de momentopname vóór de eerste await. De opslaan-dialoog (en in Tauri de - // schrijfactie) kan minuten duren en de gebruiker kan ondertussen doorwerken; `content` is - // dan verouderd. Daarom mag `isDirty` pas worden gewist als de inhoud ná de await nog - // letterlijk dezelfde is — bepaald via `sameIFCSource` (bevinding K8b: anders stil verlies). - - try { - // Bestaand opslaan-doel? Web: fileHandle. Tauri: het echte pad in filePath. - const ref: FileRef | null = state.fileHandle - ? { kind: 'handle', handle: state.fileHandle } - : (isTauri() && state.filePath ? { kind: 'path', path: state.filePath } : null); - - if (ref && await saveToRef(ref, content)) { - // "Nog ongewijzigd?" bewust BUITEN de Immer-producer berekend met get(): binnen een draft - // is `s.tasks` e.d. een proxy en nóóit referentie-gelijk aan de plain array, dus een - // sameIFCSource(state, s) binnen de producer zou altijd false geven en isDirty nóóit - // meer wissen — een ergere regressie dan de bug die we hier repareren. - if (sameIFCSource(state, get())) set((s) => { s.isDirty = false; }); - return; - } - - // Geen (bruikbare) ref, of in-place opslaan geweigerd → opslaan-als. - const outcome = await saveFileDialog( - `${projectFileBase(state.project.name)}.ifc`, - content, - [{ name: 'IFC Files', extensions: ['ifc'] }], - ); - if (!outcome) return; - // Opnieuw buiten de producer bepalen of er tijdens de dialoog iets gewijzigd is. - const unchanged = sameIFCSource(state, get()); - set((s) => { - s.filePath = outcome.ref?.kind === 'path' ? outcome.ref.path : outcome.name; - s.fileHandle = outcome.ref?.kind === 'handle' ? outcome.ref.handle : null; - // Alleen "opgeslagen" als er tijdens de dialoog niets gewijzigd is; anders blijft het - // document terecht gewijzigd en houdt de gebruiker zijn sluitwaarschuwing. - if (unchanged) s.isDirty = false; - }); - await pushRecent(outcome.ref, outcome.name); - noticeIfDownloaded(outcome); - } catch (err) { - console.error('Save failed:', err); - get().notify({ severity: 'error', messageKey: 'notifications.saveFailed', detail: (err as Error).message }); - } - }), + saveFile: async () => { await saveActiveDocument(); }, + saveFileForDocument: (documentId) => saveActiveDocument(documentId), saveFileAs: async () => runProjectFileWrite(async () => { const state = get(); + const documentId = state.activeDocumentId; // Gedeelde helper (pakket R1): één plek voor het state→IFC-options-object, zodat dit // pad niet opnieuw velden kan laten vallen. const content = writeIFC(buildWriteIFCInput(state)); @@ -366,11 +543,9 @@ export const createFileSlice: AppSliceFactory = (runtime) => (set, ge [{ name: 'IFC Files', extensions: ['ifc'] }], ); if (!outcome) return; - const unchanged = sameIFCSource(state, get()); - set((s) => { - s.filePath = outcome.ref?.kind === 'path' ? outcome.ref.path : outcome.name; - s.fileHandle = outcome.ref?.kind === 'handle' ? outcome.ref.handle : null; - if (unchanged) s.isDirty = false; + commitSavedDocument(documentId, state, { + filePath: outcome.ref?.kind === 'path' ? outcome.ref.path : outcome.name, + fileHandle: outcome.ref?.kind === 'handle' ? outcome.ref.handle : null, }); await pushRecent(outcome.ref, outcome.name); noticeIfDownloaded(outcome); @@ -395,6 +570,18 @@ export const createFileSlice: AppSliceFactory = (runtime) => (set, ge if (cpmError) return { ok: false, error: cpmError }; const state = get(); + const warnings = detectXerExportLoss(format, { + sourceArchive: state.xerSourceArchive, + importMetadata: state.xerImportMetadata, + project: state.project, + tasks: state.tasks, + sequences: state.sequences, + assignments: state.assignments, + activityCodeTypes: state.activityCodeTypes, + customFieldDefs: state.customFieldDefs, + baselines: state.baselines, + activeBaselineId: state.activeBaselineId, + }); let content: string; let ext: string; @@ -405,6 +592,9 @@ export const createFileSlice: AppSliceFactory = (runtime) => (set, ge content = writeCSV( state.project, state.calendar, state.tasks, state.sequences, state.resources, state.assignments, state.customTaskTypes, + // In "datums zoals opgeslagen" mag een niet-vastgelegde as niet als verzonnen 0/No + // het bestand in (critreview laag 3, bevinding 6). Buiten de modus: `undefined`. + unrecordedExportGate(state.recordedDates, state.datesAsRecorded), ); ext = 'csv'; filters = [{ name: 'CSV Files', extensions: ['csv'] }]; @@ -438,9 +628,21 @@ export const createFileSlice: AppSliceFactory = (runtime) => (set, ge } const outcome = await saveFileDialog(`${projectFileBase(state.project.name)}.${ext}`, content, filters); - if (outcome) await pushRecent(outcome.ref, outcome.name); + // Cancel is geen export: geen toast en ook geen latente warnings in de succes-envelope. + // De centrale plaatsing hier maakt Backstage en ribbon automatisch één meldschrijver. + if (!outcome) return { ok: true, warnings: [] }; + await pushRecent(outcome.ref, outcome.name); noticeIfDownloaded(outcome); - return { ok: true }; + if (warnings.length > 0) { + get().notify({ + severity: 'info', + messageKey: 'notifications.xerExportLoss', + params: { format: format === 'mspdi' ? 'MSPDI' : format.toUpperCase() }, + dedupeKey: `xer-export-loss:${state.activeDocumentId}:${format}`, + helpArticleId: XER_IMPORT_HELP_ARTICLE_ID, + }); + } + return { ok: true, warnings }; }, exportProjectWithPool: async (): Promise => { @@ -455,16 +657,16 @@ export const createFileSlice: AppSliceFactory = (runtime) => (set, ge const projectContent = writeIFC(buildWriteIFCInput(state)); const base = projectFileBase(state.project.name); const outcome = await saveFileDialog(`${base}.ifc`, projectContent, [{ name: 'IFC Files', extensions: ['ifc'] }]); - if (!outcome) return { ok: true }; // dialoog geannuleerd — geen fout + if (!outcome) return { ok: true, warnings: [] }; // dialoog geannuleerd — geen fout await pushRecent(outcome.ref, outcome.name); noticeIfDownloaded(outcome); // 2. De pool ernaast (los bestand), alleen als het project aan een bedrijf gebonden is. const companyId = state.project.companyId; - if (!companyId) return { ok: true }; + if (!companyId) return { ok: true, warnings: [] }; const poolContent = state.exportPoolIFC(companyId); - if (!poolContent) return { ok: true }; + if (!poolContent) return { ok: true, warnings: [] }; noticeIfDownloaded(await saveFileDialog(`${base}-bibliotheek.ifc`, poolContent, [{ name: 'IFC Files', extensions: ['ifc'] }])); - return { ok: true }; + return { ok: true, warnings: [] }; }, recentFiles: [], @@ -479,7 +681,7 @@ export const createFileSlice: AppSliceFactory = (runtime) => (set, ge try { const { readTextFile, readFile } = await import('@tauri-apps/plugin-fs'); const input = await readFormatInput(filePath, { readTextFile, readFile }); - const parsed = await parseOpenedFile(input, labels); + const parsed = activeImportResult(await parseOpenedFile(input, labels)); return { projectId: parsed.project.id, projectName: parsed.project.name, @@ -653,14 +855,12 @@ export const createFileSlice: AppSliceFactory = (runtime) => (set, ge labels, ); - if (!isActivePristine(get())) get().newDocument(); - // Opslagdoel-guard (T8, stap 5a; verbreed T8-spec-review F4; T11: `saveTargetFor` — zie // openFile voor de volledige toelichting). `readFormat` is hierboven al opgezocht (F2). const target = saveTargetFor(readFormat, entry.ref, entry.name); - // Zelfde open-pad-semantiek als openFile (zie daar); loopt door de gedeelde implementatie. - get().applyLoadedProject(parsed, { + // Zelfde open-pad-semantiek als openFile (zie daar), ook voor meervoudige XER-import. + get().applyOpenedImport(parsed, { filePath: target.filePath, fileHandle: target.fileHandle, recompute: true, @@ -678,9 +878,9 @@ export const createFileSlice: AppSliceFactory = (runtime) => (set, ge } }, - openExampleFromString: (content: string, name: string, labels) => { + openExampleFromString: async (content: string, name: string, labels) => { try { - const parsed = readIFC(content, labels); + const parsed = await readIFCWithXerReconstruction(content, labels); // Zelfde multi-document-gedrag als openFile: hergebruik het actieve // tabblad alleen als dat nog leeg en ongewijzigd is, anders nieuw tabblad. diff --git a/src/state/slices/projectSlice.ts b/src/state/slices/projectSlice.ts index 295c373c7..9bf9f3273 100644 --- a/src/state/slices/projectSlice.ts +++ b/src/state/slices/projectSlice.ts @@ -25,6 +25,9 @@ import { emitExtensionEvent, HOST_EVENTS } from '@/services/extensionEvents'; import { clearTimephasedLossNoticeForDoc } from '../timephasedLossNotice'; import type { AppSliceFactory } from './types'; import { deriveHoursPerDay } from '@/services/subdayIo'; +import { isLeafTask } from '@/utils/taskHierarchy'; +import type { XerImportMetadata } from '@/services/importTypes'; +import type { XerSourceArchive } from '@/services/xerSourceArchive'; // K-item 27: de fabriek woont in de bladmodule `../defaults` (breekt de import-cyclus met // documentContract/snapshot). Hier alleen doorgegeven, zodat bestaande importers ongemoeid blijven. import { createDefaultProject } from '../defaults'; @@ -84,6 +87,10 @@ export interface ProjectSlice { fileHandle: FileSystemFileHandle | null; /** Persoonlijke sessiekeuze voor echte bestands-AutoSave; per document via DOCUMENT_FIELDS. */ autoSaveToFile: boolean; + /** XER-herkomst van het actieve document; externe relaties blijven solverloze brondata. */ + xerImportMetadata: XerImportMetadata | null; + xerSourceArchive: XerSourceArchive | null; + xerSourceProjectId: string | null; setProject: (project: Partial) => void; /** Zet WBS-autonummering aan/uit; bij aanzetten wordt de hele boom direct hernummerd. */ setWbsAutoNumber: (on: boolean) => void; @@ -170,6 +177,9 @@ export const createProjectSlice: AppSliceFactory = (runtime) => (s filePath: null, fileHandle: null, autoSaveToFile: false, + xerImportMetadata: null, + xerSourceArchive: null, + xerSourceProjectId: null, setProject: (updates) => { // T7b (plan-§9/O2-vervolg, orkestratorbesluit 2026-08-15 — optie B, ná escalatie T7 + de @@ -362,8 +372,10 @@ export const createProjectSlice: AppSliceFactory = (runtime) => (s // wijzigt niet tussen de "voor"- en "na"-solve hieronder (alleen datums schuiven), dus één // expansie op `s.tasks` volstaat voor beide takken. const { sequences: expandedSequences } = expandSummaryRelations(s.tasks, s.sequences); - const solve = (tasks: Task[], dataDate: string | undefined, projectStartDate: string): CPMResult => { - const leaf = tasks.filter((t) => t.childIds.length === 0); + const solve = ( + tasks: Task[], dataDate: string | undefined, projectStartDate: string, projectEndDate: string, + ): CPMResult => { + const leaf = tasks.filter(isLeafTask); return new CPMSolver(leaf, expandedSequences, s.calendar, s.calendars, { dataDate, progressMode: s.project.progressMode, @@ -372,6 +384,7 @@ export const createProjectSlice: AppSliceFactory = (runtime) => (s // de HUIDIGE projectstart, de "na"-solve tegen de NIEUWE — anders zou deze preview een // wortel-taak vóór zijn eigen projectbegin kunnen tonen. projectStartDate, + projectEndDate, }).solve(); }; @@ -399,11 +412,14 @@ export const createProjectSlice: AppSliceFactory = (runtime) => (s } const fresh = s.cpmResult && !s.cpmResult.error && !s.scheduleStale ? s.cpmResult : null; - const before = fresh ?? solve(s.tasks.map((t) => shiftTask(t, 0)), s.project.statusDate, s.project.startDate); + const before = fresh ?? solve( + s.tasks.map((t) => shiftTask(t, 0)), s.project.statusDate, s.project.startDate, s.project.endDate, + ); const after = solve( s.tasks.map((t) => shiftTask(t, delta)), shiftIso(s.project.statusDate, delta), newStartDate, + shiftIso(s.project.endDate, delta) || s.project.endDate, ); if (after.error) return { ...empty, error: after.error }; diff --git a/src/state/slices/resourceSlice.ts b/src/state/slices/resourceSlice.ts index ed9a86a3b..4961ab61e 100644 --- a/src/state/slices/resourceSlice.ts +++ b/src/state/slices/resourceSlice.ts @@ -8,6 +8,7 @@ import { syncProjectCalendar } from '../syncProjectCalendar'; import { clearTimephasedWindow, clearTimephasedDurationWalks } from '@/utils/taskDefaults'; import { notifyTimephasedLoss } from '../timephasedLossNotice'; import type { AppSliceFactory } from './types'; +import { isSummaryTask } from '@/utils/taskHierarchy'; /** Puur leesbaarheids-alias: `WorkCalendar` heeft al `id`/`name`, dus geen aparte intersectie * nodig — een resource-kalender IS gewoon een `WorkCalendar` (zie fase 2.5-ontwerp §3.1). */ @@ -127,7 +128,7 @@ export const createResourceSlice: AppSliceFactory = (runtime) => set((s) => { // Leaf-only, geen-milestone-assignment-regel (§2.4): vroege return, geen snapshot. const task = s.tasks.find(t => t.id === taskId); - if (!task || task.isMilestone || task.childIds.length > 0) return; + if (!task || task.isMilestone || isSummaryTask(task)) return; // Onbekend (of null/undefined — JS-callers via de dev-bridge omzeilen de compiler) // resourceId: stil weigeren, geen snapshot (M6-conventie, zoals removeResource). Een // toewijzing zonder bestaande resource vergiftigt anders élke writeIFC/auto-save @@ -255,7 +256,7 @@ export const createResourceSlice: AppSliceFactory = (runtime) => if (!assignment) return; const newTask = s.tasks.find(t => t.id === newTaskId); // Zelfde milestone/summary-guard als assignResource (§2.4) — geen toewijzing op zulke taken. - if (!newTask || newTask.isMilestone || newTask.childIds.length > 0) return; + if (!newTask || newTask.isMilestone || isSummaryTask(newTask)) return; // Weiger dubbele resource-op-taak (dekt ook het degenererende geval newTaskId === oude taskId: // de bestaande toewijzing zelf telt al mee als "al op de doeltaak"). const alreadyOnTarget = s.assignments.some( diff --git a/src/state/slices/scheduleSlice.ts b/src/state/slices/scheduleSlice.ts index e11a3879f..43b8191cf 100644 --- a/src/state/slices/scheduleSlice.ts +++ b/src/state/slices/scheduleSlice.ts @@ -1,5 +1,5 @@ import type { CPMResult } from '@/engine/scheduler/CPMSolver'; -import { cpmResultFromRecorded, type RecordedDatesState } from '@/engine/scheduler/recordedDates'; +import { applyRecordedTimesToTasks, type RecordedDatesState } from '@/engine/scheduler/recordedDates'; import { solveProject } from '@/engine/scheduler/solveProject'; import { expandSummaryRelations } from '@/engine/scheduler/expandSummaryRelations'; import { computeReliableResourceLoad, type ResourceLoadResult } from '@/engine/scheduler/ResourceLoad'; @@ -12,6 +12,7 @@ import { markScheduleStale } from '../transaction'; import { emitExtensionEvent, HOST_EVENTS } from '@/services/extensionEvents'; import { notifyLevelingDelayRounded } from '../timephasedLossNotice'; import type { AppSliceFactory } from './types'; +import { isLeafTask } from '@/utils/taskHierarchy'; export interface ScheduleSlice { cpmResult: CPMResult | null; @@ -124,6 +125,7 @@ export const createScheduleSlice: AppSliceFactory = (runtime) => // CPMSolver) — zonder deze optie kon een taak met een verouderde `scheduleStart` (bv. gezet // vóór een latere wijziging van de projectstartdatum) gewoon vóór het projectbegin doorlopen. projectStartDate: s.project.startDate, + projectEndDate: s.project.endDate, }); // If circular dependency detected, store the result (with error) and bail @@ -182,25 +184,10 @@ export const createScheduleSlice: AppSliceFactory = (runtime) => if (!info || s.datesAsRecorded) return; // no-op ⇒ géén snapshot (transaction.ts-patroon) runtime.beginUndoable(s); - for (const task of s.tasks) { - const rec = info.times[task.id]; - if (!rec) continue; - task.time.earlyStart = rec.start; - task.time.earlyFinish = rec.finish; - task.time.lateStart = rec.lateStart ?? rec.start; - task.time.lateFinish = rec.lateFinish ?? rec.finish; - task.time.totalFloat = rec.totalFloat ?? 0; - task.time.freeFloat = rec.freeFloat ?? 0; - task.time.isCritical = rec.isCritical ?? false; - // De analyse-afleidingen komen uit de zojuist weggegooide solve en zouden een planning - // beschrijven die niet meer op het scherm staat. `applyCpmResult` hanteert dezelfde regel - // voor uitgezette opties: afwezig ⇒ het veld wordt gewist. - task.time.interferingFloat = undefined; - task.time.isNearCritical = undefined; - task.time.floatPath = undefined; - } - - s.cpmResult = cpmResultFromRecorded(info.times, s.tasks, s.calendar); + // Gedeelde kern (XER-etappeplan §3.4, taak T3): schrijft de vastlegging in de taken en levert + // meteen het gereconstrueerde `CPMResult` — dezelfde functie die de standaard-aan-laadroute + // (taak T4) op een payload-kloon zal gebruiken. Geen gedragswijziging t.o.v. vóór de extractie. + s.cpmResult = applyRecordedTimesToTasks(s.tasks, info.times, s.calendar); s.resourceLoadResult = computeReliableResourceLoad( s.cpmResult, s.resources, s.assignments, s.tasks, s.calendar, s.calendars, ); @@ -240,7 +227,7 @@ export const createScheduleSlice: AppSliceFactory = (runtime) => return { delays: {}, unresolved: {}, unresolvedReasons: {}, shifts: {}, projectEndBefore: end, projectEndAfter: end, gaps: {} }; } // De leveler werkt op leaf-taken (net als de CPM-pass in runCPM). - const leafTasks = s.tasks.filter((t) => t.childIds.length === 0); + const leafTasks = s.tasks.filter(isLeafTask); // Zelfde samenvattingsrelatie-propagatie als runCPM (zie daar): `ResourceLeveler` krijgt hier // alleen bladtaken door, dus de expansie moet vóór het leaf-filter gebeuren, met de VOLLEDIGE // taakboom (parentId/childIds) als bron — `ResourceLeveler` zelf blijft ongewijzigd, die kent @@ -258,6 +245,7 @@ export const createScheduleSlice: AppSliceFactory = (runtime) => // Zelfde projectstart-vloer als runCPM hierboven (gebruikstest-bevinding 2026-08) — anders // zou de nivelleerder een wortel-taak vóór het projectbegin kunnen laten staan. projectStartDate: s.project.startDate, + projectEndDate: s.project.endDate, }, ); }, diff --git a/src/state/slices/taskSlice.ts b/src/state/slices/taskSlice.ts index a52dfad59..d39250cad 100644 --- a/src/state/slices/taskSlice.ts +++ b/src/state/slices/taskSlice.ts @@ -6,6 +6,7 @@ import { } from '@/utils/taskDefaults'; import { generateId } from '@/utils/id'; import { formatDate } from '@/utils/dateUtils'; +import { reconcileP6SuspendResume } from '@/utils/p6SuspendResume'; import { deriveWbsCodes, applyWbsNumbering, flattenOrder } from '@/utils/wbs'; import { applyProgressInvariants, @@ -303,6 +304,7 @@ export const createTaskSlice: AppSliceFactory = (runtime) => (set, ge priority: partial.priority ?? 500, parentId, childIds: [], + isSummary: partial.isSummary, // T14b (gebruikstestbevinding, ernst hoog — dataverlies): een meegegeven `partial.time` wordt // veld-voor-veld gemerged met de verse default i.p.v. ongewijzigd overgenomen — anders bleef // een ontbrekend veld (bv. `completion`) `undefined` tot writeIFC crashte op @@ -404,6 +406,7 @@ export const createTaskSlice: AppSliceFactory = (runtime) => (set, ge // proportioneel — de verdeling reist mee met de bewerking i.p.v. te verouderen. Zie // `taskDefaults.ts`'s `rescaleTaskContours`. Kalender-/datumwijzigingen raken de as niet. if (timeUpdateTouchesTimephasedWindow(time)) rescaleTaskContours(s.tasks[idx], oldWorkMinutes, contourHpd); + reconcileP6SuspendResume(s.tasks[idx]); // Z14b (eigenaarsprincipe 2026-08-18) — een inhoudelijke bewerking (duur/datums/kalender) // ontkoppelt het GELEZEN Z8-venster van de motor; de rauwe bron (`timephasedContours`) blijft // staan. Zie `taskDefaults.ts`'s `clearTimephasedWindow`/`timeUpdateTouchesTimephasedWindow` diff --git a/src/state/slices/types.ts b/src/state/slices/types.ts index 30cd8deb2..6953bf983 100644 --- a/src/state/slices/types.ts +++ b/src/state/slices/types.ts @@ -195,6 +195,42 @@ export type NotificationMessageKey = | 'notifications.relationsSkippedOnInsert' | 'notifications.mppLegacy' | 'notifications.mppEncrypted' + | 'notifications.xerInvalidInput' + | 'notifications.xerInvalidFile' + | 'notifications.xerInvalidEncoding' + | 'notifications.xerDuplicateTable' + | 'notifications.xerMissingRequiredColumns' + | 'notifications.xerMissingRequiredValue' + | 'notifications.xerAmbiguousDecimal' + | 'notifications.xerInvalidNumberFormat' + | 'notifications.xerInvalidNumber' + | 'notifications.xerSingleProjectRequired' + | 'notifications.xerEmptyProject' + | 'notifications.xerDuplicateId' + | 'notifications.xerAmbiguousLocalRelation' + | 'notifications.xerDanglingLocalRelation' + | 'notifications.xerEnumFallback' + | 'notifications.xerImportOpened' + | 'notifications.xerImportProjectsSeen' + | 'notifications.xerImportEmptyProjectsSkipped' + | 'notifications.xerImportBaselineProjectsExcluded' + | 'notifications.xerImportBaselinesMaterialized' + | 'notifications.xerImportDanglingBaselineReferences' + | 'notifications.xerImportBaselineFallback' + | 'notifications.xerImportExternalLinks' + | 'notifications.xerImportEncoding' + | 'notifications.xerImportParserIssues' + | 'notifications.xerImportCalendarIssues' + | 'notifications.xerImportNumberIssues' + | 'notifications.xerImportEnumFallbacks' + | 'notifications.xerImportUnsupportedSemantics' + // XER-etappeplan §3.7 (taak T4, X-O7 laag 3): "datums zoals opgeslagen" staat standaard aan zodra + // een geopend XER-document restverschillen heeft. Meervoud, `count` = som van + // `recordedDates.shifted` over alle documenten van dit bestand (één regel, ook bij twaalf + // projecten — zie `xerImportNotice`/`applyOpenedImport`). + | 'notifications.xerImportDatesAsRecorded' + | 'notifications.xerImportDatesAsRecordedOffer' + | 'notifications.xerExportLoss' | 'notifications.mppSourceScheduleNotes' | 'notifications.projectStartAnchorsClamped' | 'notifications.mppTimephasedSteeringLost' @@ -204,6 +240,13 @@ export type NotificationMessageKey = // hele werkdagen — zie `src/state/timephasedLossNotice.ts`s `notifyLevelingDelayRounded`. | 'notifications.levelingDelayRoundedToWorkdays'; +/** Een vertaalde detailregel onder een toast. Anders dan `detail` is deze tekst altijd + * gebruikerszichtbaar en dus via dezelfde gesloten sleutelunie en i18n-keten getypeerd. */ +export interface NotificationDetailLine { + messageKey: NotificationMessageKey; + params?: Record; +} + export interface AppNotification { /** Stabiele id — uitsluitend voor de React-key en voor `dismissNotification`. */ id: string; @@ -214,6 +257,8 @@ export interface AppNotification { params?: Record; /** Rauwe technische tekst (`err.message`) — BEWUST onvertaald. */ detail?: string; + /** Optionele, vertaalde feiten onder de hoofdboodschap (X10: één XER-bestandsverslag). */ + detailLines?: NotificationDetailLine[]; /** Samenvouw-sleutel: een tweede melding met dezelfde sleutel wordt één regel met een teller. */ dedupeKey?: string; /** Aantal samengevouwen voorkomens; 1 bij de eerste. */ diff --git a/src/state/slices/uiSlice.ts b/src/state/slices/uiSlice.ts index c6b437f62..69fdda263 100644 --- a/src/state/slices/uiSlice.ts +++ b/src/state/slices/uiSlice.ts @@ -4,6 +4,7 @@ import { MCP_DEFAULT_PORT, peekTheme } from '@/utils/settingsStore'; import { detectSystemPrefersDark } from '@/utils/theme'; import { DEFAULT_BAR_COLOR_SELECTION } from '@/types/barColor'; import { maxGanttZoom } from '@/engine/renderer/timelineTiers'; +import { isSummaryTask } from '@/utils/taskHierarchy'; export interface UiSlice { ui: UIState; @@ -303,11 +304,20 @@ export const createUiSlice: AppSlice = (set, get) => ({ existing.messageKey = n.messageKey; existing.params = n.params; existing.detail = n.detail; + // Optionele X10-uitbreidingen alleen aanraken wanneer de nieuwe melding ze werkelijk + // meebrengt. Zo blijven alle historische meldingen byte-identiek bij een dedupe-update. + if (n.detailLines !== undefined) existing.detailLines = [...n.detailLines]; + if (n.helpArticleId !== undefined) existing.helpArticleId = n.helpArticleId; return; } } // 2. Nieuwe melding onderaan toevoegen. - s.ui.notifications.push({ ...n, id: `n${++notificationSeq}`, count: 1 }); + s.ui.notifications.push({ + ...n, + ...(n.detailLines !== undefined ? { detailLines: [...n.detailLines] } : {}), + id: `n${++notificationSeq}`, + count: 1, + }); // 3. Begrens op MAX_NOTIFICATIONS. Bij overschrijding verwijderen we bij VOORKEUR de oudste // `info`, en pas als die er niet is de oudste melding overall — een fout mag nooit door een // info verdrongen worden (de cyclus-/opslaafout is juist degene die moet blijven staan). @@ -345,7 +355,7 @@ export const createUiSlice: AppSlice = (set, get) => ({ collapseTasks: (taskIds) => { set((s) => { - const summaryIds = s.tasks.filter((t) => t.childIds.length > 0).map((t) => t.id); + const summaryIds = s.tasks.filter(isSummaryTask).map((t) => t.id); if (!taskIds || taskIds.length === 0) { // Geen doellijst ⇒ alles. Bewust een VERVANGING (niet toevoegen): zo verdwijnen meteen ook // ids van taken die inmiddels geen summary meer zijn. @@ -365,7 +375,7 @@ export const createUiSlice: AppSlice = (set, get) => ({ expandTasks: (taskIds) => { set((s) => { - const summaryIds = new Set(s.tasks.filter((t) => t.childIds.length > 0).map((t) => t.id)); + const summaryIds = new Set(s.tasks.filter(isSummaryTask).map((t) => t.id)); // Doellijst beperken tot echte summary-taken; zonder lijst gelden ze allemaal. const targets = !taskIds || taskIds.length === 0 ? summaryIds diff --git a/src/types/baseline.ts b/src/types/baseline.ts index 9135ab635..665d2d1f7 100644 --- a/src/types/baseline.ts +++ b/src/types/baseline.ts @@ -4,6 +4,10 @@ import type { MilestoneKind } from './task'; * de IFC-GUID via ifcGuid(task.id)) zodat matching over hernoemingen heen werkt. */ export interface BaselineTask { taskId: string; + /** Oorspronkelijke bronidentiteit als deze baseline uit een extern bronproject is opgebouwd. + * `taskId` blijft bewust de koppeling naar de huidige OPS-taak voor varianceweergave. */ + sourceTaskId?: string; + sourceTaskCode?: string; start: string; // ISO 8601 — snapshot van task.time.earlyStart t.t.v. opslaan (fallback: scheduleStart) finish: string; // ISO 8601 — snapshot van task.time.earlyFinish (fallback: scheduleFinish) duration: number; // werkdagen (task.time.scheduleDuration) @@ -24,6 +28,8 @@ export type BaselineOverlay = Map; /** Een P6-stijl baseline: onbeperkt aantal; precies één is "actief" (activeBaselineId in de slice). */ export interface Baseline { id: string; + /** Projectidentiteit van een gematerialiseerd extern baselineproject (onder meer XER X4b). */ + sourceProjectId?: string; name: string; createdAt: string; // ISO datetime — de snapshot-datum (ook getoond in het rapport) tasks: BaselineTask[]; // keyed op taskId diff --git a/src/types/calendar.ts b/src/types/calendar.ts index e62a4ccda..9492429ae 100644 --- a/src/types/calendar.ts +++ b/src/types/calendar.ts @@ -1,6 +1,10 @@ import type { HolidayCountry } from '@/engine/calendar/holidays'; import type { LibraryOrigin } from '@/types/library'; +/** Diagnose van de P6/XER-penalty-pset na IFC-inlezen. `REJECTED` en `ABSENT` maken expliciet + * dat P6-kalendersemantiek NIET is geactiveerd; alleen de twee geldige staten dragen `p6Source`. */ +export type P6NonWorkPenaltyDatesState = 'ABSENT' | 'VALID_EMPTY' | 'VALID_VALUES' | 'REJECTED'; + export interface WorkCalendar { id: string; name: string; @@ -36,6 +40,27 @@ export interface WorkCalendar { * dag- of uur-lus raakt dit veld. INVARIANT (afgedwongen door de parser, T3/T4, niet hier): een datum * komt nooit tegelijk in `holidays` én in `workingExceptions` voor. */ workingExceptions?: WorkingException[]; + /** + * Expliciete herkomst van de P6-projectielaag. Alleen de XER-reader zet `XER`; IFC mag dit + * uitsluitend als round-tripmetadata van zo'n import bewaren. Zonder deze stempel zijn alle + * kalenderprimitieven en solverpaden gewone OPS-/formaatneutrale kalendersemantiek. + */ + p6Source?: 'XER'; + /** + * BRONDIAGNOSE, GEEN REKENINVOER (sinds etappe 7b-2). De XER-lezer noteert hier de redundante + * vrije-dagrecords die hij NIET heeft kunnen verklaren: een vrije uitzondering op een al + * niet-werkende weekdag, of een direct aangrenzende herhaling van dezelfde vrije datum. + * + * Tot 7b-2 liet `CalendarEngine` zo'n record als één extra niet-werkdag meewegen in uurduur- en + * floatwandelingen. Die projectie is verwijderd: gemeten over alle zes penaltydragende + * corpusprojecten verklaarde ze nul cellen op alle zes de fidelity-assen, en waar ze wél iets + * deed (kalender 842/896 in `rehab-2.xer`) is de werkelijk bedoelde vrije dag inmiddels + * gereconstrueerd (`weekendClampTarget` in `xerCalendarData.ts`). Geen enkele solverpad leest dit + * veld nog; het round-trippt uitsluitend door het IFC zodat de brondiagnose bewaard blijft. + */ + p6NonWorkPenaltyDates?: string[]; + /** IFC-round-tripdiagnose voor de complete penaltylijst. Afwezig = geen P6-penaltypset gezien. */ + p6NonWorkPenaltyDatesState?: P6NonWorkPenaltyDatesState; } /** Dag-uitzondering die werktijd TOEVOEGT/AANPAST op een anders niet-werkende dag (fase 3.8, T2; diff --git a/src/types/project.ts b/src/types/project.ts index 02b00486d..0874c3eb5 100644 --- a/src/types/project.ts +++ b/src/types/project.ts @@ -1,98 +1,191 @@ -/** Voortgangs-scheduling-modus (P6, fase 2.6). undefined ⇒ RETAINED_LOGIC (de default). */ -export type ProgressMode = 'RETAINED_LOGIC' | 'PROGRESS_OVERRIDE'; - -/** - * Project-scoped reken-opties (fase 2.9, §3.4/§7). ELKE default = het huidige gedrag ⇒ een afwezig - * (of leeg) blok is byte-identiek aan vóór 2.9. Deze opties horen bij het BESTAND (net als - * statusDate/progressMode), niet bij de app-settings — anders zou hetzelfde bestand op twee machines - * een ander schema geven (§7). De solver leest ze via `CPMOptions.schedulingOptions`. - */ -export interface SchedulingOptions { - /** Kalender voor relatie-lag (P6 4-way, Rapport B §7.1). Default 'predecessor' = de huidige - * LAG_CALENDAR-constante (lagCalendar.ts) ⇒ byte-identiek. */ - lagCalendar?: 'predecessor' | 'successor' | '24hour' | 'projectDefault'; - /** Kritiek-definitie. Default { mode:'totalFloat', threshold:0 } = het huidige tf≤0. */ - criticalDefinition?: { mode: 'totalFloat' | 'longestPath'; threshold?: number }; // threshold mag negatief (P6) - /** TF-berekeningswijze. Default 'smallest' = de huidige min(finish,start)-float. */ - totalFloatMode?: 'start' | 'finish' | 'smallest'; - /** Open-ended taken kritiek? Default = huidig gedrag (een eindtaak krijgt tf via LF−EF). */ - makeOpenEndedCritical?: boolean; - /** Near-critical-drempel in werkdagen (fractioneel in uur-modus). Default undefined ⇒ feature uit. */ - nearCriticalThreshold?: number; - /** Multiple float paths. Default undefined ⇒ uit (byte-identiek). */ - floatPaths?: { enabled: boolean; method: 'FREE_FLOAT' | 'TOTAL_FLOAT'; maxPaths: number }; - /** T9 (voortgangsafronding, MSP-pariteit): MS Project hervat het restwerk van een IN PROGRESS- - * taak NIET op `max(statusDate, voorganger-druk)` (P6's eigen RETAINED_LOGIC-conventie, de - * huidige/default-berekening — zie `CPMSolver.ts`'s voortgangstak) maar op `actualStart + - * reeds-verstreken-duur` (`scheduleDuration/durationMinutes − remaining`, doorgesnapt via - * dezelfde werk-optelling als het restwerk zelf) — als DERDE, uitsluitend VERHOGENDE vloer naast - * de bestaande twee. Default `undefined`/`false` ⇒ het bestaande, P6-getrouwe gedrag, byte- - * identiek (bewaakt door `cases-progress.json`'s Scenario A/B/C, die P6's eigen gedocumenteerde - * RETAINED_LOGIC-semantiek toetsen — dít veld mag die niet stilzwijgend wijzigen). Uitsluitend - * `true` gezet door `mppReader.ts` (élke `.mpp`-import) — MSPDI/P6/CSV/IFC blijven op de - * bestaande P6-semantiek tot een eigen, apart gemeten reden om ze ook om te zetten. */ - resumeFromActualElapsed?: boolean; - /** B1 (eindreview T16c, dossier (c)4-herdiagnose): MS Project verschuift een NIET-GESTARTE taak - * (`completion === 0`) NIET automatisch naar op-of-ná de statusdatum — dat "NIET GESTART: - * statusdatum als ondergrens"-gedrag (`CPMSolver.ts`'s forward-pass, vlak vóór - * `addDurationChecked`) is P6-eigen RETAINED_LOGIC-semantiek, net als `resumeFromActualElapsed` - * hierboven een P6-conventie vervangt voor de VOORTGANG-tak. Bewijs (reviewer-meting, - * `calendar-exception-precedence.mpp`, publiek MPXJ-fixture): mét de vloer wijkt de taak ~8 jaar - * af van MS Project se eigen opgeslagen Start/Finish (de statusdatum, 2023-05-01, ligt ver ná de - * taak se eigen 2015-10-01-anker); zónder de vloer is het resultaat minuut-exact. Een EIGEN, - * sibling-vlag i.p.v. hergebruik van `resumeFromActualElapsed` zelf: die twee bestrijken - * disjuncte taak-populaties (`completion > 0` resp. `completion === 0`) en een taak kan hooguit - * in één van beide vallen — samenvoegen tot één vlag zou geen enkel gedrag delen, alleen de - * "MSP-voortgangsconventie i.p.v. P6-RETAINED_LOGIC"-FAMILIE is gedeeld. Default `undefined`/ - * `false` ⇒ het bestaande, P6-getrouwe gedrag, byte-identiek (bewaakt door dezelfde - * `cases-progress.json`-scenario's als `resumeFromActualElapsed`). Uitsluitend `true` gezet door - * `mppReader.ts` (élke `.mpp`-import); MSPDI/P6/CSV/IFC blijven op de bestaande P6-semantiek — - * het P6-pad (`progressMode`/statusdatum-gedreven planningen) behoudt de vloer bewust: dat is - * precies de RETAINED_LOGIC-conventie die P6 zélf documenteert. */ - unstartedIgnoresStatusDate?: boolean; -} - +/** Voortgangs-scheduling-modus (P6, fase 2.6). undefined ⇒ RETAINED_LOGIC (de default). */ +export type ProgressMode = 'RETAINED_LOGIC' | 'PROGRESS_OVERRIDE'; + +/** + * Project-scoped reken-opties (fase 2.9, §3.4/§7). ELKE default = het huidige gedrag ⇒ een afwezig + * (of leeg) blok is byte-identiek aan vóór 2.9. Deze opties horen bij het BESTAND (net als + * statusDate/progressMode), niet bij de app-settings — anders zou hetzelfde bestand op twee machines + * een ander schema geven (§7). De solver leest ze via `CPMOptions.schedulingOptions`. + */ +export interface SchedulingOptions { + /** Expliciete provenance voor P6-XER-projectieregels. Alleen de XER-reader zet deze waarde; + * IFC bewaart haar uitsluitend om een XER-import semantisch gelijk te round-trippen. */ + p6Source?: 'XER'; + /** Kalender voor relatie-lag (P6 4-way, Rapport B §7.1). Default 'predecessor' ⇒ byte-identiek + * met de oude vaste voorgangerskalender. LET OP (XER-etappe X5, eindreview bevinding 5): tot + * september 2026 was dit een dode instelling — de UI bood de keuze en het IFC bewaarde haar, maar + * de solver las een constante. Sinds `relDeps.lagEngine` (`CPMSolver.ts`) is ze effectief, voor + * ELK formaat en niet alleen achter `p6Source`: een bestaand document waarin ooit 'successor', + * '24hour' of 'projectDefault' is gekozen, plant sindsdien naar die keuze. Eigenaarsbesluit, + * plan §10.f. */ + lagCalendar?: 'predecessor' | 'successor' | '24hour' | 'projectDefault'; + /** Kritiek-definitie. `threshold` is de bestaande grens in taakdagen; `thresholdHours` bewaart + * een P6/XER-grens in bronuren en wordt per taak tegen floaturen op diens effectieve kalender + * vergeleken. Bij beide wint `thresholdHours`. Beide grenzen mogen negatief zijn. */ + criticalDefinition?: { + mode: 'totalFloat' | 'longestPath'; + threshold?: number; + thresholdHours?: number; + }; + /** TF-berekeningswijze. Default 'smallest' = de huidige min(finish,start)-float. */ + totalFloatMode?: 'start' | 'finish' | 'smallest'; + /** Open-ended taken kritiek? Default = huidig gedrag (een eindtaak krijgt tf via LF−EF). */ + makeOpenEndedCritical?: boolean; + /** P6/XER-bronsignaal: verwachte einddatums mogen de resterende duur begrenzen. X7 consumeert + * dit pas samen met het taakveld; bewaren voorkomt dat de SCHEDOPTIONS-keuze verloren gaat. */ + useExpectedFinishDates?: boolean; + /** P6/XER-bronsemantiek voor de late zijde bij voortgang: een gestart activiteit houdt zijn + * geregistreerde start als LS, een voltooide activiteit houdt haar actual-venster als LS/LF, + * en zo'n voltooide opvolger trekt een nog open voorganger niet historisch terug. Afwezig/false + * bewaart de bestaande niet-XER-semantiek. */ + preserveActualDatesInBackwardPass?: boolean; + /** P6/XER rapporteert vrije float niet negatief: bij een onhaalbare late constraint blijft TF + * negatief maar wordt FF nul. Afwezig/false bewaart de algemene getekende OPS-semantiek. */ + clampNegativeFreeFloat?: boolean; + /** P6/XER gebruikt voor een nulduurmijlpaal de in TASK geplande kalendergrens: een geplande + * dagstart blijft startmijlpaal, een gepland bandeinde mag op de voorgangerfinish landen. + * Afwezig/false houdt de algemene/MSP-conventie byte-identiek. */ + p6ZeroDurationUsesPlannedBoundary?: boolean; + /** XER TASK.target_start_date kan bij P6 naast het netwerk een geplande vloer vertegenwoordigen, + * maar alleen wanneer start én finish meer dan één kalenderdag later liggen dan de berekende + * netwerkgrens. Een gewone volgende-bandstart is geen vrije planning en activeert deze regel + * niet. Alleen XER zet de vlag; andere bronnen houden de generieke netwerksemantiek. */ + p6UseTaskPlannedStartFloor?: boolean; + /** P6 kan een TT_FinMile als twee aangrenzende kalendergrenzen opslaan (ES op bandstart, + * EF op het vorige bandeinde). Alleen XER activeert deze representatie. */ + p6FinishMilestoneBoundaryWindow?: boolean; + /** XER/P6: geregistreerde actual start/finish zijn broninstants en worden niet naar een + * kalenderband genormaliseerd. Default afwezig houdt de bestaande formaatsemantiek. */ + p6PreserveActualInstants?: boolean; + /** XER/P6 bewaart Actual Start als historie, maar de zesassige Early/Late Start van een lopende + * activiteit beschrijft de start van het resterende werk (`max(statusdatum, relatiegrens)`). + * Alleen het XER-importpad zet deze bronvlag; andere formaten blijven hun bestaande zichtbare + * actual-startvenster gebruiken. */ + p6UseRemainingStartForProgress?: boolean; + /** XER/P6: een datetime-SNLT/MSO/FNLT/MFO op een nulduurmijlpaal is een exact bronpunt, + * ook wanneer dat punt de inclusieve start van een werkband is. Default uit. */ + p6PreserveZeroDurationConstraintInstants?: boolean; + /** XER/P6: gebruik PROJECT.plan_end_date als late-pass-anker wanneer + * SCHEDOPTIONS.sched_use_project_end_date_for_float=Y. De datum zelf blijft project.endDate; + * deze vlag bepaalt uitsluitend of de solver hem voor float gebruikt. Default uit. */ + useProjectEndDateForFloat?: boolean; + /** Near-critical-drempel in werkdagen (fractioneel in uur-modus). Default undefined ⇒ feature uit. */ + nearCriticalThreshold?: number; + /** Multiple float paths. Default undefined ⇒ uit (byte-identiek). */ + floatPaths?: { enabled: boolean; method: 'FREE_FLOAT' | 'TOTAL_FLOAT'; maxPaths: number }; + /** T9 (voortgangsafronding, MSP-pariteit): MS Project hervat het restwerk van een IN PROGRESS- + * taak NIET op `max(statusDate, voorganger-druk)` (P6's eigen RETAINED_LOGIC-conventie, de + * huidige/default-berekening — zie `CPMSolver.ts`'s voortgangstak) maar op `actualStart + + * reeds-verstreken-duur` (`scheduleDuration/durationMinutes − remaining`, doorgesnapt via + * dezelfde werk-optelling als het restwerk zelf) — als DERDE, uitsluitend VERHOGENDE vloer naast + * de bestaande twee. Default `undefined`/`false` ⇒ het bestaande, P6-getrouwe gedrag, byte- + * identiek (bewaakt door `cases-progress.json`'s Scenario A/B/C, die P6's eigen gedocumenteerde + * RETAINED_LOGIC-semantiek toetsen — dít veld mag die niet stilzwijgend wijzigen). Uitsluitend + * `true` gezet door `mppReader.ts` (élke `.mpp`-import) — MSPDI/P6/CSV/IFC blijven op de + * bestaande P6-semantiek tot een eigen, apart gemeten reden om ze ook om te zetten. */ + resumeFromActualElapsed?: boolean; + /** B1 (eindreview T16c, dossier (c)4-herdiagnose): MS Project verschuift een NIET-GESTARTE taak + * (`completion === 0`) NIET automatisch naar op-of-ná de statusdatum — dat "NIET GESTART: + * statusdatum als ondergrens"-gedrag (`CPMSolver.ts`'s forward-pass, vlak vóór + * `addDurationChecked`) is P6-eigen RETAINED_LOGIC-semantiek, net als `resumeFromActualElapsed` + * hierboven een P6-conventie vervangt voor de VOORTGANG-tak. Bewijs (reviewer-meting, + * `calendar-exception-precedence.mpp`, publiek MPXJ-fixture): mét de vloer wijkt de taak ~8 jaar + * af van MS Project se eigen opgeslagen Start/Finish (de statusdatum, 2023-05-01, ligt ver ná de + * taak se eigen 2015-10-01-anker); zónder de vloer is het resultaat minuut-exact. Een EIGEN, + * sibling-vlag i.p.v. hergebruik van `resumeFromActualElapsed` zelf: die twee bestrijken + * disjuncte taak-populaties (`completion > 0` resp. `completion === 0`) en een taak kan hooguit + * in één van beide vallen — samenvoegen tot één vlag zou geen enkel gedrag delen, alleen de + * "MSP-voortgangsconventie i.p.v. P6-RETAINED_LOGIC"-FAMILIE is gedeeld. Default `undefined`/ + * `false` ⇒ het bestaande, P6-getrouwe gedrag, byte-identiek (bewaakt door dezelfde + * `cases-progress.json`-scenario's als `resumeFromActualElapsed`). Uitsluitend `true` gezet door + * `mppReader.ts` (élke `.mpp`-import); MSPDI/P6/CSV/IFC blijven op de bestaande P6-semantiek — + * het P6-pad (`progressMode`/statusdatum-gedreven planningen) behoudt de vloer bewust: dat is + * precies de RETAINED_LOGIC-conventie die P6 zélf documenteert. */ + unstartedIgnoresStatusDate?: boolean; + /** XER/P6 (diagnose laag 1, klasse (i)): P6 zet een VOLTOOIDE activiteit ook aan de LATE zijde + * neer als een taak met nul restduur op de statusdatum — niet op haar historische actual- + * venster (dat `preserveActualDatesInBackwardPass` hierboven wél als LS/LF-PIN gebruikt). De + * gemeten regel (rehab-2, 2.036 voltooide taken, 99,9% dekking): `LF = prevWorkInstant(LS)` op + * de taak-eigen (voortgangs)kalender, en `LS` = de vroegste van de door haar opvolgers + * toegestane late finishen — geklemd op de statusdatum (`nextWorkInstant(statusdatum)`) zodat + * ze nooit vóór de statusdatum lijkt te vallen. De relatie-lag telt daarbij NIET mee tussen + * twee voltooide activiteiten (2.033/2.036), maar WEL zolang de opvolger nog restwerk heeft + * (240/2.036 faalt juist zónder die uitzondering). Dat geeft een voltooide activiteit voor het + * eerst een zinvolle totale float in plaats van 0, en laat haar backward-druk uitoefenen op + * haar eigen voorgangers zoals elke andere taak. Default afwezig/false ⇒ de bestaande + * actual-venster-pin blijft behouden, byte-identiek voor IFC/MSPDI/MPP en voor elke XER die + * vóór deze vlag is ingelezen en als IFC is opgeslagen. Uitsluitend gezet door `xerReader` + * (`xerScheduleOptions.ts`), en alleen effectief onder `p6Source === 'XER'` + * (`CPMSolver.p6XerOption`) — exact het `p6FinishMilestoneBoundaryWindow`-stramien. + * + * BEWIJSBASIS (review-bevinding 6, eerlijk afgebakend): de regel is gemeten op precies ÉÉN + * corpusbestand. Over het hele XER-corpus komen 2.042 voltooide taken door de venster-poort; + * 2.040 daarvan staan in `rehab-2.xer`, de twee overige zijn losse mpxj-fixtures met één taak + * en zonder ls/lf-orakel. De 591 voltooide taken in de 28 andere corpusbestanden vallen + * allemaal buiten de poort (`wrongDurationType` 430, `remainingStartOff` 146, …). Wat de rest + * van het corpus beschermt is dus niet de regel maar de NAUWTE van de poort + * (`DT_FixedDUR2` + `rem_target_link_flag=Y` + expliciet targetvenster + `CP_Drtn`) — verruimt + * iemand die poort, dan landt deze regel in één klap op honderden ongevalideerde taken. + * + * RETAINED LOGIC: élk gemeten corpusbestand draait `RETAINED_LOGIC` (rehab-2 heeft zelfs geen + * SCHEDOPTIONS-rij en valt op de defaults terug). Er is dus GEEN meting van P6-gedrag onder + * `sched_progress_override = Y`, en evenmin onder P6's "Actual Dates" (N/N). + * `deriveXerScheduleOptions` zet de vlag daarom expliciet weer UIT zodra de bron iets anders + * dan retained logic declareert (N/Y, N/N, Y/Y of een onbekend token; `declaresRetainedLogic`) + * — fail-closed, byte-identiek aan vóór deze etappe. Wie hem daar wil openzetten heeft eerst + * een meting onder die modus nodig. + * + * BEWIJSSTATUS (her-review 2026-09-07, bevinding 1): de poort is CORRELATIONEEL, geen + * P6-mechanisme. Het enige directe P6-bewijs voor de topologie "open voorganger → voltooide + * opvolger" (casus 09 en 10 van `cases-p6-verified.json`, echt P6 23.12) spreekt de regel + * tegen zodra de poort daar opengaat: P6 geeft de open voorganger tf 0, deze regel tf −5. + * Op de echte bron (`cases-import.xer`: `DT_FixedDrtn`, geen `target_end_date`) blijft de + * poort dicht op `wrongDurationType`/`missingExplicitTargetWindow`; dat is een toevallige + * nauwte, geen semantische verzoening. Zie plan §5 X-O7 laag 1. */ + p6CompletedLateFromRemainingWindow?: boolean; +} + export interface Project { - id: string; - name: string; - description: string; - startDate: string; // ISO 8601 - endDate: string; - calendarId: string; - createdAt: string; - modifiedAt: string; - author: string; + id: string; + name: string; + description: string; + startDate: string; // ISO 8601 + endDate: string; + calendarId: string; + createdAt: string; + modifiedAt: string; + author: string; company: string; /** Projectstandaard voor handmatig aangemaakte taken. De urenplanning-hoofdschakelaar blijft de * capabilitypoort: staat die uit, dan maakt de UI ondanks deze bewaarde voorkeur dagtaken. */ defaultTaskDurationUnit?: import('@/types/task').TaskDurationUnit; - /** - * WBS-codes automatisch nummeren (1.2.3.4, afgeleid uit de boompositie): aan ⇒ live - * hernummeren bij elke structuurmutatie; uit/ontbreekt ⇒ vrije tekst (bestaand gedrag), - * met een expliciete "Hernummer WBS"-actie. Nieuwe projecten krijgen true; geladen - * bestanden zonder vlag blijven op vrije tekst (MSP-stabiliteitsmodel: codes in - * omloop worden niet stilzwijgend herschreven). - */ - wbsAutoNumber?: boolean; - /** P6 "data date" (fase 2.6): de grens verleden/toekomst. undefined = geen statusdatum ⇒ gedrag - * exact als vóór 2.6. Gezet ⇒ remaining werk kan niet vóór deze dag starten. */ - statusDate?: string; // ISO — date-only in dag-modus; mag datetime zijn in uur-modus (fase 2.8b, §3.4) - /** Voortgangs-scheduling-modus (fase 2.6). undefined ⇒ RETAINED_LOGIC. Documentinstelling. */ - progressMode?: ProgressMode; - /** OPTIONEEL — project-scoped reken-opties (fase 2.9, §3.4/§7). Afwezig ⇒ elke default ⇒ - * byte-identiek gedrag. */ - schedulingOptions?: SchedulingOptions; - /** OPTIONEEL — projectbinding aan een bedrijfsbibliotheek (spec B1, §2). Afwezig ⇒ project is - * (nog) aan geen enkel bedrijf gebonden; heropening zonder de pool is onschuldig. `companyName` - * is een gedenormaliseerde cache zodat een gedeeld bestand het bedrijf toont zonder de pool. */ - companyId?: string; - companyName?: string; -} - -export interface ProjectStats { - totalTasks: number; - totalMilestones: number; - criticalPathLength: number; // in work days - totalFloat: number; // in work days - percentComplete: number; // 0-100 -} + /** + * WBS-codes automatisch nummeren (1.2.3.4, afgeleid uit de boompositie): aan ⇒ live + * hernummeren bij elke structuurmutatie; uit/ontbreekt ⇒ vrije tekst (bestaand gedrag), + * met een expliciete "Hernummer WBS"-actie. Nieuwe projecten krijgen true; geladen + * bestanden zonder vlag blijven op vrije tekst (MSP-stabiliteitsmodel: codes in + * omloop worden niet stilzwijgend herschreven). + */ + wbsAutoNumber?: boolean; + /** P6 "data date" (fase 2.6): de grens verleden/toekomst. undefined = geen statusdatum ⇒ gedrag + * exact als vóór 2.6. Gezet ⇒ remaining werk kan niet vóór deze dag starten. */ + statusDate?: string; // ISO — date-only in dag-modus; mag datetime zijn in uur-modus (fase 2.8b, §3.4) + /** Voortgangs-scheduling-modus (fase 2.6). undefined ⇒ RETAINED_LOGIC. Documentinstelling. */ + progressMode?: ProgressMode; + /** OPTIONEEL — project-scoped reken-opties (fase 2.9, §3.4/§7). Afwezig ⇒ elke default ⇒ + * byte-identiek gedrag. */ + schedulingOptions?: SchedulingOptions; + /** OPTIONEEL — projectbinding aan een bedrijfsbibliotheek (spec B1, §2). Afwezig ⇒ project is + * (nog) aan geen enkel bedrijf gebonden; heropening zonder de pool is onschuldig. `companyName` + * is een gedenormaliseerde cache zodat een gedeeld bestand het bedrijf toont zonder de pool. */ + companyId?: string; + companyName?: string; +} + +export interface ProjectStats { + totalTasks: number; + totalMilestones: number; + criticalPathLength: number; // in work days + totalFloat: number; // in work days + percentComplete: number; // 0-100 +} diff --git a/src/types/sequence.ts b/src/types/sequence.ts index a61a8a309..4c3e0c92e 100644 --- a/src/types/sequence.ts +++ b/src/types/sequence.ts @@ -22,7 +22,11 @@ export interface Sequence { * afwezig ⇒ `lagDays` (dagen) is de bron (byte-identiek). */ lagMinutes?: number; /** Lag-eenheid; ontbreekt = WORKTIME (werkdagen) — bestaand gedrag, migratieloos. */ - lagUnit?: LagUnit; + lagUnit?: LagUnit; + /** XER/P6-bronsemantiek: een nul-lag FS waarvan de expliciete geplande opvolgerstart exact op + * het geplande voorgangereinde én een kalenderbandeinde ligt, behoudt die finishgrens als + * startrepresentatie. Andere importformaten laten dit veld weg en houden hun bestaande snap. */ + p6StartAtPredecessorFinishBoundary?: boolean; /** * Procentuele lag: percentage van de duur van de VOORGANGER (bv. 50 = "SS+50%"), * per CPM-run opnieuw geëvalueerd uit de actuele duur (MS Project-semantiek) en diff --git a/src/types/task.ts b/src/types/task.ts index 4a8f0eeab..7c5bbdfdd 100644 --- a/src/types/task.ts +++ b/src/types/task.ts @@ -175,6 +175,28 @@ export interface TaskTimephasedContour { */ export type MspTaskType = 'FIXED_UNITS' | 'FIXED_DURATION' | 'FIXED_WORK'; +/** + * P6's eigen "Duration Type" (XER `TASK.duration_type` — Primavera "Duration Type" op de + * activiteit: stuurt hoe P6 zelf duur/eenheden/snelheid aan elkaar koppelt bij een bewerking). + * VIER canonieke P6-tokens (Oracle P6-schema): Fixed Duration & Units, Fixed Duration & Units/Time, + * Fixed Units/Time, Fixed Units. Deze unie draagt de canonieke schrijfwijze; de XER-etappe se + * generieke enum-tokenregel (§4.7 van het XER-etappeplan, geïmplementeerd in X4a/X5) matcht + * corpusvarianten case-insensitief tegen deze lijst en rapporteert — nooit stil — een token dat ook + * ná case-fold onbekend blijft. NIET te verwarren met `MspTaskType` hierboven: twee bronformaten, + * twee taaktypeconcepten met een andere waardenverzameling. + */ +export type P6DurationType = 'DT_FixedDrtn' | 'DT_FixedDUR2' | 'DT_FixedRate' | 'DT_FixedQty'; + +/** P6/XER `complete_pct_type`: bronsoort van voortgang en restduur. */ +export type P6CompletePctType = 'CP_Drtn' | 'CP_Phys' | 'CP_Units'; + +/** + * P6's eigen "Activity Type" (XER `TASK.task_type`): Task Dependent, Resource Dependent, Level of + * Effort, Start Milestone, Finish Milestone, WBS Summary. ZES canonieke P6-tokens; zie + * `P6DurationType` voor de case-insensitieve-matchafspraak (§4.7). + */ +export type P6ActivityType = 'TT_Task' | 'TT_Rsrc' | 'TT_LOE' | 'TT_Mile' | 'TT_FinMile' | 'TT_WBS'; + /** * Soort mijlpaal (fase 2.4, P6 Start/Finish Milestone). Dag-granulair grens-model: * START ankert op een dagbegin, FINISH op een dageinde (einde werkdag F = begin @@ -471,6 +493,46 @@ export interface Task { * Afwezig/false ⇒ byte-identiek. Round-tript via `OPS_MspTaskType` (`ifcPsets.ts`, zelfde pset * als `mspTaskType` — het is hetzelfde MSP-taaktypeconcept-paar). */ effortDriven?: boolean; + /** OPTIONEEL — P6's eigen Duration Type bij .xer-import (zie `P6DurationType`). VELD-ALS-SIGNAAL, + * eigen opgeslagen veld NAAST `mspTaskType` — géén hergebruik: de twee bronformaten kennen elk + * hun eigen taaktypeconcept met een andere waardenverzameling en een andere reken-relatie (MSP: + * Fixed Units/Duration/Work; P6: Fixed Duration&Units/Duration&Units-per-Time/Units-per-Time/ + * Units). Puur data — eigenaarsbesluit XER-etappeplan §1/X0 (2026-08-20): GEEN enkele solverstap + * leest dit veld deze etappe. VASTGELEGDE AFSPRAAK (§1 van het plan): de latere taaktypes/effort- + * driven-motor-etappe (`2026-08-18-spec-taaktypes-effort-driven.md`) mapt zowel `mspTaskType` als + * `p6DurationType` naar ÉÉN interne superset-rekenmodel — twee opslagvelden nu, één rekenmodel + * straks, geen twee eilanden. Afwezig ⇒ geen .xer-herkomst of onbekend token (byte-identiek). */ + p6DurationType?: P6DurationType; + /** OPTIONEEL — P6's eigen Activity Type bij .xer-import (zie `P6ActivityType`). Zelfde + * eigenaarsbesluit/superset-afspraak als `p6DurationType` hierboven — puur data, geen solverstap + * leest dit veld deze etappe (de XER-lezer leidt `isMilestone`/`isHammock` er later wél + * OPERATIONEEL uit af — TT_Mile/TT_FinMile resp. TT_LOE, zie X4a — maar dat zijn AFGELEIDEN, niet + * dit veld zelf, dat blijft de rauwe herkomst). Afwezig ⇒ geen .xer-herkomst (byte-identiek). */ + p6ActivityType?: P6ActivityType; + /** `task_id` is slechts uniek binnen dit P6-project; beide bronidentiteiten blijven bewaard. */ + p6ProjectId?: string; + p6TaskId?: string; + /** + * Fail-closed XER-provenance: de bron-TASK droeg werkelijk zowel `target_start_date` als + * `target_end_date`. Dit bewaart uitsluitend de aanwezigheid van broninvoer — nooit P6's + * opgeslagen early/late- of floatuitkomst — zodat een readerfallback of later berekende + * `scheduleStart` niet als bronfeit kan worden verward. + */ + p6ExplicitTargetWindow?: boolean; + /** CP_Phys/CP_Units gebruiken hun bronrestduur en nooit percentage-afleiding in de solver. */ + p6CompletePctType?: P6CompletePctType; + /** XER `expect_end_date`, pas actief met de expliciete projectscope-vlag uit X5. */ + p6ExpectedFinish?: string; + /** P6-specifieke opt-in voor `time.resume`/`time.stop`. De XER-lezer zet dit + * uitsluitend bij een volledig, chronologisch suspend/resume-paar; losse of + * omgekeerde brondata blijft behouden maar activeert nooit de P6-route. + * Default/afwezig behoudt de bestaande MSP-conventie. */ + p6SuspendResume?: boolean; + /** Expliciete WBS-/samenvattingsidentiteit. `true` betekent dat de taak ook zonder kinderen een + * samenvatting blijft (bijvoorbeeld een lege PROJWBS-rij uit P6). `false` verwijdert alleen die + * expliciete marker; taken met kinderen blijven samenvatting. Afwezig houdt bij updates de + * bestaande waarde ongemoeid. Gebruik `isSummaryTask`/`isLeafTask` voor de semantiek. */ + isSummary?: boolean; parentId: string | null; // WBS parent childIds: string[]; // WBS children time: TaskTime; diff --git a/src/types/taskGrid.ts b/src/types/taskGrid.ts index a9ee77495..284cd9378 100644 --- a/src/types/taskGrid.ts +++ b/src/types/taskGrid.ts @@ -6,6 +6,15 @@ import type { TaskRelationIndex } from '@/engine/taskGrid/relationIndex'; export type TaskGridSurfaceId = 'gantt-task-grid' | 'full-task-grid'; export type TaskColumnId = string & { readonly __taskColumnId: unique symbol }; +/** + * "Datums zoals opgeslagen" (issue #63, XER-etappeplan laag 3 §3.6). Gedragslogica en de exacte + * regels staan in `src/state/recordedDatesSelectors.ts` — deze twee unietypes wonen hier (leaf- + * laag) omdat `TaskColumnContext` hieronder ze nodig heeft en niets in `src/types/*.ts` van + * `@/state/*` afhangt; `recordedDatesSelectors.ts` importeert ze hier weer type-only vandaan. + */ +export type RecordedTaskAxis = 'ls' | 'lf' | 'tf' | 'ff'; +export type RecordedTaskMark = 'deviates' | 'partly-unrecorded' | undefined; + export type GridResult = | { ok: true; value: T } | { ok: false; errors: E }; @@ -130,6 +139,15 @@ export interface TaskColumnContext { effectiveHoursPerDay?: (task: Task) => number; /** De adapter levert hier de echte projectkalenderberekening voor baselineafwijkingen. */ signedWorkDaysBetween?: (fromIso: string, toIso: string) => number; + /** "Datums zoals opgeslagen" (XER-etappeplan laag 3, T6) — badge voor de kolom `recorded.source`. + * `undefined` op documenten zonder vastlegging (`recordedDates === null`), dus de kolom bestaat + * dan niet: `available(ctx) => ctx.recordedMark !== undefined`. */ + recordedMark?: (task: Task) => RecordedTaskMark; + /** Welke late-/floatassen het BESTAND niet vastlegde voor deze taak — de bestaande late-/float- + * kolommen gebruiken dit om "niet vastgelegd" te tonen in plaats van de bestaande `?? 0`- + * terugval (die als VELDWAARDE blijft staan, zie `recordedDates.ts` §3.4) als een echt getal te + * presenteren. Zelfde aanwezigheid als `recordedMark` (beide `undefined` zonder vastlegging). */ + recordedUnrecordedAxes?: (task: Task) => readonly RecordedTaskAxis[]; } export interface TaskColumnDescriptor { diff --git a/src/utils/devBridge.ts b/src/utils/devBridge.ts index 11c5db81f..3d9c353b4 100644 --- a/src/utils/devBridge.ts +++ b/src/utils/devBridge.ts @@ -2,8 +2,14 @@ import { useAppStore } from '@/state/appStore'; import { appLog } from '@/services/debug/appLog'; import { writeIFC } from '@/services/ifc/ifcWriter'; import { buildWriteIFCInput } from '@/state/ifcSaveInput'; -import { readIFC } from '@/services/ifc/ifcReader'; -import { parseOpenedFile, readFormatInput } from '@/services/formatRegistry'; +import { + parseOpenedFile, + readIFCWithXerReconstruction, + readFormatForFile, + readFormatInput, + saveTargetFor, + type FormatIO, +} from '@/services/formatRegistry'; import { enableExtension, disableExtension, removeExtension, saveExtensionToDb, installFromZipBlob } from '@/extensions'; import type { ExpectedExtensionIdentity, InstallOutcome } from '@/extensions'; import type { ExtensionManifest, ReadyExtension } from '@/extensions/types'; @@ -51,12 +57,12 @@ function stateSnapshot(s: AppState) { } /** Niveau 1 — serialiseer de huidige state naar IFC en parse 'm terug; meet dataverlies. Werkt ook in de browser. */ -function roundTrip() { +async function roundTrip() { const s = useAppStore.getState(); const content = writeIFC(buildWriteIFCInput(s)); // Geen `labels`: dev-only zelftesthaak (`window.__OPS__`), geen productie-UI — `readIFC` valt // terug op de Engelse default voor een bestand zonder IFCPROJECT (zie ImportLabels). - const parsed = readIFC(content); + const parsed = await readIFCWithXerReconstruction(content); const before = counts(s); const after = { tasks: parsed.tasks.length, @@ -85,12 +91,24 @@ async function saveToPath(path: string) { * Dev-only gedragsverbetering (T1): loopt nu via de formatRegistry, dus `.xml` wordt hier ook * herkend (voorheen viel dat stil terug op IFC). T2: binaire formaten worden als bytes gelezen * i.p.v. tekst. */ +export async function openFromPathWithIO(path: string, io: FormatIO) { + const input = await readFormatInput(path, io); + const parsed = await parseOpenedFile(input); + const target = saveTargetFor(readFormatForFile(path), { kind: 'path', path }, path); + const opened = useAppStore.getState().applyOpenedImport(parsed, { + filePath: target.filePath, + fileHandle: target.fileHandle, + recompute: true, + fit: true, + hourDataNotice: true, + linkedOpen: true, + }); + return { path, ...opened, ...counts(useAppStore.getState()) }; +} + async function openFromPath(path: string) { const { readTextFile, readFile } = await import('@tauri-apps/plugin-fs'); - const input = await readFormatInput(path, { readTextFile, readFile }); - const parsed = await parseOpenedFile(input); - useAppStore.getState().loadState(parsed); - return { path, ...counts(useAppStore.getState()) }; + return openFromPathWithIO(path, { readTextFile, readFile }); } /** Dev-only: installeer een extensie direct vanuit een code-string (voor zelftests). */ @@ -131,7 +149,7 @@ async function scanStoredExtensions(): Promise; @@ -146,7 +164,7 @@ async function runOp(cmd: OpsCommand): Promise> { case 'getState': return { ok: true, result: stateSnapshot(useAppStore.getState()) }; case 'roundTrip': - return { ok: true, result: roundTrip() }; + return { ok: true, result: await roundTrip() }; case 'save': { const path = cmd.args?.path as string | undefined; if (!path) return { ok: false, error: 'missing args.path' }; @@ -199,6 +217,14 @@ async function runOp(cmd: OpsCommand): Promise> { } } +/** Serializeer precies de payload die de Tauri-poller naar res.json schrijft. Geëxporteerd voor de + * headless contracttest: een Promise in `result` stringifyt naar `{}` en mag nooit onzichtbaar + * worden achter een browser-only rooktest. */ +export async function serializeOpsTestResponse(cmd: OpsCommand): Promise { + const res = await runOp(cmd); + return JSON.stringify({ id: cmd.id, ...res }); +} + /** * Tier 2 poller: bestandssysteem-controlekanaal in de échte Tauri-runtime. * Leest `/ops-test/cmd.json`, voert uit, schrijft `res.json`. @@ -227,8 +253,7 @@ async function startOpsTestPoller(): Promise { await writeTextFile(resPath, JSON.stringify({ ok: false, error: 'invalid JSON' })); return; } - const res = await runOp(cmd); - await writeTextFile(resPath, JSON.stringify({ id: cmd.id, ...res })); + await writeTextFile(resPath, await serializeOpsTestResponse(cmd)); } catch (err) { try { await writeTextFile(resPath, JSON.stringify({ ok: false, error: String(err) })); } catch { /* leeg */ } } diff --git a/src/utils/documents.ts b/src/utils/documents.ts index fd733393f..4e4705831 100644 --- a/src/utils/documents.ts +++ b/src/utils/documents.ts @@ -1,5 +1,6 @@ import type { Task } from '@/types/task'; import type { CPMResult } from '@/engine/scheduler/CPMSolver'; +import { isLeafTask, isSummaryTask } from '@/utils/taskHierarchy'; /** * Afgeleide identiteit + statistieken per geopend document, voor de @@ -104,7 +105,7 @@ export interface DocStats { } export function buildStats(tasks: Task[], cpm: CPMResult | null, projectEnd: string): DocStats { - const leaves = tasks.filter((t) => t.childIds.length === 0); + const leaves = tasks.filter(isLeafTask); const milestoneCount = tasks.filter((t) => t.isMilestone).length; const criticalCount = cpm ? cpm.criticalPath.length @@ -127,7 +128,7 @@ export interface ThumbBar { export function buildThumbnail(tasks: Task[], identityColor: string, maxBars = 9): ThumbBar[] { const points: { start: number; end: number; ms: boolean; crit: boolean }[] = []; for (const t of tasks) { - if (t.childIds.length > 0) continue; // alleen bladtaken/mijlpalen + if (isSummaryTask(t)) continue; // alleen bladtaken/mijlpalen const startStr = t.time.earlyStart || t.time.scheduleStart; if (!startStr) continue; const start = Date.parse(startStr); diff --git a/src/utils/idb.ts b/src/utils/idb.ts index f166c3443..5dc41a7db 100644 --- a/src/utils/idb.ts +++ b/src/utils/idb.ts @@ -7,7 +7,12 @@ const dbPromises = new Map>(); -function openDb(dbName: string, storeName: string): Promise { +/** + * Open één object-store zonder de fout te verbergen. De algemene helpers hieronder kiezen bewust + * voor stille terugval; recovery gebruikt deze openstap rechtstreeks voor zijn eigen atomische + * write-transaction, waar een fout juist aan de auto-save moet terugkeren. + */ +export function openIdb(dbName: string, storeName: string): Promise { const cacheKey = `${dbName}::${storeName}`; const existing = dbPromises.get(cacheKey); if (existing) return existing; @@ -40,7 +45,7 @@ function openDb(dbName: string, storeName: string): Promise { /** Alle records uit de store. Bij een IDB-fout: lege lijst (stil). */ export async function idbGetAll(dbName: string, storeName: string): Promise { try { - const db = await openDb(dbName, storeName); + const db = await openIdb(dbName, storeName); return await new Promise((resolve, reject) => { const tx = db.transaction(storeName, 'readonly'); const req = tx.objectStore(storeName).getAll(); @@ -55,7 +60,7 @@ export async function idbGetAll(dbName: string, storeName: string): Promise(dbName: string, storeName: string, id: string): Promise { try { - const db = await openDb(dbName, storeName); + const db = await openIdb(dbName, storeName); return await new Promise((resolve, reject) => { const tx = db.transaction(storeName, 'readonly'); const req = tx.objectStore(storeName).get(id); @@ -73,7 +78,7 @@ export async function idbGet(dbName: string, storeName: string, id: string): * laat de overige velden gewoon toe. */ export async function idbPut(dbName: string, storeName: string, value: { id: string }): Promise { try { - const db = await openDb(dbName, storeName); + const db = await openIdb(dbName, storeName); await new Promise((resolve, reject) => { const tx = db.transaction(storeName, 'readwrite'); tx.objectStore(storeName).put(value); @@ -88,7 +93,7 @@ export async function idbPut(dbName: string, storeName: string, value: { id: str /** Verwijder een record op id. Faalt stil. */ export async function idbDelete(dbName: string, storeName: string, id: string): Promise { try { - const db = await openDb(dbName, storeName); + const db = await openIdb(dbName, storeName); await new Promise((resolve, reject) => { const tx = db.transaction(storeName, 'readwrite'); tx.objectStore(storeName).delete(id); diff --git a/src/utils/notificationDetail.ts b/src/utils/notificationDetail.ts new file mode 100644 index 000000000..4e66a087b --- /dev/null +++ b/src/utils/notificationDetail.ts @@ -0,0 +1,12 @@ +import type { NotificationDetailLine } from '@/state/slices/types'; + +/** + * Eén getypeerde detailregel volgt exact dezelfde i18n-route als de hoofdtoast. Deze pure helper + * blijft React-vrij zodat de headless planningharnas hem zonder component-runtime kan toetsen. + */ +export function notificationDetailText( + t: (key: NotificationDetailLine['messageKey'], params?: Record) => string, + line: NotificationDetailLine, +): string { + return t(line.messageKey, line.params); +} diff --git a/src/utils/p6CompletedTargetWindow.ts b/src/utils/p6CompletedTargetWindow.ts new file mode 100644 index 000000000..c459e1a65 --- /dev/null +++ b/src/utils/p6CompletedTargetWindow.ts @@ -0,0 +1,131 @@ +import type { SchedulingOptions } from '@/types/project'; +import type { Task } from '@/types/task'; +import { isZeroDurationMilestone } from '@/engine/scheduler/duration'; +import { parseInstant } from '@/utils/dateUtils'; +import { hasValidP6SuspendResume } from '@/utils/p6SuspendResume'; +import { isLeafTask } from '@/utils/taskHierarchy'; + +export type P6CompletedWindowReason = + | 'eligible' + | 'missingDataDate' + | 'notXerSource' + | 'remainingStartOff' + | 'notLeafTask' + | 'missingProjectProvenance' + | 'missingTaskProvenance' + | 'missingExplicitTargetWindow' + | 'wrongCompletePctType' + | 'wrongDurationType' + | 'wrongActivityType' + | 'hasSuspendResume' + | 'notCompleted' + | 'zeroDurationMilestone'; + +export interface P6CompletedWindowDecision { + eligible: boolean; + reason: P6CompletedWindowReason; +} + +function mayUseSuspendResumeCompletedWindow( + task: Task, + dataDate: Date | null, + schedulingOptions: SchedulingOptions | undefined, +): boolean { + if (task.p6SuspendResume !== true) return false; + if (task.time.completion < 1) return false; + if (task.p6ActivityType !== 'TT_Task') return false; + if (dataDate === null) return false; + if (schedulingOptions?.preserveActualDatesInBackwardPass !== true) return false; + if (!task.time.actualFinish) return false; + const actualFinishTime = parseInstant(task.time.actualFinish).getTime(); + if (!Number.isFinite(actualFinishTime)) return false; + const targetFinishTime = parseInstant(task.time.scheduleFinish).getTime(); + if (!Number.isFinite(targetFinishTime)) return false; + if (actualFinishTime < targetFinishTime) return false; + if (actualFinishTime > dataDate.getTime()) return false; + if (!hasValidP6SuspendResume(task)) return false; + if (!task.time.resume) return false; + const resumeTime = parseInstant(task.time.resume).getTime(); + return Number.isFinite(resumeTime) && resumeTime <= actualFinishTime; +} + +/** + * Alleen een bewezen XER-bladactiviteit mag voor een voltooide taak de P6-statusdatum als + * bronsemantiek gebruiken. Dit is bewust géén datumoverride en leest géén opgeslagen P6 + * early/late/float-uitkomst: de datuminvoer is uitsluitend de al toegestane projectstatusdatum. + * + * De onafhankelijke guards maken de representatie fail-closed na IFC, undo en extensie- + * round-trips: project-, taak-, voortgangstype- en activiteitstypeprovenance moeten tegelijk + * bestaan. De vastgelegde `CP_Drtn`/`DT_FixedDUR2`-vorm is de enige bronvorm waarvoor de + * corpusprobes een bron-alleen, consistente completed statusdatum-route bevestigen. `CP_Phys` + * wordt wel volledig bewaard, maar blijft hier fail-closed: tien gelijkvormige corpusactiviteiten + * leverden acht P6-statuspunten en twee ontbrekende early/late-orakels op. Omdat de lezer de + * opgeslagen P6-uitkomst nooit als invoer mag gebruiken en er geen toegestane invoerveld-deler is, + * zou toelaten een semantische gok zijn. Een P6 suspend/resume-paar + * blijft standaard fail-closed en mag uitsluitend door de bestaande CP_Drtn-poort wanneer de taak zelf al + * aantoonbaar voltooid is, haar actual-finish parseerbaar binnen het expliciete targetvenster en + * de projectstatusdatum valt (`target_end_date <= actualFinish <= dataDate`), de backward-actual- + * preserve-vlag aan staat, de resume niet ná de actual-finish valt en het interne stop/resume-paar + * geldig is. Actieve, halve, omgekeerde of stale suspend/resume-vormen houden dus expliciet de + * bestaande `hasSuspendResume`-reden. Nulduurmijlpalen blijven buiten dit eerste causaliteitspakket. + * + * Guardvolgorde is bewust vast en fail-closed: de eerste afwijzing is de ENIGE reden die we + * rapporteren. Zo blijft de diagnose stabiel en deelt de boolean-wrapper exact dezelfde bron. + */ +export function explainP6CompletedDataDateWindow( + task: Task, + dataDate: Date | null, + schedulingOptions: SchedulingOptions | undefined, +): P6CompletedWindowDecision { + if (dataDate === null) return { eligible: false, reason: 'missingDataDate' }; + if (schedulingOptions?.p6Source !== 'XER') return { eligible: false, reason: 'notXerSource' }; + if (schedulingOptions.p6UseRemainingStartForProgress !== true) { + return { eligible: false, reason: 'remainingStartOff' }; + } + if (!isLeafTask(task)) return { eligible: false, reason: 'notLeafTask' }; + if (task.p6ProjectId === undefined || task.p6ProjectId === '') { + return { eligible: false, reason: 'missingProjectProvenance' }; + } + if (task.p6TaskId === undefined || task.p6TaskId === '') { + return { eligible: false, reason: 'missingTaskProvenance' }; + } + if (task.p6ExplicitTargetWindow !== true) { + return { eligible: false, reason: 'missingExplicitTargetWindow' }; + } + const isPhysicalCompletion = task.p6CompletePctType === 'CP_Phys'; + if (task.p6CompletePctType !== 'CP_Drtn' && !isPhysicalCompletion) { + return { eligible: false, reason: 'wrongCompletePctType' }; + } + if (task.p6DurationType !== 'DT_FixedDUR2') return { eligible: false, reason: 'wrongDurationType' }; + const validActivity = task.p6ActivityType === 'TT_Task' + || (!isPhysicalCompletion && task.p6ActivityType === 'TT_Rsrc'); + if (!validActivity) { + return { eligible: false, reason: 'wrongActivityType' }; + } + if (task.p6SuspendResume === true + && (isPhysicalCompletion || !mayUseSuspendResumeCompletedWindow(task, dataDate, schedulingOptions))) { + return { eligible: false, reason: 'hasSuspendResume' }; + } + if (task.time.completion < 1) return { eligible: false, reason: 'notCompleted' }; + if (isZeroDurationMilestone(task)) return { eligible: false, reason: 'zeroDurationMilestone' }; + if (isPhysicalCompletion) { + const actualFinish = task.time.actualFinish ? parseInstant(task.time.actualFinish) : null; + // CP_Phys heeft voor een toekomstige bewezen route een echt geregistreerd eindfeit nodig: + // leeg, syntactisch ongeldig en ná de P6-statusdatum zijn alle drie fail-closed. Deze check + // staat bewust vóór de definitieve CP_Phys-afwijzing zodat een latere route-opening haar + // grens niet stil kan omzeilen. + if (actualFinish === null || !Number.isFinite(actualFinish.getTime()) || actualFinish > dataDate) { + return { eligible: false, reason: 'notCompleted' }; + } + return { eligible: false, reason: 'wrongCompletePctType' }; + } + return { eligible: true, reason: 'eligible' }; +} + +export function usesP6CompletedDataDateWindow( + task: Task, + dataDate: Date | null, + schedulingOptions: SchedulingOptions | undefined, +): boolean { + return explainP6CompletedDataDateWindow(task, dataDate, schedulingOptions).eligible; +} diff --git a/src/utils/p6SuspendResume.ts b/src/utils/p6SuspendResume.ts new file mode 100644 index 000000000..5b0852eb1 --- /dev/null +++ b/src/utils/p6SuspendResume.ts @@ -0,0 +1,25 @@ +import type { Task } from '@/types/task'; +import { parseInstant } from '@/utils/dateUtils'; + +type P6SuspendResumeTask = Pick; + +/** + * Enige waarheid voor de P6 suspend/resume-route. De raw datums blijven universele taakdata; + * alleen een expliciet P6-bron/opt-in-signaal met twee parseerbare, chronologische instanties mag + * de P6-semantiek in writer, reader en solver activeren. + */ +export function hasValidP6SuspendResume(task: P6SuspendResumeTask): boolean { + if (!task.p6ProjectId || task.p6SuspendResume !== true) return false; + const { stop, resume } = task.time; + if (!stop || !resume) return false; + const stopTime = parseInstant(stop).getTime(); + const resumeTime = parseInstant(resume).getTime(); + return Number.isFinite(stopTime) && Number.isFinite(resumeTime) && stopTime <= resumeTime; +} + +/** Wis uitsluitend een stale semantische opt-in; raw stop/resume blijven onaangeraakt. */ +export function reconcileP6SuspendResume(task: P6SuspendResumeTask): void { + if (task.p6SuspendResume === true && !hasValidP6SuspendResume(task)) { + delete task.p6SuspendResume; + } +} diff --git a/src/utils/taskHierarchy.ts b/src/utils/taskHierarchy.ts new file mode 100644 index 000000000..3bc055400 --- /dev/null +++ b/src/utils/taskHierarchy.ts @@ -0,0 +1,10 @@ +import type { Task } from '@/types/task'; + +/** Eén bron voor de semantische WBS-identiteit. Een expliciete lege samenvatting is nooit blad. */ +export function isSummaryTask(task: Task | undefined): boolean { + return task?.isSummary === true || (task?.childIds.length ?? 0) > 0; +} + +export function isLeafTask(task: Task | undefined): boolean { + return !!task && !isSummaryTask(task); +} diff --git a/tests/browser/README.md b/tests/browser/README.md new file mode 100644 index 000000000..2055fa1dd --- /dev/null +++ b/tests/browser/README.md @@ -0,0 +1,170 @@ +# X11/XWayland-browserharnas voor XER + +Dit harnas voert de fase-1-gebruikstest en de afgebakende fase-2A-proef voor multi-document + +Help uit tegen de browser-dev-build van precies deze worktree. Het opent een echt zichtbaar +Chromium-venster, kiest via de zichtbare Engelse knop `Open` een XER-bestand met de echte +browser-filechooser en controleert daarna zowel de DOM als `window.__OPS__`. + +## Voorwaarden + +- Node 22 en npm moeten beschikbaar zijn. +- `OPS_XER_CORPUS` moet naar de corpusroot wijzen. De standaardrun gebruikt daaruit + `crawl-xer/p6diff-baseline.xer`; fase 2A gebruikt + `crawl-xer/eh_P6Workshops/OZB-Start-09Dec24.xer`. +- `OPS_NODE_MODULES_DIR` kan naar een bestaande externe `node_modules` wijzen wanneer deze + worktree geen eigen dependencies heeft. Het harnas maakt hoogstens tijdelijk een symlink en + verwijdert die in de opruimroute. +- `OPS_PLAYWRIGHT_DIR` kan naar een bestaande installatie van `playwright-core` wijzen. Dit is + geen productdependency en wijzigt geen lockfile. +- Er moet een zichtbaar desktopdisplay zijn. Bij een lege `DISPLAY` probeert het harnas de + Wayland-socket `/run/user/1000/wayland-0` met `XDG_RUNTIME_DIR=/run/user/1000`, + `WAYLAND_DISPLAY=wayland-0` en `--ozone-platform=wayland`. + +Een lokale run zonder corpus, Chromium of bruikbaar display eindigt rood met exitcode 1. Een +expliciete CI-run mag overslaan met `OPS_XER_X11_CI=1`; dat is geen browserbewijs. + +## Uitvoeren + +```bash +OPS_XER_CORPUS="/pad/naar/testdata-crawl" \ +OPS_NODE_MODULES_DIR="/pad/naar/node_modules" \ +OPS_PLAYWRIGHT_DIR="/pad/naar/node_modules/playwright-core" \ +npm run test:browser:x11 +``` + +Fase 2A gebruikt hetzelfde script en alle infrastructuurgates, met één expliciet scenario: + +```bash +OPS_XER_X11_SCENARIO=multidoc-help \ +OPS_XER_CORPUS="/pad/naar/testdata-crawl" \ +OPS_NODE_MODULES_DIR="/pad/naar/node_modules" \ +OPS_PLAYWRIGHT_DIR="/pad/naar/node_modules/playwright-core" \ +npm run test:browser:x11 +``` + +Fase 2B heeft twee afzonderlijke headed scenario's: + +```bash +OPS_XER_X11_SCENARIO=multidoc-recovery OPS_XER_CORPUS="/pad/naar/testdata-crawl" npm run test:browser:x11 +OPS_XER_X11_SCENARIO=large-resources OPS_XER_CORPUS="/pad/naar/testdata-crawl" npm run test:browser:x11 +``` + +De eerste gebruikt opnieuw het openbare 15→12-dossier. Na één zichtbare gebruikerswijziging via +Home → Task bewijst hij de echte browser-autosave: twaalf afzonderlijke IFC-records en één manifest +in IndexedDB, een tweede manifestcommit ongeveer tien seconden na de eerste, en rollback van een +expliciet afgebroken IndexedDB-transactie. Daarna herlaadt hij dezelfde tab, gebruikt uitsluitend de +zichtbare normale Recovery-dialoog en herstelt alle twaalf documenten. De tweede opent +`crawl-xer-extra/jailaff-xer-splitter/rehab-2.xer`, volgt de werkelijk geleverde enkel-documentroute +en bedient Resources en Histogram via zichtbare lintknoppen. + +De corpusloze Git-eindintegriteits- en fase-2A-contracttests zijn afzonderlijk uit te voeren met: + +```bash +node tests/browser/git-evidence-contract.mjs +node tests/browser/x11-multidoc-contract.mjs +node tests/browser/x11-phase2b-contract.mjs +node tests/browser/x11-phase2b-privacy-contract.mjs +node tests/browser/x11-phase2b-mutations.mjs +``` + +De eerste doodt afzonderlijk een verschil in Git-toplevel, branch, HEAD en de ruwe +`status --porcelain=v1`, zonder een checkout of de werkboom te wijzigen. De tweede pint het +importreport, de twaalf veilige documentprojecties, de switchroutes, latenties en Help-route. +Het fase-2B-contract pint recoverytelling, documentvolgorde/actief document, +archive-digest/selectorbehoud, de grote taak-/assignment-/kalendertellingen, de zichtbare +Resources-/Histogramroutes en gesloten privacyallowlists. Gerichte mutanten worden aangezet met +`OPS_X11_PHASE2B_MUTATION=` en horen exitcode 1 te geven. +De afzonderlijke privacytest weigert pathlike tekst in stdout, metadata, serversamenvatting en +stateartefact via `OPS_X11_PHASE2B_PRIVACY_MUTATION=`. De vaste mutantendriver pint +28 bestaande plus 4 vrije-tekstmutanten en vereist voor alle 32 semantisch exitcode 1. + +Het harnas verwijdert geërfde `OPS_DEV_GUARDED` en `OPS_DEV_PORT`, start zelf `npm run dev` en +leest de werkelijk toegewezen URL en PID uit de launcher-output. Het controleert dat de normale +bewaakte launcherregel zichtbaar is, de URL de vaste gestempelde worktreepoort gebruikt, HTTP +200 antwoordt en `/proc/PID/cwd` exact de worktree-root is. Een al actieve bewaker laat de run +rood eindigen; omzeilen met geërfde guard- of poortvariabelen is niet toegestaan. De server +wordt altijd gestopt nadat de browserrun klaar is of faalt. + +De init-scriptlaag verwijdert uitsluitend binnen de testcontext `showOpenFilePicker`, zodat de +bestaande `input[type=file]`-terugval wordt gebruikt. De test vereist vervolgens een echte +Playwright `filechooser`-event en gebruikt `chooser.setFiles(...)`. Vóór de Open-klik worden alle +`open`- en `import`-methoden van de dev-bridge structureel vervangen door blokkerende wrappers. +Iedere directe of computed aanroep wordt geteld, gooit meteen een fout en maakt de run rood; +na de import moet de teller nul zijn. De store wordt alleen gelezen voor state-inspectie. + +## SMALL-A-asserties + +De import moet opleveren: + +- één geopend document; +- acht echte XER-werk- en mijlpaaltaken (de vier afgeleide WBS-samenvattingsrijen tellen niet + mee); +- zeven relaties; +- een aanwezig CPM-resultaat; +- een aanwezig `xerSourceArchive` en een niet-lege `xerSourceProjectId`; +- een zichtbare Engelse XER-openmelding met `helpArticleId` `gids-xer-import`. + +Daarnaast moeten `window.alert`, `window.confirm` en `window.prompt` nul keer zijn aangeroepen. + +## Fase-2A-asserties + +De multi-documentproef pint het echte XER-importreport op 15 gevonden projecten, 12 geopende +documenten, 3 overgeslagen lege projecten, 0 uitgesloten baselineprojecten, 0 gematerialiseerde +baselines en 9 dangling baselineverwijzingen. Alle twaalf zichtbare DOM-tabs moeten afzonderlijk +activeren naar een niet-leeg document met een unieke gehashte P6-projectidentiteit, bronarchief en +CPM-resultaat. Ctrl+1, Ctrl+5 en Ctrl+9 worden buiten invoervelden bediend; tabs 10–12 lopen via +een echte tabklik en ArrowRight-events. Iedere wissel correleert de actieve tab met het tabpanel en +de read-only storeprojectie. + +De zichtbare toastdetails worden vóór auto-dismiss gelezen. Voor dit openbare dossier moet de +melding exact de veilige Engelse telregels en `Text encoding selected: windows-1252.` tonen; +de gesloten allowlist weigert iedere extra regel, ook wanneer die geen pad of extensie bevat. +De echte `Read more`-knop moet +Backstage Help openen op het Engelse artikel `Opening Primavera P6 (.xer)`, inclusief de uitleg +over meerdere documenten, lege projecten en baselines. De openlatency en alle twaalf +switchlatencies worden als eindige meetwaarden vastgelegd; er geldt geen zelfverzonnen harde +performancegrens. + +## Evidence en beperking + +Elke geslaagde run schrijft buiten de repo naar een unieke submap onder +`/tmp/xer-x11-evidence/`. Daarin staan metadata, de privacy-geredigeerde overzichtsscreenshot, +een afzonderlijke elementopname van de zichtbare XER-toast, de geobserveerde state en de +privacyveilige `server-summary.json`; ruwe dev-serveroutput wordt niet bewaard. Fase 2A voegt een +geredigeerde Help-screenshot toe. De toastopname wordt vóór +auto-dismiss gemaakt en accepteert alleen generieke importaantallen, de exacte veilige +encodingregel en `Read more`; pad-, +bestands-, project-, taak- en resourcenamen zijn er niet toegestaan. Canvas, documentnaam, +tablabels en mogelijk corpusdragende Help-inhoud worden in de overige screenshots gemaskeerd. +De metadata maakt twee verschillende historische ankers expliciet. `git.base` is de vaste +cumulatieve fase-2A-basis `790d6cd8266682fa9b7798a3d1f9e0a1a2498db9`; `git.commitParent` is de +live directe `HEAD^` van de commit waarop de browserrun werkelijk draait. Daarnaast bevat zij de +live HEAD en alleen SHA-256-fingerprints van worktree, branch en ruwe beginstatus. De rauwe waarden +blijven uitsluitend intern voor de zesdelige Git-eindgate. Na browser-, server- en +dependencycleanup moeten alle zes waarden exact gelijk zijn aan hun beginwaarde voordat een +evidencebestand wordt geschreven. Metadata bevat geen absolute paden, URL's, executables, +corpus-/bestandsnamen of ruwe serveroutput. Een succesvolle fase-2B-run schrijft op stdout alleen +het opaque `x11-`-evidence-id. Het ouderproces vangt de werkelijke stdout van het +browser-childproces op en laat die door dezelfde gesloten privacygate lopen; `privacy-audit.json` +moet daarvoor `capturedStdout: true` en voor stdout, metadata, server- en stateartefact expliciet +`pathlikeDetected: false` vastleggen. Vóór die denylist controleert een afzonderlijke positieve +stringveldengate per kanaal ieder tekstveld: alleen vastgelegde paden met een opaque UUID-, +Git-/SHA-/FNV-hash- of vaste generieke enumwaarde zijn geldig. Onbekende stringvelden en vrije +labels zijn standaard rood; `stringSchemaSafe` en `denylistSafe` moeten beide `true` zijn. + +Deze test bewijst de browser-dev-build met de `input[type=file]`-terugval. Hij bewijst niet de +Chromium File System Access API, de native OS-bestandkiezer of Tauri `plugin-dialog`/`plugin-fs`. +Die OS-specifieke routes vallen buiten fase 1 en deze fase-2A-stap. + +Fase 2B bewijst daarnaast alleen de browser-IndexedDB-route in de tijdelijke Playwright-context. +De abortproef is een echte IndexedDB-transaction-abort en toont dat de voorafgaande consistente +recordset byte-/digestgelijk blijft; zij simuleert geen procescrash midden in productcode, geen +browser-OOM-kill en geen stroomuitval. Er wordt nadrukkelijk geen Tauri-`fsync`-, filesystem- of +powerlossclaim gedaan. Alle evidence blijft uitsluitend lokaal onder `/tmp/xer-x11-evidence/` en +hoort niet in Git of een publicatie. + +De grootbestandproef bindt renderbewijs structureel aan de primaire Gantt-canvas onder +`data-testid="gantt-vscroll"`. Alleen geaggregeerde 64×64-samplekenmerken worden vastgelegd: +afmetingen, niet-transparante samples, kleur-buckets, luminantiebereik en overgangstelling. Geen +pixels of labels worden geëxporteerd. Voor de gecontroleerd-dirty same-tab recoveryreload geldt +`beforeUnloadDialogs` exact 1; `alert`, `confirm` en `prompt` blijven elk exact 0. diff --git a/tests/browser/git-evidence-contract.mjs b/tests/browser/git-evidence-contract.mjs new file mode 100644 index 000000000..dddb236b3 --- /dev/null +++ b/tests/browser/git-evidence-contract.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import { + assertGitEvidenceUnchanged, + assertMetadataGitIdentity, + metadataGitIdentity, +} from './x11-xer-evidence.mjs'; + +const expectedBase = '790d6cd8266682fa9b7798a3d1f9e0a1a2498db9'; +const expectedCommitParent = '00d82a43bc6e552d899c61dd0c3ae05f6e8f728a'; + +const begin = Object.freeze({ + toplevel: '/tmp/ops-xer-x11-harness', + branch: 'codex/xer-x11-harness', + head: '58f498ec76188b4459747a3dd1c72cce6d4a0ec4', + base: expectedBase, + commitParent: expectedCommitParent, + statusPorcelainV1: ' M known-dirty.txt\n?? known-untracked.txt\n', +}); + +const end = { ...begin }; +if (process.env.OPS_X11_GIT_MUTATION === 'base') { + end.base = '1111111111111111111111111111111111111111'; +} +if (process.env.OPS_X11_GIT_MUTATION === 'commit-parent') { + end.commitParent = '2222222222222222222222222222222222222222'; +} + +if (process.env.OPS_X11_GIT_MUTATION) { + assertGitEvidenceUnchanged(begin, end); +} else { + assert.doesNotThrow( + () => assertGitEvidenceUnchanged(begin, end), + 'exact gelijke Git-identiteit inclusief raw dirty/untracked-status moet groen blijven', + ); +} + +const mutations = [ + ['toplevel', '/tmp/andere-worktree'], + ['branch', 'andere-schone-branch'], + ['head', '1111111111111111111111111111111111111111'], + ['base', '2222222222222222222222222222222222222222'], + ['commitParent', '3333333333333333333333333333333333333333'], + ['statusPorcelainV1', `${begin.statusPorcelainV1}?? during-run.txt\n`], +]; + +for (const [field, value] of mutations) { + assert.throws( + () => assertGitEvidenceUnchanged(begin, { ...begin, [field]: value }), + (error) => { + assert.match(error.message, /git-eindintegriteitsgate rood/); + assert.match(error.message, new RegExp(`verschil=${field}(?:,|;)`)); + return true; + }, + `${field}-verschil moet de eindintegriteitsgate rood maken`, + ); +} + +const metadata = { git: metadataGitIdentity(begin) }; +assertMetadataGitIdentity(metadata, begin); +for (const [field, value] of [ + ['base', '1111111111111111111111111111111111111111'], + ['commitParent', '2222222222222222222222222222222222222222'], +]) { + const mutated = { ...metadata.git, [field]: value }; + assert.throws( + () => assertMetadataGitIdentity({ git: mutated }, begin), + /metadata-git-identiteitsgate rood/, + `${field}-verschil moet ook de metadata-identiteitsgate rood maken`, + ); +} + +console.log( + `OK git-evidence-contract: Git-eindpoorten, fasebasis ${expectedBase} en directe commitparent ` + + `${expectedCommitParent} zijn exact gepind`, +); diff --git a/tests/browser/helpers/ops-state.mjs b/tests/browser/helpers/ops-state.mjs new file mode 100644 index 000000000..80e203924 --- /dev/null +++ b/tests/browser/helpers/ops-state.mjs @@ -0,0 +1,501 @@ +const XER_OPENED_MESSAGE = 'notifications.xerImportOpened'; + +export async function readOpsState(page) { + return page.evaluate((xerMessageKey) => { + const bridge = window.__OPS__; + if (!bridge) throw new Error('window.__OPS__ ontbreekt'); + const state = bridge.store.getState(); + const notifications = Object.values(state.ui.notifications.entries); + const xerNotification = notifications.find((item) => item.messageKey === xerMessageKey); + return { + documents: state.documents.count, + // De 4 WBS-samenvattingsrijen zijn afgeleide structuur; SMALL-A telt de 8 echte + // geïmporteerde werk-/mijlpaaltaken die in de XER TASK-tabel staan. + tasks: state.tasks.importedCount, + relations: state.sequences.count, + cpm: state.cpmResult, + xerSourceArchive: state.xerSourceArchive.present, + xerSourceProjectId: state.xerSourceProjectId, + xerNotification: xerNotification ? { + messageKey: xerNotification.messageKey, + helpArticleId: xerNotification.helpArticleId ?? null, + } : null, + notifications: notifications.map((item) => ({ + severity: item.severity, + messageKey: item.messageKey, + helpArticleId: item.helpArticleId ?? null, + })), + }; + }, XER_OPENED_MESSAGE); +} + +export async function readMultiDocumentOpsState(page) { + return page.evaluate((xerMessageKey) => { + const bridge = window.__OPS__; + if (!bridge) throw new Error('window.__OPS__ ontbreekt'); + const state = bridge.store.getState(); + const notifications = Object.values(state.ui.notifications.entries); + const notification = notifications.find((item) => item.messageKey === xerMessageKey); + return { + report: { ...state.xerImportReport }, + documentCount: state.documents.count, + activeDocument: { ...state.activeDocument }, + ui: { + activeRibbonTab: state.ui.activeRibbonTab, + backstageSection: state.ui.backstageSection, + pendingHelpArticleConsumed: state.ui.pendingHelpArticleConsumed, + }, + notification: notification ? { + messageKey: notification.messageKey, + helpArticleId: notification.helpArticleId, + count: notification.count, + detailLines: Object.values(notification.detailLines).map((line) => ({ ...line })), + } : null, + }; + }, XER_OPENED_MESSAGE); +} + +export async function readPhase2BDocumentState(page) { + return page.evaluate(() => { + const state = window.__OPS__?.store.getState(); + if (!state) throw new Error('window.__OPS__-snapshot ontbreekt'); + return { + documentCount: state.documents.count, + documentOrder: Object.values(state.documents.order), + activeDocumentHash: state.documents.activeDocumentHash, + activeDocument: { ...state.activeDocument }, + ui: { + activeRibbonTab: state.ui.activeRibbonTab, + showResourcePanel: state.ui.showResourcePanel, + showHistogram: state.ui.showHistogram, + }, + }; + }); +} + +export function assertSmallAState(snapshot) { + const failures = []; + if (snapshot.documents !== 1) failures.push(`documents=${snapshot.documents}, verwacht 1`); + if (snapshot.tasks !== 8) failures.push(`tasks=${snapshot.tasks}, verwacht 8`); + if (snapshot.relations !== 7) failures.push(`relations=${snapshot.relations}, verwacht 7`); + if (!snapshot.cpm) failures.push('cpmResult ontbreekt'); + if (!snapshot.xerSourceArchive) failures.push('xerSourceArchive ontbreekt'); + if (!snapshot.xerSourceProjectId) failures.push('xerSourceProjectId is leeg'); + if (snapshot.xerNotification?.messageKey !== XER_OPENED_MESSAGE) { + failures.push('XER-openmelding ontbreekt'); + } + if (snapshot.xerNotification?.helpArticleId !== 'gids-xer-import') { + failures.push('XER-openmelding mist helpArticleId gids-xer-import'); + } + if (failures.length > 0) throw new Error(`SMALL-A store-assertie mislukt: ${failures.join('; ')}`); +} + +export const XER_OPEN_MESSAGE_KEY = XER_OPENED_MESSAGE; + +export const MULTI_DOCUMENT_EXPECTED_REPORT = Object.freeze({ + projectsSeen: 15, + documentsOpened: 12, + emptyProjectsSkipped: 3, + baselineProjectsExcluded: 0, + baselinesMaterialized: 0, + danglingBaselineReferences: 9, +}); + +export const MULTI_DOCUMENT_EXPECTED_ENCODING = Object.freeze({ + messageKey: 'notifications.xerImportEncoding', + value: 'windows-1252', +}); + +export const RECOVERY_EXPECTED_DOCUMENTS = 12; + +// De taakpin wordt door de echte headed proef aan de actieve rijke projectprojectie gemeten. +// De zichtbare browserroute telt 6.977 niet-samenvattende werk-/mijlpaaltaken. De X11-planoracle +// van 6.976 betreft de bronactiviteiten vóór deze UI-projectie. De overige tellingen komen uit +// dezelfde actieve, rijke projectprojectie. +export const LARGE_RESOURCE_EXPECTED_COUNTS = Object.freeze({ + tasks: 6977, + assignments: 52640, + calendars: 124, +}); + +export const LARGE_RESOURCE_EXPECTED_CATALOGS = Object.freeze({ + resources: 179, + sourceRows: 179, + activityCodeCatalogs: 12, + udfDefs: 3, +}); + +const RECOVERY_PRIVACY_LINES = Object.freeze([ + 'Restore unsaved work', + "Don't restore", + 'Restore', +]); + +const LARGE_RESOURCE_PRIVACY_LINES = Object.freeze(['RESOURCES', 'Histogram']); + +function assertExactPrivacyLines(observed, expected, label) { + if (!Array.isArray(observed) || observed.length !== expected.length) { + throw new Error(`${label}-privacyallowlist rood: regels=${JSON.stringify(observed)}`); + } + for (let index = 0; index < expected.length; index += 1) { + if (observed[index] !== expected[index]) { + throw new Error( + `${label}-privacyallowlist rood: regel ${index + 1}=${JSON.stringify(observed[index])}, ` + + `verwacht ${JSON.stringify(expected[index])}`, + ); + } + } +} + +function assertFiniteLatency(value, label, failures) { + if (!Number.isFinite(value) || value < 0) failures.push(`${label} is niet eindig`); +} + +const OPAQUE_EVIDENCE_ID = /^x11-[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const SHA256_FINGERPRINT = /^sha256-[0-9a-f]{64}$/; +const SHA256_HEX = /^[0-9a-f]{64}$/; +const GIT_OBJECT_ID = /^[0-9a-f]{40}$/; +const FNV1A_FINGERPRINT = /^fnv1a-[0-9a-f]{8}$/; + +const exactString = (...allowed) => (value) => allowed.includes(value); +const matchesString = (pattern) => (value) => pattern.test(value); + +// Positieve stringgrens: ieder evidencekanaal legt alle toegestane stringpaden en +// hun structurele waardeformaat vast. Een string op ieder ander pad is standaard rood. +export const PHASE2B_STRING_FIELD_ALLOWLIST = Object.freeze({ + metadata: Object.freeze([ + Object.freeze({ path: /^metadata\.runId$/, format: 'opaque-x11-uuid', validate: matchesString(OPAQUE_EVIDENCE_ID) }), + Object.freeze({ path: /^metadata\.scenario$/, format: 'scenario-enum', validate: exactString('multidoc-recovery', 'large-resources') }), + Object.freeze({ path: /^metadata\.git\.(?:worktreeFingerprint|branchFingerprint|statusFingerprint)$/, format: 'sha256-fingerprint', validate: matchesString(SHA256_FINGERPRINT) }), + Object.freeze({ path: /^metadata\.git\.(?:head|base|commitParent)$/, format: 'git-object-id', validate: matchesString(GIT_OBJECT_ID) }), + Object.freeze({ path: /^metadata\.server\.outputDigest$/, format: 'sha256-fingerprint', validate: matchesString(SHA256_FINGERPRINT) }), + Object.freeze({ path: /^metadata\.devBridgeOpenGate\.wrapped\.\d+$/, format: 'fixed-bridge-wrapper', validate: exactString('devBridge.openFromPath') }), + Object.freeze({ path: /^metadata\.devBridgeOpenGate\.storeFacade\.facadeFunctions\.\d+$/, format: 'fixed-facade-function', validate: exactString('devBridge.store.getState') }), + ]), + serverSummary: Object.freeze([ + Object.freeze({ path: /^serverSummary\.outputDigest$/, format: 'sha256-fingerprint', validate: matchesString(SHA256_FINGERPRINT) }), + ]), + stateArtifact: Object.freeze([ + Object.freeze({ path: /^stateArtifact\.abortedTransaction\.(?:beforeFingerprint|afterFingerprint)$/, format: 'fnv1a-fingerprint', validate: matchesString(FNV1A_FINGERPRINT) }), + Object.freeze({ path: /^stateArtifact\.autosaveTrigger\.route$/, format: 'fixed-visible-route', validate: exactString('visible:Home>Task') }), + Object.freeze({ path: /^stateArtifact\.initial\.(?:activeDocumentHash|manifestActiveDocumentHash)$/, format: 'fnv1a-fingerprint', validate: matchesString(FNV1A_FINGERPRINT) }), + Object.freeze({ path: /^stateArtifact\.initial\.(?:documentOrder|manifestOrder)\.\d+$/, format: 'fnv1a-fingerprint', validate: matchesString(FNV1A_FINGERPRINT) }), + Object.freeze({ path: /^stateArtifact\.initial\.documents\.\d+\.(?:documentHash|projectIdentityHash|selectorHash|ifcDigest)$/, format: 'fnv1a-fingerprint', validate: matchesString(FNV1A_FINGERPRINT) }), + Object.freeze({ path: /^stateArtifact\.initial\.documents\.\d+\.archiveDigest$/, format: 'sha256-hex', validate: matchesString(SHA256_HEX) }), + Object.freeze({ path: /^stateArtifact\.restored\.activeDocumentHash$/, format: 'fnv1a-fingerprint', validate: matchesString(FNV1A_FINGERPRINT) }), + Object.freeze({ path: /^stateArtifact\.restored\.documentOrder\.\d+$/, format: 'fnv1a-fingerprint', validate: matchesString(FNV1A_FINGERPRINT) }), + Object.freeze({ path: /^stateArtifact\.restored\.documents\.\d+\.(?:documentHash|projectIdentityHash|selectorHash)$/, format: 'fnv1a-fingerprint', validate: matchesString(FNV1A_FINGERPRINT) }), + Object.freeze({ path: /^stateArtifact\.restored\.documents\.\d+\.archiveDigest$/, format: 'sha256-hex', validate: matchesString(SHA256_HEX) }), + Object.freeze({ path: /^stateArtifact\.sourceArchiveDigest$/, format: 'sha256-hex', validate: matchesString(SHA256_HEX) }), + Object.freeze({ path: /^stateArtifact\.gantt\.rootAnchor$/, format: 'fixed-gantt-anchor', validate: exactString('gantt-vscroll') }), + Object.freeze({ path: /^stateArtifact\.privacy\.visibleLines\.0$/, format: 'fixed-ui-term', validate: exactString('Restore unsaved work', 'RESOURCES') }), + Object.freeze({ path: /^stateArtifact\.privacy\.visibleLines\.1$/, format: 'fixed-ui-term', validate: exactString("Don't restore", 'Histogram') }), + Object.freeze({ path: /^stateArtifact\.privacy\.visibleLines\.2$/, format: 'fixed-ui-term', validate: exactString('Restore') }), + ]), +}); + +const PATHLIKE_PATTERNS = [ + { label: 'slash', pattern: /[\\/]/ }, + { label: 'url', pattern: /\b[a-z][a-z0-9+.-]*:\/\//i }, + { label: 'drive', pattern: /\b[A-Za-z]:[\\/]/ }, + { label: 'source-file', pattern: /\.(?:xer|ifc|mpp|xml|csv)\b/i }, + { label: 'corpus-name', pattern: /\b(?:crawl-xer|rehab-2|p6diff-baseline)\b/i }, +]; + +function inspectAllowedStringFields(value, channel, violations, seen) { + if (typeof value === 'string') { + const rule = PHASE2B_STRING_FIELD_ALLOWLIST[channel.split('.')[0]]?.find(({ path }) => path.test(channel)); + if (!rule) { + violations.push(`${channel}:unknown-string-field`); + } else if (!rule.validate(value)) { + violations.push(`${channel}:invalid-${rule.format}`); + } + return; + } + if (!value || typeof value !== 'object' || seen.has(value)) return; + seen.add(value); + for (const [key, member] of Object.entries(value)) { + inspectAllowedStringFields(member, `${channel}.${key}`, violations, seen); + } +} + +function inspectPathlikeStrings(value, channel, violations, seen) { + if (typeof value === 'string') { + for (const { label, pattern } of PATHLIKE_PATTERNS) { + if (pattern.test(value)) violations.push(`${channel}:${label}`); + } + return; + } + if (!value || typeof value !== 'object' || seen.has(value)) return; + seen.add(value); + for (const [key, member] of Object.entries(value)) { + inspectPathlikeStrings(member, `${channel}.${key}`, violations, seen); + } +} + +/** Gesloten fase-2B-privacygrens voor alle tekstuele evidencekanalen. */ +export function assertPhase2BEvidencePrivacy(envelope) { + const shapeViolations = []; + const denylistViolations = []; + if (!OPAQUE_EVIDENCE_ID.test(envelope?.evidenceId ?? '')) shapeViolations.push('evidenceId:not-opaque'); + if (envelope?.stdout !== envelope?.evidenceId || !OPAQUE_EVIDENCE_ID.test(envelope?.stdout ?? '')) { + shapeViolations.push('stdout:not-opaque-id-only'); + } + for (const channel of ['metadata', 'serverSummary', 'stateArtifact']) { + if (!envelope?.[channel] || typeof envelope[channel] !== 'object' || Array.isArray(envelope[channel])) { + shapeViolations.push(`${channel}:object-required`); + continue; + } + inspectAllowedStringFields(envelope[channel], channel, shapeViolations, new WeakSet()); + inspectPathlikeStrings(envelope[channel], channel, denylistViolations, new WeakSet()); + } + inspectPathlikeStrings(envelope?.stdout, 'stdout', denylistViolations, new WeakSet()); + if (shapeViolations.length > 0 || denylistViolations.length > 0) { + const details = [ + ...[...new Set(shapeViolations)].map((violation) => `shape:${violation}`), + ...[...new Set(denylistViolations)].map((violation) => `denylist:${violation}`), + ]; + throw new Error(`X11 fase-2B-privacycontract rood: ${details.join('; ')}`); + } + return Object.freeze({ + stdoutOpaqueIdOnly: true, + stringSchemaSafe: true, + denylistSafe: true, + pathlikeDetected: false, + metadataSafe: true, + serverArtifactSafe: true, + stateArtifactSafe: true, + }); +} + +export function assertRecoveryEvidence(evidence) { + const failures = []; + const initial = evidence?.initial ?? {}; + const restored = evidence?.restored ?? {}; + if (initial.documentCount !== RECOVERY_EXPECTED_DOCUMENTS) failures.push(`initial.documentCount=${initial.documentCount}`); + if (initial.manifestDocumentCount !== RECOVERY_EXPECTED_DOCUMENTS) failures.push(`manifestDocumentCount=${initial.manifestDocumentCount}`); + if (restored.documentCount !== RECOVERY_EXPECTED_DOCUMENTS) failures.push(`restored.documentCount=${restored.documentCount}`); + + const initialDocuments = Array.isArray(initial.documents) ? initial.documents : []; + const restoredDocuments = Array.isArray(restored.documents) ? restored.documents : []; + if (initialDocuments.length !== RECOVERY_EXPECTED_DOCUMENTS) failures.push(`initial.documents=${initialDocuments.length}`); + if (restoredDocuments.length !== RECOVERY_EXPECTED_DOCUMENTS) failures.push(`restored.documents=${restoredDocuments.length}`); + + if (JSON.stringify(initial.documentOrder) !== JSON.stringify(initial.manifestOrder)) { + failures.push('manifestvolgorde wijkt af van documentvolgorde'); + } + if (JSON.stringify(restored.documentOrder) !== JSON.stringify(initial.documentOrder)) { + failures.push('herstelde documentvolgorde wijkt af'); + } + if (initial.activeDocumentHash !== initial.manifestActiveDocumentHash) failures.push('manifest activeDocumentId wijkt af'); + if (restored.activeDocumentHash !== initial.activeDocumentHash) failures.push('verkeerd hersteld actief document'); + + const documentHashes = new Set(); + const projectHashes = new Set(); + for (let index = 0; index < RECOVERY_EXPECTED_DOCUMENTS; index += 1) { + const before = initialDocuments[index]; + const after = restoredDocuments[index]; + const ordinal = index + 1; + if (!before || !after) continue; + if (before.ordinal !== ordinal || after.ordinal !== ordinal) failures.push(`document ${ordinal}: ordinal wijkt af`); + if (!before.documentHash || documentHashes.has(before.documentHash)) failures.push(`document ${ordinal}: documenthash ontbreekt/dubbel`); + documentHashes.add(before.documentHash); + if (!before.projectIdentityHash || projectHashes.has(before.projectIdentityHash)) failures.push(`document ${ordinal}: projectidentiteit ontbreekt/dubbel`); + projectHashes.add(before.projectIdentityHash); + if (after.documentHash !== before.documentHash) failures.push(`document ${ordinal}: identiteit niet behouden`); + if (after.projectIdentityHash !== before.projectIdentityHash) failures.push(`document ${ordinal}: project-id niet behouden`); + if (!before.sourceArchivePresent || !after.sourceArchivePresent) failures.push(`document ${ordinal}: bronarchief ontbreekt`); + if (!before.archiveDigest || after.archiveDigest !== before.archiveDigest) failures.push(`document ${ordinal}: archivedigest niet behouden`); + if (!Number.isInteger(before.archiveByteLength) || before.archiveByteLength <= 0 || after.archiveByteLength !== before.archiveByteLength) { + failures.push(`document ${ordinal}: archieflengte niet behouden`); + } + if (!before.selectorPresent || !after.selectorPresent || !before.selectorHash || after.selectorHash !== before.selectorHash) { + failures.push(`document ${ordinal}: selector niet behouden`); + } + if (!before.ifcDigest || !Number.isInteger(before.ifcByteLength) || before.ifcByteLength <= 0) { + failures.push(`document ${ordinal}: IFC-recoverysnapshot ontbreekt`); + } + if (!Number.isInteger(after.taskCount) || after.taskCount <= 0) failures.push(`document ${ordinal}: hersteld document is leeg`); + if (!after.cpmExecutable) failures.push(`document ${ordinal}: CPM niet uitvoerbaar`); + if (!after.cpmExecutedViaF5) failures.push(`document ${ordinal}: zichtbare F5-CPM-route niet uitgevoerd`); + } + + const throttle = evidence?.throttle ?? {}; + if (evidence?.autosaveTrigger?.route !== 'visible:Home>Task' || evidence?.autosaveTrigger?.taskDelta !== 1) { + failures.push('zichtbare autosavetrigger ontbreekt'); + } + assertFiniteLatency(throttle.intervalMs, 'throttle.intervalMs', failures); + if (!throttle.manifestUnchangedBeforeDue) failures.push('manifest wijzigde vóór throttletermijn'); + if (throttle.secondAddedAt - throttle.firstAddedAt < 9_500) failures.push('10s-throttle niet bewezen'); + const aborted = evidence?.abortedTransaction ?? {}; + if (!aborted.transactionAborted) failures.push('IndexedDB-transactie is niet werkelijk afgebroken'); + if (!aborted.beforeFingerprint || aborted.afterFingerprint !== aborted.beforeFingerprint) { + failures.push('vorige consistente recoveryset veranderde na abort'); + } + if (!evidence?.recoveryUi?.visible || evidence.recoveryUi.entryCount !== RECOVERY_EXPECTED_DOCUMENTS || + !evidence.recoveryUi.restoredViaVisibleButton) { + failures.push('normale zichtbare recoveryroute niet bewezen'); + } + if (evidence?.recoveryUi?.beforeUnloadDialogs !== 1) { + failures.push(`beforeUnloadDialogs=${evidence?.recoveryUi?.beforeUnloadDialogs}, verwacht exact 1`); + } + const recoveryDialogs = evidence?.runtime?.nativeDialogs ?? {}; + if (recoveryDialogs.alert !== 0) failures.push(`alert=${recoveryDialogs.alert}, verwacht 0`); + if (recoveryDialogs.confirm !== 0) failures.push(`confirm=${recoveryDialogs.confirm}, verwacht 0`); + if (recoveryDialogs.prompt !== 0) failures.push(`prompt=${recoveryDialogs.prompt}, verwacht 0`); + assertExactPrivacyLines(evidence?.privacy?.visibleLines, RECOVERY_PRIVACY_LINES, 'recovery'); + if (failures.length > 0) throw new Error(`X11 recoverycontract rood: ${failures.join('; ')}`); +} + +export function assertLargeResourceEvidence(evidence) { + const failures = []; + for (const [field, expected] of Object.entries(LARGE_RESOURCE_EXPECTED_COUNTS)) { + if (evidence?.counts?.[field] !== expected) failures.push(`counts.${field}=${evidence?.counts?.[field]}, verwacht ${expected}`); + } + if (!evidence?.sourceArchivePresent || !evidence.sourceArchiveDigest) failures.push('bronarchief/digest ontbreekt'); + if (!evidence?.selectorPresent) failures.push('projectselector ontbreekt'); + if (!evidence?.cpmPresent) failures.push('CPM ontbreekt'); + for (const [field, expected] of Object.entries(LARGE_RESOURCE_EXPECTED_CATALOGS)) { + if (evidence?.catalogs?.[field] !== expected) failures.push(`catalogs.${field}=${evidence?.catalogs?.[field]}, verwacht ${expected}`); + } + const gantt = evidence?.gantt ?? {}; + if (gantt.rootAnchor !== 'gantt-vscroll') failures.push('Gantt-root is niet specifiek aan gantt-vscroll gebonden'); + if (!gantt.primaryCanvasBound) failures.push('verkeerde canvas: primaire Gantt-canvas niet bewezen'); + if (!Number.isInteger(gantt.width) || gantt.width <= 300 || !Number.isInteger(gantt.height) || gantt.height <= 150) { + failures.push('primaire Gantt-canvas heeft ongeldige afmetingen'); + } + if (!Number.isInteger(gantt.sampleCount) || gantt.sampleCount < 1024) failures.push('Gantt-sample is te klein'); + if (!Number.isInteger(gantt.nonTransparentSamples) || gantt.nonTransparentSamples < gantt.sampleCount * 0.9) { + failures.push('Gantt-render bevat te weinig niet-transparante samples'); + } + if (!Number.isInteger(gantt.uniqueColorBuckets) || gantt.uniqueColorBuckets < 4 || + !Number.isFinite(gantt.luminanceRange) || gantt.luminanceRange < 8 || + !Number.isInteger(gantt.transitionCount) || gantt.transitionCount < 10) { + failures.push('lege Gantt: onvoldoende geaggregeerde pixelvariatie'); + } + for (const field of ['firstGanttRendered', 'resourcesPanelVisible', 'histogramVisible']) { + if (!evidence?.routes?.[field]) failures.push(`route.${field} ontbreekt`); + } + for (const field of ['importMs', 'firstGanttMs', 'tabActivationMs', 'resourcesPanelMs', 'histogramMs']) { + assertFiniteLatency(evidence?.latencies?.[field], `latencies.${field}`, failures); + } + if (evidence?.runtime?.crashed) failures.push('Chromium-page crashte'); + if (evidence?.runtime?.pageErrors !== 0) failures.push(`pageErrors=${evidence?.runtime?.pageErrors}`); + const dialogs = evidence?.runtime?.nativeDialogs ?? {}; + if (dialogs.alert !== 0 || dialogs.confirm !== 0 || dialogs.prompt !== 0) failures.push(`native dialogs=${JSON.stringify(dialogs)}`); + assertExactPrivacyLines(evidence?.privacy?.visibleLines, LARGE_RESOURCE_PRIVACY_LINES, 'grootbestand'); + if (failures.length > 0) throw new Error(`X11 grootbestandcontract rood: ${failures.join('; ')}`); +} + +export const MULTI_DOCUMENT_EXPECTED_TOAST_LINES = Object.freeze([ + 'XER file opened: 12 project documents.', + '15 projects found.', + '3 empty projects skipped.', + '9 dangling baseline references ignored.', + 'Text encoding selected: windows-1252.', + 'Read more', +]); + +const EXPECTED_SWITCH_ROUTES = new Map([ + [1, 'shortcut:Control+1'], + [5, 'shortcut:Control+5'], + [9, 'shortcut:Control+9'], + [10, 'click'], + [11, 'keyboard:ArrowRight'], + [12, 'keyboard:ArrowRight'], +]); + +function finiteLatency(value) { + return Number.isFinite(value) && value >= 0; +} + +export function normalizeVisibleToastLines(text) { + return String(text) + .split(/\r?\n/) + .map((line) => line.replace(/[^\S\r\n]+/g, ' ').trim()) + .filter(Boolean); +} + +export function assertMultiDocumentToastLines(lines) { + const observed = Array.isArray(lines) ? lines : []; + const expected = MULTI_DOCUMENT_EXPECTED_TOAST_LINES; + if (observed.length !== expected.length) { + const extras = observed.filter((line) => !expected.includes(line)); + throw new Error( + `X11 multi-documenttoast-allowlist rood: regels=${observed.length}, verwacht ${expected.length}` + + `${extras.length > 0 ? `; extra=${JSON.stringify(extras)}` : ''}`, + ); + } + for (let index = 0; index < expected.length; index += 1) { + if (observed[index] !== expected[index]) { + throw new Error( + `X11 multi-documenttoast-allowlist rood: regel ${index + 1}=${JSON.stringify(observed[index])}, ` + + `verwacht ${JSON.stringify(expected[index])}`, + ); + } + } +} + +export function assertMultiDocumentEvidence(evidence) { + const failures = []; + for (const [field, expected] of Object.entries(MULTI_DOCUMENT_EXPECTED_REPORT)) { + if (evidence.report?.[field] !== expected) { + failures.push(`report.${field}=${evidence.report?.[field]}, verwacht ${expected}`); + } + } + if (evidence.encoding?.messageKey !== MULTI_DOCUMENT_EXPECTED_ENCODING.messageKey) { + failures.push(`encoding-messageKey=${evidence.encoding?.messageKey ?? 'ontbreekt'}, verwacht ${MULTI_DOCUMENT_EXPECTED_ENCODING.messageKey}`); + } + if (evidence.encoding?.value !== MULTI_DOCUMENT_EXPECTED_ENCODING.value) { + failures.push(`encoding=${evidence.encoding?.value ?? 'ontbreekt'}, verwacht ${MULTI_DOCUMENT_EXPECTED_ENCODING.value}`); + } + assertMultiDocumentToastLines(evidence.toastLines); + if (!finiteLatency(evidence.openLatencyMs)) failures.push('openLatencyMs is niet eindig'); + + const documents = Array.isArray(evidence.documents) ? evidence.documents : []; + if (documents.length !== 12) failures.push(`documents=${documents.length}, verwacht 12`); + const documentHashes = new Set(); + const projectHashes = new Set(); + for (let index = 0; index < documents.length; index += 1) { + const document = documents[index]; + const expectedOrdinal = index + 1; + if (document.ordinal !== expectedOrdinal) failures.push(`documentordinal=${document.ordinal}, verwacht ${expectedOrdinal}`); + if (!document.documentHash) failures.push(`document ${expectedOrdinal}: documenthash ontbreekt`); + if (documentHashes.has(document.documentHash)) failures.push(`document ${expectedOrdinal}: dubbele documenthash`); + documentHashes.add(document.documentHash); + if (!document.projectIdentityHash) failures.push(`document ${expectedOrdinal}: project-id-hash ontbreekt`); + if (projectHashes.has(document.projectIdentityHash)) failures.push(`document ${expectedOrdinal}: dubbel project-id`); + projectHashes.add(document.projectIdentityHash); + if (!Number.isInteger(document.taskCount) || document.taskCount <= 0) failures.push(`document ${expectedOrdinal}: leeg document`); + if (!document.sourceArchivePresent) failures.push(`document ${expectedOrdinal}: bronarchief ontbreekt`); + if (!document.cpmPresent) failures.push(`document ${expectedOrdinal}: CPM ontbreekt`); + } + + const switches = Array.isArray(evidence.switches) ? evidence.switches : []; + if (switches.length !== 12) failures.push(`switches=${switches.length}, verwacht 12`); + for (let index = 0; index < switches.length; index += 1) { + const item = switches[index]; + const ordinal = index + 1; + const expectedRoute = EXPECTED_SWITCH_ROUTES.get(ordinal) ?? 'click'; + if (item.requestedOrdinal !== ordinal) failures.push(`switch ${ordinal}: requestedOrdinal=${item.requestedOrdinal}`); + if (item.observedOrdinal !== ordinal) failures.push(`switch ${ordinal}: schakelde naar ${item.observedOrdinal}`); + if (item.route !== expectedRoute) failures.push(`switch ${ordinal}: route=${item.route}, verwacht ${expectedRoute}`); + if (!item.outsideInput) failures.push(`switch ${ordinal}: focus stond in invoerveld`); + if (!item.tabVisible) failures.push(`switch ${ordinal}: actieve tab niet zichtbaar`); + if (!item.tabpanelCorrelated) failures.push(`switch ${ordinal}: tabpanel niet gecorreleerd`); + if (!item.activeDocumentHashCorrelated) failures.push(`switch ${ordinal}: actief document niet gecorreleerd`); + if (!finiteLatency(item.latencyMs)) failures.push(`switch ${ordinal}: latency is niet eindig`); + } + + const help = evidence.help ?? {}; + if (help.notificationHelpArticleId !== 'gids-xer-import') failures.push('verkeerde Help-artikelroute'); + if (help.activeTocTitle !== 'Opening Primavera P6 (.xer)') failures.push('verkeerde actieve Help-titel'); + if (help.articleHeading !== 'Opening Primavera P6 (.xer)') failures.push('verkeerde Help-artikelkop'); + if (help.activeRibbonTab !== 'file' || help.backstageSection !== 'help') failures.push('Backstage Help-route niet actief'); + if (!help.pendingHelpArticleConsumed) failures.push('Help-routeverzoek niet geconsumeerd'); + if (!help.hasMultiDocumentExplanation) failures.push('multi-documentuitleg ontbreekt'); + if (!help.hasEmptyProjectExplanation) failures.push('lege-projectuitleg ontbreekt'); + if (!help.hasBaselineExplanation) failures.push('baseline-uitleg ontbreekt'); + + if (failures.length > 0) throw new Error(`X11 multi-documentcontract rood: ${failures.join('; ')}`); +} diff --git a/tests/browser/recorded-dates.spec.ts b/tests/browser/recorded-dates.spec.ts new file mode 100644 index 000000000..0d38e3d41 --- /dev/null +++ b/tests/browser/recorded-dates.spec.ts @@ -0,0 +1,122 @@ +// "Datums zoals opgeslagen" voor XER (laag 3) — de eerste browserdekking van de standaard-aan-modus +// (her-check laag 3, bevinding 9). Echte gebruikersflow: een .xer via de ribbonknop Openen en de +// bestandskiezer; daarna wordt gecontroleerd wat de gebruiker ZIET — de strook, de celtekst in de +// tabel (mét de product-datumnotatie, precies de naad waar bevinding 1 zat) en de badge in het +// eigenschappenpaneel — met `window.__OPS__` alleen om de taak-id's op te zoeken en het paneel/de +// kolom aan te zetten. De fixture is dezelfde corpusloze orakel-fixture als in +// `tests/planning/check-xer-recorded-times.ts`: T1 legt vroege datums vast die van onze +// herberekening afwijken (⇒ de modus gaat vanzelf aan), T3 legt alleen het vroege paar vast +// (⇒ laat-start/-einde en speling zijn "niet vastgelegd"). +import type { Locator, Page } from '@playwright/test'; +import { expect, state, test } from './fixtures/ops'; + +const FIXTURE_HEADER = [ + 'task_id', 'proj_id', 'clndr_id', 'task_code', 'task_name', 'task_type', 'duration_type', + 'status_code', 'target_drtn_hr_cnt', 'remain_drtn_hr_cnt', 'target_start_date', 'target_end_date', + 'early_start_date', 'early_end_date', 'late_start_date', 'late_end_date', + 'total_float_hr_cnt', 'free_float_hr_cnt', +].join('\t'); + +const XER_FIXTURE = [ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tStandaard 8u\tCA_Base\t8\t40\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date', + '%R\tP1\tLaag3Browser\tC1\t2026-01-01\t2026-01-01', + '%T\tTASK', + `%F\t${FIXTURE_HEADER}`, + '%R\tT1\tP1\tC1\tA1\tDeviates\tTT_Task\tDT_FixedDUR\tTK_NotStart\t40\t40\t2026-01-02\t2026-01-09\t2026-01-05\t2026-01-12\t2026-01-10\t2026-01-17\t40\t8', + '%R\tT3\tP1\tC1\tA3\tEarlyOnly\tTT_Task\tDT_FixedDUR\tTK_NotStart\t16\t16\t2026-03-01\t2026-03-03\t2026-03-01\t2026-03-03\t\t\t\t', + '%E', +].join('\n'); + +function taskCell(page: Page, taskId: string, columnId: string): Locator { + return page.locator( + `[data-task-grid-surface-id="full-task-grid"] [data-grid-data-row="true"][data-grid-row-key="${taskId}"]` + + ` [data-grid-data-cell="true"][data-grid-column-id="${columnId}"]`, + ); +} + +async function openXerViaFileChooser(page: Page): Promise { + const openButton = page.locator('button.ribbon-btn').filter({ hasText: /^(Open|Openen)$/ }); + await expect(openButton).toHaveCount(1); + const chooserPromise = page.waitForEvent('filechooser'); + await openButton.click(); + const chooser = await chooserPromise; + await chooser.setFiles({ name: 'laag3.xer', mimeType: 'application/octet-stream', buffer: Buffer.from(XER_FIXTURE) }); +} + +test('datums zoals opgeslagen: een XER met restverschillen opent in de modus, met "niet vastgelegd" in tabel en badge', async ({ page, ops: _ops }) => { + await openXerViaFileChooser(page); + + // De modus gaat vanzelf aan (verse XER-import mét verschoven taken) en de strook zegt het. + const strip = page.locator('[data-ops-recorded-dates-active]'); + await expect(strip).toBeVisible(); + await expect(strip).toContainText(/Primavera/); + await expect.poll(() => state(page).then(snapshot => snapshot.tasks.length)).toBe(2); + const ids = await page.evaluate(() => { + const s = window.__OPS__!.store.getState(); + return { + deviates: s.tasks.find(task => task.wbsCode === 'A1')!.id, + earlyOnly: s.tasks.find(task => task.wbsCode === 'A3')!.id, + datesAsRecorded: s.datesAsRecorded, + scheduleStale: s.scheduleStale, + }; + }); + expect(ids.datesAsRecorded).toBe(true); + expect(ids.scheduleStale).toBe(false); + + // Tabel: de late-start-kolom erbij (een productinstelling, via dezelfde actie als de kolomkiezer) + // en dan de CELTEKST lezen — met de product-datumnotatie, want dat is de naad waar een verzonnen + // late datum ("01-03-2026") stond in plaats van "Niet vastgelegd". + await page.getByRole('button', { name: /^(Table|Tabel)$/ }).click(); + await expect(page.locator('[data-task-grid-surface-id="full-task-grid"] [role="grid"]')).toBeVisible(); + await page.evaluate(() => { + const s = window.__OPS__!.store.getState(); + const columns = s.taskGridSurfaces['full-task-grid'].columns; + const wanted = ['task.time.lateStart', 'task.time.totalFloat'] as const; + const missing = wanted.filter(id => !columns.some(column => column.id === id)); + if (missing.length > 0) { + s.setTaskGridColumns('full-task-grid', [ + ...columns, + ...missing.map(id => ({ id: id as typeof columns[number]['id'], width: 140, pinned: false })), + ]); + } + }); + await expect(taskCell(page, ids.earlyOnly, 'task.time.lateStart')).toHaveText(/^(Niet vastgelegd|Not recorded)$/); + await expect(taskCell(page, ids.earlyOnly, 'task.time.totalFloat')).toHaveText(/^(Niet vastgelegd|Not recorded)$/); + // De volledig vastgelegde taak toont gewoon P6's late start (10 januari 2026) in de datumnotatie. + await expect(taskCell(page, ids.deviates, 'task.time.lateStart')).toHaveText(/10.01.2026|2026.01.10|01.10.2026/); + await expect(taskCell(page, ids.deviates, 'task.time.lateStart')).not.toHaveText(/^(Niet vastgelegd|Not recorded)$/); + + // Eigenschappenpaneel: de badge zegt per taak hetzelfde als de tabel, en noemt Primavera alleen + // omdat dit een XER-herkomst is. + await page.evaluate((taskId) => { + const s = window.__OPS__!.store.getState(); + s.selectTask(taskId); + s.setUI({ showPropertiesPanel: true, rightPanelCollapsed: false }); + }, ids.earlyOnly); + await expect(page.locator('[data-ops-task-recorded-dates="partly-unrecorded"]')).toBeVisible(); + await page.evaluate((taskId) => { window.__OPS__!.store.getState().selectTask(taskId); }, ids.deviates); + const activeBadge = page.locator('[data-ops-task-recorded-dates="active"]'); + await expect(activeBadge).toBeVisible(); + await expect(activeBadge).toContainText(/Primavera/); + + // Eindreview bevinding 6: de rapporten kennen de "niet vastgelegd"-cel niet; in de modus staat + // daarom boven elk rapport (hier het standaardtype, de Gantt-afdruk) één melding die zegt dat je + // naar de datums uit het bestand kijkt. Buiten de modus is die melding weg. + await page.getByRole('button', { name: /^(Report|Rapport)$/ }).click(); + const reportNote = page.locator('[data-report-recorded-dates-note]'); + await expect(reportNote).toBeVisible(); + await expect(reportNote).toContainText(/(opgeslagen|recorded)/); + + // Herberekenen verlaat de modus: strook weg, en de kolom toont weer onze eigen, echte speling. + await page.locator('[data-ops-recorded-dates-recalculate]').click(); + await expect(strip).toHaveCount(0); + await expect(reportNote).toHaveCount(0); + await page.getByRole('button', { name: /^(Table|Tabel)$/ }).click(); + await expect.poll(() => state(page).then(() => page.evaluate(() => window.__OPS__!.store.getState().datesAsRecorded))).toBe(false); + await expect(taskCell(page, ids.earlyOnly, 'task.time.totalFloat')).not.toHaveText(/^(Niet vastgelegd|Not recorded)$/); +}); diff --git a/tests/browser/x11-multidoc-contract.mjs b/tests/browser/x11-multidoc-contract.mjs new file mode 100644 index 000000000..48dd6b504 --- /dev/null +++ b/tests/browser/x11-multidoc-contract.mjs @@ -0,0 +1,67 @@ +import { + assertMultiDocumentEvidence, + MULTI_DOCUMENT_EXPECTED_ENCODING, + MULTI_DOCUMENT_EXPECTED_REPORT, + MULTI_DOCUMENT_EXPECTED_TOAST_LINES, +} from './helpers/ops-state.mjs'; + +const routes = new Map([ + [1, 'shortcut:Control+1'], + [5, 'shortcut:Control+5'], + [9, 'shortcut:Control+9'], + [10, 'click'], + [11, 'keyboard:ArrowRight'], + [12, 'keyboard:ArrowRight'], +]); + +const evidence = { + report: { ...MULTI_DOCUMENT_EXPECTED_REPORT }, + encoding: { ...MULTI_DOCUMENT_EXPECTED_ENCODING }, + toastLines: [...MULTI_DOCUMENT_EXPECTED_TOAST_LINES], + openLatencyMs: 1, + documents: Array.from({ length: 12 }, (_, index) => ({ + ordinal: index + 1, + documentHash: `document-hash-${index + 1}`, + projectIdentityHash: `project-hash-${index + 1}`, + taskCount: 1, + sourceArchivePresent: true, + cpmPresent: true, + })), + switches: Array.from({ length: 12 }, (_, index) => ({ + requestedOrdinal: index + 1, + observedOrdinal: index + 1, + route: routes.get(index + 1) ?? 'click', + outsideInput: true, + tabVisible: true, + tabpanelCorrelated: true, + activeDocumentHashCorrelated: true, + latencyMs: 1, + })), + help: { + notificationHelpArticleId: 'gids-xer-import', + activeTocTitle: 'Opening Primavera P6 (.xer)', + articleHeading: 'Opening Primavera P6 (.xer)', + activeRibbonTab: 'file', + backstageSection: 'help', + pendingHelpArticleConsumed: true, + hasMultiDocumentExplanation: true, + hasEmptyProjectExplanation: true, + hasBaselineExplanation: true, + }, +}; + +const mutation = process.env.OPS_X11_MULTIDOC_MUTATION; +if (mutation === 'report-projects') evidence.report.projectsSeen = 14; +if (mutation === 'report-documents') evidence.report.documentsOpened = 11; +if (mutation === 'report-empty') evidence.report.emptyProjectsSkipped = 2; +if (mutation === 'report-dangling') evidence.report.danglingBaselineReferences = 8; +if (mutation === 'duplicate-project-id') evidence.documents[1].projectIdentityHash = evidence.documents[0].projectIdentityHash; +if (mutation === 'empty-document') evidence.documents[6].taskCount = 0; +if (mutation === 'shortcut') evidence.switches[4].observedOrdinal = 4; +if (mutation === 'help-route') evidence.help.notificationHelpArticleId = 'verkeerd-artikel'; +if (mutation === 'encoding-missing') evidence.encoding.value = null; +if (mutation === 'encoding-wrong') evidence.encoding.value = 'utf-8'; +if (mutation === 'toast-extra') evidence.toastLines.splice(-1, 0, 'Imported project Secret Alpha'); + +assertMultiDocumentEvidence(evidence); +console.log('OK x11-multidoc-contract: report, encoding, gesloten toastallowlist, 12 documenten, navigatie, latenties en Help-route gepind'); diff --git a/tests/browser/x11-phase2b-contract.mjs b/tests/browser/x11-phase2b-contract.mjs new file mode 100644 index 000000000..f253bd5ee --- /dev/null +++ b/tests/browser/x11-phase2b-contract.mjs @@ -0,0 +1,113 @@ +import { + assertLargeResourceEvidence, + assertRecoveryEvidence, + assertPhase2BEvidencePrivacy, + LARGE_RESOURCE_EXPECTED_CATALOGS, + LARGE_RESOURCE_EXPECTED_COUNTS, + RECOVERY_EXPECTED_DOCUMENTS, +} from './helpers/ops-state.mjs'; + +const recoveryDocuments = Array.from({ length: RECOVERY_EXPECTED_DOCUMENTS }, (_, index) => ({ + ordinal: index + 1, + documentHash: `document-${index + 1}`, + projectIdentityHash: `project-${index + 1}`, + selectorHash: `selector-${index + 1}`, + archiveDigest: `${String(index + 1).padStart(64, '0')}`, + archiveByteLength: 1000 + index, + ifcDigest: `ifc-${index + 1}`, + ifcByteLength: 2000 + index, + taskCount: 1, + sourceArchivePresent: true, + selectorPresent: true, + cpmExecutable: true, + cpmExecutedViaF5: true, +})); +const order = recoveryDocuments.map((document) => document.documentHash); +const recovery = { + initial: { + documentCount: RECOVERY_EXPECTED_DOCUMENTS, + manifestDocumentCount: RECOVERY_EXPECTED_DOCUMENTS, + activeDocumentHash: order.at(-1), + manifestActiveDocumentHash: order.at(-1), + documentOrder: [...order], + manifestOrder: [...order], + documents: recoveryDocuments.map((document) => ({ ...document })), + }, + throttle: { firstAddedAt: 1_000, secondAddedAt: 11_000, intervalMs: 10_000, manifestUnchangedBeforeDue: true }, + autosaveTrigger: { route: 'visible:Home>Task', taskDelta: 1 }, + abortedTransaction: { transactionAborted: true, beforeFingerprint: 'stable-set', afterFingerprint: 'stable-set' }, + recoveryUi: { visible: true, entryCount: RECOVERY_EXPECTED_DOCUMENTS, restoredViaVisibleButton: true, beforeUnloadDialogs: 1 }, + runtime: { nativeDialogs: { alert: 0, confirm: 0, prompt: 0 } }, + restored: { + documentCount: RECOVERY_EXPECTED_DOCUMENTS, + activeDocumentHash: order.at(-1), + documentOrder: [...order], + documents: recoveryDocuments.map(({ ifcDigest: _ifcDigest, ifcByteLength: _ifcByteLength, ...document }) => ({ ...document })), + }, + privacy: { visibleLines: ['Restore unsaved work', "Don't restore", 'Restore'] }, +}; + +const large = { + counts: { ...LARGE_RESOURCE_EXPECTED_COUNTS }, + sourceArchivePresent: true, + sourceArchiveDigest: 'a'.repeat(64), + selectorPresent: true, + cpmPresent: true, + catalogs: { ...LARGE_RESOURCE_EXPECTED_CATALOGS }, + gantt: { + rootAnchor: 'gantt-vscroll', + primaryCanvasBound: true, + width: 1200, + height: 600, + sampleCount: 4096, + nonTransparentSamples: 4096, + uniqueColorBuckets: 12, + luminanceRange: 48, + transitionCount: 320, + }, + routes: { firstGanttRendered: true, resourcesPanelVisible: true, histogramVisible: true }, + latencies: { importMs: 1, firstGanttMs: 1, tabActivationMs: 1, resourcesPanelMs: 1, histogramMs: 1 }, + runtime: { crashed: false, pageErrors: 0, nativeDialogs: { alert: 0, confirm: 0, prompt: 0 } }, + privacy: { visibleLines: ['RESOURCES', 'Histogram'] }, +}; + +const mutation = process.env.OPS_X11_PHASE2B_MUTATION; +if (mutation === 'recovery-count') recovery.initial.manifestDocumentCount = 11; +if (mutation === 'archive') recovery.restored.documents[0].sourceArchivePresent = false; +if (mutation === 'digest') recovery.restored.documents[0].archiveDigest = 'changed'; +if (mutation === 'selector') recovery.restored.documents[0].selectorPresent = false; +if (mutation === 'active-document') recovery.restored.activeDocumentHash = order[0]; +if (mutation === 'order') recovery.restored.documentOrder.reverse(); +if (mutation === 'missing-document') recovery.restored.documents.pop(); +if (mutation === 'beforeunload-99') recovery.recoveryUi.beforeUnloadDialogs = 99; +if (mutation === 'beforeunload-0') recovery.recoveryUi.beforeUnloadDialogs = 0; +if (mutation === 'dialog-alert') recovery.runtime.nativeDialogs.alert = 1; +if (mutation === 'dialog-confirm') recovery.runtime.nativeDialogs.confirm = 1; +if (mutation === 'dialog-prompt') recovery.runtime.nativeDialogs.prompt = 1; +if (mutation === 'large-tasks') large.counts.tasks -= 1; +if (mutation === 'large-assignments') large.counts.assignments -= 1; +if (mutation === 'large-calendars') large.counts.calendars -= 1; +if (mutation === 'large-resources') large.catalogs.resources -= 1; +if (mutation === 'large-source-rows') large.catalogs.sourceRows -= 1; +if (mutation === 'large-activity-catalogs') large.catalogs.activityCodeCatalogs -= 1; +if (mutation === 'large-udf-defs') large.catalogs.udfDefs -= 1; +if (mutation === 'wrong-gantt-canvas') large.gantt.primaryCanvasBound = false; +if (mutation === 'empty-gantt') { + large.gantt.uniqueColorBuckets = 1; + large.gantt.luminanceRange = 0; + large.gantt.transitionCount = 0; +} +if (mutation === 'resources-route') large.routes.resourcesPanelVisible = false; +if (mutation === 'histogram-route') large.routes.histogramVisible = false; +if (mutation === 'privacy') large.privacy.visibleLines.push('Imported project Secret Alpha'); + +assertRecoveryEvidence(recovery); +assertLargeResourceEvidence(large); +assertPhase2BEvidencePrivacy({ + evidenceId: 'x11-123e4567-e89b-12d3-a456-426614174000', + stdout: 'x11-123e4567-e89b-12d3-a456-426614174000', + metadata: { git: { head: 'a'.repeat(40) }, privacyAudit: { pathlikeDetected: false } }, + serverSummary: { httpStatus: 200, cwdMatched: true }, + stateArtifact: { counts: { documents: 12 } }, +}); +console.log('OK x11-phase2b-contract: recovery-, grootbestand-, route- en privacycontracten gepind'); diff --git a/tests/browser/x11-phase2b-mutations.mjs b/tests/browser/x11-phase2b-mutations.mjs new file mode 100644 index 000000000..a6d17033d --- /dev/null +++ b/tests/browser/x11-phase2b-mutations.mjs @@ -0,0 +1,69 @@ +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const phaseContract = fileURLToPath(new URL('./x11-phase2b-contract.mjs', import.meta.url)); +const privacyContract = fileURLToPath(new URL('./x11-phase2b-privacy-contract.mjs', import.meta.url)); + +const existingPhaseMutations = Object.freeze([ + 'recovery-count', 'archive', 'digest', 'selector', 'active-document', 'order', + 'missing-document', 'beforeunload-99', 'beforeunload-0', 'dialog-alert', + 'dialog-confirm', 'dialog-prompt', 'large-tasks', 'large-assignments', + 'large-calendars', 'large-resources', 'large-source-rows', + 'large-activity-catalogs', 'large-udf-defs', 'wrong-gantt-canvas', + 'empty-gantt', 'resources-route', 'histogram-route', 'privacy', +]); +const existingPrivacyMutations = Object.freeze([ + 'stdout-path', 'metadata-path', 'server-url', 'artifact-file', +]); +const positiveSchemaMutations = Object.freeze([ + 'metadata-private-label', 'state-private-label', 'server-private-label', + 'stdout-private-label', +]); + +const existingMutationCount = existingPhaseMutations.length + existingPrivacyMutations.length; +const totalMutationCount = existingMutationCount + positiveSchemaMutations.length; +if (existingMutationCount !== 28 || totalMutationCount !== 32) { + throw new Error( + `fase-2B-mutantentelling rood: bestaand=${existingMutationCount}, totaal=${totalMutationCount}`, + ); +} + +function expectSemanticFailure(script, envName, mutation, expectedError) { + const env = { ...process.env }; + delete env.OPS_X11_PHASE2B_MUTATION; + delete env.OPS_X11_PHASE2B_PRIVACY_MUTATION; + env[envName] = mutation; + const result = spawnSync(process.execPath, [script], { + env, + encoding: 'utf8', + maxBuffer: 1024 * 1024, + }); + if (result.status !== 1) { + throw new Error(`mutant ${mutation} gaf exitcode ${result.status}, verwacht 1`); + } + if (!expectedError.test(result.stderr)) { + throw new Error(`mutant ${mutation} faalde niet via het bedoelde contract`); + } +} + +for (const mutation of existingPhaseMutations) { + expectSemanticFailure( + phaseContract, + 'OPS_X11_PHASE2B_MUTATION', + mutation, + /(?:X11 (?:recoverycontract|grootbestandcontract)|grootbestand-privacyallowlist) rood:/, + ); +} +for (const mutation of [...existingPrivacyMutations, ...positiveSchemaMutations]) { + expectSemanticFailure( + privacyContract, + 'OPS_X11_PHASE2B_PRIVACY_MUTATION', + mutation, + /X11 fase-2B-privacycontract rood:/, + ); +} + +console.log( + `OK x11-phase2b-mutations: ${existingMutationCount} bestaande en ` + + `${positiveSchemaMutations.length} nieuwe mutanten semantisch rood`, +); diff --git a/tests/browser/x11-phase2b-privacy-contract.mjs b/tests/browser/x11-phase2b-privacy-contract.mjs new file mode 100644 index 000000000..064200de7 --- /dev/null +++ b/tests/browser/x11-phase2b-privacy-contract.mjs @@ -0,0 +1,37 @@ +import { assertPhase2BEvidencePrivacy } from './helpers/ops-state.mjs'; + +const evidenceId = 'x11-123e4567-e89b-12d3-a456-426614174000'; +const envelope = { + evidenceId, + stdout: evidenceId, + metadata: { + runId: evidenceId, + git: { + worktreeFingerprint: `sha256-${'a'.repeat(64)}`, + branchFingerprint: `sha256-${'b'.repeat(64)}`, + head: 'c'.repeat(40), + base: 'd'.repeat(40), + commitParent: 'e'.repeat(40), + statusFingerprint: `sha256-${'f'.repeat(64)}`, + statusClean: true, + }, + server: { httpStatus: 200, cwdMatched: true, assignedPort: 3017 }, + browser: { headless: false, visible: true, executableVerified: true, wayland: true }, + privacyAudit: { pathlikeDetected: false }, + }, + serverSummary: { httpStatus: 200, cwdMatched: true, outputDigest: `sha256-${'1'.repeat(64)}` }, + stateArtifact: { counts: { tasks: 6977 }, sourceArchiveDigest: '2'.repeat(64) }, +}; + +const mutation = process.env.OPS_X11_PHASE2B_PRIVACY_MUTATION; +if (mutation === 'stdout-path') envelope.stdout = `${evidenceId} /tmp/secret`; +if (mutation === 'metadata-path') envelope.metadata.leak = '/home/user/private'; +if (mutation === 'server-url') envelope.serverSummary.leak = 'http://localhost:3017/'; +if (mutation === 'artifact-file') envelope.stateArtifact.leak = 'rehab-2.xer'; +if (mutation === 'metadata-private-label') envelope.metadata.privateLabel = 'Secret Alpha'; +if (mutation === 'state-private-label') envelope.stateArtifact.privateLabel = 'Customer Apollo'; +if (mutation === 'server-private-label') envelope.serverSummary.privateLabel = 'Internal Blue'; +if (mutation === 'stdout-private-label') envelope.stdout = 'Confidential Delta'; + +assertPhase2BEvidencePrivacy(envelope); +console.log('OK x11-phase2b-privacy-contract: stdout, metadata, server- en stateartefact zijn gesloten'); diff --git a/tests/browser/x11-xer-evidence.mjs b/tests/browser/x11-xer-evidence.mjs new file mode 100644 index 000000000..c9bd6277c --- /dev/null +++ b/tests/browser/x11-xer-evidence.mjs @@ -0,0 +1,1537 @@ +import { access, lstat, mkdir, readFile, readlink, symlink, unlink, writeFile } from 'node:fs/promises'; +import { constants as fsConstants } from 'node:fs'; +import { execFileSync, spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; +import process from 'node:process'; +import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; +import { createHash, randomUUID } from 'node:crypto'; +import { + readOpsState, + readMultiDocumentOpsState, + readPhase2BDocumentState, + assertSmallAState, + assertMultiDocumentEvidence, + assertRecoveryEvidence, + assertLargeResourceEvidence, + assertPhase2BEvidencePrivacy, + assertMultiDocumentToastLines, + normalizeVisibleToastLines, + XER_OPEN_MESSAGE_KEY, +} from './helpers/ops-state.mjs'; + +const REPO_ROOT = resolve(fileURLToPath(new URL('../..', import.meta.url))); +const DEFAULT_CHROMIUM = '/home/nozzit/.cache/ms-playwright/chromium-1237/chrome-linux64/chrome'; +const FALLBACK_CHROMIUM = '/usr/bin/google-chrome'; +const DEFAULT_CORPUS_FILE = 'crawl-xer/p6diff-baseline.xer'; +const MULTI_DOCUMENT_CORPUS_FILE = 'crawl-xer/eh_P6Workshops/OZB-Start-09Dec24.xer'; +const MULTI_DOCUMENT_SCENARIO = 'multidoc-help'; +const RECOVERY_SCENARIO = 'multidoc-recovery'; +const LARGE_RESOURCE_SCENARIO = 'large-resources'; +const LARGE_RESOURCE_CORPUS_FILE = 'crawl-xer-extra/jailaff-xer-splitter/rehab-2.xer'; +const EVIDENCE_ROOT = '/tmp/xer-x11-evidence'; +const PHASE_2A_BASE = '790d6cd8266682fa9b7798a3d1f9e0a1a2498db9'; + +function runId() { + return `x11-${randomUUID()}`; +} + +function fingerprint(value) { + return `sha256-${createHash('sha256').update(String(value)).digest('hex')}`; +} + +function isExplicitCiMode() { + return process.env.OPS_XER_X11_CI === '1'; +} + +function fail(message) { + throw new Error(message); +} + +function gitOutput(args, label) { + try { + return execFileSync('git', args, { + cwd: REPO_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (error) { + const stderr = typeof error?.stderr === 'string' ? error.stderr.trim() : ''; + fail(`git-bewijs kon ${label} niet lezen${stderr ? `: ${stderr}` : ''}`); + } +} + +function readGitEvidence() { + const toplevel = gitOutput(['rev-parse', '--show-toplevel'], 'toplevel').trim(); + const branch = gitOutput(['branch', '--show-current'], 'branch').trim(); + const head = gitOutput(['rev-parse', 'HEAD'], 'HEAD').trim(); + const base = gitOutput(['rev-parse', `${PHASE_2A_BASE}^{commit}`], 'fase-2A-basis').trim(); + const commitParent = gitOutput(['rev-parse', 'HEAD^'], 'directe HEAD-parent').trim(); + const statusPorcelainV1 = gitOutput(['status', '--porcelain=v1'], 'status'); + if (toplevel !== REPO_ROOT) { + fail(`git-toplevel-gate rood: ${toplevel}, verwacht exact ${REPO_ROOT}`); + } + return { toplevel, branch, head, base, commitParent, statusPorcelainV1 }; +} + +export function assertGitEvidenceUnchanged(before, after) { + const fields = ['toplevel', 'branch', 'head', 'base', 'commitParent', 'statusPorcelainV1']; + const differences = fields.filter((field) => before[field] !== after[field]); + + if (differences.length > 0) { + fail( + `git-eindintegriteitsgate rood: verschil=${differences.join(',')}; ` + + `begin=${JSON.stringify(before)}; eind=${JSON.stringify(after)}`, + ); + } +} + +export function assertMetadataGitIdentity(metadata, expected) { + const observed = metadata.git; + const sanitizedExpected = metadataGitIdentity(expected); + if (JSON.stringify(observed) !== JSON.stringify(sanitizedExpected)) { + fail(`metadata-git-identiteitsgate rood: observed=${JSON.stringify(observed)}; expected=${JSON.stringify(expected)}`); + } +} + +export function metadataGitIdentity(evidence) { + return { + worktreeFingerprint: fingerprint(evidence.toplevel), + branchFingerprint: fingerprint(evidence.branch), + head: evidence.head, + base: evidence.base, + commitParent: evidence.commitParent, + statusFingerprint: fingerprint(evidence.statusPorcelainV1), + statusClean: evidence.statusPorcelainV1 === '', + integrityFieldsMatched: true, + }; +} + +async function readableFile(path, label) { + try { + await access(path, fsConstants.R_OK); + } catch { + fail(`${label} ontbreekt of is niet leesbaar: ${path}`); + } +} + +async function preflight() { + const scenario = process.env.OPS_XER_X11_SCENARIO?.trim() || 'small-a'; + if (!['small-a', MULTI_DOCUMENT_SCENARIO, RECOVERY_SCENARIO, LARGE_RESOURCE_SCENARIO].includes(scenario)) { + fail(`onbekend OPS_XER_X11_SCENARIO: ${scenario}`); + } + const corpusFile = scenario === LARGE_RESOURCE_SCENARIO + ? LARGE_RESOURCE_CORPUS_FILE + : [MULTI_DOCUMENT_SCENARIO, RECOVERY_SCENARIO].includes(scenario) + ? MULTI_DOCUMENT_CORPUS_FILE + : DEFAULT_CORPUS_FILE; + const corpusRoot = process.env.OPS_XER_CORPUS?.trim(); + const display = process.env.DISPLAY?.trim(); + const waylandSocket = resolve(process.env.XDG_RUNTIME_DIR?.trim() || '/run/user/1000', process.env.WAYLAND_DISPLAY?.trim() || 'wayland-0'); + const chromiumPath = [ + process.env.OPS_CHROMIUM_PATH?.trim(), + DEFAULT_CHROMIUM, + FALLBACK_CHROMIUM, + ].find((candidate) => candidate && existsSync(candidate)); + + if (isExplicitCiMode()) { + console.error('SKIP: expliciete CI-modus (OPS_XER_X11_CI=1)'); + return null; + } + + if (!corpusRoot) fail('OPS_XER_CORPUS is verplicht voor een lokale X11-run'); + await readableFile(resolve(corpusRoot, corpusFile), 'XER-corpusbestand'); + if (!chromiumPath) fail('geen bruikbaar Chromium-executable gevonden'); + if (!display && !existsSync(waylandSocket)) { + fail('geen bruikbaar desktopdisplay: DISPLAY is leeg en Wayland-socket ontbreekt'); + } + + return { + corpusRoot: resolve(corpusRoot), + xerPath: resolve(corpusRoot, corpusFile), + scenario, + chromiumPath, + display: display || null, + waylandSocket: existsSync(waylandSocket) ? waylandSocket : null, + }; +} + +async function waitForDevServer(child) { + let output = `[harness] SERVER_PID=${child.pid}\n[harness] DEV_ENV OPS_DEV_GUARDED=absent OPS_DEV_PORT=absent\n`; + let assignedUrl = null; + + const observe = (chunk) => { + output += chunk.toString(); + assignedUrl ??= output.match(/http:\/\/localhost:\d+\//)?.[0] ?? null; + }; + child.stdout.on('data', observe); + child.stderr.on('data', observe); + + const deadline = Date.now() + 30_000; + while (!assignedUrl && Date.now() < deadline) { + if (child.exitCode !== null) fail(`npm run dev stopte vóór URL (${child.exitCode}). Output:\n${output}`); + await new Promise((resolvePromise) => setTimeout(resolvePromise, 100)); + } + if (!assignedUrl) fail(`npm run dev gaf binnen 30s geen toegewezen URL. Output:\n${output}`); + + let response = null; + let lastFetchError = null; + while (!response && Date.now() < deadline) { + try { + const candidate = await fetch(assignedUrl); + if (candidate.status === 200) response = candidate; + else fail(`dev-server ${assignedUrl} gaf HTTP ${candidate.status}`); + } catch (error) { + lastFetchError = error; + await new Promise((resolvePromise) => setTimeout(resolvePromise, 100)); + } + } + if (!response) fail(`dev-server ${assignedUrl} werd niet bereikbaar: ${lastFetchError}. Output:\n${output}`); + + const procCwd = await readlink(`/proc/${child.pid}/cwd`); + if (procCwd !== REPO_ROOT) { + fail(`servercwd is ${procCwd}, verwacht exact ${REPO_ROOT}`); + } + + const launchConfig = JSON.parse(await readFile(resolve(REPO_ROOT, '.claude/launch.json'), 'utf8')); + const recordedPort = launchConfig.opsDevPort; + const assignedPort = Number(new URL(assignedUrl).port); + const normalRoute = output.includes( + `▶ open-planner-studio dev — worktree "${REPO_ROOT.split('/').pop()}" → ${assignedUrl}`, + ); + if (!normalRoute) { + fail(`serveroutput bewijst de normale bewaakte devroute niet. Output:\n${output}`); + } + if (!Number.isInteger(recordedPort) || assignedPort !== recordedPort) { + fail(`devpoort ${assignedPort} is niet de vaste worktreepoort ${recordedPort}`); + } + + return { + assignedUrl, + pid: child.pid, + procCwd, + output, + guard: { + inheritedEnvRemoved: true, + normalRoute, + externallyForcedPort: false, + recordedPort, + }, + }; +} + +async function startDevServer() { + const npmCommand = process.env.OPS_NPM_PATH?.trim() || 'npm'; + const childEnv = { ...process.env }; + delete childEnv.OPS_DEV_GUARDED; + delete childEnv.OPS_DEV_PORT; + if ('OPS_DEV_GUARDED' in childEnv || 'OPS_DEV_PORT' in childEnv) { + fail('serverguard-env kon niet worden opgeschoond'); + } + const child = spawn(npmCommand, ['run', 'dev'], { + cwd: REPO_ROOT, + detached: true, + env: childEnv, + stdio: ['ignore', 'pipe', 'pipe'], + }); + try { + const server = await waitForDevServer(child); + return { child, server }; + } catch (error) { + await stopDevServer(child); + throw error; + } +} + +function privacySafeServerSummary(server) { + return { + processObserved: Number.isInteger(server.pid) && server.pid > 0, + httpStatus: 200, + assignedPort: Number(new URL(server.assignedUrl).port), + cwdMatched: server.procCwd === REPO_ROOT, + normalGuardedRoute: server.guard.normalRoute === true, + inheritedGuardEnvRemoved: server.guard.inheritedEnvRemoved === true, + externallyForcedPort: server.guard.externallyForcedPort === true, + fixedPortMatched: Number(new URL(server.assignedUrl).port) === server.guard.recordedPort, + outputDigest: fingerprint(server.output), + }; +} + +async function ensureDependencies() { + const linkPath = resolve(REPO_ROOT, 'node_modules'); + if (existsSync(linkPath)) return { linkPath: null }; + const externalCandidates = [ + process.env.OPS_NODE_MODULES_DIR?.trim(), + '/home/nozzit/open-aec/open-planner-studio/node_modules', + ].filter(Boolean); + const external = externalCandidates.find((candidate) => + existsSync(resolve(candidate, 'react/package.json')) && existsSync(resolve(candidate, 'vite/package.json')), + ); + if (!external) fail('geen bestaand extern node_modules-pad; stel OPS_NODE_MODULES_DIR in'); + await readableFile(resolve(external, 'react/package.json'), 'extern node_modules'); + await symlink(external, linkPath, 'dir'); + return { linkPath }; +} + +async function removeTemporaryDependencies(linkPath) { + if (!linkPath) return; + try { + const stat = await lstat(linkPath); + if (stat.isSymbolicLink()) await unlink(linkPath); + } catch { /* opruimen is best effort; de runstatus blijft leidend */ } +} + +function loadPlaywrightCore() { + const candidates = [ + process.env.OPS_PLAYWRIGHT_DIR?.trim(), + '/home/nozzit/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules', + ].filter(Boolean); + for (const candidate of candidates) { + try { + const require = createRequire(import.meta.url); + const packageJson = resolve(candidate, 'package.json'); + if (existsSync(packageJson)) { + const metadata = JSON.parse(require(packageJson)); + if (metadata.name === 'playwright-core') return require(candidate); + } + return require(require.resolve('playwright-core', { paths: [candidate] })); + } catch { /* probeer het volgende expliciete/externe pad */ } + } + fail('playwright-core niet gevonden; stel OPS_PLAYWRIGHT_DIR in'); +} + +async function assertNoDirectPathOpen() { + const source = await readFile(fileURLToPath(import.meta.url), 'utf8'); + const forbidden = [ + ['open', 'From', 'Path'].join(''), + ['open', 'From', 'Path', 'WithIO'].join(''), + ['open', 'Help', 'Article'].join(''), + ]; + if (forbidden.some((token) => source.includes(token))) { + fail('statische guard: directe pad- of Help-openactie is verboden in het X11-harnas'); + } +} + +async function installDevBridgeOpenGate(page) { + return page.evaluate(() => { + const bridge = window.__OPS__; + if (!bridge) throw new Error('window.__OPS__ ontbreekt voor anti-sluiproute-gate'); + const gate = { calls: [], wrapped: [] }; + window.__OPS_X11_BRIDGE_GATE__ = gate; + + const liveGetState = bridge.store.getState.bind(bridge.store); + const dataRecord = (entries) => { + const record = Object.create(null); + for (const [key, value] of entries) record[key] = value; + return Object.freeze(record); + }; + const hashScalar = (value) => { + if (typeof value !== 'string' || value.length === 0) return null; + let hash = 0x811c9dc5; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return `fnv1a-${(hash >>> 0).toString(16).padStart(8, '0')}`; + }; + const makeSnapshot = () => { + const live = liveGetState(); + const notificationEntries = Object.create(null); + const liveNotifications = Array.isArray(live.ui?.notifications) ? live.ui.notifications : []; + for (let index = 0; index < liveNotifications.length; index += 1) { + const item = liveNotifications[index]; + const detailEntries = Object.create(null); + const liveDetails = Array.isArray(item.detailLines) ? item.detailLines : []; + for (let detailIndex = 0; detailIndex < liveDetails.length; detailIndex += 1) { + const detail = liveDetails[detailIndex]; + detailEntries[String(detailIndex)] = dataRecord([ + ['messageKey', typeof detail.messageKey === 'string' ? detail.messageKey : null], + ['count', Number.isFinite(detail.params?.count) ? detail.params.count : null], + ['encoding', typeof detail.params?.encoding === 'string' ? detail.params.encoding : null], + ]); + } + Object.freeze(detailEntries); + notificationEntries[String(index)] = dataRecord([ + ['severity', typeof item.severity === 'string' ? item.severity : null], + ['messageKey', typeof item.messageKey === 'string' ? item.messageKey : null], + ['helpArticleId', typeof item.helpArticleId === 'string' ? item.helpArticleId : null], + ['count', Number.isFinite(item.params?.count) ? item.params.count : null], + ['detailLines', detailEntries], + ]); + } + Object.freeze(notificationEntries); + const importedTaskCount = Array.isArray(live.tasks) + ? live.tasks.filter((task) => task.isSummary !== true && (task.childIds?.length ?? 0) === 0).length + : 0; + const rawReport = live.xerSourceArchive?.diagnostics?.file?.importReport; + const documentOrderEntries = Object.create(null); + const liveDocuments = Array.isArray(live.documents) ? live.documents : []; + for (let index = 0; index < liveDocuments.length; index += 1) { + documentOrderEntries[String(index)] = hashScalar(liveDocuments[index]?.id); + } + Object.freeze(documentOrderEntries); + const sourceResourceCatalog = live.xerSourceArchive?.readModel?.resourceCatalog; + const sourceMetadataCatalog = live.xerSourceArchive?.readModel?.metadataCatalog; + const importReport = dataRecord([ + ['projectsSeen', Number.isInteger(rawReport?.projectsSeen) ? rawReport.projectsSeen : null], + ['documentsOpened', Number.isInteger(rawReport?.documentsOpened) ? rawReport.documentsOpened : null], + ['emptyProjectsSkipped', Number.isInteger(rawReport?.emptyProjectsSkipped) ? rawReport.emptyProjectsSkipped : null], + ['baselineProjectsExcluded', Number.isInteger(rawReport?.baselineProjectsExcluded) ? rawReport.baselineProjectsExcluded : null], + ['baselinesMaterialized', Number.isInteger(rawReport?.baselinesMaterialized) ? rawReport.baselinesMaterialized : null], + ['danglingBaselineReferences', Number.isInteger(rawReport?.danglingBaselineReferences) ? rawReport.danglingBaselineReferences : null], + ]); + return dataRecord([ + ['documents', dataRecord([ + ['count', liveDocuments.length], + ['order', documentOrderEntries], + ['activeDocumentHash', hashScalar(live.activeDocumentId)], + ])], + ['tasks', dataRecord([['importedCount', importedTaskCount]])], + ['sequences', dataRecord([['count', Array.isArray(live.sequences) ? live.sequences.length : 0]])], + ['cpmResult', Boolean(live.cpmResult)], + ['xerSourceArchive', dataRecord([['present', Boolean(live.xerSourceArchive)]])], + ['xerSourceProjectId', typeof live.xerSourceProjectId === 'string' ? live.xerSourceProjectId : null], + ['xerImportReport', importReport], + ['activeDocument', dataRecord([ + ['documentHash', hashScalar(live.activeDocumentId)], + ['projectIdentityHash', hashScalar(live.xerSourceProjectId)], + ['selectorHash', hashScalar(live.xerSourceProjectId)], + ['selectorPresent', typeof live.xerSourceProjectId === 'string' && live.xerSourceProjectId.length > 0], + ['taskCount', importedTaskCount], + ['sequenceCount', Array.isArray(live.sequences) ? live.sequences.length : 0], + ['assignmentCount', Array.isArray(live.assignments) ? live.assignments.length : 0], + ['resourceCount', Array.isArray(live.resources) ? live.resources.length : 0], + ['calendarCount', Array.isArray(live.calendars) ? live.calendars.length : 0], + ['activityCodeTypeCount', Array.isArray(live.activityCodeTypes) ? live.activityCodeTypes.length : 0], + ['customFieldDefCount', Array.isArray(live.customFieldDefs) ? live.customFieldDefs.length : 0], + ['cpmPresent', Boolean(live.cpmResult)], + ['sourceArchivePresent', Boolean(live.xerSourceArchive)], + ['archiveDigest', typeof live.xerSourceArchive?.sha256 === 'string' ? live.xerSourceArchive.sha256 : null], + ['archiveByteLength', Number.isInteger(live.xerSourceArchive?.byteLength) ? live.xerSourceArchive.byteLength : null], + ['sourceResourceCatalogCount', Array.isArray(sourceResourceCatalog?.resources) ? sourceResourceCatalog.resources.length : 0], + ['sourceResourceRowCount', Array.isArray(sourceResourceCatalog?.rows?.resources) ? sourceResourceCatalog.rows.resources.length : 0], + ['sourceMetadataActivityCodeTypeCount', Array.isArray(sourceMetadataCatalog?.activityCodeTypes) ? sourceMetadataCatalog.activityCodeTypes.length : 0], + ['sourceMetadataCustomFieldDefCount', Array.isArray(sourceMetadataCatalog?.customFieldDefs) ? sourceMetadataCatalog.customFieldDefs.length : 0], + ])], + ['ui', dataRecord([ + ['activeRibbonTab', typeof live.ui?.activeRibbonTab === 'string' ? live.ui.activeRibbonTab : null], + ['backstageSection', typeof live.ui?.backstageSection === 'string' ? live.ui.backstageSection : null], + ['pendingHelpArticleConsumed', live.ui?.pendingHelpArticleId === null], + ['showResourcePanel', Boolean(live.ui?.showResourcePanel)], + ['showHistogram', Boolean(live.ui?.showHistogram)], + ['notifications', dataRecord([ + ['count', liveNotifications.length], + ['entries', notificationEntries], + ])], + ])], + ]); + }; + const storeFacade = Object.create(null); + Object.defineProperty(storeFacade, 'getState', { + value: () => makeSnapshot(), + enumerable: true, + writable: false, + configurable: false, + }); + Object.freeze(storeFacade); + Object.defineProperty(bridge, 'store', { + value: storeFacade, + enumerable: true, + writable: false, + configurable: false, + }); + + const inspectFunctions = (value, path, found, invalidObjects, seenObjects) => { + if (typeof value === 'function') { + found.push(path); + return; + } + if (!value || typeof value !== 'object' || seenObjects.has(value)) return; + seenObjects.add(value); + if (Array.isArray(value) || Object.getPrototypeOf(value) !== null) invalidObjects.push(path); + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor && 'value' in descriptor) { + inspectFunctions(descriptor.value, `${path}.${String(key)}`, found, invalidObjects, seenObjects); + } + } + }; + const inspectStoreFacade = () => { + const snapshot = storeFacade.getState(); + const secondSnapshot = storeFacade.getState(); + const facadeFunctions = []; + const snapshotFunctions = []; + const invalidFacadeObjects = []; + const invalidSnapshotObjects = []; + inspectFunctions(storeFacade, 'devBridge.store', facadeFunctions, invalidFacadeObjects, new WeakSet()); + inspectFunctions(snapshot, 'snapshot', snapshotFunctions, invalidSnapshotObjects, new WeakSet()); + const result = { + frozen: Object.isFrozen(storeFacade), + freshSnapshots: snapshot !== secondSnapshot, + facadeFunctions, + snapshotFunctions, + invalidFacadeObjects, + invalidSnapshotObjects, + openFileAbsent: !Reflect.has(snapshot, 'openFile'), + applyOpenedImportAbsent: !Reflect.has(snapshot, 'applyOpenedImport'), + }; + if (!result.frozen || !result.freshSnapshots || + JSON.stringify(result.facadeFunctions) !== JSON.stringify(['devBridge.store.getState']) || + result.snapshotFunctions.length !== 0 || result.invalidFacadeObjects.length !== 0 || + result.invalidSnapshotObjects.length !== 0 || !result.openFileAbsent || !result.applyOpenedImportAbsent) { + throw new Error(`read-only-store-gate rood: ${JSON.stringify(result)}`); + } + return result; + }; + + const seen = new WeakSet(); + + const visit = (value, path) => { + if (!value || (typeof value !== 'object' && typeof value !== 'function') || seen.has(value)) return; + seen.add(value); + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string' || (path === 'devBridge' && (key === 'store' || key === 'log'))) continue; + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !('value' in descriptor)) continue; + const memberPath = `${path}.${key}`; + const member = descriptor.value; + if (typeof member === 'function' && /open|import/i.test(key)) { + const blocked = function blockedDevBridgeOpen(...args) { + gate.calls.push({ method: memberPath, argc: args.length }); + throw new Error(`X11 anti-sluiproute: ${memberPath} werd aangeroepen`); + }; + Object.defineProperty(value, key, { ...descriptor, value: blocked }); + gate.wrapped.push(memberPath); + continue; + } + visit(member, memberPath); + } + }; + + visit(bridge, 'devBridge'); + if (gate.wrapped.length === 0) throw new Error('anti-sluiproute-gate vond geen devBridge-openmethoden'); + return { wrapped: [...gate.wrapped], storeFacade: inspectStoreFacade() }; + }); +} + +async function readDevBridgeOpenGate(page) { + return page.evaluate(() => { + const bridge = window.__OPS__; + if (!bridge) throw new Error('window.__OPS__ ontbreekt bij gate-readback'); + const inspectFunctions = (value, path, found, invalidObjects, seenObjects) => { + if (typeof value === 'function') { + found.push(path); + return; + } + if (!value || typeof value !== 'object' || seenObjects.has(value)) return; + seenObjects.add(value); + if (Array.isArray(value) || Object.getPrototypeOf(value) !== null) invalidObjects.push(path); + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor && 'value' in descriptor) { + inspectFunctions(descriptor.value, `${path}.${String(key)}`, found, invalidObjects, seenObjects); + } + } + }; + const snapshot = bridge.store.getState(); + const secondSnapshot = bridge.store.getState(); + const facadeFunctions = []; + const snapshotFunctions = []; + const invalidFacadeObjects = []; + const invalidSnapshotObjects = []; + inspectFunctions(bridge.store, 'devBridge.store', facadeFunctions, invalidFacadeObjects, new WeakSet()); + inspectFunctions(snapshot, 'snapshot', snapshotFunctions, invalidSnapshotObjects, new WeakSet()); + const storeFacade = { + frozen: Object.isFrozen(bridge.store), + freshSnapshots: snapshot !== secondSnapshot, + facadeFunctions, + snapshotFunctions, + invalidFacadeObjects, + invalidSnapshotObjects, + openFileAbsent: !Reflect.has(snapshot, 'openFile'), + applyOpenedImportAbsent: !Reflect.has(snapshot, 'applyOpenedImport'), + }; + if (!storeFacade.frozen || !storeFacade.freshSnapshots || + JSON.stringify(storeFacade.facadeFunctions) !== JSON.stringify(['devBridge.store.getState']) || + storeFacade.snapshotFunctions.length !== 0 || storeFacade.invalidFacadeObjects.length !== 0 || + storeFacade.invalidSnapshotObjects.length !== 0 || !storeFacade.openFileAbsent || + !storeFacade.applyOpenedImportAbsent) { + throw new Error(`read-only-store-gate rood: ${JSON.stringify(storeFacade)}`); + } + return { + calls: [...(window.__OPS_X11_BRIDGE_GATE__?.calls ?? [])], + wrapped: [...(window.__OPS_X11_BRIDGE_GATE__?.wrapped ?? [])], + storeFacade, + }; + }); +} + +function assertPrivacySafeToast(text, box) { + const normalized = text.replace(/\s+/g, ' ').trim(); + const expected = /^XER file opened: \d+ project documents?\. \d+ projects? found\.(?: \d+ enum fallbacks?\.)? Read more$/; + if (!expected.test(normalized)) fail(`toasttekst valt buiten privacyveilige allowlist: ${JSON.stringify(normalized)}`); + if (/[\\/]|\.xer\b/i.test(normalized)) fail(`toasttekst bevat een pad of bestandsnaam: ${JSON.stringify(normalized)}`); + if (!box || box.width < 180 || box.height < 40 || box.width > 1440 || box.height > 300) { + fail(`toastclip heeft ongeldige afmetingen: ${JSON.stringify(box)}`); + } +} + +function assertPrivacySafeMultiDocumentToast(text, box) { + assertMultiDocumentToastLines(normalizeVisibleToastLines(text)); + if (!box || box.width < 180 || box.height < 80 || box.width > 1440 || box.height > 500) { + fail(`multi-documenttoast heeft ongeldige afmetingen: ${JSON.stringify(box)}`); + } +} + +async function openWithVisibleFileChooser(page, xerPath) { + const openButton = page.locator('button.ribbon-btn').filter({ hasText: /^Open$/ }); + if (await openButton.count() !== 1) fail(`zichtbare Engelse Open-knop niet uniek: ${await openButton.count()}`); + const fileChooserPromise = page.waitForEvent('filechooser'); + await openButton.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(xerPath); +} + +async function readActiveTabCorrelation(page) { + return page.evaluate(() => { + const hashScalar = (value) => { + if (typeof value !== 'string' || value.length === 0) return null; + let hash = 0x811c9dc5; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return `fnv1a-${(hash >>> 0).toString(16).padStart(8, '0')}`; + }; + const activeTab = document.querySelector('[role="tab"][aria-selected="true"]'); + const panel = document.querySelector('[role="tabpanel"]'); + const rect = activeTab?.getBoundingClientRect(); + const tabViewport = activeTab?.closest('[data-ops-tabstrip-viewport]')?.getBoundingClientRect(); + const activeElement = document.activeElement; + const inInput = activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement || + activeElement instanceof HTMLSelectElement || activeElement?.getAttribute('contenteditable') === 'true'; + const state = window.__OPS__?.store.getState(); + return { + observedOrdinal: Number(activeTab?.getAttribute('data-ops-tab-index')), + outsideInput: !inInput, + tabVisible: Boolean( + rect && tabViewport && rect.width > 0 && rect.height > 0 && + rect.left >= tabViewport.left && rect.right <= tabViewport.right, + ), + tabpanelCorrelated: Boolean( + activeTab?.id && panel?.id && activeTab.getAttribute('aria-controls') === panel.id && + panel.getAttribute('aria-labelledby') === activeTab.id, + ), + activeDocumentHashCorrelated: Boolean( + state?.activeDocument?.documentHash && + state.activeDocument.documentHash === hashScalar(activeTab?.getAttribute('data-ops-tab')), + ), + }; + }); +} + +async function waitForActiveOrdinal(page, ordinal) { + await page.waitForFunction((expected) => { + const active = document.querySelector('[role="tab"][aria-selected="true"]'); + return Number(active?.getAttribute('data-ops-tab-index')) === expected; + }, ordinal, { timeout: 10_000 }); +} + +async function switchToOrdinal(page, ordinal, route) { + const started = performance.now(); + if (route.startsWith('shortcut:')) { + await page.locator('[role="tab"][aria-selected="true"]').click(); + const outsideInputBefore = await page.evaluate(() => { + const active = document.activeElement; + return !(active instanceof HTMLInputElement) && !(active instanceof HTMLTextAreaElement) && + !(active instanceof HTMLSelectElement) && active?.getAttribute('contenteditable') !== 'true'; + }); + if (!outsideInputBefore) fail(`shortcut ${ordinal} startte in een invoerveld`); + await page.keyboard.press(route.slice('shortcut:'.length)); + } else if (route === 'click') { + await page.locator(`[role="tab"][data-ops-tab-index="${ordinal}"]`).click(); + } else if (route === 'keyboard:ArrowRight') { + await page.locator('[role="tab"][aria-selected="true"]').press('ArrowRight'); + } else { + fail(`onbekende documentwisselroute: ${route}`); + } + await waitForActiveOrdinal(page, ordinal); + const latencyMs = performance.now() - started; + return { ...(await readActiveTabCorrelation(page)), latencyMs }; +} + +async function runBrowserMultiDocumentHelp(page, preflightResult, bridgeGateSetup) { + const openStarted = performance.now(); + await openWithVisibleFileChooser(page, preflightResult.xerPath); + + const toast = page.locator('.ops-toast').filter({ hasText: 'XER file opened' }).first(); + await toast.waitFor({ state: 'visible', timeout: 30_000 }); + await page.waitForFunction(() => window.__OPS__?.store.getState()?.documents?.count === 12, null, { timeout: 30_000 }); + const openLatencyMs = performance.now() - openStarted; + const toastText = await toast.innerText(); + const toastLines = normalizeVisibleToastLines(toastText); + const toastBox = await toast.boundingBox(); + assertPrivacySafeMultiDocumentToast(toastText, toastBox); + const toastScreenshot = await toast.screenshot(); + + const imported = await readMultiDocumentOpsState(page); + if (imported.documentCount !== 12) fail(`DOM/store import opende ${imported.documentCount} documenten, verwacht 12`); + if (imported.notification?.count !== 12 || imported.notification.helpArticleId !== 'gids-xer-import') { + fail(`XER-notificatieprojectie onjuist: ${JSON.stringify(imported.notification)}`); + } + const detailCounts = Object.fromEntries(imported.notification.detailLines.map((line) => [line.messageKey, line.count])); + const expectedDetails = { + 'notifications.xerImportProjectsSeen': 15, + 'notifications.xerImportEmptyProjectsSkipped': 3, + 'notifications.xerImportDanglingBaselineReferences': 9, + }; + for (const [messageKey, count] of Object.entries(expectedDetails)) { + if (detailCounts[messageKey] !== count) fail(`zichtbare toastdetail ${messageKey}=${detailCounts[messageKey]}, verwacht ${count}`); + } + const encodingDetail = imported.notification.detailLines.find( + (line) => line.messageKey === 'notifications.xerImportEncoding', + ); + const encoding = { + messageKey: encodingDetail?.messageKey ?? null, + value: encodingDetail?.encoding ?? null, + }; + + const readMore = toast.locator('button.ops-toast-readmore'); + if (await readMore.count() !== 1) fail('zichtbare Read more-knop ontbreekt of is niet uniek'); + await readMore.click(); + const activeHelpItem = page.locator('.help-toc-item.active'); + const articleHeading = page.locator('.help-article-body h1'); + await activeHelpItem.waitFor({ state: 'visible', timeout: 15_000 }); + await articleHeading.waitFor({ state: 'visible', timeout: 15_000 }); + const activeTocTitle = (await activeHelpItem.innerText()).trim(); + const headingTitle = (await articleHeading.innerText()).trim(); + const helpBodyText = await page.locator('.help-article-body').innerText(); + const helpState = await readMultiDocumentOpsState(page); + const help = { + notificationHelpArticleId: imported.notification.helpArticleId, + activeTocTitle, + articleHeading: headingTitle, + activeRibbonTab: helpState.ui.activeRibbonTab, + backstageSection: helpState.ui.backstageSection, + pendingHelpArticleConsumed: helpState.ui.pendingHelpArticleConsumed, + hasMultiDocumentExplanation: helpBodyText.includes('several project documents'), + hasEmptyProjectExplanation: helpBodyText.includes('Empty projects do not create a pointless tab.'), + hasBaselineExplanation: helpBodyText.includes('A P6 baseline project is not opened as a separate schedulable document.'), + }; + const helpScreenshot = await page.screenshot({ + fullPage: false, + mask: [ + page.locator('canvas'), + page.locator('.title-bar-file-name'), + page.locator('[role="tab"]'), + page.locator('.help-article-body'), + ], + maskColor: '#263238', + }); + + const homeTab = page.locator('button.ribbon-tab').filter({ hasText: /^Home$/ }); + if (await homeTab.count() !== 1) fail('zichtbare Engelse Home-tab ontbreekt of is niet uniek'); + await homeTab.click(); + const tabs = page.locator('[role="tab"][data-ops-tab-index]'); + await tabs.first().waitFor({ state: 'visible', timeout: 10_000 }); + if (await tabs.count() !== 12) fail(`DOM-tabtelling=${await tabs.count()}, verwacht 12`); + + const routeByOrdinal = new Map([ + [1, 'shortcut:Control+1'], + [5, 'shortcut:Control+5'], + [9, 'shortcut:Control+9'], + [10, 'click'], + [11, 'keyboard:ArrowRight'], + [12, 'keyboard:ArrowRight'], + ]); + const documents = []; + const switches = []; + for (let ordinal = 1; ordinal <= 12; ordinal += 1) { + const route = routeByOrdinal.get(ordinal) ?? 'click'; + const switched = await switchToOrdinal(page, ordinal, route); + const snapshot = await readMultiDocumentOpsState(page); + switches.push({ requestedOrdinal: ordinal, route, ...switched }); + documents.push({ ordinal, ...snapshot.activeDocument }); + } + + const screenshot = await page.screenshot({ + fullPage: false, + mask: [ + page.locator('canvas'), + page.locator('.title-bar-file-name'), + page.locator('[role="tab"]'), + page.locator('.help-article-body'), + ], + maskColor: '#263238', + }); + const evidence = { report: imported.report, encoding, toastLines, openLatencyMs, documents, switches, help }; + assertMultiDocumentEvidence(evidence); + + const bridgeGate = await readDevBridgeOpenGate(page); + if (bridgeGate.calls.length !== 0) fail(`anti-sluiproute-gate rood: ${JSON.stringify(bridgeGate.calls)}`); + if (bridgeGate.wrapped.length !== bridgeGateSetup.wrapped.length || bridgeGate.wrapped.length === 0) { + fail(`anti-sluiproute-gate verloor wrappers: ${JSON.stringify(bridgeGate)}`); + } + const dialogCounts = await page.evaluate(() => ({ ...window.__OPS_X11_DIALOG_COUNTS__ })); + if (dialogCounts.alert !== 0 || dialogCounts.confirm !== 0 || dialogCounts.prompt !== 0) { + fail(`native-dialog-audit rood: ${JSON.stringify(dialogCounts)}`); + } + return { + state: evidence, + domText: null, + dialogCounts, + bridgeGate, + screenshot, + toastScreenshot, + helpScreenshot, + toastBox, + }; +} + +async function readRecoveryDatabase(page) { + return page.evaluate(async () => { + const hashScalar = (value) => { + if (typeof value !== 'string') return null; + let hash = 0x811c9dc5; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return `fnv1a-${(hash >>> 0).toString(16).padStart(8, '0')}`; + }; + const sessionId = sessionStorage.getItem('ops-recovery-session'); + if (!sessionId) return null; + const db = await new Promise((resolvePromise, reject) => { + const request = indexedDB.open('ops-recovery'); + request.onsuccess = () => resolvePromise(request.result); + request.onerror = () => reject(request.error); + }); + try { + const records = await new Promise((resolvePromise, reject) => { + const request = db.transaction('records', 'readonly').objectStore('records').getAll(); + request.onsuccess = () => resolvePromise(request.result); + request.onerror = () => reject(request.error); + }); + const ours = records.filter((record) => record.sessionId === sessionId); + const manifest = ours.find((record) => record.kind === 'manifest'); + if (!manifest) return null; + const documents = (manifest.documents ?? []).map((document, index) => { + const record = ours.find((item) => item.kind === 'doc' && item.docId === document.id); + return { + ordinal: index + 1, + documentHash: hashScalar(document.id), + ifcDigest: record ? hashScalar(record.ifc) : null, + ifcByteLength: record ? new TextEncoder().encode(record.ifc).byteLength : 0, + addedAt: record?.addedAt ?? null, + }; + }); + const fingerprint = hashScalar(JSON.stringify(ours + .map((record) => ({ + id: hashScalar(record.id), kind: record.kind, addedAt: record.addedAt, + active: record.kind === 'manifest' ? hashScalar(record.activeDocumentId) : null, + order: record.kind === 'manifest' ? (record.documents ?? []).map((item) => hashScalar(item.id)) : null, + ifc: record.kind === 'doc' ? hashScalar(record.ifc) : null, + })) + .sort((left, right) => String(left.id).localeCompare(String(right.id))))); + return { + sessionHash: hashScalar(sessionId), + addedAt: manifest.addedAt, + activeDocumentHash: hashScalar(manifest.activeDocumentId), + documentOrder: documents.map((document) => document.documentHash), + documents, + fingerprint, + }; + } finally { + db.close(); + } + }); +} + +async function waitForRecoveryDatabase(page, predicate, label, timeout = 60_000) { + const deadline = Date.now() + timeout; + let observed = null; + while (Date.now() < deadline) { + observed = await readRecoveryDatabase(page); + if (observed && predicate(observed)) return observed; + await new Promise((resolvePromise) => setTimeout(resolvePromise, 200)); + } + fail(`${label} niet binnen ${timeout}ms waargenomen; observed=${JSON.stringify(observed)}`); +} + +async function abortIndexedDbTransaction(page) { + return page.evaluate(async () => { + const sessionId = sessionStorage.getItem('ops-recovery-session'); + if (!sessionId) throw new Error('recovery-session ontbreekt voor abortproef'); + const db = await new Promise((resolvePromise, reject) => { + const request = indexedDB.open('ops-recovery'); + request.onsuccess = () => resolvePromise(request.result); + request.onerror = () => reject(request.error); + }); + try { + return await new Promise((resolvePromise, reject) => { + const tx = db.transaction('records', 'readwrite'); + // Een expliciete abort levert volgens IndexedDB ook een bubbling error-event op de + // request/transaction. Onderdruk alleen dat verwachte AbortError-pad; onabort is de gate. + tx.onerror = (event) => { event.preventDefault(); }; + tx.oncomplete = () => reject(new Error('abortproef committeerde onverwacht')); + tx.onabort = () => resolvePromise(true); + const request = tx.objectStore('records').put({ + id: `${sessionId}::phase2b-abort-probe`, kind: 'doc', sessionId, + docId: 'phase2b-abort-probe', ifc: 'NIET-COMMITTEREN', addedAt: Date.now(), + }); + request.onerror = (event) => { event.preventDefault(); }; + tx.abort(); + }); + } finally { + db.close(); + } + }); +} + +async function collectDocumentSnapshots(page, { proveCpm = false } = {}) { + const tabs = page.locator('[role="tab"][data-ops-tab-index]'); + const count = await tabs.count(); + const documents = []; + for (let ordinal = 1; ordinal <= count; ordinal += 1) { + await tabs.nth(ordinal - 1).click(); + await waitForActiveOrdinal(page, ordinal); + let snapshot = await readPhase2BDocumentState(page); + let cpmExecutedViaF5 = false; + if (proveCpm) { + await page.keyboard.press('F5'); + await page.waitForFunction(() => window.__OPS__?.store.getState()?.activeDocument?.cpmPresent, null, { timeout: 30_000 }); + snapshot = await readPhase2BDocumentState(page); + cpmExecutedViaF5 = true; + } + documents.push({ + ordinal, + ...snapshot.activeDocument, + cpmExecutable: Boolean(snapshot.activeDocument.cpmPresent), + cpmExecutedViaF5, + }); + } + return documents; +} + +async function runBrowserRecovery(page, preflightResult, bridgeGateSetup) { + await openWithVisibleFileChooser(page, preflightResult.xerPath); + await page.waitForFunction(() => window.__OPS__?.store.getState()?.documents?.count === 12, null, { timeout: 45_000 }); + const tabs = page.locator('[role="tab"][data-ops-tab-index]'); + if (await tabs.count() !== 12) fail(`recovery-import leverde ${await tabs.count()} tabs, verwacht 12`); + + // Een geopende bronimport is terecht schoon en maakt dus nog geen crashsnapshot. Maak via de + // gewone zichtbare productbediening één echte gebruikerswijziging; vanaf dat moment moet de + // browser-autosave alle twaalf open documenten veiligstellen. + const taskCountBeforeTrigger = (await readPhase2BDocumentState(page)).activeDocument.taskCount; + const homeTab = page.locator('button.ribbon-tab').filter({ hasText: /^Home$/ }); + await homeTab.click(); + const addTaskButton = page.locator('button.ribbon-btn').filter({ hasText: /^Task$/ }); + if (await addTaskButton.count() !== 1) fail('zichtbare Engelse Task-knop voor autosavetrigger ontbreekt of is niet uniek'); + await addTaskButton.click(); + await page.waitForFunction((previous) => window.__OPS__?.store.getState()?.activeDocument?.taskCount === previous + 1, taskCountBeforeTrigger); + const autosaveTrigger = { route: 'visible:Home>Task', taskDelta: 1 }; + + const firstStored = await waitForRecoveryDatabase( + page, + (value) => value.documents.length === 12 && value.documents.every((document) => document.ifcByteLength > 0), + 'eerste volledige recoveryset', + ); + const currentOrdinal = Number(await page.locator('[role="tab"][aria-selected="true"]').getAttribute('data-ops-tab-index')); + const targetOrdinal = currentOrdinal === 2 ? 1 : 2; + await tabs.nth(targetOrdinal - 1).click(); + await waitForActiveOrdinal(page, targetOrdinal); + await new Promise((resolvePromise) => setTimeout(resolvePromise, 750)); + const beforeDue = await readRecoveryDatabase(page); + const secondStored = await waitForRecoveryDatabase( + page, + (value) => value.addedAt > firstStored.addedAt && value.activeDocumentHash !== firstStored.activeDocumentHash, + 'door throttle uitgestelde manifestupdate', + 20_000, + ); + const throttle = { + firstAddedAt: firstStored.addedAt, + secondAddedAt: secondStored.addedAt, + intervalMs: secondStored.addedAt - firstStored.addedAt, + manifestUnchangedBeforeDue: beforeDue?.addedAt === firstStored.addedAt, + }; + + const liveBefore = await readPhase2BDocumentState(page); + const liveDocuments = await collectDocumentSnapshots(page); + await tabs.nth(targetOrdinal - 1).click(); + await waitForActiveOrdinal(page, targetOrdinal); + const activeBeforeReload = await readPhase2BDocumentState(page); + const manifestForReload = await waitForRecoveryDatabase( + page, + (value) => value.activeDocumentHash === activeBeforeReload.activeDocumentHash, + 'manifest met gekozen actief document', + 20_000, + ); + const initialDocuments = liveDocuments.map((document, index) => ({ + ...document, + ifcDigest: manifestForReload.documents[index]?.ifcDigest ?? null, + ifcByteLength: manifestForReload.documents[index]?.ifcByteLength ?? 0, + })); + + const beforeAbort = await readRecoveryDatabase(page); + const transactionAborted = await abortIndexedDbTransaction(page); + const afterAbort = await readRecoveryDatabase(page); + const abortedTransaction = { + transactionAborted, + beforeFingerprint: beforeAbort?.fingerprint ?? null, + afterFingerprint: afterAbort?.fingerprint ?? null, + }; + + const preReloadDialogs = await page.evaluate(() => ({ ...window.__OPS_X11_DIALOG_COUNTS__ })); + let beforeUnloadDialogs = 0; + const handleDialog = async (dialog) => { + if (dialog.type() === 'beforeunload') { beforeUnloadDialogs += 1; await dialog.accept(); return; } + await dialog.dismiss(); + }; + page.on('dialog', handleDialog); + await page.reload({ waitUntil: 'domcontentloaded', timeout: 45_000 }); + await page.waitForFunction(() => Boolean(window.__OPS__) && typeof window.showOpenFilePicker === 'undefined'); + const restoredBridgeSetup = await installDevBridgeOpenGate(page); + const recoveryDialog = page.locator('[role="dialog"]').filter({ hasText: 'Restore unsaved work' }); + await recoveryDialog.waitFor({ state: 'visible', timeout: 60_000 }); + const entryCount = await recoveryDialog.locator('li').count(); + const privacyLines = [ + (await recoveryDialog.locator('span').filter({ hasText: /^Restore unsaved work$/ }).innerText()).trim(), + (await recoveryDialog.locator('button').filter({ hasText: /^Don't restore$/ }).innerText()).trim(), + (await recoveryDialog.locator('button').filter({ hasText: /^Restore$/ }).innerText()).trim(), + ]; + const recoveryScreenshot = await page.screenshot({ + fullPage: false, + mask: [page.locator('canvas'), page.locator('.title-bar-file-name'), page.locator('[role="tab"]'), recoveryDialog.locator('li')], + maskColor: '#263238', + }); + await recoveryDialog.locator('button').filter({ hasText: /^Restore$/ }).click(); + await page.waitForFunction(() => window.__OPS__?.store.getState()?.documents?.count === 12, null, { timeout: 60_000 }); + await page.locator('[role="tab"][data-ops-tab-index]').first().waitFor({ state: 'visible' }); + const restoredDocuments = await collectDocumentSnapshots(page, { proveCpm: true }); + await page.locator(`[role="tab"][data-ops-tab-index="${targetOrdinal}"]`).click(); + await waitForActiveOrdinal(page, targetOrdinal); + const restoredState = await readPhase2BDocumentState(page); + const evidence = { + initial: { + documentCount: liveBefore.documentCount, + manifestDocumentCount: manifestForReload.documents.length, + activeDocumentHash: activeBeforeReload.activeDocumentHash, + manifestActiveDocumentHash: manifestForReload.activeDocumentHash, + documentOrder: activeBeforeReload.documentOrder, + manifestOrder: manifestForReload.documentOrder, + documents: initialDocuments, + }, + throttle, + autosaveTrigger, + abortedTransaction, + recoveryUi: { visible: true, entryCount, restoredViaVisibleButton: true, beforeUnloadDialogs }, + runtime: { nativeDialogs: null }, + restored: { + documentCount: restoredState.documentCount, + activeDocumentHash: restoredState.activeDocumentHash, + documentOrder: restoredState.documentOrder, + documents: restoredDocuments, + }, + privacy: { visibleLines: privacyLines }, + }; + const bridgeGate = await readDevBridgeOpenGate(page); + if (bridgeGate.calls.length !== 0 || restoredBridgeSetup.wrapped.length === 0 || bridgeGateSetup.wrapped.length === 0) { + fail(`recovery anti-sluiproute-gate rood: ${JSON.stringify(bridgeGate)}`); + } + const postReloadDialogs = await page.evaluate(() => ({ ...window.__OPS_X11_DIALOG_COUNTS__ })); + const dialogCounts = Object.fromEntries(Object.keys(postReloadDialogs).map((key) => [key, preReloadDialogs[key] + postReloadDialogs[key]])); + evidence.runtime.nativeDialogs = dialogCounts; + assertRecoveryEvidence(evidence); + if (Object.values(dialogCounts).some((count) => count !== 0)) fail(`recovery native-dialog-audit rood: ${JSON.stringify(dialogCounts)}`); + const screenshot = await page.screenshot({ + fullPage: false, + mask: [page.locator('canvas'), page.locator('.title-bar-file-name'), page.locator('[role="tab"]')], + maskColor: '#263238', + }); + page.off('dialog', handleDialog); + return { state: evidence, dialogCounts, bridgeGate, screenshot, recoveryScreenshot, toastScreenshot: null, toastBox: null }; +} + +async function readPrimaryGanttRenderEvidence(page) { + return page.evaluate(() => { + const anchor = document.querySelector('[data-testid="gantt-vscroll"]'); + const root = anchor?.parentElement ?? null; + const primaryContainer = root?.firstElementChild ?? null; + const primaryCanvas = primaryContainer?.querySelector(':scope > canvas') ?? null; + const primaryScroll = primaryContainer?.querySelector(':scope > [data-testid="gantt-hscroll"]') ?? null; + const primaryCanvasBound = Boolean( + root && primaryContainer && primaryCanvas instanceof HTMLCanvasElement && primaryScroll && + anchor?.parentElement === root && primaryContainer.parentElement === root, + ); + if (!(primaryCanvas instanceof HTMLCanvasElement)) { + return { + rootAnchor: anchor ? 'gantt-vscroll' : null, + primaryCanvasBound, + width: 0, + height: 0, + sampleCount: 0, + nonTransparentSamples: 0, + uniqueColorBuckets: 0, + luminanceRange: 0, + transitionCount: 0, + }; + } + const rect = primaryCanvas.getBoundingClientRect(); + const sampleWidth = 64; + const sampleHeight = 64; + const sampleCanvas = document.createElement('canvas'); + sampleCanvas.width = sampleWidth; + sampleCanvas.height = sampleHeight; + const sampleContext = sampleCanvas.getContext('2d', { willReadFrequently: true }); + if (!sampleContext) throw new Error('Gantt-samplecontext ontbreekt'); + sampleContext.drawImage(primaryCanvas, 0, 0, sampleWidth, sampleHeight); + const pixels = sampleContext.getImageData(0, 0, sampleWidth, sampleHeight).data; + const buckets = new Set(); + let nonTransparentSamples = 0; + let minLuminance = 255; + let maxLuminance = 0; + let transitionCount = 0; + let previousBucket = null; + for (let index = 0; index < pixels.length; index += 4) { + const red = pixels[index]; + const green = pixels[index + 1]; + const blue = pixels[index + 2]; + const alpha = pixels[index + 3]; + if (alpha > 0) nonTransparentSamples += 1; + const bucket = `${red >> 4}:${green >> 4}:${blue >> 4}:${alpha >> 4}`; + buckets.add(bucket); + if (previousBucket !== null && previousBucket !== bucket) transitionCount += 1; + previousBucket = bucket; + const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue; + minLuminance = Math.min(minLuminance, luminance); + maxLuminance = Math.max(maxLuminance, luminance); + } + return { + rootAnchor: anchor ? 'gantt-vscroll' : null, + primaryCanvasBound, + width: Math.round(rect.width), + height: Math.round(rect.height), + sampleCount: sampleWidth * sampleHeight, + nonTransparentSamples, + uniqueColorBuckets: buckets.size, + luminanceRange: Number((maxLuminance - minLuminance).toFixed(3)), + transitionCount, + }; + }); +} + +async function runBrowserLargeResources(page, preflightResult, bridgeGateSetup) { + const pageErrors = []; + let crashed = false; + page.on('pageerror', (error) => pageErrors.push(error.message)); + page.on('crash', () => { crashed = true; }); + const importStarted = performance.now(); + await openWithVisibleFileChooser(page, preflightResult.xerPath); + await page.waitForFunction(() => { + const state = window.__OPS__?.store.getState(); + return state?.documents?.count > 0 && state?.activeDocument?.taskCount > 0 && state?.activeDocument?.sourceArchivePresent; + }, null, { timeout: 180_000 }); + const importMs = performance.now() - importStarted; + const tabs = page.locator('[role="tab"][data-ops-tab-index]'); + const tabCount = await tabs.count(); + let richOrdinal = 1; + let richSnapshot = null; + for (let ordinal = 1; ordinal <= tabCount; ordinal += 1) { + await tabs.nth(ordinal - 1).click(); + await waitForActiveOrdinal(page, ordinal); + const candidate = await readPhase2BDocumentState(page); + if (!richSnapshot || candidate.activeDocument.assignmentCount > richSnapshot.activeDocument.assignmentCount) { + richSnapshot = candidate; + richOrdinal = ordinal; + } + } + if (!richSnapshot) fail('grootbestand leverde geen documentprojectie'); + let tabActivationMs = 0; + if (tabCount > 1) { + const otherOrdinal = richOrdinal === 1 ? 2 : 1; + await tabs.nth(otherOrdinal - 1).click(); + await waitForActiveOrdinal(page, otherOrdinal); + const switchStarted = performance.now(); + await tabs.nth(richOrdinal - 1).click(); + await waitForActiveOrdinal(page, richOrdinal); + tabActivationMs = performance.now() - switchStarted; + } else { + const switchStarted = performance.now(); + await tabs.first().click(); + await waitForActiveOrdinal(page, 1); + tabActivationMs = performance.now() - switchStarted; + } + richSnapshot = await readPhase2BDocumentState(page); + const ganttStarted = performance.now(); + await page.locator('[data-testid="gantt-vscroll"]').waitFor({ state: 'visible', timeout: 60_000 }); + await page.evaluate(() => new Promise((resolvePromise) => requestAnimationFrame(() => requestAnimationFrame(resolvePromise)))); + const gantt = await readPrimaryGanttRenderEvidence(page); + const firstGanttMs = performance.now() - ganttStarted; + + const resourcesTab = page.locator('button.ribbon-tab').filter({ hasText: /^Resources$/ }); + await resourcesTab.click(); + const resourcesStarted = performance.now(); + const resourcesButton = page.locator('button.ribbon-btn').filter({ hasText: /^Resources$/ }); + await resourcesButton.click(); + await page.waitForFunction(() => window.__OPS__?.store.getState()?.ui?.showResourcePanel === true); + const resourcePanelTitle = page.locator('.ui-card-header').filter({ hasText: /^Resources$/ }); + await resourcePanelTitle.waitFor({ state: 'visible', timeout: 30_000 }); + const resourcePanelLabel = (await resourcePanelTitle.innerText()).trim(); + const resourcesPanelMs = performance.now() - resourcesStarted; + const resourcesPanelVisible = (await readPhase2BDocumentState(page)).ui.showResourcePanel; + const closeResourcePanel = resourcePanelTitle.locator('xpath=..').locator('button[title="Close"]'); + if (await closeResourcePanel.count() !== 1) fail('zichtbare sluitknop van Resources-paneel ontbreekt of is niet uniek'); + await closeResourcePanel.click(); + await page.waitForFunction(() => window.__OPS__?.store.getState()?.ui?.showResourcePanel === false); + + await resourcesTab.click(); + const histogramStarted = performance.now(); + const histogramButton = page.locator('button.ribbon-btn').filter({ hasText: /^Histogram$/ }); + await histogramButton.click(); + await page.locator('[data-tour-anchor="histogram-strip"]').waitFor({ state: 'visible', timeout: 30_000 }); + const histogramMs = performance.now() - histogramStarted; + const finalState = await readPhase2BDocumentState(page); + const active = finalState.activeDocument; + const dialogCounts = await page.evaluate(() => ({ ...window.__OPS_X11_DIALOG_COUNTS__ })); + const evidence = { + counts: { tasks: active.taskCount, assignments: active.assignmentCount, calendars: active.calendarCount }, + sourceArchivePresent: active.sourceArchivePresent, + sourceArchiveDigest: active.archiveDigest, + selectorPresent: active.selectorPresent, + cpmPresent: active.cpmPresent, + catalogs: { + resources: active.sourceResourceCatalogCount, + sourceRows: active.sourceResourceRowCount, + activityCodeCatalogs: active.sourceMetadataActivityCodeTypeCount, + udfDefs: active.sourceMetadataCustomFieldDefCount, + }, + gantt, + routes: { + firstGanttRendered: true, + resourcesPanelVisible, + histogramVisible: finalState.ui.showHistogram, + richDocumentOrdinal: richOrdinal, + documentCount: tabCount, + }, + latencies: { importMs, firstGanttMs, tabActivationMs, resourcesPanelMs, histogramMs }, + runtime: { crashed, pageErrors: pageErrors.length, nativeDialogs: dialogCounts }, + privacy: { visibleLines: [resourcePanelLabel, (await histogramButton.innerText()).trim()] }, + }; + assertLargeResourceEvidence(evidence); + const bridgeGate = await readDevBridgeOpenGate(page); + if (bridgeGate.calls.length !== 0 || bridgeGate.wrapped.length !== bridgeGateSetup.wrapped.length) { + fail(`grootbestand anti-sluiproute-gate rood: ${JSON.stringify(bridgeGate)}`); + } + const screenshot = await page.screenshot({ + fullPage: false, + mask: [ + page.locator('canvas'), page.locator('.title-bar-file-name'), page.locator('[role="tab"]'), + page.locator('tbody'), page.locator('.ops-toast'), + ], + maskColor: '#263238', + }); + return { state: evidence, dialogCounts, bridgeGate, screenshot, toastScreenshot: null, toastBox: null }; +} + +async function runBrowserSmallA(preflightResult, server) { + const { chromium } = loadPlaywrightCore(); + const browserEnv = { ...process.env }; + const launchArgs = []; + if (!browserEnv.DISPLAY?.trim() && preflightResult.waylandSocket) { + browserEnv.XDG_RUNTIME_DIR = '/run/user/1000'; + browserEnv.WAYLAND_DISPLAY = 'wayland-0'; + launchArgs.push('--ozone-platform=wayland'); + } + + const browser = await chromium.launch({ + executablePath: preflightResult.chromiumPath, + headless: false, + env: browserEnv, + args: launchArgs, + }); + const context = await browser.newContext({ + locale: 'en-US', + viewport: { width: 1440, height: 900 }, + }); + // Deze drie wijzigingen gelden alleen binnen dit Playwright-contextobject. De eerste gebeurt + // vóór de eerste pagina-load, zodat de bestaande input[type=file]-terugval werkelijk wordt + // gekozen in plaats van de Chromium File System Access API. + await context.addInitScript(() => { + delete window.showOpenFilePicker; + localStorage.setItem('ops-locale', 'en'); + localStorage.setItem('ops-docs-locale', 'en'); + localStorage.setItem('ops-documentChromeStyle', 'tabs'); + localStorage.setItem('ops-welcomeSeen', 'true'); + const counts = { alert: 0, confirm: 0, prompt: 0 }; + window.__OPS_X11_DIALOG_COUNTS__ = counts; + window.alert = () => { counts.alert += 1; }; + window.confirm = () => { counts.confirm += 1; return false; }; + window.prompt = () => { counts.prompt += 1; return null; }; + }); + const page = await context.newPage(); + try { + await page.goto(server.assignedUrl, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => Boolean(window.__OPS__) && typeof window.showOpenFilePicker === 'undefined'); + const bridgeGateSetup = await installDevBridgeOpenGate(page); + + if (preflightResult.scenario === RECOVERY_SCENARIO) { + return { + ...(await runBrowserRecovery(page, preflightResult, bridgeGateSetup)), + launchArgs, + headless: false, + }; + } + if (preflightResult.scenario === LARGE_RESOURCE_SCENARIO) { + return { + ...(await runBrowserLargeResources(page, preflightResult, bridgeGateSetup)), + launchArgs, + headless: false, + }; + } + if (preflightResult.scenario === MULTI_DOCUMENT_SCENARIO) { + return { + ...(await runBrowserMultiDocumentHelp(page, preflightResult, bridgeGateSetup)), + launchArgs, + headless: false, + }; + } + + await openWithVisibleFileChooser(page, preflightResult.xerPath); + + const toast = page.locator('.ops-toast').filter({ hasText: 'XER file opened' }).first(); + await toast.waitFor({ state: 'visible', timeout: 30_000 }); + const domText = await toast.innerText(); + const toastBox = await toast.boundingBox(); + assertPrivacySafeToast(domText, toastBox); + const toastScreenshot = await toast.screenshot(); + + try { + await page.waitForFunction((messageKey) => { + const bridge = window.__OPS__; + const state = bridge?.store.getState(); + const notifications = state?.ui?.notifications?.entries ?? {}; + const notification = Object.values(notifications).find((item) => item.messageKey === messageKey); + return Boolean( + state && state.documents?.count === 1 && state.tasks?.importedCount === 8 && + state.sequences?.count === 7 && state.cpmResult && state.xerSourceArchive?.present && + state.xerSourceProjectId && notification?.helpArticleId === 'gids-xer-import', + ); + }, XER_OPEN_MESSAGE_KEY, { timeout: 30_000 }); + } catch (error) { + const observed = await readOpsState(page).catch((readError) => ({ readError: String(readError) })); + fail(`${error instanceof Error ? error.message : String(error)}; observed=${JSON.stringify(observed)}`); + } + + const state = await readOpsState(page); + assertSmallAState(state); + + const bridgeGate = await readDevBridgeOpenGate(page); + if (bridgeGate.calls.length !== 0) { + fail(`anti-sluiproute-gate rood: ${JSON.stringify(bridgeGate.calls)}`); + } + if (bridgeGate.wrapped.length !== bridgeGateSetup.wrapped.length || bridgeGate.wrapped.length === 0) { + fail(`anti-sluiproute-gate verloor wrappers: ${JSON.stringify(bridgeGate)}`); + } + + const dialogCounts = await page.evaluate(() => ({ ...window.__OPS_X11_DIALOG_COUNTS__ })); + if (dialogCounts.alert !== 0 || dialogCounts.confirm !== 0 || dialogCounts.prompt !== 0) { + fail(`native-dialog-audit rood: ${JSON.stringify(dialogCounts)}`); + } + + const screenshot = await page.screenshot({ + fullPage: false, + mask: [ + page.locator('canvas'), + page.locator('.title-bar-file-name'), + page.locator('[role="tab"]'), + ], + maskColor: '#263238', + }); + return { + state, + domText, + dialogCounts, + bridgeGate, + screenshot, + toastScreenshot, + toastBox, + launchArgs, + headless: false, + }; + } finally { + await context.close(); + await browser.close(); + } +} + +async function stopDevServer(child) { + if (child.exitCode !== null) return; + try { process.kill(-child.pid, 'SIGTERM'); } catch { child.kill('SIGTERM'); } + await new Promise((resolvePromise) => { + const timeout = setTimeout(() => { + try { process.kill(-child.pid, 'SIGKILL'); } catch { child.kill('SIGKILL'); } + resolvePromise(); + }, 5_000); + child.once('exit', () => { clearTimeout(timeout); resolvePromise(); }); + }); +} + +async function main() { + const gitStart = readGitEvidence(); + const preflightResult = await preflight(); + if (!preflightResult) return; + await assertNoDirectPathOpen(); + const evidenceId = runId(); + const evidenceDir = resolve(EVIDENCE_ROOT, evidenceId); + const dependencies = await ensureDependencies(); + let serverHandle = null; + let browserEvidence = null; + let runError = null; + const cleanupErrors = []; + try { + serverHandle = await startDevServer(); + browserEvidence = await runBrowserSmallA(preflightResult, serverHandle.server); + } catch (error) { + runError = error; + } finally { + try { + if (serverHandle) await stopDevServer(serverHandle.child); + } catch (error) { + cleanupErrors.push(`serverstop: ${error instanceof Error ? error.message : String(error)}`); + } + try { + await removeTemporaryDependencies(dependencies.linkPath); + } catch (error) { + cleanupErrors.push(`dependencies: ${error instanceof Error ? error.message : String(error)}`); + } + } + + const gitEnd = readGitEvidence(); + assertGitEvidenceUnchanged(gitStart, gitEnd); + if (cleanupErrors.length > 0) fail(`cleanup rood: ${cleanupErrors.join('; ')}`); + if (runError) throw runError; + if (!serverHandle || !browserEvidence) fail('browser- of serverbewijs ontbreekt na de run'); + + const serverSummary = privacySafeServerSummary(serverHandle.server); + const metadata = { + runId: evidenceId, + scenario: preflightResult.scenario, + git: metadataGitIdentity(gitStart), + server: serverSummary, + browser: { + headless: browserEvidence.headless, + visible: browserEvidence.headless === false, + executableVerified: true, + wayland: browserEvidence.launchArgs.includes('--ozone-platform=wayland'), + }, + dom: preflightResult.scenario === MULTI_DOCUMENT_SCENARIO + ? { tabCount: 12, helpTitle: browserEvidence.state.help.articleHeading } + : preflightResult.scenario === RECOVERY_SCENARIO + ? { tabCount: browserEvidence.state.restored.documentCount, recoveryUi: true } + : preflightResult.scenario === LARGE_RESOURCE_SCENARIO + ? { tabCount: browserEvidence.state.routes.documentCount, resourcesPanel: true, histogram: true } + : { text: browserEvidence.domText }, + nativeDialogs: browserEvidence.dialogCounts, + devBridgeOpenGate: browserEvidence.bridgeGate, + artifacts: { + redactedUiScreenshot: true, + redactedToastScreenshot: Boolean(browserEvidence.toastScreenshot), + redactedHelpScreenshot: Boolean(browserEvidence.helpScreenshot), + redactedRecoveryScreenshot: Boolean(browserEvidence.recoveryScreenshot), + stateProjection: true, + serverSummary: true, + }, + cleanup: { serverStopped: true, temporaryDependencyLinkRemoved: true, gitIdentityUnchanged: true }, + }; + assertMetadataGitIdentity(metadata, gitStart); + const phase2BScenario = [RECOVERY_SCENARIO, LARGE_RESOURCE_SCENARIO].includes(preflightResult.scenario); + if (phase2BScenario) { + const privacyAudit = assertPhase2BEvidencePrivacy({ + evidenceId, + stdout: evidenceId, + metadata, + serverSummary, + stateArtifact: browserEvidence.state, + }); + metadata.privacyAudit = privacyAudit; + assertPhase2BEvidencePrivacy({ + evidenceId, + stdout: evidenceId, + metadata, + serverSummary, + stateArtifact: browserEvidence.state, + }); + } + await mkdir(evidenceDir, { recursive: true }); + await writeFile(resolve(evidenceDir, 'imported-redacted.png'), browserEvidence.screenshot); + if (browserEvidence.toastScreenshot) { + await writeFile(resolve(evidenceDir, 'xer-toast.png'), browserEvidence.toastScreenshot); + } + if (browserEvidence.helpScreenshot) { + await writeFile(resolve(evidenceDir, 'help-redacted.png'), browserEvidence.helpScreenshot); + } + if (browserEvidence.recoveryScreenshot) { + await writeFile(resolve(evidenceDir, 'recovery-redacted.png'), browserEvidence.recoveryScreenshot); + } + await writeFile(resolve(evidenceDir, 'state.json'), `${JSON.stringify(browserEvidence.state, null, 2)}\n`, 'utf8'); + await writeFile(resolve(evidenceDir, 'server-summary.json'), `${JSON.stringify(serverSummary, null, 2)}\n`, 'utf8'); + if (metadata.privacyAudit) { + await writeFile(resolve(evidenceDir, 'privacy-audit.json'), `${JSON.stringify(metadata.privacyAudit, null, 2)}\n`, 'utf8'); + } + await writeFile(resolve(evidenceDir, 'metadata.json'), `${JSON.stringify(metadata, null, 2)}\n`, 'utf8'); + console.log(evidenceId); +} + +async function runWithCapturedStdout() { + if (process.env.OPS_XER_X11_CAPTURED_CHILD === '1' || isExplicitCiMode()) { + await main(); + return; + } + const scriptPath = fileURLToPath(import.meta.url); + const child = spawn(process.execPath, [scriptPath], { + cwd: REPO_ROOT, + env: { ...process.env, OPS_XER_X11_CAPTURED_CHILD: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let capturedStdout = ''; + child.stdout.on('data', (chunk) => { capturedStdout += chunk.toString(); }); + child.stderr.on('data', (chunk) => { process.stderr.write(chunk); }); + const exitCode = await new Promise((resolvePromise) => child.once('exit', (code) => resolvePromise(code ?? 1))); + if (exitCode !== 0) fail(`gecapteerde X11-childrun eindigde met exitcode ${exitCode}`); + const stdout = capturedStdout.trim(); + if (!/^x11-[0-9a-f-]{36}$/i.test(stdout) || capturedStdout !== `${stdout}\n`) { + fail('stdout-privacygate rood: childstdout is niet exact één opaque evidence-id-regel'); + } + + const evidenceDir = resolve(EVIDENCE_ROOT, stdout); + const metadataPath = resolve(evidenceDir, 'metadata.json'); + const privacyPath = resolve(evidenceDir, 'privacy-audit.json'); + const metadata = JSON.parse(await readFile(metadataPath, 'utf8')); + if ([RECOVERY_SCENARIO, LARGE_RESOURCE_SCENARIO].includes(metadata.scenario)) { + const serverSummary = JSON.parse(await readFile(resolve(evidenceDir, 'server-summary.json'), 'utf8')); + const stateArtifact = JSON.parse(await readFile(resolve(evidenceDir, 'state.json'), 'utf8')); + const privacyAudit = { + ...assertPhase2BEvidencePrivacy({ + evidenceId: stdout, + stdout, + metadata, + serverSummary, + stateArtifact, + }), + capturedStdout: true, + }; + metadata.privacyAudit = privacyAudit; + assertPhase2BEvidencePrivacy({ evidenceId: stdout, stdout, metadata, serverSummary, stateArtifact }); + await writeFile(privacyPath, `${JSON.stringify(privacyAudit, null, 2)}\n`, 'utf8'); + await writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, 'utf8'); + } + process.stdout.write(`${stdout}\n`); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + runWithCapturedStdout().catch((error) => { + console.error(`X11-harnas rood: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + }); +} diff --git a/tests/library/check-ifc-hostile.ts b/tests/library/check-ifc-hostile.ts index ba6c0420f..5f35abaff 100644 --- a/tests/library/check-ifc-hostile.ts +++ b/tests/library/check-ifc-hostile.ts @@ -101,7 +101,7 @@ function samplePool(overrides: Partial = {}): CompanyPool { const pool = samplePool({ poolVersion: 1, resources: [res('r1', 'Kraan (); DROP TABLE')] }); let back: CompanyPool | undefined; let threw = false; - try { back = readPoolIFC(writePoolIFC(pool)); } catch { threw = true; } + try { back = await readPoolIFC(writePoolIFC(pool)); } catch { threw = true; } assert(!threw, 'A1: pool met "();" in resourcenaam leest zonder te gooien'); assert(back?.resources.length === 1 && back.resources[0].name === 'Kraan (); DROP TABLE', 'A1: pool-resourcenaam met "();" komt exact terug'); assert(back?.companyName === 'Aannemer BV', 'A1: pool-metadata intact naast hostile resourcenaam'); diff --git a/tests/library/check-library-slice.ts b/tests/library/check-library-slice.ts index 595a58f0b..014dadc99 100644 --- a/tests/library/check-library-slice.ts +++ b/tests/library/check-library-slice.ts @@ -1230,7 +1230,7 @@ function commitOpenBoundaryForTest(): { refreshed: number; deviated: number; rem calendar: useAppStore.getState().calendar, tasks: [], sequences: [], resources: [{ id: 'rr', name: 'Sloper', type: 'LABOR', description: '', maxUnits: 3, costPerHour: 3, libraryOrigin: { companyId: cid, libraryItemId: poolResId, poolVersion: 1, syncedHash } }], - assignments: [], filePath: null, isDirty: false, + assignments: [], filePath: null, isDirty: false, datesAsRecorded: false, }], 'doc-rec'); const copy = useAppStore.getState().resources.find(r => r.id === 'rr'); assert(copy?.costPerHour === 5, 'grens 4 ververst een behind-kopie stil na herstel'); diff --git a/tests/library/check-occupancy.ts b/tests/library/check-occupancy.ts index a177f03f1..06592c165 100644 --- a/tests/library/check-occupancy.ts +++ b/tests/library/check-occupancy.ts @@ -792,7 +792,7 @@ let afterPayload: DocumentPayload | null = null; // opleveren (addSequence weigert dat via relationRules): precies het crashherstel-geval, waar elke // niet-actieve payload per definitie `scheduleStale: true` draagt. const recoveryDoc = (id: string, tasks: Task[], sequences: Sequence[]): RecoveryDocInput => ({ - id, filePath: null, isDirty: false, + id, filePath: null, isDirty: false, datesAsRecorded: false, project: { ...createDefaultProject(), name: id }, calendar: createDefaultCalendar(), tasks, sequences, resources: [], assignments: [], diff --git a/tests/library/check-pool-ifc.ts b/tests/library/check-pool-ifc.ts index d9e2d45f3..f46d4b8d7 100644 --- a/tests/library/check-pool-ifc.ts +++ b/tests/library/check-pool-ifc.ts @@ -27,7 +27,7 @@ const pool: CompanyPool = { }; const ifc = writePoolIFC(pool); -const back = readPoolIFC(ifc); +const back = await readPoolIFC(ifc); assert(back.companyId === 'c1', 'pool round-trip: companyId'); assert(back.companyName === 'Aannemer BV', 'pool round-trip: companyName'); @@ -40,14 +40,14 @@ assert(back.resources.find(r => r.id === 'pr2')?.unitOfMeasure === 'm3', 'pool r // Idempotentie: tweede round-trip byte-stabiel op de JSON-pool. const ifc2 = writePoolIFC(back); -const back2 = readPoolIFC(ifc2); +const back2 = await readPoolIFC(ifc2); assert(JSON.stringify(back) === JSON.stringify(back2), 'pool round-trip: idempotent'); // Een gewoon projectbestand draagt geen OPS_Library ⇒ readPoolIFC gooit. let threw = false; try { const projIfc = writePoolIFC(pool).replace(/OPS_Library/g, 'OPS_Iets_Anders'); - readPoolIFC(projIfc); + await readPoolIFC(projIfc); } catch { threw = true; } assert(threw, 'readPoolIFC gooit op een bestand zonder OPS_Library'); @@ -70,7 +70,7 @@ function injectPoolJson(ifc: string, json: string): string { const hostileIfc = injectPoolJson(writePoolIFC(pool), '{}'); let back: CompanyPool | undefined; let didThrow = false; - try { back = readPoolIFC(hostileIfc); } catch { didThrow = true; } + try { back = await readPoolIFC(hostileIfc); } catch { didThrow = true; } assert(!didThrow, 'F2: readPoolIFC gooit niet op een `{}`-poolblob'); assert(Array.isArray(back?.calendars) && back.calendars.length === 0, 'F2: `{}`-blob ⇒ calendars is een lege array (geen crash op .length)'); assert(Array.isArray(back?.resources) && back.resources.length === 0, 'F2: `{}`-blob ⇒ resources is een lege array (geen crash op .length)'); @@ -89,7 +89,7 @@ function injectPoolJson(ifc: string, json: string): string { const hostileIfc2 = injectPoolJson(writePoolIFC(pool), partialJson); let back2: CompanyPool | undefined; let didThrow2 = false; - try { back2 = readPoolIFC(hostileIfc2); } catch { didThrow2 = true; } + try { back2 = await readPoolIFC(hostileIfc2); } catch { didThrow2 = true; } assert(!didThrow2, 'F2: readPoolIFC gooit niet als de pool-JSON geen `resources`-sleutel draagt'); assert(Array.isArray(back2?.resources) && back2.resources.length === 0, 'F2: ontbrekende resources-sleutel ⇒ lege array (niet undefined)'); assert(back2?.calendars.length === 1 && back2.calendars[0].id === 'pc1', 'F2: de WEL-aanwezige calendars-sleutel blijft intact naast de ontbrekende resources'); diff --git a/tests/mcp/cases-batch.ts b/tests/mcp/cases-batch.ts index 03ff69700..363601cf2 100644 --- a/tests/mcp/cases-batch.ts +++ b/tests/mcp/cases-batch.ts @@ -155,9 +155,22 @@ const stubAsyncOnly: McpToolDef = { handler: async (_args, ctx) => runMutateTool(ctx, 'mutate', () => ({ data: null })), }; +/** Strikt schema (`additionalProperties:false` + `required`), zodat een stap met ongeldige args + * ook echt op `validateToolArgs` struikelt in plaats van door het lakse `{type:'object'}`-schema + * van de andere stubs te glippen. Gebruikt door de P2-isDirty-pinningtest hieronder + * (reviewbevinding 3, `review-mcp-read.md`). */ +const stubStrict = stubMutate('planner_stub_strict', () => ({ data: null }), { + inputSchema: { + type: 'object', + properties: { taskId: { type: 'string' } }, + required: ['taskId'], + additionalProperties: false, + }, +}); + const stubs: McpToolDef[] = [ stubAddTasks, stubRecord, stubReject, stubCycle, stubCycleEdges, stubBadTempId, stubExtend, stubLevel, - stubSaveBaseline, stubDocument, stubUndo, stubAsyncOnly, + stubSaveBaseline, stubDocument, stubUndo, stubAsyncOnly, stubStrict, ]; // De registry krijgt: de ECHTE leestools (de leesstap-tests draaien tegen `planner_get_project_info`), @@ -471,6 +484,34 @@ test('batch: ongeldige steps-args ⇒ VALIDATION zonder enige mutatie', async () assertEq(backupCalls.length, 0, 'geen van de weigeringen mag een backup triggeren'); }); +// ================================================================================================= +// 13b) P2 (review-mcp-read.md #3): een schema-VALIDATION op een stap rolt terug ZONDER isDirty aan +// te raken. Dit is een ALGEMENE batcheigenschap, niet iets dat één tool zelf moet bewaken: de +// schemapoort draait IN de stappenloop, dus al BINNEN `runInMcpTransaction` (zie het kopcommentaar +// "SCHEMAPOORT PER STAP" in batchTool.ts) — de transactie is dus al gestart zodra de fout valt. +// `createMcpTransactions.ts`'s `run()` herstelt op het rollbackpad expliciet `state.isDirty = +// previousDirty` NA `restoreSnapshot` (die zelf, voor de gewone undo/redo-tegenhanger, altijd naar +// `true` zou zetten — zie `snapshot.ts`). Deze test pint dat de basis dat al goed doet, zodat een +// toekomstige regressie in die volgorde hier meteen rood wordt. +// ================================================================================================= +test('batch: schema-ongeldige stap-args ⇒ VALIDATION zonder dat isDirty verandert', async () => { + backupCalls = []; + store.setState((s) => { s.isDirty = false; }); + const beforeDirty = store.getState().isDirty; + assertEq(beforeDirty, false, 'testopzet: isDirty moet aantoonbaar op false starten'); + + const res = await runBatch([ + { tool: 'planner_stub_strict', args: { unexpected: true } }, + ]); + + assert(!res.ok, 'schema-ongeldige args horen de batch te laten falen'); + if (!res.ok) assertEq(res.code, 'VALIDATION', 'code hoort VALIDATION te zijn'); + // Backup-trigger is hier bewust GEEN assertie: die loopt via `runMutateTool`'s preBackupGuards + // vóórdat de stappenloop de schemafout tegenkomt (spiegelt "precies één backup-trigger voor de + // hele batch" hierboven) — dat is een ander, al gedekt contractpunt, niet reviewbevinding 3. + assertEq(store.getState().isDirty, beforeDirty, 'isDirty hoort exact de waarde van vóór de batch te behouden, niet naar true te kantelen door de rollback'); +}); + // ================================================================================================= // 14) Een batchable tool ZONDER synchrone batch-kern is niet uitvoerbaar in een batch // ================================================================================================= diff --git a/tests/mcp/cases-doc-file.ts b/tests/mcp/cases-doc-file.ts index a440260f4..2f4c9f719 100644 --- a/tests/mcp/cases-doc-file.ts +++ b/tests/mcp/cases-doc-file.ts @@ -199,6 +199,54 @@ function buildMinimalMppBytes(): Uint8Array { }; return buildNestedCfb(tree); } + +function xerFixture(projectName: string): string { + return [ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date', + `%R\tP1\t${projectName}\tC1\t2026-01-01 08:00`, + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_code\ttask_name\ttarget_start_date\ttarget_end_date', + '%R\tT1\tP1\tA1\tTaak\t2026-01-01 08:00\t2026-01-01 16:00', + '%E', + ].join('\n'); +} + +function xerMultiFixture(): Uint8Array { + return new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date', + '%R\tP-A\tKlein\tC1\t2026-01-01 08:00', + '%R\tP-B\tGroot\tC1\t2026-01-01 08:00', + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_code\ttask_name\ttarget_start_date\ttarget_end_date', + '%R\tA1\tP-A\tA1\tEen\t2026-01-01\t2026-01-02', + '%R\tB1\tP-B\tB1\tEen\t2026-01-01\t2026-01-02', + '%R\tB2\tP-B\tB2\tTwee\t2026-01-02\t2026-01-03', + '%E', + ].join('\n')); +} + +function xerUtf16(text: string, littleEndian: boolean): Uint8Array { + const out = new Uint8Array(2 + text.length * 2); + out.set(littleEndian ? [0xff, 0xfe] : [0xfe, 0xff]); + for (let index = 0; index < text.length; index++) { + const value = text.charCodeAt(index); + out[2 + index * 2] = littleEndian ? value & 0xff : value >>> 8; + out[3 + index * 2] = littleEndian ? value >>> 8 : value & 0xff; + } + return out; +} + +function xerCp1252(text: string): Uint8Array { + return Uint8Array.from([...text].map(character => { + if (character === '€') return 0x80; + if (character === 'é') return 0xe9; + return character.charCodeAt(0); + })); +} installFakeFs(); /** Vers document met een klein doorgerekend benchmarkproject; retourneert het aantal taken. */ @@ -288,7 +336,7 @@ test('list_documents: hersteld document zonder cpmResult meldt "niet doorgereken const gen = generateBenchmarkProject(8); const base = { project: gen.project, calendar: gen.calendar, tasks: gen.tasks, sequences: gen.sequences, - resources: gen.resources, assignments: gen.assignments, filePath: null, isDirty: false, + resources: gen.resources, assignments: gen.assignments, filePath: null, isDirty: false, datesAsRecorded: false, }; S().restoreDocuments( [ @@ -535,6 +583,9 @@ test('import_schedule: hergebruikt een leeg-en-ongewijzigd actief tabblad (geen assertEq(S().activeDocumentId, pristineId, 'hetzelfde document blijft actief'); assertEq(data.documentId, pristineId, 'de respons meldt het hergebruikte document'); assertEq(data.reusedActiveTab, true, 'de respons meldt expliciet dat het tabblad hergebruikt is'); + assertEq(data.documentsOpened, 1, 'de bestaande enkelprojectrespons meldt één geopend document'); + assertEq(data.documents?.map((document: { documentId: string }) => document.documentId), [pristineId], + 'de enkelprojectrespons blijft compatibel en vult de documentinventaris aan'); assert(S().tasks.length > 0, 'de planning is ingeladen'); assertEq(data.tasks, S().tasks.length, 'de respons meldt het aantal geïmporteerde taken'); assertEq(ctx.expectedDocId, pristineId, 'het drift-anker wijst naar het resulterende document'); @@ -614,6 +665,58 @@ test('import_schedule: .mpp gaat via het bytes-pad, wordt als MPP14 herkend mét assert(data.tasks >= 1, 'de fixture-taak is daadwerkelijk geïmporteerd (geen stille lege import)'); }); +test('import_schedule: .xer behoudt CP1252 en beide UTF-16-BOM-payloads via MCP-readFile en krijgt GEEN opslagdoel', async () => { + const source = xerFixture('Café €'); + const fixtures = [ + ['cp1252', xerCp1252(source)], + ['utf16le', xerUtf16(source, true)], + ['utf16be', xerUtf16(source, false)], + ] as const; + for (const [encoding, bytes] of fixtures) { + installFakeFs(); + resetToSingleEmptyDocument(); + const path = `${HOME}/plan-${encoding}.xer`; + setBinaryFile(path, bytes); + + const data = await callOk('planner_import_schedule', { path }); + + assertEq(readFileCalls, [path], `${encoding}: MCP leest .xer uitsluitend via readFile`); + assertEq(data.format, 'XER', `${encoding}: MCP rapporteert het XER-formaat`); + assertEq(S().project.name, 'Café €', `${encoding}: projectnaam overleeft zonder re-encoding`); + assertEq(data.tasks, 1, `${encoding}: de activiteit is geïmporteerd`); + assertEq(S().filePath, null, `${encoding}: .xer wordt nooit het Ctrl+S-opslagdoel`); + assert(String(data.notice).includes('alleen-lezen'), `${encoding}: notice noemt alleen-lezen`); + } +}); + +test('import_schedule: meerproject-XER fan-out antwoordt met exact aantal en inventaris van alle documenten', async () => { + installFakeFs(); + resetToSingleEmptyDocument(); + const path = `${HOME}/meerproject.xer`; + setBinaryFile(path, xerMultiFixture()); + + const data = await callOk('planner_import_schedule', { path }); + const docs = S().getOpenDocumentPayloads(); + const expectedInventory = docs.map(document => ({ + documentId: document.id, + projectId: document.payload.project.id, + projectName: document.payload.project.name, + tasks: document.payload.tasks.length, + sequences: document.payload.sequences.length, + resources: document.payload.resources.length, + filePath: document.payload.filePath, + })); + + assertEq(docs.map(document => document.payload.project.id), ['P-A', 'P-B'], + 'de echte planner_import_schedule-route opent beide niet-lege XER-projecten'); + assertEq(data.documentsOpened, 2, 'de respons meldt exact twee geopende documenten'); + assertEq(data.documents, expectedInventory, 'de respons inventariseert exact de aangemaakte/hergebruikte ids'); + assertEq(data.documentId, docs[1].id, 'documentId blijft compatibel en wijst naar het actieve grootste project'); + assertEq(S().activeDocumentId, docs[1].id, 'het grootste project is werkelijk actief'); + assert(String(data.notice).includes('2'), 'de XER-notice noemt het werkelijke aantal projecten/documenten'); + assert(!String(data.notice).includes('één niet-leeg P6-project'), 'de oude onware enkelvoudsclaim is verdwenen'); +}); + test('import_schedule: onbekend pad ⇒ NOT_FOUND, buiten de scope ⇒ SCOPE', async () => { installFakeFs(); resetToSingleEmptyDocument(); diff --git a/tests/mcp/cases-draft.ts b/tests/mcp/cases-draft.ts index ce8e0250a..3448e2304 100644 --- a/tests/mcp/cases-draft.ts +++ b/tests/mcp/cases-draft.ts @@ -65,6 +65,37 @@ test('draft.addTask mijlpaal ⇒ duur 0, gewone taak ⇒ default 5', () => { assertEq(store.getState().tasks.find((t) => t.id === normalId)?.time.scheduleDuration, 5, 'gewone taak hoort default-duur 5 te krijgen'); }); +test('draft add/update bewaart en reset een lege summary zonder stille solvertaak', () => { + let summaryId = ''; + const added = runInMcpTransaction(() => { + summaryId = draft.addTask({ name: 'Draft lege summary', isSummary: true }); + }); + assert(added.ok, 'aanmaaktransactie hoort te slagen'); + assertEq(store.getState().tasks.find((task) => task.id === summaryId)?.isSummary, true, + 'draft.addTask hoort expliciet true te bewaren'); + assertEq(store.getState().cpmResult?.tasks.has(summaryId), false, + 'de lege draft-summary hoort buiten CPMResult.tasks te blijven'); + + const reset = runInMcpTransaction(() => { + draft.updateTaskFields(summaryId, { isSummary: false }); + }); + assert(reset.ok, 'resettransactie hoort te slagen'); + assertEq(store.getState().tasks.find((task) => task.id === summaryId)?.isSummary, false, + 'draft.updateTaskFields hoort expliciet false als bewuste reset te bewaren'); + assertEq(store.getState().cpmResult?.tasks.has(summaryId), true, + 'de bewust teruggezette taak hoort weer een solvertaak te zijn'); + + let regularId = ''; + const regular = runInMcpTransaction(() => { + regularId = draft.addTask({ name: 'Draft gewone taak' }); + }); + assert(regular.ok, 'gewone aanmaaktransactie hoort te slagen'); + assertEq(store.getState().tasks.find((task) => task.id === regularId)?.isSummary, undefined, + 'draft.addTask mag een gewone invoer niet per ongeluk summary maken'); + assertEq(store.getState().cpmResult?.tasks.has(regularId), true, + 'de gewone draft-taak hoort in CPMResult.tasks te staan'); +}); + test('draft.addTask erft taskType van de ouder wanneer de aanroeper er zelf geen opgeeft', () => { const parentId = store.getState().addTask({ name: 'mcp-ouder', taskType: 'LOGISTIC' }); diff --git a/tests/mcp/cases-read.ts b/tests/mcp/cases-read.ts index ff0c151a1..d3af0ae0b 100644 --- a/tests/mcp/cases-read.ts +++ b/tests/mcp/cases-read.ts @@ -54,14 +54,14 @@ function cleanProject(): void { } // ================================================================================================= -// Registratie: alle 10 tools aanwezig met planner_-prefix + readOnly-annotatie +// Registratie: alle leestools aanwezig met planner_-prefix + readOnly-annotatie // ================================================================================================= -test('registratie: 10 leestools met planner_-prefix, description en readOnlyHint:true', () => { +test('registratie: alle leestools met planner_-prefix, description en readOnlyHint:true', () => { const names = [ 'planner_get_project_info', 'planner_get_project_overview', 'planner_list_tasks', 'planner_get_task', 'planner_get_critical_path', 'planner_list_resources', 'planner_get_resource_histogram', 'planner_get_calendars', 'planner_compare_baseline', - 'planner_analyze_delay', + 'planner_analyze_delay', 'planner_inspect_xer_provenance', ]; for (const n of names) { const t = getTool(n); diff --git a/tests/mcp/cases-recorded-dates.ts b/tests/mcp/cases-recorded-dates.ts index 8e9f763be..496c36a85 100644 --- a/tests/mcp/cases-recorded-dates.ts +++ b/tests/mcp/cases-recorded-dates.ts @@ -8,7 +8,9 @@ // `beginUndoable` was daar niet meer onderdrukt ⇒ undo-stack 1 → 3. // K2: de rollback popt één entry terwijl er in de modus twee gepusht waren ⇒ fantoom-undo-stap // na een GEWEIGERDE AI-actie. -import { useAppStore, test, assert, assertEq, run } from './harness'; +import { appStoreContext, makeMcpContext, useAppStore, test, assert, assertEq, run } from './harness'; +import { getTool } from '@/services/mcp/toolRegistry'; +import type { McpToolOk, McpToolResult } from '@/services/mcp/contracts'; import { runInMcpTransaction, draft } from '@/state/mcpTransaction'; import { readIFC } from '@/services/ifc/ifcReader'; import { externIfc } from '../fixtures/recordedDatesIfc'; @@ -19,6 +21,15 @@ import { historyDepthsForActiveScope } from '@/state/sessionHistory'; const store = useAppStore; const S = () => store.getState(); +/** Roep een geregistreerde leestool aan en verwacht ok:true (zelfde vorm als cases-read.ts). */ +function callOk(name: string, args: unknown = {}): any { + const tool = getTool(name); + assert(!!tool, `tool ${name} niet geregistreerd`); + const res = tool!.handler(args, makeMcpContext(appStoreContext)) as McpToolResult; + assert(res.ok, `tool ${name} gaf een fout: ${res.ok ? '' : res.error}`); + return (res as McpToolOk).data; +} + // Warm-up (zoals cases-bulk.ts): projectkalender tot bibliotheek-entry promoten via één undo, zodat // het rollback-/restore-pad een steady state heeft. S().addTask({ name: 'warmup' }); @@ -129,4 +140,72 @@ test('contextfactory B verlaat recorded-dates met één B-undo en laat A buiten 'A-undo hoort onaangeroerd te blijven'); }); +// --- 5) Bevinding 6 (critreview laag 3): geen verzonnen 0/late datum via de LEESTOOLS ----------- +// In de modus draagt `task.time` de vastlegging van het bronbestand, met de bewuste terugvallen +// (`lateStart ?? rec.start`, `totalFloat ?? 0`, `isCritical ?? false`) voor assen die het bestand +// NIET vastlegde. De taaktabel toont daar "Niet vastgelegd"; een AI-client zag tot nu toe een +// verzonnen nulspeling als feit, zonder de strook die de stand toelicht. +// MUTATIEBEWIJS: haal de `unrecorded?.includes(...)`-poort uit `getTask`/`getCriticalPath` +// (readTools.ts) ⇒ deze twee tests slaan rood. +test('leestools in de modus: niet-vastgelegde assen komen als null naar buiten (bevinding 6)', () => { + const { bId } = enterMode('mcp-unrecorded'); + assertEq(S().datesAsRecorded, true, 'voorwaarde: de modus hoort aan te staan'); + // De gedeelde #63-fixture legt uitsluitend het early- en schedule-paar vast; alle vier de + // optionele assen (late start/finish, totale/vrije speling) én isCritical ontbreken. + const detail = callOk('planner_get_task', { taskId: bId }); + const schedule = detail.schedule as Record; + assertEq(schedule.earlyStart, '2026-03-16', 'de VASTGELEGDE vroege start hoort er gewoon te staan'); + assertEq(schedule.lateStart, null, 'niet-vastgelegde late start hoort null te zijn, geen rec.start-terugval'); + assertEq(schedule.lateFinish, null, 'niet-vastgelegde late finish hoort null te zijn'); + assertEq(schedule.totalFloat, null, 'niet-vastgelegde totale speling hoort null te zijn, geen verzonnen 0'); + assertEq(schedule.freeFloat, null, 'niet-vastgelegde vrije speling hoort null te zijn'); + assertEq(schedule.isCritical, null, 'niet-vastgelegde kritiek-vlag hoort null te zijn, geen verzonnen false'); + assertEq(schedule.datesAsRecordedUnrecordedFields, + ['lateStart', 'lateFinish', 'totalFloat', 'freeFloat', 'isCritical'], + 'de respons benoemt expliciet WELKE assen het bestand niet vastlegde'); +}); + +test('leestools buiten de modus: byte-identiek aan voorheen (geen null, geen extra veld)', () => { + const { bId } = enterMode('mcp-unrecorded-off'); + S().runCPM(); // verlaat de modus, zoals F5 + assertEq(S().datesAsRecorded, false, 'voorwaarde: de modus hoort uit te staan'); + const schedule = callOk('planner_get_task', { taskId: bId }).schedule as Record; + assert(typeof schedule.totalFloat === 'number', 'buiten de modus hoort de BEREKENDE speling er te staan'); + assert(typeof schedule.lateStart === 'string', 'buiten de modus hoort de BEREKENDE late start er te staan'); + assertEq(schedule.datesAsRecordedUnrecordedFields, undefined, + 'buiten de modus hoort de respons geen extra veld te dragen'); +}); + +// Her-check laag 3, R4 — bevinding 10: de overige leestools poorten `isCritical` in de modus. +// MUTATIEBEWIJS: zet `if (t.time.isCritical) row.crit = true;` terug zonder de poort, of haal +// `criticalUnrecordedTasks`/de driewegfilter weg ⇒ deze test slaat rood. +test('leestools in de modus: kritiek is onbekend, geen verzonnen false (bevinding 10)', () => { + const { aId, bId } = enterMode('mcp-crit-unknown'); + assertEq(S().datesAsRecorded, true, 'voorwaarde: de modus hoort aan te staan'); + const info = callOk('planner_get_project_info'); + assertEq(info.statistics.criticalTasks, 0, 'geen enkele taak mag als kritiek geteld worden op een `?? false`'); + assertEq(info.statistics.criticalUnrecordedTasks, 2, 'beide taken hebben een onbekende kritiekas, apart geteld'); + const overview = callOk('planner_get_project_overview'); + const rows = (overview.tasks ?? overview.rows ?? []) as Array>; + const byId = new Map(rows.map((row) => [row.id, row])); + assertEq(byId.get(aId)?.crit, null, 'overzicht: onbekende kritiekas is null, niet weggelaten'); + assertEq(byId.get(bId)?.crit, null, 'overzicht: idem voor de tweede taak'); + const all = callOk('planner_list_tasks', {}); + const listed = (all.tasks ?? all.items ?? []) as Array>; + assertEq(listed.length, 2, 'zonder filter: beide taken'); + assertEq(listed.every((row) => row.crit === null), true, 'lijst: onbekende kritiekas is null'); + const crit = callOk('planner_list_tasks', { kritiek: true }); + const notCrit = callOk('planner_list_tasks', { kritiek: false }); + assertEq(((crit.tasks ?? crit.items ?? []) as unknown[]).length, 0, 'filter kritiek=true: onbekend hoort bij geen van beide'); + assertEq(((notCrit.tasks ?? notCrit.items ?? []) as unknown[]).length, 0, 'filter kritiek=false: onbekend hoort bij geen van beide'); + + S().runCPM(); // verlaat de modus, zoals F5 + const infoOff = callOk('planner_get_project_info'); + assertEq(infoOff.statistics.criticalUnrecordedTasks, undefined, 'buiten de modus: geen extra veld'); + assert(typeof infoOff.statistics.criticalTasks === 'number' && infoOff.statistics.criticalTasks >= 1, + 'buiten de modus: de BEREKENDE kritieke taken tellen gewoon mee'); + const rowsOff = ((callOk('planner_get_project_overview').tasks ?? []) as Array>); + assertEq(rowsOff.every((row) => row.crit === true || row.crit === undefined), true, 'buiten de modus: crit is true of afwezig, nooit null'); +}); + await run(); diff --git a/tests/mcp/cases-schemavalidatie.ts b/tests/mcp/cases-schemavalidatie.ts index ac2a6a042..949e036b4 100644 --- a/tests/mcp/cases-schemavalidatie.ts +++ b/tests/mcp/cases-schemavalidatie.ts @@ -209,12 +209,12 @@ test('dispatcher: een GELDIGE call passeert de poort ongehinderd', async () => { }); // ================================================================================================= -// 3) Schema-dekking over ALLE 39 tools +// 3) Schema-dekking over ALLE 40 tools // ================================================================================================= -test('alle 39 inputSchema\'s gebruiken uitsluitend trefwoorden die de validator afdwingt', () => { +test('alle 40 inputSchema\'s gebruiken uitsluitend trefwoorden die de validator afdwingt', () => { registerAllTools(); const tools = getTools(); - assert(tools.length === 39, `verwachtte 39 tools, kreeg ${tools.length}`); + assert(tools.length === 40, `verwachtte 40 tools, kreeg ${tools.length}`); const offenders: string[] = []; for (const t of tools) { const unknown = unsupportedKeywords(t.inputSchema); diff --git a/tests/mcp/cases-sync2-integration.ts b/tests/mcp/cases-sync2-integration.ts index c8f9b84bc..ebc74d613 100644 --- a/tests/mcp/cases-sync2-integration.ts +++ b/tests/mcp/cases-sync2-integration.ts @@ -44,7 +44,7 @@ function makeCtx(over: McpContextOverrides = {}): McpContext { * `tools/list` stilletjes verschuift. */ const EXPECTED_TOOLS = { - read: 10, // T18/T18b — leestools + read: 11, // T18/T18b + retained XER-provenance — leestools taskMutate: 6, // T19 — add/update/delete_tasks, move_task, add/remove_dependencies dependency: 1, // dependencyTools — update_dependencies (bestaande relatie wijzigen) taskOther: 3, // T19 — undo, redo, run_cpm @@ -56,9 +56,9 @@ const EXPECTED_TOOLS = { file: 2, // T21 — export_ifc, import_schedule batch: 1, // T22 — planner_batch }; -const EXPECTED_TOTAL = Object.values(EXPECTED_TOOLS).reduce((a, b) => a + b, 0); // = 39 +const EXPECTED_TOTAL = Object.values(EXPECTED_TOOLS).reduce((a, b) => a + b, 0); // = 40 -test('tools/list levert exact de verwachte toolstelling (39) via de dispatcher', async () => { +test('tools/list levert exact de verwachte toolstelling (40) via de dispatcher', async () => { const raw = await handleMcpMessage( JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), makeCtx(), diff --git a/tests/mcp/cases-taskfields.ts b/tests/mcp/cases-taskfields.ts index ed4b8ad81..3e41e708c 100644 --- a/tests/mcp/cases-taskfields.ts +++ b/tests/mcp/cases-taskfields.ts @@ -205,6 +205,27 @@ test('update_tasks: mspTaskType/effortDriven/timephasedContours zijn read-only ( assert(t.timephasedContours === undefined, 'timephasedContours niet gezet'); }); +test('X9: p6DurationType/p6ActivityType zijn via de echte MCP-update runtime read-only', async () => { + reset(); + const id = seedTask('P6-brondata', 5); + const before = JSON.stringify(taskById(id)); + + for (const [field, value] of [ + ['p6DurationType', 'DT_FixedRate'], + ['p6ActivityType', 'TT_LOE'], + ] as const) { + const res = await call('planner_update_tasks', { + updates: [{ id, fields: { [field]: value } }], + }); + const rejection = rejections(res)[0]; + assert(rejection?.reason.includes(`onbekend veld '${field}'`), `de runtimeweigering noemt ${field}`); + assert(/P6|\.xer|importdata/.test(rejection?.reason ?? ''), `de runtimeweigering motiveert ${field}`); + assertEq(okData(res).updated, [], `${field} is niet als toegepast gerapporteerd`); + } + + assertEq(JSON.stringify(taskById(id)), before, 'beide geweigerde MCP-calls laten de taak byte-identiek'); +}); + test('update_tasks: fields.time wordt geweigerd en laat de hele time-tak intact', async () => { reset(); withStatusDate(); @@ -346,6 +367,19 @@ test('update_tasks: isMilestone ⇒ duur 0; verzameltaak/taak-met-toewijzing wor const res = await call('planner_update_tasks', { updates: [{ id: parent, fields: { isMilestone: true } }] }); assertEq(rejections(res).length, 1, 'een verzameltaak kan geen mijlpaal worden'); assertEq(taskById(parent)!.isMilestone, false, 'de verzameltaak bleef ongewijzigd'); + + const emptyWbs = store.getState().addTask({ name: 'Lege P6-WBS' }); + store.setState((s) => { + const task = s.tasks.find((candidate) => candidate.id === emptyWbs)!; + task.isSummary = true; + }); + const emptyWbsResult = await call('planner_update_tasks', { + updates: [{ id: emptyWbs, fields: { isMilestone: true } }], + }); + assertEq(rejections(emptyWbsResult).length, 1, + 'een expliciete lege WBS-samenvatting kan via MCP geen mijlpaal worden'); + assertEq(taskById(emptyWbs)!.isMilestone, false, + 'de expliciete lege WBS-samenvatting bleef ongewijzigd'); }); test('update_tasks: een geweigerd fields-blok blokkeert het progress-pad van hetzelfde item NIET', async () => { diff --git a/tests/mcp/cases-xer-provenance.ts b/tests/mcp/cases-xer-provenance.ts new file mode 100644 index 000000000..72b64ebe9 --- /dev/null +++ b/tests/mcp/cases-xer-provenance.ts @@ -0,0 +1,853 @@ +// Gerichte MCP-contracttests voor retained XER/P6-bronsemantiek. +// Alle bronwaarden zijn synthetisch; er wordt geen corpusnaam of pad gebruikt. +import { useAppStore, makeMcpContext, test, assert, assertEq, run } from './harness'; +import { getTool } from '@/services/mcp/toolRegistry'; +import { handleMcpMessage } from '@/services/mcp/dispatcher'; +import type { McpContext, McpToolErr, McpToolOk, McpToolResult } from '@/services/mcp/contracts'; +import { + XER_SOURCE_ARCHIVE_CHUNK_BYTES, + createEmptyXerArchiveDiagnostics, + createEmptyXerArchiveReadModel, + type XerSourceArchive, +} from '@/services/xerSourceArchive'; +import { reconstructXerSourceFromBytes } from '@/services/xer/xerReader'; +import { createDefaultTaskTime } from '@/utils/taskDefaults'; + +const S = () => useAppStore.getState(); +const TOOL = 'planner_inspect_xer_provenance'; + +function ctx(): McpContext { + return makeMcpContext(); +} + +function call(name: string, args: unknown = {}): McpToolResult { + const tool = getTool(name); + assert(!!tool, `tool ${name} ontbreekt`); + return tool!.handler(args, ctx()) as McpToolResult; +} + +async function callAsync(name: string, args: unknown = {}): Promise { + const tool = getTool(name); + assert(!!tool, `tool ${name} ontbreekt`); + return await tool!.handler(args, ctx()); +} + +function ok(name: string, args: unknown = {}): any { + const result = call(name, args); + assert(result.ok, `${name} gaf een fout: ${result.ok ? '' : result.error}`); + return (result as McpToolOk).data; +} + +function err(name: string, args: unknown): McpToolErr { + const result = call(name, args); + assert(!result.ok, `${name} moest falen`); + return result as McpToolErr; +} + +function reset(): void { + S().newProject(); +} + +// Reviewbevinding P1 (batch): een grote gestructureerde XER-invoer mag geen enkele cel onbegrensd +// laten groeien. `LONG_CELL` is > het per-cel afkapniveau (2.000 tekens) zodat afkapping écht +// getest wordt (de oorspronkelijke synthetische fixture van 1.520 tekens bleef er ONDER en bewees +// dus niets over de afkapgrens zelf). +const LONG_CELL = 'synthetic-free-cell-' + 'x'.repeat(5000); +const LONG_CELL_TRUNCATED_LENGTH = 2000 + '…(afgekapt op 2000 tekens)'.length; + +// Reviewronde 2 (review2-3d.md #2): de eerste fix dichtte alleen `rawRow.cells` af; vrije tekst +// BUITEN `rawRow` (roleSources.description, resources.description, taskProjections.notes/ +// customFields) ging ongefilterd mee. Twee aparte secrets houden de twee behandelingen uit elkaar: +// FREE_TEXT_SECRET hoort in een veld dat zonder opt-in VOLLEDIG moet verdwijnen (notities); die +// mag dus NERGENS verschijnen, ook niet als afgekapt prefix. LABEL_SECRET hoort in een kort +// naam-/labelveld dat bewust ZICHTBAAR blijft maar hard afgekapt — daar is een 200-tekens-prefix +// een correcte uitkomst, geen lek. +const FREE_TEXT_SECRET = 'FREE-TEXT-SECRET-' + 'y'.repeat(9000); +const LABEL_SECRET = 'LABEL-SECRET-' + 'z'.repeat(9000); +const LABEL_TRUNCATED_LENGTH = 200 + '…(afgekapt op 200 tekens)'.length; + +// Reviewronde 3 (review2-3d.md N2): een BLOCKLIST van vrije-tekstsleutels vergeet onvermijdelijk een +// synoniem — `text`/`comment`/`memo`/`remark`/`title`/`longName` glipten er in ronde 2 allemaal +// doorheen. De fix keert de classificatie om naar deny-by-default (`SAFE_LABEL_KEYS`-allowlist); deze +// zes alias-sleutels op `roleSources[0]` bewijzen dat ELKE onbekende sleutel nu standaard verborgen +// is, niet alleen de drie die de vorige ronde bij naam noemde. +const ALIAS_SECRET = 'ALIAS-SECRET-' + 'a'.repeat(3000); +const ALIAS_KEYS = ['text', 'comment', 'memo', 'remark', 'title', 'longName'] as const; + +// Reviewronde 3 (review2-3d.md N5): een bronrij-vorm met méér dan twee sleutels moet nog steeds +// structureel herkend worden. +const N5_SECRET = 'N5-SHAPE-SECRET-' + 'n'.repeat(9000); + +// Reviewronde 4 (review2-3d.md R8): exact het door de review gemeten getal — een project-id van +// 80.000 tekens die als OBJECTSLEUTEL (niet als waarde) de respons in gaat. +const LONG_PROJECT_ID = 'P'.repeat(80000); + +function archiveFixture(): XerSourceArchive { + // De production-reader levert deze exact gevormde objectgrafiek. De test gebruikt een compacte + // synthetische variant, omdat deze suite MCP-responscontracten test en geen XER-parsercontract. + const readModel = structuredClone(createEmptyXerArchiveReadModel()) as any; + const diagnostics = structuredClone(createEmptyXerArchiveDiagnostics()) as any; + // Review2-3d.md ronde 3, N3: `currencyCode` komt uit CURRTYPE in het bronbestand — een 5.014-tekens + // waarde (exact het door de review gemeten getal) bewijst dat `summary` nu ook door de + // labelafkapping loopt (het IS een SAFE_LABEL_KEYS-veld, dus zichtbaar, maar hard begrensd). + readModel.numberFormat = { decimal: ',', group: '.', source: 'currtype', currencyCode: `EUR-${'Q'.repeat(5010)}` }; + readModel.taskSourceRowsByProject = { + 'PROJ-A': [ + { line: 11, cells: { task_id: 'TASK-A', task_name: LONG_CELL, task_notes: 'synthetic note' } }, + { line: 12, cells: { task_id: 'TASK-B', task_name: 'Second synthetic task' } }, + ], + // Reviewbevinding P1 (responsgrens): 100 rijen (de opt-in-paginalimiet) met elk drie ruime + // vrije cellen, zodat één pagina ná per-cel-afkapping alsnog boven de 256 kB-responsgrens komt. + 'PROJ-BIG': Array.from({ length: 100 }, (_, index) => ({ + line: 200 + index, + cells: { + task_id: `BIG-${index}`, + cell_a: 'a'.repeat(3000), + cell_b: 'b'.repeat(3000), + cell_c: 'c'.repeat(3000), + }, + })), + // Review2-3d.md #4: CJK-tekst is 1 UTF-16-code-unit maar 3 UTF-8-bytes per teken. 50 rijen × + // 2.000 CJK-tekens = 100.000 CODE-UNITS (ver onder de oude "String.length"-grens van 262.144) + // maar ≈ 300.000 ECHTE BYTES (ruim boven de 262.144-bytesgrens die de tool belooft). + 'PROJ-CJK': Array.from({ length: 50 }, (_, index) => ({ + line: 300 + index, + cells: { task_id: `CJK-${index}`, greeting: '漢'.repeat(2000) }, + })), + // Review2-3d.md #5: dit project heeft GEEN documentview (het is nooit "geopend" in de zin van + // de lezer) maar staat wél hier — precies de P2_BASELINE-situatie uit de review: de summary moet + // het adverteren, en de tool moet het ook echt accepteren. + 'PROJ-ONLY-IN-TASKROWS': [{ line: 500, cells: { task_id: 'ONLY-1' } }], + // Review2-3d.md ronde 3, N5: een bronrij met MEER dan twee sleutels (zoals + // `XerScheduleOptionsSourceRow` = `{table,line,cells}`) moet nog steeds structureel herkend + // worden — niet fail-open naar de generieke labeltak vallen. + 'PROJ-SHAPE': [{ table: 'PROJECT', line: 800, cells: { secret_col: N5_SECRET } }], + // Review2-3d.md ronde 3, N4: één rij met een kolomNAAM van 60.001 tekens (de review mat exact dit + // getal) — moet zelf afgekapt worden, niet verbatim meegaan. + 'PROJ-LONGKEY': [{ line: 600, cells: { [`col_${'k'.repeat(60001)}`]: 'short-value' } }], + // Review2-3d.md ronde 3, N4: 10 rijen × 200 cellen met LANGE kolomnamen maar TRIVIALE waarden — + // zonder key-begroting zou de budget-teller hier bijna niets zien (de waarden zijn 1 teken) en + // pas via de dure `finalizeBounded`-serialisatie ingrijpen, als het al binnen de opt-in- + // paginalimiet past. Met key-begroting breekt de teller tijdens de projectie af. + 'PROJ-LONGKEY-BUDGET': Array.from({ length: 10 }, (_, rowIndex) => ({ + line: 700 + rowIndex, + cells: Object.fromEntries(Array.from({ length: 200 }, (_, cellIndex) => [ + `col_${rowIndex}_${cellIndex}_${'k'.repeat(2500)}`, 'v', + ])), + })), + // Review2-3d.md ronde 4, R8: een project-id van 80.000 tekens (`proj_id` komt uit het + // bronbestand) belandt via `summary.catalogCounts.taskSourceRowsByProject` als OBJECTSLEUTEL — + // de generieke objecttak deed voorheen `out[childKey] = …` zonder de sleutel zelf af te kappen. + [LONG_PROJECT_ID]: [{ line: 900, cells: { task_id: 'LONGID-1' } }], + }; + readModel.resourceCatalog = { + resources: [{ id: 'resource-1', name: 'Synthetic Crew', description: FREE_TEXT_SECRET }], + identities: [{ kind: 'RESOURCE', sourceId: 'R-1', internalId: 'resource-1', line: 20 }], + rows: { + resources: [{ sourceId: 'R-1', internalId: 'resource-1', line: 20, rawType: '1', rawRow: { line: 20, cells: { rsrc_id: 'R-1', rsrc_name: 'Synthetic Crew', rsrc_notes: LONG_CELL } } }], + roles: [{ + sourceId: 'ROLE-1', internalId: 'role-1', line: 21, name: 'Synthetic Role', shortName: 'SR', + description: FREE_TEXT_SECRET, + // Zes alias-sleutels (review2-3d.md N2) — geen van deze staat op een blocklist, dus alleen + // deny-by-default (SAFE_LABEL_KEYS) verbergt ze allemaal. + text: ALIAS_SECRET, comment: ALIAS_SECRET, memo: ALIAS_SECRET, remark: ALIAS_SECRET, + title: ALIAS_SECRET, longName: ALIAS_SECRET, + rawRow: { line: 21, cells: { role_id: 'ROLE-1', role_name: 'Synthetic Role' } }, + }], + rates: [{ sourceId: 'R-1', internalId: 'resource-1', entity: { kind: 'RESOURCE', sourceId: 'R-1', internalId: 'resource-1' }, line: 22, maxUnitsPerTime: null, costs: [null, null, null, null, null], rawRow: { line: 22, cells: { rsrc_id: 'R-1' } } }], + curves: [{ sourceId: 'CURVE-1', internalId: 'curve-1', line: 23, name: LABEL_SECRET, rawPoints: Array(21).fill('0'), rawRow: { line: 23, cells: { curve_id: 'CURVE-1' } } }], + assignments: [{ sourceId: 'R-1', taskSourceId: 'TASK-A', rawRow: { line: 24, cells: { task_id: 'TASK-A' } } }], + }, + issues: [{ code: 'XER_RESOURCE_TYPE_FALLBACK', table: 'RSRC', line: 20, sourceId: 'R-1', fallback: 'LABOR' }], + }; + readModel.metadataCatalog = { + activityCodeTypes: [{ id: 'ACTIVITY-TYPE-1', name: 'Synthetic Discipline', values: [{ id: 'ACTIVITY-VALUE-1', code: 'CIVIL' }] }], + customFieldDefs: [{ id: 'UDF-1', name: LABEL_SECRET, type: 'text' }], + taskProjections: [{ + projectId: 'PROJ-A', taskId: 'TASK-A', + // `Task['notes']` (src/types/task.ts) is een OBJECTARRAY (`{id,text,done}[]`), geen string + // (review2-3d.md ronde 3, N1) — de fixture gebruikt bewust de ECHTE vorm zodat de test het + // eerdere lek (de blocklist keek alleen naar de buitenste sleutel `notes`, niet naar `text` + // binnenin) ook echt had gezien. + notes: [{ id: 'note-1', text: FREE_TEXT_SECRET, done: false }], + activityCodes: { 'ACTIVITY-TYPE-1': 'ACTIVITY-VALUE-1' }, + customFields: { 'UDF-1': FREE_TEXT_SECRET }, + }], + taskProjectionsByProject: { 'PROJ-A': [{ projectId: 'PROJ-A', taskId: 'TASK-A' }] }, + issues: [{ code: 'XER_UDF_INVALID_VALUE', table: 'UDFVALUE', line: 31 }], + issueCounts: { XER_UDF_INVALID_VALUE: 1 }, + sourceData: { + ACTVTYPE: [{ line: 25, cells: { actv_code_type_id: 'ACTIVITY-TYPE-1' } }], + ACTVCODE: [{ line: 26, cells: { actv_code_id: 'ACTIVITY-VALUE-1' } }], + TASKACTV: [{ line: 27, cells: { task_id: 'TASK-A' } }], + UDFTYPE: [{ line: 28, cells: { udf_type_id: 'UDF-1' } }], + UDFVALUE: [{ line: 29, cells: { fk_id: 'TASK-A' } }], + MEMOTYPE: [{ line: 30, cells: { memo_type_id: 'MEMO-1' } }], + TASKNOTE: [{ line: 31, cells: { task_id: 'TASK-A' } }], + // TASKMEMO draagt bij uitstek vrije notitietekst — de synthetische cel is bewust lang. + TASKMEMO: [{ line: 32, cells: { task_id: 'TASK-A', task_memo: LONG_CELL } }], + TASK_NOTES: [{ line: 33, cells: { task_notes: 'synthetic note' } }], + deferredUdfValues: [{ line: 34, cells: { fk_id: 'RESOURCE-1' } }], + unknownUdfTypes: [{ line: 35, cells: { udf_type_id: 'UDF-UNKNOWN' } }], + }, + }; + diagnostics.file = { + tableReport: { + encoding: 'windows-1252', endMarkerSeen: true, + issues: [{ code: 'XER_SYNTHETIC_DIAGNOSTIC', line: 40, table: 'TASK' }], + unknownTables: [{ name: 'SYNTHETIC_UNKNOWN', rows: 4 }], + unknownFields: [{ table: 'TASK', name: 'synthetic_unknown_field', rows: 2 }], + }, + scheduleOptions: [{ code: 'XER_DUPLICATE_SCHEDOPTIONS_PROJ_ID', projectId: 'PROJ-A', rowIndexes: [0, 1], lines: [41, 42] }], + relationResolutionIssues: [{ reason: 'dangling', line: 43, field: 'task_id', taskId: 'TASK-B', predecessorTaskId: 'TASK-MISSING' }], + resourceCatalogIssues: readModel.resourceCatalog.issues, + metadataCatalogIssues: readModel.metadataCatalog.issues, + importReport: { + projectsSeen: 2, documentsOpened: 1, emptyProjectsSkipped: 1, baselineProjectsExcluded: 0, + baselinesMaterialized: 0, danglingBaselineReferences: 1, externalLinksPreserved: 2, + baselineExclusionReverted: false, baselineFallbackReasons: [], + }, + }; + diagnostics.documentViews = { + 'PROJ-A': { sourceProjectId: 'PROJ-A', synthetic: true }, + 'PROJ-BIG': { sourceProjectId: 'PROJ-BIG', synthetic: true }, + }; + return { + schemaVersion: 1, + format: 'primavera-p6-xer', + byteLength: XER_SOURCE_ARCHIVE_CHUNK_BYTES * 10, + sha256: 'synthetic-digest', + encoding: 'windows-1252', + bom: 'none', + newline: 'lf', + byteChunks: Array.from({ length: 10 }, (_, index) => `synthetic-chunk-${index}`), + diagnostics, + readModel, + } as XerSourceArchive; +} + +function attachArchive(): XerSourceArchive { + reset(); + const archive = archiveFixture(); + S().setProject({ name: 'Synthetic XER project' }); + useAppStore.setState((state) => { + state.xerSourceArchive = archive as any; + state.xerSourceProjectId = 'PROJ-A'; + state.xerImportMetadata = { + sourceProjectId: 'PROJ-A', + defaultCurrencyCode: 'EUR', + tableReport: archive.diagnostics.file.tableReport, + calendarIssues: [], enumFallbacks: [], externalRelations: [], externalLinks: [], + report: archive.diagnostics.file.importReport, + scheduleOptions: { + source: 'schedoptions', retainedSource: { sched_use_project_end_date_for_float: true }, + fallbacks: [{ field: 'synthetic_option', token: 'X', fallback: 'false', line: 41 }], diagnostics: [], + sourceArchive: archive.readModel.scheduleOptionsSourceArchive, sourceRowIndexes: [], sourceRows: [], + }, + } as any; + }); + return archive; +} + +test('registratie: XER-provenance is read-only, gesloten en NIET batchable', () => { + const tool = getTool(TOOL); + assert(!!tool, 'XER-provenance-tool geregistreerd'); + assertEq(tool!.kind, 'read', 'kind read'); + assertEq(tool!.batchable, false, 'niet batchable (P1-fix): read-only-belofte mag een batch niet omzeilen'); + assertEq(tool!.annotations.readOnlyHint, true, 'readOnlyHint'); + assertEq(tool!.annotations.openWorldHint, false, 'openWorldHint'); + assertEq((tool!.inputSchema as any).additionalProperties, false, 'unknown keys gesloten'); +}); + +test('planner_get_task: alle acht P6-velden en relationele finish-boundary behouden aanwezigheid, ook false', () => { + reset(); + const predecessor = S().addTask({ name: 'Synthetic predecessor', isMilestone: false, parentId: null, time: createDefaultTaskTime('2026-06-01', 1) }); + const successor = S().addTask({ name: 'Synthetic successor', isMilestone: false, parentId: null, time: createDefaultTaskTime('2026-06-01', 1) }); + const sequenceId = S().addSequence({ predecessorId: predecessor, successorId: successor, type: 'FINISH_START', lagDays: 0 }); + assert(!!sequenceId, 'synthetische relatie aangemaakt'); + useAppStore.setState((state) => { + const task = state.tasks.find((item) => item.id === successor)!; + task.p6DurationType = 'DT_FixedRate'; + task.p6ActivityType = 'TT_Task'; + task.p6ProjectId = 'PROJ-A'; + task.p6TaskId = 'TASK-B'; + task.p6ExplicitTargetWindow = false; + task.p6CompletePctType = 'CP_Phys'; + task.p6ExpectedFinish = '2026-06-05'; + task.p6SuspendResume = false; + state.sequences.find((item) => item.id === sequenceId)!.p6StartAtPredecessorFinishBoundary = false; + }); + const data = ok('planner_get_task', { taskId: successor }); + assertEq(data.p6DurationType, 'DT_FixedRate', 'p6DurationType'); + assertEq(data.p6ActivityType, 'TT_Task', 'p6ActivityType'); + assertEq(data.p6ProjectId, 'PROJ-A', 'p6ProjectId'); + assertEq(data.p6TaskId, 'TASK-B', 'p6TaskId'); + assertEq(data.p6ExplicitTargetWindow, false, 'false presence target window'); + assertEq(data.p6CompletePctType, 'CP_Phys', 'p6CompletePctType'); + assertEq(data.p6ExpectedFinish, '2026-06-05', 'p6ExpectedFinish'); + assertEq(data.p6SuspendResume, false, 'false presence suspend/resume'); + assertEq(data.predecessors[0].p6StartAtPredecessorFinishBoundary, false, 'false presence relation boundary'); + const ordinary = S().addTask({ name: 'Synthetic ordinary', isMilestone: false, parentId: null, time: createDefaultTaskTime('2026-06-01', 1) }); + const ordinaryData = ok('planner_get_task', { taskId: ordinary }); + for (const field of ['p6DurationType', 'p6ActivityType', 'p6ProjectId', 'p6TaskId', 'p6ExplicitTargetWindow', 'p6CompletePctType', 'p6ExpectedFinish', 'p6SuspendResume']) { + assert(!(field in ordinaryData), `${field} ontbreekt zonder bronaanwezigheid`); + } +}); + +test('P6-velden blijven via update_tasks allemaal read-only met gerichte hints', async () => { + reset(); + const id = S().addTask({ name: 'Synthetic task', isMilestone: false, parentId: null, time: createDefaultTaskTime('2026-06-01', 1) }); + const fields: Record = { + p6DurationType: 'DT_FixedRate', p6ActivityType: 'TT_LOE', p6ProjectId: 'PROJ-A', p6TaskId: 'TASK-A', + p6ExplicitTargetWindow: false, p6CompletePctType: 'CP_Phys', p6ExpectedFinish: '2026-06-05', p6SuspendResume: false, + }; + const before = JSON.stringify(S().tasks); + for (const [field, value] of Object.entries(fields)) { + const result = await callAsync('planner_update_tasks', { updates: [{ id, fields: { [field]: value } }] }); + assert(result.ok, `${field}: update call zelf blijft een zachte weigering`); + const rejection = (result as McpToolOk).itemRejections?.[0]; + assert(!!rejection && rejection.reason.includes(`onbekend veld '${field}'`), `${field}: REJECT_HINTS is gericht`); + assert(/P6|\.xer|importdata/.test(rejection!.reason), `${field}: hint benoemt bronsemantiek`); + } + assertEq(JSON.stringify(S().tasks), before, 'geen P6-mutatie'); +}); + +test('summary: veilig, volledig geteld en zonder raw bytes of vrije raw rows', () => { + const archive = attachArchive(); + const data = ok(TOOL); + assertEq(data.sourcePresent, true, 'bron aanwezig'); + assertEq(data.source.byteLength, archive.byteLength, 'byteLength'); + assertEq(data.source.sha256, archive.sha256, 'digest'); + assertEq(data.source.byteChunkCount, 10, 'chunk count'); + assertEq(data.selector.currentProjectId, 'PROJ-A', 'selector'); + assertEq(data.numberFormat.decimal, ',', 'number format'); + assertEq(data.schedoptions.source, 'schedoptions', 'SCHEDOPTIONS source'); + assertEq(data.importReport.documentsOpened, 1, 'import report'); + assertEq(data.diagnostics.unknownTableCount, 1, 'unknown table count'); + assertEq(data.catalogCounts.resourceCatalog.resources, 1, 'resource count'); + assertEq(data.catalogCounts.metadataCatalog.customFieldDefs, 1, 'UDF-def count'); + const serialized = JSON.stringify(data); + assert(!serialized.includes('synthetic-chunk-0'), 'summary bevat geen raw base64-chunk'); + assert(!serialized.includes('synthetic-free-cell-'), 'summary bevat geen vrije raw task-row'); +}); + +test('P2 #2 (N3): summary loopt door dezelfde poort — currencyCode afgekapt, importReport geen alias, responsgrens', () => { + const archive = attachArchive(); + const data = ok(TOOL); + assertEq(data.numberFormat.currencyCode.length, LABEL_TRUNCATED_LENGTH, 'currencyCode (SAFE_LABEL_KEYS) afgekapt op 200 tekens, óók in summary (N3)'); + assert( + data.importReport !== archive.diagnostics.file.importReport, + 'importReport is een verse, gesaneerde kopie — nooit meer een levende alias naar het bevroren archief (N3)', + ); + assertEq( + data.importReport.externalLinksPreserved, + archive.diagnostics.file.importReport.externalLinksPreserved, + 'importReport-inhoud blijft (numeriek) correct ná sanering', + ); + const byteLength = new TextEncoder().encode(JSON.stringify(data)).length; + assert(byteLength <= 256 * 1024, `summary blijft binnen de responsgrens (N3), kreeg ${byteLength} bytes`); +}); + +test('resourceCatalog: alle collecties zijn afzonderlijk gepagineerd', () => { + attachArchive(); + for (const collection of ['resources', 'identities', 'resourceSources', 'roleSources', 'rates', 'curves', 'assignmentSources', 'issues']) { + const data = ok(TOOL, { section: 'resourceCatalog', collection, limit: 1, offset: 0 }); + assertEq(data.section, 'resourceCatalog', `${collection}: section`); + assertEq(data.collection, collection, `${collection}: collection`); + assertEq(data.total, 1, `${collection}: total`); + assertEq(data.items.length, 1, `${collection}: item`); + } +}); + +test('resourceCatalog: rawRow-cellen alleen achter includeRawRows, met afkapping', () => { + attachArchive(); + const closed = ok(TOOL, { section: 'resourceCatalog', collection: 'resourceSources', limit: 1 }); + assert(!('cells' in closed.items[0].rawRow), 'zonder opt-in geen cellen in rawRow'); + assertEq(closed.items[0].rawRow.fieldCount, 3, 'fieldCount i.p.v. cellen'); + assertEq(closed.items[0].sourceId, 'R-1', 'niet-vrije velden blijven zichtbaar zonder opt-in'); + const serializedClosed = JSON.stringify(closed); + assert(!serializedClosed.includes('synthetic-free-cell-'), 'geen vrije brontekst zonder opt-in'); + + const opened = ok(TOOL, { section: 'resourceCatalog', collection: 'resourceSources', limit: 1, includeRawRows: true }); + assertEq(opened.items[0].rawRow.cells.rsrc_id, 'R-1', 'korte cel ongewijzigd met opt-in'); + assertEq(opened.items[0].rawRow.cells.rsrc_notes.length, LONG_CELL_TRUNCATED_LENGTH, 'grote vrije cel afgekapt, niet volledig'); + assert(opened.items[0].rawRow.cells.rsrc_notes.endsWith('afgekapt op 2000 tekens)'), 'afkapmarker aanwezig'); + + assertEq(err(TOOL, { section: 'resourceCatalog', collection: 'resourceSources', includeRawRows: true, limit: 101 }).code, 'VALIDATION', 'opt-in-paginalimiet 100'); + assertEq(err(TOOL, { section: 'summary', includeRawRows: true }).code, 'VALIDATION', 'includeRawRows alleen bij eigen sections'); +}); + +test('metadataCatalog: activity codes, UDF-defs, projecties en retained brondata leesbaar', () => { + attachArchive(); + for (const collection of ['activityCodeTypes', 'customFieldDefs', 'taskProjections', 'issues', 'ACTVTYPE', 'ACTVCODE', 'TASKACTV', 'UDFTYPE', 'UDFVALUE', 'MEMOTYPE', 'TASKNOTE', 'TASKMEMO', 'TASK_NOTES', 'deferredUdfValues', 'unknownUdfTypes']) { + const data = ok(TOOL, { section: 'metadataCatalog', collection, limit: 1 }); + assertEq(data.collection, collection, `${collection}: collection`); + assertEq(data.total, 1, `${collection}: total`); + assertEq(data.items.length, 1, `${collection}: item`); + } +}); + +test('metadataCatalog: retained brondata (TASKMEMO) alleen achter includeRawRows, met afkapping', () => { + attachArchive(); + const closed = ok(TOOL, { section: 'metadataCatalog', collection: 'TASKMEMO', limit: 1 }); + assert(!('cells' in closed.items[0]), 'zonder opt-in geen cellen'); + assertEq(closed.items[0].fieldCount, 2, 'fieldCount i.p.v. cellen'); + const serializedClosed = JSON.stringify(closed); + assert(!serializedClosed.includes('synthetic-free-cell-'), 'geen vrije notitietekst zonder opt-in'); + + const opened = ok(TOOL, { section: 'metadataCatalog', collection: 'TASKMEMO', limit: 1, includeRawRows: true }); + assertEq(opened.items[0].cells.task_memo.length, LONG_CELL_TRUNCATED_LENGTH, 'grote notitiecel afgekapt met opt-in'); + + // Genormaliseerde (niet-rij) collecties zijn ongevoelig voor includeRawRows: geen `cells` te + // verbergen, dus geen gedragsverschil. + const normalized = ok(TOOL, { section: 'metadataCatalog', collection: 'customFieldDefs', limit: 1, includeRawRows: true }); + assertEq(normalized.items[0].id, 'UDF-1', 'genormaliseerde collectie ongewijzigd door includeRawRows'); +}); + +test('taskSourceRowsByProject: expliciete selector, standaard geen cellen, opt-in afgekapt', () => { + attachArchive(); + const closed = ok(TOOL, { section: 'taskSourceRowsByProject', projectId: 'PROJ-A', limit: 10 }); + assertEq(closed.total, 2, 'total task source rows'); + assert(!('cells' in closed.items[0]), 'zonder includeRawRows geen cellen'); + assertEq(closed.items[0].fieldCount, 3, 'fieldCount i.p.v. cellen (task_id/task_name/task_notes)'); + const serializedClosed = JSON.stringify(closed); + assert(!serializedClosed.includes('synthetic-free-cell-'), 'geen vrije task-row zonder opt-in (P1)'); + + const opened = ok(TOOL, { section: 'taskSourceRowsByProject', projectId: 'PROJ-A', limit: 1, includeRawRows: true }); + assertEq(opened.items.length, 1, 'eerste pagina'); + assertEq(opened.items[0].cells.task_name.length, LONG_CELL_TRUNCATED_LENGTH, 'grote raw cel afgekapt, niet volledig (P1)'); + assert(opened.items[0].cells.task_name.endsWith('afgekapt op 2000 tekens)'), 'afkapmarker aanwezig'); + const second = ok(TOOL, { section: 'taskSourceRowsByProject', projectId: 'PROJ-A', limit: 1, includeRawRows: true, offset: opened.next_offset }); + assertEq(second.items[0].cells.task_id, 'TASK-B', 'volgende pagina zonder overlap'); + + assertEq(err(TOOL, { section: 'taskSourceRowsByProject', projectId: 'PROJ-UNKNOWN' }).code, 'NOT_FOUND', 'unknown project'); + assertEq(err(TOOL, { section: 'taskSourceRowsByProject' }).code, 'VALIDATION', 'project-id verplicht'); + assertEq(err(TOOL, { section: 'taskSourceRowsByProject', projectId: 'PROJ-A', includeRawRows: true, limit: 101 }).code, 'VALIDATION', 'opt-in-paginalimiet 100'); +}); + +test("taskSourceRowsByProject: responsgrens vangt grote pagina's ondanks per-cel-afkapping", () => { + attachArchive(); + const tooLarge = err(TOOL, { section: 'taskSourceRowsByProject', projectId: 'PROJ-BIG', includeRawRows: true, limit: 100 }); + assertEq(tooLarge.code, 'VALIDATION', 'responsgrens (P1): 100 rijen × 3 afgekapte cellen > 256 kB'); + assert(/responsgrens|limit/.test(tooLarge.error), 'foutmelding hint naar limit/offset'); + const smaller = ok(TOOL, { section: 'taskSourceRowsByProject', projectId: 'PROJ-BIG', includeRawRows: true, limit: 10 }); + assertEq(smaller.items.length, 10, 'kleinere pagina blijft onder de responsgrens'); +}); + +test('diagnostics: archive-, unknown-table-, import-report- en documentviewinformatie', () => { + attachArchive(); + for (const collection of ['tableIssues', 'unknownTables', 'unknownFields', 'scheduleOptions', 'relationResolutionIssues', 'resourceCatalogIssues', 'metadataCatalogIssues']) { + const data = ok(TOOL, { section: 'diagnostics', collection, limit: 1 }); + assertEq(data.total, 1, `${collection}: total`); + assertEq(data.items.length, 1, `${collection}: item`); + } + // Twee documentViews in de fixture (PROJ-A + PROJ-BIG, de laatste alleen voor de + // responsgrens-test hierboven) — total telt dus mee, de pagina zelf blijft limit-gestuurd. + const documentViews = ok(TOOL, { section: 'diagnostics', collection: 'documentViews', limit: 1 }); + assertEq(documentViews.total, 2, 'documentViews: total'); + assertEq(documentViews.items.length, 1, 'documentViews: item'); + const report = ok(TOOL, { section: 'diagnostics', collection: 'importReport' }); + assertEq(report.report.externalLinksPreserved, 2, 'importReport diagnostics'); +}); + +test('rawSource: alleen opt-in, hard begrensd en paginaerbaar over grote payload', () => { + attachArchive(); + const denied = err(TOOL, { section: 'rawSource' }); + assertEq(denied.code, 'VALIDATION', 'opt-in verplicht'); + assert(/vrije notities|bronbytes/.test(denied.error), 'privacyhint'); + const first = ok(TOOL, { section: 'rawSource', includeRawSource: true, limit: 8 }); + assertEq(first.chunks.length, 8, 'harde eerste pagina'); + assertEq(first.totalChunks, 10, 'alle chunks geteld'); + assertEq(first.has_more, true, 'volgende chunkpagina'); + const second = ok(TOOL, { section: 'rawSource', includeRawSource: true, limit: 8, offset: first.next_offset }); + assertEq(second.chunks.length, 2, 'laatste pagina'); + assertEq(second.chunks[0].index, 8, 'chunk-index'); + assertEq(err(TOOL, { section: 'rawSource', includeRawSource: true, limit: 9 }).code, 'VALIDATION', 'geen onbeperkte base64'); + assertEq(err(TOOL, { section: 'rawSource', includeRawSource: false }).code, 'VALIDATION', 'false is geen opt-in'); +}); + +test('invalid args worden runtime geweigerd zonder storemutatie', () => { + attachArchive(); + const before = JSON.stringify({ tasks: S().tasks, sequences: S().sequences, archive: S().xerSourceArchive }); + assertEq(err(TOOL, { unexpected: true }).code, 'VALIDATION', 'unknown top-level key'); + assertEq(err(TOOL, { section: 'not-a-section' }).code, 'VALIDATION', 'unknown section'); + assertEq(err(TOOL, { section: 'resourceCatalog' }).code, 'VALIDATION', 'collection verplicht'); + assertEq(err(TOOL, { section: 'summary', projectId: 7 }).code, 'VALIDATION', 'project-id type'); + assertEq(err(TOOL, { section: 'metadataCatalog', collection: 'customFieldDefs', includeRawSource: true }).code, 'VALIDATION', 'raw opt-in alleen eigen section'); + assertEq(err(TOOL, { section: 'diagnostics', collection: 'importReport', limit: 1 }).code, 'VALIDATION', 'geen paging op scalar report'); + assertEq(err(TOOL, { section: 'taskSourceRowsByProject', projectId: 'PROJ-A', limit: 0 }).code, 'VALIDATION', 'limit'); + assertEq(err(TOOL, { section: 'taskSourceRowsByProject', projectId: 'PROJ-A', offset: -1 }).code, 'VALIDATION', 'offset'); + assertEq(err(TOOL, { section: 'taskSourceRowsByProject', projectId: 'PROJ-A', includeRawRows: 'yes' as any }).code, 'VALIDATION', 'includeRawRows type'); + // `diagnostics` zit sinds review2-3d.md #1 óók in RAW_ROWS_SECTIONS (documentViews draagt dezelfde + // vrije cellen als de andere drie) — `rawSource` heeft juist zijn EIGEN opt-in (`includeRawSource`) + // en blijft dus wél buiten deze lijst. + assertEq(err(TOOL, { section: 'rawSource', includeRawSource: true, includeRawRows: true }).code, 'VALIDATION', 'includeRawRows hoort niet bij rawSource (dat heeft includeRawSource)'); + assertEq(JSON.stringify({ tasks: S().tasks, sequences: S().sequences, archive: S().xerSourceArchive }), before, 'geen storemutatie door leespad'); +}); + +test('P1: planner_batch weigert deze tool vóór enige mutatie, transactie of herberekening', async () => { + attachArchive(); + // Expliciete, herkenbare uitgangstoestand — als checkExclusions vóór de transactie weigert, blijft + // dit exact zo; verandert er iets, dan liep de aanroep alsnog door de mutatie-executor. + useAppStore.setState((state) => { + state.isDirty = false; + state.scheduleStale = true; + state.cpmResult = null; + }); + const before = { isDirty: S().isDirty, scheduleStale: S().scheduleStale, cpmResult: S().cpmResult }; + + const validArgsBatch = await callAsync('planner_batch', { steps: [{ tool: TOOL, args: { section: 'summary' } }] }); + assertEq(validArgsBatch.ok, false, 'geldige args helpen niet: de tool zelf is uitgesloten'); + assertEq((validArgsBatch as McpToolErr).code, 'VALIDATION', 'batchable:false ⇒ VALIDATION vóór de transactie'); + assert(/niet batchable|uitgesloten/.test((validArgsBatch as McpToolErr).error), 'foutmelding noemt de uitsluiting'); + + const invalidArgsBatch = await callAsync('planner_batch', { steps: [{ tool: TOOL, args: { unexpected: true } }] }); + assertEq(invalidArgsBatch.ok, false, 'ook met ongeldige args blijft de weigering VALIDATION'); + assertEq((invalidArgsBatch as McpToolErr).code, 'VALIDATION', 'ongeldige args veranderen de foutcode niet'); + + assertEq(S().isDirty, before.isDirty, 'isDirty ongewijzigd: checkExclusions weigert vóór runInMcpTransaction'); + assertEq(S().scheduleStale, before.scheduleStale, 'scheduleStale ongewijzigd: geen eindherberekening gedraaid'); + assertEq(S().cpmResult, before.cpmResult, 'cpmResult ongewijzigd: geen runCPM gedraaid'); +}); + +test('no-XER document: veilige summary meldt afwezigheid, inhoudsectie geeft NOT_FOUND', () => { + reset(); + const data = ok(TOOL); + assertEq(data.sourcePresent, false, 'geen XER-bron'); + assertEq(err(TOOL, { section: 'resourceCatalog', collection: 'resources' }).code, 'NOT_FOUND', 'catalogus zonder bron'); +}); + +test('P3 (R9): sourcePresent:false loopt ook door de poort — currentProjectId afgekapt', () => { + reset(); + // `xerSourceProjectId` is GEEN statische tekst — het is een documentveld dat uit het bestand komt + // (review2-3d.md ronde 4, R9). Vóór de fix retourneerde deze tak vóór sanitize/finalizeBounded. + const hugeId = 'X'.repeat(9009); + useAppStore.setState((state) => { state.xerSourceProjectId = hugeId; }); + const data = ok(TOOL); + assertEq(data.sourcePresent, false, 'geen XER-bron'); + assertEq(data.selector.currentProjectId.length, LABEL_TRUNCATED_LENGTH, 'currentProjectId afgekapt op 200 tekens, ook zonder archief (R9)'); + assertEq( + data.note, + 'Er is voor dit document geen retained XER-bronarchief beschikbaar.', + 'de statische systeemmelding blijft intact — die gaat bewust NIET door de vrije-tekstpoort (R9)', + ); +}); + +test('P2 (R8): sleutels in de generieke objecttak worden ook afgekapt en begroot', () => { + attachArchive(); + const data = ok(TOOL); + const catalogKeys = Object.keys(data.catalogCounts.taskSourceRowsByProject); + const longKey = catalogKeys.find((k: string) => k.startsWith('P'.repeat(50))); + assert(!!longKey, 'testopzet: de 80.000-tekens-project-id staat als sleutel in catalogCounts'); + assert( + longKey!.length <= LABEL_TRUNCATED_LENGTH, + `sleutel blijft afgekapt (kreeg ${longKey!.length} tekens), niet verbatim 80.000 (R8)`, + ); + const availableMatch = data.selector.availableProjectIds.find((id: string) => id.startsWith('P'.repeat(50))); + assert(!!availableMatch, 'testopzet: dezelfde project-id staat ook in availableProjectIds'); + assertEq( + availableMatch.length, + LABEL_TRUNCATED_LENGTH, + 'availableProjectIds (waarde-pad) was al afgekapt — nu consistent met de sleutel (R8)', + ); +}); + +// ================================================================================================= +// Reviewronde 2 (review2-3d.md) — de EIGENSCHAP, niet drie losse paden. +// ================================================================================================= + +test('P1 #2: vrije tekst buiten rawRow verdwijnt zonder opt-in; labels blijven zichtbaar maar afgekapt', () => { + attachArchive(); + + // Vrije tekst (notes/description/customFields) — de SLEUTEL verdwijnt helemaal zonder opt-in, + // niet slechts "afgekapt-maar-nog-zichtbaar". + const roleClosed = ok(TOOL, { section: 'resourceCatalog', collection: 'roleSources', limit: 1 }); + assert(!('description' in roleClosed.items[0]), 'roleSources.description verdwijnt zonder opt-in'); + assert(!JSON.stringify(roleClosed).includes(FREE_TEXT_SECRET.slice(0, 50)), 'geen spoor van de vrije tekst zonder opt-in'); + // N2: geen van de zes alias-sleutels staat op een blocklist — alleen deny-by-default verbergt ze. + for (const key of ALIAS_KEYS) { + assert(!(key in roleClosed.items[0]), `roleSources.${key} verdwijnt zonder opt-in (alias-sleutel, N2)`); + } + assert(!JSON.stringify(roleClosed).includes(ALIAS_SECRET.slice(0, 30)), 'geen spoor van alias-sleutelinhoud zonder opt-in (N2)'); + + const resourcesClosed = ok(TOOL, { section: 'resourceCatalog', collection: 'resources', limit: 1 }); + assert(!('description' in resourcesClosed.items[0]), 'resources.description verdwijnt zonder opt-in'); + assert(!JSON.stringify(resourcesClosed).includes(FREE_TEXT_SECRET.slice(0, 50)), 'geen spoor van de vrije tekst zonder opt-in'); + + const projectionsClosed = ok(TOOL, { section: 'metadataCatalog', collection: 'taskProjections', limit: 1 }); + // `notes` is een objectarray (`{id,text,done}[]`, review2-3d.md N1): de array zelf blijft staan + // (met het veilige `id`/`done`), maar het BLAD `text` — de eigenlijke notitietekst — verdwijnt. + assertEq(projectionsClosed.items[0].notes[0].id, 'note-1', 'notes[].id (label) blijft zichtbaar'); + assert(!('text' in projectionsClosed.items[0].notes[0]), 'notes[].text verdwijnt zonder opt-in (N1)'); + assertEq(projectionsClosed.items[0].notes[0].done, false, 'notes[].done (boolean) blijft ongemoeid'); + assertEq(projectionsClosed.items[0].customFields.fieldCount, 1, 'customFields wordt een fieldCount-projectie, geen waarden'); + assert(!JSON.stringify(projectionsClosed).includes(FREE_TEXT_SECRET.slice(0, 50)), 'geen spoor van customFields-waarden zonder opt-in'); + + // Met opt-in: de vrije tekst komt terug, maar afgekapt op 2.000 tekens — nooit de volle 9.000. + const roleOpened = ok(TOOL, { section: 'resourceCatalog', collection: 'roleSources', limit: 1, includeRawRows: true }); + assertEq(roleOpened.items[0].description.length, LONG_CELL_TRUNCATED_LENGTH, 'description afgekapt op 2.000 tekens, niet de volle 9.000'); + assert(!JSON.stringify(roleOpened).includes(FREE_TEXT_SECRET), 'ook mét opt-in blijft de VOLLE 9.000-tekens-secret buiten bereik'); + for (const key of ALIAS_KEYS) { + assertEq(roleOpened.items[0][key].length, LONG_CELL_TRUNCATED_LENGTH, `roleSources.${key} afgekapt op 2.000 tekens met opt-in (N2)`); + } + assert(!JSON.stringify(roleOpened).includes(ALIAS_SECRET), 'ook mét opt-in blijft de volle alias-sleutelinhoud buiten bereik (N2)'); + + const projectionsOpened = ok(TOOL, { section: 'metadataCatalog', collection: 'taskProjections', limit: 1, includeRawRows: true }); + assertEq(projectionsOpened.items[0].notes[0].text.length, LONG_CELL_TRUNCATED_LENGTH, 'notes[].text afgekapt op 2.000 tekens met opt-in (N1)'); + assertEq(projectionsOpened.items[0].customFields['UDF-1'].length, LONG_CELL_TRUNCATED_LENGTH, 'customFields-waarde afgekapt op 2.000 tekens met opt-in'); + + // Labels (namen buiten rawRow) blijven ALTIJD zichtbaar, met of zonder opt-in — maar hard afgekapt + // op 200 tekens: "genormaliseerd" mag niet langer "onbegrensd" betekenen. + const curvesClosed = ok(TOOL, { section: 'resourceCatalog', collection: 'curves', limit: 1 }); + assertEq(curvesClosed.items[0].name.length, LABEL_TRUNCATED_LENGTH, 'curves.name afgekapt op 200 tekens, altijd zichtbaar'); + assert(curvesClosed.items[0].name.startsWith('LABEL-SECRET-'), 'een label-prefix mag zichtbaar zijn (bewuste keuze, geen notitie)'); + + const defsClosed = ok(TOOL, { section: 'metadataCatalog', collection: 'customFieldDefs', limit: 1 }); + assertEq(defsClosed.items[0].name.length, LABEL_TRUNCATED_LENGTH, 'customFieldDefs.name afgekapt op 200 tekens, altijd zichtbaar'); +}); + +/** Loopt recursief door een respons en verzamelt elke stringwaarde. */ +function collectStrings(value: unknown, out: string[]): void { + if (typeof value === 'string') { out.push(value); return; } + if (Array.isArray(value)) { value.forEach((item) => collectStrings(item, out)); return; } + if (value && typeof value === 'object') { Object.values(value as Record).forEach((item) => collectStrings(item, out)); } +} + +test('P1 #2: generieke walker — zonder opt-in geen string > 2.000 tekens en geen notitie-inhoud, over alle secties', () => { + attachArchive(); + const calls: Array<[string, Record]> = [ + ['summary', {}], + ['resourceCatalog/resources', { section: 'resourceCatalog', collection: 'resources', limit: 10 }], + ['resourceCatalog/roleSources', { section: 'resourceCatalog', collection: 'roleSources', limit: 10 }], + ['resourceCatalog/curves', { section: 'resourceCatalog', collection: 'curves', limit: 10 }], + ['resourceCatalog/resourceSources', { section: 'resourceCatalog', collection: 'resourceSources', limit: 10 }], + ['metadataCatalog/customFieldDefs', { section: 'metadataCatalog', collection: 'customFieldDefs', limit: 10 }], + ['metadataCatalog/taskProjections', { section: 'metadataCatalog', collection: 'taskProjections', limit: 10 }], + ['metadataCatalog/TASKMEMO', { section: 'metadataCatalog', collection: 'TASKMEMO', limit: 10 }], + ['taskSourceRowsByProject/PROJ-A', { section: 'taskSourceRowsByProject', projectId: 'PROJ-A', limit: 10 }], + ['diagnostics/documentViews', { section: 'diagnostics', collection: 'documentViews', limit: 10 }], + ['diagnostics/unknownFields', { section: 'diagnostics', collection: 'unknownFields', limit: 10 }], + ['diagnostics/importReport', { section: 'diagnostics', collection: 'importReport' }], + ]; + for (const [label, args] of calls) { + const data = ok(TOOL, args); + const strings: string[] = []; + collectStrings(data, strings); + assert(strings.length > 0, `${label}: testopzet — de respons moet minstens één string bevatten`); + for (const value of strings) { + assert(value.length <= 2000, `${label}: string van ${value.length} tekens overschrijdt de 2.000-tekens-grens zonder opt-in ("${value.slice(0, 40)}…")`); + assert(!value.includes(FREE_TEXT_SECRET.slice(0, 50)), `${label}: bevat notitie-inhoud zonder includeRawRows`); + // N2: dekt ook de zes alias-sleutels (roleSources) en, via taskProjections, geneste `notes[].text`. + assert(!value.includes(ALIAS_SECRET.slice(0, 30)), `${label}: bevat alias-sleutelinhoud zonder includeRawRows`); + } + } +}); + +/** Reviewbevinding P1 #1 (review2-3d.md): de vorige documentViews-fixture was een handgeschreven + * stub (`{sourceProjectId, synthetic:true}`) die de productievorm niet had — de test was daardoor + * structureel blind voor het lek. Deze fixture gaat wél door de ECHTE lezer (`readXER`, via + * `reconstructXerSourceFromBytes(...).archive`), corpusloos, met een bewust gigantische vrije cel in een + * TASKRSRC-kolom, zodat `documentViews[x].resources.assignments[].rawRow.cells` 'm ECHT draagt. */ +const REAL_ARCHIVE_SECRET = 'REAL-SECRET-' + 'w'.repeat(50000); + +function buildRealArchiveFixture(): XerSourceArchive { + const source = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-08-01\t\t\t\t\t\tEUR', + '%T\tCURRTYPE', + '%F\tcurr_short_name\tdecimal_symbol\tdigit_group_symbol', + '%R\tEUR\tcomma\tperiod', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date', + '%R\tP-REAL\tReal fixture project\tC\t2026-08-01 08:00', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC\tStandaard\t8\t40\t', + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_code\ttask_name\tclndr_id\ttarget_start_date\ttarget_end_date\ttarget_drtn_hr_cnt\ttask_type\tduration_type\tstatus_code', + '%R\tT-REAL\tP-REAL\tA-1\tEchte taak\tC\t2026-08-01 08:00\t2026-08-01 16:00\t8\tTT_Task\tDT_FixedDUR2\tTK_NotStart', + '%T\tRSRC', + '%F\trsrc_id\trsrc_name\trsrc_type\tclndr_id\tdef_qty_per_hr', + '%R\tR-REAL\tEchte resource\tRT_Labor\tC\t1', + '%T\tTASKRSRC', + '%F\ttaskrsrc_id\tproj_id\ttask_id\trsrc_id\ttarget_qty_per_hr\tremain_qty_per_hr\tremain_qty\ttarget_qty\tfree_secret_field', + `%R\tAS-REAL\tP-REAL\tT-REAL\tR-REAL\t0,5\t0,5\t4\t4\t${REAL_ARCHIVE_SECRET}`, + '%E', + ].join('\r\n')); + return reconstructXerSourceFromBytes(source).archive; +} + +/** Reviewbevinding #3/#6 (review2-3d.md): een rij met véél cellen (de `%F`-kolomkop van het + * bronbestand bepaalt dat aantal, dus een aanvaller-gecontroleerd bestand kan het opdrijven) mag + * geen tientallen-MB-tussenstring opbouwen vóór de responsgrens ingrijpt. 250 extra kolommen op + * ÉÉN TASKRSRC-rij, elk met een ruime vrije waarde. */ +function buildManyCellsArchiveFixture(): XerSourceArchive { + const extraColumnCount = 250; + const extraFieldNames = Array.from({ length: extraColumnCount }, (_, index) => `extra_col_${index}`); + const extraFieldValues = Array.from({ length: extraColumnCount }, (_, index) => `val${index}-${'q'.repeat(2500)}`); + const source = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-08-01\t\t\t\t\t\tEUR', + '%T\tCURRTYPE', + '%F\tcurr_short_name\tdecimal_symbol\tdigit_group_symbol', + '%R\tEUR\tcomma\tperiod', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date', + '%R\tP-MANYCELLS\tMany-cells fixture project\tC\t2026-08-01 08:00', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC\tStandaard\t8\t40\t', + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_code\ttask_name\tclndr_id\ttarget_start_date\ttarget_end_date\ttarget_drtn_hr_cnt\ttask_type\tduration_type\tstatus_code', + '%R\tT-MANYCELLS\tP-MANYCELLS\tA-1\tGrote taak\tC\t2026-08-01 08:00\t2026-08-01 16:00\t8\tTT_Task\tDT_FixedDUR2\tTK_NotStart', + '%T\tRSRC', + '%F\trsrc_id\trsrc_name\trsrc_type\tclndr_id\tdef_qty_per_hr', + '%R\tR-MANYCELLS\tGrote resource\tRT_Labor\tC\t1', + '%T\tTASKRSRC', + ['%F', 'taskrsrc_id', 'proj_id', 'task_id', 'rsrc_id', 'target_qty_per_hr', 'remain_qty_per_hr', 'remain_qty', 'target_qty', ...extraFieldNames].join('\t'), + ['%R', 'AS-MANYCELLS', 'P-MANYCELLS', 'T-MANYCELLS', 'R-MANYCELLS', '0,5', '0,5', '4', '4', ...extraFieldValues].join('\t'), + '%E', + ].join('\r\n')); + return reconstructXerSourceFromBytes(source).archive; +} + +function attachRealArchive(archive: XerSourceArchive, projectId: string): void { + reset(); + S().setProject({ name: 'Real XER fixture project' }); + useAppStore.setState((state) => { + state.xerSourceArchive = archive as any; + state.xerSourceProjectId = projectId; + }); +} + +test('P1 #1: diagnostics/documentViews gated net als de andere secties (echte lezer, geen stub)', () => { + const archive = buildRealArchiveFixture(); + const view = archive.diagnostics.documentViews['P-REAL'] as any; + assert(!!view?.resources?.assignments?.[0]?.rawRow, 'testopzet: de echte lezer levert een assignment-rawRow op'); + assert(view.resources.assignments[0].rawRow.cells.free_secret_field.includes('REAL-SECRET-'), 'testopzet: de secretkolom staat echt in de rawRow'); + attachRealArchive(archive, 'P-REAL'); + + const closed = ok(TOOL, { section: 'diagnostics', collection: 'documentViews', limit: 1 }); + const closedStr = JSON.stringify(closed); + assert(!closedStr.includes('REAL-SECRET-'), 'zonder opt-in mag de vrije TASKRSRC-cel niet verschijnen (P1 #1)'); + assert(closedStr.length < 5000, 'zonder opt-in blijft de documentview-pagina klein (geen 50.000-tekens-lek)'); + + const opened = ok(TOOL, { section: 'diagnostics', collection: 'documentViews', limit: 1, includeRawRows: true }); + const openedStr = JSON.stringify(opened); + assert(!openedStr.includes(REAL_ARCHIVE_SECRET), 'zelfs met opt-in blijft de cel afgekapt, nooit de volle 50.000 tekens'); + const cell = opened.items[0].view.resources.assignments[0].rawRow.cells.free_secret_field; + assertEq(cell.length, LONG_CELL_TRUNCATED_LENGTH, 'afgekapt op exact dezelfde grens als de andere secties'); + assert(cell.startsWith('REAL-SECRET-'), 'het zichtbare prefix bevestigt dat dit dezelfde cel is'); +}); + +test('P3 #6: cap op cellen per rij + budget-tijdens-projectie voorkomt een megabytes-tussenstring', () => { + const archive = buildManyCellsArchiveFixture(); + const view = archive.diagnostics.documentViews['P-MANYCELLS'] as any; + assertEq(Object.keys(view.resources.assignments[0].rawRow.cells).length, 258, 'testopzet: 250 extra + 8 kernvelden'); + attachRealArchive(archive, 'P-MANYCELLS'); + + // 258 cellen × ~2.026 afgekapte tekens zou zonder cap/budget ruim boven de 256 kB-responsgrens + // uitkomen — de fix moet dit AL TIJDENS de projectie afvangen (P3 #6), niet pas na een dure + // volledige serialisatie. + const tooMany = err(TOOL, { section: 'diagnostics', collection: 'documentViews', limit: 1, includeRawRows: true }); + assertEq(tooMany.code, 'VALIDATION', 'responsgrens grijpt in vóór de respons de deur uit gaat'); + assert(/responsgrens|limit/.test(tooMany.error), 'foutmelding hint naar limit/offset'); + + // Zonder opt-in blijft de pagina klein — de cap/budget speelt alleen mee zodra celinhoud + // daadwerkelijk wordt uitgelezen. (De 250 onbekende TASKRSRC-kolommen leveren wél 250 kleine + // `unknownFields`-diagnostiekregels op — legitiem en ver onder de responsgrens, vandaar de + // ruimere marge dan bij de andere "zonder opt-in blijft klein"-asserties in dit bestand.) + const closed = ok(TOOL, { section: 'diagnostics', collection: 'documentViews', limit: 1 }); + const closedLength = JSON.stringify(closed).length; + assert(closedLength < 50000, `zonder opt-in blijft de pagina klein ongeacht het celaantal, kreeg ${closedLength}`); +}); + +test('P2 #4: de responsgrens meet ECHTE UTF-8-bytes, niet UTF-16-code-units (CJK)', () => { + attachArchive(); + // 50 rijen × 2.000 CJK-tekens = 100.000 code-units (ver onder de oude "String.length"-grens) maar + // ≈ 300.000 ECHTE bytes (boven de 262.144-bytesgrens). Vóór de fix zou dit zijn DOORGELATEN. + const tooManyBytes = err(TOOL, { section: 'taskSourceRowsByProject', projectId: 'PROJ-CJK', includeRawRows: true, limit: 50 }); + assertEq(tooManyBytes.code, 'VALIDATION', 'CJK-pagina met te veel ECHTE bytes wordt geweigerd'); + + // Een kleinere pagina (10 rijen × 2.000 CJK-tekens ≈ 60.000 bytes) blijft ruim onder de grens. + const smaller = ok(TOOL, { section: 'taskSourceRowsByProject', projectId: 'PROJ-CJK', includeRawRows: true, limit: 10 }); + assertEq(smaller.items.length, 10, 'kleinere CJK-pagina blijft toegestaan'); +}); + +test('P2 #5: één selectorbron — een project alleen in taskSourceRowsByProject is toch bereikbaar', () => { + attachArchive(); + const summaryData = ok(TOOL); + assert( + summaryData.selector.availableProjectIds.includes('PROJ-ONLY-IN-TASKROWS'), + 'summary adverteert het project (unie van documentViews én taskSourceRowsByProject)', + ); + const rows = ok(TOOL, { section: 'taskSourceRowsByProject', projectId: 'PROJ-ONLY-IN-TASKROWS', limit: 10 }); + assertEq(rows.total, 1, 'de tool accepteert exact het project dat de summary adverteert — geen zelftegenspraak meer'); +}); + +test('P2 #5 (N5): rijdetectie op vorm (line + string-cells), niet op exact 2 sleutels', () => { + attachArchive(); + const closed = ok(TOOL, { section: 'taskSourceRowsByProject', projectId: 'PROJ-SHAPE', limit: 1 }); + // Vóór de fix viel een 3-sleutelrij (zoals XerScheduleOptionsSourceRow: {table,line,cells}) door + // naar de generieke objecttak: `cells` werd dan een gewoon record en elke celwaarde kwam als LABEL + // naar buiten — afgekapt, maar wél ZICHTBAAR zonder opt-in. Fail-open. Nu: fieldCount, geen cellen. + assert(!('cells' in closed.items[0]), 'cellen blijven volledig weg zonder opt-in, ook bij een 3-sleutelrij (N5)'); + assertEq(closed.items[0].fieldCount, 1, 'fieldCount i.p.v. cellen (N5)'); + assertEq(closed.items[0].table, 'PROJECT', 'overige velden (table) blijven gewoon zichtbaar als label (N5)'); + assert(!JSON.stringify(closed).includes(N5_SECRET.slice(0, 50)), 'geen spoor van de bronrij-inhoud zonder opt-in (N5)'); + + const opened = ok(TOOL, { section: 'taskSourceRowsByProject', projectId: 'PROJ-SHAPE', limit: 1, includeRawRows: true }); + assertEq(opened.items[0].cells.secret_col.length, LONG_CELL_TRUNCATED_LENGTH, 'met opt-in: cel afgekapt op 2.000 tekens, niet leesbaar als los label (N5)'); +}); + +test('P2 #4 (N4): cel-/veldNAMEN worden afgekapt én meegeteld in het budget', () => { + attachArchive(); + const opened = ok(TOOL, { section: 'taskSourceRowsByProject', projectId: 'PROJ-LONGKEY', includeRawRows: true, limit: 1 }); + const keys = Object.keys(opened.items[0].cells); + assertEq(keys.length, 1, 'testopzet: precies één cel'); + assert(keys[0].length <= LABEL_TRUNCATED_LENGTH, `kolomnaam blijft afgekapt (kreeg ${keys[0].length} tekens), niet verbatim 60.001 (N4)`); + + // 10 rijen × 200 cellen met LANGE kolomnamen maar TRIVIALE waarden: zonder key-begroting zou de + // teller hier bijna niets zien. De weigering hoort van de budget-tijdens-projectie te komen (dus + // GEEN "geserialiseerd" in de melding — spiegelt de M4-pinningtest hieronder). + const tooManyLongKeys = err(TOOL, { section: 'taskSourceRowsByProject', projectId: 'PROJ-LONGKEY-BUDGET', includeRawRows: true, limit: 10 }); + assertEq(tooManyLongKeys.code, 'VALIDATION', 'lange kolomnamen alléén (triviale waarden) raken de responsgrens (N4)'); + assert(!tooManyLongKeys.error.includes('geserialiseerd'), 'de weigering komt van de budget-tijdens-projectie (kolomnamen tellen mee), niet pas van finalizeBounded (N4)'); +}); + +test('P3 (M4): het ByteBudget-effect is gepind — de weigering komt van vóór, niet ná, de serialisatie', () => { + attachArchive(); + // `PROJ-BIG` (100 rijen × 3 cellen van 3.000 tekens) is groot genoeg om de budget-teller AL TIJDENS + // de projectie te laten afbreken, ruim vóór de dure volledige `JSON.stringify` in `finalizeBounded`. + // De twee foutmeldingen verschillen bewust in tekst (`createByteBudget` mist "geserialiseerd", + // `finalizeBounded` heeft het wél) — dat verschil is hier de gepinde eigenschap. Mutatiebewijs: + // schakel de `ByteBudget`-drempel uit en dezelfde aanroep krijgt de finalizeBounded-tekst (`.includes` + // wordt `true`), dus déze assertie kantelt — precies het gat dat de review aanwees (M4: 0 tests rood + // toen de budgetdrempel alleen werd uitgeschakeld). + const tooLarge = err(TOOL, { section: 'taskSourceRowsByProject', projectId: 'PROJ-BIG', includeRawRows: true, limit: 100 }); + assertEq(tooLarge.code, 'VALIDATION', 'responsgrens grijpt in'); + assert(!tooLarge.error.includes('geserialiseerd'), 'de weigering komt van de budget-tijdens-projectie, vóór volledige serialisatie (M4)'); +}); + +test('P3 #8: loopt via de ECHTE dispatch-weg (handleMcpMessage), niet alleen def.handler', async () => { + attachArchive(); + const dispatchCtx = makeMcpContext(undefined, { expectedDocId: S().activeDocumentId }); + const rpcCall = async (args: unknown) => { + const raw = await handleMcpMessage( + JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: TOOL, arguments: args } }), + dispatchCtx, + ); + return JSON.parse(raw); + }; + + const badLimitType = await rpcCall({ limit: '5' }); + assert(badLimitType.result?.isError === true, 'de schemapoort in de dispatcher weigert limit als string'); + assertEq(badLimitType.result.structuredContent.code, 'VALIDATION', 'VALIDATION via de dispatcher (limit)'); + + const badIncludeRawRowsType = await rpcCall({ includeRawRows: 'yes' }); + assert(badIncludeRawRowsType.result?.isError === true, 'de schemapoort weigert includeRawRows als string'); + assertEq(badIncludeRawRowsType.result.structuredContent.code, 'VALIDATION', 'VALIDATION via de dispatcher (includeRawRows)'); + + const validCall = await rpcCall({ section: 'summary' }); + assertEq(validCall.result?.isError, false, 'een geldige summary-call passeert de dispatcher ongehinderd'); +}); + +await run(); diff --git a/tests/planning/cases-p6-verified.json b/tests/planning/cases-p6-verified.json new file mode 100644 index 000000000..7cdc5957f --- /dev/null +++ b/tests/planning/cases-p6-verified.json @@ -0,0 +1,332 @@ +{ + "provenance": { + "repository": "https://github.com/danafitkowski/cpp-cpm-engine.git", + "sourceCommit": "c279a5c4ff204ba763a6f9726aa6383574b50475", + "capture": "Menselijke P6 23.12-capture van 2026-08-11; het primaire capturesheet is niet meegecommit.", + "scope": "Uitsluitend activity_code en de onvertaalde *_p6-kolommen zijn overgenomen; kloktijden en actual-suffixen blijven staan.", + "warning": "De *_engine-kolommen en PASS-oordelen zijn geen meetlat: daarbij is tijd weggegooid, het exclusief/inclusief-finishverschil genormaliseerd en de bronengine na deze capture nagefit." + }, + "cases": [ + { + "id": "01-fs-chain", + "sourceSha256": "9d06cd88cfdce11a33d853ca5410088d9ab0a1a82aa384237147cd9a08ac70fb", + "activities": [ + { + "activity_code": "A", + "ES_p6": "2026-01-05 08:00:00", + "EF_p6": "2026-01-09 17:00:00", + "LS_p6": "2026-01-05 08:00:00", + "LF_p6": "2026-01-09 17:00:00", + "TF_p6": "0", + "FF_p6": "0" + }, + { + "activity_code": "B", + "ES_p6": "2026-01-12 08:00:00", + "EF_p6": "2026-01-14 17:00:00", + "LS_p6": "2026-01-12 08:00:00", + "LF_p6": "2026-01-14 17:00:00", + "TF_p6": "0", + "FF_p6": "0" + }, + { + "activity_code": "C", + "ES_p6": "2026-01-15 08:00:00", + "EF_p6": "2026-01-16 17:00:00", + "LS_p6": "2026-01-15 08:00:00", + "LF_p6": "2026-01-16 17:00:00", + "TF_p6": "0", + "FF_p6": "0" + } + ] + }, + { + "id": "02-ss-with-lag", + "sourceSha256": "7719cb13f2bfd5297dc5302f9f2ab672376d7b6e7cafcbfa5c52cbbf64c45cae", + "activities": [ + { + "activity_code": "A", + "ES_p6": "2026-01-05 08:00:00", + "EF_p6": "2026-01-16 17:00:00", + "LS_p6": "2026-01-05 08:00:00", + "LF_p6": "2026-01-16 17:00:00", + "TF_p6": "0", + "FF_p6": "0" + }, + { + "activity_code": "B", + "ES_p6": "2026-01-12 08:00:00", + "EF_p6": "2026-01-15 17:00:00", + "LS_p6": "2026-01-13 08:00:00", + "LF_p6": "2026-01-16 17:00:00", + "TF_p6": "1", + "FF_p6": "1" + } + ] + }, + { + "id": "03-ff-with-lag", + "sourceSha256": "bb8f76a2bdb926bdd0e7177f28c94da117cd8c55b110882f84ae958ed028d2da", + "activities": [ + { + "activity_code": "A", + "ES_p6": "2026-01-05 08:00:00", + "EF_p6": "2026-01-09 17:00:00", + "LS_p6": "2026-01-05 08:00:00", + "LF_p6": "2026-01-09 17:00:00", + "TF_p6": "0", + "FF_p6": "0" + }, + { + "activity_code": "B", + "ES_p6": "2026-01-09 08:00:00", + "EF_p6": "2026-01-14 17:00:00", + "LS_p6": "2026-01-09 08:00:00", + "LF_p6": "2026-01-14 17:00:00", + "TF_p6": "0", + "FF_p6": "0" + } + ] + }, + { + "id": "04-sf-edge-case", + "sourceSha256": "be04f8ccd8430726635b87654b2039d64fb70779f12562f260bdf3e72d3eb654", + "activities": [ + { + "activity_code": "A", + "ES_p6": "2026-01-05 08:00:00", + "EF_p6": "2026-01-09 17:00:00", + "LS_p6": "2026-01-05 08:00:00", + "LF_p6": "2026-01-09 17:00:00", + "TF_p6": "0", + "FF_p6": "3" + }, + { + "activity_code": "B", + "ES_p6": "2026-01-05 08:00:00", + "EF_p6": "2026-01-07 17:00:00", + "LS_p6": "2026-01-07 08:00:00", + "LF_p6": "2026-01-09 17:00:00", + "TF_p6": "2", + "FF_p6": "2" + } + ] + }, + { + "id": "05-negative-float", + "sourceSha256": "463f4ea2ee5e973340d82b26e2adbf0305b0815161f8b78dc074f381140d22b1", + "activities": [ + { + "activity_code": "A", + "ES_p6": "2026-01-05 08:00:00", + "EF_p6": "2026-01-14 17:00:00", + "LS_p6": "2025-12-25 08:00:00", + "LF_p6": "2026-01-05 17:00:00", + "TF_p6": "-7", + "FF_p6": "0" + }, + { + "activity_code": "B", + "ES_p6": "2026-01-15 08:00:00", + "EF_p6": "2026-01-20 17:00:00", + "LS_p6": "2026-01-06 08:00:00", + "LF_p6": "2026-01-09 17:00:00", + "TF_p6": "-7", + "FF_p6": "0" + } + ] + }, + { + "id": "06-multiple-calendars", + "sourceSha256": "8623f28be05dfcbee390746c5ece5aacfb5c1501edbee3e74ffee92ac1730432", + "activities": [ + { + "activity_code": "A", + "ES_p6": "2026-01-05 08:00:00", + "EF_p6": "2026-01-16 17:00:00", + "LS_p6": "2026-01-05 08:00:00", + "LF_p6": "2026-01-16 17:00:00", + "TF_p6": "0", + "FF_p6": "0" + }, + { + "activity_code": "B", + "ES_p6": "2026-01-05 08:00:00", + "EF_p6": "2026-01-15 17:00:00", + "LS_p6": "2026-01-06 08:00:00", + "LF_p6": "2026-01-16 17:00:00", + "TF_p6": "1", + "FF_p6": "1" + } + ] + }, + { + "id": "07-ontario-holidays", + "sourceSha256": "23ec9703eab963f8f1300ecdc7bfadd902668314d3008e172048a7adb5042026", + "activities": [ + { + "activity_code": "A", + "ES_p6": "2026-01-05 08:00:00", + "EF_p6": "2026-05-12 17:00:00", + "LS_p6": "2026-01-05 08:00:00", + "LF_p6": "2026-05-12 17:00:00", + "TF_p6": "0", + "FF_p6": "0" + } + ] + }, + { + "id": "08-in-progress-retained-logic", + "sourceSha256": "67c9811d83cdeace67fe7b410aa59b8550ae30cee287d6ee2bda7cd4347e255d", + "activities": [ + { + "activity_code": "A", + "ES_p6": "2026-01-06 08:00:00 A", + "EF_p6": "2026-01-20 17:00:00", + "LS_p6": "2026-01-06 08:00:00 A", + "LF_p6": "2026-01-20 17:00:00", + "TF_p6": "0", + "FF_p6": "0" + }, + { + "activity_code": "B", + "ES_p6": "2026-01-21 08:00:00", + "EF_p6": "2026-01-27 17:00:00", + "LS_p6": "2026-01-21 08:00:00", + "LF_p6": "2026-01-27 17:00:00", + "TF_p6": "0", + "FF_p6": "0" + } + ] + }, + { + "id": "09-completed-successor", + "sourceSha256": "0307a19fb51a61f1ad7b6445cbd6f2a20618ee4408912bc2cd320aca6ed1416b", + "activities": [ + { + "activity_code": "A", + "ES_p6": "2026-01-05 08:00:00", + "EF_p6": "2026-01-09 17:00:00", + "LS_p6": "2026-01-05 08:00:00", + "LF_p6": "2026-01-09 17:00:00", + "TF_p6": "0", + "FF_p6": "0" + }, + { + "activity_code": "B", + "ES_p6": "2025-12-15 08:00:00 A", + "EF_p6": "2025-12-30 17:00:00 A", + "LS_p6": "2025-12-15 08:00:00 A", + "LF_p6": "2025-12-30 17:00:00 A", + "TF_p6": "", + "FF_p6": "" + } + ] + }, + { + "id": "10-out-of-sequence-progress", + "sourceSha256": "2f598578d4539a95ea80698ab2979fd28a3cb007d8fe09e679a1173f00f5c5f6", + "activities": [ + { + "activity_code": "A", + "ES_p6": "2026-01-12 08:00:00", + "EF_p6": "2026-01-23 17:00:00", + "LS_p6": "2026-01-12 08:00:00", + "LF_p6": "2026-01-23 17:00:00", + "TF_p6": "0", + "FF_p6": "0" + }, + { + "activity_code": "B", + "ES_p6": "2026-01-08 08:00:00 A", + "EF_p6": "2026-01-28 17:00:00", + "LS_p6": "2026-01-08 08:00:00 A", + "LF_p6": "2026-01-28 17:00:00", + "TF_p6": "0", + "FF_p6": "0" + } + ] + }, + { + "id": "11-mandatory-start-finish", + "sourceSha256": "b4353c7fcdfff89ae1740cfa1e85a5c72d7c9493a53eea37a5cab246b828453b", + "activities": [ + { + "activity_code": "A", + "ES_p6": "2026-01-12 08:00:00", + "EF_p6": "2026-01-16 17:00:00", + "LS_p6": "2026-01-12 08:00:00", + "LF_p6": "2026-01-16 17:00:00", + "TF_p6": "0", + "FF_p6": "5" + }, + { + "activity_code": "B", + "ES_p6": "2026-01-26 08:00:00", + "EF_p6": "2026-01-29 17:00:00", + "LS_p6": "2026-01-26 08:00:00", + "LF_p6": "2026-01-29 17:00:00", + "TF_p6": "0", + "FF_p6": "0" + } + ] + }, + { + "id": "12-snet-fnlt", + "sourceSha256": "42480cf535150559b1ed5ed11d5e7604ee3c6ae1d666b9a555ab84b4936f0ee6", + "activities": [ + { + "activity_code": "A", + "ES_p6": "2026-01-20 08:00:00", + "EF_p6": "2026-01-26 17:00:00", + "LS_p6": "2026-01-20 08:00:00", + "LF_p6": "2026-01-26 17:00:00", + "TF_p6": "0", + "FF_p6": "0" + }, + { + "activity_code": "B", + "ES_p6": "2026-01-27 08:00:00", + "EF_p6": "2026-02-02 17:00:00", + "LS_p6": "2026-01-27 08:00:00", + "LF_p6": "2026-02-02 17:00:00", + "TF_p6": "0", + "FF_p6": "0" + } + ] + }, + { + "id": "13-alap", + "sourceSha256": "6ed931a167215ec05a8410c0751a02e429026b62410f0fed4f8768746f6a37bc", + "activities": [ + { + "activity_code": "A", + "ES_p6": "2026-01-05 08:00:00", + "EF_p6": "2026-01-09 17:00:00", + "LS_p6": "2026-01-05 08:00:00", + "LF_p6": "2026-01-09 17:00:00", + "TF_p6": "0", + "FF_p6": "0" + }, + { + "activity_code": "B", + "ES_p6": "2026-01-12 08:00:00", + "EF_p6": "2026-01-16 17:00:00", + "LS_p6": "2026-01-12 08:00:00", + "LF_p6": "2026-01-16 17:00:00", + "TF_p6": "0", + "FF_p6": "0" + }, + { + "activity_code": "C", + "ES_p6": "2026-01-19 08:00:00", + "EF_p6": "2026-01-21 17:00:00", + "LS_p6": "2026-01-19 08:00:00", + "LF_p6": "2026-01-21 17:00:00", + "TF_p6": "0", + "FF_p6": "0" + } + ] + } + ] +} diff --git a/tests/planning/check-adapters-hours.ts b/tests/planning/check-adapters-hours.ts index 358b0f32f..e5553b64c 100644 --- a/tests/planning/check-adapters-hours.ts +++ b/tests/planning/check-adapters-hours.ts @@ -124,10 +124,15 @@ function roundTrip(label: string, tk: Task[], seq: Sequence[], cal: WorkCalendar { const p = readP6XML(writeP6XML(project, H8, tasks, sequences, resources, assignments, lib)); roundTrip('P6', p.tasks, p.sequences, p.calendar, p.resourceCalendars ?? [], false); + eq('P6XML zonder bronwaarde introduceert geen schedulingOptions', p.project.schedulingOptions, undefined); } { const p = readMSPDI(writeMSPDI(project, H8, tasks, sequences, resources, assignments, lib)); roundTrip('MSPDI', p.tasks, p.sequences, p.calendar, p.resourceCalendars ?? [], false); + eq('MSPDI zonder CriticalSlackLimit introduceert geen schedulingOptions', + p.project.schedulingOptions, undefined); + eq('MSPDI introduceert geen XER-project- of kalenderprovenance', + [p.project.schedulingOptions?.p6Source, p.calendar.p6Source], [undefined, undefined]); } // Een uurkalender kan dag- en urentaken mengen. De expliciete adaptermarkering moet die keuze diff --git a/tests/planning/check-calendar-mirror.ts b/tests/planning/check-calendar-mirror.ts new file mode 100644 index 000000000..450b1b1af --- /dev/null +++ b/tests/planning/check-calendar-mirror.ts @@ -0,0 +1,333 @@ +// check-calendar-mirror.ts — de SPIEGEL-invariant van de kalender- en duur-primitieven, corpusloos. +// +// AANLEIDING (diagnose laag 1, klasse (ii), deeldossier "spiegel", 2026-09-04/05). Het dossier van +// XER-project 761 (`rehab-2.xer`, kalender 842) meldde dat een terugwaartse duurwandeling over een +// aaneengesloten niet-werkblok van ±10 dagen 10 werkdagen overbrugt waar de voorwaartse wandeling +// er 7 telt, en vermoedde daarachter een FORMAATNEUTRAAL defect in +// `CalendarEngine.subtractWorkMinutes`/`CPMSolver.subDuration`. +// +// DIE VERMOEDENS ZIJN HIER GEMETEN EN WEERLEGD, en dat is precies wat deze batterij vastpint: +// +// * Deel A — de kalenderprimitieven zijn WEL exacte spiegels, ook over een blok van 10 +// aaneengesloten vrije kalenderdagen, in uur-modus met 1, 2 en 3 banden per dag, in dag-modus, +// en op een weekpatroon waarin het weekend werkt en een doordeweekse dag vrij is. +// * Deel B — hetzelfde geldt op SOLVER-niveau: `ES..EF` en `LS..LF` overspannen altijd evenveel +// werktijd, en `ES→LS` is altijd gelijk aan `EF→LF` (float-symmetrie), over FS/SS/FF/SF met +// positieve, nul- en negatieve lag, met het late venster dwars door het blok. +// * Deel C — de gemeten 7-vs-10 komt UITSLUITEND uit `subtractP6XerProjectedWorkMinutes`, de +// P6/XER-resultaatprojectie. Die is per constructie GEEN spiegel (er is geen voorwaartse +// tegenhanger) en hangt achter `WorkCalendar.p6Source === 'XER'`. Deel C pint zowel de +// afwijking zelf als de bronpoort: zonder `p6Source: 'XER'` is de projectie inert en geldt de +// spiegel onverkort. Zo kan die brongebonden asymmetrie nooit stilzwijgend formaatneutraal +// worden — dat zou élk IFC-/MSPDI-/MPP-project raken. +// +// Draait via run.sh (ook in de tijdzone-matrix). Exit 0 = alles groen. + +import { CalendarEngine } from '@/engine/scheduler/CalendarEngine'; +import { solveProject } from '@/engine/scheduler/solveProject'; +import { formatInstant, parseDate, parseInstant } from '@/utils/dateUtils'; +import type { Sequence } from '@/types/sequence'; +import type { Task } from '@/types/task'; +import type { SequenceType } from '@/types/sequence'; +import type { WorkCalendar } from '@/types/calendar'; + +let checks = 0; +const diffs: string[] = []; +function eq(label: string, actual: unknown, expected: unknown): void { + checks++; + if (actual !== expected) diffs.push(`${label}: kreeg ${String(actual)}, verwacht ${String(expected)}`); +} +function ok(label: string, cond: boolean): void { + checks++; + if (!cond) diffs.push(label); +} + +// ── Fixtures ──────────────────────────────────────────────────────────────────────────────── +// Eén band (08:00-17:00), twee banden (lunchpauze) en drie banden (ploegendienst met twee pauzes). +const ONE_BAND = [{ start: 480, end: 1020 }]; // 540 min +const TWO_BANDS = [{ start: 480, end: 720 }, { start: 780, end: 1020 }]; // 480 min +const THREE_BANDS = [{ start: 360, end: 600 }, { start: 660, end: 840 }, { start: 900, end: 1260 }]; // 780 min + +// Het aaneengesloten niet-werkblok: 10 kalenderdagen, ma 2026-03-09 t/m wo 2026-03-18, dus met een +// heel weekend erin. Dat is de vorm waarop het dossier de asymmetrie meende te zien. +const BLOCK = [{ name: 'Aaneengesloten blok van 10 dagen', startDate: '2026-03-09', endDate: '2026-03-18' }]; + +function hourCalendar( + id: string, workDays: number[], bands: { start: number; end: number }[], + extra: Partial = {}, +): WorkCalendar { + const byWeekday = { 1: [], 2: [], 3: [], 4: [], 5: [], 6: [], 7: [] } as Record< + 1 | 2 | 3 | 4 | 5 | 6 | 7, { start: number; end: number }[] + >; + for (const d of workDays) byWeekday[d as 1 | 2 | 3 | 4 | 5 | 6 | 7] = bands; + return { + id, name: id, description: '', workDays, workStartHour: 8, workEndHour: 17, + hoursPerDay: bands.reduce((s, b) => s + (b.end - b.start), 0) / 60, + holidays: [...BLOCK], workTime: { byWeekday }, ...extra, + }; +} + +function dayCalendar(id: string, workDays: number[]): WorkCalendar { + return { + id, name: id, description: '', workDays, workStartHour: 8, workEndHour: 16, + hoursPerDay: 8, holidays: [...BLOCK], + }; +} + +const WEEK_PATTERNS: { label: string; workDays: number[] }[] = [ + { label: 'ma-vr', workDays: [1, 2, 3, 4, 5] }, + // P6-vorm van kalender 842 uit het dossier: het weekend werkt, vrijdag is vrij. + { label: 'ma-do+za+zo', workDays: [1, 2, 3, 4, 6, 7] }, +]; + +const HOUR_SHAPES: { label: string; bands: { start: number; end: number }[] }[] = [ + { label: '1 band', bands: ONE_BAND }, + { label: '2 banden', bands: TWO_BANDS }, + { label: '3 banden', bands: THREE_BANDS }, +]; + +// Het meetvenster loopt ruim vóór en ná het blok, zodat élke wandeling van 1..20 werkdagen het blok +// een keer kruist, erin begint of er net buiten valt. +const SWEEP_FROM = Date.UTC(2026, 1, 16); +const SWEEP_TO = Date.UTC(2026, 3, 20); +const HALF_HOUR_MS = 1_800_000; +const DAY_MS = 86_400_000; +const SPANS = [1, 2, 3, 5, 7, 10, 13, 20]; + +// ═══════════════════════════════════════════════════════════════════════════════════════════ +// Deel A — de kalenderprimitieven +// ═══════════════════════════════════════════════════════════════════════════════════════════ +// +// SNAP-REGEL OP NIET-WERK-INSTANTS (het punt waar de spiegel schijnbaar breekt, en waarom niet). +// `addWorkMinutes` normaliseert zijn startpunt met `nextWorkInstant` (voorwaarts), en +// `subtractWorkMinutes` normaliseert zijn eindpunt met `prevWorkInstant` (achterwaarts) — elk in de +// richting van de eigen wandeling. Daardoor geldt de ronde-reis-identiteit +// `sub(add(t, n), n) === t` EXACT voor elk werk-instant t. Ze geldt NIET letterlijk voor een t die +// zelf geen werk-instant is: `add` snapt dan vooruit, `sub` snapt achteruit, en de twee snappunten +// liggen aan weerszijden van hetzelfde gat. Ook een t op een bandGRENS is dubbelzinnig: 17:00 (het +// eind van vrijdag) en 08:00 (het begin van maandag) zijn hetzelfde punt op de werk-as — er ligt nul +// werktijd tussen. De juiste gelijkheidstest tussen twee posities op de werk-as is daarom +// `workMinutesBetween(a, b) === 0`, niet `a.getTime() === b.getTime()`. Deel A toetst beide vormen: +// de harde ms-identiteit voor werk-instants, en de werk-as-gelijkheid voor de rest. +for (const shape of HOUR_SHAPES) { + for (const week of WEEK_PATTERNS) { + const label = `A uur ${shape.label} ${week.label}`; + const eng = new CalendarEngine(hourCalendar(`h-${shape.label}-${week.label}`, week.workDays, shape.bands)); + const unit = shape.bands.reduce((s, b) => s + (b.end - b.start), 0); + let addSub = 0; let subAdd = 0; let spanFwd = 0; let spanBack = 0; let sampled = 0; + let firstAddSub = ''; let firstSubAdd = ''; + for (let ms = SWEEP_FROM; ms < SWEEP_TO; ms += HALF_HOUR_MS) { + const t = new Date(ms); + if (!eng.isWorkInstant(t)) continue; + sampled++; + for (const n of SPANS) { + const minutes = n * unit; + // A1 — vooruit en dan terug landt exact terug op t (harde ms-identiteit). + const forward = eng.addWorkMinutes(t, minutes); + const backAgain = eng.subtractWorkMinutes(forward, minutes); + if (backAgain.getTime() !== t.getTime() && eng.workMinutesBetween(backAgain, t) !== 0) { + addSub++; + if (!firstAddSub) { + firstAddSub = `${formatInstant(t, 'hour')} +${n}d=${formatInstant(forward, 'hour')} ` + + `-${n}d=${formatInstant(backAgain, 'hour')}`; + } + } + // A2 — terug en dan vooruit landt op dezelfde positie op de werk-as (bandgrens-dualiteit). + const backward = eng.subtractWorkMinutes(t, minutes); + const forwardAgain = eng.addWorkMinutes(backward, minutes); + if (eng.workMinutesBetween(forwardAgain, t) !== 0) { + subAdd++; + if (!firstSubAdd) { + firstSubAdd = `${formatInstant(t, 'hour')} -${n}d=${formatInstant(backward, 'hour')} ` + + `+${n}d=${formatInstant(forwardAgain, 'hour')}`; + } + } + // A3 — de wandeling verbruikt precies het gevraagde aantal werkminuten, beide kanten op. + if (eng.workMinutesBetween(t, forward) !== minutes) spanFwd++; + if (eng.workMinutesBetween(backward, t) !== minutes) spanBack++; + } + } + ok(`${label}: het meetvenster bevat werk-instants`, sampled > 200); + eq(`${label} A1 sub(add(t,n),n) === t${firstAddSub ? ` — eerste: ${firstAddSub}` : ''}`, addSub, 0); + eq(`${label} A2 add(sub(t,n),n) ≡ t${firstSubAdd ? ` — eerste: ${firstSubAdd}` : ''}`, subAdd, 0); + eq(`${label} A3 voorwaartse wandeling verbruikt exact n werkminuten`, spanFwd, 0); + eq(`${label} A3 achterwaartse wandeling verbruikt exact n werkminuten`, spanBack, 0); + } +} + +for (const week of WEEK_PATTERNS) { + const label = `A dag ${week.label}`; + const eng = new CalendarEngine(dayCalendar(`d-${week.label}`, week.workDays)); + let addSub = 0; let subAdd = 0; let signed = 0; let span = 0; let sampled = 0; + for (let ms = SWEEP_FROM; ms < SWEEP_TO; ms += DAY_MS) { + const t = new Date(ms); + if (!eng.isWorkDay(t)) continue; + sampled++; + for (const n of SPANS) { + const forward = eng.addWorkDays(t, n); + if (eng.subtractWorkDays(forward, n).getTime() !== t.getTime()) addSub++; + const backward = eng.subtractWorkDays(t, n); + if (eng.addWorkDays(backward, n).getTime() !== t.getTime()) subAdd++; + // `addWorkingDaysSigned` is de zuivere offset (de begindag telt NIET als dag 1) en moet in + // beide richtingen even ver stappen — juist over het blok heen. + const shifted = eng.addWorkingDaysSigned(t, n); + if (eng.addWorkingDaysSigned(shifted, -n).getTime() !== t.getTime()) signed++; + if (eng.workDaysBetween(t, forward) !== n) span++; + } + } + ok(`${label}: het meetvenster bevat werkdagen`, sampled > 20); + eq(`${label} A4 subtractWorkDays(addWorkDays(t,n),n) === t`, addSub, 0); + eq(`${label} A4 addWorkDays(subtractWorkDays(t,n),n) === t`, subAdd, 0); + eq(`${label} A5 addWorkingDaysSigned is richtingssymmetrisch`, signed, 0); + eq(`${label} A5 addWorkDays verbruikt exact n werkdagen`, span, 0); +} + +// Expliciete regel-instantie van het dossier: 7 werkdagen vooruit en 7 werkdagen terug over hetzelfde +// blok leveren precies dezelfde twee grenzen op. Kalender 842-vorm: vrijdag vrij, weekend werkt. +{ + const eng = new CalendarEngine(hourCalendar('h-842-vorm', [1, 2, 3, 4, 6, 7], TWO_BANDS)); + const start = parseInstant('2026-03-05T08:00'); // donderdag vóór het blok + const finish = eng.addWorkMinutes(start, 7 * 480); // 7 werkdagen à 8 uur + // 03-05 do, 03-07 za, 03-08 zo, [blok 03-09..03-18], 03-19 do, 03-21 za, 03-22 zo, 03-23 ma. + eq('A6 vooruit 7 werkdagen over het blok', formatInstant(finish, 'hour'), '2026-03-23T17:00'); + eq('A6 terug 7 werkdagen over het blok landt exact terug', + formatInstant(eng.subtractWorkMinutes(finish, 7 * 480), 'hour'), '2026-03-05T08:00'); + eq('A6 beide richtingen overbruggen evenveel werkminuten', + eng.workMinutesBetween(start, finish), 7 * 480); +} + +// ═══════════════════════════════════════════════════════════════════════════════════════════ +// Deel B — de spiegel op solver-niveau (`addDuration` ↔ `subDuration`, forward- ↔ backward-pass) +// ═══════════════════════════════════════════════════════════════════════════════════════════ +function makeTask( + id: string, anchor: string, amount: number, unit: 'days' | 'hours', minutesPerDay: number, +): Task { + return { + id, name: id, description: '', wbsCode: '1', taskType: 'CONSTRUCTION', status: 'NOT_STARTED', + isMilestone: false, priority: 500, parentId: null, childIds: [], resourceIds: [], + time: { + durationType: 'WORKTIME', durationUnit: unit, + scheduleDuration: unit === 'hours' ? amount / minutesPerDay : amount, + ...(unit === 'hours' ? { durationMinutes: amount } : {}), + scheduleStart: anchor, scheduleFinish: anchor, + earlyStart: anchor, earlyFinish: anchor, lateStart: anchor, lateFinish: anchor, + freeFloat: 0, totalFloat: 0, isCritical: false, completion: 0, + }, + }; +} + +const RELATIONS: SequenceType[] = ['FINISH_START', 'START_START', 'FINISH_FINISH', 'START_FINISH']; +const LAGS = [0, 2, -2]; + +for (const hourMode of [true, false]) { + for (const shape of hourMode ? HOUR_SHAPES : [HOUR_SHAPES[1]]) { + for (const week of WEEK_PATTERNS) { + const label = `B ${hourMode ? `uur ${shape.label}` : 'dag'} ${week.label}`; + const cal = hourMode + ? hourCalendar(`b-${shape.label}-${week.label}`, week.workDays, shape.bands) + : dayCalendar(`bd-${week.label}`, week.workDays); + const eng = new CalendarEngine(cal); + const mpd = eng.hoursPerDay * 60; + const unit: 'days' | 'hours' = hourMode ? 'hours' : 'days'; + let spanFails = 0; let floatFails = 0; let solves = 0; let errors = 0; + let firstSpan = ''; let firstFloat = ''; + for (let ms = Date.UTC(2026, 1, 20); ms < Date.UTC(2026, 2, 24); ms += DAY_MS) { + const iso = new Date(ms).toISOString().slice(0, 10); + const anchor = hourMode ? `${iso}T08:00` : iso; + for (const relType of RELATIONS) { + for (const lagDays of LAGS) { + const mk = (id: string, days: number): Task => + makeTask(id, anchor, hourMode ? days * mpd : days, unit, mpd); + // X → Y → Z is de keten; SPACER duwt het projecteinde ver naar achteren zodat de keten + // speling krijgt en haar LATE venster dwars door het blok van 10 dagen valt. + const tasks = [mk('X', 3), mk('Y', 7), mk('Z', 2), mk('SPACER', 30)]; + const sequences: Sequence[] = [ + { id: 's1', predecessorId: 'X', successorId: 'Y', type: relType, lagDays }, + { id: 's2', predecessorId: 'Y', successorId: 'Z', type: 'FINISH_START', lagDays: 0 }, + ]; + const result = solveProject({ + tasks, sequences, calendar: cal, calendars: [cal], projectStartDate: iso, + }); + if (result.error) { errors++; continue; } + solves++; + for (const t of tasks) { + const es = hourMode ? parseInstant(t.time.earlyStart!) : parseDate(t.time.earlyStart!); + const ef = hourMode ? parseInstant(t.time.earlyFinish!) : parseDate(t.time.earlyFinish!); + const ls = hourMode ? parseInstant(t.time.lateStart!) : parseDate(t.time.lateStart!); + const lf = hourMode ? parseInstant(t.time.lateFinish!) : parseDate(t.time.lateFinish!); + const early = hourMode ? eng.workMinutesBetween(es, ef) : eng.workDaysBetween(es, ef); + const late = hourMode ? eng.workMinutesBetween(ls, lf) : eng.workDaysBetween(ls, lf); + if (early !== late) { + spanFails++; + if (!firstSpan) { + firstSpan = `${iso} ${relType}/${lagDays} ${t.id}: ES..EF=${early} LS..LF=${late}` + + ` (${t.time.earlyStart}..${t.time.earlyFinish} | ${t.time.lateStart}..${t.time.lateFinish})`; + } + } + const startFloat = hourMode ? eng.workMinutesBetween(es, ls) : eng.workDaysBetween(es, ls); + const finishFloat = hourMode ? eng.workMinutesBetween(ef, lf) : eng.workDaysBetween(ef, lf); + if (startFloat !== finishFloat) { + floatFails++; + if (!firstFloat) { + firstFloat = `${iso} ${relType}/${lagDays} ${t.id}: ES→LS=${startFloat} EF→LF=${finishFloat}`; + } + } + } + } + } + } + eq(`${label}: geen solve-fouten`, errors, 0); + ok(`${label}: er is echt doorgerekend`, solves > 100); + eq(`${label} B1 ES..EF en LS..LF overspannen evenveel werktijd` + + `${firstSpan ? ` — eerste: ${firstSpan}` : ''}`, spanFails, 0); + eq(`${label} B2 ES→LS is gelijk aan EF→LF${firstFloat ? ` — eerste: ${firstFloat}` : ''}`, + floatFails, 0); + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════════════════════ +// Deel C — er is GEEN brongebonden uitzondering meer op de spiegel +// ═══════════════════════════════════════════════════════════════════════════════════════════ +// +// Tot etappe 7b-2 droeg `CalendarEngine` een `subtractP6XerProjectedWorkMinutes`-projectie achter +// `p6Source === 'XER'`: die telde per `p6NonWorkPenaltyDate` in het doorlopen venster een extra +// niet-werkdag mee, zónder voorwaartse tegenhanger. Dat was precies de "7 vooruit, 10 terug" uit het +// diagnosedossier. De projectie is verwijderd nadat (a) de XER-decoder de werkelijk bedoelde vrije +// dagen kon reconstrueren en (b) gemeten was dat de projectie op élk penaltydragend corpusbestand +// nul cellen verklaarde. Deze sectie pint dat de spiegel nu ONVOORWAARDELIJK geldt: ook een kalender +// die de volledige XER-bronstempel én een penaltylijst draagt, wandelt vooruit en achteruit exact +// even ver. Zou iemand de projectie opnieuw invoeren, dan gaat C1 rood. +{ + const xer = new CalendarEngine(hourCalendar('c-xer', [1, 2, 3, 4, 6, 7], TWO_BANDS, { + p6Source: 'XER', p6NonWorkPenaltyDates: ['2026-03-13', '2026-03-20'], + })); + const neutral = new CalendarEngine(hourCalendar('c-neutraal', [1, 2, 3, 4, 6, 7], TWO_BANDS)); + // Exact het venster uit A6: 7 werkdagen à 8 uur eindigend op ma 2026-03-23. + const finish = parseInstant('2026-03-23T17:00'); + const start = parseInstant('2026-03-05T08:00'); + const duration = 7 * 480; + + eq('C1 een volledige XER-bronkalender wandelt achteruit exact even ver als een neutrale', + formatInstant(xer.subtractWorkMinutes(finish, duration), 'hour'), + formatInstant(neutral.subtractWorkMinutes(finish, duration), 'hour')); + eq('C1 en die uitkomst is de exacte inverse van de voorwaartse wandeling', + formatInstant(xer.addWorkMinutes(xer.subtractWorkMinutes(finish, duration), duration), 'hour'), + formatInstant(finish, 'hour')); + eq('C1 de bronstempel raakt ook de voorwaartse wandeling niet', + formatInstant(xer.addWorkMinutes(start, duration), 'hour'), + formatInstant(neutral.addWorkMinutes(start, duration), 'hour')); + eq('C2 de werkminutentelling is bronneutraal', + xer.workMinutesBetween(start, finish), neutral.workMinutesBetween(start, finish)); + eq('C2 en telt exact de gevraagde duur over het blok van tien dagen', + xer.workMinutesBetween(start, finish), duration); +} + +if (diffs.length === 0) { + console.log(`OK calendar-mirror: alle checks groen (${checks})`); + process.exit(0); +} else { + console.log(`XX calendar-mirror: ${diffs.length} afwijking(en) van ${checks}`); + for (const d of diffs) console.log(` - ${d}`); + process.exit(1); +} diff --git a/tests/planning/check-dev-bridge-poller.ts b/tests/planning/check-dev-bridge-poller.ts new file mode 100644 index 000000000..79ea48517 --- /dev/null +++ b/tests/planning/check-dev-bridge-poller.ts @@ -0,0 +1,34 @@ +// X9 reviewfix 2: toets de WERKELIJK JSON-geserialiseerde Tauri-pollerrespons, niet alleen runOp. +// De store initialiseert de documenttaal bij import; dit is de kleinste DOM-vorm die dat headless +// nodig heeft en bevat bewust geen Tauri- of poller-stub. +(globalThis as unknown as { document: { documentElement: { dir: string; lang: string } } }).document = { + documentElement: { dir: 'ltr', lang: 'en' }, +}; + +const { serializeOpsTestResponse } = await import('@/utils/devBridge'); + +const response = JSON.parse(await serializeOpsTestResponse({ id: 'x9-roundtrip', op: 'roundTrip' })) as { + id?: unknown; + ok?: unknown; + result?: unknown; +}; +const result = response.result as Partial<{ + bytes: unknown; + lossless: unknown; + before: { tasks?: unknown }; + after: { tasks?: unknown }; +}> | undefined; +const passed = response.id === 'x9-roundtrip' + && response.ok === true + && typeof result?.bytes === 'number' + && result.bytes > 0 + && result.lossless === true + && typeof result.before?.tasks === 'number' + && typeof result.after?.tasks === 'number'; + +if (passed) { + console.log('OK dev-bridge-poller: roundTrip is volledig JSON-geserialiseerd'); + process.exit(0); +} +console.log(`XX dev-bridge-poller: roundTrip-result is geen concrete JSON-payload: ${JSON.stringify(response)}`); +process.exit(1); diff --git a/tests/planning/check-document-activation.ts b/tests/planning/check-document-activation.ts index aafe8f81b..2c4c1ba6f 100644 --- a/tests/planning/check-document-activation.ts +++ b/tests/planning/check-document-activation.ts @@ -254,6 +254,7 @@ function taskRowNames(state: AppState): string[] { activeBaselineId: null, filePath: null, isDirty: false, + datesAsRecorded: false, }; const states: AppState[] = []; const unsubscribe = store.subscribe(state => { diff --git a/tests/planning/check-document-contract.ts b/tests/planning/check-document-contract.ts index 14afc986c..c83d620c4 100644 --- a/tests/planning/check-document-contract.ts +++ b/tests/planning/check-document-contract.ts @@ -262,6 +262,7 @@ const mkInput = (id: string, name: string): RecoveryDocInput => ({ activeBaselineId: `bl-${id}`, filePath: `/tmp/${id}.ifc`, isDirty: true, + datesAsRecorded: false, }); const inA = mkInput('rec-a', 'DocA'); const inB = mkInput('rec-b', 'DocB'); @@ -334,8 +335,36 @@ S().updateTask(kT1, { // ene `updateTask`-aanroep laat ze ongemoeid staan naast de bestaande Z14-velden hierboven). mspTaskType: 'FIXED_DURATION', effortDriven: true, timephasedContours: [{ resourceUid: 12, periods: [{ afterMinutes: 0, minutes: 60, workMinutes: 60, kind: 'actual' }] }], - time: { ...kt1Before.time, resume: '2031-03-05', stop: '2031-03-06' }, + // X7-reviewfix: precies de vijf velden die de P6-identiteit, voortgangsfamilie en + // suspend/resume-firewall vormen. Ze rijden als taakdata door document- en snapshotcontract en + // via OPS_P6Progress door de echte recovery-IFC-keten hieronder. + p6ProjectId: 'P6-K3', + p6TaskId: 'P6-T1', + p6ExplicitTargetWindow: true, + p6CompletePctType: 'CP_Phys', + p6ExpectedFinish: '2031-03-12', + p6SuspendResume: true, + time: { ...kt1Before.time, stop: '2031-03-05', resume: '2031-03-06' }, }); +const k3X7 = (candidate: Task | undefined) => [ + candidate?.p6ProjectId, + candidate?.p6TaskId, + candidate?.p6ExplicitTargetWindow, + candidate?.p6CompletePctType, + candidate?.p6ExpectedFinish, + candidate?.p6SuspendResume, +]; +const k3X7Expected = ['P6-K3', 'P6-T1', true, 'CP_Phys', '2031-03-12', true]; +eq('d K3 X7 setup: bronvelden en firewall gezet', k3X7(S().tasks.find(t => t.id === kT1)), k3X7Expected); +S().undo(); +eq('d K3 X7 undo: hele veldgroep terug naar afwezig', k3X7(S().tasks.find(t => t.id === kT1)), + [undefined, undefined, undefined, undefined, undefined, undefined]); +S().redo(); +eq('d K3 X7 redo: hele veldgroep exact hersteld', k3X7(S().tasks.find(t => t.id === kT1)), k3X7Expected); +const k3DocumentId = S().activeDocumentId; +S().newDocument(); +S().switchDocument(k3DocumentId); +eq('d K3 X7 documentwissel: hele veldgroep exact behouden', k3X7(S().tasks.find(t => t.id === kT1)), k3X7Expected); S().runCPM(); S().saveBaseline('K3-nulmeting'); // Momentopname vóór de round-trip; taak-id's worden bij het inlezen opnieuw gegenereerd (en de @@ -348,7 +377,7 @@ const kIfc = writeIFC(buildWriteIFCInput(S())); truthy('d recovery-IFC bevat geen persoonlijke taakgridvoorkeur', !kIfc.includes(taskGridSentinel)); const kParsed = readIFC(kIfc); // Exact het productiepad: de hook bouwt de recovery-invoer met deze functie, geen replica hier. -const kInput: RecoveryDocInput = recoveryInputFromParsed(kParsed, { id: 'k3-doc', filePath: '/tmp/k3.ifc', isDirty: true }); +const kInput: RecoveryDocInput = recoveryInputFromParsed(kParsed, { id: 'k3-doc', filePath: '/tmp/k3.ifc', isDirty: true, datesAsRecorded: false }); truthy('d recovery-invoer bevat geen persoonlijke taakgridvoorkeur', !JSON.stringify(kInput).includes(taskGridSentinel)); S().restoreDocuments([kInput], 'k3-doc'); @@ -382,14 +411,15 @@ eq('d K3 keten: splitGaps overleeft', kT1After?.splitGaps, [{ afterMinutes: 60, eq('d K3 keten: manuallyScheduled overleeft', kT1After?.manuallyScheduled, true); eq('d K3 keten: levelingDelayMinutes overleeft', kT1After?.levelingDelayMinutes, 15); eq('d K3 keten: levelingDelayElapsed overleeft', kT1After?.levelingDelayElapsed, true); -eq('d K3 keten: time.resume overleeft', kT1After?.time.resume, '2031-03-05'); -eq('d K3 keten: time.stop overleeft', kT1After?.time.stop, '2031-03-06'); +eq('d K3 keten: time.stop overleeft', kT1After?.time.stop, '2031-03-05'); +eq('d K3 keten: time.resume overleeft', kT1After?.time.resume, '2031-03-06'); // Z14b — de drie NIEUWE velden overleven dezelfde keten (bewijst de "tasks"-clone-snapshot-rol // écht draagt wat er verder in de plan-toelichting over beweerd wordt, geen aanname). eq('d K3 keten: mspTaskType overleeft', kT1After?.mspTaskType, 'FIXED_DURATION'); eq('d K3 keten: effortDriven overleeft', kT1After?.effortDriven, true); eq('d K3 keten: timephasedContours overleeft', kT1After?.timephasedContours, [{ resourceUid: 12, periods: [{ afterMinutes: 0, minutes: 60, workMinutes: 60, kind: 'actual' }] }]); +eq('d K3 X7 recovery/IFC: bronvelden en firewall overleven write→read→restore', k3X7(kT1After), k3X7Expected); // Contract-eenheidscheck op de tweede helft van de mapping: baselines uit de recovery-invoer // moeten ook in de document-payload landen (vangt een regressie IN `payloadFromInput`). diff --git a/tests/planning/check-document-tab-navigation.ts b/tests/planning/check-document-tab-navigation.ts new file mode 100644 index 000000000..e9885f4d0 --- /dev/null +++ b/tests/planning/check-document-tab-navigation.ts @@ -0,0 +1,509 @@ +// Documenttab-navigatie (X11): twaalf projecten moeten via de zichtbare tabstrip bereikbaar +// blijven. Deze check bewaakt de pure kern die DocumentTabBar werkelijk gebruikt voor +// pijlnavigatie én het naar beeld brengen van de actieve tab. +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { CloseDocumentDialogControl } from '@/components/layout/DocumentChrome/CloseDocumentDialogControl'; +import { + createCloseDocumentActionGate, + createCloseDocumentDialogActions, + type CloseDocumentActionGate, +} from '@/components/layout/DocumentChrome/closeDocumentActions'; +import { DocumentTabControl } from '@/components/layout/DocumentChrome/DocumentTabControl'; +import { DocumentTabList } from '@/components/layout/DocumentChrome/DocumentTabList'; +import type { DocumentCard } from '@/components/layout/DocumentChrome/useDocumentCards'; +import { + DOCUMENT_TABPANEL_ID, + documentTabCloseFocusTarget, + documentTabId, + documentTabKeyDestination, + focusDocumentTab, + handleDocumentTabKeyDown, + revealDocumentTab, +} from '@/components/layout/DocumentChrome/documentTabNavigation'; +import { + documentTabFocusTargetOutsideOverview, + projectOverviewCloseButtonId, + projectOverviewCloseFocusTarget, +} from '@/components/layout/DocumentChrome/projectOverviewFocus'; + +// shortcutRegistry importeert de i18n-config, die bij initialisatie document/localStorage leest. +// Zet alleen voor deze headless check het minimale DOM-contract neer vóór de dynamische import; +// de productiehelpers zelf blijven DOM-vrij. +const deferredAnimationFrames: FrameRequestCallback[] = []; +Object.assign(globalThis, { + document: { documentElement: {} }, + localStorage: { getItem: () => null, setItem: () => undefined }, + requestAnimationFrame: (callback: FrameRequestCallback) => { + deferredAnimationFrames.push(callback); + return deferredAnimationFrames.length; + }, +}); +const { SHORTCUTS, matchesCombo } = await import('@/hooks/keyboard/shortcutRegistry'); + +const diffs: string[] = []; +let checks = 0; + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +const ids = Array.from({ length: 12 }, (_, index) => `doc-${index + 1}`); + +// Tab 10–12 mogen geen onbereikbare staart van een Ctrl/Cmd-1..9-reeks zijn. +eq('LTR tab 9 → pijltje rechts bereikt tab 10', documentTabKeyDestination(ids, 'doc-9', 'ArrowRight', 'ltr'), 'doc-10'); +eq('LTR tab 10 → pijltje rechts bereikt tab 11', documentTabKeyDestination(ids, 'doc-10', 'ArrowRight', 'ltr'), 'doc-11'); +eq('LTR tab 11 → pijltje rechts bereikt tab 12', documentTabKeyDestination(ids, 'doc-11', 'ArrowRight', 'ltr'), 'doc-12'); +eq('LTR tab 12 → pijltje rechts wrapt naar tab 1', documentTabKeyDestination(ids, 'doc-12', 'ArrowRight', 'ltr'), 'doc-1'); +eq('LTR tab 1 → pijltje links wrapt naar tab 12', documentTabKeyDestination(ids, 'doc-1', 'ArrowLeft', 'ltr'), 'doc-12'); +eq('RTL pijltje rechts gaat naar de vorige tab', documentTabKeyDestination(ids, 'doc-6', 'ArrowRight', 'rtl'), 'doc-5'); +eq('RTL pijltje links gaat naar de volgende tab', documentTabKeyDestination(ids, 'doc-6', 'ArrowLeft', 'rtl'), 'doc-7'); +eq('Home bereikt bewust de eerste tab in RTL', documentTabKeyDestination(ids, 'doc-11', 'Home', 'rtl'), 'doc-1'); +eq('End bereikt bewust de laatste tab in RTL', documentTabKeyDestination(ids, 'doc-2', 'End', 'rtl'), 'doc-12'); +eq('andere toets verandert de actieve tab niet', documentTabKeyDestination(ids, 'doc-10', 'Enter', 'ltr'), null); +eq('een niet-bestaande actieve id veroorzaakt geen gok', documentTabKeyDestination(ids, 'verdwenen', 'ArrowRight', 'ltr'), null); + +// Volledig eventcontract dat DocumentTabBar rechtstreeks gebruikt. Een guard in de component +// vanaf index 9 moet deze reeks niet buiten de geteste productiehandler kunnen afkappen. +function runTabKey( + cardId: string, + key: string, + direction: 'ltr' | 'rtl', + sameTarget = true, +): { result: string | null; effects: string[] } { + const effects: string[] = []; + const currentTarget = {}; + const result = handleDocumentTabKeyDown( + { + key, + target: sameTarget ? currentTarget : {}, + currentTarget, + preventDefault: () => { effects.push('preventDefault'); }, + }, + ids, + cardId, + direction, + { + switchTo: (id) => { effects.push(`switch:${id}`); }, + focusTab: (id) => { effects.push(`focus:${id}`); }, + }, + ); + return { result, effects }; +} + +eq('component-event LTR 9 → 10 voert prevent/switch/focus uit', runTabKey('doc-9', 'ArrowRight', 'ltr'), { + result: 'doc-10', effects: ['preventDefault', 'switch:doc-10', 'focus:doc-10'], +}); +eq('component-event LTR 10 → 11 voert prevent/switch/focus uit', runTabKey('doc-10', 'ArrowRight', 'ltr'), { + result: 'doc-11', effects: ['preventDefault', 'switch:doc-11', 'focus:doc-11'], +}); +eq('component-event LTR 11 → 12 voert prevent/switch/focus uit', runTabKey('doc-11', 'ArrowRight', 'ltr'), { + result: 'doc-12', effects: ['preventDefault', 'switch:doc-12', 'focus:doc-12'], +}); +eq('component-event LTR 12 → 1 wrapt volledig', runTabKey('doc-12', 'ArrowRight', 'ltr'), { + result: 'doc-1', effects: ['preventDefault', 'switch:doc-1', 'focus:doc-1'], +}); +eq('component-event RTL 9 → 10 gebruikt ArrowLeft', runTabKey('doc-9', 'ArrowLeft', 'rtl'), { + result: 'doc-10', effects: ['preventDefault', 'switch:doc-10', 'focus:doc-10'], +}); +eq('component-event RTL 12 → 1 wrapt met ArrowLeft', runTabKey('doc-12', 'ArrowLeft', 'rtl'), { + result: 'doc-1', effects: ['preventDefault', 'switch:doc-1', 'focus:doc-1'], +}); +eq('child-event wordt niet als tabtoets afgehandeld', runTabKey('doc-9', 'ArrowRight', 'ltr', false), { + result: null, effects: [], +}); + +// Ctrl/Cmd-contract: dit is bewust een aparte sectie van pijlnavigatie. Alleen 1–9 zijn globale +// sneltoetsen; tab 10–12 zijn via de tablist bereikbaar, niet via een verzonnen Ctrl/Cmd+10. +const documentShortcuts = SHORTCUTS.filter(shortcut => shortcut.id.startsWith('nav.switchDocument')); +eq('Ctrl/Cmd-contract bevat precies negen entries', documentShortcuts.length, 9); +eq('Ctrl/Cmd-contract is exact 1–9', documentShortcuts.map(shortcut => shortcut.combo.key), ['1', '2', '3', '4', '5', '6', '7', '8', '9']); +eq('Ctrl+1 matcht de eerste entry', matchesCombo({ key: '1', ctrlKey: true, metaKey: false, shiftKey: false, altKey: false } as KeyboardEvent, documentShortcuts[0]?.combo), true); +eq('Cmd+1 matcht de eerste entry', matchesCombo({ key: '1', ctrlKey: false, metaKey: true, shiftKey: false, altKey: false } as KeyboardEvent, documentShortcuts[0]?.combo), true); +eq('Ctrl+10 heeft geen entry', documentShortcuts.some(shortcut => shortcut.combo.key === '10'), false); + +// Close-focuskern: focus ontstaat pas wanneer de aangevraagde id in de nieuwe, gecommitteerde +// kaartenlijst verdwenen is. Daardoor zijn schoon sluiten en dirty save/don't-save gelijkwaardig, +// terwijl annuleren geen focusbeweging veroorzaakt. +const before = [{ id: 'doc-1', isActive: true }, { id: 'doc-2', isActive: false }]; +eq('schoon actief sluiten focust het nieuwe actieve document', documentTabCloseFocusTarget( + before.map(doc => doc.id), + [{ id: 'doc-2', isActive: true }], + 'doc-1', +), 'doc-2'); +eq('schoon inactief sluiten behoudt focus op het actieve document', documentTabCloseFocusTarget( + before.map(doc => doc.id), + [{ id: 'doc-1', isActive: true }], + 'doc-2', +), 'doc-1'); +eq('dirty save focust pas na verdwijnen van het document', documentTabCloseFocusTarget( + before.map(doc => doc.id), + [{ id: 'doc-2', isActive: true }], + 'doc-1', +), 'doc-2'); +eq('dirty dont-save focust pas na verdwijnen van het document', documentTabCloseFocusTarget( + before.map(doc => doc.id), + [{ id: 'doc-2', isActive: true }], + 'doc-1', +), 'doc-2'); +eq('cancel verplaatst de focus niet', documentTabCloseFocusTarget( + before.map(doc => doc.id), + before, + 'doc-1', +), null); +eq('laatste document focust het nieuw aangemaakte actieve document', documentTabCloseFocusTarget( + ['doc-1'], + [{ id: 'doc-new', isActive: true }], + 'doc-1', +), 'doc-new'); +eq('onbekende verwijdering veroorzaakt geen focusgok', documentTabCloseFocusTarget( + before.map(doc => doc.id), + [{ id: 'doc-2', isActive: true }], + null, +), null); + +// Overzicht-focuspolicy: bij een gecommitteerde verwijdering blijft focus in de zichtbare overlay. +// Dezelfde index wint, anders de vorige; een vervangend vers document is de nieuwe kaart op index 0. +eq('overview schoon actief sluiten focust dezelfde zichtbare index', projectOverviewCloseFocusTarget( + ['doc-1', 'doc-2', 'doc-3'], ['doc-1', 'doc-3'], 'doc-2', +), 'doc-3'); +eq('overview schoon inactief sluiten focust dezelfde zichtbare index', projectOverviewCloseFocusTarget( + ['doc-1', 'doc-2', 'doc-3'], ['doc-1', 'doc-3'], 'doc-2', +), 'doc-3'); +eq('overview dirty save focust na commit dezelfde index', projectOverviewCloseFocusTarget( + ['doc-1', 'doc-2', 'doc-3'], ['doc-1', 'doc-3'], 'doc-2', +), 'doc-3'); +eq('overview dirty dont-save focust na commit vorige bij laatste index', projectOverviewCloseFocusTarget( + ['doc-1', 'doc-2', 'doc-3'], ['doc-1', 'doc-2'], 'doc-3', +), 'doc-2'); +eq('overview Cancel verschuift niets zolang kaart blijft bestaan', projectOverviewCloseFocusTarget( + ['doc-1', 'doc-2', 'doc-3'], ['doc-1', 'doc-2', 'doc-3'], 'doc-2', +), null); +eq('overview laatste document focust vervangende nieuwe kaart', projectOverviewCloseFocusTarget( + ['doc-1'], ['doc-new'], 'doc-1', +), 'doc-new'); +eq('tabbar claimt geen verborgen closefocus onder het overzicht', documentTabFocusTargetOutsideOverview( + true, 'doc-3', +), null); +eq('tabbar claimt closefocus zonder overzicht wel', documentTabFocusTargetOutsideOverview( + false, 'doc-3', +), 'doc-3'); +eq('overview-sluitknop-id is stabiel', projectOverviewCloseButtonId('doc-3'), 'ops-overview-close-doc-3'); + +eq('stabiele tabpanel-id', DOCUMENT_TABPANEL_ID, 'ops-document-tabpanel'); +eq('tab-id gebruikt de stabiele document-id-afleiding', documentTabId('doc-7'), 'ops-document-tab-doc-7'); + +const calls: ScrollIntoViewOptions[] = []; +revealDocumentTab({ scrollIntoView: (options?: ScrollIntoViewOptions) => { calls.push(options ?? {}); } }); +eq('actieve tab wordt zonder concurrerende smooth-scroll direct naar beeld gebracht', calls, [{ + block: 'nearest', inline: 'nearest', behavior: 'instant', +}]); + +// Kleinste passende renderharness: React render-to-string test de geproduceerde interactieve DOM +// zonder een nieuw testframework. Toetsnavigatie en focusdoel hierboven gebruiken de letterlijke +// productiehelpers; de browserproef dekt vervolgens echte events, focus en scroll. +const renderedTab = renderToStaticMarkup(createElement(DocumentTabControl, { + card: { + id: 'doc-7', title: 'Project zeven', fileName: null, code: 'PZ', color: '#123456', + isActive: true, isDirty: true, taskCount: 0, milestoneCount: 0, criticalCount: 0, + endDate: null, thumb: [], + }, + index: 6, + closeLabel: 'Sluiten', + tabRef: null, + onSelect: () => undefined, + onKeyDown: () => undefined, + onClose: () => undefined, +})); +eq('gerenderde tab heeft stabiel id en geselecteerde roving-tabindex', /id="ops-document-tab-doc-7"[^>]*role="tab"[^>]*aria-selected="true"[^>]*tabindex="0"/.test(renderedTab), true); +eq('gerenderde tab koppelt aan het tabpanel', /role="tab"[^>]*aria-controls="ops-document-tabpanel"/.test(renderedTab), true); +eq('gerenderde tabnaam bevat geen sluitnaam', /role="tab"[^>]*aria-label="Project zeven"/.test(renderedTab), true); +eq('gerenderde sluitknop is een sibling van de tab', /role="tab"[\s\S]*?<\/button>]*class="ops-tab-close"[^>]*aria-label="Sluiten Project zeven"/.test(renderedTab), true); + +// Productieactiehelper: de test voert de echte branches uit; callbacks leggen alleen de zichtbare +// store-/focus-effecten vast. Een verkeerde Cancel/Discard-branch kan daardoor niet groen blijven. +function closeActionHarness(options?: { + pendingId?: string; + activeId?: string; + gate?: CloseDocumentActionGate; + effects?: string[]; + save?: () => Promise; +}) { + const effects = options?.effects ?? []; + const pendingId = options?.pendingId ?? 'doc-2'; + let activeId = options?.activeId ?? 'doc-2'; + const actions = createCloseDocumentDialogActions({ + gate: options?.gate, + pendingId, + getActiveDocumentId: () => activeId, + switchDocument: (id) => { activeId = id; effects.push(`switch:${id}`); }, + closeDocument: (id) => { effects.push(`close:${id}`); }, + saveFile: async () => { + effects.push('save'); + return options?.save ? options.save() : false; + }, + clearPending: () => { effects.push('clear'); }, + restoreOpenerFocus: () => { effects.push('restoreFocus'); }, + onSavePendingChange: (pending) => { effects.push(`pending:${pending}`); }, + }); + return { actions, effects }; +} + +{ + const { actions, effects } = closeActionHarness(); + actions.cancel(); + eq('Cancel laat document open, sluit dialoog en herstelt opener', effects, ['clear', 'restoreFocus']); +} +{ + const { actions, effects } = closeActionHarness(); + actions.discard(); + eq("Don't Save gebruikt uitsluitend de discardtak", effects, ['close:doc-2', 'clear']); +} +{ + const { actions, effects } = closeActionHarness({ + activeId: 'doc-1', + save: async () => true, + }); + await actions.save(); + eq('dirty Save wisselt, bewaart en sluit pas na schone commit', effects, [ + 'switch:doc-2', 'pending:true', 'save', 'close:doc-2', 'clear', 'pending:false', + ]); +} +{ + const { actions, effects } = closeActionHarness({ save: async () => false }); + await actions.save(); + eq('geannuleerde Save As laat document open, sluit dialoog en herstelt opener', effects, [ + 'pending:true', 'save', 'clear', 'restoreFocus', 'pending:false', + ]); +} +{ + const { actions, effects } = closeActionHarness({ save: async () => { throw new Error('schrijffout'); } }); + await actions.save(); + eq('mislukte Save laat document open, sluit dialoog en herstelt opener', effects, [ + 'pending:true', 'save', 'clear', 'restoreFocus', 'pending:false', + ]); +} +{ + let releaseSave: () => void = () => undefined; + const savePending = new Promise((resolvePending) => { releaseSave = resolvePending; }); + const gate = createCloseDocumentActionGate(); + const effects: string[] = []; + const firstRender = closeActionHarness({ + gate, + effects, + save: async () => { + await savePending; + return true; + }, + }); + const secondRender = closeActionHarness({ + gate, + effects, + save: async () => { + await savePending; + return true; + }, + }); + const firstSave = firstRender.actions.save(); + const secondSave = secondRender.actions.save(); + secondRender.actions.discard(); + secondRender.actions.cancel(); + releaseSave(); + await Promise.all([firstSave, secondSave]); + eq('pending Save is single-flight over dubbele activatie en concurrerende keuzes', effects, [ + 'pending:true', 'save', 'close:doc-2', 'clear', 'pending:false', + ]); +} + +function findControl( + node: ReactNode, + predicate: (element: ReactElement>) => boolean, +): ReactElement> | null { + if (!isValidElement>(node)) return null; + if (predicate(node)) return node; + const children = node.props.children; + const values = Array.isArray(children) ? children : [children]; + for (const child of values) { + const found = findControl(child, predicate); + if (found) return found; + } + return null; +} + +// Werkelijke componentroute: DocumentTabList maakt twaalf echte DocumentTabControl-elementen. +// De test vuurt hun echte keyhandlers zonder pauzes af en rendert na iedere synchrone storewissel +// opnieuw. Een indexguard vóór de productiehelper blokkeert dan zichtbaar tab 10–12. +const routeCards = (activeId: string): DocumentCard[] => ids.map((id, index) => ({ + id, + title: `Project ${index + 1}`, + fileName: null, + code: `P${index + 1}`, + color: '#123456', + isActive: id === activeId, + isDirty: false, + taskCount: 0, + milestoneCount: 0, + criticalCount: 0, + endDate: null, + thumb: [], +})); + +function runActualTabRoute(direction: 'ltr' | 'rtl', key: 'ArrowLeft' | 'ArrowRight') { + let activeRouteId = 'doc-9'; + const focused: string[] = []; + const revealed: string[] = []; + const prevented: string[] = []; + const rovingChecks: boolean[] = []; + const tabElements = new Map(ids.map(id => [id, { + focus: () => { focused.push(id); }, + scrollIntoView: () => { revealed.push(id); }, + }])); + + const listProps = () => ({ + cards: routeCards(activeRouteId), + direction, + tablistLabel: 'Open projecten', + closeLabel: 'Sluiten', + tabRef: () => undefined, + switchTo: (id: string) => { activeRouteId = id; }, + focusTab: (id: string) => { focusDocumentTab(tabElements, id); }, + closeWithGuard: () => undefined, + }); + + for (const expectedId of ['doc-10', 'doc-11', 'doc-12', 'doc-1']) { + const list = DocumentTabList(listProps()); + const control = findControl(list, element => { + const card = element.props.card as DocumentCard | undefined; + return card?.id === activeRouteId; + }); + const renderedControl = control + ? DocumentTabControl(control.props as unknown as Parameters[0]) + : null; + const tab = findControl(renderedControl, element => element.props.role === 'tab'); + const currentTarget = {}; + (tab?.props.onKeyDown as ((event: unknown) => void) | undefined)?.({ + key, + target: currentTarget, + currentTarget, + preventDefault: () => { prevented.push(expectedId); }, + }); + + const rerendered = renderToStaticMarkup(createElement(DocumentTabList, listProps())); + const selectedPattern = new RegExp( + `id="ops-document-tab-${expectedId}"[^>]*aria-selected="true"[^>]*tabindex="0"`, + ); + rovingChecks.push(selectedPattern.test(rerendered) + && (rerendered.match(/tabindex="0"/g)?.length ?? 0) === 1); + } + return { activeRouteId, focused, revealed, prevented, rovingChecks }; +} + +eq('werkelijke LTR-componentroute bereikt 9→10→11→12→1 zonder pauzes', + runActualTabRoute('ltr', 'ArrowRight'), { + activeRouteId: 'doc-1', + focused: ['doc-10', 'doc-11', 'doc-12', 'doc-1'], + revealed: ['doc-10', 'doc-11', 'doc-12', 'doc-1'], + prevented: ['doc-10', 'doc-11', 'doc-12', 'doc-1'], + rovingChecks: [true, true, true, true], + }); +eq('werkelijke RTL-componentroute bereikt 9→10→11→12→1 zonder pauzes', + runActualTabRoute('rtl', 'ArrowLeft'), { + activeRouteId: 'doc-1', + focused: ['doc-10', 'doc-11', 'doc-12', 'doc-1'], + revealed: ['doc-10', 'doc-11', 'doc-12', 'doc-1'], + prevented: ['doc-10', 'doc-11', 'doc-12', 'doc-1'], + rovingChecks: [true, true, true, true], + }); + +const dialogEvents: string[] = []; +const dialogControl = CloseDocumentDialogControl({ + title: 'Wijzigingen opslaan?', + body: 'Project twee bevat wijzigingen.', + cancelLabel: 'Annuleren', + discardLabel: 'Niet opslaan', + saveLabel: 'Opslaan', + busy: false, + cancelButtonRef: null, + onCancel: () => { dialogEvents.push('cancel'); }, + onDiscard: () => { dialogEvents.push('discard'); }, + onSave: () => { dialogEvents.push('save'); }, +}); +const backdropClick = dialogControl.props.onClick as (() => void) | undefined; +backdropClick?.(); +eq('werkelijke dialogbackdrop is naar Cancel bedraad', dialogEvents, ['cancel']); +dialogEvents.length = 0; +const cancelControl = findControl(dialogControl, element => element.props['data-ops-close-choice'] === 'cancel'); +(cancelControl?.props.onClick as (() => void) | undefined)?.(); +eq('werkelijke Cancel-knop is naar Cancel bedraad', dialogEvents, ['cancel']); +dialogEvents.length = 0; +const discardControl = findControl(dialogControl, element => element.props['data-ops-close-choice'] === 'discard'); +(discardControl?.props.onClick as (() => void) | undefined)?.(); +eq("werkelijke Don't Save-knop is naar discard bedraad", dialogEvents, ['discard']); +const renderedDialog = renderToStaticMarkup(createElement(CloseDocumentDialogControl, { + title: 'Wijzigingen opslaan?', body: 'Project twee bevat wijzigingen.', cancelLabel: 'Annuleren', + discardLabel: 'Niet opslaan', saveLabel: 'Opslaan', busy: false, cancelButtonRef: null, + onCancel: () => undefined, onDiscard: () => undefined, onSave: () => undefined, +})); +eq('gerenderde dialoog onderscheidt alle drie acties semantisch', [ + /data-ops-close-choice="cancel"/.test(renderedDialog), + /data-ops-close-choice="discard"/.test(renderedDialog), + /data-ops-close-choice="save"/.test(renderedDialog), +], [true, true, true]); +const blockedEvents: string[] = []; +const busyDialog = CloseDocumentDialogControl({ + title: 'Wijzigingen opslaan?', body: 'Opslaan loopt.', cancelLabel: 'Annuleren', + discardLabel: 'Niet opslaan', saveLabel: 'Opslaan', busy: true, cancelButtonRef: null, + onCancel: () => { blockedEvents.push('cancel'); }, + onDiscard: () => { blockedEvents.push('discard'); }, + onSave: () => { blockedEvents.push('save'); }, +}); +(busyDialog.props.onClick as (() => void) | undefined)?.(); +const busyCancel = findControl(busyDialog, element => element.props['data-ops-close-choice'] === 'cancel'); +const busyDiscard = findControl(busyDialog, element => element.props['data-ops-close-choice'] === 'discard'); +const busySave = findControl(busyDialog, element => element.props['data-ops-close-choice'] === 'save'); +(busyCancel?.props.onClick as (() => void) | undefined)?.(); +(busyDiscard?.props.onClick as (() => void) | undefined)?.(); +(busySave?.props.onClick as (() => void) | undefined)?.(); +eq('pending Save blokkeert backdrop en alle drie dialogacties', blockedEvents, []); +eq('pending Save schakelt alle drie knoppen uit', [ + busyCancel?.props.disabled, busyDiscard?.props.disabled, busySave?.props.disabled, +], [true, true, true]); + +// Supplementaire wiring-scan: de renderharness hierboven is de componentdekking. Deze kleine +// scan bewaakt uitsluitend de App-grens, waar de gedeelde actieve werkruimte het panel daadwerkelijk +// aan de tablist hangt. +const tabControlSource = readFileSync(resolve( + process.cwd(), + 'src/components/layout/DocumentChrome/DocumentTabControl.tsx', +), 'utf8'); +eq('zichtbare tabnaam blijft decoratief naast de expliciete naam', tabControlSource.includes('className="ops-tab-name" aria-hidden="true"'), true); +const tabBarSource = readFileSync(resolve( + process.cwd(), + 'src/components/layout/DocumentChrome/DocumentTabBar.tsx', +), 'utf8'); +eq('DocumentTabBar gebruikt de functioneel geteste tablijstcomponent', + tabBarSource.includes(' 0) { + console.error(`document-tab-navigation: ${diffs.length}/${checks} checks rood`); + for (const diff of diffs) console.error(`XX ${diff}`); + process.exit(1); +} +console.log(`OK document-tab-navigation: alle ${checks} checks groen`); diff --git a/tests/planning/check-ext-contract.ts b/tests/planning/check-ext-contract.ts index cd02daab9..5377bc106 100644 --- a/tests/planning/check-ext-contract.ts +++ b/tests/planning/check-ext-contract.ts @@ -33,8 +33,9 @@ import type { Sequence } from '@/types/sequence'; import type { Resource, ResourceAssignment } from '@/types/resource'; import type { ExtProject, ExtCalendar, ExtTask, ExtTaskTime, ExtSequence, ExtResource, ExtAssignment, - ExtRibbonTab, ExtFontProvider, + ExtRibbonTab, ExtFontProvider, ExtImportSourceInfo, } from '@/extensions/extTypes'; +import { EXT_IMPORT_SOURCE_PAGE_SIZE_MAX } from '@/extensions/extTypes'; import type { ExtensionApi, ExtensionPermission } from '@/extensions/types'; import { createExtensionApi, @@ -42,16 +43,25 @@ import { } from '@/extensions/extensionApi'; import type { AppStoreContext } from '@/state/appStore'; import { EXTENSION_API_VERSION, checkApiCompatibility } from '@/extensions/apiVersion'; +import { KNOWN_PERMISSIONS, sanitizeManifestPermissions } from '@/extensions/permissions'; import { toExtProject, fromExtProject, toExtCalendar, fromExtCalendar, toExtTask, fromExtTask, + fromExtImportResult, toExtTaskTime, fromExtTaskTime, toExtSequence, fromExtSequence, toExtResource, fromExtResource, toExtAssignment, fromExtAssignment, + fromExtTaskInput, fromExtTaskUpdates, fromExtRibbonTab, fromExtFontProvider, } from '@/extensions/extMappers'; +import { getExtensionSdk } from '@/extensions/sdk'; +import { appStoreContext, useAppStore } from '@/state/appStore'; +import { readXER } from '@/services/xer/xerReader'; +import { isMultiDocumentImport } from '@/services/importTypes'; +import { decodeXerSourceArchive, sha256Hex } from '@/services/xerSourceArchive'; +import { ExtImportSourceDriftError } from '@/extensions/extImportSource'; const diffs: string[] = []; let checks = 0; @@ -80,6 +90,7 @@ const EXT_PROJECT_KEYS = keys()([ const EXT_CALENDAR_KEYS = keys()([ 'id', 'name', 'description', 'workDays', 'workStartHour', 'workEndHour', 'hoursPerDay', 'simpleBreakStartMinute', 'simpleBreakDurationMinutes', 'holidays', 'workTime', 'shift', 'workingExceptions', + 'p6Source', 'p6NonWorkPenaltyDates', ] as const); const EXT_TASK_TIME_KEYS = keys()([ @@ -91,17 +102,21 @@ const EXT_TASK_TIME_KEYS = keys()([ const EXT_TASK_KEYS = keys()([ 'id', 'name', 'description', 'wbsCode', 'taskType', 'customTaskType', 'status', 'isMilestone', 'milestoneKind', - 'mandatory', 'priority', 'levelingDelay', 'parentId', 'childIds', 'time', 'resourceIds', 'color', + 'mandatory', 'priority', 'levelingDelay', 'parentId', 'childIds', 'isSummary', 'time', 'resourceIds', 'color', 'activityCodes', 'customFields', 'constraint', 'constraint2', 'isHammock', 'externalLinks', 'deadline', 'calendarId', 'notes', // fase 3.8 (.mpp-datumgetrouwheid): leeskant-velden uit de import, zie extTypes.ts 'levelingDelayMinutes', 'levelingDelayElapsed', 'splitGaps', 'manuallyScheduled', 'mspTaskType', 'effortDriven', 'timephasedContours', 'timephasedFinishFloor', 'timephasedStartAnchor', 'timephasedDurationWalks', + // X0 (XER-etappeplan, 2026-08-20): drie nieuwe .xer-importvelden, zelfde behandeling als de + // .mpp-velden hierboven. + 'p6DurationType', 'p6ActivityType', 'p6ProjectId', 'p6TaskId', 'p6ExplicitTargetWindow', 'p6CompletePctType', 'p6ExpectedFinish', 'p6SuspendResume', ] as const); const EXT_SEQUENCE_KEYS = keys()([ 'id', 'predecessorId', 'successorId', 'type', 'lagDays', 'lagMinutes', 'lagUnit', 'lagPercent', + 'p6StartAtPredecessorFinishBoundary', ] as const); const EXT_RESOURCE_KEYS = keys()([ @@ -122,7 +137,9 @@ const NIET_PUBLIEK = { // beheerd. Een extensie die deze stempels kon zetten zou een projectkopie kunnen laten dóén // alsof hij uit een bibliotheek komt. project: ['companyId', 'companyName'] as readonly string[], - calendar: ['generation', 'libraryOrigin'] as readonly string[], + // De penaltydiagnose is een IFC-leesdiagnose, geen extensie-invoer of -uitvoer. De geldige + // brongegevens zelf (`p6Source` + lijst) blijven wél rondtrippend beschikbaar. + calendar: ['generation', 'libraryOrigin', 'p6NonWorkPenaltyDatesState'] as readonly string[], resource: ['availability', 'libraryOrigin'] as readonly string[], task: [] as readonly string[], taskTime: [] as readonly string[], @@ -130,6 +147,27 @@ const NIET_PUBLIEK = { assignment: [] as readonly string[], }; +// Zichtbare velden die een extensie uitsluitend mag LEZEN. Dit is een andere grens dan +// `NIET_PUBLIEK`: `toExt*` geeft ze bewust door, maar `fromExt*` accepteert ze niet als generieke +// solverinvoer. Alleen de native XER-reader mag de relationele P6-bronvlag afleiden. +const LEES_ALLEEN_EXT = { + project: [] as readonly string[], + calendar: ['p6Source', 'p6NonWorkPenaltyDates'] as readonly string[], + resource: [] as readonly string[], + task: [ + 'p6DurationType', 'p6ActivityType', 'p6ProjectId', 'p6TaskId', 'p6ExplicitTargetWindow', + 'p6CompletePctType', 'p6ExpectedFinish', 'p6SuspendResume', + ] as readonly string[], + taskTime: [] as readonly string[], + sequence: ['p6StartAtPredecessorFinishBoundary'] as readonly string[], + assignment: [] as readonly string[], +}; + +const PUBLIC_SCHEDULING_OPTION_KEYS = [ + 'lagCalendar', 'criticalDefinition', 'totalFloatMode', 'makeOpenEndedCritical', + 'nearCriticalThreshold', 'floatPaths', +] as const; + // ── (b) Maximale fixtures — élk optioneel veld gevuld ──────────────────────── const VOL_TIME = { @@ -180,12 +218,19 @@ const VOL_TASK = { manuallyScheduled: true, mspTaskType: 'FIXED_WORK', effortDriven: true, + // X0 (XER-etappeplan): drie nieuwe .xer-importvelden, allemaal gevuld — zelfde volgorde-eis als + // de .mpp-velden hierboven (de round-trip-check vergelijkt via JSON.stringify). + p6DurationType: 'DT_FixedDUR2', + p6ActivityType: 'TT_Rsrc', + p6ProjectId: 'P1', p6TaskId: 'T1', p6ExplicitTargetWindow: true, p6CompletePctType: 'CP_Phys', p6ExpectedFinish: '2026-06-11T17:00', + p6SuspendResume: true, timephasedContours: [{ resourceUid: 3, periods: [{ afterMinutes: 0, minutes: 480, workMinutes: 240, kind: 'remaining' }] }], timephasedFinishFloor: '2026-06-10T17:00', timephasedStartAnchor: '2026-06-01T08:00', timephasedDurationWalks: [{ anchor: '2026-06-01T08:00', resourceCalendarId: 'cal2', workMinutes: 480 }], parentId: 'p1', childIds: ['c1', 'c2'], + isSummary: true, time: VOL_TIME, resourceIds: ['r1'], color: '#abcdef', @@ -232,14 +277,131 @@ const VOL_CALENDAR = { workTime: { byWeekday: { 1: [{ start: 420, end: 960 }], 2: [], 3: [], 4: [], 5: [], 6: [], 7: [] } }, shift: 'SECOND', workingExceptions: [{ name: 'Inhaaldag', startDate: '2026-06-06', endDate: '2026-06-06' }], + p6Source: 'XER', + p6NonWorkPenaltyDates: ['2026-06-07'], + p6NonWorkPenaltyDatesState: 'VALID_VALUES', libraryOrigin: { companyId: 'b1', libraryItemId: 'i1', poolVersion: 2, syncedHash: 'h1' }, } satisfies Required; const VOL_SEQUENCE = { id: 's1', predecessorId: 'a', successorId: 'b', type: 'START_START', lagDays: 2, lagMinutes: 960, lagUnit: 'ELAPSEDTIME', lagPercent: 50, + p6StartAtPredecessorFinishBoundary: false, } satisfies Required; +// X12-fixronde: P6-relatieherkomst is uitleesbaar voor analyse, maar een gewone extensie-import +// of `addSequence` mag nooit zelf P6-solvergedrag inschakelen. Alleen de native XER-lezer heeft +// een expliciete bronmodus die deze vlag mag materialiseren. +eq('X12 extensie leest de P6-relatievlag uit maar voert haar niet generiek terug in', { + exposed: toExtSequence({ ...VOL_SEQUENCE, p6StartAtPredecessorFinishBoundary: true }) + .p6StartAtPredecessorFinishBoundary, + imported: fromExtSequence({ ...VOL_SEQUENCE, p6StartAtPredecessorFinishBoundary: true }) + .p6StartAtPredecessorFinishBoundary, +}, { exposed: true, imported: undefined }); + +{ + const hostileOptions = { + ...VOL_PROJECT.schedulingOptions, + p6Source: 'XER' as const, + useExpectedFinishDates: true, + preserveActualDatesInBackwardPass: true, + clampNegativeFreeFloat: true, + p6ZeroDurationUsesPlannedBoundary: true, + p6UseTaskPlannedStartFloor: true, + p6FinishMilestoneBoundaryWindow: true, + p6PreserveActualInstants: true, + p6UseRemainingStartForProgress: true, + p6PreserveZeroDurationConstraintInstants: true, + useProjectEndDateForFloat: true, + resumeFromActualElapsed: true, + unstartedIgnoresStatusDate: true, + }; + const imported = fromExtProject({ + ...toExtProject(VOL_PROJECT), + schedulingOptions: hostileOptions, + } as ExtProject); + eq('X12 generieke extensie-invoer reconstrueert uitsluitend de publieke schedulingOptions-whitelist', + Object.keys(imported.schedulingOptions ?? {}).sort(), [...PUBLIC_SCHEDULING_OPTION_KEYS].sort()); + eq('X12 toExtProject toont evenmin interne runtime-opties uit een intern project', + Object.keys(toExtProject({ ...VOL_PROJECT, schedulingOptions: hostileOptions }).schedulingOptions ?? {}).sort(), + [...PUBLIC_SCHEDULING_OPTION_KEYS].sort()); +} + +{ + const exposed = toExtCalendar(VOL_CALENDAR); + const imported = fromExtCalendar({ + ...exposed, + p6Source: 'XER', + p6NonWorkPenaltyDates: ['2026-06-07'], + p6NonWorkPenaltyDatesState: 'VALID_VALUES', + } as ExtCalendar & { p6NonWorkPenaltyDatesState: string }); + eq('X12 kalenderherkomst is zichtbaar in het read-model maar generieke invoer activeert haar niet', { + exposedSource: exposed.p6Source, + exposedDates: exposed.p6NonWorkPenaltyDates, + importedSource: imported.p6Source, + importedDates: imported.p6NonWorkPenaltyDates, + importedState: imported.p6NonWorkPenaltyDatesState, + }, { + exposedSource: 'XER', exposedDates: ['2026-06-07'], + importedSource: undefined, importedDates: undefined, importedState: undefined, + }); +} + +{ + const exposed = toExtTask(VOL_TASK); + const imported = fromExtTask(exposed); + eq('X12 P6-taakherkomst is zichtbaar in het read-model maar generieke invoer activeert haar niet', { + exposed: { + p6DurationType: exposed.p6DurationType, + p6ActivityType: exposed.p6ActivityType, + p6ProjectId: exposed.p6ProjectId, + p6TaskId: exposed.p6TaskId, + p6ExplicitTargetWindow: exposed.p6ExplicitTargetWindow, + p6CompletePctType: exposed.p6CompletePctType, + p6ExpectedFinish: exposed.p6ExpectedFinish, + p6SuspendResume: exposed.p6SuspendResume, + }, + imported: { + p6DurationType: imported.p6DurationType, + p6ActivityType: imported.p6ActivityType, + p6ProjectId: imported.p6ProjectId, + p6TaskId: imported.p6TaskId, + p6ExplicitTargetWindow: imported.p6ExplicitTargetWindow, + p6CompletePctType: imported.p6CompletePctType, + p6ExpectedFinish: imported.p6ExpectedFinish, + p6SuspendResume: imported.p6SuspendResume, + }, + }, { + exposed: { + p6DurationType: 'DT_FixedDUR2', p6ActivityType: 'TT_Rsrc', + p6ProjectId: 'P1', p6TaskId: 'T1', p6ExplicitTargetWindow: true, p6CompletePctType: 'CP_Phys', + p6ExpectedFinish: '2026-06-11T17:00', p6SuspendResume: true, + }, + imported: {}, + }); + + const p6Keys = [ + 'p6DurationType', 'p6ActivityType', 'p6ProjectId', 'p6TaskId', 'p6ExplicitTargetWindow', + 'p6CompletePctType', 'p6ExpectedFinish', 'p6SuspendResume', + ]; + const hostileTask = { ...exposed } as ExtTask; + const added = fromExtTaskInput({ ...hostileTask, name: 'kwaadaardige extensietaak' }); + const updated = fromExtTaskUpdates(hostileTask); + const importedResult = fromExtImportResult({ + project: toExtProject(VOL_PROJECT), + calendar: toExtCalendar(VOL_CALENDAR), + tasks: [hostileTask], sequences: [], resources: [], assignments: [], + }); + const presentP6Keys = (value: object): string[] => + p6Keys.filter(key => Object.prototype.hasOwnProperty.call(value, key)); + eq('X12 geen enkel from-extensionpad accepteert P6-taakprovenance', { + full: presentP6Keys(imported), + add: presentP6Keys(added), + update: presentP6Keys(updated), + importResult: presentP6Keys(importedResult.tasks[0]), + }, { full: [], add: [], update: [], importResult: [] }); +} + const VOL_RESOURCE = { id: 'r1', name: 'Kraan', type: 'EQUIPMENT', description: 'omschrijving', color: '#2563EB', @@ -303,25 +465,25 @@ for (const [naam, ext, bron, sleutels] of [ label: string, intern: object, bron: object, - nietPubliek: readonly string[], + nietSchrijfbaar: readonly string[], ) => { - const verwacht = Object.keys(bron).filter(k => !nietPubliek.includes(k)); + const verwacht = Object.keys(bron).filter(k => !nietSchrijfbaar.includes(k)); eq(label, verwacht.filter(k => !(k in intern)), []); }; controle('11 fromExtProject vult elk intern Project-veld', - fromExtProject(toExtProject(VOL_PROJECT)), VOL_PROJECT, NIET_PUBLIEK.project); + fromExtProject(toExtProject(VOL_PROJECT)), VOL_PROJECT, [...NIET_PUBLIEK.project, ...LEES_ALLEEN_EXT.project]); controle('12 fromExtCalendar vult elk intern WorkCalendar-veld', - fromExtCalendar(toExtCalendar(VOL_CALENDAR)), VOL_CALENDAR, NIET_PUBLIEK.calendar); + fromExtCalendar(toExtCalendar(VOL_CALENDAR)), VOL_CALENDAR, [...NIET_PUBLIEK.calendar, ...LEES_ALLEEN_EXT.calendar]); controle('13 fromExtTask vult elk intern Task-veld', - fromExtTask(toExtTask(VOL_TASK)), VOL_TASK, NIET_PUBLIEK.task); + fromExtTask(toExtTask(VOL_TASK)), VOL_TASK, [...NIET_PUBLIEK.task, ...LEES_ALLEEN_EXT.task]); controle('14 fromExtTaskTime vult elk intern TaskTime-veld', - fromExtTaskTime(toExtTaskTime(VOL_TIME)), VOL_TIME, NIET_PUBLIEK.taskTime); + fromExtTaskTime(toExtTaskTime(VOL_TIME)), VOL_TIME, [...NIET_PUBLIEK.taskTime, ...LEES_ALLEEN_EXT.taskTime]); controle('15 fromExtSequence vult elk intern Sequence-veld', - fromExtSequence(toExtSequence(VOL_SEQUENCE)), VOL_SEQUENCE, NIET_PUBLIEK.sequence); + fromExtSequence(toExtSequence(VOL_SEQUENCE)), VOL_SEQUENCE, [...NIET_PUBLIEK.sequence, ...LEES_ALLEEN_EXT.sequence]); controle('16 fromExtResource vult elk intern Resource-veld', - fromExtResource(toExtResource(VOL_RESOURCE)), VOL_RESOURCE, NIET_PUBLIEK.resource); + fromExtResource(toExtResource(VOL_RESOURCE)), VOL_RESOURCE, [...NIET_PUBLIEK.resource, ...LEES_ALLEEN_EXT.resource]); controle('17 fromExtAssignment vult elk intern ResourceAssignment-veld', - fromExtAssignment(toExtAssignment(VOL_ASSIGNMENT)), VOL_ASSIGNMENT, NIET_PUBLIEK.assignment); + fromExtAssignment(toExtAssignment(VOL_ASSIGNMENT)), VOL_ASSIGNMENT, [...NIET_PUBLIEK.assignment, ...LEES_ALLEEN_EXT.assignment]); // En de keerzijde: de niet-publieke velden moeten óók echt WEG zijn aan de ext-kant. Zonder deze // check zou "niet publiek" een lijst worden die je vult zodra iets rood wordt. @@ -341,11 +503,15 @@ for (const [naam, ext, bron, sleutels] of [ const stripNietPubliek = (o: object, weg: readonly string[]) => Object.fromEntries(Object.entries(o).filter(([k]) => !weg.includes(k))); eq('19 project round-trip', - fromExtProject(toExtProject(VOL_PROJECT)), stripNietPubliek(VOL_PROJECT, NIET_PUBLIEK.project)); + fromExtProject(toExtProject(VOL_PROJECT)), stripNietPubliek(VOL_PROJECT, [...NIET_PUBLIEK.project, ...LEES_ALLEEN_EXT.project])); eq('20 kalender round-trip', - fromExtCalendar(toExtCalendar(VOL_CALENDAR)), stripNietPubliek(VOL_CALENDAR, NIET_PUBLIEK.calendar)); - eq('21 taak round-trip', fromExtTask(toExtTask(VOL_TASK)), VOL_TASK); - eq('22 relatie round-trip', fromExtSequence(toExtSequence(VOL_SEQUENCE)), VOL_SEQUENCE); + fromExtCalendar(toExtCalendar(VOL_CALENDAR)), stripNietPubliek(VOL_CALENDAR, [...NIET_PUBLIEK.calendar, ...LEES_ALLEEN_EXT.calendar])); + eq('21 taak-readmodel reist alleen naar buiten; P6-herkomst komt generiek niet terug', + fromExtTask(toExtTask(VOL_TASK)), + stripNietPubliek(VOL_TASK, [...NIET_PUBLIEK.task, ...LEES_ALLEEN_EXT.task])); + eq('22 relatie round-trip bewaart geen native-XER-solvervlag via generieke extensie-invoer', + fromExtSequence(toExtSequence(VOL_SEQUENCE)), + stripNietPubliek(VOL_SEQUENCE, [...NIET_PUBLIEK.sequence, ...LEES_ALLEEN_EXT.sequence])); eq('23 resource round-trip', fromExtResource(toExtResource(VOL_RESOURCE)), stripNietPubliek(VOL_RESOURCE, NIET_PUBLIEK.resource)); eq('24 toewijzing round-trip', fromExtAssignment(toExtAssignment(VOL_ASSIGNMENT)), VOL_ASSIGNMENT); @@ -449,12 +615,391 @@ for (const [naam, ext, bron, sleutels] of [ eq('29 een class-provider overleeft de mapping (this blijft gebonden)', bindGooide, false); } -// ── 6. De contract-versiepoort (los van minAppVersion) ─────────────────────── +// ── 6. Publieke create-/updatepaden bewaren expliciete samenvattingsidentiteit ──────────────── +// Breuk die dit vangt: `ExtTask.isSummary` staat in het publieke type, maar één van de input- of +// updatemappers, de SDK-factory of `api.data.addTask/updateTask` laat true/false stil vallen. Een +// lege summary zou dan als gewone CPM-knoop terugkomen. +{ + const mappedInput = fromExtTaskInput({ name: 'Mapper-summary', isSummary: true }); + const mappedRegular = fromExtTaskInput({ name: 'Mapper-gewoon' }); + eq('30 fromExtTaskInput draagt expliciet true', mappedInput.isSummary, true); + eq('30a fromExtTaskInput maakt invoer zonder marker niet per ongeluk summary', + 'isSummary' in mappedRegular, false); + eq('30b fromExtTaskUpdates draagt expliciet true', fromExtTaskUpdates({ isSummary: true }).isSummary, true); + eq('30c fromExtTaskUpdates draagt expliciet false als bewuste reset', + fromExtTaskUpdates({ isSummary: false }).isSummary, false); + eq('30d fromExtTaskUpdates voegt bij een ongenoemde marker geen update toe', + 'isSummary' in fromExtTaskUpdates({ name: 'Alleen naam' }), false); + + useAppStore.getState().newProject(); + const sdkSummary = getExtensionSdk().factory.createTask({ name: 'SDK lege summary', isSummary: true }); + eq('31 SDK-factory bewaart de expliciete lege summary', sdkSummary.isSummary, true); + + const api = createExtensionApi('x4a-summary-contract', [], undefined, appStoreContext, { + app: appStoreContext, + showNotification: () => {}, + }); + const id = api.data.addTask(sdkSummary); + api.data.recalculate(); + eq('31a extension addTask bewaart de marker in de store', + useAppStore.getState().tasks.find(task => task.id === id)?.isSummary, true); + eq('31b extension addTask houdt de lege summary buiten CPMResult.tasks', + useAppStore.getState().cpmResult?.tasks.has(id), false); + + api.data.updateTask(id, { isSummary: false }); + api.data.recalculate(); + eq('31c extension updateTask kan de marker bewust naar false terugzetten', + useAppStore.getState().tasks.find(task => task.id === id)?.isSummary, false); + eq('31d een bewust teruggezette lege summary wordt weer een solvertaak', + useAppStore.getState().cpmResult?.tasks.has(id), true); + + api.data.updateTask(id, { isSummary: true }); + api.data.recalculate(); + eq('31e extension updateTask kan de samenvattingsidentiteit opnieuw aanzetten', + useAppStore.getState().tasks.find(task => task.id === id)?.isSummary, true); + eq('31f opnieuw aangezette summary blijft buiten CPMResult.tasks', + useAppStore.getState().cpmResult?.tasks.has(id), false); + api._cleanup(); +} + +// ── 7. Read-only XER-bronroute ────────────────────────────────────────────── +// Synthetische, privacyveilige fixture: twee generieke projecten en drie generieke taken. De test +// gebruikt de echte XER-reader zodat de API bovenop precies dezelfde retained archive-grafiek werkt +// als de gebruikersroute. Geen corpusnaam, bestandspad of privélabel komt in deze bron voor. +{ + const source = [ + 'ERMHDR\t23.12\t2026-09-01\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tCAL-1\tWerkweek\tCA_Project\t8\t40\t', + '%R\tCAL-2\tWerkweek\tCA_Project\t8\t40\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date', + '%R\tP-1\tSynthese A\tCAL-1\t2026-09-01 08:00', + '%R\tP-2\tSynthese B\tCAL-2\t2026-09-02 08:00', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date', + '%R\tT-1\tP-1\tCAL-1\tA-1\tTaak A\tTT_Task\tDT_FixedDUR2\tTK_NotStart\t8\t8\t2026-09-01 08:00\t2026-09-01 16:00', + '%R\tT-2\tP-2\tCAL-2\tB-1\tTaak B\tTT_Task\tDT_FixedDUR2\tTK_NotStart\t8\t8\t2026-09-02 08:00\t2026-09-02 16:00', + '%R\tT-3\tP-2\tCAL-2\tB-2\tTaak C\tTT_Task\tDT_FixedDUR2\tTK_NotStart\t16\t16\t2026-09-03 08:00\t2026-09-04 16:00', + '%T\tACTVTYPE', + '%F\tactv_code_type_id\tactv_code_type\tseq_num', + '%R\tTYPE\tFase\t1', + '%T\tACTVCODE', + '%F\tactv_code_id\tactv_code_type_id\tshort_name\tseq_num', + '%R\tVALUE\tTYPE\tFase 1\t1', + '%T\tTASKACTV', + '%F\tproj_id\ttask_id\tactv_code_type_id\tactv_code_id', + '%R\tP-2\tT-2\tTYPE\tVALUE', + '%E', + ].join('\r\n'); + const opened = readXER(new TextEncoder().encode(`\ufeff${source}`)); + if (!isMultiDocumentImport(opened)) throw new Error('Bronfixture moet twee XER-documenten opleveren'); + + useAppStore.getState().newProject(); + const api = createExtensionApi('xer-source-read-contract', ['importSource'], undefined, appStoreContext, { + app: appStoreContext, + showNotification: () => {}, + }); + + // ── P1-privacyfix: 'importSource' is default-deny, geen kern-API ────────── + // Zonder de permissie moet elke methode GOOIEN vóórdat er data gelezen wordt — geen stille null, + // geen gedeeltelijk antwoord. `apiNoPerm` deelt hetzelfde document als `api`; het enige verschil + // is de permissielijst. Mutatiebewijs: verwijder de drie 'importSource'-entries uit + // `API_PERMISSIONS` (permissions.ts) en dit blok kleurt rood (de calls slagen dan gewoon). + { + const apiNoPerm = createExtensionApi('xer-source-read-contract-no-perm', [], undefined, appStoreContext, { + app: appStoreContext, + showNotification: () => {}, + }); + const apiOtherPerm = createExtensionApi('xer-source-read-contract-other-perm', ['ribbon', 'events'], undefined, appStoreContext, { + app: appStoreContext, + showNotification: () => {}, + }); + const throwsWithout = (fn: () => unknown): boolean => { + try { fn(); return false; } catch (error) { + return error instanceof Error && /mist permissie: importSource/.test(error.message); + } + }; + eq('P1 zonder permissies gooien alle drie de bronmethoden een permissiefout', [ + throwsWithout(() => apiNoPerm.data.getImportSourceInfo()), + throwsWithout(() => apiNoPerm.data.getImportSourceChunk(0)), + throwsWithout(() => apiNoPerm.data.getImportSourceCatalogPage('taskSourceRows')), + ], [true, true, true]); + eq('P1a een ONgerelateerde permissie (ribbon/events) geeft geen toegang tot importSource', [ + throwsWithout(() => apiOtherPerm.data.getImportSourceInfo()), + throwsWithout(() => apiOtherPerm.data.getImportSourceChunk(0)), + throwsWithout(() => apiOtherPerm.data.getImportSourceCatalogPage('taskSourceRows')), + ], [true, true, true]); + apiNoPerm._cleanup(); + apiOtherPerm._cleanup(); + } + // Let op: dit toetst alléén de SDK-constante, NIET dat een echt manifest de permissie ook door + // de installatievalidatie krijgt — dat gat (een tweede, ontkoppelde permissielijst in + // validation.ts) was precies waar her-review 2 'importSource' onbereikbaar voor elke extensie + // vond. Die dekking staat in tests/planning/check-extension-validation.ts (KNOWN_PERMISSIONS + // door parseExtensionManifest(..., 'fresh') gehaald), niet hier. + eq('P1b importSource staat in de door de app gekende permissies (SDK-constante)', + KNOWN_PERMISSIONS.includes('importSource'), true); + eq('P1c een manifest dat importSource declareert behoudt hem ongewijzigd (geen filtering)', + sanitizeManifestPermissions(['importSource', 'ribbon'], 'x'), ['importSource', 'ribbon']); + + eq('37 een niet-XER-document geeft geen broninfo', api.data.getImportSourceInfo(), null); + eq('37a chunk- en catalogusroute geven zonder XER null', [ + api.data.getImportSourceChunk(0), api.data.getImportSourceCatalogPage('taskSourceRows'), + ], [null, null]); + const applied = useAppStore.getState().applyOpenedImport(opened, { + filePath: null, recompute: false, fit: false, hourDataNotice: false, linkedOpen: true, + }); + const p2DocumentId = applied.documentIds[1]; + if (p2DocumentId) useAppStore.getState().switchDocument(p2DocumentId); + const archive = opened.results[0]?.xerSourceArchive; + if (!archive) throw new Error('Bronfixture mist het retained XER-archive'); + + const info = api.data.getImportSourceInfo(); + const expectedReport = { + projectsSeen: 2, documentsOpened: 2, emptyProjectsSkipped: 0, baselineProjectsExcluded: 0, + baselinesMaterialized: 0, danglingBaselineReferences: 0, externalLinksPreserved: 0, + baselineExclusionReverted: false, baselineFallbackReasons: [], + }; + eq('37 XER-bronsamenvatting is aanwezig op het actieve document', Boolean(info), true); + eq('38 XER-bronsamenvatting heeft exact de actieve selector en archive-identiteit', info && { + sourceFormat: info.sourceFormat, + sourceProjectId: info.sourceProjectId, + selector: info.selector, + archive: info.archive, + numberFormat: info.numberFormat, + importReport: info.importReport, + }, info && { + sourceFormat: 'primavera-p6-xer', + sourceProjectId: 'P-2', + selector: { kind: 'sourceProjectId', value: 'P-2' }, + archive: { + schemaVersion: 1, byteLength: archive.byteLength, sha256: archive.sha256, + encoding: 'utf-8', bom: 'utf-8', newline: 'crlf', chunkSize: 196608, chunkCount: 1, + }, + numberFormat: { decimal: '.', group: null, source: 'default', currencyCode: 'EUR' }, + importReport: expectedReport, + }); + eq('39 XER-samenvatting bevat diagnostics/schedule-options/catalogustellingen', info && { + diagnostics: info.diagnostics, + scheduleOptions: info.scheduleOptions, + catalogs: info.catalogs, + }, { + diagnostics: { + file: { + tableReport: { encoding: 'utf-8', endMarkerSeen: true, issueCount: 0, unknownTableCount: 0, unknownFieldCount: 0 }, + scheduleOptionsDiagnosticCount: 0, relationResolutionIssueCount: 0, + resourceCatalogIssueCount: 0, metadataCatalogIssueCount: 0, + }, + document: { + calendarIssueCount: 0, enumFallbackCount: 0, scheduleOptionsFallbackCount: 0, + scheduleOptionsDiagnosticCount: 0, externalRelationCount: 0, externalLinkCount: 0, + resourceAssignmentCount: 0, resourceIssueCount: 0, + }, + }, + scheduleOptions: { + source: 'xer-defaults', retainedSource: {}, fallbackCount: 0, diagnosticCount: 0, + sourceRowCount: 1, unmatchedSourceRowCount: 0, + }, + catalogs: { + scheduleOptions: { sourceRows: 2, unmatchedRows: 0, diagnostics: 0 }, + resources: { + resources: 0, identities: 0, + rows: { resources: 0, roles: 0, rates: 0, curves: 0, assignments: 0 }, issues: 0, + }, + metadata: { + activityCodeTypes: 1, customFieldDefs: 0, taskProjections: 1, + currentProjectTaskProjections: 1, issues: 0, + issueCounts: Object.fromEntries(Object.keys(info?.catalogs.metadata.issueCounts ?? {}).map(key => [key, 0])), + sourceData: Object.fromEntries(Object.entries(info?.catalogs.metadata.sourceData ?? {}).map(([key, count]) => [key, key === 'ACTVTYPE' || key === 'ACTVCODE' || key === 'TASKACTV' ? 1 : count])), + }, + taskSourceRows: { projectCount: 2, totalRows: 3, currentProjectRows: 2 }, + }, + }); + + const chunks: Uint8Array[] = []; + for (let index = 0; index < (info?.archive.chunkCount ?? 0); index += 1) { + const chunk = api.data.getImportSourceChunk(index); + if (chunk) chunks.push(chunk); + } + const reconstructed = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.byteLength, 0)); + chunks.reduce((offset, chunk) => { reconstructed.set(chunk, offset); return offset + chunk.byteLength; }, 0); + eq('40 bronchunks reconstrueren exact de XER-bytes en digest', { + byteLength: reconstructed.byteLength, sha256: sha256Hex(reconstructed), bytes: [...reconstructed], + }, { byteLength: archive.byteLength, sha256: archive.sha256, bytes: [...decodeXerSourceArchive(archive)] }); + + let invalidIndex = false; + try { api.data.getImportSourceChunk(-1); } catch (error) { invalidIndex = error instanceof RangeError; } + let invalidFraction = false; + try { api.data.getImportSourceChunk(0.5); } catch (error) { invalidFraction = error instanceof RangeError; } + eq('41 ongeldige chunkindexen worden fail-closed gevalideerd', [invalidIndex, invalidFraction], [true, true]); + + const firstPage = api.data.getImportSourceCatalogPage('taskSourceRows', { offset: 0, limit: 1 }); + const secondPage = api.data.getImportSourceCatalogPage('taskSourceRows', { offset: 1, limit: 1 }); + const cell = (row: { cells?: unknown } | undefined, key: string): unknown => { + const cells = row?.cells; + return cells && typeof cells === 'object' ? (cells as Record)[key] : undefined; + }; + eq('42 task-bronrijen zijn pagineerbaar en documentgebonden', { + first: firstPage && { offset: firstPage.offset, limit: firstPage.limit, total: firstPage.total, ids: firstPage.items.map(row => cell(row, 'task_id')) }, + second: secondPage && { offset: secondPage.offset, limit: secondPage.limit, total: secondPage.total, ids: secondPage.items.map(row => cell(row, 'task_id')) }, + }, { + first: { offset: 0, limit: 1, total: 2, ids: ['T-2'] }, + second: { offset: 1, limit: 1, total: 2, ids: ['T-3'] }, + }); + const schedulePage = api.data.getImportSourceCatalogPage('scheduleOptionsSourceRows', { limit: 500 }); + eq('43 schedule-options-pagina gebruikt alleen de actieve projectbronrij', schedulePage?.items.map(row => cell(row, 'proj_id')), ['P-2']); + const metadataPage = api.data.getImportSourceCatalogPage('metadataSourceActvtypeRows'); + const activityTypePage = api.data.getImportSourceCatalogPage('metadataActivityCodeTypes'); + eq('43b retained metadata-catalogi en bronrijen zijn gekopieerd pagineerbaar', { + raw: metadataPage && { total: metadataPage.total, id: cell(metadataPage.items[0], 'actv_code_type_id') }, + normalized: activityTypePage && { total: activityTypePage.total, id: activityTypePage.items[0]?.id }, + }, { raw: { total: 1, id: 'TYPE' }, normalized: { total: 1, id: 'TYPE' } }); + eq('43a bronroute bevat geen generiek bron-writepad', Object.keys(api.data) + .filter(key => key.toLowerCase().includes('importsource')).sort(), [ + 'getImportSourceCatalogPage', 'getImportSourceChunk', 'getImportSourceInfo', + ]); + + let invalidRange = false; + try { api.data.getImportSourceCatalogPage('taskSourceRows', { offset: -1 }); } catch (error) { invalidRange = error instanceof RangeError; } + let oversizedRange = false; + try { api.data.getImportSourceCatalogPage('taskSourceRows', { limit: 501 }); } catch (error) { oversizedRange = error instanceof RangeError; } + let invalidCollection = false; + try { api.data.getImportSourceCatalogPage('onbekend' as never); } catch (error) { invalidCollection = error instanceof RangeError; } + eq('44 ongeldige catalogusvragen worden fail-closed gevalideerd', [invalidRange, oversizedRange, invalidCollection], [true, true, true]); + + // ── P2-fix: offset + limit blijft een safe integer, ook aan de rand ────── + // Vóór de fix duwde `records.slice(offset, offset + limit)` een losstaand grote offset zo het + // safe integer-bereik uit. `taskSourceRows` op het actieve document (P-2) telt 2 rijen; een + // offset voorbij het eind moet canoniseren naar `total` — een lege, geldige laatste pagina — in + // plaats van te gooien of een numeriek onveilige slice te maken. Mutatiebewijs: verwijder + // `resolvePageOffset` (of geef `rawOffset` rechtstreeks aan `records.slice` mee) en de eerste + // vier `total`-asserties hieronder wijken af van `2`, terwijl de oude code hier ook geen fout gaf + // — precies de stille modus die de review aanwees. + const maxSafeOffsetPage = api.data.getImportSourceCatalogPage('taskSourceRows', { offset: Number.MAX_SAFE_INTEGER }); + eq('P2 offset Number.MAX_SAFE_INTEGER canoniseert naar total i.p.v. te gooien of te overflowen', { + offset: maxSafeOffsetPage?.offset, total: maxSafeOffsetPage?.total, items: maxSafeOffsetPage?.items, + }, { offset: 2, total: 2, items: [] }); + + const nearOverflowOffset = Number.MAX_SAFE_INTEGER - EXT_IMPORT_SOURCE_PAGE_SIZE_MAX + 1; + const nearOverflowPage = api.data.getImportSourceCatalogPage('taskSourceRows', { + offset: nearOverflowOffset, limit: EXT_IMPORT_SOURCE_PAGE_SIZE_MAX, + }); + eq('P2a offset Number.MAX_SAFE_INTEGER - limit + 1 (net over de overflowgrens van offset+limit) canoniseert ook', { + offset: nearOverflowPage?.offset, total: nearOverflowPage?.total, items: nearOverflowPage?.items, + }, { offset: 2, total: 2, items: [] }); + + // Zelfde twee grenzen, maar dan op een lege (bestaande maar 0-record) collectie: `total` is 0, dus + // canoniseren moet naar 0 gaan — niet naar de offset zelf en niet naar een negatief getal. + const emptyCollectionAtMax = api.data.getImportSourceCatalogPage('resourceCatalogIssues', { offset: Number.MAX_SAFE_INTEGER }); + eq('P2b een offset voorbij het eind van een LEGE collectie canoniseert naar 0, niet naar de offset', + emptyCollectionAtMax && { offset: emptyCollectionAtMax.offset, total: emptyCollectionAtMax.total }, + { offset: 0, total: 0 }); + + const beforeMutation = api.data.getImportSourceInfo() as ExtImportSourceInfo; + const mutableInfo = api.data.getImportSourceInfo() as unknown as { archive: { sha256: string }; catalogs: { taskSourceRows: { totalRows: number } } }; + mutableInfo.archive.sha256 = 'veranderd'; + mutableInfo.catalogs.taskSourceRows.totalRows = 999; + const mutablePage = api.data.getImportSourceCatalogPage('taskSourceRows', { limit: 1 }); + if (mutablePage?.items[0]?.cells) (mutablePage.items[0].cells as Record).task_id = 'veranderd'; + const mutableChunk = api.data.getImportSourceChunk(0); + if (mutableChunk) mutableChunk[0] ^= 0xff; + eq('45 info/page/chunk zijn verse kopieën zonder mutabele alias', { + info: api.data.getImportSourceInfo(), + taskId: cell(api.data.getImportSourceCatalogPage('taskSourceRows', { limit: 1 })?.items[0], 'task_id'), + digest: api.data.getImportSourceInfo()?.archive.sha256, + chunkDigest: (() => { const chunk = api.data.getImportSourceChunk(0); return chunk ? sha256Hex(chunk) : null; })(), + }, { + info: beforeMutation, + taskId: 'T-2', + digest: archive.sha256, + chunkDigest: sha256Hex(decodeXerSourceArchive(archive).subarray(0, archive.byteLength)), + }); + + const visible = api.data.getTasks()[0]; + eq('46 alle acht P6-taakvelden blijven zichtbaar via toExtTask', visible && [ + visible.p6DurationType, visible.p6ActivityType, visible.p6ProjectId, visible.p6TaskId, + visible.p6ExplicitTargetWindow, visible.p6CompletePctType, visible.p6ExpectedFinish, + visible.p6SuspendResume, + ], ['DT_FixedDUR2', 'TT_Task', 'P-2', 'T-2', true, undefined, undefined, undefined]); + + const hostileImportTask = { ...toExtTask(VOL_TASK), id: 'T-foreign', name: 'Generieke import' }; + api.data.loadProject({ + project: toExtProject(VOL_PROJECT), calendar: toExtCalendar(VOL_CALENDAR), tasks: [hostileImportTask], + sequences: [], resources: [], assignments: [], + }); + eq('47 generieke loadProject activeert geen P6-provenance', [ + useAppStore.getState().tasks[0]?.p6DurationType, useAppStore.getState().tasks[0]?.p6ActivityType, + useAppStore.getState().tasks[0]?.p6ProjectId, useAppStore.getState().tasks[0]?.p6TaskId, + useAppStore.getState().tasks[0]?.p6ExplicitTargetWindow, useAppStore.getState().tasks[0]?.p6CompletePctType, + useAppStore.getState().tasks[0]?.p6ExpectedFinish, useAppStore.getState().tasks[0]?.p6SuspendResume, + ], [undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined]); + + // ── P2-fix: fail-closed documentdrift bij pagineren (her-review 2) ──────── + // Zonder bewaking geeft een pagineersessie na een `switchDocument` stil een lege pagina van het + // VERKEERDE project terug: page 1 op P-2, wisselen naar P-1, page 2 (offset:1) levert dan + // {sourceProjectId:'P-1', total:1, items:[]} zonder enige waarschuwing — een extensie die dat als + // "klaar, geen records meer" leest, heeft in werkelijkheid twee projecten door elkaar gehaald. + // `expectedSourceProjectId` maakt dit fail-closed. Casus (a) hieronder: het actieve document heeft + // ná de hostile `loadProject` hierboven HELEMAAL geen XER-bron meer (`archive === null`) — de + // andere risicoklasse (drift naar een niet-XER-document, niet alleen naar een ander XER-project). + { + let noArchiveDrift: ExtImportSourceDriftError | null = null; + try { + api.data.getImportSourceCatalogPage('taskSourceRows', { expectedSourceProjectId: 'P-2' }); + } catch (error) { noArchiveDrift = error instanceof ExtImportSourceDriftError ? error : null; } + eq('D1 drift naar een document ZONDER XER-bron gooit i.p.v. stil null terug te geven', { + isDriftError: noArchiveDrift !== null, + message: noArchiveDrift && [/P-2/.test(noArchiveDrift.message), /geen XER-document/.test(noArchiveDrift.message)], + }, { isDriftError: true, message: [true, true] }); + eq('D1a zonder expectedSourceProjectId blijft "geen archief" gewoon null geven (ongewijzigd gedrag)', + api.data.getImportSourceCatalogPage('taskSourceRows'), null); + } + + // loadProject is deliberately a normal generic load and must not erase/overwrite the XER source + // route through an implicit write API. The route is gone with the replaced document; a later + // document switch must still restore the retained source of the other XER document. + const xerDocumentId = applied.documentIds[0]; + if (xerDocumentId) useAppStore.getState().switchDocument(xerDocumentId); + eq('48 documentwissel herstelt de XER-selector en bronroute', { + selector: api.data.getImportSourceInfo()?.sourceProjectId, + taskId: cell(api.data.getImportSourceCatalogPage('taskSourceRows', { limit: 1 })?.items[0], 'task_id'), + }, { selector: 'P-1', taskId: 'T-1' }); + + // Casus (b): het actieve document heeft nu weer een archief (P-1) — vraag alsnog om een pagina + // met de VERWACHTING van het vorige project (P-2). Mutatiebewijs: verwijder de + // `assertNoImportSourceDrift`-aanroep uit `getExtImportSourceCatalogPage`/de wrapper in + // extensionApi.ts en D2/D2a kleuren rood (de aanroep slaagt dan gewoon met een pagina van P-1). + { + let wrongProjectDrift: ExtImportSourceDriftError | null = null; + try { + api.data.getImportSourceCatalogPage('taskSourceRows', { offset: 1, expectedSourceProjectId: 'P-2' }); + } catch (error) { wrongProjectDrift = error instanceof ExtImportSourceDriftError ? error : null; } + eq('D2 drift tussen twee XER-documenten gooit i.p.v. een lege pagina van het verkeerde project', { + isDriftError: wrongProjectDrift !== null, + message: wrongProjectDrift && [/P-2/.test(wrongProjectDrift.message), /P-1/.test(wrongProjectDrift.message)], + }, { isDriftError: true, message: [true, true] }); + + const withExpectation = api.data.getImportSourceCatalogPage('taskSourceRows', { expectedSourceProjectId: 'P-1' }); + const withoutExpectation = api.data.getImportSourceCatalogPage('taskSourceRows'); + eq('D3 een KLOPPENDE expectedSourceProjectId gedraagt zich identiek aan zonder de optie', { + withExpectation: withExpectation && { sourceProjectId: withExpectation.sourceProjectId, total: withExpectation.total }, + withoutExpectation: withoutExpectation && { sourceProjectId: withoutExpectation.sourceProjectId, total: withoutExpectation.total }, + }, { + withExpectation: { sourceProjectId: 'P-1', total: 1 }, + withoutExpectation: { sourceProjectId: 'P-1', total: 1 }, + }); + } + api._cleanup(); +} + +// ── 8. De contract-versiepoort (los van minAppVersion) ─────────────────────── // CalVer draagt geen breaking-change-signaal; `apiVersion` doet dat wel. De poort moet in BEIDE // richtingen dicht: een extensie voor een oudere major mist de brekende wijziging, een voor een // nieuwere rekent op iets dat er niet is. { - eq('30 de host-API-versie is een geldige semver', /^\d+\.\d+\.\d+$/.test(EXTENSION_API_VERSION), true); + eq('32 de host-API-versie is een geldige semver', /^\d+\.\d+\.\d+$/.test(EXTENSION_API_VERSION), true); // Legacy: geen apiVersion ⇒ toegestaan, maar herkenbaar als legacy. eq('31 geen apiVersion ⇒ toegestaan', checkApiCompatibility(undefined, '1.2.0').ok, true); diff --git a/tests/planning/check-extension-validation.ts b/tests/planning/check-extension-validation.ts index 40b8168a4..099322fde 100644 --- a/tests/planning/check-extension-validation.ts +++ b/tests/planning/check-extension-validation.ts @@ -23,6 +23,7 @@ import { type ExtensionStorage, } from '@/extensions/extensionLoader'; import { createAppStoreContext, useAppStore } from '@/state/appStore'; +import { KNOWN_PERMISSIONS } from '@/extensions/permissions'; const diffs: string[] = []; let checks = 0; @@ -141,6 +142,24 @@ expectFail('onbekende categorie', { ...volledig(), category: 'Security' }); expectFail('onbekende fresh permission', { ...volledig(), permissions: ['ribbon', 'commands'] }); expectFail('permissions is geen array', { ...volledig(), permissions: 'ribbon' }); expectFail('permission is geen string', { ...volledig(), permissions: ['ribbon', 1] }); + +// ── 2a. `permissions.ts` (KNOWN_PERMISSIONS) is de ENE bron voor deze validator ───────────── +// Her-review 2 vond een tweede, handgekopieerde permissielijst in validation.ts die +// 'importSource' miste: de permissie bestond in de guard-tabel en KNOWN_PERMISSIONS, maar géén +// installatiepad (ZIP/catalogus, los .js, dev-bridge) kon een manifest met die permissie ooit +// parsen — 'fresh' weigerde hem exact als de typefout 'bogus'. Dit haalt ELKE permissie die de +// app kent door de echte 'fresh'-validatieroute en eist dat hij overleeft; een nieuwe permissie +// die alleen aan permissions.ts wordt toegevoegd kan dus niet meer stil onbereikbaar blijven. +// Mutatiebewijs: ontkoppel `EXTENSION_PERMISSIONS` in validation.ts weer van `KNOWN_PERMISSIONS` +// (bv. terug naar de oude losse lijst zonder 'importSource') en dit blok kleurt rood. +for (const permissie of KNOWN_PERMISSIONS) { + const parsed = expectOk(`KNOWN_PERMISSIONS bevat een bereikbare permissie: ${permissie}`, + parseExtensionManifest({ ...volledig(), permissions: [permissie] }, 'fresh')); + if (parsed) { + eq(`${permissie}: fresh-parse bewaart exact deze permissie`, parsed.value.permissions, [permissie]); + } +} +expectFail('een niet-gekende permissie blijft geweigerd (regressiegrens)', { ...volledig(), permissions: ['bogus'] }); for (const veld of ['name', 'version', 'minAppVersion', 'author', 'description', 'category', 'main', 'permissions']) { expectFail(`fresh vereist ${veld}`, { ...volledig(), [veld]: undefined }); } diff --git a/tests/planning/check-fidelity-core.ts b/tests/planning/check-fidelity-core.ts new file mode 100644 index 000000000..7f2c56deb --- /dev/null +++ b/tests/planning/check-fidelity-core.ts @@ -0,0 +1,85 @@ +import { + addFidelityCounts, + classify, + classifyExact, + compareFidelityRow, + countFidelityAxis, + emptyFidelityCounts, +} from './fidelityCore'; + +const diffs: string[] = []; +let checks = 0; + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +// Breuk die dit vangt: minuutvergelijking terugbrengen tot alleen de kalenderdag. +eq('1 minuut-exact', classify('2026-01-05T08:00', '2026-01-05T08:00'), 'exact'); +eq('1a zelfde dag maar andere minuut', classify('2026-01-05T08:01', '2026-01-05T08:00'), 'sameday'); +eq('1b andere dag', classify('2026-01-06T08:00', '2026-01-05T08:00'), 'diff'); +eq('1c ontbrekend orakel', classify('2026-01-05T08:00', null), 'missing'); +eq('1d scalaire gelijkheid gebruikt geen datumdagvergelijking', classifyExact('75', '75'), 'exact'); +eq('1e scalaire afwijking is direct diff', classifyExact('76', '75'), 'diff'); + +const rows = [ + compareFidelityRow('A', { + es: { ours: '2026-01-05T08:00', truth: '2026-01-05T08:00' }, + ef: { ours: '2026-01-05T17:00', truth: '2026-01-05T17:00' }, + }), + compareFidelityRow('B', { + es: { ours: undefined, truth: '2026-01-06T08:00' }, + ef: { ours: '2026-01-06T17:00', truth: null }, + }), +]; + +// Breuk die dit vangt: een ontbrekende lezerswaarde verlaagt stil het meetbaar-aantal of telt niet +// als afwijking, terwijl het orakelveld wel degelijk aanwezig is. +eq('2 rijvorm bewaart identiteit en asuitspraak', rows[1], { + identity: 'B', + axes: { + es: { ours: undefined, truth: '2026-01-06T08:00', verdict: 'missing' }, + ef: { ours: '2026-01-06T17:00', truth: null, verdict: 'missing' }, + }, +}); +eq('2a asadministratie telt orakelcellen onafhankelijk van onze dekking', countFidelityAxis(rows, 'es'), { + exact: 1, + sameday: 0, + diff: 0, + missing: 1, + measurable: 2, + deviations: 1, +}); +eq('2b ontbrekend orakel is niet meetbaar en niet afwijkend', countFidelityAxis(rows, 'ef'), { + exact: 1, + sameday: 0, + diff: 0, + missing: 1, + measurable: 1, + deviations: 0, +}); + +// Breuk die dit vangt: de per-project-lus overschrijft de vorige projecttelling in plaats van de +// bestandssom te maken. +const total = emptyFidelityCounts(); +addFidelityCounts(total, countFidelityAxis(rows.slice(0, 1), 'es')); +addFidelityCounts(total, countFidelityAxis(rows.slice(1), 'es')); +eq('3 per-projecttellers tellen op tot één bestandssom', total, { + exact: 1, + sameday: 0, + diff: 0, + missing: 1, + measurable: 2, + deviations: 1, +}); + +if (diffs.length === 0) { + console.log(`OK fidelity-core: ${checks} checks groen`); +} else { + console.log(`XX fidelity-core: ${diffs.length} afwijking(en) van ${checks}`); + for (const diff of diffs) console.log(` XX ${diff}`); + process.exit(1); +} diff --git a/tests/planning/check-ifc-roundtrip.ts b/tests/planning/check-ifc-roundtrip.ts index 4b547ed0c..425a82cc9 100644 --- a/tests/planning/check-ifc-roundtrip.ts +++ b/tests/planning/check-ifc-roundtrip.ts @@ -73,6 +73,7 @@ import { writeIFC } from '@/services/ifc/ifcWriter'; import { readIFC } from '@/services/ifc/ifcReader'; +import { solveProject } from '@/engine/scheduler/solveProject'; import { ALL_RECORDED_SLOT_KEYS, IFC_TASKTIME_SLOTS, TASKTIME_SLOT, IFC_TASK_SLOTS, TASK_SLOT } from '@/services/ifc/ifcTaskSlots'; import type { Task, TaskTime, ExternalLink, TaskSplitGap, TaskTimephasedContour } from '@/types/task'; import type { Sequence } from '@/types/sequence'; @@ -138,6 +139,9 @@ const projCal = { { name: 'Nieuwjaar', startDate: '2027-01-01', endDate: '2027-01-01' }, ], workingExceptions: [], + p6Source: 'XER', + p6NonWorkPenaltyDates: ['2026-12-27'], + p6NonWorkPenaltyDatesState: 'VALID_VALUES', generation: PROJ_GEN, shift: 'SECOND', libraryOrigin: { companyId: 'c-fixture', libraryItemId: 'lib-projcal', poolVersion: 4 }, } satisfies Omit, 'workTime' | 'simpleBreakStartMinute' | 'simpleBreakDurationMinutes'>; @@ -149,6 +153,9 @@ const libCal = { { name: 'Overwerkdag', startDate: '2026-08-08', endDate: '2026-08-08', bands: [{ start: 360, end: 720 }] }, { name: 'Verschoven werkdag', startDate: '2026-08-15', endDate: '2026-08-15' }, ], + p6Source: 'XER', + p6NonWorkPenaltyDates: [], + p6NonWorkPenaltyDatesState: 'VALID_EMPTY', generation: LIB_GEN, shift: 'THIRD', libraryOrigin: { companyId: 'c-fixture', libraryItemId: 'lib-libcal', poolVersion: 4 }, } satisfies Omit, 'workTime' | 'simpleBreakStartMinute' | 'simpleBreakDurationMinutes'>; @@ -163,6 +170,9 @@ const _CALENDAR_FIELD_WITNESS = { workStartHour: 8, workEndHour: 16, hoursPerDay: 8, holidays: [], simpleBreakStartMinute: 720, simpleBreakDurationMinutes: 30, workingExceptions: [{ name: 'w', startDate: '2026-01-01', endDate: '2026-01-01', bands: [{ start: 0, end: 60 }] }], + p6Source: 'XER', + p6NonWorkPenaltyDates: [], + p6NonWorkPenaltyDatesState: 'VALID_EMPTY', generation: PROJ_GEN, shift: 'FIRST', libraryOrigin: { companyId: 'c-fixture', libraryItemId: 'lib-witness', poolVersion: 1 }, workTime: { byWeekday: { 1: [{ start: 480, end: 960 }], 2: [], 3: [], 4: [], 5: [], 6: [], 7: [] } }, @@ -259,6 +269,12 @@ const TM = { ] satisfies TaskTimephasedContour[], // Z14b — MSP's eigen task-type/effort-driven-vlag (eigenaarsbesluit 2026-08-18, punt 1). mspTaskType: 'FIXED_WORK', effortDriven: true, + // X0/X7 (XER-etappeplan, 2026-08-20) — duration/activity blijven tot X9 zonder IFC-pset; + // de vijf X7-firewall-/bronvelden hieronder round-trippen al wel exact via OPS_P6Progress. + p6DurationType: 'DT_FixedDUR2', p6ActivityType: 'TT_Rsrc', + p6ProjectId: 'P1', p6TaskId: 'T1', p6ExplicitTargetWindow: true, p6CompletePctType: 'CP_Phys', p6ExpectedFinish: '2026-07-31T17:00', + p6SuspendResume: true, + isSummary: false, parentId: 't-p', childIds: [], resourceIds: [], // milestone zonder assignments ⇒ afgeleide resourceIds is leeg (H2-fix) color: '#abcdef', // round-trippt via OPS_TaskAppearance (H2-fix) @@ -314,7 +330,7 @@ const tasks: Task[] = [TP, TM, TX, TY]; // mutueel exclusief (lagPercent wint altijd in de writer en zou lagDays overschrijven). De // veld-volledigheid bewaken we via een type-only getuige; de round-trip-relaties zijn realistisch. const sequences: Sequence[] = [ - { id: 's1', predecessorId: 't-x', successorId: 't-m', type: 'FINISH_START', lagDays: 2, lagUnit: 'ELAPSEDTIME' }, + { id: 's1', predecessorId: 't-x', successorId: 't-m', type: 'FINISH_START', lagDays: 2, lagUnit: 'ELAPSEDTIME', p6StartAtPredecessorFinishBoundary: true }, { id: 's2', predecessorId: 't-x', successorId: 't-y', type: 'START_START', lagDays: 0, lagPercent: 50 }, { id: 's3', predecessorId: 't-y', successorId: 't-m', type: 'FINISH_FINISH', lagDays: 1 }, { id: 's4', predecessorId: 't-x', successorId: 't-m', type: 'START_FINISH', lagDays: 0 }, @@ -323,6 +339,7 @@ const sequences: Sequence[] = [ const _SEQUENCE_FIELD_WITNESS = { id: 'w', predecessorId: 'a', successorId: 'b', type: 'FINISH_START', lagDays: 1, lagMinutes: 60, lagUnit: 'WORKTIME', lagPercent: 25, + p6StartAtPredecessorFinishBoundary: false, } satisfies Required; void _SEQUENCE_FIELD_WITNESS; @@ -358,10 +375,22 @@ const assignments: ResourceAssignment[] = [ // ── Project incl. schedulingOptions/statusDate/progressMode/wbsAutoNumber ───────────────────────── const SCHED_OPTS = { + p6Source: 'XER', lagCalendar: 'successor', - criticalDefinition: { mode: 'longestPath', threshold: -1 }, + criticalDefinition: { mode: 'longestPath', threshold: -1, thresholdHours: -8 }, totalFloatMode: 'finish', makeOpenEndedCritical: true, + useExpectedFinishDates: false, + preserveActualDatesInBackwardPass: true, + clampNegativeFreeFloat: true, + p6ZeroDurationUsesPlannedBoundary: true, + p6UseTaskPlannedStartFloor: true, + p6FinishMilestoneBoundaryWindow: true, + p6PreserveActualInstants: true, + p6UseRemainingStartForProgress: true, + p6PreserveZeroDurationConstraintInstants: true, + p6CompletedLateFromRemainingWindow: true, + useProjectEndDateForFloat: true, nearCriticalThreshold: 3, floatPaths: { enabled: true, method: 'TOTAL_FLOAT', maxPaths: 5 }, resumeFromActualElapsed: true, // T9 (voortgangsafronding): rondt lossless mee als deel van het JSON-blob @@ -495,6 +524,9 @@ const CALENDAR_CANON = { .sort((a, b) => a.name.localeCompare(b.name)) .map(e => canonize(WORKING_EXCEPTION_CANON, e, k)), }, + p6Source: KEEP, + p6NonWorkPenaltyDates: KEEP, + p6NonWorkPenaltyDatesState: KEEP, generation: KEEP, workTime: { skip: 'aanwezig ⇒ UUR-kalender; deze fixture is dag-modus. Uur-round-trip: check-adapters-hours.ts' }, shift: KEEP, @@ -554,6 +586,20 @@ const TASK_CANON = { timephasedContours: KEEP, // Z14b — MSP's eigen task-type/effort-driven-vlag (`OPS_MspTaskType`), puur data, geen verwijzing. mspTaskType: KEEP, effortDriven: KEEP, + // X9 — dezelfde smalle OPS_P6Progress-Pset bewaart ook de twee P6-taaktypen; puur brondata, + // nooit solverinvoer. + p6DurationType: KEEP, + p6ActivityType: KEEP, + // X7-reviewfix: deze vijf velden vormen samen de P6-solverfirewall en bronidentiteit. Ze moeten + // vóór de bredere X9-archiefetappe al exact door IFC heen, anders kan een kale resume na reload + // onbedoeld MSP-semantiek erven. + p6ProjectId: KEEP, + p6TaskId: KEEP, + p6ExplicitTargetWindow: KEEP, + p6CompletePctType: KEEP, + p6ExpectedFinish: KEEP, + p6SuspendResume: KEEP, + isSummary: { skip: 'false is de afwezige default; expliciet true round-trippt in check-xer-reader' }, parentId: { as: 'parent', get: (t: Task, k: Keys) => (t.parentId ? k.task(t.parentId) : null) }, childIds: { as: 'children', get: (t: Task, k: Keys) => t.childIds.map(c => k.task(c)).sort() }, time: { get: (t: Task, k: Keys) => canonize(TIME_CANON, t.time, k) }, @@ -581,6 +627,7 @@ const SEQUENCE_CANON = { predecessorId: { as: 'pred', get: (s: Sequence, k: Keys) => k.task(s.predecessorId) }, successorId: { as: 'succ', get: (s: Sequence, k: Keys) => k.task(s.successorId) }, type: KEEP, lagDays: KEEP, lagMinutes: KEEP, lagUnit: KEEP, lagPercent: KEEP, + p6StartAtPredecessorFinishBoundary: KEEP, } satisfies CanonSpec; const RESOURCE_CANON = { @@ -623,12 +670,13 @@ const PROJECT_CANON = { const BASELINE_TASK_CANON = { taskId: { as: 'task', get: (bt: BaselineTask, k: Keys) => k.task(bt.taskId) }, + sourceTaskId: KEEP, sourceTaskCode: KEEP, start: KEEP, finish: KEEP, duration: KEEP, isMilestone: KEEP, milestoneKind: KEEP, } satisfies CanonSpec; const BASELINE_CANON = { id: KEEP, // baseline-ids round-trippen letterlijk mee (OPS_Baselines-JSON) - name: KEEP, createdAt: KEEP, + name: KEEP, createdAt: KEEP, sourceProjectId: KEEP, tasks: { get: (b: Baseline, k: Keys) => (b.tasks ?? []).map(bt => canonize(BASELINE_TASK_CANON, bt, k)) .sort((x, y) => String(x.task).localeCompare(String(y.task))), @@ -735,6 +783,161 @@ const firstIfc = writeIFC(fixture); const rt1 = readIFC(firstIfc); const rt2 = readIFC(writeIFC(rt1)); +const hasP6BoundarySequence = (input: ImportResult) => + input.sequences.some(sequence => sequence.p6StartAtPredecessorFinishBoundary === true); + +{ + const validIfc = writeIFC(fixture); + const lines = validIfc.split('\n'); + const psetIndex = lines.findIndex(line => line.includes("'OPS_Sequences'")); + assert(psetIndex >= 0, 'OPS_Sequences-pset ontbreekt in de geldige writerfixture'); + const psetId = lines[psetIndex].match(/^#(\d+)=/)?.[1]; + assert(!!psetId, 'STEP-id van OPS_Sequences-pset ontbreekt'); + const relationIndex = lines.findIndex(line => line.includes('IFCRELDEFINESBYPROPERTIES(') + && line.endsWith(`,#${psetId});`)); + assert(relationIndex >= 0, 'IFCRELDEFINESBYPROPERTIES voor OPS_Sequences ontbreekt'); + + const orphan = readIFC(lines.filter((_, index) => index !== relationIndex).join('\n')); + assert(!hasP6BoundarySequence(orphan), + 'een orphan OPS_Sequences-pset zonder relatie naar het live schema blijft inert'); + + const maskedLines = [...lines]; + maskedLines.splice(psetIndex, 0, + "#990001=IFCPROPERTYSINGLEVALUE('P6StartAtPredecessorFinishBoundarySequenceGuids',$,IFCTEXT('[\"masker\"]'),$);", + "#990002=IFCPROPERTYSET('maskerpset00000000000001',#1,'OPS_Sequences',$,(#990001));"); + const masked = readIFC(maskedLines.join('\n')); + assert(hasP6BoundarySequence(masked), + 'een eerdere orphan OPS_Sequences-pset mag de later geldig gekoppelde pset niet maskeren'); + + const baselineScheduleId = lines.find(line => line.includes('IFCWORKSCHEDULE(') && line.includes('.BASELINE.')) + ?.match(/^#(\d+)=/)?.[1]; + assert(!!baselineScheduleId, 'baseline-IFCWORKSCHEDULE ontbreekt in de fixture'); + const wrongLines = [...lines]; + wrongLines[relationIndex] = wrongLines[relationIndex].replace(/,\(#[^)]+\),(#\d+)\);$/, `,(#${baselineScheduleId}),$1);`); + const wrongSchedule = readIFC(wrongLines.join('\n')); + assert(!hasP6BoundarySequence(wrongSchedule), + 'OPS_Sequences op een baseline/verkeerd schema activeert de live relatie niet'); + + const owner = lines[relationIndex].match(/,\((#\d+)\),#\d+\);$/)?.[1]; + assert(!!owner, 'live schema-eigenaar ontbreekt in OPS_Sequences-relatie'); + const duplicateLines = [...lines]; + duplicateLines.splice(relationIndex + 1, 0, + "#990003=IFCPROPERTYSINGLEVALUE('P6StartAtPredecessorFinishBoundarySequenceGuids',$,IFCTEXT('[\"duplicaat\"]'),$);", + "#990004=IFCPROPERTYSET('duplicaatpset000000000001',#1,'OPS_Sequences',$,(#990003));", + `#990005=IFCRELDEFINESBYPROPERTIES('duplicaatrel0000000000001',#1,$,$,(${owner}),#990004);`); + const duplicate = readIFC(duplicateLines.join('\n')); + assert(!hasP6BoundarySequence(duplicate), + 'twee geldig gekoppelde OPS_Sequences-bronnen zijn ambigu en falen gesloten'); +} + +{ + const orphanPenaltyIfc = writeIFC(fixture).split('\n') + .filter(line => !line.includes("IFCPROPERTYSINGLEVALUE('P6Source'")) + .join('\n'); + const orphanPenalty = readIFC(orphanPenaltyIfc); + assert(orphanPenalty.calendar.p6Source === undefined + && orphanPenalty.calendar.p6NonWorkPenaltyDates === undefined, + 'P6NonWorkPenaltyDates zonder P6Source=XER blijft bij IFC-inlees inert en wordt niet gematerialiseerd'); +} + +// ── Eindreview XER-etappe, bevinding 4: het OPS_SchedulingOptions-JSON is buiteninvoer ──────────── +// Een geschreven IFC waarvan het JSON-blob is vervangen door een vijandig object: onbekende sleutels, +// een niet-bestaande kritiek-modus, een string waar een getal hoort, een half `floatPaths`. Alles wat +// niet aan het `SchedulingOptions`-contract voldoet valt weg; wat wél klopt (incl. `p6Source`, dat +// legitiem round-tript) blijft staan. Het commentaar in `CPMSolver.ts` bij `p6SourceActive` beschrijft +// precies deze grens. +{ + const hostile = JSON.stringify({ + p6Source: 'XER', + p6CompletedLateFromRemainingWindow: true, + useProjectEndDateForFloat: 'ja', + preserveActualDatesInBackwardPass: 1, + lagCalendar: 'maan', + totalFloatMode: 'finish', + nearCriticalThreshold: 'drie', + criticalDefinition: { mode: 'NIET_BESTAAND', thresholdHours: 'tekst' }, + floatPaths: { enabled: true, method: 'TOTAL_FLOAT' }, + onzin: [1, 2, 3], + }); + const written = writeIFC(fixture); + const pattern = /IFCPROPERTYSINGLEVALUE\('SchedulingOptions',\$,IFCTEXT\('[^']*'\),\$\)/; + assert(pattern.test(written), 'fixture schrijft het OPS_SchedulingOptions-pset (anker voor de vijandige vervanging)'); + const hostileIfc = written.replace(pattern, `IFCPROPERTYSINGLEVALUE('SchedulingOptions',$,IFCTEXT('${hostile}'),$)`); + const canon = (value: unknown): string => JSON.stringify(value, (_key, v: unknown) => + (v && typeof v === 'object' && !Array.isArray(v)) + ? Object.fromEntries(Object.entries(v as Record).sort(([a], [b]) => (a < b ? -1 : 1))) + : v); + const read = readIFC(hostileIfc).project.schedulingOptions; + assert(canon(read) === canon({ + p6Source: 'XER', + p6CompletedLateFromRemainingWindow: true, + totalFloatMode: 'finish', + }), 'vijandig OPS_SchedulingOptions-JSON: alleen de goed getypeerde, bekende sleutels overleven; onzin-mode, string-getallen, onbekende sleutels en een half floatPaths-blok vallen weg'); + + const hostileCritical = JSON.stringify({ criticalDefinition: { mode: 'longestPath', threshold: 'x', thresholdHours: -4 }, floatPaths: { enabled: false, method: 'FREE_FLOAT', maxPaths: 2 } }); + const partialIfc = written.replace(pattern, `IFCPROPERTYSINGLEVALUE('SchedulingOptions',$,IFCTEXT('${hostileCritical}'),$)`); + assert(canon(readIFC(partialIfc).project.schedulingOptions) === canon({ + criticalDefinition: { mode: 'longestPath', thresholdHours: -4 }, + floatPaths: { enabled: false, method: 'FREE_FLOAT', maxPaths: 2 }, + }), 'geneste blokken: een geldige mode met een ongeldige threshold houdt alleen de geldige velden; een compleet floatPaths-blok blijft heel'); + + const emptyIfc = written.replace(pattern, `IFCPROPERTYSINGLEVALUE('SchedulingOptions',$,IFCTEXT('{"onzin":true}'),$)`); + assert(readIFC(emptyIfc).project.schedulingOptions === undefined, + 'een JSON-blob zonder één geldige sleutel levert géén leeg optieobject maar undefined (default-inert, zoals een afwezig pset)'); +} + +{ + const absentPenaltyIfc = writeIFC(fixture).split('\n') + .filter(line => !line.includes("IFCPROPERTYSINGLEVALUE('P6NonWorkPenaltyDates'")) + .join('\n'); + const absentPenalty = readIFC(absentPenaltyIfc); + assert(absentPenalty.calendar.p6Source === undefined + && absentPenalty.calendar.p6NonWorkPenaltyDates === undefined + && absentPenalty.calendar.p6NonWorkPenaltyDatesState === 'ABSENT', + 'P6Source=XER zonder penaltylijst blijft diagnostisch ABSENT en activeert geen halve P6-kalendersemantiek'); +} + +{ + const rejectedPenaltyIfc = writeIFC(fixture).replace( + '["2026-12-27"]', '["2026-12-27",7]', + ); + const rejectedPenalty = readIFC(rejectedPenaltyIfc); + assert(rejectedPenalty.calendar.p6Source === undefined + && rejectedPenalty.calendar.p6NonWorkPenaltyDates === undefined + && rejectedPenalty.calendar.p6NonWorkPenaltyDatesState === 'REJECTED', + 'één corrupte P6-penaltywaarde wijst de volledige lijst diagnostisch af en laat p6Source niet half actief'); + + const rejectedInput: ImportResult = { + ...fixture, + calendar: { + ...fixture.calendar, + p6Source: undefined, + p6NonWorkPenaltyDates: undefined, + p6NonWorkPenaltyDatesState: 'REJECTED', + }, + }; + const rejectedRoundTrip = readIFC(writeIFC(rejectedInput)); + assert(rejectedRoundTrip.calendar.p6Source === undefined + && rejectedRoundTrip.calendar.p6NonWorkPenaltyDates === undefined + && rejectedRoundTrip.calendar.p6NonWorkPenaltyDatesState === 'REJECTED', + 'REJECTED penaltydiagnostiek rondt inert afzonderlijk door IFC zonder P6-herkomst te herstellen'); + const solveAxes = (input: ImportResult) => { + const solved = solveProject({ + tasks: input.tasks, sequences: input.sequences, calendar: input.calendar, + calendars: input.resourceCalendars ?? [], dataDate: input.project.statusDate, + progressMode: input.project.progressMode, schedulingOptions: input.project.schedulingOptions, + projectStartDate: input.project.startDate, projectEndDate: input.project.endDate, + }); + assert(!solved.error, `solverfout in REJECTED-inertietest: ${solved.error}`); + return input.tasks.map(task => [task.wbsCode, task.time.earlyStart, task.time.earlyFinish, + task.time.lateStart, task.time.lateFinish, task.time.totalFloat, task.time.freeFloat]); + }; + const clean = { ...rejectedRoundTrip, calendar: { ...rejectedRoundTrip.calendar, + p6NonWorkPenaltyDatesState: undefined } }; + assert(JSON.stringify(solveAxes(rejectedRoundTrip)) === JSON.stringify(solveAxes(clean)), + 'REJECTED penaltydiagnostiek is solver-inert'); +} + { const diffs: string[] = []; collectDiffs('', canon(expectedInput), canon(rt1), diffs); @@ -1271,6 +1474,9 @@ const rt2 = readIFC(writeIFC(rt1)); parentId: null, childIds: [], resourceIds: [], time: plainTime('2026-08-18', '2026-08-20', 2), mspTaskType: 'FIXED_DURATION', effortDriven: true, timephasedContours: [{ resourceUid: 3, periods: [{ afterMinutes: 0, minutes: 60, workMinutes: 60, kind: 'actual' }] }], + // X0 (XER-etappeplan, 2026-08-20): drie nieuwe .xer-importvelden, zelfde compact-optionele + // doorgifte als de drie Z14b-velden hierboven. + p6DurationType: 'DT_FixedQty', p6ActivityType: 'TT_LOE', p6ExplicitTargetWindow: true, p6SuspendResume: true, }; const mapped8e = toExtTask(withFields); assert(mapped8e.mspTaskType === 'FIXED_DURATION', `(8e) toExtTask moet mspTaskType doorgeven — kreeg ${mapped8e.mspTaskType}`); @@ -1279,6 +1485,10 @@ const rt2 = readIFC(writeIFC(rt1)); JSON.stringify(mapped8e.timephasedContours) === JSON.stringify(withFields.timephasedContours), `(8e) toExtTask moet timephasedContours doorgeven — kreeg ${JSON.stringify(mapped8e.timephasedContours)}`, ); + assert(mapped8e.p6DurationType === 'DT_FixedQty', `(8e) toExtTask moet p6DurationType doorgeven — kreeg ${mapped8e.p6DurationType}`); + assert(mapped8e.p6ActivityType === 'TT_LOE', `(8e) toExtTask moet p6ActivityType doorgeven — kreeg ${mapped8e.p6ActivityType}`); + assert(mapped8e.p6ExplicitTargetWindow === true, `(8e) toExtTask moet p6ExplicitTargetWindow doorgeven — kreeg ${mapped8e.p6ExplicitTargetWindow}`); + assert(mapped8e.p6SuspendResume === true, `(8e) toExtTask moet p6SuspendResume doorgeven — kreeg ${mapped8e.p6SuspendResume}`); const withoutFields: Task = { id: 't-8e-neg', name: '(8e)-zonder-velden', description: '', wbsCode: '', @@ -1289,6 +1499,10 @@ const rt2 = readIFC(writeIFC(rt1)); assert(mapped8eNeg.mspTaskType === undefined, `(8e) zonder mspTaskType blijft toExtTask.mspTaskType undefined — kreeg ${mapped8eNeg.mspTaskType}`); assert(mapped8eNeg.effortDriven === undefined, `(8e) zonder effortDriven blijft toExtTask.effortDriven undefined — kreeg ${mapped8eNeg.effortDriven}`); assert(mapped8eNeg.timephasedContours === undefined, `(8e) zonder timephasedContours blijft toExtTask.timephasedContours undefined — kreeg ${mapped8eNeg.timephasedContours}`); + assert(mapped8eNeg.p6DurationType === undefined, `(8e) zonder p6DurationType blijft toExtTask.p6DurationType undefined — kreeg ${mapped8eNeg.p6DurationType}`); + assert(mapped8eNeg.p6ActivityType === undefined, `(8e) zonder p6ActivityType blijft toExtTask.p6ActivityType undefined — kreeg ${mapped8eNeg.p6ActivityType}`); + assert(mapped8eNeg.p6ExplicitTargetWindow === undefined, `(8e) zonder p6ExplicitTargetWindow blijft toExtTask.p6ExplicitTargetWindow undefined — kreeg ${mapped8eNeg.p6ExplicitTargetWindow}`); + assert(mapped8eNeg.p6SuspendResume === undefined, `(8e) zonder p6SuspendResume blijft toExtTask.p6SuspendResume undefined — kreeg ${mapped8eNeg.p6SuspendResume}`); // Mutatiebewijs (uitgevoerd): de drie nieuwe regels in `toExtTask` tijdelijk verwijderd maakt de // eerste drie asserties hierboven ROOD (undefined i.p.v. de gezette waarde). diff --git a/tests/planning/check-ifc-xer-archive-container.ts b/tests/planning/check-ifc-xer-archive-container.ts new file mode 100644 index 000000000..3877ebe66 --- /dev/null +++ b/tests/planning/check-ifc-xer-archive-container.ts @@ -0,0 +1,149 @@ +// X9 — IFC-container voor het zelfstandige XER-bronarchief. Geen reader-mock: echte STEP-tekst +// gaat door writeIFC én readIFC. De bron is geldige XER, want schema 2 reconstrueert daaruit. +import { writeIFC } from '@/services/ifc/ifcWriter'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; +import { IfcParseError } from '@/services/ifc/ifcErrors'; +import { + decodeXerSourceArchive, + sha256Hex, +} from '@/services/xerSourceArchive'; +import { readXER } from '@/services/xer/xerReader'; + +declare const process: { exit(code: number): never }; +const failures: string[] = []; +const expect = (label: string, condition: boolean) => { if (!condition) failures.push(label); }; + +const sourceText = [ + 'ERMHDR\t23.12\t2026-08-01\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date', + '%R\tP6-ARCHIVE\tContainerfixture\tC\t2026-08-01 08:00', + '%T\tSCHEDOPTIONS', + '%F\tschedoptions_id\tproj_id\tsched_use_expect_end_flag', + '%R\tSO\tP6-ARCHIVE\tY', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC\tStandaard\t8\t40\t', + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_code\ttask_name\tclndr_id\ttarget_start_date\ttarget_end_date\ttarget_drtn_hr_cnt\ttask_type\tduration_type\tstatus_code', + '%R\tA\tP6-ARCHIVE\tA-1\tArchieftaak\tC\t2026-08-01 08:00\t2026-08-01 16:00\t8\tTT_Task\tDT_FixedDUR2\tTK_NotStart', + '%T\tRSRC', + '%F\trsrc_id\trsrc_name\trsrc_type\tclndr_id\tdef_qty_per_hr', + '%R\tR\tVakman\tRT_Labor\tC\t1', + '%T\tTASKRSRC', + '%F\ttaskrsrc_id\tproj_id\ttask_id\trsrc_id\trole_id\ttarget_qty_per_hr\tremain_qty\ttarget_qty', + '%R\tAS\tP6-ARCHIVE\tA\tR\t\t1\t8\t8', + '%T\tUNKNOWN', + '%F\tpayload', + `%R\t${'x'.repeat(196_608)}`, + '%E', +].join('\r\n'); +const bytes = new TextEncoder().encode(sourceText); +const opened = readXER(bytes); +if ('kind' in opened) throw new Error('Containerfixture moet één XER-document openen'); +const archive = opened.xerSourceArchive; +if (!archive || !opened.xer) throw new Error('Containerfixture mist XER-bronarchief'); +const ifc = writeIFC(opened); + +expect('1 schrijft precies één manifestcontainer', (ifc.match(/OPS_XerSourceArchive/g) ?? []).length === 1); +expect('2 schrijft selector met P6-project', ifc.includes('OPS_XerDocument') && ifc.includes('P6-ARCHIVE')); +expect('3 volle chunk heeft 196608 bytes', archive.byteChunks.length === 2); +const read = readIFC(ifc); +expect('4 archive wordt uit IFC herlezen', read.xerSourceArchive !== undefined); +if (read.xerSourceArchive) { + expect('5 bytes zijn exact gelijk', sha256Hex(decodeXerSourceArchive(read.xerSourceArchive)) === sha256Hex(bytes)); + expect('6 projectselector herleeft naast het archief', read.xerSourceProjectId === 'P6-ARCHIVE'); + expect('6a X5- en X6-bronmateriaal herleeft uit de compacte bronreconstructie', + read.xer?.sourceProjectId === 'P6-ARCHIVE' + && (read.xer.scheduleOptions.sourceRows.length ?? 0) > 0 + && read.xer.resources?.assignments.length === 1 + && read.xer.resources.assignments[0]?.sourceId === 'AS' + && read.xer.resources.assignments[0]?.rawRow.cells.taskrsrc_id === 'AS'); +} +const rejectsArchive = (value: string) => { + try { readIFC(value); return false; } + catch (error) { return error instanceof IfcParseError && error.reason === 'xer-source-archive'; } +}; +const rejectsArchiveWith = (value: string, fragment: string) => { + try { readIFC(value); return false; } + catch (error) { + return error instanceof IfcParseError + && error.reason === 'xer-source-archive' + && error.message.includes(fragment); + } +}; +const replaceIntegerProperty = (content: string, name: string, value: number) => content.replace( + new RegExp(`(IFCPROPERTYSINGLEVALUE\\('${name}',\\$,IFCINTEGER\\()\\d+(\\),\\$\\))`), + `$1${value}$2`, +); +const psetId = (content: string, name: string) => { + const match = new RegExp(`#([A-Za-z0-9_]+)=IFCPROPERTYSET\\([^\\n]*'${name}'`).exec(content); + if (!match) throw new Error(`Pset ${name} ontbreekt in fixture`); + return match[1]!; +}; +const propertyRelationLine = (content: string, setId: string) => { + const line = content.split('\n').find(candidate => + candidate.includes('IFCRELDEFINESBYPROPERTIES(') && candidate.endsWith(`,#${setId});`)); + if (!line) throw new Error(`Relatie voor Pset #${setId} ontbreekt in fixture`); + return line; +}; +expect('7 corrupte base64 valt niet stil terug naar legacy-IFC', rejectsArchive(ifc.replace(archive.byteChunks[0]!, `!${archive.byteChunks[0]!.slice(1)}`))); +expect('8 ontbrekende chunk valt niet stil terug naar legacy-IFC', rejectsArchive(ifc.replace('ByteChunk000001', 'ByteChunkAfwezig'))); +expect('9 mismatchende bronhash valt niet stil terug naar legacy-IFC', rejectsArchive(ifc.replace(archive.sha256, `0${archive.sha256.slice(1)}`))); +expect('10 verkeerde manifestchunkgrootte valt niet stil terug naar legacy-IFC', rejectsArchive(ifc.replace('IFCINTEGER(196608)', 'IFCINTEGER(196607)'))); +expect('10a onbekende archiefschemaversie wordt getypeerd geweigerd', + rejectsArchiveWith(replaceIntegerProperty(ifc, 'SchemaVersion', 3), 'SchemaVersion')); +expect('10b mismatchende ByteLength wordt vóór samenvoegen getypeerd geweigerd', + rejectsArchiveWith(ifc.replace(`IFCINTEGER(${bytes.length})`, `IFCINTEGER(${bytes.length + 1})`), 'heeft')); +const reorderedChunks = ifc + .replace('ByteChunk000000', 'ByteChunkTMP') + .replace('ByteChunk000001', 'ByteChunk000000') + .replace('ByteChunkTMP', 'ByteChunk000001'); +expect('10c verwisselde chunkvolgorde wordt vóór decode getypeerd geweigerd', + rejectsArchiveWith(reorderedChunks, 'geordend')); +const validButChangedChunk = `${archive.byteChunks[0]![0] === 'A' ? 'B' : 'A'}${archive.byteChunks[0]!.slice(1)}`; +expect('10d geldige base64 met gewijzigde bytes faalt op SHA', + rejectsArchiveWith(ifc.replace(archive.byteChunks[0]!, validButChangedChunk), 'Sha256')); +expect('10e selector naar ontbrekende documentview wordt nooit stil geaccepteerd', + rejectsArchiveWith(ifc.replace("IFCTEXT('P6-ARCHIVE')", "IFCTEXT('P6-ONTBREEKT')"), 'documentview')); +const hugeCount = 4_294_967_296; +const hugeLength = hugeCount * 196_608; +const hugeBytes = replaceIntegerProperty( + replaceIntegerProperty(ifc, 'ByteLength', hugeLength), + 'ByteChunkCount', hugeCount, +); +expect('11 enorme coherente bytecount wordt vóór Array.from/allocatie getypeerd geweigerd', + rejectsArchiveWith(hugeBytes, 'propertybudget')); +const injectedDiagnostics = ifc.replace('ByteChunk000000', 'DiagnosticsChunk000000'); +expect('12 schema-2 accepteert geen ingespoten uitgebreide diagnosticschunk', + rejectsArchiveWith(injectedDiagnostics, 'geordend')); + +const projectLine = ifc.split('\n').find(line => line.includes('=IFCPROJECT(')); +if (!projectLine) throw new Error('IFCPROJECT ontbreekt in fixture'); +const duplicateProject = ifc.replace(projectLine, `${projectLine}\n${projectLine.replace(/^#[A-Za-z0-9_]+=/, '#999991=')}`); +expect('13 archive-IFC met twee IFCPROJECT-entiteiten wordt geweigerd', + rejectsArchiveWith(duplicateProject, 'exact één IFCPROJECT')); + +const selectorSetId = psetId(ifc, 'OPS_XerDocument'); +const selectorRelation = propertyRelationLine(ifc, selectorSetId); +const danglingSelector = ifc.replace(selectorRelation, selectorRelation.replace(/\(#[A-Za-z0-9_]+\),#[A-Za-z0-9_]+\);$/, '(#999992),#' + selectorSetId + ');')); +expect('14 selectorrelatie naar een dangling project wordt geweigerd', + rejectsArchiveWith(danglingSelector, 'hangt niet één-op-één')); + +const archiveSetId = psetId(ifc, 'OPS_XerSourceArchive'); +const duplicateRelation = ifc.replace( + selectorRelation, + selectorRelation.replace(new RegExp(`#${selectorSetId}\\);$`), `#${archiveSetId});`), +); +expect('15 dubbele propertyrelatie naar het archief wordt geweigerd', + rejectsArchiveWith(duplicateRelation, 'hangt niet één-op-één')); + +const legacyIfc = writeIFC({ project: opened.project, calendar: opened.calendar, tasks: [], sequences: [], resources: [], assignments: [] }); +const legacyRead = readIFC(legacyIfc); +expect('16 geldige oudere IFC zonder XER-Psets blijft legacy-compatibel', + legacyRead.xerSourceArchive === undefined && legacyRead.xerSourceProjectId === undefined && legacyRead.xer === undefined); + +if (failures.length === 0) { console.log('OK ifc-xer-archive-container: alle checks groen (22)'); process.exit(0); } +console.log(`XX ifc-xer-archive-container: ${failures.length} afwijking(en)`); +for (const failure of failures) console.log(` - ${failure}`); +process.exit(1); diff --git a/tests/planning/check-milestone-duration-render.ts b/tests/planning/check-milestone-duration-render.ts index c37b99790..fd7836a8e 100644 --- a/tests/planning/check-milestone-duration-render.ts +++ b/tests/planning/check-milestone-duration-render.ts @@ -55,7 +55,15 @@ function makeCtx(): { ctx: CanvasRenderingContext2D; roundRects: RRect[]; texts: } // ── Scenario: een taak met echte duur, een mijlpaal-met-duur, en een ECHTE mijlpaal ── +// Weekend-flake (gemeten op main): zowel `project.startDate` als `view.viewStartDate` defaulten op +// "vandaag" (`defaults.ts`). Op een zaterdag/zondag schuift de solver de taak naar de eerstvolgende +// werkdag (maandag) terwijl het view-origin op het weekend blijft staan — de balk verschuift dan +// pixels naar rechts terwijl de hittest hieronder op een vaste x = TTW + 20 prikt. Vaste maandag +// (in de stijl van de andere fixtures in dit bestand, bv. check-bar-colors.ts's '2026-01-05') maakt +// de fixture dag-onafhankelijk: project- én view-start liggen op dezelfde, altijd-werkende dag. +const FIXED_MONDAY = '2026-01-05'; S().newProject(); +S().setProject({ startDate: FIXED_MONDAY }); S().addTask({ name: 'Gewoon' }); S().runCPM(); const base = S().tasks[0]; @@ -102,7 +110,9 @@ const rows: ViewRow[] = [ const W = 1200, H = 600, TTW = 0, ROWH = 28, HDRH = 60; const st = S(); -const view = { ...st.view, scrollX: 0, scrollY: 0 }; +// Zelfde vaste maandag als het view-origin, zodat de balk exact bij pixel 0 begint — net als op een +// gewone werkdag, maar nooit afhankelijk van de kalenderdag waarop de suite draait. +const view = { ...st.view, scrollX: 0, scrollY: 0, viewStartDate: FIXED_MONDAY }; const { ctx, roundRects } = makeCtx(); const renderer = new GanttRenderer(ctx, { diff --git a/tests/planning/check-move-project.ts b/tests/planning/check-move-project.ts index f13652423..b4cdb6883 100644 --- a/tests/planning/check-move-project.ts +++ b/tests/planning/check-move-project.ts @@ -296,6 +296,56 @@ S().redo(); eq('134 redo verschuift opnieuw, inclusief de projectdatum', S().project.startDate, '2026-06-22'); eq('135 redo herstelt het taakanker', S().tasks.find(t => t.id === tA)?.time.scheduleStart, '2026-06-22'); +// --- X7/Hooke: actieve P6 Expected Finish volgt exact hetzelfde expliciete shift-verdict. --- +// Dit is bewust een echte store-actie (niet alleen een shiftIso-unitcheck): met de projectoptie aan +// leest de solver p6ExpectedFinish als absoluut P6-bronanker. Een projectverschuiving die het veld +// laat staan, laat de aansluitende runCPM dus opnieuw naar de OUDE datum terugsturen. +S().newProject(); +S().setProject({ + startDate: '2026-08-03', + schedulingOptions: { useExpectedFinishDates: true }, +}); +const expectedDateTask = S().addTask({ + name: 'P6 verwacht einde dag', status: 'STARTED', + time: { + ...createDefaultTaskTime('2026-08-03', 8), actualStart: '2026-08-03', + actualDuration: 2, remainingTime: 6, completion: 0.25, + }, +}); +const expectedHourTask = S().addTask({ + name: 'P6 verwacht einde uur', status: 'STARTED', + time: { + ...createDefaultTaskTime('2026-08-03T09:45', 8), actualStart: '2026-08-03T09:45', + actualDuration: 2, remainingTime: 6, completion: 0.25, + }, +}); +const expectedAbsentTask = S().addTask({ + name: 'Geen P6 verwacht einde', + time: createDefaultTaskTime('2026-08-03', 1), +}); +// `addTask` accepteert bewust alleen de gebruikersvelden uit zijn create-contract; XER-herkomst en +// custom-fieldwaarden landen via de algemene update-/importpaden. Gebruik hier dus dat echte pad. +S().updateTask(expectedDateTask, { + p6ProjectId: 'P1', p6TaskId: 'ED', p6ExpectedFinish: '2026-08-12', +}); +S().updateTask(expectedHourTask, { + p6ProjectId: 'P1', p6TaskId: 'EH', p6ExpectedFinish: '2026-08-12T13:15', +}); +S().updateTask(expectedAbsentTask, { + p6ProjectId: 'P1', p6TaskId: 'EA', customFields: { 'cf-date': '2026-08-12' }, +}); +S().runCPM(); +const expectedMove = S().moveProject('2026-08-10'); +eq('136 X7 Expected Finish-regressie gebruikt een echte projectverschuiving', expectedMove.moved, true); +eq('137 X7 actieve date-only p6ExpectedFinish schuift vormbehoudend mee', + S().tasks.find(t => t.id === expectedDateTask)?.p6ExpectedFinish, '2026-08-19'); +eq('138 X7 actieve uur-p6ExpectedFinish schuift mee met minuut exact behouden', + S().tasks.find(t => t.id === expectedHourTask)?.p6ExpectedFinish, '2026-08-19T13:15'); +eq('139 X7 afwezige p6ExpectedFinish blijft afwezig (niet invullen of wissen)', + S().tasks.find(t => t.id === expectedAbsentTask)?.p6ExpectedFinish, undefined); +eq('139b bestaand keep-beleid blijft staan: date-customfield schuift niet mee', + S().tasks.find(t => t.id === expectedAbsentTask)?.customFields?.['cf-date'], '2026-08-12'); + // --- R3: leeg project — de actie moet slagen en de preview mag geen epoch-datum tonen --- S().newProject(); S().setProject({ startDate: '2026-06-01' }); diff --git a/tests/planning/check-mpp-import.ts b/tests/planning/check-mpp-import.ts index 52d11af65..e38b22fb0 100644 --- a/tests/planning/check-mpp-import.ts +++ b/tests/planning/check-mpp-import.ts @@ -1164,6 +1164,13 @@ const PROPSKEY_ASSIGNMENT_FIELD_MAP = 131095; if (result) { truthy('I4 end-to-end readMPP: project.name uit Props/TITLE', result.project.name === 'Fixture Project'); + truthy('I4 end-to-end readMPP: zonder XER-floatbron blijven alleen de twee MPP-eigen opties staan', + JSON.stringify(result.project.schedulingOptions) === JSON.stringify({ + resumeFromActualElapsed: true, + unstartedIgnoresStatusDate: true, + })); + truthy('I4 end-to-end readMPP: project en kalender krijgen nooit XER-provenance', + result.project.schedulingOptions?.p6Source === undefined && result.calendar.p6Source === undefined); truthy('I4 end-to-end readMPP: hoursPerDay uit MINUTES_PER_DAY (480/60)', result.calendar.hoursPerDay === 8); truthy('I4 end-to-end readMPP: 6 taken', result.tasks.length === 6); diff --git a/tests/planning/check-mspdi-baseline-export.ts b/tests/planning/check-mspdi-baseline-export.ts index 8d2491e1f..2e4be6adc 100644 --- a/tests/planning/check-mspdi-baseline-export.ts +++ b/tests/planning/check-mspdi-baseline-export.ts @@ -15,6 +15,11 @@ // Draait via run.sh. Exit 0 = alles groen. import { installDOMParser } from './xmldom-shim'; import { readMSPDI } from '@/services/msproject/mspdiReader'; +import { + createEmptyXerArchiveDiagnostics, + createEmptyXerArchiveReadModel, + createXerSourceArchive, +} from '@/services/xerSourceArchive'; import { createDefaultTaskTime } from '@/utils/taskDefaults'; // ── Headless browser-stubs ─────────────────────────────────────────────────────────────────── @@ -67,6 +72,29 @@ S().runCPM(); const baselineId = S().saveBaseline('Nulmeting'); eq('01 setup: één baseline in de store', S().baselines.length, 1); eq('02 setup: baseline is actief', S().activeBaselineId, baselineId); +const savedBaseline = S().baselines[0]; +useAppStore.setState({ + baselines: [{ + ...savedBaseline, + sourceProjectId: 'P6-SOURCE-BASELINE', + name: 'Contractuele nulmeting', + createdAt: '2025-12-31T12:34:56.000Z', + projectEnd: '2035-12-31', + projectDuration: 987, + tasks: savedBaseline.tasks.map((task, index) => ({ + ...task, + sourceTaskId: `P6-TASK-${index + 1}`, + sourceTaskCode: `SRC-${index + 1}`, + })), + }], + xerSourceArchive: createXerSourceArchive(new Uint8Array(), { + encoding: 'utf-8', bom: 'none', newline: 'none', + diagnostics: createEmptyXerArchiveDiagnostics(), + readModel: createEmptyXerArchiveReadModel(), + }), + xerSourceProjectId: 'P6-PROJECT', + scheduleStale: false, +}); const before = S().baselines[0]; eq('03 setup: baseline dekt de drie leaf-taken', before.tasks.length, 3); @@ -77,7 +105,9 @@ const wantByName = new Map( ); // ── De productieroute: exportAs('mspdi'), exact zoals ribbon/backstage hem aanroepen ───────── -await S().exportAs('mspdi'); +const exportResult = await S().exportAs('mspdi'); +eq('03a MSPDI meldt ondanks slot-0-projectie het resterende baselineobjectverlies', + exportResult.ok ? exportResult.warnings[0]?.categories : [], ['baselines']); // `captured` wordt vanuit de Blob-stub gezet; TS' controlestroom ziet die schrijfactie niet en // versmalt de variabele anders tot `never`. Via een losse lezing blijft het type intact. @@ -98,6 +128,24 @@ truthy('09 import: baseline is actief', !!back.activeBaselineId && back.activeBa const backBaseline = backBaselines[0]; eq('10 import: baseline dekt evenveel taken', backBaseline?.tasks.length, before.tasks.length); +eq('10a round-trip bewijst dat OPS-baseline-identiteit en metadata niet terugkomen', { + sameId: backBaseline?.id === before.id, + sourceProjectId: backBaseline?.sourceProjectId, + sameName: backBaseline?.name === before.name, + sameCreatedAt: backBaseline?.createdAt === before.createdAt, + sameProjectEnd: backBaseline?.projectEnd === before.projectEnd, + sameProjectDuration: backBaseline?.projectDuration === before.projectDuration, + sourceTaskIdentityCount: backBaseline?.tasks.filter(task => + task.sourceTaskId !== undefined || task.sourceTaskCode !== undefined).length, +}, { + sameId: false, + sourceProjectId: undefined, + sameName: false, + sameCreatedAt: false, + sameProjectEnd: false, + sameProjectDuration: false, + sourceTaskIdentityCount: 0, +}); const backNameOf = new Map(back.tasks.map(t => [t.id, t.name])); for (const bt of backBaseline?.tasks ?? []) { diff --git a/tests/planning/check-notifications.ts b/tests/planning/check-notifications.ts index 53d90e477..fbe6989d8 100644 --- a/tests/planning/check-notifications.ts +++ b/tests/planning/check-notifications.ts @@ -27,6 +27,8 @@ import { commitPreparedGridMutation, prepareGridMutation } from '@/state/gridTra import type { CellEditIntent } from '@/types/taskGrid'; import type { PreparedGridMutation } from '@/state/gridTransaction'; import type { Sequence } from '@/types/sequence'; +import type { ImportResult, MultiDocumentImport, XerImportMetadata, XerImportReport } from '@/services/importTypes'; +import { notificationDetailText } from '@/utils/notificationDetail'; const S = () => useAppStore.getState(); const N = () => S().ui.notifications; @@ -58,6 +60,8 @@ eq('4 messageKey komt door', N()[0]?.messageKey, 'notifications.saveFailed'); eq('5 detail blijft rauw (bewust onvertaald)', N()[0]?.detail, 'EACCES'); eq('6 eerste voorkomen telt als 1', N()[0]?.count, 1); truthy('7 melding krijgt een id', typeof N()[0]?.id === 'string' && N()[0]?.id.length > 0); +eq('7a bestaande meldingen krijgen geen X10-detailregels erbij', + Object.prototype.hasOwnProperty.call(N()[0]!, 'detailLines'), false); S().notify({ severity: 'info', messageKey: 'notifications.openFailed' }); eq('8 zonder dedupeKey stapelen meldingen', N().length, 2); @@ -207,7 +211,7 @@ eq('49 newProject wist de meldingen niet', N().length, 1); // Een onleesbaar voorbeeldbestand: `readIFC` gooit sinds K4 een IfcParseError. Vroeger ging dat // naar `console.error` en zag de gebruiker een leeg scherm zonder uitleg. clearAll(); -S().openExampleFromString('dit is geen IFC', 'kapot.ifc'); +await S().openExampleFromString('dit is geen IFC', 'kapot.ifc'); eq('50 een onleesbaar bestand meldt zich', N().length, 1); eq('51 als fout', N()[0]?.severity, 'error'); eq('52 met de open-sleutel', N()[0]?.messageKey, 'notifications.openFailed'); @@ -501,6 +505,162 @@ S().updateTask(tphC, { time: { ...beforeTphC.time, scheduleDuration: 4 } }); eq('109 na newProject() meldt een NIEUW sturingsverlies WÉÉR (P1-fix, was: stil dood)', N().length, 1); eq('110 met de mpp-timephased-sleutel', N()[0]?.messageKey, 'notifications.mppTimephasedSteeringLost'); +// ── 13. X10: één eerlijke, samengestelde melding per geopend XER-bestand ────────────────────── +// Een XER kan meerdere zelfstandige projecten opleveren. De melding hoort daarom aan de +// BESTANDSactie (`applyOpenedImport`) en niet aan `applyLoadedProject`: twaalf documenten moeten +// precies één toast opleveren. De productiebreuk die deze test vangt is een notify in de +// per-documentlus, of het helemaal weglaten van de XER-samenvatting. +const x10Report = { + projectsSeen: 15, + documentsOpened: 12, + emptyProjectsSkipped: 3, + baselineProjectsExcluded: 2, + baselinesMaterialized: 2, + danglingBaselineReferences: 3, + externalLinksPreserved: 4, + baselineExclusionReverted: true, + baselineFallbackReasons: ['cycle'] as XerImportReport['baselineFallbackReasons'], +}; + +const x10CatalogIssues = { + resources: { + catalog: { issues: [{ + code: 'XER_RESOURCE_TYPE_FALLBACK', table: 'RSRC', line: 47, sourceId: 'R-1', fallback: 'LABOR', + }] }, + assignments: [], + issues: [], + } as unknown as NonNullable, + metadata: { + catalog: { issues: [{ code: 'XER_UDF_UNKNOWN_DATA_TYPE', table: 'UDFTYPE', line: 48 }] }, + } as unknown as NonNullable, +}; + +const x10XerMetadata = (sourceProjectId: string): XerImportMetadata => ({ + sourceProjectId, + defaultCurrencyCode: 'EUR', + tableReport: { + encoding: 'windows-1252', + endMarkerSeen: true, + issues: [ + { code: 'XER_SHORT_ROW', line: 40, table: 'TASK', expected: 8, actual: 7 }, + { code: 'XER_INVALID_NUMBER', line: 64, table: 'VENDOR_TABLE' }, + ], + unknownTables: [], + }, + calendarIssues: [ + { code: 'XER_CALENDAR_INVALID_PERIOD_HOURS', calendarId: 'CAL-1', line: 31, + reason: 'Uren per periode zijn ongeldig.', resolution: 'REJECTED' }, + { code: 'XER_CALENDAR_DANGLING_BASE', calendarId: 'CAL-2', line: 32, + reason: 'Basiskalender ontbreekt.', resolution: 'UNLINKED' }, + ], + enumFallbacks: [ + { family: 'activityType', token: 'TT_Unknown', fallback: 'TT_Task', table: 'TASK', field: 'task_type', line: 41 }, + { family: 'status', token: 'TK_Else', fallback: 'TK_NotStart', table: 'TASK', field: 'status_code', line: 42 }, + { family: 'relation', token: 'PR_Else', fallback: 'PR_FS', table: 'TASKPRED', field: 'pred_type', line: 43 }, + ], + scheduleOptions: { + source: 'xer-defaults', + retainedSource: {}, + fallbacks: [ + { field: 'sched_float_based_on_finish_flag', token: 'MAYBE', fallback: 'false', line: 45 }, + { field: 'sched_retained_logic', token: 'SOMETIMES', fallback: 'true', line: 46 }, + ], + diagnostics: [], + sourceArchive: { rows: [], unmatchedScheduleOptionsRowIndexes: [], diagnostics: [] }, + sourceRowIndexes: [], + sourceRows: [], + }, + externalRelations: [], + externalLinks: [], + report: x10Report, + // Dit zijn bestandsbrede catalogusproblemen. Ze mogen in de enige XER-melding + // niet als "unsupported semantics" meetellen: die regel is uitsluitend voor + // veilige terugvallen van P6-schedulingopties. + ...x10CatalogIssues, +}); + +const x10Result = (index: number): ImportResult => ({ + project: { ...S().project, id: `x10-project-${index}`, name: `XER ${index + 1}` }, + calendar: createDefaultCalendar(), + tasks: [], + sequences: [], + resources: [], + assignments: [], + xer: x10XerMetadata(`P-${index + 1}`), +}); + +const x10Import = (): MultiDocumentImport => ({ + kind: 'multi-document', + results: Array.from({ length: 12 }, (_, index) => x10Result(index)), + activeDocumentIndex: 0, +}); + +clearAll(); +S().newProject(); +S().applyOpenedImport(x10Import(), { + filePath: null, + fileHandle: null, + recompute: false, + fit: false, + hourDataNotice: false, + linkedOpen: true, +}); +eq('111 XER met twaalf documenten toont precies één bestandsmelding', N().length, 1); +eq('112 de XER-bestandsmelding is informatie, geen fout', N()[0]?.severity, 'info'); +eq('113 de XER-bestandsmelding gebruikt de samengestelde sleutel', N()[0]?.messageKey, 'notifications.xerImportOpened'); +eq('114 de hoofdtelling noemt de werkelijk geopende documenten', N()[0]?.params, { count: 12 }); +eq('115 de XER-bestandsmelding linkt naar de XER-gids', N()[0]?.helpArticleId, 'gids-xer-import'); +eq('116 de details dragen alle werkelijk gemeten, niet-nulle signalen', N()[0]?.detailLines, [ + { messageKey: 'notifications.xerImportProjectsSeen', params: { count: 15 } }, + { messageKey: 'notifications.xerImportEmptyProjectsSkipped', params: { count: 3 } }, + { messageKey: 'notifications.xerImportBaselineProjectsExcluded', params: { count: 2 } }, + { messageKey: 'notifications.xerImportBaselinesMaterialized', params: { count: 2 } }, + { messageKey: 'notifications.xerImportDanglingBaselineReferences', params: { count: 3 } }, + { messageKey: 'notifications.xerImportBaselineFallback' }, + { messageKey: 'notifications.xerImportExternalLinks', params: { count: 4 } }, + { messageKey: 'notifications.xerImportEncoding', params: { encoding: 'windows-1252' } }, + { messageKey: 'notifications.xerImportParserIssues', params: { count: 1 } }, + { messageKey: 'notifications.xerImportCalendarIssues', params: { count: 2 } }, + { messageKey: 'notifications.xerImportNumberIssues', params: { count: 2 } }, + { messageKey: 'notifications.xerImportEnumFallbacks', params: { count: 36 } }, + { messageKey: 'notifications.xerImportUnsupportedSemantics', params: { count: 24 } }, +]); + +// Een nieuwe bestandsactie mag nooit tegen de vorige worden samengevouwen: geen dedupeKey die +// alle XER-imports aan elkaar plakt. Tegelijk mag de bestaande per-document-enumtoast niet +// terugkomen als tweede soort informatie naast de samengestelde melding. +S().applyOpenedImport(x10Import(), { + filePath: null, + fileHandle: null, + recompute: false, + fit: false, + hourDataNotice: false, + linkedOpen: true, +}); +eq('117 twee afzonderlijke XER-imports tonen twee afzonderlijke meldingen', N().length, 2); +eq('118 ook de tweede melding is de samengestelde XER-melding', N()[1]?.messageKey, 'notifications.xerImportOpened'); + +// De host roept deze pure helper rechtstreeks in zijn React-renderlus aan. Dit bewijst dat een +// detailregel nooit via `detail` of een eigen formatter ontsnapt, maar met sleutel én params door +// de bestaande t-route gaat. Oude meldingen hebben bovenaan expliciet geen detailregels. +const translatedDetailCalls: Array<{ key: string; params?: Record }> = []; +const translatedDetail = notificationDetailText((key, params) => { + translatedDetailCalls.push({ key, params }); + return `${key}:${params?.count ?? ''}`; +}, N()[0]?.detailLines?.[0] ?? { messageKey: 'notifications.xerImportProjectsSeen' }); +eq('118a NotificationHost-detailhelper geeft de vertaalde tekst terug', + translatedDetail, 'notifications.xerImportProjectsSeen:15'); +eq('118b NotificationHost geeft sleutel en parameters samen aan t door', translatedDetailCalls, [ + { key: 'notifications.xerImportProjectsSeen', params: { count: 15 } }, +]); + +// De echte bestaande deeplink-route: niet een test-only callback, maar `openHelpArticle` die +// NotificationHost ook gebruikt. Een fout in helpArticleId of in de route maakt deze checks rood. +S().openHelpArticle(N()[0]?.helpArticleId ?? ''); +eq('119 Lees meer opent Backstage', S().ui.activeRibbonTab, 'file'); +eq('120 Lees meer kiest Help', S().ui.backstageSection, 'help'); +eq('121 Lees meer draagt het XER-artikel naar HelpPanel over', S().ui.pendingHelpArticleId, 'gids-xer-import'); + // ── 11. Een voorbereide gridmelding wordt pas na de atomaire datacommit getoond ─────────────── clearAll(); S().newProject(); @@ -523,12 +683,12 @@ const gridWithNotification: PreparedGridMutation | null = gridPrepared.ok ? { } : null; const gridCommit = gridWithNotification ? commitPreparedGridMutation(gridWithNotification) : null; unsubscribeGrid(); -eq('111 voorbereide gridcommit met melding slaagt', gridCommit?.ok, true); -eq('112 eerste gridpublicatie bevat data en history zonder melding', gridStates[0], { +eq('122 voorbereide gridcommit met melding slaagt', gridCommit?.ok, true); +eq('123 eerste gridpublicatie bevat data en history zonder melding', gridStates[0], { name: 'Grid na', history: 1, notifications: 0, }); -eq('113 gridmelding volgt in een aparte publicatie', gridStates.map(state => state.notifications), [0, 1]); -eq('114 uitgestelde gridmelding gebruikt het normale app-globale kanaal', N()[0]?.messageKey, 'notifications.relationCreated'); +eq('124 gridmelding volgt in een aparte publicatie', gridStates.map(state => state.notifications), [0, 1]); +eq('125 uitgestelde gridmelding gebruikt het normale app-globale kanaal', N()[0]?.messageKey, 'notifications.relationCreated'); // ── Uitkomst ──────────────────────────────────────────────────────────────── if (diffs.length) { diff --git a/tests/planning/check-p6-verified-cases-engine.ts b/tests/planning/check-p6-verified-cases-engine.ts new file mode 100644 index 000000000..521958366 --- /dev/null +++ b/tests/planning/check-p6-verified-cases-engine.ts @@ -0,0 +1,719 @@ +/** + * Motorrun van de dertien P6-23.12-geverifieerde casussen (review-bevinding 7). + * + * `check-p6-verified-cases.ts` pint `cases-p6-verified.json` uitsluitend als DATA: schema, + * herkomst, digests en de losse cellen. Niets in de repo haalde die casussen ooit door + * `solveProject`, waardoor een corpusloze X12-assertie stilzwijgend het tegenovergestelde kon + * beweren van P6's eigen opname zonder dat één poort dat meldde. Deze check sluit dat gat: elke + * casus wordt als XER-fixture opgebouwd uit de INVOERzijde van het publieke + * `cpp-cpm-engine`-vergelijkingsraamwerk (activiteiten, relaties, kalenders, statusdatum), door de + * echte lezer + solver gehaald, en per cel vergeleken met de gecommitte P6-uitvoer. + * + * Eerlijkheid boven groen: de uitkomst is NIET naar P6 toe geschreven. De check pint per casus en + * per as of wij het eens zijn met P6 (`agreement`), zodat zowel een verbetering als een + * verslechtering mechanisch opvalt. Gemeten stand (brongetrouwe transcriptie, 2026-09-07): elf van + * de dertien casussen kloppen volledig, 156 van de 160 door P6 vastgelegde cellen; de vier + * verschillen (casus 08 en 10, bezig zijnde taken rond de statusdatum) staan cel voor cel gepind + * onder punt 5. De ECHTE bytes (sectie 7) geven 77/160 zoals gelezen en 156/160 zonder de + * geregistreerde projecteinde-fout — dus exact deze transcriptie. + * + * BRONGETROUWHEID (her-review 2026-09-07, bevinding 1). De transcriptie volgt de ÉCHTE P6-export + * van precies deze dertien casussen — `cpp-cpm-engine/validation/p6-comparison/cases-import.xer`, + * de invoer die het raamwerk aan P6 gaf en waar `cases-p6-verified.json` de uitvoer van is — op de + * vier poortdiscriminatoren: `rem_target_link_flag = Y` op élk project, `duration_type = + * DT_FixedDrtn` (niet `DT_FixedDUR2`), `target_start_date` = de projectstart en een LEEG + * `target_end_date`. Een eerdere versie van dit bestand verzon `DT_FixedDUR2` en liet + * `rem_target_link_flag` weg — twee elkaar opheffende afwijkingen die de poort op de verkeerde + * reden dicht lieten staan. Wat hier bewust WEL wordt weggelaten is de SCHEDOPTIONS-tabel van de + * bron: die zet `sched_use_project_end_date_for_float = Y` terwijl geen enkele taak een + * `target_end_date` heeft, en dat raakt een geregistreerde productfout (het taak-afgeleide + * projecteinde valt dan terug op de projectSTART, waarna de late zijde daarop verankert — + * `docs/TODO.md`). Sectie 7 draait daarom de ECHTE bytes, corpusgebonden, en pint die fout + * zichtbaar, in plaats van hem in de transcriptie te verstoppen. + * + * Gevolg voor casus 09: `explainP6CompletedDataDateWindow` blijft daar GESLOTEN — maar op + * `wrongDurationType`/`missingExplicitTargetWindow`, een toevallige nauwte van de poort en géén + * semantische verzoening. Sectie 3 laat zien wat er gebeurt zodra de poort op diezelfde topologie + * opengaat: zeven van de twaalf P6-cellen gaan verloren en de open voorganger krijgt tf −5 waar P6 0 + * opnam. Dat is onverklaard tegenbewijs tegen de completed-late-regel en staat als zodanig in plan + * §5 (X-O7 laag 1) en in het docblok van `p6CompletedLateFromRemainingWindow`. + */ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { solveProject } from '@/engine/scheduler/solveProject'; +import { readXER } from '@/services/xer/xerReader'; +import { activeImportResult, isMultiDocumentImport } from '@/services/importTypes'; +import type { SchedulingOptions } from '@/types/project'; +import { parseInstant } from '@/utils/dateUtils'; +import { explainP6CompletedDataDateWindow } from '@/utils/p6CompletedTargetWindow'; +import { explainP6CompletedLateRemainingWindowEligibility } from '@/engine/scheduler/p6CompletedRouteTrace'; + +const HERE = fileURLToPath(new URL('.', import.meta.url)); +const diffs: string[] = []; +let checks = 0; + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +// ── De invoerzijde, letterlijk overgenomen uit validation/p6-comparison/cases/*/input.json ────── +// (publieke bron `cpp-cpm-engine`, commit c279a5c4, dezelfde herkomst als cases-p6-verified.json). +// Deze transcriptie is de enige handmatige stap; ze bevat GEEN uitvoer, alleen invoer. +type RelType = 'FS' | 'SS' | 'FF' | 'SF'; +interface CaseCalendar { workDays: number[]; holidays?: string[] } +interface CaseActivity { + code: string; + durationDays: number; + calendar: string; + actualStart?: string; + actualFinish?: string; + remainingDuration?: number; + constraint?: { type: string; date: string }; + constraint2?: { type: string }; +} +interface EngineCase { + id: string; + dataDate: string; + projectStart: string; + calendars: Record; + activities: CaseActivity[]; + relationships: { from: string; to: string; type: RelType; lagDays: number }[]; +} +const MONFRI: Record = { MONFRI: { workDays: [1, 2, 3, 4, 5] } }; +const CASES: EngineCase[] = [ + { + id: '01-fs-chain', dataDate: '2026-01-05', projectStart: '2026-01-05', calendars: MONFRI, + activities: [ + { code: 'A', durationDays: 5, calendar: 'MONFRI' }, + { code: 'B', durationDays: 3, calendar: 'MONFRI' }, + { code: 'C', durationDays: 2, calendar: 'MONFRI' }, + ], + relationships: [ + { from: 'A', to: 'B', type: 'FS', lagDays: 0 }, + { from: 'B', to: 'C', type: 'FS', lagDays: 0 }, + ], + }, + { + id: '02-ss-with-lag', dataDate: '2026-01-05', projectStart: '2026-01-05', calendars: MONFRI, + activities: [ + { code: 'A', durationDays: 10, calendar: 'MONFRI' }, + { code: 'B', durationDays: 4, calendar: 'MONFRI' }, + ], + relationships: [{ from: 'A', to: 'B', type: 'SS', lagDays: 5 }], + }, + { + id: '03-ff-with-lag', dataDate: '2026-01-05', projectStart: '2026-01-05', calendars: MONFRI, + activities: [ + { code: 'A', durationDays: 5, calendar: 'MONFRI' }, + { code: 'B', durationDays: 4, calendar: 'MONFRI' }, + ], + relationships: [{ from: 'A', to: 'B', type: 'FF', lagDays: 3 }], + }, + { + id: '04-sf-edge-case', dataDate: '2026-01-05', projectStart: '2026-01-05', calendars: MONFRI, + activities: [ + { code: 'A', durationDays: 5, calendar: 'MONFRI' }, + { code: 'B', durationDays: 3, calendar: 'MONFRI' }, + ], + relationships: [{ from: 'A', to: 'B', type: 'SF', lagDays: 0 }], + }, + { + id: '05-negative-float', dataDate: '2026-01-05', projectStart: '2026-01-05', calendars: MONFRI, + activities: [ + { code: 'A', durationDays: 8, calendar: 'MONFRI' }, + { code: 'B', durationDays: 4, calendar: 'MONFRI', constraint: { type: 'CS_MEOB', date: '2026-01-12' } }, + ], + relationships: [{ from: 'A', to: 'B', type: 'FS', lagDays: 0 }], + }, + { + id: '06-multiple-calendars', dataDate: '2026-01-05', projectStart: '2026-01-05', + calendars: { MONFRI: { workDays: [1, 2, 3, 4, 5] }, SIXDAY: { workDays: [1, 2, 3, 4, 5, 6] } }, + activities: [ + { code: 'A', durationDays: 10, calendar: 'MONFRI' }, + { code: 'B', durationDays: 10, calendar: 'SIXDAY' }, + ], + relationships: [], + }, + { + id: '07-ontario-holidays', dataDate: '2026-01-05', projectStart: '2026-01-05', + calendars: { + CA_ON: { + workDays: [1, 2, 3, 4, 5], + holidays: [ + '2026-01-01', '2026-02-16', '2026-04-03', '2026-05-18', '2026-07-01', '2026-08-03', + '2026-09-07', '2026-10-12', '2026-12-25', '2026-12-28', '2027-01-01', '2027-02-15', + '2027-03-26', '2027-05-24', '2027-07-01', '2027-08-02', '2027-09-06', '2027-10-11', + '2027-12-27', '2027-12-28', + ], + }, + }, + activities: [{ code: 'A', durationDays: 90, calendar: 'CA_ON' }], + relationships: [], + }, + { + id: '08-in-progress-retained-logic', dataDate: '2026-01-12', projectStart: '2026-01-05', + calendars: MONFRI, + activities: [ + { code: 'A', durationDays: 10, calendar: 'MONFRI', actualStart: '2026-01-06', remainingDuration: 7 }, + { code: 'B', durationDays: 5, calendar: 'MONFRI' }, + ], + relationships: [{ from: 'A', to: 'B', type: 'FS', lagDays: 0 }], + }, + { + id: '09-completed-successor', dataDate: '2026-01-05', projectStart: '2025-12-01', + calendars: MONFRI, + activities: [ + { + code: 'B', durationDays: 11, calendar: 'MONFRI', + actualStart: '2025-12-15', actualFinish: '2025-12-30', + }, + { code: 'A', durationDays: 5, calendar: 'MONFRI' }, + ], + relationships: [{ from: 'A', to: 'B', type: 'FS', lagDays: 0 }], + }, + { + id: '10-out-of-sequence-progress', dataDate: '2026-01-12', projectStart: '2026-01-05', + calendars: MONFRI, + activities: [ + { code: 'A', durationDays: 10, calendar: 'MONFRI' }, + { code: 'B', durationDays: 5, calendar: 'MONFRI', actualStart: '2026-01-08', remainingDuration: 3 }, + ], + relationships: [{ from: 'A', to: 'B', type: 'FS', lagDays: 0 }], + }, + { + id: '11-mandatory-start-finish', dataDate: '2026-01-05', projectStart: '2026-01-05', + calendars: MONFRI, + activities: [ + { code: 'A', durationDays: 5, calendar: 'MONFRI', constraint: { type: 'CS_MSO', date: '2026-01-12' } }, + { code: 'B', durationDays: 4, calendar: 'MONFRI', constraint: { type: 'CS_MEO', date: '2026-01-30' } }, + ], + relationships: [{ from: 'A', to: 'B', type: 'FS', lagDays: 0 }], + }, + { + id: '12-snet-fnlt', dataDate: '2026-01-05', projectStart: '2026-01-05', calendars: MONFRI, + activities: [ + { code: 'A', durationDays: 5, calendar: 'MONFRI', constraint: { type: 'CS_MSOA', date: '2026-01-20' } }, + { code: 'B', durationDays: 5, calendar: 'MONFRI', constraint: { type: 'CS_MEOB', date: '2026-02-13' } }, + ], + relationships: [{ from: 'A', to: 'B', type: 'FS', lagDays: 0 }], + }, + { + id: '13-alap', dataDate: '2026-01-05', projectStart: '2026-01-05', calendars: MONFRI, + activities: [ + { code: 'A', durationDays: 5, calendar: 'MONFRI' }, + { code: 'B', durationDays: 5, calendar: 'MONFRI', constraint2: { type: 'CS_ALAP' } }, + { code: 'C', durationDays: 3, calendar: 'MONFRI', constraint: { type: 'CS_MEOB', date: '2026-02-28' } }, + ], + relationships: [ + { from: 'A', to: 'B', type: 'FS', lagDays: 0 }, + { from: 'B', to: 'C', type: 'FS', lagDays: 0 }, + ], + }, +]; + +// ── XER-serialisatie van die invoer ───────────────────────────────────────────────────────────── +const HOURS_PER_DAY = 8; +const P6_EPOCH_UTC = Date.UTC(1899, 11, 30); + +function p6DaySerial(isoDate: string): number { + const [year, month, day] = isoDate.split('-').map(Number); + return Math.round((Date.UTC(year!, month! - 1, day!) - P6_EPOCH_UTC) / 86_400_000); +} + +/** ISO-weekdag (1 = maandag … 7 = zondag) → P6-index (1 = zondag … 7 = zaterdag). */ +function p6WeekdayIndex(isoWeekday: number): number { + return isoWeekday === 7 ? 1 : isoWeekday + 1; +} + +function calendarData(calendar: CaseCalendar): string { + const days = [1, 2, 3, 4, 5, 6, 7].map(iso => { + const index = p6WeekdayIndex(iso); + const bands = calendar.workDays.includes(iso) + ? '(0||0(s|08:00|f|12:00)())(0||1(s|13:00|f|17:00)())' + : ''; + return `(0||${index}()(${bands}))`; + }).join(''); + const exceptions = (calendar.holidays ?? []) + .map(date => `(0||${p6DaySerial(date)}(d|${p6DaySerial(date)})())`).join(''); + return `(0||CalendarData()((0||DaysOfWeek()(${days}))(0||Exceptions()(${exceptions}))))`; +} + +function statusCode(activity: CaseActivity): string { + if (activity.actualFinish) return 'TK_Complete'; + if (activity.actualStart) return 'TK_Active'; + return 'TK_NotStart'; +} + +function remainingHours(activity: CaseActivity): number { + if (activity.actualFinish) return 0; + if (activity.remainingDuration !== undefined) return activity.remainingDuration * HOURS_PER_DAY; + return activity.durationDays * HOURS_PER_DAY; +} + +function caseBytes(item: EngineCase): Uint8Array { + const calendarIds = Object.keys(item.calendars); + const lines: string[] = [ + 'ERMHDR\t23.12\t2026-01-05\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + ...calendarIds.map(id => { + const calendar = item.calendars[id]!; + const weekHours = calendar.workDays.length * HOURS_PER_DAY; + return `%R\t${id}\t${id}\tCA_Base\t${HOURS_PER_DAY}\t${weekHours}\t${calendarData(calendar)}`; + }), + '%T\tPROJECT', + // Brongetrouw (`cases-import.xer`): `rem_target_link_flag = Y`, `plan_end_date` leeg. + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date\tplan_end_date\trem_target_link_flag', + `%R\tP\t${item.id}\t${calendarIds[0]}\t${item.dataDate} 00:00\t${item.projectStart} 08:00\t\tY`, + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code' + + '\tcomplete_pct_type\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date' + + '\tact_start_date\tact_end_date\tcstr_type\tcstr_date\tcstr_type2', + ...item.activities.map(activity => [ + '%R', activity.code, 'P', activity.calendar, activity.code, activity.code, + // Brongetrouw: `DT_FixedDrtn`, `target_start_date` = projectstart, `target_end_date` leeg. + 'TT_Task', 'DT_FixedDrtn', statusCode(activity), 'CP_Drtn', + String(activity.durationDays * HOURS_PER_DAY), String(remainingHours(activity)), + `${item.projectStart} 08:00`, '', + activity.actualStart ? `${activity.actualStart} 08:00` : '', + activity.actualFinish ? `${activity.actualFinish} 17:00` : '', + activity.constraint?.type ?? '', + activity.constraint ? `${activity.constraint.date} 08:00` : '', + activity.constraint2?.type ?? '', + ].join('\t')), + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + ...item.relationships.map((relation, index) => + `%R\tR${index}\t${relation.to}\t${relation.from}\tP\tP\tPR_${relation.type}` + + `\t${relation.lagDays * HOURS_PER_DAY}`), + '%E', + ]; + return new TextEncoder().encode(lines.join('\n')); +} + +// ── De gecommitte P6-uitvoer ──────────────────────────────────────────────────────────────────── +interface P6Row extends Record { + activity_code: string; + ES_p6: string; EF_p6: string; LS_p6: string; LF_p6: string; TF_p6: string; FF_p6: string; +} +const oracle = JSON.parse(readFileSync(join(HERE, 'cases-p6-verified.json'), 'utf-8')) as { + cases: { id: string; activities: P6Row[] }[]; +}; + +/** `2026-01-05 08:00:00` en `2025-12-15 08:00:00 A` → `2026-01-05T08:00`; leeg blijft leeg. */ +function p6Instant(value: string): string | null { + if (!value) return null; + const match = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2})/.exec(value); + return match ? `${match[1]}T${match[2]}` : null; +} +function p6Number(value: string): number | null { + return value === '' ? null : Number(value); +} + +type Axis = 'es' | 'ef' | 'ls' | 'lf' | 'tf' | 'ff'; +const AXES: readonly Axis[] = ['es', 'ef', 'ls', 'lf', 'tf', 'ff']; +// Gepinde poortredenen (sectie 1/2b) — gemeten op de brongetrouwe transcriptie. +const GATE_REASONS_PIN = ['missingExplicitTargetWindow']; +const GATE_REASON_CASE_09_B = 'missingExplicitTargetWindow'; +const REAL_AS_READ_PIN = { cellen: 160, eens: 77 }; +const REAL_WITHOUT_PROJECT_END_PIN = { cellen: 160, eens: 156 }; +const REAL_PROJECT_RANGE_PIN = { start: '2026-01-05T08:00', end: '2026-01-05T08:00', flag: true }; +const DEVIATING_CELLS_PIN: Record> = { + '08-in-progress-retained-logic': { A: ['es', 'ls'] }, + '10-out-of-sequence-progress': { B: ['es', 'ls'] }, +}; +const CASE_10_B_PIN = { es: '2026-01-26T08:00', ls: '2026-01-26T08:00' }; +const CASE_08_PIN = { + A: { es: '2026-01-12T08:00', ef: '2026-01-20T17:00', ls: '2026-01-12T08:00', lf: '2026-01-20T17:00', tf: 0, ff: 0 }, + B: { es: '2026-01-21T08:00', ef: '2026-01-27T17:00', ls: '2026-01-21T08:00', lf: '2026-01-27T17:00', tf: 0, ff: 0 }, +}; + +interface Measured { es?: string; ef?: string; ls?: string; lf?: string; tf: number; ff: number } + +function solveCase(item: EngineCase): { measured: Map; gate: Record } { + const imported = activeImportResult(readXER(caseBytes(item))); + const result = solveProject({ + tasks: imported.tasks, + sequences: imported.sequences, + calendar: imported.calendar, + calendars: imported.resourceCalendars ?? [], + dataDate: imported.project.statusDate, + progressMode: imported.project.progressMode, + schedulingOptions: imported.project.schedulingOptions, + projectStartDate: imported.project.startDate, + projectEndDate: imported.project.endDate, + }); + if (result.error) throw new Error(`${item.id}: ${result.error}`); + const measured = new Map(); + for (const activity of item.activities) { + const solved = result.tasks.get(activity.code); + if (!solved) throw new Error(`${item.id}: taak ${activity.code} ontbreekt in de uitkomst`); + measured.set(activity.code, { + es: solved.earlyStart, ef: solved.earlyFinish, + ls: solved.lateStart, lf: solved.lateFinish, + tf: solved.totalFloat, ff: solved.freeFloat, + }); + } + const dataDate = imported.project.statusDate ? parseInstant(imported.project.statusDate) : null; + const gate: Record = {}; + for (const task of imported.tasks) { + gate[task.id] = explainP6CompletedDataDateWindow( + task, dataDate, imported.project.schedulingOptions, + ).reason; + } + return { measured, gate }; +} + +/** + * De agreement-matrix: per casus/activiteit/as `true` (wij zijn het eens met P6), `false` (niet + * eens) of `null` (P6 legde niets vast). Dit is een KARAKTERISERING, geen doel — hij staat hier + * zodat elke verschuiving, in beide richtingen, zichtbaar wordt. + */ +const agreement: Record>> = {}; +const gates: Record> = {}; +for (const item of CASES) { + const { measured, gate } = solveCase(item); + gates[item.id] = gate; + const oracleCase = oracle.cases.find(entry => entry.id === item.id); + if (!oracleCase) throw new Error(`orakel mist casus ${item.id}`); + agreement[item.id] = {}; + for (const row of oracleCase.activities) { + const ours = measured.get(row.activity_code); + if (!ours) throw new Error(`${item.id}: orakelactiviteit ${row.activity_code} niet gesolved`); + const perAxis: Record = {}; + for (const axis of AXES) { + const raw = row[`${axis.toUpperCase()}_p6`]!; + if (axis === 'tf' || axis === 'ff') { + const want = p6Number(raw); + perAxis[axis] = want === null ? null : ours[axis] === want; + } else { + const want = p6Instant(raw); + perAxis[axis] = want === null ? null : ours[axis] === want; + } + } + agreement[item.id]![row.activity_code] = perAxis; + } +} + +// ── 1. De poortstand per casus: nergens opent het completed-statusdatumvenster ────────────────── +// Brongetrouw gemeten (her-review bevinding 1): de bron declareert `rem_target_link_flag = Y`, dus +// `remainingStartOff` is hier NIET de reden. De poort staat dicht op de duurtype-eis +// (`DT_FixedDrtn` ≠ `DT_FixedDUR2`) en het ontbrekende `target_end_date` — een toevallige nauwte, +// geen verzoening. `p6CompletedLateFromRemainingWindow` is daardoor in alle dertien casussen inert. +eq('1 completed-statusdatumvenster blijft in alle dertien casussen gesloten — en om déze redenen', + [...new Set(Object.values(gates).flatMap(entry => Object.values(entry)))].sort(), + GATE_REASONS_PIN); + +// ── 2. Casus 09 expliciet: wij geven de open voorganger dezelfde LS/TF als P6 23.12 ───────────── +{ + const { measured, gate } = solveCase(CASES.find(item => item.id === '09-completed-successor')!); + const a = measured.get('A')!; + const b = measured.get('B')!; + eq('2 casus 09: open voorganger A krijgt LS = ES en TF = 0, exact zoals P6 23.12 opnam', { + es: a.es, ef: a.ef, ls: a.ls, lf: a.lf, tf: a.tf, ff: a.ff, + }, { + es: '2026-01-05T08:00', ef: '2026-01-09T17:00', + ls: '2026-01-05T08:00', lf: '2026-01-09T17:00', tf: 0, ff: 0, + }); + eq('2a casus 09: de voltooide opvolger B blijft op haar historische actual-venster', + { es: b.es, ef: b.ef, ls: b.ls, lf: b.lf }, + { + es: '2025-12-15T08:00', ef: '2025-12-30T17:00', + ls: '2025-12-15T08:00', lf: '2025-12-30T17:00', + }); + eq('2b casus 09: B komt niet door het completed-statusdatumvenster (poortreden zoals de bron die geeft)', + gate.B, GATE_REASON_CASE_09_B); +} + +// ── 3. Casus 09 tegenover de X12-fixture: hetzelfde patroon, andere bronvorm ──────────────────── +// Zelfde topologie (open A → FS → voltooide B), maar mét expliciet targetvenster, +// `rem_target_link_flag=Y` en een `act_end_date` NA de statusdatum. Dan opent de poort wél en legt +// B — net als elke andere opvolger — gewone backwarddruk op A. De twee fixtures spreken elkaar dus +// niet tegen; ze staan aan weerszijden van dezelfde poort. Deze assertie maakt dat mechanisch: hij +// faalt zodra iemand de poort verruimt en casus 09 alsnog binnentrekt. +{ + const calendar = calendarData({ workDays: [1, 2, 3, 4, 5] }); + const bytes = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-01-05\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + `%R\tC1\tP6 5x8\tCA_Base\t8\t40\t${calendar}`, + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date\trem_target_link_flag', + '%R\tP\tcasus 09 met targetvenster\tC1\t2026-01-05 00:00\t2025-12-01 08:00\tY', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code' + + '\tcomplete_pct_type\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date' + + '\ttarget_end_date\tact_start_date\tact_end_date', + '%R\tA\tP\tC1\tA\tOpen voorganger\tTT_Task\tDT_FixedDUR2\tTK_NotStart\tCP_Drtn\t40\t40' + + '\t2026-01-05 08:00\t2026-01-09 17:00\t\t', + '%R\tB\tP\tC1\tB\tVoltooide opvolger\tTT_Task\tDT_FixedDUR2\tTK_Complete\tCP_Drtn\t88\t0' + + '\t2025-12-15 08:00\t2025-12-30 17:00\t2025-12-15 08:00\t2025-12-30 17:00', + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%R\tR0\tB\tA\tP\tP\tPR_FS\t0', + '%E', + ].join('\n')); + const imported = activeImportResult(readXER(bytes)); + const dataDate = imported.project.statusDate ? parseInstant(imported.project.statusDate) : null; + const b = imported.tasks.find(task => task.id === 'B')!; + eq('3 dezelfde topologie MET targetvenster opent de poort wél', { + window: explainP6CompletedDataDateWindow(b, dataDate, imported.project.schedulingOptions).reason, + shared: explainP6CompletedLateRemainingWindowEligibility( + b, dataDate, imported.project.schedulingOptions, + ).eligible, + }, { window: 'eligible', shared: true }); + const result = solveProject({ + tasks: imported.tasks, + sequences: imported.sequences, + calendar: imported.calendar, + calendars: imported.resourceCalendars ?? [], + dataDate: imported.project.statusDate, + progressMode: imported.project.progressMode, + schedulingOptions: imported.project.schedulingOptions, + projectStartDate: imported.project.startDate, + projectEndDate: imported.project.endDate, + }); + if (result.error) throw new Error(result.error); + const a = result.tasks.get('A')!; + const solvedB = result.tasks.get('B')!; + // Gemeten uitkomst, niet naar een wens toe geschreven: mét de poort open volgt B het + // statusdatumvenster en houdt A — die B's start pas ná die klem kan bedienen — géén nulfloat meer. + eq('3a met de poort open verschuift de late zijde van B naar het statusdatumvenster', { + ls: solvedB.lateStart, lf: solvedB.lateFinish, + }, { ls: '2026-01-05T08:00', lf: '2026-01-02T17:00' }); + eq('3b en A verliest daardoor haar nulfloat (zelfde topologie, andere bronvorm dan casus 09)', + { ls: a.lateStart, lf: a.lateFinish, tf: a.totalFloat }, + { ls: '2025-12-29T08:00', lf: '2026-01-02T17:00', tf: -5 }); +} + +// ── 3b. De transcriptie kan niet stil terugdrijven van de bron (her-check residu A / mutatie M3) ── +// De vier poortdiscriminatoren staan hier letterlijk gepind op de bytes die `caseBytes` maakt, zoals +// `cases-import.xer` ze draagt: `DT_FixedDrtn` (nooit `DT_FixedDUR2`), `rem_target_link_flag=Y`, +// `target_start_date` = projectstart, leeg `target_end_date`. MUTATIEBEWIJS: zet `DT_FixedDUR2` +// terug in `caseBytes` ⇒ 3b slaat ROOD (de poort bleef daar toevallig even dicht, vandaar deze pin). +{ + const case09 = CASES.find(item => item.id === '09-completed-successor')!; + const text = new TextDecoder().decode(caseBytes(case09)); + const taskRows = text.split('\n').filter(line => line.startsWith('%R\t') && line.includes('\tTT_Task\t')); + eq('3b transcriptie draagt de bron-discriminatoren letterlijk', { + durationTypes: [...new Set(taskRows.map(row => row.split('\t')[7]))], + remTargetLink: text.includes('\tplan_end_date\trem_target_link_flag\n%R\tP\t09-completed-successor\tMONFRI\t2026-01-05 00:00\t2025-12-01 08:00\t\tY'), + targetStarts: [...new Set(taskRows.map(row => row.split('\t')[12]))], + targetEnds: [...new Set(taskRows.map(row => row.split('\t')[13]))], + }, { + durationTypes: ['DT_FixedDrtn'], + remTargetLink: true, + targetStarts: ['2025-12-01 08:00'], + targetEnds: [''], + }); +} + +// ── 4. De volledige agreement-matrix, eerlijk gepind ──────────────────────────────────────────── +const summary = Object.fromEntries(CASES.map(item => { + const rows = Object.values(agreement[item.id]!); + const cells = rows.flatMap(row => Object.values(row)).filter(value => value !== null); + return [item.id, { cellen: cells.length, eens: cells.filter(Boolean).length }]; +})); +eq('4 agreement met P6 23.12 per casus (karakterisering, geen doel)', summary, { + '01-fs-chain': { cellen: 18, eens: 18 }, + '02-ss-with-lag': { cellen: 12, eens: 12 }, + '03-ff-with-lag': { cellen: 12, eens: 12 }, + '04-sf-edge-case': { cellen: 12, eens: 12 }, + '05-negative-float': { cellen: 12, eens: 12 }, + '06-multiple-calendars': { cellen: 12, eens: 12 }, + '07-ontario-holidays': { cellen: 6, eens: 6 }, + // Brongetrouw (her-review bevinding 1): 156 van 160 — exact gelijk aan de echte bytes zónder de + // projecteinde-fout (sectie 7b). De vier afwijkende cellen staan in sectie 5 gepind. + '08-in-progress-retained-logic': { cellen: 12, eens: 10 }, + '09-completed-successor': { cellen: 10, eens: 10 }, + '10-out-of-sequence-progress': { cellen: 12, eens: 10 }, + '11-mandatory-start-finish': { cellen: 12, eens: 12 }, + '12-snet-fnlt': { cellen: 12, eens: 12 }, + '13-alap': { cellen: 18, eens: 18 }, +}); + +// ── 5. De bekende verschillen, cel voor cel ─────────────────────────────────────────────────── +// Brongetrouw gemeten (her-review bevinding 1): met `rem_target_link_flag = Y` en de +// `target_start_date` uit de bron verschuiven de verschillen. Casus 10 (out-of-sequence): A klopt +// nu volledig met P6 (LS = ES, TF 0) — de eerdere "A −12"-afwijking was een artefact van de +// onbrongetrouwe transcriptie — maar B's vroege/late START wijkt af. Casus 08 (in-progress, +// retained logic) verliest twee cellen: A's ES/LS. +// Eén gemeenschappelijke oorzaak, geen twee: `rem_target_link_flag = Y` zet in de lezer +// `p6UseRemainingStartForProgress`, en dan wordt de VROEGE START van een bezig zijnde taak de +// reststart (statusdatum, of ná de voorganger) — casus 08 A: 2026-01-12, casus 10 B: 2026-01-26 — +// terwijl P6 23.12 in `early_start_date` de WERKELIJKE start opneemt (01-06 resp. 01-08) en de +// reststart apart bewaart (`restart_date`, bak 2). Vier cellen, beide casussen; EF/LF/TF/FF +// kloppen wél. Dat is klasse (ii)-materiaal (bezig zijnde taken rond de statusdatum, X5-vlag), +// NIET door `p6CompletedLateFromRemainingWindow` veroorzaakt (assertie 1: de poort staat overal +// dicht, de vlag is inert). Gepind zodat het niet stil verdwijnt of groeit; dossier in plan §9. +{ + const deviating = Object.fromEntries(Object.entries(agreement).map(([id, rows]) => [ + id, + Object.fromEntries(Object.entries(rows).map(([code, axes]) => [ + code, Object.entries(axes).filter(([, value]) => value === false).map(([axis]) => axis), + ]).filter(([, axes]) => (axes as string[]).length > 0)), + ]).filter(([, rows]) => Object.keys(rows).length > 0)); + eq('5 precies deze cellen wijken af van P6 23.12', deviating, DEVIATING_CELLS_PIN); + const case10 = solveCase(CASES.find(item => item.id === '10-out-of-sequence-progress')!).measured; + eq('5a casus 10: onze A is nu gelijk aan P6 (LS 2026-01-12T08:00, LF 2026-01-23T17:00, TF 0)', + { ls: case10.get('A')!.ls, lf: case10.get('A')!.lf, tf: case10.get('A')!.tf }, + { ls: '2026-01-12T08:00', lf: '2026-01-23T17:00', tf: 0 }); + eq('5b casus 10: onze B tegenover P6 (P6: ES 2026-01-08T08:00, LS 2026-01-08T08:00)', + { es: case10.get('B')!.es, ls: case10.get('B')!.ls }, CASE_10_B_PIN); + const case08 = solveCase(CASES.find(item => item.id === '08-in-progress-retained-logic')!).measured; + eq('5c casus 08: de twee afwijkende cellen', { + A: { es: case08.get('A')!.es, ef: case08.get('A')!.ef, ls: case08.get('A')!.ls, lf: case08.get('A')!.lf, tf: case08.get('A')!.tf, ff: case08.get('A')!.ff }, + B: { es: case08.get('B')!.es, ef: case08.get('B')!.ef, ls: case08.get('B')!.ls, lf: case08.get('B')!.lf, tf: case08.get('B')!.tf, ff: case08.get('B')!.ff }, + }, CASE_08_PIN); +} + +// ── 6. Transcriptiepariteit met de publieke bron (alleen wanneer die gemount is) ──────────────── +// De invoertabel hierboven is met de hand overgenomen. Staat het publieke vergelijkingsraamwerk +// beschikbaar — via `OPS_P6_COMPARISON`, of als `cpp-cpm-engine/validation/p6-comparison` binnen +// `OPS_XER_CORPUS` — dan wordt elke casus veld voor veld tegen het bron-`input.json` gelegd. Zo kan +// de transcriptie niet stil afdrijven van de bron waar `cases-p6-verified.json` uit komt. +{ + const explicit = process.env.OPS_P6_COMPARISON; + const fromCorpus = process.env.OPS_XER_CORPUS + ? join(process.env.OPS_XER_CORPUS, 'cpp-cpm-engine', 'validation', 'p6-comparison') + : undefined; + const root = [explicit, fromCorpus].find(candidate => + candidate !== undefined && existsSync(join(candidate, 'cases'))); + if (!root) { + console.log('OK p6-verified-cases-engine: bron niet aanwezig (OPS_P6_COMPARISON/OPS_XER_CORPUS)' + + ' — transcriptiepariteit overgeslagen'); + } else { + const normalized = CASES.map(item => ({ + id: item.id, + opts: { dataDate: item.dataDate, projectStart: item.projectStart, cal_map: item.calendars }, + activities: item.activities, + relationships: item.relationships, + })); + const fromSource = CASES.map(item => { + const raw = JSON.parse( + readFileSync(join(root, 'cases', item.id, 'input.json'), 'utf-8'), + ) as { + activities: Array>; + relationships: Array>; + opts: { + dataDate: string; + projectStart: string; + cal_map: Record; + }; + }; + return { + id: item.id, + opts: { + dataDate: raw.opts.dataDate, + projectStart: raw.opts.projectStart, + cal_map: Object.fromEntries(Object.entries(raw.opts.cal_map).map(([id, calendar]) => [ + id, + calendar.holidays.length > 0 + ? { workDays: calendar.work_days, holidays: calendar.holidays } + : { workDays: calendar.work_days }, + ])), + }, + activities: raw.activities.map(activity => ({ + code: activity.code as string, + durationDays: activity.duration_days as number, + calendar: activity.clndr_id as string, + ...(activity.actual_start ? { actualStart: activity.actual_start as string } : {}), + ...(activity.actual_finish ? { actualFinish: activity.actual_finish as string } : {}), + ...(activity.remaining_duration !== undefined + ? { remainingDuration: activity.remaining_duration as number } : {}), + ...(activity.constraint ? { constraint: activity.constraint } : {}), + ...(activity.constraint2 ? { constraint2: activity.constraint2 } : {}), + })), + relationships: raw.relationships.map(relation => ({ + from: relation.from_code as string, + to: relation.to_code as string, + type: relation.type as RelType, + lagDays: relation.lag_days as number, + })), + }; + }); + eq('6 de handmatige invoertranscriptie is veld voor veld gelijk aan de publieke bron', + normalized, fromSource); + } +} + +// ── 7. De ÉCHTE bytes: `cases-import.xer` door dezelfde lezer + solver (alleen mét corpus) ────── +// Her-review bevinding 2: "157/160 eens met P6" was een uitspraak over de transcriptie, niet over +// het bestand dat P6 werkelijk kreeg. Dit pint de echte export — dertien projecten in één XER — +// zoals gelezen, én met `useProjectEndDateForFloat` uitgezet. Het gat ertussen is de geregistreerde +// productfout (`docs/TODO.md`): `sched_use_project_end_date_for_float = Y` + geen enkele +// `target_end_date`/`plan_end_date` ⇒ het taak-afgeleide projecteinde valt terug op de projectSTART +// en de hele late zijde verankert daarop. Karakterisering, geen doel: beide tellers zijn gepind +// zodat een fix het gat zichtbaar dichttrekt en een regressie het zichtbaar opent. +{ + const corpus = process.env.OPS_XER_CORPUS; + const real = corpus ? join(corpus, 'cpp-cpm-engine', 'validation', 'p6-comparison', 'cases-import.xer') : undefined; + if (!real || !existsSync(real)) { + console.log('OK p6-verified-cases-engine: cases-import.xer niet aanwezig (OPS_XER_CORPUS) — echte-bytes-run overgeslagen'); + } else { + const opened = readXER(new Uint8Array(readFileSync(real))); + const results = isMultiDocumentImport(opened) ? opened.results : [opened]; + eq('7 de echte export draagt dertien projecten', results.length, 13); + const agreeReal = (overrides?: Partial) => { + let cells = 0; let eens = 0; + const perCase: Record = {}; + for (const [index, item] of CASES.entries()) { + const imported = results.find(candidate => candidate.project.name === `XV${String(index + 1).padStart(2, '0')}`) + ?? results[index]!; + const schedulingOptions = { ...imported.project.schedulingOptions, ...overrides }; + const result = solveProject({ + tasks: imported.tasks, sequences: imported.sequences, calendar: imported.calendar, + calendars: imported.resourceCalendars ?? [], dataDate: imported.project.statusDate, + progressMode: imported.project.progressMode, schedulingOptions, + projectStartDate: imported.project.startDate, projectEndDate: imported.project.endDate, + }); + if (result.error) throw new Error(`${item.id} (echt): ${result.error}`); + const oracleCase = oracle.cases.find(entry => entry.id === item.id)!; + let caseCells = 0; let caseEens = 0; + for (const row of oracleCase.activities) { + const task = imported.tasks.find(candidate => candidate.wbsCode === row.activity_code); + const solved = task ? result.tasks.get(task.id) : undefined; + if (!solved) throw new Error(`${item.id} (echt): activiteit ${row.activity_code} niet gesolved`); + const ours: Measured = { + es: solved.earlyStart, ef: solved.earlyFinish, ls: solved.lateStart, lf: solved.lateFinish, + tf: solved.totalFloat, ff: solved.freeFloat, + }; + for (const axis of AXES) { + const raw = row[`${axis.toUpperCase()}_p6`]!; + const want = axis === 'tf' || axis === 'ff' ? p6Number(raw) : p6Instant(raw); + if (want === null) continue; + caseCells++; + if (ours[axis] === want) caseEens++; + } + } + cells += caseCells; eens += caseEens; + perCase[item.id] = `${caseEens}/${caseCells}`; + } + return { cells, eens, perCase }; + }; + const asRead = agreeReal(); + const withoutProjectEndFloat = agreeReal({ useProjectEndDateForFloat: false }); + eq('7a echte bytes, zoals gelezen (de geregistreerde projecteinde-fout inbegrepen)', + { cellen: asRead.cells, eens: asRead.eens }, REAL_AS_READ_PIN); + eq('7b echte bytes, met useProjectEndDateForFloat uit (het gat is precies die fout)', + { cellen: withoutProjectEndFloat.cells, eens: withoutProjectEndFloat.eens }, REAL_WITHOUT_PROJECT_END_PIN); + console.log(`. p6-verified-cases-engine: echte bytes per casus zoals gelezen ${JSON.stringify(asRead.perCase)}`); + console.log(`. p6-verified-cases-engine: echte bytes per casus zonder projecteinde-float ${JSON.stringify(withoutProjectEndFloat.perCase)}`); + const projectEnd = results[0]!.project.endDate; + eq('7c de bron heeft geen enkel einde (plan_end_date en target_end_date leeg) ⇒ projecteinde = projectstart — de fout zelf, gepind', + { start: results[0]!.project.startDate, end: projectEnd, flag: results[0]!.project.schedulingOptions?.useProjectEndDateForFloat }, + REAL_PROJECT_RANGE_PIN); + } +} + +if (diffs.length > 0) { + console.error(`P6 VERIFIED CASES ENGINE RED: ${diffs.length}/${checks} checks rood`); + for (const diff of diffs) console.error(`XX ${diff}`); + process.exit(1); +} +console.log(`P6 VERIFIED CASES ENGINE GREEN: ${checks} checks groen`); diff --git a/tests/planning/check-p6-verified-cases.ts b/tests/planning/check-p6-verified-cases.ts new file mode 100644 index 000000000..d756354a8 --- /dev/null +++ b/tests/planning/check-p6-verified-cases.ts @@ -0,0 +1,150 @@ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFileSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = fileURLToPath(new URL('.', import.meta.url)); +const diffs: string[] = []; +let checks = 0; + +function truthy(label: string, condition: boolean): void { + checks++; + if (!condition) diffs.push(`${label}: verwacht waar, kreeg onwaar`); +} + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +interface P6Row extends Record { + activity_code: string; + ES_p6: string; + EF_p6: string; + LS_p6: string; + LF_p6: string; + TF_p6: string; + FF_p6: string; +} +interface P6CasesFile { + provenance: { + repository: string; + sourceCommit: string; + capture: string; + scope: string; + warning: string; + }; + cases: { id: string; sourceSha256: string; activities: P6Row[] }[]; +} + +const EXPECTED_CASES = [ + { id: '01-fs-chain', sourceSha256: '9d06cd88cfdce11a33d853ca5410088d9ab0a1a82aa384237147cd9a08ac70fb', activities: ['A', 'B', 'C'] }, + { id: '02-ss-with-lag', sourceSha256: '7719cb13f2bfd5297dc5302f9f2ab672376d7b6e7cafcbfa5c52cbbf64c45cae', activities: ['A', 'B'] }, + { id: '03-ff-with-lag', sourceSha256: 'bb8f76a2bdb926bdd0e7177f28c94da117cd8c55b110882f84ae958ed028d2da', activities: ['A', 'B'] }, + { id: '04-sf-edge-case', sourceSha256: 'be04f8ccd8430726635b87654b2039d64fb70779f12562f260bdf3e72d3eb654', activities: ['A', 'B'] }, + { id: '05-negative-float', sourceSha256: '463f4ea2ee5e973340d82b26e2adbf0305b0815161f8b78dc074f381140d22b1', activities: ['A', 'B'] }, + { id: '06-multiple-calendars', sourceSha256: '8623f28be05dfcbee390746c5ece5aacfb5c1501edbee3e74ffee92ac1730432', activities: ['A', 'B'] }, + { id: '07-ontario-holidays', sourceSha256: '23ec9703eab963f8f1300ecdc7bfadd902668314d3008e172048a7adb5042026', activities: ['A'] }, + { id: '08-in-progress-retained-logic', sourceSha256: '67c9811d83cdeace67fe7b410aa59b8550ae30cee287d6ee2bda7cd4347e255d', activities: ['A', 'B'] }, + { id: '09-completed-successor', sourceSha256: '0307a19fb51a61f1ad7b6445cbd6f2a20618ee4408912bc2cd320aca6ed1416b', activities: ['A', 'B'] }, + { id: '10-out-of-sequence-progress', sourceSha256: '2f598578d4539a95ea80698ab2979fd28a3cb007d8fe09e679a1173f00f5c5f6', activities: ['A', 'B'] }, + { id: '11-mandatory-start-finish', sourceSha256: 'b4353c7fcdfff89ae1740cfa1e85a5c72d7c9493a53eea37a5cab246b828453b', activities: ['A', 'B'] }, + { id: '12-snet-fnlt', sourceSha256: '42480cf535150559b1ed5ed11d5e7604ee3c6ae1d666b9a555ab84b4936f0ee6', activities: ['A', 'B'] }, + { id: '13-alap', sourceSha256: '6ed931a167215ec05a8410c0751a02e429026b62410f0fed4f8768746f6a37bc', activities: ['A', 'B', 'C'] }, +] as const; +const EXPECTED_FLOATS = [ + '01-fs-chain/A=0/0', '01-fs-chain/B=0/0', '01-fs-chain/C=0/0', + '02-ss-with-lag/A=0/0', '02-ss-with-lag/B=1/1', + '03-ff-with-lag/A=0/0', '03-ff-with-lag/B=0/0', + '04-sf-edge-case/A=0/3', '04-sf-edge-case/B=2/2', + '05-negative-float/A=-7/0', '05-negative-float/B=-7/0', + '06-multiple-calendars/A=0/0', '06-multiple-calendars/B=1/1', + '07-ontario-holidays/A=0/0', + '08-in-progress-retained-logic/A=0/0', '08-in-progress-retained-logic/B=0/0', + '09-completed-successor/A=0/0', '09-completed-successor/B=/', + '10-out-of-sequence-progress/A=0/0', '10-out-of-sequence-progress/B=0/0', + '11-mandatory-start-finish/A=0/5', '11-mandatory-start-finish/B=0/0', + '12-snet-fnlt/A=0/0', '12-snet-fnlt/B=0/0', + '13-alap/A=0/0', '13-alap/B=0/0', '13-alap/C=0/0', +] as const; +const EXPECTED_P6_CASES_SHA256 = '50a2eb1c8eada4ec0fe6dbeba93c53d78f7d26024bdc21640b03bd5b994127f3'; + +const dataPath = join(HERE, 'cases-p6-verified.json'); +const parsed = JSON.parse(readFileSync(dataPath, 'utf-8')) as P6CasesFile; + +// Breuken die dit vangt: engine-uitvoer of PASS-oordelen overnemen, de tijd weggooien, of een van +// de 13 daadwerkelijk P6-vergelijkbare cases stil verliezen. +eq('1 precies 13 P6-vergelijkbare cases', parsed.cases.length, 13); +eq('1a precies 27 vastgelegde activiteiten', + parsed.cases.reduce((sum, item) => sum + item.activities.length, 0), 27); +eq('1b bronrepository is exact gepind', parsed.provenance.repository, + 'https://github.com/danafitkowski/cpp-cpm-engine.git'); +eq('1c broncommit is exact gepind', parsed.provenance.sourceCommit, + 'c279a5c4ff204ba763a6f9726aa6383574b50475'); +truthy('1d capture-attestatie noemt P6 23.12, datum en ontbrekend primair sheet', + parsed.provenance.capture.includes('P6 23.12') + && parsed.provenance.capture.includes('2026-08-11') + && parsed.provenance.capture.includes('niet meegecommit')); +truthy('1c herkomst begrenst de meetlat tot activity_code + *_p6', + parsed.provenance.scope.includes('activity_code') && parsed.provenance.scope.includes('*_p6')); +truthy('1d waarschuwing benoemt tijdverlies, finishnormalisatie en nagefitte bronengine', + parsed.provenance.warning.includes('tijd') + && parsed.provenance.warning.includes('exclusief/inclusief') + && parsed.provenance.warning.includes('nagefit')); + +eq('1e exacte case-, digest- en activiteitsets', parsed.cases.map(item => ({ + id: item.id, + sourceSha256: item.sourceSha256, + activities: item.activities.map(activity => activity.activity_code), +})), EXPECTED_CASES); +eq('1f TF/FF-pins blijven exact, inclusief de bewust lege completed-successor-cellen', + parsed.cases.flatMap(item => item.activities.map(activity => + `${item.id}/${activity.activity_code}=${activity.TF_p6}/${activity.FF_p6}`)), EXPECTED_FLOATS); +eq('1g alle P6-waarheidscellen zijn corpusloos vastgepind; geen enginekolom kan insluipen', + createHash('sha256').update(JSON.stringify(parsed.cases)).digest('hex'), EXPECTED_P6_CASES_SHA256); + +const expectedKeys = ['activity_code', 'ES_p6', 'EF_p6', 'LS_p6', 'LF_p6', 'TF_p6', 'FF_p6']; +for (const item of parsed.cases) { + for (const activity of item.activities) { + eq(`${item.id}/${activity.activity_code}: uitsluitend identiteit en *_p6-kolommen`, + Object.keys(activity), expectedKeys); + for (const key of ['ES_p6', 'EF_p6', 'LS_p6', 'LF_p6'] as const) { + const value = activity[key]; + truthy(`${item.id}/${activity.activity_code}/${key}: kloktijd onvertaald bewaard`, + value === '' || /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?: A)?$/.test(value)); + } + for (const key of ['TF_p6', 'FF_p6'] as const) { + const value = activity[key]; + truthy(`${item.id}/${activity.activity_code}/${key}: numeriek of expliciet leeg`, + value === '' || /^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$/.test(value)); + } + } +} + +// Met de publieke bron gemount moet de generator byte-identiek reproduceren. Zonder bron blijft de +// gecommitte data volledig gevalideerd en wordt alleen deze herkomstpariteitscheck overgeslagen. +const sourceRoot = process.env.OPS_P6_COMPARISON; +if (sourceRoot) { + const generatedPath = join(mkdtempSync(join(tmpdir(), 'ops-p6-verified-')), 'cases.json'); + execFileSync(process.execPath, [ + join(HERE, '../../scripts/generate-p6-verified-cases.mjs'), + sourceRoot, + generatedPath, + ]); + eq('2 generator reproduceert cases-p6-verified.json byte-identiek', + readFileSync(generatedPath, 'utf-8'), readFileSync(dataPath, 'utf-8')); +} else { + console.log('OK p6-verified-cases: bron niet aanwezig (OPS_P6_COMPARISON) — generatorpariteit overgeslagen'); +} + +if (diffs.length === 0) { + console.log(`OK p6-verified-cases: ${checks} checks groen`); +} else { + console.log(`XX p6-verified-cases: ${diffs.length} afwijking(en) van ${checks}`); + for (const diff of diffs) console.log(` XX ${diff}`); + process.exit(1); +} diff --git a/tests/planning/check-recorded-dates-mark.ts b/tests/planning/check-recorded-dates-mark.ts new file mode 100644 index 000000000..2dfc2930e --- /dev/null +++ b/tests/planning/check-recorded-dates-mark.ts @@ -0,0 +1,371 @@ +/** + * "Datums zoals opgeslagen" (issue #63, XER-etappeplan laag 3, T6) — de taaktabel-/eigenschappen- + * paneelmarkering. Corpusloos, twee lagen: + * + * 1. `src/state/recordedDatesSelectors.ts` (`unrecordedAxes`/`recordedTaskMark`) als pure functie, + * los van de store en los van de taakgrid. + * 2. De draadverbinding in `taskColumnRegistry.ts`: de nieuwe kolom `recorded.source` + * (`available` alleen met `ctx.recordedMark`) en de "niet vastgelegd"-tak op de vier bestaande + * late-/floatkolommen (`ctx.recordedUnrecordedAxes`). + * + * MUTATIEBEWIJS (handmatig geverifieerd, niet in deze suite herhaald): het verwijderen van de + * `ctx.recordedUnrecordedAxes?.(task).includes(axis)`-tak uit `recordedAxisFormat` + * (`taskColumnRegistry.ts`) laat de assertie "onvolledige late/floatas toont 'niet vastgelegd', niet + * de rauwe `?? 0`-terugval" hieronder ROOD gaan — de kolom toont dan weer een verzonnen `0`. + */ +import type { Task } from '@/types/task'; +import type { RecordedDatesState, RecordedTime } from '@/engine/scheduler/recordedDates'; +import type { TaskColumnContext } from '@/types/taskGrid'; +import { recordedGridBinding, recordedNoticeState, recordedTaskMark, unrecordedAxes } from '@/state/recordedDatesSelectors'; +import { useAppStore } from '@/state/appStore'; +import { readIFC } from '@/services/ifc/ifcReader'; +import { externIfc } from '../fixtures/recordedDatesIfc'; +import { buildTaskColumnRegistry } from '@/engine/taskGrid/taskColumnRegistry'; +import { buildTaskRelationIndex } from '@/engine/taskGrid/relationIndex'; +import { createTaskGridAdapter } from '@/engine/taskGrid/taskGridAdapter'; +import { taskColumnId } from '@/engine/taskGrid/fieldIds'; + +const diffs: string[] = []; +let checks = 0; + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +function ok(label: string, condition: boolean): void { + checks++; + if (!condition) diffs.push(label); +} + +// ── unrecordedAxes ────────────────────────────────────────────────────────────────────────── +const fullRecord: RecordedTime = { + start: '2026-01-01', finish: '2026-01-02', + lateStart: '2026-01-01', lateFinish: '2026-01-02', totalFloat: 0, freeFloat: 0, isCritical: true, +}; +eq('volledige vastlegging: geen enkele as ontbreekt', unrecordedAxes(fullRecord), []); +eq('geen vastlegging: lege lijst (geen "alle vier ontbreken"-verwarring bij de aanroeper)', + unrecordedAxes(undefined), []); +eq('alleen lateStart ontbreekt', unrecordedAxes({ ...fullRecord, lateStart: undefined }), ['ls']); +eq('alleen lateFinish ontbreekt', unrecordedAxes({ ...fullRecord, lateFinish: undefined }), ['lf']); +eq('alleen totalFloat ontbreekt', unrecordedAxes({ ...fullRecord, totalFloat: undefined }), ['tf']); +eq('alleen freeFloat ontbreekt', unrecordedAxes({ ...fullRecord, freeFloat: undefined }), ['ff']); +eq('alle vier optionele assen ontbreken', unrecordedAxes({ + start: '2026-01-01', finish: '2026-01-02', +}), ['ls', 'lf', 'tf', 'ff']); + +// ── recordedTaskMark ──────────────────────────────────────────────────────────────────────── +function taskWith(time: Partial): Task { + return { + id: 't-1', name: 'Taak', description: '', wbsCode: '1', taskType: 'CONSTRUCTION', + status: 'NOT_STARTED', isMilestone: false, priority: 500, parentId: null, childIds: [], + resourceIds: [], activityCodes: {}, customFields: {}, externalLinks: [], notes: [], + time: { + durationType: 'WORKTIME', durationUnit: 'days', scheduleDuration: 1, + scheduleStart: '2026-01-01', scheduleFinish: '2026-01-02', + earlyStart: '2026-01-01', earlyFinish: '2026-01-02', + lateStart: '2026-01-01', lateFinish: '2026-01-02', + freeFloat: 0, totalFloat: 0, isCritical: true, completion: 0, + ...time, + }, + } as Task; +} + +const recordedState = (times: Record): RecordedDatesState => ( + { times, total: Object.keys(times).length, shifted: 0 } +); + +eq('geen vastlegging (recordedDates === null) ⇒ undefined, ongeacht datesAsRecorded', + [recordedTaskMark(null, false, taskWith({})), recordedTaskMark(null, true, taskWith({}))], + [undefined, undefined]); + +eq('taak niet in de vastlegging ⇒ undefined', + recordedTaskMark(recordedState({ 'ander-id': fullRecord }), false, taskWith({})), undefined); + +eq('buiten de modus, vroege datums identiek aan de vastlegging, volledige assen ⇒ undefined (niets te melden)', + recordedTaskMark( + recordedState({ 't-1': fullRecord }), false, + taskWith({ earlyStart: fullRecord.start, earlyFinish: fullRecord.finish }), + ), undefined); + +eq('buiten de modus, vroege start wijkt af ⇒ deviates', + recordedTaskMark( + recordedState({ 't-1': fullRecord }), false, + taskWith({ earlyStart: '2026-02-01', earlyFinish: fullRecord.finish }), + ), 'deviates'); + +eq('buiten de modus, vroeg einde wijkt af ⇒ deviates', + recordedTaskMark( + recordedState({ 't-1': fullRecord }), false, + taskWith({ earlyStart: fullRecord.start, earlyFinish: '2026-02-01' }), + ), 'deviates'); + +// Critreview laag 3, bevinding 1: BUITEN de modus staat onze eigen, zojuist berekende late-/ +// spelinguitvoer op het scherm. "Deels niet vastgelegd" gaat over wat er getoond wordt, niet over +// het bestand — dus geen markering, en (zie de bindingsectie onderaan) al helemaal geen verbergen. +eq('buiten de modus, identieke vroege datums maar onvolledige vastlegging ⇒ undefined', + recordedTaskMark( + recordedState({ 't-1': { ...fullRecord, lateFinish: undefined } }), false, + taskWith({ earlyStart: fullRecord.start, earlyFinish: fullRecord.finish }), + ), undefined); + +eq('buiten de modus blijft "wijkt af" wél gelden bij een onvolledige vastlegging', + recordedTaskMark( + recordedState({ 't-1': { ...fullRecord, lateFinish: undefined } }), false, + taskWith({ earlyStart: '2026-02-01', earlyFinish: fullRecord.finish }), + ), 'deviates'); + +eq('IN de modus, volledige vastlegging ⇒ undefined ("wijkt af" is hier geen zinvol signaal)', + recordedTaskMark( + recordedState({ 't-1': fullRecord }), true, + taskWith({ earlyStart: fullRecord.start, earlyFinish: fullRecord.finish }), + ), undefined); + +eq('IN de modus, ONVOLLEDIGE vastlegging ⇒ partly-unrecorded (eigenschap van het bestand, niet de weergavestand)', + recordedTaskMark( + recordedState({ 't-1': { ...fullRecord, totalFloat: undefined } }), true, + taskWith({ earlyStart: fullRecord.start, earlyFinish: fullRecord.finish }), + ), 'partly-unrecorded'); + +eq('IN de modus met een taak wier vroege datums (kunstmatig) toch afwijken ⇒ nooit deviates', + recordedTaskMark( + recordedState({ 't-1': fullRecord }), true, + taskWith({ earlyStart: '2026-09-09', earlyFinish: fullRecord.finish }), + ), undefined); + +// ── Draadverbinding: taskColumnRegistry.ts ───────────────────────────────────────────────── +const registryInput = { + projectId: 'project:1', activityCodeTypes: [], customFieldDefs: [], baselines: [], +}; + +function baseContext(overrides: Partial = {}): TaskColumnContext { + const t = taskWith({}); + return { + projectId: 'project:1', + tasksById: new Map([[t.id, t]]), + relationIndex: buildTaskRelationIndex([t], [], null), + assignmentsByTaskId: new Map(), + resourcesById: new Map(), + baselinesById: new Map(), + scheduleStale: false, + ...overrides, + }; +} + +const registryPlain = buildTaskColumnRegistry(registryInput); + +ok('zonder ctx.recordedMark bestaat de kolom recorded.source niet in de gefilterde beschikbare set', + !registryPlain.filter(c => c.available(baseContext())).some(c => c.id === 'recorded.source')); + +const withMarkCtx = baseContext({ + recordedMark: () => 'deviates', + recordedUnrecordedAxes: () => ['lf', 'tf'], +}); +ok('mét ctx.recordedMark bestaat de kolom recorded.source wél in de gefilterde beschikbare set', + registryPlain.filter(c => c.available(withMarkCtx)).some(c => c.id === 'recorded.source')); + +const recordedSourceColumn = registryPlain.find(c => c.id === 'recorded.source')!; +ok('recorded.source-descriptor bestaat altijd in de ongefilterde registry (available bepaalt zichtbaarheid)', + !!recordedSourceColumn); + +const task = taskWith({}); +eq('recorded.source toont de vertaalde badge voor "deviates"', + recordedSourceColumn.format(recordedSourceColumn.read(task, withMarkCtx), task, { + ...withMarkCtx, labelForText: key => ({ 'recordedDates.markDeviates': 'Wijkt af' }[key] ?? key), + }), 'Wijkt af'); +eq('recorded.source toont de vertaalde badge voor "partly-unrecorded"', + recordedSourceColumn.format('partly-unrecorded', task, { + ...withMarkCtx, labelForText: key => ({ 'recordedDates.markPartlyUnrecorded': 'Deels niet vastgelegd' }[key] ?? key), + }), 'Deels niet vastgelegd'); +eq('recorded.source toont een streepje zonder markering', + recordedSourceColumn.format(undefined, task, withMarkCtx), '—'); +eq('recorded.source valt terug op de Engelse sleutelnaam zonder labelForText (nooit een lege cel)', + recordedSourceColumn.format('deviates', task, { ...withMarkCtx, labelForText: undefined }), 'deviates'); + +// Klembord (critreview laag 3, bevinding 11): zonder eigen `copy` leverde `copyScalar` het rauwe +// token `deviates` terwijl de cel "Wijkt af" toont. MUTATIEBEWIJS: haal de `copy`-functie uit de +// kolomdefinitie ⇒ de eerste van deze twee slaat rood. +const copyCtx = { + ...withMarkCtx, + labelForText: (key: string) => ({ + 'recordedDates.markDeviates': 'Wijkt af', + 'recordedDates.markPartlyUnrecorded': 'Deels niet vastgelegd', + }[key] ?? key), +}; +eq('recorded.source kopieert dezelfde tekst als de cel toont, niet het rauwe token', + recordedSourceColumn.copy(task, copyCtx), 'Wijkt af'); +eq('recorded.source kopieert een LEGE cel zonder markering (geen em-dash in een plakactie)', + recordedSourceColumn.copy(task, { ...copyCtx, recordedMark: () => undefined }), ''); + +// De vier late-/floatkolommen: alleen de assen die `recordedUnrecordedAxes` noemt tonen "niet +// vastgelegd"; de rest blijft de gewone geformatteerde waarde (byte-identiek aan vóór T6). +const lateStartCol = registryPlain.find(c => c.id === 'task.time.lateStart')!; +const lateFinishCol = registryPlain.find(c => c.id === 'task.time.lateFinish')!; +const totalFloatCol = registryPlain.find(c => c.id === 'task.time.totalFloat')!; +const freeFloatCol = registryPlain.find(c => c.id === 'task.time.freeFloat')!; + +const axisCtx = baseContext({ + recordedMark: () => undefined, + recordedUnrecordedAxes: () => ['lf', 'tf'], + labelForText: key => ({ 'recordedDates.notRecorded': 'Niet vastgelegd' }[key] ?? key), +}); + +eq('lateStart is NIET in de onvastgelegde-assenlijst ⇒ gewoon geformatteerd, geen "niet vastgelegd"', + lateStartCol.format(lateStartCol.read(task, axisCtx), task, axisCtx), task.time.lateStart); +eq('lateFinish IS in de onvastgelegde-assenlijst ⇒ "niet vastgelegd" i.p.v. de rauwe (`?? rec.start`) waarde', + lateFinishCol.format(lateFinishCol.read(task, axisCtx), task, axisCtx), 'Niet vastgelegd'); +eq('totalFloat IS in de onvastgelegde-assenlijst ⇒ "niet vastgelegd" i.p.v. een verzonnen 0', + totalFloatCol.format(totalFloatCol.read(task, axisCtx), task, axisCtx), 'Niet vastgelegd'); +eq('freeFloat is NIET in de onvastgelegde-assenlijst ⇒ gewoon het getal 0, geen "niet vastgelegd"', + freeFloatCol.format(freeFloatCol.read(task, axisCtx), task, axisCtx), '0'); + +// Zonder de naad (ctx.recordedUnrecordedAxes ontbreekt, zoals op elk niet-XER-document) blijft +// het gedrag van vóór T6 volledig intact — dit is de "geen regressie op bestaande documenten"-poort. +const noAxisCtx = baseContext(); +eq('zonder recordedUnrecordedAxes: totalFloat toont gewoon het getal, geen "niet vastgelegd"', + totalFloatCol.format(totalFloatCol.read(task, noAxisCtx), task, noAxisCtx), '0'); +eq('zonder recordedUnrecordedAxes: lateFinish toont gewoon de datum', + lateFinishCol.format(lateFinishCol.read(task, noAxisCtx), task, noAxisCtx), task.time.lateFinish); + +// ── recordedNoticeState: de badge in het eigenschappenpaneel ───────────────────────────────── +// Zelfde bron als de tabelkolom (critreview laag 3, bevinding 1): buiten de modus geen +// "deels niet vastgelegd" meer, en IN de modus wint dat signaal juist van het kale "actief" — +// anders zegt de badge iets anders dan de kolom `recorded.source` over dezelfde taak. +{ + const volledig = recordedState({ 't-1': fullRecord }); + const onvolledig = recordedState({ 't-1': { ...fullRecord, totalFloat: undefined } }); + const gelijk = taskWith({ earlyStart: fullRecord.start, earlyFinish: fullRecord.finish }); + const afwijkend = taskWith({ earlyStart: '2026-02-01', earlyFinish: fullRecord.finish }); + + eq('badge buiten de modus, afwijkende datums ⇒ "wijkt af"', + recordedNoticeState(volledig, false, afwijkend), 'deviates'); + eq('badge buiten de modus, onvolledige vastlegging maar gelijke datums ⇒ geen badge', + recordedNoticeState(onvolledig, false, gelijk), undefined); + eq('badge IN de modus, volledige vastlegging ⇒ "actief"', + recordedNoticeState(volledig, true, gelijk), 'active'); + eq('badge IN de modus, onvolledige vastlegging ⇒ "deels niet vastgelegd" (zelfde als de kolom)', + recordedNoticeState(onvolledig, true, gelijk), 'partly-unrecorded'); + eq('badge zonder vastlegging voor deze taak ⇒ niets, ook in de modus', + recordedNoticeState(recordedState({}), true, gelijk), undefined); +} + +// ── recordedGridBinding: de POORT tussen documentstate en de taakgrid-naad ────────────────── +// Critreview laag 3, bevinding 1 (BEVESTIGD, regressie op de bestaande #63-route): de naad hing op +// `recordedDates !== null`, dus in de AANBOD-stand verving de tabel echte CPM-uitvoer door "Niet +// vastgelegd" — permanent, want in die stand wist zelfs F5 de vastlegging niet. +{ + const recorded = recordedState({ 't-1': { start: '2026-01-01', finish: '2026-01-02' } }); + + const aanbod = recordedGridBinding(recorded, false); + ok('aanbodstand: de kolom recorded.source blijft bestaan (markering náást de waarde)', + aanbod.recordedMark !== undefined); + // `ok(... === undefined)` en niet `eq(..., undefined)`: `JSON.stringify` van een FUNCTIE is óók + // `undefined`, dus een eq-vergelijking hier zou een aanwezige naad niet van een afwezige kunnen + // onderscheiden — de assertie zou vacuüm zijn (gemeten tijdens het mutatiebewijs). + ok('aanbodstand: GEEN onvastgelegde-assennaad — de late-/floatkolommen tonen de echte berekening', + aanbod.recordedUnrecordedAxes === undefined); + + const modus = recordedGridBinding(recorded, true); + ok('modus: de onvastgelegde-assennaad bestaat wél', modus.recordedUnrecordedAxes !== undefined); + eq('modus: en noemt de vier assen die het bestand niet vastlegde', + modus.recordedUnrecordedAxes?.(taskWith({})), ['ls', 'lf', 'tf', 'ff']); + + const zonder = recordedGridBinding(null, false); + ok('geen vastlegging: beide naden ontbreken (byte-identiek aan een document van vóór T6)', + zonder.recordedMark === undefined && zonder.recordedUnrecordedAxes === undefined); +} + +// ── Einde-tot-eind door de ECHTE store: een gewone IFC in de aanbodstand ───────────────────── +// Geen synthetische context maar de documentstate zoals `applyLoadedProject` hem achterlaat, door +// dezelfde binding en dezelfde kolomdescriptor. Dit is de case die de review miste: de bedrading +// vanuit het rasteroppervlak werd nergens getoetst. +// MUTATIEBEWIJS: zet de poort in `recordedGridBinding` terug op `recorded ? … : undefined` ⇒ de +// twee `lateStart`/`totalFloat`-asserties hieronder slaan ROOD ("Niet vastgelegd" i.p.v. de waarde). +{ + const S = () => useAppStore.getState(); + S().newProject(); + S().applyLoadedProject(readIFC(externIfc('mark')), { filePath: null, recompute: true }); + ok('voorwaarde: de gewone IFC-fixture staat in de AANBOD-stand (aanbod gevuld, modus uit)', + S().recordedDates !== null && S().datesAsRecorded === false); + + const binding = recordedGridBinding(S().recordedDates, S().datesAsRecorded); + const storeTask = S().tasks.find(t => t.wbsCode === '1.1')!; + ok('voorwaarde: het bestand legde de vier optionele assen NIET vast (anders bewijst dit niets)', + unrecordedAxes(S().recordedDates!.times[storeTask.id]).length === 4); + + const ctx = baseContext({ + tasksById: new Map(S().tasks.map(t => [t.id, t])), + relationIndex: buildTaskRelationIndex(S().tasks, S().sequences, S().cpmResult), + ...binding, + labelForText: key => ({ 'recordedDates.notRecorded': 'Niet vastgelegd' }[key] ?? key), + }); + eq('aanbodstand door de echte store: lateStart toont de BEREKENDE datum, niet "Niet vastgelegd"', + lateStartCol.format(lateStartCol.read(storeTask, ctx), storeTask, ctx), storeTask.time.lateStart); + eq('aanbodstand door de echte store: totalFloat toont de BEREKENDE speling, niet "Niet vastgelegd"', + totalFloatCol.format(totalFloatCol.read(storeTask, ctx), storeTask, ctx), + String(storeTask.time.totalFloat)); + + // Tegenproef op hetzelfde document: zodra de gebruiker "Opgeslagen datums tonen" kiest, IS de + // "niet vastgelegd"-tekst juist het eerlijke antwoord — het bestand zei daar niets. + S().showRecordedDates(); + ok('voorwaarde: showRecordedDates zet de modus aan', S().datesAsRecorded === true); + const modusCtx = baseContext({ + tasksById: new Map(S().tasks.map(t => [t.id, t])), + relationIndex: buildTaskRelationIndex(S().tasks, S().sequences, S().cpmResult), + ...recordedGridBinding(S().recordedDates, S().datesAsRecorded), + labelForText: key => ({ 'recordedDates.notRecorded': 'Niet vastgelegd' }[key] ?? key), + }); + const modusTask = S().tasks.find(t => t.wbsCode === '1.1')!; + eq('IN de modus: lateStart toont wél "Niet vastgelegd" (het bestand gaf die as niet)', + lateStartCol.format(lateStartCol.read(modusTask, modusCtx), modusTask, modusCtx), 'Niet vastgelegd'); +} + +// ── De ECHTE naad: `taskGridAdapter.getCell` mét dateNotation (her-check laag 3, bev. 1 en 2) ── +// De asserties hierboven riepen `descriptor.format(...)` rechtstreeks aan. Het product doet dat +// niet: `getCell` slaat `format` over voor datumkolommen zodra `domain.dateNotation` gezet is (en +// dat is in het product altijd zo), en `copyText` ging via `copyScalar(read(...))`. Gemeten vóór de +// fix: `text="01-01-2026"` en `copyText="0"` waar "Niet vastgelegd" en "" hoorden. Deze sectie meet +// daarom op `getCell().text`/`.copyText` en `copyCell`, met de productinstelling `dateNotation: 'dmy'`. +{ + const gridTask = taskWith({ lateStart: '2026-01-01', lateFinish: '2026-01-02', totalFloat: 0, freeFloat: 0 }); + const makeAdapter = (recordedUnrecordedAxes: ((task: Task) => readonly ('ls' | 'lf' | 'tf' | 'ff')[]) | undefined) => + createTaskGridAdapter({ + surfaceId: 'full-task-grid', projectId: 'p-1', + rows: [{ kind: 'task', rowKey: 'occ-1', task: gridTask, depth: 0, dimmed: false }], + tasks: [gridTask], sequences: [], assignments: [], resources: [], baselines: [], + activityCodeTypes: [], customFieldDefs: [], scheduleStale: false, wbsAutoNumber: false, + selectedTaskIds: [], labelForColumn: key => key, + labelForText: key => ({ 'recordedDates.notRecorded': 'Niet vastgelegd' }[key] ?? key), + dateNotation: 'dmy', + recordedMark: () => undefined, + recordedUnrecordedAxes, + }); + const inMode = makeAdapter(() => ['ls', 'tf']); + const cell = (adapter: ReturnType, id: string) => adapter.getCell('occ-1', taskColumnId(id)); + eq('adapter+dateNotation: lateStart (niet vastgelegd) toont "Niet vastgelegd", géén verzonnen dd-mm-jjjj', + cell(inMode, 'task.time.lateStart')?.text, 'Niet vastgelegd'); + eq('adapter+dateNotation: lateStart (niet vastgelegd) kopieert LEEG, niet de `?? rec.start`-terugval', + [cell(inMode, 'task.time.lateStart')?.copyText, inMode.copyCell('occ-1', taskColumnId('task.time.lateStart'))], ['', '']); + eq('adapter+dateNotation: totalFloat (niet vastgelegd) toont "Niet vastgelegd" en kopieert leeg', + [cell(inMode, 'task.time.totalFloat')?.text, cell(inMode, 'task.time.totalFloat')?.copyText], ['Niet vastgelegd', '']); + eq('adapter+dateNotation: lateFinish (wél vastgelegd) toont gewoon de datum in dmy-notatie', + cell(inMode, 'task.time.lateFinish')?.text, '02-01-2026'); + eq('adapter+dateNotation: freeFloat (wél vastgelegd) toont en kopieert het getal', + [cell(inMode, 'task.time.freeFloat')?.text, cell(inMode, 'task.time.freeFloat')?.copyText], ['0', '0']); + eq('adapter+dateNotation: de celtitel verraadt de verzonnen datum evenmin', + cell(inMode, 'task.time.lateStart')?.title, 'Niet vastgelegd'); + const noMode = makeAdapter(undefined); + eq('adapter+dateNotation zónder modus: byte-identiek — lateStart toont de datum en kopieert die ook', + [cell(noMode, 'task.time.lateStart')?.text, cell(noMode, 'task.time.lateStart')?.copyText], ['01-01-2026', '01-01-2026']); + eq('adapter+dateNotation zónder modus: totalFloat toont het getal', + [cell(noMode, 'task.time.totalFloat')?.text, cell(noMode, 'task.time.totalFloat')?.copyText], ['0', '0']); +} + +if (diffs.length > 0) { + console.error(`XX recorded-dates-mark: ${diffs.length}/${checks} checks gefaald`); + for (const diff of diffs) console.error(` ${diff}`); + process.exit(1); +} +console.log(`OK recorded-dates-mark: ${checks}/${checks} checks groen`); diff --git a/tests/planning/check-recorded-dates.ts b/tests/planning/check-recorded-dates.ts index 48c7d71a7..142cc4545 100644 --- a/tests/planning/check-recorded-dates.ts +++ b/tests/planning/check-recorded-dates.ts @@ -15,13 +15,20 @@ * vaste ISO-datums, nooit `new Date()` zonder anker. */ import { + applyRecordedTimesToTasks, captureRecordedDates, countShiftedTasks, cpmResultFromRecorded, + type RecordedTime, } from '@/engine/scheduler/recordedDates'; import { createDefaultCalendar } from '@/engine/calendar/defaultCalendar'; +import type { ImportResult } from '@/services/importTypes'; import type { Task } from '@/types/task'; import { useAppStore } from '@/state/appStore'; +import { recoveryInputFromParsed } from '@/state/documentContract'; +import { recordedDatesActiveKey, recordedDatesTaskActiveKey } from '@/components/layout/recordedDatesNoticeText'; +import { unrecordedExportGate } from '@/state/recordedDatesSelectors'; +import { writeCSV } from '@/services/csv/csvWriter'; import { readIFC } from '@/services/ifc/ifcReader'; import { writeIFC } from '@/services/ifc/ifcWriter'; import { buildWriteIFCInput } from '@/state/ifcSaveInput'; @@ -147,6 +154,53 @@ const zonderIfcTaskTime = captureRecordedDates( eq('1t taak zonder IfcTaskTime landt niet in times', Object.keys(zonderIfcTaskTime.times), []); eq('1u taak zonder IfcTaskTime telt niet mee in total', zonderIfcTaskTime.total, 0); +// ── (1B) Bron-orakel — laag 0 (XER-etappeplan §3.3, taak T3) ────────────────── +// De derde parameter (`recordedTimes`) heeft VOORRANG boven `recordedFields` — de twee kanalen +// worden nooit gemengd. `mk('a', ...)` zet hier bewust een early-paar dat de early-laag zou geven +// (2099-...) om het contrast met de orakelwaarde (2026-07-...) scherp te maken: valt de +// implementatie stiekem terug op `recordedFields` zodra beide zijn meegegeven, dan geeft 1x/1y de +// 2099-datum en gaat deze case ROOD — dat IS het mutatiebewijs. +const oracle: Record = { + a: { + start: '2026-07-01', finish: '2026-07-05', + lateStart: '2026-07-02', lateFinish: '2026-07-06', + totalFloat: 1, freeFloat: 0.5, isCritical: false, + }, +}; +const metOrakel = captureRecordedDates( + [mk('a', { earlyStart: '2099-01-01', earlyFinish: '2099-01-05' })], + { a: ['earlyStart', 'earlyFinish'] }, // zou zonder orakel de early-laag geven + oracle, +); +eq('1x orakel wint van recordedFields (start)', metOrakel.times['a'].start, '2026-07-01'); +eq('1y orakel wint van recordedFields (finish)', metOrakel.times['a'].finish, '2026-07-05'); +eq('1z orakel draagt late/float/isCritical ongewijzigd door', { + lateStart: metOrakel.times['a'].lateStart, lateFinish: metOrakel.times['a'].lateFinish, + totalFloat: metOrakel.times['a'].totalFloat, freeFloat: metOrakel.times['a'].freeFloat, + isCritical: metOrakel.times['a'].isCritical, +}, { + lateStart: '2026-07-02', lateFinish: '2026-07-06', + totalFloat: 1, freeFloat: 0.5, isCritical: false, +}); +eq('1aa orakel-total = aantal entries in recordedTimes, niet in recordedFields', metOrakel.total, 1); + +// Filtering op onbekende taak-ids: zelfde regel als de andere twee lagen (zie 2e hieronder). +const orakelMetOnbekend: Record = { + a: { start: '2026-08-01', finish: '2026-08-02' }, + zzz: { start: '2026-08-03', finish: '2026-08-04' }, +}; +const gefilterd = captureRecordedDates([mk('a')], undefined, orakelMetOnbekend); +eq('1ab orakel filtert op taak-ids die echt in tasks zitten', Object.keys(gefilterd.times), ['a']); +eq('1ac orakel-total telt alleen de overgebleven, gefilterde entries', gefilterd.total, 1); + +// Randgevallen, symmetrisch met (2e)/(2f) hieronder. +eq('1ad orakel + lege takenlijst ⇒ lege vastlegging', + captureRecordedDates([], undefined, { a: { start: '2026-01-01', finish: '2026-01-02' } }), + { times: {}, total: 0 }); +eq('1ae leeg orakel-object (aanwezig, maar zonder entries) ⇒ lege vastlegging, GEEN terugval op recordedFields', + captureRecordedDates([mk('a')], { a: ['scheduleStart', 'scheduleFinish'] }, {}), + { times: {}, total: 0 }); + // ── (2) Verschiltelling ────────────────────────────────────────────────────── // Schedule-paar aanwezig (niet `[]`, zie MOET 1 hierboven — anders wordt de taak overgeslagen en // blijft `times` leeg, wat deze sectie niets zou laten testen). @@ -227,6 +281,47 @@ eq('3q times gevuld maar tasks leeg ⇒ geen projecteinde', legeTasks.projectEnd const legeBeide = cpmResultFromRecorded({}, [], cal); eq('3r volledig leeg ⇒ projectDuration 0', legeBeide.projectDuration, 0); +// ── (3B) applyRecordedTimesToTasks — gedeelde kern (XER-etappeplan §3.4, taak T3) ───────────── +// Rechtstreekse eenheidstest op de kern zelf (los van de winkel/`showRecordedDates`, die in sectie +// (8) hieronder via de ECHTE store getest wordt). Bewijst: (a) de teruggegeven `CPMResult` is +// identiek aan `cpmResultFromRecorded` op dezelfde `times`; (b) de taken worden IN-PLACE bijgewerkt +// met exact de oude terugvallen (`?? rec.start`/`?? 0`/`?? false`); (c) `interferingFloat`/ +// `isNearCritical`/`floatPath` worden gewist — MUTATIEBEWIJS: haal één van de drie wis-regels uit +// `applyRecordedTimesToTasks` en 3ab/3ac/3ad hieronder gaat ROOD (uitgevoerd en teruggedraaid +// tijdens de bouw van deze taak, zie voortgangsrapport); (d) een taak zonder vastlegging blijft +// volledig onaangeroerd. +{ + const kernTasks = [ + mk('a', { + earlyStart: '2026-09-01', earlyFinish: '2026-09-05', + interferingFloat: 3, isNearCritical: true, floatPath: 0, + }), + mk('b'), // geen vastlegging voor 'b' ⇒ moet volledig ongemoeid blijven + ]; + const kernTimes = captureRecordedDates( + [mk('a', { earlyStart: '2026-09-01', earlyFinish: '2026-09-05', totalFloat: 2, isCritical: true })], + { a: ['earlyStart', 'earlyFinish', 'totalFloat', 'isCritical'] }, + ).times; + const kernResult = applyRecordedTimesToTasks(kernTasks, kernTimes, cal); + const verwachtResult = cpmResultFromRecorded(kernTimes, kernTasks, cal); + eq('3s applyRecordedTimesToTasks levert hetzelfde CPMResult als cpmResultFromRecorded op dezelfde times', + kernResult, verwachtResult); + const aNa = kernTasks.find((t) => t.id === 'a')!; + const bNa = kernTasks.find((t) => t.id === 'b')!; + eq('3t taak a — earlyStart bijgewerkt uit de vastlegging', aNa.time.earlyStart, '2026-09-01'); + eq('3u taak a — earlyFinish bijgewerkt uit de vastlegging', aNa.time.earlyFinish, '2026-09-05'); + eq('3v taak a — lateStart-terugval blijft ?? rec.start (geen late* in het bestand)', aNa.time.lateStart, '2026-09-01'); + eq('3w taak a — lateFinish-terugval blijft ?? rec.finish', aNa.time.lateFinish, '2026-09-05'); + eq('3x taak a — totalFloat komt uit het bestand (geen terugval nodig)', aNa.time.totalFloat, 2); + eq('3y taak a — isCritical komt uit het bestand', aNa.time.isCritical, true); + eq('3z taak b (geen vastlegging) — time volledig onaangeroerd', bNa.time, mk('b').time); + eq('3aa taak b — earlyStart blijft de mk-default (bewijst dat filtering op aanwezigheid werkt)', + bNa.time.earlyStart, '2026-03-02'); + eq('3ab taak a — interferingFloat gewist', aNa.time.interferingFloat, undefined); + eq('3ac taak a — isNearCritical gewist', aNa.time.isNearCritical, undefined); + eq('3ad taak a — floatPath gewist', aNa.time.floatPath, undefined); +} + // ── (4) Gemiste deadlines ───────────────────────────────────────────────────── // `deadline` staat op Task zelf (niet op Task['time']) — vandaar `mk`'s derde parameter. const overDeadline = mk('a', { earlyStart: '2026-03-02', earlyFinish: '2026-03-06' }, { deadline: '2026-03-05' }); @@ -390,6 +485,92 @@ const earlyStartOf = (id: string) => S().tasks.find((t) => t.id === id)!.time.ea eq('7t schedule-only: vastgelegde finish van b komt uit de schedule-laag', S().recordedDates?.times[bIdSAfterLoad]?.finish, '2026-03-20'); } +// ── (7B) Standaard-aan bij het laden — bron-orakel (XER-etappeplan §3.5, taak T4) ───────────── +// Hergebruikt de fixture van (7) hierboven, maar routeert de vastlegging via het ORAKEL-kanaal +// (`recordedTimes`/`recordedTimesOrigin`) i.p.v. `recordedFields` — precies het contract dat +// `readXER` (bak 4) levert. Dit bestand blijft bewust reader-agnostisch: de synthetische +// `ImportResult` hieronder (gebouwd uit een ECHTE IFC-parse, dus geen verzonnen structuur) bewijst +// het LAADPAD-gedrag zonder aan een specifieke lezer te hangen; `check-xer-open-wiring.ts` bewijst +// hetzelfde met een echte XER. +{ + const rtOracleSource = readIFC(externIfc('7B')); + const oracleTimes = captureRecordedDates(rtOracleSource.tasks, rtOracleSource.recordedFields).times; + const asXer: ImportResult = { + ...rtOracleSource, + recordedFields: undefined, + recordedTimes: oracleTimes, + recordedTimesOrigin: 'xer', + }; + + S().newProject(); + const undoVoorLaad = S().historyEvents.filter(event => event.state === 'applied').length; + S().applyLoadedProject(asXer, { filePath: null, recompute: true }); + + eq('7u bron-orakel + restverschillen ⇒ modus staat AAN meteen na laden', S().datesAsRecorded, true); + truthy('7v recordedDates is gevuld', S().recordedDates !== null); + eq('7w shifted telt de verschoven taak (b)', S().recordedDates?.shifted, 1); + eq('7w2 de vastlegging draagt de herkomst die de meldingstekst stuurt', S().recordedDates?.origin, 'xer'); + eq('7x scheduleStale is false in de modus (risico §5.1, expliciet gecontroleerd)', S().scheduleStale, false); + truthy('7y cpmResult is de reconstructie (geen solve)', S().cpmResult !== null); + eq('7z projectEnd komt uit het bestand (orakel), niet uit een herberekening', S().cpmResult?.projectEnd, '2026-03-20'); + eq('7aa het aanzetten bij het laden pusht GEEN undo-snapshot', + S().historyEvents.filter(event => event.state === 'applied').length, undoVoorLaad); + + S().undo(); + eq('7ab undo() direct na het laden raakt de modus niet (er is niets om naar terug te gaan)', + S().datesAsRecorded, true); + eq('7ac …noch de vastlegging', S().recordedDates?.shifted, 1); + + // MUTATIEBEWIJS (O6-patroon): zet `recordedTimesOrigin` NIET ⇒ de modus blijft UIT na het laden, + // ook al is exact hetzelfde orakel meegegeven. Bewijst dat de auto-aan-route uitsluitend op de + // herkomstvlag draait, niet op de loutere aanwezigheid van `recordedTimes` — en dus dat een + // IFC/CSV/MSPDI/MPP/P6XML-document (die dit veld nooit zet) byte-identiek #63-gedrag houdt. + const asUnknownOrigin: ImportResult = { + ...rtOracleSource, recordedFields: undefined, recordedTimes: oracleTimes, + }; + S().newProject(); + S().applyLoadedProject(asUnknownOrigin, { filePath: null, recompute: true }); + eq('7ad zonder recordedTimesOrigin blijft de modus UIT (O6-mutatiebewijs)', S().datesAsRecorded, false); + truthy('7ae …maar het aanbod verschijnt nog gewoon (recordedDates gevuld)', S().recordedDates !== null); + eq('7af …met dezelfde teller', S().recordedDates?.shifted, 1); + + // Heropen-beleid (orkestratorbesluit, XER-etappe laag 3, 2026-09-05): 'xer-archive' — een + // heropende IFC met XER-archief (T5) — biedt de modus alleen AAN, net als 'xer-archive' zonder + // enige herkomst hierboven. Alleen 'xer' (verse import) zet 'm automatisch AAN. MUTATIEBEWIJS: + // stelde `applyRecordedDatesOnLoad` 'xer-archive' gelijk aan 'xer' (`origin !== undefined` i.p.v. + // `origin === 'xer'`), dan zou 7ah hieronder `true` worden en dus ROOD slaan. + const asXerArchive: ImportResult = { + ...rtOracleSource, recordedFields: undefined, recordedTimes: oracleTimes, + recordedTimesOrigin: 'xer-archive', + }; + S().newProject(); + S().applyLoadedProject(asXerArchive, { filePath: null, recompute: true }); + eq('7ag "xer-archive" (heropende IFC met XER-archief) biedt de modus alleen aan, NIET gelijk aan "xer"', + S().datesAsRecorded, false); + truthy('7ah …maar het aanbod verschijnt wél', S().recordedDates !== null); + eq('7ai …met dezelfde teller als de verse import (sectie 7u)', S().recordedDates?.shifted, 1); + eq('7ai2 …en draagt de archiefherkomst, dus óók de Primavera-tekst (sectie 14)', + S().recordedDates?.origin, 'xer-archive'); +} + +// ── (7C) Crashherstel raakt de #63-route NIET ──────────────────────────────────────────────── +// `restoreDocuments` leest de modusvlag van vóór de crash sinds manifest v4 als FEIT uit het +// recovery-manifest (`RecoveryDocInput.datesAsRecorded`, `applyRecordedDatesOnLoad(..., restoredMode)` +// in `documentActivation.ts`) — geen heuristiek meer. Deze sectie pint de tegenkant vast: een gewoon +// #63-document (IFC/CSV/MSPDI/MPP/P6XML) dat met `datesAsRecorded: false` in het manifest stond, +// komt terug in de AANBOD-stand en niet in de modus. MUTATIEBEWIJS: geef `restoredMode` in +// `applyRecordedDatesOnLoad` voorrang op de manifestvlag (`restoredMode ?? true`) ⇒ 7aj slaat ROOD. +{ + const parsed = readIFC(externIfc('7C')); + const input = recoveryInputFromParsed(parsed, { id: 'rec-63', filePath: null, isDirty: true, datesAsRecorded: false }); + S().newProject(); + S().restoreDocuments([input], 'rec-63'); + eq('7aj crashherstel van een gewoon #63-document zet de modus NIET aan', S().datesAsRecorded, false); + truthy('7ak …maar herstelt wél het aanbod', S().recordedDates !== null); + eq('7al …met dezelfde teller als het gewone openen (sectie 7i)', S().recordedDates?.shifted, 1); + eq('7am …en zonder herkomststempel, dus met de formaatneutrale tekst', S().recordedDates?.origin, undefined); +} + // ── (8) showRecordedDates — de modus betreden (Taak 5) ──────────────────────── // Zelfde fixture als (7): één FS-relatie waarvan de opgeslagen datums niet uit de logica volgen // (b staat vast op 2026-03-16, ver ná a's werkelijke opvolgdatum 2026-03-09), zodat er na de echte @@ -787,6 +968,20 @@ const earlyStartOf = (id: string) => S().tasks.find((t) => t.id === id)!.time.ea geenModusEnStale('11f na een bewerking (modus uit, wél verouderd)'); S().undo(); geenModusEnStale('11g na undo (modus terug aan, niet verouderd)'); + + // (11h) XER-etappeplan §3.5/§4-T4, risico §5.1: het bron-orakel-laadpad zet de modus AAN in + // `applyRecordedDatesOnLoad`, dus deze invariant moet ook ná EEN AUTO-AAN-LOAD gelden — niet + // alleen ná een handmatige `showRecordedDates()`-aanroep zoals 11d/11e hierboven. + const rt11h = readIFC(externIfc('11h')); + const oracleTimesFor11h = captureRecordedDates(rt11h.tasks, rt11h.recordedFields).times; + const asXerFor11h: ImportResult = { + ...rt11h, recordedFields: undefined, + recordedTimes: oracleTimesFor11h, recordedTimesOrigin: 'xer', + }; + S().newProject(); + S().applyLoadedProject(asXerFor11h, { filePath: null, recompute: true }); + truthy('11h voorwaarde: bron-orakel-load zette de modus echt aan', S().datesAsRecorded); + geenModusEnStale('11h ná een bron-orakel-load (modus meteen aan)'); } // ── (12) "Alles verversen" blijft één undo-stap (review taak 6, B2) ────────── @@ -873,6 +1068,204 @@ const earlyStartOf = (id: string) => S().tasks.find((t) => t.id === id)!.time.ea eq('13d de gebruiker blijft in document B', S().activeDocumentId, documentB); } +// ── (13B) EXPORT-UITGANG: geen verzonnen 0 in de CSV ──────────────────────────────────────── +// Critreview laag 3, bevinding 6. In de modus draagt `task.time` de vastlegging van het bestand, +// mét de bewuste terugvallen voor niet-vastgelegde assen. De taaktabel toont daar "Niet +// vastgelegd"; een CSV-cel kan een verzonnen `0` niet van een echte nulspeling onderscheiden, dus +// daar hoort een LEGE cel. MUTATIEBEWIJS: geef de poort (`unrecordedExportGate`) niet mee aan +// `writeCSV` in `fileSlice.exportFile` ⇒ 13Bb hieronder slaat rood. +{ + S().newProject(); + S().applyLoadedProject(readIFC(externIfc('csv')), { filePath: null, recompute: true }); + S().showRecordedDates(); + truthy('13Ba voorwaarde: de modus staat aan', S().datesAsRecorded); + + const kolommen = (csv: string) => csv.trim().split('\r\n').slice(1).map(regel => regel.split(';')); + const inModus = writeCSV( + S().project, S().calendar, S().tasks, S().sequences, S().resources, S().assignments, + S().customTaskTypes, unrecordedExportGate(S().recordedDates, S().datesAsRecorded), + ); + const rijenInModus = kolommen(inModus); + eq('13Bb in de modus is de niet-vastgelegde totale speling (kolom 13) een LEGE cel, geen verzonnen 0', + rijenInModus.map(rij => rij[13]), ['', '']); + eq('13Bc … en de niet-vastgelegde kritiek-vlag (kolom 12) óók, geen verzonnen "No"', + rijenInModus.map(rij => rij[12]), ['', '']); + eq('13Bd … terwijl de WÉL vastgelegde datums gewoon geëxporteerd worden', + rijenInModus.map(rij => rij[3]), ['2026-03-02', '2026-03-16']); + + // Tegenproef: buiten de modus is de export byte-identiek aan voorheen — de poort levert dan + // `undefined` en de kolommen dragen de echte berekening. + S().runCPM(); + eq('13Be voorwaarde: runCPM verliet de modus', S().datesAsRecorded, false); + const buitenModus = writeCSV( + S().project, S().calendar, S().tasks, S().sequences, S().resources, S().assignments, + S().customTaskTypes, unrecordedExportGate(S().recordedDates, S().datesAsRecorded), + ); + const rijenBuiten = kolommen(buitenModus); + eq('13Bf buiten de modus staat de BEREKENDE speling in de kolom', rijenBuiten.map(rij => rij[13]), ['0', '0']); + eq('13Bg … en de berekende kritiek-vlag', rijenBuiten.map(rij => rij[12]), ['Yes', 'Yes']); + eq('13Bh zonder poort is de export byte-identiek aan het gedrag van vóór deze wijziging', + writeCSV(S().project, S().calendar, S().tasks, S().sequences, S().resources, S().assignments, + S().customTaskTypes), buitenModus); +} + +// ── (14) De meldingstekst is BRONAFHANKELIJK ───────────────────────────────────────────────── +// De MODUS-ACTIEF-strook zegt "zoals Primavera ze opsloeg". Die strook is echter gedeeld met de +// #63-route voor elk ander formaat, waar niemand weet uit welk pakket de datums komen — daar is die +// zin een verkeerde bewering. Twee helften: +// (a) de KEUZE (`recordedDatesActiveKey`, de React-vrije besluitmodule achter de component); +// (b) de INHOUD in alle veertien talen: de Primavera-familie noemt Primavera, de neutrale familie +// NIET — een vertaler die de zin kopieert wordt hier gepakt, in elke taal. +{ + eq('14a verse XER-import ⇒ Primavera-tekst', recordedDatesActiveKey('xer'), 'recordedDates.activeCount'); + eq('14b heropende IFC met XER-archief ⇒ óók Primavera-tekst (de datums zijn echt van P6)', + recordedDatesActiveKey('xer-archive'), 'recordedDates.activeCount'); + eq('14c zonder herkomst (de #63-route, elk ander formaat) ⇒ formaatneutrale tekst', + recordedDatesActiveKey(undefined), 'recordedDates.activeCountNeutral'); + + const kandidatenL = [ + fileURLToPath(new URL('../../src/i18n/locales/', import.meta.url).href), + resolvePath(process.cwd(), 'src/i18n/locales'), + ]; + const localesRoot = kandidatenL.find((p) => existsSync(p)) ?? null; + truthy(`14d de tekstcontrole vindt src/i18n/locales/ (geprobeerd: ${kandidatenL.join(', ')})`, localesRoot !== null); + + if (localesRoot) { + const talen = readdirSync(localesRoot, { withFileTypes: true }) + .filter((e) => e.isDirectory()).map((e) => e.name).sort(); + eq('14e alle veertien talen worden gecontroleerd', talen.length, 14); + + const zonderNeutraal: string[] = []; + const neutraalNoemtPrimavera: string[] = []; + const primaveraNoemtHetNiet: string[] = []; + for (const taal of talen) { + const json = JSON.parse(readFileSync(joinPath(localesRoot, taal, 'common.json'), 'utf8')) as + Record>; + const rd = json.recordedDates ?? {}; + const primavera = Object.entries(rd).filter(([k]) => k.startsWith('activeCount_')); + const neutraal = Object.entries(rd).filter(([k]) => k.startsWith('activeCountNeutral_')); + if (neutraal.length === 0 || neutraal.length !== primavera.length) zonderNeutraal.push(taal); + if (neutraal.some(([, v]) => v.includes('Primavera'))) neutraalNoemtPrimavera.push(taal); + if (primavera.some(([, v]) => !v.includes('Primavera'))) primaveraNoemtHetNiet.push(taal); + } + eq('14f elke taal heeft de neutrale familie met exact dezelfde pluralvormen als de Primavera-familie', + zonderNeutraal, []); + eq('14g de NEUTRALE tekst noemt Primavera in geen enkele taal', neutraalNoemtPrimavera, []); + eq('14h de Primavera-tekst noemt Primavera juist WEL in elke taal (anders bewijst 14g niets)', + primaveraNoemtHetNiet, []); + + // Her-check laag 3, bevinding 5: dezelfde regel voor de per-taak-BADGE (`task.json`, + // `properties.recordedDatesActive[Neutral]`) — die zei "Primavera" op élk #63-document. + eq('14i badge: XER-herkomst ⇒ Primavera-sleutel', recordedDatesTaskActiveKey('xer'), 'properties.recordedDatesActive'); + eq('14j badge: archiefherkomst ⇒ óók Primavera-sleutel', recordedDatesTaskActiveKey('xer-archive'), 'properties.recordedDatesActive'); + eq('14k badge: zonder herkomst ⇒ neutrale sleutel', recordedDatesTaskActiveKey(undefined), 'properties.recordedDatesActiveNeutral'); + const badgeZonderNeutraal: string[] = []; + const badgeNeutraalNoemtPrimavera: string[] = []; + const badgePrimaveraNoemtHetNiet: string[] = []; + for (const taal of talen) { + const json = JSON.parse(readFileSync(joinPath(localesRoot, taal, 'task.json'), 'utf8')) as + Record>; + const props = json.properties ?? {}; + const primavera = props.recordedDatesActive; + const neutraal = props.recordedDatesActiveNeutral; + if (typeof neutraal !== 'string' || neutraal.length === 0) badgeZonderNeutraal.push(taal); + else if (neutraal.includes('Primavera')) badgeNeutraalNoemtPrimavera.push(taal); + // `Primaver` en niet `Primavera`: het Pools verbuigt de naam ("przez Primaverę"). + if (typeof primavera !== 'string' || !primavera.includes('Primaver')) badgePrimaveraNoemtHetNiet.push(taal); + } + eq('14l elke taal heeft de neutrale badge-tekst', badgeZonderNeutraal, []); + eq('14m de NEUTRALE badge noemt Primavera in geen enkele taal', badgeNeutraalNoemtPrimavera, []); + eq('14n de Primavera-badge noemt Primavera juist WEL in elke taal', badgePrimaveraNoemtHetNiet, []); + } +} + +// ── (15) Samenvattingen rollen in de modus op uit de VASTGELEGDE kinderen ─────────────────────── +// Her-check laag 3, bevinding 3: P6 legt de zes uitvoerkolommen alleen op TASK-rijen vast; een +// WBS-rij (of IFC-fase) heeft nooit een eigen vastlegging en hield daardoor de datums van de solve +// die de modus zojuist verwierp (gemeten: hoofd-WBS een half jaar ná `projectEnd`). Zelfde rollup +// als na een echte solve. MUTATIEBEWIJS: haal `rollupSummaryTasks(tasks)` uit +// `applyRecordedTimesToTasks` ⇒ 15a/15b slaan ROOD (de samenvatting houdt haar oude datums). +{ + const base = (id: string) => ({ + id, name: id, description: '', wbsCode: id, taskType: 'CONSTRUCTION', status: 'NOT_STARTED', + isMilestone: false, priority: 500, resourceIds: [], activityCodes: {}, customFields: {}, + externalLinks: [], notes: [], + }); + const leaf = (id: string, parentId: string, es: string, ef: string): Task => ({ + ...base(id), + parentId, + childIds: [], + time: { + durationType: 'WORKTIME', durationUnit: 'days', scheduleDuration: 5, + scheduleStart: es, scheduleFinish: ef, earlyStart: es, earlyFinish: ef, + lateStart: es, lateFinish: ef, totalFloat: 0, freeFloat: 0, isCritical: true, completion: 0, + }, + } as Task); + const summary: Task = { + ...base('S'), + parentId: null, + childIds: ['A', 'B'], + time: { + durationType: 'WORKTIME', durationUnit: 'days', scheduleDuration: 10, + // De "weggegooide solve": een half jaar later dan wat de kinderen vastleggen. + scheduleStart: '2026-09-01', scheduleFinish: '2026-09-30', + earlyStart: '2026-09-01', earlyFinish: '2026-09-30', + lateStart: '2026-09-01', lateFinish: '2026-09-30', + totalFloat: 0, freeFloat: 0, isCritical: true, completion: 0, + interferingFloat: 0, + }, + } as Task; + const tasks: Task[] = [summary, leaf('A', 'S', '2026-09-01', '2026-09-05'), leaf('B', 'S', '2026-09-08', '2026-09-12')]; + const times: Record = { + A: { start: '2026-03-02', finish: '2026-03-06', lateStart: '2026-03-09', lateFinish: '2026-03-13', totalFloat: 5, freeFloat: 0, isCritical: false }, + B: { start: '2026-03-09', finish: '2026-03-13', lateStart: '2026-03-09', lateFinish: '2026-03-13', totalFloat: 0, freeFloat: 0, isCritical: true }, + }; + const cpm = applyRecordedTimesToTasks(tasks, times, createDefaultCalendar()); + const s = tasks[0].time; + eq('15a de samenvatting omspant de VASTGELEGDE kinderen (vroege zijde)', + [s.earlyStart, s.earlyFinish], ['2026-03-02', '2026-03-13']); + eq('15b ... en niet meer de weggegooide solve (late zijde en speling uit de kinderen)', + [s.lateStart, s.lateFinish, s.totalFloat, s.freeFloat, s.isCritical], ['2026-03-09', '2026-03-13', 0, 0, true]); + eq('15c analyse-afgeleiden zijn ook op de samenvatting gewist', + [s.interferingFloat, s.isNearCritical, s.floatPath], [undefined, undefined, undefined]); + eq('15d de balk klopt nu met de projecteinddatum die dezelfde modus rapporteert', + [cpm.projectEnd, s.earlyFinish <= cpm.projectEnd], ['2026-03-13', true]); + eq('15e een samenvatting zonder vastgelegde kinderen blijft onaangeroerd (geen vastlegging ⇒ niets te zeggen)', + (() => { + const alone: Task = { ...summary, id: 'S2', childIds: ['C'], time: { ...summary.time } } as Task; + const c = leaf('C', 'S2', '2026-09-01', '2026-09-05'); + applyRecordedTimesToTasks([alone, c], {}, createDefaultCalendar()); + return [alone.time.earlyStart, alone.time.earlyFinish]; + })(), ['2026-09-01', '2026-09-05']); + // Her-check R1: de opgerolde samenvatting staat óók in het cpmResult — Gantt/taakraster + // (`task.time`) en rapporten/`projectEnd` (`cpmResult`) mogen elkaar niet tegenspreken. + eq('15f de opgerolde samenvatting zit in cpmResult.tasks met dezelfde waarden als task.time', + cpm.tasks.get('S'), { + earlyStart: s.earlyStart, earlyFinish: s.earlyFinish, lateStart: s.lateStart, lateFinish: s.lateFinish, + totalFloat: s.totalFloat, freeFloat: s.freeFloat, isCritical: s.isCritical, + }); + // Her-check R1 (blokkerend): een samenvatting MET eigen vastlegging (de #63-IFC-route: een fase + // draagt gewoon een IfcTaskTime) blijft staan zoals het bestand haar gaf — de rollup slaat haar + // over, en task.time == cpmResult. MUTATIEBEWIJS: haal het `skip`-predikaat uit + // `applyRecordedTimesToTasks` ⇒ 15g slaat ROOD (de rollup overschrijft de vastlegging). + { + const recordedSummary: Task = { ...summary, id: 'R', childIds: ['RA', 'RB'], time: { ...summary.time } } as Task; + const rTasks: Task[] = [recordedSummary, leaf('RA', 'R', '2026-09-01', '2026-09-05'), leaf('RB', 'R', '2026-09-08', '2026-09-12')]; + const rTimes: Record = { + R: { start: '2026-02-01', finish: '2026-04-30', lateStart: '2026-02-04', lateFinish: '2026-05-05', totalFloat: 3, freeFloat: 3, isCritical: false }, + RA: { start: '2026-03-02', finish: '2026-03-06', lateStart: '2026-03-09', lateFinish: '2026-03-13', totalFloat: 5, freeFloat: 0, isCritical: false }, + RB: { start: '2026-03-09', finish: '2026-03-13', lateStart: '2026-03-09', lateFinish: '2026-03-13', totalFloat: 0, freeFloat: 0, isCritical: true }, + }; + const rCpm = applyRecordedTimesToTasks(rTasks, rTimes, createDefaultCalendar()); + const r = rTasks[0].time; + eq('15g een samenvatting mét eigen vastlegging blijft staan zoals het bestand haar gaf (geen rollup eroverheen)', + [r.earlyStart, r.earlyFinish, r.lateStart, r.lateFinish, r.totalFloat, r.freeFloat, r.isCritical], + ['2026-02-01', '2026-04-30', '2026-02-04', '2026-05-05', 3, 3, false]); + eq('15h ... en task.time en cpmResult zeggen daar hetzelfde, projectEnd incluis', + [rCpm.tasks.get('R')?.earlyFinish, rCpm.projectEnd], ['2026-04-30', '2026-04-30']); + } +} + // ── Uitslag ────────────────────────────────────────────────────────────────── if (diffs.length === 0) { console.log(`OK recorded-dates: alle checks groen (${checks})`); diff --git a/tests/planning/check-recovery-delta.ts b/tests/planning/check-recovery-delta.ts new file mode 100644 index 000000000..69ef2cc2a --- /dev/null +++ b/tests/planning/check-recovery-delta.ts @@ -0,0 +1,120 @@ +// X9 recovery-delta — de pure grens tussen documentinhoud en recovery-opslag. +// +// Bewijst dat `isDirty` uitsluitend manifestmetadata blijft: inhoudsdelta's volgen de volledige +// IFCSaveSource via sameIFCSource. Daardoor kan één bewerking precies één writeIFC/upsert geven, +// terwijl tab- of metadatawijzigingen geen grote IFC-payload raken. +import { + RecoveryDeltaTracker, + persistedRecoveryState, + planRecoveryDelta, + type RecoverySourceDocument, +} from '@/services/recovery/recoveryDelta'; +import type { IFCSaveSource } from '@/state/ifcSaveInput'; + +declare const process: { exit(code: number): never }; + +const failures: string[] = []; +let checks = 0; +const eq = (label: string, got: unknown, want: unknown) => { + checks += 1; + if (JSON.stringify(got) !== JSON.stringify(want)) { + failures.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +}; + +// Alle referenties zijn bewust afzonderlijk. De concrete domeinvorm doet hier niet ter zake: de +// productregel is dat `sameIFCSource` precies deze velden referentieel vergelijkt. +const source = (label: string): IFCSaveSource => ({ + project: { label }, calendar: { label }, tasks: [{ label }], sequences: [{ label }], + resources: [{ label }], assignments: [{ label }], activityCodeTypes: [{ label }], + customFieldDefs: [{ label }], calendars: [{ label }], baselines: [{ label }], + activeBaselineId: label, xerImportMetadata: { label }, xerSourceArchive: { label }, + xerSourceProjectId: label, +} as unknown as IFCSaveSource); + +const a = source('a'); +const b = source('b'); +const docs: RecoverySourceDocument[] = [ + { id: 'a', source: a, filePath: '/tmp/a.ifc', isDirty: true, datesAsRecorded: false }, + { id: 'b', source: b, filePath: '/tmp/b.ifc', isDirty: true, datesAsRecorded: false }, +]; + +const first = planRecoveryDelta('a', docs, null); +eq('1 verse recovery schrijft beide documentinhouden', first.changedDocuments.map((document) => document.id), ['a', 'b']); +eq('2 verse recovery schrijft manifestmetadata', first.manifestChanged, true); + +const persisted = persistedRecoveryState('a', docs); +const aChanged = source('a-gewijzigd'); +const afterOneEdit: RecoverySourceDocument[] = [ + { ...docs[0]!, source: aChanged }, + docs[1]!, +]; +const oneEdit = planRecoveryDelta('a', afterOneEdit, persisted); +eq('3 één inhoudsbewerking selecteert exact één IFC-upsert', oneEdit.changedDocuments.map((document) => document.id), ['a']); +eq('4 één inhoudsbewerking zonder metadatawijziging schrijft geen extra manifestvorm', oneEdit.manifestChanged, false); + +const afterEditPersisted = persistedRecoveryState('a', afterOneEdit); +const activeOnly = planRecoveryDelta('b', afterOneEdit, afterEditPersisted); +eq('5 actieve-tabwissel heeft nul IFC-upserts', activeOnly.changedDocuments.length, 0); +eq('6 actieve-tabwissel is manifest-only', activeOnly.manifestChanged, true); + +// 6b/6c — "datums zoals opgeslagen" is manifestmetadata (critreview laag 3, bevindingen 2/3): +// crashherstel leest de vlag terug uit het manifest, dus een wissel van die stand MOET een +// manifestcommit geven, ook wanneer de IFC-inhoud referentieel gelijk blijft. Zonder deze regel +// zou de vlag pas op de volgende inhoudswijziging op schijf landen — en dat is precies de ronde +// waarin een crash hem kwijt zou zijn. +const modeToggled: RecoverySourceDocument[] = [ + { ...afterOneEdit[0]!, datesAsRecorded: true }, + afterOneEdit[1]!, +]; +const modeDelta = planRecoveryDelta('a', modeToggled, afterEditPersisted); +eq('6b een wissel van de modusvlag is een metadatawijziging', modeDelta.manifestChanged, true); +eq('6c … zonder één enkele IFC-upsert', modeDelta.changedDocuments.length, 0); +eq('6d … en de nieuwe persistentiebasis draagt de vlag', + persistedRecoveryState('a', modeToggled).documents[0]?.datesAsRecorded, true); + +const dirtyOnly: RecoverySourceDocument[] = [ + { ...afterOneEdit[0]!, isDirty: false }, + afterOneEdit[1]!, +]; +const metadataOnly = planRecoveryDelta('a', dirtyOnly, afterEditPersisted); +eq('7 dirty-vlag zonder andere bronvelden heeft nul IFC-upserts', metadataOnly.changedDocuments.length, 0); +eq('8 dirty-vlag zonder inhoud is manifestmetadata', metadataOnly.manifestChanged, true); + +const unchangedButDirty: RecoverySourceDocument[] = afterOneEdit.map((document) => ({ ...document, isDirty: true })); +const stillNoContentDelta = planRecoveryDelta('a', unchangedButDirty, afterEditPersisted); +eq('9 isDirty=true is nooit zelfstandig een inhoudsrevisie', stillNoContentDelta.changedDocuments.length, 0); + +const noop = planRecoveryDelta('a', afterOneEdit, afterEditPersisted); +eq('10 identieke bron én metadata doet geen I/O', noop.needsPersist, false); + +// De hook gebruikt deze tracker direct. Cruciaal: een serialization-cache is veilig, maar een +// storagefout mag de persisted basis niet promoten. De retry MOET daarom dezelfde upsert opnieuw +// aanbieden, ook wanneer hij de reeds gebouwde IFC-string mag hergebruiken. +const tracker = new RecoveryDeltaTracker(); +let serializations = 0; +const serialize = (value: IFCSaveSource): string => { + serializations += 1; + return `IFC-${(value.project as unknown as { label: string }).label}`; +}; +const initialWrite = tracker.prepare('a', docs, serialize)!; +eq('11 tracker bouwt initieel twee IFC-teksten', [initialWrite.upserts.length, serializations], [2, 2]); +tracker.commit('a', docs); +const failedWrite = tracker.prepare('a', afterOneEdit, serialize)!; +eq('12 na één edit biedt tracker exact één upsert aan', failedWrite.upserts.map((document) => document.id), ['a']); +eq('13 die edit wordt éénmaal geserialiseerd', serializations, 3); +// Simuleer de mislukte `saveRecovery`: bewust GEEN commit. +const retryAfterFailure = tracker.prepare('a', afterOneEdit, serialize)!; +eq('14 storagefout houdt de gewijzigde bron in de volgende delta', retryAfterFailure.upserts.map((document) => document.id), ['a']); +eq('15 retry mag de al gebouwde IFC-string hergebruiken', serializations, 3); +tracker.commit('a', afterOneEdit); +const manifestOnly = tracker.prepare('b', afterOneEdit, serialize)!; +eq('16 na succesvolle edit is actieve-tabwissel manifest-only', [manifestOnly.upserts.length, serializations], [0, 3]); + +if (failures.length === 0) { + console.log(`OK recovery-delta: alle ${checks} checks groen`); + process.exit(0); +} +console.log(`XX recovery-delta: ${failures.length} afwijking(en) van ${checks}`); +for (const failure of failures) console.log(` - ${failure}`); +process.exit(1); diff --git a/tests/planning/check-recovery-integrity.ts b/tests/planning/check-recovery-integrity.ts index 4fbf75da1..504e63327 100644 --- a/tests/planning/check-recovery-integrity.ts +++ b/tests/planning/check-recovery-integrity.ts @@ -194,7 +194,7 @@ eq('7f null-literal → null', parseRecoveryManifest('null'), null); } as unknown as Task], sequences: [], resources: [], assignments: [], resourceCalendars: [cal], activityCodeTypes: [], customFieldDefs: [], baselines: [], - activeBaselineId: null, filePath: '/tmp/rec-gezond.ifc', isDirty: true, + activeBaselineId: null, filePath: '/tmp/rec-gezond.ifc', isDirty: true, datesAsRecorded: false, }; const corrupt: RecoveryDocInput = { id: 'rec-corrupt', @@ -208,7 +208,7 @@ eq('7f null-literal → null', parseRecoveryManifest('null'), null); } as unknown as Task], sequences: [], resources: [], assignments: [], resourceCalendars: [cal], activityCodeTypes: [], customFieldDefs: [], baselines: [], - activeBaselineId: null, filePath: '/tmp/rec-corrupt.ifc', isDirty: true, + activeBaselineId: null, filePath: '/tmp/rec-corrupt.ifc', isDirty: true, datesAsRecorded: false, }; // 8a — het corrupte document als AANGEVRAAGD actief document: restoreDocuments moet uitwijken diff --git a/tests/planning/check-recovery-isolation.ts b/tests/planning/check-recovery-isolation.ts index d4efa051c..3734f3fdc 100644 --- a/tests/planning/check-recovery-isolation.ts +++ b/tests/planning/check-recovery-isolation.ts @@ -26,6 +26,7 @@ import { recoveryNames, type RecoveryManifest } from '@/hooks/recoveryPaths'; import { manifestOwnership, planRecoveryCleanup, planRecoveryClear, parseRecoveryManifest, + planTauriV3RecoverySave, RECOVERY_MANIFEST_VERSION, } from '@/services/recovery/recoveryStore'; @@ -97,6 +98,10 @@ eq('1r isOwnFile: DEV-manifest hoort NIET bij productie', eq('1s isOwnFile: productiebestand hoort NIET bij dev', DEV.isOwnFile('recovery.doc-1.ifc'), false); eq('1t isOwnFile: vreemd bestand', PROD.isOwnFile('ops-extensions.json'), false); eq('1u ifcName rondt met snapshotDocId', DEV.snapshotDocId(DEV.ifcName('doc-xyz')), 'doc-xyz'); +const generationName = PROD.generationIfcName('doc-xyz', 'g-test'); +eq('1v v3-generatie rondt met algemene snapshotDocId', PROD.snapshotDocId(generationName), 'doc-xyz'); +eq('1w v3-generatie telt NIET als manifestloze v1/v2-terugval', PROD.stableSnapshotDocId(generationName), null); +eq('1x v3-generatie is wel een eigen recoverybestand', PROD.isOwnFile(generationName), true); // ── 2. Een productiebuild ruimt NOOIT dev-bestanden op ──────────────────────────────────────── // De gemengde listing die deze batterij bestaat om te dekken: productie + twee worktrees + rommel. @@ -259,13 +264,65 @@ eq('5d … en activeDocumentId', v1?.activeDocumentId, 'doc-1'); eq('5e … zonder ownerId', v1?.ownerId, undefined); eq('5f … zonder heartbeatAt', v1?.heartbeatAt, undefined); -const V2_RAW = JSON.stringify(manifest(OTHER, [{ id: 'doc-1', ifc: 'recovery.doc-1.ifc' }])); +const V2_RAW = JSON.stringify({ + ...manifest(OTHER, [{ id: 'doc-1', ifc: 'recovery.doc-1.ifc' }]), + version: 2, +}); const v2 = parseRecoveryManifest(V2_RAW); -eq('5g v2-manifest parst', v2?.version, RECOVERY_MANIFEST_VERSION); +eq('5g v2-manifest parst na v3-upgrade', v2?.version, 2); eq('5h … mét ownerId', v2?.ownerId, OTHER); eq('5i … mét heartbeatAt', typeof v2?.heartbeatAt, 'number'); eq('5j v2-versienummer is opgehoogd t.o.v. v1', RECOVERY_MANIFEST_VERSION > 1, true); +const v3Plan = planTauriV3RecoverySave(v2, { + activeDocumentId: 'doc-2', + documents: [ + { id: 'doc-1', filePath: '/tmp/a.ifc', isDirty: true, datesAsRecorded: false }, + { id: 'doc-2', filePath: '/tmp/b.ifc', isDirty: false, datesAsRecorded: false }, + ], + upserts: [ + { id: 'doc-2', ifc: 'nieuwe B-inhoud', filePath: '/tmp/b.ifc', isDirty: false, datesAsRecorded: false }, + ], +}, 'g-42', PROD); +eq('5k v3-plan behoudt een v2-snapshot zonder nieuwe inhoud', v3Plan.documents[0]?.ifc, 'recovery.doc-1.ifc'); +eq('5l v3-plan maakt precies één immutable generatie voor de upsert', v3Plan.writes, + [{ name: 'recovery.snapshot.doc-2.g-42.ifc', ifc: 'nieuwe B-inhoud' }]); +eq('5m v3-manifestregel wijst naar die generatie', v3Plan.documents[1]?.ifc, 'recovery.snapshot.doc-2.g-42.ifc'); +eq('5n v4 is de actuele manifestversie (v3 + de modusvlag per document)', RECOVERY_MANIFEST_VERSION, 4); + +// 5o–5r — "datums zoals opgeslagen" reist als manifestmetadata mee (critreview laag 3, +// bevindingen 2/3), en een OUDER manifest zonder dat veld MOET leesbaar blijven: het staat op de +// schijf van iedereen die de vorige versie draaide. Ontbreekt het veld, dan geldt `false` — het +// gewone #63-aanbod, nooit stilzwijgend de modus. +const V3_ZONDER_VLAG = parseRecoveryManifest(JSON.stringify({ + version: 3, + activeDocumentId: 'doc-1', + documents: [{ id: 'doc-1', ifc: 'recovery.doc-1.ifc', filePath: '/tmp/p.ifc', isDirty: true }], + ownerId: OTHER, + heartbeatAt: Date.now(), +})); +eq('5o een v3-manifest zonder de modusvlag parst gewoon', V3_ZONDER_VLAG !== null, true); +eq('5p … en levert het veld als undefined (geen verzonnen true)', + V3_ZONDER_VLAG?.documents[0]?.datesAsRecorded, undefined); +const carryZonderVlag = planRecoveryCleanup({ + listing: ['recovery.doc-1.ifc'], + prev: V3_ZONDER_VLAG, + self: SELF, + keep: [], + ownWritten: [], + adopted: [], + names: PROD, +}); +eq('5q overname van een vreemd v3-manifest zet de modusvlag op false, niet undefined', + carryZonderVlag.carryOver[0]?.datesAsRecorded, false); + +const v4Plan = planTauriV3RecoverySave(null, { + activeDocumentId: 'doc-9', + documents: [{ id: 'doc-9', filePath: null, isDirty: true, datesAsRecorded: true }], + upserts: [{ id: 'doc-9', ifc: 'inhoud', filePath: null, isDirty: true, datesAsRecorded: true }], +}, 'g-9', PROD); +eq('5r een nieuw manifest schrijft de modusvlag mee', v4Plan.documents[0]?.datesAsRecorded, true); + // ── 6. Halffabricaten (.tmp) op exacte naam, niet met een sweep ─────────────────────────────── const tmpPlan = plan({ listing: [ @@ -293,6 +350,18 @@ const tmpGesloten = plan({ eqSet('6b gesloten eigen document: bestand + halffabricaat', tmpGesloten.remove, ['recovery.doc-2.ifc', 'recovery.doc-2.ifc.tmp']); +const oudeGeneratie = PROD.generationIfcName('doc-1', 'g-oud'); +const nieuweGeneratie = PROD.generationIfcName('doc-1', 'g-nieuw'); +const generatieCleanup = plan({ + listing: [oudeGeneratie, nieuweGeneratie, PROD.manifest], + prev: manifest(SELF, [{ id: 'doc-1', ifc: oudeGeneratie }]), + keep: [nieuweGeneratie], + ownWritten: [oudeGeneratie, nieuweGeneratie], + names: PROD, +}); +eqSet('6c oude generatie verdwijnt pas ná een manifestcommit naar de nieuwe generatie', + generatieCleanup.remove, [oudeGeneratie]); + // ── 7. clearRecovery: wissen betekent wissen — maar alleen binnen de eigen base ──────────────── // K4 eist dat óók een snapshot die het manifest niet noemt weggaat (anders duikt hij bij de // volgende start weer op als herstelkandidaat). K5 eist dat dat de eigen base niet verlaat. diff --git a/tests/planning/check-session-history-migration.ts b/tests/planning/check-session-history-migration.ts index 6d875c2ad..c46e9a687 100644 --- a/tests/planning/check-session-history-migration.ts +++ b/tests/planning/check-session-history-migration.ts @@ -73,6 +73,7 @@ const legacyRecovery = { activeBaselineId: base.activeBaselineId, filePath: '/tmp/legacy-recovery.ifc', isDirty: false, + datesAsRecorded: false, undoStack: [{ marker: oldMarker }], redoStack: [{ marker: oldMarker }], } satisfies RecoveryDocInput & LegacyHistoryFields; diff --git a/tests/planning/check-store-factory.ts b/tests/planning/check-store-factory.ts index 517f66e88..24d114fd6 100644 --- a/tests/planning/check-store-factory.ts +++ b/tests/planning/check-store-factory.ts @@ -391,6 +391,32 @@ eq('2b maar niet met hetzelfde state-object', A.getState() === B.getState(), fal } } +// ── 6. Partial-constructor bewaart expliciete lege-summarysemantiek ───────────────────── +// Breuk die dit vangt: `addTask` accepteert publiek `Partial`, maar vergeet `isSummary` in +// zijn object-literal. De taak lijkt dan opgeslagen, maar belandt als gewone leaf in de solver. +{ + const E = createAppStore(); + const summaryId = E.getState().addTask({ name: 'Store lege summary', isSummary: true }); + E.getState().runCPM(); + eq('16 store addTask bewaart expliciet true', + E.getState().tasks.find(task => task.id === summaryId)?.isSummary, true); + eq('16a store addTask houdt de lege summary buiten CPMResult.tasks', + E.getState().cpmResult?.tasks.has(summaryId), false); + + E.getState().updateTask(summaryId, { isSummary: false }); + E.getState().runCPM(); + eq('16b store updateTask kan de marker bewust terugzetten', + E.getState().tasks.find(task => task.id === summaryId)?.isSummary, false); + eq('16c de teruggezette taak wordt weer een solvertaak', + E.getState().cpmResult?.tasks.has(summaryId), true); + + const regularId = E.getState().addTask({ name: 'Store gewone taak' }); + E.getState().runCPM(); + eq('16d store addTask maakt een gewone invoer niet per ongeluk summary', + E.getState().tasks.find(task => task.id === regularId)?.isSummary, undefined); + eq('16e de gewone taak blijft een solvertaak', E.getState().cpmResult?.tasks.has(regularId), true); +} + // ── Uitslag ──────────────────────────────────────────────────────────────────────── if (diffs.length === 0) { console.log(`OK: store-factory — ${checks} checks groen`); diff --git a/tests/planning/check-web-save-fallback.ts b/tests/planning/check-web-save-fallback.ts index 65cc813ec..aef55b1ba 100644 --- a/tests/planning/check-web-save-fallback.ts +++ b/tests/planning/check-web-save-fallback.ts @@ -126,6 +126,21 @@ function installWindow(handle: FileSystemFileHandle | null, pickerError?: DOMExc }; } +let releaseDeferredSavePicker: ((handle: FileSystemFileHandle) => void) | null = null; +function installDeferredSaveWindow() { + pickerCalls = 0; + permissionCalls = 0; + g.window = { + showOpenFilePicker: () => Promise.resolve([]), + showSaveFilePicker: () => { + pickerCalls++; + return new Promise((resolveHandle) => { + releaseDeferredSavePicker = resolveHandle; + }); + }, + }; +} + /** Zet `window` op met een open-kiezer die `[handle]` teruggeeft (of `pickerError` gooit). */ function installOpenWindow(handle: FileSystemFileHandle | null, pickerError?: DOMException) { openPickerCalls = 0; @@ -272,6 +287,47 @@ async function main() { eq('7c geen rauwe browserfout als detail', S().ui.notifications[0]?.detail, undefined); eq('7d het document geldt als opgeslagen', S().isDirty, false); + // ── 7e. Een late uitkomst hoort bij document B, nooit bij inmiddels actief document C ───── + resetWebWriteRefusalForTests(); + installDeferredSaveWindow(); + const documentB = S().newDocument(); + S().addTask({ name: 'B wordt opgeslagen' }); + const documentC = S().newDocument(); + S().switchDocument(documentB); + const lateSave = S().saveFile(); + await Promise.resolve(); + S().switchDocument(documentC); + const handleB = makeHandle({}); + releaseDeferredSavePicker?.(handleB); + await lateSave; + eq('7e actieve document blijft C na late B-save', S().activeDocumentId, documentC); + eq('7f C krijgt B-bestandsnaam niet', S().filePath, null); + eq('7g C krijgt B-handle niet', S().fileHandle, null); + S().switchDocument(documentB); + eq('7h B ontvangt zijn eigen bestandsnaam', S().filePath, 'project.ifc'); + eq('7i B ontvangt zijn eigen handle', S().fileHandle === handleB, true); + eq('7j ongewijzigd B wordt na zijn eigen save schoon', S().isDirty, false); + + // Ook de expliciete Opslaan als-route bindt een late bestemming aan haar brondocument. + installDeferredSaveWindow(); + const saveAsDocument = S().newDocument(); + S().addTask({ name: 'Opslaan als blijft bij dit document' }); + const saveAsNeighbour = S().newDocument(); + S().switchDocument(saveAsDocument); + const lateSaveAs = S().saveFileAs(); + await Promise.resolve(); + S().switchDocument(saveAsNeighbour); + const saveAsHandle = makeHandle({}); + releaseDeferredSavePicker?.(saveAsHandle); + await lateSaveAs; + eq('7k actieve buur blijft actief na late Opslaan als', S().activeDocumentId, saveAsNeighbour); + eq('7l buur krijgt Opslaan als-bestandsnaam niet', S().filePath, null); + eq('7m buur krijgt Opslaan als-handle niet', S().fileHandle, null); + S().switchDocument(saveAsDocument); + eq('7n Opslaan als-bestandsnaam blijft bij bron', S().filePath, 'project.ifc'); + eq('7o Opslaan als-handle blijft bij bron', S().fileHandle === saveAsHandle, true); + eq('7p ongewijzigde Opslaan als-bron wordt schoon', S().isDirty, false); + // ── 8. featurePolicy meldt vooraf een blokkade → meteen de input-terugval, geen picker ────── resetWebReadRefusalForTests(); createdTags.length = 0; diff --git a/tests/planning/check-xer-archive-cold-read.ts b/tests/planning/check-xer-archive-cold-read.ts new file mode 100644 index 000000000..a0d8d6c91 --- /dev/null +++ b/tests/planning/check-xer-archive-cold-read.ts @@ -0,0 +1,71 @@ +// X9 reviewfix 1: schema-2-opslag is zelfstandig leesbaar in een koud proces. De lage sync-lezer +// is daarbij bewust beschermd: alleen readIFCWithXerReconstruction mag de lazy XER-chunk laden. +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const root = process.cwd(); +const esbuild = join(root, 'node_modules', '.bin', 'esbuild'); +const temporary = mkdtempSync(join(tmpdir(), 'ops-xer-cold-read-')); +const writer = join(temporary, 'writer.mjs'); +const rawReader = join(temporary, 'reader-raw.mjs'); +const asyncReader = join(temporary, 'reader-async.mjs'); +const ifc = join(temporary, 'compact.ifc'); + +let checks = 0; +const failures: string[] = []; +const expect = (label: string, condition: boolean): void => { + checks += 1; + if (!condition) failures.push(label); +}; + +function bundle(source: string, output: string): void { + execFileSync(esbuild, [ + source, '--log-level=error', '--bundle', '--platform=node', '--format=esm', + `--alias:@=${join(root, 'src')}`, + '--define:import.meta.env.DEV=false', '--define:import.meta.env.PROD=true', + '--define:import.meta.env.MODE="production"', '--define:__OPS_DEV_INSTANCE__="test"', + `--outfile=${output}`, + ], { cwd: root, stdio: 'pipe' }); +} + +function childJson(entry: string): Record { + return JSON.parse(execFileSync(process.execPath, [entry, ifc], { + cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + })) as Record; +} + +try { + bundle('tests/planning/xerArchiveColdReadWriter.ts', writer); + bundle('tests/planning/xerArchiveColdReadRaw.ts', rawReader); + bundle('tests/planning/xerArchiveColdReadAsync.ts', asyncReader); + execFileSync(process.execPath, [writer, ifc], { cwd: root, stdio: 'pipe' }); + + const raw = childJson(rawReader); + const reopened = childJson(asyncReader); + const ifcText = readFileSync(ifc, 'utf8'); + + expect('koud proces A schrijft werkelijk een compacte schema-2-IFC', + ifcText.includes("IFCPROPERTYSINGLEVALUE('SchemaVersion',$,IFCINTEGER(2),$)")); + expect('de lage sync-lezer weigert compacte opslag voorspelbaar en verwijst naar de enige async ingang', + raw.ok === false + && raw.reason === 'xer-source-archive' + && typeof raw.message === 'string' + && raw.message.includes('readIFCWithXerReconstruction')); + expect('proces B importeert alléén de officiële async ingang en herbouwt de bron zelfstandig', + reopened.sourceProjectId === 'P-COLD' + && reopened.taskCount === 1 + && typeof reopened.sha256 === 'string' + && /^[0-9a-f]{64}$/.test(reopened.sha256)); +} finally { + rmSync(temporary, { recursive: true, force: true }); +} + +if (failures.length === 0) { + console.log(`OK xer-archive-cold-read: alle checks groen (${checks})`); + process.exit(0); +} +console.log(`XX xer-archive-cold-read: ${failures.length} afwijking(en)`); +for (const failure of failures) console.log(` - ${failure}`); +process.exit(1); diff --git a/tests/planning/check-xer-archive-compact.ts b/tests/planning/check-xer-archive-compact.ts new file mode 100644 index 000000000..52e1ceae1 --- /dev/null +++ b/tests/planning/check-xer-archive-compact.ts @@ -0,0 +1,207 @@ +// X9-compactopslag — corpusloze contracttest voor een zelfstandig, uit ruwe bytes herbouwd archief. +import { isMultiDocumentImport } from '@/services/importTypes'; +import { IfcParseError } from '@/services/ifc/ifcErrors'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; +import { writeIFC } from '@/services/ifc/ifcWriter'; +import { readXER } from '@/services/xer/xerReader'; +import { + chunkXerArchiveBytes, + decodeXerSourceArchive, + encodeXerArchiveMetadataPayload, + sha256Hex, + type XerSourceArchive, +} from '@/services/xerSourceArchive'; + +declare const process: { exit(code: number): never }; + +const failures: string[] = []; +let checks = 0; +const expect = (label: string, condition: boolean): void => { + checks += 1; + if (!condition) failures.push(label); +}; + +const source = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-08-01\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date', + '%R\tP-A\tAlpha\tC\t2026-08-01 08:00', + '%R\tP-B\tBeta\tC\t2026-08-02 08:00', + '%T\tSCHEDOPTIONS', + '%F\tschedoptions_id\tproj_id\tsched_use_expect_end_flag', + '%R\tSO-A\tP-A\tY', + '%R\tSO-B\tP-B\tN', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC\tStandaard\t8\t40\t', + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_code\ttask_name\tclndr_id\ttarget_start_date\ttarget_end_date\ttarget_drtn_hr_cnt\ttask_type\tduration_type\tstatus_code', + '%R\tT-A\tP-A\tA-1\tAlpha-taak\tC\t2026-08-01 08:00\t2026-08-01 16:00\t8\tTT_Task\tDT_FixedDUR2\tTK_NotStart', + '%R\tT-B\tP-B\tB-1\tBeta-taak\tC\t2026-08-02 08:00\t2026-08-02 16:00\t8\tTT_Task\tDT_FixedDUR2\tTK_NotStart', + '%T\tRSRC', + '%F\trsrc_id\trsrc_name\trsrc_type\tclndr_id\tdef_qty_per_hr', + '%R\tR-1\tVakman\tRT_Labor\tC\t1', + '%T\tTASKRSRC', + '%F\ttaskrsrc_id\tproj_id\ttask_id\trsrc_id\trole_id\ttarget_qty_per_hr\tremain_qty\ttarget_qty', + '%R\tAS-A\tP-A\tT-A\tR-1\t\t1\t8\t8', + '%R\tAS-B\tP-B\tT-B\tR-1\t\t1\t8\t8', + '%T\tACTVTYPE', + '%F\tactv_code_type_id\tactv_code_type\tseq_num', + '%R\tTYPE\tFase\t1', + '%T\tACTVCODE', + '%F\tactv_code_id\tactv_code_type_id\tshort_name\tseq_num', + '%R\tVALUE\tTYPE\tVoorbereiding\t1', + '%T\tTASKACTV', + '%F\tproj_id\ttask_id\tactv_code_type_id\tactv_code_id', + '%R\tP-A\tT-A\tTYPE\tVALUE', + '%T\tUDFTYPE', + '%F\tudf_type_id\ttable_name\tudf_type_label\tlogical_data_type', + '%R\tUF\tTASK\tBronveld\tFT_STATICTYPE', + '%T\tUDFVALUE', + '%F\tudf_type_id\tproj_id\tfk_id\tudf_text', + '%R\tUF\tP-A\tT-A\tBronwaarde', + '%E', +].join('\r\n')); + +const opened = readXER(source); +if (!isMultiDocumentImport(opened)) throw new Error('Compactfixture moet twee documenten openen'); +const alpha = opened.results.find(result => result.xer?.sourceProjectId === 'P-A'); +if (!alpha?.xerSourceArchive || !alpha.xer) throw new Error('Compactfixture mist P-A-bronarchief'); +const archive = alpha.xerSourceArchive; +const beta = opened.results.find(result => result.xer?.sourceProjectId === 'P-B'); +if (!beta?.xerSourceArchive || !beta.xer) throw new Error('Compactfixture mist P-B-bronarchief'); + +const compactIfc = writeIFC(alpha); +const parsedCompact = readIFC(compactIfc); +const compactBetaIfc = writeIFC(beta); +const parsedCompactBeta = readIFC(compactBetaIfc); + +const escapeIfcText = (value: string): string => value.replace(/'/g, "''"); + +/** Bouw in de test zelf een echte schema-1-container om de leescompatibiliteit te bewaken. + * Productcode schrijft dit formaat nooit meer; dit is uitsluitend een historische invoerfixture. */ +function appendLegacyExpandedContainer(ifc: string, sourceArchive: XerSourceArchive, sourceProjectId: string): string { + const project = /#([A-Za-z0-9_]+)=IFCPROJECT\([^,]+,#([A-Za-z0-9_]+)/.exec(ifc); + if (!project) throw new Error('Basis-IFC mist IFCPROJECT/eigenaar'); + const diagnostics = chunkXerArchiveBytes(encodeXerArchiveMetadataPayload(sourceArchive)); + let nextId = 900000; + const lines: string[] = []; + const propertyIds: number[] = []; + const property = (name: string, value: string): void => { + const id = nextId++; + propertyIds.push(id); + lines.push(`#${id}=IFCPROPERTYSINGLEVALUE('${name}',$,${value},$);`); + }; + property('SchemaVersion', 'IFCINTEGER(1)'); + property('Format', `IFCLABEL('${escapeIfcText(sourceArchive.format)}')`); + property('ByteLength', `IFCINTEGER(${sourceArchive.byteLength})`); + property('Sha256', `IFCTEXT('${sourceArchive.sha256}')`); + property('Encoding', `IFCLABEL('${sourceArchive.encoding}')`); + property('Bom', `IFCLABEL('${sourceArchive.bom}')`); + property('Newline', `IFCLABEL('${sourceArchive.newline}')`); + property('ByteChunkSize', 'IFCINTEGER(196608)'); + property('ByteChunkCount', `IFCINTEGER(${sourceArchive.byteChunks.length})`); + property('DiagnosticsByteLength', `IFCINTEGER(${diagnostics.byteLength})`); + property('DiagnosticsSha256', `IFCTEXT('${diagnostics.sha256}')`); + property('DiagnosticsChunkCount', `IFCINTEGER(${diagnostics.byteChunks.length})`); + sourceArchive.byteChunks.forEach((chunk, index) => property( + `ByteChunk${String(index).padStart(6, '0')}`, + `IFCTEXT('${chunk}')`, + )); + diagnostics.byteChunks.forEach((chunk, index) => property( + `DiagnosticsChunk${String(index).padStart(6, '0')}`, + `IFCTEXT('${chunk}')`, + )); + const archiveSet = nextId++; + lines.push(`#${archiveSet}=IFCPROPERTYSET('0xxxxxxxxxxxxxxxxxxxxx',#${project[2]},'OPS_XerSourceArchive',$,(${propertyIds.map(id => `#${id}`).join(',')}));`); + lines.push(`#${nextId++}=IFCRELDEFINESBYPROPERTIES('1xxxxxxxxxxxxxxxxxxxxx',#${project[2]},$,$,(#${project[1]}),#${archiveSet});`); + const selectorIds: number[] = []; + const selector = (name: string, value: string): void => { + const id = nextId++; + selectorIds.push(id); + lines.push(`#${id}=IFCPROPERTYSINGLEVALUE('${name}',$,${value},$);`); + }; + selector('ArchiveSha256', `IFCTEXT('${sourceArchive.sha256}')`); + selector('SourceProjectId', `IFCTEXT('${escapeIfcText(sourceProjectId)}')`); + const selectorSet = nextId++; + lines.push(`#${selectorSet}=IFCPROPERTYSET('2xxxxxxxxxxxxxxxxxxxxx',#${project[2]},'OPS_XerDocument',$,(${selectorIds.map(id => `#${id}`).join(',')}));`); + lines.push(`#${nextId++}=IFCRELDEFINESBYPROPERTIES('3xxxxxxxxxxxxxxxxxxxxx',#${project[2]},$,$,(#${project[1]}),#${selectorSet});`); + return ifc.replace( + '\nENDSEC;\nEND-ISO-10303-21;\n', + `\n${lines.join('\n')}\nENDSEC;\nEND-ISO-10303-21;\n`, + ); +} + +const legacyBase = writeIFC({ ...alpha, xer: undefined, xerSourceArchive: undefined, xerSourceProjectId: undefined }); +const legacyIfc = appendLegacyExpandedContainer(legacyBase, archive, 'P-A'); +const parsedLegacy = readIFC(legacyIfc); + +const rejectsCompact = (candidate: string, fragment: string): boolean => { + try { + readIFC(candidate); + return false; + } catch (error) { + return error instanceof IfcParseError + && error.reason === 'xer-source-archive' + && error.message.includes(fragment); + } +}; + +expect('1 schrijver markeert uitsluitend schema-2 compacte bronreconstructie', + compactIfc.includes("IFCPROPERTYSINGLEVALUE('SchemaVersion',$,IFCINTEGER(2),$)") + && compactIfc.includes("IFCPROPERTYSINGLEVALUE('StorageFormat',$,IFCLABEL('raw-source-reconstruction-v1'),$)")); +expect('2 schrijver neemt geen afgeleide diagnostics/readmodel-projecties op', + !compactIfc.includes('DiagnosticsChunk') + && !compactIfc.includes('DiagnosticsByteLength') + && !compactIfc.includes('documentViews') + && !compactIfc.includes('taskSourceRowsByProject')); +expect('3 compacte IFC is werkelijk kleiner dan dezelfde zelfstandige schema-1-container', + compactIfc.length < legacyIfc.length); +expect('4 compact herstel bewaart exact de oorspronkelijke bytes en checksum', + parsedCompact.xerSourceArchive !== undefined + && sha256Hex(decodeXerSourceArchive(parsedCompact.xerSourceArchive)) === archive.sha256 + && parsedCompact.xerSourceArchive.sha256 === archive.sha256); +expect('5 compact herstel reconstrueert diagnostics en readmodel volledig en semantisch gelijkwaardig', + JSON.stringify(parsedCompact.xerSourceArchive?.diagnostics) === JSON.stringify(archive.diagnostics) + && JSON.stringify(parsedCompact.xerSourceArchive?.readModel) === JSON.stringify(archive.readModel)); +expect('6 compact herstel bindt de juiste projectview met X5/X6/X8-bronmateriaal', + parsedCompact.xerSourceProjectId === 'P-A' + && parsedCompact.xer?.sourceProjectId === 'P-A' + && (parsedCompact.xer?.resources?.assignments.length ?? 0) === (alpha.xer.resources?.assignments.length ?? 0) + && (parsedCompact.xer?.metadata?.catalog.taskProjectionsByProject['P-A']?.length ?? 0) + === (alpha.xer.metadata?.catalog.taskProjectionsByProject['P-A']?.length ?? 0) + && (parsedCompact.xer?.scheduleOptions.sourceRows.length ?? 0) === alpha.xer.scheduleOptions.sourceRows.length); +expect('6a iedere zelfstandige schema-2-IFC reconstrueert de bijbehorende selectorview uit dezelfde bron', + parsedCompactBeta.xerSourceArchive?.sha256 === archive.sha256 + && parsedCompactBeta.xerSourceProjectId === 'P-B' + && parsedCompactBeta.xer?.sourceProjectId === 'P-B' + && (parsedCompactBeta.xer?.resources?.assignments.length ?? 0) === (beta.xer.resources?.assignments.length ?? 0) + && (parsedCompactBeta.xer?.metadata?.catalog.taskProjectionsByProject['P-B']?.length ?? 0) + === (beta.xer.metadata?.catalog.taskProjectionsByProject['P-B']?.length ?? 0) + && (parsedCompactBeta.xer?.scheduleOptions.sourceRows.length ?? 0) === beta.xer.scheduleOptions.sourceRows.length); +expect('7 gereconstrueerde compacte archivegraaf blijft diep immutable', + Object.isFrozen(parsedCompact.xerSourceArchive) + && Object.isFrozen(parsedCompact.xerSourceArchive?.diagnostics) + && Object.isFrozen(parsedCompact.xerSourceArchive?.readModel) + && Object.isFrozen(parsedCompact.xerSourceArchive?.readModel.resourceCatalog)); +expect('8 schema-1 uitgebreide X9-container blijft achterwaarts leesbaar', + parsedLegacy.xerSourceArchive !== undefined + && parsedLegacy.xerSourceProjectId === 'P-A' + && JSON.stringify(parsedLegacy.xerSourceArchive.diagnostics) === JSON.stringify(archive.diagnostics) + && JSON.stringify(parsedLegacy.xerSourceArchive.readModel) === JSON.stringify(archive.readModel)); +expect('9 gewijzigde bronhash blijft fail-closed getypeerd geweigerd', + rejectsCompact(compactIfc.replace(archive.sha256, `0${archive.sha256.slice(1)}`), 'Sha256')); +expect('10 onbekende compacte formaatmarkering blijft fail-closed getypeerd geweigerd', + rejectsCompact(compactIfc.replace('raw-source-reconstruction-v1', 'raw-source-reconstruction-x'), 'StorageFormat')); +expect('11 schema-2-uitvoer houdt de originele bron ongewijzigd na een gewone IFC-ronde', + parsedCompact.xerSourceArchive !== undefined + && sha256Hex(decodeXerSourceArchive(parsedCompact.xerSourceArchive)) === sha256Hex(source)); + +console.log(`X9 compact fixture: source=${source.length} compact-ifc=${compactIfc.length} legacy-ifc=${legacyIfc.length}`); +if (failures.length === 0) { + console.log(`OK xer-archive-compact: alle checks groen (${checks})`); + process.exit(0); +} +console.log(`XX xer-archive-compact: ${failures.length} afwijking(en)`); +for (const failure of failures) console.log(` - ${failure}`); +process.exit(1); diff --git a/tests/planning/check-xer-archive-readmodel.ts b/tests/planning/check-xer-archive-readmodel.ts new file mode 100644 index 000000000..d0109e123 --- /dev/null +++ b/tests/planning/check-xer-archive-readmodel.ts @@ -0,0 +1,805 @@ +// X9 reviewronde 1 — het exacte bytearchief draagt één versiegebonden, getypeerd readmodel. +// De fixture gaat door de echte XER-reader en daarna per project door writeIFC/readIFC. +import { Buffer } from 'node:buffer'; +import { createHash } from 'node:crypto'; +import { isMultiDocumentImport } from '@/services/importTypes'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; +import { IfcParseError } from '@/services/ifc/ifcErrors'; +import { writeIFC } from '@/services/ifc/ifcWriter'; +import { readXER } from '@/services/xer/xerReader'; +import { + decodeXerSourceArchive, + sha256Hex, + XerSourceArchiveValidationError, +} from '@/services/xerSourceArchive'; +import { useAppStore } from '@/state/appStore'; +import { buildWriteIFCInput } from '@/state/ifcSaveInput'; +import { recoveryInputFromParsed } from '@/state/documentContract'; +import { writeExpandedLegacyXerArchiveFixture } from './xerArchiveLegacyFixture'; + +declare const process: { exit(code: number): never }; +const failures: string[] = []; +let checks = 0; +const equal = (label: string, actual: unknown, expected: unknown) => { + checks += 1; + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + failures.push(`${label}: verwacht ${JSON.stringify(expected)}, kreeg ${JSON.stringify(actual)}`); + } +}; +const truthy = (label: string, condition: boolean) => { + checks += 1; + if (!condition) failures.push(label); +}; + +const source = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-08-01\t\t\t\t\t\tEUR', + '%T\tCURRTYPE', + '%F\tcurr_short_name\tdecimal_symbol\tdigit_group_symbol', + '%R\tEUR\tcomma\tperiod', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tsum_base_proj_id', + '%R\tP-A\tProject A\tC\t2026-08-01 08:00\tP-BASE', + '%R\tP-B\tProject B\tC\t2026-08-01 08:00\tP-MISSING', + '%R\tP-BASE\tRetained baseline\tC\t2026-07-01 08:00\t', + '%R\tP-EMPTY\tLeeg legacyproject\tC\t2026-08-01 08:00\t', + '%T\tSCHEDOPTIONS', + '%F\tproj_id', + '%R\tP-A', + '%R\tP-B', + '%R\tP-B', + '%R\tP-UNMATCHED', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC\tStandaard\t8\t40\t', + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_code\ttask_name\tclndr_id\ttarget_start_date\ttarget_end_date\ttarget_drtn_hr_cnt\ttask_type\tduration_type\tstatus_code\tcomplete_pct_type\tsuspend_date\tresume_date', + '%R\tT-A\tP-A\tA-1\tTaak A\tC\t2026-08-01 08:00\t2026-08-01 16:00\t8\tTT_Task\tDT_FixedDUR2\tTK_NotStart\t\t\t', + '%R\tT-A2\tP-A\tA-2\tTaak A2\tC\t2026-08-03 08:00\t2026-08-03 16:00\t8\tTT_Task\tDT_FixedDUR2\tTK_NotStart\t\t\t', + '%R\tT-B\tP-B\tB-1\tTaak B\tC\t2026-08-02 08:00\t2026-08-02 16:00\t8\tTT_Task\tDT_FixedRate\tTK_NotStart\tCP_Phys\t\t', + '%R\tT-BASE\tP-BASE\tBL-1\tBaselinedata\tC\t2026-07-01 08:00\t2026-07-01 16:00\t8\tTT_Task\tDT_FixedDUR2\tTK_NotStart\t\t\t', + '%T\tTASKPRED', + '%F\ttask_pred_id\tproj_id\tpred_proj_id\ttask_id\tpred_task_id\tpred_type\tlag_hr_cnt', + '%R\tREL-AB\tP-B\tP-A\tT-B\tT-A\tPR_FS\t0', + '%T\tROLES', + '%F\trole_id\trole_name', + '%R\tROLE-1\tUitvoerder', + '%T\tRSRC', + '%F\trsrc_id\trsrc_name\trsrc_type\tclndr_id\tdef_qty_per_hr\trole_id', + '%R\tR-1\tVakman\tRT_Labor\tC\t2\tROLE-1', + '%T\tRSRCRATE', + '%F\trsrc_rate_id\trsrc_id\tmax_qty_per_hr\tcost_per_qty\tstart_date', + '%R\tRATE-1\tR-1\t1\t25,50\t2026-08-01 00:00', + '%T\tTASKRSRC', + '%F\ttaskrsrc_id\tproj_id\ttask_id\trsrc_id\trole_id\ttarget_qty_per_hr\tremain_qty_per_hr\tremain_qty\ttarget_qty', + '%R\tAS-A\tP-A\tT-A\tR-1\tROLE-1\t0,5\t0,5\t4\t4', + '%R\tAS-A-UNLINKED\tP-A\tT-A-MISSING\tR-1\t\t0,1\t0,1\t1\t1', + '%R\tAS-A-UNLINKED-RESOURCE\tP-A\tT-A-MISSING-2\tR-MISSING\t\t0,1\t0,1\t1\t1', + '%R\tAS-A-ROLE-MISSING\tP-A\tT-A\t\tROLE-MISSING\t0,1\t0,1\t1\t1', + '%R\tAS-UNSCOPED\t\tT-A\tR-1\t\t0,1\t0,1\t1\t1', + '%R\tAS-B\tP-B\tT-B\tR-1\t\t0,25\t0,25\t2\t2', + '%R\tAS-BASE\tP-BASE\tT-BASE\tR-1\t\t0,2\t0,2\t2\t2', + '%T\tACTVTYPE', + '%F\tactv_code_type_id\tactv_code_type\tseq_num', + '%R\tTYPE\tFase\t1', + '%T\tACTVCODE', + '%F\tactv_code_id\tactv_code_type_id\tshort_name\tseq_num', + '%R\tV-A\tTYPE\tA\t1', + '%R\tV-B\tTYPE\tB\t2', + '%T\tTASKACTV', + '%F\tproj_id\ttask_id\tactv_code_type_id\tactv_code_id', + '%R\tP-A\tT-A\tTYPE\tV-A', + '%R\tP-B\tT-B\tTYPE\tV-B', + '%R\tP-BASE\tT-BASE\tTYPE\tV-A', + '%T\tUDFTYPE', + '%F\tudf_type_id\ttable_name\tudf_type_label\tlogical_data_type', + '%R\tUF\tTASK\tKeuze\tFT_STATICTYPE', + '%T\tUDFVALUE', + '%F\tudf_type_id\tproj_id\tfk_id\tudf_text', + '%R\tUF\tP-A\tT-A\tAlleen A', + '%E', +].join('\r\n')); + +const opened = readXER(source); +if (!isMultiDocumentImport(opened)) throw new Error('De readmodel-fixture moet twee projecten openen'); +const [a, b] = opened.results; +if (!a || !b || !a.xerSourceArchive) throw new Error('XER-resultaat mist project of archief'); +const archive = a.xerSourceArchive as typeof a.xerSourceArchive & { + readModel?: { + schemaVersion: number; + numberFormat: unknown; + scheduleOptionsSourceArchive?: unknown; + resourceCatalog: unknown; + metadataCatalog: unknown; + }; + diagnostics: Record & { schemaVersion?: number; file?: unknown; documentViews?: Record }; +}; + +equal('1 archive-readmodel is versiegebonden en bewaart CURRTYPE exact', + [archive.readModel?.schemaVersion, archive.readModel?.numberFormat], + [1, { decimal: ',', group: '.', source: 'currtype', currencyCode: 'EUR' }]); +truthy('2 beide documenten gebruiken de catalogi uit exact hetzelfde archive-readmodel', + a.xer?.resources?.catalog === archive.readModel?.resourceCatalog + && b.xer?.resources?.catalog === archive.readModel?.resourceCatalog + && a.xer?.metadata?.catalog === archive.readModel?.metadataCatalog + && b.xer?.metadata?.catalog === archive.readModel?.metadataCatalog); +truthy('2a projectprovenance heeft geen tweede persistente bronkopie naast de immutable documentview', + a.xer?.resources?.assignments === a.xerSourceArchive?.diagnostics.documentViews['P-A']?.resources?.assignments + && b.xer?.resources?.assignments === b.xerSourceArchive?.diagnostics.documentViews['P-B']?.resources?.assignments + && Object.isFrozen(a.xer?.resources?.assignments) + && Object.isFrozen(a.xer?.resources?.assignments[0])); +truthy('2b X5 bewaart precies één file-wide sourceArchive in het archive-readmodel', + a.xer?.scheduleOptions.sourceArchive === archive.readModel?.scheduleOptionsSourceArchive + && b.xer?.scheduleOptions.sourceArchive === archive.readModel?.scheduleOptionsSourceArchive + && a.xer?.scheduleOptions.sourceArchive === b.xer?.scheduleOptions.sourceArchive); +truthy('2c X5-projectviews verwijzen met sourceRows naar die ene immutable filecache', + [a, b].every(result => result.xer?.scheduleOptions.sourceRows.every((row, index) => + row === result.xer?.scheduleOptions.sourceArchive.rows[result.xer.scheduleOptions.sourceRowIndexes[index]!])) + && Object.isFrozen(a.xer?.scheduleOptions.sourceArchive) + && Object.isFrozen(a.xer?.scheduleOptions.sourceRows)); +equal('3 diagnostics hebben een getypeerde versie, file-view en beide projectviews', + [archive.diagnostics.schemaVersion, Boolean(archive.diagnostics.file), Object.keys(archive.diagnostics.documentViews ?? {}).sort()], + [1, true, ['P-A', 'P-B']]); +equal('3a rijke meerprojectfixture bewaart baseline, dangling verwijzing en leeg legacyproject zonder documentview', { + report: a.xer?.report, + baselineNames: a.baselines?.map(item => item.name), + sourceProjects: Object.keys(archive.readModel?.taskSourceRowsByProject ?? {}).sort(), + projectRows: archive.readModel?.scheduleOptionsSourceArchive?.rows + .filter(row => row.table === 'PROJECT').map(row => [row.cells.proj_id, row.cells.sum_base_proj_id]), + metadataTasks: archive.readModel?.metadataCatalog.taskProjections + .map(item => `${item.projectId}/${item.taskId}`), +}, { + report: { + projectsSeen: 4, documentsOpened: 2, emptyProjectsSkipped: 1, + baselineProjectsExcluded: 1, baselinesMaterialized: 1, danglingBaselineReferences: 1, + externalLinksPreserved: 1, baselineExclusionReverted: false, baselineFallbackReasons: [], + }, + baselineNames: ['Retained baseline'], + sourceProjects: ['P-A', 'P-B', 'P-BASE'], + projectRows: [['P-A', 'P-BASE'], ['P-B', 'P-MISSING'], ['P-BASE', ''], ['P-EMPTY', '']], + metadataTasks: ['P-A/T-A', 'P-B/T-B', 'P-BASE/T-BASE'], +}); +equal('3b cross-project TASKPRED blijft brondata in beide endpointviews en wordt nooit lokale solverinvoer', + [a.xer?.externalRelations.map(item => [item.id, item.direction]), + b.xer?.externalRelations.map(item => [item.id, item.direction]), + a.xer?.externalLinks.map(item => item.id), b.xer?.externalLinks.map(item => item.id), + a.sequences.length, b.sequences.length], + [[['REL-AB', 'successor']], [['REL-AB', 'predecessor']], ['REL-AB'], ['REL-AB'], 0, 0]); +equal('3c X6 bewaart gekoppelde, ongekoppelde en baseline-TASKRSRC als onderscheiden provenance', { + canonical: archive.readModel?.resourceCatalog.rows.assignments.map(row => row.cells.taskrsrc_id), + a: a.xer?.resources?.assignments.map(item => item.sourceId), + b: b.xer?.resources?.assignments.map(item => item.sourceId), + missing: a.xer?.resources?.issues.filter(item => item.code === 'XER_ASSIGNMENT_TASK_MISSING').map(item => item.sourceId), + skipped: a.xer?.resources?.issues.filter(item => item.fallback === 'SKIPPED').map(item => [item.code, item.sourceId]), +}, { + canonical: ['AS-A', 'AS-A-UNLINKED', 'AS-A-UNLINKED-RESOURCE', 'AS-A-ROLE-MISSING', 'AS-UNSCOPED', 'AS-B', 'AS-BASE'], + a: ['AS-A', 'AS-A-UNLINKED', 'AS-A-UNLINKED-RESOURCE', 'AS-A-ROLE-MISSING'], b: ['AS-B'], + missing: ['AS-A-UNLINKED'], + skipped: [['XER_ASSIGNMENT_TASK_MISSING', 'AS-A-UNLINKED'], + ['XER_ASSIGNMENT_RESOURCE_MISSING', 'AS-A-UNLINKED-RESOURCE'], + ['XER_ASSIGNMENT_ROLE_MISSING', 'AS-A-ROLE-MISSING']], +}); +equal('3d X5 bewaart gewone, dubbele en unmatched SCHEDOPTIONS zonder bronverlies', { + diagnostics: archive.readModel?.scheduleOptionsSourceArchive.diagnostics.map(item => [item.projectId, item.rowIndexes.length]), + unmatched: archive.readModel?.scheduleOptionsSourceArchive.unmatchedScheduleOptionsRowIndexes.length, + sources: [a.xer?.scheduleOptions.source, b.xer?.scheduleOptions.source], +}, { diagnostics: [['P-B', 2]], unmatched: 1, sources: ['schedoptions', 'xer-defaults'] }); + +const roundTrips = [a, b].map(result => readIFC(writeIFC(result))); +equal('4 per zelfstandig IFC herleven X6-resources, rates en projecttoewijzingen werkelijk', + roundTrips.map(result => ({ + catalogResources: result.xer?.resources?.catalog?.resources.length, + rates: result.xer?.resources?.catalog?.rows.rates.length, + assignments: result.xer?.resources?.assignments.map(item => item.sourceId), + })), [ + { catalogResources: 1, rates: 1, assignments: ['AS-A', 'AS-A-UNLINKED', 'AS-A-UNLINKED-RESOURCE', 'AS-A-ROLE-MISSING'] }, + { catalogResources: 1, rates: 1, assignments: ['AS-B'] }, + ]); +equal('5 per zelfstandig IFC herleven X8-catalogus en documentspecifieke projectie werkelijk', + roundTrips.map(result => ({ + catalogTypes: result.xer?.metadata?.catalog?.activityCodeTypes.map(type => type.id), + taskCodes: result.tasks.find(task => task.wbsCode === (result.xerSourceProjectId === 'P-A' ? 'A-1' : 'B-1'))?.activityCodes, + udf: result.tasks.find(task => task.wbsCode === 'A-1')?.customFields, + })), [ + { catalogTypes: ['TYPE'], taskCodes: { TYPE: 'V-A' }, udf: { UF: 'Alleen A' } }, + { catalogTypes: ['TYPE'], taskCodes: { TYPE: 'V-B' }, udf: undefined }, + ]); +truthy('6 IFC-read gebruikt per document de catalogusrefs uit zijn archive-readmodel', roundTrips.every(result => { + const readModel = (result.xerSourceArchive as typeof archive | undefined)?.readModel; + return result.xer?.resources?.catalog === readModel?.resourceCatalog + && result.xer?.metadata?.catalog === readModel?.metadataCatalog; +})); +truthy('6a IFC-read materialiseert geen tweede X6-provenancekopie buiten zijn archive-documentview', + roundTrips.every(result => result.xer?.resources?.assignments + === result.xerSourceArchive?.diagnostics.documentViews[result.xerSourceProjectId ?? '']?.resources?.assignments)); +truthy('6b IFC-read bindt X5 sourceArchive/sourceRows opnieuw aan de ene archivecache', + roundTrips.every(result => { + const metadata = result.xer; + return metadata !== undefined + && metadata.scheduleOptions.sourceArchive === result.xerSourceArchive?.readModel.scheduleOptionsSourceArchive + && metadata.scheduleOptions.sourceRows.every((row, index) => + row === result.xerSourceArchive?.readModel.scheduleOptionsSourceArchive.rows[ + metadata.scheduleOptions.sourceRowIndexes[index]! + ]); + })); + +const originalIfc = writeExpandedLegacyXerArchiveFixture(a); +const diagnosticChunkPattern = /IFCPROPERTYSINGLEVALUE\('DiagnosticsChunk(\d{6})',\$,IFCTEXT\('([A-Za-z0-9+/=]+)'\),\$\)/g; +const diagnosticChunks = [...originalIfc.matchAll(diagnosticChunkPattern)] + .sort((left, right) => left[1]!.localeCompare(right[1]!)); +if (diagnosticChunks.length === 0) throw new Error('DiagnosticsChunk-container ontbreekt'); +const decodeBase64 = (value: string) => Uint8Array.from(atob(value), char => char.charCodeAt(0)); +const diagnosticBytes = new Uint8Array(diagnosticChunks.reduce((total, chunk) => total + decodeBase64(chunk[2]!).length, 0)); +let diagnosticOffset = 0; +for (const chunk of diagnosticChunks) { + const decoded = decodeBase64(chunk[2]!); + diagnosticBytes.set(decoded, diagnosticOffset); + diagnosticOffset += decoded.length; +} +const payload = JSON.parse(new TextDecoder().decode(diagnosticBytes)) as Record; +const ifcWithMetadataBytes = (encoded: Uint8Array) => { + const rebuiltChunks: string[] = []; + for (let offset = 0; offset < encoded.length; offset += 196_608) { + rebuiltChunks.push(Buffer.from(encoded.subarray(offset, offset + 196_608)).toString('base64')); + } + if (rebuiltChunks.length !== diagnosticChunks.length) { + throw new Error('Testfixture wijzigde onverwacht het diagnostics-chunkaantal'); + } + let result = originalIfc; + for (let index = 0; index < diagnosticChunks.length; index += 1) { + result = result.replace(diagnosticChunks[index]![2]!, rebuiltChunks[index]!); + } + const independentSha256 = createHash('sha256').update(encoded).digest('hex'); + return result + .replace(/(IFCPROPERTYSINGLEVALUE\('DiagnosticsByteLength',\$,IFCINTEGER\()\d+(\),\$\))/, `$1${encoded.length}$2`) + .replace(/(IFCPROPERTYSINGLEVALUE\('DiagnosticsSha256',\$,IFCTEXT\(')[0-9a-f]{64}('\),\$\))/, `$1${independentSha256}$2`); +}; +const ifcWithMetadataPayload = (metadataPayload: Record) => + ifcWithMetadataBytes(new TextEncoder().encode(JSON.stringify(metadataPayload))); +const legacySelectorPayload = JSON.parse(new TextDecoder().decode(diagnosticBytes)) as Record; +const legacyTaskRows = (legacySelectorPayload.readModel as Record) + .taskSourceRowsByProject as Record>>; +delete legacyTaskRows['P-A']; +let legacySelectorAccepted = true; +try { readIFC(ifcWithMetadataPayload(legacySelectorPayload)); } +catch { legacySelectorAccepted = false; } +truthy('6c volledig ontbrekende legacy-TASK-groep blijft een geldige verliesvrije archiefgrens', legacySelectorAccepted); +const presentEmptyTaskGroupPayload = JSON.parse(new TextDecoder().decode(diagnosticBytes)) as Record; +const presentEmptyTaskRows = (presentEmptyTaskGroupPayload.readModel as Record) + .taskSourceRowsByProject as Record>>; +presentEmptyTaskRows['P-A'] = []; +let presentEmptyTaskGroupRejection: unknown; +try { readIFC(ifcWithMetadataPayload(presentEmptyTaskGroupPayload)); } +catch (error) { presentEmptyTaskGroupRejection = error; } +truthy('6d aanwezige maar lege TASK-groep blijft hard gebonden en wordt specifiek geweigerd', + presentEmptyTaskGroupRejection instanceof IfcParseError + && presentEmptyTaskGroupRejection.reason === 'xer-source-archive' + && presentEmptyTaskGroupRejection.message.includes('ontbrekende TASK-identiteit')); +const byProject = ((payload.diagnostics as Record).documentViews) as Record>; +byProject['P-A']!.calendarIssues = [{ + code: 'XER_CALENDAR_RECOVERED', calendarId: 'C', line: 1, reason: 'fixture', resolution: 'BROKEN', +}]; +let hostileTyped = false; +try { readIFC(ifcWithMetadataPayload(payload)); } +catch (error) { hostileTyped = error instanceof IfcParseError && error.reason === 'xer-source-archive'; } +truthy('7 hostile nested diagnostic-enum wordt totaal en getypeerd geweigerd', hostileTyped); + +const hostileNumberPayload = JSON.parse(new TextDecoder().decode(diagnosticBytes)) as Record; +const hostileNumberFormat = ((hostileNumberPayload.readModel as Record).numberFormat) as Record; +hostileNumberFormat.decimal = ';'; +let hostileNumberTyped = false; +try { readIFC(ifcWithMetadataPayload(hostileNumberPayload)); } +catch (error) { hostileNumberTyped = error instanceof IfcParseError && error.reason === 'xer-source-archive'; } +truthy('7a hostile CURRTYPE-numberFormatvorm wordt getypeerd geweigerd', hostileNumberTyped); + +const reviewerCorruptions: readonly [string, (candidate: Record) => void][] = [ + ['X6 resourceobject [42]', candidate => { + const readModel = candidate.readModel as Record; + (readModel.resourceCatalog as Record).resources = [42]; + }], + ['X8 activityCodeTypes [false]', candidate => { + const readModel = candidate.readModel as Record; + (readModel.metadataCatalog as Record).activityCodeTypes = [false]; + }], + ['document externalLink zonder kernvelden', candidate => { + const diagnostics = candidate.diagnostics as Record; + const views = (diagnostics.documentViews as Record>); + views['P-A']!.externalLinks = [{ definitely: 'not-an-XerDocumentExternalLink' }]; + }], +]; +for (const [label, mutate] of reviewerCorruptions) { + const candidate = JSON.parse(new TextDecoder().decode(diagnosticBytes)) as Record; + mutate(candidate); + let rejected = false; + try { readIFC(ifcWithMetadataPayload(candidate)); } + catch (error) { rejected = error instanceof IfcParseError && error.reason === 'xer-source-archive'; } + truthy(`7b reviewerpayload ${label} wordt ondanks correcte hash getypeerd geweigerd`, rejected); +} + +const relationCorruptions: readonly [string, (candidate: Record) => void][] = [ + ['X8 canonieke én projectview wijzen coherent naar niet-bestaande T-A-GHOST', candidate => { + const readModel = candidate.readModel as Record; + const metadata = readModel.metadataCatalog as Record; + const canonical = metadata.taskProjections as Array>; + const projections = metadata.taskProjectionsByProject as Record>>; + const canonicalA = canonical.find(item => item.projectId === 'P-A' && item.taskId === 'T-A'); + const projectA = projections['P-A']?.find(item => item.taskId === 'T-A'); + if (!canonicalA || !projectA) throw new Error('X8 T-A-aanvalsdoel ontbreekt'); + canonicalA.taskId = 'T-A-GHOST'; + projectA.taskId = 'T-A-GHOST'; + }], + ['X6 assignment kruist coherent als typed taskSourceId=T-B terwijl raw TASKRSRC T-A draagt', candidate => { + const diagnostics = candidate.diagnostics as Record; + const views = diagnostics.documentViews as Record>; + const resources = views['P-A']!.resources as Record; + const assignments = resources.assignments as Array>; + const assignment = assignments.find(item => item.sourceId === 'AS-A'); + if (!assignment) throw new Error('X6 AS-A-aanvalsdoel ontbreekt'); + assignment.taskSourceId = 'T-B'; + }], + ['externalRelation gebruikt een niet-bestaand lokaal TASK-eindpunt T-A-GHOST', candidate => { + const diagnostics = candidate.diagnostics as Record; + const views = diagnostics.documentViews as Record>; + (views['P-A']!.externalRelations as Array>)[0]!.localTaskId = 'T-A-GHOST'; + }], + ['beide externalRelations zijn weg maar hun afgeleide externalLink blijft bestaan', candidate => { + const diagnostics = candidate.diagnostics as Record; + const views = diagnostics.documentViews as Record>; + views['P-A']!.externalRelations = []; + views['P-B']!.externalRelations = []; + }], + ['externalLink is in beide endpointviews coherent omgebogen naar ghost-taken', candidate => { + const diagnostics = candidate.diagnostics as Record; + const views = diagnostics.documentViews as Record>; + for (const projectId of ['P-A', 'P-B']) { + const link = (views[projectId]!.externalLinks as Array>)[0]!; + (link.predecessor as Record).taskId = 'T-A-GHOST'; + (link.successor as Record).taskId = 'T-B-GHOST'; + } + }], + ['externalLink wijst naar niet-bestaand opvolgerproject en verdwijnt uit P-B terwijl bronrelatie blijft', candidate => { + const diagnostics = candidate.diagnostics as Record; + const views = diagnostics.documentViews as Record>; + const link = (views['P-A']!.externalLinks as Array>)[0]!; + const successor = link.successor as Record; + successor.projectId = 'P-NIET-BESTAAND'; + successor.taskId = 'T-NIET-BESTAAND'; + views['P-B']!.externalLinks = []; + }], + ['X5 PROJECT-bronrijen zijn omgewisseld en alle documentindexen volgen coherent mee', candidate => { + const readModel = candidate.readModel as Record; + const sourceArchive = readModel.scheduleOptionsSourceArchive as Record; + const rows = sourceArchive.rows as Array>; + [rows[0], rows[1]] = [rows[1]!, rows[0]!]; + const diagnostics = candidate.diagnostics as Record; + const views = diagnostics.documentViews as Record>; + for (const view of Object.values(views)) { + const schedule = view.scheduleOptions as Record; + schedule.sourceRowIndexes = (schedule.sourceRowIndexes as number[]).map(index => + index === 0 ? 1 : index === 1 ? 0 : index); + } + }], + ['X6 canonieke TASKRSRC-rijen en P-A-documentview zijn samen omgewisseld', candidate => { + const readModel = candidate.readModel as Record; + const resourceCatalog = readModel.resourceCatalog as Record; + const rows = (resourceCatalog.rows as Record).assignments as Array>; + [rows[0], rows[1]] = [rows[1]!, rows[0]!]; + const diagnostics = candidate.diagnostics as Record; + const views = diagnostics.documentViews as Record>; + const resources = views['P-A']!.resources as Record; + const assignments = resources.assignments as Array>; + [assignments[0], assignments[1]] = [assignments[1]!, assignments[0]!]; + }], + ['X8-projectie onder P-A draagt projectId P-B', candidate => { + const readModel = candidate.readModel as Record; + const metadata = readModel.metadataCatalog as Record; + const projections = metadata.taskProjectionsByProject as Record>>; + projections['P-A']![0]!.projectId = 'P-B'; + }], + ['X8-projectindex dupliceert een canonieke projectie', candidate => { + const readModel = candidate.readModel as Record; + const metadata = readModel.metadataCatalog as Record; + const projections = metadata.taskProjectionsByProject as Record>>; + projections['P-A']!.push(structuredClone(projections['P-A']![0]!)); + }], + ['X8-projectindex laat een canonieke projectie weg', candidate => { + const readModel = candidate.readModel as Record; + const metadata = readModel.metadataCatalog as Record; + const projections = metadata.taskProjectionsByProject as Record>>; + projections['P-A'] = []; + }], + ['X8-canonieke projectielijst staat niet in project-/taakvolgorde', candidate => { + const readModel = candidate.readModel as Record; + const metadata = readModel.metadataCatalog as Record; + (metadata.taskProjections as Array>).reverse(); + }], + ['X5-view P-A verwijst naar een bronrij van P-B', candidate => { + const diagnostics = candidate.diagnostics as Record; + const views = diagnostics.documentViews as Record>; + const scheduleA = views['P-A']!.scheduleOptions as Record; + const scheduleB = views['P-B']!.scheduleOptions as Record; + (scheduleA.sourceRowIndexes as number[])[0] = (scheduleB.sourceRowIndexes as number[])[0]!; + }], + ['X5-view P-A verliest zijn verplichte PROJECT-rij door een verkeerd tabeltype', candidate => { + const diagnostics = candidate.diagnostics as Record; + const views = diagnostics.documentViews as Record>; + const scheduleA = views['P-A']!.scheduleOptions as Record; + const readModel = candidate.readModel as Record; + const sourceArchive = readModel.scheduleOptionsSourceArchive as Record; + const rows = sourceArchive.rows as Array>; + rows[(scheduleA.sourceRowIndexes as number[])[0]!]!.table = 'SCHEDOPTIONS'; + }], + ['X5-view P-A laat een canonieke bronrij weg', candidate => { + const diagnostics = candidate.diagnostics as Record; + const views = diagnostics.documentViews as Record>; + const scheduleA = views['P-A']!.scheduleOptions as Record; + (scheduleA.sourceRowIndexes as number[]).pop(); + }], + ['X5-unmatched-index claimt een aan P-A gekoppelde SCHEDOPTIONS-rij', candidate => { + const diagnostics = candidate.diagnostics as Record; + const views = diagnostics.documentViews as Record>; + const scheduleA = views['P-A']!.scheduleOptions as Record; + const readModel = candidate.readModel as Record; + const sourceArchive = readModel.scheduleOptionsSourceArchive as Record; + (sourceArchive.unmatchedScheduleOptionsRowIndexes as number[]).push( + (scheduleA.sourceRowIndexes as number[])[1]!, + ); + }], + ['X6-assignment van P-A draagt projectSourceId P-B', candidate => { + const diagnostics = candidate.diagnostics as Record; + const views = diagnostics.documentViews as Record>; + const resources = views['P-A']!.resources as Record; + (resources.assignments as Array>)[0]!.projectSourceId = 'P-B'; + }], + ['X6-assignment in een documentview verliest zijn vereiste projectSourceId', candidate => { + const diagnostics = candidate.diagnostics as Record; + const views = diagnostics.documentViews as Record>; + const resources = views['P-A']!.resources as Record; + delete (resources.assignments as Array>)[0]!.projectSourceId; + }], + ['TASK-sourceview onder P-A draagt proj_id P-B', candidate => { + const readModel = candidate.readModel as Record; + const taskRows = readModel.taskSourceRowsByProject as Record>>; + const cells = taskRows['P-A']![0]!.cells as Record; + cells.proj_id = 'P-B'; + }], + ['externe documentrelatie onder P-A draagt localProjectId P-B', candidate => { + const diagnostics = candidate.diagnostics as Record; + const views = diagnostics.documentViews as Record>; + (views['P-A']!.externalRelations as Array>).push({ + id: 'cross-project-probe', localProjectId: 'P-B', localTaskId: 'T-A', + externalProjectId: 'P-B', externalTaskId: 'T-B', direction: 'successor', type: 'FS', lagMinutes: 0, + }); + }], + ['canonieke externalLink onder P-A heeft geen P-A-eindpunt', candidate => { + const diagnostics = candidate.diagnostics as Record; + const views = diagnostics.documentViews as Record>; + (views['P-A']!.externalLinks as Array>).push({ + id: 'detached-link-probe', predecessor: { projectId: 'P-B', taskId: 'T-B' }, + successor: { projectId: 'P-C', taskId: 'T-C' }, type: 'FS', lagMinutes: 0, + }); + }], +]; +for (const [label, mutate] of relationCorruptions) { + const candidate = JSON.parse(new TextDecoder().decode(diagnosticBytes)) as Record; + mutate(candidate); + let rejected = false; + try { readIFC(ifcWithMetadataPayload(candidate)); } + catch (error) { rejected = error instanceof IfcParseError && error.reason === 'xer-source-archive'; } + truthy(`7c relationele reviewerpayload ${label} wordt met coherente chunks getypeerd geweigerd`, rejected); +} + +const assignmentSkipCorruptions: readonly [string, string, (candidate: Record) => void][] = [ + ['A1 TASK_MISSING is herlabeld naar RESOURCE_MISSING terwijl de resource bestaat', + 'TASKRSRC-skipdiagnostiek', candidate => { + const diagnostics = candidate.diagnostics as Record; + const views = diagnostics.documentViews as Record>; + const resources = views['P-A']!.resources as Record; + const issues = resources.issues as Array>; + const issue = issues.find(item => item.sourceId === 'AS-A-UNLINKED'); + if (!issue) throw new Error('A1-doelissue ontbreekt'); + issue.code = 'XER_ASSIGNMENT_RESOURCE_MISSING'; + }], + ['A2 een volledig geldige assignment draagt een verzonnen TASK_MISSING-issue', + 'TASKRSRC-skipdiagnostiek', candidate => { + const diagnostics = candidate.diagnostics as Record; + const views = diagnostics.documentViews as Record>; + const resources = views['P-A']!.resources as Record; + const assignments = resources.assignments as Array>; + const issues = resources.issues as Array>; + const assignment = assignments.find(item => item.sourceId === 'AS-A'); + if (!assignment) throw new Error('A2-doelassignment ontbreekt'); + issues.push({ code: 'XER_ASSIGNMENT_TASK_MISSING', table: 'TASKRSRC', + line: assignment.line, sourceId: assignment.sourceId, fallback: 'SKIPPED' }); + }], + ['A3 RESOURCE_MISSING is herlabeld naar TASK_MISSING terwijl resource én taak ontbreken', + 'TASKRSRC-skipdiagnostiek', candidate => { + const diagnostics = candidate.diagnostics as Record; + const views = diagnostics.documentViews as Record>; + const resources = views['P-A']!.resources as Record; + const issues = resources.issues as Array>; + const issue = issues.find(item => item.sourceId === 'AS-A-UNLINKED-RESOURCE'); + if (!issue) throw new Error('A3-doelissue ontbreekt'); + issue.code = 'XER_ASSIGNMENT_TASK_MISSING'; + }], + ['A4 ROLE_MISSING is herlabeld naar TASK_MISSING', + 'TASKRSRC-skipdiagnostiek', candidate => { + const diagnostics = candidate.diagnostics as Record; + const views = diagnostics.documentViews as Record>; + const resources = views['P-A']!.resources as Record; + const issues = resources.issues as Array>; + const issue = issues.find(item => item.sourceId === 'AS-A-ROLE-MISSING'); + if (!issue) throw new Error('A4-doelissue ontbreekt'); + issue.code = 'XER_ASSIGNMENT_TASK_MISSING'; + }], + ['A6 een vereiste TASK_MISSING-diagnose ontbreekt volledig', + 'TASKRSRC-skipdiagnostiek', candidate => { + const diagnostics = candidate.diagnostics as Record; + const views = diagnostics.documentViews as Record>; + const resources = views['P-A']!.resources as Record; + const issues = resources.issues as Array>; + resources.issues = issues.filter(item => item.sourceId !== 'AS-A-UNLINKED'); + }], + ['A7 een TASK_MISSING-diagnose komt dubbel voor', + 'TASKRSRC-skipdiagnostiek', candidate => { + const diagnostics = candidate.diagnostics as Record; + const views = diagnostics.documentViews as Record>; + const resources = views['P-A']!.resources as Record; + const issues = resources.issues as Array>; + const issue = issues.find(item => item.sourceId === 'AS-A-UNLINKED'); + if (!issue) throw new Error('A7-doelissue ontbreekt'); + issues.push(structuredClone(issue)); + }], + ['A8 een TASK_MISSING-diagnose is aan de verkeerde bronregel gekoppeld', + 'TASKRSRC-skipdiagnostiek', candidate => { + const diagnostics = candidate.diagnostics as Record; + const views = diagnostics.documentViews as Record>; + const resources = views['P-A']!.resources as Record; + const issues = resources.issues as Array>; + const issue = issues.find(item => item.sourceId === 'AS-A-UNLINKED'); + if (!issue) throw new Error('A8-doelissue ontbreekt'); + issue.line = Number(issue.line) + 1; + }], +]; +for (const [label, expectedRule, mutate] of assignmentSkipCorruptions) { + const candidate = JSON.parse(new TextDecoder().decode(diagnosticBytes)) as Record; + mutate(candidate); + let rejection: unknown; + try { readIFC(ifcWithMetadataPayload(candidate)); } + catch (error) { rejection = error; } + truthy(`7d ${label} raakt de specifieke getypeerde assignmentregel`, + rejection instanceof IfcParseError + && rejection.reason === 'xer-source-archive' + && rejection.message.includes(expectedRule)); +} + +const staleResourceSkipPayload = JSON.parse(new TextDecoder().decode(diagnosticBytes)) as Record; +{ + const readModel = staleResourceSkipPayload.readModel as Record; + const catalog = readModel.resourceCatalog as Record; + const canonicalRows = (catalog.rows as Record).assignments as Array>; + const canonicalRow = canonicalRows.find(row => + (row.cells as Record).taskrsrc_id === 'AS-A-UNLINKED-RESOURCE'); + const diagnostics = staleResourceSkipPayload.diagnostics as Record; + const views = diagnostics.documentViews as Record>; + const resources = views['P-A']!.resources as Record; + const assignment = (resources.assignments as Array>) + .find(item => item.sourceId === 'AS-A-UNLINKED-RESOURCE'); + const issue = (resources.issues as Array>) + .find(item => item.sourceId === 'AS-A-UNLINKED-RESOURCE'); + if (!canonicalRow || !assignment || issue?.code !== 'XER_ASSIGNMENT_RESOURCE_MISSING') { + throw new Error('A9-resourceaanvalsdoel ontbreekt of heeft niet de verwachte uitgangstoestand'); + } + (canonicalRow.cells as Record).rsrc_id = 'R-1'; + const rawRow = assignment.rawRow as Record; + (rawRow.cells as Record).rsrc_id = 'R-1'; + assignment.entity = { kind: 'RESOURCE', sourceId: 'R-1', internalId: 'xer-resource:R-1' }; +} +let staleResourceSkipRejection: unknown; +try { readIFC(ifcWithMetadataPayload(staleResourceSkipPayload)); } +catch (error) { staleResourceSkipRejection = error; } +truthy('7d A9 canonieke resource bestaat weer maar de oude RESOURCE_MISSING-diagnose blijft specifiek rood', + staleResourceSkipRejection instanceof IfcParseError + && staleResourceSkipRejection.reason === 'xer-source-archive' + && staleResourceSkipRejection.message.includes('TASKRSRC-skipdiagnostiek')); + +const missingResourceWithoutIssuePayload = JSON.parse(new TextDecoder().decode(diagnosticBytes)) as Record; +{ + const readModel = missingResourceWithoutIssuePayload.readModel as Record; + const catalog = readModel.resourceCatalog as Record; + const canonicalRows = (catalog.rows as Record).assignments as Array>; + const canonicalRow = canonicalRows.find(row => (row.cells as Record).taskrsrc_id === 'AS-A'); + const diagnostics = missingResourceWithoutIssuePayload.diagnostics as Record; + const views = diagnostics.documentViews as Record>; + const resources = views['P-A']!.resources as Record; + const assignment = (resources.assignments as Array>).find(item => item.sourceId === 'AS-A'); + const issue = (resources.issues as Array>).find(item => item.sourceId === 'AS-A'); + if (!canonicalRow || !assignment || issue) { + throw new Error('A10-resourceaanvalsdoel ontbreekt of heeft niet de verwachte uitgangstoestand'); + } + (canonicalRow.cells as Record).rsrc_id = 'R-GHOST'; + const rawRow = assignment.rawRow as Record; + (rawRow.cells as Record).rsrc_id = 'R-GHOST'; + assignment.entity = { kind: 'RESOURCE', sourceId: 'R-GHOST', internalId: 'xer-resource:R-GHOST' }; +} +let missingResourceWithoutIssueRejection: unknown; +try { readIFC(ifcWithMetadataPayload(missingResourceWithoutIssuePayload)); } +catch (error) { missingResourceWithoutIssueRejection = error; } +truthy('7d A10 canonieke resource ontbreekt nu maar zonder skipdiagnose wordt specifiek rood', + missingResourceWithoutIssueRejection instanceof IfcParseError + && missingResourceWithoutIssueRejection.reason === 'xer-source-archive' + && missingResourceWithoutIssueRejection.message.includes('TASKRSRC-skipdiagnostiek')); + +const remoteGhostPayload = JSON.parse(new TextDecoder().decode(diagnosticBytes)) as Record; +{ + const diagnostics = remoteGhostPayload.diagnostics as Record; + const views = diagnostics.documentViews as Record>; + const relation = (views['P-A']!.externalRelations as Array>)[0]!; + relation.externalTaskId = 'T-B-GHOST'; + views['P-B']!.externalRelations = []; + for (const projectId of ['P-A', 'P-B']) { + const link = (views[projectId]!.externalLinks as Array>)[0]!; + (link.successor as Record).taskId = 'T-B-GHOST'; + } +} +let remoteGhostRejection: unknown; +try { readIFC(ifcWithMetadataPayload(remoteGhostPayload)); } +catch (error) { remoteGhostRejection = error; } +truthy('7e A5 coherente externe ghost-taak in een open TASK-project raakt de specifieke endpointregel', + remoteGhostRejection instanceof IfcParseError + && remoteGhostRejection.reason === 'xer-source-archive' + && remoteGhostRejection.message.includes('ontbrekend extern TASK-eindpunt')); + +const taskGroupCorruptions: readonly [string, string, (candidate: Record) => void][] = [ + ['aanwezige TASK-groep is coherent naar een ghost-identiteit gewijzigd', + 'ontbrekende TASK-identiteit', candidate => { + const readModel = candidate.readModel as Record; + const groups = readModel.taskSourceRowsByProject as Record>>; + const cells = groups['P-A']![0]!.cells as Record; + cells.task_id = 'T-A-GHOST'; + }], + ['aanwezige TASK-groep is buiten zijn bronvolgorde herschikt', + 'staat niet in bronvolgorde', candidate => { + const readModel = candidate.readModel as Record; + const groups = readModel.taskSourceRowsByProject as Record>>; + [groups['P-A']![0], groups['P-A']![1]] = [groups['P-A']![1]!, groups['P-A']![0]!]; + }], +]; +for (const [label, expectedRule, mutate] of taskGroupCorruptions) { + const candidate = JSON.parse(new TextDecoder().decode(diagnosticBytes)) as Record; + mutate(candidate); + let rejection: unknown; + try { readIFC(ifcWithMetadataPayload(candidate)); } + catch (error) { rejection = error; } + truthy(`7f ${label} raakt de specifieke TASK-bronregel`, + rejection instanceof IfcParseError + && rejection.reason === 'xer-source-archive' + && rejection.message.includes(expectedRule)); +} + +const invalidUtf8Payload = JSON.parse(new TextDecoder().decode(diagnosticBytes)) as Record; +const invalidUtf8 = new TextEncoder().encode(JSON.stringify(invalidUtf8Payload)); +const currencyNeedle = new TextEncoder().encode('"currencyCode":"EUR"'); +const currencyOffset = invalidUtf8.findIndex((_byte, index) => + currencyNeedle.every((needleByte, needleIndex) => invalidUtf8[index + needleIndex] === needleByte)); +if (currencyOffset < 0) throw new Error('UTF-8-testdoel currencyCode ontbreekt'); +invalidUtf8[currencyOffset + '"currencyCode":"'.length] = 0xff; +truthy('7g niet-fatale controlemeter toont dat 0xff als U+FFFD geldige JSON zou blijven', (() => { + try { + const decoded = new TextDecoder().decode(invalidUtf8); + return decoded.includes('\ufffdUR') && JSON.parse(decoded) !== null; + } catch { return false; } +})()); +let invalidUtf8Rejected = false; +try { readIFC(ifcWithMetadataBytes(invalidUtf8)); } +catch (error) { invalidUtf8Rejected = error instanceof IfcParseError && error.reason === 'xer-source-archive'; } +truthy('7h correct gehashte diagnostics met ongeldige UTF-8 wordt getypeerd geweigerd', invalidUtf8Rejected); + +const toUtf16 = (text: string, endian: 'le' | 'be') => { + const bytes = new Uint8Array(2 + text.length * 2); + bytes.set(endian === 'le' ? [0xff, 0xfe] : [0xfe, 0xff]); + for (let index = 0; index < text.length; index += 1) { + const code = text.charCodeAt(index); + bytes[2 + index * 2 + (endian === 'le' ? 0 : 1)] = code & 0xff; + bytes[2 + index * 2 + (endian === 'le' ? 1 : 0)] = code >>> 8; + } + return bytes; +}; +const sourceText = new TextDecoder().decode(source); +for (const endian of ['le', 'be'] as const) { + const encoded = toUtf16(sourceText, endian); + const utfOpened = readXER(encoded); + if (!isMultiDocumentImport(utfOpened)) throw new Error(`UTF-16${endian.toUpperCase()}-fixture moet multi-project zijn`); + const firstIfcRead = readIFC(writeIFC(utfOpened.results[0]!)); + const secondIfcRead = readIFC(writeIFC(firstIfcRead)); + equal(`8 UTF-16${endian.toUpperCase()} XER→IFC→IFC bewaart presentatie en bytes`, { + encoding: secondIfcRead.xerSourceArchive?.encoding, + bom: secondIfcRead.xerSourceArchive?.bom, + newline: secondIfcRead.xerSourceArchive?.newline, + bytes: secondIfcRead.xerSourceArchive + ? sha256Hex(decodeXerSourceArchive(secondIfcRead.xerSourceArchive)) + : null, + }, { + encoding: endian === 'le' ? 'utf-16le' : 'utf-16be', + bom: endian === 'le' ? 'utf-16le' : 'utf-16be', + newline: 'crlf', + bytes: sha256Hex(encoded), + }); +} + +const store = () => useAppStore.getState(); +store().newProject(); +store().applyOpenedImport(opened, { + filePath: null, fileHandle: null, recompute: false, fit: false, + hourDataNotice: false, linkedOpen: false, +}); +store().duplicateDocument(); +for (let index = 0; index < 100; index += 1) store().setProject({ description: `readmodel-${index}` }); +for (let index = 0; index < 100; index += 1) store().undo(); +for (let index = 0; index < 100; index += 1) store().redo(); +const tabPayloads = store().getOpenDocumentPayloads(); +truthy('9 projecttabs, duplicate en 100 undo/redo delen één archive/readmodel/catalogusgrafiek', + tabPayloads.length === 3 + && new Set(tabPayloads.map(document => document.payload.xerSourceArchive)).size === 1 + && tabPayloads.every(document => + document.payload.xerImportMetadata?.resources?.catalog === archive.readModel?.resourceCatalog + && document.payload.xerImportMetadata?.metadata?.catalog === archive.readModel?.metadataCatalog)); +truthy('9a duplicate houdt exact dezelfde X5-filecache en bronrijrefs', + tabPayloads.every(document => document.payload.xerImportMetadata?.scheduleOptions.sourceArchive + === archive.readModel?.scheduleOptionsSourceArchive + && document.payload.xerImportMetadata.scheduleOptions.sourceRows.every((row, index) => + row === document.payload.xerImportMetadata?.scheduleOptions.sourceArchive.rows[ + document.payload.xerImportMetadata.scheduleOptions.sourceRowIndexes[index]! + ]))); +const recoveredInputs = tabPayloads.map((document, index) => { + const parsed = readIFC(writeIFC(buildWriteIFCInput(document.payload))); + return recoveryInputFromParsed(parsed, { id: `readmodel-recovery-${index}`, filePath: null, isDirty: true, datesAsRecorded: false }); +}); +store().restoreDocuments(recoveredInputs, recoveredInputs[0]!.id); +const recoveredPayloads = store().getOpenDocumentPayloads(); +const recoveredArchive = recoveredPayloads[0]?.payload.xerSourceArchive; +truthy('10 IFC-per-document→readIFC→recovery herstelt één bruikbare gedeelde catalogusgrafiek', + recoveredPayloads.length === 3 + && recoveredArchive !== null + && new Set(recoveredPayloads.map(document => document.payload.xerSourceArchive)).size === 1 + && recoveredPayloads.every(document => + document.payload.xerImportMetadata?.resources?.catalog === recoveredArchive?.readModel.resourceCatalog + && document.payload.xerImportMetadata?.metadata?.catalog === recoveredArchive?.readModel.metadataCatalog)); +truthy('10a recovery bindt alle projectprovenance aan de canonieke selectorview en X5-cache', + recoveredPayloads.every(document => { + const metadata = document.payload.xerImportMetadata; + const selector = metadata?.sourceProjectId; + const view = selector ? recoveredArchive?.diagnostics.documentViews[selector] : undefined; + return metadata !== null && metadata.resources !== undefined && view?.resources !== undefined + && metadata.resources.assignments === view.resources.assignments + && metadata.resources.issues === view.resources.issues + && metadata.scheduleOptions.sourceArchive === recoveredArchive?.readModel.scheduleOptionsSourceArchive + && metadata.scheduleOptions.sourceRows.every((row, index) => + row === recoveredArchive?.readModel.scheduleOptionsSourceArchive.rows[ + metadata.scheduleOptions.sourceRowIndexes[index]! + ]); + })); + +const missingSelectorArchive = recoveredArchive; +let missingSelectorRejected = false; +if (missingSelectorArchive && recoveredInputs[0]) { + try { + store().restoreDocuments([{ + ...recoveredInputs[0], + id: 'missing-selector-view', + xerSourceArchive: missingSelectorArchive, + xer: recoveredInputs[0].xer ? { ...recoveredInputs[0].xer, sourceProjectId: 'ONTBREEKT' } : undefined, + xerSourceProjectId: 'ONTBREEKT', + }], 'missing-selector-view'); + } catch (error) { + missingSelectorRejected = error instanceof XerSourceArchiveValidationError; + } +} +truthy('10b recovery weigert een ontbrekende selectorview hard en getypeerd', missingSelectorRejected); + +if (failures.length === 0) { + console.log(`OK xer-archive-readmodel: alle checks groen (${checks})`); + process.exit(0); +} +console.log(`XX xer-archive-readmodel: ${failures.length} afwijking(en) van ${checks}`); +for (const failure of failures) console.log(` - ${failure}`); +process.exit(1); diff --git a/tests/planning/check-xer-archive-recovery-corpus.ts b/tests/planning/check-xer-archive-recovery-corpus.ts new file mode 100644 index 000000000..ac1bbd114 --- /dev/null +++ b/tests/planning/check-xer-archive-recovery-corpus.ts @@ -0,0 +1,268 @@ +// X9-compactopslag — verse corpusprocessen meten de volledige IFC- en recoveryketen eerlijk. +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { isMultiDocumentImport } from '@/services/importTypes'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; +import { writeIFC } from '@/services/ifc/ifcWriter'; +import { clearRecovery, fullRecoverySave, loadRecovery, saveRecovery } from '@/services/recovery/recoveryStore'; +import { readXER } from '@/services/xer/xerReader'; +import { decodeXerSourceArchive, sha256Hex } from '@/services/xerSourceArchive'; +import { useAppStore } from '@/state/appStore'; +import { recoveryInputFromParsed } from '@/state/documentContract'; +import { buildWriteIFCInput } from '@/state/ifcSaveInput'; + +declare const process: { + argv: string[]; + cwd(): string; + env: Record; + execPath: string; + exit(code: number): never; + resourceUsage(): { maxRSS: number }; +}; + +type ProbeMode = 'ifc' | 'recovery'; +interface RecoveryProbe { + label: string; + mode: ProbeMode; + sourceBytes: number; + documents: number; + ifcChars: number; + elapsedMs: number; + peakRssKiB: number; + recoveredDocuments: number; + recoveredArchives: number; + exactBytes: boolean; + deltaDocumentWrites: number; + deltaManifestWrites: number; + deltaWriteIfcCalls: number; + deltaEditRestored: boolean; +} + +interface RecoveryStorageWrites { + documents: number; + manifests: number; + readwriteTransactions: number; +} + +// Harde guardrails zijn uitsluitend semantisch/structureel en daardoor machine-onafhankelijk: +// één documentedit mag exact één volledige IFC-upsert en één manifestcommit veroorzaken; OZB +// blijft twaalf herstelbare documenten. Walltime en OS-peak-RSS zijn wel echte procesmetingen en +// worden hieronder altijd gelogd, maar krijgen bewust GEEN bovengrens: CPU, beschikbare RAM, +// kernel/page-cache en CI-host verschillen te sterk om zo'n getal een betrouwbare veiligheidspoort +// te maken. Een niet-positieve/niet-eindige meting is wél rood, want dan meet de probe niet echt. +const MAX_DELTA_DOCUMENT_WRITES = 1; +const REQUIRED_DELTA_MANIFEST_WRITES = 1; +const OZB_RECOVERY_DOCUMENTS = 12; + +function installFakeIndexedDb(): RecoveryStorageWrites { + const records = new Map(); + const writes: RecoveryStorageWrites = { documents: 0, manifests: 0, readwriteTransactions: 0 }; + const fakeDb = { + objectStoreNames: { contains: () => true }, + createObjectStore: () => undefined, + close: () => undefined, + onversionchange: null as (() => void) | null, + transaction: (_store: string, mode: string) => { + if (mode === 'readwrite') writes.readwriteTransactions += 1; + const tx = { + oncomplete: null as (() => void) | null, + onerror: null as (() => void) | null, + error: null, + objectStore: () => ({ + getAll: () => { + const request = { result: [] as unknown[], error: null, onsuccess: null as (() => void) | null, onerror: null as (() => void) | null }; + queueMicrotask(() => { request.result = [...records.values()]; request.onsuccess?.(); }); + return request; + }, + put: (value: { id: string }) => { + if (value.id.includes('::doc::')) writes.documents += 1; + if (value.id.endsWith('::manifest')) writes.manifests += 1; + records.set(value.id, structuredClone(value)); + queueMicrotask(() => tx.oncomplete?.()); + }, + delete: (id: string) => { + records.delete(id); + queueMicrotask(() => tx.oncomplete?.()); + }, + }), + }; + return tx; + }, + }; + const fakeIndexedDb = { + open: () => { + const request = { + result: fakeDb, + error: null, + onupgradeneeded: null as (() => void) | null, + onsuccess: null as (() => void) | null, + onerror: null as (() => void) | null, + }; + queueMicrotask(() => { request.onupgradeneeded?.(); request.onsuccess?.(); }); + return request; + }, + }; + (globalThis as unknown as { window: object }).window = {}; + (globalThis as unknown as { indexedDB: unknown }).indexedDB = fakeIndexedDb; + return writes; +} + +async function runProbe(label: string, mode: ProbeMode, filePath: string): Promise { + const storageWrites = installFakeIndexedDb(); + const started = performance.now(); + const bytes = new Uint8Array(readFileSync(filePath)); + const opened = readXER(bytes); + const imported = isMultiDocumentImport(opened) ? opened.results : [opened]; + const archive = imported[0]?.xerSourceArchive; + if (!archive) throw new Error(`${label}: XER mist bronarchief`); + + const state = useAppStore.getState(); + state.newProject(); + state.applyOpenedImport(opened, { + filePath: null, fileHandle: null, recompute: false, fit: false, + hourDataNotice: false, linkedOpen: false, + }); + const docs = useAppStore.getState().getOpenDocumentPayloads(); + const ifcs = docs.map(document => writeIFC(buildWriteIFCInput(document.payload))); + const independent = ifcs.map(ifc => readIFC(ifc)); + const exactBytes = independent.every(result => result.xerSourceArchive !== undefined + && sha256Hex(decodeXerSourceArchive(result.xerSourceArchive)) === archive.sha256); + + let recoveredDocuments = 0; + let recoveredArchives = 0; + let deltaDocumentWrites = 0; + let deltaManifestWrites = 0; + let deltaWriteIfcCalls = 0; + let deltaEditRestored = false; + if (mode === 'recovery') { + await clearRecovery(); + await saveRecovery(fullRecoverySave(docs[0]!.id, docs.map((document, index) => ({ + id: document.id, + ifc: ifcs[index]!, + filePath: null, + isDirty: true, + datesAsRecorded: false, + })))); + storageWrites.documents = 0; + storageWrites.manifests = 0; + storageWrites.readwriteTransactions = 0; + + // Een echte documentedit produceert één nieuwe IFC-tekst. De storagegrens krijgt daarnaast + // alle twaalf manifestregels, maar precies één zware upsert — de cruciale OZB-regressie. + const editedPayload = { + ...docs[0]!.payload, + project: { ...docs[0]!.payload.project, description: '__x9-recovery-delta__' }, + }; + const editedIfc = writeIFC(buildWriteIFCInput(editedPayload)); + deltaWriteIfcCalls = 1; + await saveRecovery({ + activeDocumentId: docs[0]!.id, + documents: docs.map((document) => ({ id: document.id, filePath: null, isDirty: true, datesAsRecorded: false })), + upserts: [{ id: docs[0]!.id, ifc: editedIfc, filePath: null, isDirty: true, datesAsRecorded: false }], + }); + deltaDocumentWrites = storageWrites.documents; + deltaManifestWrites = storageWrites.manifests; + if (storageWrites.readwriteTransactions !== 1) { + throw new Error(`${label}: recoverydelta gebruikte ${storageWrites.readwriteTransactions} readwrite-transacties`); + } + const loaded = await loadRecovery(); + deltaEditRestored = readIFC(loaded.docs.find((document) => document.id === docs[0]!.id)!.ifc) + .project.description === '__x9-recovery-delta__'; + const inputs = loaded.docs.map(document => recoveryInputFromParsed( + readIFC(document.ifc), + { id: document.id, filePath: document.filePath, isDirty: document.isDirty, datesAsRecorded: document.datesAsRecorded }, + )); + useAppStore.getState().restoreDocuments(inputs, loaded.activeDocumentId); + const recovered = useAppStore.getState().getOpenDocumentPayloads(); + recoveredDocuments = recovered.length; + recoveredArchives = new Set(recovered.map(document => document.payload.xerSourceArchive)).size; + await clearRecovery(); + } + + return { + label, + mode, + sourceBytes: bytes.length, + documents: docs.length, + ifcChars: ifcs.reduce((total, ifc) => total + ifc.length, 0), + elapsedMs: performance.now() - started, + // De probe is een vers Node-proces: dit is de OS-piek van de volledige keten, geen heapdelta. + peakRssKiB: process.resourceUsage().maxRSS, + recoveredDocuments, + recoveredArchives, + exactBytes, + deltaDocumentWrites, + deltaManifestWrites, + deltaWriteIfcCalls, + deltaEditRestored, + }; +} + +function runChild(label: string, mode: ProbeMode, filePath: string): RecoveryProbe { + const entry = process.argv[1]; + if (!entry) throw new Error('X9-recoveryprobe mist eigen bundelpad'); + const output = execFileSync(process.execPath, [entry, '--x9-recovery-probe', label, mode, filePath], { + cwd: process.cwd(), encoding: 'utf8', env: process.env, + }); + const line = output.trim().split('\n').find(value => value.startsWith('X9_RECOVERY_PROBE ')); + if (!line) throw new Error(`${label}: recoveryprobe gaf geen meetregel terug`); + return JSON.parse(line.slice('X9_RECOVERY_PROBE '.length)) as RecoveryProbe; +} + +const probeArgs = process.argv.slice(2); +if (probeArgs[0] === '--x9-recovery-probe') { + const [_, label, mode, filePath] = probeArgs; + if (!label || (mode !== 'ifc' && mode !== 'recovery') || !filePath) { + throw new Error('X9-recoveryprobe kreeg ongeldige argumenten'); + } + console.log(`X9_RECOVERY_PROBE ${JSON.stringify(await runProbe(label, mode, filePath))}`); + process.exit(0); +} + +const root = process.env.OPS_XER_CORPUS; +const rehab = root ? join(root, 'crawl-xer-extra/jailaff-xer-splitter/rehab-2.xer') : ''; +const ozb = root ? join(root, 'crawl-xer/eh_P6Workshops/OZB-Start-09Dec24.xer') : ''; +if (!root || !existsSync(rehab) || !existsSync(ozb)) { + console.log('OK X9-recovery-corpus: rehab-2/OZB niet aanwezig — corpusprobe overgeslagen'); +} else { + const probes = [ + runChild('rehab-2 zelfstandige compacte IFC-ronde', 'ifc', rehab), + runChild('rehab-2 recovery', 'recovery', rehab), + runChild('OZB recovery', 'recovery', ozb), + ]; + const failures: string[] = []; + let checks = 0; + const expect = (label: string, condition: boolean): void => { + checks += 1; + if (!condition) failures.push(label); + }; + for (const probe of probes) { + expect(`${probe.label}: bron, tijd en OS-peak RSS zijn werkelijk gemeten`, + probe.sourceBytes > 0 && probe.documents > 0 && Number.isFinite(probe.elapsedMs) && probe.elapsedMs >= 0 + && Number.isFinite(probe.peakRssKiB) && probe.peakRssKiB > 0); + expect(`${probe.label}: iedere compacte IFC herleest de exacte bronbytes`, probe.exactBytes); + if (probe.mode === 'recovery') { + expect(`${probe.label}: recovery herstelt elk document en canoniseert het gedeelde archief`, + probe.recoveredDocuments === probe.documents && probe.recoveredArchives === 1); + expect(`${probe.label}: één documentedit schrijft één IFC en één snapshotrecord, niet alle documenten`, + probe.deltaWriteIfcCalls === 1 + && probe.deltaDocumentWrites === MAX_DELTA_DOCUMENT_WRITES + && probe.deltaManifestWrites === REQUIRED_DELTA_MANIFEST_WRITES + && probe.deltaEditRestored); + } + } + const ozbProbe = probes[2]!; + expect('OZB recovery: de publieke 15-projectenfixture herstelt precies twaalf niet-lege documenten', + ozbProbe.documents === OZB_RECOVERY_DOCUMENTS + && ozbProbe.recoveredDocuments === OZB_RECOVERY_DOCUMENTS); + console.log(`X9 recovery corpus: ${probes.map(probe => + `${probe.label}: docs=${probe.documents} source=${probe.sourceBytes} ifc=${probe.ifcChars} time=${probe.elapsedMs.toFixed(1)}ms peak-rss=${probe.peakRssKiB}KiB delta-ifc=${probe.deltaDocumentWrites} delta-manifest=${probe.deltaManifestWrites}` + ).join(' | ')}`); + if (failures.length > 0) { + console.log(`XX xer-archive-recovery-corpus: ${failures.length} afwijking(en)`); + for (const failure of failures) console.log(` - ${failure}`); + process.exit(1); + } + console.log(`OK xer-archive-recovery-corpus: alle ${checks} corpuschecks groen`); +} diff --git a/tests/planning/check-xer-archive-scale.ts b/tests/planning/check-xer-archive-scale.ts new file mode 100644 index 000000000..d55c6e4da --- /dev/null +++ b/tests/planning/check-xer-archive-scale.ts @@ -0,0 +1,88 @@ +// X9 reviewronde 1 — relatieve schaalproef op 1/8/32 decoded chunks, zonder tijdsdrempel. +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; +import { writeIFC } from '@/services/ifc/ifcWriter'; +import { + decodeXerSourceArchive, + sha256Hex, + XER_SOURCE_ARCHIVE_CHUNK_BYTES, +} from '@/services/xerSourceArchive'; +import { isMultiDocumentImport } from '@/services/importTypes'; +import { readXER } from '@/services/xer/xerReader'; + +declare const process: { exit(code: number): never; resourceUsage(): { maxRSS: number } }; +const failures: string[] = []; +let checks = 0; +const expect = (label: string, condition: boolean) => { + checks += 1; + if (!condition) failures.push(label); +}; +const measurements = [1, 8, 32].map(chunkCount => { + const bytes = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-08-01\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date', + '%R\tP-SCALE\tSchaal\tC\t2026-08-01 08:00', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC\tStandaard\t8\t40\t', + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_code\ttask_name\tclndr_id\ttarget_start_date\ttarget_end_date\ttarget_drtn_hr_cnt\ttask_type\tduration_type\tstatus_code', + '%R\tT-SCALE\tP-SCALE\tS-1\tSchaaltaak\tC\t2026-08-01 08:00\t2026-08-01 16:00\t8\tTT_Task\tDT_FixedDUR2\tTK_NotStart', + '%T\tUNKNOWN', + '%F\tpayload', + `%R\t${'x'.repeat(chunkCount * XER_SOURCE_ARCHIVE_CHUNK_BYTES)}`, + '%E', + ].join('\r\n')); + const opened = readXER(bytes); + if (isMultiDocumentImport(opened) || !opened.xerSourceArchive) { + throw new Error('Schaalfixture moet één geldig XER-bronarchief opleveren'); + } + const started = performance.now(); + const archive = opened.xerSourceArchive; + const ifc = writeIFC(opened); + const read = readIFC(ifc); + const elapsedMs = performance.now() - started; + // Deze afzonderlijke check draait in een vers Node-proces. maxRSS is dus de OS-gemeten piek + // van de volledige round-trip, niet een eindstand of een misleidende heapdelta. + const peakRssKiB = process.resourceUsage().maxRSS; + expect(`${chunkCount} chunks: count en decoded byteLength zijn exact`, + archive.byteChunks.length >= chunkCount + && read.xerSourceArchive?.byteChunks.length === archive.byteChunks.length + && read.xerSourceArchive?.byteLength === bytes.length); + expect(`${chunkCount} chunks: SHA en gedecodeerde bytes blijven exact`, + read.xerSourceArchive !== undefined + && sha256Hex(decodeXerSourceArchive(read.xerSourceArchive)) === sha256Hex(bytes)); + // Tijd en RSS blijven informatief (hostafhankelijk, geen drempel); de machine-onafhankelijke + // serialisatieverhouding hieronder is de echte poort (eindreview bevinding 10). + expect(`${chunkCount} chunks: echte tijd en OS-gemeten peak RSS zijn eindige observaties`, + Number.isFinite(peakRssKiB) && peakRssKiB > 0 && Number.isFinite(elapsedMs) && elapsedMs >= 0); + return { chunkCount, sourceBytes: bytes.length, ifcChars: ifc.length, elapsedMs, peakRssKiB }; +}); + +expect('relatieve IFC-omvang groeit mee van 1 naar 8 naar 32 chunks', + measurements[1]!.ifcChars > measurements[0]!.ifcChars * 6 + && measurements[2]!.ifcChars > measurements[1]!.ifcChars * 3); +// Eindreview bevinding 10: "eindig en positief" bewaakte niets. Walltime en RSS zijn host- +// afhankelijk, maar de SERIALISATIEVERHOUDING niet: het archief zelf kost per bronbyte een vaste +// coderingsoverhead (gemeten 2026-09-07: 1,36 / 1,34 / 1,33 IFC-tekens per bronbyte bij 1/8/32 +// chunks; de marginale kost van 8→32 chunks 1,33). Een wijziging die de serialisatie verdubbelt +// (dubbel coderen, een tweede kopie, een minder compacte codering) gaat hier rood, ongeacht de +// machine. De bovengrens 1,6 laat ~20% ruimte boven de gemeten waarde en ligt ver onder ×2. +const MAX_IFC_CHARS_PER_SOURCE_BYTE = 1.6; +const marginal = (measurements[2]!.ifcChars - measurements[1]!.ifcChars) + / (measurements[2]!.sourceBytes - measurements[1]!.sourceBytes); +expect(`32 chunks: IFC-tekens per bronbyte ≤ ${MAX_IFC_CHARS_PER_SOURCE_BYTE} (gemeten ${(measurements[2]!.ifcChars / measurements[2]!.sourceBytes).toFixed(3)})`, + measurements[2]!.ifcChars / measurements[2]!.sourceBytes <= MAX_IFC_CHARS_PER_SOURCE_BYTE); +expect(`marginale serialisatiekost 8→32 chunks ≤ ${MAX_IFC_CHARS_PER_SOURCE_BYTE} tekens per bronbyte (gemeten ${marginal.toFixed(3)})`, + marginal <= MAX_IFC_CHARS_PER_SOURCE_BYTE); +console.log(`X9 archive scale: ${measurements.map(item => + `${item.chunkCount}c source=${item.sourceBytes} ifc=${item.ifcChars} time=${item.elapsedMs.toFixed(1)}ms peak-rss=${item.peakRssKiB}KiB` +).join(' | ')}`); + +if (failures.length === 0) { + console.log(`OK xer-archive-scale: alle checks groen (${checks})`); + process.exit(0); +} +console.log(`XX xer-archive-scale: ${failures.length} afwijking(en) van ${checks}`); +for (const failure of failures) console.log(` - ${failure}`); +process.exit(1); diff --git a/tests/planning/check-xer-backward-float-trace.ts b/tests/planning/check-xer-backward-float-trace.ts new file mode 100644 index 000000000..6848dbc7d --- /dev/null +++ b/tests/planning/check-xer-backward-float-trace.ts @@ -0,0 +1,480 @@ +import { isMultiDocumentImport, type ImportResult } from '@/services/importTypes'; +import { readXER } from '@/services/xer/xerReader'; +import { solveProject } from '@/engine/scheduler/solveProject'; +import { replayXerProductBeforeOracle, syntheticZeroRegressionCandidate } from './xerTaskReplayProduct'; +import type { CpmBackwardFloatTrace, CpmProjectEndSource, CpmTaskBackwardFloatTrace } from '@/engine/scheduler/CPMSolver'; + +const diffs: string[] = []; +let checks = 0; + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +const earlyShiftCalendar = '(0||CalendarData()( (0||DaysOfWeek()( (0||1()( (0||0(s|07:00|f|15:00)()))) (0||2()( (0||0(s|07:00|f|15:00)()))) (0||3()( (0||0(s|07:00|f|15:00)()))) (0||4()( (0||0(s|07:00|f|15:00)()))) (0||5()( (0||0(s|07:00|f|15:00)()))) (0||6()()) (0||7()()))) (0||Exceptions()())))'; + +interface Variant { + id: string; + remainingStart: boolean; + useProjectEndDateForFloat: boolean; + finishMilestoneBoundary: boolean; + fixtureShape?: 'network' | 'completedOnly'; + laterOpenTaskWinsProjectEnd?: boolean; + expected: { + projectEndSource?: string; + completedLateStartSource: string; + milestoneFreeFloatSource?: string; + taskIds: string[]; + }; +} + +function fixtureBytes(variant: Variant): Uint8Array { + const taskRows = variant.fixtureShape === 'completedOnly' + ? [ + '%R\tC\tP1\tC1\tCOMP\tVoltooide winnaar\tTT_Task\tDT_FixedDUR2\tTK_Complete\tCP_Drtn\t8\t0\t2026-08-04 07:00\t2026-08-04 15:00\t2026-08-04 07:00\t2026-08-04 15:00', + ] + : [ + '%R\tP\tP1\tC1\tPRED\tEchte voorganger\tTT_Task\tDT_FixedDUR\tTK_NotStart\tCP_Drtn\t8\t8\t2026-08-03 07:00\t2026-08-03 15:00\t\t', + '%R\tC\tP1\tC1\tDUP\tVoltooide leaf\tTT_Task\tDT_FixedDUR2\tTK_Complete\tCP_Drtn\t8\t0\t2026-08-04 07:00\t2026-08-04 15:00\t2026-08-04 07:00\t2026-08-04 15:00', + '%R\tO\tP1\tC1\tOPEN\tOpen opvolger\tTT_Task\tDT_FixedDUR\tTK_NotStart\tCP_Drtn\t8\t8\t2026-08-05 07:00\t2026-08-05 15:00\t\t', + '%R\tM\tP1\tC1\tDUP\tFinish milestone\tTT_FinMile\tDT_FixedDUR\tTK_NotStart\tCP_Drtn\t0\t0\t2026-08-06 07:01\t2026-08-06 07:01\t\t', + ]; + const laterOpenWinnerRows = variant.laterOpenTaskWinsProjectEnd + ? [ + '%R\tL\tP1\tC1\tLATE\tLatere open winnaar\tTT_Task\tDT_FixedDUR\tTK_NotStart\tCP_Drtn\t8\t8\t2026-08-20 07:00\t2026-08-20 15:00\t\t', + ] + : []; + const taskPredRows = variant.fixtureShape === 'completedOnly' + ? [] + : [ + '%R\tR-PC\tC\tP\tP1\tP1\tPR_FS\t0', + '%R\tR-PO\tO\tP\tP1\tP1\tPR_FS\t0', + '%R\tR-OM\tM\tO\tP1\tP1\tPR_FS\t0', + ]; + return new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-08-17\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tproj_id\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + `%R\tC1\tVroege ploeg\tP1\tCA_Project\t8\t40\t${earlyShiftCalendar}`, + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date\tplan_end_date\trem_target_link_flag', + `%R\tP1\tBackward float trace\tC1\t2026-08-17 07:00\t2026-08-03 07:00\t2026-08-20 15:00\t${variant.remainingStart ? 'Y' : 'N'}`, + '%T\tSCHEDOPTIONS', + '%F\tproj_id\tsched_use_project_end_date_for_float', + `%R\tP1\t${variant.useProjectEndDateForFloat ? 'Y' : 'N'}`, + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\tcomplete_pct_type\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date\tact_start_date\tact_end_date', + ...taskRows, + ...laterOpenWinnerRows, + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + ...taskPredRows, + '%E', + ].join('\n')); +} + +function importFixture(bytes: Uint8Array): ImportResult { + const opened = readXER(bytes); + if (isMultiDocumentImport(opened)) throw new Error('tracefixture moet precies één project openen'); + return opened; +} + +interface TraceProjection { + projectEndSource: CpmProjectEndSource | undefined; + taskIds: string[]; + completed: CpmTaskBackwardFloatTrace | undefined; + milestone: CpmTaskBackwardFloatTrace | undefined; +} + +interface GuardDecisionProjection { + eligible: boolean; + reason: string; +} + +interface CompletedGuardTraceProjection { + displayActualLate: boolean | undefined; + completedWindow: GuardDecisionProjection | undefined; + backwardActualPin: GuardDecisionProjection | undefined; + displayActualLateDecision: GuardDecisionProjection | undefined; +} + +function traceProjection(trace: CpmBackwardFloatTrace | undefined): TraceProjection { + return { + projectEndSource: trace?.projectEndSource, + taskIds: Object.keys(trace?.byTaskId ?? {}).sort(), + completed: trace?.byTaskId.C, + milestone: trace?.byTaskId.M, + }; +} + +function solveTraceVariant(variant: Variant): TraceProjection { + const imported = structuredClone(importFixture(fixtureBytes(variant))); + imported.project.schedulingOptions = { + ...imported.project.schedulingOptions, + p6FinishMilestoneBoundaryWindow: variant.finishMilestoneBoundary, + }; + const result = solveProject({ + tasks: imported.tasks, + sequences: imported.sequences, + calendar: imported.calendar, + calendars: imported.resourceCalendars ?? [], + dataDate: imported.project.statusDate, + progressMode: imported.project.progressMode, + schedulingOptions: imported.project.schedulingOptions, + projectStartDate: imported.project.startDate, + projectEndDate: imported.project.endDate, + }); + if (result.error) throw new Error(result.error); + return traceProjection(result.backwardFloatTrace); +} + +function completedGuardFixtureBytes(): Uint8Array { + return new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-08-17\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tproj_id\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + `%R\tC1\tVroege ploeg\tP1\tCA_Project\t8\t40\t${earlyShiftCalendar}`, + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date\tplan_end_date\trem_target_link_flag', + '%R\tP1\tCompleted guard trace\tC1\t2026-08-17 07:00\t2026-08-03 07:00\t2026-08-20 15:00\tY', + '%T\tSCHEDOPTIONS', + '%F\tproj_id\tsched_use_project_end_date_for_float', + '%R\tP1\tN', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\tcomplete_pct_type\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date\tact_start_date\tact_end_date\tsuspend_date\tresume_date', + '%R\tC\tP1\tC1\tCOMP\tCompleted diag\tTT_Task\tDT_FixedDUR2\tTK_Complete\tCP_Drtn\t8\t0\t2026-08-04 07:00\t2026-08-04 15:00\t2026-08-04 07:00\t2026-08-04 15:00\t\t', + '%E', + ].join('\n')); +} + +function solveCompletedGuardFixture( + mutate?: (imported: ImportResult) => void, +) { + const imported = structuredClone(importFixture(completedGuardFixtureBytes())); + mutate?.(imported); + const result = solveProject({ + tasks: imported.tasks, + sequences: imported.sequences, + calendar: imported.calendar, + calendars: imported.resourceCalendars ?? [], + dataDate: imported.project.statusDate, + progressMode: imported.project.progressMode, + schedulingOptions: imported.project.schedulingOptions, + projectStartDate: imported.project.startDate, + projectEndDate: imported.project.endDate, + }); + if (result.error) throw new Error(result.error); + return result; +} + +function solveCompletedGuardTrace( + mutate?: (imported: ImportResult) => void, +): CompletedGuardTraceProjection { + const result = solveCompletedGuardFixture(mutate); + const trace = result.backwardFloatTrace?.byTaskId.C as + | (CpmTaskBackwardFloatTrace & { + completedWindow?: GuardDecisionProjection; + backwardActualPin?: GuardDecisionProjection; + displayActualLateDecision?: GuardDecisionProjection; + }) + | undefined; + return { + displayActualLate: trace?.displayActualLate, + completedWindow: trace?.completedWindow, + backwardActualPin: trace?.backwardActualPin, + displayActualLateDecision: trace?.displayActualLateDecision, + }; +} + +const variants: Variant[] = [ + { + id: 'completed-window-wint', + remainingStart: true, + useProjectEndDateForFloat: false, + finishMilestoneBoundary: true, + fixtureShape: 'completedOnly', + expected: { + projectEndSource: 'completedDisplayWindow', + completedLateStartSource: 'completedRemainingWindow', + taskIds: ['C'], + }, + }, + { + id: 'completed-window-verliest-van-latere-open-taak', + remainingStart: true, + useProjectEndDateForFloat: false, + finishMilestoneBoundary: true, + fixtureShape: 'network', + laterOpenTaskWinsProjectEnd: true, + expected: { + projectEndSource: 'maxEarlyFinish', + completedLateStartSource: 'completedRemainingWindow', + milestoneFreeFloatSource: 'projectEndFinishMilestoneBoundary', + taskIds: ['C', 'L', 'M', 'O', 'P'], + }, + }, + { + id: 'remaining-start-uit', remainingStart: false, useProjectEndDateForFloat: false, + finishMilestoneBoundary: true, + fixtureShape: 'network', + expected: { + projectEndSource: 'maxEarlyFinish', + completedLateStartSource: 'subDuration', + milestoneFreeFloatSource: 'projectEndFinishMilestoneBoundary', + taskIds: ['C', 'M', 'O', 'P'], + }, + }, + { + id: 'expliciet-projecteinde', remainingStart: true, useProjectEndDateForFloat: true, + finishMilestoneBoundary: true, + fixtureShape: 'network', + expected: { + projectEndSource: 'useProjectEndDateForFloat', + completedLateStartSource: 'completedRemainingWindow', + milestoneFreeFloatSource: 'clampedZero', + taskIds: ['C', 'M', 'O', 'P'], + }, + }, + { + id: 'finish-boundary-uit', remainingStart: true, useProjectEndDateForFloat: false, + finishMilestoneBoundary: false, + fixtureShape: 'network', + expected: { + completedLateStartSource: 'completedRemainingWindow', + milestoneFreeFloatSource: 'derivedFromSuccessor', + taskIds: ['C', 'M', 'O', 'P'], + }, + }, +]; + +const completedGuardVariants: Array<{ + id: string; + mutate?: (imported: ImportResult) => void; + expected: CompletedGuardTraceProjection; +}> = [ + { + id: 'active', + expected: { + displayActualLate: true, + completedWindow: { eligible: true, reason: 'eligible' }, + backwardActualPin: { eligible: true, reason: 'eligible' }, + displayActualLateDecision: { eligible: true, reason: 'eligible' }, + }, + }, + { + id: 'ontbrekende-explicit-target-provenance', + mutate: imported => { + const task = imported.tasks.find(candidate => candidate.id === 'C'); + if (!task) throw new Error('fixture mist taak C'); + task.p6ExplicitTargetWindow = false; + }, + expected: { + displayActualLate: true, + completedWindow: { eligible: false, reason: 'missingExplicitTargetWindow' }, + backwardActualPin: { eligible: true, reason: 'eligible' }, + displayActualLateDecision: { eligible: true, reason: 'eligible' }, + }, + }, + { + id: 'suspend-resume', + mutate: imported => { + const task = imported.tasks.find(candidate => candidate.id === 'C'); + if (!task) throw new Error('fixture mist taak C'); + task.p6SuspendResume = true; + }, + expected: { + displayActualLate: true, + completedWindow: { eligible: false, reason: 'hasSuspendResume' }, + backwardActualPin: { eligible: true, reason: 'eligible' }, + displayActualLateDecision: { eligible: true, reason: 'eligible' }, + }, + }, + { + id: 'ontbrekende-actual-finish', + mutate: imported => { + const task = imported.tasks.find(candidate => candidate.id === 'C'); + if (!task) throw new Error('fixture mist taak C'); + task.time.actualFinish = ''; + }, + expected: { + displayActualLate: true, + completedWindow: { eligible: true, reason: 'eligible' }, + backwardActualPin: { eligible: false, reason: 'missingActualFinish' }, + displayActualLateDecision: { eligible: true, reason: 'eligible' }, + }, + }, + { + id: 'niet-voltooid', + mutate: imported => { + const task = imported.tasks.find(candidate => candidate.id === 'C'); + if (!task) throw new Error('fixture mist taak C'); + task.time.completion = 0.5; + }, + expected: { + displayActualLate: false, + completedWindow: { eligible: false, reason: 'notCompleted' }, + backwardActualPin: { eligible: false, reason: 'notCompleted' }, + displayActualLateDecision: { eligible: false, reason: 'notCompleted' }, + }, + }, + { + id: 'preserve-uit', + mutate: imported => { + imported.project.schedulingOptions = { + ...imported.project.schedulingOptions, + preserveActualDatesInBackwardPass: false, + }; + }, + expected: { + displayActualLate: false, + completedWindow: { eligible: true, reason: 'eligible' }, + backwardActualPin: { eligible: false, reason: 'preserveActualDatesOff' }, + displayActualLateDecision: { eligible: false, reason: 'preserveActualDatesOff' }, + }, + }, + { + id: 'data-date-afwezig', + mutate: imported => { + imported.project.statusDate = undefined; + }, + expected: { + displayActualLate: false, + completedWindow: { eligible: false, reason: 'missingDataDate' }, + backwardActualPin: { eligible: false, reason: 'missingDataDate' }, + displayActualLateDecision: { eligible: false, reason: 'missingDataDate' }, + }, + }, +]; + +{ + const imported = importFixture(fixtureBytes(variants[0]!)); + const completed = imported.tasks.find(task => task.id === 'C'); + eq('backward-float-trace fixture activeert de completed-window-guard zonder oracledata', { + p6Source: imported.project.schedulingOptions?.p6Source, + remainingStart: imported.project.schedulingOptions?.p6UseRemainingStartForProgress, + completion: completed?.time.completion, + p6ProjectId: completed?.p6ProjectId, + p6TaskId: completed?.p6TaskId, + explicitTargetWindow: completed?.p6ExplicitTargetWindow, + completePctType: completed?.p6CompletePctType, + durationType: completed?.p6DurationType, + }, { + p6Source: 'XER', + remainingStart: true, + completion: 1, + p6ProjectId: 'P1', + p6TaskId: 'C', + explicitTargetWindow: true, + completePctType: 'CP_Drtn', + durationType: 'DT_FixedDUR2', + }); +} + +for (const variant of variants) { + const trace = solveTraceVariant(variant); + eq(`backward-float-trace ${variant.id}: beslisbronnen`, { + ...(variant.expected.projectEndSource !== undefined + ? { projectEndSource: trace.projectEndSource } + : {}), + completedLateStartSource: (trace.completed as { lateStartSource?: string } | undefined)?.lateStartSource, + completedDisplayActualLate: (trace.completed as { displayActualLate?: boolean } | undefined)?.displayActualLate, + ...(variant.expected.milestoneFreeFloatSource !== undefined + ? { milestoneFreeFloatSource: (trace.milestone as { freeFloatSource?: string } | undefined)?.freeFloatSource } + : {}), + }, { + ...(variant.expected.projectEndSource !== undefined + ? { projectEndSource: variant.expected.projectEndSource } + : {}), + completedLateStartSource: variant.expected.completedLateStartSource, + completedDisplayActualLate: true, + ...(variant.expected.milestoneFreeFloatSource !== undefined + ? { milestoneFreeFloatSource: variant.expected.milestoneFreeFloatSource } + : {}), + }); + eq(`backward-float-trace ${variant.id}: task.id is de enige trace-identiteit`, { + taskIds: trace.taskIds, + hasTaskCodeKey: trace.taskIds.includes('DUP'), + }, { + taskIds: variant.expected.taskIds, + hasTaskCodeKey: false, + }); +} + +for (const variant of completedGuardVariants) { + if (variant.id === 'data-date-afwezig') continue; + eq(`completed-guard-trace ${variant.id}: drie routes verklaren actief/inactief`, solveCompletedGuardTrace(variant.mutate), variant.expected); +} + +{ + const variant = completedGuardVariants.find(candidate => candidate.id === 'data-date-afwezig'); + if (!variant) throw new Error('statusdatumloze completed-guard-variant ontbreekt'); + let got: unknown; + try { + const result = solveCompletedGuardFixture(variant.mutate); + got = { + ...solveCompletedGuardTrace(variant.mutate), + projectEndSourceIsCompletedDisplayWindow: + result.backwardFloatTrace?.projectEndSource === 'completedDisplayWindow', + }; + } catch (error) { + got = { runtimeError: error instanceof Error ? error.name : 'onbekend' }; + } + eq('completed-guard-trace data-date-afwezig: decision faalt gesloten en runtime gebruikt geen completedDisplayWindow', got, { + ...variant.expected, + projectEndSourceIsCompletedDisplayWindow: false, + }); +} + +{ + const baseline = variants[0]!; + const replay = replayXerProductBeforeOracle(fixtureBytes(baseline), syntheticZeroRegressionCandidate, { + includeBackwardFloatTrace: true, + }); + const source = replay.predicate.find(log => log.sourceTaskId === 'C')?.source; + eq('backward-float-trace replay projecteert de solve-trace vóór oracle/classificatie', { + projectEndSource: source?.backwardFloatTraceProjectEndSource, + lateStartSource: source?.backwardFloatTraceLateStartSource, + displayActualLate: source?.backwardFloatTraceDisplayActualLate, + completedWindowEligible: source?.backwardFloatTraceCompletedWindowEligible, + completedWindowReason: source?.backwardFloatTraceCompletedWindowReason, + backwardActualPinEligible: source?.backwardFloatTraceBackwardActualPinEligible, + backwardActualPinReason: source?.backwardFloatTraceBackwardActualPinReason, + displayActualLateEligible: source?.backwardFloatTraceDisplayActualLateEligible, + displayActualLateReason: source?.backwardFloatTraceDisplayActualLateReason, + }, { + projectEndSource: 'completedDisplayWindow', + lateStartSource: 'completedRemainingWindow', + displayActualLate: true, + completedWindowEligible: true, + completedWindowReason: 'eligible', + backwardActualPinEligible: true, + backwardActualPinReason: 'eligible', + displayActualLateEligible: true, + displayActualLateReason: 'eligible', + }); +} + +{ + const imported = structuredClone(importFixture(fixtureBytes(variants[0]!))); + imported.project.schedulingOptions = { + ...imported.project.schedulingOptions, + p6Source: undefined, + }; + const result = solveProject({ + tasks: imported.tasks, sequences: imported.sequences, calendar: imported.calendar, + calendars: imported.resourceCalendars ?? [], dataDate: imported.project.statusDate, + progressMode: imported.project.progressMode, schedulingOptions: imported.project.schedulingOptions, + projectStartDate: imported.project.startDate, projectEndDate: imported.project.endDate, + }); + eq('backward-float-trace faalt gesloten zonder XER-bronsignaal', result.backwardFloatTrace, undefined); +} + +if (diffs.length > 0) { + console.error(`XER BACKWARD FLOAT TRACE RED: ${diffs.length}/${checks} checks rood`); + for (const diff of diffs) console.error(`XX ${diff}`); + process.exit(1); +} +console.log(`XER BACKWARD FLOAT TRACE GREEN: ${checks} checks groen`); diff --git a/tests/planning/check-xer-byte-paths.ts b/tests/planning/check-xer-byte-paths.ts new file mode 100644 index 000000000..90e0cdee4 --- /dev/null +++ b/tests/planning/check-xer-byte-paths.ts @@ -0,0 +1,350 @@ +import { openDialogFilters, binaryExtensions, parseOpenedFile } from '@/services/formatRegistry'; +import { readOpenedTauriPath } from '@/services/fileAccess/tauriBackend'; +import { openFileDialogWeb, readBytesFromRefWeb } from '@/services/fileAccess/webBackend'; +import { activeImportResult } from '@/services/importTypes'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const diffs: string[] = []; +let checks = 0; + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +function fixture(projectName: string): string { + return [ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_data', + '%R\tC1\tStandaard\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date', + `%R\tP1\t${projectName}\tC1\t2026-01-01 08:00`, + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_code\ttask_name\ttask_type\ttarget_start_date\ttarget_end_date', + '%R\tT1\tP1\tA1\tTaak\tTT_Onbekend\t2026-01-01 08:00\t2026-01-01 16:00', + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%R\tR-EXT\tT1\tEXT-1\tP1\tP-EXT\tPR_FS\t2', + '%E', + ].join('\n'); +} + +function multiFixture(): Uint8Array { + return new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date', + '%R\tP-A\tKlein\tC1\t2026-01-01 08:00', + '%R\tP-B\tGroot\tC1\t2026-01-01 08:00', + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_code\ttask_name\ttarget_start_date\ttarget_end_date', + '%R\tA1\tP-A\tA1\tEen\t2026-01-01\t2026-01-02', + '%R\tB1\tP-B\tB1\tEen\t2026-01-01\t2026-01-02', + '%R\tB2\tP-B\tB2\tTwee\t2026-01-02\t2026-01-03', + '%E', + ].join('\n')); +} + +function utf16(text: string, littleEndian: boolean): Uint8Array { + const out = new Uint8Array(2 + text.length * 2); + out.set(littleEndian ? [0xff, 0xfe] : [0xfe, 0xff]); + for (let index = 0; index < text.length; index++) { + const value = text.charCodeAt(index); + out[2 + index * 2] = littleEndian ? value & 0xff : value >>> 8; + out[3 + index * 2] = littleEndian ? value >>> 8 : value & 0xff; + } + return out; +} + +function cp1252(text: string): Uint8Array { + return Uint8Array.from([...text].map(character => { + if (character === '€') return 0x80; + if (character === 'é') return 0xe9; + return character.charCodeAt(0); + })); +} + +const encodings = [ + ['CP1252', cp1252(fixture('Café €'))], + ['UTF-16LE BOM', utf16(fixture('Café €'), true)], + ['UTF-16BE BOM', utf16(fixture('Café €'), false)], +] as const; + +const g = globalThis as unknown as { + window: Record; + document: Record; +}; +g.window = {}; +let nextBytes = new Uint8Array(); +let webTextCalls = 0; +let webArrayCalls = 0; +g.document = { + documentElement: { dir: '', lang: '' }, + createElement: (tag: string) => { + if (tag !== 'input') return {}; + const input: { + type: string; + accept: string; + files?: unknown[]; + onchange: (() => void | Promise) | null; + addEventListener: () => void; + click: () => void; + } = { + type: '', + accept: '', + onchange: null, + addEventListener: () => undefined, + click: () => { + input.files = [{ + name: 'route.xer', + text: async () => { webTextCalls++; return 'verboden'; }, + arrayBuffer: async () => { + webArrayCalls++; + return nextBytes.buffer.slice(nextBytes.byteOffset, nextBytes.byteOffset + nextBytes.byteLength); + }, + }]; + void input.onchange?.(); + }, + }; + return input; + }, +}; + +for (const [encoding, bytes] of encodings) { + // Web-openroute: de echte input-fallback moet arrayBuffer kiezen en File.text volledig mijden. + nextBytes = new Uint8Array(bytes); + webTextCalls = 0; + webArrayCalls = 0; + const webOpened = await openFileDialogWeb(openDialogFilters(), { binaryExtensions: binaryExtensions() }); + const webParsed = activeImportResult(await parseOpenedFile({ + name: webOpened?.name ?? '', + bytes: webOpened?.bytes, + text: webOpened?.content, + })); + eq(`web ${encoding}: originele bytes via File.arrayBuffer`, { + name: webParsed.project.name, + textCalls: webTextCalls, + arrayCalls: webArrayCalls, + }, { name: 'Café €', textCalls: 0, arrayCalls: 1 }); + + // Tauri-openroute: dezelfde oorspronkelijke bytes via plugin-fs.readFile, niet readTextFile. + const tauriCalls: string[] = []; + const tauriOpened = await readOpenedTauriPath('/tmp/route.xer', { + binaryExtensions: binaryExtensions(), + }, { + readTextFile: async () => { tauriCalls.push('text'); return 'verboden'; }, + readFile: async () => { tauriCalls.push('bytes'); return bytes; }, + }); + const tauriParsed = activeImportResult(await parseOpenedFile(tauriOpened)); + eq(`Tauri ${encoding}: plugin-fs-bytes blijven ongewijzigd`, { + name: tauriParsed.project.name, + calls: tauriCalls, + }, { name: 'Café €', calls: ['bytes'] }); + + // Recents-webroute: een bewaarde handle wordt als bytes herlezen en daarna normaal geparsed. + const handle = { + kind: 'file', + name: 'route.xer', + queryPermission: async () => 'granted', + requestPermission: async () => 'granted', + getFile: async () => ({ + text: async () => 'verboden', + arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + }), + } as unknown as FileSystemFileHandle; + const recentBytes = await readBytesFromRefWeb({ kind: 'handle', handle }); + const recentParsed = activeImportResult(await parseOpenedFile({ name: 'route.xer', bytes: recentBytes ?? undefined })); + eq(`recents ${encoding}: handle-bytepad bereikt de XER-reader`, recentParsed.project.name, 'Café €'); +} + +// Dev bridge: injecteer uitsluitend de filesystemrand; de echte openFromPath-kern kiest de +// registry, parsed en laadt de store. De dynamische import volgt pas na de browserstubs hierboven. +const { openFromPathWithIO } = await import('@/utils/devBridge'); +const { useAppStore } = await import('@/state/appStore'); +for (const [encoding, bytes] of encodings) { + const calls: string[] = []; + const opened = await openFromPathWithIO('/tmp/route.xer', { + readTextFile: async () => { calls.push('text'); return 'verboden'; }, + readFile: async () => { calls.push('bytes'); return bytes; }, + }); + eq(`dev bridge ${encoding}: bytes → registry → store`, { + calls, + project: useAppStore.getState().project.name, + tasks: opened.tasks, + }, { calls: ['bytes'], project: 'Café €', tasks: 1 }); +} + +// Fixronde 1, bevinding 2: parseOpenedFile → applyLoadedProject moet de XER-bronmetadata +// documentgebonden behouden. De externe relatie blijft uitsluitend daar staan (geen Task-link). +// X10 rapporteert enumterugvallen vervolgens bestandbreed in de ene samengestelde XER-melding. +const loadedState = useAppStore.getState() as ReturnType & { + xerImportMetadata?: { + enumFallbacks: Array<{ family: string; token: string; fallback: string }>; + externalRelations: Array<{ id: string }>; + } | null; +}; +const loadedDocId = loadedState.activeDocumentId; +eq('documentroute: XER-metadata staat op het geladen document', { + fallbacks: loadedState.xerImportMetadata?.enumFallbacks.map(item => [item.family, item.token, item.fallback]), + external: loadedState.xerImportMetadata?.externalRelations.map(item => item.id), + taskExternalLinks: loadedState.tasks.flatMap(task => task.externalLinks ?? []).length, +}, { + fallbacks: [['activityType', 'TT_Onbekend', 'TT_Task']], + external: ['R-EXT'], + taskExternalLinks: 0, +}); +const importNotice = loadedState.ui.notifications.find( + notice => notice.messageKey === ('notifications.xerImportOpened' as typeof notice.messageKey), +); +eq('documentroute: enumterugval zit in de ene samengestelde XER-melding', + importNotice?.detailLines?.find(line => line.messageKey === 'notifications.xerImportEnumFallbacks'), { + messageKey: 'notifications.xerImportEnumFallbacks', + params: { count: 1 }, + }); +loadedState.newDocument(); +eq('documentroute: vers document erft geen XER-metadata', + (useAppStore.getState() as typeof loadedState).xerImportMetadata, null); +useAppStore.getState().switchDocument(loadedDocId); +eq('documentroute: documentswitch herstelt eigen externe brondata', + (useAppStore.getState() as typeof loadedState).xerImportMetadata?.externalRelations.map(item => item.id), + ['R-EXT']); + +function resetDevDocuments(): void { + for (const document of [...useAppStore.getState().documents]) { + if (useAppStore.getState().documents.some(current => current.id === document.id)) { + useAppStore.getState().closeDocument(document.id); + } + } +} + +// Reviewronde 1, P2: de dev-handeling is een echte openhandeling, geen single-state loadState. +// De return-id's moeten exact de documenten zijn die de gedeelde opennaad heeft hergebruikt/gemaakt. +resetDevDocuments(); +const devMulti = await openFromPathWithIO('/tmp/twee-projecten.xer', { + readTextFile: async () => 'verboden', + readFile: async () => multiFixture(), +}) as { documentIds?: string[]; activeDocumentId?: string }; +const devMultiDocs = useAppStore.getState().getOpenDocumentPayloads(); +eq('dev bridge meerproject: twee PROJECTen worden twee echte documenten met exacte ids', { + returnedIds: devMulti.documentIds, + stateIds: devMultiDocs.map(document => document.id), + projects: devMultiDocs.map(document => document.payload.project.id), + activeDocumentId: devMulti.activeDocumentId, + stateActiveDocumentId: useAppStore.getState().activeDocumentId, +}, { + returnedIds: devMultiDocs.map(document => document.id), + stateIds: devMultiDocs.map(document => document.id), + projects: ['P-A', 'P-B'], + activeDocumentId: devMultiDocs[1]?.id, + stateActiveDocumentId: devMultiDocs[1]?.id, +}); + +const corpusRoot = process.env.OPS_XER_CORPUS; +const publicMultiPath = corpusRoot + ? join(corpusRoot, 'crawl-xer/eh_P6Workshops/OZB-Start-09Dec24.xer') + : ''; +if (publicMultiPath && existsSync(publicMultiPath)) { + resetDevDocuments(); + const publicBytes = new Uint8Array(readFileSync(publicMultiPath)); + const publicOpened = await openFromPathWithIO('/tmp/openbaar-15-projecten.xer', { + readTextFile: async () => 'verboden', + readFile: async () => publicBytes, + }) as { documentIds?: string[]; activeDocumentId?: string }; + const publicDocs = useAppStore.getState().getOpenDocumentPayloads(); + eq('dev bridge openbaar: 15 PROJECTen waaieren via open-by-path uit naar 12 documentIds', { + returnedCount: publicOpened.documentIds?.length, + returnedIds: publicOpened.documentIds, + stateIds: publicDocs.map(document => document.id), + stateCount: publicDocs.length, + activeDocumentId: publicOpened.activeDocumentId, + stateActiveDocumentId: useAppStore.getState().activeDocumentId, + }, { + returnedCount: 12, + returnedIds: publicDocs.map(document => document.id), + stateIds: publicDocs.map(document => document.id), + stateCount: 12, + activeDocumentId: useAppStore.getState().activeDocumentId, + stateActiveDocumentId: useAppStore.getState().activeDocumentId, + }); +} else { + console.log('OK XER-bytepaden: openbare DevBridge-12-documentenpin overgeslagen (OPS_XER_CORPUS)'); +} + +// Echte fileSlice-openactie boven op het web-bytepad: de bron wordt geladen, maar krijgt nooit +// een opslagdoel omdat saveTargetFor alleen IFC doorlaat. +nextBytes = new Uint8Array(encodings[0][1]); +await useAppStore.getState().openFile(); +eq('fileSlice web-open: XER laadt maar wordt geen Ctrl+S-opslagdoel', { + project: useAppStore.getState().project.name, + filePath: useAppStore.getState().filePath, + fileHandle: useAppStore.getState().fileHandle, +}, { project: 'Café €', filePath: null, fileHandle: null }); + +// Echte openRecentFile-actie boven op de handle-bytebackend. De entry kan door de headless +// IndexedDB-terugval opnieuw worden opgebouwd, maar de handle mag nooit documentopslagdoel worden. +const recentSource = encodings[2][1]; +const recentHandle = { + kind: 'file', + name: 'recent.xer', + isSameEntry: async () => false, + queryPermission: async () => 'granted', + requestPermission: async () => 'granted', + getFile: async () => ({ + text: async () => 'verboden', + arrayBuffer: async () => recentSource.buffer.slice( + recentSource.byteOffset, + recentSource.byteOffset + recentSource.byteLength, + ), + }), +} as unknown as FileSystemFileHandle; +useAppStore.setState({ + recentFiles: [{ + id: 'xer-recent', + name: 'recent.xer', + ref: { kind: 'handle', handle: recentHandle }, + addedAt: Date.now(), + }], +}); +await useAppStore.getState().openRecentFile('xer-recent'); +eq('fileSlice recents: UTF-16BE-XER laadt zonder opslagdoel', { + project: useAppStore.getState().project.name, + filePath: useAppStore.getState().filePath, + fileHandle: useAppStore.getState().fileHandle, +}, { project: 'Café €', filePath: null, fileHandle: null }); + +// Een getypeerde semantische fout gebeurt volledig vóór de documenthydratatie. +const beforeRejectedOpen = { + project: useAppStore.getState().project.name, + taskIds: useAppStore.getState().tasks.map(task => task.id), + metadata: (useAppStore.getState() as typeof loadedState).xerImportMetadata, +}; +nextBytes = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name', + '%R\tP1\tDubbel', + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_code\ttask_name\ttarget_start_date\ttarget_end_date', + '%R\tT1\tP1\tA1\tEen\t2026-01-01\t2026-01-02', + '%R\tT1\tP1\tA2\tDubbel\t2026-01-03\t2026-01-04', + '%E', +].join('\n')); +await useAppStore.getState().openFile(); +eq('fileSlice foutpad: dubbele id hydrateert niets gedeeltelijk', { + project: useAppStore.getState().project.name, + taskIds: useAppStore.getState().tasks.map(task => task.id), + metadata: (useAppStore.getState() as typeof loadedState).xerImportMetadata, + errorKey: useAppStore.getState().ui.notifications.at(-1)?.messageKey, +}, { ...beforeRejectedOpen, errorKey: 'notifications.xerDuplicateId' }); + +if (diffs.length > 0) { + console.error(`XER-bytepaden: ${diffs.length}/${checks} checks rood`); + for (const diff of diffs) console.error(`XX ${diff}`); + process.exit(1); +} +console.log(`OK XER-bytepaden: ${checks} web/Tauri/recents/dev × drie encodings`); diff --git a/tests/planning/check-xer-calendar-corpus.ts b/tests/planning/check-xer-calendar-corpus.ts new file mode 100644 index 000000000..90eb097dc --- /dev/null +++ b/tests/planning/check-xer-calendar-corpus.ts @@ -0,0 +1,238 @@ +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + readXerCalendars, + type XerCalendarReadResult, +} from '@/services/xer/xerCalendarData'; +import { parseXerTables, type XerTables } from '@/services/xer/xerTables'; + +const diffs: string[] = []; +let checks = 0; + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +interface CorpusManifest { + files: Record; +} + +interface CalendarBaseline { + version: number; + key: string; + pins: Record; + issueTotals?: Record; + dossier?: { + hash: string; + calendars: number; + dayHourFieldsPresent: number; + semanticDigest: string; + }; +} + +interface CorpusCalendarResult { + tables: XerTables; + result: XerCalendarReadResult; +} + +function semanticDigest(result: XerCalendarReadResult): string { + const semantics = result.calendars.map(calendar => ({ + id: calendar.id, + workDays: calendar.workDays, + workTime: calendar.workTime?.byWeekday, + hoursPerDay: calendar.hoursPerDay, + hoursPerWeek: calendar.hoursPerWeek, + hoursPerMonth: calendar.hoursPerMonth, + hoursPerYear: calendar.hoursPerYear, + holidays: calendar.holidays, + workingExceptions: calendar.workingExceptions ?? [], + calendarType: calendar.calendarType, + rawCalendarType: calendar.rawCalendarType, + baseCalendarId: calendar.baseCalendarId, + hourModeSource: calendar.hourModeSource, + })); + return createHash('sha256').update(JSON.stringify(semantics), 'utf8').digest('hex'); +} + +function idsForResolution( + result: XerCalendarReadResult, + resolution: 'RECOVERED' | 'REJECTED', +): string[] { + return Array.from(new Set( + result.issues + .filter(issue => issue.resolution === resolution) + .map(issue => issue.calendarId), + )); +} + +function structurallyRecoveredIds(result: XerCalendarReadResult): string[] { + return Array.from(new Set( + result.issues + .filter(issue => issue.code === 'XER_CALENDAR_COMPACT_RECORD_RECOVERED' + || issue.code === 'XER_CALENDAR_SURPLUS_CLOSE_RECOVERED') + .map(issue => issue.calendarId), + )); +} + +const corpusRoot = process.env.OPS_XER_CORPUS; +if (!corpusRoot) { + console.log('OK xer-calendar-corpus: openbare bron niet ingesteld — corpuspins overgeslagen'); + process.exit(0); +} +if (!existsSync(corpusRoot)) { + console.log('XX xer-calendar-corpus: OPS_XER_CORPUS wijst niet naar een bestaande map'); + process.exit(1); +} + +const manifest = JSON.parse( + readFileSync(join(import.meta.dirname, 'xer-corpus-manifest.json'), 'utf8'), +) as CorpusManifest; +const baseline = JSON.parse( + readFileSync(join(import.meta.dirname, 'xer-calendar-hour-mode-baseline.json'), 'utf8'), +) as CalendarBaseline; +const byHash = new Map(); +const observed: Record = {}; + +for (const [relativePath, entry] of Object.entries(manifest.files)) { + if (byHash.has(entry.sha256) || Object.prototype.hasOwnProperty.call(observed, entry.sha256)) continue; + let tables: XerTables; + try { + tables = parseXerTables(new Uint8Array(readFileSync(join(corpusRoot, relativePath)))); + } catch { + continue; + } + const calendarRows = tables.tables.get('CALENDAR')?.rows.length ?? 0; + if (calendarRows === 0) continue; + const result = readXerCalendars(tables); + byHash.set(entry.sha256, { tables, result }); + observed[entry.sha256] = { + calendarRows, + retainedRows: result.calendars.length, + recoveredRows: structurallyRecoveredIds(result).length, + rejectedRows: idsForResolution(result, 'REJECTED').length, + ...result.promotion, + }; +} + +const groupedPins: Record = {}; +for (const [hash, pin] of Object.entries(observed)) { + const key = [ + pin.calendarRows, + pin.retainedRows, + pin.recoveredRows, + pin.rejectedRows, + pin.sharedRules, + pin.withXerClockRule, + ].join('/'); + (groupedPins[key] ??= []).push(hash); +} +for (const hashes of Object.values(groupedPins)) hashes.sort(); +eq('1 productie-ingang pint per hash behoud, herstel, weigering en uurmodusdelta', + groupedPins, baseline.pins); +const issueTotals: Record = {}; +for (const corpus of byHash.values()) { + for (const issue of corpus.result.issues) { + issueTotals[issue.code] = (issueTotals[issue.code] ?? 0) + 1; + } +} +eq('1a issuecontract is corpusbreed per code gepind', issueTotals, + baseline.issueTotals ?? { baselineMissing: 1 }); + +const recoveryPins: Record = { + '2a7732b5b99de2a5f40aab40e0af52757fbe68e5d4eda404a9a060fdc1be1140': { + retainedIds: ['CAL-01', 'CAL-02', 'CAL-03', 'CAL-04', 'CAL-05', 'CAL-06', + 'RCAL-CRANE600', 'RCAL-SPECIALIST'], + recoveredIds: ['CAL-01', 'CAL-02', 'CAL-03', 'CAL-04', 'CAL-05', 'CAL-06', + 'RCAL-CRANE600', 'RCAL-SPECIALIST'], + rejectedIds: [], + issueCodes: Array(8).fill('XER_CALENDAR_COMPACT_RECORD_RECOVERED'), + }, + '0611f9054a4b566340dd53181b96d66907f4ac8faa0ef1395ec338be9f83b96d': { + retainedIds: ['1001', '1002', '1003'], + recoveredIds: ['1001', '1002', '1003'], + rejectedIds: [], + issueCodes: Array(3).fill('XER_CALENDAR_SURPLUS_CLOSE_RECOVERED'), + }, + '816e01738f4967872980ce3fed0c2bfd8c09f78197b7de1beea5ca98e094aeae': { + retainedIds: ['1001', '1002', '1003'], + recoveredIds: ['1001', '1002', '1003'], + rejectedIds: [], + issueCodes: Array(3).fill('XER_CALENDAR_SURPLUS_CLOSE_RECOVERED'), + }, + '85f8c7d681b15daf755ba1d568eb09ebcdd529a16c20045e6db6c2937899f54b': { + retainedIds: ['178', '179'], + recoveredIds: [], + rejectedIds: ['180'], + issueCodes: ['XER_CALENDAR_INVALID_STRUCTURE'], + }, +}; + +for (const [hash, expected] of Object.entries(recoveryPins)) { + const result = byHash.get(hash)?.result; + eq(`2 herstel-/weigerpin ${hash}`, result ? { + retainedIds: result.calendars.map(calendar => calendar.id), + recoveredIds: idsForResolution(result, 'RECOVERED'), + rejectedIds: idsForResolution(result, 'REJECTED'), + issueCodes: result.issues.map(issue => issue.code), + } : { hashFound: false }, expected); +} + +const danglingHash = '9590c4cdc4efa69a231b66ac509611d17f31605599ee7abde02a197f5e8b1e6e'; +const dangling = byHash.get(danglingHash)?.result; +eq('3 openbare dangling base blijft rauw zichtbaar en expliciet ongekoppeld', dangling ? { + calendarId: dangling.calendars.find(calendar => calendar.id === '1')?.id, + baseCalendarId: dangling.calendars.find(calendar => calendar.id === '1')?.baseCalendarId, + linkedBase: dangling.calendars.find(calendar => calendar.id === '1')?.baseCalendar?.id, + issues: dangling.issues.map(issue => ({ + calendarId: issue.calendarId, + line: issue.line, + code: issue.code, + resolution: issue.resolution, + })), +} : { hashFound: false }, { + calendarId: '1', + baseCalendarId: '0', + issues: [{ + calendarId: '1', + line: 154, + code: 'XER_CALENDAR_DANGLING_BASE', + resolution: 'UNLINKED', + }], +}); + +const dossierHash = '2c1dce175b9f078111a48dc13fd1777f5fbd4cd7ab6623e647e7437330c60b7f'; +const dossier = byHash.get(dossierHash); +eq('4 statische 124-dossierdigest bewaakt dagmapping, alle banden, exceptions en periodeafleiding', + dossier ? { + hash: dossierHash, + calendars: dossier.result.calendars.length, + dayHourFieldsPresent: dossier.tables.tables.get('CALENDAR')?.fields.includes('day_hr_cnt') + ? dossier.result.calendars.length + : 0, + semanticDigest: semanticDigest(dossier.result), + } : { hashFound: false }, baseline.dossier ?? { baselineMissing: true }); + +if (diffs.length === 0) { + console.log(`OK xer-calendar-corpus: ${checks} checks groen`); + process.exit(0); +} + +console.log(`XX xer-calendar-corpus: ${diffs.length} afwijking(en) van ${checks}`); +for (const diff of diffs) console.log(` - ${diff}`); +process.exit(1); diff --git a/tests/planning/check-xer-calendar-data.ts b/tests/planning/check-xer-calendar-data.ts new file mode 100644 index 000000000..1a63b8640 --- /dev/null +++ b/tests/planning/check-xer-calendar-data.ts @@ -0,0 +1,640 @@ +import { + decodeXerCalendarData, + parseXerStructuredText, + readXerCalendars, + type XerCalendar, +} from '@/services/xer/xerCalendarData'; +import { parseP6StandardWorkWeek } from '@/services/p6/p6xmlReader'; +import { canonicalizeBands } from '@/services/subdayIo'; +import { parseXerTables } from '@/services/xer/xerTables'; +import { installDOMParser } from './xmldom-shim'; + +const diffs: string[] = []; +let checks = 0; + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +function utf8(lines: readonly string[]): Uint8Array { + return new TextEncoder().encode(lines.join('\n')); +} + +function structuredRecord( + number: string, + name: string, + fields: string, + children: readonly string[], +): string { + return `(${number}||${name}(${fields})(${children.join('')}))`; +} + +// Breuk die dit vangt: structured text als losse string-splitsingen behandelen, waardoor geneste +// records of P6's DEL-DEL-opmaak de recordgrenzen verschuiven. +const nested = parseXerStructuredText( + '\u007f\u007f (0||CalendarData(kind|root)((1||DaysOfWeek()((2||2()((3||0(s|08:00|f|16:00)())))))))', +); +eq('1 eigen tokenizer bewaart nummer, naam, velden en geneste kinderen', nested, { + role: 'ROOT', + number: '0', + name: 'CalendarData', + fields: { kind: 'root' }, + children: [{ + role: 'CONTAINER', + number: '1', + name: 'DaysOfWeek', + fields: {}, + children: [{ + role: 'DAY', + number: '2', + name: '2', + fields: {}, + children: [{ + role: 'BAND', + number: '3', + name: '0', + fields: { s: '08:00', f: '16:00' }, + children: [], + }], + }], + }], +}); + +let unnamedRootBands: unknown = 'geweigerd'; +try { + unnamedRootBands = decodeXerCalendarData( + '(0||()((0||DaysOfWeek()((0||2()((0||0(s|07:00|f|12:00)())))))(0||Exceptions()())))', + ).bands.byWeekday[1]; +} catch { + // De assertion hieronder maakt een onterechte rootnaamweigering als gewone RED zichtbaar. +} +eq('1a lege hoofdrecordnaam behoudt volledig aanwezige kalenderkinderen', unnamedRootBands, [ + { start: 420, end: 720 }, +]); + +// Breuk die dit vangt: P6-dag 1 als ISO-maandag behandelen, AM/PM als 24-uursklok lezen, +// middernacht-wrap verliezen of een uitzondering met uren als vrije dag opslaan. +const decoded = decodeXerCalendarData( + '(0||CalendarData()(' + + '(0||DaysOfWeek()(' + + '(0||1()())' + + '(0||2()((0||0(s|08:00|f|12:00)())(0||1(s|1:00 PM|f|5:00 PM)())))' + + '(0||3()((0||0(s|22:00|f|06:00)())))' + + '))' + + '(0||Exceptions()(' + + '(0||0(d|0)())' + + '(0||1(d|1)((0||0(s|9:00 AM|f|1:30 PM)())))' + + '))' + + '))', +); +eq('2 DaysOfWeek zet P6 zo/ma/di om naar ISO-weekdagen en canonieke banden', decoded.bands.byWeekday, { + 1: [{ start: 480, end: 720 }, { start: 780, end: 1020 }], + 2: [{ start: 1320, end: 1800 }], + 3: [], 4: [], 5: [], 6: [], 7: [], +}); +eq('3 uitzondering zonder uren is een vrije dag vanaf de P6-epoch', decoded.holidays, [{ + name: 'Calendar exception', startDate: '1899-12-30', endDate: '1899-12-30', +}]); +eq('4 uitzondering met AM/PM-uren is een werkende uitzondering', decoded.workingExceptions, [{ + name: 'Calendar exception', startDate: '1899-12-31', endDate: '1899-12-31', + bands: [{ start: 540, end: 810 }], +}]); +eq('5 gelezen klokbanden dragen het XER-uursignaal', decoded.hasExplicitClockBands, true); + +// Breuk die dit vangt: een lege clndr_data als volledig niet-werkend behandelen. De decoder houdt +// een neutrale 5x8-default; de reader mag die per kalender verfijnen wanneer geplande bronvelden +// een P6-lunchdag bewijzen (zie check-xer-reader). +const empty = decodeXerCalendarData(''); +eq('6 lege kalenderdata valt terug op ma-vr 08:00-16:00', empty.bands.byWeekday, { + 1: [{ start: 480, end: 960 }], 2: [{ start: 480, end: 960 }], + 3: [{ start: 480, end: 960 }], 4: [{ start: 480, end: 960 }], + 5: [{ start: 480, end: 960 }], 6: [], 7: [], +}); +eq('7 gesynthetiseerde default is geen expliciet XER-kloksignaal', empty.hasExplicitClockBands, false); + +// Breuken die dit vangt: `base_clndr_id` in dezelfde eerste pas opzoeken (kind staat hier vóór +// basis), lege uren als nul behandelen, of ontbrekende uren-per-dag uit een scalar/default afleiden +// in plaats van uit de werkelijk gelezen weekbanden. +const calendarTable = parseXerTables(utf8([ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tUSD', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tbase_clndr_id\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tmonth_hr_cnt\tyear_hr_cnt\tclndr_data', + '%R\t2\tAfgeleid\t1\tCA_Project\t7.5\t37.5\t162.5\t1950\t', + '%R\t1\tBasis\t\tCA_Base\t\t\t\t\t(0||CalendarData()((0||DaysOfWeek()((0||1()())(0||2()((0||0(s|08:00|f|12:00)())))(0||3()((0||0(s|08:00|f|16:00)())))))(0||Exceptions()())))', + '%R\t3\tVolledige dag\t\tCA_Rsrc\t\t\t\t\t(0||CalendarData()((0||DaysOfWeek()((0||1()())(0||2()((0||0(s|00:00|f|24:00)())))))(0||Exceptions()())))', + '%E', +])); +const tableCalendars = readXerCalendars(calendarTable); +const child = tableCalendars.byId.get('2'); +const base = tableCalendars.byId.get('1'); +eq('8 kind vóór basis wordt in tweede pas gekoppeld', child?.baseCalendar?.id, '1'); +eq('9 clndr_type wordt semantisch en rauw bewaard', { + child: [child?.calendarType, child?.rawCalendarType], + base: [base?.calendarType, base?.rawCalendarType], +}, { + child: ['PROJECT', 'CA_Project'], + base: ['GLOBAL', 'CA_Base'], +}); +eq('10 expliciete uren-per-periode blijven ongewijzigd', { + day: child?.hoursPerDay, + week: child?.hoursPerWeek, + month: child?.hoursPerMonth, + year: child?.hoursPerYear, +}, { day: 7.5, week: 37.5, month: 162.5, year: 1950 }); +eq('11 ontbrekende uren-per-periode komen uit 12 echte weekuren over 2 werkdagen', { + day: base?.hoursPerDay, + week: base?.hoursPerWeek, + month: base?.hoursPerMonth, + year: base?.hoursPerYear, +}, { day: 6, week: 12, month: 48, year: 576 }); +eq('12 tabelresultaat behoudt X2-encodingrapportage', tableCalendars.encoding, 'utf-8'); + +// Breuken die dit vangt: alle CALENDAR-rijen in een globale `map` laten falen op één defecte rij, +// of de twee waargenomen compacte recordvormen alleen in de test voorfilteren. De productie-ingang +// moet de compacte band/exception-vorm smal herstellen, de afgeknotte rij zichtbaar weigeren en de +// geldige zusterrij behouden. +const isolatedRows = readXerCalendars(parseXerTables(utf8([ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tUSD', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_data', + '%R\tcompact\tCompact\t(0||CalendarData()((0||DaysOfWeek()((0||2()((s|08:00|f|12:00)(s|13:00|f|17:00)))))(0||Exceptions()((0||d|0())))))', + '%R\tsurplus\tSurplus\t(0||CalendarData()((0||DaysOfWeek()((0||2()((0||0(s|08:00|f|16:00)()))))))(0||Exceptions()())))', + '%R\tbroken\tAfgekapt\t(0||CalendarData()(', + '%R\tvalid\tGeldig\t(0||CalendarData()((0||DaysOfWeek()((0||2()((0||0(s|08:00|f|16:00)())))))(0||Exceptions()())))', + '%E', +]))); +eq('13 productie-ingang behoudt herstelde en geldige zusterkalenders per rij', + isolatedRows.calendars.map(calendar => calendar.id), ['compact', 'surplus', 'valid']); +eq('14 rij-issues pinnen kalender-id, bronregel, code en afhandeling', + isolatedRows.issues.map(issue => ({ + id: issue.calendarId, + line: issue.line, + code: issue.code, + resolution: issue.resolution, + })), [{ + id: 'compact', + line: 4, + code: 'XER_CALENDAR_COMPACT_RECORD_RECOVERED', + resolution: 'RECOVERED', + }, { + id: 'surplus', + line: 5, + code: 'XER_CALENDAR_SURPLUS_CLOSE_RECOVERED', + resolution: 'RECOVERED', + }, { + id: 'broken', + line: 6, + code: 'XER_CALENDAR_INVALID_STRUCTURE', + resolution: 'REJECTED', + }]); +eq('15 ieder rij-issue bevat een niet-lege reden', + isolatedRows.issues.every(issue => issue.reason.trim().length > 0), true); + +// Breuken die dit vangt: compacte records louter op prefix herkennen en daardoor een exception +// als weekdag fabriceren, een dag onder Exceptions toelaten, compactvormen door wrappers laten +// lekken of een compact record onder een onbekende ouder accepteren. Iedere rij wordt geheel +// geweigerd; alleen de geldige zusterkalender blijft over. +const nestedCompactExceptions = structuredRecord('0', 'CalendarData', '', [ + structuredRecord('0', 'DaysOfWeek', '', [ + structuredRecord('0', '2', '', [structuredRecord('0', '0', 's|08:00|f|12:00', [])]), + ]), + structuredRecord('0', 'Wrapper', '', [ + structuredRecord('0', 'Exceptions', '', ['(0||d|2())']), + ]), +]); +const misplacedCompactRows = readXerCalendars(parseXerTables(utf8([ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tUSD', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_data', + '%R\texception-under-days\t(0||CalendarData()((0||DaysOfWeek()((2||d|123((s|08:00|f|16:00)))))(0||Exceptions()())))', + '%R\tday-under-exceptions\t(0||CalendarData()((0||DaysOfWeek()((0||2()())))(0||Exceptions()((0||2()((s|08:00|f|16:00)))))))', + '%R\tnested-compact\t(0||CalendarData()((0||DaysOfWeek()((0||2()((0||Wrapper()((s|08:00|f|16:00)))))))(0||Exceptions()())))', + `%R\tnested-exceptions\t${nestedCompactExceptions}`, + '%R\twrong-parent\t(0||CalendarData()((0||DaysOfWeek()((0||2()((0||0(s|08:00|f|12:00)())))))(0||Wrong()((s|13:00|f|17:00)))(0||Exceptions()())))', + '%R\tvalid-sister\t(0||CalendarData()((0||DaysOfWeek()((0||2()((0||0(s|07:00|f|11:00)())))))(0||Exceptions()())))', + '%E', +]))); +eq('15a compacte recovery behoudt alleen de geldige zusterkalender', + misplacedCompactRows.calendars.map(calendar => ({ + id: calendar.id, + workTime: calendar.workTime?.byWeekday[1], + })), [{ id: 'valid-sister', workTime: [{ start: 420, end: 660 }] }]); +eq('15b verkeerde compactcontext wordt per rij typed geweigerd zonder default of deelkalender', + misplacedCompactRows.issues.map(issue => [issue.calendarId, issue.code, issue.resolution]), [ + ['exception-under-days', 'XER_CALENDAR_INVALID_COMPACT_CONTEXT', 'REJECTED'], + ['day-under-exceptions', 'XER_CALENDAR_INVALID_COMPACT_CONTEXT', 'REJECTED'], + ['nested-compact', 'XER_CALENDAR_INVALID_COMPACT_CONTEXT', 'REJECTED'], + ['nested-exceptions', 'XER_CALENDAR_INVALID_COMPACT_CONTEXT', 'REJECTED'], + ['wrong-parent', 'XER_CALENDAR_INVALID_COMPACT_CONTEXT', 'REJECTED'], + ]); + +// Breuken die dit vangt: aanwezige maar ongeldige data als "afwezig" behandelen en stil naar de +// ma-vr-default vallen, losse tokens/ongeldige dagen of epochs laten verdwijnen, seconden afkappen, +// overlappende banden doorgeven en negatieve periode-uren stil afleiden. Alleen de lege cel mag +// defaulten. Bij een dubbele exceptiondatum wint een werkende uitzondering altijd van een vrije dag. +const hostileRows = readXerCalendars(parseXerTables(utf8([ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tUSD', + '%T\tCALENDAR', + '%F\tclndr_id\tday_hr_cnt\tclndr_data', + '%R\tabsent\t\t', + '%R\tclock\t\t(0||CalendarData()((0||DaysOfWeek()((0||2()((0||0(s|25:00|f|26:00)())))))(0||Exceptions()())))', + '%R\tpairs\t\t(0||CalendarData()((0||DaysOfWeek()((0||2()((0||0(s|08:00|dangling)())))))(0||Exceptions()())))', + '%R\tday\t\t(0||CalendarData()((0||DaysOfWeek()((0||8()((0||0(s|08:00|f|16:00)())))))(0||Exceptions()())))', + '%R\tepoch\t\t(0||CalendarData()((0||DaysOfWeek()((0||2()((0||0(s|08:00|f|16:00)())))))(0||Exceptions()((0||0(d|geen-datum)())))))', + '%R\tseconds\t\t(0||CalendarData()((0||DaysOfWeek()((0||2()((0||0(s|08:00:59|f|16:00:00)())))))(0||Exceptions()())))', + '%R\toverlap\t\t(0||CalendarData()((0||DaysOfWeek()((0||2()((0||0(s|08:00|f|12:00)())(0||1(s|11:00|f|13:00)())))))(0||Exceptions()())))', + '%R\tperiod\t-1\t(0||CalendarData()((0||DaysOfWeek()((0||2()((0||0(s|08:00|f|16:00)())))))(0||Exceptions()())))', + '%R\tduplicate\t\t(0||CalendarData()((0||DaysOfWeek()((0||2()((0||0(s|08:00|f|16:00)())))))(0||Exceptions()((0||0(d|0)())(0||1(d|0)((0||0(s|09:00|f|13:00)())))))))', + '%R\tduplicate-key\t\t(0||CalendarData()((0||DaysOfWeek()((0||2()((0||0(s|08:00|s|09:00|f|16:00)())))))(0||Exceptions()())))', + '%R\tduplicate-day\t\t(0||CalendarData()((0||DaysOfWeek()((0||2()((0||0(s|08:00|f|12:00)())))(0||2()((0||0(s|13:00|f|17:00)())))))(0||Exceptions()())))', + '%R\tempty-key\t\t(0||CalendarData()((0||DaysOfWeek()((0||2()((0||0(|waarde|s|08:00|f|16:00)())))))(0||Exceptions()())))', + '%E', +]))); +eq('15a alleen afwezige data en de deterministisch herstelde duplicate blijven behouden', + hostileRows.calendars.map(calendar => calendar.id), ['absent', 'duplicate']); +eq('15b hostile semantiek levert onderscheiden issuecodes zonder stille defaults', + hostileRows.issues.map(issue => [issue.calendarId, issue.code, issue.resolution]), [ + ['clock', 'XER_CALENDAR_INVALID_CLOCK', 'REJECTED'], + ['pairs', 'XER_CALENDAR_ODD_FIELD_COUNT', 'REJECTED'], + ['day', 'XER_CALENDAR_INVALID_DAY', 'REJECTED'], + ['epoch', 'XER_CALENDAR_INVALID_EPOCH', 'REJECTED'], + ['seconds', 'XER_CALENDAR_NONZERO_SECONDS', 'REJECTED'], + ['overlap', 'XER_CALENDAR_OVERLAPPING_BANDS', 'REJECTED'], + ['period', 'XER_CALENDAR_INVALID_PERIOD_HOURS', 'REJECTED'], + ['duplicate', 'XER_CALENDAR_DUPLICATE_EXCEPTION', 'RECOVERED'], + ['duplicate-key', 'XER_CALENDAR_DUPLICATE_FIELD', 'REJECTED'], + ['duplicate-day', 'XER_CALENDAR_DUPLICATE_DAY', 'REJECTED'], + ['empty-key', 'XER_CALENDAR_INVALID_FIELD_PAIR', 'REJECTED'], + ]); +eq('15c werkende uitzondering wint op een dubbele datum en sluit holiday-dubbeling uit', { + holidays: hostileRows.byId.get('duplicate')?.holidays, + working: hostileRows.byId.get('duplicate')?.workingExceptions, +}, { + holidays: [], + working: [{ + name: 'Calendar exception', + startDate: '1899-12-30', + endDate: '1899-12-30', + bands: [{ start: 540, end: 780 }], + }], +}); +eq('15d hostile issues dragen steeds bronregel en reden', hostileRows.issues.map(issue => ({ + line: issue.line, + hasReason: issue.reason.trim().length > 0, +})), [ + { line: 5, hasReason: true }, + { line: 6, hasReason: true }, + { line: 7, hasReason: true }, + { line: 8, hasReason: true }, + { line: 9, hasReason: true }, + { line: 10, hasReason: true }, + { line: 11, hasReason: true }, + { line: 12, hasReason: true }, + { line: 13, hasReason: true }, + { line: 14, hasReason: true }, + { line: 15, hasReason: true }, +]); + +// Breuk die dit vangt: alleen het eerste Exceptions-kind met `find` lezen en de inhoud van een +// tweede container stil verliezen. De containers worden in bronvolgorde samengevoegd en het +// herstelbesluit blijft als typed issue zichtbaar. +const multipleExceptionContainers = readXerCalendars(parseXerTables(utf8([ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tUSD', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_data', + '%R\tmulti-exceptions\t(0||CalendarData()((0||DaysOfWeek()((0||2()((0||0(s|08:00|f|16:00)())))))(0||Exceptions()((0||0(d|0)())))(0||Exceptions()((0||1(d|1)((0||0(s|09:00|f|13:00)())))))))', + '%E', +]))); +eq('15e meerdere Exceptions-containers behouden vrije en werkende datums in bronvolgorde', { + holidays: multipleExceptionContainers.calendars[0]?.holidays, + working: multipleExceptionContainers.calendars[0]?.workingExceptions, +}, { + holidays: [{ + name: 'Calendar exception', startDate: '1899-12-30', endDate: '1899-12-30', + }], + working: [{ + name: 'Calendar exception', startDate: '1899-12-31', endDate: '1899-12-31', + bands: [{ start: 540, end: 780 }], + }], +}); +eq('15f meerdere Exceptions-containers leveren een expliciet recovery-issue', + multipleExceptionContainers.issues.map(issue => ({ + id: issue.calendarId, + line: issue.line, + code: issue.code, + resolution: issue.resolution, + })), [{ + id: 'multi-exceptions', + line: 4, + code: 'XER_CALENDAR_MULTIPLE_EXCEPTIONS_MERGED', + resolution: 'RECOVERED', + }]); + +// Breuken die dit vangt: een dangling/self-base stil laten staan of A↔B als echte circulaire +// objectgraaf koppelen. De ruwe id blijft diagnostisch bewaard; alleen veilige randen worden objecten. +const baseGraph = readXerCalendars(parseXerTables(utf8([ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tUSD', + '%T\tCALENDAR', + '%F\tclndr_id\tbase_clndr_id\tclndr_data', + '%R\tA\tB\t', + '%R\tB\tA\t', + '%R\tself\tself\t', + '%R\tdangling\tmissing\t', + '%R\troot\t\t', + '%R\tchild\troot\t', + '%E', +]))); +eq('15g ruwe base-id blijft staan maar alleen de acyclische geldige rand wordt gekoppeld', + baseGraph.calendars.map(calendar => ({ + id: calendar.id, + rawBase: calendar.baseCalendarId, + linkedBase: calendar.baseCalendar?.id, + })), [ + { id: 'A', rawBase: 'B' }, + { id: 'B', rawBase: 'A' }, + { id: 'self', rawBase: 'self' }, + { id: 'dangling', rawBase: 'missing' }, + { id: 'root' }, + { id: 'child', rawBase: 'root', linkedBase: 'root' }, + ]); +eq('15h basegraaf rapporteert cyclusleden, self en dangling onderscheiden', + baseGraph.issues.map(issue => [issue.calendarId, issue.code, issue.resolution]), [ + ['A', 'XER_CALENDAR_BASE_CYCLE', 'UNLINKED'], + ['B', 'XER_CALENDAR_BASE_CYCLE', 'UNLINKED'], + ['self', 'XER_CALENDAR_SELF_BASE', 'UNLINKED'], + ['dangling', 'XER_CALENDAR_DANGLING_BASE', 'UNLINKED'], + ]); +let baseGraphSerializable = true; +try { + JSON.stringify(baseGraph.calendars); +} catch { + baseGraphSerializable = false; +} +eq('15i kalenderresultaat blijft serialiseerbaar zonder cyclische objectgraaf', + baseGraphSerializable, true); + +// Breuken die dit vangt: basegraafdetectie recursief uitvoeren en daardoor bij een geldige diepe +// keten of cyclus de JS-callstack overschrijden. De grote cyclus heeft een korte tail; alleen de +// 10.000 echte cyclusleden krijgen een issue en de ongekoppelde objectgraaf blijft serialiseerbaar. +const deepAcyclicRows = Array.from({ length: 10_000 }, (_, index) => ( + `%R\ta-${index}\t${index === 9_999 ? '' : `a-${index + 1}`}\t` +)); +let deepAcyclic: ReturnType | undefined; +let deepAcyclicError = ''; +try { + deepAcyclic = readXerCalendars(parseXerTables(utf8([ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tUSD', + '%T\tCALENDAR', + '%F\tclndr_id\tbase_clndr_id\tclndr_data', + ...deepAcyclicRows, + '%E', + ]))); +} catch (error) { + deepAcyclicError = error instanceof Error ? `${error.name}: ${error.message}` : String(error); +} +let deepAcyclicJsonError = ''; +let deepAcyclicTransfer: XerCalendar[] | undefined; +if (deepAcyclic) { + const result = deepAcyclic; + try { + deepAcyclicTransfer = JSON.parse(JSON.stringify(result.calendars)) as XerCalendar[]; + } catch (error) { + deepAcyclicJsonError = error instanceof Error ? `${error.name}: ${error.message}` : String(error); + } +} +eq('15j iteratieve basegraaf verwerkt 10.000 acyclische vooruitverwijzingen zonder stackfout', { + error: deepAcyclicError, + jsonError: deepAcyclicJsonError, + calendars: deepAcyclic?.calendars.length, + issues: deepAcyclic?.issues.length, + firstBase: deepAcyclic?.byId.get('a-0')?.baseCalendar?.id, + firstBaseIdentity: deepAcyclic?.byId.get('a-0')?.baseCalendar + === deepAcyclic?.byId.get('a-1'), + penultimateBase: deepAcyclic?.byId.get('a-9998')?.baseCalendar?.id, + rootBase: deepAcyclic?.byId.get('a-9999')?.baseCalendar?.id, + transferCount: deepAcyclicTransfer?.length, + transferFirstBaseId: deepAcyclicTransfer?.[0]?.baseCalendarId, + transferContainsConvenienceLink: Object.prototype.hasOwnProperty.call( + deepAcyclicTransfer?.[0] ?? {}, 'baseCalendar', + ), +}, { + error: '', + jsonError: '', + calendars: 10_000, + issues: 0, + firstBase: 'a-1', + firstBaseIdentity: true, + penultimateBase: 'a-9999', + transferCount: 10_000, + transferFirstBaseId: 'a-1', + transferContainsConvenienceLink: false, +}); + +const deepCycleRows = Array.from({ length: 10_000 }, (_, index) => ( + `%R\tc-${index}\tc-${(index + 1) % 10_000}\t` +)); +let deepCycle: ReturnType | undefined; +let deepCycleError = ''; +try { + deepCycle = readXerCalendars(parseXerTables(utf8([ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tUSD', + '%T\tCALENDAR', + '%F\tclndr_id\tbase_clndr_id\tclndr_data', + '%R\tt-0\tt-1\t', + '%R\tt-1\tt-2\t', + '%R\tt-2\tc-0\t', + ...deepCycleRows, + '%E', + ]))); +} catch (error) { + deepCycleError = error instanceof Error ? `${error.name}: ${error.message}` : String(error); +} +const deepCycleIssues = deepCycle?.issues.filter( + issue => issue.code === 'XER_CALENDAR_BASE_CYCLE', +) ?? []; +let deepCycleSerializable = true; +try { + JSON.stringify(deepCycle?.calendars); +} catch { + deepCycleSerializable = false; +} +eq('15k diepe cyclus markeert alleen 10.000 echte leden en laat de tail veilig gekoppeld', { + error: deepCycleError, + cycleIssues: deepCycleIssues.length, + firstCycleId: deepCycleIssues[0]?.calendarId, + lastCycleId: deepCycleIssues.at(-1)?.calendarId, + tailCycleIssues: deepCycleIssues.filter(issue => issue.calendarId.startsWith('t-')).length, + tailLinks: ['t-0', 't-1', 't-2'].map(id => deepCycle?.byId.get(id)?.baseCalendar?.id), + cycleLink: deepCycle?.byId.get('c-0')?.baseCalendar?.id, + serializable: deepCycleSerializable, +}, { + error: '', + cycleIssues: 10_000, + firstCycleId: 'c-0', + lastCycleId: 'c-9999', + tailCycleIssues: 0, + tailLinks: ['t-1', 't-2', 'c-0'], + serializable: true, +}); + +// Breuk die dit vangt: XER-klokbanden alleen via de universele (a)/(b)/(b2)-regels laten lopen. +// De gewone enkelbandkalender `Basis` moet door de XER-eigen bronregel promoveren; `Volledige dag` +// promoveerde al door gedeelde regel (b2), terwijl de gesynthetiseerde lege `Afgeleid` dagmodus blijft. +eq('16 uurmodus-blast-radius is expliciet meetbaar vóór en na XER-regel c', tableCalendars.promotion, { + sharedRules: 1, + withXerClockRule: 2, +}); +eq('17 gewone expliciete XER-klokband promoveert door bronregel c', { + source: base?.hourModeSource, + workTime: base?.workTime?.byWeekday[1], +}, { + source: 'XER_CLOCK', + workTime: [{ start: 480, end: 720 }], +}); +eq('18 volledige dag blijft door de gedeelde b2-regel promoveren', { + source: tableCalendars.byId.get('3')?.hourModeSource, + workTime: tableCalendars.byId.get('3')?.workTime?.byWeekday[1], +}, { + source: 'SHARED', + workTime: [{ start: 0, end: 1440 }], +}); +eq('19 lege default zonder bronklokken blijft dagmodus', { + source: child?.hourModeSource, + workTime: child?.workTime, +}, {}); + +// Breuk die dit vangt: XER en P6XML voor dezelfde P6-werkweek elk een andere dagnummering, +// klokinterpretatie of bandcanonicalisatie laten gebruiken. +installDOMParser(); +const p6Calendar = new DOMParser().parseFromString( + '' + + 'Monday' + + '08:00:0012:00:00' + + '13:00:0017:00:00' + + '' + + 'Tuesday' + + '22:00:0006:00:00' + + '' + + '', + 'application/xml', +).documentElement; +const p6RawWeek = parseP6StandardWorkWeek(p6Calendar).rawByWeekday; +eq('20 P6XML levert vóór gedeelde canonicalisatie de letterlijk verwachte ruwe banden', p6RawWeek, { + 1: [{ start: 480, end: 720 }, { start: 780, end: 1020 }], + 2: [{ start: 1320, end: 360 }], +}); +const p6Week = canonicalizeBands(p6RawWeek).bands; +eq('21 equivalente XER- en P6XML-werkweken leveren dezelfde canonieke banden', decoded.bands, p6Week); + +// ── 22 — WEEKEND-KLEMHERSTEL (etappe 7b-1), volledig corpusloos ──────────────────────────────── +// Een P6-schrijver die een aaneengesloten vrij blok op de MA-VR-as klemt levert de zaterdagen af op +// de vrijdag ervóór en de zondagen op de maandag erná. De poort daarvoor is per RECORD (zie +// `weekendClampTarget`/`hasWeekendClampEvidence`): sprong over het weekend, of een aangrenzend +// duplicaat mét blokvervolg aan de binnenzijde. 22a/22b zijn de positieve gevallen, 22c–22e de +// negatieve — die laatste drie zijn de false positives die een kalenderBREDE poort wél zou maken. +function exceptionBlock(serials: readonly number[]): string { + return structuredRecord('0', 'Exceptions', '', serials.map( + (serial, index) => structuredRecord('0', String(index), `d|${serial}`, []), + )); +} +function calendarData(p6Days: readonly number[], serials: readonly number[]): string { + return structuredRecord('0', 'CalendarData', '', [ + structuredRecord('0', 'DaysOfWeek', '', p6Days.map(day => structuredRecord('0', String(day), '', [ + structuredRecord('0', '0', 's|08:00|f|12:00', []), + structuredRecord('0', '1', 's|13:00|f|17:00', []), + ]))), + exceptionBlock(serials), + ]); +} +const holidayDates = (data: string): string[] => + decodeXerCalendarData(data).holidays.map(holiday => holiday.startDate).sort(); +// P6-dagnummers: 1=zo, 2=ma, 3=di, 4=wo, 5=do, 6=vr, 7=za. +const FRIDAY_OFF_WEEK = [1, 2, 3, 4, 5, 7]; // ma-do + za + zo werkend, vrijdag vrij (vorm 842) +const MON_FRI_WEEK = [2, 3, 4, 5, 6]; // gewone ma-vr-kalender +const SEVEN_DAY_WEEK = [1, 2, 3, 4, 5, 6, 7]; // alle dagen werken + +// 22a — de LETTERLIJKE recordreeks van blok 2009-11-26 … 2009-12-05 uit kalender 842 van +// `rehab-2.xer`: 40143=2009-11-26 (do) … 40151=2009-12-04 (vr), met duplicaten op 11-27 (vr), +// 11-30 (ma) en 12-04 (vr). De zaterdag ná het blok (12-05) komt uitsluitend uit het duplicaat op +// de laatste vrijdag — het blok loopt aan de binnenzijde door (12-03 is een uitzondering). +const CLAMPED_BLOCK = [40143, 40144, 40144, 40147, 40147, 40148, 40149, 40150, 40151, 40151]; +const clamped = decodeXerCalendarData(calendarData(FRIDAY_OFF_WEEK, CLAMPED_BLOCK)); +eq('22a het geklemde blok wordt tot tien aaneengesloten vrije dagen hersteld', + clamped.holidays.map(holiday => holiday.startDate).sort(), [ + '2009-11-26', '2009-11-27', '2009-11-28', '2009-11-29', '2009-11-30', + '2009-12-01', '2009-12-02', '2009-12-03', '2009-12-04', '2009-12-05', + ]); +eq('22a een herstelde dag telt niet ook nog als virtuele P6-niet-werkdag', + clamped.p6NonWorkPenaltyDates, []); +// Eindreview bevinding 7: de reconstructie laat een spoor achter — één herstelcode per kalender, +// die via `readXerCalendars` als `XER_CALENDAR_WEEKEND_CLAMP_RECONSTRUCTED` (RECOVERED) in de +// kalenderbevindingen en dus in de openingsmelding meetelt. De gereconstrueerde dag draagt de +// Engelse naam die ook de andere lezers gebruiken (bevinding 8: geen Nederlands in het IFC). +eq('22a het herstel laat een herstelcode achter', clamped.recoveries.includes('WEEKEND_CLAMP_RECONSTRUCTED'), true); +eq('22a de gereconstrueerde dag is als zodanig benoemd', + clamped.holidays.find(holiday => holiday.startDate === '2009-12-05')?.name, + 'Calendar exception (weekend reconstruction)'); + +// 22b — blok 2009-09-18 … 2009-09-24 van dezelfde kalender: de openings-VRIJDAG draagt hier géén +// duplicaat (40074=2009-09-18 staat er één keer). Die zaterdag komt dus niet uit het duplicaat maar +// uit de SPRONG: vrijdag 09-18 en maandag 09-21 zijn allebei uitzondering, drie kalenderdagen uit +// elkaar, met een werkend weekend ertussen dat niet is opgeslagen. Zonder deze tweede bewijsvorm +// zou 2009-09-19 ontbreken — en dat is een van de tien dagen die de set-cover onafhankelijk eist. +const JUMP_BLOCK = [40074, 40077, 40077, 40078, 40079, 40080]; +eq('22b de sprong vrijdag→maandag herstelt het weekend ook zonder duplicaat op de vrijdag', + holidayDates(calendarData(FRIDAY_OFF_WEEK, JUMP_BLOCK)), [ + '2009-09-18', '2009-09-19', '2009-09-20', '2009-09-21', + '2009-09-22', '2009-09-23', '2009-09-24', + ]); + +// 22c — NEGATIEF: een op zichzelf staand dubbel vrijdagrecord op een 7-daagse kalender. Dit is de +// gewone P6-invoerfout waar de decoder al een `DUPLICATE_EXCEPTION`-herstel voor kent; er is geen +// blok en dus geen bewijs. Een kalenderbrede poort maakte hier stil zaterdag 2009-12-05 vrij. +eq('22c een los dubbel vrijdagrecord maakt geen zaterdag vrij', + holidayDates(calendarData(SEVEN_DAY_WEEK, [40151, 40151])), ['2009-12-04']); +eq('22c zonder herstel geen herstelcode', + decodeXerCalendarData(calendarData(SEVEN_DAY_WEEK, [40151, 40151])).recoveries.includes('WEEKEND_CLAMP_RECONSTRUCTED'), false); + +// 22d — NEGATIEF: een duplicaat ELDERS (woensdag 2009-12-02, geen weekendbuur) mag de betekenis van +// twee losse vrijdag-feestdagen verderop niet veranderen. Een kalenderbrede poort maakte hier +// 2009-12-05 én 2009-12-12 vrij. +eq('22d een duplicaat elders laat losse vrijdagrecords ongemoeid', + holidayDates(calendarData(FRIDAY_OFF_WEEK, [40149, 40149, 40151, 40158])), + ['2009-12-02', '2009-12-04', '2009-12-11']); + +// 22f — NEGATIEF (restlek uit de her-check): twee LOSSE feestdagen op een za-do-kalender die +// toevallig drie dagen uit elkaar liggen — vrijdag 2009-12-04 en maandag 2009-12-07. Op zo'n +// kalender is elke vrijdag-uitzondering per definitie redundant, dus zonder de meerdaags-blok-eis +// én de blokvervolg-eis zou de sprongvorm hier zaterdag 12-05 half vrijmaken (en zondag 12-06 niet). +eq('22f twee losse feestdagen drie dagen uit elkaar vormen geen geklemd blok', + holidayDates(calendarData(FRIDAY_OFF_WEEK, [40151, 40154])), ['2009-12-04', '2009-12-07']); + +// 22g — NEGATIEF (restlek uit de her-check): een tweedaags feestdagblok (do 2009-12-03 + vr 12-04) +// op een 7-daagse kalender, waarbij de vrijdag per ongeluk dubbel staat. Het blokvervolg aan de +// afgekeerde zijde is er wél (de donderdag), dus alleen de meerdaags-blok-eis houdt dit tegen. +eq('22g een tweedaags blok met een dubbel vrijdagrecord maakt geen zaterdag vrij', + holidayDates(calendarData(SEVEN_DAY_WEEK, [40150, 40151, 40151])), ['2009-12-03', '2009-12-04']); + +// 22e — NEGATIEF: dezelfde geklemde reeks op een gewone ma-vr-kalender. Het klemdoel is daar zelf al +// niet-werkend, dus herstellen zou niets veranderen en gebeurt niet. +eq('22e op een ma-vr-kalender wordt er niets hersteld', + holidayDates(calendarData(MON_FRI_WEEK, CLAMPED_BLOCK)), [ + '2009-11-26', '2009-11-27', '2009-11-30', '2009-12-01', '2009-12-02', '2009-12-03', '2009-12-04', + ]); +eq('22e op een ma-vr-kalender blijven de aangrenzende duplicaten P6-niet-werkdagen', + decodeXerCalendarData(calendarData(MON_FRI_WEEK, CLAMPED_BLOCK)).p6NonWorkPenaltyDates, + ['2009-11-27', '2009-11-30', '2009-12-04']); +eq('22e op een ma-vr-kalender geen herstelcode', + decodeXerCalendarData(calendarData(MON_FRI_WEEK, CLAMPED_BLOCK)).recoveries.includes('WEEKEND_CLAMP_RECONSTRUCTED'), false); + +if (diffs.length === 0) { + console.log(`OK xer-calendar-data: ${checks} checks groen`); + process.exit(0); +} + +console.log(`XX xer-calendar-data: ${diffs.length} afwijking(en) van ${checks}`); +for (const diff of diffs) console.log(` - ${diff}`); +process.exit(1); diff --git a/tests/planning/check-xer-chunk-boundary.ts b/tests/planning/check-xer-chunk-boundary.ts new file mode 100644 index 000000000..2e8f4fd19 --- /dev/null +++ b/tests/planning/check-xer-chunk-boundary.ts @@ -0,0 +1,79 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = fileURLToPath(new URL('.', import.meta.url)); +const root = join(here, '..', '..'); +const srcRoot = join(root, 'src'); +const allowed = join('src', 'services', 'formatRegistry.ts'); +// Alleen de parsermap is lazy. Het bytearchief is een algemene service omdat +// de IFC-reader/-writer die zonder de parserchunk moet kunnen gebruiken. +const marker = '@/services/xer/'; +const diffs: string[] = []; +let dynamicImportFound = false; + +function walk(directory: string, files: string[] = []): string[] { + for (const entry of readdirSync(directory)) { + const path = join(directory, entry); + if (statSync(path).isDirectory()) walk(path, files); + else if (/\.(ts|tsx)$/.test(entry)) files.push(path); + } + return files; +} + +for (const file of walk(srcRoot)) { + const rel = relative(root, file); + for (const [index, line] of readFileSync(file, 'utf8').split('\n').entries()) { + if (!line.includes(marker)) continue; + if (rel === allowed && line.includes('await import(')) dynamicImportFound = true; + else diffs.push(`${rel}:${index + 1} bevat een statische XER-import buiten de lazy registryrand`); + } +} +if (!dynamicImportFound) diffs.push('de lazy XER-import ontbreekt in formatRegistry.ts'); +const vite = readFileSync(join(root, 'vite.config.ts'), 'utf8'); +if (!vite.includes("return 'xer-reader'")) diffs.push('vite.config.ts mist de xer-reader manual chunk'); +const archiveModule = join(srcRoot, 'services', 'xerSourceArchive.ts'); +try { + statSync(archiveModule); +} catch { + diffs.push('xerSourceArchive.ts ontbreekt buiten de lazy XER-parsermap'); +} +try { + statSync(join(srcRoot, 'services', 'xer', 'xerSourceArchive.ts')); + diffs.push('xerSourceArchive.ts staat nog in de lazy XER-parsermap'); +} catch { + // Verwacht: de algemene archiefservice mag de parserchunk niet vergroten. +} + +// De productielezer mag P6's opgeslagen rekenantwoord nooit als invoer gebruiken. Houd de +// expliciete §4-verbodslijst hier statisch dicht: ook een toekomstige, ogenschijnlijk handige +// veldtoegang maakt deze poort rood. +const reader = readFileSync(join(srcRoot, 'services', 'xer', 'xerReader.ts'), 'utf8'); +// `PROJECT.plan_end_date` is een P6-invoer voor de expliciete X5-optie +// `useProjectEndDateForFloat`; dezelfde naam in TASK is daarentegen opgeslagen rekenuitvoer. +// Haal uitsluitend de aantoonbaar project-scoped toegang uit de statische firewallscan, zodat de +// poort de productregel bewaakt zonder twee verschillende tabellen op veldnaam te verwarren. +const readerWithoutComments = reader.replace(/\/\*[\s\S]*?\*\/|\/\/.*$/gm, ''); +const readerWithoutAllowedProjectInput = readerWithoutComments.split('projectRow.cells.plan_end_date').join(''); +const forbiddenTaskFields = [ + 'early_', 'late_', 'restart_date', 'reend_date', + 'rem_late_start_date', 'rem_late_end_date', + 'total_float_hr_cnt', 'free_float_hr_cnt', + 'driving_path_flag', 'float_path', 'float_path_order', + 'external_early_start_date', 'external_late_end_date', + 'old_restart_date', 'old_reend_date', 'old_remain_drtn_hr_cnt', + 'crt_path_num', 'critical_drtn_hr_cnt', 'act_drtn_hr_cnt', + 'plan_start_date', 'plan_end_date', +] as const; +for (const field of forbiddenTaskFields) { + if (readerWithoutAllowedProjectInput.includes(field)) { + diffs.push(`xerReader.ts bevat verboden P6-uitvoerveld ${field}`); + } +} + +if (diffs.length > 0) { + console.error(`XER-chunk-boundary: ${diffs.length} afwijking(en)`); + for (const diff of diffs) console.error(`XX ${diff}`); + process.exit(1); +} +console.log('OK XER-chunk-boundary: lazy registry-import, eigen chunk en productveld-whitelist'); diff --git a/tests/planning/check-xer-completed-cp-phys-window.ts b/tests/planning/check-xer-completed-cp-phys-window.ts new file mode 100644 index 000000000..eed8c6811 --- /dev/null +++ b/tests/planning/check-xer-completed-cp-phys-window.ts @@ -0,0 +1,338 @@ +import { isMultiDocumentImport, type ImportResult } from '@/services/importTypes'; +import { solveProject } from '@/engine/scheduler/solveProject'; +import { readXER } from '@/services/xer/xerReader'; +import { parseInstant } from '@/utils/dateUtils'; +import { scanXerGroundTruth } from './xerGroundTruth'; +import { + explainP6CompletedDataDateWindow, + type P6CompletedWindowReason, +} from '@/utils/p6CompletedTargetWindow'; +import type { Task } from '@/types/task'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +const diffs: string[] = []; +let checks = 0; + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +const calendarData = '(0||CalendarData()( (0||DaysOfWeek()( (0||1()( (0||0(s|07:00|f|15:00)()))) (0||2()( (0||0(s|07:00|f|15:00)()))) (0||3()( (0||0(s|07:00|f|15:00)()))) (0||4()( (0||0(s|07:00|f|15:00)()))) (0||5()( (0||0(s|07:00|f|15:00)()))) (0||6()()) (0||7()()))) (0||Exceptions()())))'; + +function fixtureBytes(): Uint8Array { + return new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-08-17\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tproj_id\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + `%R\tC1\tVroege ploeg\tP1\tCA_Project\t8\t40\t${calendarData}`, + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date\tplan_end_date\trem_target_link_flag', + '%R\tP1\tCompleted CP_Phys\tC1\t2026-08-17 10:00\t2026-08-03 07:00\t2026-08-20 15:00\tY', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\tcomplete_pct_type\tcomplete_pct\tphys_complete_pct\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date\tact_start_date\tact_end_date\tsuspend_date\tresume_date', + '%R\tC\tP1\tC1\tCPHYS\tCompleted physical\tTT_Task\tDT_FixedDUR2\tTK_Complete\tCP_Phys\t0\t100\t8\t0\t2026-08-04 07:00\t2026-08-04 15:00\t2026-08-04 07:00\t2026-08-04 15:00\t\t', + '%E', + ].join('\n')); +} + +function importedFixture(): ImportResult { + const imported = readXER(fixtureBytes()); + if (isMultiDocumentImport(imported)) throw new Error('CP_Phys-fixture moet precies één project openen'); + return imported; +} + +function taskOf(imported: ImportResult): Task { + const task = imported.tasks.find(candidate => candidate.id === 'C'); + if (!task) throw new Error('CP_Phys-fixture mist taak C'); + return task; +} + +function decisionOf( + mutate?: (imported: ImportResult, task: Task) => void, +): { decision: { eligible: boolean; reason: P6CompletedWindowReason }; task: Task; imported: ImportResult } { + const imported = structuredClone(importedFixture()); + const task = taskOf(imported); + mutate?.(imported, task); + const dataDate = imported.project.statusDate ? parseInstant(imported.project.statusDate) : null; + return { + decision: explainP6CompletedDataDateWindow(task, dataDate, imported.project.schedulingOptions), + task, + imported, + }; +} + +function solveFixture(mutate?: (imported: ImportResult, task: Task) => void) { + const { imported, task, decision } = decisionOf(mutate); + const result = solveProject({ + tasks: imported.tasks, + sequences: imported.sequences, + calendar: imported.calendar, + calendars: imported.resourceCalendars ?? [], + dataDate: imported.project.statusDate, + progressMode: imported.project.progressMode, + schedulingOptions: imported.project.schedulingOptions, + projectStartDate: imported.project.startDate, + projectEndDate: imported.project.endDate, + }); + if (result.error) throw new Error(result.error); + const scheduled = result.tasks.get(task.id); + const trace = result.backwardFloatTrace?.byTaskId[task.id]; + return { + source: { + p6Source: imported.project.schedulingOptions?.p6Source, + p6UseRemainingStartForProgress: imported.project.schedulingOptions?.p6UseRemainingStartForProgress, + p6CompletePctType: task.p6CompletePctType, + p6DurationType: task.p6DurationType, + p6ActivityType: task.p6ActivityType, + p6ProjectId: task.p6ProjectId, + p6TaskId: task.p6TaskId, + p6ExplicitTargetWindow: task.p6ExplicitTargetWindow, + actualFinish: task.time.actualFinish, + completion: task.time.completion, + }, + decision, + scheduled: scheduled ? { + earlyStart: scheduled.earlyStart, + earlyFinish: scheduled.earlyFinish, + lateStart: scheduled.lateStart, + lateFinish: scheduled.lateFinish, + } : null, + trace: trace ? { + projectEndSource: result.backwardFloatTrace?.projectEndSource, + completedWindow: trace.completedWindow, + } : null, + }; +} + +const baselinePhysical = solveFixture(); +eq('CP_Phys bewaart de bronvorm maar opent zonder discriminator geen completed-windowroute', + baselinePhysical.decision, { eligible: false, reason: 'wrongCompletePctType' }); +eq('CP_Phys zonder route schrijft geen completed-display als projecteinde in de backward pass', + baselinePhysical.trace?.projectEndSource, 'maxEarlyFinish'); +eq('CP_Phys zonder route behoudt een niet-invers vroeg venster', + !!baselinePhysical.scheduled + && baselinePhysical.scheduled.earlyStart <= baselinePhysical.scheduled.earlyFinish, true); + +const negativeCases: Array<{ + label: string; + mutate: (imported: ImportResult, task: Task) => void; + reason: P6CompletedWindowReason; +}> = [ + { + label: 'TT_Rsrc blijft buiten de CP_Phys-uitbreiding', + mutate: (_imported, task) => { task.p6ActivityType = 'TT_Rsrc'; }, + reason: 'wrongActivityType', + }, + { + label: 'andere duration type blijft buiten de CP_Phys-uitbreiding', + mutate: (_imported, task) => { task.p6DurationType = 'DT_FixedDrtn'; }, + reason: 'wrongDurationType', + }, + { + label: 'in-progress blijft buiten de CP_Phys-uitbreiding', + mutate: (_imported, task) => { task.time.completion = 0.5; }, + reason: 'notCompleted', + }, + { + label: 'ontbrekende actualFinish blijft fail-closed', + mutate: (_imported, task) => { task.time.actualFinish = undefined; }, + reason: 'notCompleted', + }, + { + label: 'onparseerbare actualFinish blijft zonder suspend/resume fail-closed', + mutate: (_imported, task) => { task.time.actualFinish = 'geen-datum'; }, + reason: 'notCompleted', + }, + { + label: 'actualFinish ná de statusdatum blijft zonder suspend/resume fail-closed', + mutate: (_imported, task) => { task.time.actualFinish = '2026-08-17T10:01'; }, + reason: 'notCompleted', + }, + { + label: 'ontbrekende expliciete targetwindow blijft fail-closed', + mutate: (_imported, task) => { task.p6ExplicitTargetWindow = false; }, + reason: 'missingExplicitTargetWindow', + }, + { + label: 'ontbrekende taakprovenance blijft fail-closed', + mutate: (_imported, task) => { task.p6TaskId = undefined; }, + reason: 'missingTaskProvenance', + }, + { + label: 'ontbrekende projectprovenance blijft fail-closed', + mutate: (_imported, task) => { task.p6ProjectId = undefined; }, + reason: 'missingProjectProvenance', + }, + { + label: 'ontbrekende statusdatum blijft fail-closed', + mutate: imported => { imported.project.statusDate = undefined; }, + reason: 'missingDataDate', + }, + { + label: 'suspend/resume blijft buiten de CP_Phys-uitbreiding', + mutate: (_imported, task) => { task.p6SuspendResume = true; }, + reason: 'hasSuspendResume', + }, + { + label: 'nulduurmijlpaal blijft buiten de CP_Phys-uitbreiding', + mutate: (_imported, task) => { + task.isMilestone = true; + task.time.scheduleDuration = 0; + }, + reason: 'zeroDurationMilestone', + }, +]; + +for (const testCase of negativeCases) { + eq(`CP_Phys fail-closed: ${testCase.label}`, decisionOf(testCase.mutate).decision, { + eligible: false, + reason: testCase.reason, + }); +} +eq('CP_Phys: actualFinish precies op de statusdatum is syntactisch en temporeel geldig, maar de route blijft gesloten', + decisionOf((_imported, task) => { task.time.actualFinish = '2026-08-17T10:00'; }).decision, + { eligible: false, reason: 'wrongCompletePctType' }); + +function listXerFiles(root: string): string[] { + const paths: string[] = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const path = join(root, entry.name); + if (entry.isDirectory()) paths.push(...listXerFiles(path)); + else if (entry.isFile() && entry.name.toLowerCase().endsWith('.xer')) paths.push(path); + } + return paths.sort(); +} + +function canonicalMinute(value: string | undefined): string | null { + if (!value) return null; + return value.length === 10 ? `${value}T00:00` : value.slice(0, 16); +} + +/** + * Corpusbewijs voor de CP_Phys-grens. De tien bronvormen die de eerdere kandidaat toeliet zijn + * in P6 niet uniform: acht hebben een gelijk vroeg statuspunt, twee hebben geen early/late-orakel. + * Zonder een toegestane broninvoer die dat onderscheid maakt blijft de route dus gesloten. De + * werkelijke productpopulatie is nul en kan geen nieuwe early/late-kalenderinversie veroorzaken. + */ +const corpusRoot = process.env.OPS_XER_CORPUS; +if (!corpusRoot) { + console.log('OK XER CP_Phys corpusinversie: corpus niet aanwezig (OPS_XER_CORPUS) — overgeslagen'); +} else if (!existsSync(corpusRoot)) { + diffs.push('XER CP_Phys corpusinversie: OPS_XER_CORPUS wijst niet naar een bestaande corpusmap'); +} else { + const sourceForms: Array<{ + file: string; + projectId: string; + taskId: string; + taskCode: string; + truth: [string | null, string | null, string | null, string | null]; + }> = []; + const activeProductCases: Array<{ + file: string; + projectId: string; + taskId: string; + product: [string | null, string | null, string | null, string | null]; + truth: [string | null, string | null, string | null, string |null]; + }> = []; + for (const path of listXerFiles(corpusRoot)) { + const bytes = readFileSync(path); + const truth = scanXerGroundTruth(bytes); + if (truth.errors.length > 0) continue; + const truthByTask = new Map(truth.tasks.map(task => [`${task.projectId}\u0000${task.taskId}`, task] as const)); + // De brede crawl bevat bewust corrupte parserfixtures. Zij dragen geen CP_Phys-orakel en + // horen bij de reader-foutpadbatterij, niet bij deze productinvariantselectie. + let opened: ImportResult | ReturnType; + try { + opened = readXER(bytes); + } catch { + continue; + } + const imports = isMultiDocumentImport(opened) ? opened.taskProjects.map(project => project.result) : [opened]; + for (const imported of imports) { + const dataDate = imported.project.statusDate ? parseInstant(imported.project.statusDate) : null; + const physical = imported.tasks.filter(task => task.p6CompletePctType === 'CP_Phys'); + const sourceForm = physical.filter(task => { + if (task.time.completion < 1 || !task.time.actualFinish) return false; + const asDuration = structuredClone(task); + asDuration.p6CompletePctType = 'CP_Drtn'; + return explainP6CompletedDataDateWindow(asDuration, dataDate, imported.project.schedulingOptions).eligible; + }); + const sourceFormIds = new Set(sourceForm.map(task => task.id)); + const eligible = physical.filter(task => + explainP6CompletedDataDateWindow(task, dataDate, imported.project.schedulingOptions).eligible, + ); + if (sourceForm.length === 0 && eligible.length === 0) continue; + const result = solveProject({ + tasks: imported.tasks, + sequences: imported.sequences, + calendar: imported.calendar, + calendars: imported.resourceCalendars ?? [], + dataDate: imported.project.statusDate, + progressMode: imported.project.progressMode, + schedulingOptions: imported.project.schedulingOptions, + projectStartDate: imported.project.startDate, + projectEndDate: imported.project.endDate, + }); + if (result.error) throw new Error(`CP_Phys-corpussolve ${path}/${imported.project.id}: ${result.error}`); + for (const task of sourceForm) { + const oracle = truthByTask.get(`${imported.project.id}\u0000${task.id}`); + if (!oracle) { + diffs.push(`CP_Phys-corpus: ontbrekende orakel-taak ${relative(corpusRoot, path)}/${imported.project.id}/${task.id}`); + continue; + } + sourceForms.push({ + file: relative(corpusRoot, path).split('\\').join('/'), + projectId: imported.project.id, + taskId: task.id, + taskCode: task.wbsCode, + truth: [oracle.axes.es as string | null, oracle.axes.ef as string | null, + oracle.axes.ls as string | null, oracle.axes.lf as string | null], + }); + } + for (const task of eligible) { + if (!sourceFormIds.has(task.id)) continue; + const oracle = truthByTask.get(`${imported.project.id}\u0000${task.id}`); + const product = result.tasks.get(task.id); + if (!oracle || !product) { + diffs.push(`CP_Phys-corpus: ontbrekende product/orakel-taak ${relative(corpusRoot, path)}/${imported.project.id}/${task.id}`); + continue; + } + activeProductCases.push({ + file: relative(corpusRoot, path).split('\\').join('/'), projectId: imported.project.id, taskId: task.id, + product: [canonicalMinute(product.earlyStart), canonicalMinute(product.earlyFinish), canonicalMinute(product.lateStart), canonicalMinute(product.lateFinish)], + truth: [oracle.axes.es as string | null, oracle.axes.ef as string | null, oracle.axes.ls as string | null, oracle.axes.lf as string | null], + }); + } + } + } + eq('CP_Phys corpus: exact tien eerdere kandidaatvormen zijn onderzocht', sourceForms.length, 10); + eq('CP_Phys corpus: acht kandidaatvormen hebben een gelijk P6 vroeg statuspunt', + sourceForms.filter(item => item.truth[0] !== null && item.truth[0] === item.truth[1]).length, 8); + eq('CP_Phys corpus: twee kandidaatvormen hebben geen P6 early/late-orakel', + sourceForms.filter(item => item.truth[0] === null && item.truth[1] === null + && item.truth[2] === null && item.truth[3] === null).length, 2); + eq('CP_Phys corpus: de werkelijk geactiveerde productpopulatie blijft fail-closed', activeProductCases.length, 0); + for (const item of activeProductCases) { + const [productEs, productEf, productLs, productLf] = item.product; + const [truthEs, truthEf, truthLs, truthLf] = item.truth; + eq(`CP_Phys corpus ${item.file}/${item.projectId}/${item.taskId}: product ES<=EF`, productEs !== null && productEf !== null && productEs <= productEf, true); + eq(`CP_Phys corpus ${item.file}/${item.projectId}/${item.taskId}: product LS<=LF`, productLs !== null && productLf !== null && productLs <= productLf, true); + if (truthEs === null || truthEf === null || truthLs === null || truthLf === null) { + diffs.push(`CP_Phys corpus ${item.file}/${item.projectId}/${item.taskId}: route mag niet openen zonder volledig P6 early/late-orakel`); + continue; + } + eq(`CP_Phys corpus ${item.file}/${item.projectId}/${item.taskId}: P6 ES<=EF`, truthEs <= truthEf, true); + eq(`CP_Phys corpus ${item.file}/${item.projectId}/${item.taskId}: P6 LS<=LF`, truthLs <= truthLf, true); + } +} + +if (diffs.length > 0) { + console.error(`XER completed CP_Phys window RED: ${diffs.length}/${checks} checks rood`); + for (const diff of diffs) console.error(`XX ${diff}`); + process.exit(1); +} +console.log(`XER completed CP_Phys window GREEN: ${checks} checks groen`); diff --git a/tests/planning/check-xer-completed-late.ts b/tests/planning/check-xer-completed-late.ts new file mode 100644 index 000000000..60577c6d1 --- /dev/null +++ b/tests/planning/check-xer-completed-late.ts @@ -0,0 +1,379 @@ +/** + * Corpusloze regressie voor `p6CompletedLateFromRemainingWindow` (diagnose laag 1, klasse (i), + * rehab-2: 2.036 voltooide taken, 99,9% dekking). P6 zet een voltooide activiteit ook aan de late + * zijde neer als een taak met nul restduur op de statusdatum in plaats van op haar historische + * actual-venster: `LF = prevWorkInstant(LS)`, `LS` = de vroegste van de door haar opvolgers + * toegestane late finishen, geklemd op de statusdatum — waarbij de relatie-lag NIET meetelt tussen + * twee voltooide activiteiten maar WEL zolang de opvolger nog restwerk heeft. Zie + * `CPMSolver.backwardPass` (de `backwardActualPin.eligible`-tak) en `scheduleAnalysis.ts` + * (`pinLateToActualWindow`) voor de implementatie, en `docs/superpowers/plans/2026-08-20-plan-xer- + * p6-lezer.md` §5 (X-O7) voor het besluit. + * + * Ketting A→B→C→E (kalender zo–do 07:00–15:00 (P6-dagindex 1–5 = zondag t/m donderdag; 1 = zondag), 8 u/dag): + * - A, B: voltooid, ver vóór de statusdatum. A→B is FS met een NIET-nul lag (8 u) — de gemeten + * uitzondering zegt dat die lag NIET meetelt omdat B (de opvolger) zelf ook voltooid is. + * - B→C is FS met lag 16 u; C is NIET gestart (5 dagen) — hier telt de lag WEL mee, want C heeft + * nog restwerk. C→E (lag 0) verankert C's late zijde aan de mijlpaal E. + * - G: een geïsoleerde voltooide taak zonder relaties — de klem-op-de-statusdatum-vorm zonder + * opvolgerdruk. + * + * Mutatiebewijs (handmatig uitgevoerd, zie het baanrapport): de lag-uitzondering tussen twee + * voltooide activiteiten weghalen (`succWindowCompleted ? {...lagDays:0,...} : seq` vervangen door + * kaal `seq`) laat de eerste assertie hieronder ROOD zien (A.lateStart schuift 8 u terug t.o.v. + * B.lateStart); de `LF = prevWorkInstant(LS)`-regel weghalen (`progressCal.prevWorkInstant(ls)` + * vervangen door `ls` zelf) laat de derde assertie ROOD zien zodra `windowEs` op een bandgrens valt + * (G's geïsoleerde geval). + */ +import { solveProject } from '@/engine/scheduler/solveProject'; +import { CalendarEngine } from '@/engine/scheduler/CalendarEngine'; +import { isMultiDocumentImport, type ImportResult } from '@/services/importTypes'; +import { readXER } from '@/services/xer/xerReader'; +import { parseInstant } from '@/utils/dateUtils'; +import { explainP6CompletedDataDateWindow } from '@/utils/p6CompletedTargetWindow'; +import { + explainBackwardActualPinEligibility, + explainP6CompletedLateRemainingWindowEligibility, +} from '@/engine/scheduler/p6CompletedRouteTrace'; +import type { SchedulingOptions } from '@/types/project'; + +const diffs: string[] = []; +let checks = 0; +// Gemeten 2026-09-07 (her-review bevindingen 4/5). Let op: de fixturekalender heeft banden op +// P6-dagindex 1–5 = ZONDAG t/m donderdag (P6 telt 1 = zondag), dus 2026-11-08 (zondag) is hier een +// werkdag — één werkdag vóór SSA/FSA's 2026-11-09T07:00 (lag 0). SF landt op de finish-rand +// (LS == LF), FF via `nextWorkInstant` op de start-rand: bevinding 5, ongewijzigd gepind. +const LAG_PINS_START_SIDE = { + ssl: ['2026-11-08T07:00', '2026-11-05T15:00'], fsl: ['2026-11-08T07:00', '2026-11-05T15:00'], + ssp: ['2026-11-08T07:00', '2026-11-05T15:00'], fsp: ['2026-11-08T07:00', '2026-11-05T15:00'], +}; +const LAG_PINS_FINISH_SIDE = { sfl: ['2026-11-12T15:00', '2026-11-12T15:00'], ffl: ['2026-11-15T07:00', '2026-11-12T15:00'] }; + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} +function ok(label: string, condition: boolean, detail: string): void { + checks++; + if (!condition) diffs.push(`${label}: ${detail}`); +} + +// Kalender zo–do 07:00–15:00 (P6-dagindex 1–5 = zondag t/m donderdag; 1 = zondag), 8 u/dag — exact het patroon uit de andere completed-XER-fixtures +// (check-xer-completed-suspend-resume-window.ts, check-xer-backward-float-trace.ts). +const earlyShiftCalendar = '(0||CalendarData()( (0||DaysOfWeek()( (0||1()( (0||0(s|07:00|f|15:00)()))) (0||2()( (0||0(s|07:00|f|15:00)()))) (0||3()( (0||0(s|07:00|f|15:00)()))) (0||4()( (0||0(s|07:00|f|15:00)()))) (0||5()( (0||0(s|07:00|f|15:00)()))) (0||6()()) (0||7()()))) (0||Exceptions()())))'; + +function fixtureBytes(): Uint8Array { + return new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-09-10\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tproj_id\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + `%R\tC1\tVroege ploeg\tP1\tCA_Project\t8\t40\t${earlyShiftCalendar}`, + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date\tplan_end_date\trem_target_link_flag', + '%R\tP1\tCompleted late window\tC1\t2026-09-10 07:00\t2026-07-01 07:00\t2026-09-25 15:00\tY', + '%T\tSCHEDOPTIONS', + '%F\tproj_id\tsched_use_project_end_date_for_float', + '%R\tP1\tN', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\tcomplete_pct_type\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date\tact_start_date\tact_end_date', + '%R\tA\tP1\tC1\tA\tVoltooid A\tTT_Task\tDT_FixedDUR2\tTK_Complete\tCP_Drtn\t16\t0\t2026-08-03 07:00\t2026-08-04 15:00\t2026-08-03 07:00\t2026-08-04 15:00', + '%R\tB\tP1\tC1\tB\tVoltooid B\tTT_Task\tDT_FixedDUR2\tTK_Complete\tCP_Drtn\t16\t0\t2026-08-10 07:00\t2026-08-11 15:00\t2026-08-10 07:00\t2026-08-11 15:00', + '%R\tC\tP1\tC1\tC\tNiet gestart C\tTT_Task\tDT_FixedDUR\tTK_NotStart\tCP_Drtn\t40\t40\t2026-09-14 07:00\t2026-09-18 15:00\t\t', + // E is bewust GEEN mijlpaal: een verbonden open TT_FinMile is z'n eigen contracteindpunt + // (`p6FinishMilestoneBoundaryWindow`) en zou de klem hieronder juist onttrekken van L's + // projecteindedruk — een gewone taak volgt wel de normale projecteinde-backward-regel. + '%R\tE\tP1\tC1\tE\tOpvolger E\tTT_Task\tDT_FixedDUR\tTK_NotStart\tCP_Drtn\t8\t8\t2026-09-21 07:00\t2026-09-21 15:00\t\t', + // L: geïsoleerde, niet-gestarte taak ver in de toekomst — puur om het projecteinde (max vroege + // finish) ruim voorbij E's eigen vroege finish te tillen, zodat E/C/B/A allemaal reële, + // positieve speling krijgen en de klem op de statusdatum (hieronder) niet toevallig alles + // plat drukt — anders zou de lag-uitzondering tussen A en B onzichtbaar worden (beide + // waarden zouden toch al op de vloer landen, met of zonder lag). + '%R\tL\tP1\tC1\tL\tLange, losse taak L\tTT_Task\tDT_FixedDUR\tTK_NotStart\tCP_Drtn\t80\t80\t2026-11-02 07:00\t2026-11-13 15:00\t\t', + '%R\tG\tP1\tC1\tG\tVoltooid geïsoleerd G\tTT_Task\tDT_FixedDUR2\tTK_Complete\tCP_Drtn\t8\t0\t2026-07-06 07:00\t2026-07-06 15:00\t2026-07-06 07:00\t2026-07-06 15:00', + // Review-bevinding 3 (SS/SF-rekenfout): NS is een tweede, onafhankelijke open taak (dangling, + // krijgt via L dezelfde projecteinde-speling als C/E) met vier voltooide voorgangers — één per + // relatietype, allemaal lag 0. SSA/FSA delen dezelfde SS/FS-startanker (NS's eigen late start); + // SFA/FFA delen hetzelfde SF/FF-finishanker (NS's eigen late finish). Zonder de nulrestduur- + // conversie voor SS/SF (die vóór deze fix de volle taakduur van SSA/SFA dubbel optelde) wijken + // SSA/SFA af van hun FS/FF-tegenhanger; met de fix vallen ze exact samen. + '%R\tNS\tP1\tC1\tNS\tOnafhankelijke open opvolger NS\tTT_Task\tDT_FixedDUR\tTK_NotStart\tCP_Drtn\t40\t40\t2026-09-14 07:00\t2026-09-18 15:00\t\t', + '%R\tSSA\tP1\tC1\tSSA\tVoltooid via SS\tTT_Task\tDT_FixedDUR2\tTK_Complete\tCP_Drtn\t16\t0\t2026-08-03 07:00\t2026-08-04 15:00\t2026-08-03 07:00\t2026-08-04 15:00', + '%R\tFSA\tP1\tC1\tFSA\tVoltooid via FS\tTT_Task\tDT_FixedDUR2\tTK_Complete\tCP_Drtn\t16\t0\t2026-08-03 07:00\t2026-08-04 15:00\t2026-08-03 07:00\t2026-08-04 15:00', + '%R\tSFA\tP1\tC1\tSFA\tVoltooid via SF\tTT_Task\tDT_FixedDUR2\tTK_Complete\tCP_Drtn\t16\t0\t2026-08-03 07:00\t2026-08-04 15:00\t2026-08-03 07:00\t2026-08-04 15:00', + '%R\tFFA\tP1\tC1\tFFA\tVoltooid via FF\tTT_Task\tDT_FixedDUR2\tTK_Complete\tCP_Drtn\t16\t0\t2026-08-03 07:00\t2026-08-04 15:00\t2026-08-03 07:00\t2026-08-04 15:00', + // Review-bevinding 4 (poortdivergentie): NX is `TK_Complete` ZONDER `act_end_date` — de lezer + // zet daarvoor wél `completion = 1` (status_code) maar GEEN `time.actualFinish`. Zo'n taak komt + // dus wel door `explainP6CompletedDataDateWindow` (de CP_Drtn-route eist geen actual-finish) + // maar NIET door `explainBackwardActualPinEligibility`. Zonder de gedeelde poort liep de + // weergavelaag hier vooruit op een solvertak die niet draaide. + '%R\tNX\tP1\tC1\tNX\tVoltooid zonder act_end_date NX\tTT_Task\tDT_FixedDUR2\tTK_Complete\tCP_Drtn\t16\t0\t2026-08-03 07:00\t2026-08-04 15:00\t2026-08-03 07:00\t', + // Her-review bevindingen 4/5 (lag op SS/SF): NL is een derde onafhankelijke open taak met zes + // voltooide voorgangers — SS/FS/SF/FF met 8 u lag (SSL/FSL/SFL/FFL), en SS/FS met een + // PROCENTUELE lag (SSP/FSP, 50% van 16 u = 8 u, gezet ná de import want XER kent geen + // procentlag). Vóór de fix verdween de procentlag op SS/SF stil (de nulrestduur-kloon zette de + // voorgangerduur op 0 en de lag werd daartegen opgelost); FS/FF hielden 'm wél. + '%R\tNL\tP1\tC1\tNL\tOnafhankelijke open opvolger NL\tTT_Task\tDT_FixedDUR\tTK_NotStart\tCP_Drtn\t40\t40\t2026-09-14 07:00\t2026-09-18 15:00\t\t', + '%R\tSSL\tP1\tC1\tSSL\tVoltooid via SS+lag\tTT_Task\tDT_FixedDUR2\tTK_Complete\tCP_Drtn\t16\t0\t2026-08-03 07:00\t2026-08-04 15:00\t2026-08-03 07:00\t2026-08-04 15:00', + '%R\tFSL\tP1\tC1\tFSL\tVoltooid via FS+lag\tTT_Task\tDT_FixedDUR2\tTK_Complete\tCP_Drtn\t16\t0\t2026-08-03 07:00\t2026-08-04 15:00\t2026-08-03 07:00\t2026-08-04 15:00', + '%R\tSFL\tP1\tC1\tSFL\tVoltooid via SF+lag\tTT_Task\tDT_FixedDUR2\tTK_Complete\tCP_Drtn\t16\t0\t2026-08-03 07:00\t2026-08-04 15:00\t2026-08-03 07:00\t2026-08-04 15:00', + '%R\tFFL\tP1\tC1\tFFL\tVoltooid via FF+lag\tTT_Task\tDT_FixedDUR2\tTK_Complete\tCP_Drtn\t16\t0\t2026-08-03 07:00\t2026-08-04 15:00\t2026-08-03 07:00\t2026-08-04 15:00', + '%R\tSSP\tP1\tC1\tSSP\tVoltooid via SS+procentlag\tTT_Task\tDT_FixedDUR2\tTK_Complete\tCP_Drtn\t16\t0\t2026-08-03 07:00\t2026-08-04 15:00\t2026-08-03 07:00\t2026-08-04 15:00', + '%R\tFSP\tP1\tC1\tFSP\tVoltooid via FS+procentlag\tTT_Task\tDT_FixedDUR2\tTK_Complete\tCP_Drtn\t16\t0\t2026-08-03 07:00\t2026-08-04 15:00\t2026-08-03 07:00\t2026-08-04 15:00', + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%R\tR-AB\tB\tA\tP1\tP1\tPR_FS\t8', + '%R\tR-BC\tC\tB\tP1\tP1\tPR_FS\t16', + '%R\tR-CE\tE\tC\tP1\tP1\tPR_FS\t0', + '%R\tR-SSA-NS\tNS\tSSA\tP1\tP1\tPR_SS\t0', + '%R\tR-FSA-NS\tNS\tFSA\tP1\tP1\tPR_FS\t0', + '%R\tR-SFA-NS\tNS\tSFA\tP1\tP1\tPR_SF\t0', + '%R\tR-FFA-NS\tNS\tFFA\tP1\tP1\tPR_FF\t0', + '%R\tR-SSL-NL\tNL\tSSL\tP1\tP1\tPR_SS\t8', + '%R\tR-FSL-NL\tNL\tFSL\tP1\tP1\tPR_FS\t8', + '%R\tR-SFL-NL\tNL\tSFL\tP1\tP1\tPR_SF\t8', + '%R\tR-FFL-NL\tNL\tFFL\tP1\tP1\tPR_FF\t8', + '%R\tR-SSP-NL\tNL\tSSP\tP1\tP1\tPR_SS\t0', + '%R\tR-FSP-NL\tNL\tFSP\tP1\tP1\tPR_FS\t0', + '%E', + ].join('\n')); +} + +function importFixture(): ImportResult { + const opened = readXER(fixtureBytes()); + if (isMultiDocumentImport(opened)) throw new Error('completed-late fixture moet precies één project openen'); + return opened; +} + +function solveWith(overrides?: Partial) { + const imported = structuredClone(importFixture()); + if (overrides) { + imported.project.schedulingOptions = { ...imported.project.schedulingOptions, ...overrides }; + } + // Procentlag bestaat niet in XER (TASKPRED kent alleen `lag_hr_cnt`); een gebruiker/MCP/IFC kan + // hem op een XER-project wél zetten. Hier ná de import op de SSP/FSP-relaties: 50% van 16 u. + for (const seq of imported.sequences) { + if (seq.predecessorId === 'SSP' || seq.predecessorId === 'FSP') { + seq.lagDays = 0; + seq.lagMinutes = undefined; + seq.lagPercent = 50; + } + } + const result = solveProject({ + tasks: imported.tasks, + sequences: imported.sequences, + calendar: imported.calendar, + calendars: imported.resourceCalendars ?? [], + dataDate: imported.project.statusDate, + progressMode: imported.project.progressMode, + schedulingOptions: imported.project.schedulingOptions, + projectStartDate: imported.project.startDate, + projectEndDate: imported.project.endDate, + }); + if (result.error) throw new Error(result.error); + return { imported, result }; +} + +// ── Sanity: alle drie voltooide taken (A, B, G) komen door de completedWindow-poort — anders test +// deze fixture niet wat hij claimt te testen. ────────────────────────────────────────────────── +{ + const { imported } = solveWith(); + const dataDate = imported.project.statusDate ? parseInstant(imported.project.statusDate) : null; + for (const id of ['A', 'B', 'G']) { + const task = imported.tasks.find(t => t.id === id); + if (!task) throw new Error(`fixture mist taak ${id}`); + const decision = explainP6CompletedDataDateWindow(task, dataDate, imported.project.schedulingOptions); + eq(`completed-late fixture: taak ${id} zit in de completedWindow-poort`, decision, { eligible: true, reason: 'eligible' }); + } +} + +// ── Vlag AAN (XER-default): de gemeten P6-regel. ──────────────────────────────────────────────── +{ + const { imported, result } = solveWith(); + const cal = new CalendarEngine(imported.calendar); + const a = result.tasks.get('A')!; + const b = result.tasks.get('B')!; + const c = result.tasks.get('C')!; + const g = result.tasks.get('G')!; + const dataDate = parseInstant(imported.project.statusDate!); + + // Lag vervalt tussen twee voltooide activiteiten (A→B, lag 8 u, B zelf ook voltooid): A's late + // start volgt B's late start EXACT, ondanks de niet-nul lag op de relatie. + eq('completed-late AAN: lag tussen twee voltooide activiteiten vervalt (A.lateStart = B.lateStart)', + a.lateStart, b.lateStart); + + // Lag telt WEL mee zolang de opvolger nog restwerk heeft (B→C, lag 16 u, C niet voltooid): + // B.lateStart ligt exact 16 werkuur vóór C.lateStart op de (gedeelde) taakkalender. + const bLateStart = parseInstant(b.lateStart); + const cLateStart = parseInstant(c.lateStart); + eq('completed-late AAN: lag tussen voltooid en niet-voltooid telt mee (16 werkuur)', + cal.subtractWorkMinutes(cLateStart, 16 * 60).toISOString(), bLateStart.toISOString()); + ok('completed-late AAN: B.lateStart ligt vóór C.lateStart (de lag is niet toevallig nul)', + bLateStart.getTime() < cLateStart.getTime(), `b=${b.lateStart} c=${c.lateStart}`); + + // LF = prevWorkInstant(LS) op de taakkalender, voor zowel A als B. + eq('completed-late AAN: A.lateFinish = prevWorkInstant(A.lateStart)', + cal.prevWorkInstant(parseInstant(a.lateStart)).toISOString(), parseInstant(a.lateFinish).toISOString()); + eq('completed-late AAN: B.lateFinish = prevWorkInstant(B.lateStart)', + cal.prevWorkInstant(parseInstant(b.lateStart)).toISOString(), parseInstant(b.lateFinish).toISOString()); + + // A en B delen dezelfde late-anker-keten (A erft B's late start ongewijzigd) ⇒ dezelfde + // totale float — gemeten tegen hetzelfde statusdatumvenster aan de vroege kant (niet de rauwe + // historische actual-finish, die voor A en B verschilt), anders zou de formule uitsluitend de + // historische afstand tussen A en B se eigen actuals meten. + eq('completed-late AAN: A en B hebben dezelfde totale float (zelfde late-ankerketen)', + a.totalFloat, b.totalFloat); + // Diagnose-formule H-i-B (op de finish-zijde, want totalFloatMode default 'finish'): TF = + // werkminuten(statusdatumvenster-EF → LF), exact de `workMinutesBetween`-primitief die + // `CPMSolver.signedFloat` gebruikt. (Etappe 7b haalde de XER-eigen penaltyprojectie + // `p6XerProjectedWorkMinutesBetween` weg — de kalender zelf is nu de enige as.) + const windowEs = cal.nextWorkInstant(dataDate); + const windowEf = cal.prevWorkInstant(windowEs); + const expectedTf = cal.workMinutesBetween(windowEf, parseInstant(a.lateFinish)) / (cal.hoursPerDay * 60); + eq('completed-late AAN: A.totalFloat = werkminuten(statusdatumvenster-EF → A.lateFinish)', + a.totalFloat, expectedTf); + + // G: geïsoleerde voltooide taak zonder opvolgers ⇒ de statusdatumklem zelf. + eq('completed-late AAN: G.lateStart = nextWorkInstant(statusdatum)', + parseInstant(g.lateStart).toISOString(), windowEs.toISOString()); + eq('completed-late AAN: G.lateFinish = prevWorkInstant(G.lateStart)', + parseInstant(g.lateFinish).toISOString(), cal.prevWorkInstant(windowEs).toISOString()); + eq('completed-late AAN: G heeft nul totale float (klem, geen opvolgerdruk)', g.totalFloat, 0); +} + +// ── Vlag AAN: SS/SF/FF-rekenfout (review-bevinding 3). ────────────────────────────────────────── +// `backwardConstraint` geeft voor SS/SF de late START van de voorganger terug via +// `finishFromStart(pe, predLS, predTask)` — vóór de fix telde die de VOLLE geplande duur van de +// voltooide voorganger een tweede keer mee (de nulrestduur-klem hierboven behandelde het resultaat +// dan óók nog als finish). Met de fix delen SSA/FSA (beide ankeren op NS's eigen late START) en +// SFA/FFA (beide op NS's eigen late FINISH) exact dezelfde afgeleide late start — dat is geen +// tautologie: vóór de fix week SSA/SFA een volle taakduur (16 werkuur = 2 werkdagen) af van hun +// FS/FF-tegenhanger. +{ + const { result } = solveWith(); + const ssa = result.tasks.get('SSA')!; + const fsa = result.tasks.get('FSA')!; + const sfa = result.tasks.get('SFA')!; + const ffa = result.tasks.get('FFA')!; + eq('completed-late AAN: SS en FS naar dezelfde opvolger met lag 0 geven dezelfde late start', + ssa.lateStart, fsa.lateStart); + eq('completed-late AAN: SF en FF naar dezelfde opvolger met lag 0 geven dezelfde late start', + sfa.lateStart, ffa.lateStart); + // Absolute ankers (review-bevinding 9: geen kale tautologie op de eigen formule) — vastgesteld + // via de motor zelf en hier bevroren; de mutatietest hieronder bewijst dat ze zonder de fix + // uiteenlopen. + eq('completed-late AAN: SSA/FSA absolute late start', { ssa: ssa.lateStart, fsa: fsa.lateStart }, { + ssa: '2026-11-09T07:00', fsa: '2026-11-09T07:00', + }); + eq('completed-late AAN: SFA/FFA absolute late start', { sfa: sfa.lateStart, ffa: ffa.lateStart }, { + sfa: '2026-11-16T07:00', ffa: '2026-11-16T07:00', + }); +} + +// ── Vlag AAN: lag op SS/SF uit een voltooide voorganger (her-review bevindingen 4 en 5). ───────── +// Bevinding 4: een PROCENTUELE lag werd op SS/SF tegen de nulrestduur-kloon opgelost en verdween. +// Sinds de fix wordt hij vóór de kloon tegen de ongewijzigde taak vastgezet, dus SS+50% (= 8 u) +// valt exact samen met SS+8 u én met FS+8 u en FS+50%. MUTATIEBEWIJS: haal `percentResolvedSeq` +// weg (gebruik `seq`) ⇒ SSP wijkt 8 u af van SSL/FSL/FSP. +// Bevinding 5 (karakterisering, geen doel): SF/FF met lag leveren hun late start op een andere +// bandrand dan FS/SS zodra de aftrek exact op een bandeind landt — SF geeft de finish-instant terug +// (LS == LF), FS via `nextWorkInstant` de start-instant. Welke conventie P6 hier hanteert is niet +// gemeten (rehab-2 is FS-gedomineerd, geen orakelcel dekt SS/SF-met-lag); de absolute waarden +// staan hieronder gepind zodat een bewuste symmetrisering zichtbaar is en een stille niet. +{ + const { result } = solveWith(); + const ls = (id: string) => result.tasks.get(id)!.lateStart; + const lf = (id: string) => result.tasks.get(id)!.lateFinish; + eq('completed-late AAN: SS+8u en FS+8u naar dezelfde opvolger geven dezelfde late start', + ls('SSL'), ls('FSL')); + eq('completed-late AAN: SS+50% (=8u) valt samen met SS+8u — de procentlag verdwijnt niet meer', + ls('SSP'), ls('SSL')); + eq('completed-late AAN: FS+50% valt samen met FS+8u (was al zo; blijft zo)', + ls('FSP'), ls('FSL')); + eq('completed-late AAN: de lag telt echt mee (SS+8u ligt 8 werkuur vóór SS zonder lag)', + ls('SSL') < ls('SSA'), true); + eq('completed-late AAN: absolute pins SS/FS met lag (start-rand)', { + ssl: [ls('SSL'), lf('SSL')], fsl: [ls('FSL'), lf('FSL')], ssp: [ls('SSP'), lf('SSP')], fsp: [ls('FSP'), lf('FSP')], + }, LAG_PINS_START_SIDE); + eq('completed-late AAN: absolute pins SF/FF met lag (bevinding 5: bandrand-karakterisering)', { + sfl: [ls('SFL'), lf('SFL')], ffl: [ls('FFL'), lf('FFL')], + }, LAG_PINS_FINISH_SIDE); +} + +// ── Poortpariteit solver ↔ weergave (review-bevinding 4). ────────────────────────────────────── +// NX is `TK_Complete` zonder `act_end_date`. De brede completedWindow-poort laat 'm door, de +// completed-actual-pin niet — precies de spleet waarin de weergavelaag vóór deze fix wél meebewoog +// (ls/lf verschoven naar het statusdatumvenster en de float ging tegen dat venster meten) terwijl +// `CPMSolver.backwardPass` zijn nieuwe tak oversloeg. De gedeelde poort +// `explainP6CompletedLateRemainingWindowEligibility` sluit beide kanten tegelijk; het bewijs is dat +// NX met vlag AAN byte-identiek is aan NX met vlag UIT. +{ + const { imported } = solveWith(); + const dataDate = imported.project.statusDate ? parseInstant(imported.project.statusDate) : null; + const so = imported.project.schedulingOptions; + const nx = imported.tasks.find(t => t.id === 'NX'); + if (!nx) throw new Error('fixture mist taak NX'); + eq('poortpariteit: NX heeft completion 1 maar géén actualFinish', { + completion: nx.time.completion, actualFinish: nx.time.actualFinish ?? null, + }, { completion: 1, actualFinish: null }); + eq('poortpariteit: NX komt WEL door de brede completedWindow-poort', + explainP6CompletedDataDateWindow(nx, dataDate, so), { eligible: true, reason: 'eligible' }); + eq('poortpariteit: NX komt NIET door de completed-actual-pin', + explainBackwardActualPinEligibility(nx, dataDate, so), + { eligible: false, reason: 'missingActualFinish' }); + eq('poortpariteit: de gedeelde poort weigert NX om diezelfde reden', + explainP6CompletedLateRemainingWindowEligibility(nx, dataDate, so), + { eligible: false, reason: 'missingActualFinish' }); + + const on = solveWith().result.tasks.get('NX')!; + const off = solveWith({ p6CompletedLateFromRemainingWindow: false }).result.tasks.get('NX')!; + eq('poortpariteit: NX is met vlag AAN byte-identiek aan vlag UIT (solver deed niets, weergave dus ook niet)', { + lateStart: on.lateStart, lateFinish: on.lateFinish, totalFloat: on.totalFloat, + earlyStart: on.earlyStart, earlyFinish: on.earlyFinish, + }, { + lateStart: off.lateStart, lateFinish: off.lateFinish, totalFloat: off.totalFloat, + earlyStart: off.earlyStart, earlyFinish: off.earlyFinish, + }); +} + +// ── Vlag UIT: byte-identiek aan het bestaande gedrag (rauwe actual-pin). ──────────────────────── +{ + const { result } = solveWith({ p6CompletedLateFromRemainingWindow: false }); + const a = result.tasks.get('A')!; + const b = result.tasks.get('B')!; + const g = result.tasks.get('G')!; + eq('completed-late UIT: A.lateStart/-Finish blijven op de rauwe actual-pin', { + lateStart: a.lateStart, lateFinish: a.lateFinish, + }, { lateStart: '2026-08-03T07:00', lateFinish: '2026-08-04T15:00' }); + eq('completed-late UIT: B.lateStart/-Finish blijven op de rauwe actual-pin', { + lateStart: b.lateStart, lateFinish: b.lateFinish, + }, { lateStart: '2026-08-10T07:00', lateFinish: '2026-08-11T15:00' }); + eq('completed-late UIT: G.lateStart/-Finish blijven op de rauwe actual-pin', { + lateStart: g.lateStart, lateFinish: g.lateFinish, + }, { lateStart: '2026-07-06T07:00', lateFinish: '2026-07-06T15:00' }); + eq('completed-late UIT: A en B hebben (zoals vóór deze etappe) totale float 0', { + a: a.totalFloat, b: b.totalFloat, g: g.totalFloat, + }, { a: 0, b: 0, g: 0 }); + const ssa = result.tasks.get('SSA')!; + const fsa = result.tasks.get('FSA')!; + const sfa = result.tasks.get('SFA')!; + const ffa = result.tasks.get('FFA')!; + eq('completed-late UIT: SSA/FSA/SFA/FFA blijven op de rauwe actual-pin, ongeacht relatietype', { + ssa: { lateStart: ssa.lateStart, lateFinish: ssa.lateFinish, totalFloat: ssa.totalFloat }, + fsa: { lateStart: fsa.lateStart, lateFinish: fsa.lateFinish, totalFloat: fsa.totalFloat }, + sfa: { lateStart: sfa.lateStart, lateFinish: sfa.lateFinish, totalFloat: sfa.totalFloat }, + ffa: { lateStart: ffa.lateStart, lateFinish: ffa.lateFinish, totalFloat: ffa.totalFloat }, + }, { + ssa: { lateStart: '2026-08-03T07:00', lateFinish: '2026-08-04T15:00', totalFloat: 0 }, + fsa: { lateStart: '2026-08-03T07:00', lateFinish: '2026-08-04T15:00', totalFloat: 0 }, + sfa: { lateStart: '2026-08-03T07:00', lateFinish: '2026-08-04T15:00', totalFloat: 0 }, + ffa: { lateStart: '2026-08-03T07:00', lateFinish: '2026-08-04T15:00', totalFloat: 0 }, + }); +} + +if (diffs.length > 0) { + console.error(`XER COMPLETED LATE RED: ${diffs.length}/${checks} checks rood`); + for (const diff of diffs) console.error(`XX ${diff}`); + process.exit(1); +} +console.log(`XER COMPLETED LATE GREEN: ${checks} checks groen`); diff --git a/tests/planning/check-xer-completed-loe-actual-finish.ts b/tests/planning/check-xer-completed-loe-actual-finish.ts new file mode 100644 index 000000000..f39d1721b --- /dev/null +++ b/tests/planning/check-xer-completed-loe-actual-finish.ts @@ -0,0 +1,232 @@ +import { solveProject } from '@/engine/scheduler/solveProject'; +import { explainCompletedXerLoeActualFinishEligibility } from '@/engine/scheduler/p6CompletedRouteTrace'; +import { isMultiDocumentImport, type ImportResult } from '@/services/importTypes'; +import { readXER } from '@/services/xer/xerReader'; +import { parseInstant } from '@/utils/dateUtils'; +import type { Task } from '@/types/task'; + +const diffs: string[] = []; +let checks = 0; + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +const workWeek = '(0||CalendarData()( (0||DaysOfWeek()( (0||1()( (0||0(s|08:00|f|12:00)()) (0||1(s|13:00|f|17:00)()))) (0||2()( (0||0(s|08:00|f|12:00)()) (0||1(s|13:00|f|17:00)()))) (0||3()( (0||0(s|08:00|f|12:00)()) (0||1(s|13:00|f|17:00)()))) (0||4()( (0||0(s|08:00|f|12:00)()) (0||1(s|13:00|f|17:00)()))) (0||5()( (0||0(s|08:00|f|12:00)()) (0||1(s|13:00|f|17:00)()))) (0||6()()) (0||7()()))) (0||Exceptions()())))'; + +function fixtureBytes(): Uint8Array { + return new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-09-01\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tproj_id\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + `%R\tC1\tWerkweek\tP1\tCA_Project\t8\t40\t${workWeek}`, + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date\tplan_end_date\trem_target_link_flag', + '%R\tP1\tCompleted LOE guard\tC1\t2026-06-30 17:00\t2026-01-05 08:00\t2026-06-30 17:00\tY', + '%T\tSCHEDOPTIONS', + '%F\tproj_id\tsched_use_project_end_date_for_float', + '%R\tP1\tN', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\tcomplete_pct_type\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date\tact_start_date\tact_end_date\tsuspend_date\tresume_date', + '%R\tP\tP1\tC1\tPRED\tSS voorganger\tTT_Task\tDT_FixedDUR\tTK_NotStart\tCP_Drtn\t8\t8\t2026-01-05 08:00\t2026-01-05 17:00\t\t\t\t', + '%R\tL\tP1\tC1\tLOE\tLange voltooide LOE\tTT_LOE\tDT_FixedDrtn\tTK_Complete\tCP_Drtn\t352\t0\t2026-01-05 08:00\t2026-03-06 17:00\t2026-01-05 08:00\t2026-03-06 17:00\t\t', + '%R\tO\tP1\tC1\tOPEN\tOpen opvolger\tTT_Task\tDT_FixedDUR\tTK_NotStart\tCP_Drtn\t8\t8\t2026-03-09 08:00\t2026-03-09 17:00\t\t\t\t', + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%R\tR-SS\tL\tP\tP1\tP1\tPR_SS\t0', + '%E', + ].join('\n')); +} + +function importedFixture(): ImportResult { + const opened = readXER(fixtureBytes()); + if (isMultiDocumentImport(opened)) throw new Error('completed LOE-fixture moet één project openen'); + return opened; +} + +function task(imported: ImportResult): Task { + const found = imported.tasks.find(candidate => candidate.id === 'L'); + if (!found) throw new Error('completed LOE-fixture mist taak L'); + return found; +} + +function projection(mutate?: (imported: ImportResult, loe: Task) => void) { + const imported = structuredClone(importedFixture()); + const loe = task(imported); + mutate?.(imported, loe); + const incoming = imported.sequences.filter(sequence => sequence.successorId === loe.id); + const outgoing = imported.sequences.filter(sequence => sequence.predecessorId === loe.id); + const decision = explainCompletedXerLoeActualFinishEligibility( + loe, + imported.project.statusDate ? parseInstant(imported.project.statusDate) : null, + imported.project.schedulingOptions, + incoming, + outgoing, + ); + const result = solveProject({ + tasks: imported.tasks, + sequences: imported.sequences, + calendar: imported.calendar, + calendars: imported.resourceCalendars ?? [], + dataDate: imported.project.statusDate, + progressMode: imported.project.progressMode, + schedulingOptions: imported.project.schedulingOptions, + projectStartDate: imported.project.startDate, + projectEndDate: imported.project.endDate, + }); + if (result.error) throw new Error(result.error); + const solved = result.tasks.get(loe.id); + if (!solved) throw new Error('solver mist completed LOE'); + return { + decision, + schedule: { + es: solved.earlyStart, + ef: solved.earlyFinish, + ls: solved.lateStart, + lf: solved.lateFinish, + tf: solved.totalFloat, + ff: solved.freeFloat, + earlyOrder: parseInstant(solved.earlyStart).getTime() <= parseInstant(solved.earlyFinish).getTime(), + lateOrder: parseInstant(solved.lateStart).getTime() <= parseInstant(solved.lateFinish).getTime(), + }, + }; +} + +/** + * Een onparseerbare targetdatum moet door de smalle guard worden afgewezen vóór de solver die + * terecht geen planning van corrupte invoer kan maken. Dit toetst dus de fail-closed grens zelf, + * zonder een verzonnen hammock-uitkomst voor onplanbare invoer te verwachten. + */ +function decisionOnly(mutate: (imported: ImportResult, loe: Task) => void) { + const imported = structuredClone(importedFixture()); + const loe = task(imported); + mutate(imported, loe); + return explainCompletedXerLoeActualFinishEligibility( + loe, + imported.project.statusDate ? parseInstant(imported.project.statusDate) : null, + imported.project.schedulingOptions, + imported.sequences.filter(sequence => sequence.successorId === loe.id), + imported.sequences.filter(sequence => sequence.predecessorId === loe.id), + ); +} + +const importedSource = importedFixture(); +const importedLoe = task(importedSource); +eq('completed XER LOE: reader levert alle vereiste XER-provenance en vlaggen', { + source: importedSource.project.schedulingOptions?.p6Source, + remainingStart: importedSource.project.schedulingOptions?.p6UseRemainingStartForProgress, + preserveBackward: importedSource.project.schedulingOptions?.preserveActualDatesInBackwardPass, + preserveInstants: importedSource.project.schedulingOptions?.p6PreserveActualInstants, + projectId: importedLoe.p6ProjectId, + taskId: importedLoe.p6TaskId, +}, { + source: 'XER', remainingStart: true, preserveBackward: true, preserveInstants: true, + projectId: 'P1', taskId: 'L', +}); + +eq('completed XER LOE: uitsluitend de bewezen Harbour-vorm verlaat de hammock-forwardtak', projection(), { + decision: { eligible: true, reason: 'eligible' }, + schedule: { + es: '2026-01-05T08:00', ef: '2026-03-06T17:00', + ls: '2026-01-05T08:00', lf: '2026-03-06T17:00', + tf: 0, ff: 0, earlyOrder: true, lateOrder: true, + }, +}); + +eq('completed XER LOE: meerdere inkomende SS-relaties blijven binnen de expliciete Harbour-topologie', + projection(imported => { + imported.sequences.push({ ...imported.sequences[0]!, id: 'R-SS-2' }); + }), { + decision: { eligible: true, reason: 'eligible' }, + schedule: { + es: '2026-01-05T08:00', ef: '2026-03-06T17:00', + ls: '2026-01-05T08:00', lf: '2026-03-06T17:00', + tf: 0, ff: 0, earlyOrder: true, lateOrder: true, + }, + }); + +eq('completed XER LOE: actualFinish precies op dataDate blijft een geldige bovengrens', + decisionOnly((_imported, loe) => { loe.time.actualFinish = '2026-06-30T17:00'; }), + { eligible: true, reason: 'eligible' }); + +const hammockAtDataDate = { + es: '2026-07-01T08:00', ef: '2026-07-01T08:00', + ls: '2026-07-01T08:00', lf: '2026-07-01T08:00', + tf: 0, ff: 0, earlyOrder: true, lateOrder: true, +}; +const hammockAtTargetStart = { + es: '2026-01-05T08:00', ef: '2026-01-05T08:00', + ls: '2026-01-05T08:00', lf: '2026-01-05T08:00', + tf: 0, ff: 0, earlyOrder: true, lateOrder: true, +}; +const hammockAfterPredecessorFinish = { + es: '2026-07-02T08:00', ef: '2026-07-02T08:00', + ls: '2026-07-02T08:00', lf: '2026-07-02T08:00', + tf: 0, ff: 0, earlyOrder: true, lateOrder: true, +}; + +const rejections: Array<{ + label: string; + mutate: (imported: ImportResult, loe: Task) => void; + reason: string; + schedule?: typeof hammockAtDataDate; + skipSolver?: true; +}> = [ + { label: 'andere bron', mutate: imported => { imported.project.schedulingOptions = { ...imported.project.schedulingOptions, p6Source: undefined }; }, reason: 'notXerSource' }, + { label: 'ontbrekende projectprovenance', mutate: (_imported, loe) => { loe.p6ProjectId = undefined; }, reason: 'missingProjectProvenance' }, + { label: 'lege projectprovenance', mutate: (_imported, loe) => { loe.p6ProjectId = ''; }, reason: 'missingProjectProvenance' }, + { label: 'ontbrekende taakprovenance', mutate: (_imported, loe) => { loe.p6TaskId = undefined; }, reason: 'missingTaskProvenance' }, + { label: 'lege taakprovenance', mutate: (_imported, loe) => { loe.p6TaskId = ''; }, reason: 'missingTaskProvenance' }, + { label: 'TT_Task', mutate: (_imported, loe) => { loe.p6ActivityType = 'TT_Task'; loe.isHammock = true; }, reason: 'wrongActivityType' }, + { label: 'CP_Phys', mutate: (_imported, loe) => { loe.p6CompletePctType = 'CP_Phys'; }, reason: 'wrongCompletePctType' }, + { label: 'ander duurtype', mutate: (_imported, loe) => { loe.p6DurationType = 'DT_FixedDUR2'; }, reason: 'wrongDurationType' }, + { label: 'in uitvoering', mutate: (_imported, loe) => { loe.time.completion = 0.5; }, reason: 'notCompleted' }, + { label: 'niet-completed status', mutate: (_imported, loe) => { loe.status = 'STARTED'; }, reason: 'notCompleted' }, + { label: 'oneindige completion', mutate: (_imported, loe) => { loe.time.completion = Infinity; }, reason: 'invalidCompletion' }, + { label: 'ontbrekende targetStart', mutate: (_imported, loe) => { loe.time.scheduleStart = ''; }, reason: 'missingScheduleStart', skipSolver: true }, + { label: 'ongeldige targetStart', mutate: (_imported, loe) => { loe.time.scheduleStart = 'geen-datum'; }, reason: 'invalidScheduleStart', skipSolver: true }, + { label: 'ontbrekende targetFinish', mutate: (_imported, loe) => { loe.time.scheduleFinish = ''; }, reason: 'missingScheduleFinish', skipSolver: true }, + { label: 'ongeldige targetFinish', mutate: (_imported, loe) => { loe.time.scheduleFinish = 'geen-datum'; }, reason: 'invalidScheduleFinish', skipSolver: true }, + { label: 'omgekeerd targetvenster', mutate: (_imported, loe) => { loe.time.scheduleStart = '2026-03-06T17:01'; }, reason: 'targetWindowInverted' }, + { label: 'ontbrekende actualFinish', mutate: (_imported, loe) => { loe.time.actualFinish = undefined; }, reason: 'missingActualFinish' }, + { label: 'ongeldige actualFinish', mutate: (_imported, loe) => { loe.time.actualFinish = 'geen-datum'; }, reason: 'invalidActualFinish' }, + { label: 'actualFinish vóór targetFinish', mutate: (_imported, loe) => { loe.time.actualFinish = '2026-03-06T16:00'; }, reason: 'actualFinishBeforeTargetFinish' }, + { label: 'actualFinish ná dataDate', mutate: (_imported, loe) => { loe.time.actualFinish = '2026-07-01T08:00'; }, reason: 'actualFinishAfterDataDate' }, + { label: 'ontbrekende dataDate', mutate: imported => { imported.project.statusDate = ''; }, reason: 'missingDataDate', schedule: hammockAtTargetStart }, + { label: 'ongeldige dataDate', mutate: imported => { imported.project.statusDate = 'geen-datum'; }, reason: 'invalidDataDate', skipSolver: true }, + { label: 'ontbrekend targetvenster', mutate: (_imported, loe) => { loe.p6ExplicitTargetWindow = false; }, reason: 'missingExplicitTargetWindow' }, + { label: 'stop', mutate: (_imported, loe) => { loe.time.stop = '2026-02-01T17:00'; }, reason: 'hasSuspendResume' }, + { label: 'resume', mutate: (_imported, loe) => { loe.time.resume = '2026-02-02T08:00'; }, reason: 'hasSuspendResume' }, + { label: 'suspend-markering', mutate: (_imported, loe) => { loe.p6SuspendResume = true; }, reason: 'hasSuspendResume' }, + { label: 'mijlpaalmarkering', mutate: (_imported, loe) => { loe.isMilestone = true; }, reason: 'milestoneOrZeroDuration' }, + { label: 'nulduur', mutate: (_imported, loe) => { loe.time.scheduleDuration = 0; loe.time.durationMinutes = 0; }, reason: 'milestoneOrZeroDuration' }, + { label: 'geen inkomende relatie', mutate: imported => { imported.sequences = []; }, reason: 'missingIncomingStartStart', schedule: hammockAtTargetStart }, + { label: 'inkomende FS', mutate: imported => { imported.sequences[0]!.type = 'FINISH_START'; }, reason: 'incomingNotOnlyStartStart', schedule: hammockAfterPredecessorFinish }, + { label: 'gemengde inkomende relaties', mutate: imported => { imported.sequences.push({ ...imported.sequences[0]!, id: 'R-FS', type: 'FINISH_START' }); }, reason: 'incomingNotOnlyStartStart', schedule: hammockAfterPredecessorFinish }, + { label: 'uitgaande relatie', mutate: imported => { imported.sequences.push({ ...imported.sequences[0]!, id: 'R-OUT', predecessorId: 'L', successorId: 'O' }); }, reason: 'hasOutgoingRelation' }, + { label: 'remaining-start-optie uit', mutate: imported => { imported.project.schedulingOptions = { ...imported.project.schedulingOptions, p6UseRemainingStartForProgress: false }; }, reason: 'remainingStartOff' }, + { label: 'backward-preserve-optie uit', mutate: imported => { imported.project.schedulingOptions = { ...imported.project.schedulingOptions, preserveActualDatesInBackwardPass: false }; }, reason: 'preserveActualDatesOff' }, + { label: 'actual-instant-optie uit', mutate: imported => { imported.project.schedulingOptions = { ...imported.project.schedulingOptions, p6PreserveActualInstants: false }; }, reason: 'preserveActualInstantsOff' }, +]; + +for (const rejection of rejections) { + if (rejection.skipSolver) { + eq(`completed XER LOE fail-closed: ${rejection.label}`, + decisionOnly(rejection.mutate), { eligible: false, reason: rejection.reason }); + continue; + } + const actual = projection(rejection.mutate); + eq(`completed XER LOE fail-closed: ${rejection.label}`, actual.decision, { eligible: false, reason: rejection.reason }); + eq(`completed XER LOE ${rejection.label}: fail-closed behoudt alle zes solverassen`, + actual.schedule, rejection.schedule ?? hammockAtDataDate); +} + +if (diffs.length > 0) { + console.error(`XER completed LOE actual-finish RED: ${diffs.length}/${checks} checks rood`); + for (const diff of diffs) console.error(`XX ${diff}`); + process.exit(1); +} +console.log(`XER completed LOE actual-finish GREEN: ${checks} checks groen`); diff --git a/tests/planning/check-xer-completed-suspend-resume-window.ts b/tests/planning/check-xer-completed-suspend-resume-window.ts new file mode 100644 index 000000000..7761e1d5c --- /dev/null +++ b/tests/planning/check-xer-completed-suspend-resume-window.ts @@ -0,0 +1,288 @@ +import { cloneTasksForSolve, solveProject } from '@/engine/scheduler/solveProject'; +import { isMultiDocumentImport, type ImportResult } from '@/services/importTypes'; +import { readXER } from '@/services/xer/xerReader'; +import { parseInstant } from '@/utils/dateUtils'; +import { explainP6CompletedDataDateWindow } from '@/utils/p6CompletedTargetWindow'; +import type { Task } from '@/types/task'; + +const diffs: string[] = []; +let checks = 0; + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +const earlyShiftCalendar = '(0||CalendarData()( (0||DaysOfWeek()( (0||1()( (0||0(s|07:00|f|15:00)()))) (0||2()( (0||0(s|07:00|f|15:00)()))) (0||3()( (0||0(s|07:00|f|15:00)()))) (0||4()( (0||0(s|07:00|f|15:00)()))) (0||5()( (0||0(s|07:00|f|15:00)()))) (0||6()()) (0||7()()))) (0||Exceptions()())))'; + +function bytes(lines: readonly string[]): Uint8Array { + return new TextEncoder().encode(lines.join('\n')); +} + +function importFixture(): ImportResult { + const opened = readXER(bytes([ + 'ERMHDR\t23.12\t2026-08-17\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tproj_id\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + `%R\tC1\tVroege ploeg\tP1\tCA_Project\t8\t40\t${earlyShiftCalendar}`, + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date\tplan_end_date\trem_target_link_flag', + '%R\tP1\tCompleted suspend/resume\tC1\t2026-08-17 10:00\t2026-08-03 07:00\t2026-08-20 15:00\tY', + '%T\tSCHEDOPTIONS', + '%F\tproj_id\tsched_use_project_end_date_for_float', + '%R\tP1\tN', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\tcomplete_pct_type\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date\tact_start_date\tact_end_date\tsuspend_date\tresume_date', + '%R\tC\tP1\tC1\tCOMP\tCompleted diag\tTT_Task\tDT_FixedDUR2\tTK_Complete\tCP_Drtn\t8\t0\t2026-08-04 07:00\t2026-08-04 15:00\t2026-08-04 07:00\t2026-08-14 15:00\t2026-08-10 15:00\t2026-08-12 07:00', + '%E', + ])); + if (isMultiDocumentImport(opened)) throw new Error('completed suspend/resume fixture moet precies één project openen'); + return opened; +} + +function taskOf(imported: ImportResult): Task { + const task = imported.tasks.find(candidate => candidate.id === 'C'); + if (!task) throw new Error('fixture mist taak C'); + return task; +} + +function solveProjection(mutate?: (imported: ImportResult, task: Task) => void) { + const imported = structuredClone(importFixture()); + const task = taskOf(imported); + mutate?.(imported, task); + const solveTasks = cloneTasksForSolve(imported.tasks); + const solveTask = solveTasks.find(candidate => candidate.id === task.id); + if (!solveTask) throw new Error('solve-kloon mist taak C'); + const before = { + flag: solveTask.p6SuspendResume, + stop: solveTask.time.stop, + resume: solveTask.time.resume, + }; + const decision = explainP6CompletedDataDateWindow( + task, + imported.project.statusDate ? parseInstant(imported.project.statusDate) : null, + imported.project.schedulingOptions, + ); + const result = solveProject({ + tasks: solveTasks, + sequences: imported.sequences, + calendar: imported.calendar, + calendars: imported.resourceCalendars ?? [], + dataDate: imported.project.statusDate, + progressMode: imported.project.progressMode, + schedulingOptions: imported.project.schedulingOptions, + projectStartDate: imported.project.startDate, + projectEndDate: imported.project.endDate, + }); + if (result.error) throw new Error(result.error); + const scheduled = result.tasks.get(solveTask.id); + const trace = result.backwardFloatTrace?.byTaskId.C; + return { + before, + after: { + flag: solveTask.p6SuspendResume, + stop: solveTask.time.stop, + resume: solveTask.time.resume, + }, + decision, + scheduled: scheduled + ? { + earlyStart: scheduled.earlyStart, + earlyFinish: scheduled.earlyFinish, + lateStart: scheduled.lateStart, + lateFinish: scheduled.lateFinish, + earlyFinishAtOrAfterEarlyStart: + parseInstant(scheduled.earlyFinish).getTime() >= parseInstant(scheduled.earlyStart).getTime(), + } + : null, + trace: trace + ? { + projectEndSource: result.backwardFloatTrace?.projectEndSource, + displayActualLate: trace.displayActualLate, + completedWindow: trace.completedWindow, + backwardActualPin: trace.backwardActualPin, + displayActualLateDecision: trace.displayActualLateDecision, + } + : null, + }; +} + +function decisionProjection(mutate?: (imported: ImportResult, task: Task) => void) { + const imported = structuredClone(importFixture()); + const task = taskOf(imported); + mutate?.(imported, task); + return explainP6CompletedDataDateWindow( + task, + imported.project.statusDate ? parseInstant(imported.project.statusDate) : null, + imported.project.schedulingOptions, + ); +} + +eq('XER completed suspend/resume positive: geldige P6-vorm opent de completed-windowroute en houdt de zelfstandige fixture tijdkundig geldig', solveProjection(), { + before: { + flag: true, + stop: '2026-08-10T15:00', + resume: '2026-08-12T07:00', + }, + after: { + flag: true, + stop: '2026-08-10T15:00', + resume: '2026-08-12T07:00', + }, + decision: { eligible: true, reason: 'eligible' }, + scheduled: { + earlyStart: '2026-08-17T10:00', + earlyFinish: '2026-08-17T10:00', + // p6CompletedLateFromRemainingWindow (diagnose laag 1, klasse (i), default aan voor XER): een + // voltooide taak zonder opvolgers staat aan de late zijde ook op de statusdatumklem — + // identiek aan de forward-window hierboven, i.p.v. het historische actual-venster. + lateStart: '2026-08-17T10:00', + lateFinish: '2026-08-17T10:00', + earlyFinishAtOrAfterEarlyStart: true, + }, + trace: { + projectEndSource: 'completedDisplayWindow', + displayActualLate: true, + completedWindow: { eligible: true, reason: 'eligible' }, + backwardActualPin: { eligible: true, reason: 'eligible' }, + displayActualLateDecision: { eligible: true, reason: 'eligible' }, + }, +}); + +const rejectionCases: Array<{ + label: string; + mutate: (imported: ImportResult, task: Task) => void; + want: { eligible: boolean; reason: string }; +}> = [ + { + label: 'onvolledig paar', + mutate: (_imported, task) => { + task.time.resume = undefined; + }, + want: { eligible: false, reason: 'hasSuspendResume' }, + }, + { + label: 'omgekeerd paar', + mutate: (_imported, task) => { + task.time.stop = '2026-08-13T07:00'; + task.time.resume = '2026-08-12T07:00'; + }, + want: { eligible: false, reason: 'hasSuspendResume' }, + }, + { + label: 'ongeldige resume-datum', + mutate: (_imported, task) => { + task.time.resume = 'geen-datum'; + }, + want: { eligible: false, reason: 'hasSuspendResume' }, + }, + { + label: 'ongeldige actualFinish', + mutate: (_imported, task) => { + task.time.actualFinish = 'geen-datum'; + }, + want: { eligible: false, reason: 'hasSuspendResume' }, + }, + { + label: 'actualFinish vóór targetFinish', + mutate: (_imported, task) => { + task.time.actualFinish = '2026-08-04T14:00'; + }, + want: { eligible: false, reason: 'hasSuspendResume' }, + }, + { + label: 'actualFinish ná statusdatum', + mutate: (_imported, task) => { + task.time.actualFinish = '2026-08-18T15:00'; + }, + want: { eligible: false, reason: 'hasSuspendResume' }, + }, + { + label: 'resume ná actualFinish', + mutate: (_imported, task) => { + task.time.resume = '2026-08-15T07:00'; + }, + want: { eligible: false, reason: 'hasSuspendResume' }, + }, + { + label: 'preserve-uit', + mutate: imported => { + imported.project.schedulingOptions = { + ...imported.project.schedulingOptions, + preserveActualDatesInBackwardPass: false, + }; + }, + want: { eligible: false, reason: 'hasSuspendResume' }, + }, + { + label: 'niet-XER', + mutate: imported => { + imported.project.schedulingOptions = { + ...imported.project.schedulingOptions, + p6Source: undefined, + }; + }, + want: { eligible: false, reason: 'notXerSource' }, + }, + { + label: 'completion kleiner dan 1', + mutate: (_imported, task) => { + task.time.completion = 0.5; + }, + want: { eligible: false, reason: 'hasSuspendResume' }, + }, + { + label: 'CP_Phys blijft buiten de suspend/resume-uitzondering', + mutate: (_imported, task) => { + task.p6CompletePctType = 'CP_Phys'; + }, + want: { eligible: false, reason: 'hasSuspendResume' }, + }, + { + label: 'ander durationtype', + mutate: (_imported, task) => { + task.p6DurationType = 'DT_FixedDrtn'; + }, + want: { eligible: false, reason: 'wrongDurationType' }, + }, + { + label: 'LOE', + mutate: (_imported, task) => { + task.p6ActivityType = 'TT_LOE'; + }, + want: { eligible: false, reason: 'wrongActivityType' }, + }, + { + label: 'ontbrekend expliciet targetwindow', + mutate: (_imported, task) => { + task.p6ExplicitTargetWindow = false; + }, + want: { eligible: false, reason: 'missingExplicitTargetWindow' }, + }, +]; + +for (const variant of rejectionCases) { + eq(`XER completed suspend/resume reject: ${variant.label}`, decisionProjection(variant.mutate), variant.want); +} + +{ + const projection = solveProjection(imported => { + imported.project.statusDate = undefined; + }); + eq('XER completed suspend/resume reject: statusdatumloos blijft fail-closed zonder completedDisplayWindow', { + projectEndSource: projection.trace?.projectEndSource, + completedWindow: projection.trace?.completedWindow, + }, { + projectEndSource: 'maxEarlyFinish', + completedWindow: { eligible: false, reason: 'missingDataDate' }, + }); +} + +if (diffs.length > 0) { + console.error(`XER completed suspend/resume window RED: ${diffs.length}/${checks} checks rood`); + for (const diff of diffs) console.error(`XX ${diff}`); + process.exit(1); +} +console.log(`XER completed suspend/resume window GREEN: ${checks} checks groen`); diff --git a/tests/planning/check-xer-corpus.ts b/tests/planning/check-xer-corpus.ts new file mode 100644 index 000000000..869e273b4 --- /dev/null +++ b/tests/planning/check-xer-corpus.ts @@ -0,0 +1,323 @@ +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { parseXerTables } from '@/services/xer/xerTables'; + +const diffs: string[] = []; +let checks = 0; + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +function summary(root: string, label: string): unknown { + try { + const parsed = parseXerTables(new Uint8Array(readFileSync(join(root, label)))); + return { + encoding: parsed.report.encoding, + tables: Object.fromEntries([...parsed.tables].map(([name, table]) => [name, table.rows.length])), + issues: parsed.report.issues, + unknownTables: parsed.report.unknownTables, + }; + } catch (error) { + const typed = error as { + name?: string; + xerCode?: string; + table?: string; + missingColumns?: string[]; + missingValues?: string[]; + line?: number; + }; + return { + name: typed.name, + xerCode: typed.xerCode, + ...(typed.table ? { table: typed.table } : {}), + ...(typed.missingColumns ? { missingColumns: typed.missingColumns } : {}), + ...(typed.missingValues ? { missingValues: typed.missingValues } : {}), + ...(typed.line ? { line: typed.line } : {}), + }; + } +} + +const root = process.env.OPS_XER_CORPUS; +if (!root) { + console.log('OK xer-corpus: corpus niet aanwezig (OPS_XER_CORPUS) — corpuspoort overgeslagen'); +} else if (!existsSync(root)) { + diffs.push('OPS_XER_CORPUS wijst niet naar een bestaande corpusmap'); +} else { + eq('K1 kedular-empty-schedule', summary(root, 'crawl-xer/kedular-empty-schedule.xer'), { + encoding: 'utf-8', tables: { PROJECT: 1 }, issues: [], unknownTables: [], + }); + eq('K2 kedular-field-mismatch', summary(root, 'crawl-xer/kedular-field-mismatch.xer'), { + encoding: 'utf-8', + tables: { PROJECT: 1, TASK: 2 }, + issues: [{ code: 'XER_ROW_FIELD_COUNT_MISMATCH', line: 7, table: 'TASK', expected: 13, actual: 7 }], + unknownTables: [], + }); + eq('K3 kedular-minimal-valid', summary(root, 'crawl-xer/kedular-minimal-valid.xer'), { + encoding: 'utf-8', + tables: { PROJECT: 1, CALENDAR: 1, PROJWBS: 2, TASK: 2, TASKPRED: 1 }, + issues: [], unknownTables: [], + }); + eq('K4 kedular-missing-calendar', summary(root, 'crawl-xer/kedular-missing-calendar.xer'), { + encoding: 'utf-8', + tables: { PROJECT: 1, PROJWBS: 1, TASK: 1, TASKPRED: 1 }, + issues: [], unknownTables: [], + }); + eq('K5 kedular-missing-taskpred', summary(root, 'crawl-xer/kedular-missing-taskpred.xer'), { + encoding: 'utf-8', + tables: { PROJECT: 1, CALENDAR: 1, PROJWBS: 1, TASK: 2 }, + issues: [], unknownTables: [], + }); + eq('K6 kedular-multi-project', summary(root, 'crawl-xer/kedular-multi-project.xer'), { + encoding: 'utf-8', tables: { PROJECT: 2, TASK: 2 }, issues: [], unknownTables: [], + }); + eq('K7 kedular-no-terminator', summary(root, 'crawl-xer/kedular-no-terminator.xer'), { + encoding: 'utf-8', + tables: { PROJECT: 1, CALENDAR: 1, PROJWBS: 2, TASK: 2, TASKPRED: 1 }, + issues: [{ code: 'XER_MISSING_END_MARKER', line: 19 }], unknownTables: [], + }); + eq('K8 kedular-unknown-tables', summary(root, 'crawl-xer/kedular-unknown-tables.xer'), { + encoding: 'utf-8', + tables: { PROJECT: 1, CALENDAR: 1, PROJWBS: 2, TASK: 2, TASKPRED: 1, SCHEDOPTIONS: 1 }, + issues: [], + unknownTables: [{ name: 'FINTMPL', rows: 1 }, { name: 'POBS', rows: 1 }], + }); + + eq('R1 DROID-skelet', summary(root, 'crawl-xer/droid_skeleton_fmt-1455.xer'), { + name: 'XerImportError', xerCode: 'XER_INVALID_FILE', + }); + eq('R2 p6xer-basic', summary(root, 'crawl-xer/p6xer-basic.xer'), { + name: 'XerImportError', xerCode: 'XER_MISSING_REQUIRED_COLUMNS', table: 'TASK', + missingColumns: ['proj_id', 'task_code'], + }); + eq('R3 p6xer-comprehensive', summary(root, 'crawl-xer/p6xer-comprehensive.xer'), { + encoding: 'utf-8', + tables: { PROJECT: 1 }, + issues: [ + { code: 'XER_UNKNOWN_RECORD', line: 2 }, + { code: 'XER_UNKNOWN_RECORD', line: 3 }, + { code: 'XER_TRAILING_RECORDS_AFTER_END', line: 9, ignoredRecords: 44, ignoredLines: 51 }, + ], + unknownTables: [], + }); + eq('R4 p6xer-empty_tables', summary(root, 'crawl-xer/p6xer-empty_tables.xer'), { + encoding: 'utf-8', + tables: {}, + issues: [ + { code: 'XER_UNKNOWN_RECORD', line: 2 }, + { code: 'XER_TRAILING_RECORDS_AFTER_END', line: 7, ignoredRecords: 7, ignoredLines: 9 }, + ], + unknownTables: [{ name: 'EMPTY_TABLE1', rows: 0 }], + }); + eq('R5 p6xer-malformed', summary(root, 'crawl-xer/p6xer-malformed.xer'), { + encoding: 'utf-8', + tables: {}, + issues: [{ code: 'XER_TRAILING_RECORDS_AFTER_END', line: 8, ignoredRecords: 35, ignoredLines: 42 }], + unknownTables: [{ name: 'ERMHDR', rows: 1 }], + }); + eq('R6 p6xer-encodings blijft geldige UTF-8 met onbekende tabellen', + summary(root, 'crawl-xer/p6xer-encodings.xer'), { + encoding: 'utf-8', + tables: {}, + issues: [{ + code: 'XER_TRAILING_RECORDS_AFTER_END', + line: 11, + ignoredRecords: 21, + ignoredLines: 24, + }], + unknownTables: [ + { name: 'SPECIAL_CHARS', rows: 5 }, + ], + }); + eq('R7 p6xer-mixed_endings wordt als onbekend dialect gerapporteerd', + summary(root, 'crawl-xer/p6xer-mixed_endings.xer'), { + encoding: 'utf-8', + tables: {}, + issues: [], + unknownTables: [{ name: 'TASK\\R\\N', rows: 2 }], + }); + + const manifest = JSON.parse(readFileSync(join(import.meta.dirname, 'xer-corpus-manifest.json'), 'utf8')) as { + files: Record; + }; + const noteAnchors = [ + '2bc12241c3f8ee5b7472dd0e77f2cbffafcf3b5438b17022fd9db4f4c642d4b0', + 'b9547eb91c30af1750933a64409d8b2a4a2c1dbe4c0be276a04ab150c8a50167', + ].map(hash => { + const label = Object.entries(manifest.files).find(([, entry]) => entry.sha256 === hash)?.[0]; + if (!label) return { hash, missing: true }; + const bytes = new Uint8Array(readFileSync(join(root, label))); + const parsed = parseXerTables(bytes); + const wbs = parsed.tables.get('WBSMEMO')?.rows.map(row => row.cells.wbs_memo ?? '') ?? []; + const task = parsed.tables.get('TASKMEMO')?.rows.map(row => row.cells.task_memo ?? '') ?? []; + return { + hash: createHash('sha256').update(bytes).digest('hex'), + wbsRows: wbs.length, + taskRows: task.length, + contaminated: [...wbs, ...task].filter(value => /[\u0000\uFEFF\uFFFE]|\u007f\u007f/.test(value)).length, + }; + }); + // Breuk die dit vangt: alleen de synthetische notitiefixture herstellen. De openbare dragers + // worden uitsluitend via inhoudshash aangewezen; corpusnamen of bedrijfsnamen horen niet hier. + eq('R8 twee inhoudshash-ankers hebben schone echte WBS-/TASK-notities', noteAnchors, [ + { + hash: '2bc12241c3f8ee5b7472dd0e77f2cbffafcf3b5438b17022fd9db4f4c642d4b0', + wbsRows: 15, + taskRows: 15, + contaminated: 0, + }, + { + hash: 'b9547eb91c30af1750933a64409d8b2a4a2c1dbe4c0be276a04ab150c8a50167', + wbsRows: 2, + taskRows: 4, + contaminated: 0, + }, + ]); + const anonymousIdentityHash = 'b8bc3cb30463c99c2255c5bd243888ac2706b7f6a1bfe31e242407a24f002dcf'; + const anonymousIdentityLabel = Object.entries(manifest.files) + .find(([, entry]) => entry.sha256 === anonymousIdentityHash)?.[0]; + eq('R9 anonieme pseudo-XER-drager wordt via inhoudshash op lege identiteit afgewezen', + anonymousIdentityLabel ? summary(root, anonymousIdentityLabel) : { missing: true }, { + name: 'XerImportError', + xerCode: 'XER_MISSING_REQUIRED_VALUE', + table: 'PROJECT', + missingValues: ['proj_id'], + line: 11, + }); + const missingCurrencyHash = '4d8bce790a93b9bc747abec5d99840e3e0d1ce5523e0afae3ba881762cfe2c13'; + const missingCurrencyLabel = Object.entries(manifest.files) + .find(([, entry]) => entry.sha256 === missingCurrencyHash)?.[0]; + let missingCurrencyOutcome: unknown = { missing: true }; + if (missingCurrencyLabel) { + const bytes = new Uint8Array(readFileSync(join(root, missingCurrencyLabel))); + const parsed = parseXerTables(bytes); + missingCurrencyOutcome = { + hash: createHash('sha256').update(bytes).digest('hex'), + format: parsed.numberFormat, + currencyIssues: parsed.report.issues.filter(issue => issue.code === 'XER_CURRENCY_NOT_FOUND'), + }; + } + eq('R10 valutamismatch-anker kiest nooit stil de eerste CURRTYPE-rij', missingCurrencyOutcome, { + hash: missingCurrencyHash, + format: { decimal: '.', group: null, source: 'default', currencyCode: 'EP' }, + currencyIssues: [{ code: 'XER_CURRENCY_NOT_FOUND', line: 1, table: 'CURRTYPE', currencyCode: 'EP' }], + }); + const largeCorpusHash = '2c1dce175b9f078111a48dc13fd1777f5fbd4cd7ab6623e647e7437330c60b7f'; + const largeCorpusLabel = Object.entries(manifest.files) + .find(([, entry]) => entry.sha256 === largeCorpusHash)?.[0]; + let largeCorpusOutcome: unknown = { missing: true }; + if (largeCorpusLabel) { + const bytes = new Uint8Array(readFileSync(join(root, largeCorpusLabel))); + const started = performance.now(); + const parsed = parseXerTables(bytes); + const elapsedMs = performance.now() - started; + const rows = [...parsed.tables.values()].reduce((sum, table) => sum + table.rows.length, 0); + largeCorpusOutcome = { + hash: createHash('sha256').update(bytes).digest('hex'), + bytes: bytes.length, + rows, + withinGenerousBudget: elapsedMs < 15_000, + }; + console.log( + `. xer-large: hash=${largeCorpusHash.slice(0, 16)}, bytes=${bytes.length}, ` + + `rows=${rows}, elapsedMs=${elapsedMs.toFixed(1)}`, + ); + } + // Capaciteitscontract X2-R3: geen arbitraire groottecap. De tijdgrens is ruim boven de verse + // lokale meting en bewaakt alleen een catastrofale regressie; browsergeheugen blijft X11. + eq('R11 grootste inhoudshash blijft zonder file-size-cap binnen royaal tijdplafond', largeCorpusOutcome, { + hash: largeCorpusHash, + bytes: 18_592_333, + rows: 163_628, + withinGenerousBudget: true, + }); + const trailingLinesHash = 'a303595f1def90620cb7da693e7217f364dd66bbc3b3775de5ae93106fa504e4'; + const trailingLinesLabel = Object.entries(manifest.files) + .find(([, entry]) => entry.sha256 === trailingLinesHash)?.[0]; + let trailingLinesOutcome: unknown = { missing: true }; + if (trailingLinesLabel) { + const bytes = new Uint8Array(readFileSync(join(root, trailingLinesLabel))); + const parsed = parseXerTables(bytes); + trailingLinesOutcome = { + hash: createHash('sha256').update(bytes).digest('hex'), + issue: parsed.report.issues.find(issue => issue.code === 'XER_TRAILING_RECORDS_AFTER_END'), + }; + } + // Breuk die dit vangt: alleen synthetische LF-fixtures corrigeren en de echte afsluitende + // newline in het openbare bestand als extra fysieke staartregel blijven tellen. + eq('R12 inhoudshash-anker telt de genegeerde staart fysiek', trailingLinesOutcome, { + hash: trailingLinesHash, + issue: { + code: 'XER_TRAILING_RECORDS_AFTER_END', + line: 6, + ignoredRecords: 21, + ignoredLines: 21, + }, + }); + const aggregate = { + files: 0, + ok: 0, + oracleOk: 0, + oracleErrors: 0, + referenceOk: 0, + referenceErrors: 0, + encodings: {} as Record, + errors: {} as Record, + issues: {} as Record, + unknownTables: 0, + unknownRows: 0, + }; + for (const [label, entry] of Object.entries(manifest.files)) { + aggregate.files++; + try { + const parsed = parseXerTables(new Uint8Array(readFileSync(join(root, label)))); + aggregate.ok++; + if (entry.role === 'oracle') aggregate.oracleOk++; + if (entry.role === 'reference-only') aggregate.referenceOk++; + aggregate.encodings[parsed.report.encoding] = (aggregate.encodings[parsed.report.encoding] ?? 0) + 1; + aggregate.unknownTables += parsed.report.unknownTables.length; + aggregate.unknownRows += parsed.report.unknownTables.reduce((sum, table) => sum + table.rows, 0); + for (const issue of parsed.report.issues) { + aggregate.issues[issue.code] = (aggregate.issues[issue.code] ?? 0) + 1; + } + } catch (error) { + const code = (error as { xerCode?: string }).xerCode ?? (error as Error).name; + aggregate.errors[code] = (aggregate.errors[code] ?? 0) + 1; + if (entry.role === 'oracle') aggregate.oracleErrors++; + if (entry.role === 'reference-only') aggregate.referenceErrors++; + } + } + eq('C1 volledige manifestcrawl heeft concrete parser-/rapporttellingen', aggregate, { + files: 93, + ok: 71, + oracleOk: 45, + oracleErrors: 0, + referenceOk: 1, + referenceErrors: 0, + encodings: { 'utf-8': 60, 'windows-1252': 11 }, + errors: { XER_MISSING_REQUIRED_COLUMNS: 19, XER_INVALID_FILE: 2, XER_MISSING_REQUIRED_VALUE: 1 }, + issues: { + XER_MISSING_END_MARKER: 6, + XER_UNKNOWN_RECORD: 67, + XER_ROW_FIELD_COUNT_MISMATCH: 114, + XER_TRAILING_RECORDS_AFTER_END: 5, + XER_CURRENCY_NOT_FOUND: 2, + }, + // TASKNOTE is sinds X8 een retained bron-tabel: precies twee dossier-tabellen met samen + // 132 rijen verdwijnen daarom uit de onbekende X2-rapportage. + unknownTables: 58, + unknownRows: 782, + }); +} + +if (diffs.length === 0) { + console.log(`OK xer-corpus: ${checks} checks groen`); +} else { + console.log(`XX xer-corpus: ${diffs.length} afwijking(en) van ${checks}`); + for (const diff of diffs) console.log(` XX ${diff}`); + process.exit(1); +} diff --git a/tests/planning/check-xer-corpusless-fidelity-gate.ts b/tests/planning/check-xer-corpusless-fidelity-gate.ts new file mode 100644 index 000000000..5f8be4d9a --- /dev/null +++ b/tests/planning/check-xer-corpusless-fidelity-gate.ts @@ -0,0 +1,989 @@ +/** + * Corpusloze XER-fidelity-eindpoort (X12, plan §1/§2/§3/§4.1). + * + * Dit is nadrukkelijk GEEN tweede XER-lezer. De check leest uitsluitend gereviewde, statische + * contractdata en bewaakt drie verschillende beweringen die CI zonder corpus nog kan doen: + * + * A. 93 publieke bronoccurrences met hun volledige SHA-256, rol en inclusiebesluit; + * B. de onafhankelijke, na byte- en schema-dedup geselecteerde 34 orakelentries; + * D. de openbare task-replay-pin die A en B kruist zonder een replay uit te voeren. + * + * Laag C is een compacte maar volledig uitgepakte v2-karakteriseringssnapshot: hij bewaakt de + * 34 entries en 47 projecten, maar verklaart zijn strict-nulpoort expliciet rood en ongeaccepteerd. + * Daardoor kan corpusloze CI niet veinzen dat productfidelity nul is. `readXER`, scanner, + * dedupbuilder, solveProject en de product-/replayadapter zijn hier daarom verboden imports. + */ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { gunzipSync, gzipSync } from 'node:zlib'; +import { PRODUCT_AXES, canonicalProductJson, createProductEnvelope, decodeProductEnvelopeUnchecked, deriveStrictEntryGate, productProjectionDigest, productSha256, sealProductBaseline, selectProductReportMode, validateProductBaselineV2, type ProductBaselineV2, type ProductCountsV2, type ProductEnvelopeV2, type ProductValidationPins } from './xerProductBaselineV2'; + +const HERE = fileURLToPath(new URL('.', import.meta.url)); +const AXES = ['es', 'ef', 'ls', 'lf', 'tf', 'ff'] as const; +const ROLES = [ + 'oracle', 'engine-input', 'parser-fixture', 'pseudo-xer', 'reference-only', 'synthetic-fixture', +] as const; + +type Axis = (typeof AXES)[number]; +type Role = (typeof ROLES)[number]; +type AxisCounts = { deviations: number; measurable: number }; +type ManifestEntry = { + sha256: string; + source: string; + role: Role; + included: boolean; + exclusionReason?: string; +}; +type Manifest = { version: number; policy: string; files: Record }; +type OracleEntry = { + label: string; + tasks: number; + projects: number; + counters: Record; + schemaFingerprint?: string; + reason?: string; +}; +type OracleBaseline = { files: Record }; +type ReplayAggregate = Record; +type ReplayPin = { + version: number; + manifestEntries: number; + selectedEntries: number; + projects: number; + tasks: number; + candidates: Record; +}; +type ProductCounts = ProductCountsV2; +type ProductV2 = ProductBaselineV2; +type ProductEnvelope = ProductEnvelopeV2; + +const EXPECTED = { + manifestRawSha256: '6defbc4b4a71500565e5847750662060d9baca983952098dd1b334ac81d55786', + baselineRawSha256: 'a7075bd27c73cecae71403bc9b06e8ef53707b756049598c60f125dec0c28b29', + manifestProjectionSha256: 'd38022159ee17e335bb8bf8f736cb9549c8d5f9410132f9a9b4674fe6c23b56d', + byteMultisetSha256: 'b48a8facd1f056a6b0f8219afb4aea46a01fda7be4df060af7cdc429bbf2fb19', + oracleByteUniqueSha256: 'fad95534c46d31dca1d93ea634ba096fa467e101ade8f7e127fd95285d433528', + selectedFullSha256: '685f738809d41450c89804dcb6cdd3481610d787e99734f00534b6f1797af0a3', + selectedSchemaSha256: '26f635e84f858817b19d7493f4958a06113adcfbb46a2c1ba44409b99bf57f06', + selectedContractSha256: 'a5805cef640d38568f1dfe2b67ee17a4f5324ae488fdd1abfd33afa0e4fb3daa', + occurrences: 93, + included: 45, + excluded: 48, + byteUnique: 84, + oracleByteUnique: 36, + selected: 34, + projects: 47, + tasks: 13_982, + tasksWithAnyMeasuredAxis: 13_959, + measurable: { es: 13_931, ef: 13_937, ls: 13_822, lf: 13_813, tf: 13_677, ff: 13_322 }, + // HERPIN 2026-09-07 (één herpin, volledig corpus 93/93, na het landen van 7a én 7b, laag 3 en + // origin/main): 18.398 (v2-baseline, vóór 7b) → 17.421 (kop 1206e010, ná 7b) → 15.056 (nu). + // Per bestand/as gemeten: ALLE beweging zit in rehab-2 (proj_id 761); de overige 33 entries zijn + // byte-identiek aan de v2-baseline. Reden per as, per stap: + // - 7b (weekend-klemherstel, X-O7-uitzondering, plan §5): es 618→940 (+322), ef 813→940 (+127) + // SLECHTER — de gedocumenteerde compensatiefout-blootlegging (dossier 7b-4, §9); ls 4.358→3.889, + // lf 4.350→3.889, tf 4.200→3.903, ff 473→274 beter; drivingPath 79→80. + // - 7a (completed-late, klasse (i)): ls 3.889→2.920 (−969: 969 cellen diff→exact, 0 exact→diff), + // lf 3.889→2.920 (−969, idem), tf 3.903→3.476 (netto −427: 642 diff→exact, 215 exact→diff — de + // 215 zijn allemaal TK_Complete/DT_FixedDUR2 met P6 tf = 0, klasse (ii)); es/ef/ff ongewijzigd; + // drivingPath 80→86 (+6: 87418, 87419, 87420, 87421, 87422, 87426 — open taken die nu exact P6's + // tf = 0 krijgen en daardoor bij ons kritiek worden waar P6's driving_path_flag false zegt). + // - laag 3 en origin/main: 15.056 → 15.056, per constructie en gemeten. + // Sameday (corpusbreed, alle 34 entries): es 96, ef 97, ls 129, lf 93, tf 0, ff 0 — ongewijzigd + // t.o.v. de v2-baseline; plan §1 eist nul, dus dit is een open categorie, geen residu dat hier + // 'met reden' wordt weggepind. + // Eerdere toelichting (7a-herpin op de pre-7b-basis, 2026-09-05) blijft als geschiedenis: die mat + // ls −890/lf −891/tf −358 op de OUDE kalender; op de gereconstrueerde kalender (7b) is de winst van + // dezelfde regel groter (−969/−969/−427). + productStrict: { + exact: { es: 12_358, ef: 12_310, ls: 10_245, lf: 10_199, tf: 9_636, ff: 12_698 }, + sameday: { es: 96, ef: 97, ls: 129, lf: 93, tf: 0, ff: 0 }, + diff: { es: 1_477, ef: 1_530, ls: 3_448, lf: 3_521, tf: 4_041, ff: 624 }, + missing: { es: 0, ef: 0, ls: 0, lf: 0, tf: 0, ff: 0 }, + deviations: { es: 1_573, ef: 1_627, ls: 3_577, lf: 3_614, tf: 4_041, ff: 624 }, + drivingPath: { exact: 13_179, sameday: 0, diff: 417, missing: 0, measurable: 13_596, deviations: 417 }, + }, + productPayloadSha256: 'ce8527748344de7f5bae3fc0c64041eff522cc939dcafa19d58c6df9c39233be', + productPayloadGzipSha256: 'e624e5d719c3bbb6bf02f2c69c74d07f988d39a0c9844fb492c4333e4a8cdcc2', + productProjectProjectionSha256: '088f059adb65fcad8e392d0e2c9f0efc522314588e60ee6936b12cc4e9487676', + roles: { + oracle: 45, + 'engine-input': 14, + 'parser-fixture': 15, + 'pseudo-xer': 13, + 'reference-only': 1, + 'synthetic-fixture': 5, + } satisfies Record, +} as const; + +const HEX_64 = /^[0-9a-f]{64}$/; +const HEX_16 = /^[0-9a-f]{16}$/; +const ALLOWED_STATIC_IMPORTS = new Set([ + 'node:crypto', + 'node:fs', + 'node:path', + 'node:url', + 'node:zlib', + './xerProductBaselineV2', +]); +const diffs: string[] = []; +const mutationEvidence: string[] = []; +const EXPECTED_MUTANTS = 52; +const EXPECTED_POSITIVE_CONTROLS = 5; +const EXPECTED_TOTAL_CHECKS = EXPECTED_MUTANTS + EXPECTED_POSITIVE_CONTROLS; +let mutationChecks = 0; +let positiveChecks = 0; + +function stableHash(value: unknown): string { + return createHash('sha256').update(JSON.stringify(value)).digest('hex'); +} + +function rawHash(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +function issue(problems: string[], message: string): void { + problems.push(message); +} + +function equal(problems: string[], label: string, got: unknown, want: unknown): void { + if (JSON.stringify(got) !== JSON.stringify(want)) { + issue(problems, `${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +function checkPositive(label: string, got: unknown, want: unknown): void { + positiveChecks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +function checkMutationEqual(label: string, got: unknown, want: unknown, evidence: string): void { + mutationChecks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } else { + mutationEvidence.push(`${label}: ROOD — ${evidence}`); + } +} + +function asObject(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : null; +} + +function isRole(value: unknown): value is Role { + return typeof value === 'string' && (ROLES as readonly string[]).includes(value); +} + +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0; +} + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function manifestEntries(manifest: Manifest): Array<{ label: string; entry: ManifestEntry }> { + return Object.entries(manifest.files).map(([label, entry]) => ({ label, entry })); +} + +function manifestProjection(manifest: Manifest): unknown[] { + return manifestEntries(manifest) + .map(({ label, entry }) => ({ + label, + sha256: entry.sha256, + source: entry.source, + role: entry.role, + included: entry.included, + exclusionReason: entry.exclusionReason ?? null, + })) + .sort((left, right) => left.label.localeCompare(right.label)); +} + +function byHash(manifest: Manifest): Map> { + const index = new Map>(); + for (const item of manifestEntries(manifest)) { + const current = index.get(item.entry.sha256) ?? []; + current.push(item); + index.set(item.entry.sha256, current); + } + return index; +} + +function selectedProjection(baseline: OracleBaseline, manifest: Manifest): Array<{ + prefix: string; + sha256: string | null; + schemaFingerprint: string | null; + projects: number; + tasks: number; + counters: Record; + reason: string | null; +}> { + const fullHashes = [...byHash(manifest).keys()]; + return Object.entries(baseline.files) + .map(([prefix, entry]) => ({ + prefix, + sha256: fullHashes.filter(full => full.startsWith(prefix))[0] ?? null, + schemaFingerprint: entry.schemaFingerprint ?? null, + projects: entry.projects, + tasks: entry.tasks, + counters: entry.counters, + reason: entry.reason ?? null, + })) + .sort((left, right) => (left.sha256 ?? '').localeCompare(right.sha256 ?? '')); +} + +function validateInventory(raw: unknown): { manifest: Manifest | null; problems: string[] } { + const problems: string[] = []; + const root = asObject(raw); + if (!root || !asObject(root.files)) { + issue(problems, 'inventaris: root/files is geen object'); + return { manifest: null, problems }; + } + const manifest = raw as Manifest; + const entries = manifestEntries(manifest); + equal(problems, 'inventaris.version', manifest.version, 1); + equal(problems, 'inventaris.occurrences', entries.length, EXPECTED.occurrences); + + const roleCounts = Object.fromEntries(ROLES.map(role => [role, 0])) as Record; + let included = 0; + let excluded = 0; + for (const { label, entry } of entries) { + if (!label.trim()) issue(problems, 'inventaris: lege occurrence-identiteit'); + if (!entry || typeof entry !== 'object') { issue(problems, `inventaris ${label}: geen entry`); continue; } + if (!HEX_64.test(entry.sha256 ?? '')) issue(problems, `inventaris ${label}: sha256 is niet 64 lowercase hex`); + if (!isRole(entry.role)) issue(problems, `inventaris ${label}: onbekende rol`); + else roleCounts[entry.role]++; + if (entry.included !== (entry.role === 'oracle')) { + issue(problems, `inventaris ${label}: included moet precies role=oracle volgen`); + } + if (entry.included) included++; + else { + excluded++; + if (typeof entry.exclusionReason !== 'string' || !entry.exclusionReason.trim()) { + issue(problems, `inventaris ${label}: uitgesloten entry mist exclusionReason`); + } + } + } + equal(problems, 'inventaris.rolverdeling', roleCounts, EXPECTED.roles); + equal(problems, 'inventaris.included', included, EXPECTED.included); + equal(problems, 'inventaris.excluded', excluded, EXPECTED.excluded); + + const hashIndex = byHash(manifest); + equal(problems, 'inventaris.byte-uniek', hashIndex.size, EXPECTED.byteUnique); + const occurrenceMultiset = Object.entries(Object.fromEntries( + [...hashIndex.entries()].map(([hash, occurrences]) => [hash, occurrences.length]), + )).sort(([left], [right]) => left.localeCompare(right)); + equal(problems, 'inventaris.byte-multiset-digest', stableHash(occurrenceMultiset), EXPECTED.byteMultisetSha256); + equal(problems, 'inventaris.semantische-projectie', stableHash(manifestProjection(manifest)), EXPECTED.manifestProjectionSha256); + return { manifest, problems }; +} + +function validateOracle(raw: unknown, manifest: Manifest): { baseline: OracleBaseline | null; problems: string[] } { + const problems: string[] = []; + const root = asObject(raw); + if (!root || !asObject(root.files)) { + issue(problems, 'orakel: root/files is geen object'); + return { baseline: null, problems }; + } + const baseline = raw as OracleBaseline; + const entries = Object.entries(baseline.files); + equal(problems, 'orakel.selectie-aantal', entries.length, EXPECTED.selected); + + const fullIndex = byHash(manifest); + const includedUnique = [...fullIndex.entries()] + .filter(([, occurrences]) => occurrences.some(({ entry }) => entry.role === 'oracle' && entry.included)) + .map(([hash]) => hash) + .sort(); + equal(problems, 'orakel.byte-unieke-orakels', includedUnique.length, EXPECTED.oracleByteUnique); + equal(problems, 'orakel.byte-unieke-orakels-digest', stableHash(includedUnique), EXPECTED.oracleByteUniqueSha256); + + const seenFull = new Set(); + const seenFingerprints = new Set(); + let projects = 0; + let tasks = 0; + const measurable = { es: 0, ef: 0, ls: 0, lf: 0, tf: 0, ff: 0 } as Record; + for (const [prefix, entry] of entries) { + if (!HEX_16.test(prefix)) issue(problems, `orakel ${prefix}: sleutel is geen 16-hexprefix`); + const matches = [...fullIndex.entries()].filter(([hash]) => hash.startsWith(prefix)); + if (matches.length !== 1) issue(problems, `orakel ${prefix}: prefixbotsing of ontbrekende volledige SHA (${matches.length})`); + const [fullHash, occurrences] = matches[0] ?? []; + if (fullHash) { + if (seenFull.has(fullHash)) issue(problems, `orakel ${prefix}: dubbele geselecteerde volledige SHA`); + seenFull.add(fullHash); + if (!occurrences?.some(({ entry: manifestEntry }) => manifestEntry.role === 'oracle' && manifestEntry.included)) { + issue(problems, `orakel ${prefix}: verwijst niet naar een inbegrepen orakelentry`); + } + } + if (!entry || typeof entry !== 'object') { issue(problems, `orakel ${prefix}: geen entry`); continue; } + if (typeof entry.schemaFingerprint !== 'string' || !entry.schemaFingerprint.trim()) { + issue(problems, `orakel ${prefix}: schemaFingerprint ontbreekt`); + } else if (seenFingerprints.has(entry.schemaFingerprint)) { + issue(problems, `orakel ${prefix}: dubbele schemaFingerprint`); + } else seenFingerprints.add(entry.schemaFingerprint); + if ('reason' in entry) issue(problems, `orakel ${prefix}: reason is in de nulpoort verboden`); + if (!isNonNegativeInteger(entry.projects) || !isNonNegativeInteger(entry.tasks)) { + issue(problems, `orakel ${prefix}: projects/tasks zijn geen niet-negatieve gehele getallen`); + } else { + projects += entry.projects; + tasks += entry.tasks; + } + for (const axis of AXES) { + const counts = entry.counters?.[axis]; + if (!counts || !isNonNegativeInteger(counts.measurable) || !isNonNegativeInteger(counts.deviations)) { + issue(problems, `orakel ${prefix}.${axis}: ongeldige counters`); + continue; + } + if (counts.deviations !== 0) issue(problems, `orakel ${prefix}.${axis}: deviations moet nul zijn`); + if (counts.deviations > counts.measurable) issue(problems, `orakel ${prefix}.${axis}: deviations > measurable`); + measurable[axis] += counts.measurable; + } + } + equal(problems, 'orakel.tweede-dedup', includedUnique.length - seenFull.size, 2); + equal(problems, 'orakel.geselecteerde-volledige-SHAs', stableHash([...seenFull].sort()), EXPECTED.selectedFullSha256); + equal(problems, 'orakel.schemafingerprintset', stableHash(selectedProjection(baseline, manifest) + .map(({ sha256, schemaFingerprint }) => ({ sha256, schemaFingerprint }))), EXPECTED.selectedSchemaSha256); + equal(problems, 'orakel.volledige-selectiecontract', stableHash(selectedProjection(baseline, manifest)), EXPECTED.selectedContractSha256); + equal(problems, 'orakel.projecten', projects, EXPECTED.projects); + equal(problems, 'orakel.TASK-rijen', tasks, EXPECTED.tasks); + equal(problems, 'orakel.meetbaar-per-as', measurable, EXPECTED.measurable); + return { baseline, problems }; +} + +function validateReplay(raw: unknown, oracle: OracleBaseline): string[] { + const problems: string[] = []; + const root = asObject(raw); + if (!root || !asObject(root.candidates)) return ['replay: root/candidates is geen object']; + const replay = raw as ReplayPin; + const oracleEntries = Object.values(oracle.files); + const oracleProjects = oracleEntries.reduce((sum, entry) => sum + entry.projects, 0); + const oracleTasks = oracleEntries.reduce((sum, entry) => sum + entry.tasks, 0); + const oracleMeasurable = Object.fromEntries(AXES.map(axis => [axis, + oracleEntries.reduce((sum, entry) => sum + entry.counters[axis].measurable, 0), + ])) as Record; + equal(problems, 'replay.version', replay.version, 1); + equal(problems, 'replay.manifestEntries', replay.manifestEntries, EXPECTED.occurrences); + equal(problems, 'replay.selectedEntries', replay.selectedEntries, EXPECTED.selected); + equal(problems, 'replay.projecten', replay.projects, oracleProjects); + equal(problems, 'replay.TASK-rijen', replay.tasks, oracleTasks); + const zero = replay.candidates['synthetic-zero-regression']; + const negative = replay.candidates['drop-p6-finish-milestone-boundary']; + if (!zero || !negative) return [...problems, 'replay: verplichte kandidaten ontbreken']; + for (const axis of AXES) { + const candidate = zero.aggregate?.[axis]; + if (!candidate) { issue(problems, `replay nul ${axis}: aggregate ontbreekt`); continue; } + equal(problems, `replay nul ${axis}.improved`, candidate.improved, 0); + equal(problems, `replay nul ${axis}.regressed`, candidate.regressed, 0); + equal(problems, `replay nul ${axis}.unchanged`, candidate.unchanged, oracleMeasurable[axis]); + } + equal(problems, 'replay nul overall.improved', zero.aggregate?.overall?.improved, 0); + equal(problems, 'replay nul overall.regressed', zero.aggregate?.overall?.regressed, 0); + equal(problems, 'replay nul overall.unchanged', zero.aggregate?.overall?.unchanged, EXPECTED.tasksWithAnyMeasuredAxis); + equal(problems, 'replay nul exitcode', zero.exitCode, 0); + equal(problems, 'replay nul rejected', zero.rejected, false); + const negativeRegressions = AXES.reduce((sum, axis) => sum + (negative.aggregate?.[axis]?.regressed ?? 0), 0); + checkProblem(problems, 'replay negatief heeft minstens één regressie', negativeRegressions > 0); + equal(problems, 'replay negatief exitcode', negative.exitCode, 1); + equal(problems, 'replay negatief rejected', negative.rejected, true); + return problems; +} + +function decodeProductPayload(envelope: ProductEnvelope): ProductV2 { + return decodeProductEnvelopeUnchecked(envelope); +} + +function withMutatedProduct(envelope: ProductEnvelope, mutate: (product: ProductV2) => void): ProductEnvelope { + const product = clone(decodeProductPayload(envelope)); + mutate(product); + return createProductEnvelope(sealProductBaseline(product)); +} + +function withRawMutatedProduct(envelope: ProductEnvelope, mutate: (product: ProductV2) => void): ProductEnvelope { + const product = clone(decodeProductPayload(envelope)); + mutate(product); + const payloadText = canonicalProductJson(product); + const compressed = gzipSync(Buffer.from(payloadText, 'utf8'), { level: 9 }); + return { + ...envelope, + payloadSha256: productSha256(payloadText), + payloadGzipSha256: productSha256(compressed), + projectProjectionSha256: productProjectionDigest(product), + payloadGzipBase64: compressed.toString('base64'), + }; +} + +function productPins(manifest: Manifest, oracle: OracleBaseline): ProductValidationPins { + const fullHashes = [...byHash(manifest).keys()]; + const entries = Object.fromEntries(Object.entries(oracle.files).map(([prefix, entry]) => { + const matches = fullHashes.filter(hashValue => hashValue.startsWith(prefix)); + if (matches.length !== 1 || !entry.schemaFingerprint) throw new Error(`product-v2-pin ${prefix} is niet eenduidig`); + return [matches[0]!, { schemaFingerprint: entry.schemaFingerprint }]; + })); + const counters = Object.fromEntries(AXES.map(axis => [axis, { + exact: EXPECTED.productStrict.exact[axis], + sameday: EXPECTED.productStrict.sameday[axis], + diff: EXPECTED.productStrict.diff[axis], + missing: EXPECTED.productStrict.missing[axis], + measurable: EXPECTED.measurable[axis], + deviations: EXPECTED.productStrict.deviations[axis], + }])) as Record; + return { + manifestSha256: rawHash(manifestRaw), + entries, + projects: EXPECTED.projects, + tasks: EXPECTED.tasks, + counters, + drivingPath: EXPECTED.productStrict.drivingPath, + payloadSha256: EXPECTED.productPayloadSha256, + payloadGzipSha256: EXPECTED.productPayloadGzipSha256, + projectProjectionSha256: EXPECTED.productProjectProjectionSha256, + }; +} + +/** + * Corpusloze laag C pakt de volledige v2-snapshot uit en valideert hem tegen de al gepinde + * manifest-/orakelselectie. Hij kan niet zélf productnul bewijzen, maar hij mag evenmin een + * overgangssnapshot als eindacceptatie presenteren. + */ +function validateProductV2(raw: string | ProductEnvelope, manifest: Manifest, oracle: OracleBaseline): string[] { + const rawText = typeof raw === 'string' ? raw : canonicalProductJson(raw); + return validateProductBaselineV2(rawText, productPins(manifest, oracle)).problems; +} + +function checkProblem(problems: string[], label: string, condition: boolean): void { + if (!condition) issue(problems, label); +} + +function stripExecutableNoise(source: string): string { + let result = ''; + let state: 'code' | 'line-comment' | 'block-comment' | 'single-quote' | 'double-quote' | 'template' = 'code'; + let escaped = false; + + for (let index = 0; index < source.length; index++) { + const char = source[index]!; + const next = source[index + 1]; + + if (state === 'code') { + if (char === '/' && next === '/') { + result += ' '; + index++; + state = 'line-comment'; + continue; + } + if (char === '/' && next === '*') { + result += ' '; + index++; + state = 'block-comment'; + continue; + } + if (char === '\'') { + result += char; + state = 'single-quote'; + escaped = false; + continue; + } + if (char === '"') { + result += char; + state = 'double-quote'; + escaped = false; + continue; + } + if (char === '`') { + result += char; + state = 'template'; + escaped = false; + continue; + } + result += char; + continue; + } + + if (state === 'line-comment') { + result += char === '\n' ? '\n' : ' '; + if (char === '\n') state = 'code'; + continue; + } + + if (state === 'block-comment') { + if (char === '*' && next === '/') { + result += ' '; + index++; + state = 'code'; + } else { + result += char === '\n' ? '\n' : ' '; + } + continue; + } + + if (state === 'single-quote') { + result += char; + if (escaped) escaped = false; + else if (char === '\\') escaped = true; + else if (char === '\'') state = 'code'; + continue; + } + + if (state === 'double-quote') { + result += char; + if (escaped) escaped = false; + else if (char === '\\') escaped = true; + else if (char === '"') state = 'code'; + continue; + } + + result += char; + if (escaped) escaped = false; + else if (char === '\\') escaped = true; + else if (char === '`') state = 'code'; + } + + return result; +} + +const DYNAMIC_IMPORT_RE = new RegExp(['\\b', 'import', '\\s*', '\\('].join('')); +const DYNAMIC_REQUIRE_RE = new RegExp(['\\b', 'require', '\\s*', '\\('].join('')); +const DYNAMIC_IMPORT_LABEL = ['import', '()'].join(''); +const DYNAMIC_REQUIRE_LABEL = ['require', '()'].join(''); + +function validateOwnSource(raw: string): string[] { + const problems: string[] = []; + const importModules: string[] = []; + const lines = raw.split(/\r?\n/); + + for (const [index, line] of lines.entries()) { + const trimmed = line.trim(); + if (!trimmed.startsWith('import ')) continue; + + const match = trimmed.match(/^import\s+(?:type\s+)?(?:[\s\S]*?\s+from\s+)?['"]([^'"]+)['"];?$/); + if (!match) { + issue(problems, `bron ${index + 1}: statische importregel is niet line-anchored of niet exact`); + continue; + } + importModules.push(match[1]!); + } + + equal(problems, 'bron.statische-import-aantal', importModules.length, 6); + equal(problems, 'bron.statische-import-modules', [...new Set(importModules)].sort(), [...ALLOWED_STATIC_IMPORTS].sort()); + for (const module of importModules) { + if (!ALLOWED_STATIC_IMPORTS.has(module)) { + issue(problems, `bron: verboden statische import ${module}`); + } + } + + const executableScan = stripExecutableNoise(raw); + if (DYNAMIC_IMPORT_RE.test(executableScan)) issue(problems, `bron: dynamische ${DYNAMIC_IMPORT_LABEL} is verboden`); + if (DYNAMIC_REQUIRE_RE.test(executableScan)) issue(problems, `bron: ${DYNAMIC_REQUIRE_LABEL} is verboden`); + + return problems; +} + +function validateContract(manifestRaw: unknown, oracleRaw: unknown, replayRaw: unknown): string[] { + const inventory = validateInventory(manifestRaw); + if (!inventory.manifest) return inventory.problems; + const oracle = validateOracle(oracleRaw, inventory.manifest); + if (!oracle.baseline) return [...inventory.problems, ...oracle.problems]; + return [...inventory.problems, ...oracle.problems, ...validateReplay(replayRaw, oracle.baseline)]; +} + +function expectRejected(label: string, manifest: Manifest, oracle: OracleBaseline, replay: ReplayPin): void { + mutationChecks++; + const problems = validateContract(manifest, oracle, replay); + if (problems.length === 0) diffs.push(`${label}: mutant werd ten onrechte geaccepteerd`); + else mutationEvidence.push(`${label}: ROOD — ${problems[0]}`); +} + +function expectProductRejected(label: string, envelope: string | ProductEnvelope, manifest: Manifest, oracle: OracleBaseline): void { + mutationChecks++; + const problems = validateProductV2(envelope, manifest, oracle); + if (problems.length === 0) diffs.push(`${label}: product-v2-mutant werd ten onrechte geaccepteerd`); + else mutationEvidence.push(`${label}: ROOD — ${problems[0]}`); +} + +function expectSourceRejected(label: string, raw: string): void { + mutationChecks++; + const problems = validateOwnSource(raw); + if (problems.length === 0) diffs.push(`${label}: bronmutant werd ten onrechte geaccepteerd`); + else mutationEvidence.push(`${label}: ROOD — ${problems[0]}`); +} + +const manifestRaw = readFileSync(join(HERE, 'xer-corpus-manifest.json'), 'utf8'); +const oracleRaw = readFileSync(join(HERE, 'xer-fidelity-baseline.json'), 'utf8'); +const replayRaw = readFileSync(join(HERE, 'xer-task-replay-public-pin.json'), 'utf8'); +const productV2Raw = readFileSync(join(HERE, 'xer-product-fidelity-baseline-v2.json'), 'utf8'); +const sourceRaw = readFileSync(join(HERE, 'check-xer-corpusless-fidelity-gate.ts'), 'utf8'); +const manifest = JSON.parse(manifestRaw) as Manifest; +const oracle = JSON.parse(oracleRaw) as OracleBaseline; +const replay = JSON.parse(replayRaw) as ReplayPin; +const productV2 = JSON.parse(productV2Raw) as ProductEnvelope; +const decodedProductV2 = decodeProductPayload(productV2); +const firstProductLabel = Object.keys(decodedProductV2.files)[0]!; +const multiProjectLabel = Object.entries(decodedProductV2.files) + .find(([, entry]) => entry.projectMeasurements.length >= 2)?.[0]; +const algebraProjectLabel = Object.entries(decodedProductV2.files) + .find(([, entry]) => entry.projectMeasurements.some((project, index) => project.counters.es.exact > 0 + && entry.projectMeasurements.some((candidate, candidateIndex) => candidateIndex !== index + && candidate.counters.es.diff > 0)))?.[0]; +if (!multiProjectLabel) throw new Error('product-v2-mutanten vereisen minstens een multi-projectentry'); +if (!algebraProjectLabel) throw new Error('product-v2-mutanten vereisen geschikte projecttellers'); + +type NewMutationCase = { + id: 'M33' | 'M34' | 'M35' | 'M36' | 'M37'; + label: string; + run: () => string[]; +}; + +const newMutationCases: readonly NewMutationCase[] = [ + { + id: 'M33', + label: 'M33 neutrale onbekende entrykey', + run: () => validateProductV2(withRawMutatedProduct(productV2, product => { + (product.files[firstProductLabel] as unknown as Record).unknownEntryKey = true; + }), manifest, oracle), + }, + { + id: 'M34', + label: 'M34 projectprojectie niet canoniek op project-ID gesorteerd', + run: () => validateProductV2(withRawMutatedProduct(productV2, product => { + const projects = product.files[multiProjectLabel]!.projectMeasurements; + [projects[0], projects[1]] = [projects[1]!, projects[0]!]; + }), manifest, oracle), + }, + { + id: 'M35', + label: 'M35 meetbaarheid boven projecttaaknoemer met intacte telleralgebra', + run: () => validateProductV2(withMutatedProduct(productV2, product => { + const entry = product.files[firstProductLabel]!; + const project = entry.projectMeasurements[0]!; + const projectCounts = project.counters.es; + const entryCounts = entry.counters.es; + const delta = project.truthTasks - projectCounts.measurable + 1; + projectCounts.missing += delta; + projectCounts.measurable += delta; + projectCounts.deviations += delta; + entryCounts.missing += delta; + entryCounts.measurable += delta; + entryCounts.deviations += delta; + }), manifest, oracle), + }, + { + id: 'M36', + label: 'M36 entry-identiteitsnoemer wijkt af van projectsom', + run: () => validateProductV2(withMutatedProduct(productV2, product => { + product.files[firstProductLabel]!.identityCoverage.solvedTasks--; + }), manifest, oracle), + }, + { + id: 'M37', + label: 'M37 dynamische import omzeilt de statische bronallowlist', + run: () => validateOwnSource([ + sourceRaw, + `void ${['im', 'port'].join('')}('@/services/xer/xerReader');`, + '', + ].join('\n')), + }, +]; + +const singleMutant = process.env.OPS_XER_SINGLE_MUTANT; +if (singleMutant !== undefined) { + const mutation = newMutationCases.find(candidate => candidate.id === singleMutant); + if (!mutation) { + console.error(`XX onbekende afzonderlijke mutant ${JSON.stringify(singleMutant)}; verwacht M33..M37`); + process.exit(2); + } + const problems = mutation.run(); + if (problems.length === 0) { + console.log(`XX MUTANT ${mutation.id} werd ten onrechte geaccepteerd: ${mutation.label}`); + process.exit(0); + } + console.error(`MUTANT ${mutation.id} ROOD: ${mutation.label} — ${problems[0]}`); + process.exit(1); +} + +// De actuele contracten worden als drie afzonderlijke lagen gecontroleerd. Een lege foutlijst is +// de eerste groene toestand; de matrix hieronder bewijst vervolgens dat iedere bescherming bij +// één gerichte in-memory wijziging rood wordt, zonder een tracked JSON-bestand aan te raken. +{ + const currentSourceProblems = validateOwnSource(sourceRaw); + checkPositive('bron huidige ongebundelde TS-bron is strikt corpusloos', currentSourceProblems, []); + + expectSourceRejected('bronmutant met readXER-import wordt afgewezen', + `${sourceRaw}\nimport { readXER } from '@/services/xer/xerReader';\n`); + + const templateExpressionRequireSource = [ + sourceRaw, + "const templateTrap = `probe ${" + ['requ', "ire('x')"].join('') + "} binnen een template-expressie`;", + '', + ].join('\n'); + expectSourceRejected('bronmutant met require in template-expressie wordt afgewezen', templateExpressionRequireSource); + + const multilineImportSource = [ + sourceRaw, + 'import {', + ' readXER', + "} from '@/services/xer/xerReader';", + '', + ].join('\n'); + expectSourceRejected('bronmutant met multiline import wordt afgewezen', multilineImportSource); + + const problems = validateContract(manifest, oracle, replay); + checkPositive(`A+B+D huidige statische contracten zijn consistent (${problems.join('; ')})`, problems, []); + checkPositive('A raw manifestbytes zijn exact gepind', rawHash(manifestRaw), EXPECTED.manifestRawSha256); + checkPositive('B raw oraclebaselinebytes zijn exact gepind', rawHash(oracleRaw), EXPECTED.baselineRawSha256); + const productProblems = validateProductV2(productV2Raw, manifest, oracle); + checkPositive(`C v2-karakteriseringssnapshot is volledig maar strict zichtbaar rood (${productProblems.join('; ')})`, productProblems, []); + console.log('INFO xer-corpusless-fidelity-gate: v2 productkarakterisering is volledig vastgepind; strict minute-exact eindnul blijft expliciet rood en ongeaccepteerd.'); +} + +// In-memory mutantmatrix. Elke mutatie treft één contractuitspraak; niets op schijf verandert. +{ + const firstLabel = Object.keys(manifest.files)[0]!; + const excludedLabel = Object.entries(manifest.files) + .find(([, entry]) => !entry.included)?.[0]; + const firstOracle = Object.keys(oracle.files)[0]!; + const secondOracle = Object.keys(oracle.files)[1]!; + const excludedHash = excludedLabel ? manifest.files[excludedLabel]!.sha256 : undefined; + + const removedOccurrence = clone(manifest); + delete removedOccurrence.files[firstLabel]; + expectRejected('M1 occurrence verwijderen', removedOccurrence, oracle, replay); + + const addedOccurrence = clone(manifest); + addedOccurrence.files['mutant-extra-occurrence.xer'] = clone(addedOccurrence.files[firstLabel]!); + expectRejected('M2 occurrence toevoegen', addedOccurrence, oracle, replay); + + const changedSha = clone(manifest); + changedSha.files[firstLabel]!.sha256 = `0${changedSha.files[firstLabel]!.sha256.slice(1)}`; + expectRejected('M3 volledige SHA wijzigen', changedSha, oracle, replay); + + const changedRole = clone(manifest); + changedRole.files[firstLabel]!.role = 'parser-fixture'; + expectRejected('M4 rol wijzigen zonder included mee te wijzigen', changedRole, oracle, replay); + + const changedIncluded = clone(manifest); + changedIncluded.files[firstLabel]!.included = !changedIncluded.files[firstLabel]!.included; + expectRejected('M5 included wijzigen zonder rol', changedIncluded, oracle, replay); + + const missingExclusionReason = clone(manifest); + if (excludedLabel) delete missingExclusionReason.files[excludedLabel]!.exclusionReason; + expectRejected('M6 uitsluitingsreden verwijderen', missingExclusionReason, oracle, replay); + + const emptyOccurrenceIdentity = clone(manifest); + emptyOccurrenceIdentity.files[''] = emptyOccurrenceIdentity.files[firstLabel]!; + delete emptyOccurrenceIdentity.files[firstLabel]; + expectRejected('M7 lege occurrence-identiteit', emptyOccurrenceIdentity, oracle, replay); + + const excludedOracleSource = clone(oracle); + if (excludedHash) { + delete excludedOracleSource.files[firstOracle]; + excludedOracleSource.files[excludedHash.slice(0, 16)] = clone(oracle.files[firstOracle]!); + } + expectRejected('M8 uitgesloten bron als orakel selecteren', manifest, excludedOracleSource, replay); + + const shortenedSourcePrefix = clone(oracle); + delete shortenedSourcePrefix.files[firstOracle]; + shortenedSourcePrefix.files[firstOracle.slice(0, 15)] = clone(oracle.files[firstOracle]!); + expectRejected('M9 bronhashprefix inkorten', manifest, shortenedSourcePrefix, replay); + + const duplicateFingerprint = clone(oracle); + duplicateFingerprint.files[secondOracle]!.schemaFingerprint = duplicateFingerprint.files[firstOracle]!.schemaFingerprint; + expectRejected('M10 duplicate schemafingerprint', manifest, duplicateFingerprint, replay); + + const changedFingerprint = clone(oracle); + const originalFingerprint = changedFingerprint.files[firstOracle]!.schemaFingerprint!; + changedFingerprint.files[firstOracle]!.schemaFingerprint = `${originalFingerprint.slice(0, -1)}${originalFingerprint.endsWith('0') ? '1' : '0'}`; + expectRejected('M11 schemafingerprint wijzigen', manifest, changedFingerprint, replay); + + const lostSelectedKey = clone(oracle); + delete lostSelectedKey.files[firstOracle]; + expectRejected('M12 selected keysetverlies', manifest, lostSelectedKey, replay); + + const measurableMinusOne = clone(oracle); + measurableMinusOne.files[firstOracle]!.counters.es.measurable--; + expectRejected('M13 measurable -1', manifest, measurableMinusOne, replay); + + const deviationOne = clone(oracle); + deviationOne.files[firstOracle]!.counters.es.deviations = 1; + expectRejected('M14 deviation 1', manifest, deviationOne, replay); + + const reasonAdded = clone(oracle); + reasonAdded.files[firstOracle]!.reason = 'verboden eindpoort-pin'; + expectRejected('M15 reason toevoegen', manifest, reasonAdded, replay); + + const swappedTotals = clone(replay); + swappedTotals.tasks = EXPECTED.tasksWithAnyMeasuredAxis; + expectRejected('M16 13982/13959 verwisselen', manifest, oracle, swappedTotals); + + const zeroRegressed = clone(replay); + zeroRegressed.candidates['synthetic-zero-regression']!.aggregate.es.regressed = 1; + expectRejected('M17 replay nul kandidaat regressed 1', manifest, oracle, zeroRegressed); + + const negativeExitGreen = clone(replay); + negativeExitGreen.candidates['drop-p6-finish-milestone-boundary']!.exitCode = 0; + expectRejected('M18 replay negatieve kandidaat exitcode 0', manifest, oracle, negativeExitGreen); + + const negativeWithoutRegression = clone(replay); + for (const axis of AXES) { + negativeWithoutRegression.candidates['drop-p6-finish-milestone-boundary']!.aggregate[axis].regressed = 0; + } + expectRejected('M19 replay negatieve kandidaat zonder regressie', manifest, oracle, negativeWithoutRegression); + + const v1AsFinal = clone(productV2); + (v1AsFinal as { version: number }).version = 1; + expectProductRejected('M20 v1-baseline kan niet stil als finale v2 gelden', v1AsFinal, manifest, oracle); + + expectProductRejected('M21 projectnoemer vergeten', withMutatedProduct(productV2, product => { + product.files[firstProductLabel]!.projects--; + }), manifest, oracle); + + expectProductRejected('M22 een meetbare cel stil overslaan', withMutatedProduct(productV2, product => { + product.files[firstProductLabel]!.projectMeasurements[0]!.counters.es.measurable--; + }), manifest, oracle); + + expectProductRejected('M23 entry-project-taskcode-identiteit drift', withMutatedProduct(productV2, product => { + product.files[firstProductLabel]!.projectMeasurements[0]!.taskCodeExact--; + }), manifest, oracle); + + expectProductRejected('M24 productmanifesthash drift', withMutatedProduct(productV2, product => { + product.manifestSha256 = `0${product.manifestSha256.slice(1)}`; + }), manifest, oracle); + + expectProductRejected('M25 productschemafingerprint drift', withMutatedProduct(productV2, product => { + const entry = product.files[firstProductLabel]!; + entry.schemaFingerprint = `${entry.schemaFingerprint.slice(0, -1)}${entry.schemaFingerprint.endsWith('0') ? '1' : '0'}`; + }), manifest, oracle); + + const drivingEntry = Object.values(decodeProductPayload(productV2).files) + .find(entry => entry.drivingPath.deviations > 0); + if (!drivingEntry) throw new Error('M26 vereist een entry met driving-path-afwijking'); + const strictZeroDrivingRed = clone(drivingEntry); + for (const axis of PRODUCT_AXES) { + const entryCounts = strictZeroDrivingRed.counters[axis]; + Object.assign(entryCounts, { exact: entryCounts.measurable, sameday: 0, diff: 0, missing: 0, deviations: 0 }); + for (const project of strictZeroDrivingRed.projectMeasurements) { + const counts = project.counters[axis]; + Object.assign(counts, { exact: counts.measurable, sameday: 0, diff: 0, missing: 0, deviations: 0 }); + } + } + checkMutationEqual('M26 gedragsmutant: drivingPath aan zesassige gate koppelen wordt rood', { + canonicalStrictGate: deriveStrictEntryGate(strictZeroDrivingRed), + mutatedDrivingGate: deriveStrictEntryGate(strictZeroDrivingRed) + && strictZeroDrivingRed.drivingPath.deviations === 0, + }, { canonicalStrictGate: true, mutatedDrivingGate: false }, 'canonieke gate true, driving-mutant false'); + + const routeInputs = { + strictMinuteExact: 'STRICT-INVOER', + historicalCompletedLate: 'HISTORISCH-TEGENFEIT', + sourceDayPrecision: 'DAGPRECISIE-TEGENFEIT', + }; + const strictRoute = selectProductReportMode('strict-minute-exact', routeInputs); + const swappedRoute = selectProductReportMode('historical-completed-late', routeInputs); + checkMutationEqual('M27 gedragsmutant: strict werkelijk naar tegenfeitelijke invoer routeren wordt rood', { + canonical: [strictRoute.report, strictRoute.strictGateEligible], + mutated: [swappedRoute.report, swappedRoute.strictGateEligible], + }, { + canonical: ['STRICT-INVOER', true], + mutated: ['HISTORISCH-TEGENFEIT', false], + }, 'strict gebruikt andere invoer en is als enige poortgeschikt'); + const swappedModes = clone(productV2); + (swappedModes as unknown as { reportModes: string[] }).reportModes = [ + 'historical-completed-late', 'strict-minute-exact', 'source-day-precision', + ]; + expectProductRejected('M27b verwisselde runtime-modusvolgorde in envelope', swappedModes, manifest, oracle); + + expectProductRejected('M28 onbekend priveveld in entry', withRawMutatedProduct(productV2, product => { + (product.files[firstProductLabel] as unknown as Record).privateCustomerName = 'verboden'; + }), manifest, oracle); + + for (const forbiddenKey of ['taskName', 'taskCodes', ' reason Note ', 'acceptedDeviation'] as const) { + expectProductRejected(`M28 privacy/beslisveld ${JSON.stringify(forbiddenKey)}`, withRawMutatedProduct(productV2, product => { + (product.files[firstProductLabel] as unknown as Record)[forbiddenKey] = 'verboden'; + }), manifest, oracle); + } + expectProductRejected('M28 unknown envelopekey', Object.assign(clone(productV2), { unknownEnvelopeKey: true }), manifest, oracle); + expectProductRejected('M28 unknown payloadkey', withRawMutatedProduct(productV2, product => { + (product as unknown as Record).unknownPayloadKey = true; + }), manifest, oracle); + expectProductRejected('M28 unknown projectkey', withRawMutatedProduct(productV2, product => { + (product.files[firstProductLabel]!.projectMeasurements[0] as unknown as Record).unknownProjectKey = true; + }), manifest, oracle); + expectProductRejected('M28 unknown identityCoverage-key', withRawMutatedProduct(productV2, product => { + (product.files[firstProductLabel]!.identityCoverage as unknown as Record).unknownIdentityKey = true; + }), manifest, oracle); + expectProductRejected('M28 unknown tellerkey', withRawMutatedProduct(productV2, product => { + (product.files[firstProductLabel]!.counters.es as unknown as Record).unknownCountKey = 0; + }), manifest, oracle); + + expectProductRejected('M29 projectprojecties tussen twee project-IDs migreren', withMutatedProduct(productV2, product => { + const projects = product.files[multiProjectLabel]!.projectMeasurements; + [projects[0]!.projectId, projects[1]!.projectId] = [projects[1]!.projectId, projects[0]!.projectId]; + }), manifest, oracle); + + const recompressedPayload = gunzipSync(Buffer.from(productV2.payloadGzipBase64, 'base64')); + expectProductRejected('M30 pure gziprecompressie', { + ...clone(productV2), + payloadGzipSha256: productSha256(gzipSync(recompressedPayload, { level: 1 })), + payloadGzipBase64: gzipSync(recompressedPayload, { level: 1 }).toString('base64'), + }, manifest, oracle); + const compactPayload = JSON.stringify(decodeProductPayload(productV2)); + const compactCompressed = gzipSync(Buffer.from(compactPayload, 'utf8'), { level: 9 }); + expectProductRejected('M30b compacte payloadserialisatie zonder finale newline', { + ...clone(productV2), + payloadSha256: productSha256(compactPayload), + payloadGzipSha256: productSha256(compactCompressed), + payloadGzipBase64: compactCompressed.toString('base64'), + }, manifest, oracle); + expectProductRejected('M30c compacte envelopeserialisatie zonder finale newline', + JSON.stringify(productV2), manifest, oracle); + + expectProductRejected('M31 gatePassed flip', withMutatedProduct(productV2, product => { + product.files[firstProductLabel]!.gatePassed = !product.files[firstProductLabel]!.gatePassed; + }), manifest, oracle); + + expectProductRejected('M32 projecttelleralgebra breken met behouden entrysom', withMutatedProduct(productV2, product => { + const projects = product.files[algebraProjectLabel]!.projectMeasurements; + const donor = projects.find(project => project.counters.es.exact > 0); + const receiver = projects.find(project => project !== donor && project.counters.es.diff > 0); + if (!donor || !receiver) throw new Error('M32 vereist twee geschikte projecttellers'); + donor.counters.es.exact--; + donor.counters.es.diff++; + receiver.counters.es.exact++; + receiver.counters.es.diff--; + }), manifest, oracle); + + for (const mutation of newMutationCases) { + const problems = mutation.run(); + mutationChecks++; + if (problems.length === 0) diffs.push(`${mutation.label}: mutant werd ten onrechte geaccepteerd`); + else mutationEvidence.push(`${mutation.label}: ROOD — ${problems[0]}`); + } +} + +const totalChecks = mutationChecks + positiveChecks; +if (mutationChecks !== EXPECTED_MUTANTS) { + diffs.push(`mutantenteller: verwacht exact ${EXPECTED_MUTANTS}, kreeg ${mutationChecks}`); +} +if (positiveChecks !== EXPECTED_POSITIVE_CONTROLS) { + diffs.push(`positieve-controles-teller: verwacht exact ${EXPECTED_POSITIVE_CONTROLS}, kreeg ${positiveChecks}`); +} +if (totalChecks !== EXPECTED_TOTAL_CHECKS) { + diffs.push(`totale checkteller: verwacht exact ${EXPECTED_TOTAL_CHECKS}, kreeg ${totalChecks}`); +} +if (mutationEvidence.length !== mutationChecks) { + diffs.push(`mutatiebewijs: verwacht ${mutationChecks} rode bewijzen, kreeg ${mutationEvidence.length}`); +} + +if (diffs.length === 0) { + if (process.env.OPS_XER_MUTATION_REPORT === '1') { + for (const evidence of mutationEvidence) console.log(`MUTANT ${evidence}`); + } + console.log(`OK: xer-corpusless-fidelity-gate — ${mutationChecks} mutanten + ${positiveChecks} positieve controles = ${totalChecks} checks groen`); +} else { + console.log(`XX xer-corpusless-fidelity-gate — ${diffs.length} fouten bij ${mutationChecks} mutanten + ${positiveChecks} positieve controles:`); + for (const diff of diffs) console.log(` XX ${diff}`); + process.exit(1); +} diff --git a/tests/planning/check-xer-export-loss.ts b/tests/planning/check-xer-export-loss.ts new file mode 100644 index 000000000..747396c13 --- /dev/null +++ b/tests/planning/check-xer-export-loss.ts @@ -0,0 +1,538 @@ +// X9-R2 — de echte exportAs-route levert uitsluitend werkelijk aanwezig, per writer bepaald +// XER-verlies terug. X10 vertaalt/toont dit later; deze headless check bewaakt de return-envelope. +import { + bindXerImportMetadataToArchive, + createEmptyXerArchiveDiagnostics, + createEmptyXerArchiveDocumentView, + createEmptyXerArchiveReadModel, + createXerSourceArchive, +} from '@/services/xerSourceArchive'; +import type { XerImportMetadata } from '@/services/importTypes'; +import { EXPORT_FORMATS } from '@/services/formatRegistry'; +import { xerExportTargetVerdict } from '@/services/xerExportLoss'; +import { parseXerTables } from '@/services/xer/xerTables'; +import type { + XerAssignmentCostsSource, + XerCurvePoints, + XerResourceCurveSource, + XerTaskResourceSource, +} from '@/services/xer/xerResourceTypes'; +import { readFileSync } from 'node:fs'; + +declare const process: { exit(code: number): never }; +const failures: string[] = []; +let checks = 0; +const expect = (label: string, condition: boolean) => { + checks += 1; + if (!condition) failures.push(label); +}; + +const captures: string[] = []; +const global = globalThis as Record; +global.window = global.window ?? {}; +global.Blob = class { + constructor(parts: unknown[]) { captures.push(parts.join('')); } +}; +global.URL = global.URL ?? {}; +global.URL.createObjectURL = () => 'blob:x9-export-loss'; +global.URL.revokeObjectURL = () => undefined; +global.document = { + createElement: () => ({ href: '', download: '', click: () => undefined }), +}; + +const { useAppStore } = await import('@/state/appStore'); +const store = () => useAppStore.getState(); + +function makeXerFixture(options: { + sourceBytes?: string; + unknownTable?: boolean; + typedDiagnostic?: boolean; +} = {}) { + const sourceProjectId = 'P-X9-LOSS'; + const view = createEmptyXerArchiveDocumentView(sourceProjectId); + const readModel = createEmptyXerArchiveReadModel(); + const diagnostics = { + ...createEmptyXerArchiveDiagnostics(), + documentViews: { [sourceProjectId]: view }, + }; + if (options.unknownTable) { + diagnostics.file.tableReport.unknownTables.push({ name: 'UNKNOWN_RETAINED', rows: 1 }); + } + if (options.typedDiagnostic) { + diagnostics.file.tableReport.issues.push({ + code: 'XER_ROW_FIELD_COUNT_MISMATCH', line: 7, table: 'TASK', expected: 4, actual: 3, + }); + } + const archive = createXerSourceArchive( + new TextEncoder().encode(options.sourceBytes ?? ''), + { encoding: 'utf-8', bom: 'none', newline: options.sourceBytes ? 'crlf' : 'none', diagnostics, readModel }, + ); + return { + archive, + xer: bindXerImportMetadataToArchive(archive, sourceProjectId), + sourceProjectId, + }; +} + +function makeParsedXerFixture(source: string) { + const bytes = new TextEncoder().encode(source); + const parsed = parseXerTables(bytes); + const fixture = makeXerFixture({ sourceBytes: source }); + const diagnostics = { + ...fixture.archive.diagnostics, + file: { ...fixture.archive.diagnostics.file, tableReport: parsed.report }, + }; + const archive = createXerSourceArchive(bytes, { + encoding: 'utf-8', bom: 'none', newline: 'crlf', + diagnostics, + readModel: fixture.archive.readModel, + }); + return { + archive, + xer: bindXerImportMetadataToArchive(archive, fixture.sourceProjectId), + sourceProjectId: fixture.sourceProjectId, + }; +} + +function installXer(fixture: ReturnType, xer: XerImportMetadata = fixture.xer) { + useAppStore.setState({ + xerSourceArchive: fixture.archive, + xerImportMetadata: xer, + xerSourceProjectId: fixture.sourceProjectId, + scheduleStale: false, + }); +} + +function categoriesOf(result: any): string[] { + if (!result?.ok || !Array.isArray(result.warnings) || result.warnings.length === 0) return []; + return [...(result.warnings[0]?.categories ?? [])]; +} + +function expectCategories(label: string, format: string, result: any, expected: readonly string[]) { + expect(label, result?.ok === true + && Array.isArray(result.warnings) + && (expected.length === 0 + ? result.warnings.length === 0 + : result.warnings.length === 1 + && result.warnings[0]?.code === 'XER_ONLY_DATA_NOT_EXPRESSIBLE' + && result.warnings[0]?.format === format + && result.warnings[0]?.availability === 'supported-lossy' + && JSON.stringify(categoriesOf(result)) === JSON.stringify(expected))); +} + +function resetProject(name: string) { + store().newProject(); + store().setProject({ name, startDate: '2034-01-02' }); + useAppStore.setState({ scheduleStale: false }); +} + +function clearNotifications() { + for (const notification of [...store().ui.notifications]) store().dismissNotification(notification.id); +} + +function xerLossNotifications() { + return store().ui.notifications.filter(notification => + (notification.messageKey as string) === 'notifications.xerExportLoss'); +} + +function failNextSave(error: Error) { + global.window.showOpenFilePicker = async () => []; + global.window.showSaveFilePicker = async () => { throw error; }; +} + +function restoreDownloadSave() { + delete global.window.showOpenFilePicker; + delete global.window.showSaveFilePicker; +} + +// Negatief 1: een gewoon niet-XER-project mag geen false warning krijgen. +resetProject('Schoon niet-XER'); +const cleanCsv = await store().exportAs('csv'); +expectCategories('een schoon niet-XER-project geeft geen XER-lossmelding', 'csv', cleanCsv, []); + +// Negatief 2: alleen een lege archive/importmetadata-binding is geen verliescategorie. +resetProject('Lege XER-binding'); +const emptyXer = makeXerFixture(); +installXer(emptyXer); +for (const format of ['csv', 'mspdi', 'p6'] as const) { + expectCategories(`${format}: een inhoudelijk lege XER-binding geeft geen false warning`, + format, await store().exportAs(format), []); +} +const emptyIfc = await store().exportAs('ifc'); +expect('IFC blijft ook voor een lege XER-binding lossless', emptyIfc.ok && categoriesOf(emptyIfc).length === 0); + +// Capabilityfixture: MSPDI projecteert de actieve baseline-taakwaarden, CriticalSlackLimit en +// Units/Work/WorkContour. De baselinecategorie blijft wél nodig voor het niet-roundtrippende +// OPS-baselineobject; de andere twee writerprojecties mogen geen extra categorie veroorzaken. +resetProject('Capabilityfixture'); +const capTask = store().addTask({ name: 'Geprojecteerde taak' }); +const capResource = store().addResource({ name: 'Ploeg', type: 'LABOR', description: '', maxUnits: 1 }); +store().assignResource(capTask, capResource, 0.5, 'BELL'); +const activeBaselineId = store().saveBaseline('Actief'); +store().setProject({ schedulingOptions: { criticalDefinition: { mode: 'totalFloat', threshold: 2 } } }); +installXer(makeXerFixture()); + +const capMspdi = await store().exportAs('mspdi'); +const capMspdiXml = captures.at(-1) ?? ''; +expectCategories('MSPDI meldt baseline-metadataverlies naast de bewezen slot-0-taakprojectie', + 'mspdi', capMspdi, ['baselines']); +expect('MSPDI-writer schreef de actieve baseline werkelijk', + capMspdiXml.includes('') && capMspdiXml.includes('0')); +expect('MSPDI-writer schreef CriticalSlackLimit werkelijk', capMspdiXml.includes('2')); +expect('MSPDI-writer schreef Units/Work/WorkContour werkelijk', + capMspdiXml.includes('0.5') + && capMspdiXml.includes('') + && capMspdiXml.includes('6')); + +const capCsv = await store().exportAs('csv'); +expectCategories('CSV meldt de werkelijk niet geschreven baseline/assignment/schedule-opties', + 'csv', capCsv, ['baselines', 'raw-curves-and-assignment-quantities', 'schedule-options-and-provenance']); +expect('de capabilityfixture houdt exact één geldige actieve baseline', + store().baselines.length === 1 && store().activeBaselineId === activeBaselineId); + +resetProject('Alleen onbekende tabel'); +installXer(makeParsedXerFixture([ + 'ERMHDR\t23.12', + '%T\tVENDOR_TABLE', + '%F\tvalue', + '%R\tretained', + '%E', +].join('\r\n'))); +expectCategories('onbekende tabel met een retained rij activeert zelfstandig de categorie', + 'csv', await store().exportAs('csv'), ['exact-source-bytes', 'unknown-tables-and-fields']); + +resetProject('Onbekende tabel met alleen lege rijen'); +installXer(makeParsedXerFixture([ + 'ERMHDR\t23.12', + '%T\tVENDOR_TABLE', + '%F\tmystery_a\tmystery_b', + '%R\t\t', + '%R\t \t', + '%E', +].join('\r\n'))); +expectCategories('onbekende tabel met uitsluitend lege retained cellen geeft geen unknown-categorie', + 'csv', await store().exportAs('csv'), ['exact-source-bytes']); + +resetProject('Onbekende tabel met retained continuation'); +installXer(makeParsedXerFixture([ + 'ERMHDR\t23.12', + '%T\tVENDOR_TABLE', + '%F\tvendor_field', + '%R', + '\tcontinued-value', + '%E', +].join('\r\n'))); +for (const format of ['csv', 'mspdi', 'p6'] as const) { + expectCategories(`${format}: onbekende tabel met inhoudelijke continuation activeert de unknown-categorie`, + format, await store().exportAs(format), ['exact-source-bytes', 'unknown-tables-and-fields']); +} + +resetProject('Onbekende tabel met whitespace-continuation'); +installXer(makeParsedXerFixture([ + 'ERMHDR\t23.12', + '%T\tVENDOR_TABLE', + '%F\tvendor_field', + '%R', + '\t ', + '%E', +].join('\r\n'))); +for (const format of ['csv', 'mspdi', 'p6'] as const) { + expectCategories(`${format}: onbekende tabel met alleen whitespace-continuation blijft unknown-vrij`, + format, await store().exportAs(format), ['exact-source-bytes']); +} + +resetProject('Onbekende tabel zonder rijen'); +installXer(makeParsedXerFixture([ + 'ERMHDR\t23.12', + '%T\tVENDOR_TABLE', + '%F\tmystery_a\tmystery_b', + '%E', +].join('\r\n'))); +expectCategories('onbekende tabel zonder %R geeft geen unknown-categorie', + 'csv', await store().exportAs('csv'), ['exact-source-bytes']); + +resetProject('Alleen onbekend veld'); +installXer(makeParsedXerFixture([ + 'ERMHDR\t23.12', + '%T\tPROJECT', + '%F\tproj_id\tmystery_vendor_field', + '%R\tP1\tretained-value', + '%E', +].join('\r\n'))); +expectCategories('onbekend gevuld veld in bekende tabel activeert zelfstandig de categorie', + 'csv', await store().exportAs('csv'), ['exact-source-bytes', 'unknown-tables-and-fields']); + +// Samengestelde fixture voor de overige onafhankelijke categorieën. De drie relation-/assignment- +// degradaties staan hier BEWUST niet in: ieder daarvan krijgt hieronder een eigen fixture, zodat +// één productietak nooit door een tweede oorzaak voor dezelfde categorie gemaskeerd kan worden. +resetProject('Rijke XER-data'); +const firstTask = store().addTask({ name: 'Bronactiviteit' }); +const codeTypeId = store().addActivityCodeType('Discipline'); +const codeValueId = store().addActivityCodeValue(codeTypeId, { code: 'B', description: 'Bouw' }); +store().setTaskActivityCode(firstTask, codeTypeId, codeValueId); +const udfId = store().addCustomField('Bronwaarde', 'text'); +store().setTaskCustomField(firstTask, udfId, 'XER'); +store().updateTask(firstTask, { + notes: [{ id: 'note-1', text: 'Alleen in OPS', done: false }], + externalLinks: [{ + id: 'ext-1', direction: 'predecessor', relType: 'FS', anchorDate: '2034-01-02', + sourceRef: { projectId: 'P-OTHER', taskId: 'T-OTHER' }, sourceMissing: false, + }], +}); +store().saveBaseline('Eerste'); +store().saveBaseline('Tweede actief'); +store().setProject({ + progressMode: 'PROGRESS_OVERRIDE', + schedulingOptions: { p6Source: 'XER', lagCalendar: '24hour', makeOpenEndedCritical: true }, +}); + +const richFixture = makeXerFixture({ + sourceBytes: '%T\tTASK\r\n%F\ttask_id\r\n%R\torigineel\r\n%E', + typedDiagnostic: true, +}); +installXer(richFixture); + +const sharedRich = [ + 'exact-source-bytes', + 'typed-diagnostics', + 'baselines', + 'udfs', + 'activity-codes', + 'notes', + 'external-links', + 'schedule-options-and-provenance', +] as const; + +const richResults = []; +for (const format of ['csv', 'mspdi', 'p6', 'ifc'] as const) { + richResults.push([format, await store().exportAs(format)] as const); +} +for (const [format, result] of richResults) { + if (format === 'ifc') { + expect('IFC blijft buiten de lossy-funnel', result.ok && categoriesOf(result).length === 0); + } else { + expectCategories(`${format} meldt alleen werkelijk aanwezige retained/live categorieën in de return-envelope`, + format, result, sharedRich); + } +} + +expect('succes-warnings blijven onderdeel van de bestaande exportAs-return-envelope', + richResults.filter(([format]) => format !== 'ifc').every(([, result]) => + result.ok && 'warnings' in result && Array.isArray(result.warnings) && result.warnings.length === 1)); + +function installRelationFixture(name: string, relation: { lagPercent?: number; lagUnit?: 'ELAPSEDTIME' }) { + resetProject(name); + const predecessorId = store().addTask({ name: `${name} voorganger` }); + const successorId = store().addTask({ name: `${name} opvolger` }); + store().addSequence({ + predecessorId, successorId, type: 'FINISH_START', lagDays: 1, + ...relation, + }); + installXer(makeXerFixture()); +} + +installRelationFixture('Alleen percentlag', { lagPercent: 25 }); +const percentOnly = await store().exportAs('p6'); +expectCategories('P6-percentlag zonder elapsed-signaal activeert zelfstandig relation degradation', + 'p6', percentOnly, ['p6-relation-lag-degradation']); + +installRelationFixture('Alleen elapsed lag', { lagUnit: 'ELAPSEDTIME' }); +const elapsedOnly = await store().exportAs('p6'); +expectCategories('P6-elapsed-lag zonder percent-signaal activeert zelfstandig relation degradation', + 'p6', elapsedOnly, ['p6-relation-lag-degradation']); + +function installAssignmentFixture(name: string, curve: 'BELL' | 'LATE_PEAK') { + resetProject(name); + const taskId = store().addTask({ name: `${name} taak` }); + const resourceId = store().addResource({ name: `${name} ploeg`, type: 'LABOR', description: '', maxUnits: 1 }); + store().assignResource(taskId, resourceId, 0.5, curve); + const fixture = makeXerFixture(); + installXer(fixture); + return fixture; +} + +// Vóór de contour-engine-etappe (2026-09) had P6_CURVE_TO_NAME geen eigen 'Late Peak'-vermelding en +// degradeerde LATE_PEAK bij export stil naar 'Early Peak' — toen terecht een apart assignmentverlies. +// Sinds die etappe schrijft p6xmlWriter.ts LATE_PEAK schema-natief (`P6_CURVE_TO_NAME.LATE_PEAK === +// 'Late Peak'`, eigen 21-waardentabel via ``), dus dit is GEEN verlies meer. +installAssignmentFixture('Alleen LATE_PEAK', 'LATE_PEAK'); +const latePeakOnly = await store().exportAs('p6'); +const latePeakXml = captures.at(-1) ?? ''; +expectCategories('P6-LATE_PEAK zonder retained quantities is sinds de contour-engine geen assignmentverlies meer', + 'p6', latePeakOnly, []); +expect('P6-writer schreef voor die LATE_PEAK-fixture echt de eigen Late Peak-tabel, geen Early Peak-degradatie', + latePeakXml.includes('Late Peak') && !latePeakXml.includes('Early Peak')); + +const retainedFixture = installAssignmentFixture('Alleen retained quantities', 'BELL'); +const retainedAssignment = { + rawRow: { line: 12, cells: { target_qty: '40', remain_qty: '12' } }, + sourceId: 'A-1', internalId: 'A-1', taskSourceId: 'T-1', projectSourceId: retainedFixture.sourceProjectId, + line: 12, entity: { kind: 'RESOURCE' as const, sourceId: 'R-1', internalId: 'R-1' }, + unitScale: 'DIRECT_FRACTION' as const, + quantities: { target: 40, remaining: 12 }, + rawCurves: {}, + costs: {}, +}; +installXer(retainedFixture, { + ...retainedFixture.xer, + resources: { + catalog: retainedFixture.archive.readModel.resourceCatalog, + assignments: [retainedAssignment], + issues: [], + }, +}); +const retainedOnly = await store().exportAs('p6'); +expectCategories('retained quantities met ondersteunde live curve activeren zelfstandig assignmentverlies', + 'p6', retainedOnly, ['raw-curves-and-assignment-quantities']); + +function baseRetainedAssignment(fixture: ReturnType): XerTaskResourceSource { + return { + rawRow: { line: 12, cells: {} }, + sourceId: 'A-RETAINED', internalId: 'xer-assignment:A-RETAINED', + taskSourceId: 'T-RETAINED', projectSourceId: fixture.sourceProjectId, + line: 12, + entity: { kind: 'RESOURCE', sourceId: 'R-RETAINED', internalId: 'xer-resource:R-RETAINED' }, + unitScale: 'DIRECT_FRACTION', quantities: {}, rawCurves: {}, costs: {}, + }; +} + +function installRetainedAssignmentFixture( + name: string, + mutate: (source: XerTaskResourceSource) => void, + curves: readonly XerResourceCurveSource[] = [], +) { + const fixture = installAssignmentFixture(name, 'BELL'); + const source = baseRetainedAssignment(fixture); + mutate(source); + installXer(fixture, { + ...fixture.xer, + resources: { + catalog: { + ...fixture.archive.readModel.resourceCatalog, + rows: { ...fixture.archive.readModel.resourceCatalog.rows, curves }, + }, + assignments: [source], + issues: [], + }, + }); +} + +const retainedCostFields = [ + 'perQuantity', 'target', 'remaining', 'actualRegular', 'actualOvertime', 'thisPeriod', +] as const satisfies readonly (keyof XerAssignmentCostsSource)[]; +for (const field of retainedCostFields) { + installRetainedAssignmentFixture(`Alleen retained cost ${field}`, source => { + source.costs = { [field]: 0 }; + }); + for (const format of ['mspdi', 'p6'] as const) { + expectCategories(`${format}: retained TASKRSRC-kostveld ${field} activeert zelfstandig assignmentverlies`, + format, await store().exportAs(format), ['raw-curves-and-assignment-quantities']); + } +} + +const retainedCurvePoints = [ + '0', '2', '5', '9', '14', '20', '27', '35', '44', '54', '65', + '75', '83', '89', '93', '96', '98', '99', '100', '100', '100', +] satisfies XerCurvePoints; +const retainedCurveSource: XerResourceCurveSource = { + rawRow: { line: 11, cells: { curv_id: 'CURVE-RETAINED', pct_usage_10: '65' } }, + sourceId: 'CURVE-RETAINED', internalId: 'xer-curve:CURVE-RETAINED', line: 11, + name: 'Exacte retained broncurve', rawPoints: retainedCurvePoints, +}; +installRetainedAssignmentFixture('Alleen retained broncurve', source => { + source.curveSourceId = retainedCurveSource.sourceId; +}, [retainedCurveSource]); +for (const format of ['mspdi', 'p6'] as const) { + expectCategories(`${format}: curveSourceId plus retained 21-punts broncurve activeert zelfstandig assignmentverlies`, + format, await store().exportAs(format), ['raw-curves-and-assignment-quantities']); +} + +const retainedSourceMetadataCases = [ + ['assignedRole', (source: XerTaskResourceSource) => { + source.assignedRole = { kind: 'ROLE', sourceId: 'ROLE-1', internalId: 'xer-role:ROLE-1' }; + }], + ['rateType', (source: XerTaskResourceSource) => { source.rateType = 'RT_REGULAR'; }], + ['costSourceType', (source: XerTaskResourceSource) => { source.costSourceType = 'CST_RESOURCE'; }], + ['rawResourceType', (source: XerTaskResourceSource) => { source.rawResourceType = 'RT_LABOR'; }], +] as const; +for (const [field, mutate] of retainedSourceMetadataCases) { + installRetainedAssignmentFixture(`Alleen retained bronmetadata ${field}`, mutate); + for (const format of ['mspdi', 'p6'] as const) { + expectCategories(`${format}: retained bronmetadata ${field} activeert zelfstandig assignmentverlies`, + format, await store().exportAs(format), ['raw-curves-and-assignment-quantities']); + } +} + +installAssignmentFixture('Ondersteunde P6-curve', 'BELL'); +const supportedCurve = await store().exportAs('p6'); +const supportedCurveXml = captures.at(-1) ?? ''; +expectCategories('ondersteunde P6-curve zonder retained bronwaarden geeft geen false positive', + 'p6', supportedCurve, []); +// Deze assertie houdt de negatieve fixture hierboven niet-leeg: "geen verliesmelding" is pas +// betekenisvol als de writer de curve ECHT wegschreef. De vorm volgt sinds de contour-engine- +// etappe (main, 2026-09) het P6-schema: de curvenaam staat in een ``-catalogusobject +// met `` op de toewijzing, niet meer in `` (dat element is een +// spreidingsstring — zie het docblok bij `P6_CURVE_TO_NAME` in `p6xmlWriter.ts`). +expect('P6-writer schreef voor die negatieve fixture PlannedUnitsPerTime/ResourceCurve werkelijk', + supportedCurveXml.includes('0.5') + && supportedCurveXml.includes('Bell Shaped') + && /\d+<\/ResourceCurveObjectId>/.test(supportedCurveXml)); + +// De centrale exportAs-funnel is de eigenaar van de in-app melding. Daardoor krijgen Backstage en +// ribbon exact hetzelfde gedrag zonder twee meldschrijvers. Alleen een bewezen save mag melden; +// cancel en error mogen noch de toast noch waarschuwingen in een succes-envelope laten lekken. +resetProject('Centrale exportmelding'); +store().addTask({ name: 'Meldingstaak' }); +store().saveBaseline('Meldingsbaseline'); +installXer(makeXerFixture()); +clearNotifications(); +const notifiedSuccess = await store().exportAs('csv'); +expect('succesvolle echte store-export geeft één centrale XER-lossmelding met Lees meer', + notifiedSuccess.ok + && xerLossNotifications().length === 1 + && xerLossNotifications()[0]?.helpArticleId === 'gids-xer-import'); + +clearNotifications(); +failNextSave(new DOMException('geannuleerd', 'AbortError')); +const cancelled = await store().exportAs('csv'); +restoreDownloadSave(); +expect('cancel geeft aantoonbaar warnings=[] en geen in-app lossmelding', + cancelled.ok && cancelled.warnings.length === 0 && xerLossNotifications().length === 0); + +clearNotifications(); +failNextSave(new Error('schrijffout')); +let saveRejected = false; +try { + await store().exportAs('csv'); +} catch { + saveRejected = true; +} +restoreDownloadSave(); +expect('save-error produceert geen XER-lossmelding', saveRejected && xerLossNotifications().length === 0); + +clearNotifications(); +await store().exportAs('csv'); +await store().exportAs('csv'); +expect('herhaalde export vanuit dezelfde centrale funnel dedupet tot één melding', + xerLossNotifications().length === 1 && xerLossNotifications()[0]?.count === 2); + +const backstageSource = readFileSync('src/components/backstage/Backstage.tsx', 'utf8'); +const ribbonSource = readFileSync('src/components/layout/Ribbon/ribbonWidgets.tsx', 'utf8'); +expect('Backstage en ribbon roepen beide uitsluitend de centrale exportAs-funnel aan', + backstageSource.includes('await exportAs(format)') + && ribbonSource.includes('void exportAs(f.format)') + && !backstageSource.includes('notifications.xerExportLoss') + && !ribbonSource.includes('notifications.xerExportLoss')); + +expect('alle werkelijk succesvolle exportpaden schreven uitvoer', captures.length >= 18 && captures.every(Boolean)); +expect('MPP heeft geen misleidende exportadapter en blijft expliciet unsupported', + !EXPORT_FORMATS.some(item => (item.format as string) === 'mpp') + && xerExportTargetVerdict('mpp') === 'unsupported'); + +if (failures.length === 0) { + console.log(`OK xer-export-loss: alle checks groen (${checks})`); + process.exit(0); +} +console.log(`XX xer-export-loss: ${failures.length} afwijking(en) van ${checks}`); +for (const failure of failures) console.log(` - ${failure}`); +process.exit(1); diff --git a/tests/planning/check-xer-fidelity-baseline-schema.ts b/tests/planning/check-xer-fidelity-baseline-schema.ts new file mode 100644 index 000000000..c39c270e1 --- /dev/null +++ b/tests/planning/check-xer-fidelity-baseline-schema.ts @@ -0,0 +1,350 @@ +/** + * De baseline-vorm-poort (X0/X1, XER-etappeplan §3/§6): X0 legde het lege harness-skelet vast; + * X1 vult `xer-fidelity-baseline.json` met de onafhankelijk gemeten corpuspins. Dit bestand bewaakt + * de VORM daarvan zonder de XER-parser of corpuslus te dupliceren. + * + * TWEE LAGEN, zelfde truc als `check-ext-contract.ts`: + * (a) COMPILE-TIME — `keys()` klonkt de sleutellijsten vast aan `xerFidelityTypes.ts`'s + * interfaces. Een nieuw (ook optioneel) veld op `XerFidelityBaselineEntry`/`XerFidelityCounters`/ + * `XerFidelityAxisCounts` zonder het hier te registreren geeft een COMPILEERFOUT — dat is het + * "veld uit de typelijst verwijderen ⇒ compile-fout"-mutatiebewijs uit de X0-acceptatie. + * (b) RUNTIME — `validateBaseline()` toetst een willekeurig JSON-blob TEGEN diezelfde sleutellijsten + * (type, aanwezigheid, onverwachte sleutels, en de interne `deviations <= measurable`-invariant + * per as). Dit is de laag die "de baseline-vorm muteren ⇒ de schema-check ROOD" bewijst: een + * hernoemde/weggehaalde sleutel in een RUW JSON-object (buiten het TS-typesysteem om, precies + * zoals een met de hand bewerkt of ooit-verkeerd-geschreven baselinebestand dat zou doen) wordt + * hier gevangen, niet pas bij het eerste gebruik door X1. + * + * Draait ALTIJD (geen corpus nodig) — dit is pure structuurbewaking, geen corpusmeting. + */ +import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + type XerFidelityAxisCounts, type XerFidelityCounters, type XerFidelityBaselineEntry, + type XerFidelityBaseline, type XerScannerPrecisionFacts, + emptyAxisCounts, emptyCounters, createEmptyXerFidelityBaseline, +} from './xerFidelityTypes'; + +const HERE = fileURLToPath(new URL('.', import.meta.url)); +const EXPECTED_BASELINE_KEYS = [ + '0611f9054a4b5663', '0fa5172162fbd689', '146e4c8659bad066', '1ba69d297ee9a5c6', + '1ba8cf1885491db3', '1d7901d68cc7063b', '2bc12241c3f8ee5b', '2c1dce175b9f0781', + '467ab0eb0885c4a7', '49aea658c963a005', '4d8bce790a93b9bc', '53f5cdbeb3fbd9c6', + '55b7e4463dcd36ba', '568c19375b4e0d67', '5d71eac4e70b0d95', '68ce5f0bb2b534d5', + '78325fb6445d22f5', '790b90fda837bad3', '79f6fb43bb391ed6', '816e01738f496787', + '9590c4cdc4efa69a', '9679599df9108bd3', '97058f720a70f623', 'a132f8c7ebb070d2', + 'a2ef7b35c00d8cf8', 'a2f3b2469e26f199', 'a7fc367eab6c904e', 'ac4b9d677133c6ed', + 'ad92ce52ba95b275', 'b7ef6d85acfedda1', 'b9547eb91c30af17', 'c872c9e704797d82', + 'deabc76347eaef15', 'def489b2a803af9f', +] as const; +const EXPECTED_MEASURABLE = { + es: 13_931, ef: 13_937, ls: 13_822, lf: 13_813, tf: 13_677, ff: 13_322, +} as const; +const EXPECTED_MANIFEST_SHA256 = '6defbc4b4a71500565e5847750662060d9baca983952098dd1b334ac81d55786'; +const EXPECTED_BASELINE_SHA256 = 'a7075bd27c73cecae71403bc9b06e8ef53707b756049598c60f125dec0c28b29'; + +const diffs: string[] = []; +let checks = 0; +const truthy = (label: string, cond: boolean) => { + checks++; + if (!cond) diffs.push(`${label}: verwacht waar, kreeg onwaar`); +}; +const eq = (label: string, got: unknown, want: unknown) => { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +}; + +// ── (a) Sleutellijsten, compile-time vastgeklonken aan xerFidelityTypes.ts — zelfde patroon als +// check-ext-contract.ts's `keys()`. ────────────────────────────────────────────────────────── +function keys() { + return )[]>( + lijst: K & ([keyof Required] extends [K[number]] ? unknown : ['mist:', Exclude, K[number]>]), + ): readonly (keyof Required)[] => lijst; +} + +const AXIS_COUNTS_KEYS = keys()(['deviations', 'measurable'] as const); +const COUNTERS_KEYS = keys()(['es', 'ef', 'ls', 'lf', 'tf', 'ff'] as const); +const ENTRY_KEYS = keys()([ + 'label', 'tasks', 'projects', 'counters', 'precision', 'schemaFingerprint', 'reason', +] as const); +// Optionele subset — plain, TYPEGETOETST tegen `keyof`, maar bewust NIET zelf compile-volledig (dat +// zou de required/optional-scheiding zelf weer dupliceren). REQUIRED wordt hieronder AFGELEID als +// "alles in ENTRY_KEYS min dit" — dus een nieuw veld dat je vergeet hier te noemen valt automatisch +// in REQUIRED, wat de striktere (veiligere) kant is voor een niet-geclassificeerd veld. +const OPTIONAL_ENTRY_KEYS: readonly (keyof XerFidelityBaselineEntry)[] = ['precision', 'schemaFingerprint', 'reason']; +const REQUIRED_ENTRY_KEYS = ENTRY_KEYS.filter(k => !OPTIONAL_ENTRY_KEYS.includes(k)); + +// ── (b) Runtime-validator ──────────────────────────────────────────────────────────────────── +function isPlainObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} +function isNonNegNumber(v: unknown): v is number { + return typeof v === 'number' && Number.isFinite(v) && v >= 0; +} + +function validateAxisCounts(v: unknown, path: string, problems: string[]): void { + if (!isPlainObject(v)) { problems.push(`${path}: geen object`); return; } + for (const k of AXIS_COUNTS_KEYS) { + if (!(k in v)) { problems.push(`${path}.${k}: ontbreekt`); continue; } + if (!isNonNegNumber(v[k])) problems.push(`${path}.${k}: geen niet-negatief getal (kreeg ${JSON.stringify(v[k])})`); + } + for (const k of Object.keys(v)) { + if (!(AXIS_COUNTS_KEYS as readonly string[]).includes(k)) problems.push(`${path}.${k}: onverwachte sleutel`); + } + const deviations = v.deviations; + const measurable = v.measurable; + if (isNonNegNumber(deviations) && isNonNegNumber(measurable) && deviations > measurable) { + problems.push(`${path}: deviations (${deviations}) > measurable (${measurable})`); + } +} + +function validateCounters(v: unknown, path: string, problems: string[]): void { + if (!isPlainObject(v)) { problems.push(`${path}: geen object`); return; } + for (const k of COUNTERS_KEYS) { + if (!(k in v)) { problems.push(`${path}.${k}: ontbreekt`); continue; } + validateAxisCounts(v[k], `${path}.${k}`, problems); + } + for (const k of Object.keys(v)) { + if (!(COUNTERS_KEYS as readonly string[]).includes(k)) problems.push(`${path}.${k}: onverwachte sleutel`); + } +} + +function validatePrecision(v: unknown, path: string, problems: string[]): void { + if (!isPlainObject(v)) { problems.push(`${path}: geen object`); return; } + const expected = ['dateSecondCells', 'dateNonZeroSubminuteCells', 'floatFractionalMinuteCells'] as const satisfies readonly (keyof XerScannerPrecisionFacts)[]; + for (const key of expected) { + const block = v[key]; + if (!isPlainObject(block)) { problems.push(`${path}.${key}: ontbreekt of geen object`); continue; } + const axes = key === 'floatFractionalMinuteCells' ? ['tf', 'ff'] : ['es', 'ef', 'ls', 'lf']; + for (const axis of axes) { + if (!isNonNegNumber(block[axis])) problems.push(`${path}.${key}.${axis}: geen niet-negatief getal`); + } + } +} + +function validateEntry(v: unknown, path: string, problems: string[]): void { + if (!isPlainObject(v)) { problems.push(`${path}: geen object`); return; } + for (const k of REQUIRED_ENTRY_KEYS) { + if (!(k in v)) problems.push(`${path}.${String(k)}: ontbreekt (verplicht)`); + } + if ('label' in v && typeof v.label !== 'string') problems.push(`${path}.label: geen string`); + if ('tasks' in v && !isNonNegNumber(v.tasks)) problems.push(`${path}.tasks: geen niet-negatief getal`); + if ('projects' in v && !isNonNegNumber(v.projects)) problems.push(`${path}.projects: geen niet-negatief getal`); + if ('counters' in v) validateCounters(v.counters, `${path}.counters`, problems); + if ('precision' in v) validatePrecision(v.precision, `${path}.precision`, problems); + if ('schemaFingerprint' in v && typeof v.schemaFingerprint !== 'string') problems.push(`${path}.schemaFingerprint: geen string`); + if ('reason' in v && typeof v.reason !== 'string') problems.push(`${path}.reason: geen string`); + for (const k of Object.keys(v)) { + if (!(ENTRY_KEYS as readonly string[]).includes(k)) problems.push(`${path}.${k}: onverwachte sleutel`); + } +} + +function validateBaseline(v: unknown): string[] { + const problems: string[] = []; + if (!isPlainObject(v)) { problems.push('root: geen object'); return problems; } + for (const k of Object.keys(v)) { + if (k !== 'files') problems.push(`root.${k}: onverwachte sleutel`); + } + if (!('files' in v) || !isPlainObject(v.files)) { problems.push('files: ontbreekt of geen object'); return problems; } + for (const [hash, entry] of Object.entries(v.files)) { + validateEntry(entry, `files["${hash}"]`, problems); + } + return problems; +} + +// ── 0. De v1- en v2-productfidelitybaseline dragen elk hun eigen versienummer ────────────────── +// `xer-product-fidelity-baseline.json` (v1, cel-regressiebewaking per taak/as, gelezen door +// `check-xer-product-fidelity.ts`) en `xer-product-fidelity-baseline-v2.json` (v2, de 34-entry- +// karakterisering, gelezen door `check-xer-product-fidelity-x12.ts`/`check-xer-corpusless-fidelity- +// gate.ts`) zijn twee verschillende schema's die ooit onder ÉÉN bestandsnaam botsten: v2 overschreef +// v1 stil, en de v1-lezer crashte met een TypeError zonder `XX `-regel (STATUS=1 zonder zichtbare +// reden). Deze assertie bewaakt uitsluitend de vorm-vingerafdruk (version + herkenbare sleutels) van +// elk bestand, zodat een hernieuwde botsing — ook een simpele bestandsverwisseling — hier hard en +// leesbaar rood wordt, vóór een van de twee lezers er ooit aan toekomt. +{ + const v1Raw = readFileSync(join(HERE, 'xer-product-fidelity-baseline.json'), 'utf-8'); + const v2Raw = readFileSync(join(HERE, 'xer-product-fidelity-baseline-v2.json'), 'utf-8'); + const v1Parsed: unknown = JSON.parse(v1Raw); + const v2Parsed: unknown = JSON.parse(v2Raw); + const looksLikeV1Files = isPlainObject(v1Parsed) && isPlainObject(v1Parsed.files) + && Object.keys(v1Parsed.files).every(label => label.includes('/')); + eq('0a xer-product-fidelity-baseline.json is het v1-cel-regressieschema (version 1, label-sleutels, cellTransitions)', { + version: isPlainObject(v1Parsed) ? v1Parsed.version : undefined, + hasLabelKeyedFiles: looksLikeV1Files, + hasCellTransitions: isPlainObject(v1Parsed) && isPlainObject(v1Parsed.cellTransitions) + && v1Parsed.cellTransitions.version === 1, + hasV2Fields: isPlainObject(v1Parsed) && ('manifestSha256' in v1Parsed || 'characterization' in v1Parsed), + }, { version: 1, hasLabelKeyedFiles: true, hasCellTransitions: true, hasV2Fields: false }); + eq('0b xer-product-fidelity-baseline-v2.json is het v2-karakteriseringsschema (version 2, manifestSha256/characterization)', { + version: isPlainObject(v2Parsed) ? v2Parsed.version : undefined, + hasManifestSha256: isPlainObject(v2Parsed) && typeof v2Parsed.manifestSha256 === 'string' && v2Parsed.manifestSha256.length > 0, + hasCharacterization: isPlainObject(v2Parsed) && isPlainObject(v2Parsed.characterization), + hasCellTransitions: isPlainObject(v2Parsed) && 'cellTransitions' in v2Parsed, + }, { version: 2, hasManifestSha256: true, hasCharacterization: true, hasCellTransitions: false }); +} + +// ── 1. De committe X1-baseline is gevuld en welgevormd ───────────────────────────────────────── +{ + const raw = readFileSync(join(HERE, 'xer-fidelity-baseline.json'), 'utf-8'); + eq('1a0 volledige corpusloze baseline-inhoud is exact gepind', + createHash('sha256').update(raw).digest('hex'), EXPECTED_BASELINE_SHA256); + const parsed: unknown = JSON.parse(raw); + const problems = validateBaseline(parsed); + truthy(`1 xer-fidelity-baseline.json is welgevormd (${problems.join('; ')})`, problems.length === 0); + eq('1a corpusloze CI pint de exacte 34-entryset', + isPlainObject(parsed) && isPlainObject(parsed.files) ? Object.keys(parsed.files).sort() : [], + EXPECTED_BASELINE_KEYS); + truthy('1b elke X1-entry draagt een niet-lege schemaFingerprint', + isPlainObject(parsed) && isPlainObject(parsed.files) + && Object.values(parsed.files).every(entry => isPlainObject(entry) + && typeof entry.schemaFingerprint === 'string' && entry.schemaFingerprint.length > 0)); + if (isPlainObject(parsed) && isPlainObject(parsed.files)) { + for (const axis of COUNTERS_KEYS) { + const measurable = Object.values(parsed.files).reduce((sum: number, entry) => { + if (!isPlainObject(entry) || !isPlainObject(entry.counters) + || !isPlainObject(entry.counters[axis])) return sum; + const value = entry.counters[axis].measurable; + return sum + (typeof value === 'number' ? value : 0); + }, 0); + eq(`1c corpusloze CI pint totaal meetbaar voor ${axis}`, measurable, EXPECTED_MEASURABLE[axis]); + } + truthy('1d corpusloze nulpoort eist nul afwijkingen op elke entry/as', + Object.values(parsed.files).every(entry => { + if (!isPlainObject(entry) || !isPlainObject(entry.counters)) return false; + const counters = entry.counters; + return COUNTERS_KEYS.every(axis => { + const value = counters[axis]; + return isPlainObject(value) && value.deviations === 0; + }); + })); + truthy('1e corpusloze nulpoort verbiedt reason-pins', + Object.values(parsed.files).every(entry => isPlainObject(entry) && !('reason' in entry))); + } +} + +// De manifesthash maakt de 93 relatieve paden, volledige bytehashes, herkomstklassen en expliciete +// inclusie-/uitsluitingsbesluiten zelfstandig CI-data. Een corpusmount verifieert die pins opnieuw +// tegen de bytes; zonder mount kan de populatie niet ongemerkt worden verkleind of omgeboekt. +{ + const raw = readFileSync(join(HERE, 'xer-corpus-manifest.json')); + eq('1f openbaar corpusmanifest is corpusloos exact gepind', + createHash('sha256').update(raw).digest('hex'), EXPECTED_MANIFEST_SHA256); + const manifest = JSON.parse(raw.toString('utf-8')) as unknown; + truthy('1g manifest bevat exact 93 geclassificeerde bestanden', isPlainObject(manifest) + && isPlainObject(manifest.files) && Object.keys(manifest.files).length === 93); + if (isPlainObject(manifest) && isPlainObject(manifest.files)) { + const entries = Object.entries(manifest.files); + eq('1h manifestselectie bevat 45 herkomstgeschikte orakelbestanden', + entries.filter(([, entry]) => isPlainObject(entry) && entry.included === true).length, 45); + eq('1i manifest sluit 48 fixtures/pseudo-/invoerbestanden met reden uit', + entries.filter(([, entry]) => isPlainObject(entry) && entry.included === false + && typeof entry.exclusionReason === 'string' && entry.exclusionReason.length > 0).length, 48); + truthy('1j cases-import.xer is op engine-input-herkomst uitgesloten', + isPlainObject(manifest.files['cpp-cpm-engine/validation/p6-comparison/cases-import.xer']) + && manifest.files['cpp-cpm-engine/validation/p6-comparison/cases-import.xer'].role === 'engine-input' + && manifest.files['cpp-cpm-engine/validation/p6-comparison/cases-import.xer'].included === false); + } +} + +// ── 2. Het X0-harness-skelet blijft een geldige lege bouwsteen ───────────────────────────────── +{ + const built: XerFidelityBaseline = createEmptyXerFidelityBaseline(); + const problems = validateBaseline(built); + truthy(`2 createEmptyXerFidelityBaseline() is welgevormd (${problems.join('; ')})`, problems.length === 0); + eq('2a createEmptyXerFidelityBaseline() blijft leeg', built, { files: {} }); +} + +// ── 3. emptyAxisCounts()/emptyCounters() zijn zelf welgevormde bouwstenen ─────────────────────── +{ + const axis = emptyAxisCounts(); + const axisProblems: string[] = []; + validateAxisCounts(axis, 'axis', axisProblems); + truthy(`3 emptyAxisCounts() is welgevormd (${axisProblems.join('; ')})`, axisProblems.length === 0); + eq('3a emptyAxisCounts() === {deviations:0, measurable:0}', axis, { deviations: 0, measurable: 0 }); + + const counters = emptyCounters(); + const counterProblems: string[] = []; + validateCounters(counters, 'counters', counterProblems); + truthy(`3b emptyCounters() is welgevormd (${counterProblems.join('; ')})`, counterProblems.length === 0); + for (const axisKey of COUNTERS_KEYS) { + eq(`3c emptyCounters().${axisKey} === nul`, counters[axisKey], { deviations: 0, measurable: 0 }); + } +} + +// ── 4. Een VOLLEDIG gevulde entry (élk optioneel veld gezet) valideert schoon ─────────────────── +{ + const sample: XerFidelityBaselineEntry = { + label: 'crawl-xer/p6diff-baseline.xer', + tasks: 8, + projects: 1, + counters: { + es: { deviations: 0, measurable: 8 }, ef: { deviations: 0, measurable: 8 }, + ls: { deviations: 0, measurable: 8 }, lf: { deviations: 0, measurable: 8 }, + tf: { deviations: 0, measurable: 8 }, ff: { deviations: 0, measurable: 8 }, + }, + schemaFingerprint: 'proj1|t1,t2,t3', + reason: 'voorbeeldentry — X0-schema-getuige, geen echte meting', + }; + const problems: string[] = []; + validateEntry(sample, 'sample', problems); + truthy(`4 een volledig gevulde entry valideert schoon (${problems.join('; ')})`, problems.length === 0); +} + +// ── 5. Negatieve gevallen — de validator moet ECHT iets afkeuren, geen "altijd waar"-stub ─────── +{ + const missingLabel = { tasks: 1, projects: 1, counters: emptyCounters() }; + const p1: string[] = []; + validateEntry(missingLabel, 'x', p1); + truthy('5 ontbrekend verplicht veld (label) wordt geweigerd', p1.length > 0); + + const wrongType = { label: 'x', tasks: 'acht', projects: 1, counters: emptyCounters() }; + const p2: string[] = []; + validateEntry(wrongType, 'x', p2); + truthy('5a verkeerd type (tasks als string) wordt geweigerd', p2.length > 0); + + const extraKey = { label: 'x', tasks: 1, projects: 1, counters: emptyCounters(), onbekend: true }; + const p3: string[] = []; + validateEntry(extraKey, 'x', p3); + truthy('5b een onverwachte sleutel wordt geweigerd', p3.length > 0); + + // De kern van "baseline-vorm muteren ⇒ schema-check ROOD" — een RUW object dat `deviations` + // hernoemt naar `dev` (zoals een met de hand kapotgemaakt of ooit-verkeerd-geschreven + // baselinebestand dat zou doen) moet de validator laten struikelen. + const renamedAxisField = { + label: 'x', tasks: 1, projects: 1, + counters: { + es: { dev: 0, measurable: 1 }, ef: emptyAxisCounts(), ls: emptyAxisCounts(), + lf: emptyAxisCounts(), tf: emptyAxisCounts(), ff: emptyAxisCounts(), + }, + }; + const p4: string[] = []; + validateEntry(renamedAxisField, 'x', p4); + truthy('5c hernoemd as-veld (deviations -> dev) wordt geweigerd', p4.length > 0); + + const inconsistentAxis = { + label: 'x', tasks: 1, projects: 1, + counters: { + es: { deviations: 5, measurable: 2 }, ef: emptyAxisCounts(), ls: emptyAxisCounts(), + lf: emptyAxisCounts(), tf: emptyAxisCounts(), ff: emptyAxisCounts(), + }, + }; + const p5: string[] = []; + validateEntry(inconsistentAxis, 'x', p5); + truthy('5d deviations > measurable wordt geweigerd (interne invariant)', p5.length > 0); + + const notAnObject = validateBaseline([1, 2, 3]); + truthy('5e een array i.p.v. een object wordt geweigerd', notAnObject.length > 0); +} + +// ── Uitslag ────────────────────────────────────────────────────────────────── +if (diffs.length === 0) { + console.log(`OK: xer-fidelity-baseline-schema — ${checks} checks groen`); +} else { + console.log(`XX xer-fidelity-baseline-schema — ${diffs.length} van ${checks} checks rood:`); + for (const d of diffs) console.log(` XX ${d}`); + process.exit(1); +} diff --git a/tests/planning/check-xer-fidelity.ts b/tests/planning/check-xer-fidelity.ts new file mode 100644 index 000000000..648849b70 --- /dev/null +++ b/tests/planning/check-xer-fidelity.ts @@ -0,0 +1,831 @@ +import { Buffer } from 'node:buffer'; +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + buildXerTargetBaseline, + measureXerFidelity, + validateXerBaselinePins, + xerSchemaFingerprint, +} from './xerFidelity'; +import type { XerCorpusManifest, XerCorpusRole } from './xerFidelity'; +import { XER_FIDELITY_AXES, scanXerGroundTruth } from './xerGroundTruth'; +import type { XerDateFidelityAxis } from './xerGroundTruth'; +import type { XerFidelityBaseline } from './xerFidelityTypes'; + +const diffs: string[] = []; +let checks = 0; +const HERE = fileURLToPath(new URL('.', import.meta.url)); +const REPORT = process.env.OPS_XER_FIDELITY_REPORT; + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +interface ScannerTransitionTask { + projectId: string; + taskId: string; + oldActualDerived: Partial>; + currentStoredMissing: XerDateFidelityAxis[]; +} + +interface ScannerTransitionContract { + version: number; + causeCommit: string; + previousScannerCommit: string; + identityCoverageSha256: string; + scannerErrorsSha256: string; + missingStoredDateTransitions: Record; + oracleFingerprintTransitions: Record; +} + +const scannerTransitionRaw = readFileSync( + join(HERE, 'xer-fidelity-scanner-transition.json'), + 'utf-8', +); +const scannerTransition = JSON.parse(scannerTransitionRaw) as ScannerTransitionContract; + +// Deze hash maakt de historische bronidentiteiten zelf onderdeel van de corpusloze poort. Een rij +// verwijderen of door een andere taak/as vervangen kan daardoor niet groen blijven doordat de +// runtimeprojectie alleen nog de verkleinde contractset zou doorlopen. +eq('0 X12-scannerovergang pint exact hash/project/taak/as en oude bronwaarde', + createHash('sha256').update(scannerTransitionRaw).digest('hex'), + '81c12d9c3d011d4fcfeed45a51a351c4bb6b9832efb831a741bdc3b781abd545'); + +const header = [ + 'proj_id', 'task_id', 'task_code', 'task_name', 'status_code', + 'act_start_date', 'act_end_date', + 'early_start_date', 'early_end_date', 'late_start_date', 'late_end_date', + 'total_float_hr_cnt', 'free_float_hr_cnt', 'driving_path_flag', +]; +const fixture = [ + 'ERMHDR\t8.4', + '%T\tTASK', + `%F\t${header.join('\t')}`, + '%R\tP1\t1\tA\tAlpha\tTK_NotStart\t\t\t2026-01-05 08:00\t2026-01-05 17:00\t2026-01-06 08:00\t2026-01-06 17:00\t1.25\t0.125\tY', + // Een completed activiteit houdt actuals én P6' opgeslagen rekenuitvoer naast elkaar. Dit is + // uitsluitend een orakelfixture: de grondwaarheidscanner moet de zes opgeslagen assen lezen, + // zonder die uitkomst ooit als invoer van readXER of de solver door te geven. De Windows-1252-é + // dwingt de eigen encodingfallback af; de latere productielezer of diens tokenizer komt hier + // niet aan te pas. + '%R\tP2\t2\tB\tCaf\u00e9\tTK_Complete\t2026-02-02 09:15:30\t2026-02-03 16:45:00\t2099-01-01 08:00:30\t2099-01-02 17:00:00\t2099-01-03 08:00:15.250\t2099-01-04 17:00\t\t-0.125\tN', + '%E', +].join('\r\n'); +const bytes = Buffer.from(fixture, 'latin1'); +const truth = scanXerGroundTruth(bytes); + +// Breuken die dit vangt: delen van tokenizer/veldkaart met de productielezer, UTF-8-only decoderen, +// of completed-taken stil naar actuals normaliseren in plaats van de opgeslagen XER-uitvoer rauw te +// meten. Actuals blijven hier broninvoer; stored early/late zijn uitsluitend het meet-orakel. +eq('1 scanner kiest Windows-1252 zelfstandig', truth.encoding, 'windows-1252'); +eq('1a scanner leest beide projecten', [...truth.projects], ['P1', 'P2']); +eq('1b scanner leest taakidentiteit en zeven rapportageassen', truth.tasks[0], { + projectId: 'P1', + taskId: '1', + taskCode: 'A', + statusCode: 'TK_NotStart', + axes: { + es: '2026-01-05T08:00', ef: '2026-01-05T17:00', + ls: '2026-01-06T08:00', lf: '2026-01-06T17:00', + tf: 75, ff: 7.5, + }, + rawDateSeconds: { es: null, ef: null, ls: null, lf: null }, + drivingPath: true, + presentAxes: { es: true, ef: true, ls: true, lf: true, tf: true, ff: true }, +}); +eq('1c completed bewaart rauwe early/late-orakels plus subminuutfeit en exacte fractionele float', truth.tasks[1], { + projectId: 'P2', + taskId: '2', + taskCode: 'B', + statusCode: 'TK_Complete', + axes: { + es: '2099-01-01T08:00', ef: '2099-01-02T17:00', + ls: '2099-01-03T08:00', lf: '2099-01-04T17:00', + tf: null, ff: -7.5, + }, + rawDateSeconds: { es: '30', ef: '00', ls: '15.250', lf: null }, + drivingPath: false, + presentAxes: { es: true, ef: true, ls: true, lf: true, tf: false, ff: true }, +}); +eq('1d geldige fixture heeft geen stille scannerfouten', + (truth as unknown as { errors: string[] }).errors, []); +eq('1d2 scanner pint secondenpresence, niet-nulseconden en fractionele minuten afzonderlijk', + truth.precision, { + dateSecondCells: { es: 1, ef: 1, ls: 1, lf: 0 }, + dateNonZeroSubminuteCells: { es: 1, ef: 0, ls: 1, lf: 0 }, + floatFractionalMinuteCells: { tf: 0, ff: 2 }, + }); + +const commaFixture = Buffer.from([ + 'ERMHDR\t23.12\t2026-04-01\tProject\tadmin\tAdmin\tDB\tProject Management\tEUR', + '%T\tCURRTYPE', + '%F\tcurr_short_name\tdecimal_symbol\tdigit_group_symbol\tdecimal_symbol_type\tdigit_group_symbol_type', + '%R\tUSD\t.\t,\tPERIOD\tCOMMA', + '%R\tEUR\t,\t.\tCOMMA\tPERIOD', + '%T\tTASK', + `%F\t${header.join('\t')}`, + '%R\tP1\tC\tC\tComma\tTK_Complete\t2026-04-01 08:00\t2026-04-02 17:00\t2026-04-01 08:00\t2026-04-02 17:00\t2026-04-01 08:00\t2026-04-02 17:00\t0,125\t-0,125\tY', + '%E', +].join('\n')); +const commaTruth = scanXerGroundTruth(commaFixture) as unknown as { + tasks: Array>; + errors: string[]; +}; + +// Breuk die dit vangt: Number(value) gebruiken zonder CURRTYPE. De stored date-assen zijn hier +// bewust aanwezig, zodat deze floatfixture geen tweede semantiek over completed-actuals mengt. +eq('1e CURRTYPE-kommafloat blijft meetbaar in exacte fractionele minuten', commaTruth.tasks[0], { + projectId: 'P1', + taskId: 'C', + taskCode: 'C', + statusCode: 'TK_Complete', + axes: { + es: '2026-04-01T08:00', ef: '2026-04-02T17:00', + ls: '2026-04-01T08:00', lf: '2026-04-02T17:00', + tf: 7.5, ff: -7.5, + }, + rawDateSeconds: { es: null, ef: null, ls: null, lf: null }, + drivingPath: true, + presentAxes: { es: true, ef: true, ls: true, lf: true, tf: true, ff: true }, +}); +eq('1f geldige kommafixture heeft geen parsefouten', commaTruth.errors, []); + +const currencyMismatchTruth = scanXerGroundTruth(Buffer.from([ + 'ERMHDR\t23.12\t2026-04-01\tProject\tadmin\tAdmin\tDB\tCloud\tEP', + '%T\tCURRTYPE', + '%F\tcurr_short_name\tdecimal_symbol\tdigit_group_symbol', + '%R\tEUR\t,\t.', + '%T\tTASK', + `%F\t${header.join('\t')}`, + '%R\tP1\tM\tM\tMismatch\tTK_NotStart\t\t\t2026-04-01\t2026-04-02\t2026-04-01\t2026-04-02\t1.25\t0.5\t', + '%E', +].join('\n'))) as ReturnType & { numberFormatIssues?: string[] }; +const conflictingCurrencyTruth = scanXerGroundTruth(Buffer.from([ + 'ERMHDR\t23.12\t2026-04-01\tProject\tadmin\tAdmin\tDB\tCloud\tEUR', + '%T\tCURRTYPE', + '%F\tcurr_short_name\tdecimal_symbol\tdecimal_symbol_type\tdigit_group_symbol\tdigit_group_symbol_type', + '%R\tEUR\t,\tds_Period\t.\tdg_Period', + '%E', +].join('\n'))) as ReturnType & { numberFormatIssues?: string[] }; +const wrongFamilyTruth = scanXerGroundTruth(Buffer.from([ + 'ERMHDR\t23.12\t2026-04-01\tProject\tadmin\tAdmin\tDB\tCloud\tEUR', + '%T\tCURRTYPE', + '%F\tcurr_short_name\tdecimal_symbol_type\tdigit_group_symbol_type', + '%R\tEUR\tdg_Period\tds_Comma', + '%E', +].join('\n'))) as ReturnType & { numberFormatIssues?: string[] }; +// Breuk die dit vangt: X1 dezelfde first-row- en eerste-herkenbare foutmodus laten delen met +// productie. Het orakel gebruikt punt-default na mismatch en meldt alle representatiefouten zelf. +eq('1g X1 controleert CURRTYPE via een onafhankelijk volledig pad', { + mismatch: { + issues: currencyMismatchTruth.numberFormatIssues, + errors: currencyMismatchTruth.errors, + totalFloat: currencyMismatchTruth.tasks[0]?.axes.tf, + }, + conflictErrors: conflictingCurrencyTruth.errors, + familyErrors: wrongFamilyTruth.errors, +}, { + mismatch: { + issues: ['CURRTYPE: ERMHDR-valuta EP heeft geen overeenkomstige rij; punt-default gebruikt'], + errors: [], + totalFloat: 75, + }, + conflictErrors: ['CURRTYPE EUR/decimal: tegenstrijdige separatorrepresentaties'], + familyErrors: [ + 'CURRTYPE EUR/decimal_symbol_type: dg-token hoort niet in de decimaalfamilie', + 'CURRTYPE EUR/digit_group_symbol_type: ds-token hoort niet in de groepsfamilie', + ], +}); + +const duplicateCurrencyTruth = scanXerGroundTruth(Buffer.from([ + 'ERMHDR\t23.12\t2026-04-01\tProject\tadmin\tAdmin\tDB\tCloud\tEUR', + '%T\tCURRTYPE', + '%F\tcurr_short_name\tdecimal_symbol\tdigit_group_symbol', + '%R\teur\t.\t,', + '%R\tEUR\t,\t.', + '%E', +].join('\n'))); +const identicalDuplicateCurrencyTruth = scanXerGroundTruth(Buffer.from([ + 'ERMHDR\t23.12\t2026-04-01\tProject\tadmin\tAdmin\tDB\tCloud\tEUR', + '%T\tCURRTYPE', + '%F\tcurr_short_name\tdecimal_symbol\tdecimal_symbol_type\tdigit_group_symbol\tdigit_group_symbol_type', + '%R\teur\t.\tperiod\t,\tcomma', + '%R\tEUR\t.\tds_period\t,\tdg_comma', + '%E', +].join('\n'))); +// Breuk die dit vangt: ook in X1 de eerste case-insensitieve valutamatch vertrouwen. Deze +// fixture deelt geen tokenizer, helper of verwachte foutvorm met de productieparser. +eq('1h X1 weigert afwijkende dubbele defaultvaluta via een eigen controlepad', { + conflict: duplicateCurrencyTruth.errors, + identical: identicalDuplicateCurrencyTruth.errors, +}, { + conflict: ['CURRTYPE EUR: regels 4, 5 hebben tegenstrijdige decimaal-/groepssemantiek'], + identical: [], +}); + +const terminalGroundTruth = scanXerGroundTruth(Buffer.from([ + 'ERMHDR\t23.12\t2026-04-01\tProject\tadmin\tAdmin\tDB\tCloud\tEUR', + '%T\tTASK', + `%F\t${header.join('\t')}`, + '%R\tP1\tT1\tA1\tVoor grens\tTK_NotStart\t\t\t2026-04-01\t2026-04-02\t2026-04-01\t2026-04-02\t1.25\t0.5\t', + '%E', + '%T\tCURRTYPE', + '%F\tcurr_short_name\tdecimal_symbol\tdigit_group_symbol', + '%R\tEUR\t,\t.', + '%T\tTASK', + `%F\t${header.join('\t')}`, + '%R\tP1\tT2\tA2\tNa grens\tTK_NotStart\t\t\t2026-04-03\t2026-04-04\t2026-04-03\t2026-04-04\t2,5\t1,5\t', +].join('\n'))); +// Breuken die dit vangt: alleen de taakscan of alleen de formatenscan bij de eerste %E stoppen. +// De staart mag noch een taak toevoegen, noch de getalnotatie van de betrouwbare kop wijzigen. +eq('1h-b X1-formatenscan en taakscan stoppen beide op de eerste %E', { + taskIds: terminalGroundTruth.tasks.map(task => task.taskId), + totalFloat: terminalGroundTruth.tasks[0]?.axes.tf, + errors: terminalGroundTruth.errors, + formatIssues: terminalGroundTruth.numberFormatIssues, +}, { + taskIds: ['T1'], + totalFloat: 75, + errors: [], + formatIssues: [], +}); + +const brokenTruth = scanXerGroundTruth(Buffer.from([ + 'ERMHDR\t23.12', + '%T\tTASK', + `%F\t${header.join('\t')}`, + '%R\tP1\tBAD\tBAD\tBroken\tTK_NotStart\t\t\t2026-02-30 08:00\t\t\t\t1.2x\t\t', + '%R\tP1\tNO_STATUS\tNO_STATUS\tNo status\t\t\t\t\t\t\t\t\t\t', + '%R\tP1\t\tNO_ID\tNo id\tTK_NotStart\t\t\t\t\t\t\t\t\t', + '%E', +].join('\n'))); +eq('1i niet-lege kapotte waarden en ontbrekende identiteit zijn fataal zichtbaar', brokenTruth.errors, [ + 'TASK BAD/early_start_date: ongeldige datum "2026-02-30 08:00"', + 'TASK BAD/total_float_hr_cnt: ongeldig getal "1.2x"', + 'TASK rij 3/task_id: ontbrekende waarde', +]); +const brokenMeasurement = measureXerFidelity(brokenTruth, [{ + projectId: 'P1', + tasks: [ + { sourceTaskId: 'BAD', taskCode: 'BAD' }, + { sourceTaskId: 'NO_STATUS', taskCode: 'NO_STATUS' }, + ], +}]); +eq('1j scannerfouten zijn gate-fataal bij verder exacte uitlijning', { + errors: brokenMeasurement.errors, + gatePassed: brokenMeasurement.gatePassed, +}, { errors: brokenTruth.errors, gatePassed: false }); + +const sourceDateVariants = scanXerGroundTruth(Buffer.from([ + 'ERMHDR\t23.12', + '%T\tTASK', + `%F\t${header.join('\t')}`, + '%R\tP1\tDATE_ONLY\tDATE_ONLY\tDate only\tTK_Complete\t2026-04-01\t2026-04-02\t2026-04-01\t2026-04-02\t2026-04-01\t2026-04-02\t\t\t', + '%R\tP1\tNO_STATUS\tNO_STATUS\tNo status\t\t\t\t2026-04-03\t2026-04-04\t2026-04-05\t2026-04-06\t1\t0\t', + '%R\tP1\tZERO\tZERO\tP6 zero sentinel\tTK_NotStart\t\t\t0\t0\t0\t0\t\t\t', + '%E', +].join('\n'))); + +// Bronsemantiek en vergelijksemantiek zijn bewust verschillend: XER staat een geldige datum +// zonder tijd toe en bedoelt dan middernacht. Een toekomstige OPS-adapter moet daarentegen altijd +// de volledige minuutstring teruggeven; check 2d hieronder bewaakt die strengere uitvoergrens. +eq('1i geldige bron-datum zonder tijd canonicaliseert naar middernacht', sourceDateVariants.tasks[0].axes, { + es: '2026-04-01T00:00', ef: '2026-04-02T00:00', + ls: '2026-04-01T00:00', lf: '2026-04-02T00:00', + tf: null, ff: null, +}); +eq('1j ontbrekende status valt terug op de niet-voltooide early/late-assen', sourceDateVariants.tasks[1].axes, { + es: '2026-04-03T00:00', ef: '2026-04-04T00:00', + ls: '2026-04-05T00:00', lf: '2026-04-06T00:00', + tf: 60, ff: 0, +}); +eq('1k P6 nul-datumsentinel is afwezig en geen ongeldige niet-lege orakelwaarde', { + axes: sourceDateVariants.tasks[2].axes, + presentAxes: sourceDateVariants.tasks[2].presentAxes, + errors: sourceDateVariants.errors, +}, { + axes: { es: null, ef: null, ls: null, lf: null, tf: null, ff: null }, + presentAxes: { es: false, ef: false, ls: false, lf: false, tf: false, ff: false }, + errors: [], +}); + +const duplicateTruthIds = scanXerGroundTruth(Buffer.from([ + 'ERMHDR\t23.12', + '%T\tTASK', + `%F\t${header.join('\t')}`, + '%R\tP1\tDUP\tA\tFirst\tTK_NotStart\t\t\t2026-04-01 08:00\t\t\t\t\t\t', + '%R\tP1\tDUP\tB\tSecond\tTK_NotStart\t\t\t2026-04-02 08:00\t\t\t\t\t\t', + '%E', +].join('\n'))); +eq('1l dubbele bron-taak-id binnen een project is fataal', duplicateTruthIds.errors, [ + 'TASK rij 2: dubbele task_id P1/DUP', +]); + +function scanEmptySourceIdentity(projectId: string, taskId: string, taskCode: string) { + return scanXerGroundTruth(Buffer.from([ + 'ERMHDR\t23.12', + '%T\tTASK', + `%F\t${header.join('\t')}`, + `%R\t${projectId}\t${taskId}\t${taskCode}\tIdentity\tTK_NotStart\t\t\t2026-04-01 08:00\t\t\t\t\t\t`, + '%E', + ].join('\n'))); +} + +// Breuk die dit vangt: een lege bronidentiteit als gewone Map-sleutel of gelijke taakcode +// behandelen. Iedere verplichte identiteit heeft een eigen scannerfout en levert geen truth-taak. +eq('1m lege bron-proj_id is scannerfataal', { + errors: scanEmptySourceIdentity('', 'ID', 'CODE').errors, + tasks: scanEmptySourceIdentity('', 'ID', 'CODE').tasks.length, +}, { errors: ['TASK rij 1/proj_id: ontbrekende waarde'], tasks: 0 }); +eq('1n lege bron-task_id is scannerfataal', { + errors: scanEmptySourceIdentity('P1', '', 'CODE').errors, + tasks: scanEmptySourceIdentity('P1', '', 'CODE').tasks.length, +}, { errors: ['TASK rij 1/task_id: ontbrekende waarde'], tasks: 0 }); +eq('1o lege bron-task_code is scannerfataal', { + errors: scanEmptySourceIdentity('P1', 'ID', '').errors, + tasks: scanEmptySourceIdentity('P1', 'ID', '').tasks.length, +}, { errors: ['TASK rij 1/task_code: ontbrekende waarde'], tasks: 0 }); + +const measured = measureXerFidelity(truth, [ + { + projectId: 'P1', + tasks: [{ + sourceTaskId: '1', taskCode: 'A', + earlyStart: '2026-01-05T08:00', earlyFinish: '2026-01-05T17:00', + lateStart: '2026-01-06T08:00', lateFinish: '2026-01-06T17:01', + totalFloatMinutes: 75, freeFloatMinutes: 7.5, drivingPath: false, + }], + }, + { + projectId: 'P2', + tasks: [{ + sourceTaskId: '2', taskCode: 'B', + earlyStart: '2099-01-01T08:00', earlyFinish: '2099-01-02T17:00', + lateStart: '2099-01-03T08:00', lateFinish: '2099-01-04T17:00', + totalFloatMinutes: 123, freeFloatMinutes: -7.5, drivingPath: false, + }], + }, +]); + +// Breuken die dit vangt: alles-of-niets-taaktelling over een multi-projectbestand, bestandssom die +// alleen het laatste project bevat, float in uren i.p.v. minuten, of driving path stil in de nulpoort. +eq('2 per-project meten en daarna per bestand optellen', measured.projects.map(project => ({ + projectId: project.projectId, + tasks: project.tasks, + lf: project.counters.lf, +})), [ + { projectId: 'P1', tasks: 1, lf: { deviations: 1, measurable: 1 } }, + { projectId: 'P2', tasks: 1, lf: { deviations: 0, measurable: 1 } }, +]); +eq('2a bestandssom bewaart zes onafhankelijke tellerparen', measured.counters, { + es: { deviations: 0, measurable: 2 }, + ef: { deviations: 0, measurable: 2 }, + ls: { deviations: 0, measurable: 2 }, + lf: { deviations: 1, measurable: 2 }, + tf: { deviations: 0, measurable: 1 }, + ff: { deviations: 0, measurable: 2 }, +}); +eq('2b driving path is zevende rapportage-as buiten counters', measured.drivingPath, { + deviations: 1, + measurable: 2, +}); +eq('2c multi-projectbestand strandt niet op één globale taakuitlijning', measured.errors, []); + +const nullAdapter = measureXerFidelity(truth, [ + { projectId: 'P1', tasks: [{ sourceTaskId: '1', taskCode: 'A' }] }, + { projectId: 'P2', tasks: [{ sourceTaskId: '2', taskCode: 'B' }] }, +]); +eq('2d een correct uitgelijnde null-adapter is op iedere meetbare as rood', { + counters: nullAdapter.counters, + errors: nullAdapter.errors, + gatePassed: nullAdapter.gatePassed, +}, { + counters: { + es: { deviations: 2, measurable: 2 }, ef: { deviations: 2, measurable: 2 }, + ls: { deviations: 2, measurable: 2 }, lf: { deviations: 2, measurable: 2 }, + tf: { deviations: 1, measurable: 1 }, ff: { deviations: 2, measurable: 2 }, + }, + errors: [], + gatePassed: false, +}); + +const midnightTruth = scanXerGroundTruth(Buffer.from([ + 'ERMHDR\t23.12', + '%T\tTASK', + `%F\t${header.join('\t')}`, + '%R\tP1\tMID\tMID\tMidnight\tTK_NotStart\t\t\t2026-03-01 00:00\t\t\t\t\t\t', + '%E', +].join('\n'))); +const measureMidnight = (earlyStart: string) => measureXerFidelity(midnightTruth, [{ + projectId: 'P1', + tasks: [{ sourceTaskId: 'MID', taskCode: 'MID', earlyStart }], +}]).counters.es; + +// Breuk die dit vangt: XER-datums via MPP's bewuste date-only/middernacht-uitweg vergelijken, +// of alleen de eerste zestien tekens bekijken en trailing rommel daardoor exact noemen. +eq('2e XER date-only is niet minuut-exact tegen middernacht', measureMidnight('2026-03-01'), { + deviations: 1, measurable: 1, +}); +eq('2f XER-minuut met trailing tekst is niet geldig of exact', + measureMidnight('2026-03-01T00:00 rommel'), { deviations: 1, measurable: 1 }); + +const misaligned = measureXerFidelity(midnightTruth, [ + { + projectId: 'P1', + tasks: [ + { sourceTaskId: 'MID', taskCode: 'WRONG', earlyStart: '2026-03-01T00:00' }, + { sourceTaskId: 'EXTRA', taskCode: 'EXTRA' }, + ], + }, + { projectId: 'P2', tasks: [{ sourceTaskId: 'OTHER', taskCode: 'OTHER' }] }, +]); +const misalignedShape = misaligned as unknown as { + truthProjects: number; + solvedProjects: number; + truthTasks: number; + solvedTasks: number; + gatePassed: boolean; +}; + +// Breuk die dit vangt: alleen op task-id waarden vergelijken en extra projecten/taken negeren. +eq('2f uitlijning rapporteert verkeerde code plus extra taak en project', misaligned.errors, [ + 'onverwacht opgelost project-id: P2', + 'project P1: onverwachte opgeloste taak-id: EXTRA', + 'project P1/taak MID: taskCode verwacht MID, kreeg WRONG', +]); +eq('2g waarheid- en opgelostaantallen blijven afzonderlijk zichtbaar', { + truthProjects: misalignedShape.truthProjects, + solvedProjects: misalignedShape.solvedProjects, + truthTasks: misalignedShape.truthTasks, + solvedTasks: misalignedShape.solvedTasks, +}, { truthProjects: 1, solvedProjects: 2, truthTasks: 1, solvedTasks: 3 }); +eq('2h uitlijnfouten maken de bestandsmeting gate-fataal', misalignedShape.gatePassed, false); + +const missing = measureXerFidelity(midnightTruth, [{ projectId: 'P1', tasks: [] }]); +eq('2i ontbrekende taak is een uitlijnfout', missing.errors, [ + 'project P1: ontbrekende opgeloste taak-id: MID', +]); + +const duplicateIds = measureXerFidelity(midnightTruth, [ + { projectId: 'P1', tasks: [{ sourceTaskId: 'MID', taskCode: 'MID' }, { sourceTaskId: 'MID', taskCode: 'MID' }] }, + { projectId: 'P1', tasks: [{ sourceTaskId: 'MID', taskCode: 'MID' }] }, +]); +eq('2j dubbele project- en taak-id zijn beide fataal', duplicateIds.errors, [ + 'dubbele opgeloste project-id: P1', + 'project P1: dubbele opgeloste taak-id: MID', +]); + +// Breuk die dit vangt: handgebouwde adapteruitvoer kan de scanner omzeilen. De meetgrens moet +// daarom projectId, sourceTaskId en taskCode zelf op niet-lege waarden controleren. +const emptySolvedProjectId = measureXerFidelity(midnightTruth, [{ + projectId: '', tasks: [{ sourceTaskId: 'MID', taskCode: 'MID', earlyStart: '2026-03-01T00:00' }], +}]); +eq('2k lege opgeloste projectId is expliciet gate-fataal', { + firstError: emptySolvedProjectId.errors[0], gatePassed: emptySolvedProjectId.gatePassed, +}, { firstError: 'opgelost project heeft lege projectId', gatePassed: false }); + +const emptySolvedTaskId = measureXerFidelity(midnightTruth, [{ + projectId: 'P1', tasks: [{ sourceTaskId: '', taskCode: 'MID', earlyStart: '2026-03-01T00:00' }], +}]); +eq('2l lege opgeloste sourceTaskId is expliciet gate-fataal', { + firstError: emptySolvedTaskId.errors[0], gatePassed: emptySolvedTaskId.gatePassed, +}, { firstError: 'project P1: opgeloste taak heeft lege sourceTaskId', gatePassed: false }); + +const emptySolvedTaskCode = measureXerFidelity(midnightTruth, [{ + projectId: 'P1', tasks: [{ sourceTaskId: 'MID', taskCode: '', earlyStart: '2026-03-01T00:00' }], +}]); +eq('2m lege opgeloste taskCode is expliciet gate-fataal', { + firstError: emptySolvedTaskCode.errors[0], gatePassed: emptySolvedTaskCode.gatePassed, +}, { firstError: 'project P1/taak MID: opgeloste taskCode is leeg', gatePassed: false }); + +function schemaTruth(projectIds: readonly string[], driving: 'Y' | 'N', taskId = 'S') { + return scanXerGroundTruth(Buffer.from([ + 'ERMHDR\t23.12', + '%T\tPROJECT', + '%F\tproj_id', + ...projectIds.map(projectId => `%R\t${projectId}`), + '%T\tTASK', + `%F\t${header.join('\t')}`, + `%R\tP1\t${taskId}\tSCODE\tSchema\tTK_NotStart\t\t\t2026-05-01 08:00\t2026-05-01 17:00\t2026-05-02 08:00\t2026-05-02 17:00\t1\t0\t${driving}`, + '%E', + ].join('\n'))); +} +const schemaP1 = schemaTruth(['P1'], 'Y'); +const schemaP1P2 = schemaTruth(['P1', 'P2'], 'Y'); +const schemaDrivingOnly = schemaTruth(['P1'], 'N'); +const schemaOtherTaskId = schemaTruth(['P1'], 'Y', 'OTHER'); + +// Breuken die dit vangt: PROJECT-rijen negeren of de zevende rapportage-as indirect de +// zesassige steekproef laten splitsen. +eq('2n grondwaarheid leest de echte PROJECT-set inclusief leeg project', + [...schemaP1P2.projects], ['P1', 'P2']); +eq('2o extra PROJECT-rij verandert de zesassige schemafingerprint', + xerSchemaFingerprint(schemaP1) === xerSchemaFingerprint(schemaP1P2), false); +eq('2p alleen driving_path_flag verandert de gatefingerprint niet', + xerSchemaFingerprint(schemaP1), xerSchemaFingerprint(schemaDrivingOnly)); +eq('2q andere taakidentiteit verandert de gatefingerprint', + xerSchemaFingerprint(schemaP1) === xerSchemaFingerprint(schemaOtherTaskId), false); + +const byteDuplicate = Buffer.from(bytes); +const schemaDuplicate = Buffer.from(fixture.split('\r\n').join('\n'), 'latin1'); +const noOracle = Buffer.from([ + '%T\tTASK', + '%F\tproj_id\ttask_id\ttask_code\tstatus_code\tact_start_date\tact_end_date\tearly_start_date\tearly_end_date\tlate_start_date\tlate_end_date\ttotal_float_hr_cnt\tfree_float_hr_cnt\tdriving_path_flag', + '%R\tP3\t3\tC\tTK_NotStart\t\t\t\t\t\t\t\t\t', +].join('\n')); +const pseudoOracle = Buffer.from(fixture.replace('Alpha', 'Pseudo Alpha'), 'latin1'); +const unitFiles = [ + { label: 'a/original.xer', bytes }, + { label: 'b/byte-copy.xer', bytes: byteDuplicate }, + { label: 'c/schema-copy.xer', bytes: schemaDuplicate }, + { label: 'd/no-oracle.xer', bytes: noOracle }, + { label: 'e/pseudo-with-six-axes.xer', bytes: pseudoOracle }, +] as const; +const unitManifest: XerCorpusManifest = { + version: 1, + policy: 'synthetische manifestfixture', + files: Object.fromEntries(unitFiles.map(file => { + const included = !file.label.startsWith('d/') && !file.label.startsWith('e/'); + const excludedRole: XerCorpusRole = file.label.startsWith('e/') ? 'pseudo-xer' : 'reference-only'; + return [file.label, { + sha256: createHash('sha256').update(file.bytes).digest('hex'), + source: 'synthetische testfixture', + role: (included ? 'oracle' : excludedRole) as XerCorpusRole, + included, + ...(included ? {} : { exclusionReason: 'geen poortas' }), + }]; + })), +}; +const built = buildXerTargetBaseline(unitFiles, unitManifest); + +// Breuken die dit vangt: alleen bytehash dedupliceren, schemahash op bestandsbytes baseren, +// niet-orakelbestanden laten meetellen of multi-projectcijfers niet per bestand sommeren. +eq('3 twee deduplagen laten één uniek orakelbestand over', built.stats, { + scannedFiles: 5, + manifestFiles: 5, + includedFiles: 3, + excludedFiles: 2, + byteUniqueFiles: 4, + byteDuplicateFiles: 1, + fourDateTasks: 8, + sixAxisTasks: 4, + drivingPathTasks: 8, + partialOnlyByteUniqueFiles: 0, + partialOnlyAxisCells: 0, + byteUniqueOracleFiles: 2, + schemaDuplicateFiles: 1, + uniqueOracleFiles: 1, + byteUniqueOracleTasks: 4, + uniqueOracleTasks: 2, + selectedMeasurable: { es: 2, ef: 2, ls: 2, lf: 2, tf: 1, ff: 2 }, +}); +eq('3a manifestselectie en synthetische bestanden leveren geen selectiefout', built.errors, []); +const entries = Object.values(built.baseline.files); +eq('3b pseudo-XER met zes ogenschijnlijke assen blijft op herkomst buiten de baseline', + entries.some(entry => entry.label === 'e/pseudo-with-six-axes.xer'), false); +eq('3c baseline pint per bestand de projectsom en beide tellers per as', entries.map(entry => ({ + label: entry.label, + tasks: entry.tasks, + projects: entry.projects, + counters: entry.counters, +})), [{ + label: 'a/original.xer', + tasks: 2, + projects: 2, + counters: { + es: { deviations: 0, measurable: 2 }, ef: { deviations: 0, measurable: 2 }, + ls: { deviations: 0, measurable: 2 }, lf: { deviations: 0, measurable: 2 }, + tf: { deviations: 0, measurable: 1 }, ff: { deviations: 0, measurable: 2 }, + }, +}]); + +const roleAwareFiles = [ + { label: 'a/parser-fixture.xer', bytes }, + { label: 'z/included-oracle.xer', bytes: Buffer.from(bytes) }, +] as const; +const roleAwareManifest: XerCorpusManifest = { + version: 1, + policy: 'rolbewuste byte-dedup-regressie', + files: { + 'a/parser-fixture.xer': { + sha256: createHash('sha256').update(bytes).digest('hex'), + source: 'synthetische testfixture', + role: 'parser-fixture', + included: false, + exclusionReason: 'alleen parserdekking', + }, + 'z/included-oracle.xer': { + sha256: createHash('sha256').update(bytes).digest('hex'), + source: 'synthetische testfixture', + role: 'oracle', + included: true, + }, + }, +}; +const roleAwareBaseline = buildXerTargetBaseline(roleAwareFiles, roleAwareManifest); + +// Breuk die dit vangt: de alfabetisch eerdere excluded parserfixture vult `seenBytes` voordat de +// latere inbegrepen orakelkopie aan de beurt komt. De groep moet altijd het orakellabel kiezen. +eq('3d byte-dedup verkiest inbegrepen orakel boven eerdere excluded kopie', { + labels: Object.values(roleAwareBaseline.baseline.files).map(entry => entry.label), + errors: roleAwareBaseline.errors, +}, { labels: ['z/included-oracle.xer'], errors: [] }); + +const nonZeroWithoutReason = structuredClone(built.baseline); +Object.values(nonZeroWithoutReason.files)[0].counters.es.deviations = 1; +eq('3e niet-nul-pin zonder reason wordt geweigerd', validateXerBaselinePins(nonZeroWithoutReason), [ + 'baseline-entry 1: niet-nul afwijking vereist een niet-lege reason', +]); +Object.values(nonZeroWithoutReason.files)[0].reason = 'bewust gemeten verschil'; +eq('3f niet-nul-pin met reason is welgevormd', validateXerBaselinePins(nonZeroWithoutReason), []); + +function listXerFilesRecursive(dir: string): string[] { + const files: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true }) + .sort((a, b) => a.name.localeCompare(b.name))) { + const full = join(dir, entry.name); + if (entry.isDirectory()) files.push(...listXerFilesRecursive(full)); + else if (entry.isFile() && entry.name.toLowerCase().endsWith('.xer')) files.push(full); + } + return files; +} + +if (REPORT !== undefined && REPORT !== 'detail' && REPORT !== 'baseline') { + diffs.push(`OPS_XER_FIDELITY_REPORT kent alleen 'detail' of 'baseline' (kreeg ${JSON.stringify(REPORT)})`); +} + +const corpusRoot = process.env.OPS_XER_CORPUS; +if (!corpusRoot) { + console.log('OK xer-fidelity: corpus niet aanwezig (OPS_XER_CORPUS) — corpuslus overgeslagen'); +} else if (!existsSync(corpusRoot)) { + diffs.push('OPS_XER_CORPUS wijst niet naar een bestaande corpusmap'); +} else { + const corpusFiles = listXerFilesRecursive(corpusRoot).map(path => ({ + label: relative(corpusRoot, path).split('\\').join('/'), + bytes: readFileSync(path), + })); + const manifest = JSON.parse(readFileSync(join(HERE, 'xer-corpus-manifest.json'), 'utf-8')) as XerCorpusManifest; + const corpus = buildXerTargetBaseline(corpusFiles, manifest); + eq('C0 corpusmanifest past exact op bytes, herkomstselectie en parserfouten', corpus.errors, []); + + const scannedCorpus = corpusFiles + .map(file => ({ + ...file, + fullHash: createHash('sha256').update(file.bytes).digest('hex'), + truth: scanXerGroundTruth(file.bytes), + })) + .sort((a, b) => a.label.localeCompare(b.label)); + const scansByHash = new Map(); + for (const file of scannedCorpus) { + const group = scansByHash.get(file.fullHash) ?? []; + group.push(file); + scansByHash.set(file.fullHash, group); + } + const scannerIdentityCoverage = scannedCorpus.map(file => [ + file.fullHash, + file.label, + [...file.truth.projects].sort(), + file.truth.tasks + .map(task => [ + task.projectId, + task.taskId, + task.taskCode, + XER_FIDELITY_AXES.filter(axis => task.presentAxes[axis]), + ]) + .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))), + ]); + const scannerErrorsBySource = scannedCorpus.map(file => [ + file.fullHash, + file.label, + file.truth.errors, + ]); + eq('C0a scanner behoudt exact iedere bron-project/taak/code/as-identiteit', + createHash('sha256').update(JSON.stringify(scannerIdentityCoverage)).digest('hex'), + scannerTransition.identityCoverageSha256); + eq('C0b scannerfoutidentiteiten blijven exact gelijk aan de gereviewde overgang', + createHash('sha256').update(JSON.stringify(scannerErrorsBySource)).digest('hex'), + scannerTransition.scannerErrorsSha256); + + const measuredMissingStoredDates = Object.fromEntries( + Object.entries(scannerTransition.missingStoredDateTransitions).map(([fullHash, expected]) => { + const group = scansByHash.get(fullHash) ?? []; + const truth = group[0]?.truth; + return [fullHash, { + corpusOccurrences: group.length, + tasks: expected.tasks.map(task => { + const measuredTask = truth?.tasks.find(candidate => + candidate.projectId === task.projectId && candidate.taskId === task.taskId); + return { + projectId: task.projectId, + taskId: task.taskId, + taskFound: measuredTask !== undefined, + currentStoredMissing: Object.fromEntries( + task.currentStoredMissing.map(axis => [axis, measuredTask?.axes[axis] ?? null]), + ), + }; + }), + }]; + }), + ); + const expectedMissingStoredDates = Object.fromEntries( + Object.entries(scannerTransition.missingStoredDateTransitions).map(([fullHash, expected]) => [ + fullHash, + { + corpusOccurrences: expected.corpusOccurrences, + tasks: expected.tasks.map(task => ({ + projectId: task.projectId, + taskId: task.taskId, + taskFound: true, + currentStoredMissing: Object.fromEntries( + task.currentStoredMissing.map(axis => [axis, null]), + ), + })), + }, + ]), + ); + eq('C0c completed-actuals worden alleen op de 79 benoemde broncellen niet meer als stored orakel geteld', + measuredMissingStoredDates, expectedMissingStoredDates); + + // Bindende ankers uit het goedgekeurde brief. Deze cijfers worden opnieuw uit de bytes gemeten; + // de implementatie gebruikt ze nergens om taken of bestanden te selecteren. + eq('C1 volledige crawl', corpus.stats.scannedFiles, 93); + eq('C2 unieke bestanden na byte-dedup', corpus.stats.byteUniqueFiles, 84); + eq('C3 taken met vier opgeslagen datumassen', corpus.stats.fourDateTasks, 18_397); + eq('C4 taken met alle zes effectieve assen', corpus.stats.sixAxisTasks, 17_871); + eq('C4a de eerder gemiste scheve dekking blijft volledig zichtbaar', { + files: corpus.stats.partialOnlyByteUniqueFiles, + cells: corpus.stats.partialOnlyAxisCells, + }, { files: 27, cells: 2_025 }); + eq('C5 herkomstgeselecteerde orakelbestanden na byte-dedup', corpus.stats.byteUniqueOracleFiles, 36); + eq('C6 meetbare orakeltaken na byte-dedup', corpus.stats.byteUniqueOracleTasks, 18_190); + eq('C7 unieke orakelbestanden na beide deduplagen', corpus.stats.uniqueOracleFiles, 34); + eq('C8 meetbare orakeltaken na beide deduplagen', corpus.stats.uniqueOracleTasks, 13_959); + eq('C8a twee inhoudsduplicaten na byte-dedup', corpus.stats.schemaDuplicateFiles, 2); + eq('C8b geselecteerde meetbaarheid wordt per as uit de bytes herleid', corpus.stats.selectedMeasurable, { + es: 13_931, ef: 13_937, ls: 13_822, lf: 13_813, tf: 13_677, ff: 13_322, + }); + + const baselinePath = join(HERE, 'xer-fidelity-baseline.json'); + const committed = JSON.parse(readFileSync(baselinePath, 'utf-8')) as XerFidelityBaseline; + const measuredFingerprintTransitions = Object.fromEntries( + Object.entries(scannerTransition.oracleFingerprintTransitions).map(([fullHash]) => { + const truth = scansByHash.get(fullHash)?.find(file => + manifest.files[file.label]?.included && manifest.files[file.label]?.role === 'oracle')?.truth; + return [fullHash, truth ? xerSchemaFingerprint(truth) : null]; + }), + ); + const committedFingerprintTransitions = Object.fromEntries( + Object.entries(scannerTransition.oracleFingerprintTransitions).map(([fullHash]) => [ + fullHash, + committed.files[fullHash.slice(0, 16)]?.schemaFingerprint ?? null, + ]), + ); + const expectedFingerprintTransitions = Object.fromEntries( + Object.entries(scannerTransition.oracleFingerprintTransitions).map(([fullHash, transition]) => [ + fullHash, + transition.current, + ]), + ); + eq('C8c de 14 X12-fingerprintovergangen zijn per volledige bronhash exact verklaard', { + scanner: measuredFingerprintTransitions, + baseline: committedFingerprintTransitions, + }, { + scanner: expectedFingerprintTransitions, + baseline: expectedFingerprintTransitions, + }); + if (REPORT !== 'baseline') { + eq('C9 committe baseline is exact de opnieuw gemeten per-bestandssom', committed, corpus.baseline); + eq('C10 reason-verplichting op committe baseline', validateXerBaselinePins(committed), []); + } + + console.log( + `. xer-fidelity: ${corpus.stats.scannedFiles} bestanden; byte-uniek ${corpus.stats.byteUniqueFiles}; ` + + `orakel ${corpus.stats.byteUniqueOracleFiles}→${corpus.stats.uniqueOracleFiles} bestanden, ` + + `${corpus.stats.byteUniqueOracleTasks}→${corpus.stats.uniqueOracleTasks} taken; ` + + `driving_path_flag meetbaar ${corpus.stats.drivingPathTasks} (rapportage, buiten nulpoort)`, + ); + + if (REPORT === 'detail') { + for (const [hash, entry] of Object.entries(corpus.baseline.files)) { + console.log(`. ${hash} ${entry.label}: ${entry.projects} project(en), ${entry.tasks} taken, ${JSON.stringify(entry.counters)}`); + } + } + if (REPORT === 'baseline') { + console.log(JSON.stringify(corpus.baseline, null, 2)); + } +} + +if (diffs.length === 0) { + console.log(`OK xer-fidelity: ${checks} checks groen`); +} else { + console.log(`XX xer-fidelity: ${diffs.length} afwijking(en) van ${checks}`); + for (const diff of diffs) console.log(` XX ${diff}`); + process.exit(1); +} diff --git a/tests/planning/check-xer-field-whitelist.ts b/tests/planning/check-xer-field-whitelist.ts new file mode 100644 index 000000000..7abc3752f --- /dev/null +++ b/tests/planning/check-xer-field-whitelist.ts @@ -0,0 +1,489 @@ +/** + * De veldlijsten-poort (X0, XER-etappeplan §4.1) — legt invoer, berekende uitvoer, nog niet + * ondersteunde external-dependency-proxyvelden en genegeerde niet-planningsdata apart vast, + * en toetst ze tegen de + * daadwerkelijke TASK-`%F`-kolommen over het hele publieke XER-corpus (§4.3: bestandsnamen mogen + * gewoon in tests/commits — dit is geen bedrijfsdata, anders dan `OPS_MPP_CORPUS`). + * + * DRIE BAKKEN, NIET TWEE (delta-check-redactieronde, 076f67ec): het plan startte met een whitelist + * (invoer) en een verboden-lijst (rekenuitvoer) — maar 32 corpuskolommen (audittrail, vlaggen, + * kosten, review/locatie-velden, pseudo-XER-dialectkolommen, …) vielen buiten beide, wat de poort op + * dag één onvervulbaar maakte. De derde bak, `XER_TASK_IGNORED`, vangt die: "bestaat, is geen + * rekenuitvoer, maar draagt ook geen planningsinvoer" — expliciet genegeerd, niet vergeten. + * + * X0 RAAKT `src/services/xer/` NIET AAN — met precies één, LATERE uitzondering (X-O7 laag 3, + * §4.1-bijstelling 2026-09-04): `XER_TASK_RECORDED_OUTPUT` (bak 4 hieronder) IS de whitelist voor + * de daadwerkelijk gebouwde `src/services/xer/xerRecordedTimes.ts`. De overige vier bakken + * (whitelist/forbidden/external-proxy/ignored) blijven documentatie-van-het-plan zonder eigen + * lezercode. + * + * De corpusscan hieronder is een eigen, MINIMALE %T/%F-tabelscan (geen tokenizer-hergebruik — X2 + * bouwt de echte grammatica) die uitsluitend kolomNAMEN uittrekt, nooit rijwaarden leest of + * interpreteert. + * + * HET GATENKAAS-MECHANISME (planreview M2): een corpus-`%F`-kolom die in GEEN van de VIJF bakken + * staat is een poortfout — precies de fout die de eerste twee-bakken-versie van dit plan maakte en + * die de her-check ving. Schrap je een whitelist- (of verboden-, of genegeerd-, of + * recorded-output-)veld zonder het elders te herplaatsen, dan valt die kolom terug tussen wal en + * schip en gaat de scan ROOD (mét corpus gemount) — dat IS mutatiebewijs (a) voor deze poort. + * + * Zonder `OPS_XER_CORPUS` gemount: skip met een OK-regel (zelfde conventie als + * `check-mpp-fidelity.ts`'s `OPS_MPP_CORPUS`) — dit is dus GEEN CI-poort, hij werkt alleen lokaal + * (of op een machine met het corpus gemount). De corpusvrije zelfconsistentie-checks op de drie + * constanten zelf (geen dubbele/overlappende velden) draaien wél altijd. + */ +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { join, resolve as resolvePath } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const diffs: string[] = []; +let checks = 0; +const truthy = (label: string, cond: boolean) => { + checks++; + if (!cond) diffs.push(`${label}: verwacht waar, kreeg onwaar`); +}; + +// ═══════════════════════════════════════════════════════════════════════════════════════════ +// De vijf bakken (XER-etappeplan §4.1 + D4-review: external proxy is geen lokale rekenuitvoer; +// bak 4 "recorded-output" kwam er later bij, X-O7 laag 3, §4.1-bijstelling 2026-09-04) +// ═══════════════════════════════════════════════════════════════════════════════════════════ + +/** + * BAK 1 — WHITELIST: de invoervelden die de lezer straks WEL uit de TASK-tabel mag importeren. + * Letterlijk overgenomen uit plan §4.1 — dit is documentatie-van-het-plan, geen eigen keuze. Een + * uitbreiding hier hoort samen te gaan met een wijziging in het plandocument zelf. + */ +export const XER_TASK_WHITELIST = [ + // identiteit/structuur + 'task_id', 'task_code', 'task_name', 'wbs_id', 'clndr_id', 'proj_id', 'guid', 'rsrc_id', + 'status_code', 'task_type', 'duration_type', 'priority_type', + // voortgang + 'complete_pct_type', 'complete_pct', 'phys_complete_pct', + 'act_start_date', 'act_end_date', + // duren + 'target_drtn_hr_cnt', 'remain_drtn_hr_cnt', + // geplande datums + 'target_start_date', 'target_end_date', + // werk-hoeveelheden (als data) + 'target_work_qty', 'remain_work_qty', 'act_work_qty', 'act_this_per_work_qty', + 'target_equip_qty', 'remain_equip_qty', 'act_equip_qty', 'act_this_per_equip_qty', + // constraints + 'cstr_type', 'cstr_date', 'cstr_type2', 'cstr_date2', + // suspend/resume (X7 verwerkt de semantiek; het VELD is invoer) + 'suspend_date', 'resume_date', + // échte notitiedata (X8) + dialect-alias van cstr_type in de ProjectLens-bestanden + 'task_notes', 'constraint_type', + // alleen samen met de bijbehorende SCHEDOPTIONS-vlag (X5) — het VELD zelf is invoer + 'expect_end_date', +] as const; + +/** + * BAK 2 — VERBODEN: rekenuitvoer die er als gewone velden uitziet, maar dat niet is. De lezer mag + * hier NOOIT uit lezen — de opgeslagen antwoorden zijn de meetlat (§3), nooit de invoer. + * + * De zes kolommen die ooit hier stonden (`early_start_date`, `early_end_date`, `late_start_date`, + * `late_end_date`, `total_float_hr_cnt`, `free_float_hr_cnt`) zijn VERHUISD naar + * `XER_TASK_RECORDED_OUTPUT` (bak 4, hieronder) — X-O7 laag 3, §4.1-bijstelling 2026-09-04. Ze + * zijn nog steeds geen `Task`-invoer, maar de lezer mag ze — als apart, waardedragend kanaal naast + * de taken, nooit als solverinvoer — WEL lezen voor de "datums zoals opgeslagen"-weergave. + */ +export const XER_TASK_FORBIDDEN = [ + 'restart_date', 'reend_date', + 'rem_late_start_date', 'rem_late_end_date', + 'driving_path_flag', + 'float_path', 'float_path_order', + 'old_restart_date', 'old_reend_date', 'old_remain_drtn_hr_cnt', + 'crt_path_num', + 'critical_drtn_hr_cnt', + 'act_drtn_hr_cnt', + 'plan_start_date', 'plan_end_date', +] as const; + +/** + * BAK 2b — P6-proxy/input voor externe afhankelijkheden. Dit is géén berekende lokale TASK- + * uitvoer, maar OPS ondersteunt deze bronsemantiek nog niet. Daarom blijft hij bewust buiten de + * reader/solver en krijgt hij een eigen non-interferencecontract in de X12-productpoort. + */ +export const XER_TASK_EXTERNAL_DEPENDENCY_PROXY = [ + 'external_early_start_date', 'external_late_end_date', +] as const; + +/** + * BAK 4 — "uitsluitend weergave/meetlat, nooit solverinvoer" (plan §4.1, bijgesteld 2026-09-04 — + * eigenaarsbesluit X-O7 laag 3). Naamgeving-nota: het plan noemt dit "de vierde bak" (tellend + * whitelist/forbidden/ignored als de oorspronkelijke drie), maar dit testbestand had door + * `XER_TASK_EXTERNAL_DEPENDENCY_PROXY` al VIER constanten-groepen vóór deze — dit wordt dus de + * VIJFDE `buckets`-entry hieronder. Genoemd naar de plan-SEMANTIEK ("recorded output"), niet naar + * de telling. + * + * Deze zes namen waren tot deze bijstelling onderdeel van `XER_TASK_FORBIDDEN` (bak 2). Anders dan + * de rest van bak 2 mag de lezer ze WEL uit de TASK-tabel halen — maar uitsluitend via het eigen, + * geïsoleerde kanaal `src/services/xer/xerRecordedTimes.ts` (`ImportResult.recordedTimes`), NOOIT + * naar `Task.time` en NOOIT als solverinvoer. Het enige echte bewijs daarvoor is het + * X12-mutatiebewijs (taak T2): gemuteerde opgeslagen uitvoer verplaatst de solve niet, maar + * verplaatst de weergavemodus wél. Die scherpte mag nooit als "traag" of "redundant" gesnoeid + * worden (plan §5, risico 8) — het bak-4-lidmaatschap ALLEEN is een verzwakte poort. + */ +export const XER_TASK_RECORDED_OUTPUT = [ + 'early_start_date', 'early_end_date', 'late_start_date', 'late_end_date', + 'total_float_hr_cnt', 'free_float_hr_cnt', +] as const; + +/** + * BAK 3a — "genegeerd — geen planningsdata": VOORBEHOUDEN kandidaten (plan §4.1, letterlijk). + * Deze drie zijn OP NAAM ingedeeld, niet doorgelezen tegen echte rijwaarden — blijkt tijdens de + * bouw dat één van de drie tóch planningsinvoer draagt, dan verhuist hij EXPLICIET naar + * `XER_TASK_WHITELIST` hierboven (en uit deze lijst) — nooit stilzwijgend. Een aparte constante, + * zodat die verhuizing een diff van twee regels is, niet een speurtocht in een lange platte lijst. + */ +export const XER_TASK_IGNORED_PROVISIONAL = [ + 'est_wt', 'review_type', 'tmpl_guid', +] as const; + +/** + * BAK 3b — "genegeerd — geen planningsdata": de rest (audittrail, vlaggen, kosten, review/locatie + * op naam behalve de drie voorbehouden hierboven, en een handvol restvelden). Gededupliceerd met + * bak 3a in `XER_TASK_IGNORED` hieronder — nooit dezelfde naam op twee plekken. + */ +export const XER_TASK_IGNORED_OTHER = [ + // audittrail + 'create_date', 'update_date', 'create_user', 'update_user', + // vlaggen + 'rev_fdbk_flag', 'lock_plan_flag', 'auto_compute_act_flag', + // kosten + 'remain_cost', 'plan_cost', 'act_cost', + // review/locatie (review_type zit in de voorbehouden-bak hierboven, review_end_date/location_id niet) + 'review_end_date', 'location_id', + // overig, met naam genoemd in het plan + 'target_qty_per_hr', 'act_reg_qty', 'act_ot_qty', +] as const; + +/** + * BAK 3c — pseudo-XER-dialectkolommen: bestanden in het corpus die %F-headers dragen maar GEEN + * echte P6-kolommen (plan §4.1 + X2's failure-mode-model — `p6xer-basic.xer`: + * `Task_ID`/`Start_Date`/`Duration`; `p6xer-comprehensive.xer`/`p6xer-malformed.xer`: de kleine- + * letter-varianten `start_date`/`end_date`/`duration`/`calendar_id`/`total_float`/`free_float`). + * X2 weigert deze bestanden straks met een typed fout — ze openen nooit als leeg-maar-geldig + * project — maar de NAMEN moeten wel ergens landen zolang deze scan blind over kolomnamen gaat + * (bestandsbewust classificeren is X2-werk, geen X0-scantooling). + */ +export const XER_TASK_IGNORED_PSEUDO_XER = [ + 'Task_ID', 'Task_Name', 'Start_Date', 'Duration', 'Status', + 'start_date', 'end_date', 'duration', 'calendar_id', 'total_float', 'free_float', +] as const; + +/** + * BAK 3d — tokenizer-artefact: een `%F`-regel die eindigt op een tab levert bij het splitsen een + * lege laatste "kolomnaam" op (gemeten: `pmxml-samples/testXer.xer`, + * `crawl-xer-extra/groupdocs-conversion/sample.xer`). Geen kolom, geen data — X2's tokenizer-nota. + */ +export const XER_TASK_IGNORED_TRAILING_TAB = [''] as const; + +/** BAK 3, samengevoegd — dit is de lijst die de corpusscan hieronder daadwerkelijk gebruikt. */ +export const XER_TASK_IGNORED: readonly string[] = [ + ...XER_TASK_IGNORED_PROVISIONAL, + ...XER_TASK_IGNORED_OTHER, + ...XER_TASK_IGNORED_PSEUDO_XER, + ...XER_TASK_IGNORED_TRAILING_TAB, +]; + +// ═══════════════════════════════════════════════════════════════════════════════════════════ +// Corpusvrije zelfconsistentie: geen dubbele/overlappende velden binnen of tussen de bakken. +// Draait ALTIJD, ook zonder OPS_XER_CORPUS — dit bewaakt de constanten zelf, niet het corpus. +// ═══════════════════════════════════════════════════════════════════════════════════════════ +{ + // VIJF bakken sinds X-O7 laag 3 (§4.1-bijstelling 2026-09-04): de oorspronkelijke vier plus + // `recorded-output` (bak 4, plan-semantiek — zie de naamgevingsnota bij `XER_TASK_RECORDED_OUTPUT` + // hierboven voor waarom dit de vijfde `buckets`-entry is, geen "vierde"). + const buckets: [string, readonly string[]][] = [ + ['whitelist', XER_TASK_WHITELIST], + ['forbidden', XER_TASK_FORBIDDEN], + ['external-dependency-proxy', XER_TASK_EXTERNAL_DEPENDENCY_PROXY], + ['recorded-output', XER_TASK_RECORDED_OUTPUT], + ['ignored', XER_TASK_IGNORED], + ]; + // Critreview laag 3, bevinding 8 (dubbel fail-open): de corpusscan hieronder is geen CI-poort + // (hij slaat over zonder OPS_XER_CORPUS), en de isolatiescan itereert over ÉXACT dezelfde + // constante — een naam uit `XER_TASK_RECORDED_OUTPUT` halen verwijderde hem dus stil uit béide + // poorten tegelijk. Deze zes namen staan daarom hier nóg een keer, met de hand, als + // corpusvrije verankering: schrapt iemand er één, dan is dit rood ongeacht het corpus. + truthy('bak 4 draagt exact de zes P6-rekenuitvoerkolommen (corpusvrije verankering)', + JSON.stringify([...XER_TASK_RECORDED_OUTPUT].sort()) === JSON.stringify([ + 'early_end_date', 'early_start_date', 'free_float_hr_cnt', 'late_end_date', 'late_start_date', + 'total_float_hr_cnt', + ])); + + for (const [name, list] of buckets) { + const seen = new Set(); + for (const f of list) { + truthy(`geen dubbele entry "${f}" binnen ${name}`, !seen.has(f)); + seen.add(f); + } + } + // Volledige paarsgewijze overlapcheck (i [name, new Set(list)] as const); + for (let i = 0; i < bucketSets.length; i++) { + const [nameA, setA] = bucketSets[i]; + for (let j = i + 1; j < bucketSets.length; j++) { + const [nameB, setB] = bucketSets[j]; + for (const f of setA) { + truthy(`"${f}" (${nameA}) staat niet óók in ${nameB}`, !setB.has(f)); + } + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════════════════════ +// Isolatiescan (X-O7 laag 3, §3.2 van het laag-3-plan): een grep op de zes BAK-4-kolomnamen +// binnen `src/services/xer/` mag UITSLUITEND `xerRecordedTimes.ts` raken. Draait altijd, geen +// corpus nodig — dit is een broncodescan, naar het voorbeeld van sectie 11 in +// `check-recorded-dates.ts` (dubbele wortelkandidaat, want dit script draait zowel gebundeld in +// `tests/planning/` als los vanuit een andere map). +// +// NUANCE (bewust, geen vals-positief): `total_float_hr_cnt`/`free_float_hr_cnt` komen ook voor in +// `xerTables.ts`'s `XER_DECIMAL_FIELDS` — een AL BESTAANDE, generieke veldnaam-classificatie voor +// getalformaat-parsing over ALLE XER-tabellen (niet TASK-specifiek, niets met "lees dit veld als +// taakuitvoer" te maken). Die catalogus draagt de naam als lijst-element (gevolgd door een komma), +// nooit als celtoegang of functie-argument. De scan hieronder herkent daarom twee LEESPATRONEN — +// `cells.`/`cells['']` (celtoegang) en `''` als argument BINNEN de haakjes van een +// aanroep op dezelfde regel (zoals `ctx.numberOf(row, 'total_float_hr_cnt')` — op élke argument- +// positie, eindreview bevinding 3: het oude patroon eiste het láátste argument en liet +// `numberOf(row, 'total_float_hr_cnt', 0)` ongezien door) — en negeert bewust kale +// lijst-lidmaatschap. Zonder die nuance zou deze scan op dag één al rood staan, buiten elke wijziging +// van deze etappe om. +// +// BAK 2 (eindreview bevinding 3, mutatiebewijs M-B): dezelfde scanlus loopt óók over +// `XER_TASK_FORBIDDEN`. Plan §4.1 belooft "de X12-sluiproute-scan grept tegen de whitelist", maar +// tot deze bijstelling had bak 2 GEEN enkele consument buiten de bucket-classificatie hieronder: een +// lezing van `restart_date` achter een `task_type === 'TT_Rsrc'`-conditie kwam door X12, deze scan, +// de corpusloze fidelity-poort én de reader-check (alle exit 0), omdat de X12-non-interferentie- +// fixture maar één activiteitstype kent en het corpus geen CI-poort is. Deze grep is corpusloos en +// dus wél een CI-poort. Toegestaan zijn uitsluitend twee lezingen van DEZELFDE kolomnaam op een +// ANDERE tabel — bak 2 is een TASK-tabel-lijst — elk gepind op bestand én exacte vorm: +// • `projectRow.cells.plan_end_date` in `xerReader.ts` (PROJECT-tabel: het geplande projecteinde +// voor `useProjectEndDateForFloat`, X5), en +// • `row.cells.critical_drtn_hr_cnt` in `xerScheduleOptions.ts` (SCHEDOPTIONS-tabel: de +// kritiek-drempel in uren, X5). +// Elke andere treffer — ook dezelfde naam in een ander bestand of in een andere vorm — is rood. +{ + const kandidaten = [ + fileURLToPath(new URL('../../src/', import.meta.url).href), + resolvePath(process.cwd(), 'src'), + ]; + const srcRoot = kandidaten.find((p) => existsSync(p)) ?? null; + truthy(`isolatiescan vindt src/ (geprobeerd: ${kandidaten.join(', ')})`, srcRoot !== null); + + if (srcRoot) { + // Critreview laag 3, bevinding 7: de scan liep alleen over `src/services/xer/` — een lezer in + // `src/services/ifc/` die archiefcellen uitleest viel erbuiten. Hij loopt nu over HEEL `src/`. + const xerDir = join(srcRoot, 'services', 'xer'); + truthy(`isolatiescan vindt ${xerDir}`, existsSync(xerDir)); + + if (existsSync(xerDir)) { + const xerFiles: string[] = []; + const loop = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) loop(full); + else if (/\.tsx?$/.test(entry.name)) xerFiles.push(full); + } + }; + loop(srcRoot); + truthy('isolatiescan leest een plausibel aantal bestanden in src/', xerFiles.length > 100); + + const toegestaan = join(xerDir, 'xerRecordedTimes.ts'); + // Leest `text` de kolom `field`? Drie leespatronen: celtoegang, aanroepargument op dezelfde + // regel (elke positie; `[^()\\n]` houdt het binnen één regel én buiten geneste haakjes, zodat + // een lijstelement `'x',` in een meerregelig `new Set([ … ])` níét matcht), destructurering. + const readsField = (text: string, field: string): boolean => { + const cellAccess = new RegExp(`cells(?:\\.${field}\\b|\\[['"]${field}['"]\\])`); + const callArg = new RegExp(`\\([^()\\n]*['"]${field}['"]\\s*[,)]`); + // Derde leespatroon (bevinding 7, gemeten gat): `const { early_start_date } = row.cells` + // en `const { early_start_date: x } = cells` — destructurering las de kolom ongezien. + const destructure = new RegExp(`\\{[^{}]*\\b${field}\\b[^{}]*\\}\\s*=`); + return cellAccess.test(text) || callArg.test(text) || destructure.test(text); + }; + const overtreders: string[] = []; + for (const file of xerFiles) { + if (file === toegestaan) continue; + const text = readFileSync(file, 'utf8'); + for (const field of XER_TASK_RECORDED_OUTPUT) { + if (readsField(text, field)) overtreders.push(`${field} in ${file.slice(srcRoot.length)}`); + } + } + checks++; + if (overtreders.length > 0) { + diffs.push( + `isolatiescan: BAK-4-kolommen worden buiten xerRecordedTimes.ts gelezen — ${overtreders.join(', ')}`, + ); + } + + // Zelftoets van de leespatronen (anders is een kapotte regex een stil groene scan): de + // M-B-mutant uit de eindreview en de drie vormen moeten matchen, kale lijst-lidmaatschap niet. + truthy('isolatiescan herkent de M-B-mutant (conditionele celtoegang)', + readsField("const x = row.cells.task_type === 'TT_Rsrc' && row.cells.restart_date ? 1 : 2;", 'restart_date')); + truthy('isolatiescan herkent een aanroepargument op een niet-laatste positie', + readsField("ctx.numberOf(row, 'restart_date', 0)", 'restart_date')); + truthy('isolatiescan herkent destructurering', + readsField('const { restart_date: r } = row.cells;', 'restart_date')); + truthy('isolatiescan negeert kale lijst-lidmaatschap', + !readsField("const F = new Set([\n 'a',\n 'restart_date',\n 'b',\n]);", 'restart_date')); + + // BAK 2 — de whitelist-sluiproute-grep die plan §4.1 belooft (zie de kop van dit blok). + const bak2Toegestaan: ReadonlyArray<{ field: string; file: string; exact: RegExp }> = [ + { field: 'plan_end_date', file: join(xerDir, 'xerReader.ts'), exact: /projectRow\.cells\.plan_end_date\b/ }, + { field: 'critical_drtn_hr_cnt', file: join(xerDir, 'xerScheduleOptions.ts'), exact: /row\.cells\.critical_drtn_hr_cnt\b/ }, + ]; + const bak2Overtreders: string[] = []; + for (const file of xerFiles) { + const text = readFileSync(file, 'utf8'); + for (const field of XER_TASK_FORBIDDEN) { + if (!readsField(text, field)) continue; + const uitzondering = bak2Toegestaan.find(entry => entry.field === field && entry.file === file); + if (uitzondering) { + // De uitzondering geldt voor precies die ene vorm; is de kolom daarnaast nog op een + // andere manier gelezen, dan blijft het bestand rood. + const rest = text.replace(new RegExp(uitzondering.exact.source, 'g'), ''); + if (!readsField(rest, field)) continue; + } + bak2Overtreders.push(`${field} in ${file.slice(srcRoot.length)}`); + } + } + checks++; + if (bak2Overtreders.length > 0) { + diffs.push(`sluiproute-grep: BAK-2-kolommen (verboden rekenuitvoer) worden in src/ gelezen — ${bak2Overtreders.join(', ')}`); + } + // De twee toegestane andere-tabel-lezingen moeten ook echt bestaan: verdwijnt er één, dan is + // de uitzondering dood en hoort hij hier weg. + for (const entry of bak2Toegestaan) { + truthy(`sluiproute-grep: toegestane andere-tabel-lezing van ${entry.field} bestaat nog in ${entry.file.slice(srcRoot.length)}`, + existsSync(entry.file) && entry.exact.test(readFileSync(entry.file, 'utf8'))); + } + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════════════════════ +// De corpusscan: union van alle TASK-%F-kolommen over OPS_XER_CORPUS, tegen de vijf bakken. +// ═══════════════════════════════════════════════════════════════════════════════════════════ + +const CORPUS = process.env.OPS_XER_CORPUS; + +function listXerFilesRecursive(dir: string): string[] { + const out: string[] = []; + const entries = readdirSync(dir, { withFileTypes: true }) + .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + for (const entry of entries) { + const full = join(dir, entry.name); + if (entry.isDirectory()) out.push(...listXerFilesRecursive(full)); + else if (entry.isFile() && entry.name.toLowerCase().endsWith('.xer')) out.push(full); + } + return out; +} + +/** + * Decodeert de bytes van een .xer-bestand tot tekst, puur voor kolomNAAM-extractie. GEEN X-O4- + * implementatie (dat is X2's tweepas-heuristiek voor de volledige, betrouwbare bestandsdecodering) + * — kolomnamen op een `%F`-regel zijn altijd ASCII, dus een geldige-UTF-8-toets met een latin1- + * terugval volstaat hier ruimschoots om ze leesbaar te maken. + */ +function decodeForColumnNames(bytes: Buffer): string { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + return bytes.toString('latin1'); + } +} + +/** + * Eigen, MINIMALE %T/%F-scan — geen tokenizer-hergebruik (er is nog niets te hergebruiken; X2 bouwt + * de echte XER-grammatica). Zoekt de TASK-tabel (naam case-insensitief, spatie-getrimd — dezelfde + * coulance als de generieke enum-tokenregel elders in het plan) en geeft de kolomnamen van de + * daaropvolgende `%F`-regel terug, INCLUSIEF een eventuele lege laatste kolom (afsluitende tab). + * `null` als het bestand geen TASK-tabel draagt. + */ +function scanTaskColumns(text: string): string[] | null { + const lines = text.split('\n').map(l => (l.endsWith('\r') ? l.slice(0, -1) : l)); + for (let i = 0; i < lines.length; i++) { + const tTokens = lines[i].split('\t'); + if (tTokens[0] !== '%T') continue; + if ((tTokens[1] ?? '').trim().toUpperCase() !== 'TASK') continue; + const next = lines[i + 1]; + if (!next) return null; + const fTokens = next.split('\t'); + if (fTokens[0] !== '%F') return null; + return fTokens.slice(1); + } + return null; +} + +const corpusDirExists = CORPUS !== undefined && existsSync(CORPUS); +const corpusFiles = corpusDirExists ? listXerFilesRecursive(CORPUS) : []; + +if (!CORPUS) { + console.log('OK xer-field-whitelist: corpus niet aanwezig (OPS_XER_CORPUS) — corpusscan overgeslagen'); +} else if (!corpusDirExists) { + truthy('xer-field-whitelist: OPS_XER_CORPUS wijst naar een bestaande map', false); +} else if (corpusFiles.length === 0) { + console.log('OK xer-field-whitelist: corpus aanwezig maar geen .xer-bestanden gevonden — corpusscan overgeslagen'); +} else { + // kolomnaam -> één voorbeeldbestand (relatief pad — corpus is publiek, §4.3, geen hash-only-eis) + const unionFirstSeen = new Map(); + let filesWithTaskTable = 0; + for (const path of corpusFiles) { + const bytes = readFileSync(path); + const text = decodeForColumnNames(bytes); + const cols = scanTaskColumns(text); + if (cols === null) continue; + filesWithTaskTable++; + for (const col of cols) { + if (!unionFirstSeen.has(col)) unionFirstSeen.set(col, path.slice(CORPUS.length + 1)); + } + } + + truthy(`xer-field-whitelist: minstens één corpusbestand droeg een TASK-tabel (gevonden: ${filesWithTaskTable})`, filesWithTaskTable > 0); + + const whitelistSet = new Set(XER_TASK_WHITELIST as readonly string[]); + const forbiddenSet = new Set(XER_TASK_FORBIDDEN as readonly string[]); + const externalProxySet = new Set(XER_TASK_EXTERNAL_DEPENDENCY_PROXY as readonly string[]); + const recordedOutputSet = new Set(XER_TASK_RECORDED_OUTPUT as readonly string[]); + const ignoredSet = new Set(XER_TASK_IGNORED); + + const unclassified: string[] = []; + for (const [col, exampleFile] of unionFirstSeen) { + const inWhitelist = whitelistSet.has(col); + const inForbidden = forbiddenSet.has(col); + const inExternalProxy = externalProxySet.has(col); + const inRecordedOutput = recordedOutputSet.has(col); + const inIgnored = ignoredSet.has(col); + const bucketCount = Number(inWhitelist) + Number(inForbidden) + Number(inExternalProxy) + + Number(inRecordedOutput) + Number(inIgnored); + if (bucketCount === 0) { + unclassified.push(`"${col}" (bv. ${exampleFile})`); + } else if (bucketCount > 1) { + // Kan hier eigenlijk niet gebeuren gezien de zelfconsistentie-checks hierboven, maar een + // corpuskolom die toch in >1 bak zou vallen is even fataal als "in geen enkele" — beide zijn + // een gat in de poort, dus allebei een diff i.p.v. een silent pass. + unclassified.push(`"${col}" (bv. ${exampleFile}) — staat in MEER DAN ÉÉN bak`); + } + } + + checks++; + if (unclassified.length > 0) { + diffs.push( + `xer-field-whitelist: ${unclassified.length} TASK-%F-kolom(men) uit het corpus staan in géén van de vijf bakken (whitelist/forbidden/external-proxy/recorded-output/ignored) — gatenkaas-mechanisme, plan §4.1: ${unclassified.join(', ')}`, + ); + } + + console.log(`. xer-field-whitelist: ${filesWithTaskTable} bestanden met een TASK-tabel gescand, ${unionFirstSeen.size} unieke kolomnamen, ${unclassified.length} onbekend`); +} + +// ── Uitslag ────────────────────────────────────────────────────────────────── +if (diffs.length === 0) { + console.log(`OK: xer-field-whitelist — ${checks} checks groen`); +} else { + console.log(`XX xer-field-whitelist — ${diffs.length} van ${checks} checks rood:`); + for (const d of diffs) console.log(` XX ${d}`); + process.exit(1); +} diff --git a/tests/planning/check-xer-metadata-catalog-types.ts b/tests/planning/check-xer-metadata-catalog-types.ts new file mode 100644 index 000000000..6c2f64d92 --- /dev/null +++ b/tests/planning/check-xer-metadata-catalog-types.ts @@ -0,0 +1,29 @@ +/** + * Compile-time contract: de X8-broncatalogus is volledig en recursief readonly, + * terwijl de gematerialiseerde documentview juist bewerkbaar blijft. + */ +import type { XerMetadataCatalog, XerMetadataProjectView } from '@/services/xer/xerMetadata'; + +declare const catalog: XerMetadataCatalog; +declare const projectView: XerMetadataProjectView; + +// @ts-expect-error Catalogus-activitycodetypen zijn diep readonly. +catalog.activityCodeTypes[0].name = 'verboden'; +// @ts-expect-error Cataloguswaarden zijn diep readonly. +catalog.activityCodeTypes[0].values[0].code = 'verboden'; +// @ts-expect-error Catalogus-UDF-definities zijn diep readonly. +catalog.customFieldDefs[0].name = 'verboden'; +// @ts-expect-error Catalogusprojecties zijn diep readonly. +catalog.taskProjections[0].activityCodes!.fase = 'verboden'; +// @ts-expect-error Catalogusnotities zijn diep readonly. +catalog.taskProjections[0].notes![0].text = 'verboden'; +// @ts-expect-error Catalogusissues zijn diep readonly. +catalog.issues[0].line = 0; +// @ts-expect-error Raw row-cellen blijven via dezelfde recursieve catalogustype readonly. +catalog.sourceData.TASKMEMO[0].cells.task_memo = 'verboden'; + +// Alleen deze gedupliceerde documentview is bewust mutable. +projectView.activityCodeTypes[0].name = 'toegestaan'; +projectView.activityCodeTypes[0].values[0].code = 'toegestaan'; +projectView.customFieldDefs[0].name = 'toegestaan'; +projectView.taskMetadata.get('taak')!.notes![0].text = 'toegestaan'; diff --git a/tests/planning/check-xer-metadata-corpus.ts b/tests/planning/check-xer-metadata-corpus.ts new file mode 100644 index 000000000..eb28fc264 --- /dev/null +++ b/tests/planning/check-xer-metadata-corpus.ts @@ -0,0 +1,145 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { isMultiDocumentImport } from '@/services/importTypes'; +import { buildXerMetadataCatalog } from '@/services/xer/xerMetadata'; +import { readXER, type XerReadResult } from '@/services/xer/xerReader'; +import { parseXerTables } from '@/services/xer/xerTables'; + +interface Counts { actvtype: number; actvcode: number; taskactv: number; udftype: number; udfvalue: number; memotype: number; taskmemo: number; taskNotes: number; staticTypes: number; staticValues: number; } +const empty = (): Counts => ({ actvtype: 0, actvcode: 0, taskactv: 0, udftype: 0, udfvalue: 0, memotype: 0, taskmemo: 0, taskNotes: 0, staticTypes: 0, staticValues: 0 }); +function decode(bytes: Uint8Array): string { try { return new TextDecoder('utf-8', { fatal: true }).decode(bytes); } catch { return new TextDecoder('windows-1252').decode(bytes); } } + +/** Zelfstandige minimale teller: geen import uit productie-XER, geen gedeelde tokenizer of veldkaart. */ +function countRaw(bytes: Uint8Array): Counts { + const out = empty(); let table = ''; let fields: string[] = []; const staticIds = new Set(); + for (const line of decode(bytes).split('\n')) { + const cells = line.replace(/\r$/, '').split('\t'); const marker = cells[0]?.trim(); + if (marker === '%T') { table = cells[1]?.trim().toUpperCase() ?? ''; fields = []; continue; } + if (marker === '%F') { fields = cells.slice(1).map(field => field.trim().toLowerCase()); continue; } + if (marker !== '%R') continue; + const row = Object.fromEntries(fields.map((field, index) => [field, cells[index + 1] ?? ''])); + if (table === 'ACTVTYPE') out.actvtype++; + if (table === 'ACTVCODE') out.actvcode++; + if (table === 'TASKACTV') out.taskactv++; + if (table === 'UDFTYPE') { out.udftype++; if ((row.logical_data_type || row.udf_type || '').trim().toUpperCase() === 'FT_STATICTYPE') { out.staticTypes++; staticIds.add(row.udf_type_id ?? ''); } } + if (table === 'UDFVALUE') { out.udfvalue++; if (staticIds.has(row.udf_type_id ?? '')) out.staticValues++; } + if (table === 'MEMOTYPE') out.memotype++; + if (table === 'TASKMEMO') out.taskmemo++; + if (table === 'TASK' && row.task_notes) out.taskNotes++; + } + return out; +} +function add(target: Counts, source: Counts): void { for (const key of Object.keys(target) as (keyof Counts)[]) target[key] += source[key]; } +function results(bytes: Uint8Array): XerReadResult[] { const opened = readXER(bytes); return isMultiDocumentImport(opened) ? opened.results as XerReadResult[] : [opened]; } + +interface ProductProbe { documents: number; readMs: number; rssMiB: number; heapDeltaMiB: number; } +const probePath = process.env.OPS_X8_METADATA_PROBE; +if (probePath) { + // Een vers Node-proces voorkomt dat de onafhankelijke ruwe teller en de directe catalogusmeting + // het resident geheugen van de productreader kunstmatig ophogen. + global.gc?.(); + const before = process.memoryUsage(); const started = performance.now(); + const opened = results(new Uint8Array(readFileSync(probePath))); + const readMs = performance.now() - started; + global.gc?.(); + const after = process.memoryUsage(); + const probe: ProductProbe = { + documents: opened.length, readMs, rssMiB: after.rss / 1024 / 1024, + heapDeltaMiB: (after.heapUsed - before.heapUsed) / 1024 / 1024, + }; + console.log(`X8_METADATA_PROBE ${JSON.stringify(probe)}`); + process.exit(0); +} +function productProbe(path: string): ProductProbe { + const output = execFileSync(process.execPath, ['--expose-gc', process.argv[1]!], { + cwd: process.cwd(), encoding: 'utf8', env: { ...process.env, OPS_X8_METADATA_PROBE: path }, + }); + const line = output.trim().split('\n').find(entry => entry.startsWith('X8_METADATA_PROBE ')); + if (!line) throw new Error(`X8-geheugenprobe gaf geen meetregel terug voor ${path}.`); + return JSON.parse(line.slice('X8_METADATA_PROBE '.length)) as ProductProbe; +} +/** Onafhankelijk van de product-allowlist: telt iedere %R in exact één bron-tabel. */ +function countRawTableRows(bytes: Uint8Array, wantedTable: string): number { + let table = ''; + let count = 0; + for (const line of decode(bytes).split('\n')) { + const cells = line.replace(/\r$/, '').split('\t'); + const marker = cells[0]?.trim(); + if (marker === '%T') { table = cells[1]?.trim().toUpperCase() ?? ''; continue; } + if (marker === '%R' && table === wantedTable) count++; + } + return count; +} +const root = process.env.OPS_XER_CORPUS; +if (!root) console.log('OK XER metadata-corpus: corpus niet aanwezig — corpuspoort overgeslagen'); +else { + // De harness bundelt normaal in tests/planning; de test blijft ook uitvoerbaar uit een tijdelijk + // pad, zolang hij vanuit de repositoryroot draait (zoals npm run test:planning dat doet). + const manifest = JSON.parse(readFileSync(join(process.cwd(), 'tests/planning/xer-corpus-manifest.json'), 'utf8')) as { files: Record }; + const total = empty(); + for (const label of Object.keys(manifest.files)) add(total, countRaw(new Uint8Array(readFileSync(join(root, label))))); + // De onafhankelijke ruwe scanner telt ook de drie UDFVALUE-rijen uit X2-geweigerde bestanden; + // de bestaande parser-populatiepin (71 leesbare bestanden) is daardoor 12.750, de bronmassa + // hier terecht 12.753. TASKACTV blijft in beide tellers exact 119.878. + const expected = { actvtype: 77, actvcode: 555, taskactv: 119878, udftype: 48, udfvalue: 12753, memotype: 7, taskmemo: 20, taskNotes: 2, staticTypes: 10, staticValues: 531 }; + const samples = [ + ['rehab', 'crawl-xer-extra/jailaff-xer-splitter/rehab-2.xer', 81339, 12, 208], + ['hotel', 'crawl-xer/Hotel_Construction_TEC.xer', 18020, 5, 46], + ] as const; + const diffs: string[] = []; const eq = (name: string, got: unknown, want: unknown) => { if (JSON.stringify(got) !== JSON.stringify(want)) diffs.push(`${name}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); }; + eq('C1 onafhankelijke publieke totaaltelling', total, expected); + // HarbourPointe heeft geen TASK.task_notes als substituut: de 131 notities staan uitsluitend + // in TASKNOTE. Deze probe leest %T/%F/%R zelf, zodat een allowlist-gat niet door productiecode + // of een hergebruikte tokenizer kan worden verborgen. + const harbourPath = join(root, 'crawl-xer/HarbourPointe_AssistedLiving.xer'); + eq('C1a HarbourPointe TASKNOTE-dossier bestaat', existsSync(harbourPath), true); + if (existsSync(harbourPath)) { + const bytes = new Uint8Array(readFileSync(harbourPath)); + const tables = parseXerTables(bytes); + const catalog = buildXerMetadataCatalog(tables); + const opened = results(bytes); + const memory = productProbe(harbourPath); + const sourceData = catalog.sourceData; + eq('C1b HarbourPointe behoudt alle 131 TASKNOTE-rijen zero-copy van tokenisatie tot catalogus en projectie', { + raw: countRawTableRows(bytes, 'TASKNOTE'), + tokenized: tables.tables.get('TASKNOTE')?.rows.length ?? 0, + catalog: sourceData.TASKNOTE?.length ?? 0, + zeroCopy: sourceData.TASKNOTE === tables.tables.get('TASKNOTE')?.rows, + projected: opened.reduce((sum, result) => sum + result.tasks.reduce( + (taskSum, task) => taskSum + (task.notes?.filter(note => note.id.startsWith('xer-note:tasknote:')).length ?? 0), 0), 0), + }, { raw: 131, tokenized: 131, catalog: 131, zeroCopy: true, projected: 131 }); + eq('C1c HarbourPointe TASKNOTE-pad blijft in een vers proces binnen de heappoort', { + documents: memory.documents, + rssBelow512MiB: memory.rssMiB < 512, + heapDeltaBelow256MiB: memory.heapDeltaMiB < 256, + }, { documents: opened.length, rssBelow512MiB: true, heapDeltaBelow256MiB: true }); + console.log(`. xer-metadata-perf: harbour tasknotes=131, documenten=${memory.documents}, readMs=${memory.readMs.toFixed(1)}, rssMiB=${memory.rssMiB.toFixed(1)}, heapDeltaMiB=${memory.heapDeltaMiB.toFixed(1)}`); + } + for (const [name, label, links, types, values] of samples) { + const path = join(root, label); eq(`C2 ${name}-bestand bestaat`, existsSync(path), true); + if (!existsSync(path)) continue; + const bytes = new Uint8Array(readFileSync(path)); const raw = countRaw(bytes); + // De totale reader omvat ook de kalender-, X4b-, X5- en X6-paden en is daarom gevoelig voor + // hostbelasting. Meet X8 zelf apart op reeds geparste tabellen: de grens schaalt lineair met + // het werk (koppelingen plus UDF's), terwijl de referentie-identiteit hieronder een regressie + // naar P×raw-row-kopieën direct rood maakt. + const directProfile = (() => { + const tables = parseXerTables(bytes); const catalogStarted = performance.now(); + const directCatalog = buildXerMetadataCatalog(tables); + return { catalogMs: performance.now() - catalogStarted, zeroCopy: directCatalog.sourceData.TASKACTV === tables.tables.get('TASKACTV')?.rows }; + })(); + const started = performance.now(); const opened = results(bytes); const elapsedMs = performance.now() - started; + const catalog = opened[0]?.xer.metadata?.catalog; + const mappedLinks = catalog?.taskProjections.reduce((sum, projection) => sum + Object.keys(projection.activityCodes ?? {}).length, 0); + eq(`C3 ${name}: onafhankelijke TASKACTV-telling, productprojectie en zero-copy catalogus`, { raw: raw.taskactv, mapped: mappedLinks, types: catalog?.activityCodeTypes.length, values: catalog?.activityCodeTypes.reduce((sum, type) => sum + type.values.length, 0), shared: opened.every(result => result.xer.metadata?.catalog === catalog), zeroCopy: directProfile.zeroCopy }, { raw: links, mapped: links, types, values, shared: true, zeroCopy: true }); + const maxCatalogMs = Math.max(750, (raw.taskactv + raw.udfvalue) * 0.05); + eq(`C4 ${name}: file-wide X8-mapping blijft lineair binnen de vaste werkbudgetgrens`, directProfile.catalogMs < maxCatalogMs, true); + const memory = productProbe(path); + eq(`C5 ${name}: verse productreader blijft onder 768 MiB RSS`, memory.rssMiB < 768, true); + eq(`C6 ${name}: verse productreader opent hetzelfde documentaantal`, memory.documents, opened.length); + console.log(`. xer-metadata-perf: ${name} links=${links}, documenten=${opened.length}, catalogMs=${directProfile.catalogMs.toFixed(1)}/${maxCatalogMs.toFixed(1)}, readMs=${elapsedMs.toFixed(1)}, rssMiB=${memory.rssMiB.toFixed(1)}, heapDeltaMiB=${memory.heapDeltaMiB.toFixed(1)}`); + } + if (diffs.length) { for (const diff of diffs) console.error(`XX ${diff}`); process.exitCode = 1; } + else console.log('OK XER metadata-corpus: onafhankelijke tellingen, projecties en performance groen'); +} diff --git a/tests/planning/check-xer-metadata-index.ts b/tests/planning/check-xer-metadata-index.ts new file mode 100644 index 000000000..a385292a7 --- /dev/null +++ b/tests/planning/check-xer-metadata-index.ts @@ -0,0 +1,20 @@ +import { buildXerMetadataCatalog, materializeXerMetadata } from '@/services/xer/xerMetadata'; +import { parseXerTables } from '@/services/xer/xerTables'; + +const projects = 120; +const lines = ['ERMHDR\t23.12', '%T\tTASK', '%F\ttask_id\tproj_id\ttask_code']; +for (let index = 0; index < projects; index++) lines.push(`%R\tT${index}\tP${index}\tC${index}`); +lines.push('%T\tACTVTYPE', '%F\tactv_code_type_id\tactv_code_type', '%R\tTYPE\tFase'); +lines.push('%T\tACTVCODE', '%F\tactv_code_id\tactv_code_type_id\tshort_name', '%R\tVALUE\tTYPE\tV'); +lines.push('%T\tTASKACTV', '%F\tproj_id\ttask_id\tactv_code_type_id\tactv_code_id'); +for (let index = 0; index < projects; index++) lines.push(`%R\tP${index}\tT${index}\tTYPE\tVALUE`); +lines.push('%E'); + +const catalog = buildXerMetadataCatalog(parseXerTables(new TextEncoder().encode(lines.join('\n')))); +const visits = Array.from({ length: projects }, (_, index) => materializeXerMetadata(catalog, `P${index}`).visitedTaskProjectionCount); +const diffs: string[] = []; +if (catalog.taskProjections.length !== projects) diffs.push(`platte X9-lijst ${catalog.taskProjections.length} != ${projects}`); +if (visits.some(count => count !== 1)) diffs.push(`projectindex bezocht niet exact één projectie: ${JSON.stringify(visits)}`); +if (!Object.isFrozen(catalog.taskProjectionsByProject) || !Object.values(catalog.taskProjectionsByProject).every(Object.isFrozen)) diffs.push('projectindex is niet diep bevroren'); +if (diffs.length) { console.error(`XX X8 projectindex\n${diffs.join('\n')}`); process.exitCode = 1; } +else console.log(`OK X8 projectindex: projecten=${projects}, totaleMetadata=${projects}, bezoeken=${visits.reduce((a, b) => a + b, 0)}`); diff --git a/tests/planning/check-xer-metadata-review.ts b/tests/planning/check-xer-metadata-review.ts new file mode 100644 index 000000000..3092bcdec --- /dev/null +++ b/tests/planning/check-xer-metadata-review.ts @@ -0,0 +1,112 @@ +import { isMultiDocumentImport } from '@/services/importTypes'; +import { buildXerMetadataCatalog, materializeXerMetadata } from '@/services/xer/xerMetadata'; +import { readXER } from '@/services/xer/xerReader'; +import { parseXerTables } from '@/services/xer/xerTables'; +import { createAppStore } from '@/state/appStore'; + +const rows = [ + 'ERMHDR\t23.12', '%T\tPROJECT', '%F\tproj_id\tproj_short_name\tclndr_id\tsum_base_proj_id', + '%R\tMAIN\tMain\tC\tBASE', '%R\tBASE\tBaseline\tC\t', '%R\tOTHER\tOther\tC\t', + '%T\tCALENDAR', '%F\tclndr_id\tclndr_name\tday_hr_cnt\tweek_hr_cnt', '%R\tC\tStandaard\t8\t40', + '%T\tTASK', '%F\ttask_id\tproj_id\ttask_code\ttask_name\tclndr_id\ttarget_start_date\ttarget_end_date\ttarget_drtn_hr_cnt\ttask_notes', + '%R\tSHARED\tMAIN\tM\tMain task\tC\t2026-01-01\t2026-01-02\t8\tTaak\u007f\u007fnoot', + '%R\tSHARED\tBASE\tB\tBase task\tC\t2026-01-01\t2026-01-02\t8\t', + '%R\tSHARED\tOTHER\tO\tOther task\tC\t2026-01-01\t2026-01-02\t8\t', + '%T\tACTVTYPE', '%F\tactv_code_type_id\tactv_code_type\tseq_num', '%R\tTYPE\tFase\t2', '%R\tTYPE\tDubbel\t1', + '%T\tACTVCODE', '%F\tactv_code_id\tactv_code_type_id\tshort_name', '%R\tVM\tTYPE\tM', '%R\tVB\tTYPE\tB', '%R\tVO\tTYPE\tO', + '%T\tTASKACTV', '%F\tproj_id\ttask_id\tactv_code_type_id\tactv_code_id', + '%R\tMAIN\tSHARED\tTYPE\tVM', '%R\tMAIN\tSHARED\tTYPE\tVM', '%R\tBASE\tSHARED\tTYPE\tVB', '%R\tOTHER\tSHARED\tTYPE\tVO', + '%T\tUDFTYPE', '%F\tudf_type_id\ttable_name\tudf_type_label\tlogical_data_type', + '%R\tUT\tTASK\tTekst\tFT_TEXT', '%R\tUT\tTASK\tDubbel\tFT_TEXT', '%R\tUW\tWBS\tWBS tekst\tFT_TEXT', '%R\tUX\tTASK\tOnbekend\tFT_MYSTERY', + '%T\tUDFVALUE', '%F\tudf_type_id\tproj_id\tfk_id\tudf_text', + '%R\tUT\tMAIN\tSHARED\tZ', '%R\tUT\tMAIN\tSHARED\tA', '%R\tUT\tBASE\tSHARED\tBase', '%R\tUT\tOTHER\tSHARED\tOther', + '%R\tUT\t\tSHARED\tAmbiguous', '%R\tUT\tMAIN\tMISSING\tDangling', '%R\tUW\tMAIN\tW1\tUitgesteld', '%R\tUX\tMAIN\tSHARED\tOnbekend', + '%T\tMEMOTYPE', '%F\tmemo_type_id\tmemo_type', '%R\tMT\tMemo', + '%T\tTASKMEMO', '%F\tmemo_id\tproj_id\ttask_id\tmemo_type_id\ttask_memo\tseq_num', '%R\tMM\tMAIN\tSHARED\tMT\tMemo\u007f\u007ftekst\t1', + '%E', +]; +const opened = readXER(new TextEncoder().encode(rows.join('\n'))); +if (!isMultiDocumentImport(opened)) throw new Error('reviewfixture moet MAIN en OTHER openen'); +const main = opened.results.find(result => result.project.id === 'MAIN')!; +const other = opened.results.find(result => result.project.id === 'OTHER')!; +const catalog = main.xer?.metadata?.catalog; +if (!catalog) throw new Error('reviewfixture mist de X8-broncatalogus'); +const diffs: string[] = []; let checks = 0; +const eq = (label: string, got: unknown, want: unknown) => { checks++; if (JSON.stringify(got) !== JSON.stringify(want)) diffs.push(`${label}: ${JSON.stringify(got)} != ${JSON.stringify(want)}`); }; +eq('MAIN/BASE/OTHER selectie', opened.results.map(result => result.project.id).sort(), ['MAIN', 'OTHER']); +eq('zelfde task_id lekt niet', [main.tasks[0]?.activityCodes, other.tasks[0]?.activityCodes], [{ TYPE: 'VM' }, { TYPE: 'VO' }]); +eq('baselineprojectie blijft catalogusdata', catalog.taskProjectionsByProject.BASE?.[0]?.activityCodes, { TYPE: 'VB' }); +eq('task_notes heeft precedence vóór memo en beide DEL-DEL', main.tasks[0]?.notes?.map(note => note.text), ['Taak\nnoot', 'Memo\ntekst']); +eq('duplicate UDF kiest deterministisch lexicografisch', main.tasks[0]?.customFields, { UT: 'A' }); +eq('diagnostiek onderscheidt reviewtakken', { + duplicateType: catalog.issueCounts.XER_ACTIVITY_CODE_DUPLICATE_TYPE_ID, + duplicateLink: catalog.issueCounts.XER_ACTIVITY_CODE_DUPLICATE_LINK, + duplicateUdfType: catalog.issueCounts.XER_UDF_DUPLICATE_TYPE_ID, + duplicateUdfValue: catalog.issueCounts.XER_UDF_DUPLICATE_VALUE, + deferred: catalog.issueCounts.XER_UDF_DEFERRED_ENTITY, + unknown: catalog.issueCounts.XER_UDF_UNKNOWN_DATA_TYPE, + dangling: catalog.issueCounts.XER_UDF_DANGLING_ENTITY, + ambiguous: catalog.issueCounts.XER_UDF_AMBIGUOUS_TASK, +}, { duplicateType: 1, duplicateLink: 1, duplicateUdfType: 1, duplicateUdfValue: 1, deferred: 1, unknown: 1, dangling: 1, ambiguous: 1 }); +eq('catalogus diep immutable', [Object.isFrozen(catalog), Object.isFrozen(catalog.taskProjectionsByProject.MAIN), Object.isFrozen(catalog.sourceData.TASKACTV)], [true, true, true]); + +const store = createAppStore(); +store.getState().applyLoadedProject(main, { filePath: null, fileHandle: null, recompute: false, fit: false, hourDataNotice: false, linkedOpen: true }); +const sourceId = store.getState().activeDocumentId; const sharedCatalog = store.getState().xerImportMetadata!.metadata!.catalog; +const copyId = store.getState().duplicateDocument('kopie'); +store.getState().updateTask('SHARED', { activityCodes: { TYPE: 'VO' }, customFields: { UT: 'Mutatie' }, notes: [{ id: 'mut', text: 'Mutatie', done: false }] }); +eq('mutable domeinview verandert zonder catalogusmutatie', [store.getState().tasks[0]?.customFields, sharedCatalog.taskProjectionsByProject.MAIN[0]?.customFields, store.getState().xerImportMetadata!.metadata!.catalog === sharedCatalog], [{ UT: 'Mutatie' }, { UT: 'A' }, true]); +store.getState().undo(); +eq('undo herstelt mutable domeinview', [store.getState().tasks[0]?.activityCodes, store.getState().tasks[0]?.customFields, store.getState().tasks[0]?.notes?.map(note => note.text)], [{ TYPE: 'VM' }, { UT: 'A' }, ['Taak\nnoot', 'Memo\ntekst']]); +store.getState().switchDocument(sourceId); +eq('switch terug naar bron blijft geïsoleerd', [store.getState().activeDocumentId === sourceId, store.getState().tasks[0]?.customFields, store.getState().xerImportMetadata!.metadata!.catalog === sharedCatalog], [true, { UT: 'A' }, true]); +store.getState().switchDocument(copyId); +eq('switch naar kopie na undo blijft geïsoleerd', [store.getState().tasks[0]?.customFields, store.getState().xerImportMetadata!.metadata!.catalog === sharedCatalog], [{ UT: 'A' }, true]); + +// Notitiecontract: TASK.task_notes heeft vaste voorrang, gevolgd door alle TASKNOTE-rijen in +// bronvolgorde en daarna alle TASKMEMO-rijen in bronvolgorde. Geen van de drie lagen dedupliceert +// inhoud of id's stil; een duplicate memo-id blijft een diagnose én een afzonderlijke notitie. +const noteRows = [ + 'ERMHDR\t23.12', + '%T\tTASK', '%F\ttask_id\tproj_id\ttask_code\ttask_notes', + '%R\tT\tP1\tP1-T\tTaak\u007f\u007fnoot', '%R\tT\tP2\tP2-T\t', '%R\tU\tP1\tP1-U\t', + '%T\tTASKNOTE', '%F\ttask_id\tproj_id\ttask_notes', + '%R\tT\tP1\tEerste\u007f\u007fTASKNOTE', '%R\tT\tP1\tEerste\u007f\u007fTASKNOTE', '%R\tT\tP1\tTweede TASKNOTE', + '%R\tMISSING\tP1\tDangling TASKNOTE', '%R\tT\t\tAmbigue TASKNOTE', + '%T\tMEMOTYPE', '%F\tmemo_type_id\tmemo_type', '%R\tMT\tMemo', + '%T\tTASKMEMO', '%F\tmemo_id\tproj_id\ttask_id\tmemo_type_id\ttask_memo', + '%R\tDUP\tP1\tT\tMT\tEerste memo', '%R\tDUP\tP1\tT\tMT\tTweede memo', '%R\tMISSINGTYPE\tP1\tT\tUNKNOWN\tMemo zonder type', + '%R\tDANGLINGTASK\tP1\tMISSING\tMT\tDangling memo', '%R\tAMBIGUOUS\t\tT\tMT\tAmbigue memo', + '%E', +]; +const noteTables = parseXerTables(new TextEncoder().encode(noteRows.join('\n'))); +const noteCatalog = buildXerMetadataCatalog(noteTables); +const noteView = materializeXerMetadata(noteCatalog, 'P1'); +const noteSourceData = noteCatalog.sourceData; +const noteCounts = noteCatalog.issueCounts; +eq('TASKNOTE blijft zero-copy in de catalogus en bewaart elke bronrij', { + raw: noteTables.tables.get('TASKNOTE')?.rows.length ?? 0, + catalog: noteSourceData.TASKNOTE?.length ?? 0, + shared: noteSourceData.TASKNOTE === noteTables.tables.get('TASKNOTE')?.rows, + frozen: [ + Object.isFrozen(noteSourceData.TASKNOTE), + Object.isFrozen(noteSourceData.TASKNOTE[0]), + Object.isFrozen(noteSourceData.TASKNOTE[0]?.cells), + ], +}, { raw: 5, catalog: 5, shared: true, frozen: [true, true, true] }); +eq('notities volgen task-notes, TASKNOTE-bronvolgorde en TASKMEMO-bronvolgorde zonder verlies', + noteView.taskMetadata.get('T')?.notes?.map(note => note.text), + ['Taak\nnoot', 'Eerste\nTASKNOTE', 'Eerste\nTASKNOTE', 'Tweede TASKNOTE', 'Eerste memo', 'Tweede memo', 'Memo zonder type']); +eq('dezelfde notitietekst en duplicate memo-id blijven afzonderlijke bronnen', { + uniqueIds: new Set(noteView.taskMetadata.get('T')?.notes?.map(note => note.id)).size, + count: noteView.taskMetadata.get('T')?.notes?.length, +}, { uniqueIds: 7, count: 7 }); +eq('notitiediagnostiek onderscheidt duplicate memo-id, ontbrekend memotype, dangling en ambigu taak-id', { + duplicateMemo: noteCounts.XER_NOTE_DUPLICATE_MEMO_ID, + danglingMemoType: noteCounts.XER_NOTE_DANGLING_MEMO_TYPE, + danglingTask: noteCounts.XER_NOTE_DANGLING_TASK, + ambiguousTask: noteCounts.XER_NOTE_AMBIGUOUS_TASK, +}, { duplicateMemo: 1, danglingMemoType: 1, danglingTask: 2, ambiguousTask: 2 }); + +if (diffs.length) { console.error(`XX X8 reviewerprobes (${checks})\n${diffs.join('\n')}`); process.exitCode = 1; } +else console.log(`OK X8 reviewerprobes (${checks} checks)`); diff --git a/tests/planning/check-xer-metadata-wiring.ts b/tests/planning/check-xer-metadata-wiring.ts new file mode 100644 index 000000000..3954b70dc --- /dev/null +++ b/tests/planning/check-xer-metadata-wiring.ts @@ -0,0 +1,91 @@ +import { isMultiDocumentImport } from '@/services/importTypes'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; +import { writeIFC } from '@/services/ifc/ifcWriter'; +import { payloadFromImport } from '@/state/documentContract'; +import { readXER } from '@/services/xer/xerReader'; + +const diffs: string[] = []; +let checks = 0; + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +const source = new TextEncoder().encode([ + 'ERMHDR\t23.12', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date', + '%R\tP-A\tProject A\tC\t2026-01-01 08:00', + '%R\tP-B\tProject B\tC\t2026-01-01 08:00', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC\tStandaard\t8\t40\t', + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_code\ttask_name\tclndr_id\ttarget_start_date\ttarget_end_date\ttarget_drtn_hr_cnt\ttask_notes', + '%R\tSHARED\tP-A\tA-1\tTaak A\tC\t2026-01-01 08:00\t2026-01-01 16:00\t8\tEerste\u007f\u007ftweede', + '%R\tSHARED\tP-B\tB-1\tTaak B\tC\t2026-01-01 08:00\t2026-01-01 16:00\t8\t', + '%T\tACTVTYPE', + '%F\tactv_code_type_id\tactv_code_type\tseq_num', + '%R\tTYPE\tFase\t1', + '%T\tACTVCODE', + '%F\tactv_code_id\tactv_code_type_id\tshort_name\tseq_num', + '%R\tV-A\tTYPE\tA\t1', + '%R\tV-B\tTYPE\tB\t2', + '%T\tTASKACTV', + '%F\tproj_id\ttask_id\tactv_code_type_id\tactv_code_id', + '%R\tP-A\tSHARED\tTYPE\tV-A', + '%R\tP-B\tSHARED\tTYPE\tV-B', + '%T\tUDFTYPE', + '%F\tudf_type_id\ttable_name\tudf_type_label\tlogical_data_type', + '%R\tUF\tTASK\tKeuze\tFT_STATICTYPE', + '%T\tUDFVALUE', + '%F\tudf_type_id\tproj_id\tfk_id\tudf_text', + '%R\tUF\tP-A\tSHARED\tAlleen A', + '%E', +].join('\n')); + +const opened = readXER(source); +if (!isMultiDocumentImport(opened)) throw new Error('De X8-fixture moet via het X4b-meerdocumentpad lopen.'); +const [a, b] = opened.results; + +eq('1 activity-codekoppelingen worden op project en taak geprojecteerd zonder lekkage', [ + a?.tasks.find(task => task.id === 'SHARED')?.activityCodes, + b?.tasks.find(task => task.id === 'SHARED')?.activityCodes, +], [{ TYPE: 'V-A' }, { TYPE: 'V-B' }]); +eq('2 FT_STATICTYPE wordt als tekstcustomfield alleen op de bedoelde taak gezet', [ + a?.customFieldDefs, + a?.tasks.find(task => task.id === 'SHARED')?.customFields, + b?.tasks.find(task => task.id === 'SHARED')?.customFields, +], [[{ id: 'UF', name: 'Keuze', type: 'text' }], { UF: 'Alleen A' }, undefined]); +eq('3 TASK.task_notes hergebruikt de DEL-DEL-decoder', + a?.tasks.find(task => task.id === 'SHARED')?.notes, + [{ id: 'xer-note:task:SHARED', text: 'Eerste\ntweede', done: false }]); +eq('4 bestandsbrede metadata-catalogus blijft één readonly bronobject voor X9', + a?.xer?.metadata?.catalog === b?.xer?.metadata?.catalog, true); +eq('5 TASKACTV-telling pin: iedere geldige koppeling materialiseert precies eenmaal', + [a, b].reduce((sum, result) => sum + result!.tasks.reduce( + (taskSum, task) => taskSum + Object.keys(task.activityCodes ?? {}).length, 0), 0), 2); +const payloadA = payloadFromImport(a!, null); +const ifcA = readIFC(writeIFC(a!)); +eq('6 documentcontract houdt de tijdelijke X8-broncatalogus vast', + payloadA.xerImportMetadata?.metadata?.catalog === a?.xer?.metadata?.catalog, true); +eq('7 bestaande IFC-structuurroundtrip bewaart X8-definities, waarden en notities', { + types: ifcA.activityCodeTypes, + defs: ifcA.customFieldDefs, + task: (() => { const task = ifcA.tasks.find(item => item.wbsCode === 'A-1'); return task && { activityCodes: task.activityCodes, customFields: task.customFields, notes: task.notes }; })(), +}, { + types: [{ id: 'TYPE', name: 'Fase', values: [{ id: 'V-A', code: 'A' }, { id: 'V-B', code: 'B' }] }], + defs: [{ id: 'UF', name: 'Keuze', type: 'text' }], + task: { activityCodes: { TYPE: 'V-A' }, customFields: { UF: 'Alleen A' }, notes: [{ id: 'xer-note:task:SHARED', text: 'Eerste\ntweede', done: false }] }, +}); + +if (diffs.length > 0) { + console.error(`XX XER metadata-wiring: ${diffs.length} afwijking(en) (${checks} checks)`); + for (const diff of diffs) console.error(` ${diff}`); + process.exitCode = 1; +} else { + console.log(`OK XER metadata-wiring: ${checks} checks`); +} diff --git a/tests/planning/check-xer-multi-project.ts b/tests/planning/check-xer-multi-project.ts new file mode 100644 index 000000000..a5acc8fb0 --- /dev/null +++ b/tests/planning/check-xer-multi-project.ts @@ -0,0 +1,926 @@ +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { assembleXerMultiProjectImport } from '@/services/xer/xerMultiProject'; +import type { XerReadResult } from '@/services/xer/xerReader'; +import { + parseXerNumber, + parseXerTables, + XerImportError, + type XerRow, + type XerTable, + type XerTables, +} from '@/services/xer/xerTables'; +import type { Task } from '@/types/task'; +import { createDefaultCalendar } from '@/engine/calendar/defaultCalendar'; +import { solveProject } from '@/engine/scheduler/solveProject'; +import { CalendarEngine } from '@/engine/scheduler/CalendarEngine'; +import { computeVariance } from '@/engine/variance'; +import { createDefaultTaskTime } from '@/utils/taskDefaults'; +import { payloadFromImport } from '@/state/documentContract'; +import { writeIFC } from '@/services/ifc/ifcWriter'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; +import { XER_SCHEDULING_DEFAULTS } from '@/services/xer/xerScheduleOptions'; + +const diffs: string[] = []; +let checks = 0; + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +function row(line: number, cells: Record): XerRow { + return { line, cells }; +} + +function table(name: string, rows: XerRow[]): XerTable { + return { + name, + fields: Array.from(new Set(rows.flatMap(item => Object.keys(item.cells)))), + rows, + }; +} + +function tables(projectRows: XerRow[], taskRows: XerRow[]): XerTables { + return { + header: { version: 'test', defaultCurrencyCode: 'EUR' }, + tables: new Map([ + ['PROJECT', table('PROJECT', projectRows)], + ['TASK', table('TASK', taskRows)], + ]), + report: { + encoding: 'utf-8', endMarkerSeen: true, issues: [], unknownTables: [], unknownFields: [], + }, + numberFormat: { decimal: '.', group: null, source: 'default', currencyCode: 'EUR' }, + }; +} + +function task(projectId: string, index: number, summary = false): Task { + const id = `${projectId}-T${index}`; + return { + id, + name: id, + description: '', + wbsCode: id, + taskType: 'CONSTRUCTION', + status: 'NOT_STARTED', + isMilestone: false, + priority: 500, + parentId: null, + childIds: summary ? [`${id}-child`] : [], + ...(summary ? { isSummary: true } : {}), + time: createDefaultTaskTime(`2026-01-${String(index).padStart(2, '0')}`, 1), + resourceIds: [], + }; +} + +function mappedProject(projectId: string, leaves: number, summaries = 0): XerReadResult { + const calendar = { ...createDefaultCalendar(), id: `${projectId}-calendar` }; + return { + project: { + id: projectId, + name: `Project ${projectId}`, + description: '', + startDate: '2026-01-01', + endDate: '2026-12-31', + calendarId: calendar.id, + createdAt: '2026-01-01T00:00:00.000Z', + modifiedAt: '2026-01-02T00:00:00.000Z', + author: '', + company: '', + progressMode: XER_SCHEDULING_DEFAULTS.progressMode, + schedulingOptions: structuredClone(XER_SCHEDULING_DEFAULTS.schedulingOptions), + }, + calendar, + tasks: [ + ...Array.from({ length: summaries }, (_, index) => task(projectId, index + 20, true)), + ...Array.from({ length: leaves }, (_, index) => task(projectId, index + 1)), + ], + sequences: [], + resources: [], + assignments: [], + xer: { + defaultCurrencyCode: 'EUR', + tableReport: { encoding: 'utf-8', endMarkerSeen: true, issues: [], unknownTables: [] }, + calendarIssues: [], + enumFallbacks: [], + scheduleOptions: { + source: 'xer-defaults', retainedSource: {}, fallbacks: [], diagnostics: [], + sourceArchive: { rows: [], unmatchedScheduleOptionsRowIndexes: [], diagnostics: [] }, + sourceRowIndexes: [], sourceRows: [], + }, + externalRelations: [], + externalLinks: [], + report: { + projectsSeen: 1, + documentsOpened: 1, + emptyProjectsSkipped: 0, + baselineProjectsExcluded: 0, + baselinesMaterialized: 0, + danglingBaselineReferences: 0, + externalLinksPreserved: 0, + baselineExclusionReverted: false, + baselineFallbackReasons: [], + }, + }, + }; +} + +// Breuken die dit vangt: export_flag als selector gebruiken, lege projecten als tab openen, +// samenvattingen als bladtaken tellen of het eerste in plaats van het grootste open project kiezen. +const selectionTables = tables([ + row(2, { proj_id: 'P-A', proj_short_name: 'A', export_flag: 'Y' }), + row(3, { proj_id: 'P-EMPTY', proj_short_name: 'Leeg', export_flag: 'Y' }), + row(4, { proj_id: 'P-B', proj_short_name: 'B', export_flag: 'N' }), +], [ + row(8, { proj_id: 'P-A', task_id: 'A-1', task_code: 'A-1' }), + row(9, { proj_id: 'P-A', task_id: 'A-2', task_code: 'A-2' }), + row(10, { proj_id: 'P-B', task_id: 'B-1', task_code: 'B-1' }), + row(11, { proj_id: 'P-B', task_id: 'B-2', task_code: 'B-2' }), + row(12, { proj_id: 'P-B', task_id: 'B-3', task_code: 'B-3' }), +]); +const mapped = new Map([ + ['P-A', mappedProject('P-A', 2, 4)], + ['P-B', mappedProject('P-B', 3)], +]); +const mappedCalls: string[] = []; +const selection = assembleXerMultiProjectImport(selectionTables, (projectId) => { + mappedCalls.push(projectId); + const result = mapped.get(projectId); + if (!result) throw new Error(`Onverwachte mapperaanroep voor ${projectId}`); + return result; +}); + +eq('1 alle niet-lege projecten openen in bronvolgorde; export_flag discrimineert niet', + selection.documents.map(document => document.projectId), ['P-A', 'P-B']); +eq('2 lege projectrij wordt niet gemapt of geopend', mappedCalls, ['P-A', 'P-B']); +eq('3 actief project telt alleen bladtaken onder werkelijk geopende documenten', + selection.activeProjectId, 'P-B'); +eq('4 selectieverslag telt bronrijen, documenten en lege projecten', selection.report, { + projectsSeen: 3, + documentsOpened: 2, + emptyProjectsSkipped: 1, + baselineProjectsExcluded: 0, + baselinesMaterialized: 0, + danglingBaselineReferences: 0, + externalLinksPreserved: 0, + baselineExclusionReverted: false, + baselineFallbackReasons: [], +}); + +// Breuk die dit vangt: twee PROJECT-rijen met dezelfde stabiele identiteit als twee documenten +// doorgeven. Daarmee zouden tabselectie, documentboekhouding en latere cross-projectlinks ambigu +// worden; dezelfde duplicate-id-foutvorm als de X4a-identiteitswachten is hier verplicht. +const duplicateProjectTables = tables([ + row(2, { proj_id: 'P-DUP', proj_short_name: 'Eerste' }), + row(3, { proj_id: 'P-DUP', proj_short_name: 'Tweede' }), +], [ + row(8, { proj_id: 'P-DUP', task_id: 'D-1', task_code: 'D-1' }), +]); +const duplicateProjectCalls: string[] = []; +let duplicateProjectError: unknown; +try { + assembleXerMultiProjectImport(duplicateProjectTables, (projectId) => { + duplicateProjectCalls.push(projectId); + return mappedProject(projectId, 1); + }); +} catch (error) { + duplicateProjectError = error; +} +eq('4b dubbele PROJECT-identiteit faalt getypeerd voordat de mapper draait', { + error: duplicateProjectError instanceof XerImportError ? { + code: duplicateProjectError.xerCode, + table: duplicateProjectError.table, + field: duplicateProjectError.field, + line: duplicateProjectError.line, + lines: duplicateProjectError.lines, + } : null, + mapperCalls: duplicateProjectCalls, +}, { + error: { + code: 'XER_DUPLICATE_ID', + table: 'PROJECT', + field: 'proj_id', + line: 3, + lines: [2, 3], + }, + mapperCalls: [], +}); + +// Breuk die dit vangt: een ontbrekend baselineproject als bestaand behandelen, het hoofdproject +// overslaan of de verwijzing stil laten verdwijnen uit het importverslag. +const danglingTables = tables([ + row(2, { proj_id: 'P-MAIN', sum_base_proj_id: 'P-MISSING' }), + row(3, { proj_id: 'P-OTHER' }), +], [ + row(8, { proj_id: 'P-MAIN', task_id: 'M-1', task_code: 'M-1' }), + row(9, { proj_id: 'P-OTHER', task_id: 'O-1', task_code: 'O-1' }), +]); +const danglingMapped = new Map([ + ['P-MAIN', mappedProject('P-MAIN', 1)], + ['P-OTHER', mappedProject('P-OTHER', 1)], +]); +const dangling = assembleXerMultiProjectImport(danglingTables, (projectId) => { + const result = danglingMapped.get(projectId); + if (!result) throw new Error(`Onverwachte mapperaanroep voor ${projectId}`); + return result; +}); +eq('5 dangling baselineverwijzing wordt genegeerd en geteld', { + documents: dangling.documents.map(document => document.projectId), + dangling: dangling.report.danglingBaselineReferences, + excluded: dangling.report.baselineProjectsExcluded, + baselines: dangling.report.baselinesMaterialized, +}, { + documents: ['P-MAIN', 'P-OTHER'], + dangling: 1, + excluded: 0, + baselines: 0, +}); + +function identifiedTask( + projectId: string, + id: string, + code: string, + start: string, + finish: string, +): Task { + const value = task(projectId, 1); + return { + ...value, + id, + name: `Taak ${id}`, + wbsCode: code, + time: { + ...value.time, + scheduleStart: start, + scheduleFinish: finish, + earlyStart: start, + earlyFinish: finish, + }, + }; +} + +// Breuken die dit vangt: het aanwezige baselineproject als tweede document openen, alleen een naam +// bewaren, op array-index koppelen of een baseline-only taak stil weggooien. +const baselineTables = tables([ + row(2, { proj_id: 'P-MAIN', sum_base_proj_id: 'P-BASE' }), + row(3, { proj_id: 'P-BASE' }), +], [ + row(8, { proj_id: 'P-MAIN', task_id: 'M-SAME-ID', task_code: 'CODE-ID' }), + row(9, { proj_id: 'P-MAIN', task_id: 'M-CODE', task_code: 'CODE-MATCH' }), + row(10, { proj_id: 'P-MAIN', task_id: 'M-NEW', task_code: 'CODE-NEW' }), + row(11, { proj_id: 'P-BASE', task_id: 'M-SAME-ID', task_code: 'CODE-ID-OLD' }), + row(12, { proj_id: 'P-BASE', task_id: 'B-CODE', task_code: 'CODE-MATCH' }), + row(13, { proj_id: 'P-BASE', task_id: 'B-OLD', task_code: 'CODE-OLD' }), +]); +const mainResult = mappedProject('P-MAIN', 0); +mainResult.tasks = [ + identifiedTask('P-MAIN', 'M-SAME-ID', 'CODE-ID', '2026-05-01', '2026-05-02'), + identifiedTask('P-MAIN', 'M-CODE', 'CODE-MATCH', '2026-05-03', '2026-05-04'), + identifiedTask('P-MAIN', 'M-NEW', 'CODE-NEW', '2026-05-05', '2026-05-06'), +]; +const baselineResult = mappedProject('P-BASE', 0); +baselineResult.tasks = [ + identifiedTask('P-BASE', 'M-SAME-ID', 'CODE-ID-OLD', '2026-04-01', '2026-04-02'), + identifiedTask('P-BASE', 'B-CODE', 'CODE-MATCH', '2026-04-03', '2026-04-04'), + identifiedTask('P-BASE', 'B-OLD', 'CODE-OLD', '2026-04-05', '2026-04-06'), +]; +const baselineMapped = new Map([ + ['P-MAIN', mainResult], + ['P-BASE', baselineResult], +]); +const baselineImport = assembleXerMultiProjectImport(baselineTables, (projectId) => { + const result = baselineMapped.get(projectId); + if (!result) throw new Error(`Onverwachte mapperaanroep voor ${projectId}`); + return result; +}); +const importedBaseline = baselineImport.documents[0]?.result.baselines?.[0]; +eq('6 aanwezig baselineproject wordt één echte actieve OPS-baseline, geen document', { + documents: baselineImport.documents.map(document => document.projectId), + baselineId: importedBaseline?.id, + activeBaselineId: baselineImport.documents[0]?.result.activeBaselineId, + name: importedBaseline?.name, + excluded: baselineImport.report.baselineProjectsExcluded, + materialized: baselineImport.report.baselinesMaterialized, +}, { + documents: ['P-MAIN'], + baselineId: 'xer-baseline:P-MAIN:P-BASE', + activeBaselineId: 'xer-baseline:P-MAIN:P-BASE', + name: 'Project P-BASE', + excluded: 1, + materialized: 1, +}); +eq('7 baseline koppelt id/code onafhankelijk en bewaart een baseline-only taak', + importedBaseline?.tasks.map(item => ({ + taskId: item.taskId, + sourceTaskId: item.sourceTaskId, + sourceTaskCode: item.sourceTaskCode, + start: item.start, + finish: item.finish, + })), [ + { taskId: 'M-SAME-ID', sourceTaskId: 'M-SAME-ID', sourceTaskCode: 'CODE-ID-OLD', start: '2026-04-01', finish: '2026-04-02' }, + { taskId: 'M-CODE', sourceTaskId: 'B-CODE', sourceTaskCode: 'CODE-MATCH', start: '2026-04-03', finish: '2026-04-04' }, + { taskId: 'B-OLD', sourceTaskId: 'B-OLD', sourceTaskCode: 'CODE-OLD', start: '2026-04-05', finish: '2026-04-06' }, + ]); +eq('7a baseline bewaart de uitgesloten bronprojectidentiteit naast de ownerkoppeling', + importedBaseline?.sourceProjectId, 'P-BASE'); +eq('7b baseline-materialisatie muteert de geïnjecteerde X4a-resultaten niet', { + baselines: mainResult.baselines, + activeBaselineId: mainResult.activeBaselineId, +}, { baselines: undefined, activeBaselineId: undefined }); +const syntheticBaselinePayload = payloadFromImport(baselineImport.documents[0].result, null); +const syntheticVariance = computeVariance( + syntheticBaselinePayload.tasks, + syntheticBaselinePayload.baselines.find( + item => item.id === syntheticBaselinePayload.activeBaselineId, + ) ?? null, + new CalendarEngine(syntheticBaselinePayload.calendar), + '2026-05-06', +); +eq('7b1 synthetische baseline werkt direct via documentcontract en normaal variancepad', { + baselines: syntheticBaselinePayload.baselines.length, + active: syntheticBaselinePayload.activeBaselineId, + rows: syntheticVariance.rows.map(item => [item.taskId, item.status]), +}, { + baselines: 1, + active: 'xer-baseline:P-MAIN:P-BASE', + rows: [ + ['M-SAME-ID', 'late'], + ['M-CODE', 'late'], + ['M-NEW', 'new'], + ['B-OLD', 'dropped'], + ], +}); +const syntheticRoundTrip = readIFC(writeIFC(baselineImport.documents[0].result)); +const roundTrippedTaskByCode = new Map( + syntheticRoundTrip.tasks.map(item => [item.wbsCode, item.id]), +); +const roundTrippedBaselineTasks = syntheticRoundTrip.baselines?.[0]?.tasks ?? []; +const roundTrippedPayload = payloadFromImport(syntheticRoundTrip, null); +const roundTrippedVariance = computeVariance( + roundTrippedPayload.tasks, + roundTrippedPayload.baselines.find( + item => item.id === roundTrippedPayload.activeBaselineId, + ) ?? null, + new CalendarEngine(roundTrippedPayload.calendar), + '2026-05-06', +); +eq('7b2 synthetische baseline round-tript met identificatie en datums door IFC', { + active: syntheticRoundTrip.activeBaselineId, + currentTaskLinks: [ + roundTrippedBaselineTasks[0]?.taskId === roundTrippedTaskByCode.get('CODE-ID'), + roundTrippedBaselineTasks[1]?.taskId === roundTrippedTaskByCode.get('CODE-MATCH'), + ], + baselineOnlyTaskId: roundTrippedBaselineTasks[2]?.taskId, + dates: roundTrippedBaselineTasks.map(item => ({ + start: item.start, + finish: item.finish, + })), + variance: roundTrippedVariance.rows.map(item => item.status), +}, { + active: 'xer-baseline:P-MAIN:P-BASE', + currentTaskLinks: [true, true], + baselineOnlyTaskId: 'B-OLD', + dates: [ + { start: '2026-04-01', finish: '2026-04-02' }, + { start: '2026-04-03', finish: '2026-04-04' }, + { start: '2026-04-05', finish: '2026-04-06' }, + ], + variance: ['late', 'late', 'new', 'dropped'], +}); + +// Breuk die dit vangt: een aanwezige baseline-PROJECT-rij zonder TASK-rij stil als dangling +// behandelen of hem via de X4a-mapper alsnog als leeg project proberen te openen. +const emptyBaselineTables = tables([ + row(2, { proj_id: 'P-MAIN', sum_base_proj_id: 'P-EMPTY-BASE' }), + row(3, { proj_id: 'P-EMPTY-BASE', proj_short_name: 'Lege baseline' }), +], [ + row(8, { proj_id: 'P-MAIN', task_id: 'M-1', task_code: 'M-1' }), +]); +const emptyBaselineCalls: string[] = []; +const emptyBaselineImport = assembleXerMultiProjectImport(emptyBaselineTables, (projectId) => { + emptyBaselineCalls.push(projectId); + if (projectId !== 'P-MAIN') throw new Error(`Onverwachte mapperaanroep voor ${projectId}`); + return mappedProject(projectId, 1); +}); +eq('7c aanwezige lege baseline wordt gematerialiseerd zonder lege tab of mapperaanroep', { + documents: emptyBaselineImport.documents.map(document => document.projectId), + mapperCalls: emptyBaselineCalls, + emptySkipped: emptyBaselineImport.report.emptyProjectsSkipped, + dangling: emptyBaselineImport.report.danglingBaselineReferences, + materialized: emptyBaselineImport.report.baselinesMaterialized, + baselineName: emptyBaselineImport.documents[0]?.result.baselines?.[0]?.name, + baselineTasks: emptyBaselineImport.documents[0]?.result.baselines?.[0]?.tasks, +}, { + documents: ['P-MAIN'], + mapperCalls: ['P-MAIN'], + emptySkipped: 1, + dangling: 0, + materialized: 1, + baselineName: 'Lege baseline', + baselineTasks: [], +}); + +/** + * Synthetische vangrailfixtures. Zonder het terugnemen van baseline-uitsluiting wordt SELF door + * zijn eigen verwijzing uitgesloten en sluiten A/B elkaar in de cyclus wederzijds uit: beide + * fixtures zouden dan nul documenten opleveren. De extra C in de cycluscase bewijst bovendien dat + * een echte cyclus de uitsluiting voor het hele bestand terugneemt, niet alleen bij exact nul. + */ +function guardFixture(kind: 'self' | 'cycle'): { + tables: XerTables; + mapped: Map; +} { + if (kind === 'self') { + return { + tables: tables([ + row(2, { proj_id: 'P-SELF', sum_base_proj_id: 'P-SELF' }), + ], [ + row(8, { proj_id: 'P-SELF', task_id: 'S-1', task_code: 'S-1' }), + ]), + mapped: new Map([['P-SELF', mappedProject('P-SELF', 1)]]), + }; + } + return { + tables: tables([ + row(2, { proj_id: 'P-A', sum_base_proj_id: 'P-B' }), + row(3, { proj_id: 'P-B', sum_base_proj_id: 'P-A' }), + row(4, { proj_id: 'P-C' }), + ], [ + row(8, { proj_id: 'P-A', task_id: 'A-1', task_code: 'A-1' }), + row(9, { proj_id: 'P-B', task_id: 'B-1', task_code: 'B-1' }), + row(10, { proj_id: 'P-C', task_id: 'C-1', task_code: 'C-1' }), + ]), + mapped: new Map([ + ['P-A', mappedProject('P-A', 1)], + ['P-B', mappedProject('P-B', 1)], + ['P-C', mappedProject('P-C', 1)], + ]), + }; +} + +function assembleGuardFixture(kind: 'self' | 'cycle') { + const fixture = guardFixture(kind); + return assembleXerMultiProjectImport(fixture.tables, (projectId) => { + const result = fixture.mapped.get(projectId); + if (!result) throw new Error(`Onverwachte mapperaanroep voor ${projectId}`); + return result; + }); +} + +const selfGuard = assembleGuardFixture('self'); +eq('8 zelfverwijzing neemt baseline-uitsluiting terug en opent nooit nul documenten', { + documents: selfGuard.documents.map(document => document.projectId), + baselines: selfGuard.report.baselinesMaterialized, + reverted: selfGuard.report.baselineExclusionReverted, + reasons: selfGuard.report.baselineFallbackReasons, +}, { + documents: ['P-SELF'], + baselines: 0, + reverted: true, + reasons: ['self-reference', 'all-projects-baselines'], +}); + +const cycleGuard = assembleGuardFixture('cycle'); +eq('9 cyclus neemt uitsluiting voor het hele bestand terug, ook als C open zou blijven', { + documents: cycleGuard.documents.map(document => document.projectId), + baselines: cycleGuard.report.baselinesMaterialized, + reverted: cycleGuard.report.baselineExclusionReverted, + reasons: cycleGuard.report.baselineFallbackReasons, +}, { + documents: ['P-A', 'P-B', 'P-C'], + baselines: 0, + reverted: true, + reasons: ['cycle'], +}); + +// Reviewronde 1, P1: een PROJECT-rij zonder taken wordt niet geopend en kan daarom ook geen +// baseline-eigenaar zijn. Zijn sum_base_proj_id mag een niet-leeg bronproject niet uit de openlijst +// drukken. Het derde, niet-lege project voorkomt dat de algemene alles-uitgesloten-vangrail deze +// fout maskeert: de oude implementatie opende alleen P-THIRD en verloor P-BASE volledig. +const emptyOwnerTables = tables([ + row(2, { proj_id: 'P-BASE' }), + row(3, { proj_id: 'P-EMPTY-OWNER', sum_base_proj_id: 'P-BASE' }), + row(4, { proj_id: 'P-THIRD' }), +], [ + row(8, { proj_id: 'P-BASE', task_id: 'B-1', task_code: 'B-1' }), + row(9, { proj_id: 'P-THIRD', task_id: 'T-1', task_code: 'T-1' }), +]); +const emptyOwnerImport = assembleXerMultiProjectImport( + emptyOwnerTables, + projectId => mappedProject(projectId, 1), +); +eq('9a lege baseline-eigenaar sluit een niet-leeg bronproject nooit uit', { + documents: emptyOwnerImport.documents.map(document => document.projectId), + empty: emptyOwnerImport.report.emptyProjectsSkipped, + excluded: emptyOwnerImport.report.baselineProjectsExcluded, + baselines: emptyOwnerImport.report.baselinesMaterialized, + reverted: emptyOwnerImport.report.baselineExclusionReverted, + reasons: emptyOwnerImport.report.baselineFallbackReasons, +}, { + documents: ['P-BASE', 'P-THIRD'], + empty: 1, + excluded: 0, + baselines: 0, + reverted: false, + reasons: [], +}); + +// Breuk die dit vangt: de baselinegraaf recursief aflopen. Een groot maar geldig bestand met een +// lange acyclische keten mag niet op de JavaScript-callstack stuklopen voordat selectie begint. +const deepProjectCount = 12_000; +const deepProjectRows = Array.from({ length: deepProjectCount }, (_, index) => row( + index + 2, + { + proj_id: `P-DEEP-${index}`, + ...(index + 1 < deepProjectCount + ? { sum_base_proj_id: `P-DEEP-${index + 1}` } + : {}), + }, +)); +const deepTables = tables(deepProjectRows, [ + row(deepProjectCount + 3, { + proj_id: 'P-DEEP-0', task_id: 'DEEP-1', task_code: 'DEEP-1', + }), +]); +let deepImport: ReturnType | undefined; +let deepError: unknown; +try { + deepImport = assembleXerMultiProjectImport( + deepTables, + projectId => mappedProject(projectId, 1), + ); +} catch (error) { + deepError = error; +} +eq('9b diepe acyclische baselineketen blijft stackveilig en opent het hoofdproject', { + error: deepError instanceof Error ? deepError.name : deepError ?? null, + documents: deepImport?.documents.map(document => document.projectId), + baselines: deepImport?.report.baselinesMaterialized, + reverted: deepImport?.report.baselineExclusionReverted, +}, { + error: null, + documents: ['P-DEEP-0'], + baselines: 1, + reverted: false, +}); + +// Breuken die dit vangt: beide lokale perspectieven als twee links tellen, een cross-projectrand +// als lokale Sequence toevoegen of hem als Task.externalLinks alsnog de solver in laten lekken. +const linkTables = tables([ + row(2, { proj_id: 'P-LINK-A' }), + row(3, { proj_id: 'P-LINK-B' }), +], [ + row(8, { proj_id: 'P-LINK-A', task_id: 'LA', task_code: 'LA' }), + row(9, { proj_id: 'P-LINK-B', task_id: 'LB', task_code: 'LB' }), +]); +const linkA = mappedProject('P-LINK-A', 0); +linkA.tasks = [identifiedTask('P-LINK-A', 'LA', 'LA', '2026-01-01', '2026-01-10')]; +linkA.xer.externalRelations = [{ + id: 'R-CROSS', + localProjectId: 'P-LINK-A', + localTaskId: 'LA', + externalProjectId: 'P-LINK-B', + externalTaskId: 'LB', + direction: 'successor', + type: 'FS', + lagMinutes: 0, +}]; +const linkB = mappedProject('P-LINK-B', 0); +linkB.tasks = [identifiedTask('P-LINK-B', 'LB', 'LB', '2026-01-02', '2026-01-03')]; +linkB.xer.externalRelations = [{ + id: 'R-CROSS', + localProjectId: 'P-LINK-B', + localTaskId: 'LB', + externalProjectId: 'P-LINK-A', + externalTaskId: 'LA', + direction: 'predecessor', + type: 'FS', + lagMinutes: 0, +}]; +const linkMapped = new Map([ + ['P-LINK-A', linkA], + ['P-LINK-B', linkB], +]); +const linkImport = assembleXerMultiProjectImport(linkTables, (projectId) => { + const result = linkMapped.get(projectId); + if (!result) throw new Error(`Onverwachte mapperaanroep voor ${projectId}`); + return result; +}); +eq('10 twee lokale perspectieven worden één solverloze externalLink tussen documenten', { + links: linkImport.externalLinks, + reportCount: linkImport.report.externalLinksPreserved, + sequences: linkImport.documents.map(document => document.result.sequences.length), + taskLinks: linkImport.documents.map(document => + document.result.tasks.flatMap(item => item.externalLinks ?? []).length), +}, { + links: [{ + id: 'R-CROSS', + predecessor: { projectId: 'P-LINK-A', taskId: 'LA' }, + successor: { projectId: 'P-LINK-B', taskId: 'LB' }, + type: 'FS', + lagMinutes: 0, + }], + reportCount: 1, + sequences: [0, 0], + taskLinks: [0, 0], +}); +eq('10a iedere betrokken documentpayload draagt exact dezelfde gededupliceerde bronlink', + linkImport.documents.map(document => ({ + projectId: document.projectId, + links: document.result.xer.externalLinks, + })), [ + { projectId: 'P-LINK-A', links: linkImport.externalLinks }, + { projectId: 'P-LINK-B', links: linkImport.externalLinks }, + ]); +const solvedLinkB = solveProject({ + tasks: linkImport.documents[1].result.tasks.map(item => ({ ...item, time: { ...item.time } })), + sequences: linkImport.documents[1].result.sequences, + calendar: linkImport.documents[1].result.calendar, + calendars: linkImport.documents[1].result.resourceCalendars ?? [], +}); +eq('11 cross-projectlink stuurt de losse documentsolve niet', + solvedLinkB.tasks.get('LB')?.earlyStart, '2026-01-02'); + +// Breuk die dit vangt: mapperresultaten rechtstreeks doorgeven of één gedeelde array/object na +// payloadFromImport tussen twee geopende documenten laten bestaan. +const isolationTables = tables([ + row(2, { proj_id: 'P-ISO-A' }), + row(3, { proj_id: 'P-ISO-B' }), +], [ + row(8, { proj_id: 'P-ISO-A', task_id: 'IA', task_code: 'IA' }), + row(9, { proj_id: 'P-ISO-B', task_id: 'IB', task_code: 'IB' }), +]); +const isolationA = mappedProject('P-ISO-A', 1); +const isolationB = mappedProject('P-ISO-B', 1); +const sharedResources = [{ + id: 'R-SHARED', name: 'Gedeelde invoer', type: 'LABOR' as const, description: '', maxUnits: 1, +}]; +isolationA.resources = sharedResources; +isolationB.resources = sharedResources; +const isolationMapped = new Map([ + ['P-ISO-A', isolationA], + ['P-ISO-B', isolationB], +]); +const isolationImport = assembleXerMultiProjectImport(isolationTables, (projectId) => { + const result = isolationMapped.get(projectId); + if (!result) throw new Error(`Onverwachte mapperaanroep voor ${projectId}`); + return result; +}); +const isolationDocA = isolationImport.documents[0].result; +const isolationDocB = isolationImport.documents[1].result; +const isolationPayloadA = payloadFromImport(isolationDocA, null); +const isolationPayloadB = payloadFromImport(isolationDocB, null); +isolationPayloadA.tasks[0].name = 'Alleen A gewijzigd'; +isolationPayloadA.resources[0].name = 'Alleen A-resource'; +eq('12 elk documentresultaat/payload is losstaand van bron en buurdocument', { + resultDetached: isolationDocA !== isolationA, + tasksDetached: isolationDocA.tasks !== isolationA.tasks, + sharedInputSplit: isolationDocA.resources !== isolationDocB.resources, + sourceTaskName: isolationA.tasks[0].name, + sourceResourceName: sharedResources[0].name, + otherTaskName: isolationPayloadB.tasks[0].name, + otherResourceName: isolationPayloadB.resources[0].name, +}, { + resultDetached: true, + tasksDetached: true, + sharedInputSplit: true, + sourceTaskName: 'P-ISO-A-T1', + sourceResourceName: 'Gedeelde invoer', + otherTaskName: 'P-ISO-B-T1', + otherResourceName: 'Gedeelde invoer', +}); + +interface CorpusManifest { + files: Record; +} + +function corpusBytes(hash: string): Uint8Array | null { + const root = process.env.OPS_XER_CORPUS; + if (!root) return null; + if (!existsSync(root)) { + diffs.push('OPS_XER_CORPUS wijst niet naar een bestaande openbare corpusmap'); + return null; + } + const manifest = JSON.parse( + readFileSync(join(import.meta.dirname, 'xer-corpus-manifest.json'), 'utf8'), + ) as CorpusManifest; + const relativePath = Object.entries(manifest.files) + .find(([, entry]) => entry.sha256 === hash)?.[0]; + if (!relativePath) { + diffs.push(`openbare XER-hash ${hash.slice(0, 16)} ontbreekt in het manifest`); + return null; + } + try { + const bytes = new Uint8Array(readFileSync(join(root, relativePath))); + eq(`openbare hash ${hash.slice(0, 16)} is byte-identiek`, + createHash('sha256').update(bytes).digest('hex'), hash); + return bytes; + } catch { + diffs.push(`openbare XER-hash ${hash.slice(0, 16)} kon niet worden gelezen`); + return null; + } +} + +function normalizeSourceDate(raw: string | undefined, fallback: string): string { + const value = raw?.trim(); + return value ? value.replace(' ', 'T') : fallback; +} + +/** Testadapter voor het seriële wiring-venster: minimale X4a-vorm uit echte X2-tabellen. */ +function mapCorpusProject(source: XerTables, projectId: string): XerReadResult { + const projectRow = source.tables.get('PROJECT')?.rows + .find(item => item.cells.proj_id === projectId); + if (!projectRow) throw new Error('Openbare testadapter mist een gevraagde projectrij'); + const taskRows = (source.tables.get('TASK')?.rows ?? []) + .filter(item => item.cells.proj_id === projectId); + const fallbackStart = normalizeSourceDate(projectRow.cells.last_recalc_date, '1970-01-01'); + const tasks = taskRows.map((item, index): Task => { + const start = normalizeSourceDate(item.cells.target_start_date, fallbackStart); + const finish = normalizeSourceDate(item.cells.target_end_date, start); + const durationHours = parseXerNumber( + item.cells.target_drtn_hr_cnt ?? '', source.numberFormat, + ) ?? 0; + const mapped = task(projectId, index + 1); + return { + ...mapped, + id: item.cells.task_id, + name: item.cells.task_name || item.cells.task_code, + wbsCode: item.cells.task_code, + time: { + ...mapped.time, + scheduleDuration: durationHours / 8, + scheduleStart: start, + scheduleFinish: finish, + earlyStart: start, + earlyFinish: finish, + lateStart: start, + lateFinish: finish, + }, + }; + }); + const starts = tasks.map(item => item.time.scheduleStart).sort(); + const finishes = tasks.map(item => item.time.scheduleFinish).sort(); + const calendar = { ...createDefaultCalendar(), id: projectRow.cells.clndr_id || `${projectId}-cal` }; + return { + project: { + id: projectId, + name: projectRow.cells.proj_short_name || projectId, + description: '', + startDate: starts[0] ?? fallbackStart, + endDate: finishes[finishes.length - 1] ?? fallbackStart, + calendarId: calendar.id, + createdAt: fallbackStart, + modifiedAt: fallbackStart, + author: '', + company: '', + progressMode: XER_SCHEDULING_DEFAULTS.progressMode, + schedulingOptions: structuredClone(XER_SCHEDULING_DEFAULTS.schedulingOptions), + }, + calendar, + tasks, + sequences: [], + resources: [], + assignments: [], + xer: { + defaultCurrencyCode: source.header.defaultCurrencyCode, + tableReport: source.report, + calendarIssues: [], + enumFallbacks: [], + scheduleOptions: { + source: 'xer-defaults', retainedSource: {}, fallbacks: [], diagnostics: [], + sourceArchive: { rows: [], unmatchedScheduleOptionsRowIndexes: [], diagnostics: [] }, + sourceRowIndexes: [], sourceRows: [], + }, + externalRelations: [], + externalLinks: [], + report: { + projectsSeen: 1, + documentsOpened: 1, + emptyProjectsSkipped: 0, + baselineProjectsExcluded: 0, + baselinesMaterialized: 0, + danglingBaselineReferences: 0, + externalLinksPreserved: 0, + baselineExclusionReverted: false, + baselineFallbackReasons: [], + }, + }, + }; +} + +const multiProjectHash = '2bc12241c3f8ee5b7472dd0e77f2cbffafcf3b5438b17022fd9db4f4c642d4b0'; +const multiProjectBytes = corpusBytes(multiProjectHash); +if (multiProjectBytes) { + const heapBefore = process.memoryUsage().heapUsed; + const started = performance.now(); + const corpusTables = parseXerTables(multiProjectBytes); + const corpusImport = assembleXerMultiProjectImport( + corpusTables, + projectId => mapCorpusProject(corpusTables, projectId), + ); + const elapsedMs = performance.now() - started; + const heapDeltaBytes = process.memoryUsage().heapUsed - heapBefore; + eq('13 openbare 15-projectenhash opent 12 documenten en telt lege/dangling rijen', { + projects: corpusImport.report.projectsSeen, + documents: corpusImport.report.documentsOpened, + empty: corpusImport.report.emptyProjectsSkipped, + dangling: corpusImport.report.danglingBaselineReferences, + excluded: corpusImport.report.baselineProjectsExcluded, + }, { + projects: 15, + documents: 12, + empty: 3, + dangling: 9, + excluded: 0, + }); + const leafCounts = corpusImport.documents.map(document => document.result.tasks.length); + const activeLeafCount = corpusImport.documents + .find(document => document.projectId === corpusImport.activeProjectId)?.result.tasks.length; + eq('14 openbare actieve selectie heeft de meeste bladtaken onder geopende documenten', + activeLeafCount, Math.max(...leafCounts)); + eq('15 openbare kernmeting levert eindige tijd en heapdelta', { + elapsedFinite: Number.isFinite(elapsedMs), + heapFinite: Number.isFinite(heapDeltaBytes), + }, { elapsedFinite: true, heapFinite: true }); + console.log( + `. X4b-kernmeting hash=${multiProjectHash.slice(0, 16)} documenten=${corpusImport.documents.length} ` + + `elapsedMs=${elapsedMs.toFixed(1)} heapDeltaBytes=${heapDeltaBytes}`, + ); +} else { + console.log('OK XER-multi-project: openbare 15-projectenpin overgeslagen (OPS_XER_CORPUS)'); +} + +const baselineCorpusHash = '1ba69d297ee9a5c65c644e7373dc414aa334c996b74800c4688aea7e3681eebf'; +const baselineCorpusBytes = corpusBytes(baselineCorpusHash); +if (baselineCorpusBytes) { + const corpusTables = parseXerTables(baselineCorpusBytes); + const corpusImport = assembleXerMultiProjectImport( + corpusTables, + projectId => mapCorpusProject(corpusTables, projectId), + ); + const document = corpusImport.documents[0]?.result; + const baseline = document?.baselines?.[0]; + const currentIds = new Set(document?.tasks.map(item => item.id) ?? []); + eq('16 openbare baselinehash levert één document met één actieve echte baseline', { + documents: corpusImport.documents.length, + baselines: document?.baselines?.length, + active: document?.activeBaselineId === baseline?.id, + baselineTasks: baseline?.tasks.length, + matchedTasks: baseline?.tasks.filter(item => currentIds.has(item.taskId)).length, + baselineOnlyTasks: baseline?.tasks.filter(item => !currentIds.has(item.taskId)).length, + }, { + documents: 1, + baselines: 1, + active: true, + baselineTasks: 4, + matchedTasks: 3, + baselineOnlyTasks: 1, + }); + if (document && baseline) { + const payload = payloadFromImport(document, null); + const variance = computeVariance( + payload.tasks, + payload.baselines.find(item => item.id === payload.activeBaselineId) ?? null, + new CalendarEngine(payload.calendar), + payload.project.endDate, + ); + eq('17 openbare baseline werkt via normaal documentcontract en variancepad', { + payloadBaselines: payload.baselines.length, + active: payload.activeBaselineId === baseline.id, + rows: variance.rows.length, + newRows: variance.rows.filter(item => item.status === 'new').length, + droppedRows: variance.rows.filter(item => item.status === 'dropped').length, + }, { + payloadBaselines: 1, + active: true, + rows: 5, + newRows: 1, + droppedRows: 1, + }); + const roundTripped = readIFC(writeIFC(document)); + eq('18 openbare baseline round-tript door het bestaande IFC-contract', { + baselines: roundTripped.baselines?.length, + active: roundTripped.activeBaselineId === baseline.id, + tasks: roundTripped.baselines?.[0]?.tasks.length, + datesFilled: roundTripped.baselines?.[0]?.tasks.every(item => !!item.start && !!item.finish), + }, { + baselines: 1, + active: true, + tasks: 4, + datesFilled: true, + }); + } +} else { + console.log('OK XER-multi-project: openbare baselinepin overgeslagen (OPS_XER_CORPUS)'); +} + +if (diffs.length > 0) { + console.error(`XER-multi-project: ${diffs.length}/${checks} checks rood`); + for (const diff of diffs) console.error(`XX ${diff}`); + process.exit(1); +} +console.log(`OK XER-multi-project: ${checks} selectiechecks groen`); diff --git a/tests/planning/check-xer-open-loe-target-span.ts b/tests/planning/check-xer-open-loe-target-span.ts new file mode 100644 index 000000000..1c26c4c0c --- /dev/null +++ b/tests/planning/check-xer-open-loe-target-span.ts @@ -0,0 +1,298 @@ +import { solveProject } from '@/engine/scheduler/solveProject'; +import { CalendarEngine } from '@/engine/scheduler/CalendarEngine'; +import { explainOpenXerLoeTargetSpanEligibility } from '@/engine/scheduler/p6OpenLoeTargetSpanTrace'; +import { isMultiDocumentImport, type ImportResult } from '@/services/importTypes'; +import { readIFCWithXerReconstruction } from '@/services/formatRegistry'; +import { readIFC } from '@/services/ifc/ifcReader'; +import { writeIFC } from '@/services/ifc/ifcWriter'; +import { readXER } from '@/services/xer/xerReader'; +import type { Task } from '@/types/task'; +import { parseInstant } from '@/utils/dateUtils'; + +const diffs: string[] = []; +let checks = 0; + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +const workWeek = '(0||CalendarData()( (0||DaysOfWeek()( (0||1()( (0||0(s|08:00|f|12:00)()) (0||1(s|13:00|f|17:00)()))) (0||2()( (0||0(s|08:00|f|12:00)()) (0||1(s|13:00|f|17:00)()))) (0||3()( (0||0(s|08:00|f|12:00)()) (0||1(s|13:00|f|17:00)()))) (0||4()( (0||0(s|08:00|f|12:00)()) (0||1(s|13:00|f|17:00)()))) (0||5()( (0||0(s|08:00|f|12:00)()) (0||1(s|13:00|f|17:00)()))) (0||6()()) (0||7()()))) (0||Exceptions()())))'; + +/** Kleine raw-XER-vorm uit Ashspace: SS naar open LOE, FF naar opvolger, geen actuals. */ +interface FixtureOptions { + targetStart?: string; + targetEnd?: string; + rawYear?: string; + outgoingType?: string; + outgoingLag?: string; + includeOutgoing?: boolean; + dayMode?: boolean; +} + +function fixtureBytes(options: FixtureOptions = {}): Uint8Array { + const dayMode = options.dayMode ?? false; + const targetStart = options.targetStart ?? (dayMode ? '2026-01-05' : '2026-01-05 08:00'); + const targetEnd = options.targetEnd ?? (dayMode ? '2026-01-16' : '2026-01-16 17:00'); + const rawYear = options.rawYear ?? '2099'; + const outgoingType = options.outgoingType ?? 'PR_FF'; + const outgoingLag = options.outgoingLag ?? '0'; + const includeOutgoing = options.includeOutgoing ?? true; + const predecessorStart = dayMode ? '2026-01-05' : '2026-01-05 08:00'; + const predecessorEnd = dayMode ? '2026-01-05' : '2026-01-05 17:00'; + const successorStart = dayMode ? targetEnd : targetEnd.replace('17:00', '08:00'); + const calendarData = dayMode ? '' : workWeek; + return new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-09-01\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tproj_id\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + `%R\tC1\tWerkweek\tP1\tCA_Project\t8\t40\t${calendarData}`, + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date\tplan_end_date', + '%R\tP1\tOpen LOE targetspan\tC1\t2025-12-31 17:00\t2026-01-05 08:00\t2026-01-30 17:00', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\tcomplete_pct_type\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date\tact_start_date\tact_end_date\tearly_start_date\tearly_end_date\tlate_start_date\tlate_end_date\ttotal_float_hr_cnt\tfree_float_hr_cnt\trestart_date\treend_date\tdriving_path_flag', + `%R\tP\tP1\tC1\tPRED\tVoorganger\tTT_Task\tDT_FixedDUR2\tTK_NotStart\tCP_Drtn\t8\t8\t${predecessorStart}\t${predecessorEnd}\t\t\t${rawYear}-01-01 00:00\t${rawYear}-01-01 17:00\t${rawYear}-01-01 00:00\t${rawYear}-01-01 17:00\t999\t888\t${rawYear}-01-01 00:00\t${rawYear}-01-01 17:00\tN`, + `%R\tL\tP1\tC1\tLOE\tOpen LOE\tTT_LOE\tDT_FixedDUR2\tTK_NotStart\tCP_Drtn\t80\t80\t${targetStart}\t${targetEnd}\t\t\t${rawYear}-02-01 00:00\t${rawYear}-02-01 17:00\t${rawYear}-02-01 00:00\t${rawYear}-02-01 17:00\t777\t666\t${rawYear}-02-01 00:00\t${rawYear}-02-01 17:00\tY`, + `%R\tS\tP1\tC1\tSUCC\tOpvolger\tTT_Task\tDT_FixedDUR2\tTK_NotStart\tCP_Drtn\t8\t8\t${successorStart}\t${targetEnd}\t\t\t${rawYear}-03-01 00:00\t${rawYear}-03-01 17:00\t${rawYear}-03-01 00:00\t${rawYear}-03-01 17:00\t555\t444\t${rawYear}-03-01 00:00\t${rawYear}-03-01 17:00\tN`, + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%R\tR-SS\tL\tP\tP1\tP1\tPR_SS\t0', + ...(includeOutgoing ? [`%R\tR-FF\tS\tL\tP1\tP1\t${outgoingType}\t${outgoingLag}`] : []), + '%E', + ].join('\n')); +} + +function importedFixture(options?: FixtureOptions): ImportResult { + const opened = readXER(fixtureBytes(options)); + if (isMultiDocumentImport(opened)) throw new Error('open-LOE-fixture moet één project openen'); + return opened; +} + +function task(imported: ImportResult): Task { + const found = imported.tasks.find(candidate => candidate.id === 'L' || candidate.p6TaskId === 'L' || candidate.wbsCode === 'LOE'); + if (!found) throw new Error('open-LOE-fixture mist taak L'); + return found; +} + +const imported = importedFixture(); +const loe = task(imported); +eq('open XER LOE: raw fixture levert uitsluitend toegestane provenance', { + source: imported.project.schedulingOptions?.p6Source, + activity: loe.p6ActivityType, + completePct: loe.p6CompletePctType, + duration: loe.p6DurationType, + targetWindow: loe.p6ExplicitTargetWindow, + status: loe.status, + actualStart: loe.time.actualStart, + actualFinish: loe.time.actualFinish, +}, { + source: 'XER', activity: 'TT_LOE', completePct: 'CP_Drtn', duration: 'DT_FixedDUR2', + targetWindow: true, status: 'NOT_STARTED', actualStart: undefined, actualFinish: undefined, +}); +const calendar = new CalendarEngine(imported.calendar); +eq('open XER LOE: targetvenster en bronduur gebruiken dezelfde positieve werkminuten', { + durationMinutes: loe.time.durationMinutes, + scheduleDuration: loe.time.scheduleDuration, + targetWindowMinutes: calendar.workMinutesBetween( + parseInstant(loe.time.scheduleStart), + parseInstant(loe.time.scheduleFinish), + ), +}, { durationMinutes: 80 * 60, scheduleDuration: 10, targetWindowMinutes: 72 * 60 }); +eq('open XER LOE: de pure diagnose accepteert de positieve raw-vorm', + explainOpenXerLoeTargetSpanEligibility( + loe, + imported.project.schedulingOptions, + imported.sequences.filter(sequence => sequence.successorId === loe.id), + imported.sequences.filter(sequence => sequence.predecessorId === loe.id), + parseInstant(loe.time.scheduleStart), + calendar.workMinutesBetween(parseInstant(loe.time.scheduleStart), parseInstant(loe.time.scheduleFinish)), + calendar.hoursPerDay * 60, + ), + { eligible: true, reason: 'eligible' }, +); + +const result = solveProject({ + tasks: imported.tasks, + sequences: imported.sequences, + calendar: imported.calendar, + calendars: imported.resourceCalendars ?? [], + dataDate: imported.project.statusDate, + progressMode: imported.project.progressMode, + schedulingOptions: imported.project.schedulingOptions, + projectStartDate: imported.project.startDate, + projectEndDate: imported.project.endDate, +}); +if (result.error) throw new Error(result.error); +const solved = result.tasks.get(loe.id); +if (!solved) throw new Error('solver mist open LOE'); + +// Dit is bewust de initiële rode causale eis: de uitgaande FF is geen eigen finish-driver in de +// generieke hammockroute, dus alleen een geverifieerde XER-targetspanroute mag dit doel halen. +eq('open XER LOE: expliciet targetvenster bepaalt de eigen span', { + es: solved.earlyStart, ef: solved.earlyFinish, + ls: solved.lateStart, lf: solved.lateFinish, + tf: solved.totalFloat, ff: solved.freeFloat, +}, { + es: '2026-01-05T08:00', ef: '2026-01-16T17:00', + ls: '2026-01-05T08:00', lf: '2026-01-16T17:00', + tf: 0, ff: 0, +}); + +function diagnose(input: ImportResult) { + const candidate = task(input); + const candidateCalendar = new CalendarEngine(input.calendar); + return explainOpenXerLoeTargetSpanEligibility( + candidate, + input.project.schedulingOptions, + input.sequences.filter(sequence => sequence.successorId === candidate.id), + input.sequences.filter(sequence => sequence.predecessorId === candidate.id), + parseInstant('2026-01-05T08:00'), + candidate.time.scheduleStart && candidate.time.scheduleFinish + ? candidateCalendar.workMinutesBetween(parseInstant(candidate.time.scheduleStart), parseInstant(candidate.time.scheduleFinish)) + : Number.NaN, + candidateCalendar.hoursPerDay * 60, + ); +} + +function axes(input: ImportResult) { + const candidate = task(input); + const solvedResult = solveProject({ + tasks: input.tasks, sequences: input.sequences, calendar: input.calendar, + calendars: input.resourceCalendars ?? [], dataDate: input.project.statusDate, + progressMode: input.project.progressMode, schedulingOptions: input.project.schedulingOptions, + projectStartDate: input.project.startDate, projectEndDate: input.project.endDate, + }); + if (solvedResult.error) throw new Error(solvedResult.error); + const solvedTask = solvedResult.tasks.get(candidate.id); + if (!solvedTask) throw new Error('solver mist open LOE in mutatie'); + return { + es: solvedTask.earlyStart, ef: solvedTask.earlyFinish, + ls: solvedTask.lateStart, lf: solvedTask.lateFinish, + tf: solvedTask.totalFloat, ff: solvedTask.freeFloat, + }; +} + +const baseAxes = axes(importedFixture()); +const absurdRaw = importedFixture({ rawYear: '2088' }); +eq('open XER LOE: raw early/late/float/restart/reend/driving blijven meetlat en zijn productinert', + axes(absurdRaw), baseAxes); + +// Permanente echte-reader-regressie: de Ashspace-vorm bewaart XER-provenance, TT_LOE en een +// positief expliciet targetvenster, maar een XER-kalender zonder klokbanden blijft dagmodus. De +// uur-native targetspanroute mag dan niet openen; de gewone hammocksemantiek blijft leidend. +const dayModeImported = importedFixture({ dayMode: true }); +const dayModeLoe = task(dayModeImported); +const dayModeCalendar = new CalendarEngine(dayModeImported.calendar); +// De dagmodusprojectie draagt de bronduur in hele dagen; geef de expliciete XER-bronduur hier +// bewust weer als minuten door aan de solveringang. Zo isoleren we de uurmodusguard zónder de +// echte readXER-provenance, taakvorm of het gelezen doelvenster te vervangen. +dayModeLoe.time.durationMinutes = 80 * 60; +eq('open XER LOE dagmodus: echte reader bewaart provenance en expliciet doelvenster zonder workTime', { + source: dayModeImported.project.schedulingOptions?.p6Source, + activity: dayModeLoe.p6ActivityType, + targetWindow: dayModeLoe.p6ExplicitTargetWindow, + start: dayModeLoe.time.scheduleStart, + finish: dayModeLoe.time.scheduleFinish, + workTime: dayModeImported.calendar.workTime, + hourMode: dayModeCalendar.isHourMode, +}, { + source: 'XER', activity: 'TT_LOE', targetWindow: true, + start: '2026-01-05', finish: '2026-01-16', workTime: undefined, hourMode: false, +}); +eq('open XER LOE dagmodus: uur-native route sluit fail-closed', + explainOpenXerLoeTargetSpanEligibility( + dayModeLoe, + dayModeImported.project.schedulingOptions, + dayModeImported.sequences.filter(sequence => sequence.successorId === dayModeLoe.id), + dayModeImported.sequences.filter(sequence => sequence.predecessorId === dayModeLoe.id), + parseInstant(dayModeLoe.time.scheduleStart), + Number.NaN, + dayModeCalendar.hoursPerDay * 60, + ), + { eligible: false, reason: 'targetWindowDurationMismatch' }, +); +let dayModeSolveError: string | undefined; +let dayModeAxes: ReturnType | undefined; +try { + dayModeAxes = axes(dayModeImported); +} catch (error) { + dayModeSolveError = error instanceof Error ? error.message : String(error); +} +eq('open XER LOE dagmodus: echte readXER-naar-solveProject-route gooit niet', dayModeSolveError, undefined); +eq('open XER LOE dagmodus: generieke hammockroute bewaart alle zes solverassen', dayModeAxes, { + es: '2026-01-05', ef: '2026-01-05', ls: '2026-01-05', lf: '2026-01-05', tf: 0, ff: 0, +}); + +const changedTargetEnd = importedFixture({ targetEnd: '2026-01-19 17:00' }); +eq('open XER LOE: alleen target_end_date wijzigt kandidaat-EF en -LF', { + decision: diagnose(changedTargetEnd), axes: axes(changedTargetEnd), +}, { + decision: { eligible: true, reason: 'eligible' }, + axes: { es: '2026-01-05T08:00', ef: '2026-01-19T17:00', ls: '2026-01-05T08:00', lf: '2026-01-19T17:00', tf: 0, ff: 0 }, +}); + +const targetStartConflict = importedFixture({ targetStart: '2026-01-06 08:00' }); +eq('open XER LOE: afwijkende target_start_date valt op relationele startconflict fail-closed terug', { + decision: diagnose(targetStartConflict), axes: axes(targetStartConflict), +}, { + decision: { eligible: false, reason: 'targetStartConflictsWithRelation' }, + axes: { es: '2026-01-05T08:00', ef: '2026-01-05T08:00', ls: '2026-01-05T08:00', lf: '2026-01-05T08:00', tf: 0, ff: 0 }, +}); + +for (const mutation of [ + { label: 'omgekeerd targetvenster', mutate: (_input: ImportResult, candidate: Task) => { candidate.time.scheduleStart = '2026-01-17T08:00'; }, reason: 'targetWindowNotPositive' }, + { label: 'half targetvenster', mutate: (_input: ImportResult, candidate: Task) => { candidate.time.scheduleFinish = ''; }, reason: 'missingScheduleFinish' }, + { label: 'ongeldig targetvenster', mutate: (_input: ImportResult, candidate: Task) => { candidate.time.scheduleFinish = 'geen-datum'; }, reason: 'invalidScheduleFinish' }, + { label: 'andere bron', mutate: (input: ImportResult) => { delete input.project.schedulingOptions?.p6Source; }, reason: 'notXerSource' }, + { label: 'ander taaktype', mutate: (_input: ImportResult, candidate: Task) => { candidate.p6ActivityType = 'TT_Task'; }, reason: 'wrongActivityType' }, + { label: 'ander duurtype', mutate: (_input: ImportResult, candidate: Task) => { candidate.p6DurationType = 'DT_FixedDrtn'; }, reason: 'wrongDurationType' }, + { label: 'andere voortgangsfamilie', mutate: (_input: ImportResult, candidate: Task) => { candidate.p6CompletePctType = 'CP_Phys'; }, reason: 'wrongCompletePctType' }, + { label: 'gestarte status', mutate: (_input: ImportResult, candidate: Task) => { candidate.status = 'STARTED'; }, reason: 'notNotStarted' }, + { label: 'actual', mutate: (_input: ImportResult, candidate: Task) => { candidate.time.actualFinish = '2026-01-10T17:00'; }, reason: 'hasActuals' }, + { label: 'stop', mutate: (_input: ImportResult, candidate: Task) => { candidate.time.stop = '2026-01-10T17:00'; }, reason: 'hasSuspendResume' }, + { label: 'resume', mutate: (_input: ImportResult, candidate: Task) => { candidate.time.resume = '2026-01-11T08:00'; }, reason: 'hasSuspendResume' }, + { label: 'bronduur buiten targetband', mutate: (_input: ImportResult, candidate: Task) => { candidate.time.durationMinutes = 1; }, reason: 'targetWindowDurationMismatch' }, +]) { + const candidateInput = structuredClone(importedFixture()); + mutation.mutate(candidateInput, task(candidateInput)); + eq(`open XER LOE fail-closed: ${mutation.label}`, diagnose(candidateInput), { eligible: false, reason: mutation.reason }); +} + +for (const topology of [ + { label: 'zonder PR_FF', options: { includeOutgoing: false }, reason: 'missingOutgoingFinishFinish' }, + { label: 'met andere uitgaande type', options: { outgoingType: 'PR_FS' }, reason: 'outgoingNotOnlyFinishFinish' }, + { label: 'met lag', options: { outgoingLag: '8' }, reason: 'nonZeroRelationLag' }, +]) { + eq(`open XER LOE fail-closed: ${topology.label}`, + diagnose(importedFixture(topology.options)), { eligible: false, reason: topology.reason }); +} + +const assigned = structuredClone(importedFixture()); +task(assigned).resourceIds.push('resource-1', 'resource-2'); +eq('open XER LOE: toegevoegde toewijzingen veranderen de span niet', axes(assigned), baseAxes); + +const reloaded = await readIFCWithXerReconstruction(writeIFC(importedFixture())); +eq('open XER LOE: XER-IFC-reload bewaart de relevante XER-provenance en span', { + source: reloaded.project.schedulingOptions?.p6Source, + decision: diagnose(reloaded), + axes: axes(reloaded), +}, { source: 'XER', decision: { eligible: true, reason: 'eligible' }, axes: baseAxes }); + +const genericIfcSource = structuredClone(importedFixture()); +delete genericIfcSource.project.schedulingOptions?.p6Source; +genericIfcSource.xer = undefined; +genericIfcSource.xerSourceArchive = undefined; +genericIfcSource.xerSourceProjectId = undefined; +const genericIfc = readIFC(writeIFC(genericIfcSource)); +eq('open XER LOE: generieke IFC zonder XER-provenance blijft fail-closed', + diagnose(genericIfc), { eligible: false, reason: 'notXerSource' }); + +if (diffs.length > 0) { + console.error(`XER open LOE targetspan RED: ${diffs.length}/${checks} checks rood`); + for (const diff of diffs) console.error(`XX ${diff}`); + process.exit(1); +} +console.log(`XER open LOE targetspan GREEN: ${checks} checks groen`); diff --git a/tests/planning/check-xer-open-wiring.ts b/tests/planning/check-xer-open-wiring.ts new file mode 100644 index 000000000..ecf27b5de --- /dev/null +++ b/tests/planning/check-xer-open-wiring.ts @@ -0,0 +1,438 @@ +import { parseOpenedFile } from '@/services/formatRegistry'; +import { isMultiDocumentImport } from '@/services/importTypes'; +import type { XerMultiProjectImport } from '@/services/xer/xerMultiProject'; +import { useAppStore } from '@/state/appStore'; +import { buildWriteIFCInput } from '@/state/ifcSaveInput'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; +import { writeIFC } from '@/services/ifc/ifcWriter'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const diffs: string[] = []; +let checks = 0; + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +function ok(label: string, value: unknown): void { + eq(label, Boolean(value), true); +} + +const source = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-08-25\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_data', + '%R\tC1\tStandaard\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tsum_base_proj_id', + '%R\tMAIN\tHoofd\tC1\t2026-01-01 08:00\tBASE', + '%R\tBASE\tNulmeting\tC1\t2026-01-01 08:00\t', + '%R\tOTHER\tGrootste\tC1\t2026-01-01 08:00\t', + '%R\tEMPTY\tLeeg\tC1\t2026-01-01 08:00\t', + '%R\tDANGLING\tLos\tC1\t2026-01-01 08:00\tVERDWENEN', + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_code\ttask_name\ttarget_start_date\ttarget_end_date', + '%R\tM1\tMAIN\tM1\tHoofdtaak\t2026-01-02 08:00\t2026-01-02 16:00', + '%R\tB1\tBASE\tM1\tNulmetingstaak\t2025-12-02 08:00\t2025-12-02 16:00', + '%R\tO1\tOTHER\tO1\tEerste\t2026-01-03 08:00\t2026-01-03 16:00', + '%R\tO2\tOTHER\tO2\tTweede\t2026-01-04 08:00\t2026-01-04 16:00', + '%R\tD1\tDANGLING\tD1\tLosse taak\t2026-01-05 08:00\t2026-01-05 16:00', + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%R\tR-CROSS\tO1\tM1\tOTHER\tMAIN\tPR_FS\t0', + '%E', +].join('\n')); + +const parsed = await parseOpenedFile({ name: 'meerdere.xer', bytes: source }); +ok('1 readXER retourneert bij meerdere PROJECT-rijen de meervoudige returnvorm', + isMultiDocumentImport(parsed)); +if (!isMultiDocumentImport(parsed)) { + throw new Error('XER-meerprojectfixture leverde geen meervoudige import op'); +} +const multi = parsed as XerMultiProjectImport; +eq('2 lezer behoudt selectie-, baseline- en danglingtellingen', multi.report, { + projectsSeen: 5, + documentsOpened: 3, + emptyProjectsSkipped: 1, + baselineProjectsExcluded: 1, + baselinesMaterialized: 1, + danglingBaselineReferences: 1, + externalLinksPreserved: 1, + baselineExclusionReverted: false, + baselineFallbackReasons: [], +}); +eq('3 actieve import is het project met de meeste bladtaken', multi.activeDocumentIndex, 1); + +const store = useAppStore.getState(); +// Het startdocument is vers en mag voor het eerste geopende project worden hergebruikt. +store.applyOpenedImport(multi, { + filePath: null, + fileHandle: null, + recompute: true, + fit: true, + hourDataNotice: true, + linkedOpen: true, +}); + +const after = useAppStore.getState(); +const docs = after.getOpenDocumentPayloads(); +eq('4 echte openroute hergebruikt pristine tab en opent de overige projecten los', { + documentCount: docs.length, + ids: docs.map(document => document.payload.project.id), + active: after.project.id, +}, { + documentCount: 3, + ids: ['MAIN', 'OTHER', 'DANGLING'], + active: 'OTHER', +}); +eq('5 ieder XER-document is schoon en heeft nooit het bronbestand als save-target', + docs.map(document => ({ + id: document.payload.project.id, + dirty: document.payload.isDirty, + filePath: document.payload.filePath, + fileHandle: document.payload.fileHandle, + })), [ + { id: 'MAIN', dirty: false, filePath: null, fileHandle: null }, + { id: 'OTHER', dirty: false, filePath: null, fileHandle: null }, + { id: 'DANGLING', dirty: false, filePath: null, fileHandle: null }, + ]); +eq('6 baseline is documentgebonden en de andere projecten erven haar niet', + docs.map(document => ({ + id: document.payload.project.id, + activeBaselineId: document.payload.activeBaselineId, + baselines: document.payload.baselines.map(baseline => baseline.id), + })), [ + { id: 'MAIN', activeBaselineId: 'xer-baseline:MAIN:BASE', baselines: ['xer-baseline:MAIN:BASE'] }, + { id: 'OTHER', activeBaselineId: null, baselines: [] }, + { id: 'DANGLING', activeBaselineId: null, baselines: [] }, + ]); +ok('7 documentpayloads delen geen mutabele project- of taakobjecten', + docs.length >= 2 + && docs[0].payload.project !== docs[1].payload.project + && docs[0].payload.tasks !== docs[1].payload.tasks + && docs[0].payload.calendar !== docs[1].payload.calendar); +eq('8 elk geopend document kan onafhankelijk als IFC recoverypayload rond-tripen', + docs.map(document => { + const roundTripped = readIFC(writeIFC(buildWriteIFCInput(document.payload))); + return { + name: roundTripped.project.name, + baselines: roundTripped.baselines?.map(baseline => baseline.id) ?? [], + }; + }), [ + { name: 'Hoofd', baselines: ['xer-baseline:MAIN:BASE'] }, + { name: 'Grootste', baselines: [] }, + { name: 'Los', baselines: [] }, + ]); + +const linkIds = (metadata: typeof docs[number]['payload']['xerImportMetadata']) => + metadata?.externalLinks.map(link => link.id) ?? []; +eq('8a geconsolideerde cross-documentlink hangt exact aan beide betrokken documenten', + docs.map(document => ({ + projectId: document.payload.project.id, + links: linkIds(document.payload.xerImportMetadata), + solverSequences: document.payload.sequences.map(sequence => sequence.id), + taskLinks: document.payload.tasks.flatMap(task => task.externalLinks ?? []).length, + })), [ + { projectId: 'MAIN', links: ['R-CROSS'], solverSequences: [], taskLinks: 0 }, + { projectId: 'OTHER', links: ['R-CROSS'], solverSequences: [], taskLinks: 0 }, + { projectId: 'DANGLING', links: [], solverSequences: [], taskLinks: 0 }, + ]); +ok('8b betrokken documenten delen geen mutable externalLinks-array', + docs.length >= 2 + && docs[0].payload.xerImportMetadata?.externalLinks + !== docs[1].payload.xerImportMetadata?.externalLinks); + +const mainDocumentId = docs.find(document => document.payload.project.id === 'MAIN')?.id; +const otherDocumentId = docs.find(document => document.payload.project.id === 'OTHER')?.id; +ok('8c MAIN en OTHER bestaan beide als schakelbare documenten', mainDocumentId && otherDocumentId); +if (mainDocumentId && otherDocumentId) { + after.switchDocument(mainDocumentId); + eq('8d documentwissel hydrateert de eigen geconsolideerde XER-links', + linkIds(useAppStore.getState().xerImportMetadata), ['R-CROSS']); + after.switchDocument(otherDocumentId); + const linksBeforeUndo = JSON.stringify(linkIds(useAppStore.getState().xerImportMetadata)); + useAppStore.getState().setProject({ description: 'Undo-proef zonder brondatawijziging' }); + useAppStore.getState().undo(); + eq('8e gewone bewerking plus undo laat documentgebonden XER-links exact intact', + JSON.stringify(linkIds(useAppStore.getState().xerImportMetadata)), linksBeforeUndo); +} + +// X4b kan de metadata al door het document-/recovery-inputcontract dragen. De daadwerkelijke +// IFC-serialisatie van `xerImportMetadata.externalLinks` blijft bewust de geregistreerde X9-taak. +const recoveryDocs = docs.map(({ id, payload }) => ({ + id, + filePath: payload.filePath, + isDirty: payload.isDirty, + project: payload.project, + calendar: payload.calendar, + tasks: payload.tasks, + sequences: payload.sequences, + resources: payload.resources, + assignments: payload.assignments, + resourceCalendars: payload.calendars, + activityCodeTypes: payload.activityCodeTypes, + customFieldDefs: payload.customFieldDefs, + baselines: payload.baselines, + activeBaselineId: payload.activeBaselineId, + xer: payload.xerImportMetadata ?? undefined, + datesAsRecorded: false, +})); +useAppStore.getState().restoreDocuments(recoveryDocs, otherDocumentId ?? null); +eq('8f recovery-inputoverdracht herstelt links per document zonder solverdoorwerking', + useAppStore.getState().getOpenDocumentPayloads().map(document => ({ + projectId: document.payload.project.id, + links: linkIds(document.payload.xerImportMetadata), + sequences: document.payload.sequences.map(sequence => sequence.id), + })), [ + { projectId: 'MAIN', links: ['R-CROSS'], sequences: [] }, + { projectId: 'OTHER', links: ['R-CROSS'], sequences: [] }, + { projectId: 'DANGLING', links: [], sequences: [] }, + ]); + +// ── XER-etappeplan §3.5/§3.7/§3.10, taak T4 — standaard-aan bij het laden + de melding ───────── +// Corpusloos, draait altijd. Één taak per project, target ver uiteen van het early-orakel ⇒ +// gegarandeerd één verschoven taak — geen calendar-/uurmodus-fijnrekenwerk nodig. +{ + const restXerBytes = (projId: string, taskId: string, taskCode: string) => [ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tStandaard\tCA_Base\t8\t40\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date', + `%R\t${projId}\tRestverschillen\tC1\t2026-01-01`, + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date\tearly_start_date\tearly_end_date\tlate_start_date\tlate_end_date\ttotal_float_hr_cnt\tfree_float_hr_cnt', + `%R\t${taskId}\t${projId}\tC1\t${taskCode}\tTaak\tTT_Task\tDT_FixedDUR\tTK_NotStart\t40\t40\t2026-01-02\t2026-01-09\t2026-02-01\t2026-02-08\t2026-02-01\t2026-02-08\t0\t0`, + '%E', + ].join('\n'); + const noRecordedXerBytes = (projId: string, taskId: string, taskCode: string) => [ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tStandaard\tCA_Base\t8\t40\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date', + `%R\t${projId}\tZonderOrakel\tC1\t2026-01-01`, + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date', + `%R\t${taskId}\t${projId}\tC1\t${taskCode}\tTaak\tTT_Task\tDT_FixedDUR\tTK_NotStart\t40\t40\t2026-01-02\t2026-01-09`, + '%E', + ].join('\n'); + + const single = await parseOpenedFile({ name: 't4-rest.xer', bytes: new TextEncoder().encode(restXerBytes('PX', 'TX', 'AX')) }); + if (isMultiDocumentImport(single)) throw new Error('T4-restfixture moet enkelproject zijn'); + eq('T4-1 recordedTimesOrigin is xer', single.recordedTimesOrigin, 'xer'); + + useAppStore.getState().newDocument(); + const undoBefore = useAppStore.getState().historyEvents.filter((e) => e.state === 'applied').length; + // ID-diff i.p.v. lengte-delta: `notify()` begrenst de stapel (MAX_NOTIFICATIONS) en verdringt + // dan de OUDSTE info-melding — bij een volle stapel verandert `.length` dus niet, terwijl er wél + // een nieuwe melding bij kwam. Een set van bestaande id's blijft correct ongeacht verdringing. + const notifBefore = new Set(useAppStore.getState().ui.notifications.map((n) => n.id)); + useAppStore.getState().applyOpenedImport(single, { filePath: null, recompute: true }); + + eq('T4-2 corpusloze XER met restverschillen opent MET de modus aan', useAppStore.getState().datesAsRecorded, true); + eq('T4-3 scheduleStale blijft false in de modus (risico §5.1)', useAppStore.getState().scheduleStale, false); + ok('T4-4 recordedDates is gevuld', useAppStore.getState().recordedDates !== null); + eq('T4-5 shifted telt de enige taak', useAppStore.getState().recordedDates?.shifted, 1); + eq('T4-6 het laden pusht geen undo-snapshot', + useAppStore.getState().historyEvents.filter((e) => e.state === 'applied').length, undoBefore); + + const notifsAfterSingle = useAppStore.getState().ui.notifications.filter((n) => !notifBefore.has(n.id)); + eq('T4-7 precies één melding voor dit bestand', notifsAfterSingle.length, 1); + const singleDetail = notifsAfterSingle[0]?.detailLines + ?.find((d) => d.messageKey === 'notifications.xerImportDatesAsRecorded'); + ok('T4-8 de melding noemt "datums zoals opgeslagen"', singleDetail); + eq('T4-9 …met het juiste aantal afwijkende taken', singleDetail?.params?.count, 1); + + useAppStore.getState().undo(); + eq('T4-10 undo() direct na het laden raakt de modus niet (er is niets om naar terug te gaan)', + useAppStore.getState().datesAsRecorded, true); + + // Tegenproef: een XER zónder enige P6-rekenuitvoer opent volledig normaal — geen aanbod, geen modus. + const clean = await parseOpenedFile({ name: 't4-clean.xer', bytes: new TextEncoder().encode(noRecordedXerBytes('PZ', 'TZ', 'AZ')) }); + if (isMultiDocumentImport(clean)) throw new Error('T4-cleanfixture moet enkelproject zijn'); + eq('T4-11 fixture zonder P6-uitvoer draagt een lege recordedTimes-map (geen enkele taak met early-paar)', clean.recordedTimes, {}); + useAppStore.getState().newDocument(); + useAppStore.getState().applyOpenedImport(clean, { filePath: null, recompute: true }); + eq('T4-12 XER zonder P6-rekenuitvoer opent normaal: modus uit', useAppStore.getState().datesAsRecorded, false); + eq('T4-13 …en geen aanbod', useAppStore.getState().recordedDates, null); + + // Multi-project: twee zelfstandige XER-projecten, elk met een eigen restverschil ⇒ twee + // ONAFHANKELIJKE modusvlaggen, maar de MELDING blijft er één per bestand (plan §3.7 punt 1). + const twoProjBytes = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tStandaard\tCA_Base\t8\t40\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date', + '%R\tPA\tProjectA\tC1\t2026-01-01', + '%R\tPB\tProjectB\tC1\t2026-01-01', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date\tearly_start_date\tearly_end_date\tlate_start_date\tlate_end_date\ttotal_float_hr_cnt\tfree_float_hr_cnt', + '%R\tTA\tPA\tC1\tAA\tTaakA\tTT_Task\tDT_FixedDUR\tTK_NotStart\t40\t40\t2026-01-02\t2026-01-09\t2026-02-01\t2026-02-08\t2026-02-01\t2026-02-08\t0\t0', + '%R\tTB\tPB\tC1\tAB\tTaakB\tTT_Task\tDT_FixedDUR\tTK_NotStart\t40\t40\t2026-03-02\t2026-03-09\t2026-04-01\t2026-04-08\t2026-04-01\t2026-04-08\t0\t0', + '%E', + ].join('\n')); + const twoProj = await parseOpenedFile({ name: 't4-twee-projecten.xer', bytes: twoProjBytes }); + if (!isMultiDocumentImport(twoProj)) throw new Error('T4-multifixture moet meervoudig zijn'); + const notifBeforeMulti = new Set(useAppStore.getState().ui.notifications.map((n) => n.id)); + const openedMulti = useAppStore.getState().applyOpenedImport(twoProj, { filePath: null, recompute: true }); + eq('T4-14 twee projecten geven twee documenten', openedMulti.documentIds.length, 2); + const openedDocs = useAppStore.getState().getOpenDocumentPayloads() + .filter((d) => openedMulti.documentIds.includes(d.id)); + eq('T4-15 twee documenten, elk zijn EIGEN modusvlag aan', + openedDocs.map((d) => d.payload.datesAsRecorded), [true, true]); + eq('T4-16 elk document telt zijn eigen verschoven taak (1 elk)', + openedDocs.map((d) => d.payload.recordedDates?.shifted), [1, 1]); + const notifsAfterMulti = useAppStore.getState().ui.notifications.filter((n) => !notifBeforeMulti.has(n.id)); + eq('T4-17 twee documenten ⇒ TOCH precies één melding (niet twee)', notifsAfterMulti.length, 1); + const multiDetail = notifsAfterMulti[0]?.detailLines + ?.find((d) => d.messageKey === 'notifications.xerImportDatesAsRecorded'); + eq('T4-18 …de teller is de SOM over beide documenten (1+1=2), niet per document', + multiDetail?.params?.count, 2); + + // MUTATIEBEWIJS (O6-patroon): zet `recordedTimesOrigin` NIET ⇒ de modus blijft UIT, ook al is + // exact dezelfde vastlegging (`recordedTimes`) aanwezig. Bewijst dat de auto-aan-route + // uitsluitend op de herkomstvlag draait — en dus dat IFC/CSV/MSPDI/MPP/P6XML (die dit veld nooit + // zetten) byte-identiek #63-gedrag houden. + const singleAgain = await parseOpenedFile({ name: 't4-rest-2.xer', bytes: new TextEncoder().encode(restXerBytes('PX2', 'TX2', 'AX2')) }); + if (isMultiDocumentImport(singleAgain)) throw new Error('T4-restfixture (2) moet enkelproject zijn'); + const singleWithoutOrigin = { ...singleAgain, recordedTimesOrigin: undefined }; + useAppStore.getState().newDocument(); + useAppStore.getState().applyLoadedProject(singleWithoutOrigin, { filePath: null, recompute: true }); + eq('T4-19 zonder recordedTimesOrigin blijft de modus UIT (O6-mutatiebewijs)', useAppStore.getState().datesAsRecorded, false); + ok('T4-20 …maar het aanbod verschijnt nog gewoon (recordedDates gevuld)', useAppStore.getState().recordedDates !== null); + eq('T4-21 …met dezelfde teller als de aan-route', useAppStore.getState().recordedDates?.shifted, 1); + + // Heropen-beleid (orkestratorbesluit, XER-etappe laag 3, 2026-09-05, taak T5): 'xer-archive' — + // wat `readIFC`'s XER-archiefreconstructie zet voor een HEROPENDE IFC — biedt de modus alleen + // AAN, net als geheel géén herkomst hierboven. MUTATIEBEWIJS: stelde `applyRecordedDatesOnLoad` + // 'xer-archive' gelijk aan 'xer', dan zou T4-22 hieronder `true` worden. + const singleAsArchiveOrigin = { ...singleAgain, recordedTimesOrigin: 'xer-archive' as const }; + useAppStore.getState().newDocument(); + useAppStore.getState().applyLoadedProject(singleAsArchiveOrigin, { filePath: null, recompute: true }); + eq('T4-22 "xer-archive" (heropende IFC) biedt de modus alleen aan, NIET gelijk aan "xer"', + useAppStore.getState().datesAsRecorded, false); + ok('T4-23 …maar het aanbod verschijnt wél', useAppStore.getState().recordedDates !== null); + eq('T4-24 …met dezelfde teller', useAppStore.getState().recordedDates?.shifted, 1); + + // T4-25..29 — DE MELDING MOET DE WAARHEID ZEGGEN (critreview laag 3, bevinding 4). + // `notifications.xerImportDatesAsRecorded` zegt letterlijk "niet herberekend". Een heropende IFC + // met XER-archief draagt óók `xer`-metadata en kwam dus in dezelfde regel terecht — terwijl daar + // per heropen-beleid juist WÉL herberekend is en de modus uit staat. De aanbodroute heeft nu een + // eigen, aanbiedende regel. MUTATIEBEWIJS: tel de aanbodstand weer bij + // `datesAsRecordedShiftedTotal` op ⇒ T4-27/T4-28 slaan rood. + const notifBeforeArchive = new Set(useAppStore.getState().ui.notifications.map((n) => n.id)); + useAppStore.getState().newDocument(); + useAppStore.getState().applyOpenedImport(singleAsArchiveOrigin, { filePath: null, recompute: true }); + const archiveNotifs = useAppStore.getState().ui.notifications.filter((n) => !notifBeforeArchive.has(n.id)); + eq('T4-25 de heropende XER-archief-IFC geeft één melding', archiveNotifs.length, 1); + eq('T4-26 voorwaarde: de modus staat daarbij UIT', useAppStore.getState().datesAsRecorded, false); + ok('T4-27 de melding zegt NIET "niet herberekend" (er is wél herberekend)', + archiveNotifs[0]?.detailLines?.every((d) => d.messageKey !== 'notifications.xerImportDatesAsRecorded') ?? false); + const offerDetail = archiveNotifs[0]?.detailLines + ?.find((d) => d.messageKey === 'notifications.xerImportDatesAsRecordedOffer'); + ok('T4-28 …maar biedt de opgeslagen datums wél aan', offerDetail); + eq('T4-29 …met hetzelfde aantal afwijkende taken', offerDetail?.params?.count, 1); +} + +const corpusRoot = process.env.OPS_XER_CORPUS; +if (corpusRoot && existsSync(corpusRoot)) { + const openPublicXer = async (relativePath: string) => { + // Partieel corpus (her-check laag 3, bevinding 12): een ontbrekend bestand is een nette rode + // check, geen kale `ENOENT`-stacktrace. + if (!existsSync(join(corpusRoot, relativePath))) { + throw new Error(`corpusbestand ontbreekt: ${relativePath} (OPS_XER_CORPUS wijst naar een onvolledige corpusmap)`); + } + const bytes = new Uint8Array(readFileSync(join(corpusRoot, relativePath))); + const opened = await parseOpenedFile({ name: relativePath, bytes }); + if (!isMultiDocumentImport(opened)) throw new Error(`${relativePath}: verwacht een meervoudige XER-import`); + return opened as XerMultiProjectImport; + }; + + const stack = await openPublicXer('crawl-xer/stack_data_center_baseline.xer'); + eq('9 openbare baselinefixture levert één document met een OPS-baseline', { + documents: stack.results.length, + baselines: stack.results[0]?.baselines?.length, + active: stack.results[0]?.activeBaselineId === stack.results[0]?.baselines?.[0]?.id, + }, { documents: 1, baselines: 1, active: true }); + + const ozbStarted = performance.now(); + const ozbHeapBefore = process.memoryUsage().heapUsed; + const ozb = await openPublicXer('crawl-xer/eh_P6Workshops/OZB-Start-09Dec24.xer'); + const openedBefore = useAppStore.getState().getOpenDocumentPayloads().length; + useAppStore.getState().applyOpenedImport(ozb, { + filePath: null, + fileHandle: null, + recompute: true, + fit: true, + hourDataNotice: true, + linkedOpen: true, + }); + const ozbElapsedMs = performance.now() - ozbStarted; + const ozbHeapDeltaBytes = process.memoryUsage().heapUsed - ozbHeapBefore; + const openedAfter = useAppStore.getState().getOpenDocumentPayloads(); + eq('10 openbare 15-projectenfixture gaat door de echte openroute als 12 losse documenten', { + projects: ozb.report.projectsSeen, + documents: ozb.results.length, + empty: ozb.report.emptyProjectsSkipped, + dangling: ozb.report.danglingBaselineReferences, + addedTabs: openedAfter.length - openedBefore, + activeProjectId: useAppStore.getState().project.id, + expectedActiveProjectId: ozb.results[ozb.activeDocumentIndex]?.project.id, + }, { + projects: 15, + documents: 12, + empty: 3, + dangling: 9, + addedTabs: 12, + activeProjectId: ozb.results[ozb.activeDocumentIndex]?.project.id, + expectedActiveProjectId: ozb.results[ozb.activeDocumentIndex]?.project.id, + }); + ok('11 openbare 15-projecten-openroute meet eindige tijd en heapdelta', + Number.isFinite(ozbElapsedMs) && Number.isFinite(ozbHeapDeltaBytes)); + console.log(`. X4b-wiring openbaar: 15→${ozb.results.length} leeg=${ozb.report.emptyProjectsSkipped} dangling=${ozb.report.danglingBaselineReferences} elapsedMs=${ozbElapsedMs.toFixed(1)} heapDeltaBytes=${ozbHeapDeltaBytes}`); + + // XER-etappeplan §4/T4, acceptatie: "een corpus-XER met restverschillen opent MET de modus aan; + // een XER zonder verschillen opent normaal" — met ECHTE corpusbestanden, niet alleen de + // corpusloze fixtures hierboven. `rehab-2.xer` (project 761, gemeten in de X-O7-bijstelling: 813 + // van 6.976 vastgelegde taken wijken ná de solve nog af) en `p6diff-baseline.xer` (8 vastgelegde + // taken, gemeten: 0 wijken af — dit bestand won onderweg mee met de laag-1-fixes op main). + const openSingle = async (relativePath: string) => { + const bytes = new Uint8Array(readFileSync(join(corpusRoot, relativePath))); + const opened = await parseOpenedFile({ name: relativePath, bytes }); + if (isMultiDocumentImport(opened)) throw new Error(`${relativePath}: verwacht een enkelvoudige XER-import`); + return opened; + }; + + const rehab = await openSingle('crawl-xer-extra/jailaff-xer-splitter/rehab-2.xer'); + useAppStore.getState().newDocument(); + useAppStore.getState().applyOpenedImport(rehab, { filePath: null, recompute: true }); + eq('12 rehab-2.xer (échte restverschillen) opent MET de modus aan', useAppStore.getState().datesAsRecorded, true); + ok('13 …met een gevulde recordedDates', useAppStore.getState().recordedDates !== null); + ok('14 …en minstens één verschoven taak', (useAppStore.getState().recordedDates?.shifted ?? 0) > 0); + console.log(`. T4 rehab-2.xer: shifted=${useAppStore.getState().recordedDates?.shifted} total=${useAppStore.getState().recordedDates?.total}`); + + const p6diff = await openSingle('crawl-xer/p6diff-baseline.xer'); + useAppStore.getState().newDocument(); + useAppStore.getState().applyOpenedImport(p6diff, { filePath: null, recompute: true }); + ok('15 voorwaarde: p6diff-baseline.xer draagt écht recordedTimes', (p6diff.recordedTimes && Object.keys(p6diff.recordedTimes).length > 0)); + eq('16 p6diff-baseline.xer (0 restverschillen) opent NORMAAL: modus uit', useAppStore.getState().datesAsRecorded, false); + eq('17 …en geen aanbod (de vastgelegde datums kwamen exact uit de solve)', useAppStore.getState().recordedDates, null); +} else { + console.log('OK XER-open-wiring: openbare baseline- en 15-projectenpins overgeslagen (OPS_XER_CORPUS)'); +} + +if (diffs.length > 0) { + console.error(`XER-open-wiring: ${diffs.length}/${checks} checks rood`); + for (const diff of diffs) console.error(`XX ${diff}`); + process.exit(1); +} +console.log(`OK XER-open-wiring: alle ${checks} centrale open/document/recovery-checks groen`); diff --git a/tests/planning/check-xer-p6-presence.ts b/tests/planning/check-xer-p6-presence.ts new file mode 100644 index 000000000..270138959 --- /dev/null +++ b/tests/planning/check-xer-p6-presence.ts @@ -0,0 +1,74 @@ +// X9 reviewronde 1 — false is een aanwezige P6-waarde; raw XER-cellen blijven daarnaast exact. +import { activeImportResult } from '@/services/importTypes'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; +import { writeIFC } from '@/services/ifc/ifcWriter'; +import { readXER } from '@/services/xer/xerReader'; + +declare const process: { exit(code: number): never }; +const failures: string[] = []; +let checks = 0; +const equal = (label: string, actual: unknown, expected: unknown) => { + checks += 1; + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + failures.push(`${label}: verwacht ${JSON.stringify(expected)}, kreeg ${JSON.stringify(actual)}`); + } +}; + +const bytes = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-08-01\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name', + '%R\tP\tPresentie', + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\tcomplete_pct_type\tsuspend_date\tresume_date\tunknown_new_field\ttarget_start_date\ttarget_end_date\ttarget_drtn_hr_cnt', + '%R\tT-FALSE\tP\tFALSE\tExpliciet false\tTT_Task\tDT_Mystery\tTK_NotStart\t\tnull\tfalse\tmystery\t2026-08-01\t2026-08-02\t8', + '%R\tT-ABSENT\tP\tABSENT\tAfwezig\tTT_Task\tDT_FixedDUR2\tTK_NotStart\t\t\t\tordinary\t2026-08-02\t2026-08-03\t8', + '%E', +].join('\r\n')); +const opened = activeImportResult(readXER(bytes)); +const archive = opened.xerSourceArchive; +if (!archive) throw new Error('XER-archief ontbreekt'); +const rawRows = archive.readModel.taskSourceRowsByProject.P; +const rawFalse = rawRows?.find(row => row.cells.task_id === 'T-FALSE'); +equal('1 raw provenance onderscheidt afwezig, leeg, null, false, onbekend en gewone waarde', { + absent: rawFalse && Object.prototype.hasOwnProperty.call(rawFalse.cells, 'expect_end_date'), + empty: rawFalse?.cells.complete_pct_type, + null: rawFalse?.cells.suspend_date, + false: rawFalse?.cells.resume_date, + unknown: rawFalse?.cells.duration_type, + ordinary: rawFalse?.cells.task_type, +}, { + absent: false, + empty: '', + null: 'null', + false: 'false', + unknown: 'DT_Mystery', + ordinary: 'TT_Task', +}); + +const falseTask = opened.tasks.find(task => task.id === 'T-FALSE'); +if (!falseTask) throw new Error('T-FALSE ontbreekt'); +falseTask.p6SuspendResume = false; +const absentTask = opened.tasks.find(task => task.id === 'T-ABSENT'); +if (!absentTask) throw new Error('T-ABSENT ontbreekt'); +delete absentTask.p6SuspendResume; +const ifc = writeIFC(opened); +equal('2 IFC schrijft expliciet false als .F. en niet als afwezig', + (ifc.match(/'SuspendResume',\$,IFCBOOLEAN\(\.F\.\)/g) ?? []).length, 1); +const reloaded = readIFC(ifc); +const byCode = new Map(reloaded.tasks.map(task => [task.wbsCode, task])); +equal('3 IFC-read onderscheidt expliciet false van een afwezige P6-vlag', + [byCode.get('FALSE')?.p6SuspendResume, byCode.get('ABSENT')?.p6SuspendResume], + [false, undefined]); +equal('4 raw presentie blijft na XER→IFC→IFC byte- en celgetrouw beschikbaar', + (readIFC(writeIFC(reloaded)).xerSourceArchive?.readModel.taskSourceRowsByProject.P ?? []) + .find(row => row.cells.task_id === 'T-FALSE')?.cells, + rawFalse?.cells); + +if (failures.length === 0) { + console.log(`OK xer-p6-presence: alle checks groen (${checks})`); + process.exit(0); +} +console.log(`XX xer-p6-presence: ${failures.length} afwijking(en) van ${checks}`); +for (const failure of failures) console.log(` - ${failure}`); +process.exit(1); diff --git a/tests/planning/check-xer-p6xml-parity.ts b/tests/planning/check-xer-p6xml-parity.ts new file mode 100644 index 000000000..92e2989fa --- /dev/null +++ b/tests/planning/check-xer-p6xml-parity.ts @@ -0,0 +1,91 @@ +// X9 — P6XML is geen gelijkwaardige XER-archiefadapter. Deze smoke bewaakt de zichtbare asymmetrie. +import { createDefaultCalendar } from '@/engine/calendar/defaultCalendar'; +import { readFileSync } from 'node:fs'; +import { readP6XML } from '@/services/p6/p6xmlReader'; +import { writeP6XML } from '@/services/p6/p6xmlWriter'; +import { createDefaultProject } from '@/state/defaults'; +import type { Task } from '@/types/task'; +import { createDefaultTaskTime } from '@/utils/taskDefaults'; +import { installDOMParser } from './xmldom-shim'; + +declare const process: { exit(code: number): never }; +installDOMParser(); +const failures: string[] = []; +const expect = (label: string, condition: boolean) => { if (!condition) failures.push(label); }; +const project = createDefaultProject(); +project.id = 'P6XML-X9'; project.name = 'P6XML asymmetrie'; +const calendar = createDefaultCalendar(); project.calendarId = calendar.id; +const task = { + id: 'p6xml-x9-task', name: 'P6 velden', description: '', wbsCode: 'A1', + taskType: 'CONSTRUCTION', status: 'NOT_STARTED', isMilestone: false, priority: 500, + parentId: null, childIds: [], resourceIds: [], time: createDefaultTaskTime('2032-02-02', 1), + p6ProjectId: 'XER-PROJ', p6TaskId: 'XER-TASK', p6DurationType: 'DT_FixedRate', + p6ActivityType: 'TT_LOE', p6CompletePctType: 'CP_Phys', p6ExpectedFinish: '2032-02-10', +} as Task; +const warnings: string[] = []; +const originalWarn = console.warn; +console.warn = (...args: unknown[]) => warnings.push(args.join(' ')); +let xml = ''; +try { xml = writeP6XML(project, calendar, [task], [], [], []); } finally { console.warn = originalWarn; } +const roundTripped = readP6XML(xml).tasks.find(candidate => candidate.name === task.name); +expect('1 P6XML schrijft reguliere planningdata', xml.includes('') && roundTripped !== undefined); +expect('2 P6XML claimt geen XER/P6-veldpariteit', roundTripped?.p6DurationType === undefined + && roundTripped?.p6ActivityType === undefined + && roundTripped?.p6ProjectId === undefined + && roundTripped?.p6TaskId === undefined); +expect('3 de asymmetrie is als TODO in de writer vastgelegd', + warnings.length === 0 + && xml.includes('') + && readFileSync(new URL('../../src/services/p6/p6xmlWriter.ts', import.meta.url), 'utf8').includes('TODO(X9/P6XML)')); +// Contour-engine-vervolgafspraak (2026-09, taaktypes-etappe): `` is de ENE asymmetrie +// die niet langer tweerichtingsverkeer mist op de lezer — writeP6XML schrijft het element niet (zie +// hierboven, assertie 2/3 blijven onveranderd waar), maar readP6XML leest het nu wél wanneer een +// ECHT P6-bestand (niet onze eigen writer) het meebrengt. Injecteer het element handmatig in de +// writer-uitvoer (dezelfde truc als de rest van dit bestand voor "een echt P6-bestand zou dit +// dragen") om de vier canonieke PMXML-labels en de gerapporteerde terugval te bewijzen. +function withDurationType(baseXml: string, label: string | null): string { + if (label === null) return baseXml; + return baseXml.replace(/([\s\S]*?[^<]*<\/Type>)/, `$1${label}`); +} +function readDurationType(label: string | null): { value: string | undefined; warnings: string[] } { + const warned: string[] = []; + const original = console.warn; + console.warn = (...args: unknown[]) => warned.push(args.join(' ')); + let value: string | undefined; + try { + value = readP6XML(withDurationType(xml, label)).tasks.find(t => t.name === task.name)?.p6DurationType; + } finally { + console.warn = original; + } + return { value, warnings: warned }; +} + +const knownDurationTypeLabels: Readonly> = { + 'Fixed Duration and Units': 'DT_FixedDrtn', + 'Fixed Duration and Units/Time': 'DT_FixedDUR2', + 'Fixed Units/Time': 'DT_FixedRate', + 'Fixed Units': 'DT_FixedQty', +}; +for (const [label, expected] of Object.entries(knownDurationTypeLabels)) { + const { value, warnings: labelWarnings } = readDurationType(label); + expect(`4 ${label} leest naar ${expected}, geen melding`, + value === expected && labelWarnings.length === 0); +} + +const { value: unknownValue, warnings: unknownWarnings } = readDurationType('Fixed Whatever'); +expect('5 onbekend -label blijft afwezig en wordt gerapporteerd', + unknownValue === undefined + && unknownWarnings.length === 1 + && unknownWarnings[0].includes('Fixed Whatever')); + +const { value: missingValue, warnings: missingWarnings } = readDurationType(null); +expect('6 ontbrekend -element blijft afwezig, geen melding', + missingValue === undefined && missingWarnings.length === 0); + +if (failures.length === 0) { + console.log('OK xer-p6xml-parity: asymmetrie zichtbaar, geen gelijkwaardigheidsclaim'); + process.exit(0); +} +console.log(`XX xer-p6xml-parity: ${failures.length} afwijking(en)`); +for (const failure of failures) console.log(` - ${failure}`); +process.exit(1); diff --git a/tests/planning/check-xer-product-fidelity-x12.ts b/tests/planning/check-xer-product-fidelity-x12.ts new file mode 100644 index 000000000..5e816f24c --- /dev/null +++ b/tests/planning/check-xer-product-fidelity-x12.ts @@ -0,0 +1,2304 @@ +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { CalendarEngine } from '@/engine/scheduler/CalendarEngine'; +import { solveProject } from '@/engine/scheduler/solveProject'; +import { + fromExtCalendar, fromExtProject, fromExtSequence, fromExtTask, toExtTask, +} from '@/extensions/extMappers'; +import { readIFCWithXerReconstruction } from '@/services/formatRegistry'; +import { readIFC } from '@/services/ifc/ifcReader'; +import { writeIFC } from '@/services/ifc/ifcWriter'; +import { isMultiDocumentImport, type ImportResult } from '@/services/importTypes'; +import { readXER } from '@/services/xer/xerReader'; +import type { WorkCalendar } from '@/types/calendar'; +import { usesP6CompletedDataDateWindow } from '@/utils/p6CompletedTargetWindow'; +import { buildXerTargetBaseline, type XerCorpusFile, type XerCorpusManifest, type XerSolvedProject } from './xerFidelity'; +import { scanXerGroundTruth, XER_FIDELITY_AXES, type XerFidelityAxis } from './xerGroundTruth'; +import { parseInstant } from '@/utils/dateUtils'; +import { + measureXerProductFidelity, + type XerProductAxisCounts, + type XerProductMeasurement, + type XerProductProjectMeasurement, +} from './xerProductFidelity'; +import { + canonicalProductEnvelope, + sealProductBaseline, + selectProductReportMode, + validateProductBaselineV2, + type ProductBaselineV2, + type ProductEntryV2, +} from './xerProductBaselineV2'; + +const HERE = fileURLToPath(new URL('.', import.meta.url)); +const REPORT = process.env.OPS_XER_FIDELITY_REPORT; +const REPORT_MODES = new Set(['baseline', 'detail', 'summary', 'counterfactuals']); +const diffs: string[] = []; +let checks = 0; + +type ProductBaselineEntry = ProductEntryV2; +type ProductBaseline = ProductBaselineV2; +type ProductBaselineEntryDraft = Omit & { + projectMeasurements: Array; + projectProjectionSha256?: string; +}; + +interface CounterfactualReport { + mode: 'historical-completed-late' | 'source-day-precision'; + strictGateEligible: false; + counters: Record; + drivingPath: XerProductAxisCounts; + identityErrors: number; + scannerErrors: number; +} + +/** De complete 34-entry/47-projectsnapshot is compact opgeslagen, maar wordt hier altijd volledig + * uitgepakt vóór vergelijking. Alleen v2 met zichtbare rode overgangsstatus is leesbaar. Eigen + * bestand (`xer-product-fidelity-baseline-v2.json`) naast de losstaande v1-cel-regressiebaseline + * (`xer-product-fidelity-baseline.json`, bewaakt door `check-xer-product-fidelity.ts`) — de twee + * schema's botsten hier ooit stil onder één bestandsnaam, zie `check-xer-fidelity-baseline-schema.ts`. */ +function readProductBaseline(): ProductBaseline { + const raw = readFileSync(join(HERE, 'xer-product-fidelity-baseline-v2.json'), 'utf8'); + const validated = validateProductBaselineV2(raw); + if (!validated.payload || validated.problems.length > 0) { + throw new Error(`X12 productbaseline faalt gedeelde runtime-schemavalidatie: ${validated.problems.join('; ')}`); + } + return validated.payload; +} + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); +} +/** T2 (XER-etappeplan §4/T2, mutatiebewijs positieve helft): bewijst dat GEEN van de gegeven + * gemuteerde bak-4-orakelwaarden ook maar ergens in `Task.time` van de geïmporteerde taken staat — + * precies de kernregel van laag 3 ("opgeslagen uitvoer is meetlat, nooit invoer"), hier getoetst op + * de productfixtures i.p.v. de corpusloze oracle-fixture in `check-xer-recorded-times.ts`. */ +function noRecordedAxisLeak( + tasks: readonly ImportResult['tasks'][number][], + oracleValues: readonly (string | number)[], +): boolean { + // Critreview laag 3, bevinding 9: de vorige vorm vergeleek alleen RAUWE STRINGS. Een lek van een + // gemuteerde FLOAT (`999 * 60 / 540`) naar `Task.time.totalFloat` glipte er dus doorheen, en een + // datum die onderweg van `2040-11-04T08:00` naar `2040-11-04` (of andersom) was genormaliseerd + // eveneens. Nu wordt per waarde het TYPE gerespecteerd en worden datums genormaliseerd tot hun + // instant (met de dag als grovere terugval, zodat een gedegradeerde representatie óók telt). + const key = (value: string): string => { + const t = parseInstant(value).getTime(); + return Number.isNaN(t) ? `s:${value}` : `i:${t}`; + }; + const dayKey = (value: string): string => `d:${value.slice(0, 10)}`; + const isDatum = (value: string): boolean => /^\d{4}-\d{2}-\d{2}/.test(value); + const verboden = new Set(); + for (const value of oracleValues) { + if (typeof value === 'number') { verboden.add(`n:${value}`); continue; } + verboden.add(key(value)); + if (isDatum(value)) verboden.add(dayKey(value)); + } + return tasks.every(task => Object.values(task.time).every(value => { + if (typeof value === 'number') return !verboden.has(`n:${value}`); + if (typeof value !== 'string') return true; + if (isDatum(value)) return !verboden.has(key(value)) && !verboden.has(dayKey(value)); + return !verboden.has(key(value)); + })); +} +function hash(bytes: Uint8Array): string { return createHash('sha256').update(bytes).digest('hex'); } +function xerStructuredRecord( + number: string, + name: string, + fields: string, + children: readonly string[], +): string { + return `(${number}||${name}(${fields})(${children.join('')}))`; +} +function fiveDayCalendarData(start: string, finish: string): string { + const days = [2, 3, 4, 5, 6].map(day => xerStructuredRecord('0', String(day), '', [ + xerStructuredRecord('0', '0', `s|${start}|f|${finish}`, []), + ])); + return xerStructuredRecord('0', 'CalendarData', '', [ + xerStructuredRecord('0', 'DaysOfWeek', '', days), + xerStructuredRecord('0', 'Exceptions', '', []), + ]); +} +function totalDeviations(entry: ProductBaselineEntry): number { + return XER_FIDELITY_AXES.reduce((total, axis) => total + entry.counters[axis].deviations, 0); +} +function copyCounts(counts: XerProductAxisCounts): XerProductAxisCounts { return { ...counts }; } +function summarizeMeasurement(measurement: XerProductMeasurement): Omit { + return { + counters: Object.fromEntries(XER_FIDELITY_AXES.map(axis => [axis, copyCounts(measurement.counters[axis])])) as Record, + drivingPath: copyCounts(measurement.drivingPath), + identityErrors: measurement.identityErrors.length, + scannerErrors: measurement.scannerErrors.length, + }; +} +/** De solver bewaart dagmodus bewust compact; de X12-meetlat vergelijkt dezelfde P6-betekenis per minuut. */ +function canonicalProductMinute(value: string | undefined): string | undefined { + return value?.match(/^\d{4}-\d{2}-\d{2}$/) ? `${value}T00:00` : value; +} +eq('X12 canonicaliseert uitsluitend datumrepresentatie naar de P6-minuut', + canonicalProductMinute('2026-01-01'), '2026-01-01T00:00'); +function listXerFiles(dir: string): string[] { + const found: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const path = join(dir, entry.name); + if (entry.isDirectory()) found.push(...listXerFiles(path)); + else if (entry.isFile() && entry.name.toLowerCase().endsWith('.xer')) found.push(path); + } + return found; +} + +/** Alleen echte TASK-bladen, met float op de effectieve taak-kalender in P6-minuten. */ +function solveImported(imported: ImportResult): XerSolvedProject { + const cpm = solveProject({ + tasks: imported.tasks, sequences: imported.sequences, calendar: imported.calendar, + calendars: imported.resourceCalendars ?? [], dataDate: imported.project.statusDate, + progressMode: imported.project.progressMode, schedulingOptions: imported.project.schedulingOptions, + projectStartDate: imported.project.startDate, projectEndDate: imported.project.endDate, + }); + if (cpm.error) throw new Error(`${imported.project.id}: ${cpm.error}`); + const calendars = new Map([[imported.calendar.id, imported.calendar], ...(imported.resourceCalendars ?? []) + .map(calendar => [calendar.id, calendar] as const)]); + const floatMinutesPerDay = new Map([...calendars].map(([id, calendar]) => [ + id, + new CalendarEngine(calendar).hoursPerDay * 60, + ])); + return { + projectId: imported.project.id, + tasks: imported.tasks.filter(task => task.p6ActivityType !== undefined).map(task => { + const calendar = (task.calendarId ? calendars.get(task.calendarId) : undefined) ?? imported.calendar; + const minutesPerDay = floatMinutesPerDay.get(calendar.id)!; + return { + sourceTaskId: task.id, taskCode: task.wbsCode, + earlyStart: canonicalProductMinute(task.time.earlyStart), earlyFinish: canonicalProductMinute(task.time.earlyFinish), + lateStart: canonicalProductMinute(task.time.lateStart), lateFinish: canonicalProductMinute(task.time.lateFinish), + totalFloatMinutes: task.time.totalFloat * minutesPerDay, + freeFloatMinutes: task.time.freeFloat * minutesPerDay, + drivingPath: task.time.isCritical, + }; + }), + }; +} + +/** Productprojecties uit X4b-baselines; bronidentiteit komt uitsluitend uit de importpayload. */ +function materializedBaselineProjects(imported: ImportResult): XerSolvedProject[] { + return (imported.baselines ?? []).flatMap((baseline) => { + if (!baseline.sourceProjectId) return []; + return [{ + projectId: baseline.sourceProjectId, + tasks: baseline.tasks.map(task => ({ + sourceTaskId: task.sourceTaskId ?? task.taskId, + taskCode: task.sourceTaskCode ?? '', + earlyStart: canonicalProductMinute(task.start), + earlyFinish: canonicalProductMinute(task.finish), + })), + }]; + }); +} + +/** Alle geopende én als baseline gematerialiseerde productuitkomsten, één keer per bronproject. */ +function solveProductProjects(imports: readonly ImportResult[]): XerSolvedProject[] { + const byProjectId = new Map(); + for (const imported of imports) { + const opened = solveImported(imported); + byProjectId.set(opened.projectId, opened); + } + for (const imported of imports) { + for (const baseline of materializedBaselineProjects(imported)) { + if (!byProjectId.has(baseline.projectId)) byProjectId.set(baseline.projectId, baseline); + } + } + return [...byProjectId.values()]; +} + +/** + * Tegenfeit 1: voor alleen volledig voltooide bronactiviteiten met twee echte actuals worden + * LS/LF als historische actuals herleid. Dit is een diagnose-experiment, nooit een nieuwe + * waarheid of solverinvoer. Ontbreekt één bronfeit, dan blijft de strict-orakelcel onaangeraakt. + */ +function historicalCompletedLateTruth( + truth: ReturnType, + imports: readonly ImportResult[], +): ReturnType { + const actuals = new Map(); + for (const imported of imports) { + for (const task of imported.tasks) { + if (task.time.completion !== 1 || !task.time.actualStart || !task.time.actualFinish) continue; + const lateStart = canonicalProductMinute(task.time.actualStart); + const lateFinish = canonicalProductMinute(task.time.actualFinish); + if (!lateStart || !lateFinish) continue; + actuals.set(`${task.p6ProjectId ?? imported.project.id}\u0000${task.p6TaskId ?? task.id}`, { lateStart, lateFinish }); + } + } + return { + ...truth, + tasks: truth.tasks.map(task => { + const actual = actuals.get(`${task.projectId}\u0000${task.taskId}`); + if (!actual) return task; + return { ...task, axes: { ...task.axes, ls: actual.lateStart, lf: actual.lateFinish } }; + }), + }; +} + +/** + * Tegenfeit 2: alleen een aantoonbaar middernacht-orakel mag in deze rapportage een same-day + * datumafwijking herclassificeren. Strict blijft onveranderd minuutexact en gebruikt dit nooit. + */ +function sourceDayPrecisionReport(measurement: XerProductMeasurement): Omit { + const report = summarizeMeasurement(measurement); + for (const delta of measurement.detail) { + if (delta.axis === 'drivingPath' || delta.bucket !== 'sameday' || typeof delta.truth !== 'string') continue; + if (!delta.truth.endsWith('T00:00')) continue; + const counts = report.counters[delta.axis]; + counts.exact++; + counts.sameday--; + counts.deviations--; + } + return report; +} + +function counterfactualReports( + strict: XerProductMeasurement, + truth: ReturnType, + imports: readonly ImportResult[], + solved: readonly XerSolvedProject[], +): CounterfactualReport[] { + const historical = measureXerProductFidelity(historicalCompletedLateTruth(truth, imports), solved); + const inputs = { + strictMinuteExact: summarizeMeasurement(strict), + historicalCompletedLate: summarizeMeasurement(historical), + sourceDayPrecision: sourceDayPrecisionReport(strict), + }; + return (['historical-completed-late', 'source-day-precision'] as const).map(mode => { + const selected = selectProductReportMode(mode, inputs); + return { mode, strictGateEligible: false, ...selected.report }; + }); +} + +// De drie modi zijn semantisch niet verwisselbaar: alleen strict is een poort, de twee andere +// zijn expliciet gelabelde analyses met verschillende bronvoorwaarden. +{ + const truth = scanXerGroundTruth(new TextEncoder().encode([ + '%T\tTASK', + '%F\tproj_id\ttask_id\ttask_code\tstatus_code\tearly_start_date\tearly_end_date\tlate_start_date\tlate_end_date\ttotal_float_hr_cnt\tfree_float_hr_cnt', + '%R\tP\t1\tA\tTK_Complete\t2026-01-01 00:00\t2026-01-01 17:00\t2026-01-01 08:00\t2026-01-01 17:00\t0\t0', + '%E', + ].join('\n'))); + const solved: XerSolvedProject[] = [{ projectId: 'P', tasks: [{ + sourceTaskId: '1', taskCode: 'A', earlyStart: '2026-01-01T08:00', earlyFinish: '2026-01-01T17:00', + lateStart: '2026-01-02T08:00', lateFinish: '2026-01-02T17:00', totalFloatMinutes: 0, freeFloatMinutes: 0, + }] }]; + const strict = measureXerProductFidelity(truth, solved); + const reports = counterfactualReports(strict, truth, [{ + project: { id: 'P' }, tasks: [{ id: '1', time: { + completion: 1, actualStart: '2026-01-02T08:00', actualFinish: '2026-01-02T17:00', + } }], + }] as unknown as ImportResult[], solved); + eq('X12 strict en beide tegenfeitelijke rapportmodi zijn gelabeld en niet poortgeschikt', { + strictGate: strict.gatePassed, + modes: reports.map(report => [report.mode, report.strictGateEligible]), + strict: { es: strict.counters.es.deviations, ls: strict.counters.ls.deviations, lf: strict.counters.lf.deviations }, + historical: { es: reports[0]?.counters.es.deviations, ls: reports[0]?.counters.ls.deviations, lf: reports[0]?.counters.lf.deviations }, + dayPrecision: { es: reports[1]?.counters.es.deviations, ls: reports[1]?.counters.ls.deviations }, + }, { + strictGate: false, + modes: [['historical-completed-late', false], ['source-day-precision', false]], + strict: { es: 1, ls: 1, lf: 1 }, + historical: { es: 1, ls: 0, lf: 0 }, + dayPrecision: { es: 0, ls: 1 }, + }); + const nonMidnightTruth = { + ...truth, + tasks: truth.tasks.map(task => ({ ...task, axes: { ...task.axes, es: '2026-01-01T08:00' } })), + }; + const nonMidnightSolved = solved.map(project => ({ ...project, tasks: project.tasks.map(task => ({ + ...task, earlyStart: '2026-01-01T09:00', + })) })); + const nonMidnightStrict = measureXerProductFidelity(nonMidnightTruth, nonMidnightSolved); + eq('X12 dagprecisie accepteert nooit een bronas met niet-middernachttijd', + sourceDayPrecisionReport(nonMidnightStrict).counters.es.deviations, 1); +} + +async function productBaseline( + corpus: readonly XerCorpusFile[], + manifest: XerCorpusManifest, +): Promise { + const target = buildXerTargetBaseline(corpus, manifest); + if (target.errors.length > 0) throw new Error(`X1-manifest/grondwaarheid faalt: ${target.errors.join('; ')}`); + const byLabel = new Map(corpus.map(file => [file.label, file])); + const files: Record = {}; + for (const targetEntry of Object.values(target.baseline.files).sort((a, b) => a.label.localeCompare(b.label))) { + const file = byLabel.get(targetEntry.label); + if (!file) throw new Error(`geselecteerde X1-entry ontbreekt: ${targetEntry.label}`); + const opened = readXER(file.bytes); + const imports = isMultiDocumentImport(opened) ? opened.taskProjects.map(document => document.result) : [opened]; + const solvedProjects = solveProductProjects(imports); + const truth = scanXerGroundTruth(file.bytes); + const result = measureXerProductFidelity(truth, solvedProjects); + if (REPORT === undefined && targetEntry.label === 'crawl-xer/p6diff-baseline.xer') { + const publicTask = solvedProjects.flatMap(project => project.tasks) + .find(task => task.sourceTaskId === '1010'); + eq('publieke p6diff-baseline taak A1010 eindigt op P6-bandeinde 17:00', + publicTask?.earlyFinish, '2026-04-07T17:00'); + } + if (REPORT === undefined + && targetEntry.label === 'crawl-xer-extra/jailaff-xer-splitter/rehab-2.xer') { + const publicTask = solvedProjects.flatMap(project => project.tasks) + .find(task => task.taskCode === 'V000040'); + const earlierFinishMilestone = solvedProjects.flatMap(project => project.tasks) + .find(task => task.taskCode === 'V000030'); + eq('publieke rehab-2 TT_FinMile bewaart P6 start-/vorige-finishgrens', { + earlyStart: publicTask?.earlyStart, + earlyFinish: publicTask?.earlyFinish, + }, { + earlyStart: '2010-05-02T08:00', + earlyFinish: '2010-05-01T17:00', + }); + eq('publieke rehab-2 open TT_FinMile ankert late datums op zijn eigen vroege grens', { + lateStart: earlierFinishMilestone?.lateStart, + lateFinish: earlierFinishMilestone?.lateFinish, + totalFloatMinutes: earlierFinishMilestone?.totalFloatMinutes, + }, { + lateStart: '2009-04-29T08:00', + lateFinish: '2009-04-28T17:00', + totalFloatMinutes: 0, + }); + const holidayWindowTask = solvedProjects.flatMap(project => project.tasks) + .find(task => task.taskCode === 'V3153490'); + eq('publieke rehab-2 taak V3153490 rekent P6-kalenderanomalieën in late datums en finish-float', { + lateStart: holidayWindowTask?.lateStart, + lateFinish: holidayWindowTask?.lateFinish, + totalFloatMinutes: holidayWindowTask?.totalFloatMinutes, + freeFloatMinutes: holidayWindowTask?.freeFloatMinutes, + }, { + lateStart: '2009-11-16T08:00', + lateFinish: '2009-12-12T17:00', + totalFloatMinutes: 48480, + freeFloatMinutes: 6720, + }); + const runningRemainingTask = solvedProjects.flatMap(project => project.tasks) + .find(task => task.taskCode === 'V3101180'); + eq('publieke rehab-2 lopende taak toont de resterende vroege start, niet de historische actual start', + runningRemainingTask?.earlyStart, '2008-05-27T08:00'); + const finishLagBoundaryTask = solvedProjects.flatMap(project => project.tasks) + .find(task => task.taskCode === 'V3102370'); + eq('publieke rehab-2 FF+2d-taak bewaart de P6-finishgrens', { + lateStart: finishLagBoundaryTask?.lateStart, + lateFinish: finishLagBoundaryTask?.lateFinish, + }, { + lateStart: '2009-12-28T08:00', + lateFinish: '2010-01-05T17:00', + }); + } + if (REPORT === undefined && targetEntry.label === 'crawl-xer/gimmer-crag-mountain-refuge.xer') { + const publicTask = solvedProjects.flatMap(project => project.tasks) + .find(task => task.taskCode === 'ACT-3'); + eq('publieke Gimmer-taak ACT-3 bewaart de geplande FS-start op de voorganger-finishgrens', { + earlyStart: publicTask?.earlyStart, + earlyFinish: publicTask?.earlyFinish, + }, { + earlyStart: '2025-06-19T17:00', + earlyFinish: '2025-06-26T17:00', + }); + } + if (REPORT === undefined && targetEntry.label === 'crawl-xer/hb-intel_Project_Schedule.xer') { + const zeroActivity = solvedProjects.flatMap(project => project.tasks) + .find(task => task.taskCode === 'B01-019'); + eq('publieke hb-intel nulduuractiviteit bewaart P6 start-/vorige-finishgrenzen', { + earlyStart: zeroActivity?.earlyStart, + earlyFinish: zeroActivity?.earlyFinish, + lateStart: zeroActivity?.lateStart, + lateFinish: zeroActivity?.lateFinish, + }, { + earlyStart: '2026-08-06T08:00', + earlyFinish: '2026-08-05T17:00', + lateStart: '2026-08-06T08:00', + lateFinish: '2026-08-05T17:00', + }); + } + if (REPORT === undefined && targetEntry.label === 'crawl-xer/Roads_Project_TEC.xer') { + const publicTask = solvedProjects.flatMap(project => project.tasks) + .find(task => task.taskCode === 'A10500'); + const effectiveCalendarFloat = solvedProjects.flatMap(project => project.tasks) + .find(task => task.taskCode === 'A14610'); + eq('publieke Roads-taak A10500 behoudt voltooide P6-actuals op middernacht', { + earlyStart: publicTask?.earlyStart, + earlyFinish: publicTask?.earlyFinish, + }, { + earlyStart: '2013-01-19T00:00', + earlyFinish: '2013-01-26T00:00', + }); + eq('publieke Roads-taak A14610 zet float om met de afgeleide effectieve kalenderdag', + effectiveCalendarFloat?.totalFloatMinutes, 8520); + } + if (REPORT === undefined + && targetEntry.label === 'P6-Viewer/XER Files/TERMINAL BUILDING-AIRPORT.xer') { + const publicTask = solvedProjects.flatMap(project => project.tasks) + .find(task => task.taskCode === 'A3450'); + const publicStart = solvedProjects.flatMap(project => project.tasks) + .find(task => task.taskCode === 'A1010'); + const publicEnd = solvedProjects.flatMap(project => project.tasks) + .find(task => task.taskCode === 'A5800'); + eq('publieke Terminal-taak A3450 gebruikt PROJECT.plan_end_date voor de late floatzijde', { + lateFinish: publicTask?.lateFinish, + totalFloatMinutes: publicTask?.totalFloatMinutes, + startMilestoneLateStart: publicStart?.lateStart, + endMilestoneFreeFloatMinutes: publicEnd?.freeFloatMinutes, + }, { + lateFinish: '2013-02-28T17:00', + totalFloatMinutes: 480, + startMilestoneLateStart: '2012-05-01T08:00', + endMilestoneFreeFloatMinutes: 0, + }); + } + if (REPORT === 'detail') { + console.log(`. ${targetEntry.label}: projecten ${result.truthProjects}/${result.solvedProjects}; taken ${result.truthTasks}/${result.solvedTasks}`); + for (const axis of XER_FIDELITY_AXES) console.log(`. ${axis} ${JSON.stringify(result.counters[axis])}`); + for (const item of result.detail) console.log(`. ${item.projectId}/${item.taskCode} ${item.axis}: ${item.bucket}; p6=${JSON.stringify(item.truth)} ops=${JSON.stringify(item.ours)}`); + for (const error of result.errors) console.log(`. IDENTITEIT ${error}`); + } + if (REPORT === 'counterfactuals') { + const reports = counterfactualReports(result, truth, imports, solvedProjects); + const strictReport = selectProductReportMode('strict-minute-exact', { + strictMinuteExact: summarizeMeasurement(result), + historicalCompletedLate: reports[0]!, + sourceDayPrecision: reports[1]!, + }); + console.log(JSON.stringify({ + label: targetEntry.label, + strict: { mode: strictReport.mode, strictGateEligible: strictReport.strictGateEligible, ...strictReport.report }, + counterfactuals: reports, + })); + } + const fileSha256 = hash(file.bytes); + files[fileSha256] = { + sha256: fileSha256, schemaFingerprint: targetEntry.schemaFingerprint ?? '', + projects: result.truthProjects, tasks: result.truthTasks, + identityCoverage: { + solvedTasks: result.solvedTasks, + taskCodePresent: result.projects.reduce((sum, project) => sum + project.taskCodePresent, 0), + taskCodeExact: result.projects.reduce((sum, project) => sum + project.taskCodeExact, 0), + }, + projectMeasurements: result.projects, + counters: result.counters, drivingPath: result.drivingPath, + identityErrors: result.identityErrors, + scannerErrors: result.scannerErrors, + gatePassed: result.gatePassed, + }; + } + return sealProductBaseline({ + version: 2, + manifestSha256: hash(readFileSync(join(HERE, 'xer-corpus-manifest.json'))), + characterization: { + finalZeroGate: 'red', + accepted: false, + openCategories: ['strict-six-axis-deviations', 'strict-sameday-deviations', 'driving-path-report-only'], + }, + files, + }); +} + +// Corpusloze RED-/GREEN-probe voor de vier expliciete productbakken. +{ + const truth = scanXerGroundTruth(new TextEncoder().encode([ + '%T\tTASK', + '%F\tproj_id\ttask_id\ttask_code\tstatus_code\tearly_start_date\tearly_end_date\tlate_start_date\tlate_end_date\ttotal_float_hr_cnt\tfree_float_hr_cnt', + '%R\tP\t1\tA\tTK_NotStart\t2026-01-01 08:00\t2026-01-01 17:00\t\t\t1\t', '%E', + ].join('\n'))); + const measured = measureXerProductFidelity(truth, [{ projectId: 'P', tasks: [{ + sourceTaskId: '1', taskCode: 'A', earlyStart: '2026-01-01T08:00', earlyFinish: '2026-01-01T08:00', totalFloatMinutes: 60, + }] }]); + eq('X12 vierbakken houden exact, diff en meetbare missing gescheiden', measured.counters, { + es: { exact: 1, sameday: 0, diff: 0, missing: 0, measurable: 1, deviations: 0 }, + ef: { exact: 0, sameday: 1, diff: 0, missing: 0, measurable: 1, deviations: 1 }, + ls: { exact: 0, sameday: 0, diff: 0, missing: 0, measurable: 0, deviations: 0 }, + lf: { exact: 0, sameday: 0, diff: 0, missing: 0, measurable: 0, deviations: 0 }, + tf: { exact: 1, sameday: 0, diff: 0, missing: 0, measurable: 1, deviations: 0 }, + ff: { exact: 0, sameday: 0, diff: 0, missing: 0, measurable: 0, deviations: 0 }, + }); +} + +// De veldlijst alleen inventariseren is onvoldoende: alle verboden TASK-resultaatkolommen moeten +// operationeel non-interferent zijn. De onafhankelijke scanner moet juist wél op de zes orakelassen +// en driving reageren, terwijl readerinvoer en productsolve bytegelijk blijven. +{ + const forbiddenFields = [ + 'early_start_date', 'early_end_date', 'late_start_date', 'late_end_date', + 'restart_date', 'reend_date', 'rem_late_start_date', 'rem_late_end_date', + 'total_float_hr_cnt', 'free_float_hr_cnt', 'driving_path_flag', + 'float_path', 'float_path_order', + 'old_restart_date', 'old_reend_date', 'old_remain_drtn_hr_cnt', 'crt_path_num', + 'critical_drtn_hr_cnt', 'act_drtn_hr_cnt', 'plan_start_date', 'plan_end_date', + ] as const; + const dateFields = new Set([ + 'early_start_date', 'early_end_date', 'late_start_date', 'late_end_date', + 'restart_date', 'reend_date', 'rem_late_start_date', 'rem_late_end_date', + 'old_restart_date', 'old_reend_date', + 'plan_start_date', 'plan_end_date', + ]); + const primaryValues: Record = { + early_start_date: '2026-01-05 08:00', early_end_date: '2026-01-05 16:00', + late_start_date: '2026-01-06 08:00', late_end_date: '2026-01-06 16:00', + total_float_hr_cnt: '8', free_float_hr_cnt: '4', driving_path_flag: 'Y', + }; + const taskPrefixFields = [ + 'task_id', 'proj_id', 'clndr_id', 'task_code', 'task_name', 'task_type', 'duration_type', + 'status_code', 'target_drtn_hr_cnt', 'remain_drtn_hr_cnt', 'target_start_date', 'target_end_date', + ]; + const statusCases = [ + { id: 'not-started', status: 'TK_NotStart', actualStart: '', actualFinish: '', remaining: '8' }, + { id: 'active', status: 'TK_Active', actualStart: '2026-01-05 08:00', actualFinish: '', remaining: '4' }, + { id: 'complete', status: 'TK_Complete', actualStart: '2026-01-05 08:00', actualFinish: '2026-01-05 16:00', remaining: '0' }, + ] as const; + const makeBytes = (statusCase: typeof statusCases[number], absurd: boolean) => new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-01-05\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tStandard 5x8\tCA_Base\t8\t40\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date', + '%R\tP\tNon-interference\tC1\t2026-01-05 08:00\t2026-01-05 08:00', + '%T\tTASK', + `%F\t${[...taskPrefixFields, 'act_start_date', 'act_end_date', ...forbiddenFields].join('\t')}`, + `%R\t${[ + 'T1', 'P', 'C1', 'A100', `Non-interference ${statusCase.id}`, 'TT_Task', 'DT_FixedDUR', + statusCase.status, '8', statusCase.remaining, '2026-01-05 08:00', '2026-01-05 16:00', + statusCase.actualStart, statusCase.actualFinish, + ...forbiddenFields.map((field, index) => absurd + ? field === 'driving_path_flag' ? 'N' + : dateFields.has(field) ? `2099-12-${String((index % 20) + 1).padStart(2, '0')} 23:59` + : String(-1000 - index) + : primaryValues[field] ?? (dateFields.has(field) ? '2026-01-07 12:34' : String(index + 1))), + ].join('\t')}`, + '%E', + ].join('\n')); + const solverInput = (input: ImportResult) => ({ + project: { + startDate: input.project.startDate, endDate: input.project.endDate, + statusDate: input.project.statusDate, progressMode: input.project.progressMode, + schedulingOptions: input.project.schedulingOptions, + }, + calendar: input.calendar, + resourceCalendars: input.resourceCalendars ?? [], + tasks: input.tasks, + sequences: input.sequences, + }); + const axes = (input: ImportResult) => { + const solved = solveImported(input).tasks; + return solved.map(task => [task.taskCode, task.earlyStart, task.earlyFinish, + task.lateStart, task.lateFinish, task.totalFloatMinutes, task.freeFloatMinutes]); + }; + for (const statusCase of statusCases) { + const normalBytes = makeBytes(statusCase, false); + const absurdBytes = makeBytes(statusCase, true); + const normal = readXER(normalBytes); + const absurd = readXER(absurdBytes); + if (isMultiDocumentImport(normal) || isMultiDocumentImport(absurd)) { + throw new Error('X12 forbidden-fieldfixture moet enkelproject zijn'); + } + eq(`X12 ${statusCase.id}: verboden stored output is non-interferent voor import/solverinvoer`, + solverInput(absurd), solverInput(normal)); + eq(`X12 ${statusCase.id}: verboden stored output is non-interferent voor zes productassen`, + axes(absurd), axes(normal)); + const normalTruth = scanXerGroundTruth(normalBytes); + const absurdTruth = scanXerGroundTruth(absurdBytes); + eq(`X12 ${statusCase.id}: scannertruth reageert wel op zes raw stored assen`, { + normalErrors: normalTruth.errors, + absurdErrors: absurdTruth.errors, + sixAxesChanged: XER_FIDELITY_AXES.every(axis => + normalTruth.tasks[0]?.axes[axis] !== absurdTruth.tasks[0]?.axes[axis]), + drivingChanged: normalTruth.tasks[0]?.drivingPath !== absurdTruth.tasks[0]?.drivingPath, + }, { + normalErrors: [], absurdErrors: [], sixAxesChanged: true, drivingChanged: true, + }); + + const ifcNormal = await readIFCWithXerReconstruction(writeIFC(normal)); + const ifcAbsurd = await readIFCWithXerReconstruction(writeIFC(absurd)); + eq(`X12 ${statusCase.id}: XER→solve→IFC→solve laat forbidden output niet teruglekken`, + axes(ifcAbsurd), axes(ifcNormal)); + + const extNormal: ImportResult = { ...normal, tasks: normal.tasks.map(task => fromExtTask(toExtTask(task))) }; + const extAbsurd: ImportResult = { ...absurd, tasks: absurd.tasks.map(task => fromExtTask(toExtTask(task))) }; + eq(`X12 ${statusCase.id}: volledige ExtTask/TaskTime-round-trip laat forbidden output niet teruglekken`, + axes(extAbsurd), axes(extNormal)); + } + + const makeExternalProxyBytes = (absurd: boolean) => new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-01-05\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tStandard 5x8\tCA_Base\t8\t40\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date', + '%R\tP\tExternal proxy\tC1\t2026-01-05 08:00\t2026-01-05 08:00', + '%T\tTASK', + `%F\t${[...taskPrefixFields, 'external_early_start_date', 'external_late_end_date'].join('\t')}`, + `%R\t${[ + 'T1', 'P', 'C1', 'A100', 'External proxy', 'TT_Task', 'DT_FixedDUR', + 'TK_NotStart', '8', '8', '2026-01-05 08:00', '2026-01-05 16:00', + absurd ? '2099-12-01 23:59' : '2026-01-04 08:00', + absurd ? '2099-12-31 23:59' : '2026-01-07 16:00', + ].join('\t')}`, + '%E', + ].join('\n')); + const externalNormal = readXER(makeExternalProxyBytes(false)); + const externalAbsurd = readXER(makeExternalProxyBytes(true)); + if (isMultiDocumentImport(externalNormal) || isMultiDocumentImport(externalAbsurd)) { + throw new Error('X12 external-proxyfixture moet enkelproject zijn'); + } + eq('X12 unsupported external-dependency proxy/input is bewust non-interferent voor reader/solver', + solverInput(externalAbsurd), solverInput(externalNormal)); + eq('X12 unsupported external-dependency proxy/input is bewust non-interferent voor solve-resultaat', + axes(externalAbsurd), axes(externalNormal)); +} + +// Minuutprecisie betekent letterlijk minuutprecisie: 08:00 en 08:01 zijn niet "binnen een +// minuut" gelijk. Deze corpusloze rij houdt alle andere assen exact, zodat uitsluitend ES rood is. +{ + const truth = scanXerGroundTruth(new TextEncoder().encode([ + '%T\tTASK', + '%F\tproj_id\ttask_id\ttask_code\tstatus_code\tearly_start_date\tearly_end_date\tlate_start_date\tlate_end_date\ttotal_float_hr_cnt\tfree_float_hr_cnt', + '%R\tP\tMIN\tMIN\tTK_NotStart\t2026-01-01 08:01\t2026-01-01 16:00\t2026-01-01 08:00\t2026-01-01 16:00\t0\t0', + '%E', + ].join('\n'))); + const measured = measureXerProductFidelity(truth, [{ projectId: 'P', tasks: [{ + sourceTaskId: 'MIN', taskCode: 'MIN', earlyStart: '2026-01-01T08:00', + earlyFinish: '2026-01-01T16:00', lateStart: '2026-01-01T08:00', + lateFinish: '2026-01-01T16:00', totalFloatMinutes: 0, freeFloatMinutes: 0, + }] }]); + eq('X12 08:00 versus 08:01 geeft exact één minuutafwijking', { + total: XER_FIDELITY_AXES.reduce((sum, axis) => sum + measured.counters[axis].deviations, 0), + es: measured.counters.es, + }, { + total: 1, + es: { exact: 0, sameday: 1, diff: 0, missing: 0, measurable: 1, deviations: 1 }, + }); +} + +// Fractionele P6-minuten blijven exacte getallen: de comparator mag geen halve minuut afronden. +{ + const bytes = new TextEncoder().encode([ + '%T\tTASK', + '%F\tproj_id\ttask_id\ttask_code\tstatus_code\tearly_start_date\tearly_end_date\tlate_start_date\tlate_end_date\ttotal_float_hr_cnt\tfree_float_hr_cnt', + '%R\tP\tPOS\tPOS\tTK_NotStart\t2026-01-01 08:00:30\t2026-01-01 16:00\t2026-01-01 08:00\t2026-01-01 16:00\t0.125\t-0.125', + '%E', + ].join('\n')); + const truth = scanXerGroundTruth(bytes); + const exact = measureXerProductFidelity(truth, [{ projectId: 'P', tasks: [{ + sourceTaskId: 'POS', taskCode: 'POS', + earlyStart: '2026-01-01T08:00', earlyFinish: '2026-01-01T16:00', + lateStart: '2026-01-01T08:00', lateFinish: '2026-01-01T16:00', + totalFloatMinutes: 7.5, freeFloatMinutes: -7.5, + }] }]); + eq('X12 fractionele minuten vergelijken exact en subminuutdatum blijft apart gepind', { + tf: exact.counters.tf, ff: exact.counters.ff, es: exact.counters.es, + precision: truth.precision, + }, { + tf: { exact: 1, sameday: 0, diff: 0, missing: 0, measurable: 1, deviations: 0 }, + ff: { exact: 1, sameday: 0, diff: 0, missing: 0, measurable: 1, deviations: 0 }, + es: { exact: 1, sameday: 0, diff: 0, missing: 0, measurable: 1, deviations: 0 }, + precision: { + dateSecondCells: { es: 1, ef: 0, ls: 0, lf: 0 }, + dateNonZeroSubminuteCells: { es: 1, ef: 0, ls: 0, lf: 0 }, + floatFractionalMinuteCells: { tf: 1, ff: 1 }, + }, + }); + const rounded = measureXerProductFidelity(truth, [{ projectId: 'P', tasks: [{ + sourceTaskId: 'POS', taskCode: 'POS', + earlyStart: '2026-01-01T08:00', earlyFinish: '2026-01-01T16:00', + lateStart: '2026-01-01T08:00', lateFinish: '2026-01-01T16:00', + totalFloatMinutes: 8, freeFloatMinutes: -7, + }] }]); + eq('X12 integerafronding van halve minuten is hard rood', { + tf: rounded.counters.tf.deviations, ff: rounded.counters.ff.deviations, + }, { tf: 1, ff: 1 }); +} + +// Productmeetlat: missing/identiteit zijn harde, afzonderlijke kanalen; driving blijft bewust +// rapportage buiten de zesassige nulpoort. +{ + const bytes = new TextEncoder().encode([ + '%T\tTASK', + '%F\tproj_id\ttask_id\ttask_code\tstatus_code\tearly_start_date\tearly_end_date\tlate_start_date\tlate_end_date\ttotal_float_hr_cnt\tfree_float_hr_cnt\tdriving_path_flag', + '%R\tP\t1\tA\tTK_NotStart\t2026-01-01 08:00\t2026-01-01 16:00\t2026-01-01 08:00\t2026-01-01 16:00\t0\t0\tY', + '%E', + ].join('\n')); + const truth = scanXerGroundTruth(bytes); + const exactTask = { + sourceTaskId: '1', taskCode: 'A', earlyStart: '2026-01-01T08:00', + earlyFinish: '2026-01-01T16:00', lateStart: '2026-01-01T08:00', + lateFinish: '2026-01-01T16:00', totalFloatMinutes: 0, freeFloatMinutes: 0, + drivingPath: false, + }; + const missing = measureXerProductFidelity(truth, [{ projectId: 'P', tasks: [{ + ...exactTask, earlyStart: undefined, + }] }]); + eq('X12 truth aanwezig plus ours ontbreekt telt meetbaar missing en deviation', missing.counters.es, + { exact: 0, sameday: 0, diff: 0, missing: 1, measurable: 1, deviations: 1 }); + + const errorCases = { + extraProject: measureXerProductFidelity(truth, [ + { projectId: 'P', tasks: [exactTask] }, { projectId: 'Q', tasks: [] }, + ]).errors, + missingProject: measureXerProductFidelity(truth, []).errors, + duplicateProject: measureXerProductFidelity(truth, [ + { projectId: 'P', tasks: [exactTask] }, { projectId: 'P', tasks: [exactTask] }, + ]).errors, + extraTask: measureXerProductFidelity(truth, [{ projectId: 'P', tasks: [ + exactTask, { ...exactTask, sourceTaskId: '2', taskCode: 'B' }, + ] }]).errors, + missingTask: measureXerProductFidelity(truth, [{ projectId: 'P', tasks: [] }]).errors, + duplicateTask: measureXerProductFidelity(truth, [{ projectId: 'P', tasks: [exactTask, exactTask] }]).errors, + emptyCode: measureXerProductFidelity(truth, [{ projectId: 'P', tasks: [{ ...exactTask, taskCode: '' }] }]).errors, + wrongCode: measureXerProductFidelity(truth, [{ projectId: 'P', tasks: [{ ...exactTask, taskCode: 'B' }] }]).errors, + }; + eq('X12 project-/taakidentiteitsfouten blijven hard en afzonderlijk benoemd', errorCases, { + extraProject: ['extra project Q'], + missingProject: ['ontbrekend project P', 'project P: ontbrekende taak 1'], + duplicateProject: ['dubbel opgelost project P'], + extraTask: ['project P: extra taak 2'], + missingTask: ['project P: ontbrekende taak 1'], + duplicateTask: ['project P: dubbele opgeloste taak-id 1'], + emptyCode: ['project P/taak 1: taskCode is leeg'], + wrongCode: ['project P/taak 1: code verwacht A, kreeg B'], + }); + const invalidDateTruth = scanXerGroundTruth(new TextEncoder().encode([ + '%T\tTASK', + '%F\tproj_id\ttask_id\ttask_code\tstatus_code\tearly_start_date', + '%R\tP\t1\tA\tTK_NotStart\tgeen-datum', + '%E', + ].join('\n'))); + const separatedErrors = measureXerProductFidelity(invalidDateTruth, [{ + projectId: 'P', tasks: [{ sourceTaskId: '1', taskCode: 'A' }], + }]); + eq('X12 scanner- en identiteitsfouten blijven afzonderlijke harde kanalen', { + scannerErrors: separatedErrors.scannerErrors, + identityErrors: separatedErrors.identityErrors, + gatePassed: separatedErrors.gatePassed, + }, { + scannerErrors: ['TASK 1/early_start_date: ongeldige datum "geen-datum"'], + identityErrors: [], + gatePassed: false, + }); + const drivingOnly = measureXerProductFidelity(truth, [{ projectId: 'P', tasks: [exactTask] }]); + eq('X12 driving-path wijkt apart af maar blokkeert de zesassige nulpoort niet', { + drivingPath: drivingOnly.drivingPath, + sixAxisDeviations: XER_FIDELITY_AXES.reduce( + (sum, axis) => sum + drivingOnly.counters[axis].deviations, 0), + gatePassed: drivingOnly.gatePassed, + }, { + drivingPath: { exact: 0, sameday: 0, diff: 1, missing: 0, measurable: 1, deviations: 1 }, + sixAxisDeviations: 0, + gatePassed: true, + }); +} + +// X-O2/X4b-contract: een uitgesloten baseline-PROJECT blijft via `project.baselines` een echte +// productuitkomst. De meetadapter mag daarom niet alleen de geopende documentresultaten tellen. +// Deze fixture bevat bewust geen P6-rekenuitvoer; hij beschermt uitsluitend bronidentiteit. +{ + const bytes = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tsum_base_proj_id\tplan_start_date', + '%R\tP-MAIN\tHuidig\tP-BASE\t2026-01-01 08:00', + '%R\tP-BASE\tBaseline\t\t2025-12-01 08:00', + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date\ttotal_float_hr_cnt\tfree_float_hr_cnt', + '%R\tM-1\tP-MAIN\tMAIN-100\tHuidige taak\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-01-01 08:00\t2026-01-01 16:00\t0\t0', + '%R\tB-1\tP-BASE\tBASE-100\tBaselinetaak\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2025-12-01 08:00\t2025-12-01 16:00\t0\t0', + '%E', + ].join('\n')); + const opened = readXER(bytes); + if (!isMultiDocumentImport(opened)) throw new Error('X12-baselinefixture moet meervoudig importeren'); + const truth = scanXerGroundTruth(bytes); + const baselineIdentity = measureXerProductFidelity( + truth, + solveProductProjects(opened.results), + ); + const measured = measureXerProductFidelity( + truth, + solveProductProjects(opened.taskProjects.map(document => document.result)), + ); + eq('X12 productadapter telt gematerialiseerd baselineproject en brontaakidentiteit', { + truthProjects: measured.truthProjects, + solvedProjects: measured.solvedProjects, + truthTasks: measured.truthTasks, + solvedTasks: measured.solvedTasks, + baselineIdentityErrors: baselineIdentity.errors, + errors: measured.errors, + tf: measured.counters.tf, + ff: measured.counters.ff, + }, { + truthProjects: 2, + solvedProjects: 2, + truthTasks: 2, + solvedTasks: 2, + baselineIdentityErrors: [], + errors: [], + tf: { exact: 2, sameday: 0, diff: 0, missing: 0, measurable: 2, deviations: 0 }, + ff: { exact: 2, sameday: 0, diff: 0, missing: 0, measurable: 2, deviations: 0 }, + }); +} + +// Bronsemantische readerprobe voor de finishmijlpaalgrens. Positief is uitsluitend de gesloten +// TT_FinMile-vorm op bandstart+1; een gewone taak en een andere minuut zijn negatieve controles. +{ + const calendarData = fiveDayCalendarData('08:00', '16:00'); + const bytes = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-01-05\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + `%R\tC1\tGrenskalender\tCA_Base\t8\t40\t${calendarData}`, + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date', + '%R\tP\tMijlpaalgrens\tC1\t2026-01-05 08:00\t2026-01-05 08:00', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date', + '%R\tF\tP\tC1\tF\tFinishgrens\tTT_FinMile\tDT_FixedDUR2\tTK_NotStart\t0\t0\t2026-01-05 08:01\t2026-01-05 08:01', + '%R\tN\tP\tC1\tN\tAndere minuut\tTT_FinMile\tDT_FixedDUR2\tTK_NotStart\t0\t0\t2026-01-05 08:02\t2026-01-05 08:02', + '%R\tT\tP\tC1\tT\tGewone taak\tTT_Task\tDT_FixedDUR2\tTK_NotStart\t0\t0\t2026-01-05 08:01\t2026-01-05 08:01', + '%E', + ].join('\n')); + const imported = readXER(bytes); + if (isMultiDocumentImport(imported)) throw new Error('X12 finishgrens-readerfixture moet enkelproject zijn'); + const byCode = (code: string) => imported.tasks.find(task => task.wbsCode === code)?.time; + eq('X12 TT_FinMile-correctie is positief en negatief bronmatig begrensd', { + positive: [byCode('F')?.scheduleStart, byCode('F')?.scheduleFinish], + otherMinute: [byCode('N')?.scheduleStart, byCode('N')?.scheduleFinish], + ordinaryTask: [byCode('T')?.scheduleStart, byCode('T')?.scheduleFinish], + }, { + positive: ['2026-01-05T08:00', '2026-01-02T16:00'], + otherMinute: ['2026-01-05T08:02', '2026-01-05T08:02'], + ordinaryTask: ['2026-01-05T08:01', '2026-01-05T08:01'], + }); +} + +// Bronsemantische duurprobe voor lege CALENDAR.clndr_data: een hele-dag-anker maakt de band +// afleidbaar; een fractionele slotdag mag dan het duurvenster herstellen, een breed venster niet. +{ + const bytes = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-01-05\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tLege bronkalender\tCA_Base\t8\t40\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date', + '%R\tP\tDuurvenster\tC1\t2026-01-05 08:00\t2026-01-05 08:00', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date', + '%R\tA\tP\tC1\tA\tBandanker\tTT_Task\tDT_FixedDUR2\tTK_NotStart\t8\t8\t2026-01-05 08:00\t2026-01-05 16:00', + '%R\tF\tP\tC1\tF\tFractionele slotdag\tTT_Task\tDT_FixedDUR2\tTK_NotStart\t11\t11\t2026-01-05 08:00\t2026-01-06 12:00', + '%R\tW\tP\tC1\tW\tBreed doelvenster\tTT_Task\tDT_FixedDUR2\tTK_NotStart\t8\t8\t2026-01-05 08:00\t2026-01-09 16:00', + '%E', + ].join('\n')); + const imported = readXER(bytes); + if (isMultiDocumentImport(imported)) throw new Error('X12 duurvensterfixture moet enkelproject zijn'); + const duration = (code: string) => imported.tasks.find(task => task.wbsCode === code)?.time.durationMinutes; + eq('X12 targetvenster herstelt alleen de afleidbare fractionele slotdag', { + anchor: duration('A'), fractional: duration('F'), wide: duration('W'), + }, { anchor: 480, fractional: 720, wide: 480 }); +} + +// Corpusloze P6 case 09: een historische, voltooide opvolger is géén late-pass-eindpunt voor +// een nog open voorganger. Deze invoer volgt uitsluitend de invoerzijde van de P6-23.12-capture +// (projectstart/statusdatum, FS, actuals). BELANGRIJK voor D3/D4: de capture normaliseert bij een +// gestarte taak ACT_START naar ES én LS, en bij een voltooide taak ACT_END naar EF én LF. De B- +// verwachting hieronder beschermt dus de captureweergave en de backward-doorwerking op A, maar is +// UITDRUKKELIJK GEEN raw-XER-bewijs dat P6 late datums algemeen op actuals zet. Raw-XER-orakels +// (zoals rehab-2) blijven de enige meetlat voor die afzonderlijke semantiek. +{ + const calendarData = fiveDayCalendarData('08:00', '17:00'); + const bytes = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-01-05\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + `%R\tC1\tP6 5x9\tCA_Base\t9\t45\t${calendarData}`, + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date', + '%R\tP\tP6 case 09\tC1\t2026-01-05 08:00\t2025-12-01 08:00', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\tact_start_date\tact_end_date', + '%R\tA\tP\tC1\tA100\tOpen voorganger\tTT_Task\tDT_FixedDUR2\tTK_NotStart\t45\t45\t\t', + '%R\tB\tP\tC1\tB100\tVoltooide opvolger\tTT_Task\tDT_FixedDUR2\tTK_Complete\t99\t0\t2025-12-15 08:00\t2025-12-30 17:00', + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%R\tR1\tB\tA\tP\tP\tPR_FS\t0', + '%E', + ].join('\n')); + const imported = readXER(bytes); + if (isMultiDocumentImport(imported)) throw new Error('X12 P6 case-09-fixture moet enkelproject zijn'); + eq('X12 P6 case 09: statusdatumfallback is geen expliciet TASK-window', + imported.tasks.find(task => task.wbsCode === 'B100')?.p6ExplicitTargetWindow, undefined); + const solved = solveImported(imported).tasks; + const open = solved.find(task => task.taskCode === 'A100'); + const completed = solved.find(task => task.taskCode === 'B100'); + eq('X12 P6 case 09: historical completed successor releases the predecessor late pass', { + open: [open?.earlyStart, open?.earlyFinish, open?.lateStart, open?.lateFinish, + open?.totalFloatMinutes, open?.freeFloatMinutes], + completed: [completed?.earlyStart, completed?.earlyFinish, completed?.lateStart, completed?.lateFinish], + }, { + open: ['2026-01-05T08:00', '2026-01-09T17:00', '2026-01-05T08:00', '2026-01-09T17:00', 0, 0], + completed: ['2025-12-15T08:00', '2025-12-30T17:00', '2025-12-15T08:00', '2025-12-30T17:00'], + }); +} + +// X12 late-passfixture: P6 behandelt een verbonden, open TT_FinMile als een eigen +// contracteindpunt. LS/LF blijven op de geplande start-/vorige-finishgrens, terwijl FF nog steeds +// de vrije ruimte tot het latere projecteinde meet. Een losse finishmijlpaal mag deze bronregel niet +// activeren; de echte voorgangerverbinding is onderdeel van het contract. + +// D1/D2 gebruiken dezelfde korte P6-FS-keten. P6's opgeslagen rekenuitvoer komt nergens in deze +// invoer voor; ES/EF/LS/LF/TF/FF worden uitsluitend door de productsolver bepaald. De finishmijlpaal +// maakt de start-/finishgrens zichtbaar zonder een tweede, niet-gerelateerde eindtak als projectanker. +{ + const calendarData = fiveDayCalendarData('08:00', '17:00'); + function d1Bytes(withScheduleOptions: boolean): Uint8Array { + return new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-01-05\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + `%R\tC1\tD1 5x9\tCA_Base\t9\t45\t${calendarData}`, + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date\tplan_end_date', + '%R\tP\tD1 einde als floatgrens\tC1\t2026-01-05 08:00\t2026-01-05 08:00\t2026-01-09 17:00', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date', + '%R\tA\tP\tC1\tA100\tFS-voorganger\tTT_Task\tDT_FixedDUR2\tTK_NotStart\t9\t9\t2026-01-05 08:00\t2026-01-05 17:00', + '%R\tM\tP\tC1\tM100\tFinishmijlpaal\tTT_FinMile\tDT_FixedDUR2\tTK_NotStart\t0\t0\t2026-01-06 08:01\t2026-01-06 08:01', + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%R\tR1\tM\tA\tP\tP\tPR_FS\t0', + ...(withScheduleOptions ? [ + '%T\tSCHEDOPTIONS', + '%F\tproj_id\tsched_float_type\tsched_use_project_end_date_for_float', + '%R\tP\tFT_FF\tY', + ] : []), + '%E', + ].join('\n')); + } + const without = readXER(d1Bytes(false)); + const explicit = readXER(d1Bytes(true)); + if (isMultiDocumentImport(without) || isMultiDocumentImport(explicit)) { + throw new Error('X12 D1 moet tweemaal enkelproject importeren'); + } + const defaultTasks = solveImported(without).tasks; + const explicitTasks = solveImported(explicit).tasks; + const pick = (tasks: XerSolvedProject['tasks'], code: string) => tasks.find(task => task.taskCode === code); + const defaultPredecessor = pick(defaultTasks, 'A100'); + const defaultMilestone = pick(defaultTasks, 'M100'); + const explicitPredecessor = pick(explicitTasks, 'A100'); + const explicitMilestone = pick(explicitTasks, 'M100'); + // D1: alleen de expliciete P6-einddatumschakelaar mag het PROJECT-einde als late-pass-anker maken. + eq('X12 D1: expliciete P6-projecteindfloat stuurt de verbonden finishmijlpaal en FS-voorganger', { + options: [explicit.project.schedulingOptions?.totalFloatMode, + explicit.project.schedulingOptions?.useProjectEndDateForFloat], + explicitPredecessor: [explicitPredecessor?.lateStart, explicitPredecessor?.lateFinish, + explicitPredecessor?.totalFloatMinutes, explicitPredecessor?.freeFloatMinutes], + explicitMilestone: [explicitMilestone?.lateStart, explicitMilestone?.lateFinish, + explicitMilestone?.totalFloatMinutes, explicitMilestone?.freeFloatMinutes], + }, { + options: ['finish', true], + explicitPredecessor: ['2026-01-09T08:00', '2026-01-09T17:00', 2160, 0], + explicitMilestone: ['2026-01-09T17:00', '2026-01-09T17:00', 2160, 0], + }); + // D2 volgt pas na D1: FT_FF is al de XER-default, maar de afwezige end-date-vlag houdt + // PROJECT.plan_end_date nadrukkelijk buiten de late pass. Daardoor is de vergelijking geen + // impliciete omschakeling naar OPS' algemene `smallest`-modus. + eq('X12 D2: zonder SCHEDOPTIONS blijft FT_FF actief maar is de projecteindfloat uit', { + defaultOptions: [without.project.schedulingOptions?.totalFloatMode, + without.project.schedulingOptions?.useProjectEndDateForFloat], + defaultPredecessor: [defaultPredecessor?.lateStart, defaultPredecessor?.lateFinish, + defaultPredecessor?.totalFloatMinutes, defaultPredecessor?.freeFloatMinutes], + defaultMilestone: [defaultMilestone?.lateStart, defaultMilestone?.lateFinish, + defaultMilestone?.totalFloatMinutes, defaultMilestone?.freeFloatMinutes], + differsFromD1: defaultPredecessor?.lateFinish !== explicitPredecessor?.lateFinish, + }, { + defaultOptions: ['finish', undefined], + defaultPredecessor: ['2026-01-05T08:00', '2026-01-05T17:00', 0, 0], + defaultMilestone: ['2026-01-06T08:00', '2026-01-05T17:00', 0, 0], + differsFromD1: true, + }); +} + +{ + const bytes = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-04-01\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tStandard 5x8\tCA_Base\t8\t40\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date', + '%R\tP\tOpen eindmijlpaal\tC1\t2026-04-01 08:00\t2026-04-01 08:00', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date', + '%R\t1\tP\tC1\tA100\tVoorganger\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-04-01 08:00\t2026-04-01 17:00', + '%R\t2\tP\tC1\tA200\tContracteinde\tTT_FinMile\tDT_FixedDUR\tTK_NotStart\t0\t0\t2026-04-02 08:01\t2026-04-02 08:01', + '%R\t3\tP\tC1\tA300\tLater projecteinde\tTT_FinMile\tDT_FixedDUR\tTK_NotStart\t0\t0\t2026-04-10 08:01\t2026-04-10 08:01', + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%R\tR1\t2\t1\tP\tP\tPR_FS\t0', + '%E', + ].join('\n')); + const imported = readXER(bytes); + if (isMultiDocumentImport(imported)) throw new Error('X12 open-eindfixture moet enkelproject zijn'); + const task = solveImported(imported).tasks.find(candidate => candidate.taskCode === 'A200'); + eq('X12 verbonden open TT_FinMile houdt late grens maar behoudt vrije ruimte tot projecteinde', { + lateStart: task?.lateStart, + lateFinish: task?.lateFinish, + totalFloatMinutes: task?.totalFloatMinutes, + freeFloatMinutes: task?.freeFloatMinutes, + }, { + lateStart: '2026-04-02T08:00', + lateFinish: '2026-04-01T17:00', + totalFloatMinutes: 0, + freeFloatMinutes: 2880, + }); + const noSource = readXER(bytes); + const explicitOff = readXER(bytes); + if (isMultiDocumentImport(noSource) || isMultiDocumentImport(explicitOff)) { + throw new Error('X12 finishmijlpaal-provenancefixture moet enkelproject zijn'); + } + delete noSource.project.schedulingOptions?.p6Source; + delete explicitOff.project.schedulingOptions?.p6Source; + if (explicitOff.project.schedulingOptions) { + explicitOff.project.schedulingOptions.p6FinishMilestoneBoundaryWindow = false; + } + const noSourceTask = solveImported(noSource).tasks.find(candidate => candidate.taskCode === 'A200'); + const explicitOffTask = solveImported(explicitOff).tasks.find(candidate => candidate.taskCode === 'A200'); + eq('X12 p6FinishMilestoneBoundaryWindow is inert zonder XER-projectprovenance', { + noSource: [noSourceTask?.lateStart, noSourceTask?.lateFinish, noSourceTask?.totalFloatMinutes], + explicitOff: [explicitOffTask?.lateStart, explicitOffTask?.lateFinish, explicitOffTask?.totalFloatMinutes], + differsFromProven: noSourceTask?.lateFinish !== task?.lateFinish, + }, { + noSource: [explicitOffTask?.lateStart, explicitOffTask?.lateFinish, explicitOffTask?.totalFloatMinutes], + explicitOff: [explicitOffTask?.lateStart, explicitOffTask?.lateFinish, explicitOffTask?.totalFloatMinutes], + differsFromProven: true, + }); +} + +// Ook taakvloer en exact constraint-instant zijn uitsluitend P6-XER-projecties. Een gewone +// solver/IFC-payload die alleen gelijknamige booleans bevat, maar geen bronstempel, moet exact het +// expliciet-uitgeschakelde gedrag houden. +{ + const calendarData = fiveDayCalendarData('08:00', '16:00'); + const bytes = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + `%R\tC1\tStandard 5x8\tCA_Base\t8\t40\t${calendarData}`, + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date', + '%R\tP\tP6 taakprovenance\tC1\t2026-01-01 08:00\t2026-01-01 08:00', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date\tcstr_type\tcstr_date', + '%R\tPRED\tP\tC1\tPRED\tVoorganger\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-01-01 08:00\t2026-01-01 16:00\t\t', + '%R\tFLOOR\tP\tC1\tFLOOR\tGeplande vloer\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-01-05 08:00\t2026-01-05 16:00\t\t', + '%R\tMILE\tP\tC1\tMILE\tExacte grens\tTT_Mile\tDT_FixedDUR\tTK_NotStart\t0\t0\t2026-01-08 08:00\t2026-01-08 08:00\tCS_MSOB\t2026-01-08 08:00', + '%R\tLATE\tP\tC1\tLATE\tLater einde\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-01-12 08:00\t2026-01-12 16:00\t\t', + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%R\tR1\tFLOOR\tPRED\tP\tP\tPR_FS\t0', + '%E', + ].join('\n')); + const proven = readXER(bytes); + const noSource = readXER(bytes); + const explicitOff = readXER(bytes); + if (isMultiDocumentImport(proven) || isMultiDocumentImport(noSource) + || isMultiDocumentImport(explicitOff)) throw new Error('X12 taakprovenancefixture moet enkelproject zijn'); + delete noSource.project.schedulingOptions?.p6Source; + delete explicitOff.project.schedulingOptions?.p6Source; + if (explicitOff.project.schedulingOptions) { + explicitOff.project.schedulingOptions.p6UseTaskPlannedStartFloor = false; + explicitOff.project.schedulingOptions.p6PreserveZeroDurationConstraintInstants = false; + } + const resultOf = (input: ImportResult) => { + const solved = solveImported(input).tasks; + const floor = solved.find(task => task.taskCode === 'FLOOR'); + const milestone = solved.find(task => task.taskCode === 'MILE'); + return [floor?.earlyStart, milestone?.lateStart, milestone?.lateFinish]; + }; + const provenResult = resultOf(proven); + const noSourceResult = resultOf(noSource); + const offResult = resultOf(explicitOff); + eq('X12 taakvloer en nulduurconstraint vereisen XER-projectprovenance', { + proven: provenResult, + noSource: noSourceResult, + explicitOff: offResult, + }, { + proven: ['2026-01-05T08:00', '2026-01-08T08:00', '2026-01-08T08:00'], + noSource: offResult, + explicitOff: offResult, + }); + const hostileExt = readXER(bytes); + if (isMultiDocumentImport(hostileExt)) throw new Error('X12 extensiesolvefixture moet enkelproject zijn'); + const hostilePredecessor = hostileExt.tasks.find(task => task.wbsCode === 'PRED'); + const hostileSourceTask = hostileExt.tasks.find(task => task.wbsCode === 'FLOOR'); + const hostileSequence = hostileExt.sequences.find(sequence => sequence.successorId === hostileSourceTask?.id); + if (!hostilePredecessor || !hostileSourceTask || !hostileSequence) { + throw new Error('X12 extensiesolvefixture mist PRED → FLOOR'); + } + // Dit object stelt rechtstreeks een ongetypeerde JS-extensieruntime voor. Er loopt bewust geen + // `toExtProject`/`toExtTask` vóór: die uitleesmappers zouden de vervalste invoer al saneren en + // daarmee precies de from-extensiongrens maskeren die deze fixture moet bewaken. + const hostileRuntimeProject = { + ...hostileExt.project, + schedulingOptions: { + ...hostileExt.project.schedulingOptions, + p6Source: 'XER', + p6UseTaskPlannedStartFloor: true, + p6PreserveZeroDurationConstraintInstants: true, + }, + } as unknown as Parameters[0]; + const hostileRuntimeTask = { + ...hostileSourceTask, + time: { + ...hostileSourceTask.time, + completion: 0.5, + actualStart: '2026-01-01T08:00', + actualFinish: undefined, + remainingTime: 1, + remainingMinutes: 480, + resume: '2026-01-06T08:00', + stop: undefined, + }, + p6DurationType: 'DT_FixedDUR2', + p6ActivityType: 'TT_Rsrc', + p6ProjectId: 'FORGED-PROJECT', + p6TaskId: 'FORGED-TASK', + p6CompletePctType: 'CP_Phys', + p6ExpectedFinish: '2026-01-30T17:00', + p6SuspendResume: true, + } as unknown as Parameters[0]; + const importedHostileTask = fromExtTask(hostileRuntimeTask); + const genericExtensionImport: ImportResult = { + ...hostileExt, + project: fromExtProject(hostileRuntimeProject), + calendar: fromExtCalendar({ ...hostileExt.calendar } as Parameters[0]), + // De echte netwerkrelatie blijft bewust in de hostile invoer. Zonder PRED → FLOOR is de + // planned-start-floor niet te onderscheiden van een gewone worteltaakstart en meet deze + // fixture een objectvorm in plaats van de solveruitkomst die door de vervalste vlag wijzigt. + tasks: [ + fromExtTask({ ...hostilePredecessor } as Parameters[0]), + importedHostileTask, + ], + sequences: [fromExtSequence({ ...hostileSequence } as Parameters[0])], + }; + const hostileSolve = solveProject({ + tasks: genericExtensionImport.tasks, + sequences: genericExtensionImport.sequences, + calendar: genericExtensionImport.calendar, + calendars: genericExtensionImport.resourceCalendars ?? [], + dataDate: genericExtensionImport.project.statusDate, + progressMode: genericExtensionImport.project.progressMode, + schedulingOptions: genericExtensionImport.project.schedulingOptions, + projectStartDate: genericExtensionImport.project.startDate, + projectEndDate: genericExtensionImport.project.endDate, + }); + if (hostileSolve.error) throw new Error(`X12 hostile extensiesolve faalt: ${hostileSolve.error}`); + const hostileSolvedTask = hostileSolve.tasks.get(importedHostileTask.id); + eq('X12 generieke extensie-import kan interne P6-opties niet via de echte solve activeren', { + projectSource: genericExtensionImport.project.schedulingOptions?.p6Source, + plannedStartFloor: genericExtensionImport.project.schedulingOptions?.p6UseTaskPlannedStartFloor, + taskProvenance: { + p6DurationType: importedHostileTask.p6DurationType, + p6ActivityType: importedHostileTask.p6ActivityType, + p6ProjectId: importedHostileTask.p6ProjectId, + p6TaskId: importedHostileTask.p6TaskId, + p6CompletePctType: importedHostileTask.p6CompletePctType, + p6ExpectedFinish: importedHostileTask.p6ExpectedFinish, + p6SuspendResume: importedHostileTask.p6SuspendResume, + }, + solvedEarlyStart: hostileSolvedTask?.earlyStart, + solvedEarlyFinish: hostileSolvedTask?.earlyFinish, + appliedEarlyStart: hostileSolvedTask?.earlyStart, + }, { + projectSource: undefined, + plannedStartFloor: undefined, + taskProvenance: {}, + // Zonder vervalste P6-bronstempel blijft de planned-start-floor inert. De generieke solver + // kiest hier zijn gewone project-/netwerkvenster; een mutatie die `fromExtProject` met een + // objectspread laat terugschrijven activeert de P6-vloer en maakt precies deze solve-assert + // rood (FLOOR schuift dan naar zijn geplande 5 januari-anker). + solvedEarlyStart: '2026-01-01T08:00', + solvedEarlyFinish: '2026-01-06T16:00', + appliedEarlyStart: '2026-01-01T08:00', + }); +} + +// Negatieve bronprobe voor de geplande startvloer: een doelstart precies één kalenderdag na een +// geldige netwerkgrens is geen impliciete constraint. Alleen de ruimere positieve vorm hierboven +// (begin én einde meer dan één dag later) mag de XER-vloer activeren. +{ + const calendarData = fiveDayCalendarData('08:00', '16:00'); + const bytes = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-01-05\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + `%R\tC1\tStartvloer\tCA_Base\t8\t40\t${calendarData}`, + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date', + '%R\tP\tStartvloergrens\tC1\t2026-01-05 08:00\t2026-01-05 08:00', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date', + '%R\tM\tP\tC1\tM\tNetwerkpunt\tTT_Mile\tDT_FixedDUR2\tTK_NotStart\t0\t0\t2026-01-05 08:00\t2026-01-05 08:00', + '%R\tN\tP\tC1\tN\tVolgende dag is geen vloer\tTT_Task\tDT_FixedDUR2\tTK_NotStart\t8\t8\t2026-01-06 08:00\t2026-01-06 16:00', + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%R\tR1\tN\tM\tP\tP\tPR_FS\t0', + '%E', + ].join('\n')); + const imported = readXER(bytes); + if (isMultiDocumentImport(imported)) throw new Error('X12 startvloer-negatief moet enkelproject zijn'); + const successor = solveImported(imported).tasks.find(task => task.taskCode === 'N'); + eq('X12 geplande startvloer blijft uit op de één-daggrens', { + source: imported.project.schedulingOptions?.p6Source, + option: imported.project.schedulingOptions?.p6UseTaskPlannedStartFloor, + earlyStart: successor?.earlyStart, + }, { source: 'XER', option: true, earlyStart: '2026-01-05T08:00' }); +} + +// P6-XER gebruikt voor een lopende activiteit de start van het resterende werk als Early Start. +// Actual Start blijft bronhistorie, maar mag de zesassige resterende netwerkdatum niet vervangen. +// De vlag komt uitsluitend uit het XER-importpad; dezelfde solver zonder vlag houdt zijn bestaande +// MSP/IFC-actual-weergave. +{ + const bytes = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-01-12\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tStandard 5x8\tCA_Base\t8\t40\t(0||CalendarData()((0||DaysOfWeek()((0||1()())(0||2()((0||0(s|08:00|f|12:00)())(0||1(s|13:00|f|17:00)())))(0||3()((0||0(s|08:00|f|12:00)())(0||1(s|13:00|f|17:00)())))(0||4()((0||0(s|08:00|f|12:00)())(0||1(s|13:00|f|17:00)())))(0||5()((0||0(s|08:00|f|12:00)())(0||1(s|13:00|f|17:00)())))(0||6()((0||0(s|08:00|f|12:00)())(0||1(s|13:00|f|17:00)())))(0||7()())))(0||Exceptions())))', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\trem_target_link_flag\tlast_recalc_date\tplan_start_date', + '%R\tP\tResterend werk\tC1\tY\t2026-01-12 08:00\t2026-01-05 08:00', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\tcomplete_pct_type\tphys_complete_pct\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\tact_start_date\ttarget_start_date\ttarget_end_date', + '%R\t1\tP\tC1\tA100\tLopende taak\tTT_Task\tDT_FixedDUR2\tTK_Active\tCP_Phys\t50\t24\t16\t2026-01-06 08:00\t2026-01-05 08:00\t2026-01-07 17:00', + '%R\t2\tP\tC1\tB100\tOpvolger\tTT_Task\tDT_FixedDUR2\tTK_NotStart\tCP_Phys\t0\t8\t8\t\t2026-01-08 08:00\t2026-01-08 17:00', + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%R\tR1\t2\t1\tP\tP\tPR_FS\t0', + '%E', + ].join('\n')); + const imported = readXER(bytes); + if (isMultiDocumentImport(imported)) throw new Error('X12 resterend-werkfixture moet enkelproject zijn'); + const task = solveImported(imported).tasks.find(candidate => candidate.taskCode === 'A100'); + eq('X12 lopende XER-taak gebruikt resterende start op de statusdatum voor ES en LS', { + earlyStart: task?.earlyStart, + lateStart: task?.lateStart, + }, { + earlyStart: '2026-01-12T08:00', + lateStart: '2026-01-12T08:00', + }); + const unlinkedBytes = new TextEncoder().encode( + new TextDecoder().decode(bytes).replace('\tY\t2026-01-12 08:00', '\tN\t2026-01-12 08:00'), + ); + const unlinked = readXER(unlinkedBytes); + if (isMultiDocumentImport(unlinked)) throw new Error('X12 actual-starttegenvoorbeeld moet enkelproject zijn'); + const unlinkedTask = solveImported(unlinked).tasks.find(candidate => candidate.taskCode === 'A100'); + eq('X12 zonder resterend-doelkoppeling blijft de zichtbare Actual Start ongewijzigd', { + sourceFlag: unlinked.project.schedulingOptions?.p6UseRemainingStartForProgress, + earlyStart: unlinkedTask?.earlyStart, + }, { + sourceFlag: false, + earlyStart: '2026-01-06T08:00', + }); +} + +// Afzonderlijke auditgrensprobe. De oude gecombineerde completed-chainfixture had ongeldige +// `clndr_data`: de reader meldde XER_CALENDAR_INVALID_STRUCTURE en viel gedocumenteerd terug op +// 08:00-16:00. Daardoor ontstond 16:00; dat was geen finish-inclusiviteits- of late-passfout. +// Deze fixture bewijst uitsluitend dat één effectieve dag op een syntactisch geldige 08:00-17:00- +// kalender exact op 17:00 eindigt. Completed-successorsemantiek blijft onafhankelijk gepind door +// de gehashte echte P6-capture in cases-p6-verified.json geval 09 en check-p6-verified-cases.ts. +{ + const calendarData = fiveDayCalendarData('08:00', '17:00'); + const xerLines = [ + 'ERMHDR\t23.12\t2026-01-05\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + `%R\tC1\tStandard 5x9\tCA_Base\t9\t45\t${calendarData}`, + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date', + '%R\tP\tFinishgrensprobe\tC1\t2026-01-05 08:00\t2026-01-05 08:00', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date', + '%R\t1\tP\tC1\tA100\tEen effectieve dag\tTT_Task\tDT_FixedDUR2\tTK_NotStart\t9\t9\t2026-01-05 08:00\t2026-01-05 17:00', + '%E', + ]; + const bytes = new TextEncoder().encode(xerLines.join('\n')); + const imported = readXER(bytes); + if (isMultiDocumentImport(imported)) throw new Error('X12 finishgrensfixture moet enkelproject zijn'); + const engine = new CalendarEngine(imported.calendar); + const start = new Date('2026-01-05T08:00:00Z'); + const solved = solveImported(imported).tasks.find(task => task.taskCode === 'A100'); + eq('X12 geldige negenuurskalender bewaart de 17:00-finishgrens', { + bands: engine.effectiveBandsOn(start), + effectiveDayMinutes: engine.hoursPerDay * 60, + calendarFinish: engine.addWorkMinutes(start, engine.hoursPerDay * 60).toISOString().slice(0, 16), + productEarlyFinish: solved?.earlyFinish, + }, { + bands: [{ start: 480, end: 1020 }], + effectiveDayMinutes: 540, + calendarFinish: '2026-01-05T17:00', + productEarlyFinish: '2026-01-05T17:00', + }); + + // Mutatiebewijs: breek alleen de kalenderstructuur terug naar de oude recordvorm. De reader + // activeert dan aantoonbaar de 08:00-16:00-fallback en uitsluitend deze grensprobe verliest 17:00. + const malformedCalendarData = '(0||CalendarData()((0||DaysOfWeek()((0||2()((0||0(s|08:00|f|17:00)())))(0||3()((0||0(s|08:00|f|17:00)())))(0||4()((0||0(s|08:00|f|17:00)())))(0||5()((0||0(s|08:00|f|17:00)())))(0||6()((0||0(s|08:00|f|17:00)())))))(0||Exceptions())))'; + const malformedBytes = new TextEncoder().encode( + xerLines.join('\n').replace(calendarData, malformedCalendarData), + ); + const malformed = readXER(malformedBytes); + if (isMultiDocumentImport(malformed)) throw new Error('X12 gemuteerde finishgrensfixture moet enkelproject zijn'); + const malformedEngine = new CalendarEngine(malformed.calendar); + eq('X12 kalenderstructuurmutatie maakt precies de 17:00-grensprobe rood', { + bands: malformedEngine.effectiveBandsOn(start), + effectiveDayMinutes: malformedEngine.hoursPerDay * 60, + mutatedFinish: malformedEngine.addWorkMinutes(start, malformedEngine.hoursPerDay * 60) + .toISOString().slice(0, 16), + wouldPassBoundaryProbe: malformedEngine.addWorkMinutes(start, malformedEngine.hoursPerDay * 60) + .toISOString().slice(0, 16) === '2026-01-05T17:00', + }, { + bands: [{ start: 480, end: 960 }], + effectiveDayMinutes: 480, + mutatedFinish: '2026-01-05T16:00', + wouldPassBoundaryProbe: false, + }); +} + +// Een P6-FS met nul lag mag de opvolger exact op de finishgrens van zijn voorganger zetten. +// Dit is niet de algemene halfopen-bandregel: uitsluitend de reader mag de brongebonden vlag +// afleiden uit de geplande P6-invoer. De fixture is de kleine, corpusloze tegenhanger van de +// 17:00-grenzen die in rehab-2 en Gimmer voorkomen. +{ + const calendarData = fiveDayCalendarData('08:00', '17:00'); + const bytes = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-01-05\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + `%R\tC1\tStandard 5x9\tCA_Base\t9\t45\t${calendarData}`, + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date', + '%R\tP\tFS-finishgrens\tC1\t2026-01-05 08:00\t2026-01-05 08:00', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date', + '%R\tA\tP\tC1\tA100\tVoorganger\tTT_Task\tDT_FixedDUR2\tTK_NotStart\t9\t9\t2026-01-05 08:00\t2026-01-05 17:00', + '%R\tB\tP\tC1\tB100\tOpvolger\tTT_Task\tDT_FixedDUR2\tTK_NotStart\t9\t9\t2026-01-05 17:00\t2026-01-06 17:00', + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%R\tR1\tB\tA\tP\tP\tPR_FS\t0', + '%E', + ].join('\n')); + const imported = readXER(bytes); + if (isMultiDocumentImport(imported)) throw new Error('X12 FS-finishgrensfixture moet enkelproject zijn'); + const predecessor = solveImported(imported).tasks.find(task => task.taskCode === 'A100'); + const successor = solveImported(imported).tasks.find(task => task.taskCode === 'B100'); + eq('X12 P6 FS-nul-lag bewaart de gedeelde finish/start-minuut', { + sourceFlag: imported.sequences.find(sequence => sequence.id === 'R1')?.p6StartAtPredecessorFinishBoundary, + predecessorFinish: predecessor?.earlyFinish, + successorStart: successor?.earlyStart, + successorFinish: successor?.earlyFinish, + }, { + sourceFlag: true, + predecessorFinish: '2026-01-05T17:00', + successorStart: '2026-01-05T17:00', + successorFinish: '2026-01-06T17:00', + }); + + // De relatievlag kan in een generieke payload nog aanwezig zijn, maar mag zonder de centrale + // XER-bronstempel geen enkel forward- of backward-pad bereiken. Vergelijk met dezelfde XER + // input waarin uitsluitend de vlag zelf is weggehaald: alle zes taakassen moeten identiek zijn. + const genericPayload = readXER(bytes); + const explicitNoBoundary = readXER(bytes); + if (isMultiDocumentImport(genericPayload) || isMultiDocumentImport(explicitNoBoundary)) { + throw new Error('X12 relatie-firewallfixture moet enkelproject zijn'); + } + delete genericPayload.project.schedulingOptions?.p6Source; + delete explicitNoBoundary.sequences[0]?.p6StartAtPredecessorFinishBoundary; + const relationAxes = (input: ImportResult) => solveImported(input).tasks.map(task => [ + task.taskCode, task.earlyStart, task.earlyFinish, task.lateStart, task.lateFinish, + task.totalFloatMinutes, task.freeFloatMinutes, + ]); + eq('X12 generieke payload met rauwe P6-relatievlag is solver-identiek aan geen vlag', + relationAxes(genericPayload), relationAxes(explicitNoBoundary)); + + // De vlag is niet alleen opgeslagen metadata: na XER → IFC → inlezen moet hij nog steeds de + // exacte 17:00-boundary dragen. Dit maakt de IFC-ronde een datumpariteitscheck, geen velddump. + const roundTripped = await readIFCWithXerReconstruction(writeIFC(imported)); + const roundTripSolve = solveProject({ + tasks: roundTripped.tasks, + sequences: roundTripped.sequences, + calendar: roundTripped.calendar, + calendars: roundTripped.resourceCalendars ?? [], + dataDate: roundTripped.project.statusDate, + progressMode: roundTripped.project.progressMode, + schedulingOptions: roundTripped.project.schedulingOptions, + projectStartDate: roundTripped.project.startDate, + projectEndDate: roundTripped.project.endDate, + }); + if (roundTripSolve.error) throw new Error(roundTripSolve.error); + // XER-bronidentiteiten zijn na IFC bewust niet nodig voor de app-solve; de taaknamen zijn hier + // het stabiele, door IFC bewaarde identificatiemiddel van deze synthetische tweetaaksfixture. + const rtPredecessor = roundTripped.tasks.find(task => task.name === 'Voorganger'); + const rtSuccessor = roundTripped.tasks.find(task => task.name === 'Opvolger'); + eq('X12 XER-IFC-XER bewaart de P6-FS-finishgrens in de echte datumuitkomst', { + relationFlag: roundTripped.sequences[0]?.p6StartAtPredecessorFinishBoundary, + predecessorFinish: rtPredecessor?.time.earlyFinish, + successorStart: rtSuccessor?.time.earlyStart, + successorFinish: rtSuccessor?.time.earlyFinish, + }, { + relationFlag: true, + predecessorFinish: '2026-01-05T17:00', + successorStart: '2026-01-05T17:00', + successorFinish: '2026-01-06T17:00', + }); +} + +// P6 werkdaglag op een finish-finishgrens bewaart de finishklok. Twee effectieve dagen terug vanaf +// woensdag 17:00 is maandag 17:00, niet dinsdag 08:00. Dit is een smalle XER-backwardprojectie; +// de generieke add/subtract-kalenderalgebra en andere formaten blijven fysiek. +{ + const calendarData = fiveDayCalendarData('08:00', '17:00'); + const bytes = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-01-05\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + `%R\tC1\tStandard 5x9\tCA_Base\t9\t45\t${calendarData}`, + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date', + '%R\tP\tFF-lagfinishgrens\tC1\t2026-01-05 08:00\t2026-01-05 08:00', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date', + '%R\t1\tP\tC1\tA100\tVoorganger\tTT_Task\tDT_FixedDUR2\tTK_NotStart\t9\t9\t2026-01-05 08:00\t2026-01-05 17:00', + '%R\t2\tP\tC1\tB100\tOpvolger\tTT_Task\tDT_FixedDUR2\tTK_NotStart\t9\t9\t2026-01-07 08:00\t2026-01-07 17:00', + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%R\tR1\t2\t1\tP\tP\tPR_FF\t18', + '%E', + ].join('\n')); + const imported = readXER(bytes); + if (isMultiDocumentImport(imported)) throw new Error('X12 FF-lagfinishgrensfixture moet enkelproject zijn'); + const tasks = solveImported(imported).tasks; + const predecessor = tasks.find(task => task.taskCode === 'A100'); + const successor = tasks.find(task => task.taskCode === 'B100'); + eq('X12 P6 backward-werkdaglag bewaart de finishgrens', { + predecessorLateFinish: predecessor?.lateFinish, + successorLateFinish: successor?.lateFinish, + }, { + predecessorLateFinish: '2026-01-05T17:00', + successorLateFinish: '2026-01-07T17:00', + }); +} + +// X12 kalender-/late-passfixture: oude P6-XER-kalenders kunnen een vrije dag ook op een al +// niet-werkende weekdag opslaan en dezelfde vrije datum direct naast zichzelf herhalen. P6 telt +// beide bronvormen als een extra niet-werkdag in backward- en floatwandelingen; de forwardzijde +// blijft de fysieke kalender volgen. Een latere, niet-aangrenzende herhaling is alleen schema- +// herhaling en mag die straf niet nogmaals toevoegen. +{ + const bytes = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2025-12-31\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tP6 anomaliekalender\tCA_Base\t\t40\t(0||CalendarData()((0||DaysOfWeek()((0||1()())(0||2()((0||0(s|08:00|f|12:00)())(0||1(s|13:00|f|17:00)())))(0||3()((0||0(s|08:00|f|12:00)())(0||1(s|13:00|f|17:00)())))(0||4()((0||0(s|08:00|f|12:00)())(0||1(s|13:00|f|17:00)())))(0||5()((0||0(s|08:00|f|12:00)())(0||1(s|13:00|f|17:00)())))(0||6()((0||0(s|08:00|f|12:00)())(0||1(s|13:00|f|17:00)())))(0||7()())))(0||Exceptions()((0||0(d|46025)())(0||1(d|46027)())(0||2(d|46027)())(0||3(d|46028)((0||0(s|08:00|f|12:00)())(0||1(s|13:00|f|17:00)())))(0||4(d|46027)())))))', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date', + '%R\tP\tP6 kalenderanomalie\tC1\t2025-12-31 08:00\t2025-12-31 08:00', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date', + '%R\t1\tP\tC1\tA100\tWerk over anomalieën\tTT_Task\tDT_FixedDUR\tTK_NotStart\t32\t32\t2025-12-31 08:00\t2026-01-08 17:00', + '%E', + ].join('\n')); + const imported = readXER(bytes); + if (isMultiDocumentImport(imported)) throw new Error('X12 kalenderanomaliefixture moet enkelproject zijn'); + const engine = new CalendarEngine(imported.calendar); + const start = new Date('2025-12-31T08:00:00Z'); + const finish = new Date('2026-01-08T17:00:00Z'); + const physicalFinish = engine.addWorkMinutes(start, 1920); + eq('X12 generieke kalenderalgebra blijft fysiek en in beide richtingen invers', { + p6Source: imported.calendar.p6Source, + penaltyDates: imported.calendar.p6NonWorkPenaltyDates, + physicalFinish: physicalFinish.toISOString().slice(0, 16), + subtractAdd: engine.subtractWorkMinutes(physicalFinish, 1920).toISOString().slice(0, 16), + addSubtract: engine.addWorkMinutes(engine.subtractWorkMinutes(finish, 1920), 1920) + .toISOString().slice(0, 16), + betweenForward: engine.workMinutesBetween(start, physicalFinish), + betweenBackward: engine.workMinutesBetween(physicalFinish, start), + }, { + p6Source: 'XER', + penaltyDates: ['2026-01-03', '2026-01-05'], + physicalFinish: '2026-01-06T17:00', + subtractAdd: '2025-12-31T08:00', + addSubtract: '2026-01-08T17:00', + betweenForward: 1920, + betweenBackward: -1920, + }); + // Negatieve recordvormen in dezelfde bron: de werkende uitzondering (d|46028 met banden) en + // de latere, niet-aangrenzende herhaling van d|46027 leveren geen derde strafdatum op. + eq('X12 werkende en niet-aangrenzend herhaalde uitzonderingen activeren geen extra P6-straf', + imported.calendar.p6NonWorkPenaltyDates, ['2026-01-03', '2026-01-05']); + + const explicitHoursBytes = new TextEncoder().encode( + new TextDecoder().decode(bytes).replace('\tCA_Base\t\t40\t', '\tCA_Base\t8\t40\t'), + ); + const explicitHours = readXER(explicitHoursBytes); + if (isMultiDocumentImport(explicitHours)) throw new Error('X12 expliciete-dagurenfixture moet enkelproject zijn'); + eq('X12 dezelfde uitzonderingsrecords met expliciete day_hr_cnt krijgen geen afgeleide strafvlag', + explicitHours.calendar.p6NonWorkPenaltyDates, undefined); + + const genericCalendar: WorkCalendar = { ...imported.calendar }; + delete genericCalendar.p6Source; + const genericEngine = new CalendarEngine(genericCalendar); + eq('X12 dezelfde datums zonder XER-provenance kunnen generieke kalenderalgebra niet wijzigen', { + retainedDates: genericCalendar.p6NonWorkPenaltyDates, + added: genericEngine.addWorkMinutes(start, 1920).toISOString().slice(0, 16), + subtracted: genericEngine.subtractWorkMinutes(finish, 1920).toISOString().slice(0, 16), + between: genericEngine.workMinutesBetween(start, finish), + }, { + retainedDates: ['2026-01-03', '2026-01-05'], + added: '2026-01-06T17:00', + subtracted: '2026-01-02T08:00', + between: 2880, + }); + + const projectionBytes = new TextEncoder().encode(new TextDecoder().decode(bytes).replace( + '%R\t1\tP\tC1\tA100\tWerk over anomalieën\tTT_Task\tDT_FixedDUR\tTK_NotStart\t32\t32\t2025-12-31 08:00\t2026-01-08 17:00', + [ + '%R\tA\tP\tC1\tA100\tBackward-projectie\tTT_Task\tDT_FixedDUR\tTK_NotStart\t24\t24\t2025-12-31 08:00\t2026-01-02 17:00', + '%R\tX\tP\tC1\tX100\tVrije-floatprojectie\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2025-12-31 08:00\t2025-12-31 17:00', + '%R\tB\tP\tC1\tB100\tGeplande opvolger\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-01-08 08:00\t2026-01-08 17:00', + '%R\tLP\tP\tC1\tLP100\tLagvoorganger\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2025-12-30 08:00\t2025-12-30 17:00', + '%R\tLS\tP\tC1\tLS100\tLagopvolger\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-01-08 08:00\t2026-01-08 17:00', + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%R\tR-A\tB\tA\tP\tP\tPR_FS\t0', + '%R\tR-X\tB\tX\tP\tP\tPR_FS\t0', + '%R\tR-LAG\tLS\tLP\tP\tP\tPR_FS\t32', + ].join('\n'), + )); + const projectionImport = readXER(projectionBytes); + if (isMultiDocumentImport(projectionImport)) throw new Error('X12 P6-projectiefixture moet enkelproject zijn'); + const projected = solveImported(projectionImport).tasks; + const projectedTask = (code: string) => projected.find(task => task.taskCode === code); + // Etappe 7b-2: de P6-XER-resultaatprojectie is VERWIJDERD. Ze schoof hier de late kant en de lag + // per onverklaarde strafdatum een extra niet-werkdag naar voren (`aLateStart` 2026-01-02 → 12-31, + // `aTotalFloat` 960 → 0, `lagPredecessorLateFinish` 12-31 → 12-29) terwijl de forwardkant fysiek + // bleef — precies de asymmetrie die het diagnosedossier als "7 vooruit, 10 terug" mat. Gemeten + // over alle zes penaltydragende corpusprojecten verklaarde ze nul cellen op alle zes de assen. + // Deze fixture pint nu het omgekeerde: óók met volledige XER-provenance ÉN twee onverklaarde + // strafdatums op de kalender is de backward-, lag- en floatkant de exacte spiegel van de + // forwardkant. `A100` (24 u) spant vooruit 12-31→01-02 en achteruit even lang, en houdt de twee + // werkdagen speling die haar opvolger `B100` haar laat. + eq('X12 er is geen brongebonden projectie meer: backward, lag en float spiegelen de forwardkant', { + projectSource: projectionImport.project.schedulingOptions?.p6Source, + calendarSource: projectionImport.calendar.p6Source, + penaltyDates: projectionImport.calendar.p6NonWorkPenaltyDates, + aEarlyFinish: projectedTask('A100')?.earlyFinish, + aLateStart: projectedTask('A100')?.lateStart, + aTotalFloat: projectedTask('A100')?.totalFloatMinutes, + xTotalFloat: projectedTask('X100')?.totalFloatMinutes, + xFreeFloat: projectedTask('X100')?.freeFloatMinutes, + lagPredecessorLateFinish: projectedTask('LP100')?.lateFinish, + successorEarlyStart: projectedTask('B100')?.earlyStart, + }, { + projectSource: 'XER', + calendarSource: 'XER', + penaltyDates: ['2026-01-03', '2026-01-05'], + aEarlyFinish: '2026-01-02T17:00', + aLateStart: '2026-01-02T08:00', + aTotalFloat: 960, + xTotalFloat: 1920, + xFreeFloat: 1920, + lagPredecessorLateFinish: '2025-12-31T17:00', + successorEarlyStart: '2026-01-08T08:00', + }); + + const sixAxes = (input: ImportResult) => { + const result = solveProject({ + tasks: input.tasks, + sequences: input.sequences, + calendar: input.calendar, + calendars: input.resourceCalendars ?? [], + dataDate: input.project.statusDate, + progressMode: input.project.progressMode, + schedulingOptions: input.project.schedulingOptions, + projectStartDate: input.project.startDate, + projectEndDate: input.project.endDate, + }); + if (result.error) throw new Error(result.error); + return input.tasks.sort((a, b) => a.wbsCode.localeCompare(b.wbsCode)) + .map(task => [task.wbsCode, task.time.earlyStart, task.time.earlyFinish, + task.time.lateStart, task.time.lateFinish, task.time.totalFloat, task.time.freeFloat]); + }; + const xerForIfc = readXER(projectionBytes); + if (isMultiDocumentImport(xerForIfc)) throw new Error('X12 XER-naar-IFC-fixture moet enkelproject zijn'); + // De officiële async ingang reconstrueert de XER-provenance koud uit de IFC-bronbytes. + const ifcRoundTrip = await readIFCWithXerReconstruction(writeIFC(xerForIfc)); + const directForParity = readXER(projectionBytes); + if (isMultiDocumentImport(directForParity)) throw new Error('X12 directe pariteitsfixture moet enkelproject zijn'); + eq('X12 XER-naar-IFC bewaart P6-provenance en alle zes solve-assen', { + projectSource: ifcRoundTrip.project.schedulingOptions?.p6Source, + calendarSource: ifcRoundTrip.calendar.p6Source, + axes: sixAxes(ifcRoundTrip), + }, { + projectSource: 'XER', + calendarSource: 'XER', + axes: sixAxes(directForParity), + }); + + const ordinaryIfcSource = readXER(projectionBytes); + const ordinaryDirect = readXER(projectionBytes); + if (isMultiDocumentImport(ordinaryIfcSource) || isMultiDocumentImport(ordinaryDirect)) { + throw new Error('X12 gewone-IFC-provenancefixture moet enkelproject zijn'); + } + delete ordinaryIfcSource.project.schedulingOptions?.p6Source; + delete ordinaryIfcSource.calendar.p6Source; + delete ordinaryDirect.project.schedulingOptions?.p6Source; + delete ordinaryDirect.calendar.p6Source; + // Zonder XER-archief blijft dit bewust een gewone IFC en dus een synchrone read-probe. + const ordinaryIfc = readIFC(writeIFC({ + ...ordinaryIfcSource, xer: undefined, xerSourceArchive: undefined, xerSourceProjectId: undefined, + })); + eq('X12 gewone IFC zonder XER-bronstempels blijft zesassig formaatneutraal', { + projectSource: ordinaryIfc.project.schedulingOptions?.p6Source, + calendarSource: ordinaryIfc.calendar.p6Source, + axes: sixAxes(ordinaryIfc), + }, { + projectSource: undefined, + calendarSource: undefined, + axes: sixAxes(ordinaryDirect), + }); + + const unprovenImport = readXER(projectionBytes); + if (isMultiDocumentImport(unprovenImport)) throw new Error('X12 provenance-tegenvoorbeeld moet enkelproject zijn'); + delete unprovenImport.calendar.p6Source; + const unproven = solveImported(unprovenImport).tasks; + const unprovenTask = (code: string) => unproven.find(task => task.taskCode === code); + eq('X12 penaltymetadata zonder XER-kalenderprovenance is volledig inert', { + aLateStart: unprovenTask('A100')?.lateStart, + aTotalFloat: unprovenTask('A100')?.totalFloatMinutes, + xTotalFloat: unprovenTask('X100')?.totalFloatMinutes, + xFreeFloat: unprovenTask('X100')?.freeFloatMinutes, + lagPredecessorLateFinish: unprovenTask('LP100')?.lateFinish, + }, { + aLateStart: '2026-01-02T08:00', + aTotalFloat: 960, + xTotalFloat: 1920, + xFreeFloat: 1920, + lagPredecessorLateFinish: '2025-12-31T17:00', + }); +} + +// P6 kan ook een gewone TT_Task met nul duur als twee aangrenzende werkgrenzen bewaren. Anders +// dan een start-/finishmijlpaal blijft de activiteit als TT_Task getypeerd; de geïnverteerde +// target-window is het bronbewijs voor ES/LS op de volgende bandstart en EF/LF op het vorige +// bandeinde. De fixture bevat geen P6-uitvoerwaarden in het productpad. +{ + const bytes = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-08-06\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tStandard 5x8\tCA_Base\t8\t40\t(0||CalendarData()((0||DaysOfWeek()((0||1()())(0||2()((0||0(s|08:00|f|12:00)())(0||1(s|13:00|f|17:00)())))(0||3()((0||0(s|08:00|f|12:00)())(0||1(s|13:00|f|17:00)())))(0||4()((0||0(s|08:00|f|12:00)())(0||1(s|13:00|f|17:00)())))(0||5()((0||0(s|08:00|f|12:00)())(0||1(s|13:00|f|17:00)())))(0||6()((0||0(s|08:00|f|12:00)())(0||1(s|13:00|f|17:00)())))(0||7()())))(0||Exceptions())))', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date', + '%R\tP\tP6 nulduuractiviteit\tC1\t2026-08-06 08:00\t2026-08-06 08:00', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date', + '%R\t1\tP\tC1\tA100\tNulduuractiviteit\tTT_Task\tDT_FixedDUR2\tTK_NotStart\t0\t0\t2026-08-06 08:00\t2026-08-05 16:00', + '%E', + ].join('\n')); + const imported = readXER(bytes); + if (isMultiDocumentImport(imported)) throw new Error('X12 nulduuractiviteitfixture moet enkelproject zijn'); + const task = solveImported(imported).tasks.find(candidate => candidate.taskCode === 'A100'); + eq('X12 gewone P6-nulduuractiviteit bewaart de aangrenzende werkgrenzen', { + earlyStart: task?.earlyStart, + earlyFinish: task?.earlyFinish, + lateStart: task?.lateStart, + lateFinish: task?.lateFinish, + }, { + earlyStart: '2026-08-06T08:00', + earlyFinish: '2026-08-05T16:00', + lateStart: '2026-08-06T08:00', + lateFinish: '2026-08-05T16:00', + }); +} + +// Float wordt intern in taakdagen bewaard. De productadapter moet die dagen naar P6-minuten +// terugzetten met CalendarEngine.hoursPerDay (de effectieve, uit banden afgeleide dag), niet met +// een tegenstrijdige ruwe CALENDAR.day_hr_cnt. Deze bronvorm is publiek aanwezig in Roads. +{ + const bytes = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-01-05\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tTienuursband met ruwe achturendag\tCA_Base\t8\t40\t(0||CalendarData()((0||DaysOfWeek()((0||1()())(0||2()((0||0(s|07:00|f|17:00)())))(0||3()((0||0(s|07:00|f|17:00)())))(0||4()((0||0(s|07:00|f|17:00)())))(0||5()((0||0(s|07:00|f|17:00)())))(0||6()((0||0(s|07:00|f|17:00)())))(0||7()())))(0||Exceptions())))', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date', + '%R\tP\tEffectieve floatkalender\tC1\t2026-01-05 07:00\t2026-01-05 07:00', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date', + '%R\t1\tP\tC1\tA100\tVroeg pad\tTT_Task\tDT_FixedDUR2\tTK_NotStart\t10\t10\t2026-01-05 07:00\t2026-01-05 17:00', + '%R\t2\tP\tC1\tB100\tLater pad\tTT_Task\tDT_FixedDUR2\tTK_NotStart\t10\t10\t2026-01-06 07:00\t2026-01-06 17:00', + '%E', + ].join('\n')); + const imported = readXER(bytes); + if (isMultiDocumentImport(imported)) throw new Error('X12 effectieve-floatkalenderfixture moet enkelproject zijn'); + const tenHourBands = [{ start: 7 * 60, end: 17 * 60 }]; + const effectiveCalendar = { + ...imported.calendar, + workDays: [1, 2, 3, 4, 5], + workStartHour: 7, + workEndHour: 17, + workTime: { byWeekday: { + 1: tenHourBands, 2: tenHourBands, 3: tenHourBands, 4: tenHourBands, 5: tenHourBands, + 6: [], 7: [], + } }, + }; + const engine = new CalendarEngine(effectiveCalendar); + const task = solveImported({ ...imported, calendar: effectiveCalendar }).tasks + .find(candidate => candidate.taskCode === 'A100'); + eq('X12 productadapter zet taakdagen om met effectieve kalenderuren', { + rawHoursPerDay: effectiveCalendar.hoursPerDay, + effectiveHoursPerDay: engine.hoursPerDay, + totalFloatMinutes: task?.totalFloatMinutes, + freeFloatMinutes: task?.freeFloatMinutes, + }, { + rawHoursPerDay: 8, + effectiveHoursPerDay: 10, + totalFloatMinutes: 600, + freeFloatMinutes: 600, + }); +} + +// X12 completed/actual-pakket. P6's opgeslagen early/late zijn uitsluitend de onafhankelijke +// meetlat; deze fixture maakt ze daarom NIET tot productinvoer. Het broncontract dat de +// productketen wel ontvangt is de XER-taskwindow (`target_*`), de status en de actuals. De +// completed XER-activiteit reconstrueert in dit pakket uitsluitend P6's vroege window wanneer haar +// geregistreerde historie daarvan afwijkt. Late datums en floats blijven op de al bestaande +// completed-semantiek; dat afzonderlijke orakelprobleem blijft bewust open. De aanvullende vormen +// bewaken dat de grens smal blijft: meerdere assignments zijn niet causaal, onvolledige provenance, +// actieve taken, LOE, suspend/resume, summaries en completed milestones vallen erbuiten en een +// gewone/IFC-taak behoudt de bestaande actual-semantiek. +{ + type CompletedFixtureKind = 'completed-task' | 'active-task' | 'completed-milestone'; + type TargetShape = 'both' | 'start-only' | 'invalid-start'; + const completedPackageBytes = ( + kind: CompletedFixtureKind, assignments: 0 | 2 = 0, rawOutput: 'oracle' | 'mutated' = 'oracle', + targetShape: TargetShape = 'both', + ) => { + const completed = kind !== 'active-task'; + const milestone = kind === 'completed-milestone'; + const remTargetLink = kind === 'active-task' ? 'N' : 'Y'; + const projectTargetStart = '2026-01-02 08:00'; + const projectTargetFinish = milestone ? projectTargetStart : '2026-01-02 16:00'; + const targetStart = targetShape === 'invalid-start' ? 'geen-datum' : projectTargetStart; + const targetFinish = targetShape === 'start-only' ? '' : projectTargetFinish; + const actualStart = kind === 'active-task' ? '2026-01-07 08:00' : '2026-01-08 08:00'; + const actualFinish = completed ? (milestone ? actualStart : '2026-01-08 16:00') : ''; + // Dit zijn bewust uitsluitend orakelcellen in de fixture: de readerwhitelist verbiedt ze. + const rawEarlyStart = rawOutput === 'oracle' ? '2026-01-05 08:00' : '2040-11-04 08:00'; + const rawEarlyFinish = rawOutput === 'oracle' ? '2026-01-02 16:00' : '2040-11-03 16:00'; + const rawLateStart = rawOutput === 'oracle' ? '2026-01-02 08:00' : '2040-11-03 08:00'; + const rawLateFinish = rawOutput === 'oracle' ? '2026-01-02 16:00' : '2040-11-03 16:00'; + const lines = [ + 'ERMHDR\t23.12\t2026-01-05\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + `%R\tC1\tStandaard 5x8\tCA_Base\t8\t40\t${fiveDayCalendarData('08:00', '16:00')}`, + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\trem_target_link_flag\tlast_recalc_date\tplan_start_date\tplan_end_date', + `%R\tP\tCompleted package\tC1\t${remTargetLink}\t2026-01-05 08:00\t${projectTargetStart}\t${projectTargetFinish}`, + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\tcomplete_pct_type\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date\tact_start_date\tact_end_date\tearly_start_date\tearly_end_date\tlate_start_date\tlate_end_date\ttotal_float_hr_cnt\tfree_float_hr_cnt', + `%R\tT1\tP\tC1\tA100\tCompleted fidelity\t${milestone ? 'TT_Mile' : 'TT_Task'}\tDT_FixedDUR2\t${completed ? 'TK_Complete' : 'TK_Active'}\tCP_Drtn\t${milestone ? '0' : '8'}\t${completed ? '0' : '4'}\t${targetStart}\t${targetFinish}\t${actualStart}\t${actualFinish}\t${milestone ? actualStart : rawEarlyStart}\t${milestone ? actualStart : rawEarlyFinish}\t${milestone ? actualStart : rawLateStart}\t${milestone ? actualStart : rawLateFinish}\t0\t0`, + ]; + if (assignments === 2) { + lines.push( + '%T\tRSRC', + '%F\trsrc_id\trsrc_name\trsrc_type\tdef_qty_per_hr', + '%R\tR1\tEerste resource\tRT_Labor\t1', + '%R\tR2\tTweede resource\tRT_Labor\t1', + '%T\tTASKRSRC', + '%F\ttaskrsrc_id\tproj_id\ttask_id\trsrc_id\ttarget_qty_per_hr', + '%R\tAR1\tP\tT1\tR1\t1', + '%R\tAR2\tP\tT1\tR2\t1', + ); + } + lines.push('%E'); + return new TextEncoder().encode(lines.join('\n')); + }; + const one = readXER(completedPackageBytes('completed-task')); + const oneRawMutated = readXER(completedPackageBytes('completed-task', 0, 'mutated')); + const two = readXER(completedPackageBytes('completed-task', 2)); + const active = readXER(completedPackageBytes('active-task')); + const milestone = readXER(completedPackageBytes('completed-milestone')); + const startOnly = readXER(completedPackageBytes('completed-task', 0, 'oracle', 'start-only')); + const invalidStart = readXER(completedPackageBytes('completed-task', 0, 'oracle', 'invalid-start')); + if (isMultiDocumentImport(one) || isMultiDocumentImport(oneRawMutated) || isMultiDocumentImport(two) + || isMultiDocumentImport(active) || isMultiDocumentImport(milestone) + || isMultiDocumentImport(startOnly) || isMultiDocumentImport(invalidStart)) { + throw new Error('X12 completed/actual-fixtures moeten elk één project opleveren'); + } + const resultOf = (input: ImportResult) => solveImported(input).tasks.find(task => task.taskCode === 'A100'); + const oneResult = resultOf(one); + const oneRawMutatedResult = resultOf(oneRawMutated); + const twoResult = resultOf(two); + const activeResult = resultOf(active); + const milestoneResult = resultOf(milestone); + const startOnlyResult = resultOf(startOnly); + const invalidStartResult = resultOf(invalidStart); + eq('X12 F1 completed gewone XER-taak reconstrueert alleen P6 ES/EF uit de statusdatumroute', { + explicitTargetWindow: one.tasks.find(task => task.wbsCode === 'A100')?.p6ExplicitTargetWindow, + earlyStart: oneResult?.earlyStart, earlyFinish: oneResult?.earlyFinish, + lateStart: oneResult?.lateStart, lateFinish: oneResult?.lateFinish, + totalFloatMinutes: oneResult?.totalFloatMinutes, freeFloatMinutes: oneResult?.freeFloatMinutes, + }, { + explicitTargetWindow: true, + // p6CompletedLateFromRemainingWindow (diagnose laag 1, klasse (i), default aan voor XER): een + // geïsoleerde voltooide taak zonder opvolgers staat aan de late zijde ook op de + // statusdatumklem — identiek aan haar eigen statusdatumvenster — i.p.v. het historische + // actual-venster (act_start/act_end 2026-01-08). + earlyStart: '2026-01-05T08:00', earlyFinish: '2026-01-02T16:00', + lateStart: '2026-01-05T08:00', lateFinish: '2026-01-02T16:00', + totalFloatMinutes: 0, freeFloatMinutes: 0, + }); + eq('X12 F1 raw early/late-mutatatie beïnvloedt de solver niet', { + oracleAxes: [oneResult?.earlyStart, oneResult?.earlyFinish, oneResult?.lateStart, oneResult?.lateFinish], + mutatedAxes: [oneRawMutatedResult?.earlyStart, oneRawMutatedResult?.earlyFinish, + oneRawMutatedResult?.lateStart, oneRawMutatedResult?.lateFinish], + }, { + oracleAxes: ['2026-01-05T08:00', '2026-01-02T16:00', '2026-01-05T08:00', '2026-01-02T16:00'], + mutatedAxes: ['2026-01-05T08:00', '2026-01-02T16:00', '2026-01-05T08:00', '2026-01-02T16:00'], + }); + // T2 (XER-etappeplan §4/T2) — DE POSITIEVE HELFT: het orakel-kanaal zélf verandert wél mee met de + // gemuteerde bak-4-cellen (dat is precies waar het voor bestaat — de weergavemodus mag bewegen), + // terwijl de solverprojectie hierboven bewijsbaar niet beweegt. `total_float_hr_cnt`/ + // `free_float_hr_cnt` staan in deze fixture altijd hardcoded op '0' (rawOutput muteert alleen de + // vier datumcellen), dus `totalFloat`/`freeFloat`/`isCritical` blijven ook in de gemuteerde + // variant identiek — dat IS het verwachte gedrag, geen gat in het bewijs. + eq('X12-T2 F1 recordedTimes draagt de orakelwaarden (oracle-variant)', one.recordedTimes?.T1, { + start: '2026-01-05T08:00', finish: '2026-01-02T16:00', + lateStart: '2026-01-02T08:00', lateFinish: '2026-01-02T16:00', + totalFloat: 0, freeFloat: 0, isCritical: true, + }); + eq('X12-T2 F1 recordedTimes verschilt exact op de vier gemuteerde datumassen, float/isCritical ongewijzigd', + oneRawMutated.recordedTimes?.T1, { + start: '2040-11-04T08:00', finish: '2040-11-03T16:00', + lateStart: '2040-11-03T08:00', lateFinish: '2040-11-03T16:00', + totalFloat: 0, freeFloat: 0, isCritical: true, + }); + eq('X12-T2 F1 recordedTimesOrigin is xer op beide varianten', + [one.recordedTimesOrigin, oneRawMutated.recordedTimesOrigin], ['xer', 'xer']); + eq('X12-T2 F1 geen enkele Task.time-as van de gemuteerde variant draagt een orakelwaarde', + noRecordedAxisLeak(oneRawMutated.tasks, [ + '2040-11-04T08:00', '2040-11-03T16:00', '2040-11-03T08:00', + ]), true); + // De statusdatum, taakprovenance en gewone geplande start moeten ook na de native opslaggrens + // aanwezig blijven; XER-archiefreconstructie levert daarmee dezelfde brongebonden route op. + const oneReloaded = await readIFCWithXerReconstruction(writeIFC(one)); + const oneReloadedResult = resultOf(oneReloaded); + eq('X12 F1 XER→IFC→reload behoudt alleen de completed data-datesemantiek', { + explicitTargetWindow: oneReloaded.tasks.find(task => task.wbsCode === 'A100')?.p6ExplicitTargetWindow, + axes: [oneReloadedResult?.earlyStart, oneReloadedResult?.earlyFinish, + oneReloadedResult?.lateStart, oneReloadedResult?.lateFinish], + }, { + explicitTargetWindow: true, + axes: ['2026-01-05T08:00', '2026-01-02T16:00', '2026-01-05T08:00', '2026-01-02T16:00'], + }); + eq('X12 F2 meerdere XER-assignments veranderen completed-bronsemantiek niet', { + assignmentCount: two.assignments.length, + axes: [twoResult?.earlyStart, twoResult?.earlyFinish, twoResult?.lateStart, twoResult?.lateFinish], + }, { + assignmentCount: 2, + axes: [oneResult?.earlyStart, oneResult?.earlyFinish, oneResult?.lateStart, oneResult?.lateFinish], + }); + eq('X12 F3 active XER-taak behoudt bestaande actual-startsemantiek', { + earlyStart: activeResult?.earlyStart, + }, { earlyStart: '2026-01-07T08:00' }); + eq('X12 F4 completed milestone valt buiten completed-taskbronsemantiek', { + earlyStart: milestoneResult?.earlyStart, earlyFinish: milestoneResult?.earlyFinish, + lateStart: milestoneResult?.lateStart, lateFinish: milestoneResult?.lateFinish, + }, { + earlyStart: '2026-01-08T08:00', earlyFinish: '2026-01-08T08:00', + lateStart: '2026-01-08T08:00', lateFinish: '2026-01-08T08:00', + }); + + eq('X12 presencepoort vereist twee geldige expliciete TASK-targetvelden', { + startOnly: { + presence: startOnly.tasks.find(task => task.wbsCode === 'A100')?.p6ExplicitTargetWindow, + early: [startOnlyResult?.earlyStart, startOnlyResult?.earlyFinish], + }, + invalidStart: { + presence: invalidStart.tasks.find(task => task.wbsCode === 'A100')?.p6ExplicitTargetWindow, + early: [invalidStartResult?.earlyStart, invalidStartResult?.earlyFinish], + }, + }, { + startOnly: { presence: undefined, early: ['2026-01-08T08:00', '2026-01-08T16:00'] }, + invalidStart: { presence: undefined, early: ['2026-01-08T08:00', '2026-01-08T16:00'] }, + }); + + const oneTask = one.tasks.find(task => task.wbsCode === 'A100'); + if (!oneTask) throw new Error('X12 F1 mist A100'); + const variantResult = (patch: Partial) => resultOf({ + ...one, + tasks: one.tasks.map(task => task.id === oneTask.id ? { ...task, ...patch } : task), + }); + const noPresenceResult = variantResult({ p6ExplicitTargetWindow: undefined }); + const loeResult = variantResult({ p6ActivityType: 'TT_LOE', isHammock: true }); + const suspendedResult = variantResult({ + p6SuspendResume: true, + time: { ...oneTask.time, stop: '2026-01-06T08:00', resume: '2026-01-07T08:00' }, + }); + eq('X12 fail-closed route weigert ontbrekende presence, LOE en suspend/resume buiten de bewezen completed-bronrelatie', { + noPresence: [noPresenceResult?.earlyStart, noPresenceResult?.earlyFinish], + loe: [loeResult?.earlyStart, loeResult?.earlyFinish], + suspended: [suspendedResult?.earlyStart, suspendedResult?.earlyFinish], + }, { + noPresence: ['2026-01-08T08:00', '2026-01-08T16:00'], + loe: ['2026-01-02T08:00', '2026-01-02T08:00'], + suspended: ['2026-01-08T08:00', '2026-01-08T16:00'], + }); + + const summaryCandidate = { ...oneTask, isSummary: true, childIds: ['child'] }; + const activeTask = active.tasks.find(task => task.wbsCode === 'A100'); + const milestoneTask = milestone.tasks.find(task => task.wbsCode === 'A100'); + if (!activeTask || !milestoneTask) throw new Error('X12 negatieve predicatefixtures missen A100'); + const completedGuardDataDate = new Date(0); + eq('X12 completed bronpredicate blijft fail-closed voor alle uitgesloten bronvormen', { + noPresence: usesP6CompletedDataDateWindow( + { ...oneTask, p6ExplicitTargetWindow: undefined }, completedGuardDataDate, one.project.schedulingOptions), + active: usesP6CompletedDataDateWindow(activeTask, completedGuardDataDate, active.project.schedulingOptions), + milestone: usesP6CompletedDataDateWindow(milestoneTask, completedGuardDataDate, milestone.project.schedulingOptions), + loe: usesP6CompletedDataDateWindow( + { ...oneTask, p6ActivityType: 'TT_LOE', isHammock: true }, completedGuardDataDate, one.project.schedulingOptions), + suspendResume: usesP6CompletedDataDateWindow( + { ...oneTask, p6SuspendResume: true }, completedGuardDataDate, one.project.schedulingOptions), + summary: usesP6CompletedDataDateWindow(summaryCandidate, completedGuardDataDate, one.project.schedulingOptions), + generic: usesP6CompletedDataDateWindow(oneTask, completedGuardDataDate, undefined), + }, { + noPresence: false, active: false, milestone: false, loe: false, + suspendResume: false, summary: false, generic: false, + }); + + const predecessor = { + ...oneTask, + id: 'open-predecessor', + wbsCode: 'P100', + name: 'Open predecessor', + status: 'NOT_STARTED' as const, + p6TaskId: 'T0', + p6ExplicitTargetWindow: undefined, + time: { + ...oneTask.time, + scheduleStart: '2026-01-05T08:00', scheduleFinish: '2026-01-05T16:00', + scheduleDuration: 1, durationMinutes: 480, + completion: 0, remainingTime: 1, remainingMinutes: 480, + actualStart: undefined, actualFinish: undefined, + }, + }; + const connected: ImportResult = { + ...one, + tasks: [predecessor, oneTask], + sequences: [{ + id: 'A-FS-B', predecessorId: predecessor.id, successorId: oneTask.id, + type: 'FINISH_START', lagDays: 0, + }], + }; + const connectedSolved = solveImported(connected).tasks; + const connectedA = connectedSolved.find(task => task.taskCode === 'P100'); + const connectedB = connectedSolved.find(task => task.taskCode === 'A100'); + // p6CompletedLateFromRemainingWindow (diagnose laag 1, klasse (i)): vóór deze vlag beschreef B's + // rauwe actual-pin (act_start/act_end 2026-01-08, ver NÁ B's eigen statusdatumvenster) pure + // historie en mocht ze geen backwarddruk op open A leggen — vandaar de oude testnaam. Mét de vlag + // draagt B (voltooid, window-eligible, geen eigen opvolgers) een zinvolle late kant: haar eigen + // statusdatumklem (2026-01-05T08:00), identiek aan haar vroege venster. Open A (FS lag 0 naar B, + // zelf al gepland op diezelfde 2026-01-05) mag daar niet ná finishen ⇒ A's late finish valt + // terug naar de vorige werkdag (2026-01-02) en A krijgt ECHTE negatieve float (-480 min, één + // werkdag) — een out-of-sequence-signaal, geen bug: A had volgens B's positie al klaar moeten + // zijn. Dat is precies de spiegel van de gemeten regel: een voltooide opvolger legt nu, net als + // elke andere opvolger, gewone backward-druk op haar voorganger. + eq('X12 A→FS→B(completed) legt normale backwarddruk op open A (B draagt een zinvolle late kant)', { + a: { + earlyStart: connectedA?.earlyStart, earlyFinish: connectedA?.earlyFinish, + lateStart: connectedA?.lateStart, lateFinish: connectedA?.lateFinish, + totalFloatMinutes: connectedA?.totalFloatMinutes, + }, + b: { + earlyStart: connectedB?.earlyStart, earlyFinish: connectedB?.earlyFinish, + lateStart: connectedB?.lateStart, lateFinish: connectedB?.lateFinish, + freeFloatMinutes: connectedB?.freeFloatMinutes, + }, + }, { + a: { + earlyStart: '2026-01-05T08:00', earlyFinish: '2026-01-05T16:00', + lateStart: '2026-01-02T08:00', lateFinish: '2026-01-02T16:00', totalFloatMinutes: -480, + }, + b: { + earlyStart: '2026-01-05T08:00', earlyFinish: '2026-01-02T16:00', + lateStart: '2026-01-05T08:00', lateFinish: '2026-01-02T16:00', freeFloatMinutes: 0, + }, + }); + + const openSuccessor = { + ...predecessor, + id: 'open-successor', + wbsCode: 'S100', + name: 'Open successor', + p6TaskId: 'T2', + time: { + ...predecessor.time, + scheduleStart: '2026-01-01T08:00', scheduleFinish: '2026-01-01T16:00', + }, + }; + const displayOnlyCandidate: ImportResult = { + ...one, + tasks: [oneTask, openSuccessor], + sequences: [{ + id: 'B-FS-S', predecessorId: oneTask.id, successorId: openSuccessor.id, + type: 'FINISH_START', lagDays: 0, + }], + }; + const displayOnlyCpm = solveProject({ + tasks: displayOnlyCandidate.tasks, + sequences: displayOnlyCandidate.sequences, + calendar: displayOnlyCandidate.calendar, + calendars: displayOnlyCandidate.resourceCalendars ?? [], + dataDate: displayOnlyCandidate.project.statusDate, + progressMode: displayOnlyCandidate.project.progressMode, + schedulingOptions: displayOnlyCandidate.project.schedulingOptions, + projectStartDate: displayOnlyCandidate.project.startDate, + projectEndDate: displayOnlyCandidate.project.endDate, + }); + if (displayOnlyCpm.error) throw new Error(`X12 display-only candidate faalde: ${displayOnlyCpm.error}`); + const displayOnlyCompleted = displayOnlyCandidate.tasks.find(task => task.wbsCode === 'A100'); + const displayOnlySuccessor = displayOnlyCandidate.tasks.find(task => task.wbsCode === 'S100'); + eq('X12 completed-weergaveprojectie beweegt open successor of projectfinish niet', { + completedDisplay: [displayOnlyCompleted?.time.earlyStart, displayOnlyCompleted?.time.earlyFinish], + successor: [displayOnlySuccessor?.time.earlyStart, displayOnlySuccessor?.time.earlyFinish, + displayOnlySuccessor?.time.lateStart, displayOnlySuccessor?.time.lateFinish], + projectEnd: displayOnlyCpm.projectEnd, + }, { + completedDisplay: ['2026-01-05T08:00', '2026-01-02T16:00'], + successor: ['2026-01-09T08:00', '2026-01-09T16:00', '2026-01-09T08:00', '2026-01-09T16:00'], + projectEnd: '2026-01-09T16:00', + }); + + // F5: hetzelfde TaskTime-paar zonder de twee XER-provenancevoorwaarden moet het generieke + // (en daarna gewone IFC-)gedrag behouden. Geen raw P6-uitkomst wordt op dit pad opgeslagen, + // ingelezen of aan de solver doorgegeven. + const generic: ImportResult = { + ...one, + project: { ...one.project, schedulingOptions: undefined }, + tasks: one.tasks.map(({ p6ProjectId: _project, p6TaskId: _task, p6ActivityType: _activity, p6DurationType: _duration, p6ExplicitTargetWindow: _window, ...task }) => task), + xer: undefined, + xerSourceArchive: undefined, + xerSourceProjectId: undefined, + }; + const genericIfc = readIFC(writeIFC(generic)); + const genericTask = (input: ImportResult) => { + const cpm = solveProject({ + tasks: input.tasks, sequences: input.sequences, calendar: input.calendar, + calendars: input.resourceCalendars ?? [], dataDate: input.project.statusDate, + progressMode: input.project.progressMode, schedulingOptions: input.project.schedulingOptions, + projectStartDate: input.project.startDate, projectEndDate: input.project.endDate, + }); + if (cpm.error) throw new Error(`X12 F5 generieke solve faalde: ${cpm.error}`); + return input.tasks.find(task => task.wbsCode === 'A100'); + }; + const genericResult = genericTask(generic); + const genericIfcResult = genericTask(genericIfc); + eq('X12 F5 generieke non-XER-bron en gewoon IFC behouden alle bestaande datum-/floatvelden', { + generic: [genericResult?.time.earlyStart, genericResult?.time.earlyFinish, + genericResult?.time.lateStart, genericResult?.time.lateFinish, + genericResult?.time.totalFloat, genericResult?.time.freeFloat], + ifc: [genericIfcResult?.time.earlyStart, genericIfcResult?.time.earlyFinish, + genericIfcResult?.time.lateStart, genericIfcResult?.time.lateFinish, + genericIfcResult?.time.totalFloat, genericIfcResult?.time.freeFloat], + }, { + generic: ['2026-01-08T08:00', '2026-01-08T16:00', + '2026-01-08T16:00', '2026-01-08T16:00', 0, 0], + ifc: ['2026-01-08T08:00', '2026-01-08T16:00', + '2026-01-08T16:00', '2026-01-08T16:00', 0, 0], + }); +} + +// De openbare expected-finish-populatie heeft momenteel geen actieve drager. Deze vaste drie-taaks- +// fixture houdt daarom de eigen X7-route mutatiegevoelig: een voltooide voorganger, een actieve +// CP_Drtn-taak met afwijkende expected finish, en een zichtbare opvolger. Alleen de SCHEDOPTIONS- +// vlag verschilt tussen uit en aan; opgeslagen P6-uitvoer blijft een scanner-orakel, geen invoer. +{ + const calendarData = fiveDayCalendarData('08:00', '17:00'); + const packageBytes = (useExpectedFinish: boolean, mutateStoredOutput = false) => new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-01-06\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + `%R\tC1\tX7 5x9\tCA_Base\t9\t45\t${calendarData}`, + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date\tdef_duration_type', + '%R\tP\tExpected finish keten\tC1\t2026-01-06 08:00\t2026-01-05 08:00\tDT_FixedRate', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\tcomplete_pct_type\tcomplete_pct\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date\tact_start_date\tact_end_date\texpect_end_date\tearly_start_date\tearly_end_date\tlate_start_date\tlate_end_date\ttotal_float_hr_cnt\tfree_float_hr_cnt\tdriving_path_flag', + `%R\tA\tP\tC1\tA100\tVoltooide voorganger\tTT_Task\tDT_FixedDUR\tTK_Complete\tCP_Drtn\t100\t8\t0\t2026-01-05 08:00\t2026-01-05 17:00\t2026-01-05 08:00\t2026-01-05 17:00\t\t${mutateStoredOutput ? '2040-02-01 08:00' : '2026-01-05 08:00'}\t${mutateStoredOutput ? '2040-02-01 17:00' : '2026-01-05 17:00'}\t${mutateStoredOutput ? '2040-02-02 08:00' : '2026-01-05 08:00'}\t${mutateStoredOutput ? '2040-02-02 17:00' : '2026-01-05 17:00'}\t${mutateStoredOutput ? '999' : '0'}\t${mutateStoredOutput ? '888' : '0'}\t${mutateStoredOutput ? 'N' : 'Y'}`, + `%R\tB\tP\tC1\tB100\tActieve expected finish\tTT_Task\t\tTK_Active\tCP_Drtn\t50\t32\t16\t2026-01-05 08:00\t2026-01-08 17:00\t2026-01-05 08:00\t\t2026-01-12\t${mutateStoredOutput ? '2040-03-01 08:00' : '2026-01-06 08:00'}\t${mutateStoredOutput ? '2040-03-01 17:00' : '2026-01-07 17:00'}\t${mutateStoredOutput ? '2040-03-02 08:00' : '2026-01-06 08:00'}\t${mutateStoredOutput ? '2040-03-02 17:00' : '2026-01-07 17:00'}\t${mutateStoredOutput ? '777' : '0'}\t${mutateStoredOutput ? '666' : '0'}\t${mutateStoredOutput ? 'N' : 'Y'}`, + `%R\tC\tP\tC1\tC100\tOpvolger\tTT_Task\tDT_FixedDUR\tTK_NotStart\tCP_Drtn\t0\t8\t8\t2026-01-08 08:00\t2026-01-08 17:00\t\t\t\t${mutateStoredOutput ? '2040-04-01 08:00' : '2026-01-08 08:00'}\t${mutateStoredOutput ? '2040-04-01 17:00' : '2026-01-08 17:00'}\t${mutateStoredOutput ? '2040-04-02 08:00' : '2026-01-08 08:00'}\t${mutateStoredOutput ? '2040-04-02 17:00' : '2026-01-08 17:00'}\t${mutateStoredOutput ? '555' : '0'}\t${mutateStoredOutput ? '444' : '0'}\t${mutateStoredOutput ? 'N' : 'Y'}`, + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%R\tR-AB\tB\tA\tP\tP\tPR_FS\t0', + '%R\tR-BC\tC\tB\tP\tP\tPR_FS\t0', + '%T\tSCHEDOPTIONS', + '%F\tproj_id\tsched_use_expect_end_flag', + `%R\tP\t${useExpectedFinish ? 'Y' : 'N'}`, + '%E', + ].join('\n')); + const off = readXER(packageBytes(false)); + const on = readXER(packageBytes(true)); + const mutated = readXER(packageBytes(true, true)); + if (isMultiDocumentImport(off) || isMultiDocumentImport(on) || isMultiDocumentImport(mutated)) { + throw new Error('X12 expected-finishketen moet enkelproject zijn'); + } + const axes = (input: ImportResult) => solveImported(input).tasks.map(task => [ + task.sourceTaskId, task.taskCode, task.earlyStart, task.earlyFinish, + task.lateStart, task.lateFinish, task.totalFloatMinutes, task.freeFloatMinutes, + ]); + const sourceProjection = (input: ImportResult) => ({ + expectedFinish: input.tasks.map(task => [task.p6TaskId, task.p6DurationType, task.p6ExpectedFinish]), + productInput: { project: input.project, calendar: input.calendar, tasks: input.tasks, sequences: input.sequences }, + }); + eq('X12 expected-finishketen heeft precies één actieve bronrij en behoudt de ontbrekende duration_type via projectdefault', + on.tasks.filter(task => task.p6ExpectedFinish !== undefined && task.status === 'STARTED' + && task.time.completion > 0 && task.time.completion < 1) + .map(task => [task.p6TaskId, task.p6DurationType, task.p6ExpectedFinish]), + [['B', 'DT_FixedRate', '2026-01-12']]); + eq('X12 expected-finishketen houdt stored P6-uitvoer buiten reader- en solverinput', + sourceProjection(mutated), sourceProjection(on)); + eq('X12 expected-finishketen pinnt per bronrij alle zes productassen voor vlag uit en aan', { + off: axes(off), on: axes(on), + }, { + off: [ + ['A', 'A100', '2026-01-05T08:00', '2026-01-05T17:00', '2026-01-05T08:00', '2026-01-05T17:00', 0, 0], + ['B', 'B100', '2026-01-05T08:00', '2026-01-07T15:00', '2026-01-05T08:00', '2026-01-07T15:00', 0, 0], + ['C', 'C100', '2026-01-07T15:00', '2026-01-08T14:00', '2026-01-07T15:00', '2026-01-08T14:00', 0, 0], + ], + on: [ + ['A', 'A100', '2026-01-05T08:00', '2026-01-05T17:00', '2026-01-05T08:00', '2026-01-05T17:00', 0, 0], + ['B', 'B100', '2026-01-05T08:00', '2026-01-12T17:00', '2026-01-05T08:00', '2026-01-12T17:00', 0, 0], + ['C', 'C100', '2026-01-13T08:00', '2026-01-13T16:00', '2026-01-13T08:00', '2026-01-13T16:00', 0, 0], + ], + }); + eq('X12 expected-finishketen laat alleen de expliciete vlag de actieve keten bewegen', { + activeSources: on.tasks.filter(task => task.p6ExpectedFinish !== undefined && task.status === 'STARTED').length, + movedTasks: axes(off).filter((task, index) => JSON.stringify(task) !== JSON.stringify(axes(on)[index])).map(task => task[0]), + }, { activeSources: 1, movedTasks: ['B', 'C'] }); + eq('X12 expected-finishketen houdt stored P6-uitvoer buiten alle productassen', axes(mutated), axes(on)); + // T2 (XER-etappeplan §4/T2) — DE POSITIEVE HELFT op de tweede fixturefamilie: drie bronrijen + // (A/B/C), elk met eigen gemuteerde early/late/float-cellen. `recordedTimes` moet op alle drie + // exact de orakelwaarde dragen (oracle-variant `on`) en exact verschillen op alle zes assen in de + // gemuteerde variant (`mutated`) — terwijl de solverprojectie hierboven (`axes(mutated) === axes(on)`) + // bewijsbaar ongewijzigd blijft. + eq('X12-T2 expected-finishketen recordedTimes draagt de orakelwaarden per bronrij (oracle-variant)', { + A: on.recordedTimes?.A, B: on.recordedTimes?.B, C: on.recordedTimes?.C, + }, { + A: { + start: '2026-01-05T08:00', finish: '2026-01-05T17:00', + lateStart: '2026-01-05T08:00', lateFinish: '2026-01-05T17:00', + totalFloat: 0, freeFloat: 0, isCritical: true, + }, + B: { + start: '2026-01-06T08:00', finish: '2026-01-07T17:00', + lateStart: '2026-01-06T08:00', lateFinish: '2026-01-07T17:00', + totalFloat: 0, freeFloat: 0, isCritical: true, + }, + C: { + start: '2026-01-08T08:00', finish: '2026-01-08T17:00', + lateStart: '2026-01-08T08:00', lateFinish: '2026-01-08T17:00', + totalFloat: 0, freeFloat: 0, isCritical: true, + }, + }); + eq('X12-T2 expected-finishketen recordedTimes verschilt exact op alle zes gemuteerde assen per bronrij', { + A: mutated.recordedTimes?.A, B: mutated.recordedTimes?.B, C: mutated.recordedTimes?.C, + }, { + A: { + start: '2040-02-01T08:00', finish: '2040-02-01T17:00', + lateStart: '2040-02-02T08:00', lateFinish: '2040-02-02T17:00', + totalFloat: 999 * 60 / 540, freeFloat: 888 * 60 / 540, isCritical: false, + }, + B: { + start: '2040-03-01T08:00', finish: '2040-03-01T17:00', + lateStart: '2040-03-02T08:00', lateFinish: '2040-03-02T17:00', + totalFloat: 777 * 60 / 540, freeFloat: 666 * 60 / 540, isCritical: false, + }, + C: { + start: '2040-04-01T08:00', finish: '2040-04-01T17:00', + lateStart: '2040-04-02T08:00', lateFinish: '2040-04-02T17:00', + totalFloat: 555 * 60 / 540, freeFloat: 444 * 60 / 540, isCritical: false, + }, + }); + eq('X12-T2 expected-finishketen — geen enkele Task.time-as van de gemuteerde variant draagt een orakelwaarde', + noRecordedAxisLeak(mutated.tasks, [ + '2040-02-01T08:00', '2040-02-01T17:00', '2040-02-02T08:00', '2040-02-02T17:00', + '2040-03-01T08:00', '2040-03-01T17:00', '2040-03-02T08:00', '2040-03-02T17:00', + '2040-04-01T08:00', '2040-04-01T17:00', '2040-04-02T08:00', '2040-04-02T17:00', + // Her-check laag 3, bevinding 8: de zes gemuteerde FLOATS (in dagen, zoals `Task.time` ze + // draagt) — zonder deze zes was de `n:`-tak van `noRecordedAxisLeak` dode code. + 999 * 60 / 540, 888 * 60 / 540, 777 * 60 / 540, 666 * 60 / 540, 555 * 60 / 540, 444 * 60 / 540, + ]), true); + const normalTruth = scanXerGroundTruth(packageBytes(true)); + const mutatedTruth = scanXerGroundTruth(packageBytes(true, true)); + eq('X12 expected-finishketen laat de onafhankelijke scannertruth wel op stored uitvoer reageren', + normalTruth.tasks.map(task => [task.taskId, task.axes, task.drivingPath]) + .every((task, index) => JSON.stringify(task) !== JSON.stringify( + [mutatedTruth.tasks[index]?.taskId, mutatedTruth.tasks[index]?.axes, mutatedTruth.tasks[index]?.drivingPath])), true); +} + +const corpusRoot = process.env.OPS_XER_CORPUS; +if (REPORT !== undefined && !REPORT_MODES.has(REPORT)) { + diffs.push(`onbekende OPS_XER_FIDELITY_REPORT-modus: ${REPORT}`); +} +if (!corpusRoot) console.log('X12 PRODUCTGATE: corpus niet aanwezig; alleen corpusloze bescherming uitgevoerd'); +else if (!existsSync(corpusRoot)) diffs.push('OPS_XER_CORPUS wijst niet naar een bestaande corpusmap'); +else { + const corpus = listXerFiles(corpusRoot).map(path => ({ label: relative(corpusRoot, path).split('\\').join('/'), bytes: readFileSync(path) })); + const manifest = JSON.parse(readFileSync(join(HERE, 'xer-corpus-manifest.json'), 'utf8')) as XerCorpusManifest; + const measured = await productBaseline(corpus, manifest); + if (REPORT === 'baseline') process.stdout.write(canonicalProductEnvelope(measured)); + else if (REPORT === 'summary' || REPORT === 'detail' || REPORT === 'counterfactuals') { + const entries = Object.entries(measured.files); + const tasks = entries.reduce((total, [, entry]) => total + entry.tasks, 0); + const projects = entries.reduce((total, [, entry]) => total + entry.projects, 0); + const deviations = entries.reduce((total, [, entry]) => total + totalDeviations(entry), 0); + const identityErrors = entries.reduce((total, [, entry]) => total + entry.identityErrors.length, 0); + const scannerErrors = entries.reduce((total, [, entry]) => total + entry.scannerErrors.length, 0); + console.log(`MEASURE ONLY X12 productfidelity: STRICT minute-exact ${entries.length} entries; ${projects} projecten; ${tasks} taken; ${deviations} zesassige afwijkingen; ${identityErrors} identiteitsfouten; ${scannerErrors} scannerfouten`); + } else { + const entries = Object.entries(measured.files); + const allGatePassed = entries.every(([, entry]) => entry.gatePassed === true); + const allAxesZero = entries.every(([, entry]) => XER_FIDELITY_AXES + .every(axis => entry.counters[axis].deviations === 0)); + const totalSixAxisDeviations = entries.reduce( + (total, [, entry]) => total + totalDeviations(entry), + 0, + ); + const identityErrors = entries.reduce((total, [, entry]) => total + entry.identityErrors.length, 0); + const scannerErrors = entries.reduce((total, [, entry]) => total + entry.scannerErrors.length, 0); + eq('X12 nuldoel is baseline-onafhankelijk: ieder bestand haalt de zesassige poort', allGatePassed, true); + eq('X12 nuldoel is baseline-onafhankelijk: alle zes assen zijn nul', allAxesZero, true); + eq('X12 nuldoel is baseline-onafhankelijk: totaal zesassige afwijkingen is nul', totalSixAxisDeviations, 0); + eq('X12 nuldoel is baseline-onafhankelijk: identiteitsfouten zijn nul', identityErrors, 0); + eq('X12 nuldoel is baseline-onafhankelijk: scannerfouten zijn nul', scannerErrors, 0); + eq('X12 productbaseline is de verse volledige productmeting', readProductBaseline(), measured); + } +} +if (diffs.length > 0) { + console.error(`X12 PRODUCTGATE RED: ${diffs.length}/${checks} checks rood`); + for (const diff of diffs) console.error(`XX ${diff}`); + process.exit(1); +} +if (REPORT !== undefined) { + if (REPORT !== 'baseline') { + console.log(`MEASURE ONLY X12 productfidelity: meetcommando voltooid; ${checks} corpusloze checks groen`); + } +} else console.log(`X12 PRODUCTGATE GREEN: ${checks} checks groen`); diff --git a/tests/planning/check-xer-product-fidelity.ts b/tests/planning/check-xer-product-fidelity.ts new file mode 100644 index 000000000..c7e525e36 --- /dev/null +++ b/tests/planning/check-xer-product-fidelity.ts @@ -0,0 +1,285 @@ +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { solveProject } from '@/engine/scheduler/solveProject'; +import { readXER } from '@/services/xer/xerReader'; +import { activeImportResult } from '@/services/importTypes'; +import { scanXerGroundTruth, XER_FIDELITY_AXES, type XerFidelityAxis } from './xerGroundTruth'; +import { measureXerFidelity, type XerSolvedProject, type XerSolvedTask } from './xerFidelity'; + +const diffs: string[] = []; +let checks = 0; +const here = fileURLToPath(new URL('.', import.meta.url)); +const baseline = JSON.parse(readFileSync(join(here, 'xer-product-fidelity-baseline.json'), 'utf8')) as { + version: number; + cellTransitions: { + version: 1; + files: Record; + improvements: Array<{ + cell: [sourceProjectId: string, sourceTaskId: string, axis: XerFidelityAxis]; + old: { bucket: 'diff'; value: string | number }; + current: { bucket: 'exact'; value: string | number }; + }>; + }>; + }; + files: Record['counters']; + reason: string; + }>; +}; + +type CellValue = string | number; +type CellBucket = 'exact' | 'diff' | 'missing'; +interface MeasuredCell { + fileSha256: string; + sourceProjectId: string; + sourceTaskId: string; + axis: XerFidelityAxis; + bucket: CellBucket; + value: CellValue | undefined; + oracle: CellValue; +} + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +function solved(bytes: Uint8Array): XerSolvedProject { + const imported = activeImportResult(readXER(bytes)); + const cpm = solveProject({ + tasks: imported.tasks, + sequences: imported.sequences, + calendar: imported.calendar, + calendars: imported.resourceCalendars ?? [], + dataDate: imported.project.statusDate, + // Review-bevinding 5: deze harness dráágt het `cellTransitions.previouslyExact`-mechanisme — + // de enige plek die "exact → fout" per cel kan afvangen — maar riep `solveProject` aan zónder + // `progressMode`/`schedulingOptions`. Elke brongebonden P6-optie (en dus ook + // `p6CompletedLateFromRemainingWindow`) was hier per constructie inert, waardoor de poort + // structureel blind was voor precies de wijzigingen waarvoor ze bedoeld is. Nu meet ze het + // echte product, zoals `check-xer-product-fidelity-x12.ts` en de solver zelf. + progressMode: imported.project.progressMode, + schedulingOptions: imported.project.schedulingOptions, + projectStartDate: imported.project.startDate, + projectEndDate: imported.project.endDate, + }); + if (cpm.error) throw new Error(cpm.error); + const calendarById = new Map([ + [imported.calendar.id, imported.calendar], + ...(imported.resourceCalendars ?? []).map(calendar => [calendar.id, calendar] as const), + ]); + return { + projectId: imported.project.id, + // Alleen echte TASK-activiteiten. PROJWBS-samenvattingen zijn expres geen fidelitybladen. + tasks: imported.tasks.filter(task => task.p6ActivityType !== undefined).map(task => { + const calendar = (task.calendarId ? calendarById.get(task.calendarId) : undefined) ?? imported.calendar; + const minutesPerDay = calendar.hoursPerDay * 60; + return { + sourceTaskId: task.id, + taskCode: task.wbsCode, + earlyStart: task.time.earlyStart, + earlyFinish: task.time.earlyFinish, + lateStart: task.time.lateStart, + lateFinish: task.time.lateFinish, + totalFloatMinutes: task.time.totalFloat * minutesPerDay, + freeFloatMinutes: task.time.freeFloat * minutesPerDay, + }; + }), + }; +} + +function solvedAxis(task: XerSolvedTask | undefined, axis: XerFidelityAxis): CellValue | undefined { + if (!task) return undefined; + switch (axis) { + case 'es': return task.earlyStart; + case 'ef': return task.earlyFinish; + case 'ls': return task.lateStart; + case 'lf': return task.lateFinish; + case 'tf': return task.totalFloatMinutes; + case 'ff': return task.freeFloatMinutes; + } +} + +function cellKey( + fileSha256: string, + cell: readonly [sourceProjectId: string, sourceTaskId: string, axis: XerFidelityAxis], +): string { + return JSON.stringify([fileSha256, ...cell]); +} + +function sortedCells(cells: ReadonlyArray): string[] { + return cells.map(cell => JSON.stringify(cell)).sort(); +} + +const root = process.env.OPS_XER_CORPUS; +if (!root) { + console.log('OK xer-product-fidelity: corpus niet aanwezig (OPS_XER_CORPUS) — twee productpins overgeslagen'); +} else if (!existsSync(root)) { + diffs.push('OPS_XER_CORPUS wijst niet naar een bestaande corpusmap'); +} else if (baseline.files === undefined || baseline.cellTransitions === undefined) { + // BEKEND, NIET DOOR DEZE ETAPPE VEROORZAAKT (gemeten 2026-09-05 op 92e98b8e én op deze branch): + // `xer-product-fidelity-baseline.json` draagt sinds de X12-v2-meetlat een ANDER schema + // (`version: 2`, gzip-payload) dan deze v1-harness verwacht. De check crashte daardoor met een + // kale `TypeError` op `Object.keys(baseline.files)`; hier is dat een leesbare rode regel geworden + // die de oorzaak benoemt. Gevolg voor review-bevinding 5: het `cellTransitions.previouslyExact`- + // mechanisme — het ENIGE dat per cel "eerder exact, nu fout" kan afvangen — meet sinds die + // schemawissel niets meer. Repareren betekent v1 zijn eigen baselinebestand teruggeven; dat is + // een eigen etappe en staat als zodanig in docs/TODO.md. + diffs.push('v1-productbaseline ontbreekt: xer-product-fidelity-baseline.json draagt het v2-schema' + + ` (version ${String(baseline.version)}) — de per-cel-poort previouslyExact meet niets`); +} else { + const pins = [ + 'crawl-xer/p6diff-baseline.xer', + 'crawl-xer-extra/p6difftool/sample-target.xer', + ] as const; + eq('productbaseline heeft precies de twee bindende openbare orakels', { + version: baseline.version, + labels: Object.keys(baseline.files).sort(), + }, { version: 1, labels: [...pins].sort() }); + const measuredCells = new Map(); + const measuredHashes: string[] = []; + for (const label of pins) { + const bytes = readFileSync(join(root, label)); + const fileSha256 = createHash('sha256').update(bytes).digest('hex'); + measuredHashes.push(fileSha256); + const truth = scanXerGroundTruth(bytes); + const ours = solved(bytes); + const oursById = new Map(ours.tasks.map(task => [task.sourceTaskId, task])); + for (const task of truth.tasks) { + for (const axis of XER_FIDELITY_AXES) { + const oracle = task.axes[axis]; + if (oracle === null) continue; + const value = solvedAxis(oursById.get(task.taskId), axis); + const cell: [string, string, XerFidelityAxis] = [task.projectId, task.taskId, axis]; + measuredCells.set(cellKey(fileSha256, cell), { + fileSha256, + sourceProjectId: task.projectId, + sourceTaskId: task.taskId, + axis, + bucket: value === undefined ? 'missing' : value === oracle ? 'exact' : 'diff', + value, + oracle, + }); + } + } + const measurement = measureXerFidelity(truth, [ours]); + if (process.env.OPS_XER_FIDELITY_REPORT === 'detail') { + console.log(`. ${label}`); + for (const task of truth.tasks) { + const actual = oursById.get(task.taskId); + console.log(`. ${task.taskId} truth=${JSON.stringify(task.axes)} ours=${JSON.stringify(actual)}`); + } + } + const expected = baseline.files[label]; + eq(`${label}: 8 echte activiteiten in één project`, { + truthProjects: measurement.truthProjects, + solvedProjects: measurement.solvedProjects, + truthTasks: measurement.truthTasks, + solvedTasks: measurement.solvedTasks, + errors: measurement.errors, + }, { truthProjects: 1, solvedProjects: 1, truthTasks: 8, solvedTasks: 8, errors: [] }); + eq(`${label}: eerste X4a-nulmeting pint uitsluitend de zes meetbare tellerparen`, { + tasks: measurement.tasks, + projects: measurement.truthProjects, + counters: measurement.counters, + }, { tasks: expected.tasks, projects: expected.projects, counters: expected.counters }); + eq(`${label}: iedere niet-nul-nulmeting heeft een expliciete scopeverklaring`, + expected.reason.trim().length > 0, true); + } + + const transitionFiles = Object.entries(baseline.cellTransitions.files).sort(([left], [right]) => + left.localeCompare(right)); + // Herbasis 2026-09-07 (zelfde cellTransitions-schema, versie 1; geschiedenis herstart): de harness gaf vóór baan 7a `progressMode`/ + // `schedulingOptions` niet door, dus de oude v1-cellen (24+13 exact, 16+1 verbeteringen) waren + // tegen een OPTIELOZE solve gemeten. Sinds de correctie meet v1 dezelfde solve als X12; de + // overgangshistorie start daarom opnieuw vanaf de huidige exacte set (44+16), zonder verbeteringen. + // Wat er t.o.v. de oude pin per cel schoof staat in de `reason`-velden van het baselinebestand. + eq('celovergangscontract pint twee hash-orakels, 60 eerder exacte cellen en (nog) nul verbeteringen', { + version: baseline.cellTransitions.version, + measuredHashes: measuredHashes.sort(), + fileHashes: transitionFiles.map(([fileSha256]) => fileSha256), + previouslyExact: transitionFiles.map(([, file]) => file.previouslyExact.length), + improvements: transitionFiles.map(([, file]) => file.improvements.length), + improvementAxes: transitionFiles.map(([, file]) => + [...new Set(file.improvements.map(improvement => improvement.cell[2]))].sort()), + buckets: [...new Set(transitionFiles.flatMap(([, file]) => file.improvements.flatMap(improvement => + [improvement.old.bucket, improvement.current.bucket])))].sort(), + }, { + version: 1, + measuredHashes: [ + '568c19375b4e0d674c75e6aea023c98772fb33e6896755f866e4641a56197300', + 'c872c9e704797d829205f3c5486e7c4cd5aec729143a63fbcf8b0ec8e8864a3c', + ], + fileHashes: [ + '568c19375b4e0d674c75e6aea023c98772fb33e6896755f866e4641a56197300', + 'c872c9e704797d829205f3c5486e7c4cd5aec729143a63fbcf8b0ec8e8864a3c', + ], + previouslyExact: [44, 16], + improvements: [0, 0], + improvementAxes: [[], []], + buckets: [], + }); + const actualTransitions = transitionFiles.flatMap(([fileSha256, file]) => file.improvements.map(improvement => { + const measured = measuredCells.get(cellKey(fileSha256, improvement.cell)); + return { + fileSha256, + cell: improvement.cell, + old: improvement.old, + current: measured === undefined ? undefined : { bucket: measured.bucket, value: measured.value }, + oracle: measured?.oracle, + }; + })); + const expectedTransitions = transitionFiles.flatMap(([fileSha256, file]) => file.improvements.map(improvement => ({ + fileSha256, + cell: improvement.cell, + old: improvement.old, + current: improvement.current, + oracle: improvement.current.value, + }))); + eq('celovergangscontract bindt iedere verbetering aan hash/project/task/as en oude/actuele waarde', + actualTransitions, expectedTransitions); + const exactSetResults = transitionFiles.map(([fileSha256, file]) => { + const actual = [...measuredCells.values()] + .filter(cell => cell.fileSha256 === fileSha256 && cell.bucket === 'exact') + .map(cell => [cell.sourceProjectId, cell.sourceTaskId, cell.axis] as const); + const expected = [ + ...file.previouslyExact, + ...file.improvements.map(improvement => improvement.cell), + ]; + return { fileSha256, actual: sortedCells(actual), expected: sortedCells(expected) }; + }); + eq('celovergangscontract laat geen eerder exacte cel verdwijnen en geen extra verbetering binnensluipen', + exactSetResults.map(result => ({ fileSha256: result.fileSha256, exact: result.actual })), + exactSetResults.map(result => ({ fileSha256: result.fileSha256, exact: result.expected }))); + + // De vijf openbare bare-token-dragers bestaan uit twee X4b-meerprojectbestanden en precies + // deze drie X4a-enkelprojectdragers. Dit is het echte-corpusdeel van het mutatiebewijs: de + // kale FS/SS-tak uitschakelen verandert hun gerapporteerde fallback én dus deze telling. + const bareRelationPins = { + 'P6-Viewer/XER Files/sample.xer': 10, + 'crawl-xer-extra/clause-epc/MER-1-2026_update_2026-05-01.xer': 119, + 'crawl-xer/MER-1-2026_epc.xer': 119, + } as const; + for (const [label, expectedBareRelations] of Object.entries(bareRelationPins)) { + const imported = activeImportResult(readXER(readFileSync(join(root, label)))); + if (!imported.xer) throw new Error(`${label}: XER-metadata ontbreekt`); + eq(`${label}: alle kale FS/SS-relaties bereiken de echte Sequence-set zonder terugval`, { + relations: imported.sequences.length, + fallbackRelations: imported.xer.enumFallbacks.filter(item => item.family === 'relation').length, + }, { relations: expectedBareRelations, fallbackRelations: 0 }); + } +} + +if (diffs.length > 0) { + console.error(`XER-productfidelity: ${diffs.length}/${checks} checks rood`); + for (const diff of diffs) console.error(`XX ${diff}`); + process.exit(1); +} +console.log(`OK XER-productfidelity: ${checks} pins groen`); diff --git a/tests/planning/check-xer-progress-corpus.ts b/tests/planning/check-xer-progress-corpus.ts new file mode 100644 index 000000000..75a28e974 --- /dev/null +++ b/tests/planning/check-xer-progress-corpus.ts @@ -0,0 +1,92 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * X7-dossierorakel: leest uitsluitend de ruwe XER-%T/%F/%R-grammatica. Er is + * bewust geen import uit src/: dit telt en controleert P6-bronfeiten, niet de + * productiereader of diens mapping. + */ +type RawTask = Record; + +function files(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const full = join(dir, entry.name); + return entry.isDirectory() ? files(full) : entry.name.toLowerCase().endsWith('.xer') ? [full] : []; + }); +} + +function rawTasks(file: string): RawTask[] { + let table = ''; + let fields: string[] = []; + const tasks: RawTask[] = []; + for (const line of readFileSync(file, 'latin1').split(/\r?\n/)) { + const cells = line.split('\t'); + if (cells[0] === '%T') { table = cells[1] ?? ''; fields = []; } + else if (table === 'TASK' && cells[0] === '%F') fields = cells.slice(1); + else if (table === 'TASK' && cells[0] === '%R') { + tasks.push(Object.fromEntries(fields.map((field, index) => [field, cells[index + 1] ?? '']))); + } + } + return tasks; +} + +function equal(label: string, actual: unknown, expected: unknown): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error(`${label}: ${JSON.stringify(actual)} !== ${JSON.stringify(expected)}`); +} + +const root = process.env.OPS_XER_CORPUS; +if (!root) { + console.log('OK XER-X7-dossier: corpus niet aanwezig; onafhankelijk orakel structureel gecontroleerd'); +} else if (!existsSync(root)) { + throw new Error('OPS_XER_CORPUS bestaat niet'); +} else { + const tasks = files(root).flatMap(rawTasks); + const counts = { + suspend: 0, resume: 0, expectedFinish: 0, + suspendWithClock: 0, resumeWithClock: 0, expectedFinishWithClock: 0, + validPairs: 0, stopOnly: 0, reversed: 0, + CP_Drtn: 0, CP_Phys: 0, CP_Units: 0, + }; + const hasClock = (value: string): boolean => /(?:\s|T)\d{1,2}:\d{2}/.test(value); + for (const task of tasks) { + const stop = task.suspend_date; + const resume = task.resume_date; + const expectedFinish = task.expect_end_date; + if (stop) { counts.suspend++; if (hasClock(stop)) counts.suspendWithClock++; } + if (resume) { counts.resume++; if (hasClock(resume)) counts.resumeWithClock++; } + if (expectedFinish) { + counts.expectedFinish++; + if (hasClock(expectedFinish)) counts.expectedFinishWithClock++; + } + if (stop && resume) { + if (stop <= resume) counts.validPairs++; + else counts.reversed++; + } else if (stop) counts.stopOnly++; + if (task.complete_pct_type === 'CP_Drtn') counts.CP_Drtn++; + if (task.complete_pct_type === 'CP_Phys') counts.CP_Phys++; + if (task.complete_pct_type === 'CP_Units') counts.CP_Units++; + } + // De publieke 93 files bevatten exact 22 suspendvelden: twintig geldige + // paren en twee stop-only records. Deze telling komt uit de ruwe TASK-rijen, + // dus niet uit een door de reader afgeleide projectselectie. + equal('onafhankelijk P6-X7-telling', counts, { + suspend: 22, resume: 20, expectedFinish: 246, + suspendWithClock: 20, resumeWithClock: 19, expectedFinishWithClock: 246, + validPairs: 20, stopOnly: 2, reversed: 0, + CP_Drtn: 16813, CP_Phys: 1492, CP_Units: 8, + }); + + const torture = files(root).find(file => file.endsWith('/p6_torture_test_v1.xer')); + if (!torture) throw new Error('P6-torture-dossier ontbreekt'); + const byId = new Map(rawTasks(torture).map(task => [task.task_id, task])); + equal('P6 CP_Phys bewaart resterende duur als bronfeit', byId.get('A4200') && { + type: byId.get('A4200')?.complete_pct_type, percent: byId.get('A4200')?.phys_complete_pct, remaining: byId.get('A4200')?.remain_drtn_hr_cnt, + }, { type: 'CP_Phys', percent: '35', remaining: '120' }); + equal('P6 suspend zonder resume blijft bronfeit maar geen paar', byId.get('A4210') && { + stop: byId.get('A4210')?.suspend_date, resume: byId.get('A4210')?.resume_date, + }, { stop: '2026-02-27', resume: '' }); + equal('P6 out-of-sequence-dossier bevat chronologisch suspend/resume-paar', byId.get('A4230') && { + stop: byId.get('A4230')?.suspend_date, resume: byId.get('A4230')?.resume_date, type: byId.get('A4230')?.complete_pct_type, + }, { stop: '2026-02-19', resume: '2026-03-09', type: 'CP_Drtn' }); + console.log('OK XER-X7-dossier: onafhankelijke %T/%F/%R-telling en P6-torture-bronfeiten groen'); +} diff --git a/tests/planning/check-xer-progress-families.ts b/tests/planning/check-xer-progress-families.ts new file mode 100644 index 000000000..2954c058c --- /dev/null +++ b/tests/planning/check-xer-progress-families.ts @@ -0,0 +1,92 @@ +import { isMultiDocumentImport } from '@/services/importTypes'; +import { readXER, type XerReadResult } from '@/services/xer/xerReader'; +import { XerImportError } from '@/services/xer/xerTables'; + +const diffs: string[] = []; +let checks = 0; +const eq = (label: string, got: unknown, want: unknown): void => { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +}; + +const header = [ + 'ERMHDR\t23.12\t2026-08-01\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date', + '%R\tP1\tFamilies\tC1\t2026-08-10 08:00', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tproj_id\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tWerkweek\tP1\tCA_Project\t8\t40\t', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tstatus_code\tcomplete_pct_type\tcomplete_pct\tphys_complete_pct\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date\tact_start_date\tact_end_date', +] as const; + +function readRows(rows: readonly string[]): XerReadResult | XerImportError { + try { + const result = readXER(new TextEncoder().encode([...header, ...rows, '%E'].join('\n'))); + if (isMultiDocumentImport(result)) throw new Error('familiefixture gaf meerdere documenten'); + return result; + } catch (error) { + if (error instanceof XerImportError) return error; + throw error; + } +} + +function taskSummary(row: string): unknown { + const result = readRows([row]); + if (result instanceof XerImportError) { + return { error: result.xerCode, table: result.table, field: result.field }; + } + const task = result.tasks[0]; + return { + type: task?.p6CompletePctType, + status: task?.status, + completion: task?.time.completion, + remainingMinutes: task?.time.remainingMinutes, + }; +} + +eq('a CP_Drtn negeert een ongeldig fysiek percentage', taskSummary( + '%R\tD\tP1\tC1\tD\tDuur\tTT_Task\tTK_Active\tCP_Drtn\t25\tBAD\t8\t1\t2026-08-03 08:00\t2026-08-03 16:00\t2026-08-03 08:00\t', +), { type: 'CP_Drtn', status: 'STARTED', completion: 0.25, remainingMinutes: 360 }); + +eq('b CP_Phys negeert een ongeldig complete_pct', taskSummary( + '%R\tP\tP1\tC1\tP\tFysiek\tTT_Task\tTK_Active\tCP_Phys\tBAD\t75\t8\t6\t2026-08-03 08:00\t2026-08-03 16:00\t2026-08-03 08:00\t', +), { type: 'CP_Phys', status: 'STARTED', completion: 0.75, remainingMinutes: 360 }); + +eq('c CP_Units negeert een ongeldig fysiek percentage', taskSummary( + '%R\tU\tP1\tC1\tU\tEenheden\tTT_Task\tTK_Active\tCP_Units\t50\tBAD\t8\t2\t2026-08-03 08:00\t2026-08-03 16:00\t2026-08-03 08:00\t', +), { type: 'CP_Units', status: 'STARTED', completion: 0.5, remainingMinutes: 120 }); + +eq('d ontbrekend type blijft afwezig, gebruikt legacy fysiek percentage en bewaart bronremaining', taskSummary( + '%R\tL\tP1\tC1\tL\tLegacy\tTT_Task\tTK_Active\t\t25\t75\t8\t1\t2026-08-03 08:00\t2026-08-03 16:00\t2026-08-03 08:00\t', +), { type: undefined, status: 'STARTED', completion: 0.75, remainingMinutes: 60 }); + +eq('e expliciete status dwingt 0/1 af zonder percentagekolommen te parsen', [ + taskSummary('%R\tNS\tP1\tC1\tNS\tNiet gestart\tTT_Task\tTK_NotStart\tCP_Drtn\tBAD\tBAD\t8\t1\t2026-08-03 08:00\t2026-08-03 16:00\t\t'), + taskSummary('%R\tDONE\tP1\tC1\tDONE\tGereed\tTT_Task\tTK_Complete\tCP_Phys\tBAD\tBAD\t8\t0\t2026-08-03 08:00\t2026-08-03 16:00\t2026-08-03 08:00\t2026-08-03 16:00'), +], [ + { type: 'CP_Drtn', status: 'NOT_STARTED', completion: 0, remainingMinutes: 60 }, + { type: 'CP_Phys', status: 'COMPLETED', completion: 1, remainingMinutes: 0 }, +]); + +for (const [label, row, field] of [ + ['CP_Drtn', '%R\tBD\tP1\tC1\tBD\tBad duur\tTT_Task\tTK_Active\tCP_Drtn\tBAD\t75\t8\t1\t2026-08-03 08:00\t2026-08-03 16:00\t2026-08-03 08:00\t', 'complete_pct'], + ['CP_Phys', '%R\tBP\tP1\tC1\tBP\tBad fysiek\tTT_Task\tTK_Active\tCP_Phys\t25\tBAD\t8\t1\t2026-08-03 08:00\t2026-08-03 16:00\t2026-08-03 08:00\t', 'phys_complete_pct'], + ['CP_Units', '%R\tBU\tP1\tC1\tBU\tBad eenheden\tTT_Task\tTK_Active\tCP_Units\tBAD\t75\t8\t1\t2026-08-03 08:00\t2026-08-03 16:00\t2026-08-03 08:00\t', 'complete_pct'], + ['legacy', '%R\tBL\tP1\tC1\tBL\tBad legacy\tTT_Task\tTK_Active\t\tBAD\tBAD\t8\t1\t2026-08-03 08:00\t2026-08-03 16:00\t2026-08-03 08:00\t', 'phys_complete_pct'], +] as const) { + const result = readRows([row]); + eq(`${label} relevant ongeldig percentage faalt typed`, result instanceof XerImportError ? { + code: result.xerCode, table: result.table, field: result.field, + } : result, { code: 'XER_INVALID_NUMBER', table: 'TASK', field }); +} + +if (diffs.length > 0) { + console.error(`XER-X7-percentagefamilies: ${diffs.length}/${checks} checks rood`); + for (const diff of diffs) console.error(`XX ${diff}`); + process.exit(1); +} +console.log(`OK XER-X7-percentagefamilies: ${checks} checks groen`); diff --git a/tests/planning/check-xer-progress.ts b/tests/planning/check-xer-progress.ts new file mode 100644 index 000000000..b250d0653 --- /dev/null +++ b/tests/planning/check-xer-progress.ts @@ -0,0 +1,253 @@ +import { solveProject } from '@/engine/scheduler/solveProject'; +import { isMultiDocumentImport } from '@/services/importTypes'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; +import { writeIFC } from '@/services/ifc/ifcWriter'; +import { readXER, type XerReadResult } from '@/services/xer/xerReader'; +import { XerImportError } from '@/services/xer/xerTables'; + +const diffs: string[] = []; +let checks = 0; + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +function bytes(lines: readonly string[]): Uint8Array { + return new TextEncoder().encode(lines.join('\n')); +} + +function read(lines: readonly string[]): XerReadResult { + const result = readXER(bytes(lines)); + if (isMultiDocumentImport(result)) throw new Error('De enkelprojectfixture gaf meerdere documenten terug'); + return result; +} + +const header = [ + 'ERMHDR\t23.12\t2026-08-01\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date', + '%R\tP1\tVoortgang\tC1\t2026-08-10 08:00', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tproj_id\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tWerkweek\tP1\tCA_Project\t8\t40\t', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tstatus_code\tcomplete_pct_type\tcomplete_pct\tphys_complete_pct\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date\tact_start_date\tact_end_date\tsuspend_date\tresume_date\texpect_end_date', +] as const; + +const outputFirewall = read([ + ...header.slice(0, -2), + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tstatus_code\ttarget_drtn_hr_cnt\ttarget_start_date\ttarget_end_date\tearly_start_date\tlate_end_date\ttotal_float_hr_cnt\trestart_date\treend_date\tdriving_path_flag', + '%R\tFW\tP1\tC1\tFW\tFirewall\tTT_Task\tTK_NotStart\t8\t2026-08-03 08:00\t2026-08-03 16:00\t2040-01-01 01:02\t2041-02-02 03:04\t999999\t2042-03-03 05:06\t2043-04-04 07:08\tY', + '%E', +]); +const cleanFirewall = read([ + ...header.slice(0, -2), + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tstatus_code\ttarget_drtn_hr_cnt\ttarget_start_date\ttarget_end_date', + '%R\tFW\tP1\tC1\tFW\tFirewall\tTT_Task\tTK_NotStart\t8\t2026-08-03 08:00\t2026-08-03 16:00', + '%E', +]); +const solveFirewall = (imported: XerReadResult) => { + const result = solveProject({ + tasks: imported.tasks, + sequences: imported.sequences, + calendar: imported.calendar, + calendars: [imported.calendar, ...(imported.resourceCalendars ?? [])], + dataDate: imported.project.statusDate, + progressMode: imported.project.progressMode, + schedulingOptions: imported.project.schedulingOptions, + projectStartDate: imported.project.startDate, + }); + const task = result.tasks.get('FW'); + return task ? [task.earlyStart, task.earlyFinish, task.lateStart, task.lateFinish, task.totalFloat, task.freeFloat] : null; +}; +eq('X7-0a verboden P6-uitvoercellen blijven exact in het archief-readmodel', + outputFirewall.xerSourceArchive?.readModel.taskSourceRowsByProject.P1?.[0]?.cells, { + task_id: 'FW', proj_id: 'P1', clndr_id: 'C1', task_code: 'FW', task_name: 'Firewall', + task_type: 'TT_Task', status_code: 'TK_NotStart', target_drtn_hr_cnt: '8', + target_start_date: '2026-08-03 08:00', target_end_date: '2026-08-03 16:00', + early_start_date: '2040-01-01 01:02', late_end_date: '2041-02-02 03:04', + total_float_hr_cnt: '999999', restart_date: '2042-03-03 05:06', + reend_date: '2043-04-04 07:08', driving_path_flag: 'Y', + }); +eq('X7-0b verboden P6 early/late/float/restart/reend/driving-output verandert de solve niet', + solveFirewall(outputFirewall), solveFirewall(cleanFirewall)); + +const progress = read([ + ...header, + // CP_Drtn: 25% van 8 uur laat exact 6 uur over; de tegenstrijdige bronrestduur is expres 1 uur. + '%R\tD\tP1\tC1\tD\tDuur\tTT_Task\tTK_Active\tCP_Drtn\t25\t90\t8\t1\t2026-08-03 08:00\t2026-08-03 16:00\t2026-08-03 08:00\t\t\t\t', + // CP_Phys/CP_Units bewaren het percentage, maar hun expliciete restduur is de solverinvoer. + '%R\tP\tP1\tC1\tP\tFysiek\tTT_Task\tTK_Active\tCP_Phys\t10\t75\t8\t6\t2026-08-03 08:00\t2026-08-03 16:00\t2026-08-03 08:00\t\t\t\t', + '%R\tU\tP1\tC1\tU\tEenheden\tTT_Task\tTK_Active\tCP_Units\t50\t10\t8\t2\t2026-08-03 08:00\t2026-08-03 16:00\t2026-08-03 08:00\t\t\t\t', + // Alleen het volledige, chronologische paar mag de P6-solverroute openen. + '%R\tSR\tP1\tC1\tSR\tStop en hervat\tTT_Task\tTK_Active\tCP_Drtn\t25\t25\t8\t6\t2026-08-03 08:00\t2026-08-03 16:00\t2026-08-03 08:00\t\t2026-08-04 16:00\t2026-08-06 08:00\t2026-08-12 16:00', + '%R\tS\tP1\tC1\tS\tAlleen stop\tTT_Task\tTK_Active\tCP_Drtn\t25\t25\t8\t6\t2026-08-03 08:00\t2026-08-03 16:00\t2026-08-03 08:00\t\t2026-08-04 16:00\t\t', + '%R\tR\tP1\tC1\tR\tAlleen hervat\tTT_Task\tTK_Active\tCP_Drtn\t25\t25\t8\t6\t2026-08-03 08:00\t2026-08-03 16:00\t2026-08-03 08:00\t\t\t2026-08-06 08:00\t', + '%R\tW\tP1\tC1\tW\tVerkeerde volgorde\tTT_Task\tTK_Active\tCP_Drtn\t25\t25\t8\t6\t2026-08-03 08:00\t2026-08-03 16:00\t2026-08-03 08:00\t\t2026-08-08 16:00\t2026-08-06 08:00\t', + '%E', +]); + +const byId = new Map(progress.tasks.map(task => [task.id, task])); +eq('X7-1 complete_pct_type bewaart de onafhankelijke P6-voortgangsbron en identiteiten', + ['D', 'P', 'U'].map(id => { + const task = byId.get(id)!; + return [task.p6ProjectId, task.p6TaskId, task.p6CompletePctType, task.time.completion, task.time.remainingMinutes]; + }), [ + ['P1', 'D', 'CP_Drtn', 0.25, 360], + ['P1', 'P', 'CP_Phys', 0.75, 360], + ['P1', 'U', 'CP_Units', 0.5, 120], + ]); + +const statusMatrix = read([ + ...header, + '%R\tNS-P\tP1\tC1\tNS-P\tNiet gestart fysiek\tTT_Task\tTK_NotStart\tCP_Phys\t10\t75\t8\t6\t2026-08-03 08:00\t2026-08-03 16:00\t\t\t\t\t', + '%R\tNS-U\tP1\tC1\tNS-U\tNiet gestart eenheden\tTT_Task\tTK_NotStart\tCP_Units\t50\t10\t8\t2\t2026-08-03 08:00\t2026-08-03 16:00\t\t\t\t\t', + '%R\tNS-D\tP1\tC1\tNS-D\tNiet gestart duur\tTT_Task\tTK_NotStart\tCP_Drtn\t\t75\t8\t7\t2026-08-03 08:00\t2026-08-03 16:00\t\t\t\t\t', + '%R\tA-D\tP1\tC1\tA-D\tActief duur\tTT_Task\tTK_Active\tCP_Drtn\t25\t90\t8\t1\t2026-08-03 08:00\t2026-08-03 16:00\t2026-08-03 08:00\t\t\t\t', + '%R\tA-P\tP1\tC1\tA-P\tActief fysiek\tTT_Task\tTK_Active\tCP_Phys\t10\t75\t8\t6\t2026-08-03 08:00\t2026-08-03 16:00\t2026-08-03 08:00\t\t\t\t', + '%R\tA-U\tP1\tC1\tA-U\tActief eenheden\tTT_Task\tTK_Active\tCP_Units\t50\t10\t8\t2\t2026-08-03 08:00\t2026-08-03 16:00\t2026-08-03 08:00\t\t\t\t', + '%R\tDONE\tP1\tC1\tDONE\tGereed\tTT_Task\tTK_Complete\tCP_Phys\t10\t75\t8\t0\t2026-08-03 08:00\t2026-08-03 16:00\t2026-08-03 08:00\t2026-08-03 16:00\t\t\t', + '%E', +]); +const statusById = new Map(statusMatrix.tasks.map(task => [task.id, task])); +eq('X7-1a expliciete P6-status leidt; de drie percentagefamilies blijven gescheiden', + ['NS-P', 'NS-U', 'NS-D', 'A-D', 'A-P', 'A-U', 'DONE'].map(id => { + const task = statusById.get(id)!; + return [id, task.status, task.time.completion, task.time.remainingMinutes]; + }), [ + ['NS-P', 'NOT_STARTED', 0, 360], + ['NS-U', 'NOT_STARTED', 0, 120], + ['NS-D', 'NOT_STARTED', 0, 420], + ['A-D', 'STARTED', 0.25, 360], + ['A-P', 'STARTED', 0.75, 360], + ['A-U', 'STARTED', 0.5, 120], + ['DONE', 'COMPLETED', 1, 0], + ]); + +let invalidPercentage: unknown; +try { + read([ + ...header, + '%R\tBAD\tP1\tC1\tBAD\tOngeldig percentage\tTT_Task\tTK_Active\tCP_Phys\t10\tniet-een-getal\t8\t6\t2026-08-03 08:00\t2026-08-03 16:00\t2026-08-03 08:00\t\t\t\t', + '%E', + ]); +} catch (error) { + invalidPercentage = error; +} +eq('X7-1b ongeldig relevant percentage faalt getypeerd en veldgericht', invalidPercentage instanceof XerImportError ? { + code: invalidPercentage.xerCode, + table: invalidPercentage.table, + field: invalidPercentage.field, +} : invalidPercentage, { code: 'XER_INVALID_NUMBER', table: 'TASK', field: 'phys_complete_pct' }); +eq('X7-2 suspend/resume is universele brondata, maar uitsluitend een geldig P6-paar opent de bronvlag', + ['SR', 'S', 'R', 'W'].map(id => { + const task = byId.get(id)!; + return [task.time.stop, task.time.resume, task.p6SuspendResume]; + }), [ + ['2026-08-04T16:00', '2026-08-06T08:00', true], + ['2026-08-04T16:00', undefined, undefined], + [undefined, '2026-08-06T08:00', undefined], + ['2026-08-08T16:00', '2026-08-06T08:00', undefined], + ]); +eq('X7-3 expect_end_date blijft opgeslagen, onafhankelijk van de X5-projectvlag', + [byId.get('SR')?.p6ExpectedFinish, progress.project.schedulingOptions?.useExpectedFinishDates], + ['2026-08-12T16:00', true]); + +function solved(expectFlag: boolean): string | undefined { + const imported = read([ + ...header, + '%R\tA\tP1\tC1\tA\tVerwacht einde\tTT_Task\tTK_Active\tCP_Phys\t10\t75\t8\t2\t2026-08-03 08:00\t2026-08-03 16:00\t2026-08-03 08:00\t\t\t\t2026-08-12 16:00', + '%E', + ]); + imported.project.schedulingOptions = { useExpectedFinishDates: expectFlag }; + const result = solveProject({ + tasks: imported.tasks, + sequences: imported.sequences, + calendar: imported.calendar, + calendars: [imported.calendar, ...(imported.resourceCalendars ?? [])], + dataDate: imported.project.statusDate, + progressMode: imported.project.progressMode, + schedulingOptions: imported.project.schedulingOptions, + projectStartDate: imported.project.startDate, + }); + return result.tasks.get('A')?.earlyFinish; +} +eq('X7-4 expect_end_date stuurt uitsluitend met de expliciete X5-vlag', + [solved(false), solved(true)], ['2026-08-10T10:00', '2026-08-12T16:00']); + +function solvedResume(taskId: string): string | undefined { + const result = solveProject({ + tasks: progress.tasks.filter(task => task.id === taskId), + sequences: [], + calendar: progress.calendar, + calendars: [progress.calendar, ...(progress.resourceCalendars ?? [])], + dataDate: progress.project.statusDate, + progressMode: progress.project.progressMode, + schedulingOptions: progress.project.schedulingOptions, + projectStartDate: progress.project.startDate, + }); + return result.tasks.get(taskId)?.earlyFinish; +} +eq('X7-5 losse P6-resume blijft brondata en opent nooit de MSP-resume-route', + solvedResume('R'), '2026-08-10T14:00'); + +const reloaded = readIFC(writeIFC(progress)); +const reloadedByCode = new Map(reloaded.tasks.map(task => [task.wbsCode, task])); +eq('X7-5a writer→reader behoudt bronidentiteit, voortgangstype, verwacht einde en firewall', + ['SR', 'R'].map(code => { + const task = reloadedByCode.get(code)!; + return [code, task.p6ProjectId, task.p6TaskId, task.p6CompletePctType, + task.p6ExpectedFinish, task.p6SuspendResume, task.time.stop, task.time.resume]; + }), [ + ['SR', 'P1', 'SR', 'CP_Drtn', '2026-08-12T16:00', true, '2026-08-04T16:00', '2026-08-06T08:00'], + ['R', 'P1', 'R', 'CP_Drtn', undefined, undefined, undefined, '2026-08-06T08:00'], + ]); +const reloadedLooseResume = reloadedByCode.get('R')!; +const reloadedSolve = solveProject({ + tasks: [reloadedLooseResume], + sequences: [], + calendar: reloaded.calendar, + calendars: [reloaded.calendar, ...(reloaded.resourceCalendars ?? [])], + dataDate: reloaded.project.statusDate, + progressMode: reloaded.project.progressMode, + schedulingOptions: reloaded.project.schedulingOptions, + projectStartDate: reloaded.project.startDate, +}); +eq('X7-5b losse P6-resume erft ook ná IFC-reload nooit de MSP-resume-route', + reloadedSolve.tasks.get(reloadedLooseResume.id)?.earlyFinish, '2026-08-10T14:00'); + +const multi = readXER(bytes([ + 'ERMHDR\t23.12\t2026-08-01\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name', + '%R\tP1\tEen', + '%R\tP2\tTwee', + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_code\ttask_name\tcomplete_pct_type\tcomplete_pct\tphys_complete_pct\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date', + '%R\tGELIJK\tP1\tP1\tP1-taak\tCP_Drtn\t25\t25\t8\t6\t2026-08-03\t2026-08-03', + '%R\tGELIJK\tP2\tP2\tP2-taak\tCP_Phys\t10\t75\t8\t2\t2026-08-03\t2026-08-03', + '%E', +])); +if (!isMultiDocumentImport(multi)) { + diffs.push('X7-6 projectfilter-fixture gaf geen meervoudige import terug'); +} else { + eq('X7-6 file-wide index is lineair maar lekt geen gelijke task_id tussen projecten', + multi.documents.map(document => document.result.tasks.filter(task => !task.isSummary).map(task => [ + task.p6ProjectId, task.p6TaskId, task.name, task.p6CompletePctType, task.time.remainingTime, + ])), [ + [['P1', 'GELIJK', 'P1-taak', 'CP_Drtn', 0.75]], + [['P2', 'GELIJK', 'P2-taak', 'CP_Phys', 0.25]], + ]); +} + +if (diffs.length > 0) { + console.error(`XER-X7-voortgang: ${diffs.length}/${checks} checks rood`); + for (const diff of diffs) console.error(`XX ${diff}`); + process.exit(1); +} +console.log(`OK XER-X7-voortgang: ${checks} parser-/solverfirewallchecks groen`); diff --git a/tests/planning/check-xer-project-data-date.ts b/tests/planning/check-xer-project-data-date.ts new file mode 100644 index 000000000..fb84f76b2 --- /dev/null +++ b/tests/planning/check-xer-project-data-date.ts @@ -0,0 +1,191 @@ +import { solveProject } from '@/engine/scheduler/solveProject'; +import { explainCompletedXerLoeActualFinishEligibility } from '@/engine/scheduler/p6CompletedRouteTrace'; +import { isMultiDocumentImport } from '@/services/importTypes'; +import { readXER, type XerReadResult } from '@/services/xer/xerReader'; +import { parseInstant } from '@/utils/dateUtils'; + +const diffs: string[] = []; +let checks = 0; + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +function bytes(lines: readonly string[]): Uint8Array { + return new TextEncoder().encode(lines.join('\n')); +} + +function read(source: Uint8Array): XerReadResult { + const parsed = readXER(source); + if (isMultiDocumentImport(parsed)) throw new Error('PROJECT-data-date-fixture gaf onverwacht meerdere documenten terug'); + return parsed; +} + +function fixture(fields: readonly string[], values: Readonly>): Uint8Array { + const projectFields = ['proj_id', 'proj_short_name', 'clndr_id', ...fields]; + const projectValues = projectFields.map(field => ({ + proj_id: 'P1', + proj_short_name: 'PROJECT.data_date-dialect', + clndr_id: 'C1', + ...values, + })[field] ?? ''); + return bytes([ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_data', + '%R\tC1\tStandaard\t', + '%T\tPROJECT', + `%F\t${projectFields.join('\t')}`, + `%R\t${projectValues.join('\t')}`, + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_code\ttask_name\ttarget_start_date\ttarget_end_date', + '%R\tT1\tP1\tA1\tBronactiviteit\t2026-01-02\t2026-01-03', + '%E', + ]); +} + +const cases = [ + { name: 'last-only geldig', fields: ['last_recalc_date'], values: { last_recalc_date: '2026-01-10' }, statusDate: '2026-01-10' }, + { name: 'last-only ongeldig', fields: ['last_recalc_date'], values: { last_recalc_date: 'geen-datum' }, statusDate: undefined }, + { name: 'last-only leeg', fields: ['last_recalc_date'], values: { last_recalc_date: '' }, statusDate: undefined }, + { name: 'data-only geldig', fields: ['data_date'], values: { data_date: '2026-01-05' }, statusDate: '2026-01-05' }, + { name: 'data-only ongeldig', fields: ['data_date'], values: { data_date: 'geen-datum' }, statusDate: undefined }, + { name: 'data-only leeg', fields: ['data_date'], values: { data_date: '' }, statusDate: undefined }, + { name: 'beide geldig gelijk', fields: ['last_recalc_date', 'data_date'], values: { last_recalc_date: '2026-01-05', data_date: '2026-01-05' }, statusDate: '2026-01-05' }, + { name: 'beide geldig verschillend', fields: ['last_recalc_date', 'data_date'], values: { last_recalc_date: '2026-01-10', data_date: '2026-01-05' }, statusDate: '2026-01-10' }, + { name: 'beide last leeg data geldig', fields: ['last_recalc_date', 'data_date'], values: { last_recalc_date: '', data_date: '2026-01-05' }, statusDate: undefined }, + { name: 'beide last ongeldig data geldig', fields: ['last_recalc_date', 'data_date'], values: { last_recalc_date: 'geen-datum', data_date: '2026-01-05' }, statusDate: undefined }, + { name: 'beide kolommen afwezig', fields: [], values: {}, statusDate: undefined }, +] as const; + +for (const test of cases) { + const result = read(fixture(test.fields, test.values)); + eq(`PROJECT-data-date ${test.name}: statusDate volgt uitsluitend de geselecteerde %F-kolom`, result.project.statusDate, test.statusDate); +} + +// Dit is de bestaande last_recalc_date-dialectvorm. De bytekopie én alle betrokken +// productvelden zijn bewust gepind: de data_date-kandidaat mag deze route niet wijzigen. +const legacySource = fixture(['last_recalc_date'], { last_recalc_date: '2026-04-02' }); +const legacyBefore = Array.from(legacySource); +const legacy = read(legacySource); +eq('PROJECT-data-date legacy last-dialect: bronbytes blijven identiek na lezen', Array.from(legacySource), legacyBefore); +eq('PROJECT-data-date legacy last-dialect: productvelden blijven byte/productveld-identiek', { + id: legacy.project.id, + name: legacy.project.name, + startDate: legacy.project.startDate, + endDate: legacy.project.endDate, + modifiedAt: legacy.project.modifiedAt, + statusDate: legacy.project.statusDate, + calendarId: legacy.project.calendarId, + task: legacy.tasks.map(task => ({ + id: task.id, + scheduleStart: task.time.scheduleStart, + scheduleFinish: task.time.scheduleFinish, + })), +}, { + id: 'P1', + name: 'PROJECT.data_date-dialect', + startDate: '2026-01-02', + endDate: '2026-01-03', + modifiedAt: '2026-04-02', + statusDate: '2026-04-02', + calendarId: 'C1', + task: [{ id: 'T1', scheduleStart: '2026-01-02', scheduleFinish: '2026-01-03' }], +}); + +// data_date is uitsluitend een compatibiliteitsbron voor project.statusDate. De reeds bestaande +// TASK-terugval voor een ontbrekend targetvenster blijft exclusief op last_recalc_date gebaseerd. +const dataOnlyMissingTargetWindow = read(bytes([ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_data', + '%R\tC1\tStandaard\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tdata_date', + '%R\tP1\tPROJECT.data_date-dialect\tC1\t2026-01-05', + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_code\ttask_name\ttarget_start_date\ttarget_end_date', + '%R\tT1\tP1\tA1\tOntbrekend targetvenster\t\t', + '%E', +])); +eq('PROJECT-data-date data-only zonder targetvenster: statusDate gebruikt data_date', + dataOnlyMissingTargetWindow.project.statusDate, '2026-01-05'); +eq('PROJECT-data-date data-only zonder targetvenster: scheduleStart houdt oude last-only-terugval', + dataOnlyMissingTargetWindow.tasks[0]?.time.scheduleStart, '1970-01-01'); +eq('PROJECT-data-date data-only zonder targetvenster: scheduleFinish houdt oude last-only-terugval', + dataOnlyMissingTargetWindow.tasks[0]?.time.scheduleFinish, '1970-01-01'); + +// End-to-end combinatie: PROJECT heeft uitsluitend data_date, terwijl de voltooide TT_LOE via +// precies de bestaande, smalle actualFinish-route moet blijven lopen. De LOE-batterij bewaakt de +// volledige fail-closed matrix; deze fixture verbindt haar bewezen vorm aan het nieuwe dialect. +const dataDateOnlyCompletedLoe = read(bytes([ + 'ERMHDR\t23.12\t2026-09-01\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tproj_id\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tWerkweek\tP1\tCA_Project\t8\t40\t(0||CalendarData()( (0||DaysOfWeek()( (0||1()( (0||0(s|08:00|f|12:00)()) (0||1(s|13:00|f|17:00)()))) (0||2()( (0||0(s|08:00|f|12:00)()) (0||1(s|13:00|f|17:00)()))) (0||3()( (0||0(s|08:00|f|12:00)()) (0||1(s|13:00|f|17:00)()))) (0||4()( (0||0(s|08:00|f|12:00)()) (0||1(s|13:00|f|17:00)()))) (0||5()( (0||0(s|08:00|f|12:00)()) (0||1(s|13:00|f|17:00)()))) (0||6()()) (0||7()()))) (0||Exceptions()())))', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tdata_date\tplan_start_date\tplan_end_date\trem_target_link_flag', + '%R\tP1\tPROJECT.data_date completed LOE\tC1\t2026-06-30 17:00\t2026-01-05 08:00\t2026-06-30 17:00\tY', + '%T\tSCHEDOPTIONS', + '%F\tproj_id\tsched_use_project_end_date_for_float', + '%R\tP1\tN', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\tcomplete_pct_type\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date\tact_start_date\tact_end_date\tsuspend_date\tresume_date', + '%R\tP\tP1\tC1\tPRED\tSS voorganger\tTT_Task\tDT_FixedDUR\tTK_NotStart\tCP_Drtn\t8\t8\t2026-01-05 08:00\t2026-01-05 17:00\t\t\t\t', + '%R\tL\tP1\tC1\tLOE\tLange voltooide LOE\tTT_LOE\tDT_FixedDrtn\tTK_Complete\tCP_Drtn\t352\t0\t2026-01-05 08:00\t2026-03-06 17:00\t2026-01-05 08:00\t2026-03-06 17:00\t\t', + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%R\tR-SS\tL\tP\tP1\tP1\tPR_SS\t0', + '%E', +])); +const dataDateOnlyLoe = dataDateOnlyCompletedLoe.tasks.find(task => task.id === 'L'); +if (!dataDateOnlyLoe) throw new Error('PROJECT.data_date completed-LOE-fixture mist taak L'); +const dataDateOnlyIncoming = dataDateOnlyCompletedLoe.sequences.filter(sequence => sequence.successorId === dataDateOnlyLoe.id); +const dataDateOnlyOutgoing = dataDateOnlyCompletedLoe.sequences.filter(sequence => sequence.predecessorId === dataDateOnlyLoe.id); +eq('PROJECT-data-date completed LOE: uitsluitend data_date levert de statusdatum voor de bestaande route', + dataDateOnlyCompletedLoe.project.statusDate, '2026-06-30T17:00'); +eq('PROJECT-data-date completed LOE: TT_LOE blijft binnen de smalle actualFinish-route', + explainCompletedXerLoeActualFinishEligibility( + dataDateOnlyLoe, + dataDateOnlyCompletedLoe.project.statusDate ? parseInstant(dataDateOnlyCompletedLoe.project.statusDate) : null, + dataDateOnlyCompletedLoe.project.schedulingOptions, + dataDateOnlyIncoming, + dataDateOnlyOutgoing, + ), { eligible: true, reason: 'eligible' }); +const dataDateOnlyLoeSolve = solveProject({ + tasks: dataDateOnlyCompletedLoe.tasks, + sequences: dataDateOnlyCompletedLoe.sequences, + calendar: dataDateOnlyCompletedLoe.calendar, + calendars: dataDateOnlyCompletedLoe.resourceCalendars ?? [], + dataDate: dataDateOnlyCompletedLoe.project.statusDate, + progressMode: dataDateOnlyCompletedLoe.project.progressMode, + schedulingOptions: dataDateOnlyCompletedLoe.project.schedulingOptions, + projectStartDate: dataDateOnlyCompletedLoe.project.startDate, + projectEndDate: dataDateOnlyCompletedLoe.project.endDate, +}); +if (dataDateOnlyLoeSolve.error) throw new Error(dataDateOnlyLoeSolve.error); +const dataDateOnlyLoeSolved = dataDateOnlyLoeSolve.tasks.get(dataDateOnlyLoe.id); +if (!dataDateOnlyLoeSolved) throw new Error('PROJECT.data_date completed-LOE-solver mist taak L'); +eq('PROJECT-data-date completed LOE: bestaande actualFinish-uitkomst blijft exact behouden', { + es: dataDateOnlyLoeSolved.earlyStart, + ef: dataDateOnlyLoeSolved.earlyFinish, + ls: dataDateOnlyLoeSolved.lateStart, + lf: dataDateOnlyLoeSolved.lateFinish, + tf: dataDateOnlyLoeSolved.totalFloat, + ff: dataDateOnlyLoeSolved.freeFloat, +}, { + es: '2026-01-05T08:00', ef: '2026-03-06T17:00', + ls: '2026-01-05T08:00', lf: '2026-03-06T17:00', + tf: 0, ff: 0, +}); + +if (diffs.length > 0) { + console.error(`XX XER PROJECT.data_date (${checks} checks)`); + for (const diff of diffs) console.error(`XX ${diff}`); + process.exit(1); +} + +console.log(`OK XER PROJECT.data_date (${checks} checks)`); diff --git a/tests/planning/check-xer-project-index.ts b/tests/planning/check-xer-project-index.ts new file mode 100644 index 000000000..e76b4d8ae --- /dev/null +++ b/tests/planning/check-xer-project-index.ts @@ -0,0 +1,220 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { isMultiDocumentImport } from '@/services/importTypes'; +import { indexXerProjectRows, readXER } from '@/services/xer/xerReader'; +import { XerImportError, type XerRow, type XerTables } from '@/services/xer/xerTables'; + +const projects = Array.from({ length: 12 }, (_, index) => `P${index + 1}`); +const taskIds = Array.from({ length: 100 }, (_, index) => `T${index + 1}`); +const wbsIds = Array.from({ length: 20 }, (_, index) => `W${index + 1}`); + +function row(line: number, cells: Record): XerRow { + return { line, cells }; +} + +const taskRows = projects.flatMap((projectId, projectIndex) => taskIds.map((taskId, taskIndex) => row( + projectIndex * taskIds.length + taskIndex + 1, + { proj_id: projectId, task_id: taskId }, +))); +const wbsRows = projects.flatMap((projectId, projectIndex) => wbsIds.map((wbsId, wbsIndex) => row( + projectIndex * wbsIds.length + wbsIndex + 1, + { proj_id: projectId, wbs_id: wbsId }, +))); +const relationRows = projects.flatMap((projectId, projectIndex) => Array.from({ length: 30 }, (_, relationIndex) => row( + projectIndex * 30 + relationIndex + 1, + { proj_id: projectId, pred_proj_id: projectId, task_id: `T${relationIndex + 2}`, pred_task_id: `T${relationIndex + 1}` }, +))); +const tables: XerTables = { + header: { version: 'test', defaultCurrencyCode: 'EUR' }, + tables: new Map([ + ['TASK', { name: 'TASK', fields: [], rows: taskRows }], + ['PROJWBS', { name: 'PROJWBS', fields: [], rows: wbsRows }], + ['TASKPRED', { name: 'TASKPRED', fields: [], rows: relationRows }], + ]), + report: { + encoding: 'utf-8', endMarkerSeen: true, issues: [], unknownTables: [], unknownFields: [], + }, + numberFormat: { decimal: '.', group: null, source: 'default', currencyCode: 'EUR' }, +}; +const index = indexXerProjectRows(tables); +const expectedVisits = taskRows.length + wbsRows.length + relationRows.length; +if (index.visitCount !== expectedVisits) { + throw new Error(`lineaire index verwacht ${expectedVisits} bezoeken, kreeg ${index.visitCount}`); +} +for (const projectId of projects) { + const counts = [ + index.tasksByProject.get(projectId)?.length, + index.wbsByProject.get(projectId)?.length, + index.relationsByProject.get(projectId)?.length, + ]; + if (JSON.stringify(counts) !== JSON.stringify([100, 20, 30])) { + throw new Error(`${projectId} verwacht [100,20,30], kreeg ${JSON.stringify(counts)}`); + } +} + +const blankRelationIndex = indexXerProjectRows({ + ...tables, + tables: new Map([ + ['TASK', { name: 'TASK', fields: [], rows: [ + row(1, { proj_id: 'P1', task_id: 'A1' }), row(2, { proj_id: 'P1', task_id: 'B1' }), + row(3, { proj_id: 'P2', task_id: 'A2' }), row(4, { proj_id: 'P2', task_id: 'B2' }), + ] }], + ['PROJWBS', { name: 'PROJWBS', fields: [], rows: [] }], + ['TASKPRED', { name: 'TASKPRED', fields: [], rows: [ + row(5, { proj_id: '', pred_proj_id: '', task_id: 'B1', pred_task_id: 'A1' }), + ] }], + ]), +}); +if (blankRelationIndex.relationsByProject.get('P1')?.length !== 1 + || blankRelationIndex.relationsByProject.get('P2')?.length !== undefined) { + throw new Error('lege TASKPRED.proj_id moet via de reeds geïndexeerde lokale eindpunten bij P1 landen'); +} + +const reusedEndpointIndex = indexXerProjectRows({ + ...tables, + tables: new Map([ + ['TASK', { name: 'TASK', fields: [], rows: [ + row(1, { proj_id: 'P1', task_id: 'A' }), row(2, { proj_id: 'P1', task_id: 'B' }), + row(3, { proj_id: 'P2', task_id: 'A' }), + ] }], + ['PROJWBS', { name: 'PROJWBS', fields: [], rows: [] }], + ['TASKPRED', { name: 'TASKPRED', fields: [], rows: [ + row(4, { proj_id: '', pred_proj_id: '', task_id: 'B', pred_task_id: 'A' }), + ] }], + ]), +}); +if (reusedEndpointIndex.relationsByProject.get('P1')?.length !== 1 + || reusedEndpointIndex.relationsByProject.get('P2') !== undefined) { + throw new Error('ongescopeerde A→B met alleen B uniek-lokaal moet uitsluitend aan P1 worden toegewezen'); +} + +const uniqueIntersectionIndex = indexXerProjectRows({ + ...tables, + tables: new Map([ + ['TASK', { name: 'TASK', fields: [], rows: [ + row(1, { proj_id: 'P1', task_id: 'A' }), row(2, { proj_id: 'P1', task_id: 'B' }), + row(3, { proj_id: 'P2', task_id: 'A' }), row(4, { proj_id: 'P3', task_id: 'B' }), + ] }], + ['PROJWBS', { name: 'PROJWBS', fields: [], rows: [] }], + ['TASKPRED', { name: 'TASKPRED', fields: [], rows: [ + row(5, { proj_id: '', pred_proj_id: '', task_id: 'B', pred_task_id: 'A' }), + ] }], + ]), +}); +if (uniqueIntersectionIndex.relationsByProject.get('P1')?.length !== 1 + || uniqueIntersectionIndex.relationsByProject.get('P2') !== undefined + || uniqueIntersectionIndex.relationsByProject.get('P3') !== undefined + || uniqueIntersectionIndex.relationResolutionIssues.length !== 0) { + throw new Error('ongescopeerde A→B moet naar de unieke lokale doorsnede P1, ook als beide ids elders worden hergebruikt'); +} + +const ambiguousRows = Array.from({ length: 30 }, (_, index) => row( + 25 + index, + { proj_id: '', pred_proj_id: '', task_id: 'B', pred_task_id: 'A', task_pred_id: `R${index + 1}` }, +)); +const ambiguousIndex = indexXerProjectRows({ + ...tables, + tables: new Map([ + ['TASK', { name: 'TASK', fields: [], rows: projects.flatMap((projectId, index) => [ + row(index * 2 + 1, { proj_id: projectId, task_id: 'A' }), + row(index * 2 + 2, { proj_id: projectId, task_id: 'B' }), + ]) }], + ['PROJWBS', { name: 'PROJWBS', fields: [], rows: [] }], + ['TASKPRED', { name: 'TASKPRED', fields: [], rows: ambiguousRows }], + ]), +}); +const ambiguousFanout = Array.from(ambiguousIndex.relationsByProject.values()) + .reduce((sum, rows) => sum + rows.length, 0); +if (ambiguousIndex.visitCount !== 54 || ambiguousFanout !== 0 + || ambiguousIndex.relationResolutionIssues.length !== 30 + || ambiguousIndex.relationResolutionIssues.some(issue => issue.reason !== 'ambiguous')) { + throw new Error(`30 ambigue ongescopeerde relaties mogen niet 360× uitwaaieren: bezoeken=${ambiguousIndex.visitCount}, fanout=${ambiguousFanout}, issues=${JSON.stringify(ambiguousIndex.relationResolutionIssues)}`); +} + +const ambiguousXer = [ + 'ERMHDR\t23.12\t2026-08-01\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name', + ...projects.map(projectId => `%R\t${projectId}\tProject ${projectId}`), + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_code\ttask_name\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date', + ...projects.flatMap(projectId => ['A', 'B'].map(taskId => `%R\t${taskId}\t${projectId}\t${taskId}\t${projectId}-${taskId}\t8\t8\t2026-08-03\t2026-08-03`)), + '%T\tTASKPRED', + '%F\ttask_pred_id\tproj_id\ttask_id\tpred_proj_id\tpred_task_id\tpred_type', + ...Array.from({ length: 30 }, (_, index) => `%R\tR${index + 1}\t\tB\t\tA\tPR_FS`), + '%E', +]; +let ambiguousError: unknown; +try { + readXER(new TextEncoder().encode(ambiguousXer.join('\n'))); +} catch (error) { + ambiguousError = error; +} +if (!(ambiguousError instanceof XerImportError) + || ambiguousError.xerCode !== 'XER_AMBIGUOUS_LOCAL_RELATION' + || ambiguousError.table !== 'TASKPRED' + || ambiguousError.field !== 'proj_id' + || !ambiguousError.message.includes('niet één uniek lokaal project')) { + throw new Error(`ambigue ongescopeerde relatie moet gericht typed falen: ${String(ambiguousError)}`); +} + +const danglingXer = ambiguousXer.map(line => line.startsWith('%R\tR1\t') + ? '%R\tR1\t\tB\t\tONTBREEKT\tPR_FS' + : line).filter(line => !/^%R\tR(?:[2-9]|[12]\d|30)\t/.test(line)); +let danglingError: unknown; +try { + readXER(new TextEncoder().encode(danglingXer.join('\n'))); +} catch (error) { + danglingError = error; +} +if (!(danglingError instanceof XerImportError) + || danglingError.xerCode !== 'XER_DANGLING_LOCAL_RELATION' + || danglingError.table !== 'TASKPRED' + || danglingError.field !== 'pred_task_id') { + throw new Error(`dangling ongescopeerde relatie moet gericht typed falen: ${String(danglingError)}`); +} + +const root = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..'); +const readerSource = readFileSync(join(root, 'src/services/xer/xerReader.ts'), 'utf8'); +for (const fullScan of [ + "(tables.tables.get('TASK')?.rows ?? []).filter(row => row.cells.proj_id === projectId)", + "(tables.tables.get('PROJWBS')?.rows ?? []).filter(row => row.cells.proj_id === projectId)", + "(tables.tables.get('TASKPRED')?.rows ?? []).filter(row =>", +]) { + if (readerSource.includes(fullScan)) { + throw new Error(`P×M-terugval: readXerProject voert opnieuw een per-project full scan uit: ${fullScan}`); + } +} + +const taskFields = 'task_id\tproj_id\twbs_id\ttask_code\ttask_name\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date'; +const xer = [ + 'ERMHDR\t23.12\t2026-08-01\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name', + ...projects.map(projectId => `%R\t${projectId}\tProject ${projectId}`), + '%T\tPROJWBS', + '%F\twbs_id\tproj_id\tparent_wbs_id\tseq_num\twbs_short_name\twbs_name', + ...projects.flatMap(projectId => wbsIds.map((wbsId, index) => `%R\t${wbsId}\t${projectId}\t\t${index + 1}\t${wbsId}\tWBS ${wbsId}`)), + '%T\tTASK', + `%F\t${taskFields}`, + ...projects.flatMap(projectId => taskIds.map((taskId, index) => `%R\t${taskId}\t${projectId}\tW${(index % wbsIds.length) + 1}\t${taskId}\t${projectId}-${taskId}\t8\t8\t2026-08-03 08:00\t2026-08-03 16:00`)), + '%T\tTASKPRED', + '%F\ttask_pred_id\tproj_id\ttask_id\tpred_proj_id\tpred_task_id\tpred_type', + ...projects.flatMap(projectId => Array.from({ length: 30 }, (_, index) => `%R\tR${index + 1}\t${projectId}\tT${index + 2}\t${projectId}\tT${index + 1}\tPR_FS`)), + '%E', +].join('\n'); +const imported = readXER(new TextEncoder().encode(xer)); +if (!isMultiDocumentImport(imported)) throw new Error('12-projectfixture gaf geen meervoudige import terug'); +if (imported.documents.length !== 12) throw new Error(`12 projectdocumenten verwacht, kreeg ${imported.documents.length}`); +for (const document of imported.documents) { + const leaves = document.result.tasks.filter(task => !task.isSummary); + if (leaves.length !== 100 || document.result.sequences.length !== 30) { + throw new Error(`${document.result.project.id} lekte of verloor rows: ${leaves.length} taken, ${document.result.sequences.length} relaties`); + } + if (leaves[0]?.p6TaskId !== 'T1' || leaves[0]?.name !== `${document.result.project.id}-T1`) { + throw new Error(`${document.result.project.id} task_id-only projectfilter lekt identiteiten`); + } +} + +console.log(`OK XER-projectindex: 12 projecten, 1.200 gelijke task_id's, 240 WBS, 360 relaties, exact ${index.visitCount} bezoeken`); diff --git a/tests/planning/check-xer-reader.ts b/tests/planning/check-xer-reader.ts new file mode 100644 index 000000000..45d4d03eb --- /dev/null +++ b/tests/planning/check-xer-reader.ts @@ -0,0 +1,676 @@ +import { readXER, type XerReadResult } from '@/services/xer/xerReader'; +import { isMultiDocumentImport } from '@/services/importTypes'; +import { XerImportError } from '@/services/xer/xerTables'; +import { solveProject } from '@/engine/scheduler/solveProject'; +import { expandSummaryRelations } from '@/engine/scheduler/expandSummaryRelations'; +import { computeResourceLoad } from '@/engine/scheduler/ResourceLoad'; +import { levelResources } from '@/engine/scheduler/ResourceLeveler'; +import { writeIFC } from '@/services/ifc/ifcWriter'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; + +const diffs: string[] = []; +let checks = 0; + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +function ok(label: string, value: unknown): void { + eq(label, Boolean(value), true); +} + +function bytes(lines: readonly string[]): Uint8Array { + return new TextEncoder().encode(lines.join('\n')); +} + +function read(lines: readonly string[]): XerReadResult { + const parsed = readXER(bytes(lines)); + if (isMultiDocumentImport(parsed)) throw new Error('Enkelprojectfixture gaf een meervoudige import terug'); + return parsed; +} + +const fixture = [ + 'ERMHDR\t23.12\t2026-04-01\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tproj_id\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tDagploeg\tP1\tCA_Project\t8\t40\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tdef_duration_type\tplan_start_date\tplan_end_date', + '%R\tP1\tBrugrenovatie\tC1\t2026-04-02 09:30\tdt_fixeddur2\t2099-01-01 00:00\t2099-12-31 00:00', + '%T\tPROJWBS', + '%F\twbs_id\tproj_id\tparent_wbs_id\tseq_num\twbs_short_name\twbs_name', + '%R\tW-CHILD\tP1\tW-ROOT\t20\t1.1\tOnderbouw', + '%R\tW-ROOT\tP1\t\t10\t1\tBrug', + '%R\tW-SIB\tP1\tW-ROOT\t30\t1.2\tBovenbouw', + '%T\tTASK', + '%F\ttask_id\tproj_id\twbs_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\tpriority_type\tphys_complete_pct\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date\tact_start_date\tact_end_date\tcstr_type\tcstr_date\tcstr_type2\tcstr_date2\tearly_start_date\tearly_end_date\tlate_start_date\tlate_end_date\tplan_start_date\tplan_end_date', + '%R\tT1\tP1\tW-CHILD\tC1\tA100\tStartsein\ttt_mile\tDT_FixedDUR2\ttk_notstart\tPT_Normal\t0\t0\t0\t2026-04-06 08:00\t2026-04-06 08:00\t\t\t\t\t\t\t2099-01-01 00:00\t2099-01-02 00:00\t2099-01-03 00:00\t2099-01-04 00:00\t2099-01-05 00:00\t2099-01-06 00:00', + '%R\tT2\tP1\tW-CHILD\tC1\tA200\tFundering\tTT_LOE\tDT_FixedDrtn\tTK_Active\tPT_High\t25\t16\t12\t2026-04-06 08:00\t2026-04-07 16:00\t2026-04-06 08:00\t\tCS_MANSTART\t2026-04-06 08:00\tCS_MEOB\t2026-04-08 16:00\t2099-02-01 00:00\t2099-02-02 00:00\t2099-02-03 00:00\t2099-02-04 00:00\t2099-02-05 00:00\t2099-02-06 00:00', + '%R\tT3\tP1\tW-SIB\tC1\tA300\tLigger\tTT_Rsrc\tDT_FixedQty\tTK_Complete\tPT_Top\t100\t8\t0\t2026-04-08 08:00\t2026-04-08 16:00\t2026-04-08 08:00\t2026-04-08 16:00\tCS_MANFINISH\t2026-04-08 16:00\t\t\t2099-03-01 00:00\t2099-03-02 00:00\t2099-03-03 00:00\t2099-03-04 00:00\t2099-03-05 00:00\t2099-03-06 00:00', + '%R\tT4\tP1\tW-SIB\tC1\tA400\tControle\tTT_WBS\tDT_FixedRate\tTK_NotStart\tPT_Normal\t0\t8\t8\t2026-04-09 08:00\t2026-04-09 16:00\t\t\tCS_MSOA\t2026-04-09 08:00\tCS_MEOA\t2026-04-10 08:00\t2099-04-01 00:00\t2099-04-02 00:00\t2099-04-03 00:00\t2099-04-04 00:00\t2099-04-05 00:00\t2099-04-06 00:00', + '%R\tT5\tP1\tW-SIB\tC1\tA500\tOnbekende bronwaarden\tTT_Alien\tDT_Alien\tTK_Alien\tPT_Alien\t0\t8\t8\t2026-04-10 08:00\t2026-04-10 16:00\t\t\tCS_Alien\t2026-04-10 08:00\t\t\t2099-05-01 00:00\t2099-05-02 00:00\t2099-05-03 00:00\t2099-05-04 00:00\t2099-05-05 00:00\t2099-05-06 00:00', + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%R\tR1\tT2\tT1\tP1\tP1\tPR_FS\t1.5', + '%R\tR2\tT3\tT2\tP1\tP1\tss\t-2', + '%R\tR3\tT4\tT3\tP1\tP1\tPR_FF\t0', + '%R\tR4\tT5\tT4\tP1\tP1\tPR_SF\t0.25', + '%R\tR5\tT5\tEXT-7\tP1\tP-EXT\tFS\t4', + '%E', +] as const; + +const result = read(fixture); +eq('0 enkelproject-XER draagt hetzelfde volledige importverslagcontract als meerproject-XER', + result.xer.report, { + projectsSeen: 1, + documentsOpened: 1, + emptyProjectsSkipped: 0, + baselineProjectsExcluded: 0, + baselinesMaterialized: 0, + danglingBaselineReferences: 0, + externalLinksPreserved: 0, + baselineExclusionReverted: false, + baselineFallbackReasons: [], + }); +eq('1 PROJECT-identiteit, statusdatum, projectkalender en header-valuta', { + id: result.project.id, + name: result.project.name, + statusDate: result.project.statusDate, + calendarId: result.project.calendarId, + currency: result.xer.defaultCurrencyCode, +}, { + id: 'P1', + name: 'Brugrenovatie', + statusDate: '2026-04-02T09:30', + calendarId: 'C1', + currency: 'EUR', +}); +eq('2 projectdatumbereik komt uit input-TASK-velden, nooit uit PROJECT plan/output', { + start: result.project.startDate, + end: result.project.endDate, +}, { start: '2026-04-06T08:00', end: '2026-04-10T16:00' }); +eq('3 PROJECT-kalender blijft de hoofdkalender', { + id: result.calendar.id, + hourMode: result.calendar.workTime !== undefined, + mondayBands: result.calendar.workTime?.byWeekday[1], +}, { id: 'C1', hourMode: true, mondayBands: [{ start: 480, end: 960 }] }); + +// X12-kalenderbandfixture: `clndr_data` is leeg, maar de toegestane geplande invoer bewijst een +// P6 Standard 5x8-dag met lunchpauze: 40 uur vanaf wo 08:00 eindigt di 17:00, niet 16:00. +// De bestaande Brugrenovatie-fixture hierboven bewijst tegelijk dat een lege kalender met +// volledige-dagfinishes om 16:00 aaneengesloten 08:00-16:00 blijft. +const p6Lunch = read([ + 'ERMHDR\t23.12\t2026-04-01\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tStandard 5x8\tCA_Base\t8\t40\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date', + '%R\tP1\tP6 lunchdag\tC1\t2026-04-01 08:00\t2026-04-01 08:00', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date', + '%R\t1010\tP1\tC1\tA1010\tOntwerp\tTT_Task\tDT_FixedDUR\tTK_NotStart\t40\t40\t2026-04-01 08:00\t2026-04-07 17:00', + '%R\t2010\tP1\tC1\tA2010\tFrame\tTT_Task\tDT_FixedDUR\tTK_NotStart\t60\t60\t2026-04-29 08:00\t2026-05-08 17:00', + '%R\t2020\tP1\tC1\tA2020\tDak\tTT_Task\tDT_FixedDUR\tTK_NotStart\t32\t32\t2026-05-11 08:00\t2026-05-14 17:00', + '%R\t3000\tP1\tC1\tA3000\tInspectie\tTT_Task\tDT_FixedDUR\tTK_NotStart\t16\t16\t2026-05-15 08:00\t2026-05-18 17:00', + '%R\t3010\tP1\tC1\tA3010\tProjecteinde\tTT_Mile\tDT_FixedDUR\tTK_NotStart\t0\t0\t2026-05-18 17:00\t2026-05-18 17:00', + '%R\t4000\tP1\tC1\tA4000\tDagtaak\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-05-20 08:00\t2026-05-20 17:00', + '%R\t4010\tP1\tC1\tA4010\tVolgende start\tTT_Mile\tDT_FixedDUR\tTK_NotStart\t0\t0\t2026-05-21 08:00\t2026-05-21 08:00', + '%R\t5000\tP1\tC1\tA5000\tVroege voorganger\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-05-25 08:00\t2026-05-25 17:00', + '%R\t5010\tP1\tC1\tA5010\tGepland na ruimte\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-06-01 08:00\t2026-06-01 17:00', + '%R\t6000\tP1\tC1\tA6000\tLaat anker\tTT_Task\tDT_FixedDUR\tTK_NotStart\t0\t0\t2026-06-02 16:49\t2026-06-02 16:49', + '%R\t6010\tP1\tC1\tA6010\tSS-opvolger\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-06-03 08:00\t2026-06-03 17:00', + '%R\t7000\tP1\tC1\tA7000\tP6 eindgrens +1 minuut\tTT_FinMile\tDT_FixedDUR\tTK_NotStart\t0\t0\t2026-06-10 08:01\t2026-06-10 08:01', + '%R\t8000\tP1\tC1\tA8000\tVoorganger met eindgrens\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-05-25 08:00\t2026-05-25 17:00', + '%R\t8010\tP1\tC1\tA8010\tGeplande FS-eindgrens\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-05-25 17:00\t2026-05-26 17:00', + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%R\tR1\t2020\t2010\tP1\tP1\tPR_FS\t0', + '%R\tR2\t3010\t3000\tP1\tP1\tPR_FS\t0', + '%R\tR3\t4010\t4000\tP1\tP1\tPR_FS\t0', + '%R\tR4\t5010\t5000\tP1\tP1\tPR_FS\t0', + '%R\tR5\t6010\t6000\tP1\tP1\tPR_SS\t0', + '%R\tR6\t7000\t5000\tP1\tP1\tPR_FS\t0', + '%R\tR7\t8010\t8000\tP1\tP1\tPR_FS\t0', + '%E', +]); +solveProject({ + tasks: p6Lunch.tasks, + sequences: p6Lunch.sequences, + calendar: p6Lunch.calendar, + calendars: p6Lunch.resourceCalendars ?? [], + dataDate: p6Lunch.project.statusDate, + progressMode: p6Lunch.project.progressMode, + schedulingOptions: p6Lunch.project.schedulingOptions, + projectStartDate: p6Lunch.project.startDate, +}); +eq('3a lege P6-5x8-kalender leidt lunchbanden af uit geplande start/eind/duur', { + bands: p6Lunch.calendar.workTime?.byWeekday[1], + earlyFinish: p6Lunch.tasks.find(task => task.id === '1010')?.time.earlyFinish, +}, { + bands: [{ start: 480, end: 720 }, { start: 780, end: 1020 }], + earlyFinish: '2026-04-07T17:00', +}); +eq('3b P6-planned window bewaart fractionele duur en FS neemt volgende werkstart', { + predecessorFinish: p6Lunch.tasks.find(task => task.id === '2010')?.time.earlyFinish, + successorStart: p6Lunch.tasks.find(task => task.id === '2020')?.time.earlyStart, +}, { + predecessorFinish: '2026-05-08T17:00', + successorStart: '2026-05-11T08:00', +}); +eq('3c P6 nulduurmijlpaal met FS+0 landt op de voorganger-finishgrens', + p6Lunch.tasks.find(task => task.id === '3010')?.time.earlyStart, + '2026-05-18T17:00'); +eq('3d P6-startmijlpaal op geplande dagstart blijft de volgende werkstart', + p6Lunch.tasks.find(task => task.id === '4010')?.time.earlyStart, + '2026-05-21T08:00'); +eq('3e XER-geplande start blijft ondergrens wanneer voorgangerlogica eerder vrijgeeft', + p6Lunch.tasks.find(task => task.id === '5010')?.time.earlyStart, + '2026-06-01T08:00'); +eq('3f een geplande volgende-start vervangt geen relatie-instant binnen één dag', + p6Lunch.tasks.find(task => task.id === '6010')?.time.earlyStart, + '2026-06-02T16:49'); +eq('3g P6 TT_FinMile op bandstart plus één minuut gebruikt start- en vorige finishgrens', { + earlyStart: p6Lunch.tasks.find(task => task.id === '7000')?.time.earlyStart, + earlyFinish: p6Lunch.tasks.find(task => task.id === '7000')?.time.earlyFinish, + lateStart: p6Lunch.tasks.find(task => task.id === '7000')?.time.lateStart, + lateFinish: p6Lunch.tasks.find(task => task.id === '7000')?.time.lateFinish, +}, { + earlyStart: '2026-06-10T08:00', + earlyFinish: '2026-06-09T17:00', + lateStart: '2026-06-10T08:00', + lateFinish: '2026-06-09T17:00', +}); +eq('3h XER-reader markeert de expliciete geplande FS-finishgrens brongebonden', + p6Lunch.sequences.find(sequence => sequence.id === 'R7')?.p6StartAtPredecessorFinishBoundary, + true); +eq('3i XER-FS bewaart een expliciete geplande start op de voorganger-finishgrens', { + predecessorFinish: p6Lunch.tasks.find(task => task.id === '8000')?.time.earlyFinish, + successorStart: p6Lunch.tasks.find(task => task.id === '8010')?.time.earlyStart, + successorFinish: p6Lunch.tasks.find(task => task.id === '8010')?.time.earlyFinish, +}, { + predecessorFinish: '2026-05-25T17:00', + successorStart: '2026-05-25T17:00', + successorFinish: '2026-05-26T17:00', +}); + +// X12-voortgangsfixture: sommige geldige XER-producenten leveren bij CP_Drtn wel de expliciete +// target-/restduur maar geen redundante `complete_pct`-kolom. Status+restduur blijven dan de +// invoerwaarheid; 40 resterende uren op 80 totaal betekent 50%, niet 0%/nogmaals 80 uur. +const p6DurationProgressWithoutPct = read([ + 'ERMHDR\t23.12\t2026-06-01\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tStandard 5x8\tCA_Base\t8\t40\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date', + '%R\tP1\tVoortgang zonder percentage\tC1\t2026-06-01 08:00\t2026-05-25 08:00', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\tcomplete_pct_type\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date\tact_start_date', + '%R\tT1\tP1\tC1\tA100\tActieve duurtaak\tTT_Task\tDT_FixedDUR\tTK_Active\tCP_Drtn\t80\t40\t2026-05-25 08:00\t2026-06-05 17:00\t2026-05-25 08:00', + '%E', +]); +solveProject({ + tasks: p6DurationProgressWithoutPct.tasks, + sequences: p6DurationProgressWithoutPct.sequences, + calendar: p6DurationProgressWithoutPct.calendar, + calendars: p6DurationProgressWithoutPct.resourceCalendars ?? [], + dataDate: p6DurationProgressWithoutPct.project.statusDate, + progressMode: p6DurationProgressWithoutPct.project.progressMode, + schedulingOptions: p6DurationProgressWithoutPct.project.schedulingOptions, + projectStartDate: p6DurationProgressWithoutPct.project.startDate, +}); +eq('3j CP_Drtn zonder complete_pct leidt voortgang en restwerk af uit expliciete bronduur', { + completion: p6DurationProgressWithoutPct.tasks[0]?.time.completion, + remainingMinutes: p6DurationProgressWithoutPct.tasks[0]?.time.remainingMinutes, + earlyFinish: p6DurationProgressWithoutPct.tasks[0]?.time.earlyFinish, +}, { + completion: 0.5, + remainingMinutes: 2400, + earlyFinish: '2026-06-05T17:00', +}); + +// X12-actualfixture: P6-actuals zijn geregistreerde broninstants, ook wanneer ze buiten een +// kalenderband op 00:00 liggen. De XER-route mag ze niet naar dezelfde-dag-start/vorige-finish +// normaliseren; dat zou zowel datum als minuut van een historisch feit wijzigen. +const p6MidnightActual = read([ + 'ERMHDR\t23.12\t2026-06-01\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tStandard 5x8\tCA_Base\t8\t40\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date', + '%R\tP1\tP6 actualinstants\tC1\t2026-06-01 08:00\t2026-05-01 08:00', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\tcomplete_pct_type\tphys_complete_pct\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date\tact_start_date\tact_end_date', + '%R\tT1\tP1\tC1\tA100\tVoltooid om middernacht\tTT_Task\tDT_FixedDUR\tTK_Complete\tCP_Phys\t100\t40\t0\t2026-05-04 08:00\t2026-05-08 17:00\t2026-05-03 00:00\t2026-05-10 00:00', + '%E', +]); +solveProject({ + tasks: p6MidnightActual.tasks, + sequences: p6MidnightActual.sequences, + calendar: p6MidnightActual.calendar, + calendars: p6MidnightActual.resourceCalendars ?? [], + dataDate: p6MidnightActual.project.statusDate, + progressMode: p6MidnightActual.project.progressMode, + schedulingOptions: p6MidnightActual.project.schedulingOptions, + projectStartDate: p6MidnightActual.project.startDate, +}); +eq('3k XER/P6 behoudt voltooide actual start/finish als minuutexacte broninstants', { + earlyStart: p6MidnightActual.tasks[0]?.time.earlyStart, + earlyFinish: p6MidnightActual.tasks[0]?.time.earlyFinish, + lateStart: p6MidnightActual.tasks[0]?.time.lateStart, + lateFinish: p6MidnightActual.tasks[0]?.time.lateFinish, +}, { + earlyStart: '2026-05-03T00:00', + earlyFinish: '2026-05-10T00:00', + lateStart: '2026-05-03T00:00', + lateFinish: '2026-05-10T00:00', +}); + +const byId = new Map(result.tasks.map(task => [task.id, task])); +eq('4 WBS-rijen worden exact één samenvattingstaak, vóór activiteiten en stabiel gesorteerd', + result.tasks.slice(0, 3).map(task => ({ id: task.id, parent: task.parentId, children: task.childIds })), + [ + { id: 'xer-wbs:P1:W-ROOT', parent: null, children: ['xer-wbs:P1:W-CHILD', 'xer-wbs:P1:W-SIB'] }, + { id: 'xer-wbs:P1:W-CHILD', parent: 'xer-wbs:P1:W-ROOT', children: ['T1', 'T2'] }, + { id: 'xer-wbs:P1:W-SIB', parent: 'xer-wbs:P1:W-ROOT', children: ['T3', 'T4', 'T5'] }, + ]); +eq('5 er ontstaan geen extra fidelity-bladtaken voor WBS', { + total: result.tasks.length, + activities: result.tasks.filter(task => task.p6ActivityType !== undefined).length, + summaries: result.tasks.filter(task => task.id.startsWith('xer-wbs:')).length, +}, { total: 8, activities: 5, summaries: 3 }); +eq('6 bekende activity/duration enums zijn case-insensitief en canoniek bewaard', [ + [byId.get('T1')?.p6ActivityType, byId.get('T1')?.p6DurationType], + [byId.get('T2')?.p6ActivityType, byId.get('T2')?.p6DurationType], +], [['TT_Mile', 'DT_FixedDUR2'], ['TT_LOE', 'DT_FixedDrtn']]); +eq('7 activity type leidt alleen de afgesproken operationele vlaggen af', { + startMilestone: [byId.get('T1')?.isMilestone, byId.get('T1')?.milestoneKind], + loe: byId.get('T2')?.isHammock, + resourceDependent: byId.get('T3')?.p6ActivityType, + wbsActivity: byId.get('T4')?.p6ActivityType, +}, { + startMilestone: [true, 'START'], + loe: true, + resourceDependent: 'TT_Rsrc', + wbsActivity: 'TT_WBS', +}); +eq('8 onbekende tokens vallen gedocumenteerd terug en worden per veld gerapporteerd', { + activity: byId.get('T5')?.p6ActivityType, + duration: byId.get('T5')?.p6DurationType, + status: byId.get('T5')?.status, + priority: byId.get('T5')?.priority, + fallbacks: result.xer.enumFallbacks.map(item => [item.family, item.token, item.fallback]), +}, { + activity: 'TT_Task', + duration: 'DT_FixedDUR2', + status: 'NOT_STARTED', + priority: 500, + fallbacks: [ + ['activityType', 'TT_Alien', 'TT_Task'], + ['durationType', 'DT_Alien', 'DT_FixedDUR2'], + ['status', 'TK_Alien', 'NOT_STARTED'], + ['priority', 'PT_Alien', '500'], + ['constraint', 'CS_Alien', 'ASAP'], + ], +}); +eq('9 P6-status, completion, actuals en bronduur worden zonder X7-semantiek gelezen', { + t2: [byId.get('T2')?.status, byId.get('T2')?.time.completion, byId.get('T2')?.time.actualStart, + byId.get('T2')?.time.durationMinutes, byId.get('T2')?.time.remainingMinutes], + t3: [byId.get('T3')?.status, byId.get('T3')?.time.completion, byId.get('T3')?.time.actualFinish], +}, { + t2: ['STARTED', 0.25, '2026-04-06T08:00', 960, 720], + t3: ['COMPLETED', 1, '2026-04-08T16:00'], +}); +eq('10 productielezer negeert P6-output- en planvelden', { + t2Start: byId.get('T2')?.time.scheduleStart, + t2Finish: byId.get('T2')?.time.scheduleFinish, + t2Early: byId.get('T2')?.time.earlyStart, + t2Late: byId.get('T2')?.time.lateFinish, +}, { + t2Start: '2026-04-06T08:00', + t2Finish: '2026-04-07T16:00', + t2Early: '2026-04-06T08:00', + t2Late: '2026-04-07T16:00', +}); +eq('11 mandatory constraints zijn hard; secundaire constraints blijven soft', { + t2: [byId.get('T2')?.constraint, byId.get('T2')?.constraint2], + t3: byId.get('T3')?.constraint, + t4: [byId.get('T4')?.constraint, byId.get('T4')?.constraint2], +}, { + t2: [ + { type: 'MSO', date: '2026-04-06T08:00', hard: true }, + { type: 'FNLT', date: '2026-04-08T16:00' }, + ], + t3: { type: 'MFO', date: '2026-04-08T16:00', hard: true }, + t4: [ + { type: 'SNET', date: '2026-04-09T08:00' }, + { type: 'FNET', date: '2026-04-10T08:00' }, + ], +}); +eq('12 alle vier PR-relaties plus kale SS worden gemapt en lag blijft integer minuten', + result.sequences.map(sequence => [sequence.id, sequence.type, sequence.lagMinutes]), [ + ['R1', 'FINISH_START', 90], + ['R2', 'START_START', -120], + ['R3', 'FINISH_FINISH', 0], + ['R4', 'START_FINISH', 15], + ]); +eq('13 externe relatie is uitsluitend brondata en geen solver-Sequence', { + sequenceIds: result.sequences.map(sequence => sequence.id), + external: result.xer.externalRelations, +}, { + sequenceIds: ['R1', 'R2', 'R3', 'R4'], + external: [{ + id: 'R5', + localProjectId: 'P1', + localTaskId: 'T5', + externalProjectId: 'P-EXT', + externalTaskId: 'EXT-7', + direction: 'predecessor', + type: 'FS', + lagMinutes: 240, + }], +}); + +// X12 late-pass RED/GREEN: P6 mag alleen het toegestane PROJECT.plan_end_date als late +// projecteinde-anker gebruiken wanneer SCHEDOPTIONS dat expliciet vraagt. De bronwaarde ligt op +// middernacht; in de projectkalender betekent dat het voorafgaande bandeinde (28 februari 17:00). +const p6ProjectEndForFloat = read([ + 'ERMHDR\t23.12\t2026-04-01\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tproj_id\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC-END\tZeven dagen\tP-END\tCA_Project\t8\t56\t(0||CalendarData()( (0||DaysOfWeek()( (0||1()( (0||0(s|08:00|f|12:00)()) (0||1(s|13:00|f|17:00)()))) (0||2()( (0||0(s|08:00|f|12:00)()) (0||1(s|13:00|f|17:00)()))) (0||3()( (0||0(s|08:00|f|12:00)()) (0||1(s|13:00|f|17:00)()))) (0||4()( (0||0(s|08:00|f|12:00)()) (0||1(s|13:00|f|17:00)()))) (0||5()( (0||0(s|08:00|f|12:00)()) (0||1(s|13:00|f|17:00)()))) (0||6()( (0||0(s|08:00|f|12:00)()) (0||1(s|13:00|f|17:00)()))) (0||7()( (0||0(s|08:00|f|12:00)()) (0||1(s|13:00|f|17:00)()))))) (0||Exceptions()())))', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tplan_start_date\tplan_end_date', + '%R\tP-END\tProjecteinde voor float\tC-END\t2013-02-27 17:00\t2013-03-01 00:00', + '%T\tSCHEDOPTIONS', + '%F\tproj_id\tsched_use_project_end_date_for_float', + '%R\tP-END\tY', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date\tcstr_type\tcstr_date', + '%R\tT-START\tP-END\tC-END\tSTART\tProjectstart\tTT_Mile\tDT_FixedDrtn\tTK_NotStart\t0\t0\t2012-05-01 08:00\t2012-05-01 08:00\tCS_MSOB\t2012-05-01 08:00', + '%R\tT-END\tP-END\tC-END\tEND\tProjecteinde\tTT_FinMile\tDT_FixedDrtn\tTK_NotStart\t0\t0\t2013-02-27 17:00\t2013-02-27 17:00\t\t', + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%R\tR-END\tT-END\tT-START\tP-END\tP-END\tPR_FS\t0', + '%E', +]); +const p6ProjectEndTasks = p6ProjectEndForFloat.tasks.map(task => ({ ...task, time: { ...task.time } })); +const p6ProjectEndSolveInput = { + tasks: p6ProjectEndTasks, + sequences: p6ProjectEndForFloat.sequences, + calendar: p6ProjectEndForFloat.calendar, + calendars: p6ProjectEndForFloat.resourceCalendars ?? [], + schedulingOptions: p6ProjectEndForFloat.project.schedulingOptions, + projectStartDate: p6ProjectEndForFloat.project.startDate, + projectEndDate: p6ProjectEndForFloat.project.endDate, +}; +solveProject(p6ProjectEndSolveInput); +const p6ProjectEndTask = p6ProjectEndTasks.find(task => task.id === 'T-END'); +const p6ProjectStartTask = p6ProjectEndTasks.find(task => task.id === 'T-START'); +eq('13a SCHEDOPTIONS projecteinde geeft één P6-werkdag late float zonder outputvelden', { + importedProjectEnd: p6ProjectEndForFloat.project.endDate, + useProjectEndDateForFloat: p6ProjectEndForFloat.project.schedulingOptions?.useProjectEndDateForFloat, + lateFinish: p6ProjectEndTask?.time.lateFinish, + totalFloatMinutes: (p6ProjectEndTask?.time.totalFloat ?? NaN) * 8 * 60, + freeFloatMinutes: (p6ProjectEndTask?.time.freeFloat ?? NaN) * 8 * 60, +}, { + importedProjectEnd: '2013-03-01T00:00', + useProjectEndDateForFloat: true, + lateFinish: '2013-02-28T17:00', + totalFloatMinutes: 480, + freeFloatMinutes: 0, +}); +eq('13b P6 SNLT op de exacte startband houdt een nulduurmijlpaal op die broninstant', { + lateStart: p6ProjectStartTask?.time.lateStart, + lateFinish: p6ProjectStartTask?.time.lateFinish, +}, { + lateStart: '2012-05-01T08:00', + lateFinish: '2012-05-01T08:00', +}); + +function typedError(label: string, lines: readonly string[], code: string): void { + let got = 'NO_ERROR'; + try { + read(lines); + } catch (error) { + got = error instanceof XerImportError ? error.xerCode : String(error); + } + eq(label, got, code); +} + +typedError('14 meerdere uitsluitend lege PROJECT-rijen worden expliciet geweigerd', [ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tUSD', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name', + '%R\tP1\tEen', + '%R\tP2\tTwee', + '%E', +], 'XER_EMPTY_PROJECT'); + +typedError('15 project zonder TASK wordt expliciet geweigerd', [ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tUSD', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name', + '%R\tP1\tLeeg', + '%E', +], 'XER_EMPTY_PROJECT'); + +ok('16 XER-resultaat levert lege resourcevelden binnen het gedeelde importcontract', + result.resources.length === 0 && result.assignments.length === 0); + +const singleDangling = read([ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tsum_base_proj_id', + '%R\tP1\tLosse baselineverwijzing\tP-MISSING', + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_code\ttask_name\ttarget_start_date\ttarget_end_date', + '%R\tT1\tP1\tA1\tTaak\t2026-01-01\t2026-01-02', + '%E', +]); +eq('16a enkelproject-XER rapporteert een dangling baselineverwijzing echt', { + dangling: singleDangling.xer.report.danglingBaselineReferences, + baselines: singleDangling.baselines ?? [], +}, { dangling: 1, baselines: [] }); + +const singleSelf = read([ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tsum_base_proj_id', + '%R\tP1\tZelfbaseline\tP1', + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_code\ttask_name\ttarget_start_date\ttarget_end_date', + '%R\tT1\tP1\tA1\tTaak\t2026-01-01\t2026-01-02', + '%E', +]); +eq('16b enkelproject-XER rapporteert zelfverwijzing en vangrail zonder opensemantiek te wijzigen', { + project: singleSelf.project.id, + report: singleSelf.xer.report, +}, { + project: 'P1', + report: { + projectsSeen: 1, + documentsOpened: 1, + emptyProjectsSkipped: 0, + baselineProjectsExcluded: 0, + baselinesMaterialized: 0, + danglingBaselineReferences: 0, + externalLinksPreserved: 0, + baselineExclusionReverted: true, + baselineFallbackReasons: ['self-reference', 'all-projects-baselines'], + }, +}); + +// Fixronde 1, bevinding 1: een lege PROJWBS-rij blijft semantisch een samenvattingstaak. Dit +// bewijs rijdt niet alleen langs de fidelityfilter, maar door de echte solveProject-keten én de +// gedeelde relatie-/resourceconsumenten die uitsluitend bladtaken mogen zien. +const emptyWbsResult = read([ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_data', + '%R\tC1\tStandaard\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date', + '%R\tP1\tLege WBS\tC1\t2026-01-01 08:00', + '%T\tPROJWBS', + '%F\twbs_id\tproj_id\tparent_wbs_id\tseq_num\twbs_short_name\twbs_name', + '%R\tW-USED\tP1\t\t10\t1\tGebruikt', + '%R\tW-EMPTY\tP1\t\t20\t2\tLeeg maar summary', + '%T\tTASK', + '%F\ttask_id\tproj_id\twbs_id\ttask_code\ttask_name\ttarget_drtn_hr_cnt\ttarget_start_date\ttarget_end_date', + '%R\tT1\tP1\tW-USED\tA1\tTaak\t8\t2026-01-01 08:00\t2026-01-01 16:00', + '%E', +]); +const emptyWbsId = 'xer-wbs:P1:W-EMPTY'; +const emptyWbs = emptyWbsResult.tasks.find(task => task.id === emptyWbsId); +eq('17 lege PROJWBS heeft expliciete samenvattingsidentiteit', + (emptyWbs as typeof emptyWbs & { isSummary?: boolean })?.isSummary, true); +const emptyWbsSolve = solveProject({ + tasks: emptyWbsResult.tasks.map(task => ({ ...task, time: { ...task.time } })), + sequences: [], + calendar: emptyWbsResult.calendar, + calendars: emptyWbsResult.resourceCalendars ?? [], +}); +eq('18 echte solveProject-route neemt lege PROJWBS niet als CPM-knoop op', + emptyWbsSolve.tasks.has(emptyWbsId), false); +eq('19 samenvattingsrelatie vanaf lege PROJWBS wordt zichtbaar gedropt', + expandSummaryRelations(emptyWbsResult.tasks, [{ + id: 'EMPTY-WBS-REL', predecessorId: emptyWbsId, successorId: 'T1', + type: 'FINISH_START', lagDays: 0, + }]).droppedSequenceIds, ['EMPTY-WBS-REL']); +const poisonedSummaryTasks = emptyWbsResult.tasks.map(task => task.id === emptyWbsId + ? { ...task, time: { ...task.time, scheduleDuration: 1, earlyFinish: '2026-01-02' } } + : task); +const emptyWbsLoad = computeResourceLoad( + [{ id: 'R1', name: 'Ploeg', type: 'LABOR', description: '', maxUnits: 1 }], + [{ id: 'A1', taskId: emptyWbsId, resourceId: 'R1', unitsPerDay: 1 }], + poisonedSummaryTasks, + emptyWbsResult.calendar, + emptyWbsResult.resourceCalendars ?? [], +); +eq('20 resourcebelasting behandelt lege PROJWBS niet als bladtaak', emptyWbsLoad.load, {}); +const emptyWbsLeveling = levelResources( + poisonedSummaryTasks, + [], + [{ id: 'R1', name: 'Ploeg', type: 'LABOR', description: '', maxUnits: 1 }], + [ + { id: 'A1', taskId: emptyWbsId, resourceId: 'R1', unitsPerDay: 1 }, + { id: 'A2', taskId: 'T1', resourceId: 'R1', unitsPerDay: 1 }, + ], + emptyWbsResult.calendar, + emptyWbsResult.resourceCalendars ?? [], + emptyWbsSolve, + { constrainToFloat: false }, +); +eq('20b nivelleerder laat lege PROJWBS ook bij directe aanroep buiten CPM en vraag', { + delays: emptyWbsLeveling.delays, + shifts: emptyWbsLeveling.shifts, +}, { delays: {}, shifts: {} }); +const emptyWbsIfc = readIFC(writeIFC(emptyWbsResult)); +eq('21 IFC-roundtrip behoudt lege PROJWBS als expliciete samenvatting', + (emptyWbsIfc.tasks.find(task => task.wbsCode === '2') as TaskWithSummary | undefined)?.isSummary, + true); + +type TaskWithSummary = XerReadResult['tasks'][number] & { isSummary?: boolean }; + +function duplicateFixture(table: 'PROJWBS' | 'TASK' | 'TASKPRED'): readonly string[] { + const lines = [ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name', + '%R\tP1\tDubbeltest', + '%T\tPROJWBS', + '%F\twbs_id\tproj_id\tparent_wbs_id\tseq_num\twbs_short_name\twbs_name', + '%R\tW1\tP1\t\t10\t1\tEen', + ...(table === 'PROJWBS' ? ['%R\tW1\tP1\t\t20\t2\tDubbel'] : []), + '%T\tTASK', + '%F\ttask_id\tproj_id\twbs_id\ttask_code\ttask_name\ttarget_start_date\ttarget_end_date', + '%R\tT1\tP1\tW1\tA1\tEen\t2026-01-01\t2026-01-02', + ...(table === 'TASK' ? ['%R\tT1\tP1\tW1\tA2\tDubbel\t2026-01-03\t2026-01-04'] : [ + '%R\tT2\tP1\tW1\tA2\tTwee\t2026-01-03\t2026-01-04', + ]), + ...(table === 'TASKPRED' ? [ + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%R\tR1\tT2\tT1\tP1\tP1\tPR_FS\t0', + '%R\tR1\tT2\tT1\tP1\tP1\tPR_FS\t0', + ] : []), + '%E', + ]; + return lines; +} + +typedError('22 dubbele wbs_id wordt vóór boombouw getypeerd geweigerd', + duplicateFixture('PROJWBS'), 'XER_DUPLICATE_ID'); +typedError('23 dubbele task_id wordt vóór taakmap getypeerd geweigerd', + duplicateFixture('TASK'), 'XER_DUPLICATE_ID'); +typedError('24 dubbele relatie-id wordt vóór relatiebouw getypeerd geweigerd', + duplicateFixture('TASKPRED'), 'XER_DUPLICATE_ID'); + +typedError('25 lokaal gedeclareerde relatie met ontbrekend lokaal eindpunt verdwijnt nooit stil', [ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name', + '%R\tP1\tVerweesde relatie', + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_code\ttask_name\ttarget_start_date\ttarget_end_date', + '%R\tT1\tP1\tA1\tEen\t2026-01-01\t2026-01-02', + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%R\tR-DANGLING\tMISSING\tT1\tP1\tP1\tPR_FS\t0', + '%E', +], 'XER_DANGLING_LOCAL_RELATION'); + +function sortingFixture(order: readonly string[]): readonly string[] { + const rows: Record = { + root: '%R\tROOT\tP1\t\t1\t0\tRoot', + z: '%R\tW-Z\tP1\tROOT\t10\tZ\tZ', + umlaut: '%R\tW-Ä\tP1\tROOT\t10\tÄ\tÄ', + }; + return [ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name', + '%R\tP1\tSortering', + '%T\tPROJWBS', + '%F\twbs_id\tproj_id\tparent_wbs_id\tseq_num\twbs_short_name\twbs_name', + ...order.map(key => rows[key]), + '%T\tTASK', + '%F\ttask_id\tproj_id\twbs_id\ttask_code\ttask_name\ttarget_start_date\ttarget_end_date', + '%R\tT1\tP1\tROOT\tA1\tTaak\t2026-01-01\t2026-01-02', + '%E', + ]; +} + +function sortedWbsUnderLocale(locale: string, order: readonly string[]): string[] { + const original = String.prototype.localeCompare; + String.prototype.localeCompare = function localeBound(other: string): number { + return original.call(String(this), other, locale); + }; + try { + return read(sortingFixture(order)).tasks + .filter(task => task.id.startsWith('xer-wbs:')) + .map(task => task.id); + } finally { + String.prototype.localeCompare = original; + } +} +const expectedWbsOrder = ['xer-wbs:P1:ROOT', 'xer-wbs:P1:W-Z', 'xer-wbs:P1:W-Ä']; +eq('26 WBS-sortering is hostonafhankelijk onder en-US', + sortedWbsUnderLocale('en-US', ['root', 'umlaut', 'z']), expectedWbsOrder); +eq('27 WBS-sortering is hostonafhankelijk onder sv-SE', + sortedWbsUnderLocale('sv-SE', ['root', 'umlaut', 'z']), expectedWbsOrder); +eq('28 unieke wbs_id is tie-breaker, onafhankelijk van bronhussel', + sortedWbsUnderLocale('en-US', ['z', 'root', 'umlaut']), expectedWbsOrder); + +if (diffs.length > 0) { + console.error(`XER-reader: ${diffs.length}/${checks} checks rood`); + for (const diff of diffs) console.error(`XX ${diff}`); + process.exit(1); +} +console.log(`OK XER-reader: ${checks} corpusloze project/taak/WBS/relatie/constraint-checks`); diff --git a/tests/planning/check-xer-recorded-roundtrip.ts b/tests/planning/check-xer-recorded-roundtrip.ts new file mode 100644 index 000000000..007d2d53f --- /dev/null +++ b/tests/planning/check-xer-recorded-roundtrip.ts @@ -0,0 +1,576 @@ +/** + * T5 (XER-etappeplan laag 3, §3.8) — "datums zoals opgeslagen" overleeft een IFC-opslag, een + * heropening en het crashherstel. + * + * WAT DIT BEWIJST + * 1. XER openen → IFC opslaan → heropenen levert EXACT dezelfde `recordedTimes` op — alle zes + * assen, inclusief de `undefined`s ("niet vastgelegd" blijft niet-vastgelegd, wordt nooit een + * verzonnen 0 of een kopie van de vroege kant). + * 2. Dat geldt zowel wanneer er ÍN de modus wordt opgeslagen (P6's waarden staan dan in + * `task.time`, zie `applyRecordedTimesToTasks`) als daarbuiten (onze herberekening staat er). + * De vastlegging hangt aan de BRON, niet aan wat er toevallig in `task.time` stond. + * 3. HEROPEN-BELEID (orkestratorbesluit, 2026-09-05): een VERSE XER-import met restverschillen + * zet de modus zelf meteen aan (`recordedTimesOrigin === 'xer'`, taak T4). Een HEROPENDE IFC + * met XER-archief krijgt altijd `recordedTimesOrigin === 'xer-archive'` en de modus NOOIT + * automatisch — ongeacht of het bestand ín of buiten de modus werd opgeslagen — en biedt hem + * alleen aan (het gewone #63-gedrag). Reden: een sindsdien bewerkte en opgeslagen planning mag + * bij heropenen niet stilzwijgend P6's oude datums tonen. + * 4. Een IFC ZONDER XER-archief blijft byte-identiek: geen `recordedTimes`, geen origin, en de + * bestaande #63-IFC-route (`recordedFields`) doet onveranderd zijn werk. + * 5. CRASHHERSTEL IS GEEN HEROPENING: `restoreDocuments` leest de modusvlag van vóór de crash als + * FEIT uit het recovery-manifest (v4, `RecoveryDocInput.datesAsRecorded` → + * `applyRecordedDatesOnLoad(..., restoredMode)` voor het actieve, `applyRestoredRecordedMode` + * voor een slapend document) — aan blijft aan, uit blijft uit — zonder opnieuw op + * `recordedTimesOrigin` te beslissen (dat zou voor elk XER-archiefdocument altijd "alleen + * aanbieden" zijn, zie punt 3). + * 6. MUTATIEBEWIJS: één gewijzigde orakelcel in de bron verplaatst de vastlegging over de hele + * keten heen WÉL, maar het `cpmResult` ná `runCPM` GEEN millimeter. + * 7. Hardening: de sha256-poort op het archief geldt ook voor de vastlegging — een gemanipuleerde + * archiefchunk wordt geweigerd in plaats van stil een andere vastlegging op te leveren. + * + * Deze check leest de ECHTE productiebeslissing (`payload.datesAsRecorded` + + * `payload.recordedDates`) rechtstreeks van de store af — geen losse `modeVerdict`-spiegeling meer + * van de beslisregel, want die zou na taak T5 opnieuw moeten weten dat `applyLoadedProject` en + * `restoreDocuments` verschillende paden bewandelen (`applyRecordedDatesOnLoad` zonder resp. mét + * `restoredMode`, plus `applyRestoredRecordedMode` voor slapende documenten). Rechtstreeks aflezen + * toetst dus de werkelijke bedrading, niet een kopie ervan. + */ +import { captureRecordedDates } from '@/engine/scheduler/recordedDates'; +import { unrecordedExportGate } from '@/state/recordedDatesSelectors'; +import type { RecordedTime } from '@/engine/scheduler/recordedDates'; +import type { DocumentPayload } from '@/state/documentContract'; +import { readXER } from '@/services/xer/xerReader'; +import { isMultiDocumentImport, type ImportResult } from '@/services/importTypes'; +import { writeIFC } from '@/services/ifc/ifcWriter'; +import { buildWriteIFCInput } from '@/state/ifcSaveInput'; +import { useAppStore } from '@/state/appStore'; +import { recoveryInputFromParsed } from '@/state/documentContract'; +import { clearRecovery, fullRecoverySave, loadRecovery, saveRecovery } from '@/services/recovery/recoveryStore'; +import { readXerArchiveIFC } from './xerArchiveTestReader'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +declare const process: { + env: Record; + exit(code: number): never; +}; + +const failures: string[] = []; +let checks = 0; +const expect = (label: string, condition: boolean): void => { + checks += 1; + if (!condition) failures.push(label); +}; +const eq = (label: string, got: unknown, want: unknown): void => { + checks += 1; + const a = JSON.stringify(got); + const b = JSON.stringify(want); + if (a !== b) failures.push(`${label}: verwacht ${b}, kreeg ${a}`); +}; +const store = () => useAppStore.getState(); + +// ── Headless browseropslag-dubbel voor recoveryStore (identiek aan check-xer-source-archive-chain) ── +const records = new Map(); +const fakeDb = { + objectStoreNames: { contains: () => true }, + createObjectStore: () => undefined, + close: () => undefined, + onversionchange: null as (() => void) | null, + transaction: (_store: string, _mode: string) => { + const tx = { + oncomplete: null as (() => void) | null, + onerror: null as (() => void) | null, + error: null, + objectStore: () => ({ + getAll: () => { + const request = { result: [] as unknown[], error: null, onsuccess: null as (() => void) | null, onerror: null as (() => void) | null }; + queueMicrotask(() => { request.result = [...records.values()]; request.onsuccess?.(); }); + return request; + }, + put: (value: { id: string }) => { + records.set(value.id, structuredClone(value)); + queueMicrotask(() => tx.oncomplete?.()); + }, + delete: (id: string) => { + records.delete(id); + queueMicrotask(() => tx.oncomplete?.()); + }, + }), + }; + return tx; + }, +}; +(globalThis as unknown as { window: object }).window = {}; +(globalThis as unknown as { indexedDB: unknown }).indexedDB = { + open: () => { + const request = { + result: fakeDb, + error: null, + onupgradeneeded: null as (() => void) | null, + onsuccess: null as (() => void) | null, + onerror: null as (() => void) | null, + }; + queueMicrotask(() => { request.onupgradeneeded?.(); request.onsuccess?.(); }); + return request; + }, +}; + +// ═════════════════════════════════════════════════════════════════════════════════════════════ +// Corpusloze fixture — DAGMODUS met twee kalenders (8u en 6u per dag), zodat de uren→dagen- +// omrekening van de float (incl. een niet-ronde uitkomst) door de hele keten heen wordt getoetst. +// UURMODUS staat hier bewust NIET in: het corpusdeel onderaan dekt die as met echte bestanden — +// gemeten 2026-09-05 zijn álle 6.976 vastgelegde instants van `rehab-2.xer` en alle 8 van +// `p6diff-baseline.xer` uurmodus-instants (`YYYY-MM-DDTHH:mm`), dus de dag/uur-representatie over +// de round-trip heen wordt daar wél bewezen. Claim hier dus niets meer dan de fixture waarmaakt. +// ═════════════════════════════════════════════════════════════════════════════════════════════ +const TASK_FIELDS = [ + 'task_id', 'proj_id', 'clndr_id', 'task_code', 'task_name', 'task_type', 'duration_type', + 'status_code', 'target_drtn_hr_cnt', 'remain_drtn_hr_cnt', 'target_start_date', 'target_end_date', + 'early_start_date', 'early_end_date', 'late_start_date', 'late_end_date', + 'total_float_hr_cnt', 'free_float_hr_cnt', +].join('\t'); + +/** `mutate: true` verandert precies ÉÉN orakelcel: `late_start_date` van R1 (2026-01-10 → 2026-01-19). + * Verder is het bestand byte-voor-byte gelijk — het mutatiebewijs mag geen tweede verschil kennen. */ +function fixtureBytes(mutate: boolean): Uint8Array { + const r1LateStart = mutate ? '2026-01-19' : '2026-01-10'; + return new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tStandaard 8u\tCA_Base\t8\t40\t', + '%R\tC2\tKort 6u\tCA_Base\t6\t30\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date', + '%R\tP1\tRoundTripFixture\tC1\t2026-01-01\t2026-01-01', + '%T\tTASK', + `%F\t${TASK_FIELDS}`, + // R1 — alle zes assen gevuld; late_start_date is de gemuteerde cel. + `%R\tR1\tP1\tC1\tA1\tVolledig\tTT_Task\tDT_FixedDUR\tTK_NotStart\t40\t40\t2026-01-02\t2026-01-09\t2026-01-05\t2026-01-12\t${r1LateStart}\t2026-01-17\t40\t8`, + // R2 — kritiek (float 0) op de projectkalender. + '%R\tR2\tP1\tC1\tA2\tKritiek\tTT_Task\tDT_FixedDUR\tTK_NotStart\t32\t32\t2026-02-02\t2026-02-06\t2026-02-02\t2026-02-06\t2026-02-02\t2026-02-06\t0\t0', + // R3 — ALLEEN de early-as: late/float ontbreken. Dit is de taak die bewijst dat "niet + // vastgelegd" een undefined blijft over de hele IFC-keten heen. + '%R\tR3\tP1\tC1\tA3\tAlleenEarly\tTT_Task\tDT_FixedDUR\tTK_NotStart\t16\t16\t2026-03-02\t2026-03-04\t2026-03-02\t2026-03-04\t\t\t\t', + // R4 — eigen kalender C2 (6u/dag): 6u float ⇒ exact 1,0 dag. Bewijst dat de TAAK-effectieve + // kalender de omrekening stuurt, óók ná de round-trip. + '%R\tR4\tP1\tC2\tA4\tEigenKalender\tTT_Task\tDT_FixedDUR\tTK_NotStart\t12\t12\t2026-04-02\t2026-04-03\t2026-04-02\t2026-04-03\t\t\t6\t', + // R5 — geen enkele early-as ⇒ GEEN uitspraak, ook niet ná een round-trip (geen terugval op + // target_*, en de IFC-`recordedFields`-route mag hem er ook niet alsnog in schuiven). + '%R\tR5\tP1\tC1\tA5\tGeenEarly\tTT_Task\tDT_FixedDUR\tTK_NotStart\t32\t32\t2026-05-04\t2026-05-08\t\t\t\t\t\t', + // R6 — niet-ronde float: Math.round(4.01u × 60) = 241 min / 480 min-per-dag. Een keten die de + // vastlegging ergens door een dag- of getalafronding haalt, valt hierop om. + '%R\tR6\tP1\tC1\tA6\tFractie\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-07-01\t2026-07-02\t2026-07-01\t2026-07-02\t\t\t4.01\t', + '%E', + ].join('\r\n')); +} + +function single(bytes: Uint8Array): ImportResult { + const opened = readXER(bytes); + if (isMultiDocumentImport(opened)) throw new Error('Enkelprojectfixture opende als multi-document'); + return opened; +} + +/** Leest de ECHTE productiebeslissing van een document af — geen recomputatie, gewoon de velden + * die `applyRecordedDatesOnLoad`/`applyRestoredRecordedMode` op de payload zetten. */ +function modeSummary(payload: DocumentPayload): { origin: string | undefined; total: number; shifted: number; mode: boolean } { + return { + origin: payload.recordedDates?.origin, + total: payload.recordedDates?.total ?? 0, + shifted: payload.recordedDates?.shifted ?? 0, + mode: payload.datesAsRecorded, + }; +} + +// ── 1. Basislijn: het oorspronkelijke openen ───────────────────────────────────────────────── +const original = single(fixtureBytes(false)); +const originalTimes = original.recordedTimes ?? {}; +eq('1a bron levert vastlegging voor vijf van de zes taken', Object.keys(originalTimes).sort(), ['R1', 'R2', 'R3', 'R4', 'R6']); +eq('1b bron-origin is xer', original.recordedTimesOrigin, 'xer'); +eq('1c R3 draagt alleen de early-as (late/float blijven undefined)', originalTimes.R3, { + start: '2026-03-02', finish: '2026-03-04', +}); +eq('1e R6 draagt een niet-ronde float (241 min / 480 min-per-dag)', originalTimes.R6?.totalFloat, 241 / 480); + +store().newProject(); +const applied = store().applyOpenedImport(original, { + filePath: null, fileHandle: null, recompute: true, fit: false, hourDataNotice: false, linkedOpen: false, +}); +void applied; +const basePayload = store().getOpenDocumentPayloads()[0]!.payload; +expect('1d document draagt het XER-bronarchief en de selector', + basePayload.xerSourceArchive !== null && basePayload.xerSourceProjectId === 'P1'); +// Heropen-beleid (taak T4): een VERSE import met restverschillen zet de modus zelf meteen aan — +// `basePayload` staat hierdoor AL in de modus (P6's waarden staan al in `task.time`). Sectie 3 +// hergebruikt dat rechtstreeks in plaats van de modus handmatig te reconstrueren. +expect('1f een verse import met restverschillen opent al ín de modus (T4-beleid)', + basePayload.datesAsRecorded === true); +eq('1g de vastlegging op het document draagt de VERSE-import-herkomst', basePayload.recordedDates?.origin, 'xer'); +expect('1h het besluit is niet triviaal: deze fixture verschuift echt taken (aantal > 0)', + (basePayload.recordedDates?.shifted ?? 0) > 0); + +// ── 2. Opslaan BUITEN de modus: `task.time` draagt onze herberekening ──────────────────────── +// De modus verlaten is een gewone mutator (`runCPM`, hetzelfde pad als F5) — zo komt de +// "buiten de modus"-toestand tot stand zoals een gebruiker hem ook zou bereiken, in plaats van +// hem handmatig te reconstrueren. +store().runCPM(); +const outsidePayload = store().getOpenDocumentPayloads()[0]!.payload; +expect('2z runCPM verlaat de modus echt', outsidePayload.datesAsRecorded === false); +const ifcOutsideMode = writeIFC(buildWriteIFCInput(outsidePayload)); +const reopenedOutside = readXerArchiveIFC(ifcOutsideMode); +eq('2a heropend IFC levert exact dezelfde vastlegging (alle zes assen, incl. de undefined-assen)', + reopenedOutside.recordedTimes, originalTimes); +eq('2b heropend IFC draagt de HEROPEN-herkomst (niet "xer" — dit is een heropening, geen verse import)', + reopenedOutside.recordedTimesOrigin, 'xer-archive'); +eq('2c heropende taak-id\'s matchen de archiefrijen (OPS_TaskIdentity)', + reopenedOutside.tasks.map(t => t.id).sort(), ['R1', 'R2', 'R3', 'R4', 'R5', 'R6']); +eq('2d captureRecordedDates levert identieke times vóór en ná de round-trip', + captureRecordedDates(reopenedOutside.tasks, reopenedOutside.recordedFields, reopenedOutside.recordedTimes), + captureRecordedDates(original.tasks, original.recordedFields, original.recordedTimes)); +expect('2e de IFC-`recordedFields`-route wordt NIET gemengd met het bron-orakel', + reopenedOutside.recordedFields !== undefined + && captureRecordedDates(reopenedOutside.tasks, reopenedOutside.recordedFields).times.R5 !== undefined + && captureRecordedDates(reopenedOutside.tasks, reopenedOutside.recordedFields, reopenedOutside.recordedTimes).times.R5 === undefined); + +// ── 3. Opslaan ÍN de modus: P6's waarden staan in `task.time` ──────────────────────────────── +// `basePayload` (sectie 1) staat dankzij het T4-beleid AL in de modus — dat IS de "ín de modus +// opgeslagen"-toestand, geen aparte `applyRecordedTimesToTasks`-constructie meer nodig. +const ifcInMode = writeIFC(buildWriteIFCInput(basePayload)); +const reopenedInMode = readXerArchiveIFC(ifcInMode); +eq('3a in de modus opgeslagen IFC levert dezelfde vastlegging', reopenedInMode.recordedTimes, originalTimes); +eq('3b in de modus opgeslagen IFC draagt de HEROPEN-herkomst', reopenedInMode.recordedTimesOrigin, 'xer-archive'); +expect('3c de twee opslagvormen verschillen echt (anders bewijst 3a niets)', + ifcInMode !== ifcOutsideMode); + +// ── 4. Heropen-beleid: dezelfde vastlegging, maar NOOIT automatisch weer aan ────────────────── +// Orkestratorbesluit (2026-09-05): een heropende IFC met XER-archief krijgt NOOIT de automatische +// modus, ongeacht of het bestand ín of buiten de modus werd opgeslagen — alleen een VERSE +// XER-import (sectie 1) doet dat. Anders zou een sindsdien bewerkte en opgeslagen planning bij +// heropenen stilzwijgend P6's oude datums tonen. +store().newProject(); +store().applyOpenedImport(reopenedOutside, { + filePath: null, fileHandle: null, recompute: true, fit: false, hourDataNotice: false, linkedOpen: false, +}); +const reopenedOutsidePayload = store().getOpenDocumentPayloads()[0]!.payload; +expect('4a heropenen van een buiten-de-modus opgeslagen IFC biedt de modus alleen aan', + reopenedOutsidePayload.datesAsRecorded === false && reopenedOutsidePayload.recordedDates !== null); + +store().newProject(); +store().applyOpenedImport(reopenedInMode, { + filePath: null, fileHandle: null, recompute: true, fit: false, hourDataNotice: false, linkedOpen: false, +}); +const reopenedInModePayload = store().getOpenDocumentPayloads()[0]!.payload; +expect('4b heropenen van een ín-de-modus opgeslagen IFC biedt de modus OOK alleen aan (geen stille terugval naar aan)', + reopenedInModePayload.datesAsRecorded === false && reopenedInModePayload.recordedDates !== null); +eq('4c beide heropeningen komen op hetzelfde aantal verschoven taken uit als de oorspronkelijke import', + modeSummary(reopenedInModePayload).shifted, modeSummary(basePayload).shifted); +eq('4d ... en dat geldt ook voor de buiten-de-modus-heropening', + modeSummary(reopenedOutsidePayload).shifted, modeSummary(basePayload).shifted); + +// ── 5. IFC ZÓNDER XER-archief blijft byte-identiek gedrag houden ───────────────────────────── +const ifcWithoutArchive = writeIFC({ + ...buildWriteIFCInput(basePayload), + xer: undefined, xerSourceArchive: undefined, xerSourceProjectId: undefined, +}); +const reopenedPlain = readXerArchiveIFC(ifcWithoutArchive); +expect('5a IFC zonder XER-archief levert geen recordedTimes en geen herkomst', + reopenedPlain.recordedTimes === undefined && reopenedPlain.recordedTimesOrigin === undefined); +expect('5b de bestaande #63-IFC-route (recordedFields) werkt daar onveranderd', + captureRecordedDates(reopenedPlain.tasks, reopenedPlain.recordedFields).total === reopenedPlain.tasks.length); + +// ── 6. Crashherstel is GEEN heropening: de modusvlag van vóór de crash komt terug ───────────── +// "Aan blijft aan, uit blijft uit" — en dat is sinds de critreview (bevindingen 2/3) een +// OPGESCHREVEN FEIT: `datesAsRecorded` reist als recovery-METADATA mee (manifestveld v4), net als +// `filePath`/`isDirty`. `restoreDocuments` leest die vlag en beslist niet opnieuw. De vorige, +// afgeleide vorm ("0 verschoven op de rauwe taken ⇒ de modus stond aan") was aantoonbaar +// vals-positief — zie 6h hieronder, precies de toestand waarin die heuristiek de modus ten +// onrechte weer aanzette. +async function recoverSingleDocument(label: string, payload: DocumentPayload): Promise { + await clearRecovery(); + const ifc = writeIFC(buildWriteIFCInput(payload)); + await saveRecovery(fullRecoverySave('doc-1', [{ id: 'doc-1', ifc, filePath: null, isDirty: true, datesAsRecorded: payload.datesAsRecorded }])); + const loaded = await loadRecovery(); + expect(`${label}: recoveryStore levert de snapshot terug`, loaded.docs.length === 1); + const recoveredParsed = readXerArchiveIFC(loaded.docs[0]!.ifc); + const input = recoveryInputFromParsed(recoveredParsed, { + id: 'doc-1', filePath: null, isDirty: true, + // Wat de recovery-metadata teruggaf — de bewaarde modusvlag, geen aanname. + datesAsRecorded: loaded.docs[0]!.datesAsRecorded, + }); + const result = store().restoreDocuments([input], 'doc-1'); + expect(`${label}: restoreDocuments slaagde zonder overgeslagen documenten`, result.skippedIds.length === 0); + await clearRecovery(); + return store().getOpenDocumentPayloads()[0]!.payload; +} + +const recoveredFromInMode = await recoverSingleDocument('6a', basePayload); +expect('6b hersteld document dat vóór de crash IN de modus stond, komt weer IN de modus terug (aan blijft aan)', + recoveredFromInMode.datesAsRecorded === true); +eq('6c hersteld-in-modus document draagt dezelfde vastlegging als het gewone openen', + recoveredFromInMode.recordedDates?.times, originalTimes); + +const recoveredFromOutsideMode = await recoverSingleDocument('6d', outsidePayload); +expect('6e hersteld document dat vóór de crash UIT de modus stond, blijft UIT de modus (uit blijft uit)', + recoveredFromOutsideMode.datesAsRecorded === false); +expect('6f ... maar het #63-aanbod verschijnt wel (de restverschillen blijven zichtbaar)', + recoveredFromOutsideMode.recordedDates !== null); +eq('6g hersteld-buiten-modus document draagt dezelfde vastlegging als het gewone openen', + recoveredFromOutsideMode.recordedDates?.times, originalTimes); + +// 6h — HET GAT WAAR DE HEURISTIEK OP STUKLIEP (critreview bevinding 2). Een bewerking verlaat de +// modus en zet `scheduleStale`, maar de herberekening staat pas op `setTimeout(0)` +// (`useExitRecordedDates`); valt de auto-save-snapshot in dat gat, dan dráágt `task.time` nog +// steeds P6's waarden terwijl de modus UIT staat. De oude terugleesmeting zag dan "0 verschoven" +// en zette de modus weer aan — inclusief het wissen van de verouderd-vlag, op een half bewerkte +// planning. Met de metadatavlag kan dat niet meer. +{ + store().newProject(); + store().applyOpenedImport(original, { + filePath: null, fileHandle: null, recompute: true, fit: false, hourDataNotice: false, linkedOpen: false, + }); + expect('6h voorwaarde: de verse import opent ín de modus', + store().getOpenDocumentPayloads()[0]!.payload.datesAsRecorded === true); + const editId = store().tasks[0]!.id; + const p6Start = store().tasks[0]!.time.earlyStart; + store().updateTask(editId, { name: 'Hernoemd door gebruiker' }); + const gap = store().getOpenDocumentPayloads()[0]!.payload; + expect('6i voorwaarde: de bewerking verliet de modus en zette de planning op verouderd', + gap.datesAsRecorded === false && gap.scheduleStale === true); + expect('6j voorwaarde: maar `task.time` draagt nog P6\'s waarden (de herberekening is uitgesteld)', + gap.tasks.find(task => task.id === editId)!.time.earlyStart === p6Start); + + const recoveredFromGap = await recoverSingleDocument('6k', gap); + expect('6l herstel uit dat gat zet de modus NIET aan (de metadata zei uit, en dat telt)', + recoveredFromGap.datesAsRecorded === false); + expect('6m ... de bewerking zelf overleeft het herstel gewoon', + recoveredFromGap.tasks.find(task => task.id === editId)?.name === 'Hernoemd door gebruiker'); + expect('6n ... en het #63-aanbod blijft over, zodat de gebruiker P6\'s datums zelf kan terugzetten', + recoveredFromGap.recordedDates !== null); +} + +// 6o — SLAPENDE documenten (critreview bevinding 3). `restoreDocuments` rekent alleen het actieve +// document door; de rest kwam terug met P6's datums in `task.time`, zonder modus en MET +// `scheduleStale` — waarna automatisch berekenen (of de eerste F5) die datums stil wegrekende. +{ + store().newProject(); + store().applyOpenedImport(original, { + filePath: null, fileHandle: null, recompute: true, fit: false, hourDataNotice: false, linkedOpen: false, + }); + const firstId = store().activeDocumentId!; + store().newDocument(); + store().applyOpenedImport(original, { + filePath: null, fileHandle: null, recompute: true, fit: false, hourDataNotice: false, linkedOpen: false, + }); + const secondId = store().activeDocumentId!; + const before = store().getOpenDocumentPayloads(); + expect('6o voorwaarde: beide documenten staan ín de modus', + before.length === 2 && before.every(document => document.payload.datesAsRecorded === true)); + + await clearRecovery(); + await saveRecovery(fullRecoverySave(secondId, before.map(document => ({ + id: document.id, + ifc: writeIFC(buildWriteIFCInput(document.payload)), + filePath: null, + isDirty: true, + datesAsRecorded: document.payload.datesAsRecorded, + })))); + const loadedTwo = await loadRecovery(); + expect('6p de recovery-metadata draagt de modusvlag voor BEIDE documenten', + loadedTwo.docs.length === 2 && loadedTwo.docs.every(document => document.datesAsRecorded === true)); + const inputs = loadedTwo.docs.map(document => recoveryInputFromParsed( + readXerArchiveIFC(document.ifc), + { id: document.id, filePath: null, isDirty: true, datesAsRecorded: document.datesAsRecorded }, + )); + store().restoreDocuments(inputs, secondId); + await clearRecovery(); + + const restoredById = new Map(store().getOpenDocumentPayloads().map(d => [d.id, d.payload])); + const actief = restoredById.get(secondId); + const slapend = restoredById.get(firstId); + expect('6q het ACTIEVE herstelde document staat weer in de modus', + actief?.datesAsRecorded === true && actief?.scheduleStale === false); + expect('6r het SLAPENDE herstelde document staat óók weer in de modus', + slapend?.datesAsRecorded === true); + expect('6s ... en is dus niet als "verouderd" gemarkeerd (de invariant modus-aan-én-verouderd)', + slapend?.scheduleStale === false); + expect('6t ... met een cpmResult uit de vastlegging, niet uit een solve (geen lege planning)', + slapend?.cpmResult !== null); + // Her-check laag 3, bevinding 4: "in de modus" betekent op het slapende pad hetzelfde als op het + // actieve — de vastlegging staat er (zonder verzonnen `shifted`), dus export-poort, "niet + // vastgelegd"-kolommen en badge werken. MUTATIEBEWIJS: laat `applyRestoredRecordedMode` + // `payload.recordedDates` weer weg ⇒ 6v/6w slaan ROOD. + expect('6v het SLAPENDE herstelde document draagt zijn vastlegging, zonder verzonnen teller', + slapend?.recordedDates !== null && slapend?.recordedDates?.shifted === undefined + && slapend?.recordedDates?.origin === 'xer-archive' + && Object.keys(slapend?.recordedDates?.times ?? {}).length === Object.keys(originalTimes).length); + expect('6w ... zodat de export-poort ook dáár actief is (geen verzonnen speling naar CSV/MCP)', + slapend !== undefined && unrecordedExportGate(slapend.recordedDates, slapend.datesAsRecorded) !== undefined); + // Alleen de taken die P6 écht vastlegde (R5 heeft geen vastlegging, zie sectie 2e): voor die + // taken moet de weergave nog exact de vastlegging zijn, want er is niet herberekend. + eq('6u ... en met P6\'s datums nog ín de vastgelegde taken', + Object.fromEntries((slapend?.tasks ?? []) + .filter(task => originalTimes[task.id] !== undefined) + .map(task => [task.id, task.time.earlyStart])), + Object.fromEntries(Object.entries(originalTimes).map(([id, time]) => [id, time.start]))); + + // Deze sectie opende bewust een TWEEDE document; de secties hierna gaan van één uit. + store().closeDocument(firstId); + expect('6v opruimen: er blijft één document open voor de volgende secties', + store().getOpenDocumentPayloads().length === 1); +} + +// ── 7. MUTATIEBEWIJS — één orakelcel verschuift de vastlegging, niet de berekening ─────────── +function chainOf(mutate: boolean): { times: Record; cpm: string } { + const parsed = single(fixtureBytes(mutate)); + store().newProject(); + store().applyOpenedImport(parsed, { + filePath: null, fileHandle: null, recompute: true, fit: false, hourDataNotice: false, linkedOpen: false, + }); + const payload = store().getOpenDocumentPayloads()[0]!.payload; + const reopened = readXerArchiveIFC(writeIFC(buildWriteIFCInput(payload))); + store().newProject(); + store().applyOpenedImport(reopened, { + filePath: null, fileHandle: null, recompute: true, fit: false, hourDataNotice: false, linkedOpen: false, + }); + store().runCPM(); + const result = store().cpmResult; + const cpm = JSON.stringify({ + tasks: [...(result?.tasks ?? new Map())].sort(([a], [b]) => a.localeCompare(b)), + criticalPath: [...(result?.criticalPath ?? [])].sort(), + projectEnd: result?.projectEnd ?? null, + projectDuration: result?.projectDuration ?? null, + times: store().tasks.map(t => [t.id, t.time]).sort(), + }); + return { times: reopened.recordedTimes ?? {}, cpm }; +} +const clean = chainOf(false); +const mutated = chainOf(true); +eq('7a de gemuteerde orakelcel komt exact op zijn as terug ná de volledige IFC-keten', + mutated.times.R1?.lateStart, '2026-01-19'); +eq('7b geen enkele andere as van R1 schuift mee', + { ...mutated.times.R1, lateStart: clean.times.R1?.lateStart }, clean.times.R1); +eq('7c alle overige taken blijven identiek', + { ...mutated.times, R1: null }, { ...clean.times, R1: null }); +eq('7d het cpmResult ná runCPM is byte-gelijk — de gemuteerde P6-uitvoer raakt de solve niet', + mutated.cpm, clean.cpm); + +// ── 8. Hardening: de sha256-poort dekt ook de vastlegging ──────────────────────────────────── +// Manipuleer één base64-teken van de eerste archiefchunk. De reconstructie mag dan NIET stil een +// andere vastlegging opleveren, maar moet de hele lezing weigeren. +const chunkMatch = /IFCPROPERTYSINGLEVALUE\('ByteChunk000000',\$,IFCTEXT\('([A-Za-z0-9+/=]+)'\)/.exec(ifcOutsideMode); +expect('8a de fixture-IFC draagt een leesbare archiefchunk', chunkMatch !== null); +if (chunkMatch) { + const chunk = chunkMatch[1]!; + const flipped = (chunk[0] === 'A' ? 'B' : 'A') + chunk.slice(1); + const tampered = ifcOutsideMode.replace(`IFCTEXT('${chunk}')`, `IFCTEXT('${flipped}')`); + let threw = false; + try { readXerArchiveIFC(tampered); } catch { threw = true; } + expect('8b een gemanipuleerde archiefchunk wordt geweigerd i.p.v. stil anders gelezen', threw); +} + +// ── 9. Meerprojecten-XER: elk document krijgt de vastlegging van ZIJN EIGEN project ────────── +// Het archief is bestandsbreed en draagt de rijen van álle projecten. De selector `OPS_XerDocument` +// moet dus echt gebruikt worden — een implementatie die `results[0]` pakt, of die de projecten samen +// in één map gooit, valt hier om. +const twoProjectBytes = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tStandaard 8u\tCA_Base\t8\t40\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date', + '%R\tPA\tProjectA\tC1\t2026-01-01\t2026-01-01', + '%R\tPB\tProjectB\tC1\t2026-01-01\t2026-01-01', + '%T\tTASK', + `%F\t${TASK_FIELDS}`, + '%R\tA1\tPA\tC1\tAA1\tA-een\tTT_Task\tDT_FixedDUR\tTK_NotStart\t40\t40\t2026-01-02\t2026-01-09\t2026-01-05\t2026-01-12\t2026-01-10\t2026-01-17\t40\t8', + '%R\tB1\tPB\tC1\tBB1\tB-een\tTT_Task\tDT_FixedDUR\tTK_NotStart\t40\t40\t2026-06-01\t2026-06-08\t2026-06-02\t2026-06-09\t2026-06-04\t2026-06-11\t16\t0', + '%E', +].join('\r\n')); +const twoProject = readXER(twoProjectBytes); +expect('9a tweeprojectenfixture opent als multi-document', isMultiDocumentImport(twoProject)); +if (isMultiDocumentImport(twoProject)) { + store().newProject(); + store().applyOpenedImport(twoProject, { + filePath: null, fileHandle: null, recompute: true, fit: false, hourDataNotice: false, linkedOpen: false, + }); + const docs = store().getOpenDocumentPayloads(); + eq('9b twee documenten geopend', docs.length, 2); + for (const doc of docs) { + const selector = doc.payload.xerSourceProjectId!; + const before = twoProject.results.find(r => r.xer?.sourceProjectId === selector)!; + const after = readXerArchiveIFC(writeIFC(buildWriteIFCInput(doc.payload))); + eq(`9c ${selector} — heropend document krijgt de vastlegging van zijn EIGEN project`, + after.recordedTimes, before.recordedTimes); + eq(`9d ${selector} — en uitsluitend zijn eigen taak-id`, + Object.keys(after.recordedTimes ?? {}), [selector === 'PA' ? 'A1' : 'B1']); + } +} + +// ═════════════════════════════════════════════════════════════════════════════════════════════ +// MET CORPUS — dezelfde keten op twee echte P6-bestanden, mét tijdmeting. +// ═════════════════════════════════════════════════════════════════════════════════════════════ +const CORPUS = process.env.OPS_XER_CORPUS; + +function findFile(dir: string, name: string): string | null { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + const found = findFile(full, name); + if (found) return found; + } else if (entry.name === name) { + return full; + } + } + return null; +} + +function corpusRoundTrip(label: string, path: string): void { + const t0 = Date.now(); + const opened = readXER(new Uint8Array(readFileSync(path))); + const parsed = isMultiDocumentImport(opened) ? opened.results[opened.activeDocumentIndex]! : opened; + const tRead = Date.now(); + store().newProject(); + store().applyOpenedImport(parsed, { + filePath: null, fileHandle: null, recompute: true, fit: false, hourDataNotice: false, linkedOpen: false, + }); + const payload = store().getOpenDocumentPayloads().find(d => d.payload.xerSourceProjectId === parsed.xer?.sourceProjectId)!.payload; + const ifc = writeIFC(buildWriteIFCInput(payload)); + const tWrite = Date.now(); + const reopened = readXerArchiveIFC(ifc); + const tReopen = Date.now(); + eq(`C-${label} vastlegging identiek na opslaan+heropenen`, reopened.recordedTimes, parsed.recordedTimes); + // Heropen-beleid: de VERSE import draagt 'xer', maar elke HEROPENING (ook van hetzelfde + // bestand) draagt altijd 'xer-archive' — dat is het hele punt van de policy, geen regressie. + eq(`C-${label} herkomst is de HEROPEN-vorm (niet gelijk aan de verse-importherkomst)`, + reopened.recordedTimesOrigin, 'xer-archive'); + expect(`C-${label} de vastlegging is niet leeg (anders bewijst de gelijkheid niets)`, + Object.keys(reopened.recordedTimes ?? {}).length > 0); + console.log(`. xer-recorded-roundtrip: ${label} — taken=${parsed.tasks.length}` + + ` vastgelegd=${Object.keys(parsed.recordedTimes ?? {}).length}` + + ` ifc=${(ifc.length / 1024 / 1024).toFixed(1)}MiB` + + ` lees=${tRead - t0}ms store+schrijf=${tWrite - tRead}ms heropen=${tReopen - tWrite}ms`); +} + +if (!CORPUS) { + console.log('OK xer-recorded-roundtrip: corpus niet aanwezig (OPS_XER_CORPUS) — corpuspoort overgeslagen'); +} else if (!existsSync(CORPUS)) { + expect('xer-recorded-roundtrip: OPS_XER_CORPUS wijst naar een bestaande map', false); +} else { + const p6diff = join(CORPUS, 'crawl-xer', 'p6diff-baseline.xer'); + if (existsSync(p6diff)) corpusRoundTrip('p6diff-baseline.xer', p6diff); + else expect(`xer-recorded-roundtrip: ${p6diff} bestaat`, false); + + const rehab = findFile(CORPUS, 'rehab-2.xer'); + if (rehab) corpusRoundTrip('rehab-2.xer', rehab); + else expect('xer-recorded-roundtrip: rehab-2.xer gevonden in OPS_XER_CORPUS', false); +} + +// ── Uitslag ────────────────────────────────────────────────────────────────────────────────── +if (failures.length === 0) { + console.log(`OK xer-recorded-roundtrip: alle checks groen (${checks})`); +} else { + console.log(`XX xer-recorded-roundtrip: ${failures.length} afwijking(en) van ${checks}`); + for (const failure of failures) console.log(` XX ${failure}`); + process.exit(1); +} diff --git a/tests/planning/check-xer-recorded-times.ts b/tests/planning/check-xer-recorded-times.ts new file mode 100644 index 000000000..3dabb16bc --- /dev/null +++ b/tests/planning/check-xer-recorded-times.ts @@ -0,0 +1,293 @@ +/** + * BAK 4 — "datums zoals opgeslagen" voor XER (XER-etappeplan §4.1-bijstelling 2026-09-04, + * eigenaarsbesluit X-O7 laag 3), taak T1. Toetst `readXerRecordedTimes` + * (`src/services/xer/xerRecordedTimes.ts`) en de wiring in `readXER` + * (`ImportResult.recordedTimes`/`recordedTimesOrigin`). + * + * CORPUSLOOS (altijd): oracle-fixture met bekende waarden per as, plus mutatiebewijs (b) — een + * TIJDELIJKE lokale assertie die aantoont dat een lek van een bak-4-kolom naar `Task.time` deze + * poort rood maakt. De DEFINITIEVE X12-assertie (positieve helft: gemuteerde opgeslagen uitvoer + * verplaatst de solve niet, maar verplaatst de weergavemodus wél) is taak T2 en herschrijft + * `check-xer-product-fidelity-x12.ts` naar v2 — dat bestand wordt door DEZE taak NIET aangeraakt. + * + * MET CORPUS (OPS_XER_CORPUS): aantallen per as voor twee gepinde bestanden. + */ +import { readXER, type XerReadResult } from '@/services/xer/xerReader'; +import { isMultiDocumentImport } from '@/services/importTypes'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +const diffs: string[] = []; +let checks = 0; + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +function truthy(label: string, cond: boolean): void { + checks++; + if (!cond) diffs.push(`${label}: verwacht waar, kreeg onwaar`); +} + +function bytes(lines: readonly string[]): Uint8Array { + return new TextEncoder().encode(lines.join('\n')); +} + +function read(lines: readonly string[]): XerReadResult { + const parsed = readXER(bytes(lines)); + if (isMultiDocumentImport(parsed)) throw new Error('Enkelprojectfixture gaf een meervoudige import terug'); + return parsed; +} + +// ═══════════════════════════════════════════════════════════════════════════════════════════ +// Corpusloze oracle-fixture — dag-modus, twee kalenders (C1: 8u/dag, C2: 6u/dag — bewijst dat de +// omrekening de TAAK-EFFECTIEVE kalender gebruikt, niet de projectkalender). +// ═══════════════════════════════════════════════════════════════════════════════════════════ +const FIXTURE_HEADER = [ + 'task_id', 'proj_id', 'clndr_id', 'task_code', 'task_name', 'task_type', 'duration_type', + 'status_code', 'target_drtn_hr_cnt', 'remain_drtn_hr_cnt', 'target_start_date', 'target_end_date', + 'early_start_date', 'early_end_date', 'late_start_date', 'late_end_date', + 'total_float_hr_cnt', 'free_float_hr_cnt', +].join('\t'); + +const fixture = [ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tStandaard 8u\tCA_Base\t8\t40\t', + '%R\tC2\tKort 6u\tCA_Base\t6\t30\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date', + '%R\tP1\tRecordedTimesFixture\tC1\t2026-01-01\t2026-01-01', + '%T\tTASK', + `%F\t${FIXTURE_HEADER}`, + // T1 — volledig: early+late+float, positieve speling (5 dagen), niet kritiek. + '%R\tT1\tP1\tC1\tA1\tDeviates\tTT_Task\tDT_FixedDUR\tTK_NotStart\t40\t40\t2026-01-02\t2026-01-09\t2026-01-05\t2026-01-12\t2026-01-10\t2026-01-17\t40\t8', + // T2 — kritiek: total_float_hr_cnt=0 ⇒ isCritical=true (afgeleid, geen driving_path_flag). + '%R\tT2\tP1\tC1\tA2\tCritical\tTT_Task\tDT_FixedDUR\tTK_NotStart\t32\t32\t2026-02-01\t2026-02-05\t2026-02-01\t2026-02-05\t2026-02-01\t2026-02-05\t0\t0', + // T3 — alleen early: late/float ontbreken ⇒ "niet vastgelegd", geen 0/gelijkstelling. + '%R\tT3\tP1\tC1\tA3\tEarlyOnly\tTT_Task\tDT_FixedDUR\tTK_NotStart\t16\t16\t2026-03-01\t2026-03-03\t2026-03-01\t2026-03-03\t\t\t\t', + // T4 — early_end_date ontbreekt: GEEN uitspraak, ook al zijn late/float wél aanwezig. + '%R\tT4\tP1\tC1\tA4\tMissingEarlyFinish\tTT_Task\tDT_FixedDUR\tTK_NotStart\t16\t16\t2026-04-01\t2026-04-06\t2026-04-01\t\t2026-04-05\t2026-04-06\t16\t8', + // T5 — geen enkele early-as, target_start/end WEL aanwezig: bewijst dat er GEEN terugval is. + '%R\tT5\tP1\tC1\tA5\tNoEarlyAtAll\tTT_Task\tDT_FixedDUR\tTK_NotStart\t32\t32\t2026-05-01\t2026-05-05\t\t\t\t\t\t', + // T6 — onparseerbare early_start_date: taak volledig overgeslagen (geen half paar). + '%R\tT6\tP1\tC1\tA6\tMalformed\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-06-01\t2026-06-05\tnot-a-date\t2026-06-05\t\t\t\t', + // T7 — fractionele uren: 4.01u × 60 = 240,6min → Math.round → 241min / 480min-per-dag. + '%R\tT7\tP1\tC1\tA7\tRounding\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-07-01\t2026-07-02\t2026-07-01\t2026-07-02\t\t\t4.01\t', + // T8 — eigen kalender C2 (6u/dag): 6u float ⇒ 1,0 dag. Op de PROJECTkalender (C1, 8u/dag) zou + // dit fout 0,75 dag zijn — bewijst dat de TAAK-effectieve kalender wordt gebruikt. + '%R\tT8\tP1\tC2\tA8\tOtherCalendar\tTT_Task\tDT_FixedDUR\tTK_NotStart\t12\t12\t2026-08-01\t2026-08-02\t2026-08-01\t2026-08-02\t\t\t6\t', + '%E', +] as const; + +const result = read(fixture); + +eq('0 recordedTimesOrigin is xer', result.recordedTimesOrigin, 'xer'); +truthy('0b recordedTimes bestaat', !!result.recordedTimes); +const rt = result.recordedTimes ?? {}; + +eq('1 T1 — volledig early+late+float, niet kritiek', rt.T1, { + start: '2026-01-05', finish: '2026-01-12', + lateStart: '2026-01-10', lateFinish: '2026-01-17', + totalFloat: 5, freeFloat: 1, isCritical: false, +}); +eq('2 T2 — kritiek afgeleid uit totalFloat=0', rt.T2, { + start: '2026-02-01', finish: '2026-02-05', + lateStart: '2026-02-01', lateFinish: '2026-02-05', + totalFloat: 0, freeFloat: 0, isCritical: true, +}); +eq('3 T3 — alleen early vastgelegd; late/float/isCritical ontbreken (geen 0/gelijkstelling)', rt.T3, { + start: '2026-03-01', finish: '2026-03-03', +}); +truthy('4 T4 — early_end_date ontbreekt ⇒ GEEN uitspraak, ook met late/float aanwezig', rt.T4 === undefined); +truthy('5 T5 — geen early-as ⇒ GEEN terugval op target_start/end_date', rt.T5 === undefined); +truthy('6 T6 — onparseerbare early_start_date ⇒ taak volledig overgeslagen', rt.T6 === undefined); +eq('7 T7 — Math.round(4.01×60)=241min / 480min-per-dag', rt.T7, { + start: '2026-07-01', finish: '2026-07-02', + totalFloat: 241 / 480, isCritical: false, +}); +eq('8 T8 — omrekening via de TAAK-effectieve kalender (C2, 6u/dag), niet de projectkalender (C1)', + rt.T8, { start: '2026-08-01', finish: '2026-08-02', totalFloat: 1, isCritical: false }); + +// ── Geen enkele orakelwaarde lekt naar Task.time (de kernregel van laag 3) ────────────────── +// Concreet en scherp (geen tautologie): het bestand plant T1 op target_start/end 02–09 jan, terwijl +// het orakel 05–12 jan zegt. Staat één van die orakelwaarden ook in `task.time`, dan is er ergens +// een lezerregressie die een bak-4-kolom alsnog naar `Task.time` kopieert — precies wat +// MUTATIEBEWIJS (b) hieronder (tijdelijk, handmatig) aantoont. +truthy('9 T1 — task.time draagt NERGENS de orakel-earlyStart (2026-01-05)', + !Object.values(result.tasks.find(t => t.id === 'T1')!.time).includes('2026-01-05')); +truthy('10 T1 — task.time draagt NERGENS de orakel-lateStart (2026-01-10)', + !Object.values(result.tasks.find(t => t.id === 'T1')!.time).includes('2026-01-10')); + +console.log(`. xer-recorded-times: ${Object.keys(rt).length} van ${result.tasks.length} taken met vastgelegde early-as (fixture)`); + +// ═══════════════════════════════════════════════════════════════════════════════════════════ +// MUTATIEBEWIJS (b) — status na taak T2. Checks 9/10 hierboven zijn tijdens de bouw van taak T1 +// bewust ROOD gemaakt door `xerReader.ts` handmatig te muteren (`row.cells.late_start_date` laten +// lekken naar `Task.time.lateStart` in `mappedActivities.push(...)`), gecontroleerd, en weer +// teruggedraaid — nooit als blijvende code hier, want een permanente mutatie in dit testbestand zou +// de fixture naar de implementatie toe schrijven (verboden, baan-preambule). +// +// KEUZE (expliciet gevraagd bij taak T2): checks 9/10 BLIJVEN STAAN als extra, corpusloze dekking +// naast de definitieve X12-mutatieproef — geen verplaatsing. Motivatie: dit bestand draagt zijn eigen +// T1..T8-fixture dekt assen (afronding, per-taak-kalender, ontbrekend-paar-uitsluiting) die de +// X12-productfixtures niet als hoofddoel hebben, en de assertie hier is goedkoper te onderhouden +// (één klein, doelgericht bestand) dan de zwaardere productfixtures. De DEFINITIEVE, plan-vereiste +// X12-mutatieproef (§4/T2: beide helften — `recordedTimes` verschilt exact op de gemuteerde assen +// mét de verwachte waarden, én de solverprojectie blijft byte-gelijk) staat sinds T2 in +// `check-xer-product-fidelity-x12.ts` (zoek op "X12-T2"), op de bestaande `completedPackageBytes`- +// en `packageBytes`-fixtures — dat bestand is de canonieke poort voor dit mutatiebewijs. + +// ═══════════════════════════════════════════════════════════════════════════════════════════ +// MET CORPUS: aantallen per as voor twee gepinde bestanden (X-O7-bijstellingsrapport). +// ═══════════════════════════════════════════════════════════════════════════════════════════ +const CORPUS = process.env.OPS_XER_CORPUS; + +function axisCounts(r: XerReadResult): { + tasks: number; early: number; late: number; totalFloat: number; freeFloat: number; critical: number; +} { + const times = Object.values(r.recordedTimes ?? {}); + return { + tasks: r.tasks.length, + early: times.length, + late: times.filter(t => t.lateStart !== undefined && t.lateFinish !== undefined).length, + totalFloat: times.filter(t => t.totalFloat !== undefined).length, + freeFloat: times.filter(t => t.freeFloat !== undefined).length, + critical: times.filter(t => t.isCritical === true).length, + }; +} + +if (!CORPUS) { + console.log('OK xer-recorded-times: corpus niet aanwezig (OPS_XER_CORPUS) — corpuspoort overgeslagen'); +} else if (!existsSync(CORPUS)) { + truthy('xer-recorded-times: OPS_XER_CORPUS wijst naar een bestaande map', false); +} else { + const p6diffPath = join(CORPUS, 'crawl-xer', 'p6diff-baseline.xer'); + if (existsSync(p6diffPath)) { + const parsed = readXER(new Uint8Array(readFileSync(p6diffPath))); + const r = isMultiDocumentImport(parsed) ? parsed.results[parsed.activeDocumentIndex] : parsed; + const counts = axisCounts(r as XerReadResult); + eq('K1 p6diff-baseline.xer — 8 van 12 taken met vastgelegd early-paar', counts.early, 8); + console.log(`. xer-recorded-times: p6diff-baseline.xer — ${JSON.stringify(counts)}`); + } else { + truthy(`xer-recorded-times: ${p6diffPath} bestaat`, false); + } + + // rehab-2.xer kan op verschillende dieptes in de crawl-extra-boom staan; zoek 'm op, net als + // andere corpuschecks doen voor bestanden buiten een vaste submap. + function findFile(dir: string, name: string): string | null { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + const found = findFile(full, name); + if (found) return found; + } else if (entry.name === name) { + return full; + } + } + return null; + } + const rehabPath = findFile(CORPUS, 'rehab-2.xer'); + if (rehabPath) { + const parsed = readXER(new Uint8Array(readFileSync(rehabPath))); + const r = isMultiDocumentImport(parsed) ? parsed.results[parsed.activeDocumentIndex] : parsed; + const counts = axisCounts(r as XerReadResult); + truthy(`K2 rehab-2.xer — ~6,9k taken met vastgelegd early-paar (gemeten: ${counts.early})`, + counts.early >= 6800 && counts.early <= 7000); + console.log(`. xer-recorded-times: rehab-2.xer — ${JSON.stringify(counts)}`); + } else { + truthy('xer-recorded-times: rehab-2.xer gevonden in OPS_XER_CORPUS', false); + } +} + +// ═══════════════════════════════════════════════════════════════════════════════════════════ +// Her-check laag 3, bevindingen 6 en 7 — ordetoets en kritiekdefinitie. +// ═══════════════════════════════════════════════════════════════════════════════════════════ +{ + const PROJECT_HEADER = 'proj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date\tcritical_path_type\tcritical_drtn_hr_cnt'; + const withProject = (projectRow: string, taskRows: readonly string[]) => [ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tStandaard 8u\tCA_Base\t8\t40\t', + '%T\tPROJECT', + `%F\t${PROJECT_HEADER}`, + projectRow, + '%T\tTASK', + `%F\t${FIXTURE_HEADER}`, + ...taskRows, + '%E', + ]; + // Bevinding 6 (her-check laag 3) — GEMETEN EN VERWORPEN: een vastgelegd einde vóór de start is + // P6's eigen conventie voor voltooide activiteiten (early_start = statusdatum, early_end = het + // werkelijke einde; rehab-2: 2.042 van 6.976 taken, 2.036 daarvan TK_Complete). De lezer mag die + // vastlegging dus NIET weigeren — anders liegt de modus over 29% van dat bestand. Zie het + // docblok van `readXerRecordedTimes`. MUTATIEBEWIJS: voeg een `finish < start ⇒ continue`-guard + // toe ⇒ 9a slaat ROOD. + const completedInverted = read(withProject( + '%R\tP1\tCompleted\tC1\t2026-02-16\t2026-01-01\tCT_TotFloat\t0', + ['%R\tT1\tP1\tC1\tA1\tDone\tTT_Task\tDT_FixedDUR\tTK_Complete\t40\t0\t2026-02-02\t2026-02-09\t2026-02-16\t2026-02-09\t2026-02-16\t2026-02-09\t0\t0'], + )); + eq('9a P6-conventie voor voltooide activiteiten: early_end vóór early_start wordt letterlijk vastgelegd, niet geweigerd', + completedInverted.recordedTimes?.T1, { + start: '2026-02-16', finish: '2026-02-09', lateStart: '2026-02-16', lateFinish: '2026-02-09', + totalFloat: 0, freeFloat: 0, isCritical: true, + }); + // Her-check R2: de resterende 2,6% inversies zijn NIET voltooid (nul-duur/mijlpaalrijen, scheve + // bronnen) — óók die worden letterlijk vastgelegd, zodat een "alleen voltooid"-guard hier rood slaat. + const notStartedInverted = read(withProject( + '%R\tP1\tNotStarted\tC1\t2026-01-01\t2026-01-01\tCT_TotFloat\t0', + ['%R\tT1\tP1\tC1\tA1\tScheef\tTT_Task\tDT_FixedDUR\tTK_NotStart\t0\t0\t2026-02-09\t2026-02-09\t2026-02-16\t2026-02-09\t\t\t\t'], + )); + eq('9b ook een NIET-gestarte rij met einde vóór start wordt letterlijk vastgelegd (geen guard op statusklasse)', + notStartedInverted.recordedTimes?.T1, { start: '2026-02-16', finish: '2026-02-09' }); + // Her-check R3: laat de kalenderhardening de speling vallen (`day_hr_cnt` 0 ⇒ minutesPerDay 0), + // dan is óók isCritical "niet vastgelegd" — geen harde `false` naast een "Niet vastgelegd"-cel. + const zeroDay = read([ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC0\tNuluur\tCA_Base\t0\t0\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date', + '%R\tP1\tZeroDay\tC0\t2026-01-01\t2026-01-01', + '%T\tTASK', + `%F\t${FIXTURE_HEADER}`, + '%R\tT1\tP1\tC0\tA1\tNuluur\tTT_Task\tDT_FixedDUR\tTK_NotStart\t40\t40\t2026-01-02\t2026-01-09\t2026-01-05\t2026-01-12\t2026-01-10\t2026-01-17\t40\t8', + '%E', + ]); + eq('9c speling niet omrekenbaar (minutesPerDay ≤ 0) ⇒ totalFloat, freeFloat én isCritical alle drie "niet vastgelegd"', + [zeroDay.recordedTimes?.T1?.totalFloat, zeroDay.recordedTimes?.T1?.freeFloat, zeroDay.recordedTimes?.T1?.isCritical], + [undefined, undefined, undefined]); + + // Bevinding 7: `isCritical` volgt de kritiekdefinitie die de solver óók krijgt. + const drempel = read(withProject( + '%R\tP1\tDrempel\tC1\t2026-01-01\t2026-01-01\tCT_TotFloat\t8', + ['%R\tT1\tP1\tC1\tA1\tFloat4h\tTT_Task\tDT_FixedDUR\tTK_NotStart\t40\t40\t2026-01-02\t2026-01-09\t2026-01-05\t2026-01-12\t2026-01-10\t2026-01-17\t4\t0'], + )); + eq('9e drempel 8u en 4u speling ⇒ kritiek (de solver kleurt hem óók kritiek)', + [drempel.recordedTimes?.T1?.totalFloat, drempel.recordedTimes?.T1?.isCritical, drempel.project.schedulingOptions?.criticalDefinition], + [0.5, true, { mode: 'totalFloat', thresholdHours: 8 }]); + const drivpath = read(withProject( + '%R\tP1\tLongest\tC1\t2026-01-01\t2026-01-01\tCT_DrivPath\t0', + ['%R\tT1\tP1\tC1\tA1\tLongest\tTT_Task\tDT_FixedDUR\tTK_NotStart\t40\t40\t2026-01-02\t2026-01-09\t2026-01-05\t2026-01-12\t2026-01-10\t2026-01-17\t0\t0'], + )); + eq('9f longest-path-kritiek is uit een float niet af te leiden ⇒ isCritical "niet vastgelegd", float blijft', + [drivpath.recordedTimes?.T1?.totalFloat, drivpath.recordedTimes?.T1?.isCritical, drivpath.project.schedulingOptions?.criticalDefinition], + [0, undefined, { mode: 'longestPath' }]); + eq('9g zonder PROJECT-kolommen blijft de P6-default (drempel 0) ⇒ byte-identiek aan vóór de bevinding', + [rt.T2?.isCritical, rt.T1?.isCritical], [true, false]); +} + +// ── Uitslag ────────────────────────────────────────────────────────────────── +if (diffs.length === 0) { + console.log(`OK: xer-recorded-times — ${checks} checks groen`); +} else { + console.log(`XX xer-recorded-times — ${diffs.length} van ${checks} checks rood:`); + for (const d of diffs) console.log(` XX ${d}`); + process.exit(1); +} diff --git a/tests/planning/check-xer-registry.ts b/tests/planning/check-xer-registry.ts new file mode 100644 index 000000000..d4c865609 --- /dev/null +++ b/tests/planning/check-xer-registry.ts @@ -0,0 +1,130 @@ +import { + allReadFormats, + binaryExtensions, + importErrorMessageKey, + openDialogFilters, + parseOpenedFile, + readFormatInput, + readFormatForFile, + saveTargetFor, +} from '@/services/formatRegistry'; +import { XerImportError, type XerImportErrorCode } from '@/services/xer/xerTables'; +import { activeImportResult } from '@/services/importTypes'; + +const diffs: string[] = []; +let checks = 0; + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +function fixture(projectName: string): string { + return [ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_data', + '%R\tC1\tStandaard\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date', + `%R\tP1\t${projectName}\tC1\t2026-01-01 08:00`, + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_code\ttask_name\ttarget_start_date\ttarget_end_date', + '%R\tT1\tP1\tA1\tTaak\t2026-01-01 08:00\t2026-01-01 16:00', + '%E', + ].join('\n'); +} + +function utf16(text: string, littleEndian: boolean): Uint8Array { + const out = new Uint8Array(2 + text.length * 2); + out[0] = littleEndian ? 0xff : 0xfe; + out[1] = littleEndian ? 0xfe : 0xff; + for (let index = 0; index < text.length; index++) { + const value = text.charCodeAt(index); + out[2 + index * 2] = littleEndian ? value & 0xff : value >>> 8; + out[3 + index * 2] = littleEndian ? value >>> 8 : value & 0xff; + } + return out; +} + +function cp1252(text: string): Uint8Array { + const bytes: number[] = []; + for (const character of text) { + if (character === '€') bytes.push(0x80); + else if (character === 'é') bytes.push(0xe9); + else bytes.push(character.charCodeAt(0)); + } + return Uint8Array.from(bytes); +} + +const format = readFormatForFile('planning.XeR'); +eq('1 .xer staat als lazy binair leesformaat in het register', format && { + id: format.id, + kind: format.kind, + canBeSaveTarget: Boolean(format.canBeSaveTarget), +}, { id: 'xer', kind: 'binary', canBeSaveTarget: false }); +eq('2 XER staat in openfilters en binaire extensies', { + binary: binaryExtensions().includes('xer'), + filter: openDialogFilters().some(item => item.extensions.includes('xer')), + listed: allReadFormats().some(item => item.id === 'xer'), +}, { binary: true, filter: true, listed: true }); +eq('3 XER is nooit een opslagdoel', saveTargetFor(format, null, 'bron.xer'), { + filePath: null, + fileHandle: null, +}); + +const ioCalls: string[] = []; +const routed = await readFormatInput('/tmp/bron.xer', { + readTextFile: async () => { + ioCalls.push('text'); + throw new Error('XER mag nooit als tekst gelezen worden'); + }, + readFile: async () => { + ioCalls.push('bytes'); + return new Uint8Array([0x80, 0xff]); + }, +}); +eq('4 gedeeld Tauri/MCP/dev-bridge-pad kiest bytes zonder re-encoding', { + calls: ioCalls, + sameBytes: routed.bytes?.[0] === 0x80 && routed.bytes?.[1] === 0xff, + text: routed.text, +}, { calls: ['bytes'], sameBytes: true }); + +const encoded = [ + ['CP1252', cp1252(fixture('Café €'))], + ['UTF-16LE BOM', utf16(fixture('Café €'), true)], + ['UTF-16BE BOM', utf16(fixture('Café €'), false)], +] as const; +for (const [label, input] of encoded) { + const parsed = activeImportResult(await parseOpenedFile({ name: 'bron.xer', bytes: input })); + eq(`5 ${label} bereikt de lazy reader als originele bytes`, parsed.project.name, 'Café €'); +} + +const errorKeys: ReadonlyArray = [ + ['XER_INVALID_INPUT', 'notifications.xerInvalidInput'], + ['XER_INVALID_FILE', 'notifications.xerInvalidFile'], + ['XER_INVALID_ENCODING', 'notifications.xerInvalidEncoding'], + ['XER_DUPLICATE_TABLE', 'notifications.xerDuplicateTable'], + ['XER_MISSING_REQUIRED_COLUMNS', 'notifications.xerMissingRequiredColumns'], + ['XER_MISSING_REQUIRED_VALUE', 'notifications.xerMissingRequiredValue'], + ['XER_AMBIGUOUS_DECIMAL', 'notifications.xerAmbiguousDecimal'], + ['XER_INVALID_NUMBER_FORMAT', 'notifications.xerInvalidNumberFormat'], + ['XER_INVALID_NUMBER', 'notifications.xerInvalidNumber'], + ['XER_SINGLE_PROJECT_REQUIRED', 'notifications.xerSingleProjectRequired'], + ['XER_EMPTY_PROJECT', 'notifications.xerEmptyProject'], + ['XER_DUPLICATE_ID', 'notifications.xerDuplicateId'], + ['XER_AMBIGUOUS_LOCAL_RELATION', 'notifications.xerAmbiguousLocalRelation'], + ['XER_DANGLING_LOCAL_RELATION', 'notifications.xerDanglingLocalRelation'], +]; +eq('6 iedere typed XER-fout heeft een eigen meldingssleutel', + errorKeys.map(([code]) => importErrorMessageKey(new XerImportError(code, code))), + errorKeys.map(([, key]) => key)); + +if (diffs.length > 0) { + console.error(`XER-registry: ${diffs.length}/${checks} checks rood`); + for (const diff of diffs) console.error(`XX ${diff}`); + process.exit(1); +} +console.log(`OK XER-registry: ${checks} lazy/bytes/filter/opslagdoel-checks`); diff --git a/tests/planning/check-xer-resource-catalog-types.ts b/tests/planning/check-xer-resource-catalog-types.ts new file mode 100644 index 000000000..3968e1415 --- /dev/null +++ b/tests/planning/check-xer-resource-catalog-types.ts @@ -0,0 +1,37 @@ +/** + * Compile-time contract: de gedeelde XER-catalogus is diep readonly, terwijl + * de per-projectmaterialisatie een mutabele projectie houdt. Elke ongebruikte + * @ts-expect-error betekent dat een catalogusmutatie weer zou compileren. + */ +import type { XerResourceCatalog } from '@/services/xer/xerResources'; +import type { XerResourceReadResult } from '@/services/xer/xerResourceTypes'; + +declare const catalog: XerResourceCatalog; +declare const projectView: XerResourceReadResult; + +// @ts-expect-error Catalogusresources zijn diep readonly. +catalog.resources[0].name = 'verboden'; +// @ts-expect-error Beschikbaarheidstappen in de catalogus zijn diep readonly. +catalog.resources[0].availabilitySteps![0].maxUnits = 99; +// @ts-expect-error Identiteiten in de catalogus zijn readonly. +catalog.identities[0].sourceId = 'verboden'; +// @ts-expect-error Rijen en hun cellen in de catalogus zijn readonly. +catalog.rows.assignments[0].cells.taskrsrc_id = 'verboden'; +// @ts-expect-error Rate-entiteiten in de catalogus zijn diep readonly. +catalog.rows.rates[0].entity.sourceId = 'verboden'; +// @ts-expect-error Rate-kostentuples in de catalogus zijn readonly. +catalog.rows.rates[0].costs[0] = 99; +// @ts-expect-error Curvepunten in de catalogus zijn readonly tuples. +catalog.rows.curves[0].rawPoints[0] = '99'; +// @ts-expect-error Numerieke curvepunten in de catalogus zijn readonly tuples. +catalog.rows.curves[0].numericPoints![0] = 99; +// @ts-expect-error Catalogusissues zijn readonly. +catalog.issues[0].sourceId = 'verboden'; + +// De projectprojectie is bewust mutable en staat los van de catalogus. +projectView.resources[0].name = 'toegestaan'; +projectView.sources.assignments[0].entity.sourceId = 'toegestaan'; +projectView.sources.assignments[0].assignedRole!.sourceId = 'toegestaan'; +projectView.sources.assignments[0].quantities.target = 1; +projectView.sources.assignments[0].costs.target = 1; +projectView.sources.assignments[0].rawCurves.target = 'TC'; diff --git a/tests/planning/check-xer-resource-corpus.ts b/tests/planning/check-xer-resource-corpus.ts new file mode 100644 index 000000000..5befb8112 --- /dev/null +++ b/tests/planning/check-xer-resource-corpus.ts @@ -0,0 +1,255 @@ +/** + * X6-corpuspoort. Het orakel leest %T/%F/%R direct uit de oorspronkelijke bytes, zonder een + * productiemodule te importeren. Pas daarna mag de productiecatalogus worden vergeleken. + */ +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { readXER } from '@/services/xer/xerReader'; +import { readXerCalendars } from '@/services/xer/xerCalendarData'; +import { indexXerTaskResourceRows } from '@/services/xer/xerResourceAssignments'; +import { buildXerResourceCatalog, materializeXerResources } from '@/services/xer/xerResources'; +import { parseXerTables } from '@/services/xer/xerTables'; + +interface Pin { + bytes: number; + resources: number; + rates: number; + assignments: number; + materialAssignments: number; + curves: number; + roles: number; + maxParserMs: number; + maxCatalogBuildMs: number; + maxMaterializationMs: number; + maxEndToEndMs: number; + maxParserHeapBytes: number; + maxCatalogBuildHeapBytes: number; + maxMaterializationHeapBytes: number; + maxEndToEndHeapBytes: number; +} + +const PINS: Readonly> = { + a2ef7b35c00d8cf8: { + bytes: 1797989, resources: 92, rates: 91, assignments: 3575, materialAssignments: 85, curves: 0, roles: 2, + maxParserMs: 1000, maxCatalogBuildMs: 1000, maxMaterializationMs: 1000, maxEndToEndMs: 3000, + maxParserHeapBytes: 67108864, maxCatalogBuildHeapBytes: 33554432, maxMaterializationHeapBytes: 33554432, maxEndToEndHeapBytes: 134217728, + }, + '2c1dce175b9f0781': { + bytes: 18592333, resources: 179, rates: 55, assignments: 52640, materialAssignments: 10584, curves: 1, roles: 2, + maxParserMs: 8000, maxCatalogBuildMs: 5000, maxMaterializationMs: 5000, maxEndToEndMs: 16000, + maxParserHeapBytes: 402653184, maxCatalogBuildHeapBytes: 134217728, maxMaterializationHeapBytes: 134217728, maxEndToEndHeapBytes: 805306368, + }, +}; + +type DirectRow = Record; +interface DirectRate { + sourceId: string; + kind: 'RESOURCE' | 'ROLE'; + entitySourceId: string; + effectiveDate?: string; + maxUnitsPerTime: number | null; + costs: Array; +} +interface DirectCurve { sourceId: string; rawPoints: string[]; numericPoints?: number[]; } +interface DirectScan { + projectId: string; + resources: number; + rates: DirectRate[]; + assignments: Array<{ sourceId: string; taskId: string; entityKind: 'RESOURCE' | 'ROLE'; entitySourceId: string }>; + materialAssignments: number; + curves: DirectCurve[]; + roles: number; + resourceCalendars: Array<{ sourceId: string; calendarSourceId?: string }>; + rawAssignmentIds: string[]; +} + +const diffs: string[] = []; +let checks = 0; +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); +} +function present(label: string, value: T | null | undefined): value is T { + checks++; + if (value === null || value === undefined) { + diffs.push(`${label}: verplichte corpusdata ontbreekt`); + return false; + } + return true; +} +function hash(bytes: Uint8Array): string { return createHash('sha256').update(bytes).digest('hex').slice(0, 16); } +function files(dir: string): string[] { + const found: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) found.push(...files(path)); + else if (entry.isFile() && entry.name.toLowerCase().endsWith('.xer')) found.push(path); + } + return found; +} +function gc(): void { + const force = (globalThis as typeof globalThis & { gc?: () => void }).gc; + if (typeof force !== 'function') throw new Error('X6-corpuspoort vereist node --expose-gc'); + force(); +} +function measure(work: () => T): { value: T; ms: number; heapBytes: number } { + const before = process.memoryUsage().heapUsed; + const start = performance.now(); + const value = work(); + const ms = performance.now() - start; + // Alle vier fasen houden hun resultaat vast voor het onafhankelijke orakel. Daarom is dit een + // live-heapdelta; een GC per fase zou die legitiem live grafiek scannen in plaats van meten. + return { value, ms, heapBytes: Math.max(0, process.memoryUsage().heapUsed - before) }; +} +function separator(raw: string, family: 'decimal' | 'group'): '.' | ',' | undefined { + const value = raw.trim().toLowerCase(); + if (!value) return undefined; + if (value === '.' || value === 'period' || value === `d${family === 'decimal' ? 's' : 'g'}_period`) return '.'; + if (value === ',' || value === 'comma' || value === `d${family === 'decimal' ? 's' : 'g'}_comma`) return ','; + throw new Error(`Onafhankelijke scanner kan CURRTYPE-token ${raw} niet duiden`); +} +function numberFormat(rows: ReadonlyMap, currencyCode: string): { decimal: '.' | ','; group: '.' | ',' | null } { + const currency = (rows.get('CURRTYPE') ?? []).find(row => row.curr_short_name?.trim().toLowerCase() === currencyCode.trim().toLowerCase()); + if (!currency) return { decimal: '.', group: null }; + const decimal = separator(currency.decimal_symbol || currency.decimal_symbol_type || '', 'decimal'); + const group = separator(currency.digit_group_symbol || currency.digit_group_symbol_type || '', 'group'); + if (!decimal || !group || decimal === group) throw new Error('Onafhankelijke scanner mist geldige CURRTYPE-separators'); + return { decimal, group }; +} +function sourceNumber(raw: string | undefined, format: { decimal: '.' | ','; group: '.' | ',' | null }): number | null { + const value = raw?.trim() ?? ''; + if (!value) return null; + const normalized = (format.group ? value.split(format.group).join('') : value).replace(format.decimal, '.'); + const parsed = Number(normalized); + if (!Number.isFinite(parsed)) throw new Error(`Onafhankelijke scanner kreeg ongeldig getal ${JSON.stringify(value)}`); + return parsed; +} +/** Onafhankelijke tegenhanger van `normalizeCurveValues` (contourEngine.ts): 21 eindige, + * niet-negatieve punten met een positieve som over de indices 1..20, of `undefined`. Sinds de + * contour-engine-etappe is dit de ENIGE validatie die de productiecode nog doet (geen + * eigen XER-curve-familie-best-fit meer — die is verwijderd, zie xerResourceCurves.ts). */ +function curveNumericPoints(points: readonly string[], format: { decimal: '.' | ','; group: '.' | ',' | null }): number[] | undefined { + if (points.length !== 21) return undefined; + const values: number[] = []; + for (const point of points) { + const value = sourceNumber(point, format); + if (value === null || !Number.isFinite(value) || value < 0) return undefined; + values.push(value); + } + const sum = values.slice(1).reduce((total, point) => total + point, 0); + return sum > 0 ? values : undefined; +} +function scan(bytes: Uint8Array): DirectScan { + const text = new TextDecoder().decode(bytes); + const rows = new Map(); + let table = ''; + let fields: string[] = []; + let currencyCode = ''; + for (const line of text.split(/\r\n|\n|\r/)) { + const values = line.split('\t'); + if (values[0] === 'ERMHDR') currencyCode = values[8] ?? ''; + if (values[0] === '%E') break; + if (values[0] === '%T') { table = values[1] ?? ''; fields = []; continue; } + if (values[0] === '%F') { fields = values.slice(1).map(field => field.trim().toLowerCase()); continue; } + if (values[0] !== '%R' || fields.length === 0) continue; + const row: DirectRow = {}; + fields.forEach((field, index) => { row[field] = values[index + 1] ?? ''; }); + const tableRows = rows.get(table); + if (tableRows) tableRows.push(row); + else rows.set(table, [row]); + } + const format = numberFormat(rows, currencyCode); + const projectId = rows.get('PROJECT')?.[0]?.proj_id ?? ''; + const resources = rows.get('RSRC') ?? []; + const roles = rows.get('ROLES') ?? []; + const resourceTypes = new Map(resources.map(row => [row.rsrc_id, row.rsrc_type?.trim().toLowerCase()])); + const assignmentRows = (rows.get('TASKRSRC') ?? []).filter(row => row.proj_id === projectId); + const rateRows = [ + ...(rows.get('RSRCRATE') ?? []).map(row => ({ row, kind: 'RESOURCE' as const, id: 'rsrc_rate_id', entity: 'rsrc_id' })), + ...(rows.get('ROLERATE') ?? []).map(row => ({ row, kind: 'ROLE' as const, id: 'role_rate_id', entity: 'role_id' })), + ]; + return { + projectId, + resources: resources.length, + rates: rateRows.map(({ row, kind, id, entity }) => ({ + sourceId: row[id] || '', kind, entitySourceId: row[entity] || '', + ...(row.start_date?.trim().match(/^(\d{4}-\d{2}-\d{2})/)?.[1] ? { effectiveDate: row.start_date.trim().slice(0, 10) } : {}), + maxUnitsPerTime: sourceNumber(row.max_qty_per_hr, format), + costs: ['cost_per_qty', 'cost_per_qty2', 'cost_per_qty3', 'cost_per_qty4', 'cost_per_qty5'].map(field => sourceNumber(row[field], format)), + })).sort((left, right) => left.kind.localeCompare(right.kind) || left.entitySourceId.localeCompare(right.entitySourceId) || left.sourceId.localeCompare(right.sourceId)), + assignments: assignmentRows.map(row => ({ sourceId: row.taskrsrc_id || '', taskId: row.task_id || '', entityKind: row.rsrc_id?.trim() ? 'RESOURCE' as const : 'ROLE' as const, entitySourceId: row.rsrc_id?.trim() || row.role_id?.trim() || '' })), + materialAssignments: assignmentRows.filter(row => resourceTypes.get(row.rsrc_id) === 'rt_mat').length, + curves: (rows.get('RSRCCURVDATA') ?? []).map(row => { + const rawPoints = Array.from({ length: 21 }, (_, index) => row[`pct_usage_${index}`] ?? ''); + const numericPoints = curveNumericPoints(rawPoints, format); + return { sourceId: row.curv_id || '', rawPoints, ...(numericPoints ? { numericPoints } : {}) }; + }), + roles: roles.length, + resourceCalendars: resources.map(row => ({ sourceId: row.rsrc_id || '', ...(row.clndr_id?.trim() ? { calendarSourceId: row.clndr_id.trim() } : {}) })), + rawAssignmentIds: (rows.get('TASKRSRC') ?? []).map(row => row.taskrsrc_id || ''), + }; +} +function exceeds(label: string, actual: number, maximum: number): void { + checks++; + if (actual > maximum) diffs.push(`${label}: ${actual.toFixed(1)} boven ${maximum}`); +} + +const corpus = process.env.OPS_XER_CORPUS; +if (!corpus) console.log('OK X6 corpus: OPS_XER_CORPUS niet gezet — corpuspins overgeslagen'); +else if (!existsSync(corpus)) eq('X6 corpusmap bestaat', false, true); +else { + const targets = new Map(); + for (const file of files(corpus)) { + const content = new Uint8Array(readFileSync(file)); + const digest = hash(content); + if (PINS[digest]) targets.set(digest, content); + if (targets.size === Object.keys(PINS).length) break; + } + for (const [digest, pin] of Object.entries(PINS)) { + const content = targets.get(digest); + eq(`${digest}: openbare corpuspin aanwezig`, Boolean(content), true); + if (!content) continue; + const source = scan(content); + eq(`${digest}: onafhankelijke bytescan telt de volledige X6-bron`, { bytes: content.byteLength, resources: source.resources, rates: source.rates.length, assignments: source.assignments.length, materialAssignments: source.materialAssignments, curves: source.curves.length, roles: source.roles }, { bytes: pin.bytes, resources: pin.resources, rates: pin.rates, assignments: pin.assignments, materialAssignments: pin.materialAssignments, curves: pin.curves, roles: pin.roles }); + gc(); + const parser = measure(() => parseXerTables(content)); + const tables = parser.value; + const calendarResult = readXerCalendars(tables); + const projectRow = tables.tables.get('PROJECT')?.rows[0]; + const project = projectRow?.cells.proj_id; + const projectCalendar = projectRow ? calendarResult.byId.get(projectRow.cells.clndr_id ?? '') ?? calendarResult.calendars[0] : undefined; + if (!present(`${digest}: projectrij`, projectRow) || !present(`${digest}: project-id`, project) || !present(`${digest}: projectkalender`, projectCalendar)) continue; + const availableCalendarIds = new Set(calendarResult.calendars.map(calendar => calendar.id)); + const catalogBuild = measure(() => buildXerResourceCatalog(tables, availableCalendarIds)); + const catalog = catalogBuild.value; + const materialization = measure(() => materializeXerResources(catalog, tables, { + projectId: project, projectCalendarId: projectCalendar.id, projectHoursPerDay: projectCalendar.hoursPerDay, + availableCalendarIds, calendarHoursPerDay: new Map(calendarResult.calendars.map(calendar => [calendar.id, calendar.hoursPerDay])), + taskIds: new Set((tables.tables.get('TASK')?.rows ?? []).filter(row => row.cells.proj_id === project).map(row => row.cells.task_id)), + }, indexXerTaskResourceRows(tables).get(project) ?? [])); + const materialized = materialization.value; + const endToEnd = measure(() => readXER(content)); + const catalogRates = catalog.rows.rates.map(rate => ({ sourceId: rate.sourceId, kind: rate.entity.kind, entitySourceId: rate.entity.sourceId, ...(rate.effectiveDate ? { effectiveDate: rate.effectiveDate } : {}), maxUnitsPerTime: rate.maxUnitsPerTime, costs: [...rate.costs] })).sort((left, right) => left.kind.localeCompare(right.kind) || left.entitySourceId.localeCompare(right.entitySourceId) || left.sourceId.localeCompare(right.sourceId)); + eq(`${digest}: exacte rates en ingangsdatums volgen de directe tabscan`, catalogRates, source.rates); + eq(`${digest}: resourcekalenderverwijzingen volgen de directe RSRC-scan`, catalog.rows.resources.map(row => ({ sourceId: row.sourceId, ...(row.calendarSourceId ? { calendarSourceId: row.calendarSourceId } : {}) })), source.resourceCalendars); + eq(`${digest}: projectpartitie bevat alleen de directe TASKRSRC-projectview`, materialized.sources.assignments.map(row => ({ sourceId: row.sourceId, taskId: row.taskSourceId, entityKind: row.entity.kind, entitySourceId: row.entity.sourceId })), source.assignments); + eq(`${digest}: role-only TASKRSRC blijft role-only`, materialized.sources.assignments.filter(row => row.entity.kind === 'ROLE').map(row => row.sourceId), source.assignments.filter(row => row.entityKind === 'ROLE').map(row => row.sourceId)); + eq(`${digest}: 21 curvepunten en onafhankelijke normalisatievalidatie blijven behouden`, catalog.rows.curves.map(curve => ({ sourceId: curve.sourceId, rawPoints: [...curve.rawPoints], ...(curve.numericPoints ? { numericPoints: [...curve.numericPoints] } : {}) })), source.curves); + const rawRowByAssignmentId = new Map(catalog.rows.assignments.map(row => [row.cells.taskrsrc_id, row])); + eq(`${digest}: iedere projectbron behoudt de catalogus-raw-rowidentiteit`, materialized.sources.assignments.every(sourceAssignment => sourceAssignment.rawRow === rawRowByAssignmentId.get(sourceAssignment.sourceId)), true); + eq(`${digest}: retained raw TASKRSRC-telling is volledig vóór projectfilter`, catalog.rows.assignments.map(row => row.cells.taskrsrc_id), source.rawAssignmentIds); + exceeds(`${digest}: parser ms`, parser.ms, pin.maxParserMs); + exceeds(`${digest}: catalogusbouw ms`, catalogBuild.ms, pin.maxCatalogBuildMs); + exceeds(`${digest}: materialisatie ms`, materialization.ms, pin.maxMaterializationMs); + exceeds(`${digest}: end-to-end readXER ms`, endToEnd.ms, pin.maxEndToEndMs); + exceeds(`${digest}: parser heap`, parser.heapBytes, pin.maxParserHeapBytes); + exceeds(`${digest}: catalogusbouw heap`, catalogBuild.heapBytes, pin.maxCatalogBuildHeapBytes); + exceeds(`${digest}: materialisatie heap`, materialization.heapBytes, pin.maxMaterializationHeapBytes); + exceeds(`${digest}: end-to-end readXER heap`, endToEnd.heapBytes, pin.maxEndToEndHeapBytes); + console.log(`. X6 corpus ${digest}: parser ${parser.ms.toFixed(1)} ms/${parser.heapBytes} B; catalogusbouw ${catalogBuild.ms.toFixed(1)} ms/${catalogBuild.heapBytes} B; materialisatie ${materialization.ms.toFixed(1)} ms/${materialization.heapBytes} B; end-to-end ${endToEnd.ms.toFixed(1)} ms/${endToEnd.heapBytes} B`); + } +} +if (diffs.length) { console.error(`XX X6 corpus (${checks} checks)\n${diffs.join('\n')}`); process.exitCode = 1; } +else console.log(`OK X6 corpus (${checks} checks)`); diff --git a/tests/planning/check-xer-resource-duplicate.ts b/tests/planning/check-xer-resource-duplicate.ts new file mode 100644 index 000000000..a7523e978 --- /dev/null +++ b/tests/planning/check-xer-resource-duplicate.ts @@ -0,0 +1,140 @@ +/** + * X6/storecontract: documentduplicatie kloont de mutable projectview, maar materialiseert de + * bestandsbrede immutable XER-catalogus en haar ruwe rijen niet opnieuw. + */ +import { createAppStore } from '@/state/appStore'; +import { readXER } from '@/services/xer/xerReader'; +import { isMultiDocumentImport } from '@/services/importTypes'; + +const diffs: string[] = []; +let checks = 0; + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +function present(label: string, value: T | null | undefined): value is T { + checks++; + if (value === null || value === undefined) { + diffs.push(`${label}: verplichte testdata ontbreekt`); + return false; + } + return true; +} + +const RAW_ASSIGNMENT_COUNT = 52_640; + +function largeSyntheticXer(): Uint8Array { + const lines = [ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tdef_duration_type\tlast_recalc_date', + '%R\tP1\tGrote duplicatieproef\tCP\tDT_FixedDUR2\t2026-01-01', + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_name\ttask_code\ttarget_start_date\ttarget_end_date\ttarget_drtn_hr_cnt\ttask_type\tstatus_code', + '%R\tT1\tP1\tTaak\tA1\t2026-01-01\t2026-01-02\t8\tTT_Task\tTK_NotStart', + '%T\tRSRC', + '%F\trsrc_id\trsrc_name\trsrc_type\tdef_qty_per_hr', + '%R\tR1\tVakman\tRT_Labor\t1', + '%T\tTASKRSRC', + '%F\ttaskrsrc_id\tproj_id\ttask_id\trsrc_id\ttarget_qty_per_hr\tremain_qty\ttarget_cost', + '%R\tA-PROJECT\tP1\tT1\tR1\t0.5\t4\t100', + ]; + for (let index = 1; index < RAW_ASSIGNMENT_COUNT; index++) { + lines.push(`%R\tA-RETAINED-${index}\tB9\tTB-${index}\tR1\t1\t8\t10`); + } + lines.push('%E'); + return new TextEncoder().encode(lines.join('\n')); +} + +const parsed = readXER(largeSyntheticXer()); +if (isMultiDocumentImport(parsed)) { + diffs.push('X6-storefixture hoort één projectdocument op te leveren'); +} else { + const store = createAppStore(); + store.getState().applyLoadedProject(parsed, { + filePath: null, + fileHandle: null, + recompute: false, + fit: false, + hourDataNotice: false, + linkedOpen: true, + }); + const sourceDocumentId = store.getState().activeDocumentId; + const sourceMetadata = store.getState().xerImportMetadata; + const sourceResources = sourceMetadata?.resources; + const sourceCatalog = sourceResources?.catalog; + const sourceProjectAssignment = sourceResources?.assignments.find(source => source.sourceId === 'A-PROJECT'); + const sourceRawRow = sourceCatalog?.rows.assignments.find(row => row.cells.taskrsrc_id === 'A-PROJECT'); + const sourceLastRawRow = sourceCatalog?.rows.assignments[RAW_ASSIGNMENT_COUNT - 1]; + + eq('X6-storefixture valideert eerst catalogusomvang en projectview', { + rawRows: sourceCatalog?.rows.assignments.length, + projectSources: sourceResources?.assignments.map(source => source.sourceId), + domainAssignments: store.getState().assignments.map(assignment => assignment.id), + }, { + rawRows: RAW_ASSIGNMENT_COUNT, + projectSources: ['A-PROJECT'], + domainAssignments: ['xer-assignment:A-PROJECT'], + }); + + if (present('X6-storefixture heeft broncatalogus', sourceCatalog) + && present('X6-storefixture heeft projectassignmentbron', sourceProjectAssignment) + && present('X6-storefixture heeft eerste raw projectrij', sourceRawRow) + && present('X6-storefixture heeft laatste retained raw rij', sourceLastRawRow)) { + const duplicateId = store.getState().duplicateDocument('Grote duplicatieproef — kopie'); + const duplicateMetadata = store.getState().xerImportMetadata; + const duplicateResources = duplicateMetadata?.resources; + const duplicateProjectAssignment = duplicateResources?.assignments.find(source => source.sourceId === 'A-PROJECT'); + const parkedSource = store.getState().documents.find(entry => entry.id === sourceDocumentId)?.payload; + + eq('duplicateDocument behoudt één file-wide catalogus en alle raw-rowidentiteiten', { + duplicateActive: store.getState().activeDocumentId === duplicateId, + sourceCatalog: parkedSource?.xerImportMetadata?.resources?.catalog === sourceCatalog, + duplicateCatalog: duplicateResources?.catalog === sourceCatalog, + firstRaw: duplicateResources?.catalog.rows.assignments[0] === sourceCatalog.rows.assignments[0], + projectRaw: duplicateProjectAssignment?.rawRow === sourceRawRow, + lastRaw: duplicateResources?.catalog.rows.assignments[RAW_ASSIGNMENT_COUNT - 1] === sourceLastRawRow, + }, { + duplicateActive: true, + sourceCatalog: true, + duplicateCatalog: true, + firstRaw: true, + projectRaw: true, + lastRaw: true, + }); + + eq('duplicateDocument bouwt een documentspecifieke wrapper rond dezelfde canonieke provenance', { + metadataCloned: duplicateMetadata !== sourceMetadata, + resourceMetadataCloned: duplicateResources !== sourceResources, + assignmentArrayCanonical: duplicateResources?.assignments === sourceResources?.assignments, + assignmentCanonical: duplicateProjectAssignment === sourceProjectAssignment, + entityCanonical: duplicateProjectAssignment?.entity === sourceProjectAssignment.entity, + quantitiesCanonical: duplicateProjectAssignment?.quantities === sourceProjectAssignment.quantities, + costsCanonical: duplicateProjectAssignment?.costs === sourceProjectAssignment.costs, + rawCurvesCanonical: duplicateProjectAssignment?.rawCurves === sourceProjectAssignment.rawCurves, + x5CacheCanonical: duplicateMetadata?.scheduleOptions.sourceArchive + === sourceMetadata?.scheduleOptions.sourceArchive, + }, { + metadataCloned: true, + resourceMetadataCloned: true, + assignmentArrayCanonical: true, + assignmentCanonical: true, + entityCanonical: true, + quantitiesCanonical: true, + costsCanonical: true, + rawCurvesCanonical: true, + x5CacheCanonical: true, + }); + } +} + +if (diffs.length > 0) { + console.error(`XX X6 duplicateDocument (${checks} checks)\n${diffs.join('\n')}`); + process.exitCode = 1; +} else { + console.log(`OK X6 duplicateDocument (${checks} checks)`); +} diff --git a/tests/planning/check-xer-resources.ts b/tests/planning/check-xer-resources.ts new file mode 100644 index 000000000..9882515a5 --- /dev/null +++ b/tests/planning/check-xer-resources.ts @@ -0,0 +1,440 @@ +/** + * X6-contract: de lezer projecteert P6-resources/toewijzingen zonder de bestandsbrede bronrijen + * te kopiëren. Breuken die dit vangt: TASKRSRC negeren, resource/rol-id's mengen, material-rate + * als percentage interpreteren, curvepunten weggooien, of mutable parserrijen delen. + */ +import { readXER } from '@/services/xer/xerReader'; +import { parseXerTables } from '@/services/xer/xerTables'; +import { isMultiDocumentImport } from '@/services/importTypes'; +import { readXerCalendars } from '@/services/xer/xerCalendarData'; +import { indexXerTaskResourceRows } from '@/services/xer/xerResourceAssignments'; +import { buildXerResourceCatalog, materializeXerResources } from '@/services/xer/xerResources'; +import { CONTOUR_SHAPE_VALUES } from '@/engine/contour/contourEngine'; + +const diffs: string[] = []; +let checks = 0; + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +function present(label: string, value: T | null | undefined): value is T { + checks++; + if (value === null || value === undefined) { + diffs.push(`${label}: verplichte testdata ontbreekt`); + return false; + } + return true; +} + +function rejectsMutation(label: string, mutate: () => void): void { + checks++; + try { + mutate(); + diffs.push(`${label}: mutatie werd niet door runtime-freeze geweigerd`); + } catch (error) { + if (!(error instanceof TypeError)) diffs.push(`${label}: verwacht TypeError, kreeg ${String(error)}`); + } +} + +/** Een clone-regressie mag deze contracttest niet vóór zijn diagnostiek doen crashen. */ +function mutatesProjectView(label: string, mutate: () => void): void { + checks++; + try { + mutate(); + } catch (error) { + diffs.push(`${label}: gematerialiseerde projectie is niet mutable (${error instanceof TypeError ? error.message : String(error)})`); + } +} + +function bytes(lines: readonly string[]): Uint8Array { + return new TextEncoder().encode(lines.join('\n')); +} + +const curveFields = Array.from({ length: 21 }, (_, index) => `pct_usage_${index}`); +// Exacte contour-engine-tabel (CONTOUR_SHAPE_VALUES.BELL): bewust NIET afgerond of herschaald, want +// `matchCurveValues` matcht op exacte tabelwaarden (tolerantie 1e-6). `curv_name` ('Klok') is met +// opzet GEEN P6-curvenaam — dit bewijst dat de exacte-tabelmatch de naam-terugval niet nodig heeft. +const bell = [...CONTOUR_SHAPE_VALUES.BELL]; +// Lineaire aanloop-ramp die GEEN van de acht contour-engine-tabellen exact raakt (front-loaded +// vorm, maar 6/4 in plaats van FRONT_LOADED's eigen 6.5/3.5) — bewijst de P6_NAME_TO_CURVE- +// naamterugval: alleen leesbaar via `curv_name` ('Front Loaded'), nooit via een tabelmatch. +const frontLoadedRamp = [0, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4]; +const fixture = bytes([ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tdef_duration_type\tlast_recalc_date', + '%R\tP1\tX6 Project\tCP\tDT_FixedDUR2\t2026-01-01', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tclndr_data', + '%R\tCP\tProject\tCA_Project\t8\t', + '%R\tCR\tResource\tCA_Rsrc\t10\t', + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_name\ttask_code\ttarget_start_date\ttarget_end_date\ttarget_drtn_hr_cnt\ttask_type\tstatus_code', + '%R\tT1\tP1\tMetselen\tA1\t2026-01-01\t2026-01-02\t8\tTT_Task\tTK_NotStart', + '%R\tT2\tP1\tOntwerp\tA2\t2026-01-01\t2026-01-02\t8\tTT_Task\tTK_NotStart', + '%R\tT3\tP1\tControle\tA3\t2026-01-01\t2026-01-02\t8\tTT_Task\tTK_NotStart', + '%R\tT4\tP1\tSchilderen\tA4\t2026-01-01\t2026-01-02\t8\tTT_Task\tTK_NotStart', + '%T\tUMEASURE', + '%F\tunit_id\tunit_abbrev', + '%R\tU-KG\tkg', + '%T\tROLES', + '%F\trole_id\trole_name', + '%R\t42\tOntwerper', + '%T\tRSRC', + '%F\trsrc_id\trsrc_name\trsrc_type\tclndr_id\tdef_qty_per_hr\tunit_id\trole_id', + '%R\t42\tVakman\tRT_Labor\tCR\t2\t\t42', + '%R\tMAT\tMortel\tRT_Mat\tCR\t40\tU-KG\t', + '%T\tRSRCRATE', + '%F\trsrc_rate_id\trsrc_id\tmax_qty_per_hr\tcost_per_qty\tstart_date', + '%R\tR-1\t42\t1\t25\t2026-01-01 00:00', + '%R\tR-2\tMAT\t50\t4.5\t2026-01-01 00:00', + '%R\tR-ORPHAN\tNOPE\t1\t99\t2026-01-01 00:00', + '%T\tROLERATE', + '%F\trole_rate_id\trole_id\tmax_qty_per_hr\tcost_per_qty\tstart_date', + '%R\tROLE-RATE\t42\t0.75\t80\t2026-02-01 00:00', + '%T\tRSRCCURVDATA', + `%F\tcurv_id\tcurv_name\t${curveFields.join('\t')}`, + `%R\tC-BELL\tKlok\t${bell.join('\t')}`, + `%R\tC-RAMP\tFront Loaded\t${frontLoadedRamp.join('\t')}`, + '%T\tTASKRSRC', + '%F\ttaskrsrc_id\tproj_id\ttask_id\trsrc_id\trole_id\ttarget_qty_per_hr\tremain_qty_per_hr\tremain_qty\ttarget_qty\tcurv_id\ttarget_crv\tremain_crv\tactual_crv\tcost_per_qty\ttarget_cost\tremain_cost', + '%R\tA-LABOR\tP1\tT1\t42\t42\t0.5\t0.5\t4\t5\tC-BELL\tTC\tRC\tAC\t25\t125\t100', + '%R\tA-MATERIAL\tP1\tT2\tMAT\t\t3\t3\t18\t24\t\t\t\t\t4\t96\t72', + '%R\tA-ROLE\tP1\tT3\t\t42\t0.25\t0.25\t2\t2\t\t\t\t\t80\t160\t120', + '%R\tA-LABOR2\tP1\tT4\t42\t\t0.5\t0.5\t4\t5\tC-RAMP\t\t\t\t25\t125\t100', + '%E', +]); + +const parsed = parseXerTables(fixture); +const taskRow = parsed.tables.get('TASK')?.rows[0]; +eq('X6-a parser bevries gedeelde TASKRSRC- en taakbronrijen', { + row: taskRow ? Object.isFrozen(taskRow) : false, + cells: taskRow ? Object.isFrozen(taskRow.cells) : false, +}, { row: true, cells: true }); + +const directCalendars = readXerCalendars(parsed); +const directCatalog = buildXerResourceCatalog(parsed, new Set(directCalendars.calendars.map(calendar => calendar.id))); +const directProjectCalendar = directCalendars.byId.get('CP'); +if (present('X6-a2 directe materialisatie heeft projectkalender', directProjectCalendar)) { + const directMaterialized = materializeXerResources(directCatalog, parsed, { + projectId: 'P1', projectCalendarId: directProjectCalendar.id, projectHoursPerDay: directProjectCalendar.hoursPerDay, + availableCalendarIds: new Set(directCalendars.calendars.map(calendar => calendar.id)), + calendarHoursPerDay: new Map(directCalendars.calendars.map(calendar => [calendar.id, calendar.hoursPerDay])), + taskIds: new Set(['T1', 'T2', 'T3']), + }, indexXerTaskResourceRows(parsed).get('P1') ?? []); + const peerMaterialized = materializeXerResources(directCatalog, parsed, { + projectId: 'P1', projectCalendarId: directProjectCalendar.id, projectHoursPerDay: directProjectCalendar.hoursPerDay, + availableCalendarIds: new Set(directCalendars.calendars.map(calendar => calendar.id)), + calendarHoursPerDay: new Map(directCalendars.calendars.map(calendar => [calendar.id, calendar.hoursPerDay])), + taskIds: new Set(['T1', 'T2', 'T3']), + }, indexXerTaskResourceRows(parsed).get('P1') ?? []); + const catalogResource = directCatalog.resources.find(resource => resource.id === 'xer-resource:42'); + const materializedResource = directMaterialized.resources.find(resource => resource.id === 'xer-resource:42'); + const peerResource = peerMaterialized.resources.find(resource => resource.id === 'xer-resource:42'); + const catalogResourceSource = directCatalog.rows.resources.find(source => source.sourceId === '42'); + const materializedResourceSource = directMaterialized.sources.resources.find(source => source.sourceId === '42'); + const peerResourceSource = peerMaterialized.sources.resources.find(source => source.sourceId === '42'); + eq('X6-a2 resourceprojectie heeft eigen objectidentiteit ten opzichte van catalogus en peerprojectie', { + catalog: materializedResource !== catalogResource, + peer: materializedResource !== peerResource, + sourceCatalog: materializedResourceSource !== catalogResourceSource, + sourcePeer: materializedResourceSource !== peerResourceSource, + }, { catalog: true, peer: true, sourceCatalog: true, sourcePeer: true }); + if (present('X6-a2 directe materialisatie heeft resourcekopie', materializedResource)) { + mutatesProjectView('X6-a2 resourceprojectie', () => { materializedResource.name = 'Alleen directe projectview'; }); + } + if (present('X6-a2 directe materialisatie heeft resourcebronkopie', materializedResourceSource)) { + mutatesProjectView('X6-a2 resourcebronprojectie', () => { materializedResourceSource.rawType = 'Alleen projectview'; }); + } + eq('X6-a2 resourceprojectie is geïsoleerd van catalogus en peerprojectie', { + catalog: catalogResource?.name, + peer: peerResource?.name, + catalogSource: catalogResourceSource?.rawType, + peerSource: peerResourceSource?.rawType, + }, { catalog: 'Vakman', peer: 'Vakman', catalogSource: 'RT_Labor', peerSource: 'RT_Labor' }); + + const materializedRole = directMaterialized.resources.find(resource => resource.id === 'xer-role:42'); + const peerRole = peerMaterialized.resources.find(resource => resource.id === 'xer-role:42'); + const catalogRoleSource = directCatalog.rows.roles.find(source => source.sourceId === '42'); + const materializedRoleSource = directMaterialized.sources.roles.find(source => source.sourceId === '42'); + const peerRoleSource = peerMaterialized.sources.roles.find(source => source.sourceId === '42'); + eq('X6-a3 roleprojectie heeft eigen objectidentiteit ten opzichte van catalogus en peerprojectie', { + rolePeer: materializedRole !== peerRole, + sourceCatalog: materializedRoleSource !== catalogRoleSource, + sourcePeer: materializedRoleSource !== peerRoleSource, + }, { rolePeer: true, sourceCatalog: true, sourcePeer: true }); + if (present('X6-a3 directe materialisatie heeft rolekopie', materializedRole)) { + mutatesProjectView('X6-a3 roleprojectie', () => { materializedRole.name = 'Alleen directe roleview'; }); + } + if (present('X6-a3 directe materialisatie heeft rolebronkopie', materializedRoleSource)) { + mutatesProjectView('X6-a3 rolebronprojectie', () => { materializedRoleSource.name = 'Alleen roleprojectie'; }); + } + eq('X6-a3 roleprojectie is geïsoleerd van catalogus en peerprojectie', { + peerRole: peerRole?.name, + catalogSource: catalogRoleSource?.name, + peerSource: peerRoleSource?.name, + }, { peerRole: 'Ontwerper', catalogSource: 'Ontwerper', peerSource: 'Ontwerper' }); + + const materializedAssignment = directMaterialized.assignments.find(assignment => assignment.id === 'xer-assignment:A-LABOR'); + const peerAssignment = peerMaterialized.assignments.find(assignment => assignment.id === 'xer-assignment:A-LABOR'); + const materializedAssignmentSource = directMaterialized.sources.assignments.find(source => source.sourceId === 'A-LABOR'); + const peerAssignmentSource = peerMaterialized.sources.assignments.find(source => source.sourceId === 'A-LABOR'); + const catalogAssignmentRow = directCatalog.rows.assignments.find(row => row.cells.taskrsrc_id === 'A-LABOR'); + eq('X6-a4 assignmentprojectie heeft eigen identiteit, maar behoudt raw-row-identiteit', { + assignmentPeer: materializedAssignment !== peerAssignment, + sourcePeer: materializedAssignmentSource !== peerAssignmentSource, + rawRow: materializedAssignmentSource?.rawRow === catalogAssignmentRow, + }, { assignmentPeer: true, sourcePeer: true, rawRow: true }); + if (present('X6-a4 directe materialisatie heeft assignmentkopie', materializedAssignment)) { + mutatesProjectView('X6-a4 assignmentprojectie', () => { materializedAssignment.unitsPerDay = 99; }); + } + if (present('X6-a4 directe materialisatie heeft assignmentbronkopie', materializedAssignmentSource)) { + mutatesProjectView('X6-a4 assignmentbronprojectie', () => { materializedAssignmentSource.quantities.target = 99; }); + } + eq('X6-a4 assignmentprojectie is geïsoleerd van peerprojectie', { + peerUnits: peerAssignment?.unitsPerDay, + peerTarget: peerAssignmentSource?.quantities.target, + }, { peerUnits: 0.5, peerTarget: 5 }); +} + +const opened = readXER(fixture); +if (isMultiDocumentImport(opened)) { + diffs.push('X6-b enkelproject-fixture mag niet als multi-documentresultaat terugkomen'); +} else { + eq('X6-b RSRC projecteert kalender, rates en rollen in gescheiden naamruimten', + opened.resources.map(resource => ({ + id: resource.id, + type: resource.type, + calendarId: resource.calendarId, + maxUnits: resource.maxUnits, + unitOfMeasure: resource.unitOfMeasure, + costPerHour: resource.costPerHour, + availabilitySteps: resource.availabilitySteps, + })), [ + { + id: 'xer-resource:42', type: 'LABOR', calendarId: 'CR', maxUnits: 2, + costPerHour: 25, availabilitySteps: [{ from: '2026-01-01', maxUnits: 1 }], + }, + { + id: 'xer-resource:MAT', type: 'MATERIAL', calendarId: 'CR', maxUnits: 40, + unitOfMeasure: 'kg', costPerHour: 4.5, availabilitySteps: [{ from: '2026-01-01', maxUnits: 50 }], + }, + { + id: 'xer-role:42', type: 'LABOR', maxUnits: 0, + costPerHour: 80, availabilitySteps: [{ from: '2026-02-01', maxUnits: 0.75 }], + }, + ]); + eq('X6-c TASKRSRC vult toewijzingen, resourceIds en materiaaluren op resourcekalender', { + assignments: opened.assignments.map(assignment => ({ + taskId: assignment.taskId, + resourceId: assignment.resourceId, + unitsPerDay: assignment.unitsPerDay, + curve: assignment.curve, + })), + taskResourceIds: opened.tasks.filter(task => !task.isSummary).map(task => [task.id, task.resourceIds]), + }, { + assignments: [ + // C-BELL matcht de contour-engine-tabel EXACT ⇒ `matchCurveValues` beslist, `curv_name` + // ('Klok') is geen P6-naam en wordt niet geraadpleegd. + { taskId: 'T1', resourceId: 'xer-resource:42', unitsPerDay: 0.5, curve: 'BELL' }, + { taskId: 'T2', resourceId: 'xer-resource:MAT', unitsPerDay: 30 }, + { taskId: 'T3', resourceId: 'xer-role:42', unitsPerDay: 0.25 }, + // C-RAMP matcht GEEN tabel exact ⇒ `matchCurveValues` geeft undefined, de OPS-curve komt + // uitsluitend uit de `P6_NAME_TO_CURVE['Front Loaded']`-terugval. + { taskId: 'T4', resourceId: 'xer-resource:42', unitsPerDay: 0.5, curve: 'FRONT_LOADED' }, + ], + taskResourceIds: [ + ['T1', ['xer-resource:42']], + ['T2', ['xer-resource:MAT']], + ['T3', ['xer-role:42']], + ['T4', ['xer-resource:42']], + ], + }); + const t1Assignment = opened.assignments.find(assignment => assignment.taskId === 'T1'); + const t4Assignment = opened.assignments.find(assignment => assignment.taskId === 'T4'); + eq('X6-c2 curveValues draagt de genormaliseerde 21-punts RSRCCURVDATA-data (index0 op 0)', { + t1: t1Assignment?.curveValues, + t4: t4Assignment?.curveValues, + }, { + t1: [0, ...CONTOUR_SHAPE_VALUES.BELL.slice(1)], + t4: frontLoadedRamp, + }); + const xer = opened.xer.resources; + const catalog = xer?.catalog; + const curve = catalog?.rows.curves.find(item => item.sourceId === 'C-BELL'); + const resourceRate = catalog?.rows.rates.find(item => item.sourceId === 'R-1'); + const projectAssignment = xer?.assignments.find(item => item.sourceId === 'A-LABOR'); + const rawAssignment = catalog?.rows.assignments.find(row => row.cells.taskrsrc_id === 'A-LABOR'); + eq('X6-d retained bronmetadata houdt 21 curvepunten en raw assignment-identiteit vast', { + rawAssignments: catalog?.rows.assignments.length, + projectAssignments: xer?.assignments.length, + points: curve?.rawPoints.length, + numericPoints: curve?.numericPoints?.length, + sameRow: projectAssignment?.rawRow === rawAssignment, + }, { rawAssignments: 4, projectAssignments: 4, points: 21, numericPoints: 21, sameRow: true }); + + const firstResource = catalog?.resources.find(resource => resource.id === 'xer-resource:42'); + const firstSource = catalog?.rows.resources.find(source => source.sourceId === '42'); + const orphanIssue = catalog?.issues.find(issue => issue.sourceId === 'R-ORPHAN'); + eq('X6-d2 volledige catalogusobjectgraaf is expliciet runtime-deep-frozen', { + catalog: catalog ? Object.isFrozen(catalog) : false, + resources: catalog ? Object.isFrozen(catalog.resources) : false, + resource: firstResource ? Object.isFrozen(firstResource) : false, + availabilitySteps: firstResource?.availabilitySteps ? Object.isFrozen(firstResource.availabilitySteps) : false, + availabilityStep: firstResource?.availabilitySteps?.[0] ? Object.isFrozen(firstResource.availabilitySteps[0]) : false, + identities: catalog ? Object.isFrozen(catalog.identities) : false, + identity: catalog?.identities[0] ? Object.isFrozen(catalog.identities[0]) : false, + rows: catalog ? Object.isFrozen(catalog.rows) : false, + source: firstSource ? Object.isFrozen(firstSource) : false, + sourceRawRow: firstSource ? Object.isFrozen(firstSource.rawRow) : false, + sourceCells: firstSource ? Object.isFrozen(firstSource.rawRow.cells) : false, + rate: resourceRate ? Object.isFrozen(resourceRate) : false, + rateEntity: resourceRate ? Object.isFrozen(resourceRate.entity) : false, + rateCostsTuple: resourceRate ? Object.isFrozen(resourceRate.costs) : false, + curve: curve ? Object.isFrozen(curve) : false, + rawCurveTuple: curve ? Object.isFrozen(curve.rawPoints) : false, + numericCurveTuple: curve?.numericPoints ? Object.isFrozen(curve.numericPoints) : false, + assignments: catalog ? Object.isFrozen(catalog.rows.assignments) : false, + assignmentRow: rawAssignment ? Object.isFrozen(rawAssignment) : false, + assignmentCells: rawAssignment ? Object.isFrozen(rawAssignment.cells) : false, + issues: catalog ? Object.isFrozen(catalog.issues) : false, + issue: orphanIssue ? Object.isFrozen(orphanIssue) : false, + }, { + catalog: true, resources: true, resource: true, availabilitySteps: true, + availabilityStep: true, identities: true, identity: true, rows: true, source: true, + sourceRawRow: true, sourceCells: true, rate: true, rateEntity: true, + rateCostsTuple: true, curve: true, rawCurveTuple: true, numericCurveTuple: true, + assignments: true, assignmentRow: true, assignmentCells: true, issues: true, issue: true, + }); + + if (present('X6-d3 mutatieproef heeft catalogusresource', firstResource)) { + rejectsMutation('X6-d3 catalogusresource weigert mutatie vóór X4b-fan-out', () => { + (firstResource as unknown as { name: string }).name = 'verboden'; + }); + } + if (present('X6-d4 mutatieproef heeft ratetuple', resourceRate?.costs)) { + rejectsMutation('X6-d4 catalogus-ratetuple weigert mutatie vóór X4b-fan-out', () => { + (resourceRate.costs as unknown as [number | null, number | null, number | null, number | null, number | null])[0] = 9_999; + }); + } + if (present('X6-d5 mutatieproef heeft curvepunten', curve?.rawPoints)) { + rejectsMutation('X6-d5 catalogus-curvetuple weigert mutatie vóór X4b-fan-out', () => { + (curve.rawPoints as unknown as [string, string, string, string, string, string, string, string, string, string, string, string, string, string, string, string, string, string, string, string, string])[0] = '9999'; + }); + } +} + +const multiLines = [ + 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tdef_duration_type\tlast_recalc_date', + '%R\tP1\tEen\tCP\tDT_FixedDUR2\t2026-01-01', + '%R\tP2\tTwee\tCP\tDT_FixedDUR2\t2026-01-01', + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_name\ttask_code\ttarget_start_date\ttarget_end_date\ttarget_drtn_hr_cnt\ttask_type\tstatus_code', + '%R\tT1\tP1\tEen\tE1\t2026-01-01\t2026-01-02\t8\tTT_Task\tTK_NotStart', + '%R\tT2\tP2\tTwee\tE2\t2026-01-01\t2026-01-02\t8\tTT_Task\tTK_NotStart', + '%T\tRSRC', + '%F\trsrc_id\trsrc_name\trsrc_type\tdef_qty_per_hr', + '%R\tR1\tGedeeld\tRT_Labor\t1', + '%T\tTASKRSRC', + '%F\ttaskrsrc_id\tproj_id\ttask_id\trsrc_id\ttarget_qty_per_hr', + '%R\tA1\tP1\tT1\tR1\t1', + '%R\tA-MISSING\tP1\tT-MISSING\tR1\t7', + '%R\tA2\tP2\tT2\tR1\t2', + '%R\tA-UNSCOPED\t\tT1\tR1\t8', + '%R\tA-BASELINE\tB9\tTB\tR1\t9', + '%E', +] as const; +const multi = readXER(bytes(multiLines)); +if (!isMultiDocumentImport(multi)) { + diffs.push('X6-e tweeporject-fixture moet de X4b-meerdocumentvorm volgen'); +} else { + const [first, second] = multi.results; + const firstXer = first?.xer?.resources; + const secondXer = second?.xer?.resources; + const firstAssignment = first?.assignments.find(assignment => assignment.id === 'xer-assignment:A1'); + const secondAssignment = second?.assignments.find(assignment => assignment.id === 'xer-assignment:A2'); + const secondSource = secondXer?.assignments.find(source => source.sourceId === 'A2'); + const secondRawRow = secondXer?.catalog.rows.assignments.find(row => row.cells.taskrsrc_id === 'A2'); + eq('X6-e partitioneert TASKRSRC lineair per project, behoudt unscoped/baselinebronnen en deelt catalogusidentiteit', { + projects: multi.results.map(result => [result.project.id, result.assignments.map(assignment => assignment.id)]), + sourceViews: multi.results.map(result => [result.project.id, result.xer?.resources?.assignments.map(source => source.sourceId)]), + missingTaskIssues: firstXer?.issues.filter(issue => issue.code === 'XER_ASSIGNMENT_TASK_MISSING').map(issue => issue.sourceId), + rawRows: firstXer?.catalog.rows.assignments.length, + sharedCatalog: firstXer?.catalog === secondXer?.catalog, + projectRowIdentity: secondSource?.rawRow === secondRawRow, + }, { + projects: [['P1', ['xer-assignment:A1']], ['P2', ['xer-assignment:A2']]], + sourceViews: [['P1', ['A1', 'A-MISSING']], ['P2', ['A2']]], + missingTaskIssues: ['A-MISSING'], + rawRows: 5, + sharedCatalog: true, + projectRowIdentity: true, + }); + const firstResource = first?.resources.find(resource => resource.id === 'xer-resource:R1'); + const secondResource = second?.resources.find(resource => resource.id === 'xer-resource:R1'); + if (present('X6-e2 P1-resource bestaat', firstResource)) firstResource.name = 'Alleen project P1'; + if (present('X6-e2 P1-assignment bestaat', firstAssignment)) firstAssignment.unitsPerDay = 99; + eq('X6-e2 projectprojecties zijn mutable en onderling geïsoleerd, terwijl de catalogus immutable gedeeld blijft', { + firstResource: firstResource?.name, + secondResource: secondResource?.name, + firstUnits: firstAssignment?.unitsPerDay, + secondUnits: secondAssignment?.unitsPerDay, + catalogFrozen: firstXer ? Object.isFrozen(firstXer.catalog) : false, + catalogRowsFrozen: firstXer ? Object.isFrozen(firstXer.catalog.rows) : false, + }, { + firstResource: 'Alleen project P1', + secondResource: 'Gedeeld', + firstUnits: 99, + secondUnits: 2, + catalogFrozen: true, + catalogRowsFrozen: true, + }); + const sharedCatalog = firstXer?.catalog; + const sharedCatalogResource = sharedCatalog?.resources.find(resource => resource.id === 'xer-resource:R1'); + if (present('X6-e3 gedeelde catalogusresource bestaat na X4b-fan-out', sharedCatalogResource)) { + const originalName = secondXer?.catalog.resources.find(resource => resource.id === 'xer-resource:R1')?.name; + rejectsMutation('X6-e3 catalogusmutatie wordt na X4b-fan-out geweigerd', () => { + (sharedCatalogResource as unknown as { name: string }).name = 'lek naar P2'; + }); + eq('X6-e3 geweigerde catalogusmutatie kan P2 niet veranderen', + secondXer?.catalog.resources.find(resource => resource.id === 'xer-resource:R1')?.name, + originalName); + } +} + +const baseline = readXER(bytes(multiLines.map(line => { + if (line === '%F\tproj_id\tproj_short_name\tclndr_id\tdef_duration_type\tlast_recalc_date') { + return `${line}\tsum_base_proj_id`; + } + if (line === '%R\tP1\tEen\tCP\tDT_FixedDUR2\t2026-01-01') return `${line}\tP2`; + if (line === '%R\tP2\tTwee\tCP\tDT_FixedDUR2\t2026-01-01') return `${line}\t`; + return line; +}))); +if (!isMultiDocumentImport(baseline)) { + diffs.push('X6-f baseline-fixture moet via de X4b-meerdocumentvorm openen'); +} else { + const result = baseline.results[0]; + const xer = result?.xer?.resources; + eq('X6-f baselineproject wordt niet geopend maar zijn TASKRSRC-bronrijen blijven ongeschonden voor X9 retained', { + documents: baseline.results.map(item => item.project.id), + baselines: result?.baselines?.map(item => item.id), + rawAssignments: xer?.catalog.rows.assignments.length, + retainedKinds: xer?.catalog.rows.assignments.map(row => row.cells.taskrsrc_id), + }, { + documents: ['P1'], baselines: ['xer-baseline:P1:P2'], rawAssignments: 5, + retainedKinds: ['A1', 'A-MISSING', 'A2', 'A-UNSCOPED', 'A-BASELINE'], + }); +} + +if (diffs.length > 0) { + console.error(`XX X6 XER-resources (${checks} checks)\n${diffs.join('\n')}`); + process.exitCode = 1; +} else { + console.log(`OK X6 XER-resources (${checks} checks)`); +} diff --git a/tests/planning/check-xer-schedule-option-provenance.ts b/tests/planning/check-xer-schedule-option-provenance.ts new file mode 100644 index 000000000..5d8b94f02 --- /dev/null +++ b/tests/planning/check-xer-schedule-option-provenance.ts @@ -0,0 +1,223 @@ +import { solveProject } from '@/engine/scheduler/solveProject'; +import { isMultiDocumentImport } from '@/services/importTypes'; +import { readXER, type XerReadResult } from '@/services/xer/xerReader'; + +const failures: string[] = []; +let checks = 0; + +function equal(label: string, actual: unknown, expected: unknown): void { + checks++; + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + failures.push(`${label}: verwacht ${JSON.stringify(expected)}, kreeg ${JSON.stringify(actual)}`); + } +} + +function xerLines( + scheduleRow: string | undefined, + remTargetLinkFlag: string, + storedLateStart: string, + storedFloat: string, +): string[] { + return [ + 'ERMHDR\t23.12\t2026-09-01\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC8\tAcht uur\t8\t40\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tcritical_path_type\tcritical_drtn_hr_cnt\trem_target_link_flag', + `%R\tP-PROVENANCE\tProvenance\tC8\t2026-09-01 08:00\tCT_TotFloat\t8\t${remTargetLinkFlag}`, + ...(scheduleRow ? [ + '%T\tSCHEDOPTIONS', + '%F\tproj_id\tsched_float_type\tsched_use_project_end_date_for_float\tsched_calendar_on_relationship_lag\tsched_retained_logic\tsched_progress_override', + `%R\tP-PROVENANCE\t${scheduleRow}\tY\tRCAL_Predecessor\tY\tN`, + ] : []), + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_code\ttask_name\tclndr_id\ttarget_start_date\ttarget_end_date\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\tearly_start_date\tearly_end_date\tlate_start_date\tlate_end_date\ttotal_float_hr_cnt\tfree_float_hr_cnt', + `%R\tT-PROVENANCE\tP-PROVENANCE\tP1\tTaak\tC8\t2026-09-01 08:00\t2026-09-02 16:00\t16\t16\t2026-09-01 08:00\t2026-09-02 16:00\t${storedLateStart}\t${storedLateStart}\t${storedFloat}\t${storedFloat}`, + '%E', + ]; +} + +function bytes(lines: readonly string[]): Uint8Array { + return new TextEncoder().encode(lines.join('\n')); +} + +function opened(source: Uint8Array): XerReadResult { + const result = readXER(source); + if (isMultiDocumentImport(result)) throw new Error('Fixture moet één project opleveren'); + return result; +} + +function scheduleMetadata(result: XerReadResult): { + source: string; + retainedSource: unknown; + fallbacks: unknown; + diagnostics: unknown; + projectSignal: string | undefined; + scheduleRows: string[]; +} { + const metadata = result.xer.scheduleOptions; + return { + source: metadata.source, + retainedSource: metadata.retainedSource, + fallbacks: metadata.fallbacks, + diagnostics: metadata.diagnostics, + projectSignal: metadata.sourceRows.find(row => row.table === 'PROJECT')?.cells.rem_target_link_flag, + scheduleRows: metadata.sourceRows + .filter(row => row.table === 'SCHEDOPTIONS') + .map(row => row.cells.sched_float_type ?? ''), + }; +} + +function schedulingOptions(result: XerReadResult): unknown { + return { + progressMode: result.project.progressMode, + schedulingOptions: result.project.schedulingOptions, + }; +} + +function solverAxes(result: XerReadResult): unknown { + const tasks = result.tasks.map(task => ({ + ...task, + time: { ...task.time }, + })); + solveProject({ + tasks, + sequences: result.sequences, + calendar: result.calendar, + calendars: result.resourceCalendars ?? [], + dataDate: result.project.statusDate, + progressMode: result.project.progressMode, + schedulingOptions: result.project.schedulingOptions, + projectStartDate: result.project.startDate, + projectEndDate: result.project.endDate, + }); + return tasks.map(task => ({ + id: task.id, + earlyStart: task.time.earlyStart, + earlyFinish: task.time.earlyFinish, + lateStart: task.time.lateStart, + lateFinish: task.time.lateFinish, + totalFloat: task.time.totalFloat, + freeFloat: task.time.freeFloat, + })); +} + +const noRowY = opened(bytes(xerLines(undefined, 'Y', '2099-01-01 00:00', '999'))); +const noRowN = opened(bytes(xerLines(undefined, 'N', '2099-01-01 00:00', '999'))); +const finishRow = opened(bytes(xerLines('FT_FF', 'Y', '2099-01-01 00:00', '999'))); +const startRow = opened(bytes(xerLines('FT_SS', 'Y', '2099-01-01 00:00', '999'))); + +equal('geen SCHEDOPTIONS-rij is herkenbaar als XER-defaults met PROJECT-signaal Y', + scheduleMetadata(noRowY), { + source: 'xer-defaults', + retainedSource: {}, + fallbacks: [], + diagnostics: [], + projectSignal: 'Y', + scheduleRows: [], + }); +equal('geen SCHEDOPTIONS-rij is herkenbaar als XER-defaults met PROJECT-signaal N', + scheduleMetadata(noRowN), { + source: 'xer-defaults', + retainedSource: {}, + fallbacks: [], + diagnostics: [], + projectSignal: 'N', + scheduleRows: [], + }); +equal('PROJECT-afleiding Y/N raakt uitsluitend de toegestane XER-progressievlag', [ + noRowY.project.schedulingOptions?.p6UseRemainingStartForProgress, + noRowN.project.schedulingOptions?.p6UseRemainingStartForProgress, +], [true, false]); + +const invalidProjectSignal = opened(bytes(xerLines(undefined, 'MAYBE', '2099-01-01 00:00', '999'))); +equal('onbekend PROJECT-signaal valt fail-closed terug met een fallbackdiagnose', { + source: invalidProjectSignal.xer.scheduleOptions.source, + useRemainingStartForProgress: + invalidProjectSignal.project.schedulingOptions?.p6UseRemainingStartForProgress, + fallbacks: invalidProjectSignal.xer.scheduleOptions.fallbacks, +}, { + source: 'xer-defaults', + useRemainingStartForProgress: false, + fallbacks: [{ field: 'rem_target_link_flag', token: 'MAYBE', fallback: 'false', line: 7 }], +}); + +equal('unieke finish-float-rij is expliciet schedoptions-provenance', scheduleMetadata(finishRow), { + source: 'schedoptions', + retainedSource: { sched_use_project_end_date_for_float: true }, + fallbacks: [], + diagnostics: [], + projectSignal: 'Y', + scheduleRows: ['FT_FF'], +}); +equal('finish-float-token kiest de finale SchedulingOptions zonder stored oracle', { + progressMode: finishRow.project.progressMode, + lagCalendar: finishRow.project.schedulingOptions?.lagCalendar, + totalFloatMode: finishRow.project.schedulingOptions?.totalFloatMode, + useProjectEndDateForFloat: finishRow.project.schedulingOptions?.useProjectEndDateForFloat, + p6UseRemainingStartForProgress: finishRow.project.schedulingOptions?.p6UseRemainingStartForProgress, +}, { + progressMode: 'RETAINED_LOGIC', + lagCalendar: 'predecessor', + totalFloatMode: 'finish', + useProjectEndDateForFloat: true, + p6UseRemainingStartForProgress: true, +}); +equal('unieke start-float-rij is expliciet schedoptions-provenance', scheduleMetadata(startRow), { + source: 'schedoptions', + retainedSource: { sched_use_project_end_date_for_float: true }, + fallbacks: [], + diagnostics: [], + projectSignal: 'Y', + scheduleRows: ['FT_SS'], +}); +equal('start-float-token kiest uitsluitend totalFloatMode=start', { + totalFloatMode: startRow.project.schedulingOptions?.totalFloatMode, + lagCalendar: startRow.project.schedulingOptions?.lagCalendar, + useProjectEndDateForFloat: startRow.project.schedulingOptions?.useProjectEndDateForFloat, +}, { totalFloatMode: 'start', lagCalendar: 'predecessor', useProjectEndDateForFloat: true }); + +const fallback = opened(bytes(xerLines('NOT_A_FLOAT_MODE', 'Y', '2099-01-01 00:00', '999'))); +equal('onbekend SCHEDOPTIONS-token valt fail-closed terug naar de veilige finish-default', { + source: fallback.xer.scheduleOptions.source, + totalFloatMode: fallback.project.schedulingOptions?.totalFloatMode, + fallbacks: fallback.xer.scheduleOptions.fallbacks, + diagnostics: fallback.xer.scheduleOptions.diagnostics, +}, { + source: 'schedoptions', + totalFloatMode: 'finish', + fallbacks: [{ field: 'sched_float_type', token: 'NOT_A_FLOAT_MODE', fallback: 'finish', line: 10 }], + diagnostics: [], +}); + +// Mutatieproef 1: één tokenflip mag alleen de corresponderende option wijzigen. +equal('mutatieproef tokenflip: FT_FF -> FT_SS verandert de bronkeuze aantoonbaar', + startRow.project.schedulingOptions?.totalFloatMode, + 'start'); +equal('mutatieproef tokenflip laat de PROJECT-afleiding intact', + startRow.project.schedulingOptions?.p6UseRemainingStartForProgress, + finishRow.project.schedulingOptions?.p6UseRemainingStartForProgress); + +// Mutatieproef 2: dezelfde bytes zonder de SCHEDOPTIONS-rij vallen terug naar defaults. +equal('mutatieproef row verwijderen: bron wordt XER-defaults', noRowY.xer.scheduleOptions.source, 'xer-defaults'); +equal('mutatieproef row verwijderen: expliciet finish-float verdwijnt uit de finale options', + noRowY.project.schedulingOptions?.totalFloatMode, + 'finish'); +equal('mutatieproef row verwijderen: PROJECT-signaal blijft behouden', + noRowY.project.schedulingOptions?.p6UseRemainingStartForProgress, + true); + +// Mutatieproef 3: stored P6-late/float-orakel wijzigen mag geen options of solverinput wijzigen. +const oracleMutated = opened(bytes(xerLines('FT_FF', 'Y', '1900-01-01 00:00', '-12345'))); +equal('mutatieproef stored oracle: options blijven byte-identiek', + schedulingOptions(oracleMutated), schedulingOptions(finishRow)); +equal('mutatieproef stored oracle: solverresultaat blijft byte-identiek', + solverAxes(oracleMutated), solverAxes(finishRow)); + +if (failures.length > 0) { + for (const failure of failures) console.error(`XX ${failure}`); + process.exitCode = 1; +} else { + console.log(`OK XER schedule-option provenance: ${checks} checks`); +} diff --git a/tests/planning/check-xer-schedule-options-corpus.ts b/tests/planning/check-xer-schedule-options-corpus.ts new file mode 100644 index 000000000..45defb200 --- /dev/null +++ b/tests/planning/check-xer-schedule-options-corpus.ts @@ -0,0 +1,1084 @@ +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { cloneTasksForSolve, solveProject } from '@/engine/scheduler/solveProject'; +import { readXER } from '@/services/xer/xerReader'; +import { isMultiDocumentImport, type ImportResult } from '@/services/importTypes'; +import { + XER_SCHEDOPTIONS_COLUMN_DISPOSITIONS, +} from '@/services/xer/xerScheduleOptions'; +import { XerImportError } from '@/services/xer/xerTables'; +import type { ProgressMode, SchedulingOptions } from '@/types/project'; +import { + measureXerFidelity, + type XerSolvedProject, + type XerSolvedTask, +} from './xerFidelity'; +import { + scanXerGroundTruth, + XER_FIDELITY_AXES, + type XerGroundTruth, +} from './xerGroundTruth'; +import { emptyCounters, type XerFidelityCounters } from './xerFidelityTypes'; +import { + expectedXerScheduleOptions, + hasProjectAddressableScheduleRow, + scanRawXerScheduleOptions, + type IndependentXerScheduleExpected, + type RawXerScheduleScan, +} from './xerScheduleOptionsGroundTruth'; + +const BLAST_AXES = [...XER_FIDELITY_AXES, 'isCritical'] as const; +type BlastAxis = typeof BLAST_AXES[number]; +type AxisVector = [number, number, number, number, number, number, number]; +type DefaultKey = + | 'totalFloatFinish' + | 'retainedLogic' + | 'openEndedNotCritical' + | 'predecessorLagCalendar' + | 'expectedFinishDates' + | 'preserveActualDates' + | 'clampNegativeFreeFloat' + | 'projectCriticalDefinition'; + +const DEFAULT_KEYS: readonly DefaultKey[] = [ + 'totalFloatFinish', + 'retainedLogic', + 'openEndedNotCritical', + 'predecessorLagCalendar', + 'expectedFinishDates', + 'preserveActualDates', + 'clampNegativeFreeFloat', + 'projectCriticalDefinition', +]; + +const DEFERRED_DEFAULTS = [] as const; + +interface SolverVariant { + progressMode?: ProgressMode; + schedulingOptions?: SchedulingOptions; +} + +interface DefaultMeasurement { + /** Aantal taakwaarden dat per blast-as verandert van de tegenvariant naar de gekozen XER-default. */ + movement: AxisVector; + chosenNegativeFloatTasks: number; + counterfactualNegativeFloatTasks: number; +} + +interface MeasuredFile { + id: string; + state: 'measured' | 'deferred'; + tasks: number; + oracleNegativeFloatTasks: number; + deferredCode?: string; + houseNegativeFloatTasks?: number; + xerDefaultsMovement?: AxisVector; + xerDefaultsNegativeFloatTasks?: number; + defaults?: Record; +} + +interface ExpectedFinishVariantFile { + id: string; + tasks: number; + sourceTasks: number; + activeSourceTasks: number; + movement: AxisVector; + directionChanges: number; + directionDigest: string; +} + +interface ExpectedFinishVariantBaseline { + selection: { + chosen: boolean; + counterfactual: boolean; + }; + population: { + oracleFiles: number; + readableFiles: number; + deferredFiles: number; + projects: number; + tasks: number; + sourceTasks: number; + activeSourceTasks: number; + sourceFiles: number; + movingFiles: number; + }; + movement: AxisVector; + files: ExpectedFinishVariantFile[]; + fidelity: { + chosen: XerFidelityCounters; + counterfactual: XerFidelityCounters; + }; +} + +const EXPECTED_FINISH_SELECTION = { chosen: true, counterfactual: false } as const; + +interface BaselineValueDelta { + path: string; + before: unknown; + after: unknown; +} + +const CAUSAL_FIDELITY_AXES = ['es', 'ef', 'ls', 'lf', 'tf', 'ff'] as const; +type CausalFidelityAxis = typeof CAUSAL_FIDELITY_AXES[number]; +type CausalCounterField = 'deviations' | 'measurable'; +interface CausalCounterRow { + branch: 'house' | 'xerDefaults' | 'retainedLogic' | 'preserveActualDates'; + variant?: 'chosen' | 'counterfactual'; + axis: CausalFidelityAxis; + deviations: number; + measurable: number; +} +interface CausalProductEffects { + version: 1; + axes: readonly CausalFidelityAxis[]; + loeDataDate: { + fields: readonly CausalCounterField[]; + rows: CausalCounterRow[]; + }; + completedProgress: { + fields: readonly CausalCounterField[]; + rows: CausalCounterRow[]; + }; +} + +interface BlastRadiusBaseline { + version: 8; + axes: readonly BlastAxis[]; + defaults: readonly DefaultKey[]; + deferredDefaults: typeof DEFERRED_DEFAULTS; + population: { + scanned: number; + oracleAxisFiles: number; + rawWithSchedOptions: number; + rawWithoutSchedOptions: number; + projectAddressableSchedOptions: number; + functionallyWithoutSchedOptions: number; + measured: number; + deferred: number; + readableFiles: number; + openedProjectsWithDefaults: number; + wiredProjectsWithDefaults: number; + concreteProjectsCompared: number; + oracleNegativeFloatFiles: number; + withoutSchedOptionsNegativeFloatFiles: number; + rawNegativeFloatFiles: number; + rawWithoutSchedOptionsNegativeFloatFiles: number; + }; + schedOptionsRows: { + rows: number; + floatFinish: number; + retainedLogic: number; + openEndedNotCritical: number; + predecessorLagCalendar: number; + projectEndForFloat: number; + expectedFinishDates: number; + lagFromEarlyStart: number; + progressOverride: number; + unknownFloatDialect: number; + derivedRows: number; + derivedFallbacks: number; + derivedFloatDialectFallbacks: number; + derivedRetainedLogic: number; + derivedProgressOverride: number; + derivedFinishFloat: number; + derivedLagPredecessor: number; + derivedLagSuccessor: number; + derivedLag24Hour: number; + derivedLagProjectDefault: number; + retainedProjectEndValues: number; + }; + corpusUnion: string[]; + files: MeasuredFile[]; + expectedFinishVariant: ExpectedFinishVariantBaseline; + /** Historisch X7-dossier; de levende duration-type-regel staat als corpusloze mutatiefixture in X12. */ + legacyDurationTypeHistoricalDelta?: BaselineValueDelta[]; + causalProductEffects?: CausalProductEffects; + fidelity: { + house: XerFidelityCounters; + xerDefaults: XerFidelityCounters; + defaults: Record; + }; +} + +type RawXerTables = RawXerScheduleScan['tables']; +type RawXerTable = RawXerTables extends Map ? T : never; + +type BlastSolvedTask = XerSolvedTask & { isCritical?: boolean }; +interface BlastSolvedProject extends XerSolvedProject { + tasks: BlastSolvedTask[]; +} + +const diffs: string[] = []; +let checks = 0; +const here = fileURLToPath(new URL('.', import.meta.url)); +const baselinePath = join(here, 'xer-schedoptions-blast-radius.json'); +const report = process.env.OPS_XER_SCHEDOPTIONS_REPORT; + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) diffs.push(label); +} + +function independentOraclePopulation(population: BlastRadiusBaseline['population']): unknown { + return { + scanned: population.scanned, + oracleAxisFiles: population.oracleAxisFiles, + rawWithSchedOptions: population.rawWithSchedOptions, + rawWithoutSchedOptions: population.rawWithoutSchedOptions, + projectAddressableSchedOptions: population.projectAddressableSchedOptions, + functionallyWithoutSchedOptions: population.functionallyWithoutSchedOptions, + oracleNegativeFloatFiles: population.oracleNegativeFloatFiles, + withoutSchedOptionsNegativeFloatFiles: population.withoutSchedOptionsNegativeFloatFiles, + rawNegativeFloatFiles: population.rawNegativeFloatFiles, + rawWithoutSchedOptionsNegativeFloatFiles: population.rawWithoutSchedOptionsNegativeFloatFiles, + }; +} + +function productDerivedPopulation(population: BlastRadiusBaseline['population']): unknown { + return { + measured: population.measured, + deferred: population.deferred, + readableFiles: population.readableFiles, + openedProjectsWithDefaults: population.openedProjectsWithDefaults, + wiredProjectsWithDefaults: population.wiredProjectsWithDefaults, + concreteProjectsCompared: population.concreteProjectsCompared, + }; +} + +function causalCounterRow( + branch: CausalCounterRow['branch'], + variant: CausalCounterRow['variant'], + axis: CausalFidelityAxis, + counters: XerFidelityCounters, +): CausalCounterRow { + return { + branch, + ...(variant === undefined ? {} : { variant }), + axis, + deviations: counters[axis].deviations, + measurable: counters[axis].measurable, + }; +} + +/** Alleen vooraf benoemde branches en tellervelden; geen volledige files-/fidelity-subboom. */ +function causalProductEffects(baseline: Pick): CausalProductEffects { + return { + version: 1, + axes: CAUSAL_FIDELITY_AXES, + loeDataDate: { + fields: ['deviations', 'measurable'], + rows: (['house', 'xerDefaults'] as const).flatMap(branch => + CAUSAL_FIDELITY_AXES.map(axis => causalCounterRow( + branch, undefined, axis, baseline.fidelity[branch], + ))), + }, + completedProgress: { + fields: ['deviations', 'measurable'], + rows: (['retainedLogic', 'preserveActualDates'] as const).flatMap(branch => + (['chosen', 'counterfactual'] as const).flatMap(variant => + CAUSAL_FIDELITY_AXES.map(axis => causalCounterRow( + branch, variant, axis, baseline.fidelity.defaults[branch][variant], + )))), + }, + }; +} + +function causalProductEffectsShape(snapshot: CausalProductEffects | undefined): unknown { + const loeRows = snapshot?.loeDataDate?.rows ?? []; + const completedRows = snapshot?.completedProgress?.rows ?? []; + const rowKey = (row: CausalCounterRow): string => + JSON.stringify([row.branch, row.variant ?? null, row.axis]); + return { + topKeys: Object.keys(snapshot ?? {}).sort(), + causeKeys: { + loeDataDate: Object.keys(snapshot?.loeDataDate ?? {}).sort(), + completedProgress: Object.keys(snapshot?.completedProgress ?? {}).sort(), + }, + axes: snapshot?.axes, + fields: { + loeDataDate: snapshot?.loeDataDate?.fields, + completedProgress: snapshot?.completedProgress?.fields, + }, + rowCounts: { loeDataDate: loeRows.length, completedProgress: completedRows.length }, + branches: { + loeDataDate: [...new Set(loeRows.map(row => row.branch))].sort(), + completedProgress: [...new Set(completedRows.map(row => row.branch))].sort(), + }, + variants: [...new Set(completedRows.map(row => row.variant).filter(Boolean))].sort(), + axisSets: { + loeDataDate: [...new Set(loeRows.map(row => row.axis))].sort(), + completedProgress: [...new Set(completedRows.map(row => row.axis))].sort(), + }, + uniqueRows: { + loeDataDate: new Set(loeRows.map(rowKey)).size, + completedProgress: new Set(completedRows.map(rowKey)).size, + }, + }; +} + +function listXerFilesRecursive(dir: string): string[] { + const files: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true }) + .sort((a, b) => a.name.localeCompare(b.name))) { + const full = join(dir, entry.name); + if (entry.isDirectory()) files.push(...listXerFilesRecursive(full)); + else if (entry.isFile() && entry.name.toLowerCase().endsWith('.xer')) files.push(full); + } + return files; +} + +function hash(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function hasOracleAxis(truth: XerGroundTruth): boolean { + return truth.tasks.some(task => XER_FIDELITY_AXES.some(axis => task.axes[axis] !== null)); +} + +function taskHasNegativeFloat(task: XerGroundTruth['tasks'][number]): boolean { + return (typeof task.axes.tf === 'number' && task.axes.tf < 0) + || (typeof task.axes.ff === 'number' && task.axes.ff < 0); +} + +function decodedForTableMarker(bytes: Uint8Array): string { + if (bytes[0] === 0xff && bytes[1] === 0xfe) { + return new TextDecoder('utf-16le').decode(bytes.subarray(2)); + } + if (bytes[0] === 0xfe && bytes[1] === 0xff) { + return new TextDecoder('utf-16be').decode(bytes.subarray(2)); + } + const payload = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf + ? bytes.subarray(3) + : bytes; + return new TextDecoder('windows-1252').decode(payload); +} + +function hasRawNegativeFloat(bytes: Uint8Array): boolean { + let table = ''; + let fields: string[] = []; + for (const line of decodedForTableMarker(bytes).split(/\r?\n/)) { + const cells = line.split('\t'); + if (cells[0] === '%E') break; + if (cells[0] === '%T') { + table = cells[1]?.trim().toUpperCase() ?? ''; + fields = []; + continue; + } + if (table !== 'TASK') continue; + if (cells[0] === '%F') { + fields = cells.slice(1).map(field => field.trim().toLowerCase()); + continue; + } + if (cells[0] !== '%R') continue; + for (const field of ['total_float_hr_cnt', 'free_float_hr_cnt']) { + const index = fields.indexOf(field); + if (index >= 0 && /^-\d/.test(cells[index + 1]?.trim() ?? '')) return true; + } + } + return false; +} + +function addCounters(target: XerFidelityCounters, source: XerFidelityCounters): void { + for (const axis of XER_FIDELITY_AXES) { + target[axis].deviations += source[axis].deviations; + target[axis].measurable += source[axis].measurable; + } +} + +function solvedTaskAxis(task: BlastSolvedTask, axis: BlastAxis): string | number | boolean | undefined { + switch (axis) { + case 'es': return task.earlyStart; + case 'ef': return task.earlyFinish; + case 'ls': return task.lateStart; + case 'lf': return task.lateFinish; + case 'tf': return task.totalFloatMinutes; + case 'ff': return task.freeFloatMinutes; + case 'isCritical': return task.isCritical; + } +} + +function movement(before: BlastSolvedProject, after: BlastSolvedProject): AxisVector { + const afterById = new Map(after.tasks.map(task => [task.sourceTaskId, task])); + return BLAST_AXES.map(axis => before.tasks.reduce((sum, task) => { + const next = afterById.get(task.sourceTaskId); + return sum + (next && solvedTaskAxis(task, axis) === solvedTaskAxis(next, axis) ? 0 : 1); + }, 0)) as AxisVector; +} + +function movementProjects( + before: readonly BlastSolvedProject[], + after: readonly BlastSolvedProject[], +): AxisVector { + const afterByProject = new Map(after.map(project => [project.projectId, project])); + const total = Array(BLAST_AXES.length).fill(0) as AxisVector; + for (const project of before) { + const next = afterByProject.get(project.projectId); + if (!next) throw new Error(`blast-radius mist project ${project.projectId} in de tegenvariant`); + movement(project, next).forEach((value, index) => { total[index] += value; }); + } + return total; +} + +interface TaskAxisMovement { + projectId: string; + taskId: string; + taskCode: string; + axis: BlastAxis; + before: string | number | boolean | undefined; + after: string | number | boolean | undefined; +} + +// X7-reviewpunt 4: de vroegere 16→15-teller is geen CP_Drtn- of projectindexeffect. Deze vijftien +// taakdelta's komen exact van X5's `preserveActualDatesInBackwardPass=true` tegenover de +// house/counterfactual `false`. De pin bewaart taak + as + beide instanties; alleen een totaalteller +// zou opnieuw een toevallig gelijk blijvende verschuiving kunnen verbergen. +const TORTURE_PRESERVE_ACTUAL_LS_PIN = [ + ['A1000', '2025-12-26T08:00', '2026-01-05T08:00'], + ['A2000', '2026-01-03T15:00', '2026-01-19T08:00'], + ['A2100', '2026-01-15T15:00', '2026-01-16T08:00'], + ['A2110', '2026-02-12T12:00', '2026-01-30T08:00'], + ['A2300', '2026-04-16T13:00', '2026-02-09T08:00'], + ['A3000', '2025-12-26T08:00', '2026-01-05T08:00'], + ['A3010', '2026-01-03T15:00', '2026-01-19T07:00'], + ['A3020', '2026-06-01T12:30', '2026-01-12T07:00'], + ['A3030', '2026-10-14T07:00', '2026-02-02T07:00'], + ['A4100', '2026-01-15T15:00', '2026-01-26T07:00'], + ['A4110', '2026-01-22T15:00', '2026-02-02T07:00'], + ['A4200', '2026-01-31T15:00', '2026-02-16T07:00'], + ['A4210', '2026-02-12T15:00', '2026-02-23T07:00'], + ['A4220', '2026-02-24T15:00', '2026-02-25T08:00'], + ['A4230', '2026-02-17T15:00', '2026-02-17T07:00'], +] as const; + +function lateStartPin(details: readonly TaskAxisMovement[]): Array { + return details.filter(detail => detail.axis === 'ls') + .map(detail => [detail.taskId, detail.before, detail.after] as const); +} + +function movementDetails( + before: readonly BlastSolvedProject[], + after: readonly BlastSolvedProject[], +): TaskAxisMovement[] { + const afterByProject = new Map(after.map(project => [project.projectId, project])); + const details: TaskAxisMovement[] = []; + for (const project of before) { + const nextProject = afterByProject.get(project.projectId); + if (!nextProject) throw new Error(`detailmeting mist project ${project.projectId}`); + const nextById = new Map(nextProject.tasks.map(task => [task.sourceTaskId, task])); + for (const task of project.tasks) { + const next = nextById.get(task.sourceTaskId); + if (!next) throw new Error(`detailmeting mist taak ${project.projectId}/${task.sourceTaskId}`); + for (const axis of BLAST_AXES) { + const beforeValue = solvedTaskAxis(task, axis); + const afterValue = solvedTaskAxis(next, axis); + if (beforeValue !== afterValue) details.push({ + projectId: project.projectId, + taskId: task.sourceTaskId, + taskCode: task.taskCode, + axis, + before: beforeValue, + after: afterValue, + }); + } + } + } + return details; +} + +function negativeFloatTasks(solved: readonly BlastSolvedProject[]): number { + return solved.reduce((sum, project) => sum + + project.tasks.filter(task => (task.totalFloatMinutes ?? 0) < 0).length, 0); +} + +function projectResult( + imported: ImportResult, + variant: SolverVariant, +): BlastSolvedProject { + const tasks = cloneTasksForSolve(imported.tasks); + const cpm = solveProject({ + tasks, + sequences: imported.sequences, + calendar: imported.calendar, + calendars: imported.resourceCalendars ?? [], + dataDate: imported.project.statusDate, + progressMode: variant.progressMode, + schedulingOptions: variant.schedulingOptions, + projectStartDate: imported.project.startDate, + }); + if (cpm.error) throw new Error(cpm.error); + const calendarById = new Map([ + [imported.calendar.id, imported.calendar], + ...(imported.resourceCalendars ?? []).map(calendar => [calendar.id, calendar] as const), + ]); + const output: BlastSolvedTask[] = tasks + .filter(task => task.p6ActivityType !== undefined) + .map(task => { + const calendar = (task.calendarId ? calendarById.get(task.calendarId) : undefined) ?? imported.calendar; + const minutesPerDay = calendar.hoursPerDay * 60; + return { + sourceTaskId: task.id, + taskCode: task.wbsCode, + earlyStart: task.time.earlyStart, + earlyFinish: task.time.earlyFinish, + lateStart: task.time.lateStart, + lateFinish: task.time.lateFinish, + totalFloatMinutes: task.time.totalFloat * minutesPerDay, + freeFloatMinutes: task.time.freeFloat * minutesPerDay, + isCritical: cpm.tasks.get(task.id)?.isCritical, + }; + }); + return { projectId: imported.project.id, tasks: output }; +} + +function variantDefinitions( + criticalDefinition: SchedulingOptions['criticalDefinition'], +): Record { + return { + totalFloatFinish: { + chosen: { schedulingOptions: { totalFloatMode: 'finish' } }, + counterfactual: { schedulingOptions: { totalFloatMode: 'smallest' } }, + }, + retainedLogic: { + chosen: { progressMode: 'RETAINED_LOGIC' }, + counterfactual: { progressMode: 'PROGRESS_OVERRIDE' }, + }, + openEndedNotCritical: { + chosen: { schedulingOptions: { makeOpenEndedCritical: false } }, + counterfactual: { schedulingOptions: { makeOpenEndedCritical: true } }, + }, + predecessorLagCalendar: { + chosen: { schedulingOptions: { lagCalendar: 'predecessor' } }, + counterfactual: { schedulingOptions: { lagCalendar: 'successor' } }, + }, + expectedFinishDates: { + chosen: { schedulingOptions: { useExpectedFinishDates: true } }, + counterfactual: { schedulingOptions: { useExpectedFinishDates: false } }, + }, + preserveActualDates: { + chosen: { schedulingOptions: { preserveActualDatesInBackwardPass: true } }, + counterfactual: { schedulingOptions: { preserveActualDatesInBackwardPass: false } }, + }, + clampNegativeFreeFloat: { + chosen: { schedulingOptions: { clampNegativeFreeFloat: true } }, + counterfactual: { schedulingOptions: { clampNegativeFreeFloat: false } }, + }, + projectCriticalDefinition: { + chosen: { schedulingOptions: { criticalDefinition } }, + counterfactual: { schedulingOptions: { criticalDefinition: { mode: 'totalFloat', thresholdHours: 0 } } }, + }, + }; +} + +function deferredCode(error: unknown): string { + return error instanceof XerImportError ? error.xerCode : 'UNEXPECTED_IMPORT_ERROR'; +} + +function measureCorpus(root: string): BlastRadiusBaseline { + const scanned = listXerFilesRecursive(root).map(path => { + const bytes = readFileSync(path); + return { + path, + bytes, + fullHash: hash(bytes), + truth: scanXerGroundTruth(bytes), + rawScan: scanRawXerScheduleOptions(bytes), + }; + }); + const oracleFiles = scanned.filter(file => hasOracleAxis(file.truth)); + const hasRawScheduleRows = (file: typeof scanned[number]): boolean => + (file.rawScan.tables.get('SCHEDOPTIONS')?.rows.length ?? 0) > 0; + const hasAddressableScheduleRows = (file: typeof scanned[number]): boolean => + hasProjectAddressableScheduleRow(file.rawScan); + const rawWithout = oracleFiles.filter(file => !hasRawScheduleRows(file)); + const rawWith = oracleFiles.filter(hasRawScheduleRows); + const without = oracleFiles.filter(file => !hasAddressableScheduleRows(file)); + const addressable = oracleFiles.filter(hasAddressableScheduleRows); + + const allScheduleTables = scanned + .map(file => file.rawScan.tables.get('SCHEDOPTIONS')) + .filter((table): table is RawXerTable => table !== undefined); + const derivedRows: IndependentXerScheduleExpected[] = []; + for (const file of scanned) { + for (const projectId of file.rawScan.projectRowIndexesById.keys()) { + if ((file.rawScan.scheduleRowIndexesById.get(projectId)?.length ?? 0) > 0) { + derivedRows.push(expectedXerScheduleOptions(file.rawScan, projectId)); + } + } + } + const union = [...new Set(allScheduleTables.flatMap(table => table.fields))].sort(); + const scheduleRows = allScheduleTables.flatMap(table => table.rows); + const normalizedCount = (field: string, token: string): number => scheduleRows.filter(row => + row.cells[field]?.trim().toUpperCase() === token).length; + + const fidelity: BlastRadiusBaseline['fidelity'] = { + house: emptyCounters(), + xerDefaults: emptyCounters(), + defaults: Object.fromEntries(DEFAULT_KEYS.map(key => [key, { + chosen: emptyCounters(), + counterfactual: emptyCounters(), + }])) as BlastRadiusBaseline['fidelity']['defaults'], + }; + + // X7-reviewpunt 4: een eigen corpusbaan over ALLE dossiers met een fidelity-as, dus niet alleen + // de functioneel SCHEDOPTIONS-loze defaultpopulatie hieronder en ook niet de gecombineerde + // xerDefaults-vector. De gekozen baan zet uitsluitend expected-finish aan; de tegenvariant + // uitsluitend uit. Richtingsdigest bevat before/after en wordt daardoor rood als beide vlaggen + // worden verwisseld, ook wanneer de symmetrische bewegingsteller gelijk blijft. + const expectedOccurrences = new Map(); + const expectedFiles: ExpectedFinishVariantFile[] = []; + const expectedMovement = Array(BLAST_AXES.length).fill(0) as AxisVector; + const expectedFidelity = { chosen: emptyCounters(), counterfactual: emptyCounters() }; + let expectedReadableFiles = 0; + let expectedDeferredFiles = 0; + let expectedProjects = 0; + let expectedTasks = 0; + let expectedSourceTasks = 0; + let expectedActiveSourceTasks = 0; + for (const file of [...oracleFiles].sort((a, b) => + a.fullHash.localeCompare(b.fullHash) || a.path.localeCompare(b.path))) { + const occurrence = (expectedOccurrences.get(file.fullHash) ?? 0) + 1; + expectedOccurrences.set(file.fullHash, occurrence); + const id = `${file.fullHash.slice(0, 16)}-${occurrence}`; + let importedProjects: ImportResult[]; + try { + const opened = readXER(file.bytes); + importedProjects = isMultiDocumentImport(opened) ? opened.results : [opened]; + } catch { + expectedDeferredFiles++; + continue; + } + expectedReadableFiles++; + expectedProjects += importedProjects.length; + const openedProjectIds = new Set(importedProjects.map(imported => imported.project.id)); + const openedTruth: XerGroundTruth = { + ...file.truth, + projects: new Set([...file.truth.projects].filter(projectId => openedProjectIds.has(projectId))), + tasks: file.truth.tasks.filter(task => openedProjectIds.has(task.projectId)), + }; + expectedTasks += openedTruth.tasks.length; + const sourceTasks = (file.rawScan.tables.get('TASK')?.rows ?? []).filter(row => + openedProjectIds.has(row.cells.proj_id?.trim() ?? '') + && (row.cells.expect_end_date?.trim() ?? '') !== '').length; + expectedSourceTasks += sourceTasks; + const activeSourceTasks = importedProjects.flatMap(imported => imported.tasks).filter(task => + task.p6ExpectedFinish !== undefined + && task.status === 'STARTED' + && task.time.completion > 0 + && task.time.completion < 1).length; + expectedActiveSourceTasks += activeSourceTasks; + const chosen = importedProjects.map(imported => projectResult(imported, { + schedulingOptions: { useExpectedFinishDates: EXPECTED_FINISH_SELECTION.chosen }, + })); + const counterfactual = importedProjects.map(imported => projectResult(imported, { + schedulingOptions: { useExpectedFinishDates: EXPECTED_FINISH_SELECTION.counterfactual }, + })); + const movement = movementProjects(counterfactual, chosen); + movement.forEach((value, index) => { expectedMovement[index] += value; }); + const details = movementDetails(counterfactual, chosen); + for (const [variant, solved] of [ + ['chosen', chosen], + ['counterfactual', counterfactual], + ] as const) { + const measured = measureXerFidelity(openedTruth, solved); + if (measured.errors.length > 0) throw new Error(`${id}: expected-finish ${variant}-uitlijning mislukt`); + addCounters(expectedFidelity[variant], measured.counters); + } + if (sourceTasks > 0 || details.length > 0) { + expectedFiles.push({ + id, + tasks: openedTruth.tasks.length, + sourceTasks, + activeSourceTasks, + movement, + directionChanges: details.length, + directionDigest: hash(new TextEncoder().encode(JSON.stringify(details))), + }); + } + } + const expectedFinishVariant: ExpectedFinishVariantBaseline = { + selection: EXPECTED_FINISH_SELECTION, + population: { + oracleFiles: oracleFiles.length, + readableFiles: expectedReadableFiles, + deferredFiles: expectedDeferredFiles, + projects: expectedProjects, + tasks: expectedTasks, + sourceTasks: expectedSourceTasks, + activeSourceTasks: expectedActiveSourceTasks, + sourceFiles: expectedFiles.filter(file => file.sourceTasks > 0).length, + movingFiles: expectedFiles.filter(file => file.movement.some(Boolean)).length, + }, + movement: expectedMovement, + files: expectedFiles, + fidelity: expectedFidelity, + }; + + const occurrences = new Map(); + const files: MeasuredFile[] = []; + let openedProjectsWithDefaults = 0; + let wiredProjectsWithDefaults = 0; + let concreteProjectsCompared = 0; + for (const file of oracleFiles) { + let importedProjects: ImportResult[]; + try { + const opened = readXER(file.bytes); + importedProjects = isMultiDocumentImport(opened) ? opened.results : [opened]; + } catch { + continue; + } + const archives = importedProjects.map(imported => imported.xer?.scheduleOptions.sourceArchive); + if (!archives.every(archive => archive === archives[0])) { + throw new Error(`${file.path}: SCHEDOPTIONS-bronarchief is per document gekopieerd`); + } + if (JSON.stringify(archives[0]) !== JSON.stringify(file.rawScan.sourceArchive)) { + throw new Error(`${file.path}: bestandsbreed raw-bronarchief wijkt af van het onafhankelijke orakel`); + } + for (const imported of importedProjects) { + const expected = expectedXerScheduleOptions(file.rawScan, imported.project.id, { + taskCount: imported.tasks.filter(task => task.p6ActivityType !== undefined).length, + }); + const actualMetadata = imported.xer?.scheduleOptions; + const actual = { + progressMode: imported.project.progressMode, + schedulingOptions: imported.project.schedulingOptions, + source: actualMetadata?.source, + retainedSource: actualMetadata?.retainedSource, + fallbacks: actualMetadata?.fallbacks, + diagnostics: actualMetadata?.diagnostics, + sourceRowIndexes: actualMetadata?.sourceRowIndexes, + sourceRows: actualMetadata?.sourceRows, + }; + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`${file.path}/${imported.project.id}: concrete SCHEDOPTIONS-afleiding wijkt af`); + } + concreteProjectsCompared++; + } + } + for (const file of [...without].sort((a, b) => + a.fullHash.localeCompare(b.fullHash) || a.path.localeCompare(b.path))) { + const occurrence = (occurrences.get(file.fullHash) ?? 0) + 1; + occurrences.set(file.fullHash, occurrence); + const id = `${file.fullHash.slice(0, 16)}-${occurrence}`; + let oracleNegativeFloatTasks = file.truth.tasks.filter(taskHasNegativeFloat).length; + let importedProjects: ImportResult[]; + try { + const opened = readXER(file.bytes); + importedProjects = isMultiDocumentImport(opened) ? opened.results : [opened]; + } catch (error) { + files.push({ + id, + state: 'deferred', + tasks: file.truth.tasks.length, + oracleNegativeFloatTasks, + deferredCode: deferredCode(error), + }); + continue; + } + + // X4b kan aanwezige baselineprojecten bewust niet als document openen. De blast-radius meet + // daarom alle WERKELIJK geopende projecten uit dit bestand en filtert de onafhankelijke + // grondwaarheid op exact die ids; zo blijft de uitlijning per project zonder terug te vallen + // op één willekeurig actief document. + const openedProjectIds = new Set(importedProjects.map(imported => imported.project.id)); + const openedTruth: XerGroundTruth = { + ...file.truth, + projects: new Set([...file.truth.projects].filter(projectId => openedProjectIds.has(projectId))), + tasks: file.truth.tasks.filter(task => openedProjectIds.has(task.projectId)), + }; + oracleNegativeFloatTasks = openedTruth.tasks.filter(taskHasNegativeFloat).length; + // Gebruik ook hier het onafhankelijke PROJECT/SCHEDOPTIONS-orakel. De oude lokale subset + // controleerde slechts zeven publieke opties en verklaarde daardoor iedere correct bedrade + // XER-defaultset met interne bronsemantiek ten onrechte als "niet bedraad". Dit pad leest + // nog steeds uitsluitend raw tabellen; het importeert geen productie-afleiding. + const xerDefaultsExpected = importedProjects.map(imported => + expectedXerScheduleOptions(file.rawScan, imported.project.id, { + taskCount: imported.tasks.filter(task => task.p6ActivityType !== undefined).length, + })); + const xerDefaultsVariants = xerDefaultsExpected.map(expected => ({ + progressMode: expected.progressMode, + schedulingOptions: expected.schedulingOptions, + })); + openedProjectsWithDefaults += importedProjects.length; + wiredProjectsWithDefaults += importedProjects.filter((imported, index) => + JSON.stringify({ + progressMode: imported.project.progressMode, + schedulingOptions: imported.project.schedulingOptions, + source: imported.xer?.scheduleOptions.source, + }) === JSON.stringify({ + ...xerDefaultsVariants[index], + source: xerDefaultsExpected[index].source, + })).length; + const house = importedProjects.map(imported => projectResult(imported, {})); + const houseMeasurement = measureXerFidelity(openedTruth, house); + if (houseMeasurement.errors.length > 0) throw new Error(`${id}: fidelity-uitlijning mislukt`); + addCounters(fidelity.house, houseMeasurement.counters); + + const xerDefaults = importedProjects.map((imported, index) => projectResult(imported, { + progressMode: xerDefaultsVariants[index].progressMode, + schedulingOptions: xerDefaultsVariants[index].schedulingOptions, + })); + const xerDefaultsFidelity = measureXerFidelity(openedTruth, xerDefaults); + if (xerDefaultsFidelity.errors.length > 0) { + throw new Error(`${id}: gecombineerde-XER-defaultuitlijning mislukt`); + } + addCounters(fidelity.xerDefaults, xerDefaultsFidelity.counters); + if (report === 'details' && id === '2a7732b5b99de2a5-1') { + console.log(`XER-DETAILS ${JSON.stringify(movementDetails(house, xerDefaults))}`); + } + + const defaultMeasurements = {} as Record; + for (const key of DEFAULT_KEYS) { + const chosen = importedProjects.map((imported, index) => projectResult( + imported, + variantDefinitions(xerDefaultsVariants[index].schedulingOptions?.criticalDefinition)[key].chosen, + )); + const counterfactual = importedProjects.map((imported, index) => projectResult( + imported, + variantDefinitions( + xerDefaultsVariants[index].schedulingOptions?.criticalDefinition, + )[key].counterfactual, + )); + defaultMeasurements[key] = { + movement: movementProjects(counterfactual, chosen), + chosenNegativeFloatTasks: negativeFloatTasks(chosen), + counterfactualNegativeFloatTasks: negativeFloatTasks(counterfactual), + }; + if (id === '2a7732b5b99de2a5-1' && key === 'preserveActualDates') { + const counterfactualToChosen = movementDetails(counterfactual, chosen); + eq('torture per-taak/orakelpin: preserveActualDates false→true beweegt exact 15 late starts', + lateStartPin(counterfactualToChosen), TORTURE_PRESERVE_ACTUAL_LS_PIN); + eq('torture causaliteit: gecombineerde XER-defaultbeweging op late start is exact dezelfde X5-mutatie', + lateStartPin(movementDetails(house, xerDefaults)), TORTURE_PRESERVE_ACTUAL_LS_PIN); + } + for (const [variant, solved] of [ + ['chosen', chosen], + ['counterfactual', counterfactual], + ] as const) { + const measured = measureXerFidelity(openedTruth, solved); + if (measured.errors.length > 0) throw new Error(`${id}: ${variant}-uitlijning mislukt`); + addCounters(fidelity.defaults[key][variant], measured.counters); + } + } + files.push({ + id, + state: 'measured', + tasks: openedTruth.tasks.length, + oracleNegativeFloatTasks, + houseNegativeFloatTasks: negativeFloatTasks(house), + xerDefaultsMovement: movementProjects(house, xerDefaults), + xerDefaultsNegativeFloatTasks: negativeFloatTasks(xerDefaults), + defaults: defaultMeasurements, + }); + } + + return { + version: 8, + axes: BLAST_AXES, + defaults: DEFAULT_KEYS, + deferredDefaults: DEFERRED_DEFAULTS, + population: { + scanned: scanned.length, + oracleAxisFiles: oracleFiles.length, + rawWithSchedOptions: rawWith.length, + rawWithoutSchedOptions: rawWithout.length, + projectAddressableSchedOptions: addressable.length, + functionallyWithoutSchedOptions: without.length, + measured: files.filter(file => file.state === 'measured').length, + deferred: files.filter(file => file.state === 'deferred').length, + readableFiles: files.filter(file => file.state === 'measured').length, + openedProjectsWithDefaults, + wiredProjectsWithDefaults, + concreteProjectsCompared, + oracleNegativeFloatFiles: oracleFiles.filter(file => file.truth.tasks.some(taskHasNegativeFloat)).length, + withoutSchedOptionsNegativeFloatFiles: without.filter(file => file.truth.tasks.some(taskHasNegativeFloat)).length, + rawNegativeFloatFiles: scanned.filter(file => hasRawNegativeFloat(file.bytes)).length, + rawWithoutSchedOptionsNegativeFloatFiles: scanned.filter(file => + !hasAddressableScheduleRows(file) && hasRawNegativeFloat(file.bytes)).length, + }, + schedOptionsRows: { + rows: scheduleRows.length, + floatFinish: normalizedCount('sched_float_type', 'FT_FF'), + retainedLogic: normalizedCount('sched_retained_logic', 'Y'), + openEndedNotCritical: normalizedCount('sched_open_critical_flag', 'N'), + predecessorLagCalendar: normalizedCount('sched_calendar_on_relationship_lag', 'RCAL_PREDECESSOR'), + projectEndForFloat: normalizedCount('sched_use_project_end_date_for_float', 'Y'), + expectedFinishDates: normalizedCount('sched_use_expect_end_flag', 'Y'), + lagFromEarlyStart: normalizedCount('sched_lag_early_start_flag', 'Y'), + progressOverride: normalizedCount('sched_progress_override', 'Y'), + unknownFloatDialect: normalizedCount('sched_float_type', 'ST_TOTALFLOAT'), + derivedRows: derivedRows.length, + derivedFallbacks: derivedRows.reduce((sum, result) => sum + result.fallbacks.length, 0), + derivedFloatDialectFallbacks: derivedRows.reduce((sum, result) => sum + + result.fallbacks.filter(item => item.field === 'sched_float_type').length, 0), + derivedRetainedLogic: derivedRows.filter(result => result.progressMode === 'RETAINED_LOGIC').length, + derivedProgressOverride: derivedRows.filter(result => result.progressMode === 'PROGRESS_OVERRIDE').length, + derivedFinishFloat: derivedRows.filter(result => result.schedulingOptions.totalFloatMode === 'finish').length, + derivedLagPredecessor: derivedRows.filter(result => result.schedulingOptions.lagCalendar === 'predecessor').length, + derivedLagSuccessor: derivedRows.filter(result => result.schedulingOptions.lagCalendar === 'successor').length, + derivedLag24Hour: derivedRows.filter(result => result.schedulingOptions.lagCalendar === '24hour').length, + derivedLagProjectDefault: derivedRows.filter(result => + result.schedulingOptions.lagCalendar === 'projectDefault').length, + retainedProjectEndValues: derivedRows.filter(result => + result.retainedSource.sched_use_project_end_date_for_float !== undefined).length, + }, + corpusUnion: union, + files, + expectedFinishVariant, + fidelity, + }; +} + +if (!existsSync(baselinePath)) { + diffs.push('blast-radiusbaseline ontbreekt'); +} else { + const committed = JSON.parse(readFileSync(baselinePath, 'utf8')) as BlastRadiusBaseline; + eq('baselineversie en asvolgorde', { version: committed.version, axes: committed.axes }, { + version: 8, + axes: BLAST_AXES, + }); + eq('baseline bevat alle defaults los van elkaar', committed.defaults, DEFAULT_KEYS); + eq('geen geïmplementeerde XER-default staat nog als uitstel geregistreerd', + committed.deferredDefaults, DEFERRED_DEFAULTS); + eq('baseline pint exact de 36 functioneel SCHEDOPTIONS-loze bestanden', committed.files.length, 36); + eq('baseline bevat alleen hash-identiteiten', + committed.files.every(file => /^[0-9a-f]{16}-\d+$/.test(file.id)), true); + eq('iedere gemeten bestandregel pint iedere default als eigen gekozen/tegenvariant', + committed.files.filter(file => file.state === 'measured').every(file => + JSON.stringify(Object.keys(file.defaults ?? {})) === JSON.stringify(DEFAULT_KEYS)), true); + eq('iedere gemeten bestandregel pint ook de gecombineerde XER-defaultset', + committed.files.filter(file => file.state === 'measured').every(file => + Array.isArray(file.xerDefaultsMovement) + && file.xerDefaultsMovement.length === BLAST_AXES.length + && typeof file.xerDefaultsNegativeFloatTasks === 'number'), true); + eq('expectedFinishDates heeft een zelfstandige gekozen/tegenvariant-corpuspin', + typeof committed.expectedFinishVariant === 'object' + && Array.isArray(committed.expectedFinishVariant?.files) + && committed.expectedFinishVariant?.movement.length === BLAST_AXES.length, true); + eq('historisch ontbrekend-duration-type-dossier blijft als zodanig herkenbaar', + Array.isArray(committed.legacyDurationTypeHistoricalDelta) + && committed.legacyDurationTypeHistoricalDelta.length > 0, true); +} + +const root = process.env.OPS_XER_CORPUS; +if (!root) { + console.log('OK XER-SCHEDOPTIONS-corpus: corpus niet aanwezig; committe pins structureel gecontroleerd'); +} else if (!existsSync(root)) { + diffs.push('OPS_XER_CORPUS bestaat niet'); +} else { + const measured = measureCorpus(root); + eq('onafhankelijke X12-orakelvorm pint alleen raw/scanner-populatie en negatieve-floatverdeling', + independentOraclePopulation(measured.population), { + scanned: 93, + // De rauwe X12-meetlat normaliseert completed taken niet meer naar actuals. Twee bestanden + // die uitsluitend via die oude substitutie een as leken te dragen vallen daarom bewust uit + // deze SCHEDOPTIONS-populatie; de historische v8-pin blijft afzonderlijk rood en ongemoeid. + oracleAxisFiles: 58, + rawWithSchedOptions: 23, + rawWithoutSchedOptions: 35, + projectAddressableSchedOptions: 22, + functionallyWithoutSchedOptions: 36, + oracleNegativeFloatFiles: 5, + withoutSchedOptionsNegativeFloatFiles: 4, + rawNegativeFloatFiles: 6, + rawWithoutSchedOptionsNegativeFloatFiles: 5, + }); + eq('productafgeleide populatie pint alleen reader-, wiring- en projectvergelijkingstellers', + productDerivedPopulation(measured.population), { + measured: 34, + deferred: 2, + readableFiles: 34, + openedProjectsWithDefaults: 35, + wiredProjectsWithDefaults: 35, + concreteProjectsCompared: 70, + }); + eq('meerderheidsinstellingen worden uit alle 50 tabelrijen herleid', measured.schedOptionsRows, { + rows: 50, + floatFinish: 41, + retainedLogic: 48, + openEndedNotCritical: 48, + predecessorLagCalendar: 44, + projectEndForFloat: 39, + expectedFinishDates: 48, + lagFromEarlyStart: 40, + progressOverride: 1, + unknownFloatDialect: 8, + derivedRows: 49, + derivedFallbacks: 8, + derivedFloatDialectFallbacks: 8, + derivedRetainedLogic: 48, + derivedProgressOverride: 1, + derivedFinishFloat: 49, + derivedLagPredecessor: 44, + derivedLagSuccessor: 3, + derivedLag24Hour: 1, + derivedLagProjectDefault: 1, + retainedProjectEndValues: 47, + }); + eq('exact twee functioneel SCHEDOPTIONS-loze bestanden blijven typed uitgesteld', + measured.files.filter(file => file.state === 'deferred').map(file => file.deferredCode).sort(), + ['XER_DANGLING_LOCAL_RELATION', 'XER_INVALID_FILE']); + eq('de onafhankelijke ruwe SCHEDOPTIONS-scan vindt exact 27 kolommen', + measured.corpusUnion.length, 27); + eq('de 27-kolommenunion is exact de productieclassificatiematrix', measured.corpusUnion, + XER_SCHEDOPTIONS_COLUMN_DISPOSITIONS.map(item => item.field).sort()); + + if (report === 'baseline') { + console.log(JSON.stringify(measured)); + } else if (existsSync(baselinePath)) { + const committed = JSON.parse(readFileSync(baselinePath, 'utf8')) as BlastRadiusBaseline; + eq('expectedFinishDates zelfstandige per-bestand/as/populatie en richting blijven exact gepind', + measured.expectedFinishVariant, committed.expectedFinishVariant); + // Herpin 2026-09-05 (X-O7 laag 1, klasse (i) — `p6CompletedLateFromRemainingWindow`), gemeten + // over de hele corpusunie: `xerDefaults` ls 4791 → 3901, lf 4781 → 3891, tf 4592 → 4235 + // (es/ef/ff ongewijzigd). Netto −2.137 afwijkende cellen, volledig toe te schrijven aan één + // corpusbestand (rehab-2): de late zijde van voltooide activiteiten volgt nu het + // statusdatumvenster in plaats van de rauwe actual-pin. De tf-winst is NETTO: per cel gemeten + // op rehab-2 worden 572 tf-cellen exact en 215 eerder exacte tf-cellen fout — alle 215 zijn + // voltooide taken waar P6 `tf = 0` geeft en onze afgeleide LS nog van een zelf foute + // opvolger-LS komt (diagnose laag 1, klasse (ii)); vóór deze etappe was hun `tf = 0` + // degeneratie (LS = de historische actual-start), dus per ongeluk goed. Zie het baanrapport en + // het docblok bij `p6CompletedLateFromRemainingWindow` in `types/project.ts`. + eq('expliciete completed/progress/LOE/data_date-projectie bewaakt shape, keys, rijen, assen en waarden', { + shape: causalProductEffectsShape(committed.causalProductEffects), + measured: causalProductEffects(measured), + }, { + shape: { + topKeys: ['axes', 'completedProgress', 'loeDataDate', 'version'], + causeKeys: { + loeDataDate: ['fields', 'rows'], + completedProgress: ['fields', 'rows'], + }, + axes: CAUSAL_FIDELITY_AXES, + fields: { + loeDataDate: ['deviations', 'measurable'], + completedProgress: ['deviations', 'measurable'], + }, + rowCounts: { loeDataDate: 12, completedProgress: 24 }, + branches: { + loeDataDate: ['house', 'xerDefaults'], + completedProgress: ['preserveActualDates', 'retainedLogic'], + }, + variants: ['chosen', 'counterfactual'], + axisSets: { + loeDataDate: [...CAUSAL_FIDELITY_AXES].sort(), + completedProgress: [...CAUSAL_FIDELITY_AXES].sort(), + }, + uniqueRows: { loeDataDate: 12, completedProgress: 24 }, + }, + measured: committed.causalProductEffects, + }); + } +} + +if (diffs.length > 0) { + console.error(`XER-SCHEDOPTIONS-corpus: ${diffs.length}/${checks} checks rood`); + for (const diff of diffs) console.error(`XX ${diff}`); + process.exit(1); +} +console.log(`OK XER-SCHEDOPTIONS-corpus: ${checks} checks groen`); diff --git a/tests/planning/check-xer-schedule-options-wiring.ts b/tests/planning/check-xer-schedule-options-wiring.ts new file mode 100644 index 000000000..e6cb56d6d --- /dev/null +++ b/tests/planning/check-xer-schedule-options-wiring.ts @@ -0,0 +1,561 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { cloneTasksForSolve, solveProject } from '@/engine/scheduler/solveProject'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; +import { writeIFC } from '@/services/ifc/ifcWriter'; +import { isMultiDocumentImport } from '@/services/importTypes'; +import type { XerScheduleOptionsMetadata } from '@/services/xer/xerScheduleOptions'; +import { readXER, type XerReadResult } from '@/services/xer/xerReader'; +import { useAppStore } from '@/state/appStore'; +import { recoveryInputFromParsed } from '@/state/documentContract'; +import { + expectedXerScheduleOptions, + scanRawXerScheduleOptions, +} from './xerScheduleOptionsGroundTruth'; + +const diffs: string[] = []; +let checks = 0; + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +function ok(label: string, value: unknown): void { + eq(label, Boolean(value), true); +} + +function bytes(lines: readonly string[]): Uint8Array { + return new TextEncoder().encode(lines.join('\n')); +} + +function scheduleMetadata(result: XerReadResult): XerScheduleOptionsMetadata { + return result.xer.scheduleOptions; +} + +function legacyMetadata(metadata: XerScheduleOptionsMetadata) { + return { + source: metadata.source, + retainedSource: metadata.retainedSource, + fallbacks: metadata.fallbacks, + sourceRows: metadata.sourceRows, + }; +} + +function openedProjects(source: Uint8Array): XerReadResult[] { + const opened = readXER(source); + return isMultiDocumentImport(opened) + ? opened.results as XerReadResult[] + : [opened]; +} + +const multiSource = bytes([ + 'ERMHDR\t23.12\t2026-08-25\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC4\tVier uur\t4\t20\t', + '%R\tC8\tAcht uur\t8\t40\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tcritical_path_type\tcritical_drtn_hr_cnt\trem_target_link_flag', + '%R\tP-A\tProject A\tC4\t2026-01-05 08:00\tCT_TotFloat\t8\tY', + '%R\tP-B\tProject B\tC8\t2026-01-05 08:00\tCT_DrivPath\t0\tN', + '%T\tSCHEDOPTIONS', + '%F\tproj_id\tsched_calendar_on_relationship_lag\tsched_float_type\tsched_retained_logic\tsched_progress_override\tsched_open_critical_flag\tsched_use_expect_end_flag\tsched_use_project_end_date_for_float\tlevelprioritylist\tschedhash', + '%R\tP-A\trcal_Successor\tft_ss\tN\tY\tY\tN\tY\tA-RAW\tHASH-A', + '%R\tP-B\tRCAL_24Hour\tST_TotalFloat\tY\tN\tN\tY\tN\tB-RAW\tHASH-B', + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_code\ttask_name\tclndr_id\ttarget_start_date\ttarget_end_date\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\tearly_start_date\tearly_end_date\tlate_start_date\tlate_end_date\ttotal_float_hr_cnt\tfree_float_hr_cnt', + '%R\tA1\tP-A\tA1\tTaak A\tC4\t2026-01-05 08:00\t2026-01-05 12:00\t4\t4\t1900-01-01 00:00\t1900-01-02 00:00\t2099-01-01 00:00\t2099-01-02 00:00\t999\t-999', + '%R\tB1\tP-B\tB1\tTaak B1\tC8\t2026-01-05 08:00\t2026-01-05 16:00\t8\t8\t2001-01-01 00:00\t2001-01-02 00:00\t2088-01-01 00:00\t2088-01-02 00:00\t888\t-888', + '%R\tB2\tP-B\tB2\tTaak B2\tC8\t2026-01-06 08:00\t2026-01-06 16:00\t8\t8\t2002-01-01 00:00\t2002-01-02 00:00\t2087-01-01 00:00\t2087-01-02 00:00\t777\t-777', + '%E', +]); + +const multiOpened = readXER(multiSource); +ok('1 twee projectrijen leveren de X4b-meerdocumentvorm', isMultiDocumentImport(multiOpened)); +if (!isMultiDocumentImport(multiOpened)) { + throw new Error('X5-wiringfixture leverde geen meervoudige import'); +} +const [projectA, projectB] = multiOpened.results as XerReadResult[]; + +eq('bestandsbreed raw-archief heeft één gedeelde bronkopie met projectviews als referenties', { + sameArchive: projectA?.xer.scheduleOptions.sourceArchive === projectB?.xer.scheduleOptions.sourceArchive, + rows: projectA?.xer.scheduleOptions.sourceArchive.rows.length, + projectARefs: projectA?.xer.scheduleOptions.sourceRows.every((row, index) => + row === projectA.xer.scheduleOptions.sourceArchive.rows[projectA.xer.scheduleOptions.sourceRowIndexes[index]]), + projectBRefs: projectB?.xer.scheduleOptions.sourceRows.every((row, index) => + row === projectB.xer.scheduleOptions.sourceArchive.rows[projectB.xer.scheduleOptions.sourceRowIndexes[index]]), +}, { sameArchive: true, rows: 4, projectARefs: true, projectBRefs: true }); + +eq('2 ieder project krijgt uitsluitend zijn eigen SCHEDOPTIONS-semantiek', [ + { + id: projectA?.project.id, + progressMode: projectA?.project.progressMode, + schedulingOptions: projectA?.project.schedulingOptions, + }, + { + id: projectB?.project.id, + progressMode: projectB?.project.progressMode, + schedulingOptions: projectB?.project.schedulingOptions, + }, +], [ + { + id: 'P-A', + progressMode: 'PROGRESS_OVERRIDE', + schedulingOptions: { + p6Source: 'XER', + lagCalendar: 'successor', + criticalDefinition: { mode: 'totalFloat', thresholdHours: 8 }, + totalFloatMode: 'start', + makeOpenEndedCritical: true, + useExpectedFinishDates: false, + preserveActualDatesInBackwardPass: true, + clampNegativeFreeFloat: true, + p6ZeroDurationUsesPlannedBoundary: true, + p6UseTaskPlannedStartFloor: true, + p6FinishMilestoneBoundaryWindow: true, + p6PreserveActualInstants: true, + p6UseRemainingStartForProgress: true, + p6PreserveZeroDurationConstraintInstants: true, + p6CompletedLateFromRemainingWindow: false, + useProjectEndDateForFloat: true, + }, + }, + { + id: 'P-B', + progressMode: 'RETAINED_LOGIC', + schedulingOptions: { + p6Source: 'XER', + lagCalendar: '24hour', + criticalDefinition: { mode: 'longestPath' }, + totalFloatMode: 'finish', + makeOpenEndedCritical: false, + useExpectedFinishDates: true, + preserveActualDatesInBackwardPass: true, + clampNegativeFreeFloat: true, + p6ZeroDurationUsesPlannedBoundary: true, + p6UseTaskPlannedStartFloor: true, + p6FinishMilestoneBoundaryWindow: true, + p6PreserveActualInstants: true, + p6UseRemainingStartForProgress: false, + p6PreserveZeroDurationConstraintInstants: true, + p6CompletedLateFromRemainingWindow: true, + useProjectEndDateForFloat: false, + }, + }, +]); + +// De onafhankelijke raw-ground-truth moet precies dezelfde projectgebonden schakelaars afleiden +// als de productlezer, maar mag hem niet importeren. Dit dekt de OZB-klasse: PROJECT-invoer en +// SCHEDOPTIONS-invoer worden per project samengevoegd, ook in een meerdocumentbestand. +const independentMultiTruth = scanRawXerScheduleOptions(multiSource); +eq('2a onafhankelijke SCHEDOPTIONS-grondwaarheid combineert PROJECT en SCHEDOPTIONS per project', + ['P-A', 'P-B'].map(projectId => { + const expected = expectedXerScheduleOptions(independentMultiTruth, projectId, { taskCount: 1 }); + return { + projectId, + useRemainingStartForProgress: expected.schedulingOptions.p6UseRemainingStartForProgress, + useProjectEndDateForFloat: expected.schedulingOptions.useProjectEndDateForFloat, + retainedSource: expected.retainedSource, + }; + }), [ + { + projectId: 'P-A', + useRemainingStartForProgress: true, + useProjectEndDateForFloat: true, + retainedSource: { sched_use_project_end_date_for_float: true }, + }, + { + projectId: 'P-B', + useRemainingStartForProgress: false, + useProjectEndDateForFloat: false, + retainedSource: { sched_use_project_end_date_for_float: false }, + }, + ]); + +eq('3 retained bronvlag, onbekend enumtoken en volledige bronrij blijven per project zichtbaar', [ + scheduleMetadata(projectA!), + scheduleMetadata(projectB!), +].map(metadata => metadata && ({ + source: metadata.source, + retainedSource: metadata.retainedSource, + fallbacks: metadata.fallbacks, + rows: metadata.sourceRows.map(row => ({ + table: row.table, + line: row.line, + projectId: row.cells.proj_id, + floatToken: row.cells.sched_float_type, + retainedToken: row.cells.sched_use_project_end_date_for_float, + ignoredRawToken: row.cells.levelprioritylist, + hash: row.cells.schedhash, + })), +})), [ + { + source: 'schedoptions', + retainedSource: { sched_use_project_end_date_for_float: true }, + fallbacks: [], + rows: [ + { + table: 'PROJECT', line: 8, projectId: 'P-A', + floatToken: undefined, retainedToken: undefined, ignoredRawToken: undefined, hash: undefined, + }, + { + table: 'SCHEDOPTIONS', line: 12, projectId: 'P-A', + floatToken: 'ft_ss', retainedToken: 'Y', ignoredRawToken: 'A-RAW', hash: 'HASH-A', + }, + ], + }, + { + source: 'schedoptions', + retainedSource: { sched_use_project_end_date_for_float: false }, + fallbacks: [{ + field: 'sched_float_type', token: 'ST_TotalFloat', fallback: 'finish', line: 13, + }], + rows: [ + { + table: 'PROJECT', line: 9, projectId: 'P-B', + floatToken: undefined, retainedToken: undefined, ignoredRawToken: undefined, hash: undefined, + }, + { + table: 'SCHEDOPTIONS', line: 13, projectId: 'P-B', + floatToken: 'ST_TotalFloat', retainedToken: 'N', ignoredRawToken: 'B-RAW', hash: 'HASH-B', + }, + ], + }, +]); + +function noScheduleSource(p6Date: string, p6Float: string): Uint8Array { + return bytes([ + 'ERMHDR\t23.12\t2026-08-25\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC8\tAcht uur\t8\t40\t', + '%R\tC4\tVier uur\t4\t20\t(0||CalendarData()((0||DaysOfWeek()((0||2()((0||0(s|08:00|f|12:00)())))(0||3()((0||0(s|08:00|f|12:00)())))(0||4()((0||0(s|08:00|f|12:00)())))(0||5()((0||0(s|08:00|f|12:00)())))(0||6()((0||0(s|08:00|f|12:00)())))))(0||Exceptions()())))', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tcritical_path_type\tcritical_drtn_hr_cnt', + '%R\tP-DEFAULT\tZonder tabel\tC8\t2026-01-05 08:00\tCT_TotFloat\t8', + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_code\ttask_name\tclndr_id\ttarget_start_date\ttarget_end_date\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\tearly_start_date\tearly_end_date\tlate_start_date\tlate_end_date\ttotal_float_hr_cnt\tfree_float_hr_cnt', + `%R\tSHORT\tP-DEFAULT\tSHORT\tVieruurstaak\tC4\t2026-01-05 08:00\t2026-01-05 12:00\t4\t4\t${p6Date}\t${p6Date}\t${p6Date}\t${p6Date}\t${p6Float}\t${p6Float}`, + `%R\tDRIVER\tP-DEFAULT\tDRIVER\tDrijver\tC8\t2026-01-05 08:00\t2026-01-07 16:00\t24\t24\t${p6Date}\t${p6Date}\t${p6Date}\t${p6Date}\t${p6Float}\t${p6Float}`, + '%E', + ]); +} + +const defaultProject = openedProjects(noScheduleSource('1900-01-01 00:00', '999'))[0]!; +eq('4 ontbrekende SCHEDOPTIONS krijgt altijd de expliciete P6-defaultset op de 4h/8h-fixture', { + progressMode: defaultProject.project.progressMode, + schedulingOptions: defaultProject.project.schedulingOptions, + projectHours: defaultProject.calendar.hoursPerDay, + taskHours: defaultProject.resourceCalendars?.find(calendar => calendar.id === 'C4')?.hoursPerDay, + metadata: legacyMetadata(scheduleMetadata(defaultProject)), +}, { + progressMode: 'RETAINED_LOGIC', + schedulingOptions: { + p6Source: 'XER', + lagCalendar: 'predecessor', + criticalDefinition: { mode: 'totalFloat', thresholdHours: 8 }, + totalFloatMode: 'finish', + makeOpenEndedCritical: false, + useExpectedFinishDates: true, + preserveActualDatesInBackwardPass: true, + clampNegativeFreeFloat: true, + p6ZeroDurationUsesPlannedBoundary: true, + p6UseTaskPlannedStartFloor: true, + p6FinishMilestoneBoundaryWindow: true, + p6PreserveActualInstants: true, + p6UseRemainingStartForProgress: false, + p6PreserveZeroDurationConstraintInstants: true, + p6CompletedLateFromRemainingWindow: true, + }, + projectHours: 8, + taskHours: 4, + metadata: { + source: 'xer-defaults', + retainedSource: {}, + fallbacks: [], + sourceRows: [{ + table: 'PROJECT', + line: 8, + cells: { + proj_id: 'P-DEFAULT', + proj_short_name: 'Zonder tabel', + clndr_id: 'C8', + last_recalc_date: '2026-01-05 08:00', + critical_path_type: 'CT_TotFloat', + critical_drtn_hr_cnt: '8', + }, + }], + }, +}); + +const hostileProject = openedProjects(bytes([ + 'ERMHDR\t23.12\t2026-08-25\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tcritical_path_type\tcritical_drtn_hr_cnt', + '%R\tP-HOSTILE\tHostile\tCT_TotFloat\t8', + '%T\tSCHEDOPTIONS', + '%F\tproj_id\tsched_float_type\tsched_retained_logic\tsched_progress_override', + '%R\tP-HOSTILE\tFT_SS\tN\tY', + '%R\tP-HOSTILE\tFT_FF\tY\tN', + '%R\tORPHAN\tFT_MIN\tN\tY', + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_code\ttask_name\ttarget_start_date\ttarget_end_date\ttarget_drtn_hr_cnt', + '%R\tT1\tP-HOSTILE\tT1\tTaak\t2026-01-05\t2026-01-06\t8', + '%E', +]))[0]!; +eq('hostile reader bewaart orphan/duplicates eenmaal en past geen onzekere rijsemantiek toe', { + source: hostileProject.xer.scheduleOptions.source, + progressMode: hostileProject.project.progressMode, + floatMode: hostileProject.project.schedulingOptions?.totalFloatMode, + sourceRowIndexes: hostileProject.xer.scheduleOptions.sourceRowIndexes, + sourceProjectIds: hostileProject.xer.scheduleOptions.sourceRows.map(row => row.cells.proj_id), + archiveProjectIds: hostileProject.xer.scheduleOptions.sourceArchive.rows.map(row => row.cells.proj_id), + unmatched: hostileProject.xer.scheduleOptions.sourceArchive.unmatchedScheduleOptionsRowIndexes, + diagnostics: hostileProject.xer.scheduleOptions.diagnostics, +}, { + source: 'xer-defaults', + progressMode: 'RETAINED_LOGIC', + floatMode: 'finish', + sourceRowIndexes: [0, 1, 2], + sourceProjectIds: ['P-HOSTILE', 'P-HOSTILE', 'P-HOSTILE'], + archiveProjectIds: ['P-HOSTILE', 'P-HOSTILE', 'P-HOSTILE', 'ORPHAN'], + unmatched: [3], + diagnostics: [{ + code: 'XER_DUPLICATE_SCHEDOPTIONS_PROJ_ID', + projectId: 'P-HOSTILE', + rowIndexes: [1, 2], + lines: [7, 8], + }], +}); + +function solvedAxes(source: Uint8Array): unknown { + const imported = openedProjects(source)[0]!; + const tasks = cloneTasksForSolve(imported.tasks); + const result = solveProject({ + tasks, + sequences: imported.sequences, + calendar: imported.calendar, + calendars: imported.resourceCalendars ?? [], + dataDate: imported.project.statusDate, + progressMode: imported.project.progressMode, + schedulingOptions: imported.project.schedulingOptions, + projectStartDate: imported.project.startDate, + }); + return tasks.map(task => ({ + id: task.id, + es: task.time.earlyStart, + ef: task.time.earlyFinish, + ls: task.time.lateStart, + lf: task.time.lateFinish, + tf: task.time.totalFloat, + ff: task.time.freeFloat, + critical: result.tasks.get(task.id)?.isCritical, + })); +} + +const solvedDefault = solvedAxes(noScheduleSource('1900-01-01 00:00', '999')); +eq('5 8h-kritiekdrempel wordt na echte readerwiring per effectieve 4h-taakkalender toegepast', + solvedDefault, [ + { + id: 'SHORT', es: '2026-01-05T08:00', ef: '2026-01-05T12:00', + ls: '2026-01-07T08:00', lf: '2026-01-07T16:00', tf: 2, ff: 2, critical: true, + }, + { + id: 'DRIVER', es: '2026-01-05T08:00', ef: '2026-01-07T16:00', + ls: '2026-01-05T08:00', lf: '2026-01-07T16:00', tf: 0, ff: 0, critical: true, + }, + ]); +eq('6 P6 early/late/float-bronvelden zijn geen solverinvoer', { + first: solvedDefault, + second: solvedAxes(noScheduleSource('2099-12-31 23:59', '-12345')), +}, { + first: solvedDefault, + second: solvedDefault, +}); + +useAppStore.getState().applyOpenedImport(multiOpened, { + filePath: null, + fileHandle: null, + recompute: true, + fit: false, + hourDataNotice: false, + linkedOpen: true, +}); +const openedDocs = useAppStore.getState().getOpenDocumentPayloads(); +eq('7 X4b-file-openfan-out bewaart per document zijn eigen projectinstellingen en bronmetadata', + openedDocs.map(document => ({ + id: document.payload.project.id, + progressMode: document.payload.project.progressMode, + floatMode: document.payload.project.schedulingOptions?.totalFloatMode, + sourceProjectIds: document.payload.xerImportMetadata?.scheduleOptions.sourceRows + .map(row => row.cells.proj_id), + })), [ + { id: 'P-A', progressMode: 'PROGRESS_OVERRIDE', floatMode: 'start', sourceProjectIds: ['P-A', 'P-A'] }, + { id: 'P-B', progressMode: 'RETAINED_LOGIC', floatMode: 'finish', sourceProjectIds: ['P-B', 'P-B'] }, + ]); +eq('file-open bewaart de file-wide bronkopie gedeeld tussen de documentpayloads', + openedDocs[0]?.payload.xerImportMetadata?.scheduleOptions.sourceArchive + === openedDocs[1]?.payload.xerImportMetadata?.scheduleOptions.sourceArchive, + true); + +const firstDocumentId = openedDocs[0]?.id; +const secondDocumentId = openedDocs[1]?.id; +if (!firstDocumentId || !secondDocumentId) throw new Error('Wiringfixture mist geopende documenten'); +useAppStore.getState().switchDocument(firstDocumentId); +const beforeUndo = JSON.stringify({ + progressMode: useAppStore.getState().project.progressMode, + schedulingOptions: useAppStore.getState().project.schedulingOptions, + metadata: useAppStore.getState().xerImportMetadata, +}); +useAppStore.getState().setProject({ description: 'undo-proef' }); +useAppStore.getState().undo(); +eq('8 documentwissel plus undo bewaart bedrade projectinstellingen en immutable bronmetadata', + JSON.stringify({ + progressMode: useAppStore.getState().project.progressMode, + schedulingOptions: useAppStore.getState().project.schedulingOptions, + metadata: useAppStore.getState().xerImportMetadata, + }), beforeUndo); + +const recoveryInputs = (multiOpened.results as XerReadResult[]).map((result, index) => + recoveryInputFromParsed(result, { + id: index === 0 ? firstDocumentId : secondDocumentId, + filePath: null, + isDirty: false, + datesAsRecorded: false, + })); +useAppStore.getState().restoreDocuments(recoveryInputs, secondDocumentId); +eq('9 RecoveryDocInput bewaart instellingen en retained bronwaarde per project', + useAppStore.getState().getOpenDocumentPayloads().map(document => ({ + id: document.payload.project.id, + progressMode: document.payload.project.progressMode, + floatMode: document.payload.project.schedulingOptions?.totalFloatMode, + retained: document.payload.xerImportMetadata?.scheduleOptions.retainedSource, + })), [ + { id: 'P-A', progressMode: 'PROGRESS_OVERRIDE', floatMode: 'start', retained: { sched_use_project_end_date_for_float: true } }, + { id: 'P-B', progressMode: 'RETAINED_LOGIC', floatMode: 'finish', retained: { sched_use_project_end_date_for_float: false } }, + ]); +const recoveredDocuments = useAppStore.getState().getOpenDocumentPayloads(); +eq('RecoveryDocInput behoudt één gedeeld file-wide bronarchief', + recoveredDocuments[0]?.payload.xerImportMetadata?.scheduleOptions.sourceArchive + === recoveredDocuments[1]?.payload.xerImportMetadata?.scheduleOptions.sourceArchive, + true); + +eq('10 IFC bewaart X5-documentprovenance naast de bestaande projectinstellingen', + (multiOpened.results as XerReadResult[]).map(result => { + const roundTripped = readIFC(writeIFC(result)); + return { + name: roundTripped.project.name, + progressMode: roundTripped.project.progressMode, + schedulingOptions: roundTripped.project.schedulingOptions, + xer: { + sourceProjectId: roundTripped.xer?.sourceProjectId, + source: roundTripped.xer?.scheduleOptions.source, + retained: roundTripped.xer?.scheduleOptions.retainedSource, + report: roundTripped.xer?.report, + }, + }; + }), [ + { + name: 'Project A', progressMode: 'PROGRESS_OVERRIDE', + schedulingOptions: projectA?.project.schedulingOptions, + xer: { + sourceProjectId: projectA?.xer.sourceProjectId, + source: projectA?.xer.scheduleOptions.source, + retained: projectA?.xer.scheduleOptions.retainedSource, + report: projectA?.xer.report, + }, + }, + { + // RETAINED_LOGIC is IFC's bestaande canonieke default en komt daarom als `undefined` terug; + // de solversemantiek blijft retained. Een expliciete PROGRESS_OVERRIDE hierboven blijft staan. + name: 'Project B', progressMode: undefined, + schedulingOptions: projectB?.project.schedulingOptions, + xer: { + sourceProjectId: projectB?.xer.sourceProjectId, + source: projectB?.xer.scheduleOptions.source, + retained: projectB?.xer.scheduleOptions.retainedSource, + report: projectB?.xer.report, + }, + }, + ]); + +const corpusRoot = process.env.OPS_XER_CORPUS; +if (corpusRoot && existsSync(corpusRoot)) { + const relativePath = 'crawl-xer/eh_P6Workshops/OZB-Start-09Dec24.xer'; + const publicBytes = new Uint8Array(readFileSync(join(corpusRoot, relativePath))); + const publicTruth = scanRawXerScheduleOptions(publicBytes); + const publicOpened = readXER(publicBytes); + if (!isMultiDocumentImport(publicOpened)) throw new Error('Openbare 15-projectenfixture is niet meervoudig'); + const settings = (publicOpened.results as XerReadResult[]).map(result => { + const expected = expectedXerScheduleOptions(publicTruth, result.project.id, { + taskCount: result.tasks.filter(task => task.p6ActivityType !== undefined).length, + }); + const metadata = scheduleMetadata(result); + return { + projectId: result.project.id, + source: metadata?.source, + isolated: JSON.stringify({ + progressMode: result.project.progressMode, + schedulingOptions: result.project.schedulingOptions, + }) === JSON.stringify({ + progressMode: expected.progressMode, + schedulingOptions: expected.schedulingOptions, + }) && JSON.stringify({ + source: metadata?.source, + retainedSource: metadata?.retainedSource, + fallbacks: metadata?.fallbacks, + diagnostics: metadata?.diagnostics, + sourceRowIndexes: metadata?.sourceRowIndexes, + sourceRows: metadata?.sourceRows, + }) === JSON.stringify({ + source: expected.source, + retainedSource: expected.retainedSource, + fallbacks: expected.fallbacks, + diagnostics: expected.diagnostics, + sourceRowIndexes: expected.sourceRowIndexes, + sourceRows: expected.sourceRows, + }), + progressMode: result.project.progressMode, + lagCalendar: result.project.schedulingOptions?.lagCalendar, + totalFloatMode: result.project.schedulingOptions?.totalFloatMode, + critical: result.project.schedulingOptions?.criticalDefinition, + retainedProjectEnd: metadata?.retainedSource.sched_use_project_end_date_for_float, + sourceRows: metadata?.sourceRows.length, + }; + }); + eq('11 openbare 15-projectenmeting houdt alle 12 geopende settings per project geïsoleerd', { + projectsSeen: publicOpened.report.projectsSeen, + documentsOpened: publicOpened.results.length, + isolated: settings.every(item => item.isolated), + idsUnique: new Set(settings.map(item => item.projectId)).size, + }, { projectsSeen: 15, documentsOpened: 12, isolated: true, idsUnique: 12 }); + const expectedProjectIds = [ + '9029', '9030', '9031', '9032', '9033', '9040', + '9045', '9047', '9049', '10091', '10093', '10096', + ]; + eq('12 openbare 15-projectenmeting pint de instellingen van ieder geopend project afzonderlijk', + settings, expectedProjectIds.map(projectId => ({ + projectId, + source: 'schedoptions', + isolated: true, + progressMode: projectId === '10093' ? 'PROGRESS_OVERRIDE' : 'RETAINED_LOGIC', + lagCalendar: 'predecessor', + totalFloatMode: 'finish', + critical: { mode: 'totalFloat', thresholdHours: 0 }, + retainedProjectEnd: true, + sourceRows: 2, + }))); + for (const item of settings) console.log(`. X5-settings ${JSON.stringify(item)}`); +} else { + console.log('OK XER-SCHEDOPTIONS-wiring: openbare 15-projectenmeting overgeslagen (OPS_XER_CORPUS)'); +} + +if (diffs.length > 0) { + console.error(`XER-SCHEDOPTIONS-wiring: ${diffs.length}/${checks} checks rood`); + for (const diff of diffs) console.error(`XX ${diff}`); + process.exit(1); +} +console.log(`OK XER-SCHEDOPTIONS-wiring: ${checks} checks groen`); diff --git a/tests/planning/check-xer-schedule-options.ts b/tests/planning/check-xer-schedule-options.ts new file mode 100644 index 000000000..47f4be939 --- /dev/null +++ b/tests/planning/check-xer-schedule-options.ts @@ -0,0 +1,1082 @@ +import { CPMSolver } from '@/engine/scheduler/CPMSolver'; +import { createDefaultProject } from '@/state/defaults'; +import { + deriveXerScheduleOptions as deriveIndexedXerScheduleOptions, + indexXerScheduleOptions, + XER_SCHEDOPTIONS_COLUMN_DISPOSITIONS, + XER_SCHEDULING_DEFAULTS, +} from '@/services/xer/xerScheduleOptions'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; +import { writeIFC } from '@/services/ifc/ifcWriter'; +import { parseXerTables } from '@/services/xer/xerTables'; +import type { WorkCalendar } from '@/types/calendar'; +import type { Sequence } from '@/types/sequence'; +import type { Task } from '@/types/task'; +import { createDefaultTaskTime } from '@/utils/taskDefaults'; + +const diffs: string[] = []; +let checks = 0; + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +function deriveXerScheduleOptions( + tables: ReturnType, + projectId: string, + context: { hoursPerDay?: number; taskCount?: number } = {}, +) { + return deriveIndexedXerScheduleOptions(indexXerScheduleOptions(tables), projectId, context); +} + +function legacyResult(result: ReturnType) { + const { sourceArchive: _archive, sourceRowIndexes: _indexes, diagnostics: _diagnostics, ...legacy } = result; + return legacy; +} + +const predecessorCalendar: WorkCalendar = { + id: 'pred', + name: 'pred', + description: '', + workDays: [1, 2, 3, 4, 5], + workStartHour: 8, + workEndHour: 16, + hoursPerDay: 8, + holidays: [], +}; + +const successorCalendar: WorkCalendar = { + ...predecessorCalendar, + id: 'succ', + name: 'succ', + workDays: [1, 2, 3, 4, 5, 6], +}; + +function task(id: string, duration: number, calendarId: string): Task { + return { + id, + name: id, + description: '', + wbsCode: '', + taskType: 'CONSTRUCTION', + status: 'NOT_STARTED', + isMilestone: false, + priority: 500, + parentId: null, + childIds: [], + time: createDefaultTaskTime('2026-06-01', duration), + resourceIds: [], + calendarId, + }; +} + +const P6_DAY_BANDS = [{ start: 480, end: 720 }, { start: 780, end: 1020 }]; + +function p6Calendar(id: string, workDays: number[]): WorkCalendar { + return { + id, + name: id, + description: '', + workDays, + workStartHour: 8, + workEndHour: 17, + hoursPerDay: 8, + holidays: [], + workTime: { + byWeekday: { + 1: workDays.includes(1) ? P6_DAY_BANDS : [], + 2: workDays.includes(2) ? P6_DAY_BANDS : [], + 3: workDays.includes(3) ? P6_DAY_BANDS : [], + 4: workDays.includes(4) ? P6_DAY_BANDS : [], + 5: workDays.includes(5) ? P6_DAY_BANDS : [], + 6: workDays.includes(6) ? P6_DAY_BANDS : [], + 7: workDays.includes(7) ? P6_DAY_BANDS : [], + }, + }, + }; +} + +function p6Task(id: string, durationDays: number, calendarId: string, start: string): Task { + const result = task(id, durationDays, calendarId); + result.time = createDefaultTaskTime(start, durationDays); + result.time.durationMinutes = durationDays * 8 * 60; + return result; +} + +function axes(result: ReturnType, id: string): unknown { + const value = result.tasks.get(id); + return value && { + es: value.earlyStart, + ef: value.earlyFinish, + ls: value.lateStart, + lf: value.lateFinish, + tf: value.totalFloat, + ff: value.freeFloat, + }; +} + +const tasks = [task('P', 4, 'pred'), task('S', 1, 'succ')]; +const sequences: Sequence[] = [{ + id: 'R', + predecessorId: 'P', + successorId: 'S', + type: 'FINISH_START', + lagDays: 1, +}]; + +const successorLag = new CPMSolver( + tasks, + sequences, + predecessorCalendar, + [successorCalendar], + { schedulingOptions: { lagCalendar: 'successor' } }, +).solve(); + +eq( + 'successor-lag gebruikt de zesdaagse opvolgerkalender', + successorLag.tasks.get('S')?.earlyStart, + '2026-06-06', +); + +const predLagCalendar: WorkCalendar = { + ...predecessorCalendar, + id: 'lag-pred', + name: 'lag-pred', + holidays: [ + { name: 'x', startDate: '2026-06-02', endDate: '2026-06-03' }, + ], +}; +const succLagCalendar: WorkCalendar = { + ...predecessorCalendar, + id: 'lag-succ', + name: 'lag-succ', + holidays: [{ name: 'x', startDate: '2026-06-02', endDate: '2026-06-02' }], +}; +const projectLagCalendar: WorkCalendar = { + ...predecessorCalendar, + id: 'lag-project', + name: 'lag-project', + holidays: [{ name: 'x', startDate: '2026-06-02', endDate: '2026-06-04' }], +}; +const lagTasks = [task('LP', 1, 'lag-pred'), task('LS', 1, 'lag-succ')]; +const lagSequences: Sequence[] = [{ + id: 'LR', + predecessorId: 'LP', + successorId: 'LS', + type: 'START_START', + lagDays: 3, +}]; +function solveLag(lagCalendar: 'predecessor' | 'successor' | 'projectDefault' | '24hour'): string | undefined { + return new CPMSolver( + lagTasks, + lagSequences, + projectLagCalendar, + [predLagCalendar, succLagCalendar], + { schedulingOptions: { lagCalendar } }, + ).solve().tasks.get('LS')?.earlyStart; +} +eq('vier lagkalenders kiezen ieder hun eigen bron', { + predecessor: solveLag('predecessor'), + successor: solveLag('successor'), + projectDefault: solveLag('projectDefault'), + '24hour': solveLag('24hour'), +}, { + predecessor: '2026-06-08', + successor: '2026-06-05', + projectDefault: '2026-06-09', + '24hour': '2026-06-04', +}); + +const elapsedTail = task('ELAPSED-END', 10, 'lag-project'); +const elapsedTasks = [...lagTasks, elapsedTail]; +const elapsedSequence: Sequence[] = [{ + ...lagSequences[0], + id: 'ELAPSED-R', + lagUnit: 'ELAPSEDTIME', +}]; +function solveElapsedLag(lagCalendar: 'predecessor' | 'successor' | 'projectDefault' | '24hour'): unknown { + const result = new CPMSolver( + elapsedTasks, + elapsedSequence, + projectLagCalendar, + [predLagCalendar, succLagCalendar], + { schedulingOptions: { lagCalendar } }, + ).solve(); + return axes(result, 'LP'); +} +const elapsedPredecessor = solveElapsedLag('predecessor'); +eq('ELAPSEDTIME-lag is onafhankelijk van de gekozen werkkalender', { + predecessor: elapsedPredecessor, + successor: solveElapsedLag('successor'), + projectDefault: solveElapsedLag('projectDefault'), + '24hour': solveElapsedLag('24hour'), +}, { + predecessor: elapsedPredecessor, + successor: elapsedPredecessor, + projectDefault: elapsedPredecessor, + '24hour': elapsedPredecessor, +}); + +const crossModePredCalendar: WorkCalendar = { + ...predLagCalendar, + id: 'cross-pred', + name: 'cross-pred', +}; +const crossModeSuccCalendar: WorkCalendar = { + ...p6Calendar('cross-succ', [1, 2, 3, 4, 5, 6]), + holidays: [{ name: 'x', startDate: '2026-06-03', endDate: '2026-06-03' }], +}; +const crossModeProjectCalendar: WorkCalendar = { + ...projectLagCalendar, + id: 'cross-project', + name: 'cross-project', +}; +const crossModeTasks = [ + task('CROSS-P', 2, 'cross-pred'), + p6Task('CROSS-S', 1, 'cross-succ', '2026-06-01'), +]; +const crossModeSequences: Sequence[] = [{ + id: 'CROSS-R', + predecessorId: 'CROSS-P', + successorId: 'CROSS-S', + type: 'FINISH_START', + lagDays: 0, + lagMinutes: 8 * 60, +}]; +function solveCrossModeLag( + lagCalendar: 'predecessor' | 'successor' | 'projectDefault' | '24hour', +): unknown { + const result = new CPMSolver( + crossModeTasks, + crossModeSequences, + crossModeProjectCalendar, + [crossModePredCalendar, crossModeSuccCalendar], + { schedulingOptions: { lagCalendar } }, + ).solve(); + return { + predecessor: axes(result, 'CROSS-P'), + successor: axes(result, 'CROSS-S'), + }; +} +for (const lagCalendar of ['predecessor', 'successor', 'projectDefault', '24hour'] as const) { + const solved = solveCrossModeLag(lagCalendar) as { + predecessor: { es: string; ef: string; ls: string; lf: string; tf: number; ff: number }; + successor: { es: string; ef: string; ls: string; lf: string; tf: number; ff: number }; + }; + eq(`cross-modus ${lagCalendar}: forward/backward blijven elkaars inverse`, { + predecessor: { + ls: solved.predecessor.ls, + lf: solved.predecessor.lf, + tf: solved.predecessor.tf, + }, + successor: { + ls: solved.successor.ls, + lf: solved.successor.lf, + tf: solved.successor.tf, + }, + }, { + predecessor: { + ls: solved.predecessor.es, + lf: solved.predecessor.ef, + tf: 0, + }, + successor: { + ls: solved.successor.es, + lf: solved.successor.ef, + tf: 0, + }, + }); +} + +const endProjectCalendar: WorkCalendar = { + ...predecessorCalendar, + id: 'end-project', + name: 'end-project', +}; +const sixDayCalendar: WorkCalendar = { + ...predecessorCalendar, + id: 'six-day', + name: 'six-day', + workDays: [1, 2, 3, 4, 5, 6], +}; +const endTasks = [task('LONG', 6, 'six-day'), task('SHORT', 1, 'end-project')]; +const ordinaryEnd = new CPMSolver(endTasks, [], endProjectCalendar, [sixDayCalendar]).solve(); +eq('één project gebruikt één gemeenschappelijk projecteinde zonder taakkalender-snap', + ordinaryEnd.tasks.get('SHORT')?.lateFinish, '2026-06-06'); + +function sourceWithoutXerFloatValue(options?: { + resumeFromActualElapsed?: boolean; + unstartedIgnoresStatusDate?: boolean; +}): unknown { + return [...new CPMSolver( + endTasks, + [], + endProjectCalendar, + [sixDayCalendar], + { schedulingOptions: options }, + ).solve().tasks]; +} +eq('afwezige XER-floatbron houdt verse/MPP/MSPDI/P6XML-uitvoer byte-identiek', { + fresh: sourceWithoutXerFloatValue(createDefaultProject().schedulingOptions), + mpp: sourceWithoutXerFloatValue({ + resumeFromActualElapsed: true, + unstartedIgnoresStatusDate: true, + }), + mspdi: sourceWithoutXerFloatValue(undefined), + p6xml: sourceWithoutXerFloatValue(undefined), +}, { + fresh: [...ordinaryEnd.tasks], + mpp: [...ordinaryEnd.tasks], + mspdi: [...ordinaryEnd.tasks], + p6xml: [...ordinaryEnd.tasks], +}); + +const explicitInertEnd = new CPMSolver( + endTasks, + [], + endProjectCalendar, + [sixDayCalendar], + { + schedulingOptions: { + useExpectedFinishDates: false, + preserveActualDatesInBackwardPass: false, + clampNegativeFreeFloat: false, + }, + }, +).solve(); +eq('expliciet uitgeschakelde XER-bronvlaggen laten niet-XER-solvergedrag byte-identiek', + [...explicitInertEnd.tasks], [...ordinaryEnd.tasks]); +eq('een vers OPS-project krijgt geen stille XER-defaults', + createDefaultProject().schedulingOptions, undefined); + +// Drie onafhankelijk in P6 geverifieerde brongevallen. Alleen taak-/kalenderinvoer gaat de solver +// in: de orakeldatums worden uitsluitend hieronder als verwachtingen gebruikt. +const p6MonFri = p6Calendar('p6-monfri', [1, 2, 3, 4, 5]); +const p6SixDay = p6Calendar('p6-sixday', [1, 2, 3, 4, 5, 6]); +const p6MultiCalendar = new CPMSolver( + [ + p6Task('A', 10, 'p6-monfri', '2026-01-05T08:00'), + p6Task('B', 10, 'p6-sixday', '2026-01-05T08:00'), + ], + [], + p6MonFri, + [p6SixDay], + { schedulingOptions: { totalFloatMode: 'finish' } }, +).solve(); +eq('P6-geval 06: meerdere kalenders, alle zes datum-/floatassen', { + A: axes(p6MultiCalendar, 'A'), + B: axes(p6MultiCalendar, 'B'), +}, { + A: { es: '2026-01-05T08:00', ef: '2026-01-16T17:00', ls: '2026-01-05T08:00', lf: '2026-01-16T17:00', tf: 0, ff: 0 }, + B: { es: '2026-01-05T08:00', ef: '2026-01-15T17:00', ls: '2026-01-06T08:00', lf: '2026-01-16T17:00', tf: 1, ff: 1 }, +}); + +const retainedA = p6Task('A', 10, 'p6-monfri', '2026-01-05T08:00'); +retainedA.time.actualStart = '2026-01-06T08:00'; +retainedA.time.completion = 0.3; +retainedA.time.remainingMinutes = 7 * 8 * 60; +const p6Retained = new CPMSolver( + [retainedA, p6Task('B', 5, 'p6-monfri', '2026-01-05T08:00')], + [{ id: 'P6-08-R', predecessorId: 'A', successorId: 'B', type: 'FINISH_START', lagDays: 0 }], + p6MonFri, + [], + { + dataDate: '2026-01-12', + progressMode: 'RETAINED_LOGIC', + schedulingOptions: { totalFloatMode: 'finish', preserveActualDatesInBackwardPass: true }, + }, +).solve(); +eq('P6-geval 08: retained logic plant restwerk vanaf de statusdatum', { + A: axes(p6Retained, 'A'), + B: axes(p6Retained, 'B'), +}, { + A: { es: '2026-01-06T08:00', ef: '2026-01-20T17:00', ls: '2026-01-06T08:00', lf: '2026-01-20T17:00', tf: 0, ff: 0 }, + B: { es: '2026-01-21T08:00', ef: '2026-01-27T17:00', ls: '2026-01-21T08:00', lf: '2026-01-27T17:00', tf: 0, ff: 0 }, +}); + +const completedB = p6Task('B', 11, 'p6-monfri', '2025-12-15T08:00'); +completedB.time.actualStart = '2025-12-15T08:00'; +completedB.time.actualFinish = '2025-12-30T17:00'; +completedB.time.completion = 1; +const p6CompletedSuccessor = new CPMSolver( + [completedB, p6Task('A', 5, 'p6-monfri', '2026-01-05T08:00')], + [{ id: 'P6-09-R', predecessorId: 'A', successorId: 'B', type: 'FINISH_START', lagDays: 0 }], + p6MonFri, + [], + { + dataDate: '2026-01-05', + progressMode: 'RETAINED_LOGIC', + projectStartDate: '2025-12-01', + schedulingOptions: { totalFloatMode: 'finish', preserveActualDatesInBackwardPass: true }, + }, +).solve(); +eq('P6-geval 09: voltooide opvolger trekt de voorganger niet historisch terug', { + A: axes(p6CompletedSuccessor, 'A'), + B: axes(p6CompletedSuccessor, 'B'), +}, { + A: { es: '2026-01-05T08:00', ef: '2026-01-09T17:00', ls: '2026-01-05T08:00', lf: '2026-01-09T17:00', tf: 0, ff: 0 }, + B: { es: '2025-12-15T08:00', ef: '2025-12-30T17:00', ls: '2025-12-15T08:00', lf: '2025-12-30T17:00', tf: 0, ff: 0 }, +}); + +const negativeA = p6Task('A', 8, 'p6-monfri', '2026-01-05T08:00'); +const negativeB = p6Task('B', 4, 'p6-monfri', '2026-01-05T08:00'); +negativeB.constraint = { type: 'FNLT', date: '2026-01-12T08:00' }; +const p6NegativeFloat = new CPMSolver( + [negativeA, negativeB], + [{ id: 'P6-05-R', predecessorId: 'A', successorId: 'B', type: 'FINISH_START', lagDays: 0 }], + p6MonFri, + [], + { + dataDate: '2026-01-05T08:00', + progressMode: 'RETAINED_LOGIC', + projectStartDate: '2026-01-05T08:00', + schedulingOptions: { totalFloatMode: 'finish', clampNegativeFreeFloat: true }, + }, +).solve(); +eq('P6-geval 05: finish-float bewaart de negatieve float op beide ketentaken', { + A: axes(p6NegativeFloat, 'A'), + B: axes(p6NegativeFloat, 'B'), +}, { + A: { es: '2026-01-05T08:00', ef: '2026-01-14T17:00', ls: '2025-12-25T08:00', lf: '2026-01-05T17:00', tf: -7, ff: 0 }, + B: { es: '2026-01-15T08:00', ef: '2026-01-20T17:00', ls: '2026-01-06T08:00', lf: '2026-01-09T17:00', tf: -7, ff: 0 }, +}); + +const completedPred = task('CP', 5, 'pred'); +completedPred.time.actualStart = '2026-06-01'; +completedPred.time.actualFinish = '2026-06-05'; +completedPred.time.completion = 1; +const outOfSequenceSucc = task('IP', 2, 'pred'); +outOfSequenceSucc.time.actualStart = '2026-06-01'; +outOfSequenceSucc.time.completion = 0.5; +outOfSequenceSucc.time.remainingTime = 1; +const completedFloat = new CPMSolver( + [completedPred, outOfSequenceSucc], + [{ id: 'CP-IP', predecessorId: 'CP', successorId: 'IP', type: 'FINISH_START', lagDays: 0 }], + predecessorCalendar, + [], + { + dataDate: '2026-06-01', + progressMode: 'RETAINED_LOGIC', + schedulingOptions: { preserveActualDatesInBackwardPass: true }, + }, +).solve().tasks.get('CP'); +eq('P6-voltooide activiteit rapporteert geen float, ook niet bij out-of-sequence-actuals', { + tf: completedFloat?.totalFloat, + ff: completedFloat?.freeFloat, +}, { tf: 0, ff: 0 }); + +const running = p6Task('RUNNING', 4, 'p6-monfri', '2026-07-06T08:00'); +running.status = 'STARTED'; +running.time.completion = 0.5; +running.time.actualStart = '2026-07-06T08:00'; +const runningDriver = p6Task('RUNNING-DRIVER', 10, 'p6-monfri', '2026-07-06T08:00'); +function solveRunningFloat(mode: 'start' | 'finish' | 'smallest'): unknown { + const solved = new CPMSolver( + [running, runningDriver], + [], + p6MonFri, + [], + { + dataDate: '2026-07-08', + progressMode: 'RETAINED_LOGIC', + schedulingOptions: { totalFloatMode: mode, preserveActualDatesInBackwardPass: true }, + }, + ).solve().tasks.get('RUNNING'); + return solved && { + earlyStart: solved.earlyStart, + lateStart: solved.lateStart, + earlyFinish: solved.earlyFinish, + lateFinish: solved.lateFinish, + totalFloat: solved.totalFloat, + }; +} +eq('lopende taak gebruikt per expliciete modus echt LS-ES, LF-EF of de kleinste', { + start: solveRunningFloat('start'), + finish: solveRunningFloat('finish'), + smallest: solveRunningFloat('smallest'), +}, { + start: { + earlyStart: '2026-07-06T08:00', + lateStart: '2026-07-06T08:00', + earlyFinish: '2026-07-09T17:00', + lateFinish: '2026-07-21T17:00', + totalFloat: 0, + }, + finish: { + earlyStart: '2026-07-06T08:00', + lateStart: '2026-07-06T08:00', + earlyFinish: '2026-07-09T17:00', + lateFinish: '2026-07-21T17:00', + totalFloat: 8, + }, + smallest: { + earlyStart: '2026-07-06T08:00', + lateStart: '2026-07-06T08:00', + earlyFinish: '2026-07-09T17:00', + lateFinish: '2026-07-21T17:00', + totalFloat: 0, + }, +}); + +function xer(projectFields: readonly string[], projectValues: readonly string[], schedule?: { + fields: readonly string[]; + values: readonly string[]; +}): Uint8Array { + const lines = [ + 'ERMHDR\t23.12\t2026-06-01\t\t\t\t\t\tEUR', + '%T\tPROJECT', + `%F\t${projectFields.join('\t')}`, + `%R\t${projectValues.join('\t')}`, + ]; + if (schedule) { + lines.push( + '%T\tSCHEDOPTIONS', + `%F\t${schedule.fields.join('\t')}`, + `%R\t${schedule.values.join('\t')}`, + ); + } + lines.push('%E'); + return new TextEncoder().encode(lines.join('\n')); +} + +function projectEndSource(value: 'Y' | 'N') { + return deriveXerScheduleOptions(parseXerTables(xer( + ['proj_id'], + ['P1'], + { + fields: ['proj_id', 'sched_use_project_end_date_for_float'], + values: ['P1', value], + }, + )), 'P1'); +} +const projectEndTrue = projectEndSource('Y'); +const projectEndFalse = projectEndSource('N'); +eq('projecteindevlag blijft als bronwaarde bewaard en stuurt de solveroptie', { + trueValue: projectEndTrue.retainedSource, + falseValue: projectEndFalse.retainedSource, + disposition: XER_SCHEDOPTIONS_COLUMN_DISPOSITIONS.find( + item => item.field === 'sched_use_project_end_date_for_float', + ), +}, { + trueValue: { sched_use_project_end_date_for_float: true }, + falseValue: { sched_use_project_end_date_for_float: false }, + disposition: { + field: 'sched_use_project_end_date_for_float', + status: 'mapped', + target: 'schedulingOptions.useProjectEndDateForFloat', + }, +}); +const projectEndTrueSolve = new CPMSolver( + endTasks, + [], + endProjectCalendar, + [sixDayCalendar], + { schedulingOptions: projectEndTrue.schedulingOptions }, +).solve(); +const projectEndFalseSolve = new CPMSolver( + endTasks, + [], + endProjectCalendar, + [sixDayCalendar], + { schedulingOptions: projectEndFalse.schedulingOptions }, +).solve(); +eq('projecteindevlag true/false verandert binnen één project geen enkele taakdatum', { + trueResult: [...projectEndTrueSolve.tasks], + falseResult: [...projectEndFalseSolve.tasks], +}, { + trueResult: [...ordinaryEnd.tasks], + falseResult: [...ordinaryEnd.tasks], +}); + +const withoutTable = deriveXerScheduleOptions(parseXerTables(xer( + ['proj_id', 'critical_path_type', 'critical_drtn_hr_cnt'], + ['P1', 'CT_TotFloat', '16'], +)), 'P1', { hoursPerDay: 8, taskCount: 9 }); + +const hostileIndex = indexXerScheduleOptions(parseXerTables(new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-08-25\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tcritical_path_type\tcritical_drtn_hr_cnt', + '%R\tP1\tCT_TotFloat\t8', + '%R\tP2\tCT_TotFloat\t0', + '%T\tSCHEDOPTIONS', + '%F\tproj_id\tsched_float_type\tsched_retained_logic\tsched_progress_override', + '%R\tP1\tFT_SS\tN\tY', + '%R\tP1\tFT_FF\tY\tN', + '%R\tORPHAN\tFT_MIN\tN\tY', + '%E', +].join('\n')))); +const duplicateResult = deriveIndexedXerScheduleOptions(hostileIndex, 'P1'); +const unaffectedResult = deriveIndexedXerScheduleOptions(hostileIndex, 'P2'); +eq('hostile bronarchief bewaart iedere raw rij eenmaal en diagnosticeert duplicate/unmatched', { + rows: hostileIndex.sourceArchive.rows.map(row => [row.table, row.line, row.cells.proj_id]), + unmatched: hostileIndex.sourceArchive.unmatchedScheduleOptionsRowIndexes, + duplicates: hostileIndex.sourceArchive.diagnostics, + duplicateSource: duplicateResult.source, + duplicateOptions: duplicateResult.schedulingOptions, + duplicateDiagnostics: duplicateResult.diagnostics, + unaffectedSource: unaffectedResult.source, +}, { + rows: [ + ['PROJECT', 4, 'P1'], + ['PROJECT', 5, 'P2'], + ['SCHEDOPTIONS', 8, 'P1'], + ['SCHEDOPTIONS', 9, 'P1'], + ['SCHEDOPTIONS', 10, 'ORPHAN'], + ], + unmatched: [4], + duplicates: [{ + code: 'XER_DUPLICATE_SCHEDOPTIONS_PROJ_ID', + projectId: 'P1', + rowIndexes: [2, 3], + lines: [8, 9], + }], + duplicateSource: 'xer-defaults', + duplicateOptions: { + p6Source: 'XER', + lagCalendar: 'predecessor', + criticalDefinition: { mode: 'totalFloat', thresholdHours: 8 }, + totalFloatMode: 'finish', + makeOpenEndedCritical: false, + useExpectedFinishDates: true, + preserveActualDatesInBackwardPass: true, + clampNegativeFreeFloat: true, + p6ZeroDurationUsesPlannedBoundary: true, + p6UseTaskPlannedStartFloor: true, + p6FinishMilestoneBoundaryWindow: true, + p6PreserveActualInstants: true, + p6UseRemainingStartForProgress: false, + p6PreserveZeroDurationConstraintInstants: true, + p6CompletedLateFromRemainingWindow: true, + }, + duplicateDiagnostics: [{ + code: 'XER_DUPLICATE_SCHEDOPTIONS_PROJ_ID', + projectId: 'P1', + rowIndexes: [2, 3], + lines: [8, 9], + }], + unaffectedSource: 'xer-defaults', +}); + +const LINEAR_PROJECTS = 4_000; +const linearProjectRows = Array.from({ length: LINEAR_PROJECTS }, (_, index) => + `%R\tP${index}\tCT_TotFloat\t${index % 9}`).join('\n'); +const linearScheduleRows = Array.from({ length: LINEAR_PROJECTS }, (_, index) => + `%R\tP${index}\t${index % 2 === 0 ? 'FT_SS' : 'FT_FF'}`).join('\n'); +// X6 maakt parserrijen terecht immutable. Deze instrumentatietest meet uitsluitend property-reads +// en krijgt daarom een lokale, mutable meetkopie — nooit de runtime-bron die readers delen. +const linearTables = structuredClone(parseXerTables(new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-08-25\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tcritical_path_type\tcritical_drtn_hr_cnt', + linearProjectRows, + '%T\tSCHEDOPTIONS', + '%F\tproj_id\tsched_float_type', + linearScheduleRows, + '%E', +].join('\n')))); +let projIdReads = 0; +for (const tableName of ['PROJECT', 'SCHEDOPTIONS']) { + for (const row of linearTables.tables.get(tableName)?.rows ?? []) { + const value = row.cells.proj_id; + Object.defineProperty(row.cells, 'proj_id', { + enumerable: true, + get: () => { projIdReads++; return value; }, + }); + } +} +const linearIndex = indexXerScheduleOptions(linearTables); +let deriveArchiveProjectIdReads = 0; +for (const row of linearIndex.sourceArchive.rows) { + const value = row.cells.proj_id; + Object.defineProperty(row.cells, 'proj_id', { + enumerable: true, + get: () => { deriveArchiveProjectIdReads++; return value; }, + }); +} +for (let index = 0; index < LINEAR_PROJECTS; index++) { + deriveIndexedXerScheduleOptions(linearIndex, `P${index}`); +} +eq('PROJECT en SCHEDOPTIONS worden eenmaal lineair geïndexeerd; afleiding is O(1)', + { indexProjectIdReads: projIdReads, deriveArchiveProjectIdReads }, + { indexProjectIdReads: LINEAR_PROJECTS * 2, deriveArchiveProjectIdReads: 0 }); + +eq('expliciete XER-defaultset is brongebonden en compleet', legacyResult(withoutTable), { + source: 'xer-defaults', + progressMode: 'RETAINED_LOGIC', + schedulingOptions: { + p6Source: 'XER', + lagCalendar: 'predecessor', + criticalDefinition: { mode: 'totalFloat', thresholdHours: 16 }, + totalFloatMode: 'finish', + makeOpenEndedCritical: false, + useExpectedFinishDates: true, + preserveActualDatesInBackwardPass: true, + clampNegativeFreeFloat: true, + p6ZeroDurationUsesPlannedBoundary: true, + p6UseTaskPlannedStartFloor: true, + p6FinishMilestoneBoundaryWindow: true, + p6PreserveActualInstants: true, + p6UseRemainingStartForProgress: false, + p6PreserveZeroDurationConstraintInstants: true, + p6CompletedLateFromRemainingWindow: true, + }, + retainedSource: {}, + fallbacks: [], + sourceRows: [{ + table: 'PROJECT', + line: 4, + cells: { + proj_id: 'P1', + critical_path_type: 'CT_TotFloat', + critical_drtn_hr_cnt: '16', + }, + }], +}); +eq('geexporteerde defaults blijven de ongewijzigde nul-drempel leveren', XER_SCHEDULING_DEFAULTS, { + progressMode: 'RETAINED_LOGIC', + schedulingOptions: { + p6Source: 'XER', + lagCalendar: 'predecessor', + criticalDefinition: { mode: 'totalFloat', thresholdHours: 0 }, + totalFloatMode: 'finish', + makeOpenEndedCritical: false, + useExpectedFinishDates: true, + preserveActualDatesInBackwardPass: true, + clampNegativeFreeFloat: true, + p6ZeroDurationUsesPlannedBoundary: true, + p6UseTaskPlannedStartFloor: true, + p6FinishMilestoneBoundaryWindow: true, + p6PreserveActualInstants: true, + p6UseRemainingStartForProgress: false, + p6PreserveZeroDurationConstraintInstants: true, + p6CompletedLateFromRemainingWindow: true, + }, +}); +eq('default 1/8: finish-float', XER_SCHEDULING_DEFAULTS.schedulingOptions.totalFloatMode, 'finish'); +eq('default 2/8: retained logic', XER_SCHEDULING_DEFAULTS.progressMode, 'RETAINED_LOGIC'); +eq('default 3/8: kritiek op totale float met nul uur als P6-bronwaarde', + XER_SCHEDULING_DEFAULTS.schedulingOptions.criticalDefinition, + { mode: 'totalFloat', thresholdHours: 0 }); +eq('default 4/8: open eindes niet automatisch kritiek', + XER_SCHEDULING_DEFAULTS.schedulingOptions.makeOpenEndedCritical, false); +eq('default 5/8: relatielag op de voorgangerskalender', + XER_SCHEDULING_DEFAULTS.schedulingOptions.lagCalendar, 'predecessor'); +eq('default 6/8: verwachte einddatums als bewaard bronbeleid (solverconsumptie volgt in X7)', + XER_SCHEDULING_DEFAULTS.schedulingOptions.useExpectedFinishDates, true); +eq('default 7/8: P6-actuals blijven feiten in de backward-pass', + XER_SCHEDULING_DEFAULTS.schedulingOptions.preserveActualDatesInBackwardPass, true); +eq('default 8/8: P6-vrije-float wordt niet negatief', + XER_SCHEDULING_DEFAULTS.schedulingOptions.clampNegativeFreeFloat, true); +eq('X12-default: geplande nulduurmijlpaalgrens is XER-brongebonden', + XER_SCHEDULING_DEFAULTS.schedulingOptions.p6ZeroDurationUsesPlannedBoundary, true); +eq('X12-default: geplande taakstartvloer is XER-brongebonden', + XER_SCHEDULING_DEFAULTS.schedulingOptions.p6UseTaskPlannedStartFloor, true); +eq('X12-default: finishmijlpaalvenster is XER-brongebonden', + XER_SCHEDULING_DEFAULTS.schedulingOptions.p6FinishMilestoneBoundaryWindow, true); +eq('X12-default: P6-actualinstants blijven minuutexact', + XER_SCHEDULING_DEFAULTS.schedulingOptions.p6PreserveActualInstants, true); +eq('X12-default: nulduurmijlpaal-constraints blijven exacte broninstants', + XER_SCHEDULING_DEFAULTS.schedulingOptions.p6PreserveZeroDurationConstraintInstants, true); + +const fourHourBands = [{ start: 480, end: 720 }]; +const fourHourCalendar: WorkCalendar = { + ...p6Calendar('p6-four-hour', [1, 2, 3, 4, 5]), + workEndHour: 12, + hoursPerDay: 4, + workTime: { + byWeekday: { + 1: fourHourBands, + 2: fourHourBands, + 3: fourHourBands, + 4: fourHourBands, + 5: fourHourBands, + 6: [], + 7: [], + }, + }, +}; +const thresholdTask = p6Task('THRESHOLD-4H', 1, fourHourCalendar.id, '2026-01-05T08:00'); +thresholdTask.time.durationMinutes = 4 * 60; +const thresholdDriver = p6Task('THRESHOLD-DRIVER', 3, p6MonFri.id, '2026-01-05T08:00'); +const thresholdSource = deriveXerScheduleOptions(parseXerTables(xer( + ['proj_id', 'critical_path_type', 'critical_drtn_hr_cnt'], + ['P1', 'CT_TotFloat', '8'], +)), 'P1', { hoursPerDay: 8, taskCount: 2 }); +const thresholdSolve = new CPMSolver( + [thresholdDriver, thresholdTask], + [], + p6MonFri, + [fourHourCalendar], + { schedulingOptions: thresholdSource.schedulingOptions }, +).solve().tasks.get(thresholdTask.id); +eq('P6-drempeluren vergelijken tegen floaturen van de effectieve 4h-taakkalender', { + mapped: thresholdSource.schedulingOptions.criticalDefinition, + totalFloatTaskDays: thresholdSolve?.totalFloat, + totalFloatHours: thresholdSolve && thresholdSolve.totalFloat * fourHourCalendar.hoursPerDay, + isCritical: thresholdSolve?.isCritical, +}, { + mapped: { mode: 'totalFloat', thresholdHours: 8 }, + totalFloatTaskDays: 2, + totalFloatHours: 8, + isCritical: true, +}); + +const defaultLagSolve = new CPMSolver( + lagTasks, + lagSequences, + projectLagCalendar, + [predLagCalendar, succLagCalendar], + { + progressMode: withoutTable.progressMode, + schedulingOptions: withoutTable.schedulingOptions, + }, +).solve(); +eq('XER-default gebruikt aantoonbaar de voorgangerskalender in een multi-kalendernet', + defaultLagSolve.tasks.get('LS')?.earlyStart, '2026-06-08'); + +const mapped = deriveXerScheduleOptions(parseXerTables(xer( + ['proj_id', 'critical_path_type', 'critical_drtn_hr_cnt'], + ['P1', 'ct_drivpath', '0'], + { + fields: [ + 'proj_id', + 'sched_calendar_on_relationship_lag', + 'sched_float_type', + 'sched_retained_logic', + 'sched_progress_override', + 'sched_open_critical_flag', + 'sched_use_project_end_date_for_float', + 'sched_use_expect_end_flag', + 'enable_multiple_longest_path_calc', + 'use_total_float', + 'limit_multiple_longest_path_calc', + 'max_multiple_longest_path', + ], + values: ['P1', 'RCAL_SUCCESSOR', 'ft_ss', 'N', 'y', 'Y', 'n', 'N', 'Y', 'Y', 'Y', '3'], + }, +)), 'P1', { hoursPerDay: 8, taskCount: 9 }); + +eq('bekende enums en vlaggen worden case-insensitief naar bestaande opties gemapt', legacyResult(mapped), { + source: 'schedoptions', + progressMode: 'PROGRESS_OVERRIDE', + schedulingOptions: { + p6Source: 'XER', + lagCalendar: 'successor', + criticalDefinition: { mode: 'longestPath' }, + totalFloatMode: 'start', + makeOpenEndedCritical: true, + useExpectedFinishDates: false, + preserveActualDatesInBackwardPass: true, + clampNegativeFreeFloat: true, + p6ZeroDurationUsesPlannedBoundary: true, + p6UseTaskPlannedStartFloor: true, + p6FinishMilestoneBoundaryWindow: true, + p6PreserveActualInstants: true, + p6UseRemainingStartForProgress: false, + p6PreserveZeroDurationConstraintInstants: true, + p6CompletedLateFromRemainingWindow: false, + useProjectEndDateForFloat: false, + floatPaths: { enabled: true, method: 'TOTAL_FLOAT', maxPaths: 3 }, + }, + retainedSource: { sched_use_project_end_date_for_float: false }, + fallbacks: [], + sourceRows: [ + { + table: 'PROJECT', + line: 4, + cells: { + proj_id: 'P1', + critical_path_type: 'ct_drivpath', + critical_drtn_hr_cnt: '0', + }, + }, + { + table: 'SCHEDOPTIONS', + line: 7, + cells: { + proj_id: 'P1', + sched_calendar_on_relationship_lag: 'RCAL_SUCCESSOR', + sched_float_type: 'ft_ss', + sched_retained_logic: 'N', + sched_progress_override: 'y', + sched_open_critical_flag: 'Y', + sched_use_project_end_date_for_float: 'n', + sched_use_expect_end_flag: 'N', + enable_multiple_longest_path_calc: 'Y', + use_total_float: 'Y', + limit_multiple_longest_path_calc: 'Y', + max_multiple_longest_path: '3', + }, + }, + ], +}); + +const mixedCaseLag = deriveXerScheduleOptions(parseXerTables(xer( + ['proj_id'], + ['P1'], + { + fields: ['proj_id', 'sched_calendar_on_relationship_lag'], + values: ['P1', 'rcal_Successor'], + }, +)), 'P1'); +eq('RCAL_SUCCESSOR en rcal_Successor zijn dezelfde bekende enumwaarde', { + upper: mapped.schedulingOptions.lagCalendar, + mixed: mixedCaseLag.schedulingOptions.lagCalendar, + mixedFallbacks: mixedCaseLag.fallbacks, +}, { + upper: 'successor', + mixed: 'successor', + mixedFallbacks: [], +}); + +const allLagTokens = Object.fromEntries([ + ['rcal_Predecessor', 'predecessor'], + ['RCAL_SUCCESSOR', 'successor'], + ['rcal_24Hour', '24hour'], + ['RCAL_PROJDEFAULT', 'projectDefault'], +].map(([token, expected]) => { + const result = deriveXerScheduleOptions(parseXerTables(xer( + ['proj_id'], + ['P1'], + { + fields: ['proj_id', 'sched_calendar_on_relationship_lag'], + values: ['P1', token], + }, + )), 'P1'); + return [token, { value: result.schedulingOptions.lagCalendar, fallbacks: result.fallbacks, expected }]; +})); +eq('alle vier P6-lagkalendertokens worden case-insensitief zonder fallback gedecodeerd', + allLagTokens, { + rcal_Predecessor: { value: 'predecessor', fallbacks: [], expected: 'predecessor' }, + RCAL_SUCCESSOR: { value: 'successor', fallbacks: [], expected: 'successor' }, + rcal_24Hour: { value: '24hour', fallbacks: [], expected: '24hour' }, + RCAL_PROJDEFAULT: { value: 'projectDefault', fallbacks: [], expected: 'projectDefault' }, + }); + +const unknownFloat = deriveXerScheduleOptions(parseXerTables(xer( + ['proj_id'], + ['P1'], + { + fields: ['proj_id', 'sched_float_type'], + values: ['P1', 'ST_TotalFloat'], + }, +)), 'P1'); +eq('onbekend dialecttoken valt naar finish terug en wordt zichtbaar gerapporteerd', { + totalFloatMode: unknownFloat.schedulingOptions.totalFloatMode, + fallbacks: unknownFloat.fallbacks, +}, { + totalFloatMode: 'finish', + fallbacks: [{ + field: 'sched_float_type', + token: 'ST_TotalFloat', + fallback: 'finish', + line: 7, + }], +}); + +const actualDatesMode = deriveXerScheduleOptions(parseXerTables(xer( + ['proj_id'], + ['P1'], + { + fields: ['proj_id', 'sched_retained_logic', 'sched_progress_override'], + values: ['P1', 'N', 'N'], + }, +)), 'P1'); +eq('niet-ondersteunde P6 Actual Dates-modus valt nooit stil terug', { + progressMode: actualDatesMode.progressMode, + fallbacks: actualDatesMode.fallbacks, +}, { + progressMode: 'RETAINED_LOGIC', + fallbacks: [{ + field: 'sched_retained_logic/sched_progress_override', + token: 'N/N', + fallback: 'RETAINED_LOGIC', + line: 7, + }], +}); +// Her-review 7a, bevinding 3: de completed-late-klem sluit op "niet aantoonbaar retained logic", +// niet op de solver-terugval. N/N (Actual Dates) en Y/Y houden de vlag dus NIET aan; Y/N en +// (leeg)/(leeg) wél. MUTATIEBEWIJS: klem terug op `progressMode !== 'RETAINED_LOGIC'` ⇒ de N/N- +// en Y/Y-regels hieronder slaan ROOD. +const completedLateFlagFor = (retained: string, override: string) => deriveXerScheduleOptions(parseXerTables(xer( + ['proj_id'], + ['P1'], + { + fields: ['proj_id', 'sched_retained_logic', 'sched_progress_override'], + values: ['P1', retained, override], + }, +)), 'P1').schedulingOptions.p6CompletedLateFromRemainingWindow; +eq('completed-late-klem: Actual Dates (N/N) zet de vlag UIT', completedLateFlagFor('N', 'N'), false); +eq('completed-late-klem: tegenstrijdig Y/Y zet de vlag UIT', completedLateFlagFor('Y', 'Y'), false); +eq('completed-late-klem: progress override (N/Y) zet de vlag UIT', completedLateFlagFor('N', 'Y'), false); +eq('completed-late-klem: expliciet retained logic (Y/N) houdt de vlag AAN', completedLateFlagFor('Y', 'N'), true); +eq('completed-late-klem: geen declaratie (leeg/leeg) houdt de vlag AAN (P6-default, rehab-2)', completedLateFlagFor('', ''), true); +eq('completed-late-klem: half paar Y/(leeg) telt als retained logic', completedLateFlagFor('Y', ''), true); +eq('completed-late-klem: onbekend token zet de vlag UIT', completedLateFlagFor('MAYBE', 'N'), false); + +const ifcProject = { + ...createDefaultProject(), + id: 'P6-IFC', + schedulingOptions: withoutTable.schedulingOptions, +}; +const ifcRoundTrip = readIFC(writeIFC({ + project: ifcProject, + calendar: predecessorCalendar, + tasks: [], + sequences: [], + resources: [], + assignments: [], +})); +eq('X5-bronvlaggen round-trippen verliesloos via OPS_SchedulingOptions in IFC', + ifcRoundTrip.project.schedulingOptions, withoutTable.schedulingOptions); + +const expectedColumns = [ + 'enable_multiple_longest_path_calc', + 'key_activity_for_multiple_longest_paths', + 'level_all_rsrc_flag', + 'level_float_thrs_cnt', + 'level_keep_sched_date_flag', + 'level_outer_assign_flag', + 'level_outer_assign_priority', + 'level_over_alloc_pct', + 'level_within_float_flag', + 'levelprioritylist', + 'limit_multiple_longest_path_calc', + 'max_multiple_longest_path', + 'proj_id', + 'sched_calendar_on_relationship_lag', + 'sched_float_type', + 'sched_lag_early_start_flag', + 'sched_open_critical_flag', + 'sched_outer_depend_type', + 'sched_progress_override', + 'sched_retained_logic', + 'sched_setplantoforecast', + 'sched_use_expect_end_flag', + 'sched_use_project_end_date_for_float', + 'schedhash', + 'schedoptions_id', + 'use_total_float', + 'use_total_float_multiple_longest_paths', +] as const; +eq('iedere kolom uit de echte 27-kolommenunion heeft exact één bestemming', + XER_SCHEDOPTIONS_COLUMN_DISPOSITIONS.map(item => item.field).sort(), + [...expectedColumns].sort()); +eq('iedere kolomstatus is gemapt, gemotiveerd genegeerd of expliciet TODO', + XER_SCHEDOPTIONS_COLUMN_DISPOSITIONS.every(item => + item.status === 'mapped' || item.status === 'ignored' || item.status === 'todo'), true); +eq('genegeerde en TODO-kolommen hebben nooit een lege reden', + XER_SCHEDOPTIONS_COLUMN_DISPOSITIONS + .filter(item => item.status !== 'mapped') + .every(item => item.reason.trim().length > 0), true); + +if (diffs.length > 0) { + console.error(`XER-SCHEDOPTIONS: ${diffs.length}/${checks} checks rood`); + for (const diff of diffs) console.error(`XX ${diff}`); + process.exit(1); +} +console.log(`OK XER-SCHEDOPTIONS: ${checks} checks groen`); diff --git a/tests/planning/check-xer-source-archive-chain.ts b/tests/planning/check-xer-source-archive-chain.ts new file mode 100644 index 000000000..2d83d0d35 --- /dev/null +++ b/tests/planning/check-xer-source-archive-chain.ts @@ -0,0 +1,234 @@ +// X9 fixronde 2 — één echte twaalfprojectenketen door reader, store, IFC en headless recovery. +import { isMultiDocumentImport } from '@/services/importTypes'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; +import { writeIFC } from '@/services/ifc/ifcWriter'; +import { clearRecovery, fullRecoverySave, loadRecovery, saveRecovery } from '@/services/recovery/recoveryStore'; +import { readXER } from '@/services/xer/xerReader'; +import { decodeXerSourceArchive, sha256Hex } from '@/services/xerSourceArchive'; +import { useAppStore } from '@/state/appStore'; +import { recoveryInputFromParsed } from '@/state/documentContract'; +import { buildWriteIFCInput } from '@/state/ifcSaveInput'; + +declare const process: { + exit(code: number): never; + resourceUsage(): { maxRSS: number }; +}; + +const failures: string[] = []; +let checks = 0; +const expect = (label: string, condition: boolean) => { + checks += 1; + if (!condition) failures.push(label); +}; +const store = () => useAppStore.getState(); + +// Alleen de browseropslaggrens is gedubbeld; recoveryStore, IFC-writer/reader en restoreDocuments +// draaien echt. Browser/Tauri-UI en de tiensecondenhook blijven conform ruling X11. +const records = new Map(); +const fakeDb = { + objectStoreNames: { contains: () => true }, + createObjectStore: () => undefined, + close: () => undefined, + onversionchange: null as (() => void) | null, + transaction: (_store: string, _mode: string) => { + const tx = { + oncomplete: null as (() => void) | null, + onerror: null as (() => void) | null, + error: null, + objectStore: () => ({ + getAll: () => { + const request = { result: [] as unknown[], error: null, onsuccess: null as (() => void) | null, onerror: null as (() => void) | null }; + queueMicrotask(() => { request.result = [...records.values()]; request.onsuccess?.(); }); + return request; + }, + put: (value: { id: string }) => { + records.set(value.id, structuredClone(value)); + queueMicrotask(() => tx.oncomplete?.()); + }, + delete: (id: string) => { + records.delete(id); + queueMicrotask(() => tx.oncomplete?.()); + }, + }), + }; + return tx; + }, +}; +(globalThis as unknown as { window: object }).window = {}; +(globalThis as unknown as { indexedDB: unknown }).indexedDB = { + open: () => { + const request = { + result: fakeDb, + error: null, + onupgradeneeded: null as (() => void) | null, + onsuccess: null as (() => void) | null, + onerror: null as (() => void) | null, + }; + queueMicrotask(() => { request.onupgradeneeded?.(); request.onsuccess?.(); }); + return request; + }, +}; + +const projectRows: string[] = []; +const scheduleRows: string[] = []; +const taskRows: string[] = []; +const assignmentRows: string[] = []; +const codeRows: string[] = []; +const udfRows: string[] = []; +for (let index = 1; index <= 12; index += 1) { + const suffix = String(index).padStart(2, '0'); + projectRows.push(`%R\tP-${suffix}\tProject ${suffix}\tC\t2026-08-${suffix} 08:00`); + scheduleRows.push(`%R\tSO-${suffix}\tP-${suffix}\tY`); + taskRows.push(`%R\tT-${suffix}\tP-${suffix}\t${suffix}-1\tTaak ${suffix}\tC\t2026-08-${suffix} 08:00\t2026-08-${suffix} 16:00\t8\tTT_Task\tDT_FixedDUR2\tTK_NotStart`); + assignmentRows.push(`%R\tAS-${suffix}\tP-${suffix}\tT-${suffix}\tR-1\tMISSING-ROLE\t1\t8\t8`); + codeRows.push(`%R\tP-${suffix}\tT-${suffix}\tTYPE\tV-${suffix}`); + udfRows.push(`%R\tUF\tP-${suffix}\tT-${suffix}\tBron ${suffix}`); +} +const codeValues = Array.from({ length: 12 }, (_, offset) => { + const suffix = String(offset + 1).padStart(2, '0'); + return `%R\tV-${suffix}\tTYPE\t${suffix}\t${offset + 1}`; +}); +const source = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-08-01\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date', + ...projectRows, + '%T\tSCHEDOPTIONS', + '%F\tschedoptions_id\tproj_id\tsched_use_expect_end_flag', + ...scheduleRows, + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC\tStandaard\t8\t40\t', + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_code\ttask_name\tclndr_id\ttarget_start_date\ttarget_end_date\ttarget_drtn_hr_cnt\ttask_type\tduration_type\tstatus_code', + ...taskRows, + '%T\tRSRC', + '%F\trsrc_id\trsrc_name\trsrc_type\tclndr_id\tdef_qty_per_hr', + '%R\tR-1\tVakman\tRT_Labor\tC\t1', + '%T\tTASKRSRC', + '%F\ttaskrsrc_id\tproj_id\ttask_id\trsrc_id\trole_id\ttarget_qty_per_hr\tremain_qty\ttarget_qty', + ...assignmentRows, + '%T\tACTVTYPE', + '%F\tactv_code_type_id\tactv_code_type\tseq_num', + '%R\tTYPE\tFase\t1', + '%T\tACTVCODE', + '%F\tactv_code_id\tactv_code_type_id\tshort_name\tseq_num', + ...codeValues, + '%T\tTASKACTV', + '%F\tproj_id\ttask_id\tactv_code_type_id\tactv_code_id', + ...codeRows, + '%T\tUDFTYPE', + '%F\tudf_type_id\ttable_name\tudf_type_label\tlogical_data_type', + '%R\tUF\tTASK\tBronveld\tFT_STATICTYPE', + '%T\tUDFVALUE', + '%F\tudf_type_id\tproj_id\tfk_id\tudf_text', + ...udfRows, + '%E', +].join('\r\n')); + +const opened = readXER(source); +if (!isMultiDocumentImport(opened)) throw new Error('Twaalfprojectenfixture opende niet als multi-document'); +expect('1 echte XER-reader opent twaalf afzonderlijke projecten', opened.results.length === 12); +const initialArchive = opened.results[0]?.xerSourceArchive; +if (!initialArchive) throw new Error('Twaalfprojectenfixture mist bronarchief'); +expect('2 alle readerresultaten delen één runtimearchive en één X5-filecache', + new Set(opened.results.map(result => result.xerSourceArchive)).size === 1 + && new Set(opened.results.map(result => result.xer?.scheduleOptions.sourceArchive)).size === 1 + && opened.results.every(result => result.xer?.scheduleOptions.sourceArchive + === initialArchive.readModel.scheduleOptionsSourceArchive)); +expect('2a de echte fixture bevat per project niet-triviale assignment- én issueprovenance', + opened.results.every(result => (result.xer?.resources?.assignments.length ?? 0) === 1 + && (result.xer?.resources?.issues.length ?? 0) >= 1)); + +store().newProject(); +store().applyOpenedImport(opened, { + filePath: null, fileHandle: null, recompute: false, fit: false, + hourDataNotice: false, linkedOpen: false, +}); +const twelve = store().getOpenDocumentPayloads(); +expect('3 applyOpenedImport levert twaalf echte documenttabs', twelve.length === 12); +for (const document of twelve) store().switchDocument(document.id); +expect('4 documentwissel behoudt selector en exact hetzelfde archiveobject', + store().xerSourceArchive === initialArchive && store().xerSourceProjectId === 'P-12'); + +const duplicateId = store().duplicateDocument('Project 12 duplicate'); +for (let index = 0; index < 100; index += 1) store().setProject({ description: `keten-${index}` }); +for (let index = 0; index < 100; index += 1) store().undo(); +expect('5 honderd undo-stappen herstellen de documentspecifieke semantiek en houden archive-ref vast', + store().project.description === '' && store().xerSourceArchive === initialArchive); +for (let index = 0; index < 100; index += 1) store().redo(); +expect('6 honderd redo-stappen herstellen alleen de duplicate-semantiek', store().project.description === 'keten-99'); +const thirteen = store().getOpenDocumentPayloads(); +expect('7 twaalf projecten plus duplicate delen één archive/readmodel/catalogusgrafiek', + thirteen.length === 13 + && new Set(thirteen.map(document => document.payload.xerSourceArchive)).size === 1 + && new Set(thirteen.map(document => document.payload.xerImportMetadata?.scheduleOptions.sourceArchive)).size === 1 + && new Set(thirteen.map(document => document.payload.xerImportMetadata?.resources?.catalog)).size === 1 + && new Set(thirteen.map(document => document.payload.xerImportMetadata?.metadata?.catalog)).size === 1); +expect('8 documentspecifieke semantiek blijft geïsoleerd van de duplicate', + thirteen.find(document => document.id === duplicateId)?.payload.project.description === 'keten-99' + && thirteen.filter(document => document.id !== duplicateId).every(document => document.payload.project.description === '')); + +const ifcs = thirteen.map(document => writeIFC(buildWriteIFCInput(document.payload))); +const independentlyReopened = ifcs.map(ifc => readIFC(ifc)); +expect('9 iedere zelfstandige IFC is zonder siblingstate heropenbaar met exacte bytes en SHA', + independentlyReopened.every(result => result.xerSourceArchive !== undefined + && sha256Hex(decodeXerSourceArchive(result.xerSourceArchive)) === initialArchive.sha256 + && result.xerSourceArchive.sha256 === initialArchive.sha256 + && result.xerSourceProjectId === result.xer?.sourceProjectId)); + +await clearRecovery(); +await saveRecovery(fullRecoverySave(thirteen[0]!.id, thirteen.map((document, index) => ({ + id: document.id, + ifc: ifcs[index]!, + filePath: null, + isDirty: true, + datesAsRecorded: false, +})))); +const loaded = await loadRecovery(); +expect('10 headless recoveryStore laadt alle dertien zelfstandige IFC-snapshots', loaded.docs.length === 13); +const recoveryInputs = loaded.docs.map(document => recoveryInputFromParsed( + readIFC(document.ifc), + { id: document.id, filePath: null, isDirty: true, datesAsRecorded: false }, +)); +store().restoreDocuments(recoveryInputs, duplicateId); +const recovered = store().getOpenDocumentPayloads(); +const canonicalArchive = recovered[0]?.payload.xerSourceArchive; +expect('11 recovery canonicaliseert alle bytes en caches naar één runtimegrafiek', + canonicalArchive !== null && canonicalArchive !== undefined + && new Set(recovered.map(document => document.payload.xerSourceArchive)).size === 1 + && new Set(recovered.map(document => document.payload.xerImportMetadata?.scheduleOptions.sourceArchive)).size === 1 + && new Set(recovered.map(document => document.payload.xerImportMetadata?.resources?.catalog)).size === 1 + && new Set(recovered.map(document => document.payload.xerImportMetadata?.metadata?.catalog)).size === 1); +expect('12 recovery bindt assignment/issues en X5-bronrijen aan de canonieke selectorview', + recovered.every(document => { + const metadata = document.payload.xerImportMetadata; + const selector = metadata?.sourceProjectId; + const view = selector ? canonicalArchive?.diagnostics.documentViews[selector] : undefined; + return metadata !== null && metadata.resources !== undefined && view?.resources !== undefined + && metadata.resources.assignments === view.resources.assignments + && metadata.resources.issues === view.resources.issues + && metadata.scheduleOptions.sourceArchive === canonicalArchive?.readModel.scheduleOptionsSourceArchive + && metadata.scheduleOptions.sourceRows.every((row, index) => + row === canonicalArchive?.readModel.scheduleOptionsSourceArchive.rows[ + metadata.scheduleOptions.sourceRowIndexes[index]! + ]); + })); +expect('13 recovery bewaart documentspecifieke semantiek en projectselectie', + recovered.find(document => document.id === duplicateId)?.payload.project.description === 'keten-99' + && recovered.filter(document => document.id !== duplicateId).every(document => document.payload.project.description === '') + && new Set(recovered.map(document => document.payload.xerSourceProjectId)).size === 12); +await clearRecovery(); + +const peakRssKiB = process.resourceUsage().maxRSS; +expect('14 schaalproef rapporteert OS-gemeten peak RSS zonder absolute tijd- of groottelimiet', + Number.isFinite(peakRssKiB) && peakRssKiB > 0); +console.log(`X9 12-project chain: peak-rss=${peakRssKiB}KiB; archives=${new Set(recovered.map(document => document.payload.xerSourceArchive)).size}; x5-caches=${new Set(recovered.map(document => document.payload.xerImportMetadata?.scheduleOptions.sourceArchive)).size}`); + +if (failures.length === 0) { + console.log(`OK xer-source-archive-chain: alle checks groen (${checks})`); + process.exit(0); +} +console.log(`XX xer-source-archive-chain: ${failures.length} afwijking(en) van ${checks}`); +for (const failure of failures) console.log(` - ${failure}`); +process.exit(1); diff --git a/tests/planning/check-xer-source-archive-lifecycle.ts b/tests/planning/check-xer-source-archive-lifecycle.ts new file mode 100644 index 000000000..a495a5f00 --- /dev/null +++ b/tests/planning/check-xer-source-archive-lifecycle.ts @@ -0,0 +1,208 @@ +// X9 — één archive-ref door documenttabs, duplicate, undo/redo en recovery. +import { createDefaultCalendar } from '@/engine/calendar/defaultCalendar'; +import { readFileSync } from 'node:fs'; +import { + createEmptyXerArchiveDiagnostics, + createEmptyXerArchiveReadModel, + createXerSourceArchive, + decodeXerSourceArchive, + sha256Hex, + withXerArchiveDocumentView, + XER_SOURCE_ARCHIVE_CHUNK_BYTES, +} from '@/services/xerSourceArchive'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; +import { writeIFC } from '@/services/ifc/ifcWriter'; +import { useAppStore } from '@/state/appStore'; +import { recoveryInputFromParsed } from '@/state/documentContract'; +import { buildWriteIFCInput } from '@/state/ifcSaveInput'; +import { createDefaultProject } from '@/state/defaults'; +import { isMultiDocumentImport } from '@/services/importTypes'; +import { readXER } from '@/services/xer/xerReader'; +import type { Task } from '@/types/task'; +import { createDefaultTaskTime } from '@/utils/taskDefaults'; +import { clearRecovery, fullRecoverySave, loadRecovery, saveRecovery } from '@/services/recovery/recoveryStore'; + +declare const process: { + exit(code: number): never; + resourceUsage(): { maxRSS: number }; +}; + +const failures: string[] = []; +let checks = 0; +const expect = (label: string, condition: boolean) => { + checks += 1; + if (!condition) failures.push(label); +}; +const store = () => useAppStore.getState(); + +// Browser-API is de enige niet-headless rand. Deze minimale IDB-dubbel bewaart echte records en +// transacties; saveRecovery/loadRecovery/clearRecovery zelf draaien ongewijzigd door hun publieke grens. +const idbRecords = new Map(); +const fakeDb = { + objectStoreNames: { contains: () => true }, + createObjectStore: () => undefined, + close: () => undefined, + onversionchange: null as (() => void) | null, + transaction: (_store: string, _mode: string) => { + const tx = { + oncomplete: null as (() => void) | null, + onerror: null as (() => void) | null, + error: null, + objectStore: () => ({ + getAll: () => { + const request = { result: [] as unknown[], error: null, onsuccess: null as (() => void) | null, onerror: null as (() => void) | null }; + queueMicrotask(() => { request.result = [...idbRecords.values()]; request.onsuccess?.(); }); + return request; + }, + put: (value: { id: string }) => { + idbRecords.set(value.id, structuredClone(value)); + queueMicrotask(() => tx.oncomplete?.()); + }, + delete: (id: string) => { + idbRecords.delete(id); + queueMicrotask(() => tx.oncomplete?.()); + }, + }), + }; + return tx; + }, +}; +const fakeIndexedDb = { + open: () => { + const request = { + result: fakeDb, + error: null, + onupgradeneeded: null as (() => void) | null, + onsuccess: null as (() => void) | null, + onerror: null as (() => void) | null, + }; + queueMicrotask(() => { request.onupgradeneeded?.(); request.onsuccess?.(); }); + return request; + }, +}; +(globalThis as unknown as { window: object }).window = {}; +(globalThis as unknown as { indexedDB: unknown }).indexedDB = fakeIndexedDb; + +const bytes = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-08-01\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date', + '%R\tP-X9\tX9 lifecycle\tC\t2032-01-05 08:00', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC\tStandaard\t8\t40\t', + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_code\ttask_name\tclndr_id\ttarget_start_date\ttarget_end_date\ttarget_drtn_hr_cnt\ttask_type\tduration_type\tstatus_code', + '%R\tT-X9\tP-X9\tX9-1\tX9 taak\tC\t2032-01-05 08:00\t2032-01-05 16:00\t8\tTT_Task\tDT_FixedDUR2\tTK_NotStart', + '%T\tUNKNOWN', + '%F\tpayload', + `%R\t${'x'.repeat(XER_SOURCE_ARCHIVE_CHUNK_BYTES * 3 + 17)}`, + '%E', +].join('\r\n')); +const opened = readXER(bytes); +if (isMultiDocumentImport(opened) || !opened.xerSourceArchive) { + throw new Error('X9-lifecyclefixture moet één geldig XER-bronarchief opleveren'); +} +const sourceArchive = opened.xerSourceArchive; +const baseArchive = createXerSourceArchive(bytes, { + encoding: sourceArchive.encoding, + bom: sourceArchive.bom, + newline: sourceArchive.newline, + diagnostics: { ...sourceArchive.diagnostics, opaqueExtensions: { fixture: { typed: true } } }, + readModel: sourceArchive.readModel, +}); +const project = createDefaultProject(); +project.id = 'x9-lifecycle'; project.name = 'X9 lifecycle'; +const calendar = createDefaultCalendar(); +project.calendarId = calendar.id; +const xer = { + sourceProjectId: 'P-X9', + defaultCurrencyCode: 'EUR', + tableReport: { encoding: 'windows-1252' as const, endMarkerSeen: true, issues: [], unknownTables: [] }, + calendarIssues: [], enumFallbacks: [], + scheduleOptions: { source: 'xer-defaults' as const, retainedSource: {}, fallbacks: [], diagnostics: [], sourceArchive: { rows: [], unmatchedScheduleOptionsRowIndexes: [], diagnostics: [] }, sourceRowIndexes: [], sourceRows: [] }, + externalRelations: [], externalLinks: [], + report: { projectsSeen: 1, documentsOpened: 1, emptyProjectsSkipped: 0, baselineProjectsExcluded: 0, baselinesMaterialized: 0, danglingBaselineReferences: 0, externalLinksPreserved: 0, baselineExclusionReverted: false, baselineFallbackReasons: [] }, +}; +const archive = withXerArchiveDocumentView(baseArchive, xer); + +store().newProject(); +store().applyOpenedImport({ + project, calendar, + tasks: [{ id: 'x9-task', name: 'X9 taak', parentId: null, childIds: [], time: createDefaultTaskTime('2032-01-05', 1) } as unknown as Task], + sequences: [], resources: [], assignments: [], xerSourceArchive: archive, xer, +}, { filePath: null, fileHandle: null, recompute: false, fit: false, hourDataNotice: false, linkedOpen: false }); +for (let index = 0; index < 11; index += 1) store().duplicateDocument(); +const twelve = store().getOpenDocumentPayloads(); +store().duplicateDocument(); +const thirteen = store().getOpenDocumentPayloads(); +expect('1 twaalf documenten plus een duplicate zijn geopend', twelve.length === 12 && thirteen.length === 13); +expect('2 alle tabs delen exact één archiefobject', new Set(thirteen.map(doc => doc.payload.xerSourceArchive)).size === 1); +expect('3 ook de base64-chunkarray is één gedeelde ref', thirteen.every(doc => doc.payload.xerSourceArchive?.byteChunks === archive.byteChunks)); +expect('3a documentcontract houdt de gedeelde archive-ref expliciet buiten undo-snapshots', + readFileSync(new URL('../../src/state/documentContract.ts', import.meta.url), 'utf8') + .includes("field({ key: 'xerSourceArchive', get: (s) => s.xerSourceArchive, set: (s, v) => { s.xerSourceArchive = v; }, fresh: () => null, snapshot: 'none'")); + +for (let index = 0; index < 100; index += 1) store().setProject({ description: `wijziging-${index}` }); +for (let index = 0; index < 100; index += 1) store().undo(); +expect('4 honderd undo-stappen veranderen de snapshot-none archive-ref niet', + store().xerSourceArchive === archive && store().project.description !== 'wijziging-99'); +store().redo(); +expect('5 redo verandert evenmin de archive-ref', store().xerSourceArchive === archive); + +const serializedSnapshots = thirteen.map(doc => writeIFC(buildWriteIFCInput(doc.payload))); +await clearRecovery(); +await saveRecovery(fullRecoverySave(thirteen[0]!.id, thirteen.map((doc, index) => ({ + id: doc.id, + ifc: serializedSnapshots[index]!, + filePath: null, + isDirty: true, + datesAsRecorded: false, +})))); +const loadedRecovery = await loadRecovery(); +expect('5a publieke headless recovery-backend bewaart alle zelfstandige IFC-snapshots', + loadedRecovery.docs.length === 13 && loadedRecovery.activeDocumentId === thirteen[0]!.id); +const parsedSnapshots = loadedRecovery.docs.map(doc => readIFC(doc.ifc)); +const snapshots = parsedSnapshots.map((parsed, index) => { + return recoveryInputFromParsed(parsed, { id: loadedRecovery.docs[index]!.id, filePath: null, isDirty: true, datesAsRecorded: false }); +}); +store().restoreDocuments(snapshots, snapshots[0]!.id); +const recovered = store().getOpenDocumentPayloads(); +const recoveredArchives = recovered.map(doc => doc.payload.xerSourceArchive).filter((value): value is NonNullable => value !== null); +expect('6 recovery dedupliceert zelfstandige IFC-archieven tot één runtimeobject', new Set(recoveredArchives).size === 1); +expect('7 recovery behoudt byte-identieke bron, hash en éénzelfde bronafgeleide diagnosticsgraaf', + recoveredArchives.length === 13 + && sha256Hex(decodeXerSourceArchive(recoveredArchives[0]!)) === sha256Hex(bytes) + && parsedSnapshots.every(parsed => JSON.stringify(parsed.xerSourceArchive?.diagnostics) === JSON.stringify(parsedSnapshots[0]!.xerSourceArchive?.diagnostics))); +await clearRecovery(); +expect('7a publieke recovery-cleargrens verwijdert de headless records', (await loadRecovery()).docs.length === 0); +const peakRssKiB = process.resourceUsage().maxRSS; +expect('8 OS-gemeten peak RSS is beschikbaar zonder tijdsdrempel', Number.isFinite(peakRssKiB) && peakRssKiB > 0); +console.log(`X9 archive lifecycle: peak-rss=${peakRssKiB}KiB; unique-runtime-archives=${new Set(recoveredArchives).size}`); + +const collisionArchive = withXerArchiveDocumentView(createXerSourceArchive(bytes, { + encoding: archive.encoding, + bom: archive.bom, + newline: archive.newline, + diagnostics: { ...createEmptyXerArchiveDiagnostics(), opaqueExtensions: { fixture: { typed: false } } }, + readModel: createEmptyXerArchiveReadModel(), +}), xer); +expect('9 collisionfixture heeft bewust dezelfde snelle hash+lengtesleutel maar andere metadata', + collisionArchive.sha256 === archive.sha256 + && collisionArchive.byteLength === archive.byteLength + && JSON.stringify(collisionArchive.diagnostics) !== JSON.stringify(archive.diagnostics)); +store().restoreDocuments([ + { ...snapshots[0]!, id: 'collision-a', xerSourceArchive: archive }, + { ...snapshots[1]!, id: 'collision-b', xerSourceArchive: collisionArchive }, +], 'collision-a'); +const collisionDocs = store().getOpenDocumentPayloads(); +expect('10 recovery deelt een hash+lengte-hit pas na inhoudelijke manifest/chunk/metadatavergelijking', + collisionDocs[0]?.payload.xerSourceArchive !== collisionDocs[1]?.payload.xerSourceArchive); + +if (failures.length === 0) { + console.log(`OK xer-source-archive-lifecycle: alle checks groen (${checks})`); + process.exit(0); +} +console.log(`XX xer-source-archive-lifecycle: ${failures.length} afwijking(en)`); +for (const failure of failures) console.log(` - ${failure}`); +process.exit(1); diff --git a/tests/planning/check-xer-source-archive.ts b/tests/planning/check-xer-source-archive.ts new file mode 100644 index 000000000..2ef70aa1d --- /dev/null +++ b/tests/planning/check-xer-source-archive.ts @@ -0,0 +1,184 @@ +// X9 — onafhankelijke contractcheck voor het canonieke, byte-exacte XER-bronarchief. +// +// Deze test gebruikt doelbewust ruwe bytes (geen XER-reader), zodat de productmapper niet zijn +// eigen orakel kan leveren. De module bestond bij de eerste rode run nog niet. +import { + XER_SOURCE_ARCHIVE_CHUNK_BYTES, + createEmptyXerArchiveDiagnostics, + createEmptyXerArchiveDocumentView, + createEmptyXerArchiveReadModel, + createXerSourceArchive, + decodeXerSourceArchive, + detectXerSourcePresentation, + sha256Hex, + withXerArchiveDocumentView, +} from '@/services/xerSourceArchive'; + +declare const process: { exit(code: number): never }; + +const failures: string[] = []; +let checks = 0; +const equal = (label: string, actual: unknown, expected: unknown) => { + checks++; + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + failures.push(`${label}: verwacht ${JSON.stringify(expected)}, kreeg ${JSON.stringify(actual)}`); + } +}; +const truthy = (label: string, condition: boolean) => { + checks++; + if (!condition) failures.push(`${label}: verwacht waar, kreeg onwaar`); +}; + +const encoder = new TextEncoder(); +const raw = new Uint8Array([ + 0xef, 0xbb, 0xbf, + ...encoder.encode('%T\tPROJECT\r\n%F\tproj_id\tproj_short_name\r\n%R\tP-1\tOlé\r\n%T\tUNKNOWN\r\n%F\ta\r\n%R\t¤\r\n%E\r\n'), +]); +const documentView = createEmptyXerArchiveDocumentView('P-1'); +documentView.tableReport.encoding = 'windows-1252'; +documentView.tableReport.issues.push({ code: 'XER_ROW_FIELD_COUNT_MISMATCH', line: 4, expected: 2, actual: 1 }); +documentView.tableReport.unknownTables.push({ name: 'UNKNOWN', rows: 1 }); +documentView.calendarIssues.push({ code: 'XER_CALENDAR_RECOVERED', calendarId: 'C', line: 5, reason: 'fixture', resolution: 'RECOVERED' }); +documentView.enumFallbacks.push({ family: 'durationType', token: 'ST_TotalFloat', fallback: 'DT_FixedDUR2', table: 'TASK', field: 'duration_type', line: 4 }); +const diagnostics = { + ...createEmptyXerArchiveDiagnostics(), + file: { + ...createEmptyXerArchiveDiagnostics().file, + tableReport: documentView.tableReport, + }, + documentViews: { 'P-1': documentView }, +}; +const expectedDiagnostics = structuredClone(diagnostics); + +const archive = createXerSourceArchive(raw, { + schemaVersion: 1, + format: 'primavera-p6-xer', + encoding: 'windows-1252', + bom: 'utf-8', + newline: 'crlf', + diagnostics, + readModel: createEmptyXerArchiveReadModel(), +}); + +equal('1 chunkgrootte is bindend', XER_SOURCE_ARCHIVE_CHUNK_BYTES, 196608); +equal('2 bytes komen byte-identiek terug', [...decodeXerSourceArchive(archive)], [...raw]); +equal('3 lengte komt uit de oorspronkelijke bytes', archive.byteLength, raw.byteLength); +equal('4 schema en bronvorm blijven expliciet', [archive.schemaVersion, archive.format, archive.encoding, archive.bom, archive.newline], [1, 'primavera-p6-xer', 'windows-1252', 'utf-8', 'crlf']); +equal('5 onbekende tabeldiagnostic blijft getypeerd', archive.diagnostics, diagnostics); +truthy('6 archive en alle diagnostische containers zijn diep immutable', + Object.isFrozen(archive) && Object.isFrozen(archive.byteChunks) && Object.isFrozen(archive.diagnostics) + && Object.isFrozen(archive.diagnostics.file) + && Object.isFrozen(archive.diagnostics.file.tableReport) + && Object.isFrozen(archive.diagnostics.file.tableReport.issues) + && Object.isFrozen(archive.diagnostics.file.tableReport.unknownTables) + && Object.isFrozen(archive.diagnostics.documentViews['P-1']) + && Object.isFrozen(archive.diagnostics.documentViews['P-1']!.calendarIssues) + && Object.isFrozen(archive.diagnostics.documentViews['P-1']!.enumFallbacks)); +truthy('7 diagnostics zijn geen mutable invoeralias', archive.diagnostics !== diagnostics); +let callerCanMutateDiagnostics = true; +try { + diagnostics.file.tableReport.issues.push({ code: 'XER_UNKNOWN_RECORD', line: 99 }); + diagnostics.documentViews['P-1']!.calendarIssues.push({ code: 'LATE_MUTATION', calendarId: 'C', line: 99, reason: 'late', resolution: 'REJECTED' }); +} catch { callerCanMutateDiagnostics = false; } +truthy('8 aanroeper behoudt zijn eigen mutable diagnostics-object', callerCanMutateDiagnostics); +equal('9 latere invoermutatie raakt het archive niet', archive.diagnostics, expectedDiagnostics); +truthy('10 SHA-256 is lowercase hex over bytes', /^[0-9a-f]{64}$/.test(archive.sha256)); +equal('10a SHA-256 volgt het onafhankelijke bekende testvector-orakel', + sha256Hex(encoder.encode('abc')), 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'); +const large = new Uint8Array(XER_SOURCE_ARCHIVE_CHUNK_BYTES * 4 + 31); +large.fill(0xa5); +const largeArchive = createXerSourceArchive(large, { + schemaVersion: 1, format: 'primavera-p6-xer', encoding: 'windows-1252', bom: 'none', newline: 'lf', + diagnostics: createEmptyXerArchiveDiagnostics(), readModel: createEmptyXerArchiveReadModel(), +}); +equal('11 groot archief kent geen beleidsmatige bestandsgroottegrens', [...decodeXerSourceArchive(largeArchive)], [...large]); + +const blankLegacyFieldReadModel = { + ...createEmptyXerArchiveReadModel(), + taskSourceRowsByProject: { + '': [{ line: 6, cells: { '': 'exact bewaarde celtekst', task_id: 'T-1' } }], + }, +}; +let blankLegacyFieldArchive: ReturnType | undefined; +try { + blankLegacyFieldArchive = createXerSourceArchive(raw, { + schemaVersion: 1, + format: 'primavera-p6-xer', + encoding: 'windows-1252', + bom: 'utf-8', + newline: 'crlf', + diagnostics: createEmptyXerArchiveDiagnostics(), + readModel: blankLegacyFieldReadModel, + }); +} catch { + // De assertion hieronder rapporteert de compatibiliteitsregressie als gewone RED-check. +} +equal('12 lege legacy veldnaam en oorspronkelijke celtekst blijven in het bronreadmodel behouden', + blankLegacyFieldArchive?.readModel.taskSourceRowsByProject['']?.[0]?.cells[''], + 'exact bewaarde celtekst'); + +const utf16 = (value: string, endian: 'le' | 'be') => { + const result = new Uint8Array(2 + value.length * 2); + result.set(endian === 'le' ? [0xff, 0xfe] : [0xfe, 0xff]); + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + result[2 + index * 2 + (endian === 'le' ? 0 : 1)] = code & 0xff; + result[2 + index * 2 + (endian === 'le' ? 1 : 0)] = code >>> 8; + } + return result; +}; +const utf16LePresentation = detectXerSourcePresentation(utf16('ERMHDR\r\n%T\tTASK\r\n%E\r\n', 'le')); +equal('13 UTF-16LE-BOM wordt vóór newlineclassificatie gedecodeerd', + [utf16LePresentation.encoding, utf16LePresentation.bom, utf16LePresentation.newline], + ['utf-16le', 'utf-16le', 'crlf']); +const utf16BePresentation = detectXerSourcePresentation(utf16('ERMHDR\n%T\tTASK\n%E\n', 'be')); +equal('14 UTF-16BE-BOM wordt vóór newlineclassificatie gedecodeerd', + [utf16BePresentation.encoding, utf16BePresentation.bom, utf16BePresentation.newline], + ['utf-16be', 'utf-16be', 'lf']); + +const unattachedArchive = createXerSourceArchive(new Uint8Array([1, 2, 3]), { + encoding: 'windows-1252', bom: 'none', newline: 'none', + diagnostics: createEmptyXerArchiveDiagnostics(), readModel: createEmptyXerArchiveReadModel(), +}); +const callerView = createEmptyXerArchiveDocumentView('CALLER'); +const callerAssignments: never[] = []; +const callerIssues: never[] = []; +const callerMetadata = { + ...callerView, + scheduleOptions: { + ...callerView.scheduleOptions, + sourceArchive: createEmptyXerArchiveReadModel().scheduleOptionsSourceArchive, + sourceRows: [], + }, + resources: { + catalog: createEmptyXerArchiveReadModel().resourceCatalog, + assignments: callerAssignments, + issues: callerIssues, + }, +}; +const attachedArchive = withXerArchiveDocumentView(unattachedArchive, callerMetadata); +let callerArraysRemainMutable = true; +try { + callerAssignments.push({ marker: 'late assignment' } as never); + callerIssues.push({ marker: 'late issue' } as never); +} catch { + callerArraysRemainMutable = false; +} +truthy('15 missing-view-pad bevriest geen caller-owned assignment- of issuearray', + callerArraysRemainMutable + && !Object.isFrozen(callerAssignments) + && !Object.isFrozen(callerIssues)); +equal('16 latere caller-mutatie werkt niet als alias door in de immutable documentview', + [ + attachedArchive.diagnostics.documentViews.CALLER?.resources?.assignments.length, + attachedArchive.diagnostics.documentViews.CALLER?.resources?.issues.length, + ], + [0, 0]); + +if (failures.length === 0) { + console.log(`OK xer-source-archive: alle checks groen (${checks})`); + process.exit(0); +} +console.log(`XX xer-source-archive: ${failures.length} afwijking(en) van ${checks}`); +for (const failure of failures) console.log(` - ${failure}`); +process.exit(1); diff --git a/tests/planning/check-xer-source-retention.ts b/tests/planning/check-xer-source-retention.ts new file mode 100644 index 000000000..97dfc2b2b --- /dev/null +++ b/tests/planning/check-xer-source-retention.ts @@ -0,0 +1,729 @@ +// P0 XER-bronretentiecontract — corpusloos, over echte gebruikersgrenzen en met onafhankelijke +// STEP-inspectie. Mutanten veranderen geldige inhoud; elke grens moet daardoor inhoudelijk rood zijn. +import { activeImportResult } from '@/services/importTypes'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; +import { writeIFC } from '@/services/ifc/ifcWriter'; +import { clearRecovery, fullRecoverySave, loadRecovery, saveRecovery } from '@/services/recovery/recoveryStore'; +import { readXER } from '@/services/xer/xerReader'; +import { detectXerExportLoss } from '@/services/xerExportLoss'; +import { decodeXerSourceArchive, sha256Hex } from '@/services/xerSourceArchive'; +import { createAppStore, type AppState } from '@/state/appStore'; +import { payloadFromImport, recoveryInputFromParsed } from '@/state/documentContract'; +import { buildWriteIFCInput } from '@/state/ifcSaveInput'; + +declare const process: { env: Record; exit(code: number): never }; + +const mutantNames = [ + 'import-hydrate', + 'run-cpm', + 'snapshot-undo-redo', + 'document-switch', + 'duplicate', + 'recovery-write-load-restore', + 'ifc-writer-reader', + 'independent-step', +] as const; +type MutantName = typeof mutantNames[number]; +const mutant = process.env.OPS_XER_RETENTION_MUTANT as MutantName | undefined; +if (mutant && !mutantNames.includes(mutant)) throw new Error(`Onbekende retentiemutant: ${mutant}`); + +const failures: string[] = []; +let checks = 0; +const normalize = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(normalize); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.keys(value).sort().map(key => [ + key, normalize((value as Record)[key]), + ])); + } + return value; +}; +const firstDifference = (actual: unknown, expected: unknown, path = '$'): string => { + const left = normalize(actual); + const right = normalize(expected); + if (JSON.stringify(left) === JSON.stringify(right)) return path; + if (Array.isArray(left) && Array.isArray(right)) { + const size = Math.max(left.length, right.length); + for (let index = 0; index < size; index += 1) { + if (JSON.stringify(left[index]) !== JSON.stringify(right[index])) { + return firstDifference(left[index], right[index], `${path}[${index}]`); + } + } + } + if (left && right && typeof left === 'object' && typeof right === 'object') { + const keys = [...new Set([...Object.keys(left), ...Object.keys(right)])].sort(); + for (const key of keys) { + const leftValue = (left as Record)[key]; + const rightValue = (right as Record)[key]; + if (JSON.stringify(leftValue) !== JSON.stringify(rightValue)) { + return firstDifference(leftValue, rightValue, `${path}.${key}`); + } + } + } + return path; +}; +const equal = (label: string, actual: unknown, expected: unknown) => { + checks += 1; + if (JSON.stringify(normalize(actual)) !== JSON.stringify(normalize(expected))) { + failures.push(`${label}: inhoud wijkt af bij ${firstDifference(actual, expected)}`); + } +}; +const truthy = (label: string, value: boolean) => { + checks += 1; + if (!value) failures.push(`${label}: voorwaarde is niet vervuld`); +}; + +// De browserrand is nep; recoveryStore, IFC-write/read en documentrestore zijn echt. +const records = new Map(); +const fakeDb = { + objectStoreNames: { contains: () => true }, + createObjectStore: () => undefined, + close: () => undefined, + onversionchange: null as (() => void) | null, + transaction: (_store: string, _mode: string) => { + const tx = { + oncomplete: null as (() => void) | null, + onerror: null as (() => void) | null, + onabort: null as (() => void) | null, + error: null, + abort: () => tx.onabort?.(), + objectStore: () => ({ + getAll: () => { + const request = { + result: [] as unknown[], + error: null, + onsuccess: null as (() => void) | null, + onerror: null as (() => void) | null, + }; + queueMicrotask(() => { + request.result = [...records.values()]; + request.onsuccess?.(); + }); + return request; + }, + put: (value: { id: string }) => { + records.set(value.id, structuredClone(value)); + queueMicrotask(() => tx.oncomplete?.()); + }, + delete: (id: string) => { + records.delete(id); + queueMicrotask(() => tx.oncomplete?.()); + }, + }), + }; + return tx; + }, +}; +(globalThis as unknown as { window: object }).window = {}; +(globalThis as unknown as { indexedDB: unknown }).indexedDB = { + open: () => { + const request = { + result: fakeDb, + error: null, + onupgradeneeded: null as (() => void) | null, + onsuccess: null as (() => void) | null, + onerror: null as (() => void) | null, + }; + queueMicrotask(() => { + request.onupgradeneeded?.(); + request.onsuccess?.(); + }); + return request; + }, +}; + +// Drie bronchunks, een onbekende tabel en een onbekend taakveld. De kalenderpayload is echte P6-XER: +// dubbele/non-work exceptions leveren p6NonWorkPenaltyDates op; de FS-relatie begint exact op de +// target-finishgrens van de voorganger en levert zo de sequence-boundaryvlag op. +const unknownPayload = 'onbekende-bronwaarde-'.repeat(24_000); +const bytes = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2025-12-31\t\t\t\t\t\tEUR', + '%T\tCURRTYPE', + '%F\tcurr_short_name\tdecimal_symbol\tdigit_group_symbol', + '%R\tEUR\tcomma\tperiod', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date', + '%R\tP-RET\tBronretentie\tC-RET\t2025-12-31 08:00', + '%T\tSCHEDOPTIONS', + '%F\tschedoptions_id\tproj_id\tsched_use_expect_end_flag', + '%R\tSO-RET\tP-RET\tY', + '%R\tSO-DUP\tP-RET\tY', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC-RET\tBronkalender\tCA_Base\t\t40\t(0||CalendarData()((0||DaysOfWeek()((0||1()())(0||2()((0||0(s|08:00|f|12:00)())(0||1(s|13:00|f|17:00)())))(0||3()((0||0(s|08:00|f|12:00)())(0||1(s|13:00|f|17:00)())))(0||4()((0||0(s|08:00|f|12:00)())(0||1(s|13:00|f|17:00)())))(0||5()((0||0(s|08:00|f|12:00)())(0||1(s|13:00|f|17:00)())))(0||6()((0||0(s|08:00|f|12:00)())(0||1(s|13:00|f|17:00)())))(0||7()())))(0||Exceptions()((0||0(d|46025)())(0||1(d|46027)())(0||2(d|46027)())(0||3(d|46028)((0||0(s|08:00|f|12:00)())(0||1(s|13:00|f|17:00)())))(0||4(d|46027)())))))', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\tcomplete_pct_type\tcomplete_pct\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date\texpect_end_date\tsuspend_date\tresume_date\tfuture_vendor_field', + '%R\tT-PRED\tP-RET\tC-RET\tPRED\tVoorganger\tTT_Task\tDT_FixedDUR2\tTK_NotStart\tCP_Drtn\t0\t8\t8\t2025-12-31 08:00\t2025-12-31 17:00\t2025-12-31 17:00\t2025-12-31 10:00\t2025-12-31 12:00\tbehouden-A', + '%R\tT-SUCC\tP-RET\tC-RET\tSUCC\tOpvolger\tTT_Task\tDT_FixedDUR2\tTK_NotStart\tCP_Drtn\t0\t8\t8\t2025-12-31 17:00\t2026-01-01 17:00\t2026-01-01 17:00\t2026-01-01 10:00\t2026-01-01 12:00\tbehouden-B', + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%R\tREL-RET\tT-SUCC\tT-PRED\tP-RET\tP-RET\tPR_FS\t0', + '%T\tRSRC', + '%F\trsrc_id\trsrc_name\trsrc_type\tclndr_id\tdef_qty_per_hr', + '%R\tR-RET\tVakman\tRT_Labor\tC-RET\t1', + '%T\tTASKRSRC', + '%F\ttaskrsrc_id\tproj_id\ttask_id\trsrc_id\ttarget_qty_per_hr\tremain_qty\ttarget_qty', + '%R\tAS-RET\tP-RET\tT-SUCC\tR-RET\t1\t8\t8', + '%T\tACTVTYPE', + '%F\tactv_code_type_id\tactv_code_type\tseq_num', + '%R\tTYPE-RET\tFase\t1', + '%T\tACTVCODE', + '%F\tactv_code_id\tactv_code_type_id\tshort_name\tseq_num', + '%R\tVALUE-RET\tTYPE-RET\tBouw\t1', + '%T\tTASKACTV', + '%F\tproj_id\ttask_id\tactv_code_type_id\tactv_code_id', + '%R\tP-RET\tT-SUCC\tTYPE-RET\tVALUE-RET', + '%T\tUDFTYPE', + '%F\tudf_type_id\ttable_name\tudf_type_label\tlogical_data_type', + '%R\tUDF-RET\tTASK\tBronlabel\tFT_STATICTYPE', + '%T\tUDFVALUE', + '%F\tudf_type_id\tproj_id\tfk_id\tudf_text', + '%R\tUDF-RET\tP-RET\tT-SUCC\tNiet verliezen', + '%T\tVENDOR_UNKNOWN', + '%F\tvendor_payload', + '%R\t' + unknownPayload, + '%E', +].join('\r\n')); + +const expectedP6 = { + taskFields: [ + { p6DurationType: 'DT_FixedDUR2', p6ActivityType: 'TT_Task', p6ProjectId: 'P-RET', p6TaskId: 'T-PRED', p6ExplicitTargetWindow: true, p6CompletePctType: 'CP_Drtn', p6ExpectedFinish: '2025-12-31T17:00', p6SuspendResume: true }, + { p6DurationType: 'DT_FixedDUR2', p6ActivityType: 'TT_Task', p6ProjectId: 'P-RET', p6TaskId: 'T-SUCC', p6ExplicitTargetWindow: true, p6CompletePctType: 'CP_Drtn', p6ExpectedFinish: '2026-01-01T17:00', p6SuspendResume: true }, + ], + calendar: { + p6Source: 'XER', + p6NonWorkPenaltyDates: ['2026-01-03', '2026-01-05'], + p6NonWorkPenaltyDatesState: 'VALID_VALUES', + }, + sequenceBoundary: [true], +}; +const expectedLossCategories = [ + 'exact-source-bytes', + 'unknown-tables-and-fields', + 'typed-diagnostics', + 'udfs', + 'activity-codes', + 'raw-curves-and-assignment-quantities', + 'schedule-options-and-provenance', +]; + +type SourceState = Pick; +const lossCategories = (state: SourceState) => + detectXerExportLoss('p6', { + sourceArchive: state.xerSourceArchive, + importMetadata: state.xerImportMetadata, + project: state.project, + tasks: state.tasks, + sequences: state.sequences, + assignments: state.assignments, + activityCodeTypes: state.activityCodeTypes, + customFieldDefs: state.customFieldDefs, + baselines: state.baselines, + activeBaselineId: state.activeBaselineId, + })[0]?.categories ?? []; + +const chunkPins = (chunks: readonly string[]) => chunks.map((chunk, index) => ({ + index, + base64Length: chunk.length, + sha256: sha256Hex(new TextEncoder().encode(chunk)), +})); +const taskP6Fields = (state: SourceState) => state.tasks + .map(task => ({ + p6DurationType: task.p6DurationType, + p6ActivityType: task.p6ActivityType, + p6ProjectId: task.p6ProjectId, + p6TaskId: task.p6TaskId, + p6ExplicitTargetWindow: task.p6ExplicitTargetWindow, + p6CompletePctType: task.p6CompletePctType, + p6ExpectedFinish: task.p6ExpectedFinish, + p6SuspendResume: task.p6SuspendResume, + })) + .sort((a, b) => (a.p6TaskId ?? '').localeCompare(b.p6TaskId ?? '')); +const p6Projection = (state: SourceState) => ({ + taskFields: taskP6Fields(state), + calendar: { + p6Source: state.calendar.p6Source, + p6NonWorkPenaltyDates: state.calendar.p6NonWorkPenaltyDates, + p6NonWorkPenaltyDatesState: state.calendar.p6NonWorkPenaltyDatesState, + }, + sequenceBoundary: state.sequences.map(sequence => sequence.p6StartAtPredecessorFinishBoundary), +}); +const sourceSignature = (state: SourceState) => { + const archive = state.xerSourceArchive; + if (!archive) throw new Error('P0-fixture verloor het XER-bronarchief'); + const decoded = decodeXerSourceArchive(archive); + return { + raw: { + byteLength: decoded.byteLength, + sha256: sha256Hex(decoded), + archiveSha256: archive.sha256, + chunks: chunkPins(archive.byteChunks), + }, + selector: state.xerSourceProjectId, + diagnostics: archive.diagnostics, + importMetadata: state.xerImportMetadata, + readModel: { + schemaVersion: archive.readModel.schemaVersion, + numberFormat: archive.readModel.numberFormat, + scheduleRows: archive.readModel.scheduleOptionsSourceArchive.rows, + scheduleDiagnostics: archive.readModel.scheduleOptionsSourceArchive.diagnostics, + resourceCatalog: archive.readModel.resourceCatalog, + metadataCatalog: archive.readModel.metadataCatalog, + taskSourceRowsByProject: archive.readModel.taskSourceRowsByProject, + }, + p6: p6Projection(state), + lossDetectorInput: { + archiveSha256: archive.sha256, + selector: state.xerSourceProjectId, + importSourceProjectId: state.xerImportMetadata?.sourceProjectId, + taskFields: taskP6Fields(state), + sequenceBoundary: state.sequences.map(sequence => sequence.p6StartAtPredecessorFinishBoundary), + }, + lossCategories: lossCategories(state), + }; +}; +const scheduleProjection = (state: AppState) => state.tasks + .map(task => ({ + id: task.id, + duration: task.time.scheduleDuration, + durationMinutes: task.time.durationMinutes, + scheduleStart: task.time.scheduleStart, + scheduleFinish: task.time.scheduleFinish, + earlyStart: task.time.earlyStart, + earlyFinish: task.time.earlyFinish, + lateStart: task.time.lateStart, + lateFinish: task.time.lateFinish, + freeFloat: task.time.freeFloat, + totalFloat: task.time.totalFloat, + })) + .sort((a, b) => a.id.localeCompare(b.id)); + +interface StepEntity { id: string; type: string; args: string[]; line: string } +const splitStepArgs = (value: string): string[] => { + const args: string[] = []; + let current = ''; + let depth = 0; + let quoted = false; + for (let index = 0; index < value.length; index += 1) { + const char = value[index]!; + if (char === "'" && quoted && value[index + 1] === "'") { + current += "''"; + index += 1; + continue; + } + if (char === "'") quoted = !quoted; + if (!quoted && char === '(') depth += 1; + if (!quoted && char === ')') depth -= 1; + if (!quoted && depth === 0 && char === ',') { + args.push(current); + current = ''; + } else { + current += char; + } + } + args.push(current); + return args; +}; +const parseStep = (step: string): Map => { + const entities = new Map(); + for (const line of step.split(/\r?\n/)) { + const match = line.match(/^#(\d+)=([A-Z0-9_]+)\((.*)\);$/); + if (match) entities.set(match[1]!, { + id: match[1]!, + type: match[2]!, + args: splitStepArgs(match[3]!), + line, + }); + } + return entities; +}; +const refs = (value: string) => [...value.matchAll(/#(\d+)/g)].map(match => match[1]!); +const ifcString = (value: string) => { + const match = value.match(/^'(.*)'$/); + return match ? match[1]!.replace(/''/g, "'") : value; +}; +const ifcValue = (value: string): string | number | boolean => { + const typed = value.match(/^[A-Z0-9_]+\((.*)\)$/); + const raw = typed?.[1] ?? value; + if (raw === '.T.') return true; + if (raw === '.F.') return false; + if (/^-?\d+(?:\.\d+)?$/.test(raw)) return Number(raw); + return ifcString(raw); +}; +const psetsNamed = (entities: Map, name: string) => + [...entities.values()].filter(entity => + entity.type === 'IFCPROPERTYSET' && ifcString(entity.args[2] ?? '') === name); +const psetView = (entities: Map, pset: StepEntity) => { + const properties = refs(pset.args[4] ?? '').map(id => entities.get(id)) + .filter((entity): entity is StepEntity => entity?.type === 'IFCPROPERTYSINGLEVALUE'); + return { + names: properties.map(property => ifcString(property.args[0] ?? '')), + values: Object.fromEntries(properties.map(property => [ + ifcString(property.args[0] ?? ''), + ifcValue(property.args[2] ?? ''), + ])), + }; +}; +const attachedObjectIds = (entities: Map, psetId: string) => + [...entities.values()] + .filter(entity => + entity.type === 'IFCRELDEFINESBYPROPERTIES' + && refs(entity.args[5] ?? '').includes(psetId)) + .flatMap(entity => refs(entity.args[4] ?? '')); +const reorderArchiveProperties = (step: string) => { + const entities = parseStep(step); + const archive = psetsNamed(entities, 'OPS_XerSourceArchive')[0]; + if (!archive) throw new Error('Mutant kan OPS_XerSourceArchive niet vinden'); + const propertyIds = refs(archive.args[4] ?? ''); + if (propertyIds.length < 2) throw new Error('Mutant kan archiefproperties niet verwisselen'); + [propertyIds[0], propertyIds[1]] = [propertyIds[1]!, propertyIds[0]!]; + const mutatedArgs = [...archive.args]; + mutatedArgs[4] = `(${propertyIds.map(id => `#${id}`).join(',')})`; + return step.replace(archive.line, `#${archive.id}=${archive.type}(${mutatedArgs.join(',')});`); +}; +const mutateDurationType = (step: string) => { + const original = "IFCLABEL('DT_FixedDUR2')"; + const replacement = "IFCLABEL('DT_FixedRate')"; + if (!step.includes(original)) throw new Error('Mutant kan DurationType niet vinden'); + return step.replace(original, replacement); +}; + +const directActive = activeImportResult(readXER(bytes)); +// Gerichte productpoort: dit komt rechtstreeks uit echte XER, vóór hydrate of handmatige injectie. +equal('0 readXER leidt kalenderstate, kalenderbron en sequence-boundary uit bronregels af', + p6Projection(payloadFromImport(directActive, null)), expectedP6); + +let hydrateInput = directActive; +if (mutant === 'import-hydrate') { + hydrateInput = { + ...directActive, + xerSourceArchive: directActive.xerSourceArchive && { + ...directActive.xerSourceArchive, + readModel: { + ...directActive.xerSourceArchive.readModel, + numberFormat: { decimal: '.', group: ',', source: 'default' as const, currencyCode: 'USD' }, + }, + }, + }; +} +const app = createAppStore(); +const state = () => app.getState(); +state().applyLoadedProject(hydrateInput, { + filePath: null, + fileHandle: null, + recompute: false, + fit: false, + hourDataNotice: false, + linkedOpen: true, +}); +const baseline = sourceSignature(state()); +equal('1 import/hydrate pinnt exact drie bronchunks', baseline.raw.chunks.length, 3); +equal('2 import/hydrate pinnt onbekend, diagnostics, CURRTYPE, bronrijen en catalogi', { + unknownTables: baseline.diagnostics.file.tableReport.unknownTables, + unknownFields: baseline.diagnostics.file.tableReport.unknownFields, + numberFormat: baseline.readModel.numberFormat, + scheduleRows: baseline.readModel.scheduleRows.length, + scheduleDiagnostics: baseline.readModel.scheduleDiagnostics.length, + resourceRows: baseline.readModel.resourceCatalog.rows.assignments.length, + metadataTypes: baseline.readModel.metadataCatalog.activityCodeTypes.length, + taskProjects: Object.keys(baseline.readModel.taskSourceRowsByProject), +}, { + unknownTables: [{ name: 'VENDOR_UNKNOWN', rows: 1 }], + unknownFields: [{ table: 'TASK', name: 'future_vendor_field', rows: 2 }], + numberFormat: { decimal: ',', group: '.', source: 'currtype', currencyCode: 'EUR' }, + scheduleRows: 3, + scheduleDiagnostics: 1, + resourceRows: 1, + metadataTypes: 1, + taskProjects: ['P-RET'], +}); +equal('3 import/hydrate pinnt alle P6-provenance', baseline.p6, expectedP6); +equal('4 import/hydrate herberekent alle losscategorieën', baseline.lossCategories, expectedLossCategories); +const retains = (label: string, source: SourceState = state()) => + equal(label, sourceSignature(source), baseline); + +// Live edit + CPM: verander echte duurinvoer, eis een afgeleide wijziging en pin de bron los daarvan. +state().runCPM(); +const beforeEdit = scheduleProjection(state()); +const predecessor = state().tasks.find(task => task.p6TaskId === 'T-PRED'); +if (!predecessor) throw new Error('P0-fixture mist de voorganger'); +state().updateTask(predecessor.id, { + time: { ...predecessor.time, scheduleDuration: 3, durationMinutes: 1_440 }, +}); +if (mutant !== 'run-cpm') state().runCPM(); +const afterEdit = scheduleProjection(state()); +truthy('5 live edit verandert CPM-relevante duurinvoer', + afterEdit.some((task, index) => + task.duration !== beforeEdit[index]?.duration + || task.durationMinutes !== beforeEdit[index]?.durationMinutes)); +truthy('6 runCPM verandert minstens één afgeleide datum of float', + afterEdit.some((task, index) => + task.earlyStart !== beforeEdit[index]?.earlyStart + || task.earlyFinish !== beforeEdit[index]?.earlyFinish + || task.lateStart !== beforeEdit[index]?.lateStart + || task.lateFinish !== beforeEdit[index]?.lateFinish + || task.freeFloat !== beforeEdit[index]?.freeFloat + || task.totalFloat !== beforeEdit[index]?.totalFloat)); +retains('7 live edit/runCPM behoudt bytes, digest, chunks en alle bronvelden'); + +state().undo(); +equal('8 undo herstelt live invoer en afgeleide toestand exact', scheduleProjection(state()), beforeEdit); +if (mutant === 'snapshot-undo-redo') app.setState({ xerSourceProjectId: null }); +retains('9 undo herstelt ook de volledige bronretentie'); +if (mutant === 'snapshot-undo-redo') app.setState({ xerSourceProjectId: 'P-RET' }); +state().redo(); +equal('10 redo zet live invoer en afgeleide toestand exact terug', scheduleProjection(state()), afterEdit); +retains('11 redo zet de volledige bronretentie exact terug'); + +// Documentwissel. +const sourceDocumentId = state().activeDocumentId; +state().newDocument(); +const temporaryDocumentId = state().activeDocumentId; +state().switchDocument(sourceDocumentId); +if (mutant === 'document-switch') app.setState({ xerSourceProjectId: null }); +retains('12 documentwissel behoudt de volledige bronretentie'); +if (mutant === 'document-switch') app.setState({ xerSourceProjectId: 'P-RET' }); +state().closeDocument(temporaryDocumentId); +equal('13 documentwissel houdt exact het brondocument open', { + count: state().documents.length, + ids: state().documents.map(document => document.id), + activeId: state().activeDocumentId, +}, { count: 1, ids: [sourceDocumentId], activeId: sourceDocumentId }); + +// Duplicatie: veranderlijke payloads zijn gekloond; het diep immutable archief wordt bewust gedeeld. +const duplicateId = state().duplicateDocument('Bronretentie-kopie'); +const sourcePayload = state().documents.find(document => document.id === sourceDocumentId)?.payload; +if (!sourcePayload) throw new Error('Duplicatie verloor bronpayload'); +equal('14 duplicatie maakt precies één unieke document-id', { + count: state().documents.length, + uniqueIds: new Set(state().documents.map(document => document.id)).size, + activeId: state().activeDocumentId, +}, { count: 2, uniqueIds: 2, activeId: duplicateId }); +truthy('15 duplicatie deelt alleen het immutable XER-archief', + sourcePayload.xerSourceArchive === state().xerSourceArchive + && sourcePayload.project !== state().project + && sourcePayload.tasks !== state().tasks + && sourcePayload.sequences !== state().sequences + && sourcePayload.resources !== state().resources + && sourcePayload.assignments !== state().assignments + && sourcePayload.calendars !== state().calendars + && sourcePayload.activityCodeTypes !== state().activityCodeTypes + && sourcePayload.customFieldDefs !== state().customFieldDefs); +const duplicateTask = state().tasks.find(task => task.p6TaskId === 'T-PRED'); +const sourceTaskName = sourcePayload.tasks.find(task => task.p6TaskId === 'T-PRED')?.name; +if (!duplicateTask) throw new Error('Duplicatie verloor voorganger'); +state().updateTask(duplicateTask.id, { name: 'Alleen in kopie' }); +equal('16 mutatie in kopie raakt brondocument niet', + sourcePayload.tasks.find(task => task.p6TaskId === 'T-PRED')?.name, sourceTaskName); +if (mutant === 'duplicate') app.setState({ xerSourceProjectId: null }); +retains('17 duplicatie behoudt de volledige bronretentie'); +if (mutant === 'duplicate') app.setState({ xerSourceProjectId: 'P-RET' }); + +// Recovery: pin opslaginhoud en herstel daarna in een verse store waarvan het startdocument verdwijnt. +const openDocs = state().getOpenDocumentPayloads(); +const recoveryWrites = openDocs.map(document => ({ + id: document.id, + ifc: writeIFC(buildWriteIFCInput(document.payload)), + filePath: null, + isDirty: true, + datesAsRecorded: false, +})); +const expectedRecovery = recoveryWrites.map((document, index) => ({ + id: document.id, + ifcSha256: sha256Hex(new TextEncoder().encode(document.ifc)), + source: sourceSignature(openDocs[index]!.payload), +})); +await clearRecovery(); +if (mutant !== 'recovery-write-load-restore') { + await saveRecovery(fullRecoverySave(duplicateId, recoveryWrites)); +} +const loadedRecovery = await loadRecovery(); +equal('18 recovery write/load pinnt exacte aantallen, ids, actieve id en IFC-inhoud', { + count: loadedRecovery.docs.length, + ids: loadedRecovery.docs.map(document => document.id), + activeId: loadedRecovery.activeDocumentId, + ifcSha256: loadedRecovery.docs.map(document => sha256Hex(new TextEncoder().encode(document.ifc))), +}, { + count: expectedRecovery.length, + ids: expectedRecovery.map(document => document.id), + activeId: duplicateId, + ifcSha256: expectedRecovery.map(document => document.ifcSha256), +}); +const recoveryShapeOk = + loadedRecovery.docs.length === expectedRecovery.length + && loadedRecovery.docs.every((document, index) => document.id === expectedRecovery[index]?.id); +if (recoveryShapeOk) { + loadedRecovery.docs.forEach((document, index) => { + equal(`19 recovery load pinnt bronhandtekening document ${index + 1}`, + sourceSignature(payloadFromImport(readIFC(document.ifc), null)), expectedRecovery[index]!.source); + }); +} +const recoveredApp = createAppStore(); +const initialFreshDocumentId = recoveredApp.getState().activeDocumentId; +recoveredApp.getState().newDocument(); +const freshDocumentId = recoveredApp.getState().activeDocumentId; +recoveredApp.getState().closeDocument(initialFreshDocumentId); +if (recoveryShapeOk) { + const recoveryInputs = loadedRecovery.docs.map(document => recoveryInputFromParsed( + readIFC(document.ifc), + { id: document.id, filePath: null, isDirty: true, datesAsRecorded: false }, + )); + recoveredApp.getState().restoreDocuments(recoveryInputs, duplicateId); +} +equal('20 recovery restore vervangt de verse lege toestand volledig', { + count: recoveredApp.getState().documents.length, + ids: recoveredApp.getState().documents.map(document => document.id), + activeId: recoveredApp.getState().activeDocumentId, + freshIdPresent: recoveredApp.getState().documents.some(document => document.id === freshDocumentId), +}, { + count: expectedRecovery.length, + ids: expectedRecovery.map(document => document.id), + activeId: duplicateId, + freshIdPresent: false, +}); +if (recoveryShapeOk) { + recoveredApp.getState().getOpenDocumentPayloads().forEach((document, index) => { + equal(`21 recovery restore pinnt bronhandtekening document ${index + 1}`, + sourceSignature(document.payload), expectedRecovery[index]!.source); + }); +} +await clearRecovery(); + +// IFC-write en onafhankelijke STEP-inspectie op het herstelde actieve document. +const finalState = recoveryShapeOk ? recoveredApp.getState() : state(); +const writtenIfc = writeIFC(buildWriteIFCInput(finalState)); +const inspectedIfc = mutant === 'independent-step' + ? reorderArchiveProperties(writtenIfc) + : writtenIfc; +const entities = parseStep(inspectedIfc); +const projects = [...entities.values()].filter(entity => entity.type === 'IFCPROJECT'); +const archivePsets = psetsNamed(entities, 'OPS_XerSourceArchive'); +const selectorPsets = psetsNamed(entities, 'OPS_XerDocument'); +equal('22 STEP bevat unieke bronpsets op het bedoelde IFCPROJECT', { + projects: projects.length, + archivePsets: archivePsets.length, + selectorPsets: selectorPsets.length, + archiveAttachments: archivePsets[0] ? attachedObjectIds(entities, archivePsets[0].id) : [], + selectorAttachments: selectorPsets[0] ? attachedObjectIds(entities, selectorPsets[0].id) : [], +}, { + projects: 1, + archivePsets: 1, + selectorPsets: 1, + archiveAttachments: projects[0] ? [projects[0].id] : [], + selectorAttachments: projects[0] ? [projects[0].id] : [], +}); +if (!archivePsets[0] || !selectorPsets[0]) throw new Error('STEP mist bronpropertyset'); +const archiveView = psetView(entities, archivePsets[0]); +const selectorView = psetView(entities, selectorPsets[0]); +const expectedArchiveNames = [ + 'SchemaVersion', 'Format', 'StorageFormat', 'ByteLength', 'Sha256', + 'ByteChunkSize', 'ByteChunkCount', 'ByteChunk000000', 'ByteChunk000001', 'ByteChunk000002', +]; +equal('23 STEP pinnt propertyvolgorde en exact drie chunks', archiveView.names, expectedArchiveNames); +const directChunkValues = expectedArchiveNames.slice(7).map(name => String(archiveView.values[name])); +equal('24 STEP pinnt bronmanifest, chunkvolgorde en selector onafhankelijk', { + byteLength: archiveView.values.ByteLength, + sha256: archiveView.values.Sha256, + chunkCount: archiveView.values.ByteChunkCount, + chunks: chunkPins(directChunkValues), + selectorNames: selectorView.names, + selector: selectorView.values.SourceProjectId, + selectorDigest: selectorView.values.ArchiveSha256, +}, { + byteLength: bytes.byteLength, + sha256: baseline.raw.sha256, + chunkCount: 3, + chunks: baseline.raw.chunks, + selectorNames: ['ArchiveSha256', 'SourceProjectId'], + selector: 'P-RET', + selectorDigest: baseline.raw.sha256, +}); + +const p6Psets = psetsNamed(entities, 'OPS_P6Progress'); +const expectedP6Names = [ + 'ProjectId', 'TaskId', 'ExplicitTargetWindow', 'CompletePctType', + 'ExpectedFinish', 'DurationType', 'ActivityType', 'SuspendResume', +]; +equal('25 STEP bevat voor beide taken exact alle acht P6-properties in vaste volgorde', + p6Psets.map(pset => psetView(entities, pset).names), + [expectedP6Names, expectedP6Names]); +const directTaskP6 = p6Psets.map(pset => { + const view = psetView(entities, pset); + const attachments = attachedObjectIds(entities, pset.id); + return { + attachmentTypes: attachments.map(id => entities.get(id)?.type), + p6DurationType: view.values.DurationType, + p6ActivityType: view.values.ActivityType, + p6ProjectId: view.values.ProjectId, + p6TaskId: view.values.TaskId, + p6ExplicitTargetWindow: view.values.ExplicitTargetWindow, + p6CompletePctType: view.values.CompletePctType, + p6ExpectedFinish: view.values.ExpectedFinish, + p6SuspendResume: view.values.SuspendResume, + }; +}).sort((a, b) => String(a.p6TaskId).localeCompare(String(b.p6TaskId))); +equal('26 STEP pinnt alle acht P6-taakwaarden en IFCTASK-attachment', + directTaskP6, + expectedP6.taskFields.map(fields => ({ attachmentTypes: ['IFCTASK'], ...fields }))); + +const calendarPsets = psetsNamed(entities, 'OPS_Calendar'); +equal('27 STEP bevat één kalenderpset op IFCWORKCALENDAR', { + count: calendarPsets.length, + attachmentTypes: calendarPsets[0] + ? attachedObjectIds(entities, calendarPsets[0].id).map(id => entities.get(id)?.type) + : [], +}, { count: 1, attachmentTypes: ['IFCWORKCALENDAR'] }); +if (!calendarPsets[0]) throw new Error('STEP mist kalenderpropertyset'); +const calendarView = psetView(entities, calendarPsets[0]); +const calendarP6Names = calendarView.names.filter(name => name.startsWith('P6')); +const calendarPenaltyDates = JSON.parse(String(calendarView.values.P6NonWorkPenaltyDates)) as unknown; +equal('28 STEP pinnt kalender-P6-vlaggen en hun volgorde onafhankelijk', { + names: calendarP6Names, + p6Source: calendarView.values.P6Source, + p6NonWorkPenaltyDates: calendarPenaltyDates, + p6NonWorkPenaltyDatesState: + calendarView.values.P6Source === 'XER' && Array.isArray(calendarPenaltyDates) + ? 'VALID_VALUES' + : 'REJECTED', +}, { names: ['P6Source', 'P6NonWorkPenaltyDates'], ...expectedP6.calendar }); + +const sequencePsets = psetsNamed(entities, 'OPS_Sequences'); +equal('29 STEP bevat één sequence-pset op IFCWORKSCHEDULE', { + count: sequencePsets.length, + attachmentTypes: sequencePsets[0] + ? attachedObjectIds(entities, sequencePsets[0].id).map(id => entities.get(id)?.type) + : [], +}, { count: 1, attachmentTypes: ['IFCWORKSCHEDULE'] }); +if (!sequencePsets[0]) throw new Error('STEP mist sequencepropertyset'); +const sequenceView = psetView(entities, sequencePsets[0]); +const boundaryGuids = JSON.parse(String( + sequenceView.values.P6StartAtPredecessorFinishBoundarySequenceGuids, +)) as unknown; +const relationGuids = [...entities.values()] + .filter(entity => entity.type === 'IFCRELSEQUENCE') + .map(entity => ifcString(entity.args[0] ?? '')); +equal('30 STEP pinnt sequence-boundary tegen de echte IFCRELSEQUENCE-GUID', + boundaryGuids, relationGuids); + +// Productreader is een afzonderlijke grens. De mutant blijft geldige STEP en verandert concrete data. +const ifcForReader = mutant === 'ifc-writer-reader' + ? mutateDurationType(writtenIfc) + : writtenIfc; +const reloaded = readIFC(ifcForReader); +equal('31 IFC writer/read reconstructie behoudt volledige bronretentie', + sourceSignature(payloadFromImport(reloaded, null)), baseline); +equal('32 onafhankelijke raw digest blijft gelijk aan originele bronbytes', + baseline.raw.sha256, sha256Hex(bytes)); + +if (failures.length === 0) { + console.log(`OK xer-source-retention: alle checks groen (${checks})${mutant ? `; mutant=${mutant}` : ''}`); + process.exit(0); +} +console.log(`XX xer-source-retention: ${failures.length} inhoudelijke afwijking(en) van ${checks}${mutant ? `; mutant=${mutant}` : ''}`); +for (const failure of failures) console.log(` - ${failure}`); +process.exit(1); diff --git a/tests/planning/check-xer-suspend-invariant.ts b/tests/planning/check-xer-suspend-invariant.ts new file mode 100644 index 000000000..15b12be88 --- /dev/null +++ b/tests/planning/check-xer-suspend-invariant.ts @@ -0,0 +1,174 @@ +import { solveProject } from '@/engine/scheduler/solveProject'; +import { isMultiDocumentImport, type ImportResult } from '@/services/importTypes'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; +import { writeIFC } from '@/services/ifc/ifcWriter'; +import { readXER } from '@/services/xer/xerReader'; +import { createAppStore } from '@/state/appStore'; +import { recoveryInputFromParsed } from '@/state/documentContract'; +import { buildWriteIFCInput } from '@/state/ifcSaveInput'; +import type { Task } from '@/types/task'; + +const diffs: string[] = []; +let checks = 0; +const eq = (label: string, got: unknown, want: unknown): void => { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +}; + +const xer = [ + 'ERMHDR\t23.12\t2026-08-01\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date', + '%R\tP1\tInvariant\tC1\t2026-08-10 08:00', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tproj_id\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tWerkweek\tP1\tCA_Project\t8\t40\t', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tstatus_code\tcomplete_pct_type\tcomplete_pct\tphys_complete_pct\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date\tact_start_date\tact_end_date\tsuspend_date\tresume_date\texpect_end_date', + '%R\tINV\tP1\tC1\tINV\tInvariant\tTT_Task\tTK_Active\tCP_Phys\t\t75\t8\t2\t2026-08-03 08:00\t2026-08-03 16:00\t2026-08-03 08:00\t\t2026-08-04 15:30\t2026-08-20 08:00\t', + '%E', +]; +const opened = readXER(new TextEncoder().encode(xer.join('\n'))); +if (isMultiDocumentImport(opened)) throw new Error('invariantfixture gaf meerdere documenten'); +const source: ImportResult = opened; + +function solveFinish(imported: ImportResult): string | undefined { + const sourceTask = imported.tasks.find(candidate => candidate.wbsCode === 'INV')!; + const task = structuredClone(sourceTask); + const result = solveProject({ + tasks: [task], + sequences: [], + calendar: imported.calendar, + calendars: [imported.calendar, ...(imported.resourceCalendars ?? [])], + dataDate: imported.project.statusDate, + progressMode: imported.project.progressMode, + schedulingOptions: imported.project.schedulingOptions, + projectStartDate: imported.project.startDate, + }); + return result.tasks.get(task.id)?.earlyFinish; +} + +eq('geldig ongewijzigd XER-paar houdt P6-semantiek', { + flag: opened.tasks[0]?.p6SuspendResume, + finish: solveFinish(opened), +}, { flag: true, finish: '2026-08-20T10:00' }); + +function storeWithSource() { + const store = createAppStore(); + store.setState({ + project: source.project, + calendar: source.calendar, + tasks: structuredClone(source.tasks), + sequences: structuredClone(source.sequences), + resources: structuredClone(source.resources ?? []), + assignments: structuredClone(source.assignments ?? []), + calendars: structuredClone(source.resourceCalendars ?? []), + activityCodeTypes: structuredClone(source.activityCodeTypes ?? []), + customFieldDefs: structuredClone(source.customFieldDefs ?? []), + }); + return store; +} + +const looseStore = storeWithSource(); +const looseBefore = looseStore.getState().tasks.find(task => task.wbsCode === 'INV')!; +looseStore.getState().updateTask(looseBefore.id, { + time: { ...looseBefore.time, stop: undefined }, +}); +const looseEdited = looseStore.getState().tasks.find(task => task.wbsCode === 'INV')!; +eq('updateTask naar losse resume wist onmiddellijk de semantische vlag maar bewaart raw resume', { + stop: looseEdited.time.stop, + resume: looseEdited.time.resume, + flag: looseEdited.p6SuspendResume, +}, { stop: undefined, resume: '2026-08-20T08:00', flag: undefined }); + +const looseParsed = readIFC(writeIFC(buildWriteIFCInput(looseStore.getState()))); +const recoveryStore = createAppStore(); +recoveryStore.getState().restoreDocuments([ + recoveryInputFromParsed(looseParsed, { id: 'x7-recovery', filePath: '/tmp/x7-recovery.ifc', isDirty: true, datesAsRecorded: false }), +], 'x7-recovery'); +const recovered = recoveryStore.getState(); +const recoveredTask = recovered.tasks.find(task => task.wbsCode === 'INV')!; +const recoveredImport: ImportResult = { + project: recovered.project, + calendar: recovered.calendar, + tasks: recovered.tasks, + sequences: recovered.sequences, + resources: recovered.resources, + assignments: recovered.assignments, + resourceCalendars: recovered.calendars, + activityCodeTypes: recovered.activityCodeTypes, + customFieldDefs: recovered.customFieldDefs, +}; +eq('losse resume blijft na IFC/recovery raw data zonder P6-route', { + stop: recoveredTask.time.stop, + resume: recoveredTask.time.resume, + flag: recoveredTask.p6SuspendResume, + finish: solveFinish(recoveredImport), +}, { + stop: undefined, + resume: '2026-08-20T08:00', + flag: undefined, + finish: '2026-08-10T10:00', +}); + +const reverseStore = storeWithSource(); +const reverseBefore = reverseStore.getState().tasks.find(task => task.wbsCode === 'INV')!; +reverseStore.getState().updateTask(reverseBefore.id, { + time: { ...reverseBefore.time, stop: '2026-08-21T08:00' }, +}); +const reverseEdited = reverseStore.getState().tasks.find(task => task.wbsCode === 'INV')!; +eq('updateTask naar omgekeerd paar wist de semantische vlag maar bewaart beide raw datums', { + stop: reverseEdited.time.stop, + resume: reverseEdited.time.resume, + flag: reverseEdited.p6SuspendResume, +}, { stop: '2026-08-21T08:00', resume: '2026-08-20T08:00', flag: undefined }); + +const staleSolverInput = structuredClone(opened); +const staleSolverTask = staleSolverInput.tasks.find(task => task.wbsCode === 'INV')!; +staleSolverTask.time.stop = '2026-08-21T08:00'; +staleSolverTask.p6SuspendResume = true; +eq('solverpredicate weigert stale true bij een omgekeerd paar', solveFinish(staleSolverInput), + '2026-08-10T10:00'); + +const staleWriterInput = structuredClone(opened); +const staleWriterTask = staleWriterInput.tasks.find(task => task.wbsCode === 'INV')!; +staleWriterTask.time.stop = '2026-08-21T08:00'; +staleWriterTask.p6SuspendResume = true; +const gatedIfc = writeIFC(staleWriterInput); +eq('IFC-writergate serialiseert stale true niet', gatedIfc.includes("'SuspendResume'"), false); +const gatedReloadTask = readIFC(gatedIfc).tasks.find(task => task.wbsCode === 'INV')!; +eq('IFC-writergate bewaart omgekeerde raw datums maar niet de vlag', { + stop: gatedReloadTask.time.stop, + resume: gatedReloadTask.time.resume, + flag: gatedReloadTask.p6SuspendResume, +}, { stop: '2026-08-21T08:00', resume: '2026-08-20T08:00', flag: undefined }); + +const validIfc = writeIFC(opened); +const staleIfc = validIfc.replace('2026-08-04T15:30', '2026-08-21T08:00'); +if (staleIfc === validIfc) throw new Error('stale-IFC-fixture kon de Stop-datum niet muteren'); +const staleReload = readIFC(staleIfc); +const staleTask = staleReload.tasks.find(task => task.wbsCode === 'INV')!; +eq('IFC-reader herstelt een stale true niet bij een omgekeerd raw paar', { + stop: staleTask.time.stop, + resume: staleTask.time.resume, + flag: staleTask.p6SuspendResume, +}, { stop: '2026-08-21T08:00', resume: '2026-08-20T08:00', flag: undefined }); + +const validReload = readIFC(validIfc); +eq('geldig onveranderd paar round-tript exact en houdt P6-semantiek', { + flag: validReload.tasks.find(task => task.wbsCode === 'INV')?.p6SuspendResume, + finish: solveFinish(validReload), +}, { flag: true, finish: '2026-08-20T10:00' }); + +// Compilebewijs: de echte updateTask-route accepteert een volledige TaskTime; extensions lopen +// via exact deze route. MCP laat stop/resume niet toe en heeft dus geen schrijfpad om hier te testen. +void (looseEdited satisfies Task); + +if (diffs.length > 0) { + console.error(`XER-X7-suspendinvariant: ${diffs.length}/${checks} checks rood`); + for (const diff of diffs) console.error(`XX ${diff}`); + process.exit(1); +} +console.log(`OK XER-X7-suspendinvariant: ${checks} checks groen`); diff --git a/tests/planning/check-xer-tables.ts b/tests/planning/check-xer-tables.ts new file mode 100644 index 000000000..bd44a36ee --- /dev/null +++ b/tests/planning/check-xer-tables.ts @@ -0,0 +1,1195 @@ +import { + parseXerNumber, + parseXerTables, + XER_TRANSPORT_KIND, + type XerByteInput, +} from '@/services/xer/xerTables'; + +const diffs: string[] = []; +let checks = 0; + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +function utf8(lines: readonly string[], newline = '\n'): Uint8Array { + return new TextEncoder().encode(lines.join(newline)); +} + +function withPrefix(prefix: readonly number[], bytes: Uint8Array): Uint8Array { + const result = new Uint8Array(prefix.length + bytes.length); + result.set(prefix); + result.set(bytes, prefix.length); + return result; +} + +function utf16(text: string, bigEndian: boolean): Uint8Array { + const result = new Uint8Array(2 + text.length * 2); + result[0] = bigEndian ? 0xfe : 0xff; + result[1] = bigEndian ? 0xff : 0xfe; + for (let index = 0; index < text.length; index++) { + const code = text.charCodeAt(index); + result[2 + index * 2] = bigEndian ? code >> 8 : code & 0xff; + result[3 + index * 2] = bigEndian ? code & 0xff : code >> 8; + } + return result; +} + +function windows1252(text: string): Uint8Array { + return Uint8Array.from([...text].map(char => char.charCodeAt(0))); +} + +function encodingOutcome(bytes: Uint8Array): Record { + try { + const parsed = parseXerTables(bytes); + return { + encoding: parsed.report.encoding, + name: parsed.tables.get('PROJECT')?.rows[0]?.cells.proj_name, + }; + } catch (error) { + return { error: (error as Error).name }; + } +} + +function numberOutcome(raw: string, parsed: ReturnType): unknown { + return parseXerNumber(raw, parsed.numberFormat); +} + +function caughtXerError(run: () => unknown): Record | null { + try { + run(); + return null; + } catch (error) { + const typed = error as { + name?: string; + xerCode?: string; + table?: string; + field?: string; + missingColumns?: string[]; + missingValues?: string[]; + line?: number; + lines?: number[]; + encoding?: string; + }; + return { + name: typed.name, + xerCode: typed.xerCode, + ...(typed.table ? { table: typed.table } : {}), + ...(typed.field ? { field: typed.field } : {}), + ...(typed.missingColumns ? { missingColumns: typed.missingColumns } : {}), + ...(typed.missingValues ? { missingValues: typed.missingValues } : {}), + ...(typed.line ? { line: typed.line } : {}), + ...(typed.lines ? { lines: typed.lines } : {}), + ...(typed.encoding ? { encoding: typed.encoding } : {}), + }; + } +} + +// Breuk die dit vangt: ERMHDR als gewone tabel behandelen, het negende veld verkeerd indexeren, +// of de %T/%F/%R-markers niet als afzonderlijke grammaticale records verwerken. +const basic = parseXerTables(utf8([ + 'ERMHDR\t23.12\t2026-04-01\tProject\tadmin\tAdmin\tDB\tProject Management\tEUR', + '%T\tTASK', + '%F\tproj_id\ttask_id\ttask_code\tstatus_code', + '%R\tP1\t1\tA1000\ttK_Active', + '%E', +])); +eq('1 ERMHDR en basisgrammatica', { + version: basic.header.version, + defaultCurrencyCode: basic.header.defaultCurrencyCode, + encoding: basic.report.encoding, + ended: basic.report.endMarkerSeen, + fields: basic.tables.get('TASK')?.fields, + row: basic.tables.get('TASK')?.rows[0]?.cells, +}, { + version: '23.12', + defaultCurrencyCode: 'EUR', + encoding: 'utf-8', + ended: true, + fields: ['proj_id', 'task_id', 'task_code', 'status_code'], + row: { proj_id: 'P1', task_id: '1', task_code: 'A1000', status_code: 'tK_Active' }, +}); + +// Breuk die dit vangt: alleen LF accepteren of EOF zonder %E stil als volledig bestand behandelen. +const noTerminator = parseXerTables(utf8([ + 'ERMHDR\t19.12', + '%T\tPROJECT', + '%F\tproj_id', + '%R\tP1', +], '\r\n')); +eq('2 CRLF en ontbrekende %E worden afzonderlijk verwerkt', { + project: noTerminator.tables.get('PROJECT')?.rows[0]?.cells, + endMarkerSeen: noTerminator.report.endMarkerSeen, + issues: noTerminator.report.issues.map(issue => issue.code), +}, { + project: { proj_id: 'P1' }, + endMarkerSeen: false, + issues: ['XER_MISSING_END_MARKER'], +}); + +// Breuk die dit vangt: CSV-achtige algemene quote-escaping invoeren, XER's dubbele quotes niet +// terugbrengen, of de betekenisvolle lege kolomnaam na een afsluitende %F-tab wegtrimmen. +const quotesAndTrailingField = parseXerTables(utf8([ + 'ERMHDR\t23.12', + '%T\tTASK', + '%F\tproj_id\ttask_id\ttask_code\ttask_name\t', + '%R\tP1\t1\tA1000\tBuis 1"" staal\textra', + '%E', +])); +eq('3 quotes en lege laatste kolomnaam', { + fields: quotesAndTrailingField.tables.get('TASK')?.fields, + row: quotesAndTrailingField.tables.get('TASK')?.rows[0]?.cells, +}, { + fields: ['proj_id', 'task_id', 'task_code', 'task_name', ''], + row: { proj_id: 'P1', task_id: '1', task_code: 'A1000', task_name: 'Buis 1" staal', '': 'extra' }, +}); + +// Breuk die dit vangt: te korte/lange rijen stil accepteren of na één kapotte rij de hele import +// afbreken, terwijl de betrouwbare kolommen en volgende rijen nog leesbaar zijn. +const mismatchedRows = parseXerTables(utf8([ + 'ERMHDR\t23.12', + '%T\tTASK', + '%F\tproj_id\ttask_id\ttask_code\ttask_name', + '%R\tP1\t1\tA1', + '%R\tP1\t2\tA2\tNaam\textra', + '%R\tP1\t3\tA3\tNaam', + '%E', +])); +eq('4 veld-/waardetellingsverschil wordt verzameld en vervolgd', { + rows: mismatchedRows.tables.get('TASK')?.rows.map(row => row.cells), + issues: mismatchedRows.report.issues, +}, { + rows: [ + { proj_id: 'P1', task_id: '1', task_code: 'A1', task_name: '' }, + { proj_id: 'P1', task_id: '2', task_code: 'A2', task_name: 'Naam' }, + { proj_id: 'P1', task_id: '3', task_code: 'A3', task_name: 'Naam' }, + ], + issues: [ + { code: 'XER_ROW_FIELD_COUNT_MISMATCH', line: 4, table: 'TASK', expected: 4, actual: 3 }, + { code: 'XER_ROW_FIELD_COUNT_MISMATCH', line: 5, table: 'TASK', expected: 4, actual: 5 }, + ], +}); + +// Breuk die dit vangt: een lege eerste token als onbekend record weggooien, DEL-DEL in notities +// laten staan, of de BOM/NUL-vervuiling uit MPXJ's NotesHelper-gevallen doorgeven aan de gebruiker. +const multilineNote = parseXerTables(utf8([ + 'ERMHDR\t23.12', + '%T\tTASKMEMO', + '%F\ttask_id\ttask_memo', + `%R\t1\tBegin\u007f\u007fmidden\u0000\ufeff`, + '\t\teinde', + '%E', +])); +eq('5 DEL-DEL-notitie, BOM/NUL-strip en lege-token-continuatie', { + rows: multilineNote.tables.get('TASKMEMO')?.rows.length, + note: multilineNote.tables.get('TASKMEMO')?.rows[0]?.cells.task_memo, + issues: multilineNote.report.issues, +}, { + rows: 1, + note: 'Begin\nmidden\neinde', + issues: [], +}); + +// Breuk die dit vangt: verzonnen/toekomstige tabellen in het resultaat toelaten of stil overslaan +// zonder naam en rijtelling voor het importverslag te bewaren. +const unknownTable = parseXerTables(utf8([ + 'ERMHDR\t23.12', + '%T\tSPECIAL_CHARS', + '%F\tid\tdescription', + '%R\t1\tcafé', + '%R\t2\tproject', + '%T\tPROJECT', + '%F\tproj_id', + '%R\tP1', + '%E', +])); +eq('6 onbekende tabellen worden overgeslagen en geteld', { + tables: [...unknownTable.tables.keys()], + unknown: unknownTable.report.unknownTables, +}, { + tables: ['PROJECT'], + unknown: [{ name: 'SPECIAL_CHARS', rows: 2 }], +}); + +const unknownTableWithOnlyEmptyRows = parseXerTables(utf8([ + 'ERMHDR\t23.12', + '%T\tEMPTY_VENDOR_TABLE', + '%F\tmystery_a\tmystery_b', + '%R\t\t', + '%R\t \t', + '%E', +])); +eq('6a onbekende tabel telt uitsluitend rijen met minstens één niet-lege cel', + unknownTableWithOnlyEmptyRows.report.unknownTables, + [{ name: 'EMPTY_VENDOR_TABLE', rows: 0 }]); + +const unknownTableWithRetainedContinuation = parseXerTables(utf8([ + 'ERMHDR\t23.12', + '%T\tVENDOR_TABLE', + '%F\tvendor_field', + '%R', + '\tcontinued-value', + '%E', +])); +eq('6b onbekende tabel telt een lege rij zodra de continuation retained inhoud draagt', + unknownTableWithRetainedContinuation.report.unknownTables, + [{ name: 'VENDOR_TABLE', rows: 1 }]); + +const unknownTableWithWhitespaceContinuation = parseXerTables(utf8([ + 'ERMHDR\t23.12', + '%T\tVENDOR_TABLE', + '%F\tvendor_field', + '%R', + '\t ', + '%E', +])); +eq('6c onbekende tabel telt een lege rij met uitsluitend whitespace-continuation niet', + unknownTableWithWhitespaceContinuation.report.unknownTables, + [{ name: 'VENDOR_TABLE', rows: 0 }]); + +const filledUnknownRowBytes = utf8([ + 'ERMHDR\t23.12', + '%T\tVENDOR_TABLE', + '%F\tvendor_field', + '%R\tinitial-value', + '\tcontinued-value-1', + '\tcontinued-value-2', + '%E', +]); +const filledUnknownRowBytesBeforeParse = [...filledUnknownRowBytes]; +const unknownTableWithFilledRowAndContinuations = parseXerTables(filledUnknownRowBytes); +eq('6d onbekende tabel telt een gevulde rij met meerdere continuations exact eenmaal en bewaart de bronbytes', { + unknownTables: unknownTableWithFilledRowAndContinuations.report.unknownTables, + sourceBytes: [...filledUnknownRowBytes], +}, { + unknownTables: [{ name: 'VENDOR_TABLE', rows: 1 }], + sourceBytes: filledUnknownRowBytesBeforeParse, +}); + +const unknownTableWithoutRows = parseXerTables(utf8([ + 'ERMHDR\t23.12', + '%T\tEMPTY_VENDOR_TABLE', + '%F\tmystery_a\tmystery_b', + '%E', +])); +eq('6e onbekende tabel zonder datarijen blijft aanwezig met nul informatiedragende rijen', + unknownTableWithoutRows.report.unknownTables, + [{ name: 'EMPTY_VENDOR_TABLE', rows: 0 }]); + +// Breuk die dit vangt: de 8-byte DROID-signatuur als leeg maar geldig XER-project accepteren. +eq('7 een ERMHDR-skelet zonder versie is een getypeerde bestandsfout', + caughtXerError(() => parseXerTables(utf8(['ERMHDR'], '\r\n'))), { + name: 'XerImportError', + xerCode: 'XER_INVALID_FILE', + }); + +// Breuk die dit vangt: pseudo-XER met herkenbare tabelnamen maar niet-P6-kolommen als een leeg, +// zogenaamd geldig project doorlaten. Dit is tevens de verplichte kolomcontrole-mutatiefixture. +eq('8 TASK zonder verplichte P6-kolommen is getypeerd fataal', caughtXerError(() => + parseXerTables(utf8([ + 'ERMHDR\t24.8\t2024-09-08\tProject\ttest\tuser\tDB\tCloud\tUSD', + '%T\tTASK', + '%F\tTask_ID\tTask_Name\tStart_Date\tDuration\tStatus', + '%R\t1000\tTask 1\t2024-01-01\t5d\tNot Started', + '%E', + ]))), { + name: 'XerImportError', + xerCode: 'XER_MISSING_REQUIRED_COLUMNS', + table: 'TASK', + missingColumns: ['proj_id', 'task_code'], +}); + +const encodingText = [ + 'ERMHDR\t23.12', + '%T\tPROJECT', + '%F\tproj_id\tproj_name', + '%R\tP1\tCafé', + '%E', +].join('\n'); +// Breuk die dit vangt: de BOM negeren, alleen een prefix als UTF-8 testen, of CP1252 kiezen +// zonder eerst het volledige BOM-loze bestand fataal als UTF-8 te valideren. +eq('9 BOM, volledig UTF-8 en Windows-1252 kiezen de gerapporteerde encoding', [ + encodingOutcome(withPrefix([0xef, 0xbb, 0xbf], new TextEncoder().encode(encodingText))), + encodingOutcome(utf16(encodingText, false)), + encodingOutcome(utf16(encodingText, true)), + encodingOutcome(new TextEncoder().encode(encodingText)), + encodingOutcome(windows1252(encodingText)), +], [ + { encoding: 'utf-8', name: 'Café' }, + { encoding: 'utf-16le', name: 'Café' }, + { encoding: 'utf-16be', name: 'Café' }, + { encoding: 'utf-8', name: 'Café' }, + { encoding: 'windows-1252', name: 'Café' }, +]); + +// Breuk die dit vangt: CURRTYPE alleen gebruiken wanneer die vóór TASK staat, of de eerste valuta +// kiezen in plaats van ERMHDR veld 9. De kommawaarde blijft hier bewust nog een rauwe token. +const commaCurrency = parseXerTables(utf8([ + 'ERMHDR\t23.12\t2026-04-01\tProject\tadmin\tAdmin\tDB\tCloud\tEUR', + '%T\tTASK', + '%F\tproj_id\ttask_id\ttask_code\ttarget_drtn_hr_cnt', + '%R\tP1\t1\tA1\t1.234,5', + '%T\tCURRTYPE', + '%F\tcurr_short_name\tdecimal_symbol\tdigit_group_symbol', + '%R\tUSD\t.\t,', + '%R\tEUR\t,\t.', + '%E', +])); +eq('10 CURRTYPE wordt in een tweede pas op de defaultvaluta bepaald', { + format: commaCurrency.numberFormat, + raw: commaCurrency.tables.get('TASK')?.rows[0]?.cells.target_drtn_hr_cnt, +}, { + format: { decimal: ',', group: '.', source: 'currtype', currencyCode: 'EUR' }, + raw: '1.234,5', +}); + +const symbolicCurrency = parseXerTables(utf8([ + 'ERMHDR\t23.12\t2026-04-01\tProject\tadmin\tAdmin\tDB\tCloud\tEUR', + '%T\tCURRTYPE', + '%F\tcurr_short_name\tdecimal_symbol_type\tdigit_group_symbol_type', + '%R\tEUR\tds_Comma\tdg_Period', + '%E', +])); +const defaultPoint = parseXerTables(utf8([ + 'ERMHDR\t23.12', + '%T\tPROJECT', + '%F\tproj_id', + '%R\tP1', + '%E', +])); +// Breuk die dit vangt: symbolische separators als letterlijke tekst behandelen, of Number/parseFloat +// direct op localegetallen toepassen. Dezelfde parser wordt later door alle numerieke velden gebruikt. +eq('11 symbolische CURRTYPE-tokens en de getalparser', { + format: symbolicCurrency.numberFormat, + comma: numberOutcome('1.234,5', symbolicCurrency), + defaultFormat: defaultPoint.numberFormat, + point: numberOutcome('-12.5', defaultPoint), + empty: numberOutcome('', defaultPoint), + invalid: caughtXerError(() => parseXerNumber('12,5', defaultPoint.numberFormat)), +}, { + format: { decimal: ',', group: '.', source: 'currtype', currencyCode: 'EUR' }, + comma: 1234.5, + defaultFormat: { decimal: '.', group: null, source: 'default', currencyCode: '' }, + point: -12.5, + empty: null, + invalid: { name: 'XerImportError', xerCode: 'XER_INVALID_NUMBER' }, +}); + +const noCurrencyComma = utf8([ + 'ERMHDR\t23.12', + '%T\tTASK', + '%F\tproj_id\ttask_id\ttask_code\ttask_name\ttarget_drtn_hr_cnt', + '%R\tP1\t1\tA1\tNaam, met komma\t1,5', + '%E', +]); +// Breuk die dit vangt: zonder CURRTYPE stil punt-decimaal aannemen terwijl een numeriek P6-veld +// aantoonbaar komma-decimaal is. Een komma in gewone tekst is juist geen bewijs. +eq('12 komma-decimaal zonder CURRTYPE wordt getypeerd geweigerd', + caughtXerError(() => parseXerTables(noCurrencyComma)), { + name: 'XerImportError', + xerCode: 'XER_AMBIGUOUS_DECIMAL', + table: 'TASK', + field: 'target_drtn_hr_cnt', + line: 4, + }); + +function currencyOnly(decimal: string, group: string): Uint8Array { + return utf8([ + 'ERMHDR\t23.12\t2026-04-01\tProject\tadmin\tAdmin\tDB\tCloud\tEUR', + '%T\tCURRTYPE', + '%F\tcurr_short_name\tdecimal_symbol_type\tdigit_group_symbol_type', + `%R\tEUR\t${decimal}\t${group}`, + '%E', + ]); +} + +// Breuk die dit vangt: onbekende CURRTYPE-symbolen of hetzelfde decimaal-/groepsteken stil naar +// punt/default terug laten vallen en daarmee geldwaarden verkeerd interpreteren. +eq('13 onbruikbare CURRTYPE-notatie is getypeerd fataal', [ + caughtXerError(() => parseXerTables(currencyOnly('ds_Unknown', 'dg_Period'))), + caughtXerError(() => parseXerTables(currencyOnly('ds_Comma', 'dg_Comma'))), +], [ + { name: 'XerImportError', xerCode: 'XER_INVALID_NUMBER_FORMAT', table: 'CURRTYPE', line: 4, lines: [4] }, + { name: 'XerImportError', xerCode: 'XER_INVALID_NUMBER_FORMAT', table: 'CURRTYPE', line: 4, lines: [4] }, +]); + +// Breuk die dit vangt: grammaticaal kapotte rijen stil overslaan. Elk probleem blijft zichtbaar +// met regel en tabel, terwijl de nog betrouwbaar leesbare PROJECT-rij behouden blijft. +const brokenGrammar = parseXerTables(utf8([ + 'ERMHDR\t23.12', + '%T\tPROJECT', + '%R\tdata-zonder-velden', + '\twees-continuatie', + '%F\tproj_id\tproj_id', + '%R\tP1\tP2', + '%Q\tonbekend-record', + '%E', +])); +eq('14 onbetrouwbare grammaticarijen worden concreet gerapporteerd', brokenGrammar.report.issues, [ + { code: 'XER_DATA_WITHOUT_FIELDS', line: 3, table: 'PROJECT' }, + { code: 'XER_ORPHAN_CONTINUATION', line: 4, table: 'PROJECT' }, + { code: 'XER_DUPLICATE_FIELD', line: 5, table: 'PROJECT', field: 'proj_id' }, + { code: 'XER_UNKNOWN_RECORD', line: 7, table: 'PROJECT' }, +]); + +function missingColumns(table: string, fields: string): Record | null { + return caughtXerError(() => parseXerTables(utf8([ + 'ERMHDR\t23.12', + `%T\t${table}`, + `%F\t${fields}`, + '%E', + ]))); +} + +// Breuk die dit vangt: alleen TASK beveiligen en andere aanwezige P6-kern-/koppeltabellen zonder +// hun identiteitssleutels als bruikbaar doorgeven aan X3/X4a. +eq('15 verplichte sleutelkolommen gelden per aanwezige P6-kern-/koppeltabel', [ + missingColumns('PROJECT', 'proj_name'), + missingColumns('CALENDAR', 'clndr_name'), + missingColumns('PROJWBS', 'wbs_id\twbs_name'), + missingColumns('TASKPRED', 'task_id\tpred_task_id'), + missingColumns('RSRC', 'rsrc_name'), + missingColumns('TASKRSRC', 'task_id'), +], [ + { name: 'XerImportError', xerCode: 'XER_MISSING_REQUIRED_COLUMNS', table: 'PROJECT', missingColumns: ['proj_id'] }, + { name: 'XerImportError', xerCode: 'XER_MISSING_REQUIRED_COLUMNS', table: 'CALENDAR', missingColumns: ['clndr_id'] }, + { name: 'XerImportError', xerCode: 'XER_MISSING_REQUIRED_COLUMNS', table: 'PROJWBS', missingColumns: ['proj_id'] }, + { name: 'XerImportError', xerCode: 'XER_MISSING_REQUIRED_COLUMNS', table: 'TASKPRED', missingColumns: ['pred_type'] }, + { name: 'XerImportError', xerCode: 'XER_MISSING_REQUIRED_COLUMNS', table: 'RSRC', missingColumns: ['rsrc_id'] }, + { name: 'XerImportError', xerCode: 'XER_MISSING_REQUIRED_COLUMNS', table: 'TASKRSRC', missingColumns: ['rsrc_id'] }, +]); + +const bytesOnlyParser: (input: XerByteInput) => ReturnType = parseXerTables; +void bytesOnlyParser; +if (false) { + // @ts-expect-error XER mag nooit via een reeds gedecodeerde string de parser in. + parseXerTables('ERMHDR\t23.12'); +} +const cp1252TransportFixture = windows1252([ + 'ERMHDR\t23.12', + '%T\tPROJECT', + '%F\tproj_id\tproj_name', + '%R\tP1\tPrijs \u0080', + '%E', +].join('\n')); +const directCp1252 = parseXerTables(cp1252TransportFixture); +const reencodedCp1252 = parseXerTables(new TextEncoder().encode( + new TextDecoder().decode(cp1252TransportFixture), +)); +// Breuk die dit vangt: XER in de registry als tekst transporteren, of de runtimegrens een string +// laten aanvaarden. De CP1252-euro wordt via string onomkeerbaar U+FFFD en daarna vals als UTF-8. +eq('16 XER-consumercontract is uitsluitend ruwe bytes', { + transportKind: XER_TRANSPORT_KIND, + stringInput: caughtXerError(() => parseXerTables('ERMHDR\t23.12' as unknown as XerByteInput)), + direct: { + encoding: directCp1252.report.encoding, + name: directCp1252.tables.get('PROJECT')?.rows[0]?.cells.proj_name, + }, + reencoded: { + encoding: reencodedCp1252.report.encoding, + name: reencodedCp1252.tables.get('PROJECT')?.rows[0]?.cells.proj_name, + }, +}, { + transportKind: 'binary', + stringInput: { name: 'XerImportError', xerCode: 'XER_INVALID_INPUT' }, + direct: { encoding: 'windows-1252', name: 'Prijs €' }, + reencoded: { encoding: 'utf-8', name: 'Prijs �' }, +}); + +const p6Notes = parseXerTables(utf8([ + 'ERMHDR\t23.12', + '%T\tTASKMEMO', + '%F\ttask_id\ttask_memo', + `%R\tT1\tTaak\u007f\u007fregel\u0000\ufeff`, + '%T\tWBSMEMO', + '%F\twbs_id\twbs_memo', + `%R\tW1\tWBS\u007f\u007fregel\u0000\ufeff`, + '%T\tACCOUNT', + '%F\tacct_id\tacct_descr', + `%R\tA1\tRekening\u007f\u007fregel\u0000\ufeff`, + '%T\tRSRC', + '%F\trsrc_id\trsrc_notes', + `%R\tR1\tResource\u007f\u007fregel\u0000\ufeff`, + '%T\tROLES', + '%F\trole_id\trole_descr', + `%R\tO1\tRol\u007f\u007fregel\u0000\ufeff`, + '%T\tTASKPROC', + '%F\tproc_id\tproc_descr', + `%R\tS1\tStap\u007f\u007fregel\u0000\ufeff`, + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_data', + `%R\tC1\tKalender\u007f\u007fdata\u0000\ufeff`, + '%E', +])); +// Breuk die dit vangt: NotesHelper op veldnaam of globaal toepassen. Alleen onderzochte echte +// P6-notitievelden worden opgeschoond; structured calendar text blijft byte-inhoudelijk rauw. +eq('17 NotesHelper-semantiek is tabel- en veldspecifiek', { + task: p6Notes.tables.get('TASKMEMO')?.rows[0]?.cells.task_memo, + wbs: p6Notes.tables.get('WBSMEMO')?.rows[0]?.cells.wbs_memo, + account: p6Notes.tables.get('ACCOUNT')?.rows[0]?.cells.acct_descr, + resource: p6Notes.tables.get('RSRC')?.rows[0]?.cells.rsrc_notes, + role: p6Notes.tables.get('ROLES')?.rows[0]?.cells.role_descr, + step: p6Notes.tables.get('TASKPROC')?.rows[0]?.cells.proc_descr, + calendar: p6Notes.tables.get('CALENDAR')?.rows[0]?.cells.clndr_data, +}, { + task: 'Taak\nregel', + wbs: 'WBS\nregel', + account: 'Rekening\nregel', + resource: 'Resource\nregel', + role: 'Rol\nregel', + step: 'Stap\nregel', + calendar: `Kalender\u007f\u007fdata\u0000\ufeff`, +}); + +function emptyIdentity(table: string, fields: string, row: string): Record | null { + return caughtXerError(() => parseXerTables(utf8([ + 'ERMHDR\t23.12', + `%T\t${table}`, + `%F\t${fields}`, + `%R\t${row}`, + '%E', + ]))); +} +const roleOnlyAssignment = parseXerTables(utf8([ + 'ERMHDR\t23.12', + '%T\tTASKRSRC', + '%F\ttask_id\trsrc_id\trole_id\ttarget_qty', + '%R\tT1\t\tROLE1\t', + '%E', +])); +// Breuk die dit vangt: alleen %F-kolomnamen controleren of alle sparse assignmentwaarden +// generiek verplicht maken. Fouten dragen tabel, fysieke regel en de werkelijk ontbrekende waarde. +eq('18 aanwezige P6-tabellen vereisen niet-lege identiteit zonder role-only data af te wijzen', { + failures: [ + emptyIdentity('PROJECT', 'proj_id', ''), + emptyIdentity('CALENDAR', 'clndr_id', ''), + emptyIdentity('PROJWBS', 'wbs_id\tproj_id', 'W1\t'), + emptyIdentity('TASK', 'task_id\tproj_id\ttask_code', 'T1\t\tA1'), + emptyIdentity('TASKPRED', 'task_id\tpred_task_id\tpred_type', 'T1\tT0\t'), + emptyIdentity('RSRC', 'rsrc_id', ''), + emptyIdentity('TASKRSRC', 'task_id\trsrc_id\trole_id', '\tR1\t'), + emptyIdentity('TASKRSRC', 'task_id\trsrc_id\trole_id', 'T1\t\t'), + ], + roleOnlyRows: roleOnlyAssignment.tables.get('TASKRSRC')?.rows.length, +}, { + failures: [ + { name: 'XerImportError', xerCode: 'XER_MISSING_REQUIRED_VALUE', table: 'PROJECT', missingValues: ['proj_id'], line: 4 }, + { name: 'XerImportError', xerCode: 'XER_MISSING_REQUIRED_VALUE', table: 'CALENDAR', missingValues: ['clndr_id'], line: 4 }, + { name: 'XerImportError', xerCode: 'XER_MISSING_REQUIRED_VALUE', table: 'PROJWBS', missingValues: ['proj_id'], line: 4 }, + { name: 'XerImportError', xerCode: 'XER_MISSING_REQUIRED_VALUE', table: 'TASK', missingValues: ['proj_id'], line: 4 }, + { name: 'XerImportError', xerCode: 'XER_MISSING_REQUIRED_VALUE', table: 'TASKPRED', missingValues: ['pred_type'], line: 4 }, + { name: 'XerImportError', xerCode: 'XER_MISSING_REQUIRED_VALUE', table: 'RSRC', missingValues: ['rsrc_id'], line: 4 }, + { name: 'XerImportError', xerCode: 'XER_MISSING_REQUIRED_VALUE', table: 'TASKRSRC', missingValues: ['task_id'], line: 4 }, + { name: 'XerImportError', xerCode: 'XER_MISSING_REQUIRED_VALUE', table: 'TASKRSRC', missingValues: ['rsrc_id', 'role_id'], line: 4 }, + ], + roleOnlyRows: 1, +}); + +const missingCurrencyMatch = parseXerTables(utf8([ + 'ERMHDR\t23.12\t2026-04-01\tProject\tadmin\tAdmin\tDB\tCloud\tEP', + '%T\tCURRTYPE', + '%F\tcurr_short_name\tdecimal_symbol\tdigit_group_symbol', + '%R\tEUR\t,\t.', + '%T\tTASK', + '%F\tproj_id\ttask_id\ttask_code\ttarget_drtn_hr_cnt', + '%R\tP1\tT1\tA1\t1.5', + '%E', +])); +const consistentCurrency = parseXerTables(utf8([ + 'ERMHDR\t23.12\t2026-04-01\tProject\tadmin\tAdmin\tDB\tCloud\teur', + '%T\tCURRTYPE', + '%F\tcurr_short_name\tdecimal_symbol\tdecimal_symbol_type\tdigit_group_symbol\tdigit_group_symbol_type', + '%R\tEUR\tds_Comma\tCOMMA\tdg_Period\tPERIOD', + '%E', +])); +function currencyRow(fields: string, values: string): Record | null { + return caughtXerError(() => parseXerTables(utf8([ + 'ERMHDR\t23.12\t2026-04-01\tProject\tadmin\tAdmin\tDB\tCloud\tEUR', + '%T\tCURRTYPE', + `%F\tcurr_short_name\t${fields}`, + `%R\tEUR\t${values}`, + '%E', + ]))); +} +// Breuken die dit vangt: de eerste valutaregel als stille fallback kiezen, slechts de eerste +// herkenbare representatie vertrouwen, of ds/dg-symbolen in de verkeerde familie accepteren. +eq('19 CURRTYPE matcht exact en valideert alle separatorrepresentaties', { + missingMatch: { + format: missingCurrencyMatch.numberFormat, + issues: missingCurrencyMatch.report.issues, + }, + consistent: consistentCurrency.numberFormat, + invalid: [ + currencyRow('decimal_symbol\tdecimal_symbol_type\tdigit_group_symbol', ',\tds_Period\t.'), + currencyRow('decimal_symbol\tdigit_group_symbol\tdigit_group_symbol_type', '.\t,\tdg_Period'), + currencyRow('decimal_symbol_type', 'dg_Period'), + currencyRow('decimal_symbol\tdigit_group_symbol_type', '.\tds_Comma'), + currencyRow('decimal_symbol', 'onbekend'), + ], +}, { + missingMatch: { + format: { decimal: '.', group: null, source: 'default', currencyCode: 'EP' }, + issues: [{ code: 'XER_CURRENCY_NOT_FOUND', line: 1, table: 'CURRTYPE', currencyCode: 'EP' }], + }, + consistent: { decimal: ',', group: '.', source: 'currtype', currencyCode: 'eur' }, + invalid: Array.from({ length: 5 }, () => ({ + name: 'XerImportError', + xerCode: 'XER_INVALID_NUMBER_FORMAT', + table: 'CURRTYPE', + line: 4, + lines: [4], + })), +}); + +const terminalEnd = parseXerTables(utf8([ + 'ERMHDR\t23.12', + '%T\tPROJECT', + '%F\tproj_id', + '%R\tP1', + '%E', + '%T\tPROJECT', + '%F\tproj_id', + '%R\tP2', +])); +// Breuk die dit vangt: na de eerste %E doorlezen en een eerder betrouwbare bekende tabel +// overschrijven. De hele niet-lege staart krijgt precies één issue met record- én regeltelling. +eq('20 de eerste %E is terminaal en rapporteert de volledig genegeerde staart', { + project: terminalEnd.tables.get('PROJECT')?.rows[0]?.cells.proj_id, + rows: terminalEnd.tables.get('PROJECT')?.rows.length, + issues: terminalEnd.report.issues, +}, { + project: 'P1', + rows: 1, + issues: [{ + code: 'XER_TRAILING_RECORDS_AFTER_END', + line: 6, + ignoredRecords: 3, + ignoredLines: 3, + }], +}); + +// Breuk die dit vangt: fatale TextDecoder-TypeErrors uit BOM-takken laten ontsnappen. De BOM +// bepaalt ook bij een kapotte payload welke encoding in het typed foutcontract moet staan. +eq('21 kapotte BOM-payloads worden altijd getypeerd met encodingcontext', [ + caughtXerError(() => parseXerTables(Uint8Array.from([0xef, 0xbb, 0xbf, 0xc3, 0x28]))), + caughtXerError(() => parseXerTables(Uint8Array.from([0xff, 0xfe, 0x45]))), + caughtXerError(() => parseXerTables(Uint8Array.from([0xfe, 0xff, 0x00]))), +], [ + { name: 'XerImportError', xerCode: 'XER_INVALID_ENCODING', encoding: 'utf-8' }, + { name: 'XerImportError', xerCode: 'XER_INVALID_ENCODING', encoding: 'utf-16le' }, + { name: 'XerImportError', xerCode: 'XER_INVALID_ENCODING', encoding: 'utf-16be' }, +]); + +function noCurrencyDuration(value: string, field = 'target_drtn_hr_cnt'): Record | null { + return caughtXerError(() => parseXerTables(utf8([ + 'ERMHDR\t23.12', + '%T\tTASK', + `%F\tproj_id\ttask_id\ttask_code\t${field}`, + `%R\tP1\tT1\tA1\t${value}`, + '%E', + ]))); +} +const validCommaGroups = parseXerTables(utf8([ + 'ERMHDR\t23.12', + '%T\tTASK', + '%F\tproj_id\ttask_id\ttask_code\ttarget_drtn_hr_cnt', + '%R\tP1\tT1\tA1\t1,234', + '%R\tP1\tT2\tA2\t-12,345', + '%R\tP1\tT3\tA3\t1,234,567', + '%E', +])); +// Breuk die dit vangt: maximaal twee fractiecijfers aannemen, op een suffixregex vertrouwen, +// of geldige komma-duizendgroepen als bewezen decimaalnotatie afwijzen. +eq('22 komma-decimaalbewijs gebruikt veldcatalogus en onbeperkte precisie', { + ambiguous: [ + noCurrencyDuration('1,5000'), + noCurrencyDuration('-2,34567'), + noCurrencyDuration('1.234,567890'), + ], + groupedRows: validCommaGroups.tables.get('TASK')?.rows.length, + numericLookingText: noCurrencyDuration('1,5000', 'task_name'), +}, { + ambiguous: Array.from({ length: 3 }, () => ({ + name: 'XerImportError', + xerCode: 'XER_AMBIGUOUS_DECIMAL', + table: 'TASK', + field: 'target_drtn_hr_cnt', + line: 4, + })), + groupedRows: 3, + numericLookingText: null, +}); + +// Breuk die dit vangt: elke rij met een breedte-issue generiek overslaan. Een ontbrekende +// verplichte token blijft bij afkappen of uitsluitend extra eindtokens ondubbelzinnig zichtbaar. +eq('23 rijbreedte omzeilt ondubbelzinnig ontbrekende identiteit niet', [ + emptyIdentity('PROJECT', 'proj_id', '\tEXTRA'), + emptyIdentity('PROJECT', 'proj_id\tproj_name', ''), + emptyIdentity('TASK', 'proj_id\ttask_id\ttask_code\ttask_name', 'P1\tT1\t\tNaam\tEXTRA'), + emptyIdentity('TASK', 'proj_id\ttask_id\ttask_code\ttask_name', 'P1\tT1'), + emptyIdentity('TASKPRED', 'task_id\tpred_task_id\tpred_type', 'T1\t\tFS\tEXTRA'), + emptyIdentity('RSRC', 'rsrc_id\trsrc_name', ''), +], [ + { name: 'XerImportError', xerCode: 'XER_MISSING_REQUIRED_VALUE', table: 'PROJECT', missingValues: ['proj_id'], line: 4 }, + { name: 'XerImportError', xerCode: 'XER_MISSING_REQUIRED_VALUE', table: 'PROJECT', missingValues: ['proj_id'], line: 4 }, + { name: 'XerImportError', xerCode: 'XER_MISSING_REQUIRED_VALUE', table: 'TASK', missingValues: ['task_code'], line: 4 }, + { name: 'XerImportError', xerCode: 'XER_MISSING_REQUIRED_VALUE', table: 'TASK', missingValues: ['task_code'], line: 4 }, + { name: 'XerImportError', xerCode: 'XER_MISSING_REQUIRED_VALUE', table: 'TASKPRED', missingValues: ['pred_task_id'], line: 4 }, + { name: 'XerImportError', xerCode: 'XER_MISSING_REQUIRED_VALUE', table: 'RSRC', missingValues: ['rsrc_id'], line: 4 }, +]); + +function duplicateCurrency(secondRow: string): Record | ReturnType { + try { + return parseXerTables(utf8([ + 'ERMHDR\t23.12\t2026-04-01\tProject\tadmin\tAdmin\tDB\tCloud\tEUR', + '%T\tCURRTYPE', + '%F\tcurr_short_name\tdecimal_symbol\tdecimal_symbol_type\tdigit_group_symbol\tdigit_group_symbol_type', + '%R\teur\t.\tperiod\t,\tcomma', + secondRow, + '%E', + ])); + } catch (error) { + return caughtXerError(() => { throw error; }) ?? {}; + } +} +const identicalCurrencyDuplicates = duplicateCurrency('%R\tEUR\t.\tds_period\t,\tdg_comma'); +// Breuken die dit vangt: de eerste matching CURRTYPE-rij kiezen zonder de rest te valideren, +// of semantisch afwijkende duplicaten accepteren. Foutcontext wijst de fysieke bronregels aan. +eq('24 alle matching CURRTYPE-rijen zijn gevalideerd en semantisch eensluidend', { + identical: 'numberFormat' in identicalCurrencyDuplicates + ? identicalCurrencyDuplicates.numberFormat + : identicalCurrencyDuplicates, + conflict: duplicateCurrency('%R\tEUR\t,\tcomma\t.\tperiod'), + invalidSecond: duplicateCurrency('%R\tEUR\t.\tdg_period\t,\tdg_comma'), +}, { + identical: { decimal: '.', group: ',', source: 'currtype', currencyCode: 'EUR' }, + conflict: { + name: 'XerImportError', xerCode: 'XER_INVALID_NUMBER_FORMAT', table: 'CURRTYPE', + line: 5, lines: [4, 5], + }, + invalidSecond: { + name: 'XerImportError', xerCode: 'XER_INVALID_NUMBER_FORMAT', table: 'CURRTYPE', + line: 5, lines: [5], + }, +}); + +function trailingLinesWithFinalNewline(newline: '\n' | '\r\n'): unknown { + const text = [ + 'ERMHDR\t23.12', + '%E', + '%T\tPROJECT', + '', + '%F\tproj_id', + '%R\tP2', + ].join(newline) + newline; + return parseXerTables(new TextEncoder().encode(text)).report.issues; +} +// Breuk die dit vangt: het lege split-token ná een afsluitende newline als fysieke staartregel +// tellen. De bewust lege regel midden in de genegeerde staart moet juist wel blijven meetellen. +eq('25 ignoredLines telt fysieke LF/CRLF-staartregels zonder synthetisch eindtoken', { + lf: trailingLinesWithFinalNewline('\n'), + crlf: trailingLinesWithFinalNewline('\r\n'), +}, { + lf: [{ code: 'XER_TRAILING_RECORDS_AFTER_END', line: 3, ignoredRecords: 3, ignoredLines: 4 }], + crlf: [{ code: 'XER_TRAILING_RECORDS_AFTER_END', line: 3, ignoredRecords: 3, ignoredLines: 4 }], +}); + +// Onafhankelijke inventaris uit MPXJ XerFile.FIELD_TYPE_MAP: uitsluitend bronvelden met +// DataType.NUMERIC, DURATION of CURRENCY. De lijst is met de hand gecontroleerd en wordt niet uit +// productie afgeleid; ontbreekt daar later één veld, dan noemt deze gedragsmatrix precies dat veld. +const authoritativeDecimalFields = [ + 'act_cost', 'act_equip_qty', 'act_ot_cost', 'act_ot_qty', 'act_reg_cost', 'act_reg_qty', + 'act_work_qty', 'asgnmnt_catg_id', 'asgnmnt_catg_short_len', 'asgnmnt_catg_type_id', + 'base_exch_rate', 'complete_pct', 'cost_per_qty', 'cost_per_qty2', 'cost_per_qty3', + 'cost_per_qty4', 'cost_per_qty5', 'critical_drtn_hr_cnt', 'curv_id', 'day_hr_cnt', + 'def_qty_per_hr', 'est_wt', 'free_float_hr_cnt', 'indep_remain_total_cost', + 'indep_remain_work_qty', 'lag_hr_cnt', 'latitude', 'longitude', 'max_qty_per_hr', + 'month_hr_cnt', 'orig_cost', 'parent_asgnmnt_catg_id', + 'pct_usage_0', 'pct_usage_1', 'pct_usage_2', 'pct_usage_3', 'pct_usage_4', 'pct_usage_5', + 'pct_usage_6', 'pct_usage_7', 'pct_usage_8', 'pct_usage_9', 'pct_usage_10', 'pct_usage_11', + 'pct_usage_12', 'pct_usage_13', 'pct_usage_14', 'pct_usage_15', 'pct_usage_16', + 'pct_usage_17', 'pct_usage_18', 'pct_usage_19', 'pct_usage_20', 'phys_complete_pct', + 'proc_wt', 'remain_cost', 'remain_drtn_hr_cnt', 'remain_equip_qty', 'remain_qty', + 'remain_qty_per_hr', 'remain_work_qty', 'target_cost', 'target_drtn_hr_cnt', + 'target_equip_qty', 'target_lag_drtn_hr_cnt', 'target_qty', 'target_qty_per_hr', + 'target_work_qty', 'total_float_hr_cnt', 'udf_number', 'week_hr_cnt', 'year_hr_cnt', +] as const; + +function commaDecimalField( + table: string, + fields: readonly string[], + values: readonly string[], +): Record | null { + return caughtXerError(() => parseXerTables(utf8([ + 'ERMHDR\t23.12', + `%T\t${table}`, + `%F\t${fields.join('\t')}`, + `%R\t${values.join('\t')}`, + '%E', + ]))); +} +const uncoveredAuthoritativeFields = authoritativeDecimalFields.filter(field => + commaDecimalField('UDFVALUE', [field], ['1,5000'])?.xerCode !== 'XER_AMBIGUOUS_DECIMAL'); +const representativeDecimalFields = [ + commaDecimalField('UDFVALUE', ['udf_number'], ['1,5000']), + ...['cost_per_qty2', 'cost_per_qty3', 'cost_per_qty4', 'cost_per_qty5'] + .map(field => commaDecimalField('RSRCRATE', [field], ['1,5000'])), + commaDecimalField( + 'TASKRSRC', + ['task_id', 'rsrc_id', 'role_id', 'remain_qty_per_hr'], + ['T1', 'R1', '', '-2,34567'], + ), + commaDecimalField('RSRCCURVDATA', ['pct_usage_0', 'pct_usage_20'], ['1,5000', '2']), + commaDecimalField('PROJCOST', ['target_cost'], ['1.234,5678']), + commaDecimalField( + 'TASK', + ['proj_id', 'task_id', 'task_code', 'target_drtn_hr_cnt'], + ['P1', 'T1', 'A1', '1,5000'], + ), +]; +// Breuken die dit vangt: een suffixheuristiek, alleen de reeds gemapte X3-velden opnemen, of +// NUMERIC/DURATION/CURRENCY onvolledig overnemen. INTEGER-id en vrije tekst blijven negatief. +eq('26 gezaghebbende numerieke P6-veldmatrix draagt de komma-decimaalheuristiek volledig', { + authoritativeCount: authoritativeDecimalFields.length, + uncoveredAuthoritativeFields, + representativeDecimalFields, + negative: { + udfText: commaDecimalField('UDFVALUE', ['udf_text'], ['1,5000']), + integerId: commaDecimalField('UDFVALUE', ['udf_type_id'], ['1,5000']), + projectTextAndId: commaDecimalField( + 'PROJECT', + ['proj_id', 'proj_name'], + ['1,5000', '1,5000'], + ), + }, +}, { + authoritativeCount: 72, + uncoveredAuthoritativeFields: [], + representativeDecimalFields: [ + { name: 'XerImportError', xerCode: 'XER_AMBIGUOUS_DECIMAL', table: 'UDFVALUE', field: 'udf_number', line: 4 }, + { name: 'XerImportError', xerCode: 'XER_AMBIGUOUS_DECIMAL', table: 'RSRCRATE', field: 'cost_per_qty2', line: 4 }, + { name: 'XerImportError', xerCode: 'XER_AMBIGUOUS_DECIMAL', table: 'RSRCRATE', field: 'cost_per_qty3', line: 4 }, + { name: 'XerImportError', xerCode: 'XER_AMBIGUOUS_DECIMAL', table: 'RSRCRATE', field: 'cost_per_qty4', line: 4 }, + { name: 'XerImportError', xerCode: 'XER_AMBIGUOUS_DECIMAL', table: 'RSRCRATE', field: 'cost_per_qty5', line: 4 }, + { name: 'XerImportError', xerCode: 'XER_AMBIGUOUS_DECIMAL', table: 'TASKRSRC', field: 'remain_qty_per_hr', line: 4 }, + { name: 'XerImportError', xerCode: 'XER_AMBIGUOUS_DECIMAL', table: 'RSRCCURVDATA', field: 'pct_usage_0', line: 4 }, + { name: 'XerImportError', xerCode: 'XER_AMBIGUOUS_DECIMAL', table: 'PROJCOST', field: 'target_cost', line: 4 }, + { name: 'XerImportError', xerCode: 'XER_AMBIGUOUS_DECIMAL', table: 'TASK', field: 'target_drtn_hr_cnt', line: 4 }, + ], + negative: { udfText: null, integerId: null, projectTextAndId: null }, +}); + +function duplicateKnownTable(lines: readonly string[]): Record | null { + return caughtXerError(() => parseXerTables(utf8(lines))); +} +const repeatedUnknownTables = parseXerTables(utf8([ + 'ERMHDR\t23.12', + '%T\tMYSTERY', + '%F\tvalue', + '%R\tone', + '%T\tMYSTERY', + '%F\tvalue', + '%R\ttwo', + '%E', +])); +// Breuk die dit vangt: een tweede bekende %T vóór %E met tables.set laten overschrijven. De +// duplicate-fout moet vóór opslag ontstaan, zodat ook eerdere valuta, data of lege identiteit niet +// verdwijnen. Bewust onbekende tabellen behouden hun bestaande afzonderlijke telrapportage. +eq('27 een bekende tabel heeft vóór de eerste %E precies één sectie', { + splitCurrency: duplicateKnownTable([ + 'ERMHDR\t23.12\t2026-04-01\tProject\tadmin\tAdmin\tDB\tCloud\tEUR', + '%T\tCURRTYPE', + '%F\tcurr_short_name\tdecimal_symbol\tdigit_group_symbol', + '%R\tEUR\t.\t,', + '%T\tCURRTYPE', + '%F\tcurr_short_name\tdecimal_symbol\tdigit_group_symbol', + '%R\tEUR\t,\t.', + '%E', + ]), + duplicateProjectData: duplicateKnownTable([ + 'ERMHDR\t23.12', + '%T\tPROJECT', + '%F\tproj_id', + '%R\tP1', + '%T\tPROJECT', + '%F\tproj_id', + '%R\tP2', + '%E', + ]), + hiddenEmptyIdentity: duplicateKnownTable([ + 'ERMHDR\t23.12', + '%T\tPROJECT', + '%F\tproj_id', + '%R\t', + '%T\tPROJECT', + '%F\tproj_id', + '%R\tP2', + '%E', + ]), + unknownTables: repeatedUnknownTables.report.unknownTables, +}, { + splitCurrency: { + name: 'XerImportError', xerCode: 'XER_DUPLICATE_TABLE', table: 'CURRTYPE', + line: 5, lines: [2, 5], + }, + duplicateProjectData: { + name: 'XerImportError', xerCode: 'XER_DUPLICATE_TABLE', table: 'PROJECT', + line: 5, lines: [2, 5], + }, + hiddenEmptyIdentity: { + name: 'XerImportError', xerCode: 'XER_DUPLICATE_TABLE', table: 'PROJECT', + line: 5, lines: [2, 5], + }, + unknownTables: [{ name: 'MYSTERY', rows: 1 }, { name: 'MYSTERY', rows: 1 }], +}); + +// Onafhankelijke tegenhelft van dezelfde MPXJ XerFile.FIELD_TYPE_MAP-inventaris. Deze 104 +// bronvelden zijn uitdrukkelijk NIET NUMERIC/DURATION/CURRENCY en worden met hun bronsoort +// letterlijk vastgezet, zodat iedere botsende productie-aanvulling gedragsmatig rood wordt. +const authoritativeNonDecimalFields = [ + ['acct_id', 'INTEGER'], + ['acct_seq_num', 'INTEGER'], + ['act_end_date', 'DATE'], + ['act_start_date', 'DATE'], + ['actv_code_id', 'INTEGER'], + ['actv_code_type_id', 'INTEGER'], + ['actv_short_len', 'INTEGER'], + ['anticip_end_date', 'DATE'], + ['anticip_start_date', 'DATE'], + ['auto_compute_act_flag', 'BOOLEAN'], + ['base_clndr_id', 'INTEGER'], + ['clndr_data', 'STRING'], + ['clndr_id', 'INTEGER'], + ['clndr_name', 'STRING'], + ['clndr_type', 'STRING'], + ['cost_item_id', 'INTEGER'], + ['cost_type_id', 'INTEGER'], + ['create_date', 'DATE'], + ['cstr_date', 'DATE'], + ['cstr_date2', 'DATE'], + ['curr_id', 'INTEGER'], + ['decimal_digit_cnt', 'INTEGER'], + ['default_flag', 'STRING'], + ['driving_path_flag', 'STRING'], + ['early_end_date', 'DATE'], + ['early_start_date', 'DATE'], + ['expect_end_date', 'DATE'], + ['external_early_start_date', 'DATE'], + ['external_late_end_date', 'DATE'], + ['fk_id', 'INTEGER'], + ['float_path', 'INTEGER'], + ['float_path_order', 'INTEGER'], + ['fy_start_month_num', 'INTEGER'], + ['group_digit_cnt', 'INTEGER'], + ['last_chng_date', 'STRING'], + ['last_recalc_date', 'DATE'], + ['late_end_date', 'DATE'], + ['late_start_date', 'DATE'], + ['location_id', 'INTEGER'], + ['loginal_data_type', 'STRING'], + ['memo_type_id', 'INTEGER'], + ['memo_id', 'INTEGER'], + ['parent_acct_id', 'INTEGER'], + ['parent_actv_code_id', 'INTEGER'], + ['parent_proj_catg_id', 'INTEGER'], + ['parent_role_id', 'INTEGER'], + ['parent_role_catg_id', 'INTEGER'], + ['parent_rsrc_catg_id', 'INTEGER'], + ['parent_rsrc_id', 'INTEGER'], + ['parent_wbs_id', 'INTEGER'], + ['plan_end_date', 'DATE'], + ['plan_start_date', 'DATE'], + ['pred_task_id', 'INTEGER'], + ['proc_id', 'INTEGER'], + ['proj_catg_id', 'INTEGER'], + ['proj_catg_type_id', 'INTEGER'], + ['proj_catg_short_len', 'INTEGER'], + ['proj_id', 'INTEGER'], + ['reend_date', 'DATE'], + ['rem_late_start_date', 'DATE'], + ['rem_late_end_date', 'DATE'], + ['restart_date', 'DATE'], + ['resume_date', 'DATE'], + ['role_id', 'INTEGER'], + ['role_catg_id', 'INTEGER'], + ['role_catg_short_len', 'INTEGER'], + ['role_catg_type_id', 'INTEGER'], + ['rsrc_id', 'INTEGER'], + ['rsrc_catg_id', 'INTEGER'], + ['rsrc_catg_type_id', 'INTEGER'], + ['rsrc_catg_short_len', 'INTEGER'], + ['rsrc_role_id', 'INTEGER'], + ['rsrc_seq_num', 'INTEGER'], + ['scd_end_date', 'DATE'], + ['sched_calendar_on_relationship_lag', 'STRING'], + ['seq_num', 'INTEGER'], + ['shift_id', 'INTEGER'], + ['shift_period_id', 'INTEGER'], + ['shift_start_hr_num', 'INTEGER'], + ['skill_level', 'INTEGER'], + ['start_date', 'DATE'], + ['step_complete_flag', 'BOOLEAN'], + ['sum_base_proj_id', 'INTEGER'], + ['super_flag', 'STRING'], + ['suspend_date', 'DATE'], + ['table_name', 'STRING'], + ['target_end_date', 'DATE'], + ['target_start_date', 'DATE'], + ['task_code_base', 'INTEGER'], + ['task_code_step', 'INTEGER'], + ['task_id', 'INTEGER'], + ['task_pred_id', 'INTEGER'], + ['taskrsrc_id', 'INTEGER'], + ['tmpl_guid', 'GUID'], + ['udf_code_id', 'INTEGER'], + ['udf_date', 'DATE'], + ['udf_text', 'STRING'], + ['udf_type', 'INTEGER'], + ['udf_type_id', 'INTEGER'], + ['udf_type_label', 'STRING'], + ['udf_type_name', 'STRING'], + ['unit_id', 'INTEGER'], + ['wbs_id', 'INTEGER'], + ['wbs_memo_id', 'INTEGER'], +] as const; +const nonDecimalTypeCounts = authoritativeNonDecimalFields.reduce>( + (counts, [, type]) => ({ ...counts, [type]: (counts[type] ?? 0) + 1 }), + {}, +); +const falsePositiveNonDecimalFields = authoritativeNonDecimalFields.filter(([field]) => + commaDecimalField('UDFVALUE', [field], ['1,5000'])?.xerCode === 'XER_AMBIGUOUS_DECIMAL'); +// Breuken die dit vangt: een MPXJ-INTEGER zoals seq_num of acct_seq_num als lokale decimale +// aanvulling classificeren. De matrix test de echte parser; de verwachtingen delen geen catalogus. +eq('28 alle gezaghebbende niet-decimale P6-velden blijven buiten het decimaalbewijs', { + authoritativeCount: authoritativeNonDecimalFields.length, + typeCounts: nonDecimalTypeCounts, + falsePositiveNonDecimalFields, + seqNum: commaDecimalField('TASKPROC', ['seq_num'], ['1,5000']), +}, { + authoritativeCount: 104, + typeCounts: { INTEGER: 60, DATE: 28, BOOLEAN: 2, STRING: 13, GUID: 1 }, + falsePositiveNonDecimalFields: [], + seqNum: null, +}); + +function ambiguousDecimalEvidence( + table: string, + fields: readonly string[], + values: readonly string[], + sourceValue: string, + privatePath: string, +): Record | null { + try { + parseXerTables(utf8([ + 'ERMHDR\t23.12', + `%T\t${table}`, + `%F\t${fields.join('\t')}`, + `%R\t${values.join('\t')}`, + '%E', + ])); + return null; + } catch (error) { + const typed = error as Error & { + xerCode?: string; + table?: string; + field?: string; + line?: number; + }; + return { + name: typed.name, + xerCode: typed.xerCode, + table: typed.table ?? null, + field: typed.field ?? null, + line: typed.line ?? null, + sourceValueLeaked: typed.message.includes(sourceValue), + privatePathLeaked: typed.message.includes(privatePath), + }; + } +} +const udfPrivatePath = '/home/private-user/secret/project.xer'; +const curvePrivatePath = 'C:\\Users\\private-user\\secret\\curve.xer'; +// Breuk die dit vangt: de reeds gevonden tabel/rij/veldcontext tot boolean reduceren of bij het +// opbouwen van de fatale fout de context laten vallen. Fouttekst mag bronwaarde noch pad opnemen. +eq('29 komma-decimaalfout draagt het eerste concrete bewijs zonder brondata te lekken', { + udf: ambiguousDecimalEvidence( + 'UDFVALUE', + ['udf_text', 'udf_number'], + [udfPrivatePath, '1,5000'], + '1,5000', + udfPrivatePath, + ), + curve: ambiguousDecimalEvidence( + 'RSRCCURVDATA', + ['pct_usage_20', 'pct_usage_0', 'curve_path'], + ['-2,34567', '1,5000', curvePrivatePath], + '-2,34567', + curvePrivatePath, + ), +}, { + udf: { + name: 'XerImportError', xerCode: 'XER_AMBIGUOUS_DECIMAL', + table: 'UDFVALUE', field: 'udf_number', line: 4, + sourceValueLeaked: false, privatePathLeaked: false, + }, + curve: { + name: 'XerImportError', xerCode: 'XER_AMBIGUOUS_DECIMAL', + table: 'RSRCCURVDATA', field: 'pct_usage_20', line: 4, + sourceValueLeaked: false, privatePathLeaked: false, + }, +}); + +type UnknownFieldReport = { + unknownFields?: Array<{ table: string; name: string; rows: number }>; +}; +function reportedUnknownFields(lines: readonly string[]) { + return (parseXerTables(utf8(lines)).report as UnknownFieldReport).unknownFields; +} + +// Fixronde 2: de parser/readergrens moet onbekende velden in een bekende tabel gezaghebbend +// inventariseren, maar uitsluitend als minstens één retained cel informatiewaarde draagt. De +// bronrij zelf blijft hieronder apart gecontroleerd: detectie mag niets uit de cellen verwijderen. +const unknownFieldWithValue = parseXerTables(utf8([ + 'ERMHDR\t23.12', + '%T\tTASKNOTE', + '%F\tmystery_vendor_field', + '%R\tretained-value', + '%R\t', + '%E', +])); +eq('30 onbekend gevuld veld in bekende tabel wordt geteld zonder de bronrij te wijzigen', { + report: (unknownFieldWithValue.report as UnknownFieldReport).unknownFields, + rows: unknownFieldWithValue.tables.get('TASKNOTE')?.rows.map(row => row.cells), +}, { + report: [{ table: 'TASKNOTE', name: 'mystery_vendor_field', rows: 1 }], + rows: [ + { mystery_vendor_field: 'retained-value' }, + { mystery_vendor_field: '' }, + ], +}); +eq('31 onbekend veld met uitsluitend lege waarden draagt geen verloren informatiewaarde', + reportedUnknownFields([ + 'ERMHDR\t23.12', + '%T\tTASKNOTE', + '%F\tmystery_vendor_field', + '%R\t', + '%R\t', + '%E', + ]), []); +eq('32 bekende PROJECT-velden geven geen false positive', reportedUnknownFields([ + 'ERMHDR\t23.12', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tlast_recalc_date\tclndr_id', + '%R\tP1\tBekend\t2034-01-02 08:00\tC1', + '%E', +]), []); + +if (diffs.length === 0) { + console.log(`OK xer-tables: ${checks} checks groen`); +} else { + console.log(`XX xer-tables: ${diffs.length} afwijking(en) van ${checks}`); + for (const diff of diffs) console.log(` XX ${diff}`); + process.exit(1); +} diff --git a/tests/planning/check-xer-task-replay.ts b/tests/planning/check-xer-task-replay.ts new file mode 100644 index 000000000..f1f8731b6 --- /dev/null +++ b/tests/planning/check-xer-task-replay.ts @@ -0,0 +1,721 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { XerGroundTruth, XerGroundTruthTask } from './xerGroundTruth'; +import type { XerCorpusManifest, XerSolvedProject, XerSolvedTask } from './xerFidelity'; +import { scanXerGroundTruth } from './xerGroundTruth'; +import { + evaluateXerTaskReplay, + shouldRejectXerTaskReplay, + type XerReplayPredicateLog, +} from './xerTaskReplay'; +import { + dropFinishMilestoneBoundaryCandidate, + replayXerProductBeforeOracle, + syntheticZeroRegressionCandidate, + type XerTaskReplayCandidate, +} from './xerTaskReplayProduct'; +import { + corpusReplayExitCode, + runXerTaskReplayCorpus, + XER_TASK_REPLAY_MEMORY_MODEL, +} from './xerTaskReplayCorpus'; + +const diffs: string[] = []; +let checks = 0; +const planningDirectory = join(process.cwd(), 'tests', 'planning'); + +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} + +const corpusRoot = process.env.OPS_XER_CORPUS; +if (!corpusRoot) { + console.log('XER TASK REPLAY: publiek corpus niet aanwezig; permanente corpusloze fixtures uitgevoerd'); +} else if (!existsSync(corpusRoot)) { + diffs.push('OPS_XER_CORPUS wijst niet naar een bestaande corpusmap'); +} else { + const manifest = JSON.parse(readFileSync(join(planningDirectory, 'xer-corpus-manifest.json'), 'utf8')) as XerCorpusManifest; + const pin = JSON.parse(readFileSync(join(planningDirectory, 'xer-task-replay-public-pin.json'), 'utf8')) as { + version: number; + manifestEntries: number; + selectedEntries: number; + projects: number; + tasks: number; + candidates: Record['aggregate']; + rejected: boolean; + exitCode: number; + memoryModel: typeof XER_TASK_REPLAY_MEMORY_MODEL; + }>; + }; + eq('task replay: openbare instrumentpin heeft schema 1', pin.version, 1); + // Herpin 2026-09-05 (X-O7 laag 1, klasse (i)): de negatieve kandidaat + // `drop-p6-finish-milestone-boundary` verliest sinds `p6CompletedLateFromRemainingWindow` MEER + // cellen (ls 690 → 1466, lf 704 → 1480, tf 629 → 1363, overall 704 → 1481). Dat is de bedoelde + // richting en geen zwakkere poort: het instrument meet hoeveel PRODUCTcellen een mutant kapot + // maakt, en met de nieuwe regel zijn er simpelweg veel meer exacte late-zijde-cellen om kapot te + // maken (corpusbreed ls 4791 → 3901 en lf 4781 → 3891 afwijkingen, zie + // `xer-schedoptions-blast-radius.json`). `synthetic-zero-regression` blijft ongewijzigd op 0 + // regressies — de nulmeting is dus niet meeverschoven. + for (const candidate of [syntheticZeroRegressionCandidate, dropFinishMilestoneBoundaryCandidate]) { + const summary = runXerTaskReplayCorpus({ corpusRoot, manifest, candidate }); + eq(`task replay: openbare pin voor ${candidate.id}`, { + manifestEntries: summary.manifestEntries, + selectedEntries: summary.selectedEntries, + projects: summary.projects, + tasks: summary.tasks, + aggregate: summary.aggregate, + rejected: summary.rejected, + exitCode: corpusReplayExitCode(summary), + memoryModel: summary.memoryModel, + }, { + manifestEntries: pin.manifestEntries, + selectedEntries: pin.selectedEntries, + projects: pin.projects, + tasks: pin.tasks, + ...pin.candidates[candidate.id], + }); + } +} + +function throws(label: string, run: () => unknown, pattern: RegExp): void { + checks++; + try { + run(); + diffs.push(`${label}: verwacht harde fout ${pattern}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!pattern.test(message)) diffs.push(`${label}: onverwachte fout ${JSON.stringify(message)}`); + } +} + +const axes = (overrides: Partial = {}): XerGroundTruthTask['axes'] => ({ + es: '2026-01-05T08:00', + ef: '2026-01-05T16:00', + ls: '2026-01-05T08:00', + lf: '2026-01-05T16:00', + tf: 0, + ff: 0, + ...overrides, +}); + +eq( + 'task replay: productcounterfactual start altijd vanaf een verse source/importclone', + dropFinishMilestoneBoundaryCandidate.replayFrom, + 'source', +); +eq( + 'task replay: geheugenmodel blijft structureel één manifestentry en één solveclone tegelijk', + XER_TASK_REPLAY_MEMORY_MODEL, + 'one-manifest-entry-and-one-project-solve-clone-at-a-time', +); + +function truth(projectId = 'P1', taskCode = 'A100'): XerGroundTruth { + return { + encoding: 'utf-8', + projects: new Set([projectId]), + tasks: [{ + projectId, + taskId: 'T1', + taskCode, + statusCode: 'TK_NotStart', + axes: axes(), + rawDateSeconds: { es: null, ef: null, ls: null, lf: null }, + drivingPath: null, + presentAxes: { es: true, ef: true, ls: true, lf: true, tf: true, ff: true }, + }], + errors: [], + numberFormatIssues: [], + precision: { + dateSecondCells: { es: 0, ef: 0, ls: 0, lf: 0 }, + dateNonZeroSubminuteCells: { es: 0, ef: 0, ls: 0, lf: 0 }, + floatFractionalMinuteCells: { tf: 0, ff: 0 }, + }, + }; +} + +{ + const fixture = (poisonedOracle: boolean) => new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-04-01\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tStandard 5x8\tCA_Base\t8\t40\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date', + '%R\tP\tReplayfixture\tC1\t2026-04-01 08:00\t2026-04-01 08:00', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date\tearly_start_date\tearly_end_date\tlate_start_date\tlate_end_date\ttotal_float_hr_cnt\tfree_float_hr_cnt', + `%R\t1\tP\tC1\tA100\tVoorganger\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-04-01 08:00\t2026-04-01 17:00\t${poisonedOracle ? '2099-01-01 23:59\t2099-01-02 23:59\t2099-01-03 23:59\t2099-01-04 23:59\t999\t998' : '2026-04-01 08:00\t2026-04-01 17:00\t2026-04-01 08:00\t2026-04-01 17:00\t0\t0'}`, + `%R\t2\tP\tC1\tA200\tContracteinde\tTT_FinMile\tDT_FixedDUR\tTK_NotStart\t0\t0\t2026-04-02 08:01\t2026-04-02 08:01\t${poisonedOracle ? '2099-02-01 23:59\t2099-02-02 23:59\t2099-02-03 23:59\t2099-02-04 23:59\t997\t996' : '2026-04-02 08:00\t2026-04-01 17:00\t2026-04-02 08:00\t2026-04-01 17:00\t0\t8'}`, + `%R\t3\tP\tC1\tA300\tLater projecteinde\tTT_FinMile\tDT_FixedDUR\tTK_NotStart\t0\t0\t2026-04-10 08:01\t2026-04-10 08:01\t${poisonedOracle ? '2099-03-01 23:59\t2099-03-02 23:59\t2099-03-03 23:59\t2099-03-04 23:59\t995\t994' : '2026-04-10 08:00\t2026-04-09 17:00\t2026-04-10 08:00\t2026-04-09 17:00\t0\t0'}`, + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%R\tR1\t2\t1\tP\tP\tPR_FS\t0', + '%E', + ].join('\n')); + const normalBytes = fixture(false); + const poisonedBytes = fixture(true); + const lifecycle: Array<{ + projectId: string; + phase: 'baseline' | 'counterfactual'; + inputOrigin: 'fresh-source-clone' | 'baseline-solved-clone'; + activeSolveClones: number; + }> = []; + replayXerProductBeforeOracle(normalBytes, dropFinishMilestoneBoundaryCandidate, { + onLifecycleEvent: event => lifecycle.push(event), + }); + eq('task replay: finish-boundary counterfactual blijft lifecycle-vers vanaf bronimport', lifecycle, [ + { projectId: 'P', phase: 'baseline', inputOrigin: 'fresh-source-clone', activeSolveClones: 1 }, + { projectId: 'P', phase: 'counterfactual', inputOrigin: 'fresh-source-clone', activeSolveClones: 1 }, + ]); + const normalProduct = replayXerProductBeforeOracle(normalBytes, dropFinishMilestoneBoundaryCandidate); + const poisonedProduct = replayXerProductBeforeOracle(poisonedBytes, dropFinishMilestoneBoundaryCandidate); + eq('task replay: raw oracle kan echte predicate en beide solverroutes niet beïnvloeden', { + predicate: poisonedProduct.predicate, + baseline: poisonedProduct.baseline, + counterfactual: poisonedProduct.counterfactual, + }, { + predicate: normalProduct.predicate, + baseline: normalProduct.baseline, + counterfactual: normalProduct.counterfactual, + }); + + const zero = replayXerProductBeforeOracle(normalBytes, syntheticZeroRegressionCandidate); + const zeroResult = evaluateXerTaskReplay({ + oracle: scanXerGroundTruth(normalBytes), + baseline: zero.baseline, + counterfactual: zero.counterfactual, + predicate: zero.predicate, + }); + eq('task replay: synthetische nul-regressiecandidate blijft op alle assen onveranderd', { + aggregate: zeroResult.aggregate, + rejected: shouldRejectXerTaskReplay(zeroResult), + sequentialSolves: zero.projectsSolvedSequentially, + }, { + aggregate: { + es: { improved: 0, regressed: 0, unchanged: 3 }, + ef: { improved: 0, regressed: 0, unchanged: 3 }, + ls: { improved: 0, regressed: 0, unchanged: 3 }, + lf: { improved: 0, regressed: 0, unchanged: 3 }, + tf: { improved: 0, regressed: 0, unchanged: 3 }, + ff: { improved: 0, regressed: 0, unchanged: 3 }, + overall: { improved: 0, regressed: 0, unchanged: 3 }, + }, + rejected: false, + sequentialSolves: 2, + }); +} + +function tasksBySourceId(projects: XerSolvedProject[]): Map { + return new Map(projects.flatMap(project => project.tasks.map(task => [task.sourceTaskId, task] as const))); +} + +function predicateBySourceId(predicateLogs: readonly XerReplayPredicateLog[]): Map { + return new Map(predicateLogs.map(log => [log.sourceTaskId, log] as const)); +} + +{ + const earlyShiftCalendar = '(0||CalendarData()( (0||DaysOfWeek()( (0||1()( (0||0(s|07:00|f|15:00)()))) (0||2()( (0||0(s|07:00|f|15:00)()))) (0||3()( (0||0(s|07:00|f|15:00)()))) (0||4()( (0||0(s|07:00|f|15:00)()))) (0||5()( (0||0(s|07:00|f|15:00)()))) (0||6()()) (0||7()()))) (0||Exceptions()())))'; + const traceFixture = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-06-01\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tproj_id\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tStandard 5x8\tP1\tCA_Project\t8\t40\t', + `%R\tC2\tVroege ploeg\tP1\tCA_Project\t8\t40\t${earlyShiftCalendar}`, + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date', + '%R\tP1\tPlanned floor trace\tC1\t2026-06-01 08:00\t2026-06-01 08:00', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date', + '%R\tFB-P\tP1\tC1\tFB-PRED\tFloor bind pred\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-06-01 08:00\t2026-06-01 17:00', + '%R\tFB-S\tP1\tC1\tFB-SUCC\tFloor bind succ\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-06-04 08:00\t2026-06-04 17:00', + '%R\tOD-P\tP1\tC1\tOD-PRED\tOne day pred\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-06-01 08:00\t2026-06-01 17:00', + '%R\tOD-S\tP1\tC1\tOD-SUCC\tOne day succ\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-06-03 08:00\t2026-06-03 17:00', + '%R\tFN-P\tP1\tC1\tFN-PRED\tFinish not later pred\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-06-01 08:00\t2026-06-01 17:00', + '%R\tFN-S\tP1\tC1\tFN-SUCC\tFinish not later succ\tTT_Task\tDT_FixedDUR\tTK_NotStart\t24\t24\t2026-06-04 08:00\t2026-06-04 17:00', + '%R\tBC-P\tP1\tC2\tBC-PRED\tBoundary pred\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-06-02 07:00\t2026-06-02 15:00', + '%R\tBC-S\tP1\tC1\tBC-SUCC\tBoundary succ\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-06-02 15:00\t2026-06-03 15:00', + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%R\tR-FB\tFB-S\tFB-P\tP1\tP1\tPR_FS\t0', + '%R\tR-OD\tOD-S\tOD-P\tP1\tP1\tPR_FS\t0', + '%R\tR-FN\tFN-S\tFN-P\tP1\tP1\tPR_FS\t0', + '%R\tR-BC\tBC-S\tBC-P\tP1\tP1\tPR_FS\t0', + '%E', + ].join('\n')); + const traceReplay = replayXerProductBeforeOracle(traceFixture, syntheticZeroRegressionCandidate); + const baselineBySourceId = tasksBySourceId(traceReplay.baseline); + const predicateLogs = predicateBySourceId(traceReplay.predicate); + eq('task replay: planned-floor trace family projecteert baseline-uitkomsten en diagnosevelden via het replayharnas', { + floorBinds: { + earlyStart: baselineBySourceId.get('FB-S')?.earlyStart, + earlyFinish: baselineBySourceId.get('FB-S')?.earlyFinish, + source: predicateLogs.get('FB-S')?.source, + }, + exactOneDay: { + earlyStart: baselineBySourceId.get('OD-S')?.earlyStart, + earlyFinish: baselineBySourceId.get('OD-S')?.earlyFinish, + source: predicateLogs.get('OD-S')?.source, + }, + finishNotLater: { + earlyStart: baselineBySourceId.get('FN-S')?.earlyStart, + earlyFinish: baselineBySourceId.get('FN-S')?.earlyFinish, + source: predicateLogs.get('FN-S')?.source, + }, + predecessorBoundary: { + earlyStart: baselineBySourceId.get('BC-S')?.earlyStart, + earlyFinish: baselineBySourceId.get('BC-S')?.earlyFinish, + source: predicateLogs.get('BC-S')?.source, + }, + }, { + floorBinds: { + earlyStart: '2026-06-04T08:00', + earlyFinish: '2026-06-04T17:00', + source: { + p6Source: 'XER', + activityType: 'TT_Task', + plannedFloorTracePreFloorEarlyStart: '2026-06-02T08:00', + plannedFloorTracePreFloorEarlyFinish: '2026-06-02T17:00', + plannedFloorTraceTargetStart: '2026-06-04T08:00', + plannedFloorTraceTargetFinish: '2026-06-04T17:00', + plannedFloorTracePlannedWindowIsLater: true, + plannedFloorTraceBoundarySource: 'relationship', + plannedFloorTraceBoundarySequenceId: 'R-FB', + plannedFloorTraceBoundaryPredecessorTaskCode: 'FB-PRED', + }, + }, + exactOneDay: { + earlyStart: '2026-06-02T08:00', + earlyFinish: '2026-06-02T17:00', + source: { + p6Source: 'XER', + activityType: 'TT_Task', + plannedFloorTracePreFloorEarlyStart: '2026-06-02T08:00', + plannedFloorTracePreFloorEarlyFinish: '2026-06-02T17:00', + plannedFloorTraceTargetStart: '2026-06-03T08:00', + plannedFloorTraceTargetFinish: '2026-06-03T17:00', + plannedFloorTracePlannedWindowIsLater: false, + plannedFloorTraceBoundarySource: 'relationship', + plannedFloorTraceBoundarySequenceId: 'R-OD', + plannedFloorTraceBoundaryPredecessorTaskCode: 'OD-PRED', + }, + }, + finishNotLater: { + earlyStart: '2026-06-02T08:00', + earlyFinish: '2026-06-04T17:00', + source: { + p6Source: 'XER', + activityType: 'TT_Task', + plannedFloorTracePreFloorEarlyStart: '2026-06-02T08:00', + plannedFloorTracePreFloorEarlyFinish: '2026-06-04T17:00', + plannedFloorTraceTargetStart: '2026-06-04T08:00', + plannedFloorTraceTargetFinish: '2026-06-04T17:00', + plannedFloorTracePlannedWindowIsLater: false, + plannedFloorTraceBoundarySource: 'relationship', + plannedFloorTraceBoundarySequenceId: 'R-FN', + plannedFloorTraceBoundaryPredecessorTaskCode: 'FN-PRED', + }, + }, + predecessorBoundary: { + earlyStart: '2026-06-02T15:00', + earlyFinish: '2026-06-03T15:00', + source: { + p6Source: 'XER', + activityType: 'TT_Task', + plannedFloorTracePreFloorEarlyStart: '2026-06-02T15:00', + plannedFloorTracePreFloorEarlyFinish: '2026-06-03T15:00', + plannedFloorTraceTargetStart: '2026-06-02T15:00', + plannedFloorTraceTargetFinish: '2026-06-03T15:00', + plannedFloorTracePlannedWindowIsLater: false, + plannedFloorTraceBoundarySource: 'relationship:p6-predecessor-finish-boundary', + plannedFloorTraceBoundarySequenceId: 'R-BC', + plannedFloorTraceBoundaryPredecessorTaskCode: 'BC-PRED', + }, + }, + }); +} + +{ + const duplicateTaskCodeFixture = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-06-01\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tproj_id\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tStandard 5x8\tP1\tCA_Project\t8\t40\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date', + '%R\tP1\tDuplicate task code trace\tC1\t2026-06-01 08:00\t2026-06-01 08:00', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date', + '%R\tP-A\tP1\tC1\tPRED-A\tVoorganger A\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-06-01 08:00\t2026-06-01 17:00', + '%R\tS-A\tP1\tC1\tDUP\tOpvolger A\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-06-04 08:00\t2026-06-04 17:00', + '%R\tP-B\tP1\tC1\tPRED-B\tVoorganger B\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-06-01 08:00\t2026-06-01 17:00', + '%R\tS-B\tP1\tC1\tDUP\tOpvolger B\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-06-05 08:00\t2026-06-05 17:00', + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%R\tR-A\tS-A\tP-A\tP1\tP1\tPR_FS\t0', + '%R\tR-B\tS-B\tP-B\tP1\tP1\tPR_FS\t0', + '%E', + ].join('\n')); + const identityEchoCandidate: XerTaskReplayCandidate = { + id: 'duplicate-task-code-trace-identity-probe', + replayFrom: 'source', + predicate: context => ({ + matched: false, + source: { + sourceTaskId: context.task.id, + sourceTargetStart: context.task.time.scheduleStart, + incomingSequenceId: context.incoming[0]?.id ?? null, + }, + }), + apply: () => undefined, + }; + const duplicateReplay = replayXerProductBeforeOracle(duplicateTaskCodeFixture, identityEchoCandidate); + const duplicateEvaluation = evaluateXerTaskReplay({ + oracle: scanXerGroundTruth(duplicateTaskCodeFixture), + baseline: duplicateReplay.baseline, + counterfactual: duplicateReplay.counterfactual, + predicate: duplicateReplay.predicate, + }); + const duplicateLogsBySourceId = new Map(duplicateReplay.predicate.map(log => [ + String(log.source.sourceTaskId), + log, + ])); + const duplicateIdentityProjection = (sourceTaskId: string) => { + const source = duplicateLogsBySourceId.get(sourceTaskId)?.source; + return { + sourceTaskId: source?.sourceTaskId, + sourceTargetStart: source?.sourceTargetStart, + incomingSequenceId: source?.incomingSequenceId, + plannedFloorTraceTargetStart: source?.plannedFloorTraceTargetStart, + plannedFloorTraceBoundarySequenceId: source?.plannedFloorTraceBoundarySequenceId, + plannedFloorTraceBoundaryPredecessorTaskCode: source?.plannedFloorTraceBoundaryPredecessorTaskCode, + }; + }; + eq('task replay: task-id-projectie-mutationgate houdt dubbele taskCode-traces bij hun eigen taak', { + evaluatedTaskIds: duplicateEvaluation.tasks.map(task => task.taskId), + first: duplicateIdentityProjection('S-A'), + second: duplicateIdentityProjection('S-B'), + }, { + evaluatedTaskIds: ['P-A', 'S-A', 'P-B', 'S-B'], + first: { + sourceTaskId: 'S-A', + sourceTargetStart: '2026-06-04T08:00', + incomingSequenceId: 'R-A', + plannedFloorTraceTargetStart: '2026-06-04T08:00', + plannedFloorTraceBoundarySequenceId: 'R-A', + plannedFloorTraceBoundaryPredecessorTaskCode: 'PRED-A', + }, + second: { + sourceTaskId: 'S-B', + sourceTargetStart: '2026-06-05T08:00', + incomingSequenceId: 'R-B', + plannedFloorTraceTargetStart: '2026-06-05T08:00', + plannedFloorTraceBoundarySequenceId: 'R-B', + plannedFloorTraceBoundaryPredecessorTaskCode: 'PRED-B', + }, + }); +} + +{ + const noExplicitTargetWindowFixture = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-07-01\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tproj_id\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tStandard 5x8\tP1\tCA_Project\t8\t40\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date', + '%R\tP1\tGeen expliciet targetvenster\tC1\t2026-07-01 08:00\t2026-07-01 08:00', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date', + '%R\tP\tP1\tC1\tPRED\tVoorganger\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t\t', + '%R\tS\tP1\tC1\tNO-PROVENANCE\tOpvolger\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t\t', + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%R\tR\tS\tP\tP1\tP1\tPR_FS\t0', + '%E', + ].join('\n')); + const noExplicitTargetWindowReplay = replayXerProductBeforeOracle( + noExplicitTargetWindowFixture, + syntheticZeroRegressionCandidate, + ); + eq('task replay: geldige fallbackdatums zonder p6ExplicitTargetWindow leveren fail-closed geen trace', + predicateBySourceId(noExplicitTargetWindowReplay.predicate).get('S')?.source, + { p6Source: 'XER', activityType: 'TT_Task' }); +} + +{ + const multipleEqualDriversFixture = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-08-03\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tproj_id\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tStandard 5x8\tP1\tCA_Project\t8\t40\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date', + '%R\tP1\tGelijke drivers\tC1\t2026-08-03 08:00\t2026-08-03 08:00', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date', + '%R\tP1-A\tP1\tC1\tPRED-A\tVoorganger A\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-08-03 08:00\t2026-08-03 17:00', + '%R\tP1-B\tP1\tC1\tPRED-B\tVoorganger B\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-08-03 08:00\t2026-08-03 17:00', + '%R\tS\tP1\tC1\tSUCC\tOpvolger\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-08-06 08:00\t2026-08-06 17:00', + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%R\tR-A\tS\tP1-A\tP1\tP1\tPR_FS\t0', + '%R\tR-B\tS\tP1-B\tP1\tP1\tPR_FS\t0', + '%E', + ].join('\n')); + const replay = replayXerProductBeforeOracle(multipleEqualDriversFixture, syntheticZeroRegressionCandidate); + eq('task replay: meerdere gelijke relatiedrivers leveren fail-closed geen willekeurige trace', + predicateBySourceId(replay.predicate).get('S')?.source, + { p6Source: 'XER', activityType: 'TT_Task' }); +} + +{ + const projectStartBoundaryFixture = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-08-03\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tproj_id\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tStandard 5x8\tP1\tCA_Project\t8\t40\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date', + '%R\tP1\tProjectstartgrens\tC1\t2026-08-03 08:00\t2026-08-03 08:00', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date', + '%R\tP\tP1\tC1\tPRED\tVroege voorganger\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-08-03 08:00\t2026-08-03 17:00', + '%R\tS\tP1\tC1\tSUCC\tLatere opvolger\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-08-10 08:00\t2026-08-10 17:00', + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%R\tR\tS\tP\tP1\tP1\tPR_FS\t-16', + '%E', + ].join('\n')); + const replay = replayXerProductBeforeOracle(projectStartBoundaryFixture, syntheticZeroRegressionCandidate); + const projectStartSource = predicateBySourceId(replay.predicate).get('S')?.source; + eq('task replay: aantoonbare projectstartgrens krijgt trace zonder verzonnen relatiedriver', + { + p6Source: projectStartSource?.p6Source, + activityType: projectStartSource?.activityType, + plannedFloorTracePreFloorEarlyStart: projectStartSource?.plannedFloorTracePreFloorEarlyStart, + plannedFloorTracePreFloorEarlyFinish: projectStartSource?.plannedFloorTracePreFloorEarlyFinish, + plannedFloorTraceTargetStart: projectStartSource?.plannedFloorTraceTargetStart, + plannedFloorTraceTargetFinish: projectStartSource?.plannedFloorTraceTargetFinish, + plannedFloorTraceBoundarySource: projectStartSource?.plannedFloorTraceBoundarySource, + }, + { + p6Source: 'XER', + activityType: 'TT_Task', + plannedFloorTracePreFloorEarlyStart: '2026-08-03T08:00', + plannedFloorTracePreFloorEarlyFinish: '2026-08-03T17:00', + plannedFloorTraceTargetStart: '2026-08-10T08:00', + plannedFloorTraceTargetFinish: '2026-08-10T17:00', + plannedFloorTraceBoundarySource: 'project-start', + }); +} + +{ + const constraintOnlyFixture = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-08-03\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tproj_id\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tStandard 5x8\tP1\tCA_Project\t8\t40\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date', + '%R\tP1\tAlleen constraint\tC1\t2026-08-03 08:00\t2026-08-03 08:00', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date\tcstr_type\tcstr_date', + '%R\tS\tP1\tC1\tCONSTRAINT\tConstraintwortel\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-08-06 08:00\t2026-08-06 17:00\tCS_MSOB\t2026-08-05 08:00', + '%E', + ].join('\n')); + const replay = replayXerProductBeforeOracle(constraintOnlyFixture, syntheticZeroRegressionCandidate); + eq('task replay: constraint-only zonder netwerk- of projectstartdriver levert geen trace', + predicateBySourceId(replay.predicate).get('S')?.source, + { p6Source: 'XER', activityType: 'TT_Task' }); +} + +{ + const invalidTargetFinishFixture = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-08-03\t\t\t\t\t\tEUR', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tproj_id\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tStandard 5x8\tP1\tCA_Project\t8\t40\t', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date\tplan_start_date', + '%R\tP1\tOngeldig targeteinde\tC1\t2026-08-03 08:00\t2026-08-03 08:00', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tduration_type\tstatus_code\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date', + '%R\tP\tP1\tC1\tPRED\tVoorganger\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-08-03 08:00\t2026-08-03 17:00', + '%R\tS\tP1\tC1\tINVALID\tOngeldige opvolger\tTT_Task\tDT_FixedDUR\tTK_NotStart\t8\t8\t2026-08-06 08:00\tniet-een-datum', + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%R\tR\tS\tP\tP1\tP1\tPR_FS\t0', + '%E', + ].join('\n')); + const replay = replayXerProductBeforeOracle(invalidTargetFinishFixture, syntheticZeroRegressionCandidate); + eq('task replay: ongeldig expliciet targeteinde levert fail-closed geen trace', + predicateBySourceId(replay.predicate).get('S')?.source, + { p6Source: 'XER', activityType: 'TT_Task' }); +} + +function solved(projectId = 'P1', taskCode = 'A100', overrides: Partial = {}): XerSolvedProject { + return { + projectId, + tasks: [{ + sourceTaskId: 'T1', + taskCode, + earlyStart: '2026-01-06T08:00', + earlyFinish: '2026-01-06T16:00', + lateStart: '2026-01-05T08:00', + lateFinish: '2026-01-05T16:00', + totalFloatMinutes: 0, + freeFloatMinutes: 0, + ...overrides, + }], + }; +} + +const predicate: XerReplayPredicateLog[] = [{ + projectId: 'P1', sourceTaskId: 'T1', taskCode: 'A100', matched: true, + source: { activityType: 'TT_FinMile' }, +}]; + +{ + const result = evaluateXerTaskReplay({ + oracle: truth(), + baseline: [solved()], + counterfactual: [solved('P1', 'A100', { + earlyStart: '2026-01-05T08:00', earlyFinish: '2026-01-05T16:00', + })], + predicate, + }); + eq('task replay: ES/EF kunnen verbeteren zonder nevenas te veranderen', result.tasks[0]?.axes, { + es: { oracle: '2026-01-05T08:00', baseline: '2026-01-06T08:00', counterfactual: '2026-01-05T08:00', classification: 'improved' }, + ef: { oracle: '2026-01-05T16:00', baseline: '2026-01-06T16:00', counterfactual: '2026-01-05T16:00', classification: 'improved' }, + ls: { oracle: '2026-01-05T08:00', baseline: '2026-01-05T08:00', counterfactual: '2026-01-05T08:00', classification: 'unchanged' }, + lf: { oracle: '2026-01-05T16:00', baseline: '2026-01-05T16:00', counterfactual: '2026-01-05T16:00', classification: 'unchanged' }, + tf: { oracle: 0, baseline: 0, counterfactual: 0, classification: 'unchanged' }, + ff: { oracle: 0, baseline: 0, counterfactual: 0, classification: 'unchanged' }, + }); + eq('task replay: nul regressies blijft groen', { + aggregate: result.aggregate, + rejected: shouldRejectXerTaskReplay(result), + }, { + aggregate: { + es: { improved: 1, regressed: 0, unchanged: 0 }, + ef: { improved: 1, regressed: 0, unchanged: 0 }, + ls: { improved: 0, regressed: 0, unchanged: 1 }, + lf: { improved: 0, regressed: 0, unchanged: 1 }, + tf: { improved: 0, regressed: 0, unchanged: 1 }, + ff: { improved: 0, regressed: 0, unchanged: 1 }, + overall: { improved: 1, regressed: 0, unchanged: 0 }, + }, + rejected: false, + }); +} + +{ + const result = evaluateXerTaskReplay({ + oracle: truth(), + baseline: [solved()], + counterfactual: [solved('P1', 'A100', { + earlyStart: '2026-01-05T08:00', + lateStart: '2026-01-06T08:00', + totalFloatMinutes: 480, + })], + predicate, + }); + eq('task replay: ES-winst met LS/TF-regressie wordt afgewezen', { + classifications: { + es: result.tasks[0]?.axes.es.classification, + ls: result.tasks[0]?.axes.ls.classification, + tf: result.tasks[0]?.axes.tf.classification, + overall: result.tasks[0]?.overall, + }, + rejected: shouldRejectXerTaskReplay(result), + }, { + classifications: { es: 'improved', ls: 'regressed', tf: 'regressed', overall: 'regressed' }, + rejected: true, + }); +} + +{ + const baseline = [solved()]; + const counterfactual = [solved('P1', 'A100', { earlyStart: '2026-01-05T08:00' })]; + const first = evaluateXerTaskReplay({ oracle: truth(), baseline, counterfactual, predicate }); + const poisonedOracle = truth(); + poisonedOracle.tasks[0]!.axes = axes({ es: '2099-12-31T23:59', tf: 999999 }); + const second = evaluateXerTaskReplay({ oracle: poisonedOracle, baseline, counterfactual, predicate }); + eq('task replay: raw oracle kan predicate of solverroute niet beïnvloeden', { + firstPredicate: first.predicate, + secondPredicate: second.predicate, + baselineSame: first.tasks[0]?.axes.es.baseline === second.tasks[0]?.axes.es.baseline, + counterfactualSame: first.tasks[0]?.axes.es.counterfactual === second.tasks[0]?.axes.es.counterfactual, + }, { + firstPredicate: predicate, + secondPredicate: predicate, + baselineSame: true, + counterfactualSame: true, + }); +} + +{ + const base = solved(); + const cf = solved(); + throws('task replay: ontbrekend project is hard rood', () => evaluateXerTaskReplay({ + oracle: truth(), baseline: [], counterfactual: [cf], predicate, + }), /ontbrekend baselineproject P1/); + throws('task replay: extra project is hard rood', () => evaluateXerTaskReplay({ + oracle: truth(), baseline: [base, solved('P2')], counterfactual: [cf], predicate, + }), /extra baselineproject P2/); + throws('task replay: dubbel project is hard rood', () => evaluateXerTaskReplay({ + oracle: truth(), baseline: [base, base], counterfactual: [cf], predicate, + }), /dubbel baselineproject P1/); + throws('task replay: dubbel counterfactualproject is hard rood', () => evaluateXerTaskReplay({ + oracle: truth(), baseline: [base], counterfactual: [cf, cf], predicate, + }), /dubbel counterfactualproject P1/); + throws('task replay: ontbrekende counterfactual-taak-id is hard rood', () => evaluateXerTaskReplay({ + oracle: truth(), baseline: [base], counterfactual: [{ projectId: 'P1', tasks: [] }], predicate, + }), /ontbrekende counterfactualtaak P1\/T1/); + throws('task replay: extra bron-taak-id is hard rood', () => evaluateXerTaskReplay({ + oracle: truth(), baseline: [{ ...base, tasks: [...base.tasks, { ...base.tasks[0]!, sourceTaskId: 'T2', taskCode: 'A200' }] }], + counterfactual: [cf], predicate, + }), /extra baselinetaak P1\/T2/); + throws('task replay: dubbele bron-taak-id is hard rood', () => evaluateXerTaskReplay({ + oracle: truth(), baseline: [{ ...base, tasks: [...base.tasks, base.tasks[0]!] }], counterfactual: [cf], predicate, + }), /dubbele baselinetaak-id P1\/T1/); + throws('task replay: verkeerde baseline sourceTaskId valt niet terug op gelijke taskCode', () => evaluateXerTaskReplay({ + oracle: truth(), + baseline: [solved('P1', 'A100', { sourceTaskId: 'VERKEERD' })], + counterfactual: [cf], + predicate, + }), /ontbrekende baselinetaak P1\/T1/); + throws('task replay: verkeerde counterfactual sourceTaskId valt niet terug op gelijke taskCode', () => evaluateXerTaskReplay({ + oracle: truth(), + baseline: [base], + counterfactual: [solved('P1', 'A100', { sourceTaskId: 'VERKEERD' })], + predicate, + }), /ontbrekende counterfactualtaak P1\/T1/); + throws('task replay: ontbrekende predicate-identiteit is hard rood', () => evaluateXerTaskReplay({ + oracle: truth(), baseline: [base], counterfactual: [cf], predicate: [], + }), /ontbrekende predicate P1\/T1/); + throws('task replay: lege predicate-bronidentiteit is hard rood zonder taskCode-fallback', () => evaluateXerTaskReplay({ + oracle: truth(), baseline: [base], counterfactual: [cf], + predicate: [{ ...predicate[0]!, sourceTaskId: '' }], + }), /predicate mist project-id of bron-taak-id/); + throws('task replay: extra predicate-identiteit is hard rood', () => evaluateXerTaskReplay({ + oracle: truth(), baseline: [base], counterfactual: [cf], + predicate: [...predicate, { + projectId: 'P1', sourceTaskId: 'T2', taskCode: 'A200', matched: false, source: {}, + }], + }), /extra predicate P1\/T2/); + throws('task replay: dubbele predicate-identiteit is hard rood', () => evaluateXerTaskReplay({ + oracle: truth(), baseline: [base], counterfactual: [cf], predicate: [...predicate, ...predicate], + }), /dubbele predicate P1\/T1/); +} + +if (diffs.length > 0) { + console.error(`XER TASK REPLAY RED: ${diffs.length}/${checks} checks rood`); + for (const diff of diffs) console.error(`XX ${diff}`); + process.exit(1); +} +console.log(`XER TASK REPLAY GREEN: ${checks} checks groen`); diff --git a/tests/planning/check-xer-x7-hour-mode.ts b/tests/planning/check-xer-x7-hour-mode.ts new file mode 100644 index 000000000..43a69cf3f --- /dev/null +++ b/tests/planning/check-xer-x7-hour-mode.ts @@ -0,0 +1,233 @@ +import { solveProject } from '@/engine/scheduler/solveProject'; +import { isMultiDocumentImport } from '@/services/importTypes'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; +import { writeIFC } from '@/services/ifc/ifcWriter'; +import { readXER } from '@/services/xer/xerReader'; +import type { ImportResult } from '@/services/importTypes'; + +const diffs: string[] = []; +let checks = 0; +const eq = (label: string, got: unknown, want: unknown): void => { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +}; + +const header = [ + 'ERMHDR\t23.12\t2026-08-01\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date', + '%R\tP1\tX7 uurvelden\tC1\t2026-08-10', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tproj_id\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC1\tWerkweek\tP1\tCA_Project\t8\t40\t', + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tstatus_code\tcomplete_pct_type\tcomplete_pct\tphys_complete_pct\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date\tact_start_date\tact_end_date\tsuspend_date\tresume_date\texpect_end_date', +]; + +function read(rows: string[]): ImportResult { + const opened = readXER(new TextEncoder().encode([...header, ...rows, '%E'].join('\n'))); + if (isMultiDocumentImport(opened)) throw new Error('X7-uurmodusfixture gaf meerdere documenten'); + return opened; +} + +function solve(imported: ImportResult): Map { + const result = solveProject({ + tasks: imported.tasks, + sequences: imported.sequences, + calendar: imported.calendar, + calendars: [imported.calendar, ...(imported.resourceCalendars ?? [])], + dataDate: imported.project.statusDate, + progressMode: imported.project.progressMode, + schedulingOptions: { ...imported.project.schedulingOptions, useExpectedFinishDates: true }, + projectStartDate: imported.project.startDate, + }); + return new Map(imported.tasks.map(task => [task.wbsCode, result.tasks.get(task.id)?.earlyFinish])); +} + +// Contrast 1: als alle drie X7-velden een klok dragen, blijven reader, IFC en solver minuutexact. +const opened = read([ + // Alleen de drie X7-velden dragen een klok. Geen target/actual/constraint-kolom mag uurmodus lekken. + '%R\tH\tP1\tC1\tH\tUurprecisie\tTT_Task\tTK_Active\tCP_Drtn\t25\t\t8\t6\t2026-08-03\t2026-08-03\t2026-08-03\t\t2026-08-04 15:30\t2026-08-06 09:45\t2026-08-12 13:15', +]); +const task = opened.tasks[0]; +eq('reader bewaart uitsluitend X7-kloktijden minuut-exact', { + stop: task?.time.stop, + resume: task?.time.resume, + expected: task?.p6ExpectedFinish, + flag: task?.p6SuspendResume, + hourMode: opened.calendar.workTime !== undefined, +}, { + stop: '2026-08-04T15:30', + resume: '2026-08-06T09:45', + expected: '2026-08-12T13:15', + flag: true, + hourMode: true, +}); + +const reloaded = readIFC(writeIFC(opened)); +const roundTripped = reloaded.tasks.find(candidate => candidate.wbsCode === 'H'); +eq('IFC round-trip bewaart de drie X7-kloktijden minuut-exact', { + stop: roundTripped?.time.stop, + resume: roundTripped?.time.resume, + expected: roundTripped?.p6ExpectedFinish, + flag: roundTripped?.p6SuspendResume, +}, { + stop: '2026-08-04T15:30', + resume: '2026-08-06T09:45', + expected: '2026-08-12T13:15', + flag: true, +}); +eq('all-timestamp-contrast houdt Expected Finish als exact solveranker', + solve(reloaded).get('H'), '2026-08-12T13:15'); + +// Contrast 2: als alle drie X7-velden date-only zijn, blijft de hele keten in dagmodus. +const dayOnly = read([ + '%R\tD\tP1\tC1\tD\tDagprecisie\tTT_Task\tTK_Active\tCP_Drtn\t25\t\t8\t6\t2026-08-03\t2026-08-03\t2026-08-03\t\t2026-08-04\t2026-08-06\t2026-08-12', +]); +const dayTask = dayOnly.tasks[0]; +eq('dezelfde drie date-only X7-velden promoveren een dagmodusbestand niet', { + stop: dayTask?.time.stop, + resume: dayTask?.time.resume, + expected: dayTask?.p6ExpectedFinish, + flag: dayTask?.p6SuspendResume, + hourMode: dayOnly.calendar.workTime !== undefined, +}, { + stop: '2026-08-04', + resume: '2026-08-06', + expected: '2026-08-12', + flag: true, + hourMode: false, +}); +const dayReloaded = readIFC(writeIFC(dayOnly)); +const dayReloadedTask = dayReloaded.tasks.find(candidate => candidate.wbsCode === 'D'); +eq('all-date-only-contrast blijft door IFC exact date-only', { + stop: dayReloadedTask?.time.stop, + resume: dayReloadedTask?.time.resume, + expected: dayReloadedTask?.p6ExpectedFinish, + flag: dayReloadedTask?.p6SuspendResume, +}, { + stop: '2026-08-04', + resume: '2026-08-06', + expected: '2026-08-12', + flag: true, +}); +eq('all-date-only-contrast houdt Expected Finish als daganker', + solve(dayReloaded).get('D'), '2026-08-12'); + +// Reviewfinding A: elk X7-bronveld bezit zijn eigen precisie. Een klok in een buurveld mag de +// vorm van een date-only suspend/resume of Expected Finish niet kalenderbreed promoveren. +const mixed = read([ + '%R\tE\tP1\tC1\tE\tDate-only verwacht einde\tTT_Task\tTK_Active\tCP_Drtn\t25\t\t8\t6\t2026-08-03\t2026-08-03\t2026-08-03\t\t2026-08-04 15:30\t2026-08-06 09:45\t2026-08-12', + '%R\tR\tP1\tC1\tR\tDate-only onderbreking\tTT_Task\tTK_Active\tCP_Drtn\t25\t\t8\t6\t2026-08-03\t2026-08-03\t2026-08-03\t\t2026-08-04\t2026-08-06\t2026-08-12 13:15', +]); +const mixedByCode = new Map(mixed.tasks.map(candidate => [candidate.wbsCode, candidate])); +eq('gemengde reader bewaart per X7-veld exact zijn eigen bronvorm', { + expectedDateOnly: mixedByCode.get('E')?.p6ExpectedFinish, + stopTimestamp: mixedByCode.get('E')?.time.stop, + resumeTimestamp: mixedByCode.get('E')?.time.resume, + stopDateOnly: mixedByCode.get('R')?.time.stop, + resumeDateOnly: mixedByCode.get('R')?.time.resume, + expectedTimestamp: mixedByCode.get('R')?.p6ExpectedFinish, +}, { + expectedDateOnly: '2026-08-12', + stopTimestamp: '2026-08-04T15:30', + resumeTimestamp: '2026-08-06T09:45', + stopDateOnly: '2026-08-04', + resumeDateOnly: '2026-08-06', + expectedTimestamp: '2026-08-12T13:15', +}); + +const mixedReloaded = readIFC(writeIFC(mixed)); +const mixedReloadedByCode = new Map(mixedReloaded.tasks.map(candidate => [candidate.wbsCode, candidate])); +eq('gemengde IFC-round-trip bewaart per X7-veld exact zijn eigen bronvorm', { + expectedDateOnly: mixedReloadedByCode.get('E')?.p6ExpectedFinish, + stopTimestamp: mixedReloadedByCode.get('E')?.time.stop, + resumeTimestamp: mixedReloadedByCode.get('E')?.time.resume, + stopDateOnly: mixedReloadedByCode.get('R')?.time.stop, + resumeDateOnly: mixedReloadedByCode.get('R')?.time.resume, + expectedTimestamp: mixedReloadedByCode.get('R')?.p6ExpectedFinish, +}, { + expectedDateOnly: '2026-08-12', + stopTimestamp: '2026-08-04T15:30', + resumeTimestamp: '2026-08-06T09:45', + stopDateOnly: '2026-08-04', + resumeDateOnly: '2026-08-06', + expectedTimestamp: '2026-08-12T13:15', +}); +eq('gemengde solver gebruikt date-only Expected Finish als einde van die werkdag, nooit T00:00', + [...solve(mixedReloaded).entries()], [ + ['E', '2026-08-12T16:00'], + ['R', '2026-08-12T13:15'], + ]); + +// Reviewfinding B: een date-only Expected Finish is een FINISH-dag. De solver zoekt daarom het +// laatste effectieve band-einde op of vóór die datum. Deze fixture loopt per case door de volledige +// XER→IFC→XER-store→solverketen en onderscheidt weekband, werkende uitzondering, holiday, +// weekend, exact timestamp-anker en een aantoonbaar bandloze kalender. +const weekBands = '(0||DaysOfWeek()(' + + '(0||2()((0||0(s|08:00|f|16:00)())))' + + '(0||3()((0||0(s|08:00|f|16:00)())))' + + '(0||4()((0||0(s|08:00|f|16:00)())))' + + '(0||5()((0||0(s|08:00|f|16:00)())))' + + '(0||6()((0||0(s|08:00|f|16:00)())))' + + '))'; +const normalCalendarData = `(0||CalendarData()(${weekBands}(0||Exceptions()())))`; +const exceptionBand = '(0||0(s|06:00|f|12:00)())'; +const exceptionRecord = `(0||0(d|46246)(${exceptionBand}))`; +const holidayRecord = '(0||0(d|46246)())'; +const exceptionCalendarData = `(0||CalendarData()(${weekBands}(0||Exceptions()(${exceptionRecord}))))`; +const holidayCalendarData = `(0||CalendarData()(${weekBands}(0||Exceptions()(${holidayRecord}))))`; +const noWorkCalendarData = '(0||CalendarData()((0||DaysOfWeek()())(0||Exceptions()())))'; +const finishDayXer = [ + 'ERMHDR\t23.12\t2026-08-01\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date', + '%R\tP1\tX7 finishdag\tCN\t2026-08-10', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tproj_id\tclndr_type\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + `%R\tCN\tNormaal\tP1\tCA_Project\t8\t40\t${normalCalendarData}`, + `%R\tCX\tWerkende uitzondering\tP1\tCA_Project\t8\t40\t${exceptionCalendarData}`, + `%R\tCH\tHoliday\tP1\tCA_Project\t8\t40\t${holidayCalendarData}`, + `%R\tCZ\tGeen werk\tP1\tCA_Project\t8\t0\t${noWorkCalendarData}`, + '%T\tTASK', + '%F\ttask_id\tproj_id\tclndr_id\ttask_code\ttask_name\ttask_type\tstatus_code\tcomplete_pct_type\tcomplete_pct\tphys_complete_pct\ttarget_drtn_hr_cnt\tremain_drtn_hr_cnt\ttarget_start_date\ttarget_end_date\tact_start_date\tact_end_date\tsuspend_date\tresume_date\texpect_end_date', + '%R\tN\tP1\tCN\tN\tNormale werkdag\tTT_Task\tTK_Active\tCP_Drtn\t25\t\t8\t6\t2026-08-03\t2026-08-03\t2026-08-03\t\t2026-08-04 15:30\t2026-08-06 09:45\t2026-08-12', + '%R\tX\tP1\tCX\tX\tWerkende uitzondering\tTT_Task\tTK_Active\tCP_Drtn\t25\t\t8\t6\t2026-08-03\t2026-08-03\t2026-08-03\t\t2026-08-04 15:30\t2026-08-06 09:45\t2026-08-12', + '%R\tH\tP1\tCH\tH\tVolledige holiday\tTT_Task\tTK_Active\tCP_Drtn\t25\t\t8\t6\t2026-08-03\t2026-08-03\t2026-08-03\t\t2026-08-04 15:30\t2026-08-06 09:45\t2026-08-12', + '%R\tS\tP1\tCN\tS\tGewone zaterdag\tTT_Task\tTK_Active\tCP_Drtn\t25\t\t8\t6\t2026-08-03\t2026-08-03\t2026-08-03\t\t2026-08-04 15:30\t2026-08-06 09:45\t2026-08-15', + '%R\tT\tP1\tCH\tT\tTimestamp op holiday\tTT_Task\tTK_Active\tCP_Drtn\t25\t\t8\t6\t2026-08-03\t2026-08-03\t2026-08-03\t\t\t\t2026-08-12 13:15', + '%R\tF\tP1\tCZ\tF\tGeen werkbandpunt\tTT_Task\tTK_Active\tCP_Drtn\t25\t\t8\t6\t2026-08-03\t2026-08-03\t2026-08-03\t\t2026-08-04 15:30\t2026-08-06 09:45\t2026-08-12', + '%E', +]; +const finishDaysOpened = readXER(new TextEncoder().encode(finishDayXer.join('\n'))); +if (isMultiDocumentImport(finishDaysOpened)) throw new Error('finishdagfixture gaf meerdere documenten'); +const finishDaysReloaded = readIFC(writeIFC(finishDaysOpened)); +const openedFinishByCode = new Map(finishDaysOpened.tasks.map(candidate => [candidate.wbsCode, candidate])); +const reloadedFinishByCode = new Map(finishDaysReloaded.tasks.map(candidate => [candidate.wbsCode, candidate])); +const solvedFinishDays = solve(finishDaysReloaded); +const finishDayResult = (code: string): [string | undefined, string | undefined, string | undefined] => [ + openedFinishByCode.get(code)?.p6ExpectedFinish, + reloadedFinishByCode.get(code)?.p6ExpectedFinish, + solvedFinishDays.get(code), +]; +eq('finishdag a: normale werkdag gebruikt eigen laatste band', + finishDayResult('N'), ['2026-08-12', '2026-08-12', '2026-08-12T16:00']); +eq('finishdag b: werkende uitzondering gebruikt exceptionband 06-12', + finishDayResult('X'), ['2026-08-12', '2026-08-12', '2026-08-12T12:00']); +eq('finishdag c: volledige holiday gebruikt vorige effectieve werkdag', + finishDayResult('H'), ['2026-08-12', '2026-08-12', '2026-08-11T16:00']); +eq('finishdag d: gewone zaterdag gebruikt vorige effectieve werkdag', + finishDayResult('S'), ['2026-08-15', '2026-08-15', '2026-08-14T16:00']); +eq('finishdag e: timestamp op niet-werkdag blijft exact en wordt nooit gesnapt', + finishDayResult('T'), ['2026-08-12T13:15', '2026-08-12T13:15', '2026-08-12T13:15']); +eq('finishdag f: bandloze kalender slaat override begrensd over zonder middernacht te verzinnen', + finishDayResult('F'), ['2026-08-12', '2026-08-12', '2026-08-06T09:45']); + +if (diffs.length > 0) { + console.error(`XER-X7-uurmodus: ${diffs.length}/${checks} checks rood`); + for (const diff of diffs) console.error(`XX ${diff}`); + process.exit(1); +} +console.log(`OK XER-X7-uurmodus: ${checks} checks groen`); diff --git a/tests/planning/check-xer-x9-verdicts.ts b/tests/planning/check-xer-x9-verdicts.ts new file mode 100644 index 000000000..2277f8cad --- /dev/null +++ b/tests/planning/check-xer-x9-verdicts.ts @@ -0,0 +1,42 @@ +// X9 — expliciete grenzen: MCP mag P6-bronvelden niet als invoer herschrijven en moveProject +// behoudt ze als data, zonder ze aan de solver als invoer te geven. +import { readFileSync } from 'node:fs'; +import { useAppStore } from '@/state/appStore'; +import type { Task } from '@/types/task'; +import { createDefaultTaskTime } from '@/utils/taskDefaults'; + +declare const process: { exit(code: number): never }; +const failures: string[] = []; +const expect = (label: string, condition: boolean) => { if (!condition) failures.push(label); }; +const store = () => useAppStore.getState(); +store().newProject(); +store().setProject({ startDate: '2032-03-02' }); +const id = store().addTask({ name: 'P6 verdict' }); +const before = store().tasks.find(task => task.id === id)!; +store().updateTask(id, { + p6ProjectId: 'P-X9', p6TaskId: 'T-X9', p6ExplicitTargetWindow: true, p6CompletePctType: 'CP_Phys', + p6ExpectedFinish: '2032-03-07', p6DurationType: 'DT_FixedRate', p6ActivityType: 'TT_LOE', + p6SuspendResume: true, time: { ...before.time, ...createDefaultTaskTime('2032-03-02', 1), stop: '2032-03-03', resume: '2032-03-04' }, +} as Partial); +const result = store().moveProject('2032-03-09'); +const after = store().tasks.find(task => task.id === id); +expect('1 moveProject accepteert de planningverschuiving', result.moved === true); +const afterP6 = [ + after?.p6ProjectId, after?.p6TaskId, after?.p6ExplicitTargetWindow, after?.p6CompletePctType, after?.p6ExpectedFinish, + after?.p6DurationType, after?.p6ActivityType, after?.p6SuspendResume, +]; +expect('2 moveProject bewaart X0/X7 P6-data en verschuift alleen de semantische finishdatum', + JSON.stringify(afterP6) === JSON.stringify(['P-X9', 'T-X9', true, 'CP_Phys', '2032-03-14', 'DT_FixedRate', 'TT_LOE', true])); +const mcpFields = readFileSync(new URL('../../src/services/mcp/tools/taskFields.ts', import.meta.url), 'utf8'); +expect('3 MCP-verdict blokkeert P6 DurationType als agentinvoer', /p6DurationType: 'P6\\'s eigen Duration Type is via de bridge niet zetbaar/.test(mcpFields)); +expect('4 MCP-verdict blokkeert P6 ActivityType als agentinvoer', /p6ActivityType: 'P6\\'s eigen Activity Type is via de bridge niet zetbaar/.test(mcpFields)); +expect('5 MCP-verdict blokkeert expliciete targetvensterprovenance als agentinvoer', /p6ExplicitTargetWindow: 'de P6-XER-provenance/.test(mcpFields)); +expect('6 solverfirewall blijft in de XER-chunkcheck permanent bewaakt', + readFileSync(new URL('./check-xer-chunk-boundary.ts', import.meta.url), 'utf8').includes("'early_', 'late_', 'restart_date', 'reend_date'")); +if (failures.length === 0) { + console.log('OK xer-x9-verdicts: MCP- en moveProject-grenzen expliciet'); + process.exit(0); +} +console.log(`XX xer-x9-verdicts: ${failures.length} afwijking(en)`); +for (const failure of failures) console.log(` - ${failure}`); +process.exit(1); diff --git a/tests/planning/fidelityCore.ts b/tests/planning/fidelityCore.ts new file mode 100644 index 000000000..204b815d3 --- /dev/null +++ b/tests/planning/fidelityCore.ts @@ -0,0 +1,119 @@ +/** + * Formaat-agnostische fidelitykern voor de onafhankelijke bestandsmeetlatten. + * + * Deze module weet niets van MPP, XER, parsers of de solver. Een formaatspecifieke laag levert + * alleen een identiteit plus per as de eigen en orakelwaarde. De kern maakt daar één stabiele + * rijvorm en onafhankelijke delta-administratie van. Zo delen MPP en XER wel de meetregels, maar + * nooit hun grondwaarheidparser. + */ + +export type FidelityVerdict = 'exact' | 'sameday' | 'diff' | 'missing'; +export type FidelityComparator = (ours: string | undefined, truth: string | null) => FidelityVerdict; + +const dayOf = (value: string): string => value.slice(0, 10); + +/** Vergelijk twee ISO-instantstrings; date-only invoer blijft compatibel met de MPP-meetlat. */ +export function classify(ours: string | undefined, truth: string | null): FidelityVerdict { + if (!truth || !ours) return 'missing'; + if (ours.length >= 16 && ours.slice(0, 16) === truth.slice(0, 16)) return 'exact'; + if (ours.length === 10 && truth.slice(11, 16) === '00:00' && ours === dayOf(truth)) return 'exact'; + if (dayOf(ours) === dayOf(truth)) return 'sameday'; + return 'diff'; +} + +/** Strikte vergelijking voor scalairen zoals floatminuten en vlaggen. */ +export function classifyExact(ours: string | undefined, truth: string | null): FidelityVerdict { + if (truth === null || ours === undefined) return 'missing'; + return ours === truth ? 'exact' : 'diff'; +} + +const CANONICAL_MINUTE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/; + +function isCanonicalMinute(value: string): boolean { + const match = CANONICAL_MINUTE.exec(value); + if (!match) return false; + const [, year, month, day, hour, minute] = match; + const date = new Date(`${year}-${month}-${day}T${hour}:${minute}:00Z`); + return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 16) === value; +} + +/** XER-minuutvergelijking: exact blijft de enige groene uitkomst; een geldige andere minuut op + * dezelfde datum krijgt de diagnostische `sameday`-bak en blijft dus een deviation. */ +export function classifyMinuteExact( + ours: string | undefined, + truth: string | null, +): FidelityVerdict { + if (truth === null || ours === undefined) return 'missing'; + if (!isCanonicalMinute(ours) || !isCanonicalMinute(truth)) return 'diff'; + if (ours === truth) return 'exact'; + return dayOf(ours) === dayOf(truth) ? 'sameday' : 'diff'; +} + +export interface FidelityValuePair { + ours: string | undefined; + truth: string | null; +} + +export interface FidelityDelta extends FidelityValuePair { + verdict: FidelityVerdict; +} + +export interface FidelityComparisonRow { + identity: string; + axes: Record; +} + +/** Maak één rij; de literal-asvorm blijft behouden in het resultaattype. */ +export function compareFidelityRow( + identity: string, + values: Record, + comparators: Partial> = {}, +): FidelityComparisonRow { + const axes = {} as Record; + for (const axis of Object.keys(values) as Axis[]) { + const pair = values[axis]; + axes[axis] = { ...pair, verdict: (comparators[axis] ?? classify)(pair.ours, pair.truth) }; + } + return { identity, axes }; +} + +export interface FidelityCounts { + exact: number; + sameday: number; + diff: number; + missing: number; + /** Aantal aanwezige orakelcellen, onafhankelijk van wat de lezer oplevert. */ + measurable: number; + /** Elk meetbaar veld dat niet exact is, inclusief een ontbrekende eigen waarde. */ + deviations: number; +} + +export function emptyFidelityCounts(): FidelityCounts { + return { exact: 0, sameday: 0, diff: 0, missing: 0, measurable: 0, deviations: 0 }; +} + +export function addFidelityCounts(target: FidelityCounts, source: FidelityCounts): void { + target.exact += source.exact; + target.sameday += source.sameday; + target.diff += source.diff; + target.missing += source.missing; + target.measurable += source.measurable; + target.deviations += source.deviations; +} + +/** Tel één as; meetbaarheid volgt uitsluitend het orakel en kan dus niet stil door de lezer dalen. */ +export function countFidelityAxis( + rows: readonly FidelityComparisonRow[], + axis: Axis, +): FidelityCounts { + const counts = emptyFidelityCounts(); + for (const row of rows) { + const delta = row.axes[axis]; + counts[delta.verdict]++; + if (delta.truth !== null) { + counts.measurable++; + if (delta.verdict !== 'exact') counts.deviations++; + } + } + return counts; +} diff --git a/tests/planning/measure-xer-recovery-write-amplification.ts b/tests/planning/measure-xer-recovery-write-amplification.ts new file mode 100644 index 000000000..2d9e539d3 --- /dev/null +++ b/tests/planning/measure-xer-recovery-write-amplification.ts @@ -0,0 +1,145 @@ +/** + * Snelle, corpusloze opslaggrensmeting voor het gelande delta-recoverypakket. Deze check draait + * expliciet via `tests/planning/run.sh`; de echte OZB/rehab-proef staat daarnaast in + * `check-xer-archive-recovery-corpus.ts`. + * + * Contractknip: + * 1. `src/hooks/useAutoSave.ts` vergelijkt `IFCSaveSource` met `sameIFCSource` (isDirty is geen + * revisie-id) en biedt alle manifestmetadata plus uitsluitend nieuwe/gewijzigde IFC-upserts aan; + * 2. Tauri schrijft een v3-manifest naar immutable generatie-snapshots, rename't het manifest als + * commitpoint en ruimt alleen ná die commit op; v1/v2 blijven leesbaar; + * 3. IndexedDB doet upserts, manifest en deletes in één strikte readwrite-transactie, zonder de + * losse `idbPut`-helpers; een schrijffout promoot de persisted baseline nooit. + * + * De grens is structureel: één gewijzigde documentinhoud = één doc-upsert plus één manifest-put + * in één transactie. Walltime en RSS horen hier bewust niet thuis; die worden in verse processen + * gemeten en informatief gelogd door de corpuscheck. + */ +import { clearRecovery, loadRecovery, saveRecovery } from '@/services/recovery/recoveryStore'; + +declare const process: { exit(code: number): never }; + +interface StoredRecord { id: string; kind: 'doc' | 'manifest'; ifc?: string; } + +const records = new Map(); +let documentWrites = 0; +let unchangedDocumentWrites = 0; +let manifestWrites = 0; +let readwriteTransactions = 0; +const fakeDb = { + objectStoreNames: { contains: () => true }, + createObjectStore: () => undefined, + close: () => undefined, + onversionchange: null as (() => void) | null, + transaction: (_store: string, mode: string) => { + if (mode === 'readwrite') readwriteTransactions += 1; + const tx = { + oncomplete: null as (() => void) | null, + onerror: null as (() => void) | null, + error: null, + objectStore: () => ({ + getAll: () => { + const request = { result: [] as StoredRecord[], error: null, onsuccess: null as (() => void) | null, onerror: null as (() => void) | null }; + queueMicrotask(() => { request.result = [...records.values()]; request.onsuccess?.(); }); + return request; + }, + put: (value: StoredRecord) => { + const previous = records.get(value.id); + if (value.kind === 'doc') documentWrites += 1; + if (value.kind === 'manifest') manifestWrites += 1; + if (value.kind === 'doc' && previous?.kind === 'doc' && previous.ifc === value.ifc) { + unchangedDocumentWrites += 1; + } + records.set(value.id, structuredClone(value)); + queueMicrotask(() => tx.oncomplete?.()); + }, + delete: (id: string) => { + records.delete(id); + queueMicrotask(() => tx.oncomplete?.()); + }, + }), + }; + return tx; + }, +}; +(globalThis as unknown as { window: object }).window = {}; +(globalThis as unknown as { indexedDB: unknown }).indexedDB = { + open: () => { + const request = { + result: fakeDb, + error: null, + onupgradeneeded: null as (() => void) | null, + onsuccess: null as (() => void) | null, + onerror: null as (() => void) | null, + }; + queueMicrotask(() => { request.onupgradeneeded?.(); request.onsuccess?.(); }); + return request; + }, +}; + +const docs = Array.from({ length: 12 }, (_, index) => ({ + id: `doc-${String(index + 1).padStart(2, '0')}`, + ifc: `IFC-${index + 1}-ongewijzigd`, + filePath: null, + isDirty: true, + datesAsRecorded: false, +})); + +await clearRecovery(); +await saveRecovery({ + activeDocumentId: docs[0]!.id, + documents: docs.map(({ id, filePath, isDirty }) => ({ id, filePath, isDirty, datesAsRecorded: false })), + upserts: docs, +}); +documentWrites = 0; +unchangedDocumentWrites = 0; +manifestWrites = 0; +readwriteTransactions = 0; +const changed = docs.map((doc, index) => index === 0 ? { ...doc, ifc: 'IFC-1-gemuteerd' } : doc); +await saveRecovery({ + activeDocumentId: changed[0]!.id, + documents: changed.map(({ id, filePath, isDirty }) => ({ id, filePath, isDirty, datesAsRecorded: false })), + upserts: [changed[0]!], +}); +const changedWrites = documentWrites; +const changedManifestWrites = manifestWrites; +const changedTransactions = readwriteTransactions; +const changedRecovery = await loadRecovery(); +documentWrites = 0; +unchangedDocumentWrites = 0; +manifestWrites = 0; +readwriteTransactions = 0; +await saveRecovery({ + activeDocumentId: changed[1]!.id, + documents: changed.map(({ id, filePath, isDirty }) => ({ id, filePath, isDirty, datesAsRecorded: false })), + upserts: [], +}); +const metadataOnlyWrites = documentWrites; +const metadataOnlyManifestWrites = manifestWrites; +const metadataOnlyTransactions = readwriteTransactions; +const metadataRecovery = await loadRecovery(); +await clearRecovery(); + +if ( + changedWrites === 1 + && unchangedDocumentWrites === 0 + && changedManifestWrites === 1 + && changedTransactions === 1 + && changedRecovery.docs.length === 12 + && changedRecovery.docs[0]?.ifc === 'IFC-1-gemuteerd' + && metadataOnlyWrites === 0 + && metadataOnlyManifestWrites === 1 + && metadataOnlyTransactions === 1 + && metadataRecovery.activeDocumentId === changed[1]!.id +) { + console.log('OK X9 delta-recovery: één mutatie schrijft één IFC-record; actieve-tabwissel is manifest-only'); + process.exit(0); +} +console.log( + `RED X9 delta-recovery: changed-doc-writes=${changedWrites}; unchanged=${unchangedDocumentWrites}; ` + + `changed-manifest=${changedManifestWrites}; changed-transactions=${changedTransactions}; ` + + `metadata-doc-writes=${metadataOnlyWrites}; metadata-manifest=${metadataOnlyManifestWrites}; ` + + `metadata-transactions=${metadataOnlyTransactions}.`, +); +console.log(' Knip: manifestmetadata + delta-upserts → één strikte IndexedDB-transactie → herstelcontract.'); +process.exit(1); diff --git a/tests/planning/mppFidelity.ts b/tests/planning/mppFidelity.ts index 4f8960c47..bb96c5949 100644 --- a/tests/planning/mppFidelity.ts +++ b/tests/planning/mppFidelity.ts @@ -23,24 +23,17 @@ import { readMPP } from '@/services/mpp/mppReader'; import { solveProject } from '@/engine/scheduler/solveProject'; import { scanGroundTruthTasks, type RawTask } from './mppGroundTruth'; +import { classify, compareFidelityRow, countFidelityAxis } from './fidelityCore'; import { parseInstant } from '@/utils/dateUtils'; import type { Task } from '@/types/task'; import type { Sequence } from '@/types/sequence'; import type { Project } from '@/types/project'; +export { classify } from './fidelityCore'; + const iso = (d: Date | null): string | null => (d ? d.toISOString().slice(0, 16) : null); const dayOf = (s: string): string => s.slice(0, 10); -/** Vergelijk twee instant-strings (onze vorm kan date-only zijn in dagmodus). */ -export function classify(ours: string | undefined, truth: string | null): 'exact' | 'sameday' | 'diff' | 'missing' { - if (!truth) return 'missing'; - if (!ours) return 'missing'; - if (ours.length >= 16 && ours.slice(0, 16) === truth) return 'exact'; - if (ours.length === 10 && truth.slice(11) === '00:00' && ours === dayOf(truth)) return 'exact'; - if (dayOf(ours) === dayOf(truth)) return 'sameday'; - return 'diff'; -} - function dayDelta(ours: string, truth: string): number { const a = parseInstant(dayOf(ours)).getTime(); const b = parseInstant(dayOf(truth)).getTime(); @@ -216,21 +209,25 @@ export function measureFidelity(bytes: Uint8Array): FidelityRow { predsOf.set(s.successorId, list); } - let se = 0, ss = 0, sd = 0, fe = 0, fs = 0, fd = 0; + const comparisonRows = tasks.map(t => compareFidelityRow(t.id, { + start: { ours: t.time.earlyStart, truth: truthStart.get(t.id) ?? null }, + finish: { ours: t.time.earlyFinish, truth: truthFinish.get(t.id) ?? null }, + })); + const startCounts = countFidelityAxis(comparisonRows, 'start'); + const finishCounts = countFidelityAxis(comparisonRows, 'finish'); const buckets: Record = {}; const bump = (k: string) => { buckets[k] = (buckets[k] ?? 0) + 1; }; const diffTasks: DiffTaskDetail[] = []; - for (const t of tasks) { + for (let taskIndex = 0; taskIndex < tasks.length; taskIndex++) { + const t = tasks[taskIndex]; + const comparison = comparisonRows[taskIndex]; const truthS = truthStart.get(t.id) ?? null; const truthF = truthFinish.get(t.id) ?? null; - const cs = classify(t.time.earlyStart, truthS); - const cf = classify(t.time.earlyFinish, truthF); + const cs = comparison.axes.start.verdict; + const cf = comparison.axes.finish.verdict; - if (cs === 'exact') se++; - else if (cs === 'sameday') ss++; - else if (cs === 'diff') { - sd++; + if (cs === 'diff') { // L7 (reviewbevinding): géén `truthS && dayDelta(...) !== 0`-guard meer — beide waren dode // conditie. `classify()` geeft 'diff' UITSLUITEND terug wanneer `truth` niet-null was (anders // 'missing') én de dag verschilt (anders 'exact'/'sameday'), dus `truthS` is hier altijd @@ -245,10 +242,6 @@ export function measureFidelity(bytes: Uint8Array): FidelityRow { bump('totaal'); } - if (cf === 'exact') fe++; - else if (cf === 'sameday') fs++; - else if (cf === 'diff') fd++; - if (cs !== 'exact' || cf !== 'exact') { const mark = (c: ReturnType): ' ' | '~' | 'X' => (c === 'exact' ? ' ' : c === 'sameday' ? '~' : 'X'); diffTasks.push({ @@ -274,8 +267,8 @@ export function measureFidelity(bytes: Uint8Array): FidelityRow { return { status: 'ok', tasks: tasks.length, - startExact: se, startSameday: ss, startDiff: sd, - finishExact: fe, finishSameday: fs, finishDiff: fd, + startExact: startCounts.exact, startSameday: startCounts.sameday, startDiff: startCounts.diff, + finishExact: finishCounts.exact, finishSameday: finishCounts.sameday, finishDiff: finishCounts.diff, diffBuckets: buckets, diffTasks, }; diff --git a/tests/planning/run-xer-task-replay.ts b/tests/planning/run-xer-task-replay.ts new file mode 100644 index 000000000..c66fb78d1 --- /dev/null +++ b/tests/planning/run-xer-task-replay.ts @@ -0,0 +1,64 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { XER_FIDELITY_AXES } from './xerGroundTruth'; +import type { XerCorpusManifest } from './xerFidelity'; +import { corpusReplayExitCode, runXerTaskReplayCorpus } from './xerTaskReplayCorpus'; +import { + dropFinishMilestoneBoundaryCandidate, + syntheticZeroRegressionCandidate, +} from './xerTaskReplayProduct'; + +const args = process.argv.slice(2); +const value = (flag: string): string | undefined => { + const index = args.indexOf(flag); + return index < 0 ? undefined : args[index + 1]; +}; +const corpusRoot = value('--corpus'); +const manifestPath = value('--manifest') ?? join(process.cwd(), 'tests/planning/xer-corpus-manifest.json'); +const candidateName = value('--candidate') ?? 'finish-milestone-boundary'; +const report = value('--report') ?? 'summary'; +if (!corpusRoot) throw new Error('gebruik: --corpus [--candidate finish-milestone-boundary|zero] [--report summary|detail]'); +if (report !== 'summary' && report !== 'detail') throw new Error(`onbekend rapporttype: ${report}`); +const candidate = candidateName === 'finish-milestone-boundary' + ? dropFinishMilestoneBoundaryCandidate + : candidateName === 'zero' + ? syntheticZeroRegressionCandidate + : undefined; +if (!candidate) throw new Error(`onbekende kandidaat: ${candidateName}`); +const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as XerCorpusManifest; + +const summary = runXerTaskReplayCorpus({ + corpusRoot, + manifest, + candidate, + onEntry: entry => { + console.log(JSON.stringify({ + type: 'entry', + candidateId: candidate.id, + label: entry.label, + schemaFingerprint: entry.schemaFingerprint, + projects: entry.projects, + tasks: entry.tasks, + projectsSolvedSequentially: entry.projectsSolvedSequentially, + aggregate: entry.replay.aggregate, + })); + if (report !== 'detail') return; + for (const predicate of entry.replay.predicate) { + console.log(JSON.stringify({ type: 'predicate', candidateId: candidate.id, label: entry.label, ...predicate })); + } + for (const task of entry.replay.tasks) { + console.log(JSON.stringify({ + type: 'task', + candidateId: candidate.id, + label: entry.label, + projectId: task.projectId, + taskId: task.taskId, + taskCode: task.taskCode, + axes: Object.fromEntries(XER_FIDELITY_AXES.map(axis => [axis, task.axes[axis]])), + overall: task.overall, + })); + } + }, +}); +console.log(JSON.stringify({ type: 'summary', ...summary })); +process.exitCode = corpusReplayExitCode(summary); diff --git a/tests/planning/run.sh b/tests/planning/run.sh index ca0eea70b..bbbb28683 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -41,6 +41,7 @@ bundle_check () { # lekte mee zodra we stderr doorlieten. Dit dempt de samenvatting maar laat fouten staan. if ! "$ROOT/node_modules/.bin/esbuild" "$src" --log-level=error \ --bundle --platform=node --format=esm --alias:@="$ROOT/src" \ + --external:react-dom/server \ --define:import.meta.env.DEV=false \ --define:import.meta.env.PROD=true \ --define:import.meta.env.MODE='"production"' \ @@ -73,11 +74,19 @@ EXPECTED_BATTERIES=( msp-pariteit probes progress relations resource-leveling resource-load view ) +# JSON-meetlatdata met de verplichte `cases-*.json`-naam, maar NIET het CPM-harnessschema. Elk +# bestand hier heeft een eigen check-script verderop; zonder deze expliciete grens zou de glob het +# als een gewone batterij proberen te valideren en uitvoeren. +is_auxiliary_case_data () { + [ "$(basename "$1")" = "cases-p6-verified.json" ] +} + check_batteries () { local f base b missing=() unexpected=() local -A want=() have=() for b in "${EXPECTED_BATTERIES[@]}"; do want[$b]=1; done for f in "$DIR"/cases-*.json; do + if is_auxiliary_case_data "$f"; then continue; fi base="$(basename "$f")"; base="${base#cases-}"; base="${base%.json}" have[$base]=1 if [ -z "${want[$base]:-}" ]; then unexpected+=("$base"); fi @@ -111,9 +120,17 @@ check_batteries # van EXPECTED_BATTERIES/check_batteries hierboven, maar dan voor de losse check-scripts i.p.v. # de cases-*.json-batterijen. CHECK_SCRIPT_ALLOWLIST=( - # (voorlopig leeg — elke check-*.ts hoort via een `if bundle_check ...`-regel aangesloten te - # zijn. Een bewust handmatige/rode check hoort hier met een regel die uitlegt waarom, plus een + # (Een bewust handmatige/rode check hoort hier met een regel die uitlegt waarom, plus een # verwijzing naar de bijbehorende docs/TODO.md-notitie.) + # + # De twee X8-cataloguscontracten hieronder zijn PUUR compile-time: nul runtime-asserties, ze + # bestaan alleen uit `@ts-expect-error`-regels die vastleggen dat de gedeelde XER-catalogus diep + # readonly blijft terwijl de per-projectmaterialisatie mutabel blijft. Bundelen en draaien zou + # niets meten (een lege module). De afdwinging zit in `tsc`: `tsconfig.tests.json` heeft + # `include: ["scripts", "tests", ...]`, en onder die config is een ONGEBRUIKTE + # `@ts-expect-error` zelf een fout — verdwijnt het readonly, dan valt `npm run typecheck` om. + check-xer-metadata-catalog-types.ts + check-xer-resource-catalog-types.ts ) check_check_scripts () { @@ -187,7 +204,10 @@ if [ "$#" -gt 0 ]; then FILES=("${CASE_FILES[@]}") RUN_HOLIDAYS=0 else - FILES=("$DIR"/cases-*.json) + FILES=() + for f in "$DIR"/cases-*.json; do + if ! is_auxiliary_case_data "$f"; then FILES+=("$f"); fi + done RUN_HOLIDAYS=1 # volledige run: ook de holiday-generator-checks (fase 2.8a, §10.2) fi @@ -243,6 +263,12 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then CHCHECK="$DIR/.calendar-hours-check.mjs" if bundle_check "$DIR/check-calendar-hours.ts" "$CHCHECK"; then node "$CHCHECK" || STATUS=1; fi + # Spiegel-invariant van de kalender- en duur-primitieven (diagnose laag 1, deeldossier "spiegel"): + # add/subtract zijn elkaars exacte inverse over een aaneengesloten niet-werkblok van tien dagen, ook + # op solverniveau — en de ENIGE asymmetrie (`subtractP6XerProjectedWorkMinutes`) blijft brongebonden. + MIRRORCHECK="$DIR/.calendar-mirror-check.mjs" + if bundle_check "$DIR/check-calendar-mirror.ts" "$MIRRORCHECK"; then node "$MIRRORCHECK" || STATUS=1; fi + # Adapter-uur-precisie-checks (fase 2.8b golf 4, §7 — IFC/P6/MSPDI uur-round-trip + dag-discriminator). ADCHECK="$DIR/.adapters-hours-check.mjs" if bundle_check "$DIR/check-adapters-hours.ts" "$ADCHECK"; then node "$ADCHECK" || STATUS=1; fi @@ -285,6 +311,333 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then MPPFIDCHECK="$DIR/.mpp-fidelity.mjs" if bundle_check "$DIR/check-mpp-fidelity.ts" "$MPPFIDCHECK"; then node "$MPPFIDCHECK" || STATUS=1; fi + # Formaat-agnostische fidelitykern: rijvorm, minuutclassificatie en delta-administratie die de + # MPP- en XER-meetlat delen zonder hun grondwaarheidparsers te koppelen. + FIDCORECHECK="$DIR/.fidelity-core.mjs" + if bundle_check "$DIR/check-fidelity-core.ts" "$FIDCORECHECK"; then node "$FIDCORECHECK" || STATUS=1; fi + + # XER-veldlijsten-poort (X0, XER-etappeplan §4.1/§6): whitelist/verboden/genegeerd als getypeerde + # constanten (`check-xer-field-whitelist.ts`), getoetst tegen de union van alle TASK-%F-kolommen + # over `OPS_XER_CORPUS` — het "gatenkaas"-mechanisme: een corpuskolom die in géén van de drie + # bakken zit is ROOD. Corpus is publiek (§4.3): namen mogen in de uitvoer, geen hash-only-regime + # zoals bij OPS_MPP_CORPUS. Corpus-afwezig ⇒ nette OK-skip, zelfde conventie als hierboven — dit + # is dus GEEN CI-poort. + XERWHITELISTCHECK="$DIR/.xer-field-whitelist.mjs" + if bundle_check "$DIR/check-xer-field-whitelist.ts" "$XERWHITELISTCHECK"; then node "$XERWHITELISTCHECK" || STATUS=1; fi + + # BAK 4 (X-O7 laag 3, §4.1-bijstelling 2026-09-04): de zes P6-rekenuitvoerkolommen + # (early_*/late_*/total_float_hr_cnt/free_float_hr_cnt) als apart, niet-solvend kanaal + # (`ImportResult.recordedTimes`). Corpusloze oracle-fixture draait altijd; de corpuspins voor + # `p6diff-baseline.xer`/`rehab-2.xer` alleen met OPS_XER_CORPUS. + XERRECORDEDTIMESCHECK="$DIR/.xer-recorded-times.mjs" + if bundle_check "$DIR/check-xer-recorded-times.ts" "$XERRECORDEDTIMESCHECK"; then node "$XERRECORDEDTIMESCHECK" || STATUS=1; fi + + # T5 (laag 3, §3.8): diezelfde bak-4-vastlegging moet een IFC-OPSLAG, een HEROPENING en het + # CRASHHERSTEL overleven — in én buiten de modus, per project bij een meerprojectenbestand, en + # zonder dat een gemuteerde P6-uitvoercel de solve verplaatst. Draait de echte keten + # (writeIFC -> readIFCWithXerReconstruction -> recoveryStore -> restoreDocuments) op een + # corpusloze fixture; de corpuspins voor `p6diff-baseline.xer`/`rehab-2.xer` alleen met + # OPS_XER_CORPUS (nette OK-skip zonder corpus, zelfde conventie als hierboven). + XERRECORDEDROUNDTRIPCHECK="$DIR/.xer-recorded-roundtrip.mjs" + if bundle_check "$DIR/check-xer-recorded-roundtrip.ts" "$XERRECORDEDROUNDTRIPCHECK"; then + node "$XERRECORDEDROUNDTRIPCHECK" || STATUS=1 + fi + + # XER-fidelity-baselinevorm (X0, XER-etappeplan §3/§6): het harness-skelet — er is nog geen lezer + # (X1+), dus dit bewaakt alleen de VORM van `xer-fidelity-baseline.json` (`xerFidelityTypes.ts`) + # via een compile-locked sleutellijst + een runtime-structuurvalidator. Corpusloos, draait altijd. + XERBASELINESCHEMACHECK="$DIR/.xer-fidelity-baseline-schema.mjs" + if bundle_check "$DIR/check-xer-fidelity-baseline-schema.ts" "$XERBASELINESCHEMACHECK"; then node "$XERBASELINESCHEMACHECK" || STATUS=1; fi + + # X12: corpusloze eindvangrail. Deze bewaakt de gereviewde openbare 93-entry-inventaris, de + # onafhankelijke 34-entry-orakelselectie, het task-replay-kruiscontract en de volledige v2- + # productkarakterisering op entry-/projectniveau. Hij importeert nooit reader/scanner/solver en + # draait daarom precies één keer vóór corpuschecks, niet in de tijdzonematrix. De v2-snapshot + # blijft expliciet een rode, ongeaccepteerde karakterisering; alleen een toekomstige echte + # nulbaseline mag de corpusloze CI-laag als eindnulcontract bewaken. + XERCORPUSLESSFIDELITYGATE="$DIR/.xer-corpusless-fidelity-gate.mjs" + if bundle_check "$DIR/check-xer-corpusless-fidelity-gate.ts" "$XERCORPUSLESSFIDELITYGATE"; then + node "$XERCORPUSLESSFIDELITYGATE" || STATUS=1 + unset 'BUNDLES[-1]' + fi + + # Onafhankelijke XER-fidelitymeetlat (X1): eigen TASK-%T/%F/%R-scan, per-projectmeting, + # zes poortassen + driving-path-rapportage en byte-/schema-dedup. Zonder publiek corpus draait + # de synthetische kerncheck en slaat alleen de corpuspin expliciet over. + XERFIDCHECK="$DIR/.xer-fidelity.mjs" + if bundle_check "$DIR/check-xer-fidelity.ts" "$XERFIDCHECK"; then node "$XERFIDCHECK" || STATUS=1; fi + + # XER-formaatlaag (X2): corpusloze grammatica-/encoding-/CURRTYPE-fixtures. De productparser + # blijft bewust onafhankelijk van X1's xerGroundTruth/xerFidelity-orakelpad. + XERTABLESCHECK="$DIR/.xer-tables.mjs" + if bundle_check "$DIR/check-xer-tables.ts" "$XERTABLESCHECK"; then node "$XERTABLESCHECK" || STATUS=1; fi + + # X3-kalenderdecoder: snelle grammatica-/semantiek-/TZ-fixtures, CALENDAR-hiërarchie, + # uren-per-periode, P6XML-pariteit en de XER-eigen uurmodusregel. + XERCALENDARCHECK="$DIR/.xer-calendar-data.mjs" + if bundle_check "$DIR/check-xer-calendar-data.ts" "$XERCALENDARCHECK"; then node "$XERCALENDARCHECK" || STATUS=1; fi + + # X4a-project/taakmapping: corpusloze PROJECT/PROJWBS/TASK/TASKPRED-fixtures, enumterugval, + # constraints, WBS-samenvattingen en externe-relatie-isolatie. + XERREADERCHECK="$DIR/.xer-reader.mjs" + if bundle_check "$DIR/check-xer-reader.ts" "$XERREADERCHECK"; then node "$XERREADERCHECK" || STATUS=1; fi + + # PROJECT-statusdatum: last_recalc_date blijft de exclusieve bestaande route; uitsluitend een + # data_date-only-%F-dialect mag de fail-closed fallback openen. + XERPROJECTDATACHECK="$DIR/.xer-project-data-date.mjs" + if bundle_check "$DIR/check-xer-project-data-date.ts" "$XERPROJECTDATACHECK"; then node "$XERPROJECTDATACHECK" || STATUS=1; fi + + # X7: P6-actuals/voortgang, suspend/resume-firewall en projectspecifiek verwacht einde. + XERPROGRESSCHECK="$DIR/.xer-progress.mjs" + if bundle_check "$DIR/check-xer-progress.ts" "$XERPROGRESSCHECK"; then node "$XERPROGRESSCHECK" || STATUS=1; fi + + # X7 reviewfix 2: percentages worden uitsluitend uit hun expliciete P6-familie gelezen; + # ontbrekend type houdt de legacy fysieke fallback zonder bronremaining te herschrijven. + XERPROGRESSFAMILIESCHECK="$DIR/.xer-progress-families.mjs" + if bundle_check "$DIR/check-xer-progress-families.ts" "$XERPROGRESSFAMILIESCHECK"; then node "$XERPROGRESSFAMILIESCHECK" || STATUS=1; fi + + # X7 reviewfix 2: de P6 suspend/resume-route is een blijvende invariant door edit, IFC, + # recovery en solver heen; stale true mag na een los/omgekeerd paar niet herleven. + XERSUSPENDINVARIANTCHECK="$DIR/.xer-suspend-invariant.mjs" + if bundle_check "$DIR/check-xer-suspend-invariant.ts" "$XERSUSPENDINVARIANTCHECK"; then node "$XERSUSPENDINVARIANTCHECK" || STATUS=1; fi + + # X7 smalle solverfix: een geldige P6 suspend/resume-complete taak mag de bestaande + # completed-windowroute gebruiken; alle andere vormen blijven fail-closed. + XERCOMPLETEDSUSPENDCHECK="$DIR/.xer-completed-suspend-resume-window.mjs" + if bundle_check "$DIR/check-xer-completed-suspend-resume-window.ts" "$XERCOMPLETEDSUSPENDCHECK"; then node "$XERCOMPLETEDSUSPENDCHECK" || STATUS=1; fi + + # X7 CP_Phys-grens: brondata blijft behouden, maar zonder bron-alleen discriminator opent + # CP_Phys géén completed-windowroute; de fixture bewaakt de fail-closed- en inversegrens. + XERCOMPLETEDCPPHYSCHECK="$DIR/.xer-completed-cp-phys-window.mjs" + if bundle_check "$DIR/check-xer-completed-cp-phys-window.ts" "$XERCOMPLETEDCPPHYSCHECK"; then node "$XERCOMPLETEDCPPHYSCHECK" || STATUS=1; fi + + # X7 smalle solverfix: uitsluitend de bewezen completed XER-LOE-vorm mag de hammock-forwardtak + # passeren en haar geregistreerde actualFinish als bestaande completed-route gebruiken. + XERCOMPLETEDLOECHECK="$DIR/.xer-completed-loe-actual-finish.mjs" + if bundle_check "$DIR/check-xer-completed-loe-actual-finish.ts" "$XERCOMPLETEDLOECHECK"; then node "$XERCOMPLETEDLOECHECK" || STATUS=1; fi + + # Diagnose laag 1, klasse (i) (X-O7): een voltooide XER-activiteit staat via + # p6CompletedLateFromRemainingWindow ook aan de late zijde op nul restduur op de statusdatum + # (LF = prevWorkInstant(LS), lag vervalt tussen twee voltooide activiteiten); vlag uit blijft + # byte-identiek aan de rauwe actual-pin. + XERCOMPLETEDLATECHECK="$DIR/.xer-completed-late.mjs" + if bundle_check "$DIR/check-xer-completed-late.ts" "$XERCOMPLETEDLATECHECK"; then node "$XERCOMPLETEDLATECHECK" || STATUS=1; fi + + # X12-residu Ashspace: uitsluitend de bewezen open XER TT_LOE-span met SS-in- en FF-uittopologie + # mag het expliciete targetvenster gebruiken; de corpusloze mutatiematrix houdt alle andere vormen dicht. + XEROPENLOETARGETSPANCHECK="$DIR/.xer-open-loe-target-span.mjs" + if bundle_check "$DIR/check-xer-open-loe-target-span.ts" "$XEROPENLOETARGETSPANCHECK"; then node "$XEROPENLOETARGETSPANCHECK" || STATUS=1; fi + + # X7 reviewfix 2: suspend/resume/expected-finish kunnen zélf de XER-uurmodus activeren. + XERX7HOURMODECHECK="$DIR/.xer-x7-hour-mode.mjs" + if bundle_check "$DIR/check-xer-x7-hour-mode.ts" "$XERX7HOURMODECHECK"; then node "$XERX7HOURMODECHECK" || STATUS=1; fi + + # X7: zelfstandig bronorakel voor P6 voortgang/suspend-resume, zonder productiereader. + XERPROGRESSCORPUSCHECK="$DIR/.xer-progress-corpus.mjs" + if bundle_check "$DIR/check-xer-progress-corpus.ts" "$XERPROGRESSCORPUSCHECK"; then + node "$XERPROGRESSCORPUSCHECK" || STATUS=1 + unset 'BUNDLES[-1]' + fi + + XERPROJECTINDEXCHECK="$DIR/.xer-project-index.mjs" + if bundle_check "$DIR/check-xer-project-index.ts" "$XERPROJECTINDEXCHECK"; then node "$XERPROJECTINDEXCHECK" || STATUS=1; fi + + # X4b-multi-projectkern: documentselectie, aanwezige P6-baselines, volledige terugval bij + # zelfverwijzing/cycli, solverloze cross-projectlinks en geïsoleerde documentpayloads. De twee + # openbare acceptatiepins draaien uitsluitend wanneer OPS_XER_CORPUS beschikbaar is. + XERMULTIPROJECTCHECK="$DIR/.xer-multi-project.mjs" + if bundle_check "$DIR/check-xer-multi-project.ts" "$XERMULTIPROJECTCHECK"; then node "$XERMULTIPROJECTCHECK" || STATUS=1; fi + + # X4b-wiring: de echte registry→fileSlice→documentroute opent één XER-bestand als losse, + # geïsoleerde documenten met clean save-/recoverytoestand en een deterministische actieve tab. + XEROPENWIRINGCHECK="$DIR/.xer-open-wiring.mjs" + if bundle_check "$DIR/check-xer-open-wiring.ts" "$XEROPENWIRINGCHECK"; then node "$XEROPENWIRINGCHECK" || STATUS=1; fi + + # X5 SCHEDOPTIONS: pure mapping/defaults plus brongebonden solvervlaggen; de end-to-end-wiring + # bouwt voort op X4b's per-projectdocumenten zonder diens open-fan-out te omzeilen. + XERSCHEDOPTIONSCHECK="$DIR/.xer-schedule-options.mjs" + if bundle_check "$DIR/check-xer-schedule-options.ts" "$XERSCHEDOPTIONSCHECK"; then node "$XERSCHEDOPTIONSCHECK" || STATUS=1; fi + + # X5 seriële wiring: per-project-SCHEDOPTIONS door X4b's echte meerdocumentreader, inclusief + # defaults, bronmetadata, documentwissel/undo/recovery en de bestaande IFC-projectvelden. + XERSCHEDOPTIONSWIRINGCHECK="$DIR/.xer-schedule-options-wiring.mjs" + if bundle_check "$DIR/check-xer-schedule-options-wiring.ts" "$XERSCHEDOPTIONSWIRINGCHECK"; then node "$XERSCHEDOPTIONSWIRINGCHECK" || STATUS=1; fi + + # Pakket B: corpusloze SCHEDOPTIONS-provenance, PROJECT-afleiding en fail-closed mutaties. + XERSCHEDOPTIONSPROVENANCECHECK="$DIR/.xer-schedule-option-provenance.mjs" + if bundle_check "$DIR/check-xer-schedule-option-provenance.ts" "$XERSCHEDOPTIONSPROVENANCECHECK"; then + node "$XERSCHEDOPTIONSPROVENANCECHECK" || STATUS=1 + fi + + # X5 zware openbare corpuspin: 27-kolommenunion en de 36 actuele bestanden zonder SCHEDOPTIONS, + # per default/per as en tegen de onafhankelijke X1-meetlat. Eén keer draaien, niet per tijdzone. + XERSCHEDOPTIONSCORPUSCHECK="$DIR/.xer-schedule-options-corpus.mjs" + if bundle_check "$DIR/check-xer-schedule-options-corpus.ts" "$XERSCHEDOPTIONSCORPUSCHECK"; then + node "$XERSCHEDOPTIONSCORPUSCHECK" || STATUS=1 + unset 'BUNDLES[-1]' + fi + + # X4a-registercontract: .xer is lazy en binair, behoudt CP1252/UTF-16-BOM-bytes en wordt nooit + # een opslagdoel. + XERREGISTRYCHECK="$DIR/.xer-registry.mjs" + if bundle_check "$DIR/check-xer-registry.ts" "$XERREGISTRYCHECK"; then node "$XERREGISTRYCHECK" || STATUS=1; fi + + XERCHUNKCHECK="$DIR/.xer-chunk-boundary.mjs" + if bundle_check "$DIR/check-xer-chunk-boundary.ts" "$XERCHUNKCHECK"; then node "$XERCHUNKCHECK" || STATUS=1; fi + + XERARCHIVELIFECYCLECHECK="$DIR/.xer-source-archive-lifecycle.mjs" + if bundle_check "$DIR/check-xer-source-archive-lifecycle.ts" "$XERARCHIVELIFECYCLECHECK"; then node "$XERARCHIVELIFECYCLECHECK" || STATUS=1; fi + + XERARCHIVEREADMODELCHECK="$DIR/.xer-archive-readmodel.mjs" + if bundle_check "$DIR/check-xer-archive-readmodel.ts" "$XERARCHIVEREADMODELCHECK"; then node "$XERARCHIVEREADMODELCHECK" || STATUS=1; fi + + XERARCHIVECHAINCHECK="$DIR/.xer-source-archive-chain.mjs" + if bundle_check "$DIR/check-xer-source-archive-chain.ts" "$XERARCHIVECHAINCHECK"; then node "$XERARCHIVECHAINCHECK" || STATUS=1; fi + + XERP6PRESENCECHECK="$DIR/.xer-p6-presence.mjs" + if bundle_check "$DIR/check-xer-p6-presence.ts" "$XERP6PRESENCECHECK"; then node "$XERP6PRESENCECHECK" || STATUS=1; fi + + XERARCHIVESCALECHECK="$DIR/.xer-archive-scale.mjs" + if bundle_check "$DIR/check-xer-archive-scale.ts" "$XERARCHIVESCALECHECK"; then node "$XERARCHIVESCALECHECK" || STATUS=1; fi + + XERP6XMLPARITYCHECK="$DIR/.xer-p6xml-parity.mjs" + if bundle_check "$DIR/check-xer-p6xml-parity.ts" "$XERP6XMLPARITYCHECK"; then node "$XERP6XMLPARITYCHECK" || STATUS=1; fi + + XERX9VERDICTSCHECK="$DIR/.xer-x9-verdicts.mjs" + if bundle_check "$DIR/check-xer-x9-verdicts.ts" "$XERX9VERDICTSCHECK"; then node "$XERX9VERDICTSCHECK" || STATUS=1; fi + + XEREXPORTLOSSCHECK="$DIR/.xer-export-loss.mjs" + if bundle_check "$DIR/check-xer-export-loss.ts" "$XEREXPORTLOSSCHECK"; then node "$XEREXPORTLOSSCHECK" || STATUS=1; fi + + # X4a eerste echte productmeting: readXER + de gedeelde solveProject-keten tegen precies de twee + # onafhankelijke acht-taaks P6-orakels uit het brief. + XERPRODUCTFIDCHECK="$DIR/.xer-product-fidelity.mjs" + if bundle_check "$DIR/check-xer-product-fidelity.ts" "$XERPRODUCTFIDCHECK"; then node "$XERPRODUCTFIDCHECK" || STATUS=1; fi + + # X12: de oude twee-pinmeting blijft tijdelijk als regressiedossier bestaan, maar deze adapter + # meet de echte productketen over iedere door X1 geselecteerde entry en elk geïmporteerd project. + X12PRODUCTFIDCHECK="$DIR/.xer-product-fidelity-x12.mjs" + if bundle_check "$DIR/check-xer-product-fidelity-x12.ts" "$X12PRODUCTFIDCHECK"; then + node "$X12PRODUCTFIDCHECK" || STATUS=1 + fi + + # XER task-level counterfactual replay: corpusloze I/R/U-, oracle-firewall- en identityfixtures; + # met OPS_XER_CORPUS daarnaast de canonieke X12-selectie entry/project-voor-entry/project. + XERTASKREPLAYCHECK="$DIR/.xer-task-replay.mjs" + if bundle_check "$DIR/check-xer-task-replay.ts" "$XERTASKREPLAYCHECK"; then + node "$XERTASKREPLAYCHECK" || STATUS=1 + unset 'BUNDLES[-1]' + fi + + # XER completed-backward/float-trace: corpusloze beslisbroninstrumentatie. Deze check + # leest geen oracle of classificatie en bewaakt uitsluitend de optionele CPMResult-trace. + XERBACKWARDFLOATTRACECHECK="$DIR/.xer-backward-float-trace.mjs" + if bundle_check "$DIR/check-xer-backward-float-trace.ts" "$XERBACKWARDFLOATTRACECHECK"; then + node "$XERBACKWARDFLOATTRACECHECK" || STATUS=1 + fi + + # X4a oorspronkelijke bytes: CP1252 + beide UTF-16-BOM-vormen door web, Tauri, recents en de + # dev-bridge; de MCP-route heeft dezelfde drie fixtures in tests/mcp/cases-doc-file.ts. + XERBYTEPATHCHECK="$DIR/.xer-byte-paths.mjs" + if bundle_check "$DIR/check-xer-byte-paths.ts" "$XERBYTEPATHCHECK"; then node "$XERBYTEPATHCHECK" || STATUS=1; fi + + # X9 — exact bronarchief en de self-contained IFC-manifest/chunkcontainer. + XERSOURCEARCHIVECHECK="$DIR/.xer-source-archive.mjs" + if bundle_check "$DIR/check-xer-source-archive.ts" "$XERSOURCEARCHIVECHECK"; then node "$XERSOURCEARCHIVECHECK" || STATUS=1; fi + + IFCXERARCHIVECHECK="$DIR/.ifc-xer-archive-container.mjs" + if bundle_check "$DIR/check-ifc-xer-archive-container.ts" "$IFCXERARCHIVECHECK"; then node "$IFCXERARCHIVECHECK" || STATUS=1; fi + + # P0: corpusloze, onafhankelijke bronretentiepoort over import, edit/CPM, undo/redo, + # documentwissel/-kopie, recovery en IFC. De STEP-envelope wordt zonder product-reader gecheckt. + XERSOURCERETENTIONCHECK="$DIR/.xer-source-retention.mjs" + if bundle_check "$DIR/check-xer-source-retention.ts" "$XERSOURCERETENTIONCHECK"; then node "$XERSOURCERETENTIONCHECK" || STATUS=1; fi + + # X9-compactopslag: schema-2 bewaart alleen de gehashte bronbytes; diagnostics/readmodel + # worden bij lezen opnieuw uit die zelfstandige bron afgeleid. Schema-1 blijft invoerbaar. + XERARCHIVECOMPACTCHECK="$DIR/.xer-archive-compact.mjs" + if bundle_check "$DIR/check-xer-archive-compact.ts" "$XERARCHIVECOMPACTCHECK"; then node "$XERARCHIVECOMPACTCHECK" || STATUS=1; fi + + # X9 reviewfix 1: schema-2 XER-bronarchieven moeten in een nieuw Node-proces via de ENIGE + # officiële asynchrone IFC-ingang zelfstandig herleven; de lage sync-lezer moet veilig verwijzen. + XERARCHIVECOLDREADCHECK="$DIR/.xer-archive-cold-read.mjs" + if bundle_check "$DIR/check-xer-archive-cold-read.ts" "$XERARCHIVECOLDREADCHECK"; then node "$XERARCHIVECOLDREADCHECK" || STATUS=1; fi + + # X9: drie verse corpusprocessen. maxRSS is de OS-gemeten piek van de hele IFC- of + # recoveryketen, niet een misleidende netto heapdelta. + XERARCHIVERECOVERYCORPUSCHECK="$DIR/.xer-archive-recovery-corpus.mjs" + if bundle_check "$DIR/check-xer-archive-recovery-corpus.ts" "$XERARCHIVERECOVERYCORPUSCHECK"; then + node "$XERARCHIVERECOVERYCORPUSCHECK" || STATUS=1 + unset 'BUNDLES[-1]' + fi + + # X6: RSRC/RSRCRATE/TASKRSRC, immutable retained bronrijen en het X4b-aliasingcontract. + # De check is datumvrij en draait daarom slechts eenmaal buiten de tijdzonematrix. + XERRESOURCESCHECK="$DIR/.xer-resources.mjs" + if bundle_check "$DIR/check-xer-resources.ts" "$XERRESOURCESCHECK"; then + node "$XERRESOURCESCHECK" || STATUS=1 + unset 'BUNDLES[-1]' + fi + + # X6: echte storeduplicatie met een rehab-grote retained catalogus. Ook deze check is datumvrij. + XERRESOURCEDUPLICATECHECK="$DIR/.xer-resource-duplicate.mjs" + if bundle_check "$DIR/check-xer-resource-duplicate.ts" "$XERRESOURCEDUPLICATECHECK"; then + node "$XERRESOURCEDUPLICATECHECK" || STATUS=1 + unset 'BUNDLES[-1]' + fi + + # X8: metadata wordt éénmaal file-wide gemapt; de wiringfixture bewaakt projectisolatie, + # FT_STATICTYPE, DEL-DEL en de verplichte TASKACTV-mutatietelling. + XERMETADATAWIRINGCHECK="$DIR/.xer-metadata-wiring.mjs" + if bundle_check "$DIR/check-xer-metadata-wiring.ts" "$XERMETADATAWIRINGCHECK"; then node "$XERMETADATAWIRINGCHECK" || STATUS=1; fi + + XERMETADATAINDEXCHECK="$DIR/.xer-metadata-index.mjs" + if bundle_check "$DIR/check-xer-metadata-index.ts" "$XERMETADATAINDEXCHECK"; then node "$XERMETADATAINDEXCHECK" || STATUS=1; fi + + XERMETADATAREVIEWCHECK="$DIR/.xer-metadata-review.mjs" + if bundle_check "$DIR/check-xer-metadata-review.ts" "$XERMETADATAREVIEWCHECK"; then node "$XERMETADATAREVIEWCHECK" || STATUS=1; fi + + # X8: onafhankelijke ruwe tellingen plus de twee zware openbare performanceprofielen. + XERMETADATACORPUSCHECK="$DIR/.xer-metadata-corpus.mjs" + if bundle_check "$DIR/check-xer-metadata-corpus.ts" "$XERMETADATACORPUSCHECK"; then + node "$XERMETADATACORPUSCHECK" || STATUS=1 + unset 'BUNDLES[-1]' + fi + + # X6: onafhankelijke, hashgepinde corpusmeting. Deze draait eenmaal met expliciete GC, zodat + # parser- en kernheapdelta's vergelijkbaar blijven en nooit in de tijdzonematrix vallen. + XERRESOURCECORPUSCHECK="$DIR/.xer-resource-corpus.mjs" + if bundle_check "$DIR/check-xer-resource-corpus.ts" "$XERRESOURCECORPUSCHECK"; then + node --expose-gc "$XERRESOURCECORPUSCHECK" || STATUS=1 + unset 'BUNDLES[-1]' + fi + + # X3-openbare corpuspin: de echte productie-ingang zonder rijvoorfilter, vier concrete + # herstel-/weigerhashes, basegraafpin en de statische 124-kalenderdigest. Deze zware scan draait + # bewust één keer en wordt daarom na het bundelen uit de tijdzonematrix verwijderd. + XERCALENDARCORPUSCHECK="$DIR/.xer-calendar-corpus.mjs" + if bundle_check "$DIR/check-xer-calendar-corpus.ts" "$XERCALENDARCORPUSCHECK"; then + node "$XERCALENDARCORPUSCHECK" || STATUS=1 + unset 'BUNDLES[-1]' + fi + + # X2-corpuspoort: alle kedular-parserfixtures en publieke p6xer-robuustheidsgevallen zijn op + # concrete rapportinhoud of typed foutcode gepind. Zonder corpus een expliciete groene skip. + XERCORPUSCHECK="$DIR/.xer-corpus.mjs" + if bundle_check "$DIR/check-xer-corpus.ts" "$XERCORPUSCHECK"; then node "$XERCORPUSCHECK" || STATUS=1; fi + + # P6 23.12-capture: uitsluitend de ruwe *_p6-kolommen met tijden; enginekolommen en de + # genormaliseerde PASS-oordelen zijn expliciet geen meetlat. Met OPS_P6_COMPARISON wordt ook de + # generator byte-identiek tegen de publieke bron gedraaid. + P6VERIFIEDCHECK="$DIR/.p6-verified-cases.mjs" + if bundle_check "$DIR/check-p6-verified-cases.ts" "$P6VERIFIEDCHECK"; then node "$P6VERIFIEDCHECK" || STATUS=1; fi + + # Dezelfde dertien casussen, nu ECHT door lezer + solver (review-bevinding 7): de invoerzijde is + # corpusloos getranscribeerd, de uitkomst wordt cel voor cel tegen de P6-23.12-opname gelegd. + # Met OPS_P6_COMPARISON of OPS_XER_CORPUS wordt de transcriptie zelf tegen de bron gecontroleerd. + P6VERIFIEDENGINECHECK="$DIR/.p6-verified-cases-engine.mjs" + if bundle_check "$DIR/check-p6-verified-cases-engine.ts" "$P6VERIFIEDENGINECHECK"; then node "$P6VERIFIEDENGINECHECK" || STATUS=1; fi + # Opslagdoel-guard voor binaire bronformaten (fase 3.8 e1, T8-stap 5a): `fileSlice.openFile` # via de echte ``-terugval — .mpp krijgt GEEN opslagdoel, .ifc (contrast) wel. # Corpusdeel volgt dezelfde skip-OK-conventie als hierboven. @@ -459,6 +812,12 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then GANTTWORKSPACECHECK="$DIR/.gantt-workspace.mjs" if bundle_check "$DIR/check-gantt-workspace.ts" "$GANTTWORKSPACECHECK"; then node "$GANTTWORKSPACECHECK" || STATUS=1; fi + # X11: documenttabstrip is ook bij 12+ open projecten volledig bereikbaar. De pure + # toetsenbord-/zichtbaarheidskern voorkomt dat tab 10–12 buiten Ctrl/Cmd-1..9 een dead-end + # worden; de browserproef controleert daarnaast de echte DOM en scrollcontainer. + DTNCHECK="$DIR/.document-tab-navigation.mjs" + if bundle_check "$DIR/check-document-tab-navigation.ts" "$DTNCHECK"; then node "$DTNCHECK" || STATUS=1; fi + # Gantt-cull-regressie: de speling-band mag niet verdwijnen zolang hij zichtbaar is. De cull in # drawTaskBar keek alleen naar de BALK-extent, terwijl de band ná de balk doorloopt — een band die # nog honderden pixels in beeld stond verdween daardoor mee. Draait de echte renderer met een @@ -908,6 +1267,16 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then RECCHECK="$DIR/.recovery-integrity.mjs" if bundle_check "$DIR/check-recovery-integrity.ts" "$RECCHECK"; then node "$RECCHECK" || STATUS=1; fi + # X9 recoverydelta: inhoud volgt uitsluitend IFCSaveSource/sameIFCSource. Eén wijziging geeft + # één upsert; actieve-tab-, pad- en dirtymetadata schrijven alleen het manifest. + RECDELTACHECK="$DIR/.recovery-delta.mjs" + if bundle_check "$DIR/check-recovery-delta.ts" "$RECDELTACHECK"; then node "$RECDELTACHECK" || STATUS=1; fi + + # X9 recovery-opslaggrens: één van twaalf gewijzigde documenten geeft één doc-upsert plus één + # manifest-put in exact één IndexedDB-readwrite-transactie; actieve-tabwissel is metadata-only. + RECWRITEAMPCHECK="$DIR/.recovery-write-amplification.mjs" + if bundle_check "$DIR/measure-xer-recovery-write-amplification.ts" "$RECWRITEAMPCHECK"; then node "$RECWRITEAMPCHECK" || STATUS=1; fi + # S2: werkelijk automatisch opslaan is nadrukkelijk geen crashherstel. De controller bewaakt # het bestaande schrijfdoel, single-flight, nieuwste-run en de dirty-race zonder browser/Tauri-I/O. AACHECK="$DIR/.actual-autosave.mjs" @@ -931,6 +1300,11 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then NOTIFCHECK="$DIR/.notifications.mjs" if bundle_check "$DIR/check-notifications.ts" "$NOTIFCHECK"; then node "$NOTIFCHECK" || STATUS=1; fi + # X9 reviewfix 2: res.json van de Tauri-dev-poller mag geen onopgeloste Promise als `{}` + # serialiseren wanneer de round-tripopdracht async werkt. + DEVBRIDGEPOLLERCHECK="$DIR/.dev-bridge-poller.mjs" + if bundle_check "$DIR/check-dev-bridge-poller.ts" "$DEVBRIDGEPOLLERCHECK"; then node "$DEVBRIDGEPOLLERCHECK" || STATUS=1; fi + # T1: de duur-eenheid hoort bij de taak, inclusief kalenderplaatsing, legacy-migratie, # compacte presentatie en IFC-roundtrip. Deze check draait ook in de tijdzone-matrix. T1DURCHECK="$DIR/.task-duration-unit.mjs" @@ -986,6 +1360,15 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then # TZ-onafhankelijkheid moet bewezen worden. RECDATES="$DIR/.check-recorded-dates.mjs" if bundle_check "$DIR/check-recorded-dates.ts" "$RECDATES"; then node "$RECDATES" || STATUS=1; fi + + # XER-etappeplan laag 3, T6 — de taaktabel-/eigenschappenpaneelmarkering bovenop diezelfde + # vastlegging: `recordedDatesSelectors.ts` (puur) plus de draadverbinding in + # `taskColumnRegistry.ts` (kolom `recorded.source` + "niet vastgelegd" op late/float). Elke + # `bundle_check`-registratie draait automatisch ook in de tijdzone-matrix hieronder; deze + # batterij vergelijkt zelf geen datums (alleen letterlijke ISO-strings/labels), dus dat is + # onschuldig — geen aparte TZ-aanname om te bewijzen. + RECMARKCHECK="$DIR/.check-recorded-dates-mark.mjs" + if bundle_check "$DIR/check-recorded-dates-mark.ts" "$RECMARKCHECK"; then node "$RECMARKCHECK" || STATUS=1; fi fi # ── Losse check-bestanden bij een gerichte run (argumentvorm check-*.ts) ─────────────────── diff --git a/tests/planning/xer-calendar-hour-mode-baseline.json b/tests/planning/xer-calendar-hour-mode-baseline.json new file mode 100644 index 000000000..576c61a4f --- /dev/null +++ b/tests/planning/xer-calendar-hour-mode-baseline.json @@ -0,0 +1,82 @@ +{ + "version": 2, + "key": "calendarRows/retainedRows/structurallyRecoveredRows/rejectedRows/sharedRules/withXerClockRule", + "pins": { + "3/3/0/0/3/3": [ + "ad92ce52ba95b27539a4c0ba0c44e2a6b755ec2c15c3066009694c7430846684", + "df264eb25ca8dc0e6e29d5ba5ba4a4e370f9126aecb99ab48e9fd4383f0287b7" + ], + "1/1/0/0/0/0": [ + "0fa5172162fbd6895f88042d0277a8fbc46c9c82badbcbae333d3423dd208c86", + "1ba8cf1885491db3c19ebffc85fbd33935345ef9effb386a8da33226e665f9ab", + "1d7901d68cc7063b59dcedacc3eeae8d2c89178516421c09ae38ff523429e3af", + "78325fb6445d22f5949bf05c1e0afe3e14b72efc9cc9480800eab4bf2b0a1056", + "790b90fda837bad342658bf2c712cf6c39ab6adcd812e67edf93cda1c7eb362f", + "79f6fb43bb391ed609f164e6dc5d38d329b634467bc6a7b06cbf3d8a351c8078", + "98f079b267688de209d20d8156026111f7a045cb67f3457e277a3d29a4fd9c04", + "9f2964425c0e295bfc3a62ba0c860d9be5a6167a5494427007a42ecce5df6cc6", + "a132f8c7ebb070d29936b8852c6dca772ebb547c0ade91c17c96425e37e63818", + "a7fc367eab6c904e6659a8206b9a162df132824c630e6175665c8a63f054104b", + "ac4b9d677133c6edcd6129050f32233e77c9d62ef06c7caac0dbf1da0d1e43ab", + "b72a879f6282cc6193bb0d13e2671843cafbb1d1edb5546ec7a99f4744037262", + "bd3aefb87cfa0532de36b3972bd7dc4a5c7eb6f156b1b8e4af90e43ebf479ea4", + "c1ccf20436ba38b5846d0ac6b104551d97281093effaa40a9eb6222728fb8ee3", + "deabc76347eaef15727f430951a8a1b957dd5ab0251dc4500e15c817a8e030b8", + "def489b2a803af9f939b964b80e8fd9b2dc8143df280ad2a3088d91d4d6cdac3", + "fc74bdab8629d9e81e5c7e58900ea4632b4525f63cca9fa546d4f17605936de6" + ], + "32/32/0/0/1/32": ["2bc12241c3f8ee5b7472dd0e77f2cbffafcf3b5438b17022fd9db4f4c642d4b0"], + "1/1/0/0/0/1": [ + "1d7bd29d2c157bdd9abad5723f84edab1f8cd925cdf3003aff181b597431a948", + "68ce5f0bb2b534d5ba192e3ad8241025d74ae45e94209f11731d42d32f18f82b", + "71e56568075852d00507ff5506d5c055912ce0ba0982b97b8ace6269f794d10a", + "9590c4cdc4efa69a231b66ac509611d17f31605599ee7abde02a197f5e8b1e6e" + ], + "3/3/0/0/1/3": ["b9547eb91c30af1750933a64409d8b2a4a2c1dbe4c0be276a04ab150c8a50167"], + "11/11/0/0/7/11": [ + "9679599df9108bd3b31e755351c1543d1915b82a91445a3f1b0b9c9a3d1f86fe", + "d2c413088afed805b30b7628c91be8aca870f7d4daf00eb12a547d69913d8352" + ], + "8/8/8/0/5/8": ["2a7732b5b99de2a5f40aab40e0af52757fbe68e5d4eda404a9a060fdc1be1140"], + "2/2/0/0/0/0": [ + "146e4c8659bad066b298988613dec178ce63f3a2c35415937b3d70a8f8ef8158", + "467ab0eb0885c4a78f7101abd512bf34a6fe6e85770b49d1bee855633a4d4a73", + "53f5cdbeb3fbd9c6cb252a48c2ad9206f1f2ab44215372f83199df3a90803ffd", + "568c19375b4e0d674c75e6aea023c98772fb33e6896755f866e4641a56197300", + "97058f720a70f62399c92f7f84ffb4a29b936baf95c6a2e339e3cc4a222f318c", + "c872c9e704797d829205f3c5486e7c4cd5aec729143a63fbcf8b0ec8e8864a3c" + ], + "6/6/0/0/2/6": ["a2ef7b35c00d8cf8ba75230d71d69b96130f7f689495e86a4cf5f8fcd34431ae"], + "2/2/0/0/1/2": ["55b7e4463dcd36ba02eae072445e8fc2cc684a188e40c588b05321963a362c5e"], + "1/1/0/0/1/1": [ + "46f3c1c68552429cf32191ebc8f48d12f52e517481b6fb588ad1955937c5cd1d", + "7298574bb32ac3068b07b427a35cd2834308d2fcb0e2ac74fea4e013e738c62e", + "9b91bf5bfcc0df4f6a2fec8867a923af7758058c224a9772a728e8d2ba0f0fc5", + "ab420cc573be248ef7ef344f77b204176c3263fceb45c27866276929bf1e59e2", + "b7ef6d85acfedda1a8d3a77391592587d3ca3088013b896d03fd129b23d83834", + "e48eb3a53b7e8f40a5a87af8e983e9932c298baeb572e3a901b3961f997674ef" + ], + "124/124/0/0/122/124": ["2c1dce175b9f078111a48dc13fd1777f5fbd4cd7ab6623e647e7437330c60b7f"], + "2/2/0/0/1/1": ["4d8bce790a93b9bc747abec5d99840e3e0d1ce5523e0afae3ba881762cfe2c13"], + "3/3/3/0/3/3": [ + "0611f9054a4b566340dd53181b96d66907f4ac8faa0ef1395ec338be9f83b96d", + "816e01738f4967872980ce3fed0c2bfd8c09f78197b7de1beea5ca98e094aeae" + ], + "3/2/0/1/0/2": ["85f8c7d681b15daf755ba1d568eb09ebcdd529a16c20045e6db6c2937899f54b"], + "4/4/0/0/2/4": ["a2f3b2469e26f19985ac801786c90edfb965a7bc194a6e57704aa845c5f8611f"] + }, + "issueTotals": { + "XER_CALENDAR_DANGLING_BASE": 1, + "XER_CALENDAR_COMPACT_RECORD_RECOVERED": 8, + "XER_CALENDAR_DUPLICATE_EXCEPTION": 119, + "XER_CALENDAR_WEEKEND_CLAMP_RECONSTRUCTED": 119, + "XER_CALENDAR_SURPLUS_CLOSE_RECOVERED": 6, + "XER_CALENDAR_INVALID_STRUCTURE": 1 + }, + "dossier": { + "hash": "2c1dce175b9f078111a48dc13fd1777f5fbd4cd7ab6623e647e7437330c60b7f", + "calendars": 124, + "dayHourFieldsPresent": 0, + "semanticDigest": "ab14e3089fa6bce4bbd8232654a10330eff2725ec9249cc9e43b93a690305fb4" + } +} diff --git a/tests/planning/xer-corpus-manifest.json b/tests/planning/xer-corpus-manifest.json new file mode 100644 index 000000000..0d3957e88 --- /dev/null +++ b/tests/planning/xer-corpus-manifest.json @@ -0,0 +1,612 @@ +{ + "version": 1, + "policy": "Alle 93 openbare .xer-bestanden zijn op volledig bytehash en herkomst geclassificeerd. Alleen role=oracle/included=true stuurt de fidelitybaseline; veldinhoud kiest nooit zelf de populatie.", + "files": { + "cpp-cpm-engine/validation/p6-comparison/cases-import.xer": { + "sha256": "df264eb25ca8dc0e6e29d5ba5ba4a4e370f9126aecb99ab48e9fd4383f0287b7", + "source": "cpp-cpm-engine@c279a5c4ff204ba763a6f9726aa6383574b50475", + "role": "engine-input", + "included": false, + "exclusionReason": "Door het referentieraamwerk gegenereerde invoer voor P6; geen door P6 opgeslagen rekenuitvoer." + }, + "cpp-cpm-engine/validation/xer-corpus/cases/01-small-clean-baseline/case.xer": { + "sha256": "6e9806e13077f34f4bf657624b057036593584dc33da770bba5ec2b67895ed58", + "source": "cpp-cpm-engine@c279a5c4ff204ba763a6f9726aa6383574b50475", + "role": "engine-input", + "included": false, + "exclusionReason": "Gegenereerde parser-/engine-invoerfixture zonder onafhankelijke P6-uitvoer." + }, + "cpp-cpm-engine/validation/xer-corpus/cases/02-large-1000-activities/case.xer": { + "sha256": "db916c26851161de898b1bda017ea283dac718004b6fe00fa1d9cb0fb3f40b98", + "source": "cpp-cpm-engine@c279a5c4ff204ba763a6f9726aa6383574b50475", + "role": "engine-input", + "included": false, + "exclusionReason": "Gegenereerde parser-/engine-invoerfixture zonder onafhankelijke P6-uitvoer." + }, + "cpp-cpm-engine/validation/xer-corpus/cases/03-multiple-calendars/case.xer": { + "sha256": "2f9acbc1e2a31a05038e84f077b694508864b0f37a1b53f6f3e1aad0543377c6", + "source": "cpp-cpm-engine@c279a5c4ff204ba763a6f9726aa6383574b50475", + "role": "engine-input", + "included": false, + "exclusionReason": "Gegenereerde parser-/engine-invoerfixture zonder onafhankelijke P6-uitvoer." + }, + "cpp-cpm-engine/validation/xer-corpus/cases/04-constraints-heavy/case.xer": { + "sha256": "c40637c54fcb2cf07188826fffd3db96418b4f96714af62d1a2156d109a2633d", + "source": "cpp-cpm-engine@c279a5c4ff204ba763a6f9726aa6383574b50475", + "role": "engine-input", + "included": false, + "exclusionReason": "Gegenereerde parser-/engine-invoerfixture zonder onafhankelijke P6-uitvoer." + }, + "cpp-cpm-engine/validation/xer-corpus/cases/05-in-progress/case.xer": { + "sha256": "0438811fad839eba93e5146da5713c349885d9f6c9e1fd7d4e8785b2060d9e4a", + "source": "cpp-cpm-engine@c279a5c4ff204ba763a6f9726aa6383574b50475", + "role": "engine-input", + "included": false, + "exclusionReason": "Gegenereerde parser-/engine-invoerfixture zonder onafhankelijke P6-uitvoer." + }, + "cpp-cpm-engine/validation/xer-corpus/cases/06-fully-completed/case.xer": { + "sha256": "9f60b7600e82820cb7132d2cc69831cd2c8de7ef6d0d5e9969f6e810a443be45", + "source": "cpp-cpm-engine@c279a5c4ff204ba763a6f9726aa6383574b50475", + "role": "engine-input", + "included": false, + "exclusionReason": "Gegenereerde parser-/engine-invoerfixture zonder onafhankelijke P6-uitvoer." + }, + "cpp-cpm-engine/validation/xer-corpus/cases/07-negative-float/case.xer": { + "sha256": "2ea1ec822c891bf6437398389736796162dd0b9457dcf71de576827a362c5eab", + "source": "cpp-cpm-engine@c279a5c4ff204ba763a6f9726aa6383574b50475", + "role": "engine-input", + "included": false, + "exclusionReason": "Gegenereerde parser-/engine-invoerfixture zonder onafhankelijke P6-uitvoer." + }, + "cpp-cpm-engine/validation/xer-corpus/cases/08-disconnected-fragments/case.xer": { + "sha256": "18d5e22eb93bf43029dde4af63fdc204522d65edf8e81719051506f94709500e", + "source": "cpp-cpm-engine@c279a5c4ff204ba763a6f9726aa6383574b50475", + "role": "engine-input", + "included": false, + "exclusionReason": "Gegenereerde parser-/engine-invoerfixture zonder onafhankelijke P6-uitvoer." + }, + "cpp-cpm-engine/validation/xer-corpus/cases/09-corrupt-xer/case.xer": { + "sha256": "cedb29f9d52661aa1403af6af7234d1551483acf007b40206638cfba75232378", + "source": "cpp-cpm-engine@c279a5c4ff204ba763a6f9726aa6383574b50475", + "role": "engine-input", + "included": false, + "exclusionReason": "Gegenereerde parser-/engine-invoerfixture zonder onafhankelijke P6-uitvoer." + }, + "cpp-cpm-engine/validation/xer-corpus/cases/10-out-of-sequence-progress/case.xer": { + "sha256": "3ecc0ffd36c458ff7432bfd2340a20ee29e2c7ccb220040c6f7b0e43b8d9f254", + "source": "cpp-cpm-engine@c279a5c4ff204ba763a6f9726aa6383574b50475", + "role": "engine-input", + "included": false, + "exclusionReason": "Gegenereerde parser-/engine-invoerfixture zonder onafhankelijke P6-uitvoer." + }, + "cpp-cpm-engine/validation/xer-corpus/cases/11-no-logic/case.xer": { + "sha256": "c4ae28545d2b2bad5ed7ca9ac8ced1aed7747b8db7a268f6e4a9cae1d5e5c61d", + "source": "cpp-cpm-engine@c279a5c4ff204ba763a6f9726aa6383574b50475", + "role": "engine-input", + "included": false, + "exclusionReason": "Gegenereerde parser-/engine-invoerfixture zonder onafhankelijke P6-uitvoer." + }, + "cpp-cpm-engine/validation/xer-corpus/cases/12-milestone-heavy/case.xer": { + "sha256": "2a71ac9c5dce26dde68ff2c9f5b751a1c73416504d926e9bfa1328b66f388fbc", + "source": "cpp-cpm-engine@c279a5c4ff204ba763a6f9726aa6383574b50475", + "role": "engine-input", + "included": false, + "exclusionReason": "Gegenereerde parser-/engine-invoerfixture zonder onafhankelijke P6-uitvoer." + }, + "cpp-cpm-engine/validation/xer-corpus/cases/13-large-1000-dag-branching/case.xer": { + "sha256": "65bb212d3bf44c5d3e14e9f3aae97d2a058e44056a21cd1227e91d3f769eee82", + "source": "cpp-cpm-engine@c279a5c4ff204ba763a6f9726aa6383574b50475", + "role": "engine-input", + "included": false, + "exclusionReason": "Gegenereerde parser-/engine-invoerfixture zonder onafhankelijke P6-uitvoer." + }, + "crawl-xer/all-relation-types.xer": { + "sha256": "b851e91222c491682fb777b953eb659ee938cf5609829130d2e2baaaa2239253", + "source": "crawl-xer (publieke crawl)", + "role": "pseudo-xer", + "included": false, + "exclusionReason": "Formaat-/robuustheidsfixture of niet-canonieke XER; geen P6-uitvoerorakel." + }, + "crawl-xer/CRONOGRAMA_MACRO_SLNR_brazil.xer": { + "sha256": "bd3aefb87cfa0532de36b3972bd7dc4a5c7eb6f156b1b8e4af90e43ebf479ea4", + "source": "crawl-xer (publieke crawl)", + "role": "reference-only", + "included": false, + "exclusionReason": "Publiek referentiebestand zonder parseerbare P6-orakelcel op de zes poortassen." + }, + "crawl-xer/cycle-schedule.xer": { + "sha256": "95c66b76f33604f388121db78894b6de134c6bee125d59ca798995cee82ddde7", + "source": "crawl-xer (publieke crawl)", + "role": "pseudo-xer", + "included": false, + "exclusionReason": "Formaat-/robuustheidsfixture of niet-canonieke XER; geen P6-uitvoerorakel." + }, + "crawl-xer/DataCenter_Project.xer": { + "sha256": "c897dc5e2644749bac0b3a3a85cd637dff6027a0ccb7bf1b680680cfe69c6d7d", + "source": "crawl-xer (publieke crawl)", + "role": "pseudo-xer", + "included": false, + "exclusionReason": "Formaat-/robuustheidsfixture of niet-canonieke XER; geen P6-uitvoerorakel." + }, + "crawl-xer/droid_skeleton_fmt-1455.xer": { + "sha256": "ec911df4152b92b66372deee8df75cd497b3492067143446a5a77b8bb1e25cbc", + "source": "crawl-xer (publieke crawl)", + "role": "pseudo-xer", + "included": false, + "exclusionReason": "Formaat-/robuustheidsfixture of niet-canonieke XER; geen P6-uitvoerorakel." + }, + "crawl-xer/ecm-sample-schedule.xer": { + "sha256": "fb0b4545e60190dacf353550e0496b343a590527dad2175c022d1a6179a8521b", + "source": "crawl-xer (publieke crawl)", + "role": "pseudo-xer", + "included": false, + "exclusionReason": "Formaat-/robuustheidsfixture of niet-canonieke XER; geen P6-uitvoerorakel." + }, + "crawl-xer/eh_P6Workshops/OZB-Start-09Dec24.xer": { + "sha256": "2bc12241c3f8ee5b7472dd0e77f2cbffafcf3b5438b17022fd9db4f4c642d4b0", + "source": "crawl-xer (publieke crawl)", + "role": "oracle", + "included": true + }, + "crawl-xer/gimmer-crag-mountain-refuge.xer": { + "sha256": "9590c4cdc4efa69a231b66ac509611d17f31605599ee7abde02a197f5e8b1e6e", + "source": "crawl-xer (publieke crawl)", + "role": "oracle", + "included": true + }, + "crawl-xer/HarbourPointe_AssistedLiving.xer": { + "sha256": "b9547eb91c30af1750933a64409d8b2a4a2c1dbe4c0be276a04ab150c8a50167", + "source": "crawl-xer (publieke crawl)", + "role": "oracle", + "included": true + }, + "crawl-xer/hb-intel_Project_Schedule.xer": { + "sha256": "ad92ce52ba95b27539a4c0ba0c44e2a6b755ec2c15c3066009694c7430846684", + "source": "crawl-xer (publieke crawl)", + "role": "oracle", + "included": true + }, + "crawl-xer/Hospital_GW_Programme.bound.xer": { + "sha256": "0fa5172162fbd6895f88042d0277a8fbc46c9c82badbcbae333d3423dd208c86", + "source": "crawl-xer (publieke crawl)", + "role": "oracle", + "included": true + }, + "crawl-xer/Hospital_GW_Programme.xer": { + "sha256": "fc74bdab8629d9e81e5c7e58900ea4632b4525f63cca9fa546d4f17605936de6", + "source": "crawl-xer (publieke crawl)", + "role": "oracle", + "included": true + }, + "crawl-xer/Hotel_Construction_TEC.xer": { + "sha256": "9679599df9108bd3b31e755351c1543d1915b82a91445a3f1b0b9c9a3d1f86fe", + "source": "crawl-xer (publieke crawl)", + "role": "oracle", + "included": true + }, + "crawl-xer/kedular-empty-schedule.xer": { + "sha256": "f0af54006bc07b8731794e4b69ddad6be9ed6b0777961cd01c1cbcf3ce9dae53", + "source": "crawl-xer (publieke crawl)", + "role": "parser-fixture", + "included": false, + "exclusionReason": "Gerichte XER-parserfixture; herkomst is testopbouw, niet een P6-capture." + }, + "crawl-xer/kedular-field-mismatch.xer": { + "sha256": "4d39fc3daf2d2859aa06feb73ee66c52def843a0f4fb1589783e970f56be33e3", + "source": "crawl-xer (publieke crawl)", + "role": "parser-fixture", + "included": false, + "exclusionReason": "Gerichte XER-parserfixture; herkomst is testopbouw, niet een P6-capture." + }, + "crawl-xer/kedular-minimal-valid.xer": { + "sha256": "b72a879f6282cc6193bb0d13e2671843cafbb1d1edb5546ec7a99f4744037262", + "source": "crawl-xer (publieke crawl)", + "role": "parser-fixture", + "included": false, + "exclusionReason": "Gerichte XER-parserfixture; herkomst is testopbouw, niet een P6-capture." + }, + "crawl-xer/kedular-missing-calendar.xer": { + "sha256": "11d8c34fff8cf0b8db69348813001638c81fa1db2d2f22c0006c2d5090500477", + "source": "crawl-xer (publieke crawl)", + "role": "parser-fixture", + "included": false, + "exclusionReason": "Gerichte XER-parserfixture; herkomst is testopbouw, niet een P6-capture." + }, + "crawl-xer/kedular-missing-taskpred.xer": { + "sha256": "9f2964425c0e295bfc3a62ba0c860d9be5a6167a5494427007a42ecce5df6cc6", + "source": "crawl-xer (publieke crawl)", + "role": "parser-fixture", + "included": false, + "exclusionReason": "Gerichte XER-parserfixture; herkomst is testopbouw, niet een P6-capture." + }, + "crawl-xer/kedular-multi-project.xer": { + "sha256": "32524ea41f8bb4cba3de26d12e589bb1cd750a61e4b8e5863b89108d085b7b09", + "source": "crawl-xer (publieke crawl)", + "role": "parser-fixture", + "included": false, + "exclusionReason": "Gerichte XER-parserfixture; herkomst is testopbouw, niet een P6-capture." + }, + "crawl-xer/kedular-no-terminator.xer": { + "sha256": "98f079b267688de209d20d8156026111f7a045cb67f3457e277a3d29a4fd9c04", + "source": "crawl-xer (publieke crawl)", + "role": "parser-fixture", + "included": false, + "exclusionReason": "Gerichte XER-parserfixture; herkomst is testopbouw, niet een P6-capture." + }, + "crawl-xer/kedular-unknown-tables.xer": { + "sha256": "c1ccf20436ba38b5846d0ac6b104551d97281093effaa40a9eb6222728fb8ee3", + "source": "crawl-xer (publieke crawl)", + "role": "parser-fixture", + "included": false, + "exclusionReason": "Gerichte XER-parserfixture; herkomst is testopbouw, niet een P6-capture." + }, + "crawl-xer/MER-1-2026_epc.xer": { + "sha256": "49aea658c963a0054e97716aa608fb31798f1616c6039c1a379e6023717e660e", + "source": "crawl-xer (publieke crawl)", + "role": "oracle", + "included": true + }, + "crawl-xer/p6_torture_test_v1.xer": { + "sha256": "2a7732b5b99de2a5f40aab40e0af52757fbe68e5d4eda404a9a060fdc1be1140", + "source": "crawl-xer (publieke crawl)", + "role": "synthetic-fixture", + "included": false, + "exclusionReason": "Expliciete torture-testfixture; geen onafhankelijke P6-capture." + }, + "crawl-xer/p6diff-baseline.xer": { + "sha256": "568c19375b4e0d674c75e6aea023c98772fb33e6896755f866e4641a56197300", + "source": "crawl-xer (publieke crawl)", + "role": "oracle", + "included": true + }, + "crawl-xer/p6diff-revised.xer": { + "sha256": "97058f720a70f62399c92f7f84ffb4a29b936baf95c6a2e339e3cc4a222f318c", + "source": "crawl-xer (publieke crawl)", + "role": "oracle", + "included": true + }, + "crawl-xer/p6xer-basic.xer": { + "sha256": "18edce1bd5bd7a50a97e7dda260cb09270afc0fab3649b6d54867a4e9bd1c26b", + "source": "crawl-xer (publieke crawl)", + "role": "pseudo-xer", + "included": false, + "exclusionReason": "Formaat-/robuustheidsfixture of niet-canonieke XER; geen P6-uitvoerorakel." + }, + "crawl-xer/p6xer-comprehensive.xer": { + "sha256": "ade09ae6394d5d6b954748eab08338855c73097b6a1e46238b256049c701a4d6", + "source": "crawl-xer (publieke crawl)", + "role": "pseudo-xer", + "included": false, + "exclusionReason": "Formaat-/robuustheidsfixture of niet-canonieke XER; geen P6-uitvoerorakel." + }, + "crawl-xer/p6xer-empty_tables.xer": { + "sha256": "29c7647a5d05da5e30e5c8319201cabc1ba7fa8a14a2455aec9ecaa0a9a675ce", + "source": "crawl-xer (publieke crawl)", + "role": "pseudo-xer", + "included": false, + "exclusionReason": "Formaat-/robuustheidsfixture of niet-canonieke XER; geen P6-uitvoerorakel." + }, + "crawl-xer/p6xer-encodings.xer": { + "sha256": "f94dc80e72dc337eec1c6e3ca22d5e315361c0f33089d4d6bbd29dd8d60f2807", + "source": "crawl-xer (publieke crawl)", + "role": "pseudo-xer", + "included": false, + "exclusionReason": "Formaat-/robuustheidsfixture of niet-canonieke XER; geen P6-uitvoerorakel." + }, + "crawl-xer/p6xer-malformed.xer": { + "sha256": "5fed5cba18bca4d1c7254e0b10fa020813393de3e34acd9ead89bf4f88320315", + "source": "crawl-xer (publieke crawl)", + "role": "pseudo-xer", + "included": false, + "exclusionReason": "Formaat-/robuustheidsfixture of niet-canonieke XER; geen P6-uitvoerorakel." + }, + "crawl-xer/p6xer-mixed_endings.xer": { + "sha256": "dceb95f4d97f48f2a6092859dc019ca64debfaf6fe82a8827a1010a3b9032db8", + "source": "crawl-xer (publieke crawl)", + "role": "pseudo-xer", + "included": false, + "exclusionReason": "Formaat-/robuustheidsfixture of niet-canonieke XER; geen P6-uitvoerorakel." + }, + "crawl-xer/Roads_Project_TEC.xer": { + "sha256": "a2ef7b35c00d8cf8ba75230d71d69b96130f7f689495e86a4cf5f8fcd34431ae", + "source": "crawl-xer (publieke crawl)", + "role": "oracle", + "included": true + }, + "crawl-xer/Sample_Construction_TEC.xer": { + "sha256": "55b7e4463dcd36ba02eae072445e8fc2cc684a188e40c588b05321963a362c5e", + "source": "crawl-xer (publieke crawl)", + "role": "oracle", + "included": true + }, + "crawl-xer/stack_data_center_baseline.xer": { + "sha256": "1ba69d297ee9a5c65c644e7373dc414aa334c996b74800c4688aea7e3681eebf", + "source": "crawl-xer (publieke crawl)", + "role": "oracle", + "included": true + }, + "crawl-xer/submitlog-baseline-taskpred.xer": { + "sha256": "a303595f1def90620cb7da693e7217f364dd66bbc3b3775de5ae93106fa504e4", + "source": "crawl-xer (publieke crawl)", + "role": "pseudo-xer", + "included": false, + "exclusionReason": "Formaat-/robuustheidsfixture of niet-canonieke XER; geen P6-uitvoerorakel." + }, + "crawl-xer-extra/ashspace-primeveraxereditor/sample.xer": { + "sha256": "68ce5f0bb2b534d5ba192e3ad8241025d74ae45e94209f11731d42d32f18f82b", + "source": "crawl-xer-extra/MANIFEST.md", + "role": "oracle", + "included": true + }, + "crawl-xer-extra/clause-epc/MER-1-2026_update_2026-05-01.xer": { + "sha256": "5d71eac4e70b0d95365d5637d0e79874686f7332d1e90a939c28124260d79f47", + "source": "crawl-xer-extra/MANIFEST.md", + "role": "oracle", + "included": true + }, + "crawl-xer-extra/eq-proof/schedule.xer": { + "sha256": "233486f70f5a4ee98c6fcac046515148b28b4256e5d61466146324359c6b888a", + "source": "crawl-xer-extra/MANIFEST.md", + "role": "synthetic-fixture", + "included": false, + "exclusionReason": "Volgens crawl-xer-extra/MANIFEST.md een kleine edge-/handgetypte fixture." + }, + "crawl-xer-extra/groupdocs-conversion/sample.xer": { + "sha256": "b7ef6d85acfedda1a8d3a77391592587d3ca3088013b896d03fd129b23d83834", + "source": "crawl-xer-extra/MANIFEST.md", + "role": "oracle", + "included": true + }, + "crawl-xer-extra/jailaff-xer-splitter/rehab-2.xer": { + "sha256": "2c1dce175b9f078111a48dc13fd1777f5fbd4cd7ab6623e647e7437330c60b7f", + "source": "crawl-xer-extra/MANIFEST.md", + "role": "oracle", + "included": true + }, + "crawl-xer-extra/meridianiq/demo-sample.xer": { + "sha256": "146e4c8659bad066b298988613dec178ce63f3a2c35415937b3d70a8f8ef8158", + "source": "crawl-xer-extra/MANIFEST.md", + "role": "oracle", + "included": true + }, + "crawl-xer-extra/meridianiq/sample_update2.xer": { + "sha256": "53f5cdbeb3fbd9c6cb252a48c2ad9206f1f2ab44215372f83199df3a90803ffd", + "source": "crawl-xer-extra/MANIFEST.md", + "role": "oracle", + "included": true + }, + "crawl-xer-extra/p6difftool/sample-target.xer": { + "sha256": "c872c9e704797d829205f3c5486e7c4cd5aec729143a63fbcf8b0ec8e8864a3c", + "source": "crawl-xer-extra/MANIFEST.md", + "role": "oracle", + "included": true + }, + "crawl-xer-extra/pda-platform/simple_project.xer": { + "sha256": "8c82d244fb59e3439ba34e44ff7989f6b490344cdee3493ab7caacdce3038270", + "source": "crawl-xer-extra/MANIFEST.md", + "role": "synthetic-fixture", + "included": false, + "exclusionReason": "Volgens crawl-xer-extra/MANIFEST.md een kleine edge-/handgetypte fixture." + }, + "crawl-xer-extra/planning-risk-intelligent/sample_schedule.xer": { + "sha256": "1ba8cf1885491db3c19ebffc85fbd33935345ef9effb386a8da33226e665f9ab", + "source": "crawl-xer-extra/MANIFEST.md", + "role": "oracle", + "included": true + }, + "crawl-xer-extra/polittdj-schedule-manipulation/commercial_construction.xer": { + "sha256": "3daf749da5d4cd8909a33da2ea07f8c84cfdb62edfe4ce31d32152ffe152367d", + "source": "crawl-xer-extra/MANIFEST.md", + "role": "synthetic-fixture", + "included": false, + "exclusionReason": "Volgens crawl-xer-extra/MANIFEST.md handmatig opgebouwde synthetische fixture." + }, + "crawl-xer-extra/schedulevisualiser/Sample_Project.xer": { + "sha256": "deabc76347eaef15727f430951a8a1b957dd5ab0251dc4500e15c817a8e030b8", + "source": "crawl-xer-extra/MANIFEST.md", + "role": "oracle", + "included": true + }, + "crawl-xer-extra/xernative/sample.xer": { + "sha256": "4d8bce790a93b9bc747abec5d99840e3e0d1ce5523e0afae3ba881762cfe2c13", + "source": "crawl-xer-extra/MANIFEST.md", + "role": "oracle", + "included": true + }, + "delay-analysis-toolkit/deploy/aws-package/sample/Harbour Point DCP-03 - As-Built Programme Rev 12.xer": { + "sha256": "816e01738f4967872980ce3fed0c2bfd8c09f78197b7de1beea5ca98e094aeae", + "source": "delay-analysis-toolkit@ecab947b1bde0cf0709aa508fad37a680ad818c4", + "role": "oracle", + "included": true + }, + "delay-analysis-toolkit/deploy/aws-package/sample/Harbour Point DCP-03 - Baseline Programme Rev 0.xer": { + "sha256": "0611f9054a4b566340dd53181b96d66907f4ac8faa0ef1395ec338be9f83b96d", + "source": "delay-analysis-toolkit@ecab947b1bde0cf0709aa508fad37a680ad818c4", + "role": "oracle", + "included": true + }, + "delay-analysis-toolkit/deploy/aws-package/sample/programmes/harbour_point_dcp03/Harbour Point DCP-03 - As-Built Programme Rev 12.xer": { + "sha256": "816e01738f4967872980ce3fed0c2bfd8c09f78197b7de1beea5ca98e094aeae", + "source": "delay-analysis-toolkit@ecab947b1bde0cf0709aa508fad37a680ad818c4", + "role": "oracle", + "included": true + }, + "delay-analysis-toolkit/deploy/aws-package/sample/programmes/harbour_point_dcp03/Harbour Point DCP-03 - Baseline Programme Rev 0.xer": { + "sha256": "0611f9054a4b566340dd53181b96d66907f4ac8faa0ef1395ec338be9f83b96d", + "source": "delay-analysis-toolkit@ecab947b1bde0cf0709aa508fad37a680ad818c4", + "role": "oracle", + "included": true + }, + "delay-analysis-toolkit/deploy/aws-package/sample/revisions/revA.xer": { + "sha256": "79f6fb43bb391ed609f164e6dc5d38d329b634467bc6a7b06cbf3d8a351c8078", + "source": "delay-analysis-toolkit@ecab947b1bde0cf0709aa508fad37a680ad818c4", + "role": "oracle", + "included": true + }, + "delay-analysis-toolkit/deploy/aws-package/sample/revisions/revB.xer": { + "sha256": "def489b2a803af9f939b964b80e8fd9b2dc8143df280ad2a3088d91d4d6cdac3", + "source": "delay-analysis-toolkit@ecab947b1bde0cf0709aa508fad37a680ad818c4", + "role": "oracle", + "included": true + }, + "delay-analysis-toolkit/deploy/aws-package/sample/revisions/revC.xer": { + "sha256": "a7fc367eab6c904e6659a8206b9a162df132824c630e6175665c8a63f054104b", + "source": "delay-analysis-toolkit@ecab947b1bde0cf0709aa508fad37a680ad818c4", + "role": "oracle", + "included": true + }, + "delay-analysis-toolkit/sample/Harbour Point DCP-03 - As-Built Programme Rev 12.xer": { + "sha256": "816e01738f4967872980ce3fed0c2bfd8c09f78197b7de1beea5ca98e094aeae", + "source": "delay-analysis-toolkit@ecab947b1bde0cf0709aa508fad37a680ad818c4", + "role": "oracle", + "included": true + }, + "delay-analysis-toolkit/sample/Harbour Point DCP-03 - Baseline Programme Rev 0.xer": { + "sha256": "0611f9054a4b566340dd53181b96d66907f4ac8faa0ef1395ec338be9f83b96d", + "source": "delay-analysis-toolkit@ecab947b1bde0cf0709aa508fad37a680ad818c4", + "role": "oracle", + "included": true + }, + "delay-analysis-toolkit/sample/programmes/harbour_point_dcp03/Harbour Point DCP-03 - As-Built Programme Rev 12.xer": { + "sha256": "816e01738f4967872980ce3fed0c2bfd8c09f78197b7de1beea5ca98e094aeae", + "source": "delay-analysis-toolkit@ecab947b1bde0cf0709aa508fad37a680ad818c4", + "role": "oracle", + "included": true + }, + "delay-analysis-toolkit/sample/programmes/harbour_point_dcp03/Harbour Point DCP-03 - Baseline Programme Rev 0.xer": { + "sha256": "0611f9054a4b566340dd53181b96d66907f4ac8faa0ef1395ec338be9f83b96d", + "source": "delay-analysis-toolkit@ecab947b1bde0cf0709aa508fad37a680ad818c4", + "role": "oracle", + "included": true + }, + "delay-analysis-toolkit/sample/revisions/revA.xer": { + "sha256": "79f6fb43bb391ed609f164e6dc5d38d329b634467bc6a7b06cbf3d8a351c8078", + "source": "delay-analysis-toolkit@ecab947b1bde0cf0709aa508fad37a680ad818c4", + "role": "oracle", + "included": true + }, + "delay-analysis-toolkit/sample/revisions/revB.xer": { + "sha256": "def489b2a803af9f939b964b80e8fd9b2dc8143df280ad2a3088d91d4d6cdac3", + "source": "delay-analysis-toolkit@ecab947b1bde0cf0709aa508fad37a680ad818c4", + "role": "oracle", + "included": true + }, + "delay-analysis-toolkit/sample/revisions/revC.xer": { + "sha256": "a7fc367eab6c904e6659a8206b9a162df132824c630e6175665c8a63f054104b", + "source": "delay-analysis-toolkit@ecab947b1bde0cf0709aa508fad37a680ad818c4", + "role": "oracle", + "included": true + }, + "mpxj/junit/data/assignment/resource-rates-test.xer": { + "sha256": "7298574bb32ac3068b07b427a35cd2834308d2fcb0e2ac74fea4e013e738c62e", + "source": "mpxj@68d36e96f301483fd16a2928638587fdfb3a9af3", + "role": "parser-fixture", + "included": false, + "exclusionReason": "MPXJ-junitfixture; nuttig voor parsergedrag maar geen onafhankelijk corpusorakel." + }, + "mpxj/junit/data/calendar/invalid/invalid-8.4.xer": { + "sha256": "85f8c7d681b15daf755ba1d568eb09ebcdd529a16c20045e6db6c2937899f54b", + "source": "mpxj@68d36e96f301483fd16a2928638587fdfb3a9af3", + "role": "parser-fixture", + "included": false, + "exclusionReason": "MPXJ-junitfixture; nuttig voor parsergedrag maar geen onafhankelijk corpusorakel." + }, + "mpxj/junit/data/generated/task-percentcomplete/percent-complete-8.4.xer": { + "sha256": "71e56568075852d00507ff5506d5c055912ce0ba0982b97b8ace6269f794d10a", + "source": "mpxj@68d36e96f301483fd16a2928638587fdfb3a9af3", + "role": "parser-fixture", + "included": false, + "exclusionReason": "MPXJ-junitfixture; nuttig voor parsergedrag maar geen onafhankelijk corpusorakel." + }, + "mpxj/junit/data/PredecessorCalendar.xer": { + "sha256": "46f3c1c68552429cf32191ebc8f48d12f52e517481b6fb588ad1955937c5cd1d", + "source": "mpxj@68d36e96f301483fd16a2928638587fdfb3a9af3", + "role": "parser-fixture", + "included": false, + "exclusionReason": "MPXJ-junitfixture; nuttig voor parsergedrag maar geen onafhankelijk corpusorakel." + }, + "mpxj/junit/data/ProjectDefaultCalendar.xer": { + "sha256": "e48eb3a53b7e8f40a5a87af8e983e9932c298baeb572e3a901b3961f997674ef", + "source": "mpxj@68d36e96f301483fd16a2928638587fdfb3a9af3", + "role": "parser-fixture", + "included": false, + "exclusionReason": "MPXJ-junitfixture; nuttig voor parsergedrag maar geen onafhankelijk corpusorakel." + }, + "mpxj/junit/data/SuccessorCalendar.xer": { + "sha256": "ab420cc573be248ef7ef344f77b204176c3263fceb45c27866276929bf1e59e2", + "source": "mpxj@68d36e96f301483fd16a2928638587fdfb3a9af3", + "role": "parser-fixture", + "included": false, + "exclusionReason": "MPXJ-junitfixture; nuttig voor parsergedrag maar geen onafhankelijk corpusorakel." + }, + "mpxj/junit/data/TwentyFourHourCalendar.xer": { + "sha256": "9b91bf5bfcc0df4f6a2fec8867a923af7758058c224a9772a728e8d2ba0f0fc5", + "source": "mpxj@68d36e96f301483fd16a2928638587fdfb3a9af3", + "role": "parser-fixture", + "included": false, + "exclusionReason": "MPXJ-junitfixture; nuttig voor parsergedrag maar geen onafhankelijk corpusorakel." + }, + "P6-Viewer/XER Files/Hotel Project.xer": { + "sha256": "d2c413088afed805b30b7628c91be8aca870f7d4daf00eb12a547d69913d8352", + "source": "P6-Viewer@c32dc4c46502adf8d8338ed1abac119aedf21f75", + "role": "oracle", + "included": true + }, + "P6-Viewer/XER Files/sample.xer": { + "sha256": "1d7901d68cc7063b59dcedacc3eeae8d2c89178516421c09ae38ff523429e3af", + "source": "P6-Viewer@c32dc4c46502adf8d8338ed1abac119aedf21f75", + "role": "oracle", + "included": true + }, + "P6-Viewer/XER Files/TERMINAL BUILDING-AIRPORT.xer": { + "sha256": "a2f3b2469e26f19985ac801786c90edfb965a7bc194a6e57704aa845c5f8611f", + "source": "P6-Viewer@c32dc4c46502adf8d8338ed1abac119aedf21f75", + "role": "oracle", + "included": true + }, + "p6flow/fixtures/synthetic_v2212.xer": { + "sha256": "1d7bd29d2c157bdd9abad5723f84edab1f8cd925cdf3003aff181b597431a948", + "source": "p6flow@637c12ec610aefda957b3964f993804176d2c9bd", + "role": "synthetic-fixture", + "included": false, + "exclusionReason": "Door p6flow/scripts/make_synthetic_fixture.py gegenereerd." + }, + "pmxml-samples/testXer.xer": { + "sha256": "b8bc3cb30463c99c2255c5bd243888ac2706b7f6a1bfe31e242407a24f002dcf", + "source": "pmxml-samples (publieke voorbeeldset)", + "role": "pseudo-xer", + "included": false, + "exclusionReason": "Voorbeeldbestand met lege project-/taakidentiteit; niet geschikt als exact uitlijnbaar P6-orakel." + }, + "ProjectLens/docs/demo/northstar-baseline.xer": { + "sha256": "467ab0eb0885c4a78f7101abd512bf34a6fe6e85770b49d1bee855633a4d4a73", + "source": "ProjectLens@fe3338212eaa5acd7876a1a42d028a6bb11d5f8b", + "role": "oracle", + "included": true + }, + "ProjectLens/docs/demo/northstar-current.xer": { + "sha256": "a132f8c7ebb070d29936b8852c6dca772ebb547c0ade91c17c96425e37e63818", + "source": "ProjectLens@fe3338212eaa5acd7876a1a42d028a6bb11d5f8b", + "role": "oracle", + "included": true + }, + "ProjectLens/docs/demo/northstar-previous.xer": { + "sha256": "ac4b9d677133c6edcd6129050f32233e77c9d62ef06c7caac0dbf1da0d1e43ab", + "source": "ProjectLens@fe3338212eaa5acd7876a1a42d028a6bb11d5f8b", + "role": "oracle", + "included": true + }, + "ProjectLens/docs/demo/riverside-current.xer": { + "sha256": "790b90fda837bad342658bf2c712cf6c39ab6adcd812e67edf93cda1c7eb362f", + "source": "ProjectLens@fe3338212eaa5acd7876a1a42d028a6bb11d5f8b", + "role": "oracle", + "included": true + }, + "ProjectLens/docs/demo/riverside-previous.xer": { + "sha256": "78325fb6445d22f5949bf05c1e0afe3e14b72efc9cc9480800eab4bf2b0a1056", + "source": "ProjectLens@fe3338212eaa5acd7876a1a42d028a6bb11d5f8b", + "role": "oracle", + "included": true + } + } +} diff --git a/tests/planning/xer-fidelity-baseline.json b/tests/planning/xer-fidelity-baseline.json new file mode 100644 index 000000000..55bd64871 --- /dev/null +++ b/tests/planning/xer-fidelity-baseline.json @@ -0,0 +1,1092 @@ +{ + "files": { + "68ce5f0bb2b534d5": { + "label": "crawl-xer-extra/ashspace-primeveraxereditor/sample.xer", + "tasks": 52, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 52 + }, + "ef": { + "deviations": 0, + "measurable": 52 + }, + "ls": { + "deviations": 0, + "measurable": 52 + }, + "lf": { + "deviations": 0, + "measurable": 52 + }, + "tf": { + "deviations": 0, + "measurable": 52 + }, + "ff": { + "deviations": 0, + "measurable": 52 + } + }, + "schemaFingerprint": "97fa9ea1f533ca60" + }, + "5d71eac4e70b0d95": { + "label": "crawl-xer-extra/clause-epc/MER-1-2026_update_2026-05-01.xer", + "tasks": 120, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 120 + }, + "ef": { + "deviations": 0, + "measurable": 120 + }, + "ls": { + "deviations": 0, + "measurable": 120 + }, + "lf": { + "deviations": 0, + "measurable": 120 + }, + "tf": { + "deviations": 0, + "measurable": 120 + }, + "ff": { + "deviations": 0, + "measurable": 0 + } + }, + "schemaFingerprint": "bedebad4a58673b0" + }, + "b7ef6d85acfedda1": { + "label": "crawl-xer-extra/groupdocs-conversion/sample.xer", + "tasks": 70, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 70 + }, + "ef": { + "deviations": 0, + "measurable": 70 + }, + "ls": { + "deviations": 0, + "measurable": 70 + }, + "lf": { + "deviations": 0, + "measurable": 70 + }, + "tf": { + "deviations": 0, + "measurable": 70 + }, + "ff": { + "deviations": 0, + "measurable": 70 + } + }, + "schemaFingerprint": "eff3a3e2b55bfb17" + }, + "2c1dce175b9f0781": { + "label": "crawl-xer-extra/jailaff-xer-splitter/rehab-2.xer", + "tasks": 6977, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 6976 + }, + "ef": { + "deviations": 0, + "measurable": 6976 + }, + "ls": { + "deviations": 0, + "measurable": 6976 + }, + "lf": { + "deviations": 0, + "measurable": 6976 + }, + "tf": { + "deviations": 0, + "measurable": 6976 + }, + "ff": { + "deviations": 0, + "measurable": 6976 + } + }, + "schemaFingerprint": "2493349dd14f7fb3" + }, + "146e4c8659bad066": { + "label": "crawl-xer-extra/meridianiq/demo-sample.xer", + "tasks": 30, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 30 + }, + "ef": { + "deviations": 0, + "measurable": 30 + }, + "ls": { + "deviations": 0, + "measurable": 30 + }, + "lf": { + "deviations": 0, + "measurable": 30 + }, + "tf": { + "deviations": 0, + "measurable": 30 + }, + "ff": { + "deviations": 0, + "measurable": 30 + } + }, + "schemaFingerprint": "789d55d2260a50b8" + }, + "53f5cdbeb3fbd9c6": { + "label": "crawl-xer-extra/meridianiq/sample_update2.xer", + "tasks": 31, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 31 + }, + "ef": { + "deviations": 0, + "measurable": 31 + }, + "ls": { + "deviations": 0, + "measurable": 31 + }, + "lf": { + "deviations": 0, + "measurable": 31 + }, + "tf": { + "deviations": 0, + "measurable": 31 + }, + "ff": { + "deviations": 0, + "measurable": 31 + } + }, + "schemaFingerprint": "e5ee686e1f756d6f" + }, + "c872c9e704797d82": { + "label": "crawl-xer-extra/p6difftool/sample-target.xer", + "tasks": 8, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 8 + }, + "ef": { + "deviations": 0, + "measurable": 8 + }, + "ls": { + "deviations": 0, + "measurable": 8 + }, + "lf": { + "deviations": 0, + "measurable": 8 + }, + "tf": { + "deviations": 0, + "measurable": 8 + }, + "ff": { + "deviations": 0, + "measurable": 8 + } + }, + "schemaFingerprint": "076682f8996cef76" + }, + "1ba8cf1885491db3": { + "label": "crawl-xer-extra/planning-risk-intelligent/sample_schedule.xer", + "tasks": 20, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 20 + }, + "ef": { + "deviations": 0, + "measurable": 20 + }, + "ls": { + "deviations": 0, + "measurable": 20 + }, + "lf": { + "deviations": 0, + "measurable": 20 + }, + "tf": { + "deviations": 0, + "measurable": 20 + }, + "ff": { + "deviations": 0, + "measurable": 20 + } + }, + "schemaFingerprint": "6431acce233a860e" + }, + "deabc76347eaef15": { + "label": "crawl-xer-extra/schedulevisualiser/Sample_Project.xer", + "tasks": 24, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 24 + }, + "ef": { + "deviations": 0, + "measurable": 24 + }, + "ls": { + "deviations": 0, + "measurable": 24 + }, + "lf": { + "deviations": 0, + "measurable": 24 + }, + "tf": { + "deviations": 0, + "measurable": 24 + }, + "ff": { + "deviations": 0, + "measurable": 24 + } + }, + "schemaFingerprint": "56fdb27c001a9872" + }, + "4d8bce790a93b9bc": { + "label": "crawl-xer-extra/xernative/sample.xer", + "tasks": 25, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 25 + }, + "ef": { + "deviations": 0, + "measurable": 25 + }, + "ls": { + "deviations": 0, + "measurable": 25 + }, + "lf": { + "deviations": 0, + "measurable": 25 + }, + "tf": { + "deviations": 0, + "measurable": 25 + }, + "ff": { + "deviations": 0, + "measurable": 25 + } + }, + "schemaFingerprint": "80fd54b21f2afc76" + }, + "2bc12241c3f8ee5b": { + "label": "crawl-xer/eh_P6Workshops/OZB-Start-09Dec24.xer", + "tasks": 169, + "projects": 12, + "counters": { + "es": { + "deviations": 0, + "measurable": 166 + }, + "ef": { + "deviations": 0, + "measurable": 166 + }, + "ls": { + "deviations": 0, + "measurable": 166 + }, + "lf": { + "deviations": 0, + "measurable": 166 + }, + "tf": { + "deviations": 0, + "measurable": 152 + }, + "ff": { + "deviations": 0, + "measurable": 152 + } + }, + "schemaFingerprint": "ec668c11ba3d5921" + }, + "9590c4cdc4efa69a": { + "label": "crawl-xer/gimmer-crag-mountain-refuge.xer", + "tasks": 93, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 93 + }, + "ef": { + "deviations": 0, + "measurable": 93 + }, + "ls": { + "deviations": 0, + "measurable": 93 + }, + "lf": { + "deviations": 0, + "measurable": 93 + }, + "tf": { + "deviations": 0, + "measurable": 0 + }, + "ff": { + "deviations": 0, + "measurable": 0 + } + }, + "schemaFingerprint": "667a456c465a069f" + }, + "b9547eb91c30af17": { + "label": "crawl-xer/HarbourPointe_AssistedLiving.xer", + "tasks": 131, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 131 + }, + "ef": { + "deviations": 0, + "measurable": 131 + }, + "ls": { + "deviations": 0, + "measurable": 131 + }, + "lf": { + "deviations": 0, + "measurable": 131 + }, + "tf": { + "deviations": 0, + "measurable": 113 + }, + "ff": { + "deviations": 0, + "measurable": 113 + } + }, + "schemaFingerprint": "a81fedfd4aaa73d8" + }, + "ad92ce52ba95b275": { + "label": "crawl-xer/hb-intel_Project_Schedule.xer", + "tasks": 281, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 281 + }, + "ef": { + "deviations": 0, + "measurable": 281 + }, + "ls": { + "deviations": 0, + "measurable": 281 + }, + "lf": { + "deviations": 0, + "measurable": 281 + }, + "tf": { + "deviations": 0, + "measurable": 281 + }, + "ff": { + "deviations": 0, + "measurable": 281 + } + }, + "schemaFingerprint": "b4049a03e6562ab3" + }, + "0fa5172162fbd689": { + "label": "crawl-xer/Hospital_GW_Programme.bound.xer", + "tasks": 14, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 0 + }, + "ef": { + "deviations": 0, + "measurable": 0 + }, + "ls": { + "deviations": 0, + "measurable": 0 + }, + "lf": { + "deviations": 0, + "measurable": 0 + }, + "tf": { + "deviations": 0, + "measurable": 14 + }, + "ff": { + "deviations": 0, + "measurable": 14 + } + }, + "schemaFingerprint": "1b13676b68daa11e" + }, + "9679599df9108bd3": { + "label": "crawl-xer/Hotel_Construction_TEC.xer", + "tasks": 4236, + "projects": 2, + "counters": { + "es": { + "deviations": 0, + "measurable": 4217 + }, + "ef": { + "deviations": 0, + "measurable": 4217 + }, + "ls": { + "deviations": 0, + "measurable": 4217 + }, + "lf": { + "deviations": 0, + "measurable": 4217 + }, + "tf": { + "deviations": 0, + "measurable": 4217 + }, + "ff": { + "deviations": 0, + "measurable": 4217 + } + }, + "schemaFingerprint": "f1e5f479674e3ce4" + }, + "49aea658c963a005": { + "label": "crawl-xer/MER-1-2026_epc.xer", + "tasks": 120, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 120 + }, + "ef": { + "deviations": 0, + "measurable": 120 + }, + "ls": { + "deviations": 0, + "measurable": 120 + }, + "lf": { + "deviations": 0, + "measurable": 120 + }, + "tf": { + "deviations": 0, + "measurable": 120 + }, + "ff": { + "deviations": 0, + "measurable": 0 + } + }, + "schemaFingerprint": "406419f29971a53c" + }, + "568c19375b4e0d67": { + "label": "crawl-xer/p6diff-baseline.xer", + "tasks": 8, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 8 + }, + "ef": { + "deviations": 0, + "measurable": 8 + }, + "ls": { + "deviations": 0, + "measurable": 8 + }, + "lf": { + "deviations": 0, + "measurable": 8 + }, + "tf": { + "deviations": 0, + "measurable": 8 + }, + "ff": { + "deviations": 0, + "measurable": 8 + } + }, + "schemaFingerprint": "7948ab001aa2da93" + }, + "97058f720a70f623": { + "label": "crawl-xer/p6diff-revised.xer", + "tasks": 8, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 8 + }, + "ef": { + "deviations": 0, + "measurable": 8 + }, + "ls": { + "deviations": 0, + "measurable": 8 + }, + "lf": { + "deviations": 0, + "measurable": 8 + }, + "tf": { + "deviations": 0, + "measurable": 8 + }, + "ff": { + "deviations": 0, + "measurable": 8 + } + }, + "schemaFingerprint": "dc5627141ff39da1" + }, + "a2ef7b35c00d8cf8": { + "label": "crawl-xer/Roads_Project_TEC.xer", + "tasks": 1193, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 1193 + }, + "ef": { + "deviations": 0, + "measurable": 1193 + }, + "ls": { + "deviations": 0, + "measurable": 1193 + }, + "lf": { + "deviations": 0, + "measurable": 1193 + }, + "tf": { + "deviations": 0, + "measurable": 1036 + }, + "ff": { + "deviations": 0, + "measurable": 1036 + } + }, + "schemaFingerprint": "c860b3291dba9ee1" + }, + "55b7e4463dcd36ba": { + "label": "crawl-xer/Sample_Construction_TEC.xer", + "tasks": 51, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 51 + }, + "ef": { + "deviations": 0, + "measurable": 51 + }, + "ls": { + "deviations": 0, + "measurable": 51 + }, + "lf": { + "deviations": 0, + "measurable": 51 + }, + "tf": { + "deviations": 0, + "measurable": 51 + }, + "ff": { + "deviations": 0, + "measurable": 51 + } + }, + "schemaFingerprint": "96a23d026fc67de2" + }, + "1ba69d297ee9a5c6": { + "label": "crawl-xer/stack_data_center_baseline.xer", + "tasks": 8, + "projects": 2, + "counters": { + "es": { + "deviations": 0, + "measurable": 0 + }, + "ef": { + "deviations": 0, + "measurable": 0 + }, + "ls": { + "deviations": 0, + "measurable": 0 + }, + "lf": { + "deviations": 0, + "measurable": 0 + }, + "tf": { + "deviations": 0, + "measurable": 8 + }, + "ff": { + "deviations": 0, + "measurable": 8 + } + }, + "schemaFingerprint": "b076686bf51b5e7f" + }, + "816e01738f496787": { + "label": "delay-analysis-toolkit/deploy/aws-package/sample/Harbour Point DCP-03 - As-Built Programme Rev 12.xer", + "tasks": 61, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 61 + }, + "ef": { + "deviations": 0, + "measurable": 61 + }, + "ls": { + "deviations": 0, + "measurable": 61 + }, + "lf": { + "deviations": 0, + "measurable": 61 + }, + "tf": { + "deviations": 0, + "measurable": 61 + }, + "ff": { + "deviations": 0, + "measurable": 61 + } + }, + "schemaFingerprint": "17c89dd77c177b3a" + }, + "0611f9054a4b5663": { + "label": "delay-analysis-toolkit/deploy/aws-package/sample/Harbour Point DCP-03 - Baseline Programme Rev 0.xer", + "tasks": 60, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 60 + }, + "ef": { + "deviations": 0, + "measurable": 60 + }, + "ls": { + "deviations": 0, + "measurable": 60 + }, + "lf": { + "deviations": 0, + "measurable": 60 + }, + "tf": { + "deviations": 0, + "measurable": 60 + }, + "ff": { + "deviations": 0, + "measurable": 60 + } + }, + "schemaFingerprint": "a5df7e105f83da06" + }, + "79f6fb43bb391ed6": { + "label": "delay-analysis-toolkit/deploy/aws-package/sample/revisions/revA.xer", + "tasks": 5, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 3 + }, + "ef": { + "deviations": 0, + "measurable": 5 + }, + "ls": { + "deviations": 0, + "measurable": 0 + }, + "lf": { + "deviations": 0, + "measurable": 0 + }, + "tf": { + "deviations": 0, + "measurable": 5 + }, + "ff": { + "deviations": 0, + "measurable": 0 + } + }, + "schemaFingerprint": "fd5753c0cb6c4715" + }, + "def489b2a803af9f": { + "label": "delay-analysis-toolkit/deploy/aws-package/sample/revisions/revB.xer", + "tasks": 5, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 3 + }, + "ef": { + "deviations": 0, + "measurable": 5 + }, + "ls": { + "deviations": 0, + "measurable": 0 + }, + "lf": { + "deviations": 0, + "measurable": 0 + }, + "tf": { + "deviations": 0, + "measurable": 5 + }, + "ff": { + "deviations": 0, + "measurable": 0 + } + }, + "schemaFingerprint": "faa4c0bade1d55ae" + }, + "a7fc367eab6c904e": { + "label": "delay-analysis-toolkit/deploy/aws-package/sample/revisions/revC.xer", + "tasks": 5, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 3 + }, + "ef": { + "deviations": 0, + "measurable": 5 + }, + "ls": { + "deviations": 0, + "measurable": 0 + }, + "lf": { + "deviations": 0, + "measurable": 0 + }, + "tf": { + "deviations": 0, + "measurable": 5 + }, + "ff": { + "deviations": 0, + "measurable": 0 + } + }, + "schemaFingerprint": "8c46c34ac267acaa" + }, + "1d7901d68cc7063b": { + "label": "P6-Viewer/XER Files/sample.xer", + "tasks": 11, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 11 + }, + "ef": { + "deviations": 0, + "measurable": 11 + }, + "ls": { + "deviations": 0, + "measurable": 11 + }, + "lf": { + "deviations": 0, + "measurable": 2 + }, + "tf": { + "deviations": 0, + "measurable": 11 + }, + "ff": { + "deviations": 0, + "measurable": 11 + } + }, + "schemaFingerprint": "84d6d8bd748871eb" + }, + "a2f3b2469e26f199": { + "label": "P6-Viewer/XER Files/TERMINAL BUILDING-AIRPORT.xer", + "tasks": 66, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 66 + }, + "ef": { + "deviations": 0, + "measurable": 66 + }, + "ls": { + "deviations": 0, + "measurable": 66 + }, + "lf": { + "deviations": 0, + "measurable": 66 + }, + "tf": { + "deviations": 0, + "measurable": 66 + }, + "ff": { + "deviations": 0, + "measurable": 66 + } + }, + "schemaFingerprint": "926a3b9c4a643cea" + }, + "467ab0eb0885c4a7": { + "label": "ProjectLens/docs/demo/northstar-baseline.xer", + "tasks": 10, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 10 + }, + "ef": { + "deviations": 0, + "measurable": 10 + }, + "ls": { + "deviations": 0, + "measurable": 0 + }, + "lf": { + "deviations": 0, + "measurable": 0 + }, + "tf": { + "deviations": 0, + "measurable": 10 + }, + "ff": { + "deviations": 0, + "measurable": 0 + } + }, + "schemaFingerprint": "a272dc5906bf3d5b" + }, + "a132f8c7ebb070d2": { + "label": "ProjectLens/docs/demo/northstar-current.xer", + "tasks": 23, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 23 + }, + "ef": { + "deviations": 0, + "measurable": 23 + }, + "ls": { + "deviations": 0, + "measurable": 0 + }, + "lf": { + "deviations": 0, + "measurable": 0 + }, + "tf": { + "deviations": 0, + "measurable": 23 + }, + "ff": { + "deviations": 0, + "measurable": 0 + } + }, + "schemaFingerprint": "a1160804bdaed88e" + }, + "ac4b9d677133c6ed": { + "label": "ProjectLens/docs/demo/northstar-previous.xer", + "tasks": 22, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 22 + }, + "ef": { + "deviations": 0, + "measurable": 22 + }, + "ls": { + "deviations": 0, + "measurable": 0 + }, + "lf": { + "deviations": 0, + "measurable": 0 + }, + "tf": { + "deviations": 0, + "measurable": 22 + }, + "ff": { + "deviations": 0, + "measurable": 0 + } + }, + "schemaFingerprint": "d48ece7ca76541f4" + }, + "790b90fda837bad3": { + "label": "ProjectLens/docs/demo/riverside-current.xer", + "tasks": 23, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 23 + }, + "ef": { + "deviations": 0, + "measurable": 23 + }, + "ls": { + "deviations": 0, + "measurable": 0 + }, + "lf": { + "deviations": 0, + "measurable": 0 + }, + "tf": { + "deviations": 0, + "measurable": 23 + }, + "ff": { + "deviations": 0, + "measurable": 0 + } + }, + "schemaFingerprint": "752e6b19354f9396" + }, + "78325fb6445d22f5": { + "label": "ProjectLens/docs/demo/riverside-previous.xer", + "tasks": 22, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 22 + }, + "ef": { + "deviations": 0, + "measurable": 22 + }, + "ls": { + "deviations": 0, + "measurable": 0 + }, + "lf": { + "deviations": 0, + "measurable": 0 + }, + "tf": { + "deviations": 0, + "measurable": 22 + }, + "ff": { + "deviations": 0, + "measurable": 0 + } + }, + "schemaFingerprint": "54e563aaa3c0212e" + } + } +} diff --git a/tests/planning/xer-fidelity-scanner-transition.json b/tests/planning/xer-fidelity-scanner-transition.json new file mode 100644 index 000000000..96c574c67 --- /dev/null +++ b/tests/planning/xer-fidelity-scanner-transition.json @@ -0,0 +1,94 @@ +{ + "version": 1, + "causeCommit": "1259787970eeff3118396534abd703e3debbe390", + "previousScannerCommit": "23953b063075eabfdb801dd431c9e7a9b24edd02", + "identityCoverageSha256": "aeb782e82349c313c5e148589d76f1f93c504836198f4fffb1a65d3c93ec1fde", + "scannerErrorsSha256": "210891c76446e067aafb4f5cd2033432061d9cb6448f7115c8f2676cce633d6f", + "missingStoredDateTransitions": { + "df264eb25ca8dc0e6e29d5ba5ba4a4e370f9126aecb99ab48e9fd4383f0287b7": { + "corpusOccurrences": 1, + "tasks": [ + { "projectId": "9009", "taskId": "9216", "oldActualDerived": { "es": "2025-12-15T08:00", "ef": "2025-12-30T17:00", "ls": "2025-12-15T08:00", "lf": "2025-12-30T17:00" }, "currentStoredMissing": ["es", "ef", "ls", "lf"] } + ] + }, + "2bc12241c3f8ee5b7472dd0e77f2cbffafcf3b5438b17022fd9db4f4c642d4b0": { + "corpusOccurrences": 1, + "tasks": [ + { "projectId": "10093", "taskId": "130421", "oldActualDerived": { "es": "2024-12-10T08:00", "ef": "2024-12-10T08:00", "ls": "2024-12-10T08:00", "lf": "2024-12-10T08:00" }, "currentStoredMissing": ["es", "ef", "ls", "lf"] }, + { "projectId": "10093", "taskId": "130422", "oldActualDerived": { "es": "2024-12-10T08:00", "ef": "2024-12-12T16:00", "ls": "2024-12-10T08:00", "lf": "2024-12-12T16:00" }, "currentStoredMissing": ["es", "ef", "ls", "lf"] }, + { "projectId": "10093", "taskId": "130423", "oldActualDerived": { "es": "2024-12-12T08:00", "ef": "2024-12-20T16:00", "ls": "2024-12-12T08:00", "lf": "2024-12-20T16:00" }, "currentStoredMissing": ["es", "ef", "ls", "lf"] } + ] + }, + "2a7732b5b99de2a5f40aab40e0af52757fbe68e5d4eda404a9a060fdc1be1140": { + "corpusOccurrences": 1, + "tasks": [ + { "projectId": "SP_PLAN", "taskId": "A1000", "oldActualDerived": { "es": "2026-01-05T00:00", "ef": "2026-01-05T00:00", "ls": "2026-01-05T00:00", "lf": "2026-01-05T00:00" }, "currentStoredMissing": ["es", "ef", "ls", "lf"] }, + { "projectId": "SP_PLAN", "taskId": "A2000", "oldActualDerived": { "es": "2026-01-19T00:00", "ef": "2026-01-19T00:00", "ls": "2026-01-19T00:00", "lf": "2026-01-19T00:00" }, "currentStoredMissing": ["es", "ef", "ls", "lf"] }, + { "projectId": "SP_PLAN", "taskId": "A2100", "oldActualDerived": { "es": "2026-01-16T00:00", "ef": "2026-01-16T00:00", "ls": "2026-01-16T00:00", "lf": "2026-01-16T00:00" }, "currentStoredMissing": ["es", "ef", "ls", "lf"] }, + { "projectId": "SP_PLAN", "taskId": "A2110", "oldActualDerived": { "es": "2026-01-30T00:00", "ef": "2026-01-30T00:00", "ls": "2026-01-30T00:00", "lf": "2026-01-30T00:00" }, "currentStoredMissing": ["es", "ef", "ls", "lf"] }, + { "projectId": "SP_PLAN", "taskId": "A2300", "oldActualDerived": { "es": "2026-02-09T00:00", "ef": "2026-02-13T00:00", "ls": "2026-02-09T00:00", "lf": "2026-02-13T00:00" }, "currentStoredMissing": ["es", "ef", "ls", "lf"] }, + { "projectId": "SP_PLAN", "taskId": "A3000", "oldActualDerived": { "es": "2026-01-05T00:00", "ef": "2026-01-09T00:00", "ls": "2026-01-05T00:00", "lf": "2026-01-09T00:00" }, "currentStoredMissing": ["es", "ef", "ls", "lf"] }, + { "projectId": "SP_PLAN", "taskId": "A3010", "oldActualDerived": { "es": "2026-01-19T00:00", "ef": "2026-01-31T00:00", "ls": "2026-01-19T00:00", "lf": "2026-01-31T00:00" }, "currentStoredMissing": ["es", "ef", "ls", "lf"] }, + { "projectId": "SP_PLAN", "taskId": "A3020", "oldActualDerived": { "es": "2026-01-12T00:00", "ef": "2026-01-19T00:00", "ls": "2026-01-12T00:00", "lf": "2026-01-19T00:00" }, "currentStoredMissing": ["es", "ef", "ls", "lf"] }, + { "projectId": "SP_PLAN", "taskId": "A3030", "oldActualDerived": { "es": "2026-02-02T00:00", "ef": "2026-02-11T00:00", "ls": "2026-02-02T00:00", "lf": "2026-02-11T00:00" }, "currentStoredMissing": ["es", "ef", "ls", "lf"] }, + { "projectId": "SP_PLAN", "taskId": "A4100", "oldActualDerived": { "es": "2026-01-26T00:00", "ef": "2026-02-13T00:00", "ls": "2026-01-26T00:00", "lf": "2026-02-13T00:00" }, "currentStoredMissing": ["es", "ef", "ls", "lf"] }, + { "projectId": "SP_PLAN", "taskId": "A4110", "oldActualDerived": { "es": "2026-02-02T00:00", "ef": "2026-02-13T00:00", "ls": "2026-02-02T00:00", "lf": "2026-02-13T00:00" }, "currentStoredMissing": ["es", "ef", "ls", "lf"] } + ] + }, + "2c1dce175b9f078111a48dc13fd1777f5fbd4cd7ab6623e647e7437330c60b7f": { + "corpusOccurrences": 1, + "tasks": [ + { "projectId": "761", "taskId": "84381", "oldActualDerived": { "es": "2006-11-07T08:00", "ef": "2006-11-07T08:00", "ls": "2006-11-07T08:00", "lf": "2006-11-07T08:00" }, "currentStoredMissing": ["es", "ef", "ls", "lf"] } + ] + }, + "a7fc367eab6c904e6659a8206b9a162df132824c630e6175665c8a63f054104b": { + "corpusOccurrences": 2, + "tasks": [ + { "projectId": "1", "taskId": "10", "oldActualDerived": { "ls": "2026-01-05T08:00", "lf": "2026-05-20T17:00" }, "currentStoredMissing": ["ls", "lf"] }, + { "projectId": "1", "taskId": "80", "oldActualDerived": { "lf": "2026-05-20T17:00" }, "currentStoredMissing": ["lf"] } + ] + }, + "a132f8c7ebb070d29936b8852c6dca772ebb547c0ade91c17c96425e37e63818": { + "corpusOccurrences": 1, + "tasks": [ + { "projectId": "1000", "taskId": "1100", "oldActualDerived": { "ls": "2026-01-05T00:00", "lf": "2026-01-05T00:00" }, "currentStoredMissing": ["ls", "lf"] }, + { "projectId": "1000", "taskId": "1101", "oldActualDerived": { "ls": "2026-01-06T00:00", "lf": "2026-02-09T00:00" }, "currentStoredMissing": ["ls", "lf"] } + ] + }, + "ac4b9d677133c6edcd6129050f32233e77c9d62ef06c7caac0dbf1da0d1e43ab": { + "corpusOccurrences": 1, + "tasks": [ + { "projectId": "1000", "taskId": "1100", "oldActualDerived": { "ls": "2026-01-05T00:00", "lf": "2026-01-05T00:00" }, "currentStoredMissing": ["ls", "lf"] } + ] + }, + "790b90fda837bad342658bf2c712cf6c39ab6adcd812e67edf93cda1c7eb362f": { + "corpusOccurrences": 1, + "tasks": [ + { "projectId": "1000", "taskId": "1100", "oldActualDerived": { "ls": "2026-01-05T00:00", "lf": "2026-01-05T00:00" }, "currentStoredMissing": ["ls", "lf"] }, + { "projectId": "1000", "taskId": "1101", "oldActualDerived": { "ls": "2026-01-06T00:00", "lf": "2026-02-09T00:00" }, "currentStoredMissing": ["ls", "lf"] } + ] + }, + "78325fb6445d22f5949bf05c1e0afe3e14b72efc9cc9480800eab4bf2b0a1056": { + "corpusOccurrences": 1, + "tasks": [ + { "projectId": "1000", "taskId": "1100", "oldActualDerived": { "ls": "2026-01-05T00:00", "lf": "2026-01-05T00:00" }, "currentStoredMissing": ["ls", "lf"] } + ] + } + }, + "oracleFingerprintTransitions": { + "146e4c8659bad066b298988613dec178ce63f3a2c35415937b3d70a8f8ef8158": { "old": "e6e701b2eb7cd2fd", "current": "789d55d2260a50b8" }, + "1ba8cf1885491db3c19ebffc85fbd33935345ef9effb386a8da33226e665f9ab": { "old": "f81e7c7d61b45f91", "current": "6431acce233a860e" }, + "1d7901d68cc7063b59dcedacc3eeae8d2c89178516421c09ae38ff523429e3af": { "old": "d14f605d6a854880", "current": "84d6d8bd748871eb" }, + "2bc12241c3f8ee5b7472dd0e77f2cbffafcf3b5438b17022fd9db4f4c642d4b0": { "old": "9415c811af9cd8cf", "current": "ec668c11ba3d5921" }, + "2c1dce175b9f078111a48dc13fd1777f5fbd4cd7ab6623e647e7437330c60b7f": { "old": "c6a7ada359dc738c", "current": "2493349dd14f7fb3" }, + "53f5cdbeb3fbd9c6cb252a48c2ad9206f1f2ab44215372f83199df3a90803ffd": { "old": "edb0bca25b371266", "current": "e5ee686e1f756d6f" }, + "55b7e4463dcd36ba02eae072445e8fc2cc684a188e40c588b05321963a362c5e": { "old": "3f976da19fabf0b7", "current": "96a23d026fc67de2" }, + "78325fb6445d22f5949bf05c1e0afe3e14b72efc9cc9480800eab4bf2b0a1056": { "old": "8cc9c1c456a80b52", "current": "54e563aaa3c0212e" }, + "790b90fda837bad342658bf2c712cf6c39ab6adcd812e67edf93cda1c7eb362f": { "old": "b8ea5c0682122f82", "current": "752e6b19354f9396" }, + "a132f8c7ebb070d29936b8852c6dca772ebb547c0ade91c17c96425e37e63818": { "old": "cf7f7bf2470c4e6e", "current": "a1160804bdaed88e" }, + "a2ef7b35c00d8cf8ba75230d71d69b96130f7f689495e86a4cf5f8fcd34431ae": { "old": "2168c6d533f62f7b", "current": "c860b3291dba9ee1" }, + "a7fc367eab6c904e6659a8206b9a162df132824c630e6175665c8a63f054104b": { "old": "3b0d135d5ce02f18", "current": "8c46c34ac267acaa" }, + "ac4b9d677133c6edcd6129050f32233e77c9d62ef06c7caac0dbf1da0d1e43ab": { "old": "b83f7fe67823738b", "current": "d48ece7ca76541f4" }, + "b9547eb91c30af1750933a64409d8b2a4a2c1dbe4c0be276a04ab150c8a50167": { "old": "380e546c56886af3", "current": "a81fedfd4aaa73d8" } + } +} diff --git a/tests/planning/xer-product-fidelity-baseline-v2.json b/tests/planning/xer-product-fidelity-baseline-v2.json new file mode 100644 index 000000000..3fd8861bc --- /dev/null +++ b/tests/planning/xer-product-fidelity-baseline-v2.json @@ -0,0 +1,27 @@ +{ + "version": 2, + "manifestSha256": "6defbc4b4a71500565e5847750662060d9baca983952098dd1b334ac81d55786", + "characterization": { + "finalZeroGate": "red", + "accepted": false, + "openCategories": [ + "strict-six-axis-deviations", + "strict-sameday-deviations", + "driving-path-report-only" + ] + }, + "reportModes": [ + "strict-minute-exact", + "historical-completed-late", + "source-day-precision" + ], + "encoding": "gzip-base64-json", + "canonicalization": { + "json": "JSON.stringify(value, null, 2) + LF", + "gzip": "gzip level 9, deterministic header" + }, + "payloadSha256": "ce8527748344de7f5bae3fc0c64041eff522cc939dcafa19d58c6df9c39233be", + "payloadGzipSha256": "e624e5d719c3bbb6bf02f2c69c74d07f988d39a0c9844fb492c4333e4a8cdcc2", + "projectProjectionSha256": "088f059adb65fcad8e392d0e2c9f0efc522314588e60ee6936b12cc4e9487676", + "payloadGzipBase64": "H4sIAAAAAAACA+2dS48kyZHf7/MpGn3mAG4PtwevC0rQAsISK50k7MH8RbZU7CG6i8SuFvzuQvRwZrqqMyMrXjmVxTzMYTqrPM0iosL852b2t//87t2793/tnz5/+OHj+9++w99M//+n+Phh9M+P/+OPgVne//bde2l9lMqFQyGnlCX3bKyakwgmSc1L1HAjz5jcWoNCxFENWs5q8v7LuvWP8SnqY//04f/F449f+J/fvXv37t378eFjPPyv/umH/xqPffrCT719+Z13795Hrf3Pj729/+27EQ+f+9//+Yc/94//FI/9Dz98+tA/v//tu//95d/fvXv/+fHTh/r4/ecP//59/PuHz9+3/tcPX77v89+X/Opn4k+9xX+c/JH26cNfP3z8w/d/jsc/fv+p//mHT4/f//Dx4T/ef/mBf/vu3bu/ffFqfHj48v1/dyUJwPCUObhkEeLUWiYwKC5NxJOO6cKMiNQHkOdeiax0H0bTj/y80mTlzzdg86q/eF7/2P8U/+XDxz/0T3/+9OHj47R85Da0Q8rDqEWSX378z59++D+9Pk4Ows//+Bif/+/0L5J+/qcPrX98/PD4H//0w1/7p/hD/8qN6Ut/ePhrb//zm1/7+1r/9EPrv//UP/cv1pz8+Hf/HvXHD//+2d+em/jfe3z+y6f+p/7x8evH4d1Xdvzyw/9tus7vwRK8/83XHz9++svjH0/ZOevERTdOOvLk4/rDXz4+9k+fn1y3L5/0b/9t+te/L8NPlvnRzh8f6ve/ffftZ+3DGNPf+bef/OnD588fPv7h5G/96culjfLQvzH8x1V/+fOZ1n7y8d9+88ydMecO2Yw7cNYdONAdnHXnYfbuZFlzd/g4b3jemfGGnHlc/6Cd/7vBX+1BG/Pu+Cp3jvy7gafufHfGtZ/i7O/j8Y8nXn4/PXv07HKc9e4n3/TZP591bN6tp07pWS9+in+/+/Tphy9v8f/9b785EXc+/PDxl11V1gjQ1PowrlkMbIgwBnbyhDByNRAjcPdoCD6yeemOQAk6eHr/3fOr+2/Pg+LvT3wt5QgWIaOaO+ak0jCVUUDUGxe2QSlxltFgCJTUlMFax1wThjn9sjk4GbS+DVhngtW5e3g6SJ27gXO372xg+urefRuQzrwjzgWi039MO5iLJ819OHt1n76fL11d3tdaPm3suCFjH5c/CKefW7zKgzDOm+uLzN37uYVvXkt/ew5V37zsT77oT1t+4gV/2ujzJp9+qf/tG5g58TJ//7nGx4/906mP/hCP/ffx+fMvrPrdV+u+TyMyKILgKE3M8zBLjC2hatjE11K9GpZopZboRNSIkVrDZNXkDBluXXWeDKEAiUoRaxEA/YVkCLyKDIFnyfDkxz8BFfCeZDiDhcAzWPjsw3kfTnqxCxauosINUHhhM7gFCW/KlYe3c1ce3s5dmUdB4Cv7cuILFzgzbtqZbRj4/MsuY2Bah4HffNFZn7ZjoNZmxoOgNy7htVpGI0MeQyRGLVaUVSu1kZjKSClzG0C1uNcRfSUGFqJSR4pcU6hTN5JeEQBVrQ3VqE2kOoH30SUNMImWZfQGbdSvz4iXYuAiClwBgTOb06UI+OpMfbidq/pwO1f1PPo93+ztbuuzL3iBsePVG7sc+57s7GexLy3DvicLn7R3X+x7/PSXp9QHLJ2rSfYSLYkUdHMzAWq9glrtQoMCK2WG7KSFmqawYX0YZDtNfZtXnac+NW85N0RJkVOxF1IfrcsH0nw+kObygbRnPvD3//ov//x9ms0J0lxOkC7lBGk+J0j75ATx2oka2pSomcc/pDXO6HHO6AYAXLc7BznOG5ANEAir7g0ceHNAt4Dgqpwg4IHubMkJnkhWvsAdO84b2xUFcSkKPr+9L2VBmk0Jgu0Kg2NEstKokUQQU8s2KKNAZsFCxb0DYYmSEZMAGhdFFydHTCVobU4QGHv00aP0MqKweBjI8NS6qufMQt0yFtUxLEQCakmUSgANZV0Pg3h0IogWJ4LO4+DTYHTJWN3XWF0IhMto4GmQ2W7tV4HlZVAIi64t7HxxQZeC4aKcIODO5i7NCT5LSl4w1/a11jahIb4UDb++JS9hQzqbEvz5pX50ThBKiDd07d0jV8lVmLuSUqsMHEHE1V2KsqVUWcyih3YSg97LOEOHW1edp8OSVMSkjAwldx0n6RC/oUNbBYc2y4Y2g4a2Zz5wngrnMoKXEoLz+cB7OvCeDrynA6+aDlzjCq135UL+jLYQ4Kqi0MNcwT35bzH+4Tr6m00E4q7s58OSFGiotfUAzW2IWjZNOUXONSJXb5W7YY0AB60sEiWpY3fk96f2hOdjmrvbPabdY9o9pt1j2u3EtGvXt1yzvOUNxjTrtXl241HNGqNpqxIuHEO6IdvoGQdAH8wAUXBA15R8FLNOBGuLW1qpNJJTatqTjZFbdUkJsfZqKSdJSlkGOZehIM3YqBh5a07i9V7cci9uefvFLYvOBFecD9t5U2Xh+eWiLAHuaipuOb18cV0LLzu7tHNHl3y9k0urA8wyO7RCFbyXMarlURqRUybOfXgfo5BJWAsiROkieXiUsyeX21adP7kUJpj0BJAoTNJLuxlwXV0Lzte14FxdC6YrdTPgXEELXipowfmCFtypoOXaW3E8EPZOlD+8pMX9OGdoS0HLqr5jOM4Z2FLOktfcGT7OmbwF+a5dBIabisDG+r+ZIwB24d/MNuxLV2pqSNfraWhaulYsWBpjaU4tt6JKpVbJo/fe1LsENWbRoTxqVkVBEy1cwlZin4zGyQuEGLiZttbrmBrntbRi6s6dSoFBAglzGklrOLUChJYlbyljOXrbjzuC37PiiUuN7fsaS0vLWBZ1M8O+xsLSIpa85MryvsbmpSUsR5de4eLSq7H8mT1pLB32zC5HwHRQa0O6bmfDCQRs6gmaWK2ahEr2VnuLWqn36NawmoNaBmGEmjw6TSdxSIzeKc4Vr2xddR4BjZs0K03ZTKGXlza0w7qGdphvaIe5hna4VkM7zDW0w6WGdphvaId9EHDNBtA3tLXC/AbQr53uAzjOGYBrZ/x+TW/2vzdb4HxRycQyAkxXZvOLN+baaT89zhe9A+AsAMYYTWu3mqJWiDSQai0FU2Oq3QG4WGGlMRJizkg5ec6UTTAjtbUAyJwzihcVqdiBK1Bi6wrFuVqfBG/6iOTYWmdK3gAKYhShxo5hGwBwyfbUV7TewvntqR+Z+APY11iAI3N/17J2n2u7Bq1flKh6Gf+lg8l69sIu5D8+uPNmzla9498J/MNSAZGh0rDe8yRSgq2lrjqwljFi1EElM1kBTYijeSs8uApj45JO49/mVefxr1cRqwAlqGVHOI1/3zYvgPg6ABSfJ8CTn/8MT+L7NjH4rQubwbVrmoG37NAvpAJvy5mHt3RnHt7SnXk8wJktmmB4oMDZK3fmLnD2LZRpy6jJc/eaBIk6xKi9j2bdNY2uWjOHQW+DlQJy4y7JvUTqrcDCvoYpzNGthzm49lMOB1a83JYzF8LcbZ2qPbylI8J/oPPO1+7MxjC3eJwDHBHmYN8jz6QphXVJkWpiYoTWS7FJrDOSMo5ahzYwk+jGRQxZuIwxsWhSWxHm5MbDHF5bcOnSNhu24NytefNwwBaEjvOGtgS623LmcbydXPh8nPNrczZcsUcdFif2+Igwx7uGORoAkoMhmLTkFG20qSxHtHFr5h4ViiByxTYskQiIg1BqkriSLu1ST+hzUS7PRbl8Kcrl+SiX94G5fG3+yQfC3G058/CW7szDW7ozj2/JmXHTzmyMcvlaZ5b5emeWoxcqg4aIgbTO2nrmij2nSVAblVtTHZyxAVkdmLMOKJGiAFjqaXGUo3TrR5a3NUalv6WZMA9v6c48vKU7cx89dB899Hozc61RJUlFypAY3pPnUYlrbxxYRFq3KR+njDZ6+qLPklxK9B4uNlZEObhHuXuUu0e5e5S7R7nXGOVsaZCTI4Kc7CurCawVKSEwsZp2DnEqtfdE3SyPlDrWRK3p6AU9TZWnNpJ2FQCy5UEO72m5e1runpa7p+Xuabl7Wu5KablBPVGNPEpFsgyjD0GvUYrj0NRBQ2tYkzLaFKJqRg4Kqk2K9QrLo9zN11imKyskXXr5bBmTd2POzMc4ufLgsku+2IYQd1u+PL6pndS46R6Pe5D7tvbEs4gZVUcrXLqBZ2kp0sgAwI1gpNzUmnT2QCSFPEDRRrVKIYuDHN+zcvfzyvt55f288n5eeT+vvNJ5JTdO09iETBVzyVWNDVFkqMvwzsVQvXV2a1xEq6VWwZlguE9T4ZcHuXxvCn93bwpf58y9KfzeFH5vCr+XniyNctqdk3PlnoxHUmBIbUxaLkUNnHrqRVQKlBakZXDvwzhpL5KdMZZHOb1HuXuUu0e5e5S7R7l7lLtSlKOBMCIi9R5tFFMZ2lBNKkSNBClNOpkOlYM7QpepRxw7DPXBJMsLLNnvUe4e5e5R7h7l7lHuVUa5t5iWQyNxTjGKQMhwyUGjQkbJCaqxc4Q37SSaKYhVu6Km2im65e4rxZ6TIhhiycBRBtWWElhLOGA0gu6KpfeUWo6oo04hNRcGL6kHIPOGaT9Ai6RzacXsTBCZmUciSyf+vE6DZ6b+MC0SUqadDUZaOvnndRo8o/6MsOiRWKP/nGfUqmmpAjRwPloDes7eTSLQwC+WgUZfpgP9VEH46TPh15KCrtBqB83FR1IDgGBrFWg0UNUxTW/l2jSKCFIX1q5MSpSqpKJnJgFtXnVeChrZidhbAx46Cr1wEpC46iop6K9/8SRgnfmBnwBr+nhHNWiVuV68p7Zc8OUF3pzxZxdklESrquycN+waxVUu1F/zllqXG3VqHiE55VVOoeOhXk3rb4DJm3VrHispp1VVxcQnDNvRrWn9DYApmtbBv/KhN0t5T9AUc1iKmrayOub5m/ybmuN9C2RQW+m5d+NMJWuAe+HRakxz4i3XhBpdLGlvHcm5Sa7cVYQa9uxrcTPnxkhhwVrFbMjABoYDG5aCFcaoOsJEeWDqCmQAPTM3cs8VYz1uPo8FF8cL8YoBs8+f0PPv/Zch5ys2+jx2Pn+TX8S4Z2/wPax+8tZ+GXy+arPPI+jzAHORQZ8Flj3MfhJMXgaizwPIxautvPvF/ipYrMDRp8Fhnke/Dgov4dFnFHM6EBwNpCwaJfWSzHLlUBsKCaK0DFgGccjo0i2rpsLeoPRuOQtRcONQOg2km1edB9JAxVazJymDWn7xaNq0bjJRmh9MlObmEqV9xxLNdlmkuaRlupS0TPNJy7QLga4Ta9+Ss0gXGqO2oOeteXPAfNoNzqTjuixuypXHN/WMjRu+MffhtM87LBBy69EpwiNVQEMcaYRxKZ2ltFyZGzpZraBchIcX+zI4tqrUtQCJFQp1KqbJsnQYzSKG9WiZfLg2jyiuWTR6bp3MgKpg6ckkF67rAXLZoM8VaPDsd879ZR0xnvY61u40nnaFsTO2LsXEV2fq4009A+OVX9j7bNpf+M+jh2SrLhQpZe6uChIhyUYhULcBAlIlkVcIUu+Spn5x7SKpn+G/ravO8x8nYfCB7gqRqb6U/3AlAOIFAsRZBMSdGXBWGRRnIRAvUiBewEDchwN5VbPtlmwJ4IW94IVUyTwJ0ip9LvMD/THfkn+kVfdHj7w/ugEIb+/+HCEwA3agPwB2B8N/FDDEjghTTdAQqWRDQ9mcsDnXNjqbNWsliwthb5W8UjYrWXLR1CJWgmGYJbEWltUta7GpzR+rcOgwzlNLiDrrMGhcAyNYk1ceEZCkWF4PhsyLSurWlIXizOZVl9ax0qKaxacvuh3s/erl9sJ84qKiUNW9r68uJMTXeX3PY+KiPNyzQLGDvV8Hhzsp3iYpNiu1q6dwKl6qskbpNTd349Sppwa154zUU4zoVMIMVLCOjhXOZQq3rjpPipZGy1wQBsaoKi8kRcyrQBHzLCee/PgnssK8IyWS2Awk4twoQLw0ChDnRwHiPqMA8dqTwPDAUYA35szDW7ozD2/pzjy+JWfGTTuzDQnxWqMA8YqjAAsJYcuFckLMqXWzkWMaFDFyH9qSNfca1J0Uhg2axGpKcmVs4MwroRA49z66YUwiOWNqocRSIkagjg65FioOVYilpYHANihA0jBm8NTWQyHmgzepz77gBYmi80T4Co19uKUr+3BLV/bxlowdr97Y5Rj4ZK+/Jwc+WfgKIPj46S9POTDTyLWVXmiU5lVqwYzBVjGaY5IxsVbhqXSEFIcRuLdB4ckSjdFOc+DmVec5cOqCEJtKSTRLk/FCDvzllGcRB359OHSCoE5+/LufD5Z25MDf/+u//PP38xnD5wdZZx256MpJZ35FGNyQLKQLU1a2pArx2uNvaNP4mwswSFee2nDJmS0TKHBdrSUc5w3AFhy0K8+MuuQNbcBBWndvDrw1u4rdLO5AxJU8+NytZ90yO0/N7RLsWYB6qUzMA6fzW7EYWZIF8chtOGoQ1JJAbJh75jqyCVpeCYQayCE1yZT2q2JZQjCBtFGIBBkwRYps2iBBRxvZeYypJGn02kOvBYQrkoTPAuqZoPRCIFyUE/J9jV2aH8RF+UHb11hbCoTLijJhX2sBliKhLeot3NdaWoiEy/SZ9r60m6RtXtxJiAuZ8AlQnH6hH50dzLloZxZqtZGUSNijJ0Xm3G1UrFWMA8w6pzqVgqRMCFN5KAnWfKaOdPOq81ToEkgtoYwq2jq+kArzOirM81SY56gw70mFqnNAmOeAMF8CwjwPhHkfIOS5sZt4QHIgw3Hap7POwAFb223OzANhvvY+PS/bpy+Tq7mtOzOPg3xtHMwH4uBrd2YbDma4UnYwwxVLRitbkuypwSgMhK7aJAY6NRHxlCuYjz5aK7WTW02pOo4v4d7E1sKg966tTGJ4lIxSjOrMyWVQK4xcGNgCQdlhpNEKdobi3oOzFNoAg0/fHucC1IbkRZ7ZqeJCGDxrLOy0rV5u7HkYzEczQH4ZA7xMcub1XdnzKMhHo2DeEQVfi7HLUTDDQdnBDL92mWgWq+CkuXBPTZSr5i7RIyFVN1WcTuG6mIvmPEykszBEFnCllM6A4NZV50FQnS1KShCBLfylCqe2igNtFgNthgJtz0bCGQS0GQK0CwBos/xnu+CfXbk0zI4rDb0pV+bRT69MfnYc+N2UK/PYJ1ce32FbCHbc8N/KlXsEbR3x2az86K7AF8IFE1lU6VJQFFvBJI3TyA7Ra4GcSKfc3OjsI8S69toLJ+k9w0rgK+7DI08yckyj96kWVSMatiZhJaWkzUvzQRitWco5NxfObh0HywbxGDu4WM32KwZ9daaehz09mPVsP9R7daaeB71lKqi7mooLMe91PKsHdgLaMsazs6Kh10K8ptBjUopOJTXPJLllIW2pq5uymAwlwgb9S1+fVzRARkltmtanZxBv66rziFd66yUaRzZRKumuGXNrmjHZV1WBwoGaFwJb8n6rKEPpSI0V2pL6W7U/Fzzy/myZfMir0GnL7IPL94e3TKlYV92qh2rG6F0zZh8efP2aMTZptTAOQWvhbYRIzpP6N1rq1jgFuWIUTFW8NZTmIxkMqcWqrRYTTSMQK5ahRcitcBQo2BM6UIPRvYlox2EYLqmFlg5jhKdkvUr39TyYfVE1KOysuSGwNAe4iF+U9tZgoaVpwEVkILj39cWlmcBF0LVmFsL89eWlUyeWVbHq7poxeteM2ZQN/NU1Y8RqzyOVgiUTt1wCHDtFM2RImJtydM7dGZMPACVojI1wgA3DcpoUN696oSpUR3gPGJmohryUFH8ZibmsKhTnq0JxrioUd6REzmlONObZxM/zTlx046Qj+xDitSdiZzwuM3hjzjy8pTvzcEDxIR/nzBYqvLE7M27amW1MyLgUCmFtXSjOYSHsy4WBuZB2zGBFi7ND8q7gna3yqF41IgdAZMMchclKwpxJTEtGX6slOjV/9J6EI5QmzbmYToh794zFCrZk2cBGazCJlisWYoc2oWTPbtY3cCEevE+dm869NFP4Co19uKUr+7BT+SLva+xSFnyFV3a8emOXk+DXr/h5FISllaF4DgbhWjSoRphHEebcEKeuay9jKvufpD07deCi2Kc3fnW2ZCn1KFwGlhSQvry1T9Dg5lXnaTBzz0IRQTUhYH+pgug6GsR5GsQ5GkTcN2c4N2sQ52gQL9EgztMg7kODc3tBwAO0SfDCZhC34CCs8sYP9Abuswbf2KzBVUMyEH61Z+yeHnxD6UHqyYy7RmNk64quQ8immfXJRyOb5vylbJIkQVgUtpoyAHoChlJWYmATLxDdSxrF3anTNIE4Q4XB7tBbUG6D0jCqMG0ewFumGo3YrdUN5aLnNqmAO0ma4MwuFZdyICyy1ne2Fu6zBl/BrMFFMy8QrvIM3LOBN5MNVE/F02hhpGV6gaJkKwOrAtYhlTyKRKvNALtob8OptoCqvZDgOMN/W1e90BqYsUsBp8zDyV88QYLW8R/N8x/N8R9dkf9ojv/oEv/RPP/RLvy3qt4NtyjI04H8h1ffm9Od/+7894JnDA98xvDOf/8o/BfSkBw0sBetOsgCOWeWLh7Vy5hqOBty8lpLk1ondc8ubhS1AKa1/JemrsPelbRACxYvvaC22gSz8OigXiDVllMb6l4w8tCRylSBBF/NWVrMf4vK63CNuD3tyH94+N6f7vz3pvgPd34G8M5/t85/Q0ZhKoWmgzWZijOFu7SaG1kj9CLELFqqhJYktQxqFpShWlI7x38bV53nv9GyZqqpFqmskF9aDbquGHS+FnSuFPRK0jBzowMvTQ6cHxyYfz3o27Dpo+MKQFftX/OGWrZ5V/Id9+64t62MdUsV65313hDrCWIbjShXqDWFj8Y5DZOSR6QKCRq10RR1BJDgNBpCpY+EJXVm1pWsN4DBBW2k0kZu5DCCm8BIoTS1JfY8fEQCqIlTip5zHc0yOI5orFdivRV7Udqv4HPRtjmvqJ07b2q+U96tUR7v+gDwHfFuG/EMpCdQssEuaopuqXYavaUvx3dWkw81cC3aOpTeI9dw68k5epwZA7F51XnEA63mralWUC0UL0Q8WTcGQubHQMjcGAjZcwwEWMIZ0pO5ORByaQ6EzM+BkH3mQAhced8ncBzv3ZgzD2/pzjy8pTvz+JacGTftzDbyk2vNgZArzoFw4mDMWRK1IqgBkILGSGXIwEwpe+2lM6KKcmaEgWKaayYQ0bqS/fJQcZcJL5unSRCgew8RReyRnFKOAJHpa1Lj5J6LjyydWqnCdUO7n8DBu1SB/ejvFRr7cEtX9uGWruzjLRk7Xr2xyzlQjpoDIfArT4n37KlybZX7CPFAgiISNScXgAY6CCTl7NN4niitJwxwHblbgS5nQHDzqvMgKKLT1J/KkiOJv3RKvK+r9fT5Wk+fq/V0ulK+z+cKPf1SoafPF3r6PoWeplceD+903Hh4v3YG0+k4Clw3617hOG90y1gI5FUPmh/4pPkGErypVOY/bv7v+fPzguHwvA4Dnz9tzwqweN9+P2naQqrRNKeJ00hCaAothvbq2YBb4RpQW2FC6FJBO4bZiK62Vg7UR0CNQmJBMimOZm21emujZyCeikBr54a5lKKCvRBg0w7MjTSgbBgPoQcPh3fabzi8H52xdNqPA5dNslfY11pdOibi6Z/nxQfBd34SfCELvrrU5dvLB359jy+MhudlIPiEIk6/zo/OCbqoZ/c2HJKVRoWga85T/SVkpgYOuUzqy8CcgwaUVLx6TOrMJuMcCm5d9ULZJ/Q8WF2UO9XOJ1EQv0FBRpJVMPj1L55EqTM/8LufhYNIdkRCFMlzIyN8bmKEXxoY4fPzInwXKryp3W1/Oxv1eyXo7VWC3knwbVaCgq8DwW9+76lgnO8KgsWpeuaSs5cO0twjUQss4qME0EBhEGRrDq7KxZIzcm1diDH396c2ibOhTWZCG+MzIfvncfrZx6ci9Zkf+SVWP/uBtSHu+UJXeNxPfuVugY4R8pXnlV52CDeEOwafcwiOaNx/gUebxiKBrdOiQz7SJ5IN8Y8R5Mojfy97tEX7jFxXBUJkPfTBY91VEBueS7FfDomyMiR+69zMSfz2oJhKSFIm0DZytk4jEapGrxEJE6pY+7GRMUWlylPzfKpamRE809rhuZAkFfdUhnIrNDixQ4aWfTqcpSHKdUQbZsHhZg1LUMPaMufaxoZhSd/G3d1Pb775ik2VMs9j1QFDVOcNxoWnpM9j0bk4tKWJ/4LFiycmPYs1F3XnkPe2mWThcenzWHLAHOB5i5fqpD2PFRcfZNbdH4yv4sIa1ewnceDCsN2F03afnbs9Hbh7rYm7rilPMwhSaBqC5F4dhw7jMQoHenGSEsNznaYaEHmnWjkQcRBYPXOEunXV+SPUVrOgAsMY5C3ghdU0tur81GYPT23m5NSuVEljM1RpF5DSZnnSDpfLPqKIxo6roVlVFK6HuaIbAPK2XNn/ZM4Oc8U28OJN/a3Mk6JemXttEfRu48PF7RO6jg1tDgx13yG6mZqMalOnhNqgVLAUEeuQHFzAhSEjBBTknrhmZ8jDmg5TGbxWJZu8eCm5Ue+9eYmoyVpjz71F8pE0vGkeWpPEKJkqZ7XO7qNDYYn9VbL3Kpqx/WpmFpWh666m6kIWfH2m7lPTYbuaagvR79U9q+ehTw9GVHsRny5HvRc3TOgyzLNzjKdXQrwAwmGTKHVJmhr6xF5mGau0GqrYS8msNUXrDhW0ukzZL9I+TUk4I462edV5xAuYhuMlLi16ezIT7y6O/QIn7uLYd3Hse43MjdTI3MWx74Jpd3Hsuzj2XRz7Lpt2F8e+K6ftzX/YhxbKNaVmdVgJzUipKUwT6lyA0tAh5tOhm0lwHXnYqI2YCc4pp21edZ7/qkkqhA6thPf+0hQfwMqW+a9/8XRrAcy2zU8f78mBxHOlpE+NueDMC9w549AuPOi+jqHSBoh6bvypQAxbWul9XQ1comN9og1s6HkuYZPtbMZG7VCnLpRfzlPivFN+Hq701/Tp8YKOg695+HyLS4lk3iXXDcgIKa06NSI60iWiXTOG3zwVh9Hjia86ECDNIGtISLf2pY6zU5hnELeUgnrNDVqHPrh61ig1F0gqvbReB+HavGEqFalqrbVC6SP3UWCESec+zc8ll+iZcu5Nm0MqkFy6RDNP3XveAJDPAtplhFxBZd9E37PB64VN976sEO/p3+M+NtNCmHz27j4bjM4Eol2MPlOjeR4rzxvtp2lNr2Xz44wigy95OHyNyc/etmeDx8sY83nAuGQy0d4mfxUc1uQan8LEnrj5bOlfiTgHFWTxjjLA3XJUS6Am1VNvo7jk0FLBOaRn1cQRxrnmYQIwzhHnxlXnidNRgopXDmGq/cVa3eu68mW+J1/mOvJlz358QpirLRWZ0+qWS1rdMq/VLftodcu1pXrlQK3u23Lm4S3dmYe3dGce35Iz46ad2UaOtLjYlPJKsW6Zw0bK+3IjRMUuAaJAvU4TFimppAzJWo80OnJ3JiytUfXBCGVQydE7lcK0khsrJ4SEtdqkCwBJMod35dwTtsRRrYQ1TVFDogAAOLaYhMOJB2vboNYtR4sJy45q3a/P2IdburIPt3RlH2/J2PHqjV3Og/Ti4tOvX+0vkus+22JI+Tp63aGjkmiPMnEad5HsYZikeIBgG0BoyFUodQHN06QEC6GRMkPicgYGt646D4NWWSpxVBSNGnGfzXsWAl/DbF68cqsRbdHXOKDglA4bnUr3atM3Vm1KV5ZByls0kO6lpm+o1NTZaZp53zUR5+y9WA1xbr2mnrGN2lOOTMMRE1mhSCVZyblRTTLWKs9QWDBYTYC5qeSczTrHEGF386xlalVpXYpVrFyxFC+Zk5CERStX6jCEXWfzwpF1prTraFa6F5m+giJTOlhxKC+VG7pXmN5OhWnl4k1UgahKn4r4AT3lNAiRqKtWb4J9JKlapxbvVga0SA06U5xDvK2rXhCRYeu1aw2VzDD4pR2GuK7DEOc7DHGuwxCv2GGIcx2GeKnDEOc7DPFw2jsvErmhSvFEr9V+HYbrJC/9QG/uHYb3DsOtXazbnrE79r0h7KOezLhrNEa2rug6JgkA95J8NLJOZimbJEkQFoWtpgxTrAeGsrZAdGpDgehe0ijuTp0ah05DPAb7lG0Mym1QGkYVLKUO3jLVaFP5aKt1f+w7LSy5omjxWRzd1mG4TAbTd7b23mF4ex2GcJVn4M5/t8N/zbH2jCU8F9RMHlxTiVSZO4YUzblXrJArJZGUXJyrMiVjEeMz/Ld11Xn+K5MCdSTqkgWj0Ev57xfd4GUA+LXe8Cl0Ovn5z+RksC8DzmX78Lk08nlHLrty2pl9ONDgyjvBU9+4W+Xnzbnz8LbuzsPbujuPb8udcePubMPCb77tMDD89psORMMqUuswDZpkAnTqDgTtltRHz6VaG5AgJ282mjJgqVxzSlgVOUtdnRF0a0LRo44JS4E1irplwk4mPky8a52G9EYHEM4lasbWrBSnjLIBDQ0O3rw+/4ZNVaCv0tyH27q6D7d1dR9vy9xxA+YuJ8WnMLAnKz5d+Qq0+E09aNE+pE3Ne6O3FhDWKFTJITtm00Y1KJkloGIuLdFogF6QmpHRGVjcvOo8LPYxKKhjybmMAvpCWPxl/MkiVvx6asoJvjr58U9wpelKNaHPR7uc9eCiDye9OHxSrx6xK9QLuQI9aljva/Tm4U3dm4c3dW/m6VCvnWLTA+f2vnZnrpww1JVgqLMpQ90XDEejoOiELdWM2r1EIaXSRjYpTqUgUItmo8ewMsYoOJ39Nuqpj7ZWlTRlA2kWOJWGDrBJD5WpO0IrreVGDZuYcYKpdBXEEYGipYxaLdUNYHgup6F7bVZ1JquhS7nwNVr7cFPX9uGmru15KNSjk3Ga9kPC12LsgalDXciDejZ5qNfKHhbPrL04VEoxQHNyohDm6dyvYHBghVb6lPzrqBKJo0BO1SInED0DhFtXvTCfwmD0NhpHhFKzl+qT0rrsIdB89vD05z9redKe2UPmZHPapDSXPXz+6QVXTjuzjy7pqnnwtGHWGhBsmjR+QZN0XeegH+mPb9EjvT1/5u/PKpESykf6kzcw4rqMG+EWYdULfbeEm0RI13URw4EOwb5DC5/OsD5wbOE3T96hgwsdBNGn5hDBrArqjUZyNWcZItQ1CIDRWFLjLIxldG8kjr2ZrK0vVUuQq4OkYpyn8lGBVqNWryNYiiQfklM0FCgaCVIa0jJpcshtQ32pL5oOTyvGwT2PsS+ZWz4jPrqsudD3tteXCo++TnvPX99FuidPI8oe9uaFzLgsMUe4RiF1pjWWcLHa6LJGXtjZYNg21/Drt/6ukw2fQsevMtuwmmL1ronVtRk6pjyoZjbpWrm2HL0qOjBN4i9lGlSRerVuJhx0Znz95lXn2TGpiOEwd6l9qNzH15/Fxfv4+l3H19+UK/8w4+tvypV5KOQ1rvBhrvAWUdEr6+TYIp2cbSS4mAP5gPH1vCsFdg5t2Ibl0iu60CCXkioTgBSD2hg6d+vgRTEaD6IBqTEWJaK1paRWB2e03hBkFO6qptWpMA5IuTTAyq1191Jr5VETSahUyz6cRf0fYnz9qzP1TYyvf3Wmnic+XmIq72oqLxURPVgHx16kg7Mc814MebzT+Hq+EuK1HqWqEGuPPiAr6mBKnqdecSietbUcJWGGVjlPjdy5GmhYT5TKmfH1m1edR7wsoxXUmhKEm+JLmwt5XW8hz7cW8lxnIV9RXIbn+gr5Ulshz3cV8j60R1fWEkU+UEz0tpx5eEt35uEt3ZkLzYR87eY7PrCVMF2ZYS85w7s2EvK1+gj5igozrsW6pl5slNKpFtfkZeQYiaeQnhLnHo209TxJfZaiyRGNrUPE2lESObqJpdaTtjRtE5xrS5YhDxVAGh5TtWhTZKowqk1Cp96LZp9Kh7YIi9LByqLPguk2adHXZ+zDLV3Zh1u6sjPtg3x0Ox7v2DyYDibWOWN5U+sgH9U5yL+2zEzrg80LhiWK4cNpGjTPxVK30bxgqwZMbaBNVRdf+v2aQ+MmtUWlcyS4cdV5Ehzxo2pN69By/nru/X2SxCucJAFXPvWnLer4fX+ZxHyY0H++q4reVUU3gVI+Ltl3lxS9KUnRHil7d8vMtTNmTeqpUAwdImUa81BSp+rFpzA9Ru25Cxt1qGCwWjcmxEYNHFomiXHt4FYHcc811z6mfhPEL3LiHUttQ9AosAoBWQ1vaT3wwcFZCVqqzt/3UWfMuw4SyHcx0VsTE+VdHwC+K4luYrxfT0n0u+m/v333/wFPJ/ixDTkCAA==" +} diff --git a/tests/planning/xer-product-fidelity-baseline.json b/tests/planning/xer-product-fidelity-baseline.json new file mode 100644 index 000000000..aadf5d6c6 --- /dev/null +++ b/tests/planning/xer-product-fidelity-baseline.json @@ -0,0 +1,142 @@ +{ + "version": 1, + "cellTransitions": { + "version": 1, + "files": { + "568c19375b4e0d674c75e6aea023c98772fb33e6896755f866e4641a56197300": { + "previouslyExact": [ + ["1", "1000", "ef"], + ["1", "1000", "es"], + ["1", "1000", "ff"], + ["1", "1000", "tf"], + ["1", "1010", "ef"], + ["1", "1010", "es"], + ["1", "1010", "ff"], + ["1", "1010", "lf"], + ["1", "1010", "ls"], + ["1", "1020", "ef"], + ["1", "1020", "es"], + ["1", "1020", "ff"], + ["1", "1020", "lf"], + ["1", "1020", "ls"], + ["1", "2000", "ef"], + ["1", "2000", "es"], + ["1", "2000", "ff"], + ["1", "2000", "lf"], + ["1", "2000", "ls"], + ["1", "2000", "tf"], + ["1", "2010", "ef"], + ["1", "2010", "es"], + ["1", "2010", "ff"], + ["1", "2010", "lf"], + ["1", "2010", "ls"], + ["1", "2010", "tf"], + ["1", "2020", "ef"], + ["1", "2020", "es"], + ["1", "2020", "ff"], + ["1", "2020", "lf"], + ["1", "2020", "ls"], + ["1", "2020", "tf"], + ["1", "3000", "ef"], + ["1", "3000", "es"], + ["1", "3000", "ff"], + ["1", "3000", "lf"], + ["1", "3000", "ls"], + ["1", "3000", "tf"], + ["1", "3010", "ef"], + ["1", "3010", "es"], + ["1", "3010", "ff"], + ["1", "3010", "lf"], + ["1", "3010", "ls"], + ["1", "3010", "tf"] + ], + "improvements": [] + }, + "c872c9e704797d829205f3c5486e7c4cd5aec729143a63fbcf8b0ec8e8864a3c": { + "previouslyExact": [ + ["1", "1000", "ff"], + ["1", "1010", "ff"], + ["1", "1099", "ef"], + ["1", "1099", "es"], + ["1", "2010", "ef"], + ["1", "2010", "es"], + ["1", "2010", "ff"], + ["1", "2010", "lf"], + ["1", "2010", "ls"], + ["1", "2010", "tf"], + ["1", "2020", "ff"], + ["1", "2020", "tf"], + ["1", "3000", "ff"], + ["1", "3000", "tf"], + ["1", "3010", "ff"], + ["1", "3010", "tf"] + ], + "improvements": [] + } + } + }, + "files": { + "crawl-xer/p6diff-baseline.xer": { + "tasks": 8, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 8 + }, + "ef": { + "deviations": 0, + "measurable": 8 + }, + "ls": { + "deviations": 1, + "measurable": 8 + }, + "lf": { + "deviations": 1, + "measurable": 8 + }, + "tf": { + "deviations": 2, + "measurable": 8 + }, + "ff": { + "deviations": 0, + "measurable": 8 + } + }, + "reason": "Herbasis 2026-09-07 (etappe X12, na 7a/7b): de v1-harness gaf tot baan 7a progressMode/schedulingOptions niet aan de solver door, dus élke brongebonden P6-optie was hier inert; sinds die correctie meet v1 dezelfde solve als X12. Op deze twee bestanden is de PRODUCTsolve tussen de v2-baseline en nu byte-identiek (X12 per bestand/as gemeten); deze tellers zijn de harnascorrectie, geen productbeweging. Opgeslagen P6-uitvoer blijft verboden solverinvoer." + }, + "crawl-xer-extra/p6difftool/sample-target.xer": { + "tasks": 8, + "projects": 1, + "counters": { + "es": { + "deviations": 6, + "measurable": 8 + }, + "ef": { + "deviations": 6, + "measurable": 8 + }, + "ls": { + "deviations": 7, + "measurable": 8 + }, + "lf": { + "deviations": 7, + "measurable": 8 + }, + "tf": { + "deviations": 4, + "measurable": 8 + }, + "ff": { + "deviations": 2, + "measurable": 8 + } + }, + "reason": "Herbasis 2026-09-07 (etappe X12, na 7a/7b): zelfde harnascorrectie als p6diff-baseline; t.o.v. de oude (optieloze) v1-pin gaan es/ef/ls/lf één cel vooruit en tf/ff één cel achteruit, maar de PRODUCTsolve is op dit bestand tussen de v2-baseline en nu byte-identiek (X12). Overige afwijkingen blijven bewust zichtbaar." + } + } +} diff --git a/tests/planning/xer-schedoptions-blast-radius.json b/tests/planning/xer-schedoptions-blast-radius.json new file mode 100644 index 000000000..033af2d4f --- /dev/null +++ b/tests/planning/xer-schedoptions-blast-radius.json @@ -0,0 +1,5819 @@ +{ + "version": 8, + "axes": [ + "es", + "ef", + "ls", + "lf", + "tf", + "ff", + "isCritical" + ], + "defaults": [ + "totalFloatFinish", + "retainedLogic", + "openEndedNotCritical", + "predecessorLagCalendar", + "expectedFinishDates", + "preserveActualDates", + "clampNegativeFreeFloat", + "projectCriticalDefinition" + ], + "deferredDefaults": [], + "population": { + "scanned": 93, + "oracleAxisFiles": 58, + "rawWithSchedOptions": 23, + "rawWithoutSchedOptions": 35, + "projectAddressableSchedOptions": 22, + "functionallyWithoutSchedOptions": 36, + "measured": 34, + "deferred": 2, + "readableFiles": 34, + "openedProjectsWithDefaults": 35, + "wiredProjectsWithDefaults": 35, + "concreteProjectsCompared": 70, + "oracleNegativeFloatFiles": 5, + "withoutSchedOptionsNegativeFloatFiles": 4, + "rawNegativeFloatFiles": 6, + "rawWithoutSchedOptionsNegativeFloatFiles": 5 + }, + "causalProductEffects": { + "version": 1, + "axes": [ + "es", + "ef", + "ls", + "lf", + "tf", + "ff" + ], + "loeDataDate": { + "fields": [ + "deviations", + "measurable" + ], + "rows": [ + { + "branch": "house", + "axis": "es", + "deviations": 4557, + "measurable": 7637 + }, + { + "branch": "house", + "axis": "ef", + "deviations": 4391, + "measurable": 7649 + }, + { + "branch": "house", + "axis": "ls", + "deviations": 5280, + "measurable": 7519 + }, + { + "branch": "house", + "axis": "lf", + "deviations": 5317, + "measurable": 7510 + }, + { + "branch": "house", + "axis": "tf", + "deviations": 6233, + "measurable": 7602 + }, + { + "branch": "house", + "axis": "ff", + "deviations": 2314, + "measurable": 7221 + }, + { + "branch": "xerDefaults", + "axis": "es", + "deviations": 1384, + "measurable": 7637 + }, + { + "branch": "xerDefaults", + "axis": "ef", + "deviations": 1414, + "measurable": 7649 + }, + { + "branch": "xerDefaults", + "axis": "ls", + "deviations": 3353, + "measurable": 7519 + }, + { + "branch": "xerDefaults", + "axis": "lf", + "deviations": 3351, + "measurable": 7510 + }, + { + "branch": "xerDefaults", + "axis": "tf", + "deviations": 3868, + "measurable": 7602 + }, + { + "branch": "xerDefaults", + "axis": "ff", + "deviations": 303, + "measurable": 7221 + } + ] + }, + "completedProgress": { + "fields": [ + "deviations", + "measurable" + ], + "rows": [ + { + "branch": "retainedLogic", + "variant": "chosen", + "axis": "es", + "deviations": 4557, + "measurable": 7637 + }, + { + "branch": "retainedLogic", + "variant": "chosen", + "axis": "ef", + "deviations": 4391, + "measurable": 7649 + }, + { + "branch": "retainedLogic", + "variant": "chosen", + "axis": "ls", + "deviations": 5280, + "measurable": 7519 + }, + { + "branch": "retainedLogic", + "variant": "chosen", + "axis": "lf", + "deviations": 5317, + "measurable": 7510 + }, + { + "branch": "retainedLogic", + "variant": "chosen", + "axis": "tf", + "deviations": 6233, + "measurable": 7602 + }, + { + "branch": "retainedLogic", + "variant": "chosen", + "axis": "ff", + "deviations": 2314, + "measurable": 7221 + }, + { + "branch": "retainedLogic", + "variant": "counterfactual", + "axis": "es", + "deviations": 6359, + "measurable": 7637 + }, + { + "branch": "retainedLogic", + "variant": "counterfactual", + "axis": "ef", + "deviations": 6251, + "measurable": 7649 + }, + { + "branch": "retainedLogic", + "variant": "counterfactual", + "axis": "ls", + "deviations": 7470, + "measurable": 7519 + }, + { + "branch": "retainedLogic", + "variant": "counterfactual", + "axis": "lf", + "deviations": 7440, + "measurable": 7510 + }, + { + "branch": "retainedLogic", + "variant": "counterfactual", + "axis": "tf", + "deviations": 7410, + "measurable": 7602 + }, + { + "branch": "retainedLogic", + "variant": "counterfactual", + "axis": "ff", + "deviations": 2513, + "measurable": 7221 + }, + { + "branch": "preserveActualDates", + "variant": "chosen", + "axis": "es", + "deviations": 4557, + "measurable": 7637 + }, + { + "branch": "preserveActualDates", + "variant": "chosen", + "axis": "ef", + "deviations": 4391, + "measurable": 7649 + }, + { + "branch": "preserveActualDates", + "variant": "chosen", + "axis": "ls", + "deviations": 5248, + "measurable": 7519 + }, + { + "branch": "preserveActualDates", + "variant": "chosen", + "axis": "lf", + "deviations": 5303, + "measurable": 7510 + }, + { + "branch": "preserveActualDates", + "variant": "chosen", + "axis": "tf", + "deviations": 5702, + "measurable": 7602 + }, + { + "branch": "preserveActualDates", + "variant": "chosen", + "axis": "ff", + "deviations": 675, + "measurable": 7221 + }, + { + "branch": "preserveActualDates", + "variant": "counterfactual", + "axis": "es", + "deviations": 4557, + "measurable": 7637 + }, + { + "branch": "preserveActualDates", + "variant": "counterfactual", + "axis": "ef", + "deviations": 4391, + "measurable": 7649 + }, + { + "branch": "preserveActualDates", + "variant": "counterfactual", + "axis": "ls", + "deviations": 5280, + "measurable": 7519 + }, + { + "branch": "preserveActualDates", + "variant": "counterfactual", + "axis": "lf", + "deviations": 5317, + "measurable": 7510 + }, + { + "branch": "preserveActualDates", + "variant": "counterfactual", + "axis": "tf", + "deviations": 6233, + "measurable": 7602 + }, + { + "branch": "preserveActualDates", + "variant": "counterfactual", + "axis": "ff", + "deviations": 2314, + "measurable": 7221 + } + ] + } + }, + "schedOptionsRows": { + "rows": 50, + "floatFinish": 41, + "retainedLogic": 48, + "openEndedNotCritical": 48, + "predecessorLagCalendar": 44, + "projectEndForFloat": 39, + "expectedFinishDates": 48, + "lagFromEarlyStart": 40, + "progressOverride": 1, + "unknownFloatDialect": 8, + "derivedRows": 49, + "derivedFallbacks": 8, + "derivedFloatDialectFallbacks": 8, + "derivedRetainedLogic": 48, + "derivedProgressOverride": 1, + "derivedFinishFloat": 49, + "derivedLagPredecessor": 44, + "derivedLagSuccessor": 3, + "derivedLag24Hour": 1, + "derivedLagProjectDefault": 1, + "retainedProjectEndValues": 47 + }, + "corpusUnion": [ + "enable_multiple_longest_path_calc", + "key_activity_for_multiple_longest_paths", + "level_all_rsrc_flag", + "level_float_thrs_cnt", + "level_keep_sched_date_flag", + "level_outer_assign_flag", + "level_outer_assign_priority", + "level_over_alloc_pct", + "level_within_float_flag", + "levelprioritylist", + "limit_multiple_longest_path_calc", + "max_multiple_longest_path", + "proj_id", + "sched_calendar_on_relationship_lag", + "sched_float_type", + "sched_lag_early_start_flag", + "sched_open_critical_flag", + "sched_outer_depend_type", + "sched_progress_override", + "sched_retained_logic", + "sched_setplantoforecast", + "sched_use_expect_end_flag", + "sched_use_project_end_date_for_float", + "schedhash", + "schedoptions_id", + "use_total_float", + "use_total_float_multiple_longest_paths" + ], + "files": [ + { + "id": "0fa5172162fbd689-1", + "state": "measured", + "tasks": 14, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 1, + 1, + 1 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "11d8c34fff8cf0b8-1", + "state": "deferred", + "tasks": 1, + "oracleNegativeFloatTasks": 0, + "deferredCode": "XER_DANGLING_LOCAL_RELATION" + }, + { + "id": "146e4c8659bad066-1", + "state": "measured", + "tasks": 30, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 6, + 6, + 28, + 28, + 23, + 7, + 5 + ], + "xerDefaultsNegativeFloatTasks": 1, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 1, + 1, + 1 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 19, + 16, + 16, + 3, + 0 + ], + "chosenNegativeFloatTasks": 1, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 1, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "1ba69d297ee9a5c6-1", + "state": "measured", + "tasks": 4, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 2, + 2, + 2 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "1ba8cf1885491db3-1", + "state": "measured", + "tasks": 20, + "oracleNegativeFloatTasks": 3, + "houseNegativeFloatTasks": 4, + "xerDefaultsMovement": [ + 14, + 16, + 20, + 20, + 8, + 4, + 2 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "chosenNegativeFloatTasks": 4, + "counterfactualNegativeFloatTasks": 5 + }, + "retainedLogic": { + "movement": [ + 14, + 16, + 20, + 20, + 5, + 1, + 0 + ], + "chosenNegativeFloatTasks": 4, + "counterfactualNegativeFloatTasks": 4 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 1, + 1, + 1 + ], + "chosenNegativeFloatTasks": 4, + "counterfactualNegativeFloatTasks": 4 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 4, + "counterfactualNegativeFloatTasks": 4 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 4, + "counterfactualNegativeFloatTasks": 4 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 6, + 4, + 4, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 4 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 1, + 0 + ], + "chosenNegativeFloatTasks": 4, + "counterfactualNegativeFloatTasks": 4 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 4, + "counterfactualNegativeFloatTasks": 4 + } + } + }, + { + "id": "1d7901d68cc7063b-1", + "state": "measured", + "tasks": 11, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 2, + "xerDefaultsMovement": [ + 0, + 1, + 0, + 0, + 2, + 1, + 0 + ], + "xerDefaultsNegativeFloatTasks": 1, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 2, + 0, + 0 + ], + "chosenNegativeFloatTasks": 1, + "counterfactualNegativeFloatTasks": 2 + }, + "retainedLogic": { + "movement": [ + 9, + 10, + 11, + 11, + 2, + 0, + 0 + ], + "chosenNegativeFloatTasks": 2, + "counterfactualNegativeFloatTasks": 2 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 2, + "counterfactualNegativeFloatTasks": 2 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 2, + "counterfactualNegativeFloatTasks": 2 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 2, + "counterfactualNegativeFloatTasks": 2 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 2, + "counterfactualNegativeFloatTasks": 2 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 1, + 0 + ], + "chosenNegativeFloatTasks": 2, + "counterfactualNegativeFloatTasks": 2 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 2, + "counterfactualNegativeFloatTasks": 2 + } + } + }, + { + "id": "1d7bd29d2c157bdd-1", + "state": "measured", + "tasks": 3, + "oracleNegativeFloatTasks": 1, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "2c1dce175b9f0781-1", + "state": "measured", + "tasks": 6977, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 44, + "xerDefaultsMovement": [ + 3228, + 3066, + 3326, + 3433, + 3937, + 2129, + 70 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 333, + 0, + 0 + ], + "chosenNegativeFloatTasks": 44, + "counterfactualNegativeFloatTasks": 44 + }, + "retainedLogic": { + "movement": [ + 3026, + 3137, + 6977, + 6977, + 6967, + 535, + 29 + ], + "chosenNegativeFloatTasks": 44, + "counterfactualNegativeFloatTasks": 64 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 11, + 11, + 11 + ], + "chosenNegativeFloatTasks": 44, + "counterfactualNegativeFloatTasks": 44 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 213, + 213, + 213, + 10, + 0 + ], + "chosenNegativeFloatTasks": 44, + "counterfactualNegativeFloatTasks": 44 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 44, + "counterfactualNegativeFloatTasks": 44 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 2666, + 2617, + 2617, + 1641, + 499 + ], + "chosenNegativeFloatTasks": 544, + "counterfactualNegativeFloatTasks": 44 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 618, + 0 + ], + "chosenNegativeFloatTasks": 44, + "counterfactualNegativeFloatTasks": 44 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 44, + "counterfactualNegativeFloatTasks": 44 + } + } + }, + { + "id": "32524ea41f8bb4cb-1", + "state": "measured", + "tasks": 2, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "3daf749da5d4cd89-1", + "state": "deferred", + "tasks": 5, + "oracleNegativeFloatTasks": 0, + "deferredCode": "XER_INVALID_FILE" + }, + { + "id": "467ab0eb0885c4a7-1", + "state": "measured", + "tasks": 10, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "49aea658c963a005-1", + "state": "measured", + "tasks": 120, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 8, + 43, + 41, + 41, + 42, + 11 + ], + "xerDefaultsNegativeFloatTasks": 19, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 1, + 1, + 1 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 43, + 41, + 41, + 31, + 11 + ], + "chosenNegativeFloatTasks": 11, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 42, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "4d39fc3daf2d2859-1", + "state": "measured", + "tasks": 2, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "53f5cdbeb3fbd9c6-1", + "state": "measured", + "tasks": 31, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 1, + "xerDefaultsMovement": [ + 0, + 0, + 25, + 24, + 24, + 12, + 0 + ], + "xerDefaultsNegativeFloatTasks": 1, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 1, + "counterfactualNegativeFloatTasks": 1 + }, + "retainedLogic": { + "movement": [ + 5, + 6, + 30, + 30, + 25, + 6, + 1 + ], + "chosenNegativeFloatTasks": 1, + "counterfactualNegativeFloatTasks": 1 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 1, + "counterfactualNegativeFloatTasks": 1 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 1, + "counterfactualNegativeFloatTasks": 1 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 1, + "counterfactualNegativeFloatTasks": 1 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 25, + 24, + 24, + 11, + 0 + ], + "chosenNegativeFloatTasks": 1, + "counterfactualNegativeFloatTasks": 1 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 5, + 0 + ], + "chosenNegativeFloatTasks": 1, + "counterfactualNegativeFloatTasks": 1 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 1, + "counterfactualNegativeFloatTasks": 1 + } + } + }, + { + "id": "568c19375b4e0d67-1", + "state": "measured", + "tasks": 8, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 1, + 1, + 1, + 1, + 0, + 0, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "5d71eac4e70b0d95-1", + "state": "measured", + "tasks": 120, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 13, + 58, + 56, + 56, + 57, + 11 + ], + "xerDefaultsNegativeFloatTasks": 24, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 1, + 1, + 1 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 58, + 56, + 56, + 46, + 11 + ], + "chosenNegativeFloatTasks": 11, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 57, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "78325fb6445d22f5-1", + "state": "measured", + "tasks": 22, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 2, + 1, + 2, + 1, + 1, + 1, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 1, + 1, + 1 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 2, + 1, + 1, + 1, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "790b90fda837bad3-1", + "state": "measured", + "tasks": 23, + "oracleNegativeFloatTasks": 6, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 3, + 2, + 3, + 2, + 1, + 2, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 1 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 1, + 1, + 1 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 3, + 1, + 1, + 2, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "79f6fb43bb391ed6-1", + "state": "measured", + "tasks": 5, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 1, + 1, + 0, + 0, + 1, + 2, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 3, + 3, + 3 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "79f6fb43bb391ed6-2", + "state": "measured", + "tasks": 5, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 1, + 1, + 0, + 0, + 1, + 2, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 3, + 3, + 3 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "9590c4cdc4efa69a-1", + "state": "measured", + "tasks": 93, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 83, + 0, + 82, + 0, + 0, + 0, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "97058f720a70f623-1", + "state": "measured", + "tasks": 8, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 1, + 1, + 8, + 7, + 6, + 1, + 0 + ], + "xerDefaultsNegativeFloatTasks": 5, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 1, + 2, + 8, + 8, + 8, + 1, + 2 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 5 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 7, + 6, + 6, + 0, + 0 + ], + "chosenNegativeFloatTasks": 5, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 1, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "98f079b267688de2-1", + "state": "measured", + "tasks": 2, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "9f2964425c0e295b-1", + "state": "measured", + "tasks": 2, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "a132f8c7ebb070d2-1", + "state": "measured", + "tasks": 23, + "oracleNegativeFloatTasks": 6, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 3, + 2, + 3, + 2, + 1, + 2, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 1 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 1, + 1, + 1 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 3, + 1, + 1, + 2, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "a7fc367eab6c904e-1", + "state": "measured", + "tasks": 5, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 3, + 2, + 2, + 2, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 2, + 2, + 2 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 3, + 2, + 2, + 2, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "a7fc367eab6c904e-2", + "state": "measured", + "tasks": 5, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 3, + 2, + 2, + 2, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 2, + 2, + 2 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 3, + 2, + 2, + 2, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "ac4b9d677133c6ed-1", + "state": "measured", + "tasks": 22, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 2, + 1, + 2, + 1, + 1, + 1, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 1, + 1, + 1 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 2, + 1, + 1, + 1, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "b72a879f6282cc61-1", + "state": "measured", + "tasks": 2, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "b7ef6d85acfedda1-1", + "state": "measured", + "tasks": 70, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "c1ccf20436ba38b5-1", + "state": "measured", + "tasks": 2, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "c872c9e704797d82-1", + "state": "measured", + "tasks": 8, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 4, + 4, + 8, + 8, + 4, + 2, + 3 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 1, + 1, + 1 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "deabc76347eaef15-1", + "state": "measured", + "tasks": 24, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "def489b2a803af9f-1", + "state": "measured", + "tasks": 5, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 1, + 1, + 1, + 0, + 1, + 2, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 3, + 3, + 3 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 1, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "def489b2a803af9f-2", + "state": "measured", + "tasks": 5, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 1, + 1, + 1, + 0, + 1, + 2, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 3, + 3, + 3 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 1, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "fc74bdab8629d9e8-1", + "state": "measured", + "tasks": 14, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 1, + 1, + 1 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + } + ], + "expectedFinishVariant": { + "selection": { + "chosen": true, + "counterfactual": false + }, + "population": { + "oracleFiles": 58, + "readableFiles": 56, + "deferredFiles": 2, + "projects": 70, + "tasks": 18625, + "sourceTasks": 163, + "activeSourceTasks": 0, + "sourceFiles": 2, + "movingFiles": 0 + }, + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "files": [ + { + "id": "9590c4cdc4efa69a-1", + "tasks": 93, + "sourceTasks": 93, + "activeSourceTasks": 0, + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "directionChanges": 0, + "directionDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945" + }, + { + "id": "b7ef6d85acfedda1-1", + "tasks": 70, + "sourceTasks": 70, + "activeSourceTasks": 0, + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "directionChanges": 0, + "directionDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945" + } + ], + "fidelity": { + "chosen": { + "es": { + "deviations": 7120, + "measurable": 18524 + }, + "ef": { + "deviations": 7002, + "measurable": 18536 + }, + "ls": { + "deviations": 6176, + "measurable": 18406 + }, + "lf": { + "deviations": 6447, + "measurable": 18397 + }, + "tf": { + "deviations": 9393, + "measurable": 18296 + }, + "ff": { + "deviations": 3521, + "measurable": 17915 + } + }, + "counterfactual": { + "es": { + "deviations": 7120, + "measurable": 18524 + }, + "ef": { + "deviations": 7002, + "measurable": 18536 + }, + "ls": { + "deviations": 6176, + "measurable": 18406 + }, + "lf": { + "deviations": 6447, + "measurable": 18397 + }, + "tf": { + "deviations": 9393, + "measurable": 18296 + }, + "ff": { + "deviations": 3521, + "measurable": 17915 + } + } + } + }, + "fidelity": { + "house": { + "es": { + "deviations": 4557, + "measurable": 7637 + }, + "ef": { + "deviations": 4391, + "measurable": 7649 + }, + "ls": { + "deviations": 5280, + "measurable": 7519 + }, + "lf": { + "deviations": 5317, + "measurable": 7510 + }, + "tf": { + "deviations": 6233, + "measurable": 7602 + }, + "ff": { + "deviations": 2314, + "measurable": 7221 + } + }, + "xerDefaults": { + "es": { + "deviations": 1384, + "measurable": 7637 + }, + "ef": { + "deviations": 1414, + "measurable": 7649 + }, + "ls": { + "deviations": 3353, + "measurable": 7519 + }, + "lf": { + "deviations": 3351, + "measurable": 7510 + }, + "tf": { + "deviations": 3868, + "measurable": 7602 + }, + "ff": { + "deviations": 303, + "measurable": 7221 + } + }, + "defaults": { + "totalFloatFinish": { + "chosen": { + "es": { + "deviations": 4557, + "measurable": 7637 + }, + "ef": { + "deviations": 4391, + "measurable": 7649 + }, + "ls": { + "deviations": 5280, + "measurable": 7519 + }, + "lf": { + "deviations": 5317, + "measurable": 7510 + }, + "tf": { + "deviations": 6232, + "measurable": 7602 + }, + "ff": { + "deviations": 2314, + "measurable": 7221 + } + }, + "counterfactual": { + "es": { + "deviations": 4557, + "measurable": 7637 + }, + "ef": { + "deviations": 4391, + "measurable": 7649 + }, + "ls": { + "deviations": 5280, + "measurable": 7519 + }, + "lf": { + "deviations": 5317, + "measurable": 7510 + }, + "tf": { + "deviations": 6233, + "measurable": 7602 + }, + "ff": { + "deviations": 2314, + "measurable": 7221 + } + } + }, + "retainedLogic": { + "chosen": { + "es": { + "deviations": 4557, + "measurable": 7637 + }, + "ef": { + "deviations": 4391, + "measurable": 7649 + }, + "ls": { + "deviations": 5280, + "measurable": 7519 + }, + "lf": { + "deviations": 5317, + "measurable": 7510 + }, + "tf": { + "deviations": 6233, + "measurable": 7602 + }, + "ff": { + "deviations": 2314, + "measurable": 7221 + } + }, + "counterfactual": { + "es": { + "deviations": 6359, + "measurable": 7637 + }, + "ef": { + "deviations": 6251, + "measurable": 7649 + }, + "ls": { + "deviations": 7470, + "measurable": 7519 + }, + "lf": { + "deviations": 7440, + "measurable": 7510 + }, + "tf": { + "deviations": 7410, + "measurable": 7602 + }, + "ff": { + "deviations": 2513, + "measurable": 7221 + } + } + }, + "openEndedNotCritical": { + "chosen": { + "es": { + "deviations": 4557, + "measurable": 7637 + }, + "ef": { + "deviations": 4391, + "measurable": 7649 + }, + "ls": { + "deviations": 5280, + "measurable": 7519 + }, + "lf": { + "deviations": 5317, + "measurable": 7510 + }, + "tf": { + "deviations": 6233, + "measurable": 7602 + }, + "ff": { + "deviations": 2314, + "measurable": 7221 + } + }, + "counterfactual": { + "es": { + "deviations": 4557, + "measurable": 7637 + }, + "ef": { + "deviations": 4391, + "measurable": 7649 + }, + "ls": { + "deviations": 5280, + "measurable": 7519 + }, + "lf": { + "deviations": 5317, + "measurable": 7510 + }, + "tf": { + "deviations": 6217, + "measurable": 7602 + }, + "ff": { + "deviations": 2316, + "measurable": 7221 + } + } + }, + "predecessorLagCalendar": { + "chosen": { + "es": { + "deviations": 4557, + "measurable": 7637 + }, + "ef": { + "deviations": 4391, + "measurable": 7649 + }, + "ls": { + "deviations": 5280, + "measurable": 7519 + }, + "lf": { + "deviations": 5317, + "measurable": 7510 + }, + "tf": { + "deviations": 6233, + "measurable": 7602 + }, + "ff": { + "deviations": 2314, + "measurable": 7221 + } + }, + "counterfactual": { + "es": { + "deviations": 4557, + "measurable": 7637 + }, + "ef": { + "deviations": 4391, + "measurable": 7649 + }, + "ls": { + "deviations": 5280, + "measurable": 7519 + }, + "lf": { + "deviations": 5317, + "measurable": 7510 + }, + "tf": { + "deviations": 6233, + "measurable": 7602 + }, + "ff": { + "deviations": 2315, + "measurable": 7221 + } + } + }, + "expectedFinishDates": { + "chosen": { + "es": { + "deviations": 4557, + "measurable": 7637 + }, + "ef": { + "deviations": 4391, + "measurable": 7649 + }, + "ls": { + "deviations": 5280, + "measurable": 7519 + }, + "lf": { + "deviations": 5317, + "measurable": 7510 + }, + "tf": { + "deviations": 6233, + "measurable": 7602 + }, + "ff": { + "deviations": 2314, + "measurable": 7221 + } + }, + "counterfactual": { + "es": { + "deviations": 4557, + "measurable": 7637 + }, + "ef": { + "deviations": 4391, + "measurable": 7649 + }, + "ls": { + "deviations": 5280, + "measurable": 7519 + }, + "lf": { + "deviations": 5317, + "measurable": 7510 + }, + "tf": { + "deviations": 6233, + "measurable": 7602 + }, + "ff": { + "deviations": 2314, + "measurable": 7221 + } + } + }, + "preserveActualDates": { + "chosen": { + "es": { + "deviations": 4557, + "measurable": 7637 + }, + "ef": { + "deviations": 4391, + "measurable": 7649 + }, + "ls": { + "deviations": 5248, + "measurable": 7519 + }, + "lf": { + "deviations": 5303, + "measurable": 7510 + }, + "tf": { + "deviations": 5702, + "measurable": 7602 + }, + "ff": { + "deviations": 675, + "measurable": 7221 + } + }, + "counterfactual": { + "es": { + "deviations": 4557, + "measurable": 7637 + }, + "ef": { + "deviations": 4391, + "measurable": 7649 + }, + "ls": { + "deviations": 5280, + "measurable": 7519 + }, + "lf": { + "deviations": 5317, + "measurable": 7510 + }, + "tf": { + "deviations": 6233, + "measurable": 7602 + }, + "ff": { + "deviations": 2314, + "measurable": 7221 + } + } + }, + "clampNegativeFreeFloat": { + "chosen": { + "es": { + "deviations": 4557, + "measurable": 7637 + }, + "ef": { + "deviations": 4391, + "measurable": 7649 + }, + "ls": { + "deviations": 5280, + "measurable": 7519 + }, + "lf": { + "deviations": 5317, + "measurable": 7510 + }, + "tf": { + "deviations": 6233, + "measurable": 7602 + }, + "ff": { + "deviations": 1725, + "measurable": 7221 + } + }, + "counterfactual": { + "es": { + "deviations": 4557, + "measurable": 7637 + }, + "ef": { + "deviations": 4391, + "measurable": 7649 + }, + "ls": { + "deviations": 5280, + "measurable": 7519 + }, + "lf": { + "deviations": 5317, + "measurable": 7510 + }, + "tf": { + "deviations": 6233, + "measurable": 7602 + }, + "ff": { + "deviations": 2314, + "measurable": 7221 + } + } + }, + "projectCriticalDefinition": { + "chosen": { + "es": { + "deviations": 4557, + "measurable": 7637 + }, + "ef": { + "deviations": 4391, + "measurable": 7649 + }, + "ls": { + "deviations": 5280, + "measurable": 7519 + }, + "lf": { + "deviations": 5317, + "measurable": 7510 + }, + "tf": { + "deviations": 6233, + "measurable": 7602 + }, + "ff": { + "deviations": 2314, + "measurable": 7221 + } + }, + "counterfactual": { + "es": { + "deviations": 4557, + "measurable": 7637 + }, + "ef": { + "deviations": 4391, + "measurable": 7649 + }, + "ls": { + "deviations": 5280, + "measurable": 7519 + }, + "lf": { + "deviations": 5317, + "measurable": 7510 + }, + "tf": { + "deviations": 6233, + "measurable": 7602 + }, + "ff": { + "deviations": 2314, + "measurable": 7221 + } + } + } + } + }, + "legacyDurationTypeHistoricalDelta": [ + { + "path": "files[8].houseNegativeFloatTasks", + "before": 38, + "after": 11 + }, + { + "path": "files[8].xerDefaultsMovement[6]", + "before": 505, + "after": 523 + }, + { + "path": "files[8].defaults.totalFloatFinish.movement[4]", + "before": 331, + "after": 322 + }, + { + "path": "files[8].defaults.totalFloatFinish.chosenNegativeFloatTasks", + "before": 38, + "after": 11 + }, + { + "path": "files[8].defaults.totalFloatFinish.counterfactualNegativeFloatTasks", + "before": 38, + "after": 11 + }, + { + "path": "files[8].defaults.retainedLogic.movement[0]", + "before": 3036, + "after": 3416 + }, + { + "path": "files[8].defaults.retainedLogic.movement[1]", + "before": 3149, + "after": 3529 + }, + { + "path": "files[8].defaults.retainedLogic.movement[4]", + "before": 6971, + "after": 6962 + }, + { + "path": "files[8].defaults.retainedLogic.movement[5]", + "before": 547, + "after": 590 + }, + { + "path": "files[8].defaults.retainedLogic.movement[6]", + "before": 31, + "after": 50 + }, + { + "path": "files[8].defaults.retainedLogic.chosenNegativeFloatTasks", + "before": 38, + "after": 11 + }, + { + "path": "files[8].defaults.retainedLogic.counterfactualNegativeFloatTasks", + "before": 57, + "after": 59 + }, + { + "path": "files[8].defaults.openEndedNotCritical.chosenNegativeFloatTasks", + "before": 38, + "after": 11 + }, + { + "path": "files[8].defaults.openEndedNotCritical.counterfactualNegativeFloatTasks", + "before": 38, + "after": 11 + }, + { + "path": "files[8].defaults.predecessorLagCalendar.movement[2]", + "before": 164, + "after": 303 + }, + { + "path": "files[8].defaults.predecessorLagCalendar.movement[3]", + "before": 164, + "after": 303 + }, + { + "path": "files[8].defaults.predecessorLagCalendar.movement[4]", + "before": 164, + "after": 303 + }, + { + "path": "files[8].defaults.predecessorLagCalendar.chosenNegativeFloatTasks", + "before": 38, + "after": 11 + }, + { + "path": "files[8].defaults.predecessorLagCalendar.counterfactualNegativeFloatTasks", + "before": 38, + "after": 11 + }, + { + "path": "files[8].defaults.preserveActualDates.movement[6]", + "before": 505, + "after": 523 + }, + { + "path": "files[8].defaults.preserveActualDates.counterfactualNegativeFloatTasks", + "before": 38, + "after": 11 + }, + { + "path": "files[8].defaults.clampNegativeFreeFloat.chosenNegativeFloatTasks", + "before": 38, + "after": 11 + }, + { + "path": "files[8].defaults.clampNegativeFreeFloat.counterfactualNegativeFloatTasks", + "before": 38, + "after": 11 + }, + { + "path": "files[8].defaults.projectCriticalDefinition.chosenNegativeFloatTasks", + "before": 38, + "after": 11 + }, + { + "path": "files[8].defaults.projectCriticalDefinition.counterfactualNegativeFloatTasks", + "before": 38, + "after": 11 + }, + { + "path": "files[14].houseNegativeFloatTasks", + "before": 1, + "after": 0 + }, + { + "path": "files[14].defaults.totalFloatFinish.chosenNegativeFloatTasks", + "before": 1, + "after": 0 + }, + { + "path": "files[14].defaults.totalFloatFinish.counterfactualNegativeFloatTasks", + "before": 1, + "after": 0 + }, + { + "path": "files[14].defaults.retainedLogic.chosenNegativeFloatTasks", + "before": 1, + "after": 0 + }, + { + "path": "files[14].defaults.openEndedNotCritical.chosenNegativeFloatTasks", + "before": 1, + "after": 0 + }, + { + "path": "files[14].defaults.openEndedNotCritical.counterfactualNegativeFloatTasks", + "before": 1, + "after": 0 + }, + { + "path": "files[14].defaults.predecessorLagCalendar.chosenNegativeFloatTasks", + "before": 1, + "after": 0 + }, + { + "path": "files[14].defaults.predecessorLagCalendar.counterfactualNegativeFloatTasks", + "before": 1, + "after": 0 + }, + { + "path": "files[14].defaults.preserveActualDates.counterfactualNegativeFloatTasks", + "before": 1, + "after": 0 + }, + { + "path": "files[14].defaults.clampNegativeFreeFloat.chosenNegativeFloatTasks", + "before": 1, + "after": 0 + }, + { + "path": "files[14].defaults.clampNegativeFreeFloat.counterfactualNegativeFloatTasks", + "before": 1, + "after": 0 + }, + { + "path": "files[14].defaults.projectCriticalDefinition.chosenNegativeFloatTasks", + "before": 1, + "after": 0 + }, + { + "path": "files[14].defaults.projectCriticalDefinition.counterfactualNegativeFloatTasks", + "before": 1, + "after": 0 + }, + { + "path": "fidelity.house.es.deviations", + "before": 4552, + "after": 5156 + }, + { + "path": "fidelity.house.ef.deviations", + "before": 4488, + "after": 5144 + }, + { + "path": "fidelity.house.ls.deviations", + "before": 7485, + "after": 7505 + }, + { + "path": "fidelity.house.lf.deviations", + "before": 7466, + "after": 7482 + }, + { + "path": "fidelity.house.tf.deviations", + "before": 6932, + "after": 7195 + }, + { + "path": "fidelity.house.ff.deviations", + "before": 2341, + "after": 2428 + }, + { + "path": "fidelity.xerDefaults.es.deviations", + "before": 4552, + "after": 5156 + }, + { + "path": "fidelity.xerDefaults.ef.deviations", + "before": 4488, + "after": 5144 + }, + { + "path": "fidelity.xerDefaults.ls.deviations", + "before": 5417, + "after": 5422 + }, + { + "path": "fidelity.xerDefaults.tf.deviations", + "before": 6407, + "after": 6704 + }, + { + "path": "fidelity.xerDefaults.ff.deviations", + "before": 585, + "after": 673 + }, + { + "path": "fidelity.defaults.totalFloatFinish.chosen.es.deviations", + "before": 4552, + "after": 5156 + }, + { + "path": "fidelity.defaults.totalFloatFinish.chosen.ef.deviations", + "before": 4488, + "after": 5144 + }, + { + "path": "fidelity.defaults.totalFloatFinish.chosen.ls.deviations", + "before": 7485, + "after": 7505 + }, + { + "path": "fidelity.defaults.totalFloatFinish.chosen.lf.deviations", + "before": 7466, + "after": 7482 + }, + { + "path": "fidelity.defaults.totalFloatFinish.chosen.tf.deviations", + "before": 6931, + "after": 7194 + }, + { + "path": "fidelity.defaults.totalFloatFinish.chosen.ff.deviations", + "before": 2341, + "after": 2428 + }, + { + "path": "fidelity.defaults.totalFloatFinish.counterfactual.es.deviations", + "before": 4552, + "after": 5156 + }, + { + "path": "fidelity.defaults.totalFloatFinish.counterfactual.ef.deviations", + "before": 4488, + "after": 5144 + }, + { + "path": "fidelity.defaults.totalFloatFinish.counterfactual.ls.deviations", + "before": 7485, + "after": 7505 + }, + { + "path": "fidelity.defaults.totalFloatFinish.counterfactual.lf.deviations", + "before": 7466, + "after": 7482 + }, + { + "path": "fidelity.defaults.totalFloatFinish.counterfactual.tf.deviations", + "before": 6932, + "after": 7195 + }, + { + "path": "fidelity.defaults.totalFloatFinish.counterfactual.ff.deviations", + "before": 2341, + "after": 2428 + }, + { + "path": "fidelity.defaults.retainedLogic.chosen.es.deviations", + "before": 4552, + "after": 5156 + }, + { + "path": "fidelity.defaults.retainedLogic.chosen.ef.deviations", + "before": 4488, + "after": 5144 + }, + { + "path": "fidelity.defaults.retainedLogic.chosen.ls.deviations", + "before": 7485, + "after": 7505 + }, + { + "path": "fidelity.defaults.retainedLogic.chosen.lf.deviations", + "before": 7466, + "after": 7482 + }, + { + "path": "fidelity.defaults.retainedLogic.chosen.tf.deviations", + "before": 6932, + "after": 7195 + }, + { + "path": "fidelity.defaults.retainedLogic.chosen.ff.deviations", + "before": 2341, + "after": 2428 + }, + { + "path": "fidelity.defaults.retainedLogic.counterfactual.es.deviations", + "before": 4994, + "after": 5212 + }, + { + "path": "fidelity.defaults.retainedLogic.counterfactual.ef.deviations", + "before": 4959, + "after": 5206 + }, + { + "path": "fidelity.defaults.retainedLogic.counterfactual.ls.deviations", + "before": 7501, + "after": 7507 + }, + { + "path": "fidelity.defaults.retainedLogic.counterfactual.tf.deviations", + "before": 7410, + "after": 7409 + }, + { + "path": "fidelity.defaults.retainedLogic.counterfactual.ff.deviations", + "before": 2521, + "after": 2534 + }, + { + "path": "fidelity.defaults.openEndedNotCritical.chosen.es.deviations", + "before": 4552, + "after": 5156 + }, + { + "path": "fidelity.defaults.openEndedNotCritical.chosen.ef.deviations", + "before": 4488, + "after": 5144 + }, + { + "path": "fidelity.defaults.openEndedNotCritical.chosen.ls.deviations", + "before": 7485, + "after": 7505 + }, + { + "path": "fidelity.defaults.openEndedNotCritical.chosen.lf.deviations", + "before": 7466, + "after": 7482 + }, + { + "path": "fidelity.defaults.openEndedNotCritical.chosen.tf.deviations", + "before": 6932, + "after": 7195 + }, + { + "path": "fidelity.defaults.openEndedNotCritical.chosen.ff.deviations", + "before": 2341, + "after": 2428 + }, + { + "path": "fidelity.defaults.openEndedNotCritical.counterfactual.es.deviations", + "before": 4552, + "after": 5156 + }, + { + "path": "fidelity.defaults.openEndedNotCritical.counterfactual.ef.deviations", + "before": 4488, + "after": 5144 + }, + { + "path": "fidelity.defaults.openEndedNotCritical.counterfactual.ls.deviations", + "before": 7485, + "after": 7505 + }, + { + "path": "fidelity.defaults.openEndedNotCritical.counterfactual.lf.deviations", + "before": 7466, + "after": 7482 + }, + { + "path": "fidelity.defaults.openEndedNotCritical.counterfactual.tf.deviations", + "before": 6913, + "after": 7176 + }, + { + "path": "fidelity.defaults.openEndedNotCritical.counterfactual.ff.deviations", + "before": 2339, + "after": 2426 + }, + { + "path": "fidelity.defaults.predecessorLagCalendar.chosen.es.deviations", + "before": 4552, + "after": 5156 + }, + { + "path": "fidelity.defaults.predecessorLagCalendar.chosen.ef.deviations", + "before": 4488, + "after": 5144 + }, + { + "path": "fidelity.defaults.predecessorLagCalendar.chosen.ls.deviations", + "before": 7485, + "after": 7505 + }, + { + "path": "fidelity.defaults.predecessorLagCalendar.chosen.lf.deviations", + "before": 7466, + "after": 7482 + }, + { + "path": "fidelity.defaults.predecessorLagCalendar.chosen.tf.deviations", + "before": 6932, + "after": 7195 + }, + { + "path": "fidelity.defaults.predecessorLagCalendar.chosen.ff.deviations", + "before": 2341, + "after": 2428 + }, + { + "path": "fidelity.defaults.predecessorLagCalendar.counterfactual.es.deviations", + "before": 4552, + "after": 5156 + }, + { + "path": "fidelity.defaults.predecessorLagCalendar.counterfactual.ef.deviations", + "before": 4488, + "after": 5144 + }, + { + "path": "fidelity.defaults.predecessorLagCalendar.counterfactual.ls.deviations", + "before": 7485, + "after": 7505 + }, + { + "path": "fidelity.defaults.predecessorLagCalendar.counterfactual.lf.deviations", + "before": 7466, + "after": 7482 + }, + { + "path": "fidelity.defaults.predecessorLagCalendar.counterfactual.tf.deviations", + "before": 6932, + "after": 7195 + }, + { + "path": "fidelity.defaults.predecessorLagCalendar.counterfactual.ff.deviations", + "before": 2342, + "after": 2429 + }, + { + "path": "fidelity.defaults.preserveActualDates.chosen.es.deviations", + "before": 4552, + "after": 5156 + }, + { + "path": "fidelity.defaults.preserveActualDates.chosen.ef.deviations", + "before": 4488, + "after": 5144 + }, + { + "path": "fidelity.defaults.preserveActualDates.chosen.ls.deviations", + "before": 5417, + "after": 5422 + }, + { + "path": "fidelity.defaults.preserveActualDates.chosen.tf.deviations", + "before": 6408, + "after": 6705 + }, + { + "path": "fidelity.defaults.preserveActualDates.chosen.ff.deviations", + "before": 705, + "after": 793 + }, + { + "path": "fidelity.defaults.preserveActualDates.counterfactual.es.deviations", + "before": 4552, + "after": 5156 + }, + { + "path": "fidelity.defaults.preserveActualDates.counterfactual.ef.deviations", + "before": 4488, + "after": 5144 + }, + { + "path": "fidelity.defaults.preserveActualDates.counterfactual.ls.deviations", + "before": 7485, + "after": 7505 + }, + { + "path": "fidelity.defaults.preserveActualDates.counterfactual.lf.deviations", + "before": 7466, + "after": 7482 + }, + { + "path": "fidelity.defaults.preserveActualDates.counterfactual.tf.deviations", + "before": 6932, + "after": 7195 + }, + { + "path": "fidelity.defaults.preserveActualDates.counterfactual.ff.deviations", + "before": 2341, + "after": 2428 + }, + { + "path": "fidelity.defaults.clampNegativeFreeFloat.chosen.es.deviations", + "before": 4552, + "after": 5156 + }, + { + "path": "fidelity.defaults.clampNegativeFreeFloat.chosen.ef.deviations", + "before": 4488, + "after": 5144 + }, + { + "path": "fidelity.defaults.clampNegativeFreeFloat.chosen.ls.deviations", + "before": 7485, + "after": 7505 + }, + { + "path": "fidelity.defaults.clampNegativeFreeFloat.chosen.lf.deviations", + "before": 7466, + "after": 7482 + }, + { + "path": "fidelity.defaults.clampNegativeFreeFloat.chosen.tf.deviations", + "before": 6932, + "after": 7195 + }, + { + "path": "fidelity.defaults.clampNegativeFreeFloat.chosen.ff.deviations", + "before": 1749, + "after": 1836 + }, + { + "path": "fidelity.defaults.clampNegativeFreeFloat.counterfactual.es.deviations", + "before": 4552, + "after": 5156 + }, + { + "path": "fidelity.defaults.clampNegativeFreeFloat.counterfactual.ef.deviations", + "before": 4488, + "after": 5144 + }, + { + "path": "fidelity.defaults.clampNegativeFreeFloat.counterfactual.ls.deviations", + "before": 7485, + "after": 7505 + }, + { + "path": "fidelity.defaults.clampNegativeFreeFloat.counterfactual.lf.deviations", + "before": 7466, + "after": 7482 + }, + { + "path": "fidelity.defaults.clampNegativeFreeFloat.counterfactual.tf.deviations", + "before": 6932, + "after": 7195 + }, + { + "path": "fidelity.defaults.clampNegativeFreeFloat.counterfactual.ff.deviations", + "before": 2341, + "after": 2428 + }, + { + "path": "fidelity.defaults.projectCriticalDefinition.chosen.es.deviations", + "before": 4552, + "after": 5156 + }, + { + "path": "fidelity.defaults.projectCriticalDefinition.chosen.ef.deviations", + "before": 4488, + "after": 5144 + }, + { + "path": "fidelity.defaults.projectCriticalDefinition.chosen.ls.deviations", + "before": 7485, + "after": 7505 + }, + { + "path": "fidelity.defaults.projectCriticalDefinition.chosen.lf.deviations", + "before": 7466, + "after": 7482 + }, + { + "path": "fidelity.defaults.projectCriticalDefinition.chosen.tf.deviations", + "before": 6932, + "after": 7195 + }, + { + "path": "fidelity.defaults.projectCriticalDefinition.chosen.ff.deviations", + "before": 2341, + "after": 2428 + }, + { + "path": "fidelity.defaults.projectCriticalDefinition.counterfactual.es.deviations", + "before": 4552, + "after": 5156 + }, + { + "path": "fidelity.defaults.projectCriticalDefinition.counterfactual.ef.deviations", + "before": 4488, + "after": 5144 + }, + { + "path": "fidelity.defaults.projectCriticalDefinition.counterfactual.ls.deviations", + "before": 7485, + "after": 7505 + }, + { + "path": "fidelity.defaults.projectCriticalDefinition.counterfactual.lf.deviations", + "before": 7466, + "after": 7482 + }, + { + "path": "fidelity.defaults.projectCriticalDefinition.counterfactual.tf.deviations", + "before": 6932, + "after": 7195 + }, + { + "path": "fidelity.defaults.projectCriticalDefinition.counterfactual.ff.deviations", + "before": 2341, + "after": 2428 + } + ] +} diff --git a/tests/planning/xer-task-replay-public-pin.json b/tests/planning/xer-task-replay-public-pin.json new file mode 100644 index 000000000..d27bd339b --- /dev/null +++ b/tests/planning/xer-task-replay-public-pin.json @@ -0,0 +1,93 @@ +{ + "version": 1, + "manifestEntries": 93, + "selectedEntries": 34, + "projects": 47, + "tasks": 13982, + "candidates": { + "synthetic-zero-regression": { + "aggregate": { + "es": { + "improved": 0, + "regressed": 0, + "unchanged": 13931 + }, + "ef": { + "improved": 0, + "regressed": 0, + "unchanged": 13937 + }, + "ls": { + "improved": 0, + "regressed": 0, + "unchanged": 13822 + }, + "lf": { + "improved": 0, + "regressed": 0, + "unchanged": 13813 + }, + "tf": { + "improved": 0, + "regressed": 0, + "unchanged": 13677 + }, + "ff": { + "improved": 0, + "regressed": 0, + "unchanged": 13322 + }, + "overall": { + "improved": 0, + "regressed": 0, + "unchanged": 13959 + } + }, + "rejected": false, + "exitCode": 0, + "memoryModel": "one-manifest-entry-and-one-project-solve-clone-at-a-time" + }, + "drop-p6-finish-milestone-boundary": { + "aggregate": { + "es": { + "improved": 0, + "regressed": 0, + "unchanged": 13931 + }, + "ef": { + "improved": 0, + "regressed": 2, + "unchanged": 13935 + }, + "ls": { + "improved": 0, + "regressed": 1559, + "unchanged": 12263 + }, + "lf": { + "improved": 0, + "regressed": 1570, + "unchanged": 12243 + }, + "tf": { + "improved": 0, + "regressed": 1487, + "unchanged": 12190 + }, + "ff": { + "improved": 0, + "regressed": 0, + "unchanged": 13322 + }, + "overall": { + "improved": 0, + "regressed": 1570, + "unchanged": 12389 + } + }, + "rejected": true, + "exitCode": 1, + "memoryModel": "one-manifest-entry-and-one-project-solve-clone-at-a-time" + } + } +} diff --git a/tests/planning/xerArchiveColdReadAsync.ts b/tests/planning/xerArchiveColdReadAsync.ts new file mode 100644 index 000000000..1108564b3 --- /dev/null +++ b/tests/planning/xerArchiveColdReadAsync.ts @@ -0,0 +1,17 @@ +// Kindproces B-officieel voor X9: alleen de publieke asynchrone IFC-ingang mag de lazy XER-lezer laden. +import { readFileSync } from 'node:fs'; +import { readIFCWithXerReconstruction } from '@/services/formatRegistry'; +import { decodeXerSourceArchive, sha256Hex } from '@/services/xerSourceArchive'; + +const source = process.argv[2]; +if (!source) throw new Error('X9 koude async-lezer mist bronpad'); + +const parsed = await readIFCWithXerReconstruction(readFileSync(source, 'utf8')); +if (!parsed.xerSourceArchive || !parsed.xer) { + throw new Error('X9 koude async-lezer reconstrueerde geen XER-bronarchief'); +} +process.stdout.write(JSON.stringify({ + sourceProjectId: parsed.xer.sourceProjectId, + taskCount: parsed.tasks.length, + sha256: sha256Hex(decodeXerSourceArchive(parsed.xerSourceArchive)), +})); diff --git a/tests/planning/xerArchiveColdReadRaw.ts b/tests/planning/xerArchiveColdReadRaw.ts new file mode 100644 index 000000000..05efeb081 --- /dev/null +++ b/tests/planning/xerArchiveColdReadRaw.ts @@ -0,0 +1,18 @@ +// Kindproces B-raw voor X9: importeert bewust NIET de XER-reader en gebruikt de lage sync-lezer. +import { readFileSync } from 'node:fs'; +import { IfcParseError } from '@/services/ifc/ifcErrors'; +import { readIFC } from '@/services/ifc/ifcReader'; + +const source = process.argv[2]; +if (!source) throw new Error('X9 koude raw-lezer mist bronpad'); + +try { + const parsed = readIFC(readFileSync(source, 'utf8')); + process.stdout.write(JSON.stringify({ ok: true, hasArchive: Boolean(parsed.xerSourceArchive) })); +} catch (error) { + process.stdout.write(JSON.stringify({ + ok: false, + reason: error instanceof IfcParseError ? error.reason : undefined, + message: error instanceof Error ? error.message : String(error), + })); +} diff --git a/tests/planning/xerArchiveColdReadWriter.ts b/tests/planning/xerArchiveColdReadWriter.ts new file mode 100644 index 000000000..161fd9fe9 --- /dev/null +++ b/tests/planning/xerArchiveColdReadWriter.ts @@ -0,0 +1,29 @@ +// Kindproces A voor X9: schrijft een echte compacte schema-2-IFC zonder een lezer te openen. +import { isMultiDocumentImport } from '@/services/importTypes'; +import { writeIFC } from '@/services/ifc/ifcWriter'; +import { readXER } from '@/services/xer/xerReader'; +import { writeFileSync } from 'node:fs'; + +const target = process.argv[2]; +if (!target) throw new Error('X9 koud-schrijfproces mist doelpad'); + +const source = new TextEncoder().encode([ + 'ERMHDR\t23.12\t2026-08-01\t\t\t\t\t\tEUR', + '%T\tPROJECT', + '%F\tproj_id\tproj_short_name\tclndr_id\tlast_recalc_date', + '%R\tP-COLD\tKoude bron\tC\t2026-08-01 08:00', + '%T\tCALENDAR', + '%F\tclndr_id\tclndr_name\tday_hr_cnt\tweek_hr_cnt\tclndr_data', + '%R\tC\tStandaard\t8\t40\t', + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_code\ttask_name\tclndr_id\ttarget_start_date\ttarget_end_date\ttarget_drtn_hr_cnt\ttask_type\tduration_type\tstatus_code', + '%R\tT-COLD\tP-COLD\tC-1\tKoude taak\tC\t2026-08-01 08:00\t2026-08-01 16:00\t8\tTT_Task\tDT_FixedDUR2\tTK_NotStart', + '%E', +].join('\r\n')); + +const opened = readXER(source); +const parsed = isMultiDocumentImport(opened) ? opened.results[0] : opened; +if (!parsed || (isMultiDocumentImport(opened) && opened.results.length !== 1)) { + throw new Error('X9 koud-schrijfproces verwacht precies één XER-document'); +} +writeFileSync(target, writeIFC(parsed)); diff --git a/tests/planning/xerArchiveLegacyFixture.ts b/tests/planning/xerArchiveLegacyFixture.ts new file mode 100644 index 000000000..e4e59eb6d --- /dev/null +++ b/tests/planning/xerArchiveLegacyFixture.ts @@ -0,0 +1,82 @@ +/** + * Test-only schema-1-writer voor bestaande X9-containers. + * + * Productcode schrijft bewust alleen schema 2. Deze helper houdt de uitgebreide historische + * envelope als invoerfixture beschikbaar voor validators en achterwaartse-compatibiliteitstests. + */ +import type { ImportResult } from '@/services/importTypes'; +import { writeIFC } from '@/services/ifc/ifcWriter'; +import { + chunkXerArchiveBytes, + encodeXerArchiveMetadataPayload, + type XerSourceArchive, +} from '@/services/xerSourceArchive'; + +const escapeIfcText = (value: string): string => value.replace(/'/g, "''"); + +function appendLegacyExpandedContainer( + ifc: string, + archive: XerSourceArchive, + sourceProjectId: string, +): string { + const project = /#([A-Za-z0-9_]+)=IFCPROJECT\([^,]+,#([A-Za-z0-9_]+)/.exec(ifc); + if (!project) throw new Error('Basis-IFC mist IFCPROJECT/eigenaar'); + const diagnostics = chunkXerArchiveBytes(encodeXerArchiveMetadataPayload(archive)); + let nextId = 900000; + const lines: string[] = []; + const propertyIds: number[] = []; + const property = (name: string, value: string): void => { + const id = nextId++; + propertyIds.push(id); + lines.push(`#${id}=IFCPROPERTYSINGLEVALUE('${name}',$,${value},$);`); + }; + property('SchemaVersion', 'IFCINTEGER(1)'); + property('Format', `IFCLABEL('${escapeIfcText(archive.format)}')`); + property('ByteLength', `IFCINTEGER(${archive.byteLength})`); + property('Sha256', `IFCTEXT('${archive.sha256}')`); + property('Encoding', `IFCLABEL('${archive.encoding}')`); + property('Bom', `IFCLABEL('${archive.bom}')`); + property('Newline', `IFCLABEL('${archive.newline}')`); + property('ByteChunkSize', 'IFCINTEGER(196608)'); + property('ByteChunkCount', `IFCINTEGER(${archive.byteChunks.length})`); + property('DiagnosticsByteLength', `IFCINTEGER(${diagnostics.byteLength})`); + property('DiagnosticsSha256', `IFCTEXT('${diagnostics.sha256}')`); + property('DiagnosticsChunkCount', `IFCINTEGER(${diagnostics.byteChunks.length})`); + archive.byteChunks.forEach((chunk, index) => property( + `ByteChunk${String(index).padStart(6, '0')}`, + `IFCTEXT('${chunk}')`, + )); + diagnostics.byteChunks.forEach((chunk, index) => property( + `DiagnosticsChunk${String(index).padStart(6, '0')}`, + `IFCTEXT('${chunk}')`, + )); + const archiveSet = nextId++; + lines.push(`#${archiveSet}=IFCPROPERTYSET('0xxxxxxxxxxxxxxxxxxxxx',#${project[2]},'OPS_XerSourceArchive',$,(${propertyIds.map(id => `#${id}`).join(',')}));`); + lines.push(`#${nextId++}=IFCRELDEFINESBYPROPERTIES('1xxxxxxxxxxxxxxxxxxxxx',#${project[2]},$,$,(#${project[1]}),#${archiveSet});`); + const selectorIds: number[] = []; + const selector = (name: string, value: string): void => { + const id = nextId++; + selectorIds.push(id); + lines.push(`#${id}=IFCPROPERTYSINGLEVALUE('${name}',$,${value},$);`); + }; + selector('ArchiveSha256', `IFCTEXT('${archive.sha256}')`); + selector('SourceProjectId', `IFCTEXT('${escapeIfcText(sourceProjectId)}')`); + const selectorSet = nextId++; + lines.push(`#${selectorSet}=IFCPROPERTYSET('2xxxxxxxxxxxxxxxxxxxxx',#${project[2]},'OPS_XerDocument',$,(${selectorIds.map(id => `#${id}`).join(',')}));`); + lines.push(`#${nextId++}=IFCRELDEFINESBYPROPERTIES('3xxxxxxxxxxxxxxxxxxxxx',#${project[2]},$,$,(#${project[1]}),#${selectorSet});`); + return ifc.replace( + '\nENDSEC;\nEND-ISO-10303-21;\n', + `\n${lines.join('\n')}\nENDSEC;\nEND-ISO-10303-21;\n`, + ); +} + +export function writeExpandedLegacyXerArchiveFixture(input: ImportResult): string { + const archive = input.xerSourceArchive; + const sourceProjectId = input.xer?.sourceProjectId ?? input.xerSourceProjectId; + if (!archive || !sourceProjectId) throw new Error('Legacy-X9-fixture mist bronarchief of selector'); + return appendLegacyExpandedContainer( + writeIFC({ ...input, xer: undefined, xerSourceArchive: undefined, xerSourceProjectId: undefined }), + archive, + sourceProjectId, + ); +} diff --git a/tests/planning/xerArchiveTestReader.ts b/tests/planning/xerArchiveTestReader.ts new file mode 100644 index 000000000..c7c24c0fa --- /dev/null +++ b/tests/planning/xerArchiveTestReader.ts @@ -0,0 +1,10 @@ +// Testexpliciete toegang tot schema-2: productie opent IFC uitsluitend via de async registry-ingang; +// corpusloze XER-contracttests houden de synchrone lezer bewust klein via dezelfde geïnjecteerde +// reconstructor, zonder een verborgen module-initregistratie te herstellen. +import { readIFC } from '@/services/ifc/ifcReader'; +import type { ImportLabels, ImportResult } from '@/services/importTypes'; +import { reconstructXerSourceFromBytes } from '@/services/xer/xerReader'; + +export function readXerArchiveIFC(content: string, labels: ImportLabels = {}): ImportResult { + return readIFC(content, labels, { reconstructXerArchive: reconstructXerSourceFromBytes }); +} diff --git a/tests/planning/xerFidelity.ts b/tests/planning/xerFidelity.ts new file mode 100644 index 000000000..6da40280f --- /dev/null +++ b/tests/planning/xerFidelity.ts @@ -0,0 +1,475 @@ +import { createHash } from 'node:crypto'; +import { + classifyExact, + classifyMinuteExact, + compareFidelityRow, + countFidelityAxis, + type FidelityCounts, +} from './fidelityCore'; +import { + scanXerGroundTruth, + XER_FIDELITY_AXES, + type XerFidelityAxis, + type XerGroundTruth, + type XerGroundTruthTask, +} from './xerGroundTruth'; +import { + emptyCounters, + type XerFidelityAxisCounts, + type XerFidelityBaseline, + type XerFidelityBaselineEntry, + type XerFidelityCounters, +} from './xerFidelityTypes'; + +/** Solveruitvoer in meetlateenheden. Float is bewust al minuten: de latere adapter moet daarvoor + * de taak-effectieve kalender gebruiken; deze onafhankelijke kern kent geen productiekalender. */ +export interface XerSolvedTask { + sourceTaskId: string; + taskCode: string; + earlyStart?: string; + earlyFinish?: string; + lateStart?: string; + lateFinish?: string; + totalFloatMinutes?: number; + freeFloatMinutes?: number; + drivingPath?: boolean; +} + +export interface XerSolvedProject { + projectId: string; + tasks: XerSolvedTask[]; +} + +export interface XerProjectFidelity { + projectId: string; + tasks: number; + counters: XerFidelityCounters; + drivingPath: XerFidelityAxisCounts; +} + +export interface XerFileFidelity { + truthProjects: number; + solvedProjects: number; + truthTasks: number; + solvedTasks: number; + /** Compatibiliteitsalias voor de bestaande rapportage: gelijk aan `truthTasks`. */ + tasks: number; + projects: XerProjectFidelity[]; + counters: XerFidelityCounters; + /** Zevende rapportage-as; bewust geen lid van `counters` en dus buiten de zesassige nulpoort. */ + drivingPath: XerFidelityAxisCounts; + errors: string[]; + /** De zesassige poort: nul uitlijnfouten, gelijke aantallen en nul afwijkingen. */ + gatePassed: boolean; +} + +export interface XerCorpusFile { + label: string; + bytes: Uint8Array; +} + +export type XerCorpusRole = + | 'oracle' + | 'engine-input' + | 'parser-fixture' + | 'pseudo-xer' + | 'reference-only' + | 'synthetic-fixture'; + +export interface XerCorpusManifestEntry { + sha256: string; + source: string; + role: XerCorpusRole; + included: boolean; + exclusionReason?: string; +} + +export interface XerCorpusManifest { + version: 1; + policy: string; + files: Record; +} + +export interface XerCorpusStats { + scannedFiles: number; + manifestFiles: number; + includedFiles: number; + excludedFiles: number; + byteUniqueFiles: number; + byteDuplicateFiles: number; + /** Ruwe corpusmassa vóór dedup: vier datumassen aanwezig na statussemantiek. */ + fourDateTasks: number; + /** Ruwe corpusmassa vóór dedup: alle zes poortassen aanwezig. */ + sixAxisTasks: number; + /** Zevende rapportage-as vóór dedup; buiten de nulpoort. */ + drivingPathTasks: number; + /** Reviewreconciliatie: byte-unieke bestanden met minstens één as, maar geen alles-zes-taak. */ + partialOnlyByteUniqueFiles: number; + partialOnlyAxisCells: number; + /** Geselecteerde, herkomstvaste bestanden met minstens één effectieve poortas. */ + byteUniqueOracleFiles: number; + schemaDuplicateFiles: number; + uniqueOracleFiles: number; + byteUniqueOracleTasks: number; + uniqueOracleTasks: number; + selectedMeasurable: Record; +} + +export interface XerTargetBaselineResult { + baseline: XerFidelityBaseline; + stats: XerCorpusStats; + errors: string[]; +} + +function asComparable(value: string | number | boolean | null | undefined): string | null | undefined { + if (value === null) return null; + if (value === undefined) return undefined; + return typeof value === 'boolean' ? (value ? 'Y' : 'N') : String(value); +} + +function solvedAxis(task: XerSolvedTask | undefined, axis: XerFidelityAxis): string | number | undefined { + if (!task) return undefined; + switch (axis) { + case 'es': return task.earlyStart; + case 'ef': return task.earlyFinish; + case 'ls': return task.lateStart; + case 'lf': return task.lateFinish; + case 'tf': return task.totalFloatMinutes; + case 'ff': return task.freeFloatMinutes; + } +} + +function axisPair(counts: FidelityCounts): XerFidelityAxisCounts { + return { deviations: counts.deviations, measurable: counts.measurable }; +} + +function measureProject( + projectId: string, + truthTasks: readonly XerGroundTruthTask[], + solved: XerSolvedProject | undefined, + errors: string[], +): XerProjectFidelity { + const solvedById = new Map(); + for (const task of solved?.tasks ?? []) { + if (!task.sourceTaskId.trim()) { + errors.push(`project ${projectId}: opgeloste taak heeft lege sourceTaskId`); + continue; + } + if (!task.taskCode.trim()) { + errors.push(`project ${projectId}/taak ${task.sourceTaskId}: opgeloste taskCode is leeg`); + } + if (solvedById.has(task.sourceTaskId)) { + errors.push(`project ${projectId}: dubbele opgeloste taak-id: ${task.sourceTaskId}`); + } + else solvedById.set(task.sourceTaskId, task); + } + + const truthById = new Map(truthTasks.map(task => [task.taskId, task])); + for (const taskId of [...solvedById.keys()].filter(id => !truthById.has(id)).sort()) { + errors.push(`project ${projectId}: onverwachte opgeloste taak-id: ${taskId}`); + } + for (const taskId of [...truthById.keys()].filter(id => !solvedById.has(id)).sort()) { + errors.push(`project ${projectId}: ontbrekende opgeloste taak-id: ${taskId}`); + } + for (const truthTask of truthTasks) { + const solvedTask = solvedById.get(truthTask.taskId); + if (solvedTask && solvedTask.taskCode.trim() && solvedTask.taskCode !== truthTask.taskCode) { + errors.push( + `project ${projectId}/taak ${truthTask.taskId}: taskCode verwacht ${truthTask.taskCode}, ` + + `kreeg ${solvedTask.taskCode}`, + ); + } + } + + const rows = truthTasks.map(truthTask => { + const ours = solvedById.get(truthTask.taskId); + return compareFidelityRow(truthTask.taskId, Object.fromEntries( + XER_FIDELITY_AXES.map(axis => [axis, { + ours: asComparable(solvedAxis(ours, axis)) ?? undefined, + truth: asComparable(truthTask.axes[axis]) ?? null, + }]), + ) as Record, { + es: classifyMinuteExact, + ef: classifyMinuteExact, + ls: classifyMinuteExact, + lf: classifyMinuteExact, + tf: classifyExact, + ff: classifyExact, + }); + }); + + const counters = emptyCounters(); + for (const axis of XER_FIDELITY_AXES) counters[axis] = axisPair(countFidelityAxis(rows, axis)); + + const drivingRows = truthTasks.map(truthTask => { + const ours = solvedById.get(truthTask.taskId); + return compareFidelityRow(truthTask.taskId, { + driving: { + ours: asComparable(ours?.drivingPath) ?? undefined, + truth: asComparable(truthTask.drivingPath) ?? null, + }, + }, { driving: classifyExact }); + }); + + return { + projectId, + tasks: truthTasks.length, + counters, + drivingPath: axisPair(countFidelityAxis(drivingRows, 'driving')), + }; +} + +function addAxisCounts(target: XerFidelityAxisCounts, source: XerFidelityAxisCounts): void { + target.measurable += source.measurable; + target.deviations += source.deviations; +} + +function isFullOracleTask(task: XerGroundTruthTask): boolean { + return XER_FIDELITY_AXES.every(axis => task.presentAxes[axis]); +} + +function hasOracleAxis(task: XerGroundTruthTask): boolean { + return XER_FIDELITY_AXES.some(axis => task.axes[axis] !== null); +} + +function byteHash(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +/** + * Inhoudsvingerafdruk volgens het plan: project-id-set + taakcodes + orakelwaarden. Geen + * bestandsmetadata, rijvolgorde, whitespace of bytes, zodat inhoudelijk gelijke exports samenvallen. + */ +export function xerSchemaFingerprint(truth: XerGroundTruth): string { + const projects = [...truth.projects].sort().join('\u001f'); + const rows = truth.tasks.map(task => [ + task.projectId, + task.taskId, + task.taskCode, + ...XER_FIDELITY_AXES.map(axis => task.axes[axis] === null ? '\u2400' : task.axes[axis]), + ].join('\u001f')).sort(); + return createHash('sha256').update(`${projects}\u001e${rows.join('\u001e')}`).digest('hex').slice(0, 16); +} + +function targetCountersPerProject(truth: XerGroundTruth): XerFidelityCounters { + const byProject = new Map(); + for (const task of truth.tasks) { + const list = byProject.get(task.projectId) ?? []; + list.push(task); + byProject.set(task.projectId, list); + } + const total = emptyCounters(); + for (const tasks of byProject.values()) { + for (const axis of XER_FIDELITY_AXES) { + const measurable = tasks.reduce((sum, task) => sum + (task.axes[axis] === null ? 0 : 1), 0); + addAxisCounts(total[axis], { deviations: 0, measurable }); + } + } + return total; +} + +function targetEntry(label: string, truth: XerGroundTruth, fingerprint: string): XerFidelityBaselineEntry { + const taskProjects = new Set(truth.tasks.map(task => task.projectId)); + return { + label, + tasks: truth.tasks.length, + projects: taskProjects.size, + counters: targetCountersPerProject(truth), + schemaFingerprint: fingerprint, + }; +} + +/** + * Bouw de nuldoel-baseline uit uitsluitend het onafhankelijke orakel. Dit is nog geen lezermeting: + * X1 levert geen productielezer. De synthetische `measureXerFidelity`-checks hierboven bewaken het + * echte vergelijkpad; een latere taak sluit daar de opgeloste documenten op aan. + */ +export function buildXerTargetBaseline( + files: readonly XerCorpusFile[], + manifest: XerCorpusManifest, +): XerTargetBaselineResult { + const baseline: XerFidelityBaseline = { files: {} }; + const errors: string[] = []; + const stats: XerCorpusStats = { + scannedFiles: files.length, + manifestFiles: Object.keys(manifest.files).length, + includedFiles: Object.values(manifest.files).filter(entry => entry.included).length, + excludedFiles: Object.values(manifest.files).filter(entry => !entry.included).length, + byteUniqueFiles: 0, + byteDuplicateFiles: 0, + fourDateTasks: 0, + sixAxisTasks: 0, + drivingPathTasks: 0, + partialOnlyByteUniqueFiles: 0, + partialOnlyAxisCells: 0, + byteUniqueOracleFiles: 0, + schemaDuplicateFiles: 0, + uniqueOracleFiles: 0, + byteUniqueOracleTasks: 0, + uniqueOracleTasks: 0, + selectedMeasurable: Object.fromEntries(XER_FIDELITY_AXES.map(axis => [axis, 0])) as Record, + }; + const filesByLabel = new Map(); + for (const file of files) { + if (filesByLabel.has(file.label)) errors.push(`dubbel corpuslabel: ${file.label}`); + else filesByLabel.set(file.label, file); + } + for (const label of Object.keys(manifest.files).sort()) { + if (!filesByLabel.has(label)) errors.push(`manifestbestand ontbreekt in corpus: ${label}`); + } + for (const label of [...filesByLabel.keys()].filter(label => !(label in manifest.files)).sort()) { + errors.push(`corpusbestand ontbreekt in manifest: ${label}`); + } + const seenSchemas = new Set(); + const filesByHash = new Map>(); + + for (const file of [...files].sort((a, b) => a.label.localeCompare(b.label))) { + const manifestEntry = manifest.files[file.label]; + if (!manifestEntry) continue; + const fullByteHash = byteHash(file.bytes); + if (manifestEntry.sha256 !== fullByteHash) { + errors.push(`${file.label}: SHA-256 verwacht ${manifestEntry.sha256}, kreeg ${fullByteHash}`); + } + if (manifestEntry.included !== (manifestEntry.role === 'oracle')) { + errors.push(`${file.label}: included moet exact overeenkomen met role=oracle`); + } + if (!manifestEntry.included && !manifestEntry.exclusionReason?.trim()) { + errors.push(`${file.label}: uitgesloten manifestentry mist exclusionReason`); + } + const truth = scanXerGroundTruth(file.bytes); + stats.fourDateTasks += truth.tasks.filter(task => + task.presentAxes.es && task.presentAxes.ef && task.presentAxes.ls && task.presentAxes.lf).length; + stats.sixAxisTasks += truth.tasks.filter(isFullOracleTask).length; + stats.drivingPathTasks += truth.tasks.filter(task => task.drivingPath !== null).length; + + const group = filesByHash.get(fullByteHash) ?? []; + group.push({ file, manifestEntry, truth }); + filesByHash.set(fullByteHash, group); + } + + for (const [fullByteHash, group] of filesByHash) { + stats.byteUniqueFiles++; + stats.byteDuplicateFiles += group.length - 1; + + const oracle = group.find(candidate => + candidate.manifestEntry.included && candidate.manifestEntry.role === 'oracle'); + const selected = oracle ?? group[0]; + const { file, truth } = selected; + + const fullOracleTasks = truth.tasks.filter(isFullOracleTask).length; + const axisTasks = truth.tasks.filter(hasOracleAxis).length; + const axisCells = XER_FIDELITY_AXES.reduce((sum, axis) => + sum + truth.tasks.filter(task => task.axes[axis] !== null).length, 0); + if (axisTasks > 0 && fullOracleTasks === 0) { + stats.partialOnlyByteUniqueFiles++; + stats.partialOnlyAxisCells += axisCells; + } + if (!oracle) continue; + if (truth.errors.length > 0) { + errors.push(...truth.errors.map(error => `${file.label}: ${error}`)); + continue; + } + if (axisTasks === 0) { + errors.push(`${file.label}: als orakel geselecteerd maar geen enkele poortas meetbaar`); + continue; + } + stats.byteUniqueOracleFiles++; + stats.byteUniqueOracleTasks += axisTasks; + + const fingerprint = xerSchemaFingerprint(truth); + if (seenSchemas.has(fingerprint)) { + stats.schemaDuplicateFiles++; + continue; + } + seenSchemas.add(fingerprint); + stats.uniqueOracleFiles++; + stats.uniqueOracleTasks += axisTasks; + const entry = targetEntry(file.label, truth, fingerprint); + for (const axis of XER_FIDELITY_AXES) stats.selectedMeasurable[axis] += entry.counters[axis].measurable; + baseline.files[fullByteHash.slice(0, 16)] = entry; + } + + return { baseline, stats, errors }; +} + +export function validateXerBaselinePins(baseline: XerFidelityBaseline): string[] { + const problems: string[] = []; + Object.values(baseline.files).forEach((entry, index) => { + const hasDeviation = XER_FIDELITY_AXES.some(axis => entry.counters[axis].deviations !== 0); + if (hasDeviation && !entry.reason?.trim()) { + problems.push(`baseline-entry ${index + 1}: niet-nul afwijking vereist een niet-lege reason`); + } + }); + return problems; +} + +/** + * Meet expliciet per project en tel pas daarna op tot het bestand. Er bestaat bewust geen globale + * `truth.tasks.length === solved.tasks.length`-assertie: één XER kan meerdere projecten dragen. + */ +export function measureXerFidelity( + truth: XerGroundTruth, + solvedProjects: readonly XerSolvedProject[], +): XerFileFidelity { + const truthByProject = new Map(); + for (const task of truth.tasks) { + const list = truthByProject.get(task.projectId) ?? []; + list.push(task); + truthByProject.set(task.projectId, list); + } + const errors: string[] = [...truth.errors]; + const solvedByProject = new Map(); + for (const project of solvedProjects) { + if (!project.projectId.trim()) { + errors.push('opgelost project heeft lege projectId'); + continue; + } + if (solvedByProject.has(project.projectId)) { + errors.push(`dubbele opgeloste project-id: ${project.projectId}`); + } else { + solvedByProject.set(project.projectId, project); + } + } + for (const projectId of [...solvedByProject.keys()].filter(id => !truthByProject.has(id)).sort()) { + errors.push(`onverwacht opgelost project-id: ${projectId}`); + } + for (const projectId of [...truthByProject.keys()].filter(id => !solvedByProject.has(id)).sort()) { + errors.push(`ontbrekend opgelost project-id: ${projectId}`); + } + + const projects = [...truthByProject.entries()].map(([projectId, tasks]) => + measureProject(projectId, tasks, solvedByProject.get(projectId), errors)); + + const counters = emptyCounters(); + const drivingPath: XerFidelityAxisCounts = { deviations: 0, measurable: 0 }; + for (const project of projects) { + for (const axis of XER_FIDELITY_AXES) addAxisCounts(counters[axis], project.counters[axis]); + addAxisCounts(drivingPath, project.drivingPath); + } + + const truthTasks = projects.reduce((sum, project) => sum + project.tasks, 0); + const solvedTasks = solvedProjects.reduce((sum, project) => sum + project.tasks.length, 0); + const truthProjects = truthByProject.size; + const solvedProjectCount = solvedProjects.length; + const hasDeviations = XER_FIDELITY_AXES.some(axis => counters[axis].deviations !== 0); + + return { + truthProjects, + solvedProjects: solvedProjectCount, + truthTasks, + solvedTasks, + tasks: truthTasks, + projects, + counters, + drivingPath, + errors, + gatePassed: errors.length === 0 + && truthProjects === solvedProjectCount + && truthTasks === solvedTasks + && !hasDeviations, + }; +} diff --git a/tests/planning/xerFidelityTypes.ts b/tests/planning/xerFidelityTypes.ts new file mode 100644 index 000000000..f5ce28748 --- /dev/null +++ b/tests/planning/xerFidelityTypes.ts @@ -0,0 +1,105 @@ +/** + * De XER-fidelity-baselinevorm (X0, XER-etappeplan §3/§6-X0). Dit bestand legt uitsluitend de + * STRUCTUUR vast — geen meetkern, geen lezer, geen dedup-logica. Dat is allemaal X1-werk (de + * meetlat-baan): X1 heeft de formaat-agnostische kern uit `measureFidelity` gehaald + * (`fidelityCore.ts`), plus de onafhankelijke `xerGroundTruth.ts`, de per-project-lus en de + * byte-hash + schema-vingerafdruk-dedup uit §2 (het Hotel-paar: twee bestanden met dezelfde + * proj_id-set/taakcodes/orakelwaarden in net andere bytes). + * + * ZES ASSEN, TWEE TELLERS PER AS (§3) — anders dan de .mpp-baseline (`mpp-fidelity-baseline.json`, + * drie emmers exact/sameday/diff per as): de XER-meetlat is uniform MINUUT-EXACT (§1-granulariteit + * — élk orakelbestand wordt in uurmodus gelezen), dus er is geen "zelfde dag, andere kloktijd"- + * tussencategorie zoals bij een dag-vs-uur-gemengd .mpp-corpus. Per as is het dus binair: een + * meting wijkt af, of niet — vandaar `Deviations`/`Measurable` i.p.v. `Exact`/`Sameday`/`Diff`. De + * assen: ES/EF/LS/LF (datums) + TF/FF (float, via de formule uit plan §1: + * `ons_float_in_minuten === p6_float_uren × 60`; fractionele minuten blijven fractioneel). + * + * `Measurable` bestaat APART van `Deviations` omdat de orakeldekking scheef is (planreview M1, + * her-check-meting): na herkomstselectie en beide deduplagen zijn ES/EF/LS/LF/TF/FF respectievelijk + * 13.931/13.937/13.822/13.813/13.677/13.322 keer meetbaar — sommige bestanden dragen alleen float + * en geen datums, en andersom. Beide tellers worden gepind (X1): een lezer die stilletjes MINDER + * gaat meten moet de suite even rood maken als een lezer die FOUT meet. + */ + +/** De dubbele teller van één as: hoeveel taken op deze as gemeten zijn (`measurable`), en hoeveel + * daarvan afweken van het P6-orakel (`deviations`, per definitie `<= measurable`). */ +export interface XerFidelityAxisCounts { + deviations: number; + measurable: number; +} + +/** Alle zes assen — het "zes afwijkings- + zes meetbaar-tellers"-blok uit plan §3, met de + * P6-veldnamen als sleutel (dezelfde as-benamingen als in het plandocument: early/late start/ + * finish + total/free float). */ +export interface XerFidelityCounters { + es: XerFidelityAxisCounts; // early start + ef: XerFidelityAxisCounts; // early finish + ls: XerFidelityAxisCounts; // late start + lf: XerFidelityAxisCounts; // late finish + tf: XerFidelityAxisCounts; // total float + ff: XerFidelityAxisCounts; // free float +} + +/** Scannerfeiten die buiten de minuutvergelijking vallen maar nooit stil verloren mogen gaan. + * Datums blijven op de vier productassen minuut-exact; aanwezigheid en inhoud van een eventuele + * secondencomponent worden afzonderlijk geteld. Float behoudt de exacte numerieke minuutwaarde. */ +export interface XerScannerPrecisionFacts { + dateSecondCells: { es: number; ef: number; ls: number; lf: number }; + dateNonZeroSubminuteCells: { es: number; ef: number; ls: number; lf: number }; + floatFractionalMinuteCells: { tf: number; ff: number }; +} + +/** Één as, nul metingen — de "leeg maar welgevormd"-bouwsteen voor het harness-skelet hieronder. */ +export function emptyAxisCounts(): XerFidelityAxisCounts { + return { deviations: 0, measurable: 0 }; +} + +/** Alle zes assen, nul metingen. */ +export function emptyCounters(): XerFidelityCounters { + return { + es: emptyAxisCounts(), ef: emptyAxisCounts(), + ls: emptyAxisCounts(), lf: emptyAxisCounts(), + tf: emptyAxisCounts(), ff: emptyAxisCounts(), + }; +} + +/** + * Eén gepind bestand. In X1 zijn `tasks`/`projects` de onafhankelijke WAARHEIDSTELLINGEN over de + * taakdragende projecten; vanaf X4a moeten de apart gemeten adaptertellingen daar exact aan gelijk + * zijn. Lege PROJECT-rijen blijven wel onderdeel van `schemaFingerprint`, maar niet van deze som. + */ +export interface XerFidelityBaselineEntry { + /** Corpus is publiek (plan §4.3/§2) — een leesbaar relatief pad mag hier altijd, in + * tegenstelling tot `mpp-fidelity-baseline.json`'s hash-only-regime voor `OPS_MPP_CORPUS` + * (échte bedrijfsbestanden, die het XER-corpus niet kent). */ + label: string; + tasks: number; + projects: number; + counters: XerFidelityCounters; + /** Voorbereide baselinevorm voor het aparte precisiefeit. In D4/A-C wordt dit corpusloos gepind; + * de bestaande corpusbaseline wordt pas in de expliciete metadata-migratieronde bijgewerkt. */ + precision?: XerScannerPrecisionFacts; + /** OPTIONEEL — schema-vingerafdruk voor het §2-dedupmechanisme (het Hotel-paar-geval: geen + * byte-hash-duplicaat, wel dezelfde proj_id-set/taakcodes/orakelwaarden). De dedup-LOGICA zelf + * is X1-werk; dit veld legt alleen de PLEK in de baseline-vorm vast zodat X1 'm niet zelf hoeft + * te verzinnen. */ + schemaFingerprint?: string; + /** OPTIONEEL, maar VERPLICHT zodra minstens één as een afwijking draagt (zelfde §T15-precedent + * als `mpp-fidelity-baseline.json`'s `reason`-veld) — een geschreven, gemeten reden, geen stille + * pin. De poort die dit afdwingt is X1-werk (de `hasDeviation`-analoog van `hasDiff` in + * `check-mpp-fidelity.ts`); dit bestand legt alleen het VELD vast. */ + reason?: string; +} + +/** Sleutel = SHA-256 van de bestandsbytes (eerste 16 hex-tekens) — zelfde dedup-anker als + * `mpp-fidelity-baseline.json`. Byte-identieke duplicaten (§2: twee Harbour Point-bestanden 4×, + * drie 2×) delen zo automatisch één entry. */ +export interface XerFidelityBaseline { + files: Record; +} + +/** HET HARNESS-SKELET (X0-acceptatie): de lege, welgevormde bouwsteen blijft als schemafixture + * bestaan. De gecommitte `xer-fidelity-baseline.json` is sinds X1 gevuld uit het publieke corpus. */ +export function createEmptyXerFidelityBaseline(): XerFidelityBaseline { + return { files: {} }; +} diff --git a/tests/planning/xerGroundTruth.ts b/tests/planning/xerGroundTruth.ts new file mode 100644 index 000000000..d1fbb8a0d --- /dev/null +++ b/tests/planning/xerGroundTruth.ts @@ -0,0 +1,450 @@ +/** + * Onafhankelijke XER-grondwaarheidscan voor de fidelitymeetlat. + * + * Dit is bewust een minimale tweede parser: geen import uit `src/services/xer`, geen gedeelde + * tokenizer, veldkaart, datumparser of encodinghelper. De latere productielezer kan daardoor niet + * tegelijk met zijn eigen meetlat dezelfde fout gaan maken. Alleen TASK-%T/%F/%R en de expliciet + * toegestane P6-orakelvelden worden gelezen. + */ + +import type { XerScannerPrecisionFacts } from './xerFidelityTypes'; + +export type XerGroundTruthEncoding = 'utf-8' | 'utf-16le' | 'utf-16be' | 'windows-1252'; +export type XerFidelityAxis = 'es' | 'ef' | 'ls' | 'lf' | 'tf' | 'ff'; +export type XerDateFidelityAxis = 'es' | 'ef' | 'ls' | 'lf'; + +export const XER_FIDELITY_AXES: readonly XerFidelityAxis[] = ['es', 'ef', 'ls', 'lf', 'tf', 'ff']; + +export interface XerGroundTruthTask { + projectId: string; + taskId: string; + taskCode: string; + statusCode: string; + axes: Record; + /** Ruwe secondencomponent per datumas (`null` = de broncel had geen secondencomponent). */ + rawDateSeconds: Record; + drivingPath: boolean | null; + /** Niet-lege effectieve broncellen na statussemantiek; parsefouten staan apart in `errors`. */ + presentAxes: Record; +} + +export interface XerGroundTruth { + encoding: XerGroundTruthEncoding; + projects: Set; + tasks: XerGroundTruthTask[]; + /** Niet-lege onparseerbare waarden en ontbrekende verplichte identiteit zijn fataal. */ + errors: string[]; + /** Niet-fatale expliciete formaatkeuzes die de onafhankelijke scanner niet stil mag maken. */ + numberFormatIssues: string[]; + /** Apart gepinde scannerfeiten; geen van deze waarden wordt product- of solverinvoer. */ + precision: XerScannerPrecisionFacts; +} + +const IDENTITY_FIELDS = ['proj_id', 'task_id', 'task_code'] as const; +const KNOWN_STATUS_CODES = new Set(['tk_notstart', 'tk_active', 'tk_complete']); + +interface XerNumberFormat { + decimal: '.' | ','; + group: '.' | ',' | null; + fromCurrencyTable: boolean; +} + +function decodeXer(bytes: Uint8Array): { text: string; encoding: XerGroundTruthEncoding } { + if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) { + return { text: new TextDecoder('utf-16le').decode(bytes.subarray(2)), encoding: 'utf-16le' }; + } + if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) { + return { text: new TextDecoder('utf-16be').decode(bytes.subarray(2)), encoding: 'utf-16be' }; + } + const withoutUtf8Bom = bytes.length >= 3 + && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf + ? bytes.subarray(3) + : bytes; + try { + return { + text: new TextDecoder('utf-8', { fatal: true }).decode(withoutUtf8Bom), + encoding: 'utf-8', + }; + } catch { + return { + text: new TextDecoder('windows-1252').decode(bytes), + encoding: 'windows-1252', + }; + } +} + +/** Canonieke minuutstring; leeg is niet meetbaar, niet-leeg maar ongeldig is een scannerfout. */ +function parseOracleDate( + raw: string | undefined, + taskId: string, + field: string, + errors: string[], +): { minute: string | null; rawSeconds: string | null } { + const value = raw?.trim() ?? ''; + if (!value || value === '0') return { minute: null, rawSeconds: null }; + const match = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?$/.exec(value); + if (!match) { + errors.push(`TASK ${taskId}/${field}: ongeldige datum ${JSON.stringify(value)}`); + return { minute: null, rawSeconds: null }; + } + const [, year, month, day, rawHour, rawMinute, rawSecond, rawFraction] = match; + const hour = rawHour ?? '00'; + const minute = rawMinute ?? '00'; + if (rawSecond !== undefined && Number(rawSecond) > 59) { + errors.push(`TASK ${taskId}/${field}: ongeldige datum ${JSON.stringify(value)}`); + return { minute: null, rawSeconds: null }; + } + const date = new Date(`${year}-${month}-${day}T${hour}:${minute}:00Z`); + if (Number.isNaN(date.getTime()) + || date.toISOString().slice(0, 16) !== `${year}-${month}-${day}T${hour}:${minute}`) { + errors.push(`TASK ${taskId}/${field}: ongeldige datum ${JSON.stringify(value)}`); + return { minute: null, rawSeconds: null }; + } + return { + minute: `${year}-${month}-${day}T${hour}:${minute}`, + rawSeconds: rawSecond === undefined + ? null + : `${rawSecond}${rawFraction === undefined ? '' : `.${rawFraction}`}`, + }; +} + +function inspectNumberSymbol( + raw: string, + family: 'decimal' | 'group', + field: string, + currency: string, + errors: string[], +): '.' | ',' | undefined { + const value = raw.trim().toLowerCase(); + if (!value) return undefined; + if (value === '.' || value === 'period') return '.'; + if (value === ',' || value === 'comma') return ','; + if (value.startsWith('ds_')) { + if (family !== 'decimal') { + errors.push(`CURRTYPE ${currency}/${field}: ds-token hoort niet in de groepsfamilie`); + return undefined; + } + if (value === 'ds_period') return '.'; + if (value === 'ds_comma') return ','; + } + if (value.startsWith('dg_')) { + if (family !== 'group') { + errors.push(`CURRTYPE ${currency}/${field}: dg-token hoort niet in de decimaalfamilie`); + return undefined; + } + if (value === 'dg_period') return '.'; + if (value === 'dg_comma') return ','; + } + errors.push(`CURRTYPE ${currency}/${field}: onbekende separator ${JSON.stringify(raw.trim())}`); + return undefined; +} + +function inspectNumberFamily( + row: ReadonlyMap, + family: 'decimal' | 'group', + currency: string, + errors: string[], +): '.' | ',' | null { + const fields = family === 'decimal' + ? ['decimal_symbol', 'decimal_symbol_type'] + : ['digit_group_symbol', 'digit_group_symbol_type']; + const declared = fields.filter(field => !!row.get(field)?.trim()); + const values = declared + .map(field => inspectNumberSymbol(row.get(field) ?? '', family, field, currency, errors)) + .filter((value): value is '.' | ',' => value !== undefined); + if (new Set(values).size > 1) { + errors.push(`CURRTYPE ${currency}/${family}: tegenstrijdige separatorrepresentaties`); + return null; + } + if (declared.length === 0 && family === 'decimal') { + errors.push(`CURRTYPE ${currency}/decimal: ontbrekende separatorrepresentatie`); + } + return values[0] ?? null; +} + +function scanNumberFormat( + lines: readonly string[], + errors: string[], + issues: string[], +): XerNumberFormat { + const header = lines.find(line => line.startsWith('ERMHDR\t'))?.split('\t') ?? []; + const defaultCurrency = header[8]?.trim() ?? ''; + let table = ''; + let fields: string[] = []; + const currencyRows: Array<{ values: Map; line: number }> = []; + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const line = lines[lineIndex]; + const cells = line.split('\t'); + const marker = cells[0]?.trim(); + if (marker === '%E') break; + if (marker === '%T') { + table = cells[1]?.trim().toUpperCase() ?? ''; + fields = []; + continue; + } + if (table !== 'CURRTYPE') continue; + if (marker === '%F') { + fields = cells.slice(1).map(field => field.trim()); + continue; + } + if (marker !== '%R' || fields.length === 0) continue; + const row = new Map(); + const values = cells.slice(1); + for (let index = 0; index < fields.length; index++) row.set(fields[index], values[index] ?? ''); + currencyRows.push({ values: row, line: lineIndex + 1 }); + } + if (currencyRows.length === 0) return { decimal: '.', group: null, fromCurrencyTable: false }; + const normalizedCurrency = defaultCurrency.toLowerCase(); + const matches = normalizedCurrency + ? currencyRows.filter(candidate => + candidate.values.get('curr_short_name')?.trim().toLowerCase() === normalizedCurrency) + : []; + if (matches.length === 0) { + issues.push( + `CURRTYPE: ERMHDR-valuta ${defaultCurrency || '(leeg)'} heeft geen overeenkomstige rij; ` + + 'punt-default gebruikt', + ); + return { decimal: '.', group: null, fromCurrencyTable: false }; + } + const resolved = matches.map(match => { + const currency = match.values.get('curr_short_name')?.trim() || defaultCurrency; + const decimal = inspectNumberFamily(match.values, 'decimal', currency, errors); + const group = inspectNumberFamily(match.values, 'group', currency, errors); + if (decimal === null) return null; + if (group === decimal) { + errors.push('CURRTYPE: decimaal- en groepsteken zijn gelijk'); + return null; + } + return { decimal, group, line: match.line }; + }); + if (resolved.some(format => format === null)) { + return { decimal: '.', group: null, fromCurrencyTable: true }; + } + const first = resolved[0]!; + const conflict = resolved.find(format => + format!.decimal !== first.decimal || format!.group !== first.group); + if (conflict) { + errors.push( + `CURRTYPE ${defaultCurrency}: regels ${first.line}, ${conflict.line} ` + + 'hebben tegenstrijdige decimaal-/groepssemantiek', + ); + } + return { decimal: first.decimal, group: first.group, fromCurrencyTable: true }; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** P6-float staat in uren; vermenigvuldigen met 60 behoudt halve/fractionele minuten. Er is + * bewust geen integerafronding of epsilonpad. */ +function parseFloatMinutes( + raw: string | undefined, + taskId: string, + field: string, + format: XerNumberFormat, + errors: string[], +): number | null { + const value = raw?.trim() ?? ''; + if (!value) return null; + const decimal = escapeRegExp(format.decimal); + const group = format.group === null ? null : escapeRegExp(format.group); + const integer = group === null ? '\\d+' : `(?:\\d+|\\d{1,3}(?:${group}\\d{3})+)`; + const pattern = new RegExp(`^[+-]?${integer}(?:${decimal}\\d+)?$`); + if (!pattern.test(value)) { + errors.push(`TASK ${taskId}/${field}: ongeldig getal ${JSON.stringify(value)}`); + return null; + } + let normalized = value; + if (format.group !== null) normalized = normalized.split(format.group).join(''); + if (format.decimal === ',') normalized = normalized.replace(',', '.'); + const hours = Number(normalized); + if (!Number.isFinite(hours)) { + errors.push(`TASK ${taskId}/${field}: ongeldig getal ${JSON.stringify(value)}`); + return null; + } + return hours * 60; +} + +function parseDrivingPath( + raw: string | undefined, + taskId: string, + errors: string[], +): boolean | null { + const value = raw?.trim().toUpperCase() ?? ''; + if (!value) return null; + if (value === 'Y') return true; + if (value === 'N') return false; + errors.push(`TASK ${taskId}/driving_path_flag: ongeldige vlag ${JSON.stringify(raw?.trim())}`); + return null; +} + +function buildTask( + fields: readonly string[], + values: readonly string[], + rowNumber: number, + format: XerNumberFormat, + errors: string[], +): XerGroundTruthTask | null { + const row = new Map(); + for (let index = 0; index < fields.length; index++) row.set(fields[index], values[index] ?? ''); + for (const field of IDENTITY_FIELDS) { + if (!row.has(field)) errors.push(`TASK %F/${field}: ontbrekend verplicht veld`); + } + if (!IDENTITY_FIELDS.every(field => row.has(field))) return null; + + const projectId = row.get('proj_id')?.trim() ?? ''; + const taskId = row.get('task_id')?.trim() ?? ''; + const taskCode = row.get('task_code')?.trim() ?? ''; + let missingIdentity = false; + if (!projectId) { + errors.push(`TASK rij ${rowNumber}/proj_id: ontbrekende waarde`); + missingIdentity = true; + } + if (!taskId) { + errors.push(`TASK rij ${rowNumber}/task_id: ontbrekende waarde`); + missingIdentity = true; + } + if (!taskCode) { + errors.push(`TASK rij ${rowNumber}/task_code: ontbrekende waarde`); + missingIdentity = true; + } + if (missingIdentity) return null; + + const statusCode = row.get('status_code')?.trim() ?? ''; + const normalizedStatus = statusCode.toLowerCase(); + if (statusCode && !KNOWN_STATUS_CODES.has(normalizedStatus)) { + errors.push(`TASK ${taskId}/status_code: onbekende waarde ${JSON.stringify(statusCode)}`); + } + // Deze scanner is uitsluitend de rauwe XER-meetlat. Ook op een completed activiteit zijn + // early/late/float opgeslagen P6-uitvoer, nooit reader- of solverinvoer. Actuals blijven + // afzonderlijke toegestane bronvelden in de productielezer; ze normaliseren het orakel niet. + const start = parseOracleDate(row.get('early_start_date'), taskId, 'early_start_date', errors); + const finish = parseOracleDate(row.get('early_end_date'), taskId, 'early_end_date', errors); + const lateStart = parseOracleDate(row.get('late_start_date'), taskId, 'late_start_date', errors); + const lateFinish = parseOracleDate(row.get('late_end_date'), taskId, 'late_end_date', errors); + + return { + projectId, + taskId, + taskCode, + statusCode, + axes: { + es: start.minute, + ef: finish.minute, + ls: lateStart.minute, + lf: lateFinish.minute, + tf: parseFloatMinutes(row.get('total_float_hr_cnt'), taskId, 'total_float_hr_cnt', format, errors), + ff: parseFloatMinutes(row.get('free_float_hr_cnt'), taskId, 'free_float_hr_cnt', format, errors), + }, + rawDateSeconds: { + es: start.rawSeconds, + ef: finish.rawSeconds, + ls: lateStart.rawSeconds, + lf: lateFinish.rawSeconds, + }, + drivingPath: parseDrivingPath(row.get('driving_path_flag'), taskId, errors), + presentAxes: { + es: start.minute !== null, + ef: finish.minute !== null, + ls: lateStart.minute !== null, + lf: lateFinish.minute !== null, + tf: !!row.get('total_float_hr_cnt')?.trim(), + ff: !!row.get('free_float_hr_cnt')?.trim(), + }, + }; +} + +export function scanXerGroundTruth(bytes: Uint8Array): XerGroundTruth { + const { text, encoding } = decodeXer(bytes); + const lines = text.split('\n').map(rawLine => rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine); + const tasks: XerGroundTruthTask[] = []; + const declaredProjects = new Set(); + const taskProjects = new Set(); + const errors: string[] = []; + const numberFormatIssues: string[] = []; + const numberFormat = scanNumberFormat(lines, errors, numberFormatIssues); + let table = ''; + let fields: string[] = []; + let taskRowNumber = 0; + let projectRowNumber = 0; + let sawProjectTable = false; + const seenTaskIds = new Set(); + + for (const line of lines) { + const cells = line.split('\t'); + const marker = cells[0]?.trim(); + if (marker === '%E') break; + if (marker === '%T') { + table = cells[1]?.trim().toUpperCase() ?? ''; + if (table === 'PROJECT') sawProjectTable = true; + fields = []; + continue; + } + if (marker === '%F') { + fields = cells.slice(1).map(field => field.trim()); + continue; + } + if (marker !== '%R' || fields.length === 0) continue; + if (table === 'PROJECT') { + projectRowNumber++; + const projectIdIndex = fields.indexOf('proj_id'); + if (projectIdIndex < 0) { + errors.push('PROJECT %F/proj_id: ontbrekend verplicht veld'); + continue; + } + const projectId = cells[projectIdIndex + 1]?.trim() ?? ''; + if (!projectId) errors.push(`PROJECT rij ${projectRowNumber}/proj_id: ontbrekende waarde`); + else if (declaredProjects.has(projectId)) errors.push(`PROJECT rij ${projectRowNumber}: dubbele proj_id ${projectId}`); + else declaredProjects.add(projectId); + continue; + } + if (table !== 'TASK') continue; + taskRowNumber++; + const task = buildTask(fields, cells.slice(1), taskRowNumber, numberFormat, errors); + if (!task) continue; + const taskKey = `${task.projectId}\u0000${task.taskId}`; + if (seenTaskIds.has(taskKey)) { + errors.push(`TASK rij ${taskRowNumber}: dubbele task_id ${task.projectId}/${task.taskId}`); + } else { + seenTaskIds.add(taskKey); + } + tasks.push(task); + taskProjects.add(task.projectId); + } + + if (sawProjectTable) { + for (const task of tasks) { + if (!declaredProjects.has(task.projectId)) { + errors.push(`TASK ${task.taskId}/proj_id: niet aanwezig in PROJECT-set (${task.projectId})`); + } + } + } + const dateAxes: readonly XerDateFidelityAxis[] = ['es', 'ef', 'ls', 'lf']; + const dateSecondCells = { es: 0, ef: 0, ls: 0, lf: 0 }; + const dateNonZeroSubminuteCells = { es: 0, ef: 0, ls: 0, lf: 0 }; + for (const task of tasks) { + for (const axis of dateAxes) { + const seconds = task.rawDateSeconds[axis]; + if (seconds === null) continue; + dateSecondCells[axis]++; + if (Number(seconds) !== 0) dateNonZeroSubminuteCells[axis]++; + } + } + const precision: XerScannerPrecisionFacts = { + dateSecondCells, + dateNonZeroSubminuteCells, + floatFractionalMinuteCells: { + tf: tasks.filter(task => typeof task.axes.tf === 'number' && !Number.isInteger(task.axes.tf)).length, + ff: tasks.filter(task => typeof task.axes.ff === 'number' && !Number.isInteger(task.axes.ff)).length, + }, + }; + return { + encoding, + projects: sawProjectTable ? declaredProjects : taskProjects, + tasks, + errors, + numberFormatIssues, + precision, + }; +} diff --git a/tests/planning/xerProductBaselineV2.ts b/tests/planning/xerProductBaselineV2.ts new file mode 100644 index 000000000..f1f511b58 --- /dev/null +++ b/tests/planning/xerProductBaselineV2.ts @@ -0,0 +1,570 @@ +import { createHash } from 'node:crypto'; +import { gunzipSync, gzipSync } from 'node:zlib'; + +export const PRODUCT_AXES = ['es', 'ef', 'ls', 'lf', 'tf', 'ff'] as const; +export const PRODUCT_REPORT_MODES = [ + 'strict-minute-exact', + 'historical-completed-late', + 'source-day-precision', +] as const; +export const PRODUCT_OPEN_CATEGORIES = [ + 'strict-six-axis-deviations', + 'strict-sameday-deviations', + 'driving-path-report-only', +] as const; + +export type ProductAxis = (typeof PRODUCT_AXES)[number]; +export type ProductReportMode = (typeof PRODUCT_REPORT_MODES)[number]; + +export interface ProductCountsV2 { + exact: number; + sameday: number; + diff: number; + missing: number; + measurable: number; + deviations: number; +} + +export interface ProductProjectV2 { + projectId: string; + truthTasks: number; + solvedTasks: number; + taskCodePresent: number; + taskCodeExact: number; + counters: Record; + drivingPath: ProductCountsV2; + identityErrors: string[]; + projectionSha256: string; +} + +export interface ProductIdentityCoverageV2 { + solvedTasks: number; + taskCodePresent: number; + taskCodeExact: number; +} + +export interface ProductEntryV2 { + sha256: string; + schemaFingerprint: string; + projects: number; + tasks: number; + identityCoverage: ProductIdentityCoverageV2; + projectMeasurements: ProductProjectV2[]; + projectProjectionSha256: string; + counters: Record; + drivingPath: ProductCountsV2; + identityErrors: string[]; + scannerErrors: string[]; + gatePassed: boolean; +} + +export interface ProductCharacterizationV2 { + finalZeroGate: 'red'; + accepted: false; + openCategories: typeof PRODUCT_OPEN_CATEGORIES; +} + +export interface ProductBaselineV2 { + version: 2; + manifestSha256: string; + characterization: ProductCharacterizationV2; + files: Record; +} + +export interface ProductEnvelopeV2 { + version: 2; + manifestSha256: string; + characterization: ProductCharacterizationV2; + reportModes: typeof PRODUCT_REPORT_MODES; + encoding: 'gzip-base64-json'; + canonicalization: { + json: 'JSON.stringify(value, null, 2) + LF'; + gzip: 'gzip level 9, deterministic header'; + }; + payloadSha256: string; + payloadGzipSha256: string; + projectProjectionSha256: string; + payloadGzipBase64: string; +} + +export interface ProductValidationPins { + manifestSha256: string; + entries: Readonly>; + projects: number; + tasks: number; + counters: Record; + drivingPath: ProductCountsV2; + payloadSha256?: string; + payloadGzipSha256?: string; + projectProjectionSha256?: string; +} + +export interface ProductValidationResult { + problems: string[]; + envelope?: ProductEnvelopeV2; + payload?: ProductBaselineV2; +} + +type ProductProjectDraft = Omit & { projectionSha256?: string }; +type ProductEntryDraft = Omit & { + projectMeasurements: ProductProjectDraft[]; + projectProjectionSha256?: string; +}; +export type ProductBaselineDraft = Omit & { + files: Record; +}; + +const HEX_64 = /^[0-9a-f]{64}$/; +const ENVELOPE_KEYS = [ + 'version', 'manifestSha256', 'characterization', 'reportModes', 'encoding', 'canonicalization', + 'payloadSha256', 'payloadGzipSha256', 'projectProjectionSha256', 'payloadGzipBase64', +] as const; +const CANONICALIZATION_KEYS = ['json', 'gzip'] as const; +const CHARACTERIZATION_KEYS = ['finalZeroGate', 'accepted', 'openCategories'] as const; +const PAYLOAD_KEYS = ['version', 'manifestSha256', 'characterization', 'files'] as const; +const ENTRY_KEYS = [ + 'sha256', 'schemaFingerprint', 'projects', 'tasks', 'identityCoverage', 'projectMeasurements', + 'projectProjectionSha256', 'counters', 'drivingPath', 'identityErrors', 'scannerErrors', 'gatePassed', +] as const; +const PROJECT_KEYS = [ + 'projectId', 'truthTasks', 'solvedTasks', 'taskCodePresent', 'taskCodeExact', 'counters', + 'drivingPath', 'identityErrors', 'projectionSha256', +] as const; +const IDENTITY_KEYS = ['solvedTasks', 'taskCodePresent', 'taskCodeExact'] as const; +const COUNTS_KEYS = ['exact', 'sameday', 'diff', 'missing', 'measurable', 'deviations'] as const; +const FORBIDDEN_NORMALIZED_KEYS = new Set([ + 'taskname', 'tasknames', 'taskcode', 'taskcodes', 'taskcodelist', 'taskcodelists', + 'individualtaskcode', 'individualtaskcodes', 'individualtaskcodelist', + 'reason', 'reasons', 'reasonnote', 'reasonnotes', + 'accepteddeviation', 'accepteddeviations', 'acceptance', 'acceptatie', 'acceptatieveld', + 'privatecustomername', 'customername', +]); + +function objectValue(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : null; +} + +function nonNegativeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0; +} + +function issue(problems: string[], message: string): void { + problems.push(message); +} + +function equal(problems: string[], label: string, got: unknown, expected: unknown): void { + if (JSON.stringify(got) !== JSON.stringify(expected)) { + issue(problems, `${label}: verwacht ${JSON.stringify(expected)}, kreeg ${JSON.stringify(got)}`); + } +} + +function equalTextBytes(problems: string[], label: string, got: string, expected: string): void { + if (got !== expected) { + issue(problems, `${label}: SHA-256 ${productSha256(got)} != ${productSha256(expected)}`); + } +} + +function exactKeys( + problems: string[], + path: string, + value: unknown, + expected: readonly string[], +): value is Record { + const object = objectValue(value); + if (!object) { + issue(problems, `${path}: object verwacht`); + return false; + } + equal(problems, `${path}: exacte keyset`, Object.keys(object).sort(), [...expected].sort()); + return true; +} + +function rejectForbiddenKeys(value: unknown, path: string, problems: string[]): void { + if (Array.isArray(value)) { + value.forEach((item, index) => rejectForbiddenKeys(item, `${path}[${index}]`, problems)); + return; + } + const object = objectValue(value); + if (!object) return; + for (const [key, nested] of Object.entries(object)) { + const normalized = key.toLowerCase().replace(/[\s_-]+/g, ''); + if (FORBIDDEN_NORMALIZED_KEYS.has(normalized)) { + issue(problems, `${path}: verboden privacy-/acceptatieveld ${JSON.stringify(key)}`); + } + rejectForbiddenKeys(nested, `${path}.${key}`, problems); + } +} + +export function canonicalProductJson(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +export function productSha256(value: string | Uint8Array): string { + return createHash('sha256').update(value).digest('hex'); +} + +function canonicalGzip(value: string): Buffer { + return gzipSync(Buffer.from(value, 'utf8'), { level: 9 }); +} + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function projectProjection( + project: ProductProjectDraft | ProductProjectV2, +): Omit { + return { + projectId: project.projectId, + truthTasks: project.truthTasks, + solvedTasks: project.solvedTasks, + taskCodePresent: project.taskCodePresent, + taskCodeExact: project.taskCodeExact, + counters: project.counters, + drivingPath: project.drivingPath, + identityErrors: project.identityErrors, + }; +} + +function projectProjectionDigest(projects: readonly ProductProjectV2[]): string { + return productSha256(canonicalProductJson(projects.map(project => ({ + projectId: project.projectId, + projectionSha256: project.projectionSha256, + })))); +} + +export function productProjectionDigest(payload: ProductBaselineV2): string { + return productSha256(canonicalProductJson(Object.entries(payload.files) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([sha256, entry]) => ({ sha256, projectProjectionSha256: entry.projectProjectionSha256 })))); +} + +export function sealProductBaseline(payload: ProductBaselineDraft | ProductBaselineV2): ProductBaselineV2 { + const files = Object.fromEntries(Object.entries(payload.files) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([sha256, rawEntry]) => { + const entry = clone(rawEntry) as ProductEntryDraft; + const projects = entry.projectMeasurements + .map(rawProject => { + const project = clone(rawProject); + return { + ...projectProjection(project), + projectionSha256: productSha256(canonicalProductJson(projectProjection(project))), + } as ProductProjectV2; + }) + .sort((left, right) => left.projectId.localeCompare(right.projectId)); + return [sha256, { + sha256: entry.sha256, + schemaFingerprint: entry.schemaFingerprint, + projects: entry.projects, + tasks: entry.tasks, + identityCoverage: entry.identityCoverage, + projectMeasurements: projects, + projectProjectionSha256: projectProjectionDigest(projects), + counters: entry.counters, + drivingPath: entry.drivingPath, + identityErrors: entry.identityErrors, + scannerErrors: entry.scannerErrors, + gatePassed: entry.gatePassed, + } satisfies ProductEntryV2]; + })); + return { + version: 2, + manifestSha256: payload.manifestSha256, + characterization: { + finalZeroGate: 'red', + accepted: false, + openCategories: PRODUCT_OPEN_CATEGORIES, + }, + files, + }; +} + +export function createProductEnvelope(payloadInput: ProductBaselineDraft | ProductBaselineV2): ProductEnvelopeV2 { + const payload = sealProductBaseline(payloadInput); + const payloadText = canonicalProductJson(payload); + const compressed = canonicalGzip(payloadText); + return { + version: 2, + manifestSha256: payload.manifestSha256, + characterization: payload.characterization, + reportModes: PRODUCT_REPORT_MODES, + encoding: 'gzip-base64-json', + canonicalization: { + json: 'JSON.stringify(value, null, 2) + LF', + gzip: 'gzip level 9, deterministic header', + }, + payloadSha256: productSha256(payloadText), + payloadGzipSha256: productSha256(compressed), + projectProjectionSha256: productProjectionDigest(payload), + payloadGzipBase64: compressed.toString('base64'), + }; +} + +export function canonicalProductEnvelope(payload: ProductBaselineDraft | ProductBaselineV2): string { + return canonicalProductJson(createProductEnvelope(payload)); +} + +export function decodeProductEnvelopeUnchecked(envelope: { payloadGzipBase64: string }): ProductBaselineV2 { + return JSON.parse(gunzipSync(Buffer.from(envelope.payloadGzipBase64, 'base64')).toString('utf8')) as ProductBaselineV2; +} + +function validateStringArray(problems: string[], path: string, value: unknown, mustBeEmpty: boolean): value is string[] { + if (!Array.isArray(value) || !value.every(item => typeof item === 'string')) { + issue(problems, `${path}: stringarray verwacht`); + return false; + } + if (mustBeEmpty && value.length !== 0) issue(problems, `${path}: moet leeg zijn`); + return true; +} + +function validateCounts( + problems: string[], + path: string, + value: unknown, + denominator: number, +): value is ProductCountsV2 { + if (!exactKeys(problems, path, value, COUNTS_KEYS)) return false; + const counts = value as unknown as ProductCountsV2; + for (const key of COUNTS_KEYS) { + if (!nonNegativeInteger(counts[key])) issue(problems, `${path}.${key}: niet-negatief geheel getal verwacht`); + } + if (!COUNTS_KEYS.every(key => nonNegativeInteger(counts[key]))) return false; + if (counts.exact + counts.sameday + counts.diff + counts.missing !== counts.measurable) { + issue(problems, `${path}: exact+sameday+diff+missing moet measurable zijn`); + } + if (counts.sameday + counts.diff + counts.missing !== counts.deviations) { + issue(problems, `${path}: sameday+diff+missing moet deviations zijn`); + } + if (counts.measurable > denominator) issue(problems, `${path}: measurable overschrijdt taaknoemer ${denominator}`); + return true; +} + +function addCounts(target: ProductCountsV2, source: ProductCountsV2): void { + for (const key of COUNTS_KEYS) target[key] += source[key]; +} + +function emptyCounts(): ProductCountsV2 { + return { exact: 0, sameday: 0, diff: 0, missing: 0, measurable: 0, deviations: 0 }; +} + +function validateCounterSet( + problems: string[], + path: string, + value: unknown, + denominator: number, +): value is Record { + if (!exactKeys(problems, path, value, PRODUCT_AXES)) return false; + const counters = value as unknown as Record; + let valid = true; + for (const axis of PRODUCT_AXES) valid = validateCounts(problems, `${path}.${axis}`, counters[axis], denominator) && valid; + return valid; +} + +function validateCharacterization(problems: string[], path: string, value: unknown): value is ProductCharacterizationV2 { + if (!exactKeys(problems, path, value, CHARACTERIZATION_KEYS)) return false; + const characterization = value as unknown as ProductCharacterizationV2; + equal(problems, `${path}.finalZeroGate`, characterization.finalZeroGate, 'red'); + equal(problems, `${path}.accepted`, characterization.accepted, false); + equal(problems, `${path}.openCategories`, characterization.openCategories, PRODUCT_OPEN_CATEGORIES); + return true; +} + +export function deriveStrictEntryGate(entry: Pick): boolean { + return entry.identityErrors.length === 0 + && entry.scannerErrors.length === 0 + && PRODUCT_AXES.every(axis => entry.counters[axis].deviations === 0); +} + +function validateProject(problems: string[], path: string, value: unknown): ProductProjectV2 | null { + if (!exactKeys(problems, path, value, PROJECT_KEYS)) return null; + const project = value as unknown as ProductProjectV2; + if (typeof project.projectId !== 'string' || !project.projectId.trim()) issue(problems, `${path}.projectId: niet-lege string verwacht`); + for (const key of ['truthTasks', 'solvedTasks', 'taskCodePresent', 'taskCodeExact'] as const) { + if (!nonNegativeInteger(project[key])) issue(problems, `${path}.${key}: niet-negatief geheel getal verwacht`); + } + if (!nonNegativeInteger(project.truthTasks)) return null; + if (nonNegativeInteger(project.solvedTasks) && project.solvedTasks > project.truthTasks) issue(problems, `${path}.solvedTasks: boven truthTasks`); + if (nonNegativeInteger(project.taskCodePresent) && nonNegativeInteger(project.solvedTasks) + && project.taskCodePresent > project.solvedTasks) issue(problems, `${path}.taskCodePresent: boven solvedTasks`); + if (nonNegativeInteger(project.taskCodeExact) && nonNegativeInteger(project.taskCodePresent) + && project.taskCodeExact > project.taskCodePresent) issue(problems, `${path}.taskCodeExact: boven taskCodePresent`); + if (project.truthTasks !== project.solvedTasks || project.truthTasks !== project.taskCodePresent + || project.truthTasks !== project.taskCodeExact) issue(problems, `${path}: identiteitsdekking is niet exact`); + const countersValid = validateCounterSet(problems, `${path}.counters`, project.counters, project.truthTasks); + const drivingValid = validateCounts(problems, `${path}.drivingPath`, project.drivingPath, project.truthTasks); + const identityErrorsValid = validateStringArray(problems, `${path}.identityErrors`, project.identityErrors, true); + if (!HEX_64.test(project.projectionSha256 ?? '')) issue(problems, `${path}.projectionSha256: volledige SHA-256 verwacht`); + equal(problems, `${path}.projectionSha256`, project.projectionSha256, + productSha256(canonicalProductJson(projectProjection(project)))); + return countersValid && drivingValid && identityErrorsValid ? project : null; +} + +function validateEntry(problems: string[], key: string, value: unknown): ProductEntryV2 | null { + const path = `product-v2.files.${key}`; + if (!exactKeys(problems, path, value, ENTRY_KEYS)) return null; + const entry = value as unknown as ProductEntryV2; + if (!HEX_64.test(key) || entry.sha256 !== key) issue(problems, `${path}: entrysleutel en volledige SHA-256 moeten exact gelijk zijn`); + if (typeof entry.schemaFingerprint !== 'string' || !entry.schemaFingerprint.trim()) issue(problems, `${path}.schemaFingerprint: niet-lege string verwacht`); + const denominatorsValid = nonNegativeInteger(entry.projects) && nonNegativeInteger(entry.tasks); + if (!denominatorsValid) issue(problems, `${path}: projects/tasks moeten niet-negatieve gehele getallen zijn`); + if (!exactKeys(problems, `${path}.identityCoverage`, entry.identityCoverage, IDENTITY_KEYS)) return null; + for (const identityKey of IDENTITY_KEYS) { + if (!nonNegativeInteger(entry.identityCoverage[identityKey])) issue(problems, `${path}.identityCoverage.${identityKey}: ongeldig`); + } + if (!Array.isArray(entry.projectMeasurements)) { + issue(problems, `${path}.projectMeasurements: array verwacht`); + return null; + } + const projects = entry.projectMeasurements.map((project, index) => validateProject( + problems, `${path}.projectMeasurements[${index}]`, project, + )).filter((project): project is ProductProjectV2 => project !== null); + const projectIds = projects.map(project => project.projectId); + equal(problems, `${path}: project-ID-volgorde`, projectIds, [...projectIds].sort((left, right) => left.localeCompare(right))); + if (new Set(projectIds).size !== projectIds.length) issue(problems, `${path}: dubbele project-ID`); + equal(problems, `${path}.projects`, entry.projects, projects.length); + equal(problems, `${path}.tasks`, entry.tasks, projects.reduce((sum, project) => sum + project.truthTasks, 0)); + const identityTotals = { + solvedTasks: projects.reduce((sum, project) => sum + project.solvedTasks, 0), + taskCodePresent: projects.reduce((sum, project) => sum + project.taskCodePresent, 0), + taskCodeExact: projects.reduce((sum, project) => sum + project.taskCodeExact, 0), + }; + equal(problems, `${path}.identityCoverage: projectsom`, entry.identityCoverage, identityTotals); + if (entry.identityCoverage.solvedTasks !== entry.tasks + || entry.identityCoverage.taskCodePresent !== entry.tasks + || entry.identityCoverage.taskCodeExact !== entry.tasks) issue(problems, `${path}.identityCoverage: niet volledig`); + const projectCounters = Object.fromEntries(PRODUCT_AXES.map(axis => [axis, emptyCounts()])) as Record; + const projectDriving = emptyCounts(); + for (const project of projects) { + for (const axis of PRODUCT_AXES) addCounts(projectCounters[axis], project.counters[axis]); + addCounts(projectDriving, project.drivingPath); + } + const countersValid = nonNegativeInteger(entry.tasks) + && validateCounterSet(problems, `${path}.counters`, entry.counters, entry.tasks); + if (countersValid) { + for (const axis of PRODUCT_AXES) equal(problems, `${path}.counters.${axis}: projectsom`, entry.counters[axis], projectCounters[axis]); + } + const drivingValid = nonNegativeInteger(entry.tasks) + && validateCounts(problems, `${path}.drivingPath`, entry.drivingPath, entry.tasks); + if (drivingValid) { + equal(problems, `${path}.drivingPath: projectsom`, entry.drivingPath, projectDriving); + } + const identityErrorsValid = validateStringArray(problems, `${path}.identityErrors`, entry.identityErrors, true); + const scannerErrorsValid = validateStringArray(problems, `${path}.scannerErrors`, entry.scannerErrors, true); + if (typeof entry.gatePassed !== 'boolean') issue(problems, `${path}.gatePassed: boolean verwacht`); + else if (countersValid && identityErrorsValid && scannerErrorsValid) { + equal(problems, `${path}.gatePassed: herleid`, entry.gatePassed, deriveStrictEntryGate(entry)); + } + if (!HEX_64.test(entry.projectProjectionSha256 ?? '')) issue(problems, `${path}.projectProjectionSha256: volledige SHA-256 verwacht`); + equal(problems, `${path}.projectProjectionSha256: herleid`, entry.projectProjectionSha256, projectProjectionDigest(projects)); + return denominatorsValid && countersValid && drivingValid && identityErrorsValid && scannerErrorsValid + ? entry + : null; +} + +export function validateProductBaselineV2(rawText: string, pins?: ProductValidationPins): ProductValidationResult { + const problems: string[] = []; + let raw: unknown; + try { + raw = JSON.parse(rawText); + } catch { + return { problems: ['product-v2: envelope is geen JSON'] }; + } + rejectForbiddenKeys(raw, 'product-v2', problems); + if (!exactKeys(problems, 'product-v2.envelope', raw, ENVELOPE_KEYS)) return { problems }; + const envelope = raw as unknown as ProductEnvelopeV2; + equalTextBytes(problems, 'product-v2.envelope: canonieke JSON+LF-bytes', rawText, canonicalProductJson(raw)); + equal(problems, 'product-v2.envelope.version', envelope.version, 2); + if (!HEX_64.test(envelope.manifestSha256 ?? '')) issue(problems, 'product-v2.envelope.manifestSha256: volledige SHA-256 verwacht'); + validateCharacterization(problems, 'product-v2.envelope.characterization', envelope.characterization); + equal(problems, 'product-v2.envelope.reportModes', envelope.reportModes, PRODUCT_REPORT_MODES); + equal(problems, 'product-v2.envelope.encoding', envelope.encoding, 'gzip-base64-json'); + if (exactKeys(problems, 'product-v2.envelope.canonicalization', envelope.canonicalization, CANONICALIZATION_KEYS)) { + equal(problems, 'product-v2.envelope.canonicalization.json', envelope.canonicalization.json, 'JSON.stringify(value, null, 2) + LF'); + equal(problems, 'product-v2.envelope.canonicalization.gzip', envelope.canonicalization.gzip, 'gzip level 9, deterministic header'); + } + for (const field of ['payloadSha256', 'payloadGzipSha256', 'projectProjectionSha256'] as const) { + if (!HEX_64.test(envelope[field] ?? '')) issue(problems, `product-v2.envelope.${field}: volledige SHA-256 verwacht`); + } + if (typeof envelope.payloadGzipBase64 !== 'string' + || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(envelope.payloadGzipBase64)) { + return { problems: [...problems, 'product-v2: payloadGzipBase64 is geen canonieke base64string'], envelope }; + } + const compressed = Buffer.from(envelope.payloadGzipBase64, 'base64'); + if (compressed.toString('base64') !== envelope.payloadGzipBase64) issue(problems, 'product-v2: base64 is niet canoniek'); + let payloadText: string; + let payloadRaw: unknown; + try { + payloadText = gunzipSync(compressed).toString('utf8'); + payloadRaw = JSON.parse(payloadText); + } catch { + return { problems: [...problems, 'product-v2: gzip-payload is niet volledig decodeerbaar JSON'], envelope }; + } + equalTextBytes(problems, 'product-v2.payload: canonieke JSON+LF-bytes', payloadText, canonicalProductJson(payloadRaw)); + const canonicalCompressed = canonicalGzip(canonicalProductJson(payloadRaw)); + if (!compressed.equals(canonicalCompressed)) { + issue(problems, `product-v2.payload: gzipbytes wijken af van canonieke level-9-recompressie (${productSha256(compressed)} != ${productSha256(canonicalCompressed)})`); + } + equal(problems, 'product-v2.envelope.payloadSha256', envelope.payloadSha256, productSha256(payloadText)); + equal(problems, 'product-v2.envelope.payloadGzipSha256', envelope.payloadGzipSha256, productSha256(compressed)); + rejectForbiddenKeys(payloadRaw, 'product-v2.payload', problems); + if (!exactKeys(problems, 'product-v2.payload', payloadRaw, PAYLOAD_KEYS)) return { problems, envelope }; + const payload = payloadRaw as unknown as ProductBaselineV2; + equal(problems, 'product-v2.payload.version', payload.version, 2); + equal(problems, 'product-v2.payload.manifestSha256', payload.manifestSha256, envelope.manifestSha256); + validateCharacterization(problems, 'product-v2.payload.characterization', payload.characterization); + equal(problems, 'product-v2.envelope-payload.characterization', payload.characterization, envelope.characterization); + const filesObject = objectValue(payload.files); + if (!filesObject) return { problems: [...problems, 'product-v2.payload.files: object verwacht'], envelope }; + const entries = Object.keys(filesObject).sort(); + const totals = Object.fromEntries(PRODUCT_AXES.map(axis => [axis, emptyCounts()])) as Record; + const driving = emptyCounts(); + let projects = 0; + let tasks = 0; + for (const key of entries) { + const entry = validateEntry(problems, key, filesObject[key]); + if (!entry) continue; + projects += entry.projects; + tasks += entry.tasks; + for (const axis of PRODUCT_AXES) addCounts(totals[axis], entry.counters[axis]); + addCounts(driving, entry.drivingPath); + } + const computedProjection = productProjectionDigest(payload); + equal(problems, 'product-v2.envelope.projectProjectionSha256: herleid', envelope.projectProjectionSha256, computedProjection); + if (pins) { + equal(problems, 'product-v2.pin.manifestSha256', envelope.manifestSha256, pins.manifestSha256); + equal(problems, 'product-v2.pin.entryset', entries, Object.keys(pins.entries).sort()); + for (const [sha256, expected] of Object.entries(pins.entries)) { + equal(problems, `product-v2.pin.${sha256}.schemaFingerprint`, payload.files[sha256]?.schemaFingerprint, expected.schemaFingerprint); + } + equal(problems, 'product-v2.pin.projects', projects, pins.projects); + equal(problems, 'product-v2.pin.tasks', tasks, pins.tasks); + equal(problems, 'product-v2.pin.counters', totals, pins.counters); + equal(problems, 'product-v2.pin.drivingPath', driving, pins.drivingPath); + if (pins.projectProjectionSha256) equal(problems, 'product-v2.pin.projectProjectionSha256', computedProjection, pins.projectProjectionSha256); + if (pins.payloadSha256) equal(problems, 'product-v2.pin.payloadSha256', envelope.payloadSha256, pins.payloadSha256); + if (pins.payloadGzipSha256) equal(problems, 'product-v2.pin.payloadGzipSha256', envelope.payloadGzipSha256, pins.payloadGzipSha256); + } + return { problems, envelope, payload }; +} + +export interface ProductReportInputs { + strictMinuteExact: T; + historicalCompletedLate: T; + sourceDayPrecision: T; +} + +export function selectProductReportMode(mode: ProductReportMode, inputs: ProductReportInputs): { + mode: ProductReportMode; + strictGateEligible: boolean; + report: T; +} { + switch (mode) { + case 'strict-minute-exact': return { mode, strictGateEligible: true, report: inputs.strictMinuteExact }; + case 'historical-completed-late': return { mode, strictGateEligible: false, report: inputs.historicalCompletedLate }; + case 'source-day-precision': return { mode, strictGateEligible: false, report: inputs.sourceDayPrecision }; + } +} diff --git a/tests/planning/xerProductFidelity.ts b/tests/planning/xerProductFidelity.ts new file mode 100644 index 000000000..abed35be1 --- /dev/null +++ b/tests/planning/xerProductFidelity.ts @@ -0,0 +1,240 @@ +import { + classifyExact, + classifyMinuteExact, + compareFidelityRow, + countFidelityAxis, + type FidelityCounts, +} from './fidelityCore'; +import { + XER_FIDELITY_AXES, + type XerFidelityAxis, + type XerGroundTruth, + type XerGroundTruthTask, +} from './xerGroundTruth'; +import type { XerSolvedProject, XerSolvedTask } from './xerFidelity'; + +export interface XerProductAxisCounts { + exact: number; + sameday: number; + diff: number; + missing: number; + measurable: number; + deviations: number; +} + +export interface XerProductTaskDelta { + projectId: string; + taskId: string; + taskCode: string; + axis: XerFidelityAxis | 'drivingPath'; + truth: string | number | boolean | null; + ours: string | number | boolean | undefined; + bucket: 'exact' | 'sameday' | 'diff' | 'missing'; +} + +/** + * De productmeting blijft per project uitlegbaar. `taskCodeExact` is geen alternatieve + * identiteitssleutel: sourceTaskId blijft de join. Deze vier tellers maken wel zichtbaar dat + * de tweede identiteitskolom niet stil uit de dekking kan verdwijnen. + */ +export interface XerProductProjectMeasurement { + projectId: string; + truthTasks: number; + solvedTasks: number; + taskCodePresent: number; + taskCodeExact: number; + counters: Record; + drivingPath: XerProductAxisCounts; + identityErrors: string[]; +} + +export interface XerProductMeasurement { + truthProjects: number; + solvedProjects: number; + truthTasks: number; + solvedTasks: number; + counters: Record; + drivingPath: XerProductAxisCounts; + /** Stabiel gesorteerd; de entrysom moet precies naar de globale tellers rollen. */ + projects: XerProductProjectMeasurement[]; + /** Alleen fouten uit de onafhankelijke raw-XER-scanner. */ + scannerErrors: string[]; + /** Alleen project-/taakidentiteitsfouten uit de productuitlijning. */ + identityErrors: string[]; + /** Compatibel gecombineerd kanaal voor bestaande rapportage. */ + errors: string[]; + detail: XerProductTaskDelta[]; + gatePassed: boolean; +} + +function productCounts(counts: FidelityCounts): XerProductAxisCounts { + // fidelityCore telt een afwezig orakelveld bewust als `missing`, omdat MPP dat als rijdiagnose + // rapporteert. Voor X12 zijn alleen orakelmeetbare waarden productverplichtingen. + const missing = counts.deviations - counts.sameday - counts.diff; + return { + exact: counts.exact, + sameday: counts.sameday, + diff: counts.diff, + missing, + measurable: counts.measurable, + deviations: counts.deviations, + }; +} + +function emptyCounts(): XerProductAxisCounts { + return { exact: 0, sameday: 0, diff: 0, missing: 0, measurable: 0, deviations: 0 }; +} + +function addCounts(target: XerProductAxisCounts, source: XerProductAxisCounts): void { + target.exact += source.exact; + target.sameday += source.sameday; + target.diff += source.diff; + target.missing += source.missing; + target.measurable += source.measurable; + target.deviations += source.deviations; +} + +function comparable(value: string | number | boolean | null | undefined): string | undefined | null { + if (value === null || value === undefined) return value; + return typeof value === 'boolean' ? (value ? 'Y' : 'N') : String(value); +} + +function solvedAxis(task: XerSolvedTask | undefined, axis: XerFidelityAxis): string | number | undefined { + if (!task) return undefined; + switch (axis) { + case 'es': return task.earlyStart; + case 'ef': return task.earlyFinish; + case 'ls': return task.lateStart; + case 'lf': return task.lateFinish; + case 'tf': return task.totalFloatMinutes; + case 'ff': return task.freeFloatMinutes; + } +} + +function validateIdentity( + projectId: string, + truthTasks: readonly XerGroundTruthTask[], + solved: XerSolvedProject | undefined, + errors: string[], +): { byId: Map; errors: string[] } { + const projectErrors: string[] = []; + const byId = new Map(); + for (const task of solved?.tasks ?? []) { + if (!task.sourceTaskId.trim()) { + projectErrors.push(`project ${projectId}: opgeloste taak heeft lege sourceTaskId`); + continue; + } + if (!task.taskCode.trim()) projectErrors.push(`project ${projectId}/taak ${task.sourceTaskId}: taskCode is leeg`); + if (byId.has(task.sourceTaskId)) projectErrors.push(`project ${projectId}: dubbele opgeloste taak-id ${task.sourceTaskId}`); + else byId.set(task.sourceTaskId, task); + } + const truthById = new Map(truthTasks.map(task => [task.taskId, task])); + for (const id of [...byId.keys()].filter(id => !truthById.has(id)).sort()) projectErrors.push(`project ${projectId}: extra taak ${id}`); + for (const id of [...truthById.keys()].filter(id => !byId.has(id)).sort()) projectErrors.push(`project ${projectId}: ontbrekende taak ${id}`); + for (const task of truthTasks) { + const actual = byId.get(task.taskId); + if (actual?.taskCode && actual.taskCode !== task.taskCode) { + projectErrors.push(`project ${projectId}/taak ${task.taskId}: code verwacht ${task.taskCode}, kreeg ${actual.taskCode}`); + } + } + errors.push(...projectErrors); + return { byId, errors: projectErrors }; +} + +/** Productlaag: meet alle door readXER geopende projecten, zonder de grondwaarheid of solver te delen. */ +export function measureXerProductFidelity( + truth: XerGroundTruth, + solvedProjects: readonly XerSolvedProject[], +): XerProductMeasurement { + const scannerErrors = [...truth.errors]; + const identityErrors: string[] = []; + const truthByProject = new Map(); + for (const task of truth.tasks) { + const group = truthByProject.get(task.projectId) ?? []; + group.push(task); + truthByProject.set(task.projectId, group); + } + const solvedByProject = new Map(); + for (const project of solvedProjects) { + if (!project.projectId.trim()) identityErrors.push('opgelost project heeft lege projectId'); + else if (solvedByProject.has(project.projectId)) identityErrors.push(`dubbel opgelost project ${project.projectId}`); + else solvedByProject.set(project.projectId, project); + } + for (const id of [...solvedByProject.keys()].filter(id => !truthByProject.has(id)).sort()) identityErrors.push(`extra project ${id}`); + for (const id of [...truthByProject.keys()].filter(id => !solvedByProject.has(id)).sort()) identityErrors.push(`ontbrekend project ${id}`); + + const counters = Object.fromEntries(XER_FIDELITY_AXES.map(axis => [axis, emptyCounts()])) as Record; + const drivingPath = emptyCounts(); + const projects: XerProductProjectMeasurement[] = []; + const detail: XerProductTaskDelta[] = []; + for (const [projectId, truthTasks] of truthByProject) { + const identity = validateIdentity(projectId, truthTasks, solvedByProject.get(projectId), identityErrors); + const solvedById = identity.byId; + const projectCounters = Object.fromEntries(XER_FIDELITY_AXES.map(axis => [axis, emptyCounts()])) as Record; + const projectDrivingPath = emptyCounts(); + let taskCodePresent = 0; + let taskCodeExact = 0; + for (const truthTask of truthTasks) { + const solved = solvedById.get(truthTask.taskId); + if (solved?.taskCode.trim()) taskCodePresent++; + if (solved?.taskCode === truthTask.taskCode) taskCodeExact++; + const row = compareFidelityRow(truthTask.taskId, Object.fromEntries(XER_FIDELITY_AXES.map(axis => [axis, { + ours: comparable(solvedAxis(solved, axis)) ?? undefined, + truth: comparable(truthTask.axes[axis]) ?? null, + }])) as Record, { + es: classifyMinuteExact, ef: classifyMinuteExact, ls: classifyMinuteExact, lf: classifyMinuteExact, + tf: classifyExact, ff: classifyExact, + }); + for (const axis of XER_FIDELITY_AXES) { + const one = productCounts(countFidelityAxis([row], axis)); + addCounts(counters[axis], one); + addCounts(projectCounters[axis], one); + if (one.deviations > 0) { + const delta = row.axes[axis]; + detail.push({ projectId, taskId: truthTask.taskId, taskCode: truthTask.taskCode, axis, + truth: truthTask.axes[axis], ours: solvedAxis(solved, axis), bucket: delta.verdict }); + } + } + const driving = compareFidelityRow(truthTask.taskId, { driving: { + ours: comparable(solved?.drivingPath) ?? undefined, + truth: comparable(truthTask.drivingPath) ?? null, + } }, { driving: classifyExact }); + const drivingCounts = productCounts(countFidelityAxis([driving], 'driving')); + addCounts(drivingPath, drivingCounts); + addCounts(projectDrivingPath, drivingCounts); + if (drivingCounts.deviations > 0) { + const delta = driving.axes.driving; + detail.push({ projectId, taskId: truthTask.taskId, taskCode: truthTask.taskCode, axis: 'drivingPath', + truth: truthTask.drivingPath, ours: solved?.drivingPath, bucket: delta.verdict }); + } + } + projects.push({ + projectId, + truthTasks: truthTasks.length, + solvedTasks: solvedByProject.get(projectId)?.tasks.length ?? 0, + taskCodePresent, + taskCodeExact, + counters: projectCounters, + drivingPath: projectDrivingPath, + identityErrors: identity.errors, + }); + } + const truthTasks = truth.tasks.length; + const solvedTasks = solvedProjects.reduce((total, project) => total + project.tasks.length, 0); + const errors = [...scannerErrors, ...identityErrors]; + return { + truthProjects: truthByProject.size, + solvedProjects: solvedProjects.length, + truthTasks, + solvedTasks, + counters, + drivingPath, + projects: projects.sort((left, right) => left.projectId.localeCompare(right.projectId)), + scannerErrors, + identityErrors, + errors, + detail, + gatePassed: errors.length === 0 && truthByProject.size === solvedProjects.length + && truthTasks === solvedTasks && XER_FIDELITY_AXES.every(axis => counters[axis].deviations === 0), + }; +} diff --git a/tests/planning/xerScheduleOptionsGroundTruth.ts b/tests/planning/xerScheduleOptionsGroundTruth.ts new file mode 100644 index 000000000..e250217fd --- /dev/null +++ b/tests/planning/xerScheduleOptionsGroundTruth.ts @@ -0,0 +1,295 @@ +import type { + XerScheduleOptionFallback, + XerScheduleOptionsDiagnostic, + XerScheduleOptionsSourceArchive, + XerScheduleOptionsSourceRow, +} from '@/services/importTypes'; +import type { ProgressMode, SchedulingOptions } from '@/types/project'; + +export interface RawXerScheduleRow { + line: number; + cells: Record; +} + +export interface RawXerScheduleScan { + encoding: 'utf-8' | 'utf-16le' | 'utf-16be' | 'windows-1252'; + tables: Map; + sourceArchive: XerScheduleOptionsSourceArchive; + projectRowIndexesById: Map; + scheduleRowIndexesById: Map; +} + +export interface IndependentXerScheduleExpected { + progressMode: ProgressMode; + schedulingOptions: SchedulingOptions; + source: 'schedoptions' | 'xer-defaults'; + retainedSource: { sched_use_project_end_date_for_float?: boolean }; + fallbacks: XerScheduleOptionFallback[]; + diagnostics: XerScheduleOptionsDiagnostic[]; + sourceRowIndexes: number[]; + sourceRows: XerScheduleOptionsSourceRow[]; +} + +function decodeRaw(bytes: Uint8Array): { text: string; encoding: RawXerScheduleScan['encoding'] } { + if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) { + return { text: new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(3)), encoding: 'utf-8' }; + } + if (bytes[0] === 0xff && bytes[1] === 0xfe) { + return { text: new TextDecoder('utf-16le', { fatal: true }).decode(bytes.subarray(2)), encoding: 'utf-16le' }; + } + if (bytes[0] === 0xfe && bytes[1] === 0xff) { + return { text: new TextDecoder('utf-16be', { fatal: true }).decode(bytes.subarray(2)), encoding: 'utf-16be' }; + } + try { + return { text: new TextDecoder('utf-8', { fatal: true }).decode(bytes), encoding: 'utf-8' }; + } catch { + return { text: new TextDecoder('windows-1252').decode(bytes), encoding: 'windows-1252' }; + } +} + +function rowIndexes(rows: readonly XerScheduleOptionsSourceRow[]): Map { + const result = new Map(); + rows.forEach((row, index) => { + const projectId = row.cells.proj_id?.trim() ?? ''; + const indexes = result.get(projectId) ?? []; + indexes.push(index); + result.set(projectId, indexes); + }); + return result; +} + +/** Testlaag-orakel: geen productieparser, reader of SCHEDOPTIONS-mapper wordt aangeroepen. */ +export function scanRawXerScheduleOptions(bytes: Uint8Array): RawXerScheduleScan { + const decoded = decodeRaw(bytes); + const tables = new Map(); + let tableName = ''; + let fields: string[] = []; + decoded.text.split(/\r?\n/).forEach((line, zeroBasedLine) => { + const cells = line.split('\t'); + if (cells[0] === '%E') return; + if (cells[0] === '%T') { + tableName = cells[1]?.trim().toUpperCase() ?? ''; + fields = []; + if (tableName && !tables.has(tableName)) tables.set(tableName, { fields: [], rows: [] }); + return; + } + if (!tableName) return; + if (cells[0] === '%F') { + fields = cells.slice(1).map(field => field.trim().toLowerCase()); + tables.get(tableName)!.fields = [...fields]; + return; + } + if (cells[0] !== '%R' || fields.length === 0) return; + const row: RawXerScheduleRow = { line: zeroBasedLine + 1, cells: {} }; + fields.forEach((field, index) => { row.cells[field] = cells[index + 1] ?? ''; }); + tables.get(tableName)!.rows.push(row); + }); + + const projectRows: XerScheduleOptionsSourceRow[] = (tables.get('PROJECT')?.rows ?? []) + .map(row => ({ table: 'PROJECT', line: row.line, cells: { ...row.cells } })); + const scheduleRows: XerScheduleOptionsSourceRow[] = (tables.get('SCHEDOPTIONS')?.rows ?? []) + .map(row => ({ table: 'SCHEDOPTIONS', line: row.line, cells: { ...row.cells } })); + const rows = [...projectRows, ...scheduleRows]; + const projectRowIndexesById = rowIndexes(projectRows); + const scheduleOffset = projectRows.length; + const scheduleRowIndexesById = new Map(); + scheduleRows.forEach((row, index) => { + const projectId = row.cells.proj_id?.trim() ?? ''; + const indexes = scheduleRowIndexesById.get(projectId) ?? []; + indexes.push(scheduleOffset + index); + scheduleRowIndexesById.set(projectId, indexes); + }); + const projectIds = new Set(projectRowIndexesById.keys()); + const unmatchedScheduleOptionsRowIndexes = [...scheduleRowIndexesById] + .filter(([projectId]) => !projectIds.has(projectId)) + .flatMap(([, indexes]) => indexes); + const diagnostics: XerScheduleOptionsDiagnostic[] = [...scheduleRowIndexesById] + .filter(([, indexes]) => indexes.length > 1) + .map(([projectId, indexes]) => ({ + code: 'XER_DUPLICATE_SCHEDOPTIONS_PROJ_ID', + projectId, + rowIndexes: [...indexes], + lines: indexes.map(index => rows[index].line), + })); + return { + encoding: decoded.encoding, + tables, + sourceArchive: { rows, unmatchedScheduleOptionsRowIndexes, diagnostics }, + projectRowIndexesById, + scheduleRowIndexesById, + }; +} + +function fallback( + sink: XerScheduleOptionFallback[], row: XerScheduleOptionsSourceRow, + field: string, token: string, value: string, +): void { + sink.push({ field, token, fallback: value, line: row.line }); +} + +function bool( + row: XerScheduleOptionsSourceRow, field: string, defaultValue: boolean, + fallbacks: XerScheduleOptionFallback[], +): boolean { + const token = row.cells[field]?.trim() ?? ''; + if (!token) return defaultValue; + if (token.toUpperCase() === 'Y') return true; + if (token.toUpperCase() === 'N') return false; + fallback(fallbacks, row, field, token, String(defaultValue)); + return defaultValue; +} + +function enumToken( + row: XerScheduleOptionsSourceRow, field: string, mapping: Readonly>, + defaultValue: T, fallbacks: XerScheduleOptionFallback[], +): T { + const token = row.cells[field]?.trim() ?? ''; + if (!token) return defaultValue; + const value = mapping[token.toUpperCase()]; + if (value !== undefined) return value; + fallback(fallbacks, row, field, token, defaultValue); + return defaultValue; +} + +function rawNumber(raw: string): number | null { + const token = raw.trim(); + if (!token) return null; + const normalized = token.includes(',') && !token.includes('.') ? token.replace(',', '.') : token; + const value = Number(normalized); + return Number.isFinite(value) ? value : null; +} + +export function expectedXerScheduleOptions( + scan: RawXerScheduleScan, + projectId: string, + context: { taskCount?: number } = {}, +): IndependentXerScheduleExpected { + const projectIndexes = scan.projectRowIndexesById.get(projectId) ?? []; + const scheduleIndexes = scan.scheduleRowIndexesById.get(projectId) ?? []; + const sourceRowIndexes = [...projectIndexes, ...scheduleIndexes]; + const sourceRows = sourceRowIndexes.map(index => scan.sourceArchive.rows[index]); + const projectRow = projectIndexes.length === 1 ? scan.sourceArchive.rows[projectIndexes[0]] : undefined; + const scheduleRow = scheduleIndexes.length === 1 ? scan.sourceArchive.rows[scheduleIndexes[0]] : undefined; + const diagnostics = scan.sourceArchive.diagnostics.filter(item => item.projectId === projectId); + const fallbacks: XerScheduleOptionFallback[] = []; + const criticalToken = projectRow?.cells.critical_path_type?.trim() ?? ''; + let criticalDefinition: SchedulingOptions['criticalDefinition']; + if (criticalToken.toUpperCase() === 'CT_DRIVPATH') { + criticalDefinition = { mode: 'longestPath' }; + } else { + if (criticalToken && criticalToken.toUpperCase() !== 'CT_TOTFLOAT' && projectRow) { + fallback(fallbacks, projectRow, 'critical_path_type', criticalToken, 'totalFloat'); + } + criticalDefinition = { + mode: 'totalFloat', + thresholdHours: rawNumber(projectRow?.cells.critical_drtn_hr_cnt ?? '') ?? 0, + }; + } + const schedulingOptions: SchedulingOptions = { + // Onafhankelijk testorakel: deze XER-eigen switches zijn letterlijk uit de toegestane + // PROJECT/SCHEDOPTIONS-bronvorm afgeleid als vaste, brongebonden defaults — geen import van + // `xerScheduleOptions.ts`, zodat een productwijziging de verwachting niet kan meeschuiven. + p6Source: 'XER', + lagCalendar: 'predecessor', + criticalDefinition, + totalFloatMode: 'finish', + makeOpenEndedCritical: false, + useExpectedFinishDates: true, + preserveActualDatesInBackwardPass: true, + clampNegativeFreeFloat: true, + p6ZeroDurationUsesPlannedBoundary: true, + p6UseTaskPlannedStartFloor: true, + p6FinishMilestoneBoundaryWindow: true, + p6PreserveActualInstants: true, + // PROJECT is de bron van deze keuze, óók als een project geen SCHEDOPTIONS-rij heeft. + // Dit blijft een onafhankelijke raw-scan: nooit de productie-afleiding hergebruiken. + p6UseRemainingStartForProgress: + projectRow?.cells.rem_target_link_flag?.trim().toUpperCase() === 'Y', + p6PreserveZeroDurationConstraintInstants: true, + // X-O7 laag 1, klasse (i): de bewijsbasis is uitsluitend RETAINED_LOGIC-corpus, dus deze + // onafhankelijke afleiding zet 'm net als de productie-afleiding standaard aan en weer uit + // zodra `sched_progress_override=Y` blijkt (hieronder, ná de progressMode-afleiding). + p6CompletedLateFromRemainingWindow: true, + }; + if (!scheduleRow) { + return { + progressMode: 'RETAINED_LOGIC', schedulingOptions, source: 'xer-defaults', + retainedSource: {}, fallbacks, diagnostics, sourceRowIndexes, sourceRows, + }; + } + + schedulingOptions.lagCalendar = enumToken(scheduleRow, 'sched_calendar_on_relationship_lag', { + RCAL_PREDECESSOR: 'predecessor', RCAL_SUCCESSOR: 'successor', + RCAL_24HOUR: '24hour', RCAL_PROJDEFAULT: 'projectDefault', + } as const, 'predecessor', fallbacks); + schedulingOptions.totalFloatMode = enumToken(scheduleRow, 'sched_float_type', { + FT_SS: 'start', FT_FF: 'finish', FT_MIN: 'smallest', + } as const, 'finish', fallbacks); + schedulingOptions.makeOpenEndedCritical = bool( + scheduleRow, 'sched_open_critical_flag', false, fallbacks, + ); + schedulingOptions.useExpectedFinishDates = bool( + scheduleRow, 'sched_use_expect_end_flag', true, fallbacks, + ); + const retainedToken = scheduleRow.cells.sched_use_project_end_date_for_float?.trim() ?? ''; + let retainedSource: IndependentXerScheduleExpected['retainedSource'] = {}; + if (retainedToken.toUpperCase() === 'Y') { + retainedSource = { sched_use_project_end_date_for_float: true }; + schedulingOptions.useProjectEndDateForFloat = true; + } else if (retainedToken.toUpperCase() === 'N') { + retainedSource = { sched_use_project_end_date_for_float: false }; + schedulingOptions.useProjectEndDateForFloat = false; + } + else if (retainedToken) fallback( + fallbacks, scheduleRow, 'sched_use_project_end_date_for_float', retainedToken, 'niet bewaard', + ); + + const retainedTokenRaw = scheduleRow.cells.sched_retained_logic?.trim() ?? ''; + const overrideTokenRaw = scheduleRow.cells.sched_progress_override?.trim() ?? ''; + let progressMode: ProgressMode = 'RETAINED_LOGIC'; + if (retainedTokenRaw || overrideTokenRaw) { + const retained = bool(scheduleRow, 'sched_retained_logic', true, fallbacks); + const override = bool(scheduleRow, 'sched_progress_override', false, fallbacks); + if (!retained && override) progressMode = 'PROGRESS_OVERRIDE'; + else if (!(retained && !override)) fallback( + fallbacks, + scheduleRow, + 'sched_retained_logic/sched_progress_override', + `${retainedTokenRaw || '(leeg)'}/${overrideTokenRaw || '(leeg)'}`, + 'RETAINED_LOGIC', + ); + } + // Her-review bevinding 3: de klem sluit op "niet aantoonbaar Y/N" — N/N (Actual Dates) en Y/Y + // vallen voor de solver op RETAINED_LOGIC terug, maar houden de completed-late-vlag NIET aan. + const retainedUpper = retainedTokenRaw.toUpperCase(); + const overrideUpper = overrideTokenRaw.toUpperCase(); + if (!((retainedUpper === '' || retainedUpper === 'Y') && (overrideUpper === '' || overrideUpper === 'N'))) { + schedulingOptions.p6CompletedLateFromRemainingWindow = false; + } + + const floatPathFields = [ + 'enable_multiple_longest_path_calc', 'use_total_float_multiple_longest_paths', + 'use_total_float', 'limit_multiple_longest_path_calc', 'max_multiple_longest_path', + ]; + if (floatPathFields.some(field => (scheduleRow.cells[field]?.trim() ?? '') !== '')) { + const methodField = scheduleRow.cells.use_total_float_multiple_longest_paths?.trim() + ? 'use_total_float_multiple_longest_paths' : 'use_total_float'; + const limited = bool(scheduleRow, 'limit_multiple_longest_path_calc', true, fallbacks); + schedulingOptions.floatPaths = { + enabled: bool(scheduleRow, 'enable_multiple_longest_path_calc', false, fallbacks), + method: bool(scheduleRow, methodField, false, fallbacks) ? 'TOTAL_FLOAT' : 'FREE_FLOAT', + maxPaths: limited + ? Math.max(1, Math.floor(rawNumber(scheduleRow.cells.max_multiple_longest_path ?? '') ?? 10)) + : Math.max(1, Math.floor(context.taskCount ?? Number.MAX_SAFE_INTEGER)), + }; + } + return { + progressMode, schedulingOptions, source: 'schedoptions', retainedSource, + fallbacks, diagnostics, sourceRowIndexes, sourceRows, + }; +} + +export function hasProjectAddressableScheduleRow(scan: RawXerScheduleScan): boolean { + return [...scan.scheduleRowIndexesById].some(([projectId, indexes]) => + indexes.length === 1 && scan.projectRowIndexesById.has(projectId)); +} diff --git a/tests/planning/xerTaskReplay.ts b/tests/planning/xerTaskReplay.ts new file mode 100644 index 000000000..c33a81ba2 --- /dev/null +++ b/tests/planning/xerTaskReplay.ts @@ -0,0 +1,229 @@ +import { XER_FIDELITY_AXES, type XerFidelityAxis, type XerGroundTruth } from './xerGroundTruth'; +import type { XerSolvedProject, XerSolvedTask } from './xerFidelity'; + +export type XerReplayClassification = 'improved' | 'regressed' | 'unchanged'; +export type XerReplayValue = string | number | null | undefined; + +export interface XerReplayCounts { + improved: number; + regressed: number; + unchanged: number; +} + +export interface XerReplayPredicateLog { + projectId: string; + sourceTaskId: string; + taskCode: string; + matched: boolean; + /** Uitsluitend geselecteerde bron-/provenancefeiten; nooit P6-orakelwaarden. */ + source: Readonly>; +} + +export interface XerReplayAxisResult { + oracle: string | number | null; + baseline: string | number | undefined; + counterfactual: string | number | undefined; + classification: XerReplayClassification; +} + +export interface XerReplayTaskResult { + projectId: string; + taskId: string; + taskCode: string; + predicate: XerReplayPredicateLog; + axes: Record; + overall: XerReplayClassification; +} + +export interface XerTaskReplayResult { + tasks: XerReplayTaskResult[]; + predicate: XerReplayPredicateLog[]; + aggregate: Record & { overall: XerReplayCounts }; +} + +export interface EvaluateXerTaskReplayInput { + /** Alleen meetlat; baseline/counterfactual en predicate zijn al berekend vóór deze aanroep. */ + oracle: XerGroundTruth; + baseline: readonly XerSolvedProject[]; + counterfactual: readonly XerSolvedProject[]; + predicate: readonly XerReplayPredicateLog[]; +} + +function emptyCounts(): XerReplayCounts { + return { improved: 0, regressed: 0, unchanged: 0 }; +} + +function axisValue(task: XerSolvedTask, axis: XerFidelityAxis): string | number | undefined { + switch (axis) { + case 'es': return task.earlyStart; + case 'ef': return task.earlyFinish; + case 'ls': return task.lateStart; + case 'lf': return task.lateFinish; + case 'tf': return task.totalFloatMinutes; + case 'ff': return task.freeFloatMinutes; + } +} + +function classify( + _axis: XerFidelityAxis, + oracle: string | number | null, + baseline: string | number | undefined, + counterfactual: string | number | undefined, +): XerReplayClassification { + if (oracle === null) return 'unchanged'; + // De X12-meetlat is exact: alleen een overgang naar exact is verbetering en alleen een + // overgang van exact af is regressie. Twee verschillende niet-exacte waarden wassen elkaar + // niet via afstand, tolerantie of same-day-logica. + const baselineExact = baseline !== undefined && String(baseline) === String(oracle); + const counterfactualExact = counterfactual !== undefined && String(counterfactual) === String(oracle); + if (!baselineExact && counterfactualExact) return 'improved'; + if (baselineExact && !counterfactualExact) return 'regressed'; + return 'unchanged'; +} + +function count(target: XerReplayCounts, classification: XerReplayClassification): void { + target[classification]++; +} + +function indexProjects( + kind: 'baseline' | 'counterfactual', + projects: readonly XerSolvedProject[], +): Map { + const indexed = new Map(); + for (const project of projects) { + if (!project.projectId.trim()) throw new Error(`task replay: leeg ${kind}project-id`); + if (indexed.has(project.projectId)) throw new Error(`task replay: dubbel ${kind}project ${project.projectId}`); + indexed.set(project.projectId, project); + } + return indexed; +} + +function indexTasks( + kind: 'baseline' | 'counterfactual', + projectId: string, + tasks: readonly XerSolvedTask[], +): Map { + const indexed = new Map(); + for (const task of tasks) { + if (!task.sourceTaskId.trim()) throw new Error(`task replay: lege ${kind}bron-taak-id in project ${projectId}`); + if (!task.taskCode.trim()) throw new Error(`task replay: lege ${kind}taakcode in project ${projectId}`); + if (indexed.has(task.sourceTaskId)) { + throw new Error(`task replay: dubbele ${kind}taak-id ${projectId}/${task.sourceTaskId}`); + } + indexed.set(task.sourceTaskId, task); + } + return indexed; +} + +function indexPredicates(predicate: readonly XerReplayPredicateLog[]): Map { + const indexed = new Map(); + for (const log of predicate) { + const key = `${log.projectId}\u0000${log.sourceTaskId}`; + if (!log.projectId.trim() || !log.sourceTaskId.trim()) { + throw new Error('task replay: predicate mist project-id of bron-taak-id'); + } + if (!log.taskCode.trim()) throw new Error('task replay: predicate mist display-taakcode'); + if (indexed.has(key)) throw new Error(`task replay: dubbele predicate ${log.projectId}/${log.sourceTaskId}`); + indexed.set(key, log); + } + return indexed; +} + +/** + * Koppelt reeds berekende baseline/counterfactual aan het onafhankelijke P6-orakel. + * Deze functie voert geen predicate of solve uit: raw oracle kan daardoor uitsluitend de + * classificatie bepalen en nooit de kandidaatroute. + */ +export function evaluateXerTaskReplay(input: EvaluateXerTaskReplayInput): XerTaskReplayResult { + if (input.oracle.errors.length > 0) { + throw new Error(`task replay: onafhankelijke scannerfout: ${input.oracle.errors.join('; ')}`); + } + const truthProjects = new Map>(); + for (const task of input.oracle.tasks) { + const tasks = truthProjects.get(task.projectId) ?? new Map(); + if (!task.taskId.trim()) throw new Error(`task replay: lege orakel-taak-id in project ${task.projectId}`); + if (tasks.has(task.taskId)) throw new Error(`task replay: dubbele orakeltaak-id ${task.projectId}/${task.taskId}`); + tasks.set(task.taskId, task); + truthProjects.set(task.projectId, tasks); + } + const baselineProjects = indexProjects('baseline', input.baseline); + const counterfactualProjects = indexProjects('counterfactual', input.counterfactual); + const predicates = indexPredicates(input.predicate); + + for (const projectId of truthProjects.keys()) { + if (!baselineProjects.has(projectId)) throw new Error(`task replay: ontbrekend baselineproject ${projectId}`); + if (!counterfactualProjects.has(projectId)) throw new Error(`task replay: ontbrekend counterfactualproject ${projectId}`); + } + for (const projectId of baselineProjects.keys()) { + if (!truthProjects.has(projectId)) throw new Error(`task replay: extra baselineproject ${projectId}`); + } + for (const projectId of counterfactualProjects.keys()) { + if (!truthProjects.has(projectId)) throw new Error(`task replay: extra counterfactualproject ${projectId}`); + } + + const aggregate = Object.fromEntries([ + ...XER_FIDELITY_AXES.map(axis => [axis, emptyCounts()] as const), + ['overall', emptyCounts()] as const, + ]) as XerTaskReplayResult['aggregate']; + const results: XerReplayTaskResult[] = []; + + for (const [projectId, truthTasks] of truthProjects) { + const baselineTasks = indexTasks('baseline', projectId, baselineProjects.get(projectId)!.tasks); + const counterfactualTasks = indexTasks('counterfactual', projectId, counterfactualProjects.get(projectId)!.tasks); + for (const taskId of truthTasks.keys()) { + if (!baselineTasks.has(taskId)) throw new Error(`task replay: ontbrekende baselinetaak ${projectId}/${taskId}`); + if (!counterfactualTasks.has(taskId)) throw new Error(`task replay: ontbrekende counterfactualtaak ${projectId}/${taskId}`); + } + for (const taskId of baselineTasks.keys()) { + if (!truthTasks.has(taskId)) throw new Error(`task replay: extra baselinetaak ${projectId}/${taskId}`); + } + for (const taskId of counterfactualTasks.keys()) { + if (!truthTasks.has(taskId)) throw new Error(`task replay: extra counterfactualtaak ${projectId}/${taskId}`); + } + + for (const [taskId, truthTask] of truthTasks) { + const baselineTask = baselineTasks.get(taskId)!; + const counterfactualTask = counterfactualTasks.get(taskId)!; + if (baselineTask.taskCode !== truthTask.taskCode) { + throw new Error(`task replay: baseline display-taakcode voor ${projectId}/${taskId} verwacht ${truthTask.taskCode}, kreeg ${baselineTask.taskCode}`); + } + if (counterfactualTask.taskCode !== truthTask.taskCode) { + throw new Error(`task replay: counterfactual display-taakcode voor ${projectId}/${taskId} verwacht ${truthTask.taskCode}, kreeg ${counterfactualTask.taskCode}`); + } + const predicateKey = `${projectId}\u0000${taskId}`; + const predicate = predicates.get(predicateKey); + if (!predicate) throw new Error(`task replay: ontbrekende predicate ${projectId}/${taskId}`); + if (predicate.taskCode !== truthTask.taskCode) { + throw new Error(`task replay: predicate display-taakcode voor ${projectId}/${taskId} verwacht ${truthTask.taskCode}, kreeg ${predicate.taskCode}`); + } + + const axes = Object.fromEntries(XER_FIDELITY_AXES.map(axis => { + const oracle = truthTask.axes[axis]; + const baseline = axisValue(baselineTask, axis); + const counterfactual = axisValue(counterfactualTask, axis); + const classification = classify(axis, oracle, baseline, counterfactual); + if (oracle !== null) count(aggregate[axis], classification); + return [axis, { oracle, baseline, counterfactual, classification }]; + })) as Record; + const measurable = XER_FIDELITY_AXES.filter(axis => axes[axis].oracle !== null); + const overall: XerReplayClassification = measurable.some(axis => axes[axis].classification === 'regressed') + ? 'regressed' + : measurable.some(axis => axes[axis].classification === 'improved') + ? 'improved' + : 'unchanged'; + if (measurable.length > 0) count(aggregate.overall, overall); + results.push({ projectId, taskId, taskCode: truthTask.taskCode, predicate, axes, overall }); + predicates.delete(predicateKey); + } + } + if (predicates.size > 0) { + const extra = predicates.values().next().value as XerReplayPredicateLog; + throw new Error(`task replay: extra predicate ${extra.projectId}/${extra.sourceTaskId}`); + } + return { tasks: results, predicate: [...input.predicate], aggregate }; +} + +/** De acceptatiepoort: netto winst kan nooit een regressie op welke as dan ook wegstrepen. */ +export function shouldRejectXerTaskReplay(result: XerTaskReplayResult): boolean { + return XER_FIDELITY_AXES.some(axis => result.aggregate[axis].regressed > 0); +} diff --git a/tests/planning/xerTaskReplayCorpus.ts b/tests/planning/xerTaskReplayCorpus.ts new file mode 100644 index 000000000..8b734ea1d --- /dev/null +++ b/tests/planning/xerTaskReplayCorpus.ts @@ -0,0 +1,199 @@ +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { scanXerGroundTruth, XER_FIDELITY_AXES, type XerFidelityAxis } from './xerGroundTruth'; +import { xerSchemaFingerprint, type XerCorpusManifest } from './xerFidelity'; +import { + evaluateXerTaskReplay, + shouldRejectXerTaskReplay, + type XerReplayCounts, + type XerTaskReplayResult, +} from './xerTaskReplay'; +import { + replayXerProductBeforeOracle, + type XerTaskReplayCandidate, +} from './xerTaskReplayProduct'; + +export const XER_TASK_REPLAY_MEMORY_MODEL = 'one-manifest-entry-and-one-project-solve-clone-at-a-time' as const; + +export interface XerTaskReplayEntryResult { + label: string; + schemaFingerprint: string; + projects: number; + tasks: number; + projectsSolvedSequentially: number; + replay: XerTaskReplayResult; +} + +export interface XerTaskReplayCorpusSummary { + candidateId: string; + manifestEntries: number; + selectedEntries: number; + projects: number; + tasks: number; + aggregate: XerTaskReplayResult['aggregate']; + rejected: boolean; + maxRssBytes: number; + memoryModel: typeof XER_TASK_REPLAY_MEMORY_MODEL; +} + +export interface XerTaskReplayCorpusOptions { + corpusRoot: string; + manifest: XerCorpusManifest; + candidate: XerTaskReplayCandidate; + onEntry?: (entry: XerTaskReplayEntryResult) => void; +} + +interface HashGroup { + includedLabel?: string; +} + +function emptyCounts(): XerReplayCounts { + return { improved: 0, regressed: 0, unchanged: 0 }; +} + +function emptyAggregate(): XerTaskReplayResult['aggregate'] { + return Object.fromEntries([ + ...XER_FIDELITY_AXES.map(axis => [axis, emptyCounts()] as const), + ['overall', emptyCounts()] as const, + ]) as XerTaskReplayResult['aggregate']; +} + +function addCounts(target: XerReplayCounts, source: XerReplayCounts): void { + target.improved += source.improved; + target.regressed += source.regressed; + target.unchanged += source.unchanged; +} + +function addAggregate(target: XerTaskReplayResult['aggregate'], source: XerTaskReplayResult['aggregate']): void { + for (const axis of XER_FIDELITY_AXES) addCounts(target[axis], source[axis]); + addCounts(target.overall, source.overall); +} + +function listXerLabels(root: string): string[] { + const labels: string[] = []; + const visit = (directory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const path = join(directory, entry.name); + if (entry.isDirectory()) visit(path); + else if (entry.isFile() && entry.name.toLowerCase().endsWith('.xer')) { + labels.push(relative(root, path).split('\\').join('/')); + } + } + }; + visit(root); + return labels.sort((a, b) => a.localeCompare(b)); +} + +function sha256(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function validateInventory(labels: readonly string[], manifest: XerCorpusManifest): void { + const found = new Set(labels); + const declared = new Set(Object.keys(manifest.files)); + for (const label of declared) { + if (!found.has(label)) throw new Error(`task replay: manifestbestand ontbreekt in corpus: ${label}`); + } + for (const label of found) { + if (!declared.has(label)) throw new Error(`task replay: corpusbestand ontbreekt in manifest: ${label}`); + } +} + +/** + * Hergebruikt uitsluitend de bestaande canonieke X12-selectie zonder alle corpusbytes tegelijk te + * bewaren. Dit replayharnas voert geen eigen dedupcontract in: schemafingerprint en taskId-semantiek + * blijven ongewijzigd de bestaande X12-meetlat en daarmee een afzonderlijk eigenaarbesluit. + */ +function selectedLabels( + corpusRoot: string, + labels: readonly string[], + manifest: XerCorpusManifest, +): string[] { + const hashGroups = new Map(); + for (const label of labels) { + const bytes = readFileSync(join(corpusRoot, label)); + const actualHash = sha256(bytes); + const manifestEntry = manifest.files[label]!; + if (manifestEntry.sha256 !== actualHash) { + throw new Error(`task replay ${label}: SHA-256 verwacht ${manifestEntry.sha256}, kreeg ${actualHash}`); + } + const group = hashGroups.get(actualHash) ?? {}; + if (manifestEntry.included && group.includedLabel === undefined) group.includedLabel = label; + hashGroups.set(actualHash, group); + } + + const fingerprints = new Set(); + const selected: string[] = []; + for (const group of hashGroups.values()) { + if (!group.includedLabel) continue; + const bytes = readFileSync(join(corpusRoot, group.includedLabel)); + const truth = scanXerGroundTruth(bytes); + if (truth.errors.length > 0) { + throw new Error(`task replay ${group.includedLabel}: scannerfout: ${truth.errors.join('; ')}`); + } + const fingerprint = xerSchemaFingerprint(truth); + if (fingerprints.has(fingerprint)) continue; + fingerprints.add(fingerprint); + selected.push(group.includedLabel); + } + return selected; +} + +export function runXerTaskReplayCorpus(options: XerTaskReplayCorpusOptions): XerTaskReplayCorpusSummary { + if (!existsSync(options.corpusRoot)) { + throw new Error(`task replay: corpusmap bestaat niet: ${options.corpusRoot}`); + } + const labels = listXerLabels(options.corpusRoot); + validateInventory(labels, options.manifest); + const selected = selectedLabels(options.corpusRoot, labels, options.manifest); + const aggregate = emptyAggregate(); + let projects = 0; + let tasks = 0; + let maxRssBytes = process.memoryUsage.rss(); + + for (const label of selected) { + const bytes = readFileSync(join(options.corpusRoot, label)); + // De kandidaatroute krijgt uitsluitend productimportdata. Pas daarna wordt het oracle aan de + // onafhankelijke classificatiekern gekoppeld. + const product = replayXerProductBeforeOracle(bytes, options.candidate); + const truth = scanXerGroundTruth(bytes); + const replay = evaluateXerTaskReplay({ + oracle: truth, + baseline: product.baseline, + counterfactual: product.counterfactual, + predicate: product.predicate, + }); + const entry: XerTaskReplayEntryResult = { + label, + schemaFingerprint: xerSchemaFingerprint(truth), + projects: new Set(truth.tasks.map(task => task.projectId)).size, + tasks: truth.tasks.length, + projectsSolvedSequentially: product.projectsSolvedSequentially, + replay, + }; + projects += entry.projects; + tasks += entry.tasks; + addAggregate(aggregate, replay.aggregate); + maxRssBytes = Math.max(maxRssBytes, process.memoryUsage.rss()); + options.onEntry?.(entry); + } + + return { + candidateId: options.candidate.id, + manifestEntries: labels.length, + selectedEntries: selected.length, + projects, + tasks, + aggregate, + rejected: XER_FIDELITY_AXES.some((axis: XerFidelityAxis) => aggregate[axis].regressed > 0), + maxRssBytes, + memoryModel: XER_TASK_REPLAY_MEMORY_MODEL, + }; +} + +/** Publieke CLI gebruikt exact dezelfde rode poort als de corpusloze fixture. */ +export function corpusReplayExitCode(summary: XerTaskReplayCorpusSummary): 0 | 1 { + const result = { aggregate: summary.aggregate } as XerTaskReplayResult; + return shouldRejectXerTaskReplay(result) ? 1 : 0; +} diff --git a/tests/planning/xerTaskReplayProduct.ts b/tests/planning/xerTaskReplayProduct.ts new file mode 100644 index 000000000..6cb66b04c --- /dev/null +++ b/tests/planning/xerTaskReplayProduct.ts @@ -0,0 +1,375 @@ +import { CalendarEngine } from '@/engine/scheduler/CalendarEngine'; +import { isZeroDurationMilestone } from '@/engine/scheduler/duration'; +import { solveProject } from '@/engine/scheduler/solveProject'; +import type { CpmProjectEndSource, CpmTaskBackwardFloatTrace, CPMPlannedFloorTrace } from '@/engine/scheduler/CPMSolver'; +import { isMultiDocumentImport, type ImportResult } from '@/services/importTypes'; +import { readXER } from '@/services/xer/xerReader'; +import type { WorkCalendar } from '@/types/calendar'; +import type { ResourceAssignment } from '@/types/resource'; +import type { Sequence } from '@/types/sequence'; +import type { Task } from '@/types/task'; +import type { XerSolvedProject } from './xerFidelity'; +import type { XerReplayPredicateLog } from './xerTaskReplay'; + +export interface XerReplaySourceContext { + projectId: string; + task: Readonly; + incoming: readonly Readonly[]; + outgoing: readonly Readonly[]; + projectCalendar: Readonly; + taskCalendar: Readonly; + assignments: readonly Readonly[]; + schedulingOptions: ImportResult['project']['schedulingOptions']; +} + +export interface XerReplayPredicateDecision { + matched: boolean; + source: Readonly>; +} + +export interface XerTaskReplayCandidate { + id: string; + /** `baseline` modelleert een regelintegratie na de bestaande solve; `source` twee gelijke bronruns. */ + replayFrom: 'baseline' | 'source'; + predicate(context: XerReplaySourceContext): XerReplayPredicateDecision; + /** Test-only mutatie op een verse clone. Oraclewaarden zijn niet beschikbaar in deze API. */ + apply(imported: XerReplayMutableSolveInput, matchedTaskIds: ReadonlySet): void; +} + +export type XerReplayMutableSolveInput = Pick< + ImportResult, + 'project' | 'calendar' | 'tasks' | 'sequences' | 'resourceCalendars' +>; + +export interface XerProductReplayBeforeOracle { + candidateId: string; + baseline: XerSolvedProject[]; + counterfactual: XerSolvedProject[]; + predicate: XerReplayPredicateLog[]; + projectsSolvedSequentially: number; +} + +export interface XerReplayLifecycleEvent { + projectId: string; + phase: 'baseline' | 'counterfactual'; + inputOrigin: 'fresh-source-clone' | 'baseline-solved-clone'; + activeSolveClones: number; +} + +export interface XerReplayOptions { + /** Testinstrumentatie: één event binnen de levensduur van iedere actieve solveclone. */ + onLifecycleEvent?: (event: XerReplayLifecycleEvent) => void; + /** Projecteer de optionele CPM backward/float-trace in de replaydiagnose; standaard inert. */ + includeBackwardFloatTrace?: boolean; +} + +function canonicalProductMinute(value: string | undefined): string | undefined { + return value?.match(/^\d{4}-\d{2}-\d{2}$/) ? `${value}T00:00` : value; +} + +/** Eén solvegraph tegelijk; de projectie bewaart daarna alleen taakuitkomsten en replaytrace. */ +interface XerReplaySolveResult { + project: XerSolvedProject; + plannedFloorTraceBySourceTaskId: Readonly>; + backwardFloatTraceProjectEndSource?: CpmProjectEndSource; + backwardFloatTraceBySourceTaskId: Readonly>; +} + +function solveImported(imported: XerReplayMutableSolveInput): XerReplaySolveResult { + const cpm = solveProject({ + tasks: imported.tasks, + sequences: imported.sequences, + calendar: imported.calendar, + calendars: imported.resourceCalendars ?? [], + dataDate: imported.project.statusDate, + progressMode: imported.project.progressMode, + schedulingOptions: imported.project.schedulingOptions, + projectStartDate: imported.project.startDate, + projectEndDate: imported.project.endDate, + }); + if (cpm.error) throw new Error(`task replay ${imported.project.id}: ${cpm.error}`); + const calendars = new Map([ + [imported.calendar.id, imported.calendar], + ...(imported.resourceCalendars ?? []).map(calendar => [calendar.id, calendar] as const), + ]); + const floatMinutesPerDay = new Map([...calendars].map(([id, calendar]) => [ + id, + new CalendarEngine(calendar).hoursPerDay * 60, + ])); + const sourceTaskById = new Map(imported.tasks.map(task => [task.id, task] as const)); + const plannedFloorTraceBySourceTaskId = Object.fromEntries(Object.entries(cpm.plannedFloorTraceByTaskId ?? {}) + .flatMap(([taskId, trace]) => { + const sourceTask = sourceTaskById.get(taskId); + return sourceTask?.id.trim() ? [[sourceTask.id, trace] as const] : []; + })); + const backwardFloatTraceBySourceTaskId = Object.fromEntries(Object.entries(cpm.backwardFloatTrace?.byTaskId ?? {}) + .flatMap(([taskId, trace]) => { + const sourceTask = sourceTaskById.get(taskId); + return sourceTask?.id.trim() ? [[sourceTask.id, trace] as const] : []; + })); + return { + plannedFloorTraceBySourceTaskId, + backwardFloatTraceProjectEndSource: cpm.backwardFloatTrace?.projectEndSource, + backwardFloatTraceBySourceTaskId, + project: { + projectId: imported.project.id, + tasks: imported.tasks.filter(task => task.p6ActivityType !== undefined).map(task => { + const calendar = (task.calendarId ? calendars.get(task.calendarId) : undefined) ?? imported.calendar; + const minutesPerDay = floatMinutesPerDay.get(calendar.id)!; + return { + sourceTaskId: task.id, + taskCode: task.wbsCode, + earlyStart: canonicalProductMinute(task.time.earlyStart), + earlyFinish: canonicalProductMinute(task.time.earlyFinish), + lateStart: canonicalProductMinute(task.time.lateStart), + lateFinish: canonicalProductMinute(task.time.lateFinish), + totalFloatMinutes: task.time.totalFloat * minutesPerDay, + freeFloatMinutes: task.time.freeFloat * minutesPerDay, + drivingPath: task.time.isCritical, + }; + }), + }, + }; +} + +/** Geen retained XER-archief, resources, assignments, codes of baselines in de solveclone. */ +function cloneSolveInput(imported: ImportResult): XerReplayMutableSolveInput { + return structuredClone({ + project: imported.project, + calendar: imported.calendar, + tasks: imported.tasks, + sequences: imported.sequences, + resourceCalendars: imported.resourceCalendars, + }); +} + +function sourceContexts(imported: ImportResult): XerReplaySourceContext[] { + const incoming = new Map(); + const outgoing = new Map(); + for (const sequence of imported.sequences) { + const inList = incoming.get(sequence.successorId) ?? []; + inList.push(sequence); + incoming.set(sequence.successorId, inList); + const outList = outgoing.get(sequence.predecessorId) ?? []; + outList.push(sequence); + outgoing.set(sequence.predecessorId, outList); + } + const calendars = new Map([ + [imported.calendar.id, imported.calendar], + ...(imported.resourceCalendars ?? []).map(calendar => [calendar.id, calendar] as const), + ]); + const assignments = new Map(); + for (const assignment of imported.assignments) { + const list = assignments.get(assignment.taskId) ?? []; + list.push(assignment); + assignments.set(assignment.taskId, list); + } + return imported.tasks.filter(task => task.p6ActivityType !== undefined).map(task => ({ + projectId: imported.project.id, + task, + incoming: incoming.get(task.id) ?? [], + outgoing: outgoing.get(task.id) ?? [], + projectCalendar: imported.calendar, + taskCalendar: (task.calendarId ? calendars.get(task.calendarId) : undefined) ?? imported.calendar, + assignments: assignments.get(task.id) ?? [], + schedulingOptions: imported.project.schedulingOptions, + })); +} + +function materializedBaselineProjects(imported: ImportResult): XerSolvedProject[] { + return (imported.baselines ?? []).flatMap(baseline => { + if (!baseline.sourceProjectId) return []; + return [{ + projectId: baseline.sourceProjectId, + tasks: baseline.tasks.map(task => ({ + sourceTaskId: task.sourceTaskId ?? task.taskId, + taskCode: task.sourceTaskCode ?? '', + earlyStart: canonicalProductMinute(task.start), + earlyFinish: canonicalProductMinute(task.finish), + })), + }]; + }); +} + +function addProject(target: Map, project: XerSolvedProject, kind: string): void { + if (target.has(project.projectId)) throw new Error(`task replay: dubbel ${kind}project ${project.projectId}`); + target.set(project.projectId, project); +} + +/** + * Productdeel van één manifestentry. Predicate, baseline en counterfactual worden hier volledig + * zonder grondwaarheid berekend. Per geopend project leeft steeds maar één solveclone tegelijk. + */ +export function replayXerProductBeforeOracle( + bytes: Uint8Array, + candidate: XerTaskReplayCandidate, + options: XerReplayOptions = {}, +): XerProductReplayBeforeOracle { + const opened = readXER(bytes); + const imports = isMultiDocumentImport(opened) + ? opened.taskProjects.map(document => document.result) + : [opened]; + const baseline = new Map(); + const counterfactual = new Map(); + const predicate: XerReplayPredicateLog[] = []; + let projectsSolvedSequentially = 0; + let activeSolveClones = 0; + + function solveOne( + imported: XerReplayMutableSolveInput, + phase: XerReplayLifecycleEvent['phase'], + inputOrigin: XerReplayLifecycleEvent['inputOrigin'], + ): XerReplaySolveResult { + activeSolveClones++; + try { + options.onLifecycleEvent?.({ + projectId: imported.project.id, + phase, + inputOrigin, + activeSolveClones, + }); + return solveImported(imported); + } finally { + activeSolveClones--; + } + } + + for (const imported of imports) { + const decisions = sourceContexts(imported).map(context => ({ + context, + decision: candidate.predicate(context), + })); + const matchedTaskIds = new Set(decisions + .filter(({ decision }) => decision.matched) + .map(({ context }) => context.task.id)); + const projectPredicate = decisions.map(({ context, decision }) => ({ + projectId: context.projectId, + sourceTaskId: context.task.id, + taskCode: context.task.wbsCode, + matched: decision.matched, + source: decision.source, + })); + predicate.push(...projectPredicate); + + const replayInput = cloneSolveInput(imported); + const baselineSolve = solveOne(replayInput, 'baseline', 'fresh-source-clone'); + for (const log of projectPredicate) { + if (!log.sourceTaskId.trim()) continue; + const trace = baselineSolve.plannedFloorTraceBySourceTaskId[log.sourceTaskId]; + const backwardFloatTrace = options.includeBackwardFloatTrace + ? baselineSolve.backwardFloatTraceBySourceTaskId[log.sourceTaskId] + : undefined; + if (!trace && !backwardFloatTrace) continue; + log.source = { + ...log.source, + ...(trace ? { + plannedFloorTracePreFloorEarlyStart: trace.preFloorEarlyStart, + plannedFloorTracePreFloorEarlyFinish: trace.preFloorEarlyFinish, + plannedFloorTraceTargetStart: trace.targetStart, + plannedFloorTraceTargetFinish: trace.targetFinish, + plannedFloorTracePlannedWindowIsLater: trace.plannedWindowIsLater, + plannedFloorTraceBoundarySource: trace.boundarySource, + ...(trace.boundarySequenceId + ? { plannedFloorTraceBoundarySequenceId: trace.boundarySequenceId } + : {}), + ...(trace.boundaryPredecessorTaskCode + ? { plannedFloorTraceBoundaryPredecessorTaskCode: trace.boundaryPredecessorTaskCode } + : {}), + } : {}), + ...(backwardFloatTrace ? { + backwardFloatTraceProjectEndSource: baselineSolve.backwardFloatTraceProjectEndSource, + backwardFloatTraceLateFinishSource: backwardFloatTrace.lateFinishSource, + backwardFloatTraceLateStartSource: backwardFloatTrace.lateStartSource, + backwardFloatTraceFreeFloatSource: backwardFloatTrace.freeFloatSource, + backwardFloatTraceDisplayActualLate: backwardFloatTrace.displayActualLate, + backwardFloatTraceCompletedWindowEligible: backwardFloatTrace.completedWindow.eligible, + backwardFloatTraceCompletedWindowReason: backwardFloatTrace.completedWindow.reason, + backwardFloatTraceBackwardActualPinEligible: backwardFloatTrace.backwardActualPin.eligible, + backwardFloatTraceBackwardActualPinReason: backwardFloatTrace.backwardActualPin.reason, + backwardFloatTraceDisplayActualLateEligible: backwardFloatTrace.displayActualLateDecision.eligible, + backwardFloatTraceDisplayActualLateReason: backwardFloatTrace.displayActualLateDecision.reason, + } : {}), + }; + } + addProject(baseline, baselineSolve.project, 'baseline'); + projectsSolvedSequentially++; + + const inputOrigin = candidate.replayFrom === 'source' + ? 'fresh-source-clone' + : 'baseline-solved-clone'; + if (candidate.replayFrom === 'source') Object.assign(replayInput, cloneSolveInput(imported)); + candidate.apply(replayInput, matchedTaskIds); + addProject(counterfactual, solveOne(replayInput, 'counterfactual', inputOrigin).project, 'counterfactual'); + projectsSolvedSequentially++; + } + + for (const imported of imports) { + for (const project of materializedBaselineProjects(imported)) { + if (baseline.has(project.projectId)) continue; + addProject(baseline, project, 'baseline'); + addProject(counterfactual, structuredClone(project), 'counterfactual'); + predicate.push(...project.tasks.map(task => ({ + projectId: project.projectId, + sourceTaskId: task.sourceTaskId, + taskCode: task.taskCode, + matched: false, + source: { materializedBaseline: true }, + }))); + } + } + + return { + candidateId: candidate.id, + baseline: [...baseline.values()], + counterfactual: [...counterfactual.values()], + predicate, + projectsSolvedSequentially, + }; +} + +export const syntheticZeroRegressionCandidate: XerTaskReplayCandidate = { + id: 'synthetic-zero-regression', + replayFrom: 'source', + predicate: context => ({ + matched: false, + source: { + p6Source: context.schedulingOptions?.p6Source ?? null, + activityType: context.task.p6ActivityType ?? null, + }, + }), + apply: () => undefined, +}; + +/** Onderzoeks-counterfactual vanaf een verse XER-bronclone: verwijder alleen de finish-milestone-boundary. */ +export const dropFinishMilestoneBoundaryCandidate: XerTaskReplayCandidate = { + id: 'drop-p6-finish-milestone-boundary', + replayFrom: 'source', + predicate: context => { + const source = { + p6Source: context.schedulingOptions?.p6Source ?? null, + boundaryEnabled: context.schedulingOptions?.p6FinishMilestoneBoundaryWindow === true, + useProjectEndDateForFloat: context.schedulingOptions?.useProjectEndDateForFloat === true, + activityType: context.task.p6ActivityType ?? null, + milestoneKind: context.task.milestoneKind ?? null, + zeroDurationMilestone: isZeroDurationMilestone(context.task), + predecessorCount: context.incoming.length, + successorCount: context.outgoing.length, + calendarHoursPerDay: context.taskCalendar.hoursPerDay, + assignmentCount: context.assignments.length, + }; + return { + matched: source.p6Source === 'XER' + && source.boundaryEnabled, + source, + }; + }, + apply: (imported, matchedTaskIds) => { + if (matchedTaskIds.size === 0) return; + const schedulingOptions = imported.project.schedulingOptions; + if (!schedulingOptions || schedulingOptions.p6Source !== 'XER') return; + imported.project.schedulingOptions = { + ...schedulingOptions, + p6FinishMilestoneBoundaryWindow: false, + }; + }, +}; diff --git a/vite.config.ts b/vite.config.ts index a67413349..628aafff5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -94,8 +94,11 @@ export default defineConfig({ return m ? `locale-${m[1]}` : 'locales'; } // MPP-lezer (CFB-container + fieldmaps, fase 3.8 e1, T8): alleen dynamisch geïmporteerd - // via de mpp-entry in formatRegistry.ts, dus een eigen chunk houdt hem uit de main graf. - if (id.includes('/src/services/mpp/')) return 'mpp-reader'; + // via de mpp-entry in formatRegistry.ts, dus een eigen chunk houdt hem uit de main graf. + if (id.includes('/src/services/mpp/')) return 'mpp-reader'; + // XER-lezer: byteparser, kalenderdecoder en semantische reader worden alleen via de + // registry geladen en blijven samen in een eigen lazy chunk. + if (id.includes('/src/services/xer/')) return 'xer-reader'; if (id.includes('/node_modules/')) { // React runtime (react, react-dom, its scheduler dep). if (