diff --git a/docs/tables_frontend_overhaul_implementation_plan.md b/docs/tables_frontend_overhaul_implementation_plan.md index 85a56aa..10a8af5 100644 --- a/docs/tables_frontend_overhaul_implementation_plan.md +++ b/docs/tables_frontend_overhaul_implementation_plan.md @@ -30,7 +30,7 @@ It is intentionally implementation-focused: - Branch: `frontend/tables-overhaul` - Last updated: `2026-04-12` -- Current focus: `Phase 6` +- Current focus: `Phase 7` - Document mode: living plan and progress log ### Progress Log @@ -78,6 +78,7 @@ It is intentionally implementation-focused: | 2026-04-12 | Phase 4 planning | Planned | Expanded the `Curation` phase from a route placeholder into a concrete migration plan that moves queue-first curation out of `Tables` and into a dedicated workflow surface. | | 2026-04-12 | Phase 4 | Completed | Replaced the placeholder `/curation` route with a real queue-first workspace, added queue scope and context persistence, moved browse-to-curation handoff out of `Tables`, and preserved diagnostics and full-editor escape hatches without keeping queue work on the reference page. | | 2026-04-12 | Phase 5 | Completed | Consolidated the existing tooling routes into a coherent `Tools` workspace with a real hub, shared tooling page frame, preserved-context exits from diagnostics back into `Tables` and `Curation`, and a grouped API reference surface. | +| 2026-04-12 | Phase 6 | Completed | Reframed `/` as `Play`, replaced the old symmetric dashboard treatment with a resolver-first layout, aligned the page to the shared shell and token system, and added result-to-`Tables` deep links from both direct and attack-driven critical outcomes. | ### Lessons Learned diff --git a/src/RolemasterDb.App/Components/Pages/Home.razor b/src/RolemasterDb.App/Components/Pages/Home.razor index 8683914..f9ba88a 100644 --- a/src/RolemasterDb.App/Components/Pages/Home.razor +++ b/src/RolemasterDb.App/Components/Pages/Home.razor @@ -1,9 +1,11 @@ @page "/" @rendermode InteractiveServer @using System +@using RolemasterDb.App.Frontend.AppState @inject LookupService LookupService +@inject TableContextState TableContextState -Lookup Desk +Play @if (referenceData is null) { @@ -13,210 +15,256 @@ } else { -
-
-

Attack Lookup

-

Choose an attack, armor type, and attack roll. If it produces a critical and you enter the follow-up roll, the app resolves that too.

- -
-
-
- - -
- -
- - -
- -
- -
- - -
- @if (SelectedAttackTable is { FumbleMinRoll: int attackFumbleMin, FumbleMaxRoll: int attackFumbleMax }) - { -

Fumble range: @FormatRange(attackFumbleMin, attackFumbleMax)

- } - @if (!string.IsNullOrWhiteSpace(attackRollSummary)) - { -

@attackRollSummary

- } - @if (!string.IsNullOrWhiteSpace(attackFumbleMessage)) - { -

@attackFumbleMessage

- } -
- -
- - -
- -
- - -
- -
- -
- - -
- @if (!string.IsNullOrWhiteSpace(attackCriticalRollSummary)) - { -

@attackCriticalRollSummary

- } -
-
- -
- - Leave critical roll blank if you only need the hit result. -
+
+
+
+

Play

+

Resolve attacks fast. Open the table only when you need context.

+

Use the attack lane for live results and the direct critical lane when you already know the exact table and severity.

