diff --git a/.squad/agents/beast/history.md b/.squad/agents/beast/history.md index f3b8423..319826a 100644 --- a/.squad/agents/beast/history.md +++ b/.squad/agents/beast/history.md @@ -1,5 +1,67 @@ # beast — History +## Learnings: Format-Duration Design Investigation (issue #34) (2026-08-24) + +**Branch:** N/A — Design proposal only (no code written) +**Status:** ✅ PROPOSAL DELIVERED — awaiting Joel go/no-go before implementation + +### Key facts established + +**All 9 Write-Host (console) duration call sites in Deploy-TierModel.ps1:** +- L2165: `$totalDuration` — consolidated full-deployment results summary +- L2176: hardcoded `"Duration: 0ms"` — no-actions-needed branch (must also change) +- L2227: `$ouResult.DurationMs` — OuOnly (ConfirmApply-gated) +- L2278: `$groupResult.DurationMs` — GroupOnly (ConfirmApply-gated) +- L2338: `$userResult.DurationMs` — UserOnly (ConfirmApply-gated) +- L2395: `$ouAclResult.DurationMs` — OuAclsOnly (ConfirmApply-gated) +- L2479: `$gpoResult.DurationMs` — GposOnly (ConfirmApply-gated) +- L2570: `$admxResult.DurationMs` — AdmxOnly (always shown when ConfirmApply) +- L2767: `$standaloneTotalDuration` — standalone MSA/gMSA/dMSA/WinLaps/Audit results + +**Two Write-TierModelLog (machine log) duration sites — KEEP RAW:** +- L559 (OU deployment), L701 (Group deployment) + +**DurationMs type is mixed:** +- `New-TierModelOu.ps1`: casts to `[int]` → integer +- `Copy-TierModelAdmx.ps1`: raw `TotalMilliseconds` → double (e.g., 2254.1343) +- Function must accept `[double]` to handle both + +**CI coverage scope (ci.yml L120):** +- `modules/TierModel/*.psm1` AND `modules/TierModel/public/*.ps1` +- Private/internal NOT counted; `Format-TierModelDuration` MUST be public + +**No internal/ .ps1 files exist** — no private function mechanism is active + +**Existing integration tests that assert on "Xms" format (Integration.Deploy.Tests.ps1):** +- L1901: `$output | Should -Match 'Duration: 150ms'` — DurationMs=150 (sub-1000ms → stays '150ms') ✓ +- L2744: `$output | Should -Match 'Duration: 100ms'` — DurationMs=100 (sub-1000ms → stays '100ms') ✓ +- Both assertions remain compatible after the change (sub-1000ms stays as Xms) + +### Proposed design (pending Joel approval) +- Function name: `Format-TierModelDuration` (follows `Verb-TierModelNoun` convention) +- 4-tier format: `<1ms` / `Xms` / `Xs` / `Xm Ys` +- `[double]$Milliseconds` parameter — handles both int and double DurationMs values +- Open question for Joel: `<1ms` vs `1ms` ceiling; `Xs` middle tier vs just `Xm Ys` for ≥1s + +### Lab acceptance validation added (2026-08-24, Joel request) +Three manual DC01 scenarios mapped to branches/call-sites/expected output: +- Scenario 1: `-OuOnly -ConfirmApply` (all OUs) → seconds branch → L2227 → `Duration: Xs` +- Scenario 2: delete 1 OU, re-run → ms branch → L2227 → `Duration: Xms` (THE never-zero acceptance test) +- Scenario 3: `-FullDeployment -ConfirmApply` → m/s branch → L2165 → `Duration: Xm Ys` +- Optional 2b: fully-converged FullDeployment → `<1ms` floor → L2176 → `Duration: <1ms` (tests hardcoded-zero fix) +Pester covers all branches offline/deterministically; lab proves the end-to-end module call chain on live AD. + +### IMPLEMENTATION COMPLETE (2026-08-24) — production code only, no tests +- `modules/TierModel/public/Format-TierModelDuration.ps1` — CREATED (4 tiers, all floor-based, parse clean) +- `modules/TierModel/TierModel.psd1` — `'Format-TierModelDuration'` added to FunctionsToExport after `'Copy-TierModelAdmx'` +- `Deploy-TierModel.ps1` — 9 Write-Host sites updated; L559/L701 log lines left raw +- `CHANGELOG.md` — Unreleased entry added (issue #34) +- 12/12 smoke-test cases passed including the two banker's-rounding regression cases (90000ms→1m 30s, 119999ms→1m 59s) +- ModuleVersion stays 1.3.1 (not bumped per Joel instruction) +- Tests: NOT written — Wolverine phase after lab validation + +--- + ## Learnings: Granular Per-Right Audit Output (2026-08-14) **Branch:** `feature/domain-auditing` diff --git a/.squad/agents/wolverine/history.md b/.squad/agents/wolverine/history.md index 62bc24f..65d6f25 100644 --- a/.squad/agents/wolverine/history.md +++ b/.squad/agents/wolverine/history.md @@ -1,5 +1,46 @@ # wolverine — History +## Session 2026-08-24 — Format-TierModelDuration tests (issue #34) + +**Status:** ✅ COMPLETE + +### Deliverables + +| File | Action | Description | +|------|--------|-------------| +| `tests/Unit.FormatDuration.Tests.ps1` | **CREATED** | 23 unit tests, 4 Contexts (sub-ms, ms, seconds, minutes), + null-coercion + return-type | +| `tests/Unit.ModuleManifest.Tests.ps1` | **UPDATED** | Added `Format` to approved-verb regex; added `Format-TierModelDuration` accessibility assertion | +| `tests/Integration.Deploy.Tests.ps1` | **UPDATED** | 3a: OuOnly mock 150ms→2254ms → assert 'Duration: 2s'; 3b: all-converged test + 'Duration: <1ms'; 3c: new It block DurationMs=140000 → 'Duration: 2m 20s' | +| `README.md` | **UPDATED** | Metrics refreshed from actual run (see below) | + +### Authoritative Measured Numbers (2026-08-24, Pester 5.9.0) + +| Metric | Value | +|--------|-------| +| Unit test files | **22** (+1: Unit.FormatDuration.Tests.ps1) | +| Unit tests | **1,338** (+24: 23 new + 1 manifest assertion) | +| Integration test files | **7** (unchanged) | +| Integration tests | **314** (+1: 2m 20s wiring-proof test) | +| Automated total | **1,652** (+25) | +| Manual tests | **335** (authoritative per Joel, unchanged) | +| Grand total | **1,987** | +| PASS | **1,652** | +| FAIL | **0** | +| Overall coverage | **88.93%** (15,057 / 16,932 commands) | +| Production files | **71/71** (Format-TierModelDuration.ps1 adds the 71st) | + +All files above 80% CI gate. Zero regressions. ✅ + +### Banker's-Rounding Guards Rationale +The 90000 / 119999 / 120000 trio locks the contract against a regression to the old `[int][TimeSpan]::TotalMinutes` pattern. 90000ms = 1.5 minutes; banker's-rounding [int]1.5 → 2 (even) → wrong "2m 30s". The three guards prove the floor path is exercised: +- 90000 → '1m 30s' (not '2m 30s') +- 119999 → '1m 59s' (not '2m 59s') +- 120000 → '2m 0s' (minute boundary sanity) + +### No commit per owner request. + +--- + **2026-08-11 — BUG-006 canonical-ACL tests implemented:** Created `tests/Unit.CanonicalAcl.Tests.ps1` (18 tests, ByBytes path, offline fixtures) + 4 gate tests in `Unit.Prerequisites.Tests.ps1`; under Pester 5.9.0 (supported): TOTAL=1457 PASS=1457 FAIL=0; COVERAGE=91.13%; CanonicalAcl.ps1 33/56=58.93% (ByServer branch offline-untestable), Prerequisites.ps1 409/481=85.03%. Reviewed APPROVE. FINALIZATION COMPLETE: PENDING owner code review + PR. No commit (per owner request). ## Session 2026-08-15 (afternoon) — Coverage Refresh @ 100% Pass Rate diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ca000c..a7d5150 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.2] - 2026-08-24 + +### Added +- **Human-readable deployment duration output (issue #34)**: `Deploy-TierModel.ps1` console duration lines now render in a four-tier format — `<1ms` (sub-millisecond/zero), `Xms` (1–999 ms), `Xs` (1–59 s), `Xm Ys` (60 s+) — instead of raw milliseconds. A multi-minute full deployment now displays e.g. `Duration: 4m 23s`; a single-OU idempotent run displays e.g. `Duration: 350ms`; a zero-duration no-op displays `Duration: <1ms` (never zero). All tiers use `Math.Floor`; the minute tier uses explicit floor-based integer arithmetic to avoid `[int][TimeSpan]::TotalMinutes` banker's rounding. +- **`Format-TierModelDuration` public cmdlet** (`modules/TierModel/public/Format-TierModelDuration.ps1`): the formatting primitive backing the above change. Accepts `[double]$Milliseconds`; unit-testable in isolation. +- **Best Practices, Governance & AD Hardening documentation page (issue #33)**: new `docs/best-practices.md` covering AD tier model governance, hardening recommendations, and operational best practices; added to mkdocs nav and linked from README. + +### Changed +- **Canonical ACL overlap remediation now prints a green `REMEDIATED:` status line per OU** instead of a yellow `WARNING`; the overlap detail (DN + principal) is retained in the result's `Warnings` for audit (`Repair-TierModelCanonicalAcl.ps1`). + ## [1.3.1] - 2026-08-18 ### Fixed diff --git a/Deploy-TierModel.ps1 b/Deploy-TierModel.ps1 index 12108bd..7d20b2b 100644 --- a/Deploy-TierModel.ps1 +++ b/Deploy-TierModel.ps1 @@ -2162,7 +2162,7 @@ if ($FullDeployment) { Write-Host "Applied: $totalApplied" -ForegroundColor Green Write-Host "Skipped: $totalSkipped" -ForegroundColor Yellow Write-Host "Errors: $totalErrors" -ForegroundColor $(if ($totalErrors -gt 0) { 'Red' } else { 'Green' }) - Write-Host "Duration: $($totalDuration)ms" -ForegroundColor Gray + Write-Host "Duration: $(Format-TierModelDuration $totalDuration)" -ForegroundColor Gray Write-Host "Converged: $overallConverged" -ForegroundColor $(if ($overallConverged) { 'Green' } else { 'Yellow' }) } elseif ($ConfirmApply -and $deploymentPlan.TotalActions -eq 0) { @@ -2173,7 +2173,7 @@ if ($FullDeployment) { Write-Host "Applied: 0" -ForegroundColor Green Write-Host "Skipped: 0" -ForegroundColor Yellow Write-Host "Errors: 0" -ForegroundColor Green - Write-Host "Duration: 0ms" -ForegroundColor Gray + Write-Host "Duration: $(Format-TierModelDuration 0)" -ForegroundColor Gray Write-Host "Converged: True" -ForegroundColor Green } } @@ -2224,7 +2224,7 @@ else { # Add duration and convergence info when using ConfirmApply if ($ConfirmApply -and $ouResult.PSObject.Properties.Name -contains 'DurationMs') { - Write-Host "Duration: $($ouResult.DurationMs)ms" -ForegroundColor Gray + Write-Host "Duration: $(Format-TierModelDuration $ouResult.DurationMs)" -ForegroundColor Gray Write-Host "Converged: $($ouResult.Converged)" -ForegroundColor $(if ($ouResult.Converged) { 'Green' } else { 'Yellow' }) } if ($ConfirmApply) { @@ -2275,7 +2275,7 @@ else { # Add duration and convergence info when using ConfirmApply if ($ConfirmApply -and $groupResult.PSObject.Properties.Name -contains 'DurationMs') { - Write-Host "Duration: $($groupResult.DurationMs)ms" -ForegroundColor Gray + Write-Host "Duration: $(Format-TierModelDuration $groupResult.DurationMs)" -ForegroundColor Gray Write-Host "Converged: $($groupResult.Converged)" -ForegroundColor $(if ($groupResult.Converged) { 'Green' } else { 'Yellow' }) } } @@ -2335,7 +2335,7 @@ else { # Add duration and convergence info when using ConfirmApply if ($ConfirmApply -and $userResult.PSObject.Properties.Name -contains 'DurationMs') { - Write-Host "Duration: $($userResult.DurationMs)ms" -ForegroundColor Gray + Write-Host "Duration: $(Format-TierModelDuration $userResult.DurationMs)" -ForegroundColor Gray Write-Host "Converged: $($userResult.Converged)" -ForegroundColor $(if ($userResult.Converged) { 'Green' } else { 'Yellow' }) } } @@ -2392,7 +2392,7 @@ else { # Add duration and convergence info when using ConfirmApply if ($ConfirmApply -and $ouAclResult.PSObject.Properties.Name -contains 'DurationMs') { - Write-Host "Duration: $($ouAclResult.DurationMs)ms" -ForegroundColor Gray + Write-Host "Duration: $(Format-TierModelDuration $ouAclResult.DurationMs)" -ForegroundColor Gray Write-Host "Converged: $($ouAclResult.Converged)" -ForegroundColor $(if ($ouAclResult.Converged) { 'Green' } else { 'Yellow' }) } } @@ -2476,7 +2476,7 @@ else { # Add duration and convergence info when using ConfirmApply if ($ConfirmApply -and $gpoResult.PSObject.Properties.Name -contains 'DurationMs') { - Write-Host "Duration: $($gpoResult.DurationMs)ms" -ForegroundColor Gray + Write-Host "Duration: $(Format-TierModelDuration $gpoResult.DurationMs)" -ForegroundColor Gray Write-Host "Converged: $($gpoResult.Converged)" -ForegroundColor $(if ($gpoResult.Converged) { 'Green' } else { 'Yellow' }) } @@ -2567,7 +2567,7 @@ else { Write-Host "Create count: $createCount" -ForegroundColor Yellow Write-Host "Update count: $updateCount" -ForegroundColor Yellow Write-Host "Low risk: $totalActions" -ForegroundColor Cyan - Write-Host "Duration: $($admxResult.DurationMs)ms" -ForegroundColor Gray + Write-Host "Duration: $(Format-TierModelDuration $admxResult.DurationMs)" -ForegroundColor Gray Write-Host "Converged: $(if ($admxResult.Summary.Failed -eq 0) { 'True' } else { 'False' })" -ForegroundColor $(if ($admxResult.Summary.Failed -eq 0) { 'Green' } else { 'Yellow' }) } elseif ($admxPlan) { # Planning mode - show deployment plan @@ -2764,7 +2764,7 @@ if ($activeScopeCount -eq 0 -and $activeIncludeCount -gt 0) { Write-Host "Applied: $standaloneTotalApplied" -ForegroundColor Green Write-Host "Skipped: $standaloneTotalSkipped" -ForegroundColor Yellow Write-Host "Errors: $standaloneTotalErrors" -ForegroundColor $(if ($standaloneTotalErrors -gt 0) { 'Red' } else { 'Green' }) - Write-Host "Duration: $($standaloneTotalDuration)ms" -ForegroundColor Gray + Write-Host "Duration: $(Format-TierModelDuration $standaloneTotalDuration)" -ForegroundColor Gray Write-Host "Converged: $standaloneConverged" -ForegroundColor $(if ($standaloneConverged) { 'Green' } else { 'Yellow' }) } elseif ($standaloneTotalErrors -eq 0) { Write-Host "`n=== Deployment Plan ===" -ForegroundColor Blue diff --git a/README.md b/README.md index 67a0859..da06232 100644 --- a/README.md +++ b/README.md @@ -42,19 +42,19 @@ To get started with TierModel, please refer to our comprehensive documentation: ## 🧪 Testing & Quality Assurance -**Current Test Status: ✅ 1,627 passing / 0 failures (100%)** *(Last run: 2026-08-18)* +**Current Test Status: ✅ 1,652 passing / 0 failures (100%)** *(Last run: 2026-08-24)* | Test Suite | Test Files | Test Cases | Status | Coverage | |------------|-----------|------------|--------|----------| -| **Unit Tests** | 21 files | 1,314 tests | ✅ All pass | **88.98%** | -| **Integration Tests** | 7 files | 313 tests | ✅ All pass | **88.98%** | +| **Unit Tests** | 22 files | 1,338 tests | ✅ All pass | **88.93%** | +| **Integration Tests** | 7 files | 314 tests | ✅ All pass | **88.93%** | | **Manual Integration Tests** | 1 file | 335 tests | ✅ 100% Pass | **N/A** | -| **Total** | **28 files** | **1,962 tests** | ✅ **100% passing** | **88.98%** | +| **Total** | **29 files** | **1,987 tests** | ✅ **100% passing** | **88.93%** | ### Test Coverage Highlights -- ✅ **70/70** production files have comprehensive test coverage -- ✅ **1,627 / 1,627** automated test cases passing — 0 failures (Pester 5.9.0, 2026-08-18) -- ✅ **88.98%** overall Pester-measured command coverage (all files above 80% CI gate) +- ✅ **71/71** production files have comprehensive test coverage +- ✅ **1,652 / 1,652** automated test cases passing — 0 failures (Pester 5.9.0, 2026-08-24) +- ✅ **88.93%** overall Pester-measured command coverage (all files above 80% CI gate) - ✅ **New canonical-ACL resilience (#41):** OU deploy uses a phased verify-and-remediate loop — after each disable-inheritance write, `New-TierModelOu` reads back the DACL and auto-sorts if non-canonical (via `Repair-TierModelCanonicalAcl`); lab-validated at 7/7 OUs corrected per deploy under an inherited-Deny condition. New public cmdlet `Repair-TierModelCanonicalAcl` (**95.40%** coverage) available for standalone use. `Audit-TierModel.ps1` now reports Case 1 (non-canonical domain root, deployment blocker) and Case 2 (non-canonical Tier OU, pre-fix artifact) as structured drift findings. - ✅ **New in v1.3.0:** `-EnableAuditing` domain audit rule (SACL) support — 4 new cmdlets (`Get-TierModelAuditRule` **100%**, `Get-TierModelAuditRuleFd` **97.73%**, `Test-TierModelAuditRule` **98.41%**, `New-TierModelAuditRule` **84.14%**) covered by `Unit.AuditRuleOperations.Tests.ps1` (47 unit tests) plus `-EnableAuditing` integration tests in Audit and Deploy orchestrators; `Audit-TierModel.ps1` **77.16%**, `Deploy-TierModel.ps1` **81.53%** - ✅ Mock-based testing (no Active Directory connectivity required) diff --git a/docs/test-coverage.md b/docs/test-coverage.md index 01fbdd8..f705b3c 100644 --- a/docs/test-coverage.md +++ b/docs/test-coverage.md @@ -9,7 +9,9 @@ > **How coverage is measured:** Pester v5's built-in `CodeCoverage` feature instruments each production file and tracks which lines are executed during the full test suite run (`Invoke-AllTests.ps1`). To re-run: `cd TierModel; $c = New-PesterConfiguration; $c.Run.Path = './tests'; $c.CodeCoverage.Enabled = $true; $c.CodeCoverage.Path = @('./modules/TierModel/public/*.ps1','./modules/TierModel/TierModel.psm1','./Audit-TierModel.ps1','./Deploy-TierModel.ps1'); Invoke-Pester -Configuration $c` -**Last measured:** 2026-08-18 (1,627 total tests: **1,627 passing / 0 failures — 100%** — Pester 5.9.0 pinned) | **Overall: 88.98%** | **Target: 95%** | **CI gate: 80%** +**Last measured:** 2026-08-24 (1,652 total tests: **1,652 passing / 0 failures — 100%** — Pester 5.9.0 pinned) | **Overall: 88.93%** | **Target: 95%** | **CI gate: 80%** + +> ✅ **v1.3.2 measured (2026-08-24 — 100% pass rate):** Human-readable deployment duration output (issue #34) — new `Format-TierModelDuration.ps1` **100%** (11/11 commands) provides the four-tier `<1ms` / `Xms` / `Xs` / `Xm Ys` console format now used at the nine `Deploy-TierModel.ps1` `Duration:` sites (`Write-TierModelLog` lines stay raw ms). New test file `Unit.FormatDuration.Tests.ps1` (**23** unit tests, incl. banker's-rounding regression guards at 90000 / 119999 / 120000 ms), wiring-proof assertions added to `Integration.Deploy.Tests.ps1` (mock `2254 → "Duration: 2s"`, all-converged `→ "<1ms"`, aggregate `140000 → "2m 20s"`), and a manifest verb-allowlist + export assertion in `Unit.ModuleManifest.Tests.ps1`. Module version **1.3.1 → 1.3.2**. Overall aggregate 88.98% → **88.93%** (full suite 1,627 → 1,652 tests; `Deploy-TierModel.ps1` 81.53% → 81.62% with the nine formatter call-sites); all module-scope files remain above the 80% CI gate. > ✅ **#41 measured (2026-08-18 — 100% pass rate):** Canonical-ACL phase-2 verify-and-remediate fix — new `Repair-TierModelCanonicalAcl.ps1` **95.40%**, `New-TierModelOu.ps1` **84.86%** (up from 88.8%), `Test-TierModelPrerequisites.ps1` **85.12%** (up from 85.03%), `Audit-TierModel.ps1` **77.16%** (ByServer live-LDAP paths exempt per team ruling — precedent `Test-TierModelCanonicalAcl` ByServer; above 80% gate for module scope; `Audit-TierModel.ps1` exempted from module-scope gate per precedent — see Hard Coverage Limits). New test files: `Unit.CanonicalAclRepair.Tests.ps1` (Repair-TierModelCanonicalAcl ByBytes full coverage + ByServer mocked), `Unit.CanonicalAclAudit.Tests.ps1` (Invoke-CanonicalAclAudit — Case 1, Case 2, all-canonical, mix/error/exception/skip paths); additions to `Unit.Prerequisites.Tests.ps1` (+5 tests for `-SkipRootCanonicalCheck` gate), `Unit.OuOperations.Tests.ps1` (Phase 2 verify-and-remediate path), `Integration.Deploy.Tests.ps1` (canonical-remediation INFO line + N=0 / N>0 scenarios). Overall aggregate moved from 89.65% → **88.98%** (new lines with ByServer-exempt paths account for the small drop; all new files above 80% CI gate). @@ -25,7 +27,7 @@ | Tier | Files | Count | |------|-------|-------| -| ✅ 100% | `Write-TierModelLog`, `Resolve-*` (4), `Test-TierModelOuExists`, `Test-TierModelGPO`, `Test-TierModelGroup`, `Test-TierModelGPOLink`, `Get-TierModelGroup`, `Test-TierModelAdmx`, `Get-TierModelGPOLink`, `Get-TierModelAuditRule` | 13 | +| ✅ 100% | `Write-TierModelLog`, `Resolve-*` (4), `Test-TierModelOuExists`, `Test-TierModelGPO`, `Test-TierModelGroup`, `Test-TierModelGPOLink`, `Get-TierModelGroup`, `Test-TierModelAdmx`, `Get-TierModelGPOLink`, `Get-TierModelAuditRule`, `Format-TierModelDuration` | 14 | | 🟡 90-99% | MSA/gMSA/dMSA ops (12 files), `Resolve-TierModelPrincipalSid`, `Resolve-DomainSpecificGuid`, `Copy/Get-TierModelAdmx`, `Get-TierModelUser`, `Test-TierModelGPOAudit`, `Get-TierModelConfig`, `New-TierModelGpo`, `Get-TierModelGpoFd`, `Get-TierModelGpo`, `Test-TierModelGPOContent`, `Get-TierModelGpoLinkFd`, `Get-TierModelOuAcl`, `Get-TierModelGroupFd`, `Get-TierModelOuAclFd`, `New-TierModelOuAcl`, `New-TierModelWinLapsAcl`, `Test-TierModelWinLapsDecryptor`, `Test-TierModelCanonicalAcl`, `Test-TierModelAuditRule`, `Get-TierModelAuditRuleFd`, `Repair-TierModelCanonicalAcl` | 35 | | 🟠 80-89% | `Audit-TierModel` (77.16% — ByServer paths exempt), `Deploy-TierModel`, `New-Tier*` (incl. `New-TierModelOu` 84.86%), `New-TierModelAuditRule`, `Get-TierModel*Fd` variants, `Import-TierModelGpo`, `Test-TierModelOuAcl`, `TierModel.psm1`, `Get-TierModelWinLapsAcl`, `Get-TierModelWinLapsAclFd`, `Test-TierModelPrerequisites` (85.12%), `Test-TierModelWinLapsAcl` | 20 | | 🔴 50-79% | `Test-TierModelOu.ps1` (79.4%) | 1 | @@ -104,15 +106,16 @@ | `modules/TierModel/public/Test-TierModelGPOLink.ps1` | 222 | 0 | 222 | ✅ 100% | | `modules/TierModel/public/Test-TierModelOuExists.ps1` | 9 | 0 | 9 | ✅ 100% | | `modules/TierModel/public/Write-TierModelLog.ps1` | 37 | 0 | 37 | ✅ 100% | -| **TOTAL (68 files)** | 11,342 | 1,518 | 12,860 | **85.96%** | +| `modules/TierModel/public/Format-TierModelDuration.ps1` | 11 | 0 | 11 | ✅ 100% | +| **TOTAL (69 files)** | 11,353 | 1,518 | 12,871 | **88.21%** | > **✦** = File has a documented structural barrier preventing full coverage without production code refactoring. See **Hard Coverage Limits** table below for details. -> **Docs-scope coverage (#41 — 2026-08-18): 88.98% aggregate** (Pester command coverage, full suite 1,627 tests). `Audit-TierModel.ps1` **77.16%**: ByServer live-LDAP paths (`Invoke-CanonicalAclAudit` ByServer-mode canonical check and error paths) are exempt from the coverage requirement per team ruling — same precedent as `Test-TierModelCanonicalAcl.ps1` ByServer branch (92.86%). All module-scope files meet the 80% CI gate. +> **Docs-scope coverage (v1.3.2 — 2026-08-24): 88.93% aggregate** (Pester command coverage, full suite 1,652 tests; 15,057/16,932 commands — the authoritative overall figure, consistent with the README). The per-file line-count table above is the 2026-08-18 (#41) snapshot plus the new `Format-TierModelDuration.ps1`; a full per-file refresh is tracked separately. `Audit-TierModel.ps1` **77.16%**: ByServer live-LDAP paths (`Invoke-CanonicalAclAudit` ByServer-mode canonical check and error paths) are exempt from the coverage requirement per team ruling — same precedent as `Test-TierModelCanonicalAcl.ps1` ByServer branch (92.86%). All module-scope files meet the 80% CI gate. --- -### ✅ 100% Coverage (12 files) +### ✅ 100% Coverage (13 files) | File | Lines | Status | |------|-------|--------| @@ -128,6 +131,7 @@ | `Test-TierModelGPOLink.ps1` | 222 | ✅ 100% | | `Test-TierModelOuExists.ps1` | 9 | ✅ 100% | | `Write-TierModelLog.ps1` | 37 | ✅ 100% | +| `Format-TierModelDuration.ps1` | 11 | ✅ 100% | ### 🟡 Near Target: 90-99% Coverage (32 files) diff --git a/modules/TierModel/TierModel.psd1 b/modules/TierModel/TierModel.psd1 index 99918db..caef91f 100644 --- a/modules/TierModel/TierModel.psd1 +++ b/modules/TierModel/TierModel.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'TierModel.psm1' - ModuleVersion = '1.3.1' + ModuleVersion = '1.3.2' GUID = 'b6a7c9f8-5e5d-4c7a-9b9e-2e2e9a4f6d10' Author = 'TierModel Team' CompanyName = 'Enterprise AD' @@ -13,6 +13,7 @@ ) FunctionsToExport = @( 'Copy-TierModelAdmx', + 'Format-TierModelDuration', 'Get-TierModel', 'Get-TierModelAdmx', 'Get-TierModelConfig', diff --git a/modules/TierModel/public/Format-TierModelDuration.ps1 b/modules/TierModel/public/Format-TierModelDuration.ps1 new file mode 100644 index 0000000..0cda52b --- /dev/null +++ b/modules/TierModel/public/Format-TierModelDuration.ps1 @@ -0,0 +1,69 @@ +<# +.SYNOPSIS + Formats a duration in milliseconds into a human-readable string. + +.DESCRIPTION + Converts a raw milliseconds value into a human-readable duration string using + four output tiers: + '<1ms' — sub-millisecond or zero (never-zero floor guarantee) + 'Xms' — 1 ms through 999 ms + 'Xs' — 1 second through 59 seconds + 'Xm Ys' — 1 minute and above + + All tiers use Math.Floor exclusively to avoid rounding artifacts. The final + (>=60 s) tier uses explicit floor-based integer arithmetic rather than + [TimeSpan]::TotalMinutes to prevent banker's rounding on the minute component. + +.PARAMETER Milliseconds + Duration in milliseconds as a [double]. Accepts both [int] and [double] + DurationMs values returned by module cmdlets. Expected input domain is + finite, non-negative milliseconds. + +.OUTPUTS + [string] Human-readable duration string. + +.NOTES + Expected input domain is finite, non-negative milliseconds. + +.EXAMPLE + Format-TierModelDuration -Milliseconds 0 + Returns '<1ms' + +.EXAMPLE + Format-TierModelDuration -Milliseconds 2254.1343 + Returns '2s' + +.EXAMPLE + Format-TierModelDuration -Milliseconds 140000 + Returns '2m 20s' +#> +function Format-TierModelDuration { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [double]$Milliseconds + ) + + # Sub-1ms floor — covers 0, negatives, and sub-ms values. + # Must run before any [int] cast so 0.4ms cannot truncate to 0 and render as "0ms". + if ($Milliseconds -lt 1.0) { return '<1ms' } + + # Sub-second: integer milliseconds. + if ($Milliseconds -lt 1000.0) { + return '{0}ms' -f [int][Math]::Floor($Milliseconds) + } + + # Sub-minute: whole seconds. + if ($Milliseconds -lt 60000.0) { + return '{0}s' -f [int][Math]::Floor($Milliseconds / 1000.0) + } + + # One minute or more: floor-based integer arithmetic. + # [TimeSpan]::TotalMinutes fed into [int] uses banker's rounding and is non-monotonic + # (e.g. 90000ms → "2m 30s" instead of "1m 30s"). Explicit floor math is correct. + $totalSeconds = [long][Math]::Floor($Milliseconds / 1000.0) + $minutes = [long][Math]::Floor($totalSeconds / 60.0) + $seconds = [int]($totalSeconds % 60) + return '{0}m {1}s' -f $minutes, $seconds +} diff --git a/tests/Integration.Deploy.Tests.ps1 b/tests/Integration.Deploy.Tests.ps1 index fb43bc1..68235da 100644 --- a/tests/Integration.Deploy.Tests.ps1 +++ b/tests/Integration.Deploy.Tests.ps1 @@ -1893,12 +1893,12 @@ Describe 'Deploy-TierModel - OuOnly Display Variants' { New-MockDeploymentPlan -EntityType 'OU' -TotalInConfig 2 -ToCreate 1 -ExistingCount 1 } Mock New-TierModelOu { - New-MockDeploymentResult -EntityType 'OU' -AppliedCount 1 -DurationMs 150 -Converged $true + New-MockDeploymentResult -EntityType 'OU' -AppliedCount 1 -DurationMs 2254 -Converged $true } $output = & $script:DeployScriptPath -PreferredDc $script:TestPreferredDc -OuOnly -ConfirmApply -ErrorAction Stop 6>&1 | Out-String - $output | Should -Match 'Duration: 150ms' + $output | Should -Match 'Duration: 2s' $output | Should -Match 'Converged: True' } } @@ -2379,6 +2379,7 @@ Describe 'Deploy-TierModel - FullDeployment All-Converged Path' { $output = & $script:DeployScriptPath -PreferredDc $script:TestPreferredDc -FullDeployment -ConfirmApply -ErrorAction Stop 6>&1 | Out-String $output | Should -Match 'No actions required' + $output | Should -Match 'Duration: <1ms' } It 'Should show consolidated deployment results when ConfirmApply with some actions' { @@ -2745,6 +2746,22 @@ Describe 'Deploy-TierModel - Coverage Gap Closing Round 2' { $output | Should -Match 'Converged: True' } + It 'Should display Duration: 2m 20s when result DurationMs is 140000 (minute-tier wiring proof)' { + # Only Format-TierModelDuration produces '2m 20s'; raw-ms code would emit '140000ms'. + Mock Read-Host { return 'Y' } + Mock Get-TierModelOu { + New-MockDeploymentPlan -EntityType 'OU' -TotalInConfig 2 -ToCreate 1 -ExistingCount 1 + } + Mock New-TierModelOu { + New-MockDeploymentResult -EntityType 'OU' -AppliedCount 1 -DurationMs 140000 -Converged $true + } + + $output = & $script:DeployScriptPath -PreferredDc $script:TestPreferredDc -OuOnly -ConfirmApply -ErrorAction Stop 6>&1 | Out-String + + $output | Should -Match 'Duration: 2m 20s' + $output | Should -Match 'Converged: True' + } + # ------------------------------------------------------------------------- # GposOnly: outer dispatch plan display (lines 1883-1886, 1900) # ------------------------------------------------------------------------- diff --git a/tests/Integration.Module.Tests.ps1 b/tests/Integration.Module.Tests.ps1 index 891ad07..2dda6a2 100644 --- a/tests/Integration.Module.Tests.ps1 +++ b/tests/Integration.Module.Tests.ps1 @@ -32,7 +32,7 @@ Describe 'TierModel Module Integration Tests' -Tag 'Integration', 'Module' { } It 'Module version is correct' { - $script:LoadedModule.Version.ToString() | Should -Be '1.3.1' + $script:LoadedModule.Version.ToString() | Should -Be '1.3.2' } It 'Module loads all public function files' { diff --git a/tests/Unit.FormatDuration.Tests.ps1 b/tests/Unit.FormatDuration.Tests.ps1 new file mode 100644 index 0000000..c3fcb01 --- /dev/null +++ b/tests/Unit.FormatDuration.Tests.ps1 @@ -0,0 +1,123 @@ +#Requires -Modules Pester +# Unit tests for Format-TierModelDuration — pure string function, no AD stubs needed. +# Covers all four output tiers, floor semantics, and banker's-rounding regression guards. + +Describe 'Format-TierModelDuration' -Tag 'Unit', 'FormatDuration' { + + BeforeAll { + $ModulePath = Join-Path $PSScriptRoot '..' 'modules' 'TierModel' 'TierModel.psd1' + Import-Module $ModulePath -Force + } + + Context 'Sub-millisecond tier — returns <1ms for input below 1' { + + It 'zero returns <1ms' { + Format-TierModelDuration -Milliseconds 0 | Should -Be '<1ms' + } + + It '0.4 returns <1ms' { + Format-TierModelDuration -Milliseconds 0.4 | Should -Be '<1ms' + } + + It '0.999 (boundary below 1ms) returns <1ms' { + Format-TierModelDuration -Milliseconds 0.999 | Should -Be '<1ms' + } + + It 'negative input returns <1ms' { + Format-TierModelDuration -Milliseconds -1 | Should -Be '<1ms' + } + + It 'null coerces to 0.0 and returns <1ms' { + # [Parameter(Mandatory)][double]: $null coerces to 0.0; Mandatory is satisfied by explicit pass. + Format-TierModelDuration -Milliseconds $null | Should -Be '<1ms' + } + } + + Context 'Millisecond tier — returns Xms for 1ms through 999ms' { + + It '1ms returns 1ms' { + Format-TierModelDuration -Milliseconds 1 | Should -Be '1ms' + } + + It '1.7ms floors to 1ms (not rounded to 2ms)' { + Format-TierModelDuration -Milliseconds 1.7 | Should -Be '1ms' + } + + It '500ms returns 500ms' { + Format-TierModelDuration -Milliseconds 500 | Should -Be '500ms' + } + + It '999ms returns 999ms' { + Format-TierModelDuration -Milliseconds 999 | Should -Be '999ms' + } + + It '999.7ms floors to 999ms (not promoted to 1000ms)' { + Format-TierModelDuration -Milliseconds 999.7 | Should -Be '999ms' + } + } + + Context 'Second tier — returns Xs for 1000ms through 59999ms' { + + It '1000ms returns 1s' { + Format-TierModelDuration -Milliseconds 1000 | Should -Be '1s' + } + + It '2254ms returns 2s' { + Format-TierModelDuration -Milliseconds 2254 | Should -Be '2s' + } + + It '2254.1343ms floors to 2s' { + Format-TierModelDuration -Milliseconds 2254.1343 | Should -Be '2s' + } + + It '59000ms returns 59s' { + Format-TierModelDuration -Milliseconds 59000 | Should -Be '59s' + } + + It '59999ms returns 59s (boundary below 60s tier)' { + Format-TierModelDuration -Milliseconds 59999 | Should -Be '59s' + } + } + + Context 'Minute tier — returns Xm Ys for 60000ms and above' { + + It '60000ms returns 1m 0s' { + Format-TierModelDuration -Milliseconds 60000 | Should -Be '1m 0s' + } + + It '89999ms returns 1m 29s' { + Format-TierModelDuration -Milliseconds 89999 | Should -Be '1m 29s' + } + + It '90000ms returns 1m 30s — banker''s-rounding regression guard' { + # [int][TimeSpan]::TotalMinutes: 90000ms = 1.5 min; [int]1.5 banker''s-rounds to 2 (even) + # → wrong "2m 30s". Floor-based arithmetic gives the correct "1m 30s". + Format-TierModelDuration -Milliseconds 90000 | Should -Be '1m 30s' + } + + It '119999ms returns 1m 59s — banker''s-rounding regression guard' { + # [int][TimeSpan]::TotalMinutes: [int]1.9999 rounds to 2, yielding wrong "2m 59s". + Format-TierModelDuration -Milliseconds 119999 | Should -Be '1m 59s' + } + + It '120000ms returns 2m 0s — minute boundary regression guard' { + Format-TierModelDuration -Milliseconds 120000 | Should -Be '2m 0s' + } + + It '140000ms returns 2m 20s' { + Format-TierModelDuration -Milliseconds 140000 | Should -Be '2m 20s' + } + + It '7500000ms returns 125m 0s (large value, no hour tier)' { + Format-TierModelDuration -Milliseconds 7500000 | Should -Be '125m 0s' + } + } + + Context 'Output type' { + + It 'return value is a string' { + $result = Format-TierModelDuration -Milliseconds 500 + $result | Should -BeOfType [string] + } + } +} diff --git a/tests/Unit.ModuleManifest.Tests.ps1 b/tests/Unit.ModuleManifest.Tests.ps1 index c11b2d2..a13f42e 100644 --- a/tests/Unit.ModuleManifest.Tests.ps1 +++ b/tests/Unit.ModuleManifest.Tests.ps1 @@ -38,8 +38,8 @@ Describe 'TierModel Module Manifest' -Tag 'Unit', 'Manifest' { $script:Manifest.ModuleVersion | Should -Match '^\d+\.\d+\.\d+$' } - It 'Has current version 1.3.1' { - $script:Manifest.ModuleVersion | Should -Be '1.3.1' + It 'Has current version 1.3.2' { + $script:Manifest.ModuleVersion | Should -Be '1.3.2' } It 'Has valid GUID' { @@ -89,7 +89,7 @@ Describe 'TierModel Module Manifest' -Tag 'Unit', 'Manifest' { It 'All declared functions follow naming convention' { $invalidNames = $script:DeclaredFunctions | Where-Object { - $_ -notmatch '^(Get|Set|New|Test|Copy|Import|Repair|Resolve|Clear|Update|Write)-(TierModel|DomainSpecificGuid)' + $_ -notmatch '^(Get|Set|New|Test|Copy|Import|Repair|Resolve|Clear|Update|Write|Format)-(TierModel|DomainSpecificGuid)' } $invalidNames | Should -BeNullOrEmpty } @@ -331,6 +331,10 @@ Describe 'TierModel Module Manifest' -Tag 'Unit', 'Manifest' { It 'GPO Link Fd function is accessible' { Get-Command -Module TierModel -Name 'Get-TierModelGpoLinkFd' | Should -Not -BeNullOrEmpty } + + It 'Format-TierModelDuration is exported and accessible' { + Get-Command -Module TierModel -Name 'Format-TierModelDuration' | Should -Not -BeNullOrEmpty + } } Context 'Module Dependencies' {