From d5f5e73480f4d932f75166c77023b37b7d6cd3a3 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:03:32 +0200 Subject: [PATCH 001/171] =?UTF-8?q?docs(plan):=20etappe=202=20=E2=80=94=20?= =?UTF-8?q?XER=20lezen,=20getrouw=20aan=20P6=20op=20vier=20assen=20(concep?= =?UTF-8?q?t=20na=20drie=20verkenningsrondes)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-08-20-plan-xer-p6-lezer.md | 295 ++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md 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..c46e10728 --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md @@ -0,0 +1,295 @@ +# Etappe 2 — Primavera XER lezen, getrouw aan P6 op vier assen + +*Levend etappeplan, aangemaakt 2026-08-20 na drie verkenningsrondes (corpus-inventaris, +MPXJ-referentiestudie, eigen-laag-inventaris — rapporten in de sessie van die dag). Eigenaar van +dit document is de orkestrator; besluiten en bevindingen worden hier bijgeschreven zoals bij +`2026-08-17-plan-mpp-nul-afwijkingen.md`.* + +## §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 op de vier +datum-assen, en float-gelijkheid binnen de uur-eenheid van het formaat. De baseline bestaat +uitsluitend uit nullen, zonder één reason-pin — het `GOAL_ZERO_DEVIATIONS`-model van etappe 1, +maar dan met zes tellers per bestand in plaats van vier. + +Dit is bewust ambitieuzer dan etappe 1 op precies het punt waar etappe 1 blind was: de +.mpp-meetlat kon alleen datums meten, waardoor float-fouten (de R2/B3-klasse restpunten) alleen +door reviewers met handwerk gevonden werden. XER levert P6's float gewoon als kolom mee — dus +maken we hem tot poort. + +**Wat er níét in deze etappe zit**: XER schríjven (export) — dat is een eigen, latere etappe +(MPXJ's `PrimaveraXERFileWriter` is er de referentie voor; registratie in `docs/TODO.md` bij Z20 +van deze etappe). En de taaktypes/effort-driven-mótor blijft de aparte etappe uit +`2026-08-18-spec-taaktypes-effort-driven.md`; hier worden P6's duration- en activiteitstypes wél +gelezen en bewaard (superset-voedingsdata), maar sturen ze geen berekening. + +## §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 aangeleverd krijgt. +- Het fundament ligt er: de format-registry maakt een `.xer`-entry een patroonvolgend haakje + (lazy chunk, dialoogfilters, i18n-fouten — het .mpp-stramien), de meetkern van het + fidelity-harnas is formaat-agnostisch, en de solver leest `schedulingOptions` al volledig + terwijl nog geen enkele lezer ze vult — XER's SCHEDOPTIONS-tabel wordt de eerste. +- Het corpus is er al, en het is publiek: 93 crawl-bestanden (P6 5.0 t/m 24.x), 21.096 taken + waarvan 17.963 (85%) alle vier orakel-assen dragen, plus een meegecrawld raamwerk met 13 + scenario's waarvan de uitkomsten door echt P6 23.12 zijn geverifieerd. Geen bedrijfsdata nodig: + bestandsnamen mogen dit keer gewoon in tests en commits. + +## §3 De twee meetlatten + +1. **Corpus-orakel (bulk)**: P6 bewaart zijn laatste rekenuitvoer per taak in de TASK-tabel — + `early_start_date`/`early_end_date`/`late_start_date`/`late_end_date`/`total_float_hr_cnt`/ + `free_float_hr_cnt`. Een eigen `xerGroundTruth` leest die velden met een **onafhankelijke, + minimale tabelscan** — bewust een tweede parser naast de echte lezer, zodat een bug in de + lezer niet in de meetlat kan doorsijpelen (de les van F7 uit de etappe-1-eindreview: gedeelde + veldkaarten zijn common-mode; hier houden we zelfs de tabel-tokenizer gescheiden). + Statussemantiek hoort bij de meetlat: voltooide taken hebben lege early-velden en meten op + `act_`-datums; per bestand kan de float-kolom leeg zijn terwijl de datums gevuld zijn + (gemeten: dat is een deelveld-gat, geen alles-of-niets) — de teller-administratie kent daarom + per as een "meetbaar"-aantal naast het afwijkingental. +2. **P6-geverifieerde scenario's (scherp)**: de `p6-comparison`-map bevat 13 kleine cases + (FS/SS/FF/SF-lag, ALAP, SNET+FNLT, mandatory start/finish, multi-kalender, feestdagen, + in-progress/retained-logic, completed-successor, out-of-sequence) met per case ES/EF/LS/LF/ + TF/FF zoals **echt P6 23.12** ze produceerde. Die worden een eigen, corpus-onafhankelijke + casebatterij (`cases-p6-verified.json`) in de planningssuite — de XER-tegenhanger van + etappe 1's corpusloze cases, maar met een externe autoriteit als bron. De twee cases die het + raamwerk zelf buiten de matrix hield (fractional-lag, dangling-relationship) worden + overgenomen als gedocumenteerde niet-reproduceerbaar-in-P6-randgevallen, geen poort. + +Niet-orakel-bestanden (de ongerekende fixtures, de opzettelijk kapotte robuustheidsbestanden, +het 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.** De scheiding is in XER formeel: + `early_*`/`late_*`/`*_float_hr_cnt`/`driving_path_flag`/`float_path` zijn REKENUITVOER en + mogen uitsluitend door `xerGroundTruth` gelezen worden; `target_*`, `act_*`, `cstr_*`, + `duration_type`, `task_type`, `suspend_date`/`resume_date` en de relaties zijn INVOER en zijn + het domein van de lezer. De lezer importeert geen enkel `early_`/`late_`/float-veld — ook + niet "tijdelijk", ook niet als terugval. Drie afgekeurde pogingen in etappe 1 zijn het + waarschuwende precedent. +2. **De veld-als-signaal-regel** (vastgelegd bij etappe 1, §Aandachtspunten etappe 2): + veld-aanwezigheid op `Task` ís semantiek-signaal — `resume` gezet betekent vandaag "MSP- + hervattingsconventie". 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 flag-isatie-kandidaten, gemeten in te plannen in plaats van als verrassing: de + lag-kalender (in P6 een SCHEDOPTIONS-instelling, bij ons al `schedulingOptions.lagCalendar` — + eindelijk een echte vuller), retained logic vs. progress override (P6-expliciet; + `progressMode` bestaat), en de Z10/Z11-relatieregels waar P6 eigen semantiek blijkt te hebben. +3. **Corpus is publiek**: de 93 crawlbestanden mogen bij naam in tests, commits en rapporten. + Duiken er ooit bedrijfs-XER-bestanden op, dan geldt daarvoor de hash-only-regel van etappe 1 + onverkort. +4. **MPXJ (LGPL-2.1) uitsluitend lezen-om-te-begrijpen**; onafhankelijk herimplementeren, + herkomstvermelding per bestand met klasse-/methodeverwijzing, nooit code overnemen. Zelfde + regel voor het meegecrawlde cpp-cpm-engine-raamwerk (alleen de *data* van p6-comparison is + bruikbaar als meetlat; hun engine-code is geen referentie). +5. **Exitcode is de poort, nooit de tail**; blast-radius meten vóór verbreden; regels formuleren + op de invoer; diagnose op bladniveau; elke nieuwe decodeerregel krijgt een corpusloze + (synthetische) fixture naast zijn corpuspin. +6. **Reviewpijplijn**: verse Sonnet-implementer per taak → Opus-review voor motor-, meetlat- en + byte/grammatica-werk, Sonnet-spec-review voor mechanisch werk → fixronde bij dezelfde + implementer → her-check bij dezelfde reviewer. Reviewers draaien mutatiebewijzen zelf na. + Mergen één taak per keer met de fidelity-tellers vóór/ná in het merge-commit. + +## §5 Openstaande eigenaarsbesluiten (X-O1 t/m X-O4) + +- **X-O1 — multi-project-XER.** 6 corpusbestanden dragen meerdere projecten in één bestand + (tot 15). P6 markeert het geëxporteerde hoofdproject (`export_flag`). Voorstel: het + hoofdproject openen als document en de overige projecten als extra documenten aanbieden + (zelfde multi-document-model als de app al heeft), met cross-project-relaties als + `externalLinks`. Minimaal alternatief: alleen het hoofdproject, met een melding hoeveel + projecten er nog meer in zaten. **Tot besloten: het minimale alternatief bouwen; de + meerdocument-variant als vervolgtaak registreren.** +- **X-O2 — baseline-projecten.** XER kent geen apart baselinetype; een baseline is een tweede + PROJECT-rij waarnaar `sum_base_proj_id` verwijst. Voorstel: bij het openen van het + hoofdproject een gekoppeld baselineproject als OPS-baseline materialiseren (we hebben + meerdere-baselines-infrastructuur). Kandidaat voor de tweede helft van de etappe; geen + goal-voorwaarde. +- **X-O3 — de float-assen in de eindpoort.** Voorstel en aanname van dit plan: TF/FF tellen + volwaardig mee in de nul-poort, met de uur-eenheid van het formaat als vergelijkingsprecisie + en per as een "meetbaar"-teller (lege kolommen meten niet). Mocht de residu-iteratie een + principieel P6-float-definitieverschil blootleggen dat niet via `schedulingOptions` + (totalFloatMode, criticalDefinition) te vangen is, dan gaat dat als beslispunt terug naar de + eigenaar — niet stilzwijgend versoepelen. +- **X-O4 — encoding-gok.** XER draagt geen encoding-declaratie; de industrie-defacto is CP1252, + het corpus bevat ook UTF-8, MacRoman en Windows-1254. Voorstel: BOM-detectie → anders een + lichte heuristiek (geldige-UTF-8-toets over het hele bestand; bij falen CP1252), en de + gebruikte keuze in de openingsmelding benoemen wanneer er niet-ASCII-tekens in zaten. Geen + gebruikersinstelling in deze etappe. + +## §6 Banen en taken + +Vier banen, elk een eigen worktree (`.claude/worktrees/xer-{meetlat,lezer,motor,data}`), zoals +etappe 1. X-nummering; volgorde binnen een baan is dwingend, banen lopen parallel na X0/X1. + +### SERIEEL VOORAF + +**X0 — Typen, harness-skelet, registry-stub.** Task-/ImportResult-velden die de etappe nodig +heeft als compile-gedekte typen (P6-duration-type en -activiteitstype als opgeslagen data — +*eigen* velden naast `mspTaskType`, geen hergebruik: de veld-als-signaal-regel geldt ook voor +typen; suspend/resume-herkomstvlag; zie X8). Corpusscan-tooling (`OPS_XER_CORPUS`-env naar het +patroon van `OPS_MPP_CORPUS`). Nog géén registry-entry — die komt pas bij X4, als er echt +gelezen wordt. **Acceptatie**: typecheck-poorten; een lege-lezer-run produceert een lege maar +welgevormde baseline. + +**X1 — De meetlat éérst (baan M).** `tests/planning/xerGroundTruth.ts`: onafhankelijke, minimale +%T/%F/%R-scan die per taak de zes orakel-assen + status + act-datums levert, met eigen +encoding-afhandeling. `xerFidelity.ts` op de generieke meetkern (zelfde `solveProject`-keten, +zelfde classificatie exact/sameday/diff/missing, uitgebreid met de float-assen). +`check-xer-fidelity.ts` met per-bestand-pinning (bestandsnaam als sleutel — publiek corpus), +zes tellers + zes meetbaar-tellers, `OPS_XER_FIDELITY_REPORT`-modi, en de reason-verplichting +bij elke niet-nul-pin (de wacht tijdens de etappe; de eindpoort vervangt hem in X12). +Plus: de p6-comparison-extractie naar `cases-p6-verified.json` met een generator-script dat de +13 comparison.csv's omzet (herkomst: alleen de data). **Acceptatie**: mutatie-bewezen +(meetlatveld verleggen ⇒ rood); de dag-grens-normalisatie die het raamwerk zelf toepaste is +gedocumenteerd en bewust overgenomen of verworpen. + +### 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 — P6-viezigheid, herkomst MPXJ `NotesHelper`), de +lege-eerste-token-continuatieregel, onbekende tabellen overslaan, en de **CURRTYPE-tweepas**: +decimaal- en duizendtekens komen uit het bestand zelf en bepalen het getalparsen van álle +andere tabellen. Encoding per X-O4. Fout-tolerantie is een bewuste keuze: kapotte rijen +verzamelen in een import-rapportstructuur (geen stille skip zoals MPXJ's default, geen harde +crash) — de openingsmelding (X10) toont het aantal. **Acceptatie**: de drie opzettelijk kapotte +corpusbestanden (`p6xer-malformed.xer`, `p6xer-encodings.xer`, `p6xer-empty_tables.xer`) en de +zeven `kedular-*`-randgevallen lezen zonder crash met de verwachte rapportinhoud, gepind per +bestand; synthetische fixtures voor elke grammaticaregel; de platte-variant-bestanden zonder +%F-headers geven een nette typed fout. + +**X3 — De kalenderdecoder.** `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 (gedocumenteerd P6-gedrag), `base_clndr_id`-hiërarchie in een tweede pas, +`clndr_type` (global/project/resource), en de uren-per-periode-velden met de +afleiding-uit-weekuren-terugval — inclusief de gedocumenteerde waarschuwing dat P6 die velden +niet valideert. Aansluiting op de bestaande `subdayIo.ts`-promotie (uur- vs dagmodus) — de +holiday-bewuste laag uit etappe 1 werkt hier door. **Acceptatie**: corpusloze fixtures per +grammatica-element; corpuspin op het 124-kalender-monster (`rehab-2.xer`); de +kalenderpromotie-discriminatoren gedragen zich identiek aan de P6-XML-route (pariteitstest +tegen `parseP6StandardWorkWeek` op een equivalent kalenderpaar). + +**X4 — Kern-mapping + registry-entry.** `src/services/xer/xerReader.ts`: PROJECT (hoofdproject +via `export_flag`, X-O1-minimum met telling), PROJWBS (sorteren op `(parent_wbs_id, seq_num)` — +de bestandvolgorde is onbetrouwbaar), TASK (statussen `TK_*`; milestones uit `TT_Mile`/ +`TT_FinMile`/`TT_StartMile`; `TT_LOE` → `isHammock`; `TT_WBS`-summary; duration- en +activiteitstype als opgeslagen data), TASKPRED (`PR_*` én de prefixloze variant uit oudere +tools — gemeten in 6 bestanden), constraints (`CS_*` incl. mandatory → `hard`), en de +`ExternalRelation`-klasse voor cross-project-randen. Format-registry-entry (`kind: 'text'`, +lazy chunk, `canBeSaveTarget` blijft IFC-only), i18n-foutmeldingen in 14 talen naar het +.mpp-patroon. **Acceptatie**: eerste fidelity-nulmeting over het corpus draait en pint; de +schone-baseline-cases (`01-small-clean-baseline` e.d.) op exact nul op de datum-assen. + +### BAAN S — motor en semantiek + +**X5 — SCHEDOPTIONS en de vlaggen.** De eerste lezer die `project.schedulingOptions` vult: +`sched_calendar_on_relationship_lag` → `lagCalendar` (P6-default: predecessor), retained +logic/progress override → `progressMode`, critical-definitie en float-modus voor zover de +tabel ze draagt. Elke instelling die ons huidige default-gedrag zou veranderen wordt +blast-radius-gemeten over het corpus vóór hij doorwerkt. **Acceptatie**: de +in-progress/retained-logic- en completed-successor-cases uit `cases-p6-verified.json` groen; +corpusbestanden zonder SCHEDOPTIONS byte-identiek aan vóór deze taak. + +**X6 — Resources en toewijzingen.** RSRC/RSRCRATE/TASKRSRC: het rollen-vs-resources-ID- +botsingsprobleem (aparte naamruimten), units-schalen (de 1.000.000- en ×100-conventies zijn +per veld verschillend — herkomst MPXJ, onafhankelijk verifiëren tegen corpuswaarden), +`TT_Rsrc`-activiteiten en de resourcekalender-koppeling. Het curves-dossier is klein en +afgebakend: 2 corpusrijen met `curv_id` (beide in `rehab-2.xer`) plus de RSRCCURVDATA-tabel +(21-punts verdeling) — mappen naar onze curve-typen op best-fit, met de rauwe 21 punten als +opgeslagen data (eigenaarsprincipe; voedsel voor de latere contour-engine, zelfde patroon als +`timephasedContours`). **Acceptatie**: corpuspins op de resource-rijke bestanden +(`Roads_Project_TEC.xer`, `rehab-2.xer`); geen datumbeweging op bestanden zonder resources. + +**X7 — Suspend/resume en voortgang.** P6's suspend/resume ↔ ons `TaskTime.stop`/`resume` — +hiér slaat de veld-als-signaal-regel toe: het veld bestaat, maar de huidige solver-semantiek +eromheen is de MSP-conventie uit etappe 1. Eerst meten (de 22 corpustaken met suspend), dan +per verschil een bron-vlag naar het O6-patroon in plaats van stil hergebruik. P6-voortgang +(act-datums, `complete_pct_type`-varianten) tegen de bestaande actuals-invarianten. +**Acceptatie**: het out-of-sequence-scenario uit de P6-geverifieerde cases groen; de +suspend-dragende bestanden gepind; mutatiebewijs op elke nieuwe vlag-tak. + +### BAAN D — data en randen + +**X8 — Activity codes, UDF's, notities.** ACTVTYPE/ACTVCODE/TASKACTV → onze bestaande +`activityCodeTypes`-structuren (119.878 corpus-koppelingen — de zwaarste datamassa van de +etappe; prestatie meten op `rehab-2.xer` en `Hotel Project.xer`); UDFTYPE/UDFVALUE → onze +`customFieldDefs` (datatype-mapping); memo-tabellen → taaknotities (DEL-DEL-decodering uit X2). +**Acceptatie**: round-trip door het IFC (de bestaande psets dragen dit al — aantonen, niet +aannemen); tellingen gepind per corpusbestand. + +**X9 — Documentcontract, round-trip en exportranden.** Alle nieuwe velden (P6-typen, +herkomstvlaggen, rauwe curvepunten) door het documentcontract en de IFC-round-trip; de +exportranden warnen naar het bestaande patroon (MSPDI/P6-XML-writers: wat een XER-import draagt +en de exporteur niet uitdrukken kan); `moveProject`-verdicten; MCP-leeskant conform het +etappe-1-besluit. **Acceptatie**: het mutatiebewijs-stramien van Z14 (property weg ⇒ rood op +precies dat veld; byte-identieke examples zonder regeneratie). + +**X10 — Melding en gidsen.** De openingsmelding voor .xer naar het Z16-model: echte tellingen +(overgeslagen kapotte rijen, meegelezen extra projecten (X-O1), encoding-keuze bij niet-ASCII), +`severity: info`, 14 talen, CLDR-pluralen. Gidsen (nl+en): een eigen "Primavera P6 (.xer) +openen"-artikel naar het model van de .mpp-gids — elke planningsclaim met code-/testverwijzing, +en een eerlijke paragraaf over wat er (nog) niet meekomt (multi-project, baselines tot X-O2, +curves-als-verdeling). `verify:docs`-poort. **Acceptatie**: mutatiebewijzen op melding, i18n en +manifest, zoals Z16. + +### SERIEEL — afronding + +**X11 — Browser-gebruikstest** (aparte agent, tier 1): openen van de dossierselectie +(schone baseline, `rehab-2.xer`, multi-kalender, negatieve float, het torture-bestand), +IFC-opslaan/heropenen met veldbehoud, F5-stabiliteit, meldingen en gidslinks, taalwissel, +undo/documentwissel — het Z18-draaiboek, plus de nieuwe vraag: blijft de app vlot op de +119k-koppelingen-bestanden. + +**X12 — Residu naar nul en de eindpoort.** Detail-rapportage per as → classificeren op +bladniveau → echte fout fixen met bewijs, of escaleren per §5/X-O3; "pinnen met reden" bestaat +niet als uitweg. Daarna `GOAL_ZERO_DEVIATIONS_XER` aan (zes tellers + reason-verbod + +meetbaar-dekking-bewaking naar het F1-model: `gemetenExact === meetbaar` per as), TODO-registraties +(XER-export; multi-document-variant van X-O1; X-O2 indien onafgemaakt), en de hyperkritische +Opus-eindreview over de volledige etappe-diff — inclusief de sluiproute-scan op de +invoer/meetlat-scheiding van §4.1. + +## §7 Parallellisering + +``` +X0 ─ X1 (serieel) + ├── baan F: X2 → X3 → X4 ──┐ + ├── baan S: (na X4) X5 → X6 → X7 ──┤ + ├── baan D: (na X4) X8 → X9 → X10 ─┤ + └────────────────── X11 → X12 (serieel) +``` +Baan S en D hangen op X4 (er moet een lezer zijn); binnen F is de volgorde dwingend +(grammatica → kalender → mapping). De meetlat (X1) staat vóór alles, net als in etappe 1 — +en om dezelfde reden: wie eerst bouwt en dan meet, meet zijn eigen aannames. + +## §8 Risico's, eerlijk benoemd + +1. **De float-assen zijn onontgonnen terrein.** Etappe 1 heeft de datum-assen van de motor + glashard gemaakt, maar float is nooit tegen een extern orakel gemeten. Verwacht hier de + zwaarste dossiers: P6's float-definities (start/finish/smallest), de omgang met open einden + (`makeOpenEndedCritical`), ALAP, en negatieve float (63 corpustaken, geconcentreerd in twee + bestanden — dat worden dossiers). Dit is tegelijk de grootste winst: elke gevonden fout hier + verbetert de motor voor álle formaten. +2. **`clndr_data` is de bytepuzzel van deze etappe** — kleiner dan de .mpp-CFB-wereld, maar met + dezelfde valkuilcategorie (een eigen grammatica, AM/PM-verrassingen, epoch-conversie, + ongevalideerde uren-per-dag-velden die de uur→dag-omrekening van álle duren en floats + bepalen). De uren-per-dag-afleiding raakt de fidelity van elk bestand; blast-radius-discipline + vanaf dag één. +3. **Encoding en getalnotatie zitten ín het bestand** (CURRTYPE-tweepas, CP1252-gok). Fouten + hier zijn stil en corrumperen tekst of getallen zonder rood scherm — vandaar de + robuustheidsbestanden als eigen poort in X2. +4. **Schaal**: `rehab-2.xer` (6.977 taken, 52.640 toewijzingen, 81k code-koppelingen) is 2× + het grootste bestand dat de app ooit las. De 5000-taken-prestatie-eis van de eigenaar staat; + X11 meet het expliciet. +5. **Scope-verleiding.** Multi-project, baselines en XER-export zijn elk een halve etappe op + zich; X-O1/X-O2 begrenzen ze bewust. De goal is lezen-getrouw-op-vier-assen — al het andere + is registreerbare vervolgambitie. From deff3a7041b97b31cbe0eee816fd7c445caf481d Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:19:47 +0200 Subject: [PATCH 002/171] =?UTF-8?q?docs(plan):=20XER-etappeplan=20herwerkt?= =?UTF-8?q?=20na=20hyperkritische=20review=20=E2=80=94=20granulariteit/uur?= =?UTF-8?q?modus=20als=20kernbesluit,=20SCHEDOPTIONS-defaults-programma,?= =?UTF-8?q?=20whitelist-invoerregel,=20projectselectie=20herzien,=20X-O5?= =?UTF-8?q?=20getalnotatie,=20mutatiebewijzen=20per=20taak?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-08-20-plan-xer-p6-lezer.md | 559 ++++++++++-------- 1 file changed, 314 insertions(+), 245 deletions(-) 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 index c46e10728..9894e3321 100644 --- a/docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md +++ b/docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md @@ -1,295 +1,364 @@ # Etappe 2 — Primavera XER lezen, getrouw aan P6 op vier assen -*Levend etappeplan, aangemaakt 2026-08-20 na drie verkenningsrondes (corpus-inventaris, -MPXJ-referentiestudie, eigen-laag-inventaris — rapporten in de sessie van die dag). Eigenaar van -dit document is de orkestrator; besluiten en bevindingen worden hier bijgeschreven zoals bij -`2026-08-17-plan-mpp-nul-afwijkingen.md`.* +*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`.* ## §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 op de vier -datum-assen, en float-gelijkheid binnen de uur-eenheid van het formaat. De baseline bestaat -uitsluitend uit nullen, zonder één reason-pin — het `GOAL_ZERO_DEVIATIONS`-model van etappe 1, -maar dan met zes tellers per bestand in plaats van vier. - -Dit is bewust ambitieuzer dan etappe 1 op precies het punt waar etappe 1 blind was: de -.mpp-meetlat kon alleen datums meten, waardoor float-fouten (de R2/B3-klasse restpunten) alleen -door reviewers met handwerk gevonden werden. XER levert P6's float gewoon als kolom mee — dus -maken we hem tot poort. - -**Wat er níét in deze etappe zit**: XER schríjven (export) — dat is een eigen, latere etappe -(MPXJ's `PrimaveraXERFileWriter` is er de referentie voor; registratie in `docs/TODO.md` bij Z20 -van deze etappe). En de taaktypes/effort-driven-mótor blijft de aparte etappe uit -`2026-08-18-spec-taaktypes-effort-driven.md`; hier worden P6's duration- en activiteitstypes wél -gelezen en bewaard (superset-voedingsdata), maar sturen ze geen berekening. +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 aangeleverd krijgt. +- `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 meetkern van het - fidelity-harnas is formaat-agnostisch, en de solver leest `schedulingOptions` al volledig - terwijl nog geen enkele lezer ze vult — XER's SCHEDOPTIONS-tabel wordt de eerste. -- Het corpus is er al, en het is publiek: 93 crawl-bestanden (P6 5.0 t/m 24.x), 21.096 taken - waarvan 17.963 (85%) alle vier orakel-assen dragen, plus een meegecrawld raamwerk met 13 - scenario's waarvan de uitkomsten door echt P6 23.12 zijn geverifieerd. Geen bedrijfsdata nodig: - bestandsnamen mogen dit keer gewoon in tests en commits. + (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. Daarvan is 47% een duplicaat-paar (het Hotel-schema staat er 2× in met + identieke taken) en staan de vier Harbour Point-bestanden er elk 4× in — de fidelity-pinning + dedupliceert daarom op inhoudshash, en het unieke orakel is realistisch ~10.000 taken over + ~50 unieke bestanden. Nog altijd ~3× het .mpp-corpus, en 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 — - `early_start_date`/`early_end_date`/`late_start_date`/`late_end_date`/`total_float_hr_cnt`/ - `free_float_hr_cnt`. Een eigen `xerGroundTruth` leest die velden met een **onafhankelijke, - minimale tabelscan** — bewust een tweede parser naast de echte lezer, zodat een bug in de - lezer niet in de meetlat kan doorsijpelen (de les van F7 uit de etappe-1-eindreview: gedeelde - veldkaarten zijn common-mode; hier houden we zelfs de tabel-tokenizer gescheiden). - Statussemantiek hoort bij de meetlat: voltooide taken hebben lege early-velden en meten op - `act_`-datums; per bestand kan de float-kolom leeg zijn terwijl de datums gevuld zijn - (gemeten: dat is een deelveld-gat, geen alles-of-niets) — de teller-administratie kent daarom - per as een "meetbaar"-aantal naast het afwijkingental. -2. **P6-geverifieerde scenario's (scherp)**: de `p6-comparison`-map bevat 13 kleine cases - (FS/SS/FF/SF-lag, ALAP, SNET+FNLT, mandatory start/finish, multi-kalender, feestdagen, - in-progress/retained-logic, completed-successor, out-of-sequence) met per case ES/EF/LS/LF/ - TF/FF zoals **echt P6 23.12** ze produceerde. Die worden een eigen, corpus-onafhankelijke - casebatterij (`cases-p6-verified.json`) in de planningssuite — de XER-tegenhanger van - etappe 1's corpusloze cases, maar met een externe autoriteit als bron. De twee cases die het - raamwerk zelf buiten de matrix hield (fractional-lag, dangling-relationship) worden - overgenomen als gedocumenteerde niet-reproduceerbaar-in-P6-randgevallen, geen poort. - -Niet-orakel-bestanden (de ongerekende fixtures, de opzettelijk kapotte robuustheidsbestanden, -het 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. +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 (gemeten over de 60 orakelbestanden: ES/EF in 52, LS/LF in 40, 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.** De scheiding is in XER formeel: - `early_*`/`late_*`/`*_float_hr_cnt`/`driving_path_flag`/`float_path` zijn REKENUITVOER en - mogen uitsluitend door `xerGroundTruth` gelezen worden; `target_*`, `act_*`, `cstr_*`, - `duration_type`, `task_type`, `suspend_date`/`resume_date` en de relaties zijn INVOER en zijn - het domein van de lezer. De lezer importeert geen enkel `early_`/`late_`/float-veld — ook - niet "tijdelijk", ook niet als terugval. Drie afgekeurde pogingen in etappe 1 zijn het - waarschuwende precedent. -2. **De veld-als-signaal-regel** (vastgelegd bij etappe 1, §Aandachtspunten etappe 2): - veld-aanwezigheid op `Task` ís semantiek-signaal — `resume` gezet betekent vandaag "MSP- - hervattingsconventie". 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 flag-isatie-kandidaten, gemeten in te plannen in plaats van als verrassing: de - lag-kalender (in P6 een SCHEDOPTIONS-instelling, bij ons al `schedulingOptions.lagCalendar` — - eindelijk een echte vuller), retained logic vs. progress override (P6-expliciet; - `progressMode` bestaat), en de Z10/Z11-relatieregels waar P6 eigen semantiek blijkt te hebben. -3. **Corpus is publiek**: de 93 crawlbestanden mogen bij naam in tests, commits en rapporten. - Duiken er ooit bedrijfs-XER-bestanden op, dan geldt daarvoor de hash-only-regel van etappe 1 - onverkort. +1. **Het opgeslagen antwoord is meetlat, nooit uitkomst — als WHITELIST (planreview M2).** + 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`), + `status_code`, `task_type`, `duration_type`, `priority_type`, `complete_pct_type` + + percentages, `target_drtn_hr_cnt`/`remain_drtn_hr_cnt`, `target_start_date`/ + `target_end_date`, `act_start_date`/`act_end_date`, `cstr_type`/`cstr_date` (+`2`), + `suspend_date`/`resume_date`, 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 de rekenuitvoer die er als gewone datumvelden uitziet: + `early_*`, `late_*`, `restart_date`/`reend_date` (P6's remaining-early-datums, 15.328/15.329 + gevulde cellen!), `rem_late_start_date`/`rem_late_end_date` (10.193 elk), + `total_float_hr_cnt`/`free_float_hr_cnt`, `driving_path_flag`, `float_path`(`_order`). + De X12-sluiproute-scan grept tegen de whitelist, niet tegen een opsomming van verboden + velden. Drie afgekeurde pogingen in etappe 1 zijn het precedent. +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). +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 met klasse-/methodeverwijzing, nooit code overnemen. Zelfde - regel voor het meegecrawlde cpp-cpm-engine-raamwerk (alleen de *data* van p6-comparison is - bruikbaar als meetlat; hun engine-code is geen referentie). -5. **Exitcode is de poort, nooit de tail**; blast-radius meten vóór verbreden; regels formuleren - op de invoer; diagnose op bladniveau; elke nieuwe decodeerregel krijgt een corpusloze - (synthetische) fixture naast zijn corpuspin. -6. **Reviewpijplijn**: verse Sonnet-implementer per taak → Opus-review voor motor-, meetlat- en - byte/grammatica-werk, Sonnet-spec-review voor mechanisch werk → fixronde bij dezelfde - implementer → her-check bij dezelfde reviewer. Reviewers draaien mutatiebewijzen zelf na. - Mergen één taak per keer met de fidelity-tellers vóór/ná in het merge-commit. - -## §5 Openstaande eigenaarsbesluiten (X-O1 t/m X-O4) - -- **X-O1 — multi-project-XER.** 6 corpusbestanden dragen meerdere projecten in één bestand - (tot 15). P6 markeert het geëxporteerde hoofdproject (`export_flag`). Voorstel: het - hoofdproject openen als document en de overige projecten als extra documenten aanbieden - (zelfde multi-document-model als de app al heeft), met cross-project-relaties als - `externalLinks`. Minimaal alternatief: alleen het hoofdproject, met een melding hoeveel - projecten er nog meer in zaten. **Tot besloten: het minimale alternatief bouwen; de - meerdocument-variant als vervolgtaak registreren.** -- **X-O2 — baseline-projecten.** XER kent geen apart baselinetype; een baseline is een tweede - PROJECT-rij waarnaar `sum_base_proj_id` verwijst. Voorstel: bij het openen van het - hoofdproject een gekoppeld baselineproject als OPS-baseline materialiseren (we hebben - meerdere-baselines-infrastructuur). Kandidaat voor de tweede helft van de etappe; geen - goal-voorwaarde. -- **X-O3 — de float-assen in de eindpoort.** Voorstel en aanname van dit plan: TF/FF tellen - volwaardig mee in de nul-poort, met de uur-eenheid van het formaat als vergelijkingsprecisie - en per as een "meetbaar"-teller (lege kolommen meten niet). Mocht de residu-iteratie een - principieel P6-float-definitieverschil blootleggen dat niet via `schedulingOptions` - (totalFloatMode, criticalDefinition) te vangen is, dan gaat dat als beslispunt terug naar de - eigenaar — niet stilzwijgend versoepelen. -- **X-O4 — encoding-gok.** XER draagt geen encoding-declaratie; de industrie-defacto is CP1252, - het corpus bevat ook UTF-8, MacRoman en Windows-1254. Voorstel: BOM-detectie → anders een - lichte heuristiek (geldige-UTF-8-toets over het hele bestand; bij falen CP1252), en de - gebruikte keuze in de openingsmelding benoemen wanneer er niet-ASCII-tekens in zaten. Geen + 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 — multi-project-XER (herzien na planreview — het conceptvoorstel was feitelijk fout).** + `export_flag` discrimineert niet (gemeten: 15/15 respectievelijk 4/4 projecten dragen 'Y'; + twee bestanden missen de kolom geheel), en "het eerste project" faalt hard: in beide + Hotel-bestanden draagt het eerste project nul taken en het vierde alle 4.217. Het + concept-minimum ("alleen het hoofdproject") zou 48% van de meetlat wegsnijden. + **Nieuw voorstel**: het project met de meeste taken openen als document (bij gelijkspel het + eerste), met een informatieve melding "dit bestand bevat nog N andere projecten (M taken)"; + relaties naar niet-geopende projecten worden `externalLinks`. De multi-document-variant + (alle projecten als tabbladen) blijft geregistreerde vervolgtaak. *Tot anders besloten wordt + dit gebouwd.* +- **X-O2 — baseline-projecten (aangescherpt).** Een gekoppeld baselineproject + (`sum_base_proj_id` → aanwezige PROJECT-rij) wordt als OPS-baseline gematerialiseerd — + kandidaat voor de tweede etappehelft, geen goal-voorwaarde. **Verplichte dangling-tak** + (planreview): in het corpus verwijzen 9 van de 10 gemeten koppelingen 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. Eén corpusbestand (`stack_data_center_baseline.xer`) draagt + een écht aanwezig baselineproject en wordt de positieve testcase. +- **X-O3 — de float-assen in de eindpoort.** 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 — encoding (gecorrigeerd).** 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 — getalnotatie uit CURRTYPE (nieuw, planreview).** De decimaal/duizend-tekens komen + uit het bestand zelf en zijn niet altijd letterlijk: naast `.`/`,` bestaan symbolische + tokens (`ds_Period`, `dg_Comma`). Voorstel: bekende tokens decoderen; onbekend token of + ontbrekende CURRTYPE-tabel (62 van de 93 bestanden!) ⇒ default punt-decimaal, en bij een + daadwerkelijk aangetroffen komma-decimaal-bestand zonder CURRTYPE een typed fout boven een + stil verkeerd geparsed getal. Dit raakt élk getal — duren en floats incluis — en verdient + daarom een eigen fixture-batterij in X2. ## §6 Banen en taken -Vier banen, elk een eigen worktree (`.claude/worktrees/xer-{meetlat,lezer,motor,data}`), zoals -etappe 1. X-nummering; volgorde binnen een baan is dwingend, banen lopen parallel na X0/X1. +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, registry-stub.** Task-/ImportResult-velden die de etappe nodig -heeft als compile-gedekte typen (P6-duration-type en -activiteitstype als opgeslagen data — -*eigen* velden naast `mspTaskType`, geen hergebruik: de veld-als-signaal-regel geldt ook voor -typen; suspend/resume-herkomstvlag; zie X8). Corpusscan-tooling (`OPS_XER_CORPUS`-env naar het -patroon van `OPS_MPP_CORPUS`). Nog géén registry-entry — die komt pas bij X4, als er echt -gelezen wordt. **Acceptatie**: typecheck-poorten; een lege-lezer-run produceert een lege maar -welgevormde baseline. - -**X1 — De meetlat éérst (baan M).** `tests/planning/xerGroundTruth.ts`: onafhankelijke, minimale -%T/%F/%R-scan die per taak de zes orakel-assen + status + act-datums levert, met eigen -encoding-afhandeling. `xerFidelity.ts` op de generieke meetkern (zelfde `solveProject`-keten, -zelfde classificatie exact/sameday/diff/missing, uitgebreid met de float-assen). -`check-xer-fidelity.ts` met per-bestand-pinning (bestandsnaam als sleutel — publiek corpus), -zes tellers + zes meetbaar-tellers, `OPS_XER_FIDELITY_REPORT`-modi, en de reason-verplichting -bij elke niet-nul-pin (de wacht tijdens de etappe; de eindpoort vervangt hem in X12). -Plus: de p6-comparison-extractie naar `cases-p6-verified.json` met een generator-script dat de -13 comparison.csv's omzet (herkomst: alleen de data). **Acceptatie**: mutatie-bewezen -(meetlatveld verleggen ⇒ rood); de dag-grens-normalisatie die het raamwerk zelf toepaste is -gedocumenteerd en bewust overgenomen of verworpen. +**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-bestand-pinning op **inhoudshash** (dedupliceert de +corpus-duplicaten; naamlabels als leesbaarheid), 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 — P6-viezigheid, herkomst MPXJ `NotesHelper`), de -lege-eerste-token-continuatieregel, onbekende tabellen overslaan, en de **CURRTYPE-tweepas**: -decimaal- en duizendtekens komen uit het bestand zelf en bepalen het getalparsen van álle -andere tabellen. Encoding per X-O4. Fout-tolerantie is een bewuste keuze: kapotte rijen -verzamelen in een import-rapportstructuur (geen stille skip zoals MPXJ's default, geen harde -crash) — de openingsmelding (X10) toont het aantal. **Acceptatie**: de drie opzettelijk kapotte -corpusbestanden (`p6xer-malformed.xer`, `p6xer-encodings.xer`, `p6xer-empty_tables.xer`) en de -zeven `kedular-*`-randgevallen lezen zonder crash met de verwachte rapportinhoud, gepind per -bestand; synthetische fixtures voor elke grammaticaregel; de platte-variant-bestanden zonder -%F-headers geven een nette typed fout. - -**X3 — De kalenderdecoder.** `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 (gedocumenteerd P6-gedrag), `base_clndr_id`-hiërarchie in een tweede pas, -`clndr_type` (global/project/resource), en de uren-per-periode-velden met de -afleiding-uit-weekuren-terugval — inclusief de gedocumenteerde waarschuwing dat P6 die velden -niet valideert. Aansluiting op de bestaande `subdayIo.ts`-promotie (uur- vs dagmodus) — de -holiday-bewuste laag uit etappe 1 werkt hier door. **Acceptatie**: corpusloze fixtures per -grammatica-element; corpuspin op het 124-kalender-monster (`rehab-2.xer`); de -kalenderpromotie-discriminatoren gedragen zich identiek aan de P6-XML-route (pariteitstest -tegen `parseP6StandardWorkWeek` op een equivalent kalenderpaar). - -**X4 — Kern-mapping + registry-entry.** `src/services/xer/xerReader.ts`: PROJECT (hoofdproject -via `export_flag`, X-O1-minimum met telling), PROJWBS (sorteren op `(parent_wbs_id, seq_num)` — -de bestandvolgorde is onbetrouwbaar), TASK (statussen `TK_*`; milestones uit `TT_Mile`/ -`TT_FinMile`/`TT_StartMile`; `TT_LOE` → `isHammock`; `TT_WBS`-summary; duration- en -activiteitstype als opgeslagen data), TASKPRED (`PR_*` én de prefixloze variant uit oudere -tools — gemeten in 6 bestanden), constraints (`CS_*` incl. mandatory → `hard`), en de -`ExternalRelation`-klasse voor cross-project-randen. Format-registry-entry (`kind: 'text'`, -lazy chunk, `canBeSaveTarget` blijft IFC-only), i18n-foutmeldingen in 14 talen naar het -.mpp-patroon. **Acceptatie**: eerste fidelity-nulmeting over het corpus draait en pint; de -schone-baseline-cases (`01-small-clean-baseline` e.d.) op exact nul op de datum-assen. +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. **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. + +**X4 — Kern-mapping + registry-entry.** `src/services/xer/xerReader.ts`: PROJECT-selectie per +X-O1 (meeste-taken-heuristiek + telling), 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 6 +bestanden), 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 6 betreffende bestanden ROOD; de +case-insensitieve typematch uit ⇒ de kleine-letter-fixture ROOD. ### BAAN S — motor en semantiek -**X5 — SCHEDOPTIONS en de vlaggen.** De eerste lezer die `project.schedulingOptions` vult: -`sched_calendar_on_relationship_lag` → `lagCalendar` (P6-default: predecessor), retained -logic/progress override → `progressMode`, critical-definitie en float-modus voor zover de -tabel ze draagt. Elke instelling die ons huidige default-gedrag zou veranderen wordt -blast-radius-gemeten over het corpus vóór hij doorwerkt. **Acceptatie**: de +**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!)*: 37 van de 60 +orakelbestanden hebben géén SCHEDOPTIONS, waaronder `rehab-2.xer` (39% van het orakel). Voor +die populatie geldt een expliciet vastgelegde default-set (uitgangspunt: P6's eigen +fabrieksdefaults — o.a. total float = finish float, retained logic aan — niet onze +huisdefaults), 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; -corpusbestanden zonder SCHEDOPTIONS byte-identiek aan vóór deze taak. - -**X6 — Resources en toewijzingen.** RSRC/RSRCRATE/TASKRSRC: het rollen-vs-resources-ID- -botsingsprobleem (aparte naamruimten), units-schalen (de 1.000.000- en ×100-conventies zijn -per veld verschillend — herkomst MPXJ, onafhankelijk verifiëren tegen corpuswaarden), -`TT_Rsrc`-activiteiten en de resourcekalender-koppeling. Het curves-dossier is klein en -afgebakend: 2 corpusrijen met `curv_id` (beide in `rehab-2.xer`) plus de RSRCCURVDATA-tabel -(21-punts verdeling) — mappen naar onze curve-typen op best-fit, met de rauwe 21 punten als -opgeslagen data (eigenaarsprincipe; voedsel voor de latere contour-engine, zelfde patroon als -`timephasedContours`). **Acceptatie**: corpuspins op de resource-rijke bestanden -(`Roads_Project_TEC.xer`, `rehab-2.xer`); geen datumbeweging op bestanden zonder resources. - -**X7 — Suspend/resume en voortgang.** P6's suspend/resume ↔ ons `TaskTime.stop`/`resume` — -hiér slaat de veld-als-signaal-regel toe: het veld bestaat, maar de huidige solver-semantiek -eromheen is de MSP-conventie uit etappe 1. Eerst meten (de 22 corpustaken met suspend), dan -per verschil een bron-vlag naar het O6-patroon in plaats van stil hergebruik. P6-voortgang -(act-datums, `complete_pct_type`-varianten) tegen de bestaande actuals-invarianten. -**Acceptatie**: het out-of-sequence-scenario uit de P6-geverifieerde cases groen; de -suspend-dragende bestanden gepind; mutatiebewijs op elke nieuwe vlag-tak. +de 37-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 → onze bestaande -`activityCodeTypes`-structuren (119.878 corpus-koppelingen — de zwaarste datamassa van de -etappe; prestatie meten op `rehab-2.xer` en `Hotel Project.xer`); UDFTYPE/UDFVALUE → onze -`customFieldDefs` (datatype-mapping); memo-tabellen → taaknotities (DEL-DEL-decodering uit X2). -**Acceptatie**: round-trip door het IFC (de bestaande psets dragen dit al — aantonen, niet -aannemen); tellingen gepind per corpusbestand. - -**X9 — Documentcontract, round-trip en exportranden.** Alle nieuwe velden (P6-typen, -herkomstvlaggen, rauwe curvepunten) door het documentcontract en de IFC-round-trip; de -exportranden warnen naar het bestaande patroon (MSPDI/P6-XML-writers: wat een XER-import draagt -en de exporteur niet uitdrukken kan); `moveProject`-verdicten; MCP-leeskant conform het -etappe-1-besluit. **Acceptatie**: het mutatiebewijs-stramien van Z14 (property weg ⇒ rood op -precies dat veld; byte-identieke examples zonder regeneratie). - -**X10 — Melding en gidsen.** De openingsmelding voor .xer naar het Z16-model: echte tellingen -(overgeslagen kapotte rijen, meegelezen extra projecten (X-O1), encoding-keuze bij niet-ASCII), -`severity: info`, 14 talen, CLDR-pluralen. Gidsen (nl+en): een eigen "Primavera P6 (.xer) -openen"-artikel naar het model van de .mpp-gids — elke planningsclaim met code-/testverwijzing, -en een eerlijke paragraaf over wat er (nog) niet meekomt (multi-project, baselines tot X-O2, -curves-als-verdeling). `verify:docs`-poort. **Acceptatie**: mutatiebewijzen op melding, i18n en -manifest, zoals Z16. +**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, niet-geopende projecten + taken (X-O1), dangling baselines (X-O2), 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, eerlijk over +wat (nog) niet meekomt (multi-document, TT_Rsrc/TT_WBS-rekenmodel, curves-als-verdeling, +P6-XML-asymmetrie). **Acceptatie**: de Z16-mutatiebewijzen (melding/i18n/manifest). ### SERIEEL — afronding -**X11 — Browser-gebruikstest** (aparte agent, tier 1): openen van de dossierselectie -(schone baseline, `rehab-2.xer`, multi-kalender, negatieve float, het torture-bestand), -IFC-opslaan/heropenen met veldbehoud, F5-stabiliteit, meldingen en gidslinks, taalwissel, -undo/documentwissel — het Z18-draaiboek, plus de nieuwe vraag: blijft de app vlot op de -119k-koppelingen-bestanden. +**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, een multi-project-bestand), 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 per §5/X-O3; "pinnen met reden" bestaat -niet als uitweg. Daarna `GOAL_ZERO_DEVIATIONS_XER` aan (zes tellers + reason-verbod + -meetbaar-dekking-bewaking naar het F1-model: `gemetenExact === meetbaar` per as), TODO-registraties -(XER-export; multi-document-variant van X-O1; X-O2 indien onafgemaakt), en de hyperkritische -Opus-eindreview over de volledige etappe-diff — inclusief de sluiproute-scan op de -invoer/meetlat-scheiding van §4.1. +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 dedup-op-inhoudshash-bewaking. +TODO-registraties (XER-export; multi-document; X-O2-rest; 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) - ├── baan F: X2 → X3 → X4 ──┐ - ├── baan S: (na X4) X5 → X6 → X7 ──┤ - ├── baan D: (na X4) X8 → X9 → X10 ─┤ - └────────────────── X11 → X12 (serieel) +X0 ─ X1 (serieel; X1 bevat de meetkern-uitfactorisering) + ├── baan F: X2 → X3 → X4 ──┐ X3 = kritieke taak (uurmodus beslist alles) + ├── baan S: (na X4) X5 ─┐ │ + │ (na X4) X6 ─┤ → X7 ────┤ + ├── baan D: (na X4) X8 → X9 → X10 ──┤ + └──────────────── X11 ∥ X12 (X11 mag parallel aan de residu-iteratie) ``` -Baan S en D hangen op X4 (er moet een lezer zijn); binnen F is de volgorde dwingend -(grammatica → kalender → mapping). De meetlat (X1) staat vóór alles, net als in etappe 1 — -en om dezelfde reden: wie eerst bouwt en dan meet, meet zijn eigen aannames. +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.** Etappe 1 heeft de datum-assen van de motor - glashard gemaakt, maar float is nooit tegen een extern orakel gemeten. Verwacht hier de - zwaarste dossiers: P6's float-definities (start/finish/smallest), de omgang met open einden - (`makeOpenEndedCritical`), ALAP, en negatieve float (63 corpustaken, geconcentreerd in twee - bestanden — dat worden dossiers). Dit is tegelijk de grootste winst: elke gevonden fout hier - verbetert de motor voor álle formaten. -2. **`clndr_data` is de bytepuzzel van deze etappe** — kleiner dan de .mpp-CFB-wereld, maar met - dezelfde valkuilcategorie (een eigen grammatica, AM/PM-verrassingen, epoch-conversie, - ongevalideerde uren-per-dag-velden die de uur→dag-omrekening van álle duren en floats - bepalen). De uren-per-dag-afleiding raakt de fidelity van elk bestand; blast-radius-discipline - vanaf dag één. -3. **Encoding en getalnotatie zitten ín het bestand** (CURRTYPE-tweepas, CP1252-gok). Fouten - hier zijn stil en corrumperen tekst of getallen zonder rood scherm — vandaar de - robuustheidsbestanden als eigen poort in X2. -4. **Schaal**: `rehab-2.xer` (6.977 taken, 52.640 toewijzingen, 81k code-koppelingen) is 2× - het grootste bestand dat de app ooit las. De 5000-taken-prestatie-eis van de eigenaar staat; - X11 meet het expliciet. -5. **Scope-verleiding.** Multi-project, baselines en XER-export zijn elk een halve etappe op - zich; X-O1/X-O2 begrenzen ze bewust. De goal is lezen-getrouw-op-vier-assen — al het andere - is registreerbare vervolgambitie. +1. **De float-assen zijn onontgonnen terrein**, en het defaults-gat maakt het scherper: voor + 62% van de orakelbestanden (37 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-verleiding**: multi-document, baselines, XER-export, TT_Rsrc-rekenmodel en + p6xml-pariteit zijn elk een eigen traject; X-O1/X-O2/V3-registraties begrenzen ze bewust. + De goal is lezen-getrouw-op-vier-assen — al het andere is registreerbare vervolgambitie. From 147e70fbb32925399d5be53a81cd90544c682694 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:22:07 +0200 Subject: [PATCH 003/171] =?UTF-8?q?docs(plan):=20eigenaarsbesluiten=20X-O1?= =?UTF-8?q?=20t/m=20X-O4=20verwerkt=20=E2=80=94=20volledige=20multi-projec?= =?UTF-8?q?t-import,=20baselines=20verplicht=20bewaard,=20float-vangrails?= =?UTF-8?q?=20bekrachtigd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-08-20-plan-xer-p6-lezer.md | 56 ++++++++++--------- 1 file changed, 30 insertions(+), 26 deletions(-) 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 index 9894e3321..fa0c2b6f6 100644 --- a/docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md +++ b/docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md @@ -130,28 +130,29 @@ robuustheidsbestanden en het 8-byte-DROID-skelet) tellen niet in de fidelity-poo ## §5 Openstaande eigenaarsbesluiten -- **X-O1 — multi-project-XER (herzien na planreview — het conceptvoorstel was feitelijk fout).** - `export_flag` discrimineert niet (gemeten: 15/15 respectievelijk 4/4 projecten dragen 'Y'; - twee bestanden missen de kolom geheel), en "het eerste project" faalt hard: in beide - Hotel-bestanden draagt het eerste project nul taken en het vierde alle 4.217. Het - concept-minimum ("alleen het hoofdproject") zou 48% van de meetlat wegsnijden. - **Nieuw voorstel**: het project met de meeste taken openen als document (bij gelijkspel het - eerste), met een informatieve melding "dit bestand bevat nog N andere projecten (M taken)"; - relaties naar niet-geopende projecten worden `externalLinks`. De multi-document-variant - (alle projecten als tabbladen) blijft geregistreerde vervolgtaak. *Tot anders besloten wordt - dit gebouwd.* -- **X-O2 — baseline-projecten (aangescherpt).** Een gekoppeld baselineproject - (`sum_base_proj_id` → aanwezige PROJECT-rij) wordt als OPS-baseline gematerialiseerd — - kandidaat voor de tweede etappehelft, geen goal-voorwaarde. **Verplichte dangling-tak** - (planreview): in het corpus verwijzen 9 van de 10 gemeten koppelingen 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. Eén corpusbestand (`stack_data_center_baseline.xer`) draagt - een écht aanwezig baselineproject en wordt de positieve testcase. -- **X-O3 — de float-assen in de eindpoort.** TF/FF tellen volwaardig mee, met de formule uit +- **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; de openingsmelding benoemt + hoeveel projecten er geopend zijn. 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. 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 — encoding (gecorrigeerd).** Het corpus bevat géén enkel BOM-dragend bestand — de +- **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 @@ -233,8 +234,11 @@ waarvoor `parseP6StandardWorkWeek` (nu niet-geëxporteerd, `p6xmlReader.ts:96`) wordt of de toets via `readP6XML` loopt; mutatiebewijs: de weekuren-afleiding uitschakelen ⇒ de rehab-pin ROOD. -**X4 — Kern-mapping + registry-entry.** `src/services/xer/xerReader.ts`: PROJECT-selectie per -X-O1 (meeste-taken-heuristiek + telling), PROJWBS (sorteren op `(parent_wbs_id, seq_num)`; +**X4 — Kern-mapping + registry-entry.** `src/services/xer/xerReader.ts`: PROJECT-import per +X-O1 (álle niet-baseline-projecten als documenten; meeste-taken wordt het actieve tabblad — +dit vergt een meervoudig `ImportResult`-pad in de open-pijplijn, het eerste formaat dat dat +nodig heeft; ontwerp dat als expliciete stap en meet de open-tijd op het 15-projecten-bestand), +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 @@ -328,8 +332,7 @@ bladniveau → echte fout fixen met bewijs, of escaleren (X-O3); "pinnen met red 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 dedup-op-inhoudshash-bewaking. -TODO-registraties (XER-export; multi-document; X-O2-rest; p6xml-pariteit; driving-path-as als -poortkandidaat). Hyperkritische Opus-eindreview over de volledige etappe-diff, inclusief de +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 @@ -359,6 +362,7 @@ dan meet, meet zijn eigen aannames. 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-verleiding**: multi-document, baselines, XER-export, TT_Rsrc-rekenmodel en - p6xml-pariteit zijn elk een eigen traject; X-O1/X-O2/V3-registraties begrenzen ze bewust. - De goal is lezen-getrouw-op-vier-assen — al het andere is registreerbare vervolgambitie. +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. From 56b8d7a415d8ed242ab670fd359b130693699de4 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:27:53 +0200 Subject: [PATCH 004/171] =?UTF-8?q?docs(plan):=20her-check-correctieronde?= =?UTF-8?q?=20=E2=80=94=20whitelist=20gesloten,=20dedup=20eerlijk=20(Hotel?= =?UTF-8?q?-paar=20via=20schema-vingerafdruk),=20X-O1/X-O2=20doorvertaald?= =?UTF-8?q?=20(X4a/X4b-knip,=20per-project-meting,=20lege-projectregel,=20?= =?UTF-8?q?baseline-begrenzing),=20defaults=20op=20de=20meting,=20generiek?= =?UTF-8?q?e=20enum-regel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-08-20-plan-xer-p6-lezer.md | 138 ++++++++++++------ 1 file changed, 93 insertions(+), 45 deletions(-) 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 index fa0c2b6f6..1ff4d8804 100644 --- a/docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md +++ b/docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md @@ -52,11 +52,15 @@ twee opslagvelden nu, één rekenmodel straks, geen twee eilanden. 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. Daarvan is 47% een duplicaat-paar (het Hotel-schema staat er 2× in met - identieke taken) en staan de vier Harbour Point-bestanden er elk 4× in — de fidelity-pinning - dedupliceert daarom op inhoudshash, en het unieke orakel is realistisch ~10.000 taken over - ~50 unieke bestanden. Nog altijd ~3× het .mpp-corpus, en zonder bedrijfsdata: bestandsnamen - mogen gewoon in tests en commits. + á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 @@ -66,8 +70,9 @@ twee opslagvelden nu, één rekenmodel straks, geen twee eilanden. 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 (gemeten over de 60 orakelbestanden: ES/EF in 52, LS/LF in 40, TF ±18.400 cellen, - FF ±18.000 — er bestaan bestanden met alleen float en geen datums, en andersom). + 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 @@ -93,20 +98,28 @@ robuustheidsbestanden en het 8-byte-DROID-skelet) tellen niet in de fidelity-poo ## §4 Harde regels (geërfd uit etappe 1, plus de XER-eigen) -1. **Het opgeslagen antwoord is meetlat, nooit uitkomst — als WHITELIST (planreview M2).** - 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`), - `status_code`, `task_type`, `duration_type`, `priority_type`, `complete_pct_type` + - percentages, `target_drtn_hr_cnt`/`remain_drtn_hr_cnt`, `target_start_date`/ - `target_end_date`, `act_start_date`/`act_end_date`, `cstr_type`/`cstr_date` (+`2`), - `suspend_date`/`resume_date`, en `expect_end_date` (uitsluitend samen met de bijbehorende +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`; 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 de rekenuitvoer die er als gewone datumvelden uitziet: - `early_*`, `late_*`, `restart_date`/`reend_date` (P6's remaining-early-datums, 15.328/15.329 - gevulde cellen!), `rem_late_start_date`/`rem_late_end_date` (10.193 elk), - `total_float_hr_cnt`/`free_float_hr_cnt`, `driving_path_flag`, `float_path`(`_order`). - De X12-sluiproute-scan grept tegen de whitelist, niet tegen een opsomming van verboden - velden. Drie afgekeurde pogingen in etappe 1 zijn het precedent. + de lezer** — expliciet inclusief álle rekenuitvoer die er als gewone velden uitziet: + `early_*`, `late_*`, `restart_date`/`reend_date` (15.328/15.329 gevulde cellen), + `rem_late_start_date`/`rem_late_end_date` (10.193 elk), `total_float_hr_cnt`/ + `free_float_hr_cnt`, `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`. Een corpus-%F-kolom die op géén van beide lijsten + staat is een X0-compile-fout, geen vrije keuze. De X12-sluiproute-scan grept tegen de + whitelist. Drie afgekeurde pogingen in etappe 1 zijn het precedent. 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 @@ -135,11 +148,20 @@ robuustheidsbestanden en het 8-byte-DROID-skelet) tellen niet in de fidelity-poo 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; de openingsmelding benoemt - hoeveel projecten er geopend zijn. 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. Gevolg voor de meetlat: het volledige - orakel is bereikbaar; de fidelity meet per project en pint per bestand de som. + 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 @@ -189,8 +211,12 @@ met het uitfactoriseren van het formaat-agnostische deel (`classify()`, de rijvo 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-bestand-pinning op **inhoudshash** (dedupliceert de -corpus-duplicaten; naamlabels als leesbaarheid), per as afwijkings- én meetbaar-tellers, +`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. @@ -205,7 +231,10 @@ notitievelden (incl. BOM/NUL-strip — herkomst MPXJ `NotesHelper`), de lege-eer 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. **Failure-mode-model +geen harde crash); de openingsmelding (X10) toont het aantal. **Generieke enum-regel +(her-check)**: élke `XX_Yyy`-token wordt case-insensitief gematcht (het corpus draagt +`RCAL_SUCCESSOR` naast `rcal_Successor`, en `ST_TotalFloat` — een PMXML-vorm in een XER), +en een onbekend token is een **gerapporteerde** terugval naar de default, nooit een stille. **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 @@ -234,11 +263,8 @@ waarvoor `parseP6StandardWorkWeek` (nu niet-geëxporteerd, `p6xmlReader.ts:96`) wordt of de toets via `readP6XML` loopt; mutatiebewijs: de weekuren-afleiding uitschakelen ⇒ de rehab-pin ROOD. -**X4 — Kern-mapping + registry-entry.** `src/services/xer/xerReader.ts`: PROJECT-import per -X-O1 (álle niet-baseline-projecten als documenten; meeste-taken wordt het actieve tabblad — -dit vergt een meervoudig `ImportResult`-pad in de open-pijplijn, het eerste formaat dat dat -nodig heeft; ontwerp dat als expliciete stap en meet de open-tijd op het 15-projecten-bestand), -PROJWBS (sorteren op `(parent_wbs_id, seq_num)`; +**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. 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 @@ -252,9 +278,23 @@ blijft IFC-only), i18n-foutmeldingen 14 talen. **Acceptatie**: eerste fidelity-n 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 6 betreffende bestanden ROOD; de +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: @@ -267,9 +307,11 @@ die worden op zijn minst geregistreerd), en de mapping verhoudt zich expliciet t `mppReader`/`mspdiReader` al zetten én tot de `OPS_SchedulingOptions`-IFC-round-trip. *(b) de defaults-paragraaf (planreview B2 — het meerderheidsgeval!)*: 37 van de 60 orakelbestanden hebben géén SCHEDOPTIONS, waaronder `rehab-2.xer` (39% van het orakel). Voor -die populatie geldt een expliciet vastgelegde default-set (uitgangspunt: P6's eigen -fabrieksdefaults — o.a. total float = finish float, retained logic aan — niet onze -huisdefaults), per default blast-radius-gemeten over het corpus en gepind. **Acceptatie**: de +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 37-zonder-SCHEDOPTIONS-populatie per default-keuze gemeten en gepind; mutatiebewijs: `lagCalendar` naar successor forceren ⇒ de multi-kalender-case ROOD. @@ -313,8 +355,9 @@ 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, niet-geopende projecten + taken (X-O1), dangling baselines (X-O2), encoding-keuze bij -niet-ASCII (X-O4)), severity info, 14 talen, CLDR-pluralen. Gidsen (nl+en): "Primavera P6 +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, eerlijk over wat (nog) niet meekomt (multi-document, TT_Rsrc/TT_WBS-rekenmodel, curves-als-verdeling, P6-XML-asymmetrie). **Acceptatie**: de Z16-mutatiebewijzen (melding/i18n/manifest). @@ -323,7 +366,10 @@ P6-XML-asymmetrie). **Acceptatie**: de Z16-mutatiebewijzen (melding/i18n/manifes **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, een multi-project-bestand), IFC-opslaan/heropenen met veldbehoud, +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. @@ -338,13 +384,15 @@ whitelist-sluiproute-scan van §4.1. ## §7 Parallellisering ``` -X0 ─ X1 (serieel; X1 bevat de meetkern-uitfactorisering) - ├── baan F: X2 → X3 → X4 ──┐ X3 = kritieke taak (uurmodus beslist alles) - ├── baan S: (na X4) X5 ─┐ │ - │ (na X4) X6 ─┤ → X7 ────┤ - ├── baan D: (na X4) X8 → X9 → X10 ──┤ +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 → X10 ┤ └──────────────── X11 ∥ X12 (X11 mag parallel aan de residu-iteratie) ``` +X4b (meervoudige import) loopt parallel aan de banen S en D — alleen X10 (meldingsteksten) en +X11 (stresstest) wachten er inhoudelijk op. 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. From ed6290c9a86a59145c059a853c9cd577825d4f93 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:03:48 +0200 Subject: [PATCH 005/171] docs(plan): X-O5 besloten conform voorstel, met eigenaars-documentatie-eis op drie plekken (docblok, 14-talige foutmelding, gidsparagraaf) --- .../plans/2026-08-20-plan-xer-p6-lezer.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) 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 index 1ff4d8804..5d0ae6f24 100644 --- a/docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md +++ b/docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md @@ -181,13 +181,18 @@ robuustheidsbestanden en het 8-byte-DROID-skelet) tellen niet in de fidelity-poo 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 — getalnotatie uit CURRTYPE (nieuw, planreview).** De decimaal/duizend-tekens komen - uit het bestand zelf en zijn niet altijd letterlijk: naast `.`/`,` bestaan symbolische - tokens (`ds_Period`, `dg_Comma`). Voorstel: bekende tokens decoderen; onbekend token of - ontbrekende CURRTYPE-tabel (62 van de 93 bestanden!) ⇒ default punt-decimaal, en bij een - daadwerkelijk aangetroffen komma-decimaal-bestand zonder CURRTYPE een typed fout boven een - stil verkeerd geparsed getal. Dit raakt élk getal — duren en floats incluis — en verdient - daarom een eigen fixture-batterij in X2. +- **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 (X4a, i18n-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. ## §6 Banen en taken From 076f67ec921be46b07052576cf3db8ce35157b7d Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:12:24 +0200 Subject: [PATCH 006/171] =?UTF-8?q?docs(plan):=20delta-check-redactieronde?= =?UTF-8?q?=20=E2=80=94=20derde=20bak=20(32=20kolommen),=20PROJECT-rij=20t?= =?UTF-8?q?erug=20in=20X4a,=20enum-regel=20gesplitst=20naar=20=C2=A74.7,?= =?UTF-8?q?=20X4b=E2=86=92X10-pijl,=20stale=20zinnen=20gelijkgetrokken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-08-20-plan-xer-p6-lezer.md | 71 +++++++++++++------ 1 file changed, 50 insertions(+), 21 deletions(-) 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 index 5d0ae6f24..96f4ce883 100644 --- a/docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md +++ b/docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md @@ -108,18 +108,40 @@ robuustheidsbestanden en het 8-byte-DROID-skelet) tellen niet in de fidelity-poo 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`; 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: + `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: `early_*`, `late_*`, `restart_date`/`reend_date` (15.328/15.329 gevulde cellen), `rem_late_start_date`/`rem_late_end_date` (10.193 elk), `total_float_hr_cnt`/ `free_float_hr_cnt`, `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`. Een corpus-%F-kolom die op géén van beide lijsten - staat is een X0-compile-fout, geen vrije keuze. De X12-sluiproute-scan grept tegen de - whitelist. Drie afgekeurde pogingen in etappe 1 zijn het precedent. + `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. + **Een corpus-%F-kolom die in géén van de drie 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 @@ -190,7 +212,8 @@ robuustheidsbestanden en het 8-byte-DROID-skelet) tellen niet in de fidelity-poo 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 (X4a, i18n-patroon), en een eigen paragraaf in de .xer-gids (X10) die 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. @@ -236,10 +259,9 @@ notitievelden (incl. BOM/NUL-strip — herkomst MPXJ `NotesHelper`), de lege-eer 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. **Generieke enum-regel -(her-check)**: élke `XX_Yyy`-token wordt case-insensitief gematcht (het corpus draagt -`RCAL_SUCCESSOR` naast `rcal_Successor`, en `ST_TotalFloat` — een PMXML-vorm in een XER), -en een onbekend token is een **gerapporteerde** terugval naar de default, nooit een stille. **Failure-mode-model +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 @@ -269,15 +291,18 @@ wordt of de toets via `readP6XML` loopt; mutatiebewijs: de weekuren-afleiding ui 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. PROJWBS (sorteren op `(parent_wbs_id, seq_num)`; +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 6 -bestanden), constraints (`CS_*` incl. mandatory → `hard`), `ExternalRelation` voor +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 @@ -363,9 +388,11 @@ Z14-mutatiestramien (property weg ⇒ rood op precies dat veld; byte-identieke e 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, eerlijk over -wat (nog) niet meekomt (multi-document, TT_Rsrc/TT_WBS-rekenmodel, curves-als-verdeling, -P6-XML-asymmetrie). **Acceptatie**: de Z16-mutatiebewijzen (melding/i18n/manifest). +(.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 @@ -382,7 +409,7 @@ plus: blijft de app vlot op het 119k-koppelingen-bestand. 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 dedup-op-inhoudshash-bewaking. +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. @@ -393,11 +420,13 @@ 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 → X10 ┤ + ├── 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 — alleen X10 (meldingsteksten) en -X11 (stresstest) wachten er inhoudelijk op. +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. From 5f4ee356c68a00c86a4a87744b3b366bd0bb8a10 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:39:47 +0200 Subject: [PATCH 007/171] =?UTF-8?q?feat(xer):=20X0=20=E2=80=94=20typen,=20?= =?UTF-8?q?veldlijsten-poort,=20fidelity-baselinevorm=20(sonnet)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XER-etappe, taak X0 (SERIEEL VOORAF). Geen lezer, geen registry-entry — alleen typecontract + corpustooling, conform de X0-brief en plan §4.1/§6. - src/types/task.ts: P6DurationType/P6ActivityType + Task.p6DurationType/ p6ActivityType/p6SuspendResume (veld-als-signaal naast mspTaskType, met de vastgelegde superset-afspraak voor de latere taaktypes-etappe; suspend/ resume-herkomstvlag is een stub, X7 vult hem). - Doorgevoerd op alle compile-gedekte plekken die anders stil hadden kunnen verouderen: extTypes.ts/extMappers.ts (Ext-contract, leeskant-alleen zoals mspTaskType/effortDriven), check-ext-contract.ts (sleutellijsten), moveProject.ts (TASK_VERDICTS, 'n/a'), check-ifc-roundtrip.ts (Required- getuige + TASK_CANON skip-cellen, IFC-wiring is X9), taskFields.ts (REJECT_HINTS, MCP-zetbaarheid). - tests/planning/check-xer-field-whitelist.ts: de drie §4.1-bakken (whitelist/ verboden/genegeerd) als getypeerde constanten + een eigen, onafhankelijke %T/%F-corpusscan (OPS_XER_CORPUS) die de union van alle TASK-kolommen tegen de bakken toetst — het gatenkaas-mechanisme. Skip-OK zonder corpus, geen CI-poort (zelfde conventie als OPS_MPP_CORPUS). - tests/planning/xerFidelityTypes.ts + xer-fidelity-baseline.json + check-xer-fidelity-baseline-schema.ts: het harness-skelet — de §3-baselinevorm (zes assen, deviations/measurable) vastgelegd via een compile-locked sleutellijst + runtime-structuurvalidator, met een lege-lezer-run (createEmptyXerFidelityBaseline) die de committe lege baseline produceert. De dedup-logica zelf (§2) is X1-werk. - run.sh: beide nieuwe checks bedraad naast check-mpp-fidelity. Mutatiebewijzen (uitgevoerd, teruggedraaid): whitelist-veld schrappen ⇒ corpusscan ROOD; baseline-vorm muteren (JSON) ⇒ schema-check ROOD; veld uit XerFidelityBaselineEntry schrappen ⇒ compile-fout. Co-Authored-By: Claude Fable 5 --- src/engine/moveProject.ts | 5 + src/extensions/extMappers.ts | 10 + src/extensions/extTypes.ts | 9 + src/services/mcp/tools/taskFields.ts | 5 + src/types/task.ts | 44 +++ tests/planning/check-ext-contract.ts | 8 + tests/planning/check-ifc-roundtrip.ts | 19 ++ .../check-xer-fidelity-baseline-schema.ts | 234 ++++++++++++++ tests/planning/check-xer-field-whitelist.ts | 293 ++++++++++++++++++ tests/planning/run.sh | 15 + tests/planning/xer-fidelity-baseline.json | 3 + tests/planning/xerFidelityTypes.ts | 95 ++++++ 12 files changed, 740 insertions(+) create mode 100644 tests/planning/check-xer-fidelity-baseline-schema.ts create mode 100644 tests/planning/check-xer-field-whitelist.ts create mode 100644 tests/planning/xer-fidelity-baseline.json create mode 100644 tests/planning/xerFidelityTypes.ts diff --git a/src/engine/moveProject.ts b/src/engine/moveProject.ts index 9903673fd..64dfb46f5 100644 --- a/src/engine/moveProject.ts +++ b/src/engine/moveProject.ts @@ -106,6 +106,11 @@ 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 + p6DurationType: 'n/a', // X0 (XER-etappeplan): P6-eigen enum, geen datum + p6ActivityType: 'n/a', // X0 (XER-etappeplan): P6-eigen enum, geen datum + 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', time: 'shift', // zie TASK_TIME_VERDICTS resourceIds: 'n/a', color: 'n/a', activityCodes: 'n/a', diff --git a/src/extensions/extMappers.ts b/src/extensions/extMappers.ts index 4a980ad9d..fbc6b8a18 100644 --- a/src/extensions/extMappers.ts +++ b/src/extensions/extMappers.ts @@ -324,6 +324,11 @@ export function toExtTask(t: Task): ExtTask { // create-/update-paden (`fromExtTaskInput`) en de MCP-zetbaarheid (`taskFields.ts` REJECT_HINTS). mspTaskType: t.mspTaskType, effortDriven: t.effortDriven, + // X0 (XER-etappeplan): drie nieuwe .xer-importvelden — zelfde onvoorwaardelijke doorgifte als + // mspTaskType/effortDriven hierboven. + p6DurationType: t.p6DurationType, + p6ActivityType: t.p6ActivityType, + p6SuspendResume: t.p6SuspendResume, timephasedContours: t.timephasedContours ? t.timephasedContours.map(c => ({ resourceUid: c.resourceUid, periods: c.periods.map(p => ({ ...p })) })) : undefined, timephasedFinishFloor: t.timephasedFinishFloor, timephasedStartAnchor: t.timephasedStartAnchor, @@ -369,6 +374,11 @@ export function fromExtTask(t: ExtTask): Task { // (`fromExtTaskInput`, extensie-API) blijven hier bewust buiten (leeskant-alleen-besluit F5). mspTaskType: t.mspTaskType, effortDriven: t.effortDriven, + // X0 (XER-etappeplan): drie nieuwe .xer-importvelden — zelfde onvoorwaardelijke doorgifte als + // mspTaskType/effortDriven hierboven. + p6DurationType: t.p6DurationType, + p6ActivityType: t.p6ActivityType, + p6SuspendResume: t.p6SuspendResume, timephasedContours: t.timephasedContours ? t.timephasedContours.map(c => ({ resourceUid: c.resourceUid, periods: c.periods.map(p => ({ ...p })) })) : undefined, timephasedFinishFloor: t.timephasedFinishFloor, timephasedStartAnchor: t.timephasedStartAnchor, diff --git a/src/extensions/extTypes.ts b/src/extensions/extTypes.ts index 7e4aebb7a..c947a64a8 100644 --- a/src/extensions/extTypes.ts +++ b/src/extensions/extTypes.ts @@ -206,6 +206,15 @@ 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 (XER-etappeplan) — P6's eigen Duration Type bij .xer-import. Puur data; zelfde vertaal-/ + * zetbaarheidsnuance als `mspTaskType` (volledige vertaling, buiten de create-/update-paden). */ + p6DurationType?: 'DT_FixedDrtn' | 'DT_FixedDUR2' | 'DT_FixedRate' | 'DT_FixedQty'; + /** X0 (XER-etappeplan) — P6's eigen Activity Type bij .xer-import. Zelfde nuance als `p6DurationType`. */ + p6ActivityType?: 'TT_Task' | 'TT_Rsrc' | 'TT_LOE' | 'TT_Mile' | 'TT_FinMile' | 'TT_WBS'; + /** X0 (XER-etappeplan) — herkomstvlag voor `time.resume`/`time.stop`: signaleert P6-suspend/ + * resume-herkomst (XER `suspend_date`/`resume_date`) i.p.v. de MSP-conventie. Stub tot de XER- + * etappe se X7-taak 'm daadwerkelijk zet. 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'). diff --git a/src/services/mcp/tools/taskFields.ts b/src/services/mcp/tools/taskFields.ts index 06cf90946..eb50ab0c3 100644 --- a/src/services/mcp/tools/taskFields.ts +++ b/src/services/mcp/tools/taskFields.ts @@ -144,6 +144,11 @@ 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)', + p6SuspendResume: 'de P6-suspend/resume-herkomstvlag is via de bridge niet zetbaar (puur .xer-importdata, geen agent-invoervorm — zie planner_get_task)', }; /** Uitkomst van de veldvalidatie. */ diff --git a/src/types/task.ts b/src/types/task.ts index b9e39fa36..6dd4f1e9b 100644 --- a/src/types/task.ts +++ b/src/types/task.ts @@ -146,6 +146,25 @@ 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'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 @@ -434,6 +453,31 @@ 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; + /** OPTIONEEL — herkomstvlag voor `time.resume`/`time.stop` (O6-patroon; zie `resumeFromActualElapsed` + * in `types/project.ts` voor het precedent van een default-uit, uitsluitend-door-de-eigen-lezer- + * gezette opt-invlag). P6 kent EIGEN suspend/resume-semantiek (XER `TASK.suspend_date`/ + * `resume_date`) die het bestaande MSP-conventiepad rond `time.resume`/`time.stop` (Z12-herwerk) + * niet zonder meer deelt. DIT VELD IS EEN STUB: X0 (typecontract) legt alleen de aanwezigheid en + * de naam vast — de XER-etappe se X7-taak meet EERST of/waar P6's suspend/resume-gedrag afwijkt + * van de MSP-solversemantiek, en vult dit veld pas dan daadwerkelijk (geen enkele lezer zet het + * vóór die meting). Default/afwezig ⇒ MSP-conventie, byte-identiek. */ + p6SuspendResume?: boolean; parentId: string | null; // WBS parent childIds: string[]; // WBS children time: TaskTime; diff --git a/tests/planning/check-ext-contract.ts b/tests/planning/check-ext-contract.ts index e4dc268d9..4bbf7a383 100644 --- a/tests/planning/check-ext-contract.ts +++ b/tests/planning/check-ext-contract.ts @@ -90,6 +90,9 @@ const EXT_TASK_KEYS = keys()([ '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', 'p6SuspendResume', ] as const); const EXT_SEQUENCE_KEYS = keys()([ @@ -170,6 +173,11 @@ 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', + p6SuspendResume: true, timephasedContours: [{ resourceUid: 3, periods: [{ afterMinutes: 0, minutes: 480, workMinutes: 240, kind: 'remaining' }] }], timephasedFinishFloor: '2026-06-10T17:00', timephasedStartAnchor: '2026-06-01T08:00', diff --git a/tests/planning/check-ifc-roundtrip.ts b/tests/planning/check-ifc-roundtrip.ts index b8e05c0d5..a09bce911 100644 --- a/tests/planning/check-ifc-roundtrip.ts +++ b/tests/planning/check-ifc-roundtrip.ts @@ -247,6 +247,9 @@ 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 (XER-etappeplan, 2026-08-20) — drie nieuwe .xer-importvelden, nog GEEN IFC-pset (zie + // TASK_CANON hieronder: skip, reden X9). Alleen hier gevuld voor de `Required`-volledigheid. + p6DurationType: 'DT_FixedDUR2', p6ActivityType: 'TT_Rsrc', p6SuspendResume: true, parentId: 't-p', childIds: [], resourceIds: [], // milestone zonder assignments ⇒ afgeleide resourceIds is leeg (H2-fix) color: '#abcdef', // round-trippt via OPS_TaskAppearance (H2-fix) @@ -535,6 +538,13 @@ const TASK_CANON = { timephasedContours: KEEP, // Z14b — MSP's eigen task-type/effort-driven-vlag (`OPS_MspTaskType`), puur data, geen verwijzing. mspTaskType: KEEP, effortDriven: KEEP, + // X0 (XER-etappeplan, 2026-08-20) — drie nieuwe .xer-importvelden: typecontract-only deze etappe + // (X0 raakt src/services/ifc/ NIET aan). Geen IFC-pset ⇒ geen echte round-trip-vergelijking + // mogelijk; de daadwerkelijke IFC-round-trip-wiring is XER-etappe se X9-taak (documentcontract/ + // round-trip). Zelfde taxonomie als `interferingFloat`/`isNearCritical`/`floatPath` hierboven. + p6DurationType: { skip: 'nog geen IFC-pset — typecontract-only, X0 (XER-etappeplan); wiring volgt in X9' }, + p6ActivityType: { skip: 'nog geen IFC-pset — typecontract-only, X0 (XER-etappeplan); wiring volgt in X9' }, + p6SuspendResume: { skip: 'nog geen IFC-pset — typecontract-only, X0 (XER-etappeplan); wiring volgt in X9' }, 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) }, @@ -1226,6 +1236,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', p6SuspendResume: true, }; const mapped8e = toExtTask(withFields); assert(mapped8e.mspTaskType === 'FIXED_DURATION', `(8e) toExtTask moet mspTaskType doorgeven — kreeg ${mapped8e.mspTaskType}`); @@ -1234,6 +1247,9 @@ 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.p6SuspendResume === true, `(8e) toExtTask moet p6SuspendResume doorgeven — kreeg ${mapped8e.p6SuspendResume}`); const withoutFields: Task = { id: 't-8e-neg', name: '(8e)-zonder-velden', description: '', wbsCode: '', @@ -1244,6 +1260,9 @@ 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.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-xer-fidelity-baseline-schema.ts b/tests/planning/check-xer-fidelity-baseline-schema.ts new file mode 100644 index 000000000..e284cf310 --- /dev/null +++ b/tests/planning/check-xer-fidelity-baseline-schema.ts @@ -0,0 +1,234 @@ +/** + * De baseline-vorm-poort (X0, XER-etappeplan §3/§6-X0) — het "harness-skelet" van de X0-brief: + * er is nog GEEN lezer (X1+ bouwt de echte meetkern), dus dit bestand bewaakt alleen de VORM van + * `xer-fidelity-baseline.json` — een lege-maar-welgevormde baseline vandaag, dezelfde vorm die X1 + * straks vult. + * + * 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 { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + type XerFidelityAxisCounts, type XerFidelityCounters, type XerFidelityBaselineEntry, + type XerFidelityBaseline, emptyAxisCounts, emptyCounters, createEmptyXerFidelityBaseline, +} from './xerFidelityTypes'; + +const HERE = fileURLToPath(new URL('.', import.meta.url)); + +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', '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)[] = ['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 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 ('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; +} + +// ── 1. De committe baseline (`xer-fidelity-baseline.json`) is leeg-maar-welgevormd ───────────── +{ + const raw = readFileSync(join(HERE, 'xer-fidelity-baseline.json'), 'utf-8'); + 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 xer-fidelity-baseline.json is vandaag LEEG (X1 vult hem)', parsed, { files: {} }); +} + +// ── 2. De lege-lezer-run (harness-skelet) produceert diezelfde vorm ───────────────────────────── +{ + const built: XerFidelityBaseline = createEmptyXerFidelityBaseline(); + const problems = validateBaseline(built); + truthy(`2 createEmptyXerFidelityBaseline() is welgevormd (${problems.join('; ')})`, problems.length === 0); + eq('2a createEmptyXerFidelityBaseline() === de committe baseline', 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-field-whitelist.ts b/tests/planning/check-xer-field-whitelist.ts new file mode 100644 index 000000000..49e31fcc9 --- /dev/null +++ b/tests/planning/check-xer-field-whitelist.ts @@ -0,0 +1,293 @@ +/** + * De veldlijsten-poort (X0, XER-etappeplan §4.1) — legt de whitelist, de verboden-lijst en de + * "genegeerd — geen planningsdata"-bak als GETYPEERDE CONSTANTEN 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. + * + * DIT BESTAND SCHRIJFT GEEN LEZER. De whitelist-constanten zijn documentatie-van-het-plan (harde + * regel §4/X0-brief): X0 raakt `src/services/xer/` niet aan. De corpusscan hieronder is een eigen, + * MINIMALE %T/%F-tabelscan (geen tokenizer-hergebruik — er is nog niets om te hergebruiken; 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 drie 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-)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 het mutatiebewijs 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 } from 'node:path'; + +const diffs: string[] = []; +let checks = 0; +const truthy = (label: string, cond: boolean) => { + checks++; + if (!cond) diffs.push(`${label}: verwacht waar, kreeg onwaar`); +}; + +// ═══════════════════════════════════════════════════════════════════════════════════════════ +// De drie bakken (XER-etappeplan §4.1, bijgewerkt na de delta-check-redactieronde 076f67ec) +// ═══════════════════════════════════════════════════════════════════════════════════════════ + +/** + * 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. + */ +export const XER_TASK_FORBIDDEN = [ + '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', + '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; + +/** + * 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. +// ═══════════════════════════════════════════════════════════════════════════════════════════ +{ + const buckets: [string, readonly string[]][] = [ + ['whitelist', XER_TASK_WHITELIST], + ['forbidden', XER_TASK_FORBIDDEN], + ['ignored', XER_TASK_IGNORED], + ]; + 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); + } + } + const whitelistSet = new Set(XER_TASK_WHITELIST as readonly string[]); + const forbiddenSet = new Set(XER_TASK_FORBIDDEN as readonly string[]); + const ignoredSet = new Set(XER_TASK_IGNORED); + for (const f of whitelistSet) { + truthy(`"${f}" staat niet óók in forbidden`, !forbiddenSet.has(f)); + truthy(`"${f}" staat niet óók in ignored`, !ignoredSet.has(f)); + } + for (const f of forbiddenSet) { + truthy(`"${f}" staat niet óók in ignored`, !ignoredSet.has(f)); + } +} + +// ═══════════════════════════════════════════════════════════════════════════════════════════ +// De corpusscan: union van alle TASK-%F-kolommen over OPS_XER_CORPUS, tegen de drie bakken. +// ═══════════════════════════════════════════════════════════════════════════════════════════ + +const CORPUS = process.env.OPS_XER_CORPUS ?? '/home/nozzit/open-aec/voor claude/testdata-crawl'; + +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 = existsSync(CORPUS); +const corpusFiles = corpusDirExists ? listXerFilesRecursive(CORPUS) : []; + +if (!corpusDirExists) { + console.log(`OK xer-field-whitelist: corpus niet aanwezig (OPS_XER_CORPUS=${CORPUS}) — corpusscan overgeslagen`); +} else if (corpusFiles.length === 0) { + console.log(`OK xer-field-whitelist: corpus aanwezig maar geen .xer-bestanden gevonden (${CORPUS}) — 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 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 inIgnored = ignoredSet.has(col); + const bucketCount = Number(inWhitelist) + Number(inForbidden) + 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 drie bakken (whitelist/forbidden/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/run.sh b/tests/planning/run.sh index 9d079a009..e97ac21d0 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -183,6 +183,21 @@ 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 + # 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 + + # 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 + # 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. diff --git a/tests/planning/xer-fidelity-baseline.json b/tests/planning/xer-fidelity-baseline.json new file mode 100644 index 000000000..be32275d3 --- /dev/null +++ b/tests/planning/xer-fidelity-baseline.json @@ -0,0 +1,3 @@ +{ + "files": {} +} diff --git a/tests/planning/xerFidelityTypes.ts b/tests/planning/xerFidelityTypes.ts new file mode 100644 index 000000000..5a352d3e9 --- /dev/null +++ b/tests/planning/xerFidelityTypes.ts @@ -0,0 +1,95 @@ +/** + * 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): het uitfactoriseren van de formaat-agnostische kern uit `measureFidelity`/ + * `scanGroundTruthTasks` (zie `mppFidelity.ts`/`mppGroundTruth.ts` voor het .mpp-precedent), de + * per-project-lus (X-O1: één bestand kan meerdere projecten dragen), 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 === round(p6_float_uren × 60)`). + * + * `Measurable` bestaat APART van `Deviations` omdat de orakeldekking scheef is (planreview M1, + * her-check-meting): ES/EF gevuld in 48 orakelbestanden, LS/LF in 36, TF in ±18.400 cellen, FF in + * ±18.000 — sommige bestanden dragen alleen float en geen datums, en andersom. Beide tellers + * worden gepind (X1): een lezer die stilletjes MINDER gaat meten (een as die stil naar 0 meetbaar + * zakt) moet de suite even rood maken als een lezer die FOUT meet. + */ + +/** Eén as se dubbele teller: 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 +} + +/** Éé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. `tasks`/`projects` zijn de SOM over de daadwerkelijk geopende projecten + * binnen dat bestand (X-O1: lege en als-baseline-gematerialiseerde projecten tellen niet mee — + * "de fidelity meet per project en pint per bestand de som", plan §6-X-O1-slotzin). + */ +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; + /** 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): een "lege-lezer-run" — er is nog geen lezer, dus dit is een + * lege maar welgevormde baseline, structureel identiek aan wat X1's echte meetkern straks + * produceert. `xer-fidelity-baseline.json` bevat exact dit resultaat totdat X1 'm vult. */ +export function createEmptyXerFidelityBaseline(): XerFidelityBaseline { + return { files: {} }; +} From 9c5d617e478a8b82aa752de5e1fc86286d447a28 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:52:31 +0200 Subject: [PATCH 008/171] test(xer): bouw onafhankelijke fidelitymeetlat --- scripts/README.md | 7 + scripts/generate-p6-verified-cases.mjs | 99 +++ tests/planning/cases-p6-verified.json | 317 ++++++++ tests/planning/check-fidelity-core.ts | 85 +++ tests/planning/check-p6-verified-cases.ts | 89 +++ .../check-xer-fidelity-baseline-schema.ts | 20 +- tests/planning/check-xer-fidelity.ts | 243 ++++++ tests/planning/check-xer-field-whitelist.ts | 12 +- tests/planning/fidelityCore.ts | 97 +++ tests/planning/mppFidelity.ts | 41 +- tests/planning/run.sh | 30 +- tests/planning/xer-fidelity-baseline.json | 707 +++++++++++++++++- tests/planning/xerFidelity.ts | 317 ++++++++ tests/planning/xerFidelityTypes.ts | 14 +- tests/planning/xerGroundTruth.ts | 154 ++++ 15 files changed, 2185 insertions(+), 47 deletions(-) create mode 100644 scripts/generate-p6-verified-cases.mjs create mode 100644 tests/planning/cases-p6-verified.json create mode 100644 tests/planning/check-fidelity-core.ts create mode 100644 tests/planning/check-p6-verified-cases.ts create mode 100644 tests/planning/check-xer-fidelity.ts create mode 100644 tests/planning/fidelityCore.ts create mode 100644 tests/planning/xerFidelity.ts create mode 100644 tests/planning/xerGroundTruth.ts diff --git a/scripts/README.md b/scripts/README.md index efda4e3de..2309f46ab 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..478d88b22 --- /dev/null +++ b/scripts/generate-p6-verified-cases.mjs @@ -0,0 +1,99 @@ +#!/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 { 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 caseDirs = readdirSync(join(sourceRoot, 'cases'), { withFileTypes: true }) + .filter(entry => entry.isDirectory() && /^\d{2}-/.test(entry.name)) + .map(entry => entry.name) + .sort(); + +const cases = caseDirs.map(id => { + const csvPath = join(sourceRoot, 'cases', id, 'comparison.csv'); + if (!existsSync(csvPath)) throw new Error(`${id}: comparison.csv ontbreekt`); + const [header, ...rows] = parseCsv(readFileSync(csvPath, 'utf-8')); + const indexes = selectedColumns.map(column => { + const index = header.indexOf(column); + if (index < 0) throw new Error(`${id}: kolom ${column} ontbreekt`); + return index; + }); + const activities = rows + .filter(row => row.some(cellValue => cellValue !== '')) + .map(row => Object.fromEntries(selectedColumns.map((column, index) => [column, row[indexes[index]] ?? '']))); + return { id, activities }; +}); + +const output = { + provenance: { + source: 'cpp-cpm-engine validation/p6-comparison; menselijke P6 23.12-capture van 2026-08-11', + 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/tests/planning/cases-p6-verified.json b/tests/planning/cases-p6-verified.json new file mode 100644 index 000000000..1c968eec5 --- /dev/null +++ b/tests/planning/cases-p6-verified.json @@ -0,0 +1,317 @@ +{ + "provenance": { + "source": "cpp-cpm-engine validation/p6-comparison; menselijke P6 23.12-capture van 2026-08-11", + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "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-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-p6-verified-cases.ts b/tests/planning/check-p6-verified-cases.ts new file mode 100644 index 000000000..aa57a1766 --- /dev/null +++ b/tests/planning/check-p6-verified-cases.ts @@ -0,0 +1,89 @@ +import { execFileSync } from 'node:child_process'; +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: { source: string; scope: string; warning: string }; + cases: { id: string; activities: P6Row[] }[]; +} + +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); +truthy('1b herkomst noemt P6 23.12 en capturedatum', + parsed.provenance.source.includes('P6 23.12') && parsed.provenance.source.includes('2026-08-11')); +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')); + +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)); + } + } +} + +// 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-xer-fidelity-baseline-schema.ts b/tests/planning/check-xer-fidelity-baseline-schema.ts index e284cf310..2aa3e52e9 100644 --- a/tests/planning/check-xer-fidelity-baseline-schema.ts +++ b/tests/planning/check-xer-fidelity-baseline-schema.ts @@ -1,8 +1,7 @@ /** - * De baseline-vorm-poort (X0, XER-etappeplan §3/§6-X0) — het "harness-skelet" van de X0-brief: - * er is nog GEEN lezer (X1+ bouwt de echte meetkern), dus dit bestand bewaakt alleen de VORM van - * `xer-fidelity-baseline.json` — een lege-maar-welgevormde baseline vandaag, dezelfde vorm die X1 - * straks vult. + * 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 @@ -125,21 +124,26 @@ function validateBaseline(v: unknown): string[] { return problems; } -// ── 1. De committe baseline (`xer-fidelity-baseline.json`) is leeg-maar-welgevormd ───────────── +// ── 1. De committe X1-baseline is gevuld en welgevormd ───────────────────────────────────────── { const raw = readFileSync(join(HERE, 'xer-fidelity-baseline.json'), 'utf-8'); 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 xer-fidelity-baseline.json is vandaag LEEG (X1 vult hem)', parsed, { files: {} }); + truthy('1a xer-fidelity-baseline.json bevat de 22 unieke orakelbestanden', + isPlainObject(parsed) && isPlainObject(parsed.files) && Object.keys(parsed.files).length === 22); + 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)); } -// ── 2. De lege-lezer-run (harness-skelet) produceert diezelfde vorm ───────────────────────────── +// ── 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() === de committe baseline', built, { files: {} }); + eq('2a createEmptyXerFidelityBaseline() blijft leeg', built, { files: {} }); } // ── 3. emptyAxisCounts()/emptyCounters() zijn zelf welgevormde bouwstenen ─────────────────────── diff --git a/tests/planning/check-xer-fidelity.ts b/tests/planning/check-xer-fidelity.ts new file mode 100644 index 000000000..04c7210b0 --- /dev/null +++ b/tests/planning/check-xer-fidelity.ts @@ -0,0 +1,243 @@ +import { Buffer } from 'node:buffer'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + buildXerTargetBaseline, + measureXerFidelity, + validateXerBaselinePins, +} from './xerFidelity'; +import { scanXerGroundTruth } 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)}`); + } +} + +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.5\tY', + // Completed gebruikt de actuals voor alle vier datumassen. 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\t2099-01-02\t2099-01-03\t2099-01-04\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 tegen de opgeslagen early/late-uitvoer meten in plaats van tegen actuals. +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: 30, + }, + drivingPath: true, + storedAxes: { es: true, ef: true, ls: true, lf: true, tf: true, ff: true }, +}); +eq('1c completed gebruikt actuals en rondt fractionele float naar minuten', truth.tasks[1], { + projectId: 'P2', + taskId: '2', + taskCode: 'B', + statusCode: 'TK_Complete', + axes: { + es: '2026-02-02T09:15', ef: '2026-02-03T16:45', + ls: '2026-02-02T09:15', lf: '2026-02-03T16:45', + tf: null, ff: -7, + }, + drivingPath: false, + storedAxes: { es: true, ef: true, ls: true, lf: true, tf: false, ff: true }, +}); + +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: 30, drivingPath: false, + }], + }, + { + projectId: 'P2', + tasks: [{ + sourceTaskId: '2', taskCode: 'B', + earlyStart: '2026-02-02T09:15', earlyFinish: '2026-02-03T16:45', + lateStart: '2026-02-02T09:15', lateFinish: '2026-02-03T16:45', + totalFloatMinutes: 123, freeFloatMinutes: -7, 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 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 built = buildXerTargetBaseline([ + { 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 }, +]); + +// 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: 4, + byteUniqueFiles: 3, + byteDuplicateFiles: 1, + fourDateTasks: 6, + sixAxisTasks: 3, + drivingPathTasks: 6, + byteUniqueOracleFiles: 2, + schemaDuplicateFiles: 1, + uniqueOracleFiles: 1, + byteUniqueOracleTasks: 2, + uniqueOracleTasks: 1, +}); +const entries = Object.values(built.baseline.files); +eq('3a 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 nonZeroWithoutReason = structuredClone(built.baseline); +Object.values(nonZeroWithoutReason.files)[0].counters.es.deviations = 1; +eq('3b 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('3c 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 corpus = buildXerTargetBaseline(corpusFiles); + + // 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 datumassen', corpus.stats.fourDateTasks, 18_489); + eq('C4 taken met alle zes assen', corpus.stats.sixAxisTasks, 17_963); + eq('C5 unieke orakelbestanden na byte-dedup', corpus.stats.byteUniqueOracleFiles, 23); + eq('C6 orakeltaken na byte-dedup', corpus.stats.byteUniqueOracleTasks, 17_600); + eq('C7 unieke orakelbestanden na beide deduplagen', corpus.stats.uniqueOracleFiles, 22); + eq('C8 orakeltaken na beide deduplagen', corpus.stats.uniqueOracleTasks, 13_383); + eq('C8a precies één inhoudsduplicaat na byte-dedup', corpus.stats.schemaDuplicateFiles, 1); + + const baselinePath = join(HERE, 'xer-fidelity-baseline.json'); + const committed = JSON.parse(readFileSync(baselinePath, 'utf-8')) as XerFidelityBaseline; + 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 index 49e31fcc9..fdef97604 100644 --- a/tests/planning/check-xer-field-whitelist.ts +++ b/tests/planning/check-xer-field-whitelist.ts @@ -179,7 +179,7 @@ export const XER_TASK_IGNORED: readonly string[] = [ // De corpusscan: union van alle TASK-%F-kolommen over OPS_XER_CORPUS, tegen de drie bakken. // ═══════════════════════════════════════════════════════════════════════════════════════════ -const CORPUS = process.env.OPS_XER_CORPUS ?? '/home/nozzit/open-aec/voor claude/testdata-crawl'; +const CORPUS = process.env.OPS_XER_CORPUS; function listXerFilesRecursive(dir: string): string[] { const out: string[] = []; @@ -229,13 +229,15 @@ function scanTaskColumns(text: string): string[] | null { return null; } -const corpusDirExists = existsSync(CORPUS); +const corpusDirExists = CORPUS !== undefined && existsSync(CORPUS); const corpusFiles = corpusDirExists ? listXerFilesRecursive(CORPUS) : []; -if (!corpusDirExists) { - console.log(`OK xer-field-whitelist: corpus niet aanwezig (OPS_XER_CORPUS=${CORPUS}) — corpusscan overgeslagen`); +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 (${CORPUS}) — corpusscan overgeslagen`); + 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(); diff --git a/tests/planning/fidelityCore.ts b/tests/planning/fidelityCore.ts new file mode 100644 index 000000000..b00c3bfd5 --- /dev/null +++ b/tests/planning/fidelityCore.ts @@ -0,0 +1,97 @@ +/** + * 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'; +} + +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/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.sh b/tests/planning/run.sh index e97ac21d0..5d939be4e 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -73,11 +73,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 @@ -106,7 +114,10 @@ if [ "$#" -gt 0 ]; then for f in "$@"; do FILES+=("$DIR/$f"); done 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 @@ -183,6 +194,11 @@ 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 @@ -198,6 +214,18 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then XERBASELINESCHEMACHECK="$DIR/.xer-fidelity-baseline-schema.mjs" if bundle_check "$DIR/check-xer-fidelity-baseline-schema.ts" "$XERBASELINESCHEMACHECK"; then node "$XERBASELINESCHEMACHECK" || STATUS=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 + + # 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 + # 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. diff --git a/tests/planning/xer-fidelity-baseline.json b/tests/planning/xer-fidelity-baseline.json index be32275d3..9009a8768 100644 --- a/tests/planning/xer-fidelity-baseline.json +++ b/tests/planning/xer-fidelity-baseline.json @@ -1,3 +1,708 @@ { - "files": {} + "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": "cfe1fc0d9ba21ca6" + }, + "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": "b61c9d452729a130" + }, + "2c1dce175b9f0781": { + "label": "crawl-xer-extra/jailaff-xer-splitter/rehab-2.xer", + "tasks": 6977, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 6977 + }, + "ef": { + "deviations": 0, + "measurable": 6977 + }, + "ls": { + "deviations": 0, + "measurable": 6977 + }, + "lf": { + "deviations": 0, + "measurable": 6977 + }, + "tf": { + "deviations": 0, + "measurable": 6976 + }, + "ff": { + "deviations": 0, + "measurable": 6976 + } + }, + "schemaFingerprint": "2a90d1c08f617859" + }, + "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": "8ade02da137cc09e" + }, + "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": "a6990e00940e33f5" + }, + "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": "df43a1d904c33129" + }, + "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": "09e1f54f9c488d4d" + }, + "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": "d49801efb8734ab4" + }, + "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": "508b7e192de8b447" + }, + "2bc12241c3f8ee5b": { + "label": "crawl-xer/eh_P6Workshops/OZB-Start-09Dec24.xer", + "tasks": 169, + "projects": 12, + "counters": { + "es": { + "deviations": 0, + "measurable": 169 + }, + "ef": { + "deviations": 0, + "measurable": 169 + }, + "ls": { + "deviations": 0, + "measurable": 169 + }, + "lf": { + "deviations": 0, + "measurable": 169 + }, + "tf": { + "deviations": 0, + "measurable": 152 + }, + "ff": { + "deviations": 0, + "measurable": 152 + } + }, + "schemaFingerprint": "752737afb7e1d3e0" + }, + "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": "99edcd57081f9063" + }, + "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": "b3f8e6abe788a5fb" + }, + "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": "c0559954e2c7d7b2" + }, + "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": "747f2f90fdb99295" + }, + "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": "8bde004d6b3f8cc8" + }, + "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": "5b299c7a64f62382" + }, + "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": "3125accde6764654" + }, + "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": "05eedb2df080d008" + }, + "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": "c521d20f46f9b036" + }, + "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": "f80099273141c876" + }, + "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": "59835bb802b586b5" + }, + "b8bc3cb30463c99c": { + "label": "pmxml-samples/testXer.xer", + "tasks": 83, + "projects": 1, + "counters": { + "es": { + "deviations": 0, + "measurable": 83 + }, + "ef": { + "deviations": 0, + "measurable": 83 + }, + "ls": { + "deviations": 0, + "measurable": 83 + }, + "lf": { + "deviations": 0, + "measurable": 83 + }, + "tf": { + "deviations": 0, + "measurable": 83 + }, + "ff": { + "deviations": 0, + "measurable": 83 + } + }, + "schemaFingerprint": "1832cd1cb1022d1f" + } + } } diff --git a/tests/planning/xerFidelity.ts b/tests/planning/xerFidelity.ts new file mode 100644 index 000000000..0b723e7c8 --- /dev/null +++ b/tests/planning/xerFidelity.ts @@ -0,0 +1,317 @@ +import { createHash } from 'node:crypto'; +import { + classifyExact, + 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 { + tasks: number; + projects: XerProjectFidelity[]; + counters: XerFidelityCounters; + /** Zevende rapportage-as; bewust geen lid van `counters` en dus buiten de zesassige nulpoort. */ + drivingPath: XerFidelityAxisCounts; + errors: string[]; +} + +export interface XerCorpusFile { + label: string; + bytes: Uint8Array; +} + +export interface XerCorpusStats { + scannedFiles: 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; + byteUniqueOracleFiles: number; + schemaDuplicateFiles: number; + uniqueOracleFiles: number; + /** Taken met alle zes assen na uitsluitend byte-dedup. */ + byteUniqueOracleTasks: number; + /** Taken met alle zes assen na beide deduplagen. */ + uniqueOracleTasks: number; +} + +export interface XerTargetBaselineResult { + baseline: XerFidelityBaseline; + stats: XerCorpusStats; +} + +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, + projectIndex: number, + errors: string[], +): XerProjectFidelity { + const solvedById = new Map(); + let duplicateSolvedIds = 0; + for (const task of solved?.tasks ?? []) { + if (solvedById.has(task.sourceTaskId)) duplicateSolvedIds++; + else solvedById.set(task.sourceTaskId, task); + } + if (duplicateSolvedIds > 0) { + errors.push(`project ${projectIndex + 1}: ${duplicateSolvedIds} dubbele opgeloste taak-id('s)`); + } + + 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, { + 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.storedAxes[axis]); +} + +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.taskCode, + task.statusCode, + ...XER_FIDELITY_AXES.map(axis => task.axes[axis] ?? ''), + task.drivingPath === null ? '' : task.drivingPath ? 'Y' : 'N', + ].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 { + return { + label, + tasks: truth.tasks.length, + projects: truth.projects.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[]): XerTargetBaselineResult { + const baseline: XerFidelityBaseline = { files: {} }; + const stats: XerCorpusStats = { + scannedFiles: files.length, + byteUniqueFiles: 0, + byteDuplicateFiles: 0, + fourDateTasks: 0, + sixAxisTasks: 0, + drivingPathTasks: 0, + byteUniqueOracleFiles: 0, + schemaDuplicateFiles: 0, + uniqueOracleFiles: 0, + byteUniqueOracleTasks: 0, + uniqueOracleTasks: 0, + }; + const seenBytes = new Set(); + const seenSchemas = new Set(); + + for (const file of [...files].sort((a, b) => a.label.localeCompare(b.label))) { + const truth = scanXerGroundTruth(file.bytes); + stats.fourDateTasks += truth.tasks.filter(task => + task.storedAxes.es && task.storedAxes.ef && task.storedAxes.ls && task.storedAxes.lf).length; + stats.sixAxisTasks += truth.tasks.filter(isFullOracleTask).length; + stats.drivingPathTasks += truth.tasks.filter(task => task.drivingPath !== null).length; + + const fullByteHash = byteHash(file.bytes); + if (seenBytes.has(fullByteHash)) { + stats.byteDuplicateFiles++; + continue; + } + seenBytes.add(fullByteHash); + stats.byteUniqueFiles++; + + const fullOracleTasks = truth.tasks.filter(isFullOracleTask).length; + if (fullOracleTasks === 0) continue; + stats.byteUniqueOracleFiles++; + stats.byteUniqueOracleTasks += fullOracleTasks; + + const fingerprint = xerSchemaFingerprint(truth); + if (seenSchemas.has(fingerprint)) { + stats.schemaDuplicateFiles++; + continue; + } + seenSchemas.add(fingerprint); + stats.uniqueOracleFiles++; + stats.uniqueOracleTasks += fullOracleTasks; + baseline.files[fullByteHash.slice(0, 16)] = targetEntry(file.label, truth, fingerprint); + } + + return { baseline, stats }; +} + +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 solvedByProject = new Map(solvedProjects.map(project => [project.projectId, project])); + const errors: string[] = []; + const projects = [...truthByProject.entries()].map(([projectId, tasks], index) => + measureProject(projectId, tasks, solvedByProject.get(projectId), index, 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); + } + + return { + tasks: projects.reduce((sum, project) => sum + project.tasks, 0), + projects, + counters, + drivingPath, + errors, + }; +} diff --git a/tests/planning/xerFidelityTypes.ts b/tests/planning/xerFidelityTypes.ts index 5a352d3e9..9289f0b96 100644 --- a/tests/planning/xerFidelityTypes.ts +++ b/tests/planning/xerFidelityTypes.ts @@ -1,11 +1,10 @@ /** * 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): het uitfactoriseren van de formaat-agnostische kern uit `measureFidelity`/ - * `scanGroundTruthTasks` (zie `mppFidelity.ts`/`mppGroundTruth.ts` voor het .mpp-precedent), de - * per-project-lus (X-O1: één bestand kan meerdere projecten dragen), 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). + * 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 @@ -87,9 +86,8 @@ export interface XerFidelityBaseline { files: Record; } -/** HET HARNESS-SKELET (X0-acceptatie): een "lege-lezer-run" — er is nog geen lezer, dus dit is een - * lege maar welgevormde baseline, structureel identiek aan wat X1's echte meetkern straks - * produceert. `xer-fidelity-baseline.json` bevat exact dit resultaat totdat X1 'm vult. */ +/** 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..1bc212be4 --- /dev/null +++ b/tests/planning/xerGroundTruth.ts @@ -0,0 +1,154 @@ +/** + * 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. + */ + +export type XerGroundTruthEncoding = 'utf-8' | 'utf-16le' | 'utf-16be' | 'windows-1252'; +export type XerFidelityAxis = 'es' | 'ef' | 'ls' | 'lf' | 'tf' | 'ff'; + +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; + drivingPath: boolean | null; + /** Aanwezigheid van P6's opgeslagen uitvoerveld, los van parseerbaarheid/statussubstitutie. */ + storedAxes: Record; +} + +export interface XerGroundTruth { + encoding: XerGroundTruthEncoding; + projects: Set; + tasks: XerGroundTruthTask[]; +} + +const IDENTITY_FIELDS = ['proj_id', 'task_id', 'task_code'] as const; + +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; ongeldige/sentinelwaarden zijn niet meetbaar. */ +function parseOracleDate(raw: string | undefined): string | null { + const value = raw?.trim() ?? ''; + const match = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::\d{2})?)?$/.exec(value); + if (!match) return null; + const [, year, month, day, hour = '00', minute = '00'] = match; + const date = new Date(`${year}-${month}-${day}T${hour}:${minute}:00Z`); + if (Number.isNaN(date.getTime())) return null; + if (date.toISOString().slice(0, 16) !== `${year}-${month}-${day}T${hour}:${minute}`) return null; + return `${year}-${month}-${day}T${hour}:${minute}`; +} + +/** P6-float staat in uren; het orakel wordt meteen naar afgeronde minuten genormaliseerd. */ +function parseFloatMinutes(raw: string | undefined): number | null { + const value = raw?.trim() ?? ''; + if (!value) return null; + const hours = Number(value); + return Number.isFinite(hours) ? Math.round(hours * 60) : null; +} + +function parseDrivingPath(raw: string | undefined): boolean | null { + const value = raw?.trim().toUpperCase() ?? ''; + if (value === 'Y') return true; + if (value === 'N') return false; + return null; +} + +function buildTask(fields: readonly string[], values: readonly string[]): XerGroundTruthTask | null { + const row = new Map(); + for (let index = 0; index < fields.length; index++) row.set(fields[index], values[index] ?? ''); + 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() ?? ''; + if (!taskId) return null; + + const statusCode = row.get('status_code')?.trim() ?? ''; + const completed = statusCode.toLowerCase() === 'tk_complete'; + const start = completed ? parseOracleDate(row.get('act_start_date')) : null; + const finish = completed ? parseOracleDate(row.get('act_end_date')) : null; + + return { + projectId, + taskId, + taskCode, + statusCode, + axes: { + es: completed ? start : parseOracleDate(row.get('early_start_date')), + ef: completed ? finish : parseOracleDate(row.get('early_end_date')), + ls: completed ? start : parseOracleDate(row.get('late_start_date')), + lf: completed ? finish : parseOracleDate(row.get('late_end_date')), + tf: parseFloatMinutes(row.get('total_float_hr_cnt')), + ff: parseFloatMinutes(row.get('free_float_hr_cnt')), + }, + drivingPath: parseDrivingPath(row.get('driving_path_flag')), + storedAxes: { + es: !!row.get('early_start_date')?.trim(), + ef: !!row.get('early_end_date')?.trim(), + ls: !!row.get('late_start_date')?.trim(), + lf: !!row.get('late_end_date')?.trim(), + 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 tasks: XerGroundTruthTask[] = []; + const projects = new Set(); + let table = ''; + let fields: string[] = []; + + for (const rawLine of text.split('\n')) { + const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine; + const cells = line.split('\t'); + const marker = cells[0]?.trim(); + if (marker === '%T') { + table = cells[1]?.trim().toUpperCase() ?? ''; + fields = []; + continue; + } + if (table !== 'TASK') continue; + if (marker === '%F') { + fields = cells.slice(1).map(field => field.trim()); + continue; + } + if (marker !== '%R' || fields.length === 0) continue; + const task = buildTask(fields, cells.slice(1)); + if (!task) continue; + tasks.push(task); + projects.add(task.projectId); + } + + return { encoding, projects, tasks }; +} From a6f11026c440ed645467026982ea0e0184ef0850 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:56:35 +0200 Subject: [PATCH 009/171] test(xer): sluit X1-fidelitymeetlat na critreview --- scripts/generate-p6-verified-cases.mjs | 61 +- tests/planning/cases-p6-verified.json | 17 +- tests/planning/check-p6-verified-cases.ts | 69 +- .../check-xer-fidelity-baseline-schema.ts | 70 +- tests/planning/check-xer-fidelity.ts | 301 ++++++++- tests/planning/fidelityCore.ts | 20 + tests/planning/xer-corpus-manifest.json | 612 ++++++++++++++++++ tests/planning/xer-fidelity-baseline.json | 446 ++++++++++++- tests/planning/xerFidelity.ts | 187 +++++- tests/planning/xerFidelityTypes.ts | 16 +- tests/planning/xerGroundTruth.ts | 246 +++++-- 11 files changed, 1908 insertions(+), 137 deletions(-) create mode 100644 tests/planning/xer-corpus-manifest.json diff --git a/scripts/generate-p6-verified-cases.mjs b/scripts/generate-p6-verified-cases.mjs index 478d88b22..2f990e8c2 100644 --- a/scripts/generate-p6-verified-cases.mjs +++ b/scripts/generate-p6-verified-cases.mjs @@ -14,6 +14,7 @@ * 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'; @@ -66,29 +67,73 @@ function parseCsv(text) { } 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() && /^\d{2}-/.test(entry.name)) + .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 [header, ...rows] = parseCsv(readFileSync(csvPath, 'utf-8')); + 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 index = header.indexOf(column); - if (index < 0) throw new Error(`${id}: kolom ${column} ontbreekt`); - return index; + 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 => Object.fromEntries(selectedColumns.map((column, index) => [column, row[indexes[index]] ?? '']))); - return { id, activities }; + .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: { - source: 'cpp-cpm-engine validation/p6-comparison; menselijke P6 23.12-capture van 2026-08-11', + 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.', }, diff --git a/tests/planning/cases-p6-verified.json b/tests/planning/cases-p6-verified.json index 1c968eec5..7cdc5957f 100644 --- a/tests/planning/cases-p6-verified.json +++ b/tests/planning/cases-p6-verified.json @@ -1,12 +1,15 @@ { "provenance": { - "source": "cpp-cpm-engine validation/p6-comparison; menselijke P6 23.12-capture van 2026-08-11", + "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", @@ -39,6 +42,7 @@ }, { "id": "02-ss-with-lag", + "sourceSha256": "7719cb13f2bfd5297dc5302f9f2ab672376d7b6e7cafcbfa5c52cbbf64c45cae", "activities": [ { "activity_code": "A", @@ -62,6 +66,7 @@ }, { "id": "03-ff-with-lag", + "sourceSha256": "bb8f76a2bdb926bdd0e7177f28c94da117cd8c55b110882f84ae958ed028d2da", "activities": [ { "activity_code": "A", @@ -85,6 +90,7 @@ }, { "id": "04-sf-edge-case", + "sourceSha256": "be04f8ccd8430726635b87654b2039d64fb70779f12562f260bdf3e72d3eb654", "activities": [ { "activity_code": "A", @@ -108,6 +114,7 @@ }, { "id": "05-negative-float", + "sourceSha256": "463f4ea2ee5e973340d82b26e2adbf0305b0815161f8b78dc074f381140d22b1", "activities": [ { "activity_code": "A", @@ -131,6 +138,7 @@ }, { "id": "06-multiple-calendars", + "sourceSha256": "8623f28be05dfcbee390746c5ece5aacfb5c1501edbee3e74ffee92ac1730432", "activities": [ { "activity_code": "A", @@ -154,6 +162,7 @@ }, { "id": "07-ontario-holidays", + "sourceSha256": "23ec9703eab963f8f1300ecdc7bfadd902668314d3008e172048a7adb5042026", "activities": [ { "activity_code": "A", @@ -168,6 +177,7 @@ }, { "id": "08-in-progress-retained-logic", + "sourceSha256": "67c9811d83cdeace67fe7b410aa59b8550ae30cee287d6ee2bda7cd4347e255d", "activities": [ { "activity_code": "A", @@ -191,6 +201,7 @@ }, { "id": "09-completed-successor", + "sourceSha256": "0307a19fb51a61f1ad7b6445cbd6f2a20618ee4408912bc2cd320aca6ed1416b", "activities": [ { "activity_code": "A", @@ -214,6 +225,7 @@ }, { "id": "10-out-of-sequence-progress", + "sourceSha256": "2f598578d4539a95ea80698ab2979fd28a3cb007d8fe09e679a1173f00f5c5f6", "activities": [ { "activity_code": "A", @@ -237,6 +249,7 @@ }, { "id": "11-mandatory-start-finish", + "sourceSha256": "b4353c7fcdfff89ae1740cfa1e85a5c72d7c9493a53eea37a5cab246b828453b", "activities": [ { "activity_code": "A", @@ -260,6 +273,7 @@ }, { "id": "12-snet-fnlt", + "sourceSha256": "42480cf535150559b1ed5ed11d5e7604ee3c6ae1d666b9a555ab84b4936f0ee6", "activities": [ { "activity_code": "A", @@ -283,6 +297,7 @@ }, { "id": "13-alap", + "sourceSha256": "6ed931a167215ec05a8410c0751a02e429026b62410f0fed4f8768746f6a37bc", "activities": [ { "activity_code": "A", diff --git a/tests/planning/check-p6-verified-cases.ts b/tests/planning/check-p6-verified-cases.ts index aa57a1766..d756354a8 100644 --- a/tests/planning/check-p6-verified-cases.ts +++ b/tests/planning/check-p6-verified-cases.ts @@ -1,4 +1,5 @@ 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'; @@ -30,10 +31,48 @@ interface P6Row extends Record { FF_p6: string; } interface P6CasesFile { - provenance: { source: string; scope: string; warning: string }; - cases: { id: string; activities: P6Row[] }[]; + 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; @@ -42,8 +81,14 @@ const parsed = JSON.parse(readFileSync(dataPath, 'utf-8')) as P6CasesFile; 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); -truthy('1b herkomst noemt P6 23.12 en capturedatum', - parsed.provenance.source.includes('P6 23.12') && parsed.provenance.source.includes('2026-08-11')); +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', @@ -51,6 +96,17 @@ truthy('1d waarschuwing benoemt tijdverlies, finishnormalisatie en nagefitte bro && 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) { @@ -61,6 +117,11 @@ for (const item of parsed.cases) { 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)); + } } } diff --git a/tests/planning/check-xer-fidelity-baseline-schema.ts b/tests/planning/check-xer-fidelity-baseline-schema.ts index 2aa3e52e9..d45969f22 100644 --- a/tests/planning/check-xer-fidelity-baseline-schema.ts +++ b/tests/planning/check-xer-fidelity-baseline-schema.ts @@ -18,6 +18,7 @@ * 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 { @@ -26,6 +27,22 @@ import { } 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_935, ef: 13_941, ls: 13_833, lf: 13_825, tf: 13_677, ff: 13_322, +} as const; +const EXPECTED_MANIFEST_SHA256 = '6defbc4b4a71500565e5847750662060d9baca983952098dd1b334ac81d55786'; +const EXPECTED_BASELINE_SHA256 = 'e74960626b828b3187b11e2674783da34e7a4d8f470c1b97e40eb3ef48e7a6d2'; const diffs: string[] = []; let checks = 0; @@ -127,15 +144,64 @@ function validateBaseline(v: unknown): string[] { // ── 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); - truthy('1a xer-fidelity-baseline.json bevat de 22 unieke orakelbestanden', - isPlainObject(parsed) && isPlainObject(parsed.files) && Object.keys(parsed.files).length === 22); + 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 ───────────────────────────────── diff --git a/tests/planning/check-xer-fidelity.ts b/tests/planning/check-xer-fidelity.ts index 04c7210b0..e4b787050 100644 --- a/tests/planning/check-xer-fidelity.ts +++ b/tests/planning/check-xer-fidelity.ts @@ -1,4 +1,5 @@ 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'; @@ -6,7 +7,9 @@ import { buildXerTargetBaseline, measureXerFidelity, validateXerBaselinePins, + xerSchemaFingerprint, } from './xerFidelity'; +import type { XerCorpusManifest, XerCorpusRole } from './xerFidelity'; import { scanXerGroundTruth } from './xerGroundTruth'; import type { XerFidelityBaseline } from './xerFidelityTypes'; @@ -56,7 +59,7 @@ eq('1b scanner leest taakidentiteit en zeven rapportageassen', truth.tasks[0], { tf: 75, ff: 30, }, drivingPath: true, - storedAxes: { es: true, ef: true, ls: true, lf: true, tf: true, ff: true }, + presentAxes: { es: true, ef: true, ls: true, lf: true, tf: true, ff: true }, }); eq('1c completed gebruikt actuals en rondt fractionele float naar minuten', truth.tasks[1], { projectId: 'P2', @@ -69,8 +72,114 @@ eq('1c completed gebruikt actuals en rondt fractionele float naar minuten', trut tf: null, ff: -7, }, drivingPath: false, - storedAxes: { es: true, ef: true, ls: true, lf: true, tf: false, ff: true }, + 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, []); + +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\t\t\t\t\t1,25\t0,5\tY', + '%E', +].join('\n')); +const commaTruth = scanXerGroundTruth(commaFixture) as unknown as { + tasks: Array>; + errors: string[]; +}; + +// Breuk die dit vangt: Number(value) gebruiken zonder CURRTYPE en completed-aanwezigheid op de +// lege early/late-broncellen baseren in plaats van op de effectieve actuals. +eq('1e CURRTYPE-kommafloat blijft meetbaar in afgeronde 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: 75, ff: 30, + }, + 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 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('1g 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('1h 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\t\t\t\t\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', +]); const measured = measureXerFidelity(truth, [ { @@ -117,6 +226,118 @@ eq('2b driving path is zevende rapportage-as buiten counters', measured.drivingP }); 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', +]); + +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('2k grondwaarheid leest de echte PROJECT-set inclusief leeg project', + [...schemaP1P2.projects], ['P1', 'P2']); +eq('2l extra PROJECT-rij verandert de zesassige schemafingerprint', + xerSchemaFingerprint(schemaP1) === xerSchemaFingerprint(schemaP1P2), false); +eq('2m alleen driving_path_flag verandert de gatefingerprint niet', + xerSchemaFingerprint(schemaP1), xerSchemaFingerprint(schemaDrivingOnly)); +eq('2n 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([ @@ -124,30 +345,57 @@ const noOracle = Buffer.from([ '%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 built = buildXerTargetBaseline([ +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: 4, - byteUniqueFiles: 3, + scannedFiles: 5, + manifestFiles: 5, + includedFiles: 3, + excludedFiles: 2, + byteUniqueFiles: 4, byteDuplicateFiles: 1, - fourDateTasks: 6, - sixAxisTasks: 3, - drivingPathTasks: 6, + fourDateTasks: 8, + sixAxisTasks: 4, + drivingPathTasks: 8, + partialOnlyByteUniqueFiles: 0, + partialOnlyAxisCells: 0, byteUniqueOracleFiles: 2, schemaDuplicateFiles: 1, uniqueOracleFiles: 1, - byteUniqueOracleTasks: 2, - uniqueOracleTasks: 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('3a baseline pint per bestand de projectsom en beide tellers per as', entries.map(entry => ({ +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, @@ -165,11 +413,11 @@ eq('3a baseline pint per bestand de projectsom en beide tellers per as', entries const nonZeroWithoutReason = structuredClone(built.baseline); Object.values(nonZeroWithoutReason.files)[0].counters.es.deviations = 1; -eq('3b niet-nul-pin zonder reason wordt geweigerd', validateXerBaselinePins(nonZeroWithoutReason), [ +eq('3d 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('3c niet-nul-pin met reason is welgevormd', validateXerBaselinePins(nonZeroWithoutReason), []); +eq('3e niet-nul-pin met reason is welgevormd', validateXerBaselinePins(nonZeroWithoutReason), []); function listXerFilesRecursive(dir: string): string[] { const files: string[] = []; @@ -196,19 +444,28 @@ if (!corpusRoot) { label: relative(corpusRoot, path).split('\\').join('/'), bytes: readFileSync(path), })); - const corpus = buildXerTargetBaseline(corpusFiles); + 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, []); // 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 datumassen', corpus.stats.fourDateTasks, 18_489); - eq('C4 taken met alle zes assen', corpus.stats.sixAxisTasks, 17_963); - eq('C5 unieke orakelbestanden na byte-dedup', corpus.stats.byteUniqueOracleFiles, 23); - eq('C6 orakeltaken na byte-dedup', corpus.stats.byteUniqueOracleTasks, 17_600); - eq('C7 unieke orakelbestanden na beide deduplagen', corpus.stats.uniqueOracleFiles, 22); - eq('C8 orakeltaken na beide deduplagen', corpus.stats.uniqueOracleTasks, 13_383); - eq('C8a precies één inhoudsduplicaat na byte-dedup', corpus.stats.schemaDuplicateFiles, 1); + eq('C3 taken met vier effectieve datumassen', corpus.stats.fourDateTasks, 18_504); + eq('C4 taken met alle zes effectieve assen', corpus.stats.sixAxisTasks, 17_954); + eq('C4a de eerder gemiste scheve dekking blijft volledig zichtbaar', { + files: corpus.stats.partialOnlyByteUniqueFiles, + cells: corpus.stats.partialOnlyAxisCells, + }, { files: 29, cells: 2_088 }); + eq('C5 herkomstgeselecteerde orakelbestanden na byte-dedup', corpus.stats.byteUniqueOracleFiles, 36); + eq('C6 meetbare orakeltaken na byte-dedup', corpus.stats.byteUniqueOracleTasks, 18_194); + eq('C7 unieke orakelbestanden na beide deduplagen', corpus.stats.uniqueOracleFiles, 34); + eq('C8 meetbare orakeltaken na beide deduplagen', corpus.stats.uniqueOracleTasks, 13_963); + 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_935, ef: 13_941, ls: 13_833, lf: 13_825, tf: 13_677, ff: 13_322, + }); const baselinePath = join(HERE, 'xer-fidelity-baseline.json'); const committed = JSON.parse(readFileSync(baselinePath, 'utf-8')) as XerFidelityBaseline; diff --git a/tests/planning/fidelityCore.ts b/tests/planning/fidelityCore.ts index b00c3bfd5..19395d48d 100644 --- a/tests/planning/fidelityCore.ts +++ b/tests/planning/fidelityCore.ts @@ -27,6 +27,26 @@ export function classifyExact(ours: string | undefined, truth: string | null): F 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-poortvergelijking: uitsluitend twee geldige canonieke minuten kunnen exact zijn. */ +export function classifyMinuteExact( + ours: string | undefined, + truth: string | null, +): FidelityVerdict { + if (truth === null || ours === undefined) return 'missing'; + if (!isCanonicalMinute(ours) || !isCanonicalMinute(truth)) return 'diff'; + return ours === truth ? 'exact' : 'diff'; +} + export interface FidelityValuePair { ours: string | undefined; truth: string | null; 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 index 9009a8768..cd181f3a8 100644 --- a/tests/planning/xer-fidelity-baseline.json +++ b/tests/planning/xer-fidelity-baseline.json @@ -30,7 +30,39 @@ "measurable": 52 } }, - "schemaFingerprint": "cfe1fc0d9ba21ca6" + "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", @@ -62,7 +94,7 @@ "measurable": 70 } }, - "schemaFingerprint": "b61c9d452729a130" + "schemaFingerprint": "eff3a3e2b55bfb17" }, "2c1dce175b9f0781": { "label": "crawl-xer-extra/jailaff-xer-splitter/rehab-2.xer", @@ -94,7 +126,7 @@ "measurable": 6976 } }, - "schemaFingerprint": "2a90d1c08f617859" + "schemaFingerprint": "c6a7ada359dc738c" }, "146e4c8659bad066": { "label": "crawl-xer-extra/meridianiq/demo-sample.xer", @@ -126,7 +158,7 @@ "measurable": 30 } }, - "schemaFingerprint": "8ade02da137cc09e" + "schemaFingerprint": "e6e701b2eb7cd2fd" }, "53f5cdbeb3fbd9c6": { "label": "crawl-xer-extra/meridianiq/sample_update2.xer", @@ -158,7 +190,7 @@ "measurable": 31 } }, - "schemaFingerprint": "a6990e00940e33f5" + "schemaFingerprint": "edb0bca25b371266" }, "c872c9e704797d82": { "label": "crawl-xer-extra/p6difftool/sample-target.xer", @@ -190,7 +222,7 @@ "measurable": 8 } }, - "schemaFingerprint": "df43a1d904c33129" + "schemaFingerprint": "076682f8996cef76" }, "1ba8cf1885491db3": { "label": "crawl-xer-extra/planning-risk-intelligent/sample_schedule.xer", @@ -222,7 +254,7 @@ "measurable": 20 } }, - "schemaFingerprint": "09e1f54f9c488d4d" + "schemaFingerprint": "f81e7c7d61b45f91" }, "deabc76347eaef15": { "label": "crawl-xer-extra/schedulevisualiser/Sample_Project.xer", @@ -254,7 +286,7 @@ "measurable": 24 } }, - "schemaFingerprint": "d49801efb8734ab4" + "schemaFingerprint": "56fdb27c001a9872" }, "4d8bce790a93b9bc": { "label": "crawl-xer-extra/xernative/sample.xer", @@ -286,7 +318,7 @@ "measurable": 25 } }, - "schemaFingerprint": "508b7e192de8b447" + "schemaFingerprint": "80fd54b21f2afc76" }, "2bc12241c3f8ee5b": { "label": "crawl-xer/eh_P6Workshops/OZB-Start-09Dec24.xer", @@ -318,7 +350,39 @@ "measurable": 152 } }, - "schemaFingerprint": "752737afb7e1d3e0" + "schemaFingerprint": "9415c811af9cd8cf" + }, + "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", @@ -350,7 +414,7 @@ "measurable": 113 } }, - "schemaFingerprint": "99edcd57081f9063" + "schemaFingerprint": "380e546c56886af3" }, "ad92ce52ba95b275": { "label": "crawl-xer/hb-intel_Project_Schedule.xer", @@ -382,7 +446,39 @@ "measurable": 281 } }, - "schemaFingerprint": "b3f8e6abe788a5fb" + "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", @@ -414,7 +510,39 @@ "measurable": 4217 } }, - "schemaFingerprint": "c0559954e2c7d7b2" + "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", @@ -446,7 +574,7 @@ "measurable": 8 } }, - "schemaFingerprint": "747f2f90fdb99295" + "schemaFingerprint": "7948ab001aa2da93" }, "97058f720a70f623": { "label": "crawl-xer/p6diff-revised.xer", @@ -478,7 +606,7 @@ "measurable": 8 } }, - "schemaFingerprint": "8bde004d6b3f8cc8" + "schemaFingerprint": "dc5627141ff39da1" }, "a2ef7b35c00d8cf8": { "label": "crawl-xer/Roads_Project_TEC.xer", @@ -510,7 +638,7 @@ "measurable": 1036 } }, - "schemaFingerprint": "5b299c7a64f62382" + "schemaFingerprint": "2168c6d533f62f7b" }, "55b7e4463dcd36ba": { "label": "crawl-xer/Sample_Construction_TEC.xer", @@ -542,7 +670,39 @@ "measurable": 51 } }, - "schemaFingerprint": "3125accde6764654" + "schemaFingerprint": "3f976da19fabf0b7" + }, + "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", @@ -574,7 +734,7 @@ "measurable": 61 } }, - "schemaFingerprint": "05eedb2df080d008" + "schemaFingerprint": "17c89dd77c177b3a" }, "0611f9054a4b5663": { "label": "delay-analysis-toolkit/deploy/aws-package/sample/Harbour Point DCP-03 - Baseline Programme Rev 0.xer", @@ -606,7 +766,103 @@ "measurable": 60 } }, - "schemaFingerprint": "c521d20f46f9b036" + "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": 1 + }, + "lf": { + "deviations": 0, + "measurable": 2 + }, + "tf": { + "deviations": 0, + "measurable": 5 + }, + "ff": { + "deviations": 0, + "measurable": 0 + } + }, + "schemaFingerprint": "3b0d135d5ce02f18" }, "1d7901d68cc7063b": { "label": "P6-Viewer/XER Files/sample.xer", @@ -638,7 +894,7 @@ "measurable": 11 } }, - "schemaFingerprint": "f80099273141c876" + "schemaFingerprint": "d14f605d6a854880" }, "a2f3b2469e26f199": { "label": "P6-Viewer/XER Files/TERMINAL BUILDING-AIRPORT.xer", @@ -670,39 +926,167 @@ "measurable": 66 } }, - "schemaFingerprint": "59835bb802b586b5" + "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": 2 + }, + "lf": { + "deviations": 0, + "measurable": 2 + }, + "tf": { + "deviations": 0, + "measurable": 23 + }, + "ff": { + "deviations": 0, + "measurable": 0 + } + }, + "schemaFingerprint": "cf7f7bf2470c4e6e" + }, + "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": 1 + }, + "lf": { + "deviations": 0, + "measurable": 1 + }, + "tf": { + "deviations": 0, + "measurable": 22 + }, + "ff": { + "deviations": 0, + "measurable": 0 + } + }, + "schemaFingerprint": "b83f7fe67823738b" + }, + "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": 2 + }, + "lf": { + "deviations": 0, + "measurable": 2 + }, + "tf": { + "deviations": 0, + "measurable": 23 + }, + "ff": { + "deviations": 0, + "measurable": 0 + } + }, + "schemaFingerprint": "b8ea5c0682122f82" }, - "b8bc3cb30463c99c": { - "label": "pmxml-samples/testXer.xer", - "tasks": 83, + "78325fb6445d22f5": { + "label": "ProjectLens/docs/demo/riverside-previous.xer", + "tasks": 22, "projects": 1, "counters": { "es": { "deviations": 0, - "measurable": 83 + "measurable": 22 }, "ef": { "deviations": 0, - "measurable": 83 + "measurable": 22 }, "ls": { "deviations": 0, - "measurable": 83 + "measurable": 1 }, "lf": { "deviations": 0, - "measurable": 83 + "measurable": 1 }, "tf": { "deviations": 0, - "measurable": 83 + "measurable": 22 }, "ff": { "deviations": 0, - "measurable": 83 + "measurable": 0 } }, - "schemaFingerprint": "1832cd1cb1022d1f" + "schemaFingerprint": "8cc9c1c456a80b52" } } } diff --git a/tests/planning/xerFidelity.ts b/tests/planning/xerFidelity.ts index 0b723e7c8..f77b4beee 100644 --- a/tests/planning/xerFidelity.ts +++ b/tests/planning/xerFidelity.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto'; import { classifyExact, + classifyMinuteExact, compareFidelityRow, countFidelityAxis, type FidelityCounts, @@ -47,12 +48,19 @@ export interface XerProjectFidelity { } 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 { @@ -60,8 +68,33 @@ export interface XerCorpusFile { 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. */ @@ -70,18 +103,22 @@ export interface XerCorpusStats { 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; - /** Taken met alle zes assen na uitsluitend byte-dedup. */ byteUniqueOracleTasks: number; - /** Taken met alle zes assen na beide deduplagen. */ uniqueOracleTasks: number; + selectedMeasurable: Record; } export interface XerTargetBaselineResult { baseline: XerFidelityBaseline; stats: XerCorpusStats; + errors: string[]; } function asComparable(value: string | number | boolean | null | undefined): string | null | undefined { @@ -110,17 +147,31 @@ function measureProject( projectId: string, truthTasks: readonly XerGroundTruthTask[], solved: XerSolvedProject | undefined, - projectIndex: number, errors: string[], ): XerProjectFidelity { const solvedById = new Map(); - let duplicateSolvedIds = 0; for (const task of solved?.tasks ?? []) { - if (solvedById.has(task.sourceTaskId)) duplicateSolvedIds++; + if (solvedById.has(task.sourceTaskId)) { + errors.push(`project ${projectId}: dubbele opgeloste taak-id: ${task.sourceTaskId}`); + } else solvedById.set(task.sourceTaskId, task); } - if (duplicateSolvedIds > 0) { - errors.push(`project ${projectIndex + 1}: ${duplicateSolvedIds} dubbele opgeloste taak-id('s)`); + + 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 !== truthTask.taskCode) { + errors.push( + `project ${projectId}/taak ${truthTask.taskId}: taskCode verwacht ${truthTask.taskCode}, ` + + `kreeg ${solvedTask.taskCode}`, + ); + } } const rows = truthTasks.map(truthTask => { @@ -131,6 +182,10 @@ function measureProject( truth: asComparable(truthTask.axes[axis]) ?? null, }]), ) as Record, { + es: classifyMinuteExact, + ef: classifyMinuteExact, + ls: classifyMinuteExact, + lf: classifyMinuteExact, tf: classifyExact, ff: classifyExact, }); @@ -163,7 +218,11 @@ function addAxisCounts(target: XerFidelityAxisCounts, source: XerFidelityAxisCou } function isFullOracleTask(task: XerGroundTruthTask): boolean { - return XER_FIDELITY_AXES.every(axis => task.storedAxes[axis]); + 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 { @@ -178,10 +237,9 @@ 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, - task.statusCode, - ...XER_FIDELITY_AXES.map(axis => task.axes[axis] ?? ''), - task.drivingPath === null ? '' : task.drivingPath ? 'Y' : 'N', + ...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); } @@ -204,10 +262,11 @@ function targetCountersPerProject(truth: XerGroundTruth): XerFidelityCounters { } 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: truth.projects.size, + projects: taskProjects.size, counters: targetCountersPerProject(truth), schemaFingerprint: fingerprint, }; @@ -218,32 +277,65 @@ function targetEntry(label: string, truth: XerGroundTruth, fingerprint: string): * 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[]): XerTargetBaselineResult { +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 seenBytes = new Set(); const seenSchemas = new Set(); 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}`); + continue; + } + 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.storedAxes.es && task.storedAxes.ef && task.storedAxes.ls && task.storedAxes.lf).length; + 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 fullByteHash = byteHash(file.bytes); if (seenBytes.has(fullByteHash)) { stats.byteDuplicateFiles++; continue; @@ -252,9 +344,24 @@ export function buildXerTargetBaseline(files: readonly XerCorpusFile[]): XerTarg stats.byteUniqueFiles++; const fullOracleTasks = truth.tasks.filter(isFullOracleTask).length; - if (fullOracleTasks === 0) continue; + 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 (!manifestEntry.included) 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 += fullOracleTasks; + stats.byteUniqueOracleTasks += axisTasks; const fingerprint = xerSchemaFingerprint(truth); if (seenSchemas.has(fingerprint)) { @@ -263,11 +370,13 @@ export function buildXerTargetBaseline(files: readonly XerCorpusFile[]): XerTarg } seenSchemas.add(fingerprint); stats.uniqueOracleFiles++; - stats.uniqueOracleTasks += fullOracleTasks; - baseline.files[fullByteHash.slice(0, 16)] = targetEntry(file.label, truth, fingerprint); + 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 }; + return { baseline, stats, errors }; } export function validateXerBaselinePins(baseline: XerFidelityBaseline): string[] { @@ -295,10 +404,24 @@ export function measureXerFidelity( list.push(task); truthByProject.set(task.projectId, list); } - const solvedByProject = new Map(solvedProjects.map(project => [project.projectId, project])); - const errors: string[] = []; - const projects = [...truthByProject.entries()].map(([projectId, tasks], index) => - measureProject(projectId, tasks, solvedByProject.get(projectId), index, errors)); + const errors: string[] = [...truth.errors]; + const solvedByProject = new Map(); + for (const project of solvedProjects) { + 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 }; @@ -307,11 +430,25 @@ export function measureXerFidelity( 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 { - tasks: projects.reduce((sum, project) => sum + project.tasks, 0), + 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 index 9289f0b96..ca2b4b2d8 100644 --- a/tests/planning/xerFidelityTypes.ts +++ b/tests/planning/xerFidelityTypes.ts @@ -15,13 +15,13 @@ * `ons_float_in_minuten === round(p6_float_uren × 60)`). * * `Measurable` bestaat APART van `Deviations` omdat de orakeldekking scheef is (planreview M1, - * her-check-meting): ES/EF gevuld in 48 orakelbestanden, LS/LF in 36, TF in ±18.400 cellen, FF in - * ±18.000 — sommige bestanden dragen alleen float en geen datums, en andersom. Beide tellers - * worden gepind (X1): een lezer die stilletjes MINDER gaat meten (een as die stil naar 0 meetbaar - * zakt) moet de suite even rood maken als een lezer die FOUT meet. + * her-check-meting): na herkomstselectie en beide deduplagen zijn ES/EF/LS/LF/TF/FF respectievelijk + * 13.935/13.941/13.833/13.825/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. */ -/** Eén as se dubbele teller: hoeveel taken op deze as gemeten zijn (`measurable`), en hoeveel +/** 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; @@ -55,9 +55,9 @@ export function emptyCounters(): XerFidelityCounters { } /** - * Eén gepind bestand. `tasks`/`projects` zijn de SOM over de daadwerkelijk geopende projecten - * binnen dat bestand (X-O1: lege en als-baseline-gematerialiseerde projecten tellen niet mee — - * "de fidelity meet per project en pint per bestand de som", plan §6-X-O1-slotzin). + * 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 diff --git a/tests/planning/xerGroundTruth.ts b/tests/planning/xerGroundTruth.ts index 1bc212be4..58e0f50b3 100644 --- a/tests/planning/xerGroundTruth.ts +++ b/tests/planning/xerGroundTruth.ts @@ -19,17 +19,26 @@ export interface XerGroundTruthTask { statusCode: string; axes: Record; drivingPath: boolean | null; - /** Aanwezigheid van P6's opgeslagen uitvoerveld, los van parseerbaarheid/statussubstitutie. */ - storedAxes: Record; + /** 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[]; } 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) { @@ -55,47 +64,172 @@ function decodeXer(bytes: Uint8Array): { text: string; encoding: XerGroundTruthE } } -/** Canonieke minuutstring; ongeldige/sentinelwaarden zijn niet meetbaar. */ -function parseOracleDate(raw: string | undefined): string | null { +/** Canonieke minuutstring; leeg is niet meetbaar, niet-leeg maar ongeldig is een scannerfout. */ +function parseOracleDate( + raw: string | undefined, + taskId: string, + field: string, + errors: string[], +): string | null { const value = raw?.trim() ?? ''; + if (!value || value === '0') return null; const match = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::\d{2})?)?$/.exec(value); - if (!match) return null; - const [, year, month, day, hour = '00', minute = '00'] = match; + if (!match) { + errors.push(`TASK ${taskId}/${field}: ongeldige datum ${JSON.stringify(value)}`); + return null; + } + const [, year, month, day, rawHour, rawMinute] = match; + const hour = rawHour ?? '00'; + const minute = rawMinute ?? '00'; const date = new Date(`${year}-${month}-${day}T${hour}:${minute}:00Z`); - if (Number.isNaN(date.getTime())) return null; - if (date.toISOString().slice(0, 16) !== `${year}-${month}-${day}T${hour}:${minute}`) return null; + 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 null; + } return `${year}-${month}-${day}T${hour}:${minute}`; } +function decodeNumberSymbol( + values: readonly string[], + field: 'decimal_symbol' | 'digit_group_symbol', + errors: string[], +): '.' | ',' | null { + for (const raw of values) { + const value = raw.trim().toLowerCase(); + if (!value) continue; + if (value === '.' || value === 'period' || value === 'ds_period' || value === 'dg_period') return '.'; + if (value === ',' || value === 'comma' || value === 'ds_comma' || value === 'dg_comma') return ','; + } + if (field === 'decimal_symbol') errors.push('CURRTYPE/decimal_symbol: ontbrekende of onbekende waarde'); + return null; +} + +function scanNumberFormat(lines: readonly string[], errors: 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: Map[] = []; + for (const line of lines) { + const cells = line.split('\t'); + const marker = cells[0]?.trim(); + 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(row); + } + const row = (defaultCurrency + ? currencyRows.find(candidate => candidate.get('curr_short_name')?.trim() === defaultCurrency) + : undefined) ?? currencyRows[0]; + if (!row) return { decimal: '.', group: null, fromCurrencyTable: false }; + const decimal = decodeNumberSymbol([ + row.get('decimal_symbol') ?? '', row.get('decimal_symbol_type') ?? '', + ], 'decimal_symbol', errors); + const group = decodeNumberSymbol([ + row.get('digit_group_symbol') ?? '', row.get('digit_group_symbol_type') ?? '', + ], 'digit_group_symbol', errors); + if (decimal === null) return { decimal: '.', group: null, fromCurrencyTable: true }; + if (group === decimal) { + errors.push('CURRTYPE: decimaal- en groepsteken zijn gelijk'); + return { decimal, group: null, fromCurrencyTable: true }; + } + return { decimal, group, fromCurrencyTable: true }; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** P6-float staat in uren; het orakel wordt meteen naar afgeronde minuten genormaliseerd. */ -function parseFloatMinutes(raw: string | undefined): number | null { +function parseFloatMinutes( + raw: string | undefined, + taskId: string, + field: string, + format: XerNumberFormat, + errors: string[], +): number | null { const value = raw?.trim() ?? ''; if (!value) return null; - const hours = Number(value); - return Number.isFinite(hours) ? Math.round(hours * 60) : 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 Math.round(hours * 60); } -function parseDrivingPath(raw: string | undefined): boolean | null { +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[]): XerGroundTruthTask | 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() ?? ''; - if (!taskId) return null; + if (!taskId) { + errors.push(`TASK rij ${rowNumber}/task_id: ontbrekende waarde`); + return null; + } const statusCode = row.get('status_code')?.trim() ?? ''; - const completed = statusCode.toLowerCase() === 'tk_complete'; - const start = completed ? parseOracleDate(row.get('act_start_date')) : null; - const finish = completed ? parseOracleDate(row.get('act_end_date')) : null; + const normalizedStatus = statusCode.toLowerCase(); + if (statusCode && !KNOWN_STATUS_CODES.has(normalizedStatus)) { + errors.push(`TASK ${taskId}/status_code: onbekende waarde ${JSON.stringify(statusCode)}`); + } + const completed = normalizedStatus === 'tk_complete'; + const startField = completed ? 'act_start_date' : 'early_start_date'; + const finishField = completed ? 'act_end_date' : 'early_end_date'; + const lateStartField = completed ? 'act_start_date' : 'late_start_date'; + const lateFinishField = completed ? 'act_end_date' : 'late_end_date'; + const start = parseOracleDate(row.get(startField), taskId, startField, errors); + const finish = parseOracleDate(row.get(finishField), taskId, finishField, errors); + const lateStart = completed ? start : parseOracleDate(row.get(lateStartField), taskId, lateStartField, errors); + const lateFinish = completed ? finish : parseOracleDate(row.get(lateFinishField), taskId, lateFinishField, errors); return { projectId, @@ -103,19 +237,19 @@ function buildTask(fields: readonly string[], values: readonly string[]): XerGro taskCode, statusCode, axes: { - es: completed ? start : parseOracleDate(row.get('early_start_date')), - ef: completed ? finish : parseOracleDate(row.get('early_end_date')), - ls: completed ? start : parseOracleDate(row.get('late_start_date')), - lf: completed ? finish : parseOracleDate(row.get('late_end_date')), - tf: parseFloatMinutes(row.get('total_float_hr_cnt')), - ff: parseFloatMinutes(row.get('free_float_hr_cnt')), + es: start, + ef: finish, + ls: lateStart, + lf: lateFinish, + 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), }, - drivingPath: parseDrivingPath(row.get('driving_path_flag')), - storedAxes: { - es: !!row.get('early_start_date')?.trim(), - ef: !!row.get('early_end_date')?.trim(), - ls: !!row.get('late_start_date')?.trim(), - lf: !!row.get('late_end_date')?.trim(), + drivingPath: parseDrivingPath(row.get('driving_path_flag'), taskId, errors), + presentAxes: { + es: start !== null, + ef: finish !== null, + ls: lateStart !== null, + lf: lateFinish !== null, tf: !!row.get('total_float_hr_cnt')?.trim(), ff: !!row.get('free_float_hr_cnt')?.trim(), }, @@ -124,31 +258,71 @@ function buildTask(fields: readonly string[], values: readonly string[]): XerGro 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 projects = new Set(); + const declaredProjects = new Set(); + const taskProjects = new Set(); + const errors: string[] = []; + const numberFormat = scanNumberFormat(lines, errors); let table = ''; let fields: string[] = []; + let taskRowNumber = 0; + let projectRowNumber = 0; + let sawProjectTable = false; + const seenTaskIds = new Set(); - for (const rawLine of text.split('\n')) { - const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine; + for (const line of lines) { const cells = line.split('\t'); const marker = cells[0]?.trim(); if (marker === '%T') { table = cells[1]?.trim().toUpperCase() ?? ''; + if (table === 'PROJECT') sawProjectTable = true; fields = []; continue; } - if (table !== 'TASK') continue; if (marker === '%F') { fields = cells.slice(1).map(field => field.trim()); continue; } if (marker !== '%R' || fields.length === 0) continue; - const task = buildTask(fields, cells.slice(1)); + 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); - projects.add(task.projectId); + taskProjects.add(task.projectId); } - return { encoding, projects, tasks }; + 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})`); + } + } + } + return { + encoding, + projects: sawProjectTable ? declaredProjects : taskProjects, + tasks, + errors, + }; } From 3ea6f129210de3a1a07c1e80fa48844b28db06e7 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:22:25 +0200 Subject: [PATCH 010/171] test(xer): dicht X1-herreviewranden --- tests/planning/check-xer-fidelity.ts | 96 +++++++++++++++++++++++++--- tests/planning/xerFidelity.ts | 39 ++++++++--- tests/planning/xerGroundTruth.ts | 12 +++- 3 files changed, 129 insertions(+), 18 deletions(-) diff --git a/tests/planning/check-xer-fidelity.ts b/tests/planning/check-xer-fidelity.ts index e4b787050..377e46f71 100644 --- a/tests/planning/check-xer-fidelity.ts +++ b/tests/planning/check-xer-fidelity.ts @@ -181,6 +181,31 @@ eq('1l dubbele bron-taak-id binnen een project is fataal', duplicateTruthIds.err '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', @@ -310,6 +335,29 @@ eq('2j dubbele project- en taak-id zijn beide fataal', duplicateIds.errors, [ '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', @@ -329,13 +377,13 @@ 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('2k grondwaarheid leest de echte PROJECT-set inclusief leeg project', +eq('2n grondwaarheid leest de echte PROJECT-set inclusief leeg project', [...schemaP1P2.projects], ['P1', 'P2']); -eq('2l extra PROJECT-rij verandert de zesassige schemafingerprint', +eq('2o extra PROJECT-rij verandert de zesassige schemafingerprint', xerSchemaFingerprint(schemaP1) === xerSchemaFingerprint(schemaP1P2), false); -eq('2m alleen driving_path_flag verandert de gatefingerprint niet', +eq('2p alleen driving_path_flag verandert de gatefingerprint niet', xerSchemaFingerprint(schemaP1), xerSchemaFingerprint(schemaDrivingOnly)); -eq('2n andere taakidentiteit verandert de gatefingerprint', +eq('2q andere taakidentiteit verandert de gatefingerprint', xerSchemaFingerprint(schemaP1) === xerSchemaFingerprint(schemaOtherTaskId), false); const byteDuplicate = Buffer.from(bytes); @@ -411,13 +459,45 @@ eq('3c baseline pint per bestand de projectsom en beide tellers per as', entries }, }]); +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('3d niet-nul-pin zonder reason wordt geweigerd', validateXerBaselinePins(nonZeroWithoutReason), [ +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('3e niet-nul-pin met reason is welgevormd', validateXerBaselinePins(nonZeroWithoutReason), []); +eq('3f niet-nul-pin met reason is welgevormd', validateXerBaselinePins(nonZeroWithoutReason), []); function listXerFilesRecursive(dir: string): string[] { const files: string[] = []; @@ -452,8 +532,8 @@ if (!corpusRoot) { // 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 effectieve datumassen', corpus.stats.fourDateTasks, 18_504); - eq('C4 taken met alle zes effectieve assen', corpus.stats.sixAxisTasks, 17_954); + eq('C3 taken met vier effectieve datumassen', corpus.stats.fourDateTasks, 18_421); + 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, diff --git a/tests/planning/xerFidelity.ts b/tests/planning/xerFidelity.ts index f77b4beee..6da40280f 100644 --- a/tests/planning/xerFidelity.ts +++ b/tests/planning/xerFidelity.ts @@ -151,6 +151,13 @@ function measureProject( ): 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}`); } @@ -166,7 +173,7 @@ function measureProject( } for (const truthTask of truthTasks) { const solvedTask = solvedById.get(truthTask.taskId); - if (solvedTask && solvedTask.taskCode !== truthTask.taskCode) { + if (solvedTask && solvedTask.taskCode.trim() && solvedTask.taskCode !== truthTask.taskCode) { errors.push( `project ${projectId}/taak ${truthTask.taskId}: taskCode verwacht ${truthTask.taskCode}, ` + `kreeg ${solvedTask.taskCode}`, @@ -313,8 +320,12 @@ export function buildXerTargetBaseline( for (const label of [...filesByLabel.keys()].filter(label => !(label in manifest.files)).sort()) { errors.push(`corpusbestand ontbreekt in manifest: ${label}`); } - const seenBytes = new Set(); 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]; @@ -322,7 +333,6 @@ export function buildXerTargetBaseline( const fullByteHash = byteHash(file.bytes); if (manifestEntry.sha256 !== fullByteHash) { errors.push(`${file.label}: SHA-256 verwacht ${manifestEntry.sha256}, kreeg ${fullByteHash}`); - continue; } if (manifestEntry.included !== (manifestEntry.role === 'oracle')) { errors.push(`${file.label}: included moet exact overeenkomen met role=oracle`); @@ -336,12 +346,19 @@ export function buildXerTargetBaseline( stats.sixAxisTasks += truth.tasks.filter(isFullOracleTask).length; stats.drivingPathTasks += truth.tasks.filter(task => task.drivingPath !== null).length; - if (seenBytes.has(fullByteHash)) { - stats.byteDuplicateFiles++; - continue; - } - seenBytes.add(fullByteHash); + 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; @@ -351,7 +368,7 @@ export function buildXerTargetBaseline( stats.partialOnlyByteUniqueFiles++; stats.partialOnlyAxisCells += axisCells; } - if (!manifestEntry.included) continue; + if (!oracle) continue; if (truth.errors.length > 0) { errors.push(...truth.errors.map(error => `${file.label}: ${error}`)); continue; @@ -407,6 +424,10 @@ export function measureXerFidelity( 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 { diff --git a/tests/planning/xerGroundTruth.ts b/tests/planning/xerGroundTruth.ts index 58e0f50b3..8a6f99b9c 100644 --- a/tests/planning/xerGroundTruth.ts +++ b/tests/planning/xerGroundTruth.ts @@ -211,10 +211,20 @@ function buildTask( 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`); - return null; + 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(); From e18c9126d3ef5a07149aff573677708857ddb7a5 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:00:54 +0200 Subject: [PATCH 011/171] feat: voeg zelfstandige XER-formaatlaag toe --- src/services/xer/xerTables.ts | 374 +++++++++++++++++++++++++++ tests/planning/check-xer-corpus.ts | 174 +++++++++++++ tests/planning/check-xer-tables.ts | 388 +++++++++++++++++++++++++++++ tests/planning/run.sh | 10 + 4 files changed, 946 insertions(+) create mode 100644 src/services/xer/xerTables.ts create mode 100644 tests/planning/check-xer-corpus.ts create mode 100644 tests/planning/check-xer-tables.ts diff --git a/src/services/xer/xerTables.ts b/src/services/xer/xerTables.ts new file mode 100644 index 000000000..bc35cc5c8 --- /dev/null +++ b/src/services/xer/xerTables.ts @@ -0,0 +1,374 @@ +/** + * 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'; +export type XerImportErrorCode = + | 'XER_INVALID_FILE' + | 'XER_MISSING_REQUIRED_COLUMNS' + | 'XER_AMBIGUOUS_DECIMAL' + | 'XER_INVALID_NUMBER_FORMAT' + | 'XER_INVALID_NUMBER'; + +export class XerImportError extends Error { + readonly xerCode: XerImportErrorCode; + readonly table?: string; + readonly missingColumns?: string[]; + + constructor( + xerCode: XerImportErrorCode, + message: string, + context?: { table?: string; missingColumns?: string[] }, + ) { + super(message); + this.name = 'XerImportError'; + this.xerCode = xerCode; + this.table = context?.table; + this.missingColumns = context?.missingColumns; + } +} + +export interface XerHeader { + version: string; + defaultCurrencyCode: string; +} + +export interface XerRow { + line: number; + cells: Record; +} + +export interface XerTable { + name: string; + fields: string[]; + rows: XerRow[]; +} + +export type XerImportIssueCode = + | 'XER_MISSING_END_MARKER' + | '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; +} + +export interface XerImportReport { + encoding: XerEncoding; + endMarkerSeen: boolean; + issues: XerImportIssue[]; + unknownTables: Array<{ 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; +} + +function decodeTextCell(field: string, raw: string): string { + const unquoted = raw.replace(/""/g, '"'); + const isNote = field === 'task_notes' + || field === 'memo_text' + || field === 'notes' + || field.endsWith('_notes'); + if (!isNote) return unquoted; + const withoutContamination = Array.from(unquoted) + .filter(char => ![0, 0xfeff, 0xfffe].includes(char.charCodeAt(0))) + .join(''); + return withoutContamination.split('\u007f\u007f').join('\n'); +} + +// Tabelset waarvoor de latere X3-X8-lagen data nodig kunnen hebben. De selectie volgt voor begrip +// MPXJ's `PrimaveraXERFileReader.READ_REQUIRED_TABLES`; de opslag- en rapportcode hieronder is eigen. +const READ_TABLES = new Set([ + 'PROJECT', 'CALENDAR', 'RSRC', 'RSRCRATE', 'PROJWBS', 'TASK', 'TASKPRED', 'TASKRSRC', + 'CURRTYPE', 'UDFTYPE', 'UDFVALUE', 'SCHEDOPTIONS', 'ACTVTYPE', 'ACTVCODE', 'TASKACTV', + 'COSTTYPE', 'ACCOUNT', 'PROJCOST', 'MEMOTYPE', 'WBSMEMO', 'TASKMEMO', 'ROLES', 'ROLERATE', + 'RSRCCURVDATA', 'TASKPROC', 'LOCATION', 'UMEASURE', 'SHIFT', 'SHIFTPER', 'RSRCROLE', + 'PCATTYPE', 'PCATVAL', 'PROJPCAT', 'RCATTYPE', 'RCATVAL', 'RSRCRCAT', 'ROLECATTYPE', + 'ROLECATVAL', 'ROLERCAT', 'ASGNMNTCATTYPE', 'ASGNMNTCATVAL', 'ASGNMNTACAT', +]); + +function decodeXerBytes(bytes: Uint8Array): { text: string; encoding: XerEncoding } { + if (bytes.length >= 3 && 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.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) { + return { + text: new TextDecoder('utf-16le', { fatal: true }).decode(bytes.subarray(2)), + encoding: 'utf-16le', + }; + } + if (bytes.length >= 2 && 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 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 }, + ); + } +} + +/** + * 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, +): XerNumberFormat { + const currencyRows = tables.get('CURRTYPE')?.rows ?? []; + const selected = currencyRows.find(row => + row.cells.curr_short_name?.trim().toUpperCase() === currencyCode.trim().toUpperCase()) + ?? currencyRows[0]; + if (!selected) return { decimal: '.', group: null, source: 'default', currencyCode }; + const decodeSymbol = (values: readonly string[]): '.' | ',' | null => { + for (const raw of values) { + const value = raw.trim().toLowerCase(); + if (value === '.' || value === 'period' || value === 'ds_period' || value === 'dg_period') return '.'; + if (value === ',' || value === 'comma' || value === 'ds_comma' || value === 'dg_comma') return ','; + } + return null; + }; + const decimalValues = [ + selected.cells.decimal_symbol ?? '', + selected.cells.decimal_symbol_type ?? '', + ]; + const groupValues = [ + selected.cells.digit_group_symbol ?? '', + selected.cells.digit_group_symbol_type ?? '', + ]; + const decimal = decodeSymbol(decimalValues); + const group = decodeSymbol(groupValues); + if (decimal === null + || (group === null && groupValues.some(value => value.trim())) + || group === decimal) { + throw new XerImportError( + 'XER_INVALID_NUMBER_FORMAT', + 'CURRTYPE bevat geen betrouwbare combinatie van decimaal- en groepsteken.', + ); + } + return { + decimal, + 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; +} + +function hasProvableCommaDecimals(tables: ReadonlyMap): boolean { + const numericField = /(?:_hr_cnt|_qty|_cost|_pct|_rate|_seq_num|_num|_id)$/; + const commaDecimal = /^[+-]?(?:\d+,\d{1,2}|\d{1,3}(?:\.\d{3})+,\d+)$/; + for (const table of tables.values()) { + for (const row of table.rows) { + for (const field of table.fields) { + if (numericField.test(field) && commaDecimal.test(row.cells[field]?.trim() ?? '')) return true; + } + } + } + return false; +} + +export function parseXerTables(bytes: Uint8Array): XerTables { + 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 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: XerTable | undefined; + let currentUnknown: { name: string; rows: number } | 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)) { + current = { name, fields: [], rows: [] }; + tables.set(name, current); + } else { + current = undefined; + currentUnknown = { name, rows: 0 }; + unknownTables.push(currentUnknown); + } + } 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(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 === '%R' && current && current.fields.length > 0) { + const rowValues = values.slice(1); + if (rowValues.length !== current.fields.length) { + issues.push({ + code: 'XER_ROW_FIELD_COUNT_MISMATCH', + line: index + 1, + table: current.name, + expected: current.fields.length, + actual: rowValues.length, + }); + } + const cells: Record = {}; + current.fields.forEach((field, fieldIndex) => { + cells[field] = decodeTextCell(field, rowValues[fieldIndex] ?? ''); + }); + current.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) { + currentUnknown.rows++; + } else if (marker === '%E') { + endMarkerSeen = true; + current = undefined; + currentUnknown = undefined; + } else if (marker !== '') { + issues.push({ + code: 'XER_UNKNOWN_RECORD', + line: index + 1, + ...(current ? { table: current.name } : {}), + }); + } + } + + assertRequiredColumns(tables); + const header: XerHeader = { + version: headerCells[1] ?? '', + defaultCurrencyCode: headerCells[8] ?? '', + }; + const numberFormat = determineNumberFormat(tables, header.defaultCurrencyCode); + if (numberFormat.source === 'default' && hasProvableCommaDecimals(tables)) { + throw new XerImportError( + 'XER_AMBIGUOUS_DECIMAL', + 'XER-bestand gebruikt aantoonbaar komma-decimalen maar bevat geen bruikbare CURRTYPE-tabel.', + ); + } + + return { + header, + tables, + numberFormat, + report: { + encoding, + endMarkerSeen, + issues: endMarkerSeen ? issues : [ + ...issues, + { code: 'XER_MISSING_END_MARKER', line: lines.length }, + ], + unknownTables, + }, + }; +} diff --git a/tests/planning/check-xer-corpus.ts b/tests/planning/check-xer-corpus.ts new file mode 100644 index 000000000..c27635c51 --- /dev/null +++ b/tests/planning/check-xer-corpus.ts @@ -0,0 +1,174 @@ +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[] }; + return { + name: typed.name, + xerCode: typed.xerCode, + ...(typed.table ? { table: typed.table } : {}), + ...(typed.missingColumns ? { missingColumns: typed.missingColumns } : {}), + }; + } +} + +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'), { + name: 'XerImportError', xerCode: 'XER_MISSING_REQUIRED_COLUMNS', table: 'TASK', + missingColumns: ['proj_id'], + }); + eq('R4 p6xer-empty_tables', summary(root, 'crawl-xer/p6xer-empty_tables.xer'), { + name: 'XerImportError', xerCode: 'XER_MISSING_REQUIRED_COLUMNS', table: 'TASK', + missingColumns: ['proj_id', 'task_code'], + }); + eq('R5 p6xer-malformed', summary(root, 'crawl-xer/p6xer-malformed.xer'), { + name: 'XerImportError', xerCode: 'XER_MISSING_REQUIRED_COLUMNS', table: 'TASK', + missingColumns: ['proj_id', 'task_code'], + }); + eq('R6 p6xer-encodings blijft geldige UTF-8 met onbekende tabellen', + summary(root, 'crawl-xer/p6xer-encodings.xer'), { + encoding: 'utf-8', + tables: {}, + issues: [], + unknownTables: [ + { name: 'SPECIAL_CHARS', rows: 5 }, + { name: 'MIXED_ENCODINGS', rows: 4 }, + { name: 'EXTENDED_ASCII', rows: 5 }, + { name: 'CONTROL_CHARS', rows: 3 }, + ], + }); + 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 aggregate = { + files: 0, + ok: 0, + oracleOk: 0, + oracleErrors: 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++; + 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++; + } + } + eq('C1 volledige manifestcrawl heeft concrete parser-/rapporttellingen', aggregate, { + files: 93, + ok: 69, + oracleOk: 45, + oracleErrors: 0, + encodings: { 'utf-8': 58, 'windows-1252': 11 }, + errors: { XER_MISSING_REQUIRED_COLUMNS: 22, XER_INVALID_FILE: 2 }, + issues: { + XER_MISSING_END_MARKER: 6, + XER_UNKNOWN_RECORD: 82, + XER_ROW_FIELD_COUNT_MISMATCH: 114, + }, + unknownTables: 62, + unknownRows: 926, + }); +} + +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-tables.ts b/tests/planning/check-xer-tables.ts new file mode 100644 index 000000000..a0ca920c4 --- /dev/null +++ b/tests/planning/check-xer-tables.ts @@ -0,0 +1,388 @@ +import { parseXerNumber, 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 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; + missingColumns?: string[]; + }; + return { + name: typed.name, + xerCode: typed.xerCode, + ...(typed.table ? { table: typed.table } : {}), + ...(typed.missingColumns ? { missingColumns: typed.missingColumns } : {}), + }; + } +} + +// 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', + '%R\tP1\t1', + '%R\tP1\t2\tA2\textra', + '%R\tP1\t3\tA3', + '%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: '' }, + { proj_id: 'P1', task_id: '2', task_code: 'A2' }, + { proj_id: 'P1', task_id: '3', task_code: 'A3' }, + ], + issues: [ + { code: 'XER_ROW_FIELD_COUNT_MISMATCH', line: 4, table: 'TASK', expected: 3, actual: 2 }, + { code: 'XER_ROW_FIELD_COUNT_MISMATCH', line: 5, table: 'TASK', expected: 3, actual: 4 }, + ], +}); + +// 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\tTASK', + '%F\tproj_id\ttask_id\ttask_code\ttask_name\ttask_notes', + `%R\tP1\t1\tA1\tTaak\tBegin\u007f\u007fmidden\u0000\ufeff`, + '\t\t\t\t\teinde', + '%E', +])); +eq('5 DEL-DEL-notitie, BOM/NUL-strip en lege-token-continuatie', { + rows: multilineNote.tables.get('TASK')?.rows.length, + note: multilineNote.tables.get('TASK')?.rows[0]?.cells.task_notes, + 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 }], +}); + +// 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', + }); + +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' }, + { name: 'XerImportError', xerCode: 'XER_INVALID_NUMBER_FORMAT' }, +]); + +// 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'] }, +]); + +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/run.sh b/tests/planning/run.sh index 5d939be4e..a9b2bf959 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -220,6 +220,16 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then 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 + + # 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. From a797f3d7c6cdf5ca891693d55f1bb92523457554 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:05:48 +0200 Subject: [PATCH 012/171] fix: verhard de XER-formaatlaag na review --- src/services/xer/xerTables.ts | 265 ++++++++++++++++++++----- tests/planning/check-xer-corpus.ts | 160 +++++++++++++-- tests/planning/check-xer-fidelity.ts | 51 ++++- tests/planning/check-xer-tables.ts | 280 ++++++++++++++++++++++++++- tests/planning/xerGroundTruth.ts | 97 +++++++--- 5 files changed, 752 insertions(+), 101 deletions(-) diff --git a/src/services/xer/xerTables.ts b/src/services/xer/xerTables.ts index bc35cc5c8..32f115199 100644 --- a/src/services/xer/xerTables.ts +++ b/src/services/xer/xerTables.ts @@ -7,9 +7,19 @@ */ 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_MISSING_REQUIRED_COLUMNS' + | 'XER_MISSING_REQUIRED_VALUE' | 'XER_AMBIGUOUS_DECIMAL' | 'XER_INVALID_NUMBER_FORMAT' | 'XER_INVALID_NUMBER'; @@ -18,17 +28,29 @@ export class XerImportError extends Error { readonly xerCode: XerImportErrorCode; readonly table?: string; readonly missingColumns?: string[]; + readonly missingValues?: string[]; + readonly line?: number; + readonly encoding?: XerEncoding; constructor( xerCode: XerImportErrorCode, message: string, - context?: { table?: string; missingColumns?: string[] }, + context?: { + table?: string; + missingColumns?: string[]; + missingValues?: string[]; + line?: number; + encoding?: XerEncoding; + }, ) { super(message); this.name = 'XerImportError'; this.xerCode = xerCode; this.table = context?.table; this.missingColumns = context?.missingColumns; + this.missingValues = context?.missingValues; + this.line = context?.line; + this.encoding = context?.encoding; } } @@ -50,6 +72,8 @@ export interface XerTable { 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' @@ -63,6 +87,9 @@ export interface XerImportIssue { expected?: number; actual?: number; field?: string; + currencyCode?: string; + ignoredRecords?: number; + ignoredLines?: number; } export interface XerImportReport { @@ -86,13 +113,20 @@ export interface XerNumberFormat { currencyCode: string; } -function decodeTextCell(field: string, raw: string): 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', + 'TASKMEMO.task_memo', + 'TASKPROC.proc_descr', + 'WBSMEMO.wbs_memo', +]); + +function decodeTextCell(table: string, field: string, raw: string): string { const unquoted = raw.replace(/""/g, '"'); - const isNote = field === 'task_notes' - || field === 'memo_text' - || field === 'notes' - || field.endsWith('_notes'); - if (!isNote) return unquoted; + if (!P6_NOTE_FIELDS.has(`${table}.${field}`)) return unquoted; const withoutContamination = Array.from(unquoted) .filter(char => ![0, 0xfeff, 0xfffe].includes(char.charCodeAt(0))) .join(''); @@ -110,24 +144,34 @@ const READ_TABLES = new Set([ 'ROLECATVAL', 'ROLERCAT', 'ASGNMNTCATTYPE', 'ASGNMNTCATVAL', 'ASGNMNTACAT', ]); -function decodeXerBytes(bytes: Uint8Array): { text: string; encoding: XerEncoding } { - if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) { +function decodeBomPayload( + bytes: Uint8Array, + offset: number, + encoding: 'utf-8' | 'utf-16le' | 'utf-16be', +): { text: string; encoding: XerEncoding } { + try { return { - text: new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(3)), - encoding: 'utf-8', + 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 { - text: new TextDecoder('utf-16le', { fatal: true }).decode(bytes.subarray(2)), - encoding: 'utf-16le', - }; + return decodeBomPayload(bytes, 2, 'utf-16le'); } if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) { - return { - text: new TextDecoder('utf-16be', { fatal: true }).decode(bytes.subarray(2)), - encoding: 'utf-16be', - }; + return decodeBomPayload(bytes, 2, 'utf-16be'); } try { return { @@ -165,6 +209,44 @@ function assertRequiredColumns(tables: ReadonlyMap): void { } } +function assertRequiredIdentity( + tables: ReadonlyMap, + issues: readonly XerImportIssue[], +): void { + const structurallyBrokenRows = new Set(issues + .filter(issue => issue.code === 'XER_ROW_FIELD_COUNT_MISMATCH') + .map(issue => `${issue.table ?? ''}\u0000${issue.line}`)); + 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) { + if (structurallyBrokenRows.has(`${tableName}\u0000${row.line}`)) continue; + 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 @@ -176,33 +258,62 @@ function assertRequiredColumns(tables: ReadonlyMap): void { function determineNumberFormat( tables: ReadonlyMap, currencyCode: string, + issues: XerImportIssue[], ): XerNumberFormat { const currencyRows = tables.get('CURRTYPE')?.rows ?? []; - const selected = currencyRows.find(row => - row.cells.curr_short_name?.trim().toUpperCase() === currencyCode.trim().toUpperCase()) - ?? currencyRows[0]; - if (!selected) return { decimal: '.', group: null, source: 'default', currencyCode }; - const decodeSymbol = (values: readonly string[]): '.' | ',' | null => { - for (const raw of values) { - const value = raw.trim().toLowerCase(); - if (value === '.' || value === 'period' || value === 'ds_period' || value === 'dg_period') return '.'; - if (value === ',' || value === 'comma' || value === 'ds_comma' || value === 'dg_comma') return ','; + if (currencyRows.length === 0) { + return { decimal: '.', group: null, source: 'default', currencyCode }; + } + const normalizedCurrency = currencyCode.trim().toLowerCase(); + const selected = normalizedCurrency + ? currencyRows.find(row => + row.cells.curr_short_name?.trim().toLowerCase() === normalizedCurrency) + : undefined; + if (!selected) { + issues.push({ + code: 'XER_CURRENCY_NOT_FOUND', + line: 1, + table: 'CURRTYPE', + currencyCode, + }); + return { decimal: '.', group: null, source: 'default', currencyCode }; + } + + const decodeToken = (raw: string, family: 'decimal' | 'group'): '.' | ',' => { + 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 new XerImportError( + 'XER_INVALID_NUMBER_FORMAT', + 'CURRTYPE bevat geen betrouwbare combinatie van decimaal- en groepsteken.', + ); + }; + const resolveRepresentations = ( + values: readonly string[], + family: 'decimal' | 'group', + ): '.' | ',' | null => { + const decoded = values.filter(value => value.trim()).map(value => decodeToken(value, family)); + if (new Set(decoded).size > 1) { + throw new XerImportError( + 'XER_INVALID_NUMBER_FORMAT', + 'CURRTYPE bevat tegenstrijdige separatorrepresentaties.', + ); } - return null; + return decoded[0] ?? null; }; - const decimalValues = [ + const decimal = resolveRepresentations([ selected.cells.decimal_symbol ?? '', selected.cells.decimal_symbol_type ?? '', - ]; - const groupValues = [ + ], 'decimal'); + const group = resolveRepresentations([ selected.cells.digit_group_symbol ?? '', selected.cells.digit_group_symbol_type ?? '', - ]; - const decimal = decodeSymbol(decimalValues); - const group = decodeSymbol(groupValues); - if (decimal === null - || (group === null && groupValues.some(value => value.trim())) - || group === decimal) { + ], 'group'); + if (decimal === null || group === decimal) { throw new XerImportError( 'XER_INVALID_NUMBER_FORMAT', 'CURRTYPE bevat geen betrouwbare combinatie van decimaal- en groepsteken.', @@ -241,20 +352,58 @@ export function parseXerNumber(raw: string, format: XerNumberFormat): number | n return parsed; } +// Expliciete P6-veldencatalogus: identiteit en vrije tekst mogen nooit op hun naamvorm als +// decimaalbewijs gelden. X4a en latere mappers kunnen deze lijst uitbreiden wanneer zij nieuwe +// numerieke tabellen consumeren; X2 gebruikt hem uitsluitend om een onveilige localegok te weren. +const XER_NUMERIC_FIELD_CATALOG = new Set([ + 'CALENDAR.day_hr_cnt', 'CALENDAR.week_hr_cnt', 'CALENDAR.month_hr_cnt', 'CALENDAR.year_hr_cnt', + 'PROJECT.last_recalc_priority', + 'PROJCOST.cost_value', 'PROJCOST.cost_load_value', 'PROJCOST.actual_value', 'PROJCOST.remain_value', + 'ROLES.def_qty_per_hr', 'ROLERATE.cost_per_qty', + 'RSRC.def_qty_per_hr', 'RSRC.ot_factor', + 'RSRCRATE.cost_per_qty', 'RSRCRATE.max_qty_per_hr', + 'TASK.complete_pct', 'TASK.phys_complete_pct', + 'TASK.target_drtn_hr_cnt', 'TASK.remain_drtn_hr_cnt', 'TASK.act_drtn_hr_cnt', + 'TASK.total_float_hr_cnt', 'TASK.free_float_hr_cnt', 'TASK.old_remain_drtn_hr_cnt', + 'TASK.critical_drtn_hr_cnt', + 'TASK.target_work_qty', 'TASK.remain_work_qty', 'TASK.act_work_qty', + 'TASK.act_this_per_work_qty', 'TASK.target_equip_qty', 'TASK.remain_equip_qty', + 'TASK.act_equip_qty', 'TASK.act_this_per_equip_qty', + 'TASK.remain_cost', 'TASK.plan_cost', 'TASK.act_cost', + 'TASK.target_qty_per_hr', 'TASK.act_reg_qty', 'TASK.act_ot_qty', + 'TASKPRED.lag_hr_cnt', + 'TASKPROC.complete_pct', 'TASKPROC.seq_num', 'TASKPROC.proc_wt', + 'TASKRSRC.target_qty', 'TASKRSRC.remain_qty', 'TASKRSRC.act_reg_qty', 'TASKRSRC.act_ot_qty', + 'TASKRSRC.target_cost', 'TASKRSRC.remain_cost', 'TASKRSRC.act_reg_cost', 'TASKRSRC.act_ot_cost', + 'TASKRSRC.target_rate', 'TASKRSRC.cost_per_qty', + 'TASKRSRC.target_lag_drtn_hr_cnt', 'TASKRSRC.remain_lag_drtn_hr_cnt', +]); + function hasProvableCommaDecimals(tables: ReadonlyMap): boolean { - const numericField = /(?:_hr_cnt|_qty|_cost|_pct|_rate|_seq_num|_num|_id)$/; - const commaDecimal = /^[+-]?(?:\d+,\d{1,2}|\d{1,3}(?:\.\d{3})+,\d+)$/; + 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 (numericField.test(field) && commaDecimal.test(row.cells[field]?.trim() ?? '')) return true; + if (!XER_NUMERIC_FIELD_CATALOG.has(`${table.name}.${field}`)) continue; + const value = row.cells[field]?.trim() ?? ''; + if ((plainCommaDecimal.test(value) || groupedCommaDecimal.test(value)) + && !commaGroupedInteger.test(value)) return true; } } } return false; } -export function parseXerTables(bytes: Uint8Array): XerTables { +/** 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(); @@ -306,35 +455,46 @@ export function parseXerTables(bytes: Uint8Array): XerTables { values.slice(1).forEach((raw, fieldIndex) => { const field = current?.fields[fieldIndex]; if (field === undefined || raw === '') return; - const continuation = decodeTextCell(field, raw); + 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 === '%R' && current && current.fields.length > 0) { + const table = current; const rowValues = values.slice(1); - if (rowValues.length !== current.fields.length) { + if (rowValues.length !== table.fields.length) { issues.push({ code: 'XER_ROW_FIELD_COUNT_MISMATCH', line: index + 1, - table: current.name, - expected: current.fields.length, + table: table.name, + expected: table.fields.length, actual: rowValues.length, }); } const cells: Record = {}; - current.fields.forEach((field, fieldIndex) => { - cells[field] = decodeTextCell(field, rowValues[fieldIndex] ?? ''); + table.fields.forEach((field, fieldIndex) => { + cells[field] = decodeTextCell(table.name, field, rowValues[fieldIndex] ?? ''); }); - current.rows.push({ line: index + 1, cells }); + 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) { currentUnknown.rows++; } else if (marker === '%E') { endMarkerSeen = true; - current = undefined; - currentUnknown = undefined; + const ignoredTail = lines.slice(index + 1); + const ignoredRecords = ignoredTail.filter(line => line.trim()).length; + if (ignoredRecords > 0) { + const firstIgnoredOffset = ignoredTail.findIndex(line => line.trim()); + issues.push({ + code: 'XER_TRAILING_RECORDS_AFTER_END', + line: index + 2 + firstIgnoredOffset, + ignoredRecords, + ignoredLines: ignoredTail.length, + }); + } + break; } else if (marker !== '') { issues.push({ code: 'XER_UNKNOWN_RECORD', @@ -345,11 +505,12 @@ export function parseXerTables(bytes: Uint8Array): XerTables { } assertRequiredColumns(tables); + assertRequiredIdentity(tables, issues); const header: XerHeader = { version: headerCells[1] ?? '', defaultCurrencyCode: headerCells[8] ?? '', }; - const numberFormat = determineNumberFormat(tables, header.defaultCurrencyCode); + const numberFormat = determineNumberFormat(tables, header.defaultCurrencyCode, issues); if (numberFormat.source === 'default' && hasProvableCommaDecimals(tables)) { throw new XerImportError( 'XER_AMBIGUOUS_DECIMAL', diff --git a/tests/planning/check-xer-corpus.ts b/tests/planning/check-xer-corpus.ts index c27635c51..d9ed3797c 100644 --- a/tests/planning/check-xer-corpus.ts +++ b/tests/planning/check-xer-corpus.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { parseXerTables } from '@/services/xer/xerTables'; @@ -22,12 +23,21 @@ function summary(root: string, label: string): unknown { unknownTables: parsed.report.unknownTables, }; } catch (error) { - const typed = error as { name?: string; xerCode?: string; table?: string; missingColumns?: string[] }; + 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 } : {}), }; } } @@ -85,27 +95,42 @@ if (!root) { missingColumns: ['proj_id', 'task_code'], }); eq('R3 p6xer-comprehensive', summary(root, 'crawl-xer/p6xer-comprehensive.xer'), { - name: 'XerImportError', xerCode: 'XER_MISSING_REQUIRED_COLUMNS', table: 'TASK', - missingColumns: ['proj_id'], + 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'), { - name: 'XerImportError', xerCode: 'XER_MISSING_REQUIRED_COLUMNS', table: 'TASK', - missingColumns: ['proj_id', 'task_code'], + 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'), { - name: 'XerImportError', xerCode: 'XER_MISSING_REQUIRED_COLUMNS', table: 'TASK', - missingColumns: ['proj_id', 'task_code'], + 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: [], + issues: [{ + code: 'XER_TRAILING_RECORDS_AFTER_END', + line: 11, + ignoredRecords: 21, + ignoredLines: 24, + }], unknownTables: [ { name: 'SPECIAL_CHARS', rows: 5 }, - { name: 'MIXED_ENCODINGS', rows: 4 }, - { name: 'EXTENDED_ASCII', rows: 5 }, - { name: 'CONTROL_CHARS', rows: 3 }, ], }); eq('R7 p6xer-mixed_endings wordt als onbekend dialect gerapporteerd', @@ -117,13 +142,106 @@ if (!root) { }); const manifest = JSON.parse(readFileSync(join(import.meta.dirname, 'xer-corpus-manifest.json'), 'utf8')) as { - files: Record; + 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 aggregate = { files: 0, ok: 0, oracleOk: 0, oracleErrors: 0, + referenceOk: 0, + referenceErrors: 0, encodings: {} as Record, errors: {} as Record, issues: {} as Record, @@ -136,6 +254,7 @@ if (!root) { 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); @@ -146,22 +265,27 @@ if (!root) { 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: 69, + ok: 71, oracleOk: 45, oracleErrors: 0, - encodings: { 'utf-8': 58, 'windows-1252': 11 }, - errors: { XER_MISSING_REQUIRED_COLUMNS: 22, XER_INVALID_FILE: 2 }, + 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: 82, + XER_UNKNOWN_RECORD: 67, XER_ROW_FIELD_COUNT_MISMATCH: 114, + XER_TRAILING_RECORDS_AFTER_END: 5, + XER_CURRENCY_NOT_FOUND: 2, }, - unknownTables: 62, - unknownRows: 926, + unknownTables: 60, + unknownRows: 914, }); } diff --git a/tests/planning/check-xer-fidelity.ts b/tests/planning/check-xer-fidelity.ts index 377e46f71..8cefbaf1b 100644 --- a/tests/planning/check-xer-fidelity.ts +++ b/tests/planning/check-xer-fidelity.ts @@ -110,6 +110,53 @@ eq('1e CURRTYPE-kommafloat blijft meetbaar in afgeronde minuten', commaTruth.tas }); 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 brokenTruth = scanXerGroundTruth(Buffer.from([ 'ERMHDR\t23.12', '%T\tTASK', @@ -119,7 +166,7 @@ const brokenTruth = scanXerGroundTruth(Buffer.from([ '%R\tP1\t\tNO_ID\tNo id\tTK_NotStart\t\t\t\t\t\t\t\t\t', '%E', ].join('\n'))); -eq('1g niet-lege kapotte waarden en ontbrekende identiteit zijn fataal zichtbaar', brokenTruth.errors, [ +eq('1h 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', @@ -131,7 +178,7 @@ const brokenMeasurement = measureXerFidelity(brokenTruth, [{ { sourceTaskId: 'NO_STATUS', taskCode: 'NO_STATUS' }, ], }]); -eq('1h scannerfouten zijn gate-fataal bij verder exacte uitlijning', { +eq('1i scannerfouten zijn gate-fataal bij verder exacte uitlijning', { errors: brokenMeasurement.errors, gatePassed: brokenMeasurement.gatePassed, }, { errors: brokenTruth.errors, gatePassed: false }); diff --git a/tests/planning/check-xer-tables.ts b/tests/planning/check-xer-tables.ts index a0ca920c4..5e3692ff6 100644 --- a/tests/planning/check-xer-tables.ts +++ b/tests/planning/check-xer-tables.ts @@ -1,4 +1,9 @@ -import { parseXerNumber, parseXerTables } from '@/services/xer/xerTables'; +import { + parseXerNumber, + parseXerTables, + XER_TRANSPORT_KIND, + type XerByteInput, +} from '@/services/xer/xerTables'; const diffs: string[] = []; let checks = 0; @@ -63,12 +68,18 @@ function caughtXerError(run: () => unknown): Record | null { xerCode?: string; table?: string; missingColumns?: string[]; + missingValues?: string[]; + line?: number; + encoding?: string; }; 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 } : {}), + ...(typed.encoding ? { encoding: typed.encoding } : {}), }; } } @@ -162,15 +173,15 @@ eq('4 veld-/waardetellingsverschil wordt verzameld en vervolgd', { // laten staan, of de BOM/NUL-vervuiling uit MPXJ's NotesHelper-gevallen doorgeven aan de gebruiker. const multilineNote = parseXerTables(utf8([ 'ERMHDR\t23.12', - '%T\tTASK', - '%F\tproj_id\ttask_id\ttask_code\ttask_name\ttask_notes', - `%R\tP1\t1\tA1\tTaak\tBegin\u007f\u007fmidden\u0000\ufeff`, - '\t\t\t\t\teinde', + '%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('TASK')?.rows.length, - note: multilineNote.tables.get('TASK')?.rows[0]?.cells.task_notes, + rows: multilineNote.tables.get('TASKMEMO')?.rows.length, + note: multilineNote.tables.get('TASKMEMO')?.rows[0]?.cells.task_memo, issues: multilineNote.report.issues, }, { rows: 1, @@ -379,6 +390,261 @@ eq('15 verplichte sleutelkolommen gelden per aanwezige P6-kern-/koppeltabel', [ { 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', + })), +}); + +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', + })), + groupedRows: 3, + numericLookingText: null, +}); + if (diffs.length === 0) { console.log(`OK xer-tables: ${checks} checks groen`); } else { diff --git a/tests/planning/xerGroundTruth.ts b/tests/planning/xerGroundTruth.ts index 8a6f99b9c..a6b42bbdf 100644 --- a/tests/planning/xerGroundTruth.ts +++ b/tests/planning/xerGroundTruth.ts @@ -29,6 +29,8 @@ export interface XerGroundTruth { 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[]; } const IDENTITY_FIELDS = ['proj_id', 'task_id', 'task_code'] as const; @@ -90,22 +92,65 @@ function parseOracleDate( return `${year}-${month}-${day}T${hour}:${minute}`; } -function decodeNumberSymbol( - values: readonly string[], - field: 'decimal_symbol' | 'digit_group_symbol', +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 { - for (const raw of values) { - const value = raw.trim().toLowerCase(); - if (!value) continue; - if (value === '.' || value === 'period' || value === 'ds_period' || value === 'dg_period') return '.'; - if (value === ',' || value === 'comma' || value === 'ds_comma' || value === 'dg_comma') return ','; + 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 (field === 'decimal_symbol') errors.push('CURRTYPE/decimal_symbol: ontbrekende of onbekende waarde'); - 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[]): XerNumberFormat { +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 = ''; @@ -130,16 +175,22 @@ function scanNumberFormat(lines: readonly string[], errors: string[]): XerNumber for (let index = 0; index < fields.length; index++) row.set(fields[index], values[index] ?? ''); currencyRows.push(row); } - const row = (defaultCurrency - ? currencyRows.find(candidate => candidate.get('curr_short_name')?.trim() === defaultCurrency) - : undefined) ?? currencyRows[0]; - if (!row) return { decimal: '.', group: null, fromCurrencyTable: false }; - const decimal = decodeNumberSymbol([ - row.get('decimal_symbol') ?? '', row.get('decimal_symbol_type') ?? '', - ], 'decimal_symbol', errors); - const group = decodeNumberSymbol([ - row.get('digit_group_symbol') ?? '', row.get('digit_group_symbol_type') ?? '', - ], 'digit_group_symbol', errors); + if (currencyRows.length === 0) return { decimal: '.', group: null, fromCurrencyTable: false }; + const normalizedCurrency = defaultCurrency.toLowerCase(); + const row = normalizedCurrency + ? currencyRows.find(candidate => + candidate.get('curr_short_name')?.trim().toLowerCase() === normalizedCurrency) + : undefined; + if (!row) { + issues.push( + `CURRTYPE: ERMHDR-valuta ${defaultCurrency || '(leeg)'} heeft geen overeenkomstige rij; ` + + 'punt-default gebruikt', + ); + return { decimal: '.', group: null, fromCurrencyTable: false }; + } + const currency = row.get('curr_short_name')?.trim() || defaultCurrency; + const decimal = inspectNumberFamily(row, 'decimal', currency, errors); + const group = inspectNumberFamily(row, 'group', currency, errors); if (decimal === null) return { decimal: '.', group: null, fromCurrencyTable: true }; if (group === decimal) { errors.push('CURRTYPE: decimaal- en groepsteken zijn gelijk'); @@ -273,7 +324,8 @@ export function scanXerGroundTruth(bytes: Uint8Array): XerGroundTruth { const declaredProjects = new Set(); const taskProjects = new Set(); const errors: string[] = []; - const numberFormat = scanNumberFormat(lines, errors); + const numberFormatIssues: string[] = []; + const numberFormat = scanNumberFormat(lines, errors, numberFormatIssues); let table = ''; let fields: string[] = []; let taskRowNumber = 0; @@ -334,5 +386,6 @@ export function scanXerGroundTruth(bytes: Uint8Array): XerGroundTruth { projects: sawProjectTable ? declaredProjects : taskProjects, tasks, errors, + numberFormatIssues, }; } From ab24476ab00d6a0e8653ffc1f1dd6b6fad0e268d Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:45:37 +0200 Subject: [PATCH 013/171] fix: sluit resterende XER-rereviewgrenzen --- src/services/xer/xerTables.ts | 141 ++++++++++++--------- tests/planning/check-xer-corpus.ts | 23 ++++ tests/planning/check-xer-fidelity.ts | 57 ++++++++- tests/planning/check-xer-tables.ts | 178 +++++++++++++++++++++++++-- tests/planning/xerGroundTruth.ts | 50 +++++--- 5 files changed, 360 insertions(+), 89 deletions(-) diff --git a/src/services/xer/xerTables.ts b/src/services/xer/xerTables.ts index 32f115199..6fe28de5a 100644 --- a/src/services/xer/xerTables.ts +++ b/src/services/xer/xerTables.ts @@ -30,6 +30,7 @@ export class XerImportError extends Error { readonly missingColumns?: string[]; readonly missingValues?: string[]; readonly line?: number; + readonly lines?: number[]; readonly encoding?: XerEncoding; constructor( @@ -40,6 +41,7 @@ export class XerImportError extends Error { missingColumns?: string[]; missingValues?: string[]; line?: number; + lines?: number[]; encoding?: XerEncoding; }, ) { @@ -50,6 +52,7 @@ export class XerImportError extends Error { this.missingColumns = context?.missingColumns; this.missingValues = context?.missingValues; this.line = context?.line; + this.lines = context?.lines; this.encoding = context?.encoding; } } @@ -209,13 +212,7 @@ function assertRequiredColumns(tables: ReadonlyMap): void { } } -function assertRequiredIdentity( - tables: ReadonlyMap, - issues: readonly XerImportIssue[], -): void { - const structurallyBrokenRows = new Set(issues - .filter(issue => issue.code === 'XER_ROW_FIELD_COUNT_MISMATCH') - .map(issue => `${issue.table ?? ''}\u0000${issue.line}`)); +function assertRequiredIdentity(tables: ReadonlyMap): void { const requirements: ReadonlyArray = [ ['PROJECT', ['proj_id']], ['CALENDAR', ['clndr_id']], @@ -229,7 +226,6 @@ function assertRequiredIdentity( const table = tables.get(tableName); if (!table) continue; for (const row of table.rows) { - if (structurallyBrokenRows.has(`${tableName}\u0000${row.line}`)) continue; const missingValues = required.filter(field => !row.cells[field]?.trim()); if (tableName === 'TASKRSRC' && !row.cells.rsrc_id?.trim() @@ -265,11 +261,11 @@ function determineNumberFormat( return { decimal: '.', group: null, source: 'default', currencyCode }; } const normalizedCurrency = currencyCode.trim().toLowerCase(); - const selected = normalizedCurrency - ? currencyRows.find(row => + const matchingRows = normalizedCurrency + ? currencyRows.filter(row => row.cells.curr_short_name?.trim().toLowerCase() === normalizedCurrency) - : undefined; - if (!selected) { + : []; + if (matchingRows.length === 0) { issues.push({ code: 'XER_CURRENCY_NOT_FOUND', line: 1, @@ -279,7 +275,17 @@ function determineNumberFormat( return { decimal: '.', group: null, source: 'default', currencyCode }; } - const decodeToken = (raw: string, family: 'decimal' | 'group'): '.' | ',' => { + 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 ','; @@ -287,41 +293,55 @@ function determineNumberFormat( if (value === 'ds_comma' && family === 'decimal') return ','; if (value === 'dg_period' && family === 'group') return '.'; if (value === 'dg_comma' && family === 'group') return ','; - throw new XerImportError( - 'XER_INVALID_NUMBER_FORMAT', + 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)); + const decoded = values.filter(value => value.trim()).map(value => decodeToken(value, family, line)); if (new Set(decoded).size > 1) { - throw new XerImportError( - 'XER_INVALID_NUMBER_FORMAT', + throw formatError( 'CURRTYPE bevat tegenstrijdige separatorrepresentaties.', + line, ); } return decoded[0] ?? null; }; - const decimal = resolveRepresentations([ - selected.cells.decimal_symbol ?? '', - selected.cells.decimal_symbol_type ?? '', - ], 'decimal'); - const group = resolveRepresentations([ - selected.cells.digit_group_symbol ?? '', - selected.cells.digit_group_symbol_type ?? '', - ], 'group'); - if (decimal === null || group === decimal) { - throw new XerImportError( - 'XER_INVALID_NUMBER_FORMAT', - 'CURRTYPE bevat geen betrouwbare combinatie van decimaal- en groepsteken.', + 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, - group, + decimal: selected.decimal, + group: selected.group, source: 'currtype', currencyCode, }; @@ -352,31 +372,31 @@ export function parseXerNumber(raw: string, format: XerNumberFormat): number | n return parsed; } -// Expliciete P6-veldencatalogus: identiteit en vrije tekst mogen nooit op hun naamvorm als -// decimaalbewijs gelden. X4a en latere mappers kunnen deze lijst uitbreiden wanneer zij nieuwe -// numerieke tabellen consumeren; X2 gebruikt hem uitsluitend om een onveilige localegok te weren. -const XER_NUMERIC_FIELD_CATALOG = new Set([ - 'CALENDAR.day_hr_cnt', 'CALENDAR.week_hr_cnt', 'CALENDAR.month_hr_cnt', 'CALENDAR.year_hr_cnt', - 'PROJECT.last_recalc_priority', - 'PROJCOST.cost_value', 'PROJCOST.cost_load_value', 'PROJCOST.actual_value', 'PROJCOST.remain_value', - 'ROLES.def_qty_per_hr', 'ROLERATE.cost_per_qty', - 'RSRC.def_qty_per_hr', 'RSRC.ot_factor', - 'RSRCRATE.cost_per_qty', 'RSRCRATE.max_qty_per_hr', - 'TASK.complete_pct', 'TASK.phys_complete_pct', - 'TASK.target_drtn_hr_cnt', 'TASK.remain_drtn_hr_cnt', 'TASK.act_drtn_hr_cnt', - 'TASK.total_float_hr_cnt', 'TASK.free_float_hr_cnt', 'TASK.old_remain_drtn_hr_cnt', - 'TASK.critical_drtn_hr_cnt', - 'TASK.target_work_qty', 'TASK.remain_work_qty', 'TASK.act_work_qty', - 'TASK.act_this_per_work_qty', 'TASK.target_equip_qty', 'TASK.remain_equip_qty', - 'TASK.act_equip_qty', 'TASK.act_this_per_equip_qty', - 'TASK.remain_cost', 'TASK.plan_cost', 'TASK.act_cost', - 'TASK.target_qty_per_hr', 'TASK.act_reg_qty', 'TASK.act_ot_qty', - 'TASKPRED.lag_hr_cnt', - 'TASKPROC.complete_pct', 'TASKPROC.seq_num', 'TASKPROC.proc_wt', - 'TASKRSRC.target_qty', 'TASKRSRC.remain_qty', 'TASKRSRC.act_reg_qty', 'TASKRSRC.act_ot_qty', - 'TASKRSRC.target_cost', 'TASKRSRC.remain_cost', 'TASKRSRC.act_reg_cost', 'TASKRSRC.act_ot_cost', - 'TASKRSRC.target_rate', 'TASKRSRC.cost_per_qty', - 'TASKRSRC.target_lag_drtn_hr_cnt', 'TASKRSRC.remain_lag_drtn_hr_cnt', +// 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', 'seq_num', 'target_rate', ]); function hasProvableCommaDecimals(tables: ReadonlyMap): boolean { @@ -386,7 +406,7 @@ function hasProvableCommaDecimals(tables: ReadonlyMap): boolea for (const table of tables.values()) { for (const row of table.rows) { for (const field of table.fields) { - if (!XER_NUMERIC_FIELD_CATALOG.has(`${table.name}.${field}`)) continue; + if (!XER_DECIMAL_FIELDS.has(field)) continue; const value = row.cells[field]?.trim() ?? ''; if ((plainCommaDecimal.test(value) || groupedCommaDecimal.test(value)) && !commaGroupedInteger.test(value)) return true; @@ -487,11 +507,12 @@ export function parseXerTables(bytes: XerByteInput): XerTables { 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, + ignoredLines: ignoredTail.length - syntheticEndToken, }); } break; @@ -505,7 +526,7 @@ export function parseXerTables(bytes: XerByteInput): XerTables { } assertRequiredColumns(tables); - assertRequiredIdentity(tables, issues); + assertRequiredIdentity(tables); const header: XerHeader = { version: headerCells[1] ?? '', defaultCurrencyCode: headerCells[8] ?? '', diff --git a/tests/planning/check-xer-corpus.ts b/tests/planning/check-xer-corpus.ts index d9ed3797c..071a1caeb 100644 --- a/tests/planning/check-xer-corpus.ts +++ b/tests/planning/check-xer-corpus.ts @@ -235,6 +235,29 @@ if (!root) { 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, diff --git a/tests/planning/check-xer-fidelity.ts b/tests/planning/check-xer-fidelity.ts index 8cefbaf1b..aac0c478b 100644 --- a/tests/planning/check-xer-fidelity.ts +++ b/tests/planning/check-xer-fidelity.ts @@ -157,6 +157,59 @@ eq('1g X1 controleert CURRTYPE via een onafhankelijk volledig pad', { ], }); +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', @@ -166,7 +219,7 @@ const brokenTruth = scanXerGroundTruth(Buffer.from([ '%R\tP1\t\tNO_ID\tNo id\tTK_NotStart\t\t\t\t\t\t\t\t\t', '%E', ].join('\n'))); -eq('1h niet-lege kapotte waarden en ontbrekende identiteit zijn fataal zichtbaar', brokenTruth.errors, [ +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', @@ -178,7 +231,7 @@ const brokenMeasurement = measureXerFidelity(brokenTruth, [{ { sourceTaskId: 'NO_STATUS', taskCode: 'NO_STATUS' }, ], }]); -eq('1i scannerfouten zijn gate-fataal bij verder exacte uitlijning', { +eq('1j scannerfouten zijn gate-fataal bij verder exacte uitlijning', { errors: brokenMeasurement.errors, gatePassed: brokenMeasurement.gatePassed, }, { errors: brokenTruth.errors, gatePassed: false }); diff --git a/tests/planning/check-xer-tables.ts b/tests/planning/check-xer-tables.ts index 5e3692ff6..ecd4760b6 100644 --- a/tests/planning/check-xer-tables.ts +++ b/tests/planning/check-xer-tables.ts @@ -70,6 +70,7 @@ function caughtXerError(run: () => unknown): Record | null { missingColumns?: string[]; missingValues?: string[]; line?: number; + lines?: number[]; encoding?: string; }; return { @@ -79,6 +80,7 @@ function caughtXerError(run: () => unknown): Record | null { ...(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 } : {}), }; } @@ -148,10 +150,10 @@ eq('3 quotes en lege laatste kolomnaam', { const mismatchedRows = parseXerTables(utf8([ 'ERMHDR\t23.12', '%T\tTASK', - '%F\tproj_id\ttask_id\ttask_code', - '%R\tP1\t1', - '%R\tP1\t2\tA2\textra', - '%R\tP1\t3\tA3', + '%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', { @@ -159,13 +161,13 @@ eq('4 veld-/waardetellingsverschil wordt verzameld en vervolgd', { issues: mismatchedRows.report.issues, }, { rows: [ - { proj_id: 'P1', task_id: '1', task_code: '' }, - { proj_id: 'P1', task_id: '2', task_code: 'A2' }, - { proj_id: 'P1', task_id: '3', task_code: 'A3' }, + { 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: 3, actual: 2 }, - { code: 'XER_ROW_FIELD_COUNT_MISMATCH', line: 5, table: 'TASK', expected: 3, actual: 4 }, + { 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 }, ], }); @@ -340,8 +342,8 @@ 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' }, - { name: 'XerImportError', xerCode: 'XER_INVALID_NUMBER_FORMAT' }, + { 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 @@ -566,6 +568,9 @@ eq('19 CURRTYPE matcht exact en valideert alle separatorrepresentaties', { invalid: Array.from({ length: 5 }, () => ({ name: 'XerImportError', xerCode: 'XER_INVALID_NUMBER_FORMAT', + table: 'CURRTYPE', + line: 4, + lines: [4], })), }); @@ -645,6 +650,157 @@ eq('22 komma-decimaalbewijs gebruikt veldcatalogus en onbeperkte precisie', { 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: Array.from({ length: 9 }, () => ({ + name: 'XerImportError', xerCode: 'XER_AMBIGUOUS_DECIMAL', + })), + negative: { udfText: null, integerId: null, projectTextAndId: null }, +}); + if (diffs.length === 0) { console.log(`OK xer-tables: ${checks} checks groen`); } else { diff --git a/tests/planning/xerGroundTruth.ts b/tests/planning/xerGroundTruth.ts index a6b42bbdf..729a3ca69 100644 --- a/tests/planning/xerGroundTruth.ts +++ b/tests/planning/xerGroundTruth.ts @@ -155,10 +155,12 @@ function scanNumberFormat( const defaultCurrency = header[8]?.trim() ?? ''; let table = ''; let fields: string[] = []; - const currencyRows: Map[] = []; - for (const line of lines) { + 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 = []; @@ -173,30 +175,45 @@ function scanNumberFormat( 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(row); + currencyRows.push({ values: row, line: lineIndex + 1 }); } if (currencyRows.length === 0) return { decimal: '.', group: null, fromCurrencyTable: false }; const normalizedCurrency = defaultCurrency.toLowerCase(); - const row = normalizedCurrency - ? currencyRows.find(candidate => - candidate.get('curr_short_name')?.trim().toLowerCase() === normalizedCurrency) - : undefined; - if (!row) { + 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 currency = row.get('curr_short_name')?.trim() || defaultCurrency; - const decimal = inspectNumberFamily(row, 'decimal', currency, errors); - const group = inspectNumberFamily(row, 'group', currency, errors); - if (decimal === null) return { decimal: '.', group: null, fromCurrencyTable: true }; - if (group === decimal) { - errors.push('CURRTYPE: decimaal- en groepsteken zijn gelijk'); - return { decimal, group: null, fromCurrencyTable: true }; + 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, group, fromCurrencyTable: true }; + return { decimal: first.decimal, group: first.group, fromCurrencyTable: true }; } function escapeRegExp(value: string): string { @@ -336,6 +353,7 @@ export function scanXerGroundTruth(bytes: Uint8Array): XerGroundTruth { 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; From ba3c58b0aa88e46e22581b02358a24c15263973b Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:20:12 +0200 Subject: [PATCH 014/171] fix: sluit laatste XER-grammaticagrenzen --- src/services/xer/xerTables.ts | 43 ++++- tests/planning/check-xer-tables.ts | 281 ++++++++++++++++++++++++++++- 2 files changed, 316 insertions(+), 8 deletions(-) diff --git a/src/services/xer/xerTables.ts b/src/services/xer/xerTables.ts index 6fe28de5a..1a07133d8 100644 --- a/src/services/xer/xerTables.ts +++ b/src/services/xer/xerTables.ts @@ -18,6 +18,7 @@ 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' @@ -27,6 +28,7 @@ export type XerImportErrorCode = export class XerImportError extends Error { readonly xerCode: XerImportErrorCode; readonly table?: string; + readonly field?: string; readonly missingColumns?: string[]; readonly missingValues?: string[]; readonly line?: number; @@ -38,6 +40,7 @@ export class XerImportError extends Error { message: string, context?: { table?: string; + field?: string; missingColumns?: string[]; missingValues?: string[]; line?: number; @@ -49,6 +52,7 @@ export class XerImportError extends Error { 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; @@ -396,10 +400,18 @@ const XER_DECIMAL_FIELDS = new Set([ // 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', 'seq_num', 'target_rate', + 'plan_cost', 'remain_lag_drtn_hr_cnt', 'remain_value', 'target_rate', ]); -function hasProvableCommaDecimals(tables: ReadonlyMap): boolean { +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})+$/; @@ -409,11 +421,13 @@ function hasProvableCommaDecimals(tables: ReadonlyMap): boolea if (!XER_DECIMAL_FIELDS.has(field)) continue; const value = row.cells[field]?.trim() ?? ''; if ((plainCommaDecimal.test(value) || groupedCommaDecimal.test(value)) - && !commaGroupedInteger.test(value)) return true; + && !commaGroupedInteger.test(value)) { + return { table: table.name, field, line: row.line }; + } } } } - return false; + return null; } /** Parse uitsluitend de oorspronkelijke bestandsbytes; een stringingang is ook runtime ongeldig. */ @@ -429,6 +443,7 @@ export function parseXerTables(bytes: XerByteInput): XerTables { 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( @@ -447,6 +462,20 @@ export function parseXerTables(bytes: XerByteInput): XerTables { 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 { @@ -532,10 +561,14 @@ export function parseXerTables(bytes: XerByteInput): XerTables { defaultCurrencyCode: headerCells[8] ?? '', }; const numberFormat = determineNumberFormat(tables, header.defaultCurrencyCode, issues); - if (numberFormat.source === 'default' && hasProvableCommaDecimals(tables)) { + const commaDecimalEvidence = numberFormat.source === 'default' + ? findProvableCommaDecimal(tables) + : null; + if (commaDecimalEvidence) { throw new XerImportError( 'XER_AMBIGUOUS_DECIMAL', 'XER-bestand gebruikt aantoonbaar komma-decimalen maar bevat geen bruikbare CURRTYPE-tabel.', + commaDecimalEvidence, ); } diff --git a/tests/planning/check-xer-tables.ts b/tests/planning/check-xer-tables.ts index ecd4760b6..ffefcdea1 100644 --- a/tests/planning/check-xer-tables.ts +++ b/tests/planning/check-xer-tables.ts @@ -67,6 +67,7 @@ function caughtXerError(run: () => unknown): Record | null { name?: string; xerCode?: string; table?: string; + field?: string; missingColumns?: string[]; missingValues?: string[]; line?: number; @@ -77,6 +78,7 @@ function caughtXerError(run: () => unknown): Record | null { 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 } : {}), @@ -324,6 +326,9 @@ 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 { @@ -645,6 +650,9 @@ eq('22 komma-decimaalbewijs gebruikt veldcatalogus en onbeperkte precisie', { ambiguous: Array.from({ length: 3 }, () => ({ name: 'XerImportError', xerCode: 'XER_AMBIGUOUS_DECIMAL', + table: 'TASK', + field: 'target_drtn_hr_cnt', + line: 4, })), groupedRows: 3, numericLookingText: null, @@ -795,12 +803,279 @@ eq('26 gezaghebbende numerieke P6-veldmatrix draagt de komma-decimaalheuristiek }, { authoritativeCount: 72, uncoveredAuthoritativeFields: [], - representativeDecimalFields: Array.from({ length: 9 }, () => ({ - name: 'XerImportError', xerCode: 'XER_AMBIGUOUS_DECIMAL', - })), + 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, + }, +}); + if (diffs.length === 0) { console.log(`OK xer-tables: ${checks} checks groen`); } else { From df2676b9deaea82a7e6af6dc3bb9e99f8346ba27 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:56:44 +0200 Subject: [PATCH 015/171] Implementeer XER-kalenderdata en uurmodus --- src/services/p6/p6xmlReader.ts | 2 +- src/services/xer/xerCalendarData.ts | 365 ++++++++++++++++++ tests/planning/check-xer-calendar-data.ts | 290 ++++++++++++++ tests/planning/run.sh | 5 + .../xer-calendar-hour-mode-baseline.json | 68 ++++ 5 files changed, 729 insertions(+), 1 deletion(-) create mode 100644 src/services/xer/xerCalendarData.ts create mode 100644 tests/planning/check-xer-calendar-data.ts create mode 100644 tests/planning/xer-calendar-hour-mode-baseline.json diff --git a/src/services/p6/p6xmlReader.ts b/src/services/p6/p6xmlReader.ts index a163ef490..5b0e0dc92 100644 --- a/src/services/p6/p6xmlReader.ts +++ b/src/services/p6/p6xmlReader.ts @@ -93,7 +93,7 @@ function p6CodeToConstraint(code: string): { type: ConstraintType; hard?: boolea * 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>; } { diff --git a/src/services/xer/xerCalendarData.ts b/src/services/xer/xerCalendarData.ts new file mode 100644 index 000000000..fdb20df33 --- /dev/null +++ b/src/services/xer/xerCalendarData.ts @@ -0,0 +1,365 @@ +/** + * 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 { + number: string; + name: string; + fields: Record; + children: XerStructuredRecord[]; +} + +export interface DecodedXerCalendarData { + bands: WorkTimeBands; + holidays: Holiday[]; + workingExceptions: WorkingException[]; + hasExplicitClockBands: boolean; + deviates: boolean; +} + +export type XerCalendarType = 'GLOBAL' | 'PROJECT' | 'RESOURCE' | 'UNKNOWN'; + +export interface XerCalendar extends WorkCalendar { + calendarType: XerCalendarType; + rawCalendarType: string; + hoursPerWeek: number; + hoursPerMonth: number; + hoursPerYear: number; + baseCalendarId?: string; + baseCalendar?: XerCalendar; + hourModeSource?: 'SHARED' | 'XER_CLOCK'; +} + +export interface XerCalendarReadResult { + calendars: XerCalendar[]; + byId: Map; + 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; +} + +function parseXerClock(value: string): number | null { + const match = value.trim().match(/^(\d{1,2}):(\d{2})(?::\d{2})?\s*(AM|PM)?$/i); + if (!match) return null; + let hour = Number(match[1]); + const minute = Number(match[2]); + const meridiem = match[3]?.toUpperCase(); + if (minute > 59) return null; + if (meridiem) { + if (hour < 1 || hour > 12) return null; + hour = hour % 12 + (meridiem === 'PM' ? 12 : 0); + } else if (hour > 24 || (hour === 24 && minute !== 0)) { + return null; + } + 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 ?? ''); + const end = parseXerClock(record.fields.f ?? ''); + if (start === null || end === null) continue; + result.push({ start, end }); + } + return result; +} + +function dateFromP6Epoch(raw: string): string | null { + if (!/^-?\d+$/.test(raw.trim())) return null; + const date = new Date(Date.UTC(1899, 11, 30)); + date.setUTCDate(date.getUTCDate() + Number(raw)); + return date.toISOString().slice(0, 10); +} + +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; + const value = parseXerNumber(raw, tables.numberFormat); + return value === null || value < 0 ? undefined : value; +} + +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 }; +} + +class StructuredTextTokenizer { + private index = 0; + + constructor(private readonly text: string) {} + + parse(): XerStructuredRecord { + this.skipFormatting(); + const record = this.readRecord(); + this.skipFormatting(); + if (this.index !== this.text.length) { + throw new Error('Ongeldige XER-kalenderdata: onverwachte tekst na het hoofdrecord.'); + } + return record; + } + + private readRecord(): XerStructuredRecord { + this.expect('('); + const number = this.readWhile(char => /\d/.test(char)); + if (!number) throw new Error('Ongeldige XER-kalenderdata: recordnummer ontbreekt.'); + this.expect('||'); + const name = this.readUntil('('); + this.expect('('); + const fields = this.readFields(); + this.expect(')'); + this.skipFormatting(); + this.expect('('); + const children: XerStructuredRecord[] = []; + this.skipFormatting(); + while (this.peek() === '(') { + children.push(this.readRecord()); + this.skipFormatting(); + } + this.expect(')'); + this.expect(')'); + return { number, name, fields, children }; + } + + 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('|'); + } + for (let index = 0; index + 1 < tokens.length; index += 2) { + result[tokens[index]] = 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(); +} + +/** 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: [], + hasExplicitClockBands: false, + deviates: false, + }; + } + + const root = parseXerStructuredText(text); + const days = root.children.find(record => record.name === 'DaysOfWeek'); + const raw: Partial> = {}; + let hasExplicitClockBands = false; + for (const day of days?.children ?? []) { + const p6Day = Number(day.name); + if (!WEEKDAYS.includes(p6Day as (typeof WEEKDAYS)[number])) continue; + const dayBands = bandsFromRecords(day.children); + if (dayBands.length > 0) hasExplicitClockBands = true; + raw[p6DayToIso(p6Day)] = dayBands; + } + const canonical = canonicalizeBands(raw); + let bands = canonical.bands; + if (!hasExplicitClockBands) bands = defaultXerBands(); + + const holidays: Holiday[] = []; + const workingExceptions: WorkingException[] = []; + const exceptions = root.children.find(record => record.name === 'Exceptions'); + for (const exception of exceptions?.children ?? []) { + const date = dateFromP6Epoch(exception.fields.d ?? ''); + if (!date) continue; + const exceptionBands = canonicalizeBands({ 1: bandsFromRecords(exception.children) }).bands.byWeekday[1]; + if (exceptionBands.length > 0) { + workingExceptions.push({ + name: 'Kalenderuitzondering', startDate: date, endDate: date, bands: exceptionBands, + }); + } else { + holidays.push({ name: 'Kalenderuitzondering', startDate: date, endDate: date }); + } + } + + return { + bands, + holidays, + workingExceptions, + hasExplicitClockBands, + deviates: hasExplicitClockBands && canonical.deviates, + }; +} + +/** 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[] = rows.map(row => { + const decoded = decodeXerCalendarData(row.cells.clndr_data ?? ''); + const actual = actualWeekHours(decoded.bands); + const explicitDay = periodHours(row, 'day_hr_cnt', tables); + const explicitWeek = periodHours(row, 'week_hr_cnt', tables); + const explicitMonth = periodHours(row, 'month_hr_cnt', tables); + const explicitYear = periodHours(row, 'year_hr_cnt', tables); + 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 } + : {}), + 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; + } + return calendar; + }); + + const byId = new Map(calendars.map(calendar => [calendar.id, calendar])); + for (const calendar of calendars) { + const base = calendar.baseCalendarId ? byId.get(calendar.baseCalendarId) : undefined; + if (base && base !== calendar) calendar.baseCalendar = base; + } + + return { + calendars, + byId, + 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/tests/planning/check-xer-calendar-data.ts b/tests/planning/check-xer-calendar-data.ts new file mode 100644 index 000000000..4cc700b72 --- /dev/null +++ b/tests/planning/check-xer-calendar-data.ts @@ -0,0 +1,290 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + decodeXerCalendarData, + parseXerStructuredText, + readXerCalendars, +} 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 actualDayHours(decodedCalendar: ReturnType): number { + const dayHours = Object.values(decodedCalendar.bands.byWeekday) + .filter(dayBands => dayBands.length > 0) + .map(dayBands => dayBands.reduce((sum, band) => sum + band.end - band.start, 0) / 60); + return dayHours.reduce((sum, hours) => sum + hours, 0) / dayHours.length; +} + +// 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, { + number: '0', + name: 'CalendarData', + fields: { kind: 'root' }, + children: [{ + number: '1', + name: 'DaysOfWeek', + fields: {}, + children: [{ + number: '2', + name: '2', + fields: {}, + children: [{ + number: '3', + name: '0', + fields: { s: '08:00', f: '16:00' }, + children: [], + }], + }], + }], +}); + +// 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: 'Kalenderuitzondering', startDate: '1899-12-30', endDate: '1899-12-30', +}]); +eq('4 uitzondering met AM/PM-uren is een werkende uitzondering', decoded.workingExceptions, [{ + name: 'Kalenderuitzondering', 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 in plaats van P6's +// gedocumenteerde terugval ma-vr 08:00-16:00. +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'); + +// 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('13 uurmodus-blast-radius is expliciet meetbaar vóór en na XER-regel c', tableCalendars.promotion, { + sharedRules: 1, + withXerClockRule: 2, +}); +eq('14 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('15 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('16 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 p6Week = canonicalizeBands(parseP6StandardWorkWeek(p6Calendar).rawByWeekday).bands; +eq('17 equivalente XER- en P6XML-werkweken leveren dezelfde canonieke banden', decoded.bands, p6Week); + +// Openbare corpuspins worden uitsluitend met inhoudshashes aangeduid: geen lokale paden, namen of +// veldinhoud in broncode of foutmeldingen. Zonder corpus blijft de corpusloze gedragsset groen. +const corpusRoot = process.env.OPS_XER_CORPUS; +if (!corpusRoot) { + console.log('OK xer-calendar-data-corpus: openbare bron niet ingesteld — corpuspins overgeslagen'); +} else if (!existsSync(corpusRoot)) { + diffs.push('OPS_XER_CORPUS wijst niet naar een bestaande map'); +} else { + const manifest = JSON.parse( + readFileSync(join(import.meta.dirname, 'xer-corpus-manifest.json'), 'utf8'), + ) as { files: Record }; + const observed: Record = {}; + for (const [relativePath, entry] of Object.entries(manifest.files)) { + if (observed[entry.sha256]) continue; + let tables; + try { + tables = parseXerTables(new Uint8Array(readFileSync(join(corpusRoot, relativePath)))); + } catch { + continue; + } + const calendarTable = tables.tables.get('CALENDAR'); + const calendarCount = calendarTable?.rows.length ?? 0; + if (calendarCount === 0) continue; + const validRows = calendarTable!.rows.filter(row => { + try { + decodeXerCalendarData(row.cells.clndr_data ?? ''); + return true; + } catch { + return false; + } + }); + const scopedTables = new Map(tables.tables); + scopedTables.set('CALENDAR', { ...calendarTable!, rows: validRows }); + const result = readXerCalendars({ ...tables, tables: scopedTables }); + observed[entry.sha256] = { + calendars: calendarCount, + decoded: validRows.length, + malformed: calendarCount - validRows.length, + ...result.promotion, + }; + } + const promotionBaseline = JSON.parse( + readFileSync(join(import.meta.dirname, 'xer-calendar-hour-mode-baseline.json'), 'utf8'), + ) as { pins: Record }; + const groupedPins: Record = {}; + for (const [hash, pin] of Object.entries(observed)) { + const key = [ + pin.calendars, + pin.decoded, + pin.malformed, + pin.sharedRules, + pin.withXerClockRule, + ].join('/'); + (groupedPins[key] ??= []).push(hash); + } + for (const hashes of Object.values(groupedPins)) hashes.sort(); + eq( + '18 uurmoduspromoties zijn per openbaar bestand vóór en na XER-regel c gepind', + groupedPins, + promotionBaseline.pins, + ); + + const dossierHash = '2c1dce175b9f078111a48dc13fd1777f5fbd4cd7ab6623e647e7437330c60b7f'; + const dossierPath = Object.entries(manifest.files) + .find(([, entry]) => entry.sha256 === dossierHash)?.[0]; + let dossierPin: unknown = { hashFound: false }; + if (dossierPath) { + const dossierTables = parseXerTables(new Uint8Array(readFileSync(join(corpusRoot, dossierPath)))); + const rows = dossierTables.tables.get('CALENDAR')?.rows ?? []; + const result = readXerCalendars(dossierTables); + dossierPin = { + calendars: rows.length, + dayHourFieldsPresent: rows.filter( + row => Object.prototype.hasOwnProperty.call(row.cells, 'day_hr_cnt'), + ).length, + derivedWeekHours: rows.filter((row, index) => { + const decodedRow = decodeXerCalendarData(row.cells.clndr_data ?? ''); + const expected = Object.values(decodedRow.bands.byWeekday) + .flat() + .reduce((sum, band) => sum + band.end - band.start, 0) / 60; + return Math.abs(result.calendars[index].hoursPerWeek - expected) < 1e-9; + }).length, + derivedFromActualWeek: rows.filter((row, index) => { + const expected = actualDayHours(decodeXerCalendarData(row.cells.clndr_data ?? '')); + return Math.abs(result.calendars[index].hoursPerDay - expected) < 1e-9; + }).length, + }; + } + eq('19 inhoudshash-dossier leidt alle ontbrekende daguren uit echte weekbanden af', dossierPin, { + calendars: 124, + dayHourFieldsPresent: 0, + derivedWeekHours: 124, + derivedFromActualWeek: 124, + }); +} + +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/run.sh b/tests/planning/run.sh index a9b2bf959..728dc1452 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -225,6 +225,11 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then XERTABLESCHECK="$DIR/.xer-tables.mjs" if bundle_check "$DIR/check-xer-tables.ts" "$XERTABLESCHECK"; then node "$XERTABLESCHECK" || STATUS=1; fi + # X3-kalenderdecoder: eigen structured-text-tokenizer, weekbanden/uitzonderingen, + # CALENDAR-hiërarchie, uren-per-periode 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 + # 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" 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..f00853dc0 --- /dev/null +++ b/tests/planning/xer-calendar-hour-mode-baseline.json @@ -0,0 +1,68 @@ +{ + "version": 1, + "key": "calendarRows/decodedRows/malformedRows/sharedRules/withXerClockRule", + "pins": { + "3/3/0/3/3": [ + "ad92ce52ba95b27539a4c0ba0c44e2a6b755ec2c15c3066009694c7430846684", + "df264eb25ca8dc0e6e29d5ba5ba4a4e370f9126aecb99ab48e9fd4383f0287b7" + ], + "1/1/0/0/0": [ + "0fa5172162fbd6895f88042d0277a8fbc46c9c82badbcbae333d3423dd208c86", + "1ba8cf1885491db3c19ebffc85fbd33935345ef9effb386a8da33226e665f9ab", + "1d7901d68cc7063b59dcedacc3eeae8d2c89178516421c09ae38ff523429e3af", + "78325fb6445d22f5949bf05c1e0afe3e14b72efc9cc9480800eab4bf2b0a1056", + "790b90fda837bad342658bf2c712cf6c39ab6adcd812e67edf93cda1c7eb362f", + "79f6fb43bb391ed609f164e6dc5d38d329b634467bc6a7b06cbf3d8a351c8078", + "98f079b267688de209d20d8156026111f7a045cb67f3457e277a3d29a4fd9c04", + "9f2964425c0e295bfc3a62ba0c860d9be5a6167a5494427007a42ecce5df6cc6", + "a132f8c7ebb070d29936b8852c6dca772ebb547c0ade91c17c96425e37e63818", + "a7fc367eab6c904e6659a8206b9a162df132824c630e6175665c8a63f054104b", + "ac4b9d677133c6edcd6129050f32233e77c9d62ef06c7caac0dbf1da0d1e43ab", + "b72a879f6282cc6193bb0d13e2671843cafbb1d1edb5546ec7a99f4744037262", + "bd3aefb87cfa0532de36b3972bd7dc4a5c7eb6f156b1b8e4af90e43ebf479ea4", + "c1ccf20436ba38b5846d0ac6b104551d97281093effaa40a9eb6222728fb8ee3", + "deabc76347eaef15727f430951a8a1b957dd5ab0251dc4500e15c817a8e030b8", + "def489b2a803af9f939b964b80e8fd9b2dc8143df280ad2a3088d91d4d6cdac3", + "fc74bdab8629d9e81e5c7e58900ea4632b4525f63cca9fa546d4f17605936de6" + ], + "32/32/0/1/32": ["2bc12241c3f8ee5b7472dd0e77f2cbffafcf3b5438b17022fd9db4f4c642d4b0"], + "1/1/0/0/1": [ + "1d7bd29d2c157bdd9abad5723f84edab1f8cd925cdf3003aff181b597431a948", + "68ce5f0bb2b534d5ba192e3ad8241025d74ae45e94209f11731d42d32f18f82b", + "71e56568075852d00507ff5506d5c055912ce0ba0982b97b8ace6269f794d10a", + "9590c4cdc4efa69a231b66ac509611d17f31605599ee7abde02a197f5e8b1e6e" + ], + "3/3/0/1/3": ["b9547eb91c30af1750933a64409d8b2a4a2c1dbe4c0be276a04ab150c8a50167"], + "11/11/0/7/11": [ + "9679599df9108bd3b31e755351c1543d1915b82a91445a3f1b0b9c9a3d1f86fe", + "d2c413088afed805b30b7628c91be8aca870f7d4daf00eb12a547d69913d8352" + ], + "8/0/8/0/0": ["2a7732b5b99de2a5f40aab40e0af52757fbe68e5d4eda404a9a060fdc1be1140"], + "2/2/0/0/0": [ + "146e4c8659bad066b298988613dec178ce63f3a2c35415937b3d70a8f8ef8158", + "467ab0eb0885c4a78f7101abd512bf34a6fe6e85770b49d1bee855633a4d4a73", + "53f5cdbeb3fbd9c6cb252a48c2ad9206f1f2ab44215372f83199df3a90803ffd", + "568c19375b4e0d674c75e6aea023c98772fb33e6896755f866e4641a56197300", + "97058f720a70f62399c92f7f84ffb4a29b936baf95c6a2e339e3cc4a222f318c", + "c872c9e704797d829205f3c5486e7c4cd5aec729143a63fbcf8b0ec8e8864a3c" + ], + "6/6/0/2/6": ["a2ef7b35c00d8cf8ba75230d71d69b96130f7f689495e86a4cf5f8fcd34431ae"], + "2/2/0/1/2": ["55b7e4463dcd36ba02eae072445e8fc2cc684a188e40c588b05321963a362c5e"], + "1/1/0/1/1": [ + "46f3c1c68552429cf32191ebc8f48d12f52e517481b6fb588ad1955937c5cd1d", + "7298574bb32ac3068b07b427a35cd2834308d2fcb0e2ac74fea4e013e738c62e", + "9b91bf5bfcc0df4f6a2fec8867a923af7758058c224a9772a728e8d2ba0f0fc5", + "ab420cc573be248ef7ef344f77b204176c3263fceb45c27866276929bf1e59e2", + "b7ef6d85acfedda1a8d3a77391592587d3ca3088013b896d03fd129b23d83834", + "e48eb3a53b7e8f40a5a87af8e983e9932c298baeb572e3a901b3961f997674ef" + ], + "124/124/0/122/124": ["2c1dce175b9f078111a48dc13fd1777f5fbd4cd7ab6623e647e7437330c60b7f"], + "2/2/0/1/1": ["4d8bce790a93b9bc747abec5d99840e3e0d1ce5523e0afae3ba881762cfe2c13"], + "3/0/3/0/0": [ + "0611f9054a4b566340dd53181b96d66907f4ac8faa0ef1395ec338be9f83b96d", + "816e01738f4967872980ce3fed0c2bfd8c09f78197b7de1beea5ca98e094aeae" + ], + "3/2/1/0/2": ["85f8c7d681b15daf755ba1d568eb09ebcdd529a16c20045e6db6c2937899f54b"], + "4/4/0/2/4": ["a2f3b2469e26f19985ac801786c90edfb965a7bc194a6e57704aa845c5f8611f"] + } +} From bd05f121b8aaa04dd051fc55ee8a90cfeae6721f Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:07:01 +0200 Subject: [PATCH 016/171] Herstel robuuste XER-kalenderdecodering --- src/services/xer/xerCalendarData.ts | 429 ++++++++++++++++-- tests/planning/check-xer-calendar-corpus.ts | 238 ++++++++++ tests/planning/check-xer-calendar-data.ts | 291 +++++++----- tests/planning/run.sh | 13 +- .../xer-calendar-hour-mode-baseline.json | 49 +- 5 files changed, 838 insertions(+), 182 deletions(-) create mode 100644 tests/planning/check-xer-calendar-corpus.ts diff --git a/src/services/xer/xerCalendarData.ts b/src/services/xer/xerCalendarData.ts index fdb20df33..02f014add 100644 --- a/src/services/xer/xerCalendarData.ts +++ b/src/services/xer/xerCalendarData.ts @@ -33,6 +33,36 @@ export interface DecodedXerCalendarData { workingExceptions: WorkingException[]; hasExplicitClockBands: boolean; deviates: boolean; + recoveries: XerCalendarRecovery[]; +} + +export type XerCalendarRecovery = 'COMPACT_RECORD' | 'SURPLUS_CLOSE' | 'DUPLICATE_EXCEPTION'; + +export type XerCalendarIssueCode = + | 'XER_CALENDAR_COMPACT_RECORD_RECOVERED' + | 'XER_CALENDAR_SURPLUS_CLOSE_RECOVERED' + | 'XER_CALENDAR_INVALID_STRUCTURE' + | '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_DUPLICATE_EXCEPTION' + | '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'; @@ -51,6 +81,7 @@ export interface XerCalendar extends WorkCalendar { export interface XerCalendarReadResult { calendars: XerCalendar[]; byId: Map; + issues: XerCalendarIssue[]; encoding: XerEncoding; report: XerImportReport; promotion: { @@ -75,18 +106,50 @@ 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; } -function parseXerClock(value: string): number | null { - const match = value.trim().match(/^(\d{1,2}):(\d{2})(?::\d{2})?\s*(AM|PM)?$/i); - if (!match) return null; +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 meridiem = match[3]?.toUpperCase(); - if (minute > 59) return null; + 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) return null; + 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)) { - return null; + } 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; } @@ -94,18 +157,48 @@ function parseXerClock(value: string): number | null { 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 ?? ''); - const end = parseXerClock(record.fields.f ?? ''); - if (start === null || end === null) continue; + const start = parseXerClock(record.fields.s ?? '', 'start'); + const end = parseXerClock(record.fields.f ?? '', 'finish'); result.push({ start, end }); } return result; } -function dateFromP6Epoch(raw: string): string | null { - if (!/^-?\d+$/.test(raw.trim())) return null; +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() + Number(raw)); + 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); } @@ -121,8 +214,16 @@ function calendarType(raw: string): XerCalendarType { function periodHours(row: XerRow, field: string, tables: XerTables): number | undefined { const raw = row.cells[field]?.trim() ?? ''; if (!raw) return undefined; - const value = parseXerNumber(raw, tables.numberFormat); - return value === null || value < 0 ? undefined : value; + 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 } { @@ -151,8 +252,12 @@ function scalarBounds(bands: WorkTimeBands): { start: number; end: number } { class StructuredTextTokenizer { private index = 0; + usedCompactRecord = false; - constructor(private readonly text: string) {} + constructor( + private readonly text: string, + private readonly allowCompactRecords = false, + ) {} parse(): XerStructuredRecord { this.skipFormatting(); @@ -166,9 +271,33 @@ class StructuredTextTokenizer { private readRecord(): XerStructuredRecord { this.expect('('); + if (this.allowCompactRecords && this.text.startsWith('s|', this.index)) { + this.usedCompactRecord = true; + this.expect('s|'); + const start = this.readUntil('|'); + this.expect('|f|'); + const finish = this.readUntil(')'); + this.expect(')'); + return { number: '', name: '0', 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)) { + this.usedCompactRecord = true; + this.expect('d|'); + const epoch = this.readUntil('('); + this.expect('('); + const children: XerStructuredRecord[] = []; + this.skipFormatting(); + while (this.peek() === '(') { + children.push(this.readRecord()); + this.skipFormatting(); + } + this.expect(')'); + this.expect(')'); + return { number, name: number, fields: { d: epoch }, children }; + } const name = this.readUntil('('); this.expect('('); const fields = this.readFields(); @@ -195,8 +324,27 @@ class StructuredTextTokenizer { 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) { - result[tokens[index]] = tokens[index + 1]; + 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; } @@ -241,6 +389,39 @@ 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()) { @@ -250,59 +431,154 @@ export function decodeXerCalendarData(text: string): DecodedXerCalendarData { workingExceptions: [], hasExplicitClockBands: false, deviates: false, + recoveries: [], }; } - const root = parseXerStructuredText(text); - const days = root.children.find(record => record.name === 'DaysOfWeek'); + 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; - for (const day of days?.children ?? []) { + 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])) continue; + 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); - let bands = canonical.bands; - if (!hasExplicitClockBands) bands = defaultXerBands(); + const bands = canonical.bands; + assertNoBandOverlap(bands); - const holidays: Holiday[] = []; - const workingExceptions: WorkingException[] = []; + const holidaysByDate = new Map(); + const workingByDate = new Map(); + let duplicateException = false; const exceptions = root.children.find(record => record.name === 'Exceptions'); for (const exception of exceptions?.children ?? []) { const date = dateFromP6Epoch(exception.fields.d ?? ''); - if (!date) continue; - const exceptionBands = canonicalizeBands({ 1: bandsFromRecords(exception.children) }).bands.byWeekday[1]; + const canonicalException = canonicalizeBands({ 1: bandsFromRecords(exception.children) }).bands; + assertNoBandOverlap(canonicalException); + const exceptionBands = canonicalException.byWeekday[1]; + if (holidaysByDate.has(date) || workingByDate.has(date)) duplicateException = true; if (exceptionBands.length > 0) { - workingExceptions.push({ - name: 'Kalenderuitzondering', startDate: date, endDate: date, bands: exceptionBands, - }); - } else { - holidays.push({ name: 'Kalenderuitzondering', startDate: date, endDate: date }); + holidaysByDate.delete(date); + if (!workingByDate.has(date)) { + workingByDate.set(date, { + name: 'Kalenderuitzondering', startDate: date, endDate: date, bands: exceptionBands, + }); + } + } else if (!workingByDate.has(date) && !holidaysByDate.has(date)) { + holidaysByDate.set(date, { name: 'Kalenderuitzondering', startDate: date, endDate: date }); } } + if (duplicateException) recoveries.push('DUPLICATE_EXCEPTION'); return { bands, - holidays, - workingExceptions, + holidays: Array.from(holidaysByDate.values()), + workingExceptions: Array.from(workingByDate.values()), hasExplicitClockBands, deviates: hasExplicitClockBands && canonical.deviates, + recoveries, + }; +} + +function cyclicCalendarIds( + calendars: readonly XerCalendar[], + byId: ReadonlyMap, +): Set { + const state = new Map(); + const path: string[] = []; + const cycles = new Set(); + const visit = (calendar: XerCalendar): void => { + const currentState = state.get(calendar.id); + if (currentState === 'DONE') return; + if (currentState === 'VISITING') { + const cycleStart = path.lastIndexOf(calendar.id); + for (const id of path.slice(cycleStart)) cycles.add(id); + return; + } + state.set(calendar.id, 'VISITING'); + path.push(calendar.id); + const baseId = calendar.baseCalendarId; + const base = baseId && baseId !== calendar.id ? byId.get(baseId) : undefined; + if (base) visit(base); + path.pop(); + state.set(calendar.id, 'DONE'); }; + for (const calendar of calendars) visit(calendar); + return cycles; } /** 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[] = rows.map(row => { - const decoded = decodeXerCalendarData(row.cells.clndr_data ?? ''); + 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); - const explicitDay = periodHours(row, 'day_hr_cnt', tables); - const explicitWeek = periodHours(row, 'week_hr_cnt', tables); - const explicitMonth = periodHours(row, 'month_hr_cnt', tables); - const explicitYear = periodHours(row, 'year_hr_cnt', tables); + 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); @@ -343,18 +619,81 @@ export function readXerCalendars(tables: XerTables): XerCalendarReadResult { // Periodevelden in XER zijn leidend; promotie mag alleen de uurweergave toevoegen. calendar.hoursPerDay = hoursPerDay; } - return calendar; - }); + 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', + }); + } + } const byId = new Map(calendars.map(calendar => [calendar.id, calendar])); + const cycleIds = cyclicCalendarIds(calendars, byId); for (const calendar of calendars) { - const base = calendar.baseCalendarId ? byId.get(calendar.baseCalendarId) : undefined; - if (base && base !== calendar) calendar.baseCalendar = base; + 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; + } + calendar.baseCalendar = base; } return { calendars, byId, + issues, encoding: tables.report.encoding, report: tables.report, promotion: { 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 index 4cc700b72..57835fd11 100644 --- a/tests/planning/check-xer-calendar-data.ts +++ b/tests/planning/check-xer-calendar-data.ts @@ -1,5 +1,3 @@ -import { existsSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; import { decodeXerCalendarData, parseXerStructuredText, @@ -24,13 +22,6 @@ function utf8(lines: readonly string[]): Uint8Array { return new TextEncoder().encode(lines.join('\n')); } -function actualDayHours(decodedCalendar: ReturnType): number { - const dayHours = Object.values(decodedCalendar.bands.byWeekday) - .filter(dayBands => dayBands.length > 0) - .map(dayBands => dayBands.reduce((sum, band) => sum + band.end - band.start, 0) / 60); - return dayHours.reduce((sum, hours) => sum + hours, 0) / dayHours.length; -} - // 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( @@ -58,6 +49,18 @@ eq('1 eigen tokenizer bewaart nummer, naam, velden en geneste kinderen', nested, }], }); +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( @@ -134,28 +137,179 @@ eq('11 ontbrekende uren-per-periode komen uit 12 echte weekuren over 2 werkdagen }, { 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: 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: 'Kalenderuitzondering', + 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 }, +]); + +// 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('15e 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('15f 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('15g kalenderresultaat blijft serialiseerbaar zonder cyclische objectgraaf', + baseGraphSerializable, 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('13 uurmodus-blast-radius is expliciet meetbaar vóór en na XER-regel c', tableCalendars.promotion, { +eq('16 uurmodus-blast-radius is expliciet meetbaar vóór en na XER-regel c', tableCalendars.promotion, { sharedRules: 1, withXerClockRule: 2, }); -eq('14 gewone expliciete XER-klokband promoveert door bronregel c', { +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('15 volledige dag blijft door de gedeelde b2-regel promoveren', { +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('16 lege default zonder bronklokken blijft dagmodus', { +eq('19 lege default zonder bronklokken blijft dagmodus', { source: child?.hourModeSource, workTime: child?.workTime, }, {}); @@ -175,110 +329,13 @@ const p6Calendar = new DOMParser().parseFromString( + '', 'application/xml', ).documentElement; -const p6Week = canonicalizeBands(parseP6StandardWorkWeek(p6Calendar).rawByWeekday).bands; -eq('17 equivalente XER- en P6XML-werkweken leveren dezelfde canonieke banden', decoded.bands, p6Week); - -// Openbare corpuspins worden uitsluitend met inhoudshashes aangeduid: geen lokale paden, namen of -// veldinhoud in broncode of foutmeldingen. Zonder corpus blijft de corpusloze gedragsset groen. -const corpusRoot = process.env.OPS_XER_CORPUS; -if (!corpusRoot) { - console.log('OK xer-calendar-data-corpus: openbare bron niet ingesteld — corpuspins overgeslagen'); -} else if (!existsSync(corpusRoot)) { - diffs.push('OPS_XER_CORPUS wijst niet naar een bestaande map'); -} else { - const manifest = JSON.parse( - readFileSync(join(import.meta.dirname, 'xer-corpus-manifest.json'), 'utf8'), - ) as { files: Record }; - const observed: Record = {}; - for (const [relativePath, entry] of Object.entries(manifest.files)) { - if (observed[entry.sha256]) continue; - let tables; - try { - tables = parseXerTables(new Uint8Array(readFileSync(join(corpusRoot, relativePath)))); - } catch { - continue; - } - const calendarTable = tables.tables.get('CALENDAR'); - const calendarCount = calendarTable?.rows.length ?? 0; - if (calendarCount === 0) continue; - const validRows = calendarTable!.rows.filter(row => { - try { - decodeXerCalendarData(row.cells.clndr_data ?? ''); - return true; - } catch { - return false; - } - }); - const scopedTables = new Map(tables.tables); - scopedTables.set('CALENDAR', { ...calendarTable!, rows: validRows }); - const result = readXerCalendars({ ...tables, tables: scopedTables }); - observed[entry.sha256] = { - calendars: calendarCount, - decoded: validRows.length, - malformed: calendarCount - validRows.length, - ...result.promotion, - }; - } - const promotionBaseline = JSON.parse( - readFileSync(join(import.meta.dirname, 'xer-calendar-hour-mode-baseline.json'), 'utf8'), - ) as { pins: Record }; - const groupedPins: Record = {}; - for (const [hash, pin] of Object.entries(observed)) { - const key = [ - pin.calendars, - pin.decoded, - pin.malformed, - pin.sharedRules, - pin.withXerClockRule, - ].join('/'); - (groupedPins[key] ??= []).push(hash); - } - for (const hashes of Object.values(groupedPins)) hashes.sort(); - eq( - '18 uurmoduspromoties zijn per openbaar bestand vóór en na XER-regel c gepind', - groupedPins, - promotionBaseline.pins, - ); - - const dossierHash = '2c1dce175b9f078111a48dc13fd1777f5fbd4cd7ab6623e647e7437330c60b7f'; - const dossierPath = Object.entries(manifest.files) - .find(([, entry]) => entry.sha256 === dossierHash)?.[0]; - let dossierPin: unknown = { hashFound: false }; - if (dossierPath) { - const dossierTables = parseXerTables(new Uint8Array(readFileSync(join(corpusRoot, dossierPath)))); - const rows = dossierTables.tables.get('CALENDAR')?.rows ?? []; - const result = readXerCalendars(dossierTables); - dossierPin = { - calendars: rows.length, - dayHourFieldsPresent: rows.filter( - row => Object.prototype.hasOwnProperty.call(row.cells, 'day_hr_cnt'), - ).length, - derivedWeekHours: rows.filter((row, index) => { - const decodedRow = decodeXerCalendarData(row.cells.clndr_data ?? ''); - const expected = Object.values(decodedRow.bands.byWeekday) - .flat() - .reduce((sum, band) => sum + band.end - band.start, 0) / 60; - return Math.abs(result.calendars[index].hoursPerWeek - expected) < 1e-9; - }).length, - derivedFromActualWeek: rows.filter((row, index) => { - const expected = actualDayHours(decodeXerCalendarData(row.cells.clndr_data ?? '')); - return Math.abs(result.calendars[index].hoursPerDay - expected) < 1e-9; - }).length, - }; - } - eq('19 inhoudshash-dossier leidt alle ontbrekende daguren uit echte weekbanden af', dossierPin, { - calendars: 124, - dayHourFieldsPresent: 0, - derivedWeekHours: 124, - derivedFromActualWeek: 124, - }); -} +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); if (diffs.length === 0) { console.log(`OK xer-calendar-data: ${checks} checks groen`); diff --git a/tests/planning/run.sh b/tests/planning/run.sh index 728dc1452..fa8e5be20 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -225,11 +225,20 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then XERTABLESCHECK="$DIR/.xer-tables.mjs" if bundle_check "$DIR/check-xer-tables.ts" "$XERTABLESCHECK"; then node "$XERTABLESCHECK" || STATUS=1; fi - # X3-kalenderdecoder: eigen structured-text-tokenizer, weekbanden/uitzonderingen, - # CALENDAR-hiërarchie, uren-per-periode en de XER-eigen uurmodusregel. + # 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 + # 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" diff --git a/tests/planning/xer-calendar-hour-mode-baseline.json b/tests/planning/xer-calendar-hour-mode-baseline.json index f00853dc0..c64424528 100644 --- a/tests/planning/xer-calendar-hour-mode-baseline.json +++ b/tests/planning/xer-calendar-hour-mode-baseline.json @@ -1,12 +1,12 @@ { - "version": 1, - "key": "calendarRows/decodedRows/malformedRows/sharedRules/withXerClockRule", + "version": 2, + "key": "calendarRows/retainedRows/structurallyRecoveredRows/rejectedRows/sharedRules/withXerClockRule", "pins": { - "3/3/0/3/3": [ + "3/3/0/0/3/3": [ "ad92ce52ba95b27539a4c0ba0c44e2a6b755ec2c15c3066009694c7430846684", "df264eb25ca8dc0e6e29d5ba5ba4a4e370f9126aecb99ab48e9fd4383f0287b7" ], - "1/1/0/0/0": [ + "1/1/0/0/0/0": [ "0fa5172162fbd6895f88042d0277a8fbc46c9c82badbcbae333d3423dd208c86", "1ba8cf1885491db3c19ebffc85fbd33935345ef9effb386a8da33226e665f9ab", "1d7901d68cc7063b59dcedacc3eeae8d2c89178516421c09ae38ff523429e3af", @@ -25,20 +25,20 @@ "def489b2a803af9f939b964b80e8fd9b2dc8143df280ad2a3088d91d4d6cdac3", "fc74bdab8629d9e81e5c7e58900ea4632b4525f63cca9fa546d4f17605936de6" ], - "32/32/0/1/32": ["2bc12241c3f8ee5b7472dd0e77f2cbffafcf3b5438b17022fd9db4f4c642d4b0"], - "1/1/0/0/1": [ + "32/32/0/0/1/32": ["2bc12241c3f8ee5b7472dd0e77f2cbffafcf3b5438b17022fd9db4f4c642d4b0"], + "1/1/0/0/0/1": [ "1d7bd29d2c157bdd9abad5723f84edab1f8cd925cdf3003aff181b597431a948", "68ce5f0bb2b534d5ba192e3ad8241025d74ae45e94209f11731d42d32f18f82b", "71e56568075852d00507ff5506d5c055912ce0ba0982b97b8ace6269f794d10a", "9590c4cdc4efa69a231b66ac509611d17f31605599ee7abde02a197f5e8b1e6e" ], - "3/3/0/1/3": ["b9547eb91c30af1750933a64409d8b2a4a2c1dbe4c0be276a04ab150c8a50167"], - "11/11/0/7/11": [ + "3/3/0/0/1/3": ["b9547eb91c30af1750933a64409d8b2a4a2c1dbe4c0be276a04ab150c8a50167"], + "11/11/0/0/7/11": [ "9679599df9108bd3b31e755351c1543d1915b82a91445a3f1b0b9c9a3d1f86fe", "d2c413088afed805b30b7628c91be8aca870f7d4daf00eb12a547d69913d8352" ], - "8/0/8/0/0": ["2a7732b5b99de2a5f40aab40e0af52757fbe68e5d4eda404a9a060fdc1be1140"], - "2/2/0/0/0": [ + "8/8/8/0/5/8": ["2a7732b5b99de2a5f40aab40e0af52757fbe68e5d4eda404a9a060fdc1be1140"], + "2/2/0/0/0/0": [ "146e4c8659bad066b298988613dec178ce63f3a2c35415937b3d70a8f8ef8158", "467ab0eb0885c4a78f7101abd512bf34a6fe6e85770b49d1bee855633a4d4a73", "53f5cdbeb3fbd9c6cb252a48c2ad9206f1f2ab44215372f83199df3a90803ffd", @@ -46,9 +46,9 @@ "97058f720a70f62399c92f7f84ffb4a29b936baf95c6a2e339e3cc4a222f318c", "c872c9e704797d829205f3c5486e7c4cd5aec729143a63fbcf8b0ec8e8864a3c" ], - "6/6/0/2/6": ["a2ef7b35c00d8cf8ba75230d71d69b96130f7f689495e86a4cf5f8fcd34431ae"], - "2/2/0/1/2": ["55b7e4463dcd36ba02eae072445e8fc2cc684a188e40c588b05321963a362c5e"], - "1/1/0/1/1": [ + "6/6/0/0/2/6": ["a2ef7b35c00d8cf8ba75230d71d69b96130f7f689495e86a4cf5f8fcd34431ae"], + "2/2/0/0/1/2": ["55b7e4463dcd36ba02eae072445e8fc2cc684a188e40c588b05321963a362c5e"], + "1/1/0/0/1/1": [ "46f3c1c68552429cf32191ebc8f48d12f52e517481b6fb588ad1955937c5cd1d", "7298574bb32ac3068b07b427a35cd2834308d2fcb0e2ac74fea4e013e738c62e", "9b91bf5bfcc0df4f6a2fec8867a923af7758058c224a9772a728e8d2ba0f0fc5", @@ -56,13 +56,26 @@ "b7ef6d85acfedda1a8d3a77391592587d3ca3088013b896d03fd129b23d83834", "e48eb3a53b7e8f40a5a87af8e983e9932c298baeb572e3a901b3961f997674ef" ], - "124/124/0/122/124": ["2c1dce175b9f078111a48dc13fd1777f5fbd4cd7ab6623e647e7437330c60b7f"], - "2/2/0/1/1": ["4d8bce790a93b9bc747abec5d99840e3e0d1ce5523e0afae3ba881762cfe2c13"], - "3/0/3/0/0": [ + "124/124/0/0/122/124": ["2c1dce175b9f078111a48dc13fd1777f5fbd4cd7ab6623e647e7437330c60b7f"], + "2/2/0/0/1/1": ["4d8bce790a93b9bc747abec5d99840e3e0d1ce5523e0afae3ba881762cfe2c13"], + "3/3/3/0/3/3": [ "0611f9054a4b566340dd53181b96d66907f4ac8faa0ef1395ec338be9f83b96d", "816e01738f4967872980ce3fed0c2bfd8c09f78197b7de1beea5ca98e094aeae" ], - "3/2/1/0/2": ["85f8c7d681b15daf755ba1d568eb09ebcdd529a16c20045e6db6c2937899f54b"], - "4/4/0/2/4": ["a2f3b2469e26f19985ac801786c90edfb965a7bc194a6e57704aa845c5f8611f"] + "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_SURPLUS_CLOSE_RECOVERED": 6, + "XER_CALENDAR_INVALID_STRUCTURE": 1 + }, + "dossier": { + "hash": "2c1dce175b9f078111a48dc13fd1777f5fbd4cd7ab6623e647e7437330c60b7f", + "calendars": 124, + "dayHourFieldsPresent": 0, + "semanticDigest": "7234268c6adcbdb6e0c2224721959060f319e23472cd13068f1d6eca2a5f2066" } } From e4c498e5d57a7a91d96dcfb4ff523b00d5610124 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:38:49 +0200 Subject: [PATCH 017/171] Versterk XER-kalenderherstel en basegraaf --- src/services/xer/xerCalendarData.ts | 123 +++++++++++---- tests/planning/check-xer-calendar-data.ts | 173 +++++++++++++++++++++- 2 files changed, 266 insertions(+), 30 deletions(-) diff --git a/src/services/xer/xerCalendarData.ts b/src/services/xer/xerCalendarData.ts index 02f014add..544a28687 100644 --- a/src/services/xer/xerCalendarData.ts +++ b/src/services/xer/xerCalendarData.ts @@ -21,12 +21,21 @@ import { } 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[]; @@ -36,12 +45,17 @@ export interface DecodedXerCalendarData { recoveries: XerCalendarRecovery[]; } -export type XerCalendarRecovery = 'COMPACT_RECORD' | 'SURPLUS_CLOSE' | 'DUPLICATE_EXCEPTION'; +export type XerCalendarRecovery = + | 'COMPACT_RECORD' + | 'SURPLUS_CLOSE' + | 'MULTIPLE_EXCEPTIONS' + | 'DUPLICATE_EXCEPTION'; 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' @@ -52,6 +66,7 @@ export type XerCalendarIssueCode = | 'XER_CALENDAR_OVERLAPPING_BANDS' | 'XER_CALENDAR_INVALID_EPOCH' | 'XER_CALENDAR_INVALID_PERIOD_HOURS' + | 'XER_CALENDAR_MULTIPLE_EXCEPTIONS_MERGED' | 'XER_CALENDAR_DUPLICATE_EXCEPTION' | 'XER_CALENDAR_DANGLING_BASE' | 'XER_CALENDAR_SELF_BASE' @@ -250,6 +265,14 @@ function scalarBounds(bands: WorkTimeBands): { start: number; end: number } { return { start: 8, end: 16 }; } +type XerStructuredParseContext = + | 'ROOT' + | 'ROOT_CHILD' + | 'DAY' + | 'EXCEPTION' + | 'BAND' + | 'GENERIC'; + class StructuredTextTokenizer { private index = 0; usedCompactRecord = false; @@ -261,7 +284,7 @@ class StructuredTextTokenizer { parse(): XerStructuredRecord { this.skipFormatting(); - const record = this.readRecord(); + const record = this.readRecord('ROOT'); this.skipFormatting(); if (this.index !== this.text.length) { throw new Error('Ongeldige XER-kalenderdata: onverwachte tekst na het hoofdrecord.'); @@ -269,21 +292,23 @@ class StructuredTextTokenizer { return record; } - private readRecord(): XerStructuredRecord { + 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 { number: '', name: '0', fields: { s: start, f: finish }, children: [] }; + 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('('); @@ -291,12 +316,12 @@ class StructuredTextTokenizer { const children: XerStructuredRecord[] = []; this.skipFormatting(); while (this.peek() === '(') { - children.push(this.readRecord()); + children.push(this.readRecord('BAND')); this.skipFormatting(); } this.expect(')'); this.expect(')'); - return { number, name: number, fields: { d: epoch }, children }; + return { role: 'EXCEPTION', number, name: '', fields: { d: epoch }, children }; } const name = this.readUntil('('); this.expect('('); @@ -305,14 +330,41 @@ class StructuredTextTokenizer { 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()); + children.push(this.readRecord(childContext)); this.skipFormatting(); } this.expect(')'); this.expect(')'); - return { number, name, fields, children }; + 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 { @@ -479,8 +531,9 @@ export function decodeXerCalendarData(text: string): DecodedXerCalendarData { const holidaysByDate = new Map(); const workingByDate = new Map(); let duplicateException = false; - const exceptions = root.children.find(record => record.name === 'Exceptions'); - for (const exception of exceptions?.children ?? []) { + 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 ?? ''); const canonicalException = canonicalizeBands({ 1: bandsFromRecords(exception.children) }).bands; assertNoBandOverlap(canonicalException); @@ -514,25 +567,32 @@ function cyclicCalendarIds( byId: ReadonlyMap, ): Set { const state = new Map(); - const path: string[] = []; const cycles = new Set(); - const visit = (calendar: XerCalendar): void => { - const currentState = state.get(calendar.id); - if (currentState === 'DONE') return; - if (currentState === 'VISITING') { - const cycleStart = path.lastIndexOf(calendar.id); - for (const id of path.slice(cycleStart)) cycles.add(id); - return; + 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; } - state.set(calendar.id, 'VISITING'); - path.push(calendar.id); - const baseId = calendar.baseCalendarId; - const base = baseId && baseId !== calendar.id ? byId.get(baseId) : undefined; - if (base) visit(base); - path.pop(); - state.set(calendar.id, 'DONE'); - }; - for (const calendar of calendars) visit(calendar); + for (const calendar of path) state.set(calendar.id, 'DONE'); + } return cycles; } @@ -648,6 +708,15 @@ export function readXerCalendars(tables: XerTables): XerCalendarReadResult { 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', + }); + } } const byId = new Map(calendars.map(calendar => [calendar.id, calendar])); diff --git a/tests/planning/check-xer-calendar-data.ts b/tests/planning/check-xer-calendar-data.ts index 57835fd11..d1dec9e74 100644 --- a/tests/planning/check-xer-calendar-data.ts +++ b/tests/planning/check-xer-calendar-data.ts @@ -22,24 +22,37 @@ 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' }, @@ -178,6 +191,44 @@ eq('14 rij-issues pinnen kalender-id, bronregel, code en afhandeling', 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 @@ -245,6 +296,41 @@ eq('15d hostile issues dragen steeds bronregel en reden', hostileRows.issues.map { 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: 'Kalenderuitzondering', startDate: '1899-12-30', endDate: '1899-12-30', + }], + working: [{ + name: 'Kalenderuitzondering', 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([ @@ -259,7 +345,7 @@ const baseGraph = readXerCalendars(parseXerTables(utf8([ '%R\tchild\troot\t', '%E', ]))); -eq('15e ruwe base-id blijft staan maar alleen de acyclische geldige rand wordt gekoppeld', +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, @@ -272,7 +358,7 @@ eq('15e ruwe base-id blijft staan maar alleen de acyclische geldige rand wordt g { id: 'root' }, { id: 'child', rawBase: 'root', linkedBase: 'root' }, ]); -eq('15f basegraaf rapporteert cyclusleden, self en dangling onderscheiden', +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'], @@ -285,9 +371,90 @@ try { } catch { baseGraphSerializable = false; } -eq('15g kalenderresultaat blijft serialiseerbaar zonder cyclische objectgraaf', +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); +} +eq('15j iteratieve basegraaf verwerkt 10.000 acyclische vooruitverwijzingen zonder stackfout', { + error: deepAcyclicError, + calendars: deepAcyclic?.calendars.length, + issues: deepAcyclic?.issues.length, + firstBase: deepAcyclic?.byId.get('a-0')?.baseCalendar?.id, + penultimateBase: deepAcyclic?.byId.get('a-9998')?.baseCalendar?.id, + rootBase: deepAcyclic?.byId.get('a-9999')?.baseCalendar?.id, +}, { + error: '', + calendars: 10_000, + issues: 0, + firstBase: 'a-1', + penultimateBase: 'a-9999', +}); + +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. From a8fffe4b51a0d2e80f604fd8d9c53d16fc7781a8 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:18:31 +0200 Subject: [PATCH 018/171] Maak XER-basekoppelingen serialiseerbaar --- src/services/xer/xerCalendarData.ts | 16 ++++++++++++++- tests/planning/check-xer-calendar-data.ts | 24 +++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/services/xer/xerCalendarData.ts b/src/services/xer/xerCalendarData.ts index 544a28687..dc60ebc62 100644 --- a/src/services/xer/xerCalendarData.ts +++ b/src/services/xer/xerCalendarData.ts @@ -88,7 +88,12 @@ export interface XerCalendar extends WorkCalendar { 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'; } @@ -596,6 +601,15 @@ function cyclicCalendarIds( 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 ?? []; @@ -756,7 +770,7 @@ export function readXerCalendars(tables: XerTables): XerCalendarReadResult { }); continue; } - calendar.baseCalendar = base; + linkBaseCalendar(calendar, base); } return { diff --git a/tests/planning/check-xer-calendar-data.ts b/tests/planning/check-xer-calendar-data.ts index d1dec9e74..6687b8557 100644 --- a/tests/planning/check-xer-calendar-data.ts +++ b/tests/planning/check-xer-calendar-data.ts @@ -2,6 +2,7 @@ import { decodeXerCalendarData, parseXerStructuredText, readXerCalendars, + type XerCalendar, } from '@/services/xer/xerCalendarData'; import { parseP6StandardWorkWeek } from '@/services/p6/p6xmlReader'; import { canonicalizeBands } from '@/services/subdayIo'; @@ -393,19 +394,42 @@ try { } 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) => ( From ef7368cdc17080dd3a2b6ccd6909ceb9ff39a2ff Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 25 Aug 2026 05:10:49 +0200 Subject: [PATCH 019/171] Voeg XER-project- en taakimport toe --- src/i18n/locales/ar/common.json | 11 + src/i18n/locales/de/common.json | 11 + src/i18n/locales/en/common.json | 11 + src/i18n/locales/es/common.json | 11 + src/i18n/locales/fa/common.json | 11 + src/i18n/locales/fr/common.json | 11 + src/i18n/locales/it/common.json | 11 + src/i18n/locales/ja/common.json | 11 + src/i18n/locales/ko/common.json | 11 + src/i18n/locales/nl/common.json | 11 + src/i18n/locales/pl/common.json | 11 + src/i18n/locales/pt/common.json | 11 + src/i18n/locales/tr/common.json | 11 + src/i18n/locales/zh/common.json | 11 + src/services/fileAccess/tauriBackend.ts | 32 +- src/services/formatRegistry.ts | 47 +- src/services/mcp/tools/fileTools.ts | 8 +- src/services/xer/xerReader.ts | 513 ++++++++++++++++++ src/services/xer/xerTables.ts | 4 +- src/state/slices/types.ts | 11 + src/utils/devBridge.ts | 12 +- tests/mcp/cases-doc-file.ts | 56 ++ tests/planning/check-xer-byte-paths.ts | 207 +++++++ tests/planning/check-xer-chunk-boundary.ts | 57 ++ tests/planning/check-xer-product-fidelity.ts | 130 +++++ tests/planning/check-xer-reader.ts | 219 ++++++++ tests/planning/check-xer-registry.ts | 126 +++++ tests/planning/run.sh | 23 + .../xer-product-fidelity-baseline.json | 31 ++ vite.config.ts | 7 +- 30 files changed, 1614 insertions(+), 23 deletions(-) create mode 100644 src/services/xer/xerReader.ts create mode 100644 tests/planning/check-xer-byte-paths.ts create mode 100644 tests/planning/check-xer-chunk-boundary.ts create mode 100644 tests/planning/check-xer-product-fidelity.ts create mode 100644 tests/planning/check-xer-reader.ts create mode 100644 tests/planning/check-xer-registry.ts create mode 100644 tests/planning/xer-product-fidelity-baseline.json diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index 019fd499e..eb3bc3ea6 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -5,6 +5,17 @@ "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 هذا على أنشطة.", "mppLegacy": "يستخدم ملف .mpp هذا تنسيقًا قديمًا (Project 2007 أو أقدم). صدّره من MS Project بصيغة XML (ملف ← حفظ باسم ← XML) ثم افتح ذلك الملف.", "mppEncrypted": "ملف .mpp هذا محمي بكلمة مرور. صدّره من MS Project بصيغة XML (ملف ← حفظ باسم ← XML) ثم افتح ذلك الملف.", "saveFailed": "تعذّر الحفظ", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 5a388d9d9..9bb78b120 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -5,6 +5,17 @@ "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.", "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", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 13fe971e8..ed3539f8d 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -5,6 +5,17 @@ "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.", "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", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 5de6a3811..06f69d6c6 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -5,6 +5,17 @@ "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.", "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", diff --git a/src/i18n/locales/fa/common.json b/src/i18n/locales/fa/common.json index 3e8573bdd..6657bf5ea 100644 --- a/src/i18n/locales/fa/common.json +++ b/src/i18n/locales/fa/common.json @@ -5,6 +5,17 @@ "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 هیچ فعالیتی ندارد.", "mppLegacy": "این فایل .mpp از قالبی قدیمی (Project 2007 یا قدیمی‌تر) استفاده می‌کند. آن را در MS Project به‌صورت XML صادر کنید (فایل ← ذخیره به‌عنوان ← XML) و آن فایل را باز کنید.", "mppEncrypted": "این فایل .mpp با رمز عبور محافظت شده است. آن را در MS Project به‌صورت XML صادر کنید (فایل ← ذخیره به‌عنوان ← XML) و آن فایل را باز کنید.", "saveFailed": "ذخیره‌سازی ناموفق بود", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 977c761b0..271534373 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -5,6 +5,17 @@ "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é.", "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", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 3fc825301..ef86a381b 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -5,6 +5,17 @@ "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à.", "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", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index a3b4a63db..bae3b5cb9 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -5,6 +5,17 @@ "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 プロジェクトにはアクティビティがありません。", "mppLegacy": "この .mpp ファイルは古い形式(Project 2007 以前)を使用しています。MS Project で XML としてエクスポートし(ファイル → 名前を付けて保存 → XML)、そのファイルを開いてください。", "mppEncrypted": "この .mpp ファイルはパスワードで保護されています。MS Project で XML としてエクスポートし(ファイル → 名前を付けて保存 → XML)、そのファイルを開いてください。", "saveFailed": "保存できませんでした", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 33708ef17..ee8074384 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -5,6 +5,17 @@ "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 프로젝트에 활동이 없습니다.", "mppLegacy": "이 .mpp 파일은 이전 형식(Project 2007 이하)을 사용합니다. MS Project에서 XML로 내보낸 후(파일 → 다른 이름으로 저장 → XML) 해당 파일을 여세요.", "mppEncrypted": "이 .mpp 파일은 암호로 보호되어 있습니다. MS Project에서 XML로 내보낸 후(파일 → 다른 이름으로 저장 → XML) 해당 파일을 여세요.", "saveFailed": "저장하지 못했습니다", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 002b3b12a..eb8ba74fc 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -5,6 +5,17 @@ "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.", "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", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 3f12f2163..f287393b8 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -5,6 +5,17 @@ "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ń.", "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ć", diff --git a/src/i18n/locales/pt/common.json b/src/i18n/locales/pt/common.json index bbda332f7..d7deccf24 100644 --- a/src/i18n/locales/pt/common.json +++ b/src/i18n/locales/pt/common.json @@ -5,6 +5,17 @@ "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.", "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", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 4085cee92..2852123c7 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -5,6 +5,17 @@ "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.", "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", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 45ed6f855..64b44a207 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -5,6 +5,17 @@ "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 项目不包含作业。", "mppLegacy": "此 .mpp 文件使用较旧的格式(Project 2007 或更早版本)。请在 MS Project 中将其导出为 XML(文件 → 另存为 → XML),然后打开该文件。", "mppEncrypted": "此 .mpp 文件受密码保护。请在 MS Project 中将其导出为 XML(文件 → 另存为 → XML),然后打开该文件。", "saveFailed": "保存失败", 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..57616d713 100644 --- a/src/services/formatRegistry.ts +++ b/src/services/formatRegistry.ts @@ -54,7 +54,7 @@ const IFC_FORMAT: ReadFormat = { read: async (i, labels) => readIFC(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 +68,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). */ @@ -144,10 +151,40 @@ 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' { + 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', + } 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/mcp/tools/fileTools.ts b/src/services/mcp/tools/fileTools.ts index 9c523e616..82f2bb92f 100644 --- a/src/services/mcp/tools/fileTools.ts +++ b/src/services/mcp/tools/fileTools.ts @@ -156,10 +156,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'; } @@ -283,7 +284,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 ' + @@ -401,6 +403,8 @@ 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 één niet-leeg P6-project. 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.'); diff --git a/src/services/xer/xerReader.ts b/src/services/xer/xerReader.ts new file mode 100644 index 000000000..b11592a80 --- /dev/null +++ b/src/services/xer/xerReader.ts @@ -0,0 +1,513 @@ +/** + * 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 } from '@/services/importTypes'; +import { getCalendarBands, promoteHourCalendar } from '@/services/subdayIo'; +import type { WorkCalendar } from '@/types/calendar'; +import type { Sequence, SequenceType } from '@/types/sequence'; +import type { + P6ActivityType, + P6DurationType, + Task, + TaskConstraint, + TaskStatus, +} from '@/types/task'; +import { createDefaultTaskTime } from '@/utils/taskDefaults'; +import { formatInstant, parseInstant } from '@/utils/dateUtils'; +import { readXerCalendars, type XerCalendarIssue } from './xerCalendarData'; +import { + parseXerNumber, + parseXerTables, + XerImportError, + type XerImportReport, + type XerRow, + type XerTables, +} from './xerTables'; + +export interface XerEnumFallback { + family: 'activityType' | 'durationType' | 'status' | 'priority' | 'constraint' | 'relation'; + token: string; + fallback: string; + table: 'PROJECT' | 'TASK' | 'TASKPRED'; + field: string; + line: number; +} + +export interface XerExternalRelation { + id: string; + localProjectId: string; + localTaskId: string; + externalProjectId: string; + externalTaskId: string; + direction: 'predecessor' | 'successor'; + type: 'FS' | 'SS' | 'FF' | 'SF'; + lagMinutes: number; +} + +export interface XerReaderMetadata { + defaultCurrencyCode: string; + tableReport: XerImportReport; + calendarIssues: XerCalendarIssue[]; + enumFallbacks: XerEnumFallback[]; + externalRelations: XerExternalRelation[]; +} + +export interface XerReadResult extends ImportResult { + /** Importtijd-metadata; externe relaties blijven hier brondata en sturen de solver niet. */ + xer: XerReaderMetadata; +} + +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', +]; + +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()); +} + +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'); +} + +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 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}`; +} + +function stableWbsRows(rows: readonly XerRow[], projectId: string): XerRow[] { + return rows + .filter(row => row.cells.proj_id === projectId) + .sort((left, right) => { + const parentOrder = (left.cells.parent_wbs_id ?? '').localeCompare(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 || left.line - right.line; + }); +} + +/** Lees precies één niet-leeg P6-project uit de oorspronkelijke XER-bestandsbytes. */ +export function readXER(bytes: Uint8Array): XerReadResult { + const tables = parseXerTables(bytes); + const projectRows = tables.tables.get('PROJECT')?.rows ?? []; + if (projectRows.length !== 1) { + throw new XerImportError( + 'XER_SINGLE_PROJECT_REQUIRED', + `Deze importstap verwacht precies één PROJECT-rij; gevonden: ${projectRows.length}.`, + { table: 'PROJECT', lines: projectRows.map(row => row.line) }, + ); + } + const projectRow = projectRows[0]; + const projectId = projectRow.cells.proj_id; + const activityRows = (tables.tables.get('TASK')?.rows ?? []) + .filter(row => row.cells.proj_id === projectId); + if (activityRows.length === 0) { + throw new XerImportError( + 'XER_EMPTY_PROJECT', + `XER-project ${projectId} bevat geen activiteiten.`, + { table: 'TASK' }, + ); + } + + 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, + ].some(value => hasClock(value ?? ''))) { + hourSignalCalendarIds.add(calendarId); + } + } + for (const calendar of calendarList) { + promoteHourCalendar( + calendar, + getCalendarBands(calendar), + hourSignalCalendarIds.has(calendar.id), + false, + ); + } + + 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 start = sourceInstant(row.cells.target_start_date ?? '', hourMode) + ?? sourceInstant(projectRow.cells.last_recalc_date ?? '', hourMode) + ?? '1970-01-01'; + const finish = sourceInstant(row.cells.target_end_date ?? '', hourMode) ?? start; + const durationHours = numberOf(tables, row, 'target_drtn_hr_cnt') ?? 0; + const remainingHours = numberOf(tables, row, 'remain_drtn_hr_cnt'); + const durationMinutes = 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 status = statusOf(row.cells.status_code ?? '', row, enumFallbacks); + const physicalPercent = numberOf(tables, row, 'phys_complete_pct') ?? 0; + const completion = status === 'COMPLETED' ? 1 : Math.max(0, Math.min(1, physicalPercent / 100)); + const parentId = row.cells.wbs_id ? wbsTaskId(projectId, row.cells.wbs_id) : null; + const time = createDefaultTaskTime(start, durationMinutes / (hoursPerDay * 60)); + time.scheduleFinish = finish; + time.earlyStart = start; + time.earlyFinish = finish; + time.lateStart = start; + time.lateFinish = finish; + time.completion = completion; + if (hourMode) { + time.durationMinutes = durationMinutes; + if (remainingHours !== null) time.remainingMinutes = Math.round(remainingHours * 60); + } else if (remainingHours !== null) { + time.remainingTime = remainingHours / 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 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, + ...(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 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 projectEnd = finishes[finishes.length - 1] ?? projectStart; + const projectHourMode = projectCalendar.workTime !== undefined; + const statusDate = sourceInstant(projectRow.cells.last_recalc_date ?? '', projectHourMode); + + const wbsRows = stableWbsRows(tables.tables.get('PROJWBS')?.rows ?? [], 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, + 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); + } + + const sequences: Sequence[] = []; + const externalRelations: XerExternalRelation[] = []; + for (const row of tables.tables.get('TASKPRED')?.rows ?? []) { + 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) { + sequences.push({ + id: relationId, + predecessorId: row.cells.pred_task_id, + successorId: row.cells.task_id, + type: relationType.sequence, + lagMinutes, + lagDays: lagMinutes / (projectCalendar.hoursPerDay * 60), + }); + } 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 } : {}), + }, + calendar: projectCalendar, + resourceCalendars: calendarList.filter(calendar => calendar.id !== projectCalendar.id), + tasks: allTasks, + sequences, + resources: [], + assignments: [], + xer: { + defaultCurrencyCode: tables.header.defaultCurrencyCode, + tableReport: tables.report, + calendarIssues: calendars.issues, + enumFallbacks, + externalRelations, + }, + }; +} diff --git a/src/services/xer/xerTables.ts b/src/services/xer/xerTables.ts index 1a07133d8..cf7ae29d6 100644 --- a/src/services/xer/xerTables.ts +++ b/src/services/xer/xerTables.ts @@ -23,7 +23,9 @@ export type XerImportErrorCode = | 'XER_MISSING_REQUIRED_VALUE' | 'XER_AMBIGUOUS_DECIMAL' | 'XER_INVALID_NUMBER_FORMAT' - | 'XER_INVALID_NUMBER'; + | 'XER_INVALID_NUMBER' + | 'XER_SINGLE_PROJECT_REQUIRED' + | 'XER_EMPTY_PROJECT'; export class XerImportError extends Error { readonly xerCode: XerImportErrorCode; diff --git a/src/state/slices/types.ts b/src/state/slices/types.ts index e4ce23715..4eaea56e8 100644 --- a/src/state/slices/types.ts +++ b/src/state/slices/types.ts @@ -173,6 +173,17 @@ export type NotificationMessageKey = | 'notifications.summaryRelationsDropped' | '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.mppSourceScheduleNotes' | 'notifications.projectStartAnchorsClamped' | 'notifications.mppTimephasedSteeringLost'; diff --git a/src/utils/devBridge.ts b/src/utils/devBridge.ts index 37c246bba..f38bd66e6 100644 --- a/src/utils/devBridge.ts +++ b/src/utils/devBridge.ts @@ -3,7 +3,7 @@ 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, readFormatInput, type FormatIO } from '@/services/formatRegistry'; import { enableExtension, disableExtension, removeExtension, saveExtensionToDb, installFromZipBlob } from '@/extensions'; import type { InstallOutcome } from '@/extensions'; import type { ExtensionManifest, InstalledExtension } from '@/extensions/types'; @@ -79,14 +79,18 @@ 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. */ -async function openFromPath(path: string) { - const { readTextFile, readFile } = await import('@tauri-apps/plugin-fs'); - const input = await readFormatInput(path, { readTextFile, readFile }); +export async function openFromPathWithIO(path: string, io: FormatIO) { + const input = await readFormatInput(path, io); const parsed = await parseOpenedFile(input); useAppStore.getState().loadState(parsed); return { path, ...counts(useAppStore.getState()) }; } +async function openFromPath(path: string) { + const { readTextFile, readFile } = await import('@tauri-apps/plugin-fs'); + return openFromPathWithIO(path, { readTextFile, readFile }); +} + /** Dev-only: installeer een extensie direct vanuit een code-string (voor zelftests). */ async function installExtensionFromCode( manifest: ExtensionManifest, diff --git a/tests/mcp/cases-doc-file.ts b/tests/mcp/cases-doc-file.ts index 3c673d34c..286e08f07 100644 --- a/tests/mcp/cases-doc-file.ts +++ b/tests/mcp/cases-doc-file.ts @@ -205,6 +205,38 @@ 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 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. */ @@ -623,6 +655,30 @@ 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: onbekend pad ⇒ NOT_FOUND, buiten de scope ⇒ SCOPE', async () => { installFakeFs(); resetToSingleEmptyDocument(); diff --git a/tests/planning/check-xer-byte-paths.ts b/tests/planning/check-xer-byte-paths.ts new file mode 100644 index 000000000..465d7ec67 --- /dev/null +++ b/tests/planning/check-xer-byte-paths.ts @@ -0,0 +1,207 @@ +import { openDialogFilters, binaryExtensions, parseOpenedFile } from '@/services/formatRegistry'; +import { readOpenedTauriPath } from '@/services/fileAccess/tauriBackend'; +import { openFileDialogWeb, readBytesFromRefWeb } from '@/services/fileAccess/webBackend'; + +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.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 = 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 = 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 = 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 }); +} + +// 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 }); + +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-chunk-boundary.ts b/tests/planning/check-xer-chunk-boundary.ts new file mode 100644 index 000000000..6df9195b9 --- /dev/null +++ b/tests/planning/check-xer-chunk-boundary.ts @@ -0,0 +1,57 @@ +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'); +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'); + +// 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'); +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 (reader.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-product-fidelity.ts b/tests/planning/check-xer-product-fidelity.ts new file mode 100644 index 000000000..f5dc3afd4 --- /dev/null +++ b/tests/planning/check-xer-product-fidelity.ts @@ -0,0 +1,130 @@ +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 { scanXerGroundTruth } from './xerGroundTruth'; +import { measureXerFidelity, type XerSolvedProject } 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; + files: Record['counters']; + reason: string; + }>; +}; + +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 = readXER(bytes); + const cpm = solveProject({ + tasks: imported.tasks, + sequences: imported.sequences, + calendar: imported.calendar, + calendars: imported.resourceCalendars ?? [], + dataDate: imported.project.statusDate, + 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), + ]); + 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: Math.round(task.time.totalFloat * minutesPerDay), + freeFloatMinutes: Math.round(task.time.freeFloat * minutesPerDay), + }; + }), + }; +} + +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 { + 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() }); + for (const label of pins) { + const bytes = readFileSync(join(root, label)); + const truth = scanXerGroundTruth(bytes); + const ours = solved(bytes); + const measurement = measureXerFidelity(truth, [ours]); + if (process.env.OPS_XER_FIDELITY_REPORT === 'detail') { + const oursById = new Map(ours.tasks.map(task => [task.sourceTaskId, task])); + 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); + } + + // 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 = readXER(readFileSync(join(root, label))); + 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-reader.ts b/tests/planning/check-xer-reader.ts new file mode 100644 index 000000000..7eb443bc7 --- /dev/null +++ b/tests/planning/check-xer-reader.ts @@ -0,0 +1,219 @@ +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 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 { + return readXER(bytes(lines)); +} + +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('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, +}, { id: 'C1', hourMode: true }); + +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, + }], +}); + +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 meer dan één PROJECT wordt begrensd 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_SINGLE_PROJECT_REQUIRED'); + +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); + +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-registry.ts b/tests/planning/check-xer-registry.ts new file mode 100644 index 000000000..f01d673f6 --- /dev/null +++ b/tests/planning/check-xer-registry.ts @@ -0,0 +1,126 @@ +import { + allReadFormats, + binaryExtensions, + importErrorMessageKey, + openDialogFilters, + parseOpenedFile, + readFormatInput, + readFormatForFile, + saveTargetFor, +} from '@/services/formatRegistry'; +import { XerImportError, type XerImportErrorCode } 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 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 = 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'], +]; +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/run.sh b/tests/planning/run.sh index fa8e5be20..907aea896 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -230,6 +230,29 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then 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 + + # 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 + + # 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 + + # 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 + # 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. diff --git a/tests/planning/xer-product-fidelity-baseline.json b/tests/planning/xer-product-fidelity-baseline.json new file mode 100644 index 000000000..7523a55da --- /dev/null +++ b/tests/planning/xer-product-fidelity-baseline.json @@ -0,0 +1,31 @@ +{ + "version": 1, + "files": { + "crawl-xer/p6diff-baseline.xer": { + "tasks": 8, + "projects": 1, + "counters": { + "es": { "deviations": 3, "measurable": 8 }, + "ef": { "deviations": 7, "measurable": 8 }, + "ls": { "deviations": 4, "measurable": 8 }, + "lf": { "deviations": 8, "measurable": 8 }, + "tf": { "deviations": 2, "measurable": 8 }, + "ff": { "deviations": 0, "measurable": 8 } + }, + "reason": "Eerste X4a-nulmeting: lege bronkalender valt terug op X3-banden; latere SCHEDOPTIONS- en voortgangssemantiek zijn nog buiten scope." + }, + "crawl-xer-extra/p6difftool/sample-target.xer": { + "tasks": 8, + "projects": 1, + "counters": { + "es": { "deviations": 7, "measurable": 8 }, + "ef": { "deviations": 8, "measurable": 8 }, + "ls": { "deviations": 8, "measurable": 8 }, + "lf": { "deviations": 8, "measurable": 8 }, + "tf": { "deviations": 3, "measurable": 8 }, + "ff": { "deviations": 1, "measurable": 8 } + }, + "reason": "Eerste X4a-nulmeting: bronstatusdatum en gewijzigde inputduur sturen de solver; P6-uitvoervelden blijven bewust verboden en X5/X7 zijn nog buiten scope." + } + } +} 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 ( From 91820a90bd355c73d9be51f782192bbe98eca2f2 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:59:03 +0200 Subject: [PATCH 020/171] fix: verwerk reviewbevindingen XER-projectmapping --- src/components/canvas/ContextMenu.tsx | 3 +- .../layout/Ribbon/ribbonWidgets.tsx | 3 +- src/components/layout/StatusBar/StatusBar.tsx | 3 +- src/components/panels/ReportPanel.tsx | 5 +- src/components/panels/TableEditor.tsx | 3 +- .../task-sections/TaskAssignmentsSection.tsx | 5 +- .../task-sections/TaskHammockFields.tsx | 3 +- src/engine/moveProject.ts | 2 +- src/engine/renderer/GanttRenderer.ts | 13 +- src/engine/scheduler/CPMSolver.ts | 2 +- src/engine/scheduler/ResourceLeveler.ts | 6 +- src/engine/scheduler/ResourceLoad.ts | 5 +- src/engine/scheduler/applyCpmResult.ts | 3 +- .../scheduler/expandSummaryRelations.ts | 11 +- .../scheduler/projectStartAnchorClamp.ts | 3 +- src/engine/scheduler/solveProject.ts | 5 +- src/engine/variance.ts | 3 +- src/engine/view/dropTarget.ts | 5 +- src/engine/view/visibleRows.ts | 5 +- src/extensions/extMappers.ts | 2 + src/extensions/extTypes.ts | 1 + src/extensions/sdk.ts | 1 + src/i18n/locales/ar/common.json | 3 + src/i18n/locales/de/common.json | 3 + src/i18n/locales/en/common.json | 3 + src/i18n/locales/es/common.json | 3 + src/i18n/locales/fa/common.json | 3 + src/i18n/locales/fr/common.json | 3 + src/i18n/locales/it/common.json | 3 + src/i18n/locales/ja/common.json | 3 + src/i18n/locales/ko/common.json | 3 + src/i18n/locales/nl/common.json | 3 + src/i18n/locales/pl/common.json | 3 + src/i18n/locales/pt/common.json | 3 + src/i18n/locales/tr/common.json | 3 + src/i18n/locales/zh/common.json | 3 + src/services/benchmark/generateProject.ts | 5 +- src/services/benchmark/runner.ts | 3 +- src/services/formatRegistry.ts | 6 +- src/services/ifc/ifcPsets.ts | 17 ++ src/services/ifc/ifcWriter.ts | 5 +- src/services/importTypes.ts | 58 ++++++ .../mcp/tools/calendarResourceTools.ts | 3 +- src/services/mcp/tools/readTools.ts | 9 +- src/services/mcp/tools/taskTools.ts | 3 +- src/services/mpp/mppReader.ts | 9 +- src/services/mpp/mppTimephased.ts | 2 +- src/services/msproject/mspdiWriter.ts | 3 +- src/services/p6/p6xmlWriter.ts | 7 +- src/services/print/printPreview.ts | 5 +- src/services/xer/xerReader.ts | 111 ++++++++---- src/services/xer/xerTables.ts | 4 +- src/state/documentContract.ts | 5 + src/state/mcpTransaction.ts | 5 +- src/state/mcpValidation.ts | 5 +- src/state/relationRules.ts | 18 +- src/state/slices/baselineSlice.ts | 3 +- src/state/slices/documentSlice.ts | 1 + src/state/slices/fileSlice.ts | 12 ++ src/state/slices/projectSlice.ts | 7 +- src/state/slices/resourceSlice.ts | 5 +- src/state/slices/scheduleSlice.ts | 3 +- src/state/slices/types.ts | 3 + src/state/slices/uiSlice.ts | 5 +- src/types/task.ts | 8 +- src/utils/documents.ts | 5 +- src/utils/taskHierarchy.ts | 10 ++ tests/mcp/cases-taskfields.ts | 13 ++ tests/planning/check-ext-contract.ts | 3 +- tests/planning/check-ifc-roundtrip.ts | 2 + tests/planning/check-xer-byte-paths.ts | 65 ++++++- tests/planning/check-xer-reader.ts | 168 ++++++++++++++++++ tests/planning/check-xer-registry.ts | 2 + 73 files changed, 608 insertions(+), 124 deletions(-) create mode 100644 src/utils/taskHierarchy.ts diff --git a/src/components/canvas/ContextMenu.tsx b/src/components/canvas/ContextMenu.tsx index 2158bd002..dc7d02649 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; @@ -111,7 +112,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/layout/Ribbon/ribbonWidgets.tsx b/src/components/layout/Ribbon/ribbonWidgets.tsx index 7b2cfc9b6..f9a1a9498 100644 --- a/src/components/layout/Ribbon/ribbonWidgets.tsx +++ b/src/components/layout/Ribbon/ribbonWidgets.tsx @@ -20,6 +20,7 @@ import { addTaskNearSelection } from '@/state/taskInsertActions'; import { supportsHandles } from '@/services/fileAccess'; import { DateTextInput } from '@/components/common/DateTextInput'; import { ExtensionIcon } from '@/components/common/ExtensionIcon'; +import { isLeafTask } from '@/utils/taskHierarchy'; import { RibbonTab, type GroupLevel, type SortLevel, type Layout, type TimeScale } from '@/state/slices/types'; import type { ResourceCurve } from '@/types/resource'; import { RESOURCE_CURVES, CURVE_KEY } from '@/components/task-sections/shared'; @@ -464,7 +465,7 @@ export function ResourceAssignDropdown() { const assignResource = useAppStore(s => s.assignResource); const task = selectedTaskIds.length === 1 ? tasks.find(t => t.id === selectedTaskIds[0]) : undefined; - const valid = !!task && !task.isMilestone && task.childIds.length === 0; + const valid = !!task && isLeafTask(task) && !task.isMilestone; const assignedIds = new Set(assignments.filter(a => a.taskId === task?.id).map(a => a.resourceId)); const available = resources.filter(r => !assignedIds.has(r.id)); diff --git a/src/components/layout/StatusBar/StatusBar.tsx b/src/components/layout/StatusBar/StatusBar.tsx index d86f47811..4dae76b03 100644 --- a/src/components/layout/StatusBar/StatusBar.tsx +++ b/src/components/layout/StatusBar/StatusBar.tsx @@ -4,6 +4,7 @@ import { Terminal, Circle } from 'lucide-react'; import { scaleFromZoom } from '@/engine/renderer/timelineTiers'; import { useDisplayDate } from '@/hooks/displayDate'; import { AI_STATUS_COLOR } from '@/components/ribbon/ai/AiServerGroup'; +import { isLeafTask } from '@/utils/taskHierarchy'; export function StatusBar() { const { t } = useTranslation('menu'); @@ -22,7 +23,7 @@ export function StatusBar() { const setUI = useAppStore(s => s.setUI); const dd = useDisplayDate(); - const leafTasks = tasks.filter(t => t.childIds.length === 0); + const leafTasks = tasks.filter(isLeafTask); const milestones = tasks.filter(t => t.isMilestone); const criticalCount = cpmResult?.criticalPath.length || 0; diff --git a/src/components/panels/ReportPanel.tsx b/src/components/panels/ReportPanel.tsx index a7d24ffd7..fd23f3b30 100644 --- a/src/components/panels/ReportPanel.tsx +++ b/src/components/panels/ReportPanel.tsx @@ -19,6 +19,7 @@ import { VarianceReport, useVarianceResult, STATUS_COLOR as VARIANCE_STATUS_COLO import type { VarianceRow } from '@/engine/variance'; import type { PdfTableColumn } from '@/services/pdf/pdfTable'; import type { TFunction } from 'i18next'; +import { isLeafTask } from '@/utils/taskHierarchy'; /** Reactieve datum-formatters — zelfde vorm als `useDisplayDate()` (Hooks mogen hier niet in, dit * bouwt de kolomspec buiten React-render-tijd op in `handleExportPDF`). */ @@ -595,8 +596,8 @@ export function ReportPanel() { await writePdf(tablePdfBytes, `${fileBase}-${suffix}.pdf`); }, [reportType, projectName, fileBase, tasks, sequences, calendar, options, paperSize, orientation, autoFit, writePdf, t, dd, milestoneRows, varianceResult]); - const criticalCount = tasks.filter(t => t.time.isCritical && t.childIds.length === 0).length; - const leafCount = tasks.filter(t => t.childIds.length === 0).length; + const criticalCount = tasks.filter(t => t.time.isCritical && isLeafTask(t)).length; + const leafCount = tasks.filter(isLeafTask).length; return (
diff --git a/src/components/panels/TableEditor.tsx b/src/components/panels/TableEditor.tsx index 9b2957aab..8f678f555 100644 --- a/src/components/panels/TableEditor.tsx +++ b/src/components/panels/TableEditor.tsx @@ -16,6 +16,7 @@ import { parseDuration, formatDuration } from '@/utils/durationFormat'; import { AlertTriangle } from 'lucide-react'; import { useTableRowDrag } from './hooks/useTableRowDrag'; import { neighbourGridCell, type GridDirection } from '@/utils/gridNavigation'; +import { isSummaryTask } from '@/utils/taskHierarchy'; const MIN_COLUMN_WIDTH = 40; @@ -811,7 +812,7 @@ export function TableEditor() { ); } const { task, depth, dimmed } = row; - const isSummary = task.childIds.length > 0; + const isSummary = isSummaryTask(task); const isCollapsed = collapsedTaskIds.includes(task.id); const isSelected = selectedTaskIds.includes(task.id); // issue #26 punt 6: is DEZE rij het actuele drop-doel, en zo ja in welke zone? diff --git a/src/components/task-sections/TaskAssignmentsSection.tsx b/src/components/task-sections/TaskAssignmentsSection.tsx index 82767b55e..1aafcde3f 100644 --- a/src/components/task-sections/TaskAssignmentsSection.tsx +++ b/src/components/task-sections/TaskAssignmentsSection.tsx @@ -4,6 +4,7 @@ import type { ResourceCurve } from '@/types/resource'; import { UnitsInput } from '@/components/common/UnitsInput'; import { Trash2 } from 'lucide-react'; import { RESOURCE_CURVES, CURVE_KEY } from './shared'; +import { isLeafTask, isSummaryTask } from '@/utils/taskHierarchy'; /** * Toewijzingen (fase 2.5, §6.3 + fase 2.10 item 4 "verplaats naar…") — sectie 10 uit @@ -27,14 +28,14 @@ export function TaskAssignmentsSection({ taskId }: { taskId: string }) { // Toewijzingen (fase 2.5, §6.3) — leaf-only, geen mijlpalen/samenvattingstaken. const taskAssignments = assignments.filter(a => a.taskId === taskId); - const assignmentsDisabled = task.isMilestone || task.childIds.length > 0; + const assignmentsDisabled = task.isMilestone || isSummaryTask(task); const assignedResourceIds = new Set(taskAssignments.map(a => a.resourceId)); const availableResources = resources.filter(r => !assignedResourceIds.has(r.id)); /** Kandidaat-doeltaken voor "verplaats naar…" (item 4): leaf-taken zonder deze resource, exclusief * de huidige taak zelf. */ const moveCandidates = (resourceId: string) => tasks.filter(t => - t.id !== taskId && !t.isMilestone && t.childIds.length === 0 + t.id !== taskId && !t.isMilestone && isLeafTask(t) && !assignments.some(a => a.taskId === t.id && a.resourceId === resourceId) ); diff --git a/src/components/task-sections/TaskHammockFields.tsx b/src/components/task-sections/TaskHammockFields.tsx index 31911f078..0d7f97a44 100644 --- a/src/components/task-sections/TaskHammockFields.tsx +++ b/src/components/task-sections/TaskHammockFields.tsx @@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'; import { useAppStore } from '@/state/appStore'; import { Task } from '@/types/task'; import { SequenceType } from '@/types/sequence'; +import { isSummaryTask } from '@/utils/taskHierarchy'; /** * Hammock/Level-of-Effort-toggle + read-only driver-info (fase 2.9 §5.3) — deel van sectie 4 uit @@ -18,7 +19,7 @@ export function TaskHammockFields({ task, onChange }: { const tasks = useAppStore(s => s.tasks); const cpmResult = useAppStore(s => s.cpmResult); - if (task.isMilestone || task.childIds.length > 0) return null; + if (task.isMilestone || isSummaryTask(task)) return null; // Hammock-drivers (fase 2.9 §5.3, besluit B6): auto-detectie volgens P6-conventie — inkomende // FS/SS-relaties = start-driver (leveren de ES), FF/SF-relaties = finish-driver (leveren de EF, diff --git a/src/engine/moveProject.ts b/src/engine/moveProject.ts index 64dfb46f5..99d5e2628 100644 --- a/src/engine/moveProject.ts +++ b/src/engine/moveProject.ts @@ -111,7 +111,7 @@ const TASK_VERDICTS = { 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', + 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 diff --git a/src/engine/renderer/GanttRenderer.ts b/src/engine/renderer/GanttRenderer.ts index 50fc75759..f8fae02c0 100644 --- a/src/engine/renderer/GanttRenderer.ts +++ b/src/engine/renderer/GanttRenderer.ts @@ -14,6 +14,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'; export interface GanttRenderOptions { /** DE gedeelde zichtbare-rijenlijst (fase 2.7, §4): de renderer flattent NIET meer zelf — @@ -633,7 +634,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 @@ -959,7 +960,7 @@ export class GanttRenderer { // gewone balk, niet als ruit. 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); @@ -1914,7 +1915,7 @@ export class GanttRenderer { const task = row.task; const depth = row.depth; const isSelected = this.opts.selectedTaskIds.includes(task.id); - const isSummary = task.childIds.length > 0; + const isSummary = isSummaryTask(task); const isCollapsed = collapsed.has(task.id); // Selection highlight @@ -2053,7 +2054,7 @@ export class GanttRenderer { /** Hit test: did the click land on the collapse/expand triangle of a summary task? */ isCollapseToggle(canvasX: number, canvasY: number): Task | null { const row = this.getRowAtY(canvasY); - if (row?.kind !== 'task' || row.task.childIds.length === 0) return null; + if (row?.kind !== 'task' || isLeafTask(row.task)) return null; const indent = 55 + row.depth * 16; // Triangle area is roughly indent-12 to indent if (canvasX >= indent - 14 && canvasX <= indent + 2) { @@ -2065,7 +2066,7 @@ export class GanttRenderer { /** Hit test: did the click land on the '+' button of a summary task? */ isAddButton(canvasX: number, canvasY: number): Task | null { const task = this.getTaskAtY(canvasY); - if (!task || task.childIds.length === 0) return null; + if (!task || isLeafTask(task)) return null; const btnX = this.opts.taskTableWidth - 52; if (canvasX >= btnX && canvasX <= btnX + 14) { return task; @@ -2094,7 +2095,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 70e2ca03e..ebaf0d83f 100644 --- a/src/engine/scheduler/CPMSolver.ts +++ b/src/engine/scheduler/CPMSolver.ts @@ -250,7 +250,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 diff --git a/src/engine/scheduler/ResourceLeveler.ts b/src/engine/scheduler/ResourceLeveler.ts index 67f0fe90c..fffcb2e85 100644 --- a/src/engine/scheduler/ResourceLeveler.ts +++ b/src/engine/scheduler/ResourceLeveler.ts @@ -40,6 +40,7 @@ import { resolveCalendar } from './resolveCalendar'; import { CPMSolver, type CPMResult, type CPMOptions } from './CPMSolver'; import { distributeUnits, maxUnitsOn } from './ResourceLoad'; import { parseDate, formatDate } from '@/utils/dateUtils'; +import { isLeafTask, isSummaryTask } from '@/utils/taskHierarchy'; export interface LevelingOptions { /** true = smoothing: alleen binnen de totale float schuiven, einddatum heilig, onoplosbare @@ -102,6 +103,9 @@ export function levelResources( // Optioneel + default `{}` ⇒ byte-identiek voor elke aanroeper die niets doorgeeft. cpmOptions: CPMOptions = {}, ): LevelingResult { + // Defensief dezelfde semantische bladgrens als scheduleSlice: directe aanroepers mogen een lege, + // expliciete WBS-samenvatting nooit als nivelleer-/interne CPM-taak laten binnenglippen. + tasks = tasks.filter(isLeafTask); const projEngine = new CalendarEngine(projectCalendar); // Geselecteerde renewables: default alle non-material, anders de opgegeven ids ∩ non-material. @@ -164,7 +168,7 @@ export function levelResources( for (const a of assignments) { if (!selectedIds.has(a.resourceId)) continue; const task = taskById.get(a.taskId); - if (!task || task.isMilestone || task.childIds.length > 0) continue; + if (!task || task.isMilestone || isSummaryTask(task)) continue; const dur = task.time.scheduleDuration; if (dur <= 0) continue; const arr = distributeUnits(a.unitsPerDay, dur, a.curve ?? 'UNIFORM'); diff --git a/src/engine/scheduler/ResourceLoad.ts b/src/engine/scheduler/ResourceLoad.ts index 8acbc4c1d..9990853a6 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 } from '@/types/task'; import type { Sequence } from '@/types/sequence'; import type { WorkCalendar } from '@/types/calendar'; @@ -147,7 +148,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. @@ -291,7 +292,7 @@ export function computeHistogramReport(input: HistogramInput): HistogramReport { const perAssignment: AssignDaily[] = []; 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 dist = distributeUnits(a.unitsPerDay, task.time.scheduleDuration, a.curve ?? 'UNIFORM'); if (dist.length === 0) continue; const workDayIsos = enumerateWorkDays(projectEngine, task.time.earlyStart, task.time.earlyFinish); diff --git a/src/engine/scheduler/applyCpmResult.ts b/src/engine/scheduler/applyCpmResult.ts index b0bbd33ca..06c026db7 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 { effectiveCalendarOf } from '@/utils/taskDuration'; import { isHourCalendar } from '@/services/subdayIo'; @@ -68,7 +69,7 @@ export function applyCpmResult(tasks: Task[], result: CPMResult, cals: ApplyCpmC 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); diff --git a/src/engine/scheduler/expandSummaryRelations.ts b/src/engine/scheduler/expandSummaryRelations.ts index 8085f4638..cb8325f43 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. @@ -110,7 +111,7 @@ export function expandSummaryRelations(tasks: Task[], sequences: Sequence[]): Ex 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; @@ -124,7 +125,7 @@ export function expandSummaryRelations(tasks: Task[], sequences: Sequence[]): Ex 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); @@ -161,8 +162,8 @@ export function expandSummaryRelations(tasks: Task[], sequences: Sequence[]): Ex 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/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/solveProject.ts b/src/engine/scheduler/solveProject.ts index dc4c2429f..dfb2fa0f5 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 { @@ -53,7 +54,7 @@ export interface SolveProjectInput { * 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 +65,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 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 bbef0333c..523d2e6b7 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 5d94d543a..0e96976c7 100644 --- a/src/engine/view/visibleRows.ts +++ b/src/engine/view/visibleRows.ts @@ -9,6 +9,7 @@ import type { ColumnConfig, 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'; @@ -190,7 +191,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); @@ -205,7 +206,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/extMappers.ts b/src/extensions/extMappers.ts index fbc6b8a18..2201cc520 100644 --- a/src/extensions/extMappers.ts +++ b/src/extensions/extMappers.ts @@ -335,6 +335,7 @@ export function toExtTask(t: Task): ExtTask { 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, @@ -385,6 +386,7 @@ export function fromExtTask(t: ExtTask): Task { 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, diff --git a/src/extensions/extTypes.ts b/src/extensions/extTypes.ts index c947a64a8..3f064565f 100644 --- a/src/extensions/extTypes.ts +++ b/src/extensions/extTypes.ts @@ -233,6 +233,7 @@ export interface ExtTask { parentId: string | null; /** WBS-kinderen. */ childIds: string[]; + isSummary?: boolean; time: ExtTaskTime; resourceIds: string[]; color?: string; diff --git a/src/extensions/sdk.ts b/src/extensions/sdk.ts index ef02012b3..09f80f4f6 100644 --- a/src/extensions/sdk.ts +++ b/src/extensions/sdk.ts @@ -83,6 +83,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/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index eb3bc3ea6..dddaa3b65 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -16,6 +16,9 @@ "xerInvalidNumber": "يحتوي ملف XER على رقم غير صالح.", "xerSingleProjectRequired": "يدعم هذا الاستيراد مشروع P6 واحدًا فقط لكل ملف XER.", "xerEmptyProject": "لا يحتوي مشروع P6 في ملف XER هذا على أنشطة.", + "xerDuplicateId": "يحتوي ملف XER على معرّف مكرر لـ WBS أو نشاط أو علاقة.", + "xerDanglingLocalRelation": "تشير علاقة XER محلية إلى نشاط مفقود.", + "xerEnumFallback": "XER {{family}}: الرمز «{{token}}» غير معروف؛ استُخدمت القيمة «{{fallback}}».", "mppLegacy": "يستخدم ملف .mpp هذا تنسيقًا قديمًا (Project 2007 أو أقدم). صدّره من MS Project بصيغة XML (ملف ← حفظ باسم ← XML) ثم افتح ذلك الملف.", "mppEncrypted": "ملف .mpp هذا محمي بكلمة مرور. صدّره من MS Project بصيغة XML (ملف ← حفظ باسم ← XML) ثم افتح ذلك الملف.", "saveFailed": "تعذّر الحفظ", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 9bb78b120..8a1aa6c7a 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -16,6 +16,9 @@ "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.", + "xerDanglingLocalRelation": "Eine lokale XER-Beziehung verweist auf einen fehlenden Vorgang.", + "xerEnumFallback": "XER {{family}}: unbekanntes Token „{{token}}“; Ersatzwert „{{fallback}}“.", "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", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index ed3539f8d..be2ff689c 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -16,6 +16,9 @@ "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.", + "xerDanglingLocalRelation": "A local XER relation refers to a missing activity.", + "xerEnumFallback": "XER {{family}}: unknown token ‘{{token}}’; fell back to ‘{{fallback}}’.", "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", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 06f69d6c6..216859f1b 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -16,6 +16,9 @@ "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.", + "xerDanglingLocalRelation": "Una relación XER local hace referencia a una actividad inexistente.", + "xerEnumFallback": "XER {{family}}: token desconocido «{{token}}»; se usó «{{fallback}}».", "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", diff --git a/src/i18n/locales/fa/common.json b/src/i18n/locales/fa/common.json index 6657bf5ea..a7ed89659 100644 --- a/src/i18n/locales/fa/common.json +++ b/src/i18n/locales/fa/common.json @@ -16,6 +16,9 @@ "xerInvalidNumber": "فایل XER دارای عدد نامعتبر است.", "xerSingleProjectRequired": "این درون‌ریزی دقیقاً از یک پروژهٔ P6 در هر فایل XER پشتیبانی می‌کند.", "xerEmptyProject": "پروژهٔ P6 در این فایل XER هیچ فعالیتی ندارد.", + "xerDuplicateId": "فایل XER دارای شناسهٔ تکراری WBS، فعالیت یا رابطه است.", + "xerDanglingLocalRelation": "یک رابطهٔ محلی XER به فعالیتی ناموجود اشاره می‌کند.", + "xerEnumFallback": "XER {{family}}: نشانهٔ «{{token}}» ناشناخته است؛ «{{fallback}}» استفاده شد.", "mppLegacy": "این فایل .mpp از قالبی قدیمی (Project 2007 یا قدیمی‌تر) استفاده می‌کند. آن را در MS Project به‌صورت XML صادر کنید (فایل ← ذخیره به‌عنوان ← XML) و آن فایل را باز کنید.", "mppEncrypted": "این فایل .mpp با رمز عبور محافظت شده است. آن را در MS Project به‌صورت XML صادر کنید (فایل ← ذخیره به‌عنوان ← XML) و آن فایل را باز کنید.", "saveFailed": "ذخیره‌سازی ناموفق بود", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 271534373..39fbfdce3 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -16,6 +16,9 @@ "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.", + "xerDanglingLocalRelation": "Une relation XER locale fait référence à une activité manquante.", + "xerEnumFallback": "XER {{family}} : jeton inconnu « {{token}} » ; valeur de repli « {{fallback}} ».", "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", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index ef86a381b..0bd1338e5 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -16,6 +16,9 @@ "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.", + "xerDanglingLocalRelation": "Una relazione XER locale fa riferimento a un’attività mancante.", + "xerEnumFallback": "XER {{family}}: token sconosciuto «{{token}}»; usato «{{fallback}}».", "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", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index bae3b5cb9..1f492722c 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -16,6 +16,9 @@ "xerInvalidNumber": "XER ファイルに無効な数値があります。", "xerSingleProjectRequired": "このインポートは XER ファイルごとに 1 つの P6 プロジェクトのみ対応します。", "xerEmptyProject": "この XER ファイルの P6 プロジェクトにはアクティビティがありません。", + "xerDuplicateId": "XER ファイルに重複した WBS、アクティビティ、または関係 ID があります。", + "xerDanglingLocalRelation": "ローカル XER 関係が存在しないアクティビティを参照しています。", + "xerEnumFallback": "XER {{family}}: 不明なトークン「{{token}}」のため「{{fallback}}」を使用しました。", "mppLegacy": "この .mpp ファイルは古い形式(Project 2007 以前)を使用しています。MS Project で XML としてエクスポートし(ファイル → 名前を付けて保存 → XML)、そのファイルを開いてください。", "mppEncrypted": "この .mpp ファイルはパスワードで保護されています。MS Project で XML としてエクスポートし(ファイル → 名前を付けて保存 → XML)、そのファイルを開いてください。", "saveFailed": "保存できませんでした", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index ee8074384..e2350b663 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -16,6 +16,9 @@ "xerInvalidNumber": "XER 파일에 잘못된 숫자가 있습니다.", "xerSingleProjectRequired": "이 가져오기는 XER 파일당 정확히 하나의 P6 프로젝트를 지원합니다.", "xerEmptyProject": "이 XER 파일의 P6 프로젝트에 활동이 없습니다.", + "xerDuplicateId": "XER 파일에 중복된 WBS, 활동 또는 관계 ID가 있습니다.", + "xerDanglingLocalRelation": "로컬 XER 관계가 누락된 활동을 참조합니다.", + "xerEnumFallback": "XER {{family}}: 알 수 없는 토큰 ‘{{token}}’; ‘{{fallback}}’을 사용했습니다.", "mppLegacy": "이 .mpp 파일은 이전 형식(Project 2007 이하)을 사용합니다. MS Project에서 XML로 내보낸 후(파일 → 다른 이름으로 저장 → XML) 해당 파일을 여세요.", "mppEncrypted": "이 .mpp 파일은 암호로 보호되어 있습니다. MS Project에서 XML로 내보낸 후(파일 → 다른 이름으로 저장 → XML) 해당 파일을 여세요.", "saveFailed": "저장하지 못했습니다", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index eb8ba74fc..ef0c79374 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -16,6 +16,9 @@ "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.", + "xerDanglingLocalRelation": "Een lokale XER-relatie verwijst naar een ontbrekende activiteit.", + "xerEnumFallback": "XER {{family}}: onbekend token ‘{{token}}’; teruggevallen op ‘{{fallback}}’.", "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", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index f287393b8..d87958324 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -16,6 +16,9 @@ "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.", + "xerDanglingLocalRelation": "Lokalna relacja XER odwołuje się do brakującego działania.", + "xerEnumFallback": "XER {{family}}: nieznany token „{{token}}”; użyto „{{fallback}}”.", "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ć", diff --git a/src/i18n/locales/pt/common.json b/src/i18n/locales/pt/common.json index d7deccf24..e46213935 100644 --- a/src/i18n/locales/pt/common.json +++ b/src/i18n/locales/pt/common.json @@ -16,6 +16,9 @@ "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.", + "xerDanglingLocalRelation": "Uma relação XER local referencia uma atividade ausente.", + "xerEnumFallback": "XER {{family}}: token desconhecido “{{token}}”; usado “{{fallback}}”.", "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", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 2852123c7..f5af5fab9 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -16,6 +16,9 @@ "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.", + "xerDanglingLocalRelation": "Yerel bir XER ilişkisi eksik bir etkinliğe başvuruyor.", + "xerEnumFallback": "XER {{family}}: bilinmeyen ‘{{token}}’ belirteci; ‘{{fallback}}’ kullanıldı.", "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", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 64b44a207..7fee9e7ba 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -16,6 +16,9 @@ "xerInvalidNumber": "XER 文件包含无效数字。", "xerSingleProjectRequired": "此导入仅支持每个 XER 文件包含一个 P6 项目。", "xerEmptyProject": "此 XER 文件中的 P6 项目不包含作业。", + "xerDuplicateId": "XER 文件包含重复的 WBS、作业或关系 ID。", + "xerDanglingLocalRelation": "本地 XER 关系引用了缺失的作业。", + "xerEnumFallback": "XER {{family}}:未知标记“{{token}}”;已回退为“{{fallback}}”。", "mppLegacy": "此 .mpp 文件使用较旧的格式(Project 2007 或更早版本)。请在 MS Project 中将其导出为 XML(文件 → 另存为 → XML),然后打开该文件。", "mppEncrypted": "此 .mpp 文件受密码保护。请在 MS Project 中将其导出为 XML(文件 → 另存为 → XML),然后打开该文件。", "saveFailed": "保存失败", 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 353f4ba81..bbb0d3f1e 100644 --- a/src/services/benchmark/runner.ts +++ b/src/services/benchmark/runner.ts @@ -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 diff --git a/src/services/formatRegistry.ts b/src/services/formatRegistry.ts index 57616d713..84d3f48e7 100644 --- a/src/services/formatRegistry.ts +++ b/src/services/formatRegistry.ts @@ -165,7 +165,9 @@ export function importErrorMessageKey( | 'notifications.xerInvalidNumberFormat' | 'notifications.xerInvalidNumber' | 'notifications.xerSingleProjectRequired' - | 'notifications.xerEmptyProject' { + | 'notifications.xerEmptyProject' + | 'notifications.xerDuplicateId' + | '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'; @@ -181,6 +183,8 @@ export function importErrorMessageKey( XER_INVALID_NUMBER: 'notifications.xerInvalidNumber', XER_SINGLE_PROJECT_REQUIRED: 'notifications.xerSingleProjectRequired', XER_EMPTY_PROJECT: 'notifications.xerEmptyProject', + XER_DUPLICATE_ID: 'notifications.xerDuplicateId', + XER_DANGLING_LOCAL_RELATION: 'notifications.xerDanglingLocalRelation', } as const; if (typed?.xerCode && typed.xerCode in xerKeys) { return xerKeys[typed.xerCode as keyof typeof xerKeys]; diff --git a/src/services/ifc/ifcPsets.ts b/src/services/ifc/ifcPsets.ts index 177f5dfa7..88d419319 100644 --- a/src/services/ifc/ifcPsets.ts +++ b/src/services/ifc/ifcPsets.ts @@ -63,6 +63,8 @@ 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', + /** 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', @@ -443,6 +445,21 @@ export const PER_TASK_PSETS: PerTaskPset[] = [ } }, }, + // 15. 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/ifcWriter.ts b/src/services/ifc/ifcWriter.ts index a147c31d2..4f27ff132 100644 --- a/src/services/ifc/ifcWriter.ts +++ b/src/services/ifc/ifcWriter.ts @@ -13,7 +13,8 @@ import type { ImportResult } from '@/services/importTypes'; import { IFC_TIME_ANCHOR, FIELD_MEASURE, RESOURCE_TYPE_TO_IFC, } from './ifcConstants'; -import { PSET, PER_TASK_PSETS, ifcStr } from './ifcPsets'; +import { PSET, PER_TASK_PSETS, ifcStr } from './ifcPsets'; +import { isSummaryTask } from '@/utils/taskHierarchy'; import { projectFileBase } from '@/utils/documents'; import { IFC_TASK_SLOTS, IFC_TASKTIME_SLOTS, type TaskTimeWriteCtx, type TaskWriteCtx, @@ -896,7 +897,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') diff --git a/src/services/importTypes.ts b/src/services/importTypes.ts index 5cd7416d5..2559a9ab9 100644 --- a/src/services/importTypes.ts +++ b/src/services/importTypes.ts @@ -8,6 +8,62 @@ import type { Baseline } from '@/types/baseline'; import type { CompanyPool } from '@/types/library'; import type { RecordedFieldKey } from '@/services/ifc/ifcTaskSlots'; +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 }>; +} + +export interface XerCalendarIssueMetadata { + code: string; + calendarId: string; + line: number; + reason: string; + resolution: 'RECOVERED' | 'REJECTED' | 'UNLINKED'; +} + +export interface XerEnumFallback { + family: 'activityType' | 'durationType' | 'status' | 'priority' | 'constraint' | 'relation'; + token: string; + fallback: string; + table: 'PROJECT' | 'TASK' | 'TASKPRED'; + field: string; + line: number; +} + +export interface XerExternalRelation { + id: string; + localProjectId: string; + localTaskId: string; + externalProjectId: string; + externalTaskId: string; + direction: 'predecessor' | 'successor'; + type: 'FS' | 'SS' | 'FF' | 'SF'; + lagMinutes: number; +} + +/** Documentgebonden XER-brondata. Externe relaties zijn nadrukkelijk geen solverrelaties. */ +export interface XerImportMetadata { + defaultCurrencyCode: string; + tableReport: XerTableReportMetadata; + calendarIssues: XerCalendarIssueMetadata[]; + enumFallbacks: XerEnumFallback[]; + externalRelations: XerExternalRelation[]; +} + /** * Eén gedeelde payload-vorm voor een ingelezen project (audit P1). De vier readers (`readIFC`, * `readMSPDI`, `readP6XML`, `readCSV`) gaven elk een eigen ad-hoc objectvorm terug (11/9/7/6 @@ -100,4 +156,6 @@ 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; + /** Alleen XER: bronmetadata en solverloze cross-projectrelaties voor het geladen document. */ + xer?: XerImportMetadata; } diff --git a/src/services/mcp/tools/calendarResourceTools.ts b/src/services/mcp/tools/calendarResourceTools.ts index 275315642..19c963a5b 100644 --- a/src/services/mcp/tools/calendarResourceTools.ts +++ b/src/services/mcp/tools/calendarResourceTools.ts @@ -43,6 +43,7 @@ import type { Task } from '@/types/task'; 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 }; @@ -1126,7 +1127,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/readTools.ts b/src/services/mcp/tools/readTools.ts index ccaa7136c..5397a735f 100644 --- a/src/services/mcp/tools/readTools.ts +++ b/src/services/mcp/tools/readTools.ts @@ -38,6 +38,7 @@ 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'; // ── Lokale leestool-wikkel + nette fout ────────────────────────────────────────────────────────── @@ -256,8 +257,8 @@ 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; const p = s.project; @@ -382,7 +383,7 @@ 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); @@ -400,7 +401,7 @@ function listTasks(s: AppState, args: ListTasksArgs) { if (p > 0) row.prog = p; 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 { diff --git a/src/services/mcp/tools/taskTools.ts b/src/services/mcp/tools/taskTools.ts index bc9b933f3..4d84da092 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 @@ -92,7 +93,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/mpp/mppReader.ts b/src/services/mpp/mppReader.ts index 1a37b7a8a..0d6a8f519 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 { @@ -1664,7 +1665,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)); @@ -1735,7 +1736,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)); @@ -1826,7 +1827,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 @@ -2020,7 +2021,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 f052fa7e7..5b230ee5e 100644 --- a/src/services/msproject/mspdiWriter.ts +++ b/src/services/msproject/mspdiWriter.ts @@ -8,6 +8,7 @@ import { projectFileBase } from '@/utils/documents'; import { effectiveCalendarByTask, isHourCalendar, minutesToClock, minutesToIsoDuration, taskMinutesForWrite, } from '@/services/subdayIo'; +import { isSummaryTask } from '@/utils/taskHierarchy'; // WorkContour-enum (fase 2.5, §8.3 — geverifieerd tegen de MSPDI-schemadocumentatie/MPXJ): // 0=Flat, 1=BackLoaded, 2=FrontLoaded, 4=EarlyPeak, 5=LatePeak, 6=Bell. Index 3 en 7+ @@ -422,7 +423,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/p6xmlWriter.ts b/src/services/p6/p6xmlWriter.ts index 1a3491eec..85d2837a7 100644 --- a/src/services/p6/p6xmlWriter.ts +++ b/src/services/p6/p6xmlWriter.ts @@ -5,6 +5,7 @@ import { Project } from '@/types/project'; import { WorkCalendar } from '@/types/calendar'; import { effectiveCalendarByTask, isHourCalendar, minutesToClock, taskMinutesForWrite } from '@/services/subdayIo'; import { projectFileBase } from '@/utils/documents'; +import { isLeafTask, isSummaryTask } from '@/utils/taskHierarchy'; // Curve-/contour-naammapping (fase 2.5, §8.3): P6 kent geen `LATE_PEAK`-curve — beste // benadering is 'Early Peak' (gedocumenteerd verlies, zie verliesmatrix §8.4). UNIFORM wordt @@ -106,7 +107,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'; } @@ -290,8 +291,8 @@ export function writeP6XML( const effCalByTask = effectiveCalendarByTask(tasks, calendar, libraryCalendars); // 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 9291b7535..54a1aa0aa 100644 --- a/src/services/print/printPreview.ts +++ b/src/services/print/printPreview.ts @@ -12,6 +12,7 @@ import { PRINT_PALETTE as PRINT_COLORS } from '@/engine/renderer/themePalette'; import { dateToX as axisDateToX } from '@/engine/renderer/timeAxis'; import { computeSplitSegments } from '@/engine/renderer/splitBarGeometry'; import { snapToChoice } from '@/utils/numberChoice'; +import { isSummaryTask } from '@/utils/taskHierarchy'; // BASISmaten bij rapport-lettergrootte 100%. Niets tekent hier nog rechtstreeks mee: alle // tekenhelpers rekenen met de geschaalde varianten uit {@link ReportMetrics}/{@link makeMetrics}. @@ -675,7 +676,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); @@ -1223,7 +1224,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(12); - const isSummary = task.childIds.length > 0; + const isSummary = isSummaryTask(task); // Alternating row background if (i % 2 === 0) { diff --git a/src/services/xer/xerReader.ts b/src/services/xer/xerReader.ts index b11592a80..e6b485d54 100644 --- a/src/services/xer/xerReader.ts +++ b/src/services/xer/xerReader.ts @@ -8,7 +8,9 @@ * vastgelegd. */ -import type { ImportResult } from '@/services/importTypes'; +import type { + ImportResult, XerEnumFallback, XerExternalRelation, XerImportMetadata, +} from '@/services/importTypes'; import { getCalendarBands, promoteHourCalendar } from '@/services/subdayIo'; import type { WorkCalendar } from '@/types/calendar'; import type { Sequence, SequenceType } from '@/types/sequence'; @@ -21,47 +23,18 @@ import type { } from '@/types/task'; import { createDefaultTaskTime } from '@/utils/taskDefaults'; import { formatInstant, parseInstant } from '@/utils/dateUtils'; -import { readXerCalendars, type XerCalendarIssue } from './xerCalendarData'; +import { readXerCalendars } from './xerCalendarData'; import { parseXerNumber, parseXerTables, XerImportError, - type XerImportReport, type XerRow, type XerTables, } from './xerTables'; -export interface XerEnumFallback { - family: 'activityType' | 'durationType' | 'status' | 'priority' | 'constraint' | 'relation'; - token: string; - fallback: string; - table: 'PROJECT' | 'TASK' | 'TASKPRED'; - field: string; - line: number; -} - -export interface XerExternalRelation { - id: string; - localProjectId: string; - localTaskId: string; - externalProjectId: string; - externalTaskId: string; - direction: 'predecessor' | 'successor'; - type: 'FS' | 'SS' | 'FF' | 'SF'; - lagMinutes: number; -} - -export interface XerReaderMetadata { - defaultCurrencyCode: string; - tableReport: XerImportReport; - calendarIssues: XerCalendarIssue[]; - enumFallbacks: XerEnumFallback[]; - externalRelations: XerExternalRelation[]; -} - export interface XerReadResult extends ImportResult { /** Importtijd-metadata; externe relaties blijven hier brondata en sturen de solver niet. */ - xer: XerReaderMetadata; + xer: XerImportMetadata; } const ACTIVITY_TYPES: readonly P6ActivityType[] = [ @@ -240,17 +213,53 @@ 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 = (left.cells.parent_wbs_id ?? '').localeCompare(right.cells.parent_wbs_id ?? ''); + 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 || left.line - right.line; + 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); + } +} + /** Lees precies één niet-leeg P6-project uit de oorspronkelijke XER-bestandsbytes. */ export function readXER(bytes: Uint8Array): XerReadResult { const tables = parseXerTables(bytes); @@ -273,6 +282,35 @@ export function readXER(bytes: Uint8Array): XerReadResult { { table: 'TASK' }, ); } + const rawWbsRows = (tables.tables.get('PROJWBS')?.rows ?? []) + .filter(row => row.cells.proj_id === projectId); + const relationRows = (tables.tables.get('TASKPRED')?.rows ?? []).filter(row => { + const successorProjectId = row.cells.proj_id || projectId; + const predecessorProjectId = row.cells.pred_proj_id || successorProjectId; + return successorProjectId === projectId || predecessorProjectId === 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]; @@ -409,7 +447,7 @@ export function readXER(bytes: Uint8Array): XerReadResult { const projectHourMode = projectCalendar.workTime !== undefined; const statusDate = sourceInstant(projectRow.cells.last_recalc_date ?? '', projectHourMode); - const wbsRows = stableWbsRows(tables.tables.get('PROJWBS')?.rows ?? [], projectId); + 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 @@ -423,6 +461,7 @@ export function readXER(bytes: Uint8Array): XerReadResult { taskType: 'CONSTRUCTION', status: 'NOT_STARTED', isMilestone: false, + isSummary: true, priority: 500, parentId, childIds: [], @@ -440,7 +479,7 @@ export function readXER(bytes: Uint8Array): XerReadResult { const sequences: Sequence[] = []; const externalRelations: XerExternalRelation[] = []; - for (const row of tables.tables.get('TASKPRED')?.rows ?? []) { + 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); diff --git a/src/services/xer/xerTables.ts b/src/services/xer/xerTables.ts index cf7ae29d6..1e7d81855 100644 --- a/src/services/xer/xerTables.ts +++ b/src/services/xer/xerTables.ts @@ -25,7 +25,9 @@ export type XerImportErrorCode = | 'XER_INVALID_NUMBER_FORMAT' | 'XER_INVALID_NUMBER' | 'XER_SINGLE_PROJECT_REQUIRED' - | 'XER_EMPTY_PROJECT'; + | 'XER_EMPTY_PROJECT' + | 'XER_DUPLICATE_ID' + | 'XER_DANGLING_LOCAL_RELATION'; export class XerImportError extends Error { readonly xerCode: XerImportErrorCode; diff --git a/src/state/documentContract.ts b/src/state/documentContract.ts index 1bdce19a3..1c1b85ad6 100644 --- a/src/state/documentContract.ts +++ b/src/state/documentContract.ts @@ -9,6 +9,7 @@ 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 { ViewState } from './slices/types'; import type { Snapshot } from './snapshot'; import type { AppState } from './appStore'; @@ -74,6 +75,8 @@ export interface DocumentPayload { /** Web-opslaan-doel (browser-bestandstoegang). ALLEEN het FSA-opslaan-doel — nooit identiteit/titel (die blijft filePath: echt pad in Tauri, bestandsnaam in web). null in Tauri/fallback-web. */ fileHandle: FileSystemFileHandle | null; isDirty: boolean; + /** XER-bronmetadata per document; geen onderdeel van undo omdat bewerkingen dit niet muteren. */ + xerImportMetadata: XerImportMetadata | null; } /** Per-document projectdata + metadata om bij crash-recovery te herstellen. @@ -217,6 +220,7 @@ export const DOCUMENT_FIELDS = [ field({ key: 'filePath', get: (s) => s.filePath, set: (s, v) => { s.filePath = v; }, fresh: () => null, snapshot: 'none' }), field({ key: 'fileHandle', get: (s) => s.fileHandle, set: (s, v) => { s.fileHandle = v; }, fresh: () => null, snapshot: 'none', fromPayload: (p) => p.fileHandle ?? null }), 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 }), ]; // Compile-time volledigheidscheck: elke DocumentPayload-key MOET in DOCUMENT_FIELDS staan. Voeg je @@ -363,6 +367,7 @@ export function payloadFromImport(parsed: ImportResult, filePath: string | null) customFieldDefs: parsed.customFieldDefs ?? [], baselines: parsed.baselines ?? [], activeBaselineId: parsed.activeBaselineId ?? null, + xerImportMetadata: parsed.xer ?? null, filePath, isDirty: false, }; diff --git a/src/state/mcpTransaction.ts b/src/state/mcpTransaction.ts index 73f7a16f9..aac35e660 100644 --- a/src/state/mcpTransaction.ts +++ b/src/state/mcpTransaction.ts @@ -19,6 +19,7 @@ import type { Resource, ResourceAssignment, ResourceCurve } from '@/types/resour import type { Project } from '@/types/project'; import type { LevelingResult } from '@/engine/scheduler/ResourceLeveler'; import { clampProjectStartAnchors } from '@/engine/scheduler/projectStartAnchorClamp'; +import { isSummaryTask } from '@/utils/taskHierarchy'; /** Herintreedbaarheids-wachter: `true` zolang een `runInMcpTransaction` loopt. Los van de * `beginUndoable`-suppressievlag in transaction.ts (die stuurt de mutators aan) — deze stuurt puur @@ -678,7 +679,7 @@ export const draft = { useAppStore.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)) { @@ -731,7 +732,7 @@ export const draft = { 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/mcpValidation.ts b/src/state/mcpValidation.ts index 72c25046d..93c04dff2 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 { isSummaryTask } from '@/utils/taskHierarchy'; /** Per-item-fout: het aangesproken id + een leesbare reden (voor de per-item-rapportage van de * tool-laag). */ @@ -158,7 +159,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)` }; @@ -278,7 +279,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/relationRules.ts b/src/state/relationRules.ts index 8f8f34796..34a4ecc23 100644 --- a/src/state/relationRules.ts +++ b/src/state/relationRules.ts @@ -1,4 +1,5 @@ import type { Task } from '@/types/task'; +import { isSummaryTask as taskIsSummary } from '@/utils/taskHierarchy'; import type { Sequence, SequenceType } from '@/types/sequence'; /** @@ -45,20 +46,13 @@ export type RelationVerdict = { ok: true } | { ok: false; reason: RelationReject export type TaskLookup = (id: string) => Task | undefined; /** - * Is deze taak een verzameltaak — een taak MET subtaken? Een onbekende taak (`undefined`) is dat - * níét. Sinds het eigenaarsbesluit van 2026-08-15 heeft GEEN productiecode meer een directe - * afhankelijkheid op deze functie: `relationVerdict` leunt niet meer op dit predicaat (een - * verzameltaak-eindpunt is nu legaal, zie `isAncestorRelation` hieronder voor de regel die wél nog - * weigert), en de drie oude UI-/MCP-lezers (RelationsPanel, fileSlice, dependencyTools) lezen sinds - * dat besluit `cpmResult.droppedSequenceIds` resp. `isAncestorRelation` in plaats van dit predicaat. - * `GanttRenderer.getRelationSourceAt` dupliceert het CONCEPT `childIds.length > 0` inline (die - * renderer importeert bewust niets uit `@/state`) maar staat een verzameltaak als relatiebron - * sindsdien juist WÉL toe — dat is dus geen lockstep-lezer van deze functie. Blijft geëxporteerd - * als algemeen, headless-getest predicaat (`tests/planning/check-relation-rules.ts`); niet - * verwijderd omdat "is dit een WBS-samenvatting" een op zichzelf staand, herbruikbaar begrip is. + * Is deze taak een verzameltaak? De gedeelde semantiek staat in `utils/taskHierarchy`: een taak + * met kinderen én een expliciet gemarkeerde lege WBS-taak zijn allebei samenvattingen. Deze export + * blijft bestaan voor de state-regeltests en de bestaande state-API, maar de uitvoering delegeert + * zodat scheduler, renderer, resourcepaden en IFC exact hetzelfde begrip gebruiken. */ export function isSummaryTask(task: Task | undefined): boolean { - return (task?.childIds.length ?? 0) > 0; + return taskIsSummary(task); } /** diff --git a/src/state/slices/baselineSlice.ts b/src/state/slices/baselineSlice.ts index 24c5d7912..e3643774d 100644 --- a/src/state/slices/baselineSlice.ts +++ b/src/state/slices/baselineSlice.ts @@ -2,6 +2,7 @@ import type { Baseline } from '@/types/baseline'; import { generateId } from '@/utils/id'; import { beginUndoable, finishMutation } from '../transaction'; import type { AppSlice } from './types'; +import { isLeafTask } from '@/utils/taskHierarchy'; export interface BaselineSlice { baselines: Baseline[]; @@ -26,7 +27,7 @@ export const createBaselineSlice: AppSlice = (set) => ({ 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 29b429d04..50d93ad02 100644 --- a/src/state/slices/documentSlice.ts +++ b/src/state/slices/documentSlice.ts @@ -227,6 +227,7 @@ export const createDocumentSlice: AppSlice = (set, get) => ({ filePath: null, fileHandle: null, isDirty: true, + xerImportMetadata: src.xerImportMetadata ? deepClone(src.xerImportMetadata) : null, }; set((s) => { diff --git a/src/state/slices/fileSlice.ts b/src/state/slices/fileSlice.ts index 4c483c002..9f5a34493 100644 --- a/src/state/slices/fileSlice.ts +++ b/src/state/slices/fileSlice.ts @@ -181,6 +181,18 @@ export const createFileSlice: AppSlice = (set, get) => { const shifted = countShiftedTasks(get().tasks, recorded.times); if (shifted > 0) set((s) => { s.recordedDates = { ...recorded, shifted }; }); } + if (parsed.xer?.enumFallbacks.length) { + get().notify({ + severity: 'info', + messageKey: 'notifications.xerEnumFallback', + params: { + family: parsed.xer.enumFallbacks.map(item => item.family).join(', '), + token: parsed.xer.enumFallbacks.map(item => item.token).join(', '), + fallback: parsed.xer.enumFallbacks.map(item => item.fallback).join(', '), + }, + dedupeKey: 'xer-enum-fallback', + }); + } if (opts.fit) get().requestFitToProject(); // Issue #16: canvas op het HELE project passen. // Relaties die de solver ECHT niet kon meerekenen (eigenaarsbesluit 2026-08-15): een // verzameltaak-eindpunt op zich is sinds `expandSummaryRelations` GEEN reden meer om te diff --git a/src/state/slices/projectSlice.ts b/src/state/slices/projectSlice.ts index 883235fe9..df76722f9 100644 --- a/src/state/slices/projectSlice.ts +++ b/src/state/slices/projectSlice.ts @@ -24,6 +24,8 @@ import { freshPayload, hydratePayload } from '../documentContract'; import { emitExtensionEvent, HOST_EVENTS } from '@/services/extensionEvents'; import { clearTimephasedLossNoticeForDoc } from '../timephasedLossNotice'; import type { AppSlice } from './types'; +import { isLeafTask } from '@/utils/taskHierarchy'; +import type { XerImportMetadata } from '@/services/importTypes'; // 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'; @@ -79,6 +81,8 @@ export interface ProjectSlice { /** Web-opslaan-doel (spec §4). ALLEEN het FSA-opslaan-doel — nooit voor identiteit/titel; * die blijven bij `filePath` (echt pad in Tauri, bestandsnaam in web). `null` in Tauri/fallback-web. */ fileHandle: FileSystemFileHandle | null; + /** XER-herkomst van het actieve document; externe relaties blijven solverloze brondata. */ + xerImportMetadata: XerImportMetadata | null; setProject: (project: Partial) => void; /** Zet WBS-autonummering aan/uit; bij aanzetten wordt de hele boom direct hernummerd. */ setWbsAutoNumber: (on: boolean) => void; @@ -163,6 +167,7 @@ export const createProjectSlice: AppSlice = (set, get) => ({ isDirty: false, filePath: null, fileHandle: null, + xerImportMetadata: null, setProject: (updates) => { // T7b (plan-§9/O2-vervolg, orkestratorbesluit 2026-08-15 — optie B, ná escalatie T7 + de @@ -353,7 +358,7 @@ export const createProjectSlice: AppSlice = (set, get) => ({ // 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 leaf = tasks.filter(isLeafTask); return new CPMSolver(leaf, expandedSequences, s.calendar, s.calendars, { dataDate, progressMode: s.project.progressMode, diff --git a/src/state/slices/resourceSlice.ts b/src/state/slices/resourceSlice.ts index b47dfd0fa..1fefacb8b 100644 --- a/src/state/slices/resourceSlice.ts +++ b/src/state/slices/resourceSlice.ts @@ -6,6 +6,7 @@ import { syncProjectCalendar } from '../syncProjectCalendar'; import { clearTimephasedWindow, clearTimephasedDurationWalks } from '@/utils/taskDefaults'; import { notifyTimephasedLoss } from '../timephasedLossNotice'; import type { AppSlice } 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). */ @@ -114,7 +115,7 @@ export const createResourceSlice: AppSlice = (set, get) => ({ 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 @@ -209,7 +210,7 @@ export const createResourceSlice: AppSlice = (set, get) => ({ 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 17c906a7b..ae693b25b 100644 --- a/src/state/slices/scheduleSlice.ts +++ b/src/state/slices/scheduleSlice.ts @@ -11,6 +11,7 @@ import { import { beginUndoable, finishMutation } from '../transaction'; import { emitExtensionEvent, HOST_EVENTS } from '@/services/extensionEvents'; import type { AppSlice } from './types'; +import { isLeafTask } from '@/utils/taskHierarchy'; export interface ScheduleSlice { cpmResult: CPMResult | null; @@ -225,7 +226,7 @@ export const createScheduleSlice: AppSlice = (set, get) => ({ return { delays: {}, unresolved: {}, unresolvedReasons: {}, shifts: {}, projectEndBefore: end, projectEndAfter: end }; } // 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 diff --git a/src/state/slices/types.ts b/src/state/slices/types.ts index 4eaea56e8..322aa5d46 100644 --- a/src/state/slices/types.ts +++ b/src/state/slices/types.ts @@ -184,6 +184,9 @@ export type NotificationMessageKey = | 'notifications.xerInvalidNumber' | 'notifications.xerSingleProjectRequired' | 'notifications.xerEmptyProject' + | 'notifications.xerDuplicateId' + | 'notifications.xerDanglingLocalRelation' + | 'notifications.xerEnumFallback' | 'notifications.mppSourceScheduleNotes' | 'notifications.projectStartAnchorsClamped' | 'notifications.mppTimephasedSteeringLost'; diff --git a/src/state/slices/uiSlice.ts b/src/state/slices/uiSlice.ts index aee096d5d..a6b46f569 100644 --- a/src/state/slices/uiSlice.ts +++ b/src/state/slices/uiSlice.ts @@ -1,6 +1,7 @@ import type { UIState, AppSlice, NotifyInput } from './types'; import type { McpServerStatus } from '@/services/mcp/contracts'; import { MCP_DEFAULT_PORT, peekTheme } from '@/utils/settingsStore'; +import { isSummaryTask } from '@/utils/taskHierarchy'; export interface UiSlice { ui: UIState; @@ -323,7 +324,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. @@ -343,7 +344,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/task.ts b/src/types/task.ts index 6dd4f1e9b..2c18df236 100644 --- a/src/types/task.ts +++ b/src/types/task.ts @@ -477,8 +477,12 @@ export interface Task { * de naam vast — de XER-etappe se X7-taak meet EERST of/waar P6's suspend/resume-gedrag afwijkt * van de MSP-solversemantiek, en vult dit veld pas dan daadwerkelijk (geen enkele lezer zet het * vóór die meting). Default/afwezig ⇒ MSP-conventie, byte-identiek. */ - p6SuspendResume?: boolean; - parentId: string | null; // WBS parent + p6SuspendResume?: boolean; + /** Expliciete WBS-/samenvattingsidentiteit. `true` betekent dat de taak ook zonder kinderen een + * samenvatting blijft (bijvoorbeeld een lege PROJWBS-rij uit P6). Afwezig houdt de bestaande + * afleiding uit `childIds` intact; gebruik `isSummaryTask`/`isLeafTask` voor de semantiek. */ + isSummary?: boolean; + parentId: string | null; // WBS parent childIds: string[]; // WBS children time: TaskTime; resourceIds: string[]; 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/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/mcp/cases-taskfields.ts b/tests/mcp/cases-taskfields.ts index ec301f0c6..7193b0d1a 100644 --- a/tests/mcp/cases-taskfields.ts +++ b/tests/mcp/cases-taskfields.ts @@ -303,6 +303,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/planning/check-ext-contract.ts b/tests/planning/check-ext-contract.ts index 4bbf7a383..9ed8b7f90 100644 --- a/tests/planning/check-ext-contract.ts +++ b/tests/planning/check-ext-contract.ts @@ -83,7 +83,7 @@ const EXT_TASK_TIME_KEYS = keys()([ const EXT_TASK_KEYS = keys()([ 'id', 'name', 'description', 'wbsCode', 'taskType', '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 @@ -184,6 +184,7 @@ const VOL_TASK = { timephasedDurationWalks: [{ anchor: '2026-06-01T08:00', resourceCalendarId: 'cal2', workMinutes: 480 }], parentId: 'p1', childIds: ['c1', 'c2'], + isSummary: true, time: VOL_TIME, resourceIds: ['r1'], color: '#abcdef', diff --git a/tests/planning/check-ifc-roundtrip.ts b/tests/planning/check-ifc-roundtrip.ts index a09bce911..d262363a7 100644 --- a/tests/planning/check-ifc-roundtrip.ts +++ b/tests/planning/check-ifc-roundtrip.ts @@ -250,6 +250,7 @@ const TM = { // X0 (XER-etappeplan, 2026-08-20) — drie nieuwe .xer-importvelden, nog GEEN IFC-pset (zie // TASK_CANON hieronder: skip, reden X9). Alleen hier gevuld voor de `Required`-volledigheid. p6DurationType: 'DT_FixedDUR2', p6ActivityType: 'TT_Rsrc', 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) @@ -545,6 +546,7 @@ const TASK_CANON = { p6DurationType: { skip: 'nog geen IFC-pset — typecontract-only, X0 (XER-etappeplan); wiring volgt in X9' }, p6ActivityType: { skip: 'nog geen IFC-pset — typecontract-only, X0 (XER-etappeplan); wiring volgt in X9' }, p6SuspendResume: { skip: 'nog geen IFC-pset — typecontract-only, X0 (XER-etappeplan); wiring volgt in X9' }, + 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) }, diff --git a/tests/planning/check-xer-byte-paths.ts b/tests/planning/check-xer-byte-paths.ts index 465d7ec67..cfd4e8990 100644 --- a/tests/planning/check-xer-byte-paths.ts +++ b/tests/planning/check-xer-byte-paths.ts @@ -22,8 +22,11 @@ function fixture(projectName: string): string { '%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', + '%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'); } @@ -157,6 +160,39 @@ for (const [encoding, bytes] of encodings) { }, { 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), +// en een enumterugval wordt met familie/token/fallback zichtbaar gemeld. +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 fallbackNotice = loadedState.ui.notifications.find( + notice => notice.messageKey === ('notifications.xerEnumFallback' as typeof notice.messageKey), +); +eq('documentroute: enumterugval toont familie, token en fallback', fallbackNotice?.params, { + family: 'activityType', token: 'TT_Onbekend', fallback: 'TT_Task', +}); +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']); + // 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]); @@ -199,6 +235,31 @@ eq('fileSlice recents: UTF-16BE-XER laadt zonder opslagdoel', { 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}`); diff --git a/tests/planning/check-xer-reader.ts b/tests/planning/check-xer-reader.ts index 7eb443bc7..42879f3b4 100644 --- a/tests/planning/check-xer-reader.ts +++ b/tests/planning/check-xer-reader.ts @@ -1,5 +1,11 @@ import { readXER, type XerReadResult } from '@/services/xer/xerReader'; 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 { readIFC } from '@/services/ifc/ifcReader'; const diffs: string[] = []; let checks = 0; @@ -211,6 +217,168 @@ typedError('15 project zonder TASK wordt expliciet geweigerd', [ ok('16 XER-resultaat levert lege resourcevelden binnen het gedeelde importcontract', result.resources.length === 0 && result.assignments.length === 0); +// 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}`); diff --git a/tests/planning/check-xer-registry.ts b/tests/planning/check-xer-registry.ts index f01d673f6..af4f41a13 100644 --- a/tests/planning/check-xer-registry.ts +++ b/tests/planning/check-xer-registry.ts @@ -113,6 +113,8 @@ const errorKeys: ReadonlyArray = [ ['XER_INVALID_NUMBER', 'notifications.xerInvalidNumber'], ['XER_SINGLE_PROJECT_REQUIRED', 'notifications.xerSingleProjectRequired'], ['XER_EMPTY_PROJECT', 'notifications.xerEmptyProject'], + ['XER_DUPLICATE_ID', 'notifications.xerDuplicateId'], + ['XER_DANGLING_LOCAL_RELATION', 'notifications.xerDanglingLocalRelation'], ]; eq('6 iedere typed XER-fout heeft een eigen meldingssleutel', errorKeys.map(([code]) => importErrorMessageKey(new XerImportError(code, code))), From 8a7c75c40dbf6c22bfbf7a1bf32944fceb1d60bf Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:36:47 +0200 Subject: [PATCH 021/171] fix: behoud isSummary in publieke taakpaden --- src/extensions/extMappers.ts | 2 ++ src/extensions/extTypes.ts | 3 ++ src/state/mcpTransaction.ts | 1 + src/state/slices/taskSlice.ts | 1 + src/types/task.ts | 5 +-- tests/mcp/cases-draft.ts | 31 ++++++++++++++++ tests/planning/check-ext-contract.ts | 52 +++++++++++++++++++++++++-- tests/planning/check-store-factory.ts | 26 ++++++++++++++ 8 files changed, 117 insertions(+), 4 deletions(-) diff --git a/src/extensions/extMappers.ts b/src/extensions/extMappers.ts index 2201cc520..20795affa 100644 --- a/src/extensions/extMappers.ts +++ b/src/extensions/extMappers.ts @@ -428,6 +428,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; @@ -530,6 +531,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 diff --git a/src/extensions/extTypes.ts b/src/extensions/extTypes.ts index 3f064565f..fbd05b2e6 100644 --- a/src/extensions/extTypes.ts +++ b/src/extensions/extTypes.ts @@ -233,6 +233,9 @@ 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[]; diff --git a/src/state/mcpTransaction.ts b/src/state/mcpTransaction.ts index aac35e660..b330a01e0 100644 --- a/src/state/mcpTransaction.ts +++ b/src/state/mcpTransaction.ts @@ -243,6 +243,7 @@ export const draft = { 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). diff --git a/src/state/slices/taskSlice.ts b/src/state/slices/taskSlice.ts index ae83535c0..aefefbc29 100644 --- a/src/state/slices/taskSlice.ts +++ b/src/state/slices/taskSlice.ts @@ -313,6 +313,7 @@ export const createTaskSlice: AppSlice = (set, get) => ({ 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 diff --git a/src/types/task.ts b/src/types/task.ts index 2c18df236..236942f01 100644 --- a/src/types/task.ts +++ b/src/types/task.ts @@ -479,8 +479,9 @@ export interface Task { * vóór die meting). Default/afwezig ⇒ MSP-conventie, byte-identiek. */ p6SuspendResume?: boolean; /** Expliciete WBS-/samenvattingsidentiteit. `true` betekent dat de taak ook zonder kinderen een - * samenvatting blijft (bijvoorbeeld een lege PROJWBS-rij uit P6). Afwezig houdt de bestaande - * afleiding uit `childIds` intact; gebruik `isSummaryTask`/`isLeafTask` voor de semantiek. */ + * 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 diff --git a/tests/mcp/cases-draft.ts b/tests/mcp/cases-draft.ts index 1f87da749..068f4df5d 100644 --- a/tests/mcp/cases-draft.ts +++ b/tests/mcp/cases-draft.ts @@ -61,6 +61,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/planning/check-ext-contract.ts b/tests/planning/check-ext-contract.ts index 9ed8b7f90..762c377c3 100644 --- a/tests/planning/check-ext-contract.ts +++ b/tests/planning/check-ext-contract.ts @@ -42,8 +42,12 @@ import { toExtSequence, fromExtSequence, toExtResource, fromExtResource, toExtAssignment, fromExtAssignment, + fromExtTaskInput, fromExtTaskUpdates, fromExtRibbonTab, fromExtFontProvider, } from '@/extensions/extMappers'; +import { getExtensionSdk } from '@/extensions/sdk'; +import { createExtensionApi } from '@/extensions/extensionApi'; +import { useAppStore } from '@/state/appStore'; const diffs: string[] = []; let checks = 0; @@ -438,12 +442,56 @@ 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', []); + 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. 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-store-factory.ts b/tests/planning/check-store-factory.ts index 342f1491d..246d60b13 100644 --- a/tests/planning/check-store-factory.ts +++ b/tests/planning/check-store-factory.ts @@ -145,6 +145,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`); From 035be36ffd54cd5adceba76a5ccbcc582b2f33db Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:36:23 +0200 Subject: [PATCH 022/171] Voeg XER-meerproject- en baselinelogica toe --- src/services/xer/xerMultiProject.ts | 302 ++++++++ tests/planning/check-xer-multi-project.ts | 869 ++++++++++++++++++++++ tests/planning/run.sh | 6 + 3 files changed, 1177 insertions(+) create mode 100644 src/services/xer/xerMultiProject.ts create mode 100644 tests/planning/check-xer-multi-project.ts diff --git a/src/services/xer/xerMultiProject.ts b/src/services/xer/xerMultiProject.ts new file mode 100644 index 000000000..fd4fb350e --- /dev/null +++ b/src/services/xer/xerMultiProject.ts @@ -0,0 +1,302 @@ +import type { XerReadResult } from './xerReader'; +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 type XerBaselineFallbackReason = + | 'self-reference' + | 'cycle' + | 'all-projects-baselines'; + +export interface XerMultiProjectDocument { + projectId: string; + result: XerReadResult; +} + +export interface XerDocumentExternalLink { + id: string; + predecessor: { projectId: string; taskId: string }; + successor: { projectId: string; taskId: string }; + type: 'FS' | 'SS' | 'FF' | 'SF'; + lagMinutes: number; +} + +export interface XerMultiProjectReport { + projectsSeen: number; + documentsOpened: number; + emptyProjectsSkipped: number; + baselineProjectsExcluded: number; + baselinesMaterialized: number; + danglingBaselineReferences: number; + externalLinksPreserved: number; + baselineExclusionReverted: boolean; + baselineFallbackReasons: XerBaselineFallbackReason[]; +} + +export interface XerMultiProjectImport { + documents: 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[]; +} + +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. + * + * De 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 concrete 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 target = projectRow.cells.sum_base_proj_id?.trim() ?? ''; + if (target && projectIds.has(target)) targetByProject.set(projectRow.cells.proj_id, 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, + 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}`, + 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. + structuredClone(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; + } + } + + return { + documents, + activeProjectId, + externalLinks, + report: { + 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, + }, + }; +} diff --git a/tests/planning/check-xer-multi-project.ts b/tests/planning/check-xer-multi-project.ts new file mode 100644 index 000000000..92fe7d7eb --- /dev/null +++ b/tests/planning/check-xer-multi-project.ts @@ -0,0 +1,869 @@ +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 { readIFC } from '@/services/ifc/ifcReader'; + +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: [] }, + 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: '', + }, + 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: [], + externalRelations: [], + }, + }; +} + +// 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, + start: item.start, + finish: item.finish, + })), [ + { taskId: 'M-SAME-ID', start: '2026-04-01', finish: '2026-04-02' }, + { taskId: 'M-CODE', start: '2026-04-03', finish: '2026-04-04' }, + { taskId: 'B-OLD', start: '2026-04-05', finish: '2026-04-06' }, + ]); +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'], +}); + +// Breuk die dit vangt: alleen expliciete zelfverwijzingen/cycli als vangrail behandelen. Ook een +// acyclische graaf kan door verwijzingen vanuit lege eigenaren alle niet-lege projecten uitsluiten; +// dan moet de volledige uitsluiting terug zodat de te openen verzameling niet leeg wordt. +const allExcludedTables = tables([ + row(2, { proj_id: 'P-OPEN-A' }), + row(3, { proj_id: 'P-OPEN-B' }), + row(4, { proj_id: 'P-EMPTY-OWNER-A', sum_base_proj_id: 'P-OPEN-A' }), + row(5, { proj_id: 'P-EMPTY-OWNER-B', sum_base_proj_id: 'P-OPEN-B' }), +], [ + row(8, { proj_id: 'P-OPEN-A', task_id: 'OA-1', task_code: 'OA-1' }), + row(9, { proj_id: 'P-OPEN-B', task_id: 'OB-1', task_code: 'OB-1' }), +]); +const allExcludedGuard = assembleXerMultiProjectImport( + allExcludedTables, + projectId => mappedProject(projectId, 1), +); +eq('9a acyclische uitsluiting van alle niet-lege projecten wordt volledig teruggenomen', { + documents: allExcludedGuard.documents.map(document => document.projectId), + baselines: allExcludedGuard.report.baselinesMaterialized, + reverted: allExcludedGuard.report.baselineExclusionReverted, + reasons: allExcludedGuard.report.baselineFallbackReasons, +}, { + documents: ['P-OPEN-A', 'P-OPEN-B'], + baselines: 0, + reverted: true, + reasons: ['all-projects-baselines'], +}); + +// 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], +}); +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: '', + }, + calendar, + tasks, + sequences: [], + resources: [], + assignments: [], + xer: { + defaultCurrencyCode: source.header.defaultCurrencyCode, + tableReport: source.report, + calendarIssues: [], + enumFallbacks: [], + externalRelations: [], + }, + }; +} + +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/run.sh b/tests/planning/run.sh index 907aea896..08c534480 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -235,6 +235,12 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then XERREADERCHECK="$DIR/.xer-reader.mjs" if bundle_check "$DIR/check-xer-reader.ts" "$XERREADERCHECK"; then node "$XERREADERCHECK" || 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 + # X4a-registercontract: .xer is lazy en binair, behoudt CP1252/UTF-16-BOM-bytes en wordt nooit # een opslagdoel. XERREGISTRYCHECK="$DIR/.xer-registry.mjs" From 6446fee86e280b6da44cc9545fe8a761c66181f0 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:14:06 +0200 Subject: [PATCH 023/171] Bedraad XER-meerprojectimport --- src/services/formatRegistry.ts | 8 +- src/services/importTypes.ts | 27 +++ src/services/mcp/tools/fileTools.ts | 10 +- src/services/xer/xerMultiProject.ts | 8 +- src/services/xer/xerReader.ts | 45 ++++- src/state/slices/fileSlice.ts | 55 ++++-- src/utils/devBridge.ts | 3 +- tests/planning/check-xer-byte-paths.ts | 9 +- tests/planning/check-xer-open-wiring.ts | 188 +++++++++++++++++++ tests/planning/check-xer-product-fidelity.ts | 6 +- tests/planning/check-xer-reader.ts | 9 +- tests/planning/check-xer-registry.ts | 3 +- tests/planning/run.sh | 5 + 13 files changed, 324 insertions(+), 52 deletions(-) create mode 100644 tests/planning/check-xer-open-wiring.ts diff --git a/src/services/formatRegistry.ts b/src/services/formatRegistry.ts index 84d3f48e7..d133c1ccb 100644 --- a/src/services/formatRegistry.ts +++ b/src/services/formatRegistry.ts @@ -7,7 +7,7 @@ import { readIFC } from '@/services/ifc/ifcReader'; 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, OpenedImport } from '@/services/importTypes'; import type { FileFilter, FileRef } from '@/services/fileAccess'; import { extensionOf } from '@/utils/filePath'; @@ -28,14 +28,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(' { +export function parseOpenedFile(input: FormatInput, labels?: ImportLabels): Promise { return readFormatForFile(input.name).read(input, labels); } diff --git a/src/services/importTypes.ts b/src/services/importTypes.ts index 2559a9ab9..f98a82a87 100644 --- a/src/services/importTypes.ts +++ b/src/services/importTypes.ts @@ -159,3 +159,30 @@ export interface ImportResult { /** Alleen XER: bronmetadata en solverloze cross-projectrelaties voor het geladen document. */ xer?: XerImportMetadata; } + +/** + * 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/mcp/tools/fileTools.ts b/src/services/mcp/tools/fileTools.ts index 82f2bb92f..c6a449eff 100644 --- a/src/services/mcp/tools/fileTools.ts +++ b/src/services/mcp/tools/fileTools.ts @@ -36,7 +36,7 @@ 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'; @@ -359,7 +359,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 @@ -369,11 +369,9 @@ 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. + // Exact het bestaande laadpatroon (fileSlice.openFile), inclusief X4b's meervoudige XER-vorm. const store = useAppStore.getState(); const reusedActiveTab = isActivePristine(store); - if (!reusedActiveTab) store.newDocument(); 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 @@ -384,7 +382,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`. - useAppStore.getState().applyLoadedProject(parsed, { + useAppStore.getState().applyOpenedImport(parsed, { filePath: readFormat.canBeSaveTarget ? path : null, fileHandle: null, recompute: true, diff --git a/src/services/xer/xerMultiProject.ts b/src/services/xer/xerMultiProject.ts index fd4fb350e..8876832e8 100644 --- a/src/services/xer/xerMultiProject.ts +++ b/src/services/xer/xerMultiProject.ts @@ -1,4 +1,5 @@ import type { XerReadResult } from './xerReader'; +import type { MultiDocumentImport } from '@/services/importTypes'; import { XerImportError, type XerRow, type XerTables } from './xerTables'; import type { Baseline } from '@/types/baseline'; import type { Task } from '@/types/task'; @@ -34,7 +35,7 @@ export interface XerMultiProjectReport { baselineFallbackReasons: XerBaselineFallbackReason[]; } -export interface XerMultiProjectImport { +export interface XerMultiProjectImport extends MultiDocumentImport { documents: XerMultiProjectDocument[]; activeProjectId: string | null; /** Cross-projectbrondata tussen geopende documenten; bewust geen Sequence/Task.externalLinks. */ @@ -283,6 +284,11 @@ export function assembleXerMultiProjectImport( } return { + kind: 'multi-document', + results: documents.map(document => document.result), + activeDocumentIndex: activeProjectId === null + ? -1 + : documents.findIndex(document => document.projectId === activeProjectId), documents, activeProjectId, externalLinks, diff --git a/src/services/xer/xerReader.ts b/src/services/xer/xerReader.ts index e6b485d54..9e0c41f8f 100644 --- a/src/services/xer/xerReader.ts +++ b/src/services/xer/xerReader.ts @@ -24,6 +24,7 @@ import type { import { createDefaultTaskTime } from '@/utils/taskDefaults'; import { formatInstant, parseInstant } from '@/utils/dateUtils'; import { readXerCalendars } from './xerCalendarData'; +import { assembleXerMultiProjectImport, type XerMultiProjectImport } from './xerMultiProject'; import { parseXerNumber, parseXerTables, @@ -37,6 +38,9 @@ export interface XerReadResult extends ImportResult { xer: XerImportMetadata; } +/** X4b: één XER kan nu één payload óf een geordende verzameling documentpayloads opleveren. */ +export type XerOpenResult = XerReadResult | XerMultiProjectImport; + const ACTIVITY_TYPES: readonly P6ActivityType[] = [ 'TT_Task', 'TT_Rsrc', 'TT_LOE', 'TT_Mile', 'TT_FinMile', 'TT_WBS', ]; @@ -260,19 +264,18 @@ function assertUniqueId( } } -/** Lees precies één niet-leeg P6-project uit de oorspronkelijke XER-bestandsbytes. */ -export function readXER(bytes: Uint8Array): XerReadResult { - const tables = parseXerTables(bytes); - const projectRows = tables.tables.get('PROJECT')?.rows ?? []; - if (projectRows.length !== 1) { +/** 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, projectId: string): XerReadResult { + const projectRow = (tables.tables.get('PROJECT')?.rows ?? []) + .find(row => row.cells.proj_id === projectId); + if (!projectRow) { throw new XerImportError( - 'XER_SINGLE_PROJECT_REQUIRED', - `Deze importstap verwacht precies één PROJECT-rij; gevonden: ${projectRows.length}.`, - { table: 'PROJECT', lines: projectRows.map(row => row.line) }, + 'XER_MISSING_REQUIRED_VALUE', + `PROJECT.proj_id '${projectId}' bestaat niet in de getokenized XER-invoer.`, + { table: 'PROJECT', field: 'proj_id' }, ); } - const projectRow = projectRows[0]; - const projectId = projectRow.cells.proj_id; const activityRows = (tables.tables.get('TASK')?.rows ?? []) .filter(row => row.cells.proj_id === projectId); if (activityRows.length === 0) { @@ -550,3 +553,25 @@ export function readXER(bytes: Uint8Array): XerReadResult { }, }; } + +/** + * Lees de oorspronkelijke XER-bytes. Eén PROJECT behoudt de byte-identieke X4a-returnvorm; + * meerdere PROJECT-rijen waaieren via X4b 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 projectRows = tables.tables.get('PROJECT')?.rows ?? []; + if (projectRows.length === 1) return readXerProject(tables, projectRows[0].cells.proj_id); + + const multi = assembleXerMultiProjectImport( + tables, + projectId => readXerProject(tables, projectId), + ); + if (multi.results.length > 0) return multi; + throw new XerImportError( + 'XER_EMPTY_PROJECT', + 'Geen enkel XER-project bevat activiteiten om te openen.', + { table: 'TASK' }, + ); +} diff --git a/src/state/slices/fileSlice.ts b/src/state/slices/fileSlice.ts index 9f5a34493..497a3daf0 100644 --- a/src/state/slices/fileSlice.ts +++ b/src/state/slices/fileSlice.ts @@ -11,7 +11,7 @@ import type { AppSlice } 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 { activeImportResult, isMultiDocumentImport, type ImportLabels, type ImportResult, type OpenedImport } from '@/services/importTypes'; import { hydratePayload, payloadFromImport } from '../documentContract'; import { captureRecordedDates, countShiftedTasks } from '@/engine/scheduler/recordedDates'; import { buildWriteIFCInput, sameIFCSource } from '../ifcSaveInput'; @@ -104,6 +104,12 @@ export interface FileSlice { * 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) => void; } export const createFileSlice: AppSlice = (set, get) => { @@ -242,16 +248,34 @@ export const createFileSlice: AppSlice = (set, get) => { }); }, + applyOpenedImport: (parsed, opts) => { + const results = isMultiDocumentImport(parsed) ? parsed.results : [parsed]; + if (results.length === 0) throw new Error('Openroute ontving geen projectdocumenten'); + + const openedDocumentIds: string[] = []; + 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); + get().applyLoadedProject(result, opts); + // De open-grens hoort bij ieder document, niet alleen bij het document dat uiteindelijk + // actief blijft. Zo blijft koppeling-/librarygedrag gelijk aan herhaald enkelvoudig openen. + get().runOpenBoundary(); + } + + const activeIndex = isMultiDocumentImport(parsed) ? parsed.activeDocumentIndex : 0; + const activeId = openedDocumentIds[activeIndex]; + if (activeId && activeId !== get().activeDocumentId) get().switchDocument(activeId); + }, + 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) @@ -260,9 +284,9 @@ export const createFileSlice: AppSlice = (set, get) => { // 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, @@ -271,10 +295,6 @@ export const createFileSlice: AppSlice = (set, get) => { linkedOpen: true, }); - // Grens 1 (spec §3, plan-eis 4): ná VOLLEDIGE hydratatie — behind stil verversen, deviated - // markeren/vragen. Nooit tijdens de hydratatie zelf. - get().runOpenBoundary(); - // Recents: elke herbruikbare ref (Tauri-pad óf Chromium-handle) — óók bij een niet-IFC // bronformaat: heropenen via recents moet blijven werken, alleen het OPSLAGDOEL wordt niet // gezet (zie `target` hierboven). @@ -462,7 +482,7 @@ export const createFileSlice: AppSlice = (set, get) => { 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, @@ -579,14 +599,12 @@ export const createFileSlice: AppSlice = (set, get) => { 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, @@ -595,9 +613,6 @@ export const createFileSlice: AppSlice = (set, get) => { linkedOpen: true, }); - // Grens 1 (idem openFile): ná hydratatie de openings-check draaien. - get().runOpenBoundary(); - // MRU verversen: het net-geopende bestand naar boven (óók bij een niet-IFC bronformaat — // alleen het opslagdoel blijft leeg, zie `target` hierboven). await pushRecent(entry.ref, entry.name); diff --git a/src/utils/devBridge.ts b/src/utils/devBridge.ts index f38bd66e6..bf2aba0c2 100644 --- a/src/utils/devBridge.ts +++ b/src/utils/devBridge.ts @@ -4,6 +4,7 @@ import { writeIFC } from '@/services/ifc/ifcWriter'; import { buildWriteIFCInput } from '@/state/ifcSaveInput'; import { readIFC } from '@/services/ifc/ifcReader'; import { parseOpenedFile, readFormatInput, type FormatIO } from '@/services/formatRegistry'; +import { activeImportResult } from '@/services/importTypes'; import { enableExtension, disableExtension, removeExtension, saveExtensionToDb, installFromZipBlob } from '@/extensions'; import type { InstallOutcome } from '@/extensions'; import type { ExtensionManifest, InstalledExtension } from '@/extensions/types'; @@ -82,7 +83,7 @@ async function saveToPath(path: string) { export async function openFromPathWithIO(path: string, io: FormatIO) { const input = await readFormatInput(path, io); const parsed = await parseOpenedFile(input); - useAppStore.getState().loadState(parsed); + useAppStore.getState().loadState(activeImportResult(parsed)); return { path, ...counts(useAppStore.getState()) }; } diff --git a/tests/planning/check-xer-byte-paths.ts b/tests/planning/check-xer-byte-paths.ts index cfd4e8990..73875d283 100644 --- a/tests/planning/check-xer-byte-paths.ts +++ b/tests/planning/check-xer-byte-paths.ts @@ -1,6 +1,7 @@ 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'; const diffs: string[] = []; let checks = 0; @@ -102,11 +103,11 @@ for (const [encoding, bytes] of encodings) { webTextCalls = 0; webArrayCalls = 0; const webOpened = await openFileDialogWeb(openDialogFilters(), { binaryExtensions: binaryExtensions() }); - const webParsed = await parseOpenedFile({ + 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, @@ -121,7 +122,7 @@ for (const [encoding, bytes] of encodings) { readTextFile: async () => { tauriCalls.push('text'); return 'verboden'; }, readFile: async () => { tauriCalls.push('bytes'); return bytes; }, }); - const tauriParsed = await parseOpenedFile(tauriOpened); + const tauriParsed = activeImportResult(await parseOpenedFile(tauriOpened)); eq(`Tauri ${encoding}: plugin-fs-bytes blijven ongewijzigd`, { name: tauriParsed.project.name, calls: tauriCalls, @@ -139,7 +140,7 @@ for (const [encoding, bytes] of encodings) { }), } as unknown as FileSystemFileHandle; const recentBytes = await readBytesFromRefWeb({ kind: 'handle', handle }); - const recentParsed = await parseOpenedFile({ name: 'route.xer', bytes: recentBytes ?? undefined }); + const recentParsed = activeImportResult(await parseOpenedFile({ name: 'route.xer', bytes: recentBytes ?? undefined })); eq(`recents ${encoding}: handle-bytepad bereikt de XER-reader`, recentParsed.project.name, 'Café €'); } diff --git a/tests/planning/check-xer-open-wiring.ts b/tests/planning/check-xer-open-wiring.ts new file mode 100644 index 000000000..35a211ab2 --- /dev/null +++ b/tests/planning/check-xer-open-wiring.ts @@ -0,0 +1,188 @@ +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 { readIFC } from '@/services/ifc/ifcReader'; +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', + '%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: 0, + 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 corpusRoot = process.env.OPS_XER_CORPUS; +if (corpusRoot && existsSync(corpusRoot)) { + const openPublicXer = 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 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}`); +} 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-product-fidelity.ts b/tests/planning/check-xer-product-fidelity.ts index f5dc3afd4..4b20acce7 100644 --- a/tests/planning/check-xer-product-fidelity.ts +++ b/tests/planning/check-xer-product-fidelity.ts @@ -3,6 +3,7 @@ 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 } from './xerGroundTruth'; import { measureXerFidelity, type XerSolvedProject } from './xerFidelity'; @@ -27,7 +28,7 @@ function eq(label: string, got: unknown, want: unknown): void { } function solved(bytes: Uint8Array): XerSolvedProject { - const imported = readXER(bytes); + const imported = activeImportResult(readXER(bytes)); const cpm = solveProject({ tasks: imported.tasks, sequences: imported.sequences, @@ -114,7 +115,8 @@ if (!root) { 'crawl-xer/MER-1-2026_epc.xer': 119, } as const; for (const [label, expectedBareRelations] of Object.entries(bareRelationPins)) { - const imported = readXER(readFileSync(join(root, label))); + 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, diff --git a/tests/planning/check-xer-reader.ts b/tests/planning/check-xer-reader.ts index 42879f3b4..c3694109b 100644 --- a/tests/planning/check-xer-reader.ts +++ b/tests/planning/check-xer-reader.ts @@ -1,4 +1,5 @@ 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'; @@ -26,7 +27,9 @@ function bytes(lines: readonly string[]): Uint8Array { } function read(lines: readonly string[]): XerReadResult { - return readXER(bytes(lines)); + const parsed = readXER(bytes(lines)); + if (isMultiDocumentImport(parsed)) throw new Error('Enkelprojectfixture gaf een meervoudige import terug'); + return parsed; } const fixture = [ @@ -197,14 +200,14 @@ function typedError(label: string, lines: readonly string[], code: string): void eq(label, got, code); } -typedError('14 meer dan één PROJECT wordt begrensd geweigerd', [ +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_SINGLE_PROJECT_REQUIRED'); +], 'XER_EMPTY_PROJECT'); typedError('15 project zonder TASK wordt expliciet geweigerd', [ 'ERMHDR\t23.12\t2026-01-01\t\t\t\t\t\tUSD', diff --git a/tests/planning/check-xer-registry.ts b/tests/planning/check-xer-registry.ts index af4f41a13..031d40b0d 100644 --- a/tests/planning/check-xer-registry.ts +++ b/tests/planning/check-xer-registry.ts @@ -9,6 +9,7 @@ import { saveTargetFor, } from '@/services/formatRegistry'; import { XerImportError, type XerImportErrorCode } from '@/services/xer/xerTables'; +import { activeImportResult } from '@/services/importTypes'; const diffs: string[] = []; let checks = 0; @@ -97,7 +98,7 @@ const encoded = [ ['UTF-16BE BOM', utf16(fixture('Café €'), false)], ] as const; for (const [label, input] of encoded) { - const parsed = await parseOpenedFile({ name: 'bron.xer', bytes: input }); + const parsed = activeImportResult(await parseOpenedFile({ name: 'bron.xer', bytes: input })); eq(`5 ${label} bereikt de lazy reader als originele bytes`, parsed.project.name, 'Café €'); } diff --git a/tests/planning/run.sh b/tests/planning/run.sh index 08c534480..f490a88a2 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -241,6 +241,11 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then 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 + # X4a-registercontract: .xer is lazy en binair, behoudt CP1252/UTF-16-BOM-bytes en wordt nooit # een opslagdoel. XERREGISTRYCHECK="$DIR/.xer-registry.mjs" From 2006919f8b33fc7c5f12f36f4e211aa355ef4f86 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:32:47 +0200 Subject: [PATCH 024/171] Voeg XER SCHEDOPTIONS-kern toe --- src/engine/scheduler/CPMSolver.ts | 68 +- src/engine/scheduler/relationMath.ts | 75 +- src/engine/scheduler/scheduleAnalysis.ts | 13 + src/services/xer/xerScheduleOptions.ts | 274 + src/types/project.ts | 14 + tests/planning/check-ifc-roundtrip.ts | 4 + .../check-xer-schedule-options-corpus.ts | 578 ++ tests/planning/check-xer-schedule-options.ts | 709 +++ tests/planning/run.sh | 13 + .../xer-schedoptions-blast-radius.json | 4690 +++++++++++++++++ 10 files changed, 6400 insertions(+), 38 deletions(-) create mode 100644 src/services/xer/xerScheduleOptions.ts create mode 100644 tests/planning/check-xer-schedule-options-corpus.ts create mode 100644 tests/planning/check-xer-schedule-options.ts create mode 100644 tests/planning/xer-schedoptions-blast-radius.json diff --git a/src/engine/scheduler/CPMSolver.ts b/src/engine/scheduler/CPMSolver.ts index ebaf0d83f..cff566211 100644 --- a/src/engine/scheduler/CPMSolver.ts +++ b/src/engine/scheduler/CPMSolver.ts @@ -72,8 +72,8 @@ export interface CPMResult { 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 @@ -159,6 +159,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. @@ -194,6 +216,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 @@ -556,6 +579,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), @@ -1452,6 +1484,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; } @@ -2500,11 +2538,21 @@ export class CPMSolver { // 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) || []; + // P6 houdt een voltooide activiteit op haar geregistreerde venster, ook aan de late zijde. + // Brongebonden achter een expliciete vlag: niet-XER-projecten behouden hun bestaande gedrag. + if (preserveActualDates && task.time.actualFinish && task.time.completion >= 1) { + const ed = earlyDates.get(taskId)!; + 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). // De gewone min-combinatie wordt overgeslagen. @@ -2561,11 +2609,16 @@ export class CPMSolver { // 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 lateFinish = this.options.schedulingOptions?.useProjectEndDateForFloat + ? this.snapOnOrBefore(predCal, projectEnd) + : 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. + if (preserveActualDates && succTask.time.actualFinish && succTask.time.completion >= 1) 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 @@ -2606,7 +2659,14 @@ export class CPMSolver { // Late-zijde datum-constraints + deadline (fase 2.3) als extra bovengrens. lateFinish = this.applyBackwardBound(task, lateFinish, predCal); - const lateStart = this.subDuration(predCal, lateFinish, task, earlyDates.get(taskId)?.es ?? null); + const computedLateStart = 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. + const lateStart = preserveActualDates && task.time.actualStart && task.time.completion < 1 + ? new Date(earlyDates.get(taskId)!.es.getTime()) + : computedLateStart; results.set(taskId, { ls: lateStart, lf: lateFinish }); } diff --git a/src/engine/scheduler/relationMath.ts b/src/engine/scheduler/relationMath.ts index 34bd220ac..36a3d72e5 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'; @@ -173,6 +172,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). */ @@ -325,13 +326,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 +399,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 +454,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 +481,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 +515,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 +528,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 +547,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; @@ -581,6 +583,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 +627,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 +646,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 +662,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; @@ -704,12 +707,13 @@ function forwardHour( 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 +797,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 +810,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 +828,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 +843,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 +853,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); @@ -875,8 +881,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 +912,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 +942,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/scheduleAnalysis.ts b/src/engine/scheduler/scheduleAnalysis.ts index f686769d6..ceefc3550 100644 --- a/src/engine/scheduler/scheduleAnalysis.ts +++ b/src/engine/scheduler/scheduleAnalysis.ts @@ -240,6 +240,19 @@ export function computeScheduleResults(input: ScheduleAnalysisInput): CPMResult tf = 0; freeFloat = 0; } + // P6/XER bewaart bij voltooid werk geen floatwaarde: de actuals zijn feiten, geen nog + // verschuifbaar venster. Dit voorkomt ook een kunstmatige negatieve FF wanneer een latere + // out-of-sequence-actual vóór de historische relatiegrens ligt. Alleen de expliciete + // bronvlag activeert dit; de algemene analyse-default blijft ongewijzigd. + if (completed && so?.preserveActualDatesInBackwardPass === true) { + tf = 0; + freeFloat = 0; + } + // 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; // 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). diff --git a/src/services/xer/xerScheduleOptions.ts b/src/services/xer/xerScheduleOptions.ts new file mode 100644 index 000000000..e1adad97c --- /dev/null +++ b/src/services/xer/xerScheduleOptions.ts @@ -0,0 +1,274 @@ +/** + * Pure XER-SCHEDOPTIONS-afleiding. Deze module bedraadt zichzelf bewust niet in `xerReader`: de + * seriële importnaad volgt pas nadat de parallelle XER-kernbanen zijn samengebracht. + * + * 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 { parseXerNumber, type XerRow, type XerTables } from './xerTables'; + +export interface XerScheduleOptionFallback { + field: string; + token: string; + fallback: string; + line: number; +} + +export interface XerScheduleOptionsResult { + source: 'schedoptions' | 'xer-defaults'; + progressMode: ProgressMode; + schedulingOptions: SchedulingOptions; + fallbacks: XerScheduleOptionFallback[]; +} + +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: { + lagCalendar: 'predecessor', + criticalDefinition: { mode: 'totalFloat', threshold: 0 }, + totalFloatMode: 'finish', + makeOpenEndedCritical: false, + useProjectEndDateForFloat: true, + useExpectedFinishDates: true, + preserveActualDatesInBackwardPass: true, + clampNegativeFreeFloat: 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 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( + tables: XerTables, + projectId: string, + hoursPerDay: number, + fallbacks: XerScheduleOptionFallback[], +): SchedulingOptions['criticalDefinition'] { + const row = tables.tables.get('PROJECT')?.rows.find(item => item.cells.proj_id?.trim() === projectId); + 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 ?? '', tables.numberFormat) ?? 0; + return { mode: 'totalFloat', threshold: thresholdHours / hoursPerDay }; +} + +function scheduleRow(tables: XerTables, projectId: string): XerRow | undefined { + return tables.tables.get('SCHEDOPTIONS')?.rows.find(row => row.cells.proj_id?.trim() === projectId); +} + +export function deriveXerScheduleOptions( + tables: XerTables, + projectId: string, + context: { hoursPerDay?: number; taskCount?: number } = {}, +): XerScheduleOptionsResult { + const defaults = freshDefaults(); + const fallbacks: XerScheduleOptionFallback[] = []; + const hoursPerDay = context.hoursPerDay && context.hoursPerDay > 0 ? context.hoursPerDay : 8; + defaults.schedulingOptions.criticalDefinition = projectCriticalDefinition( + tables, + projectId, + hoursPerDay, + fallbacks, + ); + + const row = scheduleRow(tables, projectId); + if (!row) { + return { + source: 'xer-defaults', + progressMode: defaults.progressMode, + schedulingOptions: defaults.schedulingOptions, + fallbacks, + }; + } + + 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), + useProjectEndDateForFloat: booleanValue( + row, + 'sched_use_project_end_date_for_float', + true, + fallbacks, + ), + useExpectedFinishDates: booleanValue(row, 'sched_use_expect_end_flag', true, fallbacks), + }; + + const progressMode = progressModeValue(row, fallbacks); + + 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 ?? '', tables.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, fallbacks }; +} diff --git a/src/types/project.ts b/src/types/project.ts index 0b069f476..895319f91 100644 --- a/src/types/project.ts +++ b/src/types/project.ts @@ -17,6 +17,20 @@ export interface SchedulingOptions { totalFloatMode?: 'start' | 'finish' | 'smallest'; /** Open-ended taken kritiek? Default = huidig gedrag (een eindtaak krijgt tf via LF−EF). */ makeOpenEndedCritical?: boolean; + /** P6/XER: zaai de backward-pass per taak vanaf het projecteinde op de eigen taakkalender. + * Afwezig/false houdt de algemene OPS-huissemantiek byte-identiek. */ + useProjectEndDateForFloat?: 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; /** Near-critical-drempel in werkdagen (fractioneel in uur-modus). Default undefined ⇒ feature uit. */ nearCriticalThreshold?: number; /** Multiple float paths. Default undefined ⇒ uit (byte-identiek). */ diff --git a/tests/planning/check-ifc-roundtrip.ts b/tests/planning/check-ifc-roundtrip.ts index d262363a7..063033d18 100644 --- a/tests/planning/check-ifc-roundtrip.ts +++ b/tests/planning/check-ifc-roundtrip.ts @@ -352,6 +352,10 @@ const SCHED_OPTS = { criticalDefinition: { mode: 'longestPath', threshold: -1 }, totalFloatMode: 'finish', makeOpenEndedCritical: true, + useProjectEndDateForFloat: true, + useExpectedFinishDates: false, + preserveActualDatesInBackwardPass: true, + clampNegativeFreeFloat: true, nearCriticalThreshold: 3, floatPaths: { enabled: true, method: 'TOTAL_FLOAT', maxPaths: 5 }, resumeFromActualElapsed: true, // T9 (voortgangsafronding): rondt lossless mee als deel van het JSON-blob 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..a5a92186b --- /dev/null +++ b/tests/planning/check-xer-schedule-options-corpus.ts @@ -0,0 +1,578 @@ +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 { + deriveXerScheduleOptions, + XER_SCHEDOPTIONS_COLUMN_DISPOSITIONS, + type XerScheduleOptionsResult, +} from '@/services/xer/xerScheduleOptions'; +import { parseXerTables, XerImportError } from '@/services/xer/xerTables'; +import type { ProgressMode, SchedulingOptions } from '@/types/project'; +import type { Task } from '@/types/task'; +import { + measureXerFidelity, + type XerSolvedProject, + type XerSolvedTask, +} from './xerFidelity'; +import { + scanXerGroundTruth, + XER_FIDELITY_AXES, + type XerFidelityAxis, + type XerGroundTruth, +} from './xerGroundTruth'; +import { emptyCounters, type XerFidelityCounters } from './xerFidelityTypes'; + +type AxisVector = [number, number, number, number, number, number]; +type DefaultKey = + | 'totalFloatFinish' + | 'retainedLogic' + | 'openEndedNotCritical' + | 'predecessorLagCalendar' + | 'projectEndForFloat' + | 'expectedFinishDates' + | 'preserveActualDates' + | 'clampNegativeFreeFloat' + | 'projectCriticalDefinition'; + +const DEFAULT_KEYS: readonly DefaultKey[] = [ + 'totalFloatFinish', + 'retainedLogic', + 'openEndedNotCritical', + 'predecessorLagCalendar', + 'projectEndForFloat', + 'expectedFinishDates', + 'preserveActualDates', + 'clampNegativeFreeFloat', + 'projectCriticalDefinition', +]; + +interface SolverVariant { + progressMode?: ProgressMode; + schedulingOptions?: SchedulingOptions; +} + +interface DefaultMeasurement { + /** Aantal taakwaarden dat per X1-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 BlastRadiusBaseline { + version: 5; + axes: readonly XerFidelityAxis[]; + defaults: readonly DefaultKey[]; + population: { + scanned: number; + oracleAxisFiles: number; + withSchedOptions: number; + withoutSchedOptions: number; + measured: number; + deferred: number; + oracleNegativeFloatFiles: number; + withoutSchedOptionsNegativeFloatFiles: number; + rawNegativeFloatFiles: number; + rawWithoutSchedOptionsNegativeFloatFiles: number; + }; + schedOptionsRows: { + rows: number; + parserRejectedWithoutSchedOptions: 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; + }; + corpusUnion: string[]; + files: MeasuredFile[]; + fidelity: { + house: XerFidelityCounters; + xerDefaults: XerFidelityCounters; + defaults: Record; + }; +} + +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 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 hasScheduleTableMarker(bytes: Uint8Array): boolean { + return /^%T\tSCHEDOPTIONS\s*$/m.test(decodedForTableMarker(bytes)); +} + +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: 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 movement(before: XerSolvedProject, after: XerSolvedProject): AxisVector { + const afterById = new Map(after.tasks.map(task => [task.sourceTaskId, task])); + return XER_FIDELITY_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 negativeFloatTasks(solved: XerSolvedProject): number { + return solved.tasks.filter(task => (task.totalFloatMinutes ?? 0) < 0).length; +} + +function projectResult( + imported: ReturnType, + variant: SolverVariant, +): XerSolvedProject { + 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: XerSolvedTask[] = 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: Math.round(task.time.totalFloat * minutesPerDay), + freeFloatMinutes: Math.round(task.time.freeFloat * minutesPerDay), + }; + }); + return { projectId: imported.project.id, tasks: output }; +} + +function variantDefinitions( + derived: ReturnType, +): 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' } }, + }, + projectEndForFloat: { + chosen: { schedulingOptions: { useProjectEndDateForFloat: true } }, + counterfactual: { schedulingOptions: { useProjectEndDateForFloat: false } }, + }, + 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: derived.schedulingOptions.criticalDefinition } }, + counterfactual: { schedulingOptions: { criticalDefinition: { mode: 'totalFloat', threshold: 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) }; + }); + const oracleFiles = scanned.filter(file => hasOracleAxis(file.truth)); + const parsed = oracleFiles.map(file => { + try { + return { ...file, tables: parseXerTables(file.bytes) }; + } catch { + if (hasScheduleTableMarker(file.bytes)) { + throw new Error(`${file.fullHash.slice(0, 16)}: orakeldrager met SCHEDOPTIONS is niet parseerbaar`); + } + return { ...file, tables: null }; + } + }); + const hasScheduleRows = (file: typeof parsed[number]): boolean => + (file.tables?.tables.get('SCHEDOPTIONS')?.rows.length ?? 0) > 0; + const without = parsed.filter(file => !hasScheduleRows(file)); + const withTable = parsed.filter(hasScheduleRows); + + const allScheduleTables = []; + const derivedRows: XerScheduleOptionsResult[] = []; + let parserRejectedWithoutSchedOptions = 0; + for (const file of scanned) { + try { + const tables = parseXerTables(file.bytes); + const table = tables.tables.get('SCHEDOPTIONS'); + if (table) { + allScheduleTables.push(table); + for (const row of table.rows) { + derivedRows.push(deriveXerScheduleOptions(tables, row.cells.proj_id?.trim() ?? '')); + } + } + } catch { + if (hasScheduleTableMarker(file.bytes)) { + throw new Error(`${file.fullHash.slice(0, 16)}: SCHEDOPTIONS-drager is niet parseerbaar`); + } + parserRejectedWithoutSchedOptions++; + } + } + 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'], + }; + + const occurrences = new Map(); + const files: MeasuredFile[] = []; + 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}`; + const oracleNegativeFloatTasks = file.truth.tasks.filter(taskHasNegativeFloat).length; + let imported: ReturnType; + try { + imported = readXER(file.bytes); + } catch (error) { + files.push({ + id, + state: 'deferred', + tasks: file.truth.tasks.length, + oracleNegativeFloatTasks, + deferredCode: deferredCode(error), + }); + continue; + } + + if (!file.tables) throw new Error(`${id}: productie-import slaagde zonder parseerbare tabellen`); + const derived = deriveXerScheduleOptions(file.tables, imported.project.id, { + hoursPerDay: imported.calendar.hoursPerDay, + taskCount: imported.tasks.filter((task: Task) => task.p6ActivityType !== undefined).length, + }); + const house = projectResult(imported, {}); + const houseMeasurement = measureXerFidelity(file.truth, [house]); + if (houseMeasurement.errors.length > 0) throw new Error(`${id}: fidelity-uitlijning mislukt`); + addCounters(fidelity.house, houseMeasurement.counters); + + const xerDefaults = projectResult(imported, { + progressMode: derived.progressMode, + schedulingOptions: derived.schedulingOptions, + }); + const xerDefaultsFidelity = measureXerFidelity(file.truth, [xerDefaults]); + if (xerDefaultsFidelity.errors.length > 0) { + throw new Error(`${id}: gecombineerde-XER-defaultuitlijning mislukt`); + } + addCounters(fidelity.xerDefaults, xerDefaultsFidelity.counters); + + const defaultMeasurements = {} as Record; + for (const [key, pair] of Object.entries(variantDefinitions(derived)) as Array<[ + DefaultKey, + ReturnType[DefaultKey], + ]>) { + const chosen = projectResult(imported, pair.chosen); + const counterfactual = projectResult(imported, pair.counterfactual); + defaultMeasurements[key] = { + movement: movement(counterfactual, chosen), + chosenNegativeFloatTasks: negativeFloatTasks(chosen), + counterfactualNegativeFloatTasks: negativeFloatTasks(counterfactual), + }; + for (const [variant, solved] of [ + ['chosen', chosen], + ['counterfactual', counterfactual], + ] as const) { + const measured = measureXerFidelity(file.truth, [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: file.truth.tasks.length, + oracleNegativeFloatTasks, + houseNegativeFloatTasks: negativeFloatTasks(house), + xerDefaultsMovement: movement(house, xerDefaults), + xerDefaultsNegativeFloatTasks: negativeFloatTasks(xerDefaults), + defaults: defaultMeasurements, + }); + } + + return { + version: 5, + axes: XER_FIDELITY_AXES, + defaults: DEFAULT_KEYS, + population: { + scanned: scanned.length, + oracleAxisFiles: oracleFiles.length, + withSchedOptions: withTable.length, + withoutSchedOptions: without.length, + measured: files.filter(file => file.state === 'measured').length, + deferred: files.filter(file => file.state === 'deferred').length, + 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 => + !hasScheduleTableMarker(file.bytes) && hasRawNegativeFloat(file.bytes)).length, + }, + schedOptionsRows: { + rows: scheduleRows.length, + parserRejectedWithoutSchedOptions, + 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, + }, + corpusUnion: union, + files, + 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: 5, + axes: XER_FIDELITY_AXES, + }); + eq('baseline bevat alle defaults los van elkaar', committed.defaults, DEFAULT_KEYS); + eq('baseline pint exact de 36 actuele bestanden zonder SCHEDOPTIONS', 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 === XER_FIDELITY_AXES.length + && typeof file.xerDefaultsNegativeFloatTasks === 'number'), 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('openbare populatie en negatieve-floatverdeling', measured.population, { + scanned: 93, + oracleAxisFiles: 60, + withSchedOptions: 24, + withoutSchedOptions: 36, + measured: 32, + deferred: 4, + oracleNegativeFloatFiles: 5, + withoutSchedOptionsNegativeFloatFiles: 4, + rawNegativeFloatFiles: 6, + rawWithoutSchedOptionsNegativeFloatFiles: 5, + }); + eq('meerderheidsinstellingen worden uit alle 50 tabelrijen herleid', measured.schedOptionsRows, { + rows: 50, + parserRejectedWithoutSchedOptions: 22, + floatFinish: 41, + retainedLogic: 48, + openEndedNotCritical: 48, + predecessorLagCalendar: 44, + projectEndForFloat: 39, + expectedFinishDates: 48, + lagFromEarlyStart: 40, + progressOverride: 1, + unknownFloatDialect: 8, + derivedRows: 50, + derivedFallbacks: 8, + derivedFloatDialectFallbacks: 8, + derivedRetainedLogic: 49, + derivedProgressOverride: 1, + derivedFinishFloat: 50, + derivedLagPredecessor: 45, + derivedLagSuccessor: 3, + derivedLag24Hour: 1, + derivedLagProjectDefault: 1, + }); + 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('per-bestand/per-as-defaultbeweging en X1-fidelity blijven exact gepind', measured, committed); + } +} + +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.ts b/tests/planning/check-xer-schedule-options.ts new file mode 100644 index 000000000..fb83bf024 --- /dev/null +++ b/tests/planning/check-xer-schedule-options.ts @@ -0,0 +1,709 @@ +import { CPMSolver } from '@/engine/scheduler/CPMSolver'; +import { createDefaultProject } from '@/state/defaults'; +import { + deriveXerScheduleOptions, + XER_SCHEDOPTIONS_COLUMN_DISPOSITIONS, + XER_SCHEDULING_DEFAULTS, +} from '@/services/xer/xerScheduleOptions'; +import { readIFC } from '@/services/ifc/ifcReader'; +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)}`); + } +} + +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(); +const projectEndPerCalendar = new CPMSolver( + endTasks, + [], + endProjectCalendar, + [sixDayCalendar], + { schedulingOptions: { useProjectEndDateForFloat: true } }, +).solve(); +eq('projecteinde-voor-float zaait late finish op de eigen taakkalender', { + ordinary: ordinaryEnd.tasks.get('SHORT')?.lateFinish, + p6: projectEndPerCalendar.tasks.get('SHORT')?.lateFinish, +}, { ordinary: '2026-06-06', p6: '2026-06-05' }); + +const explicitInertEnd = new CPMSolver( + endTasks, + [], + endProjectCalendar, + [sixDayCalendar], + { + schedulingOptions: { + useProjectEndDateForFloat: false, + 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: { useProjectEndDateForFloat: true, 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 }); + +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')); +} + +const withoutTable = deriveXerScheduleOptions(parseXerTables(xer( + ['proj_id', 'critical_path_type', 'critical_drtn_hr_cnt'], + ['P1', 'CT_TotFloat', '16'], +)), 'P1', { hoursPerDay: 8, taskCount: 9 }); + +eq('expliciete XER-defaultset is brongebonden en compleet', withoutTable, { + source: 'xer-defaults', + progressMode: 'RETAINED_LOGIC', + schedulingOptions: { + lagCalendar: 'predecessor', + criticalDefinition: { mode: 'totalFloat', threshold: 2 }, + totalFloatMode: 'finish', + makeOpenEndedCritical: false, + useProjectEndDateForFloat: true, + useExpectedFinishDates: true, + preserveActualDatesInBackwardPass: true, + clampNegativeFreeFloat: true, + }, + fallbacks: [], +}); +eq('geexporteerde defaults blijven de ongewijzigde nul-drempel leveren', XER_SCHEDULING_DEFAULTS, { + progressMode: 'RETAINED_LOGIC', + schedulingOptions: { + lagCalendar: 'predecessor', + criticalDefinition: { mode: 'totalFloat', threshold: 0 }, + totalFloatMode: 'finish', + makeOpenEndedCritical: false, + useProjectEndDateForFloat: true, + useExpectedFinishDates: true, + preserveActualDatesInBackwardPass: true, + clampNegativeFreeFloat: true, + }, +}); +eq('default 1/9: finish-float', XER_SCHEDULING_DEFAULTS.schedulingOptions.totalFloatMode, 'finish'); +eq('default 2/9: retained logic', XER_SCHEDULING_DEFAULTS.progressMode, 'RETAINED_LOGIC'); +eq('default 3/9: kritiek op totale float met nulgrens', + XER_SCHEDULING_DEFAULTS.schedulingOptions.criticalDefinition, + { mode: 'totalFloat', threshold: 0 }); +eq('default 4/9: open eindes niet automatisch kritiek', + XER_SCHEDULING_DEFAULTS.schedulingOptions.makeOpenEndedCritical, false); +eq('default 5/9: relatielag op de voorgangerskalender', + XER_SCHEDULING_DEFAULTS.schedulingOptions.lagCalendar, 'predecessor'); +eq('default 6/9: float tot projecteinde per taakkalender', + XER_SCHEDULING_DEFAULTS.schedulingOptions.useProjectEndDateForFloat, true); +eq('default 7/9: verwachte einddatums als bewaard bronbeleid', + XER_SCHEDULING_DEFAULTS.schedulingOptions.useExpectedFinishDates, true); +eq('default 8/9: P6-actuals blijven feiten in de backward-pass', + XER_SCHEDULING_DEFAULTS.schedulingOptions.preserveActualDatesInBackwardPass, true); +eq('default 9/9: P6-vrije-float wordt niet negatief', + XER_SCHEDULING_DEFAULTS.schedulingOptions.clampNegativeFreeFloat, 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', mapped, { + source: 'schedoptions', + progressMode: 'PROGRESS_OVERRIDE', + schedulingOptions: { + lagCalendar: 'successor', + criticalDefinition: { mode: 'longestPath' }, + totalFloatMode: 'start', + makeOpenEndedCritical: true, + useProjectEndDateForFloat: false, + useExpectedFinishDates: false, + preserveActualDatesInBackwardPass: true, + clampNegativeFreeFloat: true, + floatPaths: { enabled: true, method: 'TOTAL_FLOAT', maxPaths: 3 }, + }, + fallbacks: [], +}); + +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, + }], +}); + +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/run.sh b/tests/planning/run.sh index 907aea896..c873ef549 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -235,6 +235,19 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then XERREADERCHECK="$DIR/.xer-reader.mjs" if bundle_check "$DIR/check-xer-reader.ts" "$XERREADERCHECK"; then node "$XERREADERCHECK" || STATUS=1; fi + # X5 SCHEDOPTIONS: pure mapping/defaults plus brongebonden solvervlaggen. De centrale XER-reader + # blijft in kernfase 1 bewust buiten schot; dit checkoppervlak bedient de module rechtstreeks. + XERSCHEDOPTIONSCHECK="$DIR/.xer-schedule-options.mjs" + if bundle_check "$DIR/check-xer-schedule-options.ts" "$XERSCHEDOPTIONSCHECK"; then node "$XERSCHEDOPTIONSCHECK" || 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" diff --git a/tests/planning/xer-schedoptions-blast-radius.json b/tests/planning/xer-schedoptions-blast-radius.json new file mode 100644 index 000000000..650e90b66 --- /dev/null +++ b/tests/planning/xer-schedoptions-blast-radius.json @@ -0,0 +1,4690 @@ +{ + "version": 5, + "axes": [ + "es", + "ef", + "ls", + "lf", + "tf", + "ff" + ], + "defaults": [ + "totalFloatFinish", + "retainedLogic", + "openEndedNotCritical", + "predecessorLagCalendar", + "projectEndForFloat", + "expectedFinishDates", + "preserveActualDates", + "clampNegativeFreeFloat", + "projectCriticalDefinition" + ], + "population": { + "scanned": 93, + "oracleAxisFiles": 60, + "withSchedOptions": 24, + "withoutSchedOptions": 36, + "measured": 32, + "deferred": 4, + "oracleNegativeFloatFiles": 5, + "withoutSchedOptionsNegativeFloatFiles": 4, + "rawNegativeFloatFiles": 6, + "rawWithoutSchedOptionsNegativeFloatFiles": 5 + }, + "schedOptionsRows": { + "rows": 50, + "parserRejectedWithoutSchedOptions": 22, + "floatFinish": 41, + "retainedLogic": 48, + "openEndedNotCritical": 48, + "predecessorLagCalendar": 44, + "projectEndForFloat": 39, + "expectedFinishDates": 48, + "lagFromEarlyStart": 40, + "progressOverride": 1, + "unknownFloatDialect": 8, + "derivedRows": 50, + "derivedFallbacks": 8, + "derivedFloatDialectFallbacks": 8, + "derivedRetainedLogic": 49, + "derivedProgressOverride": 1, + "derivedFinishFloat": 50, + "derivedLagPredecessor": 45, + "derivedLagSuccessor": 3, + "derivedLag24Hour": 1, + "derivedLagProjectDefault": 1 + }, + "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 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 1, + 1 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 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": [ + 0, + 0, + 21, + 18, + 16, + 4 + ], + "xerDefaultsNegativeFloatTasks": 1, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 1, + 1 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 2, + 4, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 19, + 16, + 16, + 3 + ], + "chosenNegativeFloatTasks": 1, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 1 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "1ba69d297ee9a5c6-1", + "state": "deferred", + "tasks": 8, + "oracleNegativeFloatTasks": 0, + "deferredCode": "XER_SINGLE_PROJECT_REQUIRED" + }, + { + "id": "1ba8cf1885491db3-1", + "state": "measured", + "tasks": 20, + "oracleNegativeFloatTasks": 3, + "houseNegativeFloatTasks": 4, + "xerDefaultsMovement": [ + 0, + 0, + 6, + 4, + 4, + 1 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 4, + "counterfactualNegativeFloatTasks": 4 + }, + "retainedLogic": { + "movement": [ + 14, + 16, + 20, + 20, + 5, + 1 + ], + "chosenNegativeFloatTasks": 4, + "counterfactualNegativeFloatTasks": 4 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 1, + 1 + ], + "chosenNegativeFloatTasks": 4, + "counterfactualNegativeFloatTasks": 4 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 4, + "counterfactualNegativeFloatTasks": 4 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 4, + "counterfactualNegativeFloatTasks": 4 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 4, + "counterfactualNegativeFloatTasks": 4 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 6, + 4, + 4, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 4 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 1 + ], + "chosenNegativeFloatTasks": 4, + "counterfactualNegativeFloatTasks": 4 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 4, + "counterfactualNegativeFloatTasks": 4 + } + } + }, + { + "id": "1d7901d68cc7063b-1", + "state": "measured", + "tasks": 11, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 2, + "xerDefaultsMovement": [ + 0, + 0, + 1, + 1, + 2, + 1 + ], + "xerDefaultsNegativeFloatTasks": 1, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 2, + 0 + ], + "chosenNegativeFloatTasks": 1, + "counterfactualNegativeFloatTasks": 2 + }, + "retainedLogic": { + "movement": [ + 9, + 10, + 11, + 11, + 2, + 0 + ], + "chosenNegativeFloatTasks": 2, + "counterfactualNegativeFloatTasks": 2 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 2, + "counterfactualNegativeFloatTasks": 2 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 2, + "counterfactualNegativeFloatTasks": 2 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 1, + 1, + 0, + 0 + ], + "chosenNegativeFloatTasks": 2, + "counterfactualNegativeFloatTasks": 2 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 2, + "counterfactualNegativeFloatTasks": 2 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 2, + "counterfactualNegativeFloatTasks": 2 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 1 + ], + "chosenNegativeFloatTasks": 2, + "counterfactualNegativeFloatTasks": 2 + }, + "projectCriticalDefinition": { + "movement": [ + 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 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "2a7732b5b99de2a5-1", + "state": "measured", + "tasks": 126, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 44, + "xerDefaultsMovement": [ + 0, + 0, + 16, + 12, + 12, + 9 + ], + "xerDefaultsNegativeFloatTasks": 37, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 44, + "counterfactualNegativeFloatTasks": 44 + }, + "retainedLogic": { + "movement": [ + 67, + 68, + 1, + 1, + 67, + 28 + ], + "chosenNegativeFloatTasks": 44, + "counterfactualNegativeFloatTasks": 44 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 2, + 2 + ], + "chosenNegativeFloatTasks": 44, + "counterfactualNegativeFloatTasks": 44 + }, + "predecessorLagCalendar": { + "movement": [ + 78, + 78, + 76, + 76, + 106, + 28 + ], + "chosenNegativeFloatTasks": 44, + "counterfactualNegativeFloatTasks": 43 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 44, + "counterfactualNegativeFloatTasks": 44 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 44, + "counterfactualNegativeFloatTasks": 44 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 16, + 12, + 12, + 7 + ], + "chosenNegativeFloatTasks": 37, + "counterfactualNegativeFloatTasks": 44 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 4 + ], + "chosenNegativeFloatTasks": 44, + "counterfactualNegativeFloatTasks": 44 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 44, + "counterfactualNegativeFloatTasks": 44 + } + } + }, + { + "id": "2c1dce175b9f0781-1", + "state": "measured", + "tasks": 6977, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 38, + "xerDefaultsMovement": [ + 0, + 0, + 2666, + 2618, + 2618, + 1785 + ], + "xerDefaultsNegativeFloatTasks": 543, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 38, + "counterfactualNegativeFloatTasks": 38 + }, + "retainedLogic": { + "movement": [ + 3036, + 3149, + 6977, + 6977, + 6971, + 547 + ], + "chosenNegativeFloatTasks": 38, + "counterfactualNegativeFloatTasks": 57 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 11, + 11 + ], + "chosenNegativeFloatTasks": 38, + "counterfactualNegativeFloatTasks": 38 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 164, + 164, + 164, + 10 + ], + "chosenNegativeFloatTasks": 38, + "counterfactualNegativeFloatTasks": 38 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 38, + "counterfactualNegativeFloatTasks": 38 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 38, + "counterfactualNegativeFloatTasks": 38 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 2666, + 2618, + 2618, + 1639 + ], + "chosenNegativeFloatTasks": 543, + "counterfactualNegativeFloatTasks": 38 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 621 + ], + "chosenNegativeFloatTasks": 38, + "counterfactualNegativeFloatTasks": 38 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 38, + "counterfactualNegativeFloatTasks": 38 + } + } + }, + { + "id": "32524ea41f8bb4cb-1", + "state": "deferred", + "tasks": 2, + "oracleNegativeFloatTasks": 0, + "deferredCode": "XER_SINGLE_PROJECT_REQUIRED" + }, + { + "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 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "49aea658c963a005-1", + "state": "measured", + "tasks": 120, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 0, + 0, + 0, + 39 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 9, + 0, + 0, + 9, + 9 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 1, + 1 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 30 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 39 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 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 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 1, + 1 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "53f5cdbeb3fbd9c6-1", + "state": "measured", + "tasks": 31, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 1, + "xerDefaultsMovement": [ + 0, + 0, + 26, + 25, + 24, + 12 + ], + "xerDefaultsNegativeFloatTasks": 1, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 1, + "counterfactualNegativeFloatTasks": 1 + }, + "retainedLogic": { + "movement": [ + 5, + 6, + 30, + 30, + 25, + 6 + ], + "chosenNegativeFloatTasks": 1, + "counterfactualNegativeFloatTasks": 1 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 1, + "counterfactualNegativeFloatTasks": 1 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 1, + "counterfactualNegativeFloatTasks": 1 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 1, + 5, + 0, + 0 + ], + "chosenNegativeFloatTasks": 1, + "counterfactualNegativeFloatTasks": 1 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 1, + "counterfactualNegativeFloatTasks": 1 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 25, + 24, + 24, + 11 + ], + "chosenNegativeFloatTasks": 1, + "counterfactualNegativeFloatTasks": 1 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 5 + ], + "chosenNegativeFloatTasks": 1, + "counterfactualNegativeFloatTasks": 1 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 1, + "counterfactualNegativeFloatTasks": 1 + } + } + }, + { + "id": "568c19375b4e0d67-1", + "state": "measured", + "tasks": 8, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "5d71eac4e70b0d95-1", + "state": "measured", + "tasks": 120, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 0, + 0, + 0, + 53 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 8, + 0, + 0, + 8, + 8 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 1, + 1 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 45 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 53 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "78325fb6445d22f5-1", + "state": "measured", + "tasks": 22, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 2, + 1, + 1, + 1 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 1, + 1 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 2, + 1, + 1, + 1 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "790b90fda837bad3-1", + "state": "measured", + "tasks": 23, + "oracleNegativeFloatTasks": 6, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 3, + 1, + 1, + 2 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 1, + 1 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 3, + 1, + 1, + 2 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "79f6fb43bb391ed6-1", + "state": "measured", + "tasks": 5, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 3, + 3 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "79f6fb43bb391ed6-2", + "state": "measured", + "tasks": 5, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 3, + 3 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "9590c4cdc4efa69a-1", + "state": "measured", + "tasks": 93, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "97058f720a70f623-1", + "state": "measured", + "tasks": 8, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 7, + 6, + 6, + 1 + ], + "xerDefaultsNegativeFloatTasks": 5, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 1, + 2, + 8, + 8, + 8, + 1 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 5 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 7, + 6, + 6, + 0 + ], + "chosenNegativeFloatTasks": 5, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 1 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "98f079b267688de2-1", + "state": "measured", + "tasks": 2, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 2, + 2, + 0, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 2, + 2, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "9f2964425c0e295b-1", + "state": "measured", + "tasks": 2, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 2, + 2, + 0, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 1, + 1 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 2, + 2, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "a132f8c7ebb070d2-1", + "state": "measured", + "tasks": 23, + "oracleNegativeFloatTasks": 6, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 3, + 1, + 1, + 2 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 1, + 1 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 3, + 1, + 1, + 2 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 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 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 2, + 2 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 3, + 2, + 2, + 2 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 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 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 2, + 2 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 3, + 2, + 2, + 2 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "ac4b9d677133c6ed-1", + "state": "measured", + "tasks": 22, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 2, + 1, + 1, + 1 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 1, + 1 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 2, + 1, + 1, + 1 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "b72a879f6282cc61-1", + "state": "measured", + "tasks": 2, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 2, + 2, + 0, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 2, + 2, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 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 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "c872c9e704797d82-1", + "state": "measured", + "tasks": 8, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 1, + 1 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 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 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "def489b2a803af9f-1", + "state": "measured", + "tasks": 5, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 1, + 0, + 0, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 3, + 3 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 1, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + }, + { + "id": "def489b2a803af9f-2", + "state": "measured", + "tasks": 5, + "oracleNegativeFloatTasks": 0, + "houseNegativeFloatTasks": 0, + "xerDefaultsMovement": [ + 0, + 0, + 1, + 0, + 0, + 0 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 3, + 3 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 1, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 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 + ], + "xerDefaultsNegativeFloatTasks": 0, + "defaults": { + "totalFloatFinish": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "retainedLogic": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "openEndedNotCritical": { + "movement": [ + 0, + 0, + 0, + 0, + 1, + 1 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "predecessorLagCalendar": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectEndForFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "projectCriticalDefinition": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + } + } + } + ], + "fidelity": { + "house": { + "es": { + "deviations": 4552, + "measurable": 7649 + }, + "ef": { + "deviations": 4488, + "measurable": 7661 + }, + "ls": { + "deviations": 7485, + "measurable": 7539 + }, + "lf": { + "deviations": 7466, + "measurable": 7532 + }, + "tf": { + "deviations": 6928, + "measurable": 7594 + }, + "ff": { + "deviations": 2339, + "measurable": 7217 + } + }, + "xerDefaults": { + "es": { + "deviations": 4552, + "measurable": 7649 + }, + "ef": { + "deviations": 4488, + "measurable": 7661 + }, + "ls": { + "deviations": 5418, + "measurable": 7539 + }, + "lf": { + "deviations": 5451, + "measurable": 7532 + }, + "tf": { + "deviations": 6403, + "measurable": 7594 + }, + "ff": { + "deviations": 583, + "measurable": 7217 + } + }, + "defaults": { + "totalFloatFinish": { + "chosen": { + "es": { + "deviations": 4552, + "measurable": 7649 + }, + "ef": { + "deviations": 4488, + "measurable": 7661 + }, + "ls": { + "deviations": 7485, + "measurable": 7539 + }, + "lf": { + "deviations": 7466, + "measurable": 7532 + }, + "tf": { + "deviations": 6927, + "measurable": 7594 + }, + "ff": { + "deviations": 2339, + "measurable": 7217 + } + }, + "counterfactual": { + "es": { + "deviations": 4552, + "measurable": 7649 + }, + "ef": { + "deviations": 4488, + "measurable": 7661 + }, + "ls": { + "deviations": 7485, + "measurable": 7539 + }, + "lf": { + "deviations": 7466, + "measurable": 7532 + }, + "tf": { + "deviations": 6928, + "measurable": 7594 + }, + "ff": { + "deviations": 2339, + "measurable": 7217 + } + } + }, + "retainedLogic": { + "chosen": { + "es": { + "deviations": 4552, + "measurable": 7649 + }, + "ef": { + "deviations": 4488, + "measurable": 7661 + }, + "ls": { + "deviations": 7485, + "measurable": 7539 + }, + "lf": { + "deviations": 7466, + "measurable": 7532 + }, + "tf": { + "deviations": 6928, + "measurable": 7594 + }, + "ff": { + "deviations": 2339, + "measurable": 7217 + } + }, + "counterfactual": { + "es": { + "deviations": 4994, + "measurable": 7649 + }, + "ef": { + "deviations": 4959, + "measurable": 7661 + }, + "ls": { + "deviations": 7501, + "measurable": 7539 + }, + "lf": { + "deviations": 7481, + "measurable": 7532 + }, + "tf": { + "deviations": 7406, + "measurable": 7594 + }, + "ff": { + "deviations": 2519, + "measurable": 7217 + } + } + }, + "openEndedNotCritical": { + "chosen": { + "es": { + "deviations": 4552, + "measurable": 7649 + }, + "ef": { + "deviations": 4488, + "measurable": 7661 + }, + "ls": { + "deviations": 7485, + "measurable": 7539 + }, + "lf": { + "deviations": 7466, + "measurable": 7532 + }, + "tf": { + "deviations": 6928, + "measurable": 7594 + }, + "ff": { + "deviations": 2339, + "measurable": 7217 + } + }, + "counterfactual": { + "es": { + "deviations": 4552, + "measurable": 7649 + }, + "ef": { + "deviations": 4488, + "measurable": 7661 + }, + "ls": { + "deviations": 7485, + "measurable": 7539 + }, + "lf": { + "deviations": 7466, + "measurable": 7532 + }, + "tf": { + "deviations": 6911, + "measurable": 7594 + }, + "ff": { + "deviations": 2339, + "measurable": 7217 + } + } + }, + "predecessorLagCalendar": { + "chosen": { + "es": { + "deviations": 4552, + "measurable": 7649 + }, + "ef": { + "deviations": 4488, + "measurable": 7661 + }, + "ls": { + "deviations": 7485, + "measurable": 7539 + }, + "lf": { + "deviations": 7466, + "measurable": 7532 + }, + "tf": { + "deviations": 6928, + "measurable": 7594 + }, + "ff": { + "deviations": 2339, + "measurable": 7217 + } + }, + "counterfactual": { + "es": { + "deviations": 4552, + "measurable": 7649 + }, + "ef": { + "deviations": 4488, + "measurable": 7661 + }, + "ls": { + "deviations": 7485, + "measurable": 7539 + }, + "lf": { + "deviations": 7466, + "measurable": 7532 + }, + "tf": { + "deviations": 6928, + "measurable": 7594 + }, + "ff": { + "deviations": 2340, + "measurable": 7217 + } + } + }, + "projectEndForFloat": { + "chosen": { + "es": { + "deviations": 4552, + "measurable": 7649 + }, + "ef": { + "deviations": 4488, + "measurable": 7661 + }, + "ls": { + "deviations": 7486, + "measurable": 7539 + }, + "lf": { + "deviations": 7467, + "measurable": 7532 + }, + "tf": { + "deviations": 6928, + "measurable": 7594 + }, + "ff": { + "deviations": 2339, + "measurable": 7217 + } + }, + "counterfactual": { + "es": { + "deviations": 4552, + "measurable": 7649 + }, + "ef": { + "deviations": 4488, + "measurable": 7661 + }, + "ls": { + "deviations": 7485, + "measurable": 7539 + }, + "lf": { + "deviations": 7466, + "measurable": 7532 + }, + "tf": { + "deviations": 6928, + "measurable": 7594 + }, + "ff": { + "deviations": 2339, + "measurable": 7217 + } + } + }, + "expectedFinishDates": { + "chosen": { + "es": { + "deviations": 4552, + "measurable": 7649 + }, + "ef": { + "deviations": 4488, + "measurable": 7661 + }, + "ls": { + "deviations": 7485, + "measurable": 7539 + }, + "lf": { + "deviations": 7466, + "measurable": 7532 + }, + "tf": { + "deviations": 6928, + "measurable": 7594 + }, + "ff": { + "deviations": 2339, + "measurable": 7217 + } + }, + "counterfactual": { + "es": { + "deviations": 4552, + "measurable": 7649 + }, + "ef": { + "deviations": 4488, + "measurable": 7661 + }, + "ls": { + "deviations": 7485, + "measurable": 7539 + }, + "lf": { + "deviations": 7466, + "measurable": 7532 + }, + "tf": { + "deviations": 6928, + "measurable": 7594 + }, + "ff": { + "deviations": 2339, + "measurable": 7217 + } + } + }, + "preserveActualDates": { + "chosen": { + "es": { + "deviations": 4552, + "measurable": 7649 + }, + "ef": { + "deviations": 4488, + "measurable": 7661 + }, + "ls": { + "deviations": 5417, + "measurable": 7539 + }, + "lf": { + "deviations": 5450, + "measurable": 7532 + }, + "tf": { + "deviations": 6404, + "measurable": 7594 + }, + "ff": { + "deviations": 702, + "measurable": 7217 + } + }, + "counterfactual": { + "es": { + "deviations": 4552, + "measurable": 7649 + }, + "ef": { + "deviations": 4488, + "measurable": 7661 + }, + "ls": { + "deviations": 7485, + "measurable": 7539 + }, + "lf": { + "deviations": 7466, + "measurable": 7532 + }, + "tf": { + "deviations": 6928, + "measurable": 7594 + }, + "ff": { + "deviations": 2339, + "measurable": 7217 + } + } + }, + "clampNegativeFreeFloat": { + "chosen": { + "es": { + "deviations": 4552, + "measurable": 7649 + }, + "ef": { + "deviations": 4488, + "measurable": 7661 + }, + "ls": { + "deviations": 7485, + "measurable": 7539 + }, + "lf": { + "deviations": 7466, + "measurable": 7532 + }, + "tf": { + "deviations": 6928, + "measurable": 7594 + }, + "ff": { + "deviations": 1747, + "measurable": 7217 + } + }, + "counterfactual": { + "es": { + "deviations": 4552, + "measurable": 7649 + }, + "ef": { + "deviations": 4488, + "measurable": 7661 + }, + "ls": { + "deviations": 7485, + "measurable": 7539 + }, + "lf": { + "deviations": 7466, + "measurable": 7532 + }, + "tf": { + "deviations": 6928, + "measurable": 7594 + }, + "ff": { + "deviations": 2339, + "measurable": 7217 + } + } + }, + "projectCriticalDefinition": { + "chosen": { + "es": { + "deviations": 4552, + "measurable": 7649 + }, + "ef": { + "deviations": 4488, + "measurable": 7661 + }, + "ls": { + "deviations": 7485, + "measurable": 7539 + }, + "lf": { + "deviations": 7466, + "measurable": 7532 + }, + "tf": { + "deviations": 6928, + "measurable": 7594 + }, + "ff": { + "deviations": 2339, + "measurable": 7217 + } + }, + "counterfactual": { + "es": { + "deviations": 4552, + "measurable": 7649 + }, + "ef": { + "deviations": 4488, + "measurable": 7661 + }, + "ls": { + "deviations": 7485, + "measurable": 7539 + }, + "lf": { + "deviations": 7466, + "measurable": 7532 + }, + "tf": { + "deviations": 6928, + "measurable": 7594 + }, + "ff": { + "deviations": 2339, + "measurable": 7217 + } + } + } + } + } +} From 4c43e405d469d48403b24e2c2755ccbd8fbc6086 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:00:03 +0200 Subject: [PATCH 025/171] Herstel X4b-meerprojectwiring na review --- src/services/importTypes.ts | 31 ++++++++ src/services/mcp/tools/fileTools.ts | 37 ++++++++-- src/services/xer/xerMultiProject.ts | 90 ++++++++++++----------- src/services/xer/xerReader.ts | 32 ++++++-- src/state/documentContract.ts | 4 +- src/state/slices/fileSlice.ts | 15 +++- src/utils/devBridge.ts | 21 +++++- tests/mcp/cases-doc-file.ts | 47 ++++++++++++ tests/planning/check-xer-byte-paths.ts | 79 ++++++++++++++++++++ tests/planning/check-xer-multi-project.ts | 76 ++++++++++++++----- tests/planning/check-xer-open-wiring.ts | 69 ++++++++++++++++- tests/planning/check-xer-reader.ts | 55 ++++++++++++++ 12 files changed, 471 insertions(+), 85 deletions(-) diff --git a/src/services/importTypes.ts b/src/services/importTypes.ts index f98a82a87..c5e3c2b3b 100644 --- a/src/services/importTypes.ts +++ b/src/services/importTypes.ts @@ -55,6 +55,33 @@ export interface XerExternalRelation { 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[]; +} + /** Documentgebonden XER-brondata. Externe relaties zijn nadrukkelijk geen solverrelaties. */ export interface XerImportMetadata { defaultCurrencyCode: string; @@ -62,6 +89,10 @@ export interface XerImportMetadata { calendarIssues: XerCalendarIssueMetadata[]; enumFallbacks: XerEnumFallback[]; 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; } /** diff --git a/src/services/mcp/tools/fileTools.ts b/src/services/mcp/tools/fileTools.ts index c6a449eff..c17f927aa 100644 --- a/src/services/mcp/tools/fileTools.ts +++ b/src/services/mcp/tools/fileTools.ts @@ -32,7 +32,6 @@ import { useAppStore } from '@/state/appStore'; 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'; @@ -296,7 +295,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 ' + @@ -312,7 +312,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, @@ -370,8 +370,6 @@ export const fileTools: McpToolDef[] = [ } // Exact het bestaande laadpatroon (fileSlice.openFile), inclusief X4b's meervoudige XER-vorm. - const store = useAppStore.getState(); - const reusedActiveTab = isActivePristine(store); 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 @@ -382,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`. - useAppStore.getState().applyOpenedImport(parsed, { + const opened = useAppStore.getState().applyOpenedImport(parsed, { filePath: readFormat.canBeSaveTarget ? path : null, fileHandle: null, recompute: true, @@ -394,6 +392,23 @@ export const fileTools: McpToolDef[] = [ bindExpectedDoc(ctx); const after = useAppStore.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.'); @@ -402,7 +417,11 @@ export const fileTools: McpToolDef[] = [ } 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 één niet-leeg P6-project. Opslaan schrijft IFC.'); + 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.'); @@ -412,9 +431,11 @@ export const fileTools: McpToolDef[] = [ envelope: buildEnvelope(), 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/xer/xerMultiProject.ts b/src/services/xer/xerMultiProject.ts index 8876832e8..ae60f87d3 100644 --- a/src/services/xer/xerMultiProject.ts +++ b/src/services/xer/xerMultiProject.ts @@ -1,39 +1,22 @@ import type { XerReadResult } from './xerReader'; -import type { MultiDocumentImport } from '@/services/importTypes'; +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 type XerBaselineFallbackReason = - | 'self-reference' - | 'cycle' - | 'all-projects-baselines'; - export interface XerMultiProjectDocument { projectId: string; result: XerReadResult; } -export interface XerDocumentExternalLink { - id: string; - predecessor: { projectId: string; taskId: string }; - successor: { projectId: string; taskId: string }; - type: 'FS' | 'SS' | 'FF' | 'SF'; - lagMinutes: number; -} - -export interface XerMultiProjectReport { - projectsSeen: number; - documentsOpened: number; - emptyProjectsSkipped: number; - baselineProjectsExcluded: number; - baselinesMaterialized: number; - danglingBaselineReferences: number; - externalLinksPreserved: number; - baselineExclusionReverted: boolean; - baselineFallbackReasons: XerBaselineFallbackReason[]; -} +export type XerMultiProjectReport = XerImportReport; +export type { XerBaselineFallbackReason, XerDocumentExternalLink } from '@/services/importTypes'; export interface XerMultiProjectImport extends MultiDocumentImport { documents: XerMultiProjectDocument[]; @@ -92,11 +75,13 @@ function hasNonSelfCycle(targetByProject: ReadonlyMap): boolean /** * Beslis welke aanwezige baselineprojecten als gewone documenten worden uitgesloten. * - * De 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 concrete reden. + * 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[], @@ -105,8 +90,13 @@ function decideBaselineExclusion( 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(projectRow.cells.proj_id, target); + if (target && projectIds.has(target)) targetByProject.set(ownerProjectId, target); } const excludedProjectIds = new Set(targetByProject.values()); const reasons: XerBaselineFallbackReason[] = []; @@ -283,6 +273,31 @@ export function assembleXerMultiProjectImport( } } + 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), @@ -292,17 +307,6 @@ export function assembleXerMultiProjectImport( documents, activeProjectId, externalLinks, - report: { - 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, - }, + report, }; } diff --git a/src/services/xer/xerReader.ts b/src/services/xer/xerReader.ts index 9e0c41f8f..df9cb572e 100644 --- a/src/services/xer/xerReader.ts +++ b/src/services/xer/xerReader.ts @@ -550,25 +550,43 @@ function readXerProject(tables: XerTables, projectId: string): XerReadResult { calendarIssues: calendars.issues, enumFallbacks, 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: [], + }, }, }; } /** - * Lees de oorspronkelijke XER-bytes. Eén PROJECT behoudt de byte-identieke X4a-returnvorm; - * meerdere PROJECT-rijen waaieren via X4b uit naar losse payloads. De baselinebeslissing zit vóór - * de openroute: die krijgt dus uitsluitend documenten die echt als tab geopend mogen worden. + * 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 projectRows = tables.tables.get('PROJECT')?.rows ?? []; - if (projectRows.length === 1) return readXerProject(tables, projectRows[0].cells.proj_id); - - const multi = assembleXerMultiProjectImport( + const assembled = assembleXerMultiProjectImport( tables, projectId => readXerProject(tables, projectId), ); - if (multi.results.length > 0) return multi; + 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.', diff --git a/src/state/documentContract.ts b/src/state/documentContract.ts index 1c1b85ad6..644049170 100644 --- a/src/state/documentContract.ts +++ b/src/state/documentContract.ts @@ -75,7 +75,9 @@ export interface DocumentPayload { /** Web-opslaan-doel (browser-bestandstoegang). ALLEEN het FSA-opslaan-doel — nooit identiteit/titel (die blijft filePath: echt pad in Tauri, bestandsnaam in web). null in Tauri/fallback-web. */ fileHandle: FileSystemFileHandle | null; isDirty: boolean; - /** XER-bronmetadata per document; geen onderdeel van undo omdat bewerkingen dit niet muteren. */ + /** XER-bronmetadata per document; geen onderdeel van undo omdat bewerkingen dit niet muteren. + * X9 moet dit veld (incl. geconsolideerde externalLinks + importrapport) nog door IFC laten + * round-trippen; X4b bewaart het al door documentwissel en de recovery-inputlaag. */ xerImportMetadata: XerImportMetadata | null; } diff --git a/src/state/slices/fileSlice.ts b/src/state/slices/fileSlice.ts index 497a3daf0..be1dd065b 100644 --- a/src/state/slices/fileSlice.ts +++ b/src/state/slices/fileSlice.ts @@ -70,6 +70,13 @@ export interface ApplyLoadedProjectOpts { linkedOpen?: boolean; } +/** 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. */ @@ -109,7 +116,7 @@ export interface FileSlice { * meervoudige vorm; de individuele documenten lopen daarna letterlijk door hetzelfde * applyLoadedProject-pad als IFC/CSV/MSPDI/MPP/P6XML. */ - applyOpenedImport: (parsed: OpenedImport, opts: ApplyLoadedProjectOpts) => void; + applyOpenedImport: (parsed: OpenedImport, opts: ApplyLoadedProjectOpts) => AppliedOpenedImport; } export const createFileSlice: AppSlice = (set, get) => { @@ -252,6 +259,7 @@ export const createFileSlice: AppSlice = (set, get) => { 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[] = []; for (const result of results) { // De eerste payload mag het lege starttabblad hergebruiken; elk volgend project krijgt @@ -268,6 +276,11 @@ export const createFileSlice: AppSlice = (set, get) => { 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) => { diff --git a/src/utils/devBridge.ts b/src/utils/devBridge.ts index bf2aba0c2..df83f4134 100644 --- a/src/utils/devBridge.ts +++ b/src/utils/devBridge.ts @@ -3,8 +3,13 @@ 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, type FormatIO } from '@/services/formatRegistry'; -import { activeImportResult } from '@/services/importTypes'; +import { + parseOpenedFile, + readFormatForFile, + readFormatInput, + saveTargetFor, + type FormatIO, +} from '@/services/formatRegistry'; import { enableExtension, disableExtension, removeExtension, saveExtensionToDb, installFromZipBlob } from '@/extensions'; import type { InstallOutcome } from '@/extensions'; import type { ExtensionManifest, InstalledExtension } from '@/extensions/types'; @@ -83,8 +88,16 @@ async function saveToPath(path: string) { export async function openFromPathWithIO(path: string, io: FormatIO) { const input = await readFormatInput(path, io); const parsed = await parseOpenedFile(input); - useAppStore.getState().loadState(activeImportResult(parsed)); - return { path, ...counts(useAppStore.getState()) }; + 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) { diff --git a/tests/mcp/cases-doc-file.ts b/tests/mcp/cases-doc-file.ts index 286e08f07..c81e4e8c8 100644 --- a/tests/mcp/cases-doc-file.ts +++ b/tests/mcp/cases-doc-file.ts @@ -219,6 +219,22 @@ function xerFixture(projectName: string): string { ].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]); @@ -576,6 +592,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'); @@ -679,6 +698,34 @@ test('import_schedule: .xer behoudt CP1252 en beide UTF-16-BOM-payloads via MCP- } }); +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/planning/check-xer-byte-paths.ts b/tests/planning/check-xer-byte-paths.ts index 73875d283..acfe5144b 100644 --- a/tests/planning/check-xer-byte-paths.ts +++ b/tests/planning/check-xer-byte-paths.ts @@ -2,6 +2,8 @@ import { openDialogFilters, binaryExtensions, parseOpenedFile } from '@/services 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; @@ -32,6 +34,22 @@ function fixture(projectName: string): string { ].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]); @@ -194,6 +212,67 @@ 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]); diff --git a/tests/planning/check-xer-multi-project.ts b/tests/planning/check-xer-multi-project.ts index 92fe7d7eb..c3841e402 100644 --- a/tests/planning/check-xer-multi-project.ts +++ b/tests/planning/check-xer-multi-project.ts @@ -103,6 +103,18 @@ function mappedProject(projectId: string, leaves: number, summaries = 0): XerRea calendarIssues: [], enumFallbacks: [], externalRelations: [], + externalLinks: [], + report: { + projectsSeen: 1, + documentsOpened: 1, + emptyProjectsSkipped: 0, + baselineProjectsExcluded: 0, + baselinesMaterialized: 0, + danglingBaselineReferences: 0, + externalLinksPreserved: 0, + baselineExclusionReverted: false, + baselineFallbackReasons: [], + }, }, }; } @@ -471,32 +483,36 @@ eq('9 cyclus neemt uitsluiting voor het hele bestand terug, ook als C open zou b reasons: ['cycle'], }); -// Breuk die dit vangt: alleen expliciete zelfverwijzingen/cycli als vangrail behandelen. Ook een -// acyclische graaf kan door verwijzingen vanuit lege eigenaren alle niet-lege projecten uitsluiten; -// dan moet de volledige uitsluiting terug zodat de te openen verzameling niet leeg wordt. -const allExcludedTables = tables([ - row(2, { proj_id: 'P-OPEN-A' }), - row(3, { proj_id: 'P-OPEN-B' }), - row(4, { proj_id: 'P-EMPTY-OWNER-A', sum_base_proj_id: 'P-OPEN-A' }), - row(5, { proj_id: 'P-EMPTY-OWNER-B', sum_base_proj_id: 'P-OPEN-B' }), +// 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-OPEN-A', task_id: 'OA-1', task_code: 'OA-1' }), - row(9, { proj_id: 'P-OPEN-B', task_id: 'OB-1', task_code: 'OB-1' }), + 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 allExcludedGuard = assembleXerMultiProjectImport( - allExcludedTables, +const emptyOwnerImport = assembleXerMultiProjectImport( + emptyOwnerTables, projectId => mappedProject(projectId, 1), ); -eq('9a acyclische uitsluiting van alle niet-lege projecten wordt volledig teruggenomen', { - documents: allExcludedGuard.documents.map(document => document.projectId), - baselines: allExcludedGuard.report.baselinesMaterialized, - reverted: allExcludedGuard.report.baselineExclusionReverted, - reasons: allExcludedGuard.report.baselineFallbackReasons, +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-OPEN-A', 'P-OPEN-B'], + documents: ['P-BASE', 'P-THIRD'], + empty: 1, + excluded: 0, baselines: 0, - reverted: true, - reasons: ['all-projects-baselines'], + reverted: false, + reasons: [], }); // Breuk die dit vangt: de baselinegraaf recursief aflopen. Een groot maar geldig bestand met een @@ -598,6 +614,14 @@ eq('10 twee lokale perspectieven worden één solverloze externalLink tussen doc 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, @@ -751,6 +775,18 @@ function mapCorpusProject(source: XerTables, projectId: string): XerReadResult { calendarIssues: [], enumFallbacks: [], externalRelations: [], + externalLinks: [], + report: { + projectsSeen: 1, + documentsOpened: 1, + emptyProjectsSkipped: 0, + baselineProjectsExcluded: 0, + baselinesMaterialized: 0, + danglingBaselineReferences: 0, + externalLinksPreserved: 0, + baselineExclusionReverted: false, + baselineFallbackReasons: [], + }, }, }; } diff --git a/tests/planning/check-xer-open-wiring.ts b/tests/planning/check-xer-open-wiring.ts index 35a211ab2..6e4f20918 100644 --- a/tests/planning/check-xer-open-wiring.ts +++ b/tests/planning/check-xer-open-wiring.ts @@ -41,6 +41,9 @@ const source = new TextEncoder().encode([ '%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')); @@ -58,7 +61,7 @@ eq('2 lezer behoudt selectie-, baseline- en danglingtellingen', multi.report, { baselineProjectsExcluded: 1, baselinesMaterialized: 1, danglingBaselineReferences: 1, - externalLinksPreserved: 0, + externalLinksPreserved: 1, baselineExclusionReverted: false, baselineFallbackReasons: [], }); @@ -125,6 +128,70 @@ eq('8 elk geopend document kan onafhankelijk als IFC recoverypayload rond-tripen { 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, +})); +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: [] }, + ]); + const corpusRoot = process.env.OPS_XER_CORPUS; if (corpusRoot && existsSync(corpusRoot)) { const openPublicXer = async (relativePath: string) => { diff --git a/tests/planning/check-xer-reader.ts b/tests/planning/check-xer-reader.ts index c3694109b..a62d6f48f 100644 --- a/tests/planning/check-xer-reader.ts +++ b/tests/planning/check-xer-reader.ts @@ -63,6 +63,18 @@ const fixture = [ ] 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, @@ -220,6 +232,49 @@ typedError('15 project zonder TASK wordt expliciet geweigerd', [ 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. From c9e9399f5532db27458604b10ba331b4bb02f3dc Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:55:20 +0200 Subject: [PATCH 026/171] fix: herstel XER-planningsopties na review --- .../plans/2026-08-20-plan-xer-p6-lezer.md | 8 +- src/engine/scheduler/CPMSolver.ts | 4 +- src/engine/scheduler/scheduleAnalysis.ts | 28 +- src/services/xer/xerScheduleOptions.ts | 48 +- src/types/project.ts | 13 +- tests/planning/check-adapters-hours.ts | 3 + tests/planning/check-ifc-roundtrip.ts | 3 +- tests/planning/check-mpp-import.ts | 5 + .../check-xer-schedule-options-corpus.ts | 222 +-- tests/planning/check-xer-schedule-options.ts | 217 ++- .../xer-schedoptions-blast-radius.json | 1230 +++++------------ 11 files changed, 710 insertions(+), 1071 deletions(-) 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 index 96f4ce883..217613d80 100644 --- a/docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md +++ b/docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md @@ -335,15 +335,15 @@ 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!)*: 37 van de 60 -orakelbestanden hebben géén SCHEDOPTIONS, waaronder `rehab-2.xer` (39% van het orakel). Voor +*(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 37-zonder-SCHEDOPTIONS-populatie per default-keuze gemeten en gepind; mutatiebewijs: +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)*. @@ -434,7 +434,7 @@ 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 - 62% van de orakelbestanden (37 van 60, incl. de grootste massa) bepalen ónze + 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. diff --git a/src/engine/scheduler/CPMSolver.ts b/src/engine/scheduler/CPMSolver.ts index cff566211..655afe364 100644 --- a/src/engine/scheduler/CPMSolver.ts +++ b/src/engine/scheduler/CPMSolver.ts @@ -2609,9 +2609,7 @@ export class CPMSolver { // 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 = this.options.schedulingOptions?.useProjectEndDateForFloat - ? this.snapOnOrBefore(predCal, projectEnd) - : projectEnd; + let lateFinish = projectEnd; for (const seq of succs) { const succResult = results.get(seq.successorId); const succTask = this.tasks.get(seq.successorId); diff --git a/src/engine/scheduler/scheduleAnalysis.ts b/src/engine/scheduler/scheduleAnalysis.ts index ceefc3550..da3ec67ff 100644 --- a/src/engine/scheduler/scheduleAnalysis.ts +++ b/src/engine/scheduler/scheduleAnalysis.ts @@ -126,11 +126,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) @@ -196,19 +197,22 @@ export function computeScheduleResults(input: ScheduleAnalysisInput): CPMResult // 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 + // 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) { @@ -267,7 +271,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 diff --git a/src/services/xer/xerScheduleOptions.ts b/src/services/xer/xerScheduleOptions.ts index e1adad97c..d191b447e 100644 --- a/src/services/xer/xerScheduleOptions.ts +++ b/src/services/xer/xerScheduleOptions.ts @@ -20,6 +20,11 @@ export interface XerScheduleOptionsResult { source: 'schedoptions' | 'xer-defaults'; progressMode: ProgressMode; schedulingOptions: SchedulingOptions; + /** Bronwaarden waarvoor X5 nog geen correcte solverrepresentatie heeft. Ze blijven expliciet + * beschikbaar voor de latere readerwiring en worden niet als schijnoptie op het project gezet. */ + retainedSource: { + sched_use_project_end_date_for_float?: boolean; + }; fallbacks: XerScheduleOptionFallback[]; } @@ -71,8 +76,8 @@ export const XER_SCHEDOPTIONS_COLUMN_DISPOSITIONS: readonly XerScheduleOptionCol { field: 'sched_use_expect_end_flag', status: 'mapped', target: 'schedulingOptions.useExpectedFinishDates' }, { field: 'sched_use_project_end_date_for_float', - status: 'mapped', - target: 'schedulingOptions.useProjectEndDateForFloat', + status: 'todo', + reason: 'Meerdere gelijktijdig geplande projecten ontbreken; X5 bewaart de bronwaarde zonder solversturing.', }, { 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.' }, @@ -89,10 +94,9 @@ export const XER_SCHEDULING_DEFAULTS = { progressMode: 'RETAINED_LOGIC', schedulingOptions: { lagCalendar: 'predecessor', - criticalDefinition: { mode: 'totalFloat', threshold: 0 }, + criticalDefinition: { mode: 'totalFloat', thresholdHours: 0 }, totalFloatMode: 'finish', makeOpenEndedCritical: false, - useProjectEndDateForFloat: true, useExpectedFinishDates: true, preserveActualDatesInBackwardPass: true, clampNegativeFreeFloat: true, @@ -148,6 +152,19 @@ function booleanValue( 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 progressModeValue( row: XerRow, fallbacks: XerScheduleOptionFallback[], @@ -177,7 +194,6 @@ function progressModeValue( function projectCriticalDefinition( tables: XerTables, projectId: string, - hoursPerDay: number, fallbacks: XerScheduleOptionFallback[], ): SchedulingOptions['criticalDefinition'] { const row = tables.tables.get('PROJECT')?.rows.find(item => item.cells.proj_id?.trim() === projectId); @@ -188,7 +204,7 @@ function projectCriticalDefinition( reportFallback(fallbacks, row, 'critical_path_type', token, 'totalFloat'); } const thresholdHours = parseXerNumber(row.cells.critical_drtn_hr_cnt ?? '', tables.numberFormat) ?? 0; - return { mode: 'totalFloat', threshold: thresholdHours / hoursPerDay }; + return { mode: 'totalFloat', thresholdHours }; } function scheduleRow(tables: XerTables, projectId: string): XerRow | undefined { @@ -202,11 +218,9 @@ export function deriveXerScheduleOptions( ): XerScheduleOptionsResult { const defaults = freshDefaults(); const fallbacks: XerScheduleOptionFallback[] = []; - const hoursPerDay = context.hoursPerDay && context.hoursPerDay > 0 ? context.hoursPerDay : 8; defaults.schedulingOptions.criticalDefinition = projectCriticalDefinition( tables, projectId, - hoursPerDay, fallbacks, ); @@ -216,6 +230,7 @@ export function deriveXerScheduleOptions( source: 'xer-defaults', progressMode: defaults.progressMode, schedulingOptions: defaults.schedulingOptions, + retainedSource: {}, fallbacks, }; } @@ -236,15 +251,18 @@ export function deriveXerScheduleOptions( FT_MIN: 'smallest', }, 'finish', fallbacks), makeOpenEndedCritical: booleanValue(row, 'sched_open_critical_flag', false, fallbacks), - useProjectEndDateForFloat: booleanValue( - row, - 'sched_use_project_end_date_for_float', - true, - 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 }; + const progressMode = progressModeValue(row, fallbacks); const floatPathFields = [ @@ -270,5 +288,5 @@ export function deriveXerScheduleOptions( }; } - return { source: 'schedoptions', progressMode, schedulingOptions, fallbacks }; + return { source: 'schedoptions', progressMode, schedulingOptions, retainedSource, fallbacks }; } diff --git a/src/types/project.ts b/src/types/project.ts index 895319f91..56172ed2e 100644 --- a/src/types/project.ts +++ b/src/types/project.ts @@ -11,15 +11,18 @@ 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) + /** 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: zaai de backward-pass per taak vanaf het projecteinde op de eigen taakkalender. - * Afwezig/false houdt de algemene OPS-huissemantiek byte-identiek. */ - useProjectEndDateForFloat?: 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; diff --git a/tests/planning/check-adapters-hours.ts b/tests/planning/check-adapters-hours.ts index 1cc67d491..4e562fd0a 100644 --- a/tests/planning/check-adapters-hours.ts +++ b/tests/planning/check-adapters-hours.ts @@ -123,10 +123,13 @@ 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); } // ── Review-follow-up (2026-08, op bugfix B1): GEMENGDE ISO-duur uit een VREEMD bestand ───────────── diff --git a/tests/planning/check-ifc-roundtrip.ts b/tests/planning/check-ifc-roundtrip.ts index 063033d18..33fa9bc7d 100644 --- a/tests/planning/check-ifc-roundtrip.ts +++ b/tests/planning/check-ifc-roundtrip.ts @@ -349,10 +349,9 @@ const assignments: ResourceAssignment[] = [ // ── Project incl. schedulingOptions/statusDate/progressMode/wbsAutoNumber ───────────────────────── const SCHED_OPTS = { lagCalendar: 'successor', - criticalDefinition: { mode: 'longestPath', threshold: -1 }, + criticalDefinition: { mode: 'longestPath', threshold: -1, thresholdHours: -8 }, totalFloatMode: 'finish', makeOpenEndedCritical: true, - useProjectEndDateForFloat: true, useExpectedFinishDates: false, preserveActualDatesInBackwardPass: true, clampNegativeFreeFloat: true, diff --git a/tests/planning/check-mpp-import.ts b/tests/planning/check-mpp-import.ts index 3458e9419..5cfdc6223 100644 --- a/tests/planning/check-mpp-import.ts +++ b/tests/planning/check-mpp-import.ts @@ -1164,6 +1164,11 @@ 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: 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-xer-schedule-options-corpus.ts b/tests/planning/check-xer-schedule-options-corpus.ts index a5a92186b..b9a8a680d 100644 --- a/tests/planning/check-xer-schedule-options-corpus.ts +++ b/tests/planning/check-xer-schedule-options-corpus.ts @@ -11,7 +11,6 @@ import { } from '@/services/xer/xerScheduleOptions'; import { parseXerTables, XerImportError } from '@/services/xer/xerTables'; import type { ProgressMode, SchedulingOptions } from '@/types/project'; -import type { Task } from '@/types/task'; import { measureXerFidelity, type XerSolvedProject, @@ -20,19 +19,18 @@ import { import { scanXerGroundTruth, XER_FIDELITY_AXES, - type XerFidelityAxis, type XerGroundTruth, } from './xerGroundTruth'; import { emptyCounters, type XerFidelityCounters } from './xerFidelityTypes'; -type AxisVector = [number, number, number, number, number, number]; +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' - | 'projectEndForFloat' - | 'expectedFinishDates' | 'preserveActualDates' | 'clampNegativeFreeFloat' | 'projectCriticalDefinition'; @@ -42,20 +40,24 @@ const DEFAULT_KEYS: readonly DefaultKey[] = [ 'retainedLogic', 'openEndedNotCritical', 'predecessorLagCalendar', - 'projectEndForFloat', - 'expectedFinishDates', 'preserveActualDates', 'clampNegativeFreeFloat', 'projectCriticalDefinition', ]; +const DEFERRED_DEFAULTS = [{ + key: 'expectedFinishDates', + owner: 'X7', + reason: 'Het X7-taakveld expectedFinish ontbreekt nog; X5 bewaart alleen het bronbeleid.', +}] as const; + interface SolverVariant { progressMode?: ProgressMode; schedulingOptions?: SchedulingOptions; } interface DefaultMeasurement { - /** Aantal taakwaarden dat per X1-as verandert van de tegenvariant naar de gekozen XER-default. */ + /** Aantal taakwaarden dat per blast-as verandert van de tegenvariant naar de gekozen XER-default. */ movement: AxisVector; chosenNegativeFloatTasks: number; counterfactualNegativeFloatTasks: number; @@ -74,9 +76,10 @@ interface MeasuredFile { } interface BlastRadiusBaseline { - version: 5; - axes: readonly XerFidelityAxis[]; + version: 6; + axes: readonly BlastAxis[]; defaults: readonly DefaultKey[]; + deferredDefaults: typeof DEFERRED_DEFAULTS; population: { scanned: number; oracleAxisFiles: number; @@ -91,7 +94,6 @@ interface BlastRadiusBaseline { }; schedOptionsRows: { rows: number; - parserRejectedWithoutSchedOptions: number; floatFinish: number; retainedLogic: number; openEndedNotCritical: number; @@ -111,6 +113,7 @@ interface BlastRadiusBaseline { derivedLagSuccessor: number; derivedLag24Hour: number; derivedLagProjectDefault: number; + retainedProjectEndValues: number; }; corpusUnion: string[]; files: MeasuredFile[]; @@ -124,6 +127,22 @@ interface BlastRadiusBaseline { }; } +interface RawXerRow { + cells: Record; +} + +interface RawXerTable { + fields: string[]; + rows: RawXerRow[]; +} + +type RawXerTables = Map; + +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)); @@ -172,8 +191,35 @@ function decodedForTableMarker(bytes: Uint8Array): string { return new TextDecoder('windows-1252').decode(payload); } -function hasScheduleTableMarker(bytes: Uint8Array): boolean { - return /^%T\tSCHEDOPTIONS\s*$/m.test(decodedForTableMarker(bytes)); +/** Onafhankelijke, minimale tabelscan voor de blast-radiuspoort. Deze gebruikt bewust niet + * `parseXerTables` of `deriveXerScheduleOptions`: populatie, SCHEDOPTIONS-rijen en kolommenunion + * mogen niet groen worden doordat productieparser en productie-afleiding dezelfde fout delen. */ +function scanRawXerTables(bytes: Uint8Array): RawXerTables { + const tables: RawXerTables = new Map(); + let tableName = ''; + 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') { + tableName = cells[1]?.trim().toUpperCase() ?? ''; + fields = []; + if (tableName && !tables.has(tableName)) tables.set(tableName, { fields: [], rows: [] }); + continue; + } + if (!tableName) continue; + if (cells[0] === '%F') { + fields = cells.slice(1).map(field => field.trim().toLowerCase()); + const table = tables.get(tableName)!; + table.fields = [...new Set([...table.fields, ...fields])]; + continue; + } + if (cells[0] !== '%R' || fields.length === 0) continue; + const row: RawXerRow = { cells: {} }; + fields.forEach((field, index) => { row.cells[field] = cells[index + 1] ?? ''; }); + tables.get(tableName)!.rows.push(row); + } + return tables; } function hasRawNegativeFloat(bytes: Uint8Array): boolean { @@ -208,7 +254,7 @@ function addCounters(target: XerFidelityCounters, source: XerFidelityCounters): } } -function solvedTaskAxis(task: XerSolvedTask, axis: XerFidelityAxis): string | number | undefined { +function solvedTaskAxis(task: BlastSolvedTask, axis: BlastAxis): string | number | boolean | undefined { switch (axis) { case 'es': return task.earlyStart; case 'ef': return task.earlyFinish; @@ -216,25 +262,26 @@ function solvedTaskAxis(task: XerSolvedTask, axis: XerFidelityAxis): string | nu case 'lf': return task.lateFinish; case 'tf': return task.totalFloatMinutes; case 'ff': return task.freeFloatMinutes; + case 'isCritical': return task.isCritical; } } -function movement(before: XerSolvedProject, after: XerSolvedProject): AxisVector { +function movement(before: BlastSolvedProject, after: BlastSolvedProject): AxisVector { const afterById = new Map(after.tasks.map(task => [task.sourceTaskId, task])); - return XER_FIDELITY_AXES.map(axis => before.tasks.reduce((sum, 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 negativeFloatTasks(solved: XerSolvedProject): number { +function negativeFloatTasks(solved: BlastSolvedProject): number { return solved.tasks.filter(task => (task.totalFloatMinutes ?? 0) < 0).length; } function projectResult( imported: ReturnType, variant: SolverVariant, -): XerSolvedProject { +): BlastSolvedProject { const tasks = cloneTasksForSolve(imported.tasks); const cpm = solveProject({ tasks, @@ -251,7 +298,7 @@ function projectResult( [imported.calendar.id, imported.calendar], ...(imported.resourceCalendars ?? []).map(calendar => [calendar.id, calendar] as const), ]); - const output: XerSolvedTask[] = tasks + const output: BlastSolvedTask[] = tasks .filter(task => task.p6ActivityType !== undefined) .map(task => { const calendar = (task.calendarId ? calendarById.get(task.calendarId) : undefined) ?? imported.calendar; @@ -265,13 +312,14 @@ function projectResult( lateFinish: task.time.lateFinish, totalFloatMinutes: Math.round(task.time.totalFloat * minutesPerDay), freeFloatMinutes: Math.round(task.time.freeFloat * minutesPerDay), + isCritical: cpm.tasks.get(task.id)?.isCritical, }; }); return { projectId: imported.project.id, tasks: output }; } function variantDefinitions( - derived: ReturnType, + criticalDefinition: SchedulingOptions['criticalDefinition'], ): Record { return { totalFloatFinish: { @@ -290,14 +338,6 @@ function variantDefinitions( chosen: { schedulingOptions: { lagCalendar: 'predecessor' } }, counterfactual: { schedulingOptions: { lagCalendar: 'successor' } }, }, - projectEndForFloat: { - chosen: { schedulingOptions: { useProjectEndDateForFloat: true } }, - counterfactual: { schedulingOptions: { useProjectEndDateForFloat: false } }, - }, - expectedFinishDates: { - chosen: { schedulingOptions: { useExpectedFinishDates: true } }, - counterfactual: { schedulingOptions: { useExpectedFinishDates: false } }, - }, preserveActualDates: { chosen: { schedulingOptions: { preserveActualDatesInBackwardPass: true } }, counterfactual: { schedulingOptions: { preserveActualDatesInBackwardPass: false } }, @@ -307,8 +347,39 @@ function variantDefinitions( counterfactual: { schedulingOptions: { clampNegativeFreeFloat: false } }, }, projectCriticalDefinition: { - chosen: { schedulingOptions: { criticalDefinition: derived.schedulingOptions.criticalDefinition } }, - counterfactual: { schedulingOptions: { criticalDefinition: { mode: 'totalFloat', threshold: 0 } } }, + chosen: { schedulingOptions: { criticalDefinition } }, + counterfactual: { schedulingOptions: { criticalDefinition: { mode: 'totalFloat', thresholdHours: 0 } } }, + }, + }; +} + +function rawProjectCriticalDefinition( + tables: RawXerTables, + projectId: string, +): SchedulingOptions['criticalDefinition'] { + const row = tables.get('PROJECT')?.rows.find(item => item.cells.proj_id?.trim() === projectId); + if (!row) return { mode: 'totalFloat', thresholdHours: 0 }; + const token = row.cells.critical_path_type?.trim().toUpperCase() ?? ''; + if (token === 'CT_DRIVPATH') return { mode: 'longestPath' }; + const raw = row.cells.critical_drtn_hr_cnt?.trim().replace(',', '.') ?? ''; + const thresholdHours = raw === '' ? 0 : Number(raw); + return { + mode: 'totalFloat', + thresholdHours: Number.isFinite(thresholdHours) ? thresholdHours : 0, + }; +} + +function independentXerDefaults(tables: RawXerTables, projectId: string): SolverVariant { + return { + progressMode: 'RETAINED_LOGIC', + schedulingOptions: { + lagCalendar: 'predecessor', + criticalDefinition: rawProjectCriticalDefinition(tables, projectId), + totalFloatMode: 'finish', + makeOpenEndedCritical: false, + useExpectedFinishDates: true, + preserveActualDatesInBackwardPass: true, + clampNegativeFreeFloat: true, }, }; } @@ -320,42 +391,29 @@ function deferredCode(error: unknown): string { function measureCorpus(root: string): BlastRadiusBaseline { const scanned = listXerFilesRecursive(root).map(path => { const bytes = readFileSync(path); - return { path, bytes, fullHash: hash(bytes), truth: scanXerGroundTruth(bytes) }; + return { + path, + bytes, + fullHash: hash(bytes), + truth: scanXerGroundTruth(bytes), + rawTables: scanRawXerTables(bytes), + }; }); const oracleFiles = scanned.filter(file => hasOracleAxis(file.truth)); - const parsed = oracleFiles.map(file => { - try { - return { ...file, tables: parseXerTables(file.bytes) }; - } catch { - if (hasScheduleTableMarker(file.bytes)) { - throw new Error(`${file.fullHash.slice(0, 16)}: orakeldrager met SCHEDOPTIONS is niet parseerbaar`); - } - return { ...file, tables: null }; - } - }); - const hasScheduleRows = (file: typeof parsed[number]): boolean => - (file.tables?.tables.get('SCHEDOPTIONS')?.rows.length ?? 0) > 0; - const without = parsed.filter(file => !hasScheduleRows(file)); - const withTable = parsed.filter(hasScheduleRows); - - const allScheduleTables = []; + const hasScheduleRows = (file: typeof scanned[number]): boolean => + (file.rawTables.get('SCHEDOPTIONS')?.rows.length ?? 0) > 0; + const without = oracleFiles.filter(file => !hasScheduleRows(file)); + const withTable = oracleFiles.filter(hasScheduleRows); + + const allScheduleTables = scanned + .map(file => file.rawTables.get('SCHEDOPTIONS')) + .filter((table): table is RawXerTable => table !== undefined); const derivedRows: XerScheduleOptionsResult[] = []; - let parserRejectedWithoutSchedOptions = 0; - for (const file of scanned) { - try { - const tables = parseXerTables(file.bytes); - const table = tables.tables.get('SCHEDOPTIONS'); - if (table) { - allScheduleTables.push(table); - for (const row of table.rows) { - derivedRows.push(deriveXerScheduleOptions(tables, row.cells.proj_id?.trim() ?? '')); - } - } - } catch { - if (hasScheduleTableMarker(file.bytes)) { - throw new Error(`${file.fullHash.slice(0, 16)}: SCHEDOPTIONS-drager is niet parseerbaar`); - } - parserRejectedWithoutSchedOptions++; + for (const file of scanned.filter(hasScheduleRows)) { + const tables = parseXerTables(file.bytes); + const rawRows = file.rawTables.get('SCHEDOPTIONS')!.rows; + for (const row of rawRows) { + derivedRows.push(deriveXerScheduleOptions(tables, row.cells.proj_id?.trim() ?? '')); } } const union = [...new Set(allScheduleTables.flatMap(table => table.fields))].sort(); @@ -394,19 +452,15 @@ function measureCorpus(root: string): BlastRadiusBaseline { continue; } - if (!file.tables) throw new Error(`${id}: productie-import slaagde zonder parseerbare tabellen`); - const derived = deriveXerScheduleOptions(file.tables, imported.project.id, { - hoursPerDay: imported.calendar.hoursPerDay, - taskCount: imported.tasks.filter((task: Task) => task.p6ActivityType !== undefined).length, - }); + const xerDefaultsVariant = independentXerDefaults(file.rawTables, imported.project.id); const house = projectResult(imported, {}); const houseMeasurement = measureXerFidelity(file.truth, [house]); if (houseMeasurement.errors.length > 0) throw new Error(`${id}: fidelity-uitlijning mislukt`); addCounters(fidelity.house, houseMeasurement.counters); const xerDefaults = projectResult(imported, { - progressMode: derived.progressMode, - schedulingOptions: derived.schedulingOptions, + progressMode: xerDefaultsVariant.progressMode, + schedulingOptions: xerDefaultsVariant.schedulingOptions, }); const xerDefaultsFidelity = measureXerFidelity(file.truth, [xerDefaults]); if (xerDefaultsFidelity.errors.length > 0) { @@ -415,7 +469,9 @@ function measureCorpus(root: string): BlastRadiusBaseline { addCounters(fidelity.xerDefaults, xerDefaultsFidelity.counters); const defaultMeasurements = {} as Record; - for (const [key, pair] of Object.entries(variantDefinitions(derived)) as Array<[ + for (const [key, pair] of Object.entries(variantDefinitions( + xerDefaultsVariant.schedulingOptions?.criticalDefinition, + )) as Array<[ DefaultKey, ReturnType[DefaultKey], ]>) { @@ -448,9 +504,10 @@ function measureCorpus(root: string): BlastRadiusBaseline { } return { - version: 5, - axes: XER_FIDELITY_AXES, + version: 6, + axes: BLAST_AXES, defaults: DEFAULT_KEYS, + deferredDefaults: DEFERRED_DEFAULTS, population: { scanned: scanned.length, oracleAxisFiles: oracleFiles.length, @@ -462,11 +519,10 @@ function measureCorpus(root: string): BlastRadiusBaseline { withoutSchedOptionsNegativeFloatFiles: without.filter(file => file.truth.tasks.some(taskHasNegativeFloat)).length, rawNegativeFloatFiles: scanned.filter(file => hasRawNegativeFloat(file.bytes)).length, rawWithoutSchedOptionsNegativeFloatFiles: scanned.filter(file => - !hasScheduleTableMarker(file.bytes) && hasRawNegativeFloat(file.bytes)).length, + !hasScheduleRows(file) && hasRawNegativeFloat(file.bytes)).length, }, schedOptionsRows: { rows: scheduleRows.length, - parserRejectedWithoutSchedOptions, floatFinish: normalizedCount('sched_float_type', 'FT_FF'), retainedLogic: normalizedCount('sched_retained_logic', 'Y'), openEndedNotCritical: normalizedCount('sched_open_critical_flag', 'N'), @@ -488,6 +544,8 @@ function measureCorpus(root: string): BlastRadiusBaseline { 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, @@ -500,10 +558,12 @@ if (!existsSync(baselinePath)) { } else { const committed = JSON.parse(readFileSync(baselinePath, 'utf8')) as BlastRadiusBaseline; eq('baselineversie en asvolgorde', { version: committed.version, axes: committed.axes }, { - version: 5, - axes: XER_FIDELITY_AXES, + version: 6, + axes: BLAST_AXES, }); eq('baseline bevat alle defaults los van elkaar', committed.defaults, DEFAULT_KEYS); + eq('expectedFinishDates staat eerlijk als X7-uitstel geregistreerd', + committed.deferredDefaults, DEFERRED_DEFAULTS); eq('baseline pint exact de 36 actuele bestanden zonder SCHEDOPTIONS', committed.files.length, 36); eq('baseline bevat alleen hash-identiteiten', committed.files.every(file => /^[0-9a-f]{16}-\d+$/.test(file.id)), true); @@ -513,7 +573,7 @@ if (!existsSync(baselinePath)) { 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 === XER_FIDELITY_AXES.length + && file.xerDefaultsMovement.length === BLAST_AXES.length && typeof file.xerDefaultsNegativeFloatTasks === 'number'), true); } @@ -538,7 +598,6 @@ if (!root) { }); eq('meerderheidsinstellingen worden uit alle 50 tabelrijen herleid', measured.schedOptionsRows, { rows: 50, - parserRejectedWithoutSchedOptions: 22, floatFinish: 41, retainedLogic: 48, openEndedNotCritical: 48, @@ -558,7 +617,10 @@ if (!root) { derivedLagSuccessor: 3, derivedLag24Hour: 1, derivedLagProjectDefault: 1, + retainedProjectEndValues: 47, }); + 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()); @@ -566,7 +628,7 @@ if (!root) { console.log(JSON.stringify(measured)); } else if (existsSync(baselinePath)) { const committed = JSON.parse(readFileSync(baselinePath, 'utf8')) as BlastRadiusBaseline; - eq('per-bestand/per-as-defaultbeweging en X1-fidelity blijven exact gepind', measured, committed); + eq('per-bestand/per-as-defaultbeweging en XER-fidelity blijven exact gepind', measured, committed); } } diff --git a/tests/planning/check-xer-schedule-options.ts b/tests/planning/check-xer-schedule-options.ts index fb83bf024..a933b575d 100644 --- a/tests/planning/check-xer-schedule-options.ts +++ b/tests/planning/check-xer-schedule-options.ts @@ -290,17 +290,35 @@ const sixDayCalendar: WorkCalendar = { }; const endTasks = [task('LONG', 6, 'six-day'), task('SHORT', 1, 'end-project')]; const ordinaryEnd = new CPMSolver(endTasks, [], endProjectCalendar, [sixDayCalendar]).solve(); -const projectEndPerCalendar = new CPMSolver( - endTasks, - [], - endProjectCalendar, - [sixDayCalendar], - { schedulingOptions: { useProjectEndDateForFloat: true } }, -).solve(); -eq('projecteinde-voor-float zaait late finish op de eigen taakkalender', { - ordinary: ordinaryEnd.tasks.get('SHORT')?.lateFinish, - p6: projectEndPerCalendar.tasks.get('SHORT')?.lateFinish, -}, { ordinary: '2026-06-06', p6: '2026-06-05' }); +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, @@ -309,7 +327,6 @@ const explicitInertEnd = new CPMSolver( [sixDayCalendar], { schedulingOptions: { - useProjectEndDateForFloat: false, useExpectedFinishDates: false, preserveActualDatesInBackwardPass: false, clampNegativeFreeFloat: false, @@ -333,7 +350,7 @@ const p6MultiCalendar = new CPMSolver( [], p6MonFri, [p6SixDay], - { schedulingOptions: { useProjectEndDateForFloat: true, totalFloatMode: 'finish' } }, + { schedulingOptions: { totalFloatMode: 'finish' } }, ).solve(); eq('P6-geval 06: meerdere kalenders, alle zes datum-/floatassen', { A: axes(p6MultiCalendar, 'A'), @@ -437,6 +454,59 @@ eq('P6-voltooide activiteit rapporteert geen float, ook niet bij out-of-sequence 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[]; @@ -458,6 +528,55 @@ function xer(projectFields: readonly string[], projectValues: readonly string[], 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 expliciet als TODO-bronwaarde bewaard', { + 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: 'todo', + reason: 'Meerdere gelijktijdig geplande projecten ontbreken; X5 bewaart de bronwaarde zonder solversturing.', + }, +}); +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'], @@ -468,47 +587,87 @@ eq('expliciete XER-defaultset is brongebonden en compleet', withoutTable, { progressMode: 'RETAINED_LOGIC', schedulingOptions: { lagCalendar: 'predecessor', - criticalDefinition: { mode: 'totalFloat', threshold: 2 }, + criticalDefinition: { mode: 'totalFloat', thresholdHours: 16 }, totalFloatMode: 'finish', makeOpenEndedCritical: false, - useProjectEndDateForFloat: true, useExpectedFinishDates: true, preserveActualDatesInBackwardPass: true, clampNegativeFreeFloat: true, }, + retainedSource: {}, fallbacks: [], }); eq('geexporteerde defaults blijven de ongewijzigde nul-drempel leveren', XER_SCHEDULING_DEFAULTS, { progressMode: 'RETAINED_LOGIC', schedulingOptions: { lagCalendar: 'predecessor', - criticalDefinition: { mode: 'totalFloat', threshold: 0 }, + criticalDefinition: { mode: 'totalFloat', thresholdHours: 0 }, totalFloatMode: 'finish', makeOpenEndedCritical: false, - useProjectEndDateForFloat: true, useExpectedFinishDates: true, preserveActualDatesInBackwardPass: true, clampNegativeFreeFloat: true, }, }); -eq('default 1/9: finish-float', XER_SCHEDULING_DEFAULTS.schedulingOptions.totalFloatMode, 'finish'); -eq('default 2/9: retained logic', XER_SCHEDULING_DEFAULTS.progressMode, 'RETAINED_LOGIC'); -eq('default 3/9: kritiek op totale float met nulgrens', +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', threshold: 0 }); -eq('default 4/9: open eindes niet automatisch kritiek', + { mode: 'totalFloat', thresholdHours: 0 }); +eq('default 4/8: open eindes niet automatisch kritiek', XER_SCHEDULING_DEFAULTS.schedulingOptions.makeOpenEndedCritical, false); -eq('default 5/9: relatielag op de voorgangerskalender', +eq('default 5/8: relatielag op de voorgangerskalender', XER_SCHEDULING_DEFAULTS.schedulingOptions.lagCalendar, 'predecessor'); -eq('default 6/9: float tot projecteinde per taakkalender', - XER_SCHEDULING_DEFAULTS.schedulingOptions.useProjectEndDateForFloat, true); -eq('default 7/9: verwachte einddatums als bewaard bronbeleid', +eq('default 6/8: verwachte einddatums als bewaard bronbeleid (solverconsumptie volgt in X7)', XER_SCHEDULING_DEFAULTS.schedulingOptions.useExpectedFinishDates, true); -eq('default 8/9: P6-actuals blijven feiten in de backward-pass', +eq('default 7/8: P6-actuals blijven feiten in de backward-pass', XER_SCHEDULING_DEFAULTS.schedulingOptions.preserveActualDatesInBackwardPass, true); -eq('default 9/9: P6-vrije-float wordt niet negatief', +eq('default 8/8: P6-vrije-float wordt niet negatief', XER_SCHEDULING_DEFAULTS.schedulingOptions.clampNegativeFreeFloat, 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, @@ -552,12 +711,12 @@ eq('bekende enums en vlaggen worden case-insensitief naar bestaande opties gemap criticalDefinition: { mode: 'longestPath' }, totalFloatMode: 'start', makeOpenEndedCritical: true, - useProjectEndDateForFloat: false, useExpectedFinishDates: false, preserveActualDatesInBackwardPass: true, clampNegativeFreeFloat: true, floatPaths: { enabled: true, method: 'TOTAL_FLOAT', maxPaths: 3 }, }, + retainedSource: { sched_use_project_end_date_for_float: false }, fallbacks: [], }); diff --git a/tests/planning/xer-schedoptions-blast-radius.json b/tests/planning/xer-schedoptions-blast-radius.json index 650e90b66..59589ce85 100644 --- a/tests/planning/xer-schedoptions-blast-radius.json +++ b/tests/planning/xer-schedoptions-blast-radius.json @@ -1,24 +1,30 @@ { - "version": 5, + "version": 6, "axes": [ "es", "ef", "ls", "lf", "tf", - "ff" + "ff", + "isCritical" ], "defaults": [ "totalFloatFinish", "retainedLogic", "openEndedNotCritical", "predecessorLagCalendar", - "projectEndForFloat", - "expectedFinishDates", "preserveActualDates", "clampNegativeFreeFloat", "projectCriticalDefinition" ], + "deferredDefaults": [ + { + "key": "expectedFinishDates", + "owner": "X7", + "reason": "Het X7-taakveld expectedFinish ontbreekt nog; X5 bewaart alleen het bronbeleid." + } + ], "population": { "scanned": 93, "oracleAxisFiles": 60, @@ -33,7 +39,6 @@ }, "schedOptionsRows": { "rows": 50, - "parserRejectedWithoutSchedOptions": 22, "floatFinish": 41, "retainedLogic": 48, "openEndedNotCritical": 48, @@ -52,7 +57,8 @@ "derivedLagPredecessor": 45, "derivedLagSuccessor": 3, "derivedLag24Hour": 1, - "derivedLagProjectDefault": 1 + "derivedLagProjectDefault": 1, + "retainedProjectEndValues": 47 }, "corpusUnion": [ "enable_multiple_longest_path_calc", @@ -96,6 +102,7 @@ 0, 0, 0, + 0, 0 ], "xerDefaultsNegativeFloatTasks": 0, @@ -107,6 +114,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -119,6 +127,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -131,6 +140,7 @@ 0, 0, 1, + 1, 1 ], "chosenNegativeFloatTasks": 0, @@ -139,29 +149,6 @@ "predecessorLagCalendar": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "projectEndForFloat": { - "movement": [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "expectedFinishDates": { - "movement": [ 0, 0, 0, @@ -179,6 +166,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -191,6 +179,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -203,6 +192,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -226,10 +216,11 @@ "xerDefaultsMovement": [ 0, 0, - 21, - 18, + 19, + 16, 16, - 4 + 4, + 0 ], "xerDefaultsNegativeFloatTasks": 1, "defaults": { @@ -240,6 +231,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -252,6 +244,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -264,6 +257,7 @@ 0, 0, 1, + 1, 1 ], "chosenNegativeFloatTasks": 0, @@ -272,29 +266,6 @@ "predecessorLagCalendar": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "projectEndForFloat": { - "movement": [ - 0, - 0, - 2, - 4, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "expectedFinishDates": { - "movement": [ 0, 0, 0, @@ -312,7 +283,8 @@ 19, 16, 16, - 3 + 3, + 0 ], "chosenNegativeFloatTasks": 1, "counterfactualNegativeFloatTasks": 0 @@ -324,7 +296,8 @@ 0, 0, 0, - 1 + 1, + 0 ], "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 @@ -336,6 +309,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -362,7 +336,8 @@ 6, 4, 4, - 1 + 1, + 0 ], "xerDefaultsNegativeFloatTasks": 0, "defaults": { @@ -372,11 +347,12 @@ 0, 0, 0, + 1, 0, 0 ], "chosenNegativeFloatTasks": 4, - "counterfactualNegativeFloatTasks": 4 + "counterfactualNegativeFloatTasks": 5 }, "retainedLogic": { "movement": [ @@ -385,7 +361,8 @@ 20, 20, 5, - 1 + 1, + 0 ], "chosenNegativeFloatTasks": 4, "counterfactualNegativeFloatTasks": 4 @@ -397,6 +374,7 @@ 0, 0, 1, + 1, 1 ], "chosenNegativeFloatTasks": 4, @@ -405,29 +383,6 @@ "predecessorLagCalendar": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 4, - "counterfactualNegativeFloatTasks": 4 - }, - "projectEndForFloat": { - "movement": [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 4, - "counterfactualNegativeFloatTasks": 4 - }, - "expectedFinishDates": { - "movement": [ 0, 0, 0, @@ -445,6 +400,7 @@ 6, 4, 4, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -457,7 +413,8 @@ 0, 0, 0, - 1 + 1, + 0 ], "chosenNegativeFloatTasks": 4, "counterfactualNegativeFloatTasks": 4 @@ -469,6 +426,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 4, @@ -485,10 +443,11 @@ "xerDefaultsMovement": [ 0, 0, - 1, - 1, + 0, + 0, 2, - 1 + 1, + 0 ], "xerDefaultsNegativeFloatTasks": 1, "defaults": { @@ -499,6 +458,7 @@ 0, 0, 2, + 0, 0 ], "chosenNegativeFloatTasks": 1, @@ -511,6 +471,7 @@ 11, 11, 2, + 0, 0 ], "chosenNegativeFloatTasks": 2, @@ -519,17 +480,6 @@ "openEndedNotCritical": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 2, - "counterfactualNegativeFloatTasks": 2 - }, - "predecessorLagCalendar": { - "movement": [ 0, 0, 0, @@ -540,20 +490,9 @@ "chosenNegativeFloatTasks": 2, "counterfactualNegativeFloatTasks": 2 }, - "projectEndForFloat": { + "predecessorLagCalendar": { "movement": [ 0, - 0, - 1, - 1, - 0, - 0 - ], - "chosenNegativeFloatTasks": 2, - "counterfactualNegativeFloatTasks": 2 - }, - "expectedFinishDates": { - "movement": [ 0, 0, 0, @@ -571,6 +510,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 2, @@ -583,7 +523,8 @@ 0, 0, 0, - 1 + 1, + 0 ], "chosenNegativeFloatTasks": 2, "counterfactualNegativeFloatTasks": 2 @@ -595,6 +536,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 2, @@ -614,6 +556,7 @@ 0, 0, 0, + 0, 0 ], "xerDefaultsNegativeFloatTasks": 0, @@ -625,6 +568,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -637,6 +581,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -645,17 +590,6 @@ "openEndedNotCritical": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "predecessorLagCalendar": { - "movement": [ 0, 0, 0, @@ -666,20 +600,9 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, - "projectEndForFloat": { + "predecessorLagCalendar": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "expectedFinishDates": { - "movement": [ 0, 0, 0, @@ -697,6 +620,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -709,6 +633,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -721,6 +646,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -740,7 +666,8 @@ 16, 12, 12, - 9 + 9, + 0 ], "xerDefaultsNegativeFloatTasks": 37, "defaults": { @@ -750,6 +677,7 @@ 0, 0, 0, + 2, 0, 0 ], @@ -763,7 +691,8 @@ 1, 1, 67, - 28 + 28, + 0 ], "chosenNegativeFloatTasks": 44, "counterfactualNegativeFloatTasks": 44 @@ -775,6 +704,7 @@ 0, 0, 2, + 2, 2 ], "chosenNegativeFloatTasks": 44, @@ -787,34 +717,11 @@ 76, 76, 106, - 28 - ], - "chosenNegativeFloatTasks": 44, - "counterfactualNegativeFloatTasks": 43 - }, - "projectEndForFloat": { - "movement": [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 44, - "counterfactualNegativeFloatTasks": 44 - }, - "expectedFinishDates": { - "movement": [ - 0, - 0, - 0, - 0, - 0, + 28, 0 ], "chosenNegativeFloatTasks": 44, - "counterfactualNegativeFloatTasks": 44 + "counterfactualNegativeFloatTasks": 43 }, "preserveActualDates": { "movement": [ @@ -823,7 +730,8 @@ 16, 12, 12, - 7 + 7, + 0 ], "chosenNegativeFloatTasks": 37, "counterfactualNegativeFloatTasks": 44 @@ -835,7 +743,8 @@ 0, 0, 0, - 4 + 4, + 0 ], "chosenNegativeFloatTasks": 44, "counterfactualNegativeFloatTasks": 44 @@ -847,6 +756,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 44, @@ -866,7 +776,8 @@ 2666, 2618, 2618, - 1785 + 1785, + 504 ], "xerDefaultsNegativeFloatTasks": 543, "defaults": { @@ -876,6 +787,7 @@ 0, 0, 0, + 331, 0, 0 ], @@ -889,7 +801,8 @@ 6977, 6977, 6971, - 547 + 547, + 31 ], "chosenNegativeFloatTasks": 38, "counterfactualNegativeFloatTasks": 57 @@ -901,6 +814,7 @@ 0, 0, 11, + 11, 11 ], "chosenNegativeFloatTasks": 38, @@ -913,30 +827,7 @@ 164, 164, 164, - 10 - ], - "chosenNegativeFloatTasks": 38, - "counterfactualNegativeFloatTasks": 38 - }, - "projectEndForFloat": { - "movement": [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 38, - "counterfactualNegativeFloatTasks": 38 - }, - "expectedFinishDates": { - "movement": [ - 0, - 0, - 0, - 0, - 0, + 10, 0 ], "chosenNegativeFloatTasks": 38, @@ -949,7 +840,8 @@ 2666, 2618, 2618, - 1639 + 1639, + 504 ], "chosenNegativeFloatTasks": 543, "counterfactualNegativeFloatTasks": 38 @@ -961,7 +853,8 @@ 0, 0, 0, - 621 + 621, + 0 ], "chosenNegativeFloatTasks": 38, "counterfactualNegativeFloatTasks": 38 @@ -973,6 +866,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 38, @@ -1006,6 +900,7 @@ 0, 0, 0, + 0, 0 ], "xerDefaultsNegativeFloatTasks": 0, @@ -1017,6 +912,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -1029,6 +925,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -1037,17 +934,6 @@ "openEndedNotCritical": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "predecessorLagCalendar": { - "movement": [ 0, 0, 0, @@ -1058,20 +944,9 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, - "projectEndForFloat": { + "predecessorLagCalendar": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "expectedFinishDates": { - "movement": [ 0, 0, 0, @@ -1089,6 +964,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -1101,6 +977,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -1113,6 +990,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -1132,7 +1010,8 @@ 0, 0, 0, - 39 + 39, + 0 ], "xerDefaultsNegativeFloatTasks": 0, "defaults": { @@ -1143,6 +1022,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -1155,7 +1035,8 @@ 0, 0, 9, - 9 + 9, + 0 ], "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 @@ -1167,6 +1048,7 @@ 0, 0, 1, + 1, 1 ], "chosenNegativeFloatTasks": 0, @@ -1179,47 +1061,25 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, - "projectEndForFloat": { + "preserveActualDates": { "movement": [ 0, 0, 0, 0, 0, + 30, 0 ], "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, - "expectedFinishDates": { - "movement": [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "preserveActualDates": { - "movement": [ - 0, - 0, - 0, - 0, - 0, - 30 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, "clampNegativeFreeFloat": { "movement": [ 0, @@ -1227,7 +1087,8 @@ 0, 0, 0, - 39 + 39, + 0 ], "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 @@ -1239,6 +1100,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -1258,6 +1120,7 @@ 0, 0, 0, + 0, 0 ], "xerDefaultsNegativeFloatTasks": 0, @@ -1269,6 +1132,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -1281,6 +1145,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -1293,6 +1158,7 @@ 0, 0, 1, + 1, 1 ], "chosenNegativeFloatTasks": 0, @@ -1301,29 +1167,6 @@ "predecessorLagCalendar": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "projectEndForFloat": { - "movement": [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "expectedFinishDates": { - "movement": [ 0, 0, 0, @@ -1341,6 +1184,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -1353,6 +1197,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -1365,6 +1210,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -1381,10 +1227,11 @@ "xerDefaultsMovement": [ 0, 0, - 26, 25, 24, - 12 + 24, + 12, + 0 ], "xerDefaultsNegativeFloatTasks": 1, "defaults": { @@ -1395,6 +1242,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 1, @@ -1407,7 +1255,8 @@ 30, 30, 25, - 6 + 6, + 1 ], "chosenNegativeFloatTasks": 1, "counterfactualNegativeFloatTasks": 1 @@ -1415,17 +1264,6 @@ "openEndedNotCritical": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 1, - "counterfactualNegativeFloatTasks": 1 - }, - "predecessorLagCalendar": { - "movement": [ 0, 0, 0, @@ -1436,20 +1274,9 @@ "chosenNegativeFloatTasks": 1, "counterfactualNegativeFloatTasks": 1 }, - "projectEndForFloat": { + "predecessorLagCalendar": { "movement": [ 0, - 0, - 1, - 5, - 0, - 0 - ], - "chosenNegativeFloatTasks": 1, - "counterfactualNegativeFloatTasks": 1 - }, - "expectedFinishDates": { - "movement": [ 0, 0, 0, @@ -1467,7 +1294,8 @@ 25, 24, 24, - 11 + 11, + 0 ], "chosenNegativeFloatTasks": 1, "counterfactualNegativeFloatTasks": 1 @@ -1479,7 +1307,8 @@ 0, 0, 0, - 5 + 5, + 0 ], "chosenNegativeFloatTasks": 1, "counterfactualNegativeFloatTasks": 1 @@ -1491,6 +1320,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 1, @@ -1510,6 +1340,7 @@ 0, 0, 0, + 0, 0 ], "xerDefaultsNegativeFloatTasks": 0, @@ -1521,6 +1352,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -1533,6 +1365,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -1541,17 +1374,6 @@ "openEndedNotCritical": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "predecessorLagCalendar": { - "movement": [ 0, 0, 0, @@ -1562,20 +1384,9 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, - "projectEndForFloat": { + "predecessorLagCalendar": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "expectedFinishDates": { - "movement": [ 0, 0, 0, @@ -1593,6 +1404,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -1605,6 +1417,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -1617,6 +1430,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -1636,7 +1450,8 @@ 0, 0, 0, - 53 + 53, + 0 ], "xerDefaultsNegativeFloatTasks": 0, "defaults": { @@ -1647,6 +1462,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -1659,7 +1475,8 @@ 0, 0, 8, - 8 + 8, + 0 ], "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 @@ -1671,6 +1488,7 @@ 0, 0, 1, + 1, 1 ], "chosenNegativeFloatTasks": 0, @@ -1679,29 +1497,6 @@ "predecessorLagCalendar": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "projectEndForFloat": { - "movement": [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "expectedFinishDates": { - "movement": [ 0, 0, 0, @@ -1719,7 +1514,8 @@ 0, 0, 0, - 45 + 45, + 0 ], "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 @@ -1731,7 +1527,8 @@ 0, 0, 0, - 53 + 53, + 0 ], "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 @@ -1743,6 +1540,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -1762,7 +1560,8 @@ 2, 1, 1, - 1 + 1, + 0 ], "xerDefaultsNegativeFloatTasks": 0, "defaults": { @@ -1773,6 +1572,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -1785,6 +1585,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -1797,6 +1598,7 @@ 0, 0, 1, + 1, 1 ], "chosenNegativeFloatTasks": 0, @@ -1805,29 +1607,6 @@ "predecessorLagCalendar": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "projectEndForFloat": { - "movement": [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "expectedFinishDates": { - "movement": [ 0, 0, 0, @@ -1845,7 +1624,8 @@ 2, 1, 1, - 1 + 1, + 0 ], "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 @@ -1857,6 +1637,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -1869,6 +1650,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -1888,7 +1670,8 @@ 3, 1, 1, - 2 + 2, + 0 ], "xerDefaultsNegativeFloatTasks": 0, "defaults": { @@ -1898,11 +1681,12 @@ 0, 0, 0, + 1, 0, 0 ], "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 + "counterfactualNegativeFloatTasks": 1 }, "retainedLogic": { "movement": [ @@ -1911,6 +1695,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -1923,6 +1708,7 @@ 0, 0, 1, + 1, 1 ], "chosenNegativeFloatTasks": 0, @@ -1931,29 +1717,6 @@ "predecessorLagCalendar": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "projectEndForFloat": { - "movement": [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "expectedFinishDates": { - "movement": [ 0, 0, 0, @@ -1971,7 +1734,8 @@ 3, 1, 1, - 2 + 2, + 0 ], "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 @@ -1983,6 +1747,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -1995,6 +1760,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2014,6 +1780,7 @@ 0, 0, 0, + 0, 0 ], "xerDefaultsNegativeFloatTasks": 0, @@ -2025,6 +1792,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2037,6 +1805,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2049,6 +1818,7 @@ 0, 0, 3, + 3, 3 ], "chosenNegativeFloatTasks": 0, @@ -2057,29 +1827,6 @@ "predecessorLagCalendar": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "projectEndForFloat": { - "movement": [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "expectedFinishDates": { - "movement": [ 0, 0, 0, @@ -2097,6 +1844,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2109,6 +1857,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2121,6 +1870,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2140,6 +1890,7 @@ 0, 0, 0, + 0, 0 ], "xerDefaultsNegativeFloatTasks": 0, @@ -2151,6 +1902,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2163,6 +1915,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2175,6 +1928,7 @@ 0, 0, 3, + 3, 3 ], "chosenNegativeFloatTasks": 0, @@ -2187,66 +1941,46 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, - "projectEndForFloat": { + "preserveActualDates": { "movement": [ 0, 0, 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, - "expectedFinishDates": { + "clampNegativeFreeFloat": { "movement": [ 0, 0, 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, - "preserveActualDates": { - "movement": [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "clampNegativeFreeFloat": { - "movement": [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "projectCriticalDefinition": { + "projectCriticalDefinition": { "movement": [ 0, 0, 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2266,6 +2000,7 @@ 0, 0, 0, + 0, 0 ], "xerDefaultsNegativeFloatTasks": 0, @@ -2277,6 +2012,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2289,6 +2025,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2297,17 +2034,6 @@ "openEndedNotCritical": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "predecessorLagCalendar": { - "movement": [ 0, 0, 0, @@ -2318,20 +2044,9 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, - "projectEndForFloat": { + "predecessorLagCalendar": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "expectedFinishDates": { - "movement": [ 0, 0, 0, @@ -2349,6 +2064,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2361,6 +2077,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2373,6 +2090,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2392,7 +2110,8 @@ 7, 6, 6, - 1 + 1, + 0 ], "xerDefaultsNegativeFloatTasks": 5, "defaults": { @@ -2403,6 +2122,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2415,7 +2135,8 @@ 8, 8, 8, - 1 + 1, + 2 ], "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 5 @@ -2423,17 +2144,6 @@ "openEndedNotCritical": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "predecessorLagCalendar": { - "movement": [ 0, 0, 0, @@ -2444,20 +2154,9 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, - "projectEndForFloat": { + "predecessorLagCalendar": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "expectedFinishDates": { - "movement": [ 0, 0, 0, @@ -2475,6 +2174,7 @@ 7, 6, 6, + 0, 0 ], "chosenNegativeFloatTasks": 5, @@ -2487,7 +2187,8 @@ 0, 0, 0, - 1 + 1, + 0 ], "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 @@ -2499,6 +2200,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2515,8 +2217,9 @@ "xerDefaultsMovement": [ 0, 0, - 2, - 2, + 0, + 0, + 0, 0, 0 ], @@ -2529,6 +2232,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2541,6 +2245,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2553,6 +2258,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2561,29 +2267,6 @@ "predecessorLagCalendar": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "projectEndForFloat": { - "movement": [ - 0, - 0, - 2, - 2, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "expectedFinishDates": { - "movement": [ 0, 0, 0, @@ -2601,6 +2284,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2613,6 +2297,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2625,6 +2310,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2641,8 +2327,9 @@ "xerDefaultsMovement": [ 0, 0, - 2, - 2, + 0, + 0, + 0, 0, 0 ], @@ -2655,6 +2342,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2667,6 +2355,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2679,6 +2368,7 @@ 0, 0, 1, + 1, 1 ], "chosenNegativeFloatTasks": 0, @@ -2687,29 +2377,6 @@ "predecessorLagCalendar": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "projectEndForFloat": { - "movement": [ - 0, - 0, - 2, - 2, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "expectedFinishDates": { - "movement": [ 0, 0, 0, @@ -2727,6 +2394,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2739,6 +2407,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2751,6 +2420,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2770,7 +2440,8 @@ 3, 1, 1, - 2 + 2, + 0 ], "xerDefaultsNegativeFloatTasks": 0, "defaults": { @@ -2780,11 +2451,12 @@ 0, 0, 0, + 1, 0, 0 ], "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 + "counterfactualNegativeFloatTasks": 1 }, "retainedLogic": { "movement": [ @@ -2793,6 +2465,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2805,6 +2478,7 @@ 0, 0, 1, + 1, 1 ], "chosenNegativeFloatTasks": 0, @@ -2813,29 +2487,6 @@ "predecessorLagCalendar": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "projectEndForFloat": { - "movement": [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "expectedFinishDates": { - "movement": [ 0, 0, 0, @@ -2853,7 +2504,8 @@ 3, 1, 1, - 2 + 2, + 0 ], "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 @@ -2865,6 +2517,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2877,6 +2530,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2896,7 +2550,8 @@ 3, 2, 2, - 2 + 2, + 0 ], "xerDefaultsNegativeFloatTasks": 0, "defaults": { @@ -2906,6 +2561,7 @@ 0, 0, 0, + 1, 0, 0 ], @@ -2919,6 +2575,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -2931,6 +2588,7 @@ 0, 0, 2, + 2, 2 ], "chosenNegativeFloatTasks": 0, @@ -2939,29 +2597,6 @@ "predecessorLagCalendar": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "projectEndForFloat": { - "movement": [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "expectedFinishDates": { - "movement": [ 0, 0, 0, @@ -2979,7 +2614,8 @@ 3, 2, 2, - 2 + 2, + 0 ], "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 @@ -2991,6 +2627,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3003,6 +2640,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3022,7 +2660,8 @@ 3, 2, 2, - 2 + 2, + 0 ], "xerDefaultsNegativeFloatTasks": 0, "defaults": { @@ -3032,6 +2671,7 @@ 0, 0, 0, + 1, 0, 0 ], @@ -3045,6 +2685,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3057,6 +2698,7 @@ 0, 0, 2, + 2, 2 ], "chosenNegativeFloatTasks": 0, @@ -3065,29 +2707,6 @@ "predecessorLagCalendar": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "projectEndForFloat": { - "movement": [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "expectedFinishDates": { - "movement": [ 0, 0, 0, @@ -3105,7 +2724,8 @@ 3, 2, 2, - 2 + 2, + 0 ], "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 @@ -3117,6 +2737,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3129,6 +2750,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3148,7 +2770,8 @@ 2, 1, 1, - 1 + 1, + 0 ], "xerDefaultsNegativeFloatTasks": 0, "defaults": { @@ -3159,6 +2782,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3171,6 +2795,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3183,6 +2808,7 @@ 0, 0, 1, + 1, 1 ], "chosenNegativeFloatTasks": 0, @@ -3191,29 +2817,6 @@ "predecessorLagCalendar": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "projectEndForFloat": { - "movement": [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "expectedFinishDates": { - "movement": [ 0, 0, 0, @@ -3231,7 +2834,8 @@ 2, 1, 1, - 1 + 1, + 0 ], "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 @@ -3243,6 +2847,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3255,6 +2860,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3271,8 +2877,9 @@ "xerDefaultsMovement": [ 0, 0, - 2, - 2, + 0, + 0, + 0, 0, 0 ], @@ -3285,6 +2892,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3297,6 +2905,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3305,17 +2914,6 @@ "openEndedNotCritical": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "predecessorLagCalendar": { - "movement": [ 0, 0, 0, @@ -3326,20 +2924,9 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, - "projectEndForFloat": { + "predecessorLagCalendar": { "movement": [ 0, - 0, - 2, - 2, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "expectedFinishDates": { - "movement": [ 0, 0, 0, @@ -3357,6 +2944,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3369,6 +2957,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3381,6 +2970,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3400,77 +2990,58 @@ 0, 0, 0, + 0, 0 ], "xerDefaultsNegativeFloatTasks": 0, "defaults": { - "totalFloatFinish": { - "movement": [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "retainedLogic": { - "movement": [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "openEndedNotCritical": { + "totalFloatFinish": { "movement": [ 0, 0, 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, - "predecessorLagCalendar": { + "retainedLogic": { "movement": [ 0, 0, 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, - "projectEndForFloat": { + "openEndedNotCritical": { "movement": [ 0, 0, 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, - "expectedFinishDates": { + "predecessorLagCalendar": { "movement": [ 0, 0, 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3483,6 +3054,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3495,6 +3067,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3507,6 +3080,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3526,6 +3100,7 @@ 0, 0, 0, + 0, 0 ], "xerDefaultsNegativeFloatTasks": 0, @@ -3537,6 +3112,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3549,6 +3125,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3561,6 +3138,7 @@ 0, 0, 1, + 1, 1 ], "chosenNegativeFloatTasks": 0, @@ -3569,29 +3147,6 @@ "predecessorLagCalendar": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "projectEndForFloat": { - "movement": [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "expectedFinishDates": { - "movement": [ 0, 0, 0, @@ -3609,6 +3164,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3621,6 +3177,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3633,6 +3190,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3652,6 +3210,7 @@ 0, 0, 0, + 0, 0 ], "xerDefaultsNegativeFloatTasks": 0, @@ -3663,6 +3222,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3675,6 +3235,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3683,17 +3244,6 @@ "openEndedNotCritical": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "predecessorLagCalendar": { - "movement": [ 0, 0, 0, @@ -3704,20 +3254,9 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, - "projectEndForFloat": { + "predecessorLagCalendar": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "expectedFinishDates": { - "movement": [ 0, 0, 0, @@ -3735,6 +3274,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3747,6 +3287,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3759,6 +3300,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3778,6 +3320,7 @@ 1, 0, 0, + 0, 0 ], "xerDefaultsNegativeFloatTasks": 0, @@ -3789,6 +3332,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3801,6 +3345,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3813,6 +3358,7 @@ 0, 0, 3, + 3, 3 ], "chosenNegativeFloatTasks": 0, @@ -3821,29 +3367,6 @@ "predecessorLagCalendar": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "projectEndForFloat": { - "movement": [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "expectedFinishDates": { - "movement": [ 0, 0, 0, @@ -3861,6 +3384,7 @@ 1, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3873,6 +3397,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3885,6 +3410,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3904,6 +3430,7 @@ 1, 0, 0, + 0, 0 ], "xerDefaultsNegativeFloatTasks": 0, @@ -3915,6 +3442,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3927,6 +3455,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3939,6 +3468,7 @@ 0, 0, 3, + 3, 3 ], "chosenNegativeFloatTasks": 0, @@ -3947,29 +3477,6 @@ "predecessorLagCalendar": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "projectEndForFloat": { - "movement": [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "expectedFinishDates": { - "movement": [ 0, 0, 0, @@ -3987,6 +3494,7 @@ 1, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -3999,6 +3507,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -4011,6 +3520,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -4030,6 +3540,7 @@ 0, 0, 0, + 0, 0 ], "xerDefaultsNegativeFloatTasks": 0, @@ -4041,6 +3552,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -4053,6 +3565,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -4065,6 +3578,7 @@ 0, 0, 1, + 1, 1 ], "chosenNegativeFloatTasks": 0, @@ -4073,29 +3587,6 @@ "predecessorLagCalendar": { "movement": [ 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "projectEndForFloat": { - "movement": [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 - }, - "expectedFinishDates": { - "movement": [ 0, 0, 0, @@ -4113,6 +3604,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -4125,6 +3617,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -4137,6 +3630,7 @@ 0, 0, 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -4182,11 +3676,11 @@ "measurable": 7661 }, "ls": { - "deviations": 5418, + "deviations": 5417, "measurable": 7539 }, "lf": { - "deviations": 5451, + "deviations": 5450, "measurable": 7532 }, "tf": { @@ -4415,114 +3909,6 @@ } } }, - "projectEndForFloat": { - "chosen": { - "es": { - "deviations": 4552, - "measurable": 7649 - }, - "ef": { - "deviations": 4488, - "measurable": 7661 - }, - "ls": { - "deviations": 7486, - "measurable": 7539 - }, - "lf": { - "deviations": 7467, - "measurable": 7532 - }, - "tf": { - "deviations": 6928, - "measurable": 7594 - }, - "ff": { - "deviations": 2339, - "measurable": 7217 - } - }, - "counterfactual": { - "es": { - "deviations": 4552, - "measurable": 7649 - }, - "ef": { - "deviations": 4488, - "measurable": 7661 - }, - "ls": { - "deviations": 7485, - "measurable": 7539 - }, - "lf": { - "deviations": 7466, - "measurable": 7532 - }, - "tf": { - "deviations": 6928, - "measurable": 7594 - }, - "ff": { - "deviations": 2339, - "measurable": 7217 - } - } - }, - "expectedFinishDates": { - "chosen": { - "es": { - "deviations": 4552, - "measurable": 7649 - }, - "ef": { - "deviations": 4488, - "measurable": 7661 - }, - "ls": { - "deviations": 7485, - "measurable": 7539 - }, - "lf": { - "deviations": 7466, - "measurable": 7532 - }, - "tf": { - "deviations": 6928, - "measurable": 7594 - }, - "ff": { - "deviations": 2339, - "measurable": 7217 - } - }, - "counterfactual": { - "es": { - "deviations": 4552, - "measurable": 7649 - }, - "ef": { - "deviations": 4488, - "measurable": 7661 - }, - "ls": { - "deviations": 7485, - "measurable": 7539 - }, - "lf": { - "deviations": 7466, - "measurable": 7532 - }, - "tf": { - "deviations": 6928, - "measurable": 7594 - }, - "ff": { - "deviations": 2339, - "measurable": 7217 - } - } - }, "preserveActualDates": { "chosen": { "es": { From dbc9bdb8009cd48a2e30d5bcf38b4d7b50e102a8 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:58:42 +0200 Subject: [PATCH 027/171] Bedraad XER-planningsopties per project --- src/services/importTypes.ts | 26 ++ src/services/xer/xerReader.ts | 14 + src/services/xer/xerScheduleOptions.ts | 50 ++- tests/planning/check-xer-multi-project.ts | 11 + .../check-xer-schedule-options-corpus.ts | 106 +++-- .../check-xer-schedule-options-wiring.ts | 412 ++++++++++++++++++ tests/planning/check-xer-schedule-options.ts | 38 ++ tests/planning/run.sh | 5 + .../xer-schedoptions-blast-radius.json | 348 ++++++++++++--- 9 files changed, 895 insertions(+), 115 deletions(-) create mode 100644 tests/planning/check-xer-schedule-options-wiring.ts diff --git a/src/services/importTypes.ts b/src/services/importTypes.ts index c5e3c2b3b..3837a853e 100644 --- a/src/services/importTypes.ts +++ b/src/services/importTypes.ts @@ -44,6 +44,30 @@ export interface XerEnumFallback { line: number; } +export interface XerScheduleOptionFallback { + field: string; + token: string; + fallback: string; + line: number; +} + +export interface XerScheduleOptionsSourceRow { + table: 'PROJECT' | 'SCHEDOPTIONS'; + line: number; + cells: Record; +} + +/** 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[]; + sourceRows: XerScheduleOptionsSourceRow[]; +} + export interface XerExternalRelation { id: string; localProjectId: string; @@ -88,6 +112,8 @@ export interface XerImportMetadata { 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[]; diff --git a/src/services/xer/xerReader.ts b/src/services/xer/xerReader.ts index df9cb572e..5014cbb32 100644 --- a/src/services/xer/xerReader.ts +++ b/src/services/xer/xerReader.ts @@ -25,6 +25,7 @@ import { createDefaultTaskTime } from '@/utils/taskDefaults'; import { formatInstant, parseInstant } from '@/utils/dateUtils'; import { readXerCalendars } from './xerCalendarData'; import { assembleXerMultiProjectImport, type XerMultiProjectImport } from './xerMultiProject'; +import { deriveXerScheduleOptions } from './xerScheduleOptions'; import { parseXerNumber, parseXerTables, @@ -524,6 +525,16 @@ function readXerProject(tables: XerTables, projectId: string): XerReadResult { } } + const derivedSchedule = deriveXerScheduleOptions(tables, projectId, { + hoursPerDay: projectCalendar.hoursPerDay, + taskCount: mappedActivities.length, + }); + const { + progressMode, + schedulingOptions, + ...scheduleOptionsMetadata + } = derivedSchedule; + return { project: { id: projectId, @@ -537,6 +548,8 @@ function readXerProject(tables: XerTables, projectId: string): XerReadResult { author: '', company: '', ...(statusDate ? { statusDate } : {}), + progressMode, + schedulingOptions, }, calendar: projectCalendar, resourceCalendars: calendarList.filter(calendar => calendar.id !== projectCalendar.id), @@ -549,6 +562,7 @@ function readXerProject(tables: XerTables, projectId: string): XerReadResult { tableReport: tables.report, calendarIssues: calendars.issues, enumFallbacks, + scheduleOptions: scheduleOptionsMetadata, externalRelations, externalLinks: [], // `assembleXerMultiProjectImport` vervangt dit vóór de reader retourneert door het echte, diff --git a/src/services/xer/xerScheduleOptions.ts b/src/services/xer/xerScheduleOptions.ts index d191b447e..7bf462832 100644 --- a/src/services/xer/xerScheduleOptions.ts +++ b/src/services/xer/xerScheduleOptions.ts @@ -1,31 +1,28 @@ /** - * Pure XER-SCHEDOPTIONS-afleiding. Deze module bedraadt zichzelf bewust niet in `xerReader`: de - * seriële importnaad volgt pas nadat de parallelle XER-kernbanen zijn samengebracht. + * 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, + XerScheduleOptionsMetadata, + XerScheduleOptionsSourceRow, +} from '../importTypes'; import { parseXerNumber, type XerRow, type XerTables } from './xerTables'; -export interface XerScheduleOptionFallback { - field: string; - token: string; - fallback: string; - line: number; -} +export type { + XerScheduleOptionFallback, + XerScheduleOptionsMetadata, + XerScheduleOptionsSourceRow, +} from '../importTypes'; -export interface XerScheduleOptionsResult { - source: 'schedoptions' | 'xer-defaults'; +export interface XerScheduleOptionsResult extends XerScheduleOptionsMetadata { progressMode: ProgressMode; schedulingOptions: SchedulingOptions; - /** Bronwaarden waarvoor X5 nog geen correcte solverrepresentatie heeft. Ze blijven expliciet - * beschikbaar voor de latere readerwiring en worden niet als schijnoptie op het project gezet. */ - retainedSource: { - sched_use_project_end_date_for_float?: boolean; - }; - fallbacks: XerScheduleOptionFallback[]; } export type XerScheduleOptionColumnDisposition = @@ -211,6 +208,16 @@ function scheduleRow(tables: XerTables, projectId: string): XerRow | undefined { return tables.tables.get('SCHEDOPTIONS')?.rows.find(row => row.cells.proj_id?.trim() === projectId); } +function sourceRows(tables: XerTables, projectId: string): XerScheduleOptionsSourceRow[] { + const projectRows = (tables.tables.get('PROJECT')?.rows ?? []) + .filter(row => row.cells.proj_id?.trim() === projectId) + .map(row => ({ table: 'PROJECT' as const, line: row.line, cells: { ...row.cells } })); + const scheduleRows = (tables.tables.get('SCHEDOPTIONS')?.rows ?? []) + .filter(row => row.cells.proj_id?.trim() === projectId) + .map(row => ({ table: 'SCHEDOPTIONS' as const, line: row.line, cells: { ...row.cells } })); + return [...projectRows, ...scheduleRows]; +} + export function deriveXerScheduleOptions( tables: XerTables, projectId: string, @@ -218,6 +225,7 @@ export function deriveXerScheduleOptions( ): XerScheduleOptionsResult { const defaults = freshDefaults(); const fallbacks: XerScheduleOptionFallback[] = []; + const retainedRows = sourceRows(tables, projectId); defaults.schedulingOptions.criticalDefinition = projectCriticalDefinition( tables, projectId, @@ -232,6 +240,7 @@ export function deriveXerScheduleOptions( schedulingOptions: defaults.schedulingOptions, retainedSource: {}, fallbacks, + sourceRows: retainedRows, }; } @@ -288,5 +297,12 @@ export function deriveXerScheduleOptions( }; } - return { source: 'schedoptions', progressMode, schedulingOptions, retainedSource, fallbacks }; + return { + source: 'schedoptions', + progressMode, + schedulingOptions, + retainedSource, + fallbacks, + sourceRows: retainedRows, + }; } diff --git a/tests/planning/check-xer-multi-project.ts b/tests/planning/check-xer-multi-project.ts index c3841e402..45dc41d05 100644 --- a/tests/planning/check-xer-multi-project.ts +++ b/tests/planning/check-xer-multi-project.ts @@ -20,6 +20,7 @@ import { createDefaultTaskTime } from '@/utils/taskDefaults'; import { payloadFromImport } from '@/state/documentContract'; import { writeIFC } from '@/services/ifc/ifcWriter'; import { readIFC } from '@/services/ifc/ifcReader'; +import { XER_SCHEDULING_DEFAULTS } from '@/services/xer/xerScheduleOptions'; const diffs: string[] = []; let checks = 0; @@ -88,6 +89,8 @@ function mappedProject(projectId: string, leaves: number, summaries = 0): XerRea modifiedAt: '2026-01-02T00:00:00.000Z', author: '', company: '', + progressMode: XER_SCHEDULING_DEFAULTS.progressMode, + schedulingOptions: structuredClone(XER_SCHEDULING_DEFAULTS.schedulingOptions), }, calendar, tasks: [ @@ -102,6 +105,9 @@ function mappedProject(projectId: string, leaves: number, summaries = 0): XerRea tableReport: { encoding: 'utf-8', endMarkerSeen: true, issues: [], unknownTables: [] }, calendarIssues: [], enumFallbacks: [], + scheduleOptions: { + source: 'xer-defaults', retainedSource: {}, fallbacks: [], sourceRows: [], + }, externalRelations: [], externalLinks: [], report: { @@ -763,6 +769,8 @@ function mapCorpusProject(source: XerTables, projectId: string): XerReadResult { modifiedAt: fallbackStart, author: '', company: '', + progressMode: XER_SCHEDULING_DEFAULTS.progressMode, + schedulingOptions: structuredClone(XER_SCHEDULING_DEFAULTS.schedulingOptions), }, calendar, tasks, @@ -774,6 +782,9 @@ function mapCorpusProject(source: XerTables, projectId: string): XerReadResult { tableReport: source.report, calendarIssues: [], enumFallbacks: [], + scheduleOptions: { + source: 'xer-defaults', retainedSource: {}, fallbacks: [], sourceRows: [], + }, externalRelations: [], externalLinks: [], report: { diff --git a/tests/planning/check-xer-schedule-options-corpus.ts b/tests/planning/check-xer-schedule-options-corpus.ts index b9a8a680d..03231a0ed 100644 --- a/tests/planning/check-xer-schedule-options-corpus.ts +++ b/tests/planning/check-xer-schedule-options-corpus.ts @@ -4,6 +4,7 @@ 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 { deriveXerScheduleOptions, XER_SCHEDOPTIONS_COLUMN_DISPOSITIONS, @@ -87,6 +88,8 @@ interface BlastRadiusBaseline { withoutSchedOptions: number; measured: number; deferred: number; + openedProjectsWithDefaults: number; + wiredProjectsWithDefaults: number; oracleNegativeFloatFiles: number; withoutSchedOptionsNegativeFloatFiles: number; rawNegativeFloatFiles: number; @@ -274,12 +277,27 @@ function movement(before: BlastSolvedProject, after: BlastSolvedProject): AxisVe }, 0)) as AxisVector; } -function negativeFloatTasks(solved: BlastSolvedProject): number { - return solved.tasks.filter(task => (task.totalFloatMinutes ?? 0) < 0).length; +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; +} + +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: ReturnType, + imported: ImportResult, variant: SolverVariant, ): BlastSolvedProject { const tasks = cloneTasksForSolve(imported.tasks); @@ -432,15 +450,18 @@ function measureCorpus(root: string): BlastRadiusBaseline { const occurrences = new Map(); const files: MeasuredFile[] = []; + let openedProjectsWithDefaults = 0; + let wiredProjectsWithDefaults = 0; 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}`; - const oracleNegativeFloatTasks = file.truth.tasks.filter(taskHasNegativeFloat).length; - let imported: ReturnType; + let oracleNegativeFloatTasks = file.truth.tasks.filter(taskHasNegativeFloat).length; + let importedProjects: ImportResult[]; try { - imported = readXER(file.bytes); + const opened = readXER(file.bytes); + importedProjects = isMultiDocumentImport(opened) ? opened.results : [opened]; } catch (error) { files.push({ id, @@ -452,33 +473,58 @@ function measureCorpus(root: string): BlastRadiusBaseline { continue; } - const xerDefaultsVariant = independentXerDefaults(file.rawTables, imported.project.id); - const house = projectResult(imported, {}); - const houseMeasurement = measureXerFidelity(file.truth, [house]); + // 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; + const xerDefaultsVariants = importedProjects.map(imported => + independentXerDefaults(file.rawTables, imported.project.id)); + 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: 'xer-defaults', + })).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 = projectResult(imported, { - progressMode: xerDefaultsVariant.progressMode, - schedulingOptions: xerDefaultsVariant.schedulingOptions, - }); - const xerDefaultsFidelity = measureXerFidelity(file.truth, [xerDefaults]); + 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); const defaultMeasurements = {} as Record; - for (const [key, pair] of Object.entries(variantDefinitions( - xerDefaultsVariant.schedulingOptions?.criticalDefinition, - )) as Array<[ - DefaultKey, - ReturnType[DefaultKey], - ]>) { - const chosen = projectResult(imported, pair.chosen); - const counterfactual = projectResult(imported, pair.counterfactual); + 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: movement(counterfactual, chosen), + movement: movementProjects(counterfactual, chosen), chosenNegativeFloatTasks: negativeFloatTasks(chosen), counterfactualNegativeFloatTasks: negativeFloatTasks(counterfactual), }; @@ -486,7 +532,7 @@ function measureCorpus(root: string): BlastRadiusBaseline { ['chosen', chosen], ['counterfactual', counterfactual], ] as const) { - const measured = measureXerFidelity(file.truth, [solved]); + const measured = measureXerFidelity(openedTruth, solved); if (measured.errors.length > 0) throw new Error(`${id}: ${variant}-uitlijning mislukt`); addCounters(fidelity.defaults[key][variant], measured.counters); } @@ -494,10 +540,10 @@ function measureCorpus(root: string): BlastRadiusBaseline { files.push({ id, state: 'measured', - tasks: file.truth.tasks.length, + tasks: openedTruth.tasks.length, oracleNegativeFloatTasks, houseNegativeFloatTasks: negativeFloatTasks(house), - xerDefaultsMovement: movement(house, xerDefaults), + xerDefaultsMovement: movementProjects(house, xerDefaults), xerDefaultsNegativeFloatTasks: negativeFloatTasks(xerDefaults), defaults: defaultMeasurements, }); @@ -515,6 +561,8 @@ function measureCorpus(root: string): BlastRadiusBaseline { withoutSchedOptions: without.length, measured: files.filter(file => file.state === 'measured').length, deferred: files.filter(file => file.state === 'deferred').length, + openedProjectsWithDefaults, + wiredProjectsWithDefaults, 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, @@ -589,8 +637,10 @@ if (!root) { oracleAxisFiles: 60, withSchedOptions: 24, withoutSchedOptions: 36, - measured: 32, - deferred: 4, + measured: 34, + deferred: 2, + openedProjectsWithDefaults: 35, + wiredProjectsWithDefaults: 35, oracleNegativeFloatFiles: 5, withoutSchedOptionsNegativeFloatFiles: 4, rawNegativeFloatFiles: 6, 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..76e91620b --- /dev/null +++ b/tests/planning/check-xer-schedule-options-wiring.ts @@ -0,0 +1,412 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { cloneTasksForSolve, solveProject } from '@/engine/scheduler/solveProject'; +import { readIFC } from '@/services/ifc/ifcReader'; +import { writeIFC } from '@/services/ifc/ifcWriter'; +import { isMultiDocumentImport } from '@/services/importTypes'; +import { + deriveXerScheduleOptions, + type XerScheduleOptionsMetadata, +} from '@/services/xer/xerScheduleOptions'; +import { readXER, type XerReadResult } from '@/services/xer/xerReader'; +import { parseXerTables } from '@/services/xer/xerTables'; +import { useAppStore } from '@/state/appStore'; +import { recoveryInputFromParsed } from '@/state/documentContract'; +import { isLeafTask } from '@/utils/taskHierarchy'; + +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 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', + '%R\tP-A\tProject A\tC4\t2026-01-05 08:00\tCT_TotFloat\t8', + '%R\tP-B\tProject B\tC8\t2026-01-05 08:00\tCT_DrivPath\t0', + '%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('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: { + lagCalendar: 'successor', + criticalDefinition: { mode: 'totalFloat', thresholdHours: 8 }, + totalFloatMode: 'start', + makeOpenEndedCritical: true, + useExpectedFinishDates: false, + preserveActualDatesInBackwardPass: true, + clampNegativeFreeFloat: true, + }, + }, + { + id: 'P-B', + progressMode: 'RETAINED_LOGIC', + schedulingOptions: { + lagCalendar: '24hour', + criticalDefinition: { mode: 'longestPath' }, + totalFloatMode: 'finish', + makeOpenEndedCritical: false, + useExpectedFinishDates: true, + preserveActualDatesInBackwardPass: true, + clampNegativeFreeFloat: true, + }, + }, +]); + +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: scheduleMetadata(defaultProject), +}, { + progressMode: 'RETAINED_LOGIC', + schedulingOptions: { + lagCalendar: 'predecessor', + criticalDefinition: { mode: 'totalFloat', thresholdHours: 8 }, + totalFloatMode: 'finish', + makeOpenEndedCritical: false, + useExpectedFinishDates: true, + preserveActualDatesInBackwardPass: true, + clampNegativeFreeFloat: 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', + }, + }], + }, +}); + +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'] }, + ]); + +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, + })); +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 } }, + ]); + +eq('10 bestaand project-IFC dekt schedulingOptions/progressMode; retained XER-metadata blijft expliciet X9', + (multiOpened.results as XerReadResult[]).map(result => { + const roundTripped = readIFC(writeIFC(result)); + return { + name: roundTripped.project.name, + progressMode: roundTripped.project.progressMode, + schedulingOptions: roundTripped.project.schedulingOptions, + xerMetadataSerialized: roundTripped.xer, + }; + }), [ + { + name: 'Project A', progressMode: 'PROGRESS_OVERRIDE', + schedulingOptions: projectA?.project.schedulingOptions, + xerMetadataSerialized: undefined, + }, + { + // 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, + xerMetadataSerialized: undefined, + }, + ]); + +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 publicTables = parseXerTables(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 = deriveXerScheduleOptions(publicTables, result.project.id, { + hoursPerDay: result.calendar.hoursPerDay, + taskCount: result.tasks.filter(isLeafTask).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, + }) && metadata?.sourceRows.every(row => row.cells.proj_id === result.project.id), + 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 index a933b575d..84bd4ec74 100644 --- a/tests/planning/check-xer-schedule-options.ts +++ b/tests/planning/check-xer-schedule-options.ts @@ -596,6 +596,15 @@ eq('expliciete XER-defaultset is brongebonden en compleet', withoutTable, { }, 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', @@ -718,6 +727,35 @@ eq('bekende enums en vlaggen worden case-insensitief naar bestaande opties gemap }, 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( diff --git a/tests/planning/run.sh b/tests/planning/run.sh index a54c85e74..aad7d6bdc 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -251,6 +251,11 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then 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 + # 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" diff --git a/tests/planning/xer-schedoptions-blast-radius.json b/tests/planning/xer-schedoptions-blast-radius.json index 59589ce85..fbaeeb7e1 100644 --- a/tests/planning/xer-schedoptions-blast-radius.json +++ b/tests/planning/xer-schedoptions-blast-radius.json @@ -30,8 +30,10 @@ "oracleAxisFiles": 60, "withSchedOptions": 24, "withoutSchedOptions": 36, - "measured": 32, - "deferred": 4, + "measured": 34, + "deferred": 2, + "openedProjectsWithDefaults": 35, + "wiredProjectsWithDefaults": 35, "oracleNegativeFloatFiles": 5, "withoutSchedOptionsNegativeFloatFiles": 4, "rawNegativeFloatFiles": 6, @@ -319,10 +321,113 @@ }, { "id": "1ba69d297ee9a5c6-1", - "state": "deferred", - "tasks": 8, + "state": "measured", + "tasks": 4, "oracleNegativeFloatTasks": 0, - "deferredCode": "XER_SINGLE_PROJECT_REQUIRED" + "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 + }, + "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", @@ -876,10 +981,113 @@ }, { "id": "32524ea41f8bb4cb-1", - "state": "deferred", + "state": "measured", "tasks": 2, "oracleNegativeFloatTasks": 0, - "deferredCode": "XER_SINGLE_PROJECT_REQUIRED" + "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 + }, + "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", @@ -3658,12 +3866,12 @@ "measurable": 7532 }, "tf": { - "deviations": 6928, - "measurable": 7594 + "deviations": 6931, + "measurable": 7600 }, "ff": { - "deviations": 2339, - "measurable": 7217 + "deviations": 2341, + "measurable": 7221 } }, "xerDefaults": { @@ -3684,12 +3892,12 @@ "measurable": 7532 }, "tf": { - "deviations": 6403, - "measurable": 7594 + "deviations": 6406, + "measurable": 7600 }, "ff": { - "deviations": 583, - "measurable": 7217 + "deviations": 585, + "measurable": 7221 } }, "defaults": { @@ -3712,12 +3920,12 @@ "measurable": 7532 }, "tf": { - "deviations": 6927, - "measurable": 7594 + "deviations": 6930, + "measurable": 7600 }, "ff": { - "deviations": 2339, - "measurable": 7217 + "deviations": 2341, + "measurable": 7221 } }, "counterfactual": { @@ -3738,12 +3946,12 @@ "measurable": 7532 }, "tf": { - "deviations": 6928, - "measurable": 7594 + "deviations": 6931, + "measurable": 7600 }, "ff": { - "deviations": 2339, - "measurable": 7217 + "deviations": 2341, + "measurable": 7221 } } }, @@ -3766,12 +3974,12 @@ "measurable": 7532 }, "tf": { - "deviations": 6928, - "measurable": 7594 + "deviations": 6931, + "measurable": 7600 }, "ff": { - "deviations": 2339, - "measurable": 7217 + "deviations": 2341, + "measurable": 7221 } }, "counterfactual": { @@ -3792,12 +4000,12 @@ "measurable": 7532 }, "tf": { - "deviations": 7406, - "measurable": 7594 + "deviations": 7409, + "measurable": 7600 }, "ff": { - "deviations": 2519, - "measurable": 7217 + "deviations": 2521, + "measurable": 7221 } } }, @@ -3820,12 +4028,12 @@ "measurable": 7532 }, "tf": { - "deviations": 6928, - "measurable": 7594 + "deviations": 6931, + "measurable": 7600 }, "ff": { - "deviations": 2339, - "measurable": 7217 + "deviations": 2341, + "measurable": 7221 } }, "counterfactual": { @@ -3846,12 +4054,12 @@ "measurable": 7532 }, "tf": { - "deviations": 6911, - "measurable": 7594 + "deviations": 6912, + "measurable": 7600 }, "ff": { "deviations": 2339, - "measurable": 7217 + "measurable": 7221 } } }, @@ -3874,12 +4082,12 @@ "measurable": 7532 }, "tf": { - "deviations": 6928, - "measurable": 7594 + "deviations": 6931, + "measurable": 7600 }, "ff": { - "deviations": 2339, - "measurable": 7217 + "deviations": 2341, + "measurable": 7221 } }, "counterfactual": { @@ -3900,12 +4108,12 @@ "measurable": 7532 }, "tf": { - "deviations": 6928, - "measurable": 7594 + "deviations": 6931, + "measurable": 7600 }, "ff": { - "deviations": 2340, - "measurable": 7217 + "deviations": 2342, + "measurable": 7221 } } }, @@ -3928,12 +4136,12 @@ "measurable": 7532 }, "tf": { - "deviations": 6404, - "measurable": 7594 + "deviations": 6407, + "measurable": 7600 }, "ff": { - "deviations": 702, - "measurable": 7217 + "deviations": 704, + "measurable": 7221 } }, "counterfactual": { @@ -3954,12 +4162,12 @@ "measurable": 7532 }, "tf": { - "deviations": 6928, - "measurable": 7594 + "deviations": 6931, + "measurable": 7600 }, "ff": { - "deviations": 2339, - "measurable": 7217 + "deviations": 2341, + "measurable": 7221 } } }, @@ -3982,12 +4190,12 @@ "measurable": 7532 }, "tf": { - "deviations": 6928, - "measurable": 7594 + "deviations": 6931, + "measurable": 7600 }, "ff": { - "deviations": 1747, - "measurable": 7217 + "deviations": 1749, + "measurable": 7221 } }, "counterfactual": { @@ -4008,12 +4216,12 @@ "measurable": 7532 }, "tf": { - "deviations": 6928, - "measurable": 7594 + "deviations": 6931, + "measurable": 7600 }, "ff": { - "deviations": 2339, - "measurable": 7217 + "deviations": 2341, + "measurable": 7221 } } }, @@ -4036,12 +4244,12 @@ "measurable": 7532 }, "tf": { - "deviations": 6928, - "measurable": 7594 + "deviations": 6931, + "measurable": 7600 }, "ff": { - "deviations": 2339, - "measurable": 7217 + "deviations": 2341, + "measurable": 7221 } }, "counterfactual": { @@ -4062,12 +4270,12 @@ "measurable": 7532 }, "tf": { - "deviations": 6928, - "measurable": 7594 + "deviations": 6931, + "measurable": 7600 }, "ff": { - "deviations": 2339, - "measurable": 7217 + "deviations": 2341, + "measurable": 7221 } } } From a36c0a97e6cf8202eb95b8856f820ddbb733b4f1 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:52:33 +0200 Subject: [PATCH 028/171] fix(xer): sluit X5 SCHEDOPTIONS-indexering en bronarchief --- src/services/importTypes.ts | 25 ++ src/services/xer/xerMultiProject.ts | 23 +- src/services/xer/xerReader.ts | 20 +- src/services/xer/xerScheduleOptions.ts | 131 +++++++-- src/types/project.ts | 30 +- tests/planning/check-xer-multi-project.ts | 8 +- .../check-xer-schedule-options-corpus.ts | 172 ++++++----- .../check-xer-schedule-options-wiring.ts | 103 ++++++- tests/planning/check-xer-schedule-options.ts | 118 +++++++- .../xer-schedoptions-blast-radius.json | 198 ++++++++++--- .../planning/xerScheduleOptionsGroundTruth.ts | 266 ++++++++++++++++++ 11 files changed, 920 insertions(+), 174 deletions(-) create mode 100644 tests/planning/xerScheduleOptionsGroundTruth.ts diff --git a/src/services/importTypes.ts b/src/services/importTypes.ts index 3837a853e..999b86752 100644 --- a/src/services/importTypes.ts +++ b/src/services/importTypes.ts @@ -57,6 +57,26 @@ export interface XerScheduleOptionsSourceRow { 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 { @@ -65,6 +85,11 @@ export interface XerScheduleOptionsMetadata { 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[]; } diff --git a/src/services/xer/xerMultiProject.ts b/src/services/xer/xerMultiProject.ts index ae60f87d3..23309ab95 100644 --- a/src/services/xer/xerMultiProject.ts +++ b/src/services/xer/xerMultiProject.ts @@ -34,6 +34,27 @@ interface BaselineDecision { 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 clone = structuredClone({ + ...result, + xer: { + ...result.xer, + 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]); + return clone; +} + function assertUniqueProjectIds(projectRows: readonly XerRow[]): void { const firstLineById = new Map(); for (const projectRow of projectRows) { @@ -233,7 +254,7 @@ export function assembleXerMultiProjectImport( 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. - structuredClone(mapProject(projectRow.cells.proj_id)), + cloneMappedProject(mapProject(projectRow.cells.proj_id)), ]), ); const documents = nonEmptyProjectRows diff --git a/src/services/xer/xerReader.ts b/src/services/xer/xerReader.ts index 5014cbb32..1d56540c8 100644 --- a/src/services/xer/xerReader.ts +++ b/src/services/xer/xerReader.ts @@ -25,7 +25,11 @@ import { createDefaultTaskTime } from '@/utils/taskDefaults'; import { formatInstant, parseInstant } from '@/utils/dateUtils'; import { readXerCalendars } from './xerCalendarData'; import { assembleXerMultiProjectImport, type XerMultiProjectImport } from './xerMultiProject'; -import { deriveXerScheduleOptions } from './xerScheduleOptions'; +import { + deriveXerScheduleOptions, + indexXerScheduleOptions, + type XerScheduleOptionsIndex, +} from './xerScheduleOptions'; import { parseXerNumber, parseXerTables, @@ -267,9 +271,12 @@ function assertUniqueId( /** 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, projectId: string): XerReadResult { - const projectRow = (tables.tables.get('PROJECT')?.rows ?? []) - .find(row => row.cells.proj_id === projectId); +function readXerProject( + tables: XerTables, + scheduleOptionsIndex: XerScheduleOptionsIndex, + projectId: string, +): XerReadResult { + const projectRow = scheduleOptionsIndex.projectRowsById.get(projectId)?.row; if (!projectRow) { throw new XerImportError( 'XER_MISSING_REQUIRED_VALUE', @@ -525,7 +532,7 @@ function readXerProject(tables: XerTables, projectId: string): XerReadResult { } } - const derivedSchedule = deriveXerScheduleOptions(tables, projectId, { + const derivedSchedule = deriveXerScheduleOptions(scheduleOptionsIndex, projectId, { hoursPerDay: projectCalendar.hoursPerDay, taskCount: mappedActivities.length, }); @@ -591,10 +598,11 @@ function readXerProject(tables: XerTables, projectId: string): XerReadResult { */ export function readXER(bytes: Uint8Array): XerOpenResult { const tables = parseXerTables(bytes); + const scheduleOptionsIndex = indexXerScheduleOptions(tables); const projectRows = tables.tables.get('PROJECT')?.rows ?? []; const assembled = assembleXerMultiProjectImport( tables, - projectId => readXerProject(tables, projectId), + projectId => readXerProject(tables, scheduleOptionsIndex, projectId), ); if (assembled.results.length > 0) { // De openvorm blijft compatibel: één PROJECT levert nog altijd één ImportResult. Alleen de diff --git a/src/services/xer/xerScheduleOptions.ts b/src/services/xer/xerScheduleOptions.ts index 7bf462832..6e51e1268 100644 --- a/src/services/xer/xerScheduleOptions.ts +++ b/src/services/xer/xerScheduleOptions.ts @@ -9,14 +9,18 @@ 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'; @@ -25,6 +29,21 @@ export interface XerScheduleOptionsResult extends XerScheduleOptionsMetadata { 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 }; @@ -189,50 +208,120 @@ function progressModeValue( } function projectCriticalDefinition( - tables: XerTables, + index: XerScheduleOptionsIndex, projectId: string, fallbacks: XerScheduleOptionFallback[], ): SchedulingOptions['criticalDefinition'] { - const row = tables.tables.get('PROJECT')?.rows.find(item => item.cells.proj_id?.trim() === projectId); + 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 ?? '', tables.numberFormat) ?? 0; + const thresholdHours = parseXerNumber(row.cells.critical_drtn_hr_cnt ?? '', index.numberFormat) ?? 0; return { mode: 'totalFloat', thresholdHours }; } -function scheduleRow(tables: XerTables, projectId: string): XerRow | undefined { - return tables.tables.get('SCHEDOPTIONS')?.rows.find(row => row.cells.proj_id?.trim() === projectId); +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); + } } -function sourceRows(tables: XerTables, projectId: string): XerScheduleOptionsSourceRow[] { - const projectRows = (tables.tables.get('PROJECT')?.rows ?? []) - .filter(row => row.cells.proj_id?.trim() === projectId) - .map(row => ({ table: 'PROJECT' as const, line: row.line, cells: { ...row.cells } })); - const scheduleRows = (tables.tables.get('SCHEDOPTIONS')?.rows ?? []) - .filter(row => row.cells.proj_id?.trim() === projectId) - .map(row => ({ table: 'SCHEDOPTIONS' as const, line: row.line, cells: { ...row.cells } })); - return [...projectRows, ...scheduleRows]; +/** + * 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( - tables: XerTables, + index: XerScheduleOptionsIndex, projectId: string, context: { hoursPerDay?: number; taskCount?: number } = {}, ): XerScheduleOptionsResult { const defaults = freshDefaults(); const fallbacks: XerScheduleOptionFallback[] = []; - const retainedRows = sourceRows(tables, projectId); + 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( - tables, + index, projectId, fallbacks, ); - const row = scheduleRow(tables, projectId); + const row = index.scheduleRowsById.get(projectId)?.row; if (!row) { return { source: 'xer-defaults', @@ -240,6 +329,9 @@ export function deriveXerScheduleOptions( schedulingOptions: defaults.schedulingOptions, retainedSource: {}, fallbacks, + diagnostics, + sourceArchive: index.sourceArchive, + sourceRowIndexes, sourceRows: retainedRows, }; } @@ -288,7 +380,7 @@ export function deriveXerScheduleOptions( : '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 ?? '', tables.numberFormat); + 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, @@ -303,6 +395,9 @@ export function deriveXerScheduleOptions( schedulingOptions, retainedSource, fallbacks, + diagnostics, + sourceArchive: index.sourceArchive, + sourceRowIndexes, sourceRows: retainedRows, }; } diff --git a/src/types/project.ts b/src/types/project.ts index 56172ed2e..a9e2b476d 100644 --- a/src/types/project.ts +++ b/src/types/project.ts @@ -19,21 +19,21 @@ export interface SchedulingOptions { 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; + /** 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; /** Near-critical-drempel in werkdagen (fractioneel in uur-modus). Default undefined ⇒ feature uit. */ nearCriticalThreshold?: number; /** Multiple float paths. Default undefined ⇒ uit (byte-identiek). */ diff --git a/tests/planning/check-xer-multi-project.ts b/tests/planning/check-xer-multi-project.ts index 45dc41d05..869e3e3cb 100644 --- a/tests/planning/check-xer-multi-project.ts +++ b/tests/planning/check-xer-multi-project.ts @@ -106,7 +106,9 @@ function mappedProject(projectId: string, leaves: number, summaries = 0): XerRea calendarIssues: [], enumFallbacks: [], scheduleOptions: { - source: 'xer-defaults', retainedSource: {}, fallbacks: [], sourceRows: [], + source: 'xer-defaults', retainedSource: {}, fallbacks: [], diagnostics: [], + sourceArchive: { rows: [], unmatchedScheduleOptionsRowIndexes: [], diagnostics: [] }, + sourceRowIndexes: [], sourceRows: [], }, externalRelations: [], externalLinks: [], @@ -783,7 +785,9 @@ function mapCorpusProject(source: XerTables, projectId: string): XerReadResult { calendarIssues: [], enumFallbacks: [], scheduleOptions: { - source: 'xer-defaults', retainedSource: {}, fallbacks: [], sourceRows: [], + source: 'xer-defaults', retainedSource: {}, fallbacks: [], diagnostics: [], + sourceArchive: { rows: [], unmatchedScheduleOptionsRowIndexes: [], diagnostics: [] }, + sourceRowIndexes: [], sourceRows: [], }, externalRelations: [], externalLinks: [], diff --git a/tests/planning/check-xer-schedule-options-corpus.ts b/tests/planning/check-xer-schedule-options-corpus.ts index 03231a0ed..1adf0f633 100644 --- a/tests/planning/check-xer-schedule-options-corpus.ts +++ b/tests/planning/check-xer-schedule-options-corpus.ts @@ -6,11 +6,9 @@ import { cloneTasksForSolve, solveProject } from '@/engine/scheduler/solveProjec import { readXER } from '@/services/xer/xerReader'; import { isMultiDocumentImport, type ImportResult } from '@/services/importTypes'; import { - deriveXerScheduleOptions, XER_SCHEDOPTIONS_COLUMN_DISPOSITIONS, - type XerScheduleOptionsResult, } from '@/services/xer/xerScheduleOptions'; -import { parseXerTables, XerImportError } from '@/services/xer/xerTables'; +import { XerImportError } from '@/services/xer/xerTables'; import type { ProgressMode, SchedulingOptions } from '@/types/project'; import { measureXerFidelity, @@ -23,6 +21,13 @@ import { 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]; @@ -77,19 +82,23 @@ interface MeasuredFile { } interface BlastRadiusBaseline { - version: 6; + version: 7; axes: readonly BlastAxis[]; defaults: readonly DefaultKey[]; deferredDefaults: typeof DEFERRED_DEFAULTS; population: { scanned: number; oracleAxisFiles: number; - withSchedOptions: number; - withoutSchedOptions: 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; @@ -130,16 +139,8 @@ interface BlastRadiusBaseline { }; } -interface RawXerRow { - cells: Record; -} - -interface RawXerTable { - fields: string[]; - rows: RawXerRow[]; -} - -type RawXerTables = Map; +type RawXerTables = RawXerScheduleScan['tables']; +type RawXerTable = RawXerTables extends Map ? T : never; type BlastSolvedTask = XerSolvedTask & { isCritical?: boolean }; interface BlastSolvedProject extends XerSolvedProject { @@ -194,37 +195,6 @@ function decodedForTableMarker(bytes: Uint8Array): string { return new TextDecoder('windows-1252').decode(payload); } -/** Onafhankelijke, minimale tabelscan voor de blast-radiuspoort. Deze gebruikt bewust niet - * `parseXerTables` of `deriveXerScheduleOptions`: populatie, SCHEDOPTIONS-rijen en kolommenunion - * mogen niet groen worden doordat productieparser en productie-afleiding dezelfde fout delen. */ -function scanRawXerTables(bytes: Uint8Array): RawXerTables { - const tables: RawXerTables = new Map(); - let tableName = ''; - 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') { - tableName = cells[1]?.trim().toUpperCase() ?? ''; - fields = []; - if (tableName && !tables.has(tableName)) tables.set(tableName, { fields: [], rows: [] }); - continue; - } - if (!tableName) continue; - if (cells[0] === '%F') { - fields = cells.slice(1).map(field => field.trim().toLowerCase()); - const table = tables.get(tableName)!; - table.fields = [...new Set([...table.fields, ...fields])]; - continue; - } - if (cells[0] !== '%R' || fields.length === 0) continue; - const row: RawXerRow = { cells: {} }; - fields.forEach((field, index) => { row.cells[field] = cells[index + 1] ?? ''; }); - tables.get(tableName)!.rows.push(row); - } - return tables; -} - function hasRawNegativeFloat(bytes: Uint8Array): boolean { let table = ''; let fields: string[] = []; @@ -414,24 +384,28 @@ function measureCorpus(root: string): BlastRadiusBaseline { bytes, fullHash: hash(bytes), truth: scanXerGroundTruth(bytes), - rawTables: scanRawXerTables(bytes), + rawScan: scanRawXerScheduleOptions(bytes), }; }); const oracleFiles = scanned.filter(file => hasOracleAxis(file.truth)); - const hasScheduleRows = (file: typeof scanned[number]): boolean => - (file.rawTables.get('SCHEDOPTIONS')?.rows.length ?? 0) > 0; - const without = oracleFiles.filter(file => !hasScheduleRows(file)); - const withTable = oracleFiles.filter(hasScheduleRows); + 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.rawTables.get('SCHEDOPTIONS')) + .map(file => file.rawScan.tables.get('SCHEDOPTIONS')) .filter((table): table is RawXerTable => table !== undefined); - const derivedRows: XerScheduleOptionsResult[] = []; - for (const file of scanned.filter(hasScheduleRows)) { - const tables = parseXerTables(file.bytes); - const rawRows = file.rawTables.get('SCHEDOPTIONS')!.rows; - for (const row of rawRows) { - derivedRows.push(deriveXerScheduleOptions(tables, row.cells.proj_id?.trim() ?? '')); + 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(); @@ -452,6 +426,43 @@ function measureCorpus(root: string): BlastRadiusBaseline { 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; @@ -485,7 +496,7 @@ function measureCorpus(root: string): BlastRadiusBaseline { }; oracleNegativeFloatTasks = openedTruth.tasks.filter(taskHasNegativeFloat).length; const xerDefaultsVariants = importedProjects.map(imported => - independentXerDefaults(file.rawTables, imported.project.id)); + independentXerDefaults(file.rawScan.tables, imported.project.id)); openedProjectsWithDefaults += importedProjects.length; wiredProjectsWithDefaults += importedProjects.filter((imported, index) => JSON.stringify({ @@ -550,24 +561,28 @@ function measureCorpus(root: string): BlastRadiusBaseline { } return { - version: 6, + version: 7, axes: BLAST_AXES, defaults: DEFAULT_KEYS, deferredDefaults: DEFERRED_DEFAULTS, population: { scanned: scanned.length, oracleAxisFiles: oracleFiles.length, - withSchedOptions: withTable.length, - withoutSchedOptions: without.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 => - !hasScheduleRows(file) && hasRawNegativeFloat(file.bytes)).length, + !hasAddressableScheduleRows(file) && hasRawNegativeFloat(file.bytes)).length, }, schedOptionsRows: { rows: scheduleRows.length, @@ -606,13 +621,13 @@ if (!existsSync(baselinePath)) { } else { const committed = JSON.parse(readFileSync(baselinePath, 'utf8')) as BlastRadiusBaseline; eq('baselineversie en asvolgorde', { version: committed.version, axes: committed.axes }, { - version: 6, + version: 7, axes: BLAST_AXES, }); eq('baseline bevat alle defaults los van elkaar', committed.defaults, DEFAULT_KEYS); eq('expectedFinishDates staat eerlijk als X7-uitstel geregistreerd', committed.deferredDefaults, DEFERRED_DEFAULTS); - eq('baseline pint exact de 36 actuele bestanden zonder SCHEDOPTIONS', committed.files.length, 36); + eq('baseline pint exact de 37 functioneel SCHEDOPTIONS-loze bestanden', committed.files.length, 37); 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', @@ -635,12 +650,16 @@ if (!root) { eq('openbare populatie en negatieve-floatverdeling', measured.population, { scanned: 93, oracleAxisFiles: 60, - withSchedOptions: 24, - withoutSchedOptions: 36, - measured: 34, + rawWithSchedOptions: 24, + rawWithoutSchedOptions: 36, + projectAddressableSchedOptions: 23, + functionallyWithoutSchedOptions: 37, + measured: 35, deferred: 2, - openedProjectsWithDefaults: 35, - wiredProjectsWithDefaults: 35, + readableFiles: 35, + openedProjectsWithDefaults: 36, + wiredProjectsWithDefaults: 36, + concreteProjectsCompared: 84, oracleNegativeFloatFiles: 5, withoutSchedOptionsNegativeFloatFiles: 4, rawNegativeFloatFiles: 6, @@ -657,18 +676,21 @@ if (!root) { lagFromEarlyStart: 40, progressOverride: 1, unknownFloatDialect: 8, - derivedRows: 50, + derivedRows: 49, derivedFallbacks: 8, derivedFloatDialectFallbacks: 8, - derivedRetainedLogic: 49, + derivedRetainedLogic: 48, derivedProgressOverride: 1, - derivedFinishFloat: 50, - derivedLagPredecessor: 45, + 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, diff --git a/tests/planning/check-xer-schedule-options-wiring.ts b/tests/planning/check-xer-schedule-options-wiring.ts index 76e91620b..e7880fdd4 100644 --- a/tests/planning/check-xer-schedule-options-wiring.ts +++ b/tests/planning/check-xer-schedule-options-wiring.ts @@ -4,15 +4,14 @@ import { cloneTasksForSolve, solveProject } from '@/engine/scheduler/solveProjec import { readIFC } from '@/services/ifc/ifcReader'; import { writeIFC } from '@/services/ifc/ifcWriter'; import { isMultiDocumentImport } from '@/services/importTypes'; -import { - deriveXerScheduleOptions, - type XerScheduleOptionsMetadata, -} from '@/services/xer/xerScheduleOptions'; +import type { XerScheduleOptionsMetadata } from '@/services/xer/xerScheduleOptions'; import { readXER, type XerReadResult } from '@/services/xer/xerReader'; -import { parseXerTables } from '@/services/xer/xerTables'; import { useAppStore } from '@/state/appStore'; import { recoveryInputFromParsed } from '@/state/documentContract'; -import { isLeafTask } from '@/utils/taskHierarchy'; +import { + expectedXerScheduleOptions, + scanRawXerScheduleOptions, +} from './xerScheduleOptionsGroundTruth'; const diffs: string[] = []; let checks = 0; @@ -36,6 +35,15 @@ 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) @@ -72,6 +80,15 @@ if (!isMultiDocumentImport(multiOpened)) { } 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, @@ -187,7 +204,7 @@ eq('4 ontbrekende SCHEDOPTIONS krijgt altijd de expliciete P6-defaultset op de 4 schedulingOptions: defaultProject.project.schedulingOptions, projectHours: defaultProject.calendar.hoursPerDay, taskHours: defaultProject.resourceCalendars?.find(calendar => calendar.id === 'C4')?.hoursPerDay, - metadata: scheduleMetadata(defaultProject), + metadata: legacyMetadata(scheduleMetadata(defaultProject)), }, { progressMode: 'RETAINED_LOGIC', schedulingOptions: { @@ -220,6 +237,46 @@ eq('4 ontbrekende SCHEDOPTIONS krijgt altijd de expliciete P6-defaultset op de 4 }, }); +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); @@ -285,6 +342,10 @@ eq('7 X4b-file-openfan-out bewaart per document zijn eigen projectinstellingen e { 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; @@ -321,6 +382,11 @@ eq('9 RecoveryDocInput bewaart instellingen en retained bronwaarde per project', { 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 bestaand project-IFC dekt schedulingOptions/progressMode; retained XER-metadata blijft expliciet X9', (multiOpened.results as XerReadResult[]).map(result => { @@ -350,13 +416,12 @@ 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 publicTables = parseXerTables(publicBytes); + 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 = deriveXerScheduleOptions(publicTables, result.project.id, { - hoursPerDay: result.calendar.hoursPerDay, - taskCount: result.tasks.filter(isLeafTask).length, + const expected = expectedXerScheduleOptions(publicTruth, result.project.id, { + taskCount: result.tasks.filter(task => task.p6ActivityType !== undefined).length, }); const metadata = scheduleMetadata(result); return { @@ -368,7 +433,21 @@ if (corpusRoot && existsSync(corpusRoot)) { }) === JSON.stringify({ progressMode: expected.progressMode, schedulingOptions: expected.schedulingOptions, - }) && metadata?.sourceRows.every(row => row.cells.proj_id === result.project.id), + }) && 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, diff --git a/tests/planning/check-xer-schedule-options.ts b/tests/planning/check-xer-schedule-options.ts index 84bd4ec74..bd20e5c1d 100644 --- a/tests/planning/check-xer-schedule-options.ts +++ b/tests/planning/check-xer-schedule-options.ts @@ -1,7 +1,8 @@ import { CPMSolver } from '@/engine/scheduler/CPMSolver'; import { createDefaultProject } from '@/state/defaults'; import { - deriveXerScheduleOptions, + deriveXerScheduleOptions as deriveIndexedXerScheduleOptions, + indexXerScheduleOptions, XER_SCHEDOPTIONS_COLUMN_DISPOSITIONS, XER_SCHEDULING_DEFAULTS, } from '@/services/xer/xerScheduleOptions'; @@ -23,6 +24,19 @@ function eq(label: string, got: unknown, want: unknown): void { } } +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', @@ -582,7 +596,105 @@ const withoutTable = deriveXerScheduleOptions(parseXerTables(xer( ['P1', 'CT_TotFloat', '16'], )), 'P1', { hoursPerDay: 8, taskCount: 9 }); -eq('expliciete XER-defaultset is brongebonden en compleet', withoutTable, { +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: { + lagCalendar: 'predecessor', + criticalDefinition: { mode: 'totalFloat', thresholdHours: 8 }, + totalFloatMode: 'finish', + makeOpenEndedCritical: false, + useExpectedFinishDates: true, + preserveActualDatesInBackwardPass: true, + clampNegativeFreeFloat: 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'); +const linearTables = 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: { @@ -712,7 +824,7 @@ const mapped = deriveXerScheduleOptions(parseXerTables(xer( }, )), 'P1', { hoursPerDay: 8, taskCount: 9 }); -eq('bekende enums en vlaggen worden case-insensitief naar bestaande opties gemapt', mapped, { +eq('bekende enums en vlaggen worden case-insensitief naar bestaande opties gemapt', legacyResult(mapped), { source: 'schedoptions', progressMode: 'PROGRESS_OVERRIDE', schedulingOptions: { diff --git a/tests/planning/xer-schedoptions-blast-radius.json b/tests/planning/xer-schedoptions-blast-radius.json index fbaeeb7e1..3b3e158c5 100644 --- a/tests/planning/xer-schedoptions-blast-radius.json +++ b/tests/planning/xer-schedoptions-blast-radius.json @@ -1,5 +1,5 @@ { - "version": 6, + "version": 7, "axes": [ "es", "ef", @@ -28,12 +28,16 @@ "population": { "scanned": 93, "oracleAxisFiles": 60, - "withSchedOptions": 24, - "withoutSchedOptions": 36, - "measured": 34, + "rawWithSchedOptions": 24, + "rawWithoutSchedOptions": 36, + "projectAddressableSchedOptions": 23, + "functionallyWithoutSchedOptions": 37, + "measured": 35, "deferred": 2, - "openedProjectsWithDefaults": 35, - "wiredProjectsWithDefaults": 35, + "readableFiles": 35, + "openedProjectsWithDefaults": 36, + "wiredProjectsWithDefaults": 36, + "concreteProjectsCompared": 84, "oracleNegativeFloatFiles": 5, "withoutSchedOptionsNegativeFloatFiles": 4, "rawNegativeFloatFiles": 6, @@ -50,13 +54,13 @@ "lagFromEarlyStart": 40, "progressOverride": 1, "unknownFloatDialect": 8, - "derivedRows": 50, + "derivedRows": 49, "derivedFallbacks": 8, "derivedFloatDialectFallbacks": 8, - "derivedRetainedLogic": 49, + "derivedRetainedLogic": 48, "derivedProgressOverride": 1, - "derivedFinishFloat": 50, - "derivedLagPredecessor": 45, + "derivedFinishFloat": 49, + "derivedLagPredecessor": 44, "derivedLagSuccessor": 3, "derivedLag24Hour": 1, "derivedLagProjectDefault": 1, @@ -3296,6 +3300,116 @@ } } }, + { + "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 + }, + "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", @@ -3866,8 +3980,8 @@ "measurable": 7532 }, "tf": { - "deviations": 6931, - "measurable": 7600 + "deviations": 6932, + "measurable": 7602 }, "ff": { "deviations": 2341, @@ -3892,8 +4006,8 @@ "measurable": 7532 }, "tf": { - "deviations": 6406, - "measurable": 7600 + "deviations": 6407, + "measurable": 7602 }, "ff": { "deviations": 585, @@ -3920,8 +4034,8 @@ "measurable": 7532 }, "tf": { - "deviations": 6930, - "measurable": 7600 + "deviations": 6931, + "measurable": 7602 }, "ff": { "deviations": 2341, @@ -3946,8 +4060,8 @@ "measurable": 7532 }, "tf": { - "deviations": 6931, - "measurable": 7600 + "deviations": 6932, + "measurable": 7602 }, "ff": { "deviations": 2341, @@ -3974,8 +4088,8 @@ "measurable": 7532 }, "tf": { - "deviations": 6931, - "measurable": 7600 + "deviations": 6932, + "measurable": 7602 }, "ff": { "deviations": 2341, @@ -4000,8 +4114,8 @@ "measurable": 7532 }, "tf": { - "deviations": 7409, - "measurable": 7600 + "deviations": 7410, + "measurable": 7602 }, "ff": { "deviations": 2521, @@ -4028,8 +4142,8 @@ "measurable": 7532 }, "tf": { - "deviations": 6931, - "measurable": 7600 + "deviations": 6932, + "measurable": 7602 }, "ff": { "deviations": 2341, @@ -4054,8 +4168,8 @@ "measurable": 7532 }, "tf": { - "deviations": 6912, - "measurable": 7600 + "deviations": 6913, + "measurable": 7602 }, "ff": { "deviations": 2339, @@ -4082,8 +4196,8 @@ "measurable": 7532 }, "tf": { - "deviations": 6931, - "measurable": 7600 + "deviations": 6932, + "measurable": 7602 }, "ff": { "deviations": 2341, @@ -4108,8 +4222,8 @@ "measurable": 7532 }, "tf": { - "deviations": 6931, - "measurable": 7600 + "deviations": 6932, + "measurable": 7602 }, "ff": { "deviations": 2342, @@ -4136,8 +4250,8 @@ "measurable": 7532 }, "tf": { - "deviations": 6407, - "measurable": 7600 + "deviations": 6408, + "measurable": 7602 }, "ff": { "deviations": 704, @@ -4162,8 +4276,8 @@ "measurable": 7532 }, "tf": { - "deviations": 6931, - "measurable": 7600 + "deviations": 6932, + "measurable": 7602 }, "ff": { "deviations": 2341, @@ -4190,8 +4304,8 @@ "measurable": 7532 }, "tf": { - "deviations": 6931, - "measurable": 7600 + "deviations": 6932, + "measurable": 7602 }, "ff": { "deviations": 1749, @@ -4216,8 +4330,8 @@ "measurable": 7532 }, "tf": { - "deviations": 6931, - "measurable": 7600 + "deviations": 6932, + "measurable": 7602 }, "ff": { "deviations": 2341, @@ -4244,8 +4358,8 @@ "measurable": 7532 }, "tf": { - "deviations": 6931, - "measurable": 7600 + "deviations": 6932, + "measurable": 7602 }, "ff": { "deviations": 2341, @@ -4270,8 +4384,8 @@ "measurable": 7532 }, "tf": { - "deviations": 6931, - "measurable": 7600 + "deviations": 6932, + "measurable": 7602 }, "ff": { "deviations": 2341, diff --git a/tests/planning/xerScheduleOptionsGroundTruth.ts b/tests/planning/xerScheduleOptionsGroundTruth.ts new file mode 100644 index 000000000..48b08ee99 --- /dev/null +++ b/tests/planning/xerScheduleOptionsGroundTruth.ts @@ -0,0 +1,266 @@ +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 = { + lagCalendar: 'predecessor', + criticalDefinition, + totalFloatMode: 'finish', + makeOpenEndedCritical: false, + useExpectedFinishDates: true, + preserveActualDatesInBackwardPass: true, + clampNegativeFreeFloat: 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 }; + else if (retainedToken.toUpperCase() === 'N') retainedSource = { sched_use_project_end_date_for_float: 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', + ); + } + + 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)); +} From 95fa4fb867da4d58668d5b8ec8edd78f8c58f447 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:18:52 +0200 Subject: [PATCH 029/171] Bedraad XER-resources en toewijzingen --- .../task-X6-wiring-report.md | 79 +++++++ src/services/importTypes.ts | 12 + src/services/xer/xerMultiProject.ts | 7 +- src/services/xer/xerReader.ts | 42 +++- src/services/xer/xerResourceAssignments.ts | 191 ++++++++++++++++ src/services/xer/xerResourceCurves.ts | 116 ++++++++++ src/services/xer/xerResourceTypes.ts | 157 +++++++++++++ src/services/xer/xerResources.ts | 160 ++++++++++++++ src/services/xer/xerTables.ts | 37 +++- src/state/slices/librarySlice.ts | 4 +- src/utils/wbs.ts | 2 +- tests/planning/check-xer-resource-corpus.ts | 132 +++++++++++ tests/planning/check-xer-resources.ts | 207 ++++++++++++++++++ tests/planning/check-xer-schedule-options.ts | 6 +- tests/planning/run.sh | 16 ++ 15 files changed, 1152 insertions(+), 16 deletions(-) create mode 100644 .superpowers/sdd/2026-08-20-plan-xer-p6-lezer/task-X6-wiring-report.md create mode 100644 src/services/xer/xerResourceAssignments.ts create mode 100644 src/services/xer/xerResourceCurves.ts create mode 100644 src/services/xer/xerResourceTypes.ts create mode 100644 src/services/xer/xerResources.ts create mode 100644 tests/planning/check-xer-resource-corpus.ts create mode 100644 tests/planning/check-xer-resources.ts 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..7e0057c95 --- /dev/null +++ b/.superpowers/sdd/2026-08-20-plan-xer-p6-lezer/task-X6-wiring-report.md @@ -0,0 +1,79 @@ +# 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. diff --git a/src/services/importTypes.ts b/src/services/importTypes.ts index 999b86752..1f6107984 100644 --- a/src/services/importTypes.ts +++ b/src/services/importTypes.ts @@ -7,6 +7,8 @@ import type { ActivityCodeType, CustomFieldDef } from '@/types/structure'; import type { Baseline } from '@/types/baseline'; import type { CompanyPool } from '@/types/library'; import type { RecordedFieldKey } from '@/services/ifc/ifcTaskSlots'; +import type { XerResourceCatalog } from './xer/xerResources'; +import type { XerResourceIssue, XerTaskResourceSource } from './xer/xerResourceTypes'; export type XerSourceEncoding = 'utf-8' | 'utf-16le' | 'utf-16be' | 'windows-1252'; @@ -131,6 +133,14 @@ export interface XerImportReport { 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[]; +} + /** Documentgebonden XER-brondata. Externe relaties zijn nadrukkelijk geen solverrelaties. */ export interface XerImportMetadata { defaultCurrencyCode: string; @@ -144,6 +154,8 @@ export interface XerImportMetadata { 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; } /** diff --git a/src/services/xer/xerMultiProject.ts b/src/services/xer/xerMultiProject.ts index 23309ab95..ef3013529 100644 --- a/src/services/xer/xerMultiProject.ts +++ b/src/services/xer/xerMultiProject.ts @@ -38,10 +38,12 @@ interface BaselineDecision { function cloneMappedProject(result: XerReadResult): XerReadResult { const scheduleOptions = result.xer.scheduleOptions; const archive = scheduleOptions.sourceArchive; + const resourceMetadata = result.xer.resources; + const { resources: _resources, ...xerWithoutResources } = result.xer; const clone = structuredClone({ ...result, xer: { - ...result.xer, + ...xerWithoutResources, scheduleOptions: { ...scheduleOptions, sourceArchive: { rows: [], unmatchedScheduleOptionsRowIndexes: [], diagnostics: [] }, @@ -52,6 +54,9 @@ function cloneMappedProject(result: XerReadResult): 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; return clone; } diff --git a/src/services/xer/xerReader.ts b/src/services/xer/xerReader.ts index 1d56540c8..a779e384b 100644 --- a/src/services/xer/xerReader.ts +++ b/src/services/xer/xerReader.ts @@ -24,6 +24,12 @@ import type { import { createDefaultTaskTime } from '@/utils/taskDefaults'; import { formatInstant, parseInstant } from '@/utils/dateUtils'; import { readXerCalendars } from './xerCalendarData'; +import { indexXerTaskResourceRows } from './xerResourceAssignments'; +import { + buildXerResourceCatalog, + materializeXerResources, + type XerResourceCatalog, +} from './xerResources'; import { assembleXerMultiProjectImport, type XerMultiProjectImport } from './xerMultiProject'; import { deriveXerScheduleOptions, @@ -275,6 +281,8 @@ function readXerProject( tables: XerTables, scheduleOptionsIndex: XerScheduleOptionsIndex, projectId: string, + resourceCatalog: XerResourceCatalog, + taskResourceRowsByProject: ReadonlyMap, ): XerReadResult { const projectRow = scheduleOptionsIndex.projectRowsById.get(projectId)?.row; if (!projectRow) { @@ -488,6 +496,21 @@ function readXerProject( if (parent && !parent.childIds.includes(task.id)) parent.childIds.push(task.id); } + // 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) { @@ -562,8 +585,8 @@ function readXerProject( resourceCalendars: calendarList.filter(calendar => calendar.id !== projectCalendar.id), tasks: allTasks, sequences, - resources: [], - assignments: [], + resources: resourceResult.resources, + assignments: resourceResult.assignments, xer: { defaultCurrencyCode: tables.header.defaultCurrencyCode, tableReport: tables.report, @@ -586,6 +609,11 @@ function readXerProject( baselineExclusionReverted: false, baselineFallbackReasons: [], }, + resources: { + catalog: resourceCatalog, + assignments: resourceResult.sources.assignments, + issues: resourceResult.issues, + }, }, }; } @@ -599,10 +627,18 @@ function readXerProject( 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 taskResourceRowsByProject = indexXerTaskResourceRows(tables); const projectRows = tables.tables.get('PROJECT')?.rows ?? []; const assembled = assembleXerMultiProjectImport( tables, - projectId => readXerProject(tables, scheduleOptionsIndex, projectId), + projectId => readXerProject(tables, scheduleOptionsIndex, projectId, resourceCatalog, taskResourceRowsByProject), ); if (assembled.results.length > 0) { // De openvorm blijft compatibel: één PROJECT levert nog altijd één ImportResult. Alleen de diff --git a/src/services/xer/xerResourceAssignments.ts b/src/services/xer/xerResourceAssignments.ts new file mode 100644 index 000000000..23108bc4b --- /dev/null +++ b/src/services/xer/xerResourceAssignments.ts @@ -0,0 +1,191 @@ +/** TASKRSRC-mapping met veldspecifieke P6-schalen en bron-id-indexen. */ + +import type { Resource, ResourceAssignment } from '@/types/resource'; +import type { + XerAssignmentCostsSource, XerAssignmentQuantitiesSource, XerAssignmentUnitScale, + XerResourceCurveSource, XerResourceIssue, XerResourceRateSource, XerResourceReadContext, + XerResourceSource, XerRoleSource, XerTaskResourceSource, +} from './xerResourceTypes'; +import { parseXerNumber, XerImportError, type XerRow, type XerTables } from './xerTables'; + +// 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); + } +} +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 XerResourceSource[], roles: readonly XerRoleSource[], + rates: readonly XerResourceRateSource[], curves: readonly XerResourceCurveSource[], + 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 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 = row.cells.taskrsrc_id?.trim() || `line-${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 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 (!resource) { + sources.push({ ...sourceBase, unitScale: 'DIRECT_FRACTION' }); + issues.push({ code: 'XER_ASSIGNMENT_RESOURCE_MISSING', table: 'TASKRSRC', line: row.line, sourceId, fallback: 'SKIPPED' }); + continue; + } + } else { + const role = roleById.get(entitySourceId); + if (!role) { + sources.push({ ...sourceBase, unitScale: 'DIRECT_FRACTION' }); + issues.push({ code: 'XER_ASSIGNMENT_ROLE_MISSING', table: 'TASKRSRC', line: row.line, sourceId, fallback: 'SKIPPED' }); + continue; + } + 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 (!context.taskIds.has(taskSourceId)) { + 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' }); + assignments.push({ id: `xer-assignment:${sourceId}`, taskId: taskSourceId, resourceId: resource.id, unitsPerDay: scaled.value, + ...(curve?.bestFit && curve.bestFit !== 'UNIFORM' ? { curve: curve.bestFit } : {}) }); + } + 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..809f0050c --- /dev/null +++ b/src/services/xer/xerResourceCurves.ts @@ -0,0 +1,116 @@ +/** XER-resourcecurves: rauwe 21-puntsbrondata plus een niet-destructieve OPS-best-fit. */ + +import type { ResourceCurve } from '@/types/resource'; +import type { XerCurvePoints, XerResourceCurveSource, XerResourceIssue } from './xerResourceTypes'; +import { parseXerNumber, XerImportError, type XerRow, type XerTables } from './xerTables'; + +const CURVE_FAMILIES: readonly ResourceCurve[] = [ + 'UNIFORM', 'FRONT_LOADED', 'BACK_LOADED', 'BELL', 'EARLY_PEAK', 'LATE_PEAK', +]; +const CURVE_FIELDS = Array.from({ length: 21 }, (_, index) => `pct_usage_${index}`); + +function interpolate(points: readonly (readonly [number, number])[], t: number): number { + for (let index = 0; index < points.length - 1; index++) { + const [leftT, leftWeight] = points[index]; + const [rightT, rightWeight] = points[index + 1]; + if (t < leftT || t > rightT) continue; + const fraction = rightT === leftT ? 0 : (t - leftT) / (rightT - leftT); + return leftWeight + fraction * (rightWeight - leftWeight); + } + return points[points.length - 1][1]; +} + +function familyProfile(curve: ResourceCurve): number[] { + const controls: Record = { + UNIFORM: [[0, 1], [1, 1]], + FRONT_LOADED: [[0, 1], [1, 0.2]], + BACK_LOADED: [[0, 0.2], [1, 1]], + BELL: [[0, 0.2], [0.5, 1], [1, 0.2]], + EARLY_PEAK: [[0, 0.2], [1 / 3, 1], [1, 0.2]], + LATE_PEAK: [[0, 0.2], [2 / 3, 1], [1, 0.2]], + }; + return Array.from({ length: 21 }, (_, index) => interpolate(controls[curve], index / 20)); +} + +function normalized(points: readonly number[]): number[] | undefined { + if (points.length !== 21 || points.some(point => !Number.isFinite(point) || point < 0 || point > 100)) return undefined; + const total = points.reduce((sum, point) => sum + point, 0); + return total > 0 ? points.map(point => point / total) : undefined; +} + +/** Kies de kleinste mean-squared error; normalisatie wijzigt de bewaarde bronpunten niet. */ +export function bestFitXerCurve(points: readonly number[]): ResourceCurve | undefined { + const observed = normalized(points); + if (!observed) return undefined; + let best: ResourceCurve | undefined; + let bestError = Number.POSITIVE_INFINITY; + for (const family of CURVE_FAMILIES) { + const candidate = normalized(familyProfile(family)); + if (!candidate) continue; + const error = observed.reduce((sum, point, index) => { + const delta = point - candidate[index]; + return sum + delta * delta; + }, 0) / observed.length; + if (error < bestError) { + best = family; + bestError = error; + } + } + return best; +} + +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)) { + const candidate = asCurveTuple(parsed); + if (bestFitXerCurve(candidate) !== undefined) numericPoints = candidate; + } + } catch { + // De curvelaag bewaart de tokens en rapporteert één rijgebonden fallback. + } + const bestFit = numericPoints ? bestFitXerCurve(numericPoints) : undefined; + sources.push({ rawRow: row, sourceId, internalId: `xer-curve:${sourceId}`, line: row.line, + name: row.cells.curv_name?.trim() ?? '', rawPoints, + ...(numericPoints ? { numericPoints } : {}), ...(bestFit ? { bestFit } : {}) }); + if (!numericPoints || !bestFit) { + 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..fa77c78fc --- /dev/null +++ b/src/services/xer/xerResourceTypes.ts @@ -0,0 +1,157 @@ +import type { Resource, ResourceAssignment, ResourceCurve } from '@/types/resource'; +import type { XerRow } from './xerTables'; + +/** 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; + bestFit?: ResourceCurve; +} + +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..1fb0f67c9 --- /dev/null +++ b/src/services/xer/xerResources.ts @@ -0,0 +1,160 @@ +/** Zelfstandige XER-resourcekern. Broncatalogus is bestandsbreed; projectprojecties blijven mutable. */ + +import type { Resource, ResourceType } from '@/types/resource'; +import type { + XerEntityIdentity, 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 { + resources: Resource[]; + identities: XerEntityIdentity[]; + rows: { + resources: XerResourceSource[]; + roles: XerRoleSource[]; + rates: XerResourceRateSource[]; + curves: ReturnType['sources']; + assignments: XerRow[]; + }; + issues: XerResourceIssue[]; +} + +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 }; + }); +} +function freezeArray(items: T[]): T[] { Object.freeze(items); return items; } + +/** 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 Object.freeze({ resources: freezeArray(resources), identities: freezeArray([...identities, ...roles.identities]), + rows: Object.freeze({ resources: freezeArray(sources), roles: freezeArray(roles.sources), rates: freezeArray(rates), curves: freezeArray(curves.sources), assignments: freezeArray([...(tables.tables.get('TASKRSRC')?.rows ?? [])]) }), + issues: freezeArray(issues) }); +} + +/** 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], + sources: { resources: [...catalog.rows.resources], roles: [...catalog.rows.roles], rates: [...catalog.rows.rates], curves: [...catalog.rows.curves], assignments: assignments.sources }, + issues: [...catalog.issues, ...assignments.issues] }; +} diff --git a/src/services/xer/xerTables.ts b/src/services/xer/xerTables.ts index 1e7d81855..e5e68c5b9 100644 --- a/src/services/xer/xerTables.ts +++ b/src/services/xer/xerTables.ts @@ -71,6 +71,11 @@ export interface XerHeader { } export interface XerRow { + readonly line: number; + readonly cells: Readonly>; +} + +interface MutableXerRow { line: number; cells: Record; } @@ -81,6 +86,12 @@ export interface XerTable { rows: XerRow[]; } +interface MutableXerTable { + name: string; + fields: string[]; + rows: MutableXerRow[]; +} + export type XerImportIssueCode = | 'XER_MISSING_END_MARKER' | 'XER_TRAILING_RECORDS_AFTER_END' @@ -434,6 +445,16 @@ function findProvableCommaDecimal( return null; } +/** 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)) { @@ -444,7 +465,7 @@ export function parseXerTables(bytes: XerByteInput): XerTables { } 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 tables = new Map(); const issues: XerImportIssue[] = []; const unknownTables: Array<{ name: string; rows: number }> = []; const knownTableHeaderLines = new Map(); @@ -455,7 +476,7 @@ export function parseXerTables(bytes: XerByteInput): XerTables { 'Ongeldig XER-bestand: ERMHDR met versie ontbreekt.', ); } - let current: XerTable | undefined; + let current: MutableXerTable | undefined; let currentUnknown: { name: string; rows: number } | undefined; let endMarkerSeen = false; @@ -558,15 +579,16 @@ export function parseXerTables(bytes: XerByteInput): XerTables { } } - assertRequiredColumns(tables); - assertRequiredIdentity(tables); + const immutableTables = tables as unknown as Map; + assertRequiredColumns(immutableTables); + assertRequiredIdentity(immutableTables); const header: XerHeader = { version: headerCells[1] ?? '', defaultCurrencyCode: headerCells[8] ?? '', }; - const numberFormat = determineNumberFormat(tables, header.defaultCurrencyCode, issues); + const numberFormat = determineNumberFormat(immutableTables, header.defaultCurrencyCode, issues); const commaDecimalEvidence = numberFormat.source === 'default' - ? findProvableCommaDecimal(tables) + ? findProvableCommaDecimal(immutableTables) : null; if (commaDecimalEvidence) { throw new XerImportError( @@ -576,9 +598,10 @@ export function parseXerTables(bytes: XerByteInput): XerTables { ); } + freezeXerRows(immutableTables); return { header, - tables, + tables: immutableTables, numberFormat, report: { encoding, diff --git a/src/state/slices/librarySlice.ts b/src/state/slices/librarySlice.ts index 5a5a4622a..2cd4186ef 100644 --- a/src/state/slices/librarySlice.ts +++ b/src/state/slices/librarySlice.ts @@ -1087,7 +1087,7 @@ export const createLibrarySlice: AppSlice = (set, get) => ({ const pIdx = libId ? draftPool.resources.findIndex((r) => r.id === libId) : -1; if (pIdx < 0) return; // Overschrijf de gevolgde velden van het poolitem met de bestandswaarden. - for (const f of RESOURCE_DIFF_FIELDS_LOCAL) (draftPool.resources[pIdx] as Record)[f] = (item as Record)[f]; + for (const f of RESOURCE_DIFF_FIELDS_LOCAL) (draftPool.resources[pIdx] as unknown as Record)[f] = (item as unknown as Record)[f]; // Bewezen patroon (zie promoteResourceToPool hierboven): werk verder met de PLAIN // `bumped`-return-waarde, niet met een current()-herlezing van s.pools[companyId] — die // combinatie (plain top-level object met nog-proxied nested arrays) laat Immers current() @@ -1103,7 +1103,7 @@ export const createLibrarySlice: AppSlice = (set, get) => ({ const libId = item.libraryOrigin?.libraryItemId; const pIdx = libId ? draftPool.calendars.findIndex((c) => c.id === libId) : -1; if (pIdx < 0) return; - for (const f of CALENDAR_DIFF_FIELDS_LOCAL) (draftPool.calendars[pIdx] as Record)[f] = (item as Record)[f]; + for (const f of CALENDAR_DIFF_FIELDS_LOCAL) (draftPool.calendars[pIdx] as unknown as Record)[f] = (item as unknown as Record)[f]; const bumped = bumpPool(draftPool); s.pools[companyId] = bumped; const newHash = computeCalendarHash(bumped.calendars[pIdx]); diff --git a/src/utils/wbs.ts b/src/utils/wbs.ts index 4b447cdf8..4fbf99e7d 100644 --- a/src/utils/wbs.ts +++ b/src/utils/wbs.ts @@ -25,7 +25,7 @@ import type { Task } from '@/types/task'; * mutatie nergens aan. */ function plainView(tasks: readonly Task[]): readonly Task[] { - return isDraft(tasks) ? (current(tasks) as readonly Task[]) : tasks; + return isDraft(tasks) ? (current(tasks as never) as readonly Task[]) : tasks; } /** diff --git a/tests/planning/check-xer-resource-corpus.ts b/tests/planning/check-xer-resource-corpus.ts new file mode 100644 index 000000000..10c93da41 --- /dev/null +++ b/tests/planning/check-xer-resource-corpus.ts @@ -0,0 +1,132 @@ +/** + * X6-corpuspoort. De orakelscan leest tabs rechtstreeks uit de bytes en importeert geen + * productiemodule; de tweede meting gebruikt de echte X6-kern met dezelfde catalogus/partitionering + * als readXER. Alleen anonieme bytehashes worden gelogd. + */ +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { performance } from 'node:perf_hooks'; +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; + maxCoreMs: number; + maxParserHeapBytes: number; + maxCoreHeapBytes: number; +} + +const PINS: Readonly> = { + a2ef7b35c00d8cf8: { bytes: 1797989, resources: 92, rates: 91, assignments: 3575, materialAssignments: 85, curves: 0, roles: 2, maxParserMs: 1000, maxCoreMs: 1000, maxParserHeapBytes: 67108864, maxCoreHeapBytes: 16777216 }, + '2c1dce175b9f0781': { bytes: 18592333, resources: 179, rates: 55, assignments: 52640, materialAssignments: 10584, curves: 1, roles: 2, maxParserMs: 8000, maxCoreMs: 5000, maxParserHeapBytes: 402653184, maxCoreHeapBytes: 67108864 }, +}; +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 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 scan(bytes: Uint8Array): Record { + const text = new TextDecoder().decode(bytes); + let table = ''; + let fields: string[] = []; + const rows = new Map>>(); + for (const line of text.split(/\r\n|\n|\r/)) { + const values = line.split('\t'); + if (values[0] === '%E') break; + if (values[0] === '%T') { table = values[1] ?? ''; fields = []; continue; } + if (values[0] === '%F') { fields = values.slice(1); continue; } + if (values[0] !== '%R' || fields.length === 0) continue; + const row: Record = {}; + for (let index = 0; index < fields.length; index++) row[fields[index]] = values[index + 1] ?? ''; + rows.set(table, [...(rows.get(table) ?? []), row]); + } + const project = rows.get('PROJECT')?.[0]?.proj_id ?? ''; + const resourceTypes = new Map((rows.get('RSRC') ?? []).map(row => [row.rsrc_id, row.rsrc_type?.toLowerCase()])); + const assignments = (rows.get('TASKRSRC') ?? []).filter(row => !row.proj_id || row.proj_id === project); + return { + resources: (rows.get('RSRC') ?? []).length, + rates: (rows.get('RSRCRATE') ?? []).length + (rows.get('ROLERATE') ?? []).length, + assignments: assignments.length, + materialAssignments: assignments.filter(row => resourceTypes.get(row.rsrc_id) === 'rt_mat').length, + curves: (rows.get('RSRCCURVDATA') ?? []).length, + roles: (rows.get('ROLES') ?? []).length, + }; +} +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(); +} +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; + eq(`${digest}: onafhankelijke tabscan`, { bytes: content.byteLength, ...scan(content) }, { + bytes: pin.bytes, resources: pin.resources, rates: pin.rates, assignments: pin.assignments, + materialAssignments: pin.materialAssignments, curves: pin.curves, roles: pin.roles, + }); + gc(); const parserHeap = process.memoryUsage().heapUsed; const parserStart = performance.now(); + const tables = parseXerTables(content); + const parserMs = performance.now() - parserStart; gc(); const parserDelta = Math.max(0, process.memoryUsage().heapUsed - parserHeap); + const calendarResult = readXerCalendars(tables); + const projectRow = tables.tables.get('PROJECT')?.rows[0]; + const project = projectRow?.cells.proj_id ?? ''; + const projectCalendar = calendarResult.byId.get(projectRow?.cells.clndr_id ?? '') ?? calendarResult.calendars[0]; + if (!projectCalendar) { eq(`${digest}: projectkalender bestaat`, false, true); continue; } + const availableCalendarIds = new Set(calendarResult.calendars.map(calendar => calendar.id)); + const catalog = buildXerResourceCatalog(tables, availableCalendarIds); + gc(); const coreHeap = process.memoryUsage().heapUsed; const coreStart = performance.now(); + const materialized = 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 coreMs = performance.now() - coreStart; gc(); const coreDelta = Math.max(0, process.memoryUsage().heapUsed - coreHeap); + const actual = { + resources: catalog.rows.resources.length, rates: catalog.rows.rates.length, assignments: materialized.assignments.length, + materialAssignments: materialized.assignments.filter(assignment => materialized.resources.find(resource => resource.id === assignment.resourceId)?.type === 'MATERIAL').length, + curves: catalog.rows.curves.length, roles: catalog.rows.roles.length, + }; + eq(`${digest}: productiecatalogus en lineaire projectprojectie volgen het onafhankelijke orakel`, actual, scan(content)); + checks += 4; + if (parserMs > pin.maxParserMs) diffs.push(`${digest}: parser ${parserMs.toFixed(1)} ms boven ${pin.maxParserMs} ms`); + if (coreMs > pin.maxCoreMs) diffs.push(`${digest}: X6-kern ${coreMs.toFixed(1)} ms boven ${pin.maxCoreMs} ms`); + if (parserDelta > pin.maxParserHeapBytes) diffs.push(`${digest}: parserheap ${parserDelta} boven ${pin.maxParserHeapBytes}`); + if (coreDelta > pin.maxCoreHeapBytes) diffs.push(`${digest}: kernheap ${coreDelta} boven ${pin.maxCoreHeapBytes}`); + console.log(`. X6 corpus ${digest}: parser ${parserMs.toFixed(1)} ms/${parserDelta} B; kern ${coreMs.toFixed(1)} ms/${coreDelta} 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-resources.ts b/tests/planning/check-xer-resources.ts new file mode 100644 index 000000000..b6a947cd7 --- /dev/null +++ b/tests/planning/check-xer-resources.ts @@ -0,0 +1,207 @@ +/** + * 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'; + +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')); +} + +const curveFields = Array.from({ length: 21 }, (_, index) => `pct_usage_${index}`); +const bell = [ + 20, 28, 36, 44, 52, 60, 68, 76, 84, 92, 100, + 92, 84, 76, 68, 60, 52, 44, 36, 28, 20, +]; +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', + '%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', + '%T\tRSRCCURVDATA', + `%F\tcurv_id\tcurv_name\t${curveFields.join('\t')}`, + `%R\tC-BELL\tKlok\t${bell.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', + '%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 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 }], + }, + ]); + 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: [ + { taskId: 'T1', resourceId: 'xer-resource:42', unitsPerDay: 0.5, curve: 'BELL' }, + { taskId: 'T2', resourceId: 'xer-resource:MAT', unitsPerDay: 30 }, + ], + taskResourceIds: [['T1', ['xer-resource:42']], ['T2', ['xer-resource:MAT']]], + }); + const xer = opened.xer as unknown as { + resources?: { + catalog: { rows: { assignments: readonly unknown[]; curves: readonly { rawPoints: readonly string[] }[] } }; + assignments: readonly { rawRow: object; projectSourceId?: string; quantities: unknown; costs: unknown }[]; + }; + }; + eq('X6-d retained bronmetadata houdt 21 curvepunten en raw assignment-identiteit vast', { + rawAssignments: xer.resources?.catalog.rows.assignments.length, + projectAssignments: xer.resources?.assignments.length, + points: xer.resources?.catalog.rows.curves[0]?.rawPoints.length, + sameRow: xer.resources?.assignments[0]?.rawRow === xer.resources?.catalog.rows.assignments[0], + }, { rawAssignments: 2, projectAssignments: 2, points: 21, sameRow: true }); +} + +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\tA2\tP2\tT2\tR1\t2', + '%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 as unknown as { resources?: { catalog: { rows: { assignments: readonly object[] } }; assignments: readonly { rawRow: object }[] } }; + const secondXer = second.xer as unknown as { resources?: { catalog: { rows: { assignments: readonly object[] } }; assignments: readonly { rawRow: object }[] } }; + 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)]), + rawRows: firstXer.resources?.catalog.rows.assignments.length, + sharedCatalog: firstXer.resources?.catalog === secondXer.resources?.catalog, + projectRowIdentity: secondXer.resources?.assignments[0]?.rawRow === secondXer.resources?.catalog.rows.assignments[1], + }, { + projects: [['P1', ['xer-assignment:A1']], ['P2', ['xer-assignment:A2']]], + rawRows: 3, + sharedCatalog: true, + projectRowIdentity: true, + }); + first.resources[0].name = 'Alleen project P1'; + first.assignments[0].unitsPerDay = 99; + eq('X6-e2 projectprojecties zijn mutable en onderling geïsoleerd, terwijl de catalogus immutable gedeeld blijft', { + firstResource: first.resources[0].name, + secondResource: second.resources[0].name, + firstUnits: first.assignments[0].unitsPerDay, + secondUnits: second.assignments[0].unitsPerDay, + catalogFrozen: Object.isFrozen(firstXer.resources?.catalog), + catalogRowsFrozen: Object.isFrozen(firstXer.resources?.catalog.rows), + }, { + firstResource: 'Alleen project P1', + secondResource: 'Gedeeld', + firstUnits: 99, + secondUnits: 2, + catalogFrozen: true, + catalogRowsFrozen: true, + }); +} + +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 as unknown as { resources?: { catalog: { rows: { assignments: readonly object[] } } } }; + 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.resources?.catalog.rows.assignments.length, + }, { documents: ['P1'], baselines: ['xer-baseline:P1:P2'], rawAssignments: 3 }); +} + +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-options.ts b/tests/planning/check-xer-schedule-options.ts index bd20e5c1d..2b98f25af 100644 --- a/tests/planning/check-xer-schedule-options.ts +++ b/tests/planning/check-xer-schedule-options.ts @@ -658,7 +658,9 @@ 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'); -const linearTables = parseXerTables(new TextEncoder().encode([ +// 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', @@ -667,7 +669,7 @@ const linearTables = parseXerTables(new TextEncoder().encode([ '%F\tproj_id\tsched_float_type', linearScheduleRows, '%E', -].join('\n'))); +].join('\n')))); let projIdReads = 0; for (const tableName of ['PROJECT', 'SCHEDOPTIONS']) { for (const row of linearTables.tables.get(tableName)?.rows ?? []) { diff --git a/tests/planning/run.sh b/tests/planning/run.sh index aad7d6bdc..b01a9b2fc 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -282,6 +282,22 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then XERBYTEPATHCHECK="$DIR/.xer-byte-paths.mjs" if bundle_check "$DIR/check-xer-byte-paths.ts" "$XERBYTEPATHCHECK"; then node "$XERBYTEPATHCHECK" || STATUS=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: 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. From 50799884b1b6e0153e602833717332fdba928270 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:25:16 +0200 Subject: [PATCH 030/171] Fix XER-resourcecatalogus reviewpunten --- .../task-X6-wiring-report.md | 24 ++ src/services/xer/xerResources.ts | 35 ++- src/state/slices/documentSlice.ts | 29 +- tests/planning/check-xer-resource-corpus.ts | 248 ++++++++++++++---- .../planning/check-xer-resource-duplicate.ts | 137 ++++++++++ tests/planning/check-xer-resources.ts | 188 +++++++++++-- tests/planning/run.sh | 7 + 7 files changed, 581 insertions(+), 87 deletions(-) create mode 100644 tests/planning/check-xer-resource-duplicate.ts 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 index 7e0057c95..6b7c12ccb 100644 --- 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 @@ -77,3 +77,27 @@ 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(). diff --git a/src/services/xer/xerResources.ts b/src/services/xer/xerResources.ts index 1fb0f67c9..e9b359a96 100644 --- a/src/services/xer/xerResources.ts +++ b/src/services/xer/xerResources.ts @@ -99,7 +99,25 @@ function readRatesForKind(tables: XerTables, kind: 'RESOURCE' | 'ROLE', ownerIds line: row.line, ...(date ? { effectiveDate: date } : {}), maxUnitsPerTime: numberOf(tables, row, table, 'max_qty_per_hr'), costs }; }); } -function freezeArray(items: T[]): T[] { Object.freeze(items); return items; } +/** + * 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 { @@ -145,9 +163,18 @@ export function buildXerResourceCatalog(tables: XerTables, availableCalendarIds: const roles = mapRoles(roleRows, roleIds, issues); const curves = readXerResourceCurves(tables); issues.push(...curves.issues); - return Object.freeze({ resources: freezeArray(resources), identities: freezeArray([...identities, ...roles.identities]), - rows: Object.freeze({ resources: freezeArray(sources), roles: freezeArray(roles.sources), rates: freezeArray(rates), curves: freezeArray(curves.sources), assignments: freezeArray([...(tables.tables.get('TASKRSRC')?.rows ?? [])]) }), - issues: freezeArray(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, + }); } /** Projectbound: kopieert uitsluitend de mutable projectprojectie en leest alleen zijn lineair gevonden TASKRSRC-rijen. */ diff --git a/src/state/slices/documentSlice.ts b/src/state/slices/documentSlice.ts index 50d93ad02..61d19efde 100644 --- a/src/state/slices/documentSlice.ts +++ b/src/state/slices/documentSlice.ts @@ -14,6 +14,7 @@ import { emitExtensionEvent, HOST_EVENTS } from '@/services/extensionEvents'; import { resetUndoCoalescing } from '../transaction'; import { documentTitle, untitledOrdinals } from '@/utils/documents'; import { solveProject, cloneTasksForSolve } from '@/engine/scheduler/solveProject'; +import type { XerImportMetadata, XerResourceMetadata } from '@/services/importTypes'; // Het documentcontract (payload-vorm + capture/hydrate/fresh) woont nu in `../documentContract` // (audit P10). Hier blijft alleen de multi-document back-end (registry, switchen, sluiten, @@ -129,6 +130,32 @@ 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): XerImportMetadata { + const { resources, ...withoutResources } = source; + const clone = deepClone(withoutResources); + return resources ? { ...clone, resources: cloneXerResourceMetadata(resources) } : clone; +} + /** `"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+)\)$/; @@ -227,7 +254,7 @@ export const createDocumentSlice: AppSlice = (set, get) => ({ filePath: null, fileHandle: null, isDirty: true, - xerImportMetadata: src.xerImportMetadata ? deepClone(src.xerImportMetadata) : null, + xerImportMetadata: src.xerImportMetadata ? cloneXerImportMetadata(src.xerImportMetadata) : null, }; set((s) => { diff --git a/tests/planning/check-xer-resource-corpus.ts b/tests/planning/check-xer-resource-corpus.ts index 10c93da41..b5c5d89df 100644 --- a/tests/planning/check-xer-resource-corpus.ts +++ b/tests/planning/check-xer-resource-corpus.ts @@ -1,12 +1,12 @@ /** - * X6-corpuspoort. De orakelscan leest tabs rechtstreeks uit de bytes en importeert geen - * productiemodule; de tweede meting gebruikt de echte X6-kern met dezelfde catalogus/partitionering - * als readXER. Alleen anonieme bytehashes worden gelogd. + * 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'; @@ -21,21 +21,64 @@ interface Pin { curves: number; roles: number; maxParserMs: number; - maxCoreMs: number; + maxCatalogBuildMs: number; + maxMaterializationMs: number; + maxEndToEndMs: number; maxParserHeapBytes: number; - maxCoreHeapBytes: 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, maxCoreMs: 1000, maxParserHeapBytes: 67108864, maxCoreHeapBytes: 16777216 }, - '2c1dce175b9f0781': { bytes: 18592333, resources: 179, rates: 55, assignments: 52640, materialAssignments: 10584, curves: 1, roles: 2, maxParserMs: 8000, maxCoreMs: 5000, maxParserHeapBytes: 402653184, maxCoreHeapBytes: 67108864 }, + 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[]; bestFit?: string; } +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[] = []; @@ -46,38 +89,132 @@ function files(dir: string): string[] { } return found; } -function scan(bytes: Uint8Array): Record { +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; +} +function curveBestFit(points: readonly string[], format: { decimal: '.' | ','; group: '.' | ',' | null }): string | undefined { + const values: number[] = []; + for (const point of points) { + const value = sourceNumber(point, format); + if (value === null || value < 0 || value > 100) return undefined; + values.push(value); + } + if (values.length !== 21) return undefined; + const total = values.reduce((sum, point) => sum + point, 0); + if (total === 0) return undefined; + const controls: Readonly>> = { + UNIFORM: [[0, 1], [1, 1]], FRONT_LOADED: [[0, 1], [1, 0.2]], BACK_LOADED: [[0, 0.2], [1, 1]], + BELL: [[0, 0.2], [0.5, 1], [1, 0.2]], EARLY_PEAK: [[0, 0.2], [1 / 3, 1], [1, 0.2]], LATE_PEAK: [[0, 0.2], [2 / 3, 1], [1, 0.2]], + }; + const observed = values.map(point => point / total); + let best: string | undefined; + let bestError = Number.POSITIVE_INFINITY; + for (const [family, pointsForFamily] of Object.entries(controls)) { + const profile = Array.from({ length: 21 }, (_, index) => { + const at = index / 20; + for (let control = 0; control < pointsForFamily.length - 1; control++) { + const [leftAt, leftWeight] = pointsForFamily[control]; + const [rightAt, rightWeight] = pointsForFamily[control + 1]; + if (at < leftAt || at > rightAt) continue; + return leftWeight + (rightWeight - leftWeight) * ((at - leftAt) / (rightAt - leftAt)); + } + return pointsForFamily[pointsForFamily.length - 1][1]; + }); + const profileTotal = profile.reduce((sum, point) => sum + point, 0); + const error = observed.reduce((sum, point, index) => sum + (point - profile[index] / profileTotal) ** 2, 0) / observed.length; + if (error < bestError) { best = family; bestError = error; } + } + return best; +} +function scan(bytes: Uint8Array): DirectScan { const text = new TextDecoder().decode(bytes); + const rows = new Map(); let table = ''; let fields: string[] = []; - const rows = new Map>>(); + 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); 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: Record = {}; - for (let index = 0; index < fields.length; index++) row[fields[index]] = values[index + 1] ?? ''; - rows.set(table, [...(rows.get(table) ?? []), row]); + 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 project = rows.get('PROJECT')?.[0]?.proj_id ?? ''; - const resourceTypes = new Map((rows.get('RSRC') ?? []).map(row => [row.rsrc_id, row.rsrc_type?.toLowerCase()])); - const assignments = (rows.get('TASKRSRC') ?? []).filter(row => !row.proj_id || row.proj_id === project); + 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 { - resources: (rows.get('RSRC') ?? []).length, - rates: (rows.get('RSRCRATE') ?? []).length + (rows.get('ROLERATE') ?? []).length, - assignments: assignments.length, - materialAssignments: assignments.filter(row => resourceTypes.get(row.rsrc_id) === 'rt_mat').length, - curves: (rows.get('RSRCCURVDATA') ?? []).length, - roles: (rows.get('ROLES') ?? []).length, + 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 bestFit = curveBestFit(rawPoints, format); + return { sourceId: row.curv_id || '', rawPoints, ...(bestFit ? { bestFit } : {}) }; + }), + 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 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 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); @@ -93,39 +230,44 @@ else { const content = targets.get(digest); eq(`${digest}: openbare corpuspin aanwezig`, Boolean(content), true); if (!content) continue; - eq(`${digest}: onafhankelijke tabscan`, { bytes: content.byteLength, ...scan(content) }, { - bytes: pin.bytes, resources: pin.resources, rates: pin.rates, assignments: pin.assignments, - materialAssignments: pin.materialAssignments, curves: pin.curves, roles: pin.roles, - }); - gc(); const parserHeap = process.memoryUsage().heapUsed; const parserStart = performance.now(); - const tables = parseXerTables(content); - const parserMs = performance.now() - parserStart; gc(); const parserDelta = Math.max(0, process.memoryUsage().heapUsed - parserHeap); + 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 = calendarResult.byId.get(projectRow?.cells.clndr_id ?? '') ?? calendarResult.calendars[0]; - if (!projectCalendar) { eq(`${digest}: projectkalender bestaat`, false, true); continue; } + 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 catalog = buildXerResourceCatalog(tables, availableCalendarIds); - gc(); const coreHeap = process.memoryUsage().heapUsed; const coreStart = performance.now(); - const materialized = materializeXerResources(catalog, tables, { + 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 coreMs = performance.now() - coreStart; gc(); const coreDelta = Math.max(0, process.memoryUsage().heapUsed - coreHeap); - const actual = { - resources: catalog.rows.resources.length, rates: catalog.rows.rates.length, assignments: materialized.assignments.length, - materialAssignments: materialized.assignments.filter(assignment => materialized.resources.find(resource => resource.id === assignment.resourceId)?.type === 'MATERIAL').length, - curves: catalog.rows.curves.length, roles: catalog.rows.roles.length, - }; - eq(`${digest}: productiecatalogus en lineaire projectprojectie volgen het onafhankelijke orakel`, actual, scan(content)); - checks += 4; - if (parserMs > pin.maxParserMs) diffs.push(`${digest}: parser ${parserMs.toFixed(1)} ms boven ${pin.maxParserMs} ms`); - if (coreMs > pin.maxCoreMs) diffs.push(`${digest}: X6-kern ${coreMs.toFixed(1)} ms boven ${pin.maxCoreMs} ms`); - if (parserDelta > pin.maxParserHeapBytes) diffs.push(`${digest}: parserheap ${parserDelta} boven ${pin.maxParserHeapBytes}`); - if (coreDelta > pin.maxCoreHeapBytes) diffs.push(`${digest}: kernheap ${coreDelta} boven ${pin.maxCoreHeapBytes}`); - console.log(`. X6 corpus ${digest}: parser ${parserMs.toFixed(1)} ms/${parserDelta} B; kern ${coreMs.toFixed(1)} ms/${coreDelta} B`); + }, 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 best-fit blijven behouden`, catalog.rows.curves.map(curve => ({ sourceId: curve.sourceId, rawPoints: [...curve.rawPoints], ...(curve.bestFit ? { bestFit: curve.bestFit } : {}) })), 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; } diff --git a/tests/planning/check-xer-resource-duplicate.ts b/tests/planning/check-xer-resource-duplicate.ts new file mode 100644 index 000000000..23529fd1f --- /dev/null +++ b/tests/planning/check-xer-resource-duplicate.ts @@ -0,0 +1,137 @@ +/** + * 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 kloont uitsluitend documentgebonden mutable XER-metadata', { + metadataCloned: duplicateMetadata !== sourceMetadata, + resourceMetadataCloned: duplicateResources !== sourceResources, + assignmentArrayCloned: duplicateResources?.assignments !== sourceResources?.assignments, + assignmentCloned: duplicateProjectAssignment !== sourceProjectAssignment, + entityCloned: duplicateProjectAssignment?.entity !== sourceProjectAssignment.entity, + quantitiesCloned: duplicateProjectAssignment?.quantities !== sourceProjectAssignment.quantities, + costsCloned: duplicateProjectAssignment?.costs !== sourceProjectAssignment.costs, + rawCurvesCloned: duplicateProjectAssignment?.rawCurves !== sourceProjectAssignment.rawCurves, + }, { + metadataCloned: true, + resourceMetadataCloned: true, + assignmentArrayCloned: true, + assignmentCloned: true, + entityCloned: true, + quantitiesCloned: true, + costsCloned: true, + rawCurvesCloned: 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 index b6a947cd7..6a1a2b30e 100644 --- a/tests/planning/check-xer-resources.ts +++ b/tests/planning/check-xer-resources.ts @@ -6,6 +6,9 @@ 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'; const diffs: string[] = []; let checks = 0; @@ -17,6 +20,25 @@ function eq(label: string, got: unknown, want: unknown): void { } } +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)}`); + } +} + function bytes(lines: readonly string[]): Uint8Array { return new TextEncoder().encode(lines.join('\n')); } @@ -39,6 +61,7 @@ const fixture = bytes([ '%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', '%T\tUMEASURE', '%F\tunit_id\tunit_abbrev', '%R\tU-KG\tkg', @@ -53,6 +76,10 @@ const fixture = bytes([ '%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')}`, @@ -60,6 +87,7 @@ const fixture = bytes([ '%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', '%E', ]); @@ -70,6 +98,21 @@ eq('X6-a parser bevries gedeelde TASKRSRC- en taakbronrijen', { 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 materializedResource = directMaterialized.resources.find(resource => resource.id === 'xer-resource:42'); + if (present('X6-a2 directe materialisatie heeft resourcekopie', materializedResource)) materializedResource.name = 'Alleen directe projectview'; + eq('X6-a2 directe materialisatie kan catalogusresource niet muteren', directCatalog.resources.find(resource => resource.id === 'xer-resource:42')?.name, 'Vakman'); +} + const opened = readXER(fixture); if (isMultiDocumentImport(opened)) { diffs.push('X6-b enkelproject-fixture mag niet als multi-documentresultaat terugkomen'); @@ -92,6 +135,10 @@ if (isMultiDocumentImport(opened)) { 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 => ({ @@ -105,21 +152,77 @@ if (isMultiDocumentImport(opened)) { assignments: [ { 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 }, + ], + taskResourceIds: [ + ['T1', ['xer-resource:42']], + ['T2', ['xer-resource:MAT']], + ['T3', ['xer-role:42']], ], - taskResourceIds: [['T1', ['xer-resource:42']], ['T2', ['xer-resource:MAT']]], }); - const xer = opened.xer as unknown as { - resources?: { - catalog: { rows: { assignments: readonly unknown[]; curves: readonly { rawPoints: readonly string[] }[] } }; - assignments: readonly { rawRow: object; projectSourceId?: string; quantities: unknown; costs: unknown }[]; - }; - }; + 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: xer.resources?.catalog.rows.assignments.length, - projectAssignments: xer.resources?.assignments.length, - points: xer.resources?.catalog.rows.curves[0]?.rawPoints.length, - sameRow: xer.resources?.assignments[0]?.rawRow === xer.resources?.catalog.rows.assignments[0], - }, { rawAssignments: 2, projectAssignments: 2, points: 21, sameRow: true }); + rawAssignments: catalog?.rows.assignments.length, + projectAssignments: xer?.assignments.length, + points: curve?.rawPoints.length, + numericPoints: curve?.numericPoints?.length, + sameRow: projectAssignment?.rawRow === rawAssignment, + }, { rawAssignments: 3, projectAssignments: 3, 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.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[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[0] = '9999'; + }); + } } const multiLines = [ @@ -138,7 +241,9 @@ const multiLines = [ '%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; @@ -147,28 +252,38 @@ if (!isMultiDocumentImport(multi)) { diffs.push('X6-e tweeporject-fixture moet de X4b-meerdocumentvorm volgen'); } else { const [first, second] = multi.results; - const firstXer = first.xer as unknown as { resources?: { catalog: { rows: { assignments: readonly object[] } }; assignments: readonly { rawRow: object }[] } }; - const secondXer = second.xer as unknown as { resources?: { catalog: { rows: { assignments: readonly object[] } }; assignments: readonly { rawRow: object }[] } }; + 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)]), - rawRows: firstXer.resources?.catalog.rows.assignments.length, - sharedCatalog: firstXer.resources?.catalog === secondXer.resources?.catalog, - projectRowIdentity: secondXer.resources?.assignments[0]?.rawRow === secondXer.resources?.catalog.rows.assignments[1], + 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']]], - rawRows: 3, + sourceViews: [['P1', ['A1', 'A-MISSING']], ['P2', ['A2']]], + missingTaskIssues: ['A-MISSING'], + rawRows: 5, sharedCatalog: true, projectRowIdentity: true, }); - first.resources[0].name = 'Alleen project P1'; - first.assignments[0].unitsPerDay = 99; + 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: first.resources[0].name, - secondResource: second.resources[0].name, - firstUnits: first.assignments[0].unitsPerDay, - secondUnits: second.assignments[0].unitsPerDay, - catalogFrozen: Object.isFrozen(firstXer.resources?.catalog), - catalogRowsFrozen: Object.isFrozen(firstXer.resources?.catalog.rows), + 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', @@ -177,6 +292,17 @@ if (!isMultiDocumentImport(multi)) { 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.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 => { @@ -191,12 +317,16 @@ 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 as unknown as { resources?: { catalog: { rows: { assignments: readonly object[] } } } }; + 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.resources?.catalog.rows.assignments.length, - }, { documents: ['P1'], baselines: ['xer-baseline:P1:P2'], rawAssignments: 3 }); + 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) { diff --git a/tests/planning/run.sh b/tests/planning/run.sh index b01a9b2fc..c5c6a0dd6 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -290,6 +290,13 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then 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 + # 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" From e55ead67dffa894edb2844e1ea16ae595d5cb317 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:14:57 +0200 Subject: [PATCH 031/171] Herstel resterende X6-reviewpunten --- .../task-X6-wiring-report.md | 27 ++++++ src/services/xer/xerResourceAssignments.ts | 10 +- src/services/xer/xerResourceTypes.ts | 11 +++ src/services/xer/xerResources.ts | 56 ++++++++--- src/state/slices/documentSlice.ts | 13 +-- .../check-xer-resource-catalog-types.ts | 37 ++++++++ tests/planning/check-xer-resources.ts | 92 +++++++++++++++++-- 7 files changed, 214 insertions(+), 32 deletions(-) create mode 100644 tests/planning/check-xer-resource-catalog-types.ts 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 index 6b7c12ccb..c0d30445c 100644 --- 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 @@ -101,3 +101,30 @@ exitcode 0. Rode mutaties (ieder exitcode 1, direct hersteld): rate ×100, verke 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/src/services/xer/xerResourceAssignments.ts b/src/services/xer/xerResourceAssignments.ts index 23108bc4b..47ca19f1f 100644 --- a/src/services/xer/xerResourceAssignments.ts +++ b/src/services/xer/xerResourceAssignments.ts @@ -3,8 +3,8 @@ import type { Resource, ResourceAssignment } from '@/types/resource'; import type { XerAssignmentCostsSource, XerAssignmentQuantitiesSource, XerAssignmentUnitScale, - XerResourceCurveSource, XerResourceIssue, XerResourceRateSource, XerResourceReadContext, - XerResourceSource, XerRoleSource, XerTaskResourceSource, + XerReadonly, XerResourceCurveSource, XerResourceIssue, XerResourceRateSource, + XerResourceReadContext, XerResourceSource, XerRoleSource, XerTaskResourceSource, } from './xerResourceTypes'; import { parseXerNumber, XerImportError, type XerRow, type XerTables } from './xerTables'; @@ -103,15 +103,15 @@ export function indexXerTaskResourceRows(tables: XerTables): ReadonlyMap[], 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 roleRatesById = new Map(); + const roleRatesById = new Map[]>(); for (const rate of rates) { if (rate.entity.kind !== 'ROLE') continue; const current = roleRatesById.get(rate.entity.sourceId) ?? []; diff --git a/src/services/xer/xerResourceTypes.ts b/src/services/xer/xerResourceTypes.ts index fa77c78fc..4d8ca60b4 100644 --- a/src/services/xer/xerResourceTypes.ts +++ b/src/services/xer/xerResourceTypes.ts @@ -1,6 +1,17 @@ import type { Resource, ResourceAssignment, ResourceCurve } 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']; diff --git a/src/services/xer/xerResources.ts b/src/services/xer/xerResources.ts index e9b359a96..90a4e34ed 100644 --- a/src/services/xer/xerResources.ts +++ b/src/services/xer/xerResources.ts @@ -2,8 +2,9 @@ import type { Resource, ResourceType } from '@/types/resource'; import type { - XerEntityIdentity, XerResourceIssue, XerResourceRateSource, XerResourceReadContext, - XerResourceReadResult, XerResourceSource, XerRoleSource, + 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'; @@ -15,16 +16,16 @@ export type { } from './xerResourceTypes'; export interface XerResourceCatalog { - resources: Resource[]; - identities: XerEntityIdentity[]; - rows: { - resources: XerResourceSource[]; - roles: XerRoleSource[]; - rates: XerResourceRateSource[]; - curves: ReturnType['sources']; - assignments: XerRow[]; + 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[]; }; - issues: XerResourceIssue[]; + readonly issues: readonly XerReadonly[]; } function resourceInternalId(sourceId: string): string { return `xer-resource:${sourceId}`; } @@ -174,14 +175,39 @@ export function buildXerResourceCatalog(tables: XerTables, availableCalendarIds: 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], - sources: { resources: [...catalog.rows.resources], roles: [...catalog.rows.roles], rates: [...catalog.rows.rates], curves: [...catalog.rows.curves], assignments: assignments.sources }, - issues: [...catalog.issues, ...assignments.issues] }; + 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/state/slices/documentSlice.ts b/src/state/slices/documentSlice.ts index 61d19efde..5ba774899 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 { AppSlice } from './types'; import { generateId } from '@/utils/id'; @@ -195,7 +196,7 @@ export const createDocumentSlice: AppSlice = (set, get) => ({ const newId = generateId('doc'); 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; hydratePayload(s, freshPayload()); @@ -259,7 +260,7 @@ export const createDocumentSlice: AppSlice = (set, get) => ({ 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; hydratePayload(s, copy); @@ -286,7 +287,7 @@ export const createDocumentSlice: AppSlice = (set, get) => ({ const incoming = target.payload; set((s) => { const cur = s.documents.find((d) => d.id === s.activeDocumentId); - if (cur) cur.payload = outgoing; + if (cur) cur.payload = castDraft(outgoing); hydratePayload(s, incoming); const inc = s.documents.find((d) => d.id === id); if (inc) inc.payload = null; @@ -405,10 +406,10 @@ export const createDocumentSlice: AppSlice = (set, get) => ({ if (docs.length === 0) return; const active = docs.find((d) => d.id === activeId) ?? docs[0]; set((s) => { - s.documents = docs.map((d) => ({ + s.documents = castDraft(docs.map((d) => ({ id: d.id, payload: d.id === active.id ? null : payloadFromInput(d), - })); + }))); s.activeDocumentId = active.id; hydratePayload(s, payloadFromInput(active)); resetDocumentScopedUI(s); @@ -498,7 +499,7 @@ export const createDocumentSlice: AppSlice = (set, get) => ({ 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/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-resources.ts b/tests/planning/check-xer-resources.ts index 6a1a2b30e..5f9fa0899 100644 --- a/tests/planning/check-xer-resources.ts +++ b/tests/planning/check-xer-resources.ts @@ -39,6 +39,16 @@ function rejectsMutation(label: string, mutate: () => void): void { } } +/** 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')); } @@ -108,9 +118,79 @@ if (present('X6-a2 directe materialisatie heeft projectkalender', directProjectC 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'); - if (present('X6-a2 directe materialisatie heeft resourcekopie', materializedResource)) materializedResource.name = 'Alleen directe projectview'; - eq('X6-a2 directe materialisatie kan catalogusresource niet muteren', directCatalog.resources.find(resource => resource.id === 'xer-resource:42')?.name, 'Vakman'); + 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); @@ -210,17 +290,17 @@ if (isMultiDocumentImport(opened)) { if (present('X6-d3 mutatieproef heeft catalogusresource', firstResource)) { rejectsMutation('X6-d3 catalogusresource weigert mutatie vóór X4b-fan-out', () => { - firstResource.name = 'verboden'; + (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[0] = 9_999; + (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[0] = '9999'; + (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'; }); } } @@ -297,7 +377,7 @@ if (!isMultiDocumentImport(multi)) { 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.name = 'lek naar P2'; + (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, From fbd181e51dc9048ac8245ff0f119830db2fa3f6e Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:40:23 +0200 Subject: [PATCH 032/171] Maak planning typecheck package-manager-onafhankelijk --- tests/planning/run.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/planning/run.sh b/tests/planning/run.sh index c5c6a0dd6..f34aad05c 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -699,7 +699,7 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then # oude tsconfig.roundtrip.json dekte alleen deze ene batterij, de rest werd door esbuild # gestript en dus nooit type-gecheckt. # (2) De round-trip zelf: writeIFC→readIFC veld-voor-veld + idempotentie + KNOWN_GAPS. - node "$ROOT/node_modules/.bin/tsc" --noEmit -p "$DIR/tsconfig.check.json" || STATUS=1 + "$ROOT/node_modules/.bin/tsc" --noEmit -p "$DIR/tsconfig.check.json" || STATUS=1 RTCHECK="$DIR/.ifc-roundtrip-check.mjs" if bundle_check "$DIR/check-ifc-roundtrip.ts" "$RTCHECK"; then node "$RTCHECK" || STATUS=1; fi From f6dd0108a8c4d14edc08cfafeca1c8873313a4f8 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:25:03 +0200 Subject: [PATCH 033/171] fix(mpp): breek stilstaande maandcursor direct af --- src/services/calendarRecurrence.ts | 28 +++++++++++++-------------- tests/planning/check-mpp-calendars.ts | 11 ++++++----- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/src/services/calendarRecurrence.ts b/src/services/calendarRecurrence.ts index 0e9abf632..c9e1b1ba5 100644 --- a/src/services/calendarRecurrence.ts +++ b/src/services/calendarRecurrence.ts @@ -139,19 +139,16 @@ export const MAX_RECURRENCE_DATES = 3_660; * ver boven wat één seconde nog acceptabel maakt. §7 van de hardingsdiscipline eist het ERGSTE geval * per BESTAND, niet het (goedkopere) geval per record. * - * Fix: geklemd op `MAX_RECURRENCE_DATES + 16` i.p.v. een los getal. Elke LEGITIEME generatoraanroep - * voegt per doorloop minstens één datum toe (DAILY/MONTHLY-absoluut/YEARLY-absoluut: exact 1 per - * doorloop; WEEKLY: minstens 1 binnen de 7-dagen-binnenlus, tenzij de bitmap leeg is — precies het - * gedegenereerde geval dat deze klem juist moet afvangen) — een legitiem patroon heeft dus hoogstens - * `MAX_RECURRENCE_DATES` PRODUCTIEVE doorlopen nodig, plus een kleine marge voor "verspilde" - * aanloop-bounces vóór de eerste geldige datum (MONTHLY/YEARLY-relatief kan een paar maand-/ - * jaar-stappen nodig hebben vóórdat de berekende datum boven `startDate` uitkomt — in de praktijk - * hoogstens een paar doorlopen, 16 is een royale marge). Met deze waarde (3676) is het ERGSTE geval - * per bestand 2000 × 3676 ≈ 7,35 miljoen iteraties — ~27× goedkoper dan de oude 200 miljoen, en - * elke iteratie is simpele datum-rekenkunde (µs-schaal), dus dat blijft ruim binnen een seconde. - * Mutatiebewijs: `check-mpp-calendars.ts`'s bestaande hostile-cases (occurrences=65535+165-jaars- - * bereik) blijven groen, en een nieuwe fixture met 2000 gedegenereerde MONTHLY-relatief-records - * termineert aantoonbaar binnen 1s (was 46,3s). + * Fix: geklemd op `MAX_RECURRENCE_DATES + 16` i.p.v. een los getal én een monotone-cursorcheck in + * `getMonthlyRelativeDates`. Elke LEGITIEME generatoraanroep voegt per doorloop minstens één datum + * toe (DAILY/MONTHLY-absoluut/YEARLY-absoluut: exact 1 per doorloop; WEEKLY: minstens 1 binnen de + * 7-dagen-binnenlus, tenzij de bitmap leeg is — precies het gedegenereerde geval dat de iteratieklem + * afvangt). Een patroon dat de maandcursor helemaal niet vooruitbrengt stopt nu na die eerste + * vaststelling; de onafhankelijke iteratieklem blijft als vangnet voor andere vormen van verspilde + * doorlopen in alle generatoren. Mutatiebewijs: `check-mpp-calendars.ts`'s bestaande hostile-cases + * (occurrences=65535+165-jaars-bereik) blijven groen, en de fixture met 2000 gedegenereerde + * MONTHLY-relatief-records termineert ruim binnen de 5s-poort (de historische 46,3s-regressie komt + * terug wanneer zowel deze cursorcheck als de verlaagde iteratieklem worden verwijderd). */ export const MAX_RECURRENCE_ITERATIONS = MAX_RECURRENCE_DATES + 16; @@ -365,6 +362,7 @@ function getMonthlyRelativeDates(startDate: Date, frequency: number, dayOfWeekVa let iterations = 0; while (dates.length < MAX_RECURRENCE_DATES && iterations < MAX_RECURRENCE_ITERATIONS && moreDates(date, dates.length, finishDate, occurrences)) { iterations++; + const cursor = date; date = dayNumber > 4 ? lastRelativeDay(date, dayOfWeekValue) : ordinalRelativeDay(date, dayNumber, dayOfWeekValue); if (date.getTime() >= startDate.getTime()) { dates.push(date); @@ -375,7 +373,9 @@ function getMonthlyRelativeDates(startDate: Date, frequency: number, dayOfWeekVa // weggooide; jaar/maand hier al vastleggen en in één stap doorschuiven scheelt ~1/3 van de // Date-allocaties per doorloop (relevant op de HOOG-1a/MIDDEN-A-gedegenereerde-lus-paden, waar // deze reset duizenden keren per record kan draaien). - date = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + frequency, 1)); + const nextDate = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + frequency, 1)); + if (nextDate.getTime() <= cursor.getTime()) break; + date = nextDate; } return dates; } diff --git a/tests/planning/check-mpp-calendars.ts b/tests/planning/check-mpp-calendars.ts index 38f20a2ce..f1dba19a4 100644 --- a/tests/planning/check-mpp-calendars.ts +++ b/tests/planning/check-mpp-calendars.ts @@ -917,10 +917,11 @@ const T3_NO_BANDS_HOURS = buildCalHoursBlock(Array.from({ length: 7 }, () => ({ // (dezelfde stilstaande-cursor-bug als HOOG-1a hierboven, maar dan 2000× in één kalender — het // aantal dat `MAX_CALENDAR_EXCEPTIONS` toestaat). Fix: `MAX_RECURRENCE_ITERATIONS` geklemd op // `MAX_RECURRENCE_DATES + 16` i.p.v. een los, groot getal (plus een nevenoptimalisatie: één -// Date-allocatie minder per doorloop in de cursor-reset, zie `getMonthlyRelativeDates`) — hier -// bewezen met exact dat ergste geval: 2000 records, elk gedegenereerd. Gemeten op deze machine: -// ~1,1s (was 46,3s, een 42× verbetering — ruimer dan de ~27× die de theoretische -// iteratie-reductie alleen voorspelt, dankzij de allocatie-nevenoptimalisatie). +// Date-allocatie minder per doorloop in de cursor-reset, zie `getMonthlyRelativeDates`) én de +// monotone-cursorcheck die een stilstaande maandcursor direct afbreekt — hier bewezen met exact +// dat ergste geval: 2000 records, elk gedegenereerd. De test blijft corpusloos en bewaakt de +// bestaande 5s-poort; de cursorcheck hoort de correcte uitkomst ruim daaronder te houden, terwijl +// de oude 100.000-iteratievariant zonder deze check de historische 46,3s-regressie terugbrengt. // // MARGE-HERZIENING (M3, Opus-eindreview 2026-08-17, precedent: check-adapters-hours.ts se // T4-marge-herziening): 2s was ASYMMETRISCH krap. Lokaal herhaald gemeten: 1161-1224 ms — dat is @@ -952,7 +953,7 @@ const T3_NO_BANDS_HOURS = buildCalHoursBlock(Array.from({ length: 7 }, () => ({ const elapsedMs = Date.now() - start; const totalDates = contributions.reduce((sum, c) => sum + c.ownDates.length, 0); truthy( - `MIDDEN-A 2000 gedegenereerde MONTHLY-relatief-records: aantoonbaar snel (${elapsedMs}ms, geklemd op 5s) — was 46,3s vóór MAX_RECURRENCE_ITERATIONS=MAX_RECURRENCE_DATES+16 (gemeten ~1,1-1,2s op de implementatiemachine, ruime marge voor een tragere CI-machine)`, + `MIDDEN-A 2000 gedegenereerde MONTHLY-relatief-records: aantoonbaar snel (${elapsedMs}ms, geklemd op 5s) — was 46,3s vóór de iteratie- én monotone-cursorklem`, elapsedMs < 5000, ); truthy('MIDDEN-A: 0 datums over alle 2000 records (allemaal gedegenereerd, geen enkele draagt bij)', totalDates === 0); From 3a0da2cbcf6decc464fc90a08d8803db26d9adfd Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:33:06 +0200 Subject: [PATCH 034/171] feat(xer): bedraad metadata-catalogus --- src/services/importTypes.ts | 8 + src/services/xer/xerMetadata.ts | 348 ++++++++++++++++++++ src/services/xer/xerMetadataTypes.ts | 76 +++++ src/services/xer/xerMultiProject.ts | 6 +- src/services/xer/xerReader.ts | 20 +- src/services/xer/xerTables.ts | 13 +- src/state/slices/documentSlice.ts | 12 +- tests/planning/check-xer-metadata-corpus.ts | 64 ++++ tests/planning/check-xer-metadata-wiring.ts | 91 +++++ tests/planning/run.sh | 12 + 10 files changed, 640 insertions(+), 10 deletions(-) create mode 100644 src/services/xer/xerMetadata.ts create mode 100644 src/services/xer/xerMetadataTypes.ts create mode 100644 tests/planning/check-xer-metadata-corpus.ts create mode 100644 tests/planning/check-xer-metadata-wiring.ts diff --git a/src/services/importTypes.ts b/src/services/importTypes.ts index 1f6107984..05fc5be60 100644 --- a/src/services/importTypes.ts +++ b/src/services/importTypes.ts @@ -9,6 +9,7 @@ import type { CompanyPool } from '@/types/library'; import type { RecordedFieldKey } from '@/services/ifc/ifcTaskSlots'; import type { XerResourceCatalog } from './xer/xerResources'; import type { XerResourceIssue, XerTaskResourceSource } from './xer/xerResourceTypes'; +import type { XerMetadataCatalog } from './xer/xerMetadataTypes'; export type XerSourceEncoding = 'utf-8' | 'utf-16le' | 'utf-16be' | 'windows-1252'; @@ -141,6 +142,11 @@ export interface XerResourceMetadata { 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 { defaultCurrencyCode: string; @@ -156,6 +162,8 @@ export interface XerImportMetadata { 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; } /** diff --git a/src/services/xer/xerMetadata.ts b/src/services/xer/xerMetadata.ts new file mode 100644 index 000000000..3b4977701 --- /dev/null +++ b/src/services/xer/xerMetadata.ts @@ -0,0 +1,348 @@ +/** + * 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_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 { + 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; + ensureProjection(projections, owner).notes = [{ id: `xer-note:task:${owner.taskId}`, 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 selected = new Map(); + for (const row of tables.tables.get('TASKMEMO')?.rows ?? []) { + const id = row.cells.memo_id?.trim() || `line-${row.line}`; + const previous = selected.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) }); + if (!previous || compareRows(row, previous) < 0) selected.set(id, row); + } + for (const row of [...selected.values()].sort((a, b) => sequence(a) - sequence(b) || compareRows(a, b))) { + if (!row.cells.task_memo) continue; + if (memoTypes.size > 0 && !memoTypes.has(row.cells.memo_type_id?.trim())) continue; + const owner = resolveTask(row, row.cells.task_id?.trim() ?? '', owners, 'XER_NOTE_DANGLING_TASK', 'XER_NOTE_AMBIGUOUS_TASK', 'TASKMEMO', issues, counts); + if (!owner) continue; + const projection = ensureProjection(projections, owner); + projection.notes = [...(projection.notes ?? []), { 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 catalog: XerMetadataCatalog = { + activityCodeTypes, customFieldDefs: udf.definitions, + taskProjections: [...projections.values()].sort((a, b) => compareText(a.projectId, b.projectId) || compareText(a.taskId, b.taskId)), + 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 ?? [], + 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(); + for (const projection of catalog.taskProjections) { + if (projection.projectId !== projectId) continue; + 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, + }; +} diff --git a/src/services/xer/xerMetadataTypes.ts b/src/services/xer/xerMetadataTypes.ts new file mode 100644 index 000000000..66d67fb9e --- /dev/null +++ b/src/services/xer/xerMetadataTypes.ts @@ -0,0 +1,76 @@ +import type { ActivityCodeType, CustomFieldDef, CustomFieldValue } from '@/types/structure'; +import type { Task } from '@/types/task'; +import type { XerRow } from './xerTables'; + +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_TASK' + | 'XER_NOTE_AMBIGUOUS_TASK'; + +export interface XerMetadataIssue { + code: XerMetadataIssueCode; + table: 'ACTVTYPE' | 'ACTVCODE' | 'TASKACTV' | 'UDFTYPE' | 'UDFVALUE' | 'TASK' | '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. + */ +export interface XerMetadataCatalog { + readonly activityCodeTypes: readonly ActivityCodeType[]; + readonly customFieldDefs: readonly CustomFieldDef[]; + readonly taskProjections: readonly XerMetadataTaskProjection[]; + readonly issues: readonly XerMetadataIssue[]; + readonly issueCounts: Readonly>; + readonly sourceData: { + readonly ACTVTYPE: readonly XerRow[]; + readonly ACTVCODE: readonly XerRow[]; + readonly TASKACTV: readonly XerRow[]; + readonly UDFTYPE: readonly XerRow[]; + readonly UDFVALUE: readonly XerRow[]; + readonly MEMOTYPE: readonly XerRow[]; + readonly TASKMEMO: readonly XerRow[]; + readonly TASK_NOTES: readonly XerRow[]; + readonly deferredUdfValues: readonly XerRow[]; + readonly unknownUdfTypes: readonly XerRow[]; + }; +} + +export interface XerMetadataProjectView { + activityCodeTypes: ActivityCodeType[]; + customFieldDefs: CustomFieldDef[]; + taskMetadata: ReadonlyMap; +} diff --git a/src/services/xer/xerMultiProject.ts b/src/services/xer/xerMultiProject.ts index ef3013529..21c1088d9 100644 --- a/src/services/xer/xerMultiProject.ts +++ b/src/services/xer/xerMultiProject.ts @@ -39,11 +39,12 @@ function cloneMappedProject(result: XerReadResult): XerReadResult { const scheduleOptions = result.xer.scheduleOptions; const archive = scheduleOptions.sourceArchive; const resourceMetadata = result.xer.resources; - const { resources: _resources, ...xerWithoutResources } = result.xer; + const metadata = result.xer.metadata; + const { resources: _resources, metadata: _metadata, ...xerWithoutCatalogs } = result.xer; const clone = structuredClone({ ...result, xer: { - ...xerWithoutResources, + ...xerWithoutCatalogs, scheduleOptions: { ...scheduleOptions, sourceArchive: { rows: [], unmatchedScheduleOptionsRowIndexes: [], diagnostics: [] }, @@ -57,6 +58,7 @@ function cloneMappedProject(result: XerReadResult): XerReadResult { // 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; } diff --git a/src/services/xer/xerReader.ts b/src/services/xer/xerReader.ts index a779e384b..d520d441a 100644 --- a/src/services/xer/xerReader.ts +++ b/src/services/xer/xerReader.ts @@ -24,6 +24,7 @@ import type { import { createDefaultTaskTime } from '@/utils/taskDefaults'; import { formatInstant, parseInstant } from '@/utils/dateUtils'; import { readXerCalendars } from './xerCalendarData'; +import { buildXerMetadataCatalog, materializeXerMetadata, type XerMetadataCatalog } from './xerMetadata'; import { indexXerTaskResourceRows } from './xerResourceAssignments'; import { buildXerResourceCatalog, @@ -282,6 +283,7 @@ function readXerProject( scheduleOptionsIndex: XerScheduleOptionsIndex, projectId: string, resourceCatalog: XerResourceCatalog, + metadataCatalog: XerMetadataCatalog, taskResourceRowsByProject: ReadonlyMap, ): XerReadResult { const projectRow = scheduleOptionsIndex.projectRowsById.get(projectId)?.row; @@ -496,6 +498,18 @@ function readXerProject( 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, { @@ -587,6 +601,8 @@ function readXerProject( sequences, resources: resourceResult.resources, assignments: resourceResult.assignments, + activityCodeTypes: metadata.activityCodeTypes, + customFieldDefs: metadata.customFieldDefs, xer: { defaultCurrencyCode: tables.header.defaultCurrencyCode, tableReport: tables.report, @@ -614,6 +630,7 @@ function readXerProject( assignments: resourceResult.sources.assignments, issues: resourceResult.issues, }, + metadata: { catalog: metadataCatalog }, }, }; } @@ -634,11 +651,12 @@ export function readXER(bytes: Uint8Array): XerOpenResult { if (calendarId) availableCalendarIds.add(calendarId); } const resourceCatalog = buildXerResourceCatalog(tables, availableCalendarIds); + const metadataCatalog = buildXerMetadataCatalog(tables); const taskResourceRowsByProject = indexXerTaskResourceRows(tables); const projectRows = tables.tables.get('PROJECT')?.rows ?? []; const assembled = assembleXerMultiProjectImport( tables, - projectId => readXerProject(tables, scheduleOptionsIndex, projectId, resourceCatalog, taskResourceRowsByProject), + projectId => readXerProject(tables, scheduleOptionsIndex, projectId, resourceCatalog, metadataCatalog, taskResourceRowsByProject), ); if (assembled.results.length > 0) { // De openvorm blijft compatibel: één PROJECT levert nog altijd één ImportResult. Alleen de diff --git a/src/services/xer/xerTables.ts b/src/services/xer/xerTables.ts index e5e68c5b9..248d1d20d 100644 --- a/src/services/xer/xerTables.ts +++ b/src/services/xer/xerTables.ts @@ -141,20 +141,25 @@ const P6_NOTE_FIELDS = new Set([ 'ACCOUNT.acct_descr', 'ROLES.role_descr', 'RSRC.rsrc_notes', + 'TASK.task_notes', 'TASKMEMO.task_memo', 'TASKPROC.proc_descr', 'WBSMEMO.wbs_memo', ]); -function decodeTextCell(table: string, field: string, raw: string): string { - const unquoted = raw.replace(/""/g, '"'); - if (!P6_NOTE_FIELDS.has(`${table}.${field}`)) return unquoted; - const withoutContamination = Array.from(unquoted) +/** 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; +} + // Tabelset waarvoor de latere X3-X8-lagen data nodig kunnen hebben. De selectie volgt voor begrip // MPXJ's `PrimaveraXERFileReader.READ_REQUIRED_TABLES`; de opslag- en rapportcode hieronder is eigen. const READ_TABLES = new Set([ diff --git a/src/state/slices/documentSlice.ts b/src/state/slices/documentSlice.ts index 5ba774899..bc4168fa2 100644 --- a/src/state/slices/documentSlice.ts +++ b/src/state/slices/documentSlice.ts @@ -152,9 +152,15 @@ function cloneXerResourceMetadata(source: XerResourceMetadata): XerResourceMetad } function cloneXerImportMetadata(source: XerImportMetadata): XerImportMetadata { - const { resources, ...withoutResources } = source; - const clone = deepClone(withoutResources); - return resources ? { ...clone, resources: cloneXerResourceMetadata(resources) } : clone; + 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 } : {}), + }; } /** `"Basis (variant 3)"` → `"Basis"`; een naam zonder variant-suffix blijft ongewijzigd. Zo blijft de diff --git a/tests/planning/check-xer-metadata-corpus.ts b/tests/planning/check-xer-metadata-corpus.ts new file mode 100644 index 000000000..6114c581a --- /dev/null +++ b/tests/planning/check-xer-metadata-corpus.ts @@ -0,0 +1,64 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { isMultiDocumentImport } from '@/services/importTypes'; +import { readXER, type XerReadResult } from '@/services/xer/xerReader'; + +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]; } +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); + 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); 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 en productprojectie`, { 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) }, { raw: links, mapped: links, types, values, shared: true }); + const rssMiB = process.memoryUsage().rss / 1024 / 1024; + eq(`C4 ${name}: volledige reader blijft onder 10 seconden`, elapsedMs < 10_000, true); + eq(`C5 ${name}: proces-RSS blijft onder 768 MiB`, rssMiB < 768, true); + console.log(`. xer-metadata-perf: ${name} links=${links}, documenten=${opened.length}, readMs=${elapsedMs.toFixed(1)}, rssMiB=${rssMiB.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-wiring.ts b/tests/planning/check-xer-metadata-wiring.ts new file mode 100644 index 000000000..5863c7fa0 --- /dev/null +++ b/tests/planning/check-xer-metadata-wiring.ts @@ -0,0 +1,91 @@ +import { isMultiDocumentImport } from '@/services/importTypes'; +import { readIFC } from '@/services/ifc/ifcReader'; +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/run.sh b/tests/planning/run.sh index f34aad05c..66daec556 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -297,6 +297,18 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then 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 + + # 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" From ab5e39813500cc4d5c7cb1201849512d38703fe2 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:05:07 +0200 Subject: [PATCH 035/171] fix(xer): herstel snapshot en performancepoort --- src/state/snapshot.ts | 13 ++++- tests/library/run.sh | 4 +- tests/planning/check-xer-metadata-corpus.ts | 54 ++++++++++++++++++--- 3 files changed, 62 insertions(+), 9 deletions(-) diff --git a/src/state/snapshot.ts b/src/state/snapshot.ts index 1591812f5..35e367905 100644 --- a/src/state/snapshot.ts +++ b/src/state/snapshot.ts @@ -1,4 +1,4 @@ -import { isDraft, original } from 'immer'; +import { isDraft, original, type Draft } from 'immer'; import type { WorkCalendar } from '@/types/calendar'; import type { AppState } from './appStore'; import type { DocumentPayload } from './documentContract'; @@ -98,8 +98,17 @@ void _assertPickHasNoExtras; * *guards; snapshot; mutatie*. Alle vier de aanroepers doen dat (`beginUndoable`, `withTransaction`, * `undo`, `redo`); `runInMcpTransaction` geeft sowieso plain state door. */ +/** + * Immer's `isDraft` is helaas geen TypeScript-typeguard. De runtimecheck is wel precies de + * contractgrens: alleen daarna mag `original()` een draft ontvangen. Dit houdt retained readonly + * broncatalogi buiten een ongegronde `any`-cast wanneer een documentstate door een producer loopt. + */ +function isAppStateDraft(state: AppState): state is Draft { + return isDraft(state); +} + export function createSnapshot(s: AppState): Snapshot { - const base = (isDraft(s) ? (original(s) as AppState | undefined) : s) ?? s; + const base = isAppStateDraft(s) ? original(s) : s; const snap = {} as Snapshot; for (const f of DOCUMENT_FIELDS) { if (f.snapshot === 'none') continue; diff --git a/tests/library/run.sh b/tests/library/run.sh index 2c80d5b44..ad91476c9 100755 --- a/tests/library/run.sh +++ b/tests/library/run.sh @@ -11,7 +11,9 @@ STATUS=0 # Compile-afdwinging (fixture-/type-volledigheid) — dedicated tsconfig, want de hoofd-tsconfig # sluit tests/ uit. -node "$TSC" --noEmit -p "$DIR/tsconfig.check.json" || STATUS=1 +# npm levert op POSIX een uitvoerbare shellshim in .bin; via `node` uitvoeren leest die shim als +# JavaScript. Rechtstreeks starten werkt ook wanneer de bin een echte Node-entrypoint is. +"$TSC" --noEmit -p "$DIR/tsconfig.check.json" || STATUS=1 run_check() { local name="$1" diff --git a/tests/planning/check-xer-metadata-corpus.ts b/tests/planning/check-xer-metadata-corpus.ts index 6114c581a..aeb12080a 100644 --- a/tests/planning/check-xer-metadata-corpus.ts +++ b/tests/planning/check-xer-metadata-corpus.ts @@ -1,7 +1,10 @@ +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 }); @@ -29,6 +32,33 @@ function countRaw(bytes: Uint8Array): Counts { } 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; +} const root = process.env.OPS_XER_CORPUS; if (!root) console.log('OK XER metadata-corpus: corpus niet aanwezig — corpuspoort overgeslagen'); else { @@ -50,14 +80,26 @@ else { 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); const started = performance.now(); const opened = results(bytes); const elapsedMs = performance.now() - started; + 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 en productprojectie`, { 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) }, { raw: links, mapped: links, types, values, shared: true }); - const rssMiB = process.memoryUsage().rss / 1024 / 1024; - eq(`C4 ${name}: volledige reader blijft onder 10 seconden`, elapsedMs < 10_000, true); - eq(`C5 ${name}: proces-RSS blijft onder 768 MiB`, rssMiB < 768, true); - console.log(`. xer-metadata-perf: ${name} links=${links}, documenten=${opened.length}, readMs=${elapsedMs.toFixed(1)}, rssMiB=${rssMiB.toFixed(1)}`); + 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'); From 8aeb3fe78c9eb12e487ff3e8014a38c23da1ce09 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:45:18 +0200 Subject: [PATCH 036/171] fix(xer): indexeer metadata per project --- src/services/xer/xerMetadata.ts | 14 ++++- src/services/xer/xerMetadataTypes.ts | 4 ++ src/state/snapshot.ts | 7 ++- tests/planning/check-xer-metadata-index.ts | 20 +++++++ tests/planning/check-xer-metadata-review.ts | 65 +++++++++++++++++++++ tests/planning/run.sh | 6 ++ 6 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 tests/planning/check-xer-metadata-index.ts create mode 100644 tests/planning/check-xer-metadata-review.ts diff --git a/src/services/xer/xerMetadata.ts b/src/services/xer/xerMetadata.ts index 3b4977701..e27310ffb 100644 --- a/src/services/xer/xerMetadata.ts +++ b/src/services/xer/xerMetadata.ts @@ -314,9 +314,14 @@ export function buildXerMetadataCatalog(tables: XerTables): XerMetadataCatalog { 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: [...projections.values()].sort((a, b) => compareText(a.projectId, b.projectId) || compareText(a.taskId, b.taskId)), + taskProjections, taskProjectionsByProject, issues, issueCounts: counts, sourceData: { ACTVTYPE: tables.tables.get('ACTVTYPE')?.rows ?? [], ACTVCODE: tables.tables.get('ACTVCODE')?.rows ?? [], @@ -333,8 +338,10 @@ export function buildXerMetadataCatalog(tables: XerTables): XerMetadataCatalog { /** 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(); - for (const projection of catalog.taskProjections) { - if (projection.projectId !== projectId) continue; + 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 } } : {}), @@ -344,5 +351,6 @@ export function materializeXerMetadata(catalog: XerMetadataCatalog, projectId: s 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 index 66d67fb9e..cdbe1559f 100644 --- a/src/services/xer/xerMetadataTypes.ts +++ b/src/services/xer/xerMetadataTypes.ts @@ -53,6 +53,8 @@ export interface XerMetadataCatalog { readonly activityCodeTypes: readonly ActivityCodeType[]; readonly customFieldDefs: readonly CustomFieldDef[]; readonly taskProjections: readonly XerMetadataTaskProjection[]; + /** Diep bevroren projectindex; materialisatie bezoekt uitsluitend deze projectgroep. */ + readonly taskProjectionsByProject: Readonly>; readonly issues: readonly XerMetadataIssue[]; readonly issueCounts: Readonly>; readonly sourceData: { @@ -73,4 +75,6 @@ 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/state/snapshot.ts b/src/state/snapshot.ts index 35e367905..09d8f7317 100644 --- a/src/state/snapshot.ts +++ b/src/state/snapshot.ts @@ -1,4 +1,4 @@ -import { isDraft, original, type Draft } from 'immer'; +import { current, isDraft, original, type Draft } from 'immer'; import type { WorkCalendar } from '@/types/calendar'; import type { AppState } from './appStore'; import type { DocumentPayload } from './documentContract'; @@ -108,7 +108,10 @@ function isAppStateDraft(state: AppState): state is Draft { } export function createSnapshot(s: AppState): Snapshot { - const base = isAppStateDraft(s) ? original(s) : s; + // Immer 11.1.4 typeert `original()` terecht als mogelijk undefined. De runtimegrens hierboven + // garandeert een draft; mocht een afwijkende Immer-implementatie toch geen basis teruggeven, + // dan levert `current()` binnen diezelfde draftgrens een plain, niet-intrekbare momentopname. + const base = isAppStateDraft(s) ? original(s) ?? current(s) : s; const snap = {} as Snapshot; for (const f of DOCUMENT_FIELDS) { if (f.snapshot === 'none') continue; 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..e249e8aa0 --- /dev/null +++ b/tests/planning/check-xer-metadata-review.ts @@ -0,0 +1,65 @@ +import { isMultiDocumentImport } from '@/services/importTypes'; +import { readXER } from '@/services/xer/xerReader'; +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]); + +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/run.sh b/tests/planning/run.sh index 66daec556..93272b2d8 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -302,6 +302,12 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then 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 From 384074ff135de0a5bf999358579fdb1ac76005ac Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:28:14 +0200 Subject: [PATCH 037/171] Voeg P6 XER voortgang en suspend-resume toe --- src/engine/moveProject.ts | 2 + src/engine/scheduler/CPMSolver.ts | 16 +++- src/extensions/extMappers.ts | 8 ++ src/extensions/extTypes.ts | 4 + src/services/importTypes.ts | 2 +- src/services/xer/xerReader.ts | 40 +++++++- src/types/task.ts | 26 ++++-- tests/planning/check-ext-contract.ts | 3 +- tests/planning/check-ifc-roundtrip.ts | 8 +- tests/planning/check-xer-progress.ts | 127 ++++++++++++++++++++++++++ tests/planning/run.sh | 4 + 11 files changed, 222 insertions(+), 18 deletions(-) create mode 100644 tests/planning/check-xer-progress.ts diff --git a/src/engine/moveProject.ts b/src/engine/moveProject.ts index 99d5e2628..1683868c2 100644 --- a/src/engine/moveProject.ts +++ b/src/engine/moveProject.ts @@ -108,6 +108,8 @@ const TASK_VERDICTS = { effortDriven: 'n/a', // Z14b: vlag, geen datum 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', 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 diff --git a/src/engine/scheduler/CPMSolver.ts b/src/engine/scheduler/CPMSolver.ts index 655afe364..87392aa76 100644 --- a/src/engine/scheduler/CPMSolver.ts +++ b/src/engine/scheduler/CPMSolver.ts @@ -1635,7 +1635,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 ? task.p6SuspendResume === true : 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; @@ -1873,6 +1879,14 @@ 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 expected = this.parseIn(progressCal, task.p6ExpectedFinish); + if (!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 diff --git a/src/extensions/extMappers.ts b/src/extensions/extMappers.ts index 20795affa..39f7b5c7f 100644 --- a/src/extensions/extMappers.ts +++ b/src/extensions/extMappers.ts @@ -328,6 +328,10 @@ export function toExtTask(t: Task): ExtTask { // mspTaskType/effortDriven hierboven. p6DurationType: t.p6DurationType, p6ActivityType: t.p6ActivityType, + p6ProjectId: t.p6ProjectId, + p6TaskId: t.p6TaskId, + p6CompletePctType: t.p6CompletePctType, + p6ExpectedFinish: t.p6ExpectedFinish, p6SuspendResume: t.p6SuspendResume, timephasedContours: t.timephasedContours ? t.timephasedContours.map(c => ({ resourceUid: c.resourceUid, periods: c.periods.map(p => ({ ...p })) })) : undefined, timephasedFinishFloor: t.timephasedFinishFloor, @@ -379,6 +383,10 @@ export function fromExtTask(t: ExtTask): Task { // mspTaskType/effortDriven hierboven. p6DurationType: t.p6DurationType, p6ActivityType: t.p6ActivityType, + p6ProjectId: t.p6ProjectId, + p6TaskId: t.p6TaskId, + p6CompletePctType: t.p6CompletePctType, + p6ExpectedFinish: t.p6ExpectedFinish, p6SuspendResume: t.p6SuspendResume, timephasedContours: t.timephasedContours ? t.timephasedContours.map(c => ({ resourceUid: c.resourceUid, periods: c.periods.map(p => ({ ...p })) })) : undefined, timephasedFinishFloor: t.timephasedFinishFloor, diff --git a/src/extensions/extTypes.ts b/src/extensions/extTypes.ts index fbd05b2e6..2ba13d84d 100644 --- a/src/extensions/extTypes.ts +++ b/src/extensions/extTypes.ts @@ -211,6 +211,10 @@ export interface ExtTask { p6DurationType?: 'DT_FixedDrtn' | 'DT_FixedDUR2' | 'DT_FixedRate' | 'DT_FixedQty'; /** X0 (XER-etappeplan) — P6's eigen Activity Type bij .xer-import. Zelfde nuance als `p6DurationType`. */ p6ActivityType?: 'TT_Task' | 'TT_Rsrc' | 'TT_LOE' | 'TT_Mile' | 'TT_FinMile' | 'TT_WBS'; + p6ProjectId?: string; + p6TaskId?: string; + p6CompletePctType?: 'CP_Drtn' | 'CP_Phys' | 'CP_Units'; + p6ExpectedFinish?: string; /** X0 (XER-etappeplan) — herkomstvlag voor `time.resume`/`time.stop`: signaleert P6-suspend/ * resume-herkomst (XER `suspend_date`/`resume_date`) i.p.v. de MSP-conventie. Stub tot de XER- * etappe se X7-taak 'm daadwerkelijk zet. Spiegelt {@link import('@/types/task').Task}.p6SuspendResume. */ diff --git a/src/services/importTypes.ts b/src/services/importTypes.ts index 1f6107984..7dee5167a 100644 --- a/src/services/importTypes.ts +++ b/src/services/importTypes.ts @@ -38,7 +38,7 @@ export interface XerCalendarIssueMetadata { } export interface XerEnumFallback { - family: 'activityType' | 'durationType' | 'status' | 'priority' | 'constraint' | 'relation'; + family: 'activityType' | 'durationType' | 'completePctType' | 'status' | 'priority' | 'constraint' | 'relation'; token: string; fallback: string; table: 'PROJECT' | 'TASK' | 'TASKPRED'; diff --git a/src/services/xer/xerReader.ts b/src/services/xer/xerReader.ts index a779e384b..38554363c 100644 --- a/src/services/xer/xerReader.ts +++ b/src/services/xer/xerReader.ts @@ -16,6 +16,7 @@ import type { WorkCalendar } from '@/types/calendar'; import type { Sequence, SequenceType } from '@/types/sequence'; import type { P6ActivityType, + P6CompletePctType, P6DurationType, Task, TaskConstraint, @@ -58,6 +59,7 @@ const ACTIVITY_TYPES: readonly P6ActivityType[] = [ 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(); @@ -150,6 +152,13 @@ function statusOf(raw: string, row: XerRow, issues: XerEnumFallback[]): TaskStat 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, @@ -402,8 +411,17 @@ function readXerProject( row.cells.duration_type ?? '', projectDefaultDuration, row, enumFallbacks, ); const status = statusOf(row.cells.status_code ?? '', row, enumFallbacks); + const rawCompletePctType = row.cells.complete_pct_type ?? ''; + const completePctType = completePctTypeOf(rawCompletePctType, row, enumFallbacks); + const completePercent = numberOf(tables, row, 'complete_pct'); const physicalPercent = numberOf(tables, row, 'phys_complete_pct') ?? 0; - const completion = status === 'COMPLETED' ? 1 : Math.max(0, Math.min(1, physicalPercent / 100)); + // Oudere/minimale XER-rijen missen vaak complete_pct_type/complete_pct. Behoud daar X4a's + // fysieke-percentage-terugval; alleen een expliciet CP_Drtn-record laat zijn percentage de + // restduur afleiden. + const completionPercent = completePctType === 'CP_Phys' + ? physicalPercent + : completePercent ?? physicalPercent; + const completion = status === 'COMPLETED' ? 1 : Math.max(0, Math.min(1, completionPercent / 100)); const parentId = row.cells.wbs_id ? wbsTaskId(projectId, row.cells.wbs_id) : null; const time = createDefaultTaskTime(start, durationMinutes / (hoursPerDay * 60)); time.scheduleFinish = finish; @@ -412,16 +430,25 @@ function readXerProject( time.lateStart = start; time.lateFinish = finish; time.completion = completion; + const p6RemainingHours = completePctType === 'CP_Drtn' && completePercent !== null + ? durationHours * (1 - completion) + : remainingHours; if (hourMode) { time.durationMinutes = durationMinutes; - if (remainingHours !== null) time.remainingMinutes = Math.round(remainingHours * 60); - } else if (remainingHours !== null) { - time.remainingTime = remainingHours / hoursPerDay; + 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 = sourceInstant(row.cells.suspend_date ?? '', hourMode); + const resume = sourceInstant(row.cells.resume_date ?? '', hourMode); + if (stop) time.stop = stop; + if (resume) time.resume = resume; + const validSuspendResume = stop !== undefined && resume !== undefined && parseInstant(stop) <= parseInstant(resume); + const expectedFinish = sourceInstant(row.cells.expect_end_date ?? '', hourMode); const isStartMilestone = activityType === 'TT_Mile'; const isFinishMilestone = activityType === 'TT_FinMile'; @@ -446,6 +473,11 @@ function readXerProject( : {}), p6DurationType: durationType, p6ActivityType: activityType, + p6ProjectId: projectId, + p6TaskId: row.cells.task_id, + p6CompletePctType: completePctType, + ...(expectedFinish ? { p6ExpectedFinish: expectedFinish } : {}), + ...(validSuspendResume ? { p6SuspendResume: true } : {}), ...(activityType === 'TT_LOE' ? { isHammock: true } : {}), ...(() => { const constraint = constraintOf( diff --git a/src/types/task.ts b/src/types/task.ts index 236942f01..4431d57e6 100644 --- a/src/types/task.ts +++ b/src/types/task.ts @@ -156,7 +156,10 @@ export type MspTaskType = 'FIXED_UNITS' | 'FIXED_DURATION' | 'FIXED_WORK'; * 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'; +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 @@ -468,15 +471,18 @@ export interface Task { * 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; - /** OPTIONEEL — herkomstvlag voor `time.resume`/`time.stop` (O6-patroon; zie `resumeFromActualElapsed` - * in `types/project.ts` voor het precedent van een default-uit, uitsluitend-door-de-eigen-lezer- - * gezette opt-invlag). P6 kent EIGEN suspend/resume-semantiek (XER `TASK.suspend_date`/ - * `resume_date`) die het bestaande MSP-conventiepad rond `time.resume`/`time.stop` (Z12-herwerk) - * niet zonder meer deelt. DIT VELD IS EEN STUB: X0 (typecontract) legt alleen de aanwezigheid en - * de naam vast — de XER-etappe se X7-taak meet EERST of/waar P6's suspend/resume-gedrag afwijkt - * van de MSP-solversemantiek, en vult dit veld pas dan daadwerkelijk (geen enkele lezer zet het - * vóór die meting). Default/afwezig ⇒ MSP-conventie, byte-identiek. */ + p6ActivityType?: P6ActivityType; + /** `task_id` is slechts uniek binnen dit P6-project; beide bronidentiteiten blijven bewaard. */ + p6ProjectId?: string; + p6TaskId?: string; + /** 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 diff --git a/tests/planning/check-ext-contract.ts b/tests/planning/check-ext-contract.ts index 762c377c3..d76c17c7b 100644 --- a/tests/planning/check-ext-contract.ts +++ b/tests/planning/check-ext-contract.ts @@ -96,7 +96,7 @@ const EXT_TASK_KEYS = keys()([ 'timephasedFinishFloor', 'timephasedStartAnchor', 'timephasedDurationWalks', // X0 (XER-etappeplan, 2026-08-20): drie nieuwe .xer-importvelden, zelfde behandeling als de // .mpp-velden hierboven. - 'p6DurationType', 'p6ActivityType', 'p6SuspendResume', + 'p6DurationType', 'p6ActivityType', 'p6ProjectId', 'p6TaskId', 'p6CompletePctType', 'p6ExpectedFinish', 'p6SuspendResume', ] as const); const EXT_SEQUENCE_KEYS = keys()([ @@ -181,6 +181,7 @@ const VOL_TASK = { // de .mpp-velden hierboven (de round-trip-check vergelijkt via JSON.stringify). p6DurationType: 'DT_FixedDUR2', p6ActivityType: 'TT_Rsrc', + p6ProjectId: 'P1', p6TaskId: 'T1', 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', diff --git a/tests/planning/check-ifc-roundtrip.ts b/tests/planning/check-ifc-roundtrip.ts index 33fa9bc7d..04f90ad75 100644 --- a/tests/planning/check-ifc-roundtrip.ts +++ b/tests/planning/check-ifc-roundtrip.ts @@ -249,7 +249,9 @@ const TM = { mspTaskType: 'FIXED_WORK', effortDriven: true, // X0 (XER-etappeplan, 2026-08-20) — drie nieuwe .xer-importvelden, nog GEEN IFC-pset (zie // TASK_CANON hieronder: skip, reden X9). Alleen hier gevuld voor de `Required`-volledigheid. - p6DurationType: 'DT_FixedDUR2', p6ActivityType: 'TT_Rsrc', p6SuspendResume: true, + p6DurationType: 'DT_FixedDUR2', p6ActivityType: 'TT_Rsrc', + p6ProjectId: 'P1', p6TaskId: 'T1', 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) @@ -548,6 +550,10 @@ const TASK_CANON = { // round-trip). Zelfde taxonomie als `interferingFloat`/`isNearCritical`/`floatPath` hierboven. p6DurationType: { skip: 'nog geen IFC-pset — typecontract-only, X0 (XER-etappeplan); wiring volgt in X9' }, p6ActivityType: { skip: 'nog geen IFC-pset — typecontract-only, X0 (XER-etappeplan); wiring volgt in X9' }, + p6ProjectId: { skip: 'X7-bronidentiteit wacht op X9-IFC-wiring' }, + p6TaskId: { skip: 'X7-bronidentiteit wacht op X9-IFC-wiring' }, + p6CompletePctType: { skip: 'X7-brontype wacht op X9-IFC-wiring' }, + p6ExpectedFinish: { skip: 'X7-brondatum wacht op X9-IFC-wiring' }, p6SuspendResume: { skip: 'nog geen IFC-pset — typecontract-only, X0 (XER-etappeplan); wiring volgt in X9' }, 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) }, diff --git a/tests/planning/check-xer-progress.ts b/tests/planning/check-xer-progress.ts new file mode 100644 index 000000000..44732d408 --- /dev/null +++ b/tests/planning/check-xer-progress.ts @@ -0,0 +1,127 @@ +import { solveProject } from '@/engine/scheduler/solveProject'; +import { isMultiDocumentImport } from '@/services/importTypes'; +import { readXER, type XerReadResult } 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)}`); + } +} + +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 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], + ]); +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']); + +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-5 projectfilter-fixture gaf geen meervoudige import terug'); +} else { + eq('X7-5 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/run.sh b/tests/planning/run.sh index f34aad05c..c533f2f16 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -235,6 +235,10 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then XERREADERCHECK="$DIR/.xer-reader.mjs" if bundle_check "$DIR/check-xer-reader.ts" "$XERREADERCHECK"; then node "$XERREADERCHECK" || 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 + # 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. From 9aab3eaa66574c8edf8f61a5d71d4a64f347f9ce Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:38:58 +0200 Subject: [PATCH 038/171] Pin XER voortgangscorpus op P6-brondata --- tests/planning/check-xer-progress-corpus.ts | 79 +++++++++++++++++++ tests/planning/run.sh | 7 ++ .../xer-schedoptions-blast-radius.json | 16 ++-- 3 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 tests/planning/check-xer-progress-corpus.ts diff --git a/tests/planning/check-xer-progress-corpus.ts b/tests/planning/check-xer-progress-corpus.ts new file mode 100644 index 000000000..21c470198 --- /dev/null +++ b/tests/planning/check-xer-progress-corpus.ts @@ -0,0 +1,79 @@ +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, validPairs: 0, stopOnly: 0, reversed: 0, CP_Drtn: 0, CP_Phys: 0, CP_Units: 0 }; + for (const task of tasks) { + const stop = task.suspend_date; + const resume = task.resume_date; + if (stop) counts.suspend++; + if (resume) counts.resume++; + 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, 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/run.sh b/tests/planning/run.sh index c533f2f16..020d49de8 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -239,6 +239,13 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then XERPROGRESSCHECK="$DIR/.xer-progress.mjs" if bundle_check "$DIR/check-xer-progress.ts" "$XERPROGRESSCHECK"; then node "$XERPROGRESSCHECK" || 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 + # 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. diff --git a/tests/planning/xer-schedoptions-blast-radius.json b/tests/planning/xer-schedoptions-blast-radius.json index 3b3e158c5..d04b7229b 100644 --- a/tests/planning/xer-schedoptions-blast-radius.json +++ b/tests/planning/xer-schedoptions-blast-radius.json @@ -774,8 +774,8 @@ 0, 16, 12, - 12, - 9, + 13, + 10, 0 ], "xerDefaultsNegativeFloatTasks": 37, @@ -796,11 +796,11 @@ "retainedLogic": { "movement": [ 67, - 68, + 69, 1, 1, - 67, - 28, + 68, + 29, 0 ], "chosenNegativeFloatTasks": 44, @@ -836,10 +836,10 @@ "movement": [ 0, 0, - 16, - 12, + 15, 12, - 7, + 13, + 8, 0 ], "chosenNegativeFloatTasks": 37, From a7f7231c84758da4d53ae7d5445d3d880f38b85b Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:22:53 +0200 Subject: [PATCH 039/171] fix(xer): behoud TASKNOTE-metadata --- src/services/xer/xerMetadata.ts | 33 +++++++++---- src/services/xer/xerMetadataTypes.ts | 44 +++++++++-------- src/services/xer/xerTables.ts | 3 +- tests/planning/check-xer-corpus.ts | 6 ++- .../check-xer-metadata-catalog-types.ts | 29 ++++++++++++ tests/planning/check-xer-metadata-corpus.ts | 39 +++++++++++++++ tests/planning/check-xer-metadata-review.ts | 47 +++++++++++++++++++ 7 files changed, 170 insertions(+), 31 deletions(-) create mode 100644 tests/planning/check-xer-metadata-catalog-types.ts diff --git a/src/services/xer/xerMetadata.ts b/src/services/xer/xerMetadata.ts index e27310ffb..d6023f5d0 100644 --- a/src/services/xer/xerMetadata.ts +++ b/src/services/xer/xerMetadata.ts @@ -26,7 +26,7 @@ const ISSUE_CODES = [ '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_TASK', 'XER_NOTE_AMBIGUOUS_TASK', + '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 { @@ -271,27 +271,41 @@ 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; - ensureProjection(projections, owner).notes = [{ id: `xer-note:task:${owner.taskId}`, text: row.cells.task_notes, done: false }]; + 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 selected = new Map(); + 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 = selected.get(id); + 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) }); - if (!previous || compareRows(row, previous) < 0) selected.set(id, row); + else firstMemoById.set(id, row); } - for (const row of [...selected.values()].sort((a, b) => sequence(a) - sequence(b) || compareRows(a, b))) { + for (const row of tables.tables.get('TASKMEMO')?.rows ?? []) { if (!row.cells.task_memo) continue; - if (memoTypes.size > 0 && !memoTypes.has(row.cells.memo_type_id?.trim())) 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; - const projection = ensureProjection(projections, owner); - projection.notes = [...(projection.notes ?? []), { id: `xer-note:memo:${row.cells.memo_id?.trim() || `line-${row.line}`}`, text: row.cells.task_memo, done: false }]; + append(owner, { id: `xer-note:memo:${row.cells.memo_id?.trim() || 'line'}:${row.line}`, text: row.cells.task_memo, done: false }); } } @@ -327,6 +341,7 @@ export function buildXerMetadataCatalog(tables: XerTables): XerMetadataCatalog { 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, diff --git a/src/services/xer/xerMetadataTypes.ts b/src/services/xer/xerMetadataTypes.ts index cdbe1559f..7247f14ce 100644 --- a/src/services/xer/xerMetadataTypes.ts +++ b/src/services/xer/xerMetadataTypes.ts @@ -1,6 +1,7 @@ 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' @@ -23,12 +24,13 @@ export type XerMetadataIssueCode = | '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' | 'TASKMEMO'; + table: 'ACTVTYPE' | 'ACTVCODE' | 'TASKACTV' | 'UDFTYPE' | 'UDFVALUE' | 'TASK' | 'TASKNOTE' | 'TASKMEMO'; line: number; lines?: number[]; } @@ -49,28 +51,32 @@ export interface XerMetadataTaskProjection extends XerTaskMetadata { * 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. */ -export interface XerMetadataCatalog { - readonly activityCodeTypes: readonly ActivityCodeType[]; - readonly customFieldDefs: readonly CustomFieldDef[]; - readonly taskProjections: readonly XerMetadataTaskProjection[]; +interface XerMetadataCatalogShape { + activityCodeTypes: ActivityCodeType[]; + customFieldDefs: CustomFieldDef[]; + taskProjections: XerMetadataTaskProjection[]; /** Diep bevroren projectindex; materialisatie bezoekt uitsluitend deze projectgroep. */ - readonly taskProjectionsByProject: Readonly>; - readonly issues: readonly XerMetadataIssue[]; - readonly issueCounts: Readonly>; - readonly sourceData: { - readonly ACTVTYPE: readonly XerRow[]; - readonly ACTVCODE: readonly XerRow[]; - readonly TASKACTV: readonly XerRow[]; - readonly UDFTYPE: readonly XerRow[]; - readonly UDFVALUE: readonly XerRow[]; - readonly MEMOTYPE: readonly XerRow[]; - readonly TASKMEMO: readonly XerRow[]; - readonly TASK_NOTES: readonly XerRow[]; - readonly deferredUdfValues: readonly XerRow[]; - readonly unknownUdfTypes: readonly XerRow[]; + 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[]; diff --git a/src/services/xer/xerTables.ts b/src/services/xer/xerTables.ts index 248d1d20d..dd0e879d8 100644 --- a/src/services/xer/xerTables.ts +++ b/src/services/xer/xerTables.ts @@ -142,6 +142,7 @@ const P6_NOTE_FIELDS = new Set([ 'ROLES.role_descr', 'RSRC.rsrc_notes', 'TASK.task_notes', + 'TASKNOTE.task_notes', 'TASKMEMO.task_memo', 'TASKPROC.proc_descr', 'WBSMEMO.wbs_memo', @@ -165,7 +166,7 @@ function decodeTextCell(table: string, field: string, raw: string): string { const READ_TABLES = new Set([ 'PROJECT', 'CALENDAR', 'RSRC', 'RSRCRATE', 'PROJWBS', 'TASK', 'TASKPRED', 'TASKRSRC', 'CURRTYPE', 'UDFTYPE', 'UDFVALUE', 'SCHEDOPTIONS', 'ACTVTYPE', 'ACTVCODE', 'TASKACTV', - 'COSTTYPE', 'ACCOUNT', 'PROJCOST', 'MEMOTYPE', 'WBSMEMO', 'TASKMEMO', 'ROLES', 'ROLERATE', + 'COSTTYPE', 'ACCOUNT', 'PROJCOST', 'MEMOTYPE', 'WBSMEMO', 'TASKNOTE', 'TASKMEMO', 'ROLES', 'ROLERATE', 'RSRCCURVDATA', 'TASKPROC', 'LOCATION', 'UMEASURE', 'SHIFT', 'SHIFTPER', 'RSRCROLE', 'PCATTYPE', 'PCATVAL', 'PROJPCAT', 'RCATTYPE', 'RCATVAL', 'RSRCRCAT', 'ROLECATTYPE', 'ROLECATVAL', 'ROLERCAT', 'ASGNMNTCATTYPE', 'ASGNMNTCATVAL', 'ASGNMNTACAT', diff --git a/tests/planning/check-xer-corpus.ts b/tests/planning/check-xer-corpus.ts index 071a1caeb..869e273b4 100644 --- a/tests/planning/check-xer-corpus.ts +++ b/tests/planning/check-xer-corpus.ts @@ -307,8 +307,10 @@ if (!root) { XER_TRAILING_RECORDS_AFTER_END: 5, XER_CURRENCY_NOT_FOUND: 2, }, - unknownTables: 60, - unknownRows: 914, + // 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, }); } 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 index aeb12080a..eb28fc264 100644 --- a/tests/planning/check-xer-metadata-corpus.ts +++ b/tests/planning/check-xer-metadata-corpus.ts @@ -59,6 +59,18 @@ function productProbe(path: string): ProductProbe { 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 { @@ -77,6 +89,33 @@ else { ] 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; diff --git a/tests/planning/check-xer-metadata-review.ts b/tests/planning/check-xer-metadata-review.ts index e249e8aa0..3092bcdec 100644 --- a/tests/planning/check-xer-metadata-review.ts +++ b/tests/planning/check-xer-metadata-review.ts @@ -1,5 +1,7 @@ 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 = [ @@ -61,5 +63,50 @@ eq('switch terug naar bron blijft geïsoleerd', [store.getState().activeDocument 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)`); From 0a8f19fab8336ef8390ed9bbc80cfa15fc23590f Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:31:11 +0200 Subject: [PATCH 040/171] Maak XER-projectindex lineair --- src/services/xer/xerReader.ts | 56 +++++++-- tests/planning/check-xer-progress.ts | 20 ++- tests/planning/check-xer-project-index.ts | 114 ++++++++++++++++++ tests/planning/run.sh | 3 + .../xer-schedoptions-blast-radius.json | 2 +- 5 files changed, 182 insertions(+), 13 deletions(-) create mode 100644 tests/planning/check-xer-project-index.ts diff --git a/src/services/xer/xerReader.ts b/src/services/xer/xerReader.ts index 38554363c..92887f403 100644 --- a/src/services/xer/xerReader.ts +++ b/src/services/xer/xerReader.ts @@ -53,6 +53,46 @@ export interface XerReadResult extends ImportResult { /** 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; +} + +export function indexXerProjectRows(tables: XerTables): XerProjectRowsIndex { + const tasksByProject = new Map(); + const wbsByProject = new Map(); + const relationsByProject = new Map(); + const projectsByTaskId = 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 projects = new Set(); + if (row.cells.proj_id) projects.add(row.cells.proj_id); + else for (const projectId of projectsByTaskId.get(row.cells.task_id) ?? []) projects.add(projectId); + if (row.cells.pred_proj_id) projects.add(row.cells.pred_proj_id); + else for (const projectId of projectsByTaskId.get(row.cells.pred_task_id) ?? []) projects.add(projectId); + for (const projectId of projects) append(relationsByProject, projectId, row); + } + return { tasksByProject, wbsByProject, relationsByProject, visitCount }; +} + const ACTIVITY_TYPES: readonly P6ActivityType[] = [ 'TT_Task', 'TT_Rsrc', 'TT_LOE', 'TT_Mile', 'TT_FinMile', 'TT_WBS', ]; @@ -292,6 +332,7 @@ function readXerProject( projectId: string, resourceCatalog: XerResourceCatalog, taskResourceRowsByProject: ReadonlyMap, + projectRowsIndex: XerProjectRowsIndex, ): XerReadResult { const projectRow = scheduleOptionsIndex.projectRowsById.get(projectId)?.row; if (!projectRow) { @@ -301,8 +342,7 @@ function readXerProject( { table: 'PROJECT', field: 'proj_id' }, ); } - const activityRows = (tables.tables.get('TASK')?.rows ?? []) - .filter(row => row.cells.proj_id === projectId); + const activityRows = projectRowsIndex.tasksByProject.get(projectId) ?? []; if (activityRows.length === 0) { throw new XerImportError( 'XER_EMPTY_PROJECT', @@ -310,13 +350,8 @@ function readXerProject( { table: 'TASK' }, ); } - const rawWbsRows = (tables.tables.get('PROJWBS')?.rows ?? []) - .filter(row => row.cells.proj_id === projectId); - const relationRows = (tables.tables.get('TASKPRED')?.rows ?? []).filter(row => { - const successorProjectId = row.cells.proj_id || projectId; - const predecessorProjectId = row.cells.pred_proj_id || successorProjectId; - return successorProjectId === projectId || predecessorProjectId === projectId; - }); + 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'); @@ -667,10 +702,11 @@ export function readXER(bytes: Uint8Array): XerOpenResult { } const resourceCatalog = buildXerResourceCatalog(tables, availableCalendarIds); const taskResourceRowsByProject = indexXerTaskResourceRows(tables); + const projectRowsIndex = indexXerProjectRows(tables); const projectRows = tables.tables.get('PROJECT')?.rows ?? []; const assembled = assembleXerMultiProjectImport( tables, - projectId => readXerProject(tables, scheduleOptionsIndex, projectId, resourceCatalog, taskResourceRowsByProject), + projectId => readXerProject(tables, scheduleOptionsIndex, projectId, resourceCatalog, taskResourceRowsByProject, projectRowsIndex), ); if (assembled.results.length > 0) { // De openvorm blijft compatibel: één PROJECT levert nog altijd één ImportResult. Alleen de diff --git a/tests/planning/check-xer-progress.ts b/tests/planning/check-xer-progress.ts index 44732d408..9d5ad1449 100644 --- a/tests/planning/check-xer-progress.ts +++ b/tests/planning/check-xer-progress.ts @@ -95,6 +95,22 @@ function solved(expectFlag: boolean): string | undefined { 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 multi = readXER(bytes([ 'ERMHDR\t23.12\t2026-08-01\t\t\t\t\t\tEUR', '%T\tPROJECT', @@ -108,9 +124,9 @@ const multi = readXER(bytes([ '%E', ])); if (!isMultiDocumentImport(multi)) { - diffs.push('X7-5 projectfilter-fixture gaf geen meervoudige import terug'); + diffs.push('X7-6 projectfilter-fixture gaf geen meervoudige import terug'); } else { - eq('X7-5 file-wide index is lineair maar lekt geen gelijke task_id tussen projecten', + 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, ])), [ diff --git a/tests/planning/check-xer-project-index.ts b/tests/planning/check-xer-project-index.ts new file mode 100644 index 000000000..d82fce529 --- /dev/null +++ b/tests/planning/check-xer-project-index.ts @@ -0,0 +1,114 @@ +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 type { XerRow, 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: [] }, + 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 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/run.sh b/tests/planning/run.sh index 020d49de8..deb9dfc0f 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -246,6 +246,9 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then 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. diff --git a/tests/planning/xer-schedoptions-blast-radius.json b/tests/planning/xer-schedoptions-blast-radius.json index d04b7229b..299e1e49d 100644 --- a/tests/planning/xer-schedoptions-blast-radius.json +++ b/tests/planning/xer-schedoptions-blast-radius.json @@ -772,7 +772,7 @@ "xerDefaultsMovement": [ 0, 0, - 16, + 15, 12, 13, 10, From eb9bad2020b1d6acf8f6a0a938e5a78665082169 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:52:51 +0200 Subject: [PATCH 041/171] Repareer X7-reviewbevindingen --- src/i18n/locales/ar/common.json | 1 + src/i18n/locales/de/common.json | 1 + src/i18n/locales/en/common.json | 1 + src/i18n/locales/es/common.json | 1 + src/i18n/locales/fa/common.json | 1 + src/i18n/locales/fr/common.json | 1 + src/i18n/locales/it/common.json | 1 + src/i18n/locales/ja/common.json | 1 + src/i18n/locales/ko/common.json | 1 + src/i18n/locales/nl/common.json | 1 + src/i18n/locales/pl/common.json | 1 + src/i18n/locales/pt/common.json | 1 + src/i18n/locales/tr/common.json | 1 + src/i18n/locales/zh/common.json | 1 + src/services/formatRegistry.ts | 2 + src/services/ifc/ifcPsets.ts | 33 +++++- src/services/xer/xerReader.ts | 111 +++++++++++++++--- src/services/xer/xerTables.ts | 1 + src/state/slices/types.ts | 1 + tests/planning/check-document-contract.ts | 27 +++++ tests/planning/check-ifc-roundtrip.ts | 23 ++-- tests/planning/check-xer-progress.ts | 70 +++++++++++ tests/planning/check-xer-project-index.ts | 106 ++++++++++++++++- tests/planning/check-xer-registry.ts | 1 + .../check-xer-schedule-options-corpus.ts | 76 ++++++++++++ .../xer-schedoptions-blast-radius.json | 10 +- 26 files changed, 443 insertions(+), 32 deletions(-) diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index dddaa3b65..ff99ee9c1 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -17,6 +17,7 @@ "xerSingleProjectRequired": "يدعم هذا الاستيراد مشروع P6 واحدًا فقط لكل ملف XER.", "xerEmptyProject": "لا يحتوي مشروع P6 في ملف XER هذا على أنشطة.", "xerDuplicateId": "يحتوي ملف XER على معرّف مكرر لـ WBS أو نشاط أو علاقة.", + "xerAmbiguousLocalRelation": "لا يمكن إسناد علاقة XER غير محددة النطاق بشكل قاطع إلى مشروع محلي واحد.", "xerDanglingLocalRelation": "تشير علاقة XER محلية إلى نشاط مفقود.", "xerEnumFallback": "XER {{family}}: الرمز «{{token}}» غير معروف؛ استُخدمت القيمة «{{fallback}}».", "mppLegacy": "يستخدم ملف .mpp هذا تنسيقًا قديمًا (Project 2007 أو أقدم). صدّره من MS Project بصيغة XML (ملف ← حفظ باسم ← XML) ثم افتح ذلك الملف.", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 8a1aa6c7a..c1e389a78 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -17,6 +17,7 @@ "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}}“.", "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.", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index be2ff689c..b89194f81 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -17,6 +17,7 @@ "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}}’.", "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.", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 216859f1b..cad960269 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -17,6 +17,7 @@ "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}}».", "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.", diff --git a/src/i18n/locales/fa/common.json b/src/i18n/locales/fa/common.json index a7ed89659..401b9750e 100644 --- a/src/i18n/locales/fa/common.json +++ b/src/i18n/locales/fa/common.json @@ -17,6 +17,7 @@ "xerSingleProjectRequired": "این درون‌ریزی دقیقاً از یک پروژهٔ P6 در هر فایل XER پشتیبانی می‌کند.", "xerEmptyProject": "پروژهٔ P6 در این فایل XER هیچ فعالیتی ندارد.", "xerDuplicateId": "فایل XER دارای شناسهٔ تکراری WBS، فعالیت یا رابطه است.", + "xerAmbiguousLocalRelation": "یک رابطهٔ XER بدون محدوده را نمی‌توان به‌طور یکتا به یک پروژهٔ محلی نسبت داد.", "xerDanglingLocalRelation": "یک رابطهٔ محلی XER به فعالیتی ناموجود اشاره می‌کند.", "xerEnumFallback": "XER {{family}}: نشانهٔ «{{token}}» ناشناخته است؛ «{{fallback}}» استفاده شد.", "mppLegacy": "این فایل .mpp از قالبی قدیمی (Project 2007 یا قدیمی‌تر) استفاده می‌کند. آن را در MS Project به‌صورت XML صادر کنید (فایل ← ذخیره به‌عنوان ← XML) و آن فایل را باز کنید.", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 39fbfdce3..650ffed19 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -17,6 +17,7 @@ "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}} ».", "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.", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 0bd1338e5..f3460deb0 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -17,6 +17,7 @@ "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}}».", "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.", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 1f492722c..72a484b9e 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -17,6 +17,7 @@ "xerSingleProjectRequired": "このインポートは XER ファイルごとに 1 つの P6 プロジェクトのみ対応します。", "xerEmptyProject": "この XER ファイルの P6 プロジェクトにはアクティビティがありません。", "xerDuplicateId": "XER ファイルに重複した WBS、アクティビティ、または関係 ID があります。", + "xerAmbiguousLocalRelation": "スコープのない XER 関係を 1 つのローカルプロジェクトに一意に割り当てられません。", "xerDanglingLocalRelation": "ローカル XER 関係が存在しないアクティビティを参照しています。", "xerEnumFallback": "XER {{family}}: 不明なトークン「{{token}}」のため「{{fallback}}」を使用しました。", "mppLegacy": "この .mpp ファイルは古い形式(Project 2007 以前)を使用しています。MS Project で XML としてエクスポートし(ファイル → 名前を付けて保存 → XML)、そのファイルを開いてください。", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index e2350b663..4b5bbc3c4 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -17,6 +17,7 @@ "xerSingleProjectRequired": "이 가져오기는 XER 파일당 정확히 하나의 P6 프로젝트를 지원합니다.", "xerEmptyProject": "이 XER 파일의 P6 프로젝트에 활동이 없습니다.", "xerDuplicateId": "XER 파일에 중복된 WBS, 활동 또는 관계 ID가 있습니다.", + "xerAmbiguousLocalRelation": "범위가 없는 XER 관계를 하나의 로컬 프로젝트에 명확하게 할당할 수 없습니다.", "xerDanglingLocalRelation": "로컬 XER 관계가 누락된 활동을 참조합니다.", "xerEnumFallback": "XER {{family}}: 알 수 없는 토큰 ‘{{token}}’; ‘{{fallback}}’을 사용했습니다.", "mppLegacy": "이 .mpp 파일은 이전 형식(Project 2007 이하)을 사용합니다. MS Project에서 XML로 내보낸 후(파일 → 다른 이름으로 저장 → XML) 해당 파일을 여세요.", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index ef0c79374..86bde6891 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -17,6 +17,7 @@ "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}}’.", "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.", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index d87958324..597050f81 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -17,6 +17,7 @@ "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}}”.", "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.", diff --git a/src/i18n/locales/pt/common.json b/src/i18n/locales/pt/common.json index e46213935..14f89ad92 100644 --- a/src/i18n/locales/pt/common.json +++ b/src/i18n/locales/pt/common.json @@ -17,6 +17,7 @@ "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}}”.", "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.", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index f5af5fab9..1a65ae96f 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -17,6 +17,7 @@ "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ı.", "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.", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 7fee9e7ba..3e271338e 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -17,6 +17,7 @@ "xerSingleProjectRequired": "此导入仅支持每个 XER 文件包含一个 P6 项目。", "xerEmptyProject": "此 XER 文件中的 P6 项目不包含作业。", "xerDuplicateId": "XER 文件包含重复的 WBS、作业或关系 ID。", + "xerAmbiguousLocalRelation": "未指定范围的 XER 关系无法明确分配给唯一的本地项目。", "xerDanglingLocalRelation": "本地 XER 关系引用了缺失的作业。", "xerEnumFallback": "XER {{family}}:未知标记“{{token}}”;已回退为“{{fallback}}”。", "mppLegacy": "此 .mpp 文件使用较旧的格式(Project 2007 或更早版本)。请在 MS Project 中将其导出为 XML(文件 → 另存为 → XML),然后打开该文件。", diff --git a/src/services/formatRegistry.ts b/src/services/formatRegistry.ts index d133c1ccb..3bd3234ac 100644 --- a/src/services/formatRegistry.ts +++ b/src/services/formatRegistry.ts @@ -167,6 +167,7 @@ export function importErrorMessageKey( | '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'; @@ -184,6 +185,7 @@ export function importErrorMessageKey( 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) { diff --git a/src/services/ifc/ifcPsets.ts b/src/services/ifc/ifcPsets.ts index 88d419319..344c52725 100644 --- a/src/services/ifc/ifcPsets.ts +++ b/src/services/ifc/ifcPsets.ts @@ -1,5 +1,6 @@ import type { Task, ConstraintType, TaskSplitGap, TaskTimephasedContour, TimephasedContourPeriod, MspTaskType, + P6CompletePctType, } from '@/types/task'; /** @@ -63,6 +64,8 @@ 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). @@ -445,7 +448,35 @@ export const PER_TASK_PSETS: PerTaskPset[] = [ } }, }, - // 15. Expliciete WBS-identiteit — nodig om een lege PROJWBS-samenvatting door IFC te bewaren. + // 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.p6CompletePctType) props.push({ name: 'CompletePctType', value: `IFCLABEL(${ifcStr(task.p6CompletePctType)})` }); + if (task.p6ExpectedFinish) props.push({ name: 'ExpectedFinish', value: `IFCTEXT(${ifcStr(task.p6ExpectedFinish)})` }); + if (task.p6SuspendResume === true) 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']; + for (const { name, value } of props) { + if (name === 'SuspendResume') { if (value === true) task.p6SuspendResume = true; 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 === '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_', diff --git a/src/services/xer/xerReader.ts b/src/services/xer/xerReader.ts index 92887f403..ce3bf261e 100644 --- a/src/services/xer/xerReader.ts +++ b/src/services/xer/xerReader.ts @@ -60,6 +60,14 @@ export interface XerProjectRowsIndex { 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 { @@ -67,6 +75,12 @@ export function indexXerProjectRows(tables: XerTables): XerProjectRowsIndex { 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) ?? []; @@ -83,14 +97,60 @@ export function indexXerProjectRows(tables: XerTables): XerProjectRowsIndex { 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 projects = new Set(); - if (row.cells.proj_id) projects.add(row.cells.proj_id); - else for (const projectId of projectsByTaskId.get(row.cells.task_id) ?? []) projects.add(projectId); - if (row.cells.pred_proj_id) projects.add(row.cells.pred_proj_id); - else for (const projectId of projectsByTaskId.get(row.cells.pred_task_id) ?? []) projects.add(projectId); - for (const projectId of projects) append(relationsByProject, projectId, row); + 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 }; + return { tasksByProject, wbsByProject, relationsByProject, visitCount, relationResolutionIssues }; } const ACTIVITY_TYPES: readonly P6ActivityType[] = [ @@ -445,18 +505,26 @@ function readXerProject( const durationType = durationTypeOf( row.cells.duration_type ?? '', projectDefaultDuration, row, enumFallbacks, ); - const status = statusOf(row.cells.status_code ?? '', row, enumFallbacks); + const rawStatus = row.cells.status_code ?? ''; + const status = statusOf(rawStatus, row, enumFallbacks); const rawCompletePctType = row.cells.complete_pct_type ?? ''; const completePctType = completePctTypeOf(rawCompletePctType, row, enumFallbacks); const completePercent = numberOf(tables, row, 'complete_pct'); const physicalPercent = numberOf(tables, row, 'phys_complete_pct') ?? 0; - // Oudere/minimale XER-rijen missen vaak complete_pct_type/complete_pct. Behoud daar X4a's - // fysieke-percentage-terugval; alleen een expliciet CP_Drtn-record laat zijn percentage de - // restduur afleiden. - const completionPercent = completePctType === 'CP_Phys' + // 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. + const completionPercent = rawCompletePctType.trim() === '' ? physicalPercent - : completePercent ?? physicalPercent; - const completion = status === 'COMPLETED' ? 1 : Math.max(0, Math.min(1, completionPercent / 100)); + : completePctType === 'CP_Phys' + ? physicalPercent + : completePercent ?? 0; + const completion = status === 'COMPLETED' + ? 1 + : rawStatus.trim() !== '' && status === 'NOT_STARTED' + ? 0 + : Math.max(0, Math.min(1, completionPercent / 100)); const parentId = row.cells.wbs_id ? wbsTaskId(projectId, row.cells.wbs_id) : null; const time = createDefaultTaskTime(start, durationMinutes / (hoursPerDay * 60)); time.scheduleFinish = finish; @@ -703,6 +771,21 @@ export function readXER(bytes: Uint8Array): XerOpenResult { const resourceCatalog = buildXerResourceCatalog(tables, availableCalendarIds); 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, diff --git a/src/services/xer/xerTables.ts b/src/services/xer/xerTables.ts index e5e68c5b9..f85dd80bd 100644 --- a/src/services/xer/xerTables.ts +++ b/src/services/xer/xerTables.ts @@ -27,6 +27,7 @@ export type XerImportErrorCode = | 'XER_SINGLE_PROJECT_REQUIRED' | 'XER_EMPTY_PROJECT' | 'XER_DUPLICATE_ID' + | 'XER_AMBIGUOUS_LOCAL_RELATION' | 'XER_DANGLING_LOCAL_RELATION'; export class XerImportError extends Error { diff --git a/src/state/slices/types.ts b/src/state/slices/types.ts index 322aa5d46..331e1e151 100644 --- a/src/state/slices/types.ts +++ b/src/state/slices/types.ts @@ -185,6 +185,7 @@ export type NotificationMessageKey = | 'notifications.xerSingleProjectRequired' | 'notifications.xerEmptyProject' | 'notifications.xerDuplicateId' + | 'notifications.xerAmbiguousLocalRelation' | 'notifications.xerDanglingLocalRelation' | 'notifications.xerEnumFallback' | 'notifications.mppSourceScheduleNotes' diff --git a/tests/planning/check-document-contract.ts b/tests/planning/check-document-contract.ts index 1bf02042f..d1b5900a2 100644 --- a/tests/planning/check-document-contract.ts +++ b/tests/planning/check-document-contract.ts @@ -292,8 +292,34 @@ 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' }] }], + // 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', + p6CompletePctType: 'CP_Phys', + p6ExpectedFinish: '2031-03-12', + p6SuspendResume: true, time: { ...kt1Before.time, resume: '2031-03-05', stop: '2031-03-06' }, }); +const k3X7 = (candidate: Task | undefined) => [ + candidate?.p6ProjectId, + candidate?.p6TaskId, + candidate?.p6CompletePctType, + candidate?.p6ExpectedFinish, + candidate?.p6SuspendResume, +]; +const k3X7Expected = ['P6-K3', 'P6-T1', '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]); +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 @@ -345,6 +371,7 @@ 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-ifc-roundtrip.ts b/tests/planning/check-ifc-roundtrip.ts index 04f90ad75..08942cfe8 100644 --- a/tests/planning/check-ifc-roundtrip.ts +++ b/tests/planning/check-ifc-roundtrip.ts @@ -247,8 +247,8 @@ 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 (XER-etappeplan, 2026-08-20) — drie nieuwe .xer-importvelden, nog GEEN IFC-pset (zie - // TASK_CANON hieronder: skip, reden X9). Alleen hier gevuld voor de `Required`-volledigheid. + // 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', p6CompletePctType: 'CP_Phys', p6ExpectedFinish: '2026-07-31T17:00', p6SuspendResume: true, @@ -544,17 +544,18 @@ const TASK_CANON = { timephasedContours: KEEP, // Z14b — MSP's eigen task-type/effort-driven-vlag (`OPS_MspTaskType`), puur data, geen verwijzing. mspTaskType: KEEP, effortDriven: KEEP, - // X0 (XER-etappeplan, 2026-08-20) — drie nieuwe .xer-importvelden: typecontract-only deze etappe - // (X0 raakt src/services/ifc/ NIET aan). Geen IFC-pset ⇒ geen echte round-trip-vergelijking - // mogelijk; de daadwerkelijke IFC-round-trip-wiring is XER-etappe se X9-taak (documentcontract/ - // round-trip). Zelfde taxonomie als `interferingFloat`/`isNearCritical`/`floatPath` hierboven. + // X0 (XER-etappeplan, 2026-08-20) — duration/activity blijven typecontract-only tot de bredere + // X9-archiefetappe. Zelfde taxonomie als `interferingFloat`/`isNearCritical`/`floatPath` hierboven. p6DurationType: { skip: 'nog geen IFC-pset — typecontract-only, X0 (XER-etappeplan); wiring volgt in X9' }, p6ActivityType: { skip: 'nog geen IFC-pset — typecontract-only, X0 (XER-etappeplan); wiring volgt in X9' }, - p6ProjectId: { skip: 'X7-bronidentiteit wacht op X9-IFC-wiring' }, - p6TaskId: { skip: 'X7-bronidentiteit wacht op X9-IFC-wiring' }, - p6CompletePctType: { skip: 'X7-brontype wacht op X9-IFC-wiring' }, - p6ExpectedFinish: { skip: 'X7-brondatum wacht op X9-IFC-wiring' }, - p6SuspendResume: { skip: 'nog geen IFC-pset — typecontract-only, X0 (XER-etappeplan); wiring volgt in X9' }, + // 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, + 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() }, diff --git a/tests/planning/check-xer-progress.ts b/tests/planning/check-xer-progress.ts index 9d5ad1449..44879b37b 100644 --- a/tests/planning/check-xer-progress.ts +++ b/tests/planning/check-xer-progress.ts @@ -1,6 +1,9 @@ import { solveProject } from '@/engine/scheduler/solveProject'; import { isMultiDocumentImport } from '@/services/importTypes'; +import { readIFC } from '@/services/ifc/ifcReader'; +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; @@ -59,6 +62,48 @@ eq('X7-1 complete_pct_type bewaart de onafhankelijke P6-voortgangsbron en identi ['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)!; @@ -111,6 +156,31 @@ function solvedResume(taskId: string): string | undefined { 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', diff --git a/tests/planning/check-xer-project-index.ts b/tests/planning/check-xer-project-index.ts index d82fce529..046106b3f 100644 --- a/tests/planning/check-xer-project-index.ts +++ b/tests/planning/check-xer-project-index.ts @@ -3,7 +3,7 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { isMultiDocumentImport } from '@/services/importTypes'; import { indexXerProjectRows, readXER } from '@/services/xer/xerReader'; -import type { XerRow, XerTables } from '@/services/xer/xerTables'; +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}`); @@ -69,6 +69,110 @@ if (blankRelationIndex.relationsByProject.get('P1')?.length !== 1 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 [ diff --git a/tests/planning/check-xer-registry.ts b/tests/planning/check-xer-registry.ts index 031d40b0d..d4c865609 100644 --- a/tests/planning/check-xer-registry.ts +++ b/tests/planning/check-xer-registry.ts @@ -115,6 +115,7 @@ const errorKeys: ReadonlyArray = [ ['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', diff --git a/tests/planning/check-xer-schedule-options-corpus.ts b/tests/planning/check-xer-schedule-options-corpus.ts index 1adf0f633..51a41e94d 100644 --- a/tests/planning/check-xer-schedule-options-corpus.ts +++ b/tests/planning/check-xer-schedule-options-corpus.ts @@ -261,6 +261,72 @@ function movementProjects( 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); @@ -521,6 +587,9 @@ function measureCorpus(root: string): BlastRadiusBaseline { 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) { @@ -539,6 +608,13 @@ function measureCorpus(root: string): BlastRadiusBaseline { 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], diff --git a/tests/planning/xer-schedoptions-blast-radius.json b/tests/planning/xer-schedoptions-blast-radius.json index 299e1e49d..f0ad55a47 100644 --- a/tests/planning/xer-schedoptions-blast-radius.json +++ b/tests/planning/xer-schedoptions-blast-radius.json @@ -886,9 +886,9 @@ 2618, 2618, 1785, - 504 + 505 ], - "xerDefaultsNegativeFloatTasks": 543, + "xerDefaultsNegativeFloatTasks": 544, "defaults": { "totalFloatFinish": { "movement": [ @@ -950,9 +950,9 @@ 2618, 2618, 1639, - 504 + 505 ], - "chosenNegativeFloatTasks": 543, + "chosenNegativeFloatTasks": 544, "counterfactualNegativeFloatTasks": 38 }, "clampNegativeFreeFloat": { @@ -4254,7 +4254,7 @@ "measurable": 7602 }, "ff": { - "deviations": 704, + "deviations": 705, "measurable": 7221 } }, From 0f540532536403d203ce97a4aa2d18555ad3d892 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:21:26 +0200 Subject: [PATCH 042/171] Verstevig X7-voortgang en P6-datuminvarianten --- src/engine/moveProject.ts | 2 + src/engine/scheduler/CPMSolver.ts | 3 +- src/services/ifc/ifcPsets.ts | 3 +- src/services/ifc/ifcReader.ts | 2 + src/services/xer/xerReader.ts | 36 +- src/state/mcpTransaction.ts | 3 + src/state/slices/taskSlice.ts | 2 + src/utils/p6SuspendResume.ts | 25 + tests/planning/check-document-contract.ts | 6 +- tests/planning/check-xer-progress-corpus.ts | 21 +- tests/planning/check-xer-progress-families.ts | 92 ++ .../check-xer-schedule-options-corpus.ts | 199 ++- tests/planning/check-xer-suspend-invariant.ts | 174 +++ tests/planning/check-xer-x7-hour-mode.ts | 85 ++ tests/planning/run.sh | 14 + .../xer-schedoptions-blast-radius.json | 1297 ++++++++++++++++- 16 files changed, 1922 insertions(+), 42 deletions(-) create mode 100644 src/utils/p6SuspendResume.ts create mode 100644 tests/planning/check-xer-progress-families.ts create mode 100644 tests/planning/check-xer-suspend-invariant.ts create mode 100644 tests/planning/check-xer-x7-hour-mode.ts diff --git a/src/engine/moveProject.ts b/src/engine/moveProject.ts index 1683868c2..b5ba32e9a 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. @@ -293,6 +294,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/scheduler/CPMSolver.ts b/src/engine/scheduler/CPMSolver.ts index 87392aa76..c40190d5d 100644 --- a/src/engine/scheduler/CPMSolver.ts +++ b/src/engine/scheduler/CPMSolver.ts @@ -12,6 +12,7 @@ import { signedElapsedSpan, isZeroDurationMilestone, splitTotalSpanMinutes, splitTotalSpanDays, } from './duration'; import { computeScheduleResults } from './scheduleAnalysis'; +import { hasValidP6SuspendResume } from '@/utils/p6SuspendResume'; import { forwardConstraint, forwardFinishFloor, backwardConstraint, MS_PER_MIN, MS_PER_DAY, type RelationDeps, } from './relationMath'; @@ -1638,7 +1639,7 @@ export class CPMSolver { // `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 ? task.p6SuspendResume === true : true; + const mayUseResume = task.p6ProjectId ? hasValidP6SuspendResume(task) : true; const resumeOverride = mayUseResume && t.resume && task.resourceIds.length <= 1 ? this.parseIn(progressCal, t.resume) : null; diff --git a/src/services/ifc/ifcPsets.ts b/src/services/ifc/ifcPsets.ts index 344c52725..44e581dfd 100644 --- a/src/services/ifc/ifcPsets.ts +++ b/src/services/ifc/ifcPsets.ts @@ -2,6 +2,7 @@ import type { Task, ConstraintType, TaskSplitGap, TaskTimephasedContour, TimephasedContourPeriod, MspTaskType, P6CompletePctType, } from '@/types/task'; +import { hasValidP6SuspendResume } from '@/utils/p6SuspendResume'; /** * IFC-pset-registry (fase 3, tweede helft van P11 uit docs/superpowers/modulariteit-audit.md, @@ -459,7 +460,7 @@ export const PER_TASK_PSETS: PerTaskPset[] = [ if (task.p6TaskId) props.push({ name: 'TaskId', value: `IFCTEXT(${ifcStr(task.p6TaskId)})` }); 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.p6SuspendResume === true) props.push({ name: 'SuspendResume', value: 'IFCBOOLEAN(.T.)' }); + if (hasValidP6SuspendResume(task)) props.push({ name: 'SuspendResume', value: 'IFCBOOLEAN(.T.)' }); return props.length > 0 ? props : null; }, apply(task, props) { diff --git a/src/services/ifc/ifcReader.ts b/src/services/ifc/ifcReader.ts index 5a5b071f6..91062a75f 100644 --- a/src/services/ifc/ifcReader.ts +++ b/src/services/ifc/ifcReader.ts @@ -23,6 +23,7 @@ import { type RecordedFieldKey, type TaskTimeReadHelpers, } from './ifcTaskSlots'; import { normalizeImportedProgress } from '@/services/importNormalize'; +import { reconcileP6SuspendResume } from '@/utils/p6SuspendResume'; import { canonicalizeBands, clockToMinutes, getCalendarBands, hasNonAnchorTime, isoDurationToMinutes, isSubDayMinutes, promoteHourCalendar, registerCalendarBands, @@ -140,6 +141,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); // 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 // pas NA extractCalendarLibrary hierboven (die tabel levert `calendarIdByGuid`). diff --git a/src/services/xer/xerReader.ts b/src/services/xer/xerReader.ts index ce3bf261e..f146df4fd 100644 --- a/src/services/xer/xerReader.ts +++ b/src/services/xer/xerReader.ts @@ -24,6 +24,7 @@ import type { } from '@/types/task'; import { createDefaultTaskTime } from '@/utils/taskDefaults'; import { formatInstant, parseInstant } from '@/utils/dateUtils'; +import { hasValidP6SuspendResume } from '@/utils/p6SuspendResume'; import { readXerCalendars } from './xerCalendarData'; import { indexXerTaskResourceRows } from './xerResourceAssignments'; import { @@ -468,6 +469,9 @@ function readXerProject( 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); } @@ -508,21 +512,23 @@ function readXerProject( const rawStatus = row.cells.status_code ?? ''; const status = statusOf(rawStatus, row, enumFallbacks); const rawCompletePctType = row.cells.complete_pct_type ?? ''; - const completePctType = completePctTypeOf(rawCompletePctType, row, enumFallbacks); - const completePercent = numberOf(tables, row, 'complete_pct'); - const physicalPercent = numberOf(tables, row, 'phys_complete_pct') ?? 0; + 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. - const completionPercent = rawCompletePctType.trim() === '' - ? physicalPercent - : completePctType === 'CP_Phys' - ? physicalPercent - : completePercent ?? 0; - const completion = status === 'COMPLETED' + const completionPercent = statusForcesCompletion + ? 0 + : completePctType === 'CP_Drtn' || completePctType === 'CP_Units' + ? numberOf(tables, row, 'complete_pct') ?? 0 + : numberOf(tables, row, 'phys_complete_pct') ?? 0; + const completion = statusToken === 'tk_complete' ? 1 - : rawStatus.trim() !== '' && status === 'NOT_STARTED' + : 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; @@ -533,7 +539,7 @@ function readXerProject( time.lateStart = start; time.lateFinish = finish; time.completion = completion; - const p6RemainingHours = completePctType === 'CP_Drtn' && completePercent !== null + const p6RemainingHours = status === 'STARTED' && completePctType === 'CP_Drtn' ? durationHours * (1 - completion) : remainingHours; if (hourMode) { @@ -550,7 +556,11 @@ function readXerProject( const resume = sourceInstant(row.cells.resume_date ?? '', hourMode); if (stop) time.stop = stop; if (resume) time.resume = resume; - const validSuspendResume = stop !== undefined && resume !== undefined && parseInstant(stop) <= parseInstant(resume); + const validSuspendResume = hasValidP6SuspendResume({ + p6ProjectId: projectId, + p6SuspendResume: true, + time, + }); const expectedFinish = sourceInstant(row.cells.expect_end_date ?? '', hourMode); const isStartMilestone = activityType === 'TT_Mile'; @@ -578,7 +588,7 @@ function readXerProject( p6ActivityType: activityType, p6ProjectId: projectId, p6TaskId: row.cells.task_id, - p6CompletePctType: completePctType, + ...(completePctType ? { p6CompletePctType: completePctType } : {}), ...(expectedFinish ? { p6ExpectedFinish: expectedFinish } : {}), ...(validSuspendResume ? { p6SuspendResume: true } : {}), ...(activityType === 'TT_LOE' ? { isHammock: true } : {}), diff --git a/src/state/mcpTransaction.ts b/src/state/mcpTransaction.ts index b330a01e0..74595b195 100644 --- a/src/state/mcpTransaction.ts +++ b/src/state/mcpTransaction.ts @@ -5,6 +5,7 @@ import { resetUndoCoalescing, setMcpTransactionActive } from './transaction'; import { relationVerdict } from './relationRules'; import { generateId } from '@/utils/id'; import { formatDate } from '@/utils/dateUtils'; +import { reconcileP6SuspendResume } from '@/utils/p6SuspendResume'; import { createDefaultTaskTime, mergeTaskTime, clearTimephasedWindow, timeUpdateTouchesTimephasedWindow, clearTimephasedDurationWalks, timephasedDurationWalksHaveFrozenWork, @@ -470,6 +471,7 @@ export const draft = { const { time, ...rest } = updates; Object.assign(s.tasks[idx], rest); if (time) s.tasks[idx].time = mergeTaskTime(s.tasks[idx].time, time); + 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)) { @@ -513,6 +515,7 @@ export const draft = { if (timePatch.durationType !== undefined) { task.time.durationType = timePatch.durationType; timeTouched = true; } if (timePatch.clearDurationMinutes) { delete task.time.durationMinutes; timeTouched = true; } } + 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. diff --git a/src/state/slices/taskSlice.ts b/src/state/slices/taskSlice.ts index aefefbc29..e4ab9d436 100644 --- a/src/state/slices/taskSlice.ts +++ b/src/state/slices/taskSlice.ts @@ -5,6 +5,7 @@ import { } from '@/utils/taskDefaults'; import { generateId } from '@/utils/id'; import { formatDate, parseDate, parseInstant } from '@/utils/dateUtils'; +import { reconcileP6SuspendResume } from '@/utils/p6SuspendResume'; import { deriveWbsCodes, applyWbsNumbering, flattenOrder } from '@/utils/wbs'; import type { WbsTemplate } from '@/utils/wbsTemplates'; import { detachFromParent, attachToParent, isSelfOrDescendant, collectSubtreeIds, siblingIds } from '@/state/taskTree'; @@ -408,6 +409,7 @@ export const createTaskSlice: AppSlice = (set, get) => ({ const { time, ...rest } = updates; Object.assign(s.tasks[idx], rest); if (time) s.tasks[idx].time = mergeTaskTime(s.tasks[idx].time, time); + 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/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/tests/planning/check-document-contract.ts b/tests/planning/check-document-contract.ts index d1b5900a2..b0e75f05e 100644 --- a/tests/planning/check-document-contract.ts +++ b/tests/planning/check-document-contract.ts @@ -300,7 +300,7 @@ S().updateTask(kT1, { p6CompletePctType: 'CP_Phys', p6ExpectedFinish: '2031-03-12', p6SuspendResume: true, - time: { ...kt1Before.time, resume: '2031-03-05', stop: '2031-03-06' }, + time: { ...kt1Before.time, stop: '2031-03-05', resume: '2031-03-06' }, }); const k3X7 = (candidate: Task | undefined) => [ candidate?.p6ProjectId, @@ -363,8 +363,8 @@ 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'); diff --git a/tests/planning/check-xer-progress-corpus.ts b/tests/planning/check-xer-progress-corpus.ts index 21c470198..75a28e974 100644 --- a/tests/planning/check-xer-progress-corpus.ts +++ b/tests/planning/check-xer-progress-corpus.ts @@ -41,12 +41,23 @@ if (!root) { throw new Error('OPS_XER_CORPUS bestaat niet'); } else { const tasks = files(root).flatMap(rawTasks); - const counts = { suspend: 0, resume: 0, validPairs: 0, stopOnly: 0, reversed: 0, CP_Drtn: 0, CP_Phys: 0, CP_Units: 0 }; + 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; - if (stop) counts.suspend++; - if (resume) counts.resume++; + 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++; @@ -59,7 +70,9 @@ if (!root) { // 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, validPairs: 20, stopOnly: 2, reversed: 0, + 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, }); 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-schedule-options-corpus.ts b/tests/planning/check-xer-schedule-options-corpus.ts index 51a41e94d..f11733858 100644 --- a/tests/planning/check-xer-schedule-options-corpus.ts +++ b/tests/planning/check-xer-schedule-options-corpus.ts @@ -37,6 +37,7 @@ type DefaultKey = | 'retainedLogic' | 'openEndedNotCritical' | 'predecessorLagCalendar' + | 'expectedFinishDates' | 'preserveActualDates' | 'clampNegativeFreeFloat' | 'projectCriticalDefinition'; @@ -46,16 +47,13 @@ const DEFAULT_KEYS: readonly DefaultKey[] = [ 'retainedLogic', 'openEndedNotCritical', 'predecessorLagCalendar', + 'expectedFinishDates', 'preserveActualDates', 'clampNegativeFreeFloat', 'projectCriticalDefinition', ]; -const DEFERRED_DEFAULTS = [{ - key: 'expectedFinishDates', - owner: 'X7', - reason: 'Het X7-taakveld expectedFinish ontbreekt nog; X5 bewaart alleen het bronbeleid.', -}] as const; +const DEFERRED_DEFAULTS = [] as const; interface SolverVariant { progressMode?: ProgressMode; @@ -81,8 +79,50 @@ interface MeasuredFile { 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; +} + interface BlastRadiusBaseline { - version: 7; + version: 8; axes: readonly BlastAxis[]; defaults: readonly DefaultKey[]; deferredDefaults: typeof DEFERRED_DEFAULTS; @@ -129,6 +169,9 @@ interface BlastRadiusBaseline { }; corpusUnion: string[]; files: MeasuredFile[]; + expectedFinishVariant: ExpectedFinishVariantBaseline; + /** Exact causaal delta-orakel voor het X7-contract "ontbrekend type blijft afwezig". */ + legacyMissingTypeDelta?: BaselineValueDelta[]; fidelity: { house: XerFidelityCounters; xerDefaults: XerFidelityCounters; @@ -158,6 +201,21 @@ function eq(label: string, got: unknown, want: unknown): void { if (JSON.stringify(got) !== JSON.stringify(want)) diffs.push(label); } +function baselineValueDeltas(before: unknown, after: unknown, path = ''): BaselineValueDelta[] { + if (JSON.stringify(before) === JSON.stringify(after)) return []; + if (Array.isArray(before) && Array.isArray(after)) { + if (before.length !== after.length) return [{ path: `${path}.length`, before: before.length, after: after.length }]; + return before.flatMap((value, index) => baselineValueDeltas(value, after[index], `${path}[${index}]`)); + } + if (before && after && typeof before === 'object' && typeof after === 'object') { + const beforeObject = before as Record; + const afterObject = after as Record; + return [...new Set([...Object.keys(beforeObject), ...Object.keys(afterObject)])].flatMap(key => + baselineValueDeltas(beforeObject[key], afterObject[key], path ? `${path}.${key}` : key)); + } + return [{ path, before, after }]; +} + function listXerFilesRecursive(dir: string): string[] { const files: string[] = []; for (const entry of readdirSync(dir, { withFileTypes: true }) @@ -392,6 +450,10 @@ function variantDefinitions( 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 } }, @@ -488,6 +550,100 @@ function measureCorpus(root: string): BlastRadiusBaseline { }])) 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; @@ -637,7 +793,7 @@ function measureCorpus(root: string): BlastRadiusBaseline { } return { - version: 7, + version: 8, axes: BLAST_AXES, defaults: DEFAULT_KEYS, deferredDefaults: DEFERRED_DEFAULTS, @@ -688,6 +844,7 @@ function measureCorpus(root: string): BlastRadiusBaseline { }, corpusUnion: union, files, + expectedFinishVariant, fidelity, }; } @@ -697,11 +854,11 @@ if (!existsSync(baselinePath)) { } else { const committed = JSON.parse(readFileSync(baselinePath, 'utf8')) as BlastRadiusBaseline; eq('baselineversie en asvolgorde', { version: committed.version, axes: committed.axes }, { - version: 7, + version: 8, axes: BLAST_AXES, }); eq('baseline bevat alle defaults los van elkaar', committed.defaults, DEFAULT_KEYS); - eq('expectedFinishDates staat eerlijk als X7-uitstel geregistreerd', + eq('geen geïmplementeerde XER-default staat nog als uitstel geregistreerd', committed.deferredDefaults, DEFERRED_DEFAULTS); eq('baseline pint exact de 37 functioneel SCHEDOPTIONS-loze bestanden', committed.files.length, 37); eq('baseline bevat alleen hash-identiteiten', @@ -714,6 +871,12 @@ if (!existsSync(baselinePath)) { 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('ontbrekend-typepad heeft een exact before/after-delta-orakel', + Array.isArray(committed.legacyMissingTypeDelta) && committed.legacyMissingTypeDelta.length > 0, true); } const root = process.env.OPS_XER_CORPUS; @@ -776,7 +939,23 @@ if (!root) { console.log(JSON.stringify(measured)); } else if (existsSync(baselinePath)) { const committed = JSON.parse(readFileSync(baselinePath, 'utf8')) as BlastRadiusBaseline; - eq('per-bestand/per-as-defaultbeweging en XER-fidelity blijven exact gepind', measured, committed); + eq('expectedFinishDates zelfstandige per-bestand/as/populatie en richting blijven exact gepind', + measured.expectedFinishVariant, committed.expectedFinishVariant); + const { + expectedFinishVariant: _measuredExpected, + legacyMissingTypeDelta: _measuredLegacyDelta, + ...measuredRest + } = measured; + const { + expectedFinishVariant: _committedExpected, + legacyMissingTypeDelta: committedLegacyDelta, + ...committedRest + } = committed; + void _measuredExpected; + void _measuredLegacyDelta; + void _committedExpected; + eq('ontbrekend-typepad beweegt exact de gepinde bestaande baselinecellen', + baselineValueDeltas(committedRest, measuredRest), committedLegacyDelta); } } diff --git a/tests/planning/check-xer-suspend-invariant.ts b/tests/planning/check-xer-suspend-invariant.ts new file mode 100644 index 000000000..f7c9de3f4 --- /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 { readIFC } from '@/services/ifc/ifcReader'; +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 }), +], '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-x7-hour-mode.ts b/tests/planning/check-xer-x7-hour-mode.ts new file mode 100644 index 000000000..942cf6f2e --- /dev/null +++ b/tests/planning/check-xer-x7-hour-mode.ts @@ -0,0 +1,85 @@ +import { isMultiDocumentImport } from '@/services/importTypes'; +import { readIFC } from '@/services/ifc/ifcReader'; +import { writeIFC } from '@/services/ifc/ifcWriter'; +import { readXER } from '@/services/xer/xerReader'; + +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\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', + // 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', + '%E', +]; +const opened = readXER(new TextEncoder().encode(xer.join('\n'))); +if (isMultiDocumentImport(opened)) throw new Error('uurmodusfixture gaf meerdere documenten'); +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, +}); + +const dayOnly = readXER(new TextEncoder().encode(xer.join('\n') + .replace('2026-08-04 15:30', '2026-08-04') + .replace('2026-08-06 09:45', '2026-08-06') + .replace('2026-08-12 13:15', '2026-08-12'))); +if (isMultiDocumentImport(dayOnly)) throw new Error('dagmodusfixture gaf meerdere documenten'); +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, +}); + +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/run.sh b/tests/planning/run.sh index deb9dfc0f..c745ddb7f 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -239,6 +239,20 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then 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 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 diff --git a/tests/planning/xer-schedoptions-blast-radius.json b/tests/planning/xer-schedoptions-blast-radius.json index f0ad55a47..d523ce25e 100644 --- a/tests/planning/xer-schedoptions-blast-radius.json +++ b/tests/planning/xer-schedoptions-blast-radius.json @@ -1,5 +1,5 @@ { - "version": 7, + "version": 8, "axes": [ "es", "ef", @@ -14,17 +14,12 @@ "retainedLogic", "openEndedNotCritical", "predecessorLagCalendar", + "expectedFinishDates", "preserveActualDates", "clampNegativeFreeFloat", "projectCriticalDefinition" ], - "deferredDefaults": [ - { - "key": "expectedFinishDates", - "owner": "X7", - "reason": "Het X7-taakveld expectedFinish ontbreekt nog; X5 bewaart alleen het bronbeleid." - } - ], + "deferredDefaults": [], "population": { "scanned": 93, "oracleAxisFiles": 60, @@ -165,6 +160,19 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, "preserveActualDates": { "movement": [ 0, @@ -282,6 +290,19 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, "preserveActualDates": { "movement": [ 0, @@ -392,6 +413,19 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, "preserveActualDates": { "movement": [ 0, @@ -502,6 +536,19 @@ "chosenNegativeFloatTasks": 4, "counterfactualNegativeFloatTasks": 4 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 4, + "counterfactualNegativeFloatTasks": 4 + }, "preserveActualDates": { "movement": [ 0, @@ -612,6 +659,19 @@ "chosenNegativeFloatTasks": 2, "counterfactualNegativeFloatTasks": 2 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 2, + "counterfactualNegativeFloatTasks": 2 + }, "preserveActualDates": { "movement": [ 0, @@ -722,6 +782,19 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, "preserveActualDates": { "movement": [ 0, @@ -832,6 +905,19 @@ "chosenNegativeFloatTasks": 44, "counterfactualNegativeFloatTasks": 43 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 44, + "counterfactualNegativeFloatTasks": 44 + }, "preserveActualDates": { "movement": [ 0, @@ -942,6 +1028,19 @@ "chosenNegativeFloatTasks": 38, "counterfactualNegativeFloatTasks": 38 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 11, + "counterfactualNegativeFloatTasks": 11 + }, "preserveActualDates": { "movement": [ 0, @@ -1052,6 +1151,19 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, "preserveActualDates": { "movement": [ 0, @@ -1169,6 +1281,19 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, "preserveActualDates": { "movement": [ 0, @@ -1279,6 +1404,19 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, "preserveActualDates": { "movement": [ 0, @@ -1389,6 +1527,19 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, "preserveActualDates": { "movement": [ 0, @@ -1499,6 +1650,19 @@ "chosenNegativeFloatTasks": 1, "counterfactualNegativeFloatTasks": 1 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, "preserveActualDates": { "movement": [ 0, @@ -1609,6 +1773,19 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, "preserveActualDates": { "movement": [ 0, @@ -1719,6 +1896,19 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, "preserveActualDates": { "movement": [ 0, @@ -1829,6 +2019,19 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, "preserveActualDates": { "movement": [ 0, @@ -1939,6 +2142,19 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, "preserveActualDates": { "movement": [ 0, @@ -2049,6 +2265,19 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, "preserveActualDates": { "movement": [ 0, @@ -2159,6 +2388,19 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, "preserveActualDates": { "movement": [ 0, @@ -2269,6 +2511,19 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, "preserveActualDates": { "movement": [ 0, @@ -2379,6 +2634,19 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, "preserveActualDates": { "movement": [ 0, @@ -2489,7 +2757,7 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, - "preserveActualDates": { + "expectedFinishDates": { "movement": [ 0, 0, @@ -2502,7 +2770,20 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, - "clampNegativeFreeFloat": { + "preserveActualDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, + "clampNegativeFreeFloat": { "movement": [ 0, 0, @@ -2599,6 +2880,19 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, "preserveActualDates": { "movement": [ 0, @@ -2709,6 +3003,19 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, "preserveActualDates": { "movement": [ 0, @@ -2819,6 +3126,19 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, "preserveActualDates": { "movement": [ 0, @@ -2929,6 +3249,19 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, "preserveActualDates": { "movement": [ 0, @@ -3039,6 +3372,19 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, "preserveActualDates": { "movement": [ 0, @@ -3149,6 +3495,19 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, "preserveActualDates": { "movement": [ 0, @@ -3259,6 +3618,19 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, "preserveActualDates": { "movement": [ 0, @@ -3369,6 +3741,19 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, "preserveActualDates": { "movement": [ 0, @@ -3479,6 +3864,19 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, "preserveActualDates": { "movement": [ 0, @@ -3589,6 +3987,19 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, "preserveActualDates": { "movement": [ 0, @@ -3699,6 +4110,19 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, "preserveActualDates": { "movement": [ 0, @@ -3809,6 +4233,19 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, "preserveActualDates": { "movement": [ 0, @@ -3919,6 +4356,19 @@ "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 }, + "expectedFinishDates": { + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "chosenNegativeFloatTasks": 0, + "counterfactualNegativeFloatTasks": 0 + }, "preserveActualDates": { "movement": [ 0, @@ -3961,6 +4411,779 @@ } } ], + "expectedFinishVariant": { + "selection": { + "chosen": true, + "counterfactual": false + }, + "population": { + "oracleFiles": 60, + "readableFiles": 58, + "deferredFiles": 2, + "projects": 84, + "tasks": 18778, + "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": 7665, + "measurable": 18540 + }, + "ef": { + "deviations": 7718, + "measurable": 18552 + }, + "ls": { + "deviations": 8408, + "measurable": 18430 + }, + "lf": { + "deviations": 8628, + "measurable": 18423 + }, + "tf": { + "deviations": 10372, + "measurable": 18296 + }, + "ff": { + "deviations": 3636, + "measurable": 17915 + } + }, + "counterfactual": { + "es": { + "deviations": 7665, + "measurable": 18540 + }, + "ef": { + "deviations": 7718, + "measurable": 18552 + }, + "ls": { + "deviations": 8408, + "measurable": 18430 + }, + "lf": { + "deviations": 8628, + "measurable": 18423 + }, + "tf": { + "deviations": 10372, + "measurable": 18296 + }, + "ff": { + "deviations": 3636, + "measurable": 17915 + } + } + } + }, + "legacyMissingTypeDelta": [ + { + "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 + } + ], "fidelity": { "house": { "es": { @@ -4231,6 +5454,60 @@ } } }, + "expectedFinishDates": { + "chosen": { + "es": { + "deviations": 5156, + "measurable": 7649 + }, + "ef": { + "deviations": 5144, + "measurable": 7661 + }, + "ls": { + "deviations": 7505, + "measurable": 7539 + }, + "lf": { + "deviations": 7482, + "measurable": 7532 + }, + "tf": { + "deviations": 7195, + "measurable": 7602 + }, + "ff": { + "deviations": 2428, + "measurable": 7221 + } + }, + "counterfactual": { + "es": { + "deviations": 5156, + "measurable": 7649 + }, + "ef": { + "deviations": 5144, + "measurable": 7661 + }, + "ls": { + "deviations": 7505, + "measurable": 7539 + }, + "lf": { + "deviations": 7482, + "measurable": 7532 + }, + "tf": { + "deviations": 7195, + "measurable": 7602 + }, + "ff": { + "deviations": 2428, + "measurable": 7221 + } + } + }, "preserveActualDates": { "chosen": { "es": { From 1c60b9e4df6e71ca323016d97818d353043ff250 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:06:42 +0200 Subject: [PATCH 043/171] fix: verschuif P6 Expected Finish met project --- src/engine/moveProject.ts | 3 ++ tests/planning/check-move-project.ts | 50 ++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/engine/moveProject.ts b/src/engine/moveProject.ts index b5ba32e9a..e36791fd0 100644 --- a/src/engine/moveProject.ts +++ b/src/engine/moveProject.ts @@ -271,6 +271,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 diff --git a/tests/planning/check-move-project.ts b/tests/planning/check-move-project.ts index 9bb8a764c..de4c21a6d 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' }); From 80d58e06b23fa8471ab9758a02f4ca6fe7f55155 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:17:05 +0200 Subject: [PATCH 044/171] fix: behoud X7-datumprecisie per bronveld --- src/engine/scheduler/CPMSolver.ts | 8 +- src/services/xer/xerReader.ts | 11 ++- tests/planning/check-xer-x7-hour-mode.ts | 105 ++++++++++++++++++++--- 3 files changed, 110 insertions(+), 14 deletions(-) diff --git a/src/engine/scheduler/CPMSolver.ts b/src/engine/scheduler/CPMSolver.ts index c40190d5d..ca5d44322 100644 --- a/src/engine/scheduler/CPMSolver.ts +++ b/src/engine/scheduler/CPMSolver.ts @@ -1885,7 +1885,13 @@ export class CPMSolver { // P6-vlag volledig op de gewone restduurroute. if (task.p6ProjectId && this.options.schedulingOptions?.useExpectedFinishDates === true && task.p6ExpectedFinish) { - const expected = this.parseIn(progressCal, task.p6ExpectedFinish); + const parsedExpected = this.parseIn(progressCal, task.p6ExpectedFinish); + // X7: date-only Expected Finish heeft dagprecisie, ook wanneer een ANDER X7-veld de + // taakkalender naar uurmodus promoveerde. Als finish-anker landt die datum daarom op + // het laatste effectieve band-einde van DEZELFDE dag; een echte timestamp blijft exact. + const expected = progressCal.isHourMode && !task.p6ExpectedFinish.includes('T') + ? this.dayLastBandEnd(progressCal, parsedExpected) ?? parsedExpected + : parsedExpected; if (!isNaN(expected.getTime())) ef = expected; } // Z8-HERWERKRONDE (LAAG 2 van de gelaagde beslistabel): een IN-PROGRESS-taak plant op haar diff --git a/src/services/xer/xerReader.ts b/src/services/xer/xerReader.ts index f146df4fd..69d2daeb8 100644 --- a/src/services/xer/xerReader.ts +++ b/src/services/xer/xerReader.ts @@ -212,6 +212,11 @@ function sourceInstant(raw: string, hourMode: boolean): string | undefined { return formatInstant(parsed, hourMode ? 'hour' : 'day'); } +/** 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, @@ -552,8 +557,8 @@ function readXerProject( const actualFinish = sourceInstant(row.cells.act_end_date ?? '', hourMode); if (actualStart) time.actualStart = actualStart; if (actualFinish) time.actualFinish = actualFinish; - const stop = sourceInstant(row.cells.suspend_date ?? '', hourMode); - const resume = sourceInstant(row.cells.resume_date ?? '', hourMode); + 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({ @@ -561,7 +566,7 @@ function readXerProject( p6SuspendResume: true, time, }); - const expectedFinish = sourceInstant(row.cells.expect_end_date ?? '', hourMode); + const expectedFinish = sourceX7Instant(row.cells.expect_end_date ?? ''); const isStartMilestone = activityType === 'TT_Mile'; const isFinishMilestone = activityType === 'TT_FinMile'; diff --git a/tests/planning/check-xer-x7-hour-mode.ts b/tests/planning/check-xer-x7-hour-mode.ts index 942cf6f2e..1c2dea4b9 100644 --- a/tests/planning/check-xer-x7-hour-mode.ts +++ b/tests/planning/check-xer-x7-hour-mode.ts @@ -1,7 +1,9 @@ +import { solveProject } from '@/engine/scheduler/solveProject'; import { isMultiDocumentImport } from '@/services/importTypes'; import { readIFC } from '@/services/ifc/ifcReader'; import { writeIFC } from '@/services/ifc/ifcWriter'; import { readXER } from '@/services/xer/xerReader'; +import type { ImportResult } from '@/services/importTypes'; const diffs: string[] = []; let checks = 0; @@ -12,7 +14,7 @@ const eq = (label: string, got: unknown, want: unknown): void => { } }; -const xer = [ +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', @@ -22,12 +24,33 @@ const xer = [ '%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', - '%E', -]; -const opened = readXER(new TextEncoder().encode(xer.join('\n'))); -if (isMultiDocumentImport(opened)) throw new Error('uurmodusfixture gaf meerdere documenten'); +]); const task = opened.tasks[0]; eq('reader bewaart uitsluitend X7-kloktijden minuut-exact', { stop: task?.time.stop, @@ -56,12 +79,13 @@ eq('IFC round-trip bewaart de drie X7-kloktijden minuut-exact', { 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'); -const dayOnly = readXER(new TextEncoder().encode(xer.join('\n') - .replace('2026-08-04 15:30', '2026-08-04') - .replace('2026-08-06 09:45', '2026-08-06') - .replace('2026-08-12 13:15', '2026-08-12'))); -if (isMultiDocumentImport(dayOnly)) throw new Error('dagmodusfixture gaf meerdere documenten'); +// 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, @@ -76,6 +100,67 @@ eq('dezelfde drie date-only X7-velden promoveren een dagmodusbestand niet', { 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'], + ]); if (diffs.length > 0) { console.error(`XER-X7-uurmodus: ${diffs.length}/${checks} checks rood`); From 0872a067d31b94647ccaa8e2013f2d8758d59638 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:53:23 +0200 Subject: [PATCH 045/171] fix: begrens X7 Expected Finish op werkband --- src/engine/scheduler/CPMSolver.ts | 14 ++++-- src/engine/scheduler/CalendarEngine.ts | 18 +++++-- tests/planning/check-xer-x7-hour-mode.ts | 63 ++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 9 deletions(-) diff --git a/src/engine/scheduler/CPMSolver.ts b/src/engine/scheduler/CPMSolver.ts index ca5d44322..4dacaf62b 100644 --- a/src/engine/scheduler/CPMSolver.ts +++ b/src/engine/scheduler/CPMSolver.ts @@ -1886,13 +1886,17 @@ export class CPMSolver { if (task.p6ProjectId && this.options.schedulingOptions?.useExpectedFinishDates === true && task.p6ExpectedFinish) { const parsedExpected = this.parseIn(progressCal, task.p6ExpectedFinish); - // X7: date-only Expected Finish heeft dagprecisie, ook wanneer een ANDER X7-veld de - // taakkalender naar uurmodus promoveerde. Als finish-anker landt die datum daarom op - // het laatste effectieve band-einde van DEZELFDE dag; een echte timestamp blijft exact. + // 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') - ? this.dayLastBandEnd(progressCal, parsedExpected) ?? parsedExpected + ? progressCal.prevWorkInstantOrNull(new Date( + this.startOfDay(parsedExpected).getTime() + MS_PER_DAY, + )) : parsedExpected; - if (!isNaN(expected.getTime())) ef = expected; + 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 diff --git a/src/engine/scheduler/CalendarEngine.ts b/src/engine/scheduler/CalendarEngine.ts index 3125264a9..a00769ec3 100644 --- a/src/engine/scheduler/CalendarEngine.ts +++ b/src/engine/scheduler/CalendarEngine.ts @@ -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,12 +684,22 @@ 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) ─────────────────────────────────────────────────── diff --git a/tests/planning/check-xer-x7-hour-mode.ts b/tests/planning/check-xer-x7-hour-mode.ts index 1c2dea4b9..c4c4e9679 100644 --- a/tests/planning/check-xer-x7-hour-mode.ts +++ b/tests/planning/check-xer-x7-hour-mode.ts @@ -162,6 +162,69 @@ eq('gemengde solver gebruikt date-only Expected Finish als einde van die werkdag ['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}`); From d69e765a35928a242eb75c5e94d4dc10fb7539eb Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:27:29 +0200 Subject: [PATCH 046/171] fix(ui): maak documenttabs toegankelijk --- .../layout/DocumentChrome/DocumentChrome.css | 19 +++- .../layout/DocumentChrome/DocumentTabBar.tsx | 87 ++++++++++++++----- .../DocumentChrome/documentTabNavigation.ts | 33 +++++++ .../planning/check-document-tab-navigation.ts | 57 ++++++++++++ tests/planning/run.sh | 6 ++ 5 files changed, 178 insertions(+), 24 deletions(-) create mode 100644 src/components/layout/DocumentChrome/documentTabNavigation.ts create mode 100644 tests/planning/check-document-tab-navigation.ts 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 1258cc149..bfc69a098 100644 --- a/src/components/layout/DocumentChrome/DocumentTabBar.tsx +++ b/src/components/layout/DocumentChrome/DocumentTabBar.tsx @@ -1,6 +1,8 @@ +import { useCallback, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Menu, Plus, X } from 'lucide-react'; import { useDocumentCards, useDocumentActions } from './useDocumentCards'; +import { documentTabKeyDestination, revealDocumentTab } from './documentTabNavigation'; import './DocumentChrome.css'; /** A · Documenttabs — horizontale tabstrip onder het lint. */ @@ -8,6 +10,28 @@ export function DocumentTabBar() { const { t } = useTranslation('common'); const cards = useDocumentCards(); const { switchTo, closeWithGuard, openProject, openOverview } = useDocumentActions(); + const tabRefs = useRef(new Map()); + const activeId = cards.find(card => card.isActive)?.id; + + // 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) => { + requestAnimationFrame(() => tabRefs.current.get(id)?.focus()); + }, []); + + const onTabKeyDown = useCallback((event: React.KeyboardEvent, cardId: string) => { + if (event.target !== event.currentTarget) return; + const nextId = documentTabKeyDestination(cards.map(card => card.id), cardId, event.key); + if (!nextId) return; + event.preventDefault(); + switchTo(nextId); + focusTab(nextId); + }, [cards, focusTab, switchTo]); return (
@@ -19,27 +43,50 @@ export function DocumentTabBar() { - {cards.map((card) => ( -
switchTo(card.id)} - data-ops-tab={card.id} - > - - {card.title} - {card.isDirty && } - +
+
+ {cards.map((card, index) => ( +
+ + +
+ ))}
- ))} +
+
diff --git a/src/components/layout/DocumentChrome/DocumentTabBar.tsx b/src/components/layout/DocumentChrome/DocumentTabBar.tsx index bfc69a098..ec40d49c9 100644 --- a/src/components/layout/DocumentChrome/DocumentTabBar.tsx +++ b/src/components/layout/DocumentChrome/DocumentTabBar.tsx @@ -1,17 +1,35 @@ -import { useCallback, useEffect, useRef } from 'react'; +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 { useDocumentCards, useDocumentActions } from './useDocumentCards'; -import { documentTabKeyDestination, revealDocumentTab } from './documentTabNavigation'; +import { DocumentTabControl } from './DocumentTabControl'; +import { + documentTabCloseFocusTarget, + documentTabKeyDestination, + revealDocumentTab, +} from './documentTabNavigation'; 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, openProject, openOverview } = useDocumentActions(); 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 = documentTabCloseFocusTarget(previousDocumentIds.current, cards, requestedDocumentId); + previousDocumentIds.current = currentDocumentIds; + if (focusTarget) tabRefs.current.get(focusTarget)?.focus({ preventScroll: true }); + }, [cards]); // Iedere route kan een document activeren; alleen hier kennen we de horizontale viewport. useEffect(() => { @@ -26,12 +44,12 @@ export function DocumentTabBar() { const onTabKeyDown = useCallback((event: React.KeyboardEvent, cardId: string) => { if (event.target !== event.currentTarget) return; - const nextId = documentTabKeyDestination(cards.map(card => card.id), cardId, event.key); + const nextId = documentTabKeyDestination(cards.map(card => card.id), cardId, event.key, direction); if (!nextId) return; event.preventDefault(); switchTo(nextId); focusTab(nextId); - }, [cards, focusTab, switchTo]); + }, [cards, direction, focusTab, switchTo]); return (
@@ -46,44 +64,19 @@ export function DocumentTabBar() {
{cards.map((card, index) => ( -
- - -
+ card={card} + index={index} + closeLabel={t('close')} + tabRef={(element) => { + if (element) tabRefs.current.set(card.id, element); + else tabRefs.current.delete(card.id); + }} + onSelect={(event) => { switchTo(card.id); event.currentTarget.focus(); }} + onKeyDown={(event) => onTabKeyDown(event, card.id)} + onClose={(event) => { event.stopPropagation(); closeWithGuard(card); }} + /> ))}
diff --git a/src/components/layout/DocumentChrome/DocumentTabControl.tsx b/src/components/layout/DocumentChrome/DocumentTabControl.tsx new file mode 100644 index 000000000..257e012b7 --- /dev/null +++ b/src/components/layout/DocumentChrome/DocumentTabControl.tsx @@ -0,0 +1,63 @@ +import type { KeyboardEvent, MouseEvent, Ref } from 'react'; +import type { DocumentCard } from './useDocumentCards'; +import { DOCUMENT_TABPANEL_ID, documentTabId } from './documentTabNavigation'; + +interface DocumentTabControlProps { + card: DocumentCard; + index: number; + closeLabel: string; + tabRef: Ref; + onSelect: (event: MouseEvent) => void; + onKeyDown: (event: KeyboardEvent) => void; + onClose: (event: MouseEvent) => void; +} + +/** + * Eén semantische tabcontrol met een zuster-sluitknop. De sluitknop is bewust + * geen descendant van `role=tab`: zijn naam mag de toegankelijke tabnaam niet + * vervuilen en hij blijft een zelfstandig bedienbaar element. + */ +export function DocumentTabControl({ + card, + index, + closeLabel, + tabRef, + onSelect, + onKeyDown, + onClose, +}: DocumentTabControlProps) { + return ( +
+ + +
+ ); +} diff --git a/src/components/layout/DocumentChrome/documentTabNavigation.ts b/src/components/layout/DocumentChrome/documentTabNavigation.ts index d8665db6b..37c7d7ea2 100644 --- a/src/components/layout/DocumentChrome/documentTabNavigation.ts +++ b/src/components/layout/DocumentChrome/documentTabNavigation.ts @@ -1,5 +1,14 @@ /** Toetsen die de actieve documenttab in de zichtbare tabstrip verplaatsen. */ export type DocumentTabNavigationKey = 'ArrowLeft' | 'ArrowRight' | 'Home' | 'End'; +export type DocumentTabDirection = 'ltr' | 'rtl'; + +/** Eén stabiele relatie tussen de documenttablist en de actieve documentwerkruimte. */ +export const DOCUMENT_TABPANEL_ID = 'ops-document-tabpanel'; + +/** Stabiele DOM-id voor een documenttab; document-id's zijn uniek binnen de sessie. */ +export function documentTabId(documentId: string): string { + return `ops-document-tab-${documentId}`; +} /** * Bepaal de volgende documenttab zonder een verborgen limiet van negen documenten. @@ -9,19 +18,48 @@ export function documentTabKeyDestination( documentIds: readonly string[], activeDocumentId: string, key: string, + direction: DocumentTabDirection, ): string | null { const current = documentIds.indexOf(activeDocumentId); if (current < 0 || documentIds.length === 0) return null; switch (key as DocumentTabNavigationKey) { - case 'ArrowLeft': return documentIds[(current - 1 + documentIds.length) % documentIds.length] ?? null; - case 'ArrowRight': return documentIds[(current + 1) % documentIds.length] ?? null; + case 'ArrowLeft': { + const delta = direction === 'rtl' ? 1 : -1; + return documentIds[(current + delta + documentIds.length) % documentIds.length] ?? null; + } + case 'ArrowRight': { + const delta = direction === 'rtl' ? -1 : 1; + return documentIds[(current + delta + documentIds.length) % documentIds.length] ?? null; + } case 'Home': return documentIds[0] ?? null; case 'End': return documentIds[documentIds.length - 1] ?? null; default: return null; } } +export interface DocumentTabFocusState { + id: string; + isActive: boolean; +} + +/** + * Bepaal het close-focusdoel uit twee gecommitteerde kaartenlijsten. + * + * Een bestaande document-id in de nieuwe lijst betekent dat de closebevestiging is geannuleerd + * of dat de sluitactie nog niet effectief is; in beide gevallen mag focus niet verschuiven. Alleen + * het verdwijnen van het aangevraagde document levert het nieuwe actieve document op. + */ +export function documentTabCloseFocusTarget( + previousDocumentIds: readonly string[], + currentDocuments: readonly DocumentTabFocusState[], + requestedDocumentId: string | null, +): string | null { + if (!requestedDocumentId || !previousDocumentIds.includes(requestedDocumentId)) return null; + if (currentDocuments.some(document => document.id === requestedDocumentId)) return null; + return currentDocuments.find(document => document.isActive)?.id ?? null; +} + /** Klein DOM-contract zodat de navigatie ook zonder DOM-testframework gericht toetsbaar blijft. */ export interface RevealableDocumentTab { scrollIntoView(options?: ScrollIntoViewOptions): void; diff --git a/tests/planning/check-document-tab-navigation.ts b/tests/planning/check-document-tab-navigation.ts index c026ce764..c937da2fc 100644 --- a/tests/planning/check-document-tab-navigation.ts +++ b/tests/planning/check-document-tab-navigation.ts @@ -3,11 +3,26 @@ // pijlnavigatie én het naar beeld brengen van de actieve tab. import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { DocumentTabControl } from '@/components/layout/DocumentChrome/DocumentTabControl'; import { + DOCUMENT_TABPANEL_ID, + documentTabCloseFocusTarget, + documentTabId, documentTabKeyDestination, revealDocumentTab, } from '@/components/layout/DocumentChrome/documentTabNavigation'; +// 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. +Object.assign(globalThis, { + document: { documentElement: {} }, + localStorage: { getItem: () => null, setItem: () => undefined }, +}); +const { SHORTCUTS, matchesCombo } = await import('@/hooks/keyboard/shortcutRegistry'); + const diffs: string[] = []; let checks = 0; @@ -21,33 +36,106 @@ function eq(label: string, got: unknown, want: unknown): void { 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('tab 9 → pijltje rechts bereikt tab 10', documentTabKeyDestination(ids, 'doc-9', 'ArrowRight'), 'doc-10'); -eq('tab 10 → pijltje rechts bereikt tab 11', documentTabKeyDestination(ids, 'doc-10', 'ArrowRight'), 'doc-11'); -eq('tab 11 → pijltje rechts bereikt tab 12', documentTabKeyDestination(ids, 'doc-11', 'ArrowRight'), 'doc-12'); -eq('tab 12 → pijltje rechts wrapt naar tab 1', documentTabKeyDestination(ids, 'doc-12', 'ArrowRight'), 'doc-1'); -eq('tab 1 → pijltje links wrapt naar tab 12', documentTabKeyDestination(ids, 'doc-1', 'ArrowLeft'), 'doc-12'); -eq('Home bereikt de eerste tab', documentTabKeyDestination(ids, 'doc-11', 'Home'), 'doc-1'); -eq('End bereikt de twaalfde tab', documentTabKeyDestination(ids, 'doc-2', 'End'), 'doc-12'); -eq('andere toets verandert de actieve tab niet', documentTabKeyDestination(ids, 'doc-10', 'Enter'), null); -eq('een niet-bestaande actieve id veroorzaakt geen gok', documentTabKeyDestination(ids, 'verdwenen', 'ArrowRight'), null); -eq('cijfer 10 is geen verborgen sneltoets', documentTabKeyDestination(ids, 'doc-10', '10'), null); +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); + +// 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); + +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 minimaal in beide richtingen naar beeld gebracht', calls, [{ block: 'nearest', inline: 'nearest' }]); -// Dit project heeft geen DOM-testframework. Bewaak daarom de kleine structurele ARIA-grens op de -// echte componentbron: een interactieve sluitknop mag geen descendant van role=tab zijn, en de -// tabnaam moet expliciet zijn zodat een wijziging aan de sluitknop hem niet kan vervuilen. -const tabBarSource = readFileSync(resolve( +// 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); + +// 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/DocumentTabBar.tsx', + 'src/components/layout/DocumentChrome/DocumentTabControl.tsx', ), 'utf8'); -eq('documenttab is een native button', tabBarSource.includes(' {'), true); -eq('documenttab heeft een expliciete toegankelijke naam', tabBarSource.includes('aria-label={card.title}'), true); -eq('zichtbare tabnaam is decoratief naast de expliciete naam', tabBarSource.includes('className="ops-tab-name" aria-hidden="true"'), true); -eq('sluitknop staat buiten de tab-button', /role="tab"[\s\S]*?<\/button>\s*
diff --git a/src/components/layout/DocumentChrome/DocumentTabBar.tsx b/src/components/layout/DocumentChrome/DocumentTabBar.tsx index 945931572..f644c435b 100644 --- a/src/components/layout/DocumentChrome/DocumentTabBar.tsx +++ b/src/components/layout/DocumentChrome/DocumentTabBar.tsx @@ -3,10 +3,10 @@ import { useTranslation } from 'react-i18next'; import { Menu, Plus } from 'lucide-react'; import { useAppStore } from '@/state/appStore'; import { useDocumentCards, useDocumentActions } from './useDocumentCards'; -import { DocumentTabControl } from './DocumentTabControl'; +import { DocumentTabList } from './DocumentTabList'; import { documentTabCloseFocusTarget, - handleDocumentTabKeyDown, + focusDocumentTab, revealDocumentTab, } from './documentTabNavigation'; import { documentTabFocusTargetOutsideOverview } from './projectOverviewFocus'; @@ -45,16 +45,9 @@ export function DocumentTabBar() { }, [activeId]); const focusTab = useCallback((id: string) => { - requestAnimationFrame(() => tabRefs.current.get(id)?.focus()); + focusDocumentTab(tabRefs.current, id); }, []); - const onTabKeyDown = useCallback((event: React.KeyboardEvent, cardId: string) => { - handleDocumentTabKeyDown(event, cards.map(card => card.id), cardId, direction, { - switchTo, - focusTab, - }); - }, [cards, direction, focusTab, switchTo]); - return (
{n.detail &&
{n.detail}
} + {n.detailLines?.map((line, index) => ( +
+ {notificationDetailText(t, line)} +
+ ))} {n.helpArticleId && ( // mpp-nul-data-etappe, "lees meer"-eigenaarseis: hergebruikt de bestaande Backstage → // Help-navigatie (`openHelpArticle`), geen nieuw linkmechanisme. `stopPropagation` zodat diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index ff99ee9c1..7165dfab0 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -20,6 +20,80 @@ "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 استخدم بدائل آمنة.", "mppLegacy": "يستخدم ملف .mpp هذا تنسيقًا قديمًا (Project 2007 أو أقدم). صدّره من MS Project بصيغة XML (ملف ← حفظ باسم ← XML) ثم افتح ذلك الملف.", "mppEncrypted": "ملف .mpp هذا محمي بكلمة مرور. صدّره من MS Project بصيغة XML (ملف ← حفظ باسم ← XML) ثم افتح ذلك الملف.", "saveFailed": "تعذّر الحفظ", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index c1e389a78..1651ce9c1 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -20,6 +20,32 @@ "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.", "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", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index b89194f81..356ff75e0 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -20,6 +20,32 @@ "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.", "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", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index cad960269..72bdf276a 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -20,6 +20,44 @@ "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.", "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", diff --git a/src/i18n/locales/fa/common.json b/src/i18n/locales/fa/common.json index 401b9750e..8b7a62c88 100644 --- a/src/i18n/locales/fa/common.json +++ b/src/i18n/locales/fa/common.json @@ -20,6 +20,32 @@ "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 از جایگزین‌های امن استفاده کردند.", "mppLegacy": "این فایل .mpp از قالبی قدیمی (Project 2007 یا قدیمی‌تر) استفاده می‌کند. آن را در MS Project به‌صورت XML صادر کنید (فایل ← ذخیره به‌عنوان ← XML) و آن فایل را باز کنید.", "mppEncrypted": "این فایل .mpp با رمز عبور محافظت شده است. آن را در MS Project به‌صورت XML صادر کنید (فایل ← ذخیره به‌عنوان ← XML) و آن فایل را باز کنید.", "saveFailed": "ذخیره‌سازی ناموفق بود", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 650ffed19..ea3c8a831 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -20,6 +20,44 @@ "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.", "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", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index f3460deb0..0d963e0c9 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -20,6 +20,44 @@ "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.", "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", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 72a484b9e..dc6a037d0 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -20,6 +20,20 @@ "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}}", "mppLegacy": "この .mpp ファイルは古い形式(Project 2007 以前)を使用しています。MS Project で XML としてエクスポートし(ファイル → 名前を付けて保存 → XML)、そのファイルを開いてください。", "mppEncrypted": "この .mpp ファイルはパスワードで保護されています。MS Project で XML としてエクスポートし(ファイル → 名前を付けて保存 → XML)、そのファイルを開いてください。", "saveFailed": "保存できませんでした", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 4b5bbc3c4..ba041f7fe 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -20,6 +20,20 @@ "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}}", "mppLegacy": "이 .mpp 파일은 이전 형식(Project 2007 이하)을 사용합니다. MS Project에서 XML로 내보낸 후(파일 → 다른 이름으로 저장 → XML) 해당 파일을 여세요.", "mppEncrypted": "이 .mpp 파일은 암호로 보호되어 있습니다. MS Project에서 XML로 내보낸 후(파일 → 다른 이름으로 저장 → XML) 해당 파일을 여세요.", "saveFailed": "저장하지 못했습니다", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 86bde6891..ef9775c4b 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -20,6 +20,32 @@ "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.", "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", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 597050f81..1a69f03d4 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -20,6 +20,56 @@ "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.", "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ć", diff --git a/src/i18n/locales/pt/common.json b/src/i18n/locales/pt/common.json index 14f89ad92..7d34a44e2 100644 --- a/src/i18n/locales/pt/common.json +++ b/src/i18n/locales/pt/common.json @@ -20,6 +20,44 @@ "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.", "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", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 1a65ae96f..0994451f5 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -20,6 +20,32 @@ "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ı.", "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", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 3e271338e..9f2ba7537 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -20,6 +20,20 @@ "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}}", "mppLegacy": "此 .mpp 文件使用较旧的格式(Project 2007 或更早版本)。请在 MS Project 中将其导出为 XML(文件 → 另存为 → XML),然后打开该文件。", "mppEncrypted": "此 .mpp 文件受密码保护。请在 MS Project 中将其导出为 XML(文件 → 另存为 → XML),然后打开该文件。", "saveFailed": "保存失败", diff --git a/src/state/slices/fileSlice.ts b/src/state/slices/fileSlice.ts index 63d4c6dd7..f204f5ad7 100644 --- a/src/state/slices/fileSlice.ts +++ b/src/state/slices/fileSlice.ts @@ -7,7 +7,7 @@ import { openFileDialog, saveFileDialog, saveToRef, readFromRef, readBytesFromRe import { openDialogFilters, binaryExtensions, readFormatForFile, parseOpenedFile, importErrorMessageKey, saveTargetFor, readFormatInput, type ExportFormat } from '@/services/formatRegistry'; import { loadRecents, addRecent, removeRecent, type RecentEntry } from '@/services/fileAccess/recentFiles'; import { emitExtensionEvent, HOST_EVENTS } from '@/services/extensionEvents'; -import type { AppSlice } from './types'; +import type { AppSlice, NotifyInput, NotificationDetailLine } from './types'; import type { AppState } from '../appStore'; import { isTauri } from '@/utils/platform'; import type { Task } from '@/types/task'; @@ -36,6 +36,79 @@ 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[]): 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'); + + 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 }; @@ -267,18 +340,6 @@ export const createFileSlice: AppSlice = (set, get) => { const shifted = countShiftedTasks(get().tasks, recorded.times); if (shifted > 0) set((s) => { s.recordedDates = { ...recorded, shifted }; }); } - if (parsed.xer?.enumFallbacks.length) { - get().notify({ - severity: 'info', - messageKey: 'notifications.xerEnumFallback', - params: { - family: parsed.xer.enumFallbacks.map(item => item.family).join(', '), - token: parsed.xer.enumFallbacks.map(item => item.token).join(', '), - fallback: parsed.xer.enumFallbacks.map(item => item.fallback).join(', '), - }, - dedupeKey: 'xer-enum-fallback', - }); - } if (opts.fit) get().requestFitToProject(); // Issue #16: canvas op het HELE project passen. // Relaties die de solver ECHT niet kon meerekenen (eigenaarsbesluit 2026-08-15): een // verzameltaak-eindpunt op zich is sinds `expandSummaryRelations` GEEN reden meer om te @@ -346,6 +407,12 @@ export const createFileSlice: AppSlice = (set, get) => { get().runOpenBoundary(); } + // 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); + if (notice) get().notify(notice); + const activeIndex = isMultiDocumentImport(parsed) ? parsed.activeDocumentIndex : 0; const activeId = openedDocumentIds[activeIndex]; if (activeId && activeId !== get().activeDocumentId) get().switchDocument(activeId); diff --git a/src/state/slices/types.ts b/src/state/slices/types.ts index 331e1e151..9caac7838 100644 --- a/src/state/slices/types.ts +++ b/src/state/slices/types.ts @@ -188,10 +188,31 @@ export type NotificationMessageKey = | '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' | 'notifications.mppSourceScheduleNotes' | 'notifications.projectStartAnchorsClamped' | 'notifications.mppTimephasedSteeringLost'; +/** 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; @@ -202,6 +223,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 a6b46f569..3dd279a8e 100644 --- a/src/state/slices/uiSlice.ts +++ b/src/state/slices/uiSlice.ts @@ -282,11 +282,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). 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/tests/planning/check-notifications.ts b/tests/planning/check-notifications.ts index d5bd4dab4..2c90e9bac 100644 --- a/tests/planning/check-notifications.ts +++ b/tests/planning/check-notifications.ts @@ -24,6 +24,8 @@ import { createDefaultCalendar } from '@/engine/calendar/defaultCalendar'; import { createDefaultTaskTime } from '@/utils/taskDefaults'; import { MPP_TIMEPHASED_HELP_ARTICLE_ID } from '@/state/timephasedLossNotice'; 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; @@ -55,6 +57,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); @@ -497,6 +501,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'); + // ── Uitkomst ──────────────────────────────────────────────────────────────── if (diffs.length) { console.log(`XX notifications: ${diffs.length} van de ${checks} checks FOUT`); diff --git a/tests/planning/check-xer-byte-paths.ts b/tests/planning/check-xer-byte-paths.ts index acfe5144b..90e0cdee4 100644 --- a/tests/planning/check-xer-byte-paths.ts +++ b/tests/planning/check-xer-byte-paths.ts @@ -180,8 +180,8 @@ for (const [encoding, bytes] of encodings) { } // Fixronde 1, bevinding 2: parseOpenedFile → applyLoadedProject moet de XER-bronmetadata -// documentgebonden behouden. De externe relatie blijft uitsluitend daar staan (geen Task-link), -// en een enumterugval wordt met familie/token/fallback zichtbaar gemeld. +// 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 }>; @@ -198,12 +198,14 @@ eq('documentroute: XER-metadata staat op het geladen document', { external: ['R-EXT'], taskExternalLinks: 0, }); -const fallbackNotice = loadedState.ui.notifications.find( - notice => notice.messageKey === ('notifications.xerEnumFallback' as typeof notice.messageKey), +const importNotice = loadedState.ui.notifications.find( + notice => notice.messageKey === ('notifications.xerImportOpened' as typeof notice.messageKey), ); -eq('documentroute: enumterugval toont familie, token en fallback', fallbackNotice?.params, { - family: 'activityType', token: 'TT_Onbekend', fallback: 'TT_Task', -}); +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); From 94323085bef321ad5af8ce7ce8f128ee4851b7dc Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:00:30 +0200 Subject: [PATCH 058/171] Maak XER-bronarchief compact en zelfstandig --- src/components/panels/IFCPanel.tsx | 34 +-- src/hooks/useRecoveryRestore.ts | 4 +- src/services/benchmark/runner.ts | 4 +- src/services/formatRegistry.ts | 19 +- src/services/ifc/ifcReader.ts | 70 +++++- src/services/ifc/ifcWriter.ts | 36 ++- src/services/xer/xerReader.ts | 16 ++ src/services/xerSourceArchive.ts | 6 + src/utils/devBridge.ts | 6 +- .../check-ifc-xer-archive-container.ts | 88 ++++---- tests/planning/check-xer-archive-compact.ts | 207 ++++++++++++++++++ tests/planning/check-xer-archive-readmodel.ts | 3 +- tests/planning/check-xer-archive-scale.ts | 73 +++--- .../check-xer-source-archive-chain.ts | 11 +- .../check-xer-source-archive-lifecycle.ts | 45 +++- tests/planning/run.sh | 5 + tests/planning/xerArchiveLegacyFixture.ts | 82 +++++++ 17 files changed, 560 insertions(+), 149 deletions(-) create mode 100644 tests/planning/check-xer-archive-compact.ts create mode 100644 tests/planning/xerArchiveLegacyFixture.ts diff --git a/src/components/panels/IFCPanel.tsx b/src/components/panels/IFCPanel.tsx index 2ef937860..9582bb1a1 100644 --- a/src/components/panels/IFCPanel.tsx +++ b/src/components/panels/IFCPanel.tsx @@ -2,7 +2,7 @@ import { useState, useCallback, useMemo } from 'react'; import { useAppStore } from '@/state/appStore'; import { useTranslation } from 'react-i18next'; import { writeIFC } from '@/services/ifc/ifcWriter'; -import { readIFC } from '@/services/ifc/ifcReader'; +import { readIFCWithXerReconstruction } from '@/services/formatRegistry'; import { buildWriteIFCInput } from '@/state/ifcSaveInput'; import { buildImportLabels } from '@/i18n/importLabels'; @@ -47,21 +47,23 @@ export function IFCPanel() { }, [project, calendar, tasks, sequences, resources, assignments, activityCodeTypes, customFieldDefs, resourceCalendars, baselines, activeBaselineId]); const handleApply = useCallback(() => { - try { - const data = readIFC(content, buildImportLabels(tCommon)); - loadState(data); - setViewStartDate(data.project.startDate); - runCPM(); - setDirty(false); - } catch (err) { - // Bevinding K8: alert() (de énige in de hele repo) vervangen door het gecentraliseerde kanaal. - notify({ - severity: 'error', - messageKey: 'notifications.ifcParseFailed', - detail: (err as Error).message, - }); - } - }, [content, loadState, setViewStartDate, runCPM, notify]); + void (async () => { + try { + const data = await readIFCWithXerReconstruction(content, buildImportLabels(tCommon)); + loadState(data); + setViewStartDate(data.project.startDate); + runCPM(); + setDirty(false); + } catch (err) { + // Bevinding K8: alert() (de énige in de hele repo) vervangen door het gecentraliseerde kanaal. + notify({ + severity: 'error', + messageKey: 'notifications.ifcParseFailed', + detail: (err as Error).message, + }); + } + })(); + }, [content, loadState, setViewStartDate, runCPM, notify, tCommon]); const handleCopy = useCallback(() => { void navigator.clipboard.writeText(content); diff --git a/src/hooks/useRecoveryRestore.ts b/src/hooks/useRecoveryRestore.ts index fb0e4ab7c..b072f6ae4 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'; @@ -66,7 +66,7 @@ export function useRecoveryRestore(): RecoveryRestore { let failed = 0; for (const d of loaded.docs) { try { - const parsed = readIFC(d.ifc, buildImportLabels(t)); + const parsed = await readIFCWithXerReconstruction(d.ifc, buildImportLabels(t)); // Welke velden bij crashherstel meegaan bepaalt `recoveryInputFromParsed` (bevinding // K3) — deze hook houdt bewust geen veldkennis. restored.push(recoveryInputFromParsed(parsed, { diff --git a/src/services/benchmark/runner.ts b/src/services/benchmark/runner.ts index bbb0d3f1e..08bc0d3f0 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'; @@ -187,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/formatRegistry.ts b/src/services/formatRegistry.ts index 3bd3234ac..db1ccde5e 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, OpenedImport } from '@/services/importTypes'; +import type { ImportLabels, ImportResult, OpenedImport } from '@/services/importTypes'; import type { FileFilter, FileRef } from '@/services/fileAccess'; import { extensionOf } from '@/utils/filePath'; @@ -44,6 +45,20 @@ function parseProjectXml(content: string): OpenedImport { throw new Error('Onbekend XML-formaat: geen MS Project- of Primavera-markers gevonden'); } +/** + * Lees IFC met behoud van de lazy XER-chunkgrens. Alleen een schema-2-envelope laadt de parser; + * gewone IFC en historische schema-1-archieven blijven op het bestaande synchrone hoofdpad. + */ +export async function readIFCWithXerReconstruction( + content: string, + labels: ImportLabels = {}, +): Promise { + if (content.includes(XER_SOURCE_ARCHIVE_COMPACT_STORAGE_FORMAT)) { + await import('@/services/xer/xerReader'); + } + 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,7 +66,7 @@ function parseProjectXml(content: string): OpenedImport { * 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 = filtervolgorde in openFile ('All Supported' met ifc,csv,xml,mpp,xer). diff --git a/src/services/ifc/ifcReader.ts b/src/services/ifc/ifcReader.ts index 4a82ca710..5c7c74f4f 100644 --- a/src/services/ifc/ifcReader.ts +++ b/src/services/ifc/ifcReader.ts @@ -28,7 +28,8 @@ import type { XerImportMetadata } from '@/services/importTypes'; import { bindXerImportMetadataToArchive, createXerSourceArchiveFromOwnedMetadata, decodeXerBase64Chunk, parseXerArchiveMetadataPayload, sha256Hex, - XER_SOURCE_ARCHIVE_CHUNK_BYTES, + 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, } from '@/services/xerSourceArchive'; @@ -43,6 +44,18 @@ import { const VALID_CURVES: ResourceCurve[] = ['UNIFORM', 'FRONT_LOADED', 'BACK_LOADED', 'BELL', 'EARLY_PEAK', 'LATE_PEAK']; +type XerArchiveReconstructor = (bytes: Uint8Array) => XerSourceArchive; +let xerArchiveReconstructor: XerArchiveReconstructor | null = null; + +/** + * De compacte IFC-envelope blijft synchroon leesbaar nadat de lazy XER-chunk is geladen. + * De parser registreert zichzelf bij module-initialisatie; de gewone IFC-hoofdbundel houdt + * daardoor geen statische afhankelijkheid op de zware XER-reader. + */ +export function registerXerArchiveReconstructor(reconstructor: XerArchiveReconstructor): void { + xerArchiveReconstructor = reconstructor; +} + interface StepEntity { id: string; // STEP entity ID (may include letters, e.g. "300T") type: string; @@ -284,6 +297,23 @@ function validateArchivePropertyOrder( } } +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; @@ -325,6 +355,9 @@ function extractXerSourceArchive(entities: StepEntity[], entityMap: Map): XerSourceArchive { + 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 (!xerArchiveReconstructor) { + xerArchiveError('compacte bron vereist de lazy XER-reader vóór reconstructie'); + } + const archive = xerArchiveReconstructor(sourceBytes); + if (archive.sha256 !== sourceHash || archive.byteLength !== byteLength) { + xerArchiveError('gereconstrueerd archief past niet bij de canonieke bronbytes'); + } + return archive; + } 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]` diff --git a/src/services/ifc/ifcWriter.ts b/src/services/ifc/ifcWriter.ts index bcc325480..76734c070 100644 --- a/src/services/ifc/ifcWriter.ts +++ b/src/services/ifc/ifcWriter.ts @@ -9,7 +9,7 @@ import { Baseline } from '@/types/baseline'; import { effectiveCalendarByTask, isHourCalendar, minutesToClock, minutesToIsoDuration, taskMinutesForWrite, } from '@/services/subdayIo'; -import type { ImportResult, XerImportMetadata } from '@/services/importTypes'; +import type { ImportResult } from '@/services/importTypes'; import { IFC_TIME_ANCHOR, FIELD_MEASURE, RESOURCE_TYPE_TO_IFC, } from './ifcConstants'; @@ -17,8 +17,8 @@ import { PSET, PER_TASK_PSETS, ifcStr } from './ifcPsets'; import { isSummaryTask } from '@/utils/taskHierarchy'; import { projectFileBase } from '@/utils/documents'; import { - chunkXerArchiveBytes, encodeXerArchiveMetadataPayload, withXerArchiveDocumentView, - XER_SOURCE_ARCHIVE_CHUNK_BYTES, type XerSourceArchive, + 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, @@ -209,7 +209,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, xer); + 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). @@ -325,33 +325,27 @@ export function writeIFC(input: WriteIFCInput): string { /** X9 — één self-contained Pset met manifest én deterministisch geordende bytes. */ function writeXerSourceArchive( ctx: WriteContext, ownerHistId: number, archive: XerSourceArchive | undefined, sourceProjectId: string | undefined, - xer: XerImportMetadata | undefined, ): void { if (!archive) return; if (!sourceProjectId) throw new Error('XER-bronarchief kan niet zonder OPS_XerDocument-selector worden opgeslagen.'); - const archival = xer ? withXerArchiveDocumentView(archive, xer) : archive; - const diagnostics = chunkXerArchiveBytes(encodeXerArchiveMetadataPayload(archival)); + 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(${archival.schemaVersion})`); - property('Format', `IFCLABEL(${ifcStr(archival.format)})`); - property('ByteLength', `IFCINTEGER(${archival.byteLength})`); - property('Sha256', `IFCTEXT(${ifcStr(archival.sha256)})`); - property('Encoding', `IFCLABEL(${ifcStr(archival.encoding)})`); - property('Bom', `IFCLABEL(${ifcStr(archival.bom)})`); - property('Newline', `IFCLABEL(${ifcStr(archival.newline)})`); + 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(${archival.byteChunks.length})`); - property('DiagnosticsByteLength', `IFCINTEGER(${diagnostics.byteLength})`); - property('DiagnosticsSha256', `IFCTEXT(${ifcStr(diagnostics.sha256)})`); - property('DiagnosticsChunkCount', `IFCINTEGER(${diagnostics.byteChunks.length})`); - archival.byteChunks.forEach((chunk, index) => property(`ByteChunk${String(index).padStart(6, '0')}`, `IFCTEXT(${ifcStr(chunk)})`)); - diagnostics.byteChunks.forEach((chunk, index) => property(`DiagnosticsChunk${String(index).padStart(6, '0')}`, `IFCTEXT(${ifcStr(chunk)})`)); + 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(archival.sha256)})`); + 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})`); diff --git a/src/services/xer/xerReader.ts b/src/services/xer/xerReader.ts index eda976f75..87edae974 100644 --- a/src/services/xer/xerReader.ts +++ b/src/services/xer/xerReader.ts @@ -11,6 +11,7 @@ import type { ImportResult, XerEnumFallback, XerExternalRelation, XerImportMetadata, } from '@/services/importTypes'; +import { registerXerArchiveReconstructor } from '@/services/ifc/ifcReader'; import { getCalendarBands, promoteHourCalendar } from '@/services/subdayIo'; import type { WorkCalendar } from '@/types/calendar'; import type { Sequence, SequenceType } from '@/types/sequence'; @@ -30,6 +31,7 @@ import { bindXerImportMetadataToArchive, createXerSourceArchiveFromOwnedMetadata, detectXerSourcePresentation, + type XerSourceArchive, } from '@/services/xerSourceArchive'; import { readXerCalendars } from './xerCalendarData'; import { buildXerMetadataCatalog, materializeXerMetadata, type XerMetadataCatalog } from './xerMetadata'; @@ -888,3 +890,17 @@ export function readXER(bytes: Uint8Array): XerOpenResult { { table: 'TASK' }, ); } + +/** Herbouw de volledige X9-runtimegrafiek uit uitsluitend de canonieke XER-bronbytes. */ +export function reconstructXerSourceArchiveFromBytes(bytes: Uint8Array): XerSourceArchive { + const opened = readXER(bytes); + const first = 'kind' in opened ? opened.results[0] : opened; + if (!first?.xerSourceArchive) { + throw new Error('De XER-bron leverde geen reconstruerbaar bronarchief op.'); + } + return first.xerSourceArchive; +} + +// Module-initialisatie gebeurt uitsluitend in de lazy XER-chunk (formatRegistry). Vanaf dat +// moment kan de synchrone IFC-reader compacte bronarchieven reconstrueren zonder parser-import. +registerXerArchiveReconstructor(reconstructXerSourceArchiveFromBytes); diff --git a/src/services/xerSourceArchive.ts b/src/services/xerSourceArchive.ts index 800e47960..aa6c3ff27 100644 --- a/src/services/xerSourceArchive.ts +++ b/src/services/xerSourceArchive.ts @@ -24,6 +24,12 @@ import { 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'; diff --git a/src/utils/devBridge.ts b/src/utils/devBridge.ts index df83f4134..d6ffae5c2 100644 --- a/src/utils/devBridge.ts +++ b/src/utils/devBridge.ts @@ -2,9 +2,9 @@ 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, + readIFCWithXerReconstruction, readFormatForFile, readFormatInput, saveTargetFor, @@ -51,12 +51,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, diff --git a/tests/planning/check-ifc-xer-archive-container.ts b/tests/planning/check-ifc-xer-archive-container.ts index 18de25219..96a29e7a3 100644 --- a/tests/planning/check-ifc-xer-archive-container.ts +++ b/tests/planning/check-ifc-xer-archive-container.ts @@ -1,39 +1,49 @@ // X9 — IFC-container voor het zelfstandige XER-bronarchief. Geen reader-mock: echte STEP-tekst -// gaat door writeIFC én readIFC. -import { createDefaultCalendar } from '@/engine/calendar/defaultCalendar'; -import { createDefaultProject } from '@/state/defaults'; +// gaat door writeIFC én readIFC. De bron is geldige XER, want schema 2 reconstrueert daaruit. import { writeIFC } from '@/services/ifc/ifcWriter'; import { readIFC } from '@/services/ifc/ifcReader'; import { IfcParseError } from '@/services/ifc/ifcErrors'; import { - createEmptyXerArchiveDiagnostics, - createEmptyXerArchiveReadModel, - createXerSourceArchive, 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 bytes = new Uint8Array(196_608 + 5); -for (let i = 0; i < bytes.length; i++) bytes[i] = i % 251; -const archive = createXerSourceArchive(bytes, { - encoding: 'windows-1252', bom: 'utf-8', newline: 'crlf', - diagnostics: createEmptyXerArchiveDiagnostics(), readModel: createEmptyXerArchiveReadModel(), -}); -const project = createDefaultProject(); -project.id = 'OPS-XER-PROJECT'; project.name = 'XER archief'; -const calendar = createDefaultCalendar(); project.calendarId = calendar.id; -const ifc = writeIFC({ project, calendar, tasks: [], sequences: [], resources: [], assignments: [], xerSourceArchive: archive, xer: { - sourceProjectId: 'P6-ARCHIVE', defaultCurrencyCode: '', tableReport: { encoding: 'windows-1252', endMarkerSeen: true, issues: [], unknownTables: [] }, - calendarIssues: [], enumFallbacks: [], scheduleOptions: { source: 'xer-defaults', retainedSource: {}, fallbacks: [], diagnostics: [], sourceArchive: { rows: [], unmatchedScheduleOptionsRowIndexes: [], diagnostics: [] }, sourceRowIndexes: [], sourceRows: [] }, - externalRelations: [{ id: 'EXT-1', localProjectId: 'P6-ARCHIVE', localTaskId: 'A', externalProjectId: 'B', externalTaskId: 'B', direction: 'predecessor', type: 'FS', lagMinutes: 15 }], - externalLinks: [{ id: 'LINK-1', predecessor: { projectId: 'B', taskId: 'B' }, successor: { projectId: 'P6-ARCHIVE', taskId: 'A' }, type: 'FS', lagMinutes: 15 }], - report: { projectsSeen: 2, documentsOpened: 1, emptyProjectsSkipped: 1, baselineProjectsExcluded: 0, baselinesMaterialized: 0, danglingBaselineReferences: 0, externalLinksPreserved: 1, baselineExclusionReverted: false, baselineFallbackReasons: [] }, - resources: { catalog: {} as never, assignments: [], issues: [{ code: 'XER_RESOURCE_TYPE_FALLBACK', table: 'RSRC', line: 1, sourceId: 'R', fallback: 'LABOR' }] }, -} }); +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')); @@ -43,19 +53,12 @@ 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 externe links, projectrapport en X6-provenance herleven uit diagnostics', - JSON.stringify({ - links: read.xer?.externalLinks, - report: read.xer?.report, - provenance: read.xer?.resources && { - assignments: read.xer.resources.assignments, - issues: read.xer.resources.issues, - }, - }) === JSON.stringify({ - links: [{ id: 'LINK-1', predecessor: { projectId: 'B', taskId: 'B' }, successor: { projectId: 'P6-ARCHIVE', taskId: 'A' }, type: 'FS', lagMinutes: 15 }], - report: { projectsSeen: 2, documentsOpened: 1, emptyProjectsSkipped: 1, baselineProjectsExcluded: 0, baselinesMaterialized: 0, danglingBaselineReferences: 0, externalLinksPreserved: 1, baselineExclusionReverted: false, baselineFallbackReasons: [] }, - provenance: { assignments: [], issues: [{ code: 'XER_RESOURCE_TYPE_FALLBACK', table: 'RSRC', line: 1, sourceId: 'R', fallback: 'LABOR' }] }, - })); + 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; } @@ -89,7 +92,7 @@ expect('8 ontbrekende chunk valt niet stil terug naar legacy-IFC', rejectsArchiv 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', 2), 'SchemaVersion')); + 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 @@ -111,12 +114,9 @@ const hugeBytes = replaceIntegerProperty( ); expect('11 enorme coherente bytecount wordt vóór Array.from/allocatie getypeerd geweigerd', rejectsArchiveWith(hugeBytes, 'propertybudget')); -const hugeDiagnostics = replaceIntegerProperty( - replaceIntegerProperty(ifc, 'DiagnosticsByteLength', hugeLength), - 'DiagnosticsChunkCount', hugeCount, -); -expect('12 enorme coherente diagnosticscount wordt vóór Array.from/allocatie getypeerd geweigerd', - rejectsArchiveWith(hugeDiagnostics, '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'); @@ -138,7 +138,7 @@ const duplicateRelation = ifc.replace( expect('15 dubbele propertyrelatie naar het archief wordt geweigerd', rejectsArchiveWith(duplicateRelation, 'hangt niet één-op-één')); -const legacyIfc = writeIFC({ project, calendar, tasks: [], sequences: [], resources: [], assignments: [] }); +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); diff --git a/tests/planning/check-xer-archive-compact.ts b/tests/planning/check-xer-archive-compact.ts new file mode 100644 index 000000000..3c374aa30 --- /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 { readIFC } from '@/services/ifc/ifcReader'; +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 index 4ee5c56f1..41069ed69 100644 --- a/tests/planning/check-xer-archive-readmodel.ts +++ b/tests/planning/check-xer-archive-readmodel.ts @@ -15,6 +15,7 @@ import { 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[] = []; @@ -221,7 +222,7 @@ truthy('6b IFC-read bindt X5 sourceArchive/sourceRows opnieuw aan de ene archive ]); })); -const originalIfc = writeIFC(a); +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]!)); diff --git a/tests/planning/check-xer-archive-scale.ts b/tests/planning/check-xer-archive-scale.ts index ef334234c..c0f101dce 100644 --- a/tests/planning/check-xer-archive-scale.ts +++ b/tests/planning/check-xer-archive-scale.ts @@ -1,72 +1,67 @@ // X9 reviewronde 1 — relatieve schaalproef op 1/8/32 decoded chunks, zonder tijdsdrempel. -import { createDefaultCalendar } from '@/engine/calendar/defaultCalendar'; -import type { XerImportMetadata } from '@/services/importTypes'; import { readIFC } from '@/services/ifc/ifcReader'; import { writeIFC } from '@/services/ifc/ifcWriter'; import { - createEmptyXerArchiveDiagnostics, - createEmptyXerArchiveDocumentView, - createEmptyXerArchiveReadModel, - createXerSourceArchive, decodeXerSourceArchive, sha256Hex, XER_SOURCE_ARCHIVE_CHUNK_BYTES, - bindXerImportMetadataToArchive, } from '@/services/xerSourceArchive'; -import { createDefaultProject } from '@/state/defaults'; +import { isMultiDocumentImport } from '@/services/importTypes'; +import { readXER } from '@/services/xer/xerReader'; -declare const process: { exit(code: number): never; memoryUsage(): { heapUsed: number } }; +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 project = createDefaultProject(); -const calendar = createDefaultCalendar(); -project.id = 'x9-scale'; -project.calendarId = calendar.id; -const xer = createEmptyXerArchiveDocumentView('P-SCALE'); - const measurements = [1, 8, 32].map(chunkCount => { - const bytes = new Uint8Array(chunkCount * XER_SOURCE_ARCHIVE_CHUNK_BYTES); - for (let index = 0; index < bytes.length; index += 1) bytes[index] = (index * 17 + chunkCount) % 251; - const diagnostics = { - ...createEmptyXerArchiveDiagnostics(), - documentViews: { 'P-SCALE': xer }, - }; - const readModel = createEmptyXerArchiveReadModel(); - const heapBefore = process.memoryUsage().heapUsed; + 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 = createXerSourceArchive(bytes, { - encoding: 'windows-1252', bom: 'none', newline: 'crlf', diagnostics, - readModel, - }); - const documentMetadata: XerImportMetadata = bindXerImportMetadataToArchive(archive, 'P-SCALE'); - const ifc = writeIFC({ - project, calendar, tasks: [], sequences: [], resources: [], assignments: [], - xerSourceArchive: archive, xer: documentMetadata, - }); + const archive = opened.xerSourceArchive; + const ifc = writeIFC(opened); const read = readIFC(ifc); const elapsedMs = performance.now() - started; - const heapDelta = process.memoryUsage().heapUsed - heapBefore; + // 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 === chunkCount + 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)); - expect(`${chunkCount} chunks: heap- en tijdmeting zijn eindige observaties`, - Number.isFinite(heapDelta) && Number.isFinite(elapsedMs) && elapsedMs >= 0); - return { chunkCount, sourceBytes: bytes.length, ifcChars: ifc.length, elapsedMs, heapDelta }; + 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); console.log(`X9 archive scale: ${measurements.map(item => - `${item.chunkCount}c source=${item.sourceBytes} ifc=${item.ifcChars} time=${item.elapsedMs.toFixed(1)}ms heap=${item.heapDelta}` + `${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) { diff --git a/tests/planning/check-xer-source-archive-chain.ts b/tests/planning/check-xer-source-archive-chain.ts index ad8b6ff47..38e8e6986 100644 --- a/tests/planning/check-xer-source-archive-chain.ts +++ b/tests/planning/check-xer-source-archive-chain.ts @@ -11,7 +11,7 @@ import { buildWriteIFCInput } from '@/state/ifcSaveInput'; declare const process: { exit(code: number): never; - memoryUsage(): { heapUsed: number }; + resourceUsage(): { maxRSS: number }; }; const failures: string[] = []; @@ -126,7 +126,6 @@ const source = new TextEncoder().encode([ '%E', ].join('\r\n')); -const heapBefore = process.memoryUsage().heapUsed; 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); @@ -220,10 +219,10 @@ expect('13 recovery bewaart documentspecifieke semantiek en projectselectie', && new Set(recovered.map(document => document.payload.xerSourceProjectId)).size === 12); await clearRecovery(); -const heapAfter = process.memoryUsage().heapUsed; -expect('14 schaalproef levert finite heapmetingen zonder absolute tijd- of groottelimiet', - Number.isFinite(heapBefore) && Number.isFinite(heapAfter)); -console.log(`X9 12-project chain: heap-delta=${heapAfter - heapBefore} bytes; archives=${new Set(recovered.map(document => document.payload.xerSourceArchive)).size}; x5-caches=${new Set(recovered.map(document => document.payload.xerImportMetadata?.scheduleOptions.sourceArchive)).size}`); +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})`); diff --git a/tests/planning/check-xer-source-archive-lifecycle.ts b/tests/planning/check-xer-source-archive-lifecycle.ts index 10817b5d5..f42a6cdd0 100644 --- a/tests/planning/check-xer-source-archive-lifecycle.ts +++ b/tests/planning/check-xer-source-archive-lifecycle.ts @@ -16,13 +16,15 @@ 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, loadRecovery, saveRecovery } from '@/services/recovery/recoveryStore'; declare const process: { exit(code: number): never; - memoryUsage(): { heapUsed: number }; + resourceUsage(): { maxRSS: number }; }; const failures: string[] = []; @@ -81,13 +83,33 @@ const fakeIndexedDb = { (globalThis as unknown as { window: object }).window = {}; (globalThis as unknown as { indexedDB: unknown }).indexedDB = fakeIndexedDb; -const heapBefore = process.memoryUsage().heapUsed; -const bytes = new Uint8Array(XER_SOURCE_ARCHIVE_CHUNK_BYTES * 3 + 17); -for (let index = 0; index < bytes.length; index += 1) bytes[index] = index % 251; +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: 'windows-1252', bom: 'none', newline: 'crlf', - diagnostics: { ...createEmptyXerArchiveDiagnostics(), opaqueExtensions: { fixture: { typed: true } } }, - readModel: createEmptyXerArchiveReadModel(), + 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'; @@ -147,16 +169,15 @@ 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 typed diagnostics', +expect('7 recovery behoudt byte-identieke bron, hash en éénzelfde bronafgeleide diagnosticsgraaf', recoveredArchives.length === 13 && sha256Hex(decodeXerSourceArchive(recoveredArchives[0]!)) === sha256Hex(bytes) - && JSON.stringify(recoveredArchives[0]!.diagnostics) === JSON.stringify(parsedSnapshots[0]!.xerSourceArchive?.diagnostics) && 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 heapAfter = process.memoryUsage().heapUsed; -expect('8 heapmeting is beschikbaar zonder tijdsdrempel', Number.isFinite(heapBefore) && Number.isFinite(heapAfter)); -console.log(`X9 archive lifecycle: heap-delta=${heapAfter - heapBefore} bytes; unique-runtime-archives=${new Set(recoveredArchives).size}`); +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, diff --git a/tests/planning/run.sh b/tests/planning/run.sh index 08f3d77c0..4aa16e77c 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -342,6 +342,11 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then IFCXERARCHIVECHECK="$DIR/.ifc-xer-archive-container.mjs" if bundle_check "$DIR/check-ifc-xer-archive-container.ts" "$IFCXERARCHIVECHECK"; then node "$IFCXERARCHIVECHECK" || 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 + # 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" 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, + ); +} From f2544fbf880a77fb4fcd8019894f65f55045c3d0 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:06:33 +0200 Subject: [PATCH 059/171] Maak crashherstel incrementeel en atomair --- src/hooks/recoveryPaths.ts | 31 +- src/hooks/useAutoSave.ts | 53 ++-- src/services/recovery/recoveryDelta.ts | 113 +++++++ src/services/recovery/recoveryStore.ts | 289 ++++++++++++++---- src/utils/idb.ts | 15 +- tests/planning/check-recovery-delta.ts | 105 +++++++ tests/planning/check-recovery-isolation.ts | 40 ++- .../check-xer-archive-recovery-corpus.ts | 267 ++++++++++++++++ .../check-xer-source-archive-chain.ts | 6 +- .../check-xer-source-archive-lifecycle.ts | 6 +- ...easure-xer-recovery-write-amplification.ts | 142 +++++++++ tests/planning/run.sh | 13 + 12 files changed, 982 insertions(+), 98 deletions(-) create mode 100644 src/services/recovery/recoveryDelta.ts create mode 100644 tests/planning/check-recovery-delta.ts create mode 100644 tests/planning/check-xer-archive-recovery-corpus.ts create mode 100644 tests/planning/measure-xer-recovery-write-amplification.ts diff --git a/src/hooks/recoveryPaths.ts b/src/hooks/recoveryPaths.ts index 03208f973..cb43c9951 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) @@ -94,10 +108,11 @@ export interface RecoveryManifestDoc { 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. + * 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 01c53bdb3..7f2ec5e0f 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'; // Auto-save GETHROTTLED op ~10 s (voorheen 800 ms-debounce): bij een reeks wijzigingen // schrijven we een recovery-snapshot HOOGSTENS eens per `AUTOSAVE_INTERVAL_MS`, óók tijdens @@ -22,9 +23,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 @@ -35,28 +37,31 @@ 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, + })); + 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/services/recovery/recoveryDelta.ts b/src/services/recovery/recoveryDelta.ts new file mode 100644 index 000000000..aecf44425 --- /dev/null +++ b/src/services/recovery/recoveryDelta.ts @@ -0,0 +1,113 @@ +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; + }); +} + +/** + * 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 }) => ({ id, filePath, isDirty })); + 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 }) => ({ id, filePath, isDirty })), + 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 }; + }); + return { + activeDocumentId, + documents: documents.map(({ id, filePath, isDirty }) => ({ id, filePath, isDirty })), + 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..a6677a1ed 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, @@ -13,6 +13,34 @@ export interface RecoveryDocContent { isDirty: 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; +} + +/** + * 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 }) => ({ id, filePath, isDirty })), + upserts: docs, + }; +} + /** Geladen record incl. weergave-mtime (Tauri: bestand-mtime; web: addedAt). */ export interface LoadedRecoveryDoc extends RecoveryDocContent { mtime: Date | null; @@ -32,13 +60,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 = 3; /** * Id van DEZE app-instantie (proces/realm). Wordt in het manifest gezet zodat een volgende @@ -211,7 +238,75 @@ 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) { + 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 }); + 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 }); + } + + 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 +349,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 +378,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,7 +440,10 @@ 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); @@ -528,16 +634,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 +656,88 @@ 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, + })), + 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 +746,51 @@ 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 })); + 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, + 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/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/tests/planning/check-recovery-delta.ts b/tests/planning/check-recovery-delta.ts new file mode 100644 index 000000000..3e9ae5ae4 --- /dev/null +++ b/tests/planning/check-recovery-delta.ts @@ -0,0 +1,105 @@ +// 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 }, + { id: 'b', source: b, filePath: '/tmp/b.ifc', isDirty: true }, +]; + +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); + +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-isolation.ts b/tests/planning/check-recovery-isolation.ts index d4efa051c..84f71929b 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,32 @@ 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 }, + { id: 'doc-2', filePath: '/tmp/b.ifc', isDirty: false }, + ], + upserts: [ + { id: 'doc-2', ifc: 'nieuwe B-inhoud', filePath: '/tmp/b.ifc', isDirty: 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 v3 blijft na v1/v2 de actuele manifestversie', RECOVERY_MANIFEST_VERSION, 3); + // ── 6. Halffabricaten (.tmp) op exacte naam, niet met een sweep ─────────────────────────────── const tmpPlan = plan({ listing: [ @@ -293,6 +317,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-xer-archive-recovery-corpus.ts b/tests/planning/check-xer-archive-recovery-corpus.ts new file mode 100644 index 000000000..5691dd805 --- /dev/null +++ b/tests/planning/check-xer-archive-recovery-corpus.ts @@ -0,0 +1,267 @@ +// 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 { readIFC } from '@/services/ifc/ifcReader'; +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, + })))); + 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 })), + upserts: [{ id: docs[0]!.id, ifc: editedIfc, filePath: null, isDirty: true }], + }); + 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 }, + )); + 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-source-archive-chain.ts b/tests/planning/check-xer-source-archive-chain.ts index 38e8e6986..6ca160eff 100644 --- a/tests/planning/check-xer-source-archive-chain.ts +++ b/tests/planning/check-xer-source-archive-chain.ts @@ -2,7 +2,7 @@ import { isMultiDocumentImport } from '@/services/importTypes'; import { readIFC } from '@/services/ifc/ifcReader'; import { writeIFC } from '@/services/ifc/ifcWriter'; -import { clearRecovery, loadRecovery, saveRecovery } from '@/services/recovery/recoveryStore'; +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'; @@ -178,12 +178,12 @@ expect('9 iedere zelfstandige IFC is zonder siblingstate heropenbaar met exacte && result.xerSourceProjectId === result.xer?.sourceProjectId)); await clearRecovery(); -await saveRecovery(thirteen[0]!.id, thirteen.map((document, index) => ({ +await saveRecovery(fullRecoverySave(thirteen[0]!.id, thirteen.map((document, index) => ({ id: document.id, ifc: ifcs[index]!, filePath: null, isDirty: true, -}))); +})))); 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( diff --git a/tests/planning/check-xer-source-archive-lifecycle.ts b/tests/planning/check-xer-source-archive-lifecycle.ts index f42a6cdd0..88d998d36 100644 --- a/tests/planning/check-xer-source-archive-lifecycle.ts +++ b/tests/planning/check-xer-source-archive-lifecycle.ts @@ -20,7 +20,7 @@ import { isMultiDocumentImport } from '@/services/importTypes'; import { readXER } from '@/services/xer/xerReader'; import type { Task } from '@/types/task'; import { createDefaultTaskTime } from '@/utils/taskDefaults'; -import { clearRecovery, loadRecovery, saveRecovery } from '@/services/recovery/recoveryStore'; +import { clearRecovery, fullRecoverySave, loadRecovery, saveRecovery } from '@/services/recovery/recoveryStore'; declare const process: { exit(code: number): never; @@ -152,12 +152,12 @@ expect('5 redo verandert evenmin de archive-ref', store().xerSourceArchive === a const serializedSnapshots = thirteen.map(doc => writeIFC(buildWriteIFCInput(doc.payload))); await clearRecovery(); -await saveRecovery(thirteen[0]!.id, thirteen.map((doc, index) => ({ +await saveRecovery(fullRecoverySave(thirteen[0]!.id, thirteen.map((doc, index) => ({ id: doc.id, ifc: serializedSnapshots[index]!, filePath: null, isDirty: true, -}))); +})))); const loadedRecovery = await loadRecovery(); expect('5a publieke headless recovery-backend bewaart alle zelfstandige IFC-snapshots', loadedRecovery.docs.length === 13 && loadedRecovery.activeDocumentId === thirteen[0]!.id); 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..5762e3f2b --- /dev/null +++ b/tests/planning/measure-xer-recovery-write-amplification.ts @@ -0,0 +1,142 @@ +/** + * Bewust RODE meetfixture voor het losstaande delta-recoverypakket. + * + * Niet als `check-*.ts` benoemd en daarom niet aan `tests/planning/run.sh` toegevoegd: de huidige + * recoverycontracten accepteren volledige `RecoveryDocContent[]` en schrijven iedere aangeleverde + * IFC opnieuw. Deze fixture moet pas groen worden wanneer dat pakket als één geheel verandert. + * + * Implementatieknip: + * 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. + */ +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, +})); + +await clearRecovery(); +await saveRecovery({ + activeDocumentId: docs[0]!.id, + documents: docs.map(({ id, filePath, isDirty }) => ({ id, filePath, isDirty })), + 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 })), + 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 })), + 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/run.sh b/tests/planning/run.sh index 4aa16e77c..a5b3256b0 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -347,6 +347,14 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then XERARCHIVECOMPACTCHECK="$DIR/.xer-archive-compact.mjs" if bundle_check "$DIR/check-xer-archive-compact.ts" "$XERARCHIVECOMPACTCHECK"; then node "$XERARCHIVECOMPACTCHECK" || 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" @@ -762,6 +770,11 @@ 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 + # Recovery-isolatie tussen instanties (bevinding K5). De opruimlogica veegde op PREFIX door de # gedeelde appDataDir. Twee gelijktijdige vensters wisten daarmee elkaars snapshots — en omdat # de productie-base (`recovery`) een prefix is van elke dev-base (`recovery.`), wiste één From 23953b063075eabfdb801dd431c9e7a9b24edd02 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:53:58 +0200 Subject: [PATCH 060/171] Fix X9 koude IFC-reconstructie en reviewpoorten --- docs/xer-recovery-guardrails.md | 45 ++++++++++++ src/components/backstage/Backstage.tsx | 2 +- src/components/backstage/HelpPanel.tsx | 2 +- src/components/dialogs/PoolImportDialog.tsx | 2 +- src/services/formatRegistry.ts | 3 +- src/services/ifc/ifcReader.ts | 46 +++++++----- src/services/library/libraryIfc.ts | 6 +- src/services/xer/xerReader.ts | 5 -- src/state/slices/fileSlice.ts | 9 ++- src/utils/devBridge.ts | 15 ++-- tests/library/check-ifc-hostile.ts | 2 +- tests/library/check-pool-ifc.ts | 10 +-- tests/planning/check-dev-bridge-poller.ts | 34 +++++++++ .../check-ifc-xer-archive-container.ts | 2 +- tests/planning/check-notifications.ts | 2 +- tests/planning/check-xer-archive-cold-read.ts | 71 +++++++++++++++++++ tests/planning/check-xer-archive-compact.ts | 2 +- tests/planning/check-xer-archive-readmodel.ts | 2 +- .../check-xer-archive-recovery-corpus.ts | 2 +- tests/planning/check-xer-archive-scale.ts | 2 +- tests/planning/check-xer-metadata-wiring.ts | 2 +- tests/planning/check-xer-multi-project.ts | 2 +- tests/planning/check-xer-open-wiring.ts | 2 +- tests/planning/check-xer-p6-presence.ts | 2 +- tests/planning/check-xer-progress.ts | 2 +- tests/planning/check-xer-reader.ts | 2 +- .../check-xer-schedule-options-wiring.ts | 2 +- tests/planning/check-xer-schedule-options.ts | 2 +- .../check-xer-source-archive-chain.ts | 2 +- .../check-xer-source-archive-lifecycle.ts | 2 +- tests/planning/check-xer-suspend-invariant.ts | 2 +- tests/planning/check-xer-x7-hour-mode.ts | 2 +- ...easure-xer-recovery-write-amplification.ts | 14 ++-- tests/planning/run.sh | 15 ++++ tests/planning/xerArchiveColdReadAsync.ts | 17 +++++ tests/planning/xerArchiveColdReadRaw.ts | 18 +++++ tests/planning/xerArchiveColdReadWriter.ts | 29 ++++++++ tests/planning/xerArchiveTestReader.ts | 10 +++ 38 files changed, 323 insertions(+), 68 deletions(-) create mode 100644 docs/xer-recovery-guardrails.md create mode 100644 tests/planning/check-dev-bridge-poller.ts create mode 100644 tests/planning/check-xer-archive-cold-read.ts create mode 100644 tests/planning/xerArchiveColdReadAsync.ts create mode 100644 tests/planning/xerArchiveColdReadRaw.ts create mode 100644 tests/planning/xerArchiveColdReadWriter.ts create mode 100644 tests/planning/xerArchiveTestReader.ts diff --git a/docs/xer-recovery-guardrails.md b/docs/xer-recovery-guardrails.md new file mode 100644 index 000000000..0a1d95f82 --- /dev/null +++ b/docs/xer-recovery-guardrails.md @@ -0,0 +1,45 @@ +# 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 en bestandspaden zijn manifestmetadata; ze zijn geen inhoudsrevisie. +- 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 3. 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 en 2 blijven leesbaar. 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. +- 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/src/components/backstage/Backstage.tsx b/src/components/backstage/Backstage.tsx index 569b84b56..21068f6c9 100644 --- a/src/components/backstage/Backstage.tsx +++ b/src/components/backstage/Backstage.tsx @@ -267,7 +267,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): NA het // laden (openExampleFromString laadt bewust LOS), VOOR runCPM. if (ex.category === 'showcase') applyDemoLibraryToShowcaseProject(); diff --git a/src/components/backstage/HelpPanel.tsx b/src/components/backstage/HelpPanel.tsx index 12a9e7ca6..9d2416747 100644 --- a/src/components/backstage/HelpPanel.tsx +++ b/src/components/backstage/HelpPanel.tsx @@ -186,7 +186,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/dialogs/PoolImportDialog.tsx b/src/components/dialogs/PoolImportDialog.tsx index e0aec3a5f..97fb4564c 100644 --- a/src/components/dialogs/PoolImportDialog.tsx +++ b/src/components/dialogs/PoolImportDialog.tsx @@ -86,7 +86,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/services/formatRegistry.ts b/src/services/formatRegistry.ts index db1ccde5e..47568366e 100644 --- a/src/services/formatRegistry.ts +++ b/src/services/formatRegistry.ts @@ -54,7 +54,8 @@ export async function readIFCWithXerReconstruction( labels: ImportLabels = {}, ): Promise { if (content.includes(XER_SOURCE_ARCHIVE_COMPACT_STORAGE_FORMAT)) { - await import('@/services/xer/xerReader'); + const { reconstructXerSourceArchiveFromBytes } = await import('@/services/xer/xerReader'); + return readIFC(content, labels, { reconstructXerArchive: reconstructXerSourceArchiveFromBytes }); } return readIFC(content, labels); } diff --git a/src/services/ifc/ifcReader.ts b/src/services/ifc/ifcReader.ts index 5c7c74f4f..078e56a1a 100644 --- a/src/services/ifc/ifcReader.ts +++ b/src/services/ifc/ifcReader.ts @@ -44,16 +44,14 @@ import { const VALID_CURVES: ResourceCurve[] = ['UNIFORM', 'FRONT_LOADED', 'BACK_LOADED', 'BELL', 'EARLY_PEAK', 'LATE_PEAK']; -type XerArchiveReconstructor = (bytes: Uint8Array) => XerSourceArchive; -let xerArchiveReconstructor: XerArchiveReconstructor | null = null; +/** 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. */ +export type XerArchiveReconstructor = (bytes: Uint8Array) => XerSourceArchive; -/** - * De compacte IFC-envelope blijft synchroon leesbaar nadat de lazy XER-chunk is geladen. - * De parser registreert zichzelf bij module-initialisatie; de gewone IFC-hoofdbundel houdt - * daardoor geen statische afhankelijkheid op de zware XER-reader. - */ -export function registerXerArchiveReconstructor(reconstructor: XerArchiveReconstructor): void { - xerArchiveReconstructor = reconstructor; +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 { @@ -115,7 +113,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); @@ -126,7 +128,7 @@ export function readIFC(content: string, labels: ImportLabels = {}): ImportResul // Extract project const project = extractProject(entities, entityMap, labels); - const xerSourceArchive = extractXerSourceArchive(entities, entityMap); + const xerSourceArchive = extractXerSourceArchive(entities, entityMap, options.reconstructXerArchive); const xerSourceProjectId = extractXerSourceProjectId(entities, entityMap, xerSourceArchive); const xer = extractXerImportMetadata(xerSourceArchive, xerSourceProjectId); const calendar = extractCalendar(entities, entityMap); @@ -351,12 +353,16 @@ function concatArchiveChunks(props: Map, prefix: string, count: } /** Lees en valideer vóór allocatie de self-contained X9-container; afwezig blijft legacy-compatibel. */ -function extractXerSourceArchive(entities: StepEntity[], entityMap: Map): XerSourceArchive | undefined { +function extractXerSourceArchive( + entities: StepEntity[], + entityMap: Map, + reconstructXerArchive: XerArchiveReconstructor | undefined, +): XerSourceArchive | 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); + 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'); @@ -408,7 +414,10 @@ function extractXerSourceArchive(entities: StepEntity[], entityMap: Map): XerSourceArchive { +function extractCompactXerSourceArchive( + props: Map, + reconstructXerArchive: XerArchiveReconstructor | undefined, +): XerSourceArchive { 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'); @@ -428,10 +437,13 @@ function extractCompactXerSourceArchive(props: Map): XerSourceA xerArchiveError('Sha256 is ongeldig of past niet bij de bytes'); } try { - if (!xerArchiveReconstructor) { - xerArchiveError('compacte bron vereist de lazy XER-reader vóór reconstructie'); + if (!reconstructXerArchive) { + xerArchiveError( + 'compacte bron vereist readIFCWithXerReconstruction; de lage synchrone readIFC-ingang ' + + 'laadt de XER-reader bewust niet zelf', + ); } - const archive = xerArchiveReconstructor(sourceBytes); + const archive = reconstructXerArchive(sourceBytes); if (archive.sha256 !== sourceHash || archive.byteLength !== byteLength) { xerArchiveError('gereconstrueerd archief past niet bij de canonieke bronbytes'); } 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/xer/xerReader.ts b/src/services/xer/xerReader.ts index 87edae974..5be4873d7 100644 --- a/src/services/xer/xerReader.ts +++ b/src/services/xer/xerReader.ts @@ -11,7 +11,6 @@ import type { ImportResult, XerEnumFallback, XerExternalRelation, XerImportMetadata, } from '@/services/importTypes'; -import { registerXerArchiveReconstructor } from '@/services/ifc/ifcReader'; import { getCalendarBands, promoteHourCalendar } from '@/services/subdayIo'; import type { WorkCalendar } from '@/types/calendar'; import type { Sequence, SequenceType } from '@/types/sequence'; @@ -900,7 +899,3 @@ export function reconstructXerSourceArchiveFromBytes(bytes: Uint8Array): XerSour } return first.xerSourceArchive; } - -// Module-initialisatie gebeurt uitsluitend in de lazy XER-chunk (formatRegistry). Vanaf dat -// moment kan de synchrone IFC-reader compacte bronarchieven reconstrueren zonder parser-import. -registerXerArchiveReconstructor(reconstructXerSourceArchiveFromBytes); diff --git a/src/state/slices/fileSlice.ts b/src/state/slices/fileSlice.ts index f204f5ad7..a4796f0c7 100644 --- a/src/state/slices/fileSlice.ts +++ b/src/state/slices/fileSlice.ts @@ -1,10 +1,9 @@ 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 { AppSlice, NotifyInput, NotificationDetailLine } from './types'; @@ -183,7 +182,7 @@ 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. @@ -732,9 +731,9 @@ export const createFileSlice: AppSlice = (set, get) => { } }, - 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/utils/devBridge.ts b/src/utils/devBridge.ts index d6ffae5c2..1c5af019a 100644 --- a/src/utils/devBridge.ts +++ b/src/utils/devBridge.ts @@ -116,7 +116,7 @@ async function installExtensionFromCode( return useAppStore.getState().installedExtensions[manifest.id]; } -interface OpsCommand { +export interface OpsCommand { id?: string; op: 'ping' | 'getState' | 'roundTrip' | 'save' | 'open' | 'dispatch' | 'feedbackTest'; args?: Record; @@ -131,7 +131,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' }; @@ -184,6 +184,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`. @@ -212,8 +220,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/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-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/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-ifc-xer-archive-container.ts b/tests/planning/check-ifc-xer-archive-container.ts index 96a29e7a3..3877ebe66 100644 --- a/tests/planning/check-ifc-xer-archive-container.ts +++ b/tests/planning/check-ifc-xer-archive-container.ts @@ -1,7 +1,7 @@ // 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 { readIFC } from '@/services/ifc/ifcReader'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; import { IfcParseError } from '@/services/ifc/ifcErrors'; import { decodeXerSourceArchive, diff --git a/tests/planning/check-notifications.ts b/tests/planning/check-notifications.ts index 2c90e9bac..95f44e98b 100644 --- a/tests/planning/check-notifications.ts +++ b/tests/planning/check-notifications.ts @@ -208,7 +208,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'); 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 index 3c374aa30..52e1ceae1 100644 --- a/tests/planning/check-xer-archive-compact.ts +++ b/tests/planning/check-xer-archive-compact.ts @@ -1,7 +1,7 @@ // X9-compactopslag — corpusloze contracttest voor een zelfstandig, uit ruwe bytes herbouwd archief. import { isMultiDocumentImport } from '@/services/importTypes'; import { IfcParseError } from '@/services/ifc/ifcErrors'; -import { readIFC } from '@/services/ifc/ifcReader'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; import { writeIFC } from '@/services/ifc/ifcWriter'; import { readXER } from '@/services/xer/xerReader'; import { diff --git a/tests/planning/check-xer-archive-readmodel.ts b/tests/planning/check-xer-archive-readmodel.ts index 41069ed69..916b5f7a2 100644 --- a/tests/planning/check-xer-archive-readmodel.ts +++ b/tests/planning/check-xer-archive-readmodel.ts @@ -3,7 +3,7 @@ import { Buffer } from 'node:buffer'; import { createHash } from 'node:crypto'; import { isMultiDocumentImport } from '@/services/importTypes'; -import { readIFC } from '@/services/ifc/ifcReader'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; import { IfcParseError } from '@/services/ifc/ifcErrors'; import { writeIFC } from '@/services/ifc/ifcWriter'; import { readXER } from '@/services/xer/xerReader'; diff --git a/tests/planning/check-xer-archive-recovery-corpus.ts b/tests/planning/check-xer-archive-recovery-corpus.ts index 5691dd805..191af621b 100644 --- a/tests/planning/check-xer-archive-recovery-corpus.ts +++ b/tests/planning/check-xer-archive-recovery-corpus.ts @@ -3,7 +3,7 @@ import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { isMultiDocumentImport } from '@/services/importTypes'; -import { readIFC } from '@/services/ifc/ifcReader'; +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'; diff --git a/tests/planning/check-xer-archive-scale.ts b/tests/planning/check-xer-archive-scale.ts index c0f101dce..31528eb9d 100644 --- a/tests/planning/check-xer-archive-scale.ts +++ b/tests/planning/check-xer-archive-scale.ts @@ -1,5 +1,5 @@ // X9 reviewronde 1 — relatieve schaalproef op 1/8/32 decoded chunks, zonder tijdsdrempel. -import { readIFC } from '@/services/ifc/ifcReader'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; import { writeIFC } from '@/services/ifc/ifcWriter'; import { decodeXerSourceArchive, diff --git a/tests/planning/check-xer-metadata-wiring.ts b/tests/planning/check-xer-metadata-wiring.ts index 5863c7fa0..3954b70dc 100644 --- a/tests/planning/check-xer-metadata-wiring.ts +++ b/tests/planning/check-xer-metadata-wiring.ts @@ -1,5 +1,5 @@ import { isMultiDocumentImport } from '@/services/importTypes'; -import { readIFC } from '@/services/ifc/ifcReader'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; import { writeIFC } from '@/services/ifc/ifcWriter'; import { payloadFromImport } from '@/state/documentContract'; import { readXER } from '@/services/xer/xerReader'; diff --git a/tests/planning/check-xer-multi-project.ts b/tests/planning/check-xer-multi-project.ts index 869e3e3cb..7a494caa8 100644 --- a/tests/planning/check-xer-multi-project.ts +++ b/tests/planning/check-xer-multi-project.ts @@ -19,7 +19,7 @@ import { computeVariance } from '@/engine/variance'; import { createDefaultTaskTime } from '@/utils/taskDefaults'; import { payloadFromImport } from '@/state/documentContract'; import { writeIFC } from '@/services/ifc/ifcWriter'; -import { readIFC } from '@/services/ifc/ifcReader'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; import { XER_SCHEDULING_DEFAULTS } from '@/services/xer/xerScheduleOptions'; const diffs: string[] = []; diff --git a/tests/planning/check-xer-open-wiring.ts b/tests/planning/check-xer-open-wiring.ts index 6e4f20918..e2aa70e0f 100644 --- a/tests/planning/check-xer-open-wiring.ts +++ b/tests/planning/check-xer-open-wiring.ts @@ -3,7 +3,7 @@ import { isMultiDocumentImport } from '@/services/importTypes'; import type { XerMultiProjectImport } from '@/services/xer/xerMultiProject'; import { useAppStore } from '@/state/appStore'; import { buildWriteIFCInput } from '@/state/ifcSaveInput'; -import { readIFC } from '@/services/ifc/ifcReader'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; import { writeIFC } from '@/services/ifc/ifcWriter'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; diff --git a/tests/planning/check-xer-p6-presence.ts b/tests/planning/check-xer-p6-presence.ts index 5e039075e..270138959 100644 --- a/tests/planning/check-xer-p6-presence.ts +++ b/tests/planning/check-xer-p6-presence.ts @@ -1,6 +1,6 @@ // X9 reviewronde 1 — false is een aanwezige P6-waarde; raw XER-cellen blijven daarnaast exact. import { activeImportResult } from '@/services/importTypes'; -import { readIFC } from '@/services/ifc/ifcReader'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; import { writeIFC } from '@/services/ifc/ifcWriter'; import { readXER } from '@/services/xer/xerReader'; diff --git a/tests/planning/check-xer-progress.ts b/tests/planning/check-xer-progress.ts index 173d72e32..b250d0653 100644 --- a/tests/planning/check-xer-progress.ts +++ b/tests/planning/check-xer-progress.ts @@ -1,6 +1,6 @@ import { solveProject } from '@/engine/scheduler/solveProject'; import { isMultiDocumentImport } from '@/services/importTypes'; -import { readIFC } from '@/services/ifc/ifcReader'; +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'; diff --git a/tests/planning/check-xer-reader.ts b/tests/planning/check-xer-reader.ts index a62d6f48f..47b0a77d7 100644 --- a/tests/planning/check-xer-reader.ts +++ b/tests/planning/check-xer-reader.ts @@ -6,7 +6,7 @@ import { expandSummaryRelations } from '@/engine/scheduler/expandSummaryRelation import { computeResourceLoad } from '@/engine/scheduler/ResourceLoad'; import { levelResources } from '@/engine/scheduler/ResourceLeveler'; import { writeIFC } from '@/services/ifc/ifcWriter'; -import { readIFC } from '@/services/ifc/ifcReader'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; const diffs: string[] = []; let checks = 0; diff --git a/tests/planning/check-xer-schedule-options-wiring.ts b/tests/planning/check-xer-schedule-options-wiring.ts index 6250aba78..84eea8ea1 100644 --- a/tests/planning/check-xer-schedule-options-wiring.ts +++ b/tests/planning/check-xer-schedule-options-wiring.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { cloneTasksForSolve, solveProject } from '@/engine/scheduler/solveProject'; -import { readIFC } from '@/services/ifc/ifcReader'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; import { writeIFC } from '@/services/ifc/ifcWriter'; import { isMultiDocumentImport } from '@/services/importTypes'; import type { XerScheduleOptionsMetadata } from '@/services/xer/xerScheduleOptions'; diff --git a/tests/planning/check-xer-schedule-options.ts b/tests/planning/check-xer-schedule-options.ts index 2b98f25af..935c76c66 100644 --- a/tests/planning/check-xer-schedule-options.ts +++ b/tests/planning/check-xer-schedule-options.ts @@ -6,7 +6,7 @@ import { XER_SCHEDOPTIONS_COLUMN_DISPOSITIONS, XER_SCHEDULING_DEFAULTS, } from '@/services/xer/xerScheduleOptions'; -import { readIFC } from '@/services/ifc/ifcReader'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; import { writeIFC } from '@/services/ifc/ifcWriter'; import { parseXerTables } from '@/services/xer/xerTables'; import type { WorkCalendar } from '@/types/calendar'; diff --git a/tests/planning/check-xer-source-archive-chain.ts b/tests/planning/check-xer-source-archive-chain.ts index 6ca160eff..099594424 100644 --- a/tests/planning/check-xer-source-archive-chain.ts +++ b/tests/planning/check-xer-source-archive-chain.ts @@ -1,6 +1,6 @@ // X9 fixronde 2 — één echte twaalfprojectenketen door reader, store, IFC en headless recovery. import { isMultiDocumentImport } from '@/services/importTypes'; -import { readIFC } from '@/services/ifc/ifcReader'; +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'; diff --git a/tests/planning/check-xer-source-archive-lifecycle.ts b/tests/planning/check-xer-source-archive-lifecycle.ts index 88d998d36..3212f3b56 100644 --- a/tests/planning/check-xer-source-archive-lifecycle.ts +++ b/tests/planning/check-xer-source-archive-lifecycle.ts @@ -10,7 +10,7 @@ import { withXerArchiveDocumentView, XER_SOURCE_ARCHIVE_CHUNK_BYTES, } from '@/services/xerSourceArchive'; -import { readIFC } from '@/services/ifc/ifcReader'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; import { writeIFC } from '@/services/ifc/ifcWriter'; import { useAppStore } from '@/state/appStore'; import { recoveryInputFromParsed } from '@/state/documentContract'; diff --git a/tests/planning/check-xer-suspend-invariant.ts b/tests/planning/check-xer-suspend-invariant.ts index f7c9de3f4..d131e8167 100644 --- a/tests/planning/check-xer-suspend-invariant.ts +++ b/tests/planning/check-xer-suspend-invariant.ts @@ -1,6 +1,6 @@ import { solveProject } from '@/engine/scheduler/solveProject'; import { isMultiDocumentImport, type ImportResult } from '@/services/importTypes'; -import { readIFC } from '@/services/ifc/ifcReader'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; import { writeIFC } from '@/services/ifc/ifcWriter'; import { readXER } from '@/services/xer/xerReader'; import { createAppStore } from '@/state/appStore'; diff --git a/tests/planning/check-xer-x7-hour-mode.ts b/tests/planning/check-xer-x7-hour-mode.ts index c4c4e9679..43a69cf3f 100644 --- a/tests/planning/check-xer-x7-hour-mode.ts +++ b/tests/planning/check-xer-x7-hour-mode.ts @@ -1,6 +1,6 @@ import { solveProject } from '@/engine/scheduler/solveProject'; import { isMultiDocumentImport } from '@/services/importTypes'; -import { readIFC } from '@/services/ifc/ifcReader'; +import { readXerArchiveIFC as readIFC } from './xerArchiveTestReader'; import { writeIFC } from '@/services/ifc/ifcWriter'; import { readXER } from '@/services/xer/xerReader'; import type { ImportResult } from '@/services/importTypes'; diff --git a/tests/planning/measure-xer-recovery-write-amplification.ts b/tests/planning/measure-xer-recovery-write-amplification.ts index 5762e3f2b..b28c4b6f0 100644 --- a/tests/planning/measure-xer-recovery-write-amplification.ts +++ b/tests/planning/measure-xer-recovery-write-amplification.ts @@ -1,17 +1,19 @@ /** - * Bewust RODE meetfixture voor het losstaande delta-recoverypakket. + * 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`. * - * Niet als `check-*.ts` benoemd en daarom niet aan `tests/planning/run.sh` toegevoegd: de huidige - * recoverycontracten accepteren volledige `RecoveryDocContent[]` en schrijven iedere aangeleverde - * IFC opnieuw. Deze fixture moet pas groen worden wanneer dat pakket als één geheel verandert. - * - * Implementatieknip: + * 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'; diff --git a/tests/planning/run.sh b/tests/planning/run.sh index a5b3256b0..4454d9693 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -347,6 +347,11 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then 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" @@ -775,6 +780,11 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then 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 + # Recovery-isolatie tussen instanties (bevinding K5). De opruimlogica veegde op PREFIX door de # gedeelde appDataDir. Twee gelijktijdige vensters wisten daarmee elkaars snapshots — en omdat # de productie-base (`recovery`) een prefix is van elke dev-base (`recovery.`), wiste één @@ -793,6 +803,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 + # IFC-round-trip-contract (fase 3, P11, bevinding A2/F2). Twee stappen: # (1) COMPILE-AFDWINGING van de fixture-volledigheid — de hoofd-tsconfig sluit tests/ uit, dus een # eigen tsconfig die de check-batterijen typecheckt (`satisfies Required<...>`); een 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/xerArchiveTestReader.ts b/tests/planning/xerArchiveTestReader.ts new file mode 100644 index 000000000..de10edfbf --- /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 { reconstructXerSourceArchiveFromBytes } from '@/services/xer/xerReader'; + +export function readXerArchiveIFC(content: string, labels: ImportLabels = {}): ImportResult { + return readIFC(content, labels, { reconstructXerArchive: reconstructXerSourceArchiveFromBytes }); +} From 1259787970eeff3118396534abd703e3debbe390 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:25:25 +0200 Subject: [PATCH 061/171] feat(xer): X12-productfidelity en P6-semantiek afschermen --- src/engine/moveProject.ts | 3 +- src/engine/scheduler/CPMSolver.ts | 314 +++- src/engine/scheduler/CalendarEngine.ts | 92 +- src/engine/scheduler/relationMath.ts | 30 +- src/engine/scheduler/scheduleAnalysis.ts | 50 +- src/engine/scheduler/solveProject.ts | 3 + src/extensions/extMappers.ts | 29 +- src/extensions/extTypes.ts | 8 + src/services/ifc/ifcPsets.ts | 4 + src/services/ifc/ifcReader.ts | 143 +- src/services/ifc/ifcWriter.ts | 70 +- src/services/xer/xerCalendarData.ts | 26 + src/services/xer/xerMultiProject.ts | 12 + src/services/xer/xerReader.ts | 193 ++- src/services/xer/xerScheduleOptions.ts | 21 +- src/state/slices/documentSlice.ts | 2 + src/state/slices/projectSlice.ts | 10 +- src/state/slices/scheduleSlice.ts | 2 + src/types/baseline.ts | 6 + src/types/calendar.ts | 20 + src/types/project.ts | 32 +- src/types/sequence.ts | 6 +- tests/planning/check-adapters-hours.ts | 2 + tests/planning/check-ext-contract.ts | 113 +- tests/planning/check-ifc-roundtrip.ts | 137 +- tests/planning/check-mpp-import.ts | 2 + tests/planning/check-xer-calendar-data.ts | 5 +- tests/planning/check-xer-chunk-boundary.ts | 10 +- .../check-xer-fidelity-baseline-schema.ts | 21 +- tests/planning/check-xer-fidelity.ts | 53 +- tests/planning/check-xer-field-whitelist.ts | 32 +- tests/planning/check-xer-multi-project.ts | 10 +- .../check-xer-product-fidelity-x12.ts | 1515 +++++++++++++++++ tests/planning/check-xer-product-fidelity.ts | 4 +- tests/planning/check-xer-reader.ts | 233 ++- .../check-xer-schedule-options-corpus.ts | 74 +- .../check-xer-schedule-options-wiring.ts | 57 +- tests/planning/check-xer-schedule-options.ts | 45 +- tests/planning/fidelityCore.ts | 6 +- tests/planning/run.sh | 7 + tests/planning/xerFidelityTypes.ts | 14 +- tests/planning/xerGroundTruth.ts | 93 +- tests/planning/xerProductFidelity.ts | 199 +++ .../planning/xerScheduleOptionsGroundTruth.ts | 22 +- 44 files changed, 3479 insertions(+), 251 deletions(-) create mode 100644 tests/planning/check-xer-product-fidelity-x12.ts create mode 100644 tests/planning/xerProductFidelity.ts diff --git a/src/engine/moveProject.ts b/src/engine/moveProject.ts index e36791fd0..cfe595703 100644 --- a/src/engine/moveProject.ts +++ b/src/engine/moveProject.ts @@ -169,6 +169,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 @@ -176,7 +177,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; diff --git a/src/engine/scheduler/CPMSolver.ts b/src/engine/scheduler/CPMSolver.ts index 4dacaf62b..10e22e134 100644 --- a/src/engine/scheduler/CPMSolver.ts +++ b/src/engine/scheduler/CPMSolver.ts @@ -94,6 +94,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; } /** @@ -255,6 +258,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 @@ -301,8 +307,20 @@ 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 vlag in generieke IFC/ext-data kan + // daarmee nooit P6-gedrag activeren. De XER-reader is de enige schrijver van `p6Source`. + 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`. @@ -347,6 +365,24 @@ export class CPMSolver { return this.engineForCal(resolveCalendar(task.calendarId, this.registry, this.projectCal)); } + private p6XerOption(value: boolean | undefined): boolean { + return this.options.schedulingOptions?.p6Source === 'XER' && value === true; + } + + /** Smalle P6-XER-resultaatprojectie; de CalendarEngine-primitieven blijven formaatneutraal. */ + private p6ProjectedSubtract(eng: CalendarEngine, end: Date, minutes: number): Date { + return this.options.schedulingOptions?.p6Source === 'XER' + ? eng.subtractP6XerProjectedWorkMinutes(end, minutes) + : eng.subtractWorkMinutes(end, minutes); + } + + /** Smalle P6-XER-projectie voor TF/FF en relationship free float. */ + private projectedWorkMinutesBetween(eng: CalendarEngine, a: Date, b: Date): number { + return this.options.schedulingOptions?.p6Source === 'XER' + ? eng.p6XerProjectedWorkMinutesBetween(a, b) + : eng.workMinutesBetween(a, b); + } + // ═══════════════════════════════════════════════════════════════════════════ // 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 @@ -418,7 +454,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 @@ -435,11 +476,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'; } @@ -633,6 +711,13 @@ export class CPMSolver { } if (eng.isHourMode) { 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 @@ -687,7 +772,13 @@ export class CPMSolver { } if (eng.isHourMode) { const totalMinutes = splitTotalSpanMinutes(task.splitGaps, durationMinutesOf(task, eng)); - const natural = eng.subtractWorkMinutes(end, totalMinutes); + const natural = this.p6ProjectedSubtract(eng, 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 @@ -746,6 +837,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 + ? this.p6ProjectedSubtract(eng, 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 @@ -807,7 +926,22 @@ export class CPMSolver { 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 = this.p6ProjectedSubtract(predEng, 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`). @@ -836,7 +970,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; } // H3 (Opus-review T15-iteratie-2, herbevestigd via msp-30-mutatiebewijs): `isZeroDurationMilestone` // i.p.v. de kale vlag — anders viel een dag-modus mijlpaal-met-duur-ELAPSEDTIME-taak hier stil @@ -864,7 +1005,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); } // H3 (Opus-review T15-iteratie-2) — zelfde reden als `startFromFinish` hierboven. if (!isZeroDurationMilestone(task) && task.time.durationType === 'ELAPSEDTIME') { @@ -881,7 +1026,7 @@ export class CPMSolver { * werkdag-telling. `task` optioneel: afwezig (of WORKTIME) ⇒ exact de oude twee takken. */ private signedFloat(a: Date, b: Date, eng: CalendarEngine, task?: Task): number { if (task?.time.durationType === 'ELAPSEDTIME') return signedElapsedSpan(a, b, eng); - if (eng.isHourMode) return eng.workMinutesBetween(a, b) / (eng.hoursPerDay * 60); + if (eng.isHourMode) return this.projectedWorkMinutesBetween(eng, a, b) / (eng.hoursPerDay * 60); return this.signedWorkDays(a, b, eng); } @@ -958,6 +1103,7 @@ export class CPMSolver { projectEngine: this.projectEngine, calendarFor: (t) => this.calendarFor(t), signedFloat: (a, b, eng, task) => this.signedFloat(a, b, eng, task), + projectedWorkMinutesBetween: (eng, a, b) => this.projectedWorkMinutesBetween(eng, a, b), constraintInstant: (c, eng) => this.constraintInstant(c, eng), snapOnOrAfter: (eng, d) => this.snapOnOrAfter(eng, d), snapOnOrBefore: (eng, d) => this.snapOnOrBefore(eng, d), @@ -1314,6 +1460,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; @@ -1325,9 +1472,26 @@ 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 (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. @@ -1348,7 +1512,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; @@ -1456,16 +1628,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. @@ -1510,7 +1690,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: uur ⇒ `remainingMinutes ?? durationMinutes × (1−completion)`; dag ⇒ werkdagen (§5.3). const totalSpan = progressCal.isHourMode ? durationMinutesOf(task, progressCal) : t.scheduleDuration; const remaining = progressCal.isHourMode @@ -1903,7 +2088,17 @@ export class CPMSolver { // 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- @@ -1970,6 +2165,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 }); } @@ -2001,6 +2210,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; } @@ -2027,6 +2237,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; @@ -2323,7 +2534,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; @@ -2533,7 +2748,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)), + ), ); } } @@ -2560,18 +2778,25 @@ export class CPMSolver { for (const { ef } of earlyDates.values()) { if (ef > projectEnd) projectEnd = ef; } + if (this.options.schedulingOptions?.useProjectEndDateForFloat === true) { + const configuredProjectEnd = parseInstant(this.options.projectEndDate ?? ''); + if (!isNaN(configuredProjectEnd.getTime())) { + projectEnd = this.snapOnOrBefore(this.projectEngine, configuredProjectEnd); + } + } // 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) || []; - // P6 houdt een voltooide activiteit op haar geregistreerde venster, ook aan de late zijde. - // Brongebonden achter een expliciete vlag: niet-XER-projecten behouden hun bestaande gedrag. + // Brongebonden XER-beleid: een voltooide activiteit houdt haar geregistreerde actual-window + // aan de late zijde. Dit leest uitsluitend toegestane actuals; opgeslagen XER early/late/ + // float-uitkomsten zijn bewust nooit solverinvoer. Niet-XER-projecten behouden hun bestaande + // gedrag. De raw-XER-meetlat bewaakt deze beleidskeuze afzonderlijk van haar bronuitkomsten. if (preserveActualDates && task.time.actualFinish && task.time.completion >= 1) { const ed = earlyDates.get(taskId)!; results.set(taskId, { ls: new Date(ed.es.getTime()), lf: new Date(ed.ef.getTime()) }); @@ -2629,6 +2854,23 @@ 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)!; + 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 @@ -2673,6 +2915,7 @@ export class CPMSolver { }; const constraintDate = backwardConstraint( this.relDeps, delayShiftedSuccResult, seq, task, succTask, predCal, succCal, + this.p6ZeroDurationUsesFinishBoundary(succTask, succCal), ); if (constraintDate < lateFinish) { lateFinish = constraintDate; @@ -2681,15 +2924,38 @@ export class CPMSolver { // 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 computedLateStart = 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. - const lateStart = preserveActualDates && task.time.actualStart && task.time.completion < 1 + 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); + } results.set(taskId, { ls: lateStart, lf: lateFinish }); } diff --git a/src/engine/scheduler/CalendarEngine.ts b/src/engine/scheduler/CalendarEngine.ts index a00769ec3..0424964aa 100644 --- a/src/engine/scheduler/CalendarEngine.ts +++ b/src/engine/scheduler/CalendarEngine.ts @@ -40,7 +40,10 @@ export class CalendarEngine { // Val-kuil (plan §T2): een werkende uitzondering op een NIET-werk-weekdag (bv. zaterdag) zit niet in // `workDaysPerWeek`/`countWorkWeekdays` (die kennen alleen het vaste weekpatroon) en moet dus als // EXTRA werkdag worden opgeteld in `workDaysBetween` — vandaar een eigen gesorteerde index. - private workingExceptionOnNonWorkWeekdayIdxSorted: number[]; + private workingExceptionOnNonWorkWeekdayIdxSorted: number[]; + // Alleen data voor de EXPLICIETE P6-XER-projectiemethoden onderaan. De generieke kalender- + // algebra leest deze index nergens en blijft daardoor fysiek en volledig invers. + private p6NonWorkPenaltyDayIdxSorted: number[]; // Veiligheidsgrenzen tegen vastlopen bij een kapotte kalender (geen werkdagen) // of een ongeldige/sentinel-datum: MAX_SCAN = max dagen zoeken naar een werkdag; // MAX_DAYS = absolute iteratielimiet (~547 jaar) voor de tel-lussen. @@ -92,9 +95,14 @@ export class CalendarEngine { .filter((idx) => this.workDayMask[isoDayOfWeek(new Date(idx * CalendarEngine.MS_PER_DAY))] && !this.workingExceptionDaySet.has(idx)) .sort((a, b) => a - b); - this.workingExceptionOnNonWorkWeekdayIdxSorted = [...this.workingExceptionDaySet] - .filter((idx) => !this.workDayMask[isoDayOfWeek(new Date(idx * CalendarEngine.MS_PER_DAY))]) - .sort((a, b) => a - b); + this.workingExceptionOnNonWorkWeekdayIdxSorted = [...this.workingExceptionDaySet] + .filter((idx) => !this.workDayMask[isoDayOfWeek(new Date(idx * CalendarEngine.MS_PER_DAY))]) + .sort((a, b) => a - b); + this.p6NonWorkPenaltyDayIdxSorted = calendar.p6Source === 'XER' + ? [...new Set((calendar.p6NonWorkPenaltyDates ?? []).map(date => + Math.floor(parseDate(date).getTime() / CalendarEngine.MS_PER_DAY), + ).filter(Number.isFinite))].sort((a, b) => a - b) + : []; // ── Fase 2.8b: modus-detectie + uur-setup (§4.1). Afwezige `workTime` ⇒ dag-modus: // dan wordt niets hieronder geraakt en draaien de bevroren dag-lussen ongewijzigd. this.mode = calendar.workTime ? 'hour' : 'day'; @@ -322,10 +330,10 @@ export class CalendarEngine { * vaste weekpatroon) NIET meetelt en die hier dus als EXTRA werkdag worden opgeteld. Een werkende * uitzondering op een reeds-werkende weekdag zit al in `countWorkWeekdays` en staat daarom niet in * `workingExceptionOnNonWorkWeekdayIdxSorted` (gefilterd in de constructor) — geen dubbeltelling. */ - private countWorkingExceptionsAddedInRange(startIdx: number, lastIdx: number): number { - return this.countSortedIdxInRange(this.workingExceptionOnNonWorkWeekdayIdxSorted, startIdx, lastIdx); - } - + private countWorkingExceptionsAddedInRange(startIdx: number, lastIdx: number): number { + return this.countSortedIdxInRange(this.workingExceptionOnNonWorkWeekdayIdxSorted, startIdx, lastIdx); + } + /** * Get the next working day on or after the given date. */ @@ -708,8 +716,13 @@ export class CalendarEngine { * 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 { - if (minutes <= 0) return new Date(startInstant.getTime()); + addWorkMinutes(startInstant: Date, minutes: number): Date { + return this.addPhysicalWorkMinutes(startInstant, minutes); + } + + /** Bestaande fysieke bandwandeling, zonder P6-bronpenalty. */ + 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(); let steps = 0; @@ -731,8 +744,13 @@ 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 { - if (minutes <= 0) return new Date(endInstant.getTime()); + subtractWorkMinutes(endInstant: Date, minutes: number): Date { + return this.subtractPhysicalWorkMinutes(endInstant, minutes); + } + + /** Bestaande fysieke achterwaartse bandwandeling, zonder P6-bronpenalty. */ + 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(); let steps = 0; @@ -754,8 +772,54 @@ export class CalendarEngine { /** Getekende werkminuten in `[a,b)` (§5.5, voor vrije speling). Positief als b>a, negatief als * b 0 ? 1 : -1; + const adjusted = Math.max(0, Math.abs(physical) - this.p6NonWorkPenaltyMinutesBetween(a, b)); + return sign * adjusted; + } + + /** Virtuele extra P6-nietwerktijd in het datum-inclusieve venster tussen twee instants. */ + private p6NonWorkPenaltyMinutesBetween(a: Date, b: Date): number { + if (this.p6NonWorkPenaltyDayIdxSorted.length === 0) return 0; + const aIdx = Math.floor(a.getTime() / CalendarEngine.MS_PER_DAY); + const bIdx = Math.floor(b.getTime() / CalendarEngine.MS_PER_DAY); + const startIdx = Math.min(aIdx, bIdx); + const lastIdx = Math.max(aIdx, bIdx); + return this.countSortedIdxInRange(this.p6NonWorkPenaltyDayIdxSorted, startIdx, lastIdx) + * this.hoursPerDay * 60; + } + + /** Bestaande fysieke bandminuten in `[a,b)`, zonder P6-bronpenalty. */ + private physicalWorkMinutesBetween(a: Date, b: Date): number { + const aMs = a.getTime(); const bMs = b.getTime(); if (aMs === bMs) return 0; const sign = bMs > aMs ? 1 : -1; diff --git a/src/engine/scheduler/relationMath.ts b/src/engine/scheduler/relationMath.ts index 36a3d72e5..98ad5dd64 100644 --- a/src/engine/scheduler/relationMath.ts +++ b/src/engine/scheduler/relationMath.ts @@ -51,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 @@ -83,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', }; } @@ -204,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); } @@ -280,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); } @@ -301,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); } @@ -704,6 +714,11 @@ 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); @@ -869,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). diff --git a/src/engine/scheduler/scheduleAnalysis.ts b/src/engine/scheduler/scheduleAnalysis.ts index da3ec67ff..df55a134b 100644 --- a/src/engine/scheduler/scheduleAnalysis.ts +++ b/src/engine/scheduler/scheduleAnalysis.ts @@ -6,6 +6,7 @@ import type { CPMResult, CPMTaskResult } from './CPMSolver'; import { parseDate, formatInstant, type DateMode } from '@/utils/dateUtils'; import { traceFrom } from './graphWalk'; import { projectDurationOf } from './projectDuration'; +import { isZeroDurationMilestone } from './duration'; /** * Invoer voor de resultaat-post-pass (`computeScheduleResults`). Puur data + een handvol @@ -38,6 +39,8 @@ export interface ScheduleAnalysisInput { /** `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. */ + projectedWorkMinutesBetween: (eng: CalendarEngine, a: Date, b: Date) => number; constraintInstant: (c: TaskConstraint | undefined, eng: CalendarEngine) => Date | null; snapOnOrAfter: (eng: CalendarEngine, d: Date) => Date; snapOnOrBefore: (eng: CalendarEngine, d: Date) => Date; @@ -56,7 +59,8 @@ export function computeScheduleResults(input: ScheduleAnalysisInput): CPMResult schedulingOptions, dataDate, truncatedLeadIds, hardPinViolatedIds, hammockNoFinishDriverIds, projectEngine, - calendarFor, signedFloat, constraintInstant, snapOnOrAfter, snapOnOrBefore, modeOf, + calendarFor, signedFloat, projectedWorkMinutesBetween, + constraintInstant, snapOnOrAfter, snapOnOrBefore, modeOf, } = input; const taskResults = new Map(); @@ -116,7 +120,7 @@ export function computeScheduleResults(input: ScheduleAnalysisInput): CPMResult const succCal = calendarFor(succTask); const reqStart = snapOnOrAfter(succCal, cRaw); const relFloat = succCal.isHourMode - ? succCal.workMinutesBetween(reqStart, succEarly.es) / (succCal.hoursPerDay * 60) + ? projectedWorkMinutesBetween(succCal, reqStart, succEarly.es) / (succCal.hoursPerDay * 60) : succCal.workDaysBetween(reqStart, succEarly.es) - 1; sequenceFreeFloat[seq.id] = relFloat; if (relFloat === 0) drivingSequenceIds.push(seq.id); @@ -148,6 +152,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()) { @@ -192,6 +203,26 @@ 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); + } + // 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; // 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 @@ -244,12 +275,12 @@ export function computeScheduleResults(input: ScheduleAnalysisInput): CPMResult tf = 0; freeFloat = 0; } - // P6/XER bewaart bij voltooid werk geen floatwaarde: de actuals zijn feiten, geen nog - // verschuifbaar venster. Dit voorkomt ook een kunstmatige negatieve FF wanneer een latere - // out-of-sequence-actual vóór de historische relatiegrens ligt. Alleen de expliciete - // bronvlag activeert dit; de algemene analyse-default blijft ongewijzigd. + // 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) { - tf = 0; freeFloat = 0; } // P6/XER houdt vrije float op nul wanneer een late constraint de totale float negatief maakt. @@ -315,11 +346,12 @@ 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 displayActualLate = completed && so?.preserveActualDatesInBackwardPass === true; taskResults.set(taskId, { earlyStart: formatInstant(early.es, mode), earlyFinish: formatInstant(early.ef, mode), - lateStart: formatInstant(late.ls, mode), - lateFinish: formatInstant(late.lf, mode), + lateStart: formatInstant(displayActualLate ? early.es : late.ls, mode), + lateFinish: formatInstant(displayActualLate ? early.ef : late.lf, mode), totalFloat: tf, freeFloat, isCritical, diff --git a/src/engine/scheduler/solveProject.ts b/src/engine/scheduler/solveProject.ts index dfb2fa0f5..956539292 100644 --- a/src/engine/scheduler/solveProject.ts +++ b/src/engine/scheduler/solveProject.ts @@ -48,6 +48,8 @@ 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; } /** @@ -79,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/extensions/extMappers.ts b/src/extensions/extMappers.ts index 39f7b5c7f..ab61aece2 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 { @@ -162,7 +168,7 @@ export function toExtProject(p: Project): ExtProject { wbsAutoNumber: p.wbsAutoNumber, statusDate: p.statusDate, progressMode: p.progressMode, - schedulingOptions: p.schedulingOptions ? copySchedulingOptions(p.schedulingOptions) : undefined, + schedulingOptions: p.schedulingOptions ? publicSchedulingOptions(p.schedulingOptions) : undefined, }; } @@ -181,7 +187,7 @@ export function fromExtProject(p: ExtProject): Project { wbsAutoNumber: p.wbsAutoNumber, statusDate: p.statusDate, progressMode: p.progressMode, - schedulingOptions: p.schedulingOptions ? copySchedulingOptions(p.schedulingOptions) : undefined, + schedulingOptions: p.schedulingOptions ? publicSchedulingOptions(p.schedulingOptions) : undefined, }; } @@ -200,6 +206,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, }; } @@ -571,6 +579,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 2ba13d84d..6fd01bdf2 100644 --- a/src/extensions/extTypes.ts +++ b/src/extensions/extTypes.ts @@ -97,6 +97,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 ── @@ -274,6 +279,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 ── diff --git a/src/services/ifc/ifcPsets.ts b/src/services/ifc/ifcPsets.ts index 84d28c5f1..18431d453 100644 --- a/src/services/ifc/ifcPsets.ts +++ b/src/services/ifc/ifcPsets.ts @@ -88,6 +88,10 @@ export const PSET = { 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). diff --git a/src/services/ifc/ifcReader.ts b/src/services/ifc/ifcReader.ts index 078e56a1a..5f537c241 100644 --- a/src/services/ifc/ifcReader.ts +++ b/src/services/ifc/ifcReader.ts @@ -136,7 +136,10 @@ export function readIFC( // taken (fase 2.6, §8.3) — sla ze over (robuust tegen externe tools; OPS zelf hangt er geen op). const baselineTaskStepIds = collectBaselineTaskStepIds(entities); const { tasks, taskStepIdMap, taskTimeEntities, recordedFields } = extractTasks(entities, entityMap, baselineTaskStepIds); - 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); @@ -1106,6 +1109,7 @@ function extractSequences( entities: StepEntity[], entityMap: Map, taskStepIdMap: Map, + p6BoundarySequenceGuids: ReadonlySet, ): Sequence[] { const seqEntities = entities.filter(e => e.type === 'IFCRELSEQUENCE'); const sequences: Sequence[] = []; @@ -1194,12 +1198,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 { @@ -1710,11 +1775,16 @@ function extractCalendarHoursPerDay( * 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] || ''); @@ -1726,19 +1796,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 @@ -1828,7 +1945,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[] = []; @@ -1887,6 +2005,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- diff --git a/src/services/ifc/ifcWriter.ts b/src/services/ifc/ifcWriter.ts index 76734c070..537ee8559 100644 --- a/src/services/ifc/ifcWriter.ts +++ b/src/services/ifc/ifcWriter.ts @@ -263,10 +263,11 @@ export function writeIFC(input: WriteIFCInput): string { `IFCRELNESTS(${ifcStr(guidOf(ctx, 'nest_root'))},#${ownerHistId},'WBS Hoofd',$,#${workSchedId},(${rootRefs}))`); } - // Sequences - for (const seq of sequences) { - writeSequence(ctx, seq, ownerHistId); - } + // Sequences + for (const seq of sequences) { + writeSequence(ctx, seq, ownerHistId); + } + writeSequenceMeta(ctx, workSchedId, sequences, ownerHistId); // Resources for (const res of resources) { @@ -803,10 +804,13 @@ function writeCalendarGenerationMeta( workingExceptionStepIds: number[], ): void { const gen = cal.generation; - const derivedHoursPerDay = cal.workEndHour - cal.workStartHour; - const needsHoursPerDayOverride = !cal.workTime && cal.hoursPerDay !== derivedHoursPerDay; - const hasWorkingExceptions = workingExceptionStepIds.length > 0; - if (!gen && !cal.libraryOrigin && !needsHoursPerDayOverride && !hasWorkingExceptions) return; + 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'; + if (!gen && !cal.libraryOrigin && !needsHoursPerDayOverride + && !hasWorkingExceptions && !hasP6Source && !hasRejectedPenaltyDiagnostic) return; const props: number[] = []; if (gen) { props.push(addLine(ctx, `_opscal_ruleset_${cal.id}`, @@ -832,11 +836,23 @@ function writeCalendarGenerationMeta( props.push(addLine(ctx, `_opscal_hpd_${cal.id}`, `IFCPROPERTYSINGLEVALUE('HoursPerDay',$,IFCREAL(${cal.hoursPerDay}),$)`)); } - if (hasWorkingExceptions) { + if (hasWorkingExceptions) { const idJson = JSON.stringify(workingExceptionStepIds.map(String)); props.push(addLine(ctx, `_opscal_wexc_${cal.id}`, - `IFCPROPERTYSINGLEVALUE('WorkingExceptionIds',$,IFCTEXT(${ifcStr(idJson)}),$)`)); - } + `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}`, @@ -963,7 +979,7 @@ function ifcLagValue(seq: Sequence): string { return d < 0 ? `IFCDURATION('-P${-d}D')` : `IFCDURATION('P${d}D')`; } -function writeSequence(ctx: WriteContext, seq: Sequence, ownerHistId: number): void { +function writeSequence(ctx: WriteContext, seq: Sequence, ownerHistId: number): void { let lagRef = '$'; const hasPercent = typeof seq.lagPercent === 'number' && Number.isFinite(seq.lagPercent); const hasMinutes = typeof seq.lagMinutes === 'number' && Number.isFinite(seq.lagMinutes) && seq.lagMinutes !== 0; @@ -977,9 +993,33 @@ function writeSequence(ctx: WriteContext, seq: Sequence, ownerHistId: number): v lagRef = `#${lagId}`; } - addLine(ctx, `seq_${seq.id}`, - `IFCRELSEQUENCE(${ifcStr(guidOf(ctx, seq.id))},#${ownerHistId},$,$,${ref(ctx, `task_${seq.predecessorId}`)},${ref(ctx, `task_${seq.successorId}`)},${lagRef},.${seq.type}.,$)`); -} + addLine(ctx, `seq_${seq.id}`, + `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). diff --git a/src/services/xer/xerCalendarData.ts b/src/services/xer/xerCalendarData.ts index dc60ebc62..aa6066c14 100644 --- a/src/services/xer/xerCalendarData.ts +++ b/src/services/xer/xerCalendarData.ts @@ -40,6 +40,7 @@ export interface DecodedXerCalendarData { bands: WorkTimeBands; holidays: Holiday[]; workingExceptions: WorkingException[]; + p6NonWorkPenaltyDates: string[]; hasExplicitClockBands: boolean; deviates: boolean; recoveries: XerCalendarRecovery[]; @@ -486,6 +487,7 @@ export function decodeXerCalendarData(text: string): DecodedXerCalendarData { bands: defaultXerBands(), holidays: [], workingExceptions: [], + p6NonWorkPenaltyDates: [], hasExplicitClockBands: false, deviates: false, recoveries: [], @@ -535,7 +537,9 @@ export function decodeXerCalendarData(text: string): DecodedXerCalendarData { const holidaysByDate = new Map(); const workingByDate = new Map(); + const p6NonWorkPenaltyDates = 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)) { @@ -543,6 +547,21 @@ export function decodeXerCalendarData(text: string): DecodedXerCalendarData { 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); + } + previousNonWorkDate = date; + } else { + previousNonWorkDate = undefined; + } if (holidaysByDate.has(date) || workingByDate.has(date)) duplicateException = true; if (exceptionBands.length > 0) { holidaysByDate.delete(date); @@ -555,12 +574,16 @@ export function decodeXerCalendarData(text: string): DecodedXerCalendarData { holidaysByDate.set(date, { name: 'Kalenderuitzondering', 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); if (duplicateException) recoveries.push('DUPLICATE_EXCEPTION'); return { bands, holidays: Array.from(holidaysByDate.values()), workingExceptions: Array.from(workingByDate.values()), + p6NonWorkPenaltyDates: Array.from(p6NonWorkPenaltyDates).sort(), hasExplicitClockBands, deviates: hasExplicitClockBands && canonical.deviates, recoveries, @@ -674,6 +697,9 @@ export function readXerCalendars(tables: XerTables): XerCalendarReadResult { ...(decoded.workingExceptions.length > 0 ? { workingExceptions: decoded.workingExceptions } : {}), + ...(explicitDay === undefined && decoded.p6NonWorkPenaltyDates.length > 0 + ? { p6Source: 'XER' as const, p6NonWorkPenaltyDates: decoded.p6NonWorkPenaltyDates } + : {}), calendarType: calendarType(rawCalendarType), rawCalendarType, hoursPerWeek, diff --git a/src/services/xer/xerMultiProject.ts b/src/services/xer/xerMultiProject.ts index 21c1088d9..b92015827 100644 --- a/src/services/xer/xerMultiProject.ts +++ b/src/services/xer/xerMultiProject.ts @@ -20,6 +20,11 @@ export type { XerBaselineFallbackReason, XerDocumentExternalLink } from '@/servi 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[]; @@ -170,6 +175,8 @@ function materializeBaseline( 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, @@ -180,6 +187,7 @@ function materializeBaseline( 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, @@ -333,6 +341,10 @@ export function assembleXerMultiProjectImport( ? -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 index 5be4873d7..235d36d71 100644 --- a/src/services/xer/xerReader.ts +++ b/src/services/xer/xerReader.ts @@ -11,7 +11,13 @@ import type { ImportResult, XerEnumFallback, XerExternalRelation, XerImportMetadata, } from '@/services/importTypes'; -import { getCalendarBands, promoteHourCalendar } from '@/services/subdayIo'; +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 { @@ -220,6 +226,79 @@ function sourceInstant(raw: string, hourMode: boolean): string | undefined { return formatInstant(parsed, hourMode ? 'hour' : 'day'); } +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)); @@ -490,7 +569,11 @@ function readXerProject( 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), @@ -498,6 +581,10 @@ function readXerProject( false, ); } + const calendarEngines = new Map(calendarList.map(calendar => [ + calendar.id, + new CalendarEngine(calendar), + ] as const)); const enumFallbacks: XerEnumFallback[] = []; const projectDefaultDuration = durationTypeOf( @@ -511,13 +598,30 @@ function readXerProject( for (const row of activityRows) { const effectiveCalendar = calendarById.get(row.cells.clndr_id) ?? projectCalendar; const hourMode = effectiveCalendar.workTime !== undefined; - const start = sourceInstant(row.cells.target_start_date ?? '', hourMode) + let start = sourceInstant(row.cells.target_start_date ?? '', hourMode) ?? sourceInstant(projectRow.cells.last_recalc_date ?? '', hourMode) ?? '1970-01-01'; - const finish = sourceInstant(row.cells.target_end_date ?? '', hourMode) ?? start; + let finish = sourceInstant(row.cells.target_end_date ?? '', hourMode) ?? 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 durationMinutes = Math.round(durationHours * 60); + 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( @@ -535,10 +639,20 @@ function readXerProject( // 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' - ? numberOf(tables, row, 'complete_pct') ?? 0 + ? 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 @@ -546,6 +660,22 @@ function readXerProject( ? 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; const time = createDefaultTaskTime(start, durationMinutes / (hoursPerDay * 60)); time.scheduleFinish = finish; time.earlyStart = start; @@ -554,11 +684,14 @@ function readXerProject( 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); + if (p6RemainingHours !== null) { + time.remainingMinutes = Math.round(p6RemainingHours * 60); + } } else if (p6RemainingHours !== null) { time.remainingTime = p6RemainingHours / hoursPerDay; } @@ -618,11 +751,27 @@ function readXerProject( }); } + const projectHourMode = projectCalendar.workTime !== undefined; + const derivedSchedule = deriveXerScheduleOptions(scheduleOptionsIndex, projectId, { + hoursPerDay: projectCalendar.hoursPerDay, + taskCount: mappedActivities.length, + }); + const { + progressMode, + schedulingOptions, + ...scheduleOptionsMetadata + } = derivedSchedule; 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 projectEnd = finishes[finishes.length - 1] ?? projectStart; - const projectHourMode = projectCalendar.workTime !== undefined; + 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 = sourceInstant(projectRow.cells.last_recalc_date ?? '', projectHourMode); const wbsRows = stableWbsRows(rawWbsRows, projectId); @@ -693,6 +842,21 @@ function readXerProject( 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, @@ -700,6 +864,9 @@ function readXerProject( type: relationType.sequence, lagMinutes, lagDays: lagMinutes / (projectCalendar.hoursPerDay * 60), + ...(p6StartAtPredecessorFinishBoundary + ? { p6StartAtPredecessorFinishBoundary: true } + : {}), }); } else if (successorLocal && predecessorProjectId !== projectId) { externalRelations.push({ @@ -726,16 +893,6 @@ function readXerProject( } } - const derivedSchedule = deriveXerScheduleOptions(scheduleOptionsIndex, projectId, { - hoursPerDay: projectCalendar.hoursPerDay, - taskCount: mappedActivities.length, - }); - const { - progressMode, - schedulingOptions, - ...scheduleOptionsMetadata - } = derivedSchedule; - return { project: { id: projectId, diff --git a/src/services/xer/xerScheduleOptions.ts b/src/services/xer/xerScheduleOptions.ts index 6e51e1268..e563eb594 100644 --- a/src/services/xer/xerScheduleOptions.ts +++ b/src/services/xer/xerScheduleOptions.ts @@ -92,8 +92,8 @@ export const XER_SCHEDOPTIONS_COLUMN_DISPOSITIONS: readonly XerScheduleOptionCol { field: 'sched_use_expect_end_flag', status: 'mapped', target: 'schedulingOptions.useExpectedFinishDates' }, { field: 'sched_use_project_end_date_for_float', - status: 'todo', - reason: 'Meerdere gelijktijdig geplande projecten ontbreken; X5 bewaart de bronwaarde zonder solversturing.', + 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.' }, @@ -109,6 +109,7 @@ export const XER_SCHEDOPTIONS_COLUMN_DISPOSITIONS: readonly XerScheduleOptionCol export const XER_SCHEDULING_DEFAULTS = { progressMode: 'RETAINED_LOGIC', schedulingOptions: { + p6Source: 'XER', lagCalendar: 'predecessor', criticalDefinition: { mode: 'totalFloat', thresholdHours: 0 }, totalFloatMode: 'finish', @@ -116,6 +117,12 @@ export const XER_SCHEDULING_DEFAULTS = { useExpectedFinishDates: true, preserveActualDatesInBackwardPass: true, clampNegativeFreeFloat: true, + p6ZeroDurationUsesPlannedBoundary: true, + p6UseTaskPlannedStartFloor: true, + p6FinishMilestoneBoundaryWindow: true, + p6PreserveActualInstants: true, + p6UseRemainingStartForProgress: false, + p6PreserveZeroDurationConstraintInstants: true, }, } as const satisfies { progressMode: ProgressMode; schedulingOptions: SchedulingOptions }; @@ -311,6 +318,13 @@ export function deriveXerScheduleOptions( context: { hoursPerDay?: number; taskCount?: number } = {}, ): XerScheduleOptionsResult { const defaults = freshDefaults(); + const projectRow = index.projectRowsById.get(projectId)?.row; + // 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 = + projectRow?.cells.rem_target_link_flag?.trim().toUpperCase() === 'Y'; const fallbacks: XerScheduleOptionFallback[] = []; const sourceRowIndexes = [...(index.sourceRowIndexesByProject.get(projectId) ?? [])]; const retainedRows = sourceRowIndexes.map(rowIndex => index.sourceArchive.rows[rowIndex]); @@ -363,6 +377,9 @@ export function deriveXerScheduleOptions( const retainedSource = retainedProjectEndValue === undefined ? {} : { sched_use_project_end_date_for_float: retainedProjectEndValue }; + if (retainedProjectEndValue !== undefined) { + schedulingOptions.useProjectEndDateForFloat = retainedProjectEndValue; + } const progressMode = progressModeValue(row, fallbacks); diff --git a/src/state/slices/documentSlice.ts b/src/state/slices/documentSlice.ts index e309c0b72..18c31dfca 100644 --- a/src/state/slices/documentSlice.ts +++ b/src/state/slices/documentSlice.ts @@ -541,6 +541,8 @@ export const createDocumentSlice: AppSlice = (set, get) => ({ 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. diff --git a/src/state/slices/projectSlice.ts b/src/state/slices/projectSlice.ts index 5436f948b..c3b25ae41 100644 --- a/src/state/slices/projectSlice.ts +++ b/src/state/slices/projectSlice.ts @@ -362,7 +362,9 @@ export const createProjectSlice: AppSlice = (set, get) => ({ // 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 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, @@ -372,6 +374,7 @@ export const createProjectSlice: AppSlice = (set, get) => ({ // 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 +402,14 @@ export const createProjectSlice: AppSlice = (set, get) => ({ } 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/scheduleSlice.ts b/src/state/slices/scheduleSlice.ts index ae693b25b..582d3943c 100644 --- a/src/state/slices/scheduleSlice.ts +++ b/src/state/slices/scheduleSlice.ts @@ -119,6 +119,7 @@ export const createScheduleSlice: AppSlice = (set, get) => ({ // 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 @@ -244,6 +245,7 @@ export const createScheduleSlice: AppSlice = (set, get) => ({ // 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/types/baseline.ts b/src/types/baseline.ts index 2572dac6b..0d803327d 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) @@ -14,6 +18,8 @@ export interface BaselineTask { /** 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 79011d61e..67e54fe00 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; @@ -28,6 +32,22 @@ 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'; + /** + * Alleen voor een kalender met `p6Source: 'XER'` en een door P6 zelf anders getelde, redundante vrije-dagrecord: + * een vrije uitzondering op een al niet-werkende weekdag, of een direct aangrenzende herhaling + * van dezelfde vrije datum. P6 laat zo'n record als één extra niet-werkdag meewegen in uurduur- + * en floatwandelingen, hoewel de kalenderdatum zelf al niet werkt. De XER-reader bewaart alleen + * de unieke brondata die dit gedrag dragen; afwezig is de algemene kalendersemantiek byte-identiek. + */ + 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 a9e2b476d..04421256f 100644 --- a/src/types/project.ts +++ b/src/types/project.ts @@ -7,7 +7,10 @@ export type ProgressMode = 'RETAINED_LOGIC' | 'PROGRESS_OVERRIDE'; * 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 { +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' = de huidige * LAG_CALENDAR-constante (lagCalendar.ts) ⇒ byte-identiek. */ lagCalendar?: 'predecessor' | 'successor' | '24hour' | 'projectDefault'; @@ -34,6 +37,33 @@ export interface SchedulingOptions { /** 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). */ 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/tests/planning/check-adapters-hours.ts b/tests/planning/check-adapters-hours.ts index 4e562fd0a..8520a5728 100644 --- a/tests/planning/check-adapters-hours.ts +++ b/tests/planning/check-adapters-hours.ts @@ -130,6 +130,8 @@ function roundTrip(label: string, tk: Task[], seq: Sequence[], cal: WorkCalendar 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]); } // ── Review-follow-up (2026-08, op bugfix B1): GEMENGDE ISO-duur uit een VREEMD bestand ───────────── diff --git a/tests/planning/check-ext-contract.ts b/tests/planning/check-ext-contract.ts index d76c17c7b..7aac8ec0b 100644 --- a/tests/planning/check-ext-contract.ts +++ b/tests/planning/check-ext-contract.ts @@ -75,7 +75,7 @@ const EXT_PROJECT_KEYS = keys()([ const EXT_CALENDAR_KEYS = keys()([ 'id', 'name', 'description', 'workDays', 'workStartHour', 'workEndHour', 'hoursPerDay', - 'holidays', 'workTime', 'shift', 'workingExceptions', + 'holidays', 'workTime', 'shift', 'workingExceptions', 'p6Source', 'p6NonWorkPenaltyDates', ] as const); const EXT_TASK_TIME_KEYS = keys()([ @@ -101,6 +101,7 @@ const EXT_TASK_KEYS = keys()([ const EXT_SEQUENCE_KEYS = keys()([ 'id', 'predecessorId', 'successorId', 'type', 'lagDays', 'lagMinutes', 'lagUnit', 'lagPercent', + 'p6StartAtPredecessorFinishBoundary', ] as const); const EXT_RESOURCE_KEYS = keys()([ @@ -121,7 +122,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[], @@ -129,6 +132,24 @@ 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: [] 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 = { @@ -234,14 +255,76 @@ 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 VOL_RESOURCE = { id: 'r1', name: 'Kraan', type: 'EQUIPMENT', description: 'omschrijving', costPerHour: 120, availability: 1, maxUnits: 2, calendarId: 'cal2', @@ -299,25 +382,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. @@ -337,11 +420,13 @@ 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)); + fromExtCalendar(toExtCalendar(VOL_CALENDAR)), stripNietPubliek(VOL_CALENDAR, [...NIET_PUBLIEK.calendar, ...LEES_ALLEEN_EXT.calendar])); eq('21 taak round-trip', fromExtTask(toExtTask(VOL_TASK)), VOL_TASK); - eq('22 relatie round-trip', fromExtSequence(toExtSequence(VOL_SEQUENCE)), VOL_SEQUENCE); + 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); diff --git a/tests/planning/check-ifc-roundtrip.ts b/tests/planning/check-ifc-roundtrip.ts index 0e0cbe6d7..b096e45cf 100644 --- a/tests/planning/check-ifc-roundtrip.ts +++ b/tests/planning/check-ifc-roundtrip.ts @@ -70,6 +70,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'; @@ -135,6 +136,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'>; @@ -146,6 +150,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'>; @@ -159,6 +166,9 @@ const _CALENDAR_FIELD_WITNESS = { id: 'w', name: 'w', description: 'w', workDays: [1, 2, 3, 4, 5], workStartHour: 8, workEndHour: 16, hoursPerDay: 8, holidays: [], 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: [] } }, @@ -308,7 +318,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 }, @@ -317,6 +327,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; @@ -350,6 +361,7 @@ const assignments: ResourceAssignment[] = [ // ── Project incl. schedulingOptions/statusDate/progressMode/wbsAutoNumber ───────────────────────── const SCHED_OPTS = { + p6Source: 'XER', lagCalendar: 'successor', criticalDefinition: { mode: 'longestPath', threshold: -1, thresholdHours: -8 }, totalFloatMode: 'finish', @@ -357,6 +369,13 @@ const SCHED_OPTS = { useExpectedFinishDates: false, preserveActualDatesInBackwardPass: true, clampNegativeFreeFloat: true, + p6ZeroDurationUsesPlannedBoundary: true, + p6UseTaskPlannedStartFloor: true, + p6FinishMilestoneBoundaryWindow: true, + p6PreserveActualInstants: true, + p6UseRemainingStartForProgress: true, + p6PreserveZeroDurationConstraintInstants: 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 @@ -487,6 +506,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, @@ -584,6 +606,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 = { @@ -622,12 +645,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))), @@ -733,6 +757,115 @@ const expectedInput: ImportResult = { ...fixture, resourceCalendars: (fixture.re const rt1 = readIFC(writeIFC(fixture)); 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'); +} + +{ + 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); diff --git a/tests/planning/check-mpp-import.ts b/tests/planning/check-mpp-import.ts index 5cfdc6223..19acfc3bf 100644 --- a/tests/planning/check-mpp-import.ts +++ b/tests/planning/check-mpp-import.ts @@ -1169,6 +1169,8 @@ const PROPSKEY_ASSIGNMENT_FIELD_MAP = 131095; 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-xer-calendar-data.ts b/tests/planning/check-xer-calendar-data.ts index 6687b8557..b9dfcfc92 100644 --- a/tests/planning/check-xer-calendar-data.ts +++ b/tests/planning/check-xer-calendar-data.ts @@ -104,8 +104,9 @@ eq('4 uitzondering met AM/PM-uren is een werkende uitzondering', decoded.working }]); eq('5 gelezen klokbanden dragen het XER-uursignaal', decoded.hasExplicitClockBands, true); -// Breuk die dit vangt: een lege clndr_data als volledig niet-werkend behandelen in plaats van P6's -// gedocumenteerde terugval ma-vr 08:00-16:00. +// 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 }], diff --git a/tests/planning/check-xer-chunk-boundary.ts b/tests/planning/check-xer-chunk-boundary.ts index db0bccb01..2e8f4fd19 100644 --- a/tests/planning/check-xer-chunk-boundary.ts +++ b/tests/planning/check-xer-chunk-boundary.ts @@ -49,6 +49,12 @@ try { // 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', @@ -60,7 +66,9 @@ const forbiddenTaskFields = [ 'plan_start_date', 'plan_end_date', ] as const; for (const field of forbiddenTaskFields) { - if (reader.includes(field)) diffs.push(`xerReader.ts bevat verboden P6-uitvoerveld ${field}`); + if (readerWithoutAllowedProjectInput.includes(field)) { + diffs.push(`xerReader.ts bevat verboden P6-uitvoerveld ${field}`); + } } if (diffs.length > 0) { diff --git a/tests/planning/check-xer-fidelity-baseline-schema.ts b/tests/planning/check-xer-fidelity-baseline-schema.ts index d45969f22..7032560b5 100644 --- a/tests/planning/check-xer-fidelity-baseline-schema.ts +++ b/tests/planning/check-xer-fidelity-baseline-schema.ts @@ -23,7 +23,8 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { type XerFidelityAxisCounts, type XerFidelityCounters, type XerFidelityBaselineEntry, - type XerFidelityBaseline, emptyAxisCounts, emptyCounters, createEmptyXerFidelityBaseline, + type XerFidelityBaseline, type XerScannerPrecisionFacts, + emptyAxisCounts, emptyCounters, createEmptyXerFidelityBaseline, } from './xerFidelityTypes'; const HERE = fileURLToPath(new URL('.', import.meta.url)); @@ -68,13 +69,13 @@ function keys() { 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', 'schemaFingerprint', 'reason', + '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)[] = ['schemaFingerprint', 'reason']; +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 ──────────────────────────────────────────────────────────────────── @@ -112,6 +113,19 @@ function validateCounters(v: unknown, path: string, problems: string[]): void { } } +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) { @@ -121,6 +135,7 @@ function validateEntry(v: unknown, path: string, problems: string[]): void { 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)) { diff --git a/tests/planning/check-xer-fidelity.ts b/tests/planning/check-xer-fidelity.ts index aac0c478b..ca981e80d 100644 --- a/tests/planning/check-xer-fidelity.ts +++ b/tests/planning/check-xer-fidelity.ts @@ -35,17 +35,21 @@ 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.5\tY', - // Completed gebruikt de actuals voor alle vier datumassen. 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\t2099-01-02\t2099-01-03\t2099-01-04\t\t-0.125\tN', + '%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 tegen de opgeslagen early/late-uitvoer meten in plaats van tegen actuals. +// 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], { @@ -56,26 +60,34 @@ eq('1b scanner leest taakidentiteit en zeven rapportageassen', truth.tasks[0], { axes: { es: '2026-01-05T08:00', ef: '2026-01-05T17:00', ls: '2026-01-06T08:00', lf: '2026-01-06T17:00', - tf: 75, ff: 30, + 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 gebruikt actuals en rondt fractionele float naar minuten', truth.tasks[1], { +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: '2026-02-02T09:15', ef: '2026-02-03T16:45', - ls: '2026-02-02T09:15', lf: '2026-02-03T16:45', - tf: null, ff: -7, + 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', @@ -85,7 +97,7 @@ const commaFixture = Buffer.from([ '%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\t\t\t\t\t1,25\t0,5\tY', + '%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 { @@ -93,9 +105,9 @@ const commaTruth = scanXerGroundTruth(commaFixture) as unknown as { errors: string[]; }; -// Breuk die dit vangt: Number(value) gebruiken zonder CURRTYPE en completed-aanwezigheid op de -// lege early/late-broncellen baseren in plaats van op de effectieve actuals. -eq('1e CURRTYPE-kommafloat blijft meetbaar in afgeronde minuten', commaTruth.tasks[0], { +// 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', @@ -103,8 +115,9 @@ eq('1e CURRTYPE-kommafloat blijft meetbaar in afgeronde minuten', commaTruth.tas axes: { es: '2026-04-01T08:00', ef: '2026-04-02T17:00', ls: '2026-04-01T08:00', lf: '2026-04-02T17:00', - tf: 75, ff: 30, + 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 }, }); @@ -240,7 +253,7 @@ 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\t\t\t\t\t\t\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', @@ -313,16 +326,16 @@ const measured = measureXerFidelity(truth, [ 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: 30, drivingPath: false, + totalFloatMinutes: 75, freeFloatMinutes: 7.5, drivingPath: false, }], }, { projectId: 'P2', tasks: [{ sourceTaskId: '2', taskCode: 'B', - earlyStart: '2026-02-02T09:15', earlyFinish: '2026-02-03T16:45', - lateStart: '2026-02-02T09:15', lateFinish: '2026-02-03T16:45', - totalFloatMinutes: 123, freeFloatMinutes: -7, drivingPath: false, + 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, }], }, ]); diff --git a/tests/planning/check-xer-field-whitelist.ts b/tests/planning/check-xer-field-whitelist.ts index fdef97604..c291d3536 100644 --- a/tests/planning/check-xer-field-whitelist.ts +++ b/tests/planning/check-xer-field-whitelist.ts @@ -1,6 +1,7 @@ /** - * De veldlijsten-poort (X0, XER-etappeplan §4.1) — legt de whitelist, de verboden-lijst en de - * "genegeerd — geen planningsdata"-bak als GETYPEERDE CONSTANTEN vast, en toetst ze tegen de + * 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`). * @@ -15,7 +16,7 @@ * MINIMALE %T/%F-tabelscan (geen tokenizer-hergebruik — er is nog niets om te hergebruiken; 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 drie bakken + * HET GATENKAAS-MECHANISME (planreview M2): een corpus-`%F`-kolom die in GEEN van de vier 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-)veld zonder het elders * te herplaatsen, dan valt die kolom terug tussen wal en schip en gaat de scan ROOD (mét corpus @@ -37,7 +38,7 @@ const truthy = (label: string, cond: boolean) => { }; // ═══════════════════════════════════════════════════════════════════════════════════════════ -// De drie bakken (XER-etappeplan §4.1, bijgewerkt na de delta-check-redactieronde 076f67ec) +// De vier bakken (XER-etappeplan §4.1 + D4-review: external proxy is geen lokale rekenuitvoer) // ═══════════════════════════════════════════════════════════════════════════════════════════ /** @@ -80,7 +81,6 @@ export const XER_TASK_FORBIDDEN = [ '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', @@ -88,6 +88,15 @@ export const XER_TASK_FORBIDDEN = [ '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 3a — "genegeerd — geen planningsdata": VOORBEHOUDEN kandidaten (plan §4.1, letterlijk). * Deze drie zijn OP NAAM ingedeeld, niet doorgelezen tegen echte rijwaarden — blijkt tijdens de @@ -154,6 +163,7 @@ export const XER_TASK_IGNORED: readonly string[] = [ const buckets: [string, readonly string[]][] = [ ['whitelist', XER_TASK_WHITELIST], ['forbidden', XER_TASK_FORBIDDEN], + ['external-dependency-proxy', XER_TASK_EXTERNAL_DEPENDENCY_PROXY], ['ignored', XER_TASK_IGNORED], ]; for (const [name, list] of buckets) { @@ -165,18 +175,22 @@ export const XER_TASK_IGNORED: readonly string[] = [ } 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 ignoredSet = new Set(XER_TASK_IGNORED); for (const f of whitelistSet) { truthy(`"${f}" staat niet óók in forbidden`, !forbiddenSet.has(f)); + truthy(`"${f}" staat niet óók in external-dependency-proxy`, !externalProxySet.has(f)); truthy(`"${f}" staat niet óók in ignored`, !ignoredSet.has(f)); } for (const f of forbiddenSet) { + truthy(`"${f}" staat niet óók in external-dependency-proxy`, !externalProxySet.has(f)); truthy(`"${f}" staat niet óók in ignored`, !ignoredSet.has(f)); } + for (const f of externalProxySet) truthy(`"${f}" staat niet óók in ignored`, !ignoredSet.has(f)); } // ═══════════════════════════════════════════════════════════════════════════════════════════ -// De corpusscan: union van alle TASK-%F-kolommen over OPS_XER_CORPUS, tegen de drie bakken. +// De corpusscan: union van alle TASK-%F-kolommen over OPS_XER_CORPUS, tegen de vier bakken. // ═══════════════════════════════════════════════════════════════════════════════════════════ const CORPUS = process.env.OPS_XER_CORPUS; @@ -257,14 +271,16 @@ if (!CORPUS) { 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 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 inIgnored = ignoredSet.has(col); - const bucketCount = Number(inWhitelist) + Number(inForbidden) + Number(inIgnored); + const bucketCount = Number(inWhitelist) + Number(inForbidden) + Number(inExternalProxy) + Number(inIgnored); if (bucketCount === 0) { unclassified.push(`"${col}" (bv. ${exampleFile})`); } else if (bucketCount > 1) { @@ -278,7 +294,7 @@ if (!CORPUS) { 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 drie bakken (whitelist/forbidden/ignored) — gatenkaas-mechanisme, plan §4.1: ${unclassified.join(', ')}`, + `xer-field-whitelist: ${unclassified.length} TASK-%F-kolom(men) uit het corpus staan in géén van de vier bakken (whitelist/forbidden/external-proxy/ignored) — gatenkaas-mechanisme, plan §4.1: ${unclassified.join(', ')}`, ); } diff --git a/tests/planning/check-xer-multi-project.ts b/tests/planning/check-xer-multi-project.ts index 7a494caa8..d9afb2a61 100644 --- a/tests/planning/check-xer-multi-project.ts +++ b/tests/planning/check-xer-multi-project.ts @@ -314,13 +314,17 @@ eq('6 aanwezig baselineproject wordt één echte actieve OPS-baseline, geen docu 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', start: '2026-04-01', finish: '2026-04-02' }, - { taskId: 'M-CODE', start: '2026-04-03', finish: '2026-04-04' }, - { taskId: 'B-OLD', start: '2026-04-05', finish: '2026-04-06' }, + { 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, 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..aa8cc0cc3 --- /dev/null +++ b/tests/planning/check-xer-product-fidelity-x12.ts @@ -0,0 +1,1515 @@ +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, + toExtCalendar, toExtProject, toExtSequence, toExtTask, +} from '@/extensions/extMappers'; +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 { buildXerTargetBaseline, type XerCorpusFile, type XerCorpusManifest, type XerSolvedProject } from './xerFidelity'; +import { scanXerGroundTruth, XER_FIDELITY_AXES, type XerFidelityAxis } from './xerGroundTruth'; +import { measureXerProductFidelity, type XerProductAxisCounts } from './xerProductFidelity'; + +const HERE = fileURLToPath(new URL('.', import.meta.url)); +const REPORT = process.env.OPS_XER_FIDELITY_REPORT; +const REPORT_MODES = new Set(['baseline', 'detail', 'summary']); +const diffs: string[] = []; +let checks = 0; + +interface ProductBaselineEntry { + sha256: string; + schemaFingerprint: string; + projects: number; + tasks: number; + counters: Record; + drivingPath: XerProductAxisCounts; + identityErrors: string[]; + scannerErrors: string[]; + gatePassed: boolean; +} +interface ProductBaseline { version: 2; manifestSha256: string; files: Record; } + +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 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); +} +/** 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()]; +} + +function productBaseline(corpus: readonly XerCorpusFile[], manifest: XerCorpusManifest): ProductBaseline { + 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 result = measureXerProductFidelity(scanXerGroundTruth(file.bytes), 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}`); + } + files[targetEntry.label] = { + sha256: hash(file.bytes), schemaFingerprint: targetEntry.schemaFingerprint ?? '', + projects: result.truthProjects, tasks: result.truthTasks, + counters: result.counters, drivingPath: result.drivingPath, + identityErrors: result.identityErrors, + scannerErrors: result.scannerErrors, + gatePassed: result.gatePassed, + }; + } + return { version: 2, manifestSha256: hash(readFileSync(join(HERE, 'xer-corpus-manifest.json'))), 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 = readIFC(writeIFC(normal)); + const ifcAbsurd = readIFC(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'); + 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 genericExtensionImport: ImportResult = { + ...hostileExt, + project: fromExtProject(toExtProject(hostileExt.project)), + calendar: fromExtCalendar(toExtCalendar(hostileExt.calendar)), + tasks: hostileExt.tasks.map(task => fromExtTask(toExtTask(task))), + sequences: hostileExt.sequences.map(sequence => fromExtSequence(toExtSequence(sequence))), + }; + eq('X12 generieke extensie-import kan interne P6-opties niet via de echte solve activeren', { + projectSource: genericExtensionImport.project.schedulingOptions?.p6Source, + calendarSource: genericExtensionImport.calendar.p6Source, + result: resultOf(genericExtensionImport), + }, { + projectSource: undefined, + calendarSource: undefined, + result: offResult, + }); +} + +// 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 = readIFC(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); + eq('X12 P6-XER-projectie raakt alleen backward, lag en float; forward blijft fysiek', { + projectSource: projectionImport.project.schedulingOptions?.p6Source, + calendarSource: projectionImport.calendar.p6Source, + 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', + aEarlyFinish: '2026-01-02T17:00', + aLateStart: '2025-12-31T08:00', + aTotalFloat: 0, + xTotalFloat: 960, + xFreeFloat: 960, + lagPredecessorLateFinish: '2025-12-29T17: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'); + const ifcRoundTrip = readIFC(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; + const ordinaryIfc = readIFC(writeIFC(ordinaryIfcSource)); + 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, + }); +} + +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 = productBaseline(corpus, manifest); + if (REPORT === 'baseline') console.log(JSON.stringify(measured, null, 2)); + else if (REPORT === 'summary' || REPORT === 'detail') { + 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: ${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', + JSON.parse(readFileSync(join(HERE, 'xer-product-fidelity-baseline.json'), 'utf8')) as unknown, 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) 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 index 4b20acce7..79c138006 100644 --- a/tests/planning/check-xer-product-fidelity.ts +++ b/tests/planning/check-xer-product-fidelity.ts @@ -55,8 +55,8 @@ function solved(bytes: Uint8Array): XerSolvedProject { earlyFinish: task.time.earlyFinish, lateStart: task.time.lateStart, lateFinish: task.time.lateFinish, - totalFloatMinutes: Math.round(task.time.totalFloat * minutesPerDay), - freeFloatMinutes: Math.round(task.time.freeFloat * minutesPerDay), + totalFloatMinutes: task.time.totalFloat * minutesPerDay, + freeFloatMinutes: task.time.freeFloat * minutesPerDay, }; }), }; diff --git a/tests/planning/check-xer-reader.ts b/tests/planning/check-xer-reader.ts index 47b0a77d7..45d4d03eb 100644 --- a/tests/planning/check-xer-reader.ts +++ b/tests/planning/check-xer-reader.ts @@ -95,7 +95,181 @@ eq('2 projectdatumbereik komt uit input-TASK-velden, nooit uit PROJECT plan/outp eq('3 PROJECT-kalender blijft de hoofdkalender', { id: result.calendar.id, hourMode: result.calendar.workTime !== undefined, -}, { id: 'C1', hourMode: true }); + 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', @@ -202,6 +376,63 @@ eq('13 externe relatie is uitsluitend brondata en geen solver-Sequence', { }], }); +// 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 { diff --git a/tests/planning/check-xer-schedule-options-corpus.ts b/tests/planning/check-xer-schedule-options-corpus.ts index f11733858..89534a776 100644 --- a/tests/planning/check-xer-schedule-options-corpus.ts +++ b/tests/planning/check-xer-schedule-options-corpus.ts @@ -422,8 +422,8 @@ function projectResult( earlyFinish: task.time.earlyFinish, lateStart: task.time.lateStart, lateFinish: task.time.lateFinish, - totalFloatMinutes: Math.round(task.time.totalFloat * minutesPerDay), - freeFloatMinutes: Math.round(task.time.freeFloat * minutesPerDay), + totalFloatMinutes: task.time.totalFloat * minutesPerDay, + freeFloatMinutes: task.time.freeFloat * minutesPerDay, isCritical: cpm.tasks.get(task.id)?.isCritical, }; }); @@ -469,37 +469,6 @@ function variantDefinitions( }; } -function rawProjectCriticalDefinition( - tables: RawXerTables, - projectId: string, -): SchedulingOptions['criticalDefinition'] { - const row = tables.get('PROJECT')?.rows.find(item => item.cells.proj_id?.trim() === projectId); - if (!row) return { mode: 'totalFloat', thresholdHours: 0 }; - const token = row.cells.critical_path_type?.trim().toUpperCase() ?? ''; - if (token === 'CT_DRIVPATH') return { mode: 'longestPath' }; - const raw = row.cells.critical_drtn_hr_cnt?.trim().replace(',', '.') ?? ''; - const thresholdHours = raw === '' ? 0 : Number(raw); - return { - mode: 'totalFloat', - thresholdHours: Number.isFinite(thresholdHours) ? thresholdHours : 0, - }; -} - -function independentXerDefaults(tables: RawXerTables, projectId: string): SolverVariant { - return { - progressMode: 'RETAINED_LOGIC', - schedulingOptions: { - lagCalendar: 'predecessor', - criticalDefinition: rawProjectCriticalDefinition(tables, projectId), - totalFloatMode: 'finish', - makeOpenEndedCritical: false, - useExpectedFinishDates: true, - preserveActualDatesInBackwardPass: true, - clampNegativeFreeFloat: true, - }, - }; -} - function deferredCode(error: unknown): string { return error instanceof XerImportError ? error.xerCode : 'UNEXPECTED_IMPORT_ERROR'; } @@ -717,8 +686,18 @@ function measureCorpus(root: string): BlastRadiusBaseline { tasks: file.truth.tasks.filter(task => openedProjectIds.has(task.projectId)), }; oracleNegativeFloatTasks = openedTruth.tasks.filter(taskHasNegativeFloat).length; - const xerDefaultsVariants = importedProjects.map(imported => - independentXerDefaults(file.rawScan.tables, imported.project.id)); + // 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({ @@ -727,7 +706,7 @@ function measureCorpus(root: string): BlastRadiusBaseline { source: imported.xer?.scheduleOptions.source, }) === JSON.stringify({ ...xerDefaultsVariants[index], - source: 'xer-defaults', + source: xerDefaultsExpected[index].source, })).length; const house = importedProjects.map(imported => projectResult(imported, {})); const houseMeasurement = measureXerFidelity(openedTruth, house); @@ -888,17 +867,20 @@ if (!root) { const measured = measureCorpus(root); eq('openbare populatie en negatieve-floatverdeling', measured.population, { scanned: 93, - oracleAxisFiles: 60, - rawWithSchedOptions: 24, - rawWithoutSchedOptions: 36, - projectAddressableSchedOptions: 23, - functionallyWithoutSchedOptions: 37, - measured: 35, + // 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, + measured: 34, deferred: 2, - readableFiles: 35, - openedProjectsWithDefaults: 36, - wiredProjectsWithDefaults: 36, - concreteProjectsCompared: 84, + readableFiles: 34, + openedProjectsWithDefaults: 35, + wiredProjectsWithDefaults: 35, + concreteProjectsCompared: 70, oracleNegativeFloatFiles: 5, withoutSchedOptionsNegativeFloatFiles: 4, rawNegativeFloatFiles: 6, diff --git a/tests/planning/check-xer-schedule-options-wiring.ts b/tests/planning/check-xer-schedule-options-wiring.ts index 84eea8ea1..d5599b8cc 100644 --- a/tests/planning/check-xer-schedule-options-wiring.ts +++ b/tests/planning/check-xer-schedule-options-wiring.ts @@ -58,9 +58,9 @@ const multiSource = bytes([ '%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', - '%R\tP-A\tProject A\tC4\t2026-01-05 08:00\tCT_TotFloat\t8', - '%R\tP-B\tProject B\tC8\t2026-01-05 08:00\tCT_DrivPath\t0', + '%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', @@ -105,6 +105,7 @@ eq('2 ieder project krijgt uitsluitend zijn eigen SCHEDOPTIONS-semantiek', [ id: 'P-A', progressMode: 'PROGRESS_OVERRIDE', schedulingOptions: { + p6Source: 'XER', lagCalendar: 'successor', criticalDefinition: { mode: 'totalFloat', thresholdHours: 8 }, totalFloatMode: 'start', @@ -112,12 +113,20 @@ eq('2 ieder project krijgt uitsluitend zijn eigen SCHEDOPTIONS-semantiek', [ useExpectedFinishDates: false, preserveActualDatesInBackwardPass: true, clampNegativeFreeFloat: true, + p6ZeroDurationUsesPlannedBoundary: true, + p6UseTaskPlannedStartFloor: true, + p6FinishMilestoneBoundaryWindow: true, + p6PreserveActualInstants: true, + p6UseRemainingStartForProgress: true, + p6PreserveZeroDurationConstraintInstants: true, + useProjectEndDateForFloat: true, }, }, { id: 'P-B', progressMode: 'RETAINED_LOGIC', schedulingOptions: { + p6Source: 'XER', lagCalendar: '24hour', criticalDefinition: { mode: 'longestPath' }, totalFloatMode: 'finish', @@ -125,10 +134,45 @@ eq('2 ieder project krijgt uitsluitend zijn eigen SCHEDOPTIONS-semantiek', [ useExpectedFinishDates: true, preserveActualDatesInBackwardPass: true, clampNegativeFreeFloat: true, + p6ZeroDurationUsesPlannedBoundary: true, + p6UseTaskPlannedStartFloor: true, + p6FinishMilestoneBoundaryWindow: true, + p6PreserveActualInstants: true, + p6UseRemainingStartForProgress: false, + p6PreserveZeroDurationConstraintInstants: 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!), @@ -208,6 +252,7 @@ eq('4 ontbrekende SCHEDOPTIONS krijgt altijd de expliciete P6-defaultset op de 4 }, { progressMode: 'RETAINED_LOGIC', schedulingOptions: { + p6Source: 'XER', lagCalendar: 'predecessor', criticalDefinition: { mode: 'totalFloat', thresholdHours: 8 }, totalFloatMode: 'finish', @@ -215,6 +260,12 @@ eq('4 ontbrekende SCHEDOPTIONS krijgt altijd de expliciete P6-defaultset op de 4 useExpectedFinishDates: true, preserveActualDatesInBackwardPass: true, clampNegativeFreeFloat: true, + p6ZeroDurationUsesPlannedBoundary: true, + p6UseTaskPlannedStartFloor: true, + p6FinishMilestoneBoundaryWindow: true, + p6PreserveActualInstants: true, + p6UseRemainingStartForProgress: false, + p6PreserveZeroDurationConstraintInstants: true, }, projectHours: 8, taskHours: 4, diff --git a/tests/planning/check-xer-schedule-options.ts b/tests/planning/check-xer-schedule-options.ts index 935c76c66..33a03aba4 100644 --- a/tests/planning/check-xer-schedule-options.ts +++ b/tests/planning/check-xer-schedule-options.ts @@ -554,7 +554,7 @@ function projectEndSource(value: 'Y' | 'N') { } const projectEndTrue = projectEndSource('Y'); const projectEndFalse = projectEndSource('N'); -eq('projecteindevlag blijft expliciet als TODO-bronwaarde bewaard', { +eq('projecteindevlag blijft als bronwaarde bewaard en stuurt de solveroptie', { trueValue: projectEndTrue.retainedSource, falseValue: projectEndFalse.retainedSource, disposition: XER_SCHEDOPTIONS_COLUMN_DISPOSITIONS.find( @@ -565,8 +565,8 @@ eq('projecteindevlag blijft expliciet als TODO-bronwaarde bewaard', { falseValue: { sched_use_project_end_date_for_float: false }, disposition: { field: 'sched_use_project_end_date_for_float', - status: 'todo', - reason: 'Meerdere gelijktijdig geplande projecten ontbreken; X5 bewaart de bronwaarde zonder solversturing.', + status: 'mapped', + target: 'schedulingOptions.useProjectEndDateForFloat', }, }); const projectEndTrueSolve = new CPMSolver( @@ -636,6 +636,7 @@ eq('hostile bronarchief bewaart iedere raw rij eenmaal en diagnosticeert duplica }], duplicateSource: 'xer-defaults', duplicateOptions: { + p6Source: 'XER', lagCalendar: 'predecessor', criticalDefinition: { mode: 'totalFloat', thresholdHours: 8 }, totalFloatMode: 'finish', @@ -643,6 +644,12 @@ eq('hostile bronarchief bewaart iedere raw rij eenmaal en diagnosticeert duplica useExpectedFinishDates: true, preserveActualDatesInBackwardPass: true, clampNegativeFreeFloat: true, + p6ZeroDurationUsesPlannedBoundary: true, + p6UseTaskPlannedStartFloor: true, + p6FinishMilestoneBoundaryWindow: true, + p6PreserveActualInstants: true, + p6UseRemainingStartForProgress: false, + p6PreserveZeroDurationConstraintInstants: true, }, duplicateDiagnostics: [{ code: 'XER_DUPLICATE_SCHEDOPTIONS_PROJ_ID', @@ -700,6 +707,7 @@ eq('expliciete XER-defaultset is brongebonden en compleet', legacyResult(without source: 'xer-defaults', progressMode: 'RETAINED_LOGIC', schedulingOptions: { + p6Source: 'XER', lagCalendar: 'predecessor', criticalDefinition: { mode: 'totalFloat', thresholdHours: 16 }, totalFloatMode: 'finish', @@ -707,6 +715,12 @@ eq('expliciete XER-defaultset is brongebonden en compleet', legacyResult(without useExpectedFinishDates: true, preserveActualDatesInBackwardPass: true, clampNegativeFreeFloat: true, + p6ZeroDurationUsesPlannedBoundary: true, + p6UseTaskPlannedStartFloor: true, + p6FinishMilestoneBoundaryWindow: true, + p6PreserveActualInstants: true, + p6UseRemainingStartForProgress: false, + p6PreserveZeroDurationConstraintInstants: true, }, retainedSource: {}, fallbacks: [], @@ -723,6 +737,7 @@ eq('expliciete XER-defaultset is brongebonden en compleet', legacyResult(without 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', @@ -730,6 +745,12 @@ eq('geexporteerde defaults blijven de ongewijzigde nul-drempel leveren', XER_SCH useExpectedFinishDates: true, preserveActualDatesInBackwardPass: true, clampNegativeFreeFloat: true, + p6ZeroDurationUsesPlannedBoundary: true, + p6UseTaskPlannedStartFloor: true, + p6FinishMilestoneBoundaryWindow: true, + p6PreserveActualInstants: true, + p6UseRemainingStartForProgress: false, + p6PreserveZeroDurationConstraintInstants: true, }, }); eq('default 1/8: finish-float', XER_SCHEDULING_DEFAULTS.schedulingOptions.totalFloatMode, 'finish'); @@ -747,6 +768,16 @@ 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 = { @@ -830,6 +861,7 @@ eq('bekende enums en vlaggen worden case-insensitief naar bestaande opties gemap source: 'schedoptions', progressMode: 'PROGRESS_OVERRIDE', schedulingOptions: { + p6Source: 'XER', lagCalendar: 'successor', criticalDefinition: { mode: 'longestPath' }, totalFloatMode: 'start', @@ -837,6 +869,13 @@ eq('bekende enums en vlaggen worden case-insensitief naar bestaande opties gemap useExpectedFinishDates: false, preserveActualDatesInBackwardPass: true, clampNegativeFreeFloat: true, + p6ZeroDurationUsesPlannedBoundary: true, + p6UseTaskPlannedStartFloor: true, + p6FinishMilestoneBoundaryWindow: true, + p6PreserveActualInstants: true, + p6UseRemainingStartForProgress: false, + p6PreserveZeroDurationConstraintInstants: true, + useProjectEndDateForFloat: false, floatPaths: { enabled: true, method: 'TOTAL_FLOAT', maxPaths: 3 }, }, retainedSource: { sched_use_project_end_date_for_float: false }, diff --git a/tests/planning/fidelityCore.ts b/tests/planning/fidelityCore.ts index 19395d48d..204b815d3 100644 --- a/tests/planning/fidelityCore.ts +++ b/tests/planning/fidelityCore.ts @@ -37,14 +37,16 @@ function isCanonicalMinute(value: string): boolean { return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 16) === value; } -/** XER-poortvergelijking: uitsluitend twee geldige canonieke minuten kunnen exact zijn. */ +/** 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'; - return ours === truth ? 'exact' : 'diff'; + if (ours === truth) return 'exact'; + return dayOf(ours) === dayOf(truth) ? 'sameday' : 'diff'; } export interface FidelityValuePair { diff --git a/tests/planning/run.sh b/tests/planning/run.sh index 4454d9693..7c180d58b 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -330,6 +330,13 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then 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 + # 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" diff --git a/tests/planning/xerFidelityTypes.ts b/tests/planning/xerFidelityTypes.ts index ca2b4b2d8..c224b8bb3 100644 --- a/tests/planning/xerFidelityTypes.ts +++ b/tests/planning/xerFidelityTypes.ts @@ -12,7 +12,7 @@ * 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 === round(p6_float_uren × 60)`). + * `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 @@ -40,6 +40,15 @@ export interface XerFidelityCounters { 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 }; @@ -67,6 +76,9 @@ export interface XerFidelityBaselineEntry { 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 diff --git a/tests/planning/xerGroundTruth.ts b/tests/planning/xerGroundTruth.ts index 729a3ca69..d1fbb8a0d 100644 --- a/tests/planning/xerGroundTruth.ts +++ b/tests/planning/xerGroundTruth.ts @@ -7,8 +7,11 @@ * 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']; @@ -18,6 +21,8 @@ export interface XerGroundTruthTask { 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; @@ -31,6 +36,8 @@ export interface XerGroundTruth { 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; @@ -72,24 +79,33 @@ function parseOracleDate( taskId: string, field: string, errors: string[], -): string | null { +): { minute: string | null; rawSeconds: string | null } { const value = raw?.trim() ?? ''; - if (!value || value === '0') return null; - const match = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::\d{2})?)?$/.exec(value); + 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 null; + return { minute: null, rawSeconds: null }; } - const [, year, month, day, rawHour, rawMinute] = match; + 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 null; + return { minute: null, rawSeconds: null }; } - return `${year}-${month}-${day}T${hour}:${minute}`; + return { + minute: `${year}-${month}-${day}T${hour}:${minute}`, + rawSeconds: rawSecond === undefined + ? null + : `${rawSecond}${rawFraction === undefined ? '' : `.${rawFraction}`}`, + }; } function inspectNumberSymbol( @@ -220,7 +236,8 @@ function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -/** P6-float staat in uren; het orakel wordt meteen naar afgeronde minuten genormaliseerd. */ +/** 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, @@ -246,7 +263,7 @@ function parseFloatMinutes( errors.push(`TASK ${taskId}/${field}: ongeldig getal ${JSON.stringify(value)}`); return null; } - return Math.round(hours * 60); + return hours * 60; } function parseDrivingPath( @@ -299,15 +316,13 @@ function buildTask( if (statusCode && !KNOWN_STATUS_CODES.has(normalizedStatus)) { errors.push(`TASK ${taskId}/status_code: onbekende waarde ${JSON.stringify(statusCode)}`); } - const completed = normalizedStatus === 'tk_complete'; - const startField = completed ? 'act_start_date' : 'early_start_date'; - const finishField = completed ? 'act_end_date' : 'early_end_date'; - const lateStartField = completed ? 'act_start_date' : 'late_start_date'; - const lateFinishField = completed ? 'act_end_date' : 'late_end_date'; - const start = parseOracleDate(row.get(startField), taskId, startField, errors); - const finish = parseOracleDate(row.get(finishField), taskId, finishField, errors); - const lateStart = completed ? start : parseOracleDate(row.get(lateStartField), taskId, lateStartField, errors); - const lateFinish = completed ? finish : parseOracleDate(row.get(lateFinishField), taskId, lateFinishField, errors); + // 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, @@ -315,19 +330,25 @@ function buildTask( taskCode, statusCode, axes: { - es: start, - ef: finish, - ls: lateStart, - lf: lateFinish, + 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 !== null, - ef: finish !== null, - ls: lateStart !== null, - lf: lateFinish !== null, + 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(), }, @@ -399,11 +420,31 @@ export function scanXerGroundTruth(bytes: Uint8Array): XerGroundTruth { } } } + 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/xerProductFidelity.ts b/tests/planning/xerProductFidelity.ts new file mode 100644 index 000000000..199680ad7 --- /dev/null +++ b/tests/planning/xerProductFidelity.ts @@ -0,0 +1,199 @@ +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'; +} + +export interface XerProductMeasurement { + truthProjects: number; + solvedProjects: number; + truthTasks: number; + solvedTasks: number; + counters: Record; + drivingPath: XerProductAxisCounts; + /** 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[], +): Map { + const byId = 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}: taskCode is leeg`); + if (byId.has(task.sourceTaskId)) errors.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()) errors.push(`project ${projectId}: extra taak ${id}`); + for (const id of [...truthById.keys()].filter(id => !byId.has(id)).sort()) errors.push(`project ${projectId}: ontbrekende taak ${id}`); + for (const task of truthTasks) { + const actual = byId.get(task.taskId); + if (actual?.taskCode && actual.taskCode !== task.taskCode) { + errors.push(`project ${projectId}/taak ${task.taskId}: code verwacht ${task.taskCode}, kreeg ${actual.taskCode}`); + } + } + return byId; +} + +/** 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 detail: XerProductTaskDelta[] = []; + for (const [projectId, truthTasks] of truthByProject) { + const solvedById = validateIdentity(projectId, truthTasks, solvedByProject.get(projectId), identityErrors); + for (const truthTask of truthTasks) { + const solved = solvedById.get(truthTask.taskId); + 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); + 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); + 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 }); + } + } + } + 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, + 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 index 48b08ee99..208926a3d 100644 --- a/tests/planning/xerScheduleOptionsGroundTruth.ts +++ b/tests/planning/xerScheduleOptionsGroundTruth.ts @@ -186,6 +186,10 @@ export function expectedXerScheduleOptions( }; } 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', @@ -193,6 +197,15 @@ export function expectedXerScheduleOptions( 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, }; if (!scheduleRow) { return { @@ -216,8 +229,13 @@ export function expectedXerScheduleOptions( ); 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 }; - else if (retainedToken.toUpperCase() === 'N') retainedSource = { sched_use_project_end_date_for_float: false }; + 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', ); From 97c27421ab0d72c4db527f878dac59bfba0e4db0 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:17:33 +0200 Subject: [PATCH 062/171] fix(xer): sluit P6-provenance af voor extensie-invoer --- src/extensions/extMappers.ts | 16 +--- src/extensions/extTypes.ts | 12 +-- tests/planning/check-ext-contract.ts | 63 +++++++++++- .../check-xer-product-fidelity-x12.ts | 95 +++++++++++++++++-- 4 files changed, 157 insertions(+), 29 deletions(-) diff --git a/src/extensions/extMappers.ts b/src/extensions/extMappers.ts index ab61aece2..b01236f37 100644 --- a/src/extensions/extMappers.ts +++ b/src/extensions/extMappers.ts @@ -332,8 +332,8 @@ export function toExtTask(t: Task): ExtTask { // create-/update-paden (`fromExtTaskInput`) en de MCP-zetbaarheid (`taskFields.ts` REJECT_HINTS). mspTaskType: t.mspTaskType, effortDriven: t.effortDriven, - // X0 (XER-etappeplan): drie nieuwe .xer-importvelden — zelfde onvoorwaardelijke doorgifte als - // mspTaskType/effortDriven hierboven. + // 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, @@ -387,15 +387,9 @@ export function fromExtTask(t: ExtTask): Task { // (`fromExtTaskInput`, extensie-API) blijven hier bewust buiten (leeskant-alleen-besluit F5). mspTaskType: t.mspTaskType, effortDriven: t.effortDriven, - // X0 (XER-etappeplan): drie nieuwe .xer-importvelden — zelfde onvoorwaardelijke doorgifte als - // mspTaskType/effortDriven hierboven. - p6DurationType: t.p6DurationType, - p6ActivityType: t.p6ActivityType, - p6ProjectId: t.p6ProjectId, - p6TaskId: t.p6TaskId, - p6CompletePctType: t.p6CompletePctType, - p6ExpectedFinish: t.p6ExpectedFinish, - p6SuspendResume: t.p6SuspendResume, + // 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, periods: c.periods.map(p => ({ ...p })) })) : undefined, timephasedFinishFloor: t.timephasedFinishFloor, timephasedStartAnchor: t.timephasedStartAnchor, diff --git a/src/extensions/extTypes.ts b/src/extensions/extTypes.ts index 6fd01bdf2..1e478548b 100644 --- a/src/extensions/extTypes.ts +++ b/src/extensions/extTypes.ts @@ -211,18 +211,18 @@ 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 (XER-etappeplan) — P6's eigen Duration Type bij .xer-import. Puur data; zelfde vertaal-/ - * zetbaarheidsnuance als `mspTaskType` (volledige vertaling, buiten de create-/update-paden). */ + /** 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 (XER-etappeplan) — P6's eigen Activity Type bij .xer-import. Zelfde nuance als `p6DurationType`. */ + /** 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; p6CompletePctType?: 'CP_Drtn' | 'CP_Phys' | 'CP_Units'; p6ExpectedFinish?: string; - /** X0 (XER-etappeplan) — herkomstvlag voor `time.resume`/`time.stop`: signaleert P6-suspend/ - * resume-herkomst (XER `suspend_date`/`resume_date`) i.p.v. de MSP-conventie. Stub tot de XER- - * etappe se X7-taak 'm daadwerkelijk zet. Spiegelt {@link import('@/types/task').Task}.p6SuspendResume. */ + /** 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 diff --git a/tests/planning/check-ext-contract.ts b/tests/planning/check-ext-contract.ts index 7aac8ec0b..cdccfa347 100644 --- a/tests/planning/check-ext-contract.ts +++ b/tests/planning/check-ext-contract.ts @@ -38,6 +38,7 @@ import { toExtProject, fromExtProject, toExtCalendar, fromExtCalendar, toExtTask, fromExtTask, + fromExtImportResult, toExtTaskTime, fromExtTaskTime, toExtSequence, fromExtSequence, toExtResource, fromExtResource, @@ -139,7 +140,10 @@ const LEES_ALLEEN_EXT = { project: [] as readonly string[], calendar: ['p6Source', 'p6NonWorkPenaltyDates'] as readonly string[], resource: [] as readonly string[], - task: [] as readonly string[], + task: [ + 'p6DurationType', 'p6ActivityType', 'p6ProjectId', 'p6TaskId', + 'p6CompletePctType', 'p6ExpectedFinish', 'p6SuspendResume', + ] as readonly string[], taskTime: [] as readonly string[], sequence: ['p6StartAtPredecessorFinishBoundary'] as readonly string[], assignment: [] as readonly string[], @@ -325,6 +329,59 @@ eq('X12 extensie leest de P6-relatievlag uit maar voert haar niet generiek terug }); } +{ + 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, + p6CompletePctType: exposed.p6CompletePctType, + p6ExpectedFinish: exposed.p6ExpectedFinish, + p6SuspendResume: exposed.p6SuspendResume, + }, + imported: { + p6DurationType: imported.p6DurationType, + p6ActivityType: imported.p6ActivityType, + p6ProjectId: imported.p6ProjectId, + p6TaskId: imported.p6TaskId, + p6CompletePctType: imported.p6CompletePctType, + p6ExpectedFinish: imported.p6ExpectedFinish, + p6SuspendResume: imported.p6SuspendResume, + }, + }, { + exposed: { + p6DurationType: 'DT_FixedDUR2', p6ActivityType: 'TT_Rsrc', + p6ProjectId: 'P1', p6TaskId: 'T1', p6CompletePctType: 'CP_Phys', + p6ExpectedFinish: '2026-06-11T17:00', p6SuspendResume: true, + }, + imported: {}, + }); + + const p6Keys = [ + 'p6DurationType', 'p6ActivityType', 'p6ProjectId', 'p6TaskId', + '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', costPerHour: 120, availability: 1, maxUnits: 2, calendarId: 'cal2', @@ -423,7 +480,9 @@ for (const [naam, ext, bron, sleutels] of [ 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, ...LEES_ALLEEN_EXT.calendar])); - eq('21 taak round-trip', fromExtTask(toExtTask(VOL_TASK)), VOL_TASK); + 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])); diff --git a/tests/planning/check-xer-product-fidelity-x12.ts b/tests/planning/check-xer-product-fidelity-x12.ts index aa8cc0cc3..69f89394a 100644 --- a/tests/planning/check-xer-product-fidelity-x12.ts +++ b/tests/planning/check-xer-product-fidelity-x12.ts @@ -5,8 +5,7 @@ import { fileURLToPath } from 'node:url'; import { CalendarEngine } from '@/engine/scheduler/CalendarEngine'; import { solveProject } from '@/engine/scheduler/solveProject'; import { - fromExtCalendar, fromExtProject, fromExtSequence, fromExtTask, - toExtCalendar, toExtProject, toExtSequence, toExtTask, + fromExtCalendar, fromExtProject, fromExtSequence, fromExtTask, toExtTask, } from '@/extensions/extMappers'; import { readIFC } from '@/services/ifc/ifcReader'; import { writeIFC } from '@/services/ifc/ifcWriter'; @@ -918,21 +917,97 @@ function productBaseline(corpus: readonly XerCorpusFile[], manifest: XerCorpusMa }); 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(toExtProject(hostileExt.project)), - calendar: fromExtCalendar(toExtCalendar(hostileExt.calendar)), - tasks: hostileExt.tasks.map(task => fromExtTask(toExtTask(task))), - sequences: hostileExt.sequences.map(sequence => fromExtSequence(toExtSequence(sequence))), + 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, - calendarSource: genericExtensionImport.calendar.p6Source, - result: resultOf(genericExtensionImport), + 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, - calendarSource: undefined, - result: offResult, + 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', }); } From 52fdf0edb520217bce1b5ff52684d470ca7cd722 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:40:54 +0200 Subject: [PATCH 063/171] fix(xer): gebruik koude reconstructie voor X12-roundtrips --- .../check-xer-product-fidelity-x12.ts | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/tests/planning/check-xer-product-fidelity-x12.ts b/tests/planning/check-xer-product-fidelity-x12.ts index 69f89394a..ba313a4ae 100644 --- a/tests/planning/check-xer-product-fidelity-x12.ts +++ b/tests/planning/check-xer-product-fidelity-x12.ts @@ -7,6 +7,7 @@ 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'; @@ -139,7 +140,10 @@ function solveProductProjects(imports: readonly ImportResult[]): XerSolvedProjec return [...byProjectId.values()]; } -function productBaseline(corpus: readonly XerCorpusFile[], manifest: XerCorpusManifest): ProductBaseline { +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])); @@ -399,8 +403,8 @@ function productBaseline(corpus: readonly XerCorpusFile[], manifest: XerCorpusMa normalErrors: [], absurdErrors: [], sixAxesChanged: true, drivingChanged: true, }); - const ifcNormal = readIFC(writeIFC(normal)); - const ifcAbsurd = readIFC(writeIFC(absurd)); + 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)); @@ -1211,7 +1215,7 @@ function productBaseline(corpus: readonly XerCorpusFile[], manifest: XerCorpusMa // 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 = readIFC(writeIFC(imported)); + const roundTripped = await readIFCWithXerReconstruction(writeIFC(imported)); const roundTripSolve = solveProject({ tasks: roundTripped.tasks, sequences: roundTripped.sequences, @@ -1408,7 +1412,8 @@ function productBaseline(corpus: readonly XerCorpusFile[], manifest: XerCorpusMa }; const xerForIfc = readXER(projectionBytes); if (isMultiDocumentImport(xerForIfc)) throw new Error('X12 XER-naar-IFC-fixture moet enkelproject zijn'); - const ifcRoundTrip = readIFC(writeIFC(xerForIfc)); + // 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', { @@ -1430,7 +1435,10 @@ function productBaseline(corpus: readonly XerCorpusFile[], manifest: XerCorpusMa delete ordinaryIfcSource.calendar.p6Source; delete ordinaryDirect.project.schedulingOptions?.p6Source; delete ordinaryDirect.calendar.p6Source; - const ordinaryIfc = readIFC(writeIFC(ordinaryIfcSource)); + // 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, @@ -1551,7 +1559,7 @@ else if (!existsSync(corpusRoot)) diffs.push('OPS_XER_CORPUS wijst niet naar een 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 = productBaseline(corpus, manifest); + const measured = await productBaseline(corpus, manifest); if (REPORT === 'baseline') console.log(JSON.stringify(measured, null, 2)); else if (REPORT === 'summary' || REPORT === 'detail') { const entries = Object.entries(measured.files); From 5b2ecd550f56cdaeb7f19f99ba486acd6effb3ac Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:31:32 +0200 Subject: [PATCH 064/171] fix(xer): behoud completed statusdatumsemantiek --- src/engine/moveProject.ts | 2 +- src/engine/scheduler/CPMSolver.ts | 40 ++++- src/engine/scheduler/scheduleAnalysis.ts | 6 +- src/extensions/extMappers.ts | 1 + src/extensions/extTypes.ts | 2 + src/services/ifc/ifcPsets.ts | 2 + src/services/mcp/tools/taskFields.ts | 1 + src/services/xer/xerReader.ts | 8 +- src/types/task.ts | 7 + src/utils/p6CompletedTargetWindow.ts | 31 ++++ tests/planning/check-document-contract.ts | 6 +- tests/planning/check-ext-contract.ts | 12 +- tests/planning/check-ifc-roundtrip.ts | 7 +- .../check-xer-product-fidelity-x12.ts | 150 ++++++++++++++++++ tests/planning/check-xer-x9-verdicts.ts | 9 +- 15 files changed, 261 insertions(+), 23 deletions(-) create mode 100644 src/utils/p6CompletedTargetWindow.ts diff --git a/src/engine/moveProject.ts b/src/engine/moveProject.ts index cfe595703..ff706413f 100644 --- a/src/engine/moveProject.ts +++ b/src/engine/moveProject.ts @@ -109,7 +109,7 @@ const TASK_VERDICTS = { effortDriven: 'n/a', // Z14b: vlag, geen datum 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', p6CompletePctType: 'n/a', + 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 diff --git a/src/engine/scheduler/CPMSolver.ts b/src/engine/scheduler/CPMSolver.ts index 10e22e134..5e36ed137 100644 --- a/src/engine/scheduler/CPMSolver.ts +++ b/src/engine/scheduler/CPMSolver.ts @@ -13,6 +13,7 @@ import { } from './duration'; import { computeScheduleResults } from './scheduleAnalysis'; import { hasValidP6SuspendResume } from '@/utils/p6SuspendResume'; +import { usesP6CompletedDataDateWindow as isP6CompletedDataDateCandidate } from '@/utils/p6CompletedTargetWindow'; import { forwardConstraint, forwardFinishFloor, backwardConstraint, MS_PER_MIN, MS_PER_DAY, type RelationDeps, } from './relationMath'; @@ -369,6 +370,15 @@ export class CPMSolver { return this.options.schedulingOptions?.p6Source === 'XER' && value === true; } + /** + * Fail-closed discriminator voor P6's completed data-date-route. Alleen de XER-reader kan de + * expliciete TASK-windowpresence zetten; een fallback of later berekende `scheduleStart` kan + * deze route dus nooit activeren. Geen opgeslagen P6-uitkomst wordt gelezen. + */ + private usesP6CompletedDataDateWindow(task: Task): boolean { + return this.dataDate !== null && isP6CompletedDataDateCandidate(task, this.options.schedulingOptions); + } + /** Smalle P6-XER-resultaatprojectie; de CalendarEngine-primitieven blijven formaatneutraal. */ private p6ProjectedSubtract(eng: CalendarEngine, end: Date, minutes: number): Date { return this.options.schedulingOptions?.p6Source === 'XER' @@ -1622,6 +1632,22 @@ export class CPMSolver { } { const t = task.time; + const p6CompletedAtDataDate = t.actualFinish && t.completion >= 1 + && this.usesP6CompletedDataDateWindow(task); + if (p6CompletedAtDataDate) { + // P6's completed-bladactiviteit blijft niet op haar historische actual-window staan: + // de toegestane statusdatum is haar forward referentie en nul restwerk eindigt op het + // voorafgaande werkinstant. Dit reconstrueert P6's statusdatumsemantiek zonder een + // opgeslagen early/late- of floatkolom te lezen. De bronpoort is fail-closed; andere + // formaten, milestones en suspend/resume behouden de bestaande actual-tak hieronder. + const es = this.snapOnOrAfter(progressCal, dataDate!); + const ef = progressCal.prevWorkInstant(es); + if (this.options.schedulingOptions?.preserveActualDatesInBackwardPass === true) { + for (const seq of preds) this.seqConstraint.delete(seq.id); + } + results.set(taskId, { es, ef }); + continue; + } if (t.actualFinish && t.completion >= 1) { // (1) VOLTOOID: volledig gepind op actuals — geen forward-drift voorbij actualFinish. // B4 (Opus-her-check T15-fixronde): `snapActualForward` i.p.v. een kale parse — snapt @@ -2793,11 +2819,12 @@ export class CPMSolver { const task = this.tasks.get(taskId)!; const succs = this.successors.get(taskId) || []; - // Brongebonden XER-beleid: een voltooide activiteit houdt haar geregistreerde actual-window - // aan de late zijde. Dit leest uitsluitend toegestane actuals; opgeslagen XER early/late/ - // float-uitkomsten zijn bewust nooit solverinvoer. Niet-XER-projecten behouden hun bestaande - // gedrag. De raw-XER-meetlat bewaakt deze beleidskeuze afzonderlijk van haar bronuitkomsten. - if (preserveActualDates && task.time.actualFinish && task.time.completion >= 1) { + // Brongebonden XER-beleid: een voltooide activiteit houdt normaal haar geregistreerde + // actual-window aan de late zijde. De smalle completed-taskbronvorm gebruikt echter de + // bestaande P6-statusdatum/netwerksemantiek; opgeslagen XER early/late/float-uitkomsten zijn op beide + // paden bewust nooit solverinvoer. Niet-XER en mijlpalen behouden hun bestaande gedrag. + if (preserveActualDates && task.time.actualFinish && task.time.completion >= 1 + && !this.usesP6CompletedDataDateWindow(task)) { const ed = earlyDates.get(taskId)!; results.set(taskId, { ls: new Date(ed.es.getTime()), lf: new Date(ed.ef.getTime()) }); continue; @@ -2883,7 +2910,8 @@ export class CPMSolver { 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. - if (preserveActualDates && succTask.time.actualFinish && succTask.time.completion >= 1) continue; + if (preserveActualDates && succTask.time.actualFinish && succTask.time.completion >= 1 + && !this.usesP6CompletedDataDateWindow(succTask)) 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 diff --git a/src/engine/scheduler/scheduleAnalysis.ts b/src/engine/scheduler/scheduleAnalysis.ts index df55a134b..c976f5f88 100644 --- a/src/engine/scheduler/scheduleAnalysis.ts +++ b/src/engine/scheduler/scheduleAnalysis.ts @@ -7,6 +7,7 @@ import { parseDate, formatInstant, type DateMode } from '@/utils/dateUtils'; import { traceFrom } from './graphWalk'; import { projectDurationOf } from './projectDuration'; import { isZeroDurationMilestone } from './duration'; +import { usesP6CompletedDataDateWindow } from '@/utils/p6CompletedTargetWindow'; /** * Invoer voor de resultaat-post-pass (`computeScheduleResults`). Puur data + een handvol @@ -229,6 +230,8 @@ export function computeScheduleResults(input: ScheduleAnalysisInput): CPMResult // verschillen wanneer een SNLT alleen de late start kapt). Kritiek = tf ≤ 0. const tt = taskObj.time; const completed = !!dataDate && tt.completion >= 1; + const p6CompletedDataDateWindow = completed + && usesP6CompletedDataDateWindow(taskObj, so); const finishFloat = signedFloat(early.ef, late.lf, cal, taskObj); const startFloat = signedFloat(early.es, late.ls, cal, taskObj); // Een EXPLICIETE P6-modus geldt ook voor lopende taken: start = LS−ES, finish = LF−EF en @@ -346,7 +349,8 @@ 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 displayActualLate = completed && so?.preserveActualDatesInBackwardPass === true; + const displayActualLate = completed && so?.preserveActualDatesInBackwardPass === true + && !p6CompletedDataDateWindow; taskResults.set(taskId, { earlyStart: formatInstant(early.es, mode), earlyFinish: formatInstant(early.ef, mode), diff --git a/src/extensions/extMappers.ts b/src/extensions/extMappers.ts index b01236f37..69246379a 100644 --- a/src/extensions/extMappers.ts +++ b/src/extensions/extMappers.ts @@ -338,6 +338,7 @@ export function toExtTask(t: Task): ExtTask { p6ActivityType: t.p6ActivityType, p6ProjectId: t.p6ProjectId, p6TaskId: t.p6TaskId, + p6ExplicitTargetWindow: t.p6ExplicitTargetWindow, p6CompletePctType: t.p6CompletePctType, p6ExpectedFinish: t.p6ExpectedFinish, p6SuspendResume: t.p6SuspendResume, diff --git a/src/extensions/extTypes.ts b/src/extensions/extTypes.ts index 1e478548b..8c6034411 100644 --- a/src/extensions/extTypes.ts +++ b/src/extensions/extTypes.ts @@ -218,6 +218,8 @@ export interface ExtTask { 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/ diff --git a/src/services/ifc/ifcPsets.ts b/src/services/ifc/ifcPsets.ts index 18431d453..4ce1d47b5 100644 --- a/src/services/ifc/ifcPsets.ts +++ b/src/services/ifc/ifcPsets.ts @@ -466,6 +466,7 @@ export const PER_TASK_PSETS: PerTaskPset[] = [ 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)})` }); @@ -479,6 +480,7 @@ export const PER_TASK_PSETS: PerTaskPset[] = [ 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; diff --git a/src/services/mcp/tools/taskFields.ts b/src/services/mcp/tools/taskFields.ts index eb50ab0c3..f017de236 100644 --- a/src/services/mcp/tools/taskFields.ts +++ b/src/services/mcp/tools/taskFields.ts @@ -148,6 +148,7 @@ const REJECT_HINTS: Record = { // 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)', }; diff --git a/src/services/xer/xerReader.ts b/src/services/xer/xerReader.ts index 235d36d71..b74a411f8 100644 --- a/src/services/xer/xerReader.ts +++ b/src/services/xer/xerReader.ts @@ -598,10 +598,13 @@ function readXerProject( for (const row of activityRows) { const effectiveCalendar = calendarById.get(row.cells.clndr_id) ?? projectCalendar; const hourMode = effectiveCalendar.workTime !== undefined; - let start = sourceInstant(row.cells.target_start_date ?? '', hourMode) + 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 = sourceInstant(row.cells.target_end_date ?? '', hourMode) ?? start; + 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 @@ -735,6 +738,7 @@ function readXerProject( p6ActivityType: activityType, p6ProjectId: projectId, p6TaskId: row.cells.task_id, + ...(hasExplicitTargetWindow ? { p6ExplicitTargetWindow: true } : {}), ...(completePctType ? { p6CompletePctType: completePctType } : {}), ...(expectedFinish ? { p6ExpectedFinish: expectedFinish } : {}), ...(validSuspendResume ? { p6SuspendResume: true } : {}), diff --git a/src/types/task.ts b/src/types/task.ts index 4431d57e6..1364135bb 100644 --- a/src/types/task.ts +++ b/src/types/task.ts @@ -475,6 +475,13 @@ export interface Task { /** `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. */ diff --git a/src/utils/p6CompletedTargetWindow.ts b/src/utils/p6CompletedTargetWindow.ts new file mode 100644 index 000000000..458d05dbc --- /dev/null +++ b/src/utils/p6CompletedTargetWindow.ts @@ -0,0 +1,31 @@ +import type { SchedulingOptions } from '@/types/project'; +import type { Task } from '@/types/task'; +import { isZeroDurationMilestone } from '@/engine/scheduler/duration'; + +/** + * 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 kleinste onafhankelijke bronvorm + * waarvoor de corpusprobe de completed statusdatum-inversie bevestigt. Suspend/resume en + * nulduurmijlpalen blijven expliciet buiten dit eerste causaliteitspakket. + */ +export function usesP6CompletedDataDateWindow( + task: Task, + schedulingOptions: SchedulingOptions | undefined, +): boolean { + return schedulingOptions?.p6Source === 'XER' + && schedulingOptions.p6UseRemainingStartForProgress === true + && task.p6ProjectId !== undefined && task.p6ProjectId !== '' + && task.p6TaskId !== undefined && task.p6TaskId !== '' + && task.p6ExplicitTargetWindow === true + && task.p6CompletePctType === 'CP_Drtn' + && task.p6DurationType === 'DT_FixedDUR2' + && (task.p6ActivityType === 'TT_Task' || task.p6ActivityType === 'TT_Rsrc') + && task.p6SuspendResume !== true + && task.time.completion >= 1 + && !isZeroDurationMilestone(task); +} diff --git a/tests/planning/check-document-contract.ts b/tests/planning/check-document-contract.ts index b0e75f05e..8cd6a7e00 100644 --- a/tests/planning/check-document-contract.ts +++ b/tests/planning/check-document-contract.ts @@ -297,6 +297,7 @@ S().updateTask(kT1, { // 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, @@ -305,15 +306,16 @@ S().updateTask(kT1, { const k3X7 = (candidate: Task | undefined) => [ candidate?.p6ProjectId, candidate?.p6TaskId, + candidate?.p6ExplicitTargetWindow, candidate?.p6CompletePctType, candidate?.p6ExpectedFinish, candidate?.p6SuspendResume, ]; -const k3X7Expected = ['P6-K3', 'P6-T1', 'CP_Phys', '2031-03-12', true]; +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, 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; diff --git a/tests/planning/check-ext-contract.ts b/tests/planning/check-ext-contract.ts index cdccfa347..4d8afca86 100644 --- a/tests/planning/check-ext-contract.ts +++ b/tests/planning/check-ext-contract.ts @@ -97,7 +97,7 @@ const EXT_TASK_KEYS = keys()([ 'timephasedFinishFloor', 'timephasedStartAnchor', 'timephasedDurationWalks', // X0 (XER-etappeplan, 2026-08-20): drie nieuwe .xer-importvelden, zelfde behandeling als de // .mpp-velden hierboven. - 'p6DurationType', 'p6ActivityType', 'p6ProjectId', 'p6TaskId', 'p6CompletePctType', 'p6ExpectedFinish', 'p6SuspendResume', + 'p6DurationType', 'p6ActivityType', 'p6ProjectId', 'p6TaskId', 'p6ExplicitTargetWindow', 'p6CompletePctType', 'p6ExpectedFinish', 'p6SuspendResume', ] as const); const EXT_SEQUENCE_KEYS = keys()([ @@ -141,7 +141,7 @@ const LEES_ALLEEN_EXT = { calendar: ['p6Source', 'p6NonWorkPenaltyDates'] as readonly string[], resource: [] as readonly string[], task: [ - 'p6DurationType', 'p6ActivityType', 'p6ProjectId', 'p6TaskId', + 'p6DurationType', 'p6ActivityType', 'p6ProjectId', 'p6TaskId', 'p6ExplicitTargetWindow', 'p6CompletePctType', 'p6ExpectedFinish', 'p6SuspendResume', ] as readonly string[], taskTime: [] as readonly string[], @@ -206,7 +206,7 @@ const VOL_TASK = { // de .mpp-velden hierboven (de round-trip-check vergelijkt via JSON.stringify). p6DurationType: 'DT_FixedDUR2', p6ActivityType: 'TT_Rsrc', - p6ProjectId: 'P1', p6TaskId: 'T1', p6CompletePctType: 'CP_Phys', p6ExpectedFinish: '2026-06-11T17:00', + 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', @@ -338,6 +338,7 @@ eq('X12 extensie leest de P6-relatievlag uit maar voert haar niet generiek terug p6ActivityType: exposed.p6ActivityType, p6ProjectId: exposed.p6ProjectId, p6TaskId: exposed.p6TaskId, + p6ExplicitTargetWindow: exposed.p6ExplicitTargetWindow, p6CompletePctType: exposed.p6CompletePctType, p6ExpectedFinish: exposed.p6ExpectedFinish, p6SuspendResume: exposed.p6SuspendResume, @@ -347,6 +348,7 @@ eq('X12 extensie leest de P6-relatievlag uit maar voert haar niet generiek terug p6ActivityType: imported.p6ActivityType, p6ProjectId: imported.p6ProjectId, p6TaskId: imported.p6TaskId, + p6ExplicitTargetWindow: imported.p6ExplicitTargetWindow, p6CompletePctType: imported.p6CompletePctType, p6ExpectedFinish: imported.p6ExpectedFinish, p6SuspendResume: imported.p6SuspendResume, @@ -354,14 +356,14 @@ eq('X12 extensie leest de P6-relatievlag uit maar voert haar niet generiek terug }, { exposed: { p6DurationType: 'DT_FixedDUR2', p6ActivityType: 'TT_Rsrc', - p6ProjectId: 'P1', p6TaskId: 'T1', p6CompletePctType: 'CP_Phys', + p6ProjectId: 'P1', p6TaskId: 'T1', p6ExplicitTargetWindow: true, p6CompletePctType: 'CP_Phys', p6ExpectedFinish: '2026-06-11T17:00', p6SuspendResume: true, }, imported: {}, }); const p6Keys = [ - 'p6DurationType', 'p6ActivityType', 'p6ProjectId', 'p6TaskId', + 'p6DurationType', 'p6ActivityType', 'p6ProjectId', 'p6TaskId', 'p6ExplicitTargetWindow', 'p6CompletePctType', 'p6ExpectedFinish', 'p6SuspendResume', ]; const hostileTask = { ...exposed } as ExtTask; diff --git a/tests/planning/check-ifc-roundtrip.ts b/tests/planning/check-ifc-roundtrip.ts index b096e45cf..1b3a69b90 100644 --- a/tests/planning/check-ifc-roundtrip.ts +++ b/tests/planning/check-ifc-roundtrip.ts @@ -260,7 +260,7 @@ const TM = { // 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', p6CompletePctType: 'CP_Phys', p6ExpectedFinish: '2026-07-31T17:00', + p6ProjectId: 'P1', p6TaskId: 'T1', p6ExplicitTargetWindow: true, p6CompletePctType: 'CP_Phys', p6ExpectedFinish: '2026-07-31T17:00', p6SuspendResume: true, isSummary: false, parentId: 't-p', childIds: [], @@ -575,6 +575,7 @@ const TASK_CANON = { // onbedoeld MSP-semantiek erven. p6ProjectId: KEEP, p6TaskId: KEEP, + p6ExplicitTargetWindow: KEEP, p6CompletePctType: KEEP, p6ExpectedFinish: KEEP, p6SuspendResume: KEEP, @@ -1383,7 +1384,7 @@ const hasP6BoundarySequence = (input: ImportResult) => 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', p6SuspendResume: true, + 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}`); @@ -1394,6 +1395,7 @@ const hasP6BoundarySequence = (input: ImportResult) => ); 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 = { @@ -1407,6 +1409,7 @@ const hasP6BoundarySequence = (input: ImportResult) => 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 diff --git a/tests/planning/check-xer-product-fidelity-x12.ts b/tests/planning/check-xer-product-fidelity-x12.ts index ba313a4ae..4c00a8c1e 100644 --- a/tests/planning/check-xer-product-fidelity-x12.ts +++ b/tests/planning/check-xer-product-fidelity-x12.ts @@ -719,6 +719,8 @@ async function productBaseline( ].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'); @@ -1550,6 +1552,154 @@ async function productBaseline( }); } +// 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 moet zijn P6-planningswindow behouden wanneer haar geregistreerde +// historie daarvan afwijkt. De aanvullende vormen bewaken dat de grens smal blijft: meerdere +// assignments zijn niet causaal, actieve taken en completed milestones vallen er buiten en een +// gewone/IFC-taak behoudt de bestaande actual-semantiek. +{ + type CompletedFixtureKind = 'completed-task' | 'active-task' | 'completed-milestone'; + const completedPackageBytes = ( + kind: CompletedFixtureKind, assignments: 0 | 2 = 0, rawOutput: 'oracle' | 'mutated' = 'oracle', + ) => { + const completed = kind !== 'active-task'; + const milestone = kind === 'completed-milestone'; + const remTargetLink = kind === 'active-task' ? 'N' : 'Y'; + const targetStart = '2026-01-02 08:00'; + const targetFinish = milestone ? targetStart : '2026-01-02 16:00'; + 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${targetStart}\t${targetFinish}`, + '%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')); + if (isMultiDocumentImport(one) || isMultiDocumentImport(oneRawMutated) || isMultiDocumentImport(two) + || isMultiDocumentImport(active) || isMultiDocumentImport(milestone)) { + 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); + eq('X12 F1 completed gewone XER-taak volgt de toegestane P6-statusdatum, niet actuals of raw output', { + 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, + earlyStart: '2026-01-05T08:00', earlyFinish: '2026-01-02T16:00', + lateStart: '2026-01-02T08: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-02T08:00', '2026-01-02T16:00'], + mutatedAxes: ['2026-01-05T08:00', '2026-01-02T16:00', '2026-01-02T08:00', '2026-01-02T16:00'], + }); + // 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-02T08: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', + }); + + // 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 generiek en gewoon IFC behouden bestaande actualdatumsemantiek', { + generic: [genericResult?.time.earlyStart, genericResult?.time.earlyFinish], + ifc: [genericIfcResult?.time.earlyStart, genericIfcResult?.time.earlyFinish], + }, { + generic: ['2026-01-08T08:00', '2026-01-08T16:00'], + ifc: ['2026-01-08T08:00', '2026-01-08T16:00'], + }); +} + const corpusRoot = process.env.OPS_XER_CORPUS; if (REPORT !== undefined && !REPORT_MODES.has(REPORT)) { diffs.push(`onbekende OPS_XER_FIDELITY_REPORT-modus: ${REPORT}`); diff --git a/tests/planning/check-xer-x9-verdicts.ts b/tests/planning/check-xer-x9-verdicts.ts index 80ba2d323..2277f8cad 100644 --- a/tests/planning/check-xer-x9-verdicts.ts +++ b/tests/planning/check-xer-x9-verdicts.ts @@ -14,7 +14,7 @@ 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', p6CompletePctType: 'CP_Phys', + 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); @@ -22,15 +22,16 @@ 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?.p6CompletePctType, after?.p6ExpectedFinish, + 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', 'CP_Phys', '2032-03-14', 'DT_FixedRate', 'TT_LOE', true])); + 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 solverfirewall blijft in de XER-chunkcheck permanent bewaakt', +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'); From dabbe6d325d30c75638b552dbaf82a446431d188 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:59:45 +0200 Subject: [PATCH 065/171] XER completed display/netwerk-scheiding herstellen --- src/engine/scheduler/CPMSolver.ts | 68 +++--- src/engine/scheduler/scheduleAnalysis.ts | 26 ++- src/utils/p6CompletedTargetWindow.ts | 2 + .../check-xer-product-fidelity-x12.ts | 201 ++++++++++++++++-- 4 files changed, 234 insertions(+), 63 deletions(-) diff --git a/src/engine/scheduler/CPMSolver.ts b/src/engine/scheduler/CPMSolver.ts index 5e36ed137..ca813eeda 100644 --- a/src/engine/scheduler/CPMSolver.ts +++ b/src/engine/scheduler/CPMSolver.ts @@ -370,11 +370,6 @@ export class CPMSolver { return this.options.schedulingOptions?.p6Source === 'XER' && value === true; } - /** - * Fail-closed discriminator voor P6's completed data-date-route. Alleen de XER-reader kan de - * expliciete TASK-windowpresence zetten; een fallback of later berekende `scheduleStart` kan - * deze route dus nooit activeren. Geen opgeslagen P6-uitkomst wordt gelezen. - */ private usesP6CompletedDataDateWindow(task: Task): boolean { return this.dataDate !== null && isP6CompletedDataDateCandidate(task, this.options.schedulingOptions); } @@ -393,6 +388,18 @@ export class CPMSolver { : eng.workMinutesBetween(a, b); } + /** 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), + ); + 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 @@ -1112,6 +1119,7 @@ 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), projectedWorkMinutesBetween: (eng, a, b) => this.projectedWorkMinutesBetween(eng, a, b), constraintInstant: (c, eng) => this.constraintInstant(c, eng), @@ -1623,31 +1631,9 @@ 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), - ); - if (candidate.isHourMode) progressCal = candidate; - } + const progressCal = this.progressCalendarFor(task, cal); { const t = task.time; - const p6CompletedAtDataDate = t.actualFinish && t.completion >= 1 - && this.usesP6CompletedDataDateWindow(task); - if (p6CompletedAtDataDate) { - // P6's completed-bladactiviteit blijft niet op haar historische actual-window staan: - // de toegestane statusdatum is haar forward referentie en nul restwerk eindigt op het - // voorafgaande werkinstant. Dit reconstrueert P6's statusdatumsemantiek zonder een - // opgeslagen early/late- of floatkolom te lezen. De bronpoort is fail-closed; andere - // formaten, milestones en suspend/resume behouden de bestaande actual-tak hieronder. - const es = this.snapOnOrAfter(progressCal, dataDate!); - const ef = progressCal.prevWorkInstant(es); - if (this.options.schedulingOptions?.preserveActualDatesInBackwardPass === true) { - for (const seq of preds) this.seqConstraint.delete(seq.id); - } - results.set(taskId, { es, ef }); - continue; - } if (t.actualFinish && t.completion >= 1) { // (1) VOLTOOID: volledig gepind op actuals — geen forward-drift voorbij actualFinish. // B4 (Opus-her-check T15-fixronde): `snapActualForward` i.p.v. een kale parse — snapt @@ -2801,8 +2787,17 @@ 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; + let candidateEf = early.ef; + if (this.usesP6CompletedDataDateWindow(task)) { + const progressCal = this.progressCalendarFor(task); + const projectedEs = this.snapOnOrAfter(progressCal, this.dataDate!); + candidateEf = progressCal.prevWorkInstant(projectedEs); + } + if (candidateEf > projectEnd) projectEnd = candidateEf; } if (this.options.schedulingOptions?.useProjectEndDateForFloat === true) { const configuredProjectEnd = parseInstant(this.options.projectEndDate ?? ''); @@ -2819,12 +2814,10 @@ export class CPMSolver { const task = this.tasks.get(taskId)!; const succs = this.successors.get(taskId) || []; - // Brongebonden XER-beleid: een voltooide activiteit houdt normaal haar geregistreerde - // actual-window aan de late zijde. De smalle completed-taskbronvorm gebruikt echter de - // bestaande P6-statusdatum/netwerksemantiek; opgeslagen XER early/late/float-uitkomsten zijn op beide - // paden bewust nooit solverinvoer. Niet-XER en mijlpalen behouden hun bestaande gedrag. - if (preserveActualDates && task.time.actualFinish && task.time.completion >= 1 - && !this.usesP6CompletedDataDateWindow(task)) { + // 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 (preserveActualDates && task.time.actualFinish && task.time.completion >= 1) { const ed = earlyDates.get(taskId)!; results.set(taskId, { ls: new Date(ed.es.getTime()), lf: new Date(ed.ef.getTime()) }); continue; @@ -2910,8 +2903,7 @@ export class CPMSolver { 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. - if (preserveActualDates && succTask.time.actualFinish && succTask.time.completion >= 1 - && !this.usesP6CompletedDataDateWindow(succTask)) continue; + if (preserveActualDates && succTask.time.actualFinish && succTask.time.completion >= 1) 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 diff --git a/src/engine/scheduler/scheduleAnalysis.ts b/src/engine/scheduler/scheduleAnalysis.ts index c976f5f88..34724169a 100644 --- a/src/engine/scheduler/scheduleAnalysis.ts +++ b/src/engine/scheduler/scheduleAnalysis.ts @@ -37,6 +37,7 @@ 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; @@ -60,7 +61,7 @@ export function computeScheduleResults(input: ScheduleAnalysisInput): CPMResult schedulingOptions, dataDate, truncatedLeadIds, hardPinViolatedIds, hammockNoFinishDriverIds, projectEngine, - calendarFor, signedFloat, projectedWorkMinutesBetween, + calendarFor, progressCalendarFor, signedFloat, projectedWorkMinutesBetween, constraintInstant, snapOnOrAfter, snapOnOrBefore, modeOf, } = input; @@ -230,8 +231,18 @@ export function computeScheduleResults(input: ScheduleAnalysisInput): CPMResult // verschillen wanneer een SNLT alleen de late start kapt). Kritiek = tf ≤ 0. const tt = taskObj.time; const completed = !!dataDate && tt.completion >= 1; - const p6CompletedDataDateWindow = completed - && usesP6CompletedDataDateWindow(taskObj, so); + const completedDisplayWindow = completed + && usesP6CompletedDataDateWindow(taskObj, so) + ? (() => { + const progressCal = progressCalendarFor(taskObj); + const es = snapOnOrAfter(progressCal, dataDate!); + return { + es, + ef: progressCal.prevWorkInstant(es), + mode: modeOf(progressCal), + }; + })() + : null; const finishFloat = signedFloat(early.ef, late.lf, cal, taskObj); const startFloat = signedFloat(early.es, late.ls, cal, taskObj); // Een EXPLICIETE P6-modus geldt ook voor lopende taken: start = LS−ES, finish = LF−EF en @@ -348,12 +359,11 @@ 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 displayActualLate = completed && so?.preserveActualDatesInBackwardPass === true - && !p6CompletedDataDateWindow; + const mode = completedDisplayWindow?.mode ?? modeOf(cal); + const displayActualLate = completed && so?.preserveActualDatesInBackwardPass === true; taskResults.set(taskId, { - earlyStart: formatInstant(early.es, mode), - earlyFinish: formatInstant(early.ef, mode), + earlyStart: formatInstant(completedDisplayWindow?.es ?? early.es, mode), + earlyFinish: formatInstant(completedDisplayWindow?.ef ?? early.ef, mode), lateStart: formatInstant(displayActualLate ? early.es : late.ls, mode), lateFinish: formatInstant(displayActualLate ? early.ef : late.lf, mode), totalFloat: tf, diff --git a/src/utils/p6CompletedTargetWindow.ts b/src/utils/p6CompletedTargetWindow.ts index 458d05dbc..cfdc73b60 100644 --- a/src/utils/p6CompletedTargetWindow.ts +++ b/src/utils/p6CompletedTargetWindow.ts @@ -1,6 +1,7 @@ import type { SchedulingOptions } from '@/types/project'; import type { Task } from '@/types/task'; import { isZeroDurationMilestone } from '@/engine/scheduler/duration'; +import { isLeafTask } from '@/utils/taskHierarchy'; /** * Alleen een bewezen XER-bladactiviteit mag voor een voltooide taak de P6-statusdatum als @@ -19,6 +20,7 @@ export function usesP6CompletedDataDateWindow( ): boolean { return schedulingOptions?.p6Source === 'XER' && schedulingOptions.p6UseRemainingStartForProgress === true + && isLeafTask(task) && task.p6ProjectId !== undefined && task.p6ProjectId !== '' && task.p6TaskId !== undefined && task.p6TaskId !== '' && task.p6ExplicitTargetWindow === true diff --git a/tests/planning/check-xer-product-fidelity-x12.ts b/tests/planning/check-xer-product-fidelity-x12.ts index 4c00a8c1e..e4fe9be3d 100644 --- a/tests/planning/check-xer-product-fidelity-x12.ts +++ b/tests/planning/check-xer-product-fidelity-x12.ts @@ -13,6 +13,7 @@ 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 { measureXerProductFidelity, type XerProductAxisCounts } from './xerProductFidelity'; @@ -1555,20 +1556,26 @@ async function productBaseline( // 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 moet zijn P6-planningswindow behouden wanneer haar geregistreerde -// historie daarvan afwijkt. De aanvullende vormen bewaken dat de grens smal blijft: meerdere -// assignments zijn niet causaal, actieve taken en completed milestones vallen er buiten en een +// 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 targetStart = '2026-01-02 08:00'; - const targetFinish = milestone ? targetStart : '2026-01-02 16:00'; + 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. @@ -1583,7 +1590,7 @@ async function productBaseline( `%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${targetStart}\t${targetFinish}`, + `%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`, @@ -1608,8 +1615,11 @@ async function productBaseline( 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(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'); @@ -1618,7 +1628,9 @@ async function productBaseline( const twoResult = resultOf(two); const activeResult = resultOf(active); const milestoneResult = resultOf(milestone); - eq('X12 F1 completed gewone XER-taak volgt de toegestane P6-statusdatum, niet actuals of raw output', { + 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, @@ -1626,7 +1638,7 @@ async function productBaseline( }, { explicitTargetWindow: true, earlyStart: '2026-01-05T08:00', earlyFinish: '2026-01-02T16:00', - lateStart: '2026-01-02T08:00', lateFinish: '2026-01-02T16:00', + lateStart: '2026-01-08T08:00', lateFinish: '2026-01-08T16:00', totalFloatMinutes: 0, freeFloatMinutes: 0, }); eq('X12 F1 raw early/late-mutatatie beïnvloedt de solver niet', { @@ -1634,8 +1646,8 @@ async function productBaseline( mutatedAxes: [oneRawMutatedResult?.earlyStart, oneRawMutatedResult?.earlyFinish, oneRawMutatedResult?.lateStart, oneRawMutatedResult?.lateFinish], }, { - oracleAxes: ['2026-01-05T08:00', '2026-01-02T16:00', '2026-01-02T08:00', '2026-01-02T16:00'], - mutatedAxes: ['2026-01-05T08:00', '2026-01-02T16:00', '2026-01-02T08:00', '2026-01-02T16:00'], + oracleAxes: ['2026-01-05T08:00', '2026-01-02T16:00', '2026-01-08T08:00', '2026-01-08T16:00'], + mutatedAxes: ['2026-01-05T08:00', '2026-01-02T16:00', '2026-01-08T08:00', '2026-01-08T16:00'], }); // De statusdatum, taakprovenance en gewone geplande start moeten ook na de native opslaggrens // aanwezig blijven; XER-archiefreconstructie levert daarmee dezelfde brongebonden route op. @@ -1647,7 +1659,7 @@ async function productBaseline( oneReloadedResult?.lateStart, oneReloadedResult?.lateFinish], }, { explicitTargetWindow: true, - axes: ['2026-01-05T08:00', '2026-01-02T16:00', '2026-01-02T08:00', '2026-01-02T16:00'], + axes: ['2026-01-05T08:00', '2026-01-02T16:00', '2026-01-08T08:00', '2026-01-08T16:00'], }); eq('X12 F2 meerdere XER-assignments veranderen completed-bronsemantiek niet', { assignmentCount: two.assignments.length, @@ -1667,6 +1679,155 @@ async function productBaseline( 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', { + 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'); + eq('X12 completed bronpredicate blijft fail-closed voor alle uitgesloten bronvormen', { + noPresence: usesP6CompletedDataDateWindow( + { ...oneTask, p6ExplicitTargetWindow: undefined }, one.project.schedulingOptions), + active: usesP6CompletedDataDateWindow(activeTask, active.project.schedulingOptions), + milestone: usesP6CompletedDataDateWindow(milestoneTask, milestone.project.schedulingOptions), + loe: usesP6CompletedDataDateWindow( + { ...oneTask, p6ActivityType: 'TT_LOE', isHammock: true }, one.project.schedulingOptions), + suspendResume: usesP6CompletedDataDateWindow( + { ...oneTask, p6SuspendResume: true }, one.project.schedulingOptions), + summary: usesP6CompletedDataDateWindow(summaryCandidate, one.project.schedulingOptions), + generic: usesP6CompletedDataDateWindow(oneTask, 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'); + eq('X12 A→FS→B(completed) houdt B-inversie buiten backwarddruk op open A', { + 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-05T08:00', lateFinish: '2026-01-05T16:00', totalFloatMinutes: 0, + }, + b: { + earlyStart: '2026-01-05T08:00', earlyFinish: '2026-01-02T16:00', + lateStart: '2026-01-08T08:00', lateFinish: '2026-01-08T16: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. @@ -1691,12 +1852,18 @@ async function productBaseline( }; const genericResult = genericTask(generic); const genericIfcResult = genericTask(genericIfc); - eq('X12 F5 generiek en gewoon IFC behouden bestaande actualdatumsemantiek', { - generic: [genericResult?.time.earlyStart, genericResult?.time.earlyFinish], - ifc: [genericIfcResult?.time.earlyStart, genericIfcResult?.time.earlyFinish], + 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'], - ifc: ['2026-01-08T08:00', '2026-01-08T16:00'], + 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], }); } From 37c4b21684539157a9d09ea8b524a8f7aea9f05e Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:48:11 +0200 Subject: [PATCH 066/171] Voeg taakniveau replayharnas voor XER toe --- tests/planning/check-xer-task-replay.ts | 303 ++++++++++++++++++ tests/planning/run-xer-task-replay.ts | 64 ++++ tests/planning/run.sh | 8 + .../planning/xer-task-replay-public-pin.json | 35 ++ tests/planning/xerTaskReplay.ts | 218 +++++++++++++ tests/planning/xerTaskReplayCorpus.ts | 196 +++++++++++ tests/planning/xerTaskReplayProduct.ts | 274 ++++++++++++++++ 7 files changed, 1098 insertions(+) create mode 100644 tests/planning/check-xer-task-replay.ts create mode 100644 tests/planning/run-xer-task-replay.ts create mode 100644 tests/planning/xer-task-replay-public-pin.json create mode 100644 tests/planning/xerTaskReplay.ts create mode 100644 tests/planning/xerTaskReplayCorpus.ts create mode 100644 tests/planning/xerTaskReplayProduct.ts diff --git a/tests/planning/check-xer-task-replay.ts b/tests/planning/check-xer-task-replay.ts new file mode 100644 index 000000000..8cd5a6158 --- /dev/null +++ b/tests/planning/check-xer-task-replay.ts @@ -0,0 +1,303 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +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, +} from './xerTaskReplayProduct'; +import { corpusReplayExitCode, runXerTaskReplayCorpus } from './xerTaskReplayCorpus'; + +const diffs: string[] = []; +let checks = 0; +const here = fileURLToPath(new URL('.', import.meta.url)); + +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(here, 'xer-corpus-manifest.json'), 'utf8')) as XerCorpusManifest; + const pin = JSON.parse(readFileSync(join(here, '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; + }>; + }; + eq('task replay: openbare instrumentpin heeft schema 1', pin.version, 1); + 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), + }, { + 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, +}); + +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 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 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', 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: ontbrekende taakcode is hard rood', () => evaluateXerTaskReplay({ + oracle: truth(), baseline: [base], counterfactual: [{ projectId: 'P1', tasks: [] }], predicate, + }), /ontbrekende counterfactualtaak P1\/A100/); + throws('task replay: extra taakcode is hard rood', () => evaluateXerTaskReplay({ + oracle: truth(), baseline: [{ ...base, tasks: [...base.tasks, { ...base.tasks[0]!, sourceTaskId: 'T2', taskCode: 'A200' }] }], + counterfactual: [cf], predicate, + }), /extra baselinetaak P1\/A200/); + throws('task replay: dubbele taakcode is hard rood', () => evaluateXerTaskReplay({ + oracle: truth(), baseline: [{ ...base, tasks: [...base.tasks, base.tasks[0]!] }], counterfactual: [cf], predicate, + }), /dubbele baselinetaak P1\/A100/); +} + +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/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 7c180d58b..5563d5ada 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -337,6 +337,14 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; 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 + # 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" 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..ab9429376 --- /dev/null +++ b/tests/planning/xer-task-replay-public-pin.json @@ -0,0 +1,35 @@ +{ + "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 + }, + "drop-p6-finish-milestone-boundary": { + "aggregate": { + "es": { "improved": 3, "regressed": 0, "unchanged": 13928 }, + "ef": { "improved": 3, "regressed": 2, "unchanged": 13932 }, + "ls": { "improved": 1, "regressed": 690, "unchanged": 13131 }, + "lf": { "improved": 1, "regressed": 704, "unchanged": 13108 }, + "tf": { "improved": 1, "regressed": 629, "unchanged": 13047 }, + "ff": { "improved": 0, "regressed": 1, "unchanged": 13321 }, + "overall": { "improved": 3, "regressed": 705, "unchanged": 13251 } + }, + "rejected": true, + "exitCode": 1 + } + } +} diff --git a/tests/planning/xerTaskReplay.ts b/tests/planning/xerTaskReplay.ts new file mode 100644 index 000000000..5137d18a6 --- /dev/null +++ b/tests/planning/xerTaskReplay.ts @@ -0,0 +1,218 @@ +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; + 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.taskCode.trim()) throw new Error(`task replay: lege ${kind}taakcode in project ${projectId}`); + if (indexed.has(task.taskCode)) throw new Error(`task replay: dubbele ${kind}taak ${projectId}/${task.taskCode}`); + indexed.set(task.taskCode, task); + } + return indexed; +} + +function indexPredicates(predicate: readonly XerReplayPredicateLog[]): Map { + const indexed = new Map(); + for (const log of predicate) { + const key = `${log.projectId}\u0000${log.taskCode}`; + if (!log.projectId.trim() || !log.taskCode.trim()) throw new Error('task replay: predicate mist project-id of taakcode'); + if (indexed.has(key)) throw new Error(`task replay: dubbele predicate ${log.projectId}/${log.taskCode}`); + 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 (tasks.has(task.taskCode)) throw new Error(`task replay: dubbele orakeltaak ${task.projectId}/${task.taskCode}`); + tasks.set(task.taskCode, 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 taskCode of truthTasks.keys()) { + if (!baselineTasks.has(taskCode)) throw new Error(`task replay: ontbrekende baselinetaak ${projectId}/${taskCode}`); + if (!counterfactualTasks.has(taskCode)) throw new Error(`task replay: ontbrekende counterfactualtaak ${projectId}/${taskCode}`); + } + for (const taskCode of baselineTasks.keys()) { + if (!truthTasks.has(taskCode)) throw new Error(`task replay: extra baselinetaak ${projectId}/${taskCode}`); + } + for (const taskCode of counterfactualTasks.keys()) { + if (!truthTasks.has(taskCode)) throw new Error(`task replay: extra counterfactualtaak ${projectId}/${taskCode}`); + } + + for (const [taskCode, truthTask] of truthTasks) { + const baselineTask = baselineTasks.get(taskCode)!; + const counterfactualTask = counterfactualTasks.get(taskCode)!; + if (baselineTask.sourceTaskId !== truthTask.taskId) { + throw new Error(`task replay: baseline bron-taak-id voor ${projectId}/${taskCode} verwacht ${truthTask.taskId}, kreeg ${baselineTask.sourceTaskId}`); + } + if (counterfactualTask.sourceTaskId !== truthTask.taskId) { + throw new Error(`task replay: counterfactual bron-taak-id voor ${projectId}/${taskCode} verwacht ${truthTask.taskId}, kreeg ${counterfactualTask.sourceTaskId}`); + } + const predicateKey = `${projectId}\u0000${taskCode}`; + const predicate = predicates.get(predicateKey); + if (!predicate) throw new Error(`task replay: ontbrekende predicate ${projectId}/${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: truthTask.taskId, 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.taskCode}`); + } + 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..3d4e230c1 --- /dev/null +++ b/tests/planning/xerTaskReplayCorpus.ts @@ -0,0 +1,196 @@ +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 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: 'one-manifest-entry-and-one-project-solve-clone-at-a-time'; +} + +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}`); + } +} + +/** + * Canonieke X12-selectie zonder alle corpusbytes tegelijk te bewaren. Pass 1 groepeert alleen + * hashes; pass 2 scant uitsluitend de manifest-orakels en dedupliceert dezelfde schemafingerprint. + */ +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: 'one-manifest-entry-and-one-project-solve-clone-at-a-time', + }; +} + +/** 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..ae003e11f --- /dev/null +++ b/tests/planning/xerTaskReplayProduct.ts @@ -0,0 +1,274 @@ +import { CalendarEngine } from '@/engine/scheduler/CalendarEngine'; +import { isZeroDurationMilestone } from '@/engine/scheduler/duration'; +import { solveProject } from '@/engine/scheduler/solveProject'; +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, matchedTaskCodes: 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; +} + +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 uitsluitend taakidentiteit en zes waarden. */ +function solveImported(imported: XerReplayMutableSolveInput): 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(`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, + ])); + 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, + }; + }), + }; +} + +/** 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, +): 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; + + for (const imported of imports) { + const decisions = sourceContexts(imported).map(context => ({ + context, + decision: candidate.predicate(context), + })); + const matchedTaskCodes = new Set(decisions + .filter(({ decision }) => decision.matched) + .map(({ context }) => context.task.wbsCode)); + predicate.push(...decisions.map(({ context, decision }) => ({ + projectId: context.projectId, + taskCode: context.task.wbsCode, + matched: decision.matched, + source: decision.source, + }))); + + const replayInput = cloneSolveInput(imported); + addProject(baseline, solveImported(replayInput), 'baseline'); + projectsSolvedSequentially++; + + // De kandidaat kiest expliciet zijn replaybasis. Een regelintegratie gebruikt doorgaans de + // zojuist opgeloste documentstaat; de synthetische nulcandidate bewijst twee gelijke bronruns. + // De compacte baselineprojectie is al losgekoppeld; er leeft steeds één actieve solveclone. + if (candidate.replayFrom === 'source') Object.assign(replayInput, cloneSolveInput(imported)); + candidate.apply(replayInput, matchedTaskCodes); + addProject(counterfactual, solveImported(replayInput), '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, + 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, +}; + +/** Bestaande onderzoeks-counterfactual: verwijder uitsluitend de XER finish-milestone-boundary. */ +export const dropFinishMilestoneBoundaryCandidate: XerTaskReplayCandidate = { + id: 'drop-p6-finish-milestone-boundary', + replayFrom: 'baseline', + 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, matchedTaskCodes) => { + if (matchedTaskCodes.size === 0) return; + const schedulingOptions = imported.project.schedulingOptions; + if (!schedulingOptions || schedulingOptions.p6Source !== 'XER') return; + imported.project.schedulingOptions = { + ...schedulingOptions, + p6FinishMilestoneBoundaryWindow: false, + }; + }, +}; From 9a2797aa450522444b739fb9684b91d238ce05c8 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:59:00 +0200 Subject: [PATCH 067/171] tests: maak XER task-replay lifecyclevast --- tests/planning/check-xer-task-replay.ts | 64 +++++++++++++++++-- .../planning/xer-task-replay-public-pin.json | 20 +++--- tests/planning/xerTaskReplayCorpus.ts | 11 ++-- tests/planning/xerTaskReplayProduct.ts | 47 ++++++++++++-- 4 files changed, 117 insertions(+), 25 deletions(-) diff --git a/tests/planning/check-xer-task-replay.ts b/tests/planning/check-xer-task-replay.ts index 8cd5a6158..13623b0f4 100644 --- a/tests/planning/check-xer-task-replay.ts +++ b/tests/planning/check-xer-task-replay.ts @@ -1,6 +1,5 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; import type { XerGroundTruth, XerGroundTruthTask } from './xerGroundTruth'; import type { XerCorpusManifest, XerSolvedProject, XerSolvedTask } from './xerFidelity'; import { scanXerGroundTruth } from './xerGroundTruth'; @@ -14,11 +13,15 @@ import { replayXerProductBeforeOracle, syntheticZeroRegressionCandidate, } from './xerTaskReplayProduct'; -import { corpusReplayExitCode, runXerTaskReplayCorpus } from './xerTaskReplayCorpus'; +import { + corpusReplayExitCode, + runXerTaskReplayCorpus, + XER_TASK_REPLAY_MEMORY_MODEL, +} from './xerTaskReplayCorpus'; const diffs: string[] = []; let checks = 0; -const here = fileURLToPath(new URL('.', import.meta.url)); +const planningDirectory = join(process.cwd(), 'tests', 'planning'); function eq(label: string, got: unknown, want: unknown): void { checks++; @@ -33,8 +36,8 @@ if (!corpusRoot) { } else if (!existsSync(corpusRoot)) { diffs.push('OPS_XER_CORPUS wijst niet naar een bestaande corpusmap'); } else { - const manifest = JSON.parse(readFileSync(join(here, 'xer-corpus-manifest.json'), 'utf8')) as XerCorpusManifest; - const pin = JSON.parse(readFileSync(join(here, 'xer-task-replay-public-pin.json'), 'utf8')) as { + 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; @@ -44,6 +47,7 @@ if (!corpusRoot) { aggregate: ReturnType['aggregate']; rejected: boolean; exitCode: number; + memoryModel: typeof XER_TASK_REPLAY_MEMORY_MODEL; }>; }; eq('task replay: openbare instrumentpin heeft schema 1', pin.version, 1); @@ -57,6 +61,7 @@ if (!corpusRoot) { aggregate: summary.aggregate, rejected: summary.rejected, exitCode: corpusReplayExitCode(summary), + memoryModel: summary.memoryModel, }, { manifestEntries: pin.manifestEntries, selectedEntries: pin.selectedEntries, @@ -88,6 +93,17 @@ const axes = (overrides: Partial = {}): XerGroundTru ...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', @@ -133,6 +149,19 @@ function truth(projectId = 'P1', taskCode = 'A100'): XerGroundTruth { ].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', { @@ -283,6 +312,9 @@ const predicate: XerReplayPredicateLog[] = [{ 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 taakcode is hard rood', () => evaluateXerTaskReplay({ oracle: truth(), baseline: [base], counterfactual: [{ projectId: 'P1', tasks: [] }], predicate, }), /ontbrekende counterfactualtaak P1\/A100/); @@ -293,6 +325,28 @@ const predicate: XerReplayPredicateLog[] = [{ throws('task replay: dubbele taakcode is hard rood', () => evaluateXerTaskReplay({ oracle: truth(), baseline: [{ ...base, tasks: [...base.tasks, base.tasks[0]!] }], counterfactual: [cf], predicate, }), /dubbele baselinetaak P1\/A100/); + throws('task replay: verkeerde baseline sourceTaskId is hard rood bij gelijke projectId en taskCode', () => evaluateXerTaskReplay({ + oracle: truth(), + baseline: [solved('P1', 'A100', { sourceTaskId: 'VERKEERD' })], + counterfactual: [cf], + predicate, + }), /baseline bron-taak-id voor P1\/A100 verwacht T1, kreeg VERKEERD/); + throws('task replay: verkeerde counterfactual sourceTaskId is hard rood bij gelijke projectId en taskCode', () => evaluateXerTaskReplay({ + oracle: truth(), + baseline: [base], + counterfactual: [solved('P1', 'A100', { sourceTaskId: 'VERKEERD' })], + predicate, + }), /counterfactual bron-taak-id voor P1\/A100 verwacht T1, kreeg VERKEERD/); + throws('task replay: ontbrekende predicate-identiteit is hard rood', () => evaluateXerTaskReplay({ + oracle: truth(), baseline: [base], counterfactual: [cf], predicate: [], + }), /ontbrekende predicate P1\/A100/); + throws('task replay: extra predicate-identiteit is hard rood', () => evaluateXerTaskReplay({ + oracle: truth(), baseline: [base], counterfactual: [cf], + predicate: [...predicate, { projectId: 'P1', taskCode: 'A200', matched: false, source: {} }], + }), /extra predicate P1\/A200/); + throws('task replay: dubbele predicate-identiteit is hard rood', () => evaluateXerTaskReplay({ + oracle: truth(), baseline: [base], counterfactual: [cf], predicate: [...predicate, ...predicate], + }), /dubbele predicate P1\/A100/); } if (diffs.length > 0) { diff --git a/tests/planning/xer-task-replay-public-pin.json b/tests/planning/xer-task-replay-public-pin.json index ab9429376..8cb540a8f 100644 --- a/tests/planning/xer-task-replay-public-pin.json +++ b/tests/planning/xer-task-replay-public-pin.json @@ -16,20 +16,22 @@ "overall": { "improved": 0, "regressed": 0, "unchanged": 13959 } }, "rejected": false, - "exitCode": 0 + "exitCode": 0, + "memoryModel": "one-manifest-entry-and-one-project-solve-clone-at-a-time" }, "drop-p6-finish-milestone-boundary": { "aggregate": { - "es": { "improved": 3, "regressed": 0, "unchanged": 13928 }, - "ef": { "improved": 3, "regressed": 2, "unchanged": 13932 }, - "ls": { "improved": 1, "regressed": 690, "unchanged": 13131 }, - "lf": { "improved": 1, "regressed": 704, "unchanged": 13108 }, - "tf": { "improved": 1, "regressed": 629, "unchanged": 13047 }, - "ff": { "improved": 0, "regressed": 1, "unchanged": 13321 }, - "overall": { "improved": 3, "regressed": 705, "unchanged": 13251 } + "es": { "improved": 0, "regressed": 0, "unchanged": 13931 }, + "ef": { "improved": 0, "regressed": 2, "unchanged": 13935 }, + "ls": { "improved": 0, "regressed": 690, "unchanged": 13132 }, + "lf": { "improved": 0, "regressed": 704, "unchanged": 13109 }, + "tf": { "improved": 0, "regressed": 629, "unchanged": 13048 }, + "ff": { "improved": 0, "regressed": 0, "unchanged": 13322 }, + "overall": { "improved": 0, "regressed": 704, "unchanged": 13255 } }, "rejected": true, - "exitCode": 1 + "exitCode": 1, + "memoryModel": "one-manifest-entry-and-one-project-solve-clone-at-a-time" } } } diff --git a/tests/planning/xerTaskReplayCorpus.ts b/tests/planning/xerTaskReplayCorpus.ts index 3d4e230c1..8b734ea1d 100644 --- a/tests/planning/xerTaskReplayCorpus.ts +++ b/tests/planning/xerTaskReplayCorpus.ts @@ -14,6 +14,8 @@ import { 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; @@ -32,7 +34,7 @@ export interface XerTaskReplayCorpusSummary { aggregate: XerTaskReplayResult['aggregate']; rejected: boolean; maxRssBytes: number; - memoryModel: 'one-manifest-entry-and-one-project-solve-clone-at-a-time'; + memoryModel: typeof XER_TASK_REPLAY_MEMORY_MODEL; } export interface XerTaskReplayCorpusOptions { @@ -99,8 +101,9 @@ function validateInventory(labels: readonly string[], manifest: XerCorpusManifes } /** - * Canonieke X12-selectie zonder alle corpusbytes tegelijk te bewaren. Pass 1 groepeert alleen - * hashes; pass 2 scant uitsluitend de manifest-orakels en dedupliceert dezelfde schemafingerprint. + * 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, @@ -185,7 +188,7 @@ export function runXerTaskReplayCorpus(options: XerTaskReplayCorpusOptions): Xer aggregate, rejected: XER_FIDELITY_AXES.some((axis: XerFidelityAxis) => aggregate[axis].regressed > 0), maxRssBytes, - memoryModel: 'one-manifest-entry-and-one-project-solve-clone-at-a-time', + memoryModel: XER_TASK_REPLAY_MEMORY_MODEL, }; } diff --git a/tests/planning/xerTaskReplayProduct.ts b/tests/planning/xerTaskReplayProduct.ts index ae003e11f..f2e04f657 100644 --- a/tests/planning/xerTaskReplayProduct.ts +++ b/tests/planning/xerTaskReplayProduct.ts @@ -48,6 +48,18 @@ export interface XerProductReplayBeforeOracle { 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; +} + function canonicalProductMinute(value: string | undefined): string | undefined { return value?.match(/^\d{4}-\d{2}-\d{2}$/) ? `${value}T00:00` : value; } @@ -165,6 +177,7 @@ function addProject(target: Map, project: XerSolvedPro export function replayXerProductBeforeOracle( bytes: Uint8Array, candidate: XerTaskReplayCandidate, + options: XerReplayOptions = {}, ): XerProductReplayBeforeOracle { const opened = readXER(bytes); const imports = isMultiDocumentImport(opened) @@ -174,6 +187,26 @@ export function replayXerProductBeforeOracle( const counterfactual = new Map(); const predicate: XerReplayPredicateLog[] = []; let projectsSolvedSequentially = 0; + let activeSolveClones = 0; + + function solveOne( + imported: XerReplayMutableSolveInput, + phase: XerReplayLifecycleEvent['phase'], + inputOrigin: XerReplayLifecycleEvent['inputOrigin'], + ): XerSolvedProject { + 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 => ({ @@ -191,15 +224,15 @@ export function replayXerProductBeforeOracle( }))); const replayInput = cloneSolveInput(imported); - addProject(baseline, solveImported(replayInput), 'baseline'); + addProject(baseline, solveOne(replayInput, 'baseline', 'fresh-source-clone'), 'baseline'); projectsSolvedSequentially++; - // De kandidaat kiest expliciet zijn replaybasis. Een regelintegratie gebruikt doorgaans de - // zojuist opgeloste documentstaat; de synthetische nulcandidate bewijst twee gelijke bronruns. - // De compacte baselineprojectie is al losgekoppeld; er leeft steeds één actieve solveclone. + const inputOrigin = candidate.replayFrom === 'source' + ? 'fresh-source-clone' + : 'baseline-solved-clone'; if (candidate.replayFrom === 'source') Object.assign(replayInput, cloneSolveInput(imported)); candidate.apply(replayInput, matchedTaskCodes); - addProject(counterfactual, solveImported(replayInput), 'counterfactual'); + addProject(counterfactual, solveOne(replayInput, 'counterfactual', inputOrigin), 'counterfactual'); projectsSolvedSequentially++; } @@ -239,10 +272,10 @@ export const syntheticZeroRegressionCandidate: XerTaskReplayCandidate = { apply: () => undefined, }; -/** Bestaande onderzoeks-counterfactual: verwijder uitsluitend de XER finish-milestone-boundary. */ +/** Onderzoeks-counterfactual vanaf een verse XER-bronclone: verwijder alleen de finish-milestone-boundary. */ export const dropFinishMilestoneBoundaryCandidate: XerTaskReplayCandidate = { id: 'drop-p6-finish-milestone-boundary', - replayFrom: 'baseline', + replayFrom: 'source', predicate: context => { const source = { p6Source: context.schedulingOptions?.p6Source ?? null, From 168f042531b32b4f18abfa8200d22ffad7c29411 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:21:01 +0200 Subject: [PATCH 068/171] Voeg XER-planned-floor-trace toe --- src/engine/scheduler/CPMSolver.ts | 58 +++++++++++ tests/planning/check-xer-task-replay.ts | 129 ++++++++++++++++++++++++ tests/planning/xerTaskReplayProduct.ts | 78 ++++++++++---- 3 files changed, 244 insertions(+), 21 deletions(-) diff --git a/src/engine/scheduler/CPMSolver.ts b/src/engine/scheduler/CPMSolver.ts index ca813eeda..3bbd09092 100644 --- a/src/engine/scheduler/CPMSolver.ts +++ b/src/engine/scheduler/CPMSolver.ts @@ -21,6 +21,12 @@ import { export interface CPMResult { tasks: Map; criticalPath: string[]; + /** + * 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 @@ -70,6 +76,17 @@ export interface CPMResult { error?: string; // Set if circular dependency detected } +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 @@ -246,6 +263,8 @@ 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 = {}; private options: CPMOptions; // Werkdag-gesnapte statusdatum (fase 2.6), of null ⇒ geen statusdatum-gedrag. Gezet in solve(). @@ -1058,6 +1077,7 @@ export class CPMSolver { this.hardPinViolatedIds = []; this.hammockNoFinishDriverIds = []; this.cappedTaskIds = []; + this.plannedFloorTraceByTaskId = {}; 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 @@ -1134,6 +1154,9 @@ 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 }; + } // 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]; @@ -1508,6 +1531,41 @@ export class CPMSolver { ? 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 + && !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, diff --git a/tests/planning/check-xer-task-replay.ts b/tests/planning/check-xer-task-replay.ts index 13623b0f4..50df70f1a 100644 --- a/tests/planning/check-xer-task-replay.ts +++ b/tests/planning/check-xer-task-replay.ts @@ -200,6 +200,135 @@ function truth(projectId = 'P1', taskCode = 'A100'): XerGroundTruth { }); } +function tasksByCode(projects: XerSolvedProject[]): Map { + return new Map(projects.flatMap(project => project.tasks.map(task => [task.taskCode, task] as const))); +} + +function predicateByCode(predicateLogs: readonly XerReplayPredicateLog[]): Map { + return new Map(predicateLogs.map(log => [log.taskCode, 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 baselineByCode = tasksByCode(traceReplay.baseline); + const predicateLogs = predicateByCode(traceReplay.predicate); + eq('task replay: planned-floor trace family projecteert baseline-uitkomsten en diagnosevelden via het replayharnas', { + floorBinds: { + earlyStart: baselineByCode.get('FB-SUCC')?.earlyStart, + earlyFinish: baselineByCode.get('FB-SUCC')?.earlyFinish, + source: predicateLogs.get('FB-SUCC')?.source, + }, + exactOneDay: { + earlyStart: baselineByCode.get('OD-SUCC')?.earlyStart, + earlyFinish: baselineByCode.get('OD-SUCC')?.earlyFinish, + source: predicateLogs.get('OD-SUCC')?.source, + }, + finishNotLater: { + earlyStart: baselineByCode.get('FN-SUCC')?.earlyStart, + earlyFinish: baselineByCode.get('FN-SUCC')?.earlyFinish, + source: predicateLogs.get('FN-SUCC')?.source, + }, + predecessorBoundary: { + earlyStart: baselineByCode.get('BC-SUCC')?.earlyStart, + earlyFinish: baselineByCode.get('BC-SUCC')?.earlyFinish, + source: predicateLogs.get('BC-SUCC')?.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', + }, + }, + }); +} + function solved(projectId = 'P1', taskCode = 'A100', overrides: Partial = {}): XerSolvedProject { return { projectId, diff --git a/tests/planning/xerTaskReplayProduct.ts b/tests/planning/xerTaskReplayProduct.ts index f2e04f657..0c8655044 100644 --- a/tests/planning/xerTaskReplayProduct.ts +++ b/tests/planning/xerTaskReplayProduct.ts @@ -1,6 +1,7 @@ import { CalendarEngine } from '@/engine/scheduler/CalendarEngine'; import { isZeroDurationMilestone } from '@/engine/scheduler/duration'; import { solveProject } from '@/engine/scheduler/solveProject'; +import type { CPMPlannedFloorTrace } from '@/engine/scheduler/CPMSolver'; import { isMultiDocumentImport, type ImportResult } from '@/services/importTypes'; import { readXER } from '@/services/xer/xerReader'; import type { WorkCalendar } from '@/types/calendar'; @@ -64,8 +65,13 @@ 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 uitsluitend taakidentiteit en zes waarden. */ -function solveImported(imported: XerReplayMutableSolveInput): XerSolvedProject { +/** Eén solvegraph tegelijk; de projectie bewaart daarna alleen taakuitkomsten en replaytrace. */ +interface XerReplaySolveResult { + project: XerSolvedProject; + plannedFloorTraceByTaskCode: Readonly>; +} + +function solveImported(imported: XerReplayMutableSolveInput): XerReplaySolveResult { const cpm = solveProject({ tasks: imported.tasks, sequences: imported.sequences, @@ -86,23 +92,32 @@ function solveImported(imported: XerReplayMutableSolveInput): XerSolvedProject { id, new CalendarEngine(calendar).hoursPerDay * 60, ])); + const sourceTaskById = new Map(imported.tasks.map(task => [task.id, task] as const)); + const plannedFloorTraceByTaskCode = Object.fromEntries(Object.entries(cpm.plannedFloorTraceByTaskId ?? {}) + .flatMap(([taskId, trace]) => { + const taskCode = sourceTaskById.get(taskId)?.wbsCode; + return taskCode ? [[taskCode, trace] as const] : []; + })); 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, - }; - }), + plannedFloorTraceByTaskCode, + 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, + }; + }), + }, }; } @@ -193,7 +208,7 @@ export function replayXerProductBeforeOracle( imported: XerReplayMutableSolveInput, phase: XerReplayLifecycleEvent['phase'], inputOrigin: XerReplayLifecycleEvent['inputOrigin'], - ): XerSolvedProject { + ): XerReplaySolveResult { activeSolveClones++; try { options.onLifecycleEvent?.({ @@ -224,7 +239,28 @@ export function replayXerProductBeforeOracle( }))); const replayInput = cloneSolveInput(imported); - addProject(baseline, solveOne(replayInput, 'baseline', 'fresh-source-clone'), 'baseline'); + const baselineSolve = solveOne(replayInput, 'baseline', 'fresh-source-clone'); + for (const log of predicate) { + if (log.projectId !== imported.project.id) continue; + const trace = baselineSolve.plannedFloorTraceByTaskCode[log.taskCode]; + if (!trace) continue; + log.source = { + ...log.source, + 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 } + : {}), + }; + } + addProject(baseline, baselineSolve.project, 'baseline'); projectsSolvedSequentially++; const inputOrigin = candidate.replayFrom === 'source' @@ -232,7 +268,7 @@ export function replayXerProductBeforeOracle( : 'baseline-solved-clone'; if (candidate.replayFrom === 'source') Object.assign(replayInput, cloneSolveInput(imported)); candidate.apply(replayInput, matchedTaskCodes); - addProject(counterfactual, solveOne(replayInput, 'counterfactual', inputOrigin), 'counterfactual'); + addProject(counterfactual, solveOne(replayInput, 'counterfactual', inputOrigin).project, 'counterfactual'); projectsSolvedSequentially++; } From f53dcc674fd52d105312d6530525eb62550cd19f Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:40:31 +0200 Subject: [PATCH 069/171] Herstel taakvaste XER-traceprojectie --- src/engine/scheduler/CPMSolver.ts | 1 + tests/planning/check-xer-task-replay.ts | 293 +++++++++++++++++++++--- tests/planning/xerTaskReplay.ts | 61 +++-- tests/planning/xerTaskReplayProduct.ts | 35 +-- 4 files changed, 316 insertions(+), 74 deletions(-) diff --git a/src/engine/scheduler/CPMSolver.ts b/src/engine/scheduler/CPMSolver.ts index 3bbd09092..8354c52fb 100644 --- a/src/engine/scheduler/CPMSolver.ts +++ b/src/engine/scheduler/CPMSolver.ts @@ -1534,6 +1534,7 @@ export class CPMSolver { if ( this.options.schedulingOptions?.p6Source === 'XER' && task.p6ActivityType !== undefined + && task.p6ExplicitTargetWindow === true && !Number.isNaN(plannedFloor.getTime()) && !Number.isNaN(plannedFinish.getTime()) && !Number.isNaN(networkFinish.getTime()) diff --git a/tests/planning/check-xer-task-replay.ts b/tests/planning/check-xer-task-replay.ts index 50df70f1a..e77e9d1cc 100644 --- a/tests/planning/check-xer-task-replay.ts +++ b/tests/planning/check-xer-task-replay.ts @@ -12,6 +12,7 @@ import { dropFinishMilestoneBoundaryCandidate, replayXerProductBeforeOracle, syntheticZeroRegressionCandidate, + type XerTaskReplayCandidate, } from './xerTaskReplayProduct'; import { corpusReplayExitCode, @@ -200,12 +201,12 @@ function truth(projectId = 'P1', taskCode = 'A100'): XerGroundTruth { }); } -function tasksByCode(projects: XerSolvedProject[]): Map { - return new Map(projects.flatMap(project => project.tasks.map(task => [task.taskCode, task] as const))); +function tasksBySourceId(projects: XerSolvedProject[]): Map { + return new Map(projects.flatMap(project => project.tasks.map(task => [task.sourceTaskId, task] as const))); } -function predicateByCode(predicateLogs: readonly XerReplayPredicateLog[]): Map { - return new Map(predicateLogs.map(log => [log.taskCode, log] as const)); +function predicateBySourceId(predicateLogs: readonly XerReplayPredicateLog[]): Map { + return new Map(predicateLogs.map(log => [log.sourceTaskId, log] as const)); } { @@ -238,28 +239,28 @@ function predicateByCode(predicateLogs: readonly XerReplayPredicateLog[]): Map ({ + 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, @@ -347,7 +567,8 @@ function solved(projectId = 'P1', taskCode = 'A100', overrides: Partial evaluateXerTaskReplay({ oracle: truth(), baseline: [base], counterfactual: [cf, cf], predicate, }), /dubbel counterfactualproject P1/); - throws('task replay: ontbrekende taakcode is hard rood', () => evaluateXerTaskReplay({ + throws('task replay: ontbrekende counterfactual-taak-id is hard rood', () => evaluateXerTaskReplay({ oracle: truth(), baseline: [base], counterfactual: [{ projectId: 'P1', tasks: [] }], predicate, - }), /ontbrekende counterfactualtaak P1\/A100/); - throws('task replay: extra taakcode is hard rood', () => evaluateXerTaskReplay({ + }), /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\/A200/); - throws('task replay: dubbele taakcode is hard rood', () => evaluateXerTaskReplay({ + }), /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 P1\/A100/); - throws('task replay: verkeerde baseline sourceTaskId is hard rood bij gelijke projectId en taskCode', () => evaluateXerTaskReplay({ + }), /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, - }), /baseline bron-taak-id voor P1\/A100 verwacht T1, kreeg VERKEERD/); - throws('task replay: verkeerde counterfactual sourceTaskId is hard rood bij gelijke projectId en taskCode', () => evaluateXerTaskReplay({ + }), /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, - }), /counterfactual bron-taak-id voor P1\/A100 verwacht T1, kreeg VERKEERD/); + }), /ontbrekende counterfactualtaak P1\/T1/); throws('task replay: ontbrekende predicate-identiteit is hard rood', () => evaluateXerTaskReplay({ oracle: truth(), baseline: [base], counterfactual: [cf], predicate: [], - }), /ontbrekende predicate P1\/A100/); + }), /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', taskCode: 'A200', matched: false, source: {} }], - }), /extra predicate P1\/A200/); + 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\/A100/); + }), /dubbele predicate P1\/T1/); } if (diffs.length > 0) { diff --git a/tests/planning/xerTaskReplay.ts b/tests/planning/xerTaskReplay.ts index 5137d18a6..c33a81ba2 100644 --- a/tests/planning/xerTaskReplay.ts +++ b/tests/planning/xerTaskReplay.ts @@ -12,6 +12,7 @@ export interface XerReplayCounts { export interface XerReplayPredicateLog { projectId: string; + sourceTaskId: string; taskCode: string; matched: boolean; /** Uitsluitend geselecteerde bron-/provenancefeiten; nooit P6-orakelwaarden. */ @@ -104,9 +105,12 @@ function indexTasks( ): 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.taskCode)) throw new Error(`task replay: dubbele ${kind}taak ${projectId}/${task.taskCode}`); - indexed.set(task.taskCode, task); + if (indexed.has(task.sourceTaskId)) { + throw new Error(`task replay: dubbele ${kind}taak-id ${projectId}/${task.sourceTaskId}`); + } + indexed.set(task.sourceTaskId, task); } return indexed; } @@ -114,9 +118,12 @@ function indexTasks( function indexPredicates(predicate: readonly XerReplayPredicateLog[]): Map { const indexed = new Map(); for (const log of predicate) { - const key = `${log.projectId}\u0000${log.taskCode}`; - if (!log.projectId.trim() || !log.taskCode.trim()) throw new Error('task replay: predicate mist project-id of taakcode'); - if (indexed.has(key)) throw new Error(`task replay: dubbele predicate ${log.projectId}/${log.taskCode}`); + 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; @@ -134,8 +141,9 @@ export function evaluateXerTaskReplay(input: EvaluateXerTaskReplayInput): XerTas const truthProjects = new Map>(); for (const task of input.oracle.tasks) { const tasks = truthProjects.get(task.projectId) ?? new Map(); - if (tasks.has(task.taskCode)) throw new Error(`task replay: dubbele orakeltaak ${task.projectId}/${task.taskCode}`); - tasks.set(task.taskCode, task); + 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); @@ -162,29 +170,32 @@ export function evaluateXerTaskReplay(input: EvaluateXerTaskReplayInput): XerTas 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 taskCode of truthTasks.keys()) { - if (!baselineTasks.has(taskCode)) throw new Error(`task replay: ontbrekende baselinetaak ${projectId}/${taskCode}`); - if (!counterfactualTasks.has(taskCode)) throw new Error(`task replay: ontbrekende counterfactualtaak ${projectId}/${taskCode}`); + 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 taskCode of baselineTasks.keys()) { - if (!truthTasks.has(taskCode)) throw new Error(`task replay: extra baselinetaak ${projectId}/${taskCode}`); + for (const taskId of baselineTasks.keys()) { + if (!truthTasks.has(taskId)) throw new Error(`task replay: extra baselinetaak ${projectId}/${taskId}`); } - for (const taskCode of counterfactualTasks.keys()) { - if (!truthTasks.has(taskCode)) throw new Error(`task replay: extra counterfactualtaak ${projectId}/${taskCode}`); + for (const taskId of counterfactualTasks.keys()) { + if (!truthTasks.has(taskId)) throw new Error(`task replay: extra counterfactualtaak ${projectId}/${taskId}`); } - for (const [taskCode, truthTask] of truthTasks) { - const baselineTask = baselineTasks.get(taskCode)!; - const counterfactualTask = counterfactualTasks.get(taskCode)!; - if (baselineTask.sourceTaskId !== truthTask.taskId) { - throw new Error(`task replay: baseline bron-taak-id voor ${projectId}/${taskCode} verwacht ${truthTask.taskId}, kreeg ${baselineTask.sourceTaskId}`); + 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.sourceTaskId !== truthTask.taskId) { - throw new Error(`task replay: counterfactual bron-taak-id voor ${projectId}/${taskCode} verwacht ${truthTask.taskId}, kreeg ${counterfactualTask.sourceTaskId}`); + 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${taskCode}`; + const predicateKey = `${projectId}\u0000${taskId}`; const predicate = predicates.get(predicateKey); - if (!predicate) throw new Error(`task replay: ontbrekende predicate ${projectId}/${taskCode}`); + 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]; @@ -201,13 +212,13 @@ export function evaluateXerTaskReplay(input: EvaluateXerTaskReplayInput): XerTas ? 'improved' : 'unchanged'; if (measurable.length > 0) count(aggregate.overall, overall); - results.push({ projectId, taskId: truthTask.taskId, taskCode, predicate, axes, 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.taskCode}`); + throw new Error(`task replay: extra predicate ${extra.projectId}/${extra.sourceTaskId}`); } return { tasks: results, predicate: [...input.predicate], aggregate }; } diff --git a/tests/planning/xerTaskReplayProduct.ts b/tests/planning/xerTaskReplayProduct.ts index 0c8655044..6b9672e47 100644 --- a/tests/planning/xerTaskReplayProduct.ts +++ b/tests/planning/xerTaskReplayProduct.ts @@ -33,7 +33,7 @@ export interface XerTaskReplayCandidate { replayFrom: 'baseline' | 'source'; predicate(context: XerReplaySourceContext): XerReplayPredicateDecision; /** Test-only mutatie op een verse clone. Oraclewaarden zijn niet beschikbaar in deze API. */ - apply(imported: XerReplayMutableSolveInput, matchedTaskCodes: ReadonlySet): void; + apply(imported: XerReplayMutableSolveInput, matchedTaskIds: ReadonlySet): void; } export type XerReplayMutableSolveInput = Pick< @@ -68,7 +68,7 @@ function canonicalProductMinute(value: string | undefined): string | undefined { /** Eén solvegraph tegelijk; de projectie bewaart daarna alleen taakuitkomsten en replaytrace. */ interface XerReplaySolveResult { project: XerSolvedProject; - plannedFloorTraceByTaskCode: Readonly>; + plannedFloorTraceBySourceTaskId: Readonly>; } function solveImported(imported: XerReplayMutableSolveInput): XerReplaySolveResult { @@ -93,13 +93,13 @@ function solveImported(imported: XerReplayMutableSolveInput): XerReplaySolveResu new CalendarEngine(calendar).hoursPerDay * 60, ])); const sourceTaskById = new Map(imported.tasks.map(task => [task.id, task] as const)); - const plannedFloorTraceByTaskCode = Object.fromEntries(Object.entries(cpm.plannedFloorTraceByTaskId ?? {}) + const plannedFloorTraceBySourceTaskId = Object.fromEntries(Object.entries(cpm.plannedFloorTraceByTaskId ?? {}) .flatMap(([taskId, trace]) => { - const taskCode = sourceTaskById.get(taskId)?.wbsCode; - return taskCode ? [[taskCode, trace] as const] : []; + const sourceTask = sourceTaskById.get(taskId); + return sourceTask?.id.trim() ? [[sourceTask.id, trace] as const] : []; })); return { - plannedFloorTraceByTaskCode, + plannedFloorTraceBySourceTaskId, project: { projectId: imported.project.id, tasks: imported.tasks.filter(task => task.p6ActivityType !== undefined).map(task => { @@ -228,21 +228,23 @@ export function replayXerProductBeforeOracle( context, decision: candidate.predicate(context), })); - const matchedTaskCodes = new Set(decisions + const matchedTaskIds = new Set(decisions .filter(({ decision }) => decision.matched) - .map(({ context }) => context.task.wbsCode)); - predicate.push(...decisions.map(({ context, decision }) => ({ + .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 predicate) { - if (log.projectId !== imported.project.id) continue; - const trace = baselineSolve.plannedFloorTraceByTaskCode[log.taskCode]; + for (const log of projectPredicate) { + if (!log.sourceTaskId.trim()) continue; + const trace = baselineSolve.plannedFloorTraceBySourceTaskId[log.sourceTaskId]; if (!trace) continue; log.source = { ...log.source, @@ -267,7 +269,7 @@ export function replayXerProductBeforeOracle( ? 'fresh-source-clone' : 'baseline-solved-clone'; if (candidate.replayFrom === 'source') Object.assign(replayInput, cloneSolveInput(imported)); - candidate.apply(replayInput, matchedTaskCodes); + candidate.apply(replayInput, matchedTaskIds); addProject(counterfactual, solveOne(replayInput, 'counterfactual', inputOrigin).project, 'counterfactual'); projectsSolvedSequentially++; } @@ -279,6 +281,7 @@ export function replayXerProductBeforeOracle( 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 }, @@ -331,8 +334,8 @@ export const dropFinishMilestoneBoundaryCandidate: XerTaskReplayCandidate = { source, }; }, - apply: (imported, matchedTaskCodes) => { - if (matchedTaskCodes.size === 0) return; + apply: (imported, matchedTaskIds) => { + if (matchedTaskIds.size === 0) return; const schedulingOptions = imported.project.schedulingOptions; if (!schedulingOptions || schedulingOptions.p6Source !== 'XER') return; imported.project.schedulingOptions = { From 7ca900d6fbc43833bfdccde0ebace3f56eafb33b Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:43:49 +0200 Subject: [PATCH 070/171] Voeg XER backward float trace toe --- src/engine/scheduler/CPMSolver.ts | 78 +++++- src/engine/scheduler/scheduleAnalysis.ts | 31 ++- .../check-xer-backward-float-trace.ts | 223 ++++++++++++++++++ tests/planning/run.sh | 7 + tests/planning/xerTaskReplayProduct.ts | 51 ++-- 5 files changed, 372 insertions(+), 18 deletions(-) create mode 100644 tests/planning/check-xer-backward-float-trace.ts diff --git a/src/engine/scheduler/CPMSolver.ts b/src/engine/scheduler/CPMSolver.ts index 8354c52fb..0e8ed4dbd 100644 --- a/src/engine/scheduler/CPMSolver.ts +++ b/src/engine/scheduler/CPMSolver.ts @@ -21,6 +21,8 @@ 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 @@ -76,6 +78,36 @@ export interface CPMResult { error?: string; // Set if circular dependency detected } +export type CpmProjectEndSource = + | 'maxEarlyFinish' + | 'completedDisplayWindow' + | 'useProjectEndDateForFloat'; + +export type CpmLateFinishSource = + | 'projectEnd' + | 'successorConstraint'; + +export type CpmLateStartSource = + | 'subDuration' + | 'subRemainingDuration'; + +export type CpmFreeFloatSource = + | 'derivedFromSuccessor' + | 'projectEndFinishMilestoneBoundary' + | 'clampedZero'; + +export interface CpmTaskBackwardFloatTrace { + lateFinishSource: CpmLateFinishSource; + lateStartSource: CpmLateStartSource; + freeFloatSource: CpmFreeFloatSource; + displayActualLate: boolean; +} + +export interface CpmBackwardFloatTrace { + projectEndSource: CpmProjectEndSource; + byTaskId: Record; +} + export interface CPMPlannedFloorTrace { preFloorEarlyStart: string; preFloorEarlyFinish: string; @@ -265,6 +297,8 @@ export class CPMSolver { 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(). @@ -393,6 +427,20 @@ export class CPMSolver { return this.dataDate !== null && isP6CompletedDataDateCandidate(task, 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, + }; + this.backwardFloatTrace.byTaskId[taskId] = { ...previous, ...update }; + } + /** Smalle P6-XER-resultaatprojectie; de CalendarEngine-primitieven blijven formaatneutraal. */ private p6ProjectedSubtract(eng: CalendarEngine, end: Date, minutes: number): Date { return this.options.schedulingOptions?.p6Source === 'XER' @@ -1078,6 +1126,9 @@ export class CPMSolver { 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 @@ -1146,6 +1197,7 @@ export class CPMSolver { 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). @@ -1157,6 +1209,12 @@ export class CPMSolver { 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]; @@ -2851,17 +2909,23 @@ export class CPMSolver { const task = this.tasks.get(taskId); if (!early || !task) continue; let candidateEf = early.ef; + let projectEndSource: CpmProjectEndSource = 'maxEarlyFinish'; if (this.usesP6CompletedDataDateWindow(task)) { const progressCal = this.progressCalendarFor(task); const projectedEs = this.snapOnOrAfter(progressCal, this.dataDate!); candidateEf = progressCal.prevWorkInstant(projectedEs); + projectEndSource = 'completedDisplayWindow'; + if (this.backwardFloatTrace) this.backwardFloatTrace.projectEndSource = projectEndSource; + } + if (candidateEf > projectEnd) { + projectEnd = candidateEf; } - if (candidateEf > projectEnd) projectEnd = candidateEf; } 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'; } } @@ -2878,6 +2942,11 @@ export class CPMSolver { // XER early/late/float-uitkomsten zijn op geen van beide paden solverinvoer. if (preserveActualDates && task.time.actualFinish && task.time.completion >= 1) { 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; } @@ -2946,6 +3015,7 @@ export class CPMSolver { && (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; } @@ -2956,6 +3026,7 @@ export class CPMSolver { // 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); @@ -2998,6 +3069,7 @@ export class CPMSolver { ); if (constraintDate < lateFinish) { lateFinish = constraintDate; + lateFinishSource = 'successorConstraint'; } } @@ -3036,6 +3108,10 @@ export class CPMSolver { 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/scheduleAnalysis.ts b/src/engine/scheduler/scheduleAnalysis.ts index 34724169a..654caae29 100644 --- a/src/engine/scheduler/scheduleAnalysis.ts +++ b/src/engine/scheduler/scheduleAnalysis.ts @@ -2,7 +2,7 @@ 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'; @@ -47,6 +47,8 @@ export interface ScheduleAnalysisInput { 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; } /** @@ -63,6 +65,7 @@ export function computeScheduleResults(input: ScheduleAnalysisInput): CPMResult projectEngine, calendarFor, progressCalendarFor, signedFloat, projectedWorkMinutesBetween, constraintInstant, snapOnOrAfter, snapOnOrBefore, modeOf, + backwardFloatTrace, } = input; const taskResults = new Map(); @@ -192,6 +195,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 @@ -215,6 +219,7 @@ export function computeScheduleResults(input: ScheduleAnalysisInput): CPMResult && 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, @@ -224,7 +229,10 @@ export function computeScheduleResults(input: ScheduleAnalysisInput): CPMResult // 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; + && 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 @@ -296,12 +304,16 @@ export function computeScheduleResults(input: ScheduleAnalysisInput): CPMResult // 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; + 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). @@ -361,6 +373,19 @@ export function computeScheduleResults(input: ScheduleAnalysisInput): CPMResult // `formatDate` (byte-identiek), uur-taak ⇒ `YYYY-MM-DDTHH:mm`. const mode = completedDisplayWindow?.mode ?? modeOf(cal); const displayActualLate = completed && so?.preserveActualDatesInBackwardPass === true; + if (backwardFloatTrace) { + const prior = backwardFloatTrace.byTaskId[taskId] ?? { + lateFinishSource: 'projectEnd' as const, + lateStartSource: 'subDuration' as const, + freeFloatSource: 'derivedFromSuccessor' as const, + displayActualLate: false, + }; + backwardFloatTrace.byTaskId[taskId] = { + ...prior, + freeFloatSource, + displayActualLate, + }; + } taskResults.set(taskId, { earlyStart: formatInstant(completedDisplayWindow?.es ?? early.es, mode), earlyFinish: formatInstant(completedDisplayWindow?.ef ?? early.ef, mode), 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..50b8ab3dd --- /dev/null +++ b/tests/planning/check-xer-backward-float-trace.ts @@ -0,0 +1,223 @@ +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; + expected: { + projectEndSource: string; + completedLateStartSource: string; + milestoneFreeFloatSource: string; + }; +} + +function fixtureBytes(variant: Variant): 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\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', + '%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', + '%T\tTASKPRED', + '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', + '%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', + '%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; +} + +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); +} + +const variants: Variant[] = [ + { + id: 'completed-window', remainingStart: true, useProjectEndDateForFloat: false, + finishMilestoneBoundary: true, + expected: { + projectEndSource: 'completedDisplayWindow', + completedLateStartSource: 'subRemainingDuration', + milestoneFreeFloatSource: 'projectEndFinishMilestoneBoundary', + }, + }, + { + id: 'remaining-start-uit', remainingStart: false, useProjectEndDateForFloat: false, + finishMilestoneBoundary: true, + expected: { + projectEndSource: 'maxEarlyFinish', + completedLateStartSource: 'subDuration', + milestoneFreeFloatSource: 'projectEndFinishMilestoneBoundary', + }, + }, + { + id: 'expliciet-projecteinde', remainingStart: true, useProjectEndDateForFloat: true, + finishMilestoneBoundary: true, + expected: { + projectEndSource: 'useProjectEndDateForFloat', + completedLateStartSource: 'subRemainingDuration', + milestoneFreeFloatSource: 'clampedZero', + }, + }, + { + id: 'finish-boundary-uit', remainingStart: true, useProjectEndDateForFloat: false, + finishMilestoneBoundary: false, + expected: { + projectEndSource: 'completedDisplayWindow', + completedLateStartSource: 'subRemainingDuration', + milestoneFreeFloatSource: 'derivedFromSuccessor', + }, + }, +]; + +{ + 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`, { + projectEndSource: trace.projectEndSource, + completedLateStartSource: (trace.completed as { lateStartSource?: string } | undefined)?.lateStartSource, + completedDisplayActualLate: (trace.completed as { displayActualLate?: boolean } | undefined)?.displayActualLate, + milestoneFreeFloatSource: (trace.milestone as { freeFloatSource?: string } | undefined)?.freeFloatSource, + }, { + projectEndSource: variant.expected.projectEndSource, + completedLateStartSource: variant.expected.completedLateStartSource, + completedDisplayActualLate: true, + 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: ['C', 'M', 'O', 'P'], + hasTaskCodeKey: 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, + }, { + projectEndSource: 'completedDisplayWindow', + lateStartSource: 'subRemainingDuration', + displayActualLate: true, + }); +} + +{ + 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/run.sh b/tests/planning/run.sh index 5563d5ada..f14bd6068 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -345,6 +345,13 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then 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" diff --git a/tests/planning/xerTaskReplayProduct.ts b/tests/planning/xerTaskReplayProduct.ts index 6b9672e47..c2c685b8e 100644 --- a/tests/planning/xerTaskReplayProduct.ts +++ b/tests/planning/xerTaskReplayProduct.ts @@ -1,7 +1,7 @@ import { CalendarEngine } from '@/engine/scheduler/CalendarEngine'; import { isZeroDurationMilestone } from '@/engine/scheduler/duration'; import { solveProject } from '@/engine/scheduler/solveProject'; -import type { CPMPlannedFloorTrace } from '@/engine/scheduler/CPMSolver'; +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'; @@ -59,6 +59,8 @@ export interface XerReplayLifecycleEvent { 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 { @@ -69,6 +71,8 @@ function canonicalProductMinute(value: string | undefined): string | undefined { interface XerReplaySolveResult { project: XerSolvedProject; plannedFloorTraceBySourceTaskId: Readonly>; + backwardFloatTraceProjectEndSource?: CpmProjectEndSource; + backwardFloatTraceBySourceTaskId: Readonly>; } function solveImported(imported: XerReplayMutableSolveInput): XerReplaySolveResult { @@ -98,8 +102,15 @@ function solveImported(imported: XerReplayMutableSolveInput): XerReplaySolveResu 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 => { @@ -245,21 +256,33 @@ export function replayXerProductBeforeOracle( for (const log of projectPredicate) { if (!log.sourceTaskId.trim()) continue; const trace = baselineSolve.plannedFloorTraceBySourceTaskId[log.sourceTaskId]; - if (!trace) continue; + const backwardFloatTrace = options.includeBackwardFloatTrace + ? baselineSolve.backwardFloatTraceBySourceTaskId[log.sourceTaskId] + : undefined; + if (!trace && !backwardFloatTrace) continue; log.source = { ...log.source, - 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 } - : {}), + ...(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, + } : {}), }; } addProject(baseline, baselineSolve.project, 'baseline'); From 23cf072a49ad518e43178720cddd3fe74c7be74a Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:43:10 +0200 Subject: [PATCH 071/171] Fix XER-trace bij verliezende completed-window --- src/engine/scheduler/CPMSolver.ts | 11 ++- .../check-xer-backward-float-trace.ts | 83 +++++++++++++++---- 2 files changed, 74 insertions(+), 20 deletions(-) diff --git a/src/engine/scheduler/CPMSolver.ts b/src/engine/scheduler/CPMSolver.ts index 0e8ed4dbd..1c4920f9d 100644 --- a/src/engine/scheduler/CPMSolver.ts +++ b/src/engine/scheduler/CPMSolver.ts @@ -2908,17 +2908,20 @@ export class CPMSolver { const early = earlyDates.get(taskId); const task = this.tasks.get(taskId); if (!early || !task) continue; + const usesCompletedDisplayWindow = this.usesP6CompletedDataDateWindow(task); let candidateEf = early.ef; - let projectEndSource: CpmProjectEndSource = 'maxEarlyFinish'; - if (this.usesP6CompletedDataDateWindow(task)) { + if (usesCompletedDisplayWindow) { const progressCal = this.progressCalendarFor(task); const projectedEs = this.snapOnOrAfter(progressCal, this.dataDate!); candidateEf = progressCal.prevWorkInstant(projectedEs); - projectEndSource = 'completedDisplayWindow'; - if (this.backwardFloatTrace) this.backwardFloatTrace.projectEndSource = projectEndSource; } if (candidateEf > projectEnd) { projectEnd = candidateEf; + if (this.backwardFloatTrace) { + this.backwardFloatTrace.projectEndSource = usesCompletedDisplayWindow + ? 'completedDisplayWindow' + : 'maxEarlyFinish'; + } } } if (this.options.schedulingOptions?.useProjectEndDateForFloat === true) { diff --git a/tests/planning/check-xer-backward-float-trace.ts b/tests/planning/check-xer-backward-float-trace.ts index 50b8ab3dd..375f6dea2 100644 --- a/tests/planning/check-xer-backward-float-trace.ts +++ b/tests/planning/check-xer-backward-float-trace.ts @@ -21,14 +21,39 @@ interface Variant { remainingStart: boolean; useProjectEndDateForFloat: boolean; finishMilestoneBoundary: boolean; + fixtureShape?: 'network' | 'completedOnly'; + laterOpenTaskWinsProjectEnd?: boolean; expected: { - projectEndSource: string; + projectEndSource?: string; completedLateStartSource: string; - milestoneFreeFloatSource: 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', @@ -42,15 +67,11 @@ function fixtureBytes(variant: Variant): Uint8Array { `%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', - '%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', + ...taskRows, + ...laterOpenWinnerRows, '%T\tTASKPRED', '%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt', - '%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', + ...taskPredRows, '%E', ].join('\n')); } @@ -100,39 +121,61 @@ function solveTraceVariant(variant: Variant): TraceProjection { const variants: Variant[] = [ { - id: 'completed-window', remainingStart: true, useProjectEndDateForFloat: false, + id: 'completed-window-wint', + remainingStart: true, + useProjectEndDateForFloat: false, finishMilestoneBoundary: true, + fixtureShape: 'completedOnly', expected: { projectEndSource: 'completedDisplayWindow', completedLateStartSource: 'subRemainingDuration', + taskIds: ['C'], + }, + }, + { + id: 'completed-window-verliest-van-latere-open-taak', + remainingStart: true, + useProjectEndDateForFloat: false, + finishMilestoneBoundary: true, + fixtureShape: 'network', + laterOpenTaskWinsProjectEnd: true, + expected: { + projectEndSource: 'maxEarlyFinish', + completedLateStartSource: 'subRemainingDuration', 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: 'subRemainingDuration', milestoneFreeFloatSource: 'clampedZero', + taskIds: ['C', 'M', 'O', 'P'], }, }, { id: 'finish-boundary-uit', remainingStart: true, useProjectEndDateForFloat: false, finishMilestoneBoundary: false, + fixtureShape: 'network', expected: { - projectEndSource: 'completedDisplayWindow', completedLateStartSource: 'subRemainingDuration', milestoneFreeFloatSource: 'derivedFromSuccessor', + taskIds: ['C', 'M', 'O', 'P'], }, }, ]; @@ -164,21 +207,29 @@ const variants: Variant[] = [ for (const variant of variants) { const trace = solveTraceVariant(variant); eq(`backward-float-trace ${variant.id}: beslisbronnen`, { - projectEndSource: trace.projectEndSource, + ...(variant.expected.projectEndSource !== undefined + ? { projectEndSource: trace.projectEndSource } + : {}), completedLateStartSource: (trace.completed as { lateStartSource?: string } | undefined)?.lateStartSource, completedDisplayActualLate: (trace.completed as { displayActualLate?: boolean } | undefined)?.displayActualLate, - milestoneFreeFloatSource: (trace.milestone as { freeFloatSource?: string } | undefined)?.freeFloatSource, + ...(variant.expected.milestoneFreeFloatSource !== undefined + ? { milestoneFreeFloatSource: (trace.milestone as { freeFloatSource?: string } | undefined)?.freeFloatSource } + : {}), }, { - projectEndSource: variant.expected.projectEndSource, + ...(variant.expected.projectEndSource !== undefined + ? { projectEndSource: variant.expected.projectEndSource } + : {}), completedLateStartSource: variant.expected.completedLateStartSource, completedDisplayActualLate: true, - milestoneFreeFloatSource: variant.expected.milestoneFreeFloatSource, + ...(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: ['C', 'M', 'O', 'P'], + taskIds: variant.expected.taskIds, hasTaskCodeKey: false, }); } From 1d7ddd09725ab56f1741f8c170dfc2fccec7aacf Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:50:15 +0200 Subject: [PATCH 072/171] Voeg XER-diagnose voor completed guards toe --- src/engine/scheduler/CPMSolver.ts | 26 ++- src/engine/scheduler/p6CompletedRouteTrace.ts | 61 ++++++ src/engine/scheduler/scheduleAnalysis.ts | 10 +- src/utils/p6CompletedTargetWindow.ts | 65 +++++-- .../check-xer-backward-float-trace.ts | 178 ++++++++++++++++++ tests/planning/xerTaskReplayProduct.ts | 6 + 6 files changed, 330 insertions(+), 16 deletions(-) create mode 100644 src/engine/scheduler/p6CompletedRouteTrace.ts diff --git a/src/engine/scheduler/CPMSolver.ts b/src/engine/scheduler/CPMSolver.ts index 1c4920f9d..e2b0b4b81 100644 --- a/src/engine/scheduler/CPMSolver.ts +++ b/src/engine/scheduler/CPMSolver.ts @@ -13,7 +13,16 @@ import { } from './duration'; import { computeScheduleResults } from './scheduleAnalysis'; import { hasValidP6SuspendResume } from '@/utils/p6SuspendResume'; -import { usesP6CompletedDataDateWindow as isP6CompletedDataDateCandidate } from '@/utils/p6CompletedTargetWindow'; +import { + explainP6CompletedDataDateWindow, + type P6CompletedWindowDecision, + usesP6CompletedDataDateWindow as isP6CompletedDataDateCandidate, +} from '@/utils/p6CompletedTargetWindow'; +import { + explainBackwardActualPinEligibility, + type CpmBackwardActualPinDecision, + type CpmDisplayActualLateDecision, +} from './p6CompletedRouteTrace'; import { forwardConstraint, forwardFinishFloor, backwardConstraint, MS_PER_MIN, MS_PER_DAY, type RelationDeps, } from './relationMath'; @@ -101,6 +110,9 @@ export interface CpmTaskBackwardFloatTrace { lateStartSource: CpmLateStartSource; freeFloatSource: CpmFreeFloatSource; displayActualLate: boolean; + completedWindow: P6CompletedWindowDecision; + backwardActualPin: CpmBackwardActualPinDecision; + displayActualLateDecision: CpmDisplayActualLateDecision; } export interface CpmBackwardFloatTrace { @@ -437,6 +449,9 @@ export class CPMSolver { 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 }; } @@ -2939,11 +2954,18 @@ export class CPMSolver { for (const taskId of reversed) { const task = this.tasks.get(taskId)!; const succs = this.successors.get(taskId) || []; + const completedWindow = explainP6CompletedDataDateWindow(task, this.options.schedulingOptions); + 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 (preserveActualDates && task.time.actualFinish && task.time.completion >= 1) { + if (backwardActualPin.eligible) { const ed = earlyDates.get(taskId)!; this.recordBackwardFloatTrace(taskId, { lateStartSource: this.p6XerOption( diff --git a/src/engine/scheduler/p6CompletedRouteTrace.ts b/src/engine/scheduler/p6CompletedRouteTrace.ts new file mode 100644 index 000000000..0b1ee98f0 --- /dev/null +++ b/src/engine/scheduler/p6CompletedRouteTrace.ts @@ -0,0 +1,61 @@ +import type { SchedulingOptions } from '@/types/project'; +import type { Task } from '@/types/task'; + +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; +} + +/** + * 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' }; +} diff --git a/src/engine/scheduler/scheduleAnalysis.ts b/src/engine/scheduler/scheduleAnalysis.ts index 654caae29..c122b391f 100644 --- a/src/engine/scheduler/scheduleAnalysis.ts +++ b/src/engine/scheduler/scheduleAnalysis.ts @@ -7,7 +7,8 @@ import { parseDate, formatInstant, type DateMode } from '@/utils/dateUtils'; import { traceFrom } from './graphWalk'; import { projectDurationOf } from './projectDuration'; import { isZeroDurationMilestone } from './duration'; -import { usesP6CompletedDataDateWindow } from '@/utils/p6CompletedTargetWindow'; +import { explainP6CompletedDataDateWindow, usesP6CompletedDataDateWindow } from '@/utils/p6CompletedTargetWindow'; +import { explainDisplayActualLateEligibility } from './p6CompletedRouteTrace'; /** * Invoer voor de resultaat-post-pass (`computeScheduleResults`). Puur data + een handvol @@ -372,18 +373,23 @@ 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 = completedDisplayWindow?.mode ?? modeOf(cal); - const displayActualLate = completed && so?.preserveActualDatesInBackwardPass === true; + const displayActualLateDecision = explainDisplayActualLateEligibility(taskObj, dataDate, so); + const displayActualLate = displayActualLateDecision.eligible; if (backwardFloatTrace) { const prior = backwardFloatTrace.byTaskId[taskId] ?? { lateFinishSource: 'projectEnd' as const, lateStartSource: 'subDuration' as const, freeFloatSource: 'derivedFromSuccessor' as const, displayActualLate: false, + completedWindow: explainP6CompletedDataDateWindow(taskObj, so), + backwardActualPin: { eligible: false, reason: 'missingDataDate' } as const, + displayActualLateDecision: { eligible: false, reason: 'missingDataDate' } as const, }; backwardFloatTrace.byTaskId[taskId] = { ...prior, freeFloatSource, displayActualLate, + displayActualLateDecision, }; } taskResults.set(taskId, { diff --git a/src/utils/p6CompletedTargetWindow.ts b/src/utils/p6CompletedTargetWindow.ts index cfdc73b60..cf7d93426 100644 --- a/src/utils/p6CompletedTargetWindow.ts +++ b/src/utils/p6CompletedTargetWindow.ts @@ -3,6 +3,26 @@ import type { Task } from '@/types/task'; import { isZeroDurationMilestone } from '@/engine/scheduler/duration'; import { isLeafTask } from '@/utils/taskHierarchy'; +export type P6CompletedWindowReason = + | 'eligible' + | 'notXerSource' + | 'remainingStartOff' + | 'notLeafTask' + | 'missingProjectProvenance' + | 'missingTaskProvenance' + | 'missingExplicitTargetWindow' + | 'wrongCompletePctType' + | 'wrongDurationType' + | 'wrongActivityType' + | 'hasSuspendResume' + | 'notCompleted' + | 'zeroDurationMilestone'; + +export interface P6CompletedWindowDecision { + eligible: boolean; + reason: P6CompletedWindowReason; +} + /** * 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 @@ -13,21 +33,42 @@ import { isLeafTask } from '@/utils/taskHierarchy'; * bestaan. De vastgelegde `CP_Drtn`/`DT_FixedDUR2`-vorm is de kleinste onafhankelijke bronvorm * waarvoor de corpusprobe de completed statusdatum-inversie bevestigt. Suspend/resume en * nulduurmijlpalen blijven expliciet 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, + schedulingOptions: SchedulingOptions | undefined, +): P6CompletedWindowDecision { + 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' }; + } + if (task.p6CompletePctType !== 'CP_Drtn') return { eligible: false, reason: 'wrongCompletePctType' }; + if (task.p6DurationType !== 'DT_FixedDUR2') return { eligible: false, reason: 'wrongDurationType' }; + if (task.p6ActivityType !== 'TT_Task' && task.p6ActivityType !== 'TT_Rsrc') { + return { eligible: false, reason: 'wrongActivityType' }; + } + if (task.p6SuspendResume === true) return { eligible: false, reason: 'hasSuspendResume' }; + if (task.time.completion < 1) return { eligible: false, reason: 'notCompleted' }; + if (isZeroDurationMilestone(task)) return { eligible: false, reason: 'zeroDurationMilestone' }; + return { eligible: true, reason: 'eligible' }; +} + export function usesP6CompletedDataDateWindow( task: Task, schedulingOptions: SchedulingOptions | undefined, ): boolean { - return schedulingOptions?.p6Source === 'XER' - && schedulingOptions.p6UseRemainingStartForProgress === true - && isLeafTask(task) - && task.p6ProjectId !== undefined && task.p6ProjectId !== '' - && task.p6TaskId !== undefined && task.p6TaskId !== '' - && task.p6ExplicitTargetWindow === true - && task.p6CompletePctType === 'CP_Drtn' - && task.p6DurationType === 'DT_FixedDUR2' - && (task.p6ActivityType === 'TT_Task' || task.p6ActivityType === 'TT_Rsrc') - && task.p6SuspendResume !== true - && task.time.completion >= 1 - && !isZeroDurationMilestone(task); + return explainP6CompletedDataDateWindow(task, schedulingOptions).eligible; } diff --git a/tests/planning/check-xer-backward-float-trace.ts b/tests/planning/check-xer-backward-float-trace.ts index 375f6dea2..8cbc5b1d1 100644 --- a/tests/planning/check-xer-backward-float-trace.ts +++ b/tests/planning/check-xer-backward-float-trace.ts @@ -89,6 +89,18 @@ interface TraceProjection { 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, @@ -119,6 +131,57 @@ function solveTraceVariant(variant: Variant): TraceProjection { 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 solveCompletedGuardTrace( + mutate?: (imported: ImportResult) => void, +): CompletedGuardTraceProjection { + 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); + 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', @@ -180,6 +243,105 @@ const variants: Variant[] = [ }, ]; +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: true, reason: 'eligible' }, + 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'); @@ -234,6 +396,10 @@ for (const variant of variants) { }); } +for (const variant of completedGuardVariants) { + eq(`completed-guard-trace ${variant.id}: drie routes verklaren actief/inactief`, solveCompletedGuardTrace(variant.mutate), variant.expected); +} + { const baseline = variants[0]!; const replay = replayXerProductBeforeOracle(fixtureBytes(baseline), syntheticZeroRegressionCandidate, { @@ -244,10 +410,22 @@ for (const variant of variants) { 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: 'subRemainingDuration', displayActualLate: true, + completedWindowEligible: true, + completedWindowReason: 'eligible', + backwardActualPinEligible: true, + backwardActualPinReason: 'eligible', + displayActualLateEligible: true, + displayActualLateReason: 'eligible', }); } diff --git a/tests/planning/xerTaskReplayProduct.ts b/tests/planning/xerTaskReplayProduct.ts index c2c685b8e..6cb66b04c 100644 --- a/tests/planning/xerTaskReplayProduct.ts +++ b/tests/planning/xerTaskReplayProduct.ts @@ -282,6 +282,12 @@ export function replayXerProductBeforeOracle( 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, } : {}), }; } From 04f423ced1c32cc4f9d98653ffce01bebd82f02b Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:04:55 +0200 Subject: [PATCH 073/171] Maak completed-guard missingDataDate trace sluitend --- src/engine/scheduler/CPMSolver.ts | 10 +++--- src/engine/scheduler/scheduleAnalysis.ts | 8 ++--- src/utils/p6CompletedTargetWindow.ts | 6 +++- .../check-xer-backward-float-trace.ts | 34 +++++++++++++++++-- .../check-xer-product-fidelity-x12.ts | 15 ++++---- 5 files changed, 53 insertions(+), 20 deletions(-) diff --git a/src/engine/scheduler/CPMSolver.ts b/src/engine/scheduler/CPMSolver.ts index e2b0b4b81..5c4ba36aa 100644 --- a/src/engine/scheduler/CPMSolver.ts +++ b/src/engine/scheduler/CPMSolver.ts @@ -16,7 +16,6 @@ import { hasValidP6SuspendResume } from '@/utils/p6SuspendResume'; import { explainP6CompletedDataDateWindow, type P6CompletedWindowDecision, - usesP6CompletedDataDateWindow as isP6CompletedDataDateCandidate, } from '@/utils/p6CompletedTargetWindow'; import { explainBackwardActualPinEligibility, @@ -435,8 +434,8 @@ export class CPMSolver { return this.options.schedulingOptions?.p6Source === 'XER' && value === true; } - private usesP6CompletedDataDateWindow(task: Task): boolean { - return this.dataDate !== null && isP6CompletedDataDateCandidate(task, this.options.schedulingOptions); + private p6CompletedDataDateWindowDecision(task: Task): P6CompletedWindowDecision { + return explainP6CompletedDataDateWindow(task, this.dataDate, this.options.schedulingOptions); } private recordBackwardFloatTrace( @@ -2923,7 +2922,8 @@ export class CPMSolver { const early = earlyDates.get(taskId); const task = this.tasks.get(taskId); if (!early || !task) continue; - const usesCompletedDisplayWindow = this.usesP6CompletedDataDateWindow(task); + const completedWindow = this.p6CompletedDataDateWindowDecision(task); + const usesCompletedDisplayWindow = completedWindow.eligible; let candidateEf = early.ef; if (usesCompletedDisplayWindow) { const progressCal = this.progressCalendarFor(task); @@ -2954,7 +2954,7 @@ export class CPMSolver { for (const taskId of reversed) { const task = this.tasks.get(taskId)!; const succs = this.successors.get(taskId) || []; - const completedWindow = explainP6CompletedDataDateWindow(task, this.options.schedulingOptions); + const completedWindow = this.p6CompletedDataDateWindowDecision(task); const backwardActualPin = explainBackwardActualPinEligibility( task, this.dataDate, diff --git a/src/engine/scheduler/scheduleAnalysis.ts b/src/engine/scheduler/scheduleAnalysis.ts index c122b391f..6d2fb8908 100644 --- a/src/engine/scheduler/scheduleAnalysis.ts +++ b/src/engine/scheduler/scheduleAnalysis.ts @@ -7,7 +7,7 @@ import { parseDate, formatInstant, type DateMode } from '@/utils/dateUtils'; import { traceFrom } from './graphWalk'; import { projectDurationOf } from './projectDuration'; import { isZeroDurationMilestone } from './duration'; -import { explainP6CompletedDataDateWindow, usesP6CompletedDataDateWindow } from '@/utils/p6CompletedTargetWindow'; +import { explainP6CompletedDataDateWindow } from '@/utils/p6CompletedTargetWindow'; import { explainDisplayActualLateEligibility } from './p6CompletedRouteTrace'; /** @@ -240,8 +240,8 @@ export function computeScheduleResults(input: ScheduleAnalysisInput): CPMResult // verschillen wanneer een SNLT alleen de late start kapt). Kritiek = tf ≤ 0. const tt = taskObj.time; const completed = !!dataDate && tt.completion >= 1; - const completedDisplayWindow = completed - && usesP6CompletedDataDateWindow(taskObj, so) + const completedWindowDecision = explainP6CompletedDataDateWindow(taskObj, dataDate, so); + const completedDisplayWindow = completedWindowDecision.eligible ? (() => { const progressCal = progressCalendarFor(taskObj); const es = snapOnOrAfter(progressCal, dataDate!); @@ -381,7 +381,7 @@ export function computeScheduleResults(input: ScheduleAnalysisInput): CPMResult lateStartSource: 'subDuration' as const, freeFloatSource: 'derivedFromSuccessor' as const, displayActualLate: false, - completedWindow: explainP6CompletedDataDateWindow(taskObj, so), + completedWindow: completedWindowDecision, backwardActualPin: { eligible: false, reason: 'missingDataDate' } as const, displayActualLateDecision: { eligible: false, reason: 'missingDataDate' } as const, }; diff --git a/src/utils/p6CompletedTargetWindow.ts b/src/utils/p6CompletedTargetWindow.ts index cf7d93426..7d4f55cdc 100644 --- a/src/utils/p6CompletedTargetWindow.ts +++ b/src/utils/p6CompletedTargetWindow.ts @@ -5,6 +5,7 @@ import { isLeafTask } from '@/utils/taskHierarchy'; export type P6CompletedWindowReason = | 'eligible' + | 'missingDataDate' | 'notXerSource' | 'remainingStartOff' | 'notLeafTask' @@ -39,8 +40,10 @@ export interface P6CompletedWindowDecision { */ 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' }; @@ -68,7 +71,8 @@ export function explainP6CompletedDataDateWindow( export function usesP6CompletedDataDateWindow( task: Task, + dataDate: Date | null, schedulingOptions: SchedulingOptions | undefined, ): boolean { - return explainP6CompletedDataDateWindow(task, schedulingOptions).eligible; + return explainP6CompletedDataDateWindow(task, dataDate, schedulingOptions).eligible; } diff --git a/tests/planning/check-xer-backward-float-trace.ts b/tests/planning/check-xer-backward-float-trace.ts index 8cbc5b1d1..1bf2c2a95 100644 --- a/tests/planning/check-xer-backward-float-trace.ts +++ b/tests/planning/check-xer-backward-float-trace.ts @@ -150,9 +150,9 @@ function completedGuardFixtureBytes(): Uint8Array { ].join('\n')); } -function solveCompletedGuardTrace( +function solveCompletedGuardFixture( mutate?: (imported: ImportResult) => void, -): CompletedGuardTraceProjection { +) { const imported = structuredClone(importFixture(completedGuardFixtureBytes())); mutate?.(imported); const result = solveProject({ @@ -167,6 +167,13 @@ function solveCompletedGuardTrace( 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; @@ -335,7 +342,7 @@ const completedGuardVariants: Array<{ }, expected: { displayActualLate: false, - completedWindow: { eligible: true, reason: 'eligible' }, + completedWindow: { eligible: false, reason: 'missingDataDate' }, backwardActualPin: { eligible: false, reason: 'missingDataDate' }, displayActualLateDecision: { eligible: false, reason: 'missingDataDate' }, }, @@ -397,9 +404,30 @@ for (const variant of variants) { } 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, { diff --git a/tests/planning/check-xer-product-fidelity-x12.ts b/tests/planning/check-xer-product-fidelity-x12.ts index e4fe9be3d..163b3db89 100644 --- a/tests/planning/check-xer-product-fidelity-x12.ts +++ b/tests/planning/check-xer-product-fidelity-x12.ts @@ -1719,17 +1719,18 @@ async function productBaseline( 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 }, one.project.schedulingOptions), - active: usesP6CompletedDataDateWindow(activeTask, active.project.schedulingOptions), - milestone: usesP6CompletedDataDateWindow(milestoneTask, milestone.project.schedulingOptions), + { ...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 }, one.project.schedulingOptions), + { ...oneTask, p6ActivityType: 'TT_LOE', isHammock: true }, completedGuardDataDate, one.project.schedulingOptions), suspendResume: usesP6CompletedDataDateWindow( - { ...oneTask, p6SuspendResume: true }, one.project.schedulingOptions), - summary: usesP6CompletedDataDateWindow(summaryCandidate, one.project.schedulingOptions), - generic: usesP6CompletedDataDateWindow(oneTask, undefined), + { ...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, From dcec2808299584f12770faccbfd0707c6be5b794 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:40:07 +0200 Subject: [PATCH 074/171] fix(xer): laat geldige completed suspend-resume-window toe --- src/utils/p6CompletedTargetWindow.ts | 38 ++- ...eck-xer-completed-suspend-resume-window.ts | 285 ++++++++++++++++++ .../check-xer-product-fidelity-x12.ts | 2 +- tests/planning/run.sh | 5 + 4 files changed, 326 insertions(+), 4 deletions(-) create mode 100644 tests/planning/check-xer-completed-suspend-resume-window.ts diff --git a/src/utils/p6CompletedTargetWindow.ts b/src/utils/p6CompletedTargetWindow.ts index 7d4f55cdc..93720b1fd 100644 --- a/src/utils/p6CompletedTargetWindow.ts +++ b/src/utils/p6CompletedTargetWindow.ts @@ -1,6 +1,8 @@ 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 = @@ -24,6 +26,29 @@ export interface P6CompletedWindowDecision { 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 @@ -32,8 +57,13 @@ export interface P6CompletedWindowDecision { * 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 kleinste onafhankelijke bronvorm - * waarvoor de corpusprobe de completed statusdatum-inversie bevestigt. Suspend/resume en - * nulduurmijlpalen blijven expliciet buiten dit eerste causaliteitspakket. + * waarvoor de corpusprobe de completed statusdatum-route bevestigt. Een P6 suspend/resume-paar + * blijft standaard fail-closed en mag uitsluitend door deze 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. @@ -63,7 +93,9 @@ export function explainP6CompletedDataDateWindow( if (task.p6ActivityType !== 'TT_Task' && task.p6ActivityType !== 'TT_Rsrc') { return { eligible: false, reason: 'wrongActivityType' }; } - if (task.p6SuspendResume === true) return { eligible: false, reason: 'hasSuspendResume' }; + if (task.p6SuspendResume === true && !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' }; return { eligible: true, reason: 'eligible' }; 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..e1725187b --- /dev/null +++ b/tests/planning/check-xer-completed-suspend-resume-window.ts @@ -0,0 +1,285 @@ +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', + lateStart: '2026-08-04T07:00', + lateFinish: '2026-08-14T15: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', + mutate: (_imported, task) => { + task.p6CompletePctType = 'CP_Phys'; + }, + want: { eligible: false, reason: 'wrongCompletePctType' }, + }, + { + 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-product-fidelity-x12.ts b/tests/planning/check-xer-product-fidelity-x12.ts index 163b3db89..4f1c500d4 100644 --- a/tests/planning/check-xer-product-fidelity-x12.ts +++ b/tests/planning/check-xer-product-fidelity-x12.ts @@ -1705,7 +1705,7 @@ async function productBaseline( 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', { + 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], diff --git a/tests/planning/run.sh b/tests/planning/run.sh index f14bd6068..bfcfa23a3 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -250,6 +250,11 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then 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 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 From 5bc1f4d3b3f2c1aac8fe3e2e47022eeecfe3d21c Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:56:34 +0200 Subject: [PATCH 075/171] Sta bewezen CP_Phys completed-window toe --- src/utils/p6CompletedTargetWindow.ts | 20 +- .../check-xer-completed-cp-phys-window.ts | 206 ++++++++++++++++++ ...eck-xer-completed-suspend-resume-window.ts | 4 +- tests/planning/run.sh | 5 + 4 files changed, 227 insertions(+), 8 deletions(-) create mode 100644 tests/planning/check-xer-completed-cp-phys-window.ts diff --git a/src/utils/p6CompletedTargetWindow.ts b/src/utils/p6CompletedTargetWindow.ts index 93720b1fd..dd01f45a2 100644 --- a/src/utils/p6CompletedTargetWindow.ts +++ b/src/utils/p6CompletedTargetWindow.ts @@ -56,9 +56,10 @@ function mayUseSuspendResumeCompletedWindow( * * 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 kleinste onafhankelijke bronvorm - * waarvoor de corpusprobe de completed statusdatum-route bevestigt. Een P6 suspend/resume-paar - * blijft standaard fail-closed en mag uitsluitend door deze poort wanneer de taak zelf al + * bestaan. De vastgelegde `CP_Drtn`/`DT_FixedDUR2`-vorm en de afzonderlijk bewezen + * `CP_Phys`/`DT_FixedDUR2`/`TT_Task`-subvorm zijn de onafhankelijke bronvormen waarvoor de + * corpusprobes de completed statusdatum-route bevestigen. 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 @@ -88,14 +89,21 @@ export function explainP6CompletedDataDateWindow( if (task.p6ExplicitTargetWindow !== true) { return { eligible: false, reason: 'missingExplicitTargetWindow' }; } - if (task.p6CompletePctType !== 'CP_Drtn') return { eligible: false, reason: 'wrongCompletePctType' }; + 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' }; - if (task.p6ActivityType !== 'TT_Task' && task.p6ActivityType !== 'TT_Rsrc') { + const validActivity = task.p6ActivityType === 'TT_Task' + || (!isPhysicalCompletion && task.p6ActivityType === 'TT_Rsrc'); + if (!validActivity) { return { eligible: false, reason: 'wrongActivityType' }; } - if (task.p6SuspendResume === true && !mayUseSuspendResumeCompletedWindow(task, dataDate, schedulingOptions)) { + if (task.p6SuspendResume === true + && (isPhysicalCompletion || !mayUseSuspendResumeCompletedWindow(task, dataDate, schedulingOptions))) { return { eligible: false, reason: 'hasSuspendResume' }; } + if (isPhysicalCompletion && !task.time.actualFinish) return { eligible: false, reason: 'notCompleted' }; if (task.time.completion < 1) return { eligible: false, reason: 'notCompleted' }; if (isZeroDurationMilestone(task)) return { eligible: false, reason: 'zeroDurationMilestone' }; return { eligible: true, reason: 'eligible' }; 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..8f8f7b12c --- /dev/null +++ b/tests/planning/check-xer-completed-cp-phys-window.ts @@ -0,0 +1,206 @@ +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 { + explainP6CompletedDataDateWindow, + type P6CompletedWindowReason, +} 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 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, + }; +} + +eq('CP_Phys XER-leaf wordt rechtstreeks gelezen en gebruikt de completed-windowroute', solveFixture(), { + source: { + p6Source: 'XER', + p6UseRemainingStartForProgress: true, + p6CompletePctType: 'CP_Phys', + p6DurationType: 'DT_FixedDUR2', + p6ActivityType: 'TT_Task', + p6ProjectId: 'P1', + p6TaskId: 'C', + p6ExplicitTargetWindow: true, + actualFinish: '2026-08-04T15:00', + completion: 1, + }, + decision: { eligible: true, reason: 'eligible' }, + scheduled: { + earlyStart: '2026-08-17T10:00', + earlyFinish: '2026-08-17T10:00', + lateStart: '2026-08-04T07:00', + lateFinish: '2026-08-04T15:00', + }, + trace: { + projectEndSource: 'completedDisplayWindow', + completedWindow: { eligible: true, reason: 'eligible' }, + }, +}); + +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: '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, + }); +} + +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-suspend-resume-window.ts b/tests/planning/check-xer-completed-suspend-resume-window.ts index e1725187b..a10d47fab 100644 --- a/tests/planning/check-xer-completed-suspend-resume-window.ts +++ b/tests/planning/check-xer-completed-suspend-resume-window.ts @@ -231,11 +231,11 @@ const rejectionCases: Array<{ want: { eligible: false, reason: 'hasSuspendResume' }, }, { - label: 'CP_Phys', + label: 'CP_Phys blijft buiten de suspend/resume-uitzondering', mutate: (_imported, task) => { task.p6CompletePctType = 'CP_Phys'; }, - want: { eligible: false, reason: 'wrongCompletePctType' }, + want: { eligible: false, reason: 'hasSuspendResume' }, }, { label: 'ander durationtype', diff --git a/tests/planning/run.sh b/tests/planning/run.sh index bfcfa23a3..af111fc50 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -255,6 +255,11 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then 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 smalle CP_Phys-uitbreiding: alleen de bewezen completed XER-leafsubset mag dezelfde + # completed-windowroute gebruiken; de eigen fixture bewaakt de solverroute en fail-closed grenzen. + 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 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 From 5ba6b6378c00a2d4211c1bddf56d96a8584c6c71 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 1 Sep 2026 03:19:27 +0200 Subject: [PATCH 076/171] Herstel CP_Phys-grens in XER-voltooidvenster --- src/utils/p6CompletedTargetWindow.ts | 21 +- .../check-xer-completed-cp-phys-window.ts | 182 +++++++++++++++--- tests/planning/run.sh | 4 +- 3 files changed, 176 insertions(+), 31 deletions(-) diff --git a/src/utils/p6CompletedTargetWindow.ts b/src/utils/p6CompletedTargetWindow.ts index dd01f45a2..c459e1a65 100644 --- a/src/utils/p6CompletedTargetWindow.ts +++ b/src/utils/p6CompletedTargetWindow.ts @@ -56,9 +56,12 @@ function mayUseSuspendResumeCompletedWindow( * * 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 en de afzonderlijk bewezen - * `CP_Phys`/`DT_FixedDUR2`/`TT_Task`-subvorm zijn de onafhankelijke bronvormen waarvoor de - * corpusprobes de completed statusdatum-route bevestigen. Een P6 suspend/resume-paar + * 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- @@ -103,9 +106,19 @@ export function explainP6CompletedDataDateWindow( && (isPhysicalCompletion || !mayUseSuspendResumeCompletedWindow(task, dataDate, schedulingOptions))) { return { eligible: false, reason: 'hasSuspendResume' }; } - if (isPhysicalCompletion && !task.time.actualFinish) return { eligible: false, reason: 'notCompleted' }; 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' }; } diff --git a/tests/planning/check-xer-completed-cp-phys-window.ts b/tests/planning/check-xer-completed-cp-phys-window.ts index 8f8f7b12c..eed8c6811 100644 --- a/tests/planning/check-xer-completed-cp-phys-window.ts +++ b/tests/planning/check-xer-completed-cp-phys-window.ts @@ -2,11 +2,14 @@ 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; @@ -105,31 +108,14 @@ function solveFixture(mutate?: (imported: ImportResult, task: Task) => void) { }; } -eq('CP_Phys XER-leaf wordt rechtstreeks gelezen en gebruikt de completed-windowroute', solveFixture(), { - source: { - p6Source: 'XER', - p6UseRemainingStartForProgress: true, - p6CompletePctType: 'CP_Phys', - p6DurationType: 'DT_FixedDUR2', - p6ActivityType: 'TT_Task', - p6ProjectId: 'P1', - p6TaskId: 'C', - p6ExplicitTargetWindow: true, - actualFinish: '2026-08-04T15:00', - completion: 1, - }, - decision: { eligible: true, reason: 'eligible' }, - scheduled: { - earlyStart: '2026-08-17T10:00', - earlyFinish: '2026-08-17T10:00', - lateStart: '2026-08-04T07:00', - lateFinish: '2026-08-04T15:00', - }, - trace: { - projectEndSource: 'completedDisplayWindow', - completedWindow: { eligible: true, reason: 'eligible' }, - }, -}); +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; @@ -156,6 +142,16 @@ const negativeCases: Array<{ 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; }, @@ -197,6 +193,142 @@ for (const testCase of negativeCases) { 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`); diff --git a/tests/planning/run.sh b/tests/planning/run.sh index af111fc50..3480150ba 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -255,8 +255,8 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then 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 smalle CP_Phys-uitbreiding: alleen de bewezen completed XER-leafsubset mag dezelfde - # completed-windowroute gebruiken; de eigen fixture bewaakt de solverroute en fail-closed grenzen. + # 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 From 860bab7bb9f0f5c36417f649808479df8862de61 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 1 Sep 2026 05:51:30 +0200 Subject: [PATCH 077/171] Herstel actual-finish voor voltooide XER-LOE's --- src/engine/scheduler/CPMSolver.ts | 13 +- src/engine/scheduler/p6CompletedRouteTrace.ts | 114 +++++++++ .../check-xer-completed-loe-actual-finish.ts | 232 ++++++++++++++++++ tests/planning/run.sh | 5 + 4 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 tests/planning/check-xer-completed-loe-actual-finish.ts diff --git a/src/engine/scheduler/CPMSolver.ts b/src/engine/scheduler/CPMSolver.ts index 5c4ba36aa..ad86b200b 100644 --- a/src/engine/scheduler/CPMSolver.ts +++ b/src/engine/scheduler/CPMSolver.ts @@ -19,6 +19,7 @@ import { } from '@/utils/p6CompletedTargetWindow'; import { explainBackwardActualPinEligibility, + explainCompletedXerLoeActualFinishEligibility, type CpmBackwardActualPinDecision, type CpmDisplayActualLateDecision, } from './p6CompletedRouteTrace'; @@ -1361,7 +1362,17 @@ export class CPMSolver { // FF/SF-voorganger-bounds (ondergrens ES). De AFGELEIDE duur (span ES→EF) wordt naar // `scheduleDuration` (+ `durationMinutes` op een uur-kalender) geschreven; eigen duur-invoer // wordt genegeerd. `isHammock` afwezig ⇒ deze tak draait niet (byte-identiek). - if (task.isHammock) { + // 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 es = this.hammockEarlyStart(task, preds, results, projectStart, cal); const { ef, hasFinishDriver } = this.hammockEarlyFinish(task, preds, results, es, cal); if (!hasFinishDriver) this.hammockNoFinishDriverIds.push(taskId); diff --git a/src/engine/scheduler/p6CompletedRouteTrace.ts b/src/engine/scheduler/p6CompletedRouteTrace.ts index 0b1ee98f0..82e6bc782 100644 --- a/src/engine/scheduler/p6CompletedRouteTrace.ts +++ b/src/engine/scheduler/p6CompletedRouteTrace.ts @@ -1,5 +1,43 @@ 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 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' @@ -24,6 +62,82 @@ export interface CpmDisplayActualLateDecision { 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 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/run.sh b/tests/planning/run.sh index 3480150ba..ea2101de5 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -260,6 +260,11 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then 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 + # 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 From fdb841bc6075c34029f8a23d5b80937ad9719a6f Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 1 Sep 2026 06:09:21 +0200 Subject: [PATCH 078/171] Voeg veilige XER data_date-fallback toe --- src/services/xer/xerReader.ts | 19 +++- tests/planning/check-xer-project-data-date.ts | 101 ++++++++++++++++++ tests/planning/run.sh | 5 + 3 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 tests/planning/check-xer-project-data-date.ts diff --git a/src/services/xer/xerReader.ts b/src/services/xer/xerReader.ts index b74a411f8..1110c5a07 100644 --- a/src/services/xer/xerReader.ts +++ b/src/services/xer/xerReader.ts @@ -226,6 +226,21 @@ function sourceInstant(raw: string, hourMode: boolean): string | undefined { return formatInstant(parsed, hourMode ? 'hour' : 'day'); } +/** + * `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; @@ -602,7 +617,7 @@ function readXerProject( 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) + ?? projectStatusDate(tables, projectRow, hourMode) ?? '1970-01-01'; let finish = explicitTargetFinish ?? start; // P6 XER kan een TT_FinMile op de eerste minuut ná een werkbandgrens serialiseren met gelijke @@ -776,7 +791,7 @@ function readXerProject( const projectEnd = schedulingOptions.useProjectEndDateForFloat && sourceProjectEnd ? sourceProjectEnd : taskDerivedProjectEnd; - const statusDate = sourceInstant(projectRow.cells.last_recalc_date ?? '', projectHourMode); + const statusDate = projectStatusDate(tables, projectRow, projectHourMode); const wbsRows = stableWbsRows(rawWbsRows, projectId); const wbsTasks: Task[] = wbsRows.map(row => { 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..ea51478c7 --- /dev/null +++ b/tests/planning/check-xer-project-data-date.ts @@ -0,0 +1,101 @@ +import { isMultiDocumentImport } from '@/services/importTypes'; +import { readXER, type XerReadResult } 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)}`); + } +} + +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 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' }], +}); + +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/run.sh b/tests/planning/run.sh index ea2101de5..49de729a4 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -236,6 +236,11 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then 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 From ee7fa2b21b859639906326c34d264a7aab1ad528 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 1 Sep 2026 07:11:27 +0200 Subject: [PATCH 079/171] Herstel XER data_date-fallback na review --- src/services/xer/xerReader.ts | 2 +- tests/planning/check-xer-project-data-date.ts | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/services/xer/xerReader.ts b/src/services/xer/xerReader.ts index 1110c5a07..31286e768 100644 --- a/src/services/xer/xerReader.ts +++ b/src/services/xer/xerReader.ts @@ -617,7 +617,7 @@ function readXerProject( const explicitTargetFinish = sourceInstant(row.cells.target_end_date ?? '', hourMode); const hasExplicitTargetWindow = explicitTargetStart !== undefined && explicitTargetFinish !== undefined; let start = explicitTargetStart - ?? projectStatusDate(tables, projectRow, hourMode) + ?? 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 diff --git a/tests/planning/check-xer-project-data-date.ts b/tests/planning/check-xer-project-data-date.ts index ea51478c7..4f4a2dffe 100644 --- a/tests/planning/check-xer-project-data-date.ts +++ b/tests/planning/check-xer-project-data-date.ts @@ -53,6 +53,7 @@ const cases = [ { 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; @@ -92,6 +93,28 @@ eq('PROJECT-data-date legacy last-dialect: productvelden blijven byte/productvel 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'); + if (diffs.length > 0) { console.error(`XX XER PROJECT.data_date (${checks} checks)`); for (const diff of diffs) console.error(`XX ${diff}`); From 12f1c05eccbdc67c94e58915d73f30590d3f5f07 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:08:58 +0200 Subject: [PATCH 080/171] Voeg XER data_date-LOE-regressiebewijs toe --- tests/planning/check-xer-project-data-date.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tests/planning/check-xer-project-data-date.ts b/tests/planning/check-xer-project-data-date.ts index 4f4a2dffe..fb84f76b2 100644 --- a/tests/planning/check-xer-project-data-date.ts +++ b/tests/planning/check-xer-project-data-date.ts @@ -1,5 +1,8 @@ +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; @@ -115,6 +118,70 @@ eq('PROJECT-data-date data-only zonder targetvenster: scheduleStart houdt oude l 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}`); From a543b269f801cb9e2d5090a039356d5a0a0f9eb1 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:47:50 +0200 Subject: [PATCH 081/171] Herstel smalle open XER-LOE-span --- src/engine/scheduler/CPMSolver.ts | 22 +- .../scheduler/p6OpenLoeTargetSpanTrace.ts | 116 +++++++++ .../check-xer-open-loe-target-span.ts | 243 ++++++++++++++++++ tests/planning/run.sh | 5 + 4 files changed, 384 insertions(+), 2 deletions(-) create mode 100644 src/engine/scheduler/p6OpenLoeTargetSpanTrace.ts create mode 100644 tests/planning/check-xer-open-loe-target-span.ts diff --git a/src/engine/scheduler/CPMSolver.ts b/src/engine/scheduler/CPMSolver.ts index ad86b200b..a4783fdf0 100644 --- a/src/engine/scheduler/CPMSolver.ts +++ b/src/engine/scheduler/CPMSolver.ts @@ -23,6 +23,7 @@ import { type CpmBackwardActualPinDecision, type CpmDisplayActualLateDecision, } from './p6CompletedRouteTrace'; +import { explainOpenXerLoeTargetSpanEligibility } from './p6OpenLoeTargetSpanTrace'; import { forwardConstraint, forwardFinishFloor, backwardConstraint, MS_PER_MIN, MS_PER_DAY, type RelationDeps, } from './relationMath'; @@ -1373,8 +1374,25 @@ export class CPMSolver { this.successors.get(taskId) || [], ); if (task.isHammock && !completedXerLoeActualFinish.eligible) { - const es = this.hammockEarlyStart(task, preds, results, projectStart, cal); - const { ef, hasFinishDriver } = this.hammockEarlyFinish(task, preds, results, es, cal); + const relationalEarlyStart = this.hammockEarlyStart(task, preds, results, projectStart, cal); + const openXerLoeTargetSpan = explainOpenXerLoeTargetSpanEligibility( + task, + this.options.schedulingOptions, + preds, + this.successors.get(taskId) || [], + relationalEarlyStart, + cal.workMinutesBetween( + parseInstant(task.time.scheduleStart), + parseInstant(task.time.scheduleFinish), + ), + 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 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/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..9c27e060b --- /dev/null +++ b/tests/planning/check-xer-open-loe-target-span.ts @@ -0,0 +1,243 @@ +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; +} + +function fixtureBytes(options: FixtureOptions = {}): Uint8Array { + const targetStart = options.targetStart ?? '2026-01-05 08:00'; + const targetEnd = options.targetEnd ?? '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; + 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', + '%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\t2026-01-05 08:00\t2026-01-05 17:00\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${targetEnd.replace('17:00', '08:00')}\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(new Date(loe.time.scheduleStart), new Date(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); + +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/run.sh b/tests/planning/run.sh index 49de729a4..3554b4837 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -270,6 +270,11 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then 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 + # 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 From ea45325b20c5dc8c670a77c57a117738f24e4bb1 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:09:13 +0200 Subject: [PATCH 082/171] Begrens XER-LOE-duurmeting na bronpoort --- src/engine/scheduler/CPMSolver.ts | 16 ++++++++++++---- tests/planning/check-xer-open-loe-target-span.ts | 5 ++++- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/engine/scheduler/CPMSolver.ts b/src/engine/scheduler/CPMSolver.ts index a4783fdf0..966f5da4c 100644 --- a/src/engine/scheduler/CPMSolver.ts +++ b/src/engine/scheduler/CPMSolver.ts @@ -1375,16 +1375,24 @@ export class CPMSolver { ); 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, - cal.workMinutesBetween( - parseInstant(task.time.scheduleStart), - parseInstant(task.time.scheduleFinish), - ), + targetWindowWorkMinutes, cal.hoursPerDay * 60, ); const es = openXerLoeTargetSpan.eligible diff --git a/tests/planning/check-xer-open-loe-target-span.ts b/tests/planning/check-xer-open-loe-target-span.ts index 9c27e060b..e54eae101 100644 --- a/tests/planning/check-xer-open-loe-target-span.ts +++ b/tests/planning/check-xer-open-loe-target-span.ts @@ -90,7 +90,10 @@ 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(new Date(loe.time.scheduleStart), new Date(loe.time.scheduleFinish)), + 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( From 1cb772ef111ce8441fe12c1c0feba15f5a33bcd6 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:32:09 +0200 Subject: [PATCH 083/171] Borg dagmodus voor open XER-LOE --- .../check-xer-open-loe-target-span.ts | 62 +++++++++++++++++-- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/tests/planning/check-xer-open-loe-target-span.ts b/tests/planning/check-xer-open-loe-target-span.ts index e54eae101..1c26c4c0c 100644 --- a/tests/planning/check-xer-open-loe-target-span.ts +++ b/tests/planning/check-xer-open-loe-target-span.ts @@ -29,28 +29,34 @@ interface FixtureOptions { outgoingType?: string; outgoingLag?: string; includeOutgoing?: boolean; + dayMode?: boolean; } function fixtureBytes(options: FixtureOptions = {}): Uint8Array { - const targetStart = options.targetStart ?? '2026-01-05 08:00'; - const targetEnd = options.targetEnd ?? '2026-01-16 17:00'; + 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${workWeek}`, + `%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\t2026-01-05 08:00\t2026-01-05 17:00\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\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${targetEnd.replace('17:00', '08:00')}\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`, + `%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', @@ -174,6 +180,52 @@ 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), From ceaf3f02f5d0957789a3ce3973f3d45a9459ada6 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:30:52 +0200 Subject: [PATCH 084/171] Herstel verouderde XER-corpusverwachtingen --- .../check-xer-product-fidelity-x12.ts | 76 + .../check-xer-schedule-options-corpus.ts | 53 +- .../xer-product-fidelity-baseline.json | 14 +- .../xer-schedoptions-blast-radius.json | 2131 ++++++++--------- 4 files changed, 1107 insertions(+), 1167 deletions(-) diff --git a/tests/planning/check-xer-product-fidelity-x12.ts b/tests/planning/check-xer-product-fidelity-x12.ts index 4f1c500d4..0175f88f0 100644 --- a/tests/planning/check-xer-product-fidelity-x12.ts +++ b/tests/planning/check-xer-product-fidelity-x12.ts @@ -1868,6 +1868,82 @@ async function productBaseline( }); } +// 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)); + 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}`); diff --git a/tests/planning/check-xer-schedule-options-corpus.ts b/tests/planning/check-xer-schedule-options-corpus.ts index 89534a776..492a0a285 100644 --- a/tests/planning/check-xer-schedule-options-corpus.ts +++ b/tests/planning/check-xer-schedule-options-corpus.ts @@ -170,8 +170,8 @@ interface BlastRadiusBaseline { corpusUnion: string[]; files: MeasuredFile[]; expectedFinishVariant: ExpectedFinishVariantBaseline; - /** Exact causaal delta-orakel voor het X7-contract "ontbrekend type blijft afwezig". */ - legacyMissingTypeDelta?: BaselineValueDelta[]; + /** Historisch X7-dossier; de levende duration-type-regel staat als corpusloze mutatiefixture in X12. */ + legacyDurationTypeHistoricalDelta?: BaselineValueDelta[]; fidelity: { house: XerFidelityCounters; xerDefaults: XerFidelityCounters; @@ -201,19 +201,16 @@ function eq(label: string, got: unknown, want: unknown): void { if (JSON.stringify(got) !== JSON.stringify(want)) diffs.push(label); } -function baselineValueDeltas(before: unknown, after: unknown, path = ''): BaselineValueDelta[] { - if (JSON.stringify(before) === JSON.stringify(after)) return []; - if (Array.isArray(before) && Array.isArray(after)) { - if (before.length !== after.length) return [{ path: `${path}.length`, before: before.length, after: after.length }]; - return before.flatMap((value, index) => baselineValueDeltas(value, after[index], `${path}[${index}]`)); - } - if (before && after && typeof before === 'object' && typeof after === 'object') { - const beforeObject = before as Record; - const afterObject = after as Record; - return [...new Set([...Object.keys(beforeObject), ...Object.keys(afterObject)])].flatMap(key => - baselineValueDeltas(beforeObject[key], afterObject[key], path ? `${path}.${key}` : key)); - } - return [{ path, before, after }]; +/** X12 wijzigde de onafhankelijke orakelvorm; productuitkomsten horen niet in deze projectie. */ +function x12OracleShape(baseline: Pick): unknown { + return baseline.population; +} + +/** De latere completed/progress/LOE/data_date-aanpassingen bewaken alleen productprojecties. */ +function completedProgressLoeDataDateEffects( + baseline: Pick, +): unknown { + return { files: baseline.files, fidelity: baseline.fidelity }; } function listXerFilesRecursive(dir: string): string[] { @@ -839,7 +836,7 @@ if (!existsSync(baselinePath)) { 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 37 functioneel SCHEDOPTIONS-loze bestanden', committed.files.length, 37); + 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', @@ -854,8 +851,9 @@ if (!existsSync(baselinePath)) { typeof committed.expectedFinishVariant === 'object' && Array.isArray(committed.expectedFinishVariant?.files) && committed.expectedFinishVariant?.movement.length === BLAST_AXES.length, true); - eq('ontbrekend-typepad heeft een exact before/after-delta-orakel', - Array.isArray(committed.legacyMissingTypeDelta) && committed.legacyMissingTypeDelta.length > 0, 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; @@ -923,21 +921,10 @@ if (!root) { 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); - const { - expectedFinishVariant: _measuredExpected, - legacyMissingTypeDelta: _measuredLegacyDelta, - ...measuredRest - } = measured; - const { - expectedFinishVariant: _committedExpected, - legacyMissingTypeDelta: committedLegacyDelta, - ...committedRest - } = committed; - void _measuredExpected; - void _measuredLegacyDelta; - void _committedExpected; - eq('ontbrekend-typepad beweegt exact de gepinde bestaande baselinecellen', - baselineValueDeltas(committedRest, measuredRest), committedLegacyDelta); + eq('X12-orakelvorm houdt uitsluitend de onafhankelijke corpuspopulatie exact vast', + x12OracleShape(measured), x12OracleShape(committed)); + eq('completed/progress/LOE/data_date-effecten houden uitsluitend productprojecties exact vast', + completedProgressLoeDataDateEffects(measured), completedProgressLoeDataDateEffects(committed)); } } diff --git a/tests/planning/xer-product-fidelity-baseline.json b/tests/planning/xer-product-fidelity-baseline.json index 7523a55da..9ba6c827f 100644 --- a/tests/planning/xer-product-fidelity-baseline.json +++ b/tests/planning/xer-product-fidelity-baseline.json @@ -5,27 +5,27 @@ "tasks": 8, "projects": 1, "counters": { - "es": { "deviations": 3, "measurable": 8 }, - "ef": { "deviations": 7, "measurable": 8 }, - "ls": { "deviations": 4, "measurable": 8 }, - "lf": { "deviations": 8, "measurable": 8 }, + "es": { "deviations": 1, "measurable": 8 }, + "ef": { "deviations": 1, "measurable": 8 }, + "ls": { "deviations": 2, "measurable": 8 }, + "lf": { "deviations": 2, "measurable": 8 }, "tf": { "deviations": 2, "measurable": 8 }, "ff": { "deviations": 0, "measurable": 8 } }, - "reason": "Eerste X4a-nulmeting: lege bronkalender valt terug op X3-banden; latere SCHEDOPTIONS- en voortgangssemantiek zijn nog buiten scope." + "reason": "X4a-overgangsmeting na X12/P6-werkbandnormalisatie: zestien zesassige cellen landen exact op het openbare P6-orakel; opgeslagen P6-uitvoer blijft verboden solverinvoer." }, "crawl-xer-extra/p6difftool/sample-target.xer": { "tasks": 8, "projects": 1, "counters": { "es": { "deviations": 7, "measurable": 8 }, - "ef": { "deviations": 8, "measurable": 8 }, + "ef": { "deviations": 7, "measurable": 8 }, "ls": { "deviations": 8, "measurable": 8 }, "lf": { "deviations": 8, "measurable": 8 }, "tf": { "deviations": 3, "measurable": 8 }, "ff": { "deviations": 1, "measurable": 8 } }, - "reason": "Eerste X4a-nulmeting: bronstatusdatum en gewijzigde inputduur sturen de solver; P6-uitvoervelden blijven bewust verboden en X5/X7 zijn nog buiten scope." + "reason": "X4a-overgangsmeting na X12/P6-werkbandnormalisatie: één EF-cel landt exact op het openbare P6-orakel zonder asregressie; overige afwijkingen blijven bewust zichtbaar." } } } diff --git a/tests/planning/xer-schedoptions-blast-radius.json b/tests/planning/xer-schedoptions-blast-radius.json index d523ce25e..2659438b6 100644 --- a/tests/planning/xer-schedoptions-blast-radius.json +++ b/tests/planning/xer-schedoptions-blast-radius.json @@ -22,17 +22,17 @@ "deferredDefaults": [], "population": { "scanned": 93, - "oracleAxisFiles": 60, - "rawWithSchedOptions": 24, - "rawWithoutSchedOptions": 36, - "projectAddressableSchedOptions": 23, - "functionallyWithoutSchedOptions": 37, - "measured": 35, + "oracleAxisFiles": 58, + "rawWithSchedOptions": 23, + "rawWithoutSchedOptions": 35, + "projectAddressableSchedOptions": 22, + "functionallyWithoutSchedOptions": 36, + "measured": 34, "deferred": 2, - "readableFiles": 35, - "openedProjectsWithDefaults": 36, - "wiredProjectsWithDefaults": 36, - "concreteProjectsCompared": 84, + "readableFiles": 34, + "openedProjectsWithDefaults": 35, + "wiredProjectsWithDefaults": 35, + "concreteProjectsCompared": 70, "oracleNegativeFloatFiles": 5, "withoutSchedOptionsNegativeFloatFiles": 4, "rawNegativeFloatFiles": 6, @@ -228,13 +228,13 @@ "oracleNegativeFloatTasks": 0, "houseNegativeFloatTasks": 0, "xerDefaultsMovement": [ - 0, - 0, - 19, - 16, - 16, - 4, - 0 + 6, + 6, + 28, + 28, + 23, + 7, + 5 ], "xerDefaultsNegativeFloatTasks": 1, "defaults": { @@ -474,13 +474,13 @@ "oracleNegativeFloatTasks": 3, "houseNegativeFloatTasks": 4, "xerDefaultsMovement": [ - 0, - 0, - 6, - 4, + 14, + 16, + 20, + 20, + 8, 4, - 1, - 0 + 2 ], "xerDefaultsNegativeFloatTasks": 0, "defaults": { @@ -598,7 +598,7 @@ "houseNegativeFloatTasks": 2, "xerDefaultsMovement": [ 0, - 0, + 1, 0, 0, 2, @@ -836,129 +836,6 @@ } } }, - { - "id": "2a7732b5b99de2a5-1", - "state": "measured", - "tasks": 126, - "oracleNegativeFloatTasks": 0, - "houseNegativeFloatTasks": 44, - "xerDefaultsMovement": [ - 0, - 0, - 15, - 12, - 13, - 10, - 0 - ], - "xerDefaultsNegativeFloatTasks": 37, - "defaults": { - "totalFloatFinish": { - "movement": [ - 0, - 0, - 0, - 0, - 2, - 0, - 0 - ], - "chosenNegativeFloatTasks": 44, - "counterfactualNegativeFloatTasks": 44 - }, - "retainedLogic": { - "movement": [ - 67, - 69, - 1, - 1, - 68, - 29, - 0 - ], - "chosenNegativeFloatTasks": 44, - "counterfactualNegativeFloatTasks": 44 - }, - "openEndedNotCritical": { - "movement": [ - 0, - 0, - 0, - 0, - 2, - 2, - 2 - ], - "chosenNegativeFloatTasks": 44, - "counterfactualNegativeFloatTasks": 44 - }, - "predecessorLagCalendar": { - "movement": [ - 78, - 78, - 76, - 76, - 106, - 28, - 0 - ], - "chosenNegativeFloatTasks": 44, - "counterfactualNegativeFloatTasks": 43 - }, - "expectedFinishDates": { - "movement": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 44, - "counterfactualNegativeFloatTasks": 44 - }, - "preserveActualDates": { - "movement": [ - 0, - 0, - 15, - 12, - 13, - 8, - 0 - ], - "chosenNegativeFloatTasks": 37, - "counterfactualNegativeFloatTasks": 44 - }, - "clampNegativeFreeFloat": { - "movement": [ - 0, - 0, - 0, - 0, - 0, - 4, - 0 - ], - "chosenNegativeFloatTasks": 44, - "counterfactualNegativeFloatTasks": 44 - }, - "projectCriticalDefinition": { - "movement": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "chosenNegativeFloatTasks": 44, - "counterfactualNegativeFloatTasks": 44 - } - } - }, { "id": "2c1dce175b9f0781-1", "state": "measured", @@ -966,15 +843,15 @@ "oracleNegativeFloatTasks": 0, "houseNegativeFloatTasks": 38, "xerDefaultsMovement": [ - 0, - 0, - 2666, - 2618, - 2618, - 1785, - 505 + 5638, + 5486, + 6955, + 6957, + 6363, + 2433, + 63 ], - "xerDefaultsNegativeFloatTasks": 544, + "xerDefaultsNegativeFloatTasks": 0, "defaults": { "totalFloatFinish": { "movement": [ @@ -982,7 +859,7 @@ 0, 0, 0, - 331, + 330, 0, 0 ], @@ -992,11 +869,11 @@ "retainedLogic": { "movement": [ 3036, - 3149, + 3147, 6977, 6977, 6971, - 547, + 545, 31 ], "chosenNegativeFloatTasks": 38, @@ -1038,8 +915,8 @@ 0, 0 ], - "chosenNegativeFloatTasks": 11, - "counterfactualNegativeFloatTasks": 11 + "chosenNegativeFloatTasks": 38, + "counterfactualNegativeFloatTasks": 38 }, "preserveActualDates": { "movement": [ @@ -1048,8 +925,8 @@ 2666, 2618, 2618, - 1639, - 505 + 1641, + 501 ], "chosenNegativeFloatTasks": 544, "counterfactualNegativeFloatTasks": 38 @@ -1061,7 +938,7 @@ 0, 0, 0, - 621, + 618, 0 ], "chosenNegativeFloatTasks": 38, @@ -1343,14 +1220,14 @@ "houseNegativeFloatTasks": 0, "xerDefaultsMovement": [ 0, - 0, - 0, - 0, - 0, - 39, - 0 + 8, + 43, + 41, + 41, + 42, + 11 ], - "xerDefaultsNegativeFloatTasks": 0, + "xerDefaultsNegativeFloatTasks": 19, "defaults": { "totalFloatFinish": { "movement": [ @@ -1368,11 +1245,11 @@ "retainedLogic": { "movement": [ 0, - 9, 0, 0, - 9, - 9, + 0, + 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -1421,13 +1298,13 @@ "movement": [ 0, 0, - 0, - 0, - 0, - 30, - 0 + 43, + 41, + 41, + 31, + 11 ], - "chosenNegativeFloatTasks": 0, + "chosenNegativeFloatTasks": 11, "counterfactualNegativeFloatTasks": 0 }, "clampNegativeFreeFloat": { @@ -1437,7 +1314,7 @@ 0, 0, 0, - 39, + 42, 0 ], "chosenNegativeFloatTasks": 0, @@ -1507,9 +1384,9 @@ 0, 0, 0, - 1, - 1, - 1 + 0, + 0, + 0 ], "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 @@ -1660,8 +1537,8 @@ 0, 0 ], - "chosenNegativeFloatTasks": 0, - "counterfactualNegativeFloatTasks": 0 + "chosenNegativeFloatTasks": 1, + "counterfactualNegativeFloatTasks": 1 }, "preserveActualDates": { "movement": [ @@ -1711,10 +1588,10 @@ "oracleNegativeFloatTasks": 0, "houseNegativeFloatTasks": 0, "xerDefaultsMovement": [ - 0, - 0, - 0, - 0, + 1, + 1, + 1, + 1, 0, 0, 0 @@ -1835,14 +1712,14 @@ "houseNegativeFloatTasks": 0, "xerDefaultsMovement": [ 0, - 0, - 0, - 0, - 0, - 53, - 0 + 13, + 58, + 56, + 56, + 57, + 11 ], - "xerDefaultsNegativeFloatTasks": 0, + "xerDefaultsNegativeFloatTasks": 24, "defaults": { "totalFloatFinish": { "movement": [ @@ -1860,11 +1737,11 @@ "retainedLogic": { "movement": [ 0, - 8, 0, 0, - 8, - 8, + 0, + 0, + 0, 0 ], "chosenNegativeFloatTasks": 0, @@ -1913,13 +1790,13 @@ "movement": [ 0, 0, - 0, - 0, - 0, - 45, - 0 + 58, + 56, + 56, + 46, + 11 ], - "chosenNegativeFloatTasks": 0, + "chosenNegativeFloatTasks": 11, "counterfactualNegativeFloatTasks": 0 }, "clampNegativeFreeFloat": { @@ -1929,7 +1806,7 @@ 0, 0, 0, - 53, + 57, 0 ], "chosenNegativeFloatTasks": 0, @@ -1957,8 +1834,8 @@ "oracleNegativeFloatTasks": 0, "houseNegativeFloatTasks": 0, "xerDefaultsMovement": [ - 0, - 0, + 2, + 1, 2, 1, 1, @@ -2080,10 +1957,10 @@ "oracleNegativeFloatTasks": 6, "houseNegativeFloatTasks": 0, "xerDefaultsMovement": [ - 0, - 0, 3, - 1, + 2, + 3, + 2, 1, 2, 0 @@ -2203,12 +2080,12 @@ "oracleNegativeFloatTasks": 0, "houseNegativeFloatTasks": 0, "xerDefaultsMovement": [ + 1, + 1, 0, 0, - 0, - 0, - 0, - 0, + 1, + 2, 0 ], "xerDefaultsNegativeFloatTasks": 0, @@ -2326,12 +2203,12 @@ "oracleNegativeFloatTasks": 0, "houseNegativeFloatTasks": 0, "xerDefaultsMovement": [ + 1, + 1, 0, 0, - 0, - 0, - 0, - 0, + 1, + 2, 0 ], "xerDefaultsNegativeFloatTasks": 0, @@ -2449,9 +2326,9 @@ "oracleNegativeFloatTasks": 0, "houseNegativeFloatTasks": 0, "xerDefaultsMovement": [ + 83, 0, - 0, - 0, + 82, 0, 0, 0, @@ -2572,11 +2449,11 @@ "oracleNegativeFloatTasks": 0, "houseNegativeFloatTasks": 0, "xerDefaultsMovement": [ - 0, - 0, + 1, + 1, + 8, 7, 6, - 6, 1, 0 ], @@ -2860,9 +2737,9 @@ 0, 0, 0, - 1, - 1, - 1 + 0, + 0, + 0 ], "chosenNegativeFloatTasks": 0, "counterfactualNegativeFloatTasks": 0 @@ -2941,10 +2818,10 @@ "oracleNegativeFloatTasks": 6, "houseNegativeFloatTasks": 0, "xerDefaultsMovement": [ - 0, - 0, 3, - 1, + 2, + 3, + 2, 1, 2, 0 @@ -3310,8 +3187,8 @@ "oracleNegativeFloatTasks": 0, "houseNegativeFloatTasks": 0, "xerDefaultsMovement": [ - 0, - 0, + 2, + 1, 2, 1, 1, @@ -3802,13 +3679,13 @@ "oracleNegativeFloatTasks": 0, "houseNegativeFloatTasks": 0, "xerDefaultsMovement": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0 + 4, + 4, + 8, + 8, + 4, + 2, + 3 ], "xerDefaultsNegativeFloatTasks": 0, "defaults": { @@ -4048,12 +3925,12 @@ "oracleNegativeFloatTasks": 0, "houseNegativeFloatTasks": 0, "xerDefaultsMovement": [ - 0, - 0, + 1, + 1, 1, 0, - 0, - 0, + 1, + 2, 0 ], "xerDefaultsNegativeFloatTasks": 0, @@ -4171,12 +4048,12 @@ "oracleNegativeFloatTasks": 0, "houseNegativeFloatTasks": 0, "xerDefaultsMovement": [ - 0, - 0, + 1, + 1, 1, 0, - 0, - 0, + 1, + 2, 0 ], "xerDefaultsNegativeFloatTasks": 0, @@ -4417,11 +4294,11 @@ "counterfactual": false }, "population": { - "oracleFiles": 60, - "readableFiles": 58, + "oracleFiles": 58, + "readableFiles": 56, "deferredFiles": 2, - "projects": 84, - "tasks": 18778, + "projects": 70, + "tasks": 18625, "sourceTasks": 163, "activeSourceTasks": 0, "sourceFiles": 2, @@ -4475,819 +4352,162 @@ "fidelity": { "chosen": { "es": { - "deviations": 7665, - "measurable": 18540 + "deviations": 9202, + "measurable": 18524 }, "ef": { - "deviations": 7718, - "measurable": 18552 + "deviations": 9121, + "measurable": 18536 }, "ls": { - "deviations": 8408, - "measurable": 18430 + "deviations": 8356, + "measurable": 18406 }, "lf": { - "deviations": 8628, - "measurable": 18423 + "deviations": 8557, + "measurable": 18397 }, "tf": { - "deviations": 10372, + "deviations": 10092, "measurable": 18296 }, "ff": { - "deviations": 3636, + "deviations": 3559, "measurable": 17915 } }, "counterfactual": { "es": { - "deviations": 7665, - "measurable": 18540 + "deviations": 9202, + "measurable": 18524 }, "ef": { - "deviations": 7718, - "measurable": 18552 + "deviations": 9121, + "measurable": 18536 }, "ls": { - "deviations": 8408, - "measurable": 18430 + "deviations": 8356, + "measurable": 18406 }, "lf": { - "deviations": 8628, - "measurable": 18423 + "deviations": 8557, + "measurable": 18397 }, "tf": { - "deviations": 10372, + "deviations": 10092, "measurable": 18296 }, "ff": { - "deviations": 3636, + "deviations": 3559, "measurable": 17915 } } } }, - "legacyMissingTypeDelta": [ - { - "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 + "fidelity": { + "house": { + "es": { + "deviations": 6639, + "measurable": 7637 + }, + "ef": { + "deviations": 6510, + "measurable": 7649 + }, + "ls": { + "deviations": 7460, + "measurable": 7519 + }, + "lf": { + "deviations": 7427, + "measurable": 7510 + }, + "tf": { + "deviations": 6932, + "measurable": 7602 + }, + "ff": { + "deviations": 2352, + "measurable": 7221 + } }, - { - "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 - } - ], - "fidelity": { - "house": { - "es": { - "deviations": 4552, - "measurable": 7649 - }, - "ef": { - "deviations": 4488, - "measurable": 7661 - }, - "ls": { - "deviations": 7485, - "measurable": 7539 - }, - "lf": { - "deviations": 7466, - "measurable": 7532 - }, - "tf": { - "deviations": 6932, - "measurable": 7602 - }, - "ff": { - "deviations": 2341, - "measurable": 7221 - } - }, - "xerDefaults": { - "es": { - "deviations": 4552, - "measurable": 7649 - }, - "ef": { - "deviations": 4488, - "measurable": 7661 - }, - "ls": { - "deviations": 5417, - "measurable": 7539 - }, - "lf": { - "deviations": 5450, - "measurable": 7532 - }, - "tf": { - "deviations": 6407, - "measurable": 7602 - }, - "ff": { - "deviations": 585, - "measurable": 7221 - } + "xerDefaults": { + "es": { + "deviations": 1062, + "measurable": 7637 + }, + "ef": { + "deviations": 1287, + "measurable": 7649 + }, + "ls": { + "deviations": 4791, + "measurable": 7519 + }, + "lf": { + "deviations": 4781, + "measurable": 7510 + }, + "tf": { + "deviations": 4592, + "measurable": 7602 + }, + "ff": { + "deviations": 502, + "measurable": 7221 + } }, "defaults": { "totalFloatFinish": { "chosen": { "es": { - "deviations": 4552, - "measurable": 7649 + "deviations": 6639, + "measurable": 7637 }, "ef": { - "deviations": 4488, - "measurable": 7661 + "deviations": 6510, + "measurable": 7649 }, "ls": { - "deviations": 7485, - "measurable": 7539 + "deviations": 7460, + "measurable": 7519 }, "lf": { - "deviations": 7466, - "measurable": 7532 + "deviations": 7427, + "measurable": 7510 }, "tf": { "deviations": 6931, "measurable": 7602 }, "ff": { - "deviations": 2341, + "deviations": 2352, "measurable": 7221 } }, "counterfactual": { "es": { - "deviations": 4552, - "measurable": 7649 + "deviations": 6639, + "measurable": 7637 }, "ef": { - "deviations": 4488, - "measurable": 7661 + "deviations": 6510, + "measurable": 7649 }, "ls": { - "deviations": 7485, - "measurable": 7539 + "deviations": 7460, + "measurable": 7519 }, "lf": { - "deviations": 7466, - "measurable": 7532 + "deviations": 7427, + "measurable": 7510 }, "tf": { "deviations": 6932, "measurable": 7602 }, "ff": { - "deviations": 2341, + "deviations": 2352, "measurable": 7221 } } @@ -5295,53 +4515,53 @@ "retainedLogic": { "chosen": { "es": { - "deviations": 4552, - "measurable": 7649 + "deviations": 6639, + "measurable": 7637 }, "ef": { - "deviations": 4488, - "measurable": 7661 + "deviations": 6510, + "measurable": 7649 }, "ls": { - "deviations": 7485, - "measurable": 7539 + "deviations": 7460, + "measurable": 7519 }, "lf": { - "deviations": 7466, - "measurable": 7532 + "deviations": 7427, + "measurable": 7510 }, "tf": { "deviations": 6932, "measurable": 7602 }, "ff": { - "deviations": 2341, + "deviations": 2352, "measurable": 7221 } }, "counterfactual": { "es": { - "deviations": 4994, - "measurable": 7649 + "deviations": 7020, + "measurable": 7637 }, "ef": { - "deviations": 4959, - "measurable": 7661 + "deviations": 6922, + "measurable": 7649 }, "ls": { - "deviations": 7501, - "measurable": 7539 + "deviations": 7475, + "measurable": 7519 }, "lf": { - "deviations": 7481, - "measurable": 7532 + "deviations": 7444, + "measurable": 7510 }, "tf": { "deviations": 7410, "measurable": 7602 }, "ff": { - "deviations": 2521, + "deviations": 2519, "measurable": 7221 } } @@ -5349,53 +4569,53 @@ "openEndedNotCritical": { "chosen": { "es": { - "deviations": 4552, - "measurable": 7649 + "deviations": 6639, + "measurable": 7637 }, "ef": { - "deviations": 4488, - "measurable": 7661 + "deviations": 6510, + "measurable": 7649 }, "ls": { - "deviations": 7485, - "measurable": 7539 + "deviations": 7460, + "measurable": 7519 }, "lf": { - "deviations": 7466, - "measurable": 7532 + "deviations": 7427, + "measurable": 7510 }, "tf": { "deviations": 6932, "measurable": 7602 }, "ff": { - "deviations": 2341, + "deviations": 2352, "measurable": 7221 } }, "counterfactual": { "es": { - "deviations": 4552, - "measurable": 7649 + "deviations": 6639, + "measurable": 7637 }, "ef": { - "deviations": 4488, - "measurable": 7661 + "deviations": 6510, + "measurable": 7649 }, "ls": { - "deviations": 7485, - "measurable": 7539 + "deviations": 7460, + "measurable": 7519 }, "lf": { - "deviations": 7466, - "measurable": 7532 + "deviations": 7427, + "measurable": 7510 }, "tf": { "deviations": 6913, "measurable": 7602 }, "ff": { - "deviations": 2339, + "deviations": 2350, "measurable": 7221 } } @@ -5403,53 +4623,53 @@ "predecessorLagCalendar": { "chosen": { "es": { - "deviations": 4552, - "measurable": 7649 + "deviations": 6639, + "measurable": 7637 }, "ef": { - "deviations": 4488, - "measurable": 7661 + "deviations": 6510, + "measurable": 7649 }, "ls": { - "deviations": 7485, - "measurable": 7539 + "deviations": 7460, + "measurable": 7519 }, "lf": { - "deviations": 7466, - "measurable": 7532 + "deviations": 7427, + "measurable": 7510 }, "tf": { "deviations": 6932, "measurable": 7602 }, "ff": { - "deviations": 2341, + "deviations": 2352, "measurable": 7221 } }, "counterfactual": { "es": { - "deviations": 4552, - "measurable": 7649 + "deviations": 6639, + "measurable": 7637 }, "ef": { - "deviations": 4488, - "measurable": 7661 + "deviations": 6510, + "measurable": 7649 }, "ls": { - "deviations": 7485, - "measurable": 7539 + "deviations": 7459, + "measurable": 7519 }, "lf": { - "deviations": 7466, - "measurable": 7532 + "deviations": 7425, + "measurable": 7510 }, "tf": { "deviations": 6932, "measurable": 7602 }, "ff": { - "deviations": 2342, + "deviations": 2353, "measurable": 7221 } } @@ -5457,53 +4677,53 @@ "expectedFinishDates": { "chosen": { "es": { - "deviations": 5156, - "measurable": 7649 + "deviations": 6639, + "measurable": 7637 }, "ef": { - "deviations": 5144, - "measurable": 7661 + "deviations": 6510, + "measurable": 7649 }, "ls": { - "deviations": 7505, - "measurable": 7539 + "deviations": 7460, + "measurable": 7519 }, "lf": { - "deviations": 7482, - "measurable": 7532 + "deviations": 7427, + "measurable": 7510 }, "tf": { - "deviations": 7195, + "deviations": 6932, "measurable": 7602 }, "ff": { - "deviations": 2428, + "deviations": 2352, "measurable": 7221 } }, "counterfactual": { "es": { - "deviations": 5156, - "measurable": 7649 + "deviations": 6639, + "measurable": 7637 }, "ef": { - "deviations": 5144, - "measurable": 7661 + "deviations": 6510, + "measurable": 7649 }, "ls": { - "deviations": 7505, - "measurable": 7539 + "deviations": 7460, + "measurable": 7519 }, "lf": { - "deviations": 7482, - "measurable": 7532 + "deviations": 7427, + "measurable": 7510 }, "tf": { - "deviations": 7195, + "deviations": 6932, "measurable": 7602 }, "ff": { - "deviations": 2428, + "deviations": 2352, "measurable": 7221 } } @@ -5511,53 +4731,53 @@ "preserveActualDates": { "chosen": { "es": { - "deviations": 4552, - "measurable": 7649 + "deviations": 6639, + "measurable": 7637 }, "ef": { - "deviations": 4488, - "measurable": 7661 + "deviations": 6510, + "measurable": 7649 }, "ls": { - "deviations": 5417, - "measurable": 7539 + "deviations": 7439, + "measurable": 7519 }, "lf": { - "deviations": 5450, - "measurable": 7532 + "deviations": 7417, + "measurable": 7510 }, "tf": { - "deviations": 6408, + "deviations": 6402, "measurable": 7602 }, "ff": { - "deviations": 705, + "deviations": 713, "measurable": 7221 } }, "counterfactual": { "es": { - "deviations": 4552, - "measurable": 7649 + "deviations": 6639, + "measurable": 7637 }, "ef": { - "deviations": 4488, - "measurable": 7661 + "deviations": 6510, + "measurable": 7649 }, "ls": { - "deviations": 7485, - "measurable": 7539 + "deviations": 7460, + "measurable": 7519 }, "lf": { - "deviations": 7466, - "measurable": 7532 + "deviations": 7427, + "measurable": 7510 }, "tf": { "deviations": 6932, "measurable": 7602 }, "ff": { - "deviations": 2341, + "deviations": 2352, "measurable": 7221 } } @@ -5565,53 +4785,53 @@ "clampNegativeFreeFloat": { "chosen": { "es": { - "deviations": 4552, - "measurable": 7649 + "deviations": 6639, + "measurable": 7637 }, "ef": { - "deviations": 4488, - "measurable": 7661 + "deviations": 6510, + "measurable": 7649 }, "ls": { - "deviations": 7485, - "measurable": 7539 + "deviations": 7460, + "measurable": 7519 }, "lf": { - "deviations": 7466, - "measurable": 7532 + "deviations": 7427, + "measurable": 7510 }, "tf": { "deviations": 6932, "measurable": 7602 }, "ff": { - "deviations": 1749, + "deviations": 1763, "measurable": 7221 } }, "counterfactual": { "es": { - "deviations": 4552, - "measurable": 7649 + "deviations": 6639, + "measurable": 7637 }, "ef": { - "deviations": 4488, - "measurable": 7661 + "deviations": 6510, + "measurable": 7649 }, "ls": { - "deviations": 7485, - "measurable": 7539 + "deviations": 7460, + "measurable": 7519 }, "lf": { - "deviations": 7466, - "measurable": 7532 + "deviations": 7427, + "measurable": 7510 }, "tf": { "deviations": 6932, "measurable": 7602 }, "ff": { - "deviations": 2341, + "deviations": 2352, "measurable": 7221 } } @@ -5619,57 +4839,714 @@ "projectCriticalDefinition": { "chosen": { "es": { - "deviations": 4552, - "measurable": 7649 + "deviations": 6639, + "measurable": 7637 }, "ef": { - "deviations": 4488, - "measurable": 7661 + "deviations": 6510, + "measurable": 7649 }, "ls": { - "deviations": 7485, - "measurable": 7539 + "deviations": 7460, + "measurable": 7519 }, "lf": { - "deviations": 7466, - "measurable": 7532 + "deviations": 7427, + "measurable": 7510 }, "tf": { "deviations": 6932, "measurable": 7602 }, "ff": { - "deviations": 2341, + "deviations": 2352, "measurable": 7221 } }, "counterfactual": { "es": { - "deviations": 4552, - "measurable": 7649 + "deviations": 6639, + "measurable": 7637 }, "ef": { - "deviations": 4488, - "measurable": 7661 + "deviations": 6510, + "measurable": 7649 }, "ls": { - "deviations": 7485, - "measurable": 7539 + "deviations": 7460, + "measurable": 7519 }, "lf": { - "deviations": 7466, - "measurable": 7532 + "deviations": 7427, + "measurable": 7510 }, "tf": { "deviations": 6932, "measurable": 7602 }, "ff": { - "deviations": 2341, + "deviations": 2352, "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 + } + ] } From f53de7d55e8e0e332bac4ca30e8184e09068737a Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:53:24 +0200 Subject: [PATCH 085/171] Veranker XER-fidelity op bronidentiteit --- .../check-xer-fidelity-baseline-schema.ts | 4 +- tests/planning/check-xer-fidelity.ts | 150 ++++++++++++++- tests/planning/check-xer-product-fidelity.ts | 134 ++++++++++++- .../check-xer-schedule-options-corpus.ts | 176 ++++++++++++++++-- tests/planning/xer-fidelity-baseline.json | 64 +++---- .../xer-fidelity-scanner-transition.json | 94 ++++++++++ .../xer-product-fidelity-baseline.json | 50 +++++ .../xer-schedoptions-blast-radius.json | 50 +++++ tests/planning/xerFidelityTypes.ts | 2 +- 9 files changed, 661 insertions(+), 63 deletions(-) create mode 100644 tests/planning/xer-fidelity-scanner-transition.json diff --git a/tests/planning/check-xer-fidelity-baseline-schema.ts b/tests/planning/check-xer-fidelity-baseline-schema.ts index 7032560b5..555bf906d 100644 --- a/tests/planning/check-xer-fidelity-baseline-schema.ts +++ b/tests/planning/check-xer-fidelity-baseline-schema.ts @@ -40,10 +40,10 @@ const EXPECTED_BASELINE_KEYS = [ 'deabc76347eaef15', 'def489b2a803af9f', ] as const; const EXPECTED_MEASURABLE = { - es: 13_935, ef: 13_941, ls: 13_833, lf: 13_825, tf: 13_677, ff: 13_322, + 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 = 'e74960626b828b3187b11e2674783da34e7a4d8f470c1b97e40eb3ef48e7a6d2'; +const EXPECTED_BASELINE_SHA256 = 'a7075bd27c73cecae71403bc9b06e8ef53707b756049598c60f125dec0c28b29'; const diffs: string[] = []; let checks = 0; diff --git a/tests/planning/check-xer-fidelity.ts b/tests/planning/check-xer-fidelity.ts index ca981e80d..648849b70 100644 --- a/tests/planning/check-xer-fidelity.ts +++ b/tests/planning/check-xer-fidelity.ts @@ -10,7 +10,8 @@ import { xerSchemaFingerprint, } from './xerFidelity'; import type { XerCorpusManifest, XerCorpusRole } from './xerFidelity'; -import { scanXerGroundTruth } from './xerGroundTruth'; +import { XER_FIDELITY_AXES, scanXerGroundTruth } from './xerGroundTruth'; +import type { XerDateFidelityAxis } from './xerGroundTruth'; import type { XerFidelityBaseline } from './xerFidelityTypes'; const diffs: string[] = []; @@ -25,6 +26,39 @@ function eq(label: string, got: unknown, want: unknown): void { } } +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', @@ -641,27 +675,131 @@ if (!corpusRoot) { 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 effectieve datumassen', corpus.stats.fourDateTasks, 18_421); + 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: 29, cells: 2_088 }); + }, { 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_194); + 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_963); + 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_935, ef: 13_941, ls: 13_833, lf: 13_825, tf: 13_677, ff: 13_322, + 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), []); diff --git a/tests/planning/check-xer-product-fidelity.ts b/tests/planning/check-xer-product-fidelity.ts index 79c138006..c4ea29497 100644 --- a/tests/planning/check-xer-product-fidelity.ts +++ b/tests/planning/check-xer-product-fidelity.ts @@ -1,17 +1,29 @@ +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 } from './xerGroundTruth'; -import { measureXerFidelity, type XerSolvedProject } from './xerFidelity'; +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; }; +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)) { @@ -62,6 +86,29 @@ function solved(bytes: Uint8Array): XerSolvedProject { }; } +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'); @@ -76,13 +123,34 @@ if (!root) { 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') { - const oursById = new Map(ours.tasks.map(task => [task.sourceTaskId, task])); console.log(`. ${label}`); for (const task of truth.tasks) { const actual = oursById.get(task.taskId); @@ -106,6 +174,66 @@ if (!root) { expected.reason.trim().length > 0, true); } + const transitionFiles = Object.entries(baseline.cellTransitions.files).sort(([left], [right]) => + left.localeCompare(right)); + eq('celovergangscontract pint twee hash-orakels, 37 eerder exacte cellen en exact 16+1 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: [24, 13], + improvements: [16, 1], + improvementAxes: [['ef', 'es', 'lf', 'ls'], ['ef']], + buckets: ['diff', 'exact'], + }); + 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. diff --git a/tests/planning/check-xer-schedule-options-corpus.ts b/tests/planning/check-xer-schedule-options-corpus.ts index 492a0a285..2f60b3b3c 100644 --- a/tests/planning/check-xer-schedule-options-corpus.ts +++ b/tests/planning/check-xer-schedule-options-corpus.ts @@ -121,6 +121,29 @@ interface BaselineValueDelta { 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[]; @@ -172,6 +195,7 @@ interface BlastRadiusBaseline { 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; @@ -201,16 +225,101 @@ function eq(label: string, got: unknown, want: unknown): void { if (JSON.stringify(got) !== JSON.stringify(want)) diffs.push(label); } -/** X12 wijzigde de onafhankelijke orakelvorm; productuitkomsten horen niet in deze projectie. */ -function x12OracleShape(baseline: Pick): unknown { - return baseline.population; +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], + )))), + }, + }; } -/** De latere completed/progress/LOE/data_date-aanpassingen bewaken alleen productprojecties. */ -function completedProgressLoeDataDateEffects( - baseline: Pick, -): unknown { - return { files: baseline.files, fidelity: baseline.fidelity }; +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[] { @@ -863,7 +972,8 @@ if (!root) { diffs.push('OPS_XER_CORPUS bestaat niet'); } else { const measured = measureCorpus(root); - eq('openbare populatie en negatieve-floatverdeling', measured.population, { + 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 @@ -873,17 +983,20 @@ if (!root) { 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, }); + 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, @@ -921,10 +1034,35 @@ if (!root) { 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); - eq('X12-orakelvorm houdt uitsluitend de onafhankelijke corpuspopulatie exact vast', - x12OracleShape(measured), x12OracleShape(committed)); - eq('completed/progress/LOE/data_date-effecten houden uitsluitend productprojecties exact vast', - completedProgressLoeDataDateEffects(measured), completedProgressLoeDataDateEffects(committed)); + 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, + }); } } diff --git a/tests/planning/xer-fidelity-baseline.json b/tests/planning/xer-fidelity-baseline.json index cd181f3a8..55bd64871 100644 --- a/tests/planning/xer-fidelity-baseline.json +++ b/tests/planning/xer-fidelity-baseline.json @@ -103,19 +103,19 @@ "counters": { "es": { "deviations": 0, - "measurable": 6977 + "measurable": 6976 }, "ef": { "deviations": 0, - "measurable": 6977 + "measurable": 6976 }, "ls": { "deviations": 0, - "measurable": 6977 + "measurable": 6976 }, "lf": { "deviations": 0, - "measurable": 6977 + "measurable": 6976 }, "tf": { "deviations": 0, @@ -126,7 +126,7 @@ "measurable": 6976 } }, - "schemaFingerprint": "c6a7ada359dc738c" + "schemaFingerprint": "2493349dd14f7fb3" }, "146e4c8659bad066": { "label": "crawl-xer-extra/meridianiq/demo-sample.xer", @@ -158,7 +158,7 @@ "measurable": 30 } }, - "schemaFingerprint": "e6e701b2eb7cd2fd" + "schemaFingerprint": "789d55d2260a50b8" }, "53f5cdbeb3fbd9c6": { "label": "crawl-xer-extra/meridianiq/sample_update2.xer", @@ -190,7 +190,7 @@ "measurable": 31 } }, - "schemaFingerprint": "edb0bca25b371266" + "schemaFingerprint": "e5ee686e1f756d6f" }, "c872c9e704797d82": { "label": "crawl-xer-extra/p6difftool/sample-target.xer", @@ -254,7 +254,7 @@ "measurable": 20 } }, - "schemaFingerprint": "f81e7c7d61b45f91" + "schemaFingerprint": "6431acce233a860e" }, "deabc76347eaef15": { "label": "crawl-xer-extra/schedulevisualiser/Sample_Project.xer", @@ -327,19 +327,19 @@ "counters": { "es": { "deviations": 0, - "measurable": 169 + "measurable": 166 }, "ef": { "deviations": 0, - "measurable": 169 + "measurable": 166 }, "ls": { "deviations": 0, - "measurable": 169 + "measurable": 166 }, "lf": { "deviations": 0, - "measurable": 169 + "measurable": 166 }, "tf": { "deviations": 0, @@ -350,7 +350,7 @@ "measurable": 152 } }, - "schemaFingerprint": "9415c811af9cd8cf" + "schemaFingerprint": "ec668c11ba3d5921" }, "9590c4cdc4efa69a": { "label": "crawl-xer/gimmer-crag-mountain-refuge.xer", @@ -414,7 +414,7 @@ "measurable": 113 } }, - "schemaFingerprint": "380e546c56886af3" + "schemaFingerprint": "a81fedfd4aaa73d8" }, "ad92ce52ba95b275": { "label": "crawl-xer/hb-intel_Project_Schedule.xer", @@ -638,7 +638,7 @@ "measurable": 1036 } }, - "schemaFingerprint": "2168c6d533f62f7b" + "schemaFingerprint": "c860b3291dba9ee1" }, "55b7e4463dcd36ba": { "label": "crawl-xer/Sample_Construction_TEC.xer", @@ -670,7 +670,7 @@ "measurable": 51 } }, - "schemaFingerprint": "3f976da19fabf0b7" + "schemaFingerprint": "96a23d026fc67de2" }, "1ba69d297ee9a5c6": { "label": "crawl-xer/stack_data_center_baseline.xer", @@ -847,11 +847,11 @@ }, "ls": { "deviations": 0, - "measurable": 1 + "measurable": 0 }, "lf": { "deviations": 0, - "measurable": 2 + "measurable": 0 }, "tf": { "deviations": 0, @@ -862,7 +862,7 @@ "measurable": 0 } }, - "schemaFingerprint": "3b0d135d5ce02f18" + "schemaFingerprint": "8c46c34ac267acaa" }, "1d7901d68cc7063b": { "label": "P6-Viewer/XER Files/sample.xer", @@ -894,7 +894,7 @@ "measurable": 11 } }, - "schemaFingerprint": "d14f605d6a854880" + "schemaFingerprint": "84d6d8bd748871eb" }, "a2f3b2469e26f199": { "label": "P6-Viewer/XER Files/TERMINAL BUILDING-AIRPORT.xer", @@ -975,11 +975,11 @@ }, "ls": { "deviations": 0, - "measurable": 2 + "measurable": 0 }, "lf": { "deviations": 0, - "measurable": 2 + "measurable": 0 }, "tf": { "deviations": 0, @@ -990,7 +990,7 @@ "measurable": 0 } }, - "schemaFingerprint": "cf7f7bf2470c4e6e" + "schemaFingerprint": "a1160804bdaed88e" }, "ac4b9d677133c6ed": { "label": "ProjectLens/docs/demo/northstar-previous.xer", @@ -1007,11 +1007,11 @@ }, "ls": { "deviations": 0, - "measurable": 1 + "measurable": 0 }, "lf": { "deviations": 0, - "measurable": 1 + "measurable": 0 }, "tf": { "deviations": 0, @@ -1022,7 +1022,7 @@ "measurable": 0 } }, - "schemaFingerprint": "b83f7fe67823738b" + "schemaFingerprint": "d48ece7ca76541f4" }, "790b90fda837bad3": { "label": "ProjectLens/docs/demo/riverside-current.xer", @@ -1039,11 +1039,11 @@ }, "ls": { "deviations": 0, - "measurable": 2 + "measurable": 0 }, "lf": { "deviations": 0, - "measurable": 2 + "measurable": 0 }, "tf": { "deviations": 0, @@ -1054,7 +1054,7 @@ "measurable": 0 } }, - "schemaFingerprint": "b8ea5c0682122f82" + "schemaFingerprint": "752e6b19354f9396" }, "78325fb6445d22f5": { "label": "ProjectLens/docs/demo/riverside-previous.xer", @@ -1071,11 +1071,11 @@ }, "ls": { "deviations": 0, - "measurable": 1 + "measurable": 0 }, "lf": { "deviations": 0, - "measurable": 1 + "measurable": 0 }, "tf": { "deviations": 0, @@ -1086,7 +1086,7 @@ "measurable": 0 } }, - "schemaFingerprint": "8cc9c1c456a80b52" + "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.json b/tests/planning/xer-product-fidelity-baseline.json index 9ba6c827f..30a32fc09 100644 --- a/tests/planning/xer-product-fidelity-baseline.json +++ b/tests/planning/xer-product-fidelity-baseline.json @@ -1,5 +1,55 @@ { "version": 1, + "cellTransitions": { + "version": 1, + "files": { + "568c19375b4e0d674c75e6aea023c98772fb33e6896755f866e4641a56197300": { + "previouslyExact": [ + ["1", "1000", "es"], ["1", "1000", "ef"], ["1", "1000", "tf"], ["1", "1000", "ff"], + ["1", "1010", "es"], ["1", "1010", "ls"], ["1", "1010", "ff"], + ["1", "1020", "es"], ["1", "1020", "ls"], ["1", "1020", "ff"], + ["1", "2000", "es"], ["1", "2000", "ls"], ["1", "2000", "tf"], ["1", "2000", "ff"], + ["1", "2010", "es"], ["1", "2010", "ls"], ["1", "2010", "tf"], ["1", "2010", "ff"], + ["1", "2020", "tf"], ["1", "2020", "ff"], + ["1", "3000", "tf"], ["1", "3000", "ff"], + ["1", "3010", "tf"], ["1", "3010", "ff"] + ], + "improvements": [ + { "cell": ["1", "2020", "es"], "old": { "bucket": "diff", "value": "2026-05-08T12:00" }, "current": { "bucket": "exact", "value": "2026-05-11T08:00" } }, + { "cell": ["1", "3000", "es"], "old": { "bucket": "diff", "value": "2026-05-14T12:00" }, "current": { "bucket": "exact", "value": "2026-05-15T08:00" } }, + { "cell": ["1", "1010", "ef"], "old": { "bucket": "diff", "value": "2026-04-07T16:00" }, "current": { "bucket": "exact", "value": "2026-04-07T17:00" } }, + { "cell": ["1", "1020", "ef"], "old": { "bucket": "diff", "value": "2026-04-14T16:00" }, "current": { "bucket": "exact", "value": "2026-04-14T17:00" } }, + { "cell": ["1", "2000", "ef"], "old": { "bucket": "diff", "value": "2026-04-28T16:00" }, "current": { "bucket": "exact", "value": "2026-04-28T17:00" } }, + { "cell": ["1", "2010", "ef"], "old": { "bucket": "diff", "value": "2026-05-08T12:00" }, "current": { "bucket": "exact", "value": "2026-05-08T17:00" } }, + { "cell": ["1", "2020", "ef"], "old": { "bucket": "diff", "value": "2026-05-14T12:00" }, "current": { "bucket": "exact", "value": "2026-05-14T17:00" } }, + { "cell": ["1", "3000", "ef"], "old": { "bucket": "diff", "value": "2026-05-18T12:00" }, "current": { "bucket": "exact", "value": "2026-05-18T17:00" } }, + { "cell": ["1", "2020", "ls"], "old": { "bucket": "diff", "value": "2026-05-08T12:00" }, "current": { "bucket": "exact", "value": "2026-05-11T08:00" } }, + { "cell": ["1", "3000", "ls"], "old": { "bucket": "diff", "value": "2026-05-14T12:00" }, "current": { "bucket": "exact", "value": "2026-05-15T08:00" } }, + { "cell": ["1", "1010", "lf"], "old": { "bucket": "diff", "value": "2026-04-07T16:00" }, "current": { "bucket": "exact", "value": "2026-04-07T17:00" } }, + { "cell": ["1", "1020", "lf"], "old": { "bucket": "diff", "value": "2026-04-14T16:00" }, "current": { "bucket": "exact", "value": "2026-04-14T17:00" } }, + { "cell": ["1", "2000", "lf"], "old": { "bucket": "diff", "value": "2026-04-28T16:00" }, "current": { "bucket": "exact", "value": "2026-04-28T17:00" } }, + { "cell": ["1", "2010", "lf"], "old": { "bucket": "diff", "value": "2026-05-08T12:00" }, "current": { "bucket": "exact", "value": "2026-05-08T17:00" } }, + { "cell": ["1", "2020", "lf"], "old": { "bucket": "diff", "value": "2026-05-14T12:00" }, "current": { "bucket": "exact", "value": "2026-05-14T17:00" } }, + { "cell": ["1", "3000", "lf"], "old": { "bucket": "diff", "value": "2026-05-18T12:00" }, "current": { "bucket": "exact", "value": "2026-05-18T17:00" } } + ] + }, + "c872c9e704797d829205f3c5486e7c4cd5aec729143a63fbcf8b0ec8e8864a3c": { + "previouslyExact": [ + ["1", "1000", "tf"], ["1", "1000", "ff"], + ["1", "1010", "ff"], + ["1", "1020", "ff"], + ["1", "1099", "es"], + ["1", "2010", "tf"], ["1", "2010", "ff"], + ["1", "2020", "tf"], ["1", "2020", "ff"], + ["1", "3000", "tf"], ["1", "3000", "ff"], + ["1", "3010", "tf"], ["1", "3010", "ff"] + ], + "improvements": [ + { "cell": ["1", "1099", "ef"], "old": { "bucket": "diff", "value": "2026-04-16T16:00" }, "current": { "bucket": "exact", "value": "2026-04-16T17:00" } } + ] + } + } + }, "files": { "crawl-xer/p6diff-baseline.xer": { "tasks": 8, diff --git a/tests/planning/xer-schedoptions-blast-radius.json b/tests/planning/xer-schedoptions-blast-radius.json index 2659438b6..894e8c045 100644 --- a/tests/planning/xer-schedoptions-blast-radius.json +++ b/tests/planning/xer-schedoptions-blast-radius.json @@ -38,6 +38,56 @@ "rawNegativeFloatFiles": 6, "rawWithoutSchedOptionsNegativeFloatFiles": 5 }, + "causalProductEffects": { + "version": 1, + "axes": ["es", "ef", "ls", "lf", "tf", "ff"], + "loeDataDate": { + "fields": ["deviations", "measurable"], + "rows": [ + { "branch": "house", "axis": "es", "deviations": 6639, "measurable": 7637 }, + { "branch": "house", "axis": "ef", "deviations": 6510, "measurable": 7649 }, + { "branch": "house", "axis": "ls", "deviations": 7460, "measurable": 7519 }, + { "branch": "house", "axis": "lf", "deviations": 7427, "measurable": 7510 }, + { "branch": "house", "axis": "tf", "deviations": 6932, "measurable": 7602 }, + { "branch": "house", "axis": "ff", "deviations": 2352, "measurable": 7221 }, + { "branch": "xerDefaults", "axis": "es", "deviations": 1062, "measurable": 7637 }, + { "branch": "xerDefaults", "axis": "ef", "deviations": 1287, "measurable": 7649 }, + { "branch": "xerDefaults", "axis": "ls", "deviations": 4791, "measurable": 7519 }, + { "branch": "xerDefaults", "axis": "lf", "deviations": 4781, "measurable": 7510 }, + { "branch": "xerDefaults", "axis": "tf", "deviations": 4592, "measurable": 7602 }, + { "branch": "xerDefaults", "axis": "ff", "deviations": 502, "measurable": 7221 } + ] + }, + "completedProgress": { + "fields": ["deviations", "measurable"], + "rows": [ + { "branch": "retainedLogic", "variant": "chosen", "axis": "es", "deviations": 6639, "measurable": 7637 }, + { "branch": "retainedLogic", "variant": "chosen", "axis": "ef", "deviations": 6510, "measurable": 7649 }, + { "branch": "retainedLogic", "variant": "chosen", "axis": "ls", "deviations": 7460, "measurable": 7519 }, + { "branch": "retainedLogic", "variant": "chosen", "axis": "lf", "deviations": 7427, "measurable": 7510 }, + { "branch": "retainedLogic", "variant": "chosen", "axis": "tf", "deviations": 6932, "measurable": 7602 }, + { "branch": "retainedLogic", "variant": "chosen", "axis": "ff", "deviations": 2352, "measurable": 7221 }, + { "branch": "retainedLogic", "variant": "counterfactual", "axis": "es", "deviations": 7020, "measurable": 7637 }, + { "branch": "retainedLogic", "variant": "counterfactual", "axis": "ef", "deviations": 6922, "measurable": 7649 }, + { "branch": "retainedLogic", "variant": "counterfactual", "axis": "ls", "deviations": 7475, "measurable": 7519 }, + { "branch": "retainedLogic", "variant": "counterfactual", "axis": "lf", "deviations": 7444, "measurable": 7510 }, + { "branch": "retainedLogic", "variant": "counterfactual", "axis": "tf", "deviations": 7410, "measurable": 7602 }, + { "branch": "retainedLogic", "variant": "counterfactual", "axis": "ff", "deviations": 2519, "measurable": 7221 }, + { "branch": "preserveActualDates", "variant": "chosen", "axis": "es", "deviations": 6639, "measurable": 7637 }, + { "branch": "preserveActualDates", "variant": "chosen", "axis": "ef", "deviations": 6510, "measurable": 7649 }, + { "branch": "preserveActualDates", "variant": "chosen", "axis": "ls", "deviations": 7439, "measurable": 7519 }, + { "branch": "preserveActualDates", "variant": "chosen", "axis": "lf", "deviations": 7417, "measurable": 7510 }, + { "branch": "preserveActualDates", "variant": "chosen", "axis": "tf", "deviations": 6402, "measurable": 7602 }, + { "branch": "preserveActualDates", "variant": "chosen", "axis": "ff", "deviations": 713, "measurable": 7221 }, + { "branch": "preserveActualDates", "variant": "counterfactual", "axis": "es", "deviations": 6639, "measurable": 7637 }, + { "branch": "preserveActualDates", "variant": "counterfactual", "axis": "ef", "deviations": 6510, "measurable": 7649 }, + { "branch": "preserveActualDates", "variant": "counterfactual", "axis": "ls", "deviations": 7460, "measurable": 7519 }, + { "branch": "preserveActualDates", "variant": "counterfactual", "axis": "lf", "deviations": 7427, "measurable": 7510 }, + { "branch": "preserveActualDates", "variant": "counterfactual", "axis": "tf", "deviations": 6932, "measurable": 7602 }, + { "branch": "preserveActualDates", "variant": "counterfactual", "axis": "ff", "deviations": 2352, "measurable": 7221 } + ] + } + }, "schedOptionsRows": { "rows": 50, "floatFinish": 41, diff --git a/tests/planning/xerFidelityTypes.ts b/tests/planning/xerFidelityTypes.ts index c224b8bb3..f5ce28748 100644 --- a/tests/planning/xerFidelityTypes.ts +++ b/tests/planning/xerFidelityTypes.ts @@ -16,7 +16,7 @@ * * `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.935/13.941/13.833/13.825/13.677/13.322 keer meetbaar — sommige bestanden dragen alleen float + * 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. */ From 1572a9ec2006f3094a7a09c7d894952af43aba17 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:25:13 +0200 Subject: [PATCH 086/171] test: bewaak corpusloze XER-fidelitycontracten --- .../check-xer-corpusless-fidelity-gate.ts | 483 ++++++++++++++++++ tests/planning/run.sh | 11 + 2 files changed, 494 insertions(+) create mode 100644 tests/planning/check-xer-corpusless-fidelity-gate.ts 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..c1d00498e --- /dev/null +++ b/tests/planning/check-xer-corpusless-fidelity-gate.ts @@ -0,0 +1,483 @@ +/** + * 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, de toekomstige 34-entry product-v2-meting, bestaat bewust nog niet. De huidige + * `xer-product-fidelity-baseline.json` is een v1-overgangsdossier met twee pins en redenen; + * deze check benoemt dat expliciet maar promoveert of hernoemt hem niet. `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'; + +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 ProductV1 = { version: number; files: Record }; + +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 }, + 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 diffs: string[] = []; +let checks = 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 checkEqual(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 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 checkProblem(problems: string[], label: string, condition: boolean): void { + if (!condition) issue(problems, label); +} + +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 { + checks++; + const problems = validateContract(manifest, oracle, replay); + if (problems.length === 0) diffs.push(`${label}: mutant werd ten onrechte geaccepteerd`); +} + +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 productV1Raw = readFileSync(join(HERE, 'xer-product-fidelity-baseline.json'), 'utf8'); +const manifest = JSON.parse(manifestRaw) as Manifest; +const oracle = JSON.parse(oracleRaw) as OracleBaseline; +const replay = JSON.parse(replayRaw) as ReplayPin; +const productV1 = JSON.parse(productV1Raw) as ProductV1; + +// 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 problems = validateContract(manifest, oracle, replay); + checkEqual(`A+B+D huidige statische contracten zijn consistent (${problems.join('; ')})`, problems, []); + checkEqual('A raw manifestbytes zijn exact gepind', rawHash(manifestRaw), EXPECTED.manifestRawSha256); + checkEqual('B raw oraclebaselinebytes zijn exact gepind', rawHash(oracleRaw), EXPECTED.baselineRawSha256); + checkEqual('C bestaat nog niet: productbaseline blijft expliciet v1-tweepinsdossier', { + version: productV1.version, + entries: Object.keys(productV1.files ?? {}).length, + reasons: Object.values(productV1.files ?? {}).every(entry => typeof entry.reason === 'string' && entry.reason.length > 0), + }, { version: 1, entries: 2, reasons: true }); + console.log('INFO xer-corpusless-fidelity-gate: product-v2 ontbreekt bewust; v1-tweepinsbaseline is geen X12-eindcontract.'); +} + +// 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); +} + +if (diffs.length === 0) { + console.log(`OK: xer-corpusless-fidelity-gate — ${checks} checks groen`); +} else { + console.log(`XX xer-corpusless-fidelity-gate — ${diffs.length} van ${checks} checks rood:`); + for (const diff of diffs) console.log(` XX ${diff}`); + process.exit(1); +} diff --git a/tests/planning/run.sh b/tests/planning/run.sh index 3554b4837..5f578570c 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -215,6 +215,17 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then 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 uitsluitend de gereviewde openbare 93-entry + # inventaris, onafhankelijke 34-entry-orakelselectie en task-replay-kruiscontract. Hij importeert + # nooit reader/scanner/solver en draait daarom precies één keer vóór corpuschecks, niet in de + # tijdzonematrix. De product-v2-eindbaseline bestaat nog niet; de oude v1-tweepinsbaseline blijft + # expliciet een overgangsdossier en wordt hier niet als groen eindcontract voorgesteld. + 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. From 3486cf8f5364e91584614e76645a1b6f288a6f2e Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:57:15 +0200 Subject: [PATCH 087/171] test: valideer eigen bronvalidator --- .../check-xer-corpusless-fidelity-gate.ts | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/tests/planning/check-xer-corpusless-fidelity-gate.ts b/tests/planning/check-xer-corpusless-fidelity-gate.ts index c1d00498e..7ad37602d 100644 --- a/tests/planning/check-xer-corpusless-fidelity-gate.ts +++ b/tests/planning/check-xer-corpusless-fidelity-gate.ts @@ -86,6 +86,12 @@ const EXPECTED = { 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', +]); const diffs: string[] = []; let checks = 0; @@ -341,6 +347,124 @@ function checkProblem(problems: string[], label: string, condition: boolean): vo 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 += ' '; + state = 'single-quote'; + escaped = false; + continue; + } + if (char === '"') { + result += ' '; + state = 'double-quote'; + escaped = false; + continue; + } + if (char === '`') { + result += ' '; + 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 === '\n' ? '\n' : ' '; + if (escaped) escaped = false; + else if (char === '\\') escaped = true; + else if (char === '\'') state = 'code'; + continue; + } + + if (state === 'double-quote') { + result += char === '\n' ? '\n' : ' '; + if (escaped) escaped = false; + else if (char === '\\') escaped = true; + else if (char === '"') state = 'code'; + continue; + } + + result += char === '\n' ? '\n' : ' '; + if (escaped) escaped = false; + else if (char === '\\') escaped = true; + else if (char === '`') state = 'code'; + } + + return result; +} + +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, 4); + 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 (/\bimport\s*\(/.test(executableScan)) issue(problems, 'bron: dynamische import() is verboden'); + if (/\brequire\s*\(/.test(executableScan)) issue(problems, 'bron: require() is verboden'); + + return problems; +} + function validateContract(manifestRaw: unknown, oracleRaw: unknown, replayRaw: unknown): string[] { const inventory = validateInventory(manifestRaw); if (!inventory.manifest) return inventory.problems; @@ -359,6 +483,7 @@ 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 productV1Raw = readFileSync(join(HERE, 'xer-product-fidelity-baseline.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; @@ -368,6 +493,12 @@ const productV1 = JSON.parse(productV1Raw) as ProductV1; // 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); + checkEqual('bron huidige ongebundelde TS-bron is strikt corpusloos', currentSourceProblems, []); + + const mutantSourceProblems = validateOwnSource(`${sourceRaw}\nimport { readXER } from '@/services/xer/xerReader';\n`); + checkEqual('bronmutant met readXER-import wordt afgewezen', mutantSourceProblems.length > 0, true); + const problems = validateContract(manifest, oracle, replay); checkEqual(`A+B+D huidige statische contracten zijn consistent (${problems.join('; ')})`, problems, []); checkEqual('A raw manifestbytes zijn exact gepind', rawHash(manifestRaw), EXPECTED.manifestRawSha256); From 16a28b629e4e6d06a21836ec6b3d775dceab75c0 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:10:38 +0200 Subject: [PATCH 088/171] Bewaak template-expressies in X12-sourcegate --- .../check-xer-corpusless-fidelity-gate.ts | 43 +++++++++++++++---- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/tests/planning/check-xer-corpusless-fidelity-gate.ts b/tests/planning/check-xer-corpusless-fidelity-gate.ts index 7ad37602d..fb5713244 100644 --- a/tests/planning/check-xer-corpusless-fidelity-gate.ts +++ b/tests/planning/check-xer-corpusless-fidelity-gate.ts @@ -370,19 +370,19 @@ function stripExecutableNoise(source: string): string { continue; } if (char === '\'') { - result += ' '; + result += char; state = 'single-quote'; escaped = false; continue; } if (char === '"') { - result += ' '; + result += char; state = 'double-quote'; escaped = false; continue; } if (char === '`') { - result += ' '; + result += char; state = 'template'; escaped = false; continue; @@ -409,7 +409,7 @@ function stripExecutableNoise(source: string): string { } if (state === 'single-quote') { - result += char === '\n' ? '\n' : ' '; + result += char; if (escaped) escaped = false; else if (char === '\\') escaped = true; else if (char === '\'') state = 'code'; @@ -417,14 +417,14 @@ function stripExecutableNoise(source: string): string { } if (state === 'double-quote') { - result += char === '\n' ? '\n' : ' '; + result += char; if (escaped) escaped = false; else if (char === '\\') escaped = true; else if (char === '"') state = 'code'; continue; } - result += char === '\n' ? '\n' : ' '; + result += char; if (escaped) escaped = false; else if (char === '\\') escaped = true; else if (char === '`') state = 'code'; @@ -433,6 +433,11 @@ function stripExecutableNoise(source: string): string { 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[] = []; @@ -459,8 +464,8 @@ function validateOwnSource(raw: string): string[] { } const executableScan = stripExecutableNoise(raw); - if (/\bimport\s*\(/.test(executableScan)) issue(problems, 'bron: dynamische import() is verboden'); - if (/\brequire\s*\(/.test(executableScan)) issue(problems, 'bron: require() is verboden'); + 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; } @@ -499,6 +504,28 @@ const productV1 = JSON.parse(productV1Raw) as ProductV1; const mutantSourceProblems = validateOwnSource(`${sourceRaw}\nimport { readXER } from '@/services/xer/xerReader';\n`); checkEqual('bronmutant met readXER-import wordt afgewezen', mutantSourceProblems.length > 0, true); + const templateExpressionRequireSource = [ + sourceRaw, + "const templateTrap = `probe ${" + ['requ', "ire('x')"].join('') + "} binnen een template-expressie`;", + '', + ].join('\n'); + const templateExpressionRequireProblems = validateOwnSource(templateExpressionRequireSource); + checkEqual( + 'bronmutant met require in template-expressie wordt afgewezen', + templateExpressionRequireProblems.length > 0, + true, + ); + + const multilineImportSource = [ + sourceRaw, + 'import {', + ' readXER', + "} from '@/services/xer/xerReader';", + '', + ].join('\n'); + const multilineImportProblems = validateOwnSource(multilineImportSource); + checkEqual('bronmutant met multiline import wordt afgewezen', multilineImportProblems.length > 0, true); + const problems = validateContract(manifest, oracle, replay); checkEqual(`A+B+D huidige statische contracten zijn consistent (${problems.join('; ')})`, problems, []); checkEqual('A raw manifestbytes zijn exact gepind', rawHash(manifestRaw), EXPECTED.manifestRawSha256); From af51318305583db54fdd7415878da87a0fa3344f Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:50:22 +0200 Subject: [PATCH 089/171] Werk browserslist-audit bij --- package-lock.json | 66 ++++++++++++++++++++++++++++------------------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/package-lock.json b/package-lock.json index b24529460..f645aa594 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2409,12 +2409,16 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", "dev": true, + "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/bidi-js": { @@ -2440,9 +2444,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, "funding": [ { @@ -2458,12 +2462,13 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -2473,9 +2478,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001769", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", - "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -2490,7 +2495,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/convert-source-map": { "version": "2.0.0", @@ -2555,10 +2561,11 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", - "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", - "dev": true + "version": "1.5.419", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.419.tgz", + "integrity": "sha512-nHMPn8x4yCxCI0iSnL+LlHL5sUoUfjLXkcRIagZ4GBdrfFLFaiLNvzJWbJqZhFT9IAhw5tUSNlhggWN+otvp/A==", + "dev": true, + "license": "ISC" }, "node_modules/enhanced-resolve": { "version": "5.19.0", @@ -3512,10 +3519,14 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/optionator": { "version": "0.9.4", @@ -3956,9 +3967,9 @@ "license": "MIT" }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { @@ -3974,6 +3985,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" From fd9885bd8a72494d6105236269a2bf1de2f789fd Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:58:02 +0200 Subject: [PATCH 090/171] Maak XER-exportverlies per formaat expliciet --- src/services/xerExportLoss.ts | 57 +++++++++++++++++++------ tests/planning/check-xer-export-loss.ts | 25 +++++++++-- 2 files changed, 64 insertions(+), 18 deletions(-) diff --git a/src/services/xerExportLoss.ts b/src/services/xerExportLoss.ts index 368944dfc..c9ad3dc58 100644 --- a/src/services/xerExportLoss.ts +++ b/src/services/xerExportLoss.ts @@ -2,12 +2,13 @@ import type { ExportFormat } from './formatRegistry'; export type XerLossyExportFormat = Exclude | 'mpp'; export type XerExportLossCategory = - | 'exact-source-bytes' - | 'unknown-tables-and-fields' - | 'typed-diagnostics' - | 'project-report' + | 'baselines' + | 'udfs' + | 'activity-codes' + | 'notes' + | 'raw-curves-and-assignment-quantities' | 'external-links' - | 'p6-provenance'; + | 'schedule-options-and-provenance'; export interface XerExportLossWarning { readonly code: 'XER_ONLY_DATA_NOT_EXPRESSIBLE'; @@ -22,6 +23,40 @@ export interface XerExportLossInput { readonly hasImportMetadata: boolean; } +/** + * Deze lijsten zijn bewust per doelformaat vastgelegd. De XER-import levert deze P6-gegevens + * wel aan OPS, maar geen van de drie adapters kan ze zonder verlies representeren. + */ +const XER_LOSS_CATEGORIES_BY_FORMAT: Readonly, readonly XerExportLossCategory[]>> = { + csv: [ + 'baselines', + 'udfs', + 'activity-codes', + 'notes', + 'raw-curves-and-assignment-quantities', + 'external-links', + 'schedule-options-and-provenance', + ], + mspdi: [ + 'baselines', + 'udfs', + 'activity-codes', + 'notes', + 'raw-curves-and-assignment-quantities', + 'external-links', + 'schedule-options-and-provenance', + ], + p6: [ + 'baselines', + 'udfs', + 'activity-codes', + 'notes', + 'raw-curves-and-assignment-quantities', + 'external-links', + 'schedule-options-and-provenance', + ], +}; + export function xerExportTargetVerdict( format: ExportFormat | 'mpp', ): 'lossless' | 'supported-lossy' | 'unsupported' { @@ -41,18 +76,12 @@ export function detectXerExportLoss( ): readonly XerExportLossWarning[] { if (format === 'ifc' || (!input.hasSourceArchive && !input.hasImportMetadata)) return []; const availability = format === 'mpp' ? 'unsupported' : 'supported-lossy'; - - const categories: XerExportLossCategory[] = []; - if (input.hasSourceArchive) { - categories.push('exact-source-bytes', 'unknown-tables-and-fields', 'typed-diagnostics'); - } - if (input.hasImportMetadata) { - categories.push('project-report', 'external-links', 'p6-provenance'); - } return [{ code: 'XER_ONLY_DATA_NOT_EXPRESSIBLE', format, availability, - categories, + categories: format === 'mpp' + ? [] + : XER_LOSS_CATEGORIES_BY_FORMAT[format], }]; } diff --git a/tests/planning/check-xer-export-loss.ts b/tests/planning/check-xer-export-loss.ts index e6938d493..8de131024 100644 --- a/tests/planning/check-xer-export-loss.ts +++ b/tests/planning/check-xer-export-loss.ts @@ -36,6 +36,7 @@ const { useAppStore } = await import('@/state/appStore'); const store = () => useAppStore.getState(); store().newProject(); store().setProject({ name: 'X9 export-loss', startDate: '2034-01-02' }); +const cleanCsvExport = await store().exportAs('csv'); const view = createEmptyXerArchiveDocumentView('P-X9-LOSS'); const readModel = createEmptyXerArchiveReadModel(); @@ -66,6 +67,24 @@ try { console.warn = originalWarn; } +const expectedXerCategories = [ + 'baselines', + 'udfs', + 'activity-codes', + 'notes', + 'raw-curves-and-assignment-quantities', + 'external-links', + 'schedule-options-and-provenance', +] as const; + +const cleanWarnings = cleanCsvExport.ok + && 'warnings' in cleanCsvExport + && Array.isArray(cleanCsvExport.warnings) + ? cleanCsvExport.warnings + : []; +expect('een schoon project krijgt bij CSV-export geen valse XER-lossmelding', + cleanCsvExport.ok && cleanWarnings.length === 0); + for (const [format, result] of results) { const warnings = result.ok && 'warnings' in result && Array.isArray(result.warnings) ? result.warnings @@ -77,13 +96,11 @@ for (const [format, result] of results) { && warnings.length === 1 && warnings[0]?.code === 'XER_ONLY_DATA_NOT_EXPRESSIBLE' && warnings[0]?.format === format - && warnings[0]?.categories.includes('exact-source-bytes') - && warnings[0]?.categories.includes('typed-diagnostics') - && warnings[0]?.categories.includes('project-report')); + && JSON.stringify(warnings[0]?.categories) === JSON.stringify(expectedXerCategories)); } } expect('de X9-runtimegrens is niet langer console-only', warned.length === 0); -expect('alle vier echte exportpaden schreven daadwerkelijk uitvoer', captures.length === 4 && captures.every(Boolean)); +expect('alle vijf echte exportpaden schreven daadwerkelijk uitvoer', captures.length === 5 && captures.every(Boolean)); expect('MPP heeft geen misleidende exportadapter en een expliciet unsupported-verdict', !EXPORT_FORMATS.some(item => (item.format as string) === 'mpp') && xerExportTargetVerdict('mpp') === 'unsupported'); From d85c0618bb81d8c99d94e621fef6ef2e7b45a32a Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:25:26 +0200 Subject: [PATCH 091/171] Herstel datagedreven XER-exportverlies --- src/services/xerExportLoss.ts | 243 +++++++++++++++++++---- src/state/slices/fileSlice.ts | 12 +- tests/planning/check-xer-export-loss.ts | 245 ++++++++++++++++++------ 3 files changed, 402 insertions(+), 98 deletions(-) diff --git a/src/services/xerExportLoss.ts b/src/services/xerExportLoss.ts index c9ad3dc58..18f12698e 100644 --- a/src/services/xerExportLoss.ts +++ b/src/services/xerExportLoss.ts @@ -1,14 +1,26 @@ 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'; + | 'schedule-options-and-provenance' + | 'p6-relation-lag-degradation'; export interface XerExportLossWarning { readonly code: 'XER_ONLY_DATA_NOT_EXPRESSIBLE'; @@ -18,43 +30,56 @@ export interface XerExportLossWarning { readonly categories: readonly XerExportLossCategory[]; } +/** Alleen de velden die de verliesdetector werkelijk inspecteert; geen tweede AppState-contract. */ export interface XerExportLossInput { - readonly hasSourceArchive: boolean; - readonly hasImportMetadata: boolean; + 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 schrijft uitsluitend de actieve OPS-baseline naar slot 0. */ + readonly activeBaseline: boolean; + /** 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'; } /** - * Deze lijsten zijn bewust per doelformaat vastgelegd. De XER-import levert deze P6-gegevens - * wel aan OPS, maar geen van de drie adapters kan ze zonder verlies representeren. + * 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 XER_LOSS_CATEGORIES_BY_FORMAT: Readonly, readonly XerExportLossCategory[]>> = { - csv: [ - 'baselines', - 'udfs', - 'activity-codes', - 'notes', - 'raw-curves-and-assignment-quantities', - 'external-links', - 'schedule-options-and-provenance', - ], - mspdi: [ - 'baselines', - 'udfs', - 'activity-codes', - 'notes', - 'raw-curves-and-assignment-quantities', - 'external-links', - 'schedule-options-and-provenance', - ], - p6: [ - 'baselines', - 'udfs', - 'activity-codes', - 'notes', - 'raw-curves-and-assignment-quantities', - 'external-links', - 'schedule-options-and-provenance', - ], +const EXPORT_CAPABILITIES: Readonly, ExportCapabilities>> = { + csv: { + activeBaseline: false, + projectedAssignments: false, + percentLag: true, + elapsedLag: true, + schedulingOptions: 'none', + }, + mspdi: { + activeBaseline: true, + projectedAssignments: true, + percentLag: true, + elapsedLag: true, + schedulingOptions: 'critical-slack-limit', + }, + p6: { + activeBaseline: false, + projectedAssignments: true, + percentLag: false, + elapsedLag: false, + schedulingOptions: 'none', + }, }; export function xerExportTargetVerdict( @@ -65,23 +90,157 @@ export function xerExportTargetVerdict( 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) + || Object.values(archive?.diagnostics.documentViews ?? {}).some(view => + view.tableReport.unknownTables.some(table => table.rows > 0)) + || metadata?.tableReport.unknownTables.some(table => table.rows > 0), + ); +} + +function hasBaselineLoss(capabilities: ExportCapabilities, input: XerExportLossInput): boolean { + if (input.baselines.length === 0) return false; + if (!capabilities.activeBaseline) return true; + const activeExists = input.activeBaselineId !== null + && input.baselines.some(baseline => baseline.id === input.activeBaselineId); + if (!activeExists) return true; + return input.baselines.some(baseline => baseline.id !== input.activeBaselineId); +} + +function hasRetainedAssignmentDetails(metadata: XerImportMetadata | null): boolean { + return Boolean(metadata?.resources?.assignments.some(source => + hasObjectValues(source.quantities) + || hasObjectValues(source.rawCurves))); +} + +function hasAssignmentLoss( + format: Exclude, + capabilities: ExportCapabilities, + input: XerExportLossInput, +): boolean { + if (hasRetainedAssignmentDetails(input.importMetadata)) return true; + if (!capabilities.projectedAssignments && input.assignments.length > 0) return true; + // P6 kent geen LATE_PEAK en schrijft die aantoonbaar als Early Peak; de overige live curves + // en units worden door PlannedCurve/PlannedUnitsPerTime gedragen. + return format === 'p6' && input.assignments.some(assignment => assignment.curve === 'LATE_PEAK'); +} + +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(format, 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; X10 bepaalt - * vertaling, dedupe, toast en Lees-meer-link. Daardoor is de exportlaag niet console-only en wordt - * deze worktree tegelijk geen tweede UI-schrijver. + * 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.hasSourceArchive && !input.hasImportMetadata)) return []; - const availability = format === 'mpp' ? 'unsupported' : 'supported-lossy'; + 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, - categories: format === 'mpp' - ? [] - : XER_LOSS_CATEGORIES_BY_FORMAT[format], + availability: format === 'mpp' ? 'unsupported' : 'supported-lossy', + categories, }]; } diff --git a/src/state/slices/fileSlice.ts b/src/state/slices/fileSlice.ts index a4796f0c7..d358bc7d7 100644 --- a/src/state/slices/fileSlice.ts +++ b/src/state/slices/fileSlice.ts @@ -504,8 +504,16 @@ export const createFileSlice: AppSlice = (set, get) => { const state = get(); const warnings = detectXerExportLoss(format, { - hasSourceArchive: state.xerSourceArchive !== null, - hasImportMetadata: state.xerImportMetadata !== null, + 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; diff --git a/tests/planning/check-xer-export-loss.ts b/tests/planning/check-xer-export-loss.ts index 8de131024..0f392883a 100644 --- a/tests/planning/check-xer-export-loss.ts +++ b/tests/planning/check-xer-export-loss.ts @@ -1,11 +1,11 @@ -// X9-R2 — de echte exportAs-route moet XER-only verlies als getypeerd resultaat teruggeven. -// X10 vertaalt en toont dit later; deze headless contracttest schrijft bewust geen UI. +// 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, - bindXerImportMetadataToArchive, } from '@/services/xerSourceArchive'; import type { XerImportMetadata } from '@/services/importTypes'; import { EXPORT_FORMATS } from '@/services/formatRegistry'; @@ -34,40 +34,181 @@ global.document = { const { useAppStore } = await import('@/state/appStore'); const store = () => useAppStore.getState(); -store().newProject(); -store().setProject({ name: 'X9 export-loss', startDate: '2034-01-02' }); -const cleanCsvExport = await store().exportAs('csv'); - -const view = createEmptyXerArchiveDocumentView('P-X9-LOSS'); -const readModel = createEmptyXerArchiveReadModel(); -const diagnostics = { - ...createEmptyXerArchiveDiagnostics(), - documentViews: { 'P-X9-LOSS': view }, -}; -const archive = createXerSourceArchive(new TextEncoder().encode('%T\tUNKNOWN\r\n%F\tx\r\n%R\torigineel\r\n%E'), { - encoding: 'windows-1252', bom: 'none', newline: 'crlf', diagnostics, readModel, -}); -const xer: XerImportMetadata = bindXerImportMetadataToArchive(archive, 'P-X9-LOSS'); -useAppStore.setState({ - xerSourceArchive: archive, - xerImportMetadata: xer, - xerSourceProjectId: 'P-X9-LOSS', - scheduleStale: false, -}); -const warned: string[] = []; -const originalWarn = console.warn; -console.warn = (...values: unknown[]) => warned.push(values.join(' ')); -const results = []; -try { - for (const format of ['csv', 'mspdi', 'p6', 'ifc'] as const) { - results.push([format, await store().exportAs(format)] as const); +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, + }); } -} finally { - console.warn = originalWarn; + 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 installXer(fixture: ReturnType, xer: XerImportMetadata = fixture.xer) { + useAppStore.setState({ + xerSourceArchive: fixture.archive, + xerImportMetadata: xer, + xerSourceProjectId: fixture.sourceProjectId, + scheduleStale: false, + }); } -const expectedXerCategories = [ +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 }); +} + +// 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: alleen data die MSPDI en P6 aantoonbaar projecteren. Eén actieve baseline en +// CriticalSlackLimit zijn MSPDI-lossless; Units/Work/WorkContour en PlannedUnits/PlannedCurve +// voorkomen dat een gewone live assignment als "raw retained" wordt overgewaarschuwd. +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 ondersteunde active baseline/CriticalSlackLimit/assignment niet als verlies', + 'mspdi', capMspdi, []); +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 capP6 = await store().exportAs('p6'); +const capP6Xml = captures.at(-1) ?? ''; +expectCategories('P6 verliest de baseline/schedule-opties maar niet de normale assignmentprojectie', + 'p6', capP6, ['baselines', 'schedule-options-and-provenance']); +expect('P6-writer schreef PlannedUnitsPerTime/PlannedCurve werkelijk', + capP6Xml.includes('0.5') + && capP6Xml.includes('Bell Shaped')); + +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); + +// Positieve fixture: elk signaal is concreet aanwezig. De verwachte arrays zijn onafhankelijk van +// productieconstanten uitgeschreven; formatverschillen komen uit het hierboven bewezen writergedrag. +resetProject('Rijke XER-data'); +const firstTask = store().addTask({ name: 'Bronactiviteit' }); +const secondTask = store().addTask({ name: 'Opvolger' }); +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().addSequence({ + predecessorId: firstTask, successorId: secondTask, type: 'FINISH_START', lagDays: 1, + lagPercent: 25, lagUnit: 'ELAPSEDTIME', +}); +const richResource = store().addResource({ name: 'Kraan', type: 'EQUIPMENT', description: '', maxUnits: 1 }); +store().assignResource(firstTask, richResource, 0.75, 'LATE_PEAK'); +store().saveBaseline('Eerste'); +store().saveBaseline('Tweede actief'); +store().setProject({ + progressMode: 'PROGRESS_OVERRIDE', + schedulingOptions: { p6Source: 'XER', lagCalendar: '24hour', makeOpenEndedCritical: true }, +}); + +const richFixture = makeXerFixture({ + sourceBytes: '%T\tUNKNOWN_RETAINED\r\n%F\tx\r\n%R\torigineel\r\n%E', + unknownTable: true, + typedDiagnostic: true, +}); +const retainedAssignment = { + rawRow: { line: 12, cells: { target_qty: '40', remain_qty: '12', target_crv: 'CURVE-X' } }, + sourceId: 'A-1', internalId: 'A-1', taskSourceId: 'T-1', projectSourceId: richFixture.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: { target: 'CURVE-X' }, + costs: {}, +}; +const richXer: XerImportMetadata = { + ...richFixture.xer, + resources: { + catalog: richFixture.archive.readModel.resourceCatalog, + assignments: [retainedAssignment], + issues: [], + }, +}; +installXer(richFixture, richXer); + +const sharedRich = [ + 'exact-source-bytes', + 'unknown-tables-and-fields', + 'typed-diagnostics', 'baselines', 'udfs', 'activity-codes', @@ -77,31 +218,27 @@ const expectedXerCategories = [ 'schedule-options-and-provenance', ] as const; -const cleanWarnings = cleanCsvExport.ok - && 'warnings' in cleanCsvExport - && Array.isArray(cleanCsvExport.warnings) - ? cleanCsvExport.warnings - : []; -expect('een schoon project krijgt bij CSV-export geen valse XER-lossmelding', - cleanCsvExport.ok && cleanWarnings.length === 0); - -for (const [format, result] of results) { - const warnings = result.ok && 'warnings' in result && Array.isArray(result.warnings) - ? result.warnings - : []; +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 de lossless export en levert geen XER-lossmelding', result.ok && warnings.length === 0); + expect('IFC blijft buiten de lossy-funnel', result.ok && categoriesOf(result).length === 0); + } else if (format === 'p6') { + expectCategories('P6 meldt retained/live verlies plus relation-lag-degradatie in de return-envelope', + format, result, [...sharedRich, 'p6-relation-lag-degradation']); } else { - expect(`${format} levert één gericht getypeerd XER-lossresultaat`, result.ok - && warnings.length === 1 - && warnings[0]?.code === 'XER_ONLY_DATA_NOT_EXPRESSIBLE' - && warnings[0]?.format === format - && JSON.stringify(warnings[0]?.categories) === JSON.stringify(expectedXerCategories)); + expectCategories(`${format} meldt alleen werkelijk aanwezige retained/live categorieën in de return-envelope`, + format, result, sharedRich); } } -expect('de X9-runtimegrens is niet langer console-only', warned.length === 0); -expect('alle vijf echte exportpaden schreven daadwerkelijk uitvoer', captures.length === 5 && captures.every(Boolean)); -expect('MPP heeft geen misleidende exportadapter en een expliciet unsupported-verdict', + +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)); +expect('alle twaalf echte exportpaden schreven daadwerkelijk uitvoer', captures.length === 12 && 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'); From 00e2561bea67812ff29d3783f98809bd4d1d2485 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:14:03 +0200 Subject: [PATCH 092/171] Herstel XER-verliesdetectie en exportmelding --- src/i18n/locales/ar/common.json | 1 + src/i18n/locales/de/common.json | 1 + src/i18n/locales/en/common.json | 1 + src/i18n/locales/es/common.json | 1 + src/i18n/locales/fa/common.json | 1 + src/i18n/locales/fr/common.json | 1 + src/i18n/locales/it/common.json | 1 + src/i18n/locales/ja/common.json | 1 + src/i18n/locales/ko/common.json | 1 + src/i18n/locales/nl/common.json | 1 + src/i18n/locales/pl/common.json | 1 + src/i18n/locales/pt/common.json | 1 + src/i18n/locales/tr/common.json | 1 + src/i18n/locales/zh/common.json | 1 + src/services/importTypes.ts | 2 + src/services/xer/xerTables.ts | 143 ++++++++++- src/services/xerExportLoss.ts | 29 ++- src/services/xerSourceArchive.ts | 20 +- src/state/slices/fileSlice.ts | 14 +- src/state/slices/types.ts | 1 + tests/planning/check-mspdi-baseline-export.ts | 50 +++- tests/planning/check-xer-export-loss.ts | 236 ++++++++++++++---- tests/planning/check-xer-multi-project.ts | 4 +- tests/planning/check-xer-project-index.ts | 4 +- tests/planning/check-xer-tables.ts | 45 ++++ 25 files changed, 485 insertions(+), 77 deletions(-) diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index 7165dfab0..daa7a1bf9 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -94,6 +94,7 @@ "xerImportUnsupportedSemantics_few": "{{count}} إعدادات تخطيط P6 استخدمت بدائل آمنة.", "xerImportUnsupportedSemantics_many": "{{count}} إعداد تخطيط P6 استخدم بدائل آمنة.", "xerImportUnsupportedSemantics_other": "{{count}} إعداد تخطيط P6 استخدم بدائل آمنة.", + "xerExportLoss": "يؤدي التصدير إلى {{format}} إلى فقدان معلومات مصدر XER.", "mppLegacy": "يستخدم ملف .mpp هذا تنسيقًا قديمًا (Project 2007 أو أقدم). صدّره من MS Project بصيغة XML (ملف ← حفظ باسم ← XML) ثم افتح ذلك الملف.", "mppEncrypted": "ملف .mpp هذا محمي بكلمة مرور. صدّره من MS Project بصيغة XML (ملف ← حفظ باسم ← XML) ثم افتح ذلك الملف.", "saveFailed": "تعذّر الحفظ", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 1651ce9c1..f8e2a7cf2 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -46,6 +46,7 @@ "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.", + "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", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 356ff75e0..56ade7b9a 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -46,6 +46,7 @@ "xerImportEnumFallbacks_other": "{{count}} enum fallbacks.", "xerImportUnsupportedSemantics_one": "{{count}} P6 scheduling setting used a safe fallback.", "xerImportUnsupportedSemantics_other": "{{count}} P6 scheduling settings used safe fallbacks.", + "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", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 72bdf276a..f6cc01537 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -58,6 +58,7 @@ "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.", + "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", diff --git a/src/i18n/locales/fa/common.json b/src/i18n/locales/fa/common.json index 8b7a62c88..597354976 100644 --- a/src/i18n/locales/fa/common.json +++ b/src/i18n/locales/fa/common.json @@ -46,6 +46,7 @@ "xerImportEnumFallbacks_other": "{{count}} بازگشت شمارشی.", "xerImportUnsupportedSemantics_one": "{{count}} تنظیم زمان‌بندی P6 از جایگزین امن استفاده کرد.", "xerImportUnsupportedSemantics_other": "{{count}} تنظیم زمان‌بندی P6 از جایگزین‌های امن استفاده کردند.", + "xerExportLoss": "خروجی به {{format}} باعث از دست رفتن اطلاعات منبع XER می‌شود.", "mppLegacy": "این فایل .mpp از قالبی قدیمی (Project 2007 یا قدیمی‌تر) استفاده می‌کند. آن را در MS Project به‌صورت XML صادر کنید (فایل ← ذخیره به‌عنوان ← XML) و آن فایل را باز کنید.", "mppEncrypted": "این فایل .mpp با رمز عبور محافظت شده است. آن را در MS Project به‌صورت XML صادر کنید (فایل ← ذخیره به‌عنوان ← XML) و آن فایل را باز کنید.", "saveFailed": "ذخیره‌سازی ناموفق بود", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index ea3c8a831..42051f6c1 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -58,6 +58,7 @@ "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.", + "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", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 0d963e0c9..b616222fb 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -58,6 +58,7 @@ "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.", + "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", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index dc6a037d0..ebe86203a 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -34,6 +34,7 @@ "xerImportNumberIssues_other": "数値形式の問題: {{count}}", "xerImportEnumFallbacks_other": "列挙値のフォールバック: {{count}}", "xerImportUnsupportedSemantics_other": "安全なフォールバックを使った P6 計画設定: {{count}}", + "xerExportLoss": "{{format}} へのエクスポートでは XER のソース情報が失われます。", "mppLegacy": "この .mpp ファイルは古い形式(Project 2007 以前)を使用しています。MS Project で XML としてエクスポートし(ファイル → 名前を付けて保存 → XML)、そのファイルを開いてください。", "mppEncrypted": "この .mpp ファイルはパスワードで保護されています。MS Project で XML としてエクスポートし(ファイル → 名前を付けて保存 → XML)、そのファイルを開いてください。", "saveFailed": "保存できませんでした", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index ba041f7fe..d95da5a98 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -34,6 +34,7 @@ "xerImportNumberIssues_other": "숫자 형식 문제: {{count}}", "xerImportEnumFallbacks_other": "열거형 대체: {{count}}", "xerImportUnsupportedSemantics_other": "안전한 대체를 사용한 P6 일정 설정: {{count}}", + "xerExportLoss": "{{format}} 형식으로 내보내면 XER 원본 정보가 손실됩니다.", "mppLegacy": "이 .mpp 파일은 이전 형식(Project 2007 이하)을 사용합니다. MS Project에서 XML로 내보낸 후(파일 → 다른 이름으로 저장 → XML) 해당 파일을 여세요.", "mppEncrypted": "이 .mpp 파일은 암호로 보호되어 있습니다. MS Project에서 XML로 내보낸 후(파일 → 다른 이름으로 저장 → XML) 해당 파일을 여세요.", "saveFailed": "저장하지 못했습니다", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index ef9775c4b..db9dbf56c 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -46,6 +46,7 @@ "xerImportEnumFallbacks_other": "{{count}} enum-terugvallen.", "xerImportUnsupportedSemantics_one": "{{count}} P6-planningsinstelling met veilige terugval.", "xerImportUnsupportedSemantics_other": "{{count}} P6-planningsinstellingen met veilige terugval.", + "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", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 1a69f03d4..a1efbb865 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -70,6 +70,7 @@ "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.", + "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ć", diff --git a/src/i18n/locales/pt/common.json b/src/i18n/locales/pt/common.json index 7d34a44e2..c23e7d3ee 100644 --- a/src/i18n/locales/pt/common.json +++ b/src/i18n/locales/pt/common.json @@ -58,6 +58,7 @@ "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.", + "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", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 0994451f5..4adcab95d 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -46,6 +46,7 @@ "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ı.", + "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", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 9f2ba7537..f5d1113d1 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -34,6 +34,7 @@ "xerImportNumberIssues_other": "数字格式问题:{{count}}", "xerImportEnumFallbacks_other": "枚举回退:{{count}}", "xerImportUnsupportedSemantics_other": "使用安全回退的 P6 排程设置:{{count}}", + "xerExportLoss": "导出为 {{format}} 会丢失 XER 源信息。", "mppLegacy": "此 .mpp 文件使用较旧的格式(Project 2007 或更早版本)。请在 MS Project 中将其导出为 XML(文件 → 另存为 → XML),然后打开该文件。", "mppEncrypted": "此 .mpp 文件受密码保护。请在 MS Project 中将其导出为 XML(文件 → 另存为 → XML),然后打开该文件。", "saveFailed": "保存失败", diff --git a/src/services/importTypes.ts b/src/services/importTypes.ts index 6d7e2fc04..2f4375f50 100644 --- a/src/services/importTypes.ts +++ b/src/services/importTypes.ts @@ -29,6 +29,8 @@ export interface XerTableReportMetadata { 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 { diff --git a/src/services/xer/xerTables.ts b/src/services/xer/xerTables.ts index e1c535b87..236641a86 100644 --- a/src/services/xer/xerTables.ts +++ b/src/services/xer/xerTables.ts @@ -120,6 +120,7 @@ export interface XerImportReport { endMarkerSeen: boolean; issues: XerImportIssue[]; unknownTables: Array<{ name: string; rows: number }>; + unknownFields: Array<{ table: string; name: string; rows: number }>; } export interface XerTables { @@ -162,16 +163,120 @@ function decodeTextCell(table: string, field: string, raw: string): string { return P6_NOTE_FIELDS.has(`${table}.${field}`) ? decodeXerNoteText(unquoted) : unquoted; } -// Tabelset waarvoor de latere X3-X8-lagen data nodig kunnen hebben. De selectie volgt voor begrip -// MPXJ's `PrimaveraXERFileReader.READ_REQUIRED_TABLES`; de opslag- en rapportcode hieronder is eigen. -const READ_TABLES = new Set([ - 'PROJECT', 'CALENDAR', 'RSRC', 'RSRCRATE', 'PROJWBS', 'TASK', 'TASKPRED', 'TASKRSRC', - 'CURRTYPE', 'UDFTYPE', 'UDFVALUE', 'SCHEDOPTIONS', 'ACTVTYPE', 'ACTVCODE', 'TASKACTV', - 'COSTTYPE', 'ACCOUNT', 'PROJCOST', 'MEMOTYPE', 'WBSMEMO', 'TASKNOTE', 'TASKMEMO', 'ROLES', 'ROLERATE', - 'RSRCCURVDATA', 'TASKPROC', 'LOCATION', 'UMEASURE', 'SHIFT', 'SHIFTPER', 'RSRCROLE', - 'PCATTYPE', 'PCATVAL', 'PROJPCAT', 'RCATTYPE', 'RCATVAL', 'RSRCRCAT', 'ROLECATTYPE', - 'ROLECATVAL', 'ROLERCAT', 'ASGNMNTCATTYPE', 'ASGNMNTCATVAL', 'ASGNMNTACAT', -]); +/** + * 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, @@ -452,6 +557,22 @@ function findProvableCommaDecimal( 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()) { @@ -605,6 +726,7 @@ export function parseXerTables(bytes: XerByteInput): XerTables { ); } + const unknownFields = collectUnknownFields(immutableTables); freezeXerRows(immutableTables); return { header, @@ -618,6 +740,7 @@ export function parseXerTables(bytes: XerByteInput): XerTables { { code: 'XER_MISSING_END_MARKER', line: lines.length }, ], unknownTables, + unknownFields, }, }; } diff --git a/src/services/xerExportLoss.ts b/src/services/xerExportLoss.ts index 18f12698e..8cfe7a473 100644 --- a/src/services/xerExportLoss.ts +++ b/src/services/xerExportLoss.ts @@ -45,8 +45,8 @@ export interface XerExportLossInput { } interface ExportCapabilities { - /** MSPDI schrijft uitsluitend de actieve OPS-baseline naar slot 0. */ - readonly activeBaseline: boolean; + /** 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; @@ -60,21 +60,21 @@ interface ExportCapabilities { */ const EXPORT_CAPABILITIES: Readonly, ExportCapabilities>> = { csv: { - activeBaseline: false, + baselineProjection: 'none', projectedAssignments: false, percentLag: true, elapsedLag: true, schedulingOptions: 'none', }, mspdi: { - activeBaseline: true, + baselineProjection: 'active-task-values', projectedAssignments: true, percentLag: true, elapsedLag: true, schedulingOptions: 'critical-slack-limit', }, p6: { - activeBaseline: false, + baselineProjection: 'none', projectedAssignments: true, percentLag: false, elapsedLag: false, @@ -125,19 +125,24 @@ function hasTypedDiagnostics(archive: XerSourceArchive | null, metadata: XerImpo 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)) - || metadata?.tableReport.unknownTables.some(table => table.rows > 0), + 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; - if (!capabilities.activeBaseline) return true; - const activeExists = input.activeBaselineId !== null - && input.baselines.some(baseline => baseline.id === input.activeBaselineId); - if (!activeExists) return true; - return input.baselines.some(baseline => baseline.id !== input.activeBaselineId); + // 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 { diff --git a/src/services/xerSourceArchive.ts b/src/services/xerSourceArchive.ts index aa6c3ff27..27210da14 100644 --- a/src/services/xerSourceArchive.ts +++ b/src/services/xerSourceArchive.ts @@ -207,7 +207,9 @@ export function createEmptyXerArchiveDiagnostics(): XerArchiveDiagnosticsV1 { return { schemaVersion: 1, file: { - tableReport: { encoding: 'utf-8', endMarkerSeen: true, issues: [], unknownTables: [] }, + tableReport: { + encoding: 'utf-8', endMarkerSeen: true, issues: [], unknownTables: [], unknownFields: [], + }, scheduleOptions: [], relationResolutionIssues: [], resourceCatalogIssues: [], @@ -232,7 +234,9 @@ export function createEmptyXerArchiveDocumentView(sourceProjectId: string): XerA return { sourceProjectId, defaultCurrencyCode: '', - tableReport: { encoding: 'utf-8', endMarkerSeen: true, issues: [], unknownTables: [] }, + tableReport: { + encoding: 'utf-8', endMarkerSeen: true, issues: [], unknownTables: [], unknownFields: [], + }, calendarIssues: [], enumFallbacks: [], scheduleOptions: { @@ -609,7 +613,7 @@ function validateSourceRow(value: unknown, path: string): void { function validateTableReport(value: unknown, path: string): void { const report = objectOf(value, path); - exactKeys(report, ['encoding', 'endMarkerSeen', 'issues', 'unknownTables'], 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()) { @@ -630,6 +634,16 @@ function validateTableReport(value: unknown, path: string): void { 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 = [ diff --git a/src/state/slices/fileSlice.ts b/src/state/slices/fileSlice.ts index d358bc7d7..04468c842 100644 --- a/src/state/slices/fileSlice.ts +++ b/src/state/slices/fileSlice.ts @@ -558,8 +558,20 @@ export const createFileSlice: AppSlice = (set, get) => { } 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); + 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 }; }, diff --git a/src/state/slices/types.ts b/src/state/slices/types.ts index 9caac7838..f15c62378 100644 --- a/src/state/slices/types.ts +++ b/src/state/slices/types.ts @@ -202,6 +202,7 @@ export type NotificationMessageKey = | 'notifications.xerImportNumberIssues' | 'notifications.xerImportEnumFallbacks' | 'notifications.xerImportUnsupportedSemantics' + | 'notifications.xerExportLoss' | 'notifications.mppSourceScheduleNotes' | 'notifications.projectStartAnchorsClamped' | 'notifications.mppTimephasedSteeringLost'; 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-xer-export-loss.ts b/tests/planning/check-xer-export-loss.ts index 0f392883a..23b0e55f1 100644 --- a/tests/planning/check-xer-export-loss.ts +++ b/tests/planning/check-xer-export-loss.ts @@ -10,6 +10,8 @@ import { import type { XerImportMetadata } from '@/services/importTypes'; import { EXPORT_FORMATS } from '@/services/formatRegistry'; import { xerExportTargetVerdict } from '@/services/xerExportLoss'; +import { parseXerTables } from '@/services/xer/xerTables'; +import { readFileSync } from 'node:fs'; declare const process: { exit(code: number): never }; const failures: string[] = []; @@ -66,6 +68,26 @@ function makeXerFixture(options: { }; } +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, @@ -98,6 +120,25 @@ function resetProject(name: string) { 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'); @@ -114,9 +155,9 @@ for (const format of ['csv', 'mspdi', 'p6'] as const) { const emptyIfc = await store().exportAs('ifc'); expect('IFC blijft ook voor een lege XER-binding lossless', emptyIfc.ok && categoriesOf(emptyIfc).length === 0); -// Capabilityfixture: alleen data die MSPDI en P6 aantoonbaar projecteren. Eén actieve baseline en -// CriticalSlackLimit zijn MSPDI-lossless; Units/Work/WorkContour en PlannedUnits/PlannedCurve -// voorkomen dat een gewone live assignment als "raw retained" wordt overgewaarschuwd. +// 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 }); @@ -127,8 +168,8 @@ installXer(makeXerFixture()); const capMspdi = await store().exportAs('mspdi'); const capMspdiXml = captures.at(-1) ?? ''; -expectCategories('MSPDI meldt ondersteunde active baseline/CriticalSlackLimit/assignment niet als verlies', - 'mspdi', capMspdi, []); +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')); @@ -137,25 +178,39 @@ expect('MSPDI-writer schreef Units/Work/WorkContour werkelijk', && capMspdiXml.includes('') && capMspdiXml.includes('6')); -const capP6 = await store().exportAs('p6'); -const capP6Xml = captures.at(-1) ?? ''; -expectCategories('P6 verliest de baseline/schedule-opties maar niet de normale assignmentprojectie', - 'p6', capP6, ['baselines', 'schedule-options-and-provenance']); -expect('P6-writer schreef PlannedUnitsPerTime/PlannedCurve werkelijk', - capP6Xml.includes('0.5') - && capP6Xml.includes('Bell Shaped')); - 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); -// Positieve fixture: elk signaal is concreet aanwezig. De verwachte arrays zijn onafhankelijk van -// productieconstanten uitgeschreven; formatverschillen komen uit het hierboven bewezen writergedrag. +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('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 secondTask = store().addTask({ name: 'Opvolger' }); const codeTypeId = store().addActivityCodeType('Discipline'); const codeValueId = store().addActivityCodeValue(codeTypeId, { code: 'B', description: 'Bouw' }); store().setTaskActivityCode(firstTask, codeTypeId, codeValueId); @@ -168,12 +223,6 @@ store().updateTask(firstTask, { sourceRef: { projectId: 'P-OTHER', taskId: 'T-OTHER' }, sourceMissing: false, }], }); -store().addSequence({ - predecessorId: firstTask, successorId: secondTask, type: 'FINISH_START', lagDays: 1, - lagPercent: 25, lagUnit: 'ELAPSEDTIME', -}); -const richResource = store().addResource({ name: 'Kraan', type: 'EQUIPMENT', description: '', maxUnits: 1 }); -store().assignResource(firstTask, richResource, 0.75, 'LATE_PEAK'); store().saveBaseline('Eerste'); store().saveBaseline('Tweede actief'); store().setProject({ @@ -182,38 +231,18 @@ store().setProject({ }); const richFixture = makeXerFixture({ - sourceBytes: '%T\tUNKNOWN_RETAINED\r\n%F\tx\r\n%R\torigineel\r\n%E', - unknownTable: true, + sourceBytes: '%T\tTASK\r\n%F\ttask_id\r\n%R\torigineel\r\n%E', typedDiagnostic: true, }); -const retainedAssignment = { - rawRow: { line: 12, cells: { target_qty: '40', remain_qty: '12', target_crv: 'CURVE-X' } }, - sourceId: 'A-1', internalId: 'A-1', taskSourceId: 'T-1', projectSourceId: richFixture.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: { target: 'CURVE-X' }, - costs: {}, -}; -const richXer: XerImportMetadata = { - ...richFixture.xer, - resources: { - catalog: richFixture.archive.readModel.resourceCatalog, - assignments: [retainedAssignment], - issues: [], - }, -}; -installXer(richFixture, richXer); +installXer(richFixture); const sharedRich = [ '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', ] as const; @@ -225,9 +254,6 @@ for (const format of ['csv', 'mspdi', 'p6', 'ifc'] 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 if (format === 'p6') { - expectCategories('P6 meldt retained/live verlies plus relation-lag-degradatie in de return-envelope', - format, result, [...sharedRich, 'p6-relation-lag-degradation']); } else { expectCategories(`${format} meldt alleen werkelijk aanwezige retained/live categorieën in de return-envelope`, format, result, sharedRich); @@ -237,7 +263,121 @@ for (const [format, result] of richResults) { 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)); -expect('alle twaalf echte exportpaden schreven daadwerkelijk uitvoer', captures.length === 12 && captures.every(Boolean)); + +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; +} + +installAssignmentFixture('Alleen LATE_PEAK', 'LATE_PEAK'); +const latePeakOnly = await store().exportAs('p6'); +expectCategories('P6-LATE_PEAK zonder retained quantities activeert zelfstandig assignmentverlies', + 'p6', latePeakOnly, ['raw-curves-and-assignment-quantities']); + +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']); + +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, []); +expect('P6-writer schreef voor die negatieve fixture PlannedUnitsPerTime/PlannedCurve werkelijk', + supportedCurveXml.includes('0.5') + && supportedCurveXml.includes('Bell Shaped')); + +// 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'); diff --git a/tests/planning/check-xer-multi-project.ts b/tests/planning/check-xer-multi-project.ts index d9afb2a61..a5acc8fb0 100644 --- a/tests/planning/check-xer-multi-project.ts +++ b/tests/planning/check-xer-multi-project.ts @@ -51,7 +51,9 @@ function tables(projectRows: XerRow[], taskRows: XerRow[]): XerTables { ['PROJECT', table('PROJECT', projectRows)], ['TASK', table('TASK', taskRows)], ]), - report: { encoding: 'utf-8', endMarkerSeen: true, issues: [], unknownTables: [] }, + report: { + encoding: 'utf-8', endMarkerSeen: true, issues: [], unknownTables: [], unknownFields: [], + }, numberFormat: { decimal: '.', group: null, source: 'default', currencyCode: 'EUR' }, }; } diff --git a/tests/planning/check-xer-project-index.ts b/tests/planning/check-xer-project-index.ts index 046106b3f..e76b4d8ae 100644 --- a/tests/planning/check-xer-project-index.ts +++ b/tests/planning/check-xer-project-index.ts @@ -32,7 +32,9 @@ const tables: XerTables = { ['PROJWBS', { name: 'PROJWBS', fields: [], rows: wbsRows }], ['TASKPRED', { name: 'TASKPRED', fields: [], rows: relationRows }], ]), - report: { encoding: 'utf-8', endMarkerSeen: true, issues: [], unknownTables: [] }, + report: { + encoding: 'utf-8', endMarkerSeen: true, issues: [], unknownTables: [], unknownFields: [], + }, numberFormat: { decimal: '.', group: null, source: 'default', currencyCode: 'EUR' }, }; const index = indexXerProjectRows(tables); diff --git a/tests/planning/check-xer-tables.ts b/tests/planning/check-xer-tables.ts index ffefcdea1..87d13665b 100644 --- a/tests/planning/check-xer-tables.ts +++ b/tests/planning/check-xer-tables.ts @@ -1076,6 +1076,51 @@ eq('29 komma-decimaalfout draagt het eerste concrete bewijs zonder brondata te l }, }); +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 { From f366d2e890f2c42dcef583ba19bc784254b4d6f4 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:08:33 +0200 Subject: [PATCH 093/171] test(browser): voeg fase-1 XER X11-harnas toe --- package.json | 1 + tests/browser/README.md | 67 ++++++ tests/browser/helpers/ops-state.mjs | 51 +++++ tests/browser/x11-xer-evidence.mjs | 317 ++++++++++++++++++++++++++++ 4 files changed, 436 insertions(+) create mode 100644 tests/browser/README.md create mode 100644 tests/browser/helpers/ops-state.mjs create mode 100644 tests/browser/x11-xer-evidence.mjs diff --git a/package.json b/package.json index fd3ed731f..a6f4f2505 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "test:library": "bash tests/library/run.sh", "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: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:cycles && npm run verify:audit", "preview": "vite preview", "tauri": "tauri", diff --git a/tests/browser/README.md b/tests/browser/README.md new file mode 100644 index 000000000..344ba505d --- /dev/null +++ b/tests/browser/README.md @@ -0,0 +1,67 @@ +# X11/XWayland-browserharnas voor XER + +Dit harnas voert de fase-1-gebruikstest 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 run gebruikt daaruit + `crawl-xer/p6diff-baseline.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 +``` + +Het harnas start zelf `npm run dev`, leest de werkelijk toegewezen URL en PID uit de launcher- +output, controleert HTTP 200 en controleert `/proc/PID/cwd` tegen de worktree-root. 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(...)`. Directe pad-openhaken van de +dev-bridge horen niet bij deze browserroute. + +## 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. + +## 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 screenshot, de +geobserveerde state en de dev-server-output. Canvas, documentnaam en tablabels worden in de +screenshot gemaskeerd; bron-, project-, taak- en resourcenamen worden niet als screenshotbewijs +gebruikt. + +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. diff --git a/tests/browser/helpers/ops-state.mjs b/tests/browser/helpers/ops-state.mjs new file mode 100644 index 000000000..029eb468c --- /dev/null +++ b/tests/browser/helpers/ops-state.mjs @@ -0,0 +1,51 @@ +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 xerNotification = state.ui.notifications.find((item) => item.messageKey === xerMessageKey); + const importedTasks = Array.isArray(state.tasks) + ? state.tasks.filter((task) => task.isSummary !== true && (task.childIds?.length ?? 0) === 0) + : []; + return { + documents: Array.isArray(state.documents) ? state.documents.length : -1, + // 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: importedTasks.length, + relations: Array.isArray(state.sequences) ? state.sequences.length : -1, + cpm: Boolean(state.cpmResult), + xerSourceArchive: Boolean(state.xerSourceArchive), + xerSourceProjectId: typeof state.xerSourceProjectId === 'string' ? state.xerSourceProjectId : null, + xerNotification: xerNotification ? { + messageKey: xerNotification.messageKey, + helpArticleId: xerNotification.helpArticleId ?? null, + } : null, + notifications: state.ui.notifications.map((item) => ({ + severity: item.severity, + messageKey: item.messageKey, + helpArticleId: item.helpArticleId ?? null, + })), + }; + }, XER_OPENED_MESSAGE); +} + +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; diff --git a/tests/browser/x11-xer-evidence.mjs b/tests/browser/x11-xer-evidence.mjs new file mode 100644 index 000000000..77d7163a4 --- /dev/null +++ b/tests/browser/x11-xer-evidence.mjs @@ -0,0 +1,317 @@ +import { access, lstat, mkdir, readFile, readlink, symlink, unlink, writeFile } from 'node:fs/promises'; +import { constants as fsConstants } from 'node:fs'; +import { 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 { readOpsState, assertSmallAState, 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 EVIDENCE_ROOT = '/tmp/xer-x11-evidence'; + +function runId() { + return `${new Date().toISOString().replace(/[^0-9TZ]/g, '')}-${process.pid}`; +} + +function isExplicitCiMode() { + return process.env.OPS_XER_X11_CI === '1'; +} + +function fail(message) { + throw new Error(message); +} + +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 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.log('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, DEFAULT_CORPUS_FILE), '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, DEFAULT_CORPUS_FILE), + chromiumPath, + display: display || null, + waylandSocket: existsSync(waylandSocket) ? waylandSocket : null, + }; +} + +async function waitForDevServer(child) { + let output = `[harness] SERVER_PID=${child.pid}\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}`); + } + + return { assignedUrl, pid: child.pid, procCwd, output }; +} + +async function startDevServer() { + const npmCommand = process.env.OPS_NPM_PATH?.trim() || 'npm'; + const child = spawn(npmCommand, ['run', 'dev'], { + cwd: REPO_ROOT, + detached: true, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + try { + const server = await waitForDevServer(child); + return { child, server }; + } catch (error) { + await stopDevServer(child); + throw error; + } +} + +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(''), + ]; + if (forbidden.some((token) => source.includes(token))) { + fail('statische guard: direct pad-openen is verboden in het X11-harnas'); + } +} + +async function runBrowserSmallA(preflightResult, server, evidenceDir) { + 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-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 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(preflightResult.xerPath); + + try { + await page.waitForFunction((messageKey) => { + const bridge = window.__OPS__; + const state = bridge?.store.getState(); + const notification = state?.ui.notifications.find((item) => item.messageKey === messageKey); + const importedTasks = state?.tasks?.filter((task) => + task.isSummary !== true && (task.childIds?.length ?? 0) === 0, + ); + return Boolean( + state && state.documents?.length === 1 && importedTasks?.length === 8 && + state.sequences?.length === 7 && state.cpmResult && state.xerSourceArchive && + 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 toast = page.locator('.ops-toast').filter({ hasText: 'XER file opened' }); + await toast.first().waitFor({ state: 'visible', timeout: 5_000 }); + const domText = await toast.first().innerText(); + const state = await readOpsState(page); + assertSmallAState(state); + + 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 screenshotPath = resolve(evidenceDir, 'imported-redacted.png'); + await page.screenshot({ + path: screenshotPath, + fullPage: false, + mask: [ + page.locator('canvas'), + page.locator('.title-bar-file-name'), + page.locator('[role="tab"]'), + ], + maskColor: '#263238', + }); + return { state, domText, dialogCounts, screenshotPath, 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 preflightResult = await preflight(); + if (!preflightResult) return; + await assertNoDirectPathOpen(); + const evidenceDir = resolve(EVIDENCE_ROOT, runId()); + await mkdir(evidenceDir, { recursive: true }); + const dependencies = await ensureDependencies(); + let serverHandle = null; + try { + serverHandle = await startDevServer(); + const browserEvidence = await runBrowserSmallA(preflightResult, serverHandle.server, evidenceDir); + await writeFile(resolve(evidenceDir, 'state.json'), `${JSON.stringify(browserEvidence.state, null, 2)}\n`, 'utf8'); + await writeFile(resolve(evidenceDir, 'server-output.txt'), serverHandle.server.output, 'utf8'); + await writeFile(resolve(evidenceDir, 'metadata.json'), `${JSON.stringify({ + runId: evidenceDir.split('/').pop(), + repository: REPO_ROOT, + branch: 'codex/xer-x11-harness', + server: { url: serverHandle.server.assignedUrl, pid: serverHandle.server.pid, cwd: serverHandle.server.procCwd, httpStatus: 200 }, + browser: { executablePath: preflightResult.chromiumPath, headless: browserEvidence.headless, launchArgs: browserEvidence.launchArgs }, + dom: { text: browserEvidence.domText }, + nativeDialogs: browserEvidence.dialogCounts, + screenshot: 'imported-redacted.png', + }, null, 2)}\n`, 'utf8'); + console.log(`SMALL-A OK: zichtbare importasserties geslaagd; evidence=${evidenceDir}`); + } finally { + if (serverHandle) await stopDevServer(serverHandle.child); + await removeTemporaryDependencies(dependencies.linkPath); + } +} + +main().catch((error) => { + console.error(`X11-harnas rood: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; +}); From 25580657136a16b32a70a39ed419e9a14c5e370f Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:30:24 +0200 Subject: [PATCH 094/171] test(browser): versterk XER X11-bewijs --- tests/browser/README.md | 22 +++-- tests/browser/x11-xer-evidence.mjs | 131 +++++++++++++++++++++++++++-- 2 files changed, 137 insertions(+), 16 deletions(-) diff --git a/tests/browser/README.md b/tests/browser/README.md index 344ba505d..242670124 100644 --- a/tests/browser/README.md +++ b/tests/browser/README.md @@ -31,14 +31,19 @@ OPS_PLAYWRIGHT_DIR="/pad/naar/node_modules/playwright-core" \ npm run test:browser:x11 ``` -Het harnas start zelf `npm run dev`, leest de werkelijk toegewezen URL en PID uit de launcher- -output, controleert HTTP 200 en controleert `/proc/PID/cwd` tegen de worktree-root. De server +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(...)`. Directe pad-openhaken van de -dev-bridge horen niet bij deze browserroute. +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 @@ -57,10 +62,11 @@ Daarnaast moeten `window.alert`, `window.confirm` en `window.prompt` nul keer zi ## 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 screenshot, de -geobserveerde state en de dev-server-output. Canvas, documentnaam en tablabels worden in de -screenshot gemaskeerd; bron-, project-, taak- en resourcenamen worden niet als screenshotbewijs -gebruikt. +`/tmp/xer-x11-evidence/`. Daarin staan metadata, de privacy-geredigeerde overzichtsscreenshot, +een afzonderlijke elementopname van de zichtbare XER-toast, de geobserveerde state en de +dev-server-output. De toastopname wordt vóór auto-dismiss gemaakt en accepteert alleen generieke +importaantallen plus `Read more`; pad-, bestands-, project-, taak- en resourcenamen zijn er niet +toegestaan. Canvas, documentnaam en tablabels worden in de overzichtsscreenshot gemaskeerd. 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`. diff --git a/tests/browser/x11-xer-evidence.mjs b/tests/browser/x11-xer-evidence.mjs index 77d7163a4..9a79b772f 100644 --- a/tests/browser/x11-xer-evidence.mjs +++ b/tests/browser/x11-xer-evidence.mjs @@ -66,7 +66,7 @@ async function preflight() { } async function waitForDevServer(child) { - let output = `[harness] SERVER_PID=${child.pid}\n`; + 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) => { @@ -102,15 +102,45 @@ async function waitForDevServer(child) { fail(`servercwd is ${procCwd}, verwacht exact ${REPO_ROOT}`); } - return { assignedUrl, pid: child.pid, procCwd, output }; + 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: process.env, + env: childEnv, stdio: ['ignore', 'pipe', 'pipe'], }); try { @@ -176,6 +206,59 @@ async function assertNoDirectPathOpen() { } } +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 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] }; + }); +} + +async function readDevBridgeOpenGate(page) { + return page.evaluate(() => ({ + calls: [...(window.__OPS_X11_BRIDGE_GATE__?.calls ?? [])], + wrapped: [...(window.__OPS_X11_BRIDGE_GATE__?.wrapped ?? [])], + })); +} + +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)}`); + } +} + async function runBrowserSmallA(preflightResult, server, evidenceDir) { const { chromium } = loadPlaywrightCore(); const browserEnv = { ...process.env }; @@ -213,6 +296,7 @@ async function runBrowserSmallA(preflightResult, server, evidenceDir) { try { await page.goto(server.assignedUrl, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => Boolean(window.__OPS__) && typeof window.showOpenFilePicker === 'undefined'); + const bridgeGateSetup = await installDevBridgeOpenGate(page); 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()}`); @@ -221,6 +305,14 @@ async function runBrowserSmallA(preflightResult, server, evidenceDir) { const fileChooser = await fileChooserPromise; await fileChooser.setFiles(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 toastScreenshotPath = resolve(evidenceDir, 'xer-toast.png'); + await toast.screenshot({ path: toastScreenshotPath }); + try { await page.waitForFunction((messageKey) => { const bridge = window.__OPS__; @@ -240,12 +332,17 @@ async function runBrowserSmallA(preflightResult, server, evidenceDir) { fail(`${error instanceof Error ? error.message : String(error)}; observed=${JSON.stringify(observed)}`); } - const toast = page.locator('.ops-toast').filter({ hasText: 'XER file opened' }); - await toast.first().waitFor({ state: 'visible', timeout: 5_000 }); - const domText = await toast.first().innerText(); 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)}`); @@ -262,7 +359,17 @@ async function runBrowserSmallA(preflightResult, server, evidenceDir) { ], maskColor: '#263238', }); - return { state, domText, dialogCounts, screenshotPath, launchArgs, headless: false }; + return { + state, + domText, + dialogCounts, + bridgeGate, + screenshotPath, + toastScreenshotPath, + toastBox, + launchArgs, + headless: false, + }; } finally { await context.close(); await browser.close(); @@ -298,11 +405,19 @@ async function main() { runId: evidenceDir.split('/').pop(), repository: REPO_ROOT, branch: 'codex/xer-x11-harness', - server: { url: serverHandle.server.assignedUrl, pid: serverHandle.server.pid, cwd: serverHandle.server.procCwd, httpStatus: 200 }, + server: { + url: serverHandle.server.assignedUrl, + pid: serverHandle.server.pid, + cwd: serverHandle.server.procCwd, + httpStatus: 200, + guard: serverHandle.server.guard, + }, browser: { executablePath: preflightResult.chromiumPath, headless: browserEvidence.headless, launchArgs: browserEvidence.launchArgs }, dom: { text: browserEvidence.domText }, nativeDialogs: browserEvidence.dialogCounts, + devBridgeOpenGate: browserEvidence.bridgeGate, screenshot: 'imported-redacted.png', + toastScreenshot: { file: 'xer-toast.png', box: browserEvidence.toastBox }, }, null, 2)}\n`, 'utf8'); console.log(`SMALL-A OK: zichtbare importasserties geslaagd; evidence=${evidenceDir}`); } finally { From 98864c24850dcd0e4414f0fce5c5b1a4297e837c Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:42:35 +0200 Subject: [PATCH 095/171] test(browser): maak XER store-inspectie read-only --- tests/browser/helpers/ops-state.mjs | 20 ++-- tests/browser/x11-xer-evidence.mjs | 161 ++++++++++++++++++++++++++-- 2 files changed, 159 insertions(+), 22 deletions(-) diff --git a/tests/browser/helpers/ops-state.mjs b/tests/browser/helpers/ops-state.mjs index 029eb468c..d445fa345 100644 --- a/tests/browser/helpers/ops-state.mjs +++ b/tests/browser/helpers/ops-state.mjs @@ -5,24 +5,22 @@ export async function readOpsState(page) { const bridge = window.__OPS__; if (!bridge) throw new Error('window.__OPS__ ontbreekt'); const state = bridge.store.getState(); - const xerNotification = state.ui.notifications.find((item) => item.messageKey === xerMessageKey); - const importedTasks = Array.isArray(state.tasks) - ? state.tasks.filter((task) => task.isSummary !== true && (task.childIds?.length ?? 0) === 0) - : []; + const notifications = Object.values(state.ui.notifications.entries); + const xerNotification = notifications.find((item) => item.messageKey === xerMessageKey); return { - documents: Array.isArray(state.documents) ? state.documents.length : -1, + 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: importedTasks.length, - relations: Array.isArray(state.sequences) ? state.sequences.length : -1, - cpm: Boolean(state.cpmResult), - xerSourceArchive: Boolean(state.xerSourceArchive), - xerSourceProjectId: typeof state.xerSourceProjectId === 'string' ? state.xerSourceProjectId : null, + 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: state.ui.notifications.map((item) => ({ + notifications: notifications.map((item) => ({ severity: item.severity, messageKey: item.messageKey, helpArticleId: item.helpArticleId ?? null, diff --git a/tests/browser/x11-xer-evidence.mjs b/tests/browser/x11-xer-evidence.mjs index 9a79b772f..d7b831954 100644 --- a/tests/browser/x11-xer-evidence.mjs +++ b/tests/browser/x11-xer-evidence.mjs @@ -212,6 +212,102 @@ async function installDevBridgeOpenGate(page) { 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 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]; + 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], + ]); + } + Object.freeze(notificationEntries); + const importedTaskCount = Array.isArray(live.tasks) + ? live.tasks.filter((task) => task.isSummary !== true && (task.childIds?.length ?? 0) === 0).length + : 0; + return dataRecord([ + ['documents', dataRecord([['count', Array.isArray(live.documents) ? live.documents.length : 0]])], + ['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], + ['ui', dataRecord([ + ['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) => { @@ -238,15 +334,60 @@ async function installDevBridgeOpenGate(page) { visit(bridge, 'devBridge'); if (gate.wrapped.length === 0) throw new Error('anti-sluiproute-gate vond geen devBridge-openmethoden'); - return { wrapped: [...gate.wrapped] }; + return { wrapped: [...gate.wrapped], storeFacade: inspectStoreFacade() }; }); } async function readDevBridgeOpenGate(page) { - return page.evaluate(() => ({ - calls: [...(window.__OPS_X11_BRIDGE_GATE__?.calls ?? [])], - wrapped: [...(window.__OPS_X11_BRIDGE_GATE__?.wrapped ?? [])], - })); + 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) { @@ -317,13 +458,11 @@ async function runBrowserSmallA(preflightResult, server, evidenceDir) { await page.waitForFunction((messageKey) => { const bridge = window.__OPS__; const state = bridge?.store.getState(); - const notification = state?.ui.notifications.find((item) => item.messageKey === messageKey); - const importedTasks = state?.tasks?.filter((task) => - task.isSummary !== true && (task.childIds?.length ?? 0) === 0, - ); + const notifications = state?.ui?.notifications?.entries ?? {}; + const notification = Object.values(notifications).find((item) => item.messageKey === messageKey); return Boolean( - state && state.documents?.length === 1 && importedTasks?.length === 8 && - state.sequences?.length === 7 && state.cpmResult && state.xerSourceArchive && + 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 }); From 2abf3a622a6d6662c7feaa2a5a8bf63cfffeac81 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:52:04 +0200 Subject: [PATCH 096/171] test(browser): bind XER evidence aan live Git-state --- tests/browser/README.md | 3 + tests/browser/x11-xer-evidence.mjs | 119 +++++++++++++++++++++++------ 2 files changed, 97 insertions(+), 25 deletions(-) diff --git a/tests/browser/README.md b/tests/browser/README.md index 242670124..f335b583c 100644 --- a/tests/browser/README.md +++ b/tests/browser/README.md @@ -67,6 +67,9 @@ een afzonderlijke elementopname van de zichtbare XER-toast, de geobserveerde sta dev-server-output. De toastopname wordt vóór auto-dismiss gemaakt en accepteert alleen generieke importaantallen plus `Read more`; pad-, bestands-, project-, taak- en resourcenamen zijn er niet toegestaan. Canvas, documentnaam en tablabels worden in de overzichtsscreenshot gemaskeerd. +De metadata bevat de live lokale Git-toplevel, branch, HEAD en beginstatus om het bewijs aan de +juiste checkout te koppelen. Daardoor kan zij lokale paden bevatten en is de volledige +evidencemap uitsluitend bedoeld voor lokale opslag, niet voor publicatie of commit in de repo. 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`. diff --git a/tests/browser/x11-xer-evidence.mjs b/tests/browser/x11-xer-evidence.mjs index d7b831954..c623b8424 100644 --- a/tests/browser/x11-xer-evidence.mjs +++ b/tests/browser/x11-xer-evidence.mjs @@ -1,6 +1,6 @@ import { access, lstat, mkdir, readFile, readlink, symlink, unlink, writeFile } from 'node:fs/promises'; import { constants as fsConstants } from 'node:fs'; -import { spawn } from 'node:child_process'; +import { execFileSync, spawn } from 'node:child_process'; import { createRequire } from 'node:module'; import process from 'node:process'; import { existsSync } from 'node:fs'; @@ -26,6 +26,47 @@ 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 statusPorcelainV1 = gitOutput(['status', '--porcelain=v1'], 'status'); + if (toplevel !== REPO_ROOT) { + fail(`git-toplevel-gate rood: ${toplevel}, verwacht exact ${REPO_ROOT}`); + } + return { toplevel, branch, head, statusPorcelainV1 }; +} + +function assertGitStatusUnchanged(before, after) { + if (before.statusPorcelainV1 !== after.statusPorcelainV1) { + fail( + `git-status-integriteitsgate rood: begin=${JSON.stringify(before.statusPorcelainV1)}; ` + + `eind=${JSON.stringify(after.statusPorcelainV1)}`, + ); + } +} + +function assertMetadataGitIdentity(metadata, expected) { + const observed = metadata.git; + if (observed?.toplevel !== expected.toplevel || observed?.branch !== expected.branch || + observed?.head !== expected.head || observed?.statusPorcelainV1 !== expected.statusPorcelainV1) { + fail(`metadata-git-identiteitsgate rood: observed=${JSON.stringify(observed)}; expected=${JSON.stringify(expected)}`); + } +} + async function readableFile(path, label) { try { await access(path, fsConstants.R_OK); @@ -528,6 +569,7 @@ async function stopDevServer(child) { } async function main() { + const gitStart = readGitEvidence(); const preflightResult = await preflight(); if (!preflightResult) return; await assertNoDirectPathOpen(); @@ -535,34 +577,61 @@ async function main() { await mkdir(evidenceDir, { recursive: true }); const dependencies = await ensureDependencies(); let serverHandle = null; + let browserEvidence = null; + let runError = null; + const cleanupErrors = []; try { serverHandle = await startDevServer(); - const browserEvidence = await runBrowserSmallA(preflightResult, serverHandle.server, evidenceDir); - await writeFile(resolve(evidenceDir, 'state.json'), `${JSON.stringify(browserEvidence.state, null, 2)}\n`, 'utf8'); - await writeFile(resolve(evidenceDir, 'server-output.txt'), serverHandle.server.output, 'utf8'); - await writeFile(resolve(evidenceDir, 'metadata.json'), `${JSON.stringify({ - runId: evidenceDir.split('/').pop(), - repository: REPO_ROOT, - branch: 'codex/xer-x11-harness', - server: { - url: serverHandle.server.assignedUrl, - pid: serverHandle.server.pid, - cwd: serverHandle.server.procCwd, - httpStatus: 200, - guard: serverHandle.server.guard, - }, - browser: { executablePath: preflightResult.chromiumPath, headless: browserEvidence.headless, launchArgs: browserEvidence.launchArgs }, - dom: { text: browserEvidence.domText }, - nativeDialogs: browserEvidence.dialogCounts, - devBridgeOpenGate: browserEvidence.bridgeGate, - screenshot: 'imported-redacted.png', - toastScreenshot: { file: 'xer-toast.png', box: browserEvidence.toastBox }, - }, null, 2)}\n`, 'utf8'); - console.log(`SMALL-A OK: zichtbare importasserties geslaagd; evidence=${evidenceDir}`); + browserEvidence = await runBrowserSmallA(preflightResult, serverHandle.server, evidenceDir); + } catch (error) { + runError = error; } finally { - if (serverHandle) await stopDevServer(serverHandle.child); - await removeTemporaryDependencies(dependencies.linkPath); + 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(); + assertGitStatusUnchanged(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 metadata = { + runId: evidenceDir.split('/').pop(), + git: { + toplevel: gitStart.toplevel, + branch: gitStart.branch, + head: gitStart.head, + statusPorcelainV1: gitStart.statusPorcelainV1, + statusUnchangedAfterCleanup: true, + }, + server: { + url: serverHandle.server.assignedUrl, + pid: serverHandle.server.pid, + cwd: serverHandle.server.procCwd, + httpStatus: 200, + guard: serverHandle.server.guard, + }, + browser: { executablePath: preflightResult.chromiumPath, headless: browserEvidence.headless, launchArgs: browserEvidence.launchArgs }, + dom: { text: browserEvidence.domText }, + nativeDialogs: browserEvidence.dialogCounts, + devBridgeOpenGate: browserEvidence.bridgeGate, + screenshot: 'imported-redacted.png', + toastScreenshot: { file: 'xer-toast.png', box: browserEvidence.toastBox }, + }; + assertMetadataGitIdentity(metadata, gitStart); + await writeFile(resolve(evidenceDir, 'state.json'), `${JSON.stringify(browserEvidence.state, null, 2)}\n`, 'utf8'); + await writeFile(resolve(evidenceDir, 'server-output.txt'), serverHandle.server.output, 'utf8'); + await writeFile(resolve(evidenceDir, 'metadata.json'), `${JSON.stringify(metadata, null, 2)}\n`, 'utf8'); + console.log(`SMALL-A OK: zichtbare importasserties geslaagd; evidence=${evidenceDir}`); } main().catch((error) => { From 378a5c38e3e274c19656b01ffa9fe4f8e4bafe1b Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 2 Sep 2026 04:09:31 +0200 Subject: [PATCH 097/171] test(browser): pin Git-eindidentiteit van XER-bewijs --- tests/browser/README.md | 15 +++++++-- tests/browser/git-evidence-contract.mjs | 35 +++++++++++++++++++++ tests/browser/x11-xer-evidence.mjs | 42 ++++++++++++++----------- 3 files changed, 71 insertions(+), 21 deletions(-) create mode 100644 tests/browser/git-evidence-contract.mjs diff --git a/tests/browser/README.md b/tests/browser/README.md index f335b583c..160d92162 100644 --- a/tests/browser/README.md +++ b/tests/browser/README.md @@ -31,6 +31,15 @@ OPS_PLAYWRIGHT_DIR="/pad/naar/node_modules/playwright-core" \ npm run test:browser:x11 ``` +De corpusloze eindintegriteitscontracttest is afzonderlijk uit te voeren met: + +```bash +node tests/browser/git-evidence-contract.mjs +``` + +Deze doodt afzonderlijk een verschil in Git-toplevel, branch, HEAD en de ruwe +`status --porcelain=v1`, zonder een checkout of de werkboom te wijzigen. + 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 @@ -68,8 +77,10 @@ dev-server-output. De toastopname wordt vóór auto-dismiss gemaakt en accepteer importaantallen plus `Read more`; pad-, bestands-, project-, taak- en resourcenamen zijn er niet toegestaan. Canvas, documentnaam en tablabels worden in de overzichtsscreenshot gemaskeerd. De metadata bevat de live lokale Git-toplevel, branch, HEAD en beginstatus om het bewijs aan de -juiste checkout te koppelen. Daardoor kan zij lokale paden bevatten en is de volledige -evidencemap uitsluitend bedoeld voor lokale opslag, niet voor publicatie of commit in de repo. +juiste checkout te koppelen. Na browser-, server- en dependencycleanup moeten alle vier waarden +exact gelijk zijn aan hun beginwaarde voordat een evidencebestand wordt geschreven. Daardoor kan +de metadata lokale paden bevatten en is de volledige evidencemap uitsluitend bedoeld voor lokale +opslag, niet voor publicatie of commit in de repo. 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`. diff --git a/tests/browser/git-evidence-contract.mjs b/tests/browser/git-evidence-contract.mjs new file mode 100644 index 000000000..e97501b61 --- /dev/null +++ b/tests/browser/git-evidence-contract.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { assertGitEvidenceUnchanged } from './x11-xer-evidence.mjs'; + +const begin = Object.freeze({ + toplevel: '/tmp/ops-xer-x11-harness', + branch: 'codex/xer-x11-harness', + head: '900358f2ed5c6b70b5bec0afeea0891fa8678715', + statusPorcelainV1: ' M known-dirty.txt\n?? known-untracked.txt\n', +}); + +assert.doesNotThrow( + () => assertGitEvidenceUnchanged(begin, { ...begin }), + 'exact gelijke Git-identiteit inclusief raw dirty/untracked-status moet groen blijven', +); + +const mutations = [ + ['toplevel', '/tmp/andere-worktree'], + ['branch', 'andere-schone-branch'], + ['head', '1111111111111111111111111111111111111111'], + ['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`, + ); +} + +console.log('OK git-evidence-contract: toplevel, branch, HEAD en raw status zijn exact gepind'); diff --git a/tests/browser/x11-xer-evidence.mjs b/tests/browser/x11-xer-evidence.mjs index c623b8424..7b1d23de1 100644 --- a/tests/browser/x11-xer-evidence.mjs +++ b/tests/browser/x11-xer-evidence.mjs @@ -50,11 +50,14 @@ function readGitEvidence() { return { toplevel, branch, head, statusPorcelainV1 }; } -function assertGitStatusUnchanged(before, after) { - if (before.statusPorcelainV1 !== after.statusPorcelainV1) { +export function assertGitEvidenceUnchanged(before, after) { + const fields = ['toplevel', 'branch', 'head', 'statusPorcelainV1']; + const differences = fields.filter((field) => before[field] !== after[field]); + + if (differences.length > 0) { fail( - `git-status-integriteitsgate rood: begin=${JSON.stringify(before.statusPorcelainV1)}; ` + - `eind=${JSON.stringify(after.statusPorcelainV1)}`, + `git-eindintegriteitsgate rood: verschil=${differences.join(',')}; ` + + `begin=${JSON.stringify(before)}; eind=${JSON.stringify(after)}`, ); } } @@ -441,7 +444,7 @@ function assertPrivacySafeToast(text, box) { } } -async function runBrowserSmallA(preflightResult, server, evidenceDir) { +async function runBrowserSmallA(preflightResult, server) { const { chromium } = loadPlaywrightCore(); const browserEnv = { ...process.env }; const launchArgs = []; @@ -492,8 +495,7 @@ async function runBrowserSmallA(preflightResult, server, evidenceDir) { const domText = await toast.innerText(); const toastBox = await toast.boundingBox(); assertPrivacySafeToast(domText, toastBox); - const toastScreenshotPath = resolve(evidenceDir, 'xer-toast.png'); - await toast.screenshot({ path: toastScreenshotPath }); + const toastScreenshot = await toast.screenshot(); try { await page.waitForFunction((messageKey) => { @@ -528,9 +530,7 @@ async function runBrowserSmallA(preflightResult, server, evidenceDir) { fail(`native-dialog-audit rood: ${JSON.stringify(dialogCounts)}`); } - const screenshotPath = resolve(evidenceDir, 'imported-redacted.png'); - await page.screenshot({ - path: screenshotPath, + const screenshot = await page.screenshot({ fullPage: false, mask: [ page.locator('canvas'), @@ -544,8 +544,8 @@ async function runBrowserSmallA(preflightResult, server, evidenceDir) { domText, dialogCounts, bridgeGate, - screenshotPath, - toastScreenshotPath, + screenshot, + toastScreenshot, toastBox, launchArgs, headless: false, @@ -574,7 +574,6 @@ async function main() { if (!preflightResult) return; await assertNoDirectPathOpen(); const evidenceDir = resolve(EVIDENCE_ROOT, runId()); - await mkdir(evidenceDir, { recursive: true }); const dependencies = await ensureDependencies(); let serverHandle = null; let browserEvidence = null; @@ -582,7 +581,7 @@ async function main() { const cleanupErrors = []; try { serverHandle = await startDevServer(); - browserEvidence = await runBrowserSmallA(preflightResult, serverHandle.server, evidenceDir); + browserEvidence = await runBrowserSmallA(preflightResult, serverHandle.server); } catch (error) { runError = error; } finally { @@ -599,7 +598,7 @@ async function main() { } const gitEnd = readGitEvidence(); - assertGitStatusUnchanged(gitStart, gitEnd); + 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'); @@ -628,13 +627,18 @@ async function main() { toastScreenshot: { file: 'xer-toast.png', box: browserEvidence.toastBox }, }; assertMetadataGitIdentity(metadata, gitStart); + await mkdir(evidenceDir, { recursive: true }); + await writeFile(resolve(evidenceDir, 'imported-redacted.png'), browserEvidence.screenshot); + await writeFile(resolve(evidenceDir, 'xer-toast.png'), browserEvidence.toastScreenshot); await writeFile(resolve(evidenceDir, 'state.json'), `${JSON.stringify(browserEvidence.state, null, 2)}\n`, 'utf8'); await writeFile(resolve(evidenceDir, 'server-output.txt'), serverHandle.server.output, 'utf8'); await writeFile(resolve(evidenceDir, 'metadata.json'), `${JSON.stringify(metadata, null, 2)}\n`, 'utf8'); console.log(`SMALL-A OK: zichtbare importasserties geslaagd; evidence=${evidenceDir}`); } -main().catch((error) => { - console.error(`X11-harnas rood: ${error instanceof Error ? error.message : String(error)}`); - process.exitCode = 1; -}); +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(`X11-harnas rood: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + }); +} From 3c7cce5f826f1885bce35802619fe93a26f27d1d Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 2 Sep 2026 04:23:14 +0200 Subject: [PATCH 098/171] docs: beschrijf lokale X11-browserproef --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 331a99fdf..1474456b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,7 @@ npm run test:planning # los: CPM/kalender-regressiesuite (== bash tests/plan 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: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) From b55489d94bd5ce2da06ea7b48220464ae2f7484e Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 2 Sep 2026 04:44:04 +0200 Subject: [PATCH 099/171] test(browser): bewijs XER-multidocument en Help --- tests/browser/README.md | 56 ++++- tests/browser/helpers/ops-state.mjs | 105 ++++++++ tests/browser/x11-multidoc-contract.mjs | 60 +++++ tests/browser/x11-xer-evidence.mjs | 311 +++++++++++++++++++++++- 4 files changed, 507 insertions(+), 25 deletions(-) create mode 100644 tests/browser/x11-multidoc-contract.mjs diff --git a/tests/browser/README.md b/tests/browser/README.md index 160d92162..3ca71a98c 100644 --- a/tests/browser/README.md +++ b/tests/browser/README.md @@ -1,15 +1,16 @@ # X11/XWayland-browserharnas voor XER -Dit harnas voert de fase-1-gebruikstest 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__`. +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 run gebruikt daaruit - `crawl-xer/p6diff-baseline.xer`. +- `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. @@ -31,14 +32,26 @@ OPS_PLAYWRIGHT_DIR="/pad/naar/node_modules/playwright-core" \ npm run test:browser:x11 ``` -De corpusloze eindintegriteitscontracttest is afzonderlijk uit te voeren met: +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 +``` + +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 ``` -Deze doodt afzonderlijk een verschil in Git-toplevel, branch, HEAD en de ruwe -`status --porcelain=v1`, zonder een checkout of de werkboom te wijzigen. +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 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 @@ -68,14 +81,31 @@ De import moet opleveren: 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. 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 -dev-server-output. De toastopname wordt vóór auto-dismiss gemaakt en accepteert alleen generieke -importaantallen plus `Read more`; pad-, bestands-, project-, taak- en resourcenamen zijn er niet -toegestaan. Canvas, documentnaam en tablabels worden in de overzichtsscreenshot gemaskeerd. +dev-server-output. Fase 2A voegt een geredigeerde Help-screenshot toe. De toastopname wordt vóór +auto-dismiss gemaakt en accepteert alleen generieke importaantallen plus `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 bevat de live lokale Git-toplevel, branch, HEAD en beginstatus om het bewijs aan de juiste checkout te koppelen. Na browser-, server- en dependencycleanup moeten alle vier waarden exact gelijk zijn aan hun beginwaarde voordat een evidencebestand wordt geschreven. Daardoor kan @@ -84,4 +114,4 @@ opslag, niet voor publicatie of commit in de repo. 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. +Die OS-specifieke routes vallen buiten fase 1 en deze fase-2A-stap. diff --git a/tests/browser/helpers/ops-state.mjs b/tests/browser/helpers/ops-state.mjs index d445fa345..ca7e56908 100644 --- a/tests/browser/helpers/ops-state.mjs +++ b/tests/browser/helpers/ops-state.mjs @@ -29,6 +29,32 @@ export async function readOpsState(page) { }, 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 function assertSmallAState(snapshot) { const failures = []; if (snapshot.documents !== 1) failures.push(`documents=${snapshot.documents}, verwacht 1`); @@ -47,3 +73,82 @@ export function assertSmallAState(snapshot) { } 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, +}); + +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 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 (!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/x11-multidoc-contract.mjs b/tests/browser/x11-multidoc-contract.mjs new file mode 100644 index 000000000..a2025dbc0 --- /dev/null +++ b/tests/browser/x11-multidoc-contract.mjs @@ -0,0 +1,60 @@ +import { + assertMultiDocumentEvidence, + MULTI_DOCUMENT_EXPECTED_REPORT, +} 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 }, + 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'; + +assertMultiDocumentEvidence(evidence); +console.log('OK x11-multidoc-contract: report, 12 documenten, navigatie, latenties en Help-route gepind'); diff --git a/tests/browser/x11-xer-evidence.mjs b/tests/browser/x11-xer-evidence.mjs index 7b1d23de1..bb439ad72 100644 --- a/tests/browser/x11-xer-evidence.mjs +++ b/tests/browser/x11-xer-evidence.mjs @@ -6,12 +6,20 @@ import process from 'node:process'; import { existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { resolve } from 'node:path'; -import { readOpsState, assertSmallAState, XER_OPEN_MESSAGE_KEY } from './helpers/ops-state.mjs'; +import { + readOpsState, + readMultiDocumentOpsState, + assertSmallAState, + assertMultiDocumentEvidence, + 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 EVIDENCE_ROOT = '/tmp/xer-x11-evidence'; function runId() { @@ -79,6 +87,11 @@ async function readableFile(path, label) { } async function preflight() { + const scenario = process.env.OPS_XER_X11_SCENARIO?.trim() || 'small-a'; + if (!['small-a', MULTI_DOCUMENT_SCENARIO].includes(scenario)) { + fail(`onbekend OPS_XER_X11_SCENARIO: ${scenario}`); + } + const corpusFile = scenario === MULTI_DOCUMENT_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'); @@ -94,7 +107,7 @@ async function preflight() { } if (!corpusRoot) fail('OPS_XER_CORPUS is verplicht voor een lokale X11-run'); - await readableFile(resolve(corpusRoot, DEFAULT_CORPUS_FILE), 'XER-corpusbestand'); + 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'); @@ -102,7 +115,8 @@ async function preflight() { return { corpusRoot: resolve(corpusRoot), - xerPath: resolve(corpusRoot, DEFAULT_CORPUS_FILE), + xerPath: resolve(corpusRoot, corpusFile), + scenario, chromiumPath, display: display || null, waylandSocket: existsSync(waylandSocket) ? waylandSocket : null, @@ -244,9 +258,10 @@ async function assertNoDirectPathOpen() { 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: direct pad-openen is verboden in het X11-harnas'); + fail('statische guard: directe pad- of Help-openactie is verboden in het X11-harnas'); } } @@ -263,22 +278,52 @@ async function installDevBridgeOpenGate(page) { 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], + ]); + } + 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 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', Array.isArray(live.documents) ? live.documents.length : 0]])], ['tasks', dataRecord([['importedCount', importedTaskCount]])], @@ -286,7 +331,19 @@ async function installDevBridgeOpenGate(page) { ['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)], + ['taskCount', importedTaskCount], + ['sequenceCount', Array.isArray(live.sequences) ? live.sequences.length : 0], + ['cpmPresent', Boolean(live.cpmResult)], + ['sourceArchivePresent', Boolean(live.xerSourceArchive)], + ])], ['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], ['notifications', dataRecord([ ['count', liveNotifications.length], ['entries', notificationEntries], @@ -444,6 +501,223 @@ function assertPrivacySafeToast(text, box) { } } +function assertPrivacySafeMultiDocumentToast(text, box) { + const normalized = text.replace(/\s+/g, ' ').trim(); + const required = [ + 'XER file opened: 12 project documents.', + '15 projects found.', + '3 empty projects skipped.', + '9 dangling baseline references ignored.', + 'Read more', + ]; + for (const fragment of required) { + if (!normalized.includes(fragment)) fail(`multi-documenttoast mist: ${fragment}`); + } + if (/[\\/]|\b[A-Za-z]:|\.xer\b/i.test(normalized)) { + fail('multi-documenttoast bevat een pad of bestandsnaam'); + } + 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 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 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, 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 runBrowserSmallA(preflightResult, server) { const { chromium } = loadPlaywrightCore(); const browserEnv = { ...process.env }; @@ -470,6 +744,8 @@ async function runBrowserSmallA(preflightResult, server) { 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; @@ -483,12 +759,15 @@ async function runBrowserSmallA(preflightResult, server) { await page.waitForFunction(() => Boolean(window.__OPS__) && typeof window.showOpenFilePicker === 'undefined'); const bridgeGateSetup = await installDevBridgeOpenGate(page); - 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(preflightResult.xerPath); + 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 }); @@ -605,6 +884,7 @@ async function main() { const metadata = { runId: evidenceDir.split('/').pop(), + scenario: preflightResult.scenario, git: { toplevel: gitStart.toplevel, branch: gitStart.branch, @@ -620,20 +900,27 @@ async function main() { guard: serverHandle.server.guard, }, browser: { executablePath: preflightResult.chromiumPath, headless: browserEvidence.headless, launchArgs: browserEvidence.launchArgs }, - dom: { text: browserEvidence.domText }, + dom: preflightResult.scenario === MULTI_DOCUMENT_SCENARIO + ? { tabCount: 12, helpTitle: browserEvidence.state.help.articleHeading } + : { text: browserEvidence.domText }, nativeDialogs: browserEvidence.dialogCounts, devBridgeOpenGate: browserEvidence.bridgeGate, screenshot: 'imported-redacted.png', toastScreenshot: { file: 'xer-toast.png', box: browserEvidence.toastBox }, + helpScreenshot: browserEvidence.helpScreenshot ? 'help-redacted.png' : null, }; assertMetadataGitIdentity(metadata, gitStart); await mkdir(evidenceDir, { recursive: true }); await writeFile(resolve(evidenceDir, 'imported-redacted.png'), browserEvidence.screenshot); await writeFile(resolve(evidenceDir, 'xer-toast.png'), browserEvidence.toastScreenshot); + if (browserEvidence.helpScreenshot) { + await writeFile(resolve(evidenceDir, 'help-redacted.png'), browserEvidence.helpScreenshot); + } await writeFile(resolve(evidenceDir, 'state.json'), `${JSON.stringify(browserEvidence.state, null, 2)}\n`, 'utf8'); await writeFile(resolve(evidenceDir, 'server-output.txt'), serverHandle.server.output, 'utf8'); await writeFile(resolve(evidenceDir, 'metadata.json'), `${JSON.stringify(metadata, null, 2)}\n`, 'utf8'); - console.log(`SMALL-A OK: zichtbare importasserties geslaagd; evidence=${evidenceDir}`); + const label = preflightResult.scenario === MULTI_DOCUMENT_SCENARIO ? 'FASE-2A' : 'SMALL-A'; + console.log(`${label} OK: zichtbare importasserties geslaagd; evidence=${evidenceDir}`); } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { From 67f6a7e9eb45165d61286b23d5741ca40568f248 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 2 Sep 2026 05:05:04 +0200 Subject: [PATCH 100/171] test(browser): sluit XER-bewijsprivacy af --- tests/browser/README.md | 10 ++++-- tests/browser/git-evidence-contract.mjs | 16 +++++++-- tests/browser/helpers/ops-state.mjs | 48 +++++++++++++++++++++++++ tests/browser/x11-multidoc-contract.mjs | 9 ++++- tests/browser/x11-xer-evidence.mjs | 37 +++++++++---------- 5 files changed, 96 insertions(+), 24 deletions(-) diff --git a/tests/browser/README.md b/tests/browser/README.md index 3ca71a98c..0f7f6c202 100644 --- a/tests/browser/README.md +++ b/tests/browser/README.md @@ -91,7 +91,10 @@ CPM-resultaat. Ctrl+1, Ctrl+5 en Ctrl+9 worden buiten invoervelden bediend; tabs 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. De echte `Read more`-knop moet +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 @@ -103,10 +106,11 @@ 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 dev-server-output. Fase 2A voegt een geredigeerde Help-screenshot toe. De toastopname wordt vóór -auto-dismiss gemaakt en accepteert alleen generieke importaantallen plus `Read more`; pad-, +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 bevat de live lokale Git-toplevel, branch, HEAD en beginstatus om het bewijs aan de +De metadata bevat de live lokale Git-toplevel, branch, HEAD, `HEAD^` en beginstatus om het bewijs aan de juiste checkout te koppelen. Na browser-, server- en dependencycleanup moeten alle vier waarden exact gelijk zijn aan hun beginwaarde voordat een evidencebestand wordt geschreven. Daardoor kan de metadata lokale paden bevatten en is de volledige evidencemap uitsluitend bedoeld voor lokale diff --git a/tests/browser/git-evidence-contract.mjs b/tests/browser/git-evidence-contract.mjs index e97501b61..66dc318fe 100644 --- a/tests/browser/git-evidence-contract.mjs +++ b/tests/browser/git-evidence-contract.mjs @@ -1,10 +1,16 @@ import assert from 'node:assert/strict'; -import { assertGitEvidenceUnchanged } from './x11-xer-evidence.mjs'; +import { + assertGitEvidenceUnchanged, + assertMetadataGitIdentity, +} from './x11-xer-evidence.mjs'; + +const expectedParent = '790d6cd8266682fa9b7798a3d1f9e0a1a2498db9'; const begin = Object.freeze({ toplevel: '/tmp/ops-xer-x11-harness', branch: 'codex/xer-x11-harness', head: '900358f2ed5c6b70b5bec0afeea0891fa8678715', + parent: expectedParent, statusPorcelainV1: ' M known-dirty.txt\n?? known-untracked.txt\n', }); @@ -32,4 +38,10 @@ for (const [field, value] of mutations) { ); } -console.log('OK git-evidence-contract: toplevel, branch, HEAD en raw status zijn exact gepind'); +const metadata = { git: { ...begin } }; +if (process.env.OPS_X11_GIT_MUTATION === 'parent') { + metadata.git.parent = '1111111111111111111111111111111111111111'; +} +assertMetadataGitIdentity(metadata, begin); + +console.log(`OK git-evidence-contract: vier eindpoorten en metadata-parent ${expectedParent} zijn exact gepind`); diff --git a/tests/browser/helpers/ops-state.mjs b/tests/browser/helpers/ops-state.mjs index ca7e56908..737e0331a 100644 --- a/tests/browser/helpers/ops-state.mjs +++ b/tests/browser/helpers/ops-state.mjs @@ -83,6 +83,20 @@ export const MULTI_DOCUMENT_EXPECTED_REPORT = Object.freeze({ danglingBaselineReferences: 9, }); +export const MULTI_DOCUMENT_EXPECTED_ENCODING = Object.freeze({ + messageKey: 'notifications.xerImportEncoding', + value: 'windows-1252', +}); + +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'], @@ -96,6 +110,33 @@ 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)) { @@ -103,6 +144,13 @@ export function assertMultiDocumentEvidence(evidence) { 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 : []; diff --git a/tests/browser/x11-multidoc-contract.mjs b/tests/browser/x11-multidoc-contract.mjs index a2025dbc0..48dd6b504 100644 --- a/tests/browser/x11-multidoc-contract.mjs +++ b/tests/browser/x11-multidoc-contract.mjs @@ -1,6 +1,8 @@ import { assertMultiDocumentEvidence, + MULTI_DOCUMENT_EXPECTED_ENCODING, MULTI_DOCUMENT_EXPECTED_REPORT, + MULTI_DOCUMENT_EXPECTED_TOAST_LINES, } from './helpers/ops-state.mjs'; const routes = new Map([ @@ -14,6 +16,8 @@ const routes = new Map([ 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, @@ -55,6 +59,9 @@ if (mutation === 'duplicate-project-id') evidence.documents[1].projectIdentityHa 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, 12 documenten, navigatie, latenties en Help-route gepind'); +console.log('OK x11-multidoc-contract: report, encoding, gesloten toastallowlist, 12 documenten, navigatie, latenties en Help-route gepind'); diff --git a/tests/browser/x11-xer-evidence.mjs b/tests/browser/x11-xer-evidence.mjs index bb439ad72..1263fd952 100644 --- a/tests/browser/x11-xer-evidence.mjs +++ b/tests/browser/x11-xer-evidence.mjs @@ -11,6 +11,8 @@ import { readMultiDocumentOpsState, assertSmallAState, assertMultiDocumentEvidence, + assertMultiDocumentToastLines, + normalizeVisibleToastLines, XER_OPEN_MESSAGE_KEY, } from './helpers/ops-state.mjs'; @@ -51,11 +53,12 @@ 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 parent = gitOutput(['rev-parse', 'HEAD^'], '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, statusPorcelainV1 }; + return { toplevel, branch, head, parent, statusPorcelainV1 }; } export function assertGitEvidenceUnchanged(before, after) { @@ -70,10 +73,11 @@ export function assertGitEvidenceUnchanged(before, after) { } } -function assertMetadataGitIdentity(metadata, expected) { +export function assertMetadataGitIdentity(metadata, expected) { const observed = metadata.git; if (observed?.toplevel !== expected.toplevel || observed?.branch !== expected.branch || - observed?.head !== expected.head || observed?.statusPorcelainV1 !== expected.statusPorcelainV1) { + observed?.head !== expected.head || observed?.parent !== expected.parent || + observed?.statusPorcelainV1 !== expected.statusPorcelainV1) { fail(`metadata-git-identiteitsgate rood: observed=${JSON.stringify(observed)}; expected=${JSON.stringify(expected)}`); } } @@ -300,6 +304,7 @@ async function installDevBridgeOpenGate(page) { 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); @@ -502,20 +507,7 @@ function assertPrivacySafeToast(text, box) { } function assertPrivacySafeMultiDocumentToast(text, box) { - const normalized = text.replace(/\s+/g, ' ').trim(); - const required = [ - 'XER file opened: 12 project documents.', - '15 projects found.', - '3 empty projects skipped.', - '9 dangling baseline references ignored.', - 'Read more', - ]; - for (const fragment of required) { - if (!normalized.includes(fragment)) fail(`multi-documenttoast mist: ${fragment}`); - } - if (/[\\/]|\b[A-Za-z]:|\.xer\b/i.test(normalized)) { - fail('multi-documenttoast bevat een pad of bestandsnaam'); - } + 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)}`); } @@ -607,6 +599,7 @@ async function runBrowserMultiDocumentHelp(page, preflightResult, bridgeGateSetu 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(); @@ -625,6 +618,13 @@ async function runBrowserMultiDocumentHelp(page, preflightResult, bridgeGateSetu 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'); @@ -694,7 +694,7 @@ async function runBrowserMultiDocumentHelp(page, preflightResult, bridgeGateSetu ], maskColor: '#263238', }); - const evidence = { report: imported.report, openLatencyMs, documents, switches, help }; + const evidence = { report: imported.report, encoding, toastLines, openLatencyMs, documents, switches, help }; assertMultiDocumentEvidence(evidence); const bridgeGate = await readDevBridgeOpenGate(page); @@ -889,6 +889,7 @@ async function main() { toplevel: gitStart.toplevel, branch: gitStart.branch, head: gitStart.head, + parent: gitStart.parent, statusPorcelainV1: gitStart.statusPorcelainV1, statusUnchangedAfterCleanup: true, }, From 913e27b499b6309ebf974be5ccd521997fea44e1 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 2 Sep 2026 05:18:53 +0200 Subject: [PATCH 101/171] test(browser): onderscheid fasebasis en commitparent --- tests/browser/README.md | 13 ++++--- tests/browser/git-evidence-contract.mjs | 48 +++++++++++++++++++------ tests/browser/x11-xer-evidence.mjs | 14 +++++--- 3 files changed, 54 insertions(+), 21 deletions(-) diff --git a/tests/browser/README.md b/tests/browser/README.md index 0f7f6c202..74b323b84 100644 --- a/tests/browser/README.md +++ b/tests/browser/README.md @@ -110,11 +110,14 @@ auto-dismiss gemaakt en accepteert alleen generieke importaantallen, de exacte v 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 bevat de live lokale Git-toplevel, branch, HEAD, `HEAD^` en beginstatus om het bewijs aan de -juiste checkout te koppelen. Na browser-, server- en dependencycleanup moeten alle vier waarden -exact gelijk zijn aan hun beginwaarde voordat een evidencebestand wordt geschreven. Daardoor kan -de metadata lokale paden bevatten en is de volledige evidencemap uitsluitend bedoeld voor lokale -opslag, niet voor publicatie of commit in de repo. +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 lokale Git-toplevel, branch, HEAD en ruwe beginstatus om het bewijs aan de juiste checkout te +koppelen. Na browser-, server- en dependencycleanup moeten alle zes waarden exact gelijk zijn aan +hun beginwaarde voordat een evidencebestand wordt geschreven. Daardoor kan de metadata lokale +paden bevatten en is de volledige evidencemap uitsluitend bedoeld voor lokale opslag, niet voor +publicatie of commit in de repo. 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`. diff --git a/tests/browser/git-evidence-contract.mjs b/tests/browser/git-evidence-contract.mjs index 66dc318fe..563517698 100644 --- a/tests/browser/git-evidence-contract.mjs +++ b/tests/browser/git-evidence-contract.mjs @@ -4,25 +4,41 @@ import { assertMetadataGitIdentity, } from './x11-xer-evidence.mjs'; -const expectedParent = '790d6cd8266682fa9b7798a3d1f9e0a1a2498db9'; +const expectedBase = '790d6cd8266682fa9b7798a3d1f9e0a1a2498db9'; +const expectedCommitParent = '00d82a43bc6e552d899c61dd0c3ae05f6e8f728a'; const begin = Object.freeze({ toplevel: '/tmp/ops-xer-x11-harness', branch: 'codex/xer-x11-harness', - head: '900358f2ed5c6b70b5bec0afeea0891fa8678715', - parent: expectedParent, + head: '58f498ec76188b4459747a3dd1c72cce6d4a0ec4', + base: expectedBase, + commitParent: expectedCommitParent, statusPorcelainV1: ' M known-dirty.txt\n?? known-untracked.txt\n', }); -assert.doesNotThrow( - () => assertGitEvidenceUnchanged(begin, { ...begin }), - 'exact gelijke Git-identiteit inclusief raw dirty/untracked-status moet groen blijven', -); +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`], ]; @@ -39,9 +55,19 @@ for (const [field, value] of mutations) { } const metadata = { git: { ...begin } }; -if (process.env.OPS_X11_GIT_MUTATION === 'parent') { - metadata.git.parent = '1111111111111111111111111111111111111111'; -} assertMetadataGitIdentity(metadata, begin); +for (const [field, value] of [ + ['base', '1111111111111111111111111111111111111111'], + ['commitParent', '2222222222222222222222222222222222222222'], +]) { + assert.throws( + () => assertMetadataGitIdentity({ git: { ...begin, [field]: value } }, begin), + /metadata-git-identiteitsgate rood/, + `${field}-verschil moet ook de metadata-identiteitsgate rood maken`, + ); +} -console.log(`OK git-evidence-contract: vier eindpoorten en metadata-parent ${expectedParent} zijn exact gepind`); +console.log( + `OK git-evidence-contract: Git-eindpoorten, fasebasis ${expectedBase} en directe commitparent ` + + `${expectedCommitParent} zijn exact gepind`, +); diff --git a/tests/browser/x11-xer-evidence.mjs b/tests/browser/x11-xer-evidence.mjs index 1263fd952..3dfb4392a 100644 --- a/tests/browser/x11-xer-evidence.mjs +++ b/tests/browser/x11-xer-evidence.mjs @@ -23,6 +23,7 @@ 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 EVIDENCE_ROOT = '/tmp/xer-x11-evidence'; +const PHASE_2A_BASE = '790d6cd8266682fa9b7798a3d1f9e0a1a2498db9'; function runId() { return `${new Date().toISOString().replace(/[^0-9TZ]/g, '')}-${process.pid}`; @@ -53,16 +54,17 @@ 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 parent = gitOutput(['rev-parse', 'HEAD^'], 'HEAD-parent').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, parent, statusPorcelainV1 }; + return { toplevel, branch, head, base, commitParent, statusPorcelainV1 }; } export function assertGitEvidenceUnchanged(before, after) { - const fields = ['toplevel', 'branch', 'head', 'statusPorcelainV1']; + const fields = ['toplevel', 'branch', 'head', 'base', 'commitParent', 'statusPorcelainV1']; const differences = fields.filter((field) => before[field] !== after[field]); if (differences.length > 0) { @@ -76,7 +78,8 @@ export function assertGitEvidenceUnchanged(before, after) { export function assertMetadataGitIdentity(metadata, expected) { const observed = metadata.git; if (observed?.toplevel !== expected.toplevel || observed?.branch !== expected.branch || - observed?.head !== expected.head || observed?.parent !== expected.parent || + observed?.head !== expected.head || observed?.base !== expected.base || + observed?.commitParent !== expected.commitParent || observed?.statusPorcelainV1 !== expected.statusPorcelainV1) { fail(`metadata-git-identiteitsgate rood: observed=${JSON.stringify(observed)}; expected=${JSON.stringify(expected)}`); } @@ -889,7 +892,8 @@ async function main() { toplevel: gitStart.toplevel, branch: gitStart.branch, head: gitStart.head, - parent: gitStart.parent, + base: gitStart.base, + commitParent: gitStart.commitParent, statusPorcelainV1: gitStart.statusPorcelainV1, statusUnchangedAfterCleanup: true, }, From 34cba97e3b4021a01a2dbf644e3b007ac9a8b1bd Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 2 Sep 2026 06:17:37 +0200 Subject: [PATCH 102/171] Integreer XER-export-loss-fixes Broncommits: - e812cae84f59fa4785159275856ed5bf164fd016 Herstel retained XER-data en lege onbekende tabellen - cf5b7cbbd9ba0106f957e0e6326cdbd5dc5caf76 Herken XER-inhoud op continuation-regels - 33d6ed20446110ad7c59f947e5bbb09c7f488952 Pin onbekende XER-rij tegen dubbeltellen De eerdere kandidaatcommits d91397a0, ad1b0576 en 50800b68 zijn als patch-equivalent aan bestaande hoofdbranchcommits overgeslagen. --- src/services/xer/xerTables.ts | 24 ++++- src/services/xerExportLoss.ts | 10 +- tests/planning/check-xer-export-loss.ts | 134 ++++++++++++++++++++++++ tests/planning/check-xer-tables.ts | 65 ++++++++++++ 4 files changed, 228 insertions(+), 5 deletions(-) diff --git a/src/services/xer/xerTables.ts b/src/services/xer/xerTables.ts index 236641a86..e8d5cdf43 100644 --- a/src/services/xer/xerTables.ts +++ b/src/services/xer/xerTables.ts @@ -605,7 +605,11 @@ export function parseXerTables(bytes: XerByteInput): XerTables { ); } let current: MutableXerTable | undefined; - let currentUnknown: { name: string; rows: number } | undefined; + let currentUnknown: { + report: { name: string; rows: number }; + rowStarted: boolean; + rowCounted: boolean; + } | undefined; let endMarkerSeen = false; for (let index = 1; index < lines.length; index++) { @@ -633,8 +637,9 @@ export function parseXerTables(bytes: XerByteInput): XerTables { tables.set(name, current); } else { current = undefined; - currentUnknown = { name, rows: 0 }; - unknownTables.push(currentUnknown); + 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()); @@ -662,6 +667,12 @@ export function parseXerTables(bytes: XerByteInput): XerTables { }); } 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); @@ -682,7 +693,12 @@ export function parseXerTables(bytes: XerByteInput): XerTables { } else if (marker === '%R' && current) { issues.push({ code: 'XER_DATA_WITHOUT_FIELDS', line: index + 1, table: current.name }); } else if (marker === '%R' && currentUnknown) { - currentUnknown.rows++; + // 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); diff --git a/src/services/xerExportLoss.ts b/src/services/xerExportLoss.ts index 8cfe7a473..d793426b7 100644 --- a/src/services/xerExportLoss.ts +++ b/src/services/xerExportLoss.ts @@ -148,7 +148,15 @@ function hasBaselineLoss(capabilities: ExportCapabilities, input: XerExportLossI function hasRetainedAssignmentDetails(metadata: XerImportMetadata | null): boolean { return Boolean(metadata?.resources?.assignments.some(source => hasObjectValues(source.quantities) - || hasObjectValues(source.rawCurves))); + || hasObjectValues(source.rawCurves) + || hasObjectValues(source.costs) + || source.assignedRole !== undefined + // `curveSourceId` verwijst naar de retained 21-punts curve in de bestandsbrede catalogus. + // De doelwriters schrijven noch deze bronidentiteit noch de exacte curvepunten terug. + || Boolean(source.curveSourceId?.trim()) + || Boolean(source.rateType?.trim()) + || Boolean(source.costSourceType?.trim()) + || Boolean(source.rawResourceType?.trim()))); } function hasAssignmentLoss( diff --git a/tests/planning/check-xer-export-loss.ts b/tests/planning/check-xer-export-loss.ts index 23b0e55f1..a315e4528 100644 --- a/tests/planning/check-xer-export-loss.ts +++ b/tests/planning/check-xer-export-loss.ts @@ -11,6 +11,12 @@ 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 }; @@ -195,6 +201,56 @@ installXer(makeParsedXerFixture([ 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', @@ -322,6 +378,84 @@ 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) ?? ''; diff --git a/tests/planning/check-xer-tables.ts b/tests/planning/check-xer-tables.ts index 87d13665b..bd44a36ee 100644 --- a/tests/planning/check-xer-tables.ts +++ b/tests/planning/check-xer-tables.ts @@ -214,6 +214,71 @@ eq('6 onbekende tabellen worden overgeslagen en geteld', { 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'))), { From 9f09135cc7fd7acbf65f39af83b58387fd508733 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 2 Sep 2026 06:45:25 +0200 Subject: [PATCH 103/171] Integreer XER-bronretentiepoort --- src/services/xer/xerCalendarData.ts | 6 +- tests/planning/check-xer-source-retention.ts | 728 +++++++++++++++++++ tests/planning/run.sh | 5 + 3 files changed, 738 insertions(+), 1 deletion(-) create mode 100644 tests/planning/check-xer-source-retention.ts diff --git a/src/services/xer/xerCalendarData.ts b/src/services/xer/xerCalendarData.ts index aa6066c14..972653503 100644 --- a/src/services/xer/xerCalendarData.ts +++ b/src/services/xer/xerCalendarData.ts @@ -698,7 +698,11 @@ export function readXerCalendars(tables: XerTables): XerCalendarReadResult { ? { workingExceptions: decoded.workingExceptions } : {}), ...(explicitDay === undefined && decoded.p6NonWorkPenaltyDates.length > 0 - ? { p6Source: 'XER' as const, p6NonWorkPenaltyDates: decoded.p6NonWorkPenaltyDates } + ? { + p6Source: 'XER' as const, + p6NonWorkPenaltyDates: decoded.p6NonWorkPenaltyDates, + p6NonWorkPenaltyDatesState: 'VALID_VALUES' as const, + } : {}), calendarType: calendarType(rawCalendarType), rawCalendarType, diff --git a/tests/planning/check-xer-source-retention.ts b/tests/planning/check-xer-source-retention.ts new file mode 100644 index 000000000..07f31aec3 --- /dev/null +++ b/tests/planning/check-xer-source-retention.ts @@ -0,0 +1,728 @@ +// 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, +})); +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 }, + )); + 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/run.sh b/tests/planning/run.sh index 5f578570c..cffa913d5 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -400,6 +400,11 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then 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" From 487e8e93b638f7ea34a11b70d261b303469dcc1a Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:10:01 +0200 Subject: [PATCH 104/171] Integreer X11 fase-2B-browserbewijs --- tests/browser/README.md | 58 +- tests/browser/git-evidence-contract.mjs | 6 +- tests/browser/helpers/ops-state.mjs | 299 ++++++++ tests/browser/x11-phase2b-contract.mjs | 113 +++ tests/browser/x11-phase2b-mutations.mjs | 69 ++ .../browser/x11-phase2b-privacy-contract.mjs | 37 + tests/browser/x11-xer-evidence.mjs | 673 +++++++++++++++++- 7 files changed, 1211 insertions(+), 44 deletions(-) create mode 100644 tests/browser/x11-phase2b-contract.mjs create mode 100644 tests/browser/x11-phase2b-mutations.mjs create mode 100644 tests/browser/x11-phase2b-privacy-contract.mjs diff --git a/tests/browser/README.md b/tests/browser/README.md index 74b323b84..2055fa1dd 100644 --- a/tests/browser/README.md +++ b/tests/browser/README.md @@ -42,16 +42,41 @@ 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 @@ -105,7 +130,8 @@ performancegrens. 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 -dev-server-output. Fase 2A voegt een geredigeerde Help-screenshot toe. De toastopname wordt vóór +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, @@ -113,12 +139,32 @@ tablabels en mogelijk corpusdragende Help-inhoud worden in de overige screenshot 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 lokale Git-toplevel, branch, HEAD en ruwe beginstatus om het bewijs aan de juiste checkout te -koppelen. Na browser-, server- en dependencycleanup moeten alle zes waarden exact gelijk zijn aan -hun beginwaarde voordat een evidencebestand wordt geschreven. Daardoor kan de metadata lokale -paden bevatten en is de volledige evidencemap uitsluitend bedoeld voor lokale opslag, niet voor -publicatie of commit in de repo. +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 index 563517698..dddb236b3 100644 --- a/tests/browser/git-evidence-contract.mjs +++ b/tests/browser/git-evidence-contract.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { assertGitEvidenceUnchanged, assertMetadataGitIdentity, + metadataGitIdentity, } from './x11-xer-evidence.mjs'; const expectedBase = '790d6cd8266682fa9b7798a3d1f9e0a1a2498db9'; @@ -54,14 +55,15 @@ for (const [field, value] of mutations) { ); } -const metadata = { git: { ...begin } }; +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: { ...begin, [field]: value } }, begin), + () => assertMetadataGitIdentity({ git: mutated }, begin), /metadata-git-identiteitsgate rood/, `${field}-verschil moet ook de metadata-identiteitsgate rood maken`, ); diff --git a/tests/browser/helpers/ops-state.mjs b/tests/browser/helpers/ops-state.mjs index 737e0331a..80e203924 100644 --- a/tests/browser/helpers/ops-state.mjs +++ b/tests/browser/helpers/ops-state.mjs @@ -55,6 +55,24 @@ export async function readMultiDocumentOpsState(page) { }, 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`); @@ -88,6 +106,287 @@ export const MULTI_DOCUMENT_EXPECTED_ENCODING = Object.freeze({ 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.', 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 index 3dfb4392a..c9bd6277c 100644 --- a/tests/browser/x11-xer-evidence.mjs +++ b/tests/browser/x11-xer-evidence.mjs @@ -6,11 +6,16 @@ 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, @@ -22,11 +27,18 @@ 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 `${new Date().toISOString().replace(/[^0-9TZ]/g, '')}-${process.pid}`; + return `x11-${randomUUID()}`; +} + +function fingerprint(value) { + return `sha256-${createHash('sha256').update(String(value)).digest('hex')}`; } function isExplicitCiMode() { @@ -77,14 +89,25 @@ export function assertGitEvidenceUnchanged(before, after) { export function assertMetadataGitIdentity(metadata, expected) { const observed = metadata.git; - if (observed?.toplevel !== expected.toplevel || observed?.branch !== expected.branch || - observed?.head !== expected.head || observed?.base !== expected.base || - observed?.commitParent !== expected.commitParent || - observed?.statusPorcelainV1 !== expected.statusPorcelainV1) { + 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); @@ -95,10 +118,14 @@ async function readableFile(path, label) { async function preflight() { const scenario = process.env.OPS_XER_X11_SCENARIO?.trim() || 'small-a'; - if (!['small-a', MULTI_DOCUMENT_SCENARIO].includes(scenario)) { + if (!['small-a', MULTI_DOCUMENT_SCENARIO, RECOVERY_SCENARIO, LARGE_RESOURCE_SCENARIO].includes(scenario)) { fail(`onbekend OPS_XER_X11_SCENARIO: ${scenario}`); } - const corpusFile = scenario === MULTI_DOCUMENT_SCENARIO ? MULTI_DOCUMENT_CORPUS_FILE : DEFAULT_CORPUS_FILE; + 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'); @@ -109,7 +136,7 @@ async function preflight() { ].find((candidate) => candidate && existsSync(candidate)); if (isExplicitCiMode()) { - console.log('SKIP: expliciete CI-modus (OPS_XER_X11_CI=1)'); + console.error('SKIP: expliciete CI-modus (OPS_XER_X11_CI=1)'); return null; } @@ -217,6 +244,20 @@ async function startDevServer() { } } +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 }; @@ -324,6 +365,14 @@ async function installDevBridgeOpenGate(page) { ? 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], @@ -333,7 +382,11 @@ async function installDevBridgeOpenGate(page) { ['danglingBaselineReferences', Number.isInteger(rawReport?.danglingBaselineReferences) ? rawReport.danglingBaselineReferences : null], ]); return dataRecord([ - ['documents', dataRecord([['count', Array.isArray(live.documents) ? live.documents.length : 0]])], + ['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)], @@ -343,15 +396,30 @@ async function installDevBridgeOpenGate(page) { ['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], @@ -721,6 +789,451 @@ async function runBrowserMultiDocumentHelp(page, preflightResult, bridgeGateSetu }; } +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 }; @@ -762,6 +1275,20 @@ async function runBrowserSmallA(preflightResult, server) { 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)), @@ -855,7 +1382,8 @@ async function main() { const preflightResult = await preflight(); if (!preflightResult) return; await assertNoDirectPathOpen(); - const evidenceDir = resolve(EVIDENCE_ROOT, runId()); + const evidenceId = runId(); + const evidenceDir = resolve(EVIDENCE_ROOT, evidenceId); const dependencies = await ensureDependencies(); let serverHandle = null; let browserEvidence = null; @@ -885,51 +1413,124 @@ async function main() { if (runError) throw runError; if (!serverHandle || !browserEvidence) fail('browser- of serverbewijs ontbreekt na de run'); + const serverSummary = privacySafeServerSummary(serverHandle.server); const metadata = { - runId: evidenceDir.split('/').pop(), + runId: evidenceId, scenario: preflightResult.scenario, - git: { - toplevel: gitStart.toplevel, - branch: gitStart.branch, - head: gitStart.head, - base: gitStart.base, - commitParent: gitStart.commitParent, - statusPorcelainV1: gitStart.statusPorcelainV1, - statusUnchangedAfterCleanup: true, - }, - server: { - url: serverHandle.server.assignedUrl, - pid: serverHandle.server.pid, - cwd: serverHandle.server.procCwd, - httpStatus: 200, - guard: serverHandle.server.guard, + git: metadataGitIdentity(gitStart), + server: serverSummary, + browser: { + headless: browserEvidence.headless, + visible: browserEvidence.headless === false, + executableVerified: true, + wayland: browserEvidence.launchArgs.includes('--ozone-platform=wayland'), }, - browser: { executablePath: preflightResult.chromiumPath, headless: browserEvidence.headless, launchArgs: browserEvidence.launchArgs }, dom: preflightResult.scenario === MULTI_DOCUMENT_SCENARIO ? { tabCount: 12, helpTitle: browserEvidence.state.help.articleHeading } - : { text: browserEvidence.domText }, + : 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, - screenshot: 'imported-redacted.png', - toastScreenshot: { file: 'xer-toast.png', box: browserEvidence.toastBox }, - helpScreenshot: browserEvidence.helpScreenshot ? 'help-redacted.png' : null, + 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); - await writeFile(resolve(evidenceDir, 'xer-toast.png'), browserEvidence.toastScreenshot); + 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-output.txt'), serverHandle.server.output, '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'); - const label = preflightResult.scenario === MULTI_DOCUMENT_SCENARIO ? 'FASE-2A' : 'SMALL-A'; - console.log(`${label} OK: zichtbare importasserties geslaagd; evidence=${evidenceDir}`); + 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)) { - main().catch((error) => { + runWithCapturedStdout().catch((error) => { console.error(`X11-harnas rood: ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 1; }); From d6fac50fa7ed50f3bc50f74ab7c0e9f2e7c46717 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:31:41 +0200 Subject: [PATCH 105/171] Integreer gids voor XER-exportmeldingen --- public/docs/en/gids-xer-import.md | 2 +- public/docs/nl/gids-xer-import.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/docs/en/gids-xer-import.md b/public/docs/en/gids-xer-import.md index ff9e6d98b..14b8cbea0 100644 --- a/public/docs/en/gids-xer-import.md +++ b/public/docs/en/gids-xer-import.md @@ -52,7 +52,7 @@ Some P6 concepts are already retained but do not yet have a fully equivalent sch - 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. -These limits do not remove source data. When XER-specific source data is present, the export code returns a typed loss result for CSV, MS Project XML or Primavera P6 XML. The current interface does not yet show that result as a separate notification; saving as IFC retains the source data. +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 diff --git a/public/docs/nl/gids-xer-import.md b/public/docs/nl/gids-xer-import.md index 89afbeed0..b6d69f1f5 100644 --- a/public/docs/nl/gids-xer-import.md +++ b/public/docs/nl/gids-xer-import.md @@ -52,7 +52,7 @@ Een paar P6-begrippen zijn al opgeslagen, maar hebben nog geen volledig gelijkwa - 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. -Deze grenzen verwijderen geen brongegevens. Als XER-specifieke brondata aanwezig is, levert de exportcode bij CSV, MS Project XML of Primavera P6 XML een getypeerd verliesresultaat. De huidige interface toont dat resultaat nog niet als afzonderlijke melding; opslaan als IFC bewaart de brondata. +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 From 8f09520ef7d0683549e7a29da2161326d2cc7b5b Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:10:24 +0200 Subject: [PATCH 106/171] =?UTF-8?q?docs(xer):=20eigenaarsbesluiten=20X-O6?= =?UTF-8?q?=E2=80=93X-O8,=20vierde=20whitelistbak=20en=20taaktypes-afsprak?= =?UTF-8?q?en=20(overname=202026-09-04)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit b9b189688e0e8ee4b78c2fe004edabfa0de802e9) --- .../plans/2026-08-20-plan-xer-p6-lezer.md | 85 +++++++++++++++++-- 1 file changed, 80 insertions(+), 5 deletions(-) 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 index 217613d80..c0f43a84f 100644 --- a/docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md +++ b/docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md @@ -4,7 +4,9 @@ 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`.* +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 @@ -112,9 +114,9 @@ robuustheidsbestanden en het 8-byte-DROID-skelet) tellen niet in de fidelity-poo (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: - `early_*`, `late_*`, `restart_date`/`reend_date` (15.328/15.329 gevulde cellen), - `rem_late_start_date`/`rem_late_end_date` (10.193 elk), `total_float_hr_cnt`/ - `free_float_hr_cnt`, `driving_path_flag`, `float_path`(`_order`), + `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 @@ -131,7 +133,14 @@ robuustheidsbestanden en het 8-byte-DROID-skelet) tellen niet in de fidelity-poo (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. - **Een corpus-%F-kolom die in géén van de drie bakken staat is een X0-poortfout, geen vrije + 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- @@ -216,6 +225,72 @@ robuustheidsbestanden en het 8-byte-DROID-skelet) tellen niet in de fidelity-poo 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. + 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)". ## §6 Banen en taken From 87dab10b0ce566aac4a36f43f1bf50dbd93339d1 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 2 Sep 2026 05:06:12 +0200 Subject: [PATCH 107/171] test: pin X12 productfidelity v2 karakterisering (cherry picked from commit a311faf76de1de7b04954c71b9a9c0faa0e44a25) --- .../check-xer-corpusless-fidelity-gate.ts | 243 +++++++++++++++++- .../check-xer-product-fidelity-x12.ts | 200 +++++++++++++- .../xer-product-fidelity-baseline.json | 95 ++----- tests/planning/xerProductFidelity.ts | 59 ++++- 4 files changed, 488 insertions(+), 109 deletions(-) diff --git a/tests/planning/check-xer-corpusless-fidelity-gate.ts b/tests/planning/check-xer-corpusless-fidelity-gate.ts index fb5713244..9e6a30277 100644 --- a/tests/planning/check-xer-corpusless-fidelity-gate.ts +++ b/tests/planning/check-xer-corpusless-fidelity-gate.ts @@ -8,15 +8,16 @@ * 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, de toekomstige 34-entry product-v2-meting, bestaat bewust nog niet. De huidige - * `xer-product-fidelity-baseline.json` is een v1-overgangsdossier met twee pins en redenen; - * deze check benoemt dat expliciet maar promoveert of hernoemt hem niet. `readXER`, scanner, + * 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'; const HERE = fileURLToPath(new URL('.', import.meta.url)); const AXES = ['es', 'ef', 'ls', 'lf', 'tf', 'ff'] as const; @@ -53,7 +54,44 @@ type ReplayPin = { tasks: number; candidates: Record; }; -type ProductV1 = { version: number; files: Record }; +type ProductCounts = { exact: number; sameday: number; diff: number; missing: number; measurable: number; deviations: number }; +type ProductProject = { + projectId: string; + truthTasks: number; + solvedTasks: number; + taskCodePresent: number; + taskCodeExact: number; + counters: Record; + drivingPath: ProductCounts; + identityErrors: string[]; +}; +type ProductEntry = { + sha256: string; + schemaFingerprint: string; + projects: number; + tasks: number; + identityCoverage: { solvedTasks: number; taskCodePresent: number; taskCodeExact: number }; + projectMeasurements: ProductProject[]; + counters: Record; + drivingPath: ProductCounts; + identityErrors: string[]; + scannerErrors: string[]; + gatePassed: boolean; +}; +type ProductV2 = { + version: number; + manifestSha256: string; + characterization: { finalZeroGate: string; accepted: boolean; openCategories: string[] }; + files: Record; +}; +type ProductEnvelope = { + version: number; + manifestSha256: string; + characterization: { finalZeroGate: string; accepted: boolean; openCategories: string[] }; + reportModes: string[]; + encoding: string; + payloadGzipBase64: string; +}; const EXPECTED = { manifestRawSha256: '6defbc4b4a71500565e5847750662060d9baca983952098dd1b334ac81d55786', @@ -74,6 +112,14 @@ const EXPECTED = { 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 }, + productStrict: { + exact: { es: 12_680, ef: 12_437, ls: 8_807, lf: 8_769, tf: 8_912, ff: 12_499 }, + sameday: { es: 96, ef: 97, ls: 129, lf: 93, tf: 0, ff: 0 }, + diff: { es: 1_155, ef: 1_403, ls: 4_886, lf: 4_951, tf: 4_765, ff: 823 }, + missing: { es: 0, ef: 0, ls: 0, lf: 0, tf: 0, ff: 0 }, + deviations: { es: 1_251, ef: 1_500, ls: 5_015, lf: 5_044, tf: 4_765, ff: 823 }, + drivingPath: { exact: 13_186, sameday: 0, diff: 410, missing: 0, measurable: 13_596, deviations: 410 }, + }, roles: { oracle: 45, 'engine-input': 14, @@ -91,6 +137,7 @@ const ALLOWED_STATIC_IMPORTS = new Set([ 'node:fs', 'node:path', 'node:url', + 'node:zlib', ]); const diffs: string[] = []; let checks = 0; @@ -343,6 +390,139 @@ function validateReplay(raw: unknown, oracle: OracleBaseline): string[] { return problems; } +function validProductCounts(value: unknown): value is ProductCounts { + const counts = asObject(value); + if (!counts) return false; + return ['exact', 'sameday', 'diff', 'missing', 'measurable', 'deviations'] + .every(key => isNonNegativeInteger(counts[key])); +} + +function addProductCounts(target: ProductCounts, source: ProductCounts): 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 emptyProductCounts(): ProductCounts { + return { exact: 0, sameday: 0, diff: 0, missing: 0, measurable: 0, deviations: 0 }; +} + +function decodeProductPayload(envelope: ProductEnvelope): ProductV2 { + return JSON.parse(gunzipSync(Buffer.from(envelope.payloadGzipBase64, 'base64')).toString('utf8')) as ProductV2; +} + +function withMutatedProduct(envelope: ProductEnvelope, mutate: (product: ProductV2) => void): ProductEnvelope { + const product = clone(decodeProductPayload(envelope)); + mutate(product); + return { ...envelope, payloadGzipBase64: gzipSync(JSON.stringify(product)).toString('base64') }; +} + +/** + * 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: unknown, manifest: Manifest, oracle: OracleBaseline): string[] { + const problems: string[] = []; + const envelope = raw as ProductEnvelope; + if (!asObject(raw) || envelope.version !== 2 || envelope.encoding !== 'gzip-base64-json' + || typeof envelope.payloadGzipBase64 !== 'string') { + return ['product-v2: ongeldige envelope of geen gzip-base64-payload']; + } + if (envelope.characterization?.finalZeroGate !== 'red' || envelope.characterization?.accepted !== false) { + issue(problems, 'product-v2: tijdelijke snapshot moet strict eindnulpoort zichtbaar rood en ongeaccepteerd houden'); + } + if (JSON.stringify(envelope.characterization?.openCategories) !== JSON.stringify([ + 'strict-six-axis-deviations', 'strict-sameday-deviations', 'driving-path-report-only', + ])) issue(problems, 'product-v2: open categorieen zijn niet de expliciete strict-overgangscategorieen'); + if (JSON.stringify(envelope.reportModes) !== JSON.stringify([ + 'strict-minute-exact', 'historical-completed-late', 'source-day-precision', + ])) issue(problems, 'product-v2: strict en de twee tegenfeitelijke rapportmodi zijn verwisseld of incompleet'); + let product: ProductV2; + try { + product = decodeProductPayload(envelope); + } catch { + return [...problems, 'product-v2: gzip-payload is niet volledig decodeerbaar JSON']; + } + equal(problems, 'product-v2.payload-versie', product.version, 2); + equal(problems, 'product-v2.manifesthash', product.manifestSha256, rawHash(manifestRaw)); + equal(problems, 'product-v2.envelope-payload-manifesthash', envelope.manifestSha256, product.manifestSha256); + equal(problems, 'product-v2.karakterisering', product.characterization, envelope.characterization); + if (JSON.stringify(product).includes('"reason"')) issue(problems, 'product-v2: reasons zijn verboden in de strict-karakteriseringssnapshot'); + + const oracleByLabel = new Map(Object.values(oracle.files).map(entry => [entry.label, entry])); + const labels = Object.keys(product.files).sort(); + equal(problems, 'product-v2.entryset', labels, [...oracleByLabel.keys()].sort()); + const totals = Object.fromEntries(AXES.map(axis => [axis, emptyProductCounts()])) as Record; + const driving = emptyProductCounts(); + let projects = 0; + let tasks = 0; + for (const label of labels) { + const entry = product.files[label]!; + const manifestEntry = manifest.files[label]; + const oracleEntry = oracleByLabel.get(label); + if (!manifestEntry || !oracleEntry) continue; + if (!HEX_64.test(entry.sha256)) issue(problems, `product-v2 ${label}: volledige SHA ontbreekt`); + equal(problems, `product-v2 ${label}: manifest-SHA`, entry.sha256, manifestEntry.sha256); + equal(problems, `product-v2 ${label}: schemafingerprint`, entry.schemaFingerprint, oracleEntry.schemaFingerprint); + if (entry.projects !== entry.projectMeasurements.length) issue(problems, `product-v2 ${label}: projectnoemer wijkt af van projectrecords`); + if (entry.tasks !== entry.projectMeasurements.reduce((sum, project) => sum + project.truthTasks, 0)) { + issue(problems, `product-v2 ${label}: taaknoemer wijkt af van projectrecords`); + } + if (entry.identityCoverage.solvedTasks !== entry.tasks + || entry.identityCoverage.taskCodePresent !== entry.tasks || entry.identityCoverage.taskCodeExact !== entry.tasks) { + issue(problems, `product-v2 ${label}: taskcode-identiteitsdekking is niet volledig`); + } + const projectCounters = Object.fromEntries(AXES.map(axis => [axis, emptyProductCounts()])) as Record; + const projectDriving = emptyProductCounts(); + const projectIds = new Set(); + for (const project of entry.projectMeasurements) { + if (!project.projectId || projectIds.has(project.projectId)) issue(problems, `product-v2 ${label}: project-id ontbreekt of dupliceert`); + projectIds.add(project.projectId); + if (project.truthTasks !== project.solvedTasks || project.taskCodePresent !== project.truthTasks + || project.taskCodeExact !== project.truthTasks || project.identityErrors.length !== 0) { + issue(problems, `product-v2 ${label}/${project.projectId}: projectidentiteitsdekking is niet exact`); + } + for (const axis of AXES) { + if (!validProductCounts(project.counters[axis])) { issue(problems, `product-v2 ${label}/${project.projectId}.${axis}: ongeldige teller`); continue; } + addProductCounts(projectCounters[axis], project.counters[axis]); + } + if (!validProductCounts(project.drivingPath)) issue(problems, `product-v2 ${label}/${project.projectId}.driving: ongeldige teller`); + else addProductCounts(projectDriving, project.drivingPath); + } + for (const axis of AXES) { + if (!validProductCounts(entry.counters[axis])) issue(problems, `product-v2 ${label}.${axis}: ongeldige entryteller`); + else { + equal(problems, `product-v2 ${label}.${axis}: projectsom`, projectCounters[axis], entry.counters[axis]); + if (entry.counters[axis].exact + entry.counters[axis].sameday + entry.counters[axis].diff + entry.counters[axis].missing !== entry.counters[axis].measurable + || entry.counters[axis].sameday + entry.counters[axis].diff + entry.counters[axis].missing !== entry.counters[axis].deviations) { + issue(problems, `product-v2 ${label}.${axis}: telleralgebra is inconsistent`); + } + addProductCounts(totals[axis], entry.counters[axis]); + } + } + if (!validProductCounts(entry.drivingPath)) issue(problems, `product-v2 ${label}.driving: ongeldige entryteller`); + else { equal(problems, `product-v2 ${label}.driving: projectsom`, projectDriving, entry.drivingPath); addProductCounts(driving, entry.drivingPath); } + if (entry.identityErrors.length !== 0 || entry.scannerErrors.length !== 0) issue(problems, `product-v2 ${label}: identity/scanner moet nul zijn`); + projects += entry.projects; + tasks += entry.tasks; + } + equal(problems, 'product-v2.projecten', projects, EXPECTED.projects); + equal(problems, 'product-v2.taken', tasks, EXPECTED.tasks); + for (const axis of AXES) { + equal(problems, `product-v2 ${axis}.measurable`, totals[axis].measurable, EXPECTED.measurable[axis]); + for (const field of ['exact', 'sameday', 'diff', 'missing', 'deviations'] as const) { + equal(problems, `product-v2 ${axis}.${field}`, totals[axis][field], EXPECTED.productStrict[field][axis]); + } + } + equal(problems, 'product-v2.driving', driving, EXPECTED.productStrict.drivingPath); + if (AXES.every(axis => totals[axis].deviations === 0)) issue(problems, 'product-v2: karakterisering claimt onverwacht strict nul zonder eigenaarsbesluit'); + return problems; +} + function checkProblem(problems: string[], label: string, condition: boolean): void { if (!condition) issue(problems, label); } @@ -455,7 +635,7 @@ function validateOwnSource(raw: string): string[] { importModules.push(match[1]!); } - equal(problems, 'bron.statische-import-aantal', importModules.length, 4); + equal(problems, 'bron.statische-import-aantal', importModules.length, 5); 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)) { @@ -484,15 +664,21 @@ function expectRejected(label: string, manifest: Manifest, oracle: OracleBaselin if (problems.length === 0) diffs.push(`${label}: mutant werd ten onrechte geaccepteerd`); } +function expectProductRejected(label: string, envelope: ProductEnvelope, manifest: Manifest, oracle: OracleBaseline): void { + checks++; + const problems = validateProductV2(envelope, manifest, oracle); + if (problems.length === 0) diffs.push(`${label}: product-v2-mutant werd ten onrechte geaccepteerd`); +} + 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 productV1Raw = readFileSync(join(HERE, 'xer-product-fidelity-baseline.json'), 'utf8'); +const productV2Raw = readFileSync(join(HERE, 'xer-product-fidelity-baseline.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 productV1 = JSON.parse(productV1Raw) as ProductV1; +const productV2 = JSON.parse(productV2Raw) as ProductEnvelope; // 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 @@ -530,12 +716,9 @@ const productV1 = JSON.parse(productV1Raw) as ProductV1; checkEqual(`A+B+D huidige statische contracten zijn consistent (${problems.join('; ')})`, problems, []); checkEqual('A raw manifestbytes zijn exact gepind', rawHash(manifestRaw), EXPECTED.manifestRawSha256); checkEqual('B raw oraclebaselinebytes zijn exact gepind', rawHash(oracleRaw), EXPECTED.baselineRawSha256); - checkEqual('C bestaat nog niet: productbaseline blijft expliciet v1-tweepinsdossier', { - version: productV1.version, - entries: Object.keys(productV1.files ?? {}).length, - reasons: Object.values(productV1.files ?? {}).every(entry => typeof entry.reason === 'string' && entry.reason.length > 0), - }, { version: 1, entries: 2, reasons: true }); - console.log('INFO xer-corpusless-fidelity-gate: product-v2 ontbreekt bewust; v1-tweepinsbaseline is geen X12-eindcontract.'); + const productProblems = validateProductV2(productV2, manifest, oracle); + checkEqual(`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. @@ -630,6 +813,40 @@ const productV1 = JSON.parse(productV1Raw) as ProductV1; negativeWithoutRegression.candidates['drop-p6-finish-milestone-boundary']!.aggregate[axis].regressed = 0; } expectRejected('M19 replay negatieve kandidaat zonder regressie', manifest, oracle, negativeWithoutRegression); + + const firstProductLabel = Object.keys(decodeProductPayload(productV2).files)[0]!; + const v1AsFinal = clone(productV2); + v1AsFinal.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 drivingAsGate = clone(productV2); + drivingAsGate.characterization.openCategories[2] = 'driving-path-zero-gate'; + expectProductRejected('M26 drivingPath per ongeluk in nulpoort', drivingAsGate, manifest, oracle); + + const swappedModes = clone(productV2); + [swappedModes.reportModes[0], swappedModes.reportModes[1]] = [swappedModes.reportModes[1]!, swappedModes.reportModes[0]!]; + expectProductRejected('M27 strict en counterfactual verwisseld', swappedModes, manifest, oracle); } if (diffs.length === 0) { diff --git a/tests/planning/check-xer-product-fidelity-x12.ts b/tests/planning/check-xer-product-fidelity-x12.ts index 0175f88f0..2e6a27140 100644 --- a/tests/planning/check-xer-product-fidelity-x12.ts +++ b/tests/planning/check-xer-product-fidelity-x12.ts @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'; import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { gunzipSync } from 'node:zlib'; import { CalendarEngine } from '@/engine/scheduler/CalendarEngine'; import { solveProject } from '@/engine/scheduler/solveProject'; import { @@ -16,11 +17,16 @@ 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 { measureXerProductFidelity, type XerProductAxisCounts } from './xerProductFidelity'; +import { + measureXerProductFidelity, + type XerProductAxisCounts, + type XerProductMeasurement, + type XerProductProjectMeasurement, +} from './xerProductFidelity'; const HERE = fileURLToPath(new URL('.', import.meta.url)); const REPORT = process.env.OPS_XER_FIDELITY_REPORT; -const REPORT_MODES = new Set(['baseline', 'detail', 'summary']); +const REPORT_MODES = new Set(['baseline', 'detail', 'summary', 'counterfactuals']); const diffs: string[] = []; let checks = 0; @@ -29,13 +35,55 @@ interface ProductBaselineEntry { schemaFingerprint: string; projects: number; tasks: number; + identityCoverage: { + solvedTasks: number; + taskCodePresent: number; + taskCodeExact: number; + }; + projectMeasurements: XerProductProjectMeasurement[]; counters: Record; drivingPath: XerProductAxisCounts; identityErrors: string[]; scannerErrors: string[]; gatePassed: boolean; } -interface ProductBaseline { version: 2; manifestSha256: string; files: Record; } +interface ProductBaseline { + version: 2; + manifestSha256: string; + /** Geen acceptatiebaseline: de strict-nulpoort blijft aantoonbaar rood tot een eigenaarsbesluit. */ + characterization: { + finalZeroGate: 'red'; + accepted: false; + openCategories: readonly ['strict-six-axis-deviations', 'strict-sameday-deviations', 'driving-path-report-only']; + }; + files: Record; +} + +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. */ +function readProductBaseline(): ProductBaseline { + const raw = JSON.parse(readFileSync(join(HERE, 'xer-product-fidelity-baseline.json'), 'utf8')) as Record; + if (raw.encoding !== 'gzip-base64-json' || typeof raw.payloadGzipBase64 !== 'string' + || JSON.stringify(raw.reportModes) !== JSON.stringify([ + 'strict-minute-exact', 'historical-completed-late', 'source-day-precision', + ])) { + throw new Error('X12 productbaseline is geen v2 gzip-base64 karakteriseringssnapshot'); + } + const payload = JSON.parse(gunzipSync(Buffer.from(raw.payloadGzipBase64, 'base64')).toString('utf8')) as ProductBaseline; + if (payload.version !== 2 || payload.characterization.finalZeroGate !== 'red' || payload.characterization.accepted !== false) { + throw new Error('X12 productbaseline claimt ten onrechte een geaccepteerde of groene eindpoort'); + } + return payload; +} function eq(label: string, got: unknown, want: unknown): void { checks++; @@ -62,6 +110,15 @@ function fiveDayCalendarData(start: string, finish: string): string { 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; @@ -141,6 +198,109 @@ function solveProductProjects(imports: readonly ImportResult[]): XerSolvedProjec 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); + return [ + { mode: 'historical-completed-late', strictGateEligible: false, ...summarizeMeasurement(historical) }, + { mode: 'source-day-precision', strictGateEligible: false, ...sourceDayPrecisionReport(strict) }, + ]; +} + +// 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, @@ -155,7 +315,8 @@ async function productBaseline( const opened = readXER(file.bytes); const imports = isMultiDocumentImport(opened) ? opened.taskProjects.map(document => document.result) : [opened]; const solvedProjects = solveProductProjects(imports); - const result = measureXerProductFidelity(scanXerGroundTruth(file.bytes), solvedProjects); + 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'); @@ -278,16 +439,38 @@ async function productBaseline( 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') { + console.log(JSON.stringify({ + label: targetEntry.label, + strict: { mode: 'strict-minute-exact', strictGateEligible: true, ...summarizeMeasurement(result) }, + counterfactuals: counterfactualReports(result, truth, imports, solvedProjects), + })); + } files[targetEntry.label] = { sha256: hash(file.bytes), 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 { version: 2, manifestSha256: hash(readFileSync(join(HERE, 'xer-corpus-manifest.json'))), files }; + return { + 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. @@ -1955,14 +2138,14 @@ else { const manifest = JSON.parse(readFileSync(join(HERE, 'xer-corpus-manifest.json'), 'utf8')) as XerCorpusManifest; const measured = await productBaseline(corpus, manifest); if (REPORT === 'baseline') console.log(JSON.stringify(measured, null, 2)); - else if (REPORT === 'summary' || REPORT === 'detail') { + 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: ${entries.length} entries; ${projects} projecten; ${tasks} taken; ${deviations} zesassige afwijkingen; ${identityErrors} identiteitsfouten; ${scannerErrors} scannerfouten`); + 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); @@ -1979,8 +2162,7 @@ else { 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', - JSON.parse(readFileSync(join(HERE, 'xer-product-fidelity-baseline.json'), 'utf8')) as unknown, measured); + eq('X12 productbaseline is de verse volledige productmeting', readProductBaseline(), measured); } } if (diffs.length > 0) { diff --git a/tests/planning/xer-product-fidelity-baseline.json b/tests/planning/xer-product-fidelity-baseline.json index 30a32fc09..7a2460ab0 100644 --- a/tests/planning/xer-product-fidelity-baseline.json +++ b/tests/planning/xer-product-fidelity-baseline.json @@ -1,81 +1,20 @@ { - "version": 1, - "cellTransitions": { - "version": 1, - "files": { - "568c19375b4e0d674c75e6aea023c98772fb33e6896755f866e4641a56197300": { - "previouslyExact": [ - ["1", "1000", "es"], ["1", "1000", "ef"], ["1", "1000", "tf"], ["1", "1000", "ff"], - ["1", "1010", "es"], ["1", "1010", "ls"], ["1", "1010", "ff"], - ["1", "1020", "es"], ["1", "1020", "ls"], ["1", "1020", "ff"], - ["1", "2000", "es"], ["1", "2000", "ls"], ["1", "2000", "tf"], ["1", "2000", "ff"], - ["1", "2010", "es"], ["1", "2010", "ls"], ["1", "2010", "tf"], ["1", "2010", "ff"], - ["1", "2020", "tf"], ["1", "2020", "ff"], - ["1", "3000", "tf"], ["1", "3000", "ff"], - ["1", "3010", "tf"], ["1", "3010", "ff"] - ], - "improvements": [ - { "cell": ["1", "2020", "es"], "old": { "bucket": "diff", "value": "2026-05-08T12:00" }, "current": { "bucket": "exact", "value": "2026-05-11T08:00" } }, - { "cell": ["1", "3000", "es"], "old": { "bucket": "diff", "value": "2026-05-14T12:00" }, "current": { "bucket": "exact", "value": "2026-05-15T08:00" } }, - { "cell": ["1", "1010", "ef"], "old": { "bucket": "diff", "value": "2026-04-07T16:00" }, "current": { "bucket": "exact", "value": "2026-04-07T17:00" } }, - { "cell": ["1", "1020", "ef"], "old": { "bucket": "diff", "value": "2026-04-14T16:00" }, "current": { "bucket": "exact", "value": "2026-04-14T17:00" } }, - { "cell": ["1", "2000", "ef"], "old": { "bucket": "diff", "value": "2026-04-28T16:00" }, "current": { "bucket": "exact", "value": "2026-04-28T17:00" } }, - { "cell": ["1", "2010", "ef"], "old": { "bucket": "diff", "value": "2026-05-08T12:00" }, "current": { "bucket": "exact", "value": "2026-05-08T17:00" } }, - { "cell": ["1", "2020", "ef"], "old": { "bucket": "diff", "value": "2026-05-14T12:00" }, "current": { "bucket": "exact", "value": "2026-05-14T17:00" } }, - { "cell": ["1", "3000", "ef"], "old": { "bucket": "diff", "value": "2026-05-18T12:00" }, "current": { "bucket": "exact", "value": "2026-05-18T17:00" } }, - { "cell": ["1", "2020", "ls"], "old": { "bucket": "diff", "value": "2026-05-08T12:00" }, "current": { "bucket": "exact", "value": "2026-05-11T08:00" } }, - { "cell": ["1", "3000", "ls"], "old": { "bucket": "diff", "value": "2026-05-14T12:00" }, "current": { "bucket": "exact", "value": "2026-05-15T08:00" } }, - { "cell": ["1", "1010", "lf"], "old": { "bucket": "diff", "value": "2026-04-07T16:00" }, "current": { "bucket": "exact", "value": "2026-04-07T17:00" } }, - { "cell": ["1", "1020", "lf"], "old": { "bucket": "diff", "value": "2026-04-14T16:00" }, "current": { "bucket": "exact", "value": "2026-04-14T17:00" } }, - { "cell": ["1", "2000", "lf"], "old": { "bucket": "diff", "value": "2026-04-28T16:00" }, "current": { "bucket": "exact", "value": "2026-04-28T17:00" } }, - { "cell": ["1", "2010", "lf"], "old": { "bucket": "diff", "value": "2026-05-08T12:00" }, "current": { "bucket": "exact", "value": "2026-05-08T17:00" } }, - { "cell": ["1", "2020", "lf"], "old": { "bucket": "diff", "value": "2026-05-14T12:00" }, "current": { "bucket": "exact", "value": "2026-05-14T17:00" } }, - { "cell": ["1", "3000", "lf"], "old": { "bucket": "diff", "value": "2026-05-18T12:00" }, "current": { "bucket": "exact", "value": "2026-05-18T17:00" } } - ] - }, - "c872c9e704797d829205f3c5486e7c4cd5aec729143a63fbcf8b0ec8e8864a3c": { - "previouslyExact": [ - ["1", "1000", "tf"], ["1", "1000", "ff"], - ["1", "1010", "ff"], - ["1", "1020", "ff"], - ["1", "1099", "es"], - ["1", "2010", "tf"], ["1", "2010", "ff"], - ["1", "2020", "tf"], ["1", "2020", "ff"], - ["1", "3000", "tf"], ["1", "3000", "ff"], - ["1", "3010", "tf"], ["1", "3010", "ff"] - ], - "improvements": [ - { "cell": ["1", "1099", "ef"], "old": { "bucket": "diff", "value": "2026-04-16T16:00" }, "current": { "bucket": "exact", "value": "2026-04-16T17:00" } } - ] - } - } + "version": 2, + "manifestSha256": "6defbc4b4a71500565e5847750662060d9baca983952098dd1b334ac81d55786", + "characterization": { + "finalZeroGate": "red", + "accepted": false, + "openCategories": [ + "strict-six-axis-deviations", + "strict-sameday-deviations", + "driving-path-report-only" + ] }, - "files": { - "crawl-xer/p6diff-baseline.xer": { - "tasks": 8, - "projects": 1, - "counters": { - "es": { "deviations": 1, "measurable": 8 }, - "ef": { "deviations": 1, "measurable": 8 }, - "ls": { "deviations": 2, "measurable": 8 }, - "lf": { "deviations": 2, "measurable": 8 }, - "tf": { "deviations": 2, "measurable": 8 }, - "ff": { "deviations": 0, "measurable": 8 } - }, - "reason": "X4a-overgangsmeting na X12/P6-werkbandnormalisatie: zestien zesassige cellen landen exact op het openbare P6-orakel; opgeslagen P6-uitvoer blijft verboden solverinvoer." - }, - "crawl-xer-extra/p6difftool/sample-target.xer": { - "tasks": 8, - "projects": 1, - "counters": { - "es": { "deviations": 7, "measurable": 8 }, - "ef": { "deviations": 7, "measurable": 8 }, - "ls": { "deviations": 8, "measurable": 8 }, - "lf": { "deviations": 8, "measurable": 8 }, - "tf": { "deviations": 3, "measurable": 8 }, - "ff": { "deviations": 1, "measurable": 8 } - }, - "reason": "X4a-overgangsmeting na X12/P6-werkbandnormalisatie: één EF-cel landt exact op het openbare P6-orakel zonder asregressie; overige afwijkingen blijven bewust zichtbaar." - } - } + "reportModes": [ + "strict-minute-exact", + "historical-completed-late", + "source-day-precision" + ], + "encoding": "gzip-base64-json", + "payloadGzipBase64": "H4sIAAAAAAAAA+2dWZPbyJHH3+dTMPRsruo+5m1mPLZnw14rNN4j1uFQ1Clxp9XsJdnyzDr83TcoqVs46gCqADbYgwdFiA2SyD8KrMofMivzH19tNi8+uMNxt7998fUG/eb8+r263Xl3PP34TiHKXny9ecGs89oQTRSHFADKqKOCcE4BYwgwYKVWRkmBJUVACmuhxpgoI6CllAv24uP3mnfqoMzJHXb/p06fTviPrzabzeaF392qm/92h/3v1cmdT3hw9uNnNpsXyhh3d3L2xdcbr26O7vOf93fu9jt1cm/3h507vvh689ePf99sXhxPh505bY+7n7fq591xa92H3cfzHT9/ZeM96r2z6pfgW+xh92F3+3Z7p07vtgd3tz+ctvvbm19efHzD377abP75UZXf3Xw8/2cp5qD+frP92R227ufTQb1Ux3fHO2Xc9u6we+8+uIP62R2c3Z32h5dH9f7uxv3Lz+7w+PmzbV8uuzCOeqA10hQTS7WCEjmsrEAEAkQtJ8oR6iRBQHoIOYaWIIuRh8ILpBt6zTv3Xv1ud/vWHe4Ou9vT+esl90o6BT3F2CgGvrz97rD/H2dOZ1nw8Y8ndfzp/BeKHv+0s+72tDv98t3+LOyta8g4n3R/88HZv/Q+9vm7vttb9+rgju6jNcHD3/+szKeDn4/9s2vin5w63h/ce3d7at4Em4YdX978w/kmekEoEC9+0zx8Otyf3oXsTIrIyggKaR02+/vbkzscW9ft4xHX/9v5r8Gv+WTnp1v5xdcb0Dtmd94HD7zfHY+727fhYx+vrNI3LnzCxm/m6w1oHf3nbzpi/DMSc/OcRuYmOTJElIgh84khSTGn53Sb+asW03jVEvawqr5Sp3eBSe/hvuucLK7tQRns/j0qLC2rLQqCqI6Hle/7w2H/cf7+698eDz+o/9vjYhWc5/tzfGR4Y/KDwxpTntIdG8qG5P4cvmRje3P0oo2NXtn2/JszlkxrLAka25tjl3xle3Po4oztubWx+TE4N4btDs2JYaPjJkfmwS92Bua/Bmyo21t3CB16q07ulToev+DcV43v7cGTuVH3R7d1d+bln75/vYVbBBB7c39n1cm9Of9/C+gWwDhBUcuhU4Y4DjSwkmJGLWWYW+C4FJwwwTzHGFnoJFASS4MERAQxYLn0hGcISjvrtLJEUcE41kMJCiLQu5BDEKrxuSB8hI8/sAdE/WEspygIAExQVMuUtJC8lLCYaUhKljhSDPaODPakOpZver82BpOOYRqmCC/Rw/GMejhO6snwVBGCsL63OuH4oLSe9PiwovGpYKr8+NRQVf/GGSAHwv5dOp0eCHlSUJqs+l8+L1hlxEzKVV0UymJVIVX1vi2m6KJM1Zrpcy5fe4Yf5vN116TYrD4Mq9ozec7e9gw+gb2NWXsgWY2ClfYMPcX1RWF749eXjbq+BXSVvr5j+QqPMbczw05gb3NWHcZYYIzBBYiVMLaKsJo+dBKwRvJV6xtC1l6art4e9vd3dm+OW7O//RwEzMakNHeeWUGV8c5aBZWwWHGOJaQSUcEtNgoDIQDEWkhmAfYWIqkRtgILTDJE5bzHCjukKdVeQz6QqHgZUPHmmPQhJHj4gUD4pDSVQCne+SFFFWQ1BFVMglEpr4n3D9a7TaEvbU2vNRGpa1OTRqirU/OsxiaNT4Evnxc3cmpqglJLF9N4dQl46lo3lJ56n4vfbpfkp5g711lByv257hdFVA+jpyVaG2enRVp7Vdc2zk1d92lyDklZOzYutRRje67tdNTUcqoHYFPr/eFb4dLg9D9qd6O8//iX493N7nRyh5cH907pLYqTEzLQGgc51dIDLiCEighr4BmQOOeeem2JsVxpxhB2jHDHCeYYA8OA5j5DTohIjIm0FhLPvcYDyYlJznuXbwg7NT8YJI/IGx7I43y4N37lBMVZiqHatmS0DFAT0TMJSzFcGPSA/Y8NdqWY5P3IROcBpkj6hmmkYpAVhQoErAhN5UUJWBOcQqFLPiRDLjTCE6o6f39aVnKsEENFUSqCaYUzP0hWDWshHjjBEFkIzCsLgRrqYhQU/bJITdB3gKpu2LfxqoC+mJCdn8wAAOvE9ocCWHcm7/kdMqplXgTrrgz5IFb77YM8xO7QxleBYSTWnflzRndm/CmMbs7yw4CsO7Nnc++6IzOB1a1ZfBiZdWfuAWYXMETW7LGI1p2Zs2Z3ZuRJzG7OwsNgrTvzZs0uCdJmrG6GaXvedBba2rNqhtsas+kQbuu4/+EZ9NLk9t4ddnanbnf/+9K69/ttLtoFCXPECEalVhYwppEUUggGsXUGcmEcwx4rZDAlkErMNbYcKOGF8wJSkWE2LqSl1CLEgKJAi4HMhsuiXbg5Vn3CCR5+oBs8ZbTr1es//+s2nT+I2z+JuJCslKCYSUCt0Esu97pw4EuHb49JAxoqciIr8rhyYtJJXGkwg/2LPCQtrT+ek6mBLC0nOTYBBh4iZ8bByaTYpUGsDJphRU5nVk46pTMNYKhoL1bFA4Ccmg77N14VgFf3sg/Yi9UFtYHY1ZXVGSIR1TEvdI30qMd7eJ3VaMAmnDhsoVEOaUGOWMrYcIJYHLLas3Q2pY1Na21jRh4GV3BcBt7EFzeSfheHqnEACwvyMZPmhtMx4zCFRu3IKoDtlLUNzu65uVmIgsN3ZDVhawBCtTzy8Gz4hAD1iZ0+78ZKxL0o9tRY7TT22krDjEYUKSIMUlYiwDz0SGlCEKSYIy8wlNJ6rCQQAHtvcxmD1DkmmIOeU2aZH8pQsHflBjEUbP74+uAROvyIHbA3aPMyVDuHPS4kKyUoZhqGoiXOU42j3t+61X7AmfQEMwxV5An2d6BNJkYmxWSCW2XxuvnEVIW0+ucetE9pPjUwvcsvE8oqIqiKeE9OTTqKmuanAM4MGZsZh6YtpvGqhJ86J8vzU/deHcxP3TO1RKGnyhtsz/DZkFWBY9deiSKz+UB+GuWFymmNlUFjE0GqcXG1aY0dHZoaldkGC7bfpayF4d13iZDUKHoqiOukrA1HK+Ps1H1Ynrm2E1/a3lwxhp2a/nKSnVqlDYawU/ObwzPhpdnpjp21nPb7m8/stD2pw1t3iqOTERwZ6TggXHIrkESAemwoEcxxQ4ylyhmOJCRYMey18UIDZ4QTghGFTQadAGdMIC+kZMZ5zgaik+hdtyHk1GTfPm2Ejj6gRp9159lo1fnJx6zP2R9SMA0rXRiV+k7mdKR0TVIysaYSKRWhmYyUTNgsHZi5JilpQioKAFaEZjNS0pHZTIJfiZSKYFlGSidU1nhVUulvLBx1PzCQjbohqejgXJSMZgaj9qpTx0VLMzURVRpjakGQJmFqJAAWD9EszdQ4D40K1RUEQROmhmOgibS8MaYWhL0SpjaCXj2fNV/ZbygKNd84gISarnXwwl6cg27U7e25tPphd/xpu7s9uZub3Vt3e3oIKZ1hxd4n8/K0EsZDISiR0GpsoHTaeyPOm6gwlphiQp2XznuNBVPCKowRYo4x6qXKVUZnBMNz3XmEsRIMuIFgVFjWL1PVL1nUb9qafgk4Stbzy5bzy1Tzm6iYX9lT/oA7NNRVypUiqylCEUjf+iIm8AS72u/LianZKQWLyixWPOPPFolLi0mPTCpmGR2ZCrjIiaFJMZlkvEsnsebE1KBS8jczR1xs5G+m8Wot3zcSlkY94+64b0PculTxs7HVJ2Csdh8MGVvgg6aMHbvXCY4qjFgQPUiWlQsbG7+ysWhi8MoWgEjKWBo0NpGCN3fqaMrYsdgUvWeningNvGd7/uxasO8BnB646MPueK9udkd3ePnjJ2J69cmNjwOTdUobzjDhTjkPKUfcEwwkhUooqCXl1lKlAaLQGkIBcJAaAbkSDuDWzqQgMFHmrUbcAACVFBwNBSbSu4KDgKlJwAHYCB1+RI0+9VYVQQcpZuo8NYmKyMoICpmGmcr2llS4TH3fcYRnPscmpicTM0cC3tOJeU4jk0m/K4ou1TxnyIipqR9R9tCkBgAzYjoA2HhVwEzdk80GTb0TLYOaxu1ZKXDq2qvQAN9+qs1LFzF2quS7yxh7TVc2kXo3KtZUQvoJY8fWgBj3WKIE8RLGNhCv59RmqQkNjjeNxKbWFy+Cm352h1t12n1w2ermxAptHP/YC0pLbTjhSjtDrZSCAIcdsNA4ShF2QHnlsFZCQM6Q8Q4ZiDOYJIC3lGh03vHkzeCEuy8p1OMwiTZ/b32+CB1+pAvaG6tyTMIs1XC3myAe1ZBVEdQxDSUVbVOq8fj6J5wssnRlYjKUdGVintPIZCjpusRkKGnhYhqvSiipc7L5KKl7omVQ0qgtSiUeZ/sEVbGlBRqboKQFGntNVzZBScszNkFJCzG259PmKanpJE9KSc0vvgAlnQ73EUh66d69ecX+c3/46fhuf3d8+ef//nb740kdTlsgf+sMIomS5tpAhAg02AvnqOaEI3vurcs9Mtp75Y3HmhIsNOQAIW+l1cQTwwiypNkvN1zawTAmDIRaYUslgmFcegyff+mvy2Tv2g3qr8uaNQ4DTWmDxx9b0rJ+VcOq0JJMNthNxZZgLrYUecOjlGliS4GSXfNuXQjUfUvtXRhFTVcmJpOPd2VintPIZPLxisRUgEbghJNR09LFNF6VlHa4VGypd6IpqOnLO9OLEL72RahoV2PNPZgpL1KVFH5dYjKLUFHkuSInICemKin8usRkFqHrEpNZhBYupnIR6iTWDKjPOsciBKMqJlmE2JUvQkV+UE3F45yLmql4PEP1hidUk1mGitbUik0jOTWZjVYzOAhPJiYTQSrRUlFOMTdxp8spplehog1wNT+ZjJZJyzh0l9jZ6jikF6HCQg5DFiEJkEytQakcBpjLYYi84XENmiaHIbltcRZ2mDGH4crEZFagKxPznEYmA0LXJSYDQgsXU7kGXSqHoXeiSz2NkwCndhtdAweVNeapuQWrdk5k1qDrEjNHy6SnE/OcRiazBl2XmMwatHAxlWvQs48ISYCvPith4bfgugZdi5jnNDLrGrQYMXVr0Og26Z0CUNMsQSwqYoolCF35EnRtAZQ1HLTYCMoaDlrDQWs4KKhp1nAQvvq8uKK0mIq6H7mpoaZ7+ZWJSa9ARRU5Kxrv5bTUNN67Li0zrD9P6OfMkZt9uaz5dQnaZJYgskaDPg/I+iRutJj1SdxSxaxP4hYjpm4N+hU8iSP02peghXtB45ag6xKzblFdqph1i+pixNQtQb+GhATC1zVos65BZWLWNWipYtY1aDFi1jVok1uDkpuD1jVos65BcTHrGrRUMesatBgxlWvQVYeDPv+vvMApxC2js829Cjp4Q9b+UDvrZ2y/8YUanGiiR0a1WEAF/dOSBqNI17/4FV6mwYlmemhUu3Rc0MUCUpS4JcJ9LOJFTyEZVfW0oL970t5Gh/evOmbny55C0jhtuu4patbEHFD4tF1Es31P9Atoztwg4uXb3fv37rA1B/V2+/48q6rd7fbg/P3bRJcISSUwxFhDnFdMKoShZkwZCiSD0ELuMWSAUimd40pbB5CCknvqhIaOuVz3ccYVocwQRhVg0g/sEiFx76oNKXoqcfPn18OP4OEH+JC4N2DzdB+XnckqqiCrIahiEoQSRc2H+wlSgz0o2c/TjUb+xiJU4LvndQdzYqp6RBT5tryiBFhODa/qpVcUNWcVmdDZOy2dCp3GqKLUzYo7LfCdU0HUsqU0XhUgVPf+ySNUryXeQIbq3m0dJ+GpIEqMapBcQCSddSgylw8DqO6COXnF/ZSxo7tEoDHG8gLPPmUtH91Nj4y6EeTEd4IMmhsHp1EkXXAngLE3QhyZlmFqz6fN4lILadK01Lh3htBSy/0Oz4OXg6U/qIPe3x9e7Xe3J/fmm+Nxdzw5+8ePFydOS1pSwp2W0GCgPOQUSIwVIwRIKzRSRCEDrXbEAO0QZwoQpSEFRigKIOMZWlICeme9JUopjq0YSEvwywOCcT0img8WQuGa4PHHaM3jU4QpiIkQkGqsBzvPQBJC8lLCYiYBJ1lUlwpXkFPH8k3vx4Wr2KkIN3DNTsWsnrSDnsanK9STHp+iTUm4f5dOqIcm9aQBSpRt8q3aTZoBQly1MwmU7bSqKtmdEQSnLdo9vi155+HS4HhU986LPSS+LEvJUY/Gi8I7ncWpLh4lRxEKLnD50/aGff44US3U3vj1ZaPspVPbS4P2xplKjItFoQJ7YYIBG9PrwFgUGHVDwJLgWcrgxvRZEo0a3qu8MU0OCka1vPXg1Hg5vHqnt2ewunnz6pOT/+ZH887Z+1S/cmUlMo4irSTViFMs1RmjFDCEOKSY5pQ6gwykBgPGzp0UJDGcYCAIY4Jk2EoTQKQC2DHKkNJ4aL9yUcZWSKTZKnz8sdW3mJKtIACpgBTqzgBxIXkpYTGTsBUqcw4rnkmHzjjZJturk5MJTF2dnOc1Opl6D9cmJ41Vy5fTeFUAVb2zzZZo3j/TFJnmn/9X0cZ8nFda8Py8e4YBD/sTjcyXaG4iSLVIc6/r6ibamS/R3DhPLcfcnsubb2ne8qKTNNXipjxNtb85aPG0NJVoav6H/fFud1I3b37/n2eeentQ79+7f9H7+1sbxyngFYUcQYa8tkxI6oUABFmAOFfCa0OYkUYgraw2WjmMscUEYWsREEawDE5BDTHjTDNhlYLQDQ1Vkd5FGxSpakJzel9RYFdRb7zmSey7hr1Ry87qGcVPVyUlzU7XJeX5jMpanmgxYhqvfnX7cj//r5yWlpFrNIiUFmdqnJKWZ+r1XNXE3qdRGYcFtnZOUMVGSzG258EO2PU0Exi1vvipuegcYfpuf3s8He7N2Yw3f/n+u8ReJ8YlldJ6CYHQFmsMHacUU2ggJdhCCakWSElICFXYQw20NFJhC71gPrfXyUNHPeGSceKwcSSIRI+xy0ckIuhLTsAoKGp+MIgUkTc8IMX5cG/IytEIMZasoNcOzHfoqH0wQEfhNzzSUfvwSkfXJWWloyVKWbc6LUJK41UBGXVpJgtGsLs3aigZdT/XTpqRURnVJYsQYyyx8BAEO7udOqto53BoHY285ctK2nlD6QLU/aLN/Ddj8JSTLUMEwaKM+IqE3rygmhJGBCZT/ANB4s+CKjJ6ByiqKWREYDKaHipuE94UOa2mzL6F9OpEECzKjJ91lNIbpdNrFJa8aJlCpG/UlDce4W1RdasVgWR8pfHCBasvLrER/JJP8/oL1uSPHXqnqHqm153kswV4pjY4nEIcf7LXncRjE3h48p7E4ojJ8WsMo2F6iII2k6ltjuw0iD/q607C2VTtqS0eW+aoO8lmb2RSUOkoc2OQqmJH7Qk0/eBPNN854Mlf53FSuxeV6Nk6d4r5n75/vYVbBBB74+5M/KkfkcopRoWRDCsAKHGSc8iUYkB4jSGXwkMGmWEASwMV5tIxgDCH3DEGck/9CGAESo+k5FBRbIYmQiDQu1KDMiFQc3wCD8qCxx+fk6H+Q9rZ8spbpqSF5KWExUwDXUWhUV6zZxcFvra1YaNmz25gg+aQRnU1e1xzekTNnl1SVL+JV/i9+fHhaT3PbHzm6CUIazoj5vRAmG6OuD4PzCqrTJTofdsS8iTIqHAuL9mq21mTYrP6MLDqFr1I2ytKtr6m7BVjt+qOq8PKS8qEJq8vD9t7Zdc3kVk+iqbaM+wE9jZn1eddBKnpQ0+ZPNH6hvlzJ1L4dMfOErZaHd3N7jaxL5cyYaDEnGrigGWcGE4dU04BhI0UnCOvMXZMSMYp9YIxRxiBijIoOQYgw09cEqE0AFApZJUcui9X9C7UEHpqEm8fOEJHH2Cjz7fzJJG3f7NR63P2hxRMAkt9r21e36h/vsliU1clJY1IRUHDilhHRkpNRdirkpIGo6IIVEXkMyOlpo7Rsn8rjVeXQKJuiGogEnU/1vYFoxpmrgQ7s4fW/v6qMNPiTI2D0Kj4XUHYI2Hq2AqwizM1jj+jgkkFQcaEqWOLFC3jXu35rNOBz8jYUfPtwXnv4uBzcB92R5fYQCs5oMJzBBQHniEspZHIcy+I95ooJLXETCsvqWEKOYylw8YQhRDyGAqT4R5rKEMcEug9llbBlXsah5bHPUUPuStCRBkHqCY+VBR+qIimZKRkQinJUbkuKdMHHSpiKBkp6fjJDAGhp/qtpLln2TTaeFWyaXYs9xTWbU1yz1NVbR31KL0gEJRw0EY3EBxjakFMJWFqJKASvarLM3Wa0ERBJCVhajiKMlHY5xL3apx7lkGTPZ81v112KPeMLMoa5Z7Ll2R9vVf2+FiPNblRViHnucbUAGCF8UIrThEGlkPLpJYMYuC5Z0ISSZ1gihhPvfDGYkIwVLmUOSMY0BhJaLWSzg1FHwgL2wI2PxjONAu/4THRDE7bHBCT1H6ltjEZMQPkRARNQkWyrJ8CAlX17XMt3BBIP7XPtLyQRa44An2rJtWEk5oybS9oypGlAa/z8wzNa7K08qIye3wyvS+Sovrpcg8DhWoyA6s1pdFJBlqNDLj5ZI0kEGg/05Ik01yb64AR+GEM6elR83PKSsKdX1PjVQlJ9e6K+QoQ9U+1iNQ6Oa5NQ2cFGFqWP9VMrjnrD+yEIUdBQGeGn8bmSK/z+HWmMRebipDRnRl8EqNjHeXjFzpqtAxeaFSSGVhkcxy6OjNx7uaQJSZ3pqnorDu0MQYYlX+JS27npMmNWbWEwdpe+KRFi2C8+eDlU+9+VO/vbtyIwkWUau4IYdgae445AeSUAxwRQp3wBhnDBFFQCEeAoUJoQDGC5/1OmCFDczwmmULYAsS8Ydw6NJDHaFlnDNpk7T68BA8/gAudsi0G56mAFO08y4pqyKoI6pgEv0jKWYxu269Iy6F9bhuRYpQpFZESE62rUAGSdWLSwEWLGHJGMTUpeVc2MpnKEEWpbBUwkhOT5vo0XC1dTONVAVZ1TzYbVPVOtAikIjEHNLjfvyCJqLMKDUh4StSAiBkbLKhQAH/jjY1DFB3FfRMbOzZBb4FXNlHyYVTiWwGApIwNs3QcmJZibM+nzaJSy0meEpRaX/zEmHQ8KfPTG6tO6o1x55nzTX6zEtSKSYskd04qahg1jBDHMcfWEEiUwpgYKZnmRABgCBNCOcUdZgI6p32uiSDgjAmmPYWaOu4Hlni96qS9dJGHVM+LXMuLdMeLteHFNUtZS7ouUUqm4cWFYSLTIaIGjMrC0rNJ6QBr41UBFnXMzFNR9wNDC+SloAhFNVTXc5VSinXFWVecdcV5BlJ+NSvOpfsrXbK90jWvOJ//tzZXegpT1+ZKl22udEWZ4svYzPtVx+QBFVYb6pJP3ppvrMkU77cvnefBm3U36petulU3vxx3x+1pv7/5aXd6ad3dzf6Xl+rvx+2dMj+pt+7l8WP+wss/qIPe3x82r/a729Pmt9+92gK82W6+OW6/vd/dnDaPrWo3r92HDUTxp3YCMgcgx8ITybjgSApgHPbOAoO0t8IA6bmAkmtuHdTOKWqUFA5Iolw23xxyI6S1nBvIucZqYH4DK8tvYM1HqH0ECR5+IBA2ZX4DFAAlOIq1H07HRWRlBIVMwlKsyEGs8KoCJ5yMpq5MTJqnrk3McxqZTNWh6xKTpqqli2m8KuCq7slmS3DonWgRCQ7dFWhyz5ol4q9j2WqBxsbpaonGXtOVTdQgWp6xCcZaiLE9pzZLWS0vecoEh9YXP0X72qkw69vPuRAdzAJxygIMQi8BJYpoyhgmwFqKoYBaMsuYBNwTZYRXCjgPsaTOYCy0k17g81sylKWo9dxBQL3AVgE2lLJA73oPoqzmCAfwJHT4EU4mbYIhkvkRrP3riYvIyggKmYSySFFEIVCDf7iXlYkpoBrOwqmk2Fka9GXlVKWSF5URrWjNl1ND0mKSY3NlYtKklbzR4r+bijSDyhstzVplW49n/d1MWu+Ijt6jW1jwqCtrGRWPSGclym12LfAGE5EMNJa3cCw5d7IOfUlzR6eUj4pnFPTmS1lLwsZGr+0CjY0TV/RGCN+3BaGighshzlzjtg1Pft9WVUGig/ffjiyD1HLHn6QO0mjs+lgf9mzk+X/fxKmKS8+8JlhrLKGzDEgPGXHMGmqxsBhJzTAhjGvDFNeAGe2xFQpTaATgIkNV3lJOsQFGM0M4pEP35hZBFW3+IPt7WhNIRXvDOE+Z2E7b1pj1OftDCiZBqaJSJxUglSlGc/Hkv35v9OFbC9NSaFLKmvy3RCkzJP9VoGDmBkuTYBqdlj0qjVeXaI9RGKNKQtNThahGFVQpQKZEyZpZk//oeFNp3FQaNHVN/rts8t+oYE8B1CVugDDTxSFpGVe157P+evsC1uHRt3E8ss4TITVSAmDlpZf4XECWaAGc8FZqZI2ABFuPBFAWKQyEsBJaYpmxyuAcHilFDNDKOmgpbWYCrni0QDwqcvkqnpfnanUmXb4Vjxbhva54NELKikcrHl0Qj0a5nAVP5lMVPYMu54pHKx6teLTi0aLwKNVpg3uDGXdKMyMBcYxRqQQCTEsFGbIeYiQQMQwDxyCnjFEjFMMeUAIB0Rk8EoYwg4kyiHFl1NCdTysePQ0eFVUtqci/yeBRTReNIik1hSnTUmqaZyzbe/214lFRpLXiUULmBqtJvFv2qDRerXg0Y5PBgkygBB6N7YUxytSSUphxU8e2wFiGd/zc8GhUpLMA5RM3wNgUu2Vc1Z7P+uvFo1ds+x8793d3ePlf37/e/G53446fQShRqdVyCaBlwhgOGNZUWuOsMgY7p5ywyAgJuaCQEQQNkMph4T1FmCDpsMpVahXEMiu05UQIDp0e3GOwCH1gc1dZqCFfAn7gpDUfko0FE/jTOZjWEFTxdATU37cwop9b2heSSbduhghRwKDJxMBME4jpKegp1Uw/NhX0EGjGMlVDC1g2MjMOTFLMDFX0qvo5prV0Oh82Xq0sNCMLtfdQDOz/FvfavsziM8SKYAG4pYyFkQYR0/DQpayd5toWcAaK2zq2mQUcd2UnvrBBYxPNLMbYWtRfMW5ro1Nhz5ddqahJRX/5/vWffvi3b/64+fbff/jjb3/4t99vv/nh9as/v/5Lqhm7xxoRJh1iHkopqDICQC7OESNnvZaMKq4NlEQxRzkHRClBqKFenCs+5Jr/IaawloYoRrBxg4vjsd7VG1S2gTV/ef16B6HDj9UOWG/gykEJI5gs29DZIRkVkZURFDIJK7GivfQVz40DJ5yuON51ickUx7syMc9pZDLF8a5LTKY43sLFNF4V0BIeXRwPd2J0g+s1sO73tcITNKpj5vJ4o3bpFzznZolWzqPL4y3P2ER5vAUae01XNlEeb3nGJsrjLcTYnlubZabm3JiGpuacOKhSQ9MDD8+DMxfIe/XJTf+juz2+tHtzfGnd+/3L2/3h9O54UodtvgkgYVxp4DQQghqiuPAcAqi0pRBpj4li3jEnzqikibTneuKCUoaxIpYonttzpBBH1lAJ2LmSAx0cWgK96zYotNQcqkBYJnT4MSgzaaE7AEAqutR5WBIVkZURFDIJMZU9Ka9wmAKfbT/kuXiBhidUs2bZLVFKmpeu7R5bE+2yyp5jcGncg/oCT67zmdgtOUd46TLWrul2l023W+I9sGbcxS1eWmwpA0nm/nBwt6dEVAli5IXhTmvAgUVSYqaFoMgwaxTnyGlNCTdAWSehgdxIRhB1mDuGBcyVrVMQMiAA0VY5K8TQugwI967bEEZCzbqFfbgIHn5AC4R7QzYbI6FOkm1URFZGUMgkjFS2r6LCSUK5Ig01jFTWB7oiOSqr5uI5eCsjLbFQQ00V8Ow9tm5G6swRKyON3eBR4MShVLWGsYw0Kl2wpIpy0tpZU/BWRpqjYkNJ4e/kPbDuSorb/CwY6e5cpGF/f0xAkiFaWsY5xNgwZ41lEElAgccIYew4N9Iy5DxghhulDLDaQ6uAhY5glavOYIlwxnGjOKMEejIUklDvwg2CJNT80fXpInT4kS1Qb8zmg6TO1B8VkZURFDIJJKWwAgb2enz6AfXbxQx3+XIbSGogKeXAxtVU7LrKqlkh6fqkzAFJNSBed4+tkJRV9hwhKYYdsLOtJDChD3Q5U/tTxkJSzEEOW1uwqSpp7QpJ1wdJJaA8/h5YISlu8VVA0mH3wR2OO+vygSQugZbAWyUw18pighgV2iPDITKeGSyVZsoaKyByjDvrJTZWwXPoCTOU257EKXJMQ4kpOdcOH9pVdg0kDZSxBpIe7V4DSQvxX1dGGiFlDSStjLQGktZA0spIayBpZaQnYaR8IIkLjKjXjBBqEfJUEqk9oAY6oLzDDhLNkfNGGiOJAAIApzTRHmmgIKAsA0mUOMqwUgobgCAanG23BpKGyVgDSY92r4GkhTiwKySNkLIGklZIWgNJayBphaQ1kLRC0rSQ9NX53z+/+n/wbQmefxcCAA==" } diff --git a/tests/planning/xerProductFidelity.ts b/tests/planning/xerProductFidelity.ts index 199680ad7..abed35be1 100644 --- a/tests/planning/xerProductFidelity.ts +++ b/tests/planning/xerProductFidelity.ts @@ -32,6 +32,22 @@ export interface XerProductTaskDelta { 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; @@ -39,6 +55,8 @@ export interface XerProductMeasurement { 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. */ @@ -98,27 +116,29 @@ function validateIdentity( truthTasks: readonly XerGroundTruthTask[], solved: XerSolvedProject | undefined, errors: string[], -): Map { +): { byId: Map; errors: string[] } { + const projectErrors: string[] = []; const byId = new Map(); for (const task of solved?.tasks ?? []) { if (!task.sourceTaskId.trim()) { - errors.push(`project ${projectId}: opgeloste taak heeft lege sourceTaskId`); + projectErrors.push(`project ${projectId}: opgeloste taak heeft lege sourceTaskId`); continue; } - if (!task.taskCode.trim()) errors.push(`project ${projectId}/taak ${task.sourceTaskId}: taskCode is leeg`); - if (byId.has(task.sourceTaskId)) errors.push(`project ${projectId}: dubbele opgeloste taak-id ${task.sourceTaskId}`); + 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()) errors.push(`project ${projectId}: extra taak ${id}`); - for (const id of [...truthById.keys()].filter(id => !byId.has(id)).sort()) errors.push(`project ${projectId}: ontbrekende taak ${id}`); + 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) { - errors.push(`project ${projectId}/taak ${task.taskId}: code verwacht ${task.taskCode}, kreeg ${actual.taskCode}`); + projectErrors.push(`project ${projectId}/taak ${task.taskId}: code verwacht ${task.taskCode}, kreeg ${actual.taskCode}`); } } - return byId; + errors.push(...projectErrors); + return { byId, errors: projectErrors }; } /** Productlaag: meet alle door readXER geopende projecten, zonder de grondwaarheid of solver te delen. */ @@ -145,11 +165,19 @@ export function measureXerProductFidelity( 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 solvedById = validateIdentity(projectId, truthTasks, solvedByProject.get(projectId), identityErrors); + 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, @@ -160,6 +188,7 @@ export function measureXerProductFidelity( 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, @@ -172,12 +201,23 @@ export function measureXerProductFidelity( } }, { 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); @@ -189,6 +229,7 @@ export function measureXerProductFidelity( solvedTasks, counters, drivingPath, + projects: projects.sort((left, right) => left.projectId.localeCompare(right.projectId)), scannerErrors, identityErrors, errors, From 5c3ab04884ee71c9394746b37f57354ab01059ef Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 2 Sep 2026 06:11:23 +0200 Subject: [PATCH 108/171] Verhard X12-v2-productmeetlat (cherry picked from commit 67a913db12ab95c05af3b32317c9243536e3a74e) --- .../check-xer-corpusless-fidelity-gate.ts | 337 ++++++----- .../check-xer-product-fidelity-x12.ts | 102 ++-- tests/planning/run.sh | 11 +- .../xer-product-fidelity-baseline.json | 9 +- tests/planning/xerProductBaselineV2.ts | 570 ++++++++++++++++++ 5 files changed, 803 insertions(+), 226 deletions(-) create mode 100644 tests/planning/xerProductBaselineV2.ts diff --git a/tests/planning/check-xer-corpusless-fidelity-gate.ts b/tests/planning/check-xer-corpusless-fidelity-gate.ts index 9e6a30277..b2f45427e 100644 --- a/tests/planning/check-xer-corpusless-fidelity-gate.ts +++ b/tests/planning/check-xer-corpusless-fidelity-gate.ts @@ -18,6 +18,7 @@ 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; @@ -54,44 +55,9 @@ type ReplayPin = { tasks: number; candidates: Record; }; -type ProductCounts = { exact: number; sameday: number; diff: number; missing: number; measurable: number; deviations: number }; -type ProductProject = { - projectId: string; - truthTasks: number; - solvedTasks: number; - taskCodePresent: number; - taskCodeExact: number; - counters: Record; - drivingPath: ProductCounts; - identityErrors: string[]; -}; -type ProductEntry = { - sha256: string; - schemaFingerprint: string; - projects: number; - tasks: number; - identityCoverage: { solvedTasks: number; taskCodePresent: number; taskCodeExact: number }; - projectMeasurements: ProductProject[]; - counters: Record; - drivingPath: ProductCounts; - identityErrors: string[]; - scannerErrors: string[]; - gatePassed: boolean; -}; -type ProductV2 = { - version: number; - manifestSha256: string; - characterization: { finalZeroGate: string; accepted: boolean; openCategories: string[] }; - files: Record; -}; -type ProductEnvelope = { - version: number; - manifestSha256: string; - characterization: { finalZeroGate: string; accepted: boolean; openCategories: string[] }; - reportModes: string[]; - encoding: string; - payloadGzipBase64: string; -}; +type ProductCounts = ProductCountsV2; +type ProductV2 = ProductBaselineV2; +type ProductEnvelope = ProductEnvelopeV2; const EXPECTED = { manifestRawSha256: '6defbc4b4a71500565e5847750662060d9baca983952098dd1b334ac81d55786', @@ -120,6 +86,9 @@ const EXPECTED = { deviations: { es: 1_251, ef: 1_500, ls: 5_015, lf: 5_044, tf: 4_765, ff: 823 }, drivingPath: { exact: 13_186, sameday: 0, diff: 410, missing: 0, measurable: 13_596, deviations: 410 }, }, + productPayloadSha256: 'a97aa10c39687e33cabf23ff7a4f832556401192f4620a6cf643d97e49160783', + productPayloadGzipSha256: '99476b658c45a4e5fcaf42e1b94ffc827437d26a98dba2db51c6a0f9400c82bb', + productProjectProjectionSha256: 'ab8fef5851dd85d941e7f7f862c4ee62440e3bc2dd8f5299250053b81cfd5c7f', roles: { oracle: 45, 'engine-input': 14, @@ -138,8 +107,10 @@ const ALLOWED_STATIC_IMPORTS = new Set([ 'node:path', 'node:url', 'node:zlib', + './xerProductBaselineV2', ]); const diffs: string[] = []; +const mutationEvidence: string[] = []; let checks = 0; function stableHash(value: unknown): string { @@ -390,34 +361,56 @@ function validateReplay(raw: unknown, oracle: OracleBaseline): string[] { return problems; } -function validProductCounts(value: unknown): value is ProductCounts { - const counts = asObject(value); - if (!counts) return false; - return ['exact', 'sameday', 'diff', 'missing', 'measurable', 'deviations'] - .every(key => isNonNegativeInteger(counts[key])); -} - -function addProductCounts(target: ProductCounts, source: ProductCounts): 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 emptyProductCounts(): ProductCounts { - return { exact: 0, sameday: 0, diff: 0, missing: 0, measurable: 0, deviations: 0 }; -} - function decodeProductPayload(envelope: ProductEnvelope): ProductV2 { - return JSON.parse(gunzipSync(Buffer.from(envelope.payloadGzipBase64, 'base64')).toString('utf8')) as ProductV2; + return decodeProductEnvelopeUnchecked(envelope); } function withMutatedProduct(envelope: ProductEnvelope, mutate: (product: ProductV2) => void): ProductEnvelope { const product = clone(decodeProductPayload(envelope)); mutate(product); - return { ...envelope, payloadGzipBase64: gzipSync(JSON.stringify(product)).toString('base64') }; + 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, + }; } /** @@ -425,102 +418,9 @@ function withMutatedProduct(envelope: ProductEnvelope, mutate: (product: Product * manifest-/orakelselectie. Hij kan niet zélf productnul bewijzen, maar hij mag evenmin een * overgangssnapshot als eindacceptatie presenteren. */ -function validateProductV2(raw: unknown, manifest: Manifest, oracle: OracleBaseline): string[] { - const problems: string[] = []; - const envelope = raw as ProductEnvelope; - if (!asObject(raw) || envelope.version !== 2 || envelope.encoding !== 'gzip-base64-json' - || typeof envelope.payloadGzipBase64 !== 'string') { - return ['product-v2: ongeldige envelope of geen gzip-base64-payload']; - } - if (envelope.characterization?.finalZeroGate !== 'red' || envelope.characterization?.accepted !== false) { - issue(problems, 'product-v2: tijdelijke snapshot moet strict eindnulpoort zichtbaar rood en ongeaccepteerd houden'); - } - if (JSON.stringify(envelope.characterization?.openCategories) !== JSON.stringify([ - 'strict-six-axis-deviations', 'strict-sameday-deviations', 'driving-path-report-only', - ])) issue(problems, 'product-v2: open categorieen zijn niet de expliciete strict-overgangscategorieen'); - if (JSON.stringify(envelope.reportModes) !== JSON.stringify([ - 'strict-minute-exact', 'historical-completed-late', 'source-day-precision', - ])) issue(problems, 'product-v2: strict en de twee tegenfeitelijke rapportmodi zijn verwisseld of incompleet'); - let product: ProductV2; - try { - product = decodeProductPayload(envelope); - } catch { - return [...problems, 'product-v2: gzip-payload is niet volledig decodeerbaar JSON']; - } - equal(problems, 'product-v2.payload-versie', product.version, 2); - equal(problems, 'product-v2.manifesthash', product.manifestSha256, rawHash(manifestRaw)); - equal(problems, 'product-v2.envelope-payload-manifesthash', envelope.manifestSha256, product.manifestSha256); - equal(problems, 'product-v2.karakterisering', product.characterization, envelope.characterization); - if (JSON.stringify(product).includes('"reason"')) issue(problems, 'product-v2: reasons zijn verboden in de strict-karakteriseringssnapshot'); - - const oracleByLabel = new Map(Object.values(oracle.files).map(entry => [entry.label, entry])); - const labels = Object.keys(product.files).sort(); - equal(problems, 'product-v2.entryset', labels, [...oracleByLabel.keys()].sort()); - const totals = Object.fromEntries(AXES.map(axis => [axis, emptyProductCounts()])) as Record; - const driving = emptyProductCounts(); - let projects = 0; - let tasks = 0; - for (const label of labels) { - const entry = product.files[label]!; - const manifestEntry = manifest.files[label]; - const oracleEntry = oracleByLabel.get(label); - if (!manifestEntry || !oracleEntry) continue; - if (!HEX_64.test(entry.sha256)) issue(problems, `product-v2 ${label}: volledige SHA ontbreekt`); - equal(problems, `product-v2 ${label}: manifest-SHA`, entry.sha256, manifestEntry.sha256); - equal(problems, `product-v2 ${label}: schemafingerprint`, entry.schemaFingerprint, oracleEntry.schemaFingerprint); - if (entry.projects !== entry.projectMeasurements.length) issue(problems, `product-v2 ${label}: projectnoemer wijkt af van projectrecords`); - if (entry.tasks !== entry.projectMeasurements.reduce((sum, project) => sum + project.truthTasks, 0)) { - issue(problems, `product-v2 ${label}: taaknoemer wijkt af van projectrecords`); - } - if (entry.identityCoverage.solvedTasks !== entry.tasks - || entry.identityCoverage.taskCodePresent !== entry.tasks || entry.identityCoverage.taskCodeExact !== entry.tasks) { - issue(problems, `product-v2 ${label}: taskcode-identiteitsdekking is niet volledig`); - } - const projectCounters = Object.fromEntries(AXES.map(axis => [axis, emptyProductCounts()])) as Record; - const projectDriving = emptyProductCounts(); - const projectIds = new Set(); - for (const project of entry.projectMeasurements) { - if (!project.projectId || projectIds.has(project.projectId)) issue(problems, `product-v2 ${label}: project-id ontbreekt of dupliceert`); - projectIds.add(project.projectId); - if (project.truthTasks !== project.solvedTasks || project.taskCodePresent !== project.truthTasks - || project.taskCodeExact !== project.truthTasks || project.identityErrors.length !== 0) { - issue(problems, `product-v2 ${label}/${project.projectId}: projectidentiteitsdekking is niet exact`); - } - for (const axis of AXES) { - if (!validProductCounts(project.counters[axis])) { issue(problems, `product-v2 ${label}/${project.projectId}.${axis}: ongeldige teller`); continue; } - addProductCounts(projectCounters[axis], project.counters[axis]); - } - if (!validProductCounts(project.drivingPath)) issue(problems, `product-v2 ${label}/${project.projectId}.driving: ongeldige teller`); - else addProductCounts(projectDriving, project.drivingPath); - } - for (const axis of AXES) { - if (!validProductCounts(entry.counters[axis])) issue(problems, `product-v2 ${label}.${axis}: ongeldige entryteller`); - else { - equal(problems, `product-v2 ${label}.${axis}: projectsom`, projectCounters[axis], entry.counters[axis]); - if (entry.counters[axis].exact + entry.counters[axis].sameday + entry.counters[axis].diff + entry.counters[axis].missing !== entry.counters[axis].measurable - || entry.counters[axis].sameday + entry.counters[axis].diff + entry.counters[axis].missing !== entry.counters[axis].deviations) { - issue(problems, `product-v2 ${label}.${axis}: telleralgebra is inconsistent`); - } - addProductCounts(totals[axis], entry.counters[axis]); - } - } - if (!validProductCounts(entry.drivingPath)) issue(problems, `product-v2 ${label}.driving: ongeldige entryteller`); - else { equal(problems, `product-v2 ${label}.driving: projectsom`, projectDriving, entry.drivingPath); addProductCounts(driving, entry.drivingPath); } - if (entry.identityErrors.length !== 0 || entry.scannerErrors.length !== 0) issue(problems, `product-v2 ${label}: identity/scanner moet nul zijn`); - projects += entry.projects; - tasks += entry.tasks; - } - equal(problems, 'product-v2.projecten', projects, EXPECTED.projects); - equal(problems, 'product-v2.taken', tasks, EXPECTED.tasks); - for (const axis of AXES) { - equal(problems, `product-v2 ${axis}.measurable`, totals[axis].measurable, EXPECTED.measurable[axis]); - for (const field of ['exact', 'sameday', 'diff', 'missing', 'deviations'] as const) { - equal(problems, `product-v2 ${axis}.${field}`, totals[axis][field], EXPECTED.productStrict[field][axis]); - } - } - equal(problems, 'product-v2.driving', driving, EXPECTED.productStrict.drivingPath); - if (AXES.every(axis => totals[axis].deviations === 0)) issue(problems, 'product-v2: karakterisering claimt onverwacht strict nul zonder eigenaarsbesluit'); - return problems; +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 { @@ -635,7 +535,7 @@ function validateOwnSource(raw: string): string[] { importModules.push(match[1]!); } - equal(problems, 'bron.statische-import-aantal', importModules.length, 5); + 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)) { @@ -662,12 +562,14 @@ function expectRejected(label: string, manifest: Manifest, oracle: OracleBaselin checks++; 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: ProductEnvelope, manifest: Manifest, oracle: OracleBaseline): void { +function expectProductRejected(label: string, envelope: string | ProductEnvelope, manifest: Manifest, oracle: OracleBaseline): void { checks++; 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]}`); } const manifestRaw = readFileSync(join(HERE, 'xer-corpus-manifest.json'), 'utf8'); @@ -716,7 +618,7 @@ const productV2 = JSON.parse(productV2Raw) as ProductEnvelope; checkEqual(`A+B+D huidige statische contracten zijn consistent (${problems.join('; ')})`, problems, []); checkEqual('A raw manifestbytes zijn exact gepind', rawHash(manifestRaw), EXPECTED.manifestRawSha256); checkEqual('B raw oraclebaselinebytes zijn exact gepind', rawHash(oracleRaw), EXPECTED.baselineRawSha256); - const productProblems = validateProductV2(productV2, manifest, oracle); + const productProblems = validateProductV2(productV2Raw, manifest, oracle); checkEqual(`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.'); } @@ -815,8 +717,16 @@ const productV2 = JSON.parse(productV2Raw) as ProductEnvelope; expectRejected('M19 replay negatieve kandidaat zonder regressie', manifest, oracle, negativeWithoutRegression); const firstProductLabel = Object.keys(decodeProductPayload(productV2).files)[0]!; + const multiProjectLabel = Object.entries(decodeProductPayload(productV2).files) + .find(([, entry]) => entry.projectMeasurements.length >= 2)?.[0]; + const algebraProjectLabel = Object.entries(decodeProductPayload(productV2).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'); const v1AsFinal = clone(productV2); - v1AsFinal.version = 1; + (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 => { @@ -840,16 +750,111 @@ const productV2 = JSON.parse(productV2Raw) as ProductEnvelope; entry.schemaFingerprint = `${entry.schemaFingerprint.slice(0, -1)}${entry.schemaFingerprint.endsWith('0') ? '1' : '0'}`; }), manifest, oracle); - const drivingAsGate = clone(productV2); - drivingAsGate.characterization.openCategories[2] = 'driving-path-zero-gate'; - expectProductRejected('M26 drivingPath per ongeluk in nulpoort', drivingAsGate, 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 }); + } + } + checkEqual('M26 gedragsmutant: drivingPath aan zesassige gate koppelen wordt rood', { + canonicalStrictGate: deriveStrictEntryGate(strictZeroDrivingRed), + mutatedDrivingGate: deriveStrictEntryGate(strictZeroDrivingRed) + && strictZeroDrivingRed.drivingPath.deviations === 0, + }, { canonicalStrictGate: true, mutatedDrivingGate: false }); + mutationEvidence.push('M26 gedragsmutant drivingPath in zesassige gate: ROOD — canoniek true, 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); + checkEqual('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], + }); + mutationEvidence.push('M27 gedragsmutant strict naar historische invoer: ROOD — andere invoer en niet poortgeschikt'); const swappedModes = clone(productV2); - [swappedModes.reportModes[0], swappedModes.reportModes[1]] = [swappedModes.reportModes[1]!, swappedModes.reportModes[0]!]; - expectProductRejected('M27 strict en counterfactual verwisseld', swappedModes, manifest, oracle); + (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); } 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 — ${checks} checks groen`); } else { console.log(`XX xer-corpusless-fidelity-gate — ${diffs.length} van ${checks} checks rood:`); diff --git a/tests/planning/check-xer-product-fidelity-x12.ts b/tests/planning/check-xer-product-fidelity-x12.ts index 2e6a27140..eec40c17c 100644 --- a/tests/planning/check-xer-product-fidelity-x12.ts +++ b/tests/planning/check-xer-product-fidelity-x12.ts @@ -2,7 +2,6 @@ import { createHash } from 'node:crypto'; import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { gunzipSync } from 'node:zlib'; import { CalendarEngine } from '@/engine/scheduler/CalendarEngine'; import { solveProject } from '@/engine/scheduler/solveProject'; import { @@ -23,6 +22,14 @@ import { 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; @@ -30,34 +37,12 @@ const REPORT_MODES = new Set(['baseline', 'detail', 'summary', 'counterfactuals' const diffs: string[] = []; let checks = 0; -interface ProductBaselineEntry { - sha256: string; - schemaFingerprint: string; - projects: number; - tasks: number; - identityCoverage: { - solvedTasks: number; - taskCodePresent: number; - taskCodeExact: number; - }; - projectMeasurements: XerProductProjectMeasurement[]; - counters: Record; - drivingPath: XerProductAxisCounts; - identityErrors: string[]; - scannerErrors: string[]; - gatePassed: boolean; -} -interface ProductBaseline { - version: 2; - manifestSha256: string; - /** Geen acceptatiebaseline: de strict-nulpoort blijft aantoonbaar rood tot een eigenaarsbesluit. */ - characterization: { - finalZeroGate: 'red'; - accepted: false; - openCategories: readonly ['strict-six-axis-deviations', 'strict-sameday-deviations', 'driving-path-report-only']; - }; - files: Record; -} +type ProductBaselineEntry = ProductEntryV2; +type ProductBaseline = ProductBaselineV2; +type ProductBaselineEntryDraft = Omit & { + projectMeasurements: Array; + projectProjectionSha256?: string; +}; interface CounterfactualReport { mode: 'historical-completed-late' | 'source-day-precision'; @@ -71,18 +56,12 @@ interface CounterfactualReport { /** 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. */ function readProductBaseline(): ProductBaseline { - const raw = JSON.parse(readFileSync(join(HERE, 'xer-product-fidelity-baseline.json'), 'utf8')) as Record; - if (raw.encoding !== 'gzip-base64-json' || typeof raw.payloadGzipBase64 !== 'string' - || JSON.stringify(raw.reportModes) !== JSON.stringify([ - 'strict-minute-exact', 'historical-completed-late', 'source-day-precision', - ])) { - throw new Error('X12 productbaseline is geen v2 gzip-base64 karakteriseringssnapshot'); - } - const payload = JSON.parse(gunzipSync(Buffer.from(raw.payloadGzipBase64, 'base64')).toString('utf8')) as ProductBaseline; - if (payload.version !== 2 || payload.characterization.finalZeroGate !== 'red' || payload.characterization.accepted !== false) { - throw new Error('X12 productbaseline claimt ten onrechte een geaccepteerde of groene eindpoort'); + const raw = readFileSync(join(HERE, 'xer-product-fidelity-baseline.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 payload; + return validated.payload; } function eq(label: string, got: unknown, want: unknown): void { @@ -251,10 +230,15 @@ function counterfactualReports( solved: readonly XerSolvedProject[], ): CounterfactualReport[] { const historical = measureXerProductFidelity(historicalCompletedLateTruth(truth, imports), solved); - return [ - { mode: 'historical-completed-late', strictGateEligible: false, ...summarizeMeasurement(historical) }, - { mode: 'source-day-precision', strictGateEligible: false, ...sourceDayPrecisionReport(strict) }, - ]; + 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 @@ -308,7 +292,7 @@ async function productBaseline( 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 = {}; + 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}`); @@ -440,14 +424,21 @@ async function productBaseline( 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: 'strict-minute-exact', strictGateEligible: true, ...summarizeMeasurement(result) }, - counterfactuals: counterfactualReports(result, truth, imports, solvedProjects), + strict: { mode: strictReport.mode, strictGateEligible: strictReport.strictGateEligible, ...strictReport.report }, + counterfactuals: reports, })); } - files[targetEntry.label] = { - sha256: hash(file.bytes), schemaFingerprint: targetEntry.schemaFingerprint ?? '', + const fileSha256 = hash(file.bytes); + files[fileSha256] = { + sha256: fileSha256, schemaFingerprint: targetEntry.schemaFingerprint ?? '', projects: result.truthProjects, tasks: result.truthTasks, identityCoverage: { solvedTasks: result.solvedTasks, @@ -461,7 +452,7 @@ async function productBaseline( gatePassed: result.gatePassed, }; } - return { + return sealProductBaseline({ version: 2, manifestSha256: hash(readFileSync(join(HERE, 'xer-corpus-manifest.json'))), characterization: { @@ -470,7 +461,7 @@ async function productBaseline( openCategories: ['strict-six-axis-deviations', 'strict-sameday-deviations', 'driving-path-report-only'], }, files, - }; + }); } // Corpusloze RED-/GREEN-probe voor de vier expliciete productbakken. @@ -2137,7 +2128,7 @@ 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') console.log(JSON.stringify(measured, null, 2)); + 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); @@ -2170,5 +2161,8 @@ if (diffs.length > 0) { for (const diff of diffs) console.error(`XX ${diff}`); process.exit(1); } -if (REPORT !== undefined) console.log(`MEASURE ONLY X12 productfidelity: meetcommando voltooid; ${checks} corpusloze checks groen`); -else console.log(`X12 PRODUCTGATE GREEN: ${checks} checks groen`); +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/run.sh b/tests/planning/run.sh index c185a5bc7..1a74b73ed 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -315,11 +315,12 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then 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 uitsluitend de gereviewde openbare 93-entry - # inventaris, onafhankelijke 34-entry-orakelselectie en task-replay-kruiscontract. Hij importeert - # nooit reader/scanner/solver en draait daarom precies één keer vóór corpuschecks, niet in de - # tijdzonematrix. De product-v2-eindbaseline bestaat nog niet; de oude v1-tweepinsbaseline blijft - # expliciet een overgangsdossier en wordt hier niet als groen eindcontract voorgesteld. + # 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 diff --git a/tests/planning/xer-product-fidelity-baseline.json b/tests/planning/xer-product-fidelity-baseline.json index 7a2460ab0..37cec066a 100644 --- a/tests/planning/xer-product-fidelity-baseline.json +++ b/tests/planning/xer-product-fidelity-baseline.json @@ -16,5 +16,12 @@ "source-day-precision" ], "encoding": "gzip-base64-json", - "payloadGzipBase64": "H4sIAAAAAAAAA+2dWZPbyJHH3+dTMPRsruo+5m1mPLZnw14rNN4j1uFQ1Clxp9XsJdnyzDr83TcoqVs46gCqADbYgwdFiA2SyD8KrMofMivzH19tNi8+uMNxt7998fUG/eb8+r263Xl3PP34TiHKXny9ecGs89oQTRSHFADKqKOCcE4BYwgwYKVWRkmBJUVACmuhxpgoI6CllAv24uP3mnfqoMzJHXb/p06fTviPrzabzeaF392qm/92h/3v1cmdT3hw9uNnNpsXyhh3d3L2xdcbr26O7vOf93fu9jt1cm/3h507vvh689ePf99sXhxPh505bY+7n7fq591xa92H3cfzHT9/ZeM96r2z6pfgW+xh92F3+3Z7p07vtgd3tz+ctvvbm19efHzD377abP75UZXf3Xw8/2cp5qD+frP92R227ufTQb1Ux3fHO2Xc9u6we+8+uIP62R2c3Z32h5dH9f7uxv3Lz+7w+PmzbV8uuzCOeqA10hQTS7WCEjmsrEAEAkQtJ8oR6iRBQHoIOYaWIIuRh8ILpBt6zTv3Xv1ud/vWHe4Ou9vT+esl90o6BT3F2CgGvrz97rD/H2dOZ1nw8Y8ndfzp/BeKHv+0s+72tDv98t3+LOyta8g4n3R/88HZv/Q+9vm7vttb9+rgju6jNcHD3/+szKeDn4/9s2vin5w63h/ce3d7at4Em4YdX978w/kmekEoEC9+0zx8Otyf3oXsTIrIyggKaR02+/vbkzscW9ft4xHX/9v5r8Gv+WTnp1v5xdcb0Dtmd94HD7zfHY+727fhYx+vrNI3LnzCxm/m6w1oHf3nbzpi/DMSc/OcRuYmOTJElIgh84khSTGn53Sb+asW03jVEvawqr5Sp3eBSe/hvuucLK7tQRns/j0qLC2rLQqCqI6Hle/7w2H/cf7+698eDz+o/9vjYhWc5/tzfGR4Y/KDwxpTntIdG8qG5P4cvmRje3P0oo2NXtn2/JszlkxrLAka25tjl3xle3Po4oztubWx+TE4N4btDs2JYaPjJkfmwS92Bua/Bmyo21t3CB16q07ulToev+DcV43v7cGTuVH3R7d1d+bln75/vYVbBBB7c39n1cm9Of9/C+gWwDhBUcuhU4Y4DjSwkmJGLWWYW+C4FJwwwTzHGFnoJFASS4MERAQxYLn0hGcISjvrtLJEUcE41kMJCiLQu5BDEKrxuSB8hI8/sAdE/WEspygIAExQVMuUtJC8lLCYaUhKljhSDPaODPakOpZver82BpOOYRqmCC/Rw/GMejhO6snwVBGCsL63OuH4oLSe9PiwovGpYKr8+NRQVf/GGSAHwv5dOp0eCHlSUJqs+l8+L1hlxEzKVV0UymJVIVX1vi2m6KJM1Zrpcy5fe4Yf5vN116TYrD4Mq9ozec7e9gw+gb2NWXsgWY2ClfYMPcX1RWF749eXjbq+BXSVvr5j+QqPMbczw05gb3NWHcZYYIzBBYiVMLaKsJo+dBKwRvJV6xtC1l6art4e9vd3dm+OW7O//RwEzMakNHeeWUGV8c5aBZWwWHGOJaQSUcEtNgoDIQDEWkhmAfYWIqkRtgILTDJE5bzHCjukKdVeQz6QqHgZUPHmmPQhJHj4gUD4pDSVQCne+SFFFWQ1BFVMglEpr4n3D9a7TaEvbU2vNRGpa1OTRqirU/OsxiaNT4Evnxc3cmpqglJLF9N4dQl46lo3lJ56n4vfbpfkp5g711lByv257hdFVA+jpyVaG2enRVp7Vdc2zk1d92lyDklZOzYutRRje67tdNTUcqoHYFPr/eFb4dLg9D9qd6O8//iX493N7nRyh5cH907pLYqTEzLQGgc51dIDLiCEighr4BmQOOeeem2JsVxpxhB2jHDHCeYYA8OA5j5DTohIjIm0FhLPvcYDyYlJznuXbwg7NT8YJI/IGx7I43y4N37lBMVZiqHatmS0DFAT0TMJSzFcGPSA/Y8NdqWY5P3IROcBpkj6hmmkYpAVhQoErAhN5UUJWBOcQqFLPiRDLjTCE6o6f39aVnKsEENFUSqCaYUzP0hWDWshHjjBEFkIzCsLgRrqYhQU/bJITdB3gKpu2LfxqoC+mJCdn8wAAOvE9ocCWHcm7/kdMqplXgTrrgz5IFb77YM8xO7QxleBYSTWnflzRndm/CmMbs7yw4CsO7Nnc++6IzOB1a1ZfBiZdWfuAWYXMETW7LGI1p2Zs2Z3ZuRJzG7OwsNgrTvzZs0uCdJmrG6GaXvedBba2rNqhtsas+kQbuu4/+EZ9NLk9t4ddnanbnf/+9K69/ttLtoFCXPECEalVhYwppEUUggGsXUGcmEcwx4rZDAlkErMNbYcKOGF8wJSkWE2LqSl1CLEgKJAi4HMhsuiXbg5Vn3CCR5+oBs8ZbTr1es//+s2nT+I2z+JuJCslKCYSUCt0Esu97pw4EuHb49JAxoqciIr8rhyYtJJXGkwg/2LPCQtrT+ek6mBLC0nOTYBBh4iZ8bByaTYpUGsDJphRU5nVk46pTMNYKhoL1bFA4Ccmg77N14VgFf3sg/Yi9UFtYHY1ZXVGSIR1TEvdI30qMd7eJ3VaMAmnDhsoVEOaUGOWMrYcIJYHLLas3Q2pY1Na21jRh4GV3BcBt7EFzeSfheHqnEACwvyMZPmhtMx4zCFRu3IKoDtlLUNzu65uVmIgsN3ZDVhawBCtTzy8Gz4hAD1iZ0+78ZKxL0o9tRY7TT22krDjEYUKSIMUlYiwDz0SGlCEKSYIy8wlNJ6rCQQAHtvcxmD1DkmmIOeU2aZH8pQsHflBjEUbP74+uAROvyIHbA3aPMyVDuHPS4kKyUoZhqGoiXOU42j3t+61X7AmfQEMwxV5An2d6BNJkYmxWSCW2XxuvnEVIW0+ucetE9pPjUwvcsvE8oqIqiKeE9OTTqKmuanAM4MGZsZh6YtpvGqhJ86J8vzU/deHcxP3TO1RKGnyhtsz/DZkFWBY9deiSKz+UB+GuWFymmNlUFjE0GqcXG1aY0dHZoaldkGC7bfpayF4d13iZDUKHoqiOukrA1HK+Ps1H1Ynrm2E1/a3lwxhp2a/nKSnVqlDYawU/ObwzPhpdnpjp21nPb7m8/stD2pw1t3iqOTERwZ6TggXHIrkESAemwoEcxxQ4ylyhmOJCRYMey18UIDZ4QTghGFTQadAGdMIC+kZMZ5zgaik+hdtyHk1GTfPm2Ejj6gRp9159lo1fnJx6zP2R9SMA0rXRiV+k7mdKR0TVIysaYSKRWhmYyUTNgsHZi5JilpQioKAFaEZjNS0pHZTIJfiZSKYFlGSidU1nhVUulvLBx1PzCQjbohqejgXJSMZgaj9qpTx0VLMzURVRpjakGQJmFqJAAWD9EszdQ4D40K1RUEQROmhmOgibS8MaYWhL0SpjaCXj2fNV/ZbygKNd84gISarnXwwl6cg27U7e25tPphd/xpu7s9uZub3Vt3e3oIKZ1hxd4n8/K0EsZDISiR0GpsoHTaeyPOm6gwlphiQp2XznuNBVPCKowRYo4x6qXKVUZnBMNz3XmEsRIMuIFgVFjWL1PVL1nUb9qafgk4Stbzy5bzy1Tzm6iYX9lT/oA7NNRVypUiqylCEUjf+iIm8AS72u/LianZKQWLyixWPOPPFolLi0mPTCpmGR2ZCrjIiaFJMZlkvEsnsebE1KBS8jczR1xs5G+m8Wot3zcSlkY94+64b0PculTxs7HVJ2Csdh8MGVvgg6aMHbvXCY4qjFgQPUiWlQsbG7+ysWhi8MoWgEjKWBo0NpGCN3fqaMrYsdgUvWeningNvGd7/uxasO8BnB646MPueK9udkd3ePnjJ2J69cmNjwOTdUobzjDhTjkPKUfcEwwkhUooqCXl1lKlAaLQGkIBcJAaAbkSDuDWzqQgMFHmrUbcAACVFBwNBSbSu4KDgKlJwAHYCB1+RI0+9VYVQQcpZuo8NYmKyMoICpmGmcr2llS4TH3fcYRnPscmpicTM0cC3tOJeU4jk0m/K4ou1TxnyIipqR9R9tCkBgAzYjoA2HhVwEzdk80GTb0TLYOaxu1ZKXDq2qvQAN9+qs1LFzF2quS7yxh7TVc2kXo3KtZUQvoJY8fWgBj3WKIE8RLGNhCv59RmqQkNjjeNxKbWFy+Cm352h1t12n1w2ermxAptHP/YC0pLbTjhSjtDrZSCAIcdsNA4ShF2QHnlsFZCQM6Q8Q4ZiDOYJIC3lGh03vHkzeCEuy8p1OMwiTZ/b32+CB1+pAvaG6tyTMIs1XC3myAe1ZBVEdQxDSUVbVOq8fj6J5wssnRlYjKUdGVintPIZCjpusRkKGnhYhqvSiipc7L5KKl7omVQ0qgtSiUeZ/sEVbGlBRqboKQFGntNVzZBScszNkFJCzG259PmKanpJE9KSc0vvgAlnQ73EUh66d69ecX+c3/46fhuf3d8+ef//nb740kdTlsgf+sMIomS5tpAhAg02AvnqOaEI3vurcs9Mtp75Y3HmhIsNOQAIW+l1cQTwwiypNkvN1zawTAmDIRaYUslgmFcegyff+mvy2Tv2g3qr8uaNQ4DTWmDxx9b0rJ+VcOq0JJMNthNxZZgLrYUecOjlGliS4GSXfNuXQjUfUvtXRhFTVcmJpOPd2VintPIZPLxisRUgEbghJNR09LFNF6VlHa4VGypd6IpqOnLO9OLEL72RahoV2PNPZgpL1KVFH5dYjKLUFHkuSInICemKin8usRkFqHrEpNZhBYupnIR6iTWDKjPOsciBKMqJlmE2JUvQkV+UE3F45yLmql4PEP1hidUk1mGitbUik0jOTWZjVYzOAhPJiYTQSrRUlFOMTdxp8spplehog1wNT+ZjJZJyzh0l9jZ6jikF6HCQg5DFiEJkEytQakcBpjLYYi84XENmiaHIbltcRZ2mDGH4crEZFagKxPznEYmA0LXJSYDQgsXU7kGXSqHoXeiSz2NkwCndhtdAweVNeapuQWrdk5k1qDrEjNHy6SnE/OcRiazBl2XmMwatHAxlWvQs48ISYCvPith4bfgugZdi5jnNDLrGrQYMXVr0Og26Z0CUNMsQSwqYoolCF35EnRtAZQ1HLTYCMoaDlrDQWs4KKhp1nAQvvq8uKK0mIq6H7mpoaZ7+ZWJSa9ARRU5Kxrv5bTUNN67Li0zrD9P6OfMkZt9uaz5dQnaZJYgskaDPg/I+iRutJj1SdxSxaxP4hYjpm4N+hU8iSP02peghXtB45ag6xKzblFdqph1i+pixNQtQb+GhATC1zVos65BZWLWNWipYtY1aDFi1jVok1uDkpuD1jVos65BcTHrGrRUMesatBgxlWvQVYeDPv+vvMApxC2js829Cjp4Q9b+UDvrZ2y/8YUanGiiR0a1WEAF/dOSBqNI17/4FV6mwYlmemhUu3Rc0MUCUpS4JcJ9LOJFTyEZVfW0oL970t5Gh/evOmbny55C0jhtuu4patbEHFD4tF1Es31P9Atoztwg4uXb3fv37rA1B/V2+/48q6rd7fbg/P3bRJcISSUwxFhDnFdMKoShZkwZCiSD0ELuMWSAUimd40pbB5CCknvqhIaOuVz3ccYVocwQRhVg0g/sEiFx76oNKXoqcfPn18OP4OEH+JC4N2DzdB+XnckqqiCrIahiEoQSRc2H+wlSgz0o2c/TjUb+xiJU4LvndQdzYqp6RBT5tryiBFhODa/qpVcUNWcVmdDZOy2dCp3GqKLUzYo7LfCdU0HUsqU0XhUgVPf+ySNUryXeQIbq3m0dJ+GpIEqMapBcQCSddSgylw8DqO6COXnF/ZSxo7tEoDHG8gLPPmUtH91Nj4y6EeTEd4IMmhsHp1EkXXAngLE3QhyZlmFqz6fN4lILadK01Lh3htBSy/0Oz4OXg6U/qIPe3x9e7Xe3J/fmm+Nxdzw5+8ePFydOS1pSwp2W0GCgPOQUSIwVIwRIKzRSRCEDrXbEAO0QZwoQpSEFRigKIOMZWlICeme9JUopjq0YSEvwywOCcT0img8WQuGa4PHHaM3jU4QpiIkQkGqsBzvPQBJC8lLCYiYBJ1lUlwpXkFPH8k3vx4Wr2KkIN3DNTsWsnrSDnsanK9STHp+iTUm4f5dOqIcm9aQBSpRt8q3aTZoBQly1MwmU7bSqKtmdEQSnLdo9vi155+HS4HhU986LPSS+LEvJUY/Gi8I7ncWpLh4lRxEKLnD50/aGff44US3U3vj1ZaPspVPbS4P2xplKjItFoQJ7YYIBG9PrwFgUGHVDwJLgWcrgxvRZEo0a3qu8MU0OCka1vPXg1Hg5vHqnt2ewunnz6pOT/+ZH887Z+1S/cmUlMo4irSTViFMs1RmjFDCEOKSY5pQ6gwykBgPGzp0UJDGcYCAIY4Jk2EoTQKQC2DHKkNJ4aL9yUcZWSKTZKnz8sdW3mJKtIACpgBTqzgBxIXkpYTGTsBUqcw4rnkmHzjjZJturk5MJTF2dnOc1Opl6D9cmJ41Vy5fTeFUAVb2zzZZo3j/TFJnmn/9X0cZ8nFda8Py8e4YBD/sTjcyXaG4iSLVIc6/r6ibamS/R3DhPLcfcnsubb2ne8qKTNNXipjxNtb85aPG0NJVoav6H/fFud1I3b37/n2eeentQ79+7f9H7+1sbxyngFYUcQYa8tkxI6oUABFmAOFfCa0OYkUYgraw2WjmMscUEYWsREEawDE5BDTHjTDNhlYLQDQ1Vkd5FGxSpakJzel9RYFdRb7zmSey7hr1Ry87qGcVPVyUlzU7XJeX5jMpanmgxYhqvfnX7cj//r5yWlpFrNIiUFmdqnJKWZ+r1XNXE3qdRGYcFtnZOUMVGSzG258EO2PU0Exi1vvipuegcYfpuf3s8He7N2Yw3f/n+u8ReJ8YlldJ6CYHQFmsMHacUU2ggJdhCCakWSElICFXYQw20NFJhC71gPrfXyUNHPeGSceKwcSSIRI+xy0ckIuhLTsAoKGp+MIgUkTc8IMX5cG/IytEIMZasoNcOzHfoqH0wQEfhNzzSUfvwSkfXJWWloyVKWbc6LUJK41UBGXVpJgtGsLs3aigZdT/XTpqRURnVJYsQYyyx8BAEO7udOqto53BoHY285ctK2nlD6QLU/aLN/Ddj8JSTLUMEwaKM+IqE3rygmhJGBCZT/ANB4s+CKjJ6ByiqKWREYDKaHipuE94UOa2mzL6F9OpEECzKjJ91lNIbpdNrFJa8aJlCpG/UlDce4W1RdasVgWR8pfHCBasvLrER/JJP8/oL1uSPHXqnqHqm153kswV4pjY4nEIcf7LXncRjE3h48p7E4ojJ8WsMo2F6iII2k6ltjuw0iD/q607C2VTtqS0eW+aoO8lmb2RSUOkoc2OQqmJH7Qk0/eBPNN854Mlf53FSuxeV6Nk6d4r5n75/vYVbBBB74+5M/KkfkcopRoWRDCsAKHGSc8iUYkB4jSGXwkMGmWEASwMV5tIxgDCH3DEGck/9CGAESo+k5FBRbIYmQiDQu1KDMiFQc3wCD8qCxx+fk6H+Q9rZ8spbpqSF5KWExUwDXUWhUV6zZxcFvra1YaNmz25gg+aQRnU1e1xzekTNnl1SVL+JV/i9+fHhaT3PbHzm6CUIazoj5vRAmG6OuD4PzCqrTJTofdsS8iTIqHAuL9mq21mTYrP6MLDqFr1I2ytKtr6m7BVjt+qOq8PKS8qEJq8vD9t7Zdc3kVk+iqbaM+wE9jZn1eddBKnpQ0+ZPNH6hvlzJ1L4dMfOErZaHd3N7jaxL5cyYaDEnGrigGWcGE4dU04BhI0UnCOvMXZMSMYp9YIxRxiBijIoOQYgw09cEqE0AFApZJUcui9X9C7UEHpqEm8fOEJHH2Cjz7fzJJG3f7NR63P2hxRMAkt9r21e36h/vsliU1clJY1IRUHDilhHRkpNRdirkpIGo6IIVEXkMyOlpo7Rsn8rjVeXQKJuiGogEnU/1vYFoxpmrgQ7s4fW/v6qMNPiTI2D0Kj4XUHYI2Hq2AqwizM1jj+jgkkFQcaEqWOLFC3jXu35rNOBz8jYUfPtwXnv4uBzcB92R5fYQCs5oMJzBBQHniEspZHIcy+I95ooJLXETCsvqWEKOYylw8YQhRDyGAqT4R5rKEMcEug9llbBlXsah5bHPUUPuStCRBkHqCY+VBR+qIimZKRkQinJUbkuKdMHHSpiKBkp6fjJDAGhp/qtpLln2TTaeFWyaXYs9xTWbU1yz1NVbR31KL0gEJRw0EY3EBxjakFMJWFqJKASvarLM3Wa0ERBJCVhajiKMlHY5xL3apx7lkGTPZ81v112KPeMLMoa5Z7Ll2R9vVf2+FiPNblRViHnucbUAGCF8UIrThEGlkPLpJYMYuC5Z0ISSZ1gihhPvfDGYkIwVLmUOSMY0BhJaLWSzg1FHwgL2wI2PxjONAu/4THRDE7bHBCT1H6ltjEZMQPkRARNQkWyrJ8CAlX17XMt3BBIP7XPtLyQRa44An2rJtWEk5oybS9oypGlAa/z8wzNa7K08qIye3wyvS+Sovrpcg8DhWoyA6s1pdFJBlqNDLj5ZI0kEGg/05Ik01yb64AR+GEM6elR83PKSsKdX1PjVQlJ9e6K+QoQ9U+1iNQ6Oa5NQ2cFGFqWP9VMrjnrD+yEIUdBQGeGn8bmSK/z+HWmMRebipDRnRl8EqNjHeXjFzpqtAxeaFSSGVhkcxy6OjNx7uaQJSZ3pqnorDu0MQYYlX+JS27npMmNWbWEwdpe+KRFi2C8+eDlU+9+VO/vbtyIwkWUau4IYdgae445AeSUAxwRQp3wBhnDBFFQCEeAoUJoQDGC5/1OmCFDczwmmULYAsS8Ydw6NJDHaFlnDNpk7T68BA8/gAudsi0G56mAFO08y4pqyKoI6pgEv0jKWYxu269Iy6F9bhuRYpQpFZESE62rUAGSdWLSwEWLGHJGMTUpeVc2MpnKEEWpbBUwkhOT5vo0XC1dTONVAVZ1TzYbVPVOtAikIjEHNLjfvyCJqLMKDUh4StSAiBkbLKhQAH/jjY1DFB3FfRMbOzZBb4FXNlHyYVTiWwGApIwNs3QcmJZibM+nzaJSy0meEpRaX/zEmHQ8KfPTG6tO6o1x55nzTX6zEtSKSYskd04qahg1jBDHMcfWEEiUwpgYKZnmRABgCBNCOcUdZgI6p32uiSDgjAmmPYWaOu4Hlni96qS9dJGHVM+LXMuLdMeLteHFNUtZS7ouUUqm4cWFYSLTIaIGjMrC0rNJ6QBr41UBFnXMzFNR9wNDC+SloAhFNVTXc5VSinXFWVecdcV5BlJ+NSvOpfsrXbK90jWvOJ//tzZXegpT1+ZKl22udEWZ4svYzPtVx+QBFVYb6pJP3ppvrMkU77cvnefBm3U36petulU3vxx3x+1pv7/5aXd6ad3dzf6Xl+rvx+2dMj+pt+7l8WP+wss/qIPe3x82r/a729Pmt9+92gK82W6+OW6/vd/dnDaPrWo3r92HDUTxp3YCMgcgx8ITybjgSApgHPbOAoO0t8IA6bmAkmtuHdTOKWqUFA5Iolw23xxyI6S1nBvIucZqYH4DK8tvYM1HqH0ECR5+IBA2ZX4DFAAlOIq1H07HRWRlBIVMwlKsyEGs8KoCJ5yMpq5MTJqnrk3McxqZTNWh6xKTpqqli2m8KuCq7slmS3DonWgRCQ7dFWhyz5ol4q9j2WqBxsbpaonGXtOVTdQgWp6xCcZaiLE9pzZLWS0vecoEh9YXP0X72qkw69vPuRAdzAJxygIMQi8BJYpoyhgmwFqKoYBaMsuYBNwTZYRXCjgPsaTOYCy0k17g81sylKWo9dxBQL3AVgE2lLJA73oPoqzmCAfwJHT4EU4mbYIhkvkRrP3riYvIyggKmYSySFFEIVCDf7iXlYkpoBrOwqmk2Fka9GXlVKWSF5URrWjNl1ND0mKSY3NlYtKklbzR4r+bijSDyhstzVplW49n/d1MWu+Ijt6jW1jwqCtrGRWPSGclym12LfAGE5EMNJa3cCw5d7IOfUlzR6eUj4pnFPTmS1lLwsZGr+0CjY0TV/RGCN+3BaGighshzlzjtg1Pft9WVUGig/ffjiyD1HLHn6QO0mjs+lgf9mzk+X/fxKmKS8+8JlhrLKGzDEgPGXHMGmqxsBhJzTAhjGvDFNeAGe2xFQpTaATgIkNV3lJOsQFGM0M4pEP35hZBFW3+IPt7WhNIRXvDOE+Z2E7b1pj1OftDCiZBqaJSJxUglSlGc/Hkv35v9OFbC9NSaFLKmvy3RCkzJP9VoGDmBkuTYBqdlj0qjVeXaI9RGKNKQtNThahGFVQpQKZEyZpZk//oeFNp3FQaNHVN/rts8t+oYE8B1CVugDDTxSFpGVe157P+evsC1uHRt3E8ss4TITVSAmDlpZf4XECWaAGc8FZqZI2ABFuPBFAWKQyEsBJaYpmxyuAcHilFDNDKOmgpbWYCrni0QDwqcvkqnpfnanUmXb4Vjxbhva54NELKikcrHl0Qj0a5nAVP5lMVPYMu54pHKx6teLTi0aLwKNVpg3uDGXdKMyMBcYxRqQQCTEsFGbIeYiQQMQwDxyCnjFEjFMMeUAIB0Rk8EoYwg4kyiHFl1NCdTysePQ0eFVUtqci/yeBRTReNIik1hSnTUmqaZyzbe/214lFRpLXiUULmBqtJvFv2qDRerXg0Y5PBgkygBB6N7YUxytSSUphxU8e2wFiGd/zc8GhUpLMA5RM3wNgUu2Vc1Z7P+uvFo1ds+x8793d3ePlf37/e/G53446fQShRqdVyCaBlwhgOGNZUWuOsMgY7p5ywyAgJuaCQEQQNkMph4T1FmCDpsMpVahXEMiu05UQIDp0e3GOwCH1gc1dZqCFfAn7gpDUfko0FE/jTOZjWEFTxdATU37cwop9b2heSSbduhghRwKDJxMBME4jpKegp1Uw/NhX0EGjGMlVDC1g2MjMOTFLMDFX0qvo5prV0Oh82Xq0sNCMLtfdQDOz/FvfavsziM8SKYAG4pYyFkQYR0/DQpayd5toWcAaK2zq2mQUcd2UnvrBBYxPNLMbYWtRfMW5ro1Nhz5ddqahJRX/5/vWffvi3b/64+fbff/jjb3/4t99vv/nh9as/v/5Lqhm7xxoRJh1iHkopqDICQC7OESNnvZaMKq4NlEQxRzkHRClBqKFenCs+5Jr/IaawloYoRrBxg4vjsd7VG1S2gTV/ef16B6HDj9UOWG/gykEJI5gs29DZIRkVkZURFDIJK7GivfQVz40DJ5yuON51ickUx7syMc9pZDLF8a5LTKY43sLFNF4V0BIeXRwPd2J0g+s1sO73tcITNKpj5vJ4o3bpFzznZolWzqPL4y3P2ER5vAUae01XNlEeb3nGJsrjLcTYnlubZabm3JiGpuacOKhSQ9MDD8+DMxfIe/XJTf+juz2+tHtzfGnd+/3L2/3h9O54UodtvgkgYVxp4DQQghqiuPAcAqi0pRBpj4li3jEnzqikibTneuKCUoaxIpYonttzpBBH1lAJ2LmSAx0cWgK96zYotNQcqkBYJnT4MSgzaaE7AEAqutR5WBIVkZURFDIJMZU9Ka9wmAKfbT/kuXiBhidUs2bZLVFKmpeu7R5bE+2yyp5jcGncg/oCT67zmdgtOUd46TLWrul2l023W+I9sGbcxS1eWmwpA0nm/nBwt6dEVAli5IXhTmvAgUVSYqaFoMgwaxTnyGlNCTdAWSehgdxIRhB1mDuGBcyVrVMQMiAA0VY5K8TQugwI967bEEZCzbqFfbgIHn5AC4R7QzYbI6FOkm1URFZGUMgkjFS2r6LCSUK5Ig01jFTWB7oiOSqr5uI5eCsjLbFQQ00V8Ow9tm5G6swRKyON3eBR4MShVLWGsYw0Kl2wpIpy0tpZU/BWRpqjYkNJ4e/kPbDuSorb/CwY6e5cpGF/f0xAkiFaWsY5xNgwZ41lEElAgccIYew4N9Iy5DxghhulDLDaQ6uAhY5glavOYIlwxnGjOKMEejIUklDvwg2CJNT80fXpInT4kS1Qb8zmg6TO1B8VkZURFDIJJKWwAgb2enz6AfXbxQx3+XIbSGogKeXAxtVU7LrKqlkh6fqkzAFJNSBed4+tkJRV9hwhKYYdsLOtJDChD3Q5U/tTxkJSzEEOW1uwqSpp7QpJ1wdJJaA8/h5YISlu8VVA0mH3wR2OO+vygSQugZbAWyUw18pighgV2iPDITKeGSyVZsoaKyByjDvrJTZWwXPoCTOU257EKXJMQ4kpOdcOH9pVdg0kDZSxBpIe7V4DSQvxX1dGGiFlDSStjLQGktZA0spIayBpZaQnYaR8IIkLjKjXjBBqEfJUEqk9oAY6oLzDDhLNkfNGGiOJAAIApzTRHmmgIKAsA0mUOMqwUgobgCAanG23BpKGyVgDSY92r4GkhTiwKySNkLIGklZIWgNJayBphaQ1kLRC0rSQ9NX53z+/+n/wbQmefxcCAA==" + "canonicalization": { + "json": "JSON.stringify(value, null, 2) + LF", + "gzip": "gzip level 9, deterministic header" + }, + "payloadSha256": "a97aa10c39687e33cabf23ff7a4f832556401192f4620a6cf643d97e49160783", + "payloadGzipSha256": "99476b658c45a4e5fcaf42e1b94ffc827437d26a98dba2db51c6a0f9400c82bb", + "projectProjectionSha256": "ab8fef5851dd85d941e7f7f862c4ee62440e3bc2dd8f5299250053b81cfd5c7f", + "payloadGzipBase64": "H4sIAAAAAAACA+2dS48kyZHf7/wUjT4vAXu5PXhdrAQJEERIOknYgz/JloozRHeR2JXA7y5Ek5zpys6MrHjlVBbzMIfprPI0i4gK85+b2d/+368+fPj45/75y6cff/j4mw/0T9P//yH/8Gn0L8///feZkn78zYeP2vooVYpkwwSQNPXkYpZAlUChRck1h3MkgvDWsDBLro4tJXP9+HXd+vv8Odfn/vnT/83Pf/3C//erDx8+fPg4Pv2Qn/5n//zjf8zPffrCz719/Z0PHz7mWvsfn3v7+JsPIz996X/75x//2H/45/zcf/fj50/9y8fffPhfX//9w4ePX54/f6rPv/7y6d9+nf/t05dft/7nT1+/78vflvzmZ/Ifesv/fvZH2udPf/70w+9+/cf8/Ptff+5//PHz869//OHp3z9+/YF//dWHD3/56tX49PT1+//mCijiCEiSpSRVFmgtMTqW0KYaYGO6MCNn6AM5Uq/MXnoM5+lHflppsvKnG7B51Z89r7/vf8j/4dMPv+uf//j50w/P0/I5tWEdIQ3nlkF//vE/fv7xf/f6PDmIP/3jc/7yf6Z/Ufjpnz61/sPzp+d//+cf/9w/59/1b9yYvvTHpz/39j+++7W/rfXPP7b+28/9S/9qzdmP/+Xfcv3rh3/77C+nJv6Xnr/86XP/Q//h+dvH4cM3dvz8w/9pus4f0QE//tO3Hz9//tPz78/ZOevEVTfOOvLi4/rjn3547p+/vLhuXz/p3//b9K9/W0ZeLPNXO//6UH/8zYfvP2ufxpj+zr//5A+fvnz59MPvzv7WH75e2lye+neG/3XVn/98prVffPyXfzpxZ8y5wz7jDl50Bw90h2bdeZq9O0nX3B05zhuZd2a8I2ee1z9ol/9u6Bd70Ma8O7HKnSP/bvClO7+64Nrf4+xv8/Pvz7z8/v7s8cnluOjd332zk3++6Ni8Wy+dsote/D3+/cvnzz9+fYv/r3/9pzNx59OPP/y8q0qWMxq0PlxqUkcfqkKZOgcQjlQd1RkjIjfCGMmj9CBkwI4BH391enX/9TQo/vbM13LKWVTZuaZOCUwbQRkF1aJJER8MIElHw6FYoJmgt06pAmUP/nlzcDZofR+wLgSrS/fwfJC6dAPnbt/FwPTNvfs+IF14R1wKROf/mHYwl86a+3Tx6r58P1+7urKvtXLe2HFHxj4vfxDOP7d0kwdhXDY3Fpm793OL372W/nIKVd+97M++6M9bfuYFf97oyyaff6n/5TuYOfMy//il5h9+6J/PffS7/Nx/m798+ZlVf/XNuh9h5IRGqDRKU4803EGoAZlln/haa1SnklupJXdmbizErRF4db1AhltXnSdDLMhqWtRbzoj9lWSIsooMUWbJ8OzHfwcqlD3JcAYLUWaw8OTDeR/OerELFq6iwg1QeGUzuAUJ78qVp/dzV57ez12ZR0GUG/ty5gsXODPu2pltGHj6ZdcxENZh4HdfdNGn7RhotbnLYOxNSo5aPZGzk4yhmkctXkzMKrcBwmUAJGkDuZaIOnJfiYGFudQBOVXIFtydtVdCJDNvwyzXplqDMfroCgNdc0s6esM26rdnxEsxcBEFroDAmc3pUgR8c6Y+3c9Vfbqfq3oZ/U43e7vbevIFrzB2vHljl2Pfi539LPbBMux7sfBZe/fFvufPf3pJfSjapbqmKLmBaqHwcFfk1iua1648OFPlJJiCrXAzyD68D8fk56lv86rz1GceLaVGpJATFH8l9fG6fCDP5wN5Lh/Ie+YDf/vf/ut//jXM5gR5LifI13KCPJ8T5H1ygnTrRA1vStTM4x/xGmfsOGdsAwCu252jHucN6gYIxFX3Bg+8OWhbQHBVThDpQHe25ATPJCtf4Y4f543vioK0FAVPb+9rWZBnU4Lou8LgGBm8NG6sObNwSz44kWISpcIloiNTySURgSK5FKPQ4CCCknltThCFeu6j59LLyEU0sqOOgNbNIiVR7p6omI3hWTVjLcBQMvIwsfUwSEcngnhxIugyDr4MRteMtX2NtYVAuIwGXgaZ7dZ+E1heB4W46NrizhcXbSkYLsoJIu1s7tKc4ElS8oq5vq+1vgkN6bVo+O0teQ0b8sWU4E8v9aNzgliyRqOw3iOnqqmqSDc2blVQcmaWGqHFxAGqqHvu2TqrY+9lXKDDravO02EBU3UtI2FJ3cZZOqTv6NBXwaHPsqHPoKHvmQ+cp8K5jOC1hOB8PvCRDnykAx/pwJumA9e4wutduZI/4y0EuKoo9DBXaE/+W4x/tI7+ZhOBtCv7xXDQgo2stp7RUhtqntwgQU6p5pxqtCrdqeaMgVZFNRewoB4kH8/tCS/HtIjwR0x7xLRHTHvEtPuJabeub7llecs7jGnea4sULqO6NyG3VjWHSh7ancRHTzQQ+xBBzIUGdgOIUdw7M64tbmml8oBgaNbBx0ithgIQ1V4dEigYJx0cUoahNhfn4hytBWvUR3HLo7jl/Re3LDoTXHE+7JdN1YXnl4uyBLSrqbTl9PLVdS2y7OzSLx1dyu1OLr0OdE8S2ApXjF7GqJ5GaczBiSX1EX2Mwq7ZW2Ym0q6aRuRy8eRy26rzJ5cqjJOeADFnV3htNwOtq2uh+boWmqtrIbhRNwPNFbTQtYIWmi9ooZ0KWm69FacDYe9M+cNrWtyPc4a3FLSs6jvG45zBLeUsac2dkeOcSVuQ79ZFYLSpCGys/5s5AmAX/s1swz64UVMD3K6noVnpVqlQaUKlBbfUihmXWjWN3nuz6Jq5iagNk1GTGSm5WpGSfSX26WgCUTCrY7hba72OqXHeSituEdK5FBysCJRggNUc3AoyedK0pYzl6G0/7Qh+J8UT1xrb9zWWl5axLOpmxn2NxaVFLGnJlZV9jU1LS1iOLr2ixaVXY/kze9ZYPuyZXY6AcFBrA9y2s+EMAjYLwKZeq4FySdFqb7lW7j13b1Q90DyhCmGFyJ2nkzhioeicLxWvbF11HgFdmjYvzcTdsJfXNrTjuoZ2nG9ox7mGdrxVQzvONbTjtYZ2nG9ox30QcM0GMDa0teL8BjBune5DPM4ZxFtn/H5Jb/a/N1vgfFHJxDIChBuz+dUbc+u0nx3niz0AcBYA8xjNavcKuVbMMIhrLYWgCdceiFK8iPEYQJQScYJIiZMrJeK2FgBFUiKNYqqVOkpFBvFuWEKq90nwpo8MQa11YYiGWIhyUW4SlH0DAC7ZnsaK1lu8vD2NIxN/iPsai3hk7u9W1u5zbdeg9asSVa/jPziYrGcv7EL+k4M7b+ZstQf+ncE/KhWJBCsP7z1NIiXUGnSzQbWMkUcdXJKwFzQgGi1akSFVhZoUOI9/m1edx79eVb0ilswtBeF5/Pu+eQE11gGgxjwBnv38J3jS2LeJIe5d2AxvXdOMsmWHfiUVeF/OPL2nO/P0nu7M8wHObNEEowMFzt64Mw+Bs++hzFoig0g9Kigxd8yj9j6a9zAY3awmyY69DTHOmJp0hYiSobeCC/sapjDH9x7m8NZPOR5Y8XJfzlwJc/d1qvb0no4I/4HOO9+6MxvD3OJxDnhEmMN9jzzBALJ3hQwVhIWw9VJ8EuvMYEKj1mEN3TV3l6JOolLGmFgUzFeEOb3zMEe3Fly6ts3GLTh3b948HbAF4eO84S2B7r6ceR7vJxc+H+fi1pyNN+xRx8WJPTkizMmuYY4HoqYsmIWtJMhttKksR61Jax6RKxYlkkptOLAqaqAyNAWpbEu71IFiLsqluSiXrkW5NB/l0j4wl27NP+lAmLsvZ57e0515ek935vk9OTPu2pmNUS7d6swy3e7McvTCZfBQddTWxVpPUqknmAS1yaQ1syGJGrLXQSnZwJIhF0SHDoujHMO9H1ne1xiV/p5mwjy9pzvz9J7uzGP00GP00NvNzLXGlRWKlqF5RIdIo7LU3iRTUW3dp3ycCfno8FWfBUJL7j2H+lgR5fAR5R5R7hHlHlHuEeXeYpTzpUFOjwhyuq+sJopVYiAUFnPrkjW41N6Bu3saAJ0qcGs2eqGAqfLUB1g3RWRfHuTokZZ7pOUeablHWu6Rlnuk5W6UlhvcgWtOo1RiTzj6UIqaSwkaBh0tW83etIw2haiaSDJnrk2L94rLo9zd11jCjRWSrr18tozJuzNn5mOc3nhw2TVffEOIuy9fnt/VTmrcdY/HI8h9X3sSSdWda5AXKd0xkjbIMBIiSmMckJp50y6RidgwDTTyUb1y1sVBTh5Zucd55eO88nFe+TivfJxX3ui8UprANDYhcaVUUjUXJ1IdFjqiS3GyaF3CmxS16tAqhjCOiGkq/PIglx5N4R8eTeHrnHk0hT+awh9N4Y/Sk6VRznoIhFTp4DLAUBDamLRcijkGd+hFTQuWltnKkN6HC1gvmkIoL49y9ohyjyj3iHKPKPeIco8od6Mox4Nw5Jyh99xGcdNhjcy1Yq4ZEGDSyQyskqUTdp16xKnjsBjCurzAUuIR5R5R7hHlHlHuEeXeZJR7j2k5ctYQyKMoZh2hKfOomEgTYHUJyTmadVZLnFnMupFB7Zy7px4rxZ7BCJ2oJJRcBtcGgN6ABo7G2MOo9A7QUs511CmkpiIYBXpGEtkw7Qd5kXQur5idiaoz80h06cSft2nwzNQf4UVCyryzwcRLJ/+8TYNn1J8JFz0Sa/Sf04xaNS9VgEZJR2tAz9m7SQQa5dUy0BTLdKBfKgi/fCbiVlLQFVvtaKnEAHNEzOKtIo+GZjam6a1Sm+WiStxVrJuwMUNVKHZhEtDmVeeloEmCWaI1lGGj8CsnAWmYrZKC/vYXzwLWhR/4O2BNH++oBm0614v30pYrvrzCmwv+7IKMysnX7BkVN9TZaZjO7xsVfQM6KiqvKh1EPtIpxy3jYuncJX9Nneq5O7yjV9P6G2CSlHSlW3CwW1vqYMhsnVsEx7pFsAUwNcGqvyyxQ/+yxHYdKqsei4tjLNax5umb/LutU+yLm1STq2FSdAxR0RQ0yCOP1MCTa2ONJK3jYIJSjApP3XwNek9c1w6XTTJyDc9tBGdxbcOiE/QiuUXknB2Rhpcmo1cfCWDanDhipIQc63HzNMBd2/qevGVft/k9fUIvB7PXIedpALtm9Eng2sPob4PV67DzNEBdHYp6emd2sPpFMHodfJ4GoFeYDQeYDQsR9DTAXDX7JLDsYva3weR1IHoaQK6abbs/2d8GixU4+jI4zPOoLeTRE4o5HwiOBlJRywV6AfdUJZsPQ8BcWkIqgyXr6No9mUGRaFh695SUOUuTbHweSDevOg+kmYxaTQFaBrf06tG0sG4yEcwPJoK5uUSw71ii2S4LmEtawrWkJcwnLWEXAl0n1r4lZwFXGqO2tFncmzcHzKfd4Awc12VxV648v6tnbNzxjXkMpz3tsCBMrefOOUeGiuREA0Z2KaWLlpaqSKNgrxVNisqI4l8Hx1bTujZfSRULdy5u4Ek7juY5D++5JY4R1iLnEpbUck+tsztyVSodXFORuh4glw36XLHHPvmdS39ZR4ynvY21O42nXWHsjK1w5GzaW5j6fFfPwHjjF/Yxm/Zn/ovcsyavoZwBkvQwQ81ZwUdhtPCBiloVOCpmtugKU7+4dVXoF/hv66rz/CeggjEowjBP55Sv5D9aCYB0hQBpFgFpZwacVQalWQikqxRIVzCQ9uFAWdVseyYzsKB47cpe0HQDCfIqfS6PA/3x2MCCsirxc5rc2Pf+2AYgvL/7c4TAzJY0/lV/8EoW/wGG7wgMqRPhVBM0VCv7sGziwdRCahtd3Ju3kjSUqbfKUTm5l6SpGLScV4Jhdgf1lj1ZeLLiU5s/VZVswyVNLSEWYsOxSc2UsxhElZEzghZP68FQZFFJ3ZqyUJrZvNrSOlZeVLP48kW3g73fvNxeB4fLikLN9r6+tpAQ3+b1nckgLsLENWnxWXvxQlb8QYr3Q4rNS+0WkINLlGpiufSaWoQLdO7QsPaUiDvkkTuX7I6mVEenipcyhVtXnSdFh9GSFMJBeVTTV5IipVWgSGmWE89+/HeyorQjJbL6DCTS3ChAujYKkOZHAdI+owDp1pPA6MBRgHfmzNN7ujNP7+nOPL8nZ8ZdO7MNCelWowDphqMACytTS4UTECVo3X2kPA2KGKkPa+AtombuwYbDB09iNQXChBqGyEooREm9j+6UJ5GcMbVQUik5j0w2OqZauARWZdEGg1B8cEaF4SIY0NZDIaWDN6knX/CKRNFlInyDxj7d05V9uqcr+3xPxo43b+xyDHyx19+TA18sfAMQfP78p5ccmHik2kovPEqLqrVQoixeKbcg0DGxVpGpdISNhjNGtME5wIHHaOc5cPOq8xzYU+/qUymJJW06XsmBP5/yLOLAbw+HzhDU2Y//5aeDpR058Lf/7b/+51/PZwxPD7IuOnLVlbPO/IIwuCFZyFemrGxJFdKtx9/wpvE3V2CQbzy14Zozm7oT19Va4nHeIG7BQb/xzKhr3vAGHOR19+bAW7Or2A0uxUFayYOnbp3oIOw8NbdrlkiK3EsVFhk0nd+q55EUPLOM1EaQZcZaANWHRySpI7mSp5VAaJkkawWd0n5VPWlWAtQ2CrOSIEGGnNwaAnbykULGmEqSRq89262AcEWS8CSgXghKrwTCRTmh2NfYpflBWtYiua+xi7sMlxVl4r7WIi5FwkWdnLyvtbwQCZfpM+19aTdJ2+CrlW0WMuELoDj/Qj86O5hSsS6i3GpjLRmo5w5GIqn7qFSrumR07wJ1KgWBxIRTeSgr1XShjnTzqvNUGJqJG5COqtY6vZIK0zoqTPNUmOaoMO1JhWZzQJjmgDBdA8I0D4RpHyCUubGbdEByIOFx2qezzuABW9ttzswDYbr1Pj0t26cvAsI7uzPzOCi3xsF0IA6+dWe24WDCG2UHE96wZLSKg6aAhqMIMoVZ0zwouKlqQKroMfpordTO4RWgBo2v4d7V18Jg9G6tTGJ4DM6QRw0RCB3cipAUQfFMaBI4YLRCXbBE9CxJC2+AwZdvj0sBakPyIs3sVGkhDF40FnfaVi839jIMpqMZIL2OAV4Fg2/wyl5GQTkaBdOOKPhWjF2OggkPyg4m/KXLRJN6xWBLRTo0NamWuuaegbiGm9F0CtfVQy2l4apdVDAnxTAGuACCW1edB0EL8VwAMGdqOV6rcOqrONBnMdBnKND3bCScQUCfIUC/AoA+y3++C/75jUvD/LjS0LtyZR797Mbk58eB3125Mo99euPxHb6FYMcd/63cuEfQ1xGfzwGf7wp8WaUQsOeqXQupUSsE2gRGCsy9FkzANuXmRpcYWb1br70IaO8JVwJfiRiR0yQjJzx6n2pRLedGrWn2AgDWorQYTLk1h5RSC5UU3mmIbhCP8YOL1Xy/YtA3Z+pl2LODWc/3Q703Z+pl0FskJ0q7mkoLMe9tPKsHdgL6MsbzS4jnt0K8ZthzlW5QoEViTS0pW4Nu4SbqOoyZGvavfX1RyZGEFNo0rc8uIN7WVecRr/TWS26SJ0VsLvDQjLk3zZgU62ZXHKh5obgl77eKMrZo61/XWNkys2LlbBE68v5smXwoq9DJ5Mj7IxtIkNdVt9qhmjH20IzZhwffvmaMT1otQkPJW442smpKk/o3OXRvApnDKBeCqtEaaYsBjkNr8eqrxURhZKJKZVhRDi+SCxbqQIHccPRoqtZpOOVQaNlKxzFyAHiv2jdMo0ixbBbFzpobiktzgIv4ZY1W/7wGy9IZFAtnfdDe15eWZgIXQdfLQLLH9ZWFjMjLqlhtd80Ye2jGbMoG/uKaMeq1pwGlUEksLZWMQZ1zcxIESs0kd0k9hCAGojE2ocY00IdTOU+Km1e9UhVqI0fPOBJzzfpaUvx5JOayqlCarwqluapQ2pESJcGcaMzJxM/LTlx146wj+xDirSdiJzouM3hnzjy9pzvzdEDxoRznzBYqvLM7M+7amW1MKLQUCnFtXSjNYSHuy4WZ0jR1kBJ6sRISCNENo4tXGTWq5ZwyYk5OKRdhL0ApsbqVRLFWS3Rq/ugdVHI2njTn8nRC3HskKl7o63xE9NEaTqLl02RECWwTSvYU7n0DF9LB+9S56dxLM4Vv0Nine7qyTzuVL8q+xi5lwTd4ZcebN3Y5CX77ip9HQVxaGUqXYBBvRYPmTGkUFUmNaOq6jjKmsv9J2rNzRylGfXrj1xAHB+i5SBlUICN8fWufocHNq87TYJKelHPOXIGQ+msVRNfRIM3TIM3RING+OcO5WYM0R4N0jQZpngZpHxqc2wsiHaBNQlc2g7QFB3GVN3GgN/iYNfjOZg2uGpJB+Is9Y4/04DtKD3IHd+mWm5B4Nwobyo4RBWI09mnOHyRXUMDsuYhXSIgUgIKlrMTAplEw9ygwSkRw52kCccKKQyKwt8ypDYbhXHHaPGC0xDU3lvBWN5SLXtqkIu0kaUIzu1RayoG4yNrY2Vp8zBp8A7MGF828ILzJM/DIBt5NNtACSsBo2dnK9AIlTV4GVUOqQytHLppbbY7U1XobwbVlrNYLK40L/Ld11SutgYm6FgxOMoLj1RMkeB3/8Tz/8Rz/8Q35j+f4j6/xH8/zH+/Cf6vq3WiLgjwfyH908705P/jvwX+veMbowGeMHvz3j8J/WRtxoGXqxaoN9kySkmjXyDXKmGo4GwlEraVprZO6Z9dwzrUgwVr+g6nrsHdjK9iyaJReyFptSklldLQoCLUlaMMiCuU0bECZKpDwmzlLi/lvUXkdrRG35x35jw7f+/OD/94V/9HOzwA9+O/e+W/oKMKl8HSwplNxpkrXVlNjb0xRlEXUStVsBbSWwc0zJ6wO5pf4b+Oq8/w3WrLEFWrRKobptdWg64pB52tB50pBbyQNMzc68NrkwPnBgemXg74Nmz4+rgB01f41bahlm3clPXDvgXvbyli3VLE+WO8dsZ4StdGYU8VaIcdokmC4ljQyVARs3EYzspGRlabREKZ9ABXoImIrWW+gYCj5gNJGahw4sjTFAdl4akvsacTIgFhBAHJPqY7mCYNGbmI3Yr0Ve1Her+Bz0bY5raidu2xqelDevVGe7PoAyAPx7hvxHLUDGvuQUHOjcKidR2/w9fjOK8Qwx7BirWPpPaeawzuE5J4vjIHYvOo84qFVj9bMKpoVzq9EPF03BkLnx0Do3BgI3XMMBDrQDOnp3BwIvTYHQufnQOg+cyAUb7zvUzyO9+7Mmaf3dGee3tOdeX5Pzoy7dmYb+emt5kDoDedABEsWSkmBW1GyjAiZx4AydFBiSFF76UJkapKEcJC6pZoYVa2uZL80TCN0wssWMAkC9OhZ1Yh6hmBIOaPq9DXQBCJSiZG0cytVpW5o91M8eJequB/9vUFjn+7pyj7d05V9vidjx5s3djkH6lFzIBR/4SnxkQKq1Falj6yRibGo5pogFLGhDUaFlGIaz5NL60AZw0bqXrDrBRDcvOo8CKraNPWniqYMGq+dEh/raj1jvtYz5mo9g2+U74u5Qs+4VugZ84WesU+hp9uNx8MHHzcePm6dwQw+jgLXzbo3PM4b2zIWgmTVgxYHPmmxgQTvKpX5j5v/O31+XjEcXtZh4OnTdlKAJfv2+2mzlrU6T3OaBAYokxu2PKzXSI7SitSMtRVhwq4VrVN2H7mbr5UDjZGx5sLqmXVSHE3Wao3WRk/IMhWB1i6NUinFlHphpGYdRRpbxrJhPIQdPBw+eL/h8HF0xjJ4Pw5cNsnecF9rbemYiJd/nlcfhNj5SYiFLPjmUpfvLx/47T2+MhpeloHgC4o4/zo/OicYapEi2ggEL40LY7eUpvpLTMINA1OZ1JdRJGUeWKBEjTypM7uOSyi4ddUrZZ/Y0xALNelcu5xFQfoOBYVYV8Hgt794FqUu/MC//CQcxLojEpJqmhsZEXMTI+LawIiYnxcRu1DhXe1u+/vZqD8qQe+vEvRBgu+zEhRjHQh+93svBeNiVxAswTWSlJSidNQWkYFbpqIxSkYepIJK4i0wzKQ4hJDU1pWFUv94bpM4G9p0JrQJnQjZn8bpk4/PReoLP/JzrD75gbUh7nShGzzuZ79yt0AnhOnG80qvO0Qbwp1gzDmERzTuv8KjTWOR0Ndp0ZEc6RPrhvgnhHrjkb/XPdqifcZhqwIhiR364IntKoiNp1Ls10OirgyJ3zs3cxK/PShCyQomjNZGSt55AJNZ7jVnICBTb39tZIRcucrUPA/VqghhJF47PBdBoURAGSat8BCQwIQtxXQ4y0NN6shtuGfJ4d6oZG5UW5JU29gwLOn7uLv76c13X7GpUuY0Vh0wRHXeYFp4Snoaiy7FoS1N/FcsXjwx6STWXNWdI9nbZtaFx6WnseSAOcDzFi/VSTuNFVcfZLHdH4xv4sIa1ewXceDKsN2F03ZPzt1eDty91cTdMEjTDALIBkOJI2rQsOEyRpFMUYK15BGpTlMNmKNzrZKJaDB6vXCEunXV+SPUVpOSoeAYHC3jK6tpfNX5qc8envrMyanfqJLGZ6jSryClz/KkHy6XfUQRjR9XQ7OqKNwOc8U2AOR9ubL/yZwf5opv4MW7+luZJ0W7Mff6IujdxoeL2ydsHRv6HBjavkN0Ezcd1adOCfPBUKgUVe8IgaEYKpgIMxaSDlJTCKbhzYabDlmrks1RopTUuPfeouRcwVuTSL1liAGWo1kaVkHzKImrJPMuEaNjEc37q2TvVTTj+9XMLCpDt11NtYUs+PZM3aemw3c11Rei35t7Vi9Dnx2MqP4qPl2Oeq9umLBlmOeXGM9uhHgZmYZPotQFDBrFxF7uiaq2ms2ol5LEKuTWAytaDZ2yX2x9mpJwQRxt86rziJdxGo4HUlru7cVMvIc49iuceIhjP8SxHzUyd1Ij8xDHfgimPcSxH+LYD3Hsh2zaQxz7oZy2N/9RH1Y4VYDmdXjJloihGU4T6kKRYdhQj+nQzTVLHWn4qI1FGC8pp21edZ7/qisUpsBWcvT+2hQf4sqW+W9/8XxrAc62zU8f78mBLHOlpC+NueLMK9y54NAuPBixjqFgA0SdGn8uEOOWVvpYVwMHfKxPvIENI80lbJJfzNiYH+rUlfLLeUqcdyouw5X9kj49X9FxiDUPX2xxCVjnXQrbgIwIsOrUiPlIl5h3zRh+91QcRo9nvupAgHTHZFmzdm9f6zg7Z4+EGg6QudfUsHXsQ2oky6WmgmDaS+t1MK3NG0KpxNVqrRVLH6mPgiO7dunT/FwOzT1xSr1ZC4SCENo1Nw/o0dMGgDwJaNcRcgWVfRd9LwavVzbdx7JCvJd/j/vYzAth8uTdfTEYXQhEuxh9oUbzMlZeNjrO05rdyubnGUWGWPJwxBqTT962F4PH6xjzNGBcM5l5b5O/CQ5rco0vYWJP3DxZ+hcizsGFRKOTDozwlKsDmmsN6G2U0JStVAzJ2pMZSM4uqabhijguEefGVeeJM0gzl6iSVbj2V2t1r+vK1/mefJ3ryNc9+/GZcK62VHVOq1uvaXXrvFa37qPVrbeW6tUDtbrvy5mn93Rnnt7TnXl+T86Mu3ZmGzny4mJTTivFunUOGznty42YK3XNqIbc6zRhkcEUEoK3nmF0kh7CVFrjGkMIy+CScu9civBKbqwChEC1+qQLgKBJcnST1IEaSK5esjeDXLPmgogY1PIkHM4yxNoGtW49WkxYd1TrfnvGPt3TlX26pyv7fE/Gjjdv7HIe5FcXn377an+VXPfFFkNOt9HrzjYqq/VcJk6TrpoiO4GWyKjUBjI5SVWGrmhpmpTgWXlAEgQpF2Bw66rzMOhVtLLkSmq55vyYzXsRAt/CbF66casRb9HXOKDglA8bncqPatN3Vm3KN5ZBSls0kB6lpu+o1DQkeJp53w1YUopevGYNab1CT9RG7ZBy4hFEwF44QwEvKTWuoGOt8gxnz4JeASk105SSe5c8VCXCI1mZWlVa1+KVqlQqJUoSUNbsuZUbdRjirrN58cg6U951NCs/ikzfQJEpH6w4lJbKDT0qTO+nwrRKiaZmyFy1T0X8SAEJBhMxd7MaTakP0Gp1avFuZWDL0LAL50uIt3XVKyIy4r12q9k0CQ55bYchreswpPkOQ5rrMKQbdhjSXIchXeswpPkOQzqc9i6LRG6oUjzTa7Vfh+E6ycs40JtHh+Gjw3BrF+u2Z+yBfe8I+7iDu3TLTUi8G4WNSQIgokCMxt7ZHZIrKGD2XMQrJJxiPQqWtQWiUxsK5h4FRokI7twk2zTEY0hM2cbMqQ2G4VzRATpGS1xzm8pHW637Y995YckVRYsncXRbh+EyGczY2dpHh+H9dRjiTZ6BB//dD/+1oNoTlRypkCWOLBVKhirSKWuxlHqliqkyqAKEhlQTBhdVlwv8t3XVef4rkwJ1Bu6alHLh1/Lfz7rBywDwW73hc+h09vOfyMlxXwacy/bRqTTyZUeuu3LemX040PHGO8Fz37hb5efdufP0vu7O0/u6O8/vy51x5+5sw8Lvvu0wMPz+mw5Ew6pa63DLPMkE2NQdiNYdLEZPpXobCJggmo9mglSq1ARA1UiS1tUZwfCmnHuuY8JSFMvFwhNTZ9cYrtGtTkN6c0dUSSXXRK15KcGJdAMaOh68eT39hk1VoG/S3Kf7urpP93V1n+/L3HEH5i4nxZcwsCcrvlz5BrT4XT1osT60Tc17o7eWMXvjbMaBKSi5Na6ZwR2Qi4c24NGQohA3Z+cLsLh51XlY7GNw5k4lpTIK2ith8efxJ4tY8dupKWf46uzHf4crgxvVhJ6OdrnowVUfznpx+KReO2JXaFdyBXbUsN636M3Tu7o3T+/q3szTod06xWYHzu19687cOGFoK8HQZlOGti8YjsaZc2dqUBNZj5ILG5c2kmsJLoWQW24+eh5exhiFprPfxh36aGtVSSE5avNMU2noQJ/0UIV7ELbSWmrcqKm7AE6lq6hBhJwbJLLqUDeA4aWchu21WbWZrIYt5cK3aO3TXV3bp7u6tpeh0I5Oxhnsh4RvxdgDU4e2kAftYvLQbpU9LJHEegmsDHmgJQjmrCLTuV+hLJkqttKn5F8n0wySCyaonhOg2gUg3LrqlfkUjqO30STnbNz8tfqkvC57iDyfPTz/+U9anrxn9lAEfE6blOeyh6efXnHlvDP76JKumgfPG2atIeOmSeNXNEnXdQ7Gkf7EFj3S+/Nn/v6sEinhdKQ/aQMjrsu4MW0RVr3Sd8u0SYR0XRcxHugQ7ju08OUM6wPHFn735B06uDBQiWJqDlFKZmjReECYh+hQ5W6ZEYVcFJokFSqjR2MN6s11bX2pOWCqgQrFJU3lo4qt5lqjjixaFGJogtxIsVgGBBjaEhsEprahvjQWTYfnFePgTmPsa+aWz4iPLmsujL3tjaXCo2/T3svXd5HuycuIsoe9aSEzLkvMMa1RSJ1pjWVarDa6rJEXdzYYt801/Patv+tkw5fQ8YvMNqxuVKMbiIU1pyBIg2sS125Vaku5V6NA4Un8pUyDKqBX7+4qmS+Mr9+86jw7gqk6DY/Q2ofpY3z9RVx8jK/fdXz9XbnyDzO+/q5cmYdCWeOKHOaKbBEVvbFOji/SydlGgos5UA4YXy+7UmCXbI3a8FR6pVAeHFqgCiNqcaxNsEv3jlGMcpPBPBCaUDFmXltK6nVIIu+NUEeRbuZWg4vQQEilIVVprUeUWquMCqzZtHqKEaIW/xDj69+cqe9ifP2bM/Uy8ckSU2VXU2WpiOjBOjj+Kh2c5Zj3asiTncbXy40Qr/VcqimL9dwHJiMbwhBp6hXHEslaS7kAJWxV0tTInaqjZe/AUC6Mr9+86jziJR2tkFUAzOFGr20ulHW9hTLfWihznYVyQ3EZmesrlGtthTLfVSj70B7fWEuU5EAx0fty5uk93Zmn93RnrjQTyq2b7+TAVkK4McNec0Z2bSSUW/URyg0VZsKKd4NefJTSuZYwiDJSHiBTSAeQ1HNjaz1NUp+lGASRi3fMee0oiZS7q0PrYA2mbUJIbeAJ0zBF4hF5qhZtRsIVR/VJ6DR6sRRT6dAWYVE+WFn0JJhukxZ9e8Y+3dOVfbqnKzvTPihHt+PJjs2DcDCxzhkrm1oH5ajOQfmlZWZaH+JRKDtwHjGCp0HzUhy6jxaFWnUUboN8qrr42u/XAps0rS1XvkSCG1edJ8GR/6pa0zq2lL6de/+YJPEGJ0ngjU/9eYs6ft9fJjEdJvSfHqqiD1XRTaCUjkv2PSRF70pStGdI0cOTSO1CycACCudhQ7VMYx4KdK5RYgrTY9Seuopzx4qOq3VjsvqomYaVSWLcOobXwdJTTbWPqd+E6KuceKdS21ByzlSVkb3maLAe+PDgrAQvVefv+6gzpl0HCaSHmOi9iYnKrg+APJRENzHeL6ck+qvpv7/86v8DyqDfOA05AgA=" } 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 }; + } +} From 396d5f76b61121ddc52d9f3744bb17fecedd1146 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 2 Sep 2026 06:46:11 +0200 Subject: [PATCH 109/171] test: pin 52 X12-mutanten afzonderlijk (cherry picked from commit c2c4f3607c279406abff6f701a00f3e37667d1d8) --- .../check-xer-corpusless-fidelity-gate.ts | 181 ++++++++++++++---- 1 file changed, 144 insertions(+), 37 deletions(-) diff --git a/tests/planning/check-xer-corpusless-fidelity-gate.ts b/tests/planning/check-xer-corpusless-fidelity-gate.ts index b2f45427e..2bbec77d3 100644 --- a/tests/planning/check-xer-corpusless-fidelity-gate.ts +++ b/tests/planning/check-xer-corpusless-fidelity-gate.ts @@ -111,7 +111,11 @@ const ALLOWED_STATIC_IMPORTS = new Set([ ]); const diffs: string[] = []; const mutationEvidence: string[] = []; -let checks = 0; +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'); @@ -131,13 +135,22 @@ function equal(problems: string[], label: string, got: unknown, want: unknown): } } -function checkEqual(label: string, got: unknown, want: unknown): void { - checks++; +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 @@ -559,19 +572,26 @@ function validateContract(manifestRaw: unknown, oracleRaw: unknown, replayRaw: u } function expectRejected(label: string, manifest: Manifest, oracle: OracleBaseline, replay: ReplayPin): void { - checks++; + 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 { - checks++; + 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'); @@ -581,28 +601,106 @@ 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); - checkEqual('bron huidige ongebundelde TS-bron is strikt corpusloos', currentSourceProblems, []); + checkPositive('bron huidige ongebundelde TS-bron is strikt corpusloos', currentSourceProblems, []); - const mutantSourceProblems = validateOwnSource(`${sourceRaw}\nimport { readXER } from '@/services/xer/xerReader';\n`); - checkEqual('bronmutant met readXER-import wordt afgewezen', mutantSourceProblems.length > 0, true); + 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'); - const templateExpressionRequireProblems = validateOwnSource(templateExpressionRequireSource); - checkEqual( - 'bronmutant met require in template-expressie wordt afgewezen', - templateExpressionRequireProblems.length > 0, - true, - ); + expectSourceRejected('bronmutant met require in template-expressie wordt afgewezen', templateExpressionRequireSource); const multilineImportSource = [ sourceRaw, @@ -611,15 +709,14 @@ const productV2 = JSON.parse(productV2Raw) as ProductEnvelope; "} from '@/services/xer/xerReader';", '', ].join('\n'); - const multilineImportProblems = validateOwnSource(multilineImportSource); - checkEqual('bronmutant met multiline import wordt afgewezen', multilineImportProblems.length > 0, true); + expectSourceRejected('bronmutant met multiline import wordt afgewezen', multilineImportSource); const problems = validateContract(manifest, oracle, replay); - checkEqual(`A+B+D huidige statische contracten zijn consistent (${problems.join('; ')})`, problems, []); - checkEqual('A raw manifestbytes zijn exact gepind', rawHash(manifestRaw), EXPECTED.manifestRawSha256); - checkEqual('B raw oraclebaselinebytes zijn exact gepind', rawHash(oracleRaw), EXPECTED.baselineRawSha256); + 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); - checkEqual(`C v2-karakteriseringssnapshot is volledig maar strict zichtbaar rood (${productProblems.join('; ')})`, productProblems, []); + 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.'); } @@ -716,15 +813,6 @@ const productV2 = JSON.parse(productV2Raw) as ProductEnvelope; } expectRejected('M19 replay negatieve kandidaat zonder regressie', manifest, oracle, negativeWithoutRegression); - const firstProductLabel = Object.keys(decodeProductPayload(productV2).files)[0]!; - const multiProjectLabel = Object.entries(decodeProductPayload(productV2).files) - .find(([, entry]) => entry.projectMeasurements.length >= 2)?.[0]; - const algebraProjectLabel = Object.entries(decodeProductPayload(productV2).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'); const v1AsFinal = clone(productV2); (v1AsFinal as { version: number }).version = 1; expectProductRejected('M20 v1-baseline kan niet stil als finale v2 gelden', v1AsFinal, manifest, oracle); @@ -762,12 +850,11 @@ const productV2 = JSON.parse(productV2Raw) as ProductEnvelope; Object.assign(counts, { exact: counts.measurable, sameday: 0, diff: 0, missing: 0, deviations: 0 }); } } - checkEqual('M26 gedragsmutant: drivingPath aan zesassige gate koppelen wordt rood', { + checkMutationEqual('M26 gedragsmutant: drivingPath aan zesassige gate koppelen wordt rood', { canonicalStrictGate: deriveStrictEntryGate(strictZeroDrivingRed), mutatedDrivingGate: deriveStrictEntryGate(strictZeroDrivingRed) && strictZeroDrivingRed.drivingPath.deviations === 0, - }, { canonicalStrictGate: true, mutatedDrivingGate: false }); - mutationEvidence.push('M26 gedragsmutant drivingPath in zesassige gate: ROOD — canoniek true, mutant false'); + }, { canonicalStrictGate: true, mutatedDrivingGate: false }, 'canonieke gate true, driving-mutant false'); const routeInputs = { strictMinuteExact: 'STRICT-INVOER', @@ -776,14 +863,13 @@ const productV2 = JSON.parse(productV2Raw) as ProductEnvelope; }; const strictRoute = selectProductReportMode('strict-minute-exact', routeInputs); const swappedRoute = selectProductReportMode('historical-completed-late', routeInputs); - checkEqual('M27 gedragsmutant: strict werkelijk naar tegenfeitelijke invoer routeren wordt rood', { + 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], - }); - mutationEvidence.push('M27 gedragsmutant strict naar historische invoer: ROOD — andere invoer en niet poortgeschikt'); + }, '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', @@ -849,15 +935,36 @@ const productV2 = JSON.parse(productV2Raw) as ProductEnvelope; 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 — ${checks} checks groen`); + 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} van ${checks} checks rood:`); + 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); } From 8b87c7d5ec6b7c1d02bf311a014682220be2cc80 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:34:09 +0200 Subject: [PATCH 110/171] test(xer): leg SCHEDOPTIONS-provenance fail-closed vast (cherry picked from commit 712d283f1c55156cba3c9eb4894e8a6a344db8d9) --- src/services/xer/xerReader.ts | 13 +- src/services/xer/xerScheduleOptions.ts | 21 +- .../check-xer-schedule-option-provenance.ts | 223 ++++++++++++++++++ tests/planning/run.sh | 6 + 4 files changed, 259 insertions(+), 4 deletions(-) create mode 100644 tests/planning/check-xer-schedule-option-provenance.ts diff --git a/src/services/xer/xerReader.ts b/src/services/xer/xerReader.ts index ffdb5a76b..fcc4c11a2 100644 --- a/src/services/xer/xerReader.ts +++ b/src/services/xer/xerReader.ts @@ -790,8 +790,19 @@ function readXerProject( const { progressMode, schedulingOptions, - ...scheduleOptionsMetadata } = derivedSchedule; + // 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]; diff --git a/src/services/xer/xerScheduleOptions.ts b/src/services/xer/xerScheduleOptions.ts index e563eb594..de4c360ea 100644 --- a/src/services/xer/xerScheduleOptions.ts +++ b/src/services/xer/xerScheduleOptions.ts @@ -188,6 +188,19 @@ function retainedBooleanValue( 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; +} + function progressModeValue( row: XerRow, fallbacks: XerScheduleOptionFallback[], @@ -319,13 +332,15 @@ export function deriveXerScheduleOptions( ): 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 = - projectRow?.cells.rem_target_link_flag?.trim().toUpperCase() === 'Y'; - const fallbacks: XerScheduleOptionFallback[] = []; + 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) ?? [])]; 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/run.sh b/tests/planning/run.sh index 1a74b73ed..911ab69a9 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -422,6 +422,12 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then 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" From d1c8e25eba875c96ded6488b7b9ff10a402fb865 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:00:18 +0200 Subject: [PATCH 111/171] =?UTF-8?q?feat(xer):=20bak=204=20lezen=20?= =?UTF-8?q?=E2=80=94=20zes=20P6-rekenuitvoerkolommen=20als=20apart,=20niet?= =?UTF-8?q?-solvend=20kanaal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X-O7 laag 3 (§4.1-bijstelling 2026-09-04): early_start_date, early_end_date, late_start_date, late_end_date, total_float_hr_cnt en free_float_hr_cnt verhuizen van XER_TASK_FORBIDDEN naar de nieuwe bak 4 (XER_TASK_RECORDED_OUTPUT) in check-xer-field-whitelist.ts, met een isolatiescan die bewijst dat alleen xerRecordedTimes.ts deze kolommen leest. Nieuwe src/services/xer/xerRecordedTimes.ts leest uitsluitend deze zes kolommen uit de TASK-rijen en levert Record (hergebruikt uit recordedDates.ts) — geen terugval op target_start/end_date, uren→werkdagen via de taak-effectieve kalender, isCritical afgeleid uit totalFloat<=0. sourceInstant is verplaatst naar het nieuwe src/services/xer/xerInstant.ts zodat xerReader.ts en xerRecordedTimes.ts 'm delen zonder cyclische import. readXerProject (xerReader.ts) roept de nieuwe module aan en vult ImportResult.recordedTimes/recordedTimesOrigin (importTypes.ts). Niets hiervan komt in Task.time of solverinvoer terecht. Mutatiebewijzen (handmatig uitgevoerd, teruggedraaid): (a) early_start_date uit bak 4 gehaald zonder herplaatsing ⇒ corpusscan rood (1/298 checks); (b) late_start_date tijdelijk naar Task.time.lateStart laten lekken in xerReader.ts ⇒ check-xer-recorded-times.ts rood (1/12 checks). check-xer-product-fidelity-x12.ts blijft byte-identiek vóór/ná (4/91 rood, 18.398 afwijkingen, ongewijzigd). Co-Authored-By: Claude Fable 5.1 --- src/services/importTypes.ts | 17 ++ src/services/xer/xerInstant.ts | 22 +++ src/services/xer/xerReader.ts | 30 ++- src/services/xer/xerRecordedTimes.ts | 89 +++++++++ tests/planning/check-xer-field-whitelist.ts | 154 ++++++++++++--- tests/planning/check-xer-recorded-times.ts | 207 ++++++++++++++++++++ 6 files changed, 483 insertions(+), 36 deletions(-) create mode 100644 src/services/xer/xerInstant.ts create mode 100644 src/services/xer/xerRecordedTimes.ts create mode 100644 tests/planning/check-xer-recorded-times.ts diff --git a/src/services/importTypes.ts b/src/services/importTypes.ts index 814ba8cac..4a7aac160 100644 --- a/src/services/importTypes.ts +++ b/src/services/importTypes.ts @@ -8,6 +8,7 @@ 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'; @@ -265,6 +266,22 @@ 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). */ + recordedTimesOrigin?: 'xer'; /** Alleen XER: bronmetadata en solverloze cross-projectrelaties voor het geladen document. */ xer?: XerImportMetadata; /** Alleen XER: exact, gedeeld en immutable bronarchief; nooit solverinvoer. */ 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/xerReader.ts b/src/services/xer/xerReader.ts index fcc4c11a2..a34529bb7 100644 --- a/src/services/xer/xerReader.ts +++ b/src/services/xer/xerReader.ts @@ -39,6 +39,8 @@ import { type XerSourceArchive, } from '@/services/xerSourceArchive'; import { readXerCalendars } from './xerCalendarData'; +import { sourceInstant } from './xerInstant'; +import { readXerRecordedTimes } from './xerRecordedTimes'; import { buildXerMetadataCatalog, materializeXerMetadata, type XerMetadataCatalog } from './xerMetadata'; import { indexXerTaskResourceRows } from './xerResourceAssignments'; import { @@ -217,15 +219,6 @@ function hasClock(raw: string): boolean { return /^\d{4}-\d{2}-\d{2}[ T]\d{1,2}:\d{2}/.test(raw.trim()); } -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'); -} - /** * `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* @@ -601,6 +594,23 @@ function readXerProject( new CalendarEngine(calendar), ] as const)); + // BAK 4 (XER-etappeplan §4.1-bijstelling 2026-09-04, X-O7 laag 3) — uitsluitend weergave/meetlat, + // nooit solverinvoer. Onafhankelijk van de taakmapping hieronder: leest dezelfde `activityRows`, + // maar schrijft nergens in `Task`/`Task.time`. Zie `xerRecordedTimes.ts` voor de laagkeuze. + 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, + }); + const enumFallbacks: XerEnumFallback[] = []; const projectDefaultDuration = durationTypeOf( projectRow.cells.def_duration_type ?? '', @@ -959,6 +969,8 @@ function readXerProject( assignments: resourceResult.assignments, activityCodeTypes: metadata.activityCodeTypes, customFieldDefs: metadata.customFieldDefs, + recordedTimes, + recordedTimesOrigin: 'xer', xer: { sourceProjectId: projectId, defaultCurrencyCode: tables.header.defaultCurrencyCode, diff --git a/src/services/xer/xerRecordedTimes.ts b/src/services/xer/xerRecordedTimes.ts new file mode 100644 index 000000000..2513e5fe0 --- /dev/null +++ b/src/services/xer/xerRecordedTimes.ts @@ -0,0 +1,89 @@ +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; +} + +/** 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. */ +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 letterlijke string voorkomen. Een grep op de + * zes namen binnen die map mag na dit bestand nergens anders meer treffen. + * + * 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. + * + * `isCritical` is een AFLEIDING, geen gelezen kolom: `totalFloat <= 0` zodra die vastgelegd is. + * `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 totalFloat = hoursToDays(ctx.numberOf(row, 'total_float_hr_cnt'), minutesPerDay); + const freeFloat = hoursToDays(ctx.numberOf(row, 'free_float_hr_cnt'), minutesPerDay); + const isCritical = totalFloat === undefined ? undefined : totalFloat <= 0; + + 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/tests/planning/check-xer-field-whitelist.ts b/tests/planning/check-xer-field-whitelist.ts index c291d3536..a07710dc2 100644 --- a/tests/planning/check-xer-field-whitelist.ts +++ b/tests/planning/check-xer-field-whitelist.ts @@ -11,16 +11,21 @@ * 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. * - * DIT BESTAND SCHRIJFT GEEN LEZER. De whitelist-constanten zijn documentatie-van-het-plan (harde - * regel §4/X0-brief): X0 raakt `src/services/xer/` niet aan. De corpusscan hieronder is een eigen, - * MINIMALE %T/%F-tabelscan (geen tokenizer-hergebruik — er is nog niets om te hergebruiken; X2 bouwt - * de echte grammatica) die uitsluitend kolomNAMEN uittrekt, nooit rijwaarden leest of interpreteert. + * 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. * - * HET GATENKAAS-MECHANISME (planreview M2): een corpus-`%F`-kolom die in GEEN van de vier bakken + * 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-)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 het mutatiebewijs voor deze poort. + * 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 @@ -28,7 +33,8 @@ * constanten zelf (geen dubbele/overlappende velden) draaien wél altijd. */ import { readFileSync, readdirSync, existsSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, resolve as resolvePath } from 'node:path'; +import { fileURLToPath } from 'node:url'; const diffs: string[] = []; let checks = 0; @@ -38,7 +44,8 @@ const truthy = (label: string, cond: boolean) => { }; // ═══════════════════════════════════════════════════════════════════════════════════════════ -// De vier bakken (XER-etappeplan §4.1 + D4-review: external proxy is geen lokale rekenuitvoer) +// 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) // ═══════════════════════════════════════════════════════════════════════════════════════════ /** @@ -73,12 +80,16 @@ export const XER_TASK_WHITELIST = [ /** * 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 = [ - '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', @@ -97,6 +108,27 @@ 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 @@ -160,10 +192,14 @@ export const XER_TASK_IGNORED: readonly string[] = [ // 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], ]; for (const [name, list] of buckets) { @@ -173,24 +209,85 @@ export const XER_TASK_IGNORED: readonly string[] = [ seen.add(f); } } - 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 ignoredSet = new Set(XER_TASK_IGNORED); - for (const f of whitelistSet) { - truthy(`"${f}" staat niet óók in forbidden`, !forbiddenSet.has(f)); - truthy(`"${f}" staat niet óók in external-dependency-proxy`, !externalProxySet.has(f)); - truthy(`"${f}" staat niet óók in ignored`, !ignoredSet.has(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)); + } + } } - for (const f of forbiddenSet) { - truthy(`"${f}" staat niet óók in external-dependency-proxy`, !externalProxySet.has(f)); - truthy(`"${f}" staat niet óók in ignored`, !ignoredSet.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 `'')`/`"")` (laatste argument van +// een aanroep, zoals `ctx.numberOf(row, 'total_float_hr_cnt')`) — 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. +{ + 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) { + 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(xerDir); + truthy('isolatiescan leest een plausibel aantal bestanden in src/services/xer/', xerFiles.length > 5); + + const toegestaan = join(xerDir, 'xerRecordedTimes.ts'); + 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) { + const cellAccess = new RegExp(`cells(?:\\.${field}\\b|\\[['"]${field}['"]\\])`); + const callArg = new RegExp(`['"]${field}['"]\\s*\\)`); + if (cellAccess.test(text) || callArg.test(text)) { + 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(', ')}`, + ); + } + } } - for (const f of externalProxySet) truthy(`"${f}" staat niet óók in ignored`, !ignoredSet.has(f)); } // ═══════════════════════════════════════════════════════════════════════════════════════════ -// De corpusscan: union van alle TASK-%F-kolommen over OPS_XER_CORPUS, tegen de vier bakken. +// De corpusscan: union van alle TASK-%F-kolommen over OPS_XER_CORPUS, tegen de vijf bakken. // ═══════════════════════════════════════════════════════════════════════════════════════════ const CORPUS = process.env.OPS_XER_CORPUS; @@ -272,6 +369,7 @@ if (!CORPUS) { 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[] = []; @@ -279,8 +377,10 @@ if (!CORPUS) { 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(inIgnored); + const bucketCount = Number(inWhitelist) + Number(inForbidden) + Number(inExternalProxy) + + Number(inRecordedOutput) + Number(inIgnored); if (bucketCount === 0) { unclassified.push(`"${col}" (bv. ${exampleFile})`); } else if (bucketCount > 1) { @@ -294,7 +394,7 @@ if (!CORPUS) { 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 vier bakken (whitelist/forbidden/external-proxy/ignored) — gatenkaas-mechanisme, plan §4.1: ${unclassified.join(', ')}`, + `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(', ')}`, ); } diff --git a/tests/planning/check-xer-recorded-times.ts b/tests/planning/check-xer-recorded-times.ts new file mode 100644 index 000000000..a80067679 --- /dev/null +++ b/tests/planning/check-xer-recorded-times.ts @@ -0,0 +1,207 @@ +/** + * 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), UITGEVOERD ALS HANDMATIGE PROCEDURE (niet in dit bestand aanwezig): laat +// `readXerRecordedTimes` — of de taakmapping in `xerReader.ts` — tijdelijk `row.cells.late_start_date` +// ook naar `Task.time.lateStart` schrijven (bv. `time: { ...defaultTime, lateStart: row.cells +// .late_start_date }` in `mappedActivities.push(...)`), en draai deze check opnieuw: check 10 +// hierboven gaat dan ROOD, want `task.time.lateStart` bevat dan de orakelwaarde `2026-01-10`. +// Uitgevoerd en teruggedraaid tijdens de bouw van deze taak (zie het voortgangsrapport voor de +// exitcode vóór/ná) — niet als blijvende code hier, want een permanente mutatie in dit testbestand +// zou de fixture naar de implementatie toe schrijven, wat expliciet verboden is (baan-preambule). + +// ═══════════════════════════════════════════════════════════════════════════════════════════ +// 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); + } +} + +// ── 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); +} From 8947e7e39a9711b8a21d03de0975a07c28da700e Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:51:49 +0200 Subject: [PATCH 112/171] fix(xer): resourcecurves via de contour-engine, geen eigen best-fit meer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xerResourceCurves.ts/xerResourceAssignments.ts leunden op een eigen best-fit over zes vaste curvefamilies. Vervangen door de contour-engine die de rest van de codebase (p6xmlReader/p6xmlWriter, MSPDI) al gebruikt: normalizeCurveValues valideert de 21 RSRCCURVDATA-punten (pct_usage_0..pct_usage_20) en levert ResourceAssignment.curveValues; matchCurveValues doet de exacte tabelmatch voor de OPS-curve, met terugval op P6_NAME_TO_CURVE[curv_name] wanneer een P6-curve geen van de acht tabellen exact raakt. bestFitXerCurve en de bijbehorende curveNormalisatie/ -interpolatiehelpers zijn dode code en verwijderd, evenals het bestFit-veld op XerResourceCurveSource (xerResourceTypes.ts, xerSourceArchive-validator). check-xer-resources.ts kreeg twee scenario's: een RSRCCURVDATA-curve die de BELL-tabel exact raakt (curv_name is bewust GEEN P6-naam, bewijst dat de tabelmatch voorrang heeft) en een curve die geen tabel raakt maar via curv_name 'Front Loaded' herkend wordt (bewijst de naam-terugval). Mutatiebewijs: matchCurveValues() vervangen door `undefined` maakt beide assignments in X6-c rood. check-xer-resource-corpus.ts's onafhankelijke oracle vergeleek tot nu toe tegen zijn eigen curveBestFit-kopie; die is vervangen door een curveNumericPoints-validatie die dezelfde regel volgt als normalizeCurveValues (21 eindige niet-negatieve punten, positieve som over index 1..20) — geverifieerd tegen het publieke corpus (40 checks groen). Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 821bf2f6f6cf336e5ad1ca09d09fb830ea464d48) --- src/services/xer/xerResourceAssignments.ts | 23 +++++- src/services/xer/xerResourceCurves.ts | 79 +++------------------ src/services/xer/xerResourceTypes.ts | 3 +- src/services/xerSourceArchive.ts | 4 +- tests/planning/check-xer-resource-corpus.ts | 45 ++++-------- tests/planning/check-xer-resources.ts | 33 +++++++-- 6 files changed, 73 insertions(+), 114 deletions(-) diff --git a/src/services/xer/xerResourceAssignments.ts b/src/services/xer/xerResourceAssignments.ts index 665584232..5f3fd99f0 100644 --- a/src/services/xer/xerResourceAssignments.ts +++ b/src/services/xer/xerResourceAssignments.ts @@ -1,6 +1,6 @@ /** TASKRSRC-mapping met veldspecifieke P6-schalen en bron-id-indexen. */ -import type { Resource, ResourceAssignment } from '@/types/resource'; +import type { Resource, ResourceAssignment, ResourceCurve } from '@/types/resource'; import type { XerAssignmentCostsSource, XerAssignmentQuantitiesSource, XerAssignmentUnitScale, XerReadonly, XerResourceCurveSource, XerResourceIssue, XerResourceRateSource, @@ -11,6 +11,8 @@ 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%. @@ -75,6 +77,22 @@ function assertUniqueAssignmentIds(rows: readonly XerRow[]): void { 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; @@ -200,8 +218,9 @@ export function readXerResourceAssignments( 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, - ...(curve?.bestFit && curve.bestFit !== 'UNIFORM' ? { curve: curve.bestFit } : {}) }); + ...(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 index 82cdfb5d3..2e8fbaa0e 100644 --- a/src/services/xer/xerResourceCurves.ts +++ b/src/services/xer/xerResourceCurves.ts @@ -1,73 +1,14 @@ -/** XER-resourcecurves: rauwe 21-puntsbrondata plus een niet-destructieve OPS-best-fit. */ +/** 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 { ResourceCurve } from '@/types/resource'; import type { XerCurvePoints, XerResourceCurveSource, XerResourceIssue } from './xerResourceTypes'; +import { normalizeCurveValues } from '@/engine/contour/contourEngine'; import { parseXerNumber, XerImportError, type XerRow, type XerTables } from './xerTables'; -/** De curve-families waartegen de XER-best-fit meet. Bewust NIET heel `ResourceCurve`: de - * contour-engine (2026-09) breidde dat type uit met `DOUBLE_PEAK`/`TURTLE`, twee vormen die - * alleen als MS Project-/P6-tabel bestaan en geen controlepunten-profiel hebben. De XER-best-fit - * blijft daarom op deze zes families passen; de exacte 21-puntsbron blijft naast de best-fit - * bewaard, dus er gaat niets verloren. */ -type XerCurveFamily = Extract< - ResourceCurve, 'UNIFORM' | 'FRONT_LOADED' | 'BACK_LOADED' | 'BELL' | 'EARLY_PEAK' | 'LATE_PEAK' ->; - -const CURVE_FAMILIES: readonly XerCurveFamily[] = [ - 'UNIFORM', 'FRONT_LOADED', 'BACK_LOADED', 'BELL', 'EARLY_PEAK', 'LATE_PEAK', -]; const CURVE_FIELDS = Array.from({ length: 21 }, (_, index) => `pct_usage_${index}`); -function interpolate(points: readonly (readonly [number, number])[], t: number): number { - for (let index = 0; index < points.length - 1; index++) { - const [leftT, leftWeight] = points[index]; - const [rightT, rightWeight] = points[index + 1]; - if (t < leftT || t > rightT) continue; - const fraction = rightT === leftT ? 0 : (t - leftT) / (rightT - leftT); - return leftWeight + fraction * (rightWeight - leftWeight); - } - return points[points.length - 1][1]; -} - -function familyProfile(curve: XerCurveFamily): number[] { - const controls: Record = { - UNIFORM: [[0, 1], [1, 1]], - FRONT_LOADED: [[0, 1], [1, 0.2]], - BACK_LOADED: [[0, 0.2], [1, 1]], - BELL: [[0, 0.2], [0.5, 1], [1, 0.2]], - EARLY_PEAK: [[0, 0.2], [1 / 3, 1], [1, 0.2]], - LATE_PEAK: [[0, 0.2], [2 / 3, 1], [1, 0.2]], - }; - return Array.from({ length: 21 }, (_, index) => interpolate(controls[curve], index / 20)); -} - -function normalized(points: readonly number[]): number[] | undefined { - if (points.length !== 21 || points.some(point => !Number.isFinite(point) || point < 0 || point > 100)) return undefined; - const total = points.reduce((sum, point) => sum + point, 0); - return total > 0 ? points.map(point => point / total) : undefined; -} - -/** Kies de kleinste mean-squared error; normalisatie wijzigt de bewaarde bronpunten niet. */ -export function bestFitXerCurve(points: readonly number[]): ResourceCurve | undefined { - const observed = normalized(points); - if (!observed) return undefined; - let best: ResourceCurve | undefined; - let bestError = Number.POSITIVE_INFINITY; - for (const family of CURVE_FAMILIES) { - const candidate = normalized(familyProfile(family)); - if (!candidate) continue; - const error = observed.reduce((sum, point, index) => { - const delta = point - candidate[index]; - return sum + delta * delta; - }, 0) / observed.length; - if (error < bestError) { - best = family; - bestError = error; - } - } - return best; -} - function asCurveTuple(points: readonly T[]): XerCurvePoints { return points as XerCurvePoints; } @@ -105,18 +46,16 @@ export function readXerResourceCurves(tables: XerTables): { let numericPoints: XerCurvePoints | undefined; try { const parsed = rawPoints.map(point => parseXerNumber(point, tables.numberFormat)); - if (parsed.every((point): point is number => point !== null)) { - const candidate = asCurveTuple(parsed); - if (bestFitXerCurve(candidate) !== undefined) numericPoints = candidate; + 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. } - const bestFit = numericPoints ? bestFitXerCurve(numericPoints) : undefined; sources.push({ rawRow: row, sourceId, internalId: `xer-curve:${sourceId}`, line: row.line, name: row.cells.curv_name?.trim() ?? '', rawPoints, - ...(numericPoints ? { numericPoints } : {}), ...(bestFit ? { bestFit } : {}) }); - if (!numericPoints || !bestFit) { + ...(numericPoints ? { numericPoints } : {}) }); + if (!numericPoints) { issues.push({ code: 'XER_CURVE_INVALID_POINTS', table: 'RSRCCURVDATA', line: row.line, sourceId, fallback: 'UNIFORM' }); } diff --git a/src/services/xer/xerResourceTypes.ts b/src/services/xer/xerResourceTypes.ts index 4d8ca60b4..aab96b219 100644 --- a/src/services/xer/xerResourceTypes.ts +++ b/src/services/xer/xerResourceTypes.ts @@ -1,4 +1,4 @@ -import type { Resource, ResourceAssignment, ResourceCurve } from '@/types/resource'; +import type { Resource, ResourceAssignment } from '@/types/resource'; import type { XerRow } from './xerTables'; /** @@ -80,7 +80,6 @@ export interface XerResourceCurveSource { name: string; rawPoints: XerCurvePoints; numericPoints?: XerCurvePoints; - bestFit?: ResourceCurve; } export type XerAssignmentUnitScale = 'DIRECT_FRACTION' | 'MATERIAL_PER_HOUR'; diff --git a/src/services/xerSourceArchive.ts b/src/services/xerSourceArchive.ts index 27210da14..d801149cf 100644 --- a/src/services/xerSourceArchive.ts +++ b/src/services/xerSourceArchive.ts @@ -837,7 +837,7 @@ function validateRateSource(value: unknown, path: string): void { function validateCurveSource(value: unknown, path: string): void { const source = objectOf(value, path); - exactKeys(source, ['rawRow', 'sourceId', 'internalId', 'line', 'name', 'rawPoints', 'numericPoints', 'bestFit'], 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`); @@ -849,8 +849,6 @@ function validateCurveSource(value: unknown, path: string): void { if (numericPoints.length !== 21) invalid(`${path}.numericPoints is geen tuple van lengte 21`); numericPoints.forEach((point, index) => finiteNumber(point, `${path}.numericPoints[${index}]`)); } - if (source.bestFit !== undefined) oneOf(source.bestFit, - ['UNIFORM', 'FRONT_LOADED', 'BACK_LOADED', 'BELL', 'EARLY_PEAK', 'LATE_PEAK'], `${path}.bestFit`); } function validateTaskResourceSource(value: unknown, path: string): void { diff --git a/tests/planning/check-xer-resource-corpus.ts b/tests/planning/check-xer-resource-corpus.ts index b5c5d89df..5befb8112 100644 --- a/tests/planning/check-xer-resource-corpus.ts +++ b/tests/planning/check-xer-resource-corpus.ts @@ -52,7 +52,7 @@ interface DirectRate { maxUnitsPerTime: number | null; costs: Array; } -interface DirectCurve { sourceId: string; rawPoints: string[]; bestFit?: string; } +interface DirectCurve { sourceId: string; rawPoints: string[]; numericPoints?: number[]; } interface DirectScan { projectId: string; resources: number; @@ -126,39 +126,20 @@ function sourceNumber(raw: string | undefined, format: { decimal: '.' | ','; gro if (!Number.isFinite(parsed)) throw new Error(`Onafhankelijke scanner kreeg ongeldig getal ${JSON.stringify(value)}`); return parsed; } -function curveBestFit(points: readonly string[], format: { decimal: '.' | ','; group: '.' | ',' | null }): string | undefined { +/** 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 || value < 0 || value > 100) return undefined; + if (value === null || !Number.isFinite(value) || value < 0) return undefined; values.push(value); } - if (values.length !== 21) return undefined; - const total = values.reduce((sum, point) => sum + point, 0); - if (total === 0) return undefined; - const controls: Readonly>> = { - UNIFORM: [[0, 1], [1, 1]], FRONT_LOADED: [[0, 1], [1, 0.2]], BACK_LOADED: [[0, 0.2], [1, 1]], - BELL: [[0, 0.2], [0.5, 1], [1, 0.2]], EARLY_PEAK: [[0, 0.2], [1 / 3, 1], [1, 0.2]], LATE_PEAK: [[0, 0.2], [2 / 3, 1], [1, 0.2]], - }; - const observed = values.map(point => point / total); - let best: string | undefined; - let bestError = Number.POSITIVE_INFINITY; - for (const [family, pointsForFamily] of Object.entries(controls)) { - const profile = Array.from({ length: 21 }, (_, index) => { - const at = index / 20; - for (let control = 0; control < pointsForFamily.length - 1; control++) { - const [leftAt, leftWeight] = pointsForFamily[control]; - const [rightAt, rightWeight] = pointsForFamily[control + 1]; - if (at < leftAt || at > rightAt) continue; - return leftWeight + (rightWeight - leftWeight) * ((at - leftAt) / (rightAt - leftAt)); - } - return pointsForFamily[pointsForFamily.length - 1][1]; - }); - const profileTotal = profile.reduce((sum, point) => sum + point, 0); - const error = observed.reduce((sum, point, index) => sum + (point - profile[index] / profileTotal) ** 2, 0) / observed.length; - if (error < bestError) { best = family; bestError = error; } - } - return best; + 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); @@ -202,8 +183,8 @@ function scan(bytes: Uint8Array): DirectScan { 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 bestFit = curveBestFit(rawPoints, format); - return { sourceId: row.curv_id || '', rawPoints, ...(bestFit ? { bestFit } : {}) }; + 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() } : {}) })), @@ -255,7 +236,7 @@ else { 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 best-fit blijven behouden`, catalog.rows.curves.map(curve => ({ sourceId: curve.sourceId, rawPoints: [...curve.rawPoints], ...(curve.bestFit ? { bestFit: curve.bestFit } : {}) })), source.curves); + 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); diff --git a/tests/planning/check-xer-resources.ts b/tests/planning/check-xer-resources.ts index 5f9fa0899..9882515a5 100644 --- a/tests/planning/check-xer-resources.ts +++ b/tests/planning/check-xer-resources.ts @@ -9,6 +9,7 @@ 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; @@ -54,10 +55,14 @@ function bytes(lines: readonly string[]): Uint8Array { } const curveFields = Array.from({ length: 21 }, (_, index) => `pct_usage_${index}`); -const bell = [ - 20, 28, 36, 44, 52, 60, 68, 76, 84, 92, 100, - 92, 84, 76, 68, 60, 52, 44, 36, 28, 20, -]; +// 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', @@ -72,6 +77,7 @@ const fixture = bytes([ '%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', @@ -93,11 +99,13 @@ const fixture = bytes([ '%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', ]); @@ -230,16 +238,31 @@ if (isMultiDocumentImport(opened)) { 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'); @@ -252,7 +275,7 @@ if (isMultiDocumentImport(opened)) { points: curve?.rawPoints.length, numericPoints: curve?.numericPoints?.length, sameRow: projectAssignment?.rawRow === rawAssignment, - }, { rawAssignments: 3, projectAssignments: 3, points: 21, numericPoints: 21, sameRow: true }); + }, { 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'); From 8c1c41518ab8abe3c610e5b694a7e366eafcdd5b Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:52:07 +0200 Subject: [PATCH 113/171] fix(xer): P6-export claimt geen LATE_PEAK-verlies meer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xerExportLoss.ts's hasAssignmentLoss claimde nog "P6 kent geen LATE_PEAK en schrijft die aantoonbaar als Early Peak". Dat klopte vóór de contour-engine-etappe (2026-09), maar main's p6xmlWriter.ts schrijft LATE_PEAK inmiddels schema-natief terug: P6_CURVE_TO_NAME.LATE_PEAK === 'Late Peak' met een eigen 21-waardentabel via , geen degradatie naar Early Peak meer. De speciale p6/LATE_PEAK-tak in hasAssignmentLoss is verwijderd (met het nu ongebruikte format-argument). hasRetainedAssignmentDetails' docblok bij curveSourceId beweerde dat geen enkele doelwriter "de exacte curvepunten" teruggeeft. Dat is voor het P6-doel sinds dezelfde etappe niet meer waar: curveObjIdFor in p6xmlWriter.ts schrijft ResourceAssignment.curveValues wel degelijk schema-natief terug (met een nieuw ObjectId en een synthetische naam) — verloren gaat alleen de oorspronkelijke P6-curve-identiteit/-naam (curv_id/curv_name), niet de 21 waarden zelf. Commentaar gecorrigeerd met verwijzing naar de writerregel. check-xer-export-loss.ts's LATE_PEAK-fixture verwachtte tot nu toe zelfstandig 'raw-curves-and-assignment-quantities'-verlies; dat is nu een lege categorieënlijst, met een assertie tegen de gecapturede P6-XML die 'Late Peak' bevestigt en 'Early Peak' uitsluit. Mutatiebewijs: de oude p6/LATE_PEAK-tak terugzetten in hasAssignmentLoss maakt deze check rood (1 afwijking van 62); overige claims in het bestand (baseline- projectie, percent-/elapsed-lag, scheduling-options-capabilities) nagelopen tegen csvWriter.ts/mspdiWriter.ts/p6xmlWriter.ts en nog accuraat bevonden. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 9240231ba93685126b9b07960fd715ec57857a69) --- src/services/xerExportLoss.ts | 17 ++++++++++------- tests/planning/check-xer-export-loss.ts | 11 +++++++++-- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/services/xerExportLoss.ts b/src/services/xerExportLoss.ts index d793426b7..f2b967bd4 100644 --- a/src/services/xerExportLoss.ts +++ b/src/services/xerExportLoss.ts @@ -152,7 +152,14 @@ function hasRetainedAssignmentDetails(metadata: XerImportMetadata | null): boole || hasObjectValues(source.costs) || source.assignedRole !== undefined // `curveSourceId` verwijst naar de retained 21-punts curve in de bestandsbrede catalogus. - // De doelwriters schrijven noch deze bronidentiteit noch de exacte curvepunten terug. + // 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()) @@ -160,15 +167,11 @@ function hasRetainedAssignmentDetails(metadata: XerImportMetadata | null): boole } function hasAssignmentLoss( - format: Exclude, capabilities: ExportCapabilities, input: XerExportLossInput, ): boolean { if (hasRetainedAssignmentDetails(input.importMetadata)) return true; - if (!capabilities.projectedAssignments && input.assignments.length > 0) return true; - // P6 kent geen LATE_PEAK en schrijft die aantoonbaar als Early Peak; de overige live curves - // en units worden door PlannedCurve/PlannedUnitsPerTime gedragen. - return format === 'p6' && input.assignments.some(assignment => assignment.curve === 'LATE_PEAK'); + return !capabilities.projectedAssignments && input.assignments.length > 0; } function isMspdiCriticalSlackLimit(options: SchedulingOptions): boolean { @@ -223,7 +226,7 @@ function categoriesFor( 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(format, capabilities, input)) { + if (hasAssignmentLoss(capabilities, input)) { categories.push('raw-curves-and-assignment-quantities'); } if (input.tasks.some(task => (task.externalLinks?.length ?? 0) > 0) diff --git a/tests/planning/check-xer-export-loss.ts b/tests/planning/check-xer-export-loss.ts index 07ab2f987..747396c13 100644 --- a/tests/planning/check-xer-export-loss.ts +++ b/tests/planning/check-xer-export-loss.ts @@ -351,10 +351,17 @@ function installAssignmentFixture(name: string, curve: 'BELL' | 'LATE_PEAK') { 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'); -expectCategories('P6-LATE_PEAK zonder retained quantities activeert zelfstandig assignmentverlies', - 'p6', latePeakOnly, ['raw-curves-and-assignment-quantities']); +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 = { From a5b158e70a4e0e214b3a208d4d94bbd6bffe91b7 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:58:58 +0200 Subject: [PATCH 114/171] feat(p6xml): lees naar Task.p6DurationType MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit p6xmlReader.ts las het PMXML-element nog niet, terwijl de XER-lezer hetzelfde P6-concept (duration_type-kolom) al langer naar Task.p6DurationType leest. Dat gat betekende dat een .xer-import en een gelijkwaardige .xmpxml/.xer-P6-XML-import van dezelfde activiteit een ander taaktypesignaal opleverden — relevant voor de aankomende taaktypes- etappe, die p6DurationType als opslagplek voor beide P6-paden gebruikt (eigenaarsafspraak 2026-09-04, "Afspraken met de etappe taaktypes"). Vier canonieke PMXML-labels (geverifieerd tegen MPXJ's DurationTypeHelper en de PMXML-schemadocumentatie) mappen naar dezelfde vier XER-tokens: "Fixed Duration and Units" -> DT_FixedDrtn, "Fixed Duration and Units/Time" -> DT_FixedDUR2, "Fixed Units/Time" -> DT_FixedRate, "Fixed Units" -> DT_FixedQty. Onbekend of leeg label laat het veld afwezig (byte-identiek) en rapporteert via console.warn — zelfde "veld-als-signaal, nooit een aanname"-regel als de rest van de P6-provenancevelden. Puur data: geen enkele solverstap leest dit veld, zoals bij mspTaskType/ p6ActivityType hiernaast. p6xmlWriter.ts schrijft bewust niet (buiten scope van deze etappe) — de bestaande check-xer-p6xml-parity.ts-assertie dat een round-trip via writeP6XML geen p6DurationType oplevert blijft dus waar. Uitgebreid met vijf nieuwe assertie s die het element handmatig in de writer-uitvoer injecteren (dezelfde truc die het bestand al gebruikte) om de vier labels, de gerapporteerde terugval en het afwezige-element-pad te bewijzen. Mutatiebewijs: p6DurationTypeFromXml-aanroep vervangen door `undefined` maakt 5 van de 6 assertie s in dit bestand rood. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 3c621ae485dad4d175ac1dae263e44e63e631062) --- src/services/p6/p6xmlReader.ts | 30 ++++++++++++++++ tests/planning/check-xer-p6xml-parity.ts | 45 ++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/src/services/p6/p6xmlReader.ts b/src/services/p6/p6xmlReader.ts index 5f2eeb50e..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,6 +116,33 @@ 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 @@ -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/tests/planning/check-xer-p6xml-parity.ts b/tests/planning/check-xer-p6xml-parity.ts index a7ffb9ff4..92e2989fa 100644 --- a/tests/planning/check-xer-p6xml-parity.ts +++ b/tests/planning/check-xer-p6xml-parity.ts @@ -37,6 +37,51 @@ 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); From 5ad60044fe410d6375331a26c38733b4c15bae36 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:00:36 +0200 Subject: [PATCH 115/171] docs(todo): registreer XER-opgeslagen-werk-beperking onder Contour-engine (2026-09) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De taaktypes-etappe krijgt het eersteklasveld voor opgeslagen werk op ResourceAssignment; XER laat target_qty/remain_qty/act_reg_qty tot dan bewust alleen in het bronarchief staan (eigenaarsafspraak 2026-09-04, "Afspraken met de etappe taaktypes / opgeslagen werk"). Vastgelegd: overzetten start pas met dat veld en uitsluitend bij afwijking van target_drtn_hr_cnt × target_qty_per_hr, met de meetlatbestanden (HarbourPointe_AssistedLiving, Harbour Point DCP-03, p6_torture_test_v1) en de 27,5%-resttariefafwijking in rehab-2 als referentie voor die volgende etappe. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 7d4c827560b86f5f6492155f46324b05322b2171) --- docs/TODO.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/TODO.md b/docs/TODO.md index ed0cc6ed3..4a7901db8 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -353,6 +353,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. ### Solver/presentatie — resterende punten (2026-07-20) From 50cb5b5409a82548e58281492787005be4e5ddff Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:02:53 +0200 Subject: [PATCH 116/171] fix(xer): registreer check-xer-recorded-times.ts in run.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ontbrak nog in de T1-commit — de nieuwe bak-4-check draaide daardoor niet mee in de planningssuite (tests/planning/run.sh), zoals het laag-3-plan (§4/T1) eist. --- tests/planning/run.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/planning/run.sh b/tests/planning/run.sh index 911ab69a9..18acbb22b 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -309,6 +309,13 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then 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 + # 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. From 92e98b8ed0f66837a711d464bfc6abc61b85ea36 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:13:39 +0200 Subject: [PATCH 117/171] test(kalender): spiegelpoort add/subtract + XER-projectie expliciet brongebonden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Het diagnoserapport (laag 1, klasse (ii), deeldossier "spiegel") vermoedde een formaatneutraal defect in `subtractWorkMinutes`/`subDuration`: een terugwaartse duurwandeling zou over een aaneengesloten niet-werkblok van tien dagen tien werkdagen overbruggen waar de voorwaartse er zeven telt. Dat vermoeden is gemeten en WEERLEGD; deze commit legt de meting vast als regressiepoort in plaats van de motor te wijzigen. Meting, corpusloos (`check-calendar-mirror.ts`, 82 controles): * primitieven — 0 afwijkingen over het hele meetvenster rond een blok van tien aaneengesloten vrije dagen, in uur-modus met 1, 2 en 3 banden per dag en in dag-modus, op ma-vr én op de P6-vorm ma-do+za+zo (vrijdag vrij); `sub(add(t,n),n) === t` op de milliseconde voor elk werk-instant; * solverniveau — 0 afwijkingen: `ES..EF` en `LS..LF` overspannen altijd evenveel werktijd en `ES→LS` is altijd gelijk aan `EF→LF`, over FS/SS/FF/SF met lag +2/0/-2 en het late venster dwars door het blok; * concreet geval — 7 werkdagen vooruit van do 2026-03-05 08:00 geeft ma 2026-03-23 17:00, en 7 werkdagen terug vanaf die finish landt exact terug. Meting op de bron (rehab-2.xer, kalender 842, mét de tien-dagen-blokpatch uit het dossier): `addWorkMinutes(2009-11-25T08:00, 56u)` = 2009-12-12T17:00 en `subtractWorkMinutes(2009-12-12T17:00, 56u)` = 2009-11-25T08:00 — beide exact P6's eigen LF/LS. `subtractP6XerProjectedWorkMinutes` geeft daar 2009-11-22T08:00, drie werkdagen te vroeg; die drie zijn exact de penaltydagen 2009-11-27, 11-30 en 12-04, die op die kalender al gewone holidays zijn. De 7-vs-10 komt dus uitsluitend uit de P6/XER-resultaatprojectie, die al achter `p6Source === 'XER'` hangt — geen kalenderprimitief, geen formaatneutraal defect. Deel C van de nieuwe batterij pint die asymmetrie én haar bronpoort vast: zonder `p6Source: 'XER'` is de projectie inert en geldt de spiegel onverkort. Zo kan ze nooit stilzwijgend formaatneutraal worden. Verder een docblok bij de minuut-lussen in `CalendarEngine` met de snap-regel op niet-werk-instants (richtingsbewust snappen, geen ronde-reis-identiteit buiten een werk-instant, bandgrens-dualiteit meten met `workMinutesBetween === 0`). Poorten: nieuwe check exit 0 (ook onder TZ UTC/LA/Kiritimati/Sydney), `npm run typecheck` exit 0, `npm run lint` exit 0. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 7a3dece61716ee4dc293d74ecdb27a65af136797) --- src/engine/scheduler/CalendarEngine.ts | 216 ++++++++------- tests/planning/check-calendar-mirror.ts | 353 ++++++++++++++++++++++++ tests/planning/run.sh | 6 + 3 files changed, 481 insertions(+), 94 deletions(-) create mode 100644 tests/planning/check-calendar-mirror.ts diff --git a/src/engine/scheduler/CalendarEngine.ts b/src/engine/scheduler/CalendarEngine.ts index 75d504671..edcdaf442 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). */ @@ -40,10 +40,10 @@ export class CalendarEngine { // Val-kuil (plan §T2): een werkende uitzondering op een NIET-werk-weekdag (bv. zaterdag) zit niet in // `workDaysPerWeek`/`countWorkWeekdays` (die kennen alleen het vaste weekpatroon) en moet dus als // EXTRA werkdag worden opgeteld in `workDaysBetween` — vandaar een eigen gesorteerde index. - private workingExceptionOnNonWorkWeekdayIdxSorted: number[]; - // Alleen data voor de EXPLICIETE P6-XER-projectiemethoden onderaan. De generieke kalender- - // algebra leest deze index nergens en blijft daardoor fysiek en volledig invers. - private p6NonWorkPenaltyDayIdxSorted: number[]; + private workingExceptionOnNonWorkWeekdayIdxSorted: number[]; + // Alleen data voor de EXPLICIETE P6-XER-projectiemethoden onderaan. De generieke kalender- + // algebra leest deze index nergens en blijft daardoor fysiek en volledig invers. + private p6NonWorkPenaltyDayIdxSorted: number[]; // Veiligheidsgrenzen tegen vastlopen bij een kapotte kalender (geen werkdagen) // of een ongeldige/sentinel-datum: MAX_SCAN = max dagen zoeken naar een werkdag; // MAX_DAYS = absolute iteratielimiet (~547 jaar) voor de tel-lussen. @@ -95,14 +95,14 @@ export class CalendarEngine { .filter((idx) => this.workDayMask[isoDayOfWeek(new Date(idx * CalendarEngine.MS_PER_DAY))] && !this.workingExceptionDaySet.has(idx)) .sort((a, b) => a - b); - this.workingExceptionOnNonWorkWeekdayIdxSorted = [...this.workingExceptionDaySet] - .filter((idx) => !this.workDayMask[isoDayOfWeek(new Date(idx * CalendarEngine.MS_PER_DAY))]) - .sort((a, b) => a - b); - this.p6NonWorkPenaltyDayIdxSorted = calendar.p6Source === 'XER' - ? [...new Set((calendar.p6NonWorkPenaltyDates ?? []).map(date => - Math.floor(parseDate(date).getTime() / CalendarEngine.MS_PER_DAY), - ).filter(Number.isFinite))].sort((a, b) => a - b) - : []; + this.workingExceptionOnNonWorkWeekdayIdxSorted = [...this.workingExceptionDaySet] + .filter((idx) => !this.workDayMask[isoDayOfWeek(new Date(idx * CalendarEngine.MS_PER_DAY))]) + .sort((a, b) => a - b); + this.p6NonWorkPenaltyDayIdxSorted = calendar.p6Source === 'XER' + ? [...new Set((calendar.p6NonWorkPenaltyDates ?? []).map(date => + Math.floor(parseDate(date).getTime() / CalendarEngine.MS_PER_DAY), + ).filter(Number.isFinite))].sort((a, b) => a - b) + : []; // ── Fase 2.8b: modus-detectie + uur-setup (§4.1). Afwezige `workTime` ⇒ dag-modus: // dan wordt niets hieronder geraakt en draaien de bevroren dag-lussen ongewijzigd. this.mode = calendar.workTime ? 'hour' : 'day'; @@ -121,7 +121,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); @@ -330,10 +330,10 @@ export class CalendarEngine { * vaste weekpatroon) NIET meetelt en die hier dus als EXTRA werkdag worden opgeteld. Een werkende * uitzondering op een reeds-werkende weekdag zit al in `countWorkWeekdays` en staat daarom niet in * `workingExceptionOnNonWorkWeekdayIdxSorted` (gefilterd in de constructor) — geen dubbeltelling. */ - private countWorkingExceptionsAddedInRange(startIdx: number, lastIdx: number): number { - return this.countSortedIdxInRange(this.workingExceptionOnNonWorkWeekdayIdxSorted, startIdx, lastIdx); - } - + private countWorkingExceptionsAddedInRange(startIdx: number, lastIdx: number): number { + return this.countSortedIdxInRange(this.workingExceptionOnNonWorkWeekdayIdxSorted, startIdx, lastIdx); + } + /** * Get the next working day on or after the given date. */ @@ -651,7 +651,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 | null { + private prevBandEndBound(tMs: number, strict: boolean): number | null { let best = Number.NEGATIVE_INFINITY; let dayMs = this.dayStartMsOf(tMs); let scan = 0; @@ -665,7 +665,7 @@ export class CalendarEngine { dayMs -= CalendarEngine.MS_PER_DAY; scan++; } - return best === Number.NEGATIVE_INFINITY ? null : best; + return best === Number.NEGATIVE_INFINITY ? null : best; } // ── Instant-vinders (§4.1) ───────────────────────────────────────────────── @@ -692,37 +692,65 @@ 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) ?? 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); + 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 { - const tMs = t.getTime(); - return new Date(this.prevBandEndBound(tMs, true) ?? tMs); + 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()`. + // + // De enige asymmetrie in dit bestand is `subtractP6XerProjectedWorkMinutes` onderaan, en die is + // uitdrukkelijk een P6/XER-RESULTAATPROJECTIE achter `p6Source === 'XER'` — geen kalenderprimitief. + // `tests/planning/check-calendar-mirror.ts` pint deze drie regels én die ene bronpoort 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); - } - - /** Bestaande fysieke bandwandeling, zonder P6-bronpenalty. */ - private addPhysicalWorkMinutes(startInstant: Date, minutes: number): Date { - if (minutes <= 0) return new Date(startInstant.getTime()); + addWorkMinutes(startInstant: Date, minutes: number): Date { + return this.addPhysicalWorkMinutes(startInstant, minutes); + } + + /** Bestaande fysieke bandwandeling, zonder P6-bronpenalty. */ + 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(); let steps = 0; @@ -744,13 +772,13 @@ 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); - } - - /** Bestaande fysieke achterwaartse bandwandeling, zonder P6-bronpenalty. */ - private subtractPhysicalWorkMinutes(endInstant: Date, minutes: number): Date { - if (minutes <= 0) return new Date(endInstant.getTime()); + subtractWorkMinutes(endInstant: Date, minutes: number): Date { + return this.subtractPhysicalWorkMinutes(endInstant, minutes); + } + + /** Bestaande fysieke achterwaartse bandwandeling, zonder P6-bronpenalty. */ + 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(); let steps = 0; @@ -772,54 +800,54 @@ export class CalendarEngine { /** Getekende werkminuten in `[a,b)` (§5.5, voor vrije speling). Positief als b>a, negatief als * b 0 ? 1 : -1; - const adjusted = Math.max(0, Math.abs(physical) - this.p6NonWorkPenaltyMinutesBetween(a, b)); - return sign * adjusted; - } - - /** Virtuele extra P6-nietwerktijd in het datum-inclusieve venster tussen twee instants. */ - private p6NonWorkPenaltyMinutesBetween(a: Date, b: Date): number { - if (this.p6NonWorkPenaltyDayIdxSorted.length === 0) return 0; - const aIdx = Math.floor(a.getTime() / CalendarEngine.MS_PER_DAY); - const bIdx = Math.floor(b.getTime() / CalendarEngine.MS_PER_DAY); - const startIdx = Math.min(aIdx, bIdx); - const lastIdx = Math.max(aIdx, bIdx); - return this.countSortedIdxInRange(this.p6NonWorkPenaltyDayIdxSorted, startIdx, lastIdx) - * this.hoursPerDay * 60; - } - - /** Bestaande fysieke bandminuten in `[a,b)`, zonder P6-bronpenalty. */ - private physicalWorkMinutesBetween(a: Date, b: Date): number { - const aMs = a.getTime(); + workMinutesBetween(a: Date, b: Date): number { + return this.physicalWorkMinutesBetween(a, b); + } + + /** + * P6-XER-resultaatprojectie voor een backward duur-/lagwandeling. Dit is uitdrukkelijk GEEN + * kalenderprimitief: `subtractWorkMinutes` blijft de inverse van `addWorkMinutes`. De solver mag + * deze methode alleen kiezen wanneer óók het project `schedulingOptions.p6Source === 'XER'` + * draagt; deze kalender controleert onafhankelijk haar eigen `p6Source` via de lege index. + */ + subtractP6XerProjectedWorkMinutes(endInstant: Date, minutes: number): Date { + if (minutes <= 0 || this.p6NonWorkPenaltyDayIdxSorted.length === 0) { + return this.subtractPhysicalWorkMinutes(endInstant, minutes); + } + let adjustedMinutes = minutes; + let result = this.subtractPhysicalWorkMinutes(endInstant, adjustedMinutes); + for (let iteration = 0; iteration <= this.p6NonWorkPenaltyDayIdxSorted.length; iteration++) { + const nextMinutes = minutes + this.p6NonWorkPenaltyMinutesBetween(result, endInstant); + if (nextMinutes === adjustedMinutes) return result; + adjustedMinutes = nextMinutes; + result = this.subtractPhysicalWorkMinutes(endInstant, adjustedMinutes); + } + return result; + } + + /** P6-XER-resultaatprojectie voor TF/FF; de generieke `workMinutesBetween` blijft fysiek. */ + p6XerProjectedWorkMinutesBetween(a: Date, b: Date): number { + const physical = this.physicalWorkMinutesBetween(a, b); + if (physical === 0 || this.p6NonWorkPenaltyDayIdxSorted.length === 0) return physical; + const sign = physical > 0 ? 1 : -1; + const adjusted = Math.max(0, Math.abs(physical) - this.p6NonWorkPenaltyMinutesBetween(a, b)); + return sign * adjusted; + } + + /** Virtuele extra P6-nietwerktijd in het datum-inclusieve venster tussen twee instants. */ + private p6NonWorkPenaltyMinutesBetween(a: Date, b: Date): number { + if (this.p6NonWorkPenaltyDayIdxSorted.length === 0) return 0; + const aIdx = Math.floor(a.getTime() / CalendarEngine.MS_PER_DAY); + const bIdx = Math.floor(b.getTime() / CalendarEngine.MS_PER_DAY); + const startIdx = Math.min(aIdx, bIdx); + const lastIdx = Math.max(aIdx, bIdx); + return this.countSortedIdxInRange(this.p6NonWorkPenaltyDayIdxSorted, startIdx, lastIdx) + * this.hoursPerDay * 60; + } + + /** Bestaande fysieke bandminuten in `[a,b)`, zonder P6-bronpenalty. */ + private physicalWorkMinutesBetween(a: Date, b: Date): number { + const aMs = a.getTime(); const bMs = b.getTime(); if (aMs === bMs) return 0; const sign = bMs > aMs ? 1 : -1; diff --git a/tests/planning/check-calendar-mirror.ts b/tests/planning/check-calendar-mirror.ts new file mode 100644 index 000000000..60af60da7 --- /dev/null +++ b/tests/planning/check-calendar-mirror.ts @@ -0,0 +1,353 @@ +// 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 — de ENIGE gemeten asymmetrie zit in de P6/XER-resultaatprojectie, en die is brongebonden +// ═══════════════════════════════════════════════════════════════════════════════════════════ +// +// `subtractP6XerProjectedWorkMinutes` telt per `p6NonWorkPenaltyDate` in het doorlopen venster een +// extra niet-werkdag mee. Er is met opzet GEEN voorwaartse tegenhanger: `addWorkMinutes` blijft +// fysiek. De backward-wandeling overbrugt daardoor MEER kalenderwerkdagen dan de forward-wandeling +// met dezelfde werkminuten — precies de "7 vooruit, 10 terug" uit het dossier. Dat is per +// constructie geen kalenderprimitief maar een P6-resultaatprojectie; deze batterij pint dat het +// (a) inderdaad zo werkt en (b) UITSLUITEND met `p6Source: 'XER'` aan staat. +{ + // Kalender 842-vorm: vrijdag vrij, weekend werkt, 2 banden van samen 8 uur, blok van 10 dagen. + // De twee vrijdagen ín het blok (2026-03-13 en 2026-03-20 vallen erbuiten; 2026-03-13 erin) zijn + // de realistische penaltyvorm: een vrije uitzondering op een weekdag die tóch al geen banden heeft. + const penaltyDates = ['2026-03-13']; + const base = { p6NonWorkPenaltyDates: penaltyDates } as Partial; + const withSource = new CalendarEngine( + hourCalendar('c-xer', [1, 2, 3, 4, 6, 7], TWO_BANDS, { ...base, p6Source: 'XER' }), + ); + const withoutSource = new CalendarEngine( + hourCalendar('c-neutraal', [1, 2, 3, 4, 6, 7], TWO_BANDS, base), + ); + // Exact het venster uit A6: 7 werkdagen à 8 uur eindigend op ma 2026-03-23. + const finish = parseInstant('2026-03-23T17:00'); + const duration = 7 * 480; + + // C1 — zonder bronstempel is de projectie inert: exact de fysieke, spiegelende uitkomst. + eq('C1 zonder p6Source blijft de projectie de fysieke spiegel', + formatInstant(withoutSource.subtractP6XerProjectedWorkMinutes(finish, duration), 'hour'), + formatInstant(withoutSource.subtractWorkMinutes(finish, duration), 'hour')); + eq('C1 de fysieke spiegel is de exacte inverse van de voorwaartse wandeling', + formatInstant(withoutSource.addWorkMinutes( + withoutSource.subtractWorkMinutes(finish, duration), duration), 'hour'), + formatInstant(finish, 'hour')); + + // C2 — mét bronstempel schuift de projectie de startgrens één extra vrije dag naar voren, dus + // overbrugt de terugwandeling méér kalenderwerkdagen dan de voorwaartse. Vastgepind als BESTAAND, + // bewust brongebonden gedrag, niet als wenselijke kalendersemantiek. + const physical = withSource.subtractWorkMinutes(finish, duration); + const projected = withSource.subtractP6XerProjectedWorkMinutes(finish, duration); + ok('C2 met p6Source wijkt de projectie af van de fysieke spiegel', + projected.getTime() < physical.getTime()); + eq('C2 het verschil is exact het aantal penaltydagen × hoursPerDay', + withSource.workMinutesBetween(projected, physical), penaltyDates.length * 8 * 60); + eq('C2 de fysieke spiegel zelf blijft de exacte inverse', + formatInstant(withSource.addWorkMinutes(physical, duration), 'hour'), + formatInstant(finish, 'hour')); + + // C3 — de bronpoort zit óók op de float-projectie. + const a = parseInstant('2026-03-05T08:00'); + eq('C3 zonder p6Source is de floatprojectie de fysieke werkminutentelling', + withoutSource.p6XerProjectedWorkMinutesBetween(a, finish), + withoutSource.workMinutesBetween(a, finish)); + ok('C3 met p6Source trekt de floatprojectie de penaltydagen af', + withSource.p6XerProjectedWorkMinutesBetween(a, finish) < withSource.workMinutesBetween(a, finish)); +} + +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/run.sh b/tests/planning/run.sh index 911ab69a9..5c81c9359 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -253,6 +253,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 From 64aad296b1d563a70c8bacab52cb22a122b04e45 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:10:05 +0200 Subject: [PATCH 118/171] =?UTF-8?q?feat(xer):=20X12-mutatiebewijs=20positi?= =?UTF-8?q?eve=20helft=20=E2=80=94=20recordedTimes=20beweegt,=20solve=20ni?= =?UTF-8?q?et?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan §4/T2. Op de bestaande completedPackageBytes(..., 'mutated')- en packageBytes(..., mutateStoredOutput)-fixtures in check-xer-product-fidelity-x12.ts (v2) komt de positieve helft van het mutatiebewijs naast de al bestaande negatieve helft: readXER(...).recordedTimes verschilt exact op de gemuteerde bak-4-assen met de verwachte waarden, terwijl de solverprojectie (axes(mutated) === axes(on), al bestaand) byte-gelijk blijft. Nieuwe helper noRecordedAxisLeak bewijst daarbij dat geen enkele Task.time-as van een taak een gemuteerde orakelwaarde draagt. Corpusloos: 83 → 90 checks groen (vóór/na rebase op 8b87c7d5 ongewijzigd op de overige checks); met corpus: 3/93 → 3/100 checks rood, exact dezelfde drie red lines en dezelfde 18.398 zesassige afwijkingen als op 8b87c7d5 zelf (geverifieerd via een detached worktree, geen stash) — xer-product-fidelity-baseline.json blijft ongewijzigd. check-xer-recorded-times.ts (T1): de lokale isolatie-assertie blijft staan als extra corpusloze dekking naast de nu canonieke X12-mutatieproef, met motivatie in de code. Co-Authored-By: Claude Fable 5.1 --- .../check-xer-product-fidelity-x12.ts | 79 +++++++++++++++++++ tests/planning/check-xer-recorded-times.ts | 23 ++++-- 2 files changed, 94 insertions(+), 8 deletions(-) diff --git a/tests/planning/check-xer-product-fidelity-x12.ts b/tests/planning/check-xer-product-fidelity-x12.ts index eec40c17c..94e0f4ce7 100644 --- a/tests/planning/check-xer-product-fidelity-x12.ts +++ b/tests/planning/check-xer-product-fidelity-x12.ts @@ -68,6 +68,13 @@ 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[]): boolean { + return tasks.every(task => Object.values(task.time).every(value => !oracleValues.includes(value as string))); +} function hash(bytes: Uint8Array): string { return createHash('sha256').update(bytes).digest('hex'); } function xerStructuredRecord( number: string, @@ -1823,6 +1830,29 @@ async function productBaseline( oracleAxes: ['2026-01-05T08:00', '2026-01-02T16:00', '2026-01-08T08:00', '2026-01-08T16:00'], mutatedAxes: ['2026-01-05T08:00', '2026-01-02T16:00', '2026-01-08T08:00', '2026-01-08T16: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)); @@ -2110,6 +2140,55 @@ async function productBaseline( 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', + ]), 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', diff --git a/tests/planning/check-xer-recorded-times.ts b/tests/planning/check-xer-recorded-times.ts index a80067679..dd3a19c90 100644 --- a/tests/planning/check-xer-recorded-times.ts +++ b/tests/planning/check-xer-recorded-times.ts @@ -126,14 +126,21 @@ truthy('10 T1 — task.time draagt NERGENS de orakel-lateStart (2026-01-10)', console.log(`. xer-recorded-times: ${Object.keys(rt).length} van ${result.tasks.length} taken met vastgelegde early-as (fixture)`); // ═══════════════════════════════════════════════════════════════════════════════════════════ -// MUTATIEBEWIJS (b), UITGEVOERD ALS HANDMATIGE PROCEDURE (niet in dit bestand aanwezig): laat -// `readXerRecordedTimes` — of de taakmapping in `xerReader.ts` — tijdelijk `row.cells.late_start_date` -// ook naar `Task.time.lateStart` schrijven (bv. `time: { ...defaultTime, lateStart: row.cells -// .late_start_date }` in `mappedActivities.push(...)`), en draai deze check opnieuw: check 10 -// hierboven gaat dan ROOD, want `task.time.lateStart` bevat dan de orakelwaarde `2026-01-10`. -// Uitgevoerd en teruggedraaid tijdens de bouw van deze taak (zie het voortgangsrapport voor de -// exitcode vóór/ná) — niet als blijvende code hier, want een permanente mutatie in dit testbestand -// zou de fixture naar de implementatie toe schrijven, wat expliciet verboden is (baan-preambule). +// 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). From 5d7be729e5a6b3a07dac3dded0c3afd77ba34576 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:20:26 +0200 Subject: [PATCH 119/171] feat(xer): bron-orakel als derde laagkeuze + gedeelde applyRecordedTimesToTasks-kern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan §3.1/§3.3/§3.4/§4-T3. captureRecordedDates krijgt een derde parameter (recordedTimes) die als "laag 0" voorrang heeft boven recordedFields — de twee kanalen worden nooit gemengd, filtering op onbekende taak-ids volgt dezelfde regel als de bestaande twee lagen, total telt de overgebleven entries. Nieuwe applyRecordedTimesToTasks (recordedDates.ts) is de gedeelde kern die de vastlegging in de taken schrijft en het gereconstrueerde CPMResult teruggeeft — showRecordedDates (scheduleSlice.ts) gebruikt 'm nu i.p.v. de inline lus, zonder gedragswijziging op de bestaande IFC-route (alle 207 bestaande #63-cases blijven groen, nu 227 met de nieuwe secties). check-recorded-dates.ts: nieuwe sectie (1B) voor de orakel-laag (voorrang, filtering, total, randgevallen) en (3B) als rechtstreekse eenheidstest op applyRecordedTimesToTasks (gelijk aan cpmResultFromRecorded, terugvallen intact, ongemoeide taken zonder vastlegging, wis van interferingFloat/isNearCritical/ floatPath). Mutatiebewijzen (handmatig uitgevoerd, teruggedraaid): (a) orakel-voorrang uitgeschakeld (`if (false && recordedTimes)`) ⇒ 6/227 checks rood (1x/1y/1z/ 1ab/1ac/1ae); (b) de interferingFloat-wisregel verwijderd uit applyRecordedTimesToTasks ⇒ 2/227 checks rood (het NIEUWE 3ab én het BESTAANDE 8o, bewijst dat de extractie geen gedrag verloor). Co-Authored-By: Claude Fable 5.1 --- src/engine/scheduler/recordedDates.ts | 106 +++++++++++++++++++++---- src/state/slices/scheduleSlice.ts | 25 ++---- tests/planning/check-recorded-dates.ts | 90 +++++++++++++++++++++ 3 files changed, 184 insertions(+), 37 deletions(-) diff --git a/src/engine/scheduler/recordedDates.ts b/src/engine/scheduler/recordedDates.ts index d6939c850..2964957e1 100644 --- a/src/engine/scheduler/recordedDates.ts +++ b/src/engine/scheduler/recordedDates.ts @@ -73,35 +73,63 @@ export interface RecordedDatesState extends RecordedDates { * 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 }; @@ -276,3 +304,47 @@ 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; + } + return cpmResultFromRecorded(times, tasks, calendar); +} diff --git a/src/state/slices/scheduleSlice.ts b/src/state/slices/scheduleSlice.ts index 9c2b8dae6..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'; @@ -184,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, ); diff --git a/tests/planning/check-recorded-dates.ts b/tests/planning/check-recorded-dates.ts index 48c7d71a7..10afb8249 100644 --- a/tests/planning/check-recorded-dates.ts +++ b/tests/planning/check-recorded-dates.ts @@ -15,9 +15,11 @@ * 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 { Task } from '@/types/task'; @@ -147,6 +149,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 +276,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' }); From dd8782d206ed54f1a88ffbbcf96e3b954279c0e5 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:28:35 +0200 Subject: [PATCH 120/171] feat(xer): late zijde van voltooide activiteiten volgt P6 (statusdatumvenster, echte float) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P6 zet een voltooide XER-activiteit ook aan de late zijde neer als een taak met nul restduur op de statusdatum (LF = prevWorkInstant(LS), LS = de vroegste door haar opvolgers toegestane late finish, geklemd op de statusdatum) in plaats van op haar historische actual-venster; de relatie-lag telt daarbij niet mee tussen twee voltooide activiteiten maar wel zolang de opvolger nog restwerk heeft (diagnose laag 1, klasse (i), gemeten op rehab-2: 2.033/2.036 oftewel 99,9% dekking). 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. Nieuwe brongebonden vlag `p6CompletedLateFromRemainingWindow` (SchedulingOptions), naar het bestaande O6-patroon: default afwezig/false, uitsluitend door de XER-lezer op true gezet (xerScheduleOptions.ts) en alleen effectief onder p6Source==='XER' (CPMSolver.p6XerOption) — byte-identiek voor IFC/MSPDI/MPP. Rondt automatisch mee door het bestaande OPS_SchedulingOptions-JSON-pset (geen aparte ifcPsets/ifcWriter/ifcReader-wijziging nodig). Gepoort op dezelfde completedWindow-eligibility als de forward-display (explainP6CompletedData- DateWindow): een voltooide taak buiten die nauwe poort (CP_Phys, de LOE/hammock-actual-finish- uitzondering) behoudt haar bestaande actual-pin. scheduleAnalysis.ts's TF/FF-formule meet voortaan tegen hetzelfde statusdatumvenster i.p.v. de rauwe historische early-datums, anders zou de formule alleen de historische afstand tussen twee taken se eigen actuals meten. Meting (OPS_XER_FIDELITY_REPORT=summary/detail, volledige corpus, 34 bestanden): zesassige afwijkingen totaal 18.398 → 16.259 (−2.139, boven de verwachte ≈1.800); rehab-2 alleen: 14.812 → 12.673 (ls 4.358→3.468, lf 4.350→3.459, tf 4.200→3.842; es/ef/ff ongewijzigd). Alle overige 33 corpusbestanden byte-identiek (per-bestand vergeleken, delta 0). Corpusloze fixture tests/planning/check-xer-completed-late.ts (17 checks, in run.sh): lag-uitzondering tussen twee voltooide taken, lag-telling naar een niet-voltooide opvolger, LF=prevWorkInstant(LS), statusdatumklem bij een geïsoleerde voltooide taak, en vlag-uit byte-identiek aan de rauwe actual-pin. Mutatiebewijs (handmatig): de lag-uitzondering weghalen geeft 2/17 rood; de prevWorkInstant-regel weghalen geeft 3/17 rood. Bijgewerkt: drie bestaande XER-completed-fixtures (backward-float-trace, suspend-resume-window, product-fidelity-x12) waarvan de gepinde late-datums/floats/trace-labels de oude actual-pin beschreven — die tasks vallen nu allemaal binnen de nieuwe (default-aan voor XER) regel en krijgen daardoor terecht andere, P6-getrouwe waarden. Co-Authored-By: Claude Fable 5.1 --- src/engine/scheduler/CPMSolver.ts | 80 ++++++- src/engine/scheduler/scheduleAnalysis.ts | 29 ++- src/services/xer/xerScheduleOptions.ts | 1 + src/types/project.ts | 16 ++ tests/planning/check-ifc-roundtrip.ts | 1 + .../check-xer-backward-float-trace.ts | 10 +- tests/planning/check-xer-completed-late.ts | 207 ++++++++++++++++++ ...eck-xer-completed-suspend-resume-window.ts | 7 +- .../check-xer-product-fidelity-x12.ts | 28 ++- tests/planning/run.sh | 7 + 10 files changed, 364 insertions(+), 22 deletions(-) create mode 100644 tests/planning/check-xer-completed-late.ts diff --git a/src/engine/scheduler/CPMSolver.ts b/src/engine/scheduler/CPMSolver.ts index 8f745e045..f9208dedb 100644 --- a/src/engine/scheduler/CPMSolver.ts +++ b/src/engine/scheduler/CPMSolver.ts @@ -102,11 +102,13 @@ export type CpmProjectEndSource = export type CpmLateFinishSource = | 'projectEnd' - | 'successorConstraint'; + | 'successorConstraint' + | 'completedRemainingWindow'; export type CpmLateStartSource = | 'subDuration' - | 'subRemainingDuration'; + | 'subRemainingDuration' + | 'completedRemainingWindow'; export type CpmFreeFloatSource = | 'derivedFromSuccessor' @@ -3091,6 +3093,66 @@ export class CPMSolver { // 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) { + if (this.p6XerOption(this.options.schedulingOptions?.p6CompletedLateFromRemainingWindow) + && completedWindow.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 candidateLf: 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; + const succWindowCompleted = this.p6CompletedDataDateWindowDecision(succTask).eligible; + // Een voltooide opvolger die zelf niet door de completedWindow-poort kwam (CP_Phys, + // de LOE/hammock-actual-finish-uitzondering) 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 && !succWindowCompleted) 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). + const effectiveSeq = succWindowCompleted + ? { ...seq, lagDays: 0, lagMinutes: 0, lagPercent: undefined } + : seq; + const delayShiftedSuccResult = { + ls: this.shiftByLevelingDelay(succCal, succTask, succResult.ls, -1), + lf: this.shiftByLevelingDelay(succCal, succTask, succResult.lf, -1), + }; + const constraintFinish = backwardConstraint( + this.relDeps, delayShiftedSuccResult, effectiveSeq, task, succTask, progressCal, succCal, + this.p6ZeroDurationUsesFinishBoundary(succTask, succCal), + ); + if (candidateLf === null || constraintFinish < candidateLf) candidateLf = constraintFinish; + } + const derivedLs = candidateLf === null ? windowEs : progressCal.nextWorkInstant(candidateLf); + const ls = derivedLs < windowEs ? windowEs : derivedLs; + 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( @@ -3182,8 +3244,18 @@ export class CPMSolver { 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. - if (preserveActualDates && succTask.time.actualFinish && succTask.time.completion >= 1) continue; + // 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. + if (preserveActualDates && succTask.time.actualFinish && succTask.time.completion >= 1 + && !(this.p6XerOption(this.options.schedulingOptions?.p6CompletedLateFromRemainingWindow) + && this.p6CompletedDataDateWindowDecision(succTask).eligible)) { + 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 diff --git a/src/engine/scheduler/scheduleAnalysis.ts b/src/engine/scheduler/scheduleAnalysis.ts index 6d2fb8908..d6128bad2 100644 --- a/src/engine/scheduler/scheduleAnalysis.ts +++ b/src/engine/scheduler/scheduleAnalysis.ts @@ -252,8 +252,21 @@ export function computeScheduleResults(input: ScheduleAnalysisInput): CPMResult }; })() : null; - const finishFloat = signedFloat(early.ef, late.lf, cal, taskObj); - const startFloat = signedFloat(early.es, late.ls, cal, taskObj); + // 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. + const useCompletedRemainingWindow = completedWindowDecision.eligible + && so?.p6Source === 'XER' && so.p6CompletedLateFromRemainingWindow === true; + 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, @@ -375,6 +388,14 @@ export function computeScheduleResults(input: ScheduleAnalysisInput): CPMResult 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, @@ -395,8 +416,8 @@ export function computeScheduleResults(input: ScheduleAnalysisInput): CPMResult taskResults.set(taskId, { earlyStart: formatInstant(completedDisplayWindow?.es ?? early.es, mode), earlyFinish: formatInstant(completedDisplayWindow?.ef ?? early.ef, mode), - lateStart: formatInstant(displayActualLate ? early.es : late.ls, mode), - lateFinish: formatInstant(displayActualLate ? early.ef : late.lf, 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/services/xer/xerScheduleOptions.ts b/src/services/xer/xerScheduleOptions.ts index de4c360ea..16ba6ab15 100644 --- a/src/services/xer/xerScheduleOptions.ts +++ b/src/services/xer/xerScheduleOptions.ts @@ -123,6 +123,7 @@ export const XER_SCHEDULING_DEFAULTS = { p6PreserveActualInstants: true, p6UseRemainingStartForProgress: false, p6PreserveZeroDurationConstraintInstants: true, + p6CompletedLateFromRemainingWindow: true, }, } as const satisfies { progressMode: ProgressMode; schedulingOptions: SchedulingOptions }; diff --git a/src/types/project.ts b/src/types/project.ts index e6602fb6c..58e0bdb35 100644 --- a/src/types/project.ts +++ b/src/types/project.ts @@ -97,6 +97,22 @@ export interface SchedulingOptions { * 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. */ + p6CompletedLateFromRemainingWindow?: boolean; } export interface Project { diff --git a/tests/planning/check-ifc-roundtrip.ts b/tests/planning/check-ifc-roundtrip.ts index a7b58283a..32056b76b 100644 --- a/tests/planning/check-ifc-roundtrip.ts +++ b/tests/planning/check-ifc-roundtrip.ts @@ -389,6 +389,7 @@ const SCHED_OPTS = { p6PreserveActualInstants: true, p6UseRemainingStartForProgress: true, p6PreserveZeroDurationConstraintInstants: true, + p6CompletedLateFromRemainingWindow: true, useProjectEndDateForFloat: true, nearCriticalThreshold: 3, floatPaths: { enabled: true, method: 'TOTAL_FLOAT', maxPaths: 5 }, diff --git a/tests/planning/check-xer-backward-float-trace.ts b/tests/planning/check-xer-backward-float-trace.ts index 1bf2c2a95..6848dbc7d 100644 --- a/tests/planning/check-xer-backward-float-trace.ts +++ b/tests/planning/check-xer-backward-float-trace.ts @@ -198,7 +198,7 @@ const variants: Variant[] = [ fixtureShape: 'completedOnly', expected: { projectEndSource: 'completedDisplayWindow', - completedLateStartSource: 'subRemainingDuration', + completedLateStartSource: 'completedRemainingWindow', taskIds: ['C'], }, }, @@ -211,7 +211,7 @@ const variants: Variant[] = [ laterOpenTaskWinsProjectEnd: true, expected: { projectEndSource: 'maxEarlyFinish', - completedLateStartSource: 'subRemainingDuration', + completedLateStartSource: 'completedRemainingWindow', milestoneFreeFloatSource: 'projectEndFinishMilestoneBoundary', taskIds: ['C', 'L', 'M', 'O', 'P'], }, @@ -233,7 +233,7 @@ const variants: Variant[] = [ fixtureShape: 'network', expected: { projectEndSource: 'useProjectEndDateForFloat', - completedLateStartSource: 'subRemainingDuration', + completedLateStartSource: 'completedRemainingWindow', milestoneFreeFloatSource: 'clampedZero', taskIds: ['C', 'M', 'O', 'P'], }, @@ -243,7 +243,7 @@ const variants: Variant[] = [ finishMilestoneBoundary: false, fixtureShape: 'network', expected: { - completedLateStartSource: 'subRemainingDuration', + completedLateStartSource: 'completedRemainingWindow', milestoneFreeFloatSource: 'derivedFromSuccessor', taskIds: ['C', 'M', 'O', 'P'], }, @@ -446,7 +446,7 @@ for (const variant of completedGuardVariants) { displayActualLateReason: source?.backwardFloatTraceDisplayActualLateReason, }, { projectEndSource: 'completedDisplayWindow', - lateStartSource: 'subRemainingDuration', + lateStartSource: 'completedRemainingWindow', displayActualLate: true, completedWindowEligible: true, completedWindowReason: 'eligible', diff --git a/tests/planning/check-xer-completed-late.ts b/tests/planning/check-xer-completed-late.ts new file mode 100644 index 000000000..80eddddda --- /dev/null +++ b/tests/planning/check-xer-completed-late.ts @@ -0,0 +1,207 @@ +/** + * 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 ma–vr 07:00–15:00, 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 type { SchedulingOptions } from '@/types/project'; + +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, detail: string): void { + checks++; + if (!condition) diffs.push(`${label}: ${detail}`); +} + +// Kalender ma–vr 07:00–15:00, 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', + '%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', + '%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 }; + } + 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 `p6XerProjectedWorkMinutesBetween`-primitief + // die `CPMSolver.signedFloat` zelf ook gebruikt voor een XER-project. + const windowEs = cal.nextWorkInstant(dataDate); + const windowEf = cal.prevWorkInstant(windowEs); + const expectedTf = cal.p6XerProjectedWorkMinutesBetween(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 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 }); +} + +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-suspend-resume-window.ts b/tests/planning/check-xer-completed-suspend-resume-window.ts index a10d47fab..7761e1d5c 100644 --- a/tests/planning/check-xer-completed-suspend-resume-window.ts +++ b/tests/planning/check-xer-completed-suspend-resume-window.ts @@ -135,8 +135,11 @@ eq('XER completed suspend/resume positive: geldige P6-vorm opent de completed-wi scheduled: { earlyStart: '2026-08-17T10:00', earlyFinish: '2026-08-17T10:00', - lateStart: '2026-08-04T07:00', - lateFinish: '2026-08-14T15: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: { diff --git a/tests/planning/check-xer-product-fidelity-x12.ts b/tests/planning/check-xer-product-fidelity-x12.ts index eec40c17c..396bfc3c0 100644 --- a/tests/planning/check-xer-product-fidelity-x12.ts +++ b/tests/planning/check-xer-product-fidelity-x12.ts @@ -1811,8 +1811,12 @@ async function productBaseline( 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-08T08:00', lateFinish: '2026-01-08T16: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', { @@ -1820,8 +1824,8 @@ async function productBaseline( mutatedAxes: [oneRawMutatedResult?.earlyStart, oneRawMutatedResult?.earlyFinish, oneRawMutatedResult?.lateStart, oneRawMutatedResult?.lateFinish], }, { - oracleAxes: ['2026-01-05T08:00', '2026-01-02T16:00', '2026-01-08T08:00', '2026-01-08T16:00'], - mutatedAxes: ['2026-01-05T08:00', '2026-01-02T16:00', '2026-01-08T08:00', '2026-01-08T16:00'], + 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'], }); // De statusdatum, taakprovenance en gewone geplande start moeten ook na de native opslaggrens // aanwezig blijven; XER-archiefreconstructie levert daarmee dezelfde brongebonden route op. @@ -1833,7 +1837,7 @@ async function productBaseline( oneReloadedResult?.lateStart, oneReloadedResult?.lateFinish], }, { explicitTargetWindow: true, - axes: ['2026-01-05T08:00', '2026-01-02T16:00', '2026-01-08T08:00', '2026-01-08T16:00'], + 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, @@ -1937,7 +1941,17 @@ async function productBaseline( const connectedSolved = solveImported(connected).tasks; const connectedA = connectedSolved.find(task => task.taskCode === 'P100'); const connectedB = connectedSolved.find(task => task.taskCode === 'A100'); - eq('X12 A→FS→B(completed) houdt B-inversie buiten backwarddruk op open A', { + // 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, @@ -1951,11 +1965,11 @@ async function productBaseline( }, { a: { earlyStart: '2026-01-05T08:00', earlyFinish: '2026-01-05T16:00', - lateStart: '2026-01-05T08:00', lateFinish: '2026-01-05T16:00', totalFloatMinutes: 0, + 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-08T08:00', lateFinish: '2026-01-08T16:00', freeFloatMinutes: 0, + lateStart: '2026-01-05T08:00', lateFinish: '2026-01-02T16:00', freeFloatMinutes: 0, }, }); diff --git a/tests/planning/run.sh b/tests/planning/run.sh index 5c81c9359..aecc51e8e 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -388,6 +388,13 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then 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" From 9a25398cac6c093f2bee14a4f2616ba6f9f54489 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:17:20 +0200 Subject: [PATCH 121/171] Voeg read-only XER-bronroute voor extensies toe (cherry picked from commit 12e09bce69c6d80ffe261ef46d34a9a03bc85056) --- src/extensions/apiVersion.ts | 2 +- src/extensions/extImportSource.ts | 296 +++++++++++++++++++++++++++ src/extensions/extTypes.ts | 163 +++++++++++++++ src/extensions/extensionApi.ts | 19 ++ src/extensions/types.ts | 13 ++ tests/planning/check-ext-contract.ts | 222 +++++++++++++++++++- 6 files changed, 712 insertions(+), 3 deletions(-) create mode 100644 src/extensions/extImportSource.ts 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..bcb87e32a --- /dev/null +++ b/src/extensions/extImportSource.ts @@ -0,0 +1,296 @@ +/** + * 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 }; +} + +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; + if (!selector) return null; + const { offset, limit } = validatePageOptions(options); + const view = documentViewFor(archive, metadata, selector); + const records = collectionRecords(archive, selector, view, collection); + return { + collection, + sourceProjectId: selector, + offset, + limit, + total: records.length, + items: records.slice(offset, offset + limit).map(copyRecord), + }; +} diff --git a/src/extensions/extTypes.ts b/src/extensions/extTypes.ts index e3aead80f..58a1590d8 100644 --- a/src/extensions/extTypes.ts +++ b/src/extensions/extTypes.ts @@ -380,6 +380,169 @@ 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; +} + +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..bf9ae8015 100644 --- a/src/extensions/extensionApi.ts +++ b/src/extensions/extensionApi.ts @@ -22,6 +22,7 @@ import { type ExtEventListener, } from '@/services/extensionEvents'; import { applyPermissionGuards } from './permissions'; +import { getExtImportSourceCatalogPage, getExtImportSourceChunk, toExtImportSourceInfo } from './extImportSource'; import { toExtProject, toExtCalendar, @@ -119,6 +120,24 @@ 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(); + return state.xerSourceArchive + ? getExtImportSourceCatalogPage( + state.xerSourceArchive, state.xerImportMetadata, state.xerSourceProjectId, collection, options, + ) + : null; + }, addTask: (task) => { const materialize = customTaskTypeToMaterialize(task.customTaskType); if (!materialize) return document.store.getState().addTask(fromExtTaskInput(task)); diff --git a/src/extensions/types.ts b/src/extensions/types.ts index f590838b8..df7b80752 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'; @@ -171,6 +175,15 @@ export interface ExtensionApi { getSequences(): ExtSequence[]; getResources(): ExtResource[]; getAssignments(): ExtAssignment[]; + /** Kleine read-only XER-bronsamenvatting; null voor een niet-XER-document. */ + getImportSourceInfo(): ExtImportSourceInfo | null; + /** Eén verse kopie van een retained XER-bronchunk; null voor een niet-XER-document. */ + getImportSourceChunk(index: number): Uint8Array | null; + /** Pagineerbare, gekopieerde retained XER-catalogusdata; null voor een niet-XER-document. */ + 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/tests/planning/check-ext-contract.ts b/tests/planning/check-ext-contract.ts index 453995e88..6840a6a13 100644 --- a/tests/planning/check-ext-contract.ts +++ b/tests/planning/check-ext-contract.ts @@ -33,7 +33,7 @@ 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 type { ExtensionApi, ExtensionPermission } from '@/extensions/types'; import { @@ -56,6 +56,9 @@ import { } 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'; const diffs: string[] = []; let checks = 0; @@ -656,7 +659,222 @@ for (const [naam, ext, bron, sleutels] of [ api._cleanup(); } -// ── 7. De contract-versiepoort (los van minAppVersion) ─────────────────────── +// ── 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', [], undefined, appStoreContext, { + app: appStoreContext, + showNotification: () => {}, + }); + 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]); + + 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]); + + // 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' }); + 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. From 8f1b57e706f7639cb73e29819973edb19f1a8d3a Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:49:41 +0200 Subject: [PATCH 122/171] fix(extensies): read-only XER-bronroute achter aparte permissie 'importSource' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De drie getImportSource*-methoden gaven de volledige oorspronkelijke bronbytes van een geïmporteerd bestand (audit-/herkomstvelden, kosten, ...) vrij aan élke extensie zonder enige permissie, omdat ze in de permissievrije data.*-kern zaten (reviewbevinding P1). Ze staan nu default-deny achter een eigen, hard afgedwongen permissie 'importSource' in permissions.ts — zonder de permissie gooit de guard vóórdat er data gelezen wordt, dus geen gedeeltelijk antwoord. Manifest-validatie (sanitizeManifestPermissions/KNOWN_PERMISSIONS) en docs/extensions.md (permissietabel + beperkingen) zijn meegewerkt. Mutatiebewijs: de drie 'importSource'-entries uit API_PERMISSIONS verwijderen laat de nieuwe P1/P1a-checks in check-ext-contract.ts rood lopen (249 → 2 rood), exitcode 1. (cherry picked from commit eb730ee3cc7d0f79276c50f98994f910309a48ce) --- docs/extensions.md | 6 ++-- src/extensions/extensionApi.ts | 5 ++-- src/extensions/permissions.ts | 14 +++++++++- src/extensions/types.ts | 25 ++++++++++++++--- tests/planning/check-ext-contract.ts | 41 +++++++++++++++++++++++++++- 5 files changed, 81 insertions(+), 10 deletions(-) diff --git a/docs/extensions.md b/docs/extensions.md index b3cd0690e..88b024fad 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). @@ -343,6 +345,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/src/extensions/extensionApi.ts b/src/extensions/extensionApi.ts index bf9ae8015..398d0477d 100644 --- a/src/extensions/extensionApi.ts +++ b/src/extensions/extensionApi.ts @@ -264,8 +264,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/types.ts b/src/extensions/types.ts index df7b80752..d3cea470e 100644 --- a/src/extensions/types.ts +++ b/src/extensions/types.ts @@ -40,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 @@ -51,7 +57,8 @@ export type ExtensionPermission = | 'events' | 'filesystem' | 'network' - | 'pdf-fonts'; + | 'pdf-fonts' + | 'importSource'; export type ExtensionStatus = 'enabled' | 'disabled' | 'error' | 'loading'; @@ -175,11 +182,21 @@ export interface ExtensionApi { getSequences(): ExtSequence[]; getResources(): ExtResource[]; getAssignments(): ExtAssignment[]; - /** Kleine read-only XER-bronsamenvatting; null voor een niet-XER-document. */ + /** + * 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. */ + /** + * 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. */ + /** + * Pagineerbare, gekopieerde retained XER-catalogusdata; null voor een niet-XER-document. + * Permissie `importSource` vereist. + */ getImportSourceCatalogPage( collection: ExtImportSourceCollection, options?: ExtImportSourcePageOptions, diff --git a/tests/planning/check-ext-contract.ts b/tests/planning/check-ext-contract.ts index 6840a6a13..b8b37b97d 100644 --- a/tests/planning/check-ext-contract.ts +++ b/tests/planning/check-ext-contract.ts @@ -42,6 +42,7 @@ 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, @@ -694,10 +695,48 @@ for (const [naam, ext, bron, sleutels] of [ if (!isMultiDocumentImport(opened)) throw new Error('Bronfixture moet twee XER-documenten opleveren'); useAppStore.getState().newProject(); - const api = createExtensionApi('xer-source-read-contract', [], undefined, appStoreContext, { + 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(); + } + eq('P1b importSource staat in de door de app gekende permissies (SDK/validatie)', + 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'), From f3914509c2c1a95d4ce858ca72431808e151c45c Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:52:35 +0200 Subject: [PATCH 123/171] fix(extensies): canoniseer XER-catalogus-offset tegen total i.p.v. optellen bij limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit records.slice(offset, offset + limit) kon offset+limit buiten het safe-integer-bereik duwen zodra offset dicht bij Number.MAX_SAFE_INTEGER lag (reviewbevinding P2) — de losse validatie van offset en limit bewees niet dat hun som dat ook bleef. resolvePageOffset begrenst offset nu op de werkelijke collectiegrootte vóór de slice: een offset voorbij het eind canoniseert naar total (lege, geldige laatste pagina) i.p.v. te gooien of een onveilige slice te maken; total is per definitie klein genoeg dat offset + limit daarna altijd safe blijft. Mutatiebewijs: terugzetten van de kale offset (zonder resolvePageOffset) laat de nieuwe P2/P2a/P2b-checks in check-ext-contract.ts rood lopen (252 → 3 rood) voor zowel Number.MAX_SAFE_INTEGER als Number.MAX_SAFE_INTEGER - limit + 1, en voor een lege collectie; exitcode 1. (cherry picked from commit 8f98682bc2a262a918cf5b138f47e98c410d1805) --- src/extensions/extImportSource.ts | 23 ++++++++++++++++++++-- tests/planning/check-ext-contract.ts | 29 ++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/extensions/extImportSource.ts b/src/extensions/extImportSource.ts index bcb87e32a..33f7f8397 100644 --- a/src/extensions/extImportSource.ts +++ b/src/extensions/extImportSource.ts @@ -55,6 +55,23 @@ function validatePageOptions(options?: ExtImportSourcePageOptions): { offset: nu 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 { + return offset > total ? total : offset; +} + function documentViewFor( archive: XerSourceArchive, metadata: XerImportMetadata | null | undefined, @@ -282,15 +299,17 @@ export function getExtImportSourceCatalogPage( ): ExtImportSourceCatalogPage | null { const selector = sourceProjectId ?? metadata?.sourceProjectId; if (!selector) return null; - const { offset, limit } = validatePageOptions(options); + 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: records.length, + total, items: records.slice(offset, offset + limit).map(copyRecord), }; } diff --git a/tests/planning/check-ext-contract.ts b/tests/planning/check-ext-contract.ts index b8b37b97d..37b9029ef 100644 --- a/tests/planning/check-ext-contract.ts +++ b/tests/planning/check-ext-contract.ts @@ -35,6 +35,7 @@ import type { ExtProject, ExtCalendar, ExtTask, ExtTaskTime, ExtSequence, ExtResource, ExtAssignment, 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, @@ -862,6 +863,34 @@ for (const [naam, ext, bron, sleutels] of [ 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'; From 791876b8544ff57f059946698b04ad06c0f8372e Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:54:43 +0200 Subject: [PATCH 124/171] docs(extensies): documenteer de read-only XER-bronroute in docs/extensions.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De drie getImportSource*-methoden en de 'importSource'-permissie stonden nog nergens uitgeschreven voor extensie-auteurs (reviewbevinding P2-docs) — de apiVersion-bump naar 1.1.0 was daarmee onvindbaar. Voegt een aparte paragraaf toe: null-semantiek buiten een XER-document, documentgebonden selector, maximaal 500 records per pagina, hoe je de chunks tot de exacte oorspronkelijke bytes reconstrueert (met de sha256-digest), het canoniseren van een offset voorbij het einde in plaats van een fout, en waarom dit een aparte permissie is in plaats van kern-API. (cherry picked from commit 4aeabc61f8686fba7e54b3d24e03b0fd3398cf74) --- docs/extensions.md | 60 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/docs/extensions.md b/docs/extensions.md index 88b024fad..625b02757 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -205,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 | @@ -263,6 +263,64 @@ module.exports = { }; ```` +### Read-only XER-bronroute (permissie `importSource`) + +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. + +**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 en selector.** 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. + +**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. From 6a384d06f81173c4ae862ded391316c1036e2444 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:12:23 +0200 Subject: [PATCH 125/171] fix(extensies): validation.ts leest permissies uit permissions.ts, niet uit een eigen kopie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Her-review 2 (P1): validation.ts hield een tweede, handgekopieerde permissielijst zonder 'importSource'. Elk installatiepad (ZIP/catalogus, los .js, dev-bridge) parseert met mode 'fresh', en parsePermissions weigerde 'importSource' daardoor exact als de typefout 'bogus' — de complete bronroute uit de vorige twee commits was voor geen enkele echte extensie bereikbaar, ook al stond de permissie al in de guard-tabel en KNOWN_PERMISSIONS. EXTENSION_PERMISSIONS in validation.ts is nu letterlijk KNOWN_PERMISSIONS uit permissions.ts (geen cyclus: permissions.ts importeert alleen ./types en appLog). Nieuwe regressiecheck in check-extension-validation.ts haalt élke KNOWN_PERMISSIONS-waarde door de echte parseExtensionManifest(..., 'fresh')-route en eist dat hij overleeft, plus dat 'bogus' geweigerd blijft — zodat een volgende nieuwe permissie niet in hetzelfde gat kan vallen. Het misleidende "(SDK/validatie)"-label op de bestaande P1b-check in check-ext-contract.ts (die alleen de SDK-constante toetste) is verduidelijkt met een verwijzing naar de echte validatiedekking. Mutatiebewijs: EXTENSION_PERMISSIONS terugzetten naar de oude losse lijst (zonder 'importSource') laat de nieuwe check exact op 'importSource' rood lopen, exit 1. (cherry picked from commit 772469e096d8d19fd18c080b8e9a8003e3417446) --- src/extensions/validation.ts | 15 +++++++-------- tests/planning/check-ext-contract.ts | 7 ++++++- tests/planning/check-extension-validation.ts | 19 +++++++++++++++++++ 3 files changed, 32 insertions(+), 9 deletions(-) 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/tests/planning/check-ext-contract.ts b/tests/planning/check-ext-contract.ts index 37b9029ef..2f320a5d6 100644 --- a/tests/planning/check-ext-contract.ts +++ b/tests/planning/check-ext-contract.ts @@ -733,7 +733,12 @@ for (const [naam, ext, bron, sleutels] of [ apiNoPerm._cleanup(); apiOtherPerm._cleanup(); } - eq('P1b importSource staat in de door de app gekende permissies (SDK/validatie)', + // 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']); 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 }); } From bb06b3b162e66a2d9feef61eb4fd5809d28b97d3 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:14:07 +0200 Subject: [PATCH 126/171] docs(wiki): breng Extensions-Authoring.md in lijn met de importSource-permissie docs/wiki/*.md wordt letterlijk gepubliceerd door scripts/publish-wiki.mjs (geen generatie uit docs/extensions.md) en was door de vorige drie commits niet meegewerkt (her-review 2, P2): de zin "data.*, ... are core API: always available, no permission required" was na de importSource-fix onwaar, de permissietabel en de api.data-rij misten de drie getImportSource*-methoden, en er stond geen uitleg voor extensie-auteurs. Voegt de importSource-rij toe aan de permissietabel, corrigeert de kern-API-zin, vult de api.data-rij aan, en voegt een Engelstalige "Read-only XER source route"-paragraaf toe (zelfde inhoud als de nieuwe paragraaf in docs/extensions.md, beknopter naar de toon van deze wikipagina). npm run publish:wiki (dry-run) bouwt de pagina zonder fouten en bevat de nieuwe tekst. (cherry picked from commit ac2b76391f4f94ec770e137a38b92fc2823c6238) --- docs/wiki/Extensions-Authoring.md | 61 +++++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/docs/wiki/Extensions-Authoring.md b/docs/wiki/Extensions-Authoring.md index a09ab0935..826f3b533 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,50 @@ module.exports = { }; ``` +### Read-only XER source route (permission `importSource`) + +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. + +**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. + ### Data contract: the `Ext*` types Everything crossing the extension boundary via `api.data.*`, importer handlers and `sdk.factory.*` uses @@ -180,8 +226,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). From aab1c4e5302cc35889400efc53ebdd3618a95013 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:26:43 +0200 Subject: [PATCH 127/171] fix(extensies): fail-closed documentdrift bij XER-catalogus-paginering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Her-review 2 (P2) liet zien dat een pagineersessie stil records van het verkeerde project combineert: page 1 opvragen op document A, met switchDocument naar document B wisselen, en page 2 levert dan gewoon een (mogelijk lege) pagina van B terug zonder enige waarschuwing — een extensie die een lege pagina als "klaar" leest heeft in werkelijkheid twee projecten door elkaar gehaald. Er was geen driftanker zoals het bestaande expectedDocId in de MCP-laag. getImportSourceCatalogPage(collection, options) accepteert nu een optionele options.expectedSourceProjectId. Wijkt de bronselector van het ACTIEVE document af — ook als dat document inmiddels helemaal geen XER-bron meer heeft — dan gooit de aanroep een nieuwe ExtImportSourceDriftError in plaats van stil door te gaan. De check zit zowel in de pure getExtImportSourceCatalogPage (archief aanwezig, ander project) als in de extensionApi-wrapper (archief helemaal weg na een documentwissel), want de tweede route riep de eerste anders nooit aan. Meegenomen kleine P3-fixes uit dezelfde her-review: offset -0 canoniseert nu naar +0 (resolvePageOffset), en docs/extensions.md + docs/wiki/Extensions-Authoring.md noemen nu expliciet dat deze bronroute apiVersion >= 1.1 vergt. Mutatiebewijs: de assertNoImportSourceDrift-aanroep weghalen uit zowel getExtImportSourceCatalogPage als de extensionApi-wrapper laat de nieuwe D1/D2-checks in check-ext-contract.ts rood lopen (256 → 2 rood: "kreeg isDriftError:false" i.p.v. de verwachte fout), exit 1; D1a/D3 (het bestaande, ongewijzigde gedrag zonder de optie) blijven daarbij groen. (cherry picked from commit 0816d5764c6e63f531b96562cbb19f3147be996e) --- docs/extensions.md | 32 ++++++++++++++----- docs/wiki/Extensions-Authoring.md | 17 +++++++++- src/extensions/extImportSource.ts | 40 ++++++++++++++++++++++- src/extensions/extTypes.ts | 11 +++++++ src/extensions/extensionApi.ts | 24 ++++++++++---- tests/planning/check-ext-contract.ts | 47 ++++++++++++++++++++++++++++ 6 files changed, 155 insertions(+), 16 deletions(-) diff --git a/docs/extensions.md b/docs/extensions.md index 625b02757..7a32c16c8 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -263,13 +263,15 @@ module.exports = { }; ```` -### Read-only XER-bronroute (permissie `importSource`) +### 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. +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 @@ -310,12 +312,26 @@ if (info) { pagina (`items: []`) in plaats van een fout of een numeriek onveilige slice. Buiten een XER-document levert de methode `null`. -**Documentbinding en selector.** 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. +**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 diff --git a/docs/wiki/Extensions-Authoring.md b/docs/wiki/Extensions-Authoring.md index 826f3b533..e9c2832e3 100644 --- a/docs/wiki/Extensions-Authoring.md +++ b/docs/wiki/Extensions-Authoring.md @@ -112,12 +112,14 @@ module.exports = { }; ``` -### Read-only XER source route (permission `importSource`) +### 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 @@ -156,6 +158,19 @@ route (or its absence) of the newly active document automatically. Every call re 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 diff --git a/src/extensions/extImportSource.ts b/src/extensions/extImportSource.ts index 33f7f8397..a3971aea8 100644 --- a/src/extensions/extImportSource.ts +++ b/src/extensions/extImportSource.ts @@ -69,7 +69,44 @@ function validatePageOptions(options?: ExtImportSourcePageOptions): { offset: nu * want `total` is per definitie het aantal records dat al in het geheugen staat. */ function resolvePageOffset(offset: number, total: number): number { - return offset > total ? total : offset; + // `+ 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( @@ -298,6 +335,7 @@ export function getExtImportSourceCatalogPage( 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); diff --git a/src/extensions/extTypes.ts b/src/extensions/extTypes.ts index 58a1590d8..1ca8245c4 100644 --- a/src/extensions/extTypes.ts +++ b/src/extensions/extTypes.ts @@ -419,6 +419,17 @@ export interface ExtImportSourcePageOptions { 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 { diff --git a/src/extensions/extensionApi.ts b/src/extensions/extensionApi.ts index 398d0477d..364d5fa29 100644 --- a/src/extensions/extensionApi.ts +++ b/src/extensions/extensionApi.ts @@ -22,7 +22,12 @@ import { type ExtEventListener, } from '@/services/extensionEvents'; import { applyPermissionGuards } from './permissions'; -import { getExtImportSourceCatalogPage, getExtImportSourceChunk, toExtImportSourceInfo } from './extImportSource'; +import { + assertNoImportSourceDrift, + getExtImportSourceCatalogPage, + getExtImportSourceChunk, + toExtImportSourceInfo, +} from './extImportSource'; import { toExtProject, toExtCalendar, @@ -132,11 +137,18 @@ export function createExtensionApi( }, getImportSourceCatalogPage: (collection, options) => { const state = document.store.getState(); - return state.xerSourceArchive - ? getExtImportSourceCatalogPage( - state.xerSourceArchive, state.xerImportMetadata, state.xerSourceProjectId, collection, options, - ) - : null; + // `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); diff --git a/tests/planning/check-ext-contract.ts b/tests/planning/check-ext-contract.ts index 2f320a5d6..5377bc106 100644 --- a/tests/planning/check-ext-contract.ts +++ b/tests/planning/check-ext-contract.ts @@ -61,6 +61,7 @@ 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; @@ -935,6 +936,27 @@ for (const [naam, ext, bron, sleutels] of [ 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. @@ -944,6 +966,31 @@ for (const [naam, ext, bron, sleutels] of [ 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(); } From e7824f6180022e9989abb87b2101ef3ead995292 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:09:55 +0200 Subject: [PATCH 128/171] test(renderer): mijlpaal-met-duur-fixture dag-onafhankelijk (weekend-flake) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-milestone-duration-render.ts liet project.startDate en view.viewStartDate allebei op de "vandaag"-default staan. Op zaterdag/zondag schoof de solver de basistaak naar de eerstvolgende werkdag terwijl het view-origin op het weekend bleef staan — de balk verschoof pixels naar rechts, terwijl de hittest een vaste x = TTW + 20 gebruikt. Anker nu beide op dezelfde vaste, altijd-werkende maandag (2026-01-05, in de stijl van check-bar-colors.ts), zodat de fixture niet meer van de kalenderdag van de testrun afhangt. Mutatiebewijs: met een gefakete Date ("vandaag" = za 2026-01-10) faalt de ONGEWIJZIGDE check exact zoals gerapporteerd (getTaskBarBounds → null); met de fix erin blijft hij groen onder diezelfde gefakete zaterdag én onder de echte datum van vandaag. (cherry picked from commit a133ae6eace45e731153f260d3409513e3fd8d0b) --- tests/planning/check-milestone-duration-render.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/planning/check-milestone-duration-render.ts b/tests/planning/check-milestone-duration-render.ts index ac95aafb9..133764dd3 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, { From 34031a418ab2f53a96e88f33aa3c200d9b81ef3a Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:37:29 +0200 Subject: [PATCH 129/171] test(xer): herpin X12-v2-productbaseline op p6CompletedLateFromRemainingWindow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De v2-productbaseline pint de volledige corpusmeting (34 bestanden); de late-zijde-fix uit de vorige commit verandert die meting bewust, dus geen stille herpin maar met het verschil erbij. Alleen crawl-xer-extra/jailaff-xer-splitter/rehab-2.xer (sha256 2c1dce17…, proj_id 761, 6.977 taken) verschuift, op precies de drie assen die de late-restwerkregel raakt: ls: 4.358 → 3.468 afwijkingen (−890) lf: 4.350 → 3.459 afwijkingen (−891) tf: 4.200 → 3.842 afwijkingen (−358) es/ef/ff op rehab-2 en alle zes assen op de overige 33 bestanden: ongewijzigd (delta 0). Corpustotaal zesassige afwijkingen: 18.398 → 16.259 (−2.139). Geregenereerd met `OPS_XER_FIDELITY_REPORT=baseline` (de check-eigen canonicaliseerroute, `canonicalProductEnvelope`) tegen de volledige OPS_XER_CORPUS-set, ná rebase op 92e98b8e (X12-v2-meetlat van baan 3a). De drie "nuldoel is baseline-onafhankelijk"-asserties in check-xer-product-fidelity-x12.ts blijven bewust rood (characterization.finalZeroGate: 'red', accepted: false) — dat is het reeds bestaande, corpusbrede einddoel dat pas na klasse (ii) haalbaar wordt, ongewijzigd door deze commit. Co-Authored-By: Claude Fable 5.1 --- tests/planning/xer-product-fidelity-baseline.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/planning/xer-product-fidelity-baseline.json b/tests/planning/xer-product-fidelity-baseline.json index 37cec066a..1be049e8b 100644 --- a/tests/planning/xer-product-fidelity-baseline.json +++ b/tests/planning/xer-product-fidelity-baseline.json @@ -20,8 +20,8 @@ "json": "JSON.stringify(value, null, 2) + LF", "gzip": "gzip level 9, deterministic header" }, - "payloadSha256": "a97aa10c39687e33cabf23ff7a4f832556401192f4620a6cf643d97e49160783", - "payloadGzipSha256": "99476b658c45a4e5fcaf42e1b94ffc827437d26a98dba2db51c6a0f9400c82bb", - "projectProjectionSha256": "ab8fef5851dd85d941e7f7f862c4ee62440e3bc2dd8f5299250053b81cfd5c7f", - "payloadGzipBase64": "H4sIAAAAAAACA+2dS48kyZHf7/wUjT4vAXu5PXhdrAQJEERIOknYgz/JloozRHeR2JXA7y5Ek5zpys6MrHjlVBbzMIfprPI0i4gK85+b2d/+368+fPj45/75y6cff/j4mw/0T9P//yH/8Gn0L8///feZkn78zYeP2vooVYpkwwSQNPXkYpZAlUChRck1h3MkgvDWsDBLro4tJXP9+HXd+vv8Odfn/vnT/83Pf/3C//erDx8+fPg4Pv2Qn/5n//zjf8zPffrCz719/Z0PHz7mWvsfn3v7+JsPIz996X/75x//2H/45/zcf/fj50/9y8fffPhfX//9w4ePX54/f6rPv/7y6d9+nf/t05dft/7nT1+/78vflvzmZ/Ifesv/fvZH2udPf/70w+9+/cf8/Ptff+5//PHz869//OHp3z9+/YF//dWHD3/56tX49PT1+//mCijiCEiSpSRVFmgtMTqW0KYaYGO6MCNn6AM5Uq/MXnoM5+lHflppsvKnG7B51Z89r7/vf8j/4dMPv+uf//j50w/P0/I5tWEdIQ3nlkF//vE/fv7xf/f6PDmIP/3jc/7yf6Z/Ufjpnz61/sPzp+d//+cf/9w/59/1b9yYvvTHpz/39j+++7W/rfXPP7b+28/9S/9qzdmP/+Xfcv3rh3/77C+nJv6Xnr/86XP/Q//h+dvH4cM3dvz8w/9pus4f0QE//tO3Hz9//tPz78/ZOevEVTfOOvLi4/rjn3547p+/vLhuXz/p3//b9K9/W0ZeLPNXO//6UH/8zYfvP2ufxpj+zr//5A+fvnz59MPvzv7WH75e2lye+neG/3XVn/98prVffPyXfzpxZ8y5wz7jDl50Bw90h2bdeZq9O0nX3B05zhuZd2a8I2ee1z9ol/9u6Bd70Ma8O7HKnSP/bvClO7+64Nrf4+xv8/Pvz7z8/v7s8cnluOjd332zk3++6Ni8Wy+dsote/D3+/cvnzz9+fYv/r3/9pzNx59OPP/y8q0qWMxq0PlxqUkcfqkKZOgcQjlQd1RkjIjfCGMmj9CBkwI4BH391enX/9TQo/vbM13LKWVTZuaZOCUwbQRkF1aJJER8MIElHw6FYoJmgt06pAmUP/nlzcDZofR+wLgSrS/fwfJC6dAPnbt/FwPTNvfs+IF14R1wKROf/mHYwl86a+3Tx6r58P1+7urKvtXLe2HFHxj4vfxDOP7d0kwdhXDY3Fpm793OL372W/nIKVd+97M++6M9bfuYFf97oyyaff6n/5TuYOfMy//il5h9+6J/PffS7/Nx/m798+ZlVf/XNuh9h5IRGqDRKU4803EGoAZlln/haa1SnklupJXdmbizErRF4db1AhltXnSdDLMhqWtRbzoj9lWSIsooMUWbJ8OzHfwcqlD3JcAYLUWaw8OTDeR/OerELFq6iwg1QeGUzuAUJ78qVp/dzV57ez12ZR0GUG/ty5gsXODPu2pltGHj6ZdcxENZh4HdfdNGn7RhotbnLYOxNSo5aPZGzk4yhmkctXkzMKrcBwmUAJGkDuZaIOnJfiYGFudQBOVXIFtydtVdCJDNvwyzXplqDMfroCgNdc0s6esM26rdnxEsxcBEFroDAmc3pUgR8c6Y+3c9Vfbqfq3oZ/U43e7vbevIFrzB2vHljl2Pfi539LPbBMux7sfBZe/fFvufPf3pJfSjapbqmKLmBaqHwcFfk1iua1648OFPlJJiCrXAzyD68D8fk56lv86rz1GceLaVGpJATFH8l9fG6fCDP5wN5Lh/Ie+YDf/vf/ut//jXM5gR5LifI13KCPJ8T5H1ygnTrRA1vStTM4x/xGmfsOGdsAwCu252jHucN6gYIxFX3Bg+8OWhbQHBVThDpQHe25ATPJCtf4Y4f543vioK0FAVPb+9rWZBnU4Lou8LgGBm8NG6sObNwSz44kWISpcIloiNTySURgSK5FKPQ4CCCknltThCFeu6j59LLyEU0sqOOgNbNIiVR7p6omI3hWTVjLcBQMvIwsfUwSEcngnhxIugyDr4MRteMtX2NtYVAuIwGXgaZ7dZ+E1heB4W46NrizhcXbSkYLsoJIu1s7tKc4ElS8oq5vq+1vgkN6bVo+O0teQ0b8sWU4E8v9aNzgliyRqOw3iOnqqmqSDc2blVQcmaWGqHFxAGqqHvu2TqrY+9lXKDDravO02EBU3UtI2FJ3cZZOqTv6NBXwaHPsqHPoKHvmQ+cp8K5jOC1hOB8PvCRDnykAx/pwJumA9e4wutduZI/4y0EuKoo9DBXaE/+W4x/tI7+ZhOBtCv7xXDQgo2stp7RUhtqntwgQU6p5pxqtCrdqeaMgVZFNRewoB4kH8/tCS/HtIjwR0x7xLRHTHvEtPuJabeub7llecs7jGnea4sULqO6NyG3VjWHSh7ancRHTzQQ+xBBzIUGdgOIUdw7M64tbmml8oBgaNbBx0ithgIQ1V4dEigYJx0cUoahNhfn4hytBWvUR3HLo7jl/Re3LDoTXHE+7JdN1YXnl4uyBLSrqbTl9PLVdS2y7OzSLx1dyu1OLr0OdE8S2ApXjF7GqJ5GaczBiSX1EX2Mwq7ZW2Ym0q6aRuRy8eRy26rzJ5cqjJOeADFnV3htNwOtq2uh+boWmqtrIbhRNwPNFbTQtYIWmi9ooZ0KWm69FacDYe9M+cNrWtyPc4a3FLSs6jvG45zBLeUsac2dkeOcSVuQ79ZFYLSpCGys/5s5AmAX/s1swz64UVMD3K6noVnpVqlQaUKlBbfUihmXWjWN3nuz6Jq5iagNk1GTGSm5WpGSfSX26WgCUTCrY7hba72OqXHeSituEdK5FBysCJRggNUc3AoyedK0pYzl6G0/7Qh+J8UT1xrb9zWWl5axLOpmxn2NxaVFLGnJlZV9jU1LS1iOLr2ixaVXY/kze9ZYPuyZXY6AcFBrA9y2s+EMAjYLwKZeq4FySdFqb7lW7j13b1Q90DyhCmGFyJ2nkzhioeicLxWvbF11HgFdmjYvzcTdsJfXNrTjuoZ2nG9ox7mGdrxVQzvONbTjtYZ2nG9ox30QcM0GMDa0teL8BjBune5DPM4ZxFtn/H5Jb/a/N1vgfFHJxDIChBuz+dUbc+u0nx3niz0AcBYA8xjNavcKuVbMMIhrLYWgCdceiFK8iPEYQJQScYJIiZMrJeK2FgBFUiKNYqqVOkpFBvFuWEKq90nwpo8MQa11YYiGWIhyUW4SlH0DAC7ZnsaK1lu8vD2NIxN/iPsai3hk7u9W1u5zbdeg9asSVa/jPziYrGcv7EL+k4M7b+ZstQf+ncE/KhWJBCsP7z1NIiXUGnSzQbWMkUcdXJKwFzQgGi1akSFVhZoUOI9/m1edx79eVb0ilswtBeF5/Pu+eQE11gGgxjwBnv38J3jS2LeJIe5d2AxvXdOMsmWHfiUVeF/OPL2nO/P0nu7M8wHObNEEowMFzt64Mw+Bs++hzFoig0g9Kigxd8yj9j6a9zAY3awmyY69DTHOmJp0hYiSobeCC/sapjDH9x7m8NZPOR5Y8XJfzlwJc/d1qvb0no4I/4HOO9+6MxvD3OJxDnhEmMN9jzzBALJ3hQwVhIWw9VJ8EuvMYEKj1mEN3TV3l6JOolLGmFgUzFeEOb3zMEe3Fly6ts3GLTh3b948HbAF4eO84S2B7r6ceR7vJxc+H+fi1pyNN+xRx8WJPTkizMmuYY4HoqYsmIWtJMhttKksR61Jax6RKxYlkkptOLAqaqAyNAWpbEu71IFiLsqluSiXrkW5NB/l0j4wl27NP+lAmLsvZ57e0515ek935vk9OTPu2pmNUS7d6swy3e7McvTCZfBQddTWxVpPUqknmAS1yaQ1syGJGrLXQSnZwJIhF0SHDoujHMO9H1ne1xiV/p5mwjy9pzvz9J7uzGP00GP00NvNzLXGlRWKlqF5RIdIo7LU3iRTUW3dp3ycCfno8FWfBUJL7j2H+lgR5fAR5R5R7hHlHlHuEeXeYpTzpUFOjwhyuq+sJopVYiAUFnPrkjW41N6Bu3saAJ0qcGs2eqGAqfLUB1g3RWRfHuTokZZ7pOUeablHWu6Rlnuk5W6UlhvcgWtOo1RiTzj6UIqaSwkaBh0tW83etIw2haiaSDJnrk2L94rLo9zd11jCjRWSrr18tozJuzNn5mOc3nhw2TVffEOIuy9fnt/VTmrcdY/HI8h9X3sSSdWda5AXKd0xkjbIMBIiSmMckJp50y6RidgwDTTyUb1y1sVBTh5Zucd55eO88nFe+TivfJxX3ui8UprANDYhcaVUUjUXJ1IdFjqiS3GyaF3CmxS16tAqhjCOiGkq/PIglx5N4R8eTeHrnHk0hT+awh9N4Y/Sk6VRznoIhFTp4DLAUBDamLRcijkGd+hFTQuWltnKkN6HC1gvmkIoL49y9ohyjyj3iHKPKPeIco8od6Mox4Nw5Jyh99xGcdNhjcy1Yq4ZEGDSyQyskqUTdp16xKnjsBjCurzAUuIR5R5R7hHlHlHuEeXeZJR7j2k5ctYQyKMoZh2hKfOomEgTYHUJyTmadVZLnFnMupFB7Zy7px4rxZ7BCJ2oJJRcBtcGgN6ABo7G2MOo9A7QUs511CmkpiIYBXpGEtkw7Qd5kXQur5idiaoz80h06cSft2nwzNQf4UVCyryzwcRLJ/+8TYNn1J8JFz0Sa/Sf04xaNS9VgEZJR2tAz9m7SQQa5dUy0BTLdKBfKgi/fCbiVlLQFVvtaKnEAHNEzOKtIo+GZjam6a1Sm+WiStxVrJuwMUNVKHZhEtDmVeeloEmCWaI1lGGj8CsnAWmYrZKC/vYXzwLWhR/4O2BNH++oBm0614v30pYrvrzCmwv+7IKMysnX7BkVN9TZaZjO7xsVfQM6KiqvKh1EPtIpxy3jYuncJX9Nneq5O7yjV9P6G2CSlHSlW3CwW1vqYMhsnVsEx7pFsAUwNcGqvyyxQ/+yxHYdKqsei4tjLNax5umb/LutU+yLm1STq2FSdAxR0RQ0yCOP1MCTa2ONJK3jYIJSjApP3XwNek9c1w6XTTJyDc9tBGdxbcOiE/QiuUXknB2Rhpcmo1cfCWDanDhipIQc63HzNMBd2/qevGVft/k9fUIvB7PXIedpALtm9Eng2sPob4PV67DzNEBdHYp6emd2sPpFMHodfJ4GoFeYDQeYDQsR9DTAXDX7JLDsYva3weR1IHoaQK6abbs/2d8GixU4+jI4zPOoLeTRE4o5HwiOBlJRywV6AfdUJZsPQ8BcWkIqgyXr6No9mUGRaFh695SUOUuTbHweSDevOg+kmYxaTQFaBrf06tG0sG4yEcwPJoK5uUSw71ii2S4LmEtawrWkJcwnLWEXAl0n1r4lZwFXGqO2tFncmzcHzKfd4Awc12VxV648v6tnbNzxjXkMpz3tsCBMrefOOUeGiuREA0Z2KaWLlpaqSKNgrxVNisqI4l8Hx1bTujZfSRULdy5u4Ek7juY5D++5JY4R1iLnEpbUck+tsztyVSodXFORuh4glw36XLHHPvmdS39ZR4ynvY21O42nXWHsjK1w5GzaW5j6fFfPwHjjF/Yxm/Zn/ovcsyavoZwBkvQwQ81ZwUdhtPCBiloVOCpmtugKU7+4dVXoF/hv66rz/CeggjEowjBP55Sv5D9aCYB0hQBpFgFpZwacVQalWQikqxRIVzCQ9uFAWdVseyYzsKB47cpe0HQDCfIqfS6PA/3x2MCCsirxc5rc2Pf+2AYgvL/7c4TAzJY0/lV/8EoW/wGG7wgMqRPhVBM0VCv7sGziwdRCahtd3Ju3kjSUqbfKUTm5l6SpGLScV4Jhdgf1lj1ZeLLiU5s/VZVswyVNLSEWYsOxSc2UsxhElZEzghZP68FQZFFJ3ZqyUJrZvNrSOlZeVLP48kW3g73fvNxeB4fLikLN9r6+tpAQ3+b1nckgLsLENWnxWXvxQlb8QYr3Q4rNS+0WkINLlGpiufSaWoQLdO7QsPaUiDvkkTuX7I6mVEenipcyhVtXnSdFh9GSFMJBeVTTV5IipVWgSGmWE89+/HeyorQjJbL6DCTS3ChAujYKkOZHAdI+owDp1pPA6MBRgHfmzNN7ujNP7+nOPL8nZ8ZdO7MNCelWowDphqMACytTS4UTECVo3X2kPA2KGKkPa+AtombuwYbDB09iNQXChBqGyEooREm9j+6UJ5GcMbVQUik5j0w2OqZauARWZdEGg1B8cEaF4SIY0NZDIaWDN6knX/CKRNFlInyDxj7d05V9uqcr+3xPxo43b+xyDHyx19+TA18sfAMQfP78p5ccmHik2kovPEqLqrVQoixeKbcg0DGxVpGpdISNhjNGtME5wIHHaOc5cPOq8xzYU+/qUymJJW06XsmBP5/yLOLAbw+HzhDU2Y//5aeDpR058Lf/7b/+51/PZwxPD7IuOnLVlbPO/IIwuCFZyFemrGxJFdKtx9/wpvE3V2CQbzy14Zozm7oT19Va4nHeIG7BQb/xzKhr3vAGHOR19+bAW7Or2A0uxUFayYOnbp3oIOw8NbdrlkiK3EsVFhk0nd+q55EUPLOM1EaQZcZaANWHRySpI7mSp5VAaJkkawWd0n5VPWlWAtQ2CrOSIEGGnNwaAnbykULGmEqSRq89262AcEWS8CSgXghKrwTCRTmh2NfYpflBWtYiua+xi7sMlxVl4r7WIi5FwkWdnLyvtbwQCZfpM+19aTdJ2+CrlW0WMuELoDj/Qj86O5hSsS6i3GpjLRmo5w5GIqn7qFSrumR07wJ1KgWBxIRTeSgr1XShjnTzqvNUGJqJG5COqtY6vZIK0zoqTPNUmOaoMO1JhWZzQJjmgDBdA8I0D4RpHyCUubGbdEByIOFx2qezzuABW9ttzswDYbr1Pj0t26cvAsI7uzPzOCi3xsF0IA6+dWe24WDCG2UHE96wZLSKg6aAhqMIMoVZ0zwouKlqQKroMfpordTO4RWgBo2v4d7V18Jg9G6tTGJ4DM6QRw0RCB3cipAUQfFMaBI4YLRCXbBE9CxJC2+AwZdvj0sBakPyIs3sVGkhDF40FnfaVi839jIMpqMZIL2OAV4Fg2/wyl5GQTkaBdOOKPhWjF2OggkPyg4m/KXLRJN6xWBLRTo0NamWuuaegbiGm9F0CtfVQy2l4apdVDAnxTAGuACCW1edB0EL8VwAMGdqOV6rcOqrONBnMdBnKND3bCScQUCfIUC/AoA+y3++C/75jUvD/LjS0LtyZR797Mbk58eB3125Mo99euPxHb6FYMcd/63cuEfQ1xGfzwGf7wp8WaUQsOeqXQupUSsE2gRGCsy9FkzANuXmRpcYWb1br70IaO8JVwJfiRiR0yQjJzx6n2pRLedGrWn2AgDWorQYTLk1h5RSC5UU3mmIbhCP8YOL1Xy/YtA3Z+pl2LODWc/3Q703Z+pl0FskJ0q7mkoLMe9tPKsHdgL6MsbzS4jnt0K8ZthzlW5QoEViTS0pW4Nu4SbqOoyZGvavfX1RyZGEFNo0rc8uIN7WVecRr/TWS26SJ0VsLvDQjLk3zZgU62ZXHKh5obgl77eKMrZo61/XWNkys2LlbBE68v5smXwoq9DJ5Mj7IxtIkNdVt9qhmjH20IzZhwffvmaMT1otQkPJW442smpKk/o3OXRvApnDKBeCqtEaaYsBjkNr8eqrxURhZKJKZVhRDi+SCxbqQIHccPRoqtZpOOVQaNlKxzFyAHiv2jdMo0ixbBbFzpobiktzgIv4ZY1W/7wGy9IZFAtnfdDe15eWZgIXQdfLQLLH9ZWFjMjLqlhtd80Ye2jGbMoG/uKaMeq1pwGlUEksLZWMQZ1zcxIESs0kd0k9hCAGojE2ocY00IdTOU+Km1e9UhVqI0fPOBJzzfpaUvx5JOayqlCarwqluapQ2pESJcGcaMzJxM/LTlx146wj+xDirSdiJzouM3hnzjy9pzvzdEDxoRznzBYqvLM7M+7amW1MKLQUCnFtXSjNYSHuy4WZ0jR1kBJ6sRISCNENo4tXGTWq5ZwyYk5OKRdhL0ApsbqVRLFWS3Rq/ugdVHI2njTn8nRC3HskKl7o63xE9NEaTqLl02RECWwTSvYU7n0DF9LB+9S56dxLM4Vv0Nine7qyTzuVL8q+xi5lwTd4ZcebN3Y5CX77ip9HQVxaGUqXYBBvRYPmTGkUFUmNaOq6jjKmsv9J2rNzRylGfXrj1xAHB+i5SBlUICN8fWufocHNq87TYJKelHPOXIGQ+msVRNfRIM3TIM3RING+OcO5WYM0R4N0jQZpngZpHxqc2wsiHaBNQlc2g7QFB3GVN3GgN/iYNfjOZg2uGpJB+Is9Y4/04DtKD3IHd+mWm5B4Nwobyo4RBWI09mnOHyRXUMDsuYhXSIgUgIKlrMTAplEw9ygwSkRw52kCccKKQyKwt8ypDYbhXHHaPGC0xDU3lvBWN5SLXtqkIu0kaUIzu1RayoG4yNrY2Vp8zBp8A7MGF828ILzJM/DIBt5NNtACSsBo2dnK9AIlTV4GVUOqQytHLppbbY7U1XobwbVlrNYLK40L/Ld11SutgYm6FgxOMoLj1RMkeB3/8Tz/8Rz/8Q35j+f4j6/xH8/zH+/Cf6vq3WiLgjwfyH908705P/jvwX+veMbowGeMHvz3j8J/WRtxoGXqxaoN9kySkmjXyDXKmGo4GwlEraVprZO6Z9dwzrUgwVr+g6nrsHdjK9iyaJReyFptSklldLQoCLUlaMMiCuU0bECZKpDwmzlLi/lvUXkdrRG35x35jw7f+/OD/94V/9HOzwA9+O/e+W/oKMKl8HSwplNxpkrXVlNjb0xRlEXUStVsBbSWwc0zJ6wO5pf4b+Oq8/w3WrLEFWrRKobptdWg64pB52tB50pBbyQNMzc68NrkwPnBgemXg74Nmz4+rgB01f41bahlm3clPXDvgXvbyli3VLE+WO8dsZ4StdGYU8VaIcdokmC4ljQyVARs3EYzspGRlabREKZ9ABXoImIrWW+gYCj5gNJGahw4sjTFAdl4akvsacTIgFhBAHJPqY7mCYNGbmI3Yr0Ve1Her+Bz0bY5raidu2xqelDevVGe7PoAyAPx7hvxHLUDGvuQUHOjcKidR2/w9fjOK8Qwx7BirWPpPaeawzuE5J4vjIHYvOo84qFVj9bMKpoVzq9EPF03BkLnx0Do3BgI3XMMBDrQDOnp3BwIvTYHQufnQOg+cyAUb7zvUzyO9+7Mmaf3dGee3tOdeX5Pzoy7dmYb+emt5kDoDedABEsWSkmBW1GyjAiZx4AydFBiSFF76UJkapKEcJC6pZoYVa2uZL80TCN0wssWMAkC9OhZ1Yh6hmBIOaPq9DXQBCJSiZG0cytVpW5o91M8eJequB/9vUFjn+7pyj7d05V9vidjx5s3djkH6lFzIBR/4SnxkQKq1Falj6yRibGo5pogFLGhDUaFlGIaz5NL60AZw0bqXrDrBRDcvOo8CKraNPWniqYMGq+dEh/raj1jvtYz5mo9g2+U74u5Qs+4VugZ84WesU+hp9uNx8MHHzcePm6dwQw+jgLXzbo3PM4b2zIWgmTVgxYHPmmxgQTvKpX5j5v/O31+XjEcXtZh4OnTdlKAJfv2+2mzlrU6T3OaBAYokxu2PKzXSI7SitSMtRVhwq4VrVN2H7mbr5UDjZGx5sLqmXVSHE3Wao3WRk/IMhWB1i6NUinFlHphpGYdRRpbxrJhPIQdPBw+eL/h8HF0xjJ4Pw5cNsnecF9rbemYiJd/nlcfhNj5SYiFLPjmUpfvLx/47T2+MhpeloHgC4o4/zo/OicYapEi2ggEL40LY7eUpvpLTMINA1OZ1JdRJGUeWKBEjTypM7uOSyi4ddUrZZ/Y0xALNelcu5xFQfoOBYVYV8Hgt794FqUu/MC//CQcxLojEpJqmhsZEXMTI+LawIiYnxcRu1DhXe1u+/vZqD8qQe+vEvRBgu+zEhRjHQh+93svBeNiVxAswTWSlJSidNQWkYFbpqIxSkYepIJK4i0wzKQ4hJDU1pWFUv94bpM4G9p0JrQJnQjZn8bpk4/PReoLP/JzrD75gbUh7nShGzzuZ79yt0AnhOnG80qvO0Qbwp1gzDmERzTuv8KjTWOR0Ndp0ZEc6RPrhvgnhHrjkb/XPdqifcZhqwIhiR364IntKoiNp1Ls10OirgyJ3zs3cxK/PShCyQomjNZGSt55AJNZ7jVnICBTb39tZIRcucrUPA/VqghhJF47PBdBoURAGSat8BCQwIQtxXQ4y0NN6shtuGfJ4d6oZG5UW5JU29gwLOn7uLv76c13X7GpUuY0Vh0wRHXeYFp4Snoaiy7FoS1N/FcsXjwx6STWXNWdI9nbZtaFx6WnseSAOcDzFi/VSTuNFVcfZLHdH4xv4sIa1ewXceDKsN2F03ZPzt1eDty91cTdMEjTDALIBkOJI2rQsOEyRpFMUYK15BGpTlMNmKNzrZKJaDB6vXCEunXV+SPUVpOSoeAYHC3jK6tpfNX5qc8envrMyanfqJLGZ6jSryClz/KkHy6XfUQRjR9XQ7OqKNwOc8U2AOR9ubL/yZwf5opv4MW7+luZJ0W7Mff6IujdxoeL2ydsHRv6HBjavkN0Ezcd1adOCfPBUKgUVe8IgaEYKpgIMxaSDlJTCKbhzYabDlmrks1RopTUuPfeouRcwVuTSL1liAGWo1kaVkHzKImrJPMuEaNjEc37q2TvVTTj+9XMLCpDt11NtYUs+PZM3aemw3c11Rei35t7Vi9Dnx2MqP4qPl2Oeq9umLBlmOeXGM9uhHgZmYZPotQFDBrFxF7uiaq2ms2ol5LEKuTWAytaDZ2yX2x9mpJwQRxt86rziJdxGo4HUlru7cVMvIc49iuceIhjP8SxHzUyd1Ij8xDHfgimPcSxH+LYD3Hsh2zaQxz7oZy2N/9RH1Y4VYDmdXjJloihGU4T6kKRYdhQj+nQzTVLHWn4qI1FGC8pp21edZ7/qisUpsBWcvT+2hQf4sqW+W9/8XxrAc62zU8f78mBLHOlpC+NueLMK9y54NAuPBixjqFgA0SdGn8uEOOWVvpYVwMHfKxPvIENI80lbJJfzNiYH+rUlfLLeUqcdyouw5X9kj49X9FxiDUPX2xxCVjnXQrbgIwIsOrUiPlIl5h3zRh+91QcRo9nvupAgHTHZFmzdm9f6zg7Z4+EGg6QudfUsHXsQ2oky6WmgmDaS+t1MK3NG0KpxNVqrRVLH6mPgiO7dunT/FwOzT1xSr1ZC4SCENo1Nw/o0dMGgDwJaNcRcgWVfRd9LwavVzbdx7JCvJd/j/vYzAth8uTdfTEYXQhEuxh9oUbzMlZeNjrO05rdyubnGUWGWPJwxBqTT962F4PH6xjzNGBcM5l5b5O/CQ5rco0vYWJP3DxZ+hcizsGFRKOTDozwlKsDmmsN6G2U0JStVAzJ2pMZSM4uqabhijguEefGVeeJM0gzl6iSVbj2V2t1r+vK1/mefJ3ryNc9+/GZcK62VHVOq1uvaXXrvFa37qPVrbeW6tUDtbrvy5mn93Rnnt7TnXl+T86Mu3ZmGzny4mJTTivFunUOGznty42YK3XNqIbc6zRhkcEUEoK3nmF0kh7CVFrjGkMIy+CScu9civBKbqwChEC1+qQLgKBJcnST1IEaSK5esjeDXLPmgogY1PIkHM4yxNoGtW49WkxYd1TrfnvGPt3TlX26pyv7fE/Gjjdv7HIe5FcXn377an+VXPfFFkNOt9HrzjYqq/VcJk6TrpoiO4GWyKjUBjI5SVWGrmhpmpTgWXlAEgQpF2Bw66rzMOhVtLLkSmq55vyYzXsRAt/CbF66casRb9HXOKDglA8bncqPatN3Vm3KN5ZBSls0kB6lpu+o1DQkeJp53w1YUopevGYNab1CT9RG7ZBy4hFEwF44QwEvKTWuoGOt8gxnz4JeASk105SSe5c8VCXCI1mZWlVa1+KVqlQqJUoSUNbsuZUbdRjirrN58cg6U951NCs/ikzfQJEpH6w4lJbKDT0qTO+nwrRKiaZmyFy1T0X8SAEJBhMxd7MaTakP0Gp1avFuZWDL0LAL50uIt3XVKyIy4r12q9k0CQ55bYchreswpPkOQ5rrMKQbdhjSXIchXeswpPkOQzqc9i6LRG6oUjzTa7Vfh+E6ycs40JtHh+Gjw3BrF+u2Z+yBfe8I+7iDu3TLTUi8G4WNSQIgokCMxt7ZHZIrKGD2XMQrJJxiPQqWtQWiUxsK5h4FRokI7twk2zTEY0hM2cbMqQ2G4VzRATpGS1xzm8pHW637Y995YckVRYsncXRbh+EyGczY2dpHh+H9dRjiTZ6BB//dD/+1oNoTlRypkCWOLBVKhirSKWuxlHqliqkyqAKEhlQTBhdVlwv8t3XVef4rkwJ1Bu6alHLh1/Lfz7rBywDwW73hc+h09vOfyMlxXwacy/bRqTTyZUeuu3LemX040PHGO8Fz37hb5efdufP0vu7O0/u6O8/vy51x5+5sw8Lvvu0wMPz+mw5Ew6pa63DLPMkE2NQdiNYdLEZPpXobCJggmo9mglSq1ARA1UiS1tUZwfCmnHuuY8JSFMvFwhNTZ9cYrtGtTkN6c0dUSSXXRK15KcGJdAMaOh68eT39hk1VoG/S3Kf7urpP93V1n+/L3HEH5i4nxZcwsCcrvlz5BrT4XT1osT60Tc17o7eWMXvjbMaBKSi5Na6ZwR2Qi4c24NGQohA3Z+cLsLh51XlY7GNw5k4lpTIK2ith8efxJ4tY8dupKWf46uzHf4crgxvVhJ6OdrnowVUfznpx+KReO2JXaFdyBXbUsN636M3Tu7o3T+/q3szTod06xWYHzu19687cOGFoK8HQZlOGti8YjsaZc2dqUBNZj5ILG5c2kmsJLoWQW24+eh5exhiFprPfxh36aGtVSSE5avNMU2noQJ/0UIV7ELbSWmrcqKm7AE6lq6hBhJwbJLLqUDeA4aWchu21WbWZrIYt5cK3aO3TXV3bp7u6tpeh0I5Oxhnsh4RvxdgDU4e2kAftYvLQbpU9LJHEegmsDHmgJQjmrCLTuV+hLJkqttKn5F8n0wySCyaonhOg2gUg3LrqlfkUjqO30STnbNz8tfqkvC57iDyfPTz/+U9anrxn9lAEfE6blOeyh6efXnHlvDP76JKumgfPG2atIeOmSeNXNEnXdQ7Gkf7EFj3S+/Nn/v6sEinhdKQ/aQMjrsu4MW0RVr3Sd8u0SYR0XRcxHugQ7ju08OUM6wPHFn735B06uDBQiWJqDlFKZmjReECYh+hQ5W6ZEYVcFJokFSqjR2MN6s11bX2pOWCqgQrFJU3lo4qt5lqjjixaFGJogtxIsVgGBBjaEhsEprahvjQWTYfnFePgTmPsa+aWz4iPLmsujL3tjaXCo2/T3svXd5HuycuIsoe9aSEzLkvMMa1RSJ1pjWVarDa6rJEXdzYYt801/Patv+tkw5fQ8YvMNqxuVKMbiIU1pyBIg2sS125Vaku5V6NA4Un8pUyDKqBX7+4qmS+Mr9+86jw7gqk6DY/Q2ofpY3z9RVx8jK/fdXz9XbnyDzO+/q5cmYdCWeOKHOaKbBEVvbFOji/SydlGgos5UA4YXy+7UmCXbI3a8FR6pVAeHFqgCiNqcaxNsEv3jlGMcpPBPBCaUDFmXltK6nVIIu+NUEeRbuZWg4vQQEilIVVprUeUWquMCqzZtHqKEaIW/xDj69+cqe9ifP2bM/Uy8ckSU2VXU2WpiOjBOjj+Kh2c5Zj3asiTncbXy40Qr/VcqimL9dwHJiMbwhBp6hXHEslaS7kAJWxV0tTInaqjZe/AUC6Mr9+86jziJR2tkFUAzOFGr20ulHW9hTLfWihznYVyQ3EZmesrlGtthTLfVSj70B7fWEuU5EAx0fty5uk93Zmn93RnrjQTyq2b7+TAVkK4McNec0Z2bSSUW/URyg0VZsKKd4NefJTSuZYwiDJSHiBTSAeQ1HNjaz1NUp+lGASRi3fMee0oiZS7q0PrYA2mbUJIbeAJ0zBF4hF5qhZtRsIVR/VJ6DR6sRRT6dAWYVE+WFn0JJhukxZ9e8Y+3dOVfbqnKzvTPihHt+PJjs2DcDCxzhkrm1oH5ajOQfmlZWZaH+JRKDtwHjGCp0HzUhy6jxaFWnUUboN8qrr42u/XAps0rS1XvkSCG1edJ8GR/6pa0zq2lL6de/+YJPEGJ0ngjU/9eYs6ft9fJjEdJvSfHqqiD1XRTaCUjkv2PSRF70pStGdI0cOTSO1CycACCudhQ7VMYx4KdK5RYgrTY9Seuopzx4qOq3VjsvqomYaVSWLcOobXwdJTTbWPqd+E6KuceKdS21ByzlSVkb3maLAe+PDgrAQvVefv+6gzpl0HCaSHmOi9iYnKrg+APJRENzHeL6ck+qvpv7/86v8DyqDfOA05AgA=" + "payloadSha256": "298cfe1d132a7e1be26411d5a9b6424629ae77cee57938556c80db917936204f", + "payloadGzipSha256": "cdc74567101d5af75e8660c1da5b4b13fd9df5b2604fb064f27e8feab8926269", + "projectProjectionSha256": "669cc7f629b52ff504da6733d3100584faeb08370861c4c5753938d093f32c42", + "payloadGzipBase64": "H4sIAAAAAAACA+2dS48kyZHf7/wUjT4vAXu5PXhdrAQJEERIOknYgz/JloozRHeR2JXA7y5Ek5zpqs6MzHjlVBbzMIeprPY0i4gK85+b2d/+368+fPj45/75y6cff/j4mw/0T9P//yH/8Gn0L8///feZkn78zYeP2vooVYpkwwSQNPXkYpZAlUChRck1h3MkgvDWsDBLro4tJXP9+HXd+vv8Odfn/vnT/83Pf/3C//erDx8+fPg4Pv2Qn/5n//zjf8zPffrCz719/TcfPnzMtfY/Pvf28TcfRn760v/24x//2H/45/zcf/fj50/9y8fffPhfX3/+4cPHL8+fP9XnX3/59G+/zv/26cuvW//zp6/f9+VvS37zO/kPveV/P/kr7fOnP3/64Xe//mN+/v2vP/c//vj5+dc//vD07x+//sK//urDh7989Wp8evr6/X9zBRRxBCTJUpIqC7SWGB1LaFMNsDFdmJEz9IEcqVdmLz2G8/QrP600WfnTDdi86s+e19/3P+T/8OmH3/XPf/z86Yfnafmc2rCOkIZzy6A///ofP//4v3t9nhzEn374nL/8n+knCj/96FPrPzx/ev73f/7xz/1z/l3/xo3pS398+nNv/+O7f/a3tf75x9Z/+7l/6V+tOfnxv/xbrn/98G+f/eW1if+l5y9/+tz/0H94/vZx+PCNHT//8n+arvNHdMCP//Ttx8+f//T8+1N2zjpx0Y2Tjrz4uP74px+e++cvL67b10/69z+bfvq3ZeTFMn+1868P9cfffPj+s/ZpjOnv/PtP/vDpy5dPP/zu5L/6w9dLm8tT/87wv67685/PtPaLj//yT6/cGXPusM+4g2fdwQPdoVl3nmbvTtI1d0eO80bmnRnvyJnn9Q/a+b8b+sUetDHvTqxy58i/G3zpzq/OuPb3OPvb/Pz7Ey+/vz97/OpynPXu777Zqx+fdWzerZdO2Vkv/h7//uXz5x+/vsX/17/+04m48+nHH37eVSXLGQ1aHy41qaMPVaFMnQMIR6qO6owRkRthjORRehAyYMeAj796fXX/9XVQ/O2Jr+WUs6iyc02dEpg2gjIKqkWTIj4YQJKOhkOxQDNBb51SBcoe/PPm4GTQ+j5gnQlW5+7h6SB17gbO3b6zgembe/d9QDrzjjgXiE7/Me1gLp009+ns1X35fr50dWVfa+W0seOOjH1e/iCcfm7pJg/COG9uLDJ37+cWv3st/eU1VH33sj/5oj9t+YkX/Gmjz5t8+qX+l+9g5sTL/OOXmn/4oX8+9dHv8nP/bf7y5WdW/dU3636EkRMaodIoTT3ScAehBmSWfeJrrVGdSm6lltyZubEQt0bg1fUMGW5ddZ4MsSCraVFvOSP2K8kQZRUZosyS4cmP/w5UKHuS4QwWosxg4asP53046cUuWLiKCjdA4YXN4BYkvCtXnt7PXXl6P3dlHgVRbuzLiS9c4My4a2e2YeDrL7uMgbAOA7/7orM+bcdAq81dBmNvUnLU6omcnWQM1Txq8WJiVrkNEC4DIEkbyLVE1JH7SgwszKUOyKlCtuDurL0SIpl5G2a5NtUajNFHVxjomlvS0Ru2Ub89I16KgYsocAUEzmxOlyLgmzP16X6u6tP9XNXz6Pd6s7e7ra++4Apjx5s3djn2vdjZz2IfLMO+FwuftHdf7Hv+/KeX1IeiXapripIbqBYKD3dFbr2iee3KgzNVToIp2Ao3g+zD+3BMfpr6Nq86T33m0VJqRAo5QfErqY/X5QN5Ph/Ic/lA3jMf+Nv/9l//869hNifIczlBvpQT5PmcIO+TE6RbJ2p4U6JmHv+I1zhjxzljGwBw3e4c9ThvUDdAIK66N3jgzUHbAoKrcoJIB7qzJSd4Ill5hTt+nDe+KwrSUhR8fXuvZUGeTQmi7wqDY2Tw0rix5szCLfngRIpJlAqXiI5MJZdEBIrkUoxCg4MISua1OUEU6rmPnksvIxfRyI46Alo3i5REuXuiYjaGZ9WMtQBDycjDxNbDIB2dCOLFiaDzOPgyGF0y1vY11hYC4TIaeBlktlv7TWC5Dgpx0bXFnS8u2lIwXJQTRNrZ3KU5wVdJyQvm+r7W+iY0pGvR8Ntbcg0b8tmU4E8v9aNzgliyRqOw3iOnqqmqSDc2blVQcmaWGqHFxAGqqHvu2TqrY+9lnKHDravO02EBU3UtI2FJ3cZJOqTv6NBXwaHPsqHPoKHvmQ+cp8K5jOClhOB8PvCRDnykAx/pwJumA9e4wutduZA/4y0EuKoo9DBXaE/+W4x/tI7+ZhOBtCv7xXDQgo2stp7RUhtqntwgQU6p5pxqtCrdqeaMgVZFNRewoB4kH0/tCc/HtIjwR0x7xLRHTHvEtPuJabeub7llecs7jGnea4sULqO6NyG3VjWHSh7ancRHTzQQ+xBBzIUGdgOIUdw7M64tbmml8oBgaNbBx0ithgIQ1V4dEigYJx0cUoahNhfn4hytBWvUR3HLo7jl/Re3LDoTXHE+7OdN1YXnl4uyBLSrqbTl9PLquhZZdnbp544u5XYnl14HuicJbIUrRi9jVE+jNObgxJL6iD5GYdfsLTMTaVdNI3I5e3K5bdX5k0sVxklPgJizK1zbzUDr6lpovq6F5upaCG7UzUBzBS10qaCF5gtaaKeClltvxelA2DtR/nBNi/txzvCWgpZVfcd4nDO4pZwlrbkzcpwzaQvy3boIjDYVgY31fzNHAOzCv5lt2Ac3amqA2/U0NCvdKhUqTai04JZaMeNSq6bRe28WXTM3EbVhMmoyIyVXK1Kyr8Q+HU0gCmZ1DHdrrdcxNc5bacUtQjqXgoMVgRIMsJqDW0EmT5q2lLEcve2nHcHvVfHEpcb2fY3lpWUsi7qZcV9jcWkRS1pyZWVfY9PSEpajS69ocenVWP7MnjSWD3tmlyMgHNTaALftbDiBgM0CsKnXaqBcUrTaW66Ve8/dG1UPNE+oQlghcufpJI5YKDrnc8UrW1edR0CXps1LM3E37OXahnZc19CO8w3tONfQjrdqaMe5hna81NCO8w3tuA8CrtkAxoa2VpzfAMat032IxzmDeOuM3y/pzf73ZgucLyqZWEaAcGM2v3hjbp32s+N8sQcAzgJgHqNZ7V4h14oZBnGtpRA04doDUYoXMR4DiFIiThApcXKlRNzWAqBISqRRTLVSR6nIIN4NS0j1Pgne9JEhqLUuDNEQC1Euyk2Csm8AwCXb01jReovnt6dxZOIPcV9jEY/M/d3K2n2u7Rq0vipRdR3/wcFkPXthF/KfHNx5M2erPfDvBP5RqUgkWHl472kSKaHWoJsNqmWMPOrgkoS9oAHRaNGKDKkq1KTAafzbvOo8/vWq6hWxZG4pCE/j3/fNC6ixDgA15gnw5Oc/wZPGvk0Mce/CZnjrmmaULTv0C6nA+3Lm6T3dmaf3dGeeD3BmiyYYHShw9sadeQicfQ9l1hIZROpRQYm5Yx6199G8h8HoZjVJduxtiHHG1KQrRJQMvRVc2NcwhTm+9zCHt37K8cCKl/ty5kKYu69Ttaf3dET4D3Te+dad2RjmFo9zwCPCHO575AkGkL0rZKggLIStl+KTWGcGExq1Dmvorrm7FHUSlTLGxKJgviLM6Z2HObq14NKlbTZuwbl78+bpgC0IH+cNbwl09+XM83g/ufD5OBe35my8YY86Lk7syRFhTnYNczwQNWXBLGwlQW6jTWU5ak1a84hcsSiRVGrDgVVRA5WhKUhlW9qlDhRzUS7NRbl0Kcql+SiX9oG5dGv+SQfC3H058/Se7szTe7ozz+/JmXHXzmyMculWZ5bpdmeWoxcug4eqo7Yu1nqSSj3BJKhNJq2ZDUnUkL0OSskGlgy5IDp0WBzlGO79yPK+xqj09zQT5uk93Zmn93RnHqOHHqOH3m5mrjWurFC0DM0jOkQalaX2JpmKaus+5eNMyEeHr/osEFpy7znUx4ooh48o94hyjyj3iHKPKPcWo5wvDXJ6RJDTfWU1UawSA6GwmFuXrMGl9g7c3dMA6FSBW7PRCwVMlac+wLopIvvyIEePtNwjLfdIyz3Sco+03CMtd6O03OAOXHMapRJ7wtGHUtRcStAw6GjZavamZbQpRNVEkjlzbVq8V1we5e6+xhJurJB06eWzZUzenTkzH+P0xoPLLvniG0Lcffny/K52UuOuezweQe772pNIqu5cg7xI6Y6RtEGGkRBRGuOA1MybdolMxIZpoJGP6pWzLg5y8sjKPc4rH+eVj/PKx3nl47zyRueV0gSmsQmJK6WSqrk4keqw0BFdipNF6xLepKhVh1YxhHFETFPhlwe59GgK//BoCl/nzKMp/NEU/mgKf5SeLI1y1kMgpEoHlwGGgtDGpOVSzDG4Qy9qWrC0zFaG9D5cwHrRFEJ5eZSzR5R7RLlHlHtEuUeUe0S5G0U5HoQj5wy95zaKmw5rZK4Vc82AAJNOZmCVLJ2w69QjTh2HxRDW5QWWEo8o94hyjyj3iHKPKPcmo9x7TMuRs4ZAHkUx6whNmUfFRJoAq0tIztGss1rizGLWjQxq59w99Vgp9gxG6EQloeQyuDYA9AY0cDTGHkald4CWcq6jTiE1FcEo0DOSyIZpP8iLpHN5xexMVJ2ZR6JLJ/68TYNnpv4ILxJS5p0NJl46+edtGjyj/ky46JFYo/+cZtSqeakCNEo6WgN6zt5NItAoV8tAUyzTgX6pIPzymYhbSUFXbLWjpRIDzBExi7eKPBqa2Zimt0ptlosqcVexbsLGDFWh2JlJQJtXnZeCJglmidZQho3CV04C0jBbJQX97T88CVhnfuHvgDV9vKMatOlcL95LWy74coU3Z/zZBRmVk6/ZMypuqLPTMJ3fNyr6BnRUVF5VOoh8pFOOW8bFcoJVd4pFD71V0/obYJLTygGlkuJYt1JswEpGXlU8wi50qFsuW+o+NcGqvyyxQ/+yxHYdKqsetBQ1Pa1jzddv8u9eGmnf1KGBJSUUmGCSkwOWjjUXtBSjh0XzGFSRMrUcw9Pg1nkUqC65wkrcTOSQRLX0NKQhpdJzTx1Sg2mMUMbg5tWIUZtJAyhttMKaKlTN0dbj5usAd2nr+yqwXbf5ff2Eng9m1yHn6wB2yehXgWsPo78NVtdh5+sAdZGKXgWmPax+EYyug8/XAeiy2Sn2NzvFQgR9HWAumv0qsOxi9rfB5DoQfR1ALk7Otd2f7G+DxQocfRkc5nn026BwDY++opjTgeBoIBW1XKAXcE9VsvkwBMylJaQyWLKOrt2TGRSJhqV3T0mZszTJxqeBdPOq80CayajVFKBlcEtXj6aFdZOJYH4wEczNJYJ9xxLNdlnAXNISLiUtYT5pCbsQ6Dqx9i05C7jQGLWlzeLevDlgPu0GZ+C4Lou7cuX5XT1j445vzGM47esOC8LUeu6cc2SoSE40YGSXUrpoaamKNAr2WtGkqIwo/nVwbDWta/OVVLFw5+IGnrTjaJ7z8J5b4hhhLXIuYUkt99Q6uyNXpdLBNRWp6wFy2aBPWJE5gZlJn3DoeNrbWLvTeNoVxs7YCkfOpr2Fqc939QyMN35hH7Npf+a/yD1r8hrKGSBJDzPUnBV8FEYLH6ioVYGjYmaLrjD1i1tXhX6G/7auOs9/AioYgyIMc+J6Lf/RSgCkCwRIswhIOzPgrDIozUIgXaRAuoCBtA8Hyqp8yYnMwILitQt7QdMNJMir9Lk8DvTHYwMLyqrEj9mR98e2JB7v7v4cITCzJY1/0R+8kMV/gOE7AkPqRDjVBA3Vyj4sm3gwtZDaRhf35q0kDWXqrXJUTu4laSoGLeeVYJjdQb1lTxaerPjU5k9VJdtwSVNLiIXYcGxSM+UsBlFl5IygxdN6MJRF+RdbUxZKM5tXW1rHyotqFj12ttdjIRwuKwo12/v62tJE4pu8vucxkRZh4pq0+Ky9eCYr/iDF+yHF5qV2C8jBJUo1sVx6TS3CBTp3aFh7SsQd8sidS3ZHU6qjU8VzmcKtq86TosNoSQrhoDyq6ZWkSGkVKFKa5cSTH/+drCjtSImsPgOJNDcKkC6NAqT5UYC0zyhAuvUkMDpwFOCdOfP0nu7M03u6M8/vyZlx185sQ0K61ShAuuEowMLK1FLhBEQJWncfKU+DIkbqwxp4i6iZe7Dh8MGTWE2BMKGGIbISClFS76M75UkkZ0wtlFRKziOTjY6pFi6BVVm0wSAUH5xRYbgIBmwoN6V08Cb11RdckSg6T4Rv0Nine7qyT/d0ZZ/vydjx5o1djoEv9vp7cuCLhW8Ags+f//SSAxOPVFvphUdpUbUWSpTFK+UWBDom1ioylY6w0XDGiDY4BzjwGO00B25edZ4De+pdfSolsaRNx5Uc+PMpzyIO/PZw6ARBnfz4X346WNqRA3/73/7rf/71fMbw9UHWWUcuunLSmV8QBjckC/nClJUtqUK69fgb3jT+5gIM8o2nNlxyZkt3Iq2rtcTjvEHcgoN+45lRl7zhDTjI6+7NgbdmV7EbXIqDtJIHX7v1Sgdh56m5XbNEUuReqrDIoOn8Vj2PpOCZZaQ2giwz1gKoPjwiSR3JlTytBELLJFkr6JT2q+pJsxKgtlGYlQQJMuTk1hCwk48UMsZUkjR67dluBYQrkoSvAuqZoHQlEC7KCcW+xi7ND9KyFsl9jV3aZUjLijJxX2sRlyLhsk7Ofa3lhUi4TJ9p70u7SdoGr1a2WciEL4Di9Av96OxgSsW6iHKrjbVkoJ47GImk7qNSreqS0b0L1KkUBBITTuWhrFTTmTrSzavOU2FoJm5AOqpa63QlFaZ1VJjmqTDNUWHakwrN5oAwzQFhugSEaR4I0z5AKHNjN+mA5EDC47RPZ53BA7a225yZB8J06316WrZPXwSEd3Zn5nFQbo2D6UAcfOvObMPBhDfKDia8YcloFQdNAQ1HEWQKs6Z5UHBT1YBU0WP00VqpncMrQA0aX8O9q6+FwejdWpnE8BicIY8aIhA6uBUhKYLimdAkcMBohbpgiehZkhbeAIMv3x7nAtSG5EWa2anSQhg8ayzutK1ebux5GExHM0C6jgGugsE3eGXPo6AcjYJpRxR8K8YuR8GEB2UHE/7SZaJJvWKwpSIdmppUS11zz0Bcw81oOoXr6qGW0nDVLiqYk2IYA5wBwa2rzoOghXguAJi/CqFdq3DqqzjQZzHQZyjQ92wknEFAnyFAvwCAPst/vgv++Y1Lw/y40tC7cmUe/ezG5OfHgd9duTKPfXrj8R2+hWDHHf+t3LhH0NcRn8/Kj+4KfFmlELDnql0LqVErBNoERgrMvRZMwDbl5kaXGFm9W6+9CGjvCVcCX4kYkdMkIyc8ep9qUS3nRq1p9gIA1qK0GEy5NYeUUguVFN5piG4Qj/GDi9V8v2LQN2fqedizg1nP90O9N2fqedDTRTM4djV1qYro23hWD+wE9GWM52dFQ2+FeM2w5yrdoECLxJpaUrYG3cJN1HUYMzXsX/v6opIjCSm0aVqfnUG8ravOI17prZfcJCdX4wIPzZh704xJsW52xYGaF4pb8n6rKGOLtv5ljZUtMytWzhahI+/PlsmHsgqdTI68P7JlSsW66lY7VDPGHpox+/Dg29eM8UmrRWgoecvRRlZNaVL/JofuTSBzGOVCUDVaI20xwHFoLV59tZgojExUqQwryuFFcsFCHSiQG44eTdU6DaccCi1b6ThGDgDvVXus58EUy2ZR7Ky5obg0B7iIX9Zo9c9rsCydQbFw1gftfX1paSZwEXS9DCR7XF9ZOnViWRWr7a4ZYw/NmE3ZwF9cM0a99jSgFCqJpaVp9g91zs1JECg1k9wl9RCCGIjG2IQa00AfTuU0KW5e9UJVqI0cPeNIzDXrtaT480jMZVWhNF8VSnNVobQjJUqCOdGYVxM/zztx0Y2TjuxDiLeeiJ3ouMzgnTnz9J7uzNMBxYdynDNbqPDO7sy4a2e2MaEsnlOIa+tCaQ4LcV8uzJQKW6eEXqyEBEJ0w+jiVUaNajmnjJiTU8pF2AtQSqxuJVGs1RKdmj96B5WcjSfNuTydEPceiYoXauDJ0UdrOImWGxWWwDahZE/h3jdwIR28T52bzr00U/gGjX26pyv7tFP5ouxr7FIWfINXdrx5Y5eToFw9bRCXVobSORjEW9GgOVMaRUVSI5q6rqOMqex/kvbs3FGKUZ/e+DXEwQF6LlIGFcgIX9/aJ2hw86rzNJikJ+WcM1cgpH6tgug6GqR5GqQ5GiTaN2c4N2uQ5miQLtEgzdMg7UODc3tBpAO0SejCZpC24CCu8iYO9AYfswbf2azBVUMyCH+xZ+yRHnxH6UHu4C7dchMS70ZhQ9kxokCMxj7N+YPkCgqYPRfxCgmRAlCwlJUY2DQK5h4FRokI7jxNIE5YcUgE9pY5tcEwnCtOmweMlrjmxhLe6oZy0XObVKSdJE1oZpdKSzkQF1kbO1uLj1mDb2DW4KKZF4Q3eQYe2cC7yQZaQAkYLTtbmV6gpMnLoGpIdWjlyEVzq82RulpvI7i2jNV6YaVxhv+2rnqhNTBR14LBSUZwXD1BgtfxH8/zH8/xH9+Q/3iO//gS//E8//Eu/Leq3o22KMjzgfxHN9+b84P/Hvx3xTNGBz5j9OC/fxT+y9qIAy1TL1ZtsGeSlES7Rq5RxlTD2Uggai1Na53UPbuGc64FCdbyH0xdh70bW8GWRaP0QtZqU0oqo6NFQagtQRsWUSinYQPKVIGE38xZWsx/i8rraI24Pe/If3T43p8f/Peu+I92fgbowX/3zn9DRxEuhaeDNZ2KM1W6tpoae2OKoiyiVqpmK6C1DG6eOWF1MD/HfxtXnee/0ZIlrlCLVjFM11aDrisGna8FnSsFvZE0zNzowEuTA+cHB6ZfDvo2bPr4uALQVfvXtKGWbd6V9MC9B+5tK2PdUsX6YL13xHpK1EZjThVrhRyjSYLhWtLIUBGwcRvNyEZGVppGQ5j2AVSgi4itZL2BgqHkA0obqXHgyNIUB2TjqS2xpxEjA2IFAcg9pTqaJwwauYndiPVW7EV5v4LPRdvmtKJ27ryp6UF590Z5susDIA/Eu2/Ec9QOaOxDQs2NwqF2Hr3B1+M7rxDDHMOKtY6l95xqDu8Qkns+MwZi86rziIdWPVozq2hWOF+JeLpuDITOj4HQuTEQuucYCHSgGdLTuTkQemkOhM7PgdB95kAo3njfp3gc792ZM0/v6c48vac78/yenBl37cw28tNbzYHQG86BCJYslJICt6JkGREyjwFl6KDEkKL20oXI1CQJ4SB1SzUxqlpdyX5pmEbohJctYBIE6NGzqhH1DMGQckbV6WugCUSkEiNp51aqSt3Q7qd48C5VcT/6e4PGPt3TlX26pyv7fE/Gjjdv7HIO1KPmQCj+wlPiIwVUqa1KH1kjE2NRzTVBKGJDG4wKKcU0nieX1oEyho3UvWDXMyC4edV5EFS1aepPFU0ZNK6dEh/raj1jvtYz5mo9g2+U74u5Qs+4VOgZ84WesU+hp9uNx8MHHzcePm6dwQw+jgLXzbo3PM4b2zIWgmTVgxYHPmmxgQTvKpX5j5v/e/38XDEcXtZh4Oun7VUBluzb76fNWtbqPM1pEhigTG7Y8rBeIzlKK1Iz1laECbtWtE7ZfeRuvlYONEbGmgurZ9ZJcTRZqzVaGz0hy1QEWrs0SqUUU+qFkZp1FGlsGcuG8RB28HD44P2Gw8fRGcvg/Thw2SR7w32ttaVjIl7+eV58EGLnJyEWsuCbS12+v3zgt/f4wmh4WQaCLyji9Ov86JxgqEWKaCMQvDQujN1SmuovMQk3DExlUl9GkZR5YIESNfKkzuw6zqHg1lUvlH1iT0Ms1KRz7XISBek7FBRiXQWD3/7Dkyh15hf+5SfhINYdkZBU09zIiJibGBGXBkbE/LyI2IUK72p329/PRv1RCXp/laAPEnyflaAY60Dwu3/3UjAudgXBElwjSUkpSkdtERm4ZSoao2TkQSqoJN4Cw0yKQwhJbV1ZKPWPpzaJs6FNZ0Kb0Csh+9dx+tXHpyL1mV/5OVa/+oW1Ie71Qjd43E9+5W6BTgjTjeeVXnaINoQ7wZhzCI9o3L/Co01jkdDXadGRHOkT64b4J4R645G/lz3aon3GYasCIYkd+uCJ7SqIja+l2C+HRF0ZEr93buYkfntQhJIVTBitjZS88wAms9xrzkBApt7+2sgIuXKVqXkeqlURwki8dngugkKJgDJMWuEhIIEJW4rpcJaHmtSR23DPksO9UcncqLYkqbaxYVjS93F399Ob775iU6XM61h1wBDVeYNp4Snp61h0Lg5taeK/YPHiiUmvYs1F3TmSvW1mXXhc+jqWHDAHeN7ipTppr2PFxQdZbPcH45u4sEY1+0UcuDBsd+G03Vfnbi8H7t5q4m4YpGkGAWSDocQRNWjYcBmjSKYowVryiFSnqQbM0blWyUQ0GL2eOULduur8EWqrSclQcAyOlvHKahpfdX7qs4enPnNy6jeqpPEZqvQLSOmzPOmHy2UfUUTjx9XQrCoKt8NcsQ0AeV+u7H8y54e54ht48a7+VuZJ0W7Mvb4Ierfx4eL2CVvHhj4HhrbvEN3ETUf1qVPCfDAUKkXVO0JgKIYKJsKMhaSD1BSCaXiz4aZD1qpkc5QoJTXuvbcoOVfw1iRSbxligOVoloZV0DxK4irJvEvE6FhE8/4q2XsVzfh+NTOLytBtV1NtIQu+PVP3qenwXU31hej35p7V89BnByOqX8Wny1Hv6oYJW4Z5fo7x7EaIl5Fp+CRKXcCgUUzs5Z6oaqvZjHopSaxCbj2wotXQKfvF1qcpCWfE0TavOo94GafheCCl5d5ezMR7iGNf4cRDHPshjv2okbmTGpmHOPZDMO0hjv0Qx36IYz9k0x7i2A/ltL35j/qwwqkCNK/DS7ZEDM1wmlAXigzDhnpMh26uWepIw0dtLMJ4Tjlt86rz/FddoTAFtpKj92tTfIgrW+a//YenWwtwtm1++nhPDmSZKyV9acwFZ65w54xDu/BgxDqGgg0Q9dr4U4EYt7TSx7oaOOBjfeINbBhpLmGT/GzGxvxQpy6UX85T4rxTcR6u7Jf06fmCjkOsefhii0vAOu9S2AZkRIBVp0bMR7rEvGvG8Lun4jB6PPFVBwKkOybLmrV7+1rH2Tl7JNRwgMy9poatYx9SI1kuNRUE015ar4Npbd4QSiWuVmutWPpIfRQc2bVLn+bncmjuiVPqzVogFITQrrl5QI+eNgDkq4B2GSFXUNl30fds8Lqy6T6WFeK9/Hvcx2ZeCJOv3t1ng9GZQLSL0WdqNM9j5Xmj4zSt2a1sfp5RZIglD0esMfnV2/Zs8LiOMV8HjEsmM+9t8jfBYU2u8SVM7Imbr5b+hYhzcCHR6KQDIzzl6oDmWgN6GyU0ZSsVQ7L2ZAaSs0uqabgijnPEuXHVeeIM0swlqmQVrv1qre51Xfk635Ovcx35umc/PhPO1Zaqzml16yWtbp3X6tZ9tLr11lK9eqBW93058/Se7szTe7ozz+/JmXHXzmwjR15cbMpppVi3zmEjp325EXOlrhnVkHudJiwymEJC8NYzjE7SQ5hKa1xjCGEZXFLunUsRXsmNVYAQqFafdAEQNEmObpI6UAPJ1Uv2ZpBr1lwQEYNanoTDWYZY26DWrUeLCeuOat1vz9ine7qyT/d0ZZ/vydjx5o1dzoN8dfHpt6/2q+S6z7YYcrqNXne2UVmt5zJxmnTVFNkJtERGpTaQyUmqMnRFS9OkBM/KA5IgSDkDg1tXnYdBr6KVJVdSyzXnx2zesxD4Fmbz0o1bjXiLvsYBBad82OhUflSbvrNqU76xDFLaooH0KDV9R6WmIcHTzPtuwJJS9OI1a0jrFXqiNmqHlBOPIAL2whkKeEmpcQUda5VnOHsW9ApIqZmmlNy75KEqER7JytSq0roWr1SlUilRkoCyZs+t3KjDEHedzYtH1pnyrqNZ+VFk+gaKTPlgxaG0VG7oUWF6PxWmVUo0NUPmqn0q4kcKSDCYiLmb1WhKfYBWq1OLdysDW4aGXTifQ7ytq14QkRHvtVvNpklwyLUdhrSuw5DmOwxprsOQbthhSHMdhnSpw5DmOwzpcNo7LxK5oUrxRK/Vfh2G6yQv40BvHh2Gjw7DrV2s256xB/a9I+zjDu7SLTch8W4UNiYJgIgCMRp7Z3dIrqCA2XMRr5BwivUoWNYWiE5tKJh7FBglIrhzk2zTEI8hMWUbM6c2GIZzRQfoGC1xzW0qH2217o99p4UlVxQtvoqj2zoMl8lgxs7WPjoM76/DEG/yDDz47374rwXVnqjkSIUscWSpUDJUkU5Zi6XUK1VMlUEVIDSkmjC4qLqc4b+tq87zX5kUqDNw16SUC1/Lfz/rBi8DwG/1hk+h08nPfyInx30ZcC7bR6+lkc87ctmV087sw4GON94JnvrG3So/786dp/d1d57e1915fl/ujDt3ZxsWfvdth4Hh9990IBpW1VqHW+ZJJsCm7kC07mAxeirV20DABNF8NBOkUqUmAKpGkrSuzgiGN+Xccx0TlqJYLhaemDq7xnCNbnUa0ps7okoquSZqzUsJTqQb0NDx4M3r62/YVAX6Js19uq+r+3RfV/f5vswdd2DuclJ8CQN7suLLlW9Ai9/VgxbrQ9vUvDd6axmzN85mHJiCklvjmhncAbl4aAMeDSkKcXN2PgOLm1edh8U+BmfuVFIqo6BdCYs/jz9ZxIrfTk05wVcnP/47XBncqCb09WiXsx5c9OGkF4dP6rUjdoV2IVdgRw3rfYvePL2re/P0ru7NPB3arVNsduDc3rfuzI0ThrYSDG02ZWj7guFonDl3pgY1kfUoubBxaSO5luBSCLnl5qPn4WWMUWg6+23coY+2VpUUkqM2zzSVhg70SQ9VuAdhK62lxo2augvgVLqKGkTIuUEiqw51Axiey2nYXptVm8lq2FIufIvWPt3VtX26q2t7Hgrt6GScwX5I+FaMPTB1aAt50M4mD+1W2cMSSayXwMqQB1qCYM4qMp37FcqSqWIrfUr+dTLNILlgguo5AaqdAcKtq16YT+E4ehtNcs7Gza/VJ+V12UPk+ezh6c9/0vLkPbOHIuBz2qQ8lz18/ekFV047s48u6ap58Lxh1hoybpo0fkGTdF3nYBzpT2zRI70/f+bvzyqREk5H+pM2MOK6jBvTFmHVC323TJtESNd1EeOBDuG+QwtfzrA+cGzhd0/eoYMLA5UopuYQpWSGFo0HhHmIDlXulhlRyEWhSVKhMno01qDeXNfWl5oDphqoUFzSVD6q2GquNerIokUhhibIjRSLZUCAoS2xQWBqG+pLY9F0eF4xDu51jL1mbvmM+Oiy5sLY295YKjz6Nu09f30X6Z68jCh72JsWMuOyxBzTGoXUmdZYpsVqo8saeXFng3HbXMNv3/q7TjZ8CR2/yGzD6kY1uoFYWHMKgjS4JnHtVqW2lHs1ChSexF/KNKgCevXurpL5zPj6zavOsyOYqtPwCK19mD7G15/Fxcf4+l3H19+VK/8w4+vvypV5KJQ1rshhrsgWUdEb6+T4Ip2cbSS4mAPlgPH1sisFdsnWqA1PpVcK5cGhBaowohbH2gS7dO8YxSg3GcwDoQkVY+a1paRehyTy3gh1FOlmbjW4CA2EVBpSldZ6RKm1yqjAmk2rpxghavEPMb7+zZn6LsbXvzlTzxOfLDFVdjVVloqIHqyD41fp4CzHvKshT3YaXy83QrzWc6mmLNZzH5iMbAhDpKlXHEskay3lApSwVUlTI3eqjpa9A0M5M75+86rziJd0tEJWATCHG13bXCjregtlvrVQ5joL5YbiMjLXVyiX2gplvqtQ9qE9vrGWKMmBYqL35czTe7ozT+/pzlxoJpRbN9/Jga2EcGOGveSM7NpIKLfqI5QbKsyEFe8GvfgopXMtYRBlpDxAppAOIKnnxtZ6mqQ+SzEIIhfvmPPaURIpd1eH1sEaTNuEkNrAE6ZhisQj8lQt2oyEK47qk9Bp9GIpptKhLcKifLCy6Ktguk1a9O0Z+3RPV/bpnq7sTPugHN2OJzs2D8LBxDpnrGxqHZSjOgfll5aZaX2IR6HswHnECJ4GzUtx6D5aFGrVUbgN8qnq4mu/Xwts0rS2XPkcCW5cdZ4ER/6rak3r2FL6du79Y5LEG5wkgTc+9ect6vh9f5nEdJjQf3qoij5URTeBUjou2feQFL0rSdGeIUUPTyK1CyUDCyichw3VMo15KNC5RokpTI9Re+oqzh0rOq7Wjcnqo2YaViaJcesYXgdLTzXVPqZ+E6KvcuKdSm1DyTlTVUb2mqPBeuDDg7MSvFSdv++jzph2HSSQHmKi9yYmKrs+APJQEt3EeL+ckuivpv/+8qv/D7rkD2YNOQIA" } From f6db6f7995391173063edce4470b1fc2d54140e1 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:45:27 +0200 Subject: [PATCH 130/171] test(xer): herpin corpusloze X12-eindpoort (laag C) op dezelfde late-restwerkregel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-xer-corpusless-fidelity-gate.ts bewaakt de v2-productbaseline óók corpusloos, via een in de bron gepinde EXPECTED.productStrict-aggregatie (dezelfde 34-bestanden/47-projectensnapshot als de externe JSON, alleen hardcoded zodat CI zonder OPS_XER_CORPUS 'm nog kan controleren). Die pin liep na de vorige twee commits uit de pas met de herpinde xer-product-fidelity-baseline.json — de 52 mutanten bleven groen, maar de positieve controle "C v2-karakteriseringssnapshot" werd terecht rood. Zelfde verschil als de vorige commit, nu in de corpusloze pin: ls: 8.807 → 9.697 exact, 4.886 → 3.996 diff, 5.015 → 4.125 afwijkingen (−890) lf: 8.769 → 9.660 exact, 4.951 → 4.060 diff, 5.044 → 4.153 afwijkingen (−891) tf: 8.912 → 9.270 exact, 4.765 → 4.407 diff, 4.765 → 4.407 afwijkingen (−358) drivingPath: 13.186 → 13.180 exact, 410 → 416 diff/afwijkingen (+6 — een paar taken op/nabij het kritieke pad van rehab-2 wisselen van driving-status door de gecorrigeerde late datums) es/ef/ff en alle overige tellers ongewijzigd. De drie productPayload/-Gzip/-ProjectProjection- sha256's zijn meegenomen naar de nieuwe waarden uit dezelfde regeneratie. Resultaat: 52 mutanten + 5 positieve controles = 57 checks groen. Co-Authored-By: Claude Fable 5.1 --- .../check-xer-corpusless-fidelity-gate.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/tests/planning/check-xer-corpusless-fidelity-gate.ts b/tests/planning/check-xer-corpusless-fidelity-gate.ts index 2bbec77d3..3e7648830 100644 --- a/tests/planning/check-xer-corpusless-fidelity-gate.ts +++ b/tests/planning/check-xer-corpusless-fidelity-gate.ts @@ -78,17 +78,24 @@ const EXPECTED = { 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 }, + // p6CompletedLateFromRemainingWindow (X-O7 laag 1, klasse (i)): alleen ls/lf/tf en drivingPath + // schuiven — uitsluitend rehab-2 (proj_id 761) is geraakt, es/ef/ff blijven ongewijzigd. + // ls: 8.807→9.697 exact, 4.886→3.996 diff, 5.015→4.125 afwijkingen (−890) + // lf: 8.769→9.660 exact, 4.951→4.060 diff, 5.044→4.153 afwijkingen (−891) + // tf: 8.912→9.270 exact, 4.765→4.407 diff, 4.765→4.407 afwijkingen (−358) + // drivingPath: 13.186→13.180 exact, 410→416 diff/afwijkingen (+6 — een paar taken op/nabij het + // kritieke pad van rehab-2 wisselen van driving-status door de gecorrigeerde late datums). productStrict: { - exact: { es: 12_680, ef: 12_437, ls: 8_807, lf: 8_769, tf: 8_912, ff: 12_499 }, + exact: { es: 12_680, ef: 12_437, ls: 9_697, lf: 9_660, tf: 9_270, ff: 12_499 }, sameday: { es: 96, ef: 97, ls: 129, lf: 93, tf: 0, ff: 0 }, - diff: { es: 1_155, ef: 1_403, ls: 4_886, lf: 4_951, tf: 4_765, ff: 823 }, + diff: { es: 1_155, ef: 1_403, ls: 3_996, lf: 4_060, tf: 4_407, ff: 823 }, missing: { es: 0, ef: 0, ls: 0, lf: 0, tf: 0, ff: 0 }, - deviations: { es: 1_251, ef: 1_500, ls: 5_015, lf: 5_044, tf: 4_765, ff: 823 }, - drivingPath: { exact: 13_186, sameday: 0, diff: 410, missing: 0, measurable: 13_596, deviations: 410 }, + deviations: { es: 1_251, ef: 1_500, ls: 4_125, lf: 4_153, tf: 4_407, ff: 823 }, + drivingPath: { exact: 13_180, sameday: 0, diff: 416, missing: 0, measurable: 13_596, deviations: 416 }, }, - productPayloadSha256: 'a97aa10c39687e33cabf23ff7a4f832556401192f4620a6cf643d97e49160783', - productPayloadGzipSha256: '99476b658c45a4e5fcaf42e1b94ffc827437d26a98dba2db51c6a0f9400c82bb', - productProjectProjectionSha256: 'ab8fef5851dd85d941e7f7f862c4ee62440e3bc2dd8f5299250053b81cfd5c7f', + productPayloadSha256: '298cfe1d132a7e1be26411d5a9b6424629ae77cee57938556c80db917936204f', + productPayloadGzipSha256: 'cdc74567101d5af75e8660c1da5b4b13fd9df5b2604fb064f27e8feab8926269', + productProjectProjectionSha256: '669cc7f629b52ff504da6733d3100584faeb08370861c4c5753938d093f32c42', roles: { oracle: 45, 'engine-input': 14, From 91e8c9627e8ec902aac0692ed299f910ebb4a8a8 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:51:38 +0200 Subject: [PATCH 131/171] feat(xer): datums zoals opgeslagen standaard aan bij restverschillen + melding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan §3.5/§3.7/§3.10/§4-T4. Nieuwe gedeelde kern applyRecordedDatesOnLoad (documentActivation.ts) beslist op de PAYLOAD, zonder undo-snapshot, of een geladen document de "datums zoals opgeslagen"-modus aanbiedt (elk formaat) of meteen aanzet (alleen recordedTimesOrigin === 'xer'). scheduleStale gaat nooit rechtstreeks op true, alleen op false (invariant in state/scheduleStale.ts blijft heel — check-recorded-dates.ts 11c bewaakt dat mechanisch). Gebruikt door zowel fileSlice.ts's applyLoadedProject (vers open-pad) als documentSlice.ts's restoreDocuments (crash-herstel, actief document) — beide paden delen nu dezelfde reproduceerbare detectie. Crashherstel voor XER blijft vandaag een no-op (readIFCWithXerReconstruction reconstrueert recordedTimes nog niet — dat is taak T5, een parallelle Opus-baan), maar herstelt WEL het bestaande #63-aanbod voor elk formaat dat crash-herstel tot nu toe liet vallen. applyOpenedImport telt recordedDates.shifted op over alle geopende documenten van één bestand en geeft de som door aan xerImportNotice — één detailregel (notifications.xerImportDatesAsRecorded) in de bestaande, ene XER-melding, ook bij een multi-project-XER. RecordedDatesNotice.tsx noemt in de MODUS-ACTIEF- stand nu het aantal (recordedDates.activeCount), met terugval op de bestaande tellerloze tekst. i18n: nl+en brontekst plus alle 12 overige talen voor xerImportDatesAsRecorded_* en recordedDates.activeCount_* — CLDR-pluralfamilies exact gespiegeld aan de bestaande xerImportUnsupportedSemantics_*/offer_*-sleutels per locale (zh/ja/ko zonder _one, pl met _few/_many, es/fr/it/pt met _many, ar met zero/one/two/few/many). npm run verify:i18n groen. Tests: check-recorded-dates.ts sectie (7B) bewijst het laadpad met een synthetische bron-orakel-ImportResult (hergebruikt de bestaande externIfc- fixture); sectie 11 kreeg case 11h ("na een bron-orakel-load") naast de bestaande 11d-11g. check-xer-open-wiring.ts kreeg een corpusloos T4-blok (restverschillen ⇒ modus aan, geen P6-uitvoer ⇒ normaal, twee projecten ⇒ twee vlaggen/één melding, O6-mutatiebewijs) en een corpus-gated blok met ECHTE bestanden: rehab-2.xer (813/6976 verschoven ⇒ modus aan) en p6diff-baseline.xer (0/8 verschoven ⇒ modus uit — dit bestand won onderweg mee met de laag-1-fixes op main, dus dient nu als "zonder verschillen"-bewijs i.p.v. "met"). check-xer-product-fidelity-x12.ts blijft ongewijzigd: 3/100 checks rood, exact dezelfde drie regels en dezelfde 18.398 afwijkingen als vóór deze taak. Co-Authored-By: Claude Fable 5.1 --- src/components/layout/RecordedDatesNotice.tsx | 12 +- src/i18n/locales/ar/common.json | 12 ++ src/i18n/locales/de/common.json | 4 + src/i18n/locales/en/common.json | 4 + src/i18n/locales/es/common.json | 6 + src/i18n/locales/fa/common.json | 4 + src/i18n/locales/fr/common.json | 6 + src/i18n/locales/it/common.json | 6 + src/i18n/locales/ja/common.json | 2 + src/i18n/locales/ko/common.json | 2 + src/i18n/locales/nl/common.json | 4 + src/i18n/locales/pl/common.json | 8 + src/i18n/locales/pt/common.json | 6 + src/i18n/locales/tr/common.json | 4 + src/i18n/locales/zh/common.json | 2 + src/state/documentActivation.ts | 57 +++++++ src/state/slices/documentSlice.ts | 13 +- src/state/slices/fileSlice.ts | 40 +++-- src/state/slices/types.ts | 5 + tests/planning/check-recorded-dates.ts | 64 ++++++++ tests/planning/check-xer-open-wiring.ts | 146 ++++++++++++++++++ 21 files changed, 395 insertions(+), 12 deletions(-) diff --git a/src/components/layout/RecordedDatesNotice.tsx b/src/components/layout/RecordedDatesNotice.tsx index 3cc389fd4..1eb10c7ac 100644 --- a/src/components/layout/RecordedDatesNotice.tsx +++ b/src/components/layout/RecordedDatesNotice.tsx @@ -27,6 +27,12 @@ export function RecordedDatesNotice() { const runCPM = useAppStore((s) => s.runCPM); if (datesAsRecorded) { + // XER-etappeplan §3.7 punt 2 (taak T4): de MODUS-ACTIEF-stand noemt nu het aantal, ongeacht + // bronformaat — zowel de bron-orakel-route (XER, die zelf al ín de modus opent) als de + // bestaande #63-route (elk formaat, via de "Toon"-knop) delen dezelfde state. `runCPM` wist + // `recordedDates` pas bij het VERLATEN van de modus (zelfde producer als `datesAsRecorded`), + // dus in de praktijk is `recordedDates` hier altijd gevuld; de terugval op de tellerloze + // `recordedDates.active` is defensief. return (
- {t('recordedDates.active')} + + {recordedDates + ? t('recordedDates.activeCount', { count: recordedDates.shifted }) + : t('recordedDates.active')} + +
+ ); +} diff --git a/src/engine/taskGrid/taskColumnRegistry.ts b/src/engine/taskGrid/taskColumnRegistry.ts index d41178b65..23f7a8700 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, @@ -128,6 +129,22 @@ 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); +} + function readonlyColumn(config: ReadonlyColumnConfig): TaskColumnDescriptor { const id = typeof config.id === 'string' ? taskColumnId(config.id) : config.id; const format = config.format ?? ((value: unknown) => formatScalar(value)); @@ -668,14 +685,29 @@ 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: task => task.time.lateStart, format: recordedAxisFormat('ls') }), + readonlyColumn({ id: 'task.time.lateFinish', labelKey: 'taskGrid.columns.lateFinish', category: 'computed', valueKind: 'datetime', read: task => task.time.lateFinish, format: recordedAxisFormat('lf') }), + readonlyColumn({ id: 'task.time.freeFloat', labelKey: 'taskGrid.columns.freeFloat', category: 'computed', valueKind: 'duration', read: task => task.time.freeFloat, format: recordedAxisFormat('ff') }), + readonlyColumn({ id: 'task.time.totalFloat', labelKey: 'taskGrid.columns.totalFloat', category: 'computed', valueKind: 'duration', read: task => task.time.totalFloat, format: recordedAxisFormat('tf') }), 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) => { + if (value === 'deviates') return ctx.labelForText?.('recordedDates.markDeviates') ?? 'deviates'; + if (value === 'partly-unrecorded') return ctx.labelForText?.('recordedDates.markPartlyUnrecorded') ?? 'partly-unrecorded'; + return '—'; + }, + }), 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 }), 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/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index 78cc065d8..875e4e123 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -920,7 +920,10 @@ "activeCount_other": "تشاهد الجدول كما سجّله Primavera؛ ستنتقل {{count}} مهمة عند إعادة الحساب.", "show": "عرض التواريخ المسجلة", "active": "أنت تشاهد التواريخ كما سُجّلت في الملف. لم تتم إعادة أي حساب.", - "recalculate": "إعادة الحساب" + "recalculate": "إعادة الحساب", + "markDeviates": "يختلف", + "markPartlyUnrecorded": "غير مسجل جزئيًا", + "notRecorded": "غير مسجل" }, "duration": { "suffixDay": "ي", diff --git a/src/i18n/locales/ar/task.json b/src/i18n/locales/ar/task.json index 365ee72a8..5251c5055 100644 --- a/src/i18n/locales/ar/task.json +++ b/src/i18n/locales/ar/task.json @@ -100,6 +100,7 @@ "p6Provenance": "مصدر Primavera P6", "parentId": "معرّف المهمة الأصلية", "priority": "أولوية تسوية الموارد", + "recordedSource": "المصدر (التواريخ المسجلة)", "remainingMinutes": "المتبقي (بالدقائق)", "remainingTime": "المتبقي (أيام عمل)", "resourceIds": "معرّفات الموارد", @@ -341,6 +342,9 @@ "timephasedActive": "تتبع توزيع الساعات من MS Project", "timephasedLost": "لم يعد توزيع الساعات من MS Project مطبقًا بعد التعديل — يبقى التوزيع الأصلي محفوظًا في الملف", "timephasedContoured": "توزيع ساعات خاص", + "recordedDatesActive": "يعرض التواريخ الخاصة التي سجّلها Primavera لهذه المهمة", + "recordedDatesDeviates": "يختلف عن التواريخ المسجلة", + "recordedDatesPartlyUnrecorded": "التسجيل غير مكتمل جزئيًا — راجع أعمدة التأخر/الفائض", "completion": "التقدم (%)", "progress": { "actualStart": "البداية الفعلية", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index c459dd627..02aad159c 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -812,7 +812,10 @@ "activeCount_other": "Sie sehen den Terminplan so, wie Primavera ihn gespeichert hat; 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", diff --git a/src/i18n/locales/de/task.json b/src/i18n/locales/de/task.json index 6177e69af..54d0077ab 100644 --- a/src/i18n/locales/de/task.json +++ b/src/i18n/locales/de/task.json @@ -58,6 +58,7 @@ "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", "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,9 @@ "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", + "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 432552ec2..4f435479e 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -835,7 +835,10 @@ "activeCount_other": "You're viewing the schedule as Primavera recorded it; 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", diff --git a/src/i18n/locales/en/task.json b/src/i18n/locales/en/task.json index 5f25dcb9a..f4e6f667e 100644 --- a/src/i18n/locales/en/task.json +++ b/src/i18n/locales/en/task.json @@ -101,6 +101,7 @@ "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", @@ -377,6 +378,9 @@ "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", + "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 db7dc1026..88e1a2084 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -839,7 +839,10 @@ "activeCount_other": "Estás viendo la planificación tal como la registró Primavera; 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", diff --git a/src/i18n/locales/es/task.json b/src/i18n/locales/es/task.json index 986740236..33c790a5a 100644 --- a/src/i18n/locales/es/task.json +++ b/src/i18n/locales/es/task.json @@ -58,6 +58,7 @@ "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", "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,9 @@ "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", + "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 b3524ae67..218c3efcf 100644 --- a/src/i18n/locales/fa/common.json +++ b/src/i18n/locales/fa/common.json @@ -812,7 +812,10 @@ "activeCount_other": "شما برنامه را همان‌طور که Primavera ثبت کرده مشاهده می‌کنید؛ محاسبه مجدد {{count}} فعالیت را جابه‌جا می‌کند.", "show": "نمایش تاریخ‌های ثبت‌شده", "active": "شما تاریخ‌ها را همان‌طور که در فایل ثبت شده مشاهده می‌کنید. محاسبه‌ای دوباره انجام نشده است.", - "recalculate": "محاسبه مجدد" + "recalculate": "محاسبه مجدد", + "markDeviates": "متفاوت", + "markPartlyUnrecorded": "تا حدی ثبت‌نشده", + "notRecorded": "ثبت‌نشده" }, "duration": { "suffixDay": "ر", diff --git a/src/i18n/locales/fa/task.json b/src/i18n/locales/fa/task.json index a16023b01..8eb0b2f92 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":"مبتنی بر تلاش","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":"اولویت تسطیح","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,9 @@ "timephasedActive": "توزیع ساعات MS Project را دنبال می‌کند", "timephasedLost": "توزیع ساعات MS Project پس از ویرایش دیگر اعمال نمی‌شود — توزیع اصلی در فایل نگه داشته می‌شود", "timephasedContoured": "توزیع ساعات مخصوص", + "recordedDatesActive": "تاریخ‌های ثبت‌شده خود Primavera را برای این فعالیت نشان می‌دهد", + "recordedDatesDeviates": "با تاریخ‌های ثبت‌شده تفاوت دارد", + "recordedDatesPartlyUnrecorded": "ثبت داده تا حدی ناقص است — به ستون‌های دیرترین/شناوری مراجعه کنید", "completion": "پیشرفت (%)", "progress": { "actualStart": "شروع واقعی", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 09d51fc1d..3ae4c3002 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -839,7 +839,10 @@ "activeCount_other": "Vous consultez le planning tel qu'enregistré par Primavera ; 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", diff --git a/src/i18n/locales/fr/task.json b/src/i18n/locales/fr/task.json index dc16c8efd..35e8184c3 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","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","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,9 @@ "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", + "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 5e31db15f..9d63034e5 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -839,7 +839,10 @@ "activeCount_other": "Stai visualizzando la pianificazione come registrata da Primavera; 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", diff --git a/src/i18n/locales/it/task.json b/src/i18n/locales/it/task.json index 8a02b7c83..e67028670 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","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","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,9 @@ "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à", + "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 4ae4abe19..07bb8f6a2 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -785,7 +785,10 @@ "activeCount_other": "Primavera が記録したスケジュールを表示しています。再計算すると {{count}} 件のタスクがずれます。", "show": "記録された日付を表示", "active": "ファイルに記録されている日付をそのまま表示しています。再計算は行われていません。", - "recalculate": "再計算" + "recalculate": "再計算", + "markDeviates": "乖離あり", + "markPartlyUnrecorded": "一部未記録", + "notRecorded": "未記録" }, "duration": { "suffixDay": "日", diff --git a/src/i18n/locales/ja/task.json b/src/i18n/locales/ja/task.json index d3589d274..927f51b7d 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":"作業時間主導","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":"平準化優先度","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,9 @@ "timephasedActive": "MS Project の時間配分に従っています", "timephasedLost": "編集後、MS Project の時間配分は適用されなくなりました — 元の配分はファイルに保存されたままです", "timephasedContoured": "独自の時間配分", + "recordedDatesActive": "このタスクについて Primavera 自身が記録した日付を表示します", + "recordedDatesDeviates": "記録された日付と乖離しています", + "recordedDatesPartlyUnrecorded": "記録データが一部不完全です — 最遅/余裕列を参照してください", "completion": "進捗(%)", "progress": { "actualStart": "実績開始", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 916cbcb94..cd52fc5a0 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -785,7 +785,10 @@ "activeCount_other": "Primavera에 기록된 일정을 보고 있습니다. 다시 계산하면 {{count}}개의 작업이 이동합니다.", "show": "기록된 날짜 표시", "active": "파일에 기록된 날짜를 그대로 보고 있습니다. 다시 계산되지 않았습니다.", - "recalculate": "다시 계산" + "recalculate": "다시 계산", + "markDeviates": "차이 있음", + "markPartlyUnrecorded": "일부 미기록", + "notRecorded": "미기록" }, "duration": { "suffixDay": "일", diff --git a/src/i18n/locales/ko/task.json b/src/i18n/locales/ko/task.json index f38f86253..f328c2372 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":"작업량 기준","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":"평준화 우선순위","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,9 @@ "timephasedActive": "MS Project의 시간 분배를 따릅니다", "timephasedLost": "편집 후 MS Project의 시간 분배가 더 이상 적용되지 않습니다 — 원래 분배는 파일에 그대로 보존됩니다", "timephasedContoured": "고유 시간 분배", + "recordedDatesActive": "이 작업에 대해 Primavera가 기록한 고유 날짜를 표시합니다", + "recordedDatesDeviates": "기록된 날짜와 차이가 있습니다", + "recordedDatesPartlyUnrecorded": "기록이 일부 불완전합니다 — 최지연/여유 열을 참조하세요", "completion": "진행률 (%)", "progress": { "actualStart": "실제 시작", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 8261b3db4..f09e15f0e 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -835,7 +835,10 @@ "activeCount_other": "Je ziet de planning zoals Primavera hem opsloeg; 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", diff --git a/src/i18n/locales/nl/task.json b/src/i18n/locales/nl/task.json index f0fe13bdf..01a44f943 100644 --- a/src/i18n/locales/nl/task.json +++ b/src/i18n/locales/nl/task.json @@ -101,6 +101,7 @@ "p6Provenance": "Primavera P6-herkomst", "parentId": "Bovenliggende taak-id", "priority": "Nivelleerprioriteit", + "recordedSource": "Herkomst (opgeslagen datums)", "remainingMinutes": "Resterend (minuten)", "remainingTime": "Resterend (werkdagen)", "resourceIds": "Resource-id’s", @@ -377,6 +378,9 @@ "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", + "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 f3c789289..a00ac4aa2 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -866,7 +866,10 @@ "activeCount_other": "Widzisz harmonogram tak, jak zapisał go Primavera; 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", diff --git a/src/i18n/locales/pl/task.json b/src/i18n/locales/pl/task.json index 5e29e97bd..43e8a183f 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","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","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,9 @@ "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", + "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 7b8dd9aba..230624f43 100644 --- a/src/i18n/locales/pt/common.json +++ b/src/i18n/locales/pt/common.json @@ -839,7 +839,10 @@ "activeCount_other": "Está a ver o planeamento tal como o Primavera o registou; 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", diff --git a/src/i18n/locales/pt/task.json b/src/i18n/locales/pt/task.json index 26fcadb19..a720903ad 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","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","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,9 @@ "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", + "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 70348591c..a9183628a 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -812,7 +812,10 @@ "activeCount_other": "Planı Primavera'nın kaydettiğ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", diff --git a/src/i18n/locales/tr/task.json b/src/i18n/locales/tr/task.json index 2d181250e..79e2b8add 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ı","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","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,9 @@ "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", + "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 ed757920f..83b84d7fa 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -785,7 +785,10 @@ "activeCount_other": "你正在查看 Primavera 记录的进度计划;重新计算将使 {{count}} 个任务偏移。", "show": "显示记录的日期", "active": "你正在查看文件中记录的日期,尚未重新计算。", - "recalculate": "重新计算" + "recalculate": "重新计算", + "markDeviates": "偏离", + "markPartlyUnrecorded": "部分未记录", + "notRecorded": "未记录" }, "duration": { "suffixDay": "天", diff --git a/src/i18n/locales/zh/task.json b/src/i18n/locales/zh/task.json index 440073e7d..6b7f5377b 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":"投入量驱动","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":"资源调配优先级","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,9 @@ "timephasedActive": "跟随 MS Project 的工时分布", "timephasedLost": "编辑后不再应用 MS Project 的工时分布——原始分布仍保留在文件中", "timephasedContoured": "自定义工时分布", + "recordedDatesActive": "显示 Primavera 为此任务记录的日期", + "recordedDatesDeviates": "偏离记录的日期", + "recordedDatesPartlyUnrecorded": "记录数据部分不完整——请查看最迟/时差列", "completion": "进度(%)", "progress": { "actualStart": "实际开始", diff --git a/src/state/gridTransaction.ts b/src/state/gridTransaction.ts index 8fb6c4200..43fe3ec5c 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 { recordedTaskMark } 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: recordedDates ? (task => recordedTaskMark(recordedDates, datesAsRecorded, task)) : undefined, }; const descriptors = buildTaskColumnRegistry({ projectId: state.project.id, diff --git a/src/state/recordedDatesSelectors.ts b/src/state/recordedDatesSelectors.ts new file mode 100644 index 000000000..05e50cb1d --- /dev/null +++ b/src/state/recordedDatesSelectors.ts @@ -0,0 +1,71 @@ +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; +} + +/** + * 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. Dat is een eigenschap van het BESTAND, + * niet van de weergavestand, dus dit geldt zowel in als buiten de modus. + * 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 && (task.time.earlyStart !== rec.start || task.time.earlyFinish !== rec.finish)) { + return 'deviates'; + } + return unrecordedAxes(rec).length > 0 ? 'partly-unrecorded' : undefined; +} 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/tests/planning/check-recorded-dates-mark.ts b/tests/planning/check-recorded-dates-mark.ts new file mode 100644 index 000000000..74e17cc0c --- /dev/null +++ b/tests/planning/check-recorded-dates-mark.ts @@ -0,0 +1,214 @@ +/** + * "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 { recordedTaskMark, unrecordedAxes } from '@/state/recordedDatesSelectors'; +import { buildTaskColumnRegistry } from '@/engine/taskGrid/taskColumnRegistry'; +import { buildTaskRelationIndex } from '@/engine/taskGrid/relationIndex'; + +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'); + +eq('buiten de modus, identieke vroege datums maar onvolledige vastlegging ⇒ partly-unrecorded', + recordedTaskMark( + recordedState({ 't-1': { ...fullRecord, lateFinish: undefined } }), false, + taskWith({ earlyStart: fullRecord.start, earlyFinish: fullRecord.finish }), + ), 'partly-unrecorded'); + +eq('buiten de modus: deviates wint van partly-unrecorded wanneer beide gelden', + 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'); + +// 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); + +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/run.sh b/tests/planning/run.sh index 18acbb22b..d9318f8cf 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -1320,6 +1320,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) ─────────────────── From c2824586283af9581df080b8f788b71968b92f40 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:23:34 +0200 Subject: [PATCH 133/171] docs(xer): "datums zoals Primavera ze opsloeg" in de XER-gids en de #63-gids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Taak T7 (XER-etappeplan laag 3). Nieuwe sectie in gids-xer-import.md (nl+en): wat je bij het openen ziet zodra de modus standaard aangaat (T4), de melding met het aantal, de markering in tabel en eigenschappenpaneel (T6), wat bewerken/F5 doet, dat de berekening zelf nooit Primavera's opgeslagen datums als invoer gebruikt, en dat opslaan als IFC de weergave in het projectbestand bewaart — voorzichtig geformuleerd, vooruitlopend op de IFC-round-trip (T5). datums-zoals-opgeslagen.md (nl+en) krijgt twee secties: het XER-specifieke gedrag (deze weergave kan vanzelf aangaan, in tegenstelling tot de knop-route bij andere formaten) en de uitleg van "niet vastgelegd" (welke vier assen, en waarom dat geen foutmelding is). Beide artikelen kruisverwijzen naar elkaar via docs://. Binnen de miniMarkdown-subset (geen tabellen/blockquotes/h4/HTML, alleen docs://-links). verify:docs groen (33 artikelen × 14 talen). Mutatiebewijs: het tijdelijk verwijderen van public/docs/en/datums-zoals-opgeslagen.md zette verify:docs ROOD (exit 1, "ontbreekt: public/docs/en/datums-zoals-opgeslagen.md") — bevestigd en teruggedraaid. Co-Authored-By: Claude Fable 5.1 --- public/docs/en/datums-zoals-opgeslagen.md | 26 +++++++++++++++++++++ public/docs/en/gids-xer-import.md | 26 +++++++++++++++++++++ public/docs/nl/datums-zoals-opgeslagen.md | 27 ++++++++++++++++++++++ public/docs/nl/gids-xer-import.md | 28 +++++++++++++++++++++++ 4 files changed, 107 insertions(+) diff --git a/public/docs/en/datums-zoals-opgeslagen.md b/public/docs/en/datums-zoals-opgeslagen.md index a94feeaab..72d257870 100644 --- a/public/docs/en/datums-zoals-opgeslagen.md +++ b/public/docs/en/datums-zoals-opgeslagen.md @@ -70,9 +70,35 @@ 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. + +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. Recalculate (by editing, or with **F5**), and the column fills +in with the app's own calculation. + ## 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-xer-import.md b/public/docs/en/gids-xer-import.md index 14b8cbea0..68a2e2782 100644 --- a/public/docs/en/gids-xer-import.md +++ b/public/docs/en/gids-xer-import.md @@ -8,6 +8,7 @@ A `.xer` file is Primavera P6's exchange format. Open Planner Studio can open it - 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 @@ -38,6 +39,31 @@ XER does not reliably declare its text encoding in the file. A UTF BOM is follow 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 keeps this view — including which activities deviated and which +axes were not recorded — stored in the project file. + +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. diff --git a/public/docs/nl/datums-zoals-opgeslagen.md b/public/docs/nl/datums-zoals-opgeslagen.md index ff13edfed..3431b090e 100644 --- a/public/docs/nl/datums-zoals-opgeslagen.md +++ b/public/docs/nl/datums-zoals-opgeslagen.md @@ -73,9 +73,36 @@ 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. + +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. Zodra je herberekent (bewerken of +**F5**), vult de kolom zich met de eigen berekening van de app. + ## 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-xer-import.md b/public/docs/nl/gids-xer-import.md index b6d69f1f5..618564a1b 100644 --- a/public/docs/nl/gids-xer-import.md +++ b/public/docs/nl/gids-xer-import.md @@ -8,6 +8,7 @@ Een `.xer`-bestand is het uitwisselingsformaat van Primavera P6. Open Planner St - 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 @@ -38,6 +39,33 @@ XER noemt zijn tekencodering niet betrouwbaar in het bestand. Een UTF-BOM wordt 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 blijft +deze weergave — inclusief welke activiteiten afweken en welke assen niet vastgelegd waren — bewaard in +het projectbestand. + +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. From e663d2f7b773a4ac54d0dab34fb1aba1b2dae902 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:44:44 +0200 Subject: [PATCH 134/171] feat(xer): recordedTimes overleven IFC-opslaan/heropenen en crashherstel (T5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De bak-4-vastlegging ("datums zoals opgeslagen", laag 3) reisde tot nu toe alleen mee op de directe XER-open. Sla je zo'n document als IFC op en open je het weer, dan viel `captureRecordedDates` terug op de IFC-`recordedFields`-route en presenteerde ONZE herberekening als "wat het bestand opsloeg" — de gebruikersval uit §5.4 van het etappeplan, óók op het crashherstelpad. Ontwerpkeuze (plan §3.8 hield twee routes open): de ARCHIEFROUTE, geen eigen `OPS_`-pset. De reconstructie van het compacte schema-2-bronarchief draaide al een volledige `readXER` over sha256-geverifieerde bytes en gooide `recordedTimes` alleen weg. Door de bestaande injectienaad te verbreden van `XerSourceArchive` naar `XerSourceReconstruction` (archief + `recordedTimesByProject`) is de vastlegging van een heropend document per constructie identiek aan die van het origineel: zelfde code, zelfde bytes, zelfde kalenderpromotie en getalnotatie. Geen tweede afleiding in de IFC-laag, geen `parseXerNumber` in de hoofdbundel (gemeten: `ifcReader` trekt `xerTables.ts` nog steeds niet mee), geen wijziging aan `DOCUMENT_FIELDS`/`IFC_SAVE_KEYS`/`sameIFCSource`, en het werkt óók wanneer er BUITEN de modus wordt opgeslagen — een pset had `recordedDates` moeten meeschrijven, en dat veld wist `runCPM` juist bij het verlaten van de modus. Id-matching is geen probleem: XER-taak-id's zijn de rauwe `task_id`-cellen en `OPS_TaskIdentity` draagt exact die id's door de opslag heen. Bekende grens (gedocumenteerd bij `XerArchiveReconstructor`): historische schema-1-archieven krijgen geen `recordedTimes` terug — dat pad loopt bewust zonder XER-chunk en het schema-1-leesmodel draagt de vastlegging niet zelf. Nieuwe poort `tests/planning/check-xer-recorded-roundtrip.ts` (geregistreerd in run.sh) draait de echte keten writeIFC -> readIFCWithXerReconstruction -> recoveryStore -> restoreDocuments: identieke vastlegging in én buiten de modus, per project bij een meerprojectenbestand, geen vastlegging zonder XER-archief, en een gemuteerde orakelcel verplaatst de weergave WEL maar het cpmResult na runCPM NIET. --- src/services/formatRegistry.ts | 4 +- src/services/ifc/ifcReader.ts | 86 +++- src/services/xer/xerReader.ts | 29 +- src/services/xerSourceArchive.ts | 25 + .../planning/check-xer-recorded-roundtrip.ts | 426 ++++++++++++++++++ tests/planning/run.sh | 11 + tests/planning/xerArchiveTestReader.ts | 4 +- 7 files changed, 559 insertions(+), 26 deletions(-) create mode 100644 tests/planning/check-xer-recorded-roundtrip.ts diff --git a/src/services/formatRegistry.ts b/src/services/formatRegistry.ts index 47568366e..3c58fa8ee 100644 --- a/src/services/formatRegistry.ts +++ b/src/services/formatRegistry.ts @@ -54,8 +54,8 @@ export async function readIFCWithXerReconstruction( labels: ImportLabels = {}, ): Promise { if (content.includes(XER_SOURCE_ARCHIVE_COMPACT_STORAGE_FORMAT)) { - const { reconstructXerSourceArchiveFromBytes } = await import('@/services/xer/xerReader'); - return readIFC(content, labels, { reconstructXerArchive: reconstructXerSourceArchiveFromBytes }); + const { reconstructXerSourceFromBytes } = await import('@/services/xer/xerReader'); + return readIFC(content, labels, { reconstructXerArchive: reconstructXerSourceFromBytes }); } return readIFC(content, labels); } diff --git a/src/services/ifc/ifcReader.ts b/src/services/ifc/ifcReader.ts index 440881383..845759c37 100644 --- a/src/services/ifc/ifcReader.ts +++ b/src/services/ifc/ifcReader.ts @@ -34,6 +34,7 @@ import { 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 { canonicalizeBands, clockToMinutes, getCalendarBands, hasNonAnchorTime, isoDurationToMinutes, @@ -46,9 +47,39 @@ 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. */ -export type XerArchiveReconstructor = (bytes: Uint8Array) => XerSourceArchive; +/** + * 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 @@ -137,9 +168,19 @@ export function readIFC( // Extract project const project = extractProject(entities, entityMap, labels); - const xerSourceArchive = extractXerSourceArchive(entities, entityMap, options.reconstructXerArchive); + 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). @@ -232,6 +273,7 @@ export function readIFC( baselines, activeBaselineId, libraryPool: libraryPoolOut.value, recordedFields, + ...(recordedTimes ? { recordedTimes, recordedTimesOrigin: 'xer' as const } : {}), ...(xerSourceArchive ? { xerSourceArchive } : {}), ...(xerSourceProjectId ? { xerSourceProjectId } : {}), ...(xer ? { xer } : {}), @@ -375,12 +417,14 @@ function concatArchiveChunks(props: Map, prefix: string, count: return output; } -/** Lees en valideer vóór allocatie de self-contained X9-container; afwezig blijft legacy-compatibel. */ +/** 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, -): XerSourceArchive | undefined { +): XerSourceReconstruction | undefined { const props = archiveProps(entities, entityMap, PSET.XerSourceArchive); if (!props) return undefined; const schemaVersion = nonNegativeSafeInteger(props.get('SchemaVersion'), 'SchemaVersion'); @@ -423,14 +467,19 @@ function extractXerSourceArchive( 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 { - return createXerSourceArchiveFromOwnedMetadata(sourceBytes, { - schemaVersion, - encoding: encoding as XerSourceArchiveEncoding, - bom: bom as XerSourceArchiveBom, - newline: newline as XerSourceArchiveNewline, - diagnostics: archiveMetadata.diagnostics, - readModel: archiveMetadata.readModel, - }); + // 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)}`); } @@ -440,7 +489,7 @@ function extractXerSourceArchive( function extractCompactXerSourceArchive( props: Map, reconstructXerArchive: XerArchiveReconstructor | undefined, -): XerSourceArchive { +): 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'); @@ -466,11 +515,14 @@ function extractCompactXerSourceArchive( 'laadt de XER-reader bewust niet zelf', ); } - const archive = reconstructXerArchive(sourceBytes); + const reconstruction = reconstructXerArchive(sourceBytes); + const archive = reconstruction.archive; if (archive.sha256 !== sourceHash || archive.byteLength !== byteLength) { xerArchiveError('gereconstrueerd archief past niet bij de canonieke bronbytes'); } - return archive; + // 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)}`); diff --git a/src/services/xer/xerReader.ts b/src/services/xer/xerReader.ts index a34529bb7..0a63baa7c 100644 --- a/src/services/xer/xerReader.ts +++ b/src/services/xer/xerReader.ts @@ -36,8 +36,9 @@ import { bindXerImportMetadataToArchive, createXerSourceArchiveFromOwnedMetadata, detectXerSourcePresentation, - type XerSourceArchive, + type XerSourceReconstruction, } from '@/services/xerSourceArchive'; +import type { RecordedTime } from '@/engine/scheduler/recordedDates'; import { readXerCalendars } from './xerCalendarData'; import { sourceInstant } from './xerInstant'; import { readXerRecordedTimes } from './xerRecordedTimes'; @@ -1101,12 +1102,30 @@ export function readXER(bytes: Uint8Array): XerOpenResult { ); } -/** Herbouw de volledige X9-runtimegrafiek uit uitsluitend de canonieke XER-bronbytes. */ -export function reconstructXerSourceArchiveFromBytes(bytes: Uint8Array): XerSourceArchive { +/** + * 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 first = 'kind' in opened ? opened.results[0] : opened; + 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.'); } - return first.xerSourceArchive; + 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/xerSourceArchive.ts b/src/services/xerSourceArchive.ts index 27210da14..586b3af03 100644 --- a/src/services/xerSourceArchive.ts +++ b/src/services/xerSourceArchive.ts @@ -12,6 +12,7 @@ import type { 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'; @@ -122,6 +123,30 @@ export interface XerSourceArchive { 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'; diff --git a/tests/planning/check-xer-recorded-roundtrip.ts b/tests/planning/check-xer-recorded-roundtrip.ts new file mode 100644 index 000000000..1388ad725 --- /dev/null +++ b/tests/planning/check-xer-recorded-roundtrip.ts @@ -0,0 +1,426 @@ +/** + * 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. `recordedTimesOrigin` — de vlag die het standaard-aan-beleid van taak T4 stuurt — reist mee, + * dus een heropend XER-document neemt dezelfde beslissing als het origineel. + * 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. Het crashherstelpad (`recoveryStore` → `readIFCWithXerReconstruction` → `restoreDocuments`) + * levert dezelfde vastlegging als het gewone openen. + * 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. + * + * WAT DIT BEWUST NIET DOET: de store-vlag `datesAsRecorded` zelf wordt door taak T4 + * (`fileSlice.applyLoadedProject`) gezet en is op deze basis nog niet bedraad. Deze check toetst de + * volledige INVOER van dat besluit (`recordedTimes` + `recordedTimesOrigin` + de shifted-telling) en + * spiegelt de T4-beslisregel in `modeVerdict()` hieronder, zodat de gelijkheid vóór/ná de + * round-trip nu al vastligt. + */ +import { captureRecordedDates, countShiftedTasks, applyRecordedTimesToTasks } from '@/engine/scheduler/recordedDates'; +import type { RecordedTime } from '@/engine/scheduler/recordedDates'; +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; +} + +/** De T4-beslisregel, hier gespiegeld: alleen de bron-orakelroute opent meteen ín de modus, en + * alleen wanneer de herberekening daadwerkelijk iets verschoof. */ +function modeVerdict(parsed: ImportResult, solvedTasks: ImportResult['tasks']): { origin: string | undefined; total: number; shifted: number; mode: boolean } { + const recorded = captureRecordedDates(parsed.tasks, parsed.recordedFields, parsed.recordedTimes); + const shifted = countShiftedTasks(solvedTasks, recorded.times); + return { + origin: parsed.recordedTimesOrigin, + total: recorded.total, + shifted, + mode: parsed.recordedTimesOrigin === 'xer' && recorded.total > 0 && shifted > 0, + }; +} + +// ── 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'); + +// ── 2. Opslaan BUITEN de modus: `task.time` draagt onze herberekening ──────────────────────── +const ifcOutsideMode = writeIFC(buildWriteIFCInput(basePayload)); +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 dezelfde herkomst', reopenedOutside.recordedTimesOrigin, 'xer'); +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` ──────────────────────────────── +const inModePayload = { + ...basePayload, + tasks: basePayload.tasks.map(t => ({ ...t, time: { ...t.time } })), +}; +applyRecordedTimesToTasks(inModePayload.tasks, originalTimes, inModePayload.calendar); +const ifcInMode = writeIFC(buildWriteIFCInput(inModePayload)); +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 dezelfde herkomst', reopenedInMode.recordedTimesOrigin, 'xer'); +expect('3c de twee opslagvormen verschillen echt (anders bewijst 3a niets)', + ifcInMode !== ifcOutsideMode); + +// ── 4. Het modusbesluit is identiek vóór en ná de round-trip ───────────────────────────────── +const verdictBefore = modeVerdict(original, basePayload.tasks); +store().newProject(); +store().applyOpenedImport(reopenedOutside, { + filePath: null, fileHandle: null, recompute: true, fit: false, hourDataNotice: false, linkedOpen: false, +}); +const reopenedPayload = store().getOpenDocumentPayloads()[0]!.payload; +const verdictAfter = modeVerdict(reopenedOutside, reopenedPayload.tasks); +eq('4a hetzelfde standaard-aan-besluit (origin/total/shifted/mode) vóór en ná opslaan+heropenen', + verdictAfter, verdictBefore); +expect('4b het besluit is niet triviaal: deze fixture verschuift echt taken', verdictBefore.mode); + +// ── 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: recoveryStore-round-trip ──────────────────────────────────────────────── +await clearRecovery(); +store().newProject(); +store().applyOpenedImport(original, { + filePath: null, fileHandle: null, recompute: true, fit: false, hourDataNotice: false, linkedOpen: false, +}); +const recoverySource = store().getOpenDocumentPayloads(); +await saveRecovery(fullRecoverySave(recoverySource[0]!.id, recoverySource.map(document => ({ + id: document.id, + ifc: writeIFC(buildWriteIFCInput(document.payload)), + filePath: null, + isDirty: true, +})))); +const loaded = await loadRecovery(); +expect('6a recoveryStore levert de snapshot terug', loaded.docs.length === recoverySource.length); +const recoveredParsed = loaded.docs.map(d => readXerArchiveIFC(d.ifc)); +eq('6b hersteld document draagt dezelfde vastlegging als het gewone openen', + recoveredParsed[0]?.recordedTimes, originalTimes); +eq('6c hersteld document draagt dezelfde herkomst', recoveredParsed[0]?.recordedTimesOrigin, 'xer'); +const recoveryInputs = loaded.docs.map((d, i) => recoveryInputFromParsed( + recoveredParsed[i]!, { id: d.id, filePath: null, isDirty: true }, +)); +store().restoreDocuments(recoveryInputs, recoveryInputs[0]!.id); +const restored = store().getOpenDocumentPayloads()[0]!.payload; +eq('6d na restoreDocuments is het modusbesluit gelijk aan dat van het gewone openen', + modeVerdict(recoveryInputs[0]!, restored.tasks), verdictBefore); +await clearRecovery(); + +// ── 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); + eq(`C-${label} herkomst identiek na opslaan+heropenen`, reopened.recordedTimesOrigin, parsed.recordedTimesOrigin); + 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/run.sh b/tests/planning/run.sh index d9318f8cf..264f2c697 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -316,6 +316,17 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then 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. diff --git a/tests/planning/xerArchiveTestReader.ts b/tests/planning/xerArchiveTestReader.ts index de10edfbf..c7c24c0fa 100644 --- a/tests/planning/xerArchiveTestReader.ts +++ b/tests/planning/xerArchiveTestReader.ts @@ -3,8 +3,8 @@ // reconstructor, zonder een verborgen module-initregistratie te herstellen. import { readIFC } from '@/services/ifc/ifcReader'; import type { ImportLabels, ImportResult } from '@/services/importTypes'; -import { reconstructXerSourceArchiveFromBytes } from '@/services/xer/xerReader'; +import { reconstructXerSourceFromBytes } from '@/services/xer/xerReader'; export function readXerArchiveIFC(content: string, labels: ImportLabels = {}): ImportResult { - return readIFC(content, labels, { reconstructXerArchive: reconstructXerSourceArchiveFromBytes }); + return readIFC(content, labels, { reconstructXerArchive: reconstructXerSourceFromBytes }); } From 141bc760a7e6af79d1fad0b5c3835bd3dae6b935 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:06:44 +0200 Subject: [PATCH 135/171] MCP: inspecteer retained XER-provenance (cherry picked from commit 38060ed407f5b2c4abe55ce1e282a7c6aceecb80) --- src/services/mcp/toolRegistry.ts | 2 + src/services/mcp/tools/readTools.ts | 20 +- src/services/mcp/tools/taskFields.ts | 4 + src/services/mcp/tools/xerProvenanceTools.ts | 393 +++++++++++++++++++ tests/mcp/cases-read.ts | 6 +- tests/mcp/cases-schemavalidatie.ts | 6 +- tests/mcp/cases-sync2-integration.ts | 6 +- tests/mcp/cases-xer-provenance.ts | 326 +++++++++++++++ 8 files changed, 753 insertions(+), 10 deletions(-) create mode 100644 src/services/mcp/tools/xerProvenanceTools.ts create mode 100644 tests/mcp/cases-xer-provenance.ts 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/readTools.ts b/src/services/mcp/tools/readTools.ts index e4991800e..5cbfe84b5 100644 --- a/src/services/mcp/tools/readTools.ts +++ b/src/services/mcp/tools/readTools.ts @@ -456,6 +456,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) @@ -465,6 +468,9 @@ 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. @@ -502,6 +508,14 @@ 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), @@ -1031,7 +1045,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 40cb7db39..b1e5a17e8 100644 --- a/src/services/mcp/tools/taskFields.ts +++ b/src/services/mcp/tools/taskFields.ts @@ -158,6 +158,10 @@ const REJECT_HINTS: Record = { 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/xerProvenanceTools.ts b/src/services/mcp/tools/xerProvenanceTools.ts new file mode 100644 index 000000000..77315468c --- /dev/null +++ b/src/services/mcp/tools/xerProvenanceTools.ts @@ -0,0 +1,393 @@ +// 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. + +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; +} + +class XerProvenanceError extends Error { + constructor(public readonly code: McpErrorCode, message: string) { + super(message); + } +} + +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']; + 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; +} + +function requirePage(args: XerProvenanceArgs, rawSource: boolean): { limit: number; offset: number } { + 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 > 1000) { + throw new XerProvenanceError('VALIDATION', '`limit` moet een geheel getal van 1 t/m 1000 zijn.'); + } + if (typeof offset !== 'number' || !Number.isInteger(offset) || offset < 0) { + throw new XerProvenanceError('VALIDATION', '`offset` moet een geheel getal ≥ 0 zijn.'); + } + // Een expliciete raw-source-call mag nooit ongemerkt een onbeperkte base64-respons worden. + // Boven deze grens moet de client nog een pagina opvragen. + if (rawSource && limit > 8) { + throw new XerProvenanceError('VALIDATION', '`rawSource` accepteert maximaal 8 chunks per antwoord; gebruik offset voor volgende pagina\'s.'); + } + 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; +} + +function page(items: readonly T[], args: XerProvenanceArgs, rawSource = false): { + items: T[]; + total: number; + has_more: boolean; + next_offset: number | null; +} { + const { limit, offset } = requirePage(args, rawSource); + const selected = items.slice(offset, offset + limit); + const next = offset + selected.length; + return { + // Do not publish aliases into the frozen archive. This is especially important for raw rows: + // an MCP caller can hold the structured result before transport serialization. + items: structuredClone(selected), + total: items.length, + has_more: next < items.length, + next_offset: next < items.length ? 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; +} + +function projectIds(archive: XerSourceArchive): string[] { + return Object.keys(archive.diagnostics.documentViews).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) { + return { + sourcePresent: false, + source: null, + selector: { currentProjectId: state.xerSourceProjectId, availableProjectIds: [] }, + note: 'Er is voor dit document geen retained XER-bronarchief beschikbaar.', + }; + } + 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); + return { + 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]), + ), + }, + }; +} + +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; + return { section: 'resourceCatalog', collection, ...page(values[collection], args) }; +} + +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, + }; + return { section: 'metadataCatalog', collection, ...page(values[collection] ?? [], args) }; +} + +function diagnostics(archive: XerSourceArchive, args: XerProvenanceArgs): unknown { + const file = archive.diagnostics.file; + const collection = collectionOf('diagnostics', args.collection, DIAGNOSTIC_COLLECTIONS, args.collection as Collection); + if (collection === 'importReport') { + if (args.limit !== undefined || args.offset !== undefined) { + throw new XerProvenanceError('VALIDATION', '`limit` en `offset` horen niet bij diagnostics/importReport.'); + } + return { section: 'diagnostics', collection, report: structuredClone(file.importReport) }; + } + 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, + documentViews: Object.entries(archive.diagnostics.documentViews).sort(([left], [right]) => left.localeCompare(right)).map(([projectId, view]) => ({ projectId, view })), + }; + const result = page(values[collection], args); + return { + section: 'diagnostics', + collection, + ...(collection === 'tableIssues' ? { encoding: file.tableReport.encoding, endMarkerSeen: file.tableReport.endMarkerSeen } : {}), + ...result, + }; +} + +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}.`); + } + return { + section: 'taskSourceRowsByProject', + projectId, + // XerArchiveSourceRowV1 retains every cell; this page applies no field or string truncation. + ...page(archive.readModel.taskSourceRowsByProject[projectId] ?? [], args), + }; +} + +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 = page(archive.byteChunks, args, true); + 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.items.map((base64, index) => ({ index: (typeof args.offset === 'number' ? args.offset : 0) + index, base64 })), + total: paged.total, + has_more: paged.has_more, + next_offset: paged.next_offset, + }; +} + +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.'); + } + 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 (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.' }, + offset: { type: 'number', description: 'Startindex; default 0.' }, + includeRawSource: { type: 'boolean', description: 'Verplicht true voor de expliciete rawSource-sectie.' }, + }, + 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 ' + + '(veilig: 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`. taskSourceRowsByProject vereist een expliciete ' + + '`projectId` en geeft alle cellen van die bronrijen terug zonder verborgen veld- of stringafkapping. ' + + 'rawSource vereist expliciet `includeRawSource:true`, geeft maximaal acht vaste base64-chunks per ' + + 'antwoord en meldt de privacygrens; summary lekt nooit raw bytes of vrije raw rows. De tool gebruikt ' + + 'alleen retained state, muteert de store niet, voert geen CPM uit en ondersteunt geen schrijfpad.', + kind: 'read', + batchable: true, + inputSchema, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + handler: (args, ctx) => readTool(ctx, (state) => inspect(state, args)), +}]; 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-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-xer-provenance.ts b/tests/mcp/cases-xer-provenance.ts new file mode 100644 index 000000000..deb2e9feb --- /dev/null +++ b/tests/mcp/cases-xer-provenance.ts @@ -0,0 +1,326 @@ +// Gerichte MCP-contracttests voor retained XER/P6-bronsemantiek. +// Alle bronwaarden zijn synthetisch; er wordt geen corpusnaam of pad gebruikt. +import { useAppStore, test, assert, assertEq, run } from './harness'; +import { getTool } from '@/services/mcp/toolRegistry'; +import type { McpContext, McpToolErr, McpToolOk, McpToolResult } from '@/services/mcp/contracts'; +import { + XER_SOURCE_ARCHIVE_CHUNK_BYTES, + createEmptyXerArchiveDiagnostics, + createEmptyXerArchiveReadModel, + type XerSourceArchive, +} from '@/services/xerSourceArchive'; +import { createDefaultTaskTime } from '@/utils/taskDefaults'; + +const S = () => useAppStore.getState(); +const TOOL = 'planner_inspect_xer_provenance'; + +function ctx(): McpContext { + return { + expectedDocId: null, + tempIdMap: new Map(), + paused: false, + readOnly: false, + ensureBackup: async () => null, + }; +} + +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(); +} + +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; + const longCell = 'synthetic-free-cell-' + 'x'.repeat(1500); + readModel.numberFormat = { decimal: ',', group: '.', source: 'currtype', currencyCode: 'EUR' }; + readModel.taskSourceRowsByProject = { + 'PROJ-A': [ + { line: 11, cells: { task_id: 'TASK-A', task_name: longCell, task_notes: 'synthetic note' } }, + { line: 12, cells: { task_id: 'TASK-B', task_name: 'Second synthetic task' } }, + ], + }; + readModel.resourceCatalog = { + resources: [{ id: 'resource-1', name: 'Synthetic Crew' }], + identities: [{ kind: 'RESOURCE', sourceId: 'R-1', internalId: 'resource-1', line: 20 }], + rows: { + resources: [{ sourceId: 'R-1', rawRow: { line: 20, cells: { rsrc_id: 'R-1', rsrc_name: 'Synthetic Crew' } } }], + roles: [{ sourceId: 'ROLE-1', rawRow: { line: 21, cells: { role_id: 'ROLE-1', role_name: 'Synthetic Role' } } }], + rates: [{ sourceId: 'R-1', rawRow: { line: 22, cells: { rsrc_id: 'R-1' } } }], + curves: [{ sourceId: 'CURVE-1', 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: 'Synthetic phase', type: 'text' }], + taskProjections: [{ projectId: 'PROJ-A', taskId: 'TASK-A', activityCodes: { 'ACTIVITY-TYPE-1': 'ACTIVITY-VALUE-1' }, customFields: { 'UDF-1': 'Phase A' } }], + 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: [{ line: 32, cells: { task_id: 'TASK-A' } }], + 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 } }; + 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 batchable', () => { + const tool = getTool(TOOL); + assert(!!tool, 'XER-provenance-tool geregistreerd'); + assertEq(tool!.kind, 'read', 'kind read'); + assertEq(tool!.batchable, true, 'batchable'); + 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('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('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('taskSourceRowsByProject: expliciete selector en geen verborgen veld/string-truncatie', () => { + attachArchive(); + const first = ok(TOOL, { section: 'taskSourceRowsByProject', projectId: 'PROJ-A', limit: 1 }); + assertEq(first.total, 2, 'total task source rows'); + assertEq(first.items.length, 1, 'eerste pagina'); + assertEq(first.items[0].cells.task_name.length, 1520, 'volledige synthetische raw cel'); + const second = ok(TOOL, { section: 'taskSourceRowsByProject', projectId: 'PROJ-A', limit: 1, offset: first.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'); +}); + +test('diagnostics: archive-, unknown-table-, import-report- en documentviewinformatie', () => { + attachArchive(); + for (const collection of ['tableIssues', 'unknownTables', 'unknownFields', 'scheduleOptions', 'relationResolutionIssues', 'resourceCatalogIssues', 'metadataCatalogIssues', 'documentViews']) { + const data = ok(TOOL, { section: 'diagnostics', collection, limit: 1 }); + assertEq(data.total, 1, `${collection}: total`); + assertEq(data.items.length, 1, `${collection}: 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 en planner_batch gebruiken dezelfde runtimevalidatie zonder storemutatie', async () => { + 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'); + const batch = await callAsync('planner_batch', { steps: [{ tool: TOOL, args: { section: 'taskSourceRowsByProject', projectId: 'PROJ-A', limit: 1 } }] }); + assert(batch.ok, 'geldige provenance-call in batch'); + const batchData = (batch as McpToolOk).data as any; + assertEq(batchData.steps[0].data.items.length, 1, 'batch leest dezelfde pagina'); + const badBatch = await callAsync('planner_batch', { steps: [{ tool: TOOL, args: { unexpected: true } }] }); + assertEq(badBatch.ok, false, 'ongeldige provenance-call in batch faalt hard'); + assertEq((badBatch as McpToolErr).code, 'VALIDATION', 'batch validation code'); + assertEq(JSON.stringify({ tasks: S().tasks, sequences: S().sequences, archive: S().xerSourceArchive }), before, 'geen storemutatie door lees- of batchpad'); +}); + +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'); +}); + +await run(); From 9c883429442c3d925a69110cbbcebd67510a8d0c Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:54:15 +0200 Subject: [PATCH 136/171] fix(mcp): XER-provenance niet batchable, vrije bronrijen achter opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewbevindingen uit review-mcp-read.md (P1, twee stuks): - planner_inspect_xer_provenance stond op batchable:true. Een planner_batch met alleen deze tool liep zo via de mutatie-executor: altijd een transactie, altijd runCPM/recomputeViewRows/recomputeResourceLoad — in strijd met de eigen "voert geen CPM uit"-belofte. Nu batchable:false, zodat checkExclusions (batchTool.ts) de stap al vóór enige transactie met VALIDATION afwijst. - resourceCatalog/metadataCatalog/taskSourceRowsByProject gaven ruwe XER-cellen (namen, notities, willekeurige kolommen) zonder enige opt-in en zonder limiet per cel/rij/respons terug. Nieuw: `includeRawRows` (alleen geldig bij die drie sections). Zonder opt-in levert elke rij alleen `line`+`fieldCount`, geen cellen. Met opt-in: per-cel afkapping op 2.000 tekens, een verlaagde paginalimiet (100 i.p.v. 1000, zoals rawSource al deed met 8 chunks) en een responsgrens van 256 kB die met een pagineerhint faalt. Tests in cases-xer-provenance.ts uitgebreid: batch-uitsluiting (geldige én ongeldige args), default-projectie zonder cellen, afkapping bij opt-in, en een losse responsgrens-fixture (100 rijen × 3 grote cellen) die de 256 kB-grens bewust raakt. Alle vier nieuwe gedragingen zijn mutatiebewezen (fix uit ⇒ check rood): batchable terug naar true, projectSourceRow zonder gating, en finalizeBounded weggehaald gaven elk de verwachte faalregel. (cherry picked from commit 4ab7c9ed85c1a3e088b46759baf5f308999fb9a6) --- src/services/mcp/tools/xerProvenanceTools.ts | 169 ++++++++++++++++--- tests/mcp/cases-xer-provenance.ts | 161 ++++++++++++++---- 2 files changed, 273 insertions(+), 57 deletions(-) diff --git a/src/services/mcp/tools/xerProvenanceTools.ts b/src/services/mcp/tools/xerProvenanceTools.ts index 77315468c..3e09dbd92 100644 --- a/src/services/mcp/tools/xerProvenanceTools.ts +++ b/src/services/mcp/tools/xerProvenanceTools.ts @@ -39,6 +39,72 @@ interface XerProvenanceArgs { limit?: unknown; offset?: unknown; includeRawSource?: unknown; + includeRawRows?: unknown; +} + +/** Sections waarvan een collectie/rij vrije brontekst (namen, notities, willekeurige XER-kolommen) + * kan dragen. Zonder `includeRawRows` geven deze een tel-projectie zonder celwaarden terug — de + * tweede expliciete opt-in naast `section`/`collection`, analoog aan `rawSource`/`includeRawSource`. */ +const RAW_ROWS_SECTIONS: readonly Section[] = ['resourceCatalog', 'metadataCatalog', 'taskSourceRowsByProject']; + +/** Binnen `resourceCatalog` de collecties die een `rawRow`-bronrij dragen; de rest (resources, + * identities, issues) is al een genormaliseerde, begrensde projectie. */ +const RAW_ROW_RESOURCE_COLLECTIONS = new Set(['resourceSources', 'roleSources', 'rates', 'curves', 'assignmentSources']); + +/** Binnen `metadataCatalog` de collecties die letterlijk retained bronrijen zijn (`catalog.sourceData`); + * de rest (activityCodeTypes, customFieldDefs, taskProjections, issues) is al genormaliseerd. */ +const RAW_ROW_METADATA_COLLECTIONS = new Set([ + 'ACTVTYPE', 'ACTVCODE', 'TASKACTV', 'UDFTYPE', 'UDFVALUE', 'MEMOTYPE', 'TASKNOTE', 'TASKMEMO', + 'TASK_NOTES', 'deferredUdfValues', 'unknownUdfTypes', +]); + +/** 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: een enkele vrije XER-kolom (notitie, naam) mag de respons niet + * onbegrensd laten groeien. */ +const RAW_CELL_MAX_CHARS = 2000; +/** Harde bovengrens op de totale geserialiseerde paginarespons; een backstop naast de rij- en + * celgrenzen voor het geval veel kleinere cellen samen toch groot worden. */ +const MAX_SECTION_RESPONSE_BYTES = 256 * 1024; + +interface RawSourceRowLike { + readonly line: number; + readonly cells: Readonly>; +} + +function truncateCell(value: string): string { + if (value.length <= RAW_CELL_MAX_CHARS) return value; + return `${value.slice(0, RAW_CELL_MAX_CHARS)}…(afgekapt op ${RAW_CELL_MAX_CHARS} tekens)`; +} + +/** Zonder opt-in: alleen line + celaantal, geen enkele vrije waarde. Met opt-in: volledige cellen, + * elk individueel afgekapt. */ +function projectSourceRow(row: RawSourceRowLike, includeRawRows: boolean): unknown { + if (!includeRawRows) { + return { line: row.line, fieldCount: Object.keys(row.cells).length }; + } + const cells: Record = {}; + for (const [field, value] of Object.entries(row.cells)) cells[field] = truncateCell(value); + return { line: row.line, cells }; +} + +/** Zelfde projectie voor een item dat een `rawRow` draagt naast eigen (niet-vrije, id-achtige) velden. */ +function projectRawRowBearingItem(item: Record, includeRawRows: boolean): unknown { + const { rawRow, ...rest } = item; + return { ...rest, rawRow: projectSourceRow(rawRow as RawSourceRowLike, includeRawRows) }; +} + +/** Responsgrens-backstop: gooi een typed fout met een pagineerhint in plaats van een onbegrensde + * serialisatie toe te staan. */ +function finalizeBounded>(result: T): T { + if (JSON.stringify(result).length > 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; } class XerProvenanceError extends Error { @@ -72,7 +138,7 @@ function isObject(value: unknown): value is Record { 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']; + 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(', ')}.`); @@ -81,20 +147,34 @@ function requireOnlyKeys(args: unknown): XerProvenanceArgs { return args; } -function requirePage(args: XerProvenanceArgs, rawSource: boolean): { limit: number; offset: number } { +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 > 1000) { - throw new XerProvenanceError('VALIDATION', '`limit` moet een geheel getal van 1 t/m 1000 zijn.'); + 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.'); } - // Een expliciete raw-source-call mag nooit ongemerkt een onbeperkte base64-respons worden. - // Boven deze grens moet de client nog een pagina opvragen. - if (rawSource && limit > 8) { - throw new XerProvenanceError('VALIDATION', '`rawSource` accepteert maximaal 8 chunks per antwoord; gebruik offset voor volgende pagina\'s.'); - } return { limit, offset }; } @@ -105,13 +185,13 @@ function requireString(value: unknown, name: string): string { return value; } -function page(items: readonly T[], args: XerProvenanceArgs, rawSource = false): { +function page(items: readonly T[], args: XerProvenanceArgs, options: PageOptions = {}): { items: T[]; total: number; has_more: boolean; next_offset: number | null; } { - const { limit, offset } = requirePage(args, rawSource); + const { limit, offset } = requirePage(args, options); const selected = items.slice(offset, offset + limit); const next = offset + selected.length; return { @@ -245,7 +325,15 @@ function resourceCatalog(archive: XerSourceArchive, args: XerProvenanceArgs): un assignmentSources: catalog.rows.assignments, issues: catalog.issues, } as Record; - return { section: 'resourceCatalog', collection, ...page(values[collection], args) }; + const includeRawRows = args.includeRawRows === true; + const isRawRowCollection = RAW_ROW_RESOURCE_COLLECTIONS.has(collection); + const items = isRawRowCollection + ? (values[collection] as Record[]).map((item) => projectRawRowBearingItem(item, includeRawRows)) + : values[collection]; + const pageOptions: PageOptions = isRawRowCollection && includeRawRows + ? { maxLimit: RAW_ROWS_OPT_IN_MAX_LIMIT, label: 'includeRawRows', unit: 'rijen' } + : {}; + return finalizeBounded({ section: 'resourceCatalog', collection, ...page(items, args, pageOptions) }); } function metadataCatalog(archive: XerSourceArchive, args: XerProvenanceArgs): unknown { @@ -258,7 +346,15 @@ function metadataCatalog(archive: XerSourceArchive, args: XerProvenanceArgs): un issues: catalog.issues, ...catalog.sourceData, }; - return { section: 'metadataCatalog', collection, ...page(values[collection] ?? [], args) }; + const includeRawRows = args.includeRawRows === true; + const isRawRowCollection = RAW_ROW_METADATA_COLLECTIONS.has(collection); + const items = isRawRowCollection + ? (values[collection] ?? []).map((row) => projectSourceRow(row as RawSourceRowLike, includeRawRows)) + : (values[collection] ?? []); + const pageOptions: PageOptions = isRawRowCollection && includeRawRows + ? { maxLimit: RAW_ROWS_OPT_IN_MAX_LIMIT, label: 'includeRawRows', unit: 'rijen' } + : {}; + return finalizeBounded({ section: 'metadataCatalog', collection, ...page(items, args, pageOptions) }); } function diagnostics(archive: XerSourceArchive, args: XerProvenanceArgs): unknown { @@ -294,19 +390,26 @@ function taskSourceRows(archive: XerSourceArchive, args: XerProvenanceArgs): unk if (!Object.prototype.hasOwnProperty.call(archive.readModel.taskSourceRowsByProject, projectId)) { throw new XerProvenanceError('NOT_FOUND', `Onbekende XER-projectselector: ${projectId}.`); } - return { + const includeRawRows = args.includeRawRows === true; + const rows = archive.readModel.taskSourceRowsByProject[projectId] ?? []; + // Zonder `includeRawRows` een tel-projectie zonder celwaarden; met de opt-in de volle rij, per cel + // afgekapt en met een lagere paginalimiet (zie RAW_ROWS_OPT_IN_MAX_LIMIT/RAW_CELL_MAX_CHARS hierboven). + const items = rows.map((row) => projectSourceRow(row, includeRawRows)); + const pageOptions: PageOptions = includeRawRows + ? { maxLimit: RAW_ROWS_OPT_IN_MAX_LIMIT, label: 'includeRawRows', unit: 'rijen' } + : {}; + return finalizeBounded({ section: 'taskSourceRowsByProject', projectId, - // XerArchiveSourceRowV1 retains every cell; this page applies no field or string truncation. - ...page(archive.readModel.taskSourceRowsByProject[projectId] ?? [], args), - }; + ...page(items, args, pageOptions), + }); } 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 = page(archive.byteChunks, args, true); + const paged = page(archive.byteChunks, args, { maxLimit: 8, label: 'rawSource', unit: 'chunks' }); return { section: 'rawSource', privacy: 'expliciet aangevraagd; base64-brondata kan vrije projectinformatie bevatten', @@ -330,6 +433,9 @@ function inspect(state: AppState, rawArgs: unknown): unknown { 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() === '')) { @@ -338,6 +444,12 @@ function inspect(state: AppState, rawArgs: unknown): unknown { 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` of `taskSourceRowsByProject`.', + ); + } 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`.'); @@ -366,9 +478,16 @@ const inputSchema = { 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.' }, + 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: ontgrendelt de vrije ' + + 'brontekst van een rij (naam, notitie, willekeurige XER-kolom) i.p.v. alleen line+fieldCount, ' + + 'met een lagere paginalimiet (100) en per-cel-afkapping op 2.000 tekens.', + }, }, additionalProperties: false, } as const; @@ -381,12 +500,16 @@ export const xerProvenanceTools: McpToolDef[] = [{ 'import- en diagnostiektellingen), resourceCatalog, metadataCatalog, taskSourceRowsByProject, ' + 'diagnostics of rawSource. Cataloguscollecties zijn expliciet benoemd en gepagineerd met `limit`, ' + '`offset`, `total`, `has_more` en `next_offset`. taskSourceRowsByProject vereist een expliciete ' + - '`projectId` en geeft alle cellen van die bronrijen terug zonder verborgen veld- of stringafkapping. ' + + '`projectId`; zonder `includeRawRows:true` levert elke rij alleen `line`+`fieldCount`, geen cellen. ' + 'rawSource vereist expliciet `includeRawSource:true`, geeft maximaal acht vaste base64-chunks per ' + - 'antwoord en meldt de privacygrens; summary lekt nooit raw bytes of vrije raw rows. De tool gebruikt ' + - 'alleen retained state, muteert de store niet, voert geen CPM uit en ondersteunt geen schrijfpad.', + 'antwoord en meldt de privacygrens; summary lekt nooit raw bytes of vrije raw rows. ' + + 'resourceCatalog/metadataCatalog/taskSourceRowsByProject geven zonder `includeRawRows:true` een ' + + 'beperkte projectie zonder ruwe cellen; met de opt-in gelden een lagere paginalimiet, ' + + 'per-celafkapping en een responsgrens. 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: true, + batchable: false, inputSchema, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, handler: (args, ctx) => readTool(ctx, (state) => inspect(state, args)), diff --git a/tests/mcp/cases-xer-provenance.ts b/tests/mcp/cases-xer-provenance.ts index deb2e9feb..e78c3b463 100644 --- a/tests/mcp/cases-xer-provenance.ts +++ b/tests/mcp/cases-xer-provenance.ts @@ -1,6 +1,6 @@ // Gerichte MCP-contracttests voor retained XER/P6-bronsemantiek. // Alle bronwaarden zijn synthetisch; er wordt geen corpusnaam of pad gebruikt. -import { useAppStore, test, assert, assertEq, run } from './harness'; +import { useAppStore, makeMcpContext, test, assert, assertEq, run } from './harness'; import { getTool } from '@/services/mcp/toolRegistry'; import type { McpContext, McpToolErr, McpToolOk, McpToolResult } from '@/services/mcp/contracts'; import { @@ -15,13 +15,7 @@ const S = () => useAppStore.getState(); const TOOL = 'planner_inspect_xer_provenance'; function ctx(): McpContext { - return { - expectedDocId: null, - tempIdMap: new Map(), - paused: false, - readOnly: false, - ensureBackup: async () => null, - }; + return makeMcpContext(); } function call(name: string, args: unknown = {}): McpToolResult { @@ -52,27 +46,44 @@ 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; + 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; - const longCell = 'synthetic-free-cell-' + 'x'.repeat(1500); readModel.numberFormat = { decimal: ',', group: '.', source: 'currtype', currencyCode: 'EUR' }; readModel.taskSourceRowsByProject = { 'PROJ-A': [ - { line: 11, cells: { task_id: 'TASK-A', task_name: longCell, task_notes: 'synthetic note' } }, + { 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), + }, + })), }; readModel.resourceCatalog = { resources: [{ id: 'resource-1', name: 'Synthetic Crew' }], identities: [{ kind: 'RESOURCE', sourceId: 'R-1', internalId: 'resource-1', line: 20 }], rows: { - resources: [{ sourceId: 'R-1', rawRow: { line: 20, cells: { rsrc_id: 'R-1', rsrc_name: 'Synthetic Crew' } } }], - roles: [{ sourceId: 'ROLE-1', rawRow: { line: 21, cells: { role_id: 'ROLE-1', role_name: 'Synthetic Role' } } }], - rates: [{ sourceId: 'R-1', rawRow: { line: 22, cells: { rsrc_id: 'R-1' } } }], - curves: [{ sourceId: 'CURVE-1', rawRow: { line: 23, cells: { curve_id: 'CURVE-1' } } }], + 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: '', 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: 'Curve', 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' }], @@ -92,7 +103,8 @@ function archiveFixture(): XerSourceArchive { 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: [{ line: 32, 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' } }], @@ -115,7 +127,10 @@ function archiveFixture(): XerSourceArchive { baselineExclusionReverted: false, baselineFallbackReasons: [], }, }; - diagnostics.documentViews = { 'PROJ-A': { sourceProjectId: 'PROJ-A', synthetic: true } }; + diagnostics.documentViews = { + 'PROJ-A': { sourceProjectId: 'PROJ-A', synthetic: true }, + 'PROJ-BIG': { sourceProjectId: 'PROJ-BIG', synthetic: true }, + }; return { schemaVersion: 1, format: 'primavera-p6-xer', @@ -153,11 +168,11 @@ function attachArchive(): XerSourceArchive { return archive; } -test('registratie: XER-provenance is read-only, gesloten en batchable', () => { +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, true, 'batchable'); + 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'); @@ -246,6 +261,24 @@ test('resourceCatalog: alle collecties zijn afzonderlijk gepagineerd', () => { } }); +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']) { @@ -256,25 +289,65 @@ test('metadataCatalog: activity codes, UDF-defs, projecties en retained brondata } }); -test('taskSourceRowsByProject: expliciete selector en geen verborgen veld/string-truncatie', () => { +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 first = ok(TOOL, { section: 'taskSourceRowsByProject', projectId: 'PROJ-A', limit: 1 }); - assertEq(first.total, 2, 'total task source rows'); - assertEq(first.items.length, 1, 'eerste pagina'); - assertEq(first.items[0].cells.task_name.length, 1520, 'volledige synthetische raw cel'); - const second = ok(TOOL, { section: 'taskSourceRowsByProject', projectId: 'PROJ-A', limit: 1, offset: first.next_offset }); + 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', 'documentViews']) { + 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'); }); @@ -295,7 +368,7 @@ test('rawSource: alleen opt-in, hard begrensd en paginaerbaar over grote payload assertEq(err(TOOL, { section: 'rawSource', includeRawSource: false }).code, 'VALIDATION', 'false is geen opt-in'); }); -test('invalid args en planner_batch gebruiken dezelfde runtimevalidatie zonder storemutatie', async () => { +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'); @@ -306,14 +379,34 @@ test('invalid args en planner_batch gebruiken dezelfde runtimevalidatie zonder s 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'); - const batch = await callAsync('planner_batch', { steps: [{ tool: TOOL, args: { section: 'taskSourceRowsByProject', projectId: 'PROJ-A', limit: 1 } }] }); - assert(batch.ok, 'geldige provenance-call in batch'); - const batchData = (batch as McpToolOk).data as any; - assertEq(batchData.steps[0].data.items.length, 1, 'batch leest dezelfde pagina'); - const badBatch = await callAsync('planner_batch', { steps: [{ tool: TOOL, args: { unexpected: true } }] }); - assertEq(badBatch.ok, false, 'ongeldige provenance-call in batch faalt hard'); - assertEq((badBatch as McpToolErr).code, 'VALIDATION', 'batch validation code'); - assertEq(JSON.stringify({ tasks: S().tasks, sequences: S().sequences, archive: S().xerSourceArchive }), before, 'geen storemutatie door lees- of batchpad'); + assertEq(err(TOOL, { section: 'taskSourceRowsByProject', projectId: 'PROJ-A', includeRawRows: 'yes' as any }).code, 'VALIDATION', 'includeRawRows type'); + assertEq(err(TOOL, { section: 'diagnostics', collection: 'importReport', includeRawRows: true }).code, 'VALIDATION', 'includeRawRows alleen bij de drie catalogus-sections'); + 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', () => { From 095bc5b4b7c06c5ebed0aaa31c913543029e6f5a Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:54:25 +0200 Subject: [PATCH 137/171] test(mcp): pin dat een schema-ongeldige batchstap isDirty niet aanraakt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewbevinding P2 (review-mcp-read.md #3): een ongeldige batch-stap gaf terecht VALIDATION, maar de review zag isDirty toch naar true kantelen omdat restoreSnapshot() (snapshot.ts) — voor de gewone undo/redo-tegenhanger — die vlag altijd op true zet. Op de huidige basis (na de store-contextrefactor) is dit al opgelost: createMcpTransactions.ts's run() herstelt op het rollbackpad expliciet `state.isDirty = previousDirty` NA restoreSnapshot. Dit is dus een algemene batcheigenschap, geen zaak van de XER-tool zelf — de nieuwe test pint 'm met een stub-tool met een strikt schema (additionalProperties:false + required), zodat een toekomstige regressie in die rollback-volgorde hier meteen rood wordt. Mutatiebewezen: de previousDirty-restore-regel tijdelijk verwijderd uit createMcpTransactions.ts liet precies deze test falen (isDirty false → true). (cherry picked from commit 21c4509cb700db04d67b5fe14942f912bb9a4b3a) --- tests/mcp/cases-batch.ts | 43 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) 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 // ================================================================================================= From da596a7db4695a604d0d559796aeddfc22021715 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:54:30 +0200 Subject: [PATCH 138/171] docs(claude): tooltalling bijwerken naar 40 planner_*-tools Reviewbevinding P2 (review-mcp-read.md #4): CLAUDE.md beweerde nog "39 planner_*-tools" terwijl deze etappe planner_inspect_xer_provenance toevoegt (40, bevestigd door scripts/verify-docs.ts Poort 7e en npm run verify:docs). (cherry picked from commit 41180ffc81559e9332d64ff585e703b0310d0810) --- CLAUDE.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 24d714868..66a6ad35f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -232,10 +232,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` From 6652a10e469c4e38bb994aaddf435d2bbdc65525 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:40:39 +0200 Subject: [PATCH 139/171] fix(mcp): XER-provenance-vrijetekstgate op eigenschap i.p.v. drie paden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Herbeoordeling (review2-3d.md) op de vorige fix: die dichtte precies de drie paden die het eerste rapport noemde, maar de EIGENSCHAP ("elke string uit een bronrij gaat door één afkap-/opt-in-poort") stond nog los. Twee gaten eronder: - [P1 #1] `diagnostics/documentViews` had géén `includeRawRows`-gating, géén projectie en géén responsgrens — `resources.assignments[].rawRow` gaf ruwe XER-cellen (bevestigd met een ECHTE `readXER`-fixture, geen stub: 50.000 tekens vrije tekst kwam onafgekapt terug). - [P1 #2] De gate hing aan `rawRow.cells`; vrije tekst BUITEN `rawRow` (`taskProjections.notes`, `roleSources.description`, `customFields`-waarden) ging ongefilterd mee, ook al heetten de collecties "genormaliseerd". Fix: één generieke poort (`sanitizeProvenanceValue`) die recursief over een sectierespons loopt. Een `{line,cells}`-vorm wordt STRUCTUREEL herkend (werkt dus ook op plekken zonder eigen allowlist-regel, zoals geneste documentViews-assignments). Een string onder een vrije-tekstsleutel (`notes`/`description`/`customFields`) is zonder `includeRawRows` volledig onzichtbaar, met opt-in afgekapt op 2.000 tekens. Elke andere string (namen, labels, ids) blijft altijd zichtbaar maar hard afgekapt op 200 tekens — nooit meer "genormaliseerd" gelijk aan "onbegrensd". Twee kleinere P2/P3-bevindingen uit dezelfde ronde in dezelfde beweging meegenomen: - [P2 #3/#6] `finalizeBounded` nu ook om `diagnostics`/`importReport`; een `ByteBudget` charged tijdens de projectie en breekt af zodra het budget vol is (vóór de dure volledige serialisatie), plus een harde cap van 200 cellen/velden per rij (de `%F`-kolomkop van het bronbestand bepaalde dat aantal voorheen ongebonden). - [P2 #4] de responsgrens meet nu ECHTE UTF-8-bytes (`TextEncoder`) i.p.v. `String.length` (UTF-16-code-units) — voor CJK/Arabisch/emoji zat daar een factor 2–3 tussen. - [P2 #5] `projectIds()` is nu de unie van `documentViews` en `taskSourceRowsByProject`: een project dat alleen TASK-rijen heeft (leeg of baseline-uitgesloten) werd door `summary` geadverteerd maar door de tool zelf met NOT_FOUND geweigerd. - [P3 #7] `truncateAt` knipt nooit meer een surrogaatpaar door de helft. - [P3 #8] een nieuwe test loopt via de ECHTE dispatch-weg (`handleMcpMessage`) i.p.v. uitsluitend `def.handler`. Alle nieuwe/aangepaste tests in `tests/mcp/cases-xer-provenance.ts` zijn mutatiebewezen (fix uit ⇒ exact de bijbehorende test rood): structurele rawRow-detectie uit ⇒ 7 tests rood; vrije-tekstsleutels leeg ⇒ 2 tests rood; cap+budget+finalizeBounded alledrie uit ⇒ 3 tests rood (los uitgezet blijven de andere twee lagen het nog opvangen — bewuste defense-in-depth); bytes terug naar code-units ⇒ 1 test rood; selectorbron terug naar alleen documentViews ⇒ 2 tests rood. (cherry picked from commit c3c848755e92c09436eb5db2affb0b022583db58) --- src/services/mcp/tools/xerProvenanceTools.ts | 361 ++++++++++++++----- tests/mcp/cases-xer-provenance.ts | 282 ++++++++++++++- 2 files changed, 543 insertions(+), 100 deletions(-) diff --git a/src/services/mcp/tools/xerProvenanceTools.ts b/src/services/mcp/tools/xerProvenanceTools.ts index 3e09dbd92..b726ebc84 100644 --- a/src/services/mcp/tools/xerProvenanceTools.ts +++ b/src/services/mcp/tools/xerProvenanceTools.ts @@ -4,6 +4,16 @@ // `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): niet "drie paden dichtzetten", maar "elke string die uit een +// bronrij komt gaat door precies één afkap-/opt-in-poort". `sanitizeProvenanceValue` hieronder is +// die ene poort: hij loopt recursief over WAT een sectiefunctie ook teruggeeft, herkent een +// XER-bronrij STRUCTUREEL (`{line, cells}` — dus ook op plekken waar niemand een allowlist-regel +// voor had geschreven, zoals `diagnostics/documentViews[x].resources.assignments[].rawRow`) en +// classificeert elke andere string op VELDNAAM: een vrije-tekstveld (`notes`/`description`/ +// `customFields`) is zonder `includeRawRows` onzichtbaar en met opt-in afgekapt op 2.000 tekens; al +// het overige (namen, labels, ids) blijft altijd zichtbaar maar hard afgekapt op 200 tekens. Eén +// gate, geen per-collectie-allowlist die de volgende sectie kan missen. import type { AppState } from '@/state/appStore'; import type { McpContext, McpErrorCode, McpToolDef, McpToolResult } from '../contracts'; @@ -42,63 +52,200 @@ interface XerProvenanceArgs { includeRawRows?: unknown; } -/** Sections waarvan een collectie/rij vrije brontekst (namen, notities, willekeurige XER-kolommen) - * kan dragen. Zonder `includeRawRows` geven deze een tel-projectie zonder celwaarden terug — de - * tweede expliciete opt-in naast `section`/`collection`, analoog aan `rawSource`/`includeRawSource`. */ -const RAW_ROWS_SECTIONS: readonly Section[] = ['resourceCatalog', 'metadataCatalog', 'taskSourceRowsByProject']; - -/** Binnen `resourceCatalog` de collecties die een `rawRow`-bronrij dragen; de rest (resources, - * identities, issues) is al een genormaliseerde, begrensde projectie. */ -const RAW_ROW_RESOURCE_COLLECTIONS = new Set(['resourceSources', 'roleSources', 'rates', 'curves', 'assignmentSources']); - -/** Binnen `metadataCatalog` de collecties die letterlijk retained bronrijen zijn (`catalog.sourceData`); - * de rest (activityCodeTypes, customFieldDefs, taskProjections, issues) is al genormaliseerd. */ -const RAW_ROW_METADATA_COLLECTIONS = new Set([ - 'ACTVTYPE', 'ACTVCODE', 'TASKACTV', 'UDFTYPE', 'UDFVALUE', 'MEMOTYPE', 'TASKNOTE', 'TASKMEMO', - 'TASK_NOTES', 'deferredUdfValues', 'unknownUdfTypes', -]); +/** 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: een enkele vrije XER-kolom (notitie, naam) mag de respons niet - * onbegrensd laten groeien. */ +/** Per-cel/per-string afkapgrens voor VRIJE tekst (rawRow-cellen, notities, customFields-waarden). */ const RAW_CELL_MAX_CHARS = 2000; -/** Harde bovengrens op de totale geserialiseerde paginarespons; een backstop naast de rij- en - * celgrenzen voor het geval veel kleinere cellen samen toch groot worden. */ +/** 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; +/** Vrije-tekstvelden (potentieel lange, narratieve inhoud) die uitsluitend met `includeRawRows` + * zichtbaar worden — zonder opt-in wordt de sleutel volledig weggelaten, niet afgekapt-maar- + * zichtbaar, want een notitie kan willekeurig gevoelig zijn (review2-3d.md #2: `taskProjections.notes` + * en `roleSources.description` lekten via exact deze velden). */ +const FREE_TEXT_STRING_KEYS = new Set(['notes', 'note', 'description']); +/** Vrije-tekstkáárten (UDF-/activiteitswaarden e.d.): zelfde behandeling als een rawRow-celverzameling. */ +const FREE_TEXT_MAP_KEYS = new Set(['customFields']); + interface RawSourceRowLike { readonly line: number; readonly cells: Readonly>; } +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 { - if (value.length <= RAW_CELL_MAX_CHARS) return value; - return `${value.slice(0, RAW_CELL_MAX_CHARS)}…(afgekapt op ${RAW_CELL_MAX_CHARS} tekens)`; + return truncateAt(value, RAW_CELL_MAX_CHARS); +} + +function truncateLabel(value: string): string { + return truncateAt(value, LABEL_MAX_CHARS); } -/** Zonder opt-in: alleen line + celaantal, geen enkele vrije waarde. Met opt-in: volledige cellen, - * elk individueel afgekapt. */ -function projectSourceRow(row: RawSourceRowLike, includeRawRows: boolean): unknown { +function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Structurele herkenning van een XER-bronrij (`XerArchiveSourceRowV1`): exact twee velden, `line` + * een getal, `cells` een kaart van louter strings. Dit is bewust STRUCTUREEL i.p.v. een allowlist + * per collectie — zo vindt de poort ook een `rawRow` op een plek waar niemand hem had opgeschreven + * (review2-3d.md #1, `diagnostics/documentViews[x].resources.assignments[].rawRow`). */ +function isRawSourceRowLike(value: Record): value is { line: number; cells: Record } { + const keys = Object.keys(value); + if (keys.length !== 2 || typeof value.line !== 'number' || !isPlainRecord(value.cells)) return false; + return Object.values(value.cells).every((cell) => typeof cell === 'string'); +} + +/** 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. Zonder `includeRawRows`: alleen `line` + het WERKELIJKE celaantal, geen + * enkele vrije waarde. Met opt-in: tot `MAX_CELLS_PER_ROW` cellen, elk individueel afgekapt en + * budget-gecharged; méér cellen dan de cap krijgen een expliciete `cellsTruncatedAt`-marker in + * plaats van stilzwijgend te verdwijnen. */ +function projectSourceRow(row: RawSourceRowLike, includeRawRows: boolean, budget: ByteBudget): unknown { + const fieldNames = Object.keys(row.cells); if (!includeRawRows) { - return { line: row.line, fieldCount: Object.keys(row.cells).length }; + return { line: row.line, fieldCount: fieldNames.length }; } + const limited = fieldNames.slice(0, MAX_CELLS_PER_ROW); const cells: Record = {}; - for (const [field, value] of Object.entries(row.cells)) cells[field] = truncateCell(value); - return { line: row.line, cells }; + for (const field of limited) { + const truncated = truncateCell(row.cells[field]); + budget.charge(truncated); + cells[field] = truncated; + } + const projected: Record = { line: row.line, cells }; + if (fieldNames.length > MAX_CELLS_PER_ROW) { + projected.fieldCount = fieldNames.length; + projected.cellsTruncatedAt = MAX_CELLS_PER_ROW; + } + return projected; +} + +/** Zelfde cap/afkap-/opt-in-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 = {}; + for (const key of limited) { + const raw = map[key]; + const text = typeof raw === 'string' ? raw : JSON.stringify(raw); + const truncated = truncateCell(text); + budget.charge(truncated); + out[key] = truncated; + } + const projected: Record = { ...out }; + if (keys.length > MAX_CELLS_PER_ROW) projected.__truncated = { fieldCount: keys.length, cellsTruncatedAt: MAX_CELLS_PER_ROW }; + return projected; } -/** Zelfde projectie voor een item dat een `rawRow` draagt naast eigen (niet-vrije, id-achtige) velden. */ -function projectRawRowBearingItem(item: Record, includeRawRows: boolean): unknown { - const { rawRow, ...rest } = item; - return { ...rest, rawRow: projectSourceRow(rawRow as RawSourceRowLike, includeRawRows) }; +/** + * DE ENE POORT (review2-3d.md, verdict): elke waarde die een sectiefunctie teruggeeft loopt hierdoor + * vóór hij de respons in gaat. Regels, in volgorde: + * 1. een string onder een vrije-tekstsleutel (`notes`/`description`/…) is zonder opt-in ONZICHTBAAR + * (de sleutel verdwijnt), met opt-in afgekapt op 2.000 tekens; + * 2. elke andere string wordt ALTIJD getoond maar hard afgekapt op 200 tekens (labels/namen/ids); + * 3. een object dat STRUCTUREEL een XER-bronrij is (`{line,cells}`) gaat via `projectSourceRow`, + * ongeacht waar in de boom hij zit; + * 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') { + if (key !== null && FREE_TEXT_STRING_KEYS.has(key)) { + 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)) { + return value.map((item) => sanitizeProvenanceValue(item, key, includeRawRows, budget)); + } + 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); + } + const out: Record = {}; + for (const [childKey, childValue] of Object.entries(value)) { + const sanitized = sanitizeProvenanceValue(childValue, childKey, includeRawRows, budget); + if (sanitized !== undefined) out[childKey] = sanitized; + } + return out; + } + return value; } -/** Responsgrens-backstop: gooi een typed fout met een pagineerhint in plaats van een onbegrensde - * serialisatie toe te staan. */ +/** 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 { - if (JSON.stringify(result).length > MAX_SECTION_RESPONSE_BYTES) { + 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.`, @@ -107,12 +254,6 @@ function finalizeBounded>(result: T): T { return result; } -class XerProvenanceError extends Error { - constructor(public readonly code: McpErrorCode, message: string) { - super(message); - } -} - function readTool(ctx: McpContext, fn: (state: AppState) => unknown): McpToolResult { const captured: { error: XerProvenanceError | null } = { error: null }; const result = runReadTool(ctx, (state) => { @@ -185,22 +326,32 @@ function requireString(value: unknown, name: string): string { return value; } -function page(items: readonly T[], args: XerProvenanceArgs, options: PageOptions = {}): { - items: T[]; +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 { limit, offset } = requirePage(args, options); - const selected = items.slice(offset, offset + limit); - const next = offset + selected.length; + const next = paged.offset + items.length; return { - // Do not publish aliases into the frozen archive. This is especially important for raw rows: - // an MCP caller can hold the structured result before transport serialization. - items: structuredClone(selected), - total: items.length, - has_more: next < items.length, - next_offset: next < items.length ? next : null, + items, + total: paged.total, + has_more: next < paged.total, + next_offset: next < paged.total ? next : null, }; } @@ -214,8 +365,17 @@ function collectionOf(section: Section, collection: unknown, allowed: readonl 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[] { - return Object.keys(archive.diagnostics.documentViews).sort(); + const ids = new Set([ + ...Object.keys(archive.diagnostics.documentViews), + ...Object.keys(archive.readModel.taskSourceRowsByProject), + ]); + return Array.from(ids).sort(); } function requireArchive(state: AppState): XerSourceArchive { @@ -326,14 +486,13 @@ function resourceCatalog(archive: XerSourceArchive, args: XerProvenanceArgs): un issues: catalog.issues, } as Record; const includeRawRows = args.includeRawRows === true; - const isRawRowCollection = RAW_ROW_RESOURCE_COLLECTIONS.has(collection); - const items = isRawRowCollection - ? (values[collection] as Record[]).map((item) => projectRawRowBearingItem(item, includeRawRows)) - : values[collection]; - const pageOptions: PageOptions = isRawRowCollection && includeRawRows + const pageOptions: PageOptions = includeRawRows ? { maxLimit: RAW_ROWS_OPT_IN_MAX_LIMIT, label: 'includeRawRows', unit: 'rijen' } : {}; - return finalizeBounded({ section: 'resourceCatalog', collection, ...page(items, args, pageOptions) }); + 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 { @@ -347,24 +506,28 @@ function metadataCatalog(archive: XerSourceArchive, args: XerProvenanceArgs): un ...catalog.sourceData, }; const includeRawRows = args.includeRawRows === true; - const isRawRowCollection = RAW_ROW_METADATA_COLLECTIONS.has(collection); - const items = isRawRowCollection - ? (values[collection] ?? []).map((row) => projectSourceRow(row as RawSourceRowLike, includeRawRows)) - : (values[collection] ?? []); - const pageOptions: PageOptions = isRawRowCollection && includeRawRows + const pageOptions: PageOptions = includeRawRows ? { maxLimit: RAW_ROWS_OPT_IN_MAX_LIMIT, label: 'includeRawRows', unit: 'rijen' } : {}; - return finalizeBounded({ section: 'metadataCatalog', collection, ...page(items, args, pageOptions) }); + 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.'); } - return { section: 'diagnostics', collection, report: structuredClone(file.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, @@ -374,15 +537,23 @@ function diagnostics(archive: XerSourceArchive, args: XerProvenanceArgs): unknow 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 result = page(values[collection], args); - return { + 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 } : {}), - ...result, - }; + ...envelope(paged, items), + }); } function taskSourceRows(archive: XerSourceArchive, args: XerProvenanceArgs): unknown { @@ -392,24 +563,21 @@ function taskSourceRows(archive: XerSourceArchive, args: XerProvenanceArgs): unk } const includeRawRows = args.includeRawRows === true; const rows = archive.readModel.taskSourceRowsByProject[projectId] ?? []; - // Zonder `includeRawRows` een tel-projectie zonder celwaarden; met de opt-in de volle rij, per cel - // afgekapt en met een lagere paginalimiet (zie RAW_ROWS_OPT_IN_MAX_LIMIT/RAW_CELL_MAX_CHARS hierboven). - const items = rows.map((row) => projectSourceRow(row, includeRawRows)); const pageOptions: PageOptions = includeRawRows ? { maxLimit: RAW_ROWS_OPT_IN_MAX_LIMIT, label: 'includeRawRows', unit: 'rijen' } : {}; - return finalizeBounded({ - section: 'taskSourceRowsByProject', - projectId, - ...page(items, args, pageOptions), - }); + 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 = page(archive.byteChunks, args, { maxLimit: 8, label: 'rawSource', unit: 'chunks' }); + 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', @@ -418,10 +586,10 @@ function rawSource(archive: XerSourceArchive, args: XerProvenanceArgs): unknown encoding: 'base64', chunkSizeBytes: XER_SOURCE_ARCHIVE_CHUNK_BYTES, totalChunks: archive.byteChunks.length, - chunks: paged.items.map((base64, index) => ({ index: (typeof args.offset === 'number' ? args.offset : 0) + index, base64 })), + chunks: paged.slice.map((base64, index) => ({ index: paged.offset + index, base64 })), total: paged.total, - has_more: paged.has_more, - next_offset: paged.next_offset, + has_more: next < paged.total, + next_offset: next < paged.total ? next : null, }; } @@ -447,7 +615,7 @@ function inspect(state: AppState, rawArgs: unknown): unknown { if (args.includeRawRows !== undefined && !RAW_ROWS_SECTIONS.includes(section)) { throw new XerProvenanceError( 'VALIDATION', - '`includeRawRows` hoort alleen bij section `resourceCatalog`, `metadataCatalog` of `taskSourceRowsByProject`.', + '`includeRawRows` hoort alleen bij section `resourceCatalog`, `metadataCatalog`, `taskSourceRowsByProject` of `diagnostics`.', ); } if (section === 'summary') { @@ -484,9 +652,11 @@ const inputSchema = { includeRawRows: { type: 'boolean', description: - 'Alleen bij resourceCatalog/metadataCatalog/taskSourceRowsByProject: ontgrendelt de vrije ' + - 'brontekst van een rij (naam, notitie, willekeurige XER-kolom) i.p.v. alleen line+fieldCount, ' + - 'met een lagere paginalimiet (100) en per-cel-afkapping op 2.000 tekens.', + '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, @@ -499,15 +669,18 @@ export const xerProvenanceTools: McpToolDef[] = [{ '(veilig: 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`. taskSourceRowsByProject vereist een expliciete ' + - '`projectId`; zonder `includeRawRows:true` levert elke rij alleen `line`+`fieldCount`, geen cellen. ' + - 'rawSource vereist expliciet `includeRawSource:true`, geeft maximaal acht vaste base64-chunks per ' + - 'antwoord en meldt de privacygrens; summary lekt nooit raw bytes of vrije raw rows. ' + - 'resourceCatalog/metadataCatalog/taskSourceRowsByProject geven zonder `includeRawRows:true` een ' + - 'beperkte projectie zonder ruwe cellen; met de opt-in gelden een lagere paginalimiet, ' + - 'per-celafkapping en een responsgrens. 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`.', + '`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. Vrije tekst (notities, `customFields`, ' + + 'ruwe XER-cellen) is zonder `includeRawRows:true` volledig ONZICHTBAAR, niet slechts afgekapt; met ' + + 'de opt-in per cel/veld afgekapt op 2.000 tekens, met een cap van 200 cellen per rij en een lagere ' + + 'paginalimiet (100). Korte label-/naamvelden (namen, ids) blijven ALTIJD zichtbaar maar hard ' + + 'afgekapt op 200 tekens — dit geldt voor élke string die uit een bronrij komt, ongeacht waar in de ' + + 'respons hij landt (ook `diagnostics/documentViews`). rawSource vereist expliciet ' + + '`includeRawSource:true`, geeft maximaal acht vaste base64-chunks per antwoord en meldt de ' + + 'privacygrens. Elke pagina kent een harde responsgrens (256 kB, gemeten in echte UTF-8-bytes). ' + + '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, diff --git a/tests/mcp/cases-xer-provenance.ts b/tests/mcp/cases-xer-provenance.ts index e78c3b463..1fd185615 100644 --- a/tests/mcp/cases-xer-provenance.ts +++ b/tests/mcp/cases-xer-provenance.ts @@ -2,6 +2,7 @@ // 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, @@ -9,6 +10,7 @@ import { createEmptyXerArchiveReadModel, type XerSourceArchive, } from '@/services/xerSourceArchive'; +import { reconstructXerSourceArchiveFromBytes } from '@/services/xer/xerReader'; import { createDefaultTaskTime } from '@/utils/taskDefaults'; const S = () => useAppStore.getState(); @@ -53,6 +55,17 @@ function reset(): void { 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; + 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. @@ -75,23 +88,38 @@ function archiveFixture(): XerSourceArchive { 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' } }], }; readModel.resourceCatalog = { - resources: [{ id: 'resource-1', name: 'Synthetic Crew' }], + 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: '', rawRow: { line: 21, cells: { role_id: 'ROLE-1', role_name: 'Synthetic Role' } } }], + roles: [{ sourceId: 'ROLE-1', internalId: 'role-1', line: 21, name: 'Synthetic Role', shortName: 'SR', description: FREE_TEXT_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: 'Curve', rawPoints: Array(21).fill('0'), rawRow: { line: 23, cells: { curve_id: 'CURVE-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: 'Synthetic phase', type: 'text' }], - taskProjections: [{ projectId: 'PROJ-A', taskId: 'TASK-A', activityCodes: { 'ACTIVITY-TYPE-1': 'ACTIVITY-VALUE-1' }, customFields: { 'UDF-1': 'Phase A' } }], + customFieldDefs: [{ id: 'UDF-1', name: LABEL_SECRET, type: 'text' }], + taskProjections: [{ + projectId: 'PROJ-A', taskId: 'TASK-A', notes: FREE_TEXT_SECRET, + 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 }, @@ -380,7 +408,10 @@ test('invalid args worden runtime geweigerd zonder storemutatie', () => { 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'); - assertEq(err(TOOL, { section: 'diagnostics', collection: 'importReport', includeRawRows: true }).code, 'VALIDATION', 'includeRawRows alleen bij de drie catalogus-sections'); + // `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'); }); @@ -416,4 +447,243 @@ test('no-XER document: veilige summary meldt afwezigheid, inhoudsectie geeft NOT assertEq(err(TOOL, { section: 'resourceCatalog', collection: 'resources' }).code, 'NOT_FOUND', 'catalogus zonder bron'); }); +// ================================================================================================= +// 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'); + + 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 }); + assert(!('notes' in projectionsClosed.items[0]), 'taskProjections.notes verdwijnt zonder opt-in'); + 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'); + + const projectionsOpened = ok(TOOL, { section: 'metadataCatalog', collection: 'taskProjections', limit: 1, includeRawRows: true }); + assertEq(projectionsOpened.items[0].notes.length, LONG_CELL_TRUNCATED_LENGTH, 'notes afgekapt op 2.000 tekens met opt-in'); + 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`); + } + } +}); + +/** 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 + * `reconstructXerSourceArchiveFromBytes`), 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 reconstructXerSourceArchiveFromBytes(source); +} + +/** 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 reconstructXerSourceArchiveFromBytes(source); +} + +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('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(); From 262a59f3fbec4655a30b8ee2be1aad502a2b1ba4 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:09:47 +0200 Subject: [PATCH 140/171] fix(mcp): deny-by-default vrije tekst, summary door de poort, N4/N5-fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Her-check (review2-3d.md, ronde 3) op de vorige commit: de generieke rijdetectie werkte, maar de vrije-tekstclassificatie was zelf nog een BLOCKLIST (`notes`/`note`/`description`) — dezelfde soort blinde vlek als de drie-paden- fout van ronde 1, alleen een niveau dieper. Vijf bevindingen, alle bevestigd met probes door de reviewer: - [P1 N1/N2] Blocklist omgekeerd naar DENY-BY-DEFAULT: `SAFE_LABEL_KEYS` is nu een allowlist van id-/code-/labelachtige sleutels die zonder opt-in zichtbaar blijven (afgekapt op 200 tekens). Alles wat er niet in staat — ook een onbekende toekomstige sleutel — is zonder `includeRawRows` volledig onzichtbaar. Dit dekt automatisch ook geneste vrije tekst: `Task['notes']` is een objectarray (`{id,text,done}[]`), geen string, en de oude blocklist keek alleen naar de buitenste sleutel `notes`, niet naar `text` binnenin. Zes alias-sleutels (`text`/`comment`/`memo`/`remark`/`title`/`longName`) die vorige ronde allemaal doorglipten, verdwijnen nu net als elke andere niet-toegestane sleutel — geen aparte regel per synoniem nodig. - [P2 N3] `summary` (de DEFAULT-sectie) liep door geen van beide poorten: 911.534 bytes gemeten, `numberFormat.currencyCode` onafgekapt (5.014 tekens), en `importReport` was een levende alias naar het bevroren archief — het kopcommentaar belooft expliciet "nooit een alias". `summary` loopt nu door dezelfde `sanitizeProvenanceValue` + `finalizeBounded` (met `includeRawRows` hard op `false`, want summary zat nooit in `RAW_ROWS_SECTIONS`). - [P2 N4] Cel-/veldNAMEN (de `%F`-kolomkop van het bronbestand) ontsnapten aan zowel afkapping als budget — een kolomnaam van 60.001 tekens kwam verbatim mee en telde als nul in de teller. Nu lopen sleutels door dezelfde `truncateLabel` + `budget.charge` als waarden, met botsingsveilige suffixering (`#1`, `#2`, …) na afkapping. - [P2 N5] `isRawSourceRowLike` eiste exact twee sleutels en faalde daardoor OPEN zodra een rijvorm een derde veld droeg (`XerScheduleOptionsSourceRow` = `{table,line,cells}`): zo'n rij viel door naar de generieke objecttak, waar `cells` gewoon een record werd en elke celwaarde als zichtbaar label naar buiten kwam. Detectie is nu op VORM (`line`+string-`cells`), fail- closed; overige velden (`table`) projecteren gewoon mee via dezelfde poort. - [P3 M4] Het `ByteBudget`-effect (begroten vóór serialisatie) was door geen enkele test gepind: de reviewer schakelde alleen de budgetdrempel uit en geen enkele test werd rood, omdat `finalizeBounded` dezelfde oversized respons toch nog post-hoc afving. Nieuwe test pint het VIA de foutmelding: `createByteBudget` mist het woord "geserialiseerd" dat `finalizeBounded` wél toevoegt, dus een assertie op die tekst onderscheidt "geweigerd tijdens projectie" van "geweigerd na volledige serialisatie" — zonder op tijd of geheugen te meten. Tool-description bijgewerkt naar wat de code nu daadwerkelijk garandeert (deny-by-default, summary inbegrepen, cel-/veldnamen afgekapt). Alle vijf fixes zijn mutatiebewezen (fix uit ⇒ exact de bijbehorende test(s) rood): allowlist naar "alles toegestaan" ⇒ 2 tests rood; summary buiten de poort ⇒ 2 tests rood; kolomnaam-afkapping uit ⇒ 1 test rood; kolomnaam-begroting uit (afkapping blijft aan) ⇒ 1 test rood; rijdetectie terug naar exact-2-sleutels ⇒ 1 test rood; ByteBudget-drempel uit ⇒ zowel de nieuwe M4-pinningtest als de N4-budgettest rood (bevestigt exact het gat dat de review aanwees: de oude suite zag deze mutatie niet). (cherry picked from commit 7bc71308850138a091fae1d13188af68085bce1d) --- src/services/mcp/tools/xerProvenanceTools.ts | 203 +++++++++++++------ tests/mcp/cases-xer-provenance.ts | 131 +++++++++++- 2 files changed, 266 insertions(+), 68 deletions(-) diff --git a/src/services/mcp/tools/xerProvenanceTools.ts b/src/services/mcp/tools/xerProvenanceTools.ts index b726ebc84..1485a9e56 100644 --- a/src/services/mcp/tools/xerProvenanceTools.ts +++ b/src/services/mcp/tools/xerProvenanceTools.ts @@ -5,15 +5,23 @@ // bewust vrij van raw rows en raw bytes; bronrijen en bytes zijn aparte, expliciet gepagineerde // secties. // -// DE EIGENSCHAP (review2-3d.md): niet "drie paden dichtzetten", maar "elke string die uit een -// bronrij komt gaat door precies één afkap-/opt-in-poort". `sanitizeProvenanceValue` hieronder is -// die ene poort: hij loopt recursief over WAT een sectiefunctie ook teruggeeft, herkent een -// XER-bronrij STRUCTUREEL (`{line, cells}` — dus ook op plekken waar niemand een allowlist-regel -// voor had geschreven, zoals `diagnostics/documentViews[x].resources.assignments[].rawRow`) en -// classificeert elke andere string op VELDNAAM: een vrije-tekstveld (`notes`/`description`/ -// `customFields`) is zonder `includeRawRows` onzichtbaar en met opt-in afgekapt op 2.000 tekens; al -// het overige (namen, labels, ids) blijft altijd zichtbaar maar hard afgekapt op 200 tekens. Eén -// gate, geen per-collectie-allowlist die de volgende sectie kan missen. +// 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'; @@ -75,19 +83,33 @@ const MAX_CELLS_PER_ROW = 200; * zit daar een factor 2–3 tussen). */ const MAX_SECTION_RESPONSE_BYTES = 256 * 1024; -/** Vrije-tekstvelden (potentieel lange, narratieve inhoud) die uitsluitend met `includeRawRows` - * zichtbaar worden — zonder opt-in wordt de sleutel volledig weggelaten, niet afgekapt-maar- - * zichtbaar, want een notitie kan willekeurig gevoelig zijn (review2-3d.md #2: `taskProjections.notes` - * en `roleSources.description` lekten via exact deze velden). */ -const FREE_TEXT_STRING_KEYS = new Set(['notes', 'note', 'description']); -/** Vrije-tekstkáárten (UDF-/activiteitswaarden e.d.): zelfde behandeling als een rawRow-celverzameling. */ +/** 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. */ +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', 'unit', 'unitOfMeasure', 'currencyCode', + 'currShortName', '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) + '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']); -interface RawSourceRowLike { - readonly line: number; - readonly cells: Readonly>; -} - class XerProvenanceError extends Error { constructor(public readonly code: McpErrorCode, message: string) { super(message); @@ -117,16 +139,31 @@ function isPlainRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -/** Structurele herkenning van een XER-bronrij (`XerArchiveSourceRowV1`): exact twee velden, `line` - * een getal, `cells` een kaart van louter strings. Dit is bewust STRUCTUREEL i.p.v. een allowlist - * per collectie — zo vindt de poort ook een `rawRow` op een plek waar niemand hem had opgeschreven - * (review2-3d.md #1, `diagnostics/documentViews[x].resources.assignments[].rawRow`). */ -function isRawSourceRowLike(value: Record): value is { line: number; cells: Record } { - const keys = Object.keys(value); - if (keys.length !== 2 || typeof value.line !== 'number' || !isPlainRecord(value.cells)) return false; +/** 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 @@ -151,23 +188,42 @@ function createByteBudget(maxBytes: number): ByteBudget { }; } -/** Projecteert één bronrij. Zonder `includeRawRows`: alleen `line` + het WERKELIJKE celaantal, geen - * enkele vrije waarde. Met opt-in: tot `MAX_CELLS_PER_ROW` cellen, elk individueel afgekapt en - * budget-gecharged; méér cellen dan de cap krijgen een expliciete `cellsTruncatedAt`-marker in - * plaats van stilzwijgend te verdwijnen. */ -function projectSourceRow(row: RawSourceRowLike, includeRawRows: boolean, budget: ByteBudget): unknown { - const fieldNames = Object.keys(row.cells); +/** 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: row.line, fieldCount: fieldNames.length }; + return { line, fieldCount: fieldNames.length, ...restProjected }; } + const limited = fieldNames.slice(0, MAX_CELLS_PER_ROW); - const cells: Record = {}; + const projectedCells: Record = {}; + const usedCellKeys = new Set(); for (const field of limited) { - const truncated = truncateCell(row.cells[field]); - budget.charge(truncated); - cells[field] = truncated; + const truncatedField = uniqueTruncatedKey(truncateLabel(field), usedCellKeys); + budget.charge(truncatedField); + const truncatedValue = truncateCell(cells[field]); + budget.charge(truncatedValue); + projectedCells[truncatedField] = truncatedValue; } - const projected: Record = { line: row.line, cells }; + const projected: Record = { line, cells: projectedCells, ...restProjected }; if (fieldNames.length > MAX_CELLS_PER_ROW) { projected.fieldCount = fieldNames.length; projected.cellsTruncatedAt = MAX_CELLS_PER_ROW; @@ -175,19 +231,23 @@ function projectSourceRow(row: RawSourceRowLike, includeRawRows: boolean, budget return projected; } -/** Zelfde cap/afkap-/opt-in-regime als `projectSourceRow`, voor een vrije-tekstkáárt (`customFields` - * e.d.) die geen `{line,cells}`-vorm heeft maar dezelfde privacy-eigenschap draagt. */ +/** 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 truncated = truncateCell(text); - budget.charge(truncated); - out[key] = truncated; + 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 }; @@ -195,13 +255,16 @@ function projectFreeTextMap(map: Record, includeRawRows: boolea } /** - * DE ENE POORT (review2-3d.md, verdict): elke waarde die een sectiefunctie teruggeeft loopt hierdoor - * vóór hij de respons in gaat. Regels, in volgorde: - * 1. een string onder een vrije-tekstsleutel (`notes`/`description`/…) is zonder opt-in ONZICHTBAAR - * (de sleutel verdwijnt), met opt-in afgekapt op 2.000 tekens; - * 2. elke andere string wordt ALTIJD getoond maar hard afgekapt op 200 tekens (labels/namen/ids); - * 3. een object dat STRUCTUREEL een XER-bronrij is (`{line,cells}`) gaat via `projectSourceRow`, - * ongeacht waar in de boom hij zit; + * 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 @@ -209,7 +272,8 @@ function projectFreeTextMap(map: Record, includeRawRows: boolea */ function sanitizeProvenanceValue(value: unknown, key: string | null, includeRawRows: boolean, budget: ByteBudget): unknown { if (typeof value === 'string') { - if (key !== null && FREE_TEXT_STRING_KEYS.has(key)) { + const isSafeLabel = key !== null && SAFE_LABEL_KEYS.has(key); + if (!isSafeLabel) { if (!includeRawRows) return undefined; const truncated = truncateCell(value); budget.charge(truncated); @@ -396,6 +460,7 @@ function validateProjectSelector(archive: XerSourceArchive, projectId: unknown): function summary(state: AppState, archive: XerSourceArchive | null): unknown { if (!archive) { + // Statische, systeemeigen tekst zonder bronvrije inhoud — hoeft niet door de poort. return { sourcePresent: false, source: null, @@ -410,7 +475,7 @@ function summary(state: AppState, archive: XerSourceArchive | null): unknown { const resources = readModel.resourceCatalog; const schedule = state.xerImportMetadata?.scheduleOptions; const ids = projectIds(archive); - return { + const raw = { sourcePresent: true, source: { format: archive.format, @@ -470,6 +535,15 @@ function summary(state: AppState, archive: XerSourceArchive | null): unknown { ), }, }; + // 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 { @@ -666,21 +740,24 @@ export const xerProvenanceTools: McpToolDef[] = [{ name: 'planner_inspect_xer_provenance', description: 'Bounded read-only inspectie van retained Primavera P6/XER-bronsemantiek. `section` is summary ' + - '(veilig: bronaanwezigheid, byteLength, SHA-256, chunk count, selector, number format, SCHEDOPTIONS-, ' + + '(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. Vrije tekst (notities, `customFields`, ' + - 'ruwe XER-cellen) is zonder `includeRawRows:true` volledig ONZICHTBAAR, niet slechts afgekapt; met ' + - 'de opt-in per cel/veld afgekapt op 2.000 tekens, met een cap van 200 cellen per rij en een lagere ' + - 'paginalimiet (100). Korte label-/naamvelden (namen, ids) blijven ALTIJD zichtbaar maar hard ' + - 'afgekapt op 200 tekens — dit geldt voor élke string die uit een bronrij komt, ongeacht waar in de ' + - 'respons hij landt (ook `diagnostics/documentViews`). rawSource vereist expliciet ' + + '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. Elke pagina kent een harde responsgrens (256 kB, gemeten in echte UTF-8-bytes). ' + - '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`.', + '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, diff --git a/tests/mcp/cases-xer-provenance.ts b/tests/mcp/cases-xer-provenance.ts index 1fd185615..aeefb066d 100644 --- a/tests/mcp/cases-xer-provenance.ts +++ b/tests/mcp/cases-xer-provenance.ts @@ -66,12 +66,27 @@ 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); + 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; - readModel.numberFormat = { decimal: ',', group: '.', source: 'currtype', currencyCode: 'EUR' }; + // 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' } }, @@ -99,13 +114,38 @@ function archiveFixture(): XerSourceArchive { // 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', + ])), + })), }; 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, rawRow: { line: 21, cells: { role_id: 'ROLE-1', role_name: 'Synthetic Role' } } }], + 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' } } }], @@ -116,7 +156,12 @@ function archiveFixture(): XerSourceArchive { 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', notes: FREE_TEXT_SECRET, + 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 }, }], @@ -278,6 +323,23 @@ test('summary: veilig, volledig geteld en zonder raw bytes of vrije raw rows', ( 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']) { @@ -459,13 +521,22 @@ test('P1 #2: vrije tekst buiten rawRow verdwijnt zonder opt-in; labels blijven z 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 }); - assert(!('notes' in projectionsClosed.items[0]), 'taskProjections.notes verdwijnt zonder opt-in'); + // `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'); @@ -473,9 +544,13 @@ test('P1 #2: vrije tekst buiten rawRow verdwijnt zonder opt-in; labels blijven z 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.length, LONG_CELL_TRUNCATED_LENGTH, 'notes afgekapt op 2.000 tekens met opt-in'); + 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 @@ -519,6 +594,8 @@ test('P1 #2: generieke walker — zonder opt-in geen string > 2.000 tekens en ge 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`); } } }); @@ -663,6 +740,50 @@ test('P2 #5: één selectorbron — een project alleen in taskSourceRowsByProjec 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 }); From d86576f79aaeec67679d94bace88310ce07ece54 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:25:12 +0200 Subject: [PATCH 141/171] fix(mcp): sleutels in generieke objecttak afkappen, summary-afwezig-tak door poort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Her-check 3 (review2-3d.md): GO onder twee kleine voorwaarden. - [P2 R8] De generieke objecttak van sanitizeProvenanceValue deed `out[childKey] = …` met `childKey` ongemoeid — N4 dichtte dit alleen voor projectSourceRow/projectFreeTextMap. Een project-id van 80.000 tekens (proj_id komt uit het bronbestand) overleefde daardoor verbatim zodra hij als OBJECTSLEUTEL werd gebruikt (summary.catalogCounts.taskSourceRowsByProject), terwijl dezelfde string als WAARDE (selector.availableProjectIds) al wél afgekapt werd. Nu lopen sleutels door dezelfde truncateLabel + budget.charge als waarden, met dezelfde botsingsveilige #N-suffixering. - [P3 R9] De sourcePresent:false-tak van summary retournede vóór sanitize/ finalizeBounded, met het commentaar "statische, systeemeigen tekst" — maar xerSourceProjectId is een documentveld uit het bestand, geen statische tekst. Nu loopt ook deze tak door de poort; alleen de echt statische systeemmelding (note) wordt na het saneren teruggezet, want die sleutel staat bewust niet op de allowlist. - [P3 R7, optioneel] Een volledig verborgen string-array werd `[null, null]` in plaats van iets dat "hier is iets verborgen" communiceert — de array-tak filtert nu undefined-elementen eruit. Allowlist-docblok aangevuld: `name`/`code` blijven zichtbaar vastgelegd als geaccepteerd risico (een P6-resourcenaam is vaak een persoonsnaam — geen AVG-schone lijst), `unit`/`currShortName` verwijderd (kwamen nergens in de blootgestelde grafiek voor), `unitOfMeasure` gemotiveerd als wél live. Mutatiebewezen: R8 teruggedraaid ⇒ de nieuwe R8-test rood (sleutel weer 80.000 tekens); R9 teruggedraaid ⇒ de nieuwe R9-test rood (currentProjectId weer 9.009 tekens in plaats van 200). (cherry picked from commit 63e068c1f37dbf10bc9afa4546733f2ea2456b4e) --- src/services/mcp/tools/xerProvenanceTools.ts | 59 +++++++++++++++----- tests/mcp/cases-xer-provenance.ts | 43 ++++++++++++++ 2 files changed, 89 insertions(+), 13 deletions(-) diff --git a/src/services/mcp/tools/xerProvenanceTools.ts b/src/services/mcp/tools/xerProvenanceTools.ts index 1485a9e56..ee24860e1 100644 --- a/src/services/mcp/tools/xerProvenanceTools.ts +++ b/src/services/mcp/tools/xerProvenanceTools.ts @@ -88,7 +88,18 @@ const MAX_SECTION_RESPONSE_BYTES = 256 * 1024; * `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. */ + * 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', @@ -96,13 +107,15 @@ const SAFE_LABEL_KEYS = new Set([ 'calendarSourceId', 'defaultRoleSourceId', 'unitSourceId', 'taskSourceId', 'companyId', 'libraryItemId', 'syncedHash', // korte labels/codes - 'name', 'shortName', 'code', 'taskCode', 'wbsCode', 'unit', 'unitOfMeasure', 'currencyCode', - 'currShortName', 'defaultCurrencyCode', + '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) + // 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 @@ -284,7 +297,12 @@ function sanitizeProvenanceValue(value: unknown, key: string | null, includeRawR return truncated; } if (Array.isArray(value)) { - return value.map((item) => sanitizeProvenanceValue(item, key, includeRawRows, budget)); + // 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)) { @@ -293,10 +311,18 @@ function sanitizeProvenanceValue(value: unknown, key: string | null, includeRawR 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) out[childKey] = sanitized; + if (sanitized === undefined) continue; + const truncatedKey = uniqueTruncatedKey(truncateLabel(childKey), usedKeys); + budget.charge(truncatedKey); + out[truncatedKey] = sanitized; } return out; } @@ -460,13 +486,20 @@ function validateProjectSelector(archive: XerSourceArchive, projectId: unknown): function summary(state: AppState, archive: XerSourceArchive | null): unknown { if (!archive) { - // Statische, systeemeigen tekst zonder bronvrije inhoud — hoeft niet door de poort. - return { - sourcePresent: false, - source: null, - selector: { currentProjectId: state.xerSourceProjectId, availableProjectIds: [] }, - note: 'Er is voor dit document geen retained XER-bronarchief beschikbaar.', - }; + // 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; diff --git a/tests/mcp/cases-xer-provenance.ts b/tests/mcp/cases-xer-provenance.ts index aeefb066d..d70a9786d 100644 --- a/tests/mcp/cases-xer-provenance.ts +++ b/tests/mcp/cases-xer-provenance.ts @@ -78,6 +78,10 @@ const ALIAS_KEYS = ['text', 'comment', 'memo', 'remark', 'title', 'longName'] as // 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. @@ -131,6 +135,10 @@ function archiveFixture(): XerSourceArchive { `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 }], @@ -509,6 +517,41 @@ test('no-XER document: veilige summary meldt afwezigheid, inhoudsectie geeft NOT 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. // ================================================================================================= From 0b8dbeb3cb9f09d0576e5aa06e10554c30653583 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:59:55 +0200 Subject: [PATCH 142/171] fix(xer): kalenderblokken over weekends + penaltyprojectie ontdubbeld (7b-1/7b-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Etappe 7b-1 en 7b-2 landen bewust SAMEN: elk afzonderlijk maakt het corpus meetbaar slechter (zie de meting onderaan). OORZAAK (7b-1). De `Exceptions`-lijst van kalender 842 (`R111 - 1`) in `crawl-xer-extra/jailaff-xer-splitter/rehab-2.xer` slaat aaneengesloten vrije blokken op met een klem op de MA-VR-as: elke zaterdag staat er als de vrijdag ervóór, elke zondag als de maandag erná. 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, 10-03 (vr) en 10-06 (ma) staan er twee keer. Zo'n AANGRENZEND DUPLICAAT is het bewijs dat er een dag verloren ging, en komt in het hele corpus van 93 bestanden in geen enkel ander bestand voor. Alle 23 redundante records van die kalender staan op een vrijdag of een maandag (nul op een andere weekdag), en de tien dagen die de set-cover van het 761-diagnosedossier onafhankelijk als "moeten vrij zijn" aanwees (2008-10-04, 10-05, 2008-12-07, 12-13, 12-14, 2009-09-19, 09-20, 2009-11-28, 11-29, 2009-12-05) zijn exact de klemdoelen van tien van die 23 records — één-op-één, geen rest. OORZAAK (7b-2). Voor precies diezelfde records maakte de decoder een `p6NonWorkPenaltyDate`: een virtuele extra niet-werkdag die `CalendarEngine.subtractP6XerProjectedWorkMinutes` bij elke achterwaartse duur-/lagwandeling opnieuw meetelt. Reconstrueren we de dag écht, dan zou dezelfde afwezigheid twee keer verrekend worden. Gemeten op rehab-2 met de blokken vrij: `addWorkMinutes(2009-11-25T08:00, 56u)` = 2009-12-12T17:00 en `subtractWorkMinutes` daarvan = 2009-11-25T08:00 (beide exact P6's LF/LS), terwijl de projectie 2009-11-22T08:00 gaf — drie werkdagen te vroeg, exact de drie penaltydagen 2009-11-27, 11-30 en 12-04 in dat venster. WIJZIGING. `xerCalendarData.ts` reconstrueert de geklemde weekenddag (`weekendClampTarget`) en laat voor die datum géén penaltydatum meer achter. De poort is drievoudig: de kalender moet zelf een aangrenzend duplicaat dragen, het klemdoel moet een zaterdag of zondag zijn die op déze kalender WERKT, en die dag mag niet al een eigen uitzonderingsrecord hebben. Corpusmeting van die poort: 92 van de 93 bestanden zijn byte-identiek; alleen rehab-2 verandert. De projectie zelf blijft staan — een generieke "sla een al vrije dag over"-wacht zou haar de facto schrappen en dat is gemeten slechter (zie hieronder); ze is voor twaalf andere corpusbestanden nog steeds het enige model. METING — X12-productfidelity (`OPS_XER_FIDELITY_REPORT`, per bestand): totaal 18398 → 17421 (−977) rehab-2.xer 14812 → 13835 (−977) alle 28 overige bestanden ongewijzigd, 0 verschil per as: es 1251 → 1573 (+322) ef 1500 → 1627 (+127) ls 5015 → 4546 (−469) lf 5044 → 4583 (−461) tf 4765 → 4468 (−297) ff 823 → 624 (−199) De twee helften afzonderlijk (mutatiebewijs op corpusniveau): alleen reconstructie, penalty blijft 18398 → 21673 alleen penalty weg, geen reconstructie 18398 → 21588 beide samen 18398 → 17421 Corpusloos mutatiebewijs: `check-xer-calendar-data.ts` sectie 22 draait de letterlijke recordreeks van blok 2009-11-26 … 2009-12-05 en eist tien aaneengesloten vrije dagen plus een lege penaltylijst; zonder deze fix zeven dagen en drie penaltydatums (exit 1). Twee controles erbij: zonder duplicaat gebeurt er niets, en op een gewone ma-vr-kalender ook niet. Poorten: `OPS_XER_CORPUS=… npm run test:planning` exit 1 met uitsluitend de toegestane rode set — de vier X12-regels (totaal nu 17421 i.p.v. 18398) — plus `milestone-duration-render`, dat op de basiscommit a669108e al rood staat en niets met deze etappe te maken heeft. `npm run typecheck` exit 0, `npm run lint` exit 0. Drie gepinde baselines meebewogen en opnieuw gemeten: de 124-kalenderdigest, de SCHEDOPTIONS-blast-radius (expectedFinishVariant en causalProductEffects) en de openbare task-replaypin. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit fbcdb73138cf623aff4268f57187a66a5e10b4d9) --- src/engine/scheduler/CalendarEngine.ts | 11 + src/services/xer/xerCalendarData.ts | 75 +++++ tests/planning/check-xer-calendar-data.ts | 59 ++++ .../xer-calendar-hour-mode-baseline.json | 2 +- .../xer-schedoptions-blast-radius.json | 311 +++++++++++------- .../planning/xer-task-replay-public-pin.json | 8 +- 6 files changed, 346 insertions(+), 120 deletions(-) diff --git a/src/engine/scheduler/CalendarEngine.ts b/src/engine/scheduler/CalendarEngine.ts index edcdaf442..51b8ce320 100644 --- a/src/engine/scheduler/CalendarEngine.ts +++ b/src/engine/scheduler/CalendarEngine.ts @@ -809,6 +809,17 @@ export class CalendarEngine { * kalenderprimitief: `subtractWorkMinutes` blijft de inverse van `addWorkMinutes`. De solver mag * deze methode alleen kiezen wanneer óók het project `schedulingOptions.p6Source === 'XER'` * draagt; deze kalender controleert onafhankelijk haar eigen `p6Source` via de lege index. + * + * WAAROM DEZE PROJECTIE OP EEN AL VRIJE DAG BLIJFT DUBBELTELLEN (etappe 7b-2, gemeten). Elke + * `p6NonWorkPenaltyDate` is per constructie óók een gewone holiday op deze kalender (de + * XER-decoder maakt van hetzelfde vrije-uitzonderingsrecord beide). Een generieke wacht "sla een + * penaltydag over die fysiek al niet werkt" is daarom hetzelfde als de hele projectie schrappen, + * en dat is gemeten: X12-productfidelity ging daarmee van 18.398 naar 21.588 afwijkingen. De + * projectie blijft dus staan voor de twaalf corpusbestanden waarvoor ze het enige model is. + * Het dubbeltellen wordt niet hier maar bij de BRON opgelost: zodra de decoder de werkelijk + * bedoelde vrije dag kan reconstrueren (`weekendClampTarget` in `xerCalendarData.ts`), levert hij + * voor diezelfde datum géén penaltydatum meer. Beide helften moeten samen landen — elk + * afzonderlijk maakt het corpus meetbaar slechter (18.398 → 21.673 resp. 21.588, samen 17.421). */ subtractP6XerProjectedWorkMinutes(endInstant: Date, minutes: number): Date { if (minutes <= 0 || this.p6NonWorkPenaltyDayIdxSorted.length === 0) { diff --git a/src/services/xer/xerCalendarData.ts b/src/services/xer/xerCalendarData.ts index 972653503..6b90f4773 100644 --- a/src/services/xer/xerCalendarData.ts +++ b/src/services/xer/xerCalendarData.ts @@ -223,6 +223,61 @@ function dateFromP6Epoch(raw: string): string { 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? + * + * HET BEWIJS (corpus `crawl-xer-extra/jailaff-xer-splitter/rehab-2.xer`, kalender 842 `R111 - 1`: + * ma-do + za + zo werkend, vrijdag vrij). De negen feestdagenblokken in `clndr_data` 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. Ditzelfde patroon staat in alle + * negen blokken van die kalender, en in het hele corpus van 93 bestanden komt een AANGRENZEND + * DUPLICAAT verder in geen enkel bestand voor. De schrijver heeft de blokdagen dus op de MA-VR-as + * geklemd: **zaterdag → de vrijdag ervóór, zondag → de maandag erná**. + * + * Onafhankelijke bevestiging op dezelfde kalender: alle 23 redundante records staan op een vrijdag + * of een maandag (0 op een andere weekdag), en de tien dagen die de set-cover van het + * 761-diagnosedossier onafhankelijk als "moeten vrij zijn" aanwees — 2008-10-04, 2008-10-05, + * 2008-12-07, 2008-12-13, 2008-12-14, 2009-09-19, 2009-09-20, 2009-11-28, 2009-11-29, 2009-12-05 — + * zijn exact de klemdoelen van tien van die 23 records. Eén-op-één, geen rest. + * + * DE POORT. Deze reconstructie draait alleen wanneer de kalender zélf een aangrenzend duplicaat + * draagt (zie de aanroepplek): dat duplicaat IS het bewijs dat er een dag verloren ging. Verder + * eist deze functie dat het klemdoel (a) daadwerkelijk een zaterdag of zondag is, (b) op deze + * kalender een WERKdag is — anders verandert vrij maken niets — en (c) niet zelf al een + * uitzonderingsrecord heeft. Corpusmeting van die drie eisen samen: alleen rehab-2 en de twee + * Hotel-bestanden hebben überhaupt klembare records, en de Hotel-bestanden hebben geen duplicaat + * en blijven dus onaangeraakt. + */ +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; +} + function calendarType(raw: string): XerCalendarType { switch (raw.trim().toLowerCase()) { case 'ca_base': return 'GLOBAL'; @@ -538,12 +593,15 @@ export function decodeXerCalendarData(text: string): DecodedXerCalendarData { 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]; @@ -558,6 +616,7 @@ export function decodeXerCalendarData(text: string): DecodedXerCalendarData { if (bands.byWeekday[isoDay].length === 0 || previousNonWorkDate === date) { p6NonWorkPenaltyDates.add(date); } + if (previousNonWorkDate === date) adjacentDuplicateDates.add(date); previousNonWorkDate = date; } else { previousNonWorkDate = undefined; @@ -577,6 +636,22 @@ export function decodeXerCalendarData(text: string): DecodedXerCalendarData { // 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). Zie `weekendClampTarget` voor het volledige bewijs: een schrijver die + // een uitzonderingsblok op de MA-VR-as klemt levert zaterdagen af op de vrijdag ervóór en zondagen + // op de maandag erná. Het bewijs dát deze kalender zo geschreven is, is de AANGRENZENDE DUPLICAAT: + // twee opeenvolgende records met dezelfde epochdag kunnen alleen ontstaan als twee verschillende + // brondagen op dezelfde datum zijn geklemd. Zonder zo'n duplicaat blijft alles ongewijzigd, dus + // 92 van de 93 corpusbestanden zijn hier byte-identiek. + for (const date of adjacentDuplicateDates.size > 0 ? [...p6NonWorkPenaltyDates] : []) { + const target = weekendClampTarget(date, bands, exceptionDates); + if (target === null) continue; + holidaysByDate.set(target, { + name: 'Kalenderuitzondering (weekendherstel)', startDate: target, endDate: target, + }); + // 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'); return { diff --git a/tests/planning/check-xer-calendar-data.ts b/tests/planning/check-xer-calendar-data.ts index b9dfcfc92..354651efb 100644 --- a/tests/planning/check-xer-calendar-data.ts +++ b/tests/planning/check-xer-calendar-data.ts @@ -529,6 +529,65 @@ eq('20 P6XML levert vóór gedeelde canonicalisatie de letterlijk verwachte ruwe 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 aangrenzende duplicaat in de +// `Exceptions`-lijst is daarvan het bewijs. Deze fixture is de LETTERLIJKE recordreeks van blok +// 2009-11-26 … 2009-12-05 uit kalender 842 van `rehab-2.xer` — tien blokdagen, tien records, +// waarvan drie duplicaten — op de calenderweek van diezelfde kalender: P6-dag 1=zo … 7=za, dus +// werkend op ma-do + za + zo en vrij op vrijdag (P6-dag 6 ontbreekt). +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), + ]); +} +// P6-dagnummers: 1=zo, 2=ma, 3=di, 4=wo, 5=do, 6=vr, 7=za. Vrijdag (6) ontbreekt bewust. +const FRIDAY_OFF_WEEK = [1, 2, 3, 4, 5, 7]; +const MON_FRI_WEEK = [2, 3, 4, 5, 6]; +// 40143=2009-11-26 (do) … 40151=2009-12-04 (vr); 11-28 (za), 11-29 (zo) en 12-05 (za) ontbreken en +// staan in plaats daarvan als duplicaat op 11-27 (vr), 11-30 (ma) en 12-04 (vr). +const CLAMPED_BLOCK = [40143, 40144, 40144, 40147, 40147, 40148, 40149, 40150, 40151, 40151]; +const UNCLAMPED_BLOCK = [40143, 40144, 40147, 40148, 40149, 40150, 40151]; + +const clamped = decodeXerCalendarData(calendarData(FRIDAY_OFF_WEEK, CLAMPED_BLOCK)); +eq('22 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('22 een herstelde dag telt niet ook nog als virtuele P6-niet-werkdag', + clamped.p6NonWorkPenaltyDates, []); + +// Mutatiebewijs 1 — zonder de duplicaten is er geen bewijs van een geklemde schrijver, dus geen +// herstel: precies het gedrag van vóór deze etappe, inclusief de twee vrijdagstraffen. +const unclamped = decodeXerCalendarData(calendarData(FRIDAY_OFF_WEEK, UNCLAMPED_BLOCK)); +eq('22 zonder aangrenzende duplicaat blijft het blok onaangeroerd', + unclamped.holidays.map(holiday => holiday.startDate).sort(), [ + '2009-11-26', '2009-11-27', '2009-11-30', '2009-12-01', '2009-12-02', '2009-12-03', '2009-12-04', + ]); +eq('22 zonder herstel blijven de redundante vrijdagrecords P6-niet-werkdagen', + unclamped.p6NonWorkPenaltyDates, ['2009-11-27', '2009-12-04']); + +// Mutatiebewijs 2 — dezelfde duplicaten op een gewone MA-VR-kalender: het klemdoel is daar zelf al +// niet-werkend, dus herstellen zou niets veranderen en gebeurt niet. Zo blijft de reconstructie +// beperkt tot kalenders waar een weekenddag écht werkt. +const monFri = decodeXerCalendarData(calendarData(MON_FRI_WEEK, CLAMPED_BLOCK)); +eq('22 op een ma-vr-kalender wordt er niets hersteld', + monFri.holidays.map(holiday => holiday.startDate).sort(), [ + '2009-11-26', '2009-11-27', '2009-11-30', '2009-12-01', '2009-12-02', '2009-12-03', '2009-12-04', + ]); +eq('22 op een ma-vr-kalender blijven de aangrenzende duplicaten P6-niet-werkdagen', + monFri.p6NonWorkPenaltyDates, ['2009-11-27', '2009-11-30', '2009-12-04']); + if (diffs.length === 0) { console.log(`OK xer-calendar-data: ${checks} checks groen`); process.exit(0); diff --git a/tests/planning/xer-calendar-hour-mode-baseline.json b/tests/planning/xer-calendar-hour-mode-baseline.json index c64424528..035e2477e 100644 --- a/tests/planning/xer-calendar-hour-mode-baseline.json +++ b/tests/planning/xer-calendar-hour-mode-baseline.json @@ -76,6 +76,6 @@ "hash": "2c1dce175b9f078111a48dc13fd1777f5fbd4cd7ab6623e647e7437330c60b7f", "calendars": 124, "dayHourFieldsPresent": 0, - "semanticDigest": "7234268c6adcbdb6e0c2224721959060f319e23472cd13068f1d6eca2a5f2066" + "semanticDigest": "d6221c87cbcbb6b29fc63365bd318f8f79f5ce6fa199e0706c6eac444b4c05f5" } } diff --git a/tests/planning/xer-schedoptions-blast-radius.json b/tests/planning/xer-schedoptions-blast-radius.json index 894e8c045..e1fa03082 100644 --- a/tests/planning/xer-schedoptions-blast-radius.json +++ b/tests/planning/xer-schedoptions-blast-radius.json @@ -44,47 +44,191 @@ "loeDataDate": { "fields": ["deviations", "measurable"], "rows": [ - { "branch": "house", "axis": "es", "deviations": 6639, "measurable": 7637 }, - { "branch": "house", "axis": "ef", "deviations": 6510, "measurable": 7649 }, - { "branch": "house", "axis": "ls", "deviations": 7460, "measurable": 7519 }, - { "branch": "house", "axis": "lf", "deviations": 7427, "measurable": 7510 }, - { "branch": "house", "axis": "tf", "deviations": 6932, "measurable": 7602 }, - { "branch": "house", "axis": "ff", "deviations": 2352, "measurable": 7221 }, - { "branch": "xerDefaults", "axis": "es", "deviations": 1062, "measurable": 7637 }, - { "branch": "xerDefaults", "axis": "ef", "deviations": 1287, "measurable": 7649 }, - { "branch": "xerDefaults", "axis": "ls", "deviations": 4791, "measurable": 7519 }, - { "branch": "xerDefaults", "axis": "lf", "deviations": 4781, "measurable": 7510 }, - { "branch": "xerDefaults", "axis": "tf", "deviations": 4592, "measurable": 7602 }, - { "branch": "xerDefaults", "axis": "ff", "deviations": 502, "measurable": 7221 } + { "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": 4322, "measurable": 7519 }, + { "branch": "xerDefaults", "axis": "lf", "deviations": 4320, "measurable": 7510 }, + { "branch": "xerDefaults", "axis": "tf", "deviations": 4295, "measurable": 7602 }, + { "branch": "xerDefaults", "axis": "ff", "deviations": 303, "measurable": 7221 } ] }, "completedProgress": { "fields": ["deviations", "measurable"], "rows": [ - { "branch": "retainedLogic", "variant": "chosen", "axis": "es", "deviations": 6639, "measurable": 7637 }, - { "branch": "retainedLogic", "variant": "chosen", "axis": "ef", "deviations": 6510, "measurable": 7649 }, - { "branch": "retainedLogic", "variant": "chosen", "axis": "ls", "deviations": 7460, "measurable": 7519 }, - { "branch": "retainedLogic", "variant": "chosen", "axis": "lf", "deviations": 7427, "measurable": 7510 }, - { "branch": "retainedLogic", "variant": "chosen", "axis": "tf", "deviations": 6932, "measurable": 7602 }, - { "branch": "retainedLogic", "variant": "chosen", "axis": "ff", "deviations": 2352, "measurable": 7221 }, - { "branch": "retainedLogic", "variant": "counterfactual", "axis": "es", "deviations": 7020, "measurable": 7637 }, - { "branch": "retainedLogic", "variant": "counterfactual", "axis": "ef", "deviations": 6922, "measurable": 7649 }, - { "branch": "retainedLogic", "variant": "counterfactual", "axis": "ls", "deviations": 7475, "measurable": 7519 }, - { "branch": "retainedLogic", "variant": "counterfactual", "axis": "lf", "deviations": 7444, "measurable": 7510 }, - { "branch": "retainedLogic", "variant": "counterfactual", "axis": "tf", "deviations": 7410, "measurable": 7602 }, - { "branch": "retainedLogic", "variant": "counterfactual", "axis": "ff", "deviations": 2519, "measurable": 7221 }, - { "branch": "preserveActualDates", "variant": "chosen", "axis": "es", "deviations": 6639, "measurable": 7637 }, - { "branch": "preserveActualDates", "variant": "chosen", "axis": "ef", "deviations": 6510, "measurable": 7649 }, - { "branch": "preserveActualDates", "variant": "chosen", "axis": "ls", "deviations": 7439, "measurable": 7519 }, - { "branch": "preserveActualDates", "variant": "chosen", "axis": "lf", "deviations": 7417, "measurable": 7510 }, - { "branch": "preserveActualDates", "variant": "chosen", "axis": "tf", "deviations": 6402, "measurable": 7602 }, - { "branch": "preserveActualDates", "variant": "chosen", "axis": "ff", "deviations": 713, "measurable": 7221 }, - { "branch": "preserveActualDates", "variant": "counterfactual", "axis": "es", "deviations": 6639, "measurable": 7637 }, - { "branch": "preserveActualDates", "variant": "counterfactual", "axis": "ef", "deviations": 6510, "measurable": 7649 }, - { "branch": "preserveActualDates", "variant": "counterfactual", "axis": "ls", "deviations": 7460, "measurable": 7519 }, - { "branch": "preserveActualDates", "variant": "counterfactual", "axis": "lf", "deviations": 7427, "measurable": 7510 }, - { "branch": "preserveActualDates", "variant": "counterfactual", "axis": "tf", "deviations": 6932, "measurable": 7602 }, - { "branch": "preserveActualDates", "variant": "counterfactual", "axis": "ff", "deviations": 2352, "measurable": 7221 } + { + "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 + } ] } }, @@ -4339,10 +4483,7 @@ } ], "expectedFinishVariant": { - "selection": { - "chosen": true, - "counterfactual": false - }, + "selection": { "chosen": true, "counterfactual": false }, "population": { "oracleFiles": 58, "readableFiles": 56, @@ -4354,30 +4495,14 @@ "sourceFiles": 2, "movingFiles": 0 }, - "movement": [ - 0, - 0, - 0, - 0, - 0, - 0, - 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 - ], + "movement": [0, 0, 0, 0, 0, 0, 0], "directionChanges": 0, "directionDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945" }, @@ -4386,71 +4511,27 @@ "tasks": 70, "sourceTasks": 70, "activeSourceTasks": 0, - "movement": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], + "movement": [0, 0, 0, 0, 0, 0, 0], "directionChanges": 0, "directionDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945" } ], "fidelity": { "chosen": { - "es": { - "deviations": 9202, - "measurable": 18524 - }, - "ef": { - "deviations": 9121, - "measurable": 18536 - }, - "ls": { - "deviations": 8356, - "measurable": 18406 - }, - "lf": { - "deviations": 8557, - "measurable": 18397 - }, - "tf": { - "deviations": 10092, - "measurable": 18296 - }, - "ff": { - "deviations": 3559, - "measurable": 17915 - } + "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": 9202, - "measurable": 18524 - }, - "ef": { - "deviations": 9121, - "measurable": 18536 - }, - "ls": { - "deviations": 8356, - "measurable": 18406 - }, - "lf": { - "deviations": 8557, - "measurable": 18397 - }, - "tf": { - "deviations": 10092, - "measurable": 18296 - }, - "ff": { - "deviations": 3559, - "measurable": 17915 - } + "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 } } } }, diff --git a/tests/planning/xer-task-replay-public-pin.json b/tests/planning/xer-task-replay-public-pin.json index 8cb540a8f..55b431cba 100644 --- a/tests/planning/xer-task-replay-public-pin.json +++ b/tests/planning/xer-task-replay-public-pin.json @@ -23,11 +23,11 @@ "aggregate": { "es": { "improved": 0, "regressed": 0, "unchanged": 13931 }, "ef": { "improved": 0, "regressed": 2, "unchanged": 13935 }, - "ls": { "improved": 0, "regressed": 690, "unchanged": 13132 }, - "lf": { "improved": 0, "regressed": 704, "unchanged": 13109 }, - "tf": { "improved": 0, "regressed": 629, "unchanged": 13048 }, + "ls": { "improved": 0, "regressed": 731, "unchanged": 13091 }, + "lf": { "improved": 0, "regressed": 742, "unchanged": 13071 }, + "tf": { "improved": 0, "regressed": 706, "unchanged": 12971 }, "ff": { "improved": 0, "regressed": 0, "unchanged": 13322 }, - "overall": { "improved": 0, "regressed": 704, "unchanged": 13255 } + "overall": { "improved": 0, "regressed": 742, "unchanged": 13217 } }, "rejected": true, "exitCode": 1, From f33dbecb0e4aaba4f2c5d5d1f800f8e27963047e Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:17:26 +0200 Subject: [PATCH 143/171] fix(xer): klemherstel per record + P6-penaltyprojectie eruit (7b herwerkt na review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Herwerking van `fbcdb731` na de hyperkritische review (`review-7b.md`, NIET LANDEN). Alle zeven punten afgewerkt, elk met eigen meting. 1. POORT PER RECORD (was: per kalender). `adjacentDuplicateDates.size > 0` schakelde de hele kalender om; de reviewer bewees daarmee twee false positives. De poort zit nu op het record zelf, in twee benoemde bewijsvormen (`hasWeekendClampEvidence`): * SPRONG — het record staat op een vrijdag én er is een uitzondering op de maandag erná (of andersom), met een werkend, niet-opgeslagen weekend ertussen; * DUPLICAAT MÉT BLOKVERVOLG — aangrenzend duplicaat én het blok loopt aan de binnenzijde door. Fidelity ongewijzigd: rehab-2 blijft 13.835 (gemeten met een losse per-bestandprobe op alle zeven gate-varianten; kalenderbreed 13.835, alleen-duplicaat 15.195, alleen-sprong 15.866, sprong+duplicaat 13.835). Fixtures 22c/22d in `check-xer-calendar-data.ts` zijn exact de twee false positives; tegen de kalenderbrede decoder gaan ze rood (2 afwijkingen, exit 1). 2. TWEE BLOKKEN ZONDER OPENINGSDUPLICAAT verantwoord. 2006-12-29 en 2009-09-18 dragen geen duplicaat; ze worden door de SPRONG-vorm gedekt, niet meer impliciet door een kalenderbrede vlag. Fixture 22b pint dat (2009-09-19 is een van de tien set-cover-dagen). 3. REIKWIJDTE eerlijk. Niet "23 records op één kalender": rehab-2 deelt zijn `clndr_data` 44× (groep van kalender 842) en 75× (groep van 896), dus 119 van de 124 kalenders krijgen elk dezelfde 23 dagen — 2.737 dagvermeldingen. Taken staan alleen op de 842-groep (6.548 van de 6.977) en op kalender 893 (429); de 896-groep is takenloos. 4. BRONTOETS op de reconstructie (het antwoord op de ES/EF-vraag). Voor de 6.548 taken op de 842-groep zet P6 in álle negen blokken — inclusief de gereconstrueerde weekenddagen — NUL ES, EF, LS en LF, terwijl hij in de drie dagen direct vóór en ná de vier blokken die in de projectperiode vallen 125 (okt-2008), 141 (dec-2008), 64 (sep-2009) en 78 (nov-2009) datums zet. De reconstructie is dus door de bron bevestigd. De 14 EF's op 2008-12-14 die op het eerste gezicht het tegendeel leken te bewijzen horen bij kalender 893 `R111 - 2`, een 7-daagse kalender zonder uitzonderingen die hier niet geraakt wordt. 5. BLOKABLATIE gedraaid (Plan §4.5), per as, op rehab-2: basis 14.812 es 618 ef 813 ls 4358 lf 4350 tf 4200 ff 473 alle negen blokken 13.835 es 940 ef 940 ls 3889 lf 3889 tf 3903 ff 274 zonder dec-2008 13.314 es 620 ef 684 ls 3951 lf 3948 tf 3785 ff 326 zonder okt-2008 13.394 es 741 ef 785 ls 3890 lf 3890 tf 3753 ff 335 zonder sep-2009 14.672 zonder nov-2009 14.171 zonder okt+dec 13.451 Per LOSSE DAG weglaten is telkens fors slechter (14.5k–15.2k): een half gereconstrueerd blok geeft datums die noch die van P6 noch de fysieke zijn. De vijf blokken buiten de projectperiode (13 dagen) hebben exact nul effect. KEUZE: alle negen blokken. `zonder dec-2008` scoort 521 cellen beter en zou de ES-regressie wegnemen, maar punt 4 laat zien dat P6 juist rond dát blok 141 datums zet en er géén ín — het blok vrij maken is brongetrouw. Een kalender bewust fout zetten om celtellingen te kopen is geen model. 6. ES/EF-REGRESSIE als open residu benoemd, niet weggepoetst: 344 nieuw kapotte ES- en 327 nieuw kapotte EF-cellen (es 618→940, ef 813→940), 343 daarvan op de 842-groep, 281× één dag en 56× twee dagen te LAAT. Het is een forward-ANKER-gat, geen kalendergat: voor `V3109400` telt het venster van P6 (2008-12-04 → 12-24) op de gereconstrueerde kalender exact zeven werkdagen, en ons venster (12-06 → 12-25) óók — de duurwandeling klopt dus, alleen het startanker ligt één werkdag later. De vijf getraceerde taken uit de review (V3109400, V3109300, V3109420, V3109220, V3109480) zijn het startpunt voor die vervolgetappe. 7. P6-XER-PENALTYPROJECTIE VERWIJDERD. Gemeten per bestand, met en zonder de projectie, op alle zes penaltydragende corpusprojecten: rehab-2 13.835/13.835, Hotel Project 308/308, Hotel_Construction_TEC 308/308, Roads_Project_TEC 1006/1006, p6_torture_test_v1 0/0, Harbour Point As-Built 0/0, Harbour Point Baseline 93/93 — byte-identiek op alle zes de assen. Ze verklaart nergens meer één cel. `subtractP6XerProjectedWorkMinutes`, `p6XerProjectedWorkMinutesBetween` en hun solveraanroepen zijn weg; `CalendarEngine` kent daarmee geen brongebonden asymmetrie meer en `check-calendar-mirror.ts` deel C pint dat nu andersom. `WorkCalendar.p6NonWorkPenaltyDates` blijft als BRONDIAGNOSE bestaan (round- trip door het IFC); geen solverpad leest het veld nog. De eerdere claim "voor twaalf andere corpusbestanden nog steeds het enige model" was ongemeten en is hiermee ingetrokken; de 18.398 → 21.588 uit `fbcdb731` mat de schrapping van rehab-2's eigen klemdatums, niet een generieke schrapping. X12-PRODUCTFIDELITY, per bestand (`OPS_XER_FIDELITY_REPORT=baseline`): totaal 18.398 → 17.421 (−977) rehab-2.xer 14.812 → 13.835 (−977) alle 28 overige bestanden ongewijzigd, 0 verschil per as: es +322, ef +127, ls −469, lf −461, tf −297, ff −199 BASELINES. `xer-schedoptions-blast-radius.json` staat weer in zijn oorspronkelijke opmaak (diff t.o.v. 7a3dece6: 79/16 regels i.p.v. 311). Er bewegen 47 bladwaarden, allemaal `deviations`-tellers in de tegenvariant-/causale takken: 45 omlaag (grootste `completedProgress.rows.14` 7439 → 5248, `expectedFinishVariant.fidelity.*.ls` 8356 → 6176, `.ef` 9121 → 7002) en 2 omhoog — en die twee zijn exact de ES/EF-assen van de `xerDefaults`-tak (1062 → 1384 en 1287 → 1414), dezelfde +322/+127 als hierboven. De 124-kalenderdigest en de openbare task-replaypin bewegen mee met de nieuwe kalender. GEEN OPMAAKRUIS: `git diff -w` is regel-voor-regel gelijk aan `git diff` (de CRLF-flip die `7a3dece6` in `CalendarEngine.ts` meebracht is hier niet herhaald). POORTEN. `OPS_XER_CORPUS=… npm run test:planning` exit 1 met uitsluitend: de drie X12-nuldoelregels (totaal nu 17.421), de X12-regel die de v1-overgangsbaseline met de v2-meting vergelijkt (al rood vóór deze etappe, ander schema), en `milestone-duration-render`, dat op basiscommit a669108e al rood staat. 560/560 CPM-cases groen; `mpp-fidelity` groen (2196 checks, 216 bestanden, netto ongewijzigd); X12 ZONDER corpus groen (74 corpusloze checks), dus CI en deploy blijven groen. `npm run typecheck` exit 0, `npm run lint` exit 0. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 1d1bc96d611e55d409addb67d290ae114945c2cf) --- src/engine/scheduler/CPMSolver.ts | 23 +---- src/engine/scheduler/CalendarEngine.ts | 78 ++------------- src/engine/scheduler/scheduleAnalysis.ts | 5 +- src/services/xer/xerCalendarData.ts | 84 ++++++++++------ src/types/calendar.ts | 15 ++- tests/planning/check-calendar-mirror.ts | 72 +++++--------- tests/planning/check-xer-calendar-data.ts | 77 +++++++++------ .../check-xer-product-fidelity-x12.ts | 23 +++-- .../xer-schedoptions-blast-radius.json | 95 +++++++++++++++---- 9 files changed, 250 insertions(+), 222 deletions(-) diff --git a/src/engine/scheduler/CPMSolver.ts b/src/engine/scheduler/CPMSolver.ts index 8f745e045..bb7f49ad5 100644 --- a/src/engine/scheduler/CPMSolver.ts +++ b/src/engine/scheduler/CPMSolver.ts @@ -477,20 +477,6 @@ export class CPMSolver { this.backwardFloatTrace.byTaskId[taskId] = { ...previous, ...update }; } - /** Smalle P6-XER-resultaatprojectie; de CalendarEngine-primitieven blijven formaatneutraal. */ - private p6ProjectedSubtract(eng: CalendarEngine, end: Date, minutes: number): Date { - return this.options.schedulingOptions?.p6Source === 'XER' - ? eng.subtractP6XerProjectedWorkMinutes(end, minutes) - : eng.subtractWorkMinutes(end, minutes); - } - - /** Smalle P6-XER-projectie voor TF/FF en relationship free float. */ - private projectedWorkMinutesBetween(eng: CalendarEngine, a: Date, b: Date): number { - return this.options.schedulingOptions?.p6Source === 'XER' - ? eng.p6XerProjectedWorkMinutesBetween(a, b) - : eng.workMinutesBetween(a, b); - } - /** 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; @@ -902,7 +888,7 @@ export class CPMSolver { } if (eng.isHourMode && taskDurationUnit(task) === 'hours') { const totalMinutes = splitTotalSpanMinutes(task.splitGaps, durationMinutesOf(task, eng)); - const natural = this.p6ProjectedSubtract(eng, end, totalMinutes); + 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()) { @@ -997,7 +983,7 @@ export class CPMSolver { ) / (eng.isHourMode ? 1 : eng.hoursPerDay * 60); } return eng.isHourMode - ? this.p6ProjectedSubtract(eng, end, remainingWithGaps) + ? eng.subtractWorkMinutes(end, remainingWithGaps) : eng.subtractWorkDays(end, remainingWithGaps); } @@ -1064,7 +1050,7 @@ export class CPMSolver { if (predEng.isHourMode) { const minutes = this.resolveLagMinutes(seq, predTask, predEng); if (sign < 0 && minutes > 0) { - const projected = this.p6ProjectedSubtract(predEng, base, minutes); + 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 @@ -1179,7 +1165,7 @@ export class CPMSolver { private signedFloat(a: Date, b: Date, eng: CalendarEngine, task?: Task): number { if (task?.time.durationType === 'ELAPSEDTIME') return signedElapsedSpan(a, b, eng); if (eng.isHourMode && (!task || isZeroDurationMilestone(task) || taskDurationUnit(task) === 'hours')) { - return this.projectedWorkMinutesBetween(eng, a, b) / (eng.hoursPerDay * 60); + return eng.workMinutesBetween(a, b) / (eng.hoursPerDay * 60); } return this.signedWorkDays(a, b, eng); } @@ -1287,7 +1273,6 @@ export class CPMSolver { calendarFor: (t) => this.calendarFor(t), progressCalendarFor: (t) => this.progressCalendarFor(t), signedFloat: (a, b, eng, task) => this.signedFloat(a, b, eng, task), - projectedWorkMinutesBetween: (eng, a, b) => this.projectedWorkMinutesBetween(eng, a, b), constraintInstant: (c, eng) => this.constraintInstant(c, eng), snapOnOrAfter: (eng, d) => this.snapOnOrAfter(eng, d), snapOnOrBefore: (eng, d) => this.snapOnOrBefore(eng, d), diff --git a/src/engine/scheduler/CalendarEngine.ts b/src/engine/scheduler/CalendarEngine.ts index 51b8ce320..274e24dc5 100644 --- a/src/engine/scheduler/CalendarEngine.ts +++ b/src/engine/scheduler/CalendarEngine.ts @@ -41,9 +41,6 @@ export class CalendarEngine { // `workDaysPerWeek`/`countWorkWeekdays` (die kennen alleen het vaste weekpatroon) en moet dus als // EXTRA werkdag worden opgeteld in `workDaysBetween` — vandaar een eigen gesorteerde index. private workingExceptionOnNonWorkWeekdayIdxSorted: number[]; - // Alleen data voor de EXPLICIETE P6-XER-projectiemethoden onderaan. De generieke kalender- - // algebra leest deze index nergens en blijft daardoor fysiek en volledig invers. - private p6NonWorkPenaltyDayIdxSorted: number[]; // Veiligheidsgrenzen tegen vastlopen bij een kapotte kalender (geen werkdagen) // of een ongeldige/sentinel-datum: MAX_SCAN = max dagen zoeken naar een werkdag; // MAX_DAYS = absolute iteratielimiet (~547 jaar) voor de tel-lussen. @@ -98,11 +95,6 @@ export class CalendarEngine { this.workingExceptionOnNonWorkWeekdayIdxSorted = [...this.workingExceptionDaySet] .filter((idx) => !this.workDayMask[isoDayOfWeek(new Date(idx * CalendarEngine.MS_PER_DAY))]) .sort((a, b) => a - b); - this.p6NonWorkPenaltyDayIdxSorted = calendar.p6Source === 'XER' - ? [...new Set((calendar.p6NonWorkPenaltyDates ?? []).map(date => - Math.floor(parseDate(date).getTime() / CalendarEngine.MS_PER_DAY), - ).filter(Number.isFinite))].sort((a, b) => a - b) - : []; // ── Fase 2.8b: modus-detectie + uur-setup (§4.1). Afwezige `workTime` ⇒ dag-modus: // dan wordt niets hieronder geraakt en draaien de bevroren dag-lussen ongewijzigd. this.mode = calendar.workTime ? 'hour' : 'day'; @@ -734,11 +726,13 @@ export class CalendarEngine { // juiste gelijkheidstest tussen twee posities op de werk-as is `workMinutesBetween(a, b) === 0`, // NIET `a.getTime() === b.getTime()`. // - // De enige asymmetrie in dit bestand is `subtractP6XerProjectedWorkMinutes` onderaan, en die is - // uitdrukkelijk een P6/XER-RESULTAATPROJECTIE achter `p6Source === 'XER'` — geen kalenderprimitief. - // `tests/planning/check-calendar-mirror.ts` pint deze drie regels én die ene bronpoort vast, over - // een aaneengesloten niet-werkblok van tien dagen, in dag-modus en in uur-modus met 1, 2 en 3 - // banden per dag. + // 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 @@ -748,7 +742,7 @@ export class CalendarEngine { return this.addPhysicalWorkMinutes(startInstant, minutes); } - /** Bestaande fysieke bandwandeling, zonder P6-bronpenalty. */ + /** 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); @@ -776,7 +770,7 @@ export class CalendarEngine { return this.subtractPhysicalWorkMinutes(endInstant, minutes); } - /** Bestaande fysieke achterwaartse bandwandeling, zonder P6-bronpenalty. */ + /** 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); @@ -804,59 +798,7 @@ export class CalendarEngine { return this.physicalWorkMinutesBetween(a, b); } - /** - * P6-XER-resultaatprojectie voor een backward duur-/lagwandeling. Dit is uitdrukkelijk GEEN - * kalenderprimitief: `subtractWorkMinutes` blijft de inverse van `addWorkMinutes`. De solver mag - * deze methode alleen kiezen wanneer óók het project `schedulingOptions.p6Source === 'XER'` - * draagt; deze kalender controleert onafhankelijk haar eigen `p6Source` via de lege index. - * - * WAAROM DEZE PROJECTIE OP EEN AL VRIJE DAG BLIJFT DUBBELTELLEN (etappe 7b-2, gemeten). Elke - * `p6NonWorkPenaltyDate` is per constructie óók een gewone holiday op deze kalender (de - * XER-decoder maakt van hetzelfde vrije-uitzonderingsrecord beide). Een generieke wacht "sla een - * penaltydag over die fysiek al niet werkt" is daarom hetzelfde als de hele projectie schrappen, - * en dat is gemeten: X12-productfidelity ging daarmee van 18.398 naar 21.588 afwijkingen. De - * projectie blijft dus staan voor de twaalf corpusbestanden waarvoor ze het enige model is. - * Het dubbeltellen wordt niet hier maar bij de BRON opgelost: zodra de decoder de werkelijk - * bedoelde vrije dag kan reconstrueren (`weekendClampTarget` in `xerCalendarData.ts`), levert hij - * voor diezelfde datum géén penaltydatum meer. Beide helften moeten samen landen — elk - * afzonderlijk maakt het corpus meetbaar slechter (18.398 → 21.673 resp. 21.588, samen 17.421). - */ - subtractP6XerProjectedWorkMinutes(endInstant: Date, minutes: number): Date { - if (minutes <= 0 || this.p6NonWorkPenaltyDayIdxSorted.length === 0) { - return this.subtractPhysicalWorkMinutes(endInstant, minutes); - } - let adjustedMinutes = minutes; - let result = this.subtractPhysicalWorkMinutes(endInstant, adjustedMinutes); - for (let iteration = 0; iteration <= this.p6NonWorkPenaltyDayIdxSorted.length; iteration++) { - const nextMinutes = minutes + this.p6NonWorkPenaltyMinutesBetween(result, endInstant); - if (nextMinutes === adjustedMinutes) return result; - adjustedMinutes = nextMinutes; - result = this.subtractPhysicalWorkMinutes(endInstant, adjustedMinutes); - } - return result; - } - - /** P6-XER-resultaatprojectie voor TF/FF; de generieke `workMinutesBetween` blijft fysiek. */ - p6XerProjectedWorkMinutesBetween(a: Date, b: Date): number { - const physical = this.physicalWorkMinutesBetween(a, b); - if (physical === 0 || this.p6NonWorkPenaltyDayIdxSorted.length === 0) return physical; - const sign = physical > 0 ? 1 : -1; - const adjusted = Math.max(0, Math.abs(physical) - this.p6NonWorkPenaltyMinutesBetween(a, b)); - return sign * adjusted; - } - - /** Virtuele extra P6-nietwerktijd in het datum-inclusieve venster tussen twee instants. */ - private p6NonWorkPenaltyMinutesBetween(a: Date, b: Date): number { - if (this.p6NonWorkPenaltyDayIdxSorted.length === 0) return 0; - const aIdx = Math.floor(a.getTime() / CalendarEngine.MS_PER_DAY); - const bIdx = Math.floor(b.getTime() / CalendarEngine.MS_PER_DAY); - const startIdx = Math.min(aIdx, bIdx); - const lastIdx = Math.max(aIdx, bIdx); - return this.countSortedIdxInRange(this.p6NonWorkPenaltyDayIdxSorted, startIdx, lastIdx) - * this.hoursPerDay * 60; - } - - /** Bestaande fysieke bandminuten in `[a,b)`, zonder P6-bronpenalty. */ + /** Fysieke bandminuten in `[a,b)`. */ private physicalWorkMinutesBetween(a: Date, b: Date): number { const aMs = a.getTime(); const bMs = b.getTime(); diff --git a/src/engine/scheduler/scheduleAnalysis.ts b/src/engine/scheduler/scheduleAnalysis.ts index 6d2fb8908..b04059789 100644 --- a/src/engine/scheduler/scheduleAnalysis.ts +++ b/src/engine/scheduler/scheduleAnalysis.ts @@ -43,7 +43,6 @@ export interface ScheduleAnalysisInput { * `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. */ - projectedWorkMinutesBetween: (eng: CalendarEngine, a: Date, b: Date) => number; constraintInstant: (c: TaskConstraint | undefined, eng: CalendarEngine) => Date | null; snapOnOrAfter: (eng: CalendarEngine, d: Date) => Date; snapOnOrBefore: (eng: CalendarEngine, d: Date) => Date; @@ -64,7 +63,7 @@ export function computeScheduleResults(input: ScheduleAnalysisInput): CPMResult schedulingOptions, dataDate, truncatedLeadIds, hardPinViolatedIds, hammockNoFinishDriverIds, projectEngine, - calendarFor, progressCalendarFor, signedFloat, projectedWorkMinutesBetween, + calendarFor, progressCalendarFor, signedFloat, constraintInstant, snapOnOrAfter, snapOnOrBefore, modeOf, backwardFloatTrace, } = input; @@ -126,7 +125,7 @@ export function computeScheduleResults(input: ScheduleAnalysisInput): CPMResult const succCal = calendarFor(succTask); const reqStart = snapOnOrAfter(succCal, cRaw); const relFloat = succCal.isHourMode - ? projectedWorkMinutesBetween(succCal, reqStart, succEarly.es) / (succCal.hoursPerDay * 60) + ? succCal.workMinutesBetween(reqStart, succEarly.es) / (succCal.hoursPerDay * 60) : succCal.workDaysBetween(reqStart, succEarly.es) - 1; sequenceFreeFloat[seq.id] = relFloat; if (relFloat === 0) drivingSequenceIds.push(seq.id); diff --git a/src/services/xer/xerCalendarData.ts b/src/services/xer/xerCalendarData.ts index 6b90f4773..7330136ef 100644 --- a/src/services/xer/xerCalendarData.ts +++ b/src/services/xer/xerCalendarData.ts @@ -236,31 +236,47 @@ function shiftIsoDate(date: string, offset: number): string { /** * WEEKEND-KLEMHERSTEL (etappe 7b-1) — welke ECHTE vrije dag zit er achter een redundant - * vrije-uitzonderingsrecord? + * vrije-uitzonderingsrecord, en wanneer mag je dat concluderen? * - * HET BEWIJS (corpus `crawl-xer-extra/jailaff-xer-splitter/rehab-2.xer`, kalender 842 `R111 - 1`: - * ma-do + za + zo werkend, vrijdag vrij). De negen feestdagenblokken in `clndr_data` 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. Ditzelfde patroon staat in alle - * negen blokken van die kalender, en in het hele corpus van 93 bestanden komt een AANGRENZEND - * DUPLICAAT verder in geen enkel bestand voor. De schrijver heeft de blokdagen dus op de MA-VR-as - * geklemd: **zaterdag → de vrijdag ervóór, zondag → de maandag erná**. + * 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á.** * - * Onafhankelijke bevestiging op dezelfde kalender: alle 23 redundante records staan op een vrijdag - * of een maandag (0 op een andere weekdag), en de tien dagen die de set-cover van het - * 761-diagnosedossier onafhankelijk als "moeten vrij zijn" aanwees — 2008-10-04, 2008-10-05, - * 2008-12-07, 2008-12-13, 2008-12-14, 2009-09-19, 2009-09-20, 2009-11-28, 2009-11-29, 2009-12-05 — - * zijn exact de klemdoelen van tien van die 23 records. Eén-op-één, geen rest. + * DE BRONTOETS. P6's eigen opgeslagen datums bevestigen dat: voor de 6.548 taken die op deze + * kalenderdata staan zet P6 in álle negen blokken — inclusief de gereconstrueerde weekenddagen — + * nul ES, EF, LS en LF, terwijl hij in de drie dagen direct vóór en ná de vier blokken die in de + * projectperiode vallen 125 (okt-2008), 141 (dec-2008), 64 (sep-2009) en 78 (nov-2009) datums zet. + * De weekenddagen ín die blokken zijn dus 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.) * - * DE POORT. Deze reconstructie draait alleen wanneer de kalender zélf een aangrenzend duplicaat - * draagt (zie de aanroepplek): dat duplicaat IS het bewijs dat er een dag verloren ging. Verder - * eist deze functie dat het klemdoel (a) daadwerkelijk een zaterdag of zondag is, (b) op deze - * kalender een WERKdag is — anders verandert vrij maken niets — en (c) niet zelf al een - * uitzonderingsrecord heeft. Corpusmeting van die drie eisen samen: alleen rehab-2 en de twee - * Hotel-bestanden hebben überhaupt klembare records, en de Hotel-bestanden hebben geen duplicaat - * en blijven dus onaangeraakt. + * 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, @@ -278,6 +294,18 @@ function weekendClampTarget( return target; } +/** Draagt DIT record zelf het bewijs van een geklemde weekenddag? Zie `weekendClampTarget`. */ +function hasWeekendClampEvidence( + date: string, exceptionDates: ReadonlySet, adjacentDuplicates: ReadonlySet, +): boolean { + const weekday = isoWeekdayOf(date); + // Bewijsvorm 1 — de sprong over het weekend: vrijdag ↔ maandag, drie kalenderdagen verderop. + if (exceptionDates.has(shiftIsoDate(date, weekday === 5 ? 3 : -3))) return true; + // Bewijsvorm 2 — aangrenzend duplicaat, mits het blok aan de binnenzijde doorloopt. + return adjacentDuplicates.has(date) + && exceptionDates.has(shiftIsoDate(date, weekday === 5 ? -1 : 1)); +} + function calendarType(raw: string): XerCalendarType { switch (raw.trim().toLowerCase()) { case 'ca_base': return 'GLOBAL'; @@ -636,13 +664,11 @@ export function decodeXerCalendarData(text: string): DecodedXerCalendarData { // 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). Zie `weekendClampTarget` voor het volledige bewijs: een schrijver die - // een uitzonderingsblok op de MA-VR-as klemt levert zaterdagen af op de vrijdag ervóór en zondagen - // op de maandag erná. Het bewijs dát deze kalender zo geschreven is, is de AANGRENZENDE DUPLICAAT: - // twee opeenvolgende records met dezelfde epochdag kunnen alleen ontstaan als twee verschillende - // brondagen op dezelfde datum zijn geklemd. Zonder zo'n duplicaat blijft alles ongewijzigd, dus - // 92 van de 93 corpusbestanden zijn hier byte-identiek. - for (const date of adjacentDuplicateDates.size > 0 ? [...p6NonWorkPenaltyDates] : []) { + // 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. + for (const date of [...p6NonWorkPenaltyDates]) { + if (!hasWeekendClampEvidence(date, exceptionDates, adjacentDuplicateDates)) continue; const target = weekendClampTarget(date, bands, exceptionDates); if (target === null) continue; holidaysByDate.set(target, { diff --git a/src/types/calendar.ts b/src/types/calendar.ts index bbc46990c..9492429ae 100644 --- a/src/types/calendar.ts +++ b/src/types/calendar.ts @@ -47,11 +47,16 @@ export interface WorkCalendar { */ p6Source?: 'XER'; /** - * Alleen voor een kalender met `p6Source: 'XER'` en een door P6 zelf anders getelde, redundante vrije-dagrecord: - * een vrije uitzondering op een al niet-werkende weekdag, of een direct aangrenzende herhaling - * van dezelfde vrije datum. P6 laat zo'n record als één extra niet-werkdag meewegen in uurduur- - * en floatwandelingen, hoewel de kalenderdatum zelf al niet werkt. De XER-reader bewaart alleen - * de unieke brondata die dit gedrag dragen; afwezig is de algemene kalendersemantiek byte-identiek. + * 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. */ diff --git a/tests/planning/check-calendar-mirror.ts b/tests/planning/check-calendar-mirror.ts index 60af60da7..450b1b1af 100644 --- a/tests/planning/check-calendar-mirror.ts +++ b/tests/planning/check-calendar-mirror.ts @@ -287,60 +287,40 @@ for (const hourMode of [true, false]) { } // ═══════════════════════════════════════════════════════════════════════════════════════════ -// Deel C — de ENIGE gemeten asymmetrie zit in de P6/XER-resultaatprojectie, en die is brongebonden +// Deel C — er is GEEN brongebonden uitzondering meer op de spiegel // ═══════════════════════════════════════════════════════════════════════════════════════════ // -// `subtractP6XerProjectedWorkMinutes` telt per `p6NonWorkPenaltyDate` in het doorlopen venster een -// extra niet-werkdag mee. Er is met opzet GEEN voorwaartse tegenhanger: `addWorkMinutes` blijft -// fysiek. De backward-wandeling overbrugt daardoor MEER kalenderwerkdagen dan de forward-wandeling -// met dezelfde werkminuten — precies de "7 vooruit, 10 terug" uit het dossier. Dat is per -// constructie geen kalenderprimitief maar een P6-resultaatprojectie; deze batterij pint dat het -// (a) inderdaad zo werkt en (b) UITSLUITEND met `p6Source: 'XER'` aan staat. +// 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. { - // Kalender 842-vorm: vrijdag vrij, weekend werkt, 2 banden van samen 8 uur, blok van 10 dagen. - // De twee vrijdagen ín het blok (2026-03-13 en 2026-03-20 vallen erbuiten; 2026-03-13 erin) zijn - // de realistische penaltyvorm: een vrije uitzondering op een weekdag die tóch al geen banden heeft. - const penaltyDates = ['2026-03-13']; - const base = { p6NonWorkPenaltyDates: penaltyDates } as Partial; - const withSource = new CalendarEngine( - hourCalendar('c-xer', [1, 2, 3, 4, 6, 7], TWO_BANDS, { ...base, p6Source: 'XER' }), - ); - const withoutSource = new CalendarEngine( - hourCalendar('c-neutraal', [1, 2, 3, 4, 6, 7], TWO_BANDS, base), - ); + 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; - // C1 — zonder bronstempel is de projectie inert: exact de fysieke, spiegelende uitkomst. - eq('C1 zonder p6Source blijft de projectie de fysieke spiegel', - formatInstant(withoutSource.subtractP6XerProjectedWorkMinutes(finish, duration), 'hour'), - formatInstant(withoutSource.subtractWorkMinutes(finish, duration), 'hour')); - eq('C1 de fysieke spiegel is de exacte inverse van de voorwaartse wandeling', - formatInstant(withoutSource.addWorkMinutes( - withoutSource.subtractWorkMinutes(finish, duration), duration), 'hour'), + 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')); - - // C2 — mét bronstempel schuift de projectie de startgrens één extra vrije dag naar voren, dus - // overbrugt de terugwandeling méér kalenderwerkdagen dan de voorwaartse. Vastgepind als BESTAAND, - // bewust brongebonden gedrag, niet als wenselijke kalendersemantiek. - const physical = withSource.subtractWorkMinutes(finish, duration); - const projected = withSource.subtractP6XerProjectedWorkMinutes(finish, duration); - ok('C2 met p6Source wijkt de projectie af van de fysieke spiegel', - projected.getTime() < physical.getTime()); - eq('C2 het verschil is exact het aantal penaltydagen × hoursPerDay', - withSource.workMinutesBetween(projected, physical), penaltyDates.length * 8 * 60); - eq('C2 de fysieke spiegel zelf blijft de exacte inverse', - formatInstant(withSource.addWorkMinutes(physical, duration), 'hour'), - formatInstant(finish, 'hour')); - - // C3 — de bronpoort zit óók op de float-projectie. - const a = parseInstant('2026-03-05T08:00'); - eq('C3 zonder p6Source is de floatprojectie de fysieke werkminutentelling', - withoutSource.p6XerProjectedWorkMinutesBetween(a, finish), - withoutSource.workMinutesBetween(a, finish)); - ok('C3 met p6Source trekt de floatprojectie de penaltydagen af', - withSource.p6XerProjectedWorkMinutesBetween(a, finish) < withSource.workMinutesBetween(a, finish)); + 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) { diff --git a/tests/planning/check-xer-calendar-data.ts b/tests/planning/check-xer-calendar-data.ts index 354651efb..e948f7416 100644 --- a/tests/planning/check-xer-calendar-data.ts +++ b/tests/planning/check-xer-calendar-data.ts @@ -531,11 +531,10 @@ eq('21 equivalente XER- en P6XML-werkweken leveren dezelfde canonieke banden', d // ── 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 aangrenzende duplicaat in de -// `Exceptions`-lijst is daarvan het bewijs. Deze fixture is de LETTERLIJKE recordreeks van blok -// 2009-11-26 … 2009-12-05 uit kalender 842 van `rehab-2.xer` — tien blokdagen, tien records, -// waarvan drie duplicaten — op de calenderweek van diezelfde kalender: P6-dag 1=zo … 7=za, dus -// werkend op ma-do + za + zo en vrij op vrijdag (P6-dag 6 ontbreekt). +// 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}`, []), @@ -550,43 +549,61 @@ function calendarData(p6Days: readonly number[], serials: readonly number[]): st exceptionBlock(serials), ]); } -// P6-dagnummers: 1=zo, 2=ma, 3=di, 4=wo, 5=do, 6=vr, 7=za. Vrijdag (6) ontbreekt bewust. -const FRIDAY_OFF_WEEK = [1, 2, 3, 4, 5, 7]; -const MON_FRI_WEEK = [2, 3, 4, 5, 6]; -// 40143=2009-11-26 (do) … 40151=2009-12-04 (vr); 11-28 (za), 11-29 (zo) en 12-05 (za) ontbreken en -// staan in plaats daarvan als duplicaat op 11-27 (vr), 11-30 (ma) en 12-04 (vr). -const CLAMPED_BLOCK = [40143, 40144, 40144, 40147, 40147, 40148, 40149, 40150, 40151, 40151]; -const UNCLAMPED_BLOCK = [40143, 40144, 40147, 40148, 40149, 40150, 40151]; +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('22 het geklemde blok wordt tot tien aaneengesloten vrije dagen hersteld', +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('22 een herstelde dag telt niet ook nog als virtuele P6-niet-werkdag', +eq('22a een herstelde dag telt niet ook nog als virtuele P6-niet-werkdag', clamped.p6NonWorkPenaltyDates, []); -// Mutatiebewijs 1 — zonder de duplicaten is er geen bewijs van een geklemde schrijver, dus geen -// herstel: precies het gedrag van vóór deze etappe, inclusief de twee vrijdagstraffen. -const unclamped = decodeXerCalendarData(calendarData(FRIDAY_OFF_WEEK, UNCLAMPED_BLOCK)); -eq('22 zonder aangrenzende duplicaat blijft het blok onaangeroerd', - unclamped.holidays.map(holiday => holiday.startDate).sort(), [ - '2009-11-26', '2009-11-27', '2009-11-30', '2009-12-01', '2009-12-02', '2009-12-03', '2009-12-04', +// 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', ]); -eq('22 zonder herstel blijven de redundante vrijdagrecords P6-niet-werkdagen', - unclamped.p6NonWorkPenaltyDates, ['2009-11-27', '2009-12-04']); -// Mutatiebewijs 2 — dezelfde duplicaten op een gewone MA-VR-kalender: het klemdoel is daar zelf al -// niet-werkend, dus herstellen zou niets veranderen en gebeurt niet. Zo blijft de reconstructie -// beperkt tot kalenders waar een weekenddag écht werkt. -const monFri = decodeXerCalendarData(calendarData(MON_FRI_WEEK, CLAMPED_BLOCK)); -eq('22 op een ma-vr-kalender wordt er niets hersteld', - monFri.holidays.map(holiday => holiday.startDate).sort(), [ +// 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']); + +// 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']); + +// 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('22 op een ma-vr-kalender blijven de aangrenzende duplicaten P6-niet-werkdagen', - monFri.p6NonWorkPenaltyDates, ['2009-11-27', '2009-11-30', '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']); if (diffs.length === 0) { console.log(`OK xer-calendar-data: ${checks} checks groen`); diff --git a/tests/planning/check-xer-product-fidelity-x12.ts b/tests/planning/check-xer-product-fidelity-x12.ts index eec40c17c..b12c3a989 100644 --- a/tests/planning/check-xer-product-fidelity-x12.ts +++ b/tests/planning/check-xer-product-fidelity-x12.ts @@ -1548,9 +1548,19 @@ async function productBaseline( 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); - eq('X12 P6-XER-projectie raakt alleen backward, lag en float; forward blijft fysiek', { + // 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, @@ -1561,12 +1571,13 @@ async function productBaseline( }, { projectSource: 'XER', calendarSource: 'XER', + penaltyDates: ['2026-01-03', '2026-01-05'], aEarlyFinish: '2026-01-02T17:00', - aLateStart: '2025-12-31T08:00', - aTotalFloat: 0, - xTotalFloat: 960, - xFreeFloat: 960, - lagPredecessorLateFinish: '2025-12-29T17:00', + aLateStart: '2026-01-02T08:00', + aTotalFloat: 960, + xTotalFloat: 1920, + xFreeFloat: 1920, + lagPredecessorLateFinish: '2025-12-31T17:00', successorEarlyStart: '2026-01-08T08:00', }); diff --git a/tests/planning/xer-schedoptions-blast-radius.json b/tests/planning/xer-schedoptions-blast-radius.json index e1fa03082..a39e3a2e2 100644 --- a/tests/planning/xer-schedoptions-blast-radius.json +++ b/tests/planning/xer-schedoptions-blast-radius.json @@ -4483,7 +4483,10 @@ } ], "expectedFinishVariant": { - "selection": { "chosen": true, "counterfactual": false }, + "selection": { + "chosen": true, + "counterfactual": false + }, "population": { "oracleFiles": 58, "readableFiles": 56, @@ -4495,14 +4498,30 @@ "sourceFiles": 2, "movingFiles": 0 }, - "movement": [0, 0, 0, 0, 0, 0, 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], + "movement": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], "directionChanges": 0, "directionDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945" }, @@ -4511,27 +4530,71 @@ "tasks": 70, "sourceTasks": 70, "activeSourceTasks": 0, - "movement": [0, 0, 0, 0, 0, 0, 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 } + "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 } + "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 + } } } }, From 90f5e326831ac0e39e8a47384a77cee730820b54 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:41:03 +0200 Subject: [PATCH 144/171] fix(xer): klemherstel-restlek gedicht + dossier 7b-4 en X-O7-uitzondering vastgelegd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Her-check-ronde op `1d1bc96d` (verdict GO-MET-DOSSIER-7b-4). Vijf voorwaarden afgewerkt. 1. RESTLEK GEDICHT. De reviewer vond twee nieuwe false positives op de record-lokale poort: D — een za-do-kalender met twee LOSSE feestdagen die toevallig drie dagen uit elkaar liggen (vr 2009-12-04 en ma 12-07) maakte via de sprongvorm zaterdag 12-05 half vrij; F — een 7-daagse kalender met een tweedaags blok (do 12-03 + vr 12-04) waarvan de vrijdag per ongeluk dubbel stond, maakte zaterdag 12-05 vrij. `hasWeekendClampEvidence` eist nu drie dingen, alle drie lokaal: (a) MEERDAAGS BLOK — de reeks rond het record telt minstens drie losse uitzonderingsdatums (een gat van hoogstens drie dagen breekt de reeks niet, dat gat ís het geklemde weekend). Sluit D én F. In rehab-2 tellen alle negen blokken 5–11. (b) BLOKVERVOLG AAN DE VAN-HET-WEEKEND-AFGEKEERDE ZIJDE — de dag vóór een vrijdag of ná een maandag draagt zelf ook een uitzondering; bij de sprongvorm volstaat dat één van de twee sprongpartners dat doet (dat dekt de twee blokken die met een vrijdag zonder voorganger openen: 2006-12-29 via ma 2007-01-01, 2009-09-18 via ma 2009-09-21). (c) een van de twee bewijsvormen: de sprong, of een aangrenzend duplicaat. Fixtures 22f (D) en 22g (F) zijn toegevoegd; tegen de decoder van `1d1bc96d` gaan ze beide rood met exact de gemelde extra zaterdag (2 afwijkingen, exit 1). De verscherping kost NUL cellen: rehab-2 blijft 13.835 op alle zes de assen (es 940, ef 940, ls 3889, lf 3889, tf 3903, ff 274), 119 kalenders, dezelfde 23 datums per kalender; X12-corpustotaal blijft 17.421 en alle andere bestanden byte-identiek. 2. BASELINE-OPMAAK. `xer-schedoptions-blast-radius.json` staat nu volledig in de oorspronkelijke compacte vorm — `completedProgress` stond nog uitgeklapt omdat zes rijen 126 tekens tellen. Netto diff t.o.v. basiscommit `7a3dece6`: 47 toegevoegde en 47 verwijderde regels, en dat zijn precies de 47 veranderde `deviations`-waarden — nul opmaakregels. (De eerdere "79/16" in `1d1bc96d` mat de diff t.o.v. `fbcdb731`, niet t.o.v. de basis; de reviewer mat daar terecht 192/48.) 3. RANDDEFINITIE. De 125/141/64/78 uit `1d1bc96d` telde alleen ES en EF. De definitie staat nu expliciet in het docblok — alle VIER de assen (ES, EF, LS, LF), over de 6.548 taken op de gereconstrueerde kalendergroepen, waarbij de rand de drie kalenderdagen direct vóór de eerste en direct ná de laatste dag van de GERECONSTRUEERDE blokspanne is — met de juiste getallen: 167 (okt-2008, 153 taken), 214 (dec-2008, 193), 178 (sep-2009, 157), 252 (nov-2009, 204); 811 totaal tegen 0 datums ín de blokken. Reproduceert de meting van de reviewer op de eenheid. De tweede bevestiging staat er nu ook bij: P6's eigen vensters telden op de OUDE kalender 10,00 werkdagen voor `V3109400` (7 dagen duur) en 24,00 voor `V3109300` (21 dagen), en op de gereconstrueerde exact 7,00 en 21,00. 4. PLAN-DOC. `docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md` krijgt twee stukken: §5 `X-O7` legt de regel "een fidelity-stap maakt geen as slechter" vast MÉT de expliciete, eenmalige uitzondering voor deze etappe (es +322, ef +127) en de twee bronmetingen die haar rechtvaardigen; §9 registreert dossier **7b-4 "forward-anker na gereconstrueerde kalenderblokken"** met de 344/327 cellen, de vijf getraceerde taken, de vensterlengte- tabel die aantoont dat de duurwandeling klopt, en de n=1-basis (één bestand van 93; MPXJ kent het verschijnsel niet, dus een tweede bestand moet de regel bevestigen of ontkrachten in plaats van er stil op mee te liften). 5. POORTEN. `npm run typecheck` exit 0, `npm run lint` exit 0, `check-xer-calendar-data.ts` exit 0 (44 checks), `check-calendar-mirror.ts` exit 0 (80), X12 summary mét corpus `17421` (exit 0 in meetmodus), `check-xer-schedule-options-corpus` exit 0 (17), `check-xer-calendar-corpus` exit 0 (8), `check-xer-task-replay` exit 0 (32). `git show -w` is regel-voor-regel gelijk aan `git show`. NIET GEDAAN (bewust, staat als open punt): de `public/docs`/changelog-regel over het verschil tussen een verse XER-import en een eerder opgeslagen IFC, en de `readIFCWithXerReconstruction`-fixture uit H-3. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit a66ae13456f23ded913dd081d678a9b6de36c043) --- .../plans/2026-08-20-plan-xer-p6-lezer.md | 63 ++++++ src/services/xer/xerCalendarData.ts | 90 ++++++-- tests/planning/check-xer-calendar-data.ts | 13 ++ .../xer-schedoptions-blast-radius.json | 192 +++--------------- 4 files changed, 177 insertions(+), 181 deletions(-) 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 index c0f43a84f..12fa1841e 100644 --- a/docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md +++ b/docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md @@ -292,6 +292,29 @@ robuustheidsbestanden en het 8-byte-DROID-skelet) tellen niet in de fidelity-poo `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 (167/214/178/252 voor de vier blokken die in de projectperiode vallen); + 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}`). @@ -523,3 +546,43 @@ dan meet, meet zijn eigen aannames. 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. diff --git a/src/services/xer/xerCalendarData.ts b/src/services/xer/xerCalendarData.ts index 7330136ef..51800d163 100644 --- a/src/services/xer/xerCalendarData.ts +++ b/src/services/xer/xerCalendarData.ts @@ -247,13 +247,32 @@ function shiftIsoDate(date: string, offset: number): string { * 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: voor de 6.548 taken die op deze - * kalenderdata staan zet P6 in álle negen blokken — inclusief de gereconstrueerde weekenddagen — - * nul ES, EF, LS en LF, terwijl hij in de drie dagen direct vóór en ná de vier blokken die in de - * projectperiode vallen 125 (okt-2008), 141 (dec-2008), 64 (sep-2009) en 78 (nov-2009) datums zet. - * De weekenddagen ín die blokken zijn dus 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.) + * 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 de blokken tegen 811 eromheen: de weekenddagen ín die blokken zijn dus 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 @@ -294,16 +313,61 @@ function weekendClampTarget( return target; } -/** Draagt DIT record zelf het bewijs van een geklemde weekenddag? Zie `weekendClampTarget`. */ +/** + * 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. + */ function hasWeekendClampEvidence( date: string, exceptionDates: ReadonlySet, adjacentDuplicates: ReadonlySet, ): boolean { const weekday = isoWeekdayOf(date); - // Bewijsvorm 1 — de sprong over het weekend: vrijdag ↔ maandag, drie kalenderdagen verderop. - if (exceptionDates.has(shiftIsoDate(date, weekday === 5 ? 3 : -3))) return true; - // Bewijsvorm 2 — aangrenzend duplicaat, mits het blok aan de binnenzijde doorloopt. - return adjacentDuplicates.has(date) - && exceptionDates.has(shiftIsoDate(date, weekday === 5 ? -1 : 1)); + 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 { diff --git a/tests/planning/check-xer-calendar-data.ts b/tests/planning/check-xer-calendar-data.ts index e948f7416..af5a695a9 100644 --- a/tests/planning/check-xer-calendar-data.ts +++ b/tests/planning/check-xer-calendar-data.ts @@ -595,6 +595,19 @@ 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', diff --git a/tests/planning/xer-schedoptions-blast-radius.json b/tests/planning/xer-schedoptions-blast-radius.json index a39e3a2e2..00ce8a6d3 100644 --- a/tests/planning/xer-schedoptions-blast-radius.json +++ b/tests/planning/xer-schedoptions-blast-radius.json @@ -61,174 +61,30 @@ "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 - } + { "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 } ] } }, From e1915758c53c268f9a65330dc5db2533b9fbc476 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:47:14 +0200 Subject: [PATCH 145/171] docs(xer): klemherstel-poort eerlijk begrensd + randtelling ondubbelzinnig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alleen commentaar, geen gedragswijziging. 1. `hasWeekendClampEvidence` zei impliciet dat de drie eisen de false-positive-klasse dichten. Ze dichten de twee GEMETEN gevallen (fixtures 22f en 22g), niet de klasse. Het docblok geeft nu het tegenvoorbeeld dat de klasse reproduceert: 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 (nagemeten: `2009-12-04 2009-12-05 2009-12-07 2009-12-08`; zondag 12-06 niet, want een maandagrecord is op deze kalender geen redundant record). Met de vermelding dat het risico empirisch klein is — nul treffers in 93 corpusbestanden buiten `rehab-2.xer` — maar niet nul: de poort filtert op bewijskracht en is geen sluitend bewijs. 2. "Nul in alle negen blokken tegen 811 eromheen" suggereerde dat alle negen blokken bewijs dragen. In dezelfde zin staat nu dat die 811 volledig op de vier blokken binnen de projectperiode zit (167/214/178/252) en dat de overige vijf 0 ín én 0 in de rand hebben en dus niets bewijzen. Zelfde correctie in de X-O7-tekst in `docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md`. Poort: `npm run typecheck` exit 0. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit ae5931e0f4760ebe4dc622125e8f908678b44fef) --- .../plans/2026-08-20-plan-xer-p6-lezer.md | 3 ++- src/services/xer/xerCalendarData.ts | 12 +++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) 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 index 12fa1841e..60858bd8b 100644 --- a/docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md +++ b/docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md @@ -304,7 +304,8 @@ robuustheidsbestanden en het 8-byte-DROID-skelet) tellen niet in de fidelity-poo 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 (167/214/178/252 voor de vier blokken die in de projectperiode vallen); + 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. diff --git a/src/services/xer/xerCalendarData.ts b/src/services/xer/xerCalendarData.ts index 51800d163..bf8cce1a1 100644 --- a/src/services/xer/xerCalendarData.ts +++ b/src/services/xer/xerCalendarData.ts @@ -260,7 +260,9 @@ function shiftIsoDate(date: string, offset: number): string { * de vijf blokken buiten de projectperiode * 0 0 (niet-informatief, en nul effect op de fidelity) * - * Nul datums ín de blokken tegen 811 eromheen: de weekenddagen ín die blokken zijn dus aantoonbaar + * 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.) * @@ -353,6 +355,14 @@ function exceptionRunLength(date: string, exceptionDates: ReadonlySet): * 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, From f44e0320997644be567e3ff455474ae867ea6790 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:08:55 +0200 Subject: [PATCH 146/171] =?UTF-8?q?feat(xer):=20heropen-beleid=20=E2=80=94?= =?UTF-8?q?=20alleen=20een=20verse=20XER-import=20zet=20"datums=20zoals=20?= =?UTF-8?q?opgeslagen"=20zelf=20aan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Een verse .xer-import met restverschillen opent nog steeds meteen ín de modus (T4). Een HEROPENDE IFC met XER-bronarchief (de T5-archiefroute in `ifcReader`) doet dat bewust niet meer: die krijgt uitsluitend het bestaande #63-AANBOD. Reden: een planning die intussen bewerkt en opgeslagen is mag bij heropenen niet stilzwijgend P6's oude datums tonen. Mechanisme: `ImportResult.recordedTimesOrigin` kent naast 'xer' nu 'xer-archive' (alleen gezet door `readIFC`); `applyRecordedDatesOnLoad` zet de modus alleen bij 'xer' aan. De herkomst reist mee op `RecordedDatesState.origin`, zodat de melding hem later kan lezen. Crashherstel is GEEN heropening en moet de vlag van vóór de crash houden. `restoreDocuments` gebruikt daarvoor een eigen `applyRecordedDatesOnRestore`, die de modus terugleest uit het bron-orakel: stond het document in de modus, dan dragen de rauw gelezen taken exact het orakel (0 verschoven op de pre-solve taken). Die meting werkt UITSLUITEND met een orakel — zonder `recordedTimes` haalt `captureRecordedDates` de vastlegging uit de taken zelf, waardoor "0 verschoven" een tautologie is en elk hersteld #63-document ongevraagd in de modus zou landen (gemeten, zie sectie 7C). Zonder orakel herstelt deze functie daarom alleen het aanbod. Tests: check-recorded-dates 7ag–7ai (xer-archive biedt alleen aan) en 7C (crashherstel van een gewoon #63-document blijft uit de modus), check-xer-open-wiring T4-22..24, en check-xer-recorded-roundtrip §4 (beide opslagvormen bieden bij heropenen alleen aan) en §6 (aan blijft aan, uit blijft uit). Mutatiebewijs: 'xer-archive' gelijkstellen aan 'xer' ⇒ 7ag/T4-22 rood; de orakel-voorwaarde weghalen ⇒ 7aj rood. Gids `datums-zoals-opgeslagen.md` (nl+en): één alinea dat de weergave bij heropenen wordt aangeboden, niet automatisch aangezet. --- public/docs/en/datums-zoals-opgeslagen.md | 5 + public/docs/nl/datums-zoals-opgeslagen.md | 5 + src/engine/scheduler/recordedDates.ts | 8 + src/services/ifc/ifcReader.ts | 7 +- src/services/importTypes.ts | 10 +- src/state/documentActivation.ts | 79 ++++++++- src/state/slices/documentSlice.ts | 22 +-- src/state/slices/fileSlice.ts | 10 +- tests/planning/check-recorded-dates.ts | 38 ++++ tests/planning/check-xer-open-wiring.ts | 12 ++ .../planning/check-xer-recorded-roundtrip.ts | 165 +++++++++++------- 11 files changed, 272 insertions(+), 89 deletions(-) diff --git a/public/docs/en/datums-zoals-opgeslagen.md b/public/docs/en/datums-zoals-opgeslagen.md index 72d257870..6f6c3eb4e 100644 --- a/public/docs/en/datums-zoals-opgeslagen.md +++ b/public/docs/en/datums-zoals-opgeslagen.md @@ -79,6 +79,11 @@ switches itself straight into this view, without you first clicking **Show recor 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 diff --git a/public/docs/nl/datums-zoals-opgeslagen.md b/public/docs/nl/datums-zoals-opgeslagen.md index 3431b090e..397efe37b 100644 --- a/public/docs/nl/datums-zoals-opgeslagen.md +++ b/public/docs/nl/datums-zoals-opgeslagen.md @@ -82,6 +82,11 @@ dan zet de app zichzelf meteen in deze weergave, zonder dat je eerst op **Opgesl 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 diff --git a/src/engine/scheduler/recordedDates.ts b/src/engine/scheduler/recordedDates.ts index 2964957e1..84360d5d5 100644 --- a/src/engine/scheduler/recordedDates.ts +++ b/src/engine/scheduler/recordedDates.ts @@ -67,6 +67,14 @@ export interface RecordedDates { export interface RecordedDatesState extends RecordedDates { /** Aantal taken waarvan de herberekening de datums verschoof — de teller in de melding. */ 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'; } /** diff --git a/src/services/ifc/ifcReader.ts b/src/services/ifc/ifcReader.ts index 845759c37..dc67d3491 100644 --- a/src/services/ifc/ifcReader.ts +++ b/src/services/ifc/ifcReader.ts @@ -273,7 +273,12 @@ export function readIFC( baselines, activeBaselineId, libraryPool: libraryPoolOut.value, recordedFields, - ...(recordedTimes ? { recordedTimes, recordedTimesOrigin: 'xer' as const } : {}), + // 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 } : {}), diff --git a/src/services/importTypes.ts b/src/services/importTypes.ts index 4a7aac160..8fcfd2ffe 100644 --- a/src/services/importTypes.ts +++ b/src/services/importTypes.ts @@ -280,8 +280,14 @@ export interface ImportResult { 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). */ - recordedTimesOrigin?: 'xer'; + * 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. */ diff --git a/src/state/documentActivation.ts b/src/state/documentActivation.ts index 5c260a02b..1ad728bd3 100644 --- a/src/state/documentActivation.ts +++ b/src/state/documentActivation.ts @@ -209,11 +209,11 @@ export function prepareLoadedPayload( } /** - * "Datums zoals opgeslagen" (issue #63) — de standaard-aan-detectie bij het laden (XER-etappeplan - * §3.5, taak T4). Gedeeld tussen `applyLoadedProject` (`fileSlice.ts`, een vers open-pad) en - * `restoreDocuments` (`documentSlice.ts`, crash-herstel): een hersteld document krijgt zo dezelfde - * reproduceerbare detectie als een vers geopend document — "die solve ís de detectie" (§2.3) geldt - * voor beide paden identiek, zonder dat de vlag zelf ooit apart bewaard hoeft te worden. + * "Datums zoals opgeslagen" (issue #63) — de standaard-aan-detectie bij een VERSE import + * (XER-etappeplan §3.5, taak T4). Aangeroepen door `applyLoadedProject` (`fileSlice.ts`) — dus elke + * keer dat een gebruiker een bestand opent, ook een IFC met XER-archief. Crashherstel loopt sinds + * het heropen-beleid (taak T5, 2026-09-05) NIET meer via deze functie, zie + * `applyRecordedDatesOnRestore` hieronder voor waarom. * * `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 @@ -225,11 +225,15 @@ export function prepareLoadedPayload( * aanroeper kan `payload.tasks` gerust bewaren en er ná `prepareLoadedPayload(payload, ...)` nog * steeds naar verwijzen. * - * Alleen de bron-orakel-route (XER, `parsed.recordedTimesOrigin === 'xer'`) zet de modus METEEN aan + * Alleen een VERSE XER-import (`parsed.recordedTimesOrigin === 'xer'`) zet de modus METEEN aan * (`datesAsRecorded = true`, `cpmResult` = de reconstructie i.p.v. de solve, `scheduleStale = false` * — nooit rechtstreeks `true` gezet, dus de invariant in `state/scheduleStale.ts` blijft heel). - * Overige formaten (IFC/CSV/MSPDI/MPP/P6XML) bieden de modus alleen AAN — `recordedDates` gevuld, - * `datesAsRecorded` blijft `false` — precies het bestaande #63-gedrag, ongewijzigd. + * Overige gevallen bieden de modus alleen AAN — `recordedDates` gevuld, `datesAsRecorded` blijft + * `false` — precies het bestaande #63-gedrag: dat geldt voor IFC/CSV/MSPDI/MPP/P6XML zonder + * bron-orakel, MAAR ook voor een HEROPENDE IFC met XER-bronarchief + * (`recordedTimesOrigin === 'xer-archive'`, taak T5) — heropen-beleid (orkestratorbesluit, + * XER-etappe laag 3, 2026-09-05): een intussen bewerkte en opgeslagen planning mag bij heropenen + * niet stilzwijgend P6's oude datums tonen, dus alleen een verse import krijgt de automatische AAN. * * 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 @@ -248,7 +252,7 @@ export function applyRecordedDatesOnLoad( if (recorded.total === 0) return; const shifted = countShiftedTasks(prepared.tasks, recorded.times); if (shifted === 0) return; - prepared.recordedDates = { ...recorded, shifted }; + prepared.recordedDates = { ...recorded, shifted, origin: parsed.recordedTimesOrigin }; if (parsed.recordedTimesOrigin === 'xer') { prepared.cpmResult = applyRecordedTimesToTasks(prepared.tasks, recorded.times, prepared.calendar); prepared.datesAsRecorded = true; @@ -257,3 +261,60 @@ export function applyRecordedDatesOnLoad( prepared.scheduleStale = false; } } + +/** + * Crashherstel-variant van `applyRecordedDatesOnLoad` (heropen-beleid, taak T5, 2026-09-05). + * + * Een normale heropening mag een IFC met XER-archief NOOIT automatisch in de modus zetten (zie de + * docstring hierboven) — een sindsdien bewerkte en opgeslagen planning zou anders stilzwijgend P6's + * oude datums tonen. Crashherstel is echter GEEN heropening maar het hervatten van een onderbroken + * sessie: stond het document VLAK VÓÓR de crash in de modus, dan hoort het dat na herstel weer te + * zijn; stond het uit, dan blijft het uit. Er wordt dus NIET opnieuw beslist op basis van + * `recordedTimesOrigin` — dat zou bij een XER-archief altijd "alleen aanbieden" opleveren, ongeacht + * de toestand vóór de crash. + * + * Er bestaat geen apart persistentiekanaal voor `datesAsRecorded` (geen `OPS_`-pset, zie plan §2.7) + * — maar met een BRON-ORAKEL is de modus zelf al zichtbaar in de rauw gelezen taken: + * `applyRecordedTimesToTasks` schrijft P6's waarden LETTERLIJK in `task.time.earlyStart`/ + * `earlyFinish` (zie die functie). Een crash-snapshot die IN de modus werd geschreven draagt in + * `rawTasks` dus exact het orakel op elke as die `countShiftedTasks` vergelijkt — een tweede meting + * van diezelfde functie, nu op de RAUWE (pre-solve) taken in plaats van de gesolvede, onderscheidt + * de twee gevallen: 0 verschoven op de rauwe taken is dan geen toeval maar het bewijs dat de modus + * aanstond toen dit werd opgeslagen. + * + * DIE METING WERKT UITSLUITEND MET EEN ORAKEL, en de guard daarop is geen voorzichtigheid maar een + * correctheidsvoorwaarde (gemeten 2026-09-05, sectie 7C van `check-recorded-dates.ts`): zónder + * `recordedTimes` haalt `captureRecordedDates` de vastlegging uit `rawTasks` ZELF (de + * `recordedFields`-route). Vergelijk je die taken dan met die vastlegging, dan is de uitkomst per + * definitie 0 verschoven — "de modus stond aan" zou voor ELK hersteld #63-document waar zijn, en + * crashherstel zou de modus ongevraagd aanzetten op documenten die hem nooit aan hadden. Zonder + * orakel is er dus niets terug te lezen en valt deze functie terug op het bestaande #63-gedrag: + * alleen het AANBOD herstellen. De kosten daarvan zijn eerlijk en klein — een gebruiker die vóór de + * crash in de modus stond op een niet-XER-document ziet na herstel het aanbod met exact dezelfde + * datums en is één klik verwijderd van dezelfde weergave. + * + * Zelfde aanroepcontract als `applyRecordedDatesOnLoad`: `rawTasks` is de PRE-solve array, geen + * undo-snapshot, muteert `prepared` in place. + */ +export function applyRecordedDatesOnRestore( + rawTasks: Task[], + prepared: DocumentPayload, + parsed: Pick, +): void { + const recorded = captureRecordedDates(rawTasks, parsed.recordedFields, parsed.recordedTimes); + if (recorded.total === 0) return; + const shifted = countShiftedTasks(prepared.tasks, recorded.times); + if (shifted === 0) return; + prepared.recordedDates = { ...recorded, shifted, origin: parsed.recordedTimesOrigin }; + // Alleen met een bron-orakel is `recorded.times` ONAFHANKELIJK van `rawTasks` en zegt de + // vergelijking hieronder iets — zie de docstring. + const hasOracle = parsed.recordedTimes !== undefined; + const wasInModeBeforeCrash = hasOracle && countShiftedTasks(rawTasks, recorded.times) === 0; + if (wasInModeBeforeCrash) { + prepared.cpmResult = applyRecordedTimesToTasks(prepared.tasks, recorded.times, prepared.calendar); + prepared.datesAsRecorded = true; + // Zelfde bewuste `= false` als hierboven (plan §5 risico 1) — nooit een rechtstreekse `= true` + // elders. + prepared.scheduleStale = false; + } +} diff --git a/src/state/slices/documentSlice.ts b/src/state/slices/documentSlice.ts index 43b7c5f9b..2e8d7c53f 100644 --- a/src/state/slices/documentSlice.ts +++ b/src/state/slices/documentSlice.ts @@ -27,7 +27,7 @@ import { type HistoryScopeKey, } from '../sessionHistory'; import { - applyRecordedDatesOnLoad, + applyRecordedDatesOnRestore, materializeLibraryBoundary, prepareLoadedPayload, type DocumentActivationMaterialization, @@ -569,15 +569,17 @@ export const createDocumentSlice: AppSliceFactory = (runtime) => try { const rawPayload = payloadFromInput(candidate); const p = prepareLoadedPayload(rawPayload, { recompute: true }); - // XER-etappeplan §3.5/§4-T4, risico §5.4: dezelfde standaard-aan-detectie als een vers - // open-pad (`applyLoadedProject`), zodat een hersteld document zijn modusvlag/`recordedDates` - // NIET stilzwijgend verliest. `rawPayload.tasks` is bewust de PRE-solve array — - // `prepareLoadedPayload` muteert zijn `input`-argument niet (zie de docstring van - // `applyRecordedDatesOnLoad`). Vandaag levert `readIFCWithXerReconstruction` nog geen - // `recordedTimes`/`recordedTimesOrigin` voor XER-documenten (dat is taak T5) — deze aanroep - // is dan een no-op voor XER, maar herstelt WEL het bestaande #63-aanbod (`recordedFields`, - // elk formaat) dat crashherstel tot nu toe stilzwijgend wegliet. - applyRecordedDatesOnLoad(rawPayload.tasks, p, candidate); + // XER-etappeplan §3.5/§4-T4, risico §5.4, heropen-beleid (taak T5, 2026-09-05): crashherstel + // herstelt het bestaande #63-aanbod (`recordedFields`, elk formaat) dat het tot nu toe + // stilzwijgend wegliet, EN — sinds `readIFCWithXerReconstruction` een XER-archief + // reconstrueert — de modusvlag van vóór de crash voor XER-documenten. Bewust NIET + // `applyRecordedDatesOnLoad`: die zet een IFC met XER-archief altijd op "alleen aanbieden" + // (`recordedTimesOrigin === 'xer-archive'`, het heropen-beleid), maar crashherstel is geen + // heropening — het hervat een onderbroken sessie en mag dus GEEN nieuwe beslissing nemen. + // `applyRecordedDatesOnRestore` leest in plaats daarvan af of de modus al aanstond (zie de + // docstring aldaar). `rawPayload.tasks` is bewust de PRE-solve array — `prepareLoadedPayload` + // muteert zijn `input`-argument niet. + applyRecordedDatesOnRestore(rawPayload.tasks, p, candidate); const a = materializeLibraryBoundary({ payload: p, companies: state.companies, pools: state.pools, mode: 'open-boundary', }); diff --git a/src/state/slices/fileSlice.ts b/src/state/slices/fileSlice.ts index 8dce84ff7..a7bbd98d4 100644 --- a/src/state/slices/fileSlice.ts +++ b/src/state/slices/fileSlice.ts @@ -345,10 +345,12 @@ export const createFileSlice: AppSliceFactory = (runtime) => (set, ge } const prepared = prepareLoadedPayload(payload, { recompute: !!opts.recompute }); if (opts.recompute) { - // XER-etappeplan §3.5 (taak T4): alleen de bron-orakel-route (parsed.recordedTimesOrigin - // === 'xer') zet de modus meteen aan; 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. + // 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({ diff --git a/tests/planning/check-recorded-dates.ts b/tests/planning/check-recorded-dates.ts index 4c86ad1be..052f7e5a7 100644 --- a/tests/planning/check-recorded-dates.ts +++ b/tests/planning/check-recorded-dates.ts @@ -25,6 +25,7 @@ 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 { readIFC } from '@/services/ifc/ifcReader'; import { writeIFC } from '@/services/ifc/ifcWriter'; import { buildWriteIFCInput } from '@/state/ifcSaveInput'; @@ -528,6 +529,43 @@ const earlyStartOf = (id: string) => S().tasks.find((t) => t.id === id)!.time.ea 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); +} + +// ── (7C) Crashherstel raakt de #63-route NIET ──────────────────────────────────────────────── +// `restoreDocuments` gebruikt sinds het heropen-beleid (XER-etappe laag 3, T8) een eigen +// `applyRecordedDatesOnRestore` die de modusvlag van vóór de crash TERUGLEEST uit een bron-orakel. +// Deze sectie pint de tegenkant vast: een gewoon #63-document (IFC/CSV/MSPDI/MPP/P6XML) heeft geen +// orakel, dus er valt niets terug te lezen — daar blijft het bestaande gedrag "alleen aanbieden" +// gelden. Zonder deze check zou die terugleesregel stil doorslaan naar élk hersteld document: bij +// de `recordedFields`-route komt de vastlegging namelijk uit de taken zélf, dus een vergelijking +// van die taken met die vastlegging is per definitie 0 verschoven — "de modus stond aan" zou daar +// dus ALTIJD waar lijken. MUTATIEBEWIJS: haal de orakel-voorwaarde uit +// `applyRecordedDatesOnRestore` weg (`wasInModeBeforeCrash` zonder `hasOracle`) ⇒ 7aj slaat ROOD. +{ + const parsed = readIFC(externIfc('7C')); + const input = recoveryInputFromParsed(parsed, { id: 'rec-63', filePath: null, isDirty: true }); + 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) ──────────────────────── diff --git a/tests/planning/check-xer-open-wiring.ts b/tests/planning/check-xer-open-wiring.ts index 4cb06bdb4..c21a58635 100644 --- a/tests/planning/check-xer-open-wiring.ts +++ b/tests/planning/check-xer-open-wiring.ts @@ -309,6 +309,18 @@ eq('8f recovery-inputoverdracht herstelt links per document zonder solverdoorwer 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); } const corpusRoot = process.env.OPS_XER_CORPUS; diff --git a/tests/planning/check-xer-recorded-roundtrip.ts b/tests/planning/check-xer-recorded-roundtrip.ts index 1388ad725..974cb2bce 100644 --- a/tests/planning/check-xer-recorded-roundtrip.ts +++ b/tests/planning/check-xer-recorded-roundtrip.ts @@ -9,25 +9,33 @@ * 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. `recordedTimesOrigin` — de vlag die het standaard-aan-beleid van taak T4 stuurt — reist mee, - * dus een heropend XER-document neemt dezelfde beslissing als het origineel. + * 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. Het crashherstelpad (`recoveryStore` → `readIFCWithXerReconstruction` → `restoreDocuments`) - * levert dezelfde vastlegging als het gewone openen. + * 5. CRASHHERSTEL IS GEEN HEROPENING: `restoreDocuments` gebruikt `applyRecordedDatesOnRestore` + * (niet `applyRecordedDatesOnLoad`) en herstelt de modusvlag van vóór de crash — 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. * - * WAT DIT BEWUST NIET DOET: de store-vlag `datesAsRecorded` zelf wordt door taak T4 - * (`fileSlice.applyLoadedProject`) gezet en is op deze basis nog niet bedraad. Deze check toetst de - * volledige INVOER van dat besluit (`recordedTimes` + `recordedTimesOrigin` + de shifted-telling) en - * spiegelt de T4-beslisregel in `modeVerdict()` hieronder, zodat de gelijkheid vóór/ná de - * round-trip nu al vastligt. + * 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 functies aanroepen (`applyRecordedDatesOnLoad` resp. + * `applyRecordedDatesOnRestore`). Rechtstreeks aflezen toetst dus de werkelijke bedrading, niet een + * kopie ervan. */ -import { captureRecordedDates, countShiftedTasks, applyRecordedTimesToTasks } from '@/engine/scheduler/recordedDates'; +import { captureRecordedDates } from '@/engine/scheduler/recordedDates'; 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'; @@ -160,16 +168,14 @@ function single(bytes: Uint8Array): ImportResult { return opened; } -/** De T4-beslisregel, hier gespiegeld: alleen de bron-orakelroute opent meteen ín de modus, en - * alleen wanneer de herberekening daadwerkelijk iets verschoof. */ -function modeVerdict(parsed: ImportResult, solvedTasks: ImportResult['tasks']): { origin: string | undefined; total: number; shifted: number; mode: boolean } { - const recorded = captureRecordedDates(parsed.tasks, parsed.recordedFields, parsed.recordedTimes); - const shifted = countShiftedTasks(solvedTasks, recorded.times); +/** Leest de ECHTE productiebeslissing van een document af — geen recomputatie, gewoon de velden + * die `applyRecordedDatesOnLoad`/`applyRecordedDatesOnRestore` op de payload zetten. */ +function modeSummary(payload: DocumentPayload): { origin: string | undefined; total: number; shifted: number; mode: boolean } { return { - origin: parsed.recordedTimesOrigin, - total: recorded.total, - shifted, - mode: parsed.recordedTimesOrigin === 'xer' && recorded.total > 0 && shifted > 0, + origin: payload.recordedDates?.origin, + total: payload.recordedDates?.total ?? 0, + shifted: payload.recordedDates?.shifted ?? 0, + mode: payload.datesAsRecorded, }; } @@ -191,13 +197,28 @@ 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 ──────────────────────── -const ifcOutsideMode = writeIFC(buildWriteIFCInput(basePayload)); +// 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 dezelfde herkomst', reopenedOutside.recordedTimesOrigin, 'xer'); +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', @@ -209,29 +230,39 @@ expect('2e de IFC-`recordedFields`-route wordt NIET gemengd met het bron-orakel' && captureRecordedDates(reopenedOutside.tasks, reopenedOutside.recordedFields, reopenedOutside.recordedTimes).times.R5 === undefined); // ── 3. Opslaan ÍN de modus: P6's waarden staan in `task.time` ──────────────────────────────── -const inModePayload = { - ...basePayload, - tasks: basePayload.tasks.map(t => ({ ...t, time: { ...t.time } })), -}; -applyRecordedTimesToTasks(inModePayload.tasks, originalTimes, inModePayload.calendar); -const ifcInMode = writeIFC(buildWriteIFCInput(inModePayload)); +// `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 dezelfde herkomst', reopenedInMode.recordedTimesOrigin, 'xer'); +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. Het modusbesluit is identiek vóór en ná de round-trip ───────────────────────────────── -const verdictBefore = modeVerdict(original, basePayload.tasks); +// ── 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 reopenedPayload = store().getOpenDocumentPayloads()[0]!.payload; -const verdictAfter = modeVerdict(reopenedOutside, reopenedPayload.tasks); -eq('4a hetzelfde standaard-aan-besluit (origin/total/shifted/mode) vóór en ná opslaan+heropenen', - verdictAfter, verdictBefore); -expect('4b het besluit is niet triviaal: deze fixture verschuift echt taken', verdictBefore.mode); +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({ @@ -244,33 +275,38 @@ expect('5a IFC zonder XER-archief levert geen recordedTimes en geen herkomst', expect('5b de bestaande #63-IFC-route (recordedFields) werkt daar onveranderd', captureRecordedDates(reopenedPlain.tasks, reopenedPlain.recordedFields).total === reopenedPlain.tasks.length); -// ── 6. Crashherstel: recoveryStore-round-trip ──────────────────────────────────────────────── -await clearRecovery(); -store().newProject(); -store().applyOpenedImport(original, { - filePath: null, fileHandle: null, recompute: true, fit: false, hourDataNotice: false, linkedOpen: false, -}); -const recoverySource = store().getOpenDocumentPayloads(); -await saveRecovery(fullRecoverySave(recoverySource[0]!.id, recoverySource.map(document => ({ - id: document.id, - ifc: writeIFC(buildWriteIFCInput(document.payload)), - filePath: null, - isDirty: true, -})))); -const loaded = await loadRecovery(); -expect('6a recoveryStore levert de snapshot terug', loaded.docs.length === recoverySource.length); -const recoveredParsed = loaded.docs.map(d => readXerArchiveIFC(d.ifc)); -eq('6b hersteld document draagt dezelfde vastlegging als het gewone openen', - recoveredParsed[0]?.recordedTimes, originalTimes); -eq('6c hersteld document draagt dezelfde herkomst', recoveredParsed[0]?.recordedTimesOrigin, 'xer'); -const recoveryInputs = loaded.docs.map((d, i) => recoveryInputFromParsed( - recoveredParsed[i]!, { id: d.id, filePath: null, isDirty: true }, -)); -store().restoreDocuments(recoveryInputs, recoveryInputs[0]!.id); -const restored = store().getOpenDocumentPayloads()[0]!.payload; -eq('6d na restoreDocuments is het modusbesluit gelijk aan dat van het gewone openen', - modeVerdict(recoveryInputs[0]!, restored.tasks), verdictBefore); -await clearRecovery(); +// ── 6. Crashherstel is GEEN heropening: de modusvlag van vóór de crash komt terug ───────────── +// "Aan blijft aan, uit blijft uit" — `restoreDocuments` gebruikt `applyRecordedDatesOnRestore` +// (documentSlice.ts), niet `applyRecordedDatesOnLoad`, en beslist dus NIET opnieuw op basis van +// `recordedTimesOrigin` (dat zou voor elk XER-archiefdocument altijd "alleen aanbieden" zijn, +// zie sectie 4). Twee onafhankelijke herstelrondes — één per toestand van vóór de crash. +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 }])); + 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 }); + 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); // ── 7. MUTATIEBEWIJS — één orakelcel verschuift de vastlegging, niet de berekening ─────────── function chainOf(mutate: boolean): { times: Record; cpm: string } { @@ -393,7 +429,10 @@ function corpusRoundTrip(label: string, path: string): void { const reopened = readXerArchiveIFC(ifc); const tReopen = Date.now(); eq(`C-${label} vastlegging identiek na opslaan+heropenen`, reopened.recordedTimes, parsed.recordedTimes); - eq(`C-${label} herkomst identiek na opslaan+heropenen`, reopened.recordedTimesOrigin, parsed.recordedTimesOrigin); + // 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}` From a2373628ccdc48b34f4a80e0b82475ced649c71c Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:09:15 +0200 Subject: [PATCH 147/171] fix(i18n): meldingstekst "datums zoals opgeslagen" is bronafhankelijk, 14 talen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De MODUS-ACTIEF-strook zei altijd "zoals Primavera ze opsloeg", maar diezelfde strook draagt ook de #63-route voor IFC/CSV/MSPDI/MPP/P6XML — daar weet niemand uit welk pakket de datums komen en is die zin een verkeerde bewering. Er is nu een tweede sleutelfamilie `recordedDates.activeCountNeutral` ("de datums zoals ze in het bestand staan"), in alle veertien talen met de juiste CLDR- pluralcategorieën; de Primavera-tekst blijft voor beide XER-herkomsten ('xer' en 'xer-archive' — allebei echt P6's vastlegging, ze verschillen alleen in het heropen-beleid). De keuze zelf staat in de React-vrije `recordedDatesActiveKey` (`recordedDatesNoticeText.ts`), zodat ze headless getest kan worden in plaats van als inline ternary op het oog beoordeeld te worden; het retourtype is de literale sleutelunie, dus een niet-bestaande sleutel is een compile-fout. Tests: check-recorded-dates sectie 14 — de keuze per herkomst (14a–14c) en de INHOUD in alle veertien talen (14f–14h: de neutrale familie noemt Primavera nergens, de Primavera-familie juist overal, met gelijke pluralvormen), plus 7w2/7ai2 die vastleggen dat het document de herkomst draagt die de tekst stuurt. Mutatiebewijs: de keuze altijd op de Primavera-sleutel zetten ⇒ 14c rood; de Primavera-zin in een neutrale sleutel plakken (de) ⇒ 14g rood. --- src/components/layout/RecordedDatesNotice.tsx | 7 ++- .../layout/recordedDatesNoticeText.ts | 31 +++++++++++ src/i18n/locales/ar/common.json | 6 +++ src/i18n/locales/de/common.json | 2 + src/i18n/locales/en/common.json | 2 + src/i18n/locales/es/common.json | 3 ++ src/i18n/locales/fa/common.json | 2 + src/i18n/locales/fr/common.json | 3 ++ src/i18n/locales/it/common.json | 3 ++ src/i18n/locales/ja/common.json | 1 + src/i18n/locales/ko/common.json | 1 + src/i18n/locales/nl/common.json | 2 + src/i18n/locales/pl/common.json | 4 ++ src/i18n/locales/pt/common.json | 3 ++ src/i18n/locales/tr/common.json | 2 + src/i18n/locales/zh/common.json | 1 + tests/planning/check-recorded-dates.ts | 51 +++++++++++++++++++ 17 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 src/components/layout/recordedDatesNoticeText.ts diff --git a/src/components/layout/RecordedDatesNotice.tsx b/src/components/layout/RecordedDatesNotice.tsx index 1eb10c7ac..b5f6bdf52 100644 --- a/src/components/layout/RecordedDatesNotice.tsx +++ b/src/components/layout/RecordedDatesNotice.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next'; import { AlertTriangle, CircleDot, X } from 'lucide-react'; import { useAppStore } from '@/state/appStore'; +import { recordedDatesActiveKey } from './recordedDatesNoticeText'; /** * Strook voor "datums zoals opgeslagen" (issue #63). Naar het model van `DependencyModeNotice` @@ -33,6 +34,10 @@ export function RecordedDatesNotice() { // `recordedDates` pas bij het VERLATEN van de modus (zelfde producer als `datesAsRecorded`), // dus in de praktijk is `recordedDates` hier altijd gevuld; de terugval op de tellerloze // `recordedDates.active` is defensief. + // + // Tekstcorrectie (T8, 2026-09-05): de strook is GEDEELD met de #63-route voor elk ander formaat + // (IFC/CSV/MSPDI/MPP/P6XML), waar "Primavera" een verkeerde bewering zou zijn. De keuze zelf + // staat in de React-vrije `recordedDatesActiveKey` zodat ze headless getest kan worden. return (
{recordedDates - ? t('recordedDates.activeCount', { count: recordedDates.shifted }) + ? t(recordedDatesActiveKey(recordedDates.origin), { count: recordedDates.shifted }) : t('recordedDates.active')}
diff --git a/src/components/panels/ReportPanel.tsx b/src/components/panels/ReportPanel.tsx index 44638f7a6..c07ab95c1 100644 --- a/src/components/panels/ReportPanel.tsx +++ b/src/components/panels/ReportPanel.tsx @@ -233,6 +233,19 @@ export function ReportPanel() { const activeBaselineId = useAppStore(s => s.activeBaselineId); const barColorSelection = useAppStore(s => s.ui.barColorSelection); const setUI = useAppStore(s => s.setUI); + // "Datums zoals opgeslagen" (issue #63; eindreview XER-etappe bevinding 6): de tabelrapporten dragen + // de melding in hun spec (`useTableReportSpec`); Gantt, mijlpalen en afwijkingen krijgen 'm hier, + // boven het voorbeeld — dezelfde tekst, zodat geen enkel rapport nullen toont zonder te zeggen dat + // het bestand die assen niet vastlegde. + const datesAsRecorded = useAppStore(s => s.datesAsRecorded); + const recordedDatesNote = datesAsRecorded ? ( +
+ {t('tableReports.recordedDatesNote')} +
+ ) : null; const fieldCtx = useFieldCatalogCtx(); const barColorFields = barColorFieldOptions(fieldCtx); const barColorControl = effectiveBarColorControl(barColorSelection, fieldCtx); @@ -1394,6 +1407,7 @@ export function ReportPanel() {
{reportType === 'gantt' ? (
+ {recordedDatesNote}
) : reportType === 'milestones' ? (
+ {recordedDatesNote}
) : (
+ {recordedDatesNote}
s.datesAsRecorded); return useMemo(() => { if (!isTableReportType(reportType)) return null; - switch (reportType) { - case 'lookAhead': return buildLookAhead(ctx, options, t, dd, stale); - case 'critical': return buildCritical(ctx, options, t, dd, stale); - case 'progress': return buildProgress(ctx, options, t, dd, stale); - case 'health': return buildHealth(ctx, options, t, dd, stale); - case 'resourceLoading': return buildResourceLoading(ctx, options, t, tCommon, dd, stale); - case 'resourceAssignments': return buildResourceAssignments(ctx, options, t, tCommon, dd, stale); - case 'wbsSummary': return buildWbsSummary(ctx, options, t, dd, stale); - default: return null; - } - }, [reportType, options, ctx, stale, t, tCommon, dd]); + const build = (): TableReportSpec | null => { + switch (reportType) { + case 'lookAhead': return buildLookAhead(ctx, options, t, dd, stale); + case 'critical': return buildCritical(ctx, options, t, dd, stale); + case 'progress': return buildProgress(ctx, options, t, dd, stale); + case 'health': return buildHealth(ctx, options, t, dd, stale); + case 'resourceLoading': return buildResourceLoading(ctx, options, t, tCommon, dd, stale); + case 'resourceAssignments': return buildResourceAssignments(ctx, options, t, tCommon, dd, stale); + case 'wbsSummary': return buildWbsSummary(ctx, options, t, dd, stale); + default: return null; + } + }; + const spec = build(); + if (spec && datesAsRecorded) spec.notes = [t('tableReports.recordedDatesNote'), ...spec.notes]; + return spec; + }, [reportType, options, ctx, stale, datesAsRecorded, t, tCommon, dd]); } diff --git a/src/engine/scheduler/CPMSolver.ts b/src/engine/scheduler/CPMSolver.ts index d231fc617..2168b6d9f 100644 --- a/src/engine/scheduler/CPMSolver.ts +++ b/src/engine/scheduler/CPMSolver.ts @@ -192,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. */ @@ -386,8 +387,13 @@ export class CPMSolver { } } // X12: relationele P6/XER-ankers vormen één brongepoorte grens. RelationMath en alle solver- - // paden zien uitsluitend deze effectieve relaties; een losse vlag in generieke IFC/ext-data kan - // daarmee nooit P6-gedrag activeren. De XER-reader is de enige schrijver van `p6Source`. + // 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 @@ -1044,7 +1050,9 @@ 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( 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/relationMath.ts b/src/engine/scheduler/relationMath.ts index 98ad5dd64..476b4cd09 100644 --- a/src/engine/scheduler/relationMath.ts +++ b/src/engine/scheduler/relationMath.ts @@ -579,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( diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index 469c4af52..4f44c96ea 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -1160,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/de/common.json b/src/i18n/locales/de/common.json index ae9f442bb..26024c567 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -1024,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/en/common.json b/src/i18n/locales/en/common.json index 716342f52..6359d0813 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -1047,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/es/common.json b/src/i18n/locales/es/common.json index e0212478f..d4922455e 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -1058,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/fa/common.json b/src/i18n/locales/fa/common.json index 6217d08c4..37b2dde4c 100644 --- a/src/i18n/locales/fa/common.json +++ b/src/i18n/locales/fa/common.json @@ -1024,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/fr/common.json b/src/i18n/locales/fr/common.json index 8a7d69746..d016b4354 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -1058,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/it/common.json b/src/i18n/locales/it/common.json index 40c9f8dfa..f01763f8b 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -1058,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/ja/common.json b/src/i18n/locales/ja/common.json index e5fd08120..f4c31e03a 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -990,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/ko/common.json b/src/i18n/locales/ko/common.json index 6dee23b17..648534630 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -990,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/nl/common.json b/src/i18n/locales/nl/common.json index 8ec6c1b91..9f2398bd4 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -1047,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/pl/common.json b/src/i18n/locales/pl/common.json index ef7d13013..9d07421fe 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -1092,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/pt/common.json b/src/i18n/locales/pt/common.json index 17a57cb37..e6f492977 100644 --- a/src/i18n/locales/pt/common.json +++ b/src/i18n/locales/pt/common.json @@ -1058,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/tr/common.json b/src/i18n/locales/tr/common.json index 032a5e864..da39e4f23 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -1024,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/zh/common.json b/src/i18n/locales/zh/common.json index d8393c98d..51029d462 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -990,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/services/ifc/ifcReader.ts b/src/services/ifc/ifcReader.ts index dc67d3491..69aeac3a8 100644 --- a/src/services/ifc/ifcReader.ts +++ b/src/services/ifc/ifcReader.ts @@ -36,6 +36,7 @@ import { 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, @@ -2782,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[], @@ -2796,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/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/xer/xerCalendarData.ts b/src/services/xer/xerCalendarData.ts index bf8cce1a1..d3cfd2fbf 100644 --- a/src/services/xer/xerCalendarData.ts +++ b/src/services/xer/xerCalendarData.ts @@ -50,7 +50,11 @@ export type XerCalendarRecovery = | 'COMPACT_RECORD' | 'SURPLUS_CLOSE' | 'MULTIPLE_EXCEPTIONS' - | 'DUPLICATE_EXCEPTION'; + | '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' @@ -69,6 +73,7 @@ export type XerCalendarIssueCode = | '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'; @@ -728,11 +733,11 @@ export function decodeXerCalendarData(text: string): DecodedXerCalendarData { holidaysByDate.delete(date); if (!workingByDate.has(date)) { workingByDate.set(date, { - name: 'Kalenderuitzondering', startDate: date, endDate: date, bands: exceptionBands, + name: 'Calendar exception', startDate: date, endDate: date, bands: exceptionBands, }); } } else if (!workingByDate.has(date) && !holidaysByDate.has(date)) { - holidaysByDate.set(date, { name: 'Kalenderuitzondering', startDate: date, endDate: 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 @@ -741,18 +746,21 @@ export function decodeXerCalendarData(text: string): DecodedXerCalendarData { // 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: 'Kalenderuitzondering (weekendherstel)', startDate: target, endDate: 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, @@ -936,6 +944,15 @@ export function readXerCalendars(tables: XerTables): XerCalendarReadResult { 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])); diff --git a/src/types/project.ts b/src/types/project.ts index 71ae4a0f6..0874c3eb5 100644 --- a/src/types/project.ts +++ b/src/types/project.ts @@ -11,8 +11,13 @@ 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' = de huidige - * LAG_CALENDAR-constante (lagCalendar.ts) ⇒ byte-identiek. */ + /** 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 diff --git a/tests/browser/recorded-dates.spec.ts b/tests/browser/recorded-dates.spec.ts index 8ba9d0a3a..0d38e3d41 100644 --- a/tests/browser/recorded-dates.spec.ts +++ b/tests/browser/recorded-dates.spec.ts @@ -104,9 +104,19 @@ test('datums zoals opgeslagen: een XER met restverschillen opent in de modus, me 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/planning/check-ifc-roundtrip.ts b/tests/planning/check-ifc-roundtrip.ts index 32056b76b..425a82cc9 100644 --- a/tests/planning/check-ifc-roundtrip.ts +++ b/tests/planning/check-ifc-roundtrip.ts @@ -840,6 +840,52 @@ const hasP6BoundarySequence = (input: ImportResult) => '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'")) diff --git a/tests/planning/check-xer-archive-scale.ts b/tests/planning/check-xer-archive-scale.ts index 31528eb9d..d55c6e4da 100644 --- a/tests/planning/check-xer-archive-scale.ts +++ b/tests/planning/check-xer-archive-scale.ts @@ -52,6 +52,8 @@ const measurements = [1, 8, 32].map(chunkCount => { 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 }; @@ -60,6 +62,19 @@ const measurements = [1, 8, 32].map(chunkCount => { 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(' | ')}`); diff --git a/tests/planning/check-xer-calendar-data.ts b/tests/planning/check-xer-calendar-data.ts index af5a695a9..1a63b8640 100644 --- a/tests/planning/check-xer-calendar-data.ts +++ b/tests/planning/check-xer-calendar-data.ts @@ -96,10 +96,10 @@ eq('2 DaysOfWeek zet P6 zo/ma/di om naar ISO-weekdagen en canonieke banden', dec 3: [], 4: [], 5: [], 6: [], 7: [], }); eq('3 uitzondering zonder uren is een vrije dag vanaf de P6-epoch', decoded.holidays, [{ - name: 'Kalenderuitzondering', startDate: '1899-12-30', endDate: '1899-12-30', + 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: 'Kalenderuitzondering', startDate: '1899-12-31', endDate: '1899-12-31', + 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); @@ -275,7 +275,7 @@ eq('15c werkende uitzondering wint op een dubbele datum en sluit holiday-dubbeli }, { holidays: [], working: [{ - name: 'Kalenderuitzondering', + name: 'Calendar exception', startDate: '1899-12-30', endDate: '1899-12-30', bands: [{ start: 540, end: 780 }], @@ -313,10 +313,10 @@ eq('15e meerdere Exceptions-containers behouden vrije en werkende datums in bron working: multipleExceptionContainers.calendars[0]?.workingExceptions, }, { holidays: [{ - name: 'Kalenderuitzondering', startDate: '1899-12-30', endDate: '1899-12-30', + name: 'Calendar exception', startDate: '1899-12-30', endDate: '1899-12-30', }], working: [{ - name: 'Kalenderuitzondering', startDate: '1899-12-31', endDate: '1899-12-31', + name: 'Calendar exception', startDate: '1899-12-31', endDate: '1899-12-31', bands: [{ start: 540, end: 780 }], }], }); @@ -569,6 +569,14 @@ eq('22a het geklemde blok wordt tot tien aaneengesloten vrije dagen hersteld', ]); 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 @@ -587,6 +595,8 @@ eq('22b de sprong vrijdag→maandag herstelt het weekend ook zonder duplicaat op // 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 @@ -617,6 +627,8 @@ eq('22e op een ma-vr-kalender wordt er niets hersteld', 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`); diff --git a/tests/planning/check-xer-field-whitelist.ts b/tests/planning/check-xer-field-whitelist.ts index 552970901..7abc3752f 100644 --- a/tests/planning/check-xer-field-whitelist.ts +++ b/tests/planning/check-xer-field-whitelist.ts @@ -246,10 +246,26 @@ export const XER_TASK_IGNORED: readonly string[] = [ // 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 `'')`/`"")` (laatste argument van -// een aanroep, zoals `ctx.numberOf(row, 'total_float_hr_cnt')`) — en negeert bewust kale +// `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), @@ -277,19 +293,23 @@ export const XER_TASK_IGNORED: readonly string[] = [ 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) { - const cellAccess = new RegExp(`cells(?:\\.${field}\\b|\\[['"]${field}['"]\\])`); - const callArg = new RegExp(`['"]${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*=`); - if (cellAccess.test(text) || callArg.test(text) || destructure.test(text)) { - overtreders.push(`${field} in ${file.slice(srcRoot.length)}`); - } + if (readsField(text, field)) overtreders.push(`${field} in ${file.slice(srcRoot.length)}`); } } checks++; @@ -298,6 +318,48 @@ export const XER_TASK_IGNORED: readonly string[] = [ `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'))); + } } } } diff --git a/tests/planning/xer-calendar-hour-mode-baseline.json b/tests/planning/xer-calendar-hour-mode-baseline.json index 035e2477e..576c61a4f 100644 --- a/tests/planning/xer-calendar-hour-mode-baseline.json +++ b/tests/planning/xer-calendar-hour-mode-baseline.json @@ -69,6 +69,7 @@ "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 }, @@ -76,6 +77,6 @@ "hash": "2c1dce175b9f078111a48dc13fd1777f5fbd4cd7ab6623e647e7437330c60b7f", "calendars": 124, "dayHourFieldsPresent": 0, - "semanticDigest": "d6221c87cbcbb6b29fc63365bd318f8f79f5ce6fa199e0706c6eac444b4c05f5" + "semanticDigest": "ab14e3089fa6bce4bbd8232654a10330eff2725ec9249cc9e43b93a690305fb4" } } From 2b8cd8fa900f2d60ec5bb5f639c37c7d77ee078c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 06:06:19 +0000 Subject: [PATCH 170/171] =?UTF-8?q?docs(xer):=20eigenaarsbesluiten=202026-?= =?UTF-8?q?09-09=20=E2=80=94=20heropen-beleid=20optie=20B,=20'datums=20zoa?= =?UTF-8?q?ls=20opgeslagen'=20voor=20alle=20formaten,=20meetlat=20per=20fo?= =?UTF-8?q?rmaat=20als=20aparte=20etappe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vraag 1 van de eigenaarslijst is beantwoord (optie B: heropend eigen IFC automatisch in de modus zolang het document sinds de import niet is bewerkt) en de eigenaar besloot daarbovenop dat elk formaat zich als XER moet gedragen: automatisch de weergave aan bij afwijkingen, met melding en strook, 'vergelijk wat er is' per formaat (ook CSV); de nul-afwijkingen-meetlat per formaat volgt als aparte etappe (optie 3). Beide als etappe-items in docs/TODO.md, met de gemeten huidige stand van de lezers (MSPDI/P6 XML/.mpp/CSV lezen alleen start/einde en vullen late datums en speling), de volgorde en het beschikbare corpusmateriaal. Plan §10.f verwijst. Niet gebouwd in deze PR. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JbhTSYksdWG5Ljgg9hSZhW --- docs/TODO.md | 30 +++++++++++++++++++ .../plans/2026-08-20-plan-xer-p6-lezer.md | 8 +++++ 2 files changed, 38 insertions(+) diff --git a/docs/TODO.md b/docs/TODO.md index bb83b5f39..ada8bbe6b 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -749,6 +749,36 @@ tag-push de `.snap` als release-asset. Geverifieerd via een `workflow_dispatch`- 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. +- [ ] **"Datums zoals opgeslagen" voor ÁLLE formaten zoals nu bij XER** (eigenaarsbesluit + 2026-09-09: "het moet altijd gaan zoals het nu bij XER werkt"). Stand: alleen de XER-lezer levert + `recordedTimes` (zes assen); de IFC-lezer levert `recordedFields` en biedt de weergave alleen aan; + MSPDI, P6 XML, `.mpp` en CSV lezen alleen start/einde en zetten late datums en speling als + VULLING (`lateStart = start`, `totalFloat = 0`) — geen vergelijking, geen melding, geen weergave. + Te bouwen, als eigen etappe op een nieuwe branch bovenop PR #109 (niet in die PR: de eindreview + is dan ongeldig): + 1. Lezers: MSPDI (`/////`), + P6 XML (`EarlyStartDate`/`LateStartDate`/…/`TotalFloat`/`FreeFloat`) en `.mpp` (veldkaart + nog te controleren — ONBEKEND of de late velden erin zitten) leveren `recordedTimes` op zes + assen, CSV op wat de kolommen dragen (start/einde, evt. totale speling); ontbrekende assen + zijn "niet vastgelegd", nooit vulling. Zelfde kanaal als XER, nooit solverinvoer (bak 4). + 2. Laadbeleid: bij afwijkingen automatisch de weergave aan, met openingsmelding en strook, + voor elk formaat (besluit vraag 1-van-11 optie "A: vergelijk wat er is" — ook voor CSV; + een CSV met alleen start/einde vergelijkt op die twee assen). IFC uit een ander pakket: + automatisch aan i.p.v. alleen aanbieden. Eigen IFC (heropenen): optie B — automatisch aan + zolang het document sinds de import niet is bewerkt (nieuw kenmerk "ongewijzigd sinds + import" in het IFC, gewist bij elke bewerking; opslaan-zonder-bewerken telt als + ongewijzigd), anders alleen aanbieden. + 3. Per formaat een check in `tests/planning/` (mechanisme + round-trip) en een browsertest; + gidsen `datums-zoals-opgeslagen.md` en `gids-import-export.md` (nl+en) bijwerken. + Volgorde: P6 XML → MSPDI → `.mpp` → CSV → IFC-optie B. +- [ ] **Meetlat per formaat (nul afwijkingen zoals XER §1), als aparte etappe ná het + mechanisme hierboven** (eigenaarsbesluit 2026-09-09, optie 3). 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 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 index 46ecc8a3a..9ba23b8da 100644 --- a/docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md +++ b/docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md @@ -832,6 +832,14 @@ exportverliesmelding (ná het schrijven), een bovengrens op documenten per besta 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) — beide in `docs/TODO.md`. + 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`, From 17d12f7c908e16d7052f879f6d4aca69addcefc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 06:09:51 +0000 Subject: [PATCH 171/171] =?UTF-8?q?docs(xer):=20verduidelijking=20eigenaar?= =?UTF-8?q?=20=E2=80=94=20alleen=20de=20meetlat-etappe=20naar=20de=20TODO;?= =?UTF-8?q?=20het=20mechanisme=20voor=20alle=20formaten=20wordt=20gebouwd?= =?UTF-8?q?=20(plan=20=C2=A710.f)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JbhTSYksdWG5Ljgg9hSZhW --- docs/TODO.md | 27 +++---------------- .../plans/2026-08-20-plan-xer-p6-lezer.md | 8 +++++- 2 files changed, 10 insertions(+), 25 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index ada8bbe6b..35824a954 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -749,30 +749,9 @@ tag-push de `.snap` als release-asset. Geverifieerd via een `workflow_dispatch`- 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. -- [ ] **"Datums zoals opgeslagen" voor ÁLLE formaten zoals nu bij XER** (eigenaarsbesluit - 2026-09-09: "het moet altijd gaan zoals het nu bij XER werkt"). Stand: alleen de XER-lezer levert - `recordedTimes` (zes assen); de IFC-lezer levert `recordedFields` en biedt de weergave alleen aan; - MSPDI, P6 XML, `.mpp` en CSV lezen alleen start/einde en zetten late datums en speling als - VULLING (`lateStart = start`, `totalFloat = 0`) — geen vergelijking, geen melding, geen weergave. - Te bouwen, als eigen etappe op een nieuwe branch bovenop PR #109 (niet in die PR: de eindreview - is dan ongeldig): - 1. Lezers: MSPDI (`/////`), - P6 XML (`EarlyStartDate`/`LateStartDate`/…/`TotalFloat`/`FreeFloat`) en `.mpp` (veldkaart - nog te controleren — ONBEKEND of de late velden erin zitten) leveren `recordedTimes` op zes - assen, CSV op wat de kolommen dragen (start/einde, evt. totale speling); ontbrekende assen - zijn "niet vastgelegd", nooit vulling. Zelfde kanaal als XER, nooit solverinvoer (bak 4). - 2. Laadbeleid: bij afwijkingen automatisch de weergave aan, met openingsmelding en strook, - voor elk formaat (besluit vraag 1-van-11 optie "A: vergelijk wat er is" — ook voor CSV; - een CSV met alleen start/einde vergelijkt op die twee assen). IFC uit een ander pakket: - automatisch aan i.p.v. alleen aanbieden. Eigen IFC (heropenen): optie B — automatisch aan - zolang het document sinds de import niet is bewerkt (nieuw kenmerk "ongewijzigd sinds - import" in het IFC, gewist bij elke bewerking; opslaan-zonder-bewerken telt als - ongewijzigd), anders alleen aanbieden. - 3. Per formaat een check in `tests/planning/` (mechanisme + round-trip) en een browsertest; - gidsen `datums-zoals-opgeslagen.md` en `gids-import-export.md` (nl+en) bijwerken. - Volgorde: P6 XML → MSPDI → `.mpp` → CSV → IFC-optie B. -- [ ] **Meetlat per formaat (nul afwijkingen zoals XER §1), als aparte etappe ná het - mechanisme hierboven** (eigenaarsbesluit 2026-09-09, optie 3). Nu: alleen XER (93 bestanden, +- [ ] **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, 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 index 9ba23b8da..7d07ef541 100644 --- a/docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md +++ b/docs/superpowers/plans/2026-08-20-plan-xer-p6-lezer.md @@ -837,7 +837,13 @@ exportverliesmelding (ná het schrijven), een bovengrens op documenten per besta 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) — beide in `docs/TODO.md`. + 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