+
+ Live lookup + Fast resolution + Table deep links +
+
- @if (!string.IsNullOrWhiteSpace(attackError)) - { -

@attackError

- } - - @if (attackResult is not null) - { -
-
-

@attackResult.AttackTableName vs @attackResult.ArmorTypeLabel

-
- @if (lastAttackResolution is not null) - { - Rolled: @lastAttackResolution.RawRoll - @if (lastAttackResolution.OffensiveBonus != 0) - { - OB: +@lastAttackResolution.OffensiveBonus - } - @if (lastAttackResolution.DefensiveBonus != 0) - { - DB: -@lastAttackResolution.DefensiveBonus - } - Total used: @lastAttackResolution.EffectiveTotal - } - else - { - Attack total: @attackResult.Roll - } - Hits: @attackResult.Hits - @if (!string.IsNullOrWhiteSpace(attackResult.CriticalSeverity)) - { - @attackResult.CriticalSeverity @attackResult.CriticalType critical - } - else - { - No critical - } -
- @if (!string.IsNullOrWhiteSpace(attackResult.Notes)) - { -

@attackResult.Notes

- } - - @if (attackResult.AutoCritical is not null) - { -
-

Resolved critical

- -
- } - else if (!string.IsNullOrWhiteSpace(attackResult.CriticalSeverity)) - { -
The attack produced a critical. Add a critical roll to resolve it automatically.
- } +
+
+
+
+

Primary lane

+

Attack lookup

- } -
-
-

Direct Critical Lookup

-

Use this when you already know the critical table, severity, roll, and variant if the table uses one.

