diff --git a/.github/skills/ghpmv-e2e-validation/SKILL.md b/.github/skills/ghpmv-e2e-validation/SKILL.md index 12aaa9a8..e2d0f181 100644 --- a/.github/skills/ghpmv-e2e-validation/SKILL.md +++ b/.github/skills/ghpmv-e2e-validation/SKILL.md @@ -87,7 +87,7 @@ browser login command も同様に agent が終了まで監視する。ユーザ | PAT permission preflight | HTTP status と endpoint ごとの response | | fixture 作成 | exit code、作成された repository / Project、Project number | | export | exit code、`snapshot.json`、mapping CSV、warning | -| browser-e2e field defaults / sums | typed defaults と5 Viewのsnapshot contract、target `Field: Match` / `View: Match`、new-draft functional check、rendered-header DOM check、drift report、repair report | +| browser-e2e field defaults / sums / Board limits | typed defaults と6 Viewのsnapshot contract、configured / unlimited Board limits、target `Field: Match` / `View: Match`、new-draft functional check、rendered-header / Board-limit DOM check、drift report、repair report | | GEI | migration status、target repository、Issue / PR number | | import | `result`、target Project number、`import-log.json` | | verify | overall / category result、`verify-report.json` | @@ -188,6 +188,8 @@ agent が terminal に command を直接入力できず、ユーザー自身が | host topology | `github.com-to-github.com`, `github.com-to-ghec-dr` など | | browser-e2e field-sum contract | 下記の View / field 名と期待値 | | browser-e2e field-sum status | `fixture-pending`, `snapshot-match`, `target-view-match`, `target-render-observed`, `drift-detected`, `repair-match` | +| browser-e2e Board-limit contract | `Fixture Board`: `Fixture Select/Alpha=1`, `Beta=2`, `Gamma=unlimited`; `Fixture Iteration Board`: `Fixture Sprint/Sprint 0=1`, `Sprint 1=3`, `Sprint 2/3=unlimited` | +| browser-e2e Board-limit status | `fixture-pending`, `snapshot-match`, `target-view-match`, `target-render-observed`, `drift-detected`, `repair-match` | | browser-e2e field-default contract | `Fixture Text=既定値 🌏`, `Fixture Number=-7`, `Fixture Number 2=0`, `Fixture Select=Beta` | | browser-e2e field-default status | `fixture-pending`, `snapshot-match`, `target-field-match`, `new-draft-observed`, `drift-detected`, `repair-match` | | browser-e2e Roadmap display contract | Project-shared state: both Roadmaps=`(truncateTitles=true, showDateFields=false)` | @@ -201,11 +203,14 @@ agent が terminal に command を直接入力できず、ユーザー自身が | `View 1` | `TABLE_LAYOUT` / `Status` | `Count`, `Fixture Number`, `Fixture Number 2` | | `Fixture Roadmap` | `ROADMAP_LAYOUT` / `Status` | `Fixture Number 2` | | `Fixture Board` | `BOARD_LAYOUT` / `Status` | `Fixture Number` | +| `Fixture Iteration Board` | `BOARD_LAYOUT` / none | empty | | `Fixture Empty Sums` | `TABLE_LAYOUT` / `Status` | empty | | `Fixture Roadmap Dates Hidden` | `ROADMAP_LAYOUT` / `Status` | `Fixture Number 2` | required Number fields は `Fixture Number` と `Fixture Number 2`。source / target の実 resource 名を E2E settings schema に追加する必要はない。browser state、PAT、cookie は引き続き settings に保存しない。 +同じ round-tripでBoard列上限も常に検証する。Single-selectとIterationのlogical identity、複数の異なる数値、unlimited列、limit=1を超えるitem countを標準fixtureへ含める。Step 6はnullable collectionを検査し、初回verifyとrender checkはtargetの数値/unlimited/over-limit状態を確認する。既存field-sum drift commandでAlphaを1→5へ変更しBetaをclearし、既存の一回のrepair importで戻す。item移動やcolumn value変更は行わない。 + 同じ round-trip で field defaults も常に検証する。`setup --fixture --fixture-ui` は source items 作成後に defaults を設定するため既存 item values を変更しない。Step 6 は typed defaults を snapshot から検査し、Step 10 は `Field: Match` 後に `--fixture-field-default-check` で disposable target draft への自動入力を機械確認する。drift phase は `--fixture-field-default-drift` で Text / zero Number / Single-select を変更し、negative Number default を clear した後、既存の一回の repair import で Field sum と同時に戻す。 同じ round-trip でProject-shared Roadmap state `(truncateTitles=true, showDateFields=false)` を両Roadmapからcaptureし、DOM checkpointでtitle truncationとdate非表示を検証する。negative-test phaseはtitle-only `(false,false)` とdate-only `(true,true)` をそれぞれ別checkpointで適用し、exact mismatch、DOM rendering、baseline repairを各段階で確認する。 @@ -546,7 +551,7 @@ settings の `execution.fixturePreparation`、`execution.repositoryPreparationMo `api-only` または `browser-e2e` では、settings の `execution.fixturePreparation` を `fixture preparation` として記録し、設定済みなら質問しない。設定がない場合だけ、既存 source Project を使うか fixture を作るかを一問で確認する。`api-only` の `existing` は Step 5 を実行せず、fixture 作成用権限を要求しない。`browser-e2e` の `existing` は resource を作成しない確認 Step として Step 5 を通り、現行標準 fixture contract を記録する。 -`browser-e2e` の fixture preparation 質問では、既存 round-trip が Text / Number / Single-select defaults と grouped Table / Roadmap の Field sum も検証することを質問文に含める。`create` は現行の標準 fixture が4 typed defaults、required Number fields、5 Viewsを決定的に作るため推奨する。`existing` は arbitrary Project ではなく、下記 contract を満たす現行標準 fixture または同等構成に限る。Step 6 の snapshot gate が不一致なら手編集で続行せず、新しい標準 fixture を作るか明示的に選び直す。 +`browser-e2e` の fixture preparation 質問では、既存 round-trip が Text / Number / Single-select defaults と grouped Table / Roadmap の Field sum も検証することを質問文に含める。`create` は現行の標準 fixture が4 typed defaults、required Number fields、6 Viewsを決定的に作るため推奨する。`existing` は arbitrary Project ではなく、下記 contract を満たす現行標準 fixture または同等構成に限る。Step 6 の snapshot gate が不一致なら手編集で続行せず、新しい標準 fixture を作るか明示的に選び直す。 同じ mode では、settings の `execution.repositoryPreparationMode` を `repository preparation mode` として記録し、設定済みなら質問しない。設定がない場合だけ、target repository を GEI で移行するか fixture seed で作るかを Step 4 より前に一問で確認する。token の用途が決まるまで PAT の入力を求めない。 @@ -901,7 +906,7 @@ fine-grained PAT の **Administration** または **All repositories** を付与 `browser-e2e` では fixture preparation にかかわらず、Step 5 の開始時に次を state へ記録する。 -- View names: `View 1`, `Fixture Board`, `Fixture Roadmap`, `Fixture Empty Sums`, `Fixture Roadmap Dates Hidden` +- View names: `View 1`, `Fixture Board`, `Fixture Iteration Board`, `Fixture Roadmap`, `Fixture Empty Sums`, `Fixture Roadmap Dates Hidden` - Number field names: `Fixture Number`, `Fixture Number 2` - Grouping field: `Status` - expected FieldSum: session state の fixture contract 表 @@ -986,7 +991,7 @@ source が data residency の場合は選択した source command に `--api-bas 同じ Project に明示的に再実行すると non-default Views が重複する。次のどちらかを選んでもらう。 1. 新しい fixture Project を作る(推奨) -2. `View 1` を残し、既存の `Fixture Board` / `Fixture Roadmap` / `Fixture Empty Sums` / `Fixture Roadmap Dates Hidden` を手動削除して再実行する +2. `View 1` を残し、既存の `Fixture Board` / `Fixture Iteration Board` / `Fixture Roadmap` / `Fixture Empty Sums` / `Fixture Roadmap Dates Hidden` を手動削除して再実行する Workflow は再設定できる。warning が出た場合は、目視だけで終了せず、後続 export が UI settings を警告なしで取得できるか確認する。 @@ -1080,6 +1085,7 @@ foreach ($expected in $expectedDefaults) { $expectedViews = @( [pscustomobject]@{ Name = 'View 1'; Layout = 'TABLE_LAYOUT'; GroupBy = @('Status'); FieldSum = @('Count', 'Fixture Number', 'Fixture Number 2') }, [pscustomobject]@{ Name = 'Fixture Board'; Layout = 'BOARD_LAYOUT'; GroupBy = @('Status'); FieldSum = @('Fixture Number') }, + [pscustomobject]@{ Name = 'Fixture Iteration Board'; Layout = 'BOARD_LAYOUT'; GroupBy = @(); FieldSum = @() }, [pscustomobject]@{ Name = 'Fixture Roadmap'; Layout = 'ROADMAP_LAYOUT'; GroupBy = @('Status'); FieldSum = @('Fixture Number 2') }, [pscustomobject]@{ Name = 'Fixture Empty Sums'; Layout = 'TABLE_LAYOUT'; GroupBy = @('Status'); FieldSum = @() }, [pscustomobject]@{ Name = 'Fixture Roadmap Dates Hidden'; Layout = 'ROADMAP_LAYOUT'; GroupBy = @('Status'); FieldSum = @('Fixture Number 2') } @@ -1110,6 +1116,69 @@ foreach ($expected in $expectedViews) { } Write-Output ("GHPMV_FIELD_SUM_VIEW:{0}:{1}" -f $expected.Name, ($actualFieldSum -join ', ')) } +$expectedBoardLimits = @( + [pscustomobject]@{ View = 'Fixture Board'; Field = 'Fixture Select'; Property = 'singleSelectOptionName'; Value = 'Alpha'; Limit = 1 }, + [pscustomobject]@{ View = 'Fixture Board'; Field = 'Fixture Select'; Property = 'singleSelectOptionName'; Value = 'Beta'; Limit = 2 }, + [pscustomobject]@{ View = 'Fixture Iteration Board'; Field = 'Fixture Sprint'; Property = 'iterationTitle'; Value = 'Sprint 0'; Limit = 1 }, + [pscustomobject]@{ View = 'Fixture Iteration Board'; Field = 'Fixture Sprint'; Property = 'iterationTitle'; Value = 'Sprint 1'; Limit = 3 } +) +foreach ($expectedView in @($expectedBoardLimits | Group-Object View)) { + $views = @($snapshot.views | Where-Object name -eq $expectedView.Name) + if ($views.Count -ne 1 -or $null -eq $views[0].ui.boardColumnLimits) { + Stop-FieldSumSnapshotCheck "View '$($expectedView.Name)' is missing captured boardColumnLimits." + return + } + $actualLimits = @($views[0].ui.boardColumnLimits) + if ($actualLimits.Count -ne $expectedView.Count) { + Stop-FieldSumSnapshotCheck "View '$($expectedView.Name)' boardColumnLimits must contain exactly $($expectedView.Count) configured limits; found $($actualLimits.Count)." + return + } +} +foreach ($expected in $expectedBoardLimits) { + $views = @($snapshot.views | Where-Object name -eq $expected.View) + $matches = @($views[0].ui.boardColumnLimits | Where-Object { + $_.fieldName -eq $expected.Field -and $_.($expected.Property) -eq $expected.Value -and $_.limit -eq $expected.Limit + }) + if ($matches.Count -ne 1) { + Stop-FieldSumSnapshotCheck "Board limit '$($expected.View)/$($expected.Field)/$($expected.Value)=$($expected.Limit)' was not captured exactly once." + return + } +} +$selectFields = @($snapshot.fields | Where-Object { $_.name -eq 'Fixture Select' -and $_.dataType -eq 'SINGLE_SELECT' }) +if ($selectFields.Count -ne 1 -or @($selectFields[0].options | Where-Object name -eq 'Gamma').Count -ne 1) { + Stop-FieldSumSnapshotCheck "Fixture Select must contain the unlimited logical column 'Gamma'." + return +} +$iterationFields = @($snapshot.fields | Where-Object { $_.name -eq 'Fixture Sprint' -and $_.dataType -eq 'ITERATION' }) +if ($iterationFields.Count -ne 1) { + Stop-FieldSumSnapshotCheck "Expected exactly one ITERATION field 'Fixture Sprint'." + return +} +$fixtureIterations = @($iterationFields[0].iterationConfiguration.iterations) + + @($iterationFields[0].iterationConfiguration.completedIterations) +foreach ($title in @('Sprint 2', 'Sprint 3')) { + if (@($fixtureIterations | Where-Object title -eq $title).Count -ne 1) { + Stop-FieldSumSnapshotCheck "Fixture Sprint must contain the unlimited logical column '$title'." + return + } +} +$expectedOverLimitColumns = @( + [pscustomobject]@{ Field = 'Fixture Select'; Property = 'singleSelectOptionName'; Value = 'Alpha'; MinimumItems = 2 }, + [pscustomobject]@{ Field = 'Fixture Sprint'; Property = 'iterationTitle'; Value = 'Sprint 0'; MinimumItems = 2 } +) +foreach ($expected in $expectedOverLimitColumns) { + $matchingItems = @($snapshot.items | Where-Object { + $item = $_ + @($item.fieldValues | Where-Object { + $_.fieldName -eq $expected.Field -and $_.($expected.Property) -eq $expected.Value + }).Count -eq 1 + }) + if ($matchingItems.Count -lt $expected.MinimumItems) { + Stop-FieldSumSnapshotCheck "Board column '$($expected.Field)/$($expected.Value)' must contain at least $($expected.MinimumItems) items to exceed its limit." + return + } +} +Write-Output 'GHPMV_BOARD_LIMIT_SNAPSHOT_MATCH' $roadmap = @($snapshot.views | Where-Object name -eq 'Fixture Roadmap') if ($roadmap.Count -ne 1 -or $roadmap[0].ui.roadmap.truncateTitles -ne $true -or @@ -1157,7 +1226,7 @@ Write-Output 'GHPMV_FIELD_DEFAULT_SNAPSHOT_MATCH' $global:LASTEXITCODE = 0 ``` -`GHPMV_FIELD_DEFAULT_SNAPSHOT_MATCH`、`GHPMV_FIELD_SUM_SNAPSHOT_MATCH`、`GHPMV_ROADMAP_DISPLAY_SNAPSHOT_MATCH`、command exit code 0 をすべて確認した場合だけ各 feature status を `snapshot-match` とし、先へ進む。zero は null と区別し、Single-select は source option ID でなく `singleSelectOptionName=Beta` を要求する。Table / Roadmap のいずれかだけ一致、warning、missing UI、`1 more` のような summary text、`null` と空集合以外の不一致を成功扱いしない。失敗時は source fixture contract の実値を示し、新しい標準 fixture を作るかどうかを一問で確認して停止する。 +`GHPMV_FIELD_DEFAULT_SNAPSHOT_MATCH`、`GHPMV_FIELD_SUM_SNAPSHOT_MATCH`、`GHPMV_BOARD_LIMIT_SNAPSHOT_MATCH`、`GHPMV_ROADMAP_DISPLAY_SNAPSHOT_MATCH`、command exit code 0 をすべて確認した場合だけ各 feature status を `snapshot-match` とし、先へ進む。zero は null と区別し、Single-select は source option ID でなく `singleSelectOptionName=Beta` を要求する。Table / Roadmap のいずれかだけ一致、warning、missing UI、`1 more` のような summary text、`null` と空集合以外の不一致を成功扱いしない。失敗時は source fixture contract の実値を示し、新しい標準 fixture を作るかどうかを一問で確認して停止する。 `api-only` / `browser-e2e`では、target PAT入力またはtarget resource準備より前に同じterminalでsnapshot-driven capabilityを算出する。 @@ -1531,7 +1600,7 @@ Write-Output 'GHPMV_BROWSER_FIELD_MATCH' $global:LASTEXITCODE = 0 ``` -`GHPMV_BROWSER_FIELD_MATCH`、`GHPMV_BROWSER_VIEW_MATCH`、command exit code 0 を確認した場合だけ field-default status=`target-field-match`、field-sum status=`target-view-match` とする。Field / View の warning、`PartialMatch`、`NotVerified` は、overall status が許容可能でも `browser-e2e` の成功にしない。 +`GHPMV_BROWSER_FIELD_MATCH`、`GHPMV_BROWSER_VIEW_MATCH`、command exit code 0 を確認した場合だけ field-default status=`target-field-match`、field-sum / Board-limit status=`target-view-match` とする。Field / View の warning、`PartialMatch`、`NotVerified` は、overall status が許容可能でも `browser-e2e` の成功にしない。 続けて同じ target に disposable draft を一件作成し、GitHub が4 defaultsを自動入力することを API read-back で確認する。command出力のdraft item ID / titleをtarget Projectのnested resource inventoryへ`created`として追加し、cleanup同意前には削除しない。 @@ -1555,13 +1624,15 @@ finally { target が data residency の場合は `--api-base-url ` と `--browser-base-url ` を追加する。`Fixture field defaults functionally verified: ... fields=4 draft= title='' cleanup=pending` と exit code 0 を確認し、draftをinventoryへ追加した場合だけ field-default status=`new-draft-observed` とする。初回 verify の `Item: Match` と合わせ、import 済み既存 item values が変化せず、新規 draftだけに defaults が入ったことを合格条件とする。 -### Browser field-sum machine check +### Browser field-sum / Board-limit machine check 初回 `View: Match` は browser-assisted exporter が target の各 View を Playwright で再読し、次を source snapshot と機械比較した結果である。 - `View 1`: layout、Group by=`Status`、Field sum=`Count`, `Fixture Number`, `Fixture Number 2` - `Fixture Roadmap`: layout、Group by=`Status`、Field sum=`Fixture Number 2` - `Fixture Board`: layout、Swimlanes=`Status`、Field sum=`Fixture Number` +- `Fixture Board`: `Fixture Select/Alpha=1`, `Beta=2`, `Gamma=unlimited` +- `Fixture Iteration Board`: `Fixture Sprint/Sprint 0=1`, `Sprint 1=3`, `Sprint 2/3=unlimited` - `Fixture Empty Sums`: layout、Group by=`Status`、Field sum=empty - `Fixture Roadmap Dates Hidden`: layout、Group by=`Status`、Field sum=`Fixture Number 2`、Show date fields=false @@ -1585,7 +1656,7 @@ finally { } ``` -target が data residency の場合は `--api-base-url <target-api-url>` と `--browser-base-url <target-web-url>` を追加する。command は `View 1` と2つのRoadmapをreloadし、aggregate rendering、長いtitleのclip/ellipsis、同じitem内のdate field表示/非表示をDOMで検査する。3 Viewの`Rendered Field sums verified`、`Fixture field-sum rendering verified: ... views=3`、command exit code 0を確認した場合だけ Field sum と Roadmap display のstatusを`target-render-observed`としてdeliberate driftへ進む。 +target が data residency の場合は `--api-base-url <target-api-url>` と `--browser-base-url <target-web-url>` を追加する。command は grouped Viewと2つのBoardをreloadし、aggregate rendering、長いtitleのclip/ellipsis、同じitem内のdate field表示/非表示、Boardの数値/unlimited/over-limit状態をDOMで検査する。`Rendered Field sums verified`、両Boardの`Rendered Board limits verified`、`Fixture field-sum and Board-limit rendering verified`、command exit code 0を確認した場合だけ Field sum / Board-limit / Roadmap display のstatusを`target-render-observed`としてdeliberate driftへ進む。 ### Deliberate drift と repair @@ -1804,7 +1875,7 @@ finally { } ``` -target が data residency の場合は `--api-base-url <target-api-url>` と `--browser-base-url <target-web-url>` を追加する。`Fixture field-sum drift applied`、`viewWarnings=0`、command exit code 0 を確認した後、保持中のRoadmap date-display driftを含め、Field default / Field sum / Roadmap date-displayの3 driftを同時検証するverifyを送る。 +target が data residency の場合は `--api-base-url <target-api-url>` と `--browser-base-url <target-web-url>` を追加する。`Fixture field-sum and Board-limit drift applied`、`viewWarnings=0`、command exit code 0 を確認した後、保持中のRoadmap date-display driftを含め、Field default / Field sum / Board limit / Roadmap date-displayの4 driftを同時検証するverifyを送る。drift commandは`Fixture Board`のAlphaを5へ変更しBetaをclearするが、itemやcolumn valueは変更しない。 ```powershell function Stop-FieldSumDriftCheck([string]$Message) { @@ -1844,6 +1915,7 @@ $driftFieldCategories = @($driftReport.categories | Where-Object category -eq 'F $driftViewCategories = @($driftReport.categories | Where-Object category -eq 'View') $fieldDefaultDifferences = @($driftReport.differences | Where-Object { $_.category -eq 'Field' -and $_.message -match 'default value mismatch' }) $fieldSumDifferences = @($driftReport.differences | Where-Object { $_.category -eq 'View' -and $_.message -match "view 'View 1': field sum mismatch" }) +$boardLimitDifferences = @($driftReport.differences | Where-Object { $_.category -eq 'View' -and $_.message -match "view 'Fixture Board': Board limit mismatch" }) $roadmapTitleDifferences = @($driftReport.differences | Where-Object { $_.category -eq 'View' -and $_.message -match "view 'Fixture Roadmap(?: Dates Hidden)?': truncate titles mismatch" }) @@ -1860,22 +1932,25 @@ if ($driftFieldCategories.Count -ne 1 -or $driftViewCategories.Count -ne 1 -or $driftViewCategories[0].status -ne 'Mismatch' -or $fieldSumDifferences.Count -ne 1 -or + $boardLimitDifferences.Count -ne 2 -or $roadmapTitleDifferences.Count -ne 0 -or $roadmapDateDifferences.Count -ne 2 -or - $nonInfoDifferences.Count -ne 7 -or + $nonInfoDifferences.Count -ne 9 -or $unexpectedCategoryStatuses.Count -ne 0) { - Stop-FieldSumDriftCheck 'Verify did not contain exactly four Field default mismatches, the View 1 field-sum mismatch, and both Roadmap date-display mismatches.' + Stop-FieldSumDriftCheck 'Verify did not contain exactly four Field default mismatches, the View 1 field-sum mismatch, two Fixture Board limit mismatches, and both Roadmap date-display mismatches.' return } Write-Output $fieldDefaultDifferences.message Write-Output $fieldSumDifferences.message +Write-Output $boardLimitDifferences.message Write-Output 'GHPMV_FIELD_DEFAULT_DRIFT_DETECTED' Write-Output 'GHPMV_FIELD_SUM_DRIFT_DETECTED' +Write-Output 'GHPMV_BOARD_LIMIT_DRIFT_DETECTED' Write-Output 'GHPMV_ROADMAP_DISPLAY_DRIFT_DETECTED' $global:LASTEXITCODE = 0 ``` -target が data residency の場合は、この drift verify にも初回 verify と同じ `--target-base-url <target-api-url>` と `--browser-base-url <target-web-url>` を追加する。3つの drift marker と wrapper exit code 0 を確認した場合だけ各feature status=`drift-detected`としてrepairへ進む。 +target が data residency の場合は、この drift verify にも初回 verify と同じ `--target-base-url <target-api-url>` と `--browser-base-url <target-web-url>` を追加する。4つの drift marker と wrapper exit code 0 を確認した場合だけ各feature status=`drift-detected`としてrepairへ進む。 続けて同じ snapshot と target Project へ browser-assisted import を再実行する。`--project-number` は既存 Project を常に更新するため、`--on-conflict` や `--project-title` を追加しない。 @@ -1983,11 +2058,12 @@ if ($global:GHPMV_REPAIR_VERIFY_EXIT_CODE -eq 0 -or Write-Output 'GHPMV_ITEM_VALUES_REPAIR_MATCH' Write-Output 'GHPMV_FIELD_DEFAULT_REPAIR_MATCH' Write-Output 'GHPMV_FIELD_SUM_REPAIR_MATCH' +Write-Output 'GHPMV_BOARD_LIMIT_REPAIR_MATCH' Write-Output 'GHPMV_ROADMAP_DISPLAY_REPAIR_MATCH' $global:LASTEXITCODE = 0 ``` -target が data residency の場合は repair import / verify にも初回と同じ endpoint option を追加する。Field default / Field sum / Roadmap display の3つの repair marker と command exit code 0 を確認後、次のfunctional checkを同じterminalへ送る。 +target が data residency の場合は repair import / verify にも初回と同じ endpoint option を追加する。Field default / Field sum / Board limit / Roadmap display の4つの repair marker と command exit code 0 を確認後、次のfunctional checkを同じterminalへ送る。 ```powershell $previousGhpmvToken = $env:GHPMV_TOKEN @@ -2007,9 +2083,9 @@ finally { } ``` -target が data residency の場合は `--api-base-url <target-api-url>` と `--browser-base-url <target-web-url>` を追加する。`Fixture field defaults functionally verified: ... fields=4 draft=<id> title='<title>' cleanup=pending` と exit code 0 を確認し、修復後draftをnested resource inventoryへ`created`として追加する。修復後の new draftにも4 defaultsが入り、`GHPMV_ITEM_VALUES_REPAIR_MATCH`が既知のinventory draft以外にItem差分がないことを証明した場合、追加の対話用質問を行わず3 feature status=`repair-match` とする。 +target が data residency の場合は `--api-base-url <target-api-url>` と `--browser-base-url <target-web-url>` を追加する。`Fixture field defaults functionally verified: ... fields=4 draft=<id> title='<title>' cleanup=pending` と exit code 0 を確認し、修復後draftをnested resource inventoryへ`created`として追加する。修復後の new draftにも4 defaultsが入り、`GHPMV_ITEM_VALUES_REPAIR_MATCH`が既知のinventory draft以外にItem差分がないことを証明した場合、追加の対話用質問を行わず4 feature status=`repair-match` とする。 -`browser-e2e` は field-defaultの`new-draft-observed` / `repair-match`、field-sumとRoadmap displayの`target-render-observed` / `repair-match`へ到達してから Resource inventory の cleanup 同意へ進む。`api-only` は通常の Step 10 完了後に cleanup 同意へ進む。 +`browser-e2e` は field-defaultの`new-draft-observed` / `repair-match`、field-sum / Board-limit / Roadmap displayの`target-render-observed` / `repair-match`へ到達してから Resource inventory の cleanup 同意へ進む。`api-only` は通常の Step 10 完了後に cleanup 同意へ進む。 ## Troubleshooting @@ -2035,7 +2111,7 @@ target が data residency の場合は `--api-base-url <target-api-url>` と `-- - source / target Project URL または番号 - export / import result - verify overall / category result -- browser-e2e の field-default / field-sum snapshot、initial Field/View Match、new-draft functional check、rendered-header DOM check、drift / repair result +- browser-e2e の field-default / field-sum / Board-limit snapshot、initial Field/View Match、new-draft functional check、rendered-header/Board-limit DOM check、drift / repair result - 許容した warning - resource inventory の各 name / URL / cleanup 状態と snapshot directory diff --git a/docs/BROWSER_AUTOMATION_PLAN.md b/docs/BROWSER_AUTOMATION_PLAN.md index 5232dd89..378efb2b 100644 --- a/docs/BROWSER_AUTOMATION_PLAN.md +++ b/docs/BROWSER_AUTOMATION_PLAN.md @@ -23,6 +23,7 @@ GraphQL と Playwright を組み合わせた View・Workflow 移行の詳細設 | 表示フィールドと列順 | **GraphQL** `ProjectV2View.configuration.visibleFields` | **GraphQL** `ProjectV2ViewConfigurationInput.visibleFieldIds` | target field ID へ名前で remap | | group-by(Table)/ swimlane(Board) | GraphQL `groupByFields` | **UI** | | | Board の列フィールド | GraphQL `verticalGroupByFields` | **UI**("Column by") | | +| **Board 列ごとのカード上限** | ❌ API に無い → **UI で読む** | **UI**(各列の context menu → "Set limit") | nullable complete-set。field名 + Single-select option名 / Iteration titleで識別し、null=未取得、空集合=取得済み上限なし | | sort(複数キー+方向) | GraphQL `sortByFields`(`ProjectV2SortByField.direction`) | **UI** | | | **Slice by** | ❌ API に無い → **UI で読む** | **UI** | | | **Field sum** | ❌ API に無い → **UI で読む** | **UI** | Board と grouped Table / Roadmap。Count、複数 Number field、空集合を complete-set 同期 | diff --git a/docs/MANUAL_TEST_PLAN.md b/docs/MANUAL_TEST_PLAN.md index ef954f77..cb00a7a1 100644 --- a/docs/MANUAL_TEST_PLAN.md +++ b/docs/MANUAL_TEST_PLAN.md @@ -126,7 +126,7 @@ EMU / SAML / OIDC backed organization の場合は、PAT と browser session の Team link の手動 E2E では共有 Team を変更せず、source/target の各 organization にこのテスト専用 Team を作成してください。source fixture には `--fixture-team <source-team-slug>` を渡します。target Team は同じ slug、または renamed mapping を確認する別 slug にします。 -Views の作成と name / layout / filter / visible fields は GraphQL API で設定します。標準 fixture には API 未対応の View 設定、非自明な `Fixture Roadmap → View 1 → Fixture Board → Fixture Empty Sums → Fixture Roadmap Dates Hidden` の tab order、Workflows も含まれるため、`ghpmv setup --fixture-ui` は API View import の後に C# の Playwright layer で補完します。手動で UI をぽちぽち濃くする必要はありません。 +Views の作成と name / layout / filter / visible fields は GraphQL API で設定します。標準 fixture には API 未対応の View 設定、非自明な `Fixture Roadmap → View 1 → Fixture Board → Fixture Iteration Board → Fixture Empty Sums → Fixture Roadmap Dates Hidden` の tab order、Workflows も含まれるため、`ghpmv setup --fixture-ui` は API View import の後に C# の Playwright layer で補完します。手動で UI をぽちぽち濃くする必要はありません。 --- @@ -323,11 +323,12 @@ Source project number: <source-project-number> - Views - `View 1`: grouped Table、filter、sort、Slice by、Field sum=`Count` + `Fixture Number` + `Fixture Number 2`、visible fields - - `Fixture Board`: Board、Column by、Swimlanes、Field sum + - `Fixture Board`: Board、Column by、Swimlanes、Field sum、Single-select列上限(`Alpha=1`, `Beta=2`, `Gamma=unlimited`) + - `Fixture Iteration Board`: Board、Column by=`Fixture Sprint`、Iteration列上限(`Sprint 0=1`, `Sprint 1=3`, `Sprint 2/3=unlimited`) - `Fixture Roadmap`: grouped Roadmap、Field sum=`Fixture Number 2`、date fields、Quarter zoom、markers、shared `Truncate titles`=on、`Show date fields`=off。fixture は truncation 確認用の長い draft title を含む - `Fixture Empty Sums`: grouped Table、Field sum の空選択 - `Fixture Roadmap Dates Hidden`: grouped Roadmap、`Truncate titles`=on、`Show date fields`=off - - tab order: `Fixture Roadmap` → `View 1` → `Fixture Board` → `Fixture Empty Sums` → `Fixture Roadmap Dates Hidden` + - tab order: `Fixture Roadmap` → `View 1` → `Fixture Board` → `Fixture Iteration Board` → `Fixture Empty Sums` → `Fixture Roadmap Dates Hidden` - Workflows - item state 系 built-in workflows - `Auto-add to project` @@ -344,7 +345,7 @@ Source project number: <source-project-number> Views: -- タブを `Fixture Roadmap` → `View 1` → `Fixture Board` → `Fixture Empty Sums` → `Fixture Roadmap Dates Hidden` の順に並べる +- タブを `Fixture Roadmap` → `View 1` → `Fixture Board` → `Fixture Iteration Board` → `Fixture Empty Sums` → `Fixture Roadmap Dates Hidden` の順に並べる - `View 1` (Table) - filter=`status:Todo` - visible fields を標準 fixture に合わせる @@ -356,6 +357,10 @@ Views: - Column by=`Fixture Select` - Swimlanes=`Status` - Field sum=`Fixture Number` + - Column limits: `Alpha=1`, `Beta=2`, `Gamma=unlimited` +- `Fixture Iteration Board` (Board) + - Column by=`Fixture Sprint` + - Column limits: `Sprint 0=1`, `Sprint 1=3`, `Sprint 2/3=unlimited` - `Fixture Roadmap` (Roadmap) - group by Status - Field sum=`Fixture Number 2` @@ -489,7 +494,8 @@ dotnet run --project src/Ghpmv.Cli -- export ` - 各 View の `tabPosition` が 0 から始まる source UI 順で保存され、view `number` 順とは独立している。 - `snapshot.json` の View UI 設定が 5.2 の標準 fixture と一致する。 - `View 1`: `fieldSum=["Count","Fixture Number","Fixture Number 2"]` - - `Fixture Board`: `fieldSum=["Fixture Number"]` + - `Fixture Board`: `fieldSum=["Fixture Number"]`, `boardColumnLimits=[Fixture Select/Alpha=1, Fixture Select/Beta=2]` + - `Fixture Iteration Board`: `fieldSum=[]`, `boardColumnLimits=[Fixture Sprint/Sprint 0=1, Fixture Sprint/Sprint 1=3]` - `Fixture Roadmap`: `fieldSum=["Fixture Number 2"]` - `Fixture Empty Sums`: `fieldSum=[]`(submenu を取得できた空選択。control/submenu を取得できない場合は View UI 未取得 warning) - `Fixture Roadmap Dates Hidden`: `fieldSum=["Fixture Number 2"]`, `truncateTitles=true`, `showDateFields=false` @@ -499,9 +505,10 @@ dotnet run --project src/Ghpmv.Cli -- export ` ```powershell $snapshot = Get-Content "$env:GHPMV_SNAPSHOT_DIR/snapshot.json" -Raw | ConvertFrom-Json $snapshot.views | - Where-Object name -in @('View 1', 'Fixture Board', 'Fixture Roadmap', 'Fixture Empty Sums', 'Fixture Roadmap Dates Hidden') | + Where-Object name -in @('View 1', 'Fixture Board', 'Fixture Iteration Board', 'Fixture Roadmap', 'Fixture Empty Sums', 'Fixture Roadmap Dates Hidden') | Select-Object name, groupByFields, @{ Name = 'fieldSum'; Expression = { @($_.ui.fieldSum) -join ', ' } }, + @{ Name = 'boardColumnLimits'; Expression = { @($_.ui.boardColumnLimits | ForEach-Object { "$($_.fieldName)/$($_.singleSelectOptionName)$($_.iterationTitle)=$($_.limit)" }) -join ', ' } }, @{ Name = 'truncateTitles'; Expression = { $_.ui.roadmap.truncateTitles } }, @{ Name = 'showDateFields'; Expression = { $_.ui.roadmap.showDateFields } } ``` @@ -631,16 +638,16 @@ dotnet run --project src/Ghpmv.Cli -- setup ` --browser-profile target ``` -`Rendered Field sums verified` が3 Viewに出力され、最後に `Fixture field-sum rendering verified: project=#<target-project-number> views=3` と exit code 0 になることを確認します。 +`Rendered Field sums verified`、`Rendered Board limits verified` が対象Viewに出力され、最後に `Fixture field-sum and Board-limit rendering verified: project=#<target-project-number>` と exit code 0 になることを確認します。両Boardで数値上限、unlimited列、limit=1を超えるcountを機械確認します。 3. `ghpmv setup --fixture-field-default-check --fixture-org <target-org> --fixture-project <target-project-number> --browser-profile target` を実行し、Projects UIから作成されたdisposable draftにText / negative Number / zero / Single-select defaultsが自動入力されることを確認します。出力されたdraft item ID / titleをresource inventoryに追加し、cleanup同意前には削除しません。 4. `ghpmv setup --fixture-roadmap-display-drift --fixture-org <target-org> --fixture-project <target-project-number> --browser-profile target` を実行し、baseline `(true,false)` から titleだけを `(false,false)` へ変更します。 5. browser-assisted verify を `--categories View` で実行し、2 Roadmapの `truncate titles mismatch` だけを確認します。続けて `--fixture-roadmap-title-display-render-check` を実行し、full title / hidden datesをDOMで確認します。 6. repairを挟まず `ghpmv setup --fixture-roadmap-date-display-drift ...` を実行します。この`(true,true)` transitionはtitleだけをbaselineへ戻し、dateだけをdriftします。 7. browser-assisted verify を `--categories View` で実行し、2 Roadmapの `show date fields mismatch` だけを確認します。続けて `--fixture-roadmap-date-display-render-check` を実行し、truncated title / visible datesをDOMで確認します。 -8. `ghpmv setup --fixture-field-default-drift ...` と `--fixture-field-sum-drift ...` を同じtargetに実行します。 -9. browser-assisted verify `--categories Field,View` で4件のdefault mismatch、View 1 field-sum mismatch、2件のdate mismatchだけを確認します。 -10. 7.3 の再 import を同じtargetへ一度だけ実行し、Status Updates、field defaults、Field sum、Roadmap stateを同時に復元します。 -11. browser-assisted verifyを`--categories Field,Item,View`で実行し、`Field: Match` / `View: Match`とinventory済みdraft以外のItem差分がないことを確認します。最後に`--fixture-field-default-check`を再実行して修復後defaultsを確認します。 +8. `ghpmv setup --fixture-field-default-drift ...` と `--fixture-field-sum-drift ...` を同じtargetに実行します。後者はField sumに加えて`Fixture Board`のAlpha上限を5へ変更し、Beta上限をclearしますが、itemの列やfield valueは変更しません。 +9. browser-assisted verify `--categories Field,View` で4件のdefault mismatch、View 1 field-sum mismatch、2件のBoard column-limit mismatch、2件のdate mismatchだけを確認します。 +10. 7.3 の再 import を同じtargetへ一度だけ実行し、Status Updates、field defaults、Field sum、Board column limits、Roadmap stateを同時に復元します。 +11. browser-assisted verifyを`--categories Field,Item,View`で実行し、`Field: Match` / `View: Match`、Board上限の復元、inventory済みdraft以外のItem差分がないことを確認します。最後に`--fixture-field-default-check`を再実行して修復後defaultsを確認します。 同じsnapshot / target / mappingsを再利用し、Roadmapの各controlはexactly-one-property driftと直後のDOM observationで独立に検証します。draftとProjectの削除は既存cleanup同意へまとめます。 @@ -696,12 +703,15 @@ warning / error が出た場合は、次の観点で切り分けます。 - [ ] Table view の filter / visible fields / sort / group by / field sum は browser-assisted `verify` で `View: Match`。 - [ ] Board view の Column by / Swimlanes / Slice by / field sum は browser-assisted `verify` で `View: Match`。 +- [ ] Single-select / Iteration Boardの複数列上限とunlimited列が一致し、render checkがlimit=1の列に複数の`data-board-card-id`を数えて上限超過状態を検証する。 +- [ ] targetで`Fixture Board`のAlpha上限を変更しBeta上限をclearすると、verifyが両logical columnを区別して検出する。 +- [ ] 同じsnapshot/targetへ再importすると上限が復元され、Item categoryには移動・field value変更がない。 - [ ] Roadmap view の group by / field sum / date fields / zoom / markers / Truncate titles / Show date fields は browser-assisted `verify` で `View: Match`。 - [ ] Roadmap の長い title と date-field 表示が source/target で一致し、reload 後も維持される。 - [ ] `setup --fixture-roadmap-display-drift` がtitleだけを `(false,false)` へ変更し、title mismatchとfull-title/hidden-date DOMを確認後、再 importで復元される。 - [ ] `setup --fixture-roadmap-date-display-drift` がdateだけを `(true,true)` へ変更し、date mismatchとtruncated-title/visible-date DOMを確認後、再 importで復元される。 - [ ] View 名が一致。 -- [ ] View tab order が `Fixture Roadmap` → `View 1` → `Fixture Board` → `Fixture Empty Sums` → `Fixture Roadmap Dates Hidden` で一致。 +- [ ] View tab order が `Fixture Roadmap` → `View 1` → `Fixture Board` → `Fixture Iteration Board` → `Fixture Empty Sums` → `Fixture Roadmap Dates Hidden` で一致。 - [ ] 通常幅とタブが画面幅を超える狭い幅の両方で source/target 順が一致。 - [ ] import を再実行しても既に正しい tab order は変化しない。 diff --git a/docs/MIGRATION_SCOPE.md b/docs/MIGRATION_SCOPE.md index a92040bd..0d48c476 100644 --- a/docs/MIGRATION_SCOPE.md +++ b/docs/MIGRATION_SCOPE.md @@ -62,10 +62,10 @@ View names, layouts, filters, and ordered visible fields are imported through th | Area | Supported? | Notes | |---|---:|---| | Table views | ✅ / best effort | Name, layout, filter, and visible-field order use GraphQL. Group by, the first sort key, Slice by, and grouped-view Field sum use browser enrichment. Field sums preserve Count, multiple Number fields, and an empty selection. Additional sort keys are exported but only the first is applied. | -| Board views | ✅ | Column by, Swimlanes and Field sum are tested. | +| Board views | ✅ with browser automation | Column by, Swimlanes, Field sum, and per-column soft limits are tested. Limits use the column field plus Single-select option name or Iteration title, never source node IDs. A null limit collection is left untouched; a captured empty collection clears target limits. | | Roadmap views | ✅ | Date fields, zoom level, markers, grouped-view Field sum, and the project-shared title truncation/date-field display state are tested. | | View API settings | ✅ | Name, layout, filter, and ordered visible fields are migrated without browser automation. | -| View UI-only settings | ✅ | Grouping, sorting, slicing, field sums, and Roadmap settings are exported/imported by browser automation where the UI exposes them. Roadmap checkbox failures identify the property and View. | +| View UI-only settings | ✅ | Grouping, sorting, slicing, field sums, Board column limits, and Roadmap settings are exported/imported by browser automation where the UI exposes them. Browser failures identify the property, View, and logical Board column where applicable. | | View tab order | ✅ with browser automation | The public GraphQL `POSITION` order can differ from the saved-tab order shown by GitHub. Browser-assisted export/verify read tab `href` values in DOM order, and browser-assisted import applies the minimum drag-and-drop moves after all View settings. API-only export leaves tab order uncaptured, API-only import warns when a snapshot contains it, and API-only verify marks it not verified. | | Insights charts | ❌ | Out of scope for v1. They require a separate UI automation design. | diff --git a/docs/ui-maps/projects-ui-discovery.md b/docs/ui-maps/projects-ui-discovery.md index 66f7988c..d4a318d9 100644 --- a/docs/ui-maps/projects-ui-discovery.md +++ b/docs/ui-maps/projects-ui-discovery.md @@ -73,12 +73,13 @@ GitHub Docs と public schema introspection で Text / Number / Single-select de ## フィクスチャー最終状態(gpm-source/projects/3) - Views: - - tab order=Fixture Roadmap → View 1 → Fixture Board → Fixture Empty Sums → Fixture Roadmap Dates Hidden + - tab order=Fixture Roadmap → View 1 → Fixture Board → Fixture Iteration Board → Fixture Empty Sums → Fixture Roadmap Dates Hidden - 1=View 1 (TABLE): filter=`status:Todo`, Group by=Status, Sort by=Fixture Number (asc), Slice by=Fixture Select, Field sum=[Count, Fixture Number, Fixture Number 2], visibleFields=既定 5 + Fixture Text + Fixture Date(Fixture Number はソート由来の仮想列のため visibleFields に入らない — 下記 E2E 知見 8) - 2=Fixture Board (BOARD): Column by=Fixture Select, Swimlanes=Status(GraphQL groupByFields に反映), Field sum=`Fixture Number` (Count は uncheck 済み) - 3=Fixture Roadmap (ROADMAP): Group by=Status, Field sum=Fixture Number 2, Dates=Fixture Date → Fixture Sprint end, Zoom=Quarter, Markers=[Fixture Date] - 4=Fixture Empty Sums (TABLE): Group by=Status, Field sum=[] - 5=Fixture Roadmap Dates Hidden (ROADMAP): Group by=Status, Field sum=Fixture Number 2, Truncate titles=on, Show date fields=off + - 6=Fixture Iteration Board (BOARD): Column by=Fixture Sprint, Sprint 0=1, Sprint 1=3, Sprint 2/3=unlimited - Workflows 9(GraphQL 可視分): 既定 6 enabled + Auto-add to project (#7: repo=fixture-repo, filter=`is:issue is:open`) + **Auto-add secondary**(repo=fixture-repo, filter=`is:issue label:bug`, enabled)+ **Code changes requested**(保存済み disabled, Set value=In Progress) - Field defaults: Fixture Text=`既定値 🌏`、Fixture Number=`-7`、Fixture Number 2=`0`、Fixture Select=`Beta` - fixture-repo: private, Issue #1/#2(gpm-target 側にも同名 repo あり — workflow E2E 用) @@ -136,6 +137,17 @@ Important limitations: 5. **Roadmap の親 menu には表示オプションが混在**: Truncate titles / Show date fields(表示設定)+ Markers / Field sum の子 menu。子 menu の checkbox 操作は最後に開いた menu へ scope し、親 menu の表示設定を誤操作しない。menuitem テキスト "Markers: <値>" にはマーカーだけが出る 6. **未保存 workflow のページには enable toggle が存在しない**(URL は GUID)。保存済み workflow の URL は数値 ID だが、この ID は GraphQL workflow number とは独立している。export は GraphQL の enabled 値を使い、詳細ページはサイドバーの name 一致 link で開く。toggle の accessible name も workflow 名とは限らないため、import は main detail pane 内の stateful control (`aria-pressed` / `aria-checked` / checkbox) へ fallback する 7. **未保存 disabled workflow は Edit → "Save and turn on workflow"(設定変更なしでも押せる)→ トグル off で「保存済み disabled」にできる**。未保存状態には toggle がないため、設定値が既に一致する enabled workflow も toggle を探さずこの保存経路で有効化する。保存済み disabled workflow は GraphQL の `workflows` に enabled=false で現れ、閲覧モードで設定値も読める(export 可能)。import は未保存の場合にこの save-once 経路を通す(WorkflowUiImporter.ApplyBuiltInAsync / ApplyDisabledAsync) -8. **ソートキーのフィールドは仮想列として表示される**: Fields オーバーレイで aria-checked=true になるが GraphQL `visibleFields` には永続化されない(uncheck→再 check でも変わらない)。import 側は desired 集合にソート列を含めて誤 uncheck を防止する +8. **ソートキーのフィールドは仮想列として表示できる**: hidden fieldをsortに指定するとき、importはFieldsオーバーレイで一時的にshowしてsortを適用し、sourceの`visibleFields`に含まれなければ再びhideする。仮想列をhideしてもsortは維持され、GraphQL `visibleFields`にも永続化されない。元からvisibleなsort fieldはhideしない 9. **Duplicate 直後の workflow は編集モードで開く**("Edit" ボタンが無い)→ import は Save ボタンの有無で編集モードを判定してから Edit をクリックする 10. **Playwright 1.61 の wait タイムアウトは `System.TimeoutException`**(`Microsoft.Playwright.TimeoutException` は存在せず、`PlaywrightException` の派生でもない)→ ブラウザーモジュールの catch は `exception is PlaywrightException or TimeoutException` で両方受ける(リトライ・warning 化がタイムアウトでも機能するように修正済み) + +## Board column limit UI contract (2026-08-28) + +GitHub公式手順では、Board列名の横にあるcontext menuから列上限を設定する。2026-08-31の実DOMではbuttonのaccessible nameは外部tooltipを参照する`Actions for column: <column>`、menuitemは`Set limit`。`Column limit` inputへ正整数を入力してdialog内の`Save`を押すと直ちに永続化され、View-levelの`Save view`は不要。上限削除はinputを空にして同じ`Save`を押す。 + +- 上限はsoft limitであり、item追加やautomationを禁止しない。2026-08-31のswimlane付きBoardではheaderに`<current count> / <limit>`テキストが描画されないため、render checkはdialogから上限値をread-backし、同じ`data-board-column`を持つcell内の`data-board-card-id`を数えてlimit超過を確認する。 +- 上限なしはinputが空で、snapshotではentryを作らない。Board capture成功時に全列が上限なしなら`boardColumnLimits=[]`、UIを読めなかった場合は`null`として区別する。 +- 列identityは`verticalGroupByFields`のfield名とSingle-select option名またはIteration title。source option/iteration node IDは保存しない。 +- selectorは`Sel.BoardColumn*`へ集約する。context button、dialog role/name、`data-board-column`/`data-board-card-id`は公開APIではなくGitHub UI依存であるため、変更時はBrowser E2Eで再確認する。 + +公式仕様: https://docs.github.com/en/issues/planning-and-tracking-with-projects/customizing-views-in-your-project/customizing-the-board-layout#setting-a-limit-on-the-number-of-items-in-a-column diff --git a/src/Ghpmv.Cli/Program.cs b/src/Ghpmv.Cli/Program.cs index 855229dc..9f0aebc2 100644 --- a/src/Ghpmv.Cli/Program.cs +++ b/src/Ghpmv.Cli/Program.cs @@ -1143,7 +1143,7 @@ await ProjectTemplateWriteSession.SetFinalStateAsync( }; var fixtureFieldSumDriftOption = new Option<bool>("--fixture-field-sum-drift") { - Description = "Apply the standard View 1 field-sum drift to an existing fixture Project using browser automation.", + Description = "Apply the standard field-sum and Board column-limit drift to an existing fixture Project using browser automation.", }; var fixtureRoadmapDisplayDriftOption = new Option<bool>("--fixture-roadmap-display-drift") { @@ -1448,7 +1448,13 @@ await observer.DeleteDraftAsync( new { login = org, number = projectNumber }, cancellationToken); var expectedNames = new HashSet<string>( - ["View 1", "Fixture Roadmap", "Fixture Roadmap Dates Hidden"], + [ + "View 1", + "Fixture Board", + "Fixture Iteration Board", + "Fixture Roadmap", + "Fixture Roadmap Dates Hidden", + ], StringComparer.Ordinal); var viewNumbers = projectData .GetProperty("organization") @@ -1494,8 +1500,18 @@ await observer.ValidateFixtureAsync( projectNumber, viewNumbers, cancellationToken); + await new BoardColumnLimitObserver(browserSession) + { + OnProgress = Console.Error.WriteLine, + }.ValidateFixtureAsync( + expected, + org, + ProjectOwnerType.Organization, + projectNumber, + viewNumbers, + cancellationToken); Console.Error.WriteLine( - $"Fixture field-sum rendering verified: project=#{projectNumber} views={viewNumbers.Count}"); + $"Fixture field-sum and Board-limit rendering verified: project=#{projectNumber} views={viewNumbers.Count}"); return 0; } catch (Exception exception) when (exception is PlaywrightException or InvalidOperationException or IOException or TimeoutException or GitHubGraphQLException or ArgumentException or FormatException) @@ -1646,6 +1662,8 @@ await importer.ImportAsync( parseResult.GetValue(fixtureRepoOption) ?? "fixture-repo"); var view = snapshot.Views.Single(candidate => string.Equals(candidate.Name, "View 1", StringComparison.Ordinal)); + var board = snapshot.Views.Single(candidate => + string.Equals(candidate.Name, "Fixture Board", StringComparison.Ordinal)); var projectData = await client.QueryAsync( """ query($login: String!, $number: Int!) { @@ -1686,6 +1704,32 @@ await viewImporter.ApplyFieldSumAsync( view.Name, view.Ui!.FieldSum!, cancellationToken); + var targetBoardViews = projectData + .GetProperty("organization") + .GetProperty("projectV2") + .GetProperty("views") + .GetProperty("nodes") + .EnumerateArray() + .Where(node => string.Equals( + node.GetProperty("name").GetString(), + board.Name, + StringComparison.Ordinal)) + .ToArray(); + if (targetBoardViews.Length != 1) + { + Console.Error.WriteLine( + $"error: expected exactly one target View named '{board.Name}', found {targetBoardViews.Length}."); + return 1; + } + + await viewImporter.ApplyBoardColumnLimitsAsync( + org, + ProjectOwnerType.Organization, + projectNumber, + targetBoardViews[0].GetProperty("number").GetInt32(), + board, + snapshot.Fields, + cancellationToken); foreach (var warning in viewImporter.Warnings) { Console.Error.WriteLine($"warning: {warning}"); @@ -1693,7 +1737,7 @@ await viewImporter.ApplyFieldSumAsync( Console.Error.WriteLine(string.Create( CultureInfo.InvariantCulture, - $"Fixture field-sum drift applied: project=#{projectNumber} viewWarnings={viewImporter.Warnings.Count}")); + $"Fixture field-sum and Board-limit drift applied: project=#{projectNumber} viewWarnings={viewImporter.Warnings.Count}")); return viewImporter.Warnings.Count == 0 ? 0 : 1; } catch (Exception exception) when (exception is PlaywrightException or InvalidOperationException or IOException or TimeoutException or GitHubGraphQLException or ArgumentException or FormatException) diff --git a/src/Ghpmv.Core/Browser/BoardColumnLimitObserver.cs b/src/Ghpmv.Core/Browser/BoardColumnLimitObserver.cs new file mode 100644 index 00000000..cc536181 --- /dev/null +++ b/src/Ghpmv.Core/Browser/BoardColumnLimitObserver.cs @@ -0,0 +1,134 @@ +using System.Globalization; +using Ghpmv.Core.GitHub; +using Ghpmv.Core.Snapshot; +using Microsoft.Playwright; + +namespace Ghpmv.Core.Browser; + +/// <summary>Validates configured, unlimited, and exceeded limits in the standard Board fixture.</summary> +public sealed class BoardColumnLimitObserver +{ + private readonly BrowserSession _session; + + public BoardColumnLimitObserver(BrowserSession session) + { + ArgumentNullException.ThrowIfNull(session); + _session = session; + } + + public Action<string>? OnProgress { get; set; } + + public async Task ValidateFixtureAsync( + ProjectSnapshot expected, + string ownerLogin, + ProjectOwnerType ownerType, + int projectNumber, + IReadOnlyDictionary<string, int> viewNumbers, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(expected); + ArgumentException.ThrowIfNullOrWhiteSpace(ownerLogin); + ArgumentNullException.ThrowIfNull(viewNumbers); + + var boards = expected.Views + .Where(view => view.Ui?.BoardColumnLimits is not null) + .ToArray(); + var page = await _session.GetPageAsync(cancellationToken).ConfigureAwait(false); + foreach (var board in boards) + { + if (!viewNumbers.TryGetValue(board.Name, out var viewNumber)) + { + throw new InvalidOperationException($"Expected exactly one target View named '{board.Name}'."); + } + + var url = BrowserProjectUrl.Build( + _session.BaseUrl, + ownerLogin, + ownerType, + projectNumber, + string.Create(CultureInfo.InvariantCulture, $"views/{viewNumber}")); + await _session.GotoAsync(url, cancellationToken).ConfigureAwait(false); + await page.ReloadAsync(new() { WaitUntil = WaitUntilState.DOMContentLoaded }).ConfigureAwait(false); + + var actual = await BoardColumnLimitUi.ReadAsync( + page, + board, + expected.Fields, + cancellationToken).ConfigureAwait(false); + ValidateLimits(board, actual); + var columnField = expected.Fields.Single(field => + string.Equals(field.Name, AssertSingleColumnField(board), StringComparison.Ordinal)); + var configuredNames = board.Ui!.BoardColumnLimits! + .Select(limit => limit.SingleSelectOptionName ?? limit.IterationTitle!) + .ToHashSet(StringComparer.Ordinal); + var unlimitedNames = GetValueNames(columnField) + .Where(name => !configuredNames.Contains(name)) + .ToArray(); + if (unlimitedNames.Length == 0) + { + throw new InvalidOperationException( + $"view '{board.Name}': fixture does not define an unlimited Board column"); + } + + foreach (var unlimitedName in unlimitedNames) + { + if (await Sel.BoardColumnActionsButton(page, unlimitedName).CountAsync().ConfigureAwait(false) == 0) + { + throw new InvalidOperationException( + $"view '{board.Name}': unlimited column '{unlimitedName}' is not displayed"); + } + } + + foreach (var limit in board.Ui.BoardColumnLimits!.Where(limit => limit.Limit == 1)) + { + var columnName = limit.SingleSelectOptionName ?? limit.IterationTitle!; + var count = await Sel.BoardColumnCards(page, columnName).CountAsync().ConfigureAwait(false); + if (count <= limit.Limit) + { + throw new InvalidOperationException( + $"view '{board.Name}': column '{columnName}' did not render more cards ({count}) than its limit ({limit.Limit})"); + } + } + + OnProgress?.Invoke( + $"Rendered Board limits verified for view '{board.Name}': configured={actual.Count}"); + } + } + + internal static void ValidateLimits( + ViewSnapshot expected, + IReadOnlyList<BoardColumnLimitSnapshot> actual) + { + var desired = expected.Ui?.BoardColumnLimits + ?? throw new InvalidOperationException($"view '{expected.Name}': expected Board limit state is unavailable"); + if (desired.Count != actual.Count + || desired.Any(limit => !actual.Any(candidate => + SameColumn(limit, candidate) && limit.Limit == candidate.Limit))) + { + throw new InvalidOperationException($"view '{expected.Name}': rendered Board column limits do not match the fixture"); + } + } + + private static bool SameColumn(BoardColumnLimitSnapshot first, BoardColumnLimitSnapshot second) + => string.Equals(first.FieldName, second.FieldName, StringComparison.Ordinal) + && string.Equals(first.SingleSelectOptionName, second.SingleSelectOptionName, StringComparison.Ordinal) + && string.Equals(first.IterationTitle, second.IterationTitle, StringComparison.Ordinal); + + private static string AssertSingleColumnField(ViewSnapshot view) + => view.VerticalGroupByFields.Count == 1 + ? view.VerticalGroupByFields[0] + : throw new InvalidOperationException( + $"view '{view.Name}': expected exactly one Board column field"); + + private static IEnumerable<string> GetValueNames(FieldSnapshot field) + => field.DataType switch + { + "SINGLE_SELECT" => field.Options?.Select(option => option.Name) ?? [], + "ITERATION" when field.IterationConfiguration is { } configuration => + configuration.Iterations.Concat(configuration.CompletedIterations) + .Select(iteration => iteration.Title), + _ => throw new InvalidOperationException( + $"field '{field.Name}' has unsupported Board column type '{field.DataType}'"), + }; + +} diff --git a/src/Ghpmv.Core/Browser/BoardColumnLimitUi.cs b/src/Ghpmv.Core/Browser/BoardColumnLimitUi.cs new file mode 100644 index 00000000..a71a2d45 --- /dev/null +++ b/src/Ghpmv.Core/Browser/BoardColumnLimitUi.cs @@ -0,0 +1,388 @@ +using System.Globalization; +using Ghpmv.Core.Snapshot; +using Microsoft.Playwright; + +namespace Ghpmv.Core.Browser; + +internal static class BoardColumnLimitUi +{ + public static async Task<IReadOnlyList<BoardColumnLimitSnapshot>> ReadAsync( + IPage page, + ViewSnapshot view, + IReadOnlyList<FieldSnapshot> fields, + CancellationToken cancellationToken) + { + var field = ResolveColumnField(view, fields); + var limits = new List<BoardColumnLimitSnapshot>(); + var columns = await ReadDisplayedColumnsAsync(page, cancellationToken).ConfigureAwait(false); + if (columns.Count == 0) + { + throw new InvalidOperationException($"view '{view.Name}': no displayed Board columns were found"); + } + + var missingColumns = FindMissingLogicalColumns( + field, + columns.Select(column => column.Name).ToArray()); + if (missingColumns.Count > 0) + { + throw new InvalidOperationException( + $"view '{view.Name}': Board column limit capture is unavailable because logical columns are hidden or missing: [{string.Join(", ", missingColumns)}]"); + } + + foreach (var column in columns) + { + var currentLimit = await ReadLimitAsync(page, column.Name, cancellationToken).ConfigureAwait(false); + if (currentLimit is null) + { + continue; + } + + limits.Add(CreateSnapshot(field, column.Name, currentLimit.Value, view.Name)); + } + + return limits; + } + + public static async Task<IReadOnlyList<string>> ApplyAsync( + IPage page, + ViewSnapshot view, + IReadOnlyList<FieldSnapshot> fields, + IReadOnlyList<BoardColumnLimitSnapshot> desiredLimits, + CancellationToken cancellationToken) + { + var field = ResolveColumnField(view, fields); + var columns = await ReadDisplayedColumnsAsync(page, cancellationToken).ConfigureAwait(false); + var plan = BuildReconciliationPlan( + view, + field, + desiredLimits, + columns.Select(column => column.Name).ToArray()); + if (plan.Warnings.Count > 0) + { + return plan.Warnings; + } + + foreach (var target in plan.Targets) + { + var current = await ReadLimitAsync(page, target.ColumnName, cancellationToken).ConfigureAwait(false); + if (current == target.Limit) + { + continue; + } + + await WriteLimitAsync(page, target.ColumnName, target.Limit, cancellationToken).ConfigureAwait(false); + } + + return plan.Warnings; + } + + internal static ReconciliationPlan BuildReconciliationPlan( + ViewSnapshot view, + FieldSnapshot field, + IReadOnlyList<BoardColumnLimitSnapshot> desiredLimits, + IReadOnlyList<string> displayedColumnNames) + { + var warnings = new List<string>(); + var desiredByName = new Dictionary<string, BoardColumnLimitSnapshot>(StringComparer.Ordinal); + foreach (var limit in desiredLimits) + { + if (limit.Limit <= 0) + { + warnings.Add(string.Create( + CultureInfo.InvariantCulture, + $"view '{view.Name}': Board column limit for {ViewUiImporter.DescribeColumn(limit)} must be positive, found {limit.Limit}")); + continue; + } + + if (!string.Equals(limit.FieldName, field.Name, StringComparison.Ordinal)) + { + warnings.Add( + $"view '{view.Name}': Board column limit for {ViewUiImporter.DescribeColumn(limit)} does not use column-by field '{field.Name}'"); + continue; + } + + var identityCount = (limit.SingleSelectOptionName is null ? 0 : 1) + + (limit.IterationTitle is null ? 0 : 1); + if (identityCount != 1) + { + warnings.Add( + $"view '{view.Name}': Board column limit for field '{field.Name}' must identify exactly one Single-select option or Iteration"); + continue; + } + + var valueName = GetValueName(limit); + var identityMatchesField = field.DataType switch + { + "SINGLE_SELECT" => limit.SingleSelectOptionName is not null, + "ITERATION" => limit.IterationTitle is not null, + _ => false, + }; + if (!identityMatchesField || !ValueExists(field, valueName)) + { + warnings.Add( + $"view '{view.Name}': Board column limit for {ViewUiImporter.DescribeColumn(limit)} is not a valid value of {field.DataType} field '{field.Name}'"); + continue; + } + + if (!desiredByName.TryAdd(valueName, limit)) + { + warnings.Add( + $"view '{view.Name}': duplicate Board column limit for {ViewUiImporter.DescribeColumn(limit)}"); + } + } + + if (warnings.Count > 0) + { + return new ReconciliationPlan([], warnings); + } + + foreach (var missingColumn in FindMissingLogicalColumns(field, displayedColumnNames)) + { + if (desiredByName.TryGetValue(missingColumn, out var desired)) + { + warnings.Add( + $"view '{view.Name}': target {ViewUiImporter.DescribeColumn(desired)} was not found; no Board limits were changed"); + } + else + { + warnings.Add( + $"view '{view.Name}': target {field.DataType} column '{field.Name}' / '{missingColumn}' was hidden or missing; no Board limits were changed"); + } + } + + if (warnings.Count > 0) + { + return new ReconciliationPlan([], warnings); + } + + var targets = displayedColumnNames + .Where(columnName => ValueExists(field, columnName)) + .Select(columnName => new ReconciliationTarget( + columnName, + desiredByName.TryGetValue(columnName, out var configured) + ? configured.Limit + : null)) + .ToArray(); + return new ReconciliationPlan(targets, warnings); + } + + internal static IReadOnlyList<string> FindMissingLogicalColumns( + FieldSnapshot field, + IReadOnlyList<string> displayedColumnNames) + { + ArgumentNullException.ThrowIfNull(field); + ArgumentNullException.ThrowIfNull(displayedColumnNames); + var displayed = displayedColumnNames.ToHashSet(StringComparer.Ordinal); + return GetValueNames(field) + .Where(value => !displayed.Contains(value)) + .ToArray(); + } + + internal static int? ParseLimit(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + if (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var limit) + || limit <= 0) + { + throw new InvalidOperationException($"Invalid Board column limit '{value}'"); + } + + return limit; + } + + internal static bool CanCapture( + ViewSnapshot view, + IReadOnlyList<FieldSnapshot> fields, + out string reason) + { + ArgumentNullException.ThrowIfNull(view); + ArgumentNullException.ThrowIfNull(fields); + if (!string.Equals(view.Layout, "BOARD_LAYOUT", StringComparison.Ordinal)) + { + reason = $"layout '{view.Layout}' is not a Board"; + return false; + } + + if (view.VerticalGroupByFields.Count != 1) + { + reason = $"expected exactly one column-by field, found {view.VerticalGroupByFields.Count}"; + return false; + } + + var fieldName = view.VerticalGroupByFields[0]; + var matches = fields.Where(field => + string.Equals(field.Name, fieldName, StringComparison.Ordinal)).ToArray(); + if (matches.Length != 1) + { + reason = $"column-by field '{fieldName}' does not uniquely exist in the snapshot"; + return false; + } + + if (matches[0].DataType is not ("SINGLE_SELECT" or "ITERATION")) + { + reason = $"column-by field '{fieldName}' has unsupported type '{matches[0].DataType}'"; + return false; + } + + reason = string.Empty; + return true; + } + + private static async Task<IReadOnlyList<DisplayedColumn>> ReadDisplayedColumnsAsync( + IPage page, + CancellationToken cancellationToken) + { + var buttons = Sel.BoardColumnActionsButtons(page); + await buttons.First.WaitForAsync(new() + { + State = WaitForSelectorState.Visible, + Timeout = 15_000, + }).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + var columns = new List<DisplayedColumn>(); + var count = await buttons.CountAsync().ConfigureAwait(false); + for (var index = 0; index < count; index++) + { + var button = buttons.Nth(index); + var columnName = ViewUiExporter.NormalizeUiText( + await Sel.BoardColumnHeading(button).InnerTextAsync().ConfigureAwait(false)); + if (columnName is null) + { + throw new InvalidOperationException("Board column heading has no readable logical value"); + } + + if (columns.Any(column => string.Equals(column.Name, columnName, StringComparison.Ordinal))) + { + throw new InvalidOperationException($"Board contains more than one displayed column named '{columnName}'"); + } + + columns.Add(new DisplayedColumn(columnName)); + } + + return columns; + } + + private static async Task<int?> ReadLimitAsync( + IPage page, + string columnName, + CancellationToken cancellationToken) + { + await Sel.BoardColumnActionsButton(page, columnName).ClickAsync().ConfigureAwait(false); + await PauseAsync(cancellationToken).ConfigureAwait(false); + var item = Sel.BoardColumnLimitMenuItem(page); + await item.WaitForAsync().ConfigureAwait(false); + await item.ClickAsync().ConfigureAwait(false); + await PauseAsync(cancellationToken).ConfigureAwait(false); + var input = Sel.BoardColumnLimitInput(page); + await input.WaitForAsync().ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + var value = await input.InputValueAsync().ConfigureAwait(false); + await page.Keyboard.PressAsync("Escape").ConfigureAwait(false); + await PauseAsync(cancellationToken).ConfigureAwait(false); + try + { + return ParseLimit(value); + } + catch (InvalidOperationException exception) + { + throw new InvalidOperationException( + $"Board column '{columnName}' has invalid configured limit '{value}'", + exception); + } + } + + private static async Task WriteLimitAsync( + IPage page, + string columnName, + int? limit, + CancellationToken cancellationToken) + { + await Sel.BoardColumnActionsButton(page, columnName).ClickAsync().ConfigureAwait(false); + await PauseAsync(cancellationToken).ConfigureAwait(false); + var item = Sel.BoardColumnLimitMenuItem(page); + await item.WaitForAsync().ConfigureAwait(false); + await item.ClickAsync().ConfigureAwait(false); + await PauseAsync(cancellationToken).ConfigureAwait(false); + var input = Sel.BoardColumnLimitInput(page); + await input.WaitForAsync().ConfigureAwait(false); + var overlay = Sel.BoardColumnLimitOverlay(input); + await input.FillAsync(limit?.ToString(CultureInfo.InvariantCulture) ?? string.Empty).ConfigureAwait(false); + await PauseAsync(cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + await Sel.BoardColumnLimitSaveButton(overlay).ClickAsync().ConfigureAwait(false); + await input.WaitForAsync(new() { State = WaitForSelectorState.Hidden }).ConfigureAwait(false); + await PauseAsync(cancellationToken).ConfigureAwait(false); + } + + // 300ms between consecutive UI operations (BROWSER_AUTOMATION_PLAN §1.4). + private static Task PauseAsync(CancellationToken cancellationToken) => Task.Delay(300, cancellationToken); + + private static FieldSnapshot ResolveColumnField(ViewSnapshot view, IReadOnlyList<FieldSnapshot> fields) + { + if (!CanCapture(view, fields, out var reason)) + { + throw new InvalidOperationException($"view '{view.Name}': Board column limits cannot be captured — {reason}"); + } + + var fieldName = view.VerticalGroupByFields[0]; + return fields.Single(field => string.Equals(field.Name, fieldName, StringComparison.Ordinal)); + } + + private static BoardColumnLimitSnapshot CreateSnapshot( + FieldSnapshot field, + string columnName, + int limit, + string viewName) + { + if (!ValueExists(field, columnName)) + { + throw new InvalidOperationException( + $"view '{viewName}': limited column '{columnName}' does not exist in {field.DataType} field '{field.Name}'"); + } + + return new BoardColumnLimitSnapshot + { + FieldName = field.Name, + SingleSelectOptionName = string.Equals(field.DataType, "SINGLE_SELECT", StringComparison.Ordinal) + ? columnName + : null, + IterationTitle = string.Equals(field.DataType, "ITERATION", StringComparison.Ordinal) + ? columnName + : null, + Limit = limit, + }; + } + + private static bool ValueExists(FieldSnapshot field, string value) + => GetValueNames(field).Contains(value, StringComparer.Ordinal); + + private static IEnumerable<string> GetValueNames(FieldSnapshot field) + => field.DataType switch + { + "SINGLE_SELECT" => field.Options?.Select(option => option.Name) ?? [], + "ITERATION" => field.IterationConfiguration is { } configuration + ? configuration.Iterations.Concat(configuration.CompletedIterations) + .Select(iteration => iteration.Title) + : [], + _ => [], + }; + + private static string GetValueName(BoardColumnLimitSnapshot limit) + => limit.SingleSelectOptionName + ?? limit.IterationTitle + ?? throw new InvalidOperationException( + $"Board column limit for field '{limit.FieldName}' has no logical value identity"); + + private sealed record DisplayedColumn(string Name); + + internal sealed record ReconciliationPlan( + IReadOnlyList<ReconciliationTarget> Targets, + IReadOnlyList<string> Warnings); + + internal sealed record ReconciliationTarget(string ColumnName, int? Limit); +} diff --git a/src/Ghpmv.Core/Browser/FixtureUiSnapshotFactory.cs b/src/Ghpmv.Core/Browser/FixtureUiSnapshotFactory.cs index a3ca9efc..6071140d 100644 --- a/src/Ghpmv.Core/Browser/FixtureUiSnapshotFactory.cs +++ b/src/Ghpmv.Core/Browser/FixtureUiSnapshotFactory.cs @@ -38,18 +38,37 @@ public static ProjectSnapshot Create(string repositoryName = "fixture-repo") } /// <summary> - /// Creates the standard fixture UI snapshot with the deliberate field-sum drift - /// used by the browser E2E negative test. + /// Creates the standard fixture UI snapshot with deliberate field-sum and Board + /// column-limit drift used by the browser E2E negative test. /// </summary> public static ProjectSnapshot CreateFieldSumDrift(string repositoryName = "fixture-repo") { var snapshot = Create(repositoryName); return snapshot with { - Views = snapshot.Views.Select(view => - string.Equals(view.Name, "View 1", StringComparison.Ordinal) - ? view with { Ui = view.Ui! with { FieldSum = ["Count", "Fixture Number"] } } - : view).ToList(), + Views = snapshot.Views.Select(view => view.Name switch + { + "View 1" => view with + { + Ui = view.Ui! with { FieldSum = ["Count", "Fixture Number"] }, + }, + "Fixture Board" => view with + { + Ui = view.Ui! with + { + BoardColumnLimits = + [ + new BoardColumnLimitSnapshot + { + FieldName = "Fixture Select", + SingleSelectOptionName = "Alpha", + Limit = 5, + }, + ], + }, + }, + _ => view, + }).ToList(), }; } @@ -154,7 +173,50 @@ private static IReadOnlyList<FieldSnapshot> CreateFields() => ], DefaultValue = new FieldDefaultValueSnapshot { SingleSelectOptionName = "Beta" }, }, - new FieldSnapshot { Name = "Fixture Sprint", DataType = "ITERATION" }, + new FieldSnapshot + { + Name = "Fixture Sprint", + DataType = "ITERATION", + IterationConfiguration = new IterationConfigurationSnapshot + { + Duration = 14, + StartDay = 1, + CompletedIterations = + [ + new IterationSnapshot + { + Id = "sprint-0", + Title = "Sprint 0", + StartDate = "2026-01-05", + Duration = 14, + }, + ], + Iterations = + [ + new IterationSnapshot + { + Id = "sprint-1", + Title = "Sprint 1", + StartDate = "2026-01-19", + Duration = 14, + }, + new IterationSnapshot + { + Id = "sprint-2", + Title = "Sprint 2", + StartDate = "2026-02-02", + Duration = 14, + }, + new IterationSnapshot + { + Id = "sprint-3", + Title = "Sprint 3", + StartDate = "2026-02-16", + Duration = 14, + }, + ], + }, + }, new FieldSnapshot { Name = "Fixture Teams", @@ -200,6 +262,21 @@ private static IReadOnlyList<ViewSnapshot> CreateViews() => Ui = new ViewUiSnapshot { FieldSum = ["Fixture Number"], + BoardColumnLimits = + [ + new BoardColumnLimitSnapshot + { + FieldName = "Fixture Select", + SingleSelectOptionName = "Alpha", + Limit = 1, + }, + new BoardColumnLimitSnapshot + { + FieldName = "Fixture Select", + SingleSelectOptionName = "Beta", + Limit = 2, + }, + ], }, }, new ViewSnapshot @@ -230,7 +307,7 @@ private static IReadOnlyList<ViewSnapshot> CreateViews() => new ViewSnapshot { Number = 4, - TabPosition = 3, + TabPosition = 4, Name = "Fixture Empty Sums", Layout = "TABLE_LAYOUT", Filter = null, @@ -246,7 +323,7 @@ private static IReadOnlyList<ViewSnapshot> CreateViews() => new ViewSnapshot { Number = 5, - TabPosition = 4, + TabPosition = 5, Name = "Fixture Roadmap Dates Hidden", Layout = "ROADMAP_LAYOUT", Filter = null, @@ -268,6 +345,37 @@ private static IReadOnlyList<ViewSnapshot> CreateViews() => }, }, }, + new ViewSnapshot + { + Number = 6, + TabPosition = 3, + Name = "Fixture Iteration Board", + Layout = "BOARD_LAYOUT", + Filter = null, + GroupByFields = [], + SortByFields = [], + VerticalGroupByFields = ["Fixture Sprint"], + VisibleFields = [], + Ui = new ViewUiSnapshot + { + FieldSum = [], + BoardColumnLimits = + [ + new BoardColumnLimitSnapshot + { + FieldName = "Fixture Sprint", + IterationTitle = "Sprint 0", + Limit = 1, + }, + new BoardColumnLimitSnapshot + { + FieldName = "Fixture Sprint", + IterationTitle = "Sprint 1", + Limit = 3, + }, + ], + }, + }, ]; private static IReadOnlyList<WorkflowSnapshot> CreateWorkflows(string repositoryName) => diff --git a/src/Ghpmv.Core/Browser/Sel.cs b/src/Ghpmv.Core/Browser/Sel.cs index 8098377c..043f51e8 100644 --- a/src/Ghpmv.Core/Browser/Sel.cs +++ b/src/Ghpmv.Core/Browser/Sel.cs @@ -20,6 +20,10 @@ internal static class Sel // Filter-bar "View" button. D0: once a setting is changed the accessible name // becomes "Unsaved changes View", so an exact "View" match only works before edits. private static readonly Regex ViewMenuButtonName = new("^(Unsaved changes )?View$"); + private static readonly Regex BoardColumnLimitControlName = new( + "column limit", + RegexOptions.IgnoreCase); + private static readonly Regex BoardColumnActionsButtonName = new("^Actions for column: "); /// <summary>Filter-bar "View" button that opens the view configuration menu.</summary> public static ILocator ViewMenuButton(IPage page) @@ -71,6 +75,54 @@ public static ILocator SaveViewButton(IPage page) public static ILocator UnsavedChangesStatus(IPage page) => page.GetByRole(AriaRole.Status, new() { Name = "Unsaved changes", Exact = true }).Last; + /// <summary>Actions buttons for the currently displayed Board columns.</summary> + public static ILocator BoardColumnActionsButtons(IPage page) + => page.GetByRole(AriaRole.Button, new() { NameRegex = BoardColumnActionsButtonName }); + + /// <summary>The actions button for one displayed Board column.</summary> + public static ILocator BoardColumnActionsButton(IPage page, string columnName) + => page.GetByRole( + AriaRole.Button, + new() { Name = $"Actions for column: {columnName}", Exact = true }).First; + + /// <summary>"Set limit" in an open Board column menu.</summary> + public static ILocator BoardColumnLimitMenuItem(IPage page) + => page.GetByRole(AriaRole.Menuitem, new() { Name = "Set limit", Exact = true }).Last; + + /// <summary>Numeric input used to set, change, or clear a Board column limit.</summary> + public static ILocator BoardColumnLimitInput(IPage page) + => page.GetByRole(AriaRole.Spinbutton, new() { NameRegex = BoardColumnLimitControlName }) + .Or(page.GetByRole(AriaRole.Textbox, new() { NameRegex = BoardColumnLimitControlName })) + .First; + + /// <summary>The closest column-limit overlay containing the numeric input and Save button.</summary> + public static ILocator BoardColumnLimitOverlay(ILocator input) + => input.Locator("xpath=ancestor::*[@role='dialog'][1]"); + + /// <summary>Save button within the Board column-limit overlay.</summary> + public static ILocator BoardColumnLimitSaveButton(ILocator overlay) + => overlay.GetByRole( + AriaRole.Button, + new() { NameRegex = new Regex("^Save") }); + + /// <summary>Rendered Board column containing the supplied actions button.</summary> + public static ILocator BoardColumn(ILocator actionsButton) + => actionsButton.Locator( + "xpath=ancestor::*[@data-board-column][1]"); + + /// <summary>The option or iteration title heading for a displayed Board column.</summary> + public static ILocator BoardColumnHeading(ILocator actionsButton) + => BoardColumn(actionsButton).GetByRole(AriaRole.Heading).First; + + /// <summary>Rendered cards in every Board cell for the named logical column.</summary> + public static ILocator BoardColumnCards(IPage page, string columnName) + { + var escapedName = columnName + .Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("\"", "\\\"", StringComparison.Ordinal); + return page.Locator($"[data-board-column=\"{escapedName}\"] [data-board-card-id]"); + } + /// <summary>Visible grouped Table/Roadmap header contents containing count and aggregate labels.</summary> public static ILocator GroupHeaderContents(IPage page) => page.Locator("[class*='group-header-module__groupHeaderContent']:visible"); diff --git a/src/Ghpmv.Core/Browser/ViewUiExporter.cs b/src/Ghpmv.Core/Browser/ViewUiExporter.cs index ea6434e8..d787be00 100644 --- a/src/Ghpmv.Core/Browser/ViewUiExporter.cs +++ b/src/Ghpmv.Core/Browser/ViewUiExporter.cs @@ -73,7 +73,14 @@ public async Task<ProjectSnapshot> EnrichAsync( ViewUiSnapshot? ui = null; try { - ui = await ReadViewUiAsync(page, ownerLogin, ownerType, projectNumber, view, cancellationToken).ConfigureAwait(false); + ui = await ReadViewUiAsync( + page, + ownerLogin, + ownerType, + projectNumber, + view, + snapshot.Fields, + cancellationToken).ConfigureAwait(false); } catch (Exception exception) when (exception is PlaywrightException or TimeoutException or InvalidOperationException) { @@ -106,6 +113,7 @@ private async Task<ViewUiSnapshot> ReadViewUiAsync( ProjectOwnerType ownerType, int projectNumber, ViewSnapshot view, + IReadOnlyList<FieldSnapshot> fields, CancellationToken cancellationToken) { var url = BrowserProjectUrl.Build( @@ -155,11 +163,37 @@ private async Task<ViewUiSnapshot> ReadViewUiAsync( } await page.Keyboard.PressAsync("Escape").ConfigureAwait(false); + IReadOnlyList<BoardColumnLimitSnapshot>? boardColumnLimits = null; + if (string.Equals(view.Layout, "BOARD_LAYOUT", StringComparison.Ordinal)) + { + if (BoardColumnLimitUi.CanCapture(view, fields, out var reason)) + { + try + { + boardColumnLimits = await BoardColumnLimitUi.ReadAsync( + page, + view, + fields, + cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (exception is PlaywrightException or TimeoutException or InvalidOperationException) + { + _warnings.Add( + $"view '{view.Name}': Board column limits were not captured — {exception.Message}"); + } + } + else + { + _warnings.Add( + $"view '{view.Name}': Board column limits were not captured — {reason}"); + } + } return new ViewUiSnapshot { SliceBy = sliceBy, FieldSum = fieldSum, + BoardColumnLimits = boardColumnLimits, Roadmap = roadmap, ScrapedAt = DateTimeOffset.UtcNow, }; diff --git a/src/Ghpmv.Core/Browser/ViewUiImporter.cs b/src/Ghpmv.Core/Browser/ViewUiImporter.cs index de5d1002..449d2e57 100644 --- a/src/Ghpmv.Core/Browser/ViewUiImporter.cs +++ b/src/Ghpmv.Core/Browser/ViewUiImporter.cs @@ -48,6 +48,9 @@ public static IReadOnlyList<string> CollectPreflightWarnings(ProjectSnapshot sna var warnings = new List<string>(); var fieldNames = new HashSet<string>(snapshot.Fields.Select(f => f.Name), StringComparer.Ordinal); + var fieldsByName = snapshot.Fields + .GroupBy(field => field.Name, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.ToList(), StringComparer.Ordinal); foreach (var view in snapshot.Views) { foreach (var field in view.GroupByFields) @@ -85,6 +88,8 @@ public static IReadOnlyList<string> CollectPreflightWarnings(ProjectSnapshot sna } } + CollectBoardColumnLimitWarnings(view, fieldsByName, warnings); + if (view.Ui?.Roadmap is { } roadmap) { if (roadmap.StartField is { } startField && !RoadmapFieldExists(fieldNames, startField)) @@ -102,6 +107,117 @@ public static IReadOnlyList<string> CollectPreflightWarnings(ProjectSnapshot sna return warnings; } + private static void CollectBoardColumnLimitWarnings( + ViewSnapshot view, + Dictionary<string, List<FieldSnapshot>> fieldsByName, + List<string> warnings) + { + if (view.Ui?.BoardColumnLimits is not { } limits) + { + return; + } + + if (!string.Equals(view.Layout, "BOARD_LAYOUT", StringComparison.Ordinal)) + { + warnings.Add($"view '{view.Name}': Board column limits were captured for non-Board layout '{view.Layout}'"); + return; + } + + if (view.VerticalGroupByFields.Count != 1) + { + warnings.Add(string.Create( + CultureInfo.InvariantCulture, + $"view '{view.Name}': Board column limits require exactly one column-by field, found {view.VerticalGroupByFields.Count}")); + return; + } + + var columnFieldName = view.VerticalGroupByFields[0]; + var identities = new HashSet<string>(StringComparer.Ordinal); + foreach (var limit in limits) + { + var identityCount = (limit.SingleSelectOptionName is null ? 0 : 1) + + (limit.IterationTitle is null ? 0 : 1); + if (identityCount != 1) + { + warnings.Add( + $"view '{view.Name}': Board column limit for field '{limit.FieldName}' must identify exactly one Single-select option or Iteration"); + continue; + } + + if (limit.Limit <= 0) + { + warnings.Add(string.Create( + CultureInfo.InvariantCulture, + $"view '{view.Name}': Board column limit for {DescribeColumn(limit)} must be positive, found {limit.Limit}")); + } + + if (!string.Equals(limit.FieldName, columnFieldName, StringComparison.Ordinal)) + { + warnings.Add( + $"view '{view.Name}': Board column limit for {DescribeColumn(limit)} does not use column-by field '{columnFieldName}'"); + continue; + } + + if (!fieldsByName.TryGetValue(limit.FieldName, out var matchingFields) + || matchingFields.Count != 1) + { + warnings.Add( + $"view '{view.Name}': Board column limit field '{limit.FieldName}' does not uniquely exist in the snapshot"); + continue; + } + + var field = matchingFields[0]; + string identity; + if (limit.SingleSelectOptionName is { } optionName) + { + identity = $"single-select:{limit.FieldName}:{optionName}"; + if (!string.Equals(field.DataType, "SINGLE_SELECT", StringComparison.Ordinal)) + { + warnings.Add( + $"view '{view.Name}': Board column limit for {DescribeColumn(limit)} references field type '{field.DataType}', not SINGLE_SELECT"); + } + else if (field.Options is not null + && !field.Options.Any(option => string.Equals(option.Name, optionName, StringComparison.Ordinal))) + { + warnings.Add( + $"view '{view.Name}': Board column limit option '{optionName}' does not exist in field '{limit.FieldName}'"); + } + } + else + { + var iterationTitle = limit.IterationTitle!; + identity = $"iteration:{limit.FieldName}:{iterationTitle}"; + if (!string.Equals(field.DataType, "ITERATION", StringComparison.Ordinal)) + { + warnings.Add( + $"view '{view.Name}': Board column limit for {DescribeColumn(limit)} references field type '{field.DataType}', not ITERATION"); + } + else if (field.IterationConfiguration is { } configuration + && !configuration.Iterations.Concat(configuration.CompletedIterations) + .Any(iteration => string.Equals(iteration.Title, iterationTitle, StringComparison.Ordinal))) + { + warnings.Add( + $"view '{view.Name}': Board column limit iteration '{iterationTitle}' does not exist in field '{limit.FieldName}'"); + } + } + + if (!identities.Add(identity)) + { + warnings.Add($"view '{view.Name}': duplicate Board column limit for {DescribeColumn(limit)}"); + } + } + } + + internal static string DescribeColumn(BoardColumnLimitSnapshot limit) + { + ArgumentNullException.ThrowIfNull(limit); + return limit.SingleSelectOptionName is { } optionName + ? $"Single-select column '{limit.FieldName}' / '{optionName}'" + : limit.IterationTitle is { } iterationTitle + ? $"Iteration column '{limit.FieldName}' / '{iterationTitle}'" + : $"unidentified column for field '{limit.FieldName}'"; + } + /// <summary> /// Applies only settings that the GraphQL View mutations cannot write. Views must /// already exist and be mapped from source to target numbers by the API import stage. @@ -145,7 +261,11 @@ public async Task EnrichAsync( projectNumber, string.Create(CultureInfo.InvariantCulture, $"views/{targetNumber}")); await _session.GotoAsync(url, cancellationToken).ConfigureAwait(false); - await ApplyAndVerifyBrowserOnlySettingsAsync(page, view, cancellationToken).ConfigureAwait(false); + await ApplyAndVerifyBrowserOnlySettingsAsync( + page, + view, + snapshot.Fields, + cancellationToken).ConfigureAwait(false); } catch (Exception exception) when (exception is PlaywrightException or TimeoutException or InvalidOperationException) { @@ -224,6 +344,57 @@ await ApplyAndVerifyFieldSumAsync( } } + /// <summary>Applies and verifies the complete Board column-limit set for one target View.</summary> + public async Task ApplyBoardColumnLimitsAsync( + string ownerLogin, + ProjectOwnerType ownerType, + int projectNumber, + int viewNumber, + ViewSnapshot view, + IReadOnlyList<FieldSnapshot> fields, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ownerLogin); + ArgumentNullException.ThrowIfNull(view); + ArgumentNullException.ThrowIfNull(fields); + var desired = view.Ui?.BoardColumnLimits + ?? throw new ArgumentException("The View has no captured Board column-limit state.", nameof(view)); + + OnProgress?.Invoke($"Applying Board column-limit drift for view '{view.Name}'..."); + try + { + var page = await _session.GetPageAsync(cancellationToken).ConfigureAwait(false); + var url = BrowserProjectUrl.Build( + _session.BaseUrl, + ownerLogin, + ownerType, + projectNumber, + string.Create(CultureInfo.InvariantCulture, $"views/{viewNumber}")); + await _session.GotoAsync(url, cancellationToken).ConfigureAwait(false); + _warnings.AddRange(await BoardColumnLimitUi.ApplyAsync( + page, + view, + fields, + desired, + cancellationToken).ConfigureAwait(false)); + var persisted = await BoardColumnLimitUi.ReadAsync( + page, + view, + fields, + cancellationToken).ConfigureAwait(false); + foreach (var difference in CollectBoardColumnLimitDifferences(desired, persisted)) + { + _warnings.Add($"view '{view.Name}': {difference} did not persist"); + } + + await _session.SaveStateAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (exception is PlaywrightException or TimeoutException or InvalidOperationException) + { + _warnings.Add($"view '{view.Name}': Board column-limit drift could not be applied — {exception.Message}"); + } + } + /// <summary>Applies and verifies both persisted Roadmap display checkboxes for one target View.</summary> public async Task ApplyRoadmapDisplayOptionsAsync( string ownerLogin, @@ -414,17 +585,29 @@ private async Task ReorderTabsAsync( var source = Sel.DraggableViewTab(page, move.ViewNumber); var anchor = Sel.DraggableViewTab(page, move.AnchorViewNumber); await source.ScrollIntoViewIfNeededAsync().ConfigureAwait(false); - await anchor.ScrollIntoViewIfNeededAsync().ConfigureAwait(false); - var anchorBox = await anchor.BoundingBoxAsync().ConfigureAwait(false) - ?? throw new InvalidOperationException($"view tab '{names[move.AnchorViewNumber]}' has no visible bounding box"); - await source.DragToAsync(anchor, new() + var sourceBox = await source.BoundingBoxAsync().ConfigureAwait(false) + ?? throw new InvalidOperationException($"view tab '{names[move.ViewNumber]}' has no visible bounding box"); + var sourceX = sourceBox.X + sourceBox.Width / 2; + var sourceY = sourceBox.Y + sourceBox.Height / 2; + await page.Mouse.MoveAsync(sourceX, sourceY).ConfigureAwait(false); + await page.Mouse.DownAsync().ConfigureAwait(false); + try { - TargetPosition = new() - { - X = move.PlaceBefore ? 2 : Math.Max(2, anchorBox.Width - 2), - Y = anchorBox.Height / 2, - }, - }).ConfigureAwait(false); + await Task.Delay(TimeSpan.FromMilliseconds(250), token).ConfigureAwait(false); + await page.Mouse.MoveAsync(sourceX + 8, sourceY, new() { Steps = 4 }).ConfigureAwait(false); + await anchor.ScrollIntoViewIfNeededAsync().ConfigureAwait(false); + var anchorBox = await anchor.BoundingBoxAsync().ConfigureAwait(false) + ?? throw new InvalidOperationException($"view tab '{names[move.AnchorViewNumber]}' has no visible bounding box"); + var targetX = anchorBox.X + (move.PlaceBefore ? 2 : Math.Max(2, anchorBox.Width - 2)); + var targetY = anchorBox.Y + anchorBox.Height / 2; + await page.Mouse.MoveAsync(targetX, targetY, new() { Steps = 12 }).ConfigureAwait(false); + await Task.Delay(TimeSpan.FromMilliseconds(250), token).ConfigureAwait(false); + } + finally + { + await page.Mouse.UpAsync().ConfigureAwait(false); + } + await PauseAsync(token).ConfigureAwait(false); }, token => ReadImportedTabOrderAsync( @@ -630,6 +813,7 @@ internal sealed record TabMove(int ViewNumber, int AnchorViewNumber, bool PlaceB private async Task ApplyBrowserOnlySettingsAsync( IPage page, ViewSnapshot view, + IReadOnlyList<FieldSnapshot> fields, CancellationToken cancellationToken) { // GraphQL-derived settings. Boards expose their horizontal grouping as the @@ -656,13 +840,19 @@ await TrySetSingleAsync( // grouping change has been persisted and the View has reloaded. await SaveViewAsync(page, cancellationToken).ConfigureAwait(false); + var columnByReady = true; if (isBoard && view.VerticalGroupByFields.Count > 0) { - await TrySetSingleAsync(page, "Column by", view.VerticalGroupByFields[0], view.Name, cancellationToken).ConfigureAwait(false); + columnByReady = await TrySetSingleAsync( + page, + "Column by", + view.VerticalGroupByFields[0], + view.Name, + cancellationToken).ConfigureAwait(false); } else if (isBoard) { - await TrySetSingleAsync( + columnByReady = await TrySetSingleAsync( page, "Column by", ["None", "No field"], @@ -671,17 +861,44 @@ await TrySetSingleAsync( cancellationToken).ConfigureAwait(false); } + if (isBoard && view.Ui?.BoardColumnLimits is { } boardColumnLimits && columnByReady) + { + // Column limits are saved by their own dialog and require the persisted + // Column-by selection to have rendered the target columns first. + await SaveViewAsync(page, cancellationToken).ConfigureAwait(false); + var warnings = await BoardColumnLimitUi.ApplyAsync( + page, + view, + fields, + boardColumnLimits, + cancellationToken).ConfigureAwait(false); + _warnings.AddRange(warnings); + } + else if (isBoard && view.Ui?.BoardColumnLimits is not null && !columnByReady) + { + _warnings.Add( + $"view '{view.Name}': Board column limits were not applied because the target column-by field could not be selected"); + } + if (view.SortByFields.Count > 0) { var sort = view.SortByFields[0]; if (!await IsSortAlreadyAppliedAsync(page, sort, cancellationToken).ConfigureAwait(false)) { - await TryEnsureSortFieldVisibleAsync( + var sortFieldWasTemporarilyShown = await TryEnsureSortFieldVisibleAsync( page, sort.Field, view.Name, cancellationToken).ConfigureAwait(false); await TrySetSortAsync(page, sort, view.Name, cancellationToken).ConfigureAwait(false); + if (ShouldRestoreSortFieldVisibility(view, sort.Field, sortFieldWasTemporarilyShown)) + { + await TryHideSortFieldAsync( + page, + sort.Field, + view.Name, + cancellationToken).ConfigureAwait(false); + } } } else @@ -774,16 +991,21 @@ internal static bool FieldSumControlExpected(ViewSnapshot view) private async Task ApplyAndVerifyBrowserOnlySettingsAsync( IPage page, ViewSnapshot view, + IReadOnlyList<FieldSnapshot> fields, CancellationToken cancellationToken) { IReadOnlyList<string> differences = []; for (var attempt = 1; attempt <= ViewPersistenceAttempts; attempt++) { var warningStart = _warnings.Count; - await ApplyBrowserOnlySettingsAsync(page, view, cancellationToken).ConfigureAwait(false); + await ApplyBrowserOnlySettingsAsync(page, view, fields, cancellationToken).ConfigureAwait(false); await SaveViewAsync(page, cancellationToken).ConfigureAwait(false); - var persisted = await ReadPersistedSettingsAsync(page, view, cancellationToken).ConfigureAwait(false); + var persisted = await ReadPersistedSettingsAsync( + page, + view, + fields, + cancellationToken).ConfigureAwait(false); differences = CollectPersistenceDifferences(view, persisted); if (differences.Count == 0) { @@ -889,9 +1111,11 @@ private async Task ApplyAndVerifyCheckboxesAsync( private static async Task<PersistedViewSettings> ReadPersistedSettingsAsync( IPage page, ViewSnapshot view, + IReadOnlyList<FieldSnapshot> fields, CancellationToken cancellationToken) { var menu = await OpenViewMenuAsync(page, cancellationToken).ConfigureAwait(false); + PersistedViewSettings settings; try { var groupingLabel = string.Equals(view.Layout, "BOARD_LAYOUT", StringComparison.Ordinal) @@ -931,7 +1155,7 @@ private static async Task<PersistedViewSettings> ReadPersistedSettingsAsync( } } - return new PersistedViewSettings( + settings = new PersistedViewSettings( groupBy, columnBy, sliceBy, @@ -944,6 +1168,20 @@ private static async Task<PersistedViewSettings> ReadPersistedSettingsAsync( { await CloseMenusAsync(page, cancellationToken).ConfigureAwait(false); } + + if (view.Ui?.BoardColumnLimits is not null) + { + settings = settings with + { + BoardColumnLimits = await BoardColumnLimitUi.ReadAsync( + page, + view, + fields, + cancellationToken).ConfigureAwait(false), + }; + } + + return settings; } private static async Task<IReadOnlyList<string>?> ReadPersistedFieldSumAsync( @@ -1094,6 +1332,20 @@ internal static IReadOnlyList<string> CollectPersistenceDifferences( } } + if (expected.Ui?.BoardColumnLimits is { } expectedLimits) + { + if (actual.BoardColumnLimits is null) + { + differences.Add("Board column limits are unavailable"); + } + else + { + differences.AddRange(CollectBoardColumnLimitDifferences( + expectedLimits, + actual.BoardColumnLimits)); + } + } + if (expected.Ui?.Roadmap is { } roadmap) { if (roadmap.TruncateTitles is { } expectedTruncateTitles @@ -1132,6 +1384,41 @@ internal static bool CheckboxSelectionMatches( private static string FormatBoolean(bool? value) => value?.ToString().ToLowerInvariant() ?? "unavailable"; + private static bool SameBoardColumn(BoardColumnLimitSnapshot first, BoardColumnLimitSnapshot second) + => string.Equals(first.FieldName, second.FieldName, StringComparison.Ordinal) + && string.Equals(first.SingleSelectOptionName, second.SingleSelectOptionName, StringComparison.Ordinal) + && string.Equals(first.IterationTitle, second.IterationTitle, StringComparison.Ordinal); + + private static List<string> CollectBoardColumnLimitDifferences( + IReadOnlyList<BoardColumnLimitSnapshot> expected, + IReadOnlyList<BoardColumnLimitSnapshot> actual) + { + var differences = new List<string>(); + foreach (var expectedLimit in expected) + { + var actualLimit = actual.FirstOrDefault(candidate => SameBoardColumn(expectedLimit, candidate)); + if (actualLimit is null) + { + differences.Add( + $"Board limit for {DescribeColumn(expectedLimit)} expected '{expectedLimit.Limit}', actual 'unlimited'"); + } + else if (actualLimit.Limit != expectedLimit.Limit) + { + differences.Add( + $"Board limit for {DescribeColumn(expectedLimit)} expected '{expectedLimit.Limit}', actual '{actualLimit.Limit}'"); + } + } + + foreach (var actualLimit in actual.Where(actualLimit => + !expected.Any(expectedLimit => SameBoardColumn(expectedLimit, actualLimit)))) + { + differences.Add( + $"Board limit for {DescribeColumn(actualLimit)} expected 'unlimited', actual '{actualLimit.Limit}'"); + } + + return differences; + } + internal sealed record PersistedViewSettings( string? GroupBy, string? ColumnBy, @@ -1139,7 +1426,8 @@ internal sealed record PersistedViewSettings( bool FieldSumAvailable, IReadOnlyList<string> FieldSum, bool? TruncateTitles = null, - bool? ShowDateFields = null); + bool? ShowDateFields = null, + IReadOnlyList<BoardColumnLimitSnapshot>? BoardColumnLimits = null); private async Task TrySetMenuCheckboxAsync( IPage page, @@ -1329,7 +1617,18 @@ private static async Task<bool> IsSortAlreadyAppliedAsync( } } - private async Task TryEnsureSortFieldVisibleAsync( + internal static bool ShouldRestoreSortFieldVisibility( + ViewSnapshot view, + string sortField, + bool sortFieldWasTemporarilyShown) + { + ArgumentNullException.ThrowIfNull(view); + ArgumentException.ThrowIfNullOrWhiteSpace(sortField); + return sortFieldWasTemporarilyShown + && !view.VisibleFields.Contains(sortField, StringComparer.Ordinal); + } + + private async Task<bool> TryEnsureSortFieldVisibleAsync( IPage page, string field, string viewName, @@ -1342,26 +1641,71 @@ private async Task TryEnsureSortFieldVisibleAsync( if (await item.CountAsync().ConfigureAwait(false) == 0) { await CloseMenusAsync(page, cancellationToken).ConfigureAwait(false); - return; + return false; } await item.First.ClickAsync().ConfigureAwait(false); await PauseAsync(cancellationToken).ConfigureAwait(false); var option = await FindOptionAsync(page, field).ConfigureAwait(false); + var changed = false; if (option is not null && !string.Equals(await option.GetAttributeAsync("aria-disabled").ConfigureAwait(false), "true", StringComparison.Ordinal) && !string.Equals(await option.GetAttributeAsync("aria-checked").ConfigureAwait(false), "true", StringComparison.Ordinal)) { await option.ClickAsync().ConfigureAwait(false); await PauseAsync(cancellationToken).ConfigureAwait(false); + changed = true; } await CloseMenusAsync(page, cancellationToken).ConfigureAwait(false); + return changed; } catch (Exception exception) when (exception is PlaywrightException or TimeoutException) { _warnings.Add($"view '{viewName}': sort field '{field}' could not be made visible — {exception.Message}"); await CloseMenusAsync(page, cancellationToken).ConfigureAwait(false); + return false; + } + } + + private async Task TryHideSortFieldAsync( + IPage page, + string field, + string viewName, + CancellationToken cancellationToken) + { + try + { + var menu = await OpenViewMenuAsync(page, cancellationToken).ConfigureAwait(false); + var item = Sel.ConfigurationMenuItem(menu, "Fields"); + if (await item.CountAsync().ConfigureAwait(false) == 0) + { + _warnings.Add($"view '{viewName}': temporarily shown sort field '{field}' could not be hidden because Fields is unavailable"); + await CloseMenusAsync(page, cancellationToken).ConfigureAwait(false); + return; + } + + await item.First.ClickAsync().ConfigureAwait(false); + await PauseAsync(cancellationToken).ConfigureAwait(false); + var option = await FindOptionAsync(page, field).ConfigureAwait(false); + if (option is null + || string.Equals(await option.GetAttributeAsync("aria-disabled").ConfigureAwait(false), "true", StringComparison.Ordinal) + || !string.Equals(await option.GetAttributeAsync("aria-checked").ConfigureAwait(false), "true", StringComparison.Ordinal)) + { + _warnings.Add($"view '{viewName}': temporarily shown sort field '{field}' could not be hidden"); + } + else + { + await option.ClickAsync().ConfigureAwait(false); + await PauseAsync(cancellationToken).ConfigureAwait(false); + } + + await CloseMenusAsync(page, cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (exception is PlaywrightException or TimeoutException) + { + _warnings.Add($"view '{viewName}': temporarily shown sort field '{field}' could not be hidden — {exception.Message}"); + await CloseMenusAsync(page, cancellationToken).ConfigureAwait(false); } } diff --git a/src/Ghpmv.Core/Snapshot/ProjectSnapshot.cs b/src/Ghpmv.Core/Snapshot/ProjectSnapshot.cs index 2e72cddd..739a1b16 100644 --- a/src/Ghpmv.Core/Snapshot/ProjectSnapshot.cs +++ b/src/Ghpmv.Core/Snapshot/ProjectSnapshot.cs @@ -258,11 +258,33 @@ public sealed record ViewUiSnapshot /// <summary>Grouped Table, Board, or Roadmap "Field sum" entries (e.g. "Count", number field names).</summary> public IReadOnlyList<string>? FieldSum { get; init; } + /// <summary> + /// Explicit per-column soft limits for a Board view. Null means the setting was not + /// captured; an empty collection means it was captured and no columns have a limit. + /// </summary> + public IReadOnlyList<BoardColumnLimitSnapshot>? BoardColumnLimits { get; init; } + public RoadmapSettingsSnapshot? Roadmap { get; init; } public DateTimeOffset? ScrapedAt { get; init; } } +/// <summary>A Board column limit identified without persisting a GitHub node ID.</summary> +public sealed record BoardColumnLimitSnapshot +{ + /// <summary>The Board's vertical group-by field name.</summary> + public required string FieldName { get; init; } + + /// <summary>The column option name when <see cref="FieldName"/> is a Single-select field.</summary> + public string? SingleSelectOptionName { get; init; } + + /// <summary>The column title when <see cref="FieldName"/> is an Iteration field.</summary> + public string? IterationTitle { get; init; } + + /// <summary>The positive soft card limit configured for this column.</summary> + public required int Limit { get; init; } +} + /// <summary>Roadmap-only UI settings (populated in M6 via browser automation).</summary> public sealed record RoadmapSettingsSnapshot { diff --git a/src/Ghpmv.Core/Verify/ProjectVerifier.cs b/src/Ghpmv.Core/Verify/ProjectVerifier.cs index 7516a0c6..d1f26c10 100644 --- a/src/Ghpmv.Core/Verify/ProjectVerifier.cs +++ b/src/Ghpmv.Core/Verify/ProjectVerifier.cs @@ -903,6 +903,7 @@ private static void CompareViews( CompareViewApi(name, s[0], t[0], differences); if (s[0].Ui is { } sourceUi && t[0].Ui is { } targetUi) { + WarnIfBoardLimitsNotCaptured(name, sourceUi, targetUi, differences, notVerified); CompareViewUi(name, sourceUi, targetUi, differences); } else @@ -942,8 +943,65 @@ private static void CompareViews( AddError(differences, ViewCategory, $"views named '{name}': combined API and UI settings do not match"); } + + if (HasUncapturedTargetBoardLimits(s, t)) + { + notVerified.Add(ViewCategory); + Add(differences, VerifySeverity.Warning, ViewCategory, + $"views named '{name}': Board column limits were captured in the source but could not all be read from the target"); + } } + + } + } + + private static bool HasUncapturedTargetBoardLimits( + IReadOnlyList<ViewSnapshot> source, + IReadOnlyList<ViewSnapshot> target) + { + var availableTargets = target.ToList(); + foreach (var sourceView in source.Where(view => view.Ui?.BoardColumnLimits is not null)) + { + var capturedIndex = availableTargets.FindIndex(targetView => + targetView.Ui?.BoardColumnLimits is not null + && ViewApiEquals(sourceView, targetView) + && ViewUiEqualsWithoutBoardLimits(sourceView.Ui!, targetView.Ui) + && BoardColumnLimitsEqual( + sourceView.Ui!.BoardColumnLimits, + targetView.Ui.BoardColumnLimits)); + if (capturedIndex >= 0) + { + availableTargets.RemoveAt(capturedIndex); + continue; + } + + if (availableTargets.Any(targetView => + targetView.Ui is { BoardColumnLimits: null } targetUi + && ViewApiEquals(sourceView, targetView) + && ViewUiEqualsWithoutBoardLimits(sourceView.Ui!, targetUi))) + { + return true; + } + } + + return false; + } + + private static void WarnIfBoardLimitsNotCaptured( + string name, + ViewUiSnapshot source, + ViewUiSnapshot target, + List<VerifyDifference> differences, + HashSet<string> notVerified) + { + if (source.BoardColumnLimits is null || target.BoardColumnLimits is not null) + { + return; } + + notVerified.Add(ViewCategory); + Add(differences, VerifySeverity.Warning, ViewCategory, + $"view '{name}': Board column limits were captured in the source but could not be read from the target"); } private static void CompareViewOrder( @@ -1022,10 +1080,33 @@ private static bool ViewApiEquals(ViewSnapshot source, ViewSnapshot target) && string.Equals(pair.First.Direction, pair.Second.Direction, StringComparison.Ordinal)); private static bool ViewUiEquals(ViewUiSnapshot source, ViewUiSnapshot target) + => ViewUiEqualsWithoutBoardLimits(source, target) + && BoardColumnLimitsEqual(source.BoardColumnLimits, target.BoardColumnLimits); + + private static bool ViewUiEqualsWithoutBoardLimits(ViewUiSnapshot source, ViewUiSnapshot target) => string.Equals(source.SliceBy, target.SliceBy, StringComparison.Ordinal) && UiListEquals(source.FieldSum, target.FieldSum) && RoadmapEquals(source.Roadmap, target.Roadmap); + private static bool BoardColumnLimitsEqual( + IReadOnlyList<BoardColumnLimitSnapshot>? source, + IReadOnlyList<BoardColumnLimitSnapshot>? target) + { + if (source is null || target is null) + { + return true; + } + + return MultisetEquals(source, target, (sourceLimit, targetLimit) => + SameBoardColumn(sourceLimit, targetLimit) + && sourceLimit.Limit == targetLimit.Limit); + } + + private static bool SameBoardColumn(BoardColumnLimitSnapshot source, BoardColumnLimitSnapshot target) + => string.Equals(source.FieldName, target.FieldName, StringComparison.Ordinal) + && string.Equals(source.SingleSelectOptionName, target.SingleSelectOptionName, StringComparison.Ordinal) + && string.Equals(source.IterationTitle, target.IterationTitle, StringComparison.Ordinal); + private static bool RoadmapEquals(RoadmapSettingsSnapshot? source, RoadmapSettingsSnapshot? target) => source is null && target is null || source is not null && target is not null @@ -1046,19 +1127,37 @@ private static bool MultisetEquals<T>( return false; } - var unmatched = target.ToList(); - foreach (var sourceItem in source) + var sourceMatchedByTarget = Enumerable.Repeat(-1, target.Count).ToArray(); + for (var sourceIndex = 0; sourceIndex < source.Count; sourceIndex++) { - var index = unmatched.FindIndex(targetItem => equals(sourceItem, targetItem)); - if (index < 0) + if (!TryMatch(sourceIndex, new bool[target.Count])) { return false; } - - unmatched.RemoveAt(index); } return true; + + bool TryMatch(int sourceIndex, bool[] visitedTargets) + { + for (var targetIndex = 0; targetIndex < target.Count; targetIndex++) + { + if (visitedTargets[targetIndex] || !equals(source[sourceIndex], target[targetIndex])) + { + continue; + } + + visitedTargets[targetIndex] = true; + if (sourceMatchedByTarget[targetIndex] < 0 + || TryMatch(sourceMatchedByTarget[targetIndex], visitedTargets)) + { + sourceMatchedByTarget[targetIndex] = sourceIndex; + return true; + } + } + + return false; + } } private static void CompareViewApi(string name, ViewSnapshot source, ViewSnapshot target, List<VerifyDifference> differences) @@ -1110,6 +1209,8 @@ private static void CompareViewUi(string name, ViewUiSnapshot source, ViewUiSnap $"view '{name}': field sum mismatch (source [{JoinUi(source.FieldSum)}], target [{JoinUi(target.FieldSum)}])"); } + CompareBoardColumnLimits(name, source.BoardColumnLimits, target.BoardColumnLimits, differences); + if ((source.Roadmap is null) != (target.Roadmap is null)) { AddError(differences, ViewCategory, @@ -1141,6 +1242,47 @@ private static void CompareViewUi(string name, ViewUiSnapshot source, ViewUiSnap } } + private static void CompareBoardColumnLimits( + string name, + IReadOnlyList<BoardColumnLimitSnapshot>? source, + IReadOnlyList<BoardColumnLimitSnapshot>? target, + List<VerifyDifference> differences) + { + if (source is null || target is null) + { + return; + } + + foreach (var sourceLimit in source) + { + var targetLimit = target.FirstOrDefault(candidate => SameBoardColumn(sourceLimit, candidate)); + if (targetLimit is null) + { + AddError(differences, ViewCategory, + $"view '{name}': Board limit mismatch for {DescribeBoardColumn(sourceLimit)} (source {sourceLimit.Limit}, target unlimited)"); + } + else if (sourceLimit.Limit != targetLimit.Limit) + { + AddError(differences, ViewCategory, + $"view '{name}': Board limit mismatch for {DescribeBoardColumn(sourceLimit)} (source {sourceLimit.Limit}, target {targetLimit.Limit})"); + } + } + + foreach (var targetLimit in target.Where(targetLimit => + !source.Any(sourceLimit => SameBoardColumn(sourceLimit, targetLimit)))) + { + AddError(differences, ViewCategory, + $"view '{name}': Board limit mismatch for {DescribeBoardColumn(targetLimit)} (source unlimited, target {targetLimit.Limit})"); + } + } + + private static string DescribeBoardColumn(BoardColumnLimitSnapshot limit) + => limit.SingleSelectOptionName is { } optionName + ? $"Single-select column '{limit.FieldName}' / '{optionName}'" + : limit.IterationTitle is { } iterationTitle + ? $"Iteration column '{limit.FieldName}' / '{iterationTitle}'" + : $"unidentified column for field '{limit.FieldName}'"; + private static void CompareUiBoolean( List<VerifyDifference> differences, string viewName, diff --git a/tests/Ghpmv.Browser.Tests/BrowserRoundTripTests.cs b/tests/Ghpmv.Browser.Tests/BrowserRoundTripTests.cs index 9207e682..c4b9635d 100644 --- a/tests/Ghpmv.Browser.Tests/BrowserRoundTripTests.cs +++ b/tests/Ghpmv.Browser.Tests/BrowserRoundTripTests.cs @@ -291,6 +291,16 @@ await AssertRoadmapDisplayFreshSessionAsync( view => result.ViewNumbers[view.Number], StringComparer.Ordinal), cancellationToken); + await new BoardColumnLimitObserver(targetSession).ValidateFixtureAsync( + snapshot, + TargetOrg, + ProjectOwnerType.Organization, + result.ProjectNumber, + snapshot.Views.ToDictionary( + view => view.Name, + view => result.ViewNumbers[view.Number], + StringComparer.Ordinal), + cancellationToken); await viewImporter.ApplyRoadmapDisplayOptionsAsync( TargetOrg, @@ -378,6 +388,17 @@ await viewImporter.ApplyFieldSumAsync( sourceTable.Name, ["Fixture Number"], cancellationToken); + var driftedBoard = Assert.Single( + FixtureUiSnapshotFactory.CreateFieldSumDrift().Views, + view => view.Name == "Fixture Board"); + await viewImporter.ApplyBoardColumnLimitsAsync( + TargetOrg, + ProjectOwnerType.Organization, + result.ProjectNumber, + result.ViewNumbers[driftedBoard.Number], + driftedBoard, + snapshot.Fields, + cancellationToken); Assert.Empty(viewImporter.Warnings); var targetWorkflow = Assert.Single(target.Workflows, workflow => workflow.Name == "Auto-add secondary"); @@ -407,6 +428,10 @@ await fieldDefaultImporter.ImportAsync( difference.Severity == VerifySeverity.Error && difference.Category == VerifyCategories.View && difference.Message.Contains("field sum mismatch", StringComparison.Ordinal)); + Assert.Equal(2, driftReport.Differences.Count(difference => + difference.Severity == VerifySeverity.Error + && difference.Category == VerifyCategories.View + && difference.Message.Contains("Board limit mismatch", StringComparison.Ordinal))); Assert.DoesNotContain(driftReport.Differences, difference => difference.Category == VerifyCategories.View && difference.Message.Contains("truncate titles mismatch", StringComparison.Ordinal)); @@ -565,6 +590,14 @@ private static void AssertSourceViews(ProjectSnapshot source) var sourceBoard = Assert.Single(source.Views, view => view.Name == "Fixture Board"); Assert.Equal("Fixture Select", Assert.Single(sourceBoard.VerticalGroupByFields)); Assert.Equal(["Fixture Number"], sourceBoard.Ui!.FieldSum); + Assert.Equal( + [("Alpha", 1), ("Beta", 2)], + sourceBoard.Ui.BoardColumnLimits!.Select(limit => (limit.SingleSelectOptionName, limit.Limit))); + var sourceIterationBoard = Assert.Single(source.Views, view => view.Name == "Fixture Iteration Board"); + Assert.Equal("Fixture Sprint", Assert.Single(sourceIterationBoard.VerticalGroupByFields)); + Assert.Equal( + [("Sprint 0", 1), ("Sprint 1", 3)], + sourceIterationBoard.Ui!.BoardColumnLimits!.Select(limit => (limit.IterationTitle, limit.Limit))); var sourceRoadmap = Assert.Single(source.Views, view => view.Name == "Fixture Roadmap"); Assert.Equal(["Status"], sourceRoadmap.GroupByFields); @@ -670,6 +703,9 @@ private static void AssertRoundTrippedViews(ProjectSnapshot snapshot, ProjectSna Assert.NotNull(actual.Ui); Assert.Equal(expected.Ui!.SliceBy, actual.Ui!.SliceBy); Assert.Equal(expected.Ui.FieldSum ?? [], actual.Ui.FieldSum ?? []); + Assert.Equal( + expected.Ui.BoardColumnLimits ?? [], + actual.Ui.BoardColumnLimits ?? []); Assert.Equal(expected.Ui.Roadmap is null, actual.Ui.Roadmap is null); if (expected.Ui.Roadmap is { } roadmap) { diff --git a/tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs b/tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs index ad462380..a97107c6 100644 --- a/tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs +++ b/tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs @@ -81,6 +81,66 @@ public void SortMenuMatches_requires_the_exact_field_and_direction( public void NormalizeUiText_collapses_whitespace(string text, string? expected) => Assert.Equal(expected, ViewUiExporter.NormalizeUiText(text)); + [Theory] + [InlineData(null, null)] + [InlineData("", null)] + [InlineData(" ", null)] + [InlineData("1", 1)] + [InlineData("25", 25)] + public void Parse_Board_column_limit_distinguishes_unlimited_and_numeric_values( + string? value, + int? expected) + => Assert.Equal(expected, BoardColumnLimitUi.ParseLimit(value)); + + [Theory] + [InlineData("0")] + [InlineData("-1")] + [InlineData("1.5")] + [InlineData("many")] + public void Parse_Board_column_limit_rejects_invalid_values(string value) + => Assert.Throws<InvalidOperationException>(() => BoardColumnLimitUi.ParseLimit(value)); + + [Fact] + public void Board_limit_capture_is_skipped_without_discarding_other_UI_for_unsupported_columns() + { + var view = View("Board", "BOARD_LAYOUT") with + { + VerticalGroupByFields = ["Assignees"], + Ui = new ViewUiSnapshot { FieldSum = ["Count"] }, + }; + var fields = new[] + { + new FieldSnapshot { Name = "Assignees", DataType = "ASSIGNEES" }, + }; + + Assert.False(BoardColumnLimitUi.CanCapture(view, fields, out var reason)); + Assert.Contains("unsupported type 'ASSIGNEES'", reason, StringComparison.Ordinal); + Assert.Equal(["Count"], view.Ui.FieldSum); + } + + [Fact] + public void Board_column_observer_compares_logical_identities_without_node_ids() + { + var expected = View("Board", "BOARD_LAYOUT") with + { + Ui = new ViewUiSnapshot + { + BoardColumnLimits = + [ + BoardLimit("Fixture Sprint", iteration: "Sprint 1", limit: 2), + ], + }, + }; + + BoardColumnLimitObserver.ValidateLimits( + expected, + [BoardLimit("Fixture Sprint", iteration: "Sprint 1", limit: 2)]); + Assert.Throws<InvalidOperationException>(() => + BoardColumnLimitObserver.ValidateLimits( + expected, + [BoardLimit("Fixture Sprint", iteration: "Sprint 1", limit: 3)])); + } + // ----- pre-flight warning collection ----- [Fact] @@ -149,6 +209,274 @@ public void CollectPreflightWarnings_reports_missing_field_sum_fields() Assert.Single(warnings); } + [Fact] + public void CollectPreflightWarnings_accepts_logical_single_select_and_iteration_columns() + { + var snapshot = Snapshot( + fields: ["Fixture Select", "Fixture Sprint"], + View("Select Board", "BOARD_LAYOUT") with + { + VerticalGroupByFields = ["Fixture Select"], + Ui = new ViewUiSnapshot + { + BoardColumnLimits = + [ + new BoardColumnLimitSnapshot + { + FieldName = "Fixture Select", + SingleSelectOptionName = "Alpha", + Limit = 1, + }, + ], + }, + }, + View("Iteration Board", "BOARD_LAYOUT") with + { + VerticalGroupByFields = ["Fixture Sprint"], + Ui = new ViewUiSnapshot + { + BoardColumnLimits = + [ + new BoardColumnLimitSnapshot + { + FieldName = "Fixture Sprint", + IterationTitle = "Sprint 1", + Limit = 2, + }, + ], + }, + }) with + { + Fields = + [ + new FieldSnapshot + { + Name = "Fixture Select", + DataType = "SINGLE_SELECT", + Options = + [ + new SingleSelectOptionSnapshot + { + Id = "source-option-id", + Name = "Alpha", + Color = "RED", + }, + ], + }, + new FieldSnapshot + { + Name = "Fixture Sprint", + DataType = "ITERATION", + IterationConfiguration = new IterationConfigurationSnapshot + { + Duration = 14, + StartDay = 1, + Iterations = + [ + new IterationSnapshot + { + Id = "source-iteration-id", + Title = "Sprint 1", + StartDate = "2026-08-24", + Duration = 14, + }, + ], + CompletedIterations = [], + }, + }, + ], + }; + + Assert.Empty(ViewUiImporter.CollectPreflightWarnings(snapshot)); + } + + [Fact] + public void CollectPreflightWarnings_reports_malformed_and_missing_Board_columns() + { + var snapshot = Snapshot( + fields: ["Fixture Select"], + View("Board", "BOARD_LAYOUT") with + { + VerticalGroupByFields = ["Fixture Select"], + Ui = new ViewUiSnapshot + { + BoardColumnLimits = + [ + new BoardColumnLimitSnapshot + { + FieldName = "Fixture Select", + SingleSelectOptionName = "Missing", + Limit = 0, + }, + new BoardColumnLimitSnapshot + { + FieldName = "Wrong Field", + IterationTitle = "Sprint 1", + Limit = 2, + }, + new BoardColumnLimitSnapshot + { + FieldName = "Fixture Select", + SingleSelectOptionName = "Alpha", + IterationTitle = "Sprint 1", + Limit = 3, + }, + ], + }, + }) with + { + Fields = + [ + new FieldSnapshot + { + Name = "Fixture Select", + DataType = "SINGLE_SELECT", + Options = + [ + new SingleSelectOptionSnapshot { Id = "alpha", Name = "Alpha", Color = "RED" }, + ], + }, + ], + }; + + var warnings = ViewUiImporter.CollectPreflightWarnings(snapshot); + + Assert.Contains(warnings, warning => warning.Contains("must be positive", StringComparison.Ordinal)); + Assert.Contains(warnings, warning => warning.Contains("option 'Missing' does not exist", StringComparison.Ordinal)); + Assert.Contains(warnings, warning => warning.Contains("does not use column-by field", StringComparison.Ordinal)); + Assert.Contains(warnings, warning => warning.Contains("exactly one Single-select option or Iteration", StringComparison.Ordinal)); + Assert.Equal(4, warnings.Count); + } + + [Fact] + public void Board_limit_reconciliation_skips_all_writes_when_a_target_column_is_missing() + { + var field = new FieldSnapshot + { + Name = "Fixture Select", + DataType = "SINGLE_SELECT", + Options = + [ + new SingleSelectOptionSnapshot { Id = "alpha", Name = "Alpha", Color = "RED" }, + new SingleSelectOptionSnapshot { Id = "beta", Name = "Beta", Color = "BLUE" }, + ], + }; + var view = View("Board", "BOARD_LAYOUT") with + { + VerticalGroupByFields = [field.Name], + }; + var desiredLimits = new[] + { + BoardLimit(field.Name, option: "Alpha", limit: 1), + }; + + var plan = BoardColumnLimitUi.BuildReconciliationPlan( + view, + field, + desiredLimits, + ["Beta"]); + + Assert.Contains(plan.Warnings, warning => + warning.Contains("Single-select column 'Fixture Select' / 'Alpha'", StringComparison.Ordinal) + && warning.Contains("was not found", StringComparison.Ordinal)); + Assert.Empty(plan.Targets); + } + + [Fact] + public void Board_limit_reconciliation_skips_all_writes_when_an_unlimited_target_column_is_hidden() + { + var field = new FieldSnapshot + { + Name = "Fixture Select", + DataType = "SINGLE_SELECT", + Options = + [ + new SingleSelectOptionSnapshot { Id = "alpha", Name = "Alpha", Color = "RED" }, + new SingleSelectOptionSnapshot { Id = "beta", Name = "Beta", Color = "BLUE" }, + ], + }; + var view = View("Board", "BOARD_LAYOUT") with + { + VerticalGroupByFields = [field.Name], + }; + + var plan = BoardColumnLimitUi.BuildReconciliationPlan( + view, + field, + [BoardLimit(field.Name, option: "Alpha", limit: 1)], + ["Alpha"]); + + Assert.Contains(plan.Warnings, warning => + warning.Contains("column 'Fixture Select' / 'Beta'", StringComparison.Ordinal) + && warning.Contains("hidden or missing", StringComparison.Ordinal)); + Assert.Empty(plan.Targets); + } + + [Fact] + public void Board_limit_capture_requires_every_logical_column_to_be_displayed() + { + var field = new FieldSnapshot + { + Name = "Fixture Sprint", + DataType = "ITERATION", + IterationConfiguration = new IterationConfigurationSnapshot + { + Duration = 14, + StartDay = 1, + Iterations = + [ + new IterationSnapshot { Id = "sprint-0", Title = "Sprint 0", StartDate = "2026-08-31", Duration = 14 }, + new IterationSnapshot { Id = "sprint-1", Title = "Sprint 1", StartDate = "2026-09-14", Duration = 14 }, + ], + CompletedIterations = [], + }, + }; + + var missing = BoardColumnLimitUi.FindMissingLogicalColumns(field, ["Sprint 0"]); + + Assert.Equal(["Sprint 1"], missing); + } + + [Fact] + public void Board_limit_reconciliation_skips_all_writes_for_malformed_identities() + { + var field = new FieldSnapshot + { + Name = "Fixture Select", + DataType = "SINGLE_SELECT", + Options = + [ + new SingleSelectOptionSnapshot { Id = "alpha", Name = "Alpha", Color = "RED" }, + ], + }; + var view = View("Board", "BOARD_LAYOUT") with + { + VerticalGroupByFields = [field.Name], + }; + var desiredLimits = new[] + { + BoardLimit("Wrong Field", option: "Alpha", limit: 1), + BoardLimit(field.Name, iteration: "Alpha", limit: 2), + new BoardColumnLimitSnapshot + { + FieldName = field.Name, + SingleSelectOptionName = "Alpha", + IterationTitle = "Alpha", + Limit = 3, + }, + BoardLimit(field.Name, option: "Alpha", limit: 0), + }; + + var plan = BoardColumnLimitUi.BuildReconciliationPlan( + view, + field, + desiredLimits, + ["Alpha"]); + + Assert.Equal(4, plan.Warnings.Count); + Assert.Empty(plan.Targets); + } + [Theory] [InlineData("TABLE_LAYOUT")] [InlineData("BOARD_LAYOUT")] @@ -390,6 +718,44 @@ public void Persistence_check_reports_board_column_loss() Assert.StartsWith("column-by expected", difference, StringComparison.Ordinal); } + [Fact] + public void Persistence_check_reports_changed_cleared_and_unexpected_Board_limits() + { + var view = View("Board", "BOARD_LAYOUT") with + { + VerticalGroupByFields = ["Fixture Select"], + Ui = new ViewUiSnapshot + { + BoardColumnLimits = + [ + BoardLimit("Fixture Select", option: "Alpha", limit: 1), + BoardLimit("Fixture Select", option: "Beta", limit: 2), + ], + }, + }; + var persisted = new ViewUiImporter.PersistedViewSettings( + GroupBy: null, + ColumnBy: "Fixture Select", + SliceBy: null, + FieldSumAvailable: true, + FieldSum: [], + BoardColumnLimits: + [ + BoardLimit("Fixture Select", option: "Alpha", limit: 3), + BoardLimit("Fixture Select", option: "Gamma", limit: 4), + ]); + + var differences = ViewUiImporter.CollectPersistenceDifferences(view, persisted); + + Assert.Contains(differences, difference => + difference.Contains("'Alpha' expected '1', actual '3'", StringComparison.Ordinal)); + Assert.Contains(differences, difference => + difference.Contains("'Beta' expected '2', actual 'unlimited'", StringComparison.Ordinal)); + Assert.Contains(differences, difference => + difference.Contains("'Gamma' expected 'unlimited', actual '4'", StringComparison.Ordinal)); + Assert.Equal(3, differences.Count); + } + [Fact] public void Persistence_check_reports_roadmap_display_option_loss_independently() { @@ -467,10 +833,10 @@ public void FixtureUiSnapshotFactory_creates_importable_standard_views_and_workf var snapshot = FixtureUiSnapshotFactory.Create("fixture-repo"); Assert.Equal( - ["View 1", "Fixture Board", "Fixture Roadmap", "Fixture Empty Sums", "Fixture Roadmap Dates Hidden"], + ["View 1", "Fixture Board", "Fixture Roadmap", "Fixture Empty Sums", "Fixture Roadmap Dates Hidden", "Fixture Iteration Board"], snapshot.Views.Select(v => v.Name)); Assert.Equal( - ["Fixture Roadmap", "View 1", "Fixture Board", "Fixture Empty Sums", "Fixture Roadmap Dates Hidden"], + ["Fixture Roadmap", "View 1", "Fixture Board", "Fixture Iteration Board", "Fixture Empty Sums", "Fixture Roadmap Dates Hidden"], snapshot.Views.OrderBy(view => view.TabPosition).Select(view => view.Name)); var roadmap = Assert.Single(snapshot.Views, view => view.Name == "Fixture Roadmap").Ui!.Roadmap!; Assert.True(roadmap.TruncateTitles); @@ -485,6 +851,14 @@ public void FixtureUiSnapshotFactory_creates_importable_standard_views_and_workf ["Fixture Number 2"], snapshot.Views.Single(view => view.Name == "Fixture Roadmap").Ui!.FieldSum); Assert.Empty(snapshot.Views.Single(view => view.Name == "Fixture Empty Sums").Ui!.FieldSum!); + Assert.Equal( + [("Alpha", 1), ("Beta", 2)], + snapshot.Views.Single(view => view.Name == "Fixture Board").Ui!.BoardColumnLimits! + .Select(limit => (limit.SingleSelectOptionName, limit.Limit))); + Assert.Equal( + [("Sprint 0", 1), ("Sprint 1", 3)], + snapshot.Views.Single(view => view.Name == "Fixture Iteration Board").Ui!.BoardColumnLimits! + .Select(limit => (limit.IterationTitle, limit.Limit))); Assert.Contains(snapshot.Fields, field => field.Name == "Fixture Teams" && field.DataType == "MULTI_SELECT" @@ -537,7 +911,7 @@ public void Shared_roadmap_display_settings_reject_conflicting_view_values() } [Fact] - public void FixtureUiSnapshotFactory_field_sum_drift_only_changes_View_1_field_sum() + public void FixtureUiSnapshotFactory_combined_drift_changes_field_sum_and_Board_limits() { var expected = FixtureUiSnapshotFactory.Create("fixture-repo"); var drifted = FixtureUiSnapshotFactory.CreateFieldSumDrift("fixture-repo"); @@ -559,12 +933,22 @@ public void FixtureUiSnapshotFactory_field_sum_drift_only_changes_View_1_field_s { Assert.Equal(["Count", "Fixture Number"], actual.Ui.FieldSum); } - else { Assert.Equal(view.Ui.FieldSum, actual.Ui.FieldSum); } + if (view.Name == "Fixture Board") + { + var limit = Assert.Single(actual.Ui.BoardColumnLimits!); + Assert.Equal("Alpha", limit.SingleSelectOptionName); + Assert.Equal(5, limit.Limit); + } + else + { + Assert.Equal(view.Ui.BoardColumnLimits, actual.Ui.BoardColumnLimits); + } + Assert.Equal(view.Ui.Roadmap?.StartField, actual.Ui.Roadmap?.StartField); Assert.Equal(view.Ui.Roadmap?.TargetField, actual.Ui.Roadmap?.TargetField); Assert.Equal(view.Ui.Roadmap?.Zoom, actual.Ui.Roadmap?.Zoom); @@ -600,6 +984,16 @@ public void FixtureUiSnapshotFactory_creates_typed_defaults_and_all_type_drift() public void Tab_move_plan_is_empty_when_order_already_matches() => Assert.Empty(ViewUiImporter.BuildTabMovePlan([1, 2, 3], [1, 2, 3])); + [Fact] + public void Sort_field_visibility_is_restored_only_when_import_temporarily_showed_a_hidden_field() + { + var view = FixtureUiSnapshotFactory.Create("fixture-repo").Views.Single(view => view.Name == "View 1"); + + Assert.True(ViewUiImporter.ShouldRestoreSortFieldVisibility(view, "Fixture Number", true)); + Assert.False(ViewUiImporter.ShouldRestoreSortFieldVisibility(view, "Fixture Number", false)); + Assert.False(ViewUiImporter.ShouldRestoreSortFieldVisibility(view, "Title", true)); + } + [Fact] public void Tab_move_plan_uses_one_drag_for_a_rotation() { @@ -935,6 +1329,19 @@ public void Verifier_marks_ui_not_verified_when_one_side_has_no_ui() ScrapedAt = new DateTimeOffset(2026, 7, 5, 0, 0, 0, TimeSpan.Zero), }; + private static BoardColumnLimitSnapshot BoardLimit( + string fieldName, + int limit, + string? option = null, + string? iteration = null) + => new() + { + FieldName = fieldName, + SingleSelectOptionName = option, + IterationTitle = iteration, + Limit = limit, + }; + private static SortByFieldSnapshot Sort(string field, string direction) => new() { Field = field, Direction = direction }; private static ViewSnapshot View( diff --git a/tests/Ghpmv.Browser.Tests/ViewUiSnapshotSerializationTests.cs b/tests/Ghpmv.Browser.Tests/ViewUiSnapshotSerializationTests.cs index 17441e18..2e0dac2c 100644 --- a/tests/Ghpmv.Browser.Tests/ViewUiSnapshotSerializationTests.cs +++ b/tests/Ghpmv.Browser.Tests/ViewUiSnapshotSerializationTests.cs @@ -4,7 +4,7 @@ namespace Ghpmv.Browser.Tests; /// <summary> /// Serialization round-trip for the UI-only view settings added in M6 -/// (<see cref="ViewUiSnapshot"/> incl. slicing, field sums, and roadmap settings). +/// (<see cref="ViewUiSnapshot"/> incl. slicing, field sums, Board limits, and roadmap settings). /// No Playwright required. /// </summary> public class ViewUiSnapshotSerializationTests @@ -73,6 +73,48 @@ public async Task Ui_settings_round_trip_through_snapshot_file() FieldSum = [], }, }, + new ViewSnapshot + { + Number = 6, + Name = "Limited Board", + Layout = "BOARD_LAYOUT", + GroupByFields = [], + SortByFields = [], + VerticalGroupByFields = ["Fixture Select"], + VisibleFields = [], + Ui = new ViewUiSnapshot + { + BoardColumnLimits = + [ + new BoardColumnLimitSnapshot + { + FieldName = "Fixture Select", + SingleSelectOptionName = "Alpha", + Limit = 1, + }, + new BoardColumnLimitSnapshot + { + FieldName = "Fixture Sprint", + IterationTitle = "Sprint 1", + Limit = 3, + }, + ], + }, + }, + new ViewSnapshot + { + Number = 7, + Name = "Unlimited Board", + Layout = "BOARD_LAYOUT", + GroupByFields = [], + SortByFields = [], + VerticalGroupByFields = ["Fixture Select"], + VisibleFields = [], + Ui = new ViewUiSnapshot + { + BoardColumnLimits = [], + }, + }, ], Workflows = [], Items = [], @@ -85,7 +127,7 @@ public async Task Ui_settings_round_trip_through_snapshot_file() await SnapshotFile.SaveAsync(snapshot, directory, cancellationToken); var loaded = await SnapshotFile.LoadAsync(directory, cancellationToken); - Assert.Equal(3, loaded.Views.Count); + Assert.Equal(5, loaded.Views.Count); var roadmap = loaded.Views[0]; Assert.NotNull(roadmap.Ui); @@ -102,8 +144,79 @@ public async Task Ui_settings_round_trip_through_snapshot_file() Assert.Null(loaded.Views[1].Ui); Assert.Empty(loaded.Views[2].Ui!.FieldSum!); + Assert.Collection( + loaded.Views[3].Ui!.BoardColumnLimits!, + limit => + { + Assert.Equal("Fixture Select", limit.FieldName); + Assert.Equal("Alpha", limit.SingleSelectOptionName); + Assert.Null(limit.IterationTitle); + Assert.Equal(1, limit.Limit); + }, + limit => + { + Assert.Equal("Fixture Sprint", limit.FieldName); + Assert.Null(limit.SingleSelectOptionName); + Assert.Equal("Sprint 1", limit.IterationTitle); + Assert.Equal(3, limit.Limit); + }); + Assert.Empty(loaded.Views[4].Ui!.BoardColumnLimits!); + } + + finally + { + Directory.Delete(directory, recursive: true); } + } + [Fact] + public async Task Legacy_ui_settings_without_board_limits_load_as_uncaptured() + { + const string json = + """ + { + "schemaVersion": 2, + "project": { + "title": "Legacy", + "public": false, + "closed": false, + "template": false + }, + "fields": [], + "views": [ + { + "number": 1, + "name": "Board", + "layout": "BOARD_LAYOUT", + "groupByFields": [], + "sortByFields": [], + "verticalGroupByFields": ["Status"], + "visibleFields": [], + "ui": { + "fieldSum": [] + } + } + ], + "workflows": [], + "items": [], + "statusUpdates": [], + "linkedRepositories": [], + "linkedTeams": [] + } + """; + var directory = Path.Combine(Path.GetTempPath(), "ghpmv-browser-tests-" + Guid.NewGuid().ToString("N")); + try + { + Directory.CreateDirectory(directory); + await File.WriteAllTextAsync( + Path.Combine(directory, SnapshotFile.FileName), + json, + TestContext.Current.CancellationToken); + + var loaded = await SnapshotFile.LoadAsync(directory, TestContext.Current.CancellationToken); + + Assert.Null(Assert.Single(loaded.Views).Ui!.BoardColumnLimits); + } finally { Directory.Delete(directory, recursive: true); diff --git a/tests/Ghpmv.Core.Tests/FixtureProjectBuilderTests.cs b/tests/Ghpmv.Core.Tests/FixtureProjectBuilderTests.cs index 3f41eb24..ef38f38d 100644 --- a/tests/Ghpmv.Core.Tests/FixtureProjectBuilderTests.cs +++ b/tests/Ghpmv.Core.Tests/FixtureProjectBuilderTests.cs @@ -477,6 +477,12 @@ public void New_e2e_fixture_adds_a_long_dated_roadmap_item_without_changing_the_ candidate => candidate.Draft?.Title == FixtureProjectBuilder.RoadmapLongTitle); Assert.Contains(item.FieldValues, value => value.FieldName == "Fixture Date" && value.Date is not null); Assert.Contains(item.FieldValues, value => value.FieldName == "Fixture Sprint" && value.IterationTitle is not null); + Assert.Equal(2, extended.Items.Count(candidate => candidate.FieldValues.Any(value => + value.FieldName == "Fixture Select" + && value.SingleSelectOptionName == "Alpha"))); + Assert.Equal(2, extended.Items.Count(candidate => candidate.FieldValues.Any(value => + value.FieldName == "Fixture Sprint" + && value.IterationTitle == "Sprint 0"))); Assert.True(FixtureProjectBuilder.IsRoadmapRenderingItem(item)); Assert.False(FixtureProjectBuilder.IsRoadmapRenderingItem(item with { IsArchived = true })); Assert.False(FixtureProjectBuilder.IsRoadmapRenderingItem(item with diff --git a/tests/Ghpmv.Core.Tests/ProjectVerifierTests.cs b/tests/Ghpmv.Core.Tests/ProjectVerifierTests.cs index 37a7d4fc..0d2c31ad 100644 --- a/tests/Ghpmv.Core.Tests/ProjectVerifierTests.cs +++ b/tests/Ghpmv.Core.Tests/ProjectVerifierTests.cs @@ -115,6 +115,19 @@ private static SingleSelectOptionSnapshot Option(string id, string name, string private static IterationSnapshot Iteration(string id, string title, string startDate, int duration = 14) => new() { Id = id, Title = title, StartDate = startDate, Duration = duration }; + private static BoardColumnLimitSnapshot Limit( + string fieldName, + int limit, + string? option = null, + string? iteration = null) + => new() + { + FieldName = fieldName, + SingleSelectOptionName = option, + IterationTitle = iteration, + Limit = limit, + }; + private static ItemSnapshot DraftItem(int position, string title, string? body, string? status, bool archived = false) => new() { Type = "DRAFT_ISSUE", @@ -871,6 +884,216 @@ public void View_ui_is_not_verified_when_target_ui_was_not_read() Assert.False(report.IsMatch); } + [Fact] + public void Board_column_limits_compare_by_logical_identity_and_report_each_drift() + { + var baseline = BuildSnapshot(); + var sourceBoard = baseline.Views[0] with + { + Name = "Board", + Layout = "BOARD_LAYOUT", + VerticalGroupByFields = ["Status"], + Ui = new ViewUiSnapshot + { + BoardColumnLimits = + [ + Limit("Status", option: "Todo", limit: 1), + Limit("Status", option: "In Progress", limit: 2), + ], + }, + }; + var source = baseline with { Views = [sourceBoard] }; + var target = baseline with + { + Views = + [ + sourceBoard with + { + Number = 9, + Ui = new ViewUiSnapshot + { + BoardColumnLimits = + [ + Limit("Status", option: "Todo", limit: 3), + Limit("Status", option: "Done", limit: 4), + ], + }, + }, + ], + }; + + var report = ProjectVerifier.Compare(source, target); + var differences = report.Differences + .Where(difference => difference.Category == "View" + && difference.Message.Contains("Board limit mismatch", StringComparison.Ordinal)) + .ToList(); + + Assert.Contains(differences, difference => + difference.Message.Contains("'Todo' (source 1, target 3)", StringComparison.Ordinal)); + Assert.Contains(differences, difference => + difference.Message.Contains("'In Progress' (source 2, target unlimited)", StringComparison.Ordinal)); + Assert.Contains(differences, difference => + difference.Message.Contains("'Done' (source unlimited, target 4)", StringComparison.Ordinal)); + Assert.Equal(3, differences.Count); + } + + [Fact] + public void Legacy_uncaptured_Board_limits_do_not_compare_target_state() + { + var source = BuildSnapshot(); + var target = source with + { + Views = + [ + source.Views[0] with + { + Ui = new ViewUiSnapshot + { + BoardColumnLimits = [Limit("Status", option: "Todo", limit: 1)], + }, + }, + ], + }; + + Assert.DoesNotContain(ProjectVerifier.Compare(source, target).Differences, difference => + difference.Category == "View"); + } + + [Fact] + public void Captured_Board_limits_are_not_verified_when_target_capture_is_unavailable() + { + var baseline = BuildSnapshot(); + var source = baseline with + { + Views = + [ + baseline.Views[0] with + { + Ui = new ViewUiSnapshot + { + BoardColumnLimits = [Limit("Status", option: "Todo", limit: 1)], + }, + }, + ], + }; + var target = source with + { + Views = [source.Views[0] with { Ui = new ViewUiSnapshot() }], + }; + + var report = ProjectVerifier.Compare(source, target); + + Assert.Equal(VerifyStatus.NotVerified, report.Status); + Assert.Contains(report.Differences, difference => + difference.Severity == VerifySeverity.Warning + && difference.Message.Contains("Board column limits", StringComparison.Ordinal)); + } + + [Fact] + public void Duplicate_views_with_matching_captured_and_legacy_limit_states_are_verified() + { + var baseline = BuildSnapshot(); + var captured = baseline.Views[0] with + { + Number = 1, + Name = "Duplicate", + Ui = new ViewUiSnapshot + { + BoardColumnLimits = [Limit("Status", option: "Todo", limit: 1)], + }, + }; + var legacy = captured with + { + Number = 2, + Filter = "status:Done", + Ui = new ViewUiSnapshot(), + }; + var source = baseline with { Views = [captured, legacy] }; + var target = baseline with + { + Views = + [ + legacy with { Number = 8 }, + captured with { Number = 9 }, + ], + }; + + var report = ProjectVerifier.Compare(source, target); + + Assert.DoesNotContain(report.Differences, difference => + difference.Category == "View"); + } + + [Fact] + public void Duplicate_views_report_uncaptured_limits_when_other_captured_limits_differ() + { + var baseline = BuildSnapshot(); + var captured = baseline.Views[0] with + { + Name = "Duplicate", + Ui = new ViewUiSnapshot + { + BoardColumnLimits = [Limit("Status", option: "Todo", limit: 1)], + }, + }; + var legacy = captured with { Ui = new ViewUiSnapshot() }; + var source = baseline with { Views = [captured, legacy] }; + var target = baseline with + { + Views = + [ + captured with + { + Number = 8, + Ui = new ViewUiSnapshot + { + BoardColumnLimits = [Limit("Status", option: "Todo", limit: 2)], + }, + }, + legacy with { Number = 9 }, + ], + }; + + var report = ProjectVerifier.Compare(source, target); + + Assert.Equal(VerifyStatus.NotVerified, report.Status); + Assert.Contains(report.Differences, difference => + difference.Severity == VerifySeverity.Warning + && difference.Message.Contains("Board column limits", StringComparison.Ordinal)); + } + + [Fact] + public void Duplicate_views_match_captured_limits_before_legacy_wildcards() + { + var baseline = BuildSnapshot(); + var capturedA = baseline.Views[0] with + { + Name = "Duplicate", + Ui = new ViewUiSnapshot + { + BoardColumnLimits = [Limit("Status", option: "Todo", limit: 1)], + }, + }; + var capturedB = capturedA with + { + Ui = new ViewUiSnapshot + { + BoardColumnLimits = [Limit("Status", option: "Todo", limit: 2)], + }, + }; + var legacy = capturedA with { Ui = new ViewUiSnapshot() }; + var source = baseline with { Views = [legacy, capturedA] }; + var target = baseline with + { + Views = [capturedA with { Number = 8 }, capturedB with { Number = 9 }], + }; + + var report = ProjectVerifier.Compare(source, target); + + Assert.DoesNotContain(report.Differences, difference => + difference.Category == "View"); + } + [Fact] public void Duplicate_view_names_are_compared_as_setting_multisets() {