From 63dacc4e313d399591939dd4c41cd00c447b4b9c Mon Sep 17 00:00:00 2001 From: SIkebe <17608272+SIkebe@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:14:53 +0900 Subject: [PATCH 01/11] Preserve browser-only board column limits GitHub does not expose per-column Board limits through its public APIs, so migrations previously lost this view-specific state. Capture and replay limits through browser automation using logical field/value identities, including clearing, verification, fixtures, and operator guidance. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e89ed2d8-70f0-4a29-ae59-2d644c0ac4c6 --- .github/skills/ghpmv-e2e-validation/SKILL.md | 58 +++- docs/BROWSER_AUTOMATION_PLAN.md | 1 + docs/MANUAL_TEST_PLAN.md | 26 +- docs/MIGRATION_SCOPE.md | 4 +- docs/ui-maps/projects-ui-discovery.md | 13 +- src/Ghpmv.Cli/Program.cs | 44 ++- .../Browser/BoardColumnLimitObserver.cs | 160 ++++++++++ src/Ghpmv.Core/Browser/BoardColumnLimitUi.cs | 271 +++++++++++++++++ .../Browser/FixtureUiSnapshotFactory.cs | 126 +++++++- src/Ghpmv.Core/Browser/Sel.cs | 50 +++ src/Ghpmv.Core/Browser/ViewUiExporter.cs | 36 ++- src/Ghpmv.Core/Browser/ViewUiImporter.cs | 267 +++++++++++++++- src/Ghpmv.Core/Snapshot/ProjectSnapshot.cs | 22 ++ src/Ghpmv.Core/Verify/ProjectVerifier.cs | 121 ++++++++ .../BrowserRoundTripTests.cs | 36 +++ tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs | 287 +++++++++++++++++- .../ViewUiSnapshotSerializationTests.cs | 117 ++++++- .../FixtureProjectBuilderTests.cs | 6 + .../Ghpmv.Core.Tests/ProjectVerifierTests.cs | 153 ++++++++++ 19 files changed, 1751 insertions(+), 47 deletions(-) create mode 100644 src/Ghpmv.Core/Browser/BoardColumnLimitObserver.cs create mode 100644 src/Ghpmv.Core/Browser/BoardColumnLimitUi.cs diff --git a/.github/skills/ghpmv-e2e-validation/SKILL.md b/.github/skills/ghpmv-e2e-validation/SKILL.md index 12aaa9a8..5f347921 100644 --- a/.github/skills/ghpmv-e2e-validation/SKILL.md +++ b/.github/skills/ghpmv-e2e-validation/SKILL.md @@ -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を各段階で確認する。 @@ -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,27 @@ 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 ($expected in $expectedBoardLimits) { + $views = @($snapshot.views | Where-Object name -eq $expected.View) + if ($views.Count -ne 1 -or $null -eq $views[0].ui.boardColumnLimits) { + Stop-FieldSumSnapshotCheck "View '$($expected.View)' is missing captured boardColumnLimits." + return + } + $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 + } +} +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 +1184,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 +1558,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 +1582,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 +1614,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 +1833,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 +1873,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 +1890,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 +2016,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 +2041,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 +2069,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..a338ff00 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 column 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..ee88ae5d 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,7 +638,7 @@ 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で確認します。 @@ -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列が一致し、limit=1の列はitem count超過表示になる。 +- [ ] 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..6ae44a69 100644 --- a/docs/ui-maps/projects-ui-discovery.md +++ b/docs/ui-maps/projects-ui-discovery.md @@ -73,7 +73,7 @@ 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] @@ -139,3 +139,14 @@ Important limitations: 8. **ソートキーのフィールドは仮想列として表示される**: Fields オーバーレイで aria-checked=true になるが GraphQL `visibleFields` には永続化されない(uncheck→再 check でも変わらない)。import 側は desired 集合にソート列を含めて誤 uncheck を防止する 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(`aria-label="Column context menu"`のiconを含むbutton)からmenuitem `Set column limit`を開く。`Column limit` inputへ正整数を入力してdialog内の`Save`を押すと直ちに永続化され、View-levelの`Save view`は不要。上限削除はinputを空にして同じ`Save`を押す。 + +- 上限ありの列はheaderに`<current count> / <limit>`を表示し、current countがlimitを超えるとhighlightされる。上限はsoft limitであり、item追加やautomationを禁止しない。 +- 上限なしは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、counter DOMは公開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..767f6675 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") { @@ -1494,8 +1494,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 +1656,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 +1698,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 +1731,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..3eb6365e --- /dev/null +++ b/src/Ghpmv.Core/Browser/BoardColumnLimitObserver.cs @@ -0,0 +1,160 @@ +using System.Globalization; +using System.Text.RegularExpressions; +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 partial 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 actions = Sel.BoardColumnActionsButton(page, columnName); + var counter = Sel.BoardColumnLimitCounter(Sel.BoardColumn(actions)); + await counter.WaitForAsync(new() + { + State = WaitForSelectorState.Visible, + Timeout = 15_000, + }).ConfigureAwait(false); + var counterText = await counter.InnerTextAsync().ConfigureAwait(false); + var (count, renderedLimit) = ParseCounter(counterText); + if (renderedLimit != limit.Limit || count <= renderedLimit) + { + throw new InvalidOperationException( + $"view '{board.Name}': column '{columnName}' did not render an exceeded limit (counter '{counterText}')"); + } + } + + 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"); + } + } + + internal static (int Count, int Limit) ParseCounter(string text) + { + var match = Counter().Match(text); + if (!match.Success + || !int.TryParse(match.Groups["count"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var count) + || !int.TryParse(match.Groups["limit"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var limit)) + { + throw new InvalidOperationException($"Invalid Board column limit counter '{text}'"); + } + + return (count, limit); + } + + 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}'"), + }; + + [GeneratedRegex( + @"^\s*(?<count>\d+)\s*/\s*(?<limit>\d+)\s*$", + RegexOptions.CultureInvariant)] + private static partial Regex Counter(); +} diff --git a/src/Ghpmv.Core/Browser/BoardColumnLimitUi.cs b/src/Ghpmv.Core/Browser/BoardColumnLimitUi.cs new file mode 100644 index 00000000..bf251a0a --- /dev/null +++ b/src/Ghpmv.Core/Browser/BoardColumnLimitUi.cs @@ -0,0 +1,271 @@ +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"); + } + + 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 warnings = new List<string>(); + var columns = await ReadDisplayedColumnsAsync(page, cancellationToken).ConfigureAwait(false); + var displayedNames = columns.Select(column => column.Name).ToHashSet(StringComparer.Ordinal); + var desiredByName = desiredLimits + .Where(limit => string.Equals(limit.FieldName, field.Name, StringComparison.Ordinal)) + .ToDictionary(GetValueName, StringComparer.Ordinal); + + foreach (var desired in desiredByName) + { + if (!displayedNames.Contains(desired.Key)) + { + warnings.Add( + $"view '{view.Name}': target {ViewUiImporter.DescribeColumn(desired.Value)} was not found; its limit was not applied"); + } + } + + foreach (var column in columns) + { + if (!ValueExists(field, column.Name)) + { + continue; + } + + var desired = desiredByName.TryGetValue(column.Name, out var configured) + ? configured.Limit + : (int?)null; + var current = await ReadLimitAsync(page, column.Name, cancellationToken).ConfigureAwait(false); + if (current == desired) + { + continue; + } + + await WriteLimitAsync(page, column.Name, desired, cancellationToken).ConfigureAwait(false); + } + + return warnings; + } + + 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); + var item = Sel.BoardColumnLimitMenuItem(page); + await item.WaitForAsync().ConfigureAwait(false); + await item.ClickAsync().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); + 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); + var item = Sel.BoardColumnLimitMenuItem(page); + await item.WaitForAsync().ConfigureAwait(false); + await item.ClickAsync().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); + cancellationToken.ThrowIfCancellationRequested(); + await Sel.BoardColumnLimitSaveButton(overlay).ClickAsync().ConfigureAwait(false); + await input.WaitForAsync(new() { State = WaitForSelectorState.Hidden }).ConfigureAwait(false); + } + + 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) + => field.DataType switch + { + "SINGLE_SELECT" => field.Options?.Any(option => + string.Equals(option.Name, value, StringComparison.Ordinal)) is true, + "ITERATION" => field.IterationConfiguration is { } configuration + && configuration.Iterations.Concat(configuration.CompletedIterations) + .Any(iteration => string.Equals(iteration.Title, value, StringComparison.Ordinal)), + _ => false, + }; + + 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); +} 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..a172249b 100644 --- a/src/Ghpmv.Core/Browser/Sel.cs +++ b/src/Ghpmv.Core/Browser/Sel.cs @@ -20,6 +20,9 @@ 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 | RegexOptions.CultureInvariant); /// <summary>Filter-bar "View" button that opens the view configuration menu.</summary> public static ILocator ViewMenuButton(IPage page) @@ -71,6 +74,53 @@ 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.Locator("button:has([aria-label='Column context menu'])"); + + /// <summary>The actions button for one displayed Board column.</summary> + public static ILocator BoardColumnActionsButton(IPage page, string columnName) + => page.GetByRole(AriaRole.Heading, new() { Name = columnName, Exact = true }) + .First + .Locator( + "xpath=ancestor::*[.//button[.//*[@aria-label='Column context menu']]][1]") + .Locator("button:has([aria-label='Column context menu'])") + .First; + + /// <summary>"Set column limit" in an open Board column menu.</summary> + public static ILocator BoardColumnLimitMenuItem(IPage page) + => page.GetByRole(AriaRole.Menuitem, new() { Name = "Set column 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::*[.//button[normalize-space()='Save']][1]"); + + /// <summary>Save button within the Board column-limit overlay.</summary> + public static ILocator BoardColumnLimitSaveButton(ILocator overlay) + => overlay.GetByRole(AriaRole.Button, new() { Name = "Save", Exact = true }); + + /// <summary>Rendered Board column containing the supplied actions button.</summary> + public static ILocator BoardColumn(ILocator actionsButton) + => actionsButton.Locator( + "xpath=ancestor::*[.//*[@role='heading'] and .//button[.//*[@aria-label='Column context menu']]][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 "current cards / limit" counter in a limited Board column.</summary> + public static ILocator BoardColumnLimitCounter(ILocator column) + => column.GetByText(new Regex( + @"^\s*\d+\s*/\s*\d+\s*$", + RegexOptions.CultureInvariant)).First; + /// <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..f6f61471 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, @@ -630,6 +801,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 @@ -671,6 +843,20 @@ await TrySetSingleAsync( cancellationToken).ConfigureAwait(false); } + if (isBoard && view.Ui?.BoardColumnLimits is { } boardColumnLimits) + { + // 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); + } + if (view.SortByFields.Count > 0) { var sort = view.SortByFields[0]; @@ -774,16 +960,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 +1080,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 +1124,7 @@ private static async Task<PersistedViewSettings> ReadPersistedSettingsAsync( } } - return new PersistedViewSettings( + settings = new PersistedViewSettings( groupBy, columnBy, sliceBy, @@ -944,6 +1137,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 +1301,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 +1353,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 +1395,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, 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..7cc9417e 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,10 +943,64 @@ 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)); + if (capturedIndex >= 0) + { + availableTargets.RemoveAt(capturedIndex); + continue; + } + + if (availableTargets.Any(targetView => + targetView.Ui is not null + && ViewApiEquals(sourceView, targetView) + && ViewUiEqualsWithoutBoardLimits(sourceView.Ui!, targetView.Ui))) + { + 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( IReadOnlyList<ViewSnapshot> source, IReadOnlyList<ViewSnapshot> target, @@ -1022,10 +1077,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 @@ -1110,6 +1188,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 +1221,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..dbe86c08 100644 --- a/tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs +++ b/tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs @@ -81,6 +81,77 @@ 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); + } + + [Theory] + [InlineData("2 / 1", 2, 1)] + [InlineData(" 12/3 ", 12, 3)] + public void Parse_Board_column_counter_reads_count_and_limit( + string text, + int expectedCount, + int expectedLimit) + => Assert.Equal( + (expectedCount, expectedLimit), + BoardColumnLimitObserver.ParseCounter(text)); + + [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 +220,145 @@ 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); + } + [Theory] [InlineData("TABLE_LAYOUT")] [InlineData("BOARD_LAYOUT")] @@ -390,6 +600,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 +715,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 +733,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 +793,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 +815,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); @@ -935,6 +1201,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..8b05d119 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,146 @@ 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_view_names_are_compared_as_setting_multisets() { From 4c85172a59f205bae8a4c737b29d47ba19aef49d Mon Sep 17 00:00:00 2001 From: SIkebe <17608272+SIkebe@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:26:41 +0900 Subject: [PATCH 02/11] Correct Board limit validation paths Rendering checks must resolve both fixture Boards, fixture snapshots must reject stale extra limits, and duplicate Views must not hide unavailable target captures behind a mismatched captured View. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e89ed2d8-70f0-4a29-ae59-2d644c0ac4c6 --- .github/skills/ghpmv-e2e-validation/SKILL.md | 20 +++++++--- src/Ghpmv.Cli/Program.cs | 8 +++- src/Ghpmv.Core/Verify/ProjectVerifier.cs | 9 +++-- .../Ghpmv.Core.Tests/ProjectVerifierTests.cs | 38 +++++++++++++++++++ 4 files changed, 65 insertions(+), 10 deletions(-) diff --git a/.github/skills/ghpmv-e2e-validation/SKILL.md b/.github/skills/ghpmv-e2e-validation/SKILL.md index 5f347921..0b922fba 100644 --- a/.github/skills/ghpmv-e2e-validation/SKILL.md +++ b/.github/skills/ghpmv-e2e-validation/SKILL.md @@ -551,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 の入力を求めない。 @@ -906,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 表 @@ -991,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 を警告なしで取得できるか確認する。 @@ -1122,12 +1122,20 @@ $expectedBoardLimits = @( [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 ($expected in $expectedBoardLimits) { - $views = @($snapshot.views | Where-Object name -eq $expected.View) +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 '$($expected.View)' is missing captured 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 }) diff --git a/src/Ghpmv.Cli/Program.cs b/src/Ghpmv.Cli/Program.cs index 767f6675..9f0aebc2 100644 --- a/src/Ghpmv.Cli/Program.cs +++ b/src/Ghpmv.Cli/Program.cs @@ -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") diff --git a/src/Ghpmv.Core/Verify/ProjectVerifier.cs b/src/Ghpmv.Core/Verify/ProjectVerifier.cs index 7cc9417e..193e8b73 100644 --- a/src/Ghpmv.Core/Verify/ProjectVerifier.cs +++ b/src/Ghpmv.Core/Verify/ProjectVerifier.cs @@ -965,7 +965,10 @@ private static bool HasUncapturedTargetBoardLimits( var capturedIndex = availableTargets.FindIndex(targetView => targetView.Ui?.BoardColumnLimits is not null && ViewApiEquals(sourceView, targetView) - && ViewUiEqualsWithoutBoardLimits(sourceView.Ui!, targetView.Ui)); + && ViewUiEqualsWithoutBoardLimits(sourceView.Ui!, targetView.Ui) + && BoardColumnLimitsEqual( + sourceView.Ui!.BoardColumnLimits, + targetView.Ui.BoardColumnLimits)); if (capturedIndex >= 0) { availableTargets.RemoveAt(capturedIndex); @@ -973,9 +976,9 @@ private static bool HasUncapturedTargetBoardLimits( } if (availableTargets.Any(targetView => - targetView.Ui is not null + targetView.Ui is { BoardColumnLimits: null } targetUi && ViewApiEquals(sourceView, targetView) - && ViewUiEqualsWithoutBoardLimits(sourceView.Ui!, targetView.Ui))) + && ViewUiEqualsWithoutBoardLimits(sourceView.Ui!, targetUi))) { return true; } diff --git a/tests/Ghpmv.Core.Tests/ProjectVerifierTests.cs b/tests/Ghpmv.Core.Tests/ProjectVerifierTests.cs index 8b05d119..f1eaca5c 100644 --- a/tests/Ghpmv.Core.Tests/ProjectVerifierTests.cs +++ b/tests/Ghpmv.Core.Tests/ProjectVerifierTests.cs @@ -1024,6 +1024,44 @@ public void Duplicate_views_with_matching_captured_and_legacy_limit_states_are_v 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_view_names_are_compared_as_setting_multisets() { From e7fe516b39040f98765c0d3e1bfef17b441d0a51 Mon Sep 17 00:00:00 2001 From: SIkebe <17608272+SIkebe@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:39:15 +0900 Subject: [PATCH 03/11] Fail closed when Board columns are missing A missing desired target column must not cause complete-set reconciliation to clear another displayed column. Build a deterministic reconciliation plan that suppresses all writes for the View when any desired column is unavailable, and keep the six-View fixture documentation consistent. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e89ed2d8-70f0-4a29-ae59-2d644c0ac4c6 --- .github/skills/ghpmv-e2e-validation/SKILL.md | 2 +- docs/ui-maps/projects-ui-discovery.md | 1 + src/Ghpmv.Core/Browser/BoardColumnLimitUi.cs | 68 +++++++++++++------ tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs | 34 ++++++++++ 4 files changed, 85 insertions(+), 20 deletions(-) diff --git a/.github/skills/ghpmv-e2e-validation/SKILL.md b/.github/skills/ghpmv-e2e-validation/SKILL.md index 0b922fba..7dea0ec6 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` | diff --git a/docs/ui-maps/projects-ui-discovery.md b/docs/ui-maps/projects-ui-discovery.md index 6ae44a69..f7dc8a9e 100644 --- a/docs/ui-maps/projects-ui-discovery.md +++ b/docs/ui-maps/projects-ui-discovery.md @@ -79,6 +79,7 @@ GitHub Docs と public schema introspection で Text / Number / Single-select de - 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 用) diff --git a/src/Ghpmv.Core/Browser/BoardColumnLimitUi.cs b/src/Ghpmv.Core/Browser/BoardColumnLimitUi.cs index bf251a0a..1920405b 100644 --- a/src/Ghpmv.Core/Browser/BoardColumnLimitUi.cs +++ b/src/Ghpmv.Core/Browser/BoardColumnLimitUi.cs @@ -42,9 +42,39 @@ public static async Task<IReadOnlyList<string>> ApplyAsync( CancellationToken cancellationToken) { var field = ResolveColumnField(view, fields); - var warnings = new List<string>(); var columns = await ReadDisplayedColumnsAsync(page, cancellationToken).ConfigureAwait(false); - var displayedNames = columns.Select(column => column.Name).ToHashSet(StringComparer.Ordinal); + 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 displayedNames = displayedColumnNames.ToHashSet(StringComparer.Ordinal); var desiredByName = desiredLimits .Where(limit => string.Equals(limit.FieldName, field.Name, StringComparison.Ordinal)) .ToDictionary(GetValueName, StringComparer.Ordinal); @@ -58,26 +88,20 @@ public static async Task<IReadOnlyList<string>> ApplyAsync( } } - foreach (var column in columns) + if (warnings.Count > 0) { - if (!ValueExists(field, column.Name)) - { - continue; - } - - var desired = desiredByName.TryGetValue(column.Name, out var configured) - ? configured.Limit - : (int?)null; - var current = await ReadLimitAsync(page, column.Name, cancellationToken).ConfigureAwait(false); - if (current == desired) - { - continue; - } - - await WriteLimitAsync(page, column.Name, desired, cancellationToken).ConfigureAwait(false); + return new ReconciliationPlan([], warnings); } - return 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 int? ParseLimit(string? value) @@ -268,4 +292,10 @@ private static string GetValueName(BoardColumnLimitSnapshot limit) $"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/tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs b/tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs index dbe86c08..8206cdf1 100644 --- a/tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs +++ b/tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs @@ -359,6 +359,40 @@ public void CollectPreflightWarnings_reports_malformed_and_missing_Board_columns 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); + } + [Theory] [InlineData("TABLE_LAYOUT")] [InlineData("BOARD_LAYOUT")] From 4681a4293092056251b1bd57d80edf7feb5656bc Mon Sep 17 00:00:00 2001 From: SIkebe <17608272+SIkebe@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:49:01 +0900 Subject: [PATCH 04/11] Prevent unsafe Board limit reconciliation Board limits must not be written after column selection or identity validation fails, and legacy wildcard snapshots must not make duplicate-View matching depend on enumeration order. Gate writes on prerequisites, validate runtime identities, and use bipartite multiset matching. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e89ed2d8-70f0-4a29-ae59-2d644c0ac4c6 --- src/Ghpmv.Core/Browser/BoardColumnLimitUi.cs | 55 ++++++++++++++++++- src/Ghpmv.Core/Browser/ViewUiImporter.cs | 17 +++++- src/Ghpmv.Core/Verify/ProjectVerifier.cs | 30 ++++++++-- tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs | 40 ++++++++++++++ .../Ghpmv.Core.Tests/ProjectVerifierTests.cs | 32 +++++++++++ 5 files changed, 162 insertions(+), 12 deletions(-) diff --git a/src/Ghpmv.Core/Browser/BoardColumnLimitUi.cs b/src/Ghpmv.Core/Browser/BoardColumnLimitUi.cs index 1920405b..9843ffba 100644 --- a/src/Ghpmv.Core/Browser/BoardColumnLimitUi.cs +++ b/src/Ghpmv.Core/Browser/BoardColumnLimitUi.cs @@ -75,9 +75,58 @@ internal static ReconciliationPlan BuildReconciliationPlan( { var warnings = new List<string>(); var displayedNames = displayedColumnNames.ToHashSet(StringComparer.Ordinal); - var desiredByName = desiredLimits - .Where(limit => string.Equals(limit.FieldName, field.Name, StringComparison.Ordinal)) - .ToDictionary(GetValueName, StringComparer.Ordinal); + 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 desired in desiredByName) { diff --git a/src/Ghpmv.Core/Browser/ViewUiImporter.cs b/src/Ghpmv.Core/Browser/ViewUiImporter.cs index f6f61471..96920ed1 100644 --- a/src/Ghpmv.Core/Browser/ViewUiImporter.cs +++ b/src/Ghpmv.Core/Browser/ViewUiImporter.cs @@ -828,13 +828,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"], @@ -843,7 +849,7 @@ await TrySetSingleAsync( cancellationToken).ConfigureAwait(false); } - if (isBoard && view.Ui?.BoardColumnLimits is { } boardColumnLimits) + 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. @@ -856,6 +862,11 @@ await TrySetSingleAsync( 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) { diff --git a/src/Ghpmv.Core/Verify/ProjectVerifier.cs b/src/Ghpmv.Core/Verify/ProjectVerifier.cs index 193e8b73..d1f26c10 100644 --- a/src/Ghpmv.Core/Verify/ProjectVerifier.cs +++ b/src/Ghpmv.Core/Verify/ProjectVerifier.cs @@ -1127,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) diff --git a/tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs b/tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs index 8206cdf1..36d7e252 100644 --- a/tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs +++ b/tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs @@ -393,6 +393,46 @@ public void Board_limit_reconciliation_skips_all_writes_when_a_target_column_is_ Assert.Empty(plan.Targets); } + [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")] diff --git a/tests/Ghpmv.Core.Tests/ProjectVerifierTests.cs b/tests/Ghpmv.Core.Tests/ProjectVerifierTests.cs index f1eaca5c..0d2c31ad 100644 --- a/tests/Ghpmv.Core.Tests/ProjectVerifierTests.cs +++ b/tests/Ghpmv.Core.Tests/ProjectVerifierTests.cs @@ -1062,6 +1062,38 @@ captured with && 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() { From 6d62b06d59c6994789f42f0e3b08988ae901e618 Mon Sep 17 00:00:00 2001 From: SIkebe <17608272+SIkebe@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:14:08 +0900 Subject: [PATCH 05/11] Match Board automation to the live GitHub UI The live Projects UI exposes column actions and limit dialogs differently from the earlier discovery contract, and tab reordering requires pointer activation. Align selectors, dragging, and render observation with the verified DOM so browser-assisted migrations preserve Board limits reliably. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e89ed2d8-70f0-4a29-ae59-2d644c0ac4c6 --- docs/ui-maps/projects-ui-discovery.md | 6 +-- .../Browser/BoardColumnLimitObserver.cs | 14 ++----- src/Ghpmv.Core/Browser/Sel.cs | 39 +++++++++++-------- src/Ghpmv.Core/Browser/ViewUiImporter.cs | 26 +++++++++---- 4 files changed, 48 insertions(+), 37 deletions(-) diff --git a/docs/ui-maps/projects-ui-discovery.md b/docs/ui-maps/projects-ui-discovery.md index f7dc8a9e..6e182ce6 100644 --- a/docs/ui-maps/projects-ui-discovery.md +++ b/docs/ui-maps/projects-ui-discovery.md @@ -143,11 +143,11 @@ Important limitations: ## Board column limit UI contract (2026-08-28) -GitHub公式手順では、Board列名の横にあるcontext menu(`aria-label="Column context menu"`のiconを含むbutton)からmenuitem `Set column limit`を開く。`Column limit` inputへ正整数を入力してdialog内の`Save`を押すと直ちに永続化され、View-levelの`Save view`は不要。上限削除はinputを空にして同じ`Save`を押す。 +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`を押す。 -- 上限ありの列はheaderに`<current count> / <limit>`を表示し、current countがlimitを超えるとhighlightされる。上限はsoft limitであり、item追加やautomationを禁止しない。 +- 上限は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、counter DOMは公開APIではなくGitHub UI依存であるため、変更時はBrowser E2Eで再確認する。 +- 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.Core/Browser/BoardColumnLimitObserver.cs b/src/Ghpmv.Core/Browser/BoardColumnLimitObserver.cs index 3eb6365e..8214fe9a 100644 --- a/src/Ghpmv.Core/Browser/BoardColumnLimitObserver.cs +++ b/src/Ghpmv.Core/Browser/BoardColumnLimitObserver.cs @@ -83,19 +83,11 @@ public async Task ValidateFixtureAsync( foreach (var limit in board.Ui.BoardColumnLimits!.Where(limit => limit.Limit == 1)) { var columnName = limit.SingleSelectOptionName ?? limit.IterationTitle!; - var actions = Sel.BoardColumnActionsButton(page, columnName); - var counter = Sel.BoardColumnLimitCounter(Sel.BoardColumn(actions)); - await counter.WaitForAsync(new() - { - State = WaitForSelectorState.Visible, - Timeout = 15_000, - }).ConfigureAwait(false); - var counterText = await counter.InnerTextAsync().ConfigureAwait(false); - var (count, renderedLimit) = ParseCounter(counterText); - if (renderedLimit != limit.Limit || count <= renderedLimit) + 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 an exceeded limit (counter '{counterText}')"); + $"view '{board.Name}': column '{columnName}' did not render more cards ({count}) than its limit ({limit.Limit})"); } } diff --git a/src/Ghpmv.Core/Browser/Sel.cs b/src/Ghpmv.Core/Browser/Sel.cs index a172249b..3f5b173c 100644 --- a/src/Ghpmv.Core/Browser/Sel.cs +++ b/src/Ghpmv.Core/Browser/Sel.cs @@ -22,7 +22,8 @@ internal static class Sel private static readonly Regex ViewMenuButtonName = new("^(Unsaved changes )?View$"); private static readonly Regex BoardColumnLimitControlName = new( "column limit", - RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + 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) @@ -76,20 +77,17 @@ public static ILocator UnsavedChangesStatus(IPage page) /// <summary>Actions buttons for the currently displayed Board columns.</summary> public static ILocator BoardColumnActionsButtons(IPage page) - => page.Locator("button:has([aria-label='Column context menu'])"); + => 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.Heading, new() { Name = columnName, Exact = true }) - .First - .Locator( - "xpath=ancestor::*[.//button[.//*[@aria-label='Column context menu']]][1]") - .Locator("button:has([aria-label='Column context menu'])") - .First; + => page.GetByRole( + AriaRole.Button, + new() { Name = $"Actions for column: {columnName}", Exact = true }).First; - /// <summary>"Set column limit" in an open Board column menu.</summary> + /// <summary>"Set limit" in an open Board column menu.</summary> public static ILocator BoardColumnLimitMenuItem(IPage page) - => page.GetByRole(AriaRole.Menuitem, new() { Name = "Set column limit", Exact = true }).Last; + => 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) @@ -99,17 +97,18 @@ public static ILocator BoardColumnLimitInput(IPage page) /// <summary>The closest column-limit overlay containing the numeric input and Save button.</summary> public static ILocator BoardColumnLimitOverlay(ILocator input) - => input.Locator( - "xpath=ancestor::*[.//button[normalize-space()='Save']][1]"); + => 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() { Name = "Save", Exact = true }); + => 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::*[.//*[@role='heading'] and .//button[.//*[@aria-label='Column context menu']]][1]"); + "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) @@ -118,8 +117,16 @@ public static ILocator BoardColumnHeading(ILocator actionsButton) /// <summary>Rendered "current cards / limit" counter in a limited Board column.</summary> public static ILocator BoardColumnLimitCounter(ILocator column) => column.GetByText(new Regex( - @"^\s*\d+\s*/\s*\d+\s*$", - RegexOptions.CultureInvariant)).First; + @"^\s*\d+\s*/\s*\d+\s*$")).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) diff --git a/src/Ghpmv.Core/Browser/ViewUiImporter.cs b/src/Ghpmv.Core/Browser/ViewUiImporter.cs index 96920ed1..fba5ac8b 100644 --- a/src/Ghpmv.Core/Browser/ViewUiImporter.cs +++ b/src/Ghpmv.Core/Browser/ViewUiImporter.cs @@ -588,14 +588,26 @@ private async Task ReorderTabsAsync( 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; + var targetX = anchorBox.X + (move.PlaceBefore ? 2 : Math.Max(2, anchorBox.Width - 2)); + var targetY = anchorBox.Y + anchorBox.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 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( From 6f2cd3762437369b56d934daf52521f97501cd86 Mon Sep 17 00:00:00 2001 From: SIkebe <17608272+SIkebe@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:46:28 +0900 Subject: [PATCH 06/11] Restore hidden sort fields after browser setup A fresh browser import temporarily exposes hidden fields so GitHub can select them as sort keys. Hide only fields introduced by that workaround so the first import preserves the snapshot's visible-field contract instead of requiring a second convergence pass. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e89ed2d8-70f0-4a29-ae59-2d644c0ac4c6 --- src/Ghpmv.Core/Browser/ViewUiImporter.cs | 70 ++++++++++++++++++- tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs | 10 +++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/src/Ghpmv.Core/Browser/ViewUiImporter.cs b/src/Ghpmv.Core/Browser/ViewUiImporter.cs index fba5ac8b..a1a8eb6e 100644 --- a/src/Ghpmv.Core/Browser/ViewUiImporter.cs +++ b/src/Ghpmv.Core/Browser/ViewUiImporter.cs @@ -885,12 +885,20 @@ await TrySetSingleAsync( 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 @@ -1609,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, @@ -1622,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/tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs b/tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs index 36d7e252..12afeb92 100644 --- a/tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs +++ b/tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs @@ -940,6 +940,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() { From 65d19086a6f6b71e47e96fb57efd7d16bdcab322 Mon Sep 17 00:00:00 2001 From: SIkebe <17608272+SIkebe@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:49:45 +0900 Subject: [PATCH 07/11] Keep distant tabs addressable during drag Starting a drag only after scrolling both endpoints can move the source outside a narrow tab strip. Activate the source first, then scroll the destination while the drag is active, and align the documented Board menu label with the verified UI. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e89ed2d8-70f0-4a29-ae59-2d644c0ac4c6 --- docs/BROWSER_AUTOMATION_PLAN.md | 2 +- src/Ghpmv.Core/Browser/ViewUiImporter.cs | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/BROWSER_AUTOMATION_PLAN.md b/docs/BROWSER_AUTOMATION_PLAN.md index a338ff00..378efb2b 100644 --- a/docs/BROWSER_AUTOMATION_PLAN.md +++ b/docs/BROWSER_AUTOMATION_PLAN.md @@ -23,7 +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 column limit") | nullable complete-set。field名 + Single-select option名 / Iteration titleで識別し、null=未取得、空集合=取得済み上限なし | +| **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/src/Ghpmv.Core/Browser/ViewUiImporter.cs b/src/Ghpmv.Core/Browser/ViewUiImporter.cs index a1a8eb6e..449d2e57 100644 --- a/src/Ghpmv.Core/Browser/ViewUiImporter.cs +++ b/src/Ghpmv.Core/Browser/ViewUiImporter.cs @@ -585,21 +585,21 @@ 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"); 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; - var targetX = anchorBox.X + (move.PlaceBefore ? 2 : Math.Max(2, anchorBox.Width - 2)); - var targetY = anchorBox.Y + anchorBox.Height / 2; await page.Mouse.MoveAsync(sourceX, sourceY).ConfigureAwait(false); await page.Mouse.DownAsync().ConfigureAwait(false); try { 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); } From c3261b23c9ad2b2389ccb529d1e8afa54f9bb7d9 Mon Sep 17 00:00:00 2001 From: SIkebe <17608272+SIkebe@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:02:38 +0900 Subject: [PATCH 08/11] Fail closed for hidden Board columns Board limit snapshots are complete sets, so reading or reconciling only rendered columns can silently lose limits. Require every logical option or iteration to be displayed, and remove the stale counter contract that the live swimlane UI does not render. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e89ed2d8-70f0-4a29-ae59-2d644c0ac4c6 --- .../Browser/BoardColumnLimitObserver.cs | 20 +----- src/Ghpmv.Core/Browser/BoardColumnLimitUi.cs | 46 ++++++++++--- src/Ghpmv.Core/Browser/Sel.cs | 5 -- tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs | 66 +++++++++++++++---- 4 files changed, 93 insertions(+), 44 deletions(-) diff --git a/src/Ghpmv.Core/Browser/BoardColumnLimitObserver.cs b/src/Ghpmv.Core/Browser/BoardColumnLimitObserver.cs index 8214fe9a..cc536181 100644 --- a/src/Ghpmv.Core/Browser/BoardColumnLimitObserver.cs +++ b/src/Ghpmv.Core/Browser/BoardColumnLimitObserver.cs @@ -1,5 +1,4 @@ using System.Globalization; -using System.Text.RegularExpressions; using Ghpmv.Core.GitHub; using Ghpmv.Core.Snapshot; using Microsoft.Playwright; @@ -7,7 +6,7 @@ namespace Ghpmv.Core.Browser; /// <summary>Validates configured, unlimited, and exceeded limits in the standard Board fixture.</summary> -public sealed partial class BoardColumnLimitObserver +public sealed class BoardColumnLimitObserver { private readonly BrowserSession _session; @@ -110,19 +109,6 @@ internal static void ValidateLimits( } } - internal static (int Count, int Limit) ParseCounter(string text) - { - var match = Counter().Match(text); - if (!match.Success - || !int.TryParse(match.Groups["count"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var count) - || !int.TryParse(match.Groups["limit"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var limit)) - { - throw new InvalidOperationException($"Invalid Board column limit counter '{text}'"); - } - - return (count, limit); - } - private static bool SameColumn(BoardColumnLimitSnapshot first, BoardColumnLimitSnapshot second) => string.Equals(first.FieldName, second.FieldName, StringComparison.Ordinal) && string.Equals(first.SingleSelectOptionName, second.SingleSelectOptionName, StringComparison.Ordinal) @@ -145,8 +131,4 @@ private static IEnumerable<string> GetValueNames(FieldSnapshot field) $"field '{field.Name}' has unsupported Board column type '{field.DataType}'"), }; - [GeneratedRegex( - @"^\s*(?<count>\d+)\s*/\s*(?<limit>\d+)\s*$", - RegexOptions.CultureInvariant)] - private static partial Regex Counter(); } diff --git a/src/Ghpmv.Core/Browser/BoardColumnLimitUi.cs b/src/Ghpmv.Core/Browser/BoardColumnLimitUi.cs index 9843ffba..9892a0fa 100644 --- a/src/Ghpmv.Core/Browser/BoardColumnLimitUi.cs +++ b/src/Ghpmv.Core/Browser/BoardColumnLimitUi.cs @@ -20,6 +20,15 @@ public static async Task<IReadOnlyList<BoardColumnLimitSnapshot>> ReadAsync( 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); @@ -74,7 +83,6 @@ internal static ReconciliationPlan BuildReconciliationPlan( IReadOnlyList<string> displayedColumnNames) { var warnings = new List<string>(); - var displayedNames = displayedColumnNames.ToHashSet(StringComparer.Ordinal); var desiredByName = new Dictionary<string, BoardColumnLimitSnapshot>(StringComparer.Ordinal); foreach (var limit in desiredLimits) { @@ -128,12 +136,17 @@ internal static ReconciliationPlan BuildReconciliationPlan( return new ReconciliationPlan([], warnings); } - foreach (var desired in desiredByName) + foreach (var missingColumn in FindMissingLogicalColumns(field, displayedColumnNames)) { - if (!displayedNames.Contains(desired.Key)) + 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 {ViewUiImporter.DescribeColumn(desired.Value)} was not found; its limit was not applied"); + $"view '{view.Name}': target {field.DataType} column '{field.Name}' / '{missingColumn}' was hidden or missing; no Board limits were changed"); } } @@ -153,6 +166,18 @@ internal static ReconciliationPlan BuildReconciliationPlan( 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)) @@ -324,14 +349,17 @@ private static BoardColumnLimitSnapshot CreateSnapshot( } 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?.Any(option => - string.Equals(option.Name, value, StringComparison.Ordinal)) is true, + "SINGLE_SELECT" => field.Options?.Select(option => option.Name) ?? [], "ITERATION" => field.IterationConfiguration is { } configuration - && configuration.Iterations.Concat(configuration.CompletedIterations) - .Any(iteration => string.Equals(iteration.Title, value, StringComparison.Ordinal)), - _ => false, + ? configuration.Iterations.Concat(configuration.CompletedIterations) + .Select(iteration => iteration.Title) + : [], + _ => [], }; private static string GetValueName(BoardColumnLimitSnapshot limit) diff --git a/src/Ghpmv.Core/Browser/Sel.cs b/src/Ghpmv.Core/Browser/Sel.cs index 3f5b173c..043f51e8 100644 --- a/src/Ghpmv.Core/Browser/Sel.cs +++ b/src/Ghpmv.Core/Browser/Sel.cs @@ -114,11 +114,6 @@ public static ILocator BoardColumn(ILocator actionsButton) public static ILocator BoardColumnHeading(ILocator actionsButton) => BoardColumn(actionsButton).GetByRole(AriaRole.Heading).First; - /// <summary>Rendered "current cards / limit" counter in a limited Board column.</summary> - public static ILocator BoardColumnLimitCounter(ILocator column) - => column.GetByText(new Regex( - @"^\s*\d+\s*/\s*\d+\s*$")).First; - /// <summary>Rendered cards in every Board cell for the named logical column.</summary> public static ILocator BoardColumnCards(IPage page, string columnName) { diff --git a/tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs b/tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs index 12afeb92..a97107c6 100644 --- a/tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs +++ b/tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs @@ -118,17 +118,6 @@ public void Board_limit_capture_is_skipped_without_discarding_other_UI_for_unsup Assert.Equal(["Count"], view.Ui.FieldSum); } - [Theory] - [InlineData("2 / 1", 2, 1)] - [InlineData(" 12/3 ", 12, 3)] - public void Parse_Board_column_counter_reads_count_and_limit( - string text, - int expectedCount, - int expectedLimit) - => Assert.Equal( - (expectedCount, expectedLimit), - BoardColumnLimitObserver.ParseCounter(text)); - [Fact] public void Board_column_observer_compares_logical_identities_without_node_ids() { @@ -393,6 +382,61 @@ public void Board_limit_reconciliation_skips_all_writes_when_a_target_column_is_ 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() { From 81a67168f9f9b44b65be888250600cdb882a72fe Mon Sep 17 00:00:00 2001 From: SIkebe <17608272+SIkebe@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:12:01 +0900 Subject: [PATCH 09/11] Pace Board column menu interactions GitHub's overlays can remain transitional after visibility checks resolve, so honor the browser automation pacing contract between each limit read and write interaction. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e89ed2d8-70f0-4a29-ae59-2d644c0ac4c6 --- src/Ghpmv.Core/Browser/BoardColumnLimitUi.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Ghpmv.Core/Browser/BoardColumnLimitUi.cs b/src/Ghpmv.Core/Browser/BoardColumnLimitUi.cs index 9892a0fa..a71a2d45 100644 --- a/src/Ghpmv.Core/Browser/BoardColumnLimitUi.cs +++ b/src/Ghpmv.Core/Browser/BoardColumnLimitUi.cs @@ -273,14 +273,17 @@ await buttons.First.WaitForAsync(new() 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); @@ -300,18 +303,25 @@ private static async Task WriteLimitAsync( 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)) From a87dd112892277e67c510a02578e42dc10a5c132 Mon Sep 17 00:00:00 2001 From: SIkebe <17608272+SIkebe@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:19:42 +0900 Subject: [PATCH 10/11] Align Board migration validation guidance The manual drift sequence and UI discovery notes still described behavior from before Board limit drift and sort-field restoration were added, which could make correct validation results look erroneous. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e89ed2d8-70f0-4a29-ae59-2d644c0ac4c6 --- docs/MANUAL_TEST_PLAN.md | 8 ++++---- docs/ui-maps/projects-ui-discovery.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/MANUAL_TEST_PLAN.md b/docs/MANUAL_TEST_PLAN.md index ee88ae5d..6cbcb9a2 100644 --- a/docs/MANUAL_TEST_PLAN.md +++ b/docs/MANUAL_TEST_PLAN.md @@ -644,10 +644,10 @@ dotnet run --project src/Ghpmv.Cli -- setup ` 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同意へまとめます。 diff --git a/docs/ui-maps/projects-ui-discovery.md b/docs/ui-maps/projects-ui-discovery.md index 6e182ce6..d4a318d9 100644 --- a/docs/ui-maps/projects-ui-discovery.md +++ b/docs/ui-maps/projects-ui-discovery.md @@ -137,7 +137,7 @@ 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 化がタイムアウトでも機能するように修正済み) From 7f99b68c6ba911b1cf6b3dff2c0e3c14e8f0d55f Mon Sep 17 00:00:00 2001 From: SIkebe <17608272+SIkebe@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:27:42 +0900 Subject: [PATCH 11/11] Reject incomplete Board limit fixtures early The snapshot gate previously validated configured limits only, allowing missing unlimited columns and over-limit card placement to fail after target creation. The checklist also referenced a counter that swimlane Boards do not render. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e89ed2d8-70f0-4a29-ae59-2d644c0ac4c6 --- .github/skills/ghpmv-e2e-validation/SKILL.md | 34 ++++++++++++++++++++ docs/MANUAL_TEST_PLAN.md | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/.github/skills/ghpmv-e2e-validation/SKILL.md b/.github/skills/ghpmv-e2e-validation/SKILL.md index 7dea0ec6..e2d0f181 100644 --- a/.github/skills/ghpmv-e2e-validation/SKILL.md +++ b/.github/skills/ghpmv-e2e-validation/SKILL.md @@ -1144,6 +1144,40 @@ foreach ($expected in $expectedBoardLimits) { 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 diff --git a/docs/MANUAL_TEST_PLAN.md b/docs/MANUAL_TEST_PLAN.md index 6cbcb9a2..cb00a7a1 100644 --- a/docs/MANUAL_TEST_PLAN.md +++ b/docs/MANUAL_TEST_PLAN.md @@ -703,7 +703,7 @@ 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列が一致し、limit=1の列はitem count超過表示になる。 +- [ ] 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`。