- -
-
-
- - -
- -
- - -
- - @if (SelectedCriticalTable?.Groups.Count > 0) - { +
+
- - + @foreach (var attackTable in referenceData.AttackTables) { - + }
- } -
- -
- - +
+ +
- @if (!string.IsNullOrWhiteSpace(directCriticalRollSummary)) - { -

@directCriticalRollSummary

- } + +
+ +
+ + +
+ @if (SelectedAttackTable is { FumbleMinRoll: int attackFumbleMin, FumbleMaxRoll: int attackFumbleMax }) + { +

Fumble range: @FormatRange(attackFumbleMin, attackFumbleMax)

+ } + @if (!string.IsNullOrWhiteSpace(attackRollSummary)) + { +

@attackRollSummary

+ } + @if (!string.IsNullOrWhiteSpace(attackFumbleMessage)) + { +

@attackFumbleMessage

+ } +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+ @if (!string.IsNullOrWhiteSpace(attackCriticalRollSummary)) + { +

@attackCriticalRollSummary

+ } +
+
+ +
+ + Leave critical roll blank if you only need the hit result.
-
- -
-
+ @if (!string.IsNullOrWhiteSpace(attackError)) + { +

@attackError

+ } - @if (!string.IsNullOrWhiteSpace(criticalError)) - { -

@criticalError

- } + @if (attackResult is not null) + { +
+
+
+
+

Attack result

+

@attackResult.AttackTableName vs @attackResult.ArmorTypeLabel

+
+
- @if (criticalResult is not null) - { -
- +
+ @if (lastAttackResolution is not null) + { + Rolled: @lastAttackResolution.RawRoll + @if (lastAttackResolution.OffensiveBonus != 0) + { + OB: +@lastAttackResolution.OffensiveBonus + } + + @if (lastAttackResolution.DefensiveBonus != 0) + { + DB: -@lastAttackResolution.DefensiveBonus + } + + Total used: @lastAttackResolution.EffectiveTotal + } + else + { + Attack total: @attackResult.Roll + } + Hits: @attackResult.Hits + @if (!string.IsNullOrWhiteSpace(attackResult.CriticalSeverity)) + { + @attackResult.CriticalSeverity @attackResult.CriticalType critical + } + else + { + No critical + } +
+ + @if (!string.IsNullOrWhiteSpace(attackResult.Notes)) + { +

@attackResult.Notes

+ } + + @if (attackResult.AutoCritical is not null) + { +
+
+
+

Follow-up critical

+

Resolved critical

+
+ +
+ +
+ } + else if (!string.IsNullOrWhiteSpace(attackResult.CriticalSeverity)) + { +
+ The attack produced a critical. Add a critical roll to resolve it automatically. +
+ } +
+
+ } +
+ +
+
+
+

Quick jump

+

Direct critical lookup

+
- } -
+ +
+
+
+ + +
+ +
+ + +
+ + @if (SelectedCriticalTable?.Groups.Count > 0) + { +
+ + +
+ } + +
+ +
+ + +
+ @if (!string.IsNullOrWhiteSpace(directCriticalRollSummary)) + { +

@directCriticalRollSummary

+ } +
+
+ +
+ +
+
+ + @if (!string.IsNullOrWhiteSpace(criticalError)) + { +

@criticalError

+ } + + @if (criticalResult is not null) + { +
+
+
+

Critical result

+
+ +
+ +
+ } +
+
} @@ -271,11 +319,7 @@ else return; } - var response = await LookupService.LookupAttackAsync(new AttackLookupRequest( - attackInput.AttackTable, - attackInput.ArmorType, - attackResolution.EffectiveTotal, - string.IsNullOrWhiteSpace(attackInput.CriticalRollText) ? null : criticalRoll)); + var response = await LookupService.LookupAttackAsync(new AttackLookupRequest(attackInput.AttackTable, attackInput.ArmorType, attackResolution.EffectiveTotal, string.IsNullOrWhiteSpace(attackInput.CriticalRollText) ? null : criticalRoll)); if (response is null) { @@ -292,11 +336,7 @@ else criticalError = null; criticalResult = null; - var response = await LookupService.LookupCriticalAsync(new CriticalLookupRequest( - criticalInput.CriticalType, - criticalInput.Column, - criticalInput.Roll, - SelectedCriticalTable?.Groups.Count > 0 ? criticalInput.Group : null)); + var response = await LookupService.LookupCriticalAsync(new CriticalLookupRequest(criticalInput.CriticalType, criticalInput.Column, criticalInput.Roll, SelectedCriticalTable?.Groups.Count > 0 ? criticalInput.Group : null)); if (response is null) { @@ -347,16 +387,11 @@ else } CriticalTableReference? criticalTable = null; - var pendingAttack = await LookupService.LookupAttackAsync(new AttackLookupRequest( - attackInput.AttackTable, - attackInput.ArmorType, - attackResolution.EffectiveTotal, - null)); + var pendingAttack = await LookupService.LookupAttackAsync(new AttackLookupRequest(attackInput.AttackTable, attackInput.ArmorType, attackResolution.EffectiveTotal, null)); if (!string.IsNullOrWhiteSpace(pendingAttack?.CriticalType)) { - criticalTable = referenceData?.CriticalTables.FirstOrDefault(item => - string.Equals(item.Key, pendingAttack.CriticalType, StringComparison.Ordinal)); + criticalTable = referenceData?.CriticalTables.FirstOrDefault(item => string.Equals(item.Key, pendingAttack.CriticalType, StringComparison.Ordinal)); } var result = RolemasterRoller.RollCritical(Random.Shared, criticalTable); @@ -394,15 +429,11 @@ else } private AttackResolutionSummary BuildAttackResolution() => - AttackResolutionCalculator.Resolve( - attackInput.AttackRoll, - attackInput.OffensiveBonus, - attackInput.DefensiveBonus); + AttackResolutionCalculator.Resolve(attackInput.AttackRoll, attackInput.OffensiveBonus, attackInput.DefensiveBonus); private string? BuildAttackFumbleMessage(AttackResolutionSummary resolution) { - if (!AttackResolutionCalculator.IsFumble(resolution, SelectedAttackTable) || - SelectedAttackTable is not { FumbleMinRoll: int fumbleMinRoll, FumbleMaxRoll: int fumbleMaxRoll }) + if (!AttackResolutionCalculator.IsFumble(resolution, SelectedAttackTable) || SelectedAttackTable is not { FumbleMinRoll: int fumbleMinRoll, FumbleMaxRoll: int fumbleMaxRoll }) { return null; } @@ -442,9 +473,7 @@ else return $"{summary} {string.Join(' ', modifiers)} = {resolution.EffectiveTotal}."; } - private static string BuildCriticalRollSummary( - LookupRollResult result, - CriticalTableReference? criticalTable) + private static string BuildCriticalRollSummary(LookupRollResult result, CriticalTableReference? criticalTable) { var summary = BuildRollSummary(result, "Critical"); if (criticalTable is null) @@ -452,14 +481,30 @@ else return $"{summary} Standard 1-100 roll used because no critical table is currently resolved from the attack result."; } - return RolemasterRoller.AllowsOpenEndedCritical(criticalTable) - ? $"{summary} {criticalTable.Label} uses open-ended rolls." - : $"{summary} {criticalTable.Label} is capped at 1-100."; + return RolemasterRoller.AllowsOpenEndedCritical(criticalTable) ? $"{summary} {criticalTable.Label} uses open-ended rolls." : $"{summary} {criticalTable.Label} is capped at 1-100."; } private static string FormatRange(int minRoll, int maxRoll) => $"{minRoll:00}-{maxRoll:00}"; + private string? BuildTablesHref(CriticalLookupResponse? result) + { + if (referenceData is null || result is null) + { + return null; + } + + var table = referenceData.CriticalTables.FirstOrDefault(item => string.Equals(item.Key, result.CriticalType, StringComparison.Ordinal)); + if (table is null) + { + return null; + } + + var snapshot = new TableContextSnapshot(TableSlug: table.Key, GroupKey: result.Group, ColumnKey: result.Column, RollBand: result.RollBand, Mode: TableContextMode.Reference); + + return TableContextState.BuildUri("/tables", snapshot); + } + private sealed class AttackLookupForm { public string AttackTable { get; set; } = string.Empty; @@ -477,4 +522,5 @@ else public string Group { get; set; } = string.Empty; public int Roll { get; set; } = 72; } -} + +} \ No newline at end of file diff --git a/src/RolemasterDb.App/Components/Pages/Tables.razor b/src/RolemasterDb.App/Components/Pages/Tables.razor index 3a4bc78..21ddc6b 100644 --- a/src/RolemasterDb.App/Components/Pages/Tables.razor +++ b/src/RolemasterDb.App/Components/Pages/Tables.razor @@ -149,7 +149,7 @@ await PersistAndSyncTableContextAsync(); } - private async Task LoadTableDetailAsync() + private async Task LoadTableDetailAsync(TableContextSnapshot? routeContext = null) { if (string.IsNullOrWhiteSpace(selectedTableSlug)) { @@ -172,6 +172,7 @@ } await RecordRecentTableVisitAsync(); + ApplyRouteContext(routeContext); NormalizeViewStateForCurrentDetail(); } catch (Exception exception) @@ -203,12 +204,14 @@ if (string.IsNullOrWhiteSpace(selectedTableSlug) || !string.Equals(resolvedTableSlug, selectedTableSlug, StringComparison.OrdinalIgnoreCase)) { selectedTableSlug = resolvedTableSlug; - await LoadTableDetailAsync(); + await LoadTableDetailAsync(initialContext); await PersistAndSyncTableContextAsync(); await InvokeAsync(StateHasChanged); return; } + ApplyRouteContext(initialContext); + NormalizeViewStateForCurrentDetail(); await PersistAndSyncTableContextAsync(); } } @@ -373,7 +376,7 @@ } private RolemasterDb.App.Frontend.AppState.TableContextSnapshot BuildCurrentTableContext() => - new(TableSlug: selectedTableSlug, Mode: RolemasterDb.App.Frontend.AppState.TableContextMode.Reference); + new(TableSlug: selectedTableSlug, GroupKey: selectedCell?.GroupKey, ColumnKey: selectedCell?.ColumnKey, RollBand: selectedCell?.RollBand, ResultId: selectedCell?.ResultId, Mode: RolemasterDb.App.Frontend.AppState.TableContextMode.Reference); private void SelectCell(TablesCellSelection selection) { @@ -408,6 +411,18 @@ return Task.CompletedTask; } + private void ApplyRouteContext(TableContextSnapshot? routeContext) + { + if (tableDetail is null) + { + selectedCell = null; + return; + } + + var resolvedCell = TableContextCellResolver.FindCell(tableDetail, routeContext); + selectedCell = resolvedCell is null ? null : new TablesCellSelection(resolvedCell.ResultId, resolvedCell.RollBand, resolvedCell.ColumnKey, resolvedCell.GroupKey); + } + private void NormalizeViewStateForCurrentDetail() { referenceMode = NormalizeMode(referenceMode); diff --git a/src/RolemasterDb.App/Components/Play/PlayResultActions.razor b/src/RolemasterDb.App/Components/Play/PlayResultActions.razor new file mode 100644 index 0000000..d05c0f9 --- /dev/null +++ b/src/RolemasterDb.App/Components/Play/PlayResultActions.razor @@ -0,0 +1,13 @@ +@if (!string.IsNullOrWhiteSpace(Href)) +{ +
+ Open in Tables +
+} + +@code { + + [Parameter] + public string? Href { get; set; } + +} \ No newline at end of file diff --git a/src/RolemasterDb.App/Components/_Imports.razor b/src/RolemasterDb.App/Components/_Imports.razor index 58bb4ad..0d63f30 100644 --- a/src/RolemasterDb.App/Components/_Imports.razor +++ b/src/RolemasterDb.App/Components/_Imports.razor @@ -12,6 +12,7 @@ @using RolemasterDb.App.Components @using RolemasterDb.App.Components.Curation @using RolemasterDb.App.Components.Layout +@using RolemasterDb.App.Components.Play @using RolemasterDb.App.Components.Primitives @using RolemasterDb.App.Components.Shell @using RolemasterDb.App.Components.Shared diff --git a/src/RolemasterDb.App/Frontend/AppState/TableContextCellResolver.cs b/src/RolemasterDb.App/Frontend/AppState/TableContextCellResolver.cs new file mode 100644 index 0000000..85dbcbd --- /dev/null +++ b/src/RolemasterDb.App/Frontend/AppState/TableContextCellResolver.cs @@ -0,0 +1,32 @@ +using RolemasterDb.App.Features; + +namespace RolemasterDb.App.Frontend.AppState; + +public static class TableContextCellResolver +{ + public static CriticalTableCellDetail? FindCell(CriticalTableDetail detail, TableContextSnapshot? context) + { + ArgumentNullException.ThrowIfNull(detail); + + if (context is null) + { + return null; + } + + if (context.ResultId is { } resultId) + { + var matchedByResultId = detail.Cells.FirstOrDefault(cell => cell.ResultId == resultId); + if (matchedByResultId is not null) + { + return matchedByResultId; + } + } + + if (string.IsNullOrWhiteSpace(context.RollBand) && string.IsNullOrWhiteSpace(context.ColumnKey) && string.IsNullOrWhiteSpace(context.GroupKey)) + { + return null; + } + + return detail.Cells.FirstOrDefault(cell => string.Equals(cell.RollBand, context.RollBand, StringComparison.Ordinal) && string.Equals(cell.ColumnKey, context.ColumnKey, StringComparison.Ordinal) && string.Equals(cell.GroupKey ?? string.Empty, context.GroupKey ?? string.Empty, StringComparison.Ordinal)); + } +} \ No newline at end of file diff --git a/src/RolemasterDb.App/Frontend/Curation/CurationQueueResolver.cs b/src/RolemasterDb.App/Frontend/Curation/CurationQueueResolver.cs index 1767b32..eeb5c8a 100644 --- a/src/RolemasterDb.App/Frontend/Curation/CurationQueueResolver.cs +++ b/src/RolemasterDb.App/Frontend/Curation/CurationQueueResolver.cs @@ -36,31 +36,8 @@ public static class CurationQueueResolver }).ThenBy(cell => columnOrder.GetValueOrDefault(cell.ColumnKey, int.MaxValue)).ToList(); } - public static CriticalTableCellDetail? FindCell(CriticalTableDetail detail, TableContextSnapshot? context) - { - ArgumentNullException.ThrowIfNull(detail); - - if (context is null) - { - return null; - } - - if (context.ResultId is { } resultId) - { - var matchedByResultId = detail.Cells.FirstOrDefault(cell => cell.ResultId == resultId); - if (matchedByResultId is not null) - { - return matchedByResultId; - } - } - - if (string.IsNullOrWhiteSpace(context.RollBand) && string.IsNullOrWhiteSpace(context.ColumnKey) && string.IsNullOrWhiteSpace(context.GroupKey)) - { - return null; - } - - return detail.Cells.FirstOrDefault(cell => string.Equals(cell.RollBand, context.RollBand, StringComparison.Ordinal) && string.Equals(cell.ColumnKey, context.ColumnKey, StringComparison.Ordinal) && string.Equals(cell.GroupKey ?? string.Empty, context.GroupKey ?? string.Empty, StringComparison.Ordinal)); - } + public static CriticalTableCellDetail? FindCell(CriticalTableDetail detail, TableContextSnapshot? context) => + TableContextCellResolver.FindCell(detail, context); public static CriticalTableCellDetail? FindFirstUncurated(CriticalTableDetail detail) => GetOrderedCells(detail).FirstOrDefault(cell => !cell.IsCurated); diff --git a/src/RolemasterDb.App/wwwroot/app.css b/src/RolemasterDb.App/wwwroot/app.css index 4ad7621..6bcc917 100644 --- a/src/RolemasterDb.App/wwwroot/app.css +++ b/src/RolemasterDb.App/wwwroot/app.css @@ -268,6 +268,147 @@ pre, font-family: var(--font-mono); } +.play-page, +.play-hero, +.play-hero-copy, +.play-layout, +.play-panel, +.play-panel-header, +.play-panel-copy, +.play-result-dock, +.play-result-card, +.play-result-heading, +.play-result-actions { + display: grid; + gap: 1rem; +} + +.play-page { + gap: 1.25rem; +} + +.play-hero { + gap: 1.25rem; + background: + radial-gradient(circle at top right, color-mix(in srgb, var(--accent-2) 24%, transparent), transparent 36%), + linear-gradient(180deg, color-mix(in srgb, var(--surface-card-subtle) 92%, var(--surface-1)), var(--surface-1)); +} + +.play-hero-copy { + gap: 0.65rem; + max-width: 58rem; +} + +.play-eyebrow, +.play-result-eyebrow { + margin: 0; + color: var(--accent-strong); + font-family: var(--font-ui); + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.play-title { + margin: 0; + font-size: clamp(2.1rem, 4vw, 3.4rem); + line-height: 1; + max-width: 17ch; +} + +.play-summary, +.play-action-hint, +.play-result-note { + margin: 0; + color: var(--text-secondary); +} + +.play-hero-meta { + display: flex; + gap: 0.6rem; + flex-wrap: wrap; + align-items: center; +} + +.play-layout { + grid-template-columns: minmax(0, 1.7fr) minmax(19rem, 1fr); + align-items: start; +} + +.play-panel { + align-content: start; + min-width: 0; +} + +.play-panel-header { + gap: 0.5rem; +} + +.play-panel-copy { + gap: 0.35rem; +} + +.play-form-grid { + grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); +} + +.play-form-grid-primary { + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); +} + +.play-action-row { + justify-content: space-between; +} + +.play-result-dock { + padding-top: 0.1rem; +} + +.play-result-card { + gap: 1rem; +} + +.play-result-heading { + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 0.75rem; +} + +.play-result-heading h3, +.play-result-heading h4 { + margin: 0; +} + +.play-result-actions { + align-items: center; + justify-items: end; +} + +.play-action-link { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: var(--control-height); + padding: 0.72rem 1rem; + border-radius: 999px; + border: 1px solid var(--button-secondary-border); + background: var(--button-secondary-bg); + color: var(--button-secondary-text); + text-decoration: none; + white-space: nowrap; +} + +.play-action-link:hover { + background: var(--button-secondary-bg-hover); + color: var(--button-secondary-text); +} + +.play-callout { + display: grid; + gap: 0.85rem; +} + .dashboard-grid { display: grid; gap: 1.25rem; @@ -615,6 +756,30 @@ select.input-shell { margin-bottom: 0; } +@media (max-width: 900px) { + .play-layout { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .play-title { + max-width: none; + } + + .play-result-heading { + grid-template-columns: 1fr; + } + + .play-result-actions { + justify-items: start; + } + + .play-action-row { + justify-content: flex-start; + } +} + .critical-cell { display: flex; flex-direction: column; diff --git a/src/RolemasterDb.ImportTool.Tests/CurationQueueResolverTests.cs b/src/RolemasterDb.ImportTool.Tests/CurationQueueResolverTests.cs index 46ebddb..2c44a3f 100644 --- a/src/RolemasterDb.ImportTool.Tests/CurationQueueResolverTests.cs +++ b/src/RolemasterDb.ImportTool.Tests/CurationQueueResolverTests.cs @@ -36,18 +36,6 @@ public sealed class CurationQueueResolverTests Assert.Null(CurationQueueResolver.FindNextUncurated(detail, 3)); } - [Fact] - public void Find_cell_prefers_result_id_and_falls_back_to_location_context() - { - var detail = CreateDetail(new CriticalTableCellDetail(11, "01-05", "A", "A", "severity", null, null, false, null, [], []), new CriticalTableCellDetail(12, "06-10", "B", "B", "severity", "A", "Alpha", false, null, [], [])); - - var byResult = CurationQueueResolver.FindCell(detail, new TableContextSnapshot(ResultId: 12)); - var byLocation = CurationQueueResolver.FindCell(detail, new TableContextSnapshot(GroupKey: "A", ColumnKey: "B", RollBand: "06-10")); - - Assert.Equal(12, byResult!.ResultId); - Assert.Equal(12, byLocation!.ResultId); - } - private static CriticalTableDetail CreateDetail(params CriticalTableCellDetail[] cells) => new("slash", "Slash Critical Strike Table", "standard", "Slash.pdf", null, [ new CriticalColumnReference("A", "A", "severity", 1), diff --git a/src/RolemasterDb.ImportTool.Tests/TableContextCellResolverTests.cs b/src/RolemasterDb.ImportTool.Tests/TableContextCellResolverTests.cs new file mode 100644 index 0000000..830adf4 --- /dev/null +++ b/src/RolemasterDb.ImportTool.Tests/TableContextCellResolverTests.cs @@ -0,0 +1,42 @@ +using RolemasterDb.App.Features; +using RolemasterDb.App.Frontend.AppState; + +namespace RolemasterDb.ImportTool.Tests; + +public sealed class TableContextCellResolverTests +{ + [Fact] + public void Find_cell_prefers_result_id_and_falls_back_to_location_context() + { + var detail = CreateDetail(new CriticalTableCellDetail(11, "01-05", "A", "A", "severity", null, null, false, null, [], []), new CriticalTableCellDetail(12, "06-10", "B", "B", "severity", "A", "Alpha", false, null, [], [])); + + var byResult = TableContextCellResolver.FindCell(detail, new TableContextSnapshot(ResultId: 12)); + var byLocation = TableContextCellResolver.FindCell(detail, new TableContextSnapshot(GroupKey: "A", ColumnKey: "B", RollBand: "06-10")); + + Assert.Equal(12, byResult!.ResultId); + Assert.Equal(12, byLocation!.ResultId); + } + + [Fact] + public void Find_cell_returns_null_when_context_does_not_identify_a_cell() + { + var detail = CreateDetail(new CriticalTableCellDetail(12, "06-10", "B", "B", "severity", "A", "Alpha", false, null, [], [])); + + Assert.Null(TableContextCellResolver.FindCell(detail, new TableContextSnapshot())); + Assert.Null(TableContextCellResolver.FindCell(detail, new TableContextSnapshot(GroupKey: "B", ColumnKey: "B", RollBand: "06-10"))); + } + + private static CriticalTableDetail CreateDetail(params CriticalTableCellDetail[] cells) => + new("slash", "Slash Critical Strike Table", "standard", "Slash.pdf", null, [ + new CriticalColumnReference("A", "A", "severity", 1), + new CriticalColumnReference("B", "B", "severity", 2), + new CriticalColumnReference("C", "C", "severity", 3) + ], [ + new CriticalGroupReference("A", "Alpha", 1), + new CriticalGroupReference("B", "Beta", 2) + ], [ + new CriticalRollBandReference("01-05", 1, 5, 1), + new CriticalRollBandReference("06-10", 6, 10, 2), + new CriticalRollBandReference("21-25", 21, 25, 3) + ], cells, []); +} \ No newline at end of file