Просмотр исходного кода

Fix notes editor overwriting input while typing (#256)

MarkdownEditor bound the textarea with value="@Value" and a manual
@oninput handler. Every keystroke re-rendered the component and Blazor
wrote the server's copy of the value back to the textarea. When further
keys were pressed before that round trip completed, the stale value
replaced them and the cursor jumped to the end.

Use @bind with @bind:event="oninput" so the renderer records the value
the browser sent and no longer echoes it back. Parent cards still use
@bind-Value unchanged.

Add E2E tests that delay the Blazor WebSocket to reproduce the race
reliably: typing in the middle of existing notes, and typing quickly
over a slow connection then saving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
VirSanctus 2 дней назад
Родитель
Сommit
5f8018feb5

+ 12 - 5
Shared.Rcl/Components/MarkdownEditor.razor

@@ -23,8 +23,9 @@
     <textarea
         class="w-full h-64 bg-zinc-950 text-zinc-200 border border-zinc-700 rounded p-3 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500"
         data-testid="@($"{BaseTestId}-textarea")"
-        value="@Value"
-        @oninput="HandleInput">
+        @bind="_text"
+        @bind:event="oninput"
+        @bind:after="NotifyChanged">
     </textarea>
 
 </div>
@@ -45,10 +46,16 @@
             ? "markdown-editor"
             : $"{TestIdPrefix}-markdown-editor";
 
-    async Task HandleInput(ChangeEventArgs e)
+    private string? _text;
+
+    protected override void OnParametersSet()
+    {
+        _text = Value;
+    }
+
+    Task NotifyChanged()
     {
-        Value = e.Value?.ToString();
-        await ValueChanged.InvokeAsync(Value);
+        return ValueChanged.InvokeAsync(_text);
     }
 
     async Task HandleSave()

+ 66 - 0
Tests.E2e/AccessPointCardTests.cs

@@ -457,4 +457,70 @@ public class AccessPointCardTests(
             await context.CloseAsync();
         }
     }
+
+    [Fact]
+    public async Task User_Can_Type_In_The_Middle_Of_Notes_Without_The_Cursor_Jumping() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+        await BlazorLatency.AddAsync(page, TimeSpan.FromMilliseconds(50));
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try {
+            AccessPointCardPom card = await CreateAccessPointAsync(page, name);
+
+            await card.BeginEditAsync(name);
+            await card.SetNotesAsync(name, "Line one\nLine three");
+            await card.TypeNotesLineAfterFirstLineAsync(name, "Line two typed in the middle");
+
+            await Assertions.Expect(card.NotesEditorTextarea(name))
+                .ToHaveValueAsync("Line one\nLine two typed in the middle\nLine three");
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Type_Notes_Quickly_Over_A_Slow_Connection_And_Save() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+        await BlazorLatency.AddAsync(page, TimeSpan.FromMilliseconds(50));
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+        var notes = "This is currently in bridge mode and uplinked to the core switch.";
+
+        try {
+            AccessPointCardPom card = await CreateAccessPointAsync(page, name);
+
+            await card.BeginEditAsync(name);
+            await card.TypeNotesAsync(name, notes);
+
+            await Assertions.Expect(card.NotesEditorTextarea(name)).ToHaveValueAsync(notes);
+
+            await card.SaveAsync(name);
+
+            await Assertions.Expect(card.NotesViewerContent(name)).ToHaveTextAsync(notes);
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    private async Task<AccessPointCardPom> CreateAccessPointAsync(IPage page, string name) {
+        await page.GotoAsync(_fixture.BaseUrl);
+
+        var layout = new MainLayoutPom(page);
+        await layout.AssertLoadedAsync();
+        await layout.GotoHardwareAsync();
+
+        var hardwareTree = new HardwareTreePom(page);
+        await hardwareTree.AssertLoadedAsync();
+        await hardwareTree.GotoAccessPointsListAsync();
+
+        var list = new AccessPointsListPom(page);
+        await list.AssertLoadedAsync();
+        await list.AddAccessPointAsync(name);
+        await page.WaitForURLAsync($"**/resources/hardware/{name}");
+
+        var card = new AccessPointCardPom(page);
+        await card.AssertCardVisibleAsync(name);
+        return card;
+    }
 }

+ 41 - 0
Tests.E2e/Infra/BlazorLatency.cs

@@ -0,0 +1,41 @@
+using System.Text.RegularExpressions;
+using System.Threading.Channels;
+using Microsoft.Playwright;
+
+namespace Tests.E2e.Infra;
+
+/// <summary>
+///     Simulates a slow network between the browser and the Blazor Server circuit
+///     by delaying every SignalR WebSocket frame in both directions. Frame order is preserved.
+///     Must be called before the page navigates, so the circuit's socket is routed.
+/// </summary>
+public static partial class BlazorLatency {
+    public static Task AddAsync(IPage page, TimeSpan oneWayDelay) =>
+        page.RouteWebSocketAsync(BlazorSocket(), ws => {
+            IWebSocketRoute server = ws.ConnectToServer();
+            ws.OnMessage(DelayedForwarder(oneWayDelay, server));
+            server.OnMessage(DelayedForwarder(oneWayDelay, ws));
+        });
+
+    private static Action<IWebSocketFrame> DelayedForwarder(TimeSpan delay, IWebSocketRoute target) {
+        var queue = Channel.CreateUnbounded<(DateTime Due, string? Text, byte[]? Binary)>();
+
+        _ = Task.Run(async () => {
+            await foreach ((DateTime due, var text, var binary) in queue.Reader.ReadAllAsync()) {
+                TimeSpan wait = due - DateTime.UtcNow;
+                if (wait > TimeSpan.Zero)
+                    await Task.Delay(wait);
+
+                if (binary is not null)
+                    target.Send(binary);
+                else
+                    target.Send(text ?? string.Empty);
+            }
+        });
+
+        return frame => queue.Writer.TryWrite((DateTime.UtcNow + delay, frame.Text, frame.Binary));
+    }
+
+    [GeneratedRegex("/_blazor")]
+    private static partial Regex BlazorSocket();
+}

+ 16 - 0
Tests.E2e/PageObjectModels/AccessPointCardPom.cs

@@ -119,6 +119,22 @@ public class AccessPointCardPom(IPage page) {
     public async Task SetSpeedAsync(string accessPointName, double speed) =>
         await SpeedInput(accessPointName).FillAsync(speed.ToString(CultureInfo.InvariantCulture));
 
+    public async Task SetNotesAsync(string accessPointName, string notes) =>
+        await NotesEditorTextarea(accessPointName).FillAsync(notes);
+
+    public async Task TypeNotesAsync(string accessPointName, string text) =>
+        await NotesEditorTextarea(accessPointName).PressSequentiallyAsync(
+            text,
+            new LocatorPressSequentiallyOptions { Delay = 30 });
+
+    public async Task TypeNotesLineAfterFirstLineAsync(string accessPointName, string line) {
+        await NotesEditorTextarea(accessPointName).FocusAsync();
+        await page.Keyboard.PressAsync("Control+Home");
+        await page.Keyboard.PressAsync("End");
+        await page.Keyboard.PressAsync("Enter");
+        await TypeNotesAsync(accessPointName, line);
+    }
+
     public async Task SaveAsync(string accessPointName) {
         await SaveButton(accessPointName).ClickAsync();
         await Assertions.Expect(ModelSection(accessPointName)).ToBeVisibleAsync();