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/152] =?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/152] =?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/152] =?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/152] =?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/152] 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/152] =?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/152] =?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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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/152] 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 f4ed66e232c772a6789582a432aaca2388b12590 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 18:22:32 +0000 Subject: [PATCH 106/152] docs(spec): ontwerp taaktypes, opgeslagen werk en effort-driven bewerken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opvolger van het voorstel van 2026-08-18, alleen documentatie. Bevat de MSP/P6-documentatievergelijking met bronnen, het neutrale datamodel (werkregel per taak, drie optionele werkvelden per toewijzing), de regeltabel per type × bewerking op het restant, de detailregels, de meetlat van 28 bewerkingen voor een latere MS Project-meting, de bouwvolgorde (XER merget eerst) en de corpuscijfers als onderbouwing. Drie open beslispunten (8–10) staan expliciet gemarkeerd; registratie in docs/superpowers/README.md en de TODO-toevoegingen. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0159unb6VzxUx7WG4w3a2ARA --- docs/TODO.md | 27 + docs/superpowers/README.md | 1 + ...26-09-04-spec-taaktypes-opgeslagen-werk.md | 618 ++++++++++++++++++ 3 files changed, 646 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md diff --git a/docs/TODO.md b/docs/TODO.md index ed0cc6ed3..00ce2c15d 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -350,6 +350,33 @@ deze lijst verwijderd — wat klaar is, staat in de changelog en git-historie. - [ ] **Bewerken-meetlat tegen MS Project.** De herschalingsregel (proportioneel, actuals blijven, FIXED_WORK houdt werk) volgt MSP's gedocumenteerde gedrag maar is niet tegen MSP zelf gemeten — de taaktypes-spec noemt die meetlat als de duurste post van de vervolgetappe. + +### Taaktypes / opgeslagen werk — ontwerp geschreven, nog niet gebouwd (2026-09-04) + +> Ontwerp: `docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md` (opvolger van de +> spec van 2026-08-18). Bouwt pas op een main mét de XER-import erin (tweede XER-merge eerst). +> Eigenaarsbesluiten 1–7 van 2026-09-04 staan daar in §3; drie punten staan nog open. + +- [ ] **Beslispunt 8 — twee MS Project-gedragingen passen niet in de vier P6-types** (spec §2.3): + "Fixed Units, niet effort-driven" (MSP's fabrieksinstelling) en "Fixed Duration, effort-driven". + Advies: vier types in het menu, bewaard `effortDriven` stuurt alleen de twee afwijkende cellen. + Blokkeert alleen bouwstap 6 (resource erbij/eraf). +- [ ] **Beslispunt 9 — drie optionele werkvelden per toewijzing** (begroot/verricht/resterend, de + driehoek op het restant); aanbeveling uit de XER-corpusscan, nog niet expliciet bevestigd. +- [ ] **Beslispunt 10 — meerdere toewijzingen zonder eigen spanne** (spec §6.2): OPS laat elke + toewijzing over de hele restduur lopen; MSP/P6 geven toewijzingen een eigen spanne. Later op te + heffen door `workWindowStart/Finish` te activeren. +- [ ] **MSP-meetlat: 28 bewerkingen** (spec §9) meten in MS Project (en P6) zodra iemand het heeft; + tot dan draagt elke case `evidence: 'reasoned' | 'documented'` in `cases-taaktypes.json`. +- [ ] **Telling `mspTaskType × effortDriven` over de `OPS_MPP_CRAWL`-set** (216 bestanden): bepaalt + hoe vaak beslispunt 8 in de praktijk speelt. Het corpus is niet in de repo. +- [ ] **Nivelleerder-optie "inzet verlagen bij vast werk"** (eigenaarsbesluit 7-B, 2026-09-04) als + geavanceerde optie naast het verschuiven; de verdeler raakt nu nooit inzet of werk. +- [ ] **% werk gereed** (MSP % Work Complete) naast de duurgebaseerde `completion`. +- [ ] **Projectstandaard-werkregel in de UI** (projectwizard/projectinfo); het veld zelf komt in + bouwstap 1. +- [ ] **P6-optie "preserve existing assignments"** bij resource erbij: OPS volgt altijd de + synchronisatietabel ("recalculate"); de preserve-variant is een instelling voor later. - [ ] **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. diff --git a/docs/superpowers/README.md b/docs/superpowers/README.md index fba39a9c7..bc34ff2e9 100644 --- a/docs/superpowers/README.md +++ b/docs/superpowers/README.md @@ -67,6 +67,7 @@ Wél verplaatst, omdat ze nergens meer bij horen: | document | status | |---|---| | `HANDOFF-2026-08-14-roadmap.md` | **actief** — wat er nog op de roadmap staat, met peildatum en afhankelijkheden | +| `specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md` | **actief ontwerp, nog niet gebouwd** — taaktypes/werkregels, opgeslagen werk per toewijzing en effort-driven bewerken; opvolger van `specs/2026-08-18-spec-taaktypes-effort-driven.md`. Bevat de MSP/P6-documentatievergelijking, de regeltabel, de meetlat (28 bewerkingen) en drie open beslispunten (8–10). Bouwt pas ná de tweede XER-merge. | | `werkdagen-as-ontwerp.md` | naslag; aangehaald vanuit `timeAxis.ts`, `workdayAxis.ts` en `check-workday-axis.ts` | | `verticale-drag-ontwerp.md`, `verticale-drag-ontwerp-B.md` | naslag; twee varianten van hetzelfde ontwerp | | `modulariteit-audit.md`, `prestatie-modulariteit-audit.md` | de audits waar de P-bevindingen uit komen; aangehaald vanuit testkoppen | diff --git a/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md b/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md new file mode 100644 index 000000000..4de7b8f73 --- /dev/null +++ b/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md @@ -0,0 +1,618 @@ +# Taaktypes, opgeslagen werk en effort-driven plannen — ontwerp + +*Ontwerp, 2026-09-04. Opvolger van het voorstel +[`2026-08-18-spec-taaktypes-effort-driven.md`](2026-08-18-spec-taaktypes-effort-driven.md) +("ontwerp vóór bouw"). Status: **ontwerp, nog niet gebouwd.** Alleen documentatie; er is in deze +ronde geen code aangeraakt. De contour-engine, de contour-UI en de fasen-editor (2026-09) zijn +inmiddels geleverd en worden hier als bestaand fundament gebruikt.* + +## 0. Leeswijzer + +Elke bewering in dit document draagt één van deze labels, zodat een bouwer ziet waar hij op kan +leunen en waar niet: + +| label | betekenis | +|---|---| +| **ZEKER** | staat letterlijk in de documentatie van Microsoft of Oracle (bron in §13), of in onze eigen code | +| **GEMETEN** | geteld op een corpus (het XER-corpus van de XER-sessie, §11) | +| **AFGELEID** | volgt met een korte redenering uit ZEKER/GEMETEN feiten; de redenering staat erbij | +| **BEREDENEERD** | ontwerpkeuze zonder bron; de meetlat (§9) moet dit later tegen MS Project toetsen | +| **ONBEKEND** | niet gevonden in documentatie en niet gemeten; expliciet open | +| **BESLOTEN** | eigenaarsbesluit (§3) | + +De eigenaar vroeg om "beredeneren + grondig onderzoek in de documentatie van MS Project en P6" als +bewijs, omdat er niemand met MS Project beschikbaar is om te meten (besluit 4). Dat is de reden dat +§2 zo uitgebreid is en dat §9 een concrete lijst bewerkingen bevat die een latere meting kan +afwerken. + +## 1. Waar dit over gaat + +Eén rekensom: **werk = duur × inzet.** Ken je er twee, dan ligt de derde vast. Het karakter van een +planningspakket wordt bepaald door de vraag welke van de drie *beschermd* is wanneer de gebruiker +aan een van de andere twee draait. + +Open Planner Studio beschermt vandaag altijd duur en inzet: dat zijn de opgeslagen velden +(`scheduleDuration`/`durationMinutes` op de taak, `unitsPerDay` op de toewijzing), en werk wordt er +elke keer uit afgeleid — "werk = duur × unitsPerDay, altijd afgeleid, nooit opgeslagen" staat +letterlijk in `src/types/resource.ts` (ZEKER). In de terminologie van P6 is dat **Fixed Duration & +Units/Time**; in die van MS Project **Fixed Duration, niet effort-driven** (AFGELEID uit de tabellen +in §2.1 en §2.2: bij een duurwijziging beweegt het werk mee, bij een inzetwijziging beweegt het werk +mee, een resource erbij voegt werk toe). + +Deze etappe maakt die keuze per taak instelbaar en maakt werk een echte, opgeslagen grootheid. De +kernregels uit het voorstel van 2026-08-18 blijven staan: standaard verandert er niets; de +aan-knop stuurt alleen de weergave en nooit de berekening; de semantiek zit in het document. + +## 2. Wat MS Project en P6 doen + +### 2.1 MS Project + +**De drie taaktypes en de rekentabel** (ZEKER, bron [M1], [M4]): + +| taaktype | inzet gewijzigd | duur gewijzigd | werk gewijzigd | +|---|---|---|---| +| Fixed Units | duur herberekend | werk herberekend | duur herberekend | +| Fixed Work | duur herberekend | inzet herberekend | duur herberekend | +| Fixed Duration | werk herberekend | werk herberekend | inzet herberekend | + +**Standaardtype** is Fixed Units (ZEKER, [M4]: "Fixed Units (default)"); de gebruiker kan dat in de +projectopties wijzigen. + +**Effort-driven** (ZEKER, [M2], [M3]): + +- Effort-driven gaat uitsluitend over het **toevoegen of verwijderen van resources**: "Project + lengthens or shortens the duration of the task based on the number of resources that are assigned + to it, but Project does not change the total work for the task." Bij Fixed Units verkort een + resource erbij de duur; bij Fixed Duration daalt de inzet van elke resource. +- Effort-driven werkt pas **ná de eerste toewijzing**: "When assigning multiple resources + simultaneously to a task, the duration doesn't change from your original estimate." De eerste + toewijzing(en) zetten het werk; daarna houdt effort-driven dat werk vast. +- **Fixed Work is altijd effort-driven** en dat vinkje is daar niet te wijzigen: "Project doesn't + consider fixed work tasks to have flexible work values and are therefore always effort-driven." +- Samenvattingstaken en ingevoegde projecten kunnen niet effort-driven zijn. +- Standaardwaarde van het vinkje voor nieuwe taken: de Microsoft-artikelen die ik vond noemen alleen + dát er een optie "New tasks are effort driven" bestaat, niet wat hij standaard is ([M2], [M3]). + Secundaire bronnen (trainingsmateriaal, [M9]) zeggen "standaard uit" voor 2010/2013/2016. Ik + markeer dit als **AFGELEID uit secundaire bron**, niet ZEKER. + +**Werk, verricht werk en restwerk** (ZEKER, [M5], [M6], [M7], [M8]): + +- Taakwerk = som van het werk van alle toewijzingen. Toewijzingswerk = spanne × inzet + ("The span of an assignment is multiplied by the assignment units to calculate the amount of + work": 100 % op één dag = 8 uur, 50 % = 4 uur, 200 % = 16 uur). +- Restwerk = werk − verricht werk; restduur = duur − verrichte duur. Wie de restduur wijzigt, houdt de + verrichte duur vast en verandert de totale duur ("Project changes duration to match the sum of + the remaining duration and actual duration and leaves the actual duration unchanged"). +- Wie taakwerk of verricht werk op taakniveau invoert, laat Project dat "onder de toegewezen + resources verdelen"; **hoe** die verdeling gaat (evenredig aan inzet? aan bestaand werk?) staat er + niet bij — ONBEKEND. Voor tijdgefaseerde invoer zegt [M7] wel "in the same proportion of work + scheduled for each assigned resource at that point in time". +- Wat een wijziging van de *totale* duur op een lopende taak met verrichte duur doet, staat niet + letterlijk in [M6]/[M8]. AFGELEID: verricht werk en verrichte duur zijn feiten die geen enkele + formule in de artikelen aanpast; de enige velden die de formules laten bewegen zijn duur, restduur, + restwerk en % gereed. Dit is precies de vraag die aan het eind van het vorige gesprek open bleef + ("houdt MS Project het rest- of het totaalwerk vast bij een duurwijziging op een lopende + Fixed-Work-taak"): documentatie geeft er geen uitsluitsel over; §5 kiest "de driehoek werkt op het + restant" en §9 (meting 16–18) legt dat vast als te toetsen. + +### 2.2 Primavera P6 + +**De vier duration types en hun formules** (ZEKER, [P1], [P2]): + +| duration type | wat vastligt | formule uit de documentatie | +|---|---|---| +| Fixed Duration & Units/Time | duur en inzet per tijdseenheid | Remaining Units = Units/Time × Remaining Duration | +| Fixed Duration & Units | duur en totaal werk | Units/Time = Remaining Units / Remaining Duration | +| Fixed Units/Time | inzet per tijdseenheid | Duration = Units / (Units/Time) | +| Fixed Units | totaal werk | idem | + +Let op de woorden in de formules: **Remaining** Units en **Remaining** Duration. P6 rekent de +driehoek op het resterende deel, niet op het totaal (ZEKER, [P2]). Dat is de bron van de keuze in §5. + +**De synchronisatietabel** (ZEKER, [P3] — Oracle's eigen overzicht van wat er per type verandert): + +| duration type | inzet (units) gewijzigd | duur gewijzigd | inzet/tijd gewijzigd | resource erbij zonder werk | extra resource erbij | +|---|---|---|---|---|---| +| Fixed Units/Time | duur | werk | duur | werk | duur | +| Fixed Duration & Units/Time | inzet/tijd | werk | werk | werk | werk | +| Fixed Units | duur | inzet/tijd | duur | werk | duur | +| Fixed Duration & Units | inzet/tijd | inzet/tijd | werk | werk | inzet/tijd van elke resource | + +(In P6-termen: "Units" = totaal werk in uren, "Units/Time" = inzet per tijdseenheid. Ik schrijf hier +werk en inzet/tijd om verwarring met OPS' `unitsPerDay` te voorkomen.) + +**Overige P6-regels** (ZEKER): + +- Het duration type doet pas iets zodra er minstens één resource op de activiteit staat ([P2]: + "Duration type applies only when at least one resource is assigned to the activity"). +- Bij Start- en Finish-mijlpalen is het veld uitgeschakeld ([P1]). +- Projectoptie bij het toevoegen van resources ([P4]): "Preserve the units, duration, and units/time + for existing assignments" óf "Recalculate … based on the activity's duration type". De laatste + kolom van de synchronisatietabel geldt dus alleen bij de tweede instelling. +- Projectoptie bij actuals ([P5]): "Add actual to remaining" (At Completion = Remaining + Actual) óf + "Subtract actual from at completion" (Remaining = At Completion − Actual). Verricht werk is in beide + varianten een invoer die door niets anders wordt herschreven. +- Standaard duration type voor nieuwe activiteiten: niet gevonden in de geraadpleegde pagina's — + ONBEKEND uit documentatie. GEMETEN (§11): 89 % van de activiteiten in het XER-corpus staat op + `DT_FixedDUR2` (Fixed Duration & Units). + +### 2.3 Waar ze verschillen — en de consequentie voor besluit 1 + +Zet je de tabellen van §2.1 en §2.2 naast elkaar, dan zijn drie MS Project-combinaties exact gelijk +aan een P6-type, en twee niet (AFGELEID, cel voor cel uit [M1] en [P3]): + +| MS Project | P6 | overeenkomst | +|---|---|---| +| Fixed Units, effort-driven | Fixed Units/Time | alle vijf kolommen gelijk | +| Fixed Work (altijd effort-driven) | Fixed Units | alle vijf kolommen gelijk | +| Fixed Duration, niet effort-driven | Fixed Duration & Units/Time | alle vijf kolommen gelijk | +| Fixed Duration, effort-driven | Fixed Duration & Units | **verschil bij duur gewijzigd**: MSP herberekent werk ([M1], rij Fixed Duration), P6 herberekent inzet/tijd ([P3]) | +| Fixed Units, niet effort-driven | — | **geen P6-tegenhanger**: bij resource erbij groeit het werk en blijft de duur; alle vier P6-types die de inzet vastzetten verkorten de duur | + +Besluit 1 (menukaart = de vier P6-types, geen los vinkje) dekt dus drie van de vijf +MS Project-gedragingen exact. De twee andere zijn geen exotische gevallen: "Fixed Units, niet +effort-driven" is de fabrieksinstelling van MS Project (Fixed Units is ZEKER standaard, het vinkje +staat volgens secundaire bronnen standaard uit), dus vermoedelijk het meest voorkomende type in +.mpp-bestanden — GEMETEN is dat niet (het .mpp-fidelity-corpus is hier niet beschikbaar; een telling +van `mspTaskType × effortDriven` over de `OPS_MPP_CRAWL`-set is in §12 als TODO opgenomen). + +Dit is een nieuw feit ten opzichte van het gesprek van 2026-09-04 en vraagt om een aanvulling op +besluit 1. Het staat in §3 als **beslispunt 8** met drie opties en een advies; de bouwvolgorde (§10) +zet het gedrag "resource erbij/eraf" bewust als aparte, late stap zodat de rest niet wacht. + +## 3. Besluiten + +### 3.1 Al vastgelegd (2026-08-18, voorstel + eigenaarsbesluit in het mpp-plan) + +1. De volledige motor komt er, **opt-in**; standaard blijft alles zoals nu. +2. De knop stuurt **alleen de weergave, nooit de berekening**; taaktypes zijn documentdata. +3. Een bestand met taaktypedata **ontsluit de weergave automatisch** voor dat document, met melding. +4. Het **eigenschappenpaneel** krijgt de instelling; een tabelkolom mag mee zodra het kolommensysteem + dat draagt (de tabelrevisie van 2026-08-24 bestaat inmiddels — ZEKER, `taskColumnRegistry.ts`). +5. Semantiek werkt **alleen op bewerkingen**, nooit bij het openen/herberekenen van een bestand + (anders verschuiven de gepinde importdatums). +6. Import **bewaart** MSP's velden (gedaan: `Task.mspTaskType`, `Task.effortDriven`, pset + `OPS_MspTaskType`; alleen de `.mpp`-lezer vult ze — ZEKER, `mppReader.ts`; de MSPDI-lezer leest + ``/`` op taakniveau vandaag **niet**, ZEKER, `mspdiReader.ts`). +7. De **contour-engine** hoort bij deze etappe (gedaan, 2026-09). +8. **Hele-eenheden-afronding** alleen op het formulepad, nooit op opgeslagen dagwaarden (gedaan). +9. Het interne model is **neutraal** tussen MSP en P6 (superset). +10. De **bewerken-meetlat** wordt vóór de motorbouw ontworpen (→ §9). +11. Geen tijdstip; geen gebruikersvraag die dit voorrang geeft. + +### 3.2 Genomen op 2026-09-04 + +| nr | besluit | +|---|---| +| 1 | Menukaart = **P6-vorm: vier types, geen los effort-driven-vinkje**; intern een superset die MSP dekt (zie beslispunt 8 voor de twee gevallen die niet passen). | +| 2 | **Een typewissel verandert geen enkel getal**; alleen welke hoek voortaan beschermd is. Bij wissel naar een type dat werk beschermt wordt het huidige werk vastgelegd zoals het is. | +| 3 | Resource toevoegen op een gecontoureerde taak met vast werk: **vorm blijft, hoogte zakt evenredig; de nieuwe resource is vlak; de duur wordt korter.** "Volg MS Project, meting bepaalt details." | +| 4 | Bewijs = **beredeneren + grondig documentatieonderzoek**, elke detailregel expliciet; meting tegen MSP zelf in de TODO met een bewerkingenlijst. | +| 5 | **Aan-knop = app-instelling** ("Toon taaktypes"), plus automatische ontsluiting per document (3.1-3). | +| 6 | Reikwijdte: **alleen gewone bladtaken op werktijd, en uurtaken.** | +| 7 | **Nivelleerder blijft alleen verschuiven**; "inzet verlagen bij vast werk" komt in de TODO als geavanceerde optie. | + +### 3.3 Open — nog door de eigenaar te nemen + +**Beslispunt 8 — de twee MS Project-gedragingen die niet in de vier P6-types passen (§2.3).** + +| optie | wat het betekent | gevolg | +|---|---|---| +| A. Vier types, punt | Een MSP-taak "Fixed Units, niet effort-driven" wordt Fixed Units/Time en "Fixed Duration, effort-driven" wordt Fixed Duration & Units. | Eenvoudigst. Maar een geïmporteerde MSP-standaardtaak krijgt na "resource erbij" een kortere duur waar MSP de duur zou laten staan, en een effort-driven Fixed-Duration-taak houdt bij een duurwijziging het werk vast waar MSP het werk laat meegroeien. Dat is de "onverwacht verschuivende planning" die de UX-opgave juist wil vermijden. | +| B. Vier types in het menu, `effortDriven` blijft bewaarde invoer die alleen de afwijkende kolommen stuurt **(advies)** | Het bestaande veld `Task.effortDriven` blijft staan zoals het nu al round-tript. De regeltabel (§5) leest het op precies de twee cellen waar MSP afwijkt: Fixed Units/Time + `effortDriven === false` ⇒ resource erbij/eraf verandert werk in plaats van duur; Fixed Duration & Units + `effortDriven === true` ⇒ duur gewijzigd verandert werk in plaats van inzet. Taken die de gebruiker in OPS aanmaakt krijgen het veld nooit en volgen zuiver P6. | Het menu blijft vier keuzes (besluit 1 intact), MSP-bestanden gedragen zich als in MSP, en de afwijking is zichtbaar te maken als bijschrift ("uit MS Project: niet effort-driven") in plaats van als vinkje. Kost één extra rij in de regeltabel en twee testgevallen. | +| C. Zes types | Het menu toont de volledige unie (vier P6 + twee MSP). | Volledig, maar precies de "MSP-verwarring" die de eigenaar niet wil importeren; verworpen door besluit 1. | + +Zolang dit punt open staat, bouwt de etappe stap 1–5 van §10 zonder het te raken; stap 6 (resource +erbij/eraf) is de eerste plek waar het antwoord nodig is. + +**Beslispunt 9 — drie optionele werkvelden per toewijzing (§4.3).** Aanbeveling uit de corpusscan +(GEMETEN: het resttarief `remain_qty_per_hr` wijkt in vijf bestanden structureel af van het +begrote tarief, tot 100 % van de rijen), uitgelegd aan de eigenaar op 2026-09-04 met het +metselwerkvoorbeeld (160 uur begroot, 80 verricht, 120 resterend). Geen bezwaar geuit, maar ook +geen expliciet akkoord. Dit ontwerp gaat uit van de drie velden. + +**Beslispunt 10 — meerdere toewijzingen en de taakduur (§6.2).** OPS kent geen duur per toewijzing; +het ontwerp kiest de vereenvoudiging "elke toewijzing loopt over de hele restduur van de taak" en +legt de per-toewijzing-spanne in de TODO. Dat wijkt af van MSP en P6, waar toewijzingen een eigen +spanne hebben. Bevestiging gevraagd. + +## 4. Datamodel + +### 4.1 De werkregel per taak + +Een nieuw optioneel taakveld, neutraal genoemd zodat het niet met `taskType` (OPS-domeinklasse), +`mspTaskType` (MSP-import) of `durationType` (WORKTIME/ELAPSEDTIME) botst: + +```ts +/** Welke hoeken van werk = duur × inzet beschermd zijn bij een BEWERKING (taaktypes-etappe). + * Afwezig ⇒ de projectstandaard (`Project.defaultWorkRule`), en als die ook ontbreekt + * FIXED_DURATION_RATE — het gedrag van vandaag, byte-identiek. Geen enkele solverstap leest dit + * veld; het werkt uitsluitend in de bewerkingslaag (§5). */ +export type WorkRule = + | 'FIXED_DURATION_RATE' // P6 Fixed Duration & Units/Time · MSP Fixed Duration, niet effort-driven · vandaag + | 'FIXED_DURATION_WORK' // P6 Fixed Duration & Units · MSP Fixed Duration, effort-driven (zie beslispunt 8) + | 'FIXED_WORK' // P6 Fixed Units · MSP Fixed Work + | 'FIXED_RATE'; // P6 Fixed Units/Time · MSP Fixed Units, effort-driven (zie beslispunt 8) +``` + +Gebruikersnamen (nl): *Vaste duur en inzet*, *Vaste duur en werk*, *Vast werk*, *Vaste inzet*. Elk +met een bijschrift van één zin in het paneel ("Bij een duurwijziging beweegt het werk mee" enz.). + +"Rate" staat voor `unitsPerDay` (inzet per werkdag), P6's units/time. Ik vermijd "units" omdat P6 +er totaal werk mee bedoelt en MSP inzet — precies de verwarring die het neutrale model moet +wegnemen. + +`Project.defaultWorkRule?: WorkRule` is de projectstandaard (het "taaktype als projecteigenschap" +uit het eigenaarsbesluit van 2026-08-18). Nieuwe taken krijgen **geen** eigen veld; ze volgen de +projectstandaard totdat de gebruiker per taak kiest. Afwezig ⇒ FIXED_DURATION_RATE. + +Beide velden horen in `DOCUMENT_FIELDS` (documentcontract) — anders overleven ze geen documentwissel, +undo of crashherstel (ZEKER, `documentContract.ts`). + +### 4.2 Vertaling van de importvelden + +De bestaande importvelden blijven **onaangeraakt** staan (het `manuallyScheduled`-precedent O3: één +klasse taakvelden, altijd round-trip). De werkregel wordt er bij import uit **afgeleid en apart +opgeslagen**, zodat een latere typewissel de herkomst niet vernietigt: + +| bron | invoer | `workRule` | +|---|---|---| +| MSP (.mpp, MSPDI) | Fixed Units + effort-driven | FIXED_RATE | +| | Fixed Units, niet effort-driven | FIXED_RATE **+ `effortDriven: false` blijft staan** (beslispunt 8) | +| | Fixed Duration, niet effort-driven | FIXED_DURATION_RATE | +| | Fixed Duration + effort-driven | FIXED_DURATION_WORK **+ `effortDriven: true` blijft staan** (beslispunt 8) | +| | Fixed Work | FIXED_WORK | +| P6 XML | `DurationType` = Fixed Duration and Units/Time | FIXED_DURATION_RATE | +| | Fixed Duration and Units | FIXED_DURATION_WORK | +| | Fixed Units | FIXED_WORK | +| | Fixed Units/Time | FIXED_RATE | +| XER (XER-branch) | `DT_FixedDrtn` | FIXED_DURATION_RATE | +| | `DT_FixedDUR2` | FIXED_DURATION_WORK | +| | `DT_FixedQty` | FIXED_WORK | +| | `DT_FixedRate` | FIXED_RATE | +| | `DT_FixedDUR` (niet-standaard, 24 activiteiten in p6difftool-fixtures, GEMETEN) | **geen** werkregel; token blijft rauw bewaard in `p6DurationType`; één waarschuwing. Niet raden. | + +De exacte tekenreeksen van P6 XML's `` zijn hier niet tegen het PMXML-schema +gecontroleerd — te verifiëren tegen MPXJ's `DurationTypeHelper` bij de bouw (ONBEKEND tot dan). +De XER-tokens komen van de XER-sessie (ZEKER voor hun branch). De MSPDI-codes `` 0/1/2 = +Fixed Units/Fixed Duration/Fixed Work volgen dezelfde volgorde als `mppReader.ts`'s +`MSP_TASK_TYPE_VALUES` (ZEKER). + +Omgekeerd bij export: `workRule` → MSPDI `` + `` volgens dezelfde tabel, waarbij +een bewaard `effortDriven` wint; P6 XML `` 1-op-1 (een MSP-afwijking uit beslispunt 8 +gaat daar verloren — één waarschuwing, zoals de ELAPSEDTIME-waarschuwing in de CSV-export); XER +schrijft de app niet. + +### 4.3 Werk per toewijzing: drie optionele velden + +```ts +export interface ResourceAssignment { + // … bestaande velden … + /** OPTIONEEL — begroot werk in minuten (MSP Work / P6 Planned Units / XER target_qty). */ + plannedWorkMinutes?: number; + /** OPTIONEEL — verricht werk in minuten (MSP Actual Work / P6 Actual Units / XER act_reg_qty + act_ot_qty). */ + actualWorkMinutes?: number; + /** OPTIONEEL — resterend werk in minuten (MSP Remaining Work / P6 Remaining Units / XER remain_qty). */ + remainingWorkMinutes?: number; +} +``` + +**De regel "afwezig ⇒ afgeleid zoals nu":** + +| veld | afwezig ⇒ | wanneer geschreven | +|---|---|---| +| `remainingWorkMinutes` | restduur van de taak (werkdagen × `hoursPerDay × 60`, of `remainingMinutes` in uurmodus) × `unitsPerDay`; met een contour: de som van de `remaining`-periodes | (a) door een bewerking onder een werkbeschermende regel (§5), (b) bij een expliciete werkinvoer van de gebruiker, (c) bij import wanneer de bron een waarde levert die van de afleiding afwijkt | +| `actualWorkMinutes` | som van de `actual`-periodes van de contour; zonder contour 0 | (b), (c); nooit door een planningsbewerking (verricht werk is een feit) | +| `plannedWorkMinutes` | `actual + remaining` | (b), (c), en bij een typewissel naar een werkbeschermende regel (besluit 2: "vastleggen zoals het is") | + +Waarom drie en niet één (AFGELEID uit §11): in het XER-corpus wijkt het resttarief structureel af +van het begrote tarief (rehab-2: 14.459 van 52.640 toewijzingen; DCP-03 As-Built 47/47; Baseline +45/45). Met één werkveld zou "wat was begroot" verloren gaan zodra het restant wordt herschat, en +dat is precies het getal waar een planner later op wordt afgerekend. De driehoek werkt op het +**resterende** deel (P6: ZEKER, de formules in §2.2 gebruiken Remaining; MSP: AFGELEID, §2.1); +`plannedWorkMinutes` is referentie en wordt door de driehoek nooit herschreven. + +**Waar de tijdgefaseerde contour zich toe verhoudt.** De contour (`Task.timephasedContours`) is de +*vorm*, de werkvelden zijn de *totalen*. Regel: staan ze allebei, dan moet de som van de +`remaining`-periodes gelijk zijn aan `remainingWorkMinutes` (idem actual). Een bewerking die het +restwerk wijzigt herschaalt de contour proportioneel (de bestaande `rescaleContourForDuration`, met +`keepWork` voor de werkbeschermende regels — ZEKER dat die parameter al bestaat, nu gestuurd door +`mspTaskType === 'FIXED_WORK'`; hij gaat de werkregel lezen). Wijkt de som bij import af van het veld +(bron inconsistent), dan winnen de velden voor de totalen en wordt de contour bij de eerste +bewerking op de velden herschaald — nooit bij het openen (3.1-5). BEREDENEERD. + +**Materiaalresources** vallen buiten de driehoek: hun "werk" is een hoeveelheid (P6 Unit of +Measure, MSP Material Label — ZEKER `Resource.unitOfMeasure`), geen tijd. Alleen LABOR, EQUIPMENT, +CREW en SUBCONTRACTOR sturen de duur. BEREDENEERD; MSP's tabel spreekt alleen over work resources. + +### 4.4 Round-trips + +| kanaal | taak | toewijzing | opmerking | +|---|---|---|---| +| **IFC** (native) | nieuwe pset `OPS_WorkRule` (property `WorkRule`, IFCLABEL) naast het bestaande `OPS_MspTaskType`; `Project.defaultWorkRule` in `OPS_SchedulingOptions` | JSON-pset `OPS_AssignmentWork` per taak (zelfde `writeTimephasedMeta`-vorm als `OPS_Timephased`, per toewijzing via `resourceId`, door `remapContourResourceIds`-achtige guid-remap) | route: `docs/ifc-round-trip.md` — writer, reader, fixture én canon-tabel in `check-ifc-roundtrip`; de laatste twee zijn compile-afgedwongen | +| **MSPDI** | ``, `` (nu niet gelezen — ZEKER) | ``, ``, `` op `` én de sommen op `` | de writer schrijft `` vandaag al afgeleid (ZEKER, regel 652); wordt: veld als aanwezig, anders afgeleid | +| **P6 XML** | `` | ``, ``, `` (uren) en `` = actual + remaining | lezer/writer lezen/schrijven nu alleen `PlannedUnitsPerTime` (ZEKER) | +| **XER** (alleen lezen, XER-branch) | `TASK.duration_type` → `p6DurationType` (bestaat) + `workRule` (nieuw) | `TASKRSRC.target_qty`, `act_reg_qty + act_ot_qty`, `remain_qty` → de drie velden — **alleen wanneer `target_qty` afwijkt van duur × `target_qty_per_hr`** (afspraak met de XER-sessie); tot dan blijft alles in het bronarchief `OPS_XerSourceArchive` | GEMETEN: 176 van 61.618 rijen wijken >1 % af; 263 rijen hebben werk zonder tarief | +| **CSV** | geen kolom (CSV kent geen toewijzingen — ZEKER, `csvWriter.ts` negeert `_assignments`) | — | bewust buiten scope | +| **MCP** | leestools tonen `workRule`/velden; `planner_update_task` krijgt `workRule`; werkinvoer per toewijzing via `planner_update_assignment` (bestaand contract uitbreiden) | | route: `docs/recepten/mcp-tool.md`; `cases-toolregistry.ts` vangt een vergeten registratie | +| **Extensies** | `ExtTask.workRule` alleen-lezen erbij (zoals `mspTaskType` er nu al staat — ZEKER `extTypes.ts`) | | | + +## 5. De regeltabel + +Symbolen: **R** = resterende duur van de taak (werkdagen, of minuten in uurmodus), **I** = inzet +per werkdag van een toewijzing (`unitsPerDay`), **W** = resterend werk van die toewijzing. +Verricht werk en verrichte duur staan in geen enkele cel: ze zijn feiten (§2.1, §2.2 — P6 ZEKER, +MSP AFGELEID). Alles hieronder werkt op het restant. + +| bewerking | FIXED_DURATION_RATE (vandaag) | FIXED_DURATION_WORK | FIXED_WORK | FIXED_RATE | +|---|---|---|---|---| +| **R gewijzigd** | W = R × I | I = W / R | I = W / R | W = R × I | +| **I gewijzigd** | W = R × I | W = R × I | R = W / I | R = W / I | +| **W gewijzigd** | I = W / R | I = W / R | R = W / I | R = W / I | +| **resource erbij** (met inzet I_n) | nieuwe W_n = R × I_n; rest ongemoeid | totaal W blijft; verdeeld naar rato van inzet; elke I_i = W_i / R | totaal W blijft; verdeeld naar rato van inzet; R = W / ΣI | R = W / ΣI (werk blijft); **bij bewaard `effortDriven: false`: als FIXED_DURATION_RATE** (beslispunt 8-B) | +| **resource eraf** | W_n vervalt; rest ongemoeid | totaal W blijft; opnieuw verdeeld; I_i = W_i / R | totaal W blijft; R = W / ΣI | R = W / ΣI; **bij `effortDriven: false`: W_n vervalt, R blijft** | +| **typewissel** | geen getal verandert (besluit 2) | idem + W vastleggen | idem + W vastleggen | geen getal verandert | +| **R gewijzigd, bewaard `effortDriven: true`** | — | **W = R × I** (MSP Fixed Duration + effort-driven, beslispunt 8-B) | — | — | + +Bronnen per kolom: FIXED_DURATION_RATE en FIXED_RATE en FIXED_WORK: rijen 1–3 ZEKER ([M1] en +[P3] stemmen overeen), rij 4–5 ZEKER uit [P3]'s laatste kolom en [M2]. FIXED_DURATION_WORK: rij 1 +ZEKER uit [P3] (P6-lezing; de MSP-lezing staat in de laatste rij), rij 2–3 ZEKER, rij 4–5 ZEKER +([P3]: "Units/Time of each resource"; [M2]: "decreases individual resource unit values"). +**De verdeelsleutel** bij "totaal W blijft; verdeeld" is in geen van beide documentaties benoemd +(ONBEKEND); het ontwerp kiest *naar rato van inzet* (BEREDENEERD: bij twee gelijke resources levert +het de helft-helft-uitkomst die beide handleidingen als voorbeeld geven; §9 meting 10 en 15 toetsen +het). + +"Resource erbij" op een taak **zonder** toewijzingen: de nieuwe toewijzing krijgt W = R × I; geen +enkele regel wordt toegepast (P6 ZEKER: het type doet pas iets vanaf één resource; MSP ZEKER: de +eerste toewijzing zet het werk). + +## 6. Detailregels + +### 6.1 Afronding + +- Werk wordt in **minuten** opgeslagen, als getal; geen afronding op hele dagen of hele uren + (besluit 4 en het contour-precedent: een fractie is data). Weergave rondt op twee decimalen uur. +- **Dagtaken** hebben een hele-dagen-invariant (`scheduleDuration` is een geheel aantal werkdagen — + ZEKER, `TaskTime`-docblok). Levert een regel R = W / I een fractie op, dan wordt R **naar boven op + hele dagen** afgerond en blijft W exact staan; de laatste dag is dan niet vol. Dat vraagt één + aanpassing in `assignmentDayUnits` (ResourceLoad.ts): een vierde bron vóór de formule — is + `remainingWorkMinutes` aanwezig, dan is het te verdelen totaal dát getal en niet + `unitsPerDay × durationDays`. Zonder die aanpassing toont het histogram op zo'n taak te veel werk. + BEREDENEERD; MSP staat fractionele dagen toe (0,5 d) en heeft dit probleem niet. +- **Uurtaken**: R in hele minuten, naar boven; W exact. +- I wordt op twee decimalen opgeslagen zoals nu in de UI (ZEKER `isValidUnits`-guard: > 0). + +### 6.2 Meerdere toewijzingen + +OPS kent geen duur per toewijzing: elke toewijzing loopt over de hele taakduur (ZEKER, het +datamodel; `workWindowStart/Finish` bestaat maar is ongebruikt). Regel in deze etappe (beslispunt +10): **R van de taak = max over de werkresources van W_i / I_i**, en elke toewijzing loopt over die +R. Gevolg: wijzigt de gebruiker I van één toewijzing op een FIXED_WORK-taak, dan verandert R via die +toewijzing en krijgen de andere toewijzingen I_j = W_j / R (hun werk blijft, dunner gespreid). MSP +en P6 laten de andere toewijzingen hun eigen kortere spanne houden. Dit is een bewuste, zichtbare +vereenvoudiging; de per-toewijzing-spanne staat in de TODO (§12). + +Een taakniveau-invoer van werk (kolom "Werk" op de taak) wordt **naar rato van bestaand restwerk** +over de toewijzingen verdeeld (BEREDENEERD; MSP zegt alleen "verdeeld onder de resources", [M5]). + +### 6.3 Contouren + +- **R gewijzigd**: de bestaande `rescaleContourForDuration` — as proportioneel, gaten mee, actuals + blijven; `keepWork` = de werkregel is FIXED_DURATION_WORK of FIXED_WORK (nu: `mspTaskType === + 'FIXED_WORK'`). ZEKER dat het mechanisme bestaat; de aansturing wisselt van veld. +- **I gewijzigd** op een gecontoureerde toewijzing onder een werkbeschermende regel: de contour is + data en de inzet is er een afgeleide van (de dialoog toont I per fase). De regel wordt: de hele + contour schaalt in hoogte met I_nieuw / I_oud; onder FIXED_WORK verandert daardoor R (§5) en volgt + daarna de as-herschaling. BEREDENEERD. +- **Resource erbij** op een gecontoureerde taak met vast werk: besluit 3 — bestaande contouren + houden hun vorm, hoogte × (W_i,nieuw / W_i,oud); de nieuwe toewijzing is vlak (geen contour) over de + nieuwe R; R = W / ΣI. Voor FIXED_DURATION_WORK hetzelfde zonder duurwijziging. +- **Resource eraf**: contour van de verwijderde toewijzing vervalt met de toewijzing (nu al zo). + +### 6.4 Kalenders en tijdmodus + +Ongewijzigd: de taakkalender bepaalt R in werkdagen/-minuten; de resourcekalender bepaalt op welke +dagen het histogram het werk legt (`taskWorkDayIsos`, ZEKER). Een werkregel verandert niets aan +welke dagen werkdagen zijn. ELAPSEDTIME-taken zijn buiten scope (besluit 6): hun "duur" is kloktijd +en werk = duur × inzet heeft er geen betekenis. + +### 6.5 Actuals + +- `completion`, `actualStart/Finish`, `remainingTime/Minutes` blijven zoals ze zijn: OPS' + voortgang is duurgebaseerd (P6 Duration % Complete-achtig). Een werkpercentage (MSP % Work + Complete) komt er in deze etappe niet (TODO). +- Bij een bewerking op een gestarte taak is R de **restduur** (`remainingTime`/`remainingMinutes`, + anders duur × (1 − completion) — de bestaande afleiding in de solver, ZEKER `CPMSolver.ts`) en W + het restwerk. Verrichte duur en verricht werk bewegen niet. +- Het `actual`-deel van een contour telt als `actualWorkMinutes` wanneer dat veld afwezig is. +- Afsluiten op 100 %: restwerk 0, begroot blijft; heropenen laat de velden staan. + +### 6.6 Nivelleerder, histogram, bezetting + +- Nivelleerder: verschuiven blijft de enige ingreep (besluit 7); hij leest via `assignmentDayUnits` + automatisch het opgeslagen restwerk (§6.1) en verandert nooit I of W. "Inzet verlagen om binnen + capaciteit te blijven" → TODO, geavanceerde optie. +- Histogram/overallocatie/bezettingsoverzicht: geen eigen code; ze delen `assignmentDayUnits` + (ZEKER) en krijgen de vierde bron uit §6.1 gratis mee. + +### 6.7 Wat niet mag + +- Een regel mag nooit een negatief of nul-restant opleveren: I ≤ 0 of R ≤ 0 als uitkomst ⇒ de + bewerking wordt geweigerd met behoud van de oude waarden (dezelfde "weigeren-met-behoud"-conventie + als `updateAssignment`'s `isValidUnits`, ZEKER). +- Een bewerking onder een werkregel is **één undo-stap**, ook als ze drie velden en een contour + raakt (`runtime.beginUndoable` één keer; §9 meting 28). +- Nooit een getal wijzigen bij openen, herberekenen (F5), documentwissel of typewissel. + +## 7. UI + +**Instelling "Toon taaktypes"** (`ops-showTaskTypes`, boolean, standaard uit) via +`settingsRegistry.ts` + `saveShowTaskTypes` + `SettingsPanelContent` (de drie vaste plekken — +ZEKER, `docs/recepten/instelling.md`). De instelling stuurt alleen zichtbaarheid. + +**Automatische ontsluiting per document** (3.1-3): bij het laden leidt de app een niet-gepersisteerd +documentveld `taskTypesVisible` af (rol `none` in de snapshot; in `DOCUMENT_FIELDS`, `fresh: false`) +uit "minstens één taak heeft `workRule`, `mspTaskType` of `p6DurationType`, of minstens één +toewijzing heeft een werkveld". Is dat zo, dan zijn de bedieningselementen voor dát document zichtbaar +ongeacht de app-instelling, met één informatieve melding via het bestaande meldingenkanaal en een +`helpArticleId` naar de gids (patroon `notifyTimephasedLoss`, ZEKER). + +**Eigenschappenpaneel** (`TaskPropertiesPanel`, sectie Planning): een keuzelijst *Werkregel* met de +vier keuzes en het bijschrift; daaronder een regel "Beschermd: duur en inzet" in gewone woorden. In +de toewijzingstabel (`TaskAssignmentsSection`) een kolom **Werk (rest)** in uren, bewerkbaar, en een +slotje in de kop van de kolommen die de huidige regel beschermt — dat is de "bescherming zichtbaar +maken"-opgave uit het voorstel. Een taak met een bewaard `effortDriven` dat afwijkt (beslispunt 8-B) +toont een bijschrift "uit MS Project: (niet) effort-driven", geen vinkje. + +**Taakraster**: `task.workRule` als bewerkbare enum-kolom (naast het bestaande alleen-lezen +`task.mspTaskType`, ZEKER `taskColumnRegistry.ts` regel 603) en `assignment.work` als tokens-kolom +in de categorie *resources*, met dezelfde `assignment-set`-transactie als `assignment.unitsPerDay`. +Kolommen verschijnen alleen in de kolomkiezer wanneer de weergave ontsloten is. + +**Taakdialoog**: dezelfde secties als het paneel (ze delen `task-sections/`, ZEKER). + +## 8. Reikwijdte + +In scope: gewone bladtaken op werktijd, in dag- en uurmodus (besluit 6). Buiten scope, gedrag +ongewijzigd en geïmporteerd type bewaard: ELAPSEDTIME-taken, hangmatten, mijlpalen (P6 schakelt het +veld daar zelf uit — ZEKER), samenvattingstaken (MSP: nooit effort-driven — ZEKER), taken zonder +toewijzingen (regel doet niets — ZEKER), materiaalresources (§4.3). + +## 9. De meetlat: bewerkingen om later tegen MS Project te toetsen + +Vorm: een data-gedreven case-bestand `tests/planning/cases-taaktypes.json` in het stramien van +`cases-progress.json` (ZEKER, dat stramien bestaat), met per case een uitgangssituatie, één +bewerking, de verwachte uitkomst en een veld `evidence: 'reasoned' | 'documented' | 'measured'`. +De suite draait de cases tegen de pure bewerkmodule (§10 stap 3) — géén UI. Zodra iemand een +meting in MS Project (of P6) heeft gedaan, wordt `evidence` `measured` en de verwachting eventueel +gecorrigeerd. Uitgangssituatie tenzij anders vermeld: dagtaak, 8 uur/dag, 5 werkdagen, één +werkresource op inzet 1,0 ⇒ werk 40 uur, niets verricht. + +| nr | regel | bewerking | verwacht | bewijs | +|---|---|---|---|---| +| 1 | FIXED_RATE | duur → 10 d | werk 80 u, inzet 1,0 | documented [M1] | +| 2 | FIXED_RATE | inzet → 0,5 | duur 10 d, werk 40 u | documented | +| 3 | FIXED_RATE | werk → 80 u | duur 10 d | documented | +| 4 | FIXED_RATE | tweede resource 1,0 erbij | duur 2,5 d in MSP; OPS: 3 d met halve laatste dag, werk 20 + 20 u | documented [M2] + reasoned (§6.1) | +| 5 | FIXED_RATE + `effortDriven:false` | tweede resource erbij | duur 5 d, werk 40 + 40 u | documented [M2] (beslispunt 8) | +| 6 | FIXED_DURATION_RATE | duur → 10 d | werk 80 u | documented | +| 7 | FIXED_DURATION_RATE | inzet → 0,5 | werk 20 u | documented | +| 8 | FIXED_DURATION_RATE | werk → 80 u | inzet 2,0 | documented | +| 9 | FIXED_DURATION_RATE | tweede resource erbij | duur 5 d, werk 40 + 40 u | documented [P3] | +| 10 | FIXED_DURATION_WORK | tweede resource 1,0 erbij | duur 5 d, inzet 0,5 + 0,5, werk 20 + 20 u | documented [M2]/[P3]; verdeelsleutel reasoned | +| 11 | FIXED_DURATION_WORK | duur → 10 d | inzet 0,5, werk 40 u (P6) — **MSP effort-driven Fixed Duration: werk 80 u** | documented, beide lezingen (beslispunt 8) | +| 12 | FIXED_WORK | duur → 10 d | inzet 0,5, werk 40 u | documented [M1] | +| 13 | FIXED_WORK | inzet → 0,5 | duur 10 d | documented | +| 14 | FIXED_WORK | werk → 80 u | duur 10 d | documented | +| 15 | FIXED_WORK | tweede resource 1,0 erbij | duur 2,5 d (OPS 3 d), werk 20 + 20 u | documented; verdeelsleutel reasoned | +| 16 | FIXED_RATE, 10 d, 40 % gereed (32 u verricht) | duur → 15 d | verricht 4 d/32 u ongewijzigd; rest 11 d, restwerk 88 u; totaal 120 u | reasoned (§2.1 laatste punt) | +| 17 | FIXED_WORK, 10 d, 40 % gereed | duur → 12 d | rest 8 d, restwerk 48 u ⇒ inzet 0,75; verricht ongewijzigd | reasoned | +| 18 | FIXED_DURATION_RATE, 10 d, 40 % | duur → 12 d | rest 8 d, restwerk 64 u, totaal 96 u | reasoned | +| 19 | FIXED_WORK, twee resources 1,0 (werk 20 + 20 u, 2,5 d) | één resource eraf | duur 5 d, werk 40 u op de blijver | documented [M2] | +| 20 | FIXED_DURATION_WORK, twee resources 0,5 | één eraf | blijver inzet 1,0, duur 5 d | documented | +| 21 | FIXED_RATE | wissel → FIXED_WORK, daarna duur → 10 d | na wissel niets gewijzigd; na duur: inzet 0,5 | besloten (2) + documented | +| 22 | FIXED_WORK, contour vooraan belast | duur → 10 d | as ×2, hoogte ×0,5, werk 40 u; actual-periodes onaangeraakt | reasoned (bestaande `rescaleContourForDuration`) | +| 23 | FIXED_WORK, contour vooraan belast | tweede resource erbij | vorm blijft, hoogte ×0,5, nieuwe resource vlak, duur korter | besloten (3) | +| 24 | FIXED_WORK, uurtaak 16 u, inzet 1,0 | inzet → 2,0 | duur 8 u (480 min) | documented | +| 25 | FIXED_WORK + materiaalresource 100 stuks | duur → 10 d | materiaal ongemoeid; werkresource inzet 0,5 | reasoned (§4.3) | +| 26 | elk type, geen toewijzingen | duur → 10 d | alleen de duur; geen veld geschreven | documented [P2] | +| 27 | FIXED_WORK | inzet → 0 | geweigerd, niets gewijzigd | bestaande guard | +| 28 | FIXED_WORK | inzet → 0,5, dan undo | duur, inzet, werk en contour in één stap terug | ontwerpregel §6.7 | + +Deze lijst gaat ook naar `docs/TODO.md` als "MSP-meetlat", zodat de eerstvolgende persoon met +MS Project weet wat er te meten valt. + +## 10. Bouwvolgorde — apart verifieerbare stappen + +**Stap 0 — XER merget eerst.** De XER-branch heeft een tweede merge nodig (46 overlappende +bestanden met main sinds PR #95 en drie andere PR's). Deze etappe start pas op een main **mét** XER +erin, zodat `Task.p6DurationType`, de pset `OPS_P6Progress` en het bronarchief er zijn en er niet +twee keer aan `formatRegistry.ts`, `ifcPsets.ts` en de contour-adapters wordt getrokken. + +| stap | levert | poort | +|---|---|---| +| 1 | `WorkRule`, `Task.workRule`, `Project.defaultWorkRule`, de drie werkvelden; `DOCUMENT_FIELDS`; IFC-psets `OPS_WorkRule` + `OPS_AssignmentWork`; ext-contract alleen-lezen. **Geen gedrag.** | `check-ifc-roundtrip` (fixture + canon), `check-document-contract`, `verify:cycles`; elk bestaand bestand byte-identiek | +| 2 | Importvertaling (§4.2) voor .mpp, MSPDI (incl. ``/`` lezen), P6 XML, XER; export MSPDI/P6 XML met de werkvelden; waarschuwingen | fidelity-poort blijft 0 (`GOAL_ZERO_DEVIATIONS`); `check-mpp-*`, `check-adapters-*`; nieuwe fixtures per bron | +| 3 | Pure module `src/engine/work/workTriangle.ts`: `applyDurationEdit`, `applyUnitsEdit`, `applyWorkEdit`, `applyAssignmentAdded`, `applyAssignmentRemoved`, `applyRuleChange` — invoer: taak, toewijzingen, regel, `hoursPerDay`; uitvoer: patches, nooit een store. Plus `cases-taaktypes.json` (§9) | `tests/planning/check-work-triangle.ts` draait alle cases; `evidence` per case | +| 4 | Bedrading: `taskSlice.updateTask` (R), `resourceSlice.updateAssignment` (I, W), `taskEditPlan`/`assignmentPlan` (raster), `createMcpTransactions` (MCP-tweeling — ZEKER dat die spiegel bestaat), `assignmentDayUnits` vierde bron (§6.1) | bestaande suites + `check-work-triangle` via de store; `cases-resource-load.json` uitgebreid | +| 5 | UI: instelling, auto-ontsluiting + melding, paneel/dialoog, rasterkolommen, i18n 14 locales, gidsen nl+en (+12 vertalingen, anders faalt `verify:docs` niet maar is de functie onvindbaar) | `verify:i18n`, `verify:docs`, browserspec `tests/browser/work-rule.spec.ts` (echte toetsen/klikken, store-asserties) | +| 6 | Resource erbij/eraf (§5 rij 4–5) inclusief contourregels (§6.3) — **vereist beslispunt 8** | cases 4, 5, 9, 10, 15, 19, 20, 23 | +| 7 | MCP: contract + schema + registratie; `docs/recepten/mcp-tool.md` | `tests/mcp/`, `cases-toolregistry` | +| 8 | CLAUDE.md-sectie, TODO-afvinking, `docs/superpowers/README.md` | `verify:docs` | + +Stap 1–3 zijn onafhankelijk van elkaar te reviewen en veranderen niets aan wat de gebruiker ziet. + +## 11. Corpusbewijs (GEMETEN — XER-sessie, 84 unieke bestanden, aangeleverd 2026-09-04) + +| meting | uitkomst | wat het voor dit ontwerp betekent | +|---|---|---| +| activiteiten per duration type | DT_FixedDUR2 15.978 · DT_FixedDrtn 1.773 · DT_FixedQty 153 (2 bestanden) · DT_FixedRate 0 · niet-standaard DT_FixedDUR 24 | bouwvolgorde binnen stap 3: FIXED_DURATION_WORK → FIXED_DURATION_RATE → FIXED_WORK → FIXED_RATE (minst getest in de praktijk); de vandaag hardgecodeerde regel is de op één na meest voorkomende in P6-bestanden | +| `target_qty` wijkt >1 % af van duur × tarief | 176 van 61.618 rijen (4 met duur 0); 263 rijen met werk zonder tarief; in 5 bestanden (HarbourPointe 98/417, DCP-03 As-Built 37/47, DCP-03 Baseline 35/45, p6_torture_test 5/45, testXer 1/90) | "afwezig ⇒ afgeleid" is voor 99,7 % van de rijen verliesvrij; de XER-afspraak "alleen overzetten bij afwijking" is dus goedkoop | +| toewijzingen met een curve | 2 rijen, 1 bestand | curves + werkregels: lage prioriteit, wel correct (§6.3) | +| verricht werk op DT_FixedQty | 1 rij | cases 17 en 19 hebben géén corpusdekking — puur beredeneerd | +| `remain_qty_per_hr` ≠ `target_qty_per_hr` | rehab-2 14.459/52.640 · DCP-03 As-Built 47/47 · Baseline 45/45 · Roads 71/3.575 · HarbourPointe 14/417 | het resttarief is een eigen grootheid ⇒ drie werkvelden (beslispunt 9) | + +Het corpus staat bij de eigenaar (`~/open-aec/voor claude/testdata-crawl`, env `OPS_XER_CORPUS`), +niet in de repo; de tabellen staan daar in `/tmp/xer-overname/corpusscan-werk-2.md`. Een telling +van `mspTaskType × effortDriven` over de .mpp-crawl (216 bestanden) ontbreekt nog (§12). + +## 12. Wat dit ontwerp bewust laat liggen (→ `docs/TODO.md`) + +- **MSP-meetlat**: de 28 bewerkingen uit §9 meten in MS Project (en P6) zodra iemand het heeft. +- **Telling `mspTaskType × effortDriven`** over de `OPS_MPP_CRAWL`-set: bepaalt hoe vaak beslispunt + 8 in de praktijk speelt. +- **Nivelleerder-optie "inzet verlagen"** (besluit 7-B) onder een geavanceerde optie. +- **Per-toewijzing-spanne** (`workWindowStart/Finish` activeren) — heft de vereenvoudiging van + §6.2 op. +- **% werk gereed** (MSP % Work Complete) naast de duurgebaseerde `completion`. +- **Projectstandaard-werkregel in de UI** (projectwizard/projectinfo): stap 1 slaat het veld op, + de UI ervoor is klein maar niet in deze etappe. +- **CSV**: toewijzingen en werk in CSV — pas wanneer CSV toewijzingen kent. +- **P6-optie "preserve existing assignments"** ([P4]): OPS kiest altijd "recalculate" (de + synchronisatietabel); de "preserve"-variant is een instelling voor later. + +## 13. Bronnen + +MS Project (Microsoft Support, geraadpleegd 2026-09-04): + +- [M1] *Change the task type for more accurate scheduling* — + https://support.microsoft.com/en-us/office/change-the-task-type-for-more-accurate-scheduling-b0b969ad-45bc-4e9e-8967-435587548a72 +- [M2] *Change the effort driven setting for task types* — + https://support.microsoft.com/en-us/office/change-the-effort-driven-setting-for-task-types-18efd7c7-d146-4b06-bdbe-b6a11564bdf3 +- [M3] *The duration or work value changed when I assigned a resource* — + https://support.microsoft.com/en-au/office/the-duration-or-work-value-changed-when-i-assigned-a-resource-a3573268-f613-419d-b78f-2516255c7432 +- [M4] *Type fields* — https://support.microsoft.com/en-us/project/type-fields +- [M5] *Work fields* — https://support.microsoft.com/en-us/project/work-fields +- [M6] *Remaining Work fields* — https://support.microsoft.com/en-us/project/remaining-work-fields +- [M7] *Actual Work fields* — https://support.microsoft.com/en-US/project/actual-work-fields +- [M8] *Remaining Duration (task field)* — https://support.microsoft.com/en-us/project/remaining-duration-task-field +- [M9] secundair: NCDOT-trainingsdocument *Microsoft Project 2016 – Before You Plan Your First + Project* (stelt "New tasks are effort driven" standaard uit) — + https://connect.ncdot.gov/projects/Project-Management/TrainingDocs/MicrosoftProject2016-BeforeYouPlanYourFirstProject.pdf +- Ook geraadpleegd zonder aanvullende regel: *Effort Driven (task field)*, *Duration (task field)*, + *Work Contour field* (eerder, contour-etappe). + +Primavera P6 (Oracle Help Center, geraadpleegd 2026-09-04): + +- [P1] *Define activity duration types* (P6 Professional 24) — + https://docs.oracle.com/cd/F88968_01/English/User_Guides/p6_pro_user/define_activity_duration_types.htm +- [P2] *About Duration Types* (P6 EPPM Help 24, met de Remaining-formules) — + https://docs.oracle.com/cd/F88966_01/p6help/en/6631.htm +- [P3] *Synchronizing activity duration, units, and resource units/time* (de synchronisatietabel) — + https://docs.oracle.com/cd/F88968_01/English/User_Guides/p6_pro_user/synchronizing_activity_duration_units_and_resource_units_time.htm +- [P4] *Select calculation options for resource and role assignments* — + https://docs.oracle.com/cd/G48902_01/English/User_Guides/p6_pro_user/select_calculation_options_for_resource_and_role_assignments.htm +- [P5] *Calculations Tab of the Project Preferences Dialog Box* (P6 EPPM Help 23) — + https://docs.oracle.com/cd/F74773_01/p6help/en/91690.htm +- Eerder (contour-etappe): *Future period bucket planning*, *The Resource Usage Spreadsheet*, + *Editing Period Values for Assignments*. + +Eigen code (ZEKER-verwijzingen): `src/types/task.ts`, `src/types/resource.ts`, +`src/engine/contour/contourEngine.ts` (`rescaleContourForDuration`), `src/utils/taskDefaults.ts` +(`rescaleTaskContours`), `src/engine/scheduler/ResourceLoad.ts` (`assignmentDayUnits`, +`taskWorkDayIsos`), `src/state/slices/resourceSlice.ts`, `src/state/slices/taskSlice.ts`, +`src/services/mpp/mppReader.ts`, `src/services/msproject/mspdi{Reader,Writer}.ts`, +`src/services/p6/p6xml{Reader,Writer}.ts`, `src/services/csv/csvWriter.ts`, +`src/engine/taskGrid/taskColumnRegistry.ts`, `src/state/documentContract.ts`, +`docs/ifc-round-trip.md`, `docs/recepten/instelling.md`, `tests/planning/cases-progress.json`. From a71da25d5431f3c8a3b6e7d0101b213f14f5fed1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 18:31:19 +0000 Subject: [PATCH 107/152] docs(spec): reviewcorrecties taaktypes-ontwerp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verwerkt uit de reviewronde: vijf ZEKER-labels zonder letterlijke dekking teruggebracht naar AFGELEID of het juiste citaat/bron ([M1]/[M3]/[P2]/[P4]); de motivering van "geen P6-tegenhanger" gecorrigeerd tegen [P3]; de regeltabel eenduidig gemaakt (R = max_i(W_i/I_i), verdeelsleutel op de inzet zoals ingevoerd, W en I leidend na afronding) met drie extra meetlat-cases (29–31); bronsplitsing voor "resource eraf"; kleinere correcties in de round-trip-tabel en het corpushoofdstuk. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0159unb6VzxUx7WG4w3a2ARA --- docs/TODO.md | 2 +- docs/superpowers/README.md | 2 +- ...26-09-04-spec-taaktypes-opgeslagen-werk.md | 149 +++++++++++------- 3 files changed, 96 insertions(+), 57 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 00ce2c15d..964492a47 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -366,7 +366,7 @@ deze lijst verwijderd — wat klaar is, staat in de changelog en git-historie. - [ ] **Beslispunt 10 — meerdere toewijzingen zonder eigen spanne** (spec §6.2): OPS laat elke toewijzing over de hele restduur lopen; MSP/P6 geven toewijzingen een eigen spanne. Later op te heffen door `workWindowStart/Finish` te activeren. -- [ ] **MSP-meetlat: 28 bewerkingen** (spec §9) meten in MS Project (en P6) zodra iemand het heeft; +- [ ] **MSP-meetlat: 31 bewerkingen** (spec §9) meten in MS Project (en P6) zodra iemand het heeft; tot dan draagt elke case `evidence: 'reasoned' | 'documented'` in `cases-taaktypes.json`. - [ ] **Telling `mspTaskType × effortDriven` over de `OPS_MPP_CRAWL`-set** (216 bestanden): bepaalt hoe vaak beslispunt 8 in de praktijk speelt. Het corpus is niet in de repo. diff --git a/docs/superpowers/README.md b/docs/superpowers/README.md index bc34ff2e9..f93118fb6 100644 --- a/docs/superpowers/README.md +++ b/docs/superpowers/README.md @@ -67,7 +67,7 @@ Wél verplaatst, omdat ze nergens meer bij horen: | document | status | |---|---| | `HANDOFF-2026-08-14-roadmap.md` | **actief** — wat er nog op de roadmap staat, met peildatum en afhankelijkheden | -| `specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md` | **actief ontwerp, nog niet gebouwd** — taaktypes/werkregels, opgeslagen werk per toewijzing en effort-driven bewerken; opvolger van `specs/2026-08-18-spec-taaktypes-effort-driven.md`. Bevat de MSP/P6-documentatievergelijking, de regeltabel, de meetlat (28 bewerkingen) en drie open beslispunten (8–10). Bouwt pas ná de tweede XER-merge. | +| `specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md` | **actief ontwerp, nog niet gebouwd** — taaktypes/werkregels, opgeslagen werk per toewijzing en effort-driven bewerken; opvolger van `specs/2026-08-18-spec-taaktypes-effort-driven.md`. Bevat de MSP/P6-documentatievergelijking, de regeltabel, de meetlat (31 bewerkingen) en drie open beslispunten (8–10). Bouwt pas ná de tweede XER-merge. | | `werkdagen-as-ontwerp.md` | naslag; aangehaald vanuit `timeAxis.ts`, `workdayAxis.ts` en `check-workday-axis.ts` | | `verticale-drag-ontwerp.md`, `verticale-drag-ontwerp-B.md` | naslag; twee varianten van hetzelfde ontwerp | | `modulariteit-audit.md`, `prestatie-modulariteit-audit.md` | de audits waar de P-bevindingen uit komen; aangehaald vanuit testkoppen | diff --git a/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md b/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md index 4de7b8f73..b8873166d 100644 --- a/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md +++ b/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md @@ -58,18 +58,19 @@ aan-knop stuurt alleen de weergave en nooit de berekening; de semantiek zit in h **Standaardtype** is Fixed Units (ZEKER, [M4]: "Fixed Units (default)"); de gebruiker kan dat in de projectopties wijzigen. -**Effort-driven** (ZEKER, [M2], [M3]): - -- Effort-driven gaat uitsluitend over het **toevoegen of verwijderen van resources**: "Project - lengthens or shortens the duration of the task based on the number of resources that are assigned - to it, but Project does not change the total work for the task." Bij Fixed Units verkort een - resource erbij de duur; bij Fixed Duration daalt de inzet van elke resource. -- Effort-driven werkt pas **ná de eerste toewijzing**: "When assigning multiple resources - simultaneously to a task, the duration doesn't change from your original estimate." De eerste - toewijzing(en) zetten het werk; daarna houdt effort-driven dat werk vast. -- **Fixed Work is altijd effort-driven** en dat vinkje is daar niet te wijzigen: "Project doesn't - consider fixed work tasks to have flexible work values and are therefore always effort-driven." -- Samenvattingstaken en ingevoegde projecten kunnen niet effort-driven zijn. +**Effort-driven** (ZEKER; bron per punt): + +- Effort-driven gaat uitsluitend over het **toevoegen of verwijderen van resources** ([M2]): + "Project lengthens or shortens the duration of the task based on the number of resources that are + assigned to it, but Project does not change the total work for the task." Bij Fixed Units verkort + een resource erbij de duur; bij Fixed Duration daalt de inzet van elke resource ([M2]). +- Effort-driven werkt pas **ná de eerste toewijzing** ([M3]): "If you assign multiple resources at + the same time, the duration doesn't change from your original estimate." De eerste toewijzing(en) + zetten het werk; daarna houdt effort-driven dat werk vast. +- **Fixed Work is altijd effort-driven** en dat vinkje is daar niet te wijzigen ([M1]): "Project + doesn't consider fixed work tasks to have flexible work values and are therefore always + effort-driven." +- Samenvattingstaken en ingevoegde projecten kunnen niet effort-driven zijn ([M2]). - Standaardwaarde van het vinkje voor nieuwe taken: de Microsoft-artikelen die ik vond noemen alleen dát er een optie "New tasks are effort driven" bestaat, niet wat hij standaard is ([M2], [M3]). Secundaire bronnen (trainingsmateriaal, [M9]) zeggen "standaard uit" voor 2010/2013/2016. Ik @@ -97,17 +98,25 @@ projectopties wijzigen. ### 2.2 Primavera P6 -**De vier duration types en hun formules** (ZEKER, [P1], [P2]): +**De vier duration types en hun formules** (definities ZEKER uit [P1]; de twee +Fixed-Duration-formules ZEKER uit [P2]; voor de andere twee types geeft geen van beide pagina's +een formule — de regel daar is AFGELEID uit de algemene identiteit in de inleiding van [P3], +"Duration = Units ÷ (Resource Units ÷ Time)"): -| duration type | wat vastligt | formule uit de documentatie | +| duration type | wat vastligt | formule | |---|---|---| -| Fixed Duration & Units/Time | duur en inzet per tijdseenheid | Remaining Units = Units/Time × Remaining Duration | -| Fixed Duration & Units | duur en totaal werk | Units/Time = Remaining Units / Remaining Duration | -| Fixed Units/Time | inzet per tijdseenheid | Duration = Units / (Units/Time) | -| Fixed Units | totaal werk | idem | - -Let op de woorden in de formules: **Remaining** Units en **Remaining** Duration. P6 rekent de -driehoek op het resterende deel, niet op het totaal (ZEKER, [P2]). Dat is de bron van de keuze in §5. +| Fixed Duration & Units/Time | duur en inzet per tijdseenheid | Remaining Units = Units/Time × Remaining Duration (ZEKER, [P2]) | +| Fixed Duration & Units | duur en totaal werk | Units/Time = Remaining Units / Remaining Duration (ZEKER, [P2]) | +| Fixed Units/Time | inzet per tijdseenheid | Duration = Units / (Units/Time) (AFGELEID uit [P3]) | +| Fixed Units | totaal werk | idem (AFGELEID uit [P3]) | + +Let op de woorden in de twee gedocumenteerde formules: **Remaining** Units en **Remaining** +Duration. Dat P6 de driehoek op het resterende deel rekent is daarmee ZEKER voor de twee +Fixed-Duration-types en **AFGELEID** voor Fixed Units/Time en Fixed Units: [P2] zegt daar niets +over rest versus totaal. De redenering: P6 houdt per toewijzing Actual, Remaining en At Completion +apart bij en herrekent Remaining of At Completion uit elkaar ([P5]), dus een regel die op het +totaal zou werken zou verrichte uren moeten herschrijven — wat geen van de opties in [P5] doet. +Dit is de basis van de keuze in §5 en staat daarom in §9 (cases 16–18) als te meten. **De synchronisatietabel** (ZEKER, [P3] — Oracle's eigen overzicht van wat er per type verandert): @@ -124,14 +133,16 @@ werk en inzet/tijd om verwarring met OPS' `unitsPerDay` te voorkomen.) **Overige P6-regels** (ZEKER): - Het duration type doet pas iets zodra er minstens één resource op de activiteit staat ([P2]: - "Duration type applies only when at least one resource is assigned to the activity"). + "The duration type applies only when you have at least one resource assigned to the activity"). - Bij Start- en Finish-mijlpalen is het veld uitgeschakeld ([P1]). -- Projectoptie bij het toevoegen van resources ([P4]): "Preserve the units, duration, and units/time - for existing assignments" óf "Recalculate … based on the activity's duration type". De laatste - kolom van de synchronisatietabel geldt dus alleen bij de tweede instelling. -- Projectoptie bij actuals ([P5]): "Add actual to remaining" (At Completion = Remaining + Actual) óf - "Subtract actual from at completion" (Remaining = At Completion − Actual). Verricht werk is in beide - varianten een invoer die door niets anders wordt herschreven. +- Projectoptie bij het toevoegen van resources ([P4]): "Preserve the Units, Duration, and Units/Time + for existing assignments" óf "Recalculate the Units, Duration, and Units/Time for existing + assignments based on the activity Duration Type". De laatste kolom van de synchronisatietabel + geldt dus alleen bij de tweede instelling. +- Projectoptie bij actuals ([P5]): "Add actual to remaining" (At Completion Units = Remaining Units + + Actual Units) óf "Subtract actual from at completion" (Remaining Units = At Completion Units − + Actual Units). AFGELEID uit die twee formules: verricht werk is in beide varianten invoer en wordt + door geen van beide herschreven. - Standaard duration type voor nieuwe activiteiten: niet gevonden in de geraadpleegde pagina's — ONBEKEND uit documentatie. GEMETEN (§11): 89 % van de activiteiten in het XER-corpus staat op `DT_FixedDUR2` (Fixed Duration & Units). @@ -147,7 +158,7 @@ aan een P6-type, en twee niet (AFGELEID, cel voor cel uit [M1] en [P3]): | Fixed Work (altijd effort-driven) | Fixed Units | alle vijf kolommen gelijk | | Fixed Duration, niet effort-driven | Fixed Duration & Units/Time | alle vijf kolommen gelijk | | Fixed Duration, effort-driven | Fixed Duration & Units | **verschil bij duur gewijzigd**: MSP herberekent werk ([M1], rij Fixed Duration), P6 herberekent inzet/tijd ([P3]) | -| Fixed Units, niet effort-driven | — | **geen P6-tegenhanger**: bij resource erbij groeit het werk en blijft de duur; alle vier P6-types die de inzet vastzetten verkorten de duur | +| Fixed Units, niet effort-driven | — | **geen P6-tegenhanger**: MSP combineert hier *inzet gewijzigd ⇒ duur herberekend* met *resource erbij ⇒ werk groeit, duur blijft*. In [P3] gaat de eerste eigenschap alleen samen met de twee types die bij een extra resource juist de duur verkorten (Fixed Units/Time, Fixed Units); de twee types die de duur laten staan herberekenen bij een inzetwijziging het werk | Besluit 1 (menukaart = de vier P6-types, geen los vinkje) dekt dus drie van de vijf MS Project-gedragingen exact. De twee andere zijn geen exotische gevallen: "Fixed Units, niet @@ -232,7 +243,7 @@ export type WorkRule = | 'FIXED_DURATION_RATE' // P6 Fixed Duration & Units/Time · MSP Fixed Duration, niet effort-driven · vandaag | 'FIXED_DURATION_WORK' // P6 Fixed Duration & Units · MSP Fixed Duration, effort-driven (zie beslispunt 8) | 'FIXED_WORK' // P6 Fixed Units · MSP Fixed Work - | 'FIXED_RATE'; // P6 Fixed Units/Time · MSP Fixed Units, effort-driven (zie beslispunt 8) + | 'FIXED_RATE'; // P6 Fixed Units/Time · MSP Fixed Units, effort-driven (niet effort-driven: zie beslispunt 8) ``` Gebruikersnamen (nl): *Vaste duur en inzet*, *Vaste duur en werk*, *Vast werk*, *Vaste inzet*. Elk @@ -274,9 +285,10 @@ opgeslagen**, zodat een latere typewissel de herkomst niet vernietigt: De exacte tekenreeksen van P6 XML's `` zijn hier niet tegen het PMXML-schema gecontroleerd — te verifiëren tegen MPXJ's `DurationTypeHelper` bij de bouw (ONBEKEND tot dan). -De XER-tokens komen van de XER-sessie (ZEKER voor hun branch). De MSPDI-codes `` 0/1/2 = -Fixed Units/Fixed Duration/Fixed Work volgen dezelfde volgorde als `mppReader.ts`'s -`MSP_TASK_TYPE_VALUES` (ZEKER). +De XER-tokens komen van de XER-sessie (ZEKER voor hun branch). `mppReader.ts`'s `MSP_TASK_TYPE_VALUES` gebruikt de volgorde 0/1/2 = +Fixed Units/Fixed Duration/Fixed Work (ZEKER); dat MSPDI's task-level `` dezelfde nummering +draagt is een externe bewering die de repo nergens vastlegt — te verifiëren tegen MPXJ's +`MSPDIReader` in stap 2 (ONBEKEND tot dan). Omgekeerd bij export: `workRule` → MSPDI `` + `` volgens dezelfde tabel, waarbij een bewaard `effortDriven` wint; P6 XML `` 1-op-1 (een MSP-afwijking uit beslispunt 8 @@ -315,9 +327,10 @@ dat is precies het getal waar een planner later op wordt afgerekend. De driehoek **Waar de tijdgefaseerde contour zich toe verhoudt.** De contour (`Task.timephasedContours`) is de *vorm*, de werkvelden zijn de *totalen*. Regel: staan ze allebei, dan moet de som van de `remaining`-periodes gelijk zijn aan `remainingWorkMinutes` (idem actual). Een bewerking die het -restwerk wijzigt herschaalt de contour proportioneel (de bestaande `rescaleContourForDuration`, met -`keepWork` voor de werkbeschermende regels — ZEKER dat die parameter al bestaat, nu gestuurd door -`mspTaskType === 'FIXED_WORK'`; hij gaat de werkregel lezen). Wijkt de som bij import af van het veld +restwerk wijzigt herschaalt de contour proportioneel (de bestaande `rescaleContourForDuration`: die heeft een +parameter `taskType?: MspTaskType` en leidt daar intern `keepWork` uit af voor `'FIXED_WORK'` — +ZEKER; die parameter gaat de werkregel dragen, dus verbreden naar `WorkRule` of een tweede +parameter erbij, te kiezen in stap 3). Wijkt de som bij import af van het veld (bron inconsistent), dan winnen de velden voor de totalen en wordt de contour bij de eerste bewerking op de velden herschaald — nooit bij het openen (3.1-5). BEREDENEERD. @@ -330,9 +343,9 @@ CREW en SUBCONTRACTOR sturen de duur. BEREDENEERD; MSP's tabel spreekt alleen ov | kanaal | taak | toewijzing | opmerking | |---|---|---|---| | **IFC** (native) | nieuwe pset `OPS_WorkRule` (property `WorkRule`, IFCLABEL) naast het bestaande `OPS_MspTaskType`; `Project.defaultWorkRule` in `OPS_SchedulingOptions` | JSON-pset `OPS_AssignmentWork` per taak (zelfde `writeTimephasedMeta`-vorm als `OPS_Timephased`, per toewijzing via `resourceId`, door `remapContourResourceIds`-achtige guid-remap) | route: `docs/ifc-round-trip.md` — writer, reader, fixture én canon-tabel in `check-ifc-roundtrip`; de laatste twee zijn compile-afgedwongen | -| **MSPDI** | ``, `` (nu niet gelezen — ZEKER) | ``, ``, `` op `` én de sommen op `` | de writer schrijft `` vandaag al afgeleid (ZEKER, regel 652); wordt: veld als aanwezig, anders afgeleid | +| **MSPDI** | ``, `` (nu niet gelezen én niet geschreven — ZEKER, `mspdiReader.ts`/`mspdiWriter.ts`; stap 2 bouwt beide richtingen) | ``, ``, `` op `` én de sommen op `` | de writer schrijft `` vandaag al afgeleid (ZEKER, regel 652); wordt: veld als aanwezig, anders afgeleid | | **P6 XML** | `` | ``, ``, `` (uren) en `` = actual + remaining | lezer/writer lezen/schrijven nu alleen `PlannedUnitsPerTime` (ZEKER) | -| **XER** (alleen lezen, XER-branch) | `TASK.duration_type` → `p6DurationType` (bestaat) + `workRule` (nieuw) | `TASKRSRC.target_qty`, `act_reg_qty + act_ot_qty`, `remain_qty` → de drie velden — **alleen wanneer `target_qty` afwijkt van duur × `target_qty_per_hr`** (afspraak met de XER-sessie); tot dan blijft alles in het bronarchief `OPS_XerSourceArchive` | GEMETEN: 176 van 61.618 rijen wijken >1 % af; 263 rijen hebben werk zonder tarief | +| **XER** (alleen lezen, XER-branch) | `TASK.duration_type` → `p6DurationType` (bestaat op de XER-branch, niet op main — zie stap 0) + `workRule` (nieuw) | `TASKRSRC.target_qty`, `act_reg_qty + act_ot_qty`, `remain_qty` → de drie velden — **alleen wanneer `target_qty` afwijkt van duur × `target_qty_per_hr`** (afspraak met de XER-sessie); tot dan blijft alles in het bronarchief `OPS_XerSourceArchive` | GEMETEN: 176 van 61.618 rijen wijken >1 % af; 263 rijen hebben werk zonder tarief | | **CSV** | geen kolom (CSV kent geen toewijzingen — ZEKER, `csvWriter.ts` negeert `_assignments`) | — | bewust buiten scope | | **MCP** | leestools tonen `workRule`/velden; `planner_update_task` krijgt `workRule`; werkinvoer per toewijzing via `planner_update_assignment` (bestaand contract uitbreiden) | | route: `docs/recepten/mcp-tool.md`; `cases-toolregistry.ts` vangt een vergeten registratie | | **Extensies** | `ExtTask.workRule` alleen-lezen erbij (zoals `mspTaskType` er nu al staat — ZEKER `extTypes.ts`) | | | @@ -349,19 +362,34 @@ MSP AFGELEID). Alles hieronder werkt op het restant. | **R gewijzigd** | W = R × I | I = W / R | I = W / R | W = R × I | | **I gewijzigd** | W = R × I | W = R × I | R = W / I | R = W / I | | **W gewijzigd** | I = W / R | I = W / R | R = W / I | R = W / I | -| **resource erbij** (met inzet I_n) | nieuwe W_n = R × I_n; rest ongemoeid | totaal W blijft; verdeeld naar rato van inzet; elke I_i = W_i / R | totaal W blijft; verdeeld naar rato van inzet; R = W / ΣI | R = W / ΣI (werk blijft); **bij bewaard `effortDriven: false`: als FIXED_DURATION_RATE** (beslispunt 8-B) | -| **resource eraf** | W_n vervalt; rest ongemoeid | totaal W blijft; opnieuw verdeeld; I_i = W_i / R | totaal W blijft; R = W / ΣI | R = W / ΣI; **bij `effortDriven: false`: W_n vervalt, R blijft** | +| **resource erbij** (met inzet I_n) | nieuwe W_n = R × I_n; rest ongemoeid | totaal W blijft; verdeeld naar rato van inzet; elke I_i = W_i / R | totaal W blijft; verdeeld naar rato van inzet; R = max_i(W_i / I_i) | totaal W blijft; verdeeld naar rato van inzet; R = max_i(W_i / I_i); **bij bewaard `effortDriven: false`: als FIXED_DURATION_RATE** (beslispunt 8-B) | +| **resource eraf** | W_n vervalt; rest ongemoeid | totaal W blijft; opnieuw verdeeld; I_i = W_i / R | totaal W blijft; opnieuw verdeeld; R = max_i(W_i / I_i) | totaal W blijft; opnieuw verdeeld; R = max_i(W_i / I_i); **bij `effortDriven: false`: W_n vervalt, R blijft** | | **typewissel** | geen getal verandert (besluit 2) | idem + W vastleggen | idem + W vastleggen | geen getal verandert | | **R gewijzigd, bewaard `effortDriven: true`** | — | **W = R × I** (MSP Fixed Duration + effort-driven, beslispunt 8-B) | — | — | -Bronnen per kolom: FIXED_DURATION_RATE en FIXED_RATE en FIXED_WORK: rijen 1–3 ZEKER ([M1] en -[P3] stemmen overeen), rij 4–5 ZEKER uit [P3]'s laatste kolom en [M2]. FIXED_DURATION_WORK: rij 1 -ZEKER uit [P3] (P6-lezing; de MSP-lezing staat in de laatste rij), rij 2–3 ZEKER, rij 4–5 ZEKER -([P3]: "Units/Time of each resource"; [M2]: "decreases individual resource unit values"). -**De verdeelsleutel** bij "totaal W blijft; verdeeld" is in geen van beide documentaties benoemd -(ONBEKEND); het ontwerp kiest *naar rato van inzet* (BEREDENEERD: bij twee gelijke resources levert -het de helft-helft-uitkomst die beide handleidingen als voorbeeld geven; §9 meting 10 en 15 toetsen -het). +Bronnen per kolom: FIXED_DURATION_RATE, FIXED_RATE en FIXED_WORK: rijen 1–3 ZEKER ([M1] en +[P3] stemmen overeen). Rij 4 (erbij) ZEKER uit [P3]'s kolom "add additional resources" en [M2]. +Rij 5 (eraf): [P3] heeft géén kolom voor verwijderen; [M2] dekt de drie effort-driven regels +("when you assign or remove people from a task"), dus ZEKER voor FIXED_RATE, FIXED_WORK en +FIXED_DURATION_WORK, en **AFGELEID** voor FIXED_DURATION_RATE (spiegelbeeld van rij 4). +FIXED_DURATION_WORK: rij 1 ZEKER uit [P3] (P6-lezing; de MSP-lezing staat in de laatste rij), +rij 2–3 ZEKER, rij 4 ZEKER ([P3]: "Units/Time of each resource"; [M2]: "decreases individual +resource unit values"). + +Drie regels die de tabel eenduidig maken (alle drie BEREDENEERD; §9 toetst ze): + +- **De verdeelsleutel** bij "totaal W blijft; verdeeld" is in geen van beide documentaties benoemd + (ONBEKEND). Het ontwerp verdeelt *naar rato van de inzet zoals die bij de bewerking geldt*: I_n + zoals ingevoerd voor de nieuwe toewijzing, de huidige I_i voor de bestaande — dus vóórdat de + regel zelf de inzet herrekent, anders is de sleutel circulair. Bij twee gelijke resources geeft + dat de helft-helft-uitkomst die beide handleidingen als voorbeeld geven (cases 10, 15, 30). +- **De taakduur bij meerdere toewijzingen** is R = max_i(W_i / I_i) over de werkresources (§6.2). + Direct ná een evenredige herverdeling is dat gelijk aan W / ΣI (elke toewijzing heeft dan dezelfde + W_i / I_i); heeft de gebruiker per toewijzing eigen werk ingevoerd, dan bepaalt de zwaarste + toewijzing de duur (case 29). "R = W / ΣI" mag dus nooit als losse formule worden gebouwd. +- **Na afronding** (§6.1) zijn W en I de opgeslagen grootheden en is R afgeleid en naar boven + afgerond; een volgende bewerking rekent altijd vanuit de exacte W en I, nooit terug vanuit de + afgeronde R (case 31). "Resource erbij" op een taak **zonder** toewijzingen: de nieuwe toewijzing krijgt W = R × I; geen enkele regel wordt toegepast (P6 ZEKER: het type doet pas iets vanaf één resource; MSP ZEKER: de @@ -380,13 +408,21 @@ eerste toewijzing zet het werk). `remainingWorkMinutes` aanwezig, dan is het te verdelen totaal dát getal en niet `unitsPerDay × durationDays`. Zonder die aanpassing toont het histogram op zo'n taak te veel werk. BEREDENEERD; MSP staat fractionele dagen toe (0,5 d) en heeft dit probleem niet. +- **Wat na afronding leidend is**: na case 4 geldt R = 3 d, I = 1,0 + 1,0 en W = 20 + 20 u, terwijl + R × ΣI = 48 u. De identiteit werk = duur × inzet geldt dan alleen nog voor de exacte, niet-afgeronde + R. Regel: onder de werkbeschermende regels zijn **W en I opgeslagen en is R afgeleid**; elke + volgende bewerking (case 31) rekent uit de exacte W en I en nooit terug uit de afgeronde R, zodat + herhaald bewerken niet drift. Onder de inzetbeschermende regels blijft W = R × I met de hele-dagen-R + die de gebruiker zelf koos — het gedrag van vandaag. - **Uurtaken**: R in hele minuten, naar boven; W exact. - I wordt op twee decimalen opgeslagen zoals nu in de UI (ZEKER `isValidUnits`-guard: > 0). ### 6.2 Meerdere toewijzingen OPS kent geen duur per toewijzing: elke toewijzing loopt over de hele taakduur (ZEKER, het -datamodel; `workWindowStart/Finish` bestaat maar is ongebruikt). Regel in deze etappe (beslispunt +datamodel; `workWindowStart/Finish` bestaat, round-tript al door IFC (`OPS_TimephasedWindow`) en +het extensiecontract, maar geen lezer vult het en geen solverstap leest het — de latere activering +is dus geen groen veld). Regel in deze etappe (beslispunt 10): **R van de taak = max over de werkresources van W_i / I_i**, en elke toewijzing loopt over die R. Gevolg: wijzigt de gebruiker I van één toewijzing op een FIXED_WORK-taak, dan verandert R via die toewijzing en krijgen de andere toewijzingen I_j = W_j / R (hun werk blijft, dunner gespreid). MSP @@ -407,7 +443,7 @@ over de toewijzingen verdeeld (BEREDENEERD; MSP zegt alleen "verdeeld onder de r daarna de as-herschaling. BEREDENEERD. - **Resource erbij** op een gecontoureerde taak met vast werk: besluit 3 — bestaande contouren houden hun vorm, hoogte × (W_i,nieuw / W_i,oud); de nieuwe toewijzing is vlak (geen contour) over de - nieuwe R; R = W / ΣI. Voor FIXED_DURATION_WORK hetzelfde zonder duurwijziging. + nieuwe R; R = max_i(W_i / I_i) (§5). Voor FIXED_DURATION_WORK hetzelfde zonder duurwijziging. - **Resource eraf**: contour van de verwijderde toewijzing vervalt met de toewijzing (nu al zo). ### 6.4 Kalenders en tijdmodus @@ -516,11 +552,14 @@ werkresource op inzet 1,0 ⇒ werk 40 uur, niets verricht. | 23 | FIXED_WORK, contour vooraan belast | tweede resource erbij | vorm blijft, hoogte ×0,5, nieuwe resource vlak, duur korter | besloten (3) | | 24 | FIXED_WORK, uurtaak 16 u, inzet 1,0 | inzet → 2,0 | duur 8 u (480 min) | documented | | 25 | FIXED_WORK + materiaalresource 100 stuks | duur → 10 d | materiaal ongemoeid; werkresource inzet 0,5 | reasoned (§4.3) | -| 26 | elk type, geen toewijzingen | duur → 10 d | alleen de duur; geen veld geschreven | documented [P2] | +| 26 | elk type, geen toewijzingen | duur → 10 d | alleen de duur; geen veld geschreven | documented [P2] (P6); reasoned (MSP-lezingen) | | 27 | FIXED_WORK | inzet → 0 | geweigerd, niets gewijzigd | bestaande guard | | 28 | FIXED_WORK | inzet → 0,5, dan undo | duur, inzet, werk en contour in één stap terug | ontwerpregel §6.7 | +| 29 | FIXED_WORK, twee resources 1,0 met per toewijzing ingevoerd werk 40 u en 8 u | duur → 10 d | inzet 0,5 en 0,1; werk ongewijzigd — en omgekeerd: inzet van de eerste → 2,0 ⇒ R = max(40/16, 8/8) = 2,5 d (OPS 3 d), tweede toewijzing loopt over die 3 d: I = 8/(3 × 8) ≈ 0,33 | reasoned (§5, R = max_i) | +| 30 | FIXED_WORK, één resource 1,0 (40 u) | tweede resource erbij met inzet 0,5 | verdeling 1,0 : 0,5 ⇒ werk 26,67 + 13,33 u; R = 40/12 ≈ 3,33 d (OPS 4 d) | reasoned (verdeelsleutel §5) | +| 31 | FIXED_WORK, één resource 1,0 (40 u) | inzet → 0,6, daarna inzet → 1,0 | eerst R = ⌈40/4,8⌉ = 9 d, werk 40 u; daarna R = 5 d exact (niet 9 × 8 × 1,0 = 72 u) | reasoned (§6.1, W leidend) | -Deze lijst gaat ook naar `docs/TODO.md` als "MSP-meetlat", zodat de eerstvolgende persoon met +Deze lijst (31 bewerkingen) gaat ook naar `docs/TODO.md` als "MSP-meetlat", zodat de eerstvolgende persoon met MS Project weet wat er te meten valt. ## 10. Bouwvolgorde — apart verifieerbare stappen @@ -537,7 +576,7 @@ twee keer aan `formatRegistry.ts`, `ifcPsets.ts` en de contour-adapters wordt ge | 3 | Pure module `src/engine/work/workTriangle.ts`: `applyDurationEdit`, `applyUnitsEdit`, `applyWorkEdit`, `applyAssignmentAdded`, `applyAssignmentRemoved`, `applyRuleChange` — invoer: taak, toewijzingen, regel, `hoursPerDay`; uitvoer: patches, nooit een store. Plus `cases-taaktypes.json` (§9) | `tests/planning/check-work-triangle.ts` draait alle cases; `evidence` per case | | 4 | Bedrading: `taskSlice.updateTask` (R), `resourceSlice.updateAssignment` (I, W), `taskEditPlan`/`assignmentPlan` (raster), `createMcpTransactions` (MCP-tweeling — ZEKER dat die spiegel bestaat), `assignmentDayUnits` vierde bron (§6.1) | bestaande suites + `check-work-triangle` via de store; `cases-resource-load.json` uitgebreid | | 5 | UI: instelling, auto-ontsluiting + melding, paneel/dialoog, rasterkolommen, i18n 14 locales, gidsen nl+en (+12 vertalingen, anders faalt `verify:docs` niet maar is de functie onvindbaar) | `verify:i18n`, `verify:docs`, browserspec `tests/browser/work-rule.spec.ts` (echte toetsen/klikken, store-asserties) | -| 6 | Resource erbij/eraf (§5 rij 4–5) inclusief contourregels (§6.3) — **vereist beslispunt 8** | cases 4, 5, 9, 10, 15, 19, 20, 23 | +| 6 | Resource erbij/eraf (§5 rij 4–5) inclusief contourregels (§6.3) — **vereist beslispunt 8** | cases 4, 5, 9, 10, 15, 19, 20, 23, 29, 30 | | 7 | MCP: contract + schema + registratie; `docs/recepten/mcp-tool.md` | `tests/mcp/`, `cases-toolregistry` | | 8 | CLAUDE.md-sectie, TODO-afvinking, `docs/superpowers/README.md` | `verify:docs` | @@ -550,7 +589,7 @@ Stap 1–3 zijn onafhankelijk van elkaar te reviewen en veranderen niets aan wat | activiteiten per duration type | DT_FixedDUR2 15.978 · DT_FixedDrtn 1.773 · DT_FixedQty 153 (2 bestanden) · DT_FixedRate 0 · niet-standaard DT_FixedDUR 24 | bouwvolgorde binnen stap 3: FIXED_DURATION_WORK → FIXED_DURATION_RATE → FIXED_WORK → FIXED_RATE (minst getest in de praktijk); de vandaag hardgecodeerde regel is de op één na meest voorkomende in P6-bestanden | | `target_qty` wijkt >1 % af van duur × tarief | 176 van 61.618 rijen (4 met duur 0); 263 rijen met werk zonder tarief; in 5 bestanden (HarbourPointe 98/417, DCP-03 As-Built 37/47, DCP-03 Baseline 35/45, p6_torture_test 5/45, testXer 1/90) | "afwezig ⇒ afgeleid" is voor 99,7 % van de rijen verliesvrij; de XER-afspraak "alleen overzetten bij afwijking" is dus goedkoop | | toewijzingen met een curve | 2 rijen, 1 bestand | curves + werkregels: lage prioriteit, wel correct (§6.3) | -| verricht werk op DT_FixedQty | 1 rij | cases 17 en 19 hebben géén corpusdekking — puur beredeneerd | +| verricht werk op DT_FixedQty | 1 rij | case 17 (verricht werk op een vast-werk-taak) heeft géén corpusdekking — puur beredeneerd | | `remain_qty_per_hr` ≠ `target_qty_per_hr` | rehab-2 14.459/52.640 · DCP-03 As-Built 47/47 · Baseline 45/45 · Roads 71/3.575 · HarbourPointe 14/417 | het resttarief is een eigen grootheid ⇒ drie werkvelden (beslispunt 9) | Het corpus staat bij de eigenaar (`~/open-aec/voor claude/testdata-crawl`, env `OPS_XER_CORPUS`), @@ -559,7 +598,7 @@ van `mspTaskType × effortDriven` over de .mpp-crawl (216 bestanden) ontbreekt n ## 12. Wat dit ontwerp bewust laat liggen (→ `docs/TODO.md`) -- **MSP-meetlat**: de 28 bewerkingen uit §9 meten in MS Project (en P6) zodra iemand het heeft. +- **MSP-meetlat**: de 31 bewerkingen uit §9 meten in MS Project (en P6) zodra iemand het heeft. - **Telling `mspTaskType × effortDriven`** over de `OPS_MPP_CRAWL`-set: bepaalt hoe vaak beslispunt 8 in de praktijk speelt. - **Nivelleerder-optie "inzet verlagen"** (besluit 7-B) onder een geavanceerde optie. From 6352c395b929ea2d02a4bef71f578fc14336ec9c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 05:04:04 +0000 Subject: [PATCH 108/152] =?UTF-8?q?feat(engine):=20werkdriehoek=20?= =?UTF-8?q?=E2=80=94=20pure=20rekenkern=20+=20meetlat=20(taaktypes=20bouws?= =?UTF-8?q?tap=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De regeltabel uit het ontwerp van 2026-09-04 (§5/§6) als pure module `src/engine/work/workTriangle.ts` op de RESTERENDE toestand van een taak: duur-, inzet-, werk- en taakwerkwijziging, resource erbij/eraf en typewissel per werkregel (`WorkRule`, `src/types/workRule.ts`), met de drie eenduidigheidsregels (verdeelsleutel op de inzet zoals ingevoerd, R = max_i(W_i/I_i), W en I leidend na afronding) en de beslispunt-8-B- cellen voor een bewaard MSP-`effortDriven`. Geen store, geen Task-veld, geen IFC: alleen nieuwe bestanden plus één regel in run.sh, zodat de tweede XER-merge er niet mee botst. Meetlat: `tests/planning/work-triangle-cases.json` (de 31 genummerde bewerkingen uit spec §9, met bewijslabel per case) gedreven door `check-work-triangle.ts` (242 checks). De naam mist bewust het `cases-`-prefix: die glob voert een bestand aan de CPM-harnas en de batterij-inventaris in. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0159unb6VzxUx7WG4w3a2ARA --- ...26-09-04-spec-taaktypes-opgeslagen-werk.md | 21 +- src/engine/work/workTriangle.ts | 336 ++++++++++++++++++ src/types/workRule.ts | 38 ++ tests/planning/check-work-triangle.ts | 226 ++++++++++++ tests/planning/run.sh | 3 + tests/planning/work-triangle-cases.json | 140 ++++++++ 6 files changed, 756 insertions(+), 8 deletions(-) create mode 100644 src/engine/work/workTriangle.ts create mode 100644 src/types/workRule.ts create mode 100644 tests/planning/check-work-triangle.ts create mode 100644 tests/planning/work-triangle-cases.json diff --git a/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md b/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md index b8873166d..8420d3f9d 100644 --- a/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md +++ b/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md @@ -2,9 +2,11 @@ *Ontwerp, 2026-09-04. Opvolger van het voorstel [`2026-08-18-spec-taaktypes-effort-driven.md`](2026-08-18-spec-taaktypes-effort-driven.md) -("ontwerp vóór bouw"). Status: **ontwerp, nog niet gebouwd.** Alleen documentatie; er is in deze -ronde geen code aangeraakt. De contour-engine, de contour-UI en de fasen-editor (2026-09) zijn -inmiddels geleverd en worden hier als bestaand fundament gebruikt.* +("ontwerp vóór bouw"). Status: **ontwerp; bouwstap 3 (de pure rekenkern + de meetlat) is +gebouwd op 2026-09-05, de overige stappen niet.** Stap 3 raakt alleen nieuwe bestanden en één +regel in `tests/planning/run.sh`, dus de volgorde "XER merget eerst" (§10 stap 0) blijft intact. +De contour-engine, de contour-UI en de fasen-editor (2026-09) zijn geleverd en worden hier als +bestaand fundament gebruikt.* ## 0. Leeswijzer @@ -517,10 +519,13 @@ toewijzingen (regel doet niets — ZEKER), materiaalresources (§4.3). ## 9. De meetlat: bewerkingen om later tegen MS Project te toetsen -Vorm: een data-gedreven case-bestand `tests/planning/cases-taaktypes.json` in het stramien van -`cases-progress.json` (ZEKER, dat stramien bestaat), met per case een uitgangssituatie, één -bewerking, de verwachte uitkomst en een veld `evidence: 'reasoned' | 'documented' | 'measured'`. -De suite draait de cases tegen de pure bewerkmodule (§10 stap 3) — géén UI. Zodra iemand een +Vorm: een data-gedreven case-bestand `tests/planning/work-triangle-cases.json` (gebouwd; de naam +mist bewust het `cases-`-prefix, want `run.sh` globt `cases-*.json` de CPM-harnas en een vaste +batterij-inventaris in — ZEKER), met per case een resterende uitgangssituatie, één of meer +bewerkingen, de verwachte uitkomst en een veld `evidence: 'documented' | 'reasoned' | 'decided' +| 'measured'`. `tests/planning/check-work-triangle.ts` draait de cases tegen de pure +bewerkmodule (§10 stap 3) — géén UI; de drie store-gebonden cases (22, 23, 28) telt hij en +slaat hij over. Zodra iemand een meting in MS Project (of P6) heeft gedaan, wordt `evidence` `measured` en de verwachting eventueel gecorrigeerd. Uitgangssituatie tenzij anders vermeld: dagtaak, 8 uur/dag, 5 werkdagen, één werkresource op inzet 1,0 ⇒ werk 40 uur, niets verricht. @@ -573,7 +578,7 @@ twee keer aan `formatRegistry.ts`, `ifcPsets.ts` en de contour-adapters wordt ge |---|---|---| | 1 | `WorkRule`, `Task.workRule`, `Project.defaultWorkRule`, de drie werkvelden; `DOCUMENT_FIELDS`; IFC-psets `OPS_WorkRule` + `OPS_AssignmentWork`; ext-contract alleen-lezen. **Geen gedrag.** | `check-ifc-roundtrip` (fixture + canon), `check-document-contract`, `verify:cycles`; elk bestaand bestand byte-identiek | | 2 | Importvertaling (§4.2) voor .mpp, MSPDI (incl. ``/`` lezen), P6 XML, XER; export MSPDI/P6 XML met de werkvelden; waarschuwingen | fidelity-poort blijft 0 (`GOAL_ZERO_DEVIATIONS`); `check-mpp-*`, `check-adapters-*`; nieuwe fixtures per bron | -| 3 | Pure module `src/engine/work/workTriangle.ts`: `applyDurationEdit`, `applyUnitsEdit`, `applyWorkEdit`, `applyAssignmentAdded`, `applyAssignmentRemoved`, `applyRuleChange` — invoer: taak, toewijzingen, regel, `hoursPerDay`; uitvoer: patches, nooit een store. Plus `cases-taaktypes.json` (§9) | `tests/planning/check-work-triangle.ts` draait alle cases; `evidence` per case | +| 3 **(gebouwd 2026-09-05)** | Pure module `src/engine/work/workTriangle.ts`: `applyDurationEdit`, `applyUnitsEdit`, `applyWorkEdit`, `applyTaskWorkEdit`, `applyAssignmentAdded`, `applyAssignmentRemoved`, `applyRuleChange` — invoer: de RESTERENDE toestand (`TriangleState`: regel, bewaard `effortDriven`, restduur in werkminuten, per toewijzing inzet + optioneel restwerk + `drivesDuration`), uitvoer: een nieuwe toestand of een weigering met reden; nooit een store. `WorkRule` in `src/types/workRule.ts` (verhuist in stap 1 naar `task.ts`). Plus `work-triangle-cases.json` (§9) | `tests/planning/check-work-triangle.ts`: 242 checks, 33 cases (20 documented, 11 reasoned, 2 decided, 0 measured) | | 4 | Bedrading: `taskSlice.updateTask` (R), `resourceSlice.updateAssignment` (I, W), `taskEditPlan`/`assignmentPlan` (raster), `createMcpTransactions` (MCP-tweeling — ZEKER dat die spiegel bestaat), `assignmentDayUnits` vierde bron (§6.1) | bestaande suites + `check-work-triangle` via de store; `cases-resource-load.json` uitgebreid | | 5 | UI: instelling, auto-ontsluiting + melding, paneel/dialoog, rasterkolommen, i18n 14 locales, gidsen nl+en (+12 vertalingen, anders faalt `verify:docs` niet maar is de functie onvindbaar) | `verify:i18n`, `verify:docs`, browserspec `tests/browser/work-rule.spec.ts` (echte toetsen/klikken, store-asserties) | | 6 | Resource erbij/eraf (§5 rij 4–5) inclusief contourregels (§6.3) — **vereist beslispunt 8** | cases 4, 5, 9, 10, 15, 19, 20, 23, 29, 30 | diff --git a/src/engine/work/workTriangle.ts b/src/engine/work/workTriangle.ts new file mode 100644 index 000000000..7c84f97d9 --- /dev/null +++ b/src/engine/work/workTriangle.ts @@ -0,0 +1,336 @@ +// workTriangle.ts — de PURE rekenkern van de taaktypes-etappe (ontwerp +// `docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md`, §5 en §6; bouwstap 3). +// +// WAT DIT IS. Eén rekensom, werk = duur × inzet, en per werkregel (`WorkRule`) de afspraak welke +// van de drie beschermd is wanneer de gebruiker aan een andere draait. Deze module krijgt de +// RESTERENDE toestand van één taak (restduur in werkminuten, per toewijzing inzet per dag en +// optioneel resterend werk) plus één bewerking, en geeft de nieuwe toestand terug — of een +// weigering. Verricht werk en verrichte duur zitten er bewust NIET in: dat zijn feiten die geen +// enkele regel aanraakt (spec §2.1/§2.2/§6.5); de driehoek werkt op het restant. +// +// GEEN store, geen Task/ResourceAssignment-types: de bedrading (bouwstap 4) vertaalt de +// domeinobjecten naar `TriangleState` en de uitkomst terug. Zo is de regeltabel headless en +// case-voor-case te toetsen (`tests/planning/check-work-triangle.ts` + +// `work-triangle-cases.json`, de meetlat uit spec §9) zonder dat de rest van de app er al van weet. +// +// DE DRIE REGELS DIE DE TABEL EENDUIDIG MAKEN (spec §5, "Drie regels…"): +// 1. Verdeelsleutel bij "totaal werk blijft; verdeeld": naar rato van de inzet zoals die bij de +// bewerking geldt (de ingevoerde inzet van een nieuwe toewijzing, de huidige van de bestaande). +// 2. Taakduur bij meerdere toewijzingen: R = max over de werkresources van W_i / I_i (§6.2). +// Direct ná een evenredige herverdeling is dat W / ΣI; "R = W / ΣI" wordt hier nooit als losse +// formule gebruikt. +// 3. Na afronding (dagmodus: hele werkdagen, uurmodus: hele minuten, beide naar boven) zijn W en I +// de opgeslagen grootheden en is R afgeleid; een volgende bewerking rekent uit de exacte W en I, +// nooit terug uit de afgeronde R (§6.1). +// +// "Afwezig ⇒ afgeleid": een toewijzing zonder `remainingWorkMinutes` heeft werk R × I. Een regel +// die werk beschermt legt dat werk vast (schrijft het veld) op het moment dat ze het nodig heeft; +// een regel die de inzet beschermt laat een afwezig veld afwezig (byte-identiek gedrag van vandaag). +import type { WorkRule } from '@/types/workRule'; + +export interface TriangleAssignment { + id: string; + /** Inzet in eenheden per werkdag (> 0). */ + unitsPerDay: number; + /** Resterend werk in werkminuten; afwezig ⇒ afgeleid als R × unitsPerDay. */ + remainingWorkMinutes?: number; + /** false voor materiaal (spec §4.3): telt niet mee voor de duur en wordt door geen regel + * aangeraakt; zijn "werk" is een hoeveelheid, geen tijd. */ + drivesDuration: boolean; +} + +export interface TriangleState { + rule: WorkRule; + /** Bewaard MS Project-vinkje (beslispunt 8-B). `undefined` ⇒ zuiver P6-gedrag. Alleen gelezen op + * de twee cellen waar MSP van P6 afwijkt: FIXED_RATE + `false` (resource erbij/eraf verandert + * werk in plaats van duur) en FIXED_DURATION_WORK + `true` (duur gewijzigd verandert werk in + * plaats van inzet). */ + effortDriven?: boolean; + /** Resterende duur van de taak in WERKminuten (zonder gaten). */ + remainingMinutes: number; + /** Werkminuten per werkdag (`hoursPerDay × 60`). */ + slotMinutes: number; + /** Dagmodus: R wordt naar boven op hele werkdagen (veelvouden van `slotMinutes`) afgerond; + * uurmodus: op hele minuten. */ + wholeDays: boolean; + assignments: readonly TriangleAssignment[]; +} + +export type TriangleRejection = + | 'invalid-duration' // restduur ≤ 0 of niet eindig + | 'invalid-units' // inzet ≤ 0 of niet eindig (dezelfde grens als `isValidUnits`) + | 'invalid-work' // werk ≤ 0 of niet eindig + | 'unknown-assignment' + | 'duplicate-assignment'; + +export type TriangleResult = + | { ok: true; state: TriangleState } + | { ok: false; reason: TriangleRejection }; + +// ── Hulpfuncties ──────────────────────────────────────────────────────────────────────────────── + +const EPS = 1e-9; + +const isPositive = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n) && n > 0; + +/** Beschermt de regel het (rest)werk bij een duur-/inzetwijziging? */ +export function ruleProtectsWork(rule: WorkRule): boolean { + return rule === 'FIXED_WORK' || rule === 'FIXED_DURATION_WORK'; +} + +/** Beschermt de regel de duur (P6 "Fixed Duration & …")? */ +export function ruleProtectsDuration(rule: WorkRule): boolean { + return rule === 'FIXED_DURATION_RATE' || rule === 'FIXED_DURATION_WORK'; +} + +/** Resterend werk van een toewijzing bij restduur `remainingMinutes` — het veld als het er is, + * anders afgeleid (R × I). */ +export function remainingWorkOf(a: TriangleAssignment, remainingMinutes: number): number { + return a.remainingWorkMinutes ?? remainingMinutes * a.unitsPerDay; +} + +/** Afronding van een afgeleide restduur (regel 3): naar boven op hele werkdagen in dagmodus, + * op hele minuten in uurmodus. Nooit onder één slot resp. één minuut. */ +export function roundUpRemaining(minutes: number, state: Pick): number { + if (!Number.isFinite(minutes) || minutes <= 0) return 0; + if (state.wholeDays) { + const slot = Math.max(1, state.slotMinutes); + return Math.max(slot, Math.ceil(minutes / slot - EPS) * slot); + } + return Math.max(1, Math.ceil(minutes - EPS)); +} + +/** R = max_i(W_i / I_i) over de werkresources, met W_i afgeleid waar het veld ontbreekt (regel 2), + * daarna afgerond (regel 3). Zonder werkresources blijft de huidige restduur staan. */ +function derivedRemaining(state: TriangleState, assignments: readonly TriangleAssignment[]): number { + let max = 0; + let any = false; + for (const a of assignments) { + if (!a.drivesDuration || !isPositive(a.unitsPerDay)) continue; + any = true; + max = Math.max(max, remainingWorkOf(a, state.remainingMinutes) / a.unitsPerDay); + } + if (!any) return state.remainingMinutes; + return roundUpRemaining(max, state); +} + +/** Nadat R is herleid: de ANDERE toewijzingen volgen de beschermde hoek van de regel — werk vast ⇒ + * inzet = W / R; inzet vast ⇒ een aanwezig werkveld wordt R × I, een afwezig veld blijft afwezig. */ +function followRule( + state: TriangleState, + assignments: readonly TriangleAssignment[], + newRemaining: number, + edited: ReadonlySet, +): TriangleAssignment[] { + const protectsWork = ruleProtectsWork(state.rule); + return assignments.map((a) => { + if (!a.drivesDuration || edited.has(a.id)) return a; + if (protectsWork) { + const w = remainingWorkOf(a, state.remainingMinutes); + if (w <= 0) return { ...a, remainingWorkMinutes: w }; + return { ...a, remainingWorkMinutes: w, unitsPerDay: w / newRemaining }; + } + if (a.remainingWorkMinutes === undefined) return a; + return { ...a, remainingWorkMinutes: newRemaining * a.unitsPerDay }; + }); +} + +/** Verdeel `totalWork` naar rato van inzet over de werkresources (regel 1). Niet-werkresources + * blijven onaangeraakt. */ +function splitByUnits(assignments: readonly TriangleAssignment[], totalWork: number): TriangleAssignment[] { + const driving = assignments.filter((a) => a.drivesDuration); + const sumUnits = driving.reduce((s, a) => s + a.unitsPerDay, 0); + if (sumUnits <= 0) return [...assignments]; + return assignments.map((a) => (a.drivesDuration + ? { ...a, remainingWorkMinutes: totalWork * (a.unitsPerDay / sumUnits) } + : a)); +} + +function totalRemainingWork(state: TriangleState, assignments: readonly TriangleAssignment[]): number { + return assignments.reduce((s, a) => (a.drivesDuration ? s + remainingWorkOf(a, state.remainingMinutes) : s), 0); +} + +const drivingCount = (assignments: readonly TriangleAssignment[]): number => + assignments.filter((a) => a.drivesDuration).length; + +// ── Bewerkingen ───────────────────────────────────────────────────────────────────────────────── + +/** + * Restduur gewijzigd (spec §5 rij 1). Werk beschermd ⇒ inzet = W / R' (het werk wordt daarbij + * vastgelegd); inzet beschermd ⇒ een aanwezig werkveld wordt R' × I. Uitzondering beslispunt 8-B: + * FIXED_DURATION_WORK mét bewaard `effortDriven: true` volgt MS Project (werk = R' × I). + */ +export function applyDurationEdit(state: TriangleState, newRemainingMinutes: number): TriangleResult { + if (!isPositive(newRemainingMinutes)) return { ok: false, reason: 'invalid-duration' }; + const R = roundUpRemaining(newRemainingMinutes, state); + const keepWork = ruleProtectsWork(state.rule) + && !(state.rule === 'FIXED_DURATION_WORK' && state.effortDriven === true); + const assignments = state.assignments.map((a) => { + if (!a.drivesDuration) return a; + if (keepWork) { + const w = remainingWorkOf(a, state.remainingMinutes); + if (w <= 0) return { ...a, remainingWorkMinutes: w }; + return { ...a, remainingWorkMinutes: w, unitsPerDay: w / R }; + } + if (a.remainingWorkMinutes === undefined) return a; + return { ...a, remainingWorkMinutes: R * a.unitsPerDay }; + }); + return { ok: true, state: { ...state, remainingMinutes: R, assignments } }; +} + +/** + * Inzet van één toewijzing gewijzigd (spec §5 rij 2). Duur beschermd ⇒ werk = R × I' (alleen een + * aanwezig veld wordt herschreven); anders R = max_i(W_i / I_i) met het werk van deze toewijzing + * vastgelegd, waarna de andere toewijzingen hun beschermde hoek volgen (§6.2). + */ +export function applyUnitsEdit(state: TriangleState, assignmentId: string, newUnitsPerDay: number): TriangleResult { + if (!isPositive(newUnitsPerDay)) return { ok: false, reason: 'invalid-units' }; + const idx = state.assignments.findIndex((a) => a.id === assignmentId); + if (idx < 0) return { ok: false, reason: 'unknown-assignment' }; + const target = state.assignments[idx]; + if (!target.drivesDuration || ruleProtectsDuration(state.rule)) { + const edited: TriangleAssignment = { + ...target, + unitsPerDay: newUnitsPerDay, + ...(target.drivesDuration && target.remainingWorkMinutes !== undefined + ? { remainingWorkMinutes: state.remainingMinutes * newUnitsPerDay } + : {}), + }; + return { ok: true, state: { ...state, assignments: replaceAt(state.assignments, idx, edited) } }; + } + const edited: TriangleAssignment = { + ...target, + unitsPerDay: newUnitsPerDay, + remainingWorkMinutes: remainingWorkOf(target, state.remainingMinutes), + }; + const withEdit = replaceAt(state.assignments, idx, edited); + const R = derivedRemaining(state, withEdit); + const assignments = followRule(state, withEdit, R, new Set([assignmentId])); + return { ok: true, state: { ...state, remainingMinutes: R, assignments } }; +} + +/** + * Resterend werk van één toewijzing gewijzigd (spec §5 rij 3). Duur beschermd ⇒ inzet = W' / R; + * anders R = max_i(W_i / I_i) en de andere toewijzingen volgen hun beschermde hoek. + */ +export function applyWorkEdit(state: TriangleState, assignmentId: string, newWorkMinutes: number): TriangleResult { + if (!isPositive(newWorkMinutes)) return { ok: false, reason: 'invalid-work' }; + const idx = state.assignments.findIndex((a) => a.id === assignmentId); + if (idx < 0) return { ok: false, reason: 'unknown-assignment' }; + const target = state.assignments[idx]; + if (!target.drivesDuration) { + return { ok: true, state: { ...state, assignments: replaceAt(state.assignments, idx, { ...target, remainingWorkMinutes: newWorkMinutes }) } }; + } + if (ruleProtectsDuration(state.rule)) { + const edited: TriangleAssignment = { ...target, remainingWorkMinutes: newWorkMinutes, unitsPerDay: newWorkMinutes / state.remainingMinutes }; + return { ok: true, state: { ...state, assignments: replaceAt(state.assignments, idx, edited) } }; + } + const withEdit = replaceAt(state.assignments, idx, { ...target, remainingWorkMinutes: newWorkMinutes }); + const R = derivedRemaining(state, withEdit); + const assignments = followRule(state, withEdit, R, new Set([assignmentId])); + return { ok: true, state: { ...state, remainingMinutes: R, assignments } }; +} + +/** + * Resterend werk op TAAKniveau gewijzigd (spec §6.2): naar rato van het bestaande restwerk over de + * werkresources verdeeld (bij nul bestaand werk naar rato van inzet), daarna per regel zoals een + * werkwijziging op elke toewijzing. + */ +export function applyTaskWorkEdit(state: TriangleState, newTotalWorkMinutes: number): TriangleResult { + if (!isPositive(newTotalWorkMinutes)) return { ok: false, reason: 'invalid-work' }; + if (drivingCount(state.assignments) === 0) return { ok: true, state }; + const oldTotal = totalRemainingWork(state, state.assignments); + const split: TriangleAssignment[] = oldTotal > 0 + ? state.assignments.map((a) => (a.drivesDuration + ? { ...a, remainingWorkMinutes: newTotalWorkMinutes * (remainingWorkOf(a, state.remainingMinutes) / oldTotal) } + : a)) + : splitByUnits(state.assignments, newTotalWorkMinutes); + if (ruleProtectsDuration(state.rule)) { + const assignments = split.map((a) => (a.drivesDuration && a.remainingWorkMinutes !== undefined && a.remainingWorkMinutes > 0 + ? { ...a, unitsPerDay: a.remainingWorkMinutes / state.remainingMinutes } + : a)); + return { ok: true, state: { ...state, assignments } }; + } + const R = derivedRemaining(state, split); + const edited = new Set(split.filter((a) => a.drivesDuration).map((a) => a.id)); + const assignments = followRule(state, split, R, edited); + return { ok: true, state: { ...state, remainingMinutes: R, assignments } }; +} + +/** + * Resource erbij (spec §5 rij 4). Eerste werkresource, of een regel die de inzet beschermt en de + * duur vastzet (FIXED_DURATION_RATE; FIXED_RATE mét bewaard `effortDriven: false`, beslispunt + * 8-B) ⇒ de nieuwe toewijzing krijgt afgeleid werk R × I_n en de rest blijft ongemoeid. Anders + * blijft het totale restwerk staan en wordt het naar rato van inzet verdeeld (regel 1); + * FIXED_DURATION_WORK herleidt daaruit de inzet (R blijft), FIXED_WORK/FIXED_RATE de duur + * (R = max_i(W_i / I_i), ná de verdeling W / ΣI; inzet blijft zoals ingevoerd). + */ +export function applyAssignmentAdded( + state: TriangleState, + added: { id: string; unitsPerDay: number; drivesDuration?: boolean }, +): TriangleResult { + if (!isPositive(added.unitsPerDay)) return { ok: false, reason: 'invalid-units' }; + if (state.assignments.some((a) => a.id === added.id)) return { ok: false, reason: 'duplicate-assignment' }; + const newcomer: TriangleAssignment = { id: added.id, unitsPerDay: added.unitsPerDay, drivesDuration: added.drivesDuration ?? true }; + const plain = { ok: true as const, state: { ...state, assignments: [...state.assignments, newcomer] } }; + if (!newcomer.drivesDuration || drivingCount(state.assignments) === 0) return plain; + if (state.rule === 'FIXED_DURATION_RATE') return plain; + if (state.rule === 'FIXED_RATE' && state.effortDriven === false) return plain; + const total = totalRemainingWork(state, state.assignments); + const split = splitByUnits([...state.assignments, newcomer], total); + if (state.rule === 'FIXED_DURATION_WORK') { + const assignments = split.map((a) => (a.drivesDuration && a.remainingWorkMinutes !== undefined && a.remainingWorkMinutes > 0 + ? { ...a, unitsPerDay: a.remainingWorkMinutes / state.remainingMinutes } + : a)); + return { ok: true, state: { ...state, assignments } }; + } + const R = derivedRemaining(state, split); + return { ok: true, state: { ...state, remainingMinutes: R, assignments: split } }; +} + +/** + * Resource eraf (spec §5 rij 5): spiegelbeeld van erbij. FIXED_DURATION_RATE en FIXED_RATE mét + * `effortDriven: false` ⇒ het werk van de verwijderde toewijzing vervalt, de rest blijft. Anders + * blijft het totale restwerk (inclusief dat van de verwijderde) staan en wordt het over de + * blijvers verdeeld; FIXED_DURATION_WORK herleidt de inzet, FIXED_WORK/FIXED_RATE de duur. + */ +export function applyAssignmentRemoved(state: TriangleState, assignmentId: string): TriangleResult { + const idx = state.assignments.findIndex((a) => a.id === assignmentId); + if (idx < 0) return { ok: false, reason: 'unknown-assignment' }; + const removed = state.assignments[idx]; + const rest = state.assignments.filter((a) => a.id !== assignmentId); + const plain = { ok: true as const, state: { ...state, assignments: rest } }; + if (!removed.drivesDuration || drivingCount(rest) === 0) return plain; + if (state.rule === 'FIXED_DURATION_RATE') return plain; + if (state.rule === 'FIXED_RATE' && state.effortDriven === false) return plain; + const total = totalRemainingWork(state, state.assignments); + const split = splitByUnits(rest, total); + if (state.rule === 'FIXED_DURATION_WORK') { + const assignments = split.map((a) => (a.drivesDuration && a.remainingWorkMinutes !== undefined && a.remainingWorkMinutes > 0 + ? { ...a, unitsPerDay: a.remainingWorkMinutes / state.remainingMinutes } + : a)); + return { ok: true, state: { ...state, assignments } }; + } + const R = derivedRemaining(state, split); + return { ok: true, state: { ...state, remainingMinutes: R, assignments: split } }; +} + +/** + * Typewissel (spec §5 rij 6, besluit 2): geen enkel getal verandert. Alleen wanneer de nieuwe regel + * werk beschermt wordt het huidige restwerk van de werkresources vastgelegd zoals het is (het + * veld wordt geschreven), zodat latere bewerkingen een vast anker hebben. + */ +export function applyRuleChange(state: TriangleState, rule: WorkRule): TriangleResult { + const assignments = ruleProtectsWork(rule) + ? state.assignments.map((a) => (a.drivesDuration && a.remainingWorkMinutes === undefined + ? { ...a, remainingWorkMinutes: remainingWorkOf(a, state.remainingMinutes) } + : a)) + : [...state.assignments]; + return { ok: true, state: { ...state, rule, assignments } }; +} + +function replaceAt(list: readonly TriangleAssignment[], idx: number, item: TriangleAssignment): TriangleAssignment[] { + const out = [...list]; + out[idx] = item; + return out; +} diff --git a/src/types/workRule.ts b/src/types/workRule.ts new file mode 100644 index 000000000..b0ece7bc6 --- /dev/null +++ b/src/types/workRule.ts @@ -0,0 +1,38 @@ +/** + * Werkregel van een taak (taaktypes-etappe, ontwerp 2026-09-04 §4.1): welke hoeken van + * werk = duur × inzet beschermd zijn bij een BEWERKING. Neutraal tussen MS Project en P6 — + * bewust niet `taskType` (OPS-domeinklasse), `mspTaskType` (MSP-import) of `durationType` + * (WORKTIME/ELAPSEDTIME) genoemd. "Rate" = `unitsPerDay`, P6's units/time; "work" = totaal + * werk (P6's units, MSP's work). + * + * Afwezig op een taak ⇒ de projectstandaard, en als die ook ontbreekt FIXED_DURATION_RATE — + * het gedrag van vandaag, byte-identiek. Geen enkele solverstap leest dit; het werkt uitsluitend + * in de bewerkingslaag (`src/engine/work/workTriangle.ts`). + * + * Bouwstap 3 (pure kern) levert alleen dit type en die module; het taak-/toewijzingsveld, het + * documentcontract en de IFC-round-trip volgen in bouwstap 1 (ná de tweede XER-merge, zie de + * spec §10 stap 0). Het type staat daarom in een eigen bestand en nog niet in `task.ts`. + */ +export type WorkRule = + /** P6 Fixed Duration & Units/Time · MSP Fixed Duration, niet effort-driven · vandaag. */ + | 'FIXED_DURATION_RATE' + /** P6 Fixed Duration & Units · MSP Fixed Duration, effort-driven (zie beslispunt 8). */ + | 'FIXED_DURATION_WORK' + /** P6 Fixed Units · MSP Fixed Work. */ + | 'FIXED_WORK' + /** P6 Fixed Units/Time · MSP Fixed Units, effort-driven (niet effort-driven: zie beslispunt 8). */ + | 'FIXED_RATE'; + +const WORK_RULE_TABLE = { + FIXED_DURATION_RATE: true, + FIXED_DURATION_WORK: true, + FIXED_WORK: true, + FIXED_RATE: true, +} satisfies Record; + +/** Alle werkregels als runtime-lijst (importvalidatie, dropdowns) — `satisfies` dwingt af dat de + * lijst exact de union dekt, zoals `TASK_TYPES` in `task.ts`. */ +export const WORK_RULES = Object.keys(WORK_RULE_TABLE) as WorkRule[]; + +/** De werkregel die geldt wanneer een taak (en het project) er geen draagt: het huidige gedrag. */ +export const DEFAULT_WORK_RULE: WorkRule = 'FIXED_DURATION_RATE'; diff --git a/tests/planning/check-work-triangle.ts b/tests/planning/check-work-triangle.ts new file mode 100644 index 000000000..300b1b778 --- /dev/null +++ b/tests/planning/check-work-triangle.ts @@ -0,0 +1,226 @@ +// check-work-triangle.ts — de meetlat van de taaktypes-etappe (ontwerp 2026-09-04 §9) tegen de +// pure rekenkern `src/engine/work/workTriangle.ts` (bouwstap 3). Twee lagen: +// (a) `work-triangle-cases.json`: de 31 genummerde bewerkingen uit de spec als data — per case +// een resterende toestand, één of meer bewerkingen en de verwachte uitkomst, met het +// bewijslabel (documented/reasoned/decided/measured). Cases met `scope: 'store'` (contour, +// undo) horen bij de bedrading en worden hier geteld maar overgeslagen. +// (b) eigenschappen die geen enkele case apart toetst: "afwezig ⇒ afgeleid" blijft afwezig onder +// de inzetbeschermende regels, de typewissel verandert geen getal, weigeringen laten de +// toestand onaangeraakt, materiaal telt nergens mee, en de afronding gaat naar boven. +// +// Draait via run.sh (esbuild-bundel, Node). Exit 0 = groen. Geen store: alles is puur. +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { + applyAssignmentAdded, applyAssignmentRemoved, applyDurationEdit, applyRuleChange, applyTaskWorkEdit, + applyUnitsEdit, applyWorkEdit, remainingWorkOf, roundUpRemaining, + type TriangleAssignment, type TriangleResult, type TriangleState, +} from '@/engine/work/workTriangle'; +import { WORK_RULES, type WorkRule } from '@/types/workRule'; + +declare const process: { exit(code: number): never }; + +let checks = 0; +const diffs: string[] = []; +function ok(label: string, cond: boolean): void { + checks++; + if (!cond) diffs.push(label); +} +function near(label: string, actual: number | undefined, expected: number | undefined, tol = 0.005): void { + checks++; + if (actual === undefined || expected === undefined) { + if (actual !== expected) diffs.push(`${label}: kreeg ${String(actual)}, verwacht ${String(expected)}`); + return; + } + if (Math.abs(actual - expected) > tol) diffs.push(`${label}: kreeg ${actual}, verwacht ${expected}`); +} + +// ── (a) de meetlat als data ───────────────────────────────────────────────────────────────────── + +interface CaseAssignment { id: string; units: number; hours?: number; material?: boolean } +interface CaseState { + rule: WorkRule; effortDriven?: boolean; days?: number; minutes?: number; hoursPerDay?: number; + wholeDays?: boolean; assignments: CaseAssignment[]; +} +interface ExpectAssignment { units?: number; hours?: number | null; derivedHours?: number } +interface Expect { days?: number; minutes?: number; assignments?: Record; rejected?: string } +type Edit = + | { kind: 'duration'; days?: number; minutes?: number } + | { kind: 'units'; id: string; units: number } + | { kind: 'work'; id: string; hours: number } + | { kind: 'taskWork'; hours: number } + | { kind: 'add'; id: string; units: number; material?: boolean } + | { kind: 'remove'; id: string } + | { kind: 'rule'; rule: WorkRule } + | ({ kind: 'check' } & Expect); +interface Case { + id: string; nr: number; evidence: 'documented' | 'reasoned' | 'decided' | 'measured'; source: string; + scope?: 'store'; state?: CaseState; edits?: Edit[]; expect?: Expect; +} + +const here = dirname(fileURLToPath(import.meta.url)); +const file = JSON.parse(readFileSync(join(here, 'work-triangle-cases.json'), 'utf8')) as { cases: Case[] }; + +function toState(c: CaseState): TriangleState { + const hpd = c.hoursPerDay ?? 8; + const slotMinutes = hpd * 60; + const remainingMinutes = c.minutes ?? (c.days ?? 0) * slotMinutes; + return { + rule: c.rule, + effortDriven: c.effortDriven, + remainingMinutes, + slotMinutes, + wholeDays: c.wholeDays ?? c.minutes === undefined, + assignments: c.assignments.map((a): TriangleAssignment => ({ + id: a.id, + unitsPerDay: a.units, + drivesDuration: !a.material, + ...(a.hours !== undefined ? { remainingWorkMinutes: a.hours * 60 } : {}), + })), + }; +} + +function apply(state: TriangleState, e: Edit): TriangleResult { + switch (e.kind) { + case 'duration': return applyDurationEdit(state, e.minutes ?? (e.days ?? 0) * state.slotMinutes); + case 'units': return applyUnitsEdit(state, e.id, e.units); + case 'work': return applyWorkEdit(state, e.id, e.hours * 60); + case 'taskWork': return applyTaskWorkEdit(state, e.hours * 60); + case 'add': return applyAssignmentAdded(state, { id: e.id, unitsPerDay: e.units, drivesDuration: !e.material }); + case 'remove': return applyAssignmentRemoved(state, e.id); + case 'rule': return applyRuleChange(state, e.rule); + case 'check': return { ok: true, state }; + } +} + +function assertExpect(label: string, state: TriangleState, x: Expect): void { + if (x.days !== undefined) near(`${label}: restduur (dagen)`, state.remainingMinutes / state.slotMinutes, x.days); + if (x.minutes !== undefined) near(`${label}: restduur (minuten)`, state.remainingMinutes, x.minutes); + if (!x.assignments) return; + ok(`${label}: aantal toewijzingen`, state.assignments.length === Object.keys(x.assignments).length); + for (const [id, ex] of Object.entries(x.assignments)) { + const a = state.assignments.find((s) => s.id === id); + ok(`${label}: toewijzing ${id} aanwezig`, !!a); + if (!a) continue; + if (ex.units !== undefined) near(`${label}: ${id} inzet`, a.unitsPerDay, ex.units, 0.0005); + if (ex.hours === null) ok(`${label}: ${id} werkveld afwezig (afgeleid)`, a.remainingWorkMinutes === undefined); + else if (ex.hours !== undefined) near(`${label}: ${id} restwerk (u)`, (a.remainingWorkMinutes ?? Number.NaN) / 60, ex.hours); + if (ex.derivedHours !== undefined) near(`${label}: ${id} afgeleid werk (u)`, remainingWorkOf(a, state.remainingMinutes) / 60, ex.derivedHours); + } +} + +const evidenceCount: Record = {}; +let storeScoped = 0; +for (const c of file.cases) { + evidenceCount[c.evidence] = (evidenceCount[c.evidence] ?? 0) + 1; + if (c.scope === 'store') { storeScoped++; continue; } + if (!c.state || !c.edits || !c.expect) { diffs.push(`${c.id}: onvolledige case (state/edits/expect)`); continue; } + let state = toState(c.state); + let rejected: string | undefined; + for (const e of c.edits) { + if (e.kind === 'check') { assertExpect(`${c.id} (tussenstand)`, state, e); continue; } + const r = apply(state, e); + if (!r.ok) { rejected = r.reason; break; } + state = r.state; + } + if (c.expect.rejected !== undefined) { + ok(`${c.id}: verwacht weigering ${c.expect.rejected}, kreeg ${rejected ?? 'geen'}`, rejected === c.expect.rejected); + continue; + } + if (rejected) { diffs.push(`${c.id}: onverwacht geweigerd (${rejected})`); continue; } + assertExpect(c.id, state, c.expect); +} +ok('meetlat: 31 genummerde bewerkingen aanwezig', new Set(file.cases.map((c) => c.nr)).size === 31); +ok('meetlat: geen enkele case is al gemeten (measured) — anders hoort de spec bijgewerkt', (evidenceCount.measured ?? 0) === 0); + +// ── (b) eigenschappen ─────────────────────────────────────────────────────────────────────────── + +const base: TriangleState = { + rule: 'FIXED_DURATION_RATE', remainingMinutes: 2400, slotMinutes: 480, wholeDays: true, + assignments: [{ id: 'a', unitsPerDay: 1, drivesDuration: true }, { id: 'b', unitsPerDay: 0.5, drivesDuration: true }], +}; + +// Afwezig blijft afwezig onder de regels van vandaag: geen enkel veld wordt geschreven. +for (const rule of ['FIXED_DURATION_RATE', 'FIXED_RATE'] as const) { + const s = { ...base, rule }; + const r1 = applyDurationEdit(s, 4800); + ok(`${rule}: duurwijziging schrijft geen werkveld`, r1.ok && r1.state.assignments.every((a) => a.remainingWorkMinutes === undefined)); + const r2 = applyUnitsEdit(s, 'b', 1); + ok(`${rule}: inzetwijziging laat het werkveld van de ANDERE toewijzing afwezig`, r2.ok && r2.state.assignments.find((a) => a.id === 'a')?.remainingWorkMinutes === undefined); +} +// FIXED_DURATION_RATE is byte-identiek aan vandaag: inzet gewijzigd ⇒ duur blijft. +{ + const r = applyUnitsEdit(base, 'b', 2); + ok('FIXED_DURATION_RATE: inzet gewijzigd ⇒ restduur ongewijzigd', r.ok && r.state.remainingMinutes === 2400); +} +// Typewissel verandert geen getal, in alle 16 richtingen; naar een werkbeschermende regel legt hij het werk vast. +for (const from of WORK_RULES) { + for (const to of WORK_RULES) { + const r = applyRuleChange({ ...base, rule: from }, to); + ok(`typewissel ${from}→${to}: duur en inzet ongewijzigd`, r.ok && r.state.remainingMinutes === 2400 + && r.state.assignments.every((a, i) => a.unitsPerDay === base.assignments[i].unitsPerDay) + && r.state.rule === to); + const fixed = to === 'FIXED_WORK' || to === 'FIXED_DURATION_WORK'; + ok(`typewissel ${from}→${to}: werk ${fixed ? 'vastgelegd' : 'blijft afgeleid'}`, r.ok && r.state.assignments.every((a) => + (a.remainingWorkMinutes !== undefined) === fixed)); + } +} +// Weigeringen laten de invoer onaangeraakt en noemen de reden. +{ + const s = { ...base, rule: 'FIXED_WORK' as const }; + ok('weigering: duur 0', !applyDurationEdit(s, 0).ok); + ok('weigering: duur NaN', !applyDurationEdit(s, Number.NaN).ok); + ok('weigering: werk 0', !applyWorkEdit(s, 'a', 0).ok); + ok('weigering: onbekende toewijzing', (() => { const r = applyUnitsEdit(s, 'zz', 1); return !r.ok && r.reason === 'unknown-assignment'; })()); + ok('weigering: dubbele toewijzing', (() => { const r = applyAssignmentAdded(s, { id: 'a', unitsPerDay: 1 }); return !r.ok && r.reason === 'duplicate-assignment'; })()); + ok('weigering: toewijzing erbij met inzet 0', !applyAssignmentAdded(s, { id: 'c', unitsPerDay: 0 }).ok); + ok('weigering: invoer onaangeraakt', s.assignments[0].remainingWorkMinutes === undefined && s.remainingMinutes === 2400); +} +// Materiaal: telt nergens mee — ook niet als enige "resource" (dan is er geen werkresource, dus geen regel). +{ + const s: TriangleState = { ...base, rule: 'FIXED_WORK', assignments: [{ id: 'm', unitsPerDay: 100, drivesDuration: false }] }; + const r1 = applyDurationEdit(s, 4800); + ok('materiaal: duurwijziging laat materiaal met rust', r1.ok && r1.state.assignments[0].unitsPerDay === 100 && r1.state.assignments[0].remainingWorkMinutes === undefined); + const r2 = applyAssignmentAdded(s, { id: 'a', unitsPerDay: 1 }); + ok('materiaal: eerste werkresource erbij ⇒ afgeleid werk, duur ongewijzigd', r2.ok && r2.state.remainingMinutes === 2400 && r2.state.assignments[1].remainingWorkMinutes === undefined); + const r3 = applyUnitsEdit({ ...s, rule: 'FIXED_WORK' }, 'm', 50); + ok('materiaal: hoeveelheid wijzigen raakt de duur niet', r3.ok && r3.state.remainingMinutes === 2400 && r3.state.assignments[0].unitsPerDay === 50); +} +// Taakniveau-werk: naar rato van bestaand restwerk (regel §6.2), daarna per regel. +{ + const s: TriangleState = { ...base, rule: 'FIXED_DURATION_RATE', assignments: [ + { id: 'a', unitsPerDay: 1, drivesDuration: true, remainingWorkMinutes: 2400 }, + { id: 'b', unitsPerDay: 0.5, drivesDuration: true, remainingWorkMinutes: 1200 }, + ] }; + const r = applyTaskWorkEdit(s, 7200); + ok('taakwerk (duur vast): 120 u verdeeld 80/40 ⇒ inzet 2,0 en 1,0', r.ok && r.state.remainingMinutes === 2400 + && Math.abs(r.state.assignments[0].unitsPerDay - 2) < 1e-9 && Math.abs(r.state.assignments[1].unitsPerDay - 1) < 1e-9); + const r2 = applyTaskWorkEdit({ ...s, rule: 'FIXED_WORK' }, 7200); + ok('taakwerk (werk vast): 120 u verdeeld 80/40 ⇒ R = max(80/8, 40/4) = 10 d', r2.ok && r2.state.remainingMinutes === 4800 + && r2.state.assignments.every((a) => Math.abs(a.unitsPerDay - (a.id === 'a' ? 1 : 0.5)) < 1e-9)); +} +// Afronding: naar boven, nooit onder één slot; uurmodus op hele minuten. +near('afronding: 2,5 d → 3 d', roundUpRemaining(1200, base), 1440, 0); +near('afronding: precies 3 d blijft 3 d', roundUpRemaining(1440, base), 1440, 0); +near('afronding: 3 d + drijvende-kommaruis blijft 3 d', roundUpRemaining(1440 + 1e-10, base), 1440, 0); +near('afronding: minimaal één slot', roundUpRemaining(1, base), 480, 0); +near('afronding: uurmodus 100,2 min → 101', roundUpRemaining(100.2, { slotMinutes: 480, wholeDays: false }), 101, 0); +// Werk vast + inzet vast tegelijk kan niet: FIXED_RATE met twee toewijzingen — de andere houdt haar inzet en +// haar (afgeleide) werk groeit met de langere duur (§6.2, beslispunt 10). +{ + const s: TriangleState = { ...base, rule: 'FIXED_RATE' }; + const r = applyUnitsEdit(s, 'a', 0.5); + ok('FIXED_RATE, twee toewijzingen: R = max(40/4, 20/4) = 10 d, b houdt inzet 0,5', r.ok && r.state.remainingMinutes === 4800 + && r.state.assignments[1].unitsPerDay === 0.5 && r.state.assignments[1].remainingWorkMinutes === undefined); +} + +// ── Uitslag ───────────────────────────────────────────────────────────────────────────────────── + +const label = `check-work-triangle: ${checks} checks; meetlat ${file.cases.length} cases (${Object.entries(evidenceCount).map(([k, v]) => `${k} ${v}`).join(', ')}; ${storeScoped} store-scoped overgeslagen)`; +if (diffs.length > 0) { + console.log(`XX ${label} — ${diffs.length} afwijking(en):`); + for (const d of diffs) console.log(` ${d}`); + process.exit(1); +} +console.log(`OK ${label}`); diff --git a/tests/planning/run.sh b/tests/planning/run.sh index 925b5712f..b1bd32b1d 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -298,6 +298,9 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then # native MSPDI-/P6-/IFC-round-trip van contouren en 21-punts-curves. CECHECK="$DIR/.check-contour-engine.mjs" if bundle_check "$DIR/check-contour-engine.ts" "$CECHECK"; then node "$CECHECK" || STATUS=1; fi + # Taaktypes-etappe, bouwstap 3 (ontwerp 2026-09-04 §9/§10): de pure werkdriehoek + de meetlat. + WTCHECK="$DIR/.check-work-triangle.mjs" + if bundle_check "$DIR/check-work-triangle.ts" "$WTCHECK"; then node "$WTCHECK" || STATUS=1; fi # Geavanceerde-CPM golf-0-checks (fase 2.9 — datamodel + plumbing default-inert, los van de CPM-cases). ACPMCHECK="$DIR/.advanced-cpm-check.mjs" diff --git a/tests/planning/work-triangle-cases.json b/tests/planning/work-triangle-cases.json new file mode 100644 index 000000000..355189e60 --- /dev/null +++ b/tests/planning/work-triangle-cases.json @@ -0,0 +1,140 @@ +{ + "_readme": [ + "De meetlat uit docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md §9, als data.", + "Gelezen door check-work-triangle.ts (NIET door harness.ts: de naam mist bewust het cases-*-prefix,", + "want die glob voert een bestand aan de CPM-harnas en de batterij-inventaris in run.sh).", + "Per case: `nr` = het nummer in de spec; `evidence` = documented (letterlijk in Microsoft-/Oracle-", + "documentatie, bron in `source`), reasoned (beredeneerd), decided (eigenaarsbesluit) of measured", + "(gemeten in MS Project/P6 — nog geen enkele). `scope: store` = valt buiten de pure module", + "(contour/undo) en wordt door deze check overgeslagen maar wel geteld.", + "Uitgangssituatie tenzij anders gezegd: dagtaak, 8 u/dag, 5 werkdagen, één werkresource op 1,0 ⇒ 40 u.", + "`state` beschrijft het RESTERENDE deel (spec §5: verrichte duur/werk zijn feiten buiten de driehoek).", + "`hours` op een toewijzing = aanwezig restwerkveld; weggelaten = afgeleid. In `expect` betekent", + "`hours: null` dat het veld afwezig moet blijven; `days`/`minutes` is de restduur; `rejected` de reden." + ], + "cases": [ + { "id": "wt-01", "nr": 1, "evidence": "documented", "source": "[M1] Fixed units: revise duration → work", + "state": { "rule": "FIXED_RATE", "days": 5, "assignments": [{ "id": "a", "units": 1 }] }, + "edits": [{ "kind": "duration", "days": 10 }], + "expect": { "days": 10, "assignments": { "a": { "units": 1, "hours": null, "derivedHours": 80 } } } }, + { "id": "wt-02", "nr": 2, "evidence": "documented", "source": "[M1] Fixed units: revise units → duration", + "state": { "rule": "FIXED_RATE", "days": 5, "assignments": [{ "id": "a", "units": 1 }] }, + "edits": [{ "kind": "units", "id": "a", "units": 0.5 }], + "expect": { "days": 10, "assignments": { "a": { "units": 0.5, "hours": 40 } } } }, + { "id": "wt-03", "nr": 3, "evidence": "documented", "source": "[M1] Fixed units: revise work → duration", + "state": { "rule": "FIXED_RATE", "days": 5, "assignments": [{ "id": "a", "units": 1 }] }, + "edits": [{ "kind": "work", "id": "a", "hours": 80 }], + "expect": { "days": 10, "assignments": { "a": { "units": 1, "hours": 80 } } } }, + { "id": "wt-04", "nr": 4, "evidence": "documented", "source": "[M2] fixed units + effort-driven: resource erbij verkort de duur; MSP 2,5 d, OPS rondt naar 3 d (§6.1)", + "state": { "rule": "FIXED_RATE", "days": 5, "assignments": [{ "id": "a", "units": 1 }] }, + "edits": [{ "kind": "add", "id": "b", "units": 1 }], + "expect": { "days": 3, "assignments": { "a": { "units": 1, "hours": 20 }, "b": { "units": 1, "hours": 20 } } } }, + { "id": "wt-05", "nr": 5, "evidence": "documented", "source": "[M2] niet effort-driven: werk groeit, duur blijft (beslispunt 8-B)", + "state": { "rule": "FIXED_RATE", "effortDriven": false, "days": 5, "assignments": [{ "id": "a", "units": 1 }] }, + "edits": [{ "kind": "add", "id": "b", "units": 1 }], + "expect": { "days": 5, "assignments": { "a": { "units": 1, "hours": null, "derivedHours": 40 }, "b": { "units": 1, "hours": null, "derivedHours": 40 } } } }, + { "id": "wt-06", "nr": 6, "evidence": "documented", "source": "[M1] Fixed duration: revise duration → work; [P3] FD&U/T: duur → units", + "state": { "rule": "FIXED_DURATION_RATE", "days": 5, "assignments": [{ "id": "a", "units": 1, "hours": 40 }] }, + "edits": [{ "kind": "duration", "days": 10 }], + "expect": { "days": 10, "assignments": { "a": { "units": 1, "hours": 80 } } } }, + { "id": "wt-07", "nr": 7, "evidence": "documented", "source": "[M1] Fixed duration: revise units → work", + "state": { "rule": "FIXED_DURATION_RATE", "days": 5, "assignments": [{ "id": "a", "units": 1, "hours": 40 }] }, + "edits": [{ "kind": "units", "id": "a", "units": 0.5 }], + "expect": { "days": 5, "assignments": { "a": { "units": 0.5, "hours": 20 } } } }, + { "id": "wt-08", "nr": 8, "evidence": "documented", "source": "[M1] Fixed duration: revise work → units", + "state": { "rule": "FIXED_DURATION_RATE", "days": 5, "assignments": [{ "id": "a", "units": 1 }] }, + "edits": [{ "kind": "work", "id": "a", "hours": 80 }], + "expect": { "days": 5, "assignments": { "a": { "units": 2, "hours": 80 } } } }, + { "id": "wt-09", "nr": 9, "evidence": "documented", "source": "[P3] FD&U/T: extra resource → units (werk groeit)", + "state": { "rule": "FIXED_DURATION_RATE", "days": 5, "assignments": [{ "id": "a", "units": 1 }] }, + "edits": [{ "kind": "add", "id": "b", "units": 1 }], + "expect": { "days": 5, "assignments": { "a": { "units": 1, "hours": null, "derivedHours": 40 }, "b": { "units": 1, "hours": null, "derivedHours": 40 } } } }, + { "id": "wt-10", "nr": 10, "evidence": "documented", "source": "[M2]/[P3] FD&U: units/time of each resource; verdeelsleutel reasoned", + "state": { "rule": "FIXED_DURATION_WORK", "days": 5, "assignments": [{ "id": "a", "units": 1 }] }, + "edits": [{ "kind": "add", "id": "b", "units": 1 }], + "expect": { "days": 5, "assignments": { "a": { "units": 0.5, "hours": 20 }, "b": { "units": 0.5, "hours": 20 } } } }, + { "id": "wt-11a", "nr": 11, "evidence": "documented", "source": "[P3] FD&U: duur → units/time (P6-lezing)", + "state": { "rule": "FIXED_DURATION_WORK", "days": 5, "assignments": [{ "id": "a", "units": 1 }] }, + "edits": [{ "kind": "duration", "days": 10 }], + "expect": { "days": 10, "assignments": { "a": { "units": 0.5, "hours": 40 } } } }, + { "id": "wt-11b", "nr": 11, "evidence": "documented", "source": "[M1] Fixed duration + effort-driven: duur → work (MSP-lezing, beslispunt 8-B)", + "state": { "rule": "FIXED_DURATION_WORK", "effortDriven": true, "days": 5, "assignments": [{ "id": "a", "units": 1, "hours": 40 }] }, + "edits": [{ "kind": "duration", "days": 10 }], + "expect": { "days": 10, "assignments": { "a": { "units": 1, "hours": 80 } } } }, + { "id": "wt-12", "nr": 12, "evidence": "documented", "source": "[M1] Fixed work: revise duration → units", + "state": { "rule": "FIXED_WORK", "days": 5, "assignments": [{ "id": "a", "units": 1 }] }, + "edits": [{ "kind": "duration", "days": 10 }], + "expect": { "days": 10, "assignments": { "a": { "units": 0.5, "hours": 40 } } } }, + { "id": "wt-13", "nr": 13, "evidence": "documented", "source": "[M1] Fixed work: revise units → duration", + "state": { "rule": "FIXED_WORK", "days": 5, "assignments": [{ "id": "a", "units": 1 }] }, + "edits": [{ "kind": "units", "id": "a", "units": 0.5 }], + "expect": { "days": 10, "assignments": { "a": { "units": 0.5, "hours": 40 } } } }, + { "id": "wt-14", "nr": 14, "evidence": "documented", "source": "[M1] Fixed work: revise work → duration", + "state": { "rule": "FIXED_WORK", "days": 5, "assignments": [{ "id": "a", "units": 1 }] }, + "edits": [{ "kind": "work", "id": "a", "hours": 80 }], + "expect": { "days": 10, "assignments": { "a": { "units": 1, "hours": 80 } } } }, + { "id": "wt-15", "nr": 15, "evidence": "documented", "source": "[M2] fixed work altijd effort-driven; [P3] Fixed Units: extra resource → duration; verdeelsleutel reasoned", + "state": { "rule": "FIXED_WORK", "days": 5, "assignments": [{ "id": "a", "units": 1 }] }, + "edits": [{ "kind": "add", "id": "b", "units": 1 }], + "expect": { "days": 3, "assignments": { "a": { "units": 1, "hours": 20 }, "b": { "units": 1, "hours": 20 } } } }, + { "id": "wt-16", "nr": 16, "evidence": "reasoned", "source": "spec §2.1 laatste punt: verricht 4 d/32 u blijft; rest 6 d → 11 d; restwerk 48 u → 88 u (totaal 120 u)", + "state": { "rule": "FIXED_RATE", "days": 6, "assignments": [{ "id": "a", "units": 1, "hours": 48 }] }, + "edits": [{ "kind": "duration", "days": 11 }], + "expect": { "days": 11, "assignments": { "a": { "units": 1, "hours": 88 } } } }, + { "id": "wt-17", "nr": 17, "evidence": "reasoned", "source": "spec §6.5: rest 6 d/48 u → rest 8 d ⇒ inzet 0,75; verricht 32 u ongemoeid", + "state": { "rule": "FIXED_WORK", "days": 6, "assignments": [{ "id": "a", "units": 1, "hours": 48 }] }, + "edits": [{ "kind": "duration", "days": 8 }], + "expect": { "days": 8, "assignments": { "a": { "units": 0.75, "hours": 48 } } } }, + { "id": "wt-18", "nr": 18, "evidence": "reasoned", "source": "spec §6.5: rest 6 d → 8 d, restwerk 64 u (totaal 96 u)", + "state": { "rule": "FIXED_DURATION_RATE", "days": 6, "assignments": [{ "id": "a", "units": 1, "hours": 48 }] }, + "edits": [{ "kind": "duration", "days": 8 }], + "expect": { "days": 8, "assignments": { "a": { "units": 1, "hours": 64 } } } }, + { "id": "wt-19", "nr": 19, "evidence": "documented", "source": "[M2] assign or remove: werk blijft, duur past aan", + "state": { "rule": "FIXED_WORK", "days": 3, "assignments": [{ "id": "a", "units": 1, "hours": 20 }, { "id": "b", "units": 1, "hours": 20 }] }, + "edits": [{ "kind": "remove", "id": "b" }], + "expect": { "days": 5, "assignments": { "a": { "units": 1, "hours": 40 } } } }, + { "id": "wt-20", "nr": 20, "evidence": "documented", "source": "[M2] fixed duration effort-driven: resource eraf ⇒ inzet van de blijver stijgt", + "state": { "rule": "FIXED_DURATION_WORK", "days": 5, "assignments": [{ "id": "a", "units": 0.5, "hours": 20 }, { "id": "b", "units": 0.5, "hours": 20 }] }, + "edits": [{ "kind": "remove", "id": "b" }], + "expect": { "days": 5, "assignments": { "a": { "units": 1, "hours": 40 } } } }, + { "id": "wt-21", "nr": 21, "evidence": "decided", "source": "besluit 2: typewissel verandert geen getal; daarna [M1] Fixed work: duur → units", + "state": { "rule": "FIXED_RATE", "days": 5, "assignments": [{ "id": "a", "units": 1 }] }, + "edits": [{ "kind": "rule", "rule": "FIXED_WORK" }, { "kind": "check", "days": 5, "assignments": { "a": { "units": 1, "hours": 40 } } }, { "kind": "duration", "days": 10 }], + "expect": { "days": 10, "assignments": { "a": { "units": 0.5, "hours": 40 } } } }, + { "id": "wt-22", "nr": 22, "evidence": "reasoned", "scope": "store", "source": "contour: bestaande rescaleContourForDuration (check-contour-engine.ts sectie d)" }, + { "id": "wt-23", "nr": 23, "evidence": "decided", "scope": "store", "source": "besluit 3: vorm blijft, hoogte zakt, nieuwe resource vlak — bouwstap 6" }, + { "id": "wt-24", "nr": 24, "evidence": "documented", "source": "[M1] Fixed work, uurtaak 16 u: units → duration", + "state": { "rule": "FIXED_WORK", "minutes": 960, "wholeDays": false, "assignments": [{ "id": "a", "units": 1 }] }, + "edits": [{ "kind": "units", "id": "a", "units": 2 }], + "expect": { "minutes": 480, "assignments": { "a": { "units": 2, "hours": 16 } } } }, + { "id": "wt-25", "nr": 25, "evidence": "reasoned", "source": "spec §4.3: materiaal buiten de driehoek", + "state": { "rule": "FIXED_WORK", "days": 5, "assignments": [{ "id": "a", "units": 1 }, { "id": "m", "units": 100, "material": true }] }, + "edits": [{ "kind": "duration", "days": 10 }], + "expect": { "days": 10, "assignments": { "a": { "units": 0.5, "hours": 40 }, "m": { "units": 100, "hours": null } } } }, + { "id": "wt-26", "nr": 26, "evidence": "documented", "source": "[P2] duration type applies only with ≥1 resource (P6); reasoned (MSP)", + "state": { "rule": "FIXED_WORK", "days": 5, "assignments": [] }, + "edits": [{ "kind": "duration", "days": 10 }], + "expect": { "days": 10, "assignments": {} } }, + { "id": "wt-27", "nr": 27, "evidence": "reasoned", "source": "bestaande isValidUnits-guard: 0 geweigerd, niets gewijzigd", + "state": { "rule": "FIXED_WORK", "days": 5, "assignments": [{ "id": "a", "units": 1 }] }, + "edits": [{ "kind": "units", "id": "a", "units": 0 }], + "expect": { "rejected": "invalid-units" } }, + { "id": "wt-28", "nr": 28, "evidence": "reasoned", "scope": "store", "source": "§6.7: één undo-stap — bouwstap 4" }, + { "id": "wt-29a", "nr": 29, "evidence": "reasoned", "source": "spec §5 regel 2: per toewijzing eigen werk; duur → 10 d ⇒ inzet 0,5 en 0,1", + "state": { "rule": "FIXED_WORK", "days": 5, "assignments": [{ "id": "a", "units": 1, "hours": 40 }, { "id": "b", "units": 1, "hours": 8 }] }, + "edits": [{ "kind": "duration", "days": 10 }], + "expect": { "days": 10, "assignments": { "a": { "units": 0.5, "hours": 40 }, "b": { "units": 0.1, "hours": 8 } } } }, + { "id": "wt-29b", "nr": 29, "evidence": "reasoned", "source": "spec §5 regel 2: inzet a → 2,0 ⇒ R = max(40/16, 8/8) = 2,5 d → 3 d; b loopt over die 3 d: 8/24", + "state": { "rule": "FIXED_WORK", "days": 5, "assignments": [{ "id": "a", "units": 1, "hours": 40 }, { "id": "b", "units": 1, "hours": 8 }] }, + "edits": [{ "kind": "units", "id": "a", "units": 2 }], + "expect": { "days": 3, "assignments": { "a": { "units": 2, "hours": 40 }, "b": { "units": 0.3333, "hours": 8 } } } }, + { "id": "wt-30", "nr": 30, "evidence": "reasoned", "source": "spec §5 regel 1: verdeling 1,0 : 0,5 ⇒ 26,67 + 13,33 u; R = 40/12 ≈ 3,33 d → 4 d", + "state": { "rule": "FIXED_WORK", "days": 5, "assignments": [{ "id": "a", "units": 1 }] }, + "edits": [{ "kind": "add", "id": "b", "units": 0.5 }], + "expect": { "days": 4, "assignments": { "a": { "units": 1, "hours": 26.6667 }, "b": { "units": 0.5, "hours": 13.3333 } } } }, + { "id": "wt-31", "nr": 31, "evidence": "reasoned", "source": "spec §6.1: W leidend na afronding — inzet → 0,6 ⇒ ⌈40/4,8⌉ = 9 d; daarna inzet → 1,0 ⇒ 5 d exact", + "state": { "rule": "FIXED_WORK", "days": 5, "assignments": [{ "id": "a", "units": 1 }] }, + "edits": [{ "kind": "units", "id": "a", "units": 0.6 }, { "kind": "check", "days": 9, "assignments": { "a": { "units": 0.6, "hours": 40 } } }, { "kind": "units", "id": "a", "units": 1 }], + "expect": { "days": 5, "assignments": { "a": { "units": 1, "hours": 40 } } } } + ] +} From a6eea701b1aa59eca8d3198937d87eb340e8efcd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 05:13:19 +0000 Subject: [PATCH 109/152] =?UTF-8?q?test(renderer):=20mijlpaal-met-duur-hit?= =?UTF-8?q?test=20klikt=20op=20de=20echte=20balk=20=E2=80=94=20weekendflak?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check-milestone-duration-render.ts` klikte op een vaste x (20 px) in rij 1. Een nieuw project start op "vandaag" (`defaults.ts`); valt vandaag in het weekend, dan snapt de taak naar maandag en begint de balk twee dagen rechts van de rand, zodat de vaste x haar miste — gezien op zaterdag 2026-09-05, lokaal én op CI (run 382), op origin/main precies zo; op weekdagen groen. De test vraagt nu de werkelijk getekende balk op via `getTaskBarRect` en klikt daarin; de echte-mijlpaal-controle blijft ongewijzigd. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0159unb6VzxUx7WG4w3a2ARA --- tests/planning/check-milestone-duration-render.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/planning/check-milestone-duration-render.ts b/tests/planning/check-milestone-duration-render.ts index ac95aafb9..976443e80 100644 --- a/tests/planning/check-milestone-duration-render.ts +++ b/tests/planning/check-milestone-duration-render.ts @@ -142,7 +142,14 @@ if (renderError === null) { // 3. getTaskBarBounds: mijlpaal-met-duur is sleep-/resize-baar zoals elke balk-taak; de echte // mijlpaal blijft geweigerd (geen visuele breedte om aan te slepen). const rowMidY = (i: number) => HDRH + i * ROWH + ROWH / 2; - const hitWithDuration = renderer.getTaskBarBounds(TTW + 20, rowMidY(1)); + // Klik op de WERKELIJK getekende balk (`getTaskBarRect`), niet op een vaste x: een nieuw project + // start op "vandaag" (`defaults.ts`), en valt vandaag in het weekend dan snapt de taak naar + // maandag en begint de balk pas twee dagen rechts van de rand — de oude vaste `TTW + 20` miste + // haar dan (gezien 2026-09-05, een zaterdag: lokaal én op CI rood, op weekdagen groen). + const rectWithDuration = renderer.getTaskBarRect('ms-met-duur'); + ok(`mijlpaal-met-duur: getTaskBarRect levert een balk (kreeg: ${JSON.stringify(rectWithDuration)})`, rectWithDuration !== null); + const hitX = rectWithDuration ? Math.min(rectWithDuration.left + 4, rectWithDuration.right - 1) : TTW + 20; + const hitWithDuration = renderer.getTaskBarBounds(hitX, rowMidY(1)); ok(`mijlpaal-met-duur: getTaskBarBounds accepteert haar (kreeg: ${JSON.stringify(hitWithDuration && { edge: hitWithDuration.edge })})`, hitWithDuration !== null && hitWithDuration.task.id === 'ms-met-duur'); const hitReal = renderer.getTaskBarBounds(TTW + 20, rowMidY(2)); ok('echte mijlpaal: getTaskBarBounds weigert haar nog steeds', hitReal === null); From 912039d2af073f58dfa3e3a4dafc3060e9ee3c63 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 05:18:27 +0000 Subject: [PATCH 110/152] =?UTF-8?q?fix(engine):=20werkdriehoek=20=E2=80=94?= =?UTF-8?q?=20reviewcorrecties=20op=20=C2=A74.3/=C2=A76.7=20en=20meetlat-d?= =?UTF-8?q?ekking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uit de onafhankelijke review van bouwstap 3: (1) onder FIXED_RATE schreef een inzetwijziging onterecht een werkveld — de inzetregel laat een afwezig veld afwezig (§4.3), anders wijkt een opgeslagen W na afronding van R × I af; FIXED_DURATION_WORK legt het werk juist wél vast. (2) Een afgesloten toewijzing (restwerk 0) kon R naar 0 trekken en een restduur 0 als invoer gaf Infinity-inzet met `ok: true`; één uitgangsvalidatie (§6.7) weigert nu elke uitkomst met R ≤ 0 of niet-eindige inzet. (3) NaN-inzet in de invoer gaat niet meer stil door de verdeelsleutel. (4) De loze `followRule` in `applyTaskWorkEdit` is weg. Meetlat: vijf ongedekte cellen gedekt (FDR eraf, FR eraf, FR+ED:false eraf, FDW inzet, FDW werk) plus een taakniveau-werkcase; "zwakste bewijs telt" als labelregel (wt-04/10/15 → reasoned); strikte sleutelvalidatie van het JSON-bestand, exacte nummerverzameling 1…31, "geweigerd ⇒ invoer onaangeraakt", en eigenschappen voor `effortDriven` op de verkeerde regel. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0159unb6VzxUx7WG4w3a2ARA --- ...26-09-04-spec-taaktypes-opgeslagen-werk.md | 2 +- src/engine/work/workTriangle.ts | 94 ++++++++++++------- src/types/workRule.ts | 3 +- tests/planning/check-work-triangle.ts | 83 +++++++++++++++- tests/planning/work-triangle-cases.json | 38 ++++++-- 5 files changed, 179 insertions(+), 41 deletions(-) diff --git a/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md b/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md index 8420d3f9d..fed99c48c 100644 --- a/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md +++ b/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md @@ -578,7 +578,7 @@ twee keer aan `formatRegistry.ts`, `ifcPsets.ts` en de contour-adapters wordt ge |---|---|---| | 1 | `WorkRule`, `Task.workRule`, `Project.defaultWorkRule`, de drie werkvelden; `DOCUMENT_FIELDS`; IFC-psets `OPS_WorkRule` + `OPS_AssignmentWork`; ext-contract alleen-lezen. **Geen gedrag.** | `check-ifc-roundtrip` (fixture + canon), `check-document-contract`, `verify:cycles`; elk bestaand bestand byte-identiek | | 2 | Importvertaling (§4.2) voor .mpp, MSPDI (incl. ``/`` lezen), P6 XML, XER; export MSPDI/P6 XML met de werkvelden; waarschuwingen | fidelity-poort blijft 0 (`GOAL_ZERO_DEVIATIONS`); `check-mpp-*`, `check-adapters-*`; nieuwe fixtures per bron | -| 3 **(gebouwd 2026-09-05)** | Pure module `src/engine/work/workTriangle.ts`: `applyDurationEdit`, `applyUnitsEdit`, `applyWorkEdit`, `applyTaskWorkEdit`, `applyAssignmentAdded`, `applyAssignmentRemoved`, `applyRuleChange` — invoer: de RESTERENDE toestand (`TriangleState`: regel, bewaard `effortDriven`, restduur in werkminuten, per toewijzing inzet + optioneel restwerk + `drivesDuration`), uitvoer: een nieuwe toestand of een weigering met reden; nooit een store. `WorkRule` in `src/types/workRule.ts` (verhuist in stap 1 naar `task.ts`). Plus `work-triangle-cases.json` (§9) | `tests/planning/check-work-triangle.ts`: 242 checks, 33 cases (20 documented, 11 reasoned, 2 decided, 0 measured) | +| 3 **(gebouwd 2026-09-05)** | Pure module `src/engine/work/workTriangle.ts`: `applyDurationEdit`, `applyUnitsEdit`, `applyWorkEdit`, `applyTaskWorkEdit`, `applyAssignmentAdded`, `applyAssignmentRemoved`, `applyRuleChange` — invoer: de RESTERENDE toestand (`TriangleState`: regel, bewaard `effortDriven`, restduur in werkminuten, per toewijzing inzet + optioneel restwerk + `drivesDuration`), uitvoer: een nieuwe toestand of een weigering met reden; nooit een store. `WorkRule` in `src/types/workRule.ts` (verhuist in stap 1 naar `task.ts`). Plus `work-triangle-cases.json` (§9) | `tests/planning/check-work-triangle.ts`: 332 checks, 39 cases (21 documented, 16 reasoned, 2 decided, 0 measured; bij een mengvorm telt het zwakste bewijs) plus eigenschappen: geen veld geschreven onder de inzetregels, typewissel in alle 16 richtingen getalvrij, weigering bij restduur ≤ 0 of niet-eindige inzet (§6.7), `effortDriven` zonder effect buiten de twee 8-B-cellen | | 4 | Bedrading: `taskSlice.updateTask` (R), `resourceSlice.updateAssignment` (I, W), `taskEditPlan`/`assignmentPlan` (raster), `createMcpTransactions` (MCP-tweeling — ZEKER dat die spiegel bestaat), `assignmentDayUnits` vierde bron (§6.1) | bestaande suites + `check-work-triangle` via de store; `cases-resource-load.json` uitgebreid | | 5 | UI: instelling, auto-ontsluiting + melding, paneel/dialoog, rasterkolommen, i18n 14 locales, gidsen nl+en (+12 vertalingen, anders faalt `verify:docs` niet maar is de functie onvindbaar) | `verify:i18n`, `verify:docs`, browserspec `tests/browser/work-rule.spec.ts` (echte toetsen/klikken, store-asserties) | | 6 | Resource erbij/eraf (§5 rij 4–5) inclusief contourregels (§6.3) — **vereist beslispunt 8** | cases 4, 5, 9, 10, 15, 19, 20, 23, 29, 30 | diff --git a/src/engine/work/workTriangle.ts b/src/engine/work/workTriangle.ts index 7c84f97d9..49cccf12d 100644 --- a/src/engine/work/workTriangle.ts +++ b/src/engine/work/workTriangle.ts @@ -90,7 +90,9 @@ export function remainingWorkOf(a: TriangleAssignment, remainingMinutes: number) } /** Afronding van een afgeleide restduur (regel 3): naar boven op hele werkdagen in dagmodus, - * op hele minuten in uurmodus. Nooit onder één slot resp. één minuut. */ + * op hele minuten in uurmodus. Nooit onder één slot resp. één minuut. Een ongeldig `slotMinutes` + * (≤ 0) valt in dagmodus stil terug op minuutafronding — de bedrading levert altijd + * `hoursPerDay × 60`, dit is de tweede grendel. */ export function roundUpRemaining(minutes: number, state: Pick): number { if (!Number.isFinite(minutes) || minutes <= 0) return 0; if (state.wholeDays) { @@ -100,8 +102,26 @@ export function roundUpRemaining(minutes: number, state: Pick= 0)) { + return { ok: false, reason: 'invalid-work' }; + } + } + return { ok: true, state }; +} + /** R = max_i(W_i / I_i) over de werkresources, met W_i afgeleid waar het veld ontbreekt (regel 2), - * daarna afgerond (regel 3). Zonder werkresources blijft de huidige restduur staan. */ + * daarna afgerond (regel 3). Zonder werkresources blijft de huidige restduur staan; is al het + * restwerk 0, dan komt er 0 uit en weigert `validated` de bewerking (§6.7). */ function derivedRemaining(state: TriangleState, assignments: readonly TriangleAssignment[]): number { let max = 0; let any = false; @@ -115,7 +135,11 @@ function derivedRemaining(state: TriangleState, assignments: readonly TriangleAs } /** Nadat R is herleid: de ANDERE toewijzingen volgen de beschermde hoek van de regel — werk vast ⇒ - * inzet = W / R; inzet vast ⇒ een aanwezig werkveld wordt R × I, een afwezig veld blijft afwezig. */ + * inzet = W / R; inzet vast ⇒ een aanwezig werkveld wordt R × I, een afwezig veld blijft afwezig. + * Let op bij de inzetbeschermende regels: een veldloze toewijzing heeft afgeleid werk R × I, dus + * wordt R door een ándere toewijzing naar boven afgerond, dan groeit haar werk met die afronding + * mee. Dat volgt uit "afwezig ⇒ afgeleid" (de inzet is wat de gebruiker koos) en is de enige plek + * waar afrondingsruis in een later vastgelegd werkveld kan belanden. */ function followRule( state: TriangleState, assignments: readonly TriangleAssignment[], @@ -138,16 +162,18 @@ function followRule( /** Verdeel `totalWork` naar rato van inzet over de werkresources (regel 1). Niet-werkresources * blijven onaangeraakt. */ function splitByUnits(assignments: readonly TriangleAssignment[], totalWork: number): TriangleAssignment[] { - const driving = assignments.filter((a) => a.drivesDuration); + const driving = assignments.filter((a) => a.drivesDuration && isPositive(a.unitsPerDay)); const sumUnits = driving.reduce((s, a) => s + a.unitsPerDay, 0); if (sumUnits <= 0) return [...assignments]; - return assignments.map((a) => (a.drivesDuration + return assignments.map((a) => (a.drivesDuration && isPositive(a.unitsPerDay) ? { ...a, remainingWorkMinutes: totalWork * (a.unitsPerDay / sumUnits) } : a)); } function totalRemainingWork(state: TriangleState, assignments: readonly TriangleAssignment[]): number { - return assignments.reduce((s, a) => (a.drivesDuration ? s + remainingWorkOf(a, state.remainingMinutes) : s), 0); + return assignments.reduce((s, a) => (a.drivesDuration && isPositive(a.unitsPerDay) + ? s + remainingWorkOf(a, state.remainingMinutes) + : s), 0); } const drivingCount = (assignments: readonly TriangleAssignment[]): number => @@ -175,7 +201,7 @@ export function applyDurationEdit(state: TriangleState, newRemainingMinutes: num if (a.remainingWorkMinutes === undefined) return a; return { ...a, remainingWorkMinutes: R * a.unitsPerDay }; }); - return { ok: true, state: { ...state, remainingMinutes: R, assignments } }; + return validated({ ...state, remainingMinutes: R, assignments }); } /** @@ -189,24 +215,28 @@ export function applyUnitsEdit(state: TriangleState, assignmentId: string, newUn if (idx < 0) return { ok: false, reason: 'unknown-assignment' }; const target = state.assignments[idx]; if (!target.drivesDuration || ruleProtectsDuration(state.rule)) { + // Duur beschermd: werk = R × I'. FIXED_DURATION_WORK legt dat werk vast (werkbeschermend); + // FIXED_DURATION_RATE herschrijft alleen een veld dat er al was (§4.3: afwezig blijft afwezig). + const writeWork = target.drivesDuration && (ruleProtectsWork(state.rule) || target.remainingWorkMinutes !== undefined); const edited: TriangleAssignment = { ...target, unitsPerDay: newUnitsPerDay, - ...(target.drivesDuration && target.remainingWorkMinutes !== undefined - ? { remainingWorkMinutes: state.remainingMinutes * newUnitsPerDay } - : {}), + ...(writeWork ? { remainingWorkMinutes: state.remainingMinutes * newUnitsPerDay } : {}), }; - return { ok: true, state: { ...state, assignments: replaceAt(state.assignments, idx, edited) } }; + return validated({ ...state, assignments: replaceAt(state.assignments, idx, edited) }); } - const edited: TriangleAssignment = { - ...target, - unitsPerDay: newUnitsPerDay, - remainingWorkMinutes: remainingWorkOf(target, state.remainingMinutes), - }; + // Duur herleid uit W_oud / I'. FIXED_WORK legt W_oud vast (§4.3 a); FIXED_RATE beschermt de inzet + // en schrijft géén veld: het werk van deze toewijzing blijft afgeleid (R' × I'), zodat een + // afgeronde R niet een opgeslagen W achterlaat die van R × I afwijkt. + const workBefore = remainingWorkOf(target, state.remainingMinutes); + const probe: TriangleAssignment = { ...target, unitsPerDay: newUnitsPerDay, remainingWorkMinutes: workBefore }; + const R = derivedRemaining(state, replaceAt(state.assignments, idx, probe)); + const edited: TriangleAssignment = ruleProtectsWork(state.rule) + ? probe + : { ...target, unitsPerDay: newUnitsPerDay }; const withEdit = replaceAt(state.assignments, idx, edited); - const R = derivedRemaining(state, withEdit); const assignments = followRule(state, withEdit, R, new Set([assignmentId])); - return { ok: true, state: { ...state, remainingMinutes: R, assignments } }; + return validated({ ...state, remainingMinutes: R, assignments }); } /** @@ -219,16 +249,16 @@ export function applyWorkEdit(state: TriangleState, assignmentId: string, newWor if (idx < 0) return { ok: false, reason: 'unknown-assignment' }; const target = state.assignments[idx]; if (!target.drivesDuration) { - return { ok: true, state: { ...state, assignments: replaceAt(state.assignments, idx, { ...target, remainingWorkMinutes: newWorkMinutes }) } }; + return validated({ ...state, assignments: replaceAt(state.assignments, idx, { ...target, remainingWorkMinutes: newWorkMinutes }) }); } if (ruleProtectsDuration(state.rule)) { const edited: TriangleAssignment = { ...target, remainingWorkMinutes: newWorkMinutes, unitsPerDay: newWorkMinutes / state.remainingMinutes }; - return { ok: true, state: { ...state, assignments: replaceAt(state.assignments, idx, edited) } }; + return validated({ ...state, assignments: replaceAt(state.assignments, idx, edited) }); } const withEdit = replaceAt(state.assignments, idx, { ...target, remainingWorkMinutes: newWorkMinutes }); const R = derivedRemaining(state, withEdit); const assignments = followRule(state, withEdit, R, new Set([assignmentId])); - return { ok: true, state: { ...state, remainingMinutes: R, assignments } }; + return validated({ ...state, remainingMinutes: R, assignments }); } /** @@ -249,12 +279,12 @@ export function applyTaskWorkEdit(state: TriangleState, newTotalWorkMinutes: num const assignments = split.map((a) => (a.drivesDuration && a.remainingWorkMinutes !== undefined && a.remainingWorkMinutes > 0 ? { ...a, unitsPerDay: a.remainingWorkMinutes / state.remainingMinutes } : a)); - return { ok: true, state: { ...state, assignments } }; + return validated({ ...state, assignments }); } + // Alle werkresources zijn hier "bewerkt" (elk kreeg zijn deel), dus er is niets dat nog moet + // volgen: R = max_i(W_i / I_i) en de inzet blijft zoals ze was. const R = derivedRemaining(state, split); - const edited = new Set(split.filter((a) => a.drivesDuration).map((a) => a.id)); - const assignments = followRule(state, split, R, edited); - return { ok: true, state: { ...state, remainingMinutes: R, assignments } }; + return validated({ ...state, remainingMinutes: R, assignments: split }); } /** @@ -272,7 +302,7 @@ export function applyAssignmentAdded( if (!isPositive(added.unitsPerDay)) return { ok: false, reason: 'invalid-units' }; if (state.assignments.some((a) => a.id === added.id)) return { ok: false, reason: 'duplicate-assignment' }; const newcomer: TriangleAssignment = { id: added.id, unitsPerDay: added.unitsPerDay, drivesDuration: added.drivesDuration ?? true }; - const plain = { ok: true as const, state: { ...state, assignments: [...state.assignments, newcomer] } }; + const plain = validated({ ...state, assignments: [...state.assignments, newcomer] }); if (!newcomer.drivesDuration || drivingCount(state.assignments) === 0) return plain; if (state.rule === 'FIXED_DURATION_RATE') return plain; if (state.rule === 'FIXED_RATE' && state.effortDriven === false) return plain; @@ -282,10 +312,10 @@ export function applyAssignmentAdded( const assignments = split.map((a) => (a.drivesDuration && a.remainingWorkMinutes !== undefined && a.remainingWorkMinutes > 0 ? { ...a, unitsPerDay: a.remainingWorkMinutes / state.remainingMinutes } : a)); - return { ok: true, state: { ...state, assignments } }; + return validated({ ...state, assignments }); } const R = derivedRemaining(state, split); - return { ok: true, state: { ...state, remainingMinutes: R, assignments: split } }; + return validated({ ...state, remainingMinutes: R, assignments: split }); } /** @@ -299,7 +329,7 @@ export function applyAssignmentRemoved(state: TriangleState, assignmentId: strin if (idx < 0) return { ok: false, reason: 'unknown-assignment' }; const removed = state.assignments[idx]; const rest = state.assignments.filter((a) => a.id !== assignmentId); - const plain = { ok: true as const, state: { ...state, assignments: rest } }; + const plain = validated({ ...state, assignments: rest }); if (!removed.drivesDuration || drivingCount(rest) === 0) return plain; if (state.rule === 'FIXED_DURATION_RATE') return plain; if (state.rule === 'FIXED_RATE' && state.effortDriven === false) return plain; @@ -309,10 +339,10 @@ export function applyAssignmentRemoved(state: TriangleState, assignmentId: strin const assignments = split.map((a) => (a.drivesDuration && a.remainingWorkMinutes !== undefined && a.remainingWorkMinutes > 0 ? { ...a, unitsPerDay: a.remainingWorkMinutes / state.remainingMinutes } : a)); - return { ok: true, state: { ...state, assignments } }; + return validated({ ...state, assignments }); } const R = derivedRemaining(state, split); - return { ok: true, state: { ...state, remainingMinutes: R, assignments: split } }; + return validated({ ...state, remainingMinutes: R, assignments: split }); } /** @@ -326,7 +356,7 @@ export function applyRuleChange(state: TriangleState, rule: WorkRule): TriangleR ? { ...a, remainingWorkMinutes: remainingWorkOf(a, state.remainingMinutes) } : a)) : [...state.assignments]; - return { ok: true, state: { ...state, rule, assignments } }; + return validated({ ...state, rule, assignments }); } function replaceAt(list: readonly TriangleAssignment[], idx: number, item: TriangleAssignment): TriangleAssignment[] { diff --git a/src/types/workRule.ts b/src/types/workRule.ts index b0ece7bc6..3c09f710d 100644 --- a/src/types/workRule.ts +++ b/src/types/workRule.ts @@ -34,5 +34,6 @@ const WORK_RULE_TABLE = { * lijst exact de union dekt, zoals `TASK_TYPES` in `task.ts`. */ export const WORK_RULES = Object.keys(WORK_RULE_TABLE) as WorkRule[]; -/** De werkregel die geldt wanneer een taak (en het project) er geen draagt: het huidige gedrag. */ +/** De werkregel die geldt wanneer een taak (en het project) er geen draagt: het huidige gedrag. + * Nog nergens gelezen — de bedrading (bouwstap 1/4) haalt 'm hier vandaan. */ export const DEFAULT_WORK_RULE: WorkRule = 'FIXED_DURATION_RATE'; diff --git a/tests/planning/check-work-triangle.ts b/tests/planning/check-work-triangle.ts index 300b1b778..1269df5e7 100644 --- a/tests/planning/check-work-triangle.ts +++ b/tests/planning/check-work-triangle.ts @@ -62,6 +62,40 @@ interface Case { const here = dirname(fileURLToPath(import.meta.url)); const file = JSON.parse(readFileSync(join(here, 'work-triangle-cases.json'), 'utf8')) as { cases: Case[] }; +// Sleutelvalidatie (zelfde motief als de batterij-inventaris in run.sh): een typefout in een sleutel +// (`"dayz"`, `"evidance"`) mag niet stil een case zonder asserties opleveren. +const KEYS = { + case: ['id', 'nr', 'evidence', 'source', 'scope', 'state', 'edits', 'expect'], + state: ['rule', 'effortDriven', 'days', 'minutes', 'hoursPerDay', 'wholeDays', 'assignments'], + assignment: ['id', 'units', 'hours', 'material'], + edit: ['kind', 'id', 'days', 'minutes', 'units', 'hours', 'material', 'rule', 'assignments'], + expect: ['days', 'minutes', 'assignments', 'rejected'], + expectAssignment: ['units', 'hours', 'derivedHours'], +} as const; +const EVIDENCE = ['documented', 'reasoned', 'decided', 'measured']; +function checkKeys(label: string, obj: object, allowed: readonly string[]): void { + for (const k of Object.keys(obj)) if (!allowed.includes(k)) diffs.push(`${label}: onbekende sleutel "${k}"`); +} +for (const c of file.cases) { + checkKeys(c.id, c, KEYS.case); + if (!EVIDENCE.includes(c.evidence)) diffs.push(`${c.id}: onbekend bewijslabel "${String(c.evidence)}"`); + if (c.state) { + checkKeys(`${c.id}.state`, c.state, KEYS.state); + if (!WORK_RULES.includes(c.state.rule)) diffs.push(`${c.id}: onbekende werkregel "${String(c.state.rule)}"`); + for (const a of c.state.assignments) checkKeys(`${c.id}.state.${a.id}`, a, KEYS.assignment); + } + for (const e of c.edits ?? []) { + checkKeys(`${c.id}.edit`, e, KEYS.edit); + if (e.kind === 'check' && e.assignments) for (const [id, ex] of Object.entries(e.assignments)) checkKeys(`${c.id}.check.${id}`, ex, KEYS.expectAssignment); + } + if (c.expect) { + checkKeys(`${c.id}.expect`, c.expect, KEYS.expect); + if (c.expect.rejected === undefined && !c.expect.assignments) diffs.push(`${c.id}: expect zonder assignments én zonder rejected — toetst niets`); + for (const [id, ex] of Object.entries(c.expect.assignments ?? {})) checkKeys(`${c.id}.expect.${id}`, ex, KEYS.expectAssignment); + } +} +checks += file.cases.length; + function toState(c: CaseState): TriangleState { const hpd = c.hoursPerDay ?? 8; const slotMinutes = hpd * 60; @@ -118,20 +152,24 @@ for (const c of file.cases) { if (!c.state || !c.edits || !c.expect) { diffs.push(`${c.id}: onvolledige case (state/edits/expect)`); continue; } let state = toState(c.state); let rejected: string | undefined; + let before = ''; for (const e of c.edits) { if (e.kind === 'check') { assertExpect(`${c.id} (tussenstand)`, state, e); continue; } + before = JSON.stringify(state); const r = apply(state, e); if (!r.ok) { rejected = r.reason; break; } state = r.state; } if (c.expect.rejected !== undefined) { ok(`${c.id}: verwacht weigering ${c.expect.rejected}, kreeg ${rejected ?? 'geen'}`, rejected === c.expect.rejected); + // "geweigerd, niets gewijzigd": de module is puur, dus de invoer mag niet gemuteerd zijn. + ok(`${c.id}: invoer onaangeraakt na weigering`, JSON.stringify(state) === before); continue; } if (rejected) { diffs.push(`${c.id}: onverwacht geweigerd (${rejected})`); continue; } assertExpect(c.id, state, c.expect); } -ok('meetlat: 31 genummerde bewerkingen aanwezig', new Set(file.cases.map((c) => c.nr)).size === 31); +ok('meetlat: precies de nummers 1…31 uit spec §9 aanwezig', JSON.stringify([...new Set(file.cases.map((c) => c.nr))].sort((a, b) => a - b)) === JSON.stringify(Array.from({ length: 31 }, (_, i) => i + 1))); ok('meetlat: geen enkele case is al gemeten (measured) — anders hoort de spec bijgewerkt', (evidenceCount.measured ?? 0) === 0); // ── (b) eigenschappen ─────────────────────────────────────────────────────────────────────────── @@ -200,6 +238,49 @@ for (const from of WORK_RULES) { ok('taakwerk (werk vast): 120 u verdeeld 80/40 ⇒ R = max(80/8, 40/4) = 10 d', r2.ok && r2.state.remainingMinutes === 4800 && r2.state.assignments.every((a) => Math.abs(a.unitsPerDay - (a.id === 'a' ? 1 : 0.5)) < 1e-9)); } +// `effortDriven` op een andere regel dan de twee beslispunt-8-B-cellen heeft géén effect. +// (Vergelijking zonder de vlag zelf: die reist onveranderd mee in de uitkomst.) +const sansFlag = (r: TriangleResult): string => JSON.stringify(r.ok ? { ...r.state, effortDriven: undefined } : r); +{ + const two: TriangleState = { ...base, rule: 'FIXED_WORK' }; + for (const ed of [true, false]) { + const a = applyAssignmentAdded({ ...two, effortDriven: ed }, { id: 'c', unitsPerDay: 1 }); + const b = applyAssignmentAdded(two, { id: 'c', unitsPerDay: 1 }); + ok(`effortDriven=${ed} op FIXED_WORK: resource erbij identiek aan zonder vlag`, sansFlag(a) === sansFlag(b)); + const d1 = applyDurationEdit({ ...two, effortDriven: ed }, 4800); + const d2 = applyDurationEdit(two, 4800); + ok(`effortDriven=${ed} op FIXED_WORK: duurwijziging identiek aan zonder vlag`, sansFlag(d1) === sansFlag(d2)); + } + const fdr1 = applyDurationEdit({ ...base, effortDriven: true }, 4800); + const fdr2 = applyDurationEdit(base, 4800); + ok('effortDriven=true op FIXED_DURATION_RATE: geen effect', sansFlag(fdr1) === sansFlag(fdr2)); + const fr1 = applyDurationEdit({ ...base, rule: 'FIXED_RATE', effortDriven: false }, 4800); + const fr2 = applyDurationEdit({ ...base, rule: 'FIXED_RATE' }, 4800); + ok('effortDriven=false op FIXED_RATE: alleen erbij/eraf wijkt af, een duurwijziging niet', sansFlag(fr1) === sansFlag(fr2)); + const first = applyAssignmentAdded({ ...base, rule: 'FIXED_RATE', effortDriven: false, assignments: [] }, { id: 'a', unitsPerDay: 1 }); + ok('FIXED_RATE + effortDriven=false: eerste werkresource ⇒ afgeleid werk, duur ongewijzigd', first.ok && first.state.remainingMinutes === 2400 && first.state.assignments[0].remainingWorkMinutes === undefined); +} +// §6.7: een uitkomst met R ≤ 0 of een niet-eindige inzet wordt geweigerd — restwerk 0 (afgesloten +// toewijzing) en een restduur van 0 als invoer zijn de twee randen die dat kunnen veroorzaken. +{ + const closed: TriangleState = { ...base, rule: 'FIXED_WORK', assignments: [{ id: 'a', unitsPerDay: 1, drivesDuration: true, remainingWorkMinutes: 0 }] }; + ok('restwerk 0: inzetwijziging geweigerd (R zou 0 worden)', (() => { const r = applyUnitsEdit(closed, 'a', 2); return !r.ok && r.reason === 'invalid-duration'; })()); + ok('restwerk 0: resource erbij geweigerd (R zou 0 worden)', !applyAssignmentAdded(closed, { id: 'b', unitsPerDay: 1 }).ok); + ok('restwerk 0: duurwijziging mag (werk 0 blijft 0, inzet blijft)', (() => { const r = applyDurationEdit(closed, 4800); return r.ok && r.state.assignments[0].remainingWorkMinutes === 0 && r.state.assignments[0].unitsPerDay === 1; })()); + const zeroR: TriangleState = { ...base, remainingMinutes: 0 }; + ok('restduur 0 als invoer: werkinvoer geweigerd (geen Infinity-inzet)', (() => { const r = applyWorkEdit(zeroR, 'a', 2400); return !r.ok && r.reason === 'invalid-duration'; })()); + ok('restduur 0 als invoer: resource erbij geweigerd', !applyAssignmentAdded(zeroR, { id: 'c', unitsPerDay: 1 }).ok); + const nanUnits: TriangleState = { ...base, rule: 'FIXED_WORK', assignments: [{ id: 'a', unitsPerDay: 1, drivesDuration: true }, { id: 'x', unitsPerDay: Number.NaN, drivesDuration: true }] }; + ok('NaN-inzet in de invoer: resource erbij geweigerd, niet stil NaN-werk', !applyAssignmentAdded(nanUnits, { id: 'c', unitsPerDay: 1 }).ok); +} +// FIXED_RATE: een inzetwijziging schrijft géén werkveld (§4.3) — ook niet bij afronding; het werk +// blijft afgeleid R × I en volgt de afgeronde R. +{ + const s: TriangleState = { ...base, rule: 'FIXED_RATE', assignments: [{ id: 'a', unitsPerDay: 1, drivesDuration: true }] }; + const r = applyUnitsEdit(s, 'a', 0.6); + ok('FIXED_RATE: inzet → 0,6 ⇒ R = 9 d, werkveld afwezig, afgeleid 43,2 u', r.ok && r.state.remainingMinutes === 4320 + && r.state.assignments[0].remainingWorkMinutes === undefined && Math.abs(remainingWorkOf(r.state.assignments[0], 4320) - 2592) < 1e-9); +} // Afronding: naar boven, nooit onder één slot; uurmodus op hele minuten. near('afronding: 2,5 d → 3 d', roundUpRemaining(1200, base), 1440, 0); near('afronding: precies 3 d blijft 3 d', roundUpRemaining(1440, base), 1440, 0); diff --git a/tests/planning/work-triangle-cases.json b/tests/planning/work-triangle-cases.json index 355189e60..8373731c4 100644 --- a/tests/planning/work-triangle-cases.json +++ b/tests/planning/work-triangle-cases.json @@ -5,7 +5,9 @@ "want die glob voert een bestand aan de CPM-harnas en de batterij-inventaris in run.sh).", "Per case: `nr` = het nummer in de spec; `evidence` = documented (letterlijk in Microsoft-/Oracle-", "documentatie, bron in `source`), reasoned (beredeneerd), decided (eigenaarsbesluit) of measured", - "(gemeten in MS Project/P6 — nog geen enkele). `scope: store` = valt buiten de pure module", + "(gemeten in MS Project/P6 — nog geen enkele). Bij een mengvorm telt het ZWAKSTE bewijs: rust het", + "verwachte getal mede op een beredeneerde regel (afronding §6.1, verdeelsleutel §5), dan `reasoned`,", + "ook al is de richting gedocumenteerd — `source` noemt beide. `scope: store` = valt buiten de pure module", "(contour/undo) en wordt door deze check overgeslagen maar wel geteld.", "Uitgangssituatie tenzij anders gezegd: dagtaak, 8 u/dag, 5 werkdagen, één werkresource op 1,0 ⇒ 40 u.", "`state` beschrijft het RESTERENDE deel (spec §5: verrichte duur/werk zijn feiten buiten de driehoek).", @@ -20,12 +22,12 @@ { "id": "wt-02", "nr": 2, "evidence": "documented", "source": "[M1] Fixed units: revise units → duration", "state": { "rule": "FIXED_RATE", "days": 5, "assignments": [{ "id": "a", "units": 1 }] }, "edits": [{ "kind": "units", "id": "a", "units": 0.5 }], - "expect": { "days": 10, "assignments": { "a": { "units": 0.5, "hours": 40 } } } }, + "expect": { "days": 10, "assignments": { "a": { "units": 0.5, "hours": null, "derivedHours": 40 } } } }, { "id": "wt-03", "nr": 3, "evidence": "documented", "source": "[M1] Fixed units: revise work → duration", "state": { "rule": "FIXED_RATE", "days": 5, "assignments": [{ "id": "a", "units": 1 }] }, "edits": [{ "kind": "work", "id": "a", "hours": 80 }], "expect": { "days": 10, "assignments": { "a": { "units": 1, "hours": 80 } } } }, - { "id": "wt-04", "nr": 4, "evidence": "documented", "source": "[M2] fixed units + effort-driven: resource erbij verkort de duur; MSP 2,5 d, OPS rondt naar 3 d (§6.1)", + { "id": "wt-04", "nr": 4, "evidence": "reasoned", "source": "[M2] fixed units + effort-driven: resource erbij verkort de duur (documented); MSP 2,5 d, OPS rondt naar 3 d (§6.1, reasoned)", "state": { "rule": "FIXED_RATE", "days": 5, "assignments": [{ "id": "a", "units": 1 }] }, "edits": [{ "kind": "add", "id": "b", "units": 1 }], "expect": { "days": 3, "assignments": { "a": { "units": 1, "hours": 20 }, "b": { "units": 1, "hours": 20 } } } }, @@ -33,6 +35,10 @@ "state": { "rule": "FIXED_RATE", "effortDriven": false, "days": 5, "assignments": [{ "id": "a", "units": 1 }] }, "edits": [{ "kind": "add", "id": "b", "units": 1 }], "expect": { "days": 5, "assignments": { "a": { "units": 1, "hours": null, "derivedHours": 40 }, "b": { "units": 1, "hours": null, "derivedHours": 40 } } } }, + { "id": "wt-05b", "nr": 5, "evidence": "documented", "source": "[M2] niet effort-driven, spiegelbeeld: resource eraf laat de duur staan, het werk van de vertrekker vervalt (beslispunt 8-B)", + "state": { "rule": "FIXED_RATE", "effortDriven": false, "days": 5, "assignments": [{ "id": "a", "units": 1 }, { "id": "b", "units": 1 }] }, + "edits": [{ "kind": "remove", "id": "b" }], + "expect": { "days": 5, "assignments": { "a": { "units": 1, "hours": null, "derivedHours": 40 } } } }, { "id": "wt-06", "nr": 6, "evidence": "documented", "source": "[M1] Fixed duration: revise duration → work; [P3] FD&U/T: duur → units", "state": { "rule": "FIXED_DURATION_RATE", "days": 5, "assignments": [{ "id": "a", "units": 1, "hours": 40 }] }, "edits": [{ "kind": "duration", "days": 10 }], @@ -49,10 +55,26 @@ "state": { "rule": "FIXED_DURATION_RATE", "days": 5, "assignments": [{ "id": "a", "units": 1 }] }, "edits": [{ "kind": "add", "id": "b", "units": 1 }], "expect": { "days": 5, "assignments": { "a": { "units": 1, "hours": null, "derivedHours": 40 }, "b": { "units": 1, "hours": null, "derivedHours": 40 } } } }, - { "id": "wt-10", "nr": 10, "evidence": "documented", "source": "[M2]/[P3] FD&U: units/time of each resource; verdeelsleutel reasoned", + { "id": "wt-09b", "nr": 9, "evidence": "reasoned", "source": "spec §5 rij 5: FIXED_DURATION_RATE resource eraf — de enige AFGELEIDE cel (spiegelbeeld van [P3]); werk van de vertrekker vervalt, rest ongemoeid", + "state": { "rule": "FIXED_DURATION_RATE", "days": 5, "assignments": [{ "id": "a", "units": 1 }, { "id": "b", "units": 1, "hours": 40 }] }, + "edits": [{ "kind": "remove", "id": "b" }], + "expect": { "days": 5, "assignments": { "a": { "units": 1, "hours": null, "derivedHours": 40 } } } }, + { "id": "wt-08b", "nr": 8, "evidence": "reasoned", "source": "spec §6.2: werk op taakniveau naar rato van bestaand restwerk verdeeld (40 + 20 u → 120 u ⇒ 80 + 40), daarna per regel: duur vast ⇒ inzet 2,0 en 1,0", + "state": { "rule": "FIXED_DURATION_RATE", "days": 5, "assignments": [{ "id": "a", "units": 1 }, { "id": "b", "units": 0.5 }] }, + "edits": [{ "kind": "taskWork", "hours": 120 }], + "expect": { "days": 5, "assignments": { "a": { "units": 2, "hours": 80 }, "b": { "units": 1, "hours": 40 } } } }, + { "id": "wt-10", "nr": 10, "evidence": "reasoned", "source": "[M2]/[P3] FD&U: units/time of each resource (documented); verdeelsleutel §5 (reasoned)", "state": { "rule": "FIXED_DURATION_WORK", "days": 5, "assignments": [{ "id": "a", "units": 1 }] }, "edits": [{ "kind": "add", "id": "b", "units": 1 }], "expect": { "days": 5, "assignments": { "a": { "units": 0.5, "hours": 20 }, "b": { "units": 0.5, "hours": 20 } } } }, + { "id": "wt-10b", "nr": 10, "evidence": "documented", "source": "[P3] FD&U: units/time gewijzigd → units (werk = R × I, vastgelegd want werkbeschermend)", + "state": { "rule": "FIXED_DURATION_WORK", "days": 5, "assignments": [{ "id": "a", "units": 1 }] }, + "edits": [{ "kind": "units", "id": "a", "units": 0.5 }], + "expect": { "days": 5, "assignments": { "a": { "units": 0.5, "hours": 20 } } } }, + { "id": "wt-10c", "nr": 10, "evidence": "documented", "source": "[P3] FD&U: units gewijzigd → units/time (inzet = W / R)", + "state": { "rule": "FIXED_DURATION_WORK", "days": 5, "assignments": [{ "id": "a", "units": 1 }] }, + "edits": [{ "kind": "work", "id": "a", "hours": 80 }], + "expect": { "days": 5, "assignments": { "a": { "units": 2, "hours": 80 } } } }, { "id": "wt-11a", "nr": 11, "evidence": "documented", "source": "[P3] FD&U: duur → units/time (P6-lezing)", "state": { "rule": "FIXED_DURATION_WORK", "days": 5, "assignments": [{ "id": "a", "units": 1 }] }, "edits": [{ "kind": "duration", "days": 10 }], @@ -73,7 +95,7 @@ "state": { "rule": "FIXED_WORK", "days": 5, "assignments": [{ "id": "a", "units": 1 }] }, "edits": [{ "kind": "work", "id": "a", "hours": 80 }], "expect": { "days": 10, "assignments": { "a": { "units": 1, "hours": 80 } } } }, - { "id": "wt-15", "nr": 15, "evidence": "documented", "source": "[M2] fixed work altijd effort-driven; [P3] Fixed Units: extra resource → duration; verdeelsleutel reasoned", + { "id": "wt-15", "nr": 15, "evidence": "reasoned", "source": "[M2] fixed work altijd effort-driven; [P3] Fixed Units: extra resource → duration (documented); verdeelsleutel §5 en afronding §6.1 (reasoned)", "state": { "rule": "FIXED_WORK", "days": 5, "assignments": [{ "id": "a", "units": 1 }] }, "edits": [{ "kind": "add", "id": "b", "units": 1 }], "expect": { "days": 3, "assignments": { "a": { "units": 1, "hours": 20 }, "b": { "units": 1, "hours": 20 } } } }, @@ -93,6 +115,10 @@ "state": { "rule": "FIXED_WORK", "days": 3, "assignments": [{ "id": "a", "units": 1, "hours": 20 }, { "id": "b", "units": 1, "hours": 20 }] }, "edits": [{ "kind": "remove", "id": "b" }], "expect": { "days": 5, "assignments": { "a": { "units": 1, "hours": 40 } } } }, + { "id": "wt-19b", "nr": 19, "evidence": "documented", "source": "[M2] fixed units + effort-driven, resource eraf: werk blijft (20 + 20 u → 40 u op de blijver), duur langer", + "state": { "rule": "FIXED_RATE", "days": 3, "assignments": [{ "id": "a", "units": 1, "hours": 20 }, { "id": "b", "units": 1, "hours": 20 }] }, + "edits": [{ "kind": "remove", "id": "b" }], + "expect": { "days": 5, "assignments": { "a": { "units": 1, "hours": 40 } } } }, { "id": "wt-20", "nr": 20, "evidence": "documented", "source": "[M2] fixed duration effort-driven: resource eraf ⇒ inzet van de blijver stijgt", "state": { "rule": "FIXED_DURATION_WORK", "days": 5, "assignments": [{ "id": "a", "units": 0.5, "hours": 20 }, { "id": "b", "units": 0.5, "hours": 20 }] }, "edits": [{ "kind": "remove", "id": "b" }], @@ -115,7 +141,7 @@ "state": { "rule": "FIXED_WORK", "days": 5, "assignments": [] }, "edits": [{ "kind": "duration", "days": 10 }], "expect": { "days": 10, "assignments": {} } }, - { "id": "wt-27", "nr": 27, "evidence": "reasoned", "source": "bestaande isValidUnits-guard: 0 geweigerd, niets gewijzigd", + { "id": "wt-27", "nr": 27, "evidence": "reasoned", "source": "spec §9 zegt 'bestaande guard' (isValidUnits in resourceSlice): 0 geweigerd, niets gewijzigd — geen documentatiebron, dus reasoned", "state": { "rule": "FIXED_WORK", "days": 5, "assignments": [{ "id": "a", "units": 1 }] }, "edits": [{ "kind": "units", "id": "a", "units": 0 }], "expect": { "rejected": "invalid-units" } }, From 00bcb05193e737fbbaf74efea8cd98f6311821d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 05:23:03 +0000 Subject: [PATCH 111/152] =?UTF-8?q?docs(spec):=20beslispunten=208=E2=80=93?= =?UTF-8?q?10=20genomen=20=E2=80=94=20optie=20B,=20drie=20werkvelden,=20sp?= =?UTF-8?q?anne-vereenvoudiging=20+=20TODO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eigenaarsbesluiten van 2026-09-05 vastgelegd in spec §3.3 (met de bewaarde afwegingen), de TODO-sectie afgevinkt en het vervolgpunt "per-toewijzing- spanne" uitgeschreven; README-status bijgewerkt. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0159unb6VzxUx7WG4w3a2ARA --- docs/TODO.md | 27 ++++++++++--------- docs/superpowers/README.md | 2 +- ...26-09-04-spec-taaktypes-opgeslagen-werk.md | 20 +++++++++----- 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 964492a47..a2664f5b3 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -355,19 +355,22 @@ deze lijst verwijderd — wat klaar is, staat in de changelog en git-historie. > Ontwerp: `docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md` (opvolger van de > spec van 2026-08-18). Bouwt pas op een main mét de XER-import erin (tweede XER-merge eerst). -> Eigenaarsbesluiten 1–7 van 2026-09-04 staan daar in §3; drie punten staan nog open. - -- [ ] **Beslispunt 8 — twee MS Project-gedragingen passen niet in de vier P6-types** (spec §2.3): - "Fixed Units, niet effort-driven" (MSP's fabrieksinstelling) en "Fixed Duration, effort-driven". - Advies: vier types in het menu, bewaard `effortDriven` stuurt alleen de twee afwijkende cellen. - Blokkeert alleen bouwstap 6 (resource erbij/eraf). -- [ ] **Beslispunt 9 — drie optionele werkvelden per toewijzing** (begroot/verricht/resterend, de - driehoek op het restant); aanbeveling uit de XER-corpusscan, nog niet expliciet bevestigd. -- [ ] **Beslispunt 10 — meerdere toewijzingen zonder eigen spanne** (spec §6.2): OPS laat elke - toewijzing over de hele restduur lopen; MSP/P6 geven toewijzingen een eigen spanne. Later op te - heffen door `workWindowStart/Finish` te activeren. +> Eigenaarsbesluiten 1–7 (2026-09-04) en 8–10 (2026-09-05) staan daar in §3. + +- [x] **Beslispunten 8–10 genomen (2026-09-05)**, vastgelegd in spec §3.3: 8 = optie B (vier types in + het menu, bewaard `effortDriven` stuurt alleen de twee MSP-afwijkende cellen); 9 = drie optionele + werkvelden per toewijzing; 10 = de vereenvoudiging "elke toewijzing loopt over de hele restduur" + is voor deze etappe geaccepteerd — zie het vervolgpunt hieronder. +- [ ] **Per-toewijzing-spanne (vervolg op beslispunt 10).** MS Project en P6 laten de ene resource op + een taak eerder klaar zijn dan de andere; OPS laat elke toewijzing over de hele restduur lopen + (spec §6.2: verhoog je op een vast-werk-taak de inzet van één resource, dan wordt de taak korter + en gaat de ándere resource dunner over die kortere duur in plaats van eerder klaar te zijn). + `ResourceAssignment.workWindowStart/Finish` bestaat al, round-tript door IFC + (`OPS_TimephasedWindow`) en het extensiecontract, maar geen lezer vult het en geen solverstap + leest het. Activeren raakt `assignmentDayUnits` (histogram/nivelleerder/bezetting), de + renderer (balk per toewijzing?) en de MSPDI-/P6-exports (per-assignment start/finish). - [ ] **MSP-meetlat: 31 bewerkingen** (spec §9) meten in MS Project (en P6) zodra iemand het heeft; - tot dan draagt elke case `evidence: 'reasoned' | 'documented'` in `cases-taaktypes.json`. + tot dan draagt elke case `evidence: 'documented' | 'reasoned' | 'decided'` in `work-triangle-cases.json`. - [ ] **Telling `mspTaskType × effortDriven` over de `OPS_MPP_CRAWL`-set** (216 bestanden): bepaalt hoe vaak beslispunt 8 in de praktijk speelt. Het corpus is niet in de repo. - [ ] **Nivelleerder-optie "inzet verlagen bij vast werk"** (eigenaarsbesluit 7-B, 2026-09-04) als diff --git a/docs/superpowers/README.md b/docs/superpowers/README.md index f93118fb6..a0db05f4f 100644 --- a/docs/superpowers/README.md +++ b/docs/superpowers/README.md @@ -67,7 +67,7 @@ Wél verplaatst, omdat ze nergens meer bij horen: | document | status | |---|---| | `HANDOFF-2026-08-14-roadmap.md` | **actief** — wat er nog op de roadmap staat, met peildatum en afhankelijkheden | -| `specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md` | **actief ontwerp, nog niet gebouwd** — taaktypes/werkregels, opgeslagen werk per toewijzing en effort-driven bewerken; opvolger van `specs/2026-08-18-spec-taaktypes-effort-driven.md`. Bevat de MSP/P6-documentatievergelijking, de regeltabel, de meetlat (31 bewerkingen) en drie open beslispunten (8–10). Bouwt pas ná de tweede XER-merge. | +| `specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md` | **actief ontwerp, bouwstap 3 gebouwd** — taaktypes/werkregels, opgeslagen werk per toewijzing en effort-driven bewerken; opvolger van `specs/2026-08-18-spec-taaktypes-effort-driven.md`. Bevat de MSP/P6-documentatievergelijking, de regeltabel, de meetlat (31 bewerkingen) en alle eigenaarsbesluiten (1–10, genomen). De pure rekenkern (`src/engine/work/workTriangle.ts`) staat; de overige stappen bouwen pas ná de tweede XER-merge. | | `werkdagen-as-ontwerp.md` | naslag; aangehaald vanuit `timeAxis.ts`, `workdayAxis.ts` en `check-workday-axis.ts` | | `verticale-drag-ontwerp.md`, `verticale-drag-ontwerp-B.md` | naslag; twee varianten van hetzelfde ontwerp | | `modulariteit-audit.md`, `prestatie-modulariteit-audit.md` | de audits waar de P-bevindingen uit komen; aangehaald vanuit testkoppen | diff --git a/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md b/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md index fed99c48c..560b7ddf3 100644 --- a/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md +++ b/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md @@ -205,7 +205,16 @@ zet het gedrag "resource erbij/eraf" bewust als aparte, late stap zodat de rest | 6 | Reikwijdte: **alleen gewone bladtaken op werktijd, en uurtaken.** | | 7 | **Nivelleerder blijft alleen verschuiven**; "inzet verlagen bij vast werk" komt in de TODO als geavanceerde optie. | -### 3.3 Open — nog door de eigenaar te nemen +### 3.3 Genomen op 2026-09-05 (beslispunten 8–10) + +| nr | besluit | +|---|---| +| 8 | **Optie B**: vier types in het menu; het bewaarde `Task.effortDriven` stuurt alleen de twee cellen waar MS Project van P6 afwijkt (§5). Taken die in OPS worden aangemaakt krijgen het veld nooit. | +| 9 | **Drie optionele werkvelden per toewijzing** (begroot / verricht / resterend); de driehoek werkt op het restant, begroot blijft referentie (§4.3). | +| 10 | **De vereenvoudiging "elke toewijzing loopt over de hele restduur" is geaccepteerd** voor deze etappe; de per-toewijzing-spanne staat in `docs/TODO.md` als vervolg (§6.2, §12). | + +De afwegingen achter de drie punten staan hieronder bewaard zoals ze aan de eigenaar zijn +voorgelegd. **Beslispunt 8 — de twee MS Project-gedragingen die niet in de vier P6-types passen (§2.3).** @@ -215,19 +224,18 @@ zet het gedrag "resource erbij/eraf" bewust als aparte, late stap zodat de rest | B. Vier types in het menu, `effortDriven` blijft bewaarde invoer die alleen de afwijkende kolommen stuurt **(advies)** | Het bestaande veld `Task.effortDriven` blijft staan zoals het nu al round-tript. De regeltabel (§5) leest het op precies de twee cellen waar MSP afwijkt: Fixed Units/Time + `effortDriven === false` ⇒ resource erbij/eraf verandert werk in plaats van duur; Fixed Duration & Units + `effortDriven === true` ⇒ duur gewijzigd verandert werk in plaats van inzet. Taken die de gebruiker in OPS aanmaakt krijgen het veld nooit en volgen zuiver P6. | Het menu blijft vier keuzes (besluit 1 intact), MSP-bestanden gedragen zich als in MSP, en de afwijking is zichtbaar te maken als bijschrift ("uit MS Project: niet effort-driven") in plaats van als vinkje. Kost één extra rij in de regeltabel en twee testgevallen. | | C. Zes types | Het menu toont de volledige unie (vier P6 + twee MSP). | Volledig, maar precies de "MSP-verwarring" die de eigenaar niet wil importeren; verworpen door besluit 1. | -Zolang dit punt open staat, bouwt de etappe stap 1–5 van §10 zonder het te raken; stap 6 (resource -erbij/eraf) is de eerste plek waar het antwoord nodig is. +Gekozen: B. De rekenkern van bouwstap 3 implementeert die twee cellen al (`workTriangle.ts`, +`TriangleState.effortDriven`; cases wt-05, wt-05b en wt-11b). **Beslispunt 9 — drie optionele werkvelden per toewijzing (§4.3).** Aanbeveling uit de corpusscan (GEMETEN: het resttarief `remain_qty_per_hr` wijkt in vijf bestanden structureel af van het begrote tarief, tot 100 % van de rijen), uitgelegd aan de eigenaar op 2026-09-04 met het -metselwerkvoorbeeld (160 uur begroot, 80 verricht, 120 resterend). Geen bezwaar geuit, maar ook -geen expliciet akkoord. Dit ontwerp gaat uit van de drie velden. +metselwerkvoorbeeld (160 uur begroot, 80 verricht, 120 resterend). Gekozen: de drie velden. **Beslispunt 10 — meerdere toewijzingen en de taakduur (§6.2).** OPS kent geen duur per toewijzing; het ontwerp kiest de vereenvoudiging "elke toewijzing loopt over de hele restduur van de taak" en legt de per-toewijzing-spanne in de TODO. Dat wijkt af van MSP en P6, waar toewijzingen een eigen -spanne hebben. Bevestiging gevraagd. +spanne hebben. Gekozen: accepteren, mét de TODO-notitie. ## 4. Datamodel 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 112/152] =?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 113/152] 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 114/152] 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 115/152] 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 116/152] 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 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 117/152] 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 118/152] 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 119/152] 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 120/152] 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 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 121/152] 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 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 122/152] 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 123/152] 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 124/152] 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 125/152] 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 126/152] 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 127/152] 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 128/152] 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 129/152] 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 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 130/152] 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 131/152] 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 132/152] 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 133/152] 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 134/152] 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 135/152] 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 136/152] 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 137/152] 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 138/152] 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 139/152] 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 140/152] 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 189abe33d4e0480724482a5dccb1d6c9ce2852ed Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 11:56:05 +0000 Subject: [PATCH 141/152] =?UTF-8?q?feat(taaktypes):=20stap=201=20=E2=80=94?= =?UTF-8?q?=20datamodel,=20IFC-round-trip,=20ext-contract=20(geen=20gedrag?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ontwerp 2026-09-04 §4.1/§4.3/§4.4, bouwstap 1: - `Task.workRule` (WorkRule, neutraal tussen MSP en P6) en `Project.defaultWorkRule`; `ResourceAssignment.plannedWorkMinutes/ actualWorkMinutes/remainingWorkMinutes` (drie optionele werkvelden, besluit 9). Afwezig ⇒ afgeleid zoals vandaag; geen enkele solverstap of bewerking leest ze nog (dat is stap 4). - IFC: pset `OPS_WorkRule` (descriptor 15 in PER_TASK_PSETS), `DefaultWorkRule` in OPS_ProjectSettings, de drie werkvelden in het bestaande `OPS_Timephased`-JSON-blob (golden rule: niets ⇒ niets). Fixture + canon in check-ifc-roundtrip (185 groen). - Extensiecontract: ExtTask.workRule, ExtProject.defaultWorkRule, ExtAssignment-werkvelden, mappers beide kanten, contractpoort groen. - Taakraster: alleen-lezen technische kolommen `task.workRule` en `assignment.plannedWork/actualWork/remainingWork` (uren in beeld), labels in 14 locales; veld-dekking en registerpoort groen. - MCP: planner_get_task toont workRule en de werkvelden; REJECT_HINT voor workRule tot stap 7. moveProject-verdicten n/a. - Nieuw: `src/engine/work/workRuleMapping.ts` (stap 2, nog onbedraad): MSPDI-codes 0/1/2, P6 DurationType-namen, XER-tokens, en de "afwezig ⇒ afgeleid"-importregel voor de werkvelden. Planning-, MCP- en bibliotheeksuite groen; typecheck, cycles, store-boundaries, i18n groen. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0159unb6VzxUx7WG4w3a2ARA --- src/engine/moveProject.ts | 2 + src/engine/taskGrid/fieldCoverage.ts | 5 + src/engine/taskGrid/taskColumnRegistry.ts | 26 +++ src/engine/work/workRuleMapping.ts | 145 +++++++++++++++++ src/extensions/extMappers.ts | 10 ++ src/extensions/extTypes.ts | 12 ++ src/i18n/locales/ar/task.json | 6 +- src/i18n/locales/de/task.json | 6 +- src/i18n/locales/en/task.json | 6 +- src/i18n/locales/es/task.json | 6 +- src/i18n/locales/fa/task.json | 2 +- src/i18n/locales/fr/task.json | 2 +- src/i18n/locales/it/task.json | 2 +- src/i18n/locales/ja/task.json | 2 +- src/i18n/locales/ko/task.json | 2 +- src/i18n/locales/nl/task.json | 6 +- src/i18n/locales/pl/task.json | 2 +- src/i18n/locales/pt/task.json | 2 +- src/i18n/locales/tr/task.json | 2 +- src/i18n/locales/zh/task.json | 2 +- src/services/ifc/ifcPsets.ts | 23 ++- src/services/ifc/ifcReader.ts | 24 ++- src/services/ifc/ifcWriter.ts | 19 ++- src/services/mcp/tools/readTools.ts | 6 + src/services/mcp/tools/taskFields.ts | 3 + src/types/project.ts | 190 +++++++++++----------- src/types/resource.ts | 13 ++ src/types/task.ts | 11 ++ tests/planning/check-ext-contract.ts | 9 +- tests/planning/check-ifc-roundtrip.ts | 12 ++ 30 files changed, 445 insertions(+), 113 deletions(-) create mode 100644 src/engine/work/workRuleMapping.ts diff --git a/src/engine/moveProject.ts b/src/engine/moveProject.ts index a711bf7cb..30da65899 100644 --- a/src/engine/moveProject.ts +++ b/src/engine/moveProject.ts @@ -107,6 +107,7 @@ 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 + workRule: 'n/a', // taaktypes-etappe: regel, geen datum parentId: 'n/a', childIds: 'n/a', time: 'shift', // zie TASK_TIME_VERDICTS resourceIds: 'n/a', color: 'n/a', activityCodes: 'n/a', @@ -145,6 +146,7 @@ const PROJECT_VERDICTS = { statusDate: 'shift', // P6 data date (R5) progressMode: 'n/a', schedulingOptions: 'n/a', defaultTaskDurationUnit: 'n/a', + defaultWorkRule: 'n/a', // taaktypes-etappe: projectstandaard-regel, geen datum companyId: 'n/a', companyName: 'n/a', // B1: statische bibliotheekbinding, geen planningsdatum } satisfies Record; diff --git a/src/engine/taskGrid/fieldCoverage.ts b/src/engine/taskGrid/fieldCoverage.ts index de7e9f229..c0cb22632 100644 --- a/src/engine/taskGrid/fieldCoverage.ts +++ b/src/engine/taskGrid/fieldCoverage.ts @@ -69,6 +69,7 @@ export const TASK_FIELD_COVERAGE = { manuallyScheduled: field('technical', 'task.manuallyScheduled'), mspTaskType: field('technical', 'task.mspTaskType'), effortDriven: field('technical', 'task.effortDriven'), + workRule: field('technical', 'task.workRule'), parentId: field('technical', 'task.parentId'), childIds: field('technical', 'task.childIds'), time: field('composite', ...TASK_TIME_COLUMNS), @@ -149,6 +150,10 @@ export const ASSIGNMENT_FIELD_COVERAGE = { workWindowStart: field('technical', 'assignment.workWindowStart'), workWindowFinish: field('technical', 'assignment.workWindowFinish'), curveValues: field('technical', 'assignment.curve'), + // taaktypes-etappe (spec §4.3): alleen-lezen technische kolommen tot de bedradingsstap ze bewerkbaar maakt + plannedWorkMinutes: field('technical', 'assignment.plannedWork'), + actualWorkMinutes: field('technical', 'assignment.actualWork'), + remainingWorkMinutes: field('technical', 'assignment.remainingWork'), } satisfies Record; export const BASELINE_TASK_FIELD_COVERAGE = { diff --git a/src/engine/taskGrid/taskColumnRegistry.ts b/src/engine/taskGrid/taskColumnRegistry.ts index 28c2624d8..d99a50bc5 100644 --- a/src/engine/taskGrid/taskColumnRegistry.ts +++ b/src/engine/taskGrid/taskColumnRegistry.ts @@ -368,6 +368,26 @@ function assignmentWindowText( return values.length > 0 ? values.join('; ') : '—'; } +type AssignmentWorkField = 'plannedWorkMinutes' | 'actualWorkMinutes' | 'remainingWorkMinutes'; + +/** Taaktypes-etappe (spec §4.3): de drie werkvelden zijn minuten in de state, uren in beeld + * (twee decimalen, zoals de contour-dialoog). Afwezig veld ⇒ niet getoond (afgeleid). */ +function assignmentWorkText(task: Task, ctx: TaskColumnContext, field: AssignmentWorkField): string { + const values = assignments(task, ctx).flatMap(assignment => assignment[field] !== undefined + ? [`${assignmentLabel(assignment, ctx)}: ${formatScalar(Math.round((assignment[field] / 60) * 100) / 100)}`] + : []); + return values.length > 0 ? values.join('; ') : '—'; +} + +function assignmentWorkColumn(id: string, labelKey: string, field: AssignmentWorkField): TaskColumnDescriptor { + return readonlyColumn({ + id, labelKey, category: 'resources', valueKind: 'technical', + read: (task, ctx) => assignments(task, ctx).map(item => ({ assignmentId: item.id, value: item[field] })), + format: (_value, task, ctx) => assignmentWorkText(task, ctx, field), + copy: (task, ctx) => canonicalGridJson(assignments(task, ctx).map(item => ({ assignmentId: item.id, [field]: item[field] }))), + }); +} + const STRUCTURED_CLIPBOARD_SEPARATOR = '\u2063'; const ASSIGNMENT_CLIPBOARD_MARKER = `${STRUCTURED_CLIPBOARD_SEPARATOR}ops-assignment:`; const ACTIVITY_CODE_CLIPBOARD_MARKER = `${STRUCTURED_CLIPBOARD_SEPARATOR}ops-activity-code:`; @@ -602,6 +622,8 @@ function fixedTaskColumns(input: TaskColumnRegistryInput): TaskColumnDescriptor[ readonlyColumn({ id: 'task.manuallyScheduled', labelKey: 'taskGrid.columns.manuallyScheduled', category: 'technical', valueKind: 'boolean', read: task => task.manuallyScheduled }), readonlyColumn({ id: 'task.mspTaskType', labelKey: 'taskGrid.columns.mspTaskType', category: 'technical', valueKind: 'enum', read: task => task.mspTaskType }), readonlyColumn({ id: 'task.effortDriven', labelKey: 'taskGrid.columns.effortDriven', category: 'technical', valueKind: 'boolean', read: task => task.effortDriven }), + // Taaktypes-etappe (ontwerp 2026-09-04 §7): alleen-lezen tot de UI-stap 'm bewerkbaar maakt. + readonlyColumn({ id: 'task.workRule', labelKey: 'taskGrid.columns.workRule', category: 'technical', valueKind: 'enum', read: task => task.workRule }), readonlyColumn({ id: 'task.parentId', labelKey: 'taskGrid.columns.parentId', category: 'technical', valueKind: 'text', read: task => task.parentId }), readonlyColumn({ id: 'task.childIds', labelKey: 'taskGrid.columns.childIds', category: 'technical', valueKind: 'technical', read: task => task.childIds, format: value => Array.isArray(value) && value.length ? value.join(', ') : '—', copy: task => canonicalGridJson(task.childIds) }), readonlyColumn({ id: 'task.resourceIds', labelKey: 'taskGrid.columns.resourceIds', category: 'technical', valueKind: 'technical', read: task => task.resourceIds, format: value => Array.isArray(value) && value.length ? value.join(', ') : '—', copy: task => canonicalGridJson(task.resourceIds) }), @@ -817,6 +839,10 @@ function fixedAssignmentColumns(): TaskColumnDescriptor[] { }]), }), readonlyColumn({ id: 'assignment.workWindowStart', labelKey: 'taskGrid.columns.workWindowStart', category: 'resources', valueKind: 'technical', read: (task, ctx) => assignments(task, ctx).map(item => ({ assignmentId: item.id, value: item.workWindowStart })), format: (_value, task, ctx) => assignmentWindowText(task, ctx, 'workWindowStart'), copy: (task, ctx) => canonicalGridJson(assignments(task, ctx).map(item => ({ assignmentId: item.id, workWindowStart: item.workWindowStart }))) }), + // Taaktypes-etappe (spec §4.3/§7): alleen-lezen tot de UI-stap; minuten in de state, uren in beeld. + assignmentWorkColumn('assignment.plannedWork', 'taskGrid.columns.assignmentPlannedWork', 'plannedWorkMinutes'), + assignmentWorkColumn('assignment.actualWork', 'taskGrid.columns.assignmentActualWork', 'actualWorkMinutes'), + assignmentWorkColumn('assignment.remainingWork', 'taskGrid.columns.assignmentRemainingWork', 'remainingWorkMinutes'), readonlyColumn({ id: 'assignment.workWindowFinish', labelKey: 'taskGrid.columns.workWindowFinish', category: 'resources', valueKind: 'technical', read: (task, ctx) => assignments(task, ctx).map(item => ({ assignmentId: item.id, value: item.workWindowFinish })), format: (_value, task, ctx) => assignmentWindowText(task, ctx, 'workWindowFinish'), copy: (task, ctx) => canonicalGridJson(assignments(task, ctx).map(item => ({ assignmentId: item.id, workWindowFinish: item.workWindowFinish }))) }), readonlyColumn({ id: 'assignment.id', labelKey: 'taskGrid.columns.assignmentId', category: 'technical', valueKind: 'technical', read: (task, ctx) => assignments(task, ctx).map(item => item.id), format: value => Array.isArray(value) && value.length ? value.join(', ') : '—', copy: (task, ctx) => canonicalGridJson(assignments(task, ctx).map(item => item.id)) }), readonlyColumn({ id: 'assignment.taskId', labelKey: 'taskGrid.columns.assignmentTaskId', category: 'technical', valueKind: 'technical', read: (task, ctx) => assignments(task, ctx).map(item => item.taskId), format: value => Array.isArray(value) && value.length ? value.join(', ') : '—', copy: (task, ctx) => canonicalGridJson(assignments(task, ctx).map(item => item.taskId)) }), diff --git a/src/engine/work/workRuleMapping.ts b/src/engine/work/workRuleMapping.ts new file mode 100644 index 000000000..66c548db2 --- /dev/null +++ b/src/engine/work/workRuleMapping.ts @@ -0,0 +1,145 @@ +// workRuleMapping.ts — de vertaling tussen de neutrale werkregel (`WorkRule`) en de menukaarten van +// MS Project en Primavera P6 (taaktypes-ontwerp 2026-09-04, §2.3 en §4.2). Puur, geen store. +// +// De tabel (cel voor cel geverifieerd tegen [M1] en [P3] uit de spec): +// MSP Fixed Units + effort-driven ⇔ P6 Fixed Units/Time ⇔ FIXED_RATE +// MSP Fixed Units − effort-driven ⇒ FIXED_RATE + bewaard effortDriven=false (beslispunt 8-B) +// MSP Fixed Duration − effort-driven ⇔ P6 Fixed Duration & Units/Time ⇔ FIXED_DURATION_RATE +// MSP Fixed Duration + effort-driven ⇒ FIXED_DURATION_WORK + bewaard effortDriven=true (8-B) +// MSP Fixed Work (altijd effort-driven) ⇔ P6 Fixed Units ⇔ FIXED_WORK +// De twee MSP-gevallen die niet exact in een P6-type passen worden dus op het dichtstbijzijnde +// P6-type gelegd, en het bewaarde `Task.effortDriven` stuurt de twee afwijkende cellen in de +// regeltabel (`workTriangle.ts`, `TriangleState.effortDriven`). +// +// Bronnen voor de codes en namen: MSPDI `` 0/1/2 = Fixed units / Fixed duration / Fixed work +// (Microsoft Learn, "Type Element (Multiple Parents)", ZEKER); P6 `DurationType`-namen uit de +// P6 EPPM REST-documentatie van het Activity-object (ZEKER; dezelfde enum als PMXML); de XER-tokens +// komen van de XER-sessie (`DT_FixedDrtn`/`DT_FixedDUR2`/`DT_FixedQty`/`DT_FixedRate`). +import type { MspTaskType } from '@/types/task'; +import type { WorkRule } from '@/types/workRule'; + +// ── MS Project ────────────────────────────────────────────────────────────────────────────────── + +/** MSPDI `` op een ``: 0 = Fixed units, 1 = Fixed duration, 2 = Fixed work. Dezelfde + * volgorde als `mppReader.ts`'s `MSP_TASK_TYPE_VALUES`. */ +export const MSPDI_TASK_TYPE_CODE: Record = { + FIXED_UNITS: 0, + FIXED_DURATION: 1, + FIXED_WORK: 2, +}; + +const MSP_TASK_TYPES_BY_CODE: readonly MspTaskType[] = ['FIXED_UNITS', 'FIXED_DURATION', 'FIXED_WORK']; + +/** MSPDI-code → MSP-taaktype; elke andere waarde ⇒ `undefined` (geen terugval: een ontbrekend of + * ongeldig `` betekent "geen typedata", niet "Fixed Work"). */ +export function mspTaskTypeFromCode(code: number | null | undefined): MspTaskType | undefined { + if (code === null || code === undefined || !Number.isInteger(code)) return undefined; + return MSP_TASK_TYPES_BY_CODE[code]; +} + +/** MSP-taaktype + effort-driven-vlag → werkregel. Zonder taaktype geen regel. Fixed Work is in MSP + * altijd effort-driven, dus de vlag doet daar niets. */ +export function workRuleFromMsp(type: MspTaskType | undefined, effortDriven: boolean | undefined): WorkRule | undefined { + switch (type) { + case 'FIXED_UNITS': return 'FIXED_RATE'; + case 'FIXED_DURATION': return effortDriven ? 'FIXED_DURATION_WORK' : 'FIXED_DURATION_RATE'; + case 'FIXED_WORK': return 'FIXED_WORK'; + default: return undefined; + } +} + +/** + * Werkregel → MSP-taaktype + effort-driven-vlag voor de export. Een bewaard `effortDriven` (van een + * eerdere MSP-import) wint: dan komt de taak terug zoals hij binnenkwam. Zonder bewaarde vlag de + * P6-lezing van de regel: FIXED_RATE ⇒ effort-driven aan (resource erbij verkort de duur), + * FIXED_DURATION_RATE ⇒ uit, FIXED_DURATION_WORK ⇒ aan, FIXED_WORK ⇒ altijd aan. + */ +export function mspFromWorkRule(rule: WorkRule, effortDriven: boolean | undefined): { type: MspTaskType; effortDriven: boolean } { + switch (rule) { + case 'FIXED_RATE': return { type: 'FIXED_UNITS', effortDriven: effortDriven ?? true }; + case 'FIXED_DURATION_RATE': return { type: 'FIXED_DURATION', effortDriven: effortDriven ?? false }; + case 'FIXED_DURATION_WORK': return { type: 'FIXED_DURATION', effortDriven: effortDriven ?? true }; + case 'FIXED_WORK': return { type: 'FIXED_WORK', effortDriven: true }; + } +} + +// ── Primavera P6 (XML) ────────────────────────────────────────────────────────────────────────── + +/** P6 `DurationType`-namen (Activity-object; PMXML gebruikt dezelfde tekst). */ +export const P6_DURATION_TYPE_NAME: Record = { + FIXED_DURATION_RATE: 'Fixed Duration and Units/Time', + FIXED_DURATION_WORK: 'Fixed Duration and Units', + FIXED_WORK: 'Fixed Units', + FIXED_RATE: 'Fixed Units/Time', +}; + +const P6_NAME_TO_RULE: ReadonlyMap = new Map( + (Object.entries(P6_DURATION_TYPE_NAME) as [WorkRule, string][]).map(([rule, name]) => [normalizeP6Name(name), rule]), +); + +/** Tolerant voor "&" versus "and" en hoofdletters (de P6-client toont "Fixed Duration & Units", + * het XML-schema schrijft "and"); al het andere ⇒ `undefined`. */ +function normalizeP6Name(name: string): string { + return name.trim().toLowerCase().replace(/\s*&\s*/g, ' and ').replace(/\s+/g, ' '); +} + +export function workRuleFromP6DurationType(name: string | null | undefined): WorkRule | undefined { + if (!name) return undefined; + return P6_NAME_TO_RULE.get(normalizeP6Name(name)); +} + +// ── Primavera P6 (XER) ────────────────────────────────────────────────────────────────────────── + +/** XER `TASK.duration_type`-tokens (spec §4.2). `DT_FixedDUR` (niet-standaard, in p6difftool- + * fixtures gezien) staat er bewust NIET in: niet raden, het token blijft rauw bewaard. */ +export const XER_DURATION_TYPE_TOKEN: Record = { + FIXED_DURATION_RATE: 'DT_FixedDrtn', + FIXED_DURATION_WORK: 'DT_FixedDUR2', + FIXED_WORK: 'DT_FixedQty', + FIXED_RATE: 'DT_FixedRate', +}; + +const XER_TOKEN_TO_RULE: ReadonlyMap = new Map( + (Object.entries(XER_DURATION_TYPE_TOKEN) as [WorkRule, string][]).map(([rule, token]) => [token, rule]), +); + +export function workRuleFromXerDurationType(token: string | null | undefined): WorkRule | undefined { + if (!token) return undefined; + return XER_TOKEN_TO_RULE.get(token.trim()); +} + +// ── Werkvelden bij import ─────────────────────────────────────────────────────────────────────── + +/** Tolerantie waarbinnen een bronwaarde als "gelijk aan de afleiding" geldt: één minuut. */ +const WORK_EPS_MINUTES = 1; + +/** + * "Afwezig ⇒ afgeleid" bij import (spec §4.3, geval c; de afspraak met de XER-sessie): de drie + * werkvelden worden alleen gezet wanneer de bron iets zegt dat de afleiding niet al zegt — + * verricht werk > 0, begroot werk dat van `restduur × inzet` afwijkt, of resterend werk dat van + * `begroot − verricht` afwijkt. Anders blijft alles afwezig en is de import byte-identiek aan + * vandaag. Zodra één veld nodig is, gaan alle aanwezige bronwaarden mee (één consistent drietal). + * Niet-eindige of negatieve bronwaarden gelden als afwezig. + */ +export function importedWorkFields( + source: { plannedMinutes?: number; actualMinutes?: number; remainingMinutes?: number }, + derivedWorkMinutes: number, +): { plannedWorkMinutes?: number; actualWorkMinutes?: number; remainingWorkMinutes?: number } { + const clean = (v: number | undefined): number | undefined => + (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : undefined); + const planned = clean(source.plannedMinutes); + const actual = clean(source.actualMinutes); + const remaining = clean(source.remainingMinutes); + if (planned === undefined && actual === undefined && remaining === undefined) return {}; + const derived = Number.isFinite(derivedWorkMinutes) && derivedWorkMinutes >= 0 ? derivedWorkMinutes : 0; + const plannedDeviates = planned !== undefined && Math.abs(planned - derived) > WORK_EPS_MINUTES; + const actualPresent = actual !== undefined && actual > WORK_EPS_MINUTES; + const expectedRemaining = planned !== undefined ? planned - (actual ?? 0) : derived; + const remainingDeviates = remaining !== undefined && Math.abs(remaining - expectedRemaining) > WORK_EPS_MINUTES; + if (!plannedDeviates && !actualPresent && !remainingDeviates) return {}; + return { + ...(planned !== undefined ? { plannedWorkMinutes: planned } : {}), + ...(actual !== undefined ? { actualWorkMinutes: actual } : {}), + ...(remaining !== undefined ? { remainingWorkMinutes: remaining } : {}), + }; +} diff --git a/src/extensions/extMappers.ts b/src/extensions/extMappers.ts index 15e1bb479..b467edde3 100644 --- a/src/extensions/extMappers.ts +++ b/src/extensions/extMappers.ts @@ -163,6 +163,7 @@ export function toExtProject(p: Project): ExtProject { statusDate: p.statusDate, progressMode: p.progressMode, defaultTaskDurationUnit: p.defaultTaskDurationUnit, + defaultWorkRule: p.defaultWorkRule, schedulingOptions: p.schedulingOptions ? copySchedulingOptions(p.schedulingOptions) : undefined, }; } @@ -183,6 +184,7 @@ export function fromExtProject(p: ExtProject): Project { statusDate: p.statusDate, progressMode: p.progressMode, defaultTaskDurationUnit: p.defaultTaskDurationUnit, + defaultWorkRule: p.defaultWorkRule, schedulingOptions: p.schedulingOptions ? copySchedulingOptions(p.schedulingOptions) : undefined, }; } @@ -333,6 +335,7 @@ export function toExtTask(t: Task, customTaskType?: { id: string; name: string } // create-/update-paden (`fromExtTaskInput`) en de MCP-zetbaarheid (`taskFields.ts` REJECT_HINTS). mspTaskType: t.mspTaskType, effortDriven: t.effortDriven, + workRule: t.workRule, timephasedContours: t.timephasedContours ? t.timephasedContours.map(c => ({ resourceUid: c.resourceUid, ...(c.resourceId !== undefined ? { resourceId: c.resourceId } : {}), periods: c.periods.map(p => ({ ...p })) })) : undefined, timephasedFinishFloor: t.timephasedFinishFloor, timephasedStartAnchor: t.timephasedStartAnchor, @@ -379,6 +382,7 @@ export function fromExtTask(t: ExtTask): Task { // (`fromExtTaskInput`, extensie-API) blijven hier bewust buiten (leeskant-alleen-besluit F5). mspTaskType: t.mspTaskType, effortDriven: t.effortDriven, + workRule: t.workRule, timephasedContours: t.timephasedContours ? t.timephasedContours.map(c => ({ resourceUid: c.resourceUid, ...(c.resourceId !== undefined ? { resourceId: c.resourceId } : {}), periods: c.periods.map(p => ({ ...p })) })) : undefined, timephasedFinishFloor: t.timephasedFinishFloor, timephasedStartAnchor: t.timephasedStartAnchor, @@ -646,6 +650,9 @@ export function toExtAssignment(a: ResourceAssignment): ExtAssignment { workWindowStart: a.workWindowStart, workWindowFinish: a.workWindowFinish, curveValues: a.curveValues ? [...a.curveValues] : undefined, + plannedWorkMinutes: a.plannedWorkMinutes, + actualWorkMinutes: a.actualWorkMinutes, + remainingWorkMinutes: a.remainingWorkMinutes, }; } @@ -659,6 +666,9 @@ export function fromExtAssignment(a: ExtAssignment): ResourceAssignment { workWindowStart: a.workWindowStart, workWindowFinish: a.workWindowFinish, curveValues: a.curveValues ? [...a.curveValues] : undefined, + plannedWorkMinutes: a.plannedWorkMinutes, + actualWorkMinutes: a.actualWorkMinutes, + remainingWorkMinutes: a.remainingWorkMinutes, }; } diff --git a/src/extensions/extTypes.ts b/src/extensions/extTypes.ts index aff85bda4..1e4ab90c0 100644 --- a/src/extensions/extTypes.ts +++ b/src/extensions/extTypes.ts @@ -40,6 +40,8 @@ export interface ExtProject { progressMode?: 'RETAINED_LOGIC' | 'PROGRESS_OVERRIDE'; /** Projectstandaard voor handmatig aangemaakte taken. Oudere extensies mogen dit weglaten. */ defaultTaskDurationUnit?: 'days' | 'hours'; + /** Taaktypes-etappe (spec §4.1): projectstandaard-werkregel; afwezig ⇒ FIXED_DURATION_RATE. */ + defaultWorkRule?: 'FIXED_DURATION_RATE' | 'FIXED_DURATION_WORK' | 'FIXED_WORK' | 'FIXED_RATE'; /** Project-scoped reken-opties (P6-geavanceerd). undefined ⇒ alle defaults. */ schedulingOptions?: ExtSchedulingOptions; } @@ -215,6 +217,10 @@ 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; + /** Taaktypes-etappe (ontwerp 2026-09-04 §4.1) — de neutrale werkregel van de taak (welke hoeken van + * werk = duur × inzet beschermd zijn bij een bewerking). Volledige-round-trip-veld zoals + * `mspTaskType`; zetbaar via de bridge volgt in de bedradingsstap. */ + workRule?: 'FIXED_DURATION_RATE' | 'FIXED_DURATION_WORK' | 'FIXED_WORK' | 'FIXED_RATE'; /** 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'). @@ -307,6 +313,12 @@ export interface ExtAssignment { workWindowFinish?: string; /** Contour-engine (2026-09): exacte 21-punts curve (P6/MSPDI), zie `ResourceAssignment.curveValues`. */ curveValues?: number[]; + /** Taaktypes-etappe (spec §4.3): begroot werk in werkminuten; afwezig ⇒ afgeleid. */ + plannedWorkMinutes?: number; + /** Taaktypes-etappe (spec §4.3): verricht werk in werkminuten; afwezig ⇒ afgeleid. */ + actualWorkMinutes?: number; + /** Taaktypes-etappe (spec §4.3): resterend werk in werkminuten; afwezig ⇒ restduur × inzet. */ + remainingWorkMinutes?: number; } // ── UI-contract: ribbontabbladen ── diff --git a/src/i18n/locales/ar/task.json b/src/i18n/locales/ar/task.json index 2ea68a238..3f0ad42ae 100644 --- a/src/i18n/locales/ar/task.json +++ b/src/i18n/locales/ar/task.json @@ -117,7 +117,11 @@ "totalFloat": "الفائض الكلي", "wbs": "هيكل تقسيم العمل (WBS)", "workWindowFinish": "نهاية نافذة العمل", - "workWindowStart": "بداية نافذة العمل" + "workWindowStart": "بداية نافذة العمل", + "workRule": "قاعدة العمل", + "assignmentPlannedWork": "العمل المخطط (ساعات)", + "assignmentActualWork": "العمل الفعلي (ساعات)", + "assignmentRemainingWork": "العمل المتبقي (ساعات)" }, "category": { "task": "المهمة", "planning": "التخطيط", "constraints": "القيود", "relations": "العلاقات", "resources": "الموارد", diff --git a/src/i18n/locales/de/task.json b/src/i18n/locales/de/task.json index 318774bab..3df3adc3b 100644 --- a/src/i18n/locales/de/task.json +++ b/src/i18n/locales/de/task.json @@ -61,7 +61,11 @@ "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", - "totalFloat": "Gesamtpuffer", "wbs": "Projektstrukturplan (PSP)", "workWindowFinish": "Ende des Arbeitsfensters", "workWindowStart": "Anfang des Arbeitsfensters" + "totalFloat": "Gesamtpuffer", "wbs": "Projektstrukturplan (PSP)", "workWindowFinish": "Ende des Arbeitsfensters", "workWindowStart": "Anfang des Arbeitsfensters", + "totalFloat": "Gesamtpuffer", "wbs": "Projektstrukturplan (PSP)", "workWindowFinish": "Ende des Arbeitsfensters", "workRule": "Arbeitsregel", + "totalFloat": "Gesamtpuffer", "wbs": "Projektstrukturplan (PSP)", "workWindowFinish": "Ende des Arbeitsfensters", "assignmentPlannedWork": "Geplante Arbeit (Stunden)", + "totalFloat": "Gesamtpuffer", "wbs": "Projektstrukturplan (PSP)", "workWindowFinish": "Ende des Arbeitsfensters", "assignmentActualWork": "Ist-Arbeit (Stunden)", + "totalFloat": "Gesamtpuffer", "wbs": "Projektstrukturplan (PSP)", "workWindowFinish": "Ende des Arbeitsfensters", "assignmentRemainingWork": "Verbleibende Arbeit (Stunden)" }, "category": { "task": "Aufgabe", "planning": "Planung", "constraints": "Einschränkungen", "relations": "Beziehungen", "resources": "Ressourcen", diff --git a/src/i18n/locales/en/task.json b/src/i18n/locales/en/task.json index 98fedfb51..9515c77ee 100644 --- a/src/i18n/locales/en/task.json +++ b/src/i18n/locales/en/task.json @@ -117,7 +117,11 @@ "totalFloat": "Total float", "wbs": "WBS", "workWindowFinish": "Work window finish", - "workWindowStart": "Work window start" + "workWindowStart": "Work window start", + "workRule": "Work rule", + "assignmentPlannedWork": "Planned work (hours)", + "assignmentActualWork": "Actual work (hours)", + "assignmentRemainingWork": "Remaining work (hours)" }, "category": { "task": "Task", diff --git a/src/i18n/locales/es/task.json b/src/i18n/locales/es/task.json index 5fc8dad53..5d7edac13 100644 --- a/src/i18n/locales/es/task.json +++ b/src/i18n/locales/es/task.json @@ -61,7 +61,11 @@ "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", - "totalFloat": "Holgura total", "wbs": "Estructura de desglose del trabajo (EDT)", "workWindowFinish": "Fin de ventana de trabajo", "workWindowStart": "Inicio de ventana de trabajo" + "totalFloat": "Holgura total", "wbs": "Estructura de desglose del trabajo (EDT)", "workWindowFinish": "Fin de ventana de trabajo", "workWindowStart": "Inicio de ventana de trabajo", + "totalFloat": "Holgura total", "wbs": "Estructura de desglose del trabajo (EDT)", "workWindowFinish": "Fin de ventana de trabajo", "workRule": "Regla de trabajo", + "totalFloat": "Holgura total", "wbs": "Estructura de desglose del trabajo (EDT)", "workWindowFinish": "Fin de ventana de trabajo", "assignmentPlannedWork": "Trabajo previsto (horas)", + "totalFloat": "Holgura total", "wbs": "Estructura de desglose del trabajo (EDT)", "workWindowFinish": "Fin de ventana de trabajo", "assignmentActualWork": "Trabajo real (horas)", + "totalFloat": "Holgura total", "wbs": "Estructura de desglose del trabajo (EDT)", "workWindowFinish": "Fin de ventana de trabajo", "assignmentRemainingWork": "Trabajo restante (horas)" }, "category": { "task": "Tarea", "planning": "Planificación", "constraints": "Restricciones", "relations": "Relaciones", "resources": "Recursos", diff --git a/src/i18n/locales/fa/task.json b/src/i18n/locales/fa/task.json index 2ac8aa0b2..3dcf5a4be 100644 --- a/src/i18n/locales/fa/task.json +++ b/src/i18n/locales/fa/task.json @@ -48,7 +48,7 @@ "taskGrid": { "columns": { "customTaskType": "نوع وظیفه سفارشی", - "activityCodeData":"داده‌های کد فعالیت","actualDuration":"مدت واقعی","actualFinish":"پایان واقعی","actualStart":"شروع واقعی","assignedResources":"منابع تخصیص‌یافته","assignmentCurve":"منحنی تخصیص","assignmentId":"شناسه تخصیص","assignmentResourceId":"شناسه منبع تخصیص","assignmentTaskId":"شناسه وظیفه تخصیص","assignmentUnits":"واحدهای تخصیص در روز","calendar":"تقویم","childIds":"شناسه وظیفه‌های فرزند","color":"رنگ","completion":"پیشرفت","constraint2Date":"تاریخ محدودیت ثانویه","constraint2Type":"نوع محدودیت ثانویه","constraintDate":"تاریخ محدودیت","constraintHard":"محدودیت سخت","constraintType":"نوع محدودیت","critical":"بحرانی","customFieldData":"داده‌های فیلد سفارشی","deadline":"مهلت نهایی","description":"توضیحات","duration":"مدت","durationMinutes":"مدت (دقیقه)","durationType":"نوع مدت","earlyFinish":"پایان زودهنگام","earlyStart":"شروع زودهنگام","effortDriven":"مبتنی بر تلاش","externalRelationData":"داده‌های رابطه خارجی","floatPath":"مسیر شناوری","freeFloat":"شناوری آزاد","hammock":"وظیفه هموک (مدت مشتق‌شده)","interferingFloat":"شناوری مزاحم","internalRelationData":"داده‌های رابطه داخلی","lateFinish":"پایان دیرهنگام","lateStart":"شروع دیرهنگام","levelingDelay":"تأخیر تسطیح منابع","levelingDelayElapsed":"تأخیر سپری‌شده تسطیح","levelingDelayMinutes":"تأخیر تسطیح (دقیقه)","mandatoryMilestone":"نقطه عطف الزامی","manuallyScheduled":"زمان‌بندی دستی","milestone":"نقطه عطف","milestoneKind":"نوع نقطه عطف","mspTaskType":"نوع وظیفه Microsoft Project","name":"نام وظیفه","nearCritical":"نزدیک به بحرانی","noteData":"داده‌های یادداشت","notes":"یادداشت‌ها","parentId":"شناسه وظیفه والد","priority":"اولویت تسطیح","remainingMinutes":"باقی‌مانده (دقیقه)","remainingTime":"باقی‌مانده (روز کاری)","resourceIds":"شناسه‌های منبع","resume":"تاریخ ازسرگیری","scheduleFinish":"پایان برنامه‌ریزی‌شده","scheduleStart":"شروع برنامه‌ریزی‌شده","splitGaps":"وقفه‌ها","status":"وضعیت","stop":"تاریخ توقف","taskId":"شناسه وظیفه","taskType":"نوع وظیفه","timephasedContours":"منحنی‌های زمان‌بندی‌شده","timephasedDurationWalks":"بخش‌های مدت زمان‌بندی‌شده","timephasedFinishFloor":"حد پایین پایان زمان‌بندی‌شده","timephasedStartAnchor":"لنگر شروع زمان‌بندی‌شده","totalFloat":"شناوری کل","wbs":"ساختار شکست کار (WBS)","workWindowFinish":"پایان بازه کاری","workWindowStart":"شروع بازه کاری" + "activityCodeData":"داده‌های کد فعالیت","actualDuration":"مدت واقعی","actualFinish":"پایان واقعی","actualStart":"شروع واقعی","assignedResources":"منابع تخصیص‌یافته","assignmentCurve":"منحنی تخصیص","assignmentId":"شناسه تخصیص","assignmentResourceId":"شناسه منبع تخصیص","assignmentTaskId":"شناسه وظیفه تخصیص","assignmentUnits":"واحدهای تخصیص در روز","calendar":"تقویم","childIds":"شناسه وظیفه‌های فرزند","color":"رنگ","completion":"پیشرفت","constraint2Date":"تاریخ محدودیت ثانویه","constraint2Type":"نوع محدودیت ثانویه","constraintDate":"تاریخ محدودیت","constraintHard":"محدودیت سخت","constraintType":"نوع محدودیت","critical":"بحرانی","customFieldData":"داده‌های فیلد سفارشی","deadline":"مهلت نهایی","description":"توضیحات","duration":"مدت","durationMinutes":"مدت (دقیقه)","durationType":"نوع مدت","earlyFinish":"پایان زودهنگام","earlyStart":"شروع زودهنگام","effortDriven":"مبتنی بر تلاش","externalRelationData":"داده‌های رابطه خارجی","floatPath":"مسیر شناوری","freeFloat":"شناوری آزاد","hammock":"وظیفه هموک (مدت مشتق‌شده)","interferingFloat":"شناوری مزاحم","internalRelationData":"داده‌های رابطه داخلی","lateFinish":"پایان دیرهنگام","lateStart":"شروع دیرهنگام","levelingDelay":"تأخیر تسطیح منابع","levelingDelayElapsed":"تأخیر سپری‌شده تسطیح","levelingDelayMinutes":"تأخیر تسطیح (دقیقه)","mandatoryMilestone":"نقطه عطف الزامی","manuallyScheduled":"زمان‌بندی دستی","milestone":"نقطه عطف","milestoneKind":"نوع نقطه عطف","mspTaskType":"نوع وظیفه Microsoft Project","name":"نام وظیفه","nearCritical":"نزدیک به بحرانی","noteData":"داده‌های یادداشت","notes":"یادداشت‌ها","parentId":"شناسه وظیفه والد","priority":"اولویت تسطیح","remainingMinutes":"باقی‌مانده (دقیقه)","remainingTime":"باقی‌مانده (روز کاری)","resourceIds":"شناسه‌های منبع","resume":"تاریخ ازسرگیری","scheduleFinish":"پایان برنامه‌ریزی‌شده","scheduleStart":"شروع برنامه‌ریزی‌شده","splitGaps":"وقفه‌ها","status":"وضعیت","stop":"تاریخ توقف","taskId":"شناسه وظیفه","taskType":"نوع وظیفه","timephasedContours":"منحنی‌های زمان‌بندی‌شده","timephasedDurationWalks":"بخش‌های مدت زمان‌بندی‌شده","timephasedFinishFloor":"حد پایین پایان زمان‌بندی‌شده","timephasedStartAnchor":"لنگر شروع زمان‌بندی‌شده","totalFloat":"شناوری کل","wbs":"ساختار شکست کار (WBS)","workWindowFinish":"پایان بازه کاری","workWindowStart":"شروع بازه کاری","workRule":"قاعده کار","assignmentPlannedWork":"کار برنامه‌ریزی‌شده (ساعت)","assignmentActualWork":"کار انجام‌شده (ساعت)","assignmentRemainingWork":"کار باقی‌مانده (ساعت)" }, "category": { "task": "وظیفه", "planning": "برنامه‌ریزی", "constraints": "محدودیت‌ها", "relations": "روابط", "resources": "منابع", diff --git a/src/i18n/locales/fr/task.json b/src/i18n/locales/fr/task.json index 063de3da4..768eb495c 100644 --- a/src/i18n/locales/fr/task.json +++ b/src/i18n/locales/fr/task.json @@ -48,7 +48,7 @@ "taskGrid": { "columns": { "customTaskType": "Type de tâche personnalisé", - "activityCodeData":"Données des codes d’activité","actualDuration":"Durée réelle","actualFinish":"Fin réelle","actualStart":"Début réel","assignedResources":"Ressources affectées","assignmentCurve":"Courbe d’affectation","assignmentId":"ID d’affectation","assignmentResourceId":"ID de ressource de l’affectation","assignmentTaskId":"ID de tâche de l’affectation","assignmentUnits":"Unités d’affectation par jour","calendar":"Calendrier","childIds":"ID des tâches enfants","color":"Couleur","completion":"Avancement","constraint2Date":"Date de contrainte secondaire","constraint2Type":"Type de contrainte secondaire","constraintDate":"Date de contrainte","constraintHard":"Contrainte stricte","constraintType":"Type de contrainte","critical":"Critique","customFieldData":"Données des champs personnalisés","deadline":"Échéance","description":"Description","duration":"Durée","durationMinutes":"Durée (minutes)","durationType":"Type de durée","earlyFinish":"Fin au plus tôt","earlyStart":"Début au plus tôt","effortDriven":"Piloté par l’effort","externalRelationData":"Données des relations externes","floatPath":"Chemin de marge","freeFloat":"Marge libre","hammock":"Hamac (durée dérivée)","interferingFloat":"Marge d’interférence","internalRelationData":"Données des relations internes","lateFinish":"Fin au plus tard","lateStart":"Début au plus tard","levelingDelay":"Retard de nivellement","levelingDelayElapsed":"Retard de nivellement écoulé","levelingDelayMinutes":"Retard de nivellement (minutes)","mandatoryMilestone":"Jalon obligatoire","manuallyScheduled":"Planifiée manuellement","milestone":"Jalon","milestoneKind":"Type de jalon","mspTaskType":"Type de tâche Microsoft Project","name":"Nom de tâche","nearCritical":"Presque critique","noteData":"Données des notes","notes":"Notes","parentId":"ID de la tâche parente","priority":"Priorité de nivellement","remainingMinutes":"Restant (minutes)","remainingTime":"Restant (jours ouvrés)","resourceIds":"ID des ressources","resume":"Date de reprise","scheduleFinish":"Fin planifiée","scheduleStart":"Début planifié","splitGaps":"Interruptions","status":"État","stop":"Date d’arrêt","taskId":"ID de tâche","taskType":"Type de tâche","timephasedContours":"Profils chronologiques","timephasedDurationWalks":"Segments de durée chronologiques","timephasedFinishFloor":"Plancher de fin chronologique","timephasedStartAnchor":"Ancrage de début chronologique","totalFloat":"Marge totale","wbs":"Structure de découpage du projet (SDP)","workWindowFinish":"Fin de fenêtre de travail","workWindowStart":"Début de fenêtre de travail" + "activityCodeData":"Données des codes d’activité","actualDuration":"Durée réelle","actualFinish":"Fin réelle","actualStart":"Début réel","assignedResources":"Ressources affectées","assignmentCurve":"Courbe d’affectation","assignmentId":"ID d’affectation","assignmentResourceId":"ID de ressource de l’affectation","assignmentTaskId":"ID de tâche de l’affectation","assignmentUnits":"Unités d’affectation par jour","calendar":"Calendrier","childIds":"ID des tâches enfants","color":"Couleur","completion":"Avancement","constraint2Date":"Date de contrainte secondaire","constraint2Type":"Type de contrainte secondaire","constraintDate":"Date de contrainte","constraintHard":"Contrainte stricte","constraintType":"Type de contrainte","critical":"Critique","customFieldData":"Données des champs personnalisés","deadline":"Échéance","description":"Description","duration":"Durée","durationMinutes":"Durée (minutes)","durationType":"Type de durée","earlyFinish":"Fin au plus tôt","earlyStart":"Début au plus tôt","effortDriven":"Piloté par l’effort","externalRelationData":"Données des relations externes","floatPath":"Chemin de marge","freeFloat":"Marge libre","hammock":"Hamac (durée dérivée)","interferingFloat":"Marge d’interférence","internalRelationData":"Données des relations internes","lateFinish":"Fin au plus tard","lateStart":"Début au plus tard","levelingDelay":"Retard de nivellement","levelingDelayElapsed":"Retard de nivellement écoulé","levelingDelayMinutes":"Retard de nivellement (minutes)","mandatoryMilestone":"Jalon obligatoire","manuallyScheduled":"Planifiée manuellement","milestone":"Jalon","milestoneKind":"Type de jalon","mspTaskType":"Type de tâche Microsoft Project","name":"Nom de tâche","nearCritical":"Presque critique","noteData":"Données des notes","notes":"Notes","parentId":"ID de la tâche parente","priority":"Priorité de nivellement","remainingMinutes":"Restant (minutes)","remainingTime":"Restant (jours ouvrés)","resourceIds":"ID des ressources","resume":"Date de reprise","scheduleFinish":"Fin planifiée","scheduleStart":"Début planifié","splitGaps":"Interruptions","status":"État","stop":"Date d’arrêt","taskId":"ID de tâche","taskType":"Type de tâche","timephasedContours":"Profils chronologiques","timephasedDurationWalks":"Segments de durée chronologiques","timephasedFinishFloor":"Plancher de fin chronologique","timephasedStartAnchor":"Ancrage de début chronologique","totalFloat":"Marge totale","wbs":"Structure de découpage du projet (SDP)","workWindowFinish":"Fin de fenêtre de travail","workWindowStart":"Début de fenêtre de travail","workRule":"Règle de travail","assignmentPlannedWork":"Travail prévu (heures)","assignmentActualWork":"Travail réel (heures)","assignmentRemainingWork":"Travail restant (heures)" }, "category": { "task": "Tâche", "planning": "Planification", "constraints": "Contraintes", "relations": "Relations", "resources": "Ressources", diff --git a/src/i18n/locales/it/task.json b/src/i18n/locales/it/task.json index fd7427e11..f8fb99b1e 100644 --- a/src/i18n/locales/it/task.json +++ b/src/i18n/locales/it/task.json @@ -48,7 +48,7 @@ "taskGrid": { "columns": { "customTaskType": "Tipo di attività personalizzato", - "activityCodeData":"Dati dei codici attività","actualDuration":"Durata effettiva","actualFinish":"Fine effettiva","actualStart":"Inizio effettivo","assignedResources":"Risorse assegnate","assignmentCurve":"Curva di assegnazione","assignmentId":"ID assegnazione","assignmentResourceId":"ID risorsa dell’assegnazione","assignmentTaskId":"ID attività dell’assegnazione","assignmentUnits":"Unità di assegnazione al giorno","calendar":"Calendario","childIds":"ID attività figlie","color":"Colore","completion":"Avanzamento","constraint2Date":"Data vincolo secondario","constraint2Type":"Tipo vincolo secondario","constraintDate":"Data vincolo","constraintHard":"Vincolo rigido","constraintType":"Tipo vincolo","critical":"Critica","customFieldData":"Dati dei campi personalizzati","deadline":"Scadenza","description":"Descrizione","duration":"Durata","durationMinutes":"Durata (minuti)","durationType":"Tipo di durata","earlyFinish":"Fine anticipata","earlyStart":"Inizio anticipato","effortDriven":"Basata sull’impegno","externalRelationData":"Dati delle relazioni esterne","floatPath":"Percorso margine","freeFloat":"Margine libero","hammock":"Hammock (durata derivata)","interferingFloat":"Margine interferente","internalRelationData":"Dati delle relazioni interne","lateFinish":"Fine posticipata","lateStart":"Inizio posticipato","levelingDelay":"Ritardo di livellamento","levelingDelayElapsed":"Ritardo di livellamento trascorso","levelingDelayMinutes":"Ritardo di livellamento (minuti)","mandatoryMilestone":"Cardine obbligatorio","manuallyScheduled":"Pianificata manualmente","milestone":"Cardine","milestoneKind":"Tipo di cardine","mspTaskType":"Tipo attività Microsoft Project","name":"Nome attività","nearCritical":"Quasi critica","noteData":"Dati delle note","notes":"Note","parentId":"ID attività padre","priority":"Priorità di livellamento","remainingMinutes":"Rimanente (minuti)","remainingTime":"Rimanente (giorni lavorativi)","resourceIds":"ID risorse","resume":"Data di ripresa","scheduleFinish":"Fine pianificata","scheduleStart":"Inizio pianificato","splitGaps":"Interruzioni","status":"Stato","stop":"Data di arresto","taskId":"ID attività","taskType":"Tipo attività","timephasedContours":"Profili rapportati alla scala cronologica","timephasedDurationWalks":"Segmenti di durata cronologici","timephasedFinishFloor":"Limite inferiore fine cronologica","timephasedStartAnchor":"Ancoraggio inizio cronologico","totalFloat":"Margine totale","wbs":"Struttura di scomposizione del lavoro (WBS)","workWindowFinish":"Fine finestra di lavoro","workWindowStart":"Inizio finestra di lavoro" + "activityCodeData":"Dati dei codici attività","actualDuration":"Durata effettiva","actualFinish":"Fine effettiva","actualStart":"Inizio effettivo","assignedResources":"Risorse assegnate","assignmentCurve":"Curva di assegnazione","assignmentId":"ID assegnazione","assignmentResourceId":"ID risorsa dell’assegnazione","assignmentTaskId":"ID attività dell’assegnazione","assignmentUnits":"Unità di assegnazione al giorno","calendar":"Calendario","childIds":"ID attività figlie","color":"Colore","completion":"Avanzamento","constraint2Date":"Data vincolo secondario","constraint2Type":"Tipo vincolo secondario","constraintDate":"Data vincolo","constraintHard":"Vincolo rigido","constraintType":"Tipo vincolo","critical":"Critica","customFieldData":"Dati dei campi personalizzati","deadline":"Scadenza","description":"Descrizione","duration":"Durata","durationMinutes":"Durata (minuti)","durationType":"Tipo di durata","earlyFinish":"Fine anticipata","earlyStart":"Inizio anticipato","effortDriven":"Basata sull’impegno","externalRelationData":"Dati delle relazioni esterne","floatPath":"Percorso margine","freeFloat":"Margine libero","hammock":"Hammock (durata derivata)","interferingFloat":"Margine interferente","internalRelationData":"Dati delle relazioni interne","lateFinish":"Fine posticipata","lateStart":"Inizio posticipato","levelingDelay":"Ritardo di livellamento","levelingDelayElapsed":"Ritardo di livellamento trascorso","levelingDelayMinutes":"Ritardo di livellamento (minuti)","mandatoryMilestone":"Cardine obbligatorio","manuallyScheduled":"Pianificata manualmente","milestone":"Cardine","milestoneKind":"Tipo di cardine","mspTaskType":"Tipo attività Microsoft Project","name":"Nome attività","nearCritical":"Quasi critica","noteData":"Dati delle note","notes":"Note","parentId":"ID attività padre","priority":"Priorità di livellamento","remainingMinutes":"Rimanente (minuti)","remainingTime":"Rimanente (giorni lavorativi)","resourceIds":"ID risorse","resume":"Data di ripresa","scheduleFinish":"Fine pianificata","scheduleStart":"Inizio pianificato","splitGaps":"Interruzioni","status":"Stato","stop":"Data di arresto","taskId":"ID attività","taskType":"Tipo attività","timephasedContours":"Profili rapportati alla scala cronologica","timephasedDurationWalks":"Segmenti di durata cronologici","timephasedFinishFloor":"Limite inferiore fine cronologica","timephasedStartAnchor":"Ancoraggio inizio cronologico","totalFloat":"Margine totale","wbs":"Struttura di scomposizione del lavoro (WBS)","workWindowFinish":"Fine finestra di lavoro","workWindowStart":"Inizio finestra di lavoro","workRule":"Regola di lavoro","assignmentPlannedWork":"Lavoro previsto (ore)","assignmentActualWork":"Lavoro effettivo (ore)","assignmentRemainingWork":"Lavoro rimanente (ore)" }, "category": { "task": "Attività", "planning": "Pianificazione", "constraints": "Vincoli", "relations": "Relazioni", "resources": "Risorse", diff --git a/src/i18n/locales/ja/task.json b/src/i18n/locales/ja/task.json index 2424524c7..de37d8639 100644 --- a/src/i18n/locales/ja/task.json +++ b/src/i18n/locales/ja/task.json @@ -48,7 +48,7 @@ "taskGrid": { "columns": { "customTaskType": "カスタムタスクタイプ", - "activityCodeData":"アクティビティコードデータ","actualDuration":"実績期間","actualFinish":"実績終了日","actualStart":"実績開始日","assignedResources":"割り当て済みリソース","assignmentCurve":"割り当て曲線","assignmentId":"割り当て ID","assignmentResourceId":"割り当てリソース ID","assignmentTaskId":"割り当てタスク ID","assignmentUnits":"1日あたりの割り当て単位","calendar":"カレンダー","childIds":"子タスク ID","color":"色","completion":"進捗","constraint2Date":"第2制約日","constraint2Type":"第2制約タイプ","constraintDate":"制約日","constraintHard":"強制制約","constraintType":"制約タイプ","critical":"クリティカル","customFieldData":"カスタムフィールドデータ","deadline":"期限","description":"説明","duration":"期間","durationMinutes":"期間(分)","durationType":"期間タイプ","earlyFinish":"最早終了日","earlyStart":"最早開始日","effortDriven":"作業時間主導","externalRelationData":"外部依存関係データ","floatPath":"余裕パス","freeFloat":"自由余裕","hammock":"ハンモック(派生期間)","interferingFloat":"干渉余裕","internalRelationData":"内部依存関係データ","lateFinish":"最遅終了日","lateStart":"最遅開始日","levelingDelay":"平準化遅延","levelingDelayElapsed":"経過時間による平準化遅延","levelingDelayMinutes":"平準化遅延(分)","mandatoryMilestone":"必須マイルストーン","manuallyScheduled":"手動スケジュール","milestone":"マイルストーン","milestoneKind":"マイルストーンタイプ","mspTaskType":"Microsoft Project タスクタイプ","name":"タスク名","nearCritical":"準クリティカル","noteData":"メモデータ","notes":"メモ","parentId":"親タスク ID","priority":"平準化優先度","remainingMinutes":"残り(分)","remainingTime":"残り(稼働日)","resourceIds":"リソース ID","resume":"再開日","scheduleFinish":"予定終了日","scheduleStart":"予定開始日","splitGaps":"中断期間","status":"状態","stop":"停止日","taskId":"タスク ID","taskType":"タスクタイプ","timephasedContours":"タイムフェーズ輪郭","timephasedDurationWalks":"タイムフェーズ期間セグメント","timephasedFinishFloor":"タイムフェーズ終了下限","timephasedStartAnchor":"タイムフェーズ開始アンカー","totalFloat":"総余裕","wbs":"作業分解構成(WBS)","workWindowFinish":"作業枠の終了","workWindowStart":"作業枠の開始" + "activityCodeData":"アクティビティコードデータ","actualDuration":"実績期間","actualFinish":"実績終了日","actualStart":"実績開始日","assignedResources":"割り当て済みリソース","assignmentCurve":"割り当て曲線","assignmentId":"割り当て ID","assignmentResourceId":"割り当てリソース ID","assignmentTaskId":"割り当てタスク ID","assignmentUnits":"1日あたりの割り当て単位","calendar":"カレンダー","childIds":"子タスク ID","color":"色","completion":"進捗","constraint2Date":"第2制約日","constraint2Type":"第2制約タイプ","constraintDate":"制約日","constraintHard":"強制制約","constraintType":"制約タイプ","critical":"クリティカル","customFieldData":"カスタムフィールドデータ","deadline":"期限","description":"説明","duration":"期間","durationMinutes":"期間(分)","durationType":"期間タイプ","earlyFinish":"最早終了日","earlyStart":"最早開始日","effortDriven":"作業時間主導","externalRelationData":"外部依存関係データ","floatPath":"余裕パス","freeFloat":"自由余裕","hammock":"ハンモック(派生期間)","interferingFloat":"干渉余裕","internalRelationData":"内部依存関係データ","lateFinish":"最遅終了日","lateStart":"最遅開始日","levelingDelay":"平準化遅延","levelingDelayElapsed":"経過時間による平準化遅延","levelingDelayMinutes":"平準化遅延(分)","mandatoryMilestone":"必須マイルストーン","manuallyScheduled":"手動スケジュール","milestone":"マイルストーン","milestoneKind":"マイルストーンタイプ","mspTaskType":"Microsoft Project タスクタイプ","name":"タスク名","nearCritical":"準クリティカル","noteData":"メモデータ","notes":"メモ","parentId":"親タスク ID","priority":"平準化優先度","remainingMinutes":"残り(分)","remainingTime":"残り(稼働日)","resourceIds":"リソース ID","resume":"再開日","scheduleFinish":"予定終了日","scheduleStart":"予定開始日","splitGaps":"中断期間","status":"状態","stop":"停止日","taskId":"タスク ID","taskType":"タスクタイプ","timephasedContours":"タイムフェーズ輪郭","timephasedDurationWalks":"タイムフェーズ期間セグメント","timephasedFinishFloor":"タイムフェーズ終了下限","timephasedStartAnchor":"タイムフェーズ開始アンカー","totalFloat":"総余裕","wbs":"作業分解構成(WBS)","workWindowFinish":"作業枠の終了","workWindowStart":"作業枠の開始","workRule":"作業ルール","assignmentPlannedWork":"計画作業(時間)","assignmentActualWork":"実績作業(時間)","assignmentRemainingWork":"残作業(時間)" }, "category": { "task": "タスク", "planning": "計画", "constraints": "制約", "relations": "依存関係", "resources": "リソース", diff --git a/src/i18n/locales/ko/task.json b/src/i18n/locales/ko/task.json index ed07a8c34..7144968ff 100644 --- a/src/i18n/locales/ko/task.json +++ b/src/i18n/locales/ko/task.json @@ -48,7 +48,7 @@ "taskGrid": { "columns": { "customTaskType": "사용자 지정 작업 유형", - "activityCodeData":"활동 코드 데이터","actualDuration":"실제 기간","actualFinish":"실제 완료","actualStart":"실제 시작","assignedResources":"배정된 리소스","assignmentCurve":"배정 곡선","assignmentId":"배정 ID","assignmentResourceId":"배정 리소스 ID","assignmentTaskId":"배정 작업 ID","assignmentUnits":"일일 배정 단위","calendar":"달력","childIds":"하위 작업 ID","color":"색상","completion":"진행률","constraint2Date":"보조 제약 날짜","constraint2Type":"보조 제약 유형","constraintDate":"제약 날짜","constraintHard":"강제 제약","constraintType":"제약 유형","critical":"주공정","customFieldData":"사용자 지정 필드 데이터","deadline":"기한","description":"설명","duration":"기간","durationMinutes":"기간(분)","durationType":"기간 유형","earlyFinish":"빠른 완료","earlyStart":"빠른 시작","effortDriven":"작업량 기준","externalRelationData":"외부 관계 데이터","floatPath":"여유 경로","freeFloat":"자유 여유","hammock":"해먹(파생 기간)","interferingFloat":"간섭 여유","internalRelationData":"내부 관계 데이터","lateFinish":"늦은 완료","lateStart":"늦은 시작","levelingDelay":"평준화 지연","levelingDelayElapsed":"경과 평준화 지연","levelingDelayMinutes":"평준화 지연(분)","mandatoryMilestone":"필수 마일스톤","manuallyScheduled":"수동 일정","milestone":"마일스톤","milestoneKind":"마일스톤 유형","mspTaskType":"Microsoft Project 작업 유형","name":"작업 이름","nearCritical":"준주공정","noteData":"메모 데이터","notes":"메모","parentId":"상위 작업 ID","priority":"평준화 우선순위","remainingMinutes":"남음(분)","remainingTime":"남음(근무일)","resourceIds":"리소스 ID","resume":"재개 날짜","scheduleFinish":"예정 완료","scheduleStart":"예정 시작","splitGaps":"중단 구간","status":"상태","stop":"중지 날짜","taskId":"작업 ID","taskType":"작업 유형","timephasedContours":"시간대별 분포","timephasedDurationWalks":"시간대별 기간 구간","timephasedFinishFloor":"시간대별 완료 하한","timephasedStartAnchor":"시간대별 시작 기준점","totalFloat":"총 여유","wbs":"작업 분류 체계(WBS)","workWindowFinish":"작업 창 종료","workWindowStart":"작업 창 시작" + "activityCodeData":"활동 코드 데이터","actualDuration":"실제 기간","actualFinish":"실제 완료","actualStart":"실제 시작","assignedResources":"배정된 리소스","assignmentCurve":"배정 곡선","assignmentId":"배정 ID","assignmentResourceId":"배정 리소스 ID","assignmentTaskId":"배정 작업 ID","assignmentUnits":"일일 배정 단위","calendar":"달력","childIds":"하위 작업 ID","color":"색상","completion":"진행률","constraint2Date":"보조 제약 날짜","constraint2Type":"보조 제약 유형","constraintDate":"제약 날짜","constraintHard":"강제 제약","constraintType":"제약 유형","critical":"주공정","customFieldData":"사용자 지정 필드 데이터","deadline":"기한","description":"설명","duration":"기간","durationMinutes":"기간(분)","durationType":"기간 유형","earlyFinish":"빠른 완료","earlyStart":"빠른 시작","effortDriven":"작업량 기준","externalRelationData":"외부 관계 데이터","floatPath":"여유 경로","freeFloat":"자유 여유","hammock":"해먹(파생 기간)","interferingFloat":"간섭 여유","internalRelationData":"내부 관계 데이터","lateFinish":"늦은 완료","lateStart":"늦은 시작","levelingDelay":"평준화 지연","levelingDelayElapsed":"경과 평준화 지연","levelingDelayMinutes":"평준화 지연(분)","mandatoryMilestone":"필수 마일스톤","manuallyScheduled":"수동 일정","milestone":"마일스톤","milestoneKind":"마일스톤 유형","mspTaskType":"Microsoft Project 작업 유형","name":"작업 이름","nearCritical":"준주공정","noteData":"메모 데이터","notes":"메모","parentId":"상위 작업 ID","priority":"평준화 우선순위","remainingMinutes":"남음(분)","remainingTime":"남음(근무일)","resourceIds":"리소스 ID","resume":"재개 날짜","scheduleFinish":"예정 완료","scheduleStart":"예정 시작","splitGaps":"중단 구간","status":"상태","stop":"중지 날짜","taskId":"작업 ID","taskType":"작업 유형","timephasedContours":"시간대별 분포","timephasedDurationWalks":"시간대별 기간 구간","timephasedFinishFloor":"시간대별 완료 하한","timephasedStartAnchor":"시간대별 시작 기준점","totalFloat":"총 여유","wbs":"작업 분류 체계(WBS)","workWindowFinish":"작업 창 종료","workWindowStart":"작업 창 시작","workRule":"작업 규칙","assignmentPlannedWork":"계획 작업(시간)","assignmentActualWork":"실제 작업(시간)","assignmentRemainingWork":"잔여 작업(시간)" }, "category": { "task": "작업", "planning": "계획", "constraints": "제약 조건", "relations": "관계", "resources": "리소스", diff --git a/src/i18n/locales/nl/task.json b/src/i18n/locales/nl/task.json index 6195ae17d..b7be9b3f9 100644 --- a/src/i18n/locales/nl/task.json +++ b/src/i18n/locales/nl/task.json @@ -117,7 +117,11 @@ "totalFloat": "Totale speling", "wbs": "WBS", "workWindowFinish": "Einde werkvenster", - "workWindowStart": "Start werkvenster" + "workWindowStart": "Start werkvenster", + "workRule": "Werkregel", + "assignmentPlannedWork": "Begroot werk (uren)", + "assignmentActualWork": "Verricht werk (uren)", + "assignmentRemainingWork": "Resterend werk (uren)" }, "category": { "task": "Taak", diff --git a/src/i18n/locales/pl/task.json b/src/i18n/locales/pl/task.json index 56deab809..44c7ad336 100644 --- a/src/i18n/locales/pl/task.json +++ b/src/i18n/locales/pl/task.json @@ -48,7 +48,7 @@ "taskGrid": { "columns": { "customTaskType": "Niestandardowy typ zadania", - "activityCodeData":"Dane kodów czynności","actualDuration":"Rzeczywisty czas trwania","actualFinish":"Rzeczywiste zakończenie","actualStart":"Rzeczywiste rozpoczęcie","assignedResources":"Przydzielone zasoby","assignmentCurve":"Krzywa przydziału","assignmentId":"Identyfikator przydziału","assignmentResourceId":"Identyfikator zasobu przydziału","assignmentTaskId":"Identyfikator zadania przydziału","assignmentUnits":"Jednostki przydziału dziennie","calendar":"Kalendarz","childIds":"Identyfikatory zadań podrzędnych","color":"Kolor","completion":"Postęp","constraint2Date":"Data ograniczenia dodatkowego","constraint2Type":"Typ ograniczenia dodatkowego","constraintDate":"Data ograniczenia","constraintHard":"Twarde ograniczenie","constraintType":"Typ ograniczenia","critical":"Krytyczne","customFieldData":"Dane pól niestandardowych","deadline":"Termin ostateczny","description":"Opis","duration":"Czas trwania","durationMinutes":"Czas trwania (minuty)","durationType":"Typ czasu trwania","earlyFinish":"Najwcześniejsze zakończenie","earlyStart":"Najwcześniejsze rozpoczęcie","effortDriven":"Sterowane nakładem pracy","externalRelationData":"Dane relacji zewnętrznych","floatPath":"Ścieżka zapasu","freeFloat":"Zapas swobodny","hammock":"Hammock (czas pochodny)","interferingFloat":"Zapas zakłócający","internalRelationData":"Dane relacji wewnętrznych","lateFinish":"Najpóźniejsze zakończenie","lateStart":"Najpóźniejsze rozpoczęcie","levelingDelay":"Opóźnienie bilansowania","levelingDelayElapsed":"Upłynięte opóźnienie bilansowania","levelingDelayMinutes":"Opóźnienie bilansowania (minuty)","mandatoryMilestone":"Obowiązkowy kamień milowy","manuallyScheduled":"Planowane ręcznie","milestone":"Kamień milowy","milestoneKind":"Typ kamienia milowego","mspTaskType":"Typ zadania Microsoft Project","name":"Nazwa zadania","nearCritical":"Prawie krytyczne","noteData":"Dane notatek","notes":"Notatki","parentId":"Identyfikator zadania nadrzędnego","priority":"Priorytet bilansowania","remainingMinutes":"Pozostało (minuty)","remainingTime":"Pozostało (dni robocze)","resourceIds":"Identyfikatory zasobów","resume":"Data wznowienia","scheduleFinish":"Planowane zakończenie","scheduleStart":"Planowane rozpoczęcie","splitGaps":"Przerwy","status":"Stan","stop":"Data zatrzymania","taskId":"Identyfikator zadania","taskType":"Typ zadania","timephasedContours":"Kontury okresowe","timephasedDurationWalks":"Okresowe segmenty czasu trwania","timephasedFinishFloor":"Dolna granica okresowego zakończenia","timephasedStartAnchor":"Kotwica okresowego rozpoczęcia","totalFloat":"Zapas całkowity","wbs":"Struktura podziału pracy (SPP)","workWindowFinish":"Koniec okna pracy","workWindowStart":"Początek okna pracy" + "activityCodeData":"Dane kodów czynności","actualDuration":"Rzeczywisty czas trwania","actualFinish":"Rzeczywiste zakończenie","actualStart":"Rzeczywiste rozpoczęcie","assignedResources":"Przydzielone zasoby","assignmentCurve":"Krzywa przydziału","assignmentId":"Identyfikator przydziału","assignmentResourceId":"Identyfikator zasobu przydziału","assignmentTaskId":"Identyfikator zadania przydziału","assignmentUnits":"Jednostki przydziału dziennie","calendar":"Kalendarz","childIds":"Identyfikatory zadań podrzędnych","color":"Kolor","completion":"Postęp","constraint2Date":"Data ograniczenia dodatkowego","constraint2Type":"Typ ograniczenia dodatkowego","constraintDate":"Data ograniczenia","constraintHard":"Twarde ograniczenie","constraintType":"Typ ograniczenia","critical":"Krytyczne","customFieldData":"Dane pól niestandardowych","deadline":"Termin ostateczny","description":"Opis","duration":"Czas trwania","durationMinutes":"Czas trwania (minuty)","durationType":"Typ czasu trwania","earlyFinish":"Najwcześniejsze zakończenie","earlyStart":"Najwcześniejsze rozpoczęcie","effortDriven":"Sterowane nakładem pracy","externalRelationData":"Dane relacji zewnętrznych","floatPath":"Ścieżka zapasu","freeFloat":"Zapas swobodny","hammock":"Hammock (czas pochodny)","interferingFloat":"Zapas zakłócający","internalRelationData":"Dane relacji wewnętrznych","lateFinish":"Najpóźniejsze zakończenie","lateStart":"Najpóźniejsze rozpoczęcie","levelingDelay":"Opóźnienie bilansowania","levelingDelayElapsed":"Upłynięte opóźnienie bilansowania","levelingDelayMinutes":"Opóźnienie bilansowania (minuty)","mandatoryMilestone":"Obowiązkowy kamień milowy","manuallyScheduled":"Planowane ręcznie","milestone":"Kamień milowy","milestoneKind":"Typ kamienia milowego","mspTaskType":"Typ zadania Microsoft Project","name":"Nazwa zadania","nearCritical":"Prawie krytyczne","noteData":"Dane notatek","notes":"Notatki","parentId":"Identyfikator zadania nadrzędnego","priority":"Priorytet bilansowania","remainingMinutes":"Pozostało (minuty)","remainingTime":"Pozostało (dni robocze)","resourceIds":"Identyfikatory zasobów","resume":"Data wznowienia","scheduleFinish":"Planowane zakończenie","scheduleStart":"Planowane rozpoczęcie","splitGaps":"Przerwy","status":"Stan","stop":"Data zatrzymania","taskId":"Identyfikator zadania","taskType":"Typ zadania","timephasedContours":"Kontury okresowe","timephasedDurationWalks":"Okresowe segmenty czasu trwania","timephasedFinishFloor":"Dolna granica okresowego zakończenia","timephasedStartAnchor":"Kotwica okresowego rozpoczęcia","totalFloat":"Zapas całkowity","wbs":"Struktura podziału pracy (SPP)","workWindowFinish":"Koniec okna pracy","workWindowStart":"Początek okna pracy","workRule":"Reguła pracy","assignmentPlannedWork":"Praca planowana (godziny)","assignmentActualWork":"Praca rzeczywista (godziny)","assignmentRemainingWork":"Praca pozostała (godziny)" }, "category": { "task": "Zadanie", "planning": "Planowanie", "constraints": "Ograniczenia", "relations": "Relacje", "resources": "Zasoby", diff --git a/src/i18n/locales/pt/task.json b/src/i18n/locales/pt/task.json index d19c4674b..6ab30825f 100644 --- a/src/i18n/locales/pt/task.json +++ b/src/i18n/locales/pt/task.json @@ -48,7 +48,7 @@ "taskGrid": { "columns": { "customTaskType": "Tipo de tarefa personalizado", - "activityCodeData":"Dados dos códigos de atividade","actualDuration":"Duração real","actualFinish":"Fim real","actualStart":"Início real","assignedResources":"Recursos atribuídos","assignmentCurve":"Curva de atribuição","assignmentId":"ID da atribuição","assignmentResourceId":"ID do recurso da atribuição","assignmentTaskId":"ID da tarefa da atribuição","assignmentUnits":"Unidades de atribuição por dia","calendar":"Calendário","childIds":"IDs das tarefas subordinadas","color":"Cor","completion":"Progresso","constraint2Date":"Data da restrição secundária","constraint2Type":"Tipo da restrição secundária","constraintDate":"Data da restrição","constraintHard":"Restrição rígida","constraintType":"Tipo de restrição","critical":"Crítica","customFieldData":"Dados dos campos personalizados","deadline":"Prazo","description":"Descrição","duration":"Duração","durationMinutes":"Duração (minutos)","durationType":"Tipo de duração","earlyFinish":"Fim antecipado","earlyStart":"Início antecipado","effortDriven":"Orientada pelo esforço","externalRelationData":"Dados das relações externas","floatPath":"Caminho de folga","freeFloat":"Folga livre","hammock":"Hammock (duração derivada)","interferingFloat":"Folga interferente","internalRelationData":"Dados das relações internas","lateFinish":"Fim tardio","lateStart":"Início tardio","levelingDelay":"Atraso de nivelamento","levelingDelayElapsed":"Atraso de nivelamento decorrido","levelingDelayMinutes":"Atraso de nivelamento (minutos)","mandatoryMilestone":"Marco obrigatório","manuallyScheduled":"Agendada manualmente","milestone":"Marco","milestoneKind":"Tipo de marco","mspTaskType":"Tipo de tarefa do Microsoft Project","name":"Nome da tarefa","nearCritical":"Quase crítica","noteData":"Dados das notas","notes":"Notas","parentId":"ID da tarefa principal","priority":"Prioridade de nivelamento","remainingMinutes":"Restante (minutos)","remainingTime":"Restante (dias úteis)","resourceIds":"IDs dos recursos","resume":"Data de retoma","scheduleFinish":"Fim planeado","scheduleStart":"Início planeado","splitGaps":"Interrupções","status":"Estado","stop":"Data de paragem","taskId":"ID da tarefa","taskType":"Tipo de tarefa","timephasedContours":"Contornos faseados no tempo","timephasedDurationWalks":"Segmentos de duração faseados no tempo","timephasedFinishFloor":"Limite inferior do fim faseado","timephasedStartAnchor":"Âncora do início faseado","totalFloat":"Folga total","wbs":"Estrutura analítica do projeto (EAP)","workWindowFinish":"Fim da janela de trabalho","workWindowStart":"Início da janela de trabalho" + "activityCodeData":"Dados dos códigos de atividade","actualDuration":"Duração real","actualFinish":"Fim real","actualStart":"Início real","assignedResources":"Recursos atribuídos","assignmentCurve":"Curva de atribuição","assignmentId":"ID da atribuição","assignmentResourceId":"ID do recurso da atribuição","assignmentTaskId":"ID da tarefa da atribuição","assignmentUnits":"Unidades de atribuição por dia","calendar":"Calendário","childIds":"IDs das tarefas subordinadas","color":"Cor","completion":"Progresso","constraint2Date":"Data da restrição secundária","constraint2Type":"Tipo da restrição secundária","constraintDate":"Data da restrição","constraintHard":"Restrição rígida","constraintType":"Tipo de restrição","critical":"Crítica","customFieldData":"Dados dos campos personalizados","deadline":"Prazo","description":"Descrição","duration":"Duração","durationMinutes":"Duração (minutos)","durationType":"Tipo de duração","earlyFinish":"Fim antecipado","earlyStart":"Início antecipado","effortDriven":"Orientada pelo esforço","externalRelationData":"Dados das relações externas","floatPath":"Caminho de folga","freeFloat":"Folga livre","hammock":"Hammock (duração derivada)","interferingFloat":"Folga interferente","internalRelationData":"Dados das relações internas","lateFinish":"Fim tardio","lateStart":"Início tardio","levelingDelay":"Atraso de nivelamento","levelingDelayElapsed":"Atraso de nivelamento decorrido","levelingDelayMinutes":"Atraso de nivelamento (minutos)","mandatoryMilestone":"Marco obrigatório","manuallyScheduled":"Agendada manualmente","milestone":"Marco","milestoneKind":"Tipo de marco","mspTaskType":"Tipo de tarefa do Microsoft Project","name":"Nome da tarefa","nearCritical":"Quase crítica","noteData":"Dados das notas","notes":"Notas","parentId":"ID da tarefa principal","priority":"Prioridade de nivelamento","remainingMinutes":"Restante (minutos)","remainingTime":"Restante (dias úteis)","resourceIds":"IDs dos recursos","resume":"Data de retoma","scheduleFinish":"Fim planeado","scheduleStart":"Início planeado","splitGaps":"Interrupções","status":"Estado","stop":"Data de paragem","taskId":"ID da tarefa","taskType":"Tipo de tarefa","timephasedContours":"Contornos faseados no tempo","timephasedDurationWalks":"Segmentos de duração faseados no tempo","timephasedFinishFloor":"Limite inferior do fim faseado","timephasedStartAnchor":"Âncora do início faseado","totalFloat":"Folga total","wbs":"Estrutura analítica do projeto (EAP)","workWindowFinish":"Fim da janela de trabalho","workWindowStart":"Início da janela de trabalho","workRule":"Regra de trabalho","assignmentPlannedWork":"Trabalho previsto (horas)","assignmentActualWork":"Trabalho real (horas)","assignmentRemainingWork":"Trabalho restante (horas)" }, "category": { "task": "Tarefa", "planning": "Planeamento", "constraints": "Restrições", "relations": "Relações", "resources": "Recursos", diff --git a/src/i18n/locales/tr/task.json b/src/i18n/locales/tr/task.json index b1249530c..534f6259c 100644 --- a/src/i18n/locales/tr/task.json +++ b/src/i18n/locales/tr/task.json @@ -48,7 +48,7 @@ "taskGrid": { "columns": { "customTaskType": "Özel görev türü", - "activityCodeData":"Aktivite kodu verileri","actualDuration":"Gerçekleşen süre","actualFinish":"Gerçekleşen bitiş","actualStart":"Gerçekleşen başlangıç","assignedResources":"Atanan kaynaklar","assignmentCurve":"Atama eğrisi","assignmentId":"Atama kimliği","assignmentResourceId":"Atama kaynak kimliği","assignmentTaskId":"Atama görev kimliği","assignmentUnits":"Günlük atama birimleri","calendar":"Takvim","childIds":"Alt görev kimlikleri","color":"Renk","completion":"İlerleme","constraint2Date":"İkincil kısıtlama tarihi","constraint2Type":"İkincil kısıtlama türü","constraintDate":"Kısıtlama tarihi","constraintHard":"Kesin kısıtlama","constraintType":"Kısıtlama türü","critical":"Kritik","customFieldData":"Özel alan verileri","deadline":"Son tarih","description":"Açıklama","duration":"Süre","durationMinutes":"Süre (dakika)","durationType":"Süre türü","earlyFinish":"Erken bitiş","earlyStart":"Erken başlangıç","effortDriven":"Efor odaklı","externalRelationData":"Dış ilişki verileri","floatPath":"Bolluk yolu","freeFloat":"Serbest bolluk","hammock":"Hammock (türetilmiş süre)","interferingFloat":"Engelleyici bolluk","internalRelationData":"İç ilişki verileri","lateFinish":"Geç bitiş","lateStart":"Geç başlangıç","levelingDelay":"Kaynak dengeleme gecikmesi","levelingDelayElapsed":"Geçen dengeleme gecikmesi","levelingDelayMinutes":"Dengeleme gecikmesi (dakika)","mandatoryMilestone":"Zorunlu kilometre taşı","manuallyScheduled":"Elle planlandı","milestone":"Kilometre taşı","milestoneKind":"Kilometre taşı türü","mspTaskType":"Microsoft Project görev türü","name":"Görev adı","nearCritical":"Kritiğe yakın","noteData":"Not verileri","notes":"Notlar","parentId":"Üst görev kimliği","priority":"Dengeleme önceliği","remainingMinutes":"Kalan (dakika)","remainingTime":"Kalan (iş günü)","resourceIds":"Kaynak kimlikleri","resume":"Sürdürme tarihi","scheduleFinish":"Planlanan bitiş","scheduleStart":"Planlanan başlangıç","splitGaps":"Kesintiler","status":"Durum","stop":"Durdurma tarihi","taskId":"Görev kimliği","taskType":"Görev türü","timephasedContours":"Zaman aşamalı dağılımlar","timephasedDurationWalks":"Zaman aşamalı süre parçaları","timephasedFinishFloor":"Zaman aşamalı bitiş alt sınırı","timephasedStartAnchor":"Zaman aşamalı başlangıç çıpası","totalFloat":"Toplam bolluk","wbs":"İş kırılım yapısı (WBS)","workWindowFinish":"Çalışma penceresi bitişi","workWindowStart":"Çalışma penceresi başlangıcı" + "activityCodeData":"Aktivite kodu verileri","actualDuration":"Gerçekleşen süre","actualFinish":"Gerçekleşen bitiş","actualStart":"Gerçekleşen başlangıç","assignedResources":"Atanan kaynaklar","assignmentCurve":"Atama eğrisi","assignmentId":"Atama kimliği","assignmentResourceId":"Atama kaynak kimliği","assignmentTaskId":"Atama görev kimliği","assignmentUnits":"Günlük atama birimleri","calendar":"Takvim","childIds":"Alt görev kimlikleri","color":"Renk","completion":"İlerleme","constraint2Date":"İkincil kısıtlama tarihi","constraint2Type":"İkincil kısıtlama türü","constraintDate":"Kısıtlama tarihi","constraintHard":"Kesin kısıtlama","constraintType":"Kısıtlama türü","critical":"Kritik","customFieldData":"Özel alan verileri","deadline":"Son tarih","description":"Açıklama","duration":"Süre","durationMinutes":"Süre (dakika)","durationType":"Süre türü","earlyFinish":"Erken bitiş","earlyStart":"Erken başlangıç","effortDriven":"Efor odaklı","externalRelationData":"Dış ilişki verileri","floatPath":"Bolluk yolu","freeFloat":"Serbest bolluk","hammock":"Hammock (türetilmiş süre)","interferingFloat":"Engelleyici bolluk","internalRelationData":"İç ilişki verileri","lateFinish":"Geç bitiş","lateStart":"Geç başlangıç","levelingDelay":"Kaynak dengeleme gecikmesi","levelingDelayElapsed":"Geçen dengeleme gecikmesi","levelingDelayMinutes":"Dengeleme gecikmesi (dakika)","mandatoryMilestone":"Zorunlu kilometre taşı","manuallyScheduled":"Elle planlandı","milestone":"Kilometre taşı","milestoneKind":"Kilometre taşı türü","mspTaskType":"Microsoft Project görev türü","name":"Görev adı","nearCritical":"Kritiğe yakın","noteData":"Not verileri","notes":"Notlar","parentId":"Üst görev kimliği","priority":"Dengeleme önceliği","remainingMinutes":"Kalan (dakika)","remainingTime":"Kalan (iş günü)","resourceIds":"Kaynak kimlikleri","resume":"Sürdürme tarihi","scheduleFinish":"Planlanan bitiş","scheduleStart":"Planlanan başlangıç","splitGaps":"Kesintiler","status":"Durum","stop":"Durdurma tarihi","taskId":"Görev kimliği","taskType":"Görev türü","timephasedContours":"Zaman aşamalı dağılımlar","timephasedDurationWalks":"Zaman aşamalı süre parçaları","timephasedFinishFloor":"Zaman aşamalı bitiş alt sınırı","timephasedStartAnchor":"Zaman aşamalı başlangıç çıpası","totalFloat":"Toplam bolluk","wbs":"İş kırılım yapısı (WBS)","workWindowFinish":"Çalışma penceresi bitişi","workWindowStart":"Çalışma penceresi başlangıcı","workRule":"İş kuralı","assignmentPlannedWork":"Planlanan iş (saat)","assignmentActualWork":"Gerçekleşen iş (saat)","assignmentRemainingWork":"Kalan iş (saat)" }, "category": { "task": "Görev", "planning": "Planlama", "constraints": "Kısıtlamalar", "relations": "İlişkiler", "resources": "Kaynaklar", diff --git a/src/i18n/locales/zh/task.json b/src/i18n/locales/zh/task.json index dca7daa33..5bc00bead 100644 --- a/src/i18n/locales/zh/task.json +++ b/src/i18n/locales/zh/task.json @@ -48,7 +48,7 @@ "taskGrid": { "columns": { "customTaskType": "自定义任务类型", - "activityCodeData":"活动代码数据","actualDuration":"实际工期","actualFinish":"实际完成日期","actualStart":"实际开始日期","assignedResources":"已分配资源","assignmentCurve":"分配曲线","assignmentId":"分配 ID","assignmentResourceId":"分配资源 ID","assignmentTaskId":"分配任务 ID","assignmentUnits":"每日分配单位","calendar":"日历","childIds":"子任务 ID","color":"颜色","completion":"进度","constraint2Date":"次要约束日期","constraint2Type":"次要约束类型","constraintDate":"约束日期","constraintHard":"强制约束","constraintType":"约束类型","critical":"关键","customFieldData":"自定义字段数据","deadline":"截止日期","description":"描述","duration":"工期","durationMinutes":"工期(分钟)","durationType":"工期类型","earlyFinish":"最早完成","earlyStart":"最早开始","effortDriven":"投入量驱动","externalRelationData":"外部关系数据","floatPath":"浮时路径","freeFloat":"自由浮时","hammock":"吊床任务(派生工期)","interferingFloat":"干扰浮时","internalRelationData":"内部关系数据","lateFinish":"最晚完成","lateStart":"最晚开始","levelingDelay":"资源调配延迟","levelingDelayElapsed":"已用时间调配延迟","levelingDelayMinutes":"资源调配延迟(分钟)","mandatoryMilestone":"强制里程碑","manuallyScheduled":"手动计划","milestone":"里程碑","milestoneKind":"里程碑类型","mspTaskType":"Microsoft Project 任务类型","name":"任务名称","nearCritical":"接近关键","noteData":"备注数据","notes":"备注","parentId":"父任务 ID","priority":"资源调配优先级","remainingMinutes":"剩余(分钟)","remainingTime":"剩余(工作日)","resourceIds":"资源 ID","resume":"恢复日期","scheduleFinish":"计划完成日期","scheduleStart":"计划开始日期","splitGaps":"中断间隔","status":"状态","stop":"停止日期","taskId":"任务 ID","taskType":"任务类型","timephasedContours":"时间分段分布","timephasedDurationWalks":"时间分段工期区段","timephasedFinishFloor":"时间分段完成下限","timephasedStartAnchor":"时间分段开始锚点","totalFloat":"总浮时","wbs":"工作分解结构(WBS)","workWindowFinish":"工作窗口结束","workWindowStart":"工作窗口开始" + "activityCodeData":"活动代码数据","actualDuration":"实际工期","actualFinish":"实际完成日期","actualStart":"实际开始日期","assignedResources":"已分配资源","assignmentCurve":"分配曲线","assignmentId":"分配 ID","assignmentResourceId":"分配资源 ID","assignmentTaskId":"分配任务 ID","assignmentUnits":"每日分配单位","calendar":"日历","childIds":"子任务 ID","color":"颜色","completion":"进度","constraint2Date":"次要约束日期","constraint2Type":"次要约束类型","constraintDate":"约束日期","constraintHard":"强制约束","constraintType":"约束类型","critical":"关键","customFieldData":"自定义字段数据","deadline":"截止日期","description":"描述","duration":"工期","durationMinutes":"工期(分钟)","durationType":"工期类型","earlyFinish":"最早完成","earlyStart":"最早开始","effortDriven":"投入量驱动","externalRelationData":"外部关系数据","floatPath":"浮时路径","freeFloat":"自由浮时","hammock":"吊床任务(派生工期)","interferingFloat":"干扰浮时","internalRelationData":"内部关系数据","lateFinish":"最晚完成","lateStart":"最晚开始","levelingDelay":"资源调配延迟","levelingDelayElapsed":"已用时间调配延迟","levelingDelayMinutes":"资源调配延迟(分钟)","mandatoryMilestone":"强制里程碑","manuallyScheduled":"手动计划","milestone":"里程碑","milestoneKind":"里程碑类型","mspTaskType":"Microsoft Project 任务类型","name":"任务名称","nearCritical":"接近关键","noteData":"备注数据","notes":"备注","parentId":"父任务 ID","priority":"资源调配优先级","remainingMinutes":"剩余(分钟)","remainingTime":"剩余(工作日)","resourceIds":"资源 ID","resume":"恢复日期","scheduleFinish":"计划完成日期","scheduleStart":"计划开始日期","splitGaps":"中断间隔","status":"状态","stop":"停止日期","taskId":"任务 ID","taskType":"任务类型","timephasedContours":"时间分段分布","timephasedDurationWalks":"时间分段工期区段","timephasedFinishFloor":"时间分段完成下限","timephasedStartAnchor":"时间分段开始锚点","totalFloat":"总浮时","wbs":"工作分解结构(WBS)","workWindowFinish":"工作窗口结束","workWindowStart":"工作窗口开始","workRule":"工作规则","assignmentPlannedWork":"计划工时(小时)","assignmentActualWork":"实际工时(小时)","assignmentRemainingWork":"剩余工时(小时)" }, "category": { "task": "任务", "planning": "计划", "constraints": "约束", "relations": "关系", "resources": "资源", diff --git a/src/services/ifc/ifcPsets.ts b/src/services/ifc/ifcPsets.ts index dddb2ec2d..ffb5363b1 100644 --- a/src/services/ifc/ifcPsets.ts +++ b/src/services/ifc/ifcPsets.ts @@ -1,6 +1,7 @@ import type { - Task, ConstraintType, TaskSplitGap, TaskTimephasedContour, TimephasedContourPeriod, MspTaskType, + Task, ConstraintType, TaskSplitGap, TaskTimephasedContour, TimephasedContourPeriod, MspTaskType, WorkRule, } from '@/types/task'; +import { WORK_RULES } from '@/types/workRule'; /** * IFC-pset-registry (fase 3, tweede helft van P11 uit docs/superpowers/modulariteit-audit.md, @@ -65,6 +66,10 @@ export const PSET = { /** MSP's eigen Task Type + Effort-Driven-vlag (`Task.mspTaskType`/`effortDriven`) — puur data, * geen rekengedrag (eigenaarsbesluit 2026-08-18, punt 1). */ MspTaskType: 'OPS_MspTaskType', + /** Taaktypes-etappe (spec §4.4) — de neutrale werkregel van de taak (`Task.workRule`). Eigen + * pset naast `OPS_MspTaskType`: de importvelden blijven onaangeraakt, de regel is een afgeleide + * die de gebruiker later los kan wijzigen. */ + WorkRule: 'OPS_WorkRule', // Structuur/waarden op project- of taak-niveau (afwijkende vorm — alleen naam gedeeld). ProjectSettings: 'OPS_ProjectSettings', StructureMeta: 'OPS_StructureMeta', @@ -469,6 +474,22 @@ export const PER_TASK_PSETS: PerTaskPset[] = [ } }, }, + // 15. Taaktypes-etappe (ontwerp 2026-09-04 §4.4) — de neutrale werkregel. Zelfde vorm als 14; + // `WORK_RULES` (satisfies-afgedwongen lijst) is de geldigheidscheck, een onbekende waarde + // blijft stil weg (byte-identiek voor elk bestand zonder dit pset). + { + name: PSET.WorkRule, psetSeed: 'pset_wrl_', relSeed: 'rel_wrl_', + write(task) { + return task.workRule ? [{ name: 'WorkRule', value: `IFCLABEL(${ifcStr(task.workRule)})` }] : null; + }, + apply(task, props) { + for (const { name, value } of props) { + if (name === 'WorkRule' && typeof value === 'string' && (WORK_RULES as readonly string[]).includes(value)) { + task.workRule = value as WorkRule; + } + } + }, + }, ]; /** Naam → descriptor, voor de reader-dispatch in `extractStructure`. */ diff --git a/src/services/ifc/ifcReader.ts b/src/services/ifc/ifcReader.ts index cf92f3d9b..4ec232348 100644 --- a/src/services/ifc/ifcReader.ts +++ b/src/services/ifc/ifcReader.ts @@ -1,4 +1,5 @@ import { Task, TaskTime, TaskType, TASK_TYPES } from '@/types/task'; +import { WORK_RULES, type WorkRule } from '@/types/workRule'; import { normalizeCurveValues } from '@/engine/contour/contourEngine'; import type { CustomTaskType } from '@/types/taskType'; import { createDefaultTaskTime } from '@/utils/taskDefaults'; @@ -1214,6 +1215,9 @@ function extractStructure( if (typeof v === 'boolean') project.wbsAutoNumber = v; } else if (name === 'DefaultTaskDurationUnit') { if (v === 'days' || v === 'hours') project.defaultTaskDurationUnit = v; + } else if (name === 'DefaultWorkRule') { + // Taaktypes-etappe (spec §4.1): onbekende waarde ⇒ stil weg (byte-identiek default). + if (typeof v === 'string' && (WORK_RULES as readonly string[]).includes(v)) project.defaultWorkRule = v as WorkRule; } else if (name === 'StatusDate') { // Fase 2.6 (§8.2): P6 data date → project.statusDate. if (typeof v === 'string' && v) project.statusDate = v.substring(0, 10); @@ -1944,6 +1948,15 @@ interface WindowMeta { workWindowFinish?: string; /** Contour-engine (2026-09) — exacte 21-punts curve, zie `ResourceAssignment.curveValues`. */ curveValues?: number[]; + /** Taaktypes-etappe (spec §4.3) — de drie optionele werkvelden in minuten, zelfde blob. */ + plannedWorkMinutes?: number; + actualWorkMinutes?: number; + remainingWorkMinutes?: number; +} + +/** Eindig, niet-negatief werkgetal uit het JSON-blob; al het andere blijft weg (nooit NaN in de state). */ +function workMinutesOf(v: unknown): number | undefined { + return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : undefined; } /** Per-taak verzamelde OPS_Assignments-meta: nieuw formaat (`GUID#N`-propnamen) als @@ -2007,12 +2020,18 @@ function extractAssignments( if (!m || !val || typeof val !== 'object') continue; const vv = val as Record; const curveValues = normalizeCurveValues(vv.curveValues); + const planned = workMinutesOf(vv.plannedWorkMinutes); + const actual = workMinutesOf(vv.actualWorkMinutes); + const remaining = workMinutesOf(vv.remainingWorkMinutes); const meta: WindowMeta = { ...(typeof vv.workWindowStart === 'string' ? { workWindowStart: vv.workWindowStart } : {}), ...(typeof vv.workWindowFinish === 'string' ? { workWindowFinish: vv.workWindowFinish } : {}), ...(curveValues ? { curveValues } : {}), + ...(planned !== undefined ? { plannedWorkMinutes: planned } : {}), + ...(actual !== undefined ? { actualWorkMinutes: actual } : {}), + ...(remaining !== undefined ? { remainingWorkMinutes: remaining } : {}), }; - if (meta.workWindowStart === undefined && meta.workWindowFinish === undefined && meta.curveValues === undefined) continue; + if (Object.keys(meta).length === 0) continue; indexed.push({ guid: m[1], index: parseInt(m[2], 10), meta }); } indexed.sort((a, b) => a.index - b.index); @@ -2108,6 +2127,9 @@ function extractAssignments( ...(window?.workWindowStart !== undefined ? { workWindowStart: window.workWindowStart } : {}), ...(window?.workWindowFinish !== undefined ? { workWindowFinish: window.workWindowFinish } : {}), ...(window?.curveValues !== undefined ? { curveValues: window.curveValues } : {}), + ...(window?.plannedWorkMinutes !== undefined ? { plannedWorkMinutes: window.plannedWorkMinutes } : {}), + ...(window?.actualWorkMinutes !== undefined ? { actualWorkMinutes: window.actualWorkMinutes } : {}), + ...(window?.remainingWorkMinutes !== undefined ? { remainingWorkMinutes: window.remainingWorkMinutes } : {}), }); } } diff --git a/src/services/ifc/ifcWriter.ts b/src/services/ifc/ifcWriter.ts index efe0a35d7..2dd2a426d 100644 --- a/src/services/ifc/ifcWriter.ts +++ b/src/services/ifc/ifcWriter.ts @@ -409,6 +409,11 @@ function writeStructure( projSettingProps.push(addLine(ctx, '_ps_defaultdurationunit', `IFCPROPERTYSINGLEVALUE('DefaultTaskDurationUnit',$,IFCLABEL(${ifcStr(project.defaultTaskDurationUnit)}),$)`)); } + // Taaktypes-etappe (spec §4.1): projectstandaard-werkregel, alleen wanneer gezet (golden rule). + if (project.defaultWorkRule) { + projSettingProps.push(addLine(ctx, '_ps_defaultworkrule', + `IFCPROPERTYSINGLEVALUE('DefaultWorkRule',$,IFCLABEL(${ifcStr(project.defaultWorkRule)}),$)`)); + } if (project.statusDate) { projSettingProps.push(addLine(ctx, '_ps_statusdate', `IFCPROPERTYSINGLEVALUE('StatusDate',$,IFCDATE(${ifcStr(project.statusDate)}),$)`)); @@ -1200,18 +1205,26 @@ function writeTimephasedMeta( // Zelfde defensie/filter als writeAssignmentMeta hierboven — bepaalt hetzelfde `#index`. const list = byTask.get(task.id)?.filter(a => ref(ctx, `res_${a.resourceId}`) !== '#0'); if (!list || list.length === 0) continue; - const windows: Record = {}; + const windows: Record = {}; list.forEach((a, index) => { // Contour-engine (2026-09): `curveValues` (de exacte 21-punts P6-/MSPDI-curve) reist in // hetzelfde JSON-blob mee — additief, een toewijzing zonder venster én zonder curve schrijft - // nog altijd niets (byte-identiek). - if (a.workWindowStart === undefined && a.workWindowFinish === undefined && a.curveValues === undefined) return; + // nog altijd niets (byte-identiek). Taaktypes-etappe (spec §4.3/§4.4): de drie optionele + // werkvelden (begroot/verricht/resterend, minuten) idem — zelfde blob, zelfde `GUID#N`-sleutel. + if (a.workWindowStart === undefined && a.workWindowFinish === undefined && a.curveValues === undefined + && a.plannedWorkMinutes === undefined && a.actualWorkMinutes === undefined && a.remainingWorkMinutes === undefined) return; const resGuid = guidOf(ctx, a.resourceId); const propName = `${resGuid}#${index}`; windows[propName] = { ...(a.workWindowStart !== undefined ? { workWindowStart: a.workWindowStart } : {}), ...(a.workWindowFinish !== undefined ? { workWindowFinish: a.workWindowFinish } : {}), ...(a.curveValues !== undefined ? { curveValues: [...a.curveValues] } : {}), + ...(a.plannedWorkMinutes !== undefined ? { plannedWorkMinutes: a.plannedWorkMinutes } : {}), + ...(a.actualWorkMinutes !== undefined ? { actualWorkMinutes: a.actualWorkMinutes } : {}), + ...(a.remainingWorkMinutes !== undefined ? { remainingWorkMinutes: a.remainingWorkMinutes } : {}), }; }); if (Object.keys(windows).length === 0) continue; diff --git a/src/services/mcp/tools/readTools.ts b/src/services/mcp/tools/readTools.ts index 56bdb78c7..0b3795158 100644 --- a/src/services/mcp/tools/readTools.ts +++ b/src/services/mcp/tools/readTools.ts @@ -445,6 +445,10 @@ function getTask(s: AppState, args: GetTaskArgs) { resourceName: resById.get(a.resourceId)?.name ?? null, unitsPerDay: a.unitsPerDay, curve: a.curve ?? 'UNIFORM', + // Taaktypes-etappe (spec §4.3): de drie werkvelden, alleen wanneer gezet (afwezig ⇒ afgeleid). + ...(a.plannedWorkMinutes !== undefined ? { plannedWorkMinutes: a.plannedWorkMinutes } : {}), + ...(a.actualWorkMinutes !== undefined ? { actualWorkMinutes: a.actualWorkMinutes } : {}), + ...(a.remainingWorkMinutes !== undefined ? { remainingWorkMinutes: a.remainingWorkMinutes } : {}), })); const predecessors = s.sequences @@ -501,6 +505,8 @@ function getTask(s: AppState, args: GetTaskArgs) { ...(task.mspTaskType ? { mspTaskType: task.mspTaskType } : {}), ...(task.effortDriven ? { effortDriven: true } : {}), ...(task.timephasedContours && task.timephasedContours.length > 0 ? { timephasedContours: task.timephasedContours } : {}), + // Taaktypes-etappe (ontwerp 2026-09-04): de neutrale werkregel, leesbaar zodra gezet. + ...(task.workRule ? { workRule: task.workRule } : {}), parentId: task.parentId, childIds: task.childIds, duration: nativeDuration(task), diff --git a/src/services/mcp/tools/taskFields.ts b/src/services/mcp/tools/taskFields.ts index 06c83009e..1c9f76619 100644 --- a/src/services/mcp/tools/taskFields.ts +++ b/src/services/mcp/tools/taskFields.ts @@ -152,6 +152,9 @@ 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)', + // Taaktypes-etappe (ontwerp 2026-09-04): leesbaar via planner_get_task; zetbaar volgt in de + // MCP-stap (§10 stap 7) zodra de werkdriehoek in de store bedraad is. + workRule: 'de werkregel is via de bridge nog niet zetbaar (taaktypes-etappe, bouwstap 7) — leesbaar via planner_get_task', }; /** Uitkomst van de veldvalidatie. */ diff --git a/src/types/project.ts b/src/types/project.ts index 02b00486d..bb0c557db 100644 --- a/src/types/project.ts +++ b/src/types/project.ts @@ -1,98 +1,102 @@ -/** Voortgangs-scheduling-modus (P6, fase 2.6). undefined ⇒ RETAINED_LOGIC (de default). */ -export type ProgressMode = 'RETAINED_LOGIC' | 'PROGRESS_OVERRIDE'; - -/** - * Project-scoped reken-opties (fase 2.9, §3.4/§7). ELKE default = het huidige gedrag ⇒ een afwezig - * (of leeg) blok is byte-identiek aan vóór 2.9. Deze opties horen bij het BESTAND (net als - * statusDate/progressMode), niet bij de app-settings — anders zou hetzelfde bestand op twee machines - * een ander schema geven (§7). De solver leest ze via `CPMOptions.schedulingOptions`. - */ -export interface SchedulingOptions { - /** Kalender voor relatie-lag (P6 4-way, Rapport B §7.1). Default 'predecessor' = de huidige - * LAG_CALENDAR-constante (lagCalendar.ts) ⇒ byte-identiek. */ - lagCalendar?: 'predecessor' | 'successor' | '24hour' | 'projectDefault'; - /** Kritiek-definitie. Default { mode:'totalFloat', threshold:0 } = het huidige tf≤0. */ - criticalDefinition?: { mode: 'totalFloat' | 'longestPath'; threshold?: number }; // threshold mag negatief (P6) - /** TF-berekeningswijze. Default 'smallest' = de huidige min(finish,start)-float. */ - totalFloatMode?: 'start' | 'finish' | 'smallest'; - /** Open-ended taken kritiek? Default = huidig gedrag (een eindtaak krijgt tf via LF−EF). */ - makeOpenEndedCritical?: boolean; - /** Near-critical-drempel in werkdagen (fractioneel in uur-modus). Default undefined ⇒ feature uit. */ - nearCriticalThreshold?: number; - /** Multiple float paths. Default undefined ⇒ uit (byte-identiek). */ - floatPaths?: { enabled: boolean; method: 'FREE_FLOAT' | 'TOTAL_FLOAT'; maxPaths: number }; - /** T9 (voortgangsafronding, MSP-pariteit): MS Project hervat het restwerk van een IN PROGRESS- - * taak NIET op `max(statusDate, voorganger-druk)` (P6's eigen RETAINED_LOGIC-conventie, de - * huidige/default-berekening — zie `CPMSolver.ts`'s voortgangstak) maar op `actualStart + - * reeds-verstreken-duur` (`scheduleDuration/durationMinutes − remaining`, doorgesnapt via - * dezelfde werk-optelling als het restwerk zelf) — als DERDE, uitsluitend VERHOGENDE vloer naast - * de bestaande twee. Default `undefined`/`false` ⇒ het bestaande, P6-getrouwe gedrag, byte- - * identiek (bewaakt door `cases-progress.json`'s Scenario A/B/C, die P6's eigen gedocumenteerde - * RETAINED_LOGIC-semantiek toetsen — dít veld mag die niet stilzwijgend wijzigen). Uitsluitend - * `true` gezet door `mppReader.ts` (élke `.mpp`-import) — MSPDI/P6/CSV/IFC blijven op de - * bestaande P6-semantiek tot een eigen, apart gemeten reden om ze ook om te zetten. */ - resumeFromActualElapsed?: boolean; - /** B1 (eindreview T16c, dossier (c)4-herdiagnose): MS Project verschuift een NIET-GESTARTE taak - * (`completion === 0`) NIET automatisch naar op-of-ná de statusdatum — dat "NIET GESTART: - * statusdatum als ondergrens"-gedrag (`CPMSolver.ts`'s forward-pass, vlak vóór - * `addDurationChecked`) is P6-eigen RETAINED_LOGIC-semantiek, net als `resumeFromActualElapsed` - * hierboven een P6-conventie vervangt voor de VOORTGANG-tak. Bewijs (reviewer-meting, - * `calendar-exception-precedence.mpp`, publiek MPXJ-fixture): mét de vloer wijkt de taak ~8 jaar - * af van MS Project se eigen opgeslagen Start/Finish (de statusdatum, 2023-05-01, ligt ver ná de - * taak se eigen 2015-10-01-anker); zónder de vloer is het resultaat minuut-exact. Een EIGEN, - * sibling-vlag i.p.v. hergebruik van `resumeFromActualElapsed` zelf: die twee bestrijken - * disjuncte taak-populaties (`completion > 0` resp. `completion === 0`) en een taak kan hooguit - * in één van beide vallen — samenvoegen tot één vlag zou geen enkel gedrag delen, alleen de - * "MSP-voortgangsconventie i.p.v. P6-RETAINED_LOGIC"-FAMILIE is gedeeld. Default `undefined`/ - * `false` ⇒ het bestaande, P6-getrouwe gedrag, byte-identiek (bewaakt door dezelfde - * `cases-progress.json`-scenario's als `resumeFromActualElapsed`). Uitsluitend `true` gezet door - * `mppReader.ts` (élke `.mpp`-import); MSPDI/P6/CSV/IFC blijven op de bestaande P6-semantiek — - * het P6-pad (`progressMode`/statusdatum-gedreven planningen) behoudt de vloer bewust: dat is - * precies de RETAINED_LOGIC-conventie die P6 zélf documenteert. */ - unstartedIgnoresStatusDate?: boolean; -} - +/** Voortgangs-scheduling-modus (P6, fase 2.6). undefined ⇒ RETAINED_LOGIC (de default). */ +export type ProgressMode = 'RETAINED_LOGIC' | 'PROGRESS_OVERRIDE'; + +/** + * Project-scoped reken-opties (fase 2.9, §3.4/§7). ELKE default = het huidige gedrag ⇒ een afwezig + * (of leeg) blok is byte-identiek aan vóór 2.9. Deze opties horen bij het BESTAND (net als + * statusDate/progressMode), niet bij de app-settings — anders zou hetzelfde bestand op twee machines + * een ander schema geven (§7). De solver leest ze via `CPMOptions.schedulingOptions`. + */ +export interface SchedulingOptions { + /** Kalender voor relatie-lag (P6 4-way, Rapport B §7.1). Default 'predecessor' = de huidige + * LAG_CALENDAR-constante (lagCalendar.ts) ⇒ byte-identiek. */ + lagCalendar?: 'predecessor' | 'successor' | '24hour' | 'projectDefault'; + /** Kritiek-definitie. Default { mode:'totalFloat', threshold:0 } = het huidige tf≤0. */ + criticalDefinition?: { mode: 'totalFloat' | 'longestPath'; threshold?: number }; // threshold mag negatief (P6) + /** TF-berekeningswijze. Default 'smallest' = de huidige min(finish,start)-float. */ + totalFloatMode?: 'start' | 'finish' | 'smallest'; + /** Open-ended taken kritiek? Default = huidig gedrag (een eindtaak krijgt tf via LF−EF). */ + makeOpenEndedCritical?: boolean; + /** Near-critical-drempel in werkdagen (fractioneel in uur-modus). Default undefined ⇒ feature uit. */ + nearCriticalThreshold?: number; + /** Multiple float paths. Default undefined ⇒ uit (byte-identiek). */ + floatPaths?: { enabled: boolean; method: 'FREE_FLOAT' | 'TOTAL_FLOAT'; maxPaths: number }; + /** T9 (voortgangsafronding, MSP-pariteit): MS Project hervat het restwerk van een IN PROGRESS- + * taak NIET op `max(statusDate, voorganger-druk)` (P6's eigen RETAINED_LOGIC-conventie, de + * huidige/default-berekening — zie `CPMSolver.ts`'s voortgangstak) maar op `actualStart + + * reeds-verstreken-duur` (`scheduleDuration/durationMinutes − remaining`, doorgesnapt via + * dezelfde werk-optelling als het restwerk zelf) — als DERDE, uitsluitend VERHOGENDE vloer naast + * de bestaande twee. Default `undefined`/`false` ⇒ het bestaande, P6-getrouwe gedrag, byte- + * identiek (bewaakt door `cases-progress.json`'s Scenario A/B/C, die P6's eigen gedocumenteerde + * RETAINED_LOGIC-semantiek toetsen — dít veld mag die niet stilzwijgend wijzigen). Uitsluitend + * `true` gezet door `mppReader.ts` (élke `.mpp`-import) — MSPDI/P6/CSV/IFC blijven op de + * bestaande P6-semantiek tot een eigen, apart gemeten reden om ze ook om te zetten. */ + resumeFromActualElapsed?: boolean; + /** B1 (eindreview T16c, dossier (c)4-herdiagnose): MS Project verschuift een NIET-GESTARTE taak + * (`completion === 0`) NIET automatisch naar op-of-ná de statusdatum — dat "NIET GESTART: + * statusdatum als ondergrens"-gedrag (`CPMSolver.ts`'s forward-pass, vlak vóór + * `addDurationChecked`) is P6-eigen RETAINED_LOGIC-semantiek, net als `resumeFromActualElapsed` + * hierboven een P6-conventie vervangt voor de VOORTGANG-tak. Bewijs (reviewer-meting, + * `calendar-exception-precedence.mpp`, publiek MPXJ-fixture): mét de vloer wijkt de taak ~8 jaar + * af van MS Project se eigen opgeslagen Start/Finish (de statusdatum, 2023-05-01, ligt ver ná de + * taak se eigen 2015-10-01-anker); zónder de vloer is het resultaat minuut-exact. Een EIGEN, + * sibling-vlag i.p.v. hergebruik van `resumeFromActualElapsed` zelf: die twee bestrijken + * disjuncte taak-populaties (`completion > 0` resp. `completion === 0`) en een taak kan hooguit + * in één van beide vallen — samenvoegen tot één vlag zou geen enkel gedrag delen, alleen de + * "MSP-voortgangsconventie i.p.v. P6-RETAINED_LOGIC"-FAMILIE is gedeeld. Default `undefined`/ + * `false` ⇒ het bestaande, P6-getrouwe gedrag, byte-identiek (bewaakt door dezelfde + * `cases-progress.json`-scenario's als `resumeFromActualElapsed`). Uitsluitend `true` gezet door + * `mppReader.ts` (élke `.mpp`-import); MSPDI/P6/CSV/IFC blijven op de bestaande P6-semantiek — + * het P6-pad (`progressMode`/statusdatum-gedreven planningen) behoudt de vloer bewust: dat is + * precies de RETAINED_LOGIC-conventie die P6 zélf documenteert. */ + unstartedIgnoresStatusDate?: boolean; +} + export interface Project { - id: string; - name: string; - description: string; - startDate: string; // ISO 8601 - endDate: string; - calendarId: string; - createdAt: string; - modifiedAt: string; - author: string; + id: string; + name: string; + description: string; + startDate: string; // ISO 8601 + endDate: string; + calendarId: string; + createdAt: string; + modifiedAt: string; + author: string; company: string; /** Projectstandaard voor handmatig aangemaakte taken. De urenplanning-hoofdschakelaar blijft de * capabilitypoort: staat die uit, dan maakt de UI ondanks deze bewaarde voorkeur dagtaken. */ defaultTaskDurationUnit?: import('@/types/task').TaskDurationUnit; - /** - * WBS-codes automatisch nummeren (1.2.3.4, afgeleid uit de boompositie): aan ⇒ live - * hernummeren bij elke structuurmutatie; uit/ontbreekt ⇒ vrije tekst (bestaand gedrag), - * met een expliciete "Hernummer WBS"-actie. Nieuwe projecten krijgen true; geladen - * bestanden zonder vlag blijven op vrije tekst (MSP-stabiliteitsmodel: codes in - * omloop worden niet stilzwijgend herschreven). - */ - wbsAutoNumber?: boolean; - /** P6 "data date" (fase 2.6): de grens verleden/toekomst. undefined = geen statusdatum ⇒ gedrag - * exact als vóór 2.6. Gezet ⇒ remaining werk kan niet vóór deze dag starten. */ - statusDate?: string; // ISO — date-only in dag-modus; mag datetime zijn in uur-modus (fase 2.8b, §3.4) - /** Voortgangs-scheduling-modus (fase 2.6). undefined ⇒ RETAINED_LOGIC. Documentinstelling. */ - progressMode?: ProgressMode; - /** OPTIONEEL — project-scoped reken-opties (fase 2.9, §3.4/§7). Afwezig ⇒ elke default ⇒ - * byte-identiek gedrag. */ - schedulingOptions?: SchedulingOptions; - /** OPTIONEEL — projectbinding aan een bedrijfsbibliotheek (spec B1, §2). Afwezig ⇒ project is - * (nog) aan geen enkel bedrijf gebonden; heropening zonder de pool is onschuldig. `companyName` - * is een gedenormaliseerde cache zodat een gedeeld bestand het bedrijf toont zonder de pool. */ - companyId?: string; - companyName?: string; -} - -export interface ProjectStats { - totalTasks: number; - totalMilestones: number; - criticalPathLength: number; // in work days - totalFloat: number; // in work days - percentComplete: number; // 0-100 -} + /** OPTIONEEL — projectstandaard-werkregel (taaktypes-etappe, spec §4.1): geldt voor elke taak + * zonder eigen `workRule`. Afwezig ⇒ FIXED_DURATION_RATE (vandaag). Round-tript via + * `OPS_ProjectSettings` (`DefaultWorkRule`). */ + defaultWorkRule?: import('@/types/workRule').WorkRule; + /** + * WBS-codes automatisch nummeren (1.2.3.4, afgeleid uit de boompositie): aan ⇒ live + * hernummeren bij elke structuurmutatie; uit/ontbreekt ⇒ vrije tekst (bestaand gedrag), + * met een expliciete "Hernummer WBS"-actie. Nieuwe projecten krijgen true; geladen + * bestanden zonder vlag blijven op vrije tekst (MSP-stabiliteitsmodel: codes in + * omloop worden niet stilzwijgend herschreven). + */ + wbsAutoNumber?: boolean; + /** P6 "data date" (fase 2.6): de grens verleden/toekomst. undefined = geen statusdatum ⇒ gedrag + * exact als vóór 2.6. Gezet ⇒ remaining werk kan niet vóór deze dag starten. */ + statusDate?: string; // ISO — date-only in dag-modus; mag datetime zijn in uur-modus (fase 2.8b, §3.4) + /** Voortgangs-scheduling-modus (fase 2.6). undefined ⇒ RETAINED_LOGIC. Documentinstelling. */ + progressMode?: ProgressMode; + /** OPTIONEEL — project-scoped reken-opties (fase 2.9, §3.4/§7). Afwezig ⇒ elke default ⇒ + * byte-identiek gedrag. */ + schedulingOptions?: SchedulingOptions; + /** OPTIONEEL — projectbinding aan een bedrijfsbibliotheek (spec B1, §2). Afwezig ⇒ project is + * (nog) aan geen enkel bedrijf gebonden; heropening zonder de pool is onschuldig. `companyName` + * is een gedenormaliseerde cache zodat een gedeeld bestand het bedrijf toont zonder de pool. */ + companyId?: string; + companyName?: string; +} + +export interface ProjectStats { + totalTasks: number; + totalMilestones: number; + criticalPathLength: number; // in work days + totalFloat: number; // in work days + percentComplete: number; // 0-100 +} diff --git a/src/types/resource.ts b/src/types/resource.ts index 399d6cd32..95613c3e2 100644 --- a/src/types/resource.ts +++ b/src/types/resource.ts @@ -69,4 +69,17 @@ export interface ResourceAssignment { workWindowStart?: string; /** OPTIONEEL — zie `workWindowStart`. Afwezig ⇒ geen venster (byte-identiek). */ workWindowFinish?: string; + /** OPTIONEEL (taaktypes-etappe, spec §4.3) — BEGROOT werk in werkminuten (MSP Work, P6 Planned + * Units, XER target_qty). Referentie; wordt door de werkdriehoek nooit herschreven. Afwezig ⇒ + * afgeleid als verricht + resterend. Round-tript in het `OPS_Timephased`-JSON-blob. */ + plannedWorkMinutes?: number; + /** OPTIONEEL (spec §4.3) — VERRICHT werk in werkminuten (MSP Actual Work, P6 Actual Units, XER + * act_reg_qty + act_ot_qty). Een feit: geen planningsbewerking raakt het. Afwezig ⇒ de som van + * de `actual`-periodes van de contour, anders 0. */ + actualWorkMinutes?: number; + /** OPTIONEEL (spec §4.3) — RESTEREND werk in werkminuten (MSP Remaining Work, P6 Remaining + * Units, XER remain_qty). Het getal waar de werkdriehoek op werkt. Afwezig ⇒ afgeleid als + * restduur × `unitsPerDay` (het gedrag van vandaag, byte-identiek); een werkbeschermende regel + * legt het vast zodra ze het nodig heeft (`workTriangle.ts`). */ + remainingWorkMinutes?: number; } diff --git a/src/types/task.ts b/src/types/task.ts index 4a8f0eeab..dec708c49 100644 --- a/src/types/task.ts +++ b/src/types/task.ts @@ -175,6 +175,9 @@ export interface TaskTimephasedContour { */ export type MspTaskType = 'FIXED_UNITS' | 'FIXED_DURATION' | 'FIXED_WORK'; +/** Taaktypes-etappe (ontwerp 2026-09-04 §4.1): de neutrale werkregel, zie `@/types/workRule`. */ +export type { WorkRule } from '@/types/workRule'; + /** * Soort mijlpaal (fase 2.4, P6 Start/Finish Milestone). Dag-granulair grens-model: * START ankert op een dagbegin, FINISH op een dageinde (einde werkdag F = begin @@ -471,6 +474,14 @@ 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 — de WERKREGEL van deze taak (taaktypes-etappe, ontwerp 2026-09-04 §4.1): welke + * hoeken van werk = duur × inzet beschermd zijn bij een bewerking (`WorkRule`, neutraal tussen + * MSP en P6). Afwezig ⇒ `Project.defaultWorkRule`, en als die ook ontbreekt FIXED_DURATION_RATE + * (het gedrag van vandaag, byte-identiek). Bij import AFGELEID uit `mspTaskType`+`effortDriven` + * resp. het P6-duurtype (spec §4.2) en apart bewaard, zodat een latere typewissel de herkomst + * niet vernietigt. Geen enkele solverstap leest dit; alleen de bewerkingslaag + * (`src/engine/work/workTriangle.ts`). Round-tript via `OPS_WorkRule` (`ifcPsets.ts`). */ + workRule?: import('@/types/workRule').WorkRule; 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 cd02daab9..316cd5860 100644 --- a/tests/planning/check-ext-contract.ts +++ b/tests/planning/check-ext-contract.ts @@ -74,7 +74,7 @@ function keys() { const EXT_PROJECT_KEYS = keys()([ 'id', 'name', 'description', 'startDate', 'endDate', 'calendarId', 'createdAt', 'modifiedAt', - 'author', 'company', 'wbsAutoNumber', 'statusDate', 'progressMode', 'defaultTaskDurationUnit', 'schedulingOptions', + 'author', 'company', 'wbsAutoNumber', 'statusDate', 'progressMode', 'defaultTaskDurationUnit', 'defaultWorkRule', 'schedulingOptions', ] as const); const EXT_CALENDAR_KEYS = keys()([ @@ -98,6 +98,8 @@ const EXT_TASK_KEYS = keys()([ 'levelingDelayMinutes', 'levelingDelayElapsed', 'splitGaps', 'manuallyScheduled', 'mspTaskType', 'effortDriven', 'timephasedContours', 'timephasedFinishFloor', 'timephasedStartAnchor', 'timephasedDurationWalks', + // taaktypes-etappe (ontwerp 2026-09-04): de neutrale werkregel + 'workRule', ] as const); const EXT_SEQUENCE_KEYS = keys()([ @@ -111,6 +113,8 @@ const EXT_RESOURCE_KEYS = keys()([ const EXT_ASSIGNMENT_KEYS = keys()([ 'id', 'taskId', 'resourceId', 'unitsPerDay', 'curve', 'workWindowStart', 'workWindowFinish', 'curveValues', + // taaktypes-etappe (spec §4.3): de drie optionele werkvelden + 'plannedWorkMinutes', 'actualWorkMinutes', 'remainingWorkMinutes', ] as const); // ── (c) Interne velden die BEWUST niet oversteken ──────────────────────────── @@ -180,6 +184,7 @@ const VOL_TASK = { manuallyScheduled: true, mspTaskType: 'FIXED_WORK', effortDriven: true, + workRule: 'FIXED_RATE', timephasedContours: [{ resourceUid: 3, periods: [{ afterMinutes: 0, minutes: 480, workMinutes: 240, kind: 'remaining' }] }], timephasedFinishFloor: '2026-06-10T17:00', timephasedStartAnchor: '2026-06-01T08:00', @@ -212,6 +217,7 @@ const VOL_PROJECT = { author: 'Auteur', company: 'Bedrijf', wbsAutoNumber: true, statusDate: '2026-06-01', progressMode: 'PROGRESS_OVERRIDE', defaultTaskDurationUnit: 'days', + defaultWorkRule: 'FIXED_DURATION_WORK', schedulingOptions: { lagCalendar: 'successor', criticalDefinition: { mode: 'longestPath', threshold: -1 }, @@ -253,6 +259,7 @@ const VOL_ASSIGNMENT = { id: 'a1', taskId: 't1', resourceId: 'r1', unitsPerDay: 0.5, curve: 'BELL', workWindowStart: '2026-06-01T08:00', workWindowFinish: '2026-06-10T17:00', curveValues: [0, 6.5, 6.5, 6.5, 6.5, 6.5, 6.5, 6.5, 6.5, 6.5, 6.5, 3.5, 3.5, 3.5, 3.5, 3.5, 3.5, 3.5, 3.5, 3.5, 3.5], + plannedWorkMinutes: 4800, actualWorkMinutes: 1200, remainingWorkMinutes: 3000, } satisfies Required; // ── 1. `toExt*` laat geen contractveld vallen ──────────────────────────────── diff --git a/tests/planning/check-ifc-roundtrip.ts b/tests/planning/check-ifc-roundtrip.ts index 4b547ed0c..88e32823b 100644 --- a/tests/planning/check-ifc-roundtrip.ts +++ b/tests/planning/check-ifc-roundtrip.ts @@ -259,6 +259,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, + // Taaktypes-etappe (spec §4.1): de neutrale werkregel (`OPS_WorkRule`) — bewust een ANDERE waarde + // dan wat uit mspTaskType zou volgen, zodat de round-trip het eigen veld bewijst. + workRule: 'FIXED_DURATION_WORK', parentId: 't-p', childIds: [], resourceIds: [], // milestone zonder assignments ⇒ afgeleide resourceIds is leeg (H2-fix) color: '#abcdef', // round-trippt via OPS_TaskAppearance (H2-fix) @@ -349,6 +352,9 @@ const A1 = { workWindowStart: '2026-07-06', workWindowFinish: '2026-07-08', // Contour-engine (2026-09): exacte 21-punts curve (P6/MSPDI) reist in hetzelfde JSON-pset mee. curveValues: [0, 1.3, 2.5, 3.8, 5.1, 7.6, 10.1, 7.6, 5.1, 3.8, 2.5, 2.5, 2.5, 3.8, 5.1, 7.6, 10.1, 7.6, 5.1, 3.8, 2.5], + // Taaktypes-etappe (spec §4.3): drie werkvelden in minuten, onderscheidend (begroot ≠ verricht + rest, + // zodat een writer die één veld uit de andere afleidt niet stil goedgekeurd wordt). + plannedWorkMinutes: 9600, actualWorkMinutes: 4800, remainingWorkMinutes: 7200, } satisfies Required; const assignments: ResourceAssignment[] = [ A1, @@ -377,6 +383,7 @@ const project = { author: 'Ir. Testz', company: 'Bouw BV', // (a) gaps wbsAutoNumber: true, statusDate: '2026-07-25', progressMode: 'PROGRESS_OVERRIDE', defaultTaskDurationUnit: 'days', + defaultWorkRule: 'FIXED_RATE', // taaktypes-etappe: OPS_ProjectSettings/DefaultWorkRule companyId: 'c-fixture', companyName: 'Fixture Bouw BV', schedulingOptions: SCHED_OPTS, } satisfies Required & { schedulingOptions: Required }; @@ -554,6 +561,8 @@ const TASK_CANON = { timephasedContours: KEEP, // Z14b — MSP's eigen task-type/effort-driven-vlag (`OPS_MspTaskType`), puur data, geen verwijzing. mspTaskType: KEEP, effortDriven: KEEP, + // Taaktypes-etappe — de neutrale werkregel (`OPS_WorkRule`), puur data, geen verwijzing. + workRule: KEEP, 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) }, @@ -606,6 +615,8 @@ const ASSIGNMENT_CANON = { workWindowStart: KEEP, workWindowFinish: KEEP, // Contour-engine (2026-09): `curveValues` in hetzelfde `OPS_Timephased`-pset — echte KEEP. curveValues: KEEP, + // Taaktypes-etappe (spec §4.3): de drie werkvelden in hetzelfde JSON-blob — echte KEEP. + plannedWorkMinutes: KEEP, actualWorkMinutes: KEEP, remainingWorkMinutes: KEEP, } satisfies CanonSpec; const PROJECT_CANON = { @@ -617,6 +628,7 @@ const PROJECT_CANON = { createdAt: KEEP, modifiedAt: KEEP, author: KEEP, company: KEEP, wbsAutoNumber: KEEP, statusDate: KEEP, progressMode: KEEP, schedulingOptions: KEEP, defaultTaskDurationUnit: KEEP, + defaultWorkRule: KEEP, // taaktypes-etappe: OPS_ProjectSettings/DefaultWorkRule // B1.1: bedrijfsbinding round-trippt via OPS_CompanyBinding. companyId: KEEP, companyName: KEEP, } satisfies CanonSpec; From e16364859eb57a865e1c93d4a123a18d2df8800a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 12:09:14 +0000 Subject: [PATCH 142/152] =?UTF-8?q?feat(taaktypes):=20stap=202=20=E2=80=94?= =?UTF-8?q?=20importvertaling=20en=20export=20van=20werkregel=20en=20werkv?= =?UTF-8?q?elden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ontwerp 2026-09-04 §4.2/§4.3/§4.4, bouwstap 2 (ná de merge met de XER-branch): - `importNormalize.deriveImportedWorkRules`: één afleiding van `workRule` uit de bewaarde importvelden (MSP: mspTaskType+effortDriven; P6/XER: p6DurationType), aangeroepen in de .mpp-, MSPDI-, P6 XML- en XER-lezer. Importvelden blijven onaangeraakt; een bestaande regel wint. - MSPDI: lezer leest task-level (0/1/2) en , writer schrijft ze uit de werkregel (bewaard MSP-vinkje wint, beslispunt 8-B); toewijzingen: Work/ActualWork/RemainingWork ↔ de drie werkvelden, alleen bewaard bij afwijking van duur × inzet (`importedWorkFields`). - P6 XML: beide kanten; ActualUnits/PlannedUnits/ RemainingUnits (uren) ↔ werkvelden op hun alfabetische PMXML-plek. FIX: de XER-branch had de labels "Fixed Duration and Units" en "Fixed Duration and Units/Time" verwisseld (DT_FixedDUR2 resp. DT_FixedDrtn); gecorrigeerd volgens Oracle's XER Import/Export Data Map Guide, ook in check-xer-p6xml-parity (die nu de DurationType-uitzondering op de asymmetrie vastlegt). - XER: TASKRSRC target_qty / act_reg_qty + act_ot_qty / remain_qty → werkvelden, alleen bij afwijking (afspraak met de XER-etappe); materiaal buiten de driehoek; `XerResourceReadContext.taskWorkMinutes` optioneel. - Nieuw: `check-work-rule-mapping.ts` (37 checks: tabellen, afleiding, MSPDI-/P6 XML-round-trips, XER-fixture). Fidelity-poorten ongewijzigd groen (mpp 678, xer 47); spec §4.2 bijgewerkt. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0159unb6VzxUx7WG4w3a2ARA --- ...26-09-04-spec-taaktypes-opgeslagen-werk.md | 9 +- src/services/importNormalize.ts | 22 ++ src/services/mpp/mppReader.ts | 3 +- src/services/msproject/mspdiReader.ts | 29 +- src/services/msproject/mspdiWriter.ts | 33 ++- src/services/p6/p6xmlReader.ts | 37 ++- src/services/p6/p6xmlWriter.ts | 22 ++ src/services/xer/xerReader.ts | 13 + src/services/xer/xerResourceAssignments.ts | 17 +- src/services/xer/xerResourceTypes.ts | 4 + tests/planning/check-work-rule-mapping.ts | 248 ++++++++++++++++++ tests/planning/check-xer-p6xml-parity.ts | 20 +- tests/planning/run.sh | 3 + 13 files changed, 440 insertions(+), 20 deletions(-) create mode 100644 tests/planning/check-work-rule-mapping.ts diff --git a/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md b/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md index 560b7ddf3..3a6f5f676 100644 --- a/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md +++ b/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md @@ -291,10 +291,13 @@ opgeslagen**, zodat een latere typewissel de herkomst niet vernietigt: | | `DT_FixedDUR2` | FIXED_DURATION_WORK | | | `DT_FixedQty` | FIXED_WORK | | | `DT_FixedRate` | FIXED_RATE | -| | `DT_FixedDUR` (niet-standaard, 24 activiteiten in p6difftool-fixtures, GEMETEN) | **geen** werkregel; token blijft rauw bewaard in `p6DurationType`; één waarschuwing. Niet raden. | +| | `DT_FixedDUR` (niet-standaard, 24 activiteiten in p6difftool-fixtures, GEMETEN) | de XER-lezer zelf valt bij een onbekend token gerapporteerd terug op de projectstandaard (`PROJECT.def_duration_type`, XER-etappeplan §4.7) en zet dát als `p6DurationType`; de werkregel volgt die standaard. Het rauwe token blijft in het bronarchief. (Bijgewerkt 2026-09-05 na de merge met de XER-branch; de oude regel "geen werkregel" is daarmee vervallen.) | -De exacte tekenreeksen van P6 XML's `` zijn hier niet tegen het PMXML-schema -gecontroleerd — te verifiëren tegen MPXJ's `DurationTypeHelper` bij de bouw (ONBEKEND tot dan). +De P6 XML-labels zijn geverifieerd tegen de P6 EPPM REST-documentatie van het Activity-object +(ZEKER; dezelfde enum als PMXML) en de XER-token↔label-paren tegen Oracle's XER Import/Export Data +Map Guide (TASK.duration_type): `DT_FixedDrtn` = "Fixed Duration and Units/Time", `DT_FixedDUR2` = +"Fixed Duration and Units". Let op: de P6 XML-lezer van de XER-branch had die twee labels +verwisseld; bouwstap 2 heeft dat gecorrigeerd (`p6xmlReader.ts`, `check-xer-p6xml-parity.ts`). De XER-tokens komen van de XER-sessie (ZEKER voor hun branch). `mppReader.ts`'s `MSP_TASK_TYPE_VALUES` gebruikt de volgorde 0/1/2 = Fixed Units/Fixed Duration/Fixed Work (ZEKER); dat MSPDI's task-level `` dezelfde nummering draagt is een externe bewering die de repo nergens vastlegt — te verifiëren tegen MPXJ's diff --git a/src/services/importNormalize.ts b/src/services/importNormalize.ts index fb3dfe7b5..6b02ade49 100644 --- a/src/services/importNormalize.ts +++ b/src/services/importNormalize.ts @@ -1,5 +1,27 @@ import type { Task } from '@/types/task'; import { formatDate } from '@/utils/dateUtils'; +import { workRuleFromMsp, workRuleFromXerDurationType } from '@/engine/work/workRuleMapping'; + +/** + * Taaktypes-etappe (ontwerp 2026-09-04 §4.2), bouwstap 2 — de WERKREGEL afleiden uit de bewaarde + * importvelden, één keer, in elke lezer die zulke velden zet (.mpp, MSPDI, P6 XML, XER). De + * importvelden zelf blijven onaangeraakt (O3-precedent: één klasse taakvelden); de regel is een + * apart opgeslagen afgeleide, zodat een latere typewissel de herkomst niet vernietigt. + * - MSP: `mspTaskType` + `effortDriven` → `workRuleFromMsp` (Fixed Units ⇒ FIXED_RATE, Fixed + * Duration ± effort-driven ⇒ FIXED_DURATION_WORK/-RATE, Fixed Work ⇒ FIXED_WORK). + * - P6 (XER én PMXML, beide via `p6DurationType`): `workRuleFromXerDurationType`. + * Een taak die al een `workRule` draagt (IFC) wordt niet overschreven; geen bron ⇒ geen veld + * (byte-identiek). Puur data — géén solverstap leest het; het gedrag komt in de bewerkingslaag. + */ +export function deriveImportedWorkRules(tasks: Task[]): void { + for (const task of tasks) { + if (task.workRule) continue; + const rule = task.mspTaskType + ? workRuleFromMsp(task.mspTaskType, task.effortDriven) + : workRuleFromXerDurationType(task.p6DurationType); + if (rule) task.workRule = rule; + } +} /** * Fase 2.6 — voortgang-invarianten toepassen op RAUW ingelezen taken (IFC/MSPDI/P6/CSV). diff --git a/src/services/mpp/mppReader.ts b/src/services/mpp/mppReader.ts index f42251066..22907f99d 100644 --- a/src/services/mpp/mppReader.ts +++ b/src/services/mpp/mppReader.ts @@ -136,7 +136,7 @@ import type { Resource, ResourceType } from '@/types/resource'; import type { ImportLabels, ImportResult } from '@/services/importTypes'; import { generateId } from '@/utils/id'; import { formatDate, formatInstant, isoDayOfWeek, parseInstant } from '@/utils/dateUtils'; -import { normalizeImportedProgress } from '@/services/importNormalize'; +import { normalizeImportedProgress, deriveImportedWorkRules } from '@/services/importNormalize'; import { tenthsOfMinutesToDays } from '@/services/importDurations'; import { mspCodeToConstraint } from '@/services/msproject/mspdiReader'; import { hasNonAnchorTime, isSubDayMinutes } from '@/services/subdayIo'; @@ -1283,6 +1283,7 @@ export function readTasks(ctx: ReadTasksContext): ReadTasksResult { const tasks = records.map((r) => r.task); normalizeImportedProgress(tasks, statusDate); + deriveImportedWorkRules(tasks); // taaktypes-etappe: werkregel uit mspTaskType/effortDriven return { tasks, taskIdByUniqueId, taskHourById, rawScans: raws, // Z2 — zie ReadTasksResult se toelichting; readMPP hieronder geeft dit NIET door diff --git a/src/services/msproject/mspdiReader.ts b/src/services/msproject/mspdiReader.ts index 9122acf96..ce1c7cff4 100644 --- a/src/services/msproject/mspdiReader.ts +++ b/src/services/msproject/mspdiReader.ts @@ -7,7 +7,7 @@ import { createDefaultCalendar } from '@/engine/calendar/defaultCalendar'; import { Baseline, BaselineTask } from '@/types/baseline'; import { generateId } from '@/utils/id'; import { formatDate, formatInstant, parseInstant, parseDate, isoDayOfWeek } from '@/utils/dateUtils'; -import { normalizeImportedProgress, rebuildWbsHierarchy } from '@/services/importNormalize'; +import { normalizeImportedProgress, deriveImportedWorkRules, rebuildWbsHierarchy } from '@/services/importNormalize'; import { isoDatePrefixOrToday } from '@/services/importDates'; import { tenthsOfMinutesToDays } from '@/services/importDurations'; import { descendantText, toInt, toFloat } from '@/services/xmlDom'; @@ -63,6 +63,8 @@ import { absoluteItemsToContourPeriods, mspdiValueToMinutes, splitGapsFromContours, type AbsoluteWorkItem, } from '@/services/contourIo'; import type { TaskTimephasedContour } from '@/types/task'; +import { importedWorkFields, mspTaskTypeFromCode } from '@/engine/work/workRuleMapping'; +import { taskWorkMinutes } from '@/engine/contour/contourEngine'; /** Synthetisch anker dat de DAG-schrijver op date-only datetimes plakt (§7.3). */ const MSP_TIME_ANCHOR = '08:00:00'; @@ -371,6 +373,8 @@ export function readMSPDI(content: string): ImportResult { const customTaskTypes = new Map(); const uidToId = new Map(); const uidToWbs = new Map(); + // Taaktypes-etappe: effectieve uren/dag per taak, voor de werkafleiding bij de toewijzingen. + const effHpdByTaskId = new Map(); const pendingLinks: { successorId: string; predUid: number; type: number; lag: number; lagFormat: number }[] = []; // Baseline 0 (fase 2.6, §9.1): per taak de gesnapshotte Start/Finish/Duration. const baselineEntries: BaselineTask[] = []; @@ -432,6 +436,13 @@ export function readMSPDI(content: string): ImportResult { const effCalId = effCalIdOfUid(taskCalUid); const isHour = hourModeCalIds.has(effCalId); const effHpd = calById.get(effCalId)?.hoursPerDay ?? hoursPerDay; + effHpdByTaskId.set(id, effHpd); + // Taaktypes-etappe (spec §4.2/§4.4): MSP's (0/1/2 = Fixed Units/Duration/Work) en + // — dezelfde bewaarvelden als de .mpp-lezer; de werkregel volgt eruit via + // `deriveImportedWorkRules`. Ontbrekend/ongeldig ⇒ geen veld (byte-identiek). + const mspTaskType = mspTaskTypeFromCode(getElementInt(te, 'Type', -1)); + const effortDrivenRaw = getElementText(te, 'EffortDriven').trim().toLowerCase(); + const effortDriven = mspTaskType !== undefined && (effortDrivenRaw === '1' || effortDrivenRaw === 'true'); const durationStr = getElementText(te, 'Duration'); // Duur: uur ⇒ minuten (bron van waarheid, geen afronding, §7.3); dag ⇒ het bestaande dag-pad. @@ -562,6 +573,8 @@ export function readMSPDI(content: string): ImportResult { ...(constraint ? { constraint } : {}), ...(deadline ? { deadline } : {}), ...(taskCalendarId ? { calendarId: taskCalendarId } : {}), + ...(mspTaskType ? { mspTaskType } : {}), + ...(effortDriven ? { effortDriven: true } : {}), }); taskHourById.set(id, isHour); @@ -653,13 +666,24 @@ export function readMSPDI(content: string): ImportResult { const units = parseFloat(unitsText); const contour = getElementInt(asgnEl, 'WorkContour', 0); const curve = WORKCONTOUR_TO_CURVE[contour]; + const unitsPerDay = Number.isFinite(units) && unitsText ? units : 1; + // Taaktypes-etappe (spec §4.3, geval c): // in minuten, + // alleen bewaard wanneer ze iets zeggen dat `duur × inzet` niet al zegt (`importedWorkFields`). + const workTask = taskById.get(taskId); + const derivedWork = workTask ? taskWorkMinutes(workTask.time, effHpdByTaskId.get(taskId) ?? hoursPerDay) * unitsPerDay : 0; + const workFields = importedWorkFields({ + plannedMinutes: mspDurationMinutes(getElementText(asgnEl, 'Work')) ?? undefined, + actualMinutes: mspDurationMinutes(getElementText(asgnEl, 'ActualWork')) ?? undefined, + remainingMinutes: mspDurationMinutes(getElementText(asgnEl, 'RemainingWork')) ?? undefined, + }, derivedWork); assignments.push({ id: generateId('asgn'), taskId, resourceId, - unitsPerDay: Number.isFinite(units) && unitsText ? units : 1, + unitsPerDay, ...(curve && curve !== 'UNIFORM' ? { curve } : {}), + ...workFields, }); // Contour-engine (2026-09): native `` (Type 1 = resterend, 2 = verricht werk; @@ -733,6 +757,7 @@ export function readMSPDI(content: string): ImportResult { // Voortgang-invarianten op de rauw ingelezen actuals (§3.2/§15.6). normalizeImportedProgress(tasks, project.statusDate); + deriveImportedWorkRules(tasks); // taaktypes-etappe: werkregel uit / return { project, diff --git a/src/services/msproject/mspdiWriter.ts b/src/services/msproject/mspdiWriter.ts index 80bba370c..ddaf03aea 100644 --- a/src/services/msproject/mspdiWriter.ts +++ b/src/services/msproject/mspdiWriter.ts @@ -16,6 +16,7 @@ import { resolveCalendar } from '@/engine/scheduler/resolveCalendar'; import { matchContoursToAssignments, MSPDI_WORKCONTOUR_CONTOURED } from '@/engine/contour/contourEngine'; import { contourPeriodsToDayItems, minutesToMspdiValue } from '@/services/contourIo'; import { parseInstant, formatInstant } from '@/utils/dateUtils'; +import { MSPDI_TASK_TYPE_CODE, mspFromWorkRule } from '@/engine/work/workRuleMapping'; /** * MSPDI kent geen native onderscheid tussen "N werkdagen" en "N werkuren" als blijvende @@ -470,8 +471,21 @@ export function writeMSPDI( lines.push(`${indent(3)}${uid}`); lines.push(`${indent(3)}${uid}`); lines.push(`${indent(3)}${escapeXML(task.name)}`); + // Taaktypes-etappe (spec §4.2/§4.4): (0/1/2) en uit de werkregel; een + // bewaard MSP-`effortDriven` wint (`mspFromWorkRule`). Zonder werkregel valt een taak met + // alleen bewaarde MSP-velden op die velden terug; zonder beide schrijft de export niets + // (golden rule) — MS Project neemt dan zijn eigen default (Fixed Units). + const mspType = task.workRule + ? mspFromWorkRule(task.workRule, task.mspTaskType ? (task.effortDriven ?? false) : undefined) + : task.mspTaskType ? { type: task.mspTaskType, effortDriven: task.effortDriven ?? false } : undefined; + if (mspType && !isSummary) { + lines.push(`${indent(3)}${MSPDI_TASK_TYPE_CODE[mspType.type]}`); + } lines.push(`${indent(3)}${durationTag}`); lines.push(`${indent(3)}${durationFormat}`); + if (mspType && !isSummary) { + lines.push(`${indent(3)}${mspType.effortDriven ? 1 : 0}`); + } lines.push(`${indent(3)}${formatMSPDateTime(task.time.earlyStart || task.time.scheduleStart)}`); lines.push(`${indent(3)}${formatMSPDateTime(task.time.earlyFinish || task.time.scheduleFinish)}`); lines.push(`${indent(3)}${escapeXML(task.wbsCode)}`); @@ -647,10 +661,25 @@ export function writeMSPDI( lines.push(`${indent(3)}${uid}`); lines.push(`${indent(3)}${taskUid}`); lines.push(`${indent(3)}${resUid}`); + // Taaktypes-etappe (spec §4.3/§4.4): verricht en resterend werk alleen wanneer het veld er is + // (schemavolgorde: ActualWork en RemainingWork vóór Units, zie de MSPDI-Assignment-structuur). + if (a.actualWorkMinutes !== undefined) { + lines.push(`${indent(3)}${minutesToMspdiValue(a.actualWorkMinutes)}`); + } + if (a.remainingWorkMinutes !== undefined) { + lines.push(`${indent(3)}${minutesToMspdiValue(a.remainingWorkMinutes)}`); + } lines.push(`${indent(3)}${a.unitsPerDay}`); - // Werk: bij een contour de SOM van de dagverdeling (de echte werkinhoud), anders duur × units. + // Werk: het opgeslagen begrote werk als dat er is; anders bij een contour de SOM van de + // dagverdeling (de echte werkinhoud), anders duur × units. const contourWorkMinutes = dayItems.reduce((n, d) => n + d.workMinutes, 0); - lines.push(`${indent(3)}${dayItems.length > 0 ? minutesToMspdiValue(contourWorkMinutes) : durationToISO8601(workDays, calendar.hoursPerDay)}`); + const plannedWork = a.plannedWorkMinutes + ?? (a.actualWorkMinutes !== undefined || a.remainingWorkMinutes !== undefined + ? (a.actualWorkMinutes ?? 0) + (a.remainingWorkMinutes ?? 0) + : undefined); + lines.push(`${indent(3)}${plannedWork !== undefined + ? minutesToMspdiValue(plannedWork) + : dayItems.length > 0 ? minutesToMspdiValue(contourWorkMinutes) : durationToISO8601(workDays, calendar.hoursPerDay)}`); // WorkContour 8 = Contoured zodra er een echte verdeling meegaat (MPXJ `WorkContour.CONTOURED`). const contour = dayItems.length > 0 ? MSPDI_WORKCONTOUR_CONTOURED : CURVE_TO_WORKCONTOUR[a.curve ?? 'UNIFORM']; if (contour !== 0) { diff --git a/src/services/p6/p6xmlReader.ts b/src/services/p6/p6xmlReader.ts index 4be3057a6..908d1332b 100644 --- a/src/services/p6/p6xmlReader.ts +++ b/src/services/p6/p6xmlReader.ts @@ -8,7 +8,7 @@ import { WorkCalendar, Holiday } from '@/types/calendar'; import { createDefaultCalendar } from '@/engine/calendar/defaultCalendar'; import { generateId } from '@/utils/id'; import { formatDate, formatInstant, parseInstant } from '@/utils/dateUtils'; -import { normalizeImportedProgress } from '@/services/importNormalize'; +import { normalizeImportedProgress, deriveImportedWorkRules } from '@/services/importNormalize'; import { isoDatePrefixOrToday } from '@/services/importDates'; import { directChildText, toInt, toFloat } from '@/services/xmlDom'; import type { ImportResult } from '@/services/importTypes'; @@ -19,6 +19,8 @@ import { resolveCalendar } from '@/engine/scheduler/resolveCalendar'; import { calendarForEngine } from '@/utils/effectiveWorkTime'; import { isFlatCurveValues, matchCurveValues, normalizeCurveValues } from '@/engine/contour/contourEngine'; import { axisOffsetMinutes, p6SpreadToContourPeriods, splitGapsFromContours } from '@/services/contourIo'; +import { importedWorkFields } from '@/engine/work/workRuleMapping'; +import { taskWorkMinutes } from '@/engine/contour/contourEngine'; import { OPS_P6_DURATION_UNIT_UDF_TITLE, P6_DAY_NAMES, @@ -120,9 +122,14 @@ function p6CodeToConstraint(code: string): { type: ConstraintType; hard?: boolea // (`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. +// Taaktypes-etappe (2026-09-05): de twee Fixed-Duration-labels stonden hier verwisseld. Oracle's +// eigen XER-datamap (P6 EPPM XER Import/Export Data Map Guide, TASK.duration_type) geeft: +// DT_FixedDrtn = "Fixed Duration and Units/Time", DT_FixedDUR2 = "Fixed Duration and Units", +// DT_FixedQty = "Fixed Units", DT_FixedRate = "Fixed Units/Time" — dezelfde paren als +// `workRuleMapping.ts` (`XER_DURATION_TYPE_TOKEN` ↔ `P6_DURATION_TYPE_NAME`). const P6_XML_DURATION_TYPE_BY_LABEL: Readonly> = { - 'Fixed Duration and Units': 'DT_FixedDrtn', - 'Fixed Duration and Units/Time': 'DT_FixedDUR2', + 'Fixed Duration and Units': 'DT_FixedDUR2', + 'Fixed Duration and Units/Time': 'DT_FixedDrtn', 'Fixed Units/Time': 'DT_FixedRate', 'Fixed Units': 'DT_FixedQty', }; @@ -610,6 +617,7 @@ export function readP6XML(content: string): ImportResult { // Voortgang-invarianten op de rauw ingelezen actuals (§3.2/§15.6). normalizeImportedProgress(tasks, project.statusDate); + deriveImportedWorkRules(tasks); // taaktypes-etappe: werkregel uit // Parse relationships const relElements = getAllByLocalName(doc, 'Relationship'); @@ -703,15 +711,32 @@ export function readP6XML(content: string): ImportResult { curve = P6_NAME_TO_CURVE[plannedCurveRaw]; } + // PlannedUnitsPerTime is een fractie (1.0 = 100%), geen uren/dag (L2-fix, + // spiegel van p6xmlWriter) — 1:1 overnemen. + const unitsPerDay = plannedUnitsPerTime > 0 ? plannedUnitsPerTime : 1; + // Taaktypes-etappe (spec §4.3, geval c): // in UREN + // → minuten, alleen bewaard wanneer ze afwijken van `duur × inzet/tijd` (`importedWorkFields`). + const workTask = taskById.get(taskId); + const derivedWork = workTask ? taskWorkMinutes(workTask.time, engineForTask(workTask).hoursPerDay) * unitsPerDay : 0; + const unitsMinutes = (tag: string): number | undefined => { + const raw = getElementText(asgnEl, tag); + if (!raw.trim()) return undefined; + const hours = parseFloat(raw); + return Number.isFinite(hours) ? hours * 60 : undefined; + }; + const workFields = importedWorkFields({ + plannedMinutes: unitsMinutes('PlannedUnits'), + actualMinutes: unitsMinutes('ActualUnits'), + remainingMinutes: unitsMinutes('RemainingUnits'), + }, derivedWork); assignments.push({ id: generateId('asgn'), taskId, resourceId, - // PlannedUnitsPerTime is een fractie (1.0 = 100%), geen uren/dag (L2-fix, - // spiegel van p6xmlWriter) — 1:1 overnemen. - unitsPerDay: plannedUnitsPerTime > 0 ? plannedUnitsPerTime : 1, + unitsPerDay, ...(curve && curve !== 'UNIFORM' ? { curve } : {}), ...(curveValues ? { curveValues } : {}), + ...workFields, }); // Contour-engine (2026-09) — de SPREIDING: `` (verricht, anker `ActualStartDate`) diff --git a/src/services/p6/p6xmlWriter.ts b/src/services/p6/p6xmlWriter.ts index ac4cdef89..5ec10bcf8 100644 --- a/src/services/p6/p6xmlWriter.ts +++ b/src/services/p6/p6xmlWriter.ts @@ -12,6 +12,7 @@ import { effectiveWorkTimeBands } from '@/utils/effectiveWorkTime'; import { projectFileBase } from '@/utils/documents'; import { isLeafTask, isSummaryTask } from '@/utils/taskHierarchy'; import type { CustomTaskType } from '@/types/taskType'; +import { P6_DURATION_TYPE_NAME, XER_DURATION_TYPE_TOKEN } from '@/engine/work/workRuleMapping'; const OPS_CUSTOM_TASK_TYPE_UDF_TITLE = 'OPS Custom Task Type'; const OPS_CUSTOM_TASK_TYPE_MARKER = 'OpenPlannerStudio.CustomTaskType.v1'; @@ -127,6 +128,14 @@ function taskStatusToP6(task: Task): string { return 'Not Started'; } +/** XER-token → PMXML-label, via de gedeelde tabel in `workRuleMapping.ts` (Oracle-datamap). */ +function p6LabelForToken(token: string): string | undefined { + for (const [rule, t] of Object.entries(XER_DURATION_TYPE_TOKEN) as [keyof typeof P6_DURATION_TYPE_NAME, string][]) { + if (t === token) return P6_DURATION_TYPE_NAME[rule]; + } + return undefined; +} + function taskTypeToP6(task: Task): string { if (task.isMilestone) { // Fase 2.4: de expliciete soort bepaalt het P6-activitytype; automatisch => @@ -508,6 +517,14 @@ export function writeP6XML( } lines.push(`${indent(2)}${taskTypeToP6(task)}`); lines.push(`${indent(2)}${taskStatusToP6(task)}`); + // Taaktypes-etappe (spec §4.2/§4.4): uit de werkregel; zonder werkregel valt een + // taak met alleen een bewaard P6-token op dat token terug. Zonder beide schrijft de export niets. + const durationTypeLabel = task.workRule + ? P6_DURATION_TYPE_NAME[task.workRule] + : task.p6DurationType ? p6LabelForToken(task.p6DurationType) : undefined; + if (durationTypeLabel && !task.isMilestone) { + lines.push(`${indent(2)}${durationTypeLabel}`); + } // Fase 2.8b (§7.2): uur-taak ⇒ PlannedDuration in fractionele uren uit de minuten (geen // dag-afronding); dag-taak ⇒ het bestaande `dagen × hpd`-pad (byte-identiek). const effCal = effCalByTask.get(task.id); @@ -683,6 +700,9 @@ export function writeP6XML( lines.push(`${indent(2)}${escapeXML(actualSpread)}`); lines.push(`${indent(2)}${anchorIso}`); } + // Taaktypes-etappe (spec §4.3/§4.4): de drie werkvelden in UREN, alleen wanneer gezet; op hun + // alfabetische plek in de PMXML-sequence (ActualUnits, PlannedUnits, RemainingUnits). + if (a.actualWorkMinutes !== undefined) lines.push(`${indent(2)}${a.actualWorkMinutes / 60}`); lines.push(`${indent(2)}${asgnObjId++}`); if (plannedSpread && anchorIso) { lines.push(`${indent(2)}${escapeXML(plannedSpread)}`); @@ -692,11 +712,13 @@ export function writeP6XML( // PlannedUnitsPerTime: fractie, 1.0 = 100% (L2-fix — zelfde semantiek en MPXJ-bron als // MaxUnitsPerTime hierboven; PmxmlUnitsHelper schaalt MPXJ-percentages /100 naar het // bestand). Ons `unitsPerDay` is al een fractie, dus 1:1. + if (a.plannedWorkMinutes !== undefined) lines.push(`${indent(2)}${a.plannedWorkMinutes / 60}`); lines.push(`${indent(2)}${a.unitsPerDay}`); if (remainingSpread && anchorIso) { lines.push(`${indent(2)}${escapeXML(remainingSpread)}`); lines.push(`${indent(2)}${anchorIso}`); } + if (a.remainingWorkMinutes !== undefined) lines.push(`${indent(2)}${a.remainingWorkMinutes / 60}`); if (curveObjId !== undefined) { lines.push(`${indent(2)}${curveObjId}`); } diff --git a/src/services/xer/xerReader.ts b/src/services/xer/xerReader.ts index fcc4c11a2..3e744ca9d 100644 --- a/src/services/xer/xerReader.ts +++ b/src/services/xer/xerReader.ts @@ -38,6 +38,8 @@ import { detectXerSourcePresentation, type XerSourceArchive, } from '@/services/xerSourceArchive'; +import { deriveImportedWorkRules } from '@/services/importNormalize'; +import { taskWorkMinutes as activityWorkMinutesOf } from '@/engine/contour/contourEngine'; import { readXerCalendars } from './xerCalendarData'; import { buildXerMetadataCatalog, materializeXerMetadata, type XerMetadataCatalog } from './xerMetadata'; import { indexXerTaskResourceRows } from './xerResourceAssignments'; @@ -839,6 +841,7 @@ function readXerProject( }; }); const allTasks = [...wbsTasks, ...mappedActivities]; + deriveImportedWorkRules(mappedActivities); // taaktypes-etappe: werkregel uit duration_type const taskById = new Map(allTasks.map(task => [task.id, task])); for (const task of allTasks) { if (!task.parentId) continue; @@ -867,6 +870,11 @@ function readXerProject( 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)), + // Taaktypes-etappe: geplande werkminuten per activiteit voor de werkveld-afleiding. + taskWorkMinutes: new Map(mappedActivities.map(task => [ + task.id, + activityWorkMinutes(task, calendarById.get(task.calendarId ?? '')?.hoursPerDay ?? projectCalendar.hoursPerDay), + ])), }, taskResourceRowsByProject.get(projectId) ?? []); for (const assignment of resourceResult.assignments) { const task = taskById.get(assignment.taskId); @@ -998,6 +1006,11 @@ function readXerProject( * De baselinebeslissing zit vóór de openroute: die krijgt dus uitsluitend documenten die echt als * tab geopend mogen worden. */ +/** Geplande werkminuten van een activiteit (uurmodus: de bronminuten; dagmodus: dagen × uren/dag). */ +function activityWorkMinutes(task: Task, hoursPerDay: number): number { + return activityWorkMinutesOf(task.time, hoursPerDay); +} + export function readXER(bytes: Uint8Array): XerOpenResult { const tables = parseXerTables(bytes); const scheduleOptionsIndex = indexXerScheduleOptions(tables); diff --git a/src/services/xer/xerResourceAssignments.ts b/src/services/xer/xerResourceAssignments.ts index 5f3fd99f0..5d5bb16b8 100644 --- a/src/services/xer/xerResourceAssignments.ts +++ b/src/services/xer/xerResourceAssignments.ts @@ -13,6 +13,7 @@ import { } from './xerAssignmentProvenance'; import { isFlatCurveValues, matchCurveValues, normalizeCurveValues } from '@/engine/contour/contourEngine'; import { P6_NAME_TO_CURVE } from '@/services/p6/p6xmlWriter'; +import { importedWorkFields } from '@/engine/work/workRuleMapping'; // 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%. @@ -219,8 +220,22 @@ export function readXerResourceAssignments( 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); + // Taaktypes-etappe (spec §4.3/§4.4, afspraak met de XER-etappe): target_qty / act_reg_qty + + // act_ot_qty / remain_qty (uren) worden eersteklasvelden — alleen wanneer ze afwijken van + // `activiteitsduur × target_qty_per_hr` (`importedWorkFields`); materiaal blijft buiten de + // driehoek. De rauwe rijen blijven daarnaast in het bronarchief. + const taskWork = context.taskWorkMinutes?.get(taskSourceId); + const workFields = resource.type !== 'MATERIAL' && taskWork !== undefined + ? importedWorkFields({ + plannedMinutes: quantities.target !== undefined ? quantities.target * 60 : undefined, + actualMinutes: quantities.actualRegular !== undefined || quantities.actualOvertime !== undefined + ? ((quantities.actualRegular ?? 0) + (quantities.actualOvertime ?? 0)) * 60 + : undefined, + remainingMinutes: quantities.remaining !== undefined ? quantities.remaining * 60 : undefined, + }, taskWork * scaled.value) + : {}; assignments.push({ id: `xer-assignment:${sourceId}`, taskId: taskSourceId, resourceId: resource.id, unitsPerDay: scaled.value, - ...(opsCurve ? { curve: opsCurve } : {}), ...(curveValues ? { curveValues } : {}) }); + ...(opsCurve ? { curve: opsCurve } : {}), ...(curveValues ? { curveValues } : {}), ...workFields }); } return { assignments, roleResources: Array.from(roleResourceById.values()), sources, issues }; } diff --git a/src/services/xer/xerResourceTypes.ts b/src/services/xer/xerResourceTypes.ts index aab96b219..2cf957c59 100644 --- a/src/services/xer/xerResourceTypes.ts +++ b/src/services/xer/xerResourceTypes.ts @@ -25,6 +25,10 @@ export interface XerResourceReadContext { availableCalendarIds: ReadonlySet; calendarHoursPerDay: ReadonlyMap; taskIds: ReadonlySet; + /** Taaktypes-etappe (spec §4.3/§4.4): geplande werkminuten per activiteit, voor de + * "afwezig ⇒ afgeleid"-toets van de TASKRSRC-werkhoeveelheden. Optioneel: zonder deze kaart + * worden geen werkvelden gezet (byte-identiek voor bestaande aanroepers). */ + taskWorkMinutes?: ReadonlyMap; } export interface XerEntityIdentity { diff --git a/tests/planning/check-work-rule-mapping.ts b/tests/planning/check-work-rule-mapping.ts new file mode 100644 index 000000000..10d0fee41 --- /dev/null +++ b/tests/planning/check-work-rule-mapping.ts @@ -0,0 +1,248 @@ +// check-work-rule-mapping.ts — taaktypes-etappe, bouwstap 2 (ontwerp 2026-09-04 §4.2/§4.3/§4.4): +// de importvertaling van de werkregel en de drie werkvelden, headless tegen de echte adapters. +// (a) de pure vertaaltabellen (`workRuleMapping.ts`): MSP-type+vlag ↔ werkregel, MSPDI-codes, +// P6-labels ↔ werkregel (tolerant voor "&"), XER-tokens ↔ werkregel, en de importregel +// "afwezig ⇒ afgeleid" voor de werkvelden; +// (b) `deriveImportedWorkRules`: MSP-velden gaan voor, een bestaande regel blijft, geen bron ⇒ geen veld; +// (c) MSPDI: writer → reader round-trip van / en de werkvelden op de toewijzing, +// inclusief beslispunt 8-B (Fixed Units zonder effort-driven) en de byte-identieke tak; +// (d) P6 XML: -labels beide kanten (Oracle-datamap) en de units-velden in uren; +// (e) XER: `duration_type` → werkregel en TASKRSRC-hoeveelheden → werkvelden, alleen bij afwijking. +// Draait via run.sh. Exit 0 = groen. +import { + MSPDI_TASK_TYPE_CODE, P6_DURATION_TYPE_NAME, XER_DURATION_TYPE_TOKEN, importedWorkFields, mspFromWorkRule, + mspTaskTypeFromCode, workRuleFromMsp, workRuleFromP6DurationType, workRuleFromXerDurationType, +} from '@/engine/work/workRuleMapping'; +import { WORK_RULES } from '@/types/workRule'; +import { deriveImportedWorkRules } from '@/services/importNormalize'; +import { writeMSPDI } from '@/services/msproject/mspdiWriter'; +import { readMSPDI } from '@/services/msproject/mspdiReader'; +import { writeP6XML } from '@/services/p6/p6xmlWriter'; +import { readP6XML } from '@/services/p6/p6xmlReader'; +import { readXER } from '@/services/xer/xerReader'; +import { isMultiDocumentImport } from '@/services/importTypes'; +import { createDefaultProject } from '@/state/slices/projectSlice'; +import type { Task } from '@/types/task'; +import type { Resource, ResourceAssignment } from '@/types/resource'; +import type { WorkCalendar } from '@/types/calendar'; +import { installDOMParser } from './xmldom-shim'; + +installDOMParser(); + +declare const process: { exit(code: number): never }; + +let checks = 0; +const diffs: string[] = []; +function eq(label: string, actual: unknown, expected: unknown): void { + checks++; + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + diffs.push(`${label}: kreeg ${JSON.stringify(actual)}, verwacht ${JSON.stringify(expected)}`); + } +} +function ok(label: string, cond: boolean): void { + checks++; + if (!cond) diffs.push(label); +} + +const CAL: WorkCalendar = { + id: 'cal-wr', name: 'project', description: '', workDays: [1, 2, 3, 4, 5], + workStartHour: 8, workEndHour: 16, hoursPerDay: 8, holidays: [], +}; +function task(id: string, start: string, finish: string, durationDays: number, extra?: Partial): 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: 'days', scheduleDuration: durationDays, + scheduleStart: start, scheduleFinish: finish, + earlyStart: start, earlyFinish: finish, lateStart: start, lateFinish: finish, + freeFloat: 0, totalFloat: 0, isCritical: false, completion: 0, + }, + ...extra, + }; +} +const res = (id: string): Resource => ({ id, name: id, type: 'LABOR', description: '', maxUnits: 1 }); +const assign = (id: string, taskId: string, resourceId: string, unitsPerDay: number, extra?: Partial): ResourceAssignment => + ({ id, taskId, resourceId, unitsPerDay, ...extra }); + +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- (a) vertaaltabellen --'); +{ + eq('a1 MSP → werkregel (vijf combinaties, spec §2.3)', [ + workRuleFromMsp('FIXED_UNITS', true), workRuleFromMsp('FIXED_UNITS', false), workRuleFromMsp('FIXED_UNITS', undefined), + workRuleFromMsp('FIXED_DURATION', false), workRuleFromMsp('FIXED_DURATION', true), workRuleFromMsp('FIXED_WORK', false), + workRuleFromMsp(undefined, true), + ], ['FIXED_RATE', 'FIXED_RATE', 'FIXED_RATE', 'FIXED_DURATION_RATE', 'FIXED_DURATION_WORK', 'FIXED_WORK', undefined]); + eq('a2 werkregel → MSP zonder bewaarde vlag (P6-lezing)', WORK_RULES.map((r) => mspFromWorkRule(r, undefined)), [ + { type: 'FIXED_DURATION', effortDriven: false }, { type: 'FIXED_DURATION', effortDriven: true }, + { type: 'FIXED_WORK', effortDriven: true }, { type: 'FIXED_UNITS', effortDriven: true }, + ]); + eq('a3 werkregel → MSP mét bewaarde vlag (8-B: de vlag wint, behalve bij Fixed Work)', [ + mspFromWorkRule('FIXED_RATE', false), mspFromWorkRule('FIXED_DURATION_WORK', true), mspFromWorkRule('FIXED_WORK', false), + ], [{ type: 'FIXED_UNITS', effortDriven: false }, { type: 'FIXED_DURATION', effortDriven: true }, { type: 'FIXED_WORK', effortDriven: true }]); + ok('a4 MSP heen-en-terug is stabiel voor elke regel', WORK_RULES.every((r) => { + const m = mspFromWorkRule(r, undefined); + return workRuleFromMsp(m.type, m.effortDriven) === r; + })); + eq('a5 MSPDI-codes 0/1/2 (Microsoft Learn: Fixed units / Fixed duration / Fixed work)', [ + MSPDI_TASK_TYPE_CODE.FIXED_UNITS, MSPDI_TASK_TYPE_CODE.FIXED_DURATION, MSPDI_TASK_TYPE_CODE.FIXED_WORK, + mspTaskTypeFromCode(0), mspTaskTypeFromCode(1), mspTaskTypeFromCode(2), mspTaskTypeFromCode(3), mspTaskTypeFromCode(-1), mspTaskTypeFromCode(undefined), + ], [0, 1, 2, 'FIXED_UNITS', 'FIXED_DURATION', 'FIXED_WORK', undefined, undefined, undefined]); + eq('a6 P6-labels ↔ werkregel (P6 EPPM REST-enum), beide kanten', WORK_RULES.map((r) => workRuleFromP6DurationType(P6_DURATION_TYPE_NAME[r])), WORK_RULES); + eq('a7 P6-label tolerant voor "&" en hoofdletters; onbekend ⇒ undefined', [ + workRuleFromP6DurationType('Fixed Duration & Units'), workRuleFromP6DurationType('fixed units/time'), workRuleFromP6DurationType('Fixed Whatever'), workRuleFromP6DurationType(''), + ], ['FIXED_DURATION_WORK', 'FIXED_RATE', undefined, undefined]); + eq('a8 XER-tokens ↔ werkregel (Oracle XER-datamap: Drtn = Duration & Units/Time, DUR2 = Duration & Units)', [ + XER_DURATION_TYPE_TOKEN.FIXED_DURATION_RATE, XER_DURATION_TYPE_TOKEN.FIXED_DURATION_WORK, XER_DURATION_TYPE_TOKEN.FIXED_WORK, XER_DURATION_TYPE_TOKEN.FIXED_RATE, + workRuleFromXerDurationType('DT_FixedDrtn'), workRuleFromXerDurationType('DT_FixedDUR2'), workRuleFromXerDurationType('DT_FixedQty'), workRuleFromXerDurationType('DT_FixedRate'), + workRuleFromXerDurationType('DT_FixedDUR'), workRuleFromXerDurationType(undefined), + ], ['DT_FixedDrtn', 'DT_FixedDUR2', 'DT_FixedQty', 'DT_FixedRate', 'FIXED_DURATION_RATE', 'FIXED_DURATION_WORK', 'FIXED_WORK', 'FIXED_RATE', undefined, undefined]); + ok('a9 XER-token en P6-label van dezelfde regel beschrijven hetzelfde type (spec §4.2-tabel)', + workRuleFromXerDurationType('DT_FixedDrtn') === workRuleFromP6DurationType('Fixed Duration and Units/Time') + && workRuleFromXerDurationType('DT_FixedDUR2') === workRuleFromP6DurationType('Fixed Duration and Units')); + + // "Afwezig ⇒ afgeleid" bij import. + eq('a10 niets in de bron ⇒ niets', importedWorkFields({}, 2400), {}); + eq('a11 bron gelijk aan de afleiding (binnen 1 min) ⇒ niets', importedWorkFields({ plannedMinutes: 2400.5, remainingMinutes: 2400 }, 2400), {}); + eq('a12 begroot wijkt af ⇒ alle aanwezige velden', importedWorkFields({ plannedMinutes: 3000, remainingMinutes: 3000 }, 2400), { plannedWorkMinutes: 3000, remainingWorkMinutes: 3000 }); + eq('a13 verricht werk > 0 ⇒ alle aanwezige velden, ook als begroot klopt', importedWorkFields({ plannedMinutes: 2400, actualMinutes: 600, remainingMinutes: 1800 }, 2400), { plannedWorkMinutes: 2400, actualWorkMinutes: 600, remainingWorkMinutes: 1800 }); + eq('a14 resterend wijkt af van begroot − verricht ⇒ velden', importedWorkFields({ plannedMinutes: 2400, remainingMinutes: 1800 }, 2400), { plannedWorkMinutes: 2400, remainingWorkMinutes: 1800 }); + eq('a15 ongeldige bronwaarden gelden als afwezig', importedWorkFields({ plannedMinutes: Number.NaN, actualMinutes: -5, remainingMinutes: Number.POSITIVE_INFINITY }, 2400), {}); + eq('a16 alleen resterend, afwijkend van de afleiding ⇒ veld', importedWorkFields({ remainingMinutes: 1200 }, 2400), { remainingWorkMinutes: 1200 }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- (b) deriveImportedWorkRules --'); +{ + const tasks: Task[] = [ + task('msp', '2026-06-01', '2026-06-05', 5, { mspTaskType: 'FIXED_DURATION', effortDriven: true, p6DurationType: 'DT_FixedQty' }), + task('p6', '2026-06-01', '2026-06-05', 5, { p6DurationType: 'DT_FixedRate' }), + task('kept', '2026-06-01', '2026-06-05', 5, { workRule: 'FIXED_WORK', mspTaskType: 'FIXED_UNITS' }), + task('none', '2026-06-01', '2026-06-05', 5), + ]; + deriveImportedWorkRules(tasks); + eq('b1 MSP-velden gaan vóór het P6-token; P6 alleen; bestaande regel blijft; geen bron ⇒ geen veld', + tasks.map((t) => t.workRule), ['FIXED_DURATION_WORK', 'FIXED_RATE', 'FIXED_WORK', undefined]); + ok('b2 de importvelden zelf blijven onaangeraakt', tasks[0].mspTaskType === 'FIXED_DURATION' && tasks[0].effortDriven === true && tasks[0].p6DurationType === 'DT_FixedQty'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- (c) MSPDI round-trip --'); +{ + const project = createDefaultProject(); + const tFixedWork = task('fw', '2026-06-01', '2026-06-05', 5, { workRule: 'FIXED_WORK' }); + // Beslispunt 8-B: een MSP-taak "Fixed Units, niet effort-driven" — bewaard `mspTaskType` zonder + // `effortDriven` ⇒ de export schrijft EffortDriven 0 (de vlag wint van de P6-lezing van FIXED_RATE). + const tFuNoEd = task('fu', '2026-06-01', '2026-06-05', 5, { workRule: 'FIXED_RATE', mspTaskType: 'FIXED_UNITS' }); + const tFdEd = task('fd', '2026-06-01', '2026-06-05', 5, { workRule: 'FIXED_DURATION_WORK' }); + const tPlain = task('plain', '2026-06-01', '2026-06-05', 5); + const r1 = res('r1'); + const assignments = [ + // 5 d × 8 u × 1,0 = 40 u afgeleid; bron zegt 60 u begroot, 20 verricht, 40 rest ⇒ velden. + assign('a1', 'fw', 'r1', 1, { plannedWorkMinutes: 3600, actualWorkMinutes: 1200, remainingWorkMinutes: 2400 }), + assign('a2', 'plain', 'r1', 1), // niets ⇒ Work = duur × units, komt zonder velden terug + ]; + const xml = writeMSPDI(project, CAL, [tFixedWork, tFuNoEd, tFdEd, tPlain], [], [r1], assignments, []); + const taskXml = (name: string): string => xml.slice(xml.indexOf(`${name}`), xml.indexOf('', xml.indexOf(`${name}`))); + ok('c1 Fixed Work ⇒ 2 + 1', taskXml('fw').includes('2') && taskXml('fw').includes('1')); + ok('c2 FIXED_RATE met bewaard MSP-type zonder vlag ⇒ 0 + 0 (8-B)', taskXml('fu').includes('0') && taskXml('fu').includes('0')); + ok('c3 FIXED_DURATION_WORK zonder MSP-herkomst ⇒ 1 + 1', taskXml('fd').includes('1') && taskXml('fd').includes('1')); + ok('c4 taak zonder regel of MSP-type ⇒ geen / (golden rule)', !taskXml('plain').includes('') && !taskXml('plain').includes('')); + ok('c5 toewijzing met werkvelden ⇒ ActualWork/RemainingWork vóór Units en Work = begroot', + /PT20H0M0S<\/ActualWork>\s*PT40H0M0S<\/RemainingWork>\s*1<\/Units>\s*PT60H0M0S<\/Work>/.test(xml)); + const back = readMSPDI(xml); + const byName = (n: string) => back.tasks.find((t) => t.name === n)!; + eq('c6 lezer: mspTaskType/effortDriven/workRule terug', [ + [byName('fw').mspTaskType, byName('fw').effortDriven, byName('fw').workRule], + [byName('fu').mspTaskType, byName('fu').effortDriven, byName('fu').workRule], + [byName('fd').mspTaskType, byName('fd').effortDriven, byName('fd').workRule], + [byName('plain').mspTaskType, byName('plain').effortDriven, byName('plain').workRule], + ], [ + ['FIXED_WORK', true, 'FIXED_WORK'], ['FIXED_UNITS', undefined, 'FIXED_RATE'], ['FIXED_DURATION', true, 'FIXED_DURATION_WORK'], [undefined, undefined, undefined], + ]); + const backA1 = back.assignments.find((a) => a.taskId === byName('fw').id)!; + const backA2 = back.assignments.find((a) => a.taskId === byName('plain').id)!; + eq('c7 lezer: werkvelden terug in minuten', [backA1.plannedWorkMinutes, backA1.actualWorkMinutes, backA1.remainingWorkMinutes], [3600, 1200, 2400]); + eq('c8 lezer: toewijzing zonder afwijking krijgt geen werkvelden (byte-identiek)', [backA2.plannedWorkMinutes, backA2.actualWorkMinutes, backA2.remainingWorkMinutes], [undefined, undefined, undefined]); +} + +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- (d) P6 XML round-trip --'); +{ + const project = createDefaultProject(); + const tasks = WORK_RULES.map((r) => task(r, '2026-06-01', '2026-06-05', 5, { workRule: r })); + const tToken = task('token', '2026-06-01', '2026-06-05', 5, { p6DurationType: 'DT_FixedDUR2' }); + const tPlain = task('plain', '2026-06-01', '2026-06-05', 5); + const r1 = res('r1'); + const assignments = [ + assign('a1', 'FIXED_WORK', 'r1', 1, { plannedWorkMinutes: 3600, actualWorkMinutes: 1200, remainingWorkMinutes: 2400 }), + assign('a2', 'plain', 'r1', 0.5), + ]; + const xml = writeP6XML(project, CAL, [...tasks, tToken, tPlain], [], [r1], assignments, []); + const actXml = (name: string): string => xml.slice(xml.indexOf(`${name}`), xml.indexOf('', xml.indexOf(`${name}`))); + eq('d1 writer: -label per regel', WORK_RULES.map((r) => (actXml(r).match(/([^<]*)<\/DurationType>/) ?? [])[1]), WORK_RULES.map((r) => P6_DURATION_TYPE_NAME[r])); + ok('d2 writer: bewaard XER-token zonder regel ⇒ label via de datamap', actXml('token').includes('Fixed Duration and Units')); + ok('d3 writer: geen regel/token ⇒ geen ', !actXml('plain').includes('')); + ok('d4 writer: units in uren op hun alfabetische plek', /20<\/ActualUnits>[\s\S]*60<\/PlannedUnits>\s*1<\/PlannedUnitsPerTime>[\s\S]*40<\/RemainingUnits>/.test(xml)); + const back = readP6XML(xml); + const byName = (n: string) => back.tasks.find((t) => t.name === n)!; + eq('d5 lezer: label → p6DurationType → werkregel, voor alle vier', WORK_RULES.map((r) => [byName(r).p6DurationType, byName(r).workRule]), + WORK_RULES.map((r) => [XER_DURATION_TYPE_TOKEN[r], r])); + eq('d6 lezer: verwisselde labels van vóór 2026-09-05 zijn weg (Drtn ⇔ Units/Time, DUR2 ⇔ Units)', [byName('FIXED_DURATION_RATE').p6DurationType, byName('FIXED_DURATION_WORK').p6DurationType], ['DT_FixedDrtn', 'DT_FixedDUR2']); + const backA1 = back.assignments.find((a) => a.taskId === byName('FIXED_WORK').id)!; + const backA2 = back.assignments.find((a) => a.taskId === byName('plain').id)!; + eq('d7 lezer: units (uren) → werkvelden (minuten)', [backA1.plannedWorkMinutes, backA1.actualWorkMinutes, backA1.remainingWorkMinutes], [3600, 1200, 2400]); + eq('d8 lezer: zonder afwijking geen werkvelden', [backA2.plannedWorkMinutes, backA2.remainingWorkMinutes], [undefined, undefined]); +} + +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- (e) XER --'); +{ + 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\tWerkregels\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', + '%T\tTASK', + '%F\ttask_id\tproj_id\ttask_name\ttask_code\ttarget_start_date\ttarget_end_date\ttarget_drtn_hr_cnt\ttask_type\tduration_type\tstatus_code', + '%R\tT1\tP1\tMetselen\tA1\t2026-01-05\t2026-01-09\t40\tTT_Task\tDT_FixedQty\tTK_NotStart', + '%R\tT2\tP1\tStellen\tA2\t2026-01-05\t2026-01-09\t40\tTT_Task\tDT_FixedDrtn\tTK_NotStart', + // Niet-standaard token: de XER-lezer valt gerapporteerd terug op de projectstandaard + // (def_duration_type, hier DT_FixedDUR2) — de werkregel volgt dus die standaard. + '%R\tT3\tP1\tVreemd\tA3\t2026-01-05\t2026-01-09\t40\tTT_Task\tDT_FixedDUR\tTK_NotStart', + '%T\tRSRC', + '%F\trsrc_id\trsrc_name\trsrc_type\tclndr_id\tdef_qty_per_hr', + '%R\tR1\tMetselaar\tRT_Labor\tCP\t1', + '%T\tTASKRSRC', + '%F\ttaskrsrc_id\tproj_id\ttask_id\trsrc_id\ttarget_qty_per_hr\tremain_qty_per_hr\tremain_qty\ttarget_qty\tact_reg_qty\tact_ot_qty', + // T1: 40 u × 1,0 = 40 u afgeleid; bron zegt 60 begroot, 10 + 2 verricht, 48 rest ⇒ velden. + '%R\tA1\tP1\tT1\tR1\t1\t1\t48\t60\t10\t2', + // T2: bron consistent (40 u × 0,5 = 20 begroot = 20 rest, niets verricht) ⇒ geen velden. + '%R\tA2\tP1\tT2\tR1\t0.5\t0.5\t20\t20\t0\t0', + '%E', + ]; + const parsed = readXER(new TextEncoder().encode(lines.join('\n'))); + if (isMultiDocumentImport(parsed)) { + diffs.push('e0 XER-fixture gaf een meervoudige import terug'); + } else { + const byId = (id: string) => parsed.tasks.find((t) => t.id === id); + eq('e1 duration_type → p6DurationType → werkregel; niet-standaard token ⇒ de (gerapporteerde) projectstandaard', [ + [byId('T1')?.p6DurationType, byId('T1')?.workRule], [byId('T2')?.p6DurationType, byId('T2')?.workRule], [byId('T3')?.p6DurationType, byId('T3')?.workRule], + ], [['DT_FixedQty', 'FIXED_WORK'], ['DT_FixedDrtn', 'FIXED_DURATION_RATE'], ['DT_FixedDUR2', 'FIXED_DURATION_WORK']]); + const a1 = parsed.assignments.find((a) => a.taskId === 'T1')!; + const a2 = parsed.assignments.find((a) => a.taskId === 'T2')!; + eq('e2 TASKRSRC-hoeveelheden (uren) → werkvelden (minuten), verricht = regulier + overwerk', [a1.plannedWorkMinutes, a1.actualWorkMinutes, a1.remainingWorkMinutes], [3600, 720, 2880]); + eq('e3 consistente bron ⇒ geen werkvelden (afspraak met de XER-etappe)', [a2.plannedWorkMinutes, a2.actualWorkMinutes, a2.remainingWorkMinutes], [undefined, undefined, undefined]); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +if (diffs.length > 0) { + console.log(`XX check-work-rule-mapping: ${checks} checks, ${diffs.length} afwijking(en):`); + for (const d of diffs) console.log(` ${d}`); + process.exit(1); +} +console.log(`OK check-work-rule-mapping: ${checks} checks groen`); diff --git a/tests/planning/check-xer-p6xml-parity.ts b/tests/planning/check-xer-p6xml-parity.ts index 92e2989fa..8baf1ee00 100644 --- a/tests/planning/check-xer-p6xml-parity.ts +++ b/tests/planning/check-xer-p6xml-parity.ts @@ -29,7 +29,11 @@ let xml = ''; try { xml = writeP6XML(project, calendar, [task], [], [], []); } finally { console.warn = originalWarn; } const roundTripped = readP6XML(xml).tasks.find(candidate => candidate.name === task.name); expect('1 P6XML schrijft reguliere planningdata', xml.includes('') && roundTripped !== undefined); -expect('2 P6XML claimt geen XER/P6-veldpariteit', roundTripped?.p6DurationType === undefined +// Taaktypes-etappe (2026-09-05, bouwstap 2): is sindsdien de ENE uitzondering op de +// asymmetrie — de writer schrijft het label uit de werkregel of het bewaarde token, de lezer leest +// het terug (spec §4.4). De overige P6-velden blijven éénrichting. +expect('2 P6XML claimt geen XER/P6-veldpariteit (behalve DurationType, taaktypes-etappe)', + roundTripped?.p6DurationType === 'DT_FixedRate' && roundTripped?.p6ActivityType === undefined && roundTripped?.p6ProjectId === undefined && roundTripped?.p6TaskId === undefined); @@ -44,8 +48,11 @@ expect('3 de asymmetrie is als TODO in de writer vastgelegd', // 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}`); + // Sinds bouwstap 2 van de taaktypes-etappe schrijft de writer het element zelf; strip het eerst, + // zodat elke case hieronder precies één (of géén) ziet. + const stripped = baseXml.replace(/\s*[^<]*<\/DurationType>/, ''); + if (label === null) return stripped; + return stripped.replace(/([\s\S]*?[^<]*<\/Type>)/, `$1${label}`); } function readDurationType(label: string | null): { value: string | undefined; warnings: string[] } { const warned: string[] = []; @@ -60,9 +67,12 @@ function readDurationType(label: string | null): { value: string | undefined; wa return { value, warnings: warned }; } +// Taaktypes-etappe (2026-09-05): paren volgens Oracle's XER Import/Export Data Map Guide +// (TASK.duration_type): DT_FixedDrtn = "Fixed Duration and Units/Time", DT_FixedDUR2 = "Fixed +// Duration and Units". De vorige versie van deze tabel had de twee Fixed-Duration-labels verwisseld. const knownDurationTypeLabels: Readonly> = { - 'Fixed Duration and Units': 'DT_FixedDrtn', - 'Fixed Duration and Units/Time': 'DT_FixedDUR2', + 'Fixed Duration and Units': 'DT_FixedDUR2', + 'Fixed Duration and Units/Time': 'DT_FixedDrtn', 'Fixed Units/Time': 'DT_FixedRate', 'Fixed Units': 'DT_FixedQty', }; diff --git a/tests/planning/run.sh b/tests/planning/run.sh index 0538bb8fe..4c9ab7709 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -623,6 +623,9 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then # Taaktypes-etappe, bouwstap 3 (ontwerp 2026-09-04 §9/§10): de pure werkdriehoek + de meetlat. WTCHECK="$DIR/.check-work-triangle.mjs" if bundle_check "$DIR/check-work-triangle.ts" "$WTCHECK"; then node "$WTCHECK" || STATUS=1; fi + # Taaktypes-etappe, bouwstap 2: werkregel-/werkveldvertaling (MSPDI, P6 XML, XER) beide kanten. + WRMCHECK="$DIR/.check-work-rule-mapping.mjs" + if bundle_check "$DIR/check-work-rule-mapping.ts" "$WRMCHECK"; then node "$WRMCHECK" || STATUS=1; fi # Geavanceerde-CPM golf-0-checks (fase 2.9 — datamodel + plumbing default-inert, los van de CPM-cases). ACPMCHECK="$DIR/.advanced-cpm-check.mjs" From 5f5799a7dfe4042161575f32a5b3cbe6125781d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 12:34:12 +0000 Subject: [PATCH 143/152] =?UTF-8?q?feat(taaktypes):=20stap=204=20=E2=80=94?= =?UTF-8?q?=20werkdriehoek=20bedraad=20in=20store,=20taakraster=20en=20MCP?= =?UTF-8?q?-tweeling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brug src/engine/work/workRuleApply.ts tussen de domeinobjecten en de pure kern workTriangle.ts: captureTriangle vóór de mutatie, settle… erna, één terugschrijf (applyTriangleResult) van inzet, restwerk en — waar de regel dat wil — de taakduur (verricht deel + nieuwe rest; hele dagen in dagmodus). Bedraad in taskSlice.updateTask (+ nieuw setTaskWorkRule), resourceSlice.assignResource/updateAssignment/unassignResource (+ nieuw setAssignmentWork), gridTransaction.ts (celduur én de assignment-set-cel via AssignmentSettleOp) en createMcpTransactions.ts (alle tweelingen plus setAssignmentWork/setTaskWorkRule op de draft). Een duur die uit de driehoek komt zet scheduleStale, herschaalt contour + importsplits en wist het Z8-venster — precies als een duurbewerking. rescaleTaskContours krijgt een keepWork-parameter uit de effectieve werkregel (contourKeepsWork; zonder eigen workRule blijft de MSP-afleiding gelden). reconcileContourWork = besluit 3: verandert het restwerk van een toewijzing mét contour, dan zakt de hoogte mee, de as blijft. assignmentDayUnits krijgt opgeslagen werk als derde bron (data, curvevorm over de duur); zonder werkveld byte-identiek. Regressie: tests/planning/check-work-rule-store.ts (65 checks: standaardregel byte-identiek, FIXED_WORK/FIXED_DURATION_WORK in de store, contour meetlat 22/23, raster, MCP, vierde bron, undo in één stap = meetlat 28). Planning- en MCP-suites groen. Spec §10 (stap 4 gebouwd, stap 6 store-kant meegenomen), TODO (remainingTime-vraag, B1c-koppelpunt clearLevelingGaps) en CLAUDE.md bijgewerkt. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0159unb6VzxUx7WG4w3a2ARA --- CLAUDE.md | 24 +- docs/TODO.md | 19 +- ...26-09-04-spec-taaktypes-opgeslagen-werk.md | 11 +- src/engine/scheduler/ResourceLoad.ts | 16 +- src/engine/taskGrid/taskEditPlan.ts | 17 +- src/engine/work/workRuleApply.ts | 402 ++++++++++++++++++ src/state/gridTransaction.ts | 61 ++- src/state/runtime/createMcpTransactions.ts | 98 ++++- src/state/slices/resourceSlice.ts | 95 ++++- src/state/slices/taskSlice.ts | 30 +- src/utils/taskDefaults.ts | 12 +- tests/planning/check-work-rule-store.ts | 279 ++++++++++++ tests/planning/run.sh | 3 + 13 files changed, 1032 insertions(+), 35 deletions(-) create mode 100644 src/engine/work/workRuleApply.ts create mode 100644 tests/planning/check-work-rule-store.ts diff --git a/CLAUDE.md b/CLAUDE.md index 66a6ad35f..711521916 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,12 +115,30 @@ die histogram, overallocatie, nivelleerder (`ResourceLeveler.ts`) en bezettingso (1) een opgeslagen contour (gekoppeld aan de toewijzing via `TaskTimephasedContour.resourceId`, `matchContoursToAssignments`) ⇒ data, zonder de hele-eenheden-afronding; (2) `ResourceAssignment. curveValues` (de exacte 21-punts P6-/MSPDI-curve, `CONTOUR_SHAPE_VALUES`-vorm) ⇒ eveneens data; -(3) anders de bestaande `distributeUnits`-formule, byte-identiek. De engine raakt **geen taakdatum**: +(3) opgeslagen werk (`ResourceAssignment.remainingWorkMinutes` [+ `actualWorkMinutes`], taaktypes- +etappe) ⇒ als data met de curvevorm over de duur gespreid; (4) anders de bestaande `distributeUnits`- +formule, byte-identiek. De engine raakt **geen taakdatum**: de CPM-datums van een import blijven bij laag 3/4 van de Z8-beslistabel en `splitGaps` — de fidelity-poort bewaakt dat. Een duurwijziging (`taskSlice.updateTask`, `createMcpTransactions`, `taskEditPlan`) herschaalt de contour én de importsplits proportioneel via `taskDefaults.ts`'s -`rescaleTaskContours` (actuals blijven, `mspTaskType === 'FIXED_WORK'` houdt het werk vast); een -datum-/kalender-/toewijzingswijziging raakt de as niet. `src/services/contourIo.ts` is de adapterlaag: +`rescaleTaskContours` (actuals blijven; werkbehoud volgt de effectieve werkregel via +`workRuleApply.ts`'s `contourKeepsWork` — zonder eigen `workRule` geldt nog `mspTaskType === +'FIXED_WORK'`); een datum-/kalender-/toewijzingswijziging raakt de as niet. **Werkregels +(taaktypes-etappe, spec `docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md`, in +aanbouw):** `Task.workRule` ∈ FIXED_DURATION_RATE (standaard, het gedrag van vandaag) | +FIXED_DURATION_WORK | FIXED_WORK | FIXED_RATE, anders `Project.defaultWorkRule`. De pure kern +`src/engine/work/workTriangle.ts` werkt op de RESTERENDE toestand (werk = restduur × inzet; W en I +opgeslagen, R afgeleid en naar boven afgerond op hele dagen/minuten); de brug +`src/engine/work/workRuleApply.ts` (`captureTriangle` vóór de mutatie → `settle…` erna) is op vier +plekken bedraad: `taskSlice.updateTask`/`setTaskWorkRule`, `resourceSlice.assignResource`/ +`updateAssignment`/`unassignResource`/`setAssignmentWork`, `gridTransaction.ts` en de MCP-tweeling in +`createMcpTransactions.ts`. Een duur die uit de driehoek komt (inzet/werk/resource erbij-eraf onder +FIXED_WORK/FIXED_RATE) zet `scheduleStale`, herschaalt contour + importsplits en wist het Z8-venster, +precies als een duurbewerking; verandert het restwerk van een toewijzing mét contour, dan zakt de +contourhoogte mee (`reconcileContourWork`, "vorm blijft, hoogte zakt"). Materiaalresources sturen +de duur nooit. Regressie: `tests/planning/check-work-triangle.ts` (kern + meetlat +`work-triangle-cases.json`), `check-work-rule-mapping.ts` (MSP/P6/XER-vertaling) en +`check-work-rule-store.ts` (store/raster/MCP). `src/services/contourIo.ts` is de adapterlaag: MSPDI `` (Type 1/2, per werkdag) en P6 `` + `` + de `PlannedCurve`/`RemainingCurve`/`ActualCurve`-spreidingsstrings (`"werkuren:periodeuren;…"`, MPXJ `TimephasedHelper`) round-trippen daar doorheen — let op: P6's `` is dus GEEN diff --git a/docs/TODO.md b/docs/TODO.md index 9adeeb742..196477708 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -351,12 +351,27 @@ deze lijst verwijderd — wat klaar is, staat in de changelog en git-historie. FIXED_WORK houdt werk) volgt MSP's gedocumenteerde gedrag maar is niet tegen MSP zelf gemeten — de taaktypes-spec noemt die meetlat als de duurste post van de vervolgetappe. -### Taaktypes / opgeslagen werk — ontwerp geschreven, nog niet gebouwd (2026-09-04) +### Taaktypes / opgeslagen werk — in aanbouw (spec 2026-09-04, bouw 2026-09-05) > Ontwerp: `docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md` (opvolger van de -> spec van 2026-08-18). Bouwt pas op een main mét de XER-import erin (tweede XER-merge eerst). +> spec van 2026-08-18). Bouwt op de branch `claude/contour-engine-planner-mnrsy3` (PR #101), die +> gestapeld is op de XER-branch en pas ná die PR merget. Stappen 1–4 (+ de store-kant van 6) staan +> erin; 5 (UI), 7 (MCP-tools) en 8 (docs) nog niet — zie spec §10 voor de stand per stap. > Eigenaarsbesluiten 1–7 (2026-09-04) en 8–10 (2026-09-05) staan daar in §3. +- [ ] **Duurbewerking op een taak met expliciete `remainingTime`/`remainingMinutes` (voortgangsroute).** + De brug leidt de restduur af als `remainingTime ?? duur × (1 − voortgang)` — dezelfde afleiding + als de solver. Staat `remainingTime` expliciet, dan verandert een duurbewerking die rest NIET, + en volgt de werkdriehoek dus ook niet (AFGELEID uit de code; wat MS Project hier doet is niet + gemeten). Beslissen: moet een duurbewerking `remainingTime` mee verschuiven (Δ), of is de + voortgangsroute leidend? +- [ ] **B1c-koppelpunt (`origin/t3code/b1c-etappe3`, gezien 2026-09-05):** die branch wist bij elke + as-verzettende bewerking de nivelleergaten (`clearLevelingGaps`, B1c-plan3 taak 3). Een duur die + uit de werkdriehoek komt (`afterTriangleDurationChange` in `resourceSlice.ts`/ + `createMcpTransactions.ts`, en het assignment-set-pad in `gridTransaction.ts`) hoort dat ná de + merge óók te doen — toe te voegen op de kant die als tweede merget. Geen inhoudelijke overlap + verder: B1c raakt inzet noch werk (besluit 7 blijft). + - [x] **Beslispunten 8–10 genomen (2026-09-05)**, vastgelegd in spec §3.3: 8 = optie B (vier types in het menu, bewaard `effortDriven` stuurt alleen de twee MSP-afwijkende cellen); 9 = drie optionele werkvelden per toewijzing; 10 = de vereenvoudiging "elke toewijzing loopt over de hele restduur" diff --git a/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md b/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md index 3a6f5f676..f4c1e4a14 100644 --- a/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md +++ b/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md @@ -2,9 +2,10 @@ *Ontwerp, 2026-09-04. Opvolger van het voorstel [`2026-08-18-spec-taaktypes-effort-driven.md`](2026-08-18-spec-taaktypes-effort-driven.md) -("ontwerp vóór bouw"). Status: **ontwerp; bouwstap 3 (de pure rekenkern + de meetlat) is -gebouwd op 2026-09-05, de overige stappen niet.** Stap 3 raakt alleen nieuwe bestanden en één -regel in `tests/planning/run.sh`, dus de volgorde "XER merget eerst" (§10 stap 0) blijft intact. +("ontwerp vóór bouw"). Status: **in aanbouw op de branch `claude/contour-engine-planner-mnrsy3` +(PR #101, gestapeld op de XER-branch): stappen 1, 2, 3 en 4 zijn gebouwd (2026-09-05); stap 6 is +daarin voor de store/raster/MCP-kant meegenomen (resource erbij/eraf + contourhoogte, besluit 3); +de stappen 5, 7 en 8 nog niet.** De contour-engine, de contour-UI en de fasen-editor (2026-09) zijn geleverd en worden hier als bestaand fundament gebruikt.* @@ -590,9 +591,9 @@ twee keer aan `formatRegistry.ts`, `ifcPsets.ts` en de contour-adapters wordt ge | 1 | `WorkRule`, `Task.workRule`, `Project.defaultWorkRule`, de drie werkvelden; `DOCUMENT_FIELDS`; IFC-psets `OPS_WorkRule` + `OPS_AssignmentWork`; ext-contract alleen-lezen. **Geen gedrag.** | `check-ifc-roundtrip` (fixture + canon), `check-document-contract`, `verify:cycles`; elk bestaand bestand byte-identiek | | 2 | Importvertaling (§4.2) voor .mpp, MSPDI (incl. ``/`` lezen), P6 XML, XER; export MSPDI/P6 XML met de werkvelden; waarschuwingen | fidelity-poort blijft 0 (`GOAL_ZERO_DEVIATIONS`); `check-mpp-*`, `check-adapters-*`; nieuwe fixtures per bron | | 3 **(gebouwd 2026-09-05)** | Pure module `src/engine/work/workTriangle.ts`: `applyDurationEdit`, `applyUnitsEdit`, `applyWorkEdit`, `applyTaskWorkEdit`, `applyAssignmentAdded`, `applyAssignmentRemoved`, `applyRuleChange` — invoer: de RESTERENDE toestand (`TriangleState`: regel, bewaard `effortDriven`, restduur in werkminuten, per toewijzing inzet + optioneel restwerk + `drivesDuration`), uitvoer: een nieuwe toestand of een weigering met reden; nooit een store. `WorkRule` in `src/types/workRule.ts` (verhuist in stap 1 naar `task.ts`). Plus `work-triangle-cases.json` (§9) | `tests/planning/check-work-triangle.ts`: 332 checks, 39 cases (21 documented, 16 reasoned, 2 decided, 0 measured; bij een mengvorm telt het zwakste bewijs) plus eigenschappen: geen veld geschreven onder de inzetregels, typewissel in alle 16 richtingen getalvrij, weigering bij restduur ≤ 0 of niet-eindige inzet (§6.7), `effortDriven` zonder effect buiten de twee 8-B-cellen | -| 4 | Bedrading: `taskSlice.updateTask` (R), `resourceSlice.updateAssignment` (I, W), `taskEditPlan`/`assignmentPlan` (raster), `createMcpTransactions` (MCP-tweeling — ZEKER dat die spiegel bestaat), `assignmentDayUnits` vierde bron (§6.1) | bestaande suites + `check-work-triangle` via de store; `cases-resource-load.json` uitgebreid | +| 4 **(gebouwd 2026-09-05)** | Brug `src/engine/work/workRuleApply.ts` (`captureTriangle` → kernstap → `applyTriangleResult`; `settleDurationEdit`/`settleUnitsEdit`/`planWorkEdit`+`commitTrianglePlan`/`settleAssignmentAdded`/`settleAssignmentRemoved`/`settleAssignmentPlan`/`settleRuleChange`; `contourKeepsWork`; `reconcileContourWork` = besluit 3 "vorm blijft, hoogte zakt"). Bedraad in `taskSlice.updateTask` + nieuw `setTaskWorkRule`, `resourceSlice.assignResource`/`updateAssignment`/`unassignResource` + nieuw `setAssignmentWork`, `gridTransaction.ts` (celduur én de assignment-set-cel, via `AssignmentSettleOp`), `createMcpTransactions.ts` (alle tweelingen + `setAssignmentWork`/`setTaskWorkRule` op de draft), `rescaleTaskContours(…, keepWork)` uit de effectieve regel, `assignmentDayUnits` derde bron = opgeslagen werk (§6.1). Een duur die uit de driehoek komt zet `scheduleStale`, herschaalt contour + importsplits en wist het Z8-venster — precies als een duurbewerking; onder FIXED_DURATION_RATE zonder werkvelden byte-identiek | `tests/planning/check-work-rule-store.ts` (65 checks: store, raster, MCP, vierde bron, meetlat 22/23/28), bestaande planning- en MCP-suites groen | | 5 | UI: instelling, auto-ontsluiting + melding, paneel/dialoog, rasterkolommen, i18n 14 locales, gidsen nl+en (+12 vertalingen, anders faalt `verify:docs` niet maar is de functie onvindbaar) | `verify:i18n`, `verify:docs`, browserspec `tests/browser/work-rule.spec.ts` (echte toetsen/klikken, store-asserties) | -| 6 | Resource erbij/eraf (§5 rij 4–5) inclusief contourregels (§6.3) — **vereist beslispunt 8** | cases 4, 5, 9, 10, 15, 19, 20, 23, 29, 30 | +| 6 | Resource erbij/eraf (§5 rij 4–5) inclusief contourregels (§6.3) — **vereist beslispunt 8** (genomen: 8-B). **Store/raster/MCP-kant gebouwd in stap 4** (`settleAssignmentAdded`/`Removed`, `reconcileContourWork`); wat rest is de UI-kant (stap 5) | cases 4, 5, 9, 10, 15, 19, 20, 23, 29, 30 (kern) + `check-work-rule-store` (b15–b21, d5–d9, e6–e7, f3–f4) | | 7 | MCP: contract + schema + registratie; `docs/recepten/mcp-tool.md` | `tests/mcp/`, `cases-toolregistry` | | 8 | CLAUDE.md-sectie, TODO-afvinking, `docs/superpowers/README.md` | `verify:docs` | diff --git a/src/engine/scheduler/ResourceLoad.ts b/src/engine/scheduler/ResourceLoad.ts index 6c0903031..8f1432fff 100644 --- a/src/engine/scheduler/ResourceLoad.ts +++ b/src/engine/scheduler/ResourceLoad.ts @@ -129,8 +129,14 @@ function largestRemainderRound(values: number[], targetSum: number, unitsPerDay: * blijft behouden (dezelfde garantie als het earlyFinish-besluit hieronder). * 2. `ResourceAssignment.curveValues` (exacte 21-punts P6-/MSPDI-curve): `slotWeightsFromValues` * × (unitsPerDay × duur) — ook data-achtig, dus eveneens zonder de formule-afronding. - * 3. anders de bestaande formule `distributeUnits` (curve-vorm + hele-eenheden-afronding) — - * byte-identiek voor elke toewijzing zonder contour of `curveValues`. + * 3. OPGESLAGEN WERK (taaktypes-etappe 2026-09, spec §4.3/§6.5): staat er een + * `remainingWorkMinutes` (uit een import die van duur × inzet afweek, of vastgelegd door een + * werkbeschermende regel), dan is verricht + resterend werk het totaal en wordt dát — als + * data, zonder de hele-eenheden-afronding — met de curvevorm (`CONTOUR_SHAPE_VALUES`) over de + * duur gespreid. Zo boekt een niet-sturende toewijzing (W_i / I_i < R) haar eigen werk en niet + * inzet × restduur. Afwezig ⇒ byte-identiek aan vandaag. + * 4. anders de bestaande formule `distributeUnits` (curve-vorm + hele-eenheden-afronding) — + * byte-identiek voor elke toewijzing zonder contour, `curveValues` of werkveld. * `contour` mag door de aanroeper vooraf zijn opgezocht (één `matchContoursToAssignments` per * taak); ontbreekt het argument, dan zoekt deze functie 'm zelf op uit `task.timephasedContours` * en `siblings` (alle toewijzingen van de taak — nodig voor de volgorderegel van de koppeling). @@ -156,6 +162,12 @@ export function assignmentDayUnits( const total = assignment.unitsPerDay * durationDays; return weights.map((w) => w * total); } + if (assignment.remainingWorkMinutes !== undefined && Number.isFinite(assignment.remainingWorkMinutes) && durationDays > 0) { + const workMinutes = Math.max(0, assignment.remainingWorkMinutes) + Math.max(0, assignment.actualWorkMinutes ?? 0); + const totalUnits = workMinutes / Math.max(1, mpd); + const weights = slotWeightsFromValues(CONTOUR_SHAPE_VALUES[CURVE_TO_SHAPE[assignment.curve ?? 'UNIFORM']], durationDays); + return weights.map((w) => w * totalUnits); + } return distributeUnits(assignment.unitsPerDay, durationDays, assignment.curve ?? 'UNIFORM'); } diff --git a/src/engine/taskGrid/taskEditPlan.ts b/src/engine/taskGrid/taskEditPlan.ts index d4fa65dea..4f682f2d9 100644 --- a/src/engine/taskGrid/taskEditPlan.ts +++ b/src/engine/taskGrid/taskEditPlan.ts @@ -57,6 +57,10 @@ export interface TaskEditPlanEnvironment { customTaskTypeIds?: ReadonlySet; activityCodeTypes: readonly ActivityCodeType[]; customFieldDefs: readonly CustomFieldDef[]; + /** Taaktypes-etappe (2026-09): werkbehoud bij het herschalen van een contour, afgeleid van de + * effectieve werkregel (`workRuleApply.ts`'s `contourKeepsWork`). Afwezig ⇒ de oude + * MSP-afleiding in `rescaleTaskContours`. */ + contourKeepsWork?: boolean; } export interface PlannedTaskEdit { @@ -132,8 +136,11 @@ function expectedRoute(columnId: string): CellEditIntent['route'] | null { /** Contour-engine (2026-09): duurwijziging in het grid herschaalt de contour — tweeling van * `taskSlice.updateTask`/`createMcpTransactions.updateTaskFields`, zie `taskDefaults.ts`'s * `rescaleTaskContours`. `oldWorkMinutes` is vóór de mutatie vastgelegd door `applyScheduleEdit`. */ -function finishDurationEdit(task: Task, oldWorkMinutes: number, hoursPerDay: number): boolean { - if (Number.isFinite(hoursPerDay) && hoursPerDay > 0) rescaleTaskContours(task, oldWorkMinutes, hoursPerDay); +function finishDurationEdit(task: Task, oldWorkMinutes: number, environment: TaskEditPlanEnvironment): boolean { + const hoursPerDay = environment.effectiveHoursPerDay; + if (Number.isFinite(hoursPerDay) && hoursPerDay > 0) { + rescaleTaskContours(task, oldWorkMinutes, hoursPerDay, environment.contourKeepsWork); + } return clearScheduleGuidance(task, true); } @@ -231,7 +238,7 @@ function applyScheduleEdit( task.time.durationMinutes = minutes; task.time.scheduleDuration = minutes / (environment.effectiveHoursPerDay * 60); } - lost = finishDurationEdit(task, oldWorkMinutes, environment.effectiveHoursPerDay); + lost = finishDurationEdit(task, oldWorkMinutes, environment); } else if (id === 'task.time.scheduleDuration') { if (edit.value && typeof edit.value === 'object' && 'unit' in edit.value) { const parsed = edit.value as ParsedTaskDuration; @@ -251,7 +258,7 @@ function applyScheduleEdit( task.time.scheduleDuration = parsed.scheduleDuration; task.time.durationMinutes = undefined; } - lost = finishDurationEdit(task, oldWorkMinutes, environment.effectiveHoursPerDay); + lost = finishDurationEdit(task, oldWorkMinutes, environment); return { ok: true, value: lost }; } if (!finite(edit.value) || edit.value < 0) return failure('duration', edit); @@ -264,7 +271,7 @@ function applyScheduleEdit( task.time.scheduleDuration = days; if (environment.hourMode) task.time.durationMinutes = edit.value; else delete task.time.durationMinutes; - lost = finishDurationEdit(task, oldWorkMinutes, hoursPerDay); + lost = finishDurationEdit(task, oldWorkMinutes, environment); } } else if (id === 'task.time.scheduleStart' || id === 'task.time.scheduleFinish') { if (!optionalString(edit.value)) return failure('date', edit); diff --git a/src/engine/work/workRuleApply.ts b/src/engine/work/workRuleApply.ts new file mode 100644 index 000000000..b9616ff8c --- /dev/null +++ b/src/engine/work/workRuleApply.ts @@ -0,0 +1,402 @@ +// workRuleApply.ts — de BRUG tussen de domeinobjecten (Task/ResourceAssignment/Resource) en de pure +// rekenkern `workTriangle.ts` (taaktypes-ontwerp 2026-09-04 §5/§6/§8; bouwstap 4). +// +// Deze module weet wat de kern niet mag weten: welke regel voor een taak geldt (`Task.workRule`, +// anders `Project.defaultWorkRule`, anders de standaard van vandaag), welk deel van de taak het +// RESTANT is (voortgang), welke toewijzingen de duur sturen (materiaal niet), hoe een restduur in +// minuten terugvertaalt naar `TaskTime` (hele dagen in dagmodus) en wanneer de regel überhaupt van +// toepassing is (spec §8: alleen gewone bladtaken op werktijd en uurtaken — mijlpalen, hangmatten, +// samenvattingen en ELAPSEDTIME-taken blijven byte-identiek). +// +// Aanroepvorm (store, MCP-tweeling, taakraster — allemaal dezelfde drie stappen): +// 1. `triangleStateOf(task, toewijzingen, ctx)` VÓÓR de mutatie; +// 2. één `apply…`-functie uit `workTriangle.ts`; +// 3. `applyTriangleResult(task, toewijzingen, uitkomst, ctx)` schrijft duur, inzet en werkvelden +// terug (in-place, Immer-draft-stijl) en zegt of de taakduur veranderde (⇒ planning verouderd). +// Geen store-import: de aanroeper bepaalt snapshot, `isDirty`, `scheduleStale` en meldingen. +import type { WorkCalendar } from '@/types/calendar'; +import type { Project } from '@/types/project'; +import type { Resource, ResourceAssignment } from '@/types/resource'; +import type { Task } from '@/types/task'; +import { DEFAULT_WORK_RULE, type WorkRule } from '@/types/workRule'; +import { contourIndexForAssignment, taskWorkMinutes } from '@/engine/contour/contourEngine'; +import { resolveCalendar } from '@/engine/scheduler/resolveCalendar'; +import { + applyAssignmentAdded, applyAssignmentRemoved, applyDurationEdit, applyRuleChange, applyUnitsEdit, + applyWorkEdit, ruleProtectsWork, type TriangleAssignment, type TriangleState, +} from '@/engine/work/workTriangle'; + +export interface WorkRuleContext { + /** Effectieve uren per werkdag van de taak (taakkalender, anders projectkalender). */ + hoursPerDay: number; + /** `Project.defaultWorkRule`; afwezig ⇒ FIXED_DURATION_RATE. */ + defaultWorkRule?: WorkRule; + /** Resource-opzoek voor de materiaalgrens (spec §4.3). Onbekend ⇒ telt als werkresource. */ + resourceById?: (id: string) => Resource | undefined; +} + +/** De regel die voor deze taak geldt: eigen veld, anders projectstandaard, anders vandaag. */ +export function effectiveWorkRule(task: Pick, defaultWorkRule?: WorkRule): WorkRule { + return task.workRule ?? defaultWorkRule ?? DEFAULT_WORK_RULE; +} + +/** Bewaard MSP-vinkje voor beslispunt 8-B: alleen betekenisvol op een taak met MSP-herkomst + * (`mspTaskType`); daar is "afwezig" letterlijk "niet effort-driven". Zonder herkomst ⇒ zuiver P6. */ +export function effectiveEffortDriven(task: Pick): boolean | undefined { + return task.mspTaskType ? (task.effortDriven ?? false) : undefined; +} + +/** Spec §8: de regel werkt alleen op gewone bladtaken op werktijd (dag- én uurmodus). */ +export function workRuleApplies(task: Task): boolean { + return task.childIds.length === 0 + && !task.isMilestone + && task.isHammock !== true + && task.time.durationType !== 'ELAPSEDTIME'; +} + +/** Werkminuten per werkdag van de taak. */ +export function slotMinutesOf(ctx: Pick): number { + return Math.max(1, Math.round(ctx.hoursPerDay * 60)); +} + +/** + * De RESTduur van de taak in werkminuten — dezelfde afleiding als de solver (`CPMSolver.ts`'s + * voortgangstak): uurmodus `remainingMinutes ?? duur × (1 − voortgang)`, dagmodus + * `remainingTime ?? duur × (1 − voortgang)` (dagen), beide vanuit de blijvende taak-eenheid. + */ +export function remainingMinutesOf(task: Task, ctx: Pick): number { + const t = task.time; + const slot = slotMinutesOf(ctx); + if (t.durationUnit === 'hours' && typeof t.durationMinutes === 'number' && Number.isFinite(t.durationMinutes)) { + const rem = t.remainingMinutes ?? Math.round(t.durationMinutes * (1 - (t.completion ?? 0))); + return Math.max(0, rem); + } + const days = t.remainingTime ?? Math.round(t.scheduleDuration * (1 - (t.completion ?? 0))); + return Math.max(0, days) * slot; +} + +/** Totale werkminuten van de taak (`taskWorkMinutes`) — het verrichte deel is totaal − rest. */ +export function totalMinutesOf(task: Task, ctx: Pick): number { + return taskWorkMinutes(task.time, ctx.hoursPerDay); +} + +function drivesDuration(assignment: ResourceAssignment, ctx: WorkRuleContext): boolean { + const resource = ctx.resourceById?.(assignment.resourceId); + return resource ? resource.type !== 'MATERIAL' : true; +} + +/** Stap 1: de resterende toestand van de taak als invoer voor de rekenkern. */ +export function triangleStateOf(task: Task, assignments: readonly ResourceAssignment[], ctx: WorkRuleContext): TriangleState { + return { + rule: effectiveWorkRule(task, ctx.defaultWorkRule), + effortDriven: effectiveEffortDriven(task), + remainingMinutes: remainingMinutesOf(task, ctx), + slotMinutes: slotMinutesOf(ctx), + wholeDays: task.time.durationUnit !== 'hours', + assignments: assignments + .filter((a) => a.taskId === task.id) + .map((a): TriangleAssignment => ({ + id: a.id, + unitsPerDay: a.unitsPerDay, + drivesDuration: drivesDuration(a, ctx), + ...(a.remainingWorkMinutes !== undefined ? { remainingWorkMinutes: a.remainingWorkMinutes } : {}), + })), + }; +} + +export interface TriangleWriteBack { + /** De taakduur is gewijzigd (⇒ de aanroeper zet de planning verouderd en herschaalt contouren). */ + durationChanged: boolean; + /** Toewijzingen waarvan inzet of restwerk is herschreven. */ + changedAssignmentIds: string[]; +} + +/** + * Stap 3: de uitkomst van de kern terugschrijven. De taakduur wordt ALLEEN aangeraakt wanneer de + * kern een andere restduur teruggeeft dan hij kreeg (`before`), en dan als + * `verricht deel + nieuwe rest` — het verrichte deel is een feit (spec §6.5). Dagmodus houdt hele + * dagen (`scheduleDuration` geheel; de kern rondt al naar boven op hele slots), uurmodus minuten. + * Een aanwezig `remainingTime`/`remainingMinutes` volgt de nieuwe rest. Toewijzingen: `unitsPerDay` + * en `remainingWorkMinutes` (aanwezig ⇒ geschreven, afwezig ⇒ verwijderd) exact zoals de kern ze + * teruggeeft. Muteert in-place; retourneert wat er veranderde. + */ +export function applyTriangleResult( + task: Task, + assignments: ResourceAssignment[], + before: TriangleState, + after: TriangleState, + ctx: WorkRuleContext, + opts?: { skipDuration?: boolean }, +): TriangleWriteBack { + const changed: string[] = []; + for (const next of after.assignments) { + const a = assignments.find((x) => x.id === next.id); + if (!a) continue; + const unitsChanged = Math.abs(a.unitsPerDay - next.unitsPerDay) > 1e-9; + const workChanged = (a.remainingWorkMinutes === undefined) !== (next.remainingWorkMinutes === undefined) + || (next.remainingWorkMinutes !== undefined && a.remainingWorkMinutes !== undefined + && Math.abs(a.remainingWorkMinutes - next.remainingWorkMinutes) > 1e-6); + if (!unitsChanged && !workChanged) continue; + a.unitsPerDay = Math.round(next.unitsPerDay * 10000) / 10000; + if (next.remainingWorkMinutes === undefined) delete a.remainingWorkMinutes; + else a.remainingWorkMinutes = next.remainingWorkMinutes; + changed.push(a.id); + } + + reconcileContourWork(task, assignments, changed); + + let durationChanged = false; + if (!opts?.skipDuration && Math.abs(after.remainingMinutes - before.remainingMinutes) > 1e-6) { + const slot = slotMinutesOf(ctx); + const total = totalMinutesOf(task, ctx); + const doneMinutes = Math.max(0, total - before.remainingMinutes); + const newTotal = doneMinutes + after.remainingMinutes; + const t = task.time; + if (t.durationUnit === 'hours') { + t.durationMinutes = Math.round(newTotal); + t.scheduleDuration = t.durationMinutes / slot; + if (t.remainingMinutes !== undefined) t.remainingMinutes = Math.round(after.remainingMinutes); + } else { + t.scheduleDuration = Math.max(0, Math.round(newTotal / slot)); + delete t.durationMinutes; + if (t.remainingTime !== undefined) t.remainingTime = Math.max(0, Math.round(after.remainingMinutes / slot)); + } + durationChanged = true; + } + return { durationChanged, changedAssignmentIds: changed }; +} + +/** + * Spec besluit 3 ("vorm blijft, hoogte zakt", meetlat 23): verandert de kern het RESTwerk van een + * toewijzing die een opgeslagen contour heeft, dan schalen de `remaining`-periodes van die contour + * in hoogte mee zodat hun som weer het nieuwe restwerk is — de as en de `actual`-periodes blijven + * staan (de as volgt pas een duurwijziging, via `rescaleTaskContours`). Zonder contour, zonder + * werkveld of bij een al kloppende som: niets. + */ +function reconcileContourWork(task: Task, assignments: readonly ResourceAssignment[], changedIds: readonly string[]): void { + const contours = task.timephasedContours; + if (!contours || contours.length === 0 || changedIds.length === 0) return; + const siblings = assignments.filter((a) => a.taskId === task.id); + let next = contours; + for (const id of changedIds) { + const a = siblings.find((x) => x.id === id); + if (!a || a.remainingWorkMinutes === undefined) continue; + const idx = contourIndexForAssignment(next, siblings, id); + if (idx < 0) continue; + const periods = next[idx].periods; + const remainingSum = periods.reduce((s, p) => s + (p.kind === 'actual' ? 0 : p.workMinutes), 0); + if (!(remainingSum > 0) || Math.abs(remainingSum - a.remainingWorkMinutes) < 1e-6) continue; + const factor = a.remainingWorkMinutes / remainingSum; + next = next.map((c, i) => (i === idx + ? { ...c, periods: c.periods.map((p) => (p.kind === 'actual' ? p : { ...p, workMinutes: p.workMinutes * factor })) } + : c)); + } + if (next !== contours) task.timephasedContours = next; +} + +/** + * Herschaalt een contour met werkbehoud onder de werkbeschermende regels (spec §6.3). Zonder eigen + * `workRule` geldt de oude MSP-afleiding (`mspTaskType === 'FIXED_WORK'`) náást de projectstandaard, + * zodat een vóór deze etappe opgeslagen MSP-import byte-identiek blijft herschalen. + */ +export function contourKeepsWork(task: Pick, defaultWorkRule?: WorkRule): boolean { + if (task.workRule !== undefined) return ruleProtectsWork(task.workRule); + return task.mspTaskType === 'FIXED_WORK' || ruleProtectsWork(defaultWorkRule ?? DEFAULT_WORK_RULE); +} + +// ── Hoog-niveau "settle"-API voor store, raster en MCP-tweeling ───────────────────────────────── +// +// Dezelfde drie stappen als hierboven, maar ingepakt per bewerking zodat de vier aanroepplekken +// (taskSlice/resourceSlice, gridTransaction, createMcpTransactions) letterlijk dezelfde regels +// delen. Elke functie muteert in-place en retourneert een `TriangleWriteBack`; `null` betekent +// "de regel is hier niet van toepassing of de kern weigerde" — de aanroeper laat dan zijn +// bestaande gedrag van vandaag staan (byte-identiek). + +/** De storevelden die de brug nodig heeft — bewust een `Pick`, zodat elke draft (store, MCP, + * geïsoleerde griddraft) 'm kan leveren zonder de hele `AppState`. */ +export interface WorkRuleDeps { + calendars: readonly WorkCalendar[]; + calendar: WorkCalendar; + project: Pick; + resources: readonly Resource[]; +} + +export function workRuleContextOf(task: Task, deps: WorkRuleDeps): WorkRuleContext { + const resources = deps.resources; + return { + hoursPerDay: resolveCalendar(task.calendarId, deps.calendars as WorkCalendar[], deps.calendar).hoursPerDay, + ...(deps.project.defaultWorkRule !== undefined ? { defaultWorkRule: deps.project.defaultWorkRule } : {}), + resourceById: (id) => resources.find((r) => r.id === id), + }; +} + +export interface CapturedTriangle { + state: TriangleState; + ctx: WorkRuleContext; +} + +/** Stap 1 als momentopname VÓÓR een mutatie; `null` wanneer de regel niet op deze taak werkt. */ +export function captureTriangle(task: Task, assignments: readonly ResourceAssignment[], deps: WorkRuleDeps): CapturedTriangle | null { + if (!workRuleApplies(task)) return null; + const ctx = workRuleContextOf(task, deps); + return { state: triangleStateOf(task, assignments, ctx), ctx }; +} + +const NO_CHANGE: TriangleWriteBack = { durationChanged: false, changedAssignmentIds: [] }; + +/** + * Duur gewijzigd (spec §5 rij 1) — aanroepen NÁDAT de aanroeper `task.time` heeft gezet, met de + * momentopname van daarvóór. De nieuwe restduur wordt uit de taak zelf gelezen; de kern verdeelt + * er inzet en werk naar. De duur zelf wordt hier NIET herschreven (die is al gezet; in dagmodus is + * ze al geheel, in uurmodus al in minuten). Onder FIXED_DURATION_RATE zonder werkvelden verandert + * niets — byte-identiek aan vandaag. + */ +export function settleDurationEdit(task: Task, assignments: ResourceAssignment[], captured: CapturedTriangle | null): TriangleWriteBack { + if (!captured) return NO_CHANGE; + const newRemaining = remainingMinutesOf(task, captured.ctx); + if (Math.abs(newRemaining - captured.state.remainingMinutes) < 1e-6) return NO_CHANGE; + const result = applyDurationEdit(captured.state, newRemaining); + if (!result.ok) return NO_CHANGE; + return applyTriangleResult(task, assignments, captured.state, result.state, captured.ctx, { skipDuration: true }); +} + +/** + * Inzet van één toewijzing gewijzigd (spec §5 rij 2) — aanroepen NÁDAT de aanroeper de nieuwe + * `unitsPerDay` heeft geschreven (zodat de exacte invoer staat), met de momentopname van + * daarvóór. Kan de taakduur veranderen (FIXED_WORK/FIXED_RATE) ⇒ `durationChanged`. + */ +export function settleUnitsEdit( + task: Task, + assignments: ResourceAssignment[], + captured: CapturedTriangle | null, + assignmentId: string, + newUnitsPerDay: number, +): TriangleWriteBack { + if (!captured) return NO_CHANGE; + const result = applyUnitsEdit(captured.state, assignmentId, newUnitsPerDay); + if (!result.ok) return NO_CHANGE; + return applyTriangleResult(task, assignments, captured.state, result.state, captured.ctx); +} + +/** Een doorgerekende maar nog niet geschreven uitkomst: plannen kan vóór de undo-snapshot, zodat + * een weigering geen lege undo-stap achterlaat; `commitTrianglePlan` schrijft daarna. */ +export interface TrianglePlan { + captured: CapturedTriangle; + after: TriangleState; +} + +/** Resterend werk van één toewijzing gezet (spec §5 rij 3), zonder te schrijven. + * `null` ⇒ geweigerd (werk ≤ 0, onbekende toewijzing) of niet van toepassing. */ +export function planWorkEdit( + task: Task, + assignments: readonly ResourceAssignment[], + deps: WorkRuleDeps, + assignmentId: string, + newWorkMinutes: number, +): TrianglePlan | null { + const captured = captureTriangle(task, assignments, deps); + if (!captured) return null; + const result = applyWorkEdit(captured.state, assignmentId, newWorkMinutes); + if (!result.ok) return null; + return { captured, after: result.state }; +} + +export function commitTrianglePlan(task: Task, assignments: ResourceAssignment[], plan: TrianglePlan): TriangleWriteBack { + return applyTriangleResult(task, assignments, plan.captured.state, plan.after, plan.captured.ctx); +} + +/** `planWorkEdit` + `commitTrianglePlan` in één stap (raster/MCP, waar de snapshot al staat). */ +export function settleWorkEdit( + task: Task, + assignments: ResourceAssignment[], + deps: WorkRuleDeps, + assignmentId: string, + newWorkMinutes: number, +): TriangleWriteBack | null { + const plan = planWorkEdit(task, assignments, deps, assignmentId, newWorkMinutes); + return plan ? commitTrianglePlan(task, assignments, plan) : null; +} + +/** Resource erbij (spec §5 rij 4) — aanroepen NÁDAT de nieuwe toewijzing in `assignments` staat, + * met de momentopname van daarvóór (zonder de nieuwe). */ +export function settleAssignmentAdded( + task: Task, + assignments: ResourceAssignment[], + captured: CapturedTriangle | null, + added: ResourceAssignment, +): TriangleWriteBack { + if (!captured) return NO_CHANGE; + const resource = captured.ctx.resourceById?.(added.resourceId); + const result = applyAssignmentAdded(captured.state, { + id: added.id, unitsPerDay: added.unitsPerDay, drivesDuration: resource ? resource.type !== 'MATERIAL' : true, + }); + if (!result.ok) return NO_CHANGE; + return applyTriangleResult(task, assignments, captured.state, result.state, captured.ctx); +} + +/** Resource eraf (spec §5 rij 5) — momentopname MÉT de te verwijderen toewijzing, aanroepen NÁDAT + * ze uit `assignments` is; de kern verdeelt haar werk over de blijvers waar de regel dat wil. */ +export function settleAssignmentRemoved( + task: Task, + assignments: ResourceAssignment[], + captured: CapturedTriangle | null, + removedId: string, +): TriangleWriteBack { + if (!captured) return NO_CHANGE; + const result = applyAssignmentRemoved(captured.state, removedId); + if (!result.ok) return NO_CHANGE; + return applyTriangleResult(task, assignments, captured.state, result.state, captured.ctx); +} + +/** Typewissel (spec §5 rij 6): schrijft `task.workRule` en legt onder een werkbeschermende regel het + * huidige restwerk vast; verder verandert geen getal. `undefined` = terug naar de projectstandaard. */ +export function settleRuleChange( + task: Task, + assignments: ResourceAssignment[], + deps: WorkRuleDeps, + rule: WorkRule | undefined, +): TriangleWriteBack { + const captured = captureTriangle(task, assignments, deps); + if (rule === undefined) delete task.workRule; else task.workRule = rule; + if (!captured) return NO_CHANGE; + const result = applyRuleChange(captured.state, effectiveWorkRule(task, captured.ctx.defaultWorkRule)); + if (!result.ok) return NO_CHANGE; + return applyTriangleResult(task, assignments, captured.state, result.state, captured.ctx); +} + +/** Eén taakraster-/MCP-batchwijziging op de toewijzingen van één taak, als reeks kernstappen. */ +export type AssignmentSettleOp = + | { kind: 'remove'; assignmentId: string } + | { kind: 'update'; assignmentId: string; unitsPerDay: number } + | { kind: 'add'; assignmentId: string; unitsPerDay: number; resourceId: string }; + +/** + * Meerdere toewijzingsbewerkingen op één taak in één keer (taakraster: de cel "Resources" kan + * tegelijk verwijderen, inzet wijzigen en toevoegen; `assignmentPlan.ts`). Aanroepen NÁDAT het + * plan op `assignments` is toegepast, met de momentopname van daarvóór: de kern loopt de stappen + * in de toepassingsvolgorde (verwijderen → inzet → toevoegen) door en er volgt één terugschrijf. + */ +export function settleAssignmentPlan( + task: Task, + assignments: ResourceAssignment[], + captured: CapturedTriangle | null, + ops: readonly AssignmentSettleOp[], +): TriangleWriteBack { + if (!captured || ops.length === 0) return NO_CHANGE; + let state = captured.state; + const order = { remove: 0, update: 1, add: 2 } as const; + for (const op of [...ops].sort((a, b) => order[a.kind] - order[b.kind])) { + let result; + if (op.kind === 'remove') result = applyAssignmentRemoved(state, op.assignmentId); + else if (op.kind === 'update') result = applyUnitsEdit(state, op.assignmentId, op.unitsPerDay); + else { + const resource = captured.ctx.resourceById?.(op.resourceId); + result = applyAssignmentAdded(state, { + id: op.assignmentId, unitsPerDay: op.unitsPerDay, drivesDuration: resource ? resource.type !== 'MATERIAL' : true, + }); + } + if (!result.ok) return NO_CHANGE; + state = result.state; + } + return applyTriangleResult(task, assignments, captured.state, state, captured.ctx); +} diff --git a/src/state/gridTransaction.ts b/src/state/gridTransaction.ts index 8fb6c4200..3fd05bc15 100644 --- a/src/state/gridTransaction.ts +++ b/src/state/gridTransaction.ts @@ -18,6 +18,10 @@ import { createSnapshot, restoreSnapshot, type Snapshot } from './snapshot'; import { recordDocumentDataHistoryDelta } from './sessionHistory'; import { notifyTimephasedLoss } from './timephasedLossNotice'; import { markScheduleStale } from './transaction'; +import { + captureTriangle, contourKeepsWork, settleAssignmentPlan, settleDurationEdit, type AssignmentSettleOp, +} from '@/engine/work/workRuleApply'; +import { clearTimephasedWindow, rescaleTaskContours, taskCalendarHoursPerDay, taskWorkMinutesOf } from '@/utils/taskDefaults'; import { generateId } from '@/utils/id'; import { applyRelationMutationPlan, @@ -345,12 +349,43 @@ function applyAssignmentSet( resourcesById, }); if (!planned.ok) return planned; - return { - ok: true, - value: applyTaskAssignmentPlan( - state, planned.value, () => generateId('asgn'), applyIndexes, - ), - }; + // Taaktypes-etappe (2026-09, bouwstap 4): momentopname van de werkdriehoek VÓÓR het plan; ná het + // plan volgen inzet/werk/restduur de regel van de taak (spec §5 rijen 2/4/5), in één terugschrijf. + const task = tasksById.get(intent.taskId); + const triangle = task ? captureTriangle(task, assignmentsForTask, state) : null; + const oldWorkMinutes = task ? taskWorkMinutesOf(task, taskCalendarHoursPerDay(task, state.calendars, state.calendar)) : 0; + // `applyTaskAssignmentPlan` muteert de draftobjecten in-place; de oude inzet dus vóóraf vastleggen. + const unitsBefore = new Map(assignmentsForTask.map(a => [a.id, a.unitsPerDay] as const)); + const applied = applyTaskAssignmentPlan( + state, planned.value, () => generateId('asgn'), applyIndexes, + ); + let lostTaskIds = applied.timephasedGuidanceLostTaskIds; + if (task && triangle) { + const ops: AssignmentSettleOp[] = []; + for (const op of planned.value.operations) { + if (op.kind === 'remove') ops.push({ kind: 'remove', assignmentId: op.assignmentId }); + else if (op.kind === 'update') { + const before = unitsBefore.get(op.assignmentId); + if (before !== undefined && before !== op.unitsPerDay) ops.push({ kind: 'update', assignmentId: op.assignmentId, unitsPerDay: op.unitsPerDay }); + } else { + const added = (applyIndexes.assignmentsByTaskId.get(op.taskId) ?? []).find(a => a.resourceId === op.resourceId); + if (added) ops.push({ kind: 'add', assignmentId: added.id, unitsPerDay: added.unitsPerDay, resourceId: added.resourceId }); + } + } + const settled = settleAssignmentPlan(task, state.assignments, triangle, ops); + if (settled.durationChanged) { + // Zelfde nazorg als een duurbewerking in `applyCellEdits`/`taskEditPlan.ts`. + rescaleTaskContours(task, oldWorkMinutes, taskCalendarHoursPerDay(task, state.calendars, state.calendar), contourKeepsWork(task, state.project.defaultWorkRule)); + const lost = clearTimephasedWindow(task); + if (lost && !lostTaskIds.includes(task.id)) lostTaskIds = [...lostTaskIds, task.id]; + if (state.datesAsRecorded) { + state.datesAsRecorded = false; + state.recordedDates = null; + } + markScheduleStale(state); + } + } + return { ok: true, value: { timephasedGuidanceLostTaskIds: lostTaskIds } }; } function applyRelationSet( @@ -413,6 +448,9 @@ function applyCellEdits( // clipboard.ts en `pasteIntentPresent` in prepareGridMutation hieronder). Een enkele celedit of // Delete/Backspace (via `planTaskGridClear`) behoudt de bestaande harde weigering. skipReadOnlyCells: boolean, + // Taaktypes-etappe (2026-09): de toewijzingen van deze taak (uit de callerindex, O(1)), voor de + // werkdriehoek bij een duurbewerking. + assignmentsForTask: readonly AppState['assignments'][number][] = [], ): GridResult<{ timephasedGuidanceLost: boolean; skippedReadOnlyCount: number }, readonly CellValidationError[]> { const first = edits[0]; if (!first) return { ok: true, value: { timephasedGuidanceLost: false, skippedReadOnlyCount: 0 } }; @@ -463,6 +501,7 @@ function applyCellEdits( customTaskTypeIds: new Set(state.customTaskTypes.map(type => type.id)), activityCodeTypes: state.activityCodeTypes, customFieldDefs: state.customFieldDefs, + contourKeepsWork: contourKeepsWork(task, state.project.defaultWorkRule), }; // Algemene gezamenlijke-eindtoestandvalidatie voor conditioneel schrijfbare cellen. Een cel mag @@ -606,10 +645,15 @@ function applyCellEdits( ? validatedEdits.filter(edit => !skippedConditionalEdits.has(edit)) : validatedEdits; + // Taaktypes-etappe (spec §5 rij 1): momentopname VÓÓR het plan; een gewijzigde duur laat de + // toewijzingen daarna hun regel volgen (`settleDurationEdit`) — onder de standaardregel zonder + // werkvelden verandert er niets. + const triangle = captureTriangle(task, assignmentsForTask, state); const planned = planTaskCellEdits(task, finalEdits, environment); if (!planned.ok) return planned; if (planned.value.changed) { state.tasks[taskIndex] = planned.value.task; + settleDurationEdit(state.tasks[taskIndex], state.assignments, triangle); if (planned.value.scheduleStale) { if (state.datesAsRecorded) { state.datesAsRecorded = false; @@ -690,7 +734,10 @@ export function prepareGridMutation( if (appliedCellTaskIds.has(write.taskId)) continue; appliedCellTaskIds.add(write.taskId); const taskWrites = cellWritesByTaskId.get(write.taskId) ?? [write]; - const applied = applyCellEdits(draft, taskWrites, runtime, draftTaskIndexById, skipReadOnlyCells); + const applied = applyCellEdits( + draft, taskWrites, runtime, draftTaskIndexById, skipReadOnlyCells, + draftAssignmentsByTaskId.get(write.taskId) ?? [], + ); if (!applied.ok) errors.push(...applied.errors); else { const currentTaskIndex = draftTaskIndexById.get(write.taskId); diff --git a/src/state/runtime/createMcpTransactions.ts b/src/state/runtime/createMcpTransactions.ts index e939dc8ce..1feea2b03 100644 --- a/src/state/runtime/createMcpTransactions.ts +++ b/src/state/runtime/createMcpTransactions.ts @@ -25,6 +25,11 @@ import type { LevelingResult } from '@/engine/scheduler/ResourceLeveler'; import { clampProjectStartAnchors } from '@/engine/scheduler/projectStartAnchorClamp'; import { isSummaryTask } from '@/utils/taskHierarchy'; import { reconcileP6SuspendResume } from '@/utils/p6SuspendResume'; +import { + captureTriangle, contourKeepsWork, planWorkEdit, commitTrianglePlan, settleAssignmentAdded, + settleAssignmentRemoved, settleDurationEdit, settleRuleChange, settleUnitsEdit, +} from '@/engine/work/workRuleApply'; +import type { WorkRule } from '@/types/workRule'; export type McpTransactionResult = | { ok: true; value: T; timephasedGuidanceLost: number } @@ -96,6 +101,23 @@ function createMcpDraft( runtime.recordMcpTimephasedLoss(activeLease(), taskId); }; + /** + * Taaktypes-etappe (bouwstap 4): nazorg wanneer de werkdriehoek de TAAKduur verandert (inzet/ + * werk/resource erbij-eraf onder FIXED_WORK/FIXED_RATE) — tweeling van resourceSlice.ts's + * `afterTriangleDurationChange`: contour + importsplits herschalen, Z8-venster/-walks wissen, + * verlies melden. `scheduleStale` blijft bewust aan de transactie (zie het docblok hierboven). + */ + const afterTriangleDurationChange = ( + s: { calendars: WorkCalendar[]; calendar: WorkCalendar; project: Pick }, + task: Task, + oldWorkMinutes: number, + ): void => { + rescaleTaskContours(task, oldWorkMinutes, taskCalendarHoursPerDay(task, s.calendars, s.calendar), contourKeepsWork(task, s.project.defaultWorkRule)); + const clearedWindow = clearTimephasedWindow(task); + const clearedWalks = timephasedDurationWalksHaveFrozenWork(task) && clearTimephasedDurationWalks(task); + if (clearedWindow || clearedWalks) recordTimephasedLoss(task.id); + }; + const rawDraft = { /** * Snapshot/recompute-vrije variant van de store-`addTask`: zelfde veld-afleiding (TaskTime-defaults, @@ -362,10 +384,15 @@ function createMcpDraft( const { time, ...rest } = updates; const contourHpd = taskCalendarHoursPerDay(s.tasks[idx], s.calendars, s.calendar); const oldWorkMinutes = taskWorkMinutesOf(s.tasks[idx], contourHpd); + // Taaktypes-etappe (bouwstap 4) — tweeling van taskSlice.ts's `updateTask`: momentopname vóór. + const triangle = timeUpdateTouchesTimephasedWindow(time) ? captureTriangle(s.tasks[idx], s.assignments, s) : null; Object.assign(s.tasks[idx], rest); if (time) s.tasks[idx].time = mergeTaskTime(s.tasks[idx].time, time); // Contour-engine (2026-09) — tweeling van taskSlice.ts's `updateTask`: herschaal de contour. - if (timeUpdateTouchesTimephasedWindow(time)) rescaleTaskContours(s.tasks[idx], oldWorkMinutes, contourHpd); + if (timeUpdateTouchesTimephasedWindow(time)) { + rescaleTaskContours(s.tasks[idx], oldWorkMinutes, contourHpd, contourKeepsWork(s.tasks[idx], s.project.defaultWorkRule)); + settleDurationEdit(s.tasks[idx], s.assignments, triangle); + } reconcileP6SuspendResume(s.tasks[idx]); // Z14b (eigenaarsprincipe 2026-08-18) — gedocumenteerde tweeling van taskSlice.ts's // `updateTask`: zelfde triggerset/uitleg in `taskDefaults.ts`. @@ -405,6 +432,8 @@ function createMcpDraft( const task = s.tasks[idx]; const contourHpd = taskCalendarHoursPerDay(task, s.calendars, s.calendar); const oldWorkMinutes = taskWorkMinutesOf(task, contourHpd); + // Taaktypes-etappe (bouwstap 4) — zelfde momentopname als `updateTaskFields` hierboven. + const triangle = timePatch ? captureTriangle(task, s.assignments, s) : null; Object.assign(task, top); let timeTouched = false; if (timePatch) { @@ -415,7 +444,10 @@ function createMcpDraft( if (timePatch.clearDurationMinutes) { delete task.time.durationMinutes; timeTouched = true; } } // Contour-engine (2026-09) — zelfde herschaling als `updateTaskFields` hierboven. - if (timeTouched) rescaleTaskContours(task, oldWorkMinutes, contourHpd); + if (timeTouched) { + rescaleTaskContours(task, oldWorkMinutes, contourHpd, contourKeepsWork(task, s.project.defaultWorkRule)); + settleDurationEdit(task, s.assignments, triangle); + } reconcileP6SuspendResume(task); // Z14b (eigenaarsprincipe 2026-08-18) — zelfde triggerset als `updateTaskFields`, zie // `taskDefaults.ts`. `timePatch` heeft een eigen, smallere vorm (allowlist-gedreven) dan een @@ -609,8 +641,15 @@ function createMcpDraft( if (!isValidUnits(unitsPerDay)) { throw new Error(`draft.assignResource: ongeldige unitsPerDay ${String(unitsPerDay)} (strikt positief vereist)`); } - s.assignments.push({ id, taskId, resourceId, unitsPerDay, curve }); + // Taaktypes-etappe (spec §5 rij 4) — tweeling van resourceSlice.ts's `assignResource`. + const triangle = captureTriangle(task, s.assignments, s); + const oldWorkMinutes = taskWorkMinutesOf(task, taskCalendarHoursPerDay(task, s.calendars, s.calendar)); + const added: ResourceAssignment = { id, taskId, resourceId, unitsPerDay, curve }; + s.assignments.push(added); if (!task.resourceIds.includes(resourceId)) task.resourceIds.push(resourceId); + if (settleAssignmentAdded(task, s.assignments, triangle, added).durationChanged) { + afterTriangleDurationChange(s, task, oldWorkMinutes); + } // Z14b (eigenaarsprincipe 2026-08-18, F2-fixronde) — "toewijzingen" is expliciet onderdeel // van de triggerset (plan: "duur, datums, kalender, toewijzingen"): een andere resource kan // een andere resourcekalender betekenen, precies de Z8-laag-4-discriminator — dus BEIDE @@ -639,8 +678,54 @@ function createMcpDraft( delete patch.unitsPerDay; } if (Object.keys(patch).length === 0) return; + // Taaktypes-etappe (spec §5 rij 2) — tweeling van resourceSlice.ts's `updateAssignment`. + const task = s.tasks.find((t) => t.id === s.assignments[idx].taskId); + const unitsEdit = task && typeof patch.unitsPerDay === 'number' && patch.unitsPerDay !== s.assignments[idx].unitsPerDay + ? { task, triangle: captureTriangle(task, s.assignments, s), oldWorkMinutes: taskWorkMinutesOf(task, taskCalendarHoursPerDay(task, s.calendars, s.calendar)) } + : null; Object.assign(s.assignments[idx], patch); if ('curve' in patch) delete s.assignments[idx].curveValues; // contour-engine: spiegelt resourceSlice + if (unitsEdit) { + const settled = settleUnitsEdit(unitsEdit.task, s.assignments, unitsEdit.triangle, assignmentId, s.assignments[idx].unitsPerDay); + if (settled.durationChanged) afterTriangleDurationChange(s, unitsEdit.task, unitsEdit.oldWorkMinutes); + } + s.isDirty = true; + }); + }, + + /** + * Snapshot/recompute-vrije variant van de store-`setAssignmentWork` (taaktypes-etappe, spec §5 + * rij 3): zet het resterende werk (werkminuten, > 0) van één toewijzing; de werkdriehoek leidt + * inzet of restduur af. Onbekend id, ongeldig werk of een taak buiten de regel ⇒ FOUT (de + * toollaag pre-valideert; dit is de vangrail). + */ + setAssignmentWork(assignmentId: string, remainingWorkMinutes: number): void { + store.setState((s) => { + const a = s.assignments.find((x) => x.id === assignmentId); + if (!a) throw new Error(`draft.setAssignmentWork: onbekende assignmentId '${assignmentId}'`); + const task = s.tasks.find((t) => t.id === a.taskId); + if (!task) throw new Error(`draft.setAssignmentWork: toewijzing '${assignmentId}' zonder taak`); + const oldWorkMinutes = taskWorkMinutesOf(task, taskCalendarHoursPerDay(task, s.calendars, s.calendar)); + const plan = planWorkEdit(task, s.assignments, s, assignmentId, remainingWorkMinutes); + if (!plan) { + throw new Error(`draft.setAssignmentWork: werk ${String(remainingWorkMinutes)} geweigerd (strikt positief vereist; de werkregel geldt niet op mijlpalen, hangmatten, samenvattingen of ELAPSEDTIME-taken)`); + } + if (commitTrianglePlan(task, s.assignments, plan).durationChanged) afterTriangleDurationChange(s, task, oldWorkMinutes); + s.isDirty = true; + }); + }, + + /** + * Snapshot/recompute-vrije variant van de store-`setTaskWorkRule` (spec §5 rij 6): zet de + * werkregel van een taak (`undefined` = projectstandaard). Geen getal verandert; een + * werkbeschermende regel legt het huidige restwerk vast. Onbekend id ⇒ fout. + */ + setTaskWorkRule(taskId: string, rule: WorkRule | undefined): void { + store.setState((s) => { + const task = s.tasks.find((t) => t.id === taskId); + if (!task) throw new Error(`draft.setTaskWorkRule: onbekende taskId '${taskId}'`); + if (task.workRule === rule) return; + settleRuleChange(task, s.assignments, s, rule); s.isDirty = true; }); }, @@ -729,7 +814,14 @@ function createMcpDraft( store.setState((s) => { const removed = s.assignments.find((a) => a.id === assignmentId); if (!removed) throw new Error(`draft.unassignResource: onbekende assignmentId '${assignmentId}'`); + // Taaktypes-etappe (spec §5 rij 5) — tweeling van resourceSlice.ts's `unassignResource`. + const triangleTask = s.tasks.find((t) => t.id === removed.taskId); + const triangle = triangleTask ? captureTriangle(triangleTask, s.assignments, s) : null; + const oldWorkMinutes = triangleTask ? taskWorkMinutesOf(triangleTask, taskCalendarHoursPerDay(triangleTask, s.calendars, s.calendar)) : 0; s.assignments = s.assignments.filter((a) => a.id !== assignmentId); + if (triangleTask && settleAssignmentRemoved(triangleTask, s.assignments, triangle, assignmentId).durationChanged) { + afterTriangleDurationChange(s, triangleTask, oldWorkMinutes); + } const stillAssigned = s.assignments.some( (a) => a.taskId === removed.taskId && a.resourceId === removed.resourceId, ); diff --git a/src/state/slices/resourceSlice.ts b/src/state/slices/resourceSlice.ts index 4961ab61e..710bf611c 100644 --- a/src/state/slices/resourceSlice.ts +++ b/src/state/slices/resourceSlice.ts @@ -5,7 +5,15 @@ import { contourIndexForAssignment } from '@/engine/contour/contourEngine'; import { generateId } from '@/utils/id'; import { nextFreePaletteColor } from '@/engine/renderer/resourcePalette'; import { syncProjectCalendar } from '../syncProjectCalendar'; -import { clearTimephasedWindow, clearTimephasedDurationWalks } from '@/utils/taskDefaults'; +import { + clearTimephasedWindow, clearTimephasedDurationWalks, rescaleTaskContours, taskCalendarHoursPerDay, + taskWorkMinutesOf, timephasedDurationWalksHaveFrozenWork, +} from '@/utils/taskDefaults'; +import { + captureTriangle, commitTrianglePlan, contourKeepsWork, planWorkEdit, settleAssignmentAdded, + settleAssignmentRemoved, settleUnitsEdit, +} from '@/engine/work/workRuleApply'; +import type { Task } from '@/types/task'; import { notifyTimephasedLoss } from '../timephasedLossNotice'; import type { AppSliceFactory } from './types'; import { isSummaryTask } from '@/utils/taskHierarchy'; @@ -28,6 +36,12 @@ export interface ResourceSlice { /** Wijzig eenheden/curve van een bestaande toewijzing (inline-bewerken in de UI, §6.3). */ updateAssignment: (assignmentId: string, updates: Partial>) => void; unassignResource: (assignmentId: string) => void; + /** Taaktypes-etappe (spec 2026-09-04 §5 rij 3): zet het RESTERENDE werk (werkminuten, > 0) van + * één toewijzing; de werkdriehoek leidt daaruit inzet of restduur af volgens de regel van de + * taak (`workTriangle.ts`'s `applyWorkEdit`). Een gewijzigde taakduur zet `scheduleStale`, + * herschaalt de contour en wist het Z8-venster — precies zoals een duurbewerking. Weigert stil + * (geen snapshot) bij onbekend id, ongeldig werk of een taak waarop de regel niet werkt. */ + setAssignmentWork: (assignmentId: string, remainingWorkMinutes: number) => void; /** Contour-UI (2026-09): zet of vervang de OPGESLAGEN contour van één toewijzing * (`Task.timephasedContours`, gekoppeld via `resourceId` — `contourEngine.ts`'s * `matchContoursToAssignments`), of laat 'm los (`null` ⇒ de toewijzing valt terug op de @@ -58,6 +72,20 @@ export interface ResourceSlice { const isValidUnits = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n) && n > 0; +/** + * Taaktypes-etappe (2026-09, bouwstap 4): gevolg van een DUURwijziging die uit de werkdriehoek + * komt (inzet/werk/resource erbij-eraf onder FIXED_WORK/FIXED_RATE) — dezelfde nazorg als + * `taskSlice.updateTask` bij een duurbewerking: contour én importsplits herschalen (werkbehoud + * volgens de regel), Z8-venster/-walks wissen. Retourneert of er timephased-sturing verloren ging. + */ +function afterTriangleDurationChange(s: { tasks: Task[]; calendars: WorkCalendar[]; calendar: WorkCalendar; project: { defaultWorkRule?: Task['workRule'] } }, task: Task, oldWorkMinutes: number): boolean { + const hpd = taskCalendarHoursPerDay(task, s.calendars, s.calendar); + rescaleTaskContours(task, oldWorkMinutes, hpd, contourKeepsWork(task, s.project.defaultWorkRule)); + const clearedWindow = clearTimephasedWindow(task); + const clearedWalks = timephasedDurationWalksHaveFrozenWork(task) && clearTimephasedDurationWalks(task); + return clearedWindow || clearedWalks; +} + export const createResourceSlice: AppSliceFactory = (runtime) => (set, get) => ({ resources: [], assignments: [], @@ -142,11 +170,19 @@ export const createResourceSlice: AppSliceFactory = (runtime) => runtime.beginUndoable(s); + // Taaktypes-etappe (spec §5 rij 4, beslispunt 8-B): momentopname ZONDER de nieuwe toewijzing. + const triangle = captureTriangle(task, s.assignments, s); + const oldWorkMinutes = taskWorkMinutesOf(task, taskCalendarHoursPerDay(task, s.calendars, s.calendar)); const id = generateId('asgn'); - s.assignments.push({ id, taskId, resourceId, unitsPerDay, curve }); + const added: ResourceAssignment = { id, taskId, resourceId, unitsPerDay, curve }; + s.assignments.push(added); if (!task.resourceIds.includes(resourceId)) { task.resourceIds.push(resourceId); } + // Onder FIXED_WORK/FIXED_RATE (zonder MSP-`effortDriven: false`) blijft het restwerk staan en + // wordt de restduur korter; onder de standaardregel verandert niets (byte-identiek). + const settled = settleAssignmentAdded(task, s.assignments, triangle, added); + if (settled.durationChanged) afterTriangleDurationChange(s, task, oldWorkMinutes); // Z14b (eigenaarsprincipe 2026-08-18, F2-fixronde) — "toewijzingen" is expliciet onderdeel // van de edit-time-invalidatie-triggerset (zie `taskDefaults.ts`'s `clearTimephasedWindow`/ // `clearTimephasedDurationWalks`): een andere resource kan een andere resourcekalender @@ -155,7 +191,7 @@ export const createResourceSlice: AppSliceFactory = (runtime) => const clearedWindow = clearTimephasedWindow(task); const clearedWalks = clearTimephasedDurationWalks(task); lostTimephasedGuidance = clearedWindow || clearedWalks; - runtime.finishMutation(s); + runtime.finishMutation(s, { stale: settled.durationChanged }); }); if (lostTimephasedGuidance) notifyTimephasedLoss(get().notify, get().activeDocumentId, 1); get().recomputeResourceLoad(); @@ -163,6 +199,9 @@ export const createResourceSlice: AppSliceFactory = (runtime) => }, updateAssignment: (assignmentId, updates) => { + // mpp-nul-data-etappe, DEEL 1 — zie `assignResource` hierboven (alleen relevant wanneer de + // werkdriehoek de taakduur verandert). + let lostTimephasedGuidance = false; set((s) => { const idx = s.assignments.findIndex(a => a.id === assignmentId); if (idx < 0) return; @@ -175,17 +214,54 @@ export const createResourceSlice: AppSliceFactory = (runtime) => } if (Object.keys(patch).length === 0) return; runtime.beginUndoable(s); + // Taaktypes-etappe (spec §5 rij 2): momentopname VÓÓR de inzetwijziging; de exacte invoer + // wordt eerst geschreven, daarna volgen werk en/of restduur de regel van de taak. + const task = s.tasks.find(t => t.id === s.assignments[idx].taskId); + const unitsEdit = task && typeof patch.unitsPerDay === 'number' && patch.unitsPerDay !== s.assignments[idx].unitsPerDay + ? { task, triangle: captureTriangle(task, s.assignments, s), oldWorkMinutes: taskWorkMinutesOf(task, taskCalendarHoursPerDay(task, s.calendars, s.calendar)) } + : null; Object.assign(s.assignments[idx], patch); // Contour-engine (2026-09): een bewuste curvekeuze van de gebruiker vervangt de exacte // geïmporteerde 21-punts curve (`curveValues`, P6/MSPDI) — anders zou het histogram de oude // P6-vorm blijven tonen terwijl de dropdown de nieuwe keuze laat zien. if ('curve' in patch) delete s.assignments[idx].curveValues; - runtime.finishMutation(s); + let stale = false; + if (unitsEdit) { + const settled = settleUnitsEdit(unitsEdit.task, s.assignments, unitsEdit.triangle, assignmentId, s.assignments[idx].unitsPerDay); + if (settled.durationChanged) { + lostTimephasedGuidance = afterTriangleDurationChange(s, unitsEdit.task, unitsEdit.oldWorkMinutes); + stale = true; + } + } + runtime.finishMutation(s, { stale }); }); + if (lostTimephasedGuidance) notifyTimephasedLoss(get().notify, get().activeDocumentId, 1); get().recomputeResourceLoad(); get().recomputeViewRows(); // resource-naam/toewijzing raakt kolom/groep/filter (§4.3). }, + setAssignmentWork: (assignmentId, remainingWorkMinutes) => { + let lostTimephasedGuidance = false; + set((s) => { + const a = s.assignments.find(x => x.id === assignmentId); + if (!a) return; + const task = s.tasks.find(t => t.id === a.taskId); + if (!task) return; + if (typeof remainingWorkMinutes !== 'number' || !Number.isFinite(remainingWorkMinutes) || remainingWorkMinutes <= 0) return; + const oldWorkMinutes = taskWorkMinutesOf(task, taskCalendarHoursPerDay(task, s.calendars, s.calendar)); + // Eerst plannen (puur), dan pas de snapshot: een weigering laat geen lege undo-stap achter. + const plan = planWorkEdit(task, s.assignments, s, assignmentId, remainingWorkMinutes); + if (!plan) return; + runtime.beginUndoable(s); + const settled = commitTrianglePlan(task, s.assignments, plan); + if (settled.durationChanged) lostTimephasedGuidance = afterTriangleDurationChange(s, task, oldWorkMinutes); + runtime.finishMutation(s, { stale: settled.durationChanged }); + }); + if (lostTimephasedGuidance) notifyTimephasedLoss(get().notify, get().activeDocumentId, 1); + get().recomputeResourceLoad(); + get().recomputeViewRows(); + }, + setAssignmentContour: (assignmentId, periods) => { set((s) => { const a = s.assignments.find(x => x.id === assignmentId); @@ -221,7 +297,16 @@ export const createResourceSlice: AppSliceFactory = (runtime) => runtime.beginUndoable(s); + // Taaktypes-etappe (spec §5 rij 5): momentopname MÉT de te verwijderen toewijzing. + const triangleTask = s.tasks.find(t => t.id === removed.taskId); + const triangle = triangleTask ? captureTriangle(triangleTask, s.assignments, s) : null; + const oldWorkMinutes = triangleTask ? taskWorkMinutesOf(triangleTask, taskCalendarHoursPerDay(triangleTask, s.calendars, s.calendar)) : 0; s.assignments = s.assignments.filter(a => a.id !== assignmentId); + let stale = false; + if (triangleTask) { + const settled = settleAssignmentRemoved(triangleTask, s.assignments, triangle, assignmentId); + if (settled.durationChanged) { afterTriangleDurationChange(s, triangleTask, oldWorkMinutes); stale = true; } + } // task.resourceIds alleen opschonen als er geen andere toewijzing van // dezelfde resource aan dezelfde taak meer bestaat. const stillAssigned = s.assignments.some( @@ -239,7 +324,7 @@ export const createResourceSlice: AppSliceFactory = (runtime) => const clearedWalks = clearTimephasedDurationWalks(removedTask); lostTimephasedGuidance = clearedWindow || clearedWalks; } - runtime.finishMutation(s); + runtime.finishMutation(s, { stale }); }); if (lostTimephasedGuidance) notifyTimephasedLoss(get().notify, get().activeDocumentId, 1); get().recomputeResourceLoad(); diff --git a/src/state/slices/taskSlice.ts b/src/state/slices/taskSlice.ts index d39250cad..3cf550698 100644 --- a/src/state/slices/taskSlice.ts +++ b/src/state/slices/taskSlice.ts @@ -16,6 +16,8 @@ import type { WbsTemplate } from '@/utils/wbsTemplates'; import { detachFromParent, attachToParent, isSelfOrDescendant, collectSubtreeIds, siblingIds } from '@/state/taskTree'; import { relationVerdict } from '@/state/relationRules'; import { notifyTimephasedLoss } from '../timephasedLossNotice'; +import { captureTriangle, contourKeepsWork, settleDurationEdit, settleRuleChange } from '@/engine/work/workRuleApply'; +import type { WorkRule } from '@/types/workRule'; import type { AppSliceFactory, SiblingDirection } from './types'; import { deriveHoursPerDay, hasConcreteWorkBlocks } from '@/services/subdayIo'; @@ -37,6 +39,11 @@ export interface TaskSlice { position?: { anchorId: string; where: 'above' | 'below' }; }) => string; updateTask: (id: string, updates: Partial, opts?: { coalesceKey?: string }) => void; + /** Taaktypes-etappe (spec 2026-09-04 §5 rij 6): zet de werkregel van één taak (`undefined` = terug + * naar de projectstandaard). Geen getal verandert; een werkbeschermende regel legt het huidige + * restwerk van de werkresources vast (`workTriangle.ts`'s `applyRuleChange`). Geen + * `scheduleStale` (geen datum raakt). Onbekend id of ongewijzigde regel ⇒ no-op. */ + setTaskWorkRule: (id: string, rule: WorkRule | undefined) => void; deleteTask: (id: string) => void; /** Verwijder meerdere taken en hun subbomen als precies één undoable storehandeling. */ deleteTasksBulk: (ids: readonly string[]) => void; @@ -400,12 +407,21 @@ export const createTaskSlice: AppSliceFactory = (runtime) => (set, ge // Contour-engine (2026-09): de oude werkduur vóór de merge, voor de herschaling hieronder. const contourHpd = taskCalendarHoursPerDay(s.tasks[idx], s.calendars, s.calendar); const oldWorkMinutes = taskWorkMinutesOf(s.tasks[idx], contourHpd); + // Taaktypes-etappe (2026-09, bouwstap 4): momentopname van de werkdriehoek VÓÓR de merge — + // een duurwijziging laat de toewijzingen hun regel volgen (`settleDurationEdit` hieronder). + const triangle = timeUpdateTouchesTimephasedWindow(time) ? captureTriangle(s.tasks[idx], s.assignments, s) : null; Object.assign(s.tasks[idx], rest); if (time) s.tasks[idx].time = mergeTaskTime(s.tasks[idx].time, time); // Contour-engine (2026-09): een duurwijziging herschaalt de contour (én de importsplits) // proportioneel — de verdeling reist mee met de bewerking i.p.v. te verouderen. Zie // `taskDefaults.ts`'s `rescaleTaskContours`. Kalender-/datumwijzigingen raken de as niet. - if (timeUpdateTouchesTimephasedWindow(time)) rescaleTaskContours(s.tasks[idx], oldWorkMinutes, contourHpd); + // Werkbehoud volgt de effectieve werkregel (`contourKeepsWork`). + if (timeUpdateTouchesTimephasedWindow(time)) { + rescaleTaskContours(s.tasks[idx], oldWorkMinutes, contourHpd, contourKeepsWork(s.tasks[idx], s.project.defaultWorkRule)); + // Spec §5 rij 1: werk beschermd ⇒ inzet = W / R'; anders volgt een aanwezig werkveld de + // nieuwe duur. Onder de standaardregel zonder werkvelden gebeurt er niets (byte-identiek). + settleDurationEdit(s.tasks[idx], s.assignments, triangle); + } 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 @@ -427,6 +443,18 @@ export const createTaskSlice: AppSliceFactory = (runtime) => (set, ge get().recomputeViewRows(); }, + setTaskWorkRule: (id, rule) => { + set((s) => { + const task = s.tasks.find(t => t.id === id); + if (!task || task.workRule === rule) return; // onbekend id of ongewijzigd: geen snapshot. + runtime.beginUndoable(s); + settleRuleChange(task, s.assignments, s, rule); + runtime.finishMutation(s); // geen `stale`: een typewissel raakt geen datum (spec besluit 2). + }); + get().recomputeResourceLoad(); // een vastgelegd restwerk kan de vierde bron van `assignmentDayUnits` activeren. + get().recomputeViewRows(); + }, + setTaskCalendar: (taskId, calendarId) => { // mpp-nul-data-etappe, DEEL 1 — zie `updateTask` hierboven. let lostTimephasedGuidance = false; diff --git a/src/utils/taskDefaults.ts b/src/utils/taskDefaults.ts index 63fbe693e..294e6cd71 100644 --- a/src/utils/taskDefaults.ts +++ b/src/utils/taskDefaults.ts @@ -368,7 +368,15 @@ export function taskWorkMinutesOf(task: Task, hoursPerDay: number): number { * (shift-invariant, zie `TaskSplitGap`'s docblok), dus een verplaatsing kost geen herschaling, en * een taakkalenderwissel verandert de werkminuten-duur van de taak niet. */ -export function rescaleTaskContours(task: Task, oldWorkMinutes: number, hoursPerDay: number): boolean { +export function rescaleTaskContours( + task: Task, + oldWorkMinutes: number, + hoursPerDay: number, + // Taaktypes-etappe (2026-09, bouwstap 4): werkbehoud is een REGELkeuze (`workRuleApply.ts`'s + // `contourKeepsWork`), niet langer alleen een MSP-herkomstvinkje. Zonder argument geldt de oude + // afleiding, zodat elke bestaande aanroeper byte-identiek blijft. + keepWork: boolean = task.mspTaskType === 'FIXED_WORK', +): boolean { const contours = task.timephasedContours; if (!contours || contours.length === 0) return false; const newWorkMinutes = taskWorkMinutes(task.time, hoursPerDay); @@ -382,7 +390,7 @@ export function rescaleTaskContours(task: Task, oldWorkMinutes: number, hoursPer if (!rescaleFactor(reference.periods, oldWorkMinutes, newWorkMinutes)) return false; task.timephasedContours = contours.map((c) => ({ ...c, - periods: rescaleContourForDuration(c.periods, oldWorkMinutes, newWorkMinutes, task.mspTaskType), + periods: rescaleContourForDuration(c.periods, oldWorkMinutes, newWorkMinutes, keepWork ? 'FIXED_WORK' : undefined), })); const gaps = rescaleSplitGaps(task.splitGaps, reference.periods, oldWorkMinutes, newWorkMinutes); if (gaps !== undefined) task.splitGaps = gaps; diff --git a/tests/planning/check-work-rule-store.ts b/tests/planning/check-work-rule-store.ts new file mode 100644 index 000000000..99a8c59bf --- /dev/null +++ b/tests/planning/check-work-rule-store.ts @@ -0,0 +1,279 @@ +// check-work-rule-store.ts — taaktypes-etappe (spec 2026-09-04, bouwstap 4): de werkdriehoek +// BEDRAAD in de store (`taskSlice.updateTask`/`setTaskWorkRule`, `resourceSlice.assignResource`/ +// `updateAssignment`/`unassignResource`/`setAssignmentWork`), in het taakraster +// (`gridTransaction.ts`) en in de MCP-tweeling (`createMcpTransactions.ts`), plus de vierde bron +// van `assignmentDayUnits`. De pure kern staat in `check-work-triangle.ts`; hier gaat het om +// het ritueel eromheen: byte-identiek onder de standaardregel, `scheduleStale` bij een duur die +// uit de driehoek komt, één undo-stap (meetlat 28), contourherschaling met werkbehoud (22) en +// resource erbij onder FIXED_WORK (23). +// +// Draait via run.sh. Exit 0 = alles groen. +import './domStub'; +import { useAppStore } from '@/state/appStore'; +import { runGridMutation } from '@/state/gridTransaction'; +import { draft, runInMcpTransaction } from '@/state/mcpTransaction'; +import { assignmentDayUnits } from '@/engine/scheduler/ResourceLoad'; +import { workDaySlotsToPeriods } from '@/engine/contour/contourEdit'; +import { contourKeepsWork, workRuleApplies } from '@/engine/work/workRuleApply'; +import { createDefaultTaskTime } from '@/utils/taskDefaults'; +import type { AssignmentSetIntent, CellEditIntent } from '@/types/taskGrid'; +import type { Task } from '@/types/task'; +import type { ResourceAssignment } from '@/types/resource'; + +const S = () => useAppStore.getState(); +let checks = 0; +const diffs: string[] = []; +function eq(label: string, got: unknown, want: unknown): void { + checks++; + const g = JSON.stringify(got), w = JSON.stringify(want); + if (g !== w) diffs.push(`${label}: kreeg ${g}, verwachtte ${w}`); +} +function ok(label: string, cond: boolean): void { eq(label, cond, true); } +const near = (a: number | undefined, b: number, eps = 1e-6) => a !== undefined && Math.abs(a - b) < eps; + +function reset(): void { + S().newProject(); + useAppStore.setState((s) => { s.historyEvents = []; s.nextHistorySequence = 1; s.ui.notifications = []; s.isDirty = false; }); +} +const slot = () => S().calendar.hoursPerDay * 60; +const task = (id: string): Task => S().tasks.find((t) => t.id === id)!; +const asgOf = (taskId: string, resourceId: string): ResourceAssignment => S().assignments.find((a) => a.taskId === taskId && a.resourceId === resourceId)!; +function labor(name: string): string { return S().addResource({ name, type: 'LABOR', description: '', maxUnits: 1 }); } + +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- (a) standaardregel: byte-identiek aan vandaag --'); +{ + reset(); + const t = S().addTask({ name: 'a', time: createDefaultTaskTime('2026-06-01', 5) }); + const r1 = labor('r1'); + S().assignResource(t, r1, 1); + S().runCPM(); + const before = JSON.stringify(asgOf(t, r1)); + S().updateTask(t, { time: { ...task(t).time, scheduleDuration: 10 } }); + eq('a1 duur ×2 laat de toewijzing onder FIXED_DURATION_RATE ongemoeid', JSON.stringify(asgOf(t, r1)), before); + eq('a2 …en schrijft geen werkveld', asgOf(t, r1).remainingWorkMinutes, undefined); + S().runCPM(); + S().updateAssignment(asgOf(t, r1).id, { unitsPerDay: 0.5 }); + eq('a3 inzet → 0,5 laat de duur staan', task(t).time.scheduleDuration, 10); + eq('a4 …en zet de planning NIET verouderd (zoals vandaag)', S().scheduleStale, false); + const r2 = labor('r2'); + S().assignResource(t, r2, 1); + eq('a5 tweede resource erbij: duur blijft 10', task(t).time.scheduleDuration, 10); + eq('a6 …inzet r1 blijft 0,5, geen werkvelden', [asgOf(t, r1).unitsPerDay, asgOf(t, r1).remainingWorkMinutes, asgOf(t, r2).remainingWorkMinutes], [0.5, undefined, undefined]); + eq('a7 workRuleApplies: bladtaak op werktijd', workRuleApplies(task(t)), true); + const m = S().addTask({ name: 'm', isMilestone: true }); + eq('a8 workRuleApplies: mijlpaal niet', workRuleApplies(task(m)), false); +} + +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- (b) FIXED_WORK in de store: duur, inzet, resource erbij/eraf, werk, undo --'); +{ + reset(); + const t = S().addTask({ name: 'b', time: createDefaultTaskTime('2026-06-01', 4) }); + const r1 = labor('r1'); + S().assignResource(t, r1, 1); + S().runCPM(); + const events0 = S().historyEvents.length; + S().setTaskWorkRule(t, 'FIXED_WORK'); + eq('b1 typewissel schrijft workRule', task(t).workRule, 'FIXED_WORK'); + eq('b2 …legt het restwerk vast (4 dagen × slot)', asgOf(t, r1).remainingWorkMinutes, 4 * slot()); + eq('b3 …verandert de duur niet en zet niet verouderd', [task(t).time.scheduleDuration, S().scheduleStale], [4, false]); + eq('b4 …is één undo-stap en dirty', [S().historyEvents.length - events0, S().isDirty], [1, true]); + S().setTaskWorkRule(t, 'FIXED_WORK'); + eq('b5 ongewijzigde regel is een no-op (geen undo-event)', S().historyEvents.length - events0, 1); + + // Duur ×2 ⇒ inzet ×0,5, werk blijft. + S().updateTask(t, { time: { ...task(t).time, scheduleDuration: 8 } }); + eq('b6 duur 4→8: inzet 0,5', asgOf(t, r1).unitsPerDay, 0.5); + eq('b7 …werk blijft 4 slots', asgOf(t, r1).remainingWorkMinutes, 4 * slot()); + eq('b8 …planning verouderd', S().scheduleStale, true); + S().runCPM(); + + // Inzet → 1 ⇒ duur 4 (meetlat 28 hieronder: undo in één stap). + const eventsBeforeUnits = S().historyEvents.length; + S().updateAssignment(asgOf(t, r1).id, { unitsPerDay: 1 }); + eq('b9 inzet 0,5→1: duur 4', task(t).time.scheduleDuration, 4); + eq('b10 …werk blijft, inzet exact 1', [asgOf(t, r1).remainingWorkMinutes, asgOf(t, r1).unitsPerDay], [4 * slot(), 1]); + eq('b11 …planning verouderd', S().scheduleStale, true); + eq('b12 …één undo-event', S().historyEvents.length - eventsBeforeUnits, 1); + S().undo(); + eq('b13 (meetlat 28) undo zet duur én inzet in één stap terug', [task(t).time.scheduleDuration, asgOf(t, r1).unitsPerDay], [8, 0.5]); + S().redo(); + eq('b14 redo herstelt', [task(t).time.scheduleDuration, asgOf(t, r1).unitsPerDay], [4, 1]); + S().runCPM(); + + // Resource erbij (spec §5 rij 4): werk 4 slots over twee resources à 1 ⇒ elk 2 slots, R = 2 d. + const r2 = labor('r2'); + S().assignResource(t, r2, 1); + eq('b15 tweede resource erbij onder FIXED_WORK: duur 2', task(t).time.scheduleDuration, 2); + eq('b16 …werk naar rato: 2 + 2 slots', [asgOf(t, r1).remainingWorkMinutes, asgOf(t, r2).remainingWorkMinutes], [2 * slot(), 2 * slot()]); + eq('b17 …inzet blijft zoals ingevoerd', [asgOf(t, r1).unitsPerDay, asgOf(t, r2).unitsPerDay], [1, 1]); + eq('b18 …planning verouderd', S().scheduleStale, true); + S().runCPM(); + // Materiaal erbij: telt niet mee (spec §4.3). + const mat = S().addResource({ name: 'zand', type: 'MATERIAL', description: '', maxUnits: 100 }); + S().assignResource(t, mat, 10); + eq('b19 materiaal erbij verandert de duur niet', [task(t).time.scheduleDuration, S().scheduleStale], [2, false]); + eq('b20 …en krijgt geen werkveld', asgOf(t, mat).remainingWorkMinutes, undefined); + // Resource eraf: r2 weg ⇒ 4 slots terug naar r1 ⇒ duur 4. + S().unassignResource(asgOf(t, r2).id); + eq('b21 r2 eraf: duur 4, werk 4 slots bij r1', [task(t).time.scheduleDuration, asgOf(t, r1).remainingWorkMinutes], [4, 4 * slot()]); + S().runCPM(); + // Werk gezet (spec §5 rij 3): 8 slots bij inzet 1 ⇒ duur 8. + S().setAssignmentWork(asgOf(t, r1).id, 8 * slot()); + eq('b22 werk → 8 slots: duur 8, inzet 1', [task(t).time.scheduleDuration, asgOf(t, r1).unitsPerDay, S().scheduleStale], [8, 1, true]); + const eventsBeforeBad = S().historyEvents.length; + S().setAssignmentWork(asgOf(t, r1).id, -1); + S().setAssignmentWork('bestaat-niet', 100); + eq('b23 ongeldig werk/onbekend id: no-op zonder undo-event', S().historyEvents.length, eventsBeforeBad); + // Halve dag: 3 slots bij inzet 1 op dagmodus ⇒ R rondt op hele dagen; 2,5 d ⇒ 3 d. + S().runCPM(); + S().setAssignmentWork(asgOf(t, r1).id, 2.5 * slot()); + eq('b24 2,5 dag werk rondt de duur naar boven op hele dagen', task(t).time.scheduleDuration, 3); + eq('b25 …het werk zelf blijft 2,5 slots', asgOf(t, r1).remainingWorkMinutes, 2.5 * slot()); + // Terug naar de projectstandaard: geen getal verandert, het werkveld blijft staan. + S().setTaskWorkRule(t, undefined); + eq('b26 regel terug naar projectstandaard: veld weg, werk blijft', [task(t).workRule, asgOf(t, r1).remainingWorkMinutes], [undefined, 2.5 * slot()]); +} + +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- (c) FIXED_DURATION_WORK en projectstandaard --'); +{ + reset(); + S().setProject({ defaultWorkRule: 'FIXED_DURATION_WORK' }); + const t = S().addTask({ name: 'c', time: createDefaultTaskTime('2026-06-01', 4) }); + const r1 = labor('r1'); + S().assignResource(t, r1, 1); + S().runCPM(); + eq('c1 projectstandaard geldt zonder eigen workRule', task(t).workRule, undefined); + S().updateAssignment(asgOf(t, r1).id, { unitsPerDay: 0.5 }); + eq('c2 inzet → 0,5: duur blijft 4, werk = R × I = 2 slots (vastgelegd)', [task(t).time.scheduleDuration, asgOf(t, r1).remainingWorkMinutes, S().scheduleStale], [4, 2 * slot(), false]); + S().updateTask(t, { time: { ...task(t).time, scheduleDuration: 8 } }); + eq('c3 duur 4→8: inzet 0,25, werk blijft 2 slots', [asgOf(t, r1).unitsPerDay, asgOf(t, r1).remainingWorkMinutes], [0.25, 2 * slot()]); + // MSP-afwijking 8-B: bewaard effortDriven true op FIXED_DURATION_WORK ⇒ werk volgt de duur. + S().updateTask(t, { mspTaskType: 'FIXED_DURATION', effortDriven: true }); + S().runCPM(); + S().updateTask(t, { time: { ...task(t).time, scheduleDuration: 4 } }); + eq('c4 (8-B) FD+ED: duur 8→4 laat de inzet staan en herschrijft het werk', [asgOf(t, r1).unitsPerDay, asgOf(t, r1).remainingWorkMinutes], [0.25, slot()]); +} + +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- (d) contour met werkbehoud (meetlat 22/23) --'); +{ + reset(); + const t = S().addTask({ name: 'd', time: createDefaultTaskTime('2026-06-01', 4) }); + const r1 = labor('r1'); + S().assignResource(t, r1, 1); + S().runCPM(); + S().setTaskWorkRule(t, 'FIXED_WORK'); + // Vooraan belast: 2, 1, 0,5, 0,5 slots (som 4). + const mpd = slot(); + S().setAssignmentContour(asgOf(t, r1).id, workDaySlotsToPeriods([2 * mpd, mpd, 0.5 * mpd, 0.5 * mpd], undefined, mpd)); + const workBefore = task(t).timephasedContours![0].periods.reduce((s, p) => s + p.workMinutes, 0); + S().updateTask(t, { time: { ...task(t).time, scheduleDuration: 8 } }); + const c = task(t).timephasedContours![0].periods; + eq('d1 (22) werk in de contour blijft na duur ×2', c.reduce((s, p) => s + p.workMinutes, 0), workBefore); + eq('d2 (22) as ×2', c.reduce((m, p) => Math.max(m, p.afterMinutes + p.minutes), 0), 8 * mpd); + eq('d3 inzet 0,5', asgOf(t, r1).unitsPerDay, 0.5); + eq('d4 contourKeepsWork volgt de regel (eigen veld wint van MSP-vinkje)', [ + contourKeepsWork({ workRule: 'FIXED_WORK', mspTaskType: 'FIXED_UNITS' }), + contourKeepsWork({ workRule: 'FIXED_DURATION_RATE', mspTaskType: 'FIXED_WORK' }), + contourKeepsWork({ workRule: undefined, mspTaskType: 'FIXED_WORK' }), + contourKeepsWork({ workRule: undefined, mspTaskType: undefined }, 'FIXED_DURATION_WORK'), + contourKeepsWork({ workRule: undefined, mspTaskType: undefined }), + ], [true, false, true, true, false]); + // (23) tweede resource erbij à 0,5 naast r1 à 0,5: werk (4 slots) naar rato ⇒ 2 + 2; + // R = 2 / 0,5 = 4 d ⇒ de as van r1's contour gaat van 8 terug naar 4 d, de HOOGTE halveert + // (besluit 3: vorm blijft, hoogte zakt — `reconcileContourWork`); r2 heeft geen contour (vlak). + S().runCPM(); + const r2 = labor('r2'); + S().assignResource(t, r2, 0.5); + eq('d5 (23) duur uit W/ΣI: 4 dagen', task(t).time.scheduleDuration, 4); + eq('d6 (23) werk 2 + 2 slots', [asgOf(t, r1).remainingWorkMinutes, asgOf(t, r2).remainingWorkMinutes], [2 * mpd, 2 * mpd]); + const c2 = task(t).timephasedContours!.find((k) => k.resourceId === r1)!.periods; + eq('d7 (23) contour r1: as 4 d, hoogte ×0,5 (som = nieuw restwerk)', [c2.reduce((m, p) => Math.max(m, p.afterMinutes + p.minutes), 0), c2.reduce((s, p) => s + p.workMinutes, 0)], [4 * mpd, 2 * mpd]); + eq('d7b (23) vorm blijft: verhouding eerste/laatste periode ongewijzigd (4 : 1)', c2[0].workMinutes / c2[c2.length - 1].workMinutes, 4); + eq('d8 (23) r2 heeft geen contour (vlak)', task(t).timephasedContours!.some((k) => k.resourceId === r2), false); + eq('d9 (23) histogram r1 boekt de gehalveerde contour (som 2 dag-eenheden)', assignmentDayUnits(task(t), asgOf(t, r1), mpd, undefined, S().assignments.filter((a) => a.taskId === t)).map((u) => Math.round(u * 1000) / 1000), [1, 0.5, 0.25, 0.25]); +} + +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- (e) taakraster: dezelfde regels via gridTransaction --'); +{ + reset(); + const t = S().addTask({ name: 'e', time: createDefaultTaskTime('2026-06-01', 4) }); + const r1 = labor('r1'); + S().assignResource(t, r1, 1); + S().runCPM(); + S().setTaskWorkRule(t, 'FIXED_WORK'); + const cell = (columnId: string, route: CellEditIntent['route'], value: unknown): CellEditIntent => + ({ kind: 'cell-edit', taskId: t, columnId: columnId as CellEditIntent['columnId'], route, value }); + const res = runGridMutation([cell('task.time.scheduleDuration', 'task-schedule', 8 * slot())]); + eq('e1 rasterduur 4→8 slaagt', res.ok, true); + eq('e2 …inzet 0,5, werk blijft', [asgOf(t, r1).unitsPerDay, asgOf(t, r1).remainingWorkMinutes], [0.5, 4 * slot()]); + S().runCPM(); + const set: AssignmentSetIntent = { + kind: 'assignment-set', taskId: t, columnId: 'assignment.unitsPerDay' as AssignmentSetIntent['columnId'], + tokens: [{ resourceId: r1, assignmentId: asgOf(t, r1).id, unitsPerDay: 1 }], + }; + const res2 = runGridMutation([set]); + eq('e3 rasterinzet 0,5→1 slaagt', res2.ok, true); + eq('e4 …duur 4, planning verouderd', [task(t).time.scheduleDuration, S().scheduleStale], [4, true]); + S().undo(); + eq('e5 undo in één stap', [task(t).time.scheduleDuration, asgOf(t, r1).unitsPerDay], [8, 0.5]); + S().redo(); + S().runCPM(); + const r2 = labor('r2'); + const add: AssignmentSetIntent = { + kind: 'assignment-set', taskId: t, columnId: 'assignment.resources' as AssignmentSetIntent['columnId'], + tokens: [{ resourceId: r1, assignmentId: asgOf(t, r1).id, unitsPerDay: 1 }, { resourceId: r2, unitsPerDay: 1 }], + }; + eq('e6 resource erbij via de Resources-cel slaagt', runGridMutation([add]).ok, true); + eq('e7 …duur 2, werk 2 + 2 slots', [task(t).time.scheduleDuration, asgOf(t, r1).remainingWorkMinutes, asgOf(t, r2).remainingWorkMinutes], [2, 2 * slot(), 2 * slot()]); +} + +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- (f) MCP-tweeling --'); +{ + reset(); + const t = S().addTask({ name: 'f', time: createDefaultTaskTime('2026-06-01', 4) }); + const r1 = labor('r1'); + S().assignResource(t, r1, 1); + S().runCPM(); + const r = runInMcpTransaction(() => { + draft.setTaskWorkRule(t, 'FIXED_WORK'); + draft.patchTaskFields(t, {}, { scheduleDuration: 8 }); + }); + eq('f1 MCP: typewissel + duur 4→8', [r.ok, asgOf(t, r1).unitsPerDay, asgOf(t, r1).remainingWorkMinutes], [true, 0.5, 4 * slot()]); + const r2 = runInMcpTransaction(() => { draft.updateAssignment(asgOf(t, r1).id, { unitsPerDay: 1 }); }); + eq('f2 MCP: inzet → 1 ⇒ duur 4', [r2.ok, task(t).time.scheduleDuration], [true, 4]); + const rid2 = labor('r2'); + const r3 = runInMcpTransaction(() => { draft.assignResource(t, rid2, 1); }); + eq('f3 MCP: resource erbij ⇒ duur 2', [r3.ok, task(t).time.scheduleDuration], [true, 2]); + const r4 = runInMcpTransaction(() => { draft.unassignResource(asgOf(t, rid2).id); }); + eq('f4 MCP: resource eraf ⇒ duur 4', [r4.ok, task(t).time.scheduleDuration, asgOf(t, r1).remainingWorkMinutes], [true, 4, 4 * slot()]); + const r5 = runInMcpTransaction(() => { draft.setAssignmentWork(asgOf(t, r1).id, 8 * slot()); }); + eq('f5 MCP: werk → 8 slots ⇒ duur 8', [r5.ok, task(t).time.scheduleDuration], [true, 8]); + const r6 = runInMcpTransaction(() => { draft.setAssignmentWork(asgOf(t, r1).id, 0); }); + eq('f6 MCP: werk 0 is een fout (transactie teruggerold)', [r6.ok, task(t).time.scheduleDuration], [false, 8]); +} + +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- (g) assignmentDayUnits: opgeslagen werk als vierde bron --'); +{ + const t = { id: 't', time: { ...createDefaultTaskTime('2026-06-01', 4) } } as Task; + const base: ResourceAssignment = { id: 'a', taskId: 't', resourceId: 'r', unitsPerDay: 1 }; + eq('g1 zonder werkveld: formule (byte-identiek)', assignmentDayUnits(t, base, 480), [1, 1, 1, 1]); + const r3 = (xs: number[]) => xs.map((v) => Math.round(v * 1e6) / 1e6); + eq('g2 restwerk 2 slots op 4 dagen ⇒ 0,5/dag', r3(assignmentDayUnits(t, { ...base, remainingWorkMinutes: 960 }, 480)), [0.5, 0.5, 0.5, 0.5]); + const front = assignmentDayUnits(t, { ...base, remainingWorkMinutes: 960, curve: 'FRONT_LOADED' }, 480); + ok('g3 vooraan belast: eerste dag > laatste dag, som 2', front[0] > front[3] && near(front.reduce((s, v) => s + v, 0), 2)); + eq('g4 verricht + resterend telt samen', r3(assignmentDayUnits(t, { ...base, remainingWorkMinutes: 480, actualWorkMinutes: 480 }, 480)), [0.5, 0.5, 0.5, 0.5]); +} + +console.log(`\n${checks} checks, ${diffs.length} afwijking(en)`); +if (diffs.length > 0) { + for (const d of diffs) console.log(`XX ${d}`); + process.exit(1); +} +console.log('OK work-rule-store: werkdriehoek bedraad in store, raster en MCP'); diff --git a/tests/planning/run.sh b/tests/planning/run.sh index 4c9ab7709..a4437d120 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -627,6 +627,9 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then WRMCHECK="$DIR/.check-work-rule-mapping.mjs" if bundle_check "$DIR/check-work-rule-mapping.ts" "$WRMCHECK"; then node "$WRMCHECK" || STATUS=1; fi + WRSCHECK="$DIR/.check-work-rule-store.mjs" + if bundle_check "$DIR/check-work-rule-store.ts" "$WRSCHECK"; then node "$WRSCHECK" || STATUS=1; fi + # Geavanceerde-CPM golf-0-checks (fase 2.9 — datamodel + plumbing default-inert, los van de CPM-cases). ACPMCHECK="$DIR/.advanced-cpm-check.mjs" if bundle_check "$DIR/check-advanced-cpm.ts" "$ACPMCHECK"; then node "$ACPMCHECK" || STATUS=1; fi From 81953f71eed0ef260e68d83cc33fb8c684ef062e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 12:47:10 +0000 Subject: [PATCH 144/152] =?UTF-8?q?feat(taaktypes):=20stap=207=20=E2=80=94?= =?UTF-8?q?=20werkregel=20en=20resterend=20werk=20via=20de=20MCP-bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Geen nieuwe tool; drie bestaande uitgebreid. planner_update_tasks en planner_add_tasks kennen `fields.workRule` (vier waarden of null = project- standaard): de allowlist in taskFields.ts levert hem apart van de kale veld-merge, zodat updateTasksCore ná de duurpatch draft.setTaskWorkRule aanroept — een gelijktijdige `duration` wordt onder de OUDE regel verwerkt en de nieuwe regel legt daarna het restwerk vast (spec §5 rij 6). Bij aanmaak is het een kaal veld; beide addTask-tweelingen nemen `workRule` mee. planner_manage_assignments `update` kent `remainingWorkMinutes` (> 0, via draft.setAssignmentWork; alleen waar workRuleApplies — mijlpaal/samenvatting/ hangmat/ELAPSEDTIME zacht geweigerd). planner_update_project kent `defaultWorkRule` (null ⇒ sleutel echt weg); planner_get_project_info toont hem. REJECT_HINTS voor de drie werkvelden als taakveld. tests/mcp/cases-work-rule.ts: 9 cases via de echte dispatch (zetten/wissen/ ongeldig, duur+regel in één call, FIXED_WORK vs standaardregel, resource erbij/eraf, projectstandaard, planner_batch = één undo-stap). Planning- en MCP-suites groen; spec §10, TODO en CLAUDE.md bijgewerkt. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0159unb6VzxUx7WG4w3a2ARA --- CLAUDE.md | 4 +- docs/TODO.md | 4 +- ...26-09-04-spec-taaktypes-opgeslagen-werk.md | 6 +- .../mcp/tools/calendarResourceTools.ts | 83 ++++++-- src/services/mcp/tools/readTools.ts | 1 + src/services/mcp/tools/taskFields.ts | 46 ++++- src/services/mcp/tools/taskTools.ts | 6 + src/state/runtime/createMcpTransactions.ts | 3 + src/state/slices/taskSlice.ts | 3 + tests/mcp/cases-work-rule.ts | 182 ++++++++++++++++++ 10 files changed, 315 insertions(+), 23 deletions(-) create mode 100644 tests/mcp/cases-work-rule.ts diff --git a/CLAUDE.md b/CLAUDE.md index 711521916..cf2ef20d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -138,7 +138,9 @@ precies als een duurbewerking; verandert het restwerk van een toewijzing mét co contourhoogte mee (`reconcileContourWork`, "vorm blijft, hoogte zakt"). Materiaalresources sturen de duur nooit. Regressie: `tests/planning/check-work-triangle.ts` (kern + meetlat `work-triangle-cases.json`), `check-work-rule-mapping.ts` (MSP/P6/XER-vertaling) en -`check-work-rule-store.ts` (store/raster/MCP). `src/services/contourIo.ts` is de adapterlaag: +`check-work-rule-store.ts` (store/raster/MCP). Via de MCP-bridge: `planner_update_tasks`/`planner_add_tasks` +`fields.workRule`, `planner_manage_assignments` `update.remainingWorkMinutes` en `planner_update_project` +`defaultWorkRule` (`tests/mcp/cases-work-rule.ts`). `src/services/contourIo.ts` is de adapterlaag: MSPDI `` (Type 1/2, per werkdag) en P6 `` + `` + de `PlannedCurve`/`RemainingCurve`/`ActualCurve`-spreidingsstrings (`"werkuren:periodeuren;…"`, MPXJ `TimephasedHelper`) round-trippen daar doorheen — let op: P6's `` is dus GEEN diff --git a/docs/TODO.md b/docs/TODO.md index 196477708..f5779dd2d 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -355,8 +355,8 @@ deze lijst verwijderd — wat klaar is, staat in de changelog en git-historie. > Ontwerp: `docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md` (opvolger van de > spec van 2026-08-18). Bouwt op de branch `claude/contour-engine-planner-mnrsy3` (PR #101), die -> gestapeld is op de XER-branch en pas ná die PR merget. Stappen 1–4 (+ de store-kant van 6) staan -> erin; 5 (UI), 7 (MCP-tools) en 8 (docs) nog niet — zie spec §10 voor de stand per stap. +> gestapeld is op de XER-branch en pas ná die PR merget. Stappen 1–4, 7 (+ de store-kant van 6) staan +> erin; 5 (UI) en 8 (docs) nog niet — zie spec §10 voor de stand per stap. > Eigenaarsbesluiten 1–7 (2026-09-04) en 8–10 (2026-09-05) staan daar in §3. - [ ] **Duurbewerking op een taak met expliciete `remainingTime`/`remainingMinutes` (voortgangsroute).** diff --git a/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md b/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md index f4c1e4a14..e0b814e97 100644 --- a/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md +++ b/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md @@ -3,9 +3,9 @@ *Ontwerp, 2026-09-04. Opvolger van het voorstel [`2026-08-18-spec-taaktypes-effort-driven.md`](2026-08-18-spec-taaktypes-effort-driven.md) ("ontwerp vóór bouw"). Status: **in aanbouw op de branch `claude/contour-engine-planner-mnrsy3` -(PR #101, gestapeld op de XER-branch): stappen 1, 2, 3 en 4 zijn gebouwd (2026-09-05); stap 6 is +(PR #101, gestapeld op de XER-branch): stappen 1, 2, 3, 4 en 7 zijn gebouwd (2026-09-05); stap 6 is daarin voor de store/raster/MCP-kant meegenomen (resource erbij/eraf + contourhoogte, besluit 3); -de stappen 5, 7 en 8 nog niet.** +de stappen 5 (UI) en 8 (afronding docs) nog niet.** De contour-engine, de contour-UI en de fasen-editor (2026-09) zijn geleverd en worden hier als bestaand fundament gebruikt.* @@ -594,7 +594,7 @@ twee keer aan `formatRegistry.ts`, `ifcPsets.ts` en de contour-adapters wordt ge | 4 **(gebouwd 2026-09-05)** | Brug `src/engine/work/workRuleApply.ts` (`captureTriangle` → kernstap → `applyTriangleResult`; `settleDurationEdit`/`settleUnitsEdit`/`planWorkEdit`+`commitTrianglePlan`/`settleAssignmentAdded`/`settleAssignmentRemoved`/`settleAssignmentPlan`/`settleRuleChange`; `contourKeepsWork`; `reconcileContourWork` = besluit 3 "vorm blijft, hoogte zakt"). Bedraad in `taskSlice.updateTask` + nieuw `setTaskWorkRule`, `resourceSlice.assignResource`/`updateAssignment`/`unassignResource` + nieuw `setAssignmentWork`, `gridTransaction.ts` (celduur én de assignment-set-cel, via `AssignmentSettleOp`), `createMcpTransactions.ts` (alle tweelingen + `setAssignmentWork`/`setTaskWorkRule` op de draft), `rescaleTaskContours(…, keepWork)` uit de effectieve regel, `assignmentDayUnits` derde bron = opgeslagen werk (§6.1). Een duur die uit de driehoek komt zet `scheduleStale`, herschaalt contour + importsplits en wist het Z8-venster — precies als een duurbewerking; onder FIXED_DURATION_RATE zonder werkvelden byte-identiek | `tests/planning/check-work-rule-store.ts` (65 checks: store, raster, MCP, vierde bron, meetlat 22/23/28), bestaande planning- en MCP-suites groen | | 5 | UI: instelling, auto-ontsluiting + melding, paneel/dialoog, rasterkolommen, i18n 14 locales, gidsen nl+en (+12 vertalingen, anders faalt `verify:docs` niet maar is de functie onvindbaar) | `verify:i18n`, `verify:docs`, browserspec `tests/browser/work-rule.spec.ts` (echte toetsen/klikken, store-asserties) | | 6 | Resource erbij/eraf (§5 rij 4–5) inclusief contourregels (§6.3) — **vereist beslispunt 8** (genomen: 8-B). **Store/raster/MCP-kant gebouwd in stap 4** (`settleAssignmentAdded`/`Removed`, `reconcileContourWork`); wat rest is de UI-kant (stap 5) | cases 4, 5, 9, 10, 15, 19, 20, 23, 29, 30 (kern) + `check-work-rule-store` (b15–b21, d5–d9, e6–e7, f3–f4) | -| 7 | MCP: contract + schema + registratie; `docs/recepten/mcp-tool.md` | `tests/mcp/`, `cases-toolregistry` | +| 7 **(gebouwd 2026-09-05)** | Geen nieuwe tool (39 blijft 39) maar drie bestaande tools uitgebreid: `planner_update_tasks`/`planner_add_tasks` `fields.workRule` (enum \| null, via `draft.setTaskWorkRule` ná de duurpatch — een gelijktijdige `duration` wordt onder de OUDE regel verwerkt, `taskFields.ts`), `planner_manage_assignments` `update.remainingWorkMinutes` (> 0, via `draft.setAssignmentWork`; alleen op taken waar `workRuleApplies`), `planner_update_project` `defaultWorkRule` (enum \| null, `delete` bij null); `planner_get_project_info` toont `defaultWorkRule`, `planner_get_task` toonde `workRule` + werkvelden al sinds stap 1. `REJECT_HINTS` voor `plannedWorkMinutes`/`actualWorkMinutes`/`remainingWorkMinutes` als taakveld | `tests/mcp/cases-work-rule.ts` (9 cases via de echte dispatch, incl. `planner_batch` = één undo-stap), `cases-toolregistry`/`cases-schemavalidatie` groen | | 8 | CLAUDE.md-sectie, TODO-afvinking, `docs/superpowers/README.md` | `verify:docs` | Stap 1–3 zijn onafhankelijk van elkaar te reviewen en veranderen niets aan wat de gebruiker ziet. diff --git a/src/services/mcp/tools/calendarResourceTools.ts b/src/services/mcp/tools/calendarResourceTools.ts index 51ac736f0..d46296c9a 100644 --- a/src/services/mcp/tools/calendarResourceTools.ts +++ b/src/services/mcp/tools/calendarResourceTools.ts @@ -15,6 +15,8 @@ // SCHRIJFKANT SPREEKT DE LEESKANT (harde eis): de veldnamen zijn identiek aan de T18-leestools — // `assignmentId`, `unitsPerDay`, `curve`. Een AI die `get_task` leest kan die id's/velden dus // rechtstreeks in `manage_assignments` terugstoppen. +import { WORK_RULES, type WorkRule } from '@/types/workRule'; +import { workRuleApplies } from '@/engine/work/workRuleApply'; import type { McpContext, McpToolDef, McpToolOk } from '../contracts'; import { guardNonTransactional, @@ -1013,7 +1015,7 @@ const updateCalendar: BatchStepTool = { type AssignmentAction = | { action: 'add'; taskId: string; resourceId: string; unitsPerDay: number; curve?: ResourceCurve } - | { action: 'update'; assignmentId: string; unitsPerDay?: number; curve?: ResourceCurve } + | { action: 'update'; assignmentId: string; unitsPerDay?: number; curve?: ResourceCurve; remainingWorkMinutes?: number } | { action: 'move'; assignmentId: string; taskId: string } | { action: 'remove'; assignmentId: string }; @@ -1070,10 +1072,23 @@ function classifyAssignments( } const hasUnits = act.unitsPerDay !== undefined; const hasCurve = act.curve !== undefined; - if (!hasUnits && !hasCurve) { - rejections.push({ id: act.assignmentId, reason: 'geen `unitsPerDay` of `curve` opgegeven' }); + const hasWork = act.remainingWorkMinutes !== undefined; + if (!hasUnits && !hasCurve && !hasWork) { + rejections.push({ id: act.assignmentId, reason: 'geen `unitsPerDay`, `curve` of `remainingWorkMinutes` opgegeven' }); return; } + // Taaktypes-etappe (bouwstap 7): resterend werk loopt via de werkdriehoek (`draft.setAssignmentWork`). + if (hasWork && !(typeof act.remainingWorkMinutes === 'number' && Number.isFinite(act.remainingWorkMinutes) && act.remainingWorkMinutes > 0)) { + rejections.push({ id: act.assignmentId, reason: `ongeldige remainingWorkMinutes ${String(act.remainingWorkMinutes)} (werkminuten, strikt positief vereist)` }); + return; + } + if (hasWork) { + const owner = s.tasks.find((t) => t.id === cur.taskId); + if (!owner || !workRuleApplies(owner)) { + rejections.push({ id: act.assignmentId, reason: 'resterend werk is alleen zetbaar op een gewone bladtaak op werktijd (niet op een mijlpaal, verzameltaak, hangmat of ELAPSEDTIME-taak)' }); + return; + } + } if (hasUnits && !(typeof act.unitsPerDay === 'number' && Number.isFinite(act.unitsPerDay) && act.unitsPerDay > 0)) { rejections.push({ id: act.assignmentId, reason: `ongeldige unitsPerDay ${String(act.unitsPerDay)} (eenheden/dag, strikt positief vereist)` }); return; @@ -1158,7 +1173,11 @@ function manageAssignmentsCore(ctx: McpContext, actions: AssignmentAction[]): Mu const patch: { unitsPerDay?: number; curve?: ResourceCurve } = {}; if (action.unitsPerDay !== undefined) patch.unitsPerDay = action.unitsPerDay; if (action.curve !== undefined) patch.curve = action.curve; - ctx.transactions.draft.updateAssignment(action.assignmentId, patch); + if (Object.keys(patch).length > 0) ctx.transactions.draft.updateAssignment(action.assignmentId, patch); + // Ná de inzet: een gelijktijdige `unitsPerDay` + `remainingWorkMinutes` betekent "deze inzet, + // dít werk" — de duur volgt dan uit beide (FIXED_WORK/FIXED_RATE) of de inzet wordt door + // het werk overschreven (duurbeschermende regels: I = W / R). + if (action.remainingWorkMinutes !== undefined) ctx.transactions.draft.setAssignmentWork(action.assignmentId, action.remainingWorkMinutes); updated.push(action.assignmentId); break; } @@ -1182,9 +1201,17 @@ const manageAssignments: BatchStepTool = { description: 'Beheer resource-toewijzingen in bulk (één call = één ongedaan-maak-stap). Per item één `action`: ' + '`add` (`taskId`, `resourceId`, `unitsPerDay` = eenheden per WERKDAG waarbij 1 = 100% / één ' + - 'persoon, optioneel `curve`), `update` (`assignmentId` + `unitsPerDay` en/of `curve`), `move` ' + - '(`assignmentId` naar een andere `taskId`) of `remove` (`assignmentId`). De id\'s en veldnamen zijn ' + - 'exact die van de leestools (get_task/list_resources), dus je kunt ze rechtstreeks terugstoppen. ' + + 'persoon, optioneel `curve`), `update` (`assignmentId` + `unitsPerDay`, `curve` en/of ' + + '`remainingWorkMinutes`), `move` (`assignmentId` naar een andere `taskId`) of `remove` ' + + '(`assignmentId`). De id\'s en veldnamen zijn exact die van de leestools (get_task/list_resources), ' + + 'dus je kunt ze rechtstreeks terugstoppen. WERKREGEL (taaktype, `workRule` op de taak — zie ' + + 'planner_update_tasks): werk = restduur × inzet, en de regel bepaalt welke hoek meebeweegt. Onder ' + + 'FIXED_WORK/FIXED_RATE verandert een `unitsPerDay`-wijziging of een resource erbij/eraf dus de ' + + 'TAAKDUUR; `remainingWorkMinutes` (resterend werk in werkminuten, > 0) verlengt/verkort de taak ' + + '(inzet beschermd) of verandert de inzet (duur beschermd). Onder de standaardregel ' + + 'FIXED_DURATION_RATE laat een inzetwijziging de duur ongemoeid en herschrijft `remainingWorkMinutes` ' + + 'alleen de inzet (I = W / R). De respons meldt het projecteinde; lees de taak opnieuw voor de ' + + 'nieuwe duur. ' + 'Toewijzen kan alleen op een BLADTAAK (geen mijlpaal, geen verzameltaak) en dezelfde resource mag ' + 'maar één keer op dezelfde taak staan — een tweede toewijzing zou de last dubbel tellen en wordt ' + 'zacht geweigerd, óók als het duplicaat binnen deze ene call zit. Geweigerde items komen terug in ' + @@ -1213,6 +1240,14 @@ const manageAssignments: BatchStepTool = { enum: ['UNIFORM', 'FRONT_LOADED', 'BACK_LOADED', 'BELL', 'EARLY_PEAK', 'LATE_PEAK', 'DOUBLE_PEAK', 'TURTLE'], description: 'Verdeelcurve over de duur (de acht MS Project-/P6-vormen); weglaten = UNIFORM.', }, + remainingWorkMinutes: { + type: 'number', + exclusiveMinimum: 0, + description: + 'Alleen bij `update`: RESTEREND werk van deze toewijzing in WERKminuten (8 uur = 480). De ' + + 'werkregel van de taak bepaalt wat meebeweegt: de duur (FIXED_WORK/FIXED_RATE) of de inzet ' + + '(FIXED_DURATION_*). Alleen op een gewone bladtaak op werktijd; materiaal telt niet mee voor de duur.', + }, }, }, }, @@ -1512,6 +1547,7 @@ const ISO_DATE = /^\d{4}-\d{2}-\d{2}/; /** Elke sleutel die `update_project` KENT — de allowlist waartegen onbekende sleutels afketsen. */ const PROJECT_KEYS = [ 'name', 'description', 'author', 'company', 'startDate', 'endDate', 'statusDate', 'progressMode', + 'defaultWorkRule', ] as const; /** Projectvelden die de bridge BEWUST niet schrijft, mét een reden. Een expliciete weigering is @@ -1547,7 +1583,7 @@ const PROJECT_REFUSED: Record = { */ function parseUpdateProject( args: unknown, -): { updates: Partial; clearStatusDate: boolean; clearProgressMode: boolean; touched: string[] } | string { +): { updates: Partial; clearStatusDate: boolean; clearProgressMode: boolean; clearDefaultWorkRule: boolean; touched: string[] } | string { const a = (args ?? {}) as Record; for (const key of Object.keys(a)) { if ((PROJECT_KEYS as readonly string[]).includes(key)) continue; @@ -1591,6 +1627,18 @@ function parseUpdateProject( updates.progressMode = a.progressMode; } } + // Taaktypes-etappe (bouwstap 7): de projectstandaard-werkregel; wissen = terug naar + // FIXED_DURATION_RATE (het gedrag van vandaag). Raakt geen enkel getal op bestaande taken. + let clearDefaultWorkRule = false; + if (a.defaultWorkRule !== undefined) { + if (a.defaultWorkRule === null || a.defaultWorkRule === '') { + clearDefaultWorkRule = true; + } else if (typeof a.defaultWorkRule !== 'string' || !(WORK_RULES as readonly string[]).includes(a.defaultWorkRule)) { + return `\`defaultWorkRule\` moet één van ${WORK_RULES.join(' | ')} zijn (of null voor de standaard FIXED_DURATION_RATE)`; + } else { + updates.defaultWorkRule = a.defaultWorkRule as WorkRule; + } + } // Wissen loopt NIET via de veld-merge: `Object.assign({ statusDate: undefined })` laat de sleutel // met waarde `undefined` achter, terwijl de store-actie `setStatusDate` hem echt `delete`t. Die // vorm houden we aan (IFC-serialisatie en de statusdatum-guards lezen op sleutel-aanwezigheid). @@ -1608,11 +1656,12 @@ function parseUpdateProject( ...Object.keys(updates), ...(clearStatusDate ? ['statusDate'] : []), ...(clearProgressMode ? ['progressMode'] : []), + ...(clearDefaultWorkRule ? ['defaultWorkRule'] : []), ]; if (touched.length === 0) { return `update_project vereist minstens één veld (${PROJECT_KEYS.join('/')})`; } - return { updates, clearStatusDate, clearProgressMode, touched }; + return { updates, clearStatusDate, clearProgressMode, clearDefaultWorkRule, touched }; } /** De mechanisme-uitleg die MEE MOET zodra er een statusdatum wordt GEZET. Staat bewust in de @@ -1628,18 +1677,19 @@ const STATUS_DATE_NOTE = /** Synchrone, transactie-vrije kern van `update_project`. */ function updateProjectCore( ctx: McpContext, - p: { updates: Partial; clearStatusDate: boolean; clearProgressMode: boolean; touched: string[] }, + p: { updates: Partial; clearStatusDate: boolean; clearProgressMode: boolean; clearDefaultWorkRule: boolean; touched: string[] }, ): MutationOutcome { // T7-review H1: `draft.setProject` levert nu het aantal wortel-ankers dat het klemde (zelfde // bewerkbescherming als de UI, `projectSlice.setProject`) — meegeven in `data` zodat óók het // `planner_batch`-pad (dat rechtstreeks `updateProjectCore` gebruikt, zonder `enrichOk`) dit ziet. const anchorsClamped = ctx.transactions.draft.setProject(p.updates); - if (p.clearStatusDate || p.clearProgressMode) { + if (p.clearStatusDate || p.clearProgressMode || p.clearDefaultWorkRule) { ctx.app.store.setState((s) => { // `delete` i.p.v. `= undefined`: de IFC-serialisatie en de solver-defaults lezen op // sleutel-AANWEZIGHEID (zelfde conventie als de store-actie `setStatusDate`). if (p.clearStatusDate) delete s.project.statusDate; if (p.clearProgressMode) delete s.project.progressMode; + if (p.clearDefaultWorkRule) delete s.project.defaultWorkRule; s.project.modifiedAt = new Date().toISOString(); s.isDirty = true; }); @@ -1659,7 +1709,8 @@ const updateProject: BatchStepTool = { 'Wijzig projectgegevens: `name`, `description`, `author`, `company`, `statusDate` (de peildatum ' + 'waarop voortgang wordt geregistreerd — zónder deze datum weigert het voortgangspad van ' + 'update_tasks), `endDate` (de contractuele/gewenste einddatum — puur metadata, hij dwingt NIETS ' + - 'af in de planning; lege string wist hem), `progressMode` (RETAINED_LOGIC of PROGRESS_OVERRIDE — ' + + 'af in de planning; lege string wist hem), `defaultWorkRule` (projectstandaard-taaktype voor taken ' + + 'zonder eigen `workRule`; null = FIXED_DURATION_RATE), `progressMode` (RETAINED_LOGIC of PROGRESS_OVERRIDE — ' + 'hoe de solver werk buiten de volgorde afhandelt; null = terug naar de default RETAINED_LOGIC) en ' + '`startDate`. Een ONBEKEND veld wordt geweigerd met de toegestane lijst erbij — er wordt nooit ' + 'stil iets weggegooid. BELANGRIJK over `startDate`: het is het anker voor NIEUW aan te maken ' + @@ -1706,6 +1757,13 @@ const updateProject: BatchStepTool = { enum: ['RETAINED_LOGIC', 'PROGRESS_OVERRIDE', null], description: 'Voortgangs-scheduling-modus; null = terug naar de default RETAINED_LOGIC.', }, + defaultWorkRule: { + type: ['string', 'null'], + enum: [...WORK_RULES, null], + description: + 'Projectstandaard-werkregel (taaktype) voor taken zonder eigen `workRule` — zie planner_update_tasks ' + + '`fields.workRule` voor de vier waarden. null = de standaard FIXED_DURATION_RATE. Wisselen verandert geen getal.', + }, }, additionalProperties: false, }, @@ -1747,6 +1805,7 @@ const updateProject: BatchStepTool = { project: { name: p.name, startDate: p.startDate, endDate: p.endDate, statusDate: p.statusDate ?? null, progressMode: p.progressMode ?? null, + defaultWorkRule: p.defaultWorkRule ?? null, }, // Herinnering in de payload zelf: de AI leest data vaak eerder dan de beschrijving. // T7-review H1: dit beloofde tot nu toe onvoorwaardelijk dat GEEN enkele bestaande taak diff --git a/src/services/mcp/tools/readTools.ts b/src/services/mcp/tools/readTools.ts index b48e7e172..7064b2858 100644 --- a/src/services/mcp/tools/readTools.ts +++ b/src/services/mcp/tools/readTools.ts @@ -279,6 +279,7 @@ function getProjectInfo(s: AppState) { company: p.company, ...(p.statusDate ? { statusDate: p.statusDate } : {}), ...(p.progressMode ? { progressMode: p.progressMode } : {}), + ...(p.defaultWorkRule ? { defaultWorkRule: p.defaultWorkRule } : {}), }, statistics: { totalTasks: tasks.length, diff --git a/src/services/mcp/tools/taskFields.ts b/src/services/mcp/tools/taskFields.ts index 0450ae970..2cec42c00 100644 --- a/src/services/mcp/tools/taskFields.ts +++ b/src/services/mcp/tools/taskFields.ts @@ -36,6 +36,7 @@ import type { } from '@/types/task'; import { TASK_TYPES } from '@/types/task'; import type { CustomTaskType } from '@/types/taskType'; +import { WORK_RULES, type WorkRule } from '@/types/workRule'; // --- Patch-vorm ---------------------------------------------------------------------------------- @@ -55,6 +56,10 @@ export interface TaskFieldPatch { top: Partial; time?: TaskTimePatch; customTaskType?: CustomTaskType; + /** Taaktypes-etappe (bouwstap 7): de werkregel loopt NIET via de kale veld-merge maar via + * `draft.setTaskWorkRule` — een werkbeschermende regel legt bij het zetten het huidige restwerk + * van de toewijzingen vast (spec §5 rij 6). `null` = terug naar de projectstandaard. */ + workRule?: WorkRule | null; } /** Wat de validator over de DOELTAAK moet weten (bij aanmaak: een verse, lege taak). */ @@ -117,6 +122,7 @@ export const TASK_FIELD_NAMES = [ 'constraint', 'deadline', 'calendarId', + 'workRule', ] as const; /** Gerichte hints voor sleutels die een agent redelijkerwijs probeert maar die hier niet horen. */ @@ -152,9 +158,10 @@ 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)', - // Taaktypes-etappe (ontwerp 2026-09-04): leesbaar via planner_get_task; zetbaar volgt in de - // MCP-stap (§10 stap 7) zodra de werkdriehoek in de store bedraad is. - workRule: 'de werkregel is via de bridge nog niet zetbaar (taaktypes-etappe, bouwstap 7) — leesbaar via planner_get_task', + // Taaktypes-etappe: de werkvelden per TOEWIJZING zijn geen taakvelden. + remainingWorkMinutes: 'resterend werk hoort bij een TOEWIJZING: planner_manage_assignments `update` met `remainingWorkMinutes`', + plannedWorkMinutes: 'begroot werk is via de bridge niet zetbaar (referentiewaarde uit een import — zie planner_get_task)', + actualWorkMinutes: 'verricht werk is via de bridge niet zetbaar (een feit uit een import; voortgang loopt via `progress`)', // 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)', @@ -368,6 +375,12 @@ export function parseTaskFields(raw: unknown, ctx: TaskFieldContext): TaskFieldR else if (isIsoDate(raw.deadline)) top.deadline = raw.deadline; else return { ok: false, reason: '`deadline` moet een ISO-datum zijn (YYYY-MM-DD) of null om te wissen' }; } + let workRule: WorkRule | null | undefined; + if ('workRule' in raw) { + if (raw.workRule === null) workRule = null; + else if (typeof raw.workRule === 'string' && (WORK_RULES as readonly string[]).includes(raw.workRule)) workRule = raw.workRule as WorkRule; + else return { ok: false, reason: `\`workRule\` moet één van ${WORK_RULES.join(' | ')} zijn (of null voor de projectstandaard)` }; + } if ('calendarId' in raw) { if (raw.calendarId === null) top.calendarId = undefined; else if (typeof raw.calendarId !== 'string') return { ok: false, reason: '`calendarId` moet een string zijn (of null voor de projectkalender)' }; @@ -377,7 +390,15 @@ export function parseTaskFields(raw: unknown, ctx: TaskFieldContext): TaskFieldR } const hasTime = Object.keys(time).length > 0; - return { ok: true, patch: { top, ...(hasTime ? { time } : {}), ...(customTaskType ? { customTaskType } : {}) } }; + return { + ok: true, + patch: { + top, + ...(hasTime ? { time } : {}), + ...(customTaskType ? { customTaskType } : {}), + ...(workRule !== undefined ? { workRule } : {}), + }, + }; } // --- Voortgangs-allowlist (`update_tasks.progress`) ---------------------------------------------- @@ -521,6 +542,20 @@ export const TASK_FIELD_SCHEMA_PROPERTIES: Record = { }, deadline: { type: ['string', 'null'], description: 'Zachte deadline (ISO-datum); begrenst alleen de late finish. null wist hem.' }, calendarId: { type: ['string', 'null'], description: 'Taak-kalender uit de bibliotheek (planner_get_calendars); null = projectkalender.' }, + workRule: { + type: ['string', 'null'], + enum: [...WORK_RULES, null], + description: + 'Werkregel (taaktype) van de taak: welke hoek van werk = restduur × inzet vast staat wanneer een ' + + 'andere verandert. FIXED_DURATION_RATE (standaard, MSP "Fixed Duration", niet effort-driven): ' + + 'duur en inzet blijven, het werk volgt. FIXED_DURATION_WORK (P6 "Fixed Duration & Units"): duur ' + + 'en werk blijven, de inzet volgt. FIXED_WORK (MSP "Fixed Work", P6 "Fixed Units"): het werk blijft; ' + + 'meer inzet of een extra resource maakt de taak korter. FIXED_RATE (MSP "Fixed Units" effort-driven, ' + + 'P6 "Fixed Units/Time"): de inzet blijft; meer werk maakt de taak langer. Alleen de regel wisselen ' + + 'verandert geen enkel getal. null = terug naar de projectstandaard (planner_update_project ' + + '`defaultWorkRule`). Werkt alleen op gewone bladtaken (niet op mijlpalen, verzameltaken, ' + + 'hangmatten of ELAPSEDTIME-taken).', + }, }; /** Eén regel voor in tool-beschrijvingen: welke velden er zijn en dat de rest hard weigert. */ @@ -530,4 +565,5 @@ export const TASK_FIELDS_DOC = '`durationUnit` (`days` of `hours`; zonder eenheid blijft de achterwaarts compatibele dagregel). ' + 'Geef beide samen om de eenheid bewust te wijzigen; de `time`-tak zelf, `status`, `parentId` en ' + '`resourceIds` zijn hier bewust niet zetbaar (gebruik `progress`, planner_move_task resp. ' + - 'planner_manage_assignments).'; + 'planner_manage_assignments). `workRule` zet het taaktype (werk = restduur × inzet; zie de ' + + 'veldbeschrijving) — resterend werk per toewijzing zet je met planner_manage_assignments.'; diff --git a/src/services/mcp/tools/taskTools.ts b/src/services/mcp/tools/taskTools.ts index 15c2d5dfd..492ddde22 100644 --- a/src/services/mcp/tools/taskTools.ts +++ b/src/services/mcp/tools/taskTools.ts @@ -190,6 +190,8 @@ function addTasksCore(ctx: McpContext, items: ParsedAddItem[]): MutationOutcome } return { ...top, + // Een nieuwe taak heeft nog geen toewijzingen, dus de werkregel is hier een kaal veld. + ...(it.patch.workRule ? { workRule: it.patch.workRule } : {}), name: top.name as string, tempId: it.tempId, ...(it.parentId !== undefined ? { parentId: it.parentId } : {}), @@ -354,6 +356,10 @@ function updateTasksCore(ctx: McpContext, updates: { id: string; fields?: any; p else { if (res.patch.customTaskType) ctx.transactions.draft.ensureCustomTaskType(res.patch.customTaskType); ctx.transactions.draft.patchTaskFields(id, res.patch.top, res.patch.time); + // Taaktypes-etappe (bouwstap 7): de werkregel via de driehoek-bewuste draft-actie, ná de + // duurpatch (zodat een gelijktijdige `duration` onder de OUDE regel wordt verwerkt en de + // nieuwe regel het restwerk van dát moment vastlegt — spec §5 rij 6, besluit 2). + if (res.patch.workRule !== undefined) ctx.transactions.draft.setTaskWorkRule(id, res.patch.workRule ?? undefined); touched = true; } } diff --git a/src/state/runtime/createMcpTransactions.ts b/src/state/runtime/createMcpTransactions.ts index 1feea2b03..b600d496f 100644 --- a/src/state/runtime/createMcpTransactions.ts +++ b/src/state/runtime/createMcpTransactions.ts @@ -183,6 +183,9 @@ function createMcpDraft( manuallyScheduled: partial.manuallyScheduled, levelingDelayMinutes: partial.levelingDelayMinutes, levelingDelayElapsed: partial.levelingDelayElapsed, + // Taaktypes-etappe (bouwstap 7): de werkregel bij aanmaak (planner_add_tasks `workRule`); + // een nieuwe taak heeft nog geen toewijzingen, dus dit is een kaal veld zonder driehoekstap. + workRule: partial.workRule, }; s.tasks.push(task); diff --git a/src/state/slices/taskSlice.ts b/src/state/slices/taskSlice.ts index 3cf550698..ec5bdcc17 100644 --- a/src/state/slices/taskSlice.ts +++ b/src/state/slices/taskSlice.ts @@ -342,6 +342,9 @@ export const createTaskSlice: AppSliceFactory = (runtime) => (set, ge manuallyScheduled: partial.manuallyScheduled, levelingDelayMinutes: partial.levelingDelayMinutes, levelingDelayElapsed: partial.levelingDelayElapsed, + // Taaktypes-etappe (bouwstap 7): de werkregel bij aanmaak (planner_add_tasks `workRule`); + // een nieuwe taak heeft nog geen toewijzingen, dus dit is een kaal veld zonder driehoekstap. + workRule: partial.workRule, }; // Zonder `position` (of een onbekende anker): exact het bestaande gedrag — achteraan. diff --git a/tests/mcp/cases-work-rule.ts b/tests/mcp/cases-work-rule.ts new file mode 100644 index 000000000..4ed32c7f3 --- /dev/null +++ b/tests/mcp/cases-work-rule.ts @@ -0,0 +1,182 @@ +// Taaktypes-etappe, bouwstap 7 — de werkregel en het resterende werk via de bridge +// (spec docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md §5/§10). +// +// Drie ingangen, allemaal door de ECHTE dispatch (schemapoort + guards): +// 1. planner_update_tasks `fields.workRule` (zetten, wissen, ongeldig, op een mijlpaal); +// 2. planner_manage_assignments `update.remainingWorkMinutes` (duur volgt onder FIXED_WORK, +// inzet volgt onder FIXED_DURATION_RATE; ongeldig/afwezig zacht geweigerd); +// 3. planner_update_project `defaultWorkRule` (zetten/wissen; leesbaar via get_project_info). +// Plus: één undo-stap per call, en planner_batch met typewissel + inzetwijziging in één draaiboek. +import { appStoreContext, makeMcpContext, useAppStore, test, assert, assertEq, run, type McpContextOverrides } from './harness'; +import { taskTools } from '@/services/mcp/tools/taskTools'; +import { calendarResourceTools } from '@/services/mcp/tools/calendarResourceTools'; +import { batchTools } from '@/services/mcp/tools/batchTool'; +import { readTools } from '@/services/mcp/tools/readTools'; +import { registerToolModules } from '@/services/mcp/toolRegistry'; +import { handleMcpMessage } from '@/services/mcp/dispatcher'; +import type { McpContext, McpToolResult, McpToolOk, McpToolErr } from '@/services/mcp/contracts'; +import { createDefaultTaskTime } from '@/utils/taskDefaults'; + +const store = useAppStore; +store.getState().addTask({ name: 'warmup' }); +store.getState().undo(); +registerToolModules([taskTools, calendarResourceTools, batchTools, readTools]); + +function makeCtx(over: McpContextOverrides = {}): McpContext { + return makeMcpContext(appStoreContext, { expectedDocId: store.getState().activeDocumentId, ...over }); +} +let nextId = 1; +/** Via de ECHTE dispatch-weg (schemavalidatie inbegrepen). */ +async function call(name: string, args: unknown, ctx: McpContext = makeCtx()): Promise { + const id = nextId++; + const raw = await handleMcpMessage(JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } }), ctx); + const msg = JSON.parse(raw ?? '{}') as { result?: { isError?: boolean; structuredContent?: any }; error?: { message: string } }; + if (msg.error) return { ok: false, error: msg.error.message } as McpToolResult; + const sc = msg.result?.structuredContent ?? {}; + if (msg.result?.isError || sc.ok === false) return { ok: false, error: String(sc.error ?? ''), code: sc.code } as McpToolResult; + return { ok: true, data: sc.data, itemRejections: sc.itemRejections } as McpToolResult; +} +function okData(res: McpToolResult): any { + assert(res.ok, `verwachtte ok, kreeg fout: ${res.ok ? '' : (res as McpToolErr).error}`); + return (res as McpToolOk).data as any; +} +function rejections(res: McpToolResult): { id: string; reason: string }[] { + assert(res.ok, `verwachtte ok (met zachte weigering), kreeg fout: ${res.ok ? '' : (res as McpToolErr).error}`); + return ((res as McpToolOk).itemRejections ?? []) as { id: string; reason: string }[]; +} +const task = (id: string) => store.getState().tasks.find((t) => t.id === id)!; +const asg = (taskId: string, resourceId: string) => store.getState().assignments.find((a) => a.taskId === taskId && a.resourceId === resourceId)!; +const slot = () => store.getState().calendar.hoursPerDay * 60; +function reset(): void { store.getState().newProject(); } +function seed(days = 4): { t: string; r: string } { + reset(); + const t = store.getState().addTask({ name: 'werk', time: createDefaultTaskTime('2026-06-01', days) }); + const r = store.getState().addResource({ name: 'r1', type: 'LABOR', description: '', maxUnits: 1 }); + store.getState().assignResource(t, r, 1); + store.getState().runCPM(); + return { t, r }; +} + +// 1) update_tasks.fields.workRule +test('update_tasks: workRule zetten legt het restwerk vast, wissen zet terug naar de projectstandaard', async () => { + const { t, r } = seed(); + const events0 = store.getState().historyEvents.length; + const res = await call('planner_update_tasks', { updates: [{ id: t, fields: { workRule: 'FIXED_WORK' } }] }); + assertEq(okData(res).updated, [t], 'taak bijgewerkt'); + assertEq(task(t).workRule, 'FIXED_WORK', 'workRule gezet'); + assertEq(asg(t, r).remainingWorkMinutes, 4 * slot(), 'restwerk vastgelegd (4 dagen × slot)'); + assertEq(task(t).time.scheduleDuration, 4, 'duur ongewijzigd (typewissel is getalvrij)'); + assertEq(store.getState().historyEvents.length - events0, 1, 'één undo-stap'); + const got = okData(await call('planner_get_task', { taskId: t })); + assertEq(got.workRule, 'FIXED_WORK', 'get_task toont de regel'); + okData(await call('planner_update_tasks', { updates: [{ id: t, fields: { workRule: null } }] })); + assertEq(task(t).workRule, undefined, 'null wist het veld'); + assertEq(asg(t, r).remainingWorkMinutes, 4 * slot(), 'het vastgelegde werk blijft staan (besluit 2)'); +}); + +test('update_tasks: ongeldige workRule wordt per item geweigerd met de vier waarden in de reden', async () => { + const { t } = seed(); + const res = await call('planner_update_tasks', { updates: [{ id: t, fields: { workRule: 'fixed_work' } }] }); + const rej = rejections(res); + assert(rej.length === 1 && /FIXED_DURATION_RATE \| FIXED_DURATION_WORK \| FIXED_WORK \| FIXED_RATE/.test(rej[0].reason), `reden noemt de waarden: ${rej[0]?.reason}`); + assertEq(task(t).workRule, undefined, 'niets gezet'); +}); + +test('update_tasks: workRule + duration in één call — de duur wordt onder de OUDE regel verwerkt, de nieuwe regel legt daarna vast', async () => { + const { t, r } = seed(); + okData(await call('planner_update_tasks', { updates: [{ id: t, fields: { duration: 8, workRule: 'FIXED_WORK' } }] })); + assertEq(task(t).time.scheduleDuration, 8, 'duur 8'); + assertEq(asg(t, r).unitsPerDay, 1, 'inzet blijft 1 (duur onder FIXED_DURATION_RATE verwerkt)'); + assertEq(asg(t, r).remainingWorkMinutes, 8 * slot(), 'daarna vastgelegd: 8 dagen × slot'); +}); + +test('add_tasks: workRule op een nieuwe taak is een kaal veld', async () => { + reset(); + const res = await call('planner_add_tasks', { tasks: [{ tempId: 'tmp-a', name: 'A', duration: 3, workRule: 'FIXED_RATE' }] }); + const id = okData(res).created['tmp-a'] as string; + assertEq(task(id).workRule, 'FIXED_RATE', 'workRule bij aanmaak gezet'); +}); + +// 2) manage_assignments.update.remainingWorkMinutes +test('manage_assignments: remainingWorkMinutes onder FIXED_WORK verandert de duur; onder de standaardregel de inzet', async () => { + const { t, r } = seed(); + store.getState().setTaskWorkRule(t, 'FIXED_WORK'); + const a = asg(t, r).id; + const events0 = store.getState().historyEvents.length; + const res = await call('planner_manage_assignments', { actions: [{ action: 'update', assignmentId: a, remainingWorkMinutes: 8 * slot() }] }); + assertEq(okData(res).updated, [a], 'toewijzing bijgewerkt'); + assertEq(task(t).time.scheduleDuration, 8, 'FIXED_WORK: werk 8 dagen bij inzet 1 ⇒ duur 8'); + assertEq(asg(t, r).unitsPerDay, 1, 'inzet blijft'); + assertEq(store.getState().historyEvents.length - events0, 1, 'één undo-stap'); + assertEq(store.getState().scheduleStale, false, 'de transactie heeft herrekend'); + // Standaardregel: duur beschermd ⇒ inzet = W / R. + store.getState().setTaskWorkRule(t, 'FIXED_DURATION_RATE'); + okData(await call('planner_manage_assignments', { actions: [{ action: 'update', assignmentId: a, remainingWorkMinutes: 4 * slot() }] })); + assertEq(task(t).time.scheduleDuration, 8, 'duur blijft 8'); + assertEq(asg(t, r).unitsPerDay, 0.5, 'inzet 0,5 = 4 slots / 8 dagen'); +}); + +test('manage_assignments: unitsPerDay onder FIXED_WORK verkort de taak; remainingWorkMinutes ≤ 0 en mijlpaal zacht geweigerd', async () => { + const { t, r } = seed(); + store.getState().setTaskWorkRule(t, 'FIXED_WORK'); + const a = asg(t, r).id; + okData(await call('planner_manage_assignments', { actions: [{ action: 'update', assignmentId: a, unitsPerDay: 2 }] })); + assertEq(task(t).time.scheduleDuration, 2, 'inzet 1→2: duur 4→2'); + const bad = await call('planner_manage_assignments', { actions: [{ action: 'update', assignmentId: a, remainingWorkMinutes: 0 }] }); + // Diepte-regel van de schemapoort (docs/recepten/mcp-tool.md): de BINNENKANT van een bulk-item is + // aan de handler — dus een zachte weigering met reden, geen harde VALIDATION-fout. + const rej0 = rejections(bad); + assert(rej0.length === 1 && rej0[0].reason.includes('strikt positief'), `0 zacht geweigerd met reden: ${rej0[0]?.reason}`); + const bad2 = await call('planner_manage_assignments', { actions: [{ action: 'update', assignmentId: a }] }); + const rej = rejections(bad2); + assert(rej.length === 1 && rej[0].reason.includes('remainingWorkMinutes'), `lege update noemt het nieuwe veld: ${rej[0]?.reason}`); + assertEq(task(t).time.scheduleDuration, 2, 'niets veranderd'); +}); + +test('manage_assignments: resource erbij onder FIXED_WORK verdeelt het werk en verkort de taak (8-B, zuiver P6)', async () => { + const { t, r } = seed(); + store.getState().setTaskWorkRule(t, 'FIXED_WORK'); + const r2 = store.getState().addResource({ name: 'r2', type: 'LABOR', description: '', maxUnits: 1 }); + okData(await call('planner_manage_assignments', { actions: [{ action: 'add', taskId: t, resourceId: r2, unitsPerDay: 1 }] })); + assertEq(task(t).time.scheduleDuration, 2, 'werk 4 slots over 2 × 1 ⇒ 2 dagen'); + assertEq([asg(t, r).remainingWorkMinutes, asg(t, r2).remainingWorkMinutes], [2 * slot(), 2 * slot()], 'werk 2 + 2'); + okData(await call('planner_manage_assignments', { actions: [{ action: 'remove', assignmentId: asg(t, r2).id }] })); + assertEq(task(t).time.scheduleDuration, 4, 'eraf: terug naar 4'); +}); + +// 3) update_project.defaultWorkRule +test('update_project: defaultWorkRule zetten/wissen, zichtbaar in get_project_info', async () => { + const { t, r } = seed(); + const res = await call('planner_update_project', { defaultWorkRule: 'FIXED_DURATION_WORK' }); + const data = okData(res); + assertEq(data.updated, ['defaultWorkRule'], 'gerapporteerd'); + assertEq(data.project.defaultWorkRule, 'FIXED_DURATION_WORK', 'respons toont de regel'); + assertEq(store.getState().project.defaultWorkRule, 'FIXED_DURATION_WORK', 'gezet'); + assertEq(okData(await call('planner_get_project_info', {})).project.defaultWorkRule, 'FIXED_DURATION_WORK', 'get_project_info toont hem'); + // De projectstandaard werkt op taken zonder eigen regel: inzet → 0,5 laat de duur staan en legt werk vast. + okData(await call('planner_manage_assignments', { actions: [{ action: 'update', assignmentId: asg(t, r).id, unitsPerDay: 0.5 }] })); + assertEq([task(t).time.scheduleDuration, asg(t, r).remainingWorkMinutes], [4, 2 * slot()], 'FIXED_DURATION_WORK via de projectstandaard'); + okData(await call('planner_update_project', { defaultWorkRule: null })); + assertEq('defaultWorkRule' in store.getState().project, false, 'null verwijdert de sleutel echt'); + const bad = await call('planner_update_project', { defaultWorkRule: 'Fixed Work' }); + assert(!bad.ok, 'ongeldige waarde is een harde fout'); +}); + +// 4) planner_batch: typewissel + inzet in één draaiboek = één undo-stap +test('batch: workRule + unitsPerDay in één draaiboek ⇒ één undo-stap, duur volgt de nieuwe regel', async () => { + const { t, r } = seed(); + const events0 = store.getState().historyEvents.length; + const res = await call('planner_batch', { + steps: [ + { tool: 'planner_update_tasks', args: { updates: [{ id: t, fields: { workRule: 'FIXED_WORK' } }] } }, + { tool: 'planner_manage_assignments', args: { actions: [{ action: 'update', assignmentId: asg(t, r).id, unitsPerDay: 0.5 }] } }, + ], + }); + assert(res.ok, `batch ok: ${res.ok ? '' : (res as McpToolErr).error}`); + assertEq(task(t).time.scheduleDuration, 8, 'inzet 1→0,5 onder FIXED_WORK ⇒ duur 8'); + assertEq(store.getState().historyEvents.length - events0, 1, 'één undo-stap'); + store.getState().undo(); + assertEq([task(t).workRule, task(t).time.scheduleDuration, asg(t, r).unitsPerDay], [undefined, 4, 1], 'undo zet alles in één keer terug'); +}); + +await run(); From 7d4f2bfc9596ad847d2abcc27217a37891cac9bb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 13:02:38 +0000 Subject: [PATCH 145/152] =?UTF-8?q?fix(taaktypes):=20reviewronde=20bouwsta?= =?UTF-8?q?p=204=20=E2=80=94=20voortgang=20is=20geen=20duurbewerking,=20ge?= =?UTF-8?q?en=20drift,=20verplaatsen/verwijderen=20door=20de=20driehoek?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blokkerend (B1): `settleDurationEdit` vergeleek de RESTduur, waardoor een voortgangsbewerking (completion/remainingTime via raster of een gespreide time-tak) de inzet herrekende. De poort is nu de TOTALE werkduur uit de momentopname (`CapturedTriangle.totalMinutes`). Blokkerend (B2): een duur uit de driehoek op een gestarte taak liet de rest opnieuw afleiden als nieuwe duur × (1 − completion) ⇒ drift bij heen-en-weer (case 31). De rest wordt nu expliciet geschreven (`remainingTime`/ `remainingMinutes`) zodra de taak gestart is; `completion` blijft. Belangrijk (B3): de opgeslagen-werk-bron in `assignmentDayUnits` telt nu het verrichte deel mee (`actualWorkMinutes`, anders verrichte duur × inzet) — een typewissel op een half gedane taak halveerde anders het histogram. Belangrijk (B4): `moveAssignment` (eraf + erbij) en `removeResource` (eraf per taak) lopen door de driehoek, in store én MCP-tweeling. Klein: K1 `workRule` via `updateTask`/`updateTaskFields`/`patchTaskFields` legt via `settleRuleChange` óók het werk vast; K3 alleen een herleide inzet wordt afgerond; K4 één uurmodus-test (`isHourTask`) voor lezen en schrijven; K5 `settleDurationAftermath` als ene nazorg (contour, importsplits, Z8-venster, bevroren walks) voor store, raster en MCP; K6 volgorde in een gemengde Resources-cel gedocumenteerd en getest. K2 (kalenderwissel na vastgelegd werk) staat als open beslispunt in de TODO. Tests: check-work-rule-store 65 → 95 checks (voortgang > 0, uurmodus, FIXED_RATE + 8-B, materiaal in het raster, datums-zoals-opgeslagen, verplaatsen/resource verwijderen, K1). Planning- en MCP-suites groen. Spec §6.1/§6.5/§8/§10 en CLAUDE.md bijgewerkt. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0159unb6VzxUx7WG4w3a2ARA --- CLAUDE.md | 13 +- docs/TODO.md | 12 +- ...26-09-04-spec-taaktypes-opgeslagen-werk.md | 27 ++- src/engine/scheduler/ResourceLoad.ts | 22 ++- src/engine/work/workRuleApply.ts | 58 +++++- src/state/gridTransaction.ts | 11 +- src/state/runtime/createMcpTransactions.ts | 52 ++++-- src/state/slices/resourceSlice.ts | 73 +++++--- src/state/slices/taskSlice.ts | 7 +- tests/planning/check-work-rule-store.ts | 169 ++++++++++++++++++ 10 files changed, 378 insertions(+), 66 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cf2ef20d7..654f27681 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,12 +131,15 @@ FIXED_DURATION_WORK | FIXED_WORK | FIXED_RATE, anders `Project.defaultWorkRule`. opgeslagen, R afgeleid en naar boven afgerond op hele dagen/minuten); de brug `src/engine/work/workRuleApply.ts` (`captureTriangle` vóór de mutatie → `settle…` erna) is op vier plekken bedraad: `taskSlice.updateTask`/`setTaskWorkRule`, `resourceSlice.assignResource`/ -`updateAssignment`/`unassignResource`/`setAssignmentWork`, `gridTransaction.ts` en de MCP-tweeling in -`createMcpTransactions.ts`. Een duur die uit de driehoek komt (inzet/werk/resource erbij-eraf onder -FIXED_WORK/FIXED_RATE) zet `scheduleStale`, herschaalt contour + importsplits en wist het Z8-venster, -precies als een duurbewerking; verandert het restwerk van een toewijzing mét contour, dan zakt de +`updateAssignment`/`unassignResource`/`moveAssignment`/`removeResource`/`setAssignmentWork`, +`gridTransaction.ts` en de MCP-tweeling in `createMcpTransactions.ts`. Een duur die uit de driehoek +komt (inzet/werk/resource erbij-eraf onder FIXED_WORK/FIXED_RATE) zet `scheduleStale` en loopt door +`settleDurationAftermath` (contour + importsplits herschalen, Z8-venster en bevroren walks wissen), +precies als een duurbewerking; op een gestarte taak wordt de rest dan expliciet geschreven +(`remainingTime`/`remainingMinutes`) zodat niets drift. Een voortgangsbewerking is géén duurbewerking +(de poort is de totale werkduur). Verandert het restwerk van een toewijzing mét contour, dan zakt de contourhoogte mee (`reconcileContourWork`, "vorm blijft, hoogte zakt"). Materiaalresources sturen -de duur nooit. Regressie: `tests/planning/check-work-triangle.ts` (kern + meetlat +de duur nooit; kalenders blijven buiten de driehoek (open punt K2 in de TODO). Regressie: `tests/planning/check-work-triangle.ts` (kern + meetlat `work-triangle-cases.json`), `check-work-rule-mapping.ts` (MSP/P6/XER-vertaling) en `check-work-rule-store.ts` (store/raster/MCP). Via de MCP-bridge: `planner_update_tasks`/`planner_add_tasks` `fields.workRule`, `planner_manage_assignments` `update.remainingWorkMinutes` en `planner_update_project` diff --git a/docs/TODO.md b/docs/TODO.md index f5779dd2d..395c90454 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -365,11 +365,19 @@ deze lijst verwijderd — wat klaar is, staat in de changelog en git-historie. en volgt de werkdriehoek dus ook niet (AFGELEID uit de code; wat MS Project hier doet is niet gemeten). Beslissen: moet een duurbewerking `remainingTime` mee verschuiven (Δ), of is de voortgangsroute leidend? +- [ ] **Kalenderwissel op een taak met vastgelegd werk (reviewbevinding K2, 2026-09-05).** Spec §6.4 + houdt kalenders bewust buiten de werkdriehoek. Gevolg (ZEKER, nagerekend door de reviewer): + FIXED_WORK-taak 4 d op 8 u/dag (W = 1920 min, I = 1) → taakkalender 6 u/dag ⇒ de duur blijft + 4 d (1440 werkminuten) maar W blijft 1920, dus het histogram toont 1,33 eenheden/dag op een + resource met `maxUnits: 1`. Beslissen: (a) I = W / R' herrekenen bij een kalenderwissel, + (b) W mee herschalen (werk = duur × inzet houden), of (c) laten staan en documenteren. Tot + dan geldt (c); geen testgeval. - [ ] **B1c-koppelpunt (`origin/t3code/b1c-etappe3`, gezien 2026-09-05):** die branch wist bij elke as-verzettende bewerking de nivelleergaten (`clearLevelingGaps`, B1c-plan3 taak 3). Een duur die uit de werkdriehoek komt (`afterTriangleDurationChange` in `resourceSlice.ts`/ - `createMcpTransactions.ts`, en het assignment-set-pad in `gridTransaction.ts`) hoort dat ná de - merge óók te doen — toe te voegen op de kant die als tweede merget. Geen inhoudelijke overlap + `createMcpTransactions.ts`, en het assignment-set-pad in `gridTransaction.ts` — sinds de + reviewronde één plek: `workRuleApply.ts`'s `settleDurationAftermath`) hoort dat ná de merge + óók te doen — één regel toe te voegen op de kant die als tweede merget. Geen inhoudelijke overlap verder: B1c raakt inzet noch werk (besluit 7 blijft). - [x] **Beslispunten 8–10 genomen (2026-09-05)**, vastgelegd in spec §3.3: 8 = optie B (vier types in diff --git a/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md b/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md index e0b814e97..c4b6a89a2 100644 --- a/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md +++ b/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md @@ -419,9 +419,13 @@ eerste toewijzing zet het werk). ZEKER, `TaskTime`-docblok). Levert een regel R = W / I een fractie op, dan wordt R **naar boven op hele dagen** afgerond en blijft W exact staan; de laatste dag is dan niet vol. Dat vraagt één aanpassing in `assignmentDayUnits` (ResourceLoad.ts): een vierde bron vóór de formule — is - `remainingWorkMinutes` aanwezig, dan is het te verdelen totaal dát getal en niet - `unitsPerDay × durationDays`. Zonder die aanpassing toont het histogram op zo'n taak te veel werk. - BEREDENEERD; MSP staat fractionele dagen toe (0,5 d) en heeft dit probleem niet. + `remainingWorkMinutes` aanwezig, dan is het te verdelen totaal het RESTwerk **plus het verrichte + deel** (`actualWorkMinutes` als de bron 'm gaf, anders afgeleid als verrichte duur × inzet — + reviewbevinding B3 op bouwstap 4: de driehoek schrijft alleen het restveld, en een typewissel + op een half gedane taak mag de belasting niet halveren, besluit 2), gespreid met de curvevorm + over de hele duur; niet `unitsPerDay × durationDays`. Zonder die aanpassing toont het histogram + op zo'n taak te veel werk. BEREDENEERD; MSP staat fractionele dagen toe (0,5 d) en heeft dit + probleem niet. - **Wat na afronding leidend is**: na case 4 geldt R = 3 d, I = 1,0 + 1,0 en W = 20 + 20 u, terwijl R × ΣI = 48 u. De identiteit werk = duur × inzet geldt dan alleen nog voor de exacte, niet-afgeronde R. Regel: onder de werkbeschermende regels zijn **W en I opgeslagen en is R afgeleid**; elke @@ -474,7 +478,14 @@ en werk = duur × inzet heeft er geen betekenis. Complete) komt er in deze etappe niet (TODO). - Bij een bewerking op een gestarte taak is R de **restduur** (`remainingTime`/`remainingMinutes`, anders duur × (1 − completion) — de bestaande afleiding in de solver, ZEKER `CPMSolver.ts`) en W - het restwerk. Verrichte duur en verricht werk bewegen niet. + het restwerk. Verrichte duur en verricht werk bewegen niet. **Terugschrijven op een gestarte taak + (bouwstap 4, reviewbevinding B2):** de nieuwe duur is verricht + nieuwe rest, en de rest wordt dan + EXPLICIET geschreven (`remainingTime` in dagmodus, `remainingMinutes` in uurmodus) — anders zou de + solver de rest opnieuw afleiden als nieuwe duur × (1 − completion), het verrichte deel mee + verschuiven en een heen-en-weer-bewerking driften (case 31). `completion` blijft zoals ze is. +- **Een voortgangsbewerking is geen duurbewerking** (reviewbevinding B1): `completion` of + `remainingTime` wijzigen verandert de rest maar niet de duur, en raakt de driehoek niet; de poort + is de TOTALE werkduur van de taak (`settleDurationEdit` vergelijkt die met de momentopname). - Het `actual`-deel van een contour telt als `actualWorkMinutes` wanneer dat veld afwezig is. - Afsluiten op 100 %: restwerk 0, begroot blijft; heropenen laat de velden staan. @@ -529,6 +540,12 @@ ongewijzigd en geïmporteerd type bewaard: ELAPSEDTIME-taken, hangmatten, mijlpa veld daar zelf uit — ZEKER), samenvattingstaken (MSP: nooit effort-driven — ZEKER), taken zonder toewijzingen (regel doet niets — ZEKER), materiaalresources (§4.3). +Alle toewijzingsroutes lopen door de driehoek — ook **verplaatsen** (`moveAssignment` = eraf bij de +oude taak + erbij bij de nieuwe) en **een resource verwijderen** (`removeResource` = eraf op elke +taak waar hij stond); reviewbevinding B4 op bouwstap 4. Buiten de driehoek blijft alleen de +kalender (§6.4) — met het gevolg dat na een kalenderwissel op een taak met vastgelegd werk W niet +meer bij I × R past (het histogram toont dan I' = W / R'): open beslispunt in `docs/TODO.md`. + ## 9. De meetlat: bewerkingen om later tegen MS Project te toetsen Vorm: een data-gedreven case-bestand `tests/planning/work-triangle-cases.json` (gebouwd; de naam @@ -591,7 +608,7 @@ twee keer aan `formatRegistry.ts`, `ifcPsets.ts` en de contour-adapters wordt ge | 1 | `WorkRule`, `Task.workRule`, `Project.defaultWorkRule`, de drie werkvelden; `DOCUMENT_FIELDS`; IFC-psets `OPS_WorkRule` + `OPS_AssignmentWork`; ext-contract alleen-lezen. **Geen gedrag.** | `check-ifc-roundtrip` (fixture + canon), `check-document-contract`, `verify:cycles`; elk bestaand bestand byte-identiek | | 2 | Importvertaling (§4.2) voor .mpp, MSPDI (incl. ``/`` lezen), P6 XML, XER; export MSPDI/P6 XML met de werkvelden; waarschuwingen | fidelity-poort blijft 0 (`GOAL_ZERO_DEVIATIONS`); `check-mpp-*`, `check-adapters-*`; nieuwe fixtures per bron | | 3 **(gebouwd 2026-09-05)** | Pure module `src/engine/work/workTriangle.ts`: `applyDurationEdit`, `applyUnitsEdit`, `applyWorkEdit`, `applyTaskWorkEdit`, `applyAssignmentAdded`, `applyAssignmentRemoved`, `applyRuleChange` — invoer: de RESTERENDE toestand (`TriangleState`: regel, bewaard `effortDriven`, restduur in werkminuten, per toewijzing inzet + optioneel restwerk + `drivesDuration`), uitvoer: een nieuwe toestand of een weigering met reden; nooit een store. `WorkRule` in `src/types/workRule.ts` (verhuist in stap 1 naar `task.ts`). Plus `work-triangle-cases.json` (§9) | `tests/planning/check-work-triangle.ts`: 332 checks, 39 cases (21 documented, 16 reasoned, 2 decided, 0 measured; bij een mengvorm telt het zwakste bewijs) plus eigenschappen: geen veld geschreven onder de inzetregels, typewissel in alle 16 richtingen getalvrij, weigering bij restduur ≤ 0 of niet-eindige inzet (§6.7), `effortDriven` zonder effect buiten de twee 8-B-cellen | -| 4 **(gebouwd 2026-09-05)** | Brug `src/engine/work/workRuleApply.ts` (`captureTriangle` → kernstap → `applyTriangleResult`; `settleDurationEdit`/`settleUnitsEdit`/`planWorkEdit`+`commitTrianglePlan`/`settleAssignmentAdded`/`settleAssignmentRemoved`/`settleAssignmentPlan`/`settleRuleChange`; `contourKeepsWork`; `reconcileContourWork` = besluit 3 "vorm blijft, hoogte zakt"). Bedraad in `taskSlice.updateTask` + nieuw `setTaskWorkRule`, `resourceSlice.assignResource`/`updateAssignment`/`unassignResource` + nieuw `setAssignmentWork`, `gridTransaction.ts` (celduur én de assignment-set-cel, via `AssignmentSettleOp`), `createMcpTransactions.ts` (alle tweelingen + `setAssignmentWork`/`setTaskWorkRule` op de draft), `rescaleTaskContours(…, keepWork)` uit de effectieve regel, `assignmentDayUnits` derde bron = opgeslagen werk (§6.1). Een duur die uit de driehoek komt zet `scheduleStale`, herschaalt contour + importsplits en wist het Z8-venster — precies als een duurbewerking; onder FIXED_DURATION_RATE zonder werkvelden byte-identiek | `tests/planning/check-work-rule-store.ts` (65 checks: store, raster, MCP, vierde bron, meetlat 22/23/28), bestaande planning- en MCP-suites groen | +| 4 **(gebouwd 2026-09-05)** | Brug `src/engine/work/workRuleApply.ts` (`captureTriangle` → kernstap → `applyTriangleResult`; `settleDurationEdit`/`settleUnitsEdit`/`planWorkEdit`+`commitTrianglePlan`/`settleAssignmentAdded`/`settleAssignmentRemoved`/`settleAssignmentPlan`/`settleRuleChange`; `contourKeepsWork`; `reconcileContourWork` = besluit 3 "vorm blijft, hoogte zakt"). Bedraad in `taskSlice.updateTask` + nieuw `setTaskWorkRule`, `resourceSlice.assignResource`/`updateAssignment`/`unassignResource` + nieuw `setAssignmentWork`, `gridTransaction.ts` (celduur én de assignment-set-cel, via `AssignmentSettleOp`), `createMcpTransactions.ts` (alle tweelingen + `setAssignmentWork`/`setTaskWorkRule` op de draft), `rescaleTaskContours(…, keepWork)` uit de effectieve regel, `assignmentDayUnits` derde bron = opgeslagen werk (§6.1). Een duur die uit de driehoek komt zet `scheduleStale`, herschaalt contour + importsplits en wist het Z8-venster — precies als een duurbewerking; onder FIXED_DURATION_RATE zonder werkvelden byte-identiek | `tests/planning/check-work-rule-store.ts` (95 checks: store, raster, MCP, vierde bron, meetlat 22/23/28, voortgang > 0, uurmodus, FIXED_RATE + 8-B, materiaal in het raster, datums-zoals-opgeslagen, verplaatsen/resource verwijderen), bestaande planning- en MCP-suites groen | | 5 | UI: instelling, auto-ontsluiting + melding, paneel/dialoog, rasterkolommen, i18n 14 locales, gidsen nl+en (+12 vertalingen, anders faalt `verify:docs` niet maar is de functie onvindbaar) | `verify:i18n`, `verify:docs`, browserspec `tests/browser/work-rule.spec.ts` (echte toetsen/klikken, store-asserties) | | 6 | Resource erbij/eraf (§5 rij 4–5) inclusief contourregels (§6.3) — **vereist beslispunt 8** (genomen: 8-B). **Store/raster/MCP-kant gebouwd in stap 4** (`settleAssignmentAdded`/`Removed`, `reconcileContourWork`); wat rest is de UI-kant (stap 5) | cases 4, 5, 9, 10, 15, 19, 20, 23, 29, 30 (kern) + `check-work-rule-store` (b15–b21, d5–d9, e6–e7, f3–f4) | | 7 **(gebouwd 2026-09-05)** | Geen nieuwe tool (39 blijft 39) maar drie bestaande tools uitgebreid: `planner_update_tasks`/`planner_add_tasks` `fields.workRule` (enum \| null, via `draft.setTaskWorkRule` ná de duurpatch — een gelijktijdige `duration` wordt onder de OUDE regel verwerkt, `taskFields.ts`), `planner_manage_assignments` `update.remainingWorkMinutes` (> 0, via `draft.setAssignmentWork`; alleen op taken waar `workRuleApplies`), `planner_update_project` `defaultWorkRule` (enum \| null, `delete` bij null); `planner_get_project_info` toont `defaultWorkRule`, `planner_get_task` toonde `workRule` + werkvelden al sinds stap 1. `REJECT_HINTS` voor `plannedWorkMinutes`/`actualWorkMinutes`/`remainingWorkMinutes` als taakveld | `tests/mcp/cases-work-rule.ts` (9 cases via de echte dispatch, incl. `planner_batch` = één undo-stap), `cases-toolregistry`/`cases-schemavalidatie` groen | diff --git a/src/engine/scheduler/ResourceLoad.ts b/src/engine/scheduler/ResourceLoad.ts index 8f1432fff..ccd637ac3 100644 --- a/src/engine/scheduler/ResourceLoad.ts +++ b/src/engine/scheduler/ResourceLoad.ts @@ -163,14 +163,32 @@ export function assignmentDayUnits( return weights.map((w) => w * total); } if (assignment.remainingWorkMinutes !== undefined && Number.isFinite(assignment.remainingWorkMinutes) && durationDays > 0) { - const workMinutes = Math.max(0, assignment.remainingWorkMinutes) + Math.max(0, assignment.actualWorkMinutes ?? 0); - const totalUnits = workMinutes / Math.max(1, mpd); + // Het VERRICHTE deel: `actualWorkMinutes` als de bron 'm gaf, anders afgeleid als verrichte + // duur × inzet (reviewbevinding B3: de werkdriehoek schrijft alleen `remainingWorkMinutes`, en + // een typewissel op een half gedane taak mag de belasting niet halveren — besluit 2). + const slotMinutes = Math.max(1, mpd); + const doneUnits = assignment.actualWorkMinutes !== undefined + ? Math.max(0, assignment.actualWorkMinutes) / slotMinutes + : Math.max(0, durationDays - remainingDaysOf(task, slotMinutes)) * assignment.unitsPerDay; + const totalUnits = Math.max(0, assignment.remainingWorkMinutes) / slotMinutes + doneUnits; const weights = slotWeightsFromValues(CONTOUR_SHAPE_VALUES[CURVE_TO_SHAPE[assignment.curve ?? 'UNIFORM']], durationDays); return weights.map((w) => w * totalUnits); } return distributeUnits(assignment.unitsPerDay, durationDays, assignment.curve ?? 'UNIFORM'); } +/** Resterende duur van de taak in werkdagen (dezelfde afleiding als de solver en + * `workRuleApply.ts`'s `remainingMinutesOf`): uurmodus `remainingMinutes ?? duur × (1 − voortgang)` + * (÷ slot), dagmodus `remainingTime ?? duur × (1 − voortgang)`. */ +function remainingDaysOf(task: Task, slotMinutes: number): number { + const t = task.time; + if (t.durationUnit === 'hours' && typeof t.durationMinutes === 'number' && Number.isFinite(t.durationMinutes)) { + const rem = t.remainingMinutes ?? Math.round(t.durationMinutes * (1 - (t.completion ?? 0))); + return Math.max(0, rem) / slotMinutes; + } + return Math.max(0, t.remainingTime ?? Math.round(t.scheduleDuration * (1 - (t.completion ?? 0)))); +} + /** Hulpje voor de lastlezers: één `matchContoursToAssignments`-uitslag per taak (gecachet per * aanroep), zodat de volgorderegel van de koppeling over álle toewijzingen van de taak gaat. */ export function contourLookup( diff --git a/src/engine/work/workRuleApply.ts b/src/engine/work/workRuleApply.ts index b9616ff8c..bf98d17b2 100644 --- a/src/engine/work/workRuleApply.ts +++ b/src/engine/work/workRuleApply.ts @@ -21,6 +21,9 @@ import type { Task } from '@/types/task'; import { DEFAULT_WORK_RULE, type WorkRule } from '@/types/workRule'; import { contourIndexForAssignment, taskWorkMinutes } from '@/engine/contour/contourEngine'; import { resolveCalendar } from '@/engine/scheduler/resolveCalendar'; +import { + clearTimephasedDurationWalks, clearTimephasedWindow, rescaleTaskContours, timephasedDurationWalksHaveFrozenWork, +} from '@/utils/taskDefaults'; import { applyAssignmentAdded, applyAssignmentRemoved, applyDurationEdit, applyRuleChange, applyUnitsEdit, applyWorkEdit, ruleProtectsWork, type TriangleAssignment, type TriangleState, @@ -64,10 +67,17 @@ export function slotMinutesOf(ctx: Pick): number * voortgangstak): uurmodus `remainingMinutes ?? duur × (1 − voortgang)`, dagmodus * `remainingTime ?? duur × (1 − voortgang)` (dagen), beide vanuit de blijvende taak-eenheid. */ +/** Uurtaak mét minutenbron — dezelfde test voor lezen (`remainingMinutesOf`) en schrijven + * (`applyTriangleResult`), zodat een 'hours'-taak zonder `durationMinutes` in beide richtingen als + * dagtaak wordt behandeld. */ +export function isHourTask(t: Task['time']): t is Task['time'] & { durationMinutes: number } { + return t.durationUnit === 'hours' && typeof t.durationMinutes === 'number' && Number.isFinite(t.durationMinutes); +} + export function remainingMinutesOf(task: Task, ctx: Pick): number { const t = task.time; const slot = slotMinutesOf(ctx); - if (t.durationUnit === 'hours' && typeof t.durationMinutes === 'number' && Number.isFinite(t.durationMinutes)) { + if (isHourTask(t)) { const rem = t.remainingMinutes ?? Math.round(t.durationMinutes * (1 - (t.completion ?? 0))); return Math.max(0, rem); } @@ -137,7 +147,9 @@ export function applyTriangleResult( || (next.remainingWorkMinutes !== undefined && a.remainingWorkMinutes !== undefined && Math.abs(a.remainingWorkMinutes - next.remainingWorkMinutes) > 1e-6); if (!unitsChanged && !workChanged) continue; - a.unitsPerDay = Math.round(next.unitsPerDay * 10000) / 10000; + // Alleen een door de KERN herleide inzet wordt afgerond; een exact geschreven gebruikersinvoer + // (de aanroeper schrijft die vóór de settle) blijft staan. + if (unitsChanged) a.unitsPerDay = Math.round(next.unitsPerDay * 10000) / 10000; if (next.remainingWorkMinutes === undefined) delete a.remainingWorkMinutes; else a.remainingWorkMinutes = next.remainingWorkMinutes; changed.push(a.id); @@ -152,14 +164,21 @@ export function applyTriangleResult( const doneMinutes = Math.max(0, total - before.remainingMinutes); const newTotal = doneMinutes + after.remainingMinutes; const t = task.time; - if (t.durationUnit === 'hours') { + // Gestarte taak (spec §6.5, reviewbevinding B2): het verrichte deel is een feit en de REST is wat + // de kern teruggeeft. Zonder expliciet restveld zou de solver de rest opnieuw afleiden als + // `nieuwe duur × (1 − completion)` — en dan schuift het verrichte deel mee met de nieuwe duur en + // drift een heen-en-weer-bewerking (case 31). Daarom wordt de rest bij voortgang > 0 (of een al + // aanwezig restveld) expliciet geschreven; `completion` blijft zoals ze is. Een ongestarte taak + // krijgt geen extra veld (byte-identiek). + const started = (t.completion ?? 0) > 0; + if (isHourTask(t)) { t.durationMinutes = Math.round(newTotal); t.scheduleDuration = t.durationMinutes / slot; - if (t.remainingMinutes !== undefined) t.remainingMinutes = Math.round(after.remainingMinutes); + if (started || t.remainingMinutes !== undefined) t.remainingMinutes = Math.max(0, Math.round(after.remainingMinutes)); } else { t.scheduleDuration = Math.max(0, Math.round(newTotal / slot)); delete t.durationMinutes; - if (t.remainingTime !== undefined) t.remainingTime = Math.max(0, Math.round(after.remainingMinutes / slot)); + if (started || t.remainingTime !== undefined) t.remainingTime = Math.max(0, Math.round(after.remainingMinutes / slot)); } durationChanged = true; } @@ -233,13 +252,16 @@ export function workRuleContextOf(task: Task, deps: WorkRuleDeps): WorkRuleConte export interface CapturedTriangle { state: TriangleState; ctx: WorkRuleContext; + /** Totale werkminuten van de taak op het moment van de momentopname — de poort van + * `settleDurationEdit` (reviewbevinding B1: alleen een DUURwijziging is een duurbewerking). */ + totalMinutes: number; } /** Stap 1 als momentopname VÓÓR een mutatie; `null` wanneer de regel niet op deze taak werkt. */ export function captureTriangle(task: Task, assignments: readonly ResourceAssignment[], deps: WorkRuleDeps): CapturedTriangle | null { if (!workRuleApplies(task)) return null; const ctx = workRuleContextOf(task, deps); - return { state: triangleStateOf(task, assignments, ctx), ctx }; + return { state: triangleStateOf(task, assignments, ctx), ctx, totalMinutes: totalMinutesOf(task, ctx) }; } const NO_CHANGE: TriangleWriteBack = { durationChanged: false, changedAssignmentIds: [] }; @@ -253,6 +275,9 @@ const NO_CHANGE: TriangleWriteBack = { durationChanged: false, changedAssignment */ export function settleDurationEdit(task: Task, assignments: ResourceAssignment[], captured: CapturedTriangle | null): TriangleWriteBack { if (!captured) return NO_CHANGE; + // Poort (B1): een voortgangsbewerking (`completion`/`remainingTime`) verandert de REST maar niet + // de duur — dat is geen duurbewerking (spec §6.5) en raakt de driehoek niet. + if (Math.abs(totalMinutesOf(task, captured.ctx) - captured.totalMinutes) < 1e-6) return NO_CHANGE; const newRemaining = remainingMinutesOf(task, captured.ctx); if (Math.abs(newRemaining - captured.state.remainingMinutes) < 1e-6) return NO_CHANGE; const result = applyDurationEdit(captured.state, newRemaining); @@ -364,6 +389,22 @@ export function settleRuleChange( return applyTriangleResult(task, assignments, captured.state, result.state, captured.ctx); } +/** + * Nazorg wanneer de werkdriehoek de TAAKduur verandert (inzet/werk/resource erbij-eraf onder + * FIXED_WORK/FIXED_RATE) — dezelfde als bij een duurbewerking in `taskSlice.updateTask`: contour én + * importsplits herschalen (werkbehoud volgens de regel), Z8-venster en bevroren duur-walks wissen. + * Eén definitie voor store, raster en MCP (reviewbevinding K5). Retourneert of er timephased- + * sturing verloren ging (⇒ de aanroeper meldt). `scheduleStale` en de snapshot blijven aan de + * aanroeper. + */ +export function settleDurationAftermath(task: Task, deps: WorkRuleDeps, oldWorkMinutes: number): boolean { + const hpd = workRuleContextOf(task, deps).hoursPerDay; + rescaleTaskContours(task, oldWorkMinutes, hpd, contourKeepsWork(task, deps.project.defaultWorkRule)); + const clearedWindow = clearTimephasedWindow(task); + const clearedWalks = timephasedDurationWalksHaveFrozenWork(task) && clearTimephasedDurationWalks(task); + return clearedWindow || clearedWalks; +} + /** Eén taakraster-/MCP-batchwijziging op de toewijzingen van één taak, als reeks kernstappen. */ export type AssignmentSettleOp = | { kind: 'remove'; assignmentId: string } @@ -384,6 +425,11 @@ export function settleAssignmentPlan( ): TriangleWriteBack { if (!captured || ops.length === 0) return NO_CHANGE; let state = captured.state; + // Volgorde = de toepassingsvolgorde van `applyTaskAssignmentPlan` (verwijderen → inzet → + // toevoegen). Dat is een BEREDENEERDE keuze (reviewbevinding K6): bij "r1 eraf + r2 erbij" in + // één Resources-cel onder FIXED_WORK gaat het werk van r1 eerst naar de blijvers en wordt daarna + // naar rato met r2 gedeeld — hetzelfde als twee losse bewerkingen in die volgorde. Bewaakt in + // `check-work-rule-store.ts` (sectie e). const order = { remove: 0, update: 1, add: 2 } as const; for (const op of [...ops].sort((a, b) => order[a.kind] - order[b.kind])) { let result; diff --git a/src/state/gridTransaction.ts b/src/state/gridTransaction.ts index 3fd05bc15..4bfc3d16c 100644 --- a/src/state/gridTransaction.ts +++ b/src/state/gridTransaction.ts @@ -19,9 +19,10 @@ import { recordDocumentDataHistoryDelta } from './sessionHistory'; import { notifyTimephasedLoss } from './timephasedLossNotice'; import { markScheduleStale } from './transaction'; import { - captureTriangle, contourKeepsWork, settleAssignmentPlan, settleDurationEdit, type AssignmentSettleOp, + captureTriangle, contourKeepsWork, settleAssignmentPlan, settleDurationAftermath, settleDurationEdit, + type AssignmentSettleOp, } from '@/engine/work/workRuleApply'; -import { clearTimephasedWindow, rescaleTaskContours, taskCalendarHoursPerDay, taskWorkMinutesOf } from '@/utils/taskDefaults'; +import { taskCalendarHoursPerDay, taskWorkMinutesOf } from '@/utils/taskDefaults'; import { generateId } from '@/utils/id'; import { applyRelationMutationPlan, @@ -374,9 +375,9 @@ function applyAssignmentSet( } const settled = settleAssignmentPlan(task, state.assignments, triangle, ops); if (settled.durationChanged) { - // Zelfde nazorg als een duurbewerking in `applyCellEdits`/`taskEditPlan.ts`. - rescaleTaskContours(task, oldWorkMinutes, taskCalendarHoursPerDay(task, state.calendars, state.calendar), contourKeepsWork(task, state.project.defaultWorkRule)); - const lost = clearTimephasedWindow(task); + // Zelfde nazorg als een duurbewerking (`settleDurationAftermath`: contour, importsplits, Z8- + // venster én bevroren duur-walks — reviewbevinding K5). + const lost = settleDurationAftermath(task, state, oldWorkMinutes); if (lost && !lostTaskIds.includes(task.id)) lostTaskIds = [...lostTaskIds, task.id]; if (state.datesAsRecorded) { state.datesAsRecorded = false; diff --git a/src/state/runtime/createMcpTransactions.ts b/src/state/runtime/createMcpTransactions.ts index b600d496f..e7c14723c 100644 --- a/src/state/runtime/createMcpTransactions.ts +++ b/src/state/runtime/createMcpTransactions.ts @@ -27,7 +27,7 @@ import { isSummaryTask } from '@/utils/taskHierarchy'; import { reconcileP6SuspendResume } from '@/utils/p6SuspendResume'; import { captureTriangle, contourKeepsWork, planWorkEdit, commitTrianglePlan, settleAssignmentAdded, - settleAssignmentRemoved, settleDurationEdit, settleRuleChange, settleUnitsEdit, + settleAssignmentRemoved, settleDurationAftermath, settleDurationEdit, settleRuleChange, settleUnitsEdit, } from '@/engine/work/workRuleApply'; import type { WorkRule } from '@/types/workRule'; @@ -101,21 +101,15 @@ function createMcpDraft( runtime.recordMcpTimephasedLoss(activeLease(), taskId); }; - /** - * Taaktypes-etappe (bouwstap 4): nazorg wanneer de werkdriehoek de TAAKduur verandert (inzet/ - * werk/resource erbij-eraf onder FIXED_WORK/FIXED_RATE) — tweeling van resourceSlice.ts's - * `afterTriangleDurationChange`: contour + importsplits herschalen, Z8-venster/-walks wissen, - * verlies melden. `scheduleStale` blijft bewust aan de transactie (zie het docblok hierboven). - */ + /** Taaktypes-etappe: nazorg bij een duur uit de werkdriehoek — `settleDurationAftermath` (één + * definitie voor store/raster/MCP) plus de verliesmelding via de actieve runtimelease; + * `scheduleStale` blijft bewust aan de transactie (zie het docblok hierboven). */ const afterTriangleDurationChange = ( - s: { calendars: WorkCalendar[]; calendar: WorkCalendar; project: Pick }, + s: { calendars: WorkCalendar[]; calendar: WorkCalendar; project: Pick; resources: Resource[] }, task: Task, oldWorkMinutes: number, ): void => { - rescaleTaskContours(task, oldWorkMinutes, taskCalendarHoursPerDay(task, s.calendars, s.calendar), contourKeepsWork(task, s.project.defaultWorkRule)); - const clearedWindow = clearTimephasedWindow(task); - const clearedWalks = timephasedDurationWalksHaveFrozenWork(task) && clearTimephasedDurationWalks(task); - if (clearedWindow || clearedWalks) recordTimephasedLoss(task.id); + if (settleDurationAftermath(task, s, oldWorkMinutes)) recordTimephasedLoss(task.id); }; const rawDraft = { @@ -384,7 +378,9 @@ function createMcpDraft( store.setState((s) => { const idx = s.tasks.findIndex((t) => t.id === id); if (idx < 0) return; - const { time, ...rest } = updates; + // Taaktypes-etappe (reviewbevinding K1) — tweeling van taskSlice.ts's `updateTask`: `workRule` + // loopt via `settleRuleChange`, niet via de kale merge. + const { time, workRule, ...rest } = updates; const contourHpd = taskCalendarHoursPerDay(s.tasks[idx], s.calendars, s.calendar); const oldWorkMinutes = taskWorkMinutesOf(s.tasks[idx], contourHpd); // Taaktypes-etappe (bouwstap 4) — tweeling van taskSlice.ts's `updateTask`: momentopname vóór. @@ -396,6 +392,7 @@ function createMcpDraft( rescaleTaskContours(s.tasks[idx], oldWorkMinutes, contourHpd, contourKeepsWork(s.tasks[idx], s.project.defaultWorkRule)); settleDurationEdit(s.tasks[idx], s.assignments, triangle); } + if ('workRule' in updates && s.tasks[idx].workRule !== workRule) settleRuleChange(s.tasks[idx], s.assignments, s, workRule); reconcileP6SuspendResume(s.tasks[idx]); // Z14b (eigenaarsprincipe 2026-08-18) — gedocumenteerde tweeling van taskSlice.ts's // `updateTask`: zelfde triggerset/uitleg in `taskDefaults.ts`. @@ -437,7 +434,8 @@ function createMcpDraft( const oldWorkMinutes = taskWorkMinutesOf(task, contourHpd); // Taaktypes-etappe (bouwstap 4) — zelfde momentopname als `updateTaskFields` hierboven. const triangle = timePatch ? captureTriangle(task, s.assignments, s) : null; - Object.assign(task, top); + const { workRule, ...topRest } = top; + Object.assign(task, topRest); let timeTouched = false; if (timePatch) { if (timePatch.scheduleDuration !== undefined) { task.time.scheduleDuration = timePatch.scheduleDuration; timeTouched = true; } @@ -451,6 +449,8 @@ function createMcpDraft( rescaleTaskContours(task, oldWorkMinutes, contourHpd, contourKeepsWork(task, s.project.defaultWorkRule)); settleDurationEdit(task, s.assignments, triangle); } + // Reviewbevinding K1: `workRule` via de driehoek-bewuste route, ná de duurpatch. + if ('workRule' in top && task.workRule !== workRule) settleRuleChange(task, s.assignments, s, workRule); reconcileP6SuspendResume(task); // Z14b (eigenaarsprincipe 2026-08-18) — zelfde triggerset als `updateTaskFields`, zie // `taskDefaults.ts`. `timePatch` heeft een eigen, smallere vorm (allowlist-gedreven) dan een @@ -612,8 +612,19 @@ function createMcpDraft( report.affectedTaskIds = [...new Set(doomed.map((a) => String(a.taskId)))]; report.orphanedCrewMemberIds = s.resources.filter((r) => r.parentId === id).map((r) => String(r.id)); + // Taaktypes-etappe (spec §5 rij 5, reviewbevinding B4) — tweeling van resourceSlice.ts's + // `removeResource`: elke verdwijnende toewijzing is een "resource eraf" voor haar taak. + const captured = doomed.map((a) => { + const task = s.tasks.find((t) => t.id === a.taskId); + return task ? { task, assignmentId: a.id, triangle: captureTriangle(task, s.assignments, s), oldWorkMinutes: taskWorkMinutesOf(task, taskCalendarHoursPerDay(task, s.calendars, s.calendar)) } : null; + }); s.resources = s.resources.filter((r) => r.id !== id); s.assignments = s.assignments.filter((a) => a.resourceId !== id); + for (const c of captured) { + if (c && settleAssignmentRemoved(c.task, s.assignments, c.triangle, c.assignmentId).durationChanged) { + afterTriangleDurationChange(s, c.task, c.oldWorkMinutes); + } + } for (const task of s.tasks) { const idx = task.resourceIds.indexOf(id); if (idx >= 0) task.resourceIds.splice(idx, 1); @@ -779,7 +790,20 @@ function createMcpDraft( } const oldTaskId = assignment.taskId; + // Taaktypes-etappe (spec §5 rij 4/5, reviewbevinding B4) — tweeling van resourceSlice.ts's + // `moveAssignment`: eraf bij de oude taak, erbij bij de nieuwe; momentopnamen vóór de wissel. + const oldTaskForTriangle = s.tasks.find((t) => t.id === oldTaskId); + const oldTriangle = oldTaskForTriangle ? captureTriangle(oldTaskForTriangle, s.assignments, s) : null; + const oldWorkOld = oldTaskForTriangle ? taskWorkMinutesOf(oldTaskForTriangle, taskCalendarHoursPerDay(oldTaskForTriangle, s.calendars, s.calendar)) : 0; + const newTriangle = captureTriangle(newTask, s.assignments, s); + const oldWorkNew = taskWorkMinutesOf(newTask, taskCalendarHoursPerDay(newTask, s.calendars, s.calendar)); assignment.taskId = newTaskId; + if (oldTaskForTriangle && settleAssignmentRemoved(oldTaskForTriangle, s.assignments, oldTriangle, assignmentId).durationChanged) { + afterTriangleDurationChange(s, oldTaskForTriangle, oldWorkOld); + } + if (settleAssignmentAdded(newTask, s.assignments, newTriangle, assignment).durationChanged) { + afterTriangleDurationChange(s, newTask, oldWorkNew); + } const stillOnOld = s.assignments.some( (a) => a.taskId === oldTaskId && a.resourceId === assignment.resourceId, diff --git a/src/state/slices/resourceSlice.ts b/src/state/slices/resourceSlice.ts index 710bf611c..6110e5c84 100644 --- a/src/state/slices/resourceSlice.ts +++ b/src/state/slices/resourceSlice.ts @@ -6,12 +6,11 @@ import { generateId } from '@/utils/id'; import { nextFreePaletteColor } from '@/engine/renderer/resourcePalette'; import { syncProjectCalendar } from '../syncProjectCalendar'; import { - clearTimephasedWindow, clearTimephasedDurationWalks, rescaleTaskContours, taskCalendarHoursPerDay, - taskWorkMinutesOf, timephasedDurationWalksHaveFrozenWork, + clearTimephasedWindow, clearTimephasedDurationWalks, taskCalendarHoursPerDay, taskWorkMinutesOf, } from '@/utils/taskDefaults'; import { - captureTriangle, commitTrianglePlan, contourKeepsWork, planWorkEdit, settleAssignmentAdded, - settleAssignmentRemoved, settleUnitsEdit, + captureTriangle, commitTrianglePlan, planWorkEdit, settleAssignmentAdded, settleAssignmentRemoved, + settleDurationAftermath, settleUnitsEdit, } from '@/engine/work/workRuleApply'; import type { Task } from '@/types/task'; import { notifyTimephasedLoss } from '../timephasedLossNotice'; @@ -72,18 +71,9 @@ export interface ResourceSlice { const isValidUnits = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n) && n > 0; -/** - * Taaktypes-etappe (2026-09, bouwstap 4): gevolg van een DUURwijziging die uit de werkdriehoek - * komt (inzet/werk/resource erbij-eraf onder FIXED_WORK/FIXED_RATE) — dezelfde nazorg als - * `taskSlice.updateTask` bij een duurbewerking: contour én importsplits herschalen (werkbehoud - * volgens de regel), Z8-venster/-walks wissen. Retourneert of er timephased-sturing verloren ging. - */ -function afterTriangleDurationChange(s: { tasks: Task[]; calendars: WorkCalendar[]; calendar: WorkCalendar; project: { defaultWorkRule?: Task['workRule'] } }, task: Task, oldWorkMinutes: number): boolean { - const hpd = taskCalendarHoursPerDay(task, s.calendars, s.calendar); - rescaleTaskContours(task, oldWorkMinutes, hpd, contourKeepsWork(task, s.project.defaultWorkRule)); - const clearedWindow = clearTimephasedWindow(task); - const clearedWalks = timephasedDurationWalksHaveFrozenWork(task) && clearTimephasedDurationWalks(task); - return clearedWindow || clearedWalks; +/** Taaktypes-etappe: oude werkminuten van een taak vóór een driehoekstap (voor de contourherschaling). */ +function workMinutesBefore(s: { calendars: WorkCalendar[]; calendar: WorkCalendar }, task: Task): number { + return taskWorkMinutesOf(task, taskCalendarHoursPerDay(task, s.calendars, s.calendar)); } export const createResourceSlice: AppSliceFactory = (runtime) => (set, get) => ({ @@ -132,8 +122,23 @@ export const createResourceSlice: AppSliceFactory = (runtime) => set((s) => { if (!s.resources.some(r => r.id === id)) return; // onbekend id: geen snapshot, geen loze undo-stap. runtime.beginUndoable(s); + // Taaktypes-etappe (spec §5 rij 5, reviewbevinding B4): elke verdwijnende toewijzing is een + // "resource eraf" voor haar taak — momentopname MÉT de toewijzing, settle erná. + const doomed = s.assignments.filter(a => a.resourceId === id); + const captured = doomed.map(a => { + const task = s.tasks.find(t => t.id === a.taskId); + return task ? { task, assignmentId: a.id, triangle: captureTriangle(task, s.assignments, s), oldWorkMinutes: workMinutesBefore(s, task) } : null; + }); s.resources = s.resources.filter(r => r.id !== id); s.assignments = s.assignments.filter(a => a.resourceId !== id); + let stale = false; + for (const c of captured) { + if (!c) continue; + if (settleAssignmentRemoved(c.task, s.assignments, c.triangle, c.assignmentId).durationChanged) { + settleDurationAftermath(c.task, s, c.oldWorkMinutes); + stale = true; + } + } // Verweesde verwijzingen in task.resourceIds opruimen. for (const task of s.tasks) { const idx = task.resourceIds.indexOf(id); @@ -143,7 +148,7 @@ export const createResourceSlice: AppSliceFactory = (runtime) => for (const r of s.resources) { if (r.parentId === id) r.parentId = undefined; } - runtime.finishMutation(s); + runtime.finishMutation(s, { stale }); }); get().recomputeResourceLoad(); get().recomputeViewRows(); // resource-naam/toewijzing raakt kolom/groep/filter (§4.3). @@ -172,7 +177,7 @@ export const createResourceSlice: AppSliceFactory = (runtime) => // Taaktypes-etappe (spec §5 rij 4, beslispunt 8-B): momentopname ZONDER de nieuwe toewijzing. const triangle = captureTriangle(task, s.assignments, s); - const oldWorkMinutes = taskWorkMinutesOf(task, taskCalendarHoursPerDay(task, s.calendars, s.calendar)); + const oldWorkMinutes = workMinutesBefore(s, task); const id = generateId('asgn'); const added: ResourceAssignment = { id, taskId, resourceId, unitsPerDay, curve }; s.assignments.push(added); @@ -182,7 +187,7 @@ export const createResourceSlice: AppSliceFactory = (runtime) => // Onder FIXED_WORK/FIXED_RATE (zonder MSP-`effortDriven: false`) blijft het restwerk staan en // wordt de restduur korter; onder de standaardregel verandert niets (byte-identiek). const settled = settleAssignmentAdded(task, s.assignments, triangle, added); - if (settled.durationChanged) afterTriangleDurationChange(s, task, oldWorkMinutes); + if (settled.durationChanged) settleDurationAftermath(task, s, oldWorkMinutes); // Z14b (eigenaarsprincipe 2026-08-18, F2-fixronde) — "toewijzingen" is expliciet onderdeel // van de edit-time-invalidatie-triggerset (zie `taskDefaults.ts`'s `clearTimephasedWindow`/ // `clearTimephasedDurationWalks`): een andere resource kan een andere resourcekalender @@ -218,7 +223,7 @@ export const createResourceSlice: AppSliceFactory = (runtime) => // wordt eerst geschreven, daarna volgen werk en/of restduur de regel van de taak. const task = s.tasks.find(t => t.id === s.assignments[idx].taskId); const unitsEdit = task && typeof patch.unitsPerDay === 'number' && patch.unitsPerDay !== s.assignments[idx].unitsPerDay - ? { task, triangle: captureTriangle(task, s.assignments, s), oldWorkMinutes: taskWorkMinutesOf(task, taskCalendarHoursPerDay(task, s.calendars, s.calendar)) } + ? { task, triangle: captureTriangle(task, s.assignments, s), oldWorkMinutes: workMinutesBefore(s, task) } : null; Object.assign(s.assignments[idx], patch); // Contour-engine (2026-09): een bewuste curvekeuze van de gebruiker vervangt de exacte @@ -229,7 +234,7 @@ export const createResourceSlice: AppSliceFactory = (runtime) => if (unitsEdit) { const settled = settleUnitsEdit(unitsEdit.task, s.assignments, unitsEdit.triangle, assignmentId, s.assignments[idx].unitsPerDay); if (settled.durationChanged) { - lostTimephasedGuidance = afterTriangleDurationChange(s, unitsEdit.task, unitsEdit.oldWorkMinutes); + lostTimephasedGuidance = settleDurationAftermath(unitsEdit.task, s, unitsEdit.oldWorkMinutes); stale = true; } } @@ -248,13 +253,13 @@ export const createResourceSlice: AppSliceFactory = (runtime) => const task = s.tasks.find(t => t.id === a.taskId); if (!task) return; if (typeof remainingWorkMinutes !== 'number' || !Number.isFinite(remainingWorkMinutes) || remainingWorkMinutes <= 0) return; - const oldWorkMinutes = taskWorkMinutesOf(task, taskCalendarHoursPerDay(task, s.calendars, s.calendar)); + const oldWorkMinutes = workMinutesBefore(s, task); // Eerst plannen (puur), dan pas de snapshot: een weigering laat geen lege undo-stap achter. const plan = planWorkEdit(task, s.assignments, s, assignmentId, remainingWorkMinutes); if (!plan) return; runtime.beginUndoable(s); const settled = commitTrianglePlan(task, s.assignments, plan); - if (settled.durationChanged) lostTimephasedGuidance = afterTriangleDurationChange(s, task, oldWorkMinutes); + if (settled.durationChanged) lostTimephasedGuidance = settleDurationAftermath(task, s, oldWorkMinutes); runtime.finishMutation(s, { stale: settled.durationChanged }); }); if (lostTimephasedGuidance) notifyTimephasedLoss(get().notify, get().activeDocumentId, 1); @@ -300,12 +305,12 @@ export const createResourceSlice: AppSliceFactory = (runtime) => // Taaktypes-etappe (spec §5 rij 5): momentopname MÉT de te verwijderen toewijzing. const triangleTask = s.tasks.find(t => t.id === removed.taskId); const triangle = triangleTask ? captureTriangle(triangleTask, s.assignments, s) : null; - const oldWorkMinutes = triangleTask ? taskWorkMinutesOf(triangleTask, taskCalendarHoursPerDay(triangleTask, s.calendars, s.calendar)) : 0; + const oldWorkMinutes = triangleTask ? workMinutesBefore(s, triangleTask) : 0; s.assignments = s.assignments.filter(a => a.id !== assignmentId); let stale = false; if (triangleTask) { const settled = settleAssignmentRemoved(triangleTask, s.assignments, triangle, assignmentId); - if (settled.durationChanged) { afterTriangleDurationChange(s, triangleTask, oldWorkMinutes); stale = true; } + if (settled.durationChanged) { settleDurationAftermath(triangleTask, s, oldWorkMinutes); stale = true; } } // task.resourceIds alleen opschonen als er geen andere toewijzing van // dezelfde resource aan dezelfde taak meer bestaat. @@ -352,7 +357,23 @@ export const createResourceSlice: AppSliceFactory = (runtime) => runtime.beginUndoable(s); const oldTaskId = assignment.taskId; + // Taaktypes-etappe (spec §5 rij 4/5, reviewbevinding B4): verplaatsen = eraf bij de oude taak + // én erbij bij de nieuwe. Beide momentopnamen VÓÓR de wissel (oud mét, nieuw zónder). + const oldTaskForTriangle = s.tasks.find(t => t.id === oldTaskId); + const oldTriangle = oldTaskForTriangle ? captureTriangle(oldTaskForTriangle, s.assignments, s) : null; + const oldWorkOld = oldTaskForTriangle ? workMinutesBefore(s, oldTaskForTriangle) : 0; + const newTriangle = captureTriangle(newTask, s.assignments, s); + const oldWorkNew = workMinutesBefore(s, newTask); assignment.taskId = newTaskId; + let stale = false; + if (oldTaskForTriangle && settleAssignmentRemoved(oldTaskForTriangle, s.assignments, oldTriangle, assignmentId).durationChanged) { + settleDurationAftermath(oldTaskForTriangle, s, oldWorkOld); + stale = true; + } + if (settleAssignmentAdded(newTask, s.assignments, newTriangle, assignment).durationChanged) { + settleDurationAftermath(newTask, s, oldWorkNew); + stale = true; + } // task.resourceIds bijwerken op de OUDE taak (verwijderen als geen andere toewijzing van // dezelfde resource meer resteert — spiegelt unassignResource) en de NIEUWE taak (toevoegen @@ -379,7 +400,7 @@ export const createResourceSlice: AppSliceFactory = (runtime) => const clearedNewWindow = clearTimephasedWindow(newTask); const clearedNewWalks = clearTimephasedDurationWalks(newTask); if (clearedNewWindow || clearedNewWalks) lostCount++; - runtime.finishMutation(s); + runtime.finishMutation(s, { stale }); moved = true; }); if (moved && lostCount > 0) notifyTimephasedLoss(get().notify, get().activeDocumentId, lostCount); diff --git a/src/state/slices/taskSlice.ts b/src/state/slices/taskSlice.ts index ec5bdcc17..ff3f65b20 100644 --- a/src/state/slices/taskSlice.ts +++ b/src/state/slices/taskSlice.ts @@ -406,7 +406,11 @@ export const createTaskSlice: AppSliceFactory = (runtime) => (set, ge // `ExtTaskTime`-volledigheid niet op runtime wordt afgedwongen) stil bestaande verplichte velden // (completion/floats/…) tot een lege plek diezelfde writeIFC-crash weer opende. Zie // `mergeTaskTime` in taskDefaults.ts voor de ADD-vs-UPDATE-basissemantiek. - const { time, ...rest } = updates; + // Taaktypes-etappe (reviewbevinding K1): `workRule` loopt niet via de kale merge maar via + // `settleRuleChange` (legt onder een werkbeschermende regel het restwerk vast — besluit 2), + // zodat `updateTask(id, { workRule })` (extensie-`data.updateTask`, dialogen) hetzelfde doet + // als `setTaskWorkRule`. + const { time, workRule, ...rest } = updates; // Contour-engine (2026-09): de oude werkduur vóór de merge, voor de herschaling hieronder. const contourHpd = taskCalendarHoursPerDay(s.tasks[idx], s.calendars, s.calendar); const oldWorkMinutes = taskWorkMinutesOf(s.tasks[idx], contourHpd); @@ -425,6 +429,7 @@ export const createTaskSlice: AppSliceFactory = (runtime) => (set, ge // nieuwe duur. Onder de standaardregel zonder werkvelden gebeurt er niets (byte-identiek). settleDurationEdit(s.tasks[idx], s.assignments, triangle); } + if ('workRule' in updates && s.tasks[idx].workRule !== workRule) settleRuleChange(s.tasks[idx], s.assignments, s, workRule); 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 diff --git a/tests/planning/check-work-rule-store.ts b/tests/planning/check-work-rule-store.ts index 99a8c59bf..18eade9c2 100644 --- a/tests/planning/check-work-rule-store.ts +++ b/tests/planning/check-work-rule-store.ts @@ -271,6 +271,175 @@ console.log('-- (g) assignmentDayUnits: opgeslagen werk als vierde bron --'); eq('g4 verricht + resterend telt samen', r3(assignmentDayUnits(t, { ...base, remainingWorkMinutes: 480, actualWorkMinutes: 480 }, 480)), [0.5, 0.5, 0.5, 0.5]); } +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- (h) voortgang > 0: geen drift, voortgangsbewerking is geen duurbewerking (review B1/B2/B3) --'); +{ + reset(); + const t = S().addTask({ name: 'h', time: createDefaultTaskTime('2026-06-01', 10) }); + const r1 = labor('r1'); + S().assignResource(t, r1, 1); + S().updateTask(t, { time: { ...task(t).time, completion: 0.5 } }); + S().runCPM(); + const mpd = slot(); + const loadBefore = assignmentDayUnits(task(t), asgOf(t, r1), mpd); + S().setTaskWorkRule(t, 'FIXED_WORK'); + eq('h1 typewissel legt alleen het RESTwerk vast (5 dagen)', asgOf(t, r1).remainingWorkMinutes, 5 * mpd); + const r6 = (xs: number[]) => xs.map((v) => Math.round(v * 1e6) / 1e6); + eq('h2 (B3) histogram na typewissel ongewijzigd (verricht deel afgeleid)', r6(assignmentDayUnits(task(t), asgOf(t, r1), mpd)), r6(loadBefore)); + // Voortgangsbewerking via updateTask met een gespreide time-tak (TaskDialog-patroon) raakt de inzet niet. + S().updateTask(t, { time: { ...task(t).time, completion: 0.7 } }); + eq('h3 (B1) completion-wijziging via updateTask laat de inzet staan', asgOf(t, r1).unitsPerDay, 1); + S().updateTask(t, { time: { ...task(t).time, completion: 0.5 } }); + S().runCPM(); + // Inzet 1→2 op een half gedane taak: rest 5 d × 1 = 5 slots werk ⇒ R = 2,5 ⇒ 3 d; duur = 5 + 3 = 8. + S().updateAssignment(asgOf(t, r1).id, { unitsPerDay: 2 }); + eq('h4 (B2) inzet 1→2: duur 8 (5 gedaan + 3 rest), rest expliciet 3', [task(t).time.scheduleDuration, task(t).time.remainingTime], [8, 3]); + S().runCPM(); + S().updateAssignment(asgOf(t, r1).id, { unitsPerDay: 1 }); + eq('h5 (B2) inzet 2→1: terug naar 10, rest 5 — geen drift', [task(t).time.scheduleDuration, task(t).time.remainingTime], [10, 5]); + S().runCPM(); + S().updateAssignment(asgOf(t, r1).id, { unitsPerDay: 2 }); + S().runCPM(); + S().updateAssignment(asgOf(t, r1).id, { unitsPerDay: 1 }); + eq('h6 (case 31) heen-en-weer blijft 10', task(t).time.scheduleDuration, 10); + // Raster: een voortgangscel verandert de rest, niet de duur ⇒ geen driehoekstap. + const cellP = (columnId: string, route: CellEditIntent['route'], value: unknown): CellEditIntent => + ({ kind: 'cell-edit', taskId: t, columnId: columnId as CellEditIntent['columnId'], route, value }); + eq('h7 (B1) rastercel completion 0,5→0,8 slaagt', runGridMutation([cellP('task.time.completion', 'task-progress', 0.8)]).ok, true); + eq('h8 (B1) …en laat inzet en duur staan', [asgOf(t, r1).unitsPerDay, task(t).time.scheduleDuration], [1, 10]); +} + +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- (i) uurmodus --'); +{ + reset(); + const t = S().addTask({ name: 'i', time: createDefaultTaskTime('2026-06-01', 4, 'hours') }); + const r1 = labor('r1'); + S().assignResource(t, r1, 1); + S().runCPM(); + eq('i0 voorwaarde: uurtaak van 240 minuten', [task(t).time.durationUnit, task(t).time.durationMinutes], ['hours', 240]); + S().setTaskWorkRule(t, 'FIXED_WORK'); + eq('i1 restwerk vastgelegd in minuten', asgOf(t, r1).remainingWorkMinutes, 240); + S().setAssignmentWork(asgOf(t, r1).id, 360); + eq('i2 werk 360 min bij inzet 1 ⇒ duur 360 min (geen dagafronding)', [task(t).time.durationMinutes, task(t).time.scheduleDuration], [360, 360 / slot()]); + S().runCPM(); + S().updateAssignment(asgOf(t, r1).id, { unitsPerDay: 2 }); + eq('i3 inzet 2 ⇒ 180 min', task(t).time.durationMinutes, 180); +} + +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- (j) FIXED_RATE en de 8-B-cel effortDriven:false --'); +{ + reset(); + const t = S().addTask({ name: 'j', time: createDefaultTaskTime('2026-06-01', 4) }); + const r1 = labor('r1'); + S().assignResource(t, r1, 1); + S().runCPM(); + S().setTaskWorkRule(t, 'FIXED_RATE'); + const r2 = labor('r2'); + S().assignResource(t, r2, 1); + eq('j1 FIXED_RATE (zuiver P6): resource erbij verdeelt het werk ⇒ duur 2', task(t).time.scheduleDuration, 2); + S().runCPM(); + S().unassignResource(asgOf(t, r2).id); + eq('j2 …eraf ⇒ terug naar 4', task(t).time.scheduleDuration, 4); + S().runCPM(); + S().setAssignmentWork(asgOf(t, r1).id, 8 * slot()); + eq('j3 FIXED_RATE: meer werk ⇒ langer (8), inzet blijft 1', [task(t).time.scheduleDuration, asgOf(t, r1).unitsPerDay], [8, 1]); + S().runCPM(); + // 8-B: MSP "Fixed Units, niet effort-driven" ⇒ resource erbij verandert het werk, niet de duur. + S().updateTask(t, { mspTaskType: 'FIXED_UNITS', effortDriven: false }); + S().runCPM(); + S().assignResource(t, r2, 1); + eq('j4 (8-B) FIXED_RATE + effortDriven:false: erbij laat de duur staan', task(t).time.scheduleDuration, 8); + // De kern schrijft de nieuwkomer geen veld (afwezig ⇒ afgeleid als R × I, hetzelfde getal). + eq('j5 (8-B) …de nieuwe toewijzing krijgt geen veld (afgeleid = R × I)', asgOf(t, r2).remainingWorkMinutes, undefined); + S().runCPM(); + S().unassignResource(asgOf(t, r2).id); + eq('j6 (8-B) …eraf laat de duur staan', task(t).time.scheduleDuration, 8); +} + +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- (k) raster: materiaal, gemengde Resources-cel (K6), datums-zoals-opgeslagen --'); +{ + reset(); + const t = S().addTask({ name: 'k', time: createDefaultTaskTime('2026-06-01', 4) }); + const r1 = labor('r1'); + S().assignResource(t, r1, 1); + S().runCPM(); + S().setTaskWorkRule(t, 'FIXED_WORK'); + const mat = S().addResource({ name: 'beton', type: 'MATERIAL', description: '', maxUnits: 100 }); + const addMat: AssignmentSetIntent = { + kind: 'assignment-set', taskId: t, columnId: 'assignment.resources' as AssignmentSetIntent['columnId'], + tokens: [{ resourceId: r1, assignmentId: asgOf(t, r1).id, unitsPerDay: 1 }, { resourceId: mat, unitsPerDay: 20 }], + }; + eq('k1 materiaal erbij via het raster slaagt', runGridMutation([addMat]).ok, true); + eq('k2 …en verandert de duur niet, geen werkveld op materiaal', [task(t).time.scheduleDuration, asgOf(t, mat).remainingWorkMinutes], [4, undefined]); + // Gemengde cel: r1 eraf + r2 erbij (à 0,5) in één keer. Volgorde verwijderen → toevoegen: + // r1's werk (4 slots) gaat naar de blijvers — er zijn er geen (materiaal stuurt niet), dus het + // werk vervalt; r2 komt daarna als EERSTE werkresource en krijgt geen veld (afwezig ⇒ afgeleid + // als R × I = 4 × 0,5 = 2 slots); de duur blijft 4. + const r2 = labor('r2'); + const swap: AssignmentSetIntent = { + kind: 'assignment-set', taskId: t, columnId: 'assignment.resources' as AssignmentSetIntent['columnId'], + tokens: [{ resourceId: mat, assignmentId: asgOf(t, mat).id, unitsPerDay: 20 }, { resourceId: r2, unitsPerDay: 0.5 }], + }; + eq('k3 (K6) r1 eraf + r2 erbij in één cel slaagt', runGridMutation([swap]).ok, true); + eq('k4 (K6) volgorde verwijderen→toevoegen: duur 4, r2 zonder veld (afgeleid), r1 weg', [task(t).time.scheduleDuration, asgOf(t, r2).remainingWorkMinutes, S().assignments.some((a) => a.resourceId === r1)], [4, undefined, false]); + // Datums zoals opgeslagen: een duur uit de driehoek verlaat die modus (zelfde als een duurcel). + S().runCPM(); + useAppStore.setState((s) => { s.datesAsRecorded = true; s.recordedDates = {} as never; }); + const units: AssignmentSetIntent = { + kind: 'assignment-set', taskId: t, columnId: 'assignment.unitsPerDay' as AssignmentSetIntent['columnId'], + tokens: [{ resourceId: mat, assignmentId: asgOf(t, mat).id, unitsPerDay: 20 }, { resourceId: r2, assignmentId: asgOf(t, r2).id, unitsPerDay: 1 }], + }; + eq('k5 inzet r2 0,5→1 onder FIXED_WORK via het raster slaagt', runGridMutation([units]).ok, true); + eq('k6 …duur 2, planning verouderd, datums-zoals-opgeslagen verlaten', [task(t).time.scheduleDuration, S().scheduleStale, S().datesAsRecorded, S().recordedDates], [2, true, false, null]); +} + +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- (l) moveAssignment en removeResource lopen door de driehoek (review B4) --'); +{ + reset(); + const a = S().addTask({ name: 'la', time: createDefaultTaskTime('2026-06-01', 4) }); + const b = S().addTask({ name: 'lb', time: createDefaultTaskTime('2026-06-01', 4) }); + const r1 = labor('r1'); + const r2 = labor('r2'); + S().assignResource(a, r1, 1); + S().assignResource(a, r2, 1); + S().assignResource(b, r1, 1); + S().runCPM(); + S().setTaskWorkRule(a, 'FIXED_WORK'); + S().setTaskWorkRule(b, 'FIXED_WORK'); + // a: r1 en r2 elk 4 slots vastgelegd (8 slots totaal); b: r1 4 slots. r2 van a naar b: + // a ⇒ "eraf": al het restwerk (8 slots) blijft en gaat naar r1 ⇒ R = 8 d (spec §5 rij 5); + // b ⇒ "erbij": 4 slots over r1 + r2 naar rato ⇒ 2 + 2, R = 2 d. + const events0 = S().historyEvents.length; + S().moveAssignment(asgOf(a, r2).id, b); + eq('l1 verplaatsen: oude taak houdt al haar werk bij r1 (8 slots ⇒ duur 8)', [asgOf(a, r1).remainingWorkMinutes, task(a).time.scheduleDuration], [8 * slot(), 8]); + eq('l2 verplaatsen: nieuwe taak verdeelt haar werk ⇒ duur 2, 2 + 2', [task(b).time.scheduleDuration, asgOf(b, r1).remainingWorkMinutes, asgOf(b, r2).remainingWorkMinutes], [2, 2 * slot(), 2 * slot()]); + eq('l3 …één undo-stap, planning verouderd', [S().historyEvents.length - events0, S().scheduleStale], [1, true]); + S().runCPM(); + // Resource r2 verwijderen: b verliest r2 ⇒ 4 slots terug naar r1 ⇒ duur 4. + S().removeResource(r2); + eq('l4 removeResource: b terug naar 4 dagen met al het werk bij r1', [task(b).time.scheduleDuration, asgOf(b, r1).remainingWorkMinutes, S().scheduleStale], [4, 4 * slot(), true]); +} + +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- (m) workRule via de generieke updateTask legt óók vast (review K1) --'); +{ + reset(); + const t = S().addTask({ name: 'm', time: createDefaultTaskTime('2026-06-01', 4) }); + const r1 = labor('r1'); + S().assignResource(t, r1, 1); + S().runCPM(); + S().updateTask(t, { workRule: 'FIXED_WORK' }); + eq('m1 updateTask({ workRule }) zet de regel én legt het werk vast', [task(t).workRule, asgOf(t, r1).remainingWorkMinutes], ['FIXED_WORK', 4 * slot()]); + const events0 = S().historyEvents.length; + S().updateTask(t, { workRule: 'FIXED_WORK' }); + eq('m2 ongewijzigde regel via updateTask: wel een (lege) stap zoals elke updateTask, geen getal veranderd', [asgOf(t, r1).unitsPerDay, task(t).time.scheduleDuration], [1, 4]); + void events0; +} + console.log(`\n${checks} checks, ${diffs.length} afwijking(en)`); if (diffs.length > 0) { for (const d of diffs) console.log(`XX ${d}`); From 05aacd92079b3e6e3984dc98a5b59b836d7cf40a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 16:14:00 +0000 Subject: [PATCH 146/152] =?UTF-8?q?feat(taaktypes):=20stap=205=20=E2=80=94?= =?UTF-8?q?=20instelling=20"Toon=20taaktypes",=20documentontsluiting,=20pa?= =?UTF-8?q?neel/dialoog,=20raster,=20i18n=20en=20gids?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zichtbaarheid (spec §7): instelling `ui.showTaskTypes` (`ops-showTaskTypes`, settingsRegistry + saveShowTaskTypes + SettingsPanelContent, default uit) óf documentontsluiting: `taskTypesVisible` in DOCUMENT_FIELDS (rol none, bij laden afgeleid via `hasTaskTypeData` — taak met workRule/mspTaskType/p6DurationType, projectstandaard, of toewijzing met werkveld — en gezet door setTaskWorkRule, setAssignmentWork en de rasterbewerkingen), met één melding per document (`taskTypesNotice.ts`, notifications.taskTypesUnlocked, helpArticleId → gids-taaktypes). Selector `taskTypesUnlocked` (engine/work/taskTypesVisibility). UI: `TaskWorkRuleField` in eigenschappenpaneel en taakdialoog (keuzelijst met projectstandaard, "Beschermd: …" in gewone woorden, MSP-bijschrift 8-B); in `TaskAssignmentsSection` de kolom Werk (rest) in uren met slotjes op de beschermde hoek en '—' voor materiaal; raster: `task.workRule` als bewerkbare enum (typewissel legt via gridTransaction het werk vast) en `assignment.remainingWork` als tokens-kolom "naam: uren" via de assignment-set- transactie en `settleWorkEdit`; beide alleen `available` wanneer ontsloten (`TaskColumnContext.taskTypesUnlocked`, ook in de gridtransactie-context). `task.workRule` leest in `readOnly` alleen gecertificeerde controllers en staat in de Aanbeveling-4-pin van check-grid-transaction; de plakbenchmark filtert op `available` zoals de echte adapter. i18n in 14 locales (workRule-namen, beschermingsteksten, instelling + hint, melding, kolomvalidatie); gids `gids-taaktypes` nl+en + manifest (README-telling 34); spec §10, TODO en CLAUDE.md bijgewerkt. Tests: check-work-rule-store sectie (n) (110 checks), browserspec tests/browser/work-rule.spec.ts (keuzelijst, werk typen, inzet, undo, ontsluiting; 3/3 groen), planning-/bibliotheek-/MCP-suites groen, verify:i18n, verify:docs, verify:examples groen. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0159unb6VzxUx7WG4w3a2ARA --- CLAUDE.md | 9 +- README.md | 2 +- docs/TODO.md | 9 +- ...26-09-04-spec-taaktypes-opgeslagen-werk.md | 6 +- public/docs/en/gids-taaktypes.md | 34 +++++++ public/docs/manifest.json | 20 ++++ public/docs/nl/gids-taaktypes.md | 34 +++++++ src/components/dialogs/TaskDialog.tsx | 6 ++ src/components/panels/TaskPropertiesPanel.tsx | 3 + .../settings/SettingsPanelContent.tsx | 20 +++- src/components/task-grid/FullTaskGrid.tsx | 6 +- .../task-sections/TaskAssignmentsSection.tsx | 47 +++++++++- .../task-sections/TaskWorkRuleField.tsx | 55 +++++++++++ src/engine/taskGrid/fieldCoverage.ts | 2 +- src/engine/taskGrid/taskColumnRegistry.ts | 91 +++++++++++++++++- src/engine/taskGrid/taskEditPlan.ts | 9 +- src/engine/taskGrid/taskGridAdapter.ts | 3 + src/engine/work/taskTypesVisibility.ts | 29 ++++++ src/i18n/locales/ar/common.json | 3 + src/i18n/locales/ar/task.json | 22 ++++- src/i18n/locales/de/common.json | 3 + src/i18n/locales/de/task.json | 22 ++++- src/i18n/locales/en/common.json | 3 + src/i18n/locales/en/task.json | 22 ++++- src/i18n/locales/es/common.json | 3 + src/i18n/locales/es/task.json | 22 ++++- src/i18n/locales/fa/common.json | 3 + src/i18n/locales/fa/task.json | 22 ++++- src/i18n/locales/fr/common.json | 3 + src/i18n/locales/fr/task.json | 22 ++++- src/i18n/locales/it/common.json | 3 + src/i18n/locales/it/task.json | 22 ++++- src/i18n/locales/ja/common.json | 3 + src/i18n/locales/ja/task.json | 22 ++++- src/i18n/locales/ko/common.json | 3 + src/i18n/locales/ko/task.json | 22 ++++- src/i18n/locales/nl/common.json | 3 + src/i18n/locales/nl/task.json | 22 ++++- src/i18n/locales/pl/common.json | 3 + src/i18n/locales/pl/task.json | 22 ++++- src/i18n/locales/pt/common.json | 3 + src/i18n/locales/pt/task.json | 22 ++++- src/i18n/locales/tr/common.json | 3 + src/i18n/locales/tr/task.json | 22 ++++- src/i18n/locales/zh/common.json | 3 + src/i18n/locales/zh/task.json | 22 ++++- src/state/documentContract.ts | 9 ++ src/state/gridTransaction.ts | 35 ++++++- src/state/slices/documentSlice.ts | 1 + src/state/slices/fileSlice.ts | 7 ++ src/state/slices/projectSlice.ts | 3 + src/state/slices/resourceSlice.ts | 1 + src/state/slices/taskSlice.ts | 1 + src/state/slices/types.ts | 9 +- src/state/slices/uiSlice.ts | 1 + src/state/taskTypesNotice.ts | 34 +++++++ src/types/taskGrid.ts | 6 ++ src/utils/settingsRegistry.ts | 1 + src/utils/settingsStore.ts | 5 + tests/browser/work-rule.spec.ts | 94 +++++++++++++++++++ tests/planning/check-grid-transaction.ts | 3 + tests/planning/check-work-rule-store.ts | 59 ++++++++++++ tests/planning/taskGridPerformanceHarness.ts | 5 + 63 files changed, 975 insertions(+), 34 deletions(-) create mode 100644 public/docs/en/gids-taaktypes.md create mode 100644 public/docs/nl/gids-taaktypes.md create mode 100644 src/components/task-sections/TaskWorkRuleField.tsx create mode 100644 src/engine/work/taskTypesVisibility.ts create mode 100644 src/state/taskTypesNotice.ts create mode 100644 tests/browser/work-rule.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index 654f27681..e15d69f45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -143,7 +143,14 @@ de duur nooit; kalenders blijven buiten de driehoek (open punt K2 in de TODO). R `work-triangle-cases.json`), `check-work-rule-mapping.ts` (MSP/P6/XER-vertaling) en `check-work-rule-store.ts` (store/raster/MCP). Via de MCP-bridge: `planner_update_tasks`/`planner_add_tasks` `fields.workRule`, `planner_manage_assignments` `update.remainingWorkMinutes` en `planner_update_project` -`defaultWorkRule` (`tests/mcp/cases-work-rule.ts`). `src/services/contourIo.ts` is de adapterlaag: +`defaultWorkRule` (`tests/mcp/cases-work-rule.ts`). UI: zichtbaar wanneer de instelling **Toon +taaktypes** (`ui.showTaskTypes`, `ops-showTaskTypes`, default uit) aan staat óf het document zelf +taaktypedata draagt (`taskTypesVisible` in `DOCUMENT_FIELDS`, afgeleid bij laden via +`hasTaskTypeData`, met één melding per document — `taskTypesNotice.ts`); selector `taskTypesUnlocked` +(`src/engine/work/taskTypesVisibility.ts`). Dan: `TaskWorkRuleField` in paneel en dialoog, de kolom +**Werk (rest)** met slotjes in `TaskAssignmentsSection`, en de rasterkolommen `task.workRule` en +`assignment.remainingWork` (alleen `available` wanneer ontsloten; `TaskColumnContext.taskTypesUnlocked`). +Gids: `public/docs/{nl,en}/gids-taaktypes.md`; browserspec `tests/browser/work-rule.spec.ts`. `src/services/contourIo.ts` is de adapterlaag: MSPDI `` (Type 1/2, per werkdag) en P6 `` + `` + de `PlannedCurve`/`RemainingCurve`/`ActualCurve`-spreidingsstrings (`"werkuren:periodeuren;…"`, MPXJ `TimephasedHelper`) round-trippen daar doorheen — let op: P6's `` is dus GEEN diff --git a/README.md b/README.md index d305f33bc..7bed101be 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ src/ extensions/ # Extensiesysteem (types, api, loader, service) i18n/ # Vertalingen, 14 talen × 4 namespaces hooks/ types/ utils/ styles/ -public/docs/ # In-app handleiding: 33 artikelen × 14 talen (voedt ook de wiki) +public/docs/ # In-app handleiding: 34 artikelen × 14 talen (voedt ook de wiki) examples/ # Voorbeeldplanningen in IFC tests/ # planning · library · mcp · dev-server · browser src-tauri/ # De Rust-schil (dun: precies één native command) diff --git a/docs/TODO.md b/docs/TODO.md index 395c90454..00a0a03a4 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -355,8 +355,9 @@ deze lijst verwijderd — wat klaar is, staat in de changelog en git-historie. > Ontwerp: `docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md` (opvolger van de > spec van 2026-08-18). Bouwt op de branch `claude/contour-engine-planner-mnrsy3` (PR #101), die -> gestapeld is op de XER-branch en pas ná die PR merget. Stappen 1–4, 7 (+ de store-kant van 6) staan -> erin; 5 (UI) en 8 (docs) nog niet — zie spec §10 voor de stand per stap. +> gestapeld is op de XER-branch en pas ná die PR merget. Stappen 1–7 staan erin; 8 (afronding docs) +> volgt — zie spec §10 voor de stand per stap. De gids `gids-taaktypes` bestaat in nl+en; de +> twaalf vertalingen volgen in de maandelijkse ronde. > Eigenaarsbesluiten 1–7 (2026-09-04) en 8–10 (2026-09-05) staan daar in §3. - [ ] **Duurbewerking op een taak met expliciete `remainingTime`/`remainingMinutes` (voortgangsroute).** @@ -399,8 +400,8 @@ deze lijst verwijderd — wat klaar is, staat in de changelog en git-historie. - [ ] **Nivelleerder-optie "inzet verlagen bij vast werk"** (eigenaarsbesluit 7-B, 2026-09-04) als geavanceerde optie naast het verschuiven; de verdeler raakt nu nooit inzet of werk. - [ ] **% werk gereed** (MSP % Work Complete) naast de duurgebaseerde `completion`. -- [ ] **Projectstandaard-werkregel in de UI** (projectwizard/projectinfo); het veld zelf komt in - bouwstap 1. +- [ ] **Projectstandaard-werkregel in de UI** (projectwizard/projectinfo); het veld bestaat sinds + bouwstap 1 en is via `planner_update_project` (`defaultWorkRule`) zetbaar; de gids noemt dat. - [ ] **P6-optie "preserve existing assignments"** bij resource erbij: OPS volgt altijd de synchronisatietabel ("recalculate"); de preserve-variant is een instelling voor later. - [ ] **Uur-modus-dagslot is een benadering.** De engine deelt de as in slots van `hoursPerDay × 60`; diff --git a/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md b/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md index c4b6a89a2..4c8222738 100644 --- a/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md +++ b/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md @@ -3,9 +3,9 @@ *Ontwerp, 2026-09-04. Opvolger van het voorstel [`2026-08-18-spec-taaktypes-effort-driven.md`](2026-08-18-spec-taaktypes-effort-driven.md) ("ontwerp vóór bouw"). Status: **in aanbouw op de branch `claude/contour-engine-planner-mnrsy3` -(PR #101, gestapeld op de XER-branch): stappen 1, 2, 3, 4 en 7 zijn gebouwd (2026-09-05); stap 6 is +(PR #101, gestapeld op de XER-branch): stappen 1 t/m 7 zijn gebouwd (2026-09-05); stap 6 is daarin voor de store/raster/MCP-kant meegenomen (resource erbij/eraf + contourhoogte, besluit 3); -de stappen 5 (UI) en 8 (afronding docs) nog niet.** +stap 8 (afronding docs) volgt.** De contour-engine, de contour-UI en de fasen-editor (2026-09) zijn geleverd en worden hier als bestaand fundament gebruikt.* @@ -609,7 +609,7 @@ twee keer aan `formatRegistry.ts`, `ifcPsets.ts` en de contour-adapters wordt ge | 2 | Importvertaling (§4.2) voor .mpp, MSPDI (incl. ``/`` lezen), P6 XML, XER; export MSPDI/P6 XML met de werkvelden; waarschuwingen | fidelity-poort blijft 0 (`GOAL_ZERO_DEVIATIONS`); `check-mpp-*`, `check-adapters-*`; nieuwe fixtures per bron | | 3 **(gebouwd 2026-09-05)** | Pure module `src/engine/work/workTriangle.ts`: `applyDurationEdit`, `applyUnitsEdit`, `applyWorkEdit`, `applyTaskWorkEdit`, `applyAssignmentAdded`, `applyAssignmentRemoved`, `applyRuleChange` — invoer: de RESTERENDE toestand (`TriangleState`: regel, bewaard `effortDriven`, restduur in werkminuten, per toewijzing inzet + optioneel restwerk + `drivesDuration`), uitvoer: een nieuwe toestand of een weigering met reden; nooit een store. `WorkRule` in `src/types/workRule.ts` (verhuist in stap 1 naar `task.ts`). Plus `work-triangle-cases.json` (§9) | `tests/planning/check-work-triangle.ts`: 332 checks, 39 cases (21 documented, 16 reasoned, 2 decided, 0 measured; bij een mengvorm telt het zwakste bewijs) plus eigenschappen: geen veld geschreven onder de inzetregels, typewissel in alle 16 richtingen getalvrij, weigering bij restduur ≤ 0 of niet-eindige inzet (§6.7), `effortDriven` zonder effect buiten de twee 8-B-cellen | | 4 **(gebouwd 2026-09-05)** | Brug `src/engine/work/workRuleApply.ts` (`captureTriangle` → kernstap → `applyTriangleResult`; `settleDurationEdit`/`settleUnitsEdit`/`planWorkEdit`+`commitTrianglePlan`/`settleAssignmentAdded`/`settleAssignmentRemoved`/`settleAssignmentPlan`/`settleRuleChange`; `contourKeepsWork`; `reconcileContourWork` = besluit 3 "vorm blijft, hoogte zakt"). Bedraad in `taskSlice.updateTask` + nieuw `setTaskWorkRule`, `resourceSlice.assignResource`/`updateAssignment`/`unassignResource` + nieuw `setAssignmentWork`, `gridTransaction.ts` (celduur én de assignment-set-cel, via `AssignmentSettleOp`), `createMcpTransactions.ts` (alle tweelingen + `setAssignmentWork`/`setTaskWorkRule` op de draft), `rescaleTaskContours(…, keepWork)` uit de effectieve regel, `assignmentDayUnits` derde bron = opgeslagen werk (§6.1). Een duur die uit de driehoek komt zet `scheduleStale`, herschaalt contour + importsplits en wist het Z8-venster — precies als een duurbewerking; onder FIXED_DURATION_RATE zonder werkvelden byte-identiek | `tests/planning/check-work-rule-store.ts` (95 checks: store, raster, MCP, vierde bron, meetlat 22/23/28, voortgang > 0, uurmodus, FIXED_RATE + 8-B, materiaal in het raster, datums-zoals-opgeslagen, verplaatsen/resource verwijderen), bestaande planning- en MCP-suites groen | -| 5 | UI: instelling, auto-ontsluiting + melding, paneel/dialoog, rasterkolommen, i18n 14 locales, gidsen nl+en (+12 vertalingen, anders faalt `verify:docs` niet maar is de functie onvindbaar) | `verify:i18n`, `verify:docs`, browserspec `tests/browser/work-rule.spec.ts` (echte toetsen/klikken, store-asserties) | +| 5 **(gebouwd 2026-09-05)** | Instelling `ui.showTaskTypes` (`ops-showTaskTypes`, `settingsRegistry` + `saveShowTaskTypes` + `SettingsPanelContent`, tabblad Tijdlijn); documentveld `taskTypesVisible` (`DOCUMENT_FIELDS`, rol `none`, afgeleid in `payloadFromImport` via `hasTaskTypeData` — taak met `workRule`/`mspTaskType`/`p6DurationType`, projectstandaard, of toewijzing met werkveld — en gezet door `setTaskWorkRule`/`setAssignmentWork`/rasterbewerkingen); één melding per document (`taskTypesNotice.ts`, `notifications.taskTypesUnlocked`, `helpArticleId` → `gids-taaktypes`); selector `taskTypesUnlocked`. UI: `TaskWorkRuleField` (paneel + dialoog: keuzelijst met projectstandaard, "Beschermd: …", MSP-bijschrift 8-B), kolom **Werk (rest)** + slotjes in `TaskAssignmentsSection`, raster `task.workRule` (bewerkbare enum, `available` alleen ontsloten; typewissel legt werk vast in `gridTransaction.ts`) en `assignment.remainingWork` (tokens `naam: uren`, assignment-set via `settleWorkEdit`), `TaskColumnContext.taskTypesUnlocked`; i18n 14 locales; gids `gids-taaktypes` nl+en (12 vertalingen volgen maandelijks) | `verify:i18n`, `verify:docs`, `check-work-rule-store` sectie (n), browserspec `tests/browser/work-rule.spec.ts` (keuzelijst, werk typen, inzet, undo, ontsluiting) | | 6 | Resource erbij/eraf (§5 rij 4–5) inclusief contourregels (§6.3) — **vereist beslispunt 8** (genomen: 8-B). **Store/raster/MCP-kant gebouwd in stap 4** (`settleAssignmentAdded`/`Removed`, `reconcileContourWork`); wat rest is de UI-kant (stap 5) | cases 4, 5, 9, 10, 15, 19, 20, 23, 29, 30 (kern) + `check-work-rule-store` (b15–b21, d5–d9, e6–e7, f3–f4) | | 7 **(gebouwd 2026-09-05)** | Geen nieuwe tool (39 blijft 39) maar drie bestaande tools uitgebreid: `planner_update_tasks`/`planner_add_tasks` `fields.workRule` (enum \| null, via `draft.setTaskWorkRule` ná de duurpatch — een gelijktijdige `duration` wordt onder de OUDE regel verwerkt, `taskFields.ts`), `planner_manage_assignments` `update.remainingWorkMinutes` (> 0, via `draft.setAssignmentWork`; alleen op taken waar `workRuleApplies`), `planner_update_project` `defaultWorkRule` (enum \| null, `delete` bij null); `planner_get_project_info` toont `defaultWorkRule`, `planner_get_task` toonde `workRule` + werkvelden al sinds stap 1. `REJECT_HINTS` voor `plannedWorkMinutes`/`actualWorkMinutes`/`remainingWorkMinutes` als taakveld | `tests/mcp/cases-work-rule.ts` (9 cases via de echte dispatch, incl. `planner_batch` = één undo-stap), `cases-toolregistry`/`cases-schemavalidatie` groen | | 8 | CLAUDE.md-sectie, TODO-afvinking, `docs/superpowers/README.md` | `verify:docs` | diff --git a/public/docs/en/gids-taaktypes.md b/public/docs/en/gids-taaktypes.md new file mode 100644 index 000000000..aa03beeff --- /dev/null +++ b/public/docs/en/gids-taaktypes.md @@ -0,0 +1,34 @@ +# Task types and work: fixed duration, fixed work or fixed units + +A task with resources has three numbers that belong together: the **remaining duration** (how many working days are left), the **units** per resource (units per working day, 1 = one person full-time) and the **work** (hours). Work = remaining duration × units. Change one of them and another must move. Which one moves is decided by the task's **work rule** — MS Project calls it the *task type* plus *effort-driven*, Primavera P6 the *duration type*. + +## Making it visible + +By default Open Planner Studio keeps duration and units and lets the work follow — exactly how the app has always scheduled. The work rule and the remaining work are then hidden. + +- **Setting**: turn on *Show task types (work rules)* under Settings (⚙, the Settings tab or Backstage → Settings). The work rule then appears in the properties panel and the task dialog, the *Work (rem.)* column in the assignment table, and the *Work rule* and *Remaining work* columns in the grid's column picker. +- **Automatic**: when you open a file that already contains task types (an `.mpp`, MSPDI, P6 or XER file with task types, or a work rule set earlier in this app), those controls are shown for that document regardless of the setting. The app tells you once. + +## The four work rules + +- **Fixed duration and units** (default; MS Project *Fixed Duration*, not effort-driven; P6 *Fixed Duration & Units/Time*): duration and units stay, work follows. Adding a resource does not change the duration. +- **Fixed duration and work** (P6 *Fixed Duration & Units*): duration and work stay, units follow. A second resource shares the work and lowers everyone's units. +- **Fixed work** (MS Project *Fixed Work*; P6 *Fixed Units*): the work stays. More units, or an extra resource, shortens the task; removing a resource lengthens it. +- **Fixed units** (MS Project *Fixed Units*, effort-driven; P6 *Fixed Units/Time*): the units stay. More work lengthens the task; an extra resource shares the work and shortens it. + +Switching the rule alone changes no number. Below the list the panel says in plain words what the chosen rule protects, and in the assignment table the protected column carries a lock. + +## Entering work + +In the assignment table the *Work (rem.)* column shows the remaining work in hours: stored work from the file, or otherwise remaining duration × units. Type a new number and the work rule decides what moves: under *Fixed work* or *Fixed units* the task gets longer or shorter (the schedule is then stale until you recalculate), under the two fixed-duration rules the units change. Material resources never drive the duration. + +In the grid the *Work rule* (list) and *Remaining work* (`name: hours; name: hours`) columns work the same way, also when pasting across several tasks. + +## Good to know + +- The rule works on the **remaining** part of a started task: actual duration and actual work never move. +- A day task keeps whole days: if work ÷ units yields half a day, the duration is rounded up and the work stays exact. +- Adding or removing a resource, also via *Move to…* or deleting a resource, follows the same rule. +- Every edit is one undo step. +- The project default work rule (for tasks without their own choice) can be set through the AI assistant; a UI for it will follow. +- Milestones, summary tasks, hammocks and elapsed-time tasks have no work rule. diff --git a/public/docs/manifest.json b/public/docs/manifest.json index bf22bb62c..f31fca9aa 100644 --- a/public/docs/manifest.json +++ b/public/docs/manifest.json @@ -210,6 +210,26 @@ "layer": "gidsen", "cluster": "import-export" }, + { + "id": "gids-taaktypes", + "title": { + "nl": "Taaktypes en werk", + "en": "Task types and work", + "de": "Vorgangsarten und Arbeit", + "fr": "Types de tâche et travail", + "es": "Tipos de tarea y trabajo", + "zh": "任务类型与工时", + "it": "Tipi di attività e lavoro", + "pt": "Tipos de tarefa e trabalho", + "pl": "Typy zadań i praca", + "tr": "Görev türleri ve iş", + "ar": "أنواع المهام والعمل", + "ja": "タスクの種類と作業", + "ko": "작업 유형과 작업량", + "fa": "انواع کار و کار" + }, + "layer": "gidsen" + }, { "id": "gids-msproject-import", "title": { diff --git a/public/docs/nl/gids-taaktypes.md b/public/docs/nl/gids-taaktypes.md new file mode 100644 index 000000000..85142d798 --- /dev/null +++ b/public/docs/nl/gids-taaktypes.md @@ -0,0 +1,34 @@ +# Taaktypes en werk: vaste duur, vast werk of vaste inzet + +Een taak met resources heeft drie getallen die bij elkaar horen: de **restduur** (hoeveel werkdagen er nog zijn), de **inzet** per resource (eenheden per werkdag, 1 = één persoon voltijds) en het **werk** (uren). Werk = restduur × inzet. Verandert er één, dan moet een ander getal meebewegen. Welk getal dat is, bepaalt de **werkregel** van de taak — in MS Project heet dat het *taaktype* plus *effort-driven*, in Primavera P6 het *duration type*. + +## Zichtbaar maken + +Standaard houdt Open Planner Studio duur en inzet vast en volgt het werk — precies zoals de app altijd al plande. De werkregel en het resterende werk worden dan niet getoond. + +- **Instelling**: zet *Toon taaktypes (werkregels)* aan onder Instellingen (⚙, het tabblad Instellingen of Backstage → Instellingen). Dan verschijnen de werkregel in het eigenschappenpaneel en de taakdialoog, de kolom *Werk (rest)* in de toewijzingstabel en de kolommen *Werkregel* en *Resterend werk* in de kolomkiezer van het raster. +- **Automatisch**: opent u een bestand dat al taaktypes bevat (een `.mpp`, MSPDI-, P6- of XER-bestand met taaktypes, of een eerder in deze app gezette werkregel), dan zijn die bedieningselementen voor dát document zichtbaar, ongeacht de instelling. De app meldt dat één keer. + +## De vier werkregels + +- **Vaste duur en inzet** (standaard; MS Project *Fixed Duration*, niet effort-driven; P6 *Fixed Duration & Units/Time*): duur en inzet blijven staan, het werk volgt. Een resource erbij verandert de duur niet. +- **Vaste duur en werk** (P6 *Fixed Duration & Units*): duur en werk blijven staan, de inzet volgt. Een tweede resource verdeelt het werk en verlaagt ieders inzet. +- **Vast werk** (MS Project *Fixed Work*; P6 *Fixed Units*): het werk blijft staan. Meer inzet, of een resource erbij, maakt de taak korter; een resource eraf maakt haar langer. +- **Vaste inzet** (MS Project *Fixed Units*, effort-driven; P6 *Fixed Units/Time*): de inzet blijft staan. Meer werk maakt de taak langer; een resource erbij verdeelt het werk en maakt haar korter. + +Alleen de regel wisselen verandert geen enkel getal. Onder de keuzelijst staat in gewone woorden wat de gekozen regel beschermt, en in de toewijzingstabel draagt de beschermde kolom een slotje. + +## Werk invoeren + +In de toewijzingstabel toont de kolom *Werk (rest)* het resterende werk in uren: opgeslagen werk uit het bestand, of anders restduur × inzet. Typ een nieuw getal en de werkregel bepaalt wat meebeweegt: onder *Vast werk* of *Vaste inzet* wordt de taak langer of korter (de planning is dan verouderd tot u opnieuw berekent), onder de twee vaste-duur-regels verandert de inzet. Materiaalresources tellen niet mee voor de duur. + +In het raster werken de kolommen *Werkregel* (keuzelijst) en *Resterend werk* (`naam: uren; naam: uren`) op dezelfde manier, ook bij plakken over meerdere taken. + +## Wat u moet weten + +- De regel werkt op het **resterende** deel van een gestarte taak: verrichte duur en verricht werk bewegen nooit. +- Een dagtaak houdt hele dagen: levert werk ÷ inzet een halve dag op, dan wordt de duur naar boven afgerond en blijft het werk exact staan. +- Een resource erbij of eraf, ook via *Verplaats naar…* of het verwijderen van een resource, volgt dezelfde regel. +- Elke bewerking is één stap ongedaan te maken. +- De projectstandaard-werkregel (voor taken zonder eigen keuze) is via de AI-assistent te zetten; een UI daarvoor volgt. +- Mijlpalen, verzameltaken, hangmatten en taken op doorlooptijd hebben geen werkregel. diff --git a/src/components/dialogs/TaskDialog.tsx b/src/components/dialogs/TaskDialog.tsx index 35a564f21..6e19d53fd 100644 --- a/src/components/dialogs/TaskDialog.tsx +++ b/src/components/dialogs/TaskDialog.tsx @@ -19,6 +19,7 @@ import { TaskProgressFields } from '@/components/task-sections/TaskProgressField import { TaskCpmResultSection } from '@/components/task-sections/TaskCpmResultSection'; import { TaskDependenciesSection } from '@/components/task-sections/TaskDependenciesSection'; import { TaskAssignmentsSection } from '@/components/task-sections/TaskAssignmentsSection'; +import { TaskWorkRuleField } from '@/components/task-sections/TaskWorkRuleField'; import { TaskCodesFieldsSection } from '@/components/task-sections/TaskCodesFieldsSection'; import { getPersonalTaskTypes } from '@/services/taskTypes/personalTaskTypes'; import { TaskDurationField } from '@/components/task-sections/TaskDurationField'; @@ -160,6 +161,7 @@ export function TaskDialog() { constraint2: draft.constraint2, deadline: draft.deadline, notes: draft.notes, + workRule: draft.workRule, time, }); // QA-fix P1 (fase 2.10, onderdeel 2): een gewijzigde ouder gaat via `moveTask` — die @@ -178,6 +180,7 @@ export function TaskDialog() { wbsCode: draft.wbsCode, taskType: draft.taskType, customTaskTypeId: draft.customTaskTypeId, + workRule: draft.workRule, isMilestone: draft.isMilestone, parentId: draft.parentId || null, calendarId: draft.calendarId, @@ -297,6 +300,9 @@ export function TaskDialog() {
+ {/* Taaktypes-etappe (spec §7): zelfde veld als het paneel; commit op Opslaan via `workRule` + in de updateTask-/addTask-patch (de store legt het werk vast, K1). */} + diff --git a/src/components/panels/TaskPropertiesPanel.tsx b/src/components/panels/TaskPropertiesPanel.tsx index d10b18c30..2d9cb227d 100644 --- a/src/components/panels/TaskPropertiesPanel.tsx +++ b/src/components/panels/TaskPropertiesPanel.tsx @@ -6,6 +6,7 @@ import { TaskBasicFields } from '@/components/task-sections/TaskBasicFields'; import { TaskNotesFields } from '@/components/task-sections/TaskNotesFields'; import { TaskMilestoneFields } from '@/components/task-sections/TaskMilestoneFields'; import { TaskTimeFields } from '@/components/task-sections/TaskTimeFields'; +import { TaskWorkRuleField } from '@/components/task-sections/TaskWorkRuleField'; import { TaskFreePeriodWarning } from '@/components/task-sections/TaskFreePeriodWarning'; import { TaskTimephasedNotice } from '@/components/task-sections/TaskTimephasedNotice'; import { TaskHammockFields } from '@/components/task-sections/TaskHammockFields'; @@ -100,6 +101,8 @@ export function TaskPropertiesPanel() { + + diff --git a/src/components/settings/SettingsPanelContent.tsx b/src/components/settings/SettingsPanelContent.tsx index f4b496291..21d870391 100644 --- a/src/components/settings/SettingsPanelContent.tsx +++ b/src/components/settings/SettingsPanelContent.tsx @@ -4,7 +4,7 @@ import { useAppStore } from '@/state/appStore'; import { useResolvedUITheme } from '@/hooks/useResolvedUITheme'; import { Locale, LANGUAGE_LABELS, supportedLanguages, setLocale } from '@/i18n/config'; import { UITheme, ResolvedUITheme, UI_THEMES, DocumentChromeStyle, DateNotation, DurationDisplay, BarSplitMode, UIFontFamily, UI_FONT_FAMILIES, UI_FONT_SCALES } from '@/state/slices/types'; -import { saveLocale, saveTheme, saveZoomSettings, saveDebugTerminalEnabled, saveDocumentChromeStyle, saveAutoCalcCPM, saveConstructionMode, saveDateNotation, saveEnableHourPlanning, saveAllowMixedDayHour, saveDurationDisplay, saveBarSplitMode, saveCompressNonWorkdays, saveUIFontFamily, saveUIFontScale, saveAiAutostart } from '@/utils/settingsStore'; +import { saveLocale, saveTheme, saveZoomSettings, saveDebugTerminalEnabled, saveDocumentChromeStyle, saveAutoCalcCPM, saveConstructionMode, saveDateNotation, saveEnableHourPlanning, saveShowTaskTypes, saveAllowMixedDayHour, saveDurationDisplay, saveBarSplitMode, saveCompressNonWorkdays, saveUIFontFamily, saveUIFontScale, saveAiAutostart } from '@/utils/settingsStore'; import { applyAiModeLive } from '@/services/mcp/server'; import { isTauri } from '@/utils/platform'; import { Select } from '@/components/common/Select'; @@ -63,6 +63,7 @@ export function SettingsPanelContent() { const constructionMode = useAppStore(s => s.ui.constructionMode); const dateNotation = useAppStore(s => s.ui.dateNotation); const enableHourPlanning = useAppStore(s => s.ui.enableHourPlanning); + const showTaskTypes = useAppStore(s => s.ui.showTaskTypes); const allowMixedDayHour = useAppStore(s => s.ui.allowMixedDayHour); const durationDisplay = useAppStore(s => s.ui.durationDisplay); const barSplitMode = useAppStore(s => s.ui.barSplitMode); @@ -138,6 +139,11 @@ export function SettingsPanelContent() { setUI({ enableHourPlanning: value }); void saveEnableHourPlanning(value); }; + // Taaktypes-etappe (spec §7): "Toon taaktypes" — zichtbaarheid van werkregel en resterend werk. + const applyShowTaskTypes = (value: boolean) => { + setUI({ showTaskTypes: value }); + void saveShowTaskTypes(value); + }; const applyAllowMixedDayHour = (value: boolean) => { setUI({ allowMixedDayHour: value }); @@ -355,6 +361,18 @@ export function SettingsPanelContent() { )} +
+ +

{t('settings.showTaskTypesHint')}

+

{t('settings.durationDisplay')}

) => void; +}) { + const { t } = useTranslation('task'); + const unlocked = useAppStore(s => taskTypesUnlocked(s)); + const defaultRule = useAppStore(s => s.project.defaultWorkRule); + if (!unlocked || !workRuleApplies(task)) return null; + const effective = effectiveWorkRule(task, defaultRule); + const effortDriven = effectiveEffortDriven(task); + return ( + + + + + {t('workRule.protects', { what: t(`workRule.protects${effective}`) })} + + {effortDriven !== undefined && ( + + {t(effortDriven ? 'workRule.mspEffortDriven' : 'workRule.mspNotEffortDriven')} + + )} + + ); +} diff --git a/src/engine/taskGrid/fieldCoverage.ts b/src/engine/taskGrid/fieldCoverage.ts index 7ff252b47..a139aa77c 100644 --- a/src/engine/taskGrid/fieldCoverage.ts +++ b/src/engine/taskGrid/fieldCoverage.ts @@ -69,7 +69,7 @@ export const TASK_FIELD_COVERAGE = { manuallyScheduled: field('technical', 'task.manuallyScheduled'), mspTaskType: field('technical', 'task.mspTaskType'), effortDriven: field('technical', 'task.effortDriven'), - workRule: field('technical', 'task.workRule'), + workRule: field('direct', 'task.workRule'), // XER/Primavera-herkomst: acht bronvelden, één gebundelde technische kolom (zie registry). p6DurationType: field('technical', 'task.p6Provenance'), p6ActivityType: field('technical', 'task.p6Provenance'), diff --git a/src/engine/taskGrid/taskColumnRegistry.ts b/src/engine/taskGrid/taskColumnRegistry.ts index 86d0a802a..1f0bec2cc 100644 --- a/src/engine/taskGrid/taskColumnRegistry.ts +++ b/src/engine/taskGrid/taskColumnRegistry.ts @@ -1,3 +1,5 @@ +import { WORK_RULES } from '@/types/workRule'; +import { remainingMinutesOf } from '@/engine/work/workRuleApply'; import type { Baseline, BaselineTask } from '@/types/baseline'; import type { ResourceAssignment, ResourceCurve } from '@/types/resource'; import type { ActivityCodeType, CustomFieldDef, CustomFieldValue } from '@/types/structure'; @@ -545,6 +547,61 @@ const parseAssignmentUnits: Parser = (text, task, ctx) => { return success(result); }; +/** Taaktypes-etappe: tokens mét resterend werk (opgeslagen, anders afgeleid als restduur × inzet). */ +function assignmentWorkTokens(task: Task, ctx: TaskColumnContext): TaskAssignmentToken[] { + const hoursPerDay = ctx.effectiveHoursPerDay?.(task) ?? 8; + const remaining = remainingMinutesOf(task, { hoursPerDay }); + return assignments(task, ctx).map(assignment => ({ + assignmentId: assignment.id, + resourceId: assignment.resourceId, + unitsPerDay: assignment.unitsPerDay, + curve: assignment.curve, + remainingWorkMinutes: assignment.remainingWorkMinutes ?? remaining * assignment.unitsPerDay, + })); +} + +function assignmentById(task: Task, ctx: TaskColumnContext, token: TaskAssignmentToken): ResourceAssignment { + return assignments(task, ctx).find(item => item.id === token.assignmentId) + ?? { id: token.assignmentId ?? '', taskId: task.id, resourceId: token.resourceId, unitsPerDay: token.unitsPerDay }; +} + +function workHoursText(minutes: number | undefined): string { + if (minutes === undefined || !Number.isFinite(minutes)) return '—'; + return String(Math.round((minutes / 60) * 100) / 100); +} + +const parseAssignmentWork: Parser = (text, task, ctx) => { + const structured = structuredClipboardPayload(text, ASSIGNMENT_CLIPBOARD_MARKER); + if (!structured.ok || structured.value !== undefined) return structured; + const pairs = assignmentPairs(text); + if (pairs.length === 0) return failure('assignmentWork', text); + const result = assignmentWorkTokens(task, ctx); + const tokenByAssignmentId = new Map(result.map(token => [token.assignmentId, token] as const)); + const seen = new Set(); + for (const pair of pairs) { + const separator = pair.lastIndexOf(':'); + if (separator <= 0) return failure('assignmentWork', pair); + const resolved = resolveAssignment(pair.slice(0, separator).trim(), task, ctx); + const hours = Number(pair.slice(separator + 1).trim().replace(',', '.')); + if (!resolved.ok) return resolved; + if (!Number.isFinite(hours) || hours <= 0) return failure('assignmentWork', pair); + if (seen.has(resolved.value.id)) return failure('assignmentDuplicateId', resolved.value.id); + seen.add(resolved.value.id); + tokenByAssignmentId.get(resolved.value.id)!.remainingWorkMinutes = Math.round(hours * 60); + } + return success(result); +}; + +function validateAssignmentWorkTokens(value: unknown, task: Task, ctx: TaskColumnContext): GridResult { + const base = validateAssignmentTokens(value, task, ctx, 'assignmentWork'); + if (!base.ok) return base; + for (const raw of value as readonly Partial[]) { + const w = raw.remainingWorkMinutes; + if (w !== undefined && (typeof w !== 'number' || !Number.isFinite(w) || w <= 0)) return failure('assignmentWork', raw); + } + return base; +} + const parseAssignmentCurves: Parser = (text, task, ctx) => { const structured = structuredClipboardPayload(text, ASSIGNMENT_CLIPBOARD_MARKER); if (!structured.ok || structured.value !== undefined) return structured; @@ -623,7 +680,12 @@ function fixedTaskColumns(input: TaskColumnRegistryInput): TaskColumnDescriptor[ readonlyColumn({ id: 'task.mspTaskType', labelKey: 'taskGrid.columns.mspTaskType', category: 'technical', valueKind: 'enum', read: task => task.mspTaskType }), readonlyColumn({ id: 'task.effortDriven', labelKey: 'taskGrid.columns.effortDriven', category: 'technical', valueKind: 'boolean', read: task => task.effortDriven }), // Taaktypes-etappe (ontwerp 2026-09-04 §7): alleen-lezen tot de UI-stap 'm bewerkbaar maakt. - readonlyColumn({ id: 'task.workRule', labelKey: 'taskGrid.columns.workRule', category: 'technical', valueKind: 'enum', read: task => task.workRule }), + // Taaktypes-etappe (spec §7): bewerkbare werkregel — alleen zichtbaar wanneer ontsloten; `''` = terug + // naar de projectstandaard. De driehoekstap zit in `gridTransaction.ts` (na het plan). `readOnly` + // leest UITSLUITEND de gecertificeerde controllers (isMilestone/isHammock) + childIds — zie + // `check-grid-transaction.ts` "Aanbeveling 4"; een ELAPSEDTIME-taak mag de regel dragen, de + // driehoek negeert 'm daar (`workRuleApplies`). + editableColumn({ id: 'task.workRule', labelKey: 'taskGrid.columns.workRule', category: 'planning', valueKind: 'enum', editorKind: 'enum', editorOptions: enumOptions('workRule', WORK_RULES, true), route: 'task-field', available: ctx => ctx.taskTypesUnlocked === true, read: task => task.workRule, readOnly: task => task.isMilestone || task.childIds.length > 0 || task.isHammock === true, parse: enumParser(WORK_RULES, true), validate: enumValidator(WORK_RULES, true) }), // XER/Primavera-herkomst: acht bronvelden die de XER-lezer op de taak zet en die door IFC // round-trippen. Ze zijn puur provenance (geen solverinvoer deze etappe), dus één readonly // technische kolom bundelt ze — zoals `task.activityCodes.technical` dat voor codes doet. @@ -857,7 +919,32 @@ function fixedAssignmentColumns(): TaskColumnDescriptor[] { // Taaktypes-etappe (spec §4.3/§7): alleen-lezen tot de UI-stap; minuten in de state, uren in beeld. assignmentWorkColumn('assignment.plannedWork', 'taskGrid.columns.assignmentPlannedWork', 'plannedWorkMinutes'), assignmentWorkColumn('assignment.actualWork', 'taskGrid.columns.assignmentActualWork', 'actualWorkMinutes'), - assignmentWorkColumn('assignment.remainingWork', 'taskGrid.columns.assignmentRemainingWork', 'remainingWorkMinutes'), + // Taaktypes-etappe (spec §7): resterend werk per toewijzing, bewerkbaar als "naam: uren; …" met + // dezelfde assignment-set-transactie als de inzet; alleen zichtbaar wanneer ontsloten. Getoond + // wordt het opgeslagen werk, anders het afgeleide (restduur × inzet). + editableColumn({ + id: 'assignment.remainingWork', labelKey: 'taskGrid.columns.assignmentRemainingWork', category: 'resources', valueKind: 'tokens', editorKind: 'custom', defaultWidth: 200, + available: ctx => ctx.taskTypesUnlocked === true, + read: assignmentWorkTokens, + readOnly: (task, ctx) => task.isMilestone || task.childIds.length > 0 || assignments(task, ctx).length === 0, + format: (value, _task, ctx) => Array.isArray(value) && value.length ? value.map(raw => { + const item = raw as { resourceId: string; remainingWorkMinutes?: number }; + return `${ctx.resourcesById.get(item.resourceId)?.name ?? item.resourceId}: ${workHoursText(item.remainingWorkMinutes)}`; + }).join('; ') : '—', + copy: (task, ctx) => structuredClipboardText( + assignmentWorkTokens(task, ctx).map(item => `${assignmentLabel(assignmentById(task, ctx, item), ctx)}: ${workHoursText(item.remainingWorkMinutes)}`).join('; '), + ASSIGNMENT_CLIPBOARD_MARKER, + assignmentWorkTokens(task, ctx), + ), + editText: (task, ctx) => assignmentWorkTokens(task, ctx) + .map(item => `${assignmentLabel(assignmentById(task, ctx, item), ctx)}: ${workHoursText(item.remainingWorkMinutes)}`).join('; '), + parse: parseAssignmentWork, + validate: (value, task, ctx) => validateAssignmentWorkTokens(value, task, ctx), + planWrite: (value, task) => success([{ + kind: 'assignment-set', taskId: task.id, columnId: taskColumnId('assignment.remainingWork'), + tokens: value as readonly TaskAssignmentToken[], + }]), + }), readonlyColumn({ id: 'assignment.workWindowFinish', labelKey: 'taskGrid.columns.workWindowFinish', category: 'resources', valueKind: 'technical', read: (task, ctx) => assignments(task, ctx).map(item => ({ assignmentId: item.id, value: item.workWindowFinish })), format: (_value, task, ctx) => assignmentWindowText(task, ctx, 'workWindowFinish'), copy: (task, ctx) => canonicalGridJson(assignments(task, ctx).map(item => ({ assignmentId: item.id, workWindowFinish: item.workWindowFinish }))) }), readonlyColumn({ id: 'assignment.id', labelKey: 'taskGrid.columns.assignmentId', category: 'technical', valueKind: 'technical', read: (task, ctx) => assignments(task, ctx).map(item => item.id), format: value => Array.isArray(value) && value.length ? value.join(', ') : '—', copy: (task, ctx) => canonicalGridJson(assignments(task, ctx).map(item => item.id)) }), readonlyColumn({ id: 'assignment.taskId', labelKey: 'taskGrid.columns.assignmentTaskId', category: 'technical', valueKind: 'technical', read: (task, ctx) => assignments(task, ctx).map(item => item.taskId), format: value => Array.isArray(value) && value.length ? value.join(', ') : '—', copy: (task, ctx) => canonicalGridJson(assignments(task, ctx).map(item => item.taskId)) }), diff --git a/src/engine/taskGrid/taskEditPlan.ts b/src/engine/taskGrid/taskEditPlan.ts index 4f682f2d9..a88b007a1 100644 --- a/src/engine/taskGrid/taskEditPlan.ts +++ b/src/engine/taskGrid/taskEditPlan.ts @@ -1,3 +1,4 @@ +import { WORK_RULES, type WorkRule } from '@/types/workRule'; import { validateConstraintPair } from '@/engine/scheduler/constraintValidation'; import { taskMilestoneTransition } from '@/engine/taskMilestoneTransition'; import { decodeDynamicTaskColumnId } from '@/engine/taskGrid/fieldIds'; @@ -127,7 +128,7 @@ function expectedRoute(columnId: string): CellEditIntent['route'] | null { if (columnId === 'task.name' || columnId === 'task.description' || columnId === 'task.wbsCode' || columnId === 'task.taskType' || columnId === 'task.customTaskTypeId' || columnId === 'task.priority' || columnId === 'task.color' - || columnId === 'task.notes') { + || columnId === 'task.notes' || columnId === 'task.workRule') { return 'task-field'; } return null; @@ -189,6 +190,12 @@ function applyTaskField( } else if (id === 'task.color') { if (!optionalString(edit.value)) return failure('color', edit); task.color = edit.value; + } else if (id === 'task.workRule') { + // Taaktypes-etappe (spec §7): het VELD; de driehoekstap (restwerk vastleggen onder een + // werkbeschermende regel) doet `gridTransaction.ts` ná het plan, met de toewijzingen erbij. + if (edit.value === undefined || edit.value === '') delete task.workRule; + else if (typeof edit.value === 'string' && (WORK_RULES as readonly string[]).includes(edit.value)) task.workRule = edit.value as WorkRule; + else return failure('enum', edit); } else if (id === 'task.notes') { if (typeof edit.value !== 'string') return failure('text', edit); if ((task.notes?.length ?? 0) > 1) return failure('readOnly', edit); diff --git a/src/engine/taskGrid/taskGridAdapter.ts b/src/engine/taskGrid/taskGridAdapter.ts index 1a5614215..d168d10c1 100644 --- a/src/engine/taskGrid/taskGridAdapter.ts +++ b/src/engine/taskGrid/taskGridAdapter.ts @@ -117,6 +117,8 @@ export interface CreateTaskGridAdapterDomainInput { signedWorkDaysBetween?: (fromIso: string, toIso: string) => number; dateNotation?: DateNotation; calendarOptions?: readonly { value: string; label: string }[]; + /** Taaktypes-etappe (spec §7): zie `TaskColumnContext.taskTypesUnlocked`. */ + taskTypesUnlocked?: boolean; } export interface CreateTaskGridAdapterProjectionInput { @@ -240,6 +242,7 @@ export function createTaskGridAdapterDomain( wbsAutoNumber: input.wbsAutoNumber, effectiveHoursPerDay: input.effectiveHoursPerDay, signedWorkDaysBetween: input.signedWorkDaysBetween, + taskTypesUnlocked: input.taskTypesUnlocked, }; const descriptors = buildTaskColumnRegistry({ projectId: input.projectId, diff --git a/src/engine/work/taskTypesVisibility.ts b/src/engine/work/taskTypesVisibility.ts new file mode 100644 index 000000000..19c85a751 --- /dev/null +++ b/src/engine/work/taskTypesVisibility.ts @@ -0,0 +1,29 @@ +// taskTypesVisibility.ts — taaktypes-etappe (spec 2026-09-04 §3.1-3/§7): wanneer zijn de +// werkregel-bedieningselementen zichtbaar? Twee bronnen, één antwoord: +// 1. de app-instelling "Toon taaktypes" (`ui.showTaskTypes`, `ops-showTaskTypes`, standaard uit); +// 2. het DOCUMENT zelf: draagt het al taaktypedata (een taak met `workRule`, `mspTaskType` of +// `p6DurationType`, een projectstandaard, of een toewijzing met een werkveld), dan is de +// weergave voor dát document ontsloten ongeacht de instelling — `taskTypesVisible` in +// `DOCUMENT_FIELDS` (niet gepersisteerd; bij elk laden opnieuw afgeleid), plus één +// informatieve melding per document (`taskTypesNotice.ts`). +// Puur; geen store-import. +import type { Project } from '@/types/project'; +import type { ResourceAssignment } from '@/types/resource'; +import type { Task } from '@/types/task'; + +/** Draagt dit document taaktypedata die de gebruiker hoort te kunnen zien? */ +export function hasTaskTypeData( + tasks: readonly Pick[], + assignments: readonly Pick[], + project?: Pick, +): boolean { + if (project?.defaultWorkRule !== undefined) return true; + if (tasks.some((t) => t.workRule !== undefined || t.mspTaskType !== undefined || t.p6DurationType !== undefined)) return true; + return assignments.some((a) => + a.plannedWorkMinutes !== undefined || a.actualWorkMinutes !== undefined || a.remainingWorkMinutes !== undefined); +} + +/** De ene selector voor de UI: instelling óf documentontsluiting. */ +export function taskTypesUnlocked(state: { ui: { showTaskTypes: boolean }; taskTypesVisible: boolean }): boolean { + return state.ui.showTaskTypes || state.taskTypesVisible; +} diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index 359335f3c..05c1b5786 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -141,6 +141,7 @@ "mppTimephasedSteeringLost_many": "لم يعد توزيع الساعات من MS Project يوجّه {{count}} مهمة بعد هذا التعديل؛ يبقى التوزيع الأصلي محفوظًا في الملف.", "mppTimephasedSteeringLost_other": "لم يعد توزيع الساعات من MS Project يوجّه {{count}} من المهام بعد هذا التعديل؛ يبقى التوزيع الأصلي محفوظًا في الملف.", "readMore": "اقرأ المزيد", + "taskTypesUnlocked": "يحتوي هذا الملف على أنواع مهام (قواعد عمل أو عمل محفوظ)؛ تُعرض قاعدة العمل والعمل المتبقي لهذا المستند.", "pasteSkippedReadOnly_zero": "لم يتم تخطي أي خلية للقراءة فقط.", "pasteSkippedReadOnly_one": "تم تخطي خلية واحدة للقراءة فقط (مثل رمز WBS المرقم تلقائيًا أو عمود محسوب).", "pasteSkippedReadOnly_two": "تم تخطي خليتين للقراءة فقط (مثل رمز WBS المرقم تلقائيًا أو عمود محسوب).", @@ -228,6 +229,8 @@ "hourPlanningSection": "التخطيط بالساعات", "enableHourPlanning": "تفعيل التخطيط بالساعات", "enableHourPlanningHint": "يفعّل الجدولة بالساعة/الدقيقة: مقياس زمني بالساعات، ونوبات عمل ذات نطاقات أوقات عمل، وأشرطة مهام دقيقة على مستوى الساعة. عند الإيقاف ⇐ يبقى التطبيق مبنيًا بالكامل على اليوم كما كان.", + "showTaskTypes": "إظهار أنواع المهام (قواعد العمل)", + "showTaskTypesHint": "يعرض لكل مهمة قاعدة العمل (أي ركن من العمل = المدة المتبقية × الوحدات ثابت) ولكل تعيين العمل المتبقي — في لوحة الخصائص ومربع حوار المهمة والجدول. عند الإيقاف ⇒ يجدول التطبيق كما في السابق (تبقى المدة والوحدات ويتبعهما العمل). الملف الذي يحتوي بالفعل على أنواع مهام يعرضها على أي حال.", "allowMixedDayHour": "السماح بالتخطيط المختلط باليوم/الساعة", "defaultTaskDurationUnit": "الوحدة الافتراضية للمهام الجديدة", "defaultTaskDurationUnitHint": "تحدد ما إذا كانت المهام الجديدة تبدأ بأيام العمل أو ساعات العمل. لا تتغير المهام الحالية.", diff --git a/src/i18n/locales/ar/task.json b/src/i18n/locales/ar/task.json index 1d5e22502..e503b1751 100644 --- a/src/i18n/locales/ar/task.json +++ b/src/i18n/locales/ar/task.json @@ -130,6 +130,7 @@ }, "status": { "stale": "قديم" }, "validation": { + "assignmentWork": "أدخل العمل المتبقي بالساعات كرقم أكبر من 0 (الاسم: الساعات؛ …).", "readOnly": "لا يمكن تحرير هذا العمود المحسوب.", "invalid": "القيمة المدخلة غير صالحة.", "relationToken": "استخدم مثلًا 1.2 FS+2d.", @@ -318,6 +319,22 @@ "durationTotalHours": "إجمالي الساعات", "hourCalendarHint": "يتطلب إدخال الساعات تقويمًا بالساعة (أوقات العمل)." }, + "workRule": { + "FIXED_DURATION_RATE": "مدة ووحدات ثابتة", + "FIXED_DURATION_WORK": "مدة وعمل ثابتان", + "FIXED_WORK": "عمل ثابت", + "FIXED_RATE": "وحدات ثابتة", + "label": "قاعدة العمل", + "projectDefault": "الافتراضي للمشروع ({{rule}})", + "protects": "محمي: {{what}}", + "protectsFIXED_DURATION_RATE": "المدة والوحدات (يتبع العمل)", + "protectsFIXED_DURATION_WORK": "المدة والعمل (تتبع الوحدات)", + "protectsFIXED_WORK": "العمل (تتبع المدة الوحدات)", + "protectsFIXED_RATE": "الوحدات (تتبع المدة العمل)", + "mspEffortDriven": "من MS Project: مدفوع بالجهد", + "mspNotEffortDriven": "من MS Project: غير مدفوع بالجهد", + "none": "الافتراضي للمشروع" + }, "properties": { "task": "المهمة", "deleteTask": "حذف المهمة", @@ -381,7 +398,10 @@ "contour": "توزيع الساعات…", "contoured": "مخطط", "contouredHint": "لهذا التعيين توزيع ساعات خاص به؛ عدّله أو حرّره عبر زر توزيع الساعات.", - "importedCurve": "منحنى مستورد" + "importedCurve": "منحنى مستورد", + "work": "العمل (المتبقي)", + "workHint": "العمل المتبقي بالساعات؛ تحدد قاعدة العمل ما إذا كانت المدة أم الوحدات تتغير.", + "locked": "محمي بقاعدة العمل {{rule}}" }, "calendar": "التقويم", "calendarProject": "تقويم المشروع", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 214c443e3..3615b28b3 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -73,6 +73,7 @@ "mppTimephasedSteeringLost_one": "Die Stundenverteilung aus MS Project steuert {{count}} Vorgang nach dieser Bearbeitung nicht mehr; die ursprüngliche Verteilung bleibt in der Datei erhalten.", "mppTimephasedSteeringLost_other": "Die Stundenverteilung aus MS Project steuert {{count}} Vorgänge nach dieser Bearbeitung nicht mehr; die ursprüngliche Verteilung bleibt in der Datei erhalten.", "readMore": "Mehr erfahren", + "taskTypesUnlocked": "Diese Datei enthält Vorgangsarten (Arbeitsregeln oder gespeicherte Arbeit); Arbeitsregel und Restarbeit werden für dieses Dokument angezeigt.", "pasteSkippedReadOnly_one": "{{count}} Zelle übersprungen: Sie ist schreibgeschützt (z. B. ein automatisch nummerierter PSP-Code oder eine berechnete Spalte).", "pasteSkippedReadOnly_other": "{{count}} Zellen übersprungen: Sie sind schreibgeschützt (z. B. ein automatisch nummerierter PSP-Code oder eine berechnete Spalte).", "librarySaveFailed": "Bibliothek konnte nicht gespeichert werden", @@ -152,6 +153,8 @@ "hourPlanningSection": "Stundenplanung", "enableHourPlanning": "Stundenplanung aktivieren", "enableHourPlanningHint": "Aktiviert Stunden-/Minuten-Scheduling: eine Stunden-Zeitskala, Schichten mit Arbeitszeitbändern und stundengenaue Vorgangsbalken. Aus ⇒ die App bleibt wie bisher vollständig tagesgenau.", + "showTaskTypes": "Vorgangsarten (Arbeitsregeln) anzeigen", + "showTaskTypesHint": "Zeigt je Vorgang die Arbeitsregel (welche Ecke von Arbeit = Restdauer × Einheiten fest ist) und je Zuordnung die Restarbeit — im Eigenschaftenbereich, im Vorgangsdialog und im Raster. Aus ⇒ die App plant wie bisher (Dauer und Einheiten bleiben, Arbeit folgt). Eine Datei mit Vorgangsarten zeigt sie ohnehin.", "allowMixedDayHour": "Gemischte Tages-/Stundenplanung zulassen", "defaultTaskDurationUnit": "Standardeinheit für neue Vorgänge", "defaultTaskDurationUnitHint": "Legt fest, ob neue Vorgänge in Arbeitstagen oder Arbeitsstunden beginnen. Bestehende Vorgänge ändern sich nicht.", diff --git a/src/i18n/locales/de/task.json b/src/i18n/locales/de/task.json index 58ab6e932..e3b5e9d64 100644 --- a/src/i18n/locales/de/task.json +++ b/src/i18n/locales/de/task.json @@ -73,6 +73,7 @@ }, "status": { "stale": "veraltet" }, "validation": { + "assignmentWork": "Geben Sie die Restarbeit in Stunden als Zahl größer als 0 ein (Name: Stunden; …).", "readOnly": "Diese berechnete Spalte kann nicht bearbeitet werden.", "invalid": "Der eingegebene Wert ist ungültig.", "relationToken": "Verwenden Sie zum Beispiel 1.2 FS+2d.", @@ -245,6 +246,22 @@ "durationTotalHours": "Stunden gesamt", "hourCalendarHint": "Die Stundeneingabe erfordert einen Stundenkalender (Arbeitszeiten)." }, + "workRule": { + "FIXED_DURATION_RATE": "Feste Dauer und Einheiten", + "FIXED_DURATION_WORK": "Feste Dauer und Arbeit", + "FIXED_WORK": "Feste Arbeit", + "FIXED_RATE": "Feste Einheiten", + "label": "Arbeitsregel", + "projectDefault": "Projektstandard ({{rule}})", + "protects": "Geschützt: {{what}}", + "protectsFIXED_DURATION_RATE": "Dauer und Einheiten (Arbeit folgt)", + "protectsFIXED_DURATION_WORK": "Dauer und Arbeit (Einheiten folgen)", + "protectsFIXED_WORK": "Arbeit (Dauer folgt den Einheiten)", + "protectsFIXED_RATE": "Einheiten (Dauer folgt der Arbeit)", + "mspEffortDriven": "Aus MS Project: leistungsgesteuert", + "mspNotEffortDriven": "Aus MS Project: nicht leistungsgesteuert", + "none": "Projektstandard" + }, "properties": { "task": "Aufgabe", "deleteTask": "Aufgabe löschen", @@ -308,7 +325,10 @@ "contour": "Stundenverteilung…", "contoured": "Kontur", "contouredHint": "Diese Zuweisung hat eine eigene Stundenverteilung; bearbeiten oder aufheben über die Schaltfläche Stundenverteilung.", - "importedCurve": "Importierte Kurve" + "importedCurve": "Importierte Kurve", + "work": "Arbeit (Rest)", + "workHint": "Restarbeit in Stunden; die Arbeitsregel bestimmt, ob Dauer oder Einheiten sich bewegen.", + "locked": "Durch die Arbeitsregel {{rule}} geschützt" }, "calendar": "Kalender", "calendarProject": "Projektkalender", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index cf5f71138..14ec81c8b 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -78,6 +78,7 @@ "mppTimephasedSteeringLost_one": "MS Project's date window no longer drives {{count}} task after this edit; the hour distribution itself still applies and stays in the file.", "mppTimephasedSteeringLost_other": "MS Project's date window no longer drives {{count}} tasks after this edit; the hour distribution itself still applies and stays in the file.", "readMore": "Read more", + "taskTypesUnlocked": "This file contains task types (work rules or stored work); the work rule and remaining work are shown for this document.", "pasteSkippedReadOnly_one": "Skipped {{count}} cell: it is read-only (for example an auto-numbered WBS code or a calculated column).", "pasteSkippedReadOnly_other": "Skipped {{count}} cells: they are read-only (for example an auto-numbered WBS code or a calculated column).", "librarySaveFailed": "Library could not be saved", @@ -157,6 +158,8 @@ "hourPlanningSection": "Hour planning", "enableHourPlanning": "Enable hour planning", "enableHourPlanningHint": "Turns on hour/minute scheduling: an hour time-scale, shifts with working-time bands and hour-precise task bars. Off ⇒ the app stays fully day-granular as before.", + "showTaskTypes": "Show task types (work rules)", + "showTaskTypesHint": "Shows the work rule per task (which corner of work = remaining duration × units is fixed) and the remaining work per assignment — in the properties panel, the task dialog and the grid. Off ⇒ the app schedules as before (duration and units stay, work follows). A file that already contains task types shows them regardless.", "allowMixedDayHour": "Allow mixed day/hour planning", "defaultTaskDurationUnit": "Default unit for new tasks", "defaultTaskDurationUnitHint": "Determines whether new tasks start in working days or working hours. Existing tasks do not change.", diff --git a/src/i18n/locales/en/task.json b/src/i18n/locales/en/task.json index 9a07ce8d0..ff5967f6a 100644 --- a/src/i18n/locales/en/task.json +++ b/src/i18n/locales/en/task.json @@ -189,6 +189,7 @@ "assignmentDuplicateId": "This assignment occurs more than once.", "assignmentIdentity": "This assignment belongs to a different resource.", "assignmentUnits": "Enter units per day as a number greater than 0.", + "assignmentWork": "Enter remaining work in hours as a number greater than 0 (name: hours; …).", "assignmentCurve": "Choose a known load curve.", "assignmentTaskNotFound": "The task of this assignment no longer exists.", "assignmentTaskUnavailable": "Assignments cannot be edited on this task.", @@ -335,6 +336,22 @@ "durationTotalHours": "Total hours", "hourCalendarHint": "Hour input requires an hour calendar (working times)." }, + "workRule": { + "FIXED_DURATION_RATE": "Fixed duration and units", + "FIXED_DURATION_WORK": "Fixed duration and work", + "FIXED_WORK": "Fixed work", + "FIXED_RATE": "Fixed units", + "label": "Work rule", + "projectDefault": "Project default ({{rule}})", + "protects": "Protected: {{what}}", + "protectsFIXED_DURATION_RATE": "duration and units (work follows)", + "protectsFIXED_DURATION_WORK": "duration and work (units follow)", + "protectsFIXED_WORK": "work (duration follows units)", + "protectsFIXED_RATE": "units (duration follows work)", + "mspEffortDriven": "From MS Project: effort-driven", + "mspNotEffortDriven": "From MS Project: not effort-driven", + "none": "Project default" + }, "properties": { "task": "Task", "deleteTask": "Delete task", @@ -418,7 +435,10 @@ "contour": "Hour distribution…", "contoured": "Contour", "contouredHint": "This assignment has its own hour distribution; edit or release it via the Hour distribution button.", - "importedCurve": "Imported curve" + "importedCurve": "Imported curve", + "work": "Work (rem.)", + "workHint": "Remaining work in hours; the work rule decides whether duration or units move.", + "locked": "Protected by the work rule {{rule}}" }, "calendar": "Calendar", "calendarProject": "Project calendar", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index fc6222984..542f4917d 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -90,6 +90,7 @@ "mppTimephasedSteeringLost_many": "La distribución de horas de MS Project ya no controla {{count}} tareas después de esta edición; la distribución original se conserva en el archivo.", "mppTimephasedSteeringLost_other": "La distribución de horas de MS Project ya no controla {{count}} tareas después de esta edición; la distribución original se conserva en el archivo.", "readMore": "Leer más", + "taskTypesUnlocked": "Este archivo contiene tipos de tarea (reglas de trabajo o trabajo guardado); la regla de trabajo y el trabajo restante se muestran para este documento.", "pasteSkippedReadOnly_one": "Se omitió {{count}} celda: es de solo lectura (por ejemplo, un código WBS numerado automáticamente o una columna calculada).", "pasteSkippedReadOnly_many": "Se omitieron {{count}} celdas: son de solo lectura (por ejemplo, un código WBS numerado automáticamente o una columna calculada).", "pasteSkippedReadOnly_other": "Se omitieron {{count}} celdas: son de solo lectura (por ejemplo, un código WBS numerado automáticamente o una columna calculada).", @@ -171,6 +172,8 @@ "hourPlanningSection": "Planificación por horas", "enableHourPlanning": "Activar planificación por horas", "enableHourPlanningHint": "Activa la programación por horas/minutos: una escala de tiempo horaria, turnos con bandas de horario laboral y barras de tarea con precisión de hora. Desactivado ⇒ la aplicación sigue siendo totalmente granular por días, como antes.", + "showTaskTypes": "Mostrar tipos de tarea (reglas de trabajo)", + "showTaskTypesHint": "Muestra por tarea la regla de trabajo (qué esquina de trabajo = duración restante × unidades está fija) y por asignación el trabajo restante — en el panel de propiedades, el diálogo de tarea y la cuadrícula. Desactivado ⇒ la aplicación planifica como antes (duración y unidades se mantienen, el trabajo sigue). Un archivo que ya contiene tipos de tarea los muestra de todos modos.", "allowMixedDayHour": "Permitir planificación mixta de días/horas", "defaultTaskDurationUnit": "Unidad predeterminada para tareas nuevas", "defaultTaskDurationUnitHint": "Determina si las tareas nuevas comienzan en días laborables u horas de trabajo. Las tareas existentes no cambian.", diff --git a/src/i18n/locales/es/task.json b/src/i18n/locales/es/task.json index 8447eef1e..3c7cc4fed 100644 --- a/src/i18n/locales/es/task.json +++ b/src/i18n/locales/es/task.json @@ -73,6 +73,7 @@ }, "status": { "stale": "obsoleto" }, "validation": { + "assignmentWork": "Introduzca el trabajo restante en horas como un número mayor que 0 (nombre: horas; …).", "readOnly": "Esta columna calculada no se puede editar.", "invalid": "El valor introducido no es válido.", "relationToken": "Use, por ejemplo, 1.2 FS+2d.", @@ -249,6 +250,22 @@ "durationTotalHours": "Horas totales", "hourCalendarHint": "La entrada en horas requiere un calendario horario (horarios de trabajo)." }, + "workRule": { + "FIXED_DURATION_RATE": "Duración y unidades fijas", + "FIXED_DURATION_WORK": "Duración y trabajo fijos", + "FIXED_WORK": "Trabajo fijo", + "FIXED_RATE": "Unidades fijas", + "label": "Regla de trabajo", + "projectDefault": "Valor predeterminado del proyecto ({{rule}})", + "protects": "Protegido: {{what}}", + "protectsFIXED_DURATION_RATE": "duración y unidades (el trabajo sigue)", + "protectsFIXED_DURATION_WORK": "duración y trabajo (las unidades siguen)", + "protectsFIXED_WORK": "trabajo (la duración sigue a las unidades)", + "protectsFIXED_RATE": "unidades (la duración sigue al trabajo)", + "mspEffortDriven": "De MS Project: condicionado por el esfuerzo", + "mspNotEffortDriven": "De MS Project: no condicionado por el esfuerzo", + "none": "Predeterminado del proyecto" + }, "properties": { "task": "Tarea", "deleteTask": "Eliminar tarea", @@ -312,7 +329,10 @@ "contour": "Distribución de horas…", "contoured": "Contorno", "contouredHint": "Esta asignación tiene su propia distribución de horas; edítela o libérela con el botón Distribución de horas.", - "importedCurve": "Curva importada" + "importedCurve": "Curva importada", + "work": "Trabajo (rest.)", + "workHint": "Trabajo restante en horas; la regla de trabajo decide si se mueve la duración o las unidades.", + "locked": "Protegido por la regla de trabajo {{rule}}" }, "calendar": "Calendario", "calendarProject": "Calendario del proyecto", diff --git a/src/i18n/locales/fa/common.json b/src/i18n/locales/fa/common.json index 0290450f6..867e3f796 100644 --- a/src/i18n/locales/fa/common.json +++ b/src/i18n/locales/fa/common.json @@ -73,6 +73,7 @@ "mppTimephasedSteeringLost_one": "توزیع ساعات MS Project پس از این ویرایش دیگر {{count}} وظیفه را هدایت نمی‌کند؛ توزیع اصلی در فایل نگه داشته می‌شود.", "mppTimephasedSteeringLost_other": "توزیع ساعات MS Project پس از این ویرایش دیگر {{count}} وظیفه را هدایت نمی‌کند؛ توزیع اصلی در فایل نگه داشته می‌شود.", "readMore": "بیشتر بخوانید", + "taskTypesUnlocked": "این پرونده شامل انواع کار (قواعد کار یا کار ذخیره‌شده) است؛ قاعدهٔ کار و کار باقی‌مانده برای این سند نمایش داده می‌شوند.", "pasteSkippedReadOnly_one": "{{count}} سلول رد شد: فقط‌خواندنی است (مثلاً کد WBS شماره‌گذاری‌شده خودکار یا یک ستون محاسبه‌شده).", "pasteSkippedReadOnly_other": "{{count}} سلول رد شد: فقط‌خواندنی هستند (مثلاً کد WBS شماره‌گذاری‌شده خودکار یا یک ستون محاسبه‌شده).", "librarySaveFailed": "ذخیره‌سازی کتابخانه ناموفق بود", @@ -152,6 +153,8 @@ "hourPlanningSection": "برنامه‌ریزی ساعتی", "enableHourPlanning": "فعال‌سازی برنامه‌ریزی ساعتی", "enableHourPlanningHint": "زمان‌بندی ساعت/دقیقه را فعال می‌کند: یک مقیاس زمانی ساعتی، شیفت‌های دارای بازه‌های ساعت کاری و نوارهای وظیفه با دقت ساعتی. خاموش ⇐ برنامه مانند قبل کاملاً بر پایه روز باقی می‌ماند.", + "showTaskTypes": "نمایش انواع کار (قواعد کار)", + "showTaskTypesHint": "برای هر کار قاعدهٔ کار (کدام ضلعِ کار = مدت باقی‌مانده × واحدها ثابت است) و برای هر تخصیص کار باقی‌مانده را نشان می‌دهد — در پنل ویژگی‌ها، پنجرهٔ کار و جدول. خاموش ⇒ برنامه مانند قبل زمان‌بندی می‌کند (مدت و واحدها می‌مانند، کار تبعیت می‌کند). پرونده‌ای که از قبل انواع کار دارد آن‌ها را به هر حال نشان می‌دهد.", "allowMixedDayHour": "اجازه دادن به برنامه‌ریزی ترکیبی روز/ساعت", "defaultTaskDurationUnit": "واحد پیش‌فرض وظایف جدید", "defaultTaskDurationUnitHint": "مشخص می‌کند وظایف جدید با روز کاری یا ساعت کاری شروع شوند. وظایف موجود تغییر نمی‌کنند.", diff --git a/src/i18n/locales/fa/task.json b/src/i18n/locales/fa/task.json index 36de98d53..8717dba9d 100644 --- a/src/i18n/locales/fa/task.json +++ b/src/i18n/locales/fa/task.json @@ -56,6 +56,7 @@ }, "status": { "stale": "قدیمی" }, "validation": { + "assignmentWork": "کار باقی‌مانده را به ساعت به‌صورت عددی بزرگ‌تر از 0 وارد کنید (نام: ساعت؛ …).", "readOnly": "این ستون محاسبه‌شده قابل ویرایش نیست.", "invalid": "مقدار واردشده معتبر نیست.", "relationToken": "برای نمونه از 1.2 FS+2d استفاده کنید.", @@ -228,6 +229,22 @@ "durationTotalHours": "مجموع ساعات", "hourCalendarHint": "ورود ساعتی به یک تقویم ساعتی (ساعات کاری) نیاز دارد." }, + "workRule": { + "FIXED_DURATION_RATE": "مدت و واحدهای ثابت", + "FIXED_DURATION_WORK": "مدت و کار ثابت", + "FIXED_WORK": "کار ثابت", + "FIXED_RATE": "واحدهای ثابت", + "label": "قاعدهٔ کار", + "projectDefault": "پیش‌فرض پروژه ({{rule}})", + "protects": "محافظت‌شده: {{what}}", + "protectsFIXED_DURATION_RATE": "مدت و واحدها (کار تبعیت می‌کند)", + "protectsFIXED_DURATION_WORK": "مدت و کار (واحدها تبعیت می‌کنند)", + "protectsFIXED_WORK": "کار (مدت از واحدها تبعیت می‌کند)", + "protectsFIXED_RATE": "واحدها (مدت از کار تبعیت می‌کند)", + "mspEffortDriven": "از MS Project: مبتنی بر تلاش", + "mspNotEffortDriven": "از MS Project: غیرمبتنی بر تلاش", + "none": "پیش‌فرض پروژه" + }, "properties": { "task": "وظیفه", "deleteTask": "حذف وظیفه", @@ -291,7 +308,10 @@ "contour": "توزیع ساعات…", "contoured": "کانتور", "contouredHint": "این تخصیص توزیع ساعات مخصوص به خود دارد؛ آن را با دکمهٔ توزیع ساعات ویرایش یا رها کنید.", - "importedCurve": "منحنی وارد‌شده" + "importedCurve": "منحنی وارد‌شده", + "work": "کار (باقی‌مانده)", + "workHint": "کار باقی‌مانده به ساعت؛ قاعدهٔ کار تعیین می‌کند مدت یا واحدها تغییر کنند.", + "locked": "محافظت‌شده توسط قاعدهٔ کار {{rule}}" }, "calendar": "تقویم", "calendarProject": "تقویم پروژه", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index e3ab42857..41dc71326 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -90,6 +90,7 @@ "mppTimephasedSteeringLost_many": "La répartition horaire de MS Project ne pilote plus {{count}} tâches après cette modification ; la répartition d'origine reste conservée dans le fichier.", "mppTimephasedSteeringLost_other": "La répartition horaire de MS Project ne pilote plus {{count}} tâches après cette modification ; la répartition d'origine reste conservée dans le fichier.", "readMore": "En savoir plus", + "taskTypesUnlocked": "Ce fichier contient des types de tâche (règles de travail ou travail enregistré) ; la règle de travail et le travail restant sont affichés pour ce document.", "pasteSkippedReadOnly_one": "{{count}} cellule ignorée : elle est en lecture seule (par exemple un code WBS numéroté automatiquement ou une colonne calculée).", "pasteSkippedReadOnly_many": "{{count}} cellules ignorées : elles sont en lecture seule (par exemple un code WBS numéroté automatiquement ou une colonne calculée).", "pasteSkippedReadOnly_other": "{{count}} cellules ignorées : elles sont en lecture seule (par exemple un code WBS numéroté automatiquement ou une colonne calculée).", @@ -171,6 +172,8 @@ "hourPlanningSection": "Planification horaire", "enableHourPlanning": "Activer la planification horaire", "enableHourPlanningHint": "Active la planification heure/minute : une échelle de temps en heures, des équipes avec des plages horaires de travail et des barres de tâche précises à l'heure près. Désactivé ⇒ l'application reste entièrement en granularité journalière, comme auparavant.", + "showTaskTypes": "Afficher les types de tâche (règles de travail)", + "showTaskTypesHint": "Affiche par tâche la règle de travail (quel coin de travail = durée restante × unités est fixe) et par affectation le travail restant — dans le panneau des propriétés, la boîte de dialogue de tâche et la grille. Désactivé ⇒ l'application planifie comme avant (durée et unités restent, le travail suit). Un fichier contenant déjà des types de tâche les affiche de toute façon.", "allowMixedDayHour": "Autoriser la planification mixte jour/heure", "defaultTaskDurationUnit": "Unité par défaut des nouvelles tâches", "defaultTaskDurationUnitHint": "Détermine si les nouvelles tâches commencent en jours ouvrés ou en heures de travail. Les tâches existantes ne changent pas.", diff --git a/src/i18n/locales/fr/task.json b/src/i18n/locales/fr/task.json index 30fbbba26..5ac384355 100644 --- a/src/i18n/locales/fr/task.json +++ b/src/i18n/locales/fr/task.json @@ -56,6 +56,7 @@ }, "status": { "stale": "obsolète" }, "validation": { + "assignmentWork": "Saisissez le travail restant en heures sous forme de nombre supérieur à 0 (nom : heures ; …).", "readOnly": "Cette colonne calculée ne peut pas être modifiée.", "invalid": "La valeur saisie n’est pas valide.", "relationToken": "Utilisez par exemple 1.2 FS+2d.", @@ -232,6 +233,22 @@ "durationTotalHours": "Heures totales", "hourCalendarHint": "La saisie en heures nécessite un calendrier horaire (horaires de travail)." }, + "workRule": { + "FIXED_DURATION_RATE": "Durée et unités fixes", + "FIXED_DURATION_WORK": "Durée et travail fixes", + "FIXED_WORK": "Travail fixe", + "FIXED_RATE": "Unités fixes", + "label": "Règle de travail", + "projectDefault": "Valeur par défaut du projet ({{rule}})", + "protects": "Protégé : {{what}}", + "protectsFIXED_DURATION_RATE": "durée et unités (le travail suit)", + "protectsFIXED_DURATION_WORK": "durée et travail (les unités suivent)", + "protectsFIXED_WORK": "travail (la durée suit les unités)", + "protectsFIXED_RATE": "unités (la durée suit le travail)", + "mspEffortDriven": "Depuis MS Project : piloté par l'effort", + "mspNotEffortDriven": "Depuis MS Project : non piloté par l'effort", + "none": "Valeur par défaut du projet" + }, "properties": { "task": "Tâche", "deleteTask": "Supprimer la tâche", @@ -295,7 +312,10 @@ "contour": "Répartition des heures…", "contoured": "Contour", "contouredHint": "Cette affectation a sa propre répartition des heures ; modifiez-la ou libérez-la via le bouton Répartition des heures.", - "importedCurve": "Courbe importée" + "importedCurve": "Courbe importée", + "work": "Travail (rest.)", + "workHint": "Travail restant en heures ; la règle de travail décide si la durée ou les unités bougent.", + "locked": "Protégé par la règle de travail {{rule}}" }, "calendar": "Calendrier", "calendarProject": "Calendrier du projet", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index a54473206..4a60ee855 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -90,6 +90,7 @@ "mppTimephasedSteeringLost_many": "La distribuzione oraria di MS Project non guida più {{count}} attività dopo questa modifica; la distribuzione originale resta conservata nel file.", "mppTimephasedSteeringLost_other": "La distribuzione oraria di MS Project non guida più {{count}} attività dopo questa modifica; la distribuzione originale resta conservata nel file.", "readMore": "Ulteriori informazioni", + "taskTypesUnlocked": "Questo file contiene tipi di attività (regole di lavoro o lavoro salvato); la regola di lavoro e il lavoro rimanente sono mostrati per questo documento.", "pasteSkippedReadOnly_one": "{{count}} cella ignorata: è di sola lettura (ad esempio un codice WBS numerato automaticamente o una colonna calcolata).", "pasteSkippedReadOnly_many": "{{count}} celle ignorate: sono di sola lettura (ad esempio un codice WBS numerato automaticamente o una colonna calcolata).", "pasteSkippedReadOnly_other": "{{count}} celle ignorate: sono di sola lettura (ad esempio un codice WBS numerato automaticamente o una colonna calcolata).", @@ -171,6 +172,8 @@ "hourPlanningSection": "Pianificazione oraria", "enableHourPlanning": "Attiva pianificazione oraria", "enableHourPlanningHint": "Attiva la pianificazione ora/minuto: una scala temporale oraria, turni con fasce orarie di lavoro e barre attività precise all'ora. Disattivato ⇒ l'app resta completamente granulare per giorni, come prima.", + "showTaskTypes": "Mostra tipi di attività (regole di lavoro)", + "showTaskTypesHint": "Mostra per attività la regola di lavoro (quale angolo di lavoro = durata rimanente × unità è fisso) e per assegnazione il lavoro rimanente — nel pannello proprietà, nella finestra attività e nella griglia. Disattivato ⇒ l'app pianifica come prima (durata e unità restano, il lavoro segue). Un file che contiene già tipi di attività li mostra comunque.", "allowMixedDayHour": "Consenti pianificazione mista giorno/ora", "defaultTaskDurationUnit": "Unità predefinita per le nuove attività", "defaultTaskDurationUnitHint": "Stabilisce se le nuove attività iniziano in giorni lavorativi o ore di lavoro. Le attività esistenti non cambiano.", diff --git a/src/i18n/locales/it/task.json b/src/i18n/locales/it/task.json index ec97309f0..4dd8355b5 100644 --- a/src/i18n/locales/it/task.json +++ b/src/i18n/locales/it/task.json @@ -56,6 +56,7 @@ }, "status": { "stale": "obsoleto" }, "validation": { + "assignmentWork": "Inserisci il lavoro rimanente in ore come numero maggiore di 0 (nome: ore; …).", "readOnly": "Questa colonna calcolata non può essere modificata.", "invalid": "Il valore inserito non è valido.", "relationToken": "Usa, ad esempio, 1.2 FS+2d.", @@ -232,6 +233,22 @@ "durationTotalHours": "Ore totali", "hourCalendarHint": "L'inserimento in ore richiede un calendario orario (orari di lavoro)." }, + "workRule": { + "FIXED_DURATION_RATE": "Durata e unità fisse", + "FIXED_DURATION_WORK": "Durata e lavoro fissi", + "FIXED_WORK": "Lavoro fisso", + "FIXED_RATE": "Unità fisse", + "label": "Regola di lavoro", + "projectDefault": "Predefinito del progetto ({{rule}})", + "protects": "Protetto: {{what}}", + "protectsFIXED_DURATION_RATE": "durata e unità (il lavoro segue)", + "protectsFIXED_DURATION_WORK": "durata e lavoro (le unità seguono)", + "protectsFIXED_WORK": "lavoro (la durata segue le unità)", + "protectsFIXED_RATE": "unità (la durata segue il lavoro)", + "mspEffortDriven": "Da MS Project: basata sull'impegno", + "mspNotEffortDriven": "Da MS Project: non basata sull'impegno", + "none": "Predefinito del progetto" + }, "properties": { "task": "Attività", "deleteTask": "Elimina attività", @@ -295,7 +312,10 @@ "contour": "Distribuzione ore…", "contoured": "Contorno", "contouredHint": "Questa assegnazione ha una propria distribuzione delle ore; modificala o rilasciala con il pulsante Distribuzione ore.", - "importedCurve": "Curva importata" + "importedCurve": "Curva importata", + "work": "Lavoro (rim.)", + "workHint": "Lavoro rimanente in ore; la regola di lavoro decide se si muove la durata o le unità.", + "locked": "Protetto dalla regola di lavoro {{rule}}" }, "calendar": "Calendario", "calendarProject": "Calendario del progetto", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 2d0da6703..d32a0cee4 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -56,6 +56,7 @@ "projectStartAnchorsClamped_other": "プロジェクト開始日を変更しました。先行タスクも制約もないタスクの起点 {{count}} 件を新しい開始日に合わせて移動しました。", "mppTimephasedSteeringLost_other": "この編集の後、MS Project の時間配分はもう {{count}} 件のタスクを制御していません。元の配分はファイルに保存されたままです。", "readMore": "詳細を見る", + "taskTypesUnlocked": "このファイルにはタスクの種類(作業ルールまたは保存済み作業)が含まれています。このドキュメントでは作業ルールと残作業が表示されます。", "pasteSkippedReadOnly_other": "{{count}} 件の読み取り専用セルをスキップしました(例:自動採番の WBS コードや計算列など)。", "librarySaveFailed": "ライブラリを保存できませんでした", "levelingDelayRoundedToWorkdays_other": "山崩しは、MS Project の分単位の山崩し遅延(タスク {{count}} 件分)を整数の稼働日に丸めます。" @@ -133,6 +134,8 @@ "hourPlanningSection": "時間計画", "enableHourPlanning": "時間計画を有効にする", "enableHourPlanningHint": "時間・分単位のスケジューリングを有効にします:時間の時間軸、稼働時間帯を持つシフト、時間単位で正確なタスクバー。オフの場合 ⇒ アプリは従来どおり完全に日単位のままです。", + "showTaskTypes": "タスクの種類(作業ルール)を表示", + "showTaskTypesHint": "タスクごとに作業ルール(作業 = 残存期間 × 単位 のどの角が固定か)、割り当てごとに残作業を表示します — プロパティパネル、タスクダイアログ、グリッドで。オフ ⇒ 従来どおりにスケジュールします(期間と単位は維持、作業が追従)。すでにタスクの種類を含むファイルは常に表示されます。", "allowMixedDayHour": "日単位と時間単位の混在計画を許可する", "defaultTaskDurationUnit": "新しいタスクの既定単位", "defaultTaskDurationUnitHint": "新しいタスクを稼働日または作業時間のどちらで開始するかを決めます。既存のタスクは変わりません。", diff --git a/src/i18n/locales/ja/task.json b/src/i18n/locales/ja/task.json index 141174d76..c5df7ac10 100644 --- a/src/i18n/locales/ja/task.json +++ b/src/i18n/locales/ja/task.json @@ -56,6 +56,7 @@ }, "status": { "stale": "古い" }, "validation": { + "assignmentWork": "残作業を 0 より大きい数値(時間)で入力してください(名前: 時間; …)。", "readOnly": "この計算列は編集できません。", "invalid": "入力した値は無効です。", "relationToken": "例: 1.2 FS+2d のように入力してください。", @@ -224,6 +225,22 @@ "durationTotalHours": "合計時間", "hourCalendarHint": "時間入力には時間カレンダー(稼働時間)が必要です。" }, + "workRule": { + "FIXED_DURATION_RATE": "固定期間・固定単位", + "FIXED_DURATION_WORK": "固定期間・固定作業", + "FIXED_WORK": "固定作業", + "FIXED_RATE": "固定単位", + "label": "作業ルール", + "projectDefault": "プロジェクトの既定({{rule}})", + "protects": "保護:{{what}}", + "protectsFIXED_DURATION_RATE": "期間と単位(作業が追従)", + "protectsFIXED_DURATION_WORK": "期間と作業(単位が追従)", + "protectsFIXED_WORK": "作業(期間は単位に追従)", + "protectsFIXED_RATE": "単位(期間は作業に追従)", + "mspEffortDriven": "MS Project より:実効作業駆動", + "mspNotEffortDriven": "MS Project より:実効作業駆動ではない", + "none": "プロジェクトの既定" + }, "properties": { "task": "タスク", "deleteTask": "タスクを削除", @@ -287,7 +304,10 @@ "contour": "時間配分…", "contoured": "輪郭", "contouredHint": "この割り当てには独自の時間配分があります。「時間配分」ボタンで編集または解除してください。", - "importedCurve": "インポートされた曲線" + "importedCurve": "インポートされた曲線", + "work": "作業(残)", + "workHint": "残作業(時間)。期間と単位のどちらが変わるかは作業ルールが決めます。", + "locked": "作業ルール {{rule}} により保護" }, "calendar": "カレンダー", "calendarProject": "プロジェクトカレンダー", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index a5e308df7..5230e6018 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -56,6 +56,7 @@ "projectStartAnchorsClamped_other": "프로젝트 시작일이 변경되었습니다. 선행 작업이나 제약 조건이 없는 작업 기준점 {{count}}개를 새 시작일로 이동했습니다.", "mppTimephasedSteeringLost_other": "이 편집 이후 MS Project의 시간 분배가 더 이상 {{count}}개 작업을 제어하지 않습니다. 원래 분배는 파일에 그대로 보존됩니다.", "readMore": "더 알아보기", + "taskTypesUnlocked": "이 파일에는 작업 유형(작업 규칙 또는 저장된 작업량)이 포함되어 있습니다. 이 문서에서는 작업 규칙과 남은 작업량이 표시됩니다.", "pasteSkippedReadOnly_other": "읽기 전용 셀 {{count}}개를 건너뛰었습니다(예: 자동 번호가 매겨진 WBS 코드 또는 계산된 열).", "librarySaveFailed": "라이브러리를 저장하지 못했습니다", "levelingDelayRoundedToWorkdays_other": "평준화는 MS Project의 분 단위로 정확한 평준화 지연({{count}}개 작업)을 전체 근무일로 반올림합니다." @@ -133,6 +134,8 @@ "hourPlanningSection": "시간 단위 계획", "enableHourPlanning": "시간 단위 계획 사용", "enableHourPlanningHint": "시간/분 단위 스케줄링을 켭니다: 시간 단위 시간축, 근무 시간대가 있는 교대, 시간 단위로 정확한 작업 막대. 끄면 ⇒ 앱은 이전처럼 완전히 일 단위로 유지됩니다.", + "showTaskTypes": "작업 유형(작업 규칙) 표시", + "showTaskTypesHint": "작업별 작업 규칙(작업량 = 남은 기간 × 단위 중 어느 항이 고정인지)과 배정별 남은 작업량을 표시합니다 — 속성 패널, 작업 대화 상자, 그리드에서. 끄면 ⇒ 앱은 이전처럼 일정을 계산합니다(기간과 단위 유지, 작업량이 따라감). 이미 작업 유형이 있는 파일은 항상 표시됩니다.", "allowMixedDayHour": "일/시간 혼합 계획 허용", "defaultTaskDurationUnit": "새 작업의 기본 단위", "defaultTaskDurationUnitHint": "새 작업을 근무일 또는 근무 시간 중 어떤 단위로 시작할지 정합니다. 기존 작업은 바뀌지 않습니다.", diff --git a/src/i18n/locales/ko/task.json b/src/i18n/locales/ko/task.json index bef1618c2..9fef67062 100644 --- a/src/i18n/locales/ko/task.json +++ b/src/i18n/locales/ko/task.json @@ -56,6 +56,7 @@ }, "status": { "stale": "오래됨" }, "validation": { + "assignmentWork": "남은 작업량을 0보다 큰 숫자(시간)로 입력하세요(이름: 시간; …).", "readOnly": "이 계산 열은 편집할 수 없습니다.", "invalid": "입력한 값이 올바르지 않습니다.", "relationToken": "예: 1.2 FS+2d 형식으로 입력하세요.", @@ -224,6 +225,22 @@ "durationTotalHours": "총 시간", "hourCalendarHint": "시간 입력에는 시간 달력(근무 시간)이 필요합니다." }, + "workRule": { + "FIXED_DURATION_RATE": "고정 기간 및 단위", + "FIXED_DURATION_WORK": "고정 기간 및 작업량", + "FIXED_WORK": "고정 작업량", + "FIXED_RATE": "고정 단위", + "label": "작업 규칙", + "projectDefault": "프로젝트 기본값({{rule}})", + "protects": "보호됨: {{what}}", + "protectsFIXED_DURATION_RATE": "기간과 단위(작업량이 따라감)", + "protectsFIXED_DURATION_WORK": "기간과 작업량(단위가 따라감)", + "protectsFIXED_WORK": "작업량(기간이 단위를 따름)", + "protectsFIXED_RATE": "단위(기간이 작업량을 따름)", + "mspEffortDriven": "MS Project에서: 작업량 기준", + "mspNotEffortDriven": "MS Project에서: 작업량 기준 아님", + "none": "프로젝트 기본값" + }, "properties": { "task": "작업", "deleteTask": "작업 삭제", @@ -287,7 +304,10 @@ "contour": "시간 분배…", "contoured": "윤곽", "contouredHint": "이 배정에는 고유한 시간 분배가 있습니다. 시간 분배 버튼으로 편집하거나 해제하세요.", - "importedCurve": "가져온 곡선" + "importedCurve": "가져온 곡선", + "work": "작업량(남음)", + "workHint": "남은 작업량(시간). 기간과 단위 중 무엇이 변하는지는 작업 규칙이 결정합니다.", + "locked": "작업 규칙 {{rule}}에 의해 보호됨" }, "calendar": "달력", "calendarProject": "프로젝트 달력", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index b747445d4..94ddbf2e5 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -78,6 +78,7 @@ "mppTimephasedSteeringLost_one": "Het datumvenster uit MS Project stuurt {{count}} taak niet meer na deze bewerking; de urenverdeling zelf blijft gelden en in het bestand bewaard.", "mppTimephasedSteeringLost_other": "Het datumvenster uit MS Project stuurt {{count}} taken niet meer na deze bewerking; de urenverdeling zelf blijft gelden en in het bestand bewaard.", "readMore": "Lees meer", + "taskTypesUnlocked": "Dit bestand bevat taaktypes (werkregels of opgeslagen werk); de werkregel en het resterende werk zijn voor dit document zichtbaar.", "pasteSkippedReadOnly_one": "{{count}} cel overgeslagen: hij is read-only (bijvoorbeeld een automatisch genummerde WBS-code of een berekende kolom).", "pasteSkippedReadOnly_other": "{{count}} cellen overgeslagen: ze zijn read-only (bijvoorbeeld een automatisch genummerde WBS-code of een berekende kolom).", "librarySaveFailed": "Bibliotheek kon niet worden opgeslagen", @@ -157,6 +158,8 @@ "hourPlanningSection": "Urenplanning", "enableHourPlanning": "Urenplanning inschakelen", "enableHourPlanningHint": "Zet uren-/minuten-scheduling aan: een uur-tijdschaal, ploegen met werktijd-banden en uur-precieze taakbalken. Uit ⇒ de app werkt volledig dag-granulair zoals voorheen.", + "showTaskTypes": "Toon taaktypes (werkregels)", + "showTaskTypesHint": "Toont per taak de werkregel (welke hoek van werk = restduur × inzet vast staat) en per toewijzing het resterende werk — in het eigenschappenpaneel, de taakdialoog en het raster. Uit ⇒ de app plant zoals voorheen (duur en inzet blijven, werk volgt). Een bestand dat al taaktypes bevat toont ze sowieso.", "allowMixedDayHour": "Gemengde dag/uur-planning toestaan", "defaultTaskDurationUnit": "Standaardeenheid voor nieuwe taken", "defaultTaskDurationUnitHint": "Bepaalt of nieuwe taken standaard in werkdagen of werkuren beginnen. Bestaande taken veranderen niet.", diff --git a/src/i18n/locales/nl/task.json b/src/i18n/locales/nl/task.json index 2a4b16545..557277e49 100644 --- a/src/i18n/locales/nl/task.json +++ b/src/i18n/locales/nl/task.json @@ -189,6 +189,7 @@ "assignmentDuplicateId": "Deze toewijzing komt meerdere keren voor.", "assignmentIdentity": "Deze toewijzing hoort bij een andere resource.", "assignmentUnits": "Voer eenheden per dag in als een getal groter dan 0.", + "assignmentWork": "Voer resterend werk in uren in als een getal groter dan 0 (naam: uren; …).", "assignmentCurve": "Kies een bekende belastingscurve.", "assignmentTaskNotFound": "De taak van deze toewijzing bestaat niet meer.", "assignmentTaskUnavailable": "Toewijzingen zijn op deze taak niet te bewerken.", @@ -335,6 +336,22 @@ "durationTotalHours": "Totaal uren", "hourCalendarHint": "Uren-invoer gebruikt de werktijden van de taak-kalender." }, + "workRule": { + "FIXED_DURATION_RATE": "Vaste duur en inzet", + "FIXED_DURATION_WORK": "Vaste duur en werk", + "FIXED_WORK": "Vast werk", + "FIXED_RATE": "Vaste inzet", + "label": "Werkregel", + "projectDefault": "Projectstandaard ({{rule}})", + "protects": "Beschermd: {{what}}", + "protectsFIXED_DURATION_RATE": "duur en inzet (werk volgt)", + "protectsFIXED_DURATION_WORK": "duur en werk (inzet volgt)", + "protectsFIXED_WORK": "werk (duur volgt de inzet)", + "protectsFIXED_RATE": "inzet (duur volgt het werk)", + "mspEffortDriven": "Uit MS Project: effort-driven", + "mspNotEffortDriven": "Uit MS Project: niet effort-driven", + "none": "Projectstandaard" + }, "properties": { "task": "Taak", "deleteTask": "Verwijder taak", @@ -418,7 +435,10 @@ "contour": "Urenverdeling…", "contoured": "Contour", "contouredHint": "Deze toewijzing heeft een eigen urenverdeling; bewerk of laat die los via de knop Urenverdeling.", - "importedCurve": "Geïmporteerde curve" + "importedCurve": "Geïmporteerde curve", + "work": "Werk (rest)", + "workHint": "Resterend werk in uren; de werkregel bepaalt of de duur of de inzet meebeweegt.", + "locked": "Beschermd door de werkregel {{rule}}" }, "notes": { "title": "Aantekeningen", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index f57bf6653..3f95978eb 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -107,6 +107,7 @@ "mppTimephasedSteeringLost_many": "{{count}} zadań straciło powiązanie z rozkładem godzin z MS Project po tej edycji; pierwotny rozkład pozostaje zachowany w pliku.", "mppTimephasedSteeringLost_other": "{{count}} zadania straciło powiązanie z rozkładem godzin z MS Project po tej edycji; pierwotny rozkład pozostaje zachowany w pliku.", "readMore": "Dowiedz się więcej", + "taskTypesUnlocked": "Ten plik zawiera typy zadań (reguły pracy lub zapisaną pracę); reguła pracy i pozostała praca są wyświetlane dla tego dokumentu.", "pasteSkippedReadOnly_one": "{{count}} komórka pominięta: jest tylko do odczytu (np. automatycznie numerowany kod WBS lub kolumna obliczana).", "pasteSkippedReadOnly_few": "{{count}} komórki pominięte: są tylko do odczytu (np. automatycznie numerowany kod WBS lub kolumna obliczana).", "pasteSkippedReadOnly_many": "{{count}} komórek pominiętych: są tylko do odczytu (np. automatycznie numerowany kod WBS lub kolumna obliczana).", @@ -190,6 +191,8 @@ "hourPlanningSection": "Planowanie godzinowe", "enableHourPlanning": "Włącz planowanie godzinowe", "enableHourPlanningHint": "Włącza planowanie godzinowo-minutowe: skalę czasu w godzinach, zmiany z pasmami czasu pracy i paski zadań z dokładnością do godziny. Wyłączone ⇒ aplikacja pozostaje w pełni dniowa, tak jak wcześniej.", + "showTaskTypes": "Pokaż typy zadań (reguły pracy)", + "showTaskTypesHint": "Pokazuje dla zadania regułę pracy (który róg równania praca = pozostały czas × jednostki jest stały) oraz dla przydziału pozostałą pracę — w panelu właściwości, oknie zadania i siatce. Wyłączone ⇒ aplikacja planuje jak dotychczas (czas trwania i jednostki zostają, praca się dostosowuje). Plik zawierający już typy zadań i tak je pokazuje.", "allowMixedDayHour": "Zezwól na mieszane planowanie dniowo-godzinowe", "defaultTaskDurationUnit": "Domyślna jednostka nowych zadań", "defaultTaskDurationUnitHint": "Określa, czy nowe zadania zaczynają się w dniach roboczych, czy godzinach pracy. Istniejące zadania się nie zmieniają.", diff --git a/src/i18n/locales/pl/task.json b/src/i18n/locales/pl/task.json index 15c5e1320..ed3b7e8c4 100644 --- a/src/i18n/locales/pl/task.json +++ b/src/i18n/locales/pl/task.json @@ -56,6 +56,7 @@ }, "status": { "stale": "nieaktualne" }, "validation": { + "assignmentWork": "Wpisz pozostałą pracę w godzinach jako liczbę większą od 0 (nazwa: godziny; …).", "readOnly": "Tej kolumny obliczeniowej nie można edytować.", "invalid": "Wprowadzona wartość jest nieprawidłowa.", "relationToken": "Użyj na przykład 1.2 FS+2d.", @@ -236,6 +237,22 @@ "durationTotalHours": "Suma godzin", "hourCalendarHint": "Wprowadzanie w godzinach wymaga kalendarza godzinowego (godzin pracy)." }, + "workRule": { + "FIXED_DURATION_RATE": "Stały czas trwania i jednostki", + "FIXED_DURATION_WORK": "Stały czas trwania i praca", + "FIXED_WORK": "Stała praca", + "FIXED_RATE": "Stałe jednostki", + "label": "Reguła pracy", + "projectDefault": "Domyślna dla projektu ({{rule}})", + "protects": "Chronione: {{what}}", + "protectsFIXED_DURATION_RATE": "czas trwania i jednostki (praca się dostosowuje)", + "protectsFIXED_DURATION_WORK": "czas trwania i praca (jednostki się dostosowują)", + "protectsFIXED_WORK": "praca (czas trwania zależy od jednostek)", + "protectsFIXED_RATE": "jednostki (czas trwania zależy od pracy)", + "mspEffortDriven": "Z MS Project: sterowane nakładem pracy", + "mspNotEffortDriven": "Z MS Project: niesterowane nakładem pracy", + "none": "Domyślna dla projektu" + }, "properties": { "task": "Zadanie", "deleteTask": "Usuń zadanie", @@ -299,7 +316,10 @@ "contour": "Rozkład godzin…", "contoured": "Kontur", "contouredHint": "To przypisanie ma własny rozkład godzin; edytuj go lub zwolnij przyciskiem Rozkład godzin.", - "importedCurve": "Zaimportowana krzywa" + "importedCurve": "Zaimportowana krzywa", + "work": "Praca (pozost.)", + "workHint": "Pozostała praca w godzinach; reguła pracy decyduje, czy zmienia się czas trwania, czy jednostki.", + "locked": "Chronione przez regułę pracy {{rule}}" }, "calendar": "Kalendarz", "calendarProject": "Kalendarz projektu", diff --git a/src/i18n/locales/pt/common.json b/src/i18n/locales/pt/common.json index 2630eb1d3..a5853095f 100644 --- a/src/i18n/locales/pt/common.json +++ b/src/i18n/locales/pt/common.json @@ -90,6 +90,7 @@ "mppTimephasedSteeringLost_many": "A distribuição de horas do MS Project não controla mais {{count}} tarefas após esta edição; a distribuição original permanece guardada no arquivo.", "mppTimephasedSteeringLost_other": "A distribuição de horas do MS Project não controla mais {{count}} tarefas após esta edição; a distribuição original permanece guardada no arquivo.", "readMore": "Saiba mais", + "taskTypesUnlocked": "Este ficheiro contém tipos de tarefa (regras de trabalho ou trabalho guardado); a regra de trabalho e o trabalho restante são mostrados para este documento.", "pasteSkippedReadOnly_one": "{{count}} célula ignorada: é somente leitura (por exemplo, um código WBS numerado automaticamente ou uma coluna calculada).", "pasteSkippedReadOnly_many": "{{count}} células ignoradas: são somente leitura (por exemplo, um código WBS numerado automaticamente ou uma coluna calculada).", "pasteSkippedReadOnly_other": "{{count}} células ignoradas: são somente leitura (por exemplo, um código WBS numerado automaticamente ou uma coluna calculada).", @@ -171,6 +172,8 @@ "hourPlanningSection": "Planeamento por horas", "enableHourPlanning": "Ativar planeamento por horas", "enableHourPlanningHint": "Ativa o escalonamento por hora/minuto: uma escala de tempo horária, turnos com faixas de horário de trabalho e barras de tarefa com precisão à hora. Desativado ⇒ a aplicação permanece totalmente granular por dias, como antes.", + "showTaskTypes": "Mostrar tipos de tarefa (regras de trabalho)", + "showTaskTypesHint": "Mostra por tarefa a regra de trabalho (que canto de trabalho = duração restante × unidades está fixo) e por atribuição o trabalho restante — no painel de propriedades, no diálogo da tarefa e na grelha. Desligado ⇒ a aplicação planeia como antes (duração e unidades mantêm-se, o trabalho segue). Um ficheiro que já contém tipos de tarefa mostra-os de qualquer forma.", "allowMixedDayHour": "Permitir planeamento misto de dias/horas", "defaultTaskDurationUnit": "Unidade predefinida para novas tarefas", "defaultTaskDurationUnitHint": "Define se as novas tarefas começam em dias úteis ou horas de trabalho. As tarefas existentes não mudam.", diff --git a/src/i18n/locales/pt/task.json b/src/i18n/locales/pt/task.json index 7efad5763..087b15ccc 100644 --- a/src/i18n/locales/pt/task.json +++ b/src/i18n/locales/pt/task.json @@ -56,6 +56,7 @@ }, "status": { "stale": "obsoleto" }, "validation": { + "assignmentWork": "Introduza o trabalho restante em horas como um número maior que 0 (nome: horas; …).", "readOnly": "Esta coluna calculada não pode ser editada.", "invalid": "O valor introduzido não é válido.", "relationToken": "Use, por exemplo, 1.2 FS+2d.", @@ -232,6 +233,22 @@ "durationTotalHours": "Total de horas", "hourCalendarHint": "A introdução em horas requer um calendário horário (horários de trabalho)." }, + "workRule": { + "FIXED_DURATION_RATE": "Duração e unidades fixas", + "FIXED_DURATION_WORK": "Duração e trabalho fixos", + "FIXED_WORK": "Trabalho fixo", + "FIXED_RATE": "Unidades fixas", + "label": "Regra de trabalho", + "projectDefault": "Predefinição do projeto ({{rule}})", + "protects": "Protegido: {{what}}", + "protectsFIXED_DURATION_RATE": "duração e unidades (o trabalho segue)", + "protectsFIXED_DURATION_WORK": "duração e trabalho (as unidades seguem)", + "protectsFIXED_WORK": "trabalho (a duração segue as unidades)", + "protectsFIXED_RATE": "unidades (a duração segue o trabalho)", + "mspEffortDriven": "Do MS Project: condicionado pelo esforço", + "mspNotEffortDriven": "Do MS Project: não condicionado pelo esforço", + "none": "Predefinição do projeto" + }, "properties": { "task": "Tarefa", "deleteTask": "Eliminar tarefa", @@ -295,7 +312,10 @@ "contour": "Distribuição de horas…", "contoured": "Contorno", "contouredHint": "Esta atribuição tem a sua própria distribuição de horas; edite-a ou liberte-a pelo botão Distribuição de horas.", - "importedCurve": "Curva importada" + "importedCurve": "Curva importada", + "work": "Trabalho (rest.)", + "workHint": "Trabalho restante em horas; a regra de trabalho decide se a duração ou as unidades se movem.", + "locked": "Protegido pela regra de trabalho {{rule}}" }, "calendar": "Calendário", "calendarProject": "Calendário do projeto", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 5ede04f19..ce11625af 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -73,6 +73,7 @@ "mppTimephasedSteeringLost_one": "MS Project'ten gelen saat dağılımı bu düzenlemeden sonra {{count}} görevi artık yönlendirmiyor; özgün dağılım dosyada saklı kalıyor.", "mppTimephasedSteeringLost_other": "MS Project'ten gelen saat dağılımı bu düzenlemeden sonra {{count}} görevi artık yönlendirmiyor; özgün dağılım dosyada saklı kalıyor.", "readMore": "Daha fazla bilgi", + "taskTypesUnlocked": "Bu dosya görev türleri (iş kuralları veya kaydedilmiş iş) içeriyor; iş kuralı ve kalan iş bu belge için gösteriliyor.", "pasteSkippedReadOnly_one": "{{count}} hücre atlandı: salt okunur (örneğin otomatik numaralandırılmış bir İYS kodu veya hesaplanan bir sütun).", "pasteSkippedReadOnly_other": "{{count}} hücre atlandı: salt okunur (örneğin otomatik numaralandırılmış bir İYS kodu veya hesaplanan bir sütun).", "librarySaveFailed": "Kitaplık kaydedilemedi", @@ -152,6 +153,8 @@ "hourPlanningSection": "Saat planlaması", "enableHourPlanning": "Saat planlamasını etkinleştir", "enableHourPlanningHint": "Saat/dakika zamanlamasını açar: saatlik zaman ölçeği, çalışma saati bantlarına sahip vardiyalar ve saat hassasiyetinde görev çubukları. Kapalı ⇒ uygulama önceki gibi tamamen gün bazlı kalır.", + "showTaskTypes": "Görev türlerini (iş kurallarını) göster", + "showTaskTypesHint": "Görev başına iş kuralını (iş = kalan süre × birim eşitliğinde hangi köşenin sabit olduğu) ve atama başına kalan işi gösterir — özellikler panelinde, görev iletişim kutusunda ve tabloda. Kapalı ⇒ uygulama eskisi gibi planlar (süre ve birimler kalır, iş uyar). Zaten görev türü içeren bir dosya bunları her durumda gösterir.", "allowMixedDayHour": "Karma gün/saat planlamasına izin ver", "defaultTaskDurationUnit": "Yeni görevler için varsayılan birim", "defaultTaskDurationUnitHint": "Yeni görevlerin iş günüyle mi çalışma saatiyle mi başlayacağını belirler. Mevcut görevler değişmez.", diff --git a/src/i18n/locales/tr/task.json b/src/i18n/locales/tr/task.json index 5645b6bd5..ca05d8b60 100644 --- a/src/i18n/locales/tr/task.json +++ b/src/i18n/locales/tr/task.json @@ -56,6 +56,7 @@ }, "status": { "stale": "eski" }, "validation": { + "assignmentWork": "Kalan işi saat olarak 0'dan büyük bir sayı girin (ad: saat; …).", "readOnly": "Bu hesaplanan sütun düzenlenemez.", "invalid": "Girilen değer geçerli değil.", "relationToken": "Örneğin 1.2 FS+2d kullanın.", @@ -228,6 +229,22 @@ "durationTotalHours": "Toplam saat", "hourCalendarHint": "Saat girişi bir saat takvimi (çalışma saatleri) gerektirir." }, + "workRule": { + "FIXED_DURATION_RATE": "Sabit süre ve birimler", + "FIXED_DURATION_WORK": "Sabit süre ve iş", + "FIXED_WORK": "Sabit iş", + "FIXED_RATE": "Sabit birimler", + "label": "İş kuralı", + "projectDefault": "Proje varsayılanı ({{rule}})", + "protects": "Korunan: {{what}}", + "protectsFIXED_DURATION_RATE": "süre ve birimler (iş uyar)", + "protectsFIXED_DURATION_WORK": "süre ve iş (birimler uyar)", + "protectsFIXED_WORK": "iş (süre birimleri izler)", + "protectsFIXED_RATE": "birimler (süre işi izler)", + "mspEffortDriven": "MS Project'ten: çabaya dayalı", + "mspNotEffortDriven": "MS Project'ten: çabaya dayalı değil", + "none": "Proje varsayılanı" + }, "properties": { "task": "Görev", "deleteTask": "Görevi sil", @@ -291,7 +308,10 @@ "contour": "Saat dağılımı…", "contoured": "Kontur", "contouredHint": "Bu atamanın kendi saat dağılımı var; Saat dağılımı düğmesiyle düzenleyin veya kaldırın.", - "importedCurve": "İçe aktarılan eğri" + "importedCurve": "İçe aktarılan eğri", + "work": "İş (kalan)", + "workHint": "Saat cinsinden kalan iş; iş kuralı sürenin mi yoksa birimlerin mi değişeceğine karar verir.", + "locked": "{{rule}} iş kuralı tarafından korunuyor" }, "calendar": "Takvim", "calendarProject": "Proje takvimi", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 432bef4a6..d2063dfbd 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -56,6 +56,7 @@ "projectStartAnchorsClamped_other": "项目开始日期已更改:{{count}} 个没有前置任务或限制的任务起点已移至新的开始日期。", "mppTimephasedSteeringLost_other": "此次编辑后,MS Project 的工时分布不再驱动 {{count}} 项任务;原始分布仍保留在文件中。", "readMore": "了解更多", + "taskTypesUnlocked": "此文件包含任务类型(工作规则或已保存的工时);本文档将显示工作规则和剩余工时。", "pasteSkippedReadOnly_other": "已跳过 {{count}} 个只读单元格(例如自动编号的 WBS 代码或计算列)。", "librarySaveFailed": "无法保存库", "levelingDelayRoundedToWorkdays_other": "资源平衡会将 MS Project 精确到分钟的平衡延迟({{count}} 项任务)四舍五入为整个工作日。" @@ -133,6 +134,8 @@ "hourPlanningSection": "小时计划", "enableHourPlanning": "启用小时计划", "enableHourPlanningHint": "启用小时/分钟排程:小时级时间刻度、带工作时间段的班次,以及精确到小时的任务条。关闭 ⇒ 应用将保持与之前一样完全按天粒度运行。", + "showTaskTypes": "显示任务类型(工作规则)", + "showTaskTypesHint": "按任务显示工作规则(工时 = 剩余工期 × 单位 中哪一角固定)以及按分配显示剩余工时——在属性面板、任务对话框和表格中。关闭 ⇒ 应用按以往方式排程(工期和单位保持,工时随之变化)。已包含任务类型的文件无论如何都会显示。", "allowMixedDayHour": "允许混合按天/按小时计划", "defaultTaskDurationUnit": "新任务的默认单位", "defaultTaskDurationUnitHint": "决定新任务以工作日还是工作小时开始。现有任务不会改变。", diff --git a/src/i18n/locales/zh/task.json b/src/i18n/locales/zh/task.json index 6f087635b..834d523de 100644 --- a/src/i18n/locales/zh/task.json +++ b/src/i18n/locales/zh/task.json @@ -56,6 +56,7 @@ }, "status": { "stale": "已过期" }, "validation": { + "assignmentWork": "请以大于 0 的数字输入剩余工时(小时)(名称: 小时; …)。", "readOnly": "无法编辑此计算列。", "invalid": "输入的值无效。", "relationToken": "请使用类似 1.2 FS+2d 的格式。", @@ -224,6 +225,22 @@ "durationTotalHours": "总小时数", "hourCalendarHint": "按小时输入需要小时日历(工作时间)。" }, + "workRule": { + "FIXED_DURATION_RATE": "固定工期和单位", + "FIXED_DURATION_WORK": "固定工期和工时", + "FIXED_WORK": "固定工时", + "FIXED_RATE": "固定单位", + "label": "工作规则", + "projectDefault": "项目默认({{rule}})", + "protects": "受保护:{{what}}", + "protectsFIXED_DURATION_RATE": "工期和单位(工时随之变化)", + "protectsFIXED_DURATION_WORK": "工期和工时(单位随之变化)", + "protectsFIXED_WORK": "工时(工期随单位变化)", + "protectsFIXED_RATE": "单位(工期随工时变化)", + "mspEffortDriven": "来自 MS Project:投入驱动", + "mspNotEffortDriven": "来自 MS Project:非投入驱动", + "none": "项目默认" + }, "properties": { "task": "任务", "deleteTask": "删除任务", @@ -287,7 +304,10 @@ "contour": "工时分布…", "contoured": "轮廓", "contouredHint": "此分配拥有自己的工时分布;请通过“工时分布”按钮编辑或释放。", - "importedCurve": "导入的曲线" + "importedCurve": "导入的曲线", + "work": "工时(剩余)", + "workHint": "剩余工时(小时);工作规则决定工期或单位随之变化。", + "locked": "受工作规则 {{rule}} 保护" }, "calendar": "日历", "calendarProject": "项目日历", diff --git a/src/state/documentContract.ts b/src/state/documentContract.ts index ad5c74145..2005c36d1 100644 --- a/src/state/documentContract.ts +++ b/src/state/documentContract.ts @@ -1,3 +1,4 @@ +import { hasTaskTypeData } from '@/engine/work/taskTypesVisibility'; import type { Project } from '@/types/project'; import type { WorkCalendar } from '@/types/calendar'; import type { Task } from '@/types/task'; @@ -88,6 +89,10 @@ export interface DocumentPayload { xerSourceArchive: XerSourceArchive | null; /** Selector van dit document binnen xerSourceArchive; semantiek is documentgebonden. */ xerSourceProjectId: string | null; + /** Taaktypes-etappe (spec §7): de werkregel-UI is voor dit document ontsloten omdat het al + * taaktypedata draagt (`hasTaskTypeData`) of omdat de gebruiker er een regel/werk in zette. + * Niet gepersisteerd (bij laden opnieuw afgeleid), niet in undo. */ + taskTypesVisible: boolean; } /** Per-document projectdata + metadata om bij crash-recovery te herstellen. @@ -242,6 +247,8 @@ export const DOCUMENT_FIELDS = [ field({ key: 'xerImportMetadata', get: (s) => s.xerImportMetadata, set: (s, v) => { s.xerImportMetadata = v; }, fresh: () => null, snapshot: 'none', fromPayload: (p) => p.xerImportMetadata ?? null }), field({ key: 'xerSourceArchive', get: (s) => s.xerSourceArchive, set: (s, v) => { s.xerSourceArchive = v; }, fresh: () => null, snapshot: 'none', fromPayload: (p) => p.xerSourceArchive ?? null }), field({ key: 'xerSourceProjectId', get: (s) => s.xerSourceProjectId, set: (s, v) => { s.xerSourceProjectId = v; }, fresh: () => null, snapshot: 'none', fromPayload: (p) => p.xerSourceProjectId ?? null }), + // Taaktypes-etappe (spec §7): sessie-afgeleide zichtbaarheid, geen projectdata — rol `none`. + field({ key: 'taskTypesVisible', get: (s) => s.taskTypesVisible, set: (s, v) => { s.taskTypesVisible = v; }, fresh: () => false, snapshot: 'none', fromPayload: (p) => p.taskTypesVisible ?? false }), ]; // Compile-time volledigheidscheck: elke DocumentPayload-key MOET in DOCUMENT_FIELDS staan. Voeg je @@ -415,6 +422,8 @@ export function payloadFromImport(parsed: ImportResult, filePath: string | null) xerImportMetadata: parsed.xer ?? null, xerSourceArchive: parsed.xerSourceArchive ?? null, xerSourceProjectId: parsed.xer?.sourceProjectId ?? parsed.xerSourceProjectId ?? null, + // Taaktypes-etappe (spec §7): het geladen bestand ontsluit de werkregel-UI voor zichzelf. + taskTypesVisible: hasTaskTypeData(parsed.tasks, parsed.assignments, parsed.project), filePath, isDirty: false, }; diff --git a/src/state/gridTransaction.ts b/src/state/gridTransaction.ts index 4bfc3d16c..0ad44fcbf 100644 --- a/src/state/gridTransaction.ts +++ b/src/state/gridTransaction.ts @@ -20,7 +20,7 @@ import { notifyTimephasedLoss } from './timephasedLossNotice'; import { markScheduleStale } from './transaction'; import { captureTriangle, contourKeepsWork, settleAssignmentPlan, settleDurationAftermath, settleDurationEdit, - type AssignmentSettleOp, + settleRuleChange, settleWorkEdit, type AssignmentSettleOp, } from '@/engine/work/workRuleApply'; import { taskCalendarHoursPerDay, taskWorkMinutesOf } from '@/utils/taskDefaults'; import { generateId } from '@/utils/id'; @@ -132,6 +132,9 @@ function buildGridColumnRuntime(state: Readonly): GridColumnRuntime { resourcesById: new Map(state.resources.map(resource => [resource.id, resource])), baselinesById: new Map(state.baselines.map(baseline => [baseline.id, baseline])), scheduleStale: state.scheduleStale, + // Taaktypes-etappe (spec §7): dezelfde ontsluiting als `FullTaskGrid` — anders weigert de + // gridtransactie een Werkregel-cel die de kolomkiezer wél toont. + taskTypesUnlocked: state.ui.showTaskTypes || state.taskTypesVisible, wbsAutoNumber: state.project.wbsAutoNumber === true, effectiveHoursPerDay: task => effHoursPerDay(effectiveCalendarOf( task, state.calendar, state.calendars, @@ -316,7 +319,8 @@ function applyAssignmentSet( const columnId = String(intent.columnId); if (columnId !== 'assignment.resources' && columnId !== 'assignment.unitsPerDay' - && columnId !== 'assignment.curve') { + && columnId !== 'assignment.curve' + && columnId !== 'assignment.remainingWork') { return { ok: false, errors: [validationError('plannerNotAvailable', intent, intent.tokens)] }; } let tokens = intent.tokens; @@ -374,7 +378,25 @@ function applyAssignmentSet( } } const settled = settleAssignmentPlan(task, state.assignments, triangle, ops); - if (settled.durationChanged) { + let durationChanged = settled.durationChanged; + // Taaktypes-etappe (spec §7): de kolom "Resterend werk" — per toewijzing één werkbewerking door + // de driehoek (`settleWorkEdit`), ná het (hier lege) plan. + if (columnId === 'assignment.remainingWork') { + const byId = new Map(assignmentsForTask.map(a => [a.id, a] as const)); + const byResource = new Map(assignmentsForTask.map(a => [a.resourceId, a] as const)); + for (const token of intent.tokens) { + const current = (token.assignmentId ? byId.get(token.assignmentId) : undefined) ?? byResource.get(token.resourceId); + const w = token.remainingWorkMinutes; + if (!current || typeof w !== 'number' || !Number.isFinite(w) || w <= 0) continue; + if (current.remainingWorkMinutes !== undefined && Math.abs(current.remainingWorkMinutes - w) < 1e-6) continue; + const live = state.assignments.find(a => a.id === current.id); + if (!live) continue; + const result = settleWorkEdit(task, state.assignments, state, live.id, w); + if (result?.durationChanged) durationChanged = true; + if (result) state.taskTypesVisible = true; + } + } + if (durationChanged) { // Zelfde nazorg als een duurbewerking (`settleDurationAftermath`: contour, importsplits, Z8- // venster én bevroren duur-walks — reviewbevinding K5). const lost = settleDurationAftermath(task, state, oldWorkMinutes); @@ -650,11 +672,18 @@ function applyCellEdits( // toewijzingen daarna hun regel volgen (`settleDurationEdit`) — onder de standaardregel zonder // werkvelden verandert er niets. const triangle = captureTriangle(task, assignmentsForTask, state); + const workRuleBefore = task.workRule; const planned = planTaskCellEdits(task, finalEdits, environment); if (!planned.ok) return planned; if (planned.value.changed) { state.tasks[taskIndex] = planned.value.task; settleDurationEdit(state.tasks[taskIndex], state.assignments, triangle); + // Taaktypes-etappe (spec §7, besluit 2): een typewissel in het raster legt — net als + // `setTaskWorkRule` — onder een werkbeschermende regel het huidige restwerk vast. + if (state.tasks[taskIndex].workRule !== workRuleBefore) { + settleRuleChange(state.tasks[taskIndex], state.assignments, state, state.tasks[taskIndex].workRule); + state.taskTypesVisible = true; + } if (planned.value.scheduleStale) { if (state.datesAsRecorded) { state.datesAsRecorded = false; diff --git a/src/state/slices/documentSlice.ts b/src/state/slices/documentSlice.ts index 1ab0e0b49..fb6b93ed3 100644 --- a/src/state/slices/documentSlice.ts +++ b/src/state/slices/documentSlice.ts @@ -385,6 +385,7 @@ export const createDocumentSlice: AppSliceFactory = (runtime) => // voor bron, twaalf tabs en varianten; elke IFC-save embedt later wél een eigen container. xerSourceArchive: src.xerSourceArchive, xerSourceProjectId: src.xerSourceProjectId, + taskTypesVisible: src.taskTypesVisible, }; const activation = materializeLibraryBoundary({ payload: copy, companies: source.companies, pools: source.pools, mode: 'silent-switch', diff --git a/src/state/slices/fileSlice.ts b/src/state/slices/fileSlice.ts index 842b077fe..e14b2027c 100644 --- a/src/state/slices/fileSlice.ts +++ b/src/state/slices/fileSlice.ts @@ -1,3 +1,4 @@ +import { notifyTaskTypesUnlocked } from '../taskTypesNotice'; import { writeIFC } from '@/services/ifc/ifcWriter'; import { writeCSV } from '@/services/csv/csvWriter'; import { writeMSPDI } from '@/services/msproject/mspdiWriter'; @@ -358,6 +359,12 @@ export const createFileSlice: AppSliceFactory = (runtime) => (set, ge s.ui.hourDataNotice = !s.ui.enableHourPlanning && fileHasHourData(s.tasks, [s.calendar, ...s.calendars]); } }); + // Taaktypes-etappe (spec §7): het bestand draagt taaktypedata terwijl de instelling uit staat + // ⇒ de werkregel-UI is voor dit document ontsloten; meld dat één keer (met gids-link). + { + const st = get(); + if (st.taskTypesVisible && !st.ui.showTaskTypes) notifyTaskTypesUnlocked(st.notify, st.activeDocumentId); + } if (opts.recompute) { const cpm = activation.payload.cpmResult; if (cpm?.error) { diff --git a/src/state/slices/projectSlice.ts b/src/state/slices/projectSlice.ts index 9bf9f3273..155abd9bb 100644 --- a/src/state/slices/projectSlice.ts +++ b/src/state/slices/projectSlice.ts @@ -91,6 +91,8 @@ export interface ProjectSlice { xerImportMetadata: XerImportMetadata | null; xerSourceArchive: XerSourceArchive | null; xerSourceProjectId: string | null; + /** Taaktypes-etappe (spec §7): werkregel-UI ontsloten voor dit document; zie DOCUMENT_FIELDS. */ + taskTypesVisible: boolean; setProject: (project: Partial) => void; /** Zet WBS-autonummering aan/uit; bij aanzetten wordt de hele boom direct hernummerd. */ setWbsAutoNumber: (on: boolean) => void; @@ -180,6 +182,7 @@ export const createProjectSlice: AppSliceFactory = (runtime) => (s xerImportMetadata: null, xerSourceArchive: null, xerSourceProjectId: null, + taskTypesVisible: false, setProject: (updates) => { // T7b (plan-§9/O2-vervolg, orkestratorbesluit 2026-08-15 — optie B, ná escalatie T7 + de diff --git a/src/state/slices/resourceSlice.ts b/src/state/slices/resourceSlice.ts index 6110e5c84..a16ea9602 100644 --- a/src/state/slices/resourceSlice.ts +++ b/src/state/slices/resourceSlice.ts @@ -259,6 +259,7 @@ export const createResourceSlice: AppSliceFactory = (runtime) => if (!plan) return; runtime.beginUndoable(s); const settled = commitTrianglePlan(task, s.assignments, plan); + s.taskTypesVisible = true; // spec §7: documentontsluiting. if (settled.durationChanged) lostTimephasedGuidance = settleDurationAftermath(task, s, oldWorkMinutes); runtime.finishMutation(s, { stale: settled.durationChanged }); }); diff --git a/src/state/slices/taskSlice.ts b/src/state/slices/taskSlice.ts index ff3f65b20..e3e72226e 100644 --- a/src/state/slices/taskSlice.ts +++ b/src/state/slices/taskSlice.ts @@ -457,6 +457,7 @@ export const createTaskSlice: AppSliceFactory = (runtime) => (set, ge if (!task || task.workRule === rule) return; // onbekend id of ongewijzigd: geen snapshot. runtime.beginUndoable(s); settleRuleChange(task, s.assignments, s, rule); + s.taskTypesVisible = true; // spec §7: wie een regel zet, ziet de regel (documentontsluiting). runtime.finishMutation(s); // geen `stale`: een typewissel raakt geen datum (spec besluit 2). }); get().recomputeResourceLoad(); // een vastgelegd restwerk kan de vierde bron van `assignmentDayUnits` activeren. diff --git a/src/state/slices/types.ts b/src/state/slices/types.ts index 350e477ca..0e79b9dd8 100644 --- a/src/state/slices/types.ts +++ b/src/state/slices/types.ts @@ -232,7 +232,10 @@ export type NotificationMessageKey = // B1c-plan-2 taak 1 (M10, eigenaarsbesluit 2026-08-31): nivelleren/wissen overschrijft de // `.mpp`-eigen sub-dag-nivelleervertraging (`levelingDelayMinutes`/`levelingDelayElapsed`) met // hele werkdagen — zie `src/state/timephasedLossNotice.ts`s `notifyLevelingDelayRounded`. - | 'notifications.levelingDelayRoundedToWorkdays'; + | 'notifications.levelingDelayRoundedToWorkdays' + // Taaktypes-etappe (spec §7): het geladen bestand draagt taaktypedata terwijl "Toon taaktypes" + // uit staat — de werkregel-UI is voor dit document ontsloten; zie `src/state/taskTypesNotice.ts`. + | 'notifications.taskTypesUnlocked'; /** Een vertaalde detailregel onder een toast. Anders dan `detail` is deze tekst altijd * gebruikerszichtbaar en dus via dezelfde gesloten sleutelunie en i18n-keten getypeerd. */ @@ -386,6 +389,10 @@ export interface UIState { dateNotation: DateNotation; // persisted — weergavenotatie voor datums (taak #53); opslag blijft ISO // --- Fase 2.8b: urenplanning-instellingen (§6.8); ontbrekende sleutel ⇒ default (geen reset) --- enableHourPlanning: boolean; // persisted — hoofdschakelaar Urenplanning (default UIT) + /** persisted (`ops-showTaskTypes`, taaktypes-etappe spec §7) — toon de werkregel (taaktype) en het + * resterende werk per toewijzing in paneel, dialoog en raster. Default UIT; een document dat al + * taaktypedata draagt ontsluit de weergave voor zichzelf (`taskTypesVisible`, DOCUMENT_FIELDS). */ + showTaskTypes: boolean; allowMixedDayHour: boolean; // persisted — Gemengde dag/uur-planning toestaan (default AAN); UI-poort durationDisplay: DurationDisplay; // persisted — Duurweergave (default 'auto') barSplitMode: BarSplitMode; // persisted — Taakbalken bij onderbrekingen (default 'selection') diff --git a/src/state/slices/uiSlice.ts b/src/state/slices/uiSlice.ts index 69fdda263..4f9714f8a 100644 --- a/src/state/slices/uiSlice.ts +++ b/src/state/slices/uiSlice.ts @@ -152,6 +152,7 @@ export function createDefaultUI(): UIState { // Issue #21 punt 5 (fase 2): default UIT (§0/§7.1 user-besluit). compressNonWorkdays: false, hourDataNotice: false, + showTaskTypes: false, structureLockedNotice: 0, showShortcutsDialog: false, showBenchmarkDialog: false, diff --git a/src/state/taskTypesNotice.ts b/src/state/taskTypesNotice.ts new file mode 100644 index 000000000..9d01bce91 --- /dev/null +++ b/src/state/taskTypesNotice.ts @@ -0,0 +1,34 @@ +import type { NotifyInput } from './slices/types'; + +/** + * Taaktypes-etappe (spec §7, "automatische ontsluiting"): één informatieve melding per document + * per sessie wanneer een geladen bestand taaktypedata draagt terwijl de instelling "Toon + * taaktypes" uit staat — dezelfde sessie-gate als `timephasedLossNotice.ts` (module-state, geen + * documentveld: sessie-UI-gedrag, overleeft een documentwissel-en-terug). Zichtbaar wordt de + * werkregel voor dát document sowieso (`taskTypesVisible`); de melding vertelt dat en linkt naar de + * gids. + */ +const notifiedDocIds = new Set(); + +export const TASK_TYPES_HELP_ARTICLE_ID = 'gids-taaktypes'; + +export function claimTaskTypesNotice(docId: string): boolean { + if (notifiedDocIds.has(docId)) return false; + notifiedDocIds.add(docId); + return true; +} + +/** Test-only reset (zelfde reden als `__resetTimephasedLossNoticeForTests`). */ +export function __resetTaskTypesNoticeForTests(): void { + notifiedDocIds.clear(); +} + +export function notifyTaskTypesUnlocked(notify: (n: NotifyInput) => void, docId: string): void { + if (!claimTaskTypesNotice(docId)) return; + notify({ + severity: 'info', + messageKey: 'notifications.taskTypesUnlocked', + dedupeKey: 'task-types-unlocked', + helpArticleId: TASK_TYPES_HELP_ARTICLE_ID, + }); +} diff --git a/src/types/taskGrid.ts b/src/types/taskGrid.ts index a9ee77495..722f668ce 100644 --- a/src/types/taskGrid.ts +++ b/src/types/taskGrid.ts @@ -90,6 +90,9 @@ export interface TaskAssignmentToken { assignmentId?: string; unitsPerDay: number; curve?: ResourceCurve; + /** Taaktypes-etappe (spec §7): resterend werk in werkminuten — alleen de kolom + * `assignment.remainingWork` zet 'm; `gridTransaction.ts` voert hem via de werkdriehoek uit. */ + remainingWorkMinutes?: number; } /** Eén al geparseerde domeinwrite. Paste groepeert deze writes, maar mag zichzelf niet nesten. */ @@ -130,6 +133,9 @@ export interface TaskColumnContext { effectiveHoursPerDay?: (task: Task) => number; /** De adapter levert hier de echte projectkalenderberekening voor baselineafwijkingen. */ signedWorkDaysBetween?: (fromIso: string, toIso: string) => number; + /** Taaktypes-etappe (spec §7): de werkregel-kolommen bestaan alleen wanneer de weergave + * ontsloten is (instelling of documentontsluiting, `taskTypesUnlocked`). */ + taskTypesUnlocked?: boolean; } export interface TaskColumnDescriptor { diff --git a/src/utils/settingsRegistry.ts b/src/utils/settingsRegistry.ts index 514ca7604..cecd37539 100644 --- a/src/utils/settingsRegistry.ts +++ b/src/utils/settingsRegistry.ts @@ -189,6 +189,7 @@ export const SETTINGS: SettingDescriptor[] = [ // Urenplanning (fase 2.8b, §6.8) setting({ key: 'enableHourPlanning', field: 'enableHourPlanning', parse: parseBoolean }), + setting({ key: 'showTaskTypes', field: 'showTaskTypes', parse: parseBoolean }), setting({ key: 'allowMixedDayHour', field: 'allowMixedDayHour', parse: parseBoolean }), setting({ key: 'durationDisplay', field: 'durationDisplay', parse: parseEnum(DURATION_DISPLAYS) }), setting({ key: 'barSplitMode', field: 'barSplitMode', parse: parseEnum(BAR_SPLIT_MODES) }), diff --git a/src/utils/settingsStore.ts b/src/utils/settingsStore.ts index 8aa6898a2..38e7a9a23 100644 --- a/src/utils/settingsStore.ts +++ b/src/utils/settingsStore.ts @@ -412,6 +412,11 @@ export async function saveEnableHourPlanning(value: boolean): Promise { await setSetting('enableHourPlanning', value); } +/** Taaktypes-etappe (spec §7): "Toon taaktypes" — werkregel en resterend werk zichtbaar in de UI. */ +export async function saveShowTaskTypes(value: boolean): Promise { + await setSetting('showTaskTypes', value); +} + /** App-brede UI-poort: de bestaande `ops-allowMixedDayHour`-sleutel blijft ongewijzigd leesbaar. */ export async function saveAllowMixedDayHour(value: boolean): Promise { await setSetting('allowMixedDayHour', value); diff --git a/tests/browser/work-rule.spec.ts b/tests/browser/work-rule.spec.ts new file mode 100644 index 000000000..39bcac5c0 --- /dev/null +++ b/tests/browser/work-rule.spec.ts @@ -0,0 +1,94 @@ +import type { Page } from '@playwright/test'; +import { expect, seedProject, test } from './fixtures/ops'; + +// Taaktypes-etappe (spec 2026-09-04 §7, bouwstap 5): de werkregel en het resterende werk bewerken +// via het eigenschappenpaneel. Fixture via de brug (taak + resource + toewijzing, instelling +// "Toon taaktypes" aan); de geteste handelingen zijn echte keuzelijst- en invoerbewerkingen. +// Asserties op de store (werkregel, werkveld, duur, undo) — geen canvaspixels. + +async function seedAssignedTask(page: Page): Promise<{ taskId: string; resourceId: string }> { + const [taskId] = await seedProject(page, [ + { name: 'Metselwerk', start: '2026-09-07', finish: '2026-09-10', durationDays: 4 }, + ]); + return page.evaluate((id) => { + const s = window.__OPS__!.store.getState(); + const resourceId = s.addResource({ name: 'Metselploeg', type: 'LABOR', description: '', maxUnits: 2 }); + s.assignResource(id, resourceId, 1); + s.runCPM(); + s.setUI({ showPropertiesPanel: true, rightPanelCollapsed: false, showTaskTypes: true }); + s.selectTask(id); + return { taskId: id, resourceId }; + }, taskId); +} + +function taskState(page: Page, taskId: string): Promise<{ workRule?: string; duration: number; units: number; work?: number; stale: boolean }> { + return page.evaluate((id) => { + const s = window.__OPS__!.store.getState(); + const t = s.tasks.find(x => x.id === id)!; + const a = s.assignments.find(x => x.taskId === id)!; + return { workRule: t.workRule, duration: t.time.scheduleDuration, units: a.unitsPerDay, work: a.remainingWorkMinutes, stale: s.scheduleStale }; + }, taskId); +} + +test('werkregel kiezen, werk typen en inzet wijzigen volgen de regel; undo in één stap', async ({ page, ops: _ops }) => { + const { taskId } = await seedAssignedTask(page); + + const select = page.locator('[data-ops-work-rule]'); + await expect(select).toBeVisible(); + // Standaard: projectstandaard (vaste duur en inzet) — de inzetkolom draagt het slotje. + await expect(page.locator('[data-ops-assignment-lock-units]')).toHaveAttribute('data-ops-assignment-lock-units', 'locked'); + await expect(page.locator('[data-ops-assignment-work]')).toHaveAttribute('data-ops-assignment-work', 'derived'); + + // Vast werk kiezen: geen getal verandert, het restwerk (4 d × 8 u = 32 u) wordt vastgelegd. + await select.selectOption('FIXED_WORK'); + let st = await taskState(page, taskId); + expect(st.workRule).toBe('FIXED_WORK'); + expect(st.duration).toBe(4); + expect(st.work).toBe(4 * 8 * 60); + await expect(page.locator('[data-ops-work-rule-protects]')).toHaveAttribute('data-ops-work-rule-protects', 'FIXED_WORK'); + await expect(page.locator('[data-ops-assignment-lock-work]')).toHaveAttribute('data-ops-assignment-lock-work', 'locked'); + await expect(page.locator('[data-ops-assignment-work]')).toHaveAttribute('data-ops-assignment-work', 'stored'); + + // Werk 32 → 64 uur typen: onder vast werk wordt de taak twee keer zo lang (8 d), inzet blijft 1. + const workInput = page.locator('[data-ops-assignment-work] input'); + await expect(workInput).toHaveValue('32'); + await workInput.fill('64'); + await workInput.press('Enter'); + st = await taskState(page, taskId); + expect(st.duration).toBe(8); + expect(st.units).toBe(1); + expect(st.work).toBe(64 * 60); + expect(st.stale).toBe(true); + + // Inzet 1 → 2 via de inzetkolom: 64 u ÷ 2 = 4 d. + const row = page.locator('[data-ops-assignment-row]').first(); + const unitsInput = row.locator('input').first(); + await unitsInput.fill('2'); + await unitsInput.press('Enter'); + st = await taskState(page, taskId); + expect(st.duration).toBe(4); + expect(st.units).toBe(2); + + // Undo in één stap: terug naar 8 d en inzet 1. + await page.keyboard.press('Control+z'); + st = await taskState(page, taskId); + expect(st.duration).toBe(8); + expect(st.units).toBe(1); + + // Terug naar de projectstandaard: het veld verdwijnt, het werk blijft staan (besluit 2). + await select.selectOption(''); + st = await taskState(page, taskId); + expect(st.workRule).toBeUndefined(); + expect(st.work).toBe(64 * 60); +}); + +test('instelling uit en document zonder taaktypes: geen werkregel-UI; een gezette regel ontsluit het document', async ({ page, ops: _ops }) => { + const { taskId } = await seedAssignedTask(page); + await page.evaluate(() => window.__OPS__!.store.getState().setUI({ showTaskTypes: false })); + await expect(page.locator('[data-ops-work-rule]')).toHaveCount(0); + await expect(page.locator('[data-ops-assignment-header]')).toHaveCount(0); + // Documentontsluiting: een regel via de store zet `taskTypesVisible` ⇒ de UI verschijnt. + await page.evaluate((id) => window.__OPS__!.store.getState().setTaskWorkRule(id, 'FIXED_RATE'), taskId); + await expect(page.locator('[data-ops-work-rule]')).toBeVisible(); + await expect(page.locator('[data-ops-work-rule]')).toHaveValue('FIXED_RATE'); +}); diff --git a/tests/planning/check-grid-transaction.ts b/tests/planning/check-grid-transaction.ts index b922b9704..870cc6f36 100644 --- a/tests/planning/check-grid-transaction.ts +++ b/tests/planning/check-grid-transaction.ts @@ -949,6 +949,9 @@ function observed(state: AppState): unknown { [ 'task.constraint.hard', 'task.isHammock', 'task.mandatory', 'task.milestoneKind', 'task.notes', 'task.time.durationUnit', 'task.time.scheduleDuration', 'task.wbsCode', + // Taaktypes-etappe (2026-09-05): `task.workRule` leest alleen isMilestone/isHammock (controllers) + // en childIds (nooit cel-schrijfbaar) — gecertificeerd, zelfde klasse als scheduleDuration. + 'task.workRule', ].sort()); for (const controllerId of controllerIdsInSource) { ok(`Controllerveld ${controllerId} bestaat als echte, via cell-edit schrijfbare kolom`, diff --git a/tests/planning/check-work-rule-store.ts b/tests/planning/check-work-rule-store.ts index 18eade9c2..4fe07f461 100644 --- a/tests/planning/check-work-rule-store.ts +++ b/tests/planning/check-work-rule-store.ts @@ -19,6 +19,10 @@ import { createDefaultTaskTime } from '@/utils/taskDefaults'; import type { AssignmentSetIntent, CellEditIntent } from '@/types/taskGrid'; import type { Task } from '@/types/task'; import type { ResourceAssignment } from '@/types/resource'; +import { hasTaskTypeData } from '@/engine/work/taskTypesVisibility'; +import { SETTINGS } from '@/utils/settingsRegistry'; +import { buildTaskColumnRegistry } from '@/engine/taskGrid/taskColumnRegistry'; +import { buildTaskRelationIndex } from '@/engine/taskGrid/relationIndex'; const S = () => useAppStore.getState(); let checks = 0; @@ -440,6 +444,61 @@ console.log('-- (m) workRule via de generieke updateTask legt óók vast (review void events0; } +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- (n) bouwstap 5: ontsluiting, instelling en de rasterkolommen Werkregel / Resterend werk --'); +{ + eq('n1 hasTaskTypeData: leeg ⇒ false', hasTaskTypeData([], []), false); + eq('n2 hasTaskTypeData: workRule/mspTaskType/p6DurationType/werkveld/projectstandaard ⇒ true', [ + hasTaskTypeData([{ workRule: 'FIXED_WORK' }], []), + hasTaskTypeData([{ mspTaskType: 'FIXED_UNITS' }], []), + hasTaskTypeData([{ p6DurationType: 'DT_FixedQty' }], []), + hasTaskTypeData([], [{ remainingWorkMinutes: 60 }]), + hasTaskTypeData([], [], { defaultWorkRule: 'FIXED_RATE' }), + ], [true, true, true, true, true]); + ok('n3 instelling showTaskTypes staat in het register', SETTINGS.some((d) => d.key === 'showTaskTypes' && d.field === 'showTaskTypes')); + reset(); + eq('n4 vers document: niet ontsloten, instelling uit', [S().taskTypesVisible, S().ui.showTaskTypes], [false, false]); + const t = S().addTask({ name: 'n', time: createDefaultTaskTime('2026-06-01', 4) }); + const r1 = labor('r1'); + S().assignResource(t, r1, 1); + S().runCPM(); + S().setTaskWorkRule(t, 'FIXED_WORK'); + eq('n5 een gezette regel ontsluit het document', S().taskTypesVisible, true); + // Raster: de kolom Werkregel is alleen beschikbaar wanneer ontsloten. + const registry = buildTaskColumnRegistry({ projectId: S().project.id, activityCodeTypes: [], customFieldDefs: [], baselines: [], customTaskTypes: [] }); + const workRuleCol = registry.find((d) => String(d.id) === 'task.workRule')!; + const workCol = registry.find((d) => String(d.id) === 'assignment.remainingWork')!; + const ctxBase = { projectId: S().project.id, tasksById: new Map(), relationIndex: buildTaskRelationIndex([], [], null), assignmentsByTaskId: new Map(), resourcesById: new Map(), baselinesById: new Map(), scheduleStale: false }; + eq('n6 Werkregel- en Resterend-werk-kolom alleen beschikbaar wanneer ontsloten', [ + workRuleCol.available({ ...ctxBase, taskTypesUnlocked: true }), workRuleCol.available(ctxBase), + workCol.available({ ...ctxBase, taskTypesUnlocked: true }), workCol.available(ctxBase), + ], [true, false, true, false]); + eq('n7 Werkregel is bewerkbaar met de vier waarden + projectstandaard', workRuleCol.editorOptions?.map((o) => o.value), ['', 'FIXED_DURATION_RATE', 'FIXED_DURATION_WORK', 'FIXED_WORK', 'FIXED_RATE']); + // Raster: typewissel via de cel legt het werk vast (zelfde als setTaskWorkRule), terug naar '' wist. + S().setTaskWorkRule(t, undefined); + useAppStore.setState((s) => { const a = s.assignments.find((x) => x.taskId === t)!; delete a.remainingWorkMinutes; }); + const cell = (columnId: string, route: CellEditIntent['route'], value: unknown): CellEditIntent => + ({ kind: 'cell-edit', taskId: t, columnId: columnId as CellEditIntent['columnId'], route, value }); + eq('n8 rastercel Werkregel → FIXED_WORK slaagt', runGridMutation([cell('task.workRule', 'task-field', 'FIXED_WORK')]).ok, true); + eq('n9 …regel gezet én werk vastgelegd (4 slots)', [task(t).workRule, asgOf(t, r1).remainingWorkMinutes], ['FIXED_WORK', 4 * slot()]); + eq('n10 rastercel Werkregel → leeg slaagt en wist het veld', [runGridMutation([cell('task.workRule', 'task-field', undefined)]).ok, task(t).workRule], [true, undefined]); + eq('n11 rastercel Werkregel ongeldig wordt geweigerd', runGridMutation([cell('task.workRule', 'task-field', 'fixed_work')]).ok, false); + // Raster: Resterend werk als assignment-set: 4 slots → 8 slots onder FIXED_WORK ⇒ duur 8. + S().setTaskWorkRule(t, 'FIXED_WORK'); + S().runCPM(); + const setWork: AssignmentSetIntent = { + kind: 'assignment-set', taskId: t, columnId: 'assignment.remainingWork' as AssignmentSetIntent['columnId'], + tokens: [{ resourceId: r1, assignmentId: asgOf(t, r1).id, unitsPerDay: 1, remainingWorkMinutes: 8 * slot() }], + }; + eq('n12 rastercel Resterend werk slaagt', runGridMutation([setWork]).ok, true); + eq('n13 …duur 8, werk 8 slots, planning verouderd', [task(t).time.scheduleDuration, asgOf(t, r1).remainingWorkMinutes, S().scheduleStale], [8, 8 * slot(), true]); + S().undo(); + eq('n14 …undo in één stap', [task(t).time.scheduleDuration, asgOf(t, r1).remainingWorkMinutes], [4, 4 * slot()]); + // Parser van de kolom: "naam: uren". + const parsed = workCol.parse!('r1: 12', task(t), { ...ctxBase, taskTypesUnlocked: true, tasksById: new Map([[t, task(t)]]), assignmentsByTaskId: new Map([[t, S().assignments.filter((a) => a.taskId === t)]]), resourcesById: new Map(S().resources.map((r) => [r.id, r])), effectiveHoursPerDay: () => S().calendar.hoursPerDay }); + eq('n15 parser "r1: 12" ⇒ 720 werkminuten', parsed.ok ? (parsed.value as { remainingWorkMinutes?: number }[])[0]?.remainingWorkMinutes : parsed, 720); +} + console.log(`\n${checks} checks, ${diffs.length} afwijking(en)`); if (diffs.length > 0) { for (const d of diffs) console.log(`XX ${d}`); diff --git a/tests/planning/taskGridPerformanceHarness.ts b/tests/planning/taskGridPerformanceHarness.ts index 7a8c9c0c8..1814d73a2 100644 --- a/tests/planning/taskGridPerformanceHarness.ts +++ b/tests/planning/taskGridPerformanceHarness.ts @@ -330,8 +330,13 @@ export function runTaskGridPasteBenchmark(warmups = 2, runs = 9): TaskGridPasteB }); // Alleen gewone, per-cel bewerkbare velden — relaties/technische samengestelde kolommen // ('tokens'/'technical') hebben een eigen tokenformaat en horen niet in deze meting. + // Taaktypes-etappe (2026-09-05): kolommen die alleen ontsloten beschikbaar zijn (`task.workRule`, + // `available(ctx)` leest `taskTypesUnlocked`) horen niet in de vaste 27 — de meting gebruikt, + // net als de echte adapter, alleen kolommen die in DIT (niet-ontsloten) context beschikbaar zijn. + const availabilityContext = { projectId: state.project.id } as TaskColumnContext; const writable = descriptors.filter(descriptor => descriptor.readOnly !== true && typeof descriptor.parse === 'function' + && descriptor.available(availabilityContext) && descriptor.valueKind !== 'tokens' && descriptor.valueKind !== 'technical'); const wbsPosition = writable.findIndex(descriptor => descriptor.id === taskColumnId('task.wbsCode')); const ordered = wbsPosition > 0 From 38da59f23830156e55476a7c6cc16065aa313145 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 16:16:08 +0000 Subject: [PATCH 147/152] =?UTF-8?q?docs(taaktypes):=20stap=208=20=E2=80=94?= =?UTF-8?q?=20specregistratie=20en=20wiki-featurelijst=20bijgewerkt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/superpowers/README.md: de spec staat nu als "gebouwd (stappen 1–7)" met de code-aanwijzers; docs/wiki/Features.md krijgt de feature "Task types and work" met link naar de gids gids-taaktypes. verify:docs groen. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0159unb6VzxUx7WG4w3a2ARA --- docs/superpowers/README.md | 2 +- docs/wiki/Features.md | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/README.md b/docs/superpowers/README.md index a0db05f4f..88f3ec66b 100644 --- a/docs/superpowers/README.md +++ b/docs/superpowers/README.md @@ -67,7 +67,7 @@ Wél verplaatst, omdat ze nergens meer bij horen: | document | status | |---|---| | `HANDOFF-2026-08-14-roadmap.md` | **actief** — wat er nog op de roadmap staat, met peildatum en afhankelijkheden | -| `specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md` | **actief ontwerp, bouwstap 3 gebouwd** — taaktypes/werkregels, opgeslagen werk per toewijzing en effort-driven bewerken; opvolger van `specs/2026-08-18-spec-taaktypes-effort-driven.md`. Bevat de MSP/P6-documentatievergelijking, de regeltabel, de meetlat (31 bewerkingen) en alle eigenaarsbesluiten (1–10, genomen). De pure rekenkern (`src/engine/work/workTriangle.ts`) staat; de overige stappen bouwen pas ná de tweede XER-merge. | +| `specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md` | **gebouwd (stappen 1–7, 2026-09-05; stap 8 = afronding docs)** — taaktypes/werkregels, opgeslagen werk per toewijzing en effort-driven bewerken; opvolger van `specs/2026-08-18-spec-taaktypes-effort-driven.md`. Bevat de MSP/P6-documentatievergelijking, de regeltabel, de meetlat (31 bewerkingen), alle eigenaarsbesluiten (1–10) en per stap de status en de verwerkte reviewbevindingen (§10). Code: `src/engine/work/` (kern, brug, zichtbaarheid), bedrading in de slices/het raster/de MCP-tweeling, UI in `TaskWorkRuleField`/`TaskAssignmentsSection`, gids `gids-taaktypes`. Open punten staan in `docs/TODO.md`. | | `werkdagen-as-ontwerp.md` | naslag; aangehaald vanuit `timeAxis.ts`, `workdayAxis.ts` en `check-workday-axis.ts` | | `verticale-drag-ontwerp.md`, `verticale-drag-ontwerp-B.md` | naslag; twee varianten van hetzelfde ontwerp | | `modulariteit-audit.md`, `prestatie-modulariteit-audit.md` | de audits waar de P-bevindingen uit komen; aangehaald vanuit testkoppen | diff --git a/docs/wiki/Features.md b/docs/wiki/Features.md index 1e97025f9..04e24ded0 100644 --- a/docs/wiki/Features.md +++ b/docs/wiki/Features.md @@ -29,6 +29,11 @@ bridge and automatic updates. (from the library, project-only, or orphaned) and a library/project view toggle. See [Resource libraries](docs://gids-resourcebibliotheken) in the manual. - **Assignments** — assign resources to tasks, with time-phased max-units availability. +- **Task types and work** — a work rule per task (fixed duration and units, fixed duration and + work, fixed work, fixed units — the MS Project task types and P6 duration types) decides which + corner of work = remaining duration × units moves when you edit another; remaining work per + assignment is editable in hours. Hidden by default; a file that already carries task types shows + them. See [Task types and work](docs://gids-taaktypes) in the manual. - **Histogram & leveling** — a resource histogram plus automatic leveling options, including leveling priority per task and leveling within slack only. - **Occupancy overview** — for multiple open projects drawing from the same library, a From 4016ebe89598bdb8fed6cfe16d2479749325146a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 16:36:12 +0000 Subject: [PATCH 148/152] =?UTF-8?q?fix(taaktypes):=20reviewronde=20stap=20?= =?UTF-8?q?5/7=20=E2=80=94=20plakken=20behoudt=20werk,=20werkcel=20bevries?= =?UTF-8?q?t=20niets,=20typewissel=20zonder=20stale,=20dialoog=20commit=20?= =?UTF-8?q?direct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B1: `validateAssignmentTokens` laat `remainingWorkMinutes` meereizen bij plakken naar een andere taak (de genormaliseerde tokens lieten het werk vallen ⇒ stille no-op). B2: de werkcel in het raster vergelijkt met de GETOONDE waarde (opgeslagen, anders restduur × inzet) zodat een niet-bewerkte toewijzing haar afgeleide getal niet als expliciet werk krijgt. B3: de werkregel in het eigenschappenpaneel loopt via `setTaskWorkRule` — geen `scheduleStale`, "datums zoals opgeslagen" blijft staan. B4: in de taakdialoog commit een typewissel op een bestaande taak direct (zoals de toewijzings- sectie), en Opslaan stuurt de duur alleen mee wanneer de gebruiker die in de dialoog wijzigde (anders draaide Opslaan een duur uit de driehoek terug). K1: `clearTaskTypesNoticeForDoc` bij newProject/createNewProject. K3: elk schrijfpad (updateTask, addTask, MCP-tweelingen) ontsluit het document. K4: werkkolom en rasterkolom alleen waar `workRuleApplies`. K5: werkinvoer commit op Enter/blur (`WorkHoursInput`), niet per toetsaanslag. K6: contour- restsom als afgeleide waarde, aria-labels met resourcenaam, sectiekop in de instellingen (`settings.taskTypesSection`, 14 locales). MCP: `planner_manage_assignments` `add` kent `remainingWorkMinutes` (in een batch is het nieuwe assignmentId niet tempId-resolveerbaar). Tests: check-work-rule-store sectie (o) (120 checks), cases-work-rule 10, browserspec typt toets-voor-toets en dekt het dialoogpad (3/3). Planning- en MCP-suites groen. TODO (K2 crashherstel-melding, K6a) en spec §10. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0159unb6VzxUx7WG4w3a2ARA --- docs/TODO.md | 7 ++ ...26-09-04-spec-taaktypes-opgeslagen-werk.md | 2 +- src/components/dialogs/TaskDialog.tsx | 33 ++++++-- src/components/panels/TaskPropertiesPanel.tsx | 5 +- .../settings/SettingsPanelContent.tsx | 1 + .../task-sections/TaskAssignmentsSection.tsx | 68 ++++++++++++++--- src/engine/taskGrid/taskColumnRegistry.ts | 7 +- 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 + .../mcp/tools/calendarResourceTools.ts | 22 +++++- src/state/gridTransaction.ts | 10 ++- src/state/runtime/createMcpTransactions.ts | 13 +++- src/state/slices/projectSlice.ts | 3 + src/state/slices/taskSlice.ts | 6 +- src/state/taskTypesNotice.ts | 6 ++ tests/browser/work-rule.spec.ts | 30 +++++++- tests/mcp/cases-work-rule.ts | 24 ++++++ tests/planning/check-work-rule-store.ts | 75 +++++++++++++++++++ 30 files changed, 293 insertions(+), 33 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 00a0a03a4..5acba4b40 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -373,6 +373,13 @@ deze lijst verwijderd — wat klaar is, staat in de changelog en git-historie. resource met `maxUnits: 1`. Beslissen: (a) I = W / R' herrekenen bij een kalenderwissel, (b) W mee herschalen (werk = duur × inzet houden), of (c) laten staan en documenteren. Tot dan geldt (c); geen testgeval. +- [ ] **Crashherstel ontsluit zonder melding (review K2, 2026-09-05).** `restoreDocuments` leidt + `taskTypesVisible` correct af (`payloadFromImport`) maar loopt niet langs `applyLoadedProject`, + waar de eenmalige melding zit — na herstel verschijnen de bedieningselementen zonder uitleg. + Bewust gelaten: herstel is dezelfde gebruiker in (meestal) dezelfde sessie. Meenemen zodra + `restoreDocuments` andere laadmeldingen krijgt. +- [ ] **Werkinvoer ≤ 0 in het paneel weigert stil** (review K6a): rode rand via `aria-invalid`, geen + melding — zelfde conventie als de inzetinvoer (`isValidUnits`). - [ ] **B1c-koppelpunt (`origin/t3code/b1c-etappe3`, gezien 2026-09-05):** die branch wist bij elke as-verzettende bewerking de nivelleergaten (`clearLevelingGaps`, B1c-plan3 taak 3). Een duur die uit de werkdriehoek komt (`afterTriangleDurationChange` in `resourceSlice.ts`/ diff --git a/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md b/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md index 4c8222738..fd42370c2 100644 --- a/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md +++ b/docs/superpowers/specs/2026-09-04-spec-taaktypes-opgeslagen-werk.md @@ -609,7 +609,7 @@ twee keer aan `formatRegistry.ts`, `ifcPsets.ts` en de contour-adapters wordt ge | 2 | Importvertaling (§4.2) voor .mpp, MSPDI (incl. ``/`` lezen), P6 XML, XER; export MSPDI/P6 XML met de werkvelden; waarschuwingen | fidelity-poort blijft 0 (`GOAL_ZERO_DEVIATIONS`); `check-mpp-*`, `check-adapters-*`; nieuwe fixtures per bron | | 3 **(gebouwd 2026-09-05)** | Pure module `src/engine/work/workTriangle.ts`: `applyDurationEdit`, `applyUnitsEdit`, `applyWorkEdit`, `applyTaskWorkEdit`, `applyAssignmentAdded`, `applyAssignmentRemoved`, `applyRuleChange` — invoer: de RESTERENDE toestand (`TriangleState`: regel, bewaard `effortDriven`, restduur in werkminuten, per toewijzing inzet + optioneel restwerk + `drivesDuration`), uitvoer: een nieuwe toestand of een weigering met reden; nooit een store. `WorkRule` in `src/types/workRule.ts` (verhuist in stap 1 naar `task.ts`). Plus `work-triangle-cases.json` (§9) | `tests/planning/check-work-triangle.ts`: 332 checks, 39 cases (21 documented, 16 reasoned, 2 decided, 0 measured; bij een mengvorm telt het zwakste bewijs) plus eigenschappen: geen veld geschreven onder de inzetregels, typewissel in alle 16 richtingen getalvrij, weigering bij restduur ≤ 0 of niet-eindige inzet (§6.7), `effortDriven` zonder effect buiten de twee 8-B-cellen | | 4 **(gebouwd 2026-09-05)** | Brug `src/engine/work/workRuleApply.ts` (`captureTriangle` → kernstap → `applyTriangleResult`; `settleDurationEdit`/`settleUnitsEdit`/`planWorkEdit`+`commitTrianglePlan`/`settleAssignmentAdded`/`settleAssignmentRemoved`/`settleAssignmentPlan`/`settleRuleChange`; `contourKeepsWork`; `reconcileContourWork` = besluit 3 "vorm blijft, hoogte zakt"). Bedraad in `taskSlice.updateTask` + nieuw `setTaskWorkRule`, `resourceSlice.assignResource`/`updateAssignment`/`unassignResource` + nieuw `setAssignmentWork`, `gridTransaction.ts` (celduur én de assignment-set-cel, via `AssignmentSettleOp`), `createMcpTransactions.ts` (alle tweelingen + `setAssignmentWork`/`setTaskWorkRule` op de draft), `rescaleTaskContours(…, keepWork)` uit de effectieve regel, `assignmentDayUnits` derde bron = opgeslagen werk (§6.1). Een duur die uit de driehoek komt zet `scheduleStale`, herschaalt contour + importsplits en wist het Z8-venster — precies als een duurbewerking; onder FIXED_DURATION_RATE zonder werkvelden byte-identiek | `tests/planning/check-work-rule-store.ts` (95 checks: store, raster, MCP, vierde bron, meetlat 22/23/28, voortgang > 0, uurmodus, FIXED_RATE + 8-B, materiaal in het raster, datums-zoals-opgeslagen, verplaatsen/resource verwijderen), bestaande planning- en MCP-suites groen | -| 5 **(gebouwd 2026-09-05)** | Instelling `ui.showTaskTypes` (`ops-showTaskTypes`, `settingsRegistry` + `saveShowTaskTypes` + `SettingsPanelContent`, tabblad Tijdlijn); documentveld `taskTypesVisible` (`DOCUMENT_FIELDS`, rol `none`, afgeleid in `payloadFromImport` via `hasTaskTypeData` — taak met `workRule`/`mspTaskType`/`p6DurationType`, projectstandaard, of toewijzing met werkveld — en gezet door `setTaskWorkRule`/`setAssignmentWork`/rasterbewerkingen); één melding per document (`taskTypesNotice.ts`, `notifications.taskTypesUnlocked`, `helpArticleId` → `gids-taaktypes`); selector `taskTypesUnlocked`. UI: `TaskWorkRuleField` (paneel + dialoog: keuzelijst met projectstandaard, "Beschermd: …", MSP-bijschrift 8-B), kolom **Werk (rest)** + slotjes in `TaskAssignmentsSection`, raster `task.workRule` (bewerkbare enum, `available` alleen ontsloten; typewissel legt werk vast in `gridTransaction.ts`) en `assignment.remainingWork` (tokens `naam: uren`, assignment-set via `settleWorkEdit`), `TaskColumnContext.taskTypesUnlocked`; i18n 14 locales; gids `gids-taaktypes` nl+en (12 vertalingen volgen maandelijks) | `verify:i18n`, `verify:docs`, `check-work-rule-store` sectie (n), browserspec `tests/browser/work-rule.spec.ts` (keuzelijst, werk typen, inzet, undo, ontsluiting) | +| 5 **(gebouwd 2026-09-05)** | Instelling `ui.showTaskTypes` (`ops-showTaskTypes`, `settingsRegistry` + `saveShowTaskTypes` + `SettingsPanelContent`, tabblad Tijdlijn); documentveld `taskTypesVisible` (`DOCUMENT_FIELDS`, rol `none`, afgeleid in `payloadFromImport` via `hasTaskTypeData` — taak met `workRule`/`mspTaskType`/`p6DurationType`, projectstandaard, of toewijzing met werkveld — en gezet door `setTaskWorkRule`/`setAssignmentWork`/rasterbewerkingen); één melding per document (`taskTypesNotice.ts`, `notifications.taskTypesUnlocked`, `helpArticleId` → `gids-taaktypes`); selector `taskTypesUnlocked`. UI: `TaskWorkRuleField` (paneel + dialoog: keuzelijst met projectstandaard, "Beschermd: …", MSP-bijschrift 8-B), kolom **Werk (rest)** + slotjes in `TaskAssignmentsSection`, raster `task.workRule` (bewerkbare enum, `available` alleen ontsloten; typewissel legt werk vast in `gridTransaction.ts`) en `assignment.remainingWork` (tokens `naam: uren`, assignment-set via `settleWorkEdit`), `TaskColumnContext.taskTypesUnlocked`; i18n 14 locales; gids `gids-taaktypes` nl+en (12 vertalingen volgen maandelijks) Reviewronde (2026-09-05) verwerkt: B1 cross-task plakken behoudt het werk (`validateAssignmentTokens`), B2 de werkcel vergelijkt met de getoonde waarde (niet-bewerkte toewijzingen krijgen geen expliciet werk), B3 typewissel in paneel en dialoog via `setTaskWorkRule` (geen `scheduleStale`, "datums zoals opgeslagen" blijft), B4 dialoog commit de regel direct en Opslaan stuurt de duur alleen mee als de gebruiker die wijzigde, K1 meldingsgate gewist bij `newProject`, K3 elk schrijfpad (ook MCP) ontsluit, K4 werkkolom alleen waar de regel werkt, K5 werkinvoer commit op Enter/blur, K6 contour-restsom, aria-labels met resourcenaam, sectiekop; `add` in `planner_manage_assignments` kent `remainingWorkMinutes` (batch zonder tempId voor toewijzingen). K2 (crashherstel zonder melding) en K6a in de TODO | `verify:i18n`, `verify:docs`, `check-work-rule-store` secties (n)+(o), browserspec `tests/browser/work-rule.spec.ts` (keuzelijst, werk typen toets-voor-toets, inzet, undo, dialoogpad, ontsluiting), `cases-work-rule.ts` (10) | | 6 | Resource erbij/eraf (§5 rij 4–5) inclusief contourregels (§6.3) — **vereist beslispunt 8** (genomen: 8-B). **Store/raster/MCP-kant gebouwd in stap 4** (`settleAssignmentAdded`/`Removed`, `reconcileContourWork`); wat rest is de UI-kant (stap 5) | cases 4, 5, 9, 10, 15, 19, 20, 23, 29, 30 (kern) + `check-work-rule-store` (b15–b21, d5–d9, e6–e7, f3–f4) | | 7 **(gebouwd 2026-09-05)** | Geen nieuwe tool (39 blijft 39) maar drie bestaande tools uitgebreid: `planner_update_tasks`/`planner_add_tasks` `fields.workRule` (enum \| null, via `draft.setTaskWorkRule` ná de duurpatch — een gelijktijdige `duration` wordt onder de OUDE regel verwerkt, `taskFields.ts`), `planner_manage_assignments` `update.remainingWorkMinutes` (> 0, via `draft.setAssignmentWork`; alleen op taken waar `workRuleApplies`), `planner_update_project` `defaultWorkRule` (enum \| null, `delete` bij null); `planner_get_project_info` toont `defaultWorkRule`, `planner_get_task` toonde `workRule` + werkvelden al sinds stap 1. `REJECT_HINTS` voor `plannedWorkMinutes`/`actualWorkMinutes`/`remainingWorkMinutes` als taakveld | `tests/mcp/cases-work-rule.ts` (9 cases via de echte dispatch, incl. `planner_batch` = één undo-stap), `cases-toolregistry`/`cases-schemavalidatie` groen | | 8 | CLAUDE.md-sectie, TODO-afvinking, `docs/superpowers/README.md` | `verify:docs` | diff --git a/src/components/dialogs/TaskDialog.tsx b/src/components/dialogs/TaskDialog.tsx index 6e19d53fd..ade65fe0c 100644 --- a/src/components/dialogs/TaskDialog.tsx +++ b/src/components/dialogs/TaskDialog.tsx @@ -46,6 +46,7 @@ export function TaskDialog() { const setUI = useAppStore(s => s.setUI); const addTask = useAppStore(s => s.addTask); const updateTask = useAppStore(s => s.updateTask); + const setTaskWorkRule = useAppStore(s => s.setTaskWorkRule); const moveTask = useAppStore(s => s.moveTask); const project = useAppStore(s => s.project); const constructionMode = useAppStore(s => s.ui.constructionMode); @@ -70,6 +71,7 @@ export function TaskDialog() { // store) i.p.v. `draft.time`, zodat een eventuele CPM-herberekening tijdens het open staan van de // dialoog niet wordt teruggedraaid door een verouderde draft-snapshot. const [startDate, setStartDate] = useState(''); + const initialDurationRef = useRef<{ unit: 'days' | 'hours'; scheduleDuration: number; durationMinutes?: number } | null>(null); const calendars = useAppStore(s => s.calendars); const projectCal = useAppStore(s => s.calendar); const nameInputRef = useRef(null); @@ -94,6 +96,9 @@ export function TaskDialog() { if (editingTask) { setDraft({ ...editingTask, time: { ...editingTask.time } }); + initialDurationRef.current = { + unit: editingTask.time.durationUnit, scheduleDuration: editingTask.time.scheduleDuration, durationMinutes: editingTask.time.durationMinutes, + }; // Toon de berekende start (consistent met tabel/Gantt); scheduleStart is de geplande anker. setStartDate(editingTask.time.earlyStart || editingTask.time.scheduleStart); } else { @@ -128,11 +133,21 @@ export function TaskDialog() { // teruggedraaid. Voortgangs-velden (completion/actualStart/actualFinish) komen WEL uit de // draft — dat zijn de enige `time`-subvelden die deze dialoog-sessie zelf muteert buiten de // hieronder-berekende schedule-ankervelden. + // Review B4 (taaktypes): de duur ALLEEN uit de draft wanneer de gebruiker hem in deze sessie + // wijzigde — anders zou Opslaan een duur die de werkdriehoek intussen via de toewijzingssectie + // veranderde stil terugdraaien. + const initial = initialDurationRef.current; + const durationTouched = !initial + || draft.time.durationUnit !== initial.unit + || draft.time.scheduleDuration !== initial.scheduleDuration + || draft.time.durationMinutes !== initial.durationMinutes; const time = { ...editingTask.time, - durationUnit: draft.time.durationUnit, - scheduleDuration: draft.time.scheduleDuration, - durationMinutes: draft.time.durationUnit === 'hours' ? draft.time.durationMinutes : undefined, + ...(durationTouched ? { + durationUnit: draft.time.durationUnit, + scheduleDuration: draft.time.scheduleDuration, + durationMinutes: draft.time.durationUnit === 'hours' ? draft.time.durationMinutes : undefined, + } : {}), completion: draft.time.completion, actualStart: draft.time.actualStart, actualFinish: draft.time.actualFinish, @@ -161,7 +176,6 @@ export function TaskDialog() { constraint2: draft.constraint2, deadline: draft.deadline, notes: draft.notes, - workRule: draft.workRule, time, }); // QA-fix P1 (fase 2.10, onderdeel 2): een gewijzigde ouder gaat via `moveTask` — die @@ -302,7 +316,16 @@ export function TaskDialog() {
{/* Taaktypes-etappe (spec §7): zelfde veld als het paneel; commit op Opslaan via `workRule` in de updateTask-/addTask-patch (de store legt het werk vast, K1). */} - + { + // Review B4: op een bestaande taak direct committen (zoals de toewijzingssectie, die óók + // rechtstreeks op de store werkt) zodat werk/inzet in dezelfde dialoog met de gekozen + // regel rekenen; de draft spiegelt. Een nieuwe taak houdt 'm in de draft tot Opslaan. + onChange(patch); + if (editingTask) setTaskWorkRule(editingTask.id, patch.workRule); + }} + /> diff --git a/src/components/panels/TaskPropertiesPanel.tsx b/src/components/panels/TaskPropertiesPanel.tsx index 2d9cb227d..b00a1c171 100644 --- a/src/components/panels/TaskPropertiesPanel.tsx +++ b/src/components/panels/TaskPropertiesPanel.tsx @@ -35,6 +35,7 @@ export function TaskPropertiesPanel() { const activeTaskId = useAppStore(s => s.activeTaskId); const tasks = useAppStore(s => s.tasks); const updateTask = useAppStore(s => s.updateTask); + const setTaskWorkRule = useAppStore(s => s.setTaskWorkRule); const deleteTask = useAppStore(s => s.deleteTask); const runCPM = useAppStore(s => s.runCPM); const setTaskCalendar = useAppStore(s => s.setTaskCalendar); @@ -101,7 +102,9 @@ export function TaskPropertiesPanel() { - + {/* Taaktypes-etappe (review B3): een typewissel rekent niets en gaat daarom via `setTaskWorkRule` + (geen `scheduleStale`, "datums zoals opgeslagen" blijft staan), niet via de generieke update. */} + setTaskWorkRule(task.id, patch.workRule)} /> diff --git a/src/components/settings/SettingsPanelContent.tsx b/src/components/settings/SettingsPanelContent.tsx index 21d870391..a1957420c 100644 --- a/src/components/settings/SettingsPanelContent.tsx +++ b/src/components/settings/SettingsPanelContent.tsx @@ -362,6 +362,7 @@ export function SettingsPanelContent() { )}
+

{t('settings.taskTypesSection')}