feat(dashboard): add revision-safe interactive widget grid

This commit is contained in:
cesnimda
2026-10-28 15:32:47 +01:00
parent aea7c29e70
commit 0bd290e788
21 changed files with 696 additions and 108 deletions
@@ -28,6 +28,61 @@ public sealed class DashboardPreferencesTests
private const string Route = "/api/auth/preferences/dashboard";
private const string Layout = "{\"version\":1,\"revision\":0,\"widgets\":[{\"id\":\"cards\",\"visible\":true,\"size\":\"full\"}]}";
[Fact]
public async Task Geometry_roundtrip_preserves_hidden_positions_and_blocks_legacy_downgrade()
{
await using var host = await Host.Start();
using var owner = host.Client("owner");
using var other = host.Client("other");
const string geometry = "{\"version\":2,\"revision\":0,\"widgets\":[{\"id\":\"attention\",\"visible\":false,\"size\":\"standard\",\"x\":4,\"y\":17,\"w\":8,\"h\":10}]}";
Assert.Equal(HttpStatusCode.OK, (await owner.PutAsync(Route, Json(geometry))).StatusCode);
var actual = await owner.GetFromJsonAsync<JsonElement>(Route);
Assert.Equal(2, actual.GetProperty("version").GetInt32());
Assert.Equal(1, actual.GetProperty("revision").GetInt64());
var widget = actual.GetProperty("widgets")[0];
Assert.False(widget.GetProperty("visible").GetBoolean());
Assert.Equal(4, widget.GetProperty("x").GetInt32());
Assert.Equal(17, widget.GetProperty("y").GetInt32());
Assert.Equal(8, widget.GetProperty("w").GetInt32());
Assert.Equal(10, widget.GetProperty("h").GetInt32());
Assert.Equal(HttpStatusCode.Conflict, (await owner.PutAsync(Route, Json(Layout.Replace("\"revision\":0", "\"revision\":1")))).StatusCode);
Assert.Equal(actual.GetRawText(), (await owner.GetFromJsonAsync<JsonElement>(Route)).GetRawText());
Assert.Equal(0, (await other.GetFromJsonAsync<JsonElement>(Route)).GetProperty("revision").GetInt64());
}
[Theory]
[InlineData(true, 4, HttpStatusCode.BadRequest)]
[InlineData(true, 6, HttpStatusCode.OK)]
[InlineData(false, 4, HttpStatusCode.OK)]
public async Task Visible_geometry_cannot_overlap_but_hidden_positions_and_adjacent_edges_are_supported(bool visible, int x, HttpStatusCode expected)
{
await using var host = await Host.Start();
using var owner = host.Client("owner");
var request = new { version = 2, revision = 0, widgets = new[] {
new { id = "attention", visible = true, size = "standard", x = 0, y = 0, w = 6, h = 8 },
new { id = "funnel", visible, size = "standard", x, y = 0, w = 6, h = 8 },
} };
Assert.Equal(expected, (await owner.PutAsJsonAsync(Route, request)).StatusCode);
Assert.Equal(expected == HttpStatusCode.OK ? 1 : 0, (await owner.GetFromJsonAsync<JsonElement>(Route)).GetProperty("revision").GetInt64());
}
[Theory]
[InlineData(-1, 0, 6, 8)]
[InlineData(8, 0, 6, 8)]
[InlineData(0, 250, 6, 8)]
[InlineData(0, 0, 3, 8)]
[InlineData(0, 0, 6, 3)]
[InlineData(0, 0, 6, 25)]
[InlineData(0.5, 0, 6, 8)]
public async Task Unbounded_or_fractional_geometry_is_rejected_before_persistence(double x, double y, double w, double h)
{
await using var host = await Host.Start();
using var owner = host.Client("owner");
var request = new { version = 2, revision = 0, widgets = new[] { new { id = "attention", visible = true, size = "standard", x, y, w, h } } };
Assert.Equal(HttpStatusCode.BadRequest, (await owner.PutAsJsonAsync(Route, request)).StatusCode);
Assert.Equal(0, (await owner.GetFromJsonAsync<JsonElement>(Route)).GetProperty("revision").GetInt64());
}
[Fact]
public async Task Http_roundtrip_is_owner_scoped_and_preserves_other_preferences()
{
@@ -131,10 +186,10 @@ public sealed class DashboardPreferencesTests
await using var host = await Host.Start();
using var client = host.Client("owner");
await using var db = host.Db();
const string future = "{\"version\":2,\"widgets\":[{\"id\":\"future-widget\",\"custom\":\"preserve\"}]}";
const string future = "{\"version\":3,\"widgets\":[{\"id\":\"future-widget\",\"custom\":\"preserve\"}]}";
await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE AspNetUsers SET DashboardLayoutJson={future}, DashboardLayoutRevision=4 WHERE Id='owner'");
var response = await client.GetFromJsonAsync<JsonElement>(Route);
Assert.Equal(2, response.GetProperty("version").GetInt32());
Assert.Equal(3, response.GetProperty("version").GetInt32());
Assert.Equal(4, response.GetProperty("revision").GetInt64());
Assert.Empty(response.GetProperty("widgets").EnumerateArray());
Assert.Equal(HttpStatusCode.Conflict, (await client.PutAsync(Route, Json("{\"version\":1,\"revision\":4,\"widgets\":[]}"))).StatusCode);
@@ -157,7 +212,7 @@ public sealed class DashboardPreferencesTests
{
await preRead.Task.WaitAsync(TimeSpan.FromSeconds(10));
await using var db = host.Db();
const string future = "{\"version\":2,\"widgets\":[{\"id\":\"future-widget\",\"custom\":\"preserve\"}]}";
const string future = "{\"version\":3,\"widgets\":[{\"id\":\"future-widget\",\"custom\":\"preserve\"}]}";
await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE AspNetUsers SET DashboardLayoutJson={future}, DashboardLayoutRevision=1 WHERE Id='owner'");
releaseBody.TrySetResult();
Assert.Equal(HttpStatusCode.Conflict, (await pending).StatusCode);
@@ -190,7 +245,7 @@ public sealed class DashboardPreferencesTests
[InlineData("{\"version\":1,\"revision\":\"0\",\"widgets\":[]}", 400)]
[InlineData("{\"version\":1,\"revision\":9223372036854775807,\"widgets\":[]}", 400)]
[InlineData("{\"version\":1,\"revision\":9007199254740991,\"widgets\":[]}", 400)]
[InlineData("{\"version\":2,\"revision\":0,\"widgets\":[]}", 400)]
[InlineData("{\"version\":3,\"revision\":0,\"widgets\":[]}", 400)]
[InlineData("{\"version\":1,\"revision\":-1,\"widgets\":[]}", 400)]
[InlineData("{\"version\":1,\"revision\":0,\"widgets\":null}", 400)]
[InlineData("{\"version\":1,\"revision\":0,\"widgets\":[{\"id\":\"unknown\",\"visible\":true,\"size\":\"full\"}]}", 400)]