Skip to content
Merged
1 change: 1 addition & 0 deletions docs/agent-context/build-and-validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ The current high-signal smoke covers:
- main scan shell, semantic navigation, and the search control;
- keyboard activation/focus through Settings and a click-driven About route;
- desktop and 600px narrow settings rendering, responsive control swap, bounds, and horizontal overflow;
- result-card item art containment (portrait, landscape, square icons) and the Details user-action contract;
- an ARIA snapshot of the important narrow settings structure;
- uncaught page exceptions, browser console errors, failed app-resource requests, and app-resource HTTP 5xx responses.

Expand Down
34 changes: 18 additions & 16 deletions src/App/Pages/App/Index.razor
Original file line number Diff line number Diff line change
Expand Up @@ -279,14 +279,14 @@
</div>
<button type="button"
class="details-toggle"
aria-expanded="@_detailsOpen"
aria-expanded="@(_uiState.DetailsOpen ? "true" : "false")"
@onclick="ToggleDetails">
@(_detailsOpen ? Localizer["HideDetails"] : Localizer["ShowDetails"])
<MudIcon Icon="@(_detailsOpen ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)" Size="Size.Small" />
@(_uiState.DetailsOpen ? Localizer["HideDetails"] : Localizer["ShowDetails"])
<MudIcon Icon="@(_uiState.DetailsOpen ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)" Size="Size.Small" />
</button>
</div>

@if (_detailsOpen)
@if (_uiState.DetailsOpen)
{
<section class="details" aria-label="@Localizer["ScanDetails"]">
<div class="details-group">
Expand Down Expand Up @@ -324,7 +324,7 @@
class="copy-btn"
title="@Localizer["CopyItemId"]"
@onclick="CopyItemId">
@(_copiedId ? Localizer["Copied"] : Localizer["Copy"])
@(_uiState.CopiedId ? Localizer["Copied"] : Localizer["Copy"])
</button>
}
</div>
Expand Down Expand Up @@ -359,8 +359,7 @@
private string? _itemsLocale;
private TarkovDev.GameMode? _itemsGameMode;
private MudAutocomplete<Item>? _search;
private bool _detailsOpen;
private bool _copiedId;
private readonly ResultCardUiState _uiState = new();

// Perf sequence of the scan this render is showing, plus when its notification
// arrived. Consumed after the next paint so the scan timeline records how long
Expand Down Expand Up @@ -524,8 +523,9 @@
private void RefreshResult()
{
_result = MenuVM.LastItemScan.IsSeed ? null : ScanResultAdapter.Map(MenuVM);
_detailsOpen = false;
_copiedId = false;
// Scan-driven refreshes must not collapse a Details section the user opened;
// only explicit user actions (ToggleDetails, SelectRecent, SelectItem) change it.
_uiState.OnResultRefreshed();
}

private async void OnResultChanged(object? sender, PropertyChangedEventArgs e) =>
Expand Down Expand Up @@ -559,8 +559,7 @@
private void SelectRecent(ScanResultViewModel scan)
{
_result = scan;
_detailsOpen = false;
_copiedId = false;
_uiState.OnItemSelected();
}

private System.Threading.Tasks.Task<IEnumerable<Item>> SearchItems(string value, CancellationToken cancellationToken)
Expand Down Expand Up @@ -599,14 +598,17 @@
private void SelectItem(Item? item)
{
if (item is not null)
{
// Deliberate item switch: collapse any open Details section, matching
// SelectRecent. Scan-driven refreshes alone must not close it.
_uiState.OnItemSelected();
MenuVM.ItemScans.Enqueue(new DefaultItemScan(item));
}
}

private void ToggleDetails()
{
_detailsOpen = !_detailsOpen;
if (!_detailsOpen)
_copiedId = false;
_uiState.ToggleDetails();
}

private async System.Threading.Tasks.Task FocusSearch()
Expand All @@ -623,12 +625,12 @@
try
{
System.Windows.Clipboard.SetText(_result.Item.Id);
_copiedId = true;
_uiState.MarkCopied();
}
catch
{
// Clipboard access can fail if the host window is not focused.
_copiedId = false;
_uiState.MarkCopyFailed();
}
}

Expand Down
12 changes: 10 additions & 2 deletions src/App/Pages/App/Index.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -238,14 +238,22 @@
position: relative;
display: grid;
place-items: center;
overflow: hidden;
border: 1px solid var(--rs-border-subtle);
border-radius: 8px;
background: var(--rs-surface-2);
}

.item-art img {
max-width: calc(100% - 10px);
max-height: calc(100% - 10px);
/* Deterministic frame fill: percentage max-width/max-height on a grid item
can leave the grid area height indefinite in Chromium, so tall (and even
square) intrinsic icons paint outside the frame. Absolute positioning
resolves the percentages against this fixed-size box; object-fit: contain
letterboxes without cropping or stretching any aspect ratio. */
position: absolute;
inset: 5px;
width: calc(100% - 10px);
height: calc(100% - 10px);
object-fit: contain;
}

Expand Down
41 changes: 41 additions & 0 deletions src/App/Presentation/ResultCardUiState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
namespace RatScanner.Presentation;

/// <summary>
/// Open/closed state of the scan result card's Details section together with the
/// copied-id label flag that lives on the same toggle. Centralized so the
/// user-choice rule is explicit and unit-testable: scan-driven result refreshes
/// must preserve the user's Details choice; only explicit user actions (the
/// toggle, recent-scan click, or search pick) may close it.
/// </summary>
internal sealed class ResultCardUiState
{
public bool DetailsOpen { get; private set; }

public bool CopiedId { get; private set; }

/// <summary>
/// A new scan result arrived without an explicit user selection (auto scan,
/// game-mode refresh). The Details section must not collapse — the user may be
/// watching scan details while auto-scanning. The copied-id label is per-item,
/// so it resets.
/// </summary>
public void OnResultRefreshed() => CopiedId = false;

/// <summary>The user deliberately switched to a different item (recent scan click or search pick).</summary>
public void OnItemSelected()
{
DetailsOpen = false;
CopiedId = false;
}

public void ToggleDetails()
{
DetailsOpen = !DetailsOpen;
if (!DetailsOpen)
CopiedId = false;
}

public void MarkCopied() => CopiedId = true;

public void MarkCopyFailed() => CopiedId = false;
}
101 changes: 101 additions & 0 deletions tests/RatScanner.Tests/ResultCardUiStateTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#nullable enable

using RatScanner.Presentation;
using Xunit;

namespace RatScanner.Tests;

/// <summary>
/// The Details section of the scan result card must preserve the user's choice
/// across scan-driven refreshes (auto scan enqueues fire MenuVM.PropertyChanged,
/// which re-maps the result) and close only on explicit user actions. Regression
/// coverage for the issue where Details auto-collapsed on every scan.
/// </summary>
public sealed class ResultCardUiStateTests
{
[Fact]
public void Result_refresh_preserves_open_details()
{
var state = new ResultCardUiState();
state.ToggleDetails();
Assert.True(state.DetailsOpen);

// The regression: RefreshResult() used to reset the flag on every scan.
state.OnResultRefreshed();

Assert.True(state.DetailsOpen);
}

[Fact]
public void Result_refresh_resets_copied_label_but_not_details()
{
var state = new ResultCardUiState();
state.ToggleDetails();
state.MarkCopied();
Assert.True(state.CopiedId);

state.OnResultRefreshed();

Assert.False(state.CopiedId);
Assert.True(state.DetailsOpen);
}

[Fact]
public void Item_selection_closes_details_even_when_user_opened_them()
{
var state = new ResultCardUiState();
state.ToggleDetails();
state.MarkCopied();

state.OnItemSelected();

Assert.False(state.DetailsOpen);
Assert.False(state.CopiedId);
}

[Fact]
public void Toggle_details_flips_open_and_closed()
{
var state = new ResultCardUiState();
Assert.False(state.DetailsOpen);

state.ToggleDetails();
Assert.True(state.DetailsOpen);

state.ToggleDetails();
Assert.False(state.DetailsOpen);
}

[Fact]
public void Closing_details_via_toggle_clears_copied_label()
{
var state = new ResultCardUiState();
state.ToggleDetails();
state.MarkCopied();
Assert.True(state.CopiedId);

state.ToggleDetails();

Assert.False(state.CopiedId);
}

[Fact]
public void Copy_result_flags_follow_success_and_failure()
{
var state = new ResultCardUiState();

state.MarkCopied();
Assert.True(state.CopiedId);

state.MarkCopyFailed();
Assert.False(state.CopiedId);
}

[Fact]
public void Fresh_state_starts_collapsed()
{
var state = new ResultCardUiState();
Assert.False(state.DetailsOpen);
Assert.False(state.CopiedId);
}
}
Loading