mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 09:14:25 +08:00
Move whiteboard persistence to file storage
Switch whiteboard note storage from legacy DB rows to per-note JSON files and add migration support. Update WhiteboardNoteSnapshot schema (version bump, viewport, canvas, expires, PathSvgData) and change IWhiteboardNotePersistenceService.SaveNote to return bool to surface write failures (e.g. read-only files). Implement file-based WhiteboardNotePersistenceService with legacy DB migration/cleanup, retention handling, and logging. Add comprehensive unit tests for persistence, stroke path builder, SVG import and viewport helper. Also add ThirdParty/DotNetCampus.InkCanvas project and reference it in the main csproj, and bump PostHog package to 2.6.0.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
@@ -9,24 +10,102 @@ namespace LanMountainDesktop.Tests;
|
||||
public sealed class WhiteboardNotePersistenceServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void SaveNote_ThenLoadNote_RoundTripsSnapshot()
|
||||
public void SaveNote_ThenLoadNote_RoundTripsFileSnapshot()
|
||||
{
|
||||
using var sandbox = new WhiteboardNotePersistenceSandbox();
|
||||
var service = sandbox.CreateService();
|
||||
var snapshot = CreateSampleSnapshot();
|
||||
|
||||
service.SaveNote("DesktopWhiteboard", "whiteboard-1", snapshot, retentionDays: 15);
|
||||
|
||||
var saved = service.SaveNote("DesktopWhiteboard", "whiteboard-1", snapshot, retentionDays: 15);
|
||||
var loaded = service.LoadNote("DesktopWhiteboard", "whiteboard-1", retentionDays: 15);
|
||||
|
||||
Assert.True(saved);
|
||||
Assert.True(File.Exists(sandbox.GetNoteFilePath("DesktopWhiteboard", "whiteboard-1")));
|
||||
Assert.Equal(2, loaded.Version);
|
||||
Assert.Single(loaded.Strokes);
|
||||
Assert.Equal(2, loaded.Strokes[0].Points.Count);
|
||||
Assert.Equal("M 0 0 L 12 12", loaded.Strokes[0].PathSvgData);
|
||||
Assert.Equal("#FF112233", loaded.Strokes[0].Color);
|
||||
Assert.Equal(1.75d, loaded.ViewportZoom);
|
||||
Assert.Equal(-24d, loaded.ViewportOffsetX);
|
||||
Assert.Equal(-36d, loaded.ViewportOffsetY);
|
||||
Assert.True(loaded.SavedUtc > DateTimeOffset.MinValue);
|
||||
Assert.True(loaded.ExpiresUtc > loaded.SavedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadNote_RemovesExpiredSnapshot_WhenRetentionExceeded()
|
||||
public void SaveNote_WithReadOnlyExistingFile_ReturnsFalseAndKeepsOldFile()
|
||||
{
|
||||
using var sandbox = new WhiteboardNotePersistenceSandbox();
|
||||
var service = sandbox.CreateService();
|
||||
var notePath = sandbox.GetNoteFilePath("DesktopWhiteboard", "read-only-board");
|
||||
|
||||
Assert.True(service.SaveNote("DesktopWhiteboard", "read-only-board", CreateSampleSnapshot("#FF112233"), retentionDays: 15));
|
||||
File.SetAttributes(notePath, File.GetAttributes(notePath) | FileAttributes.ReadOnly);
|
||||
|
||||
try
|
||||
{
|
||||
var saved = service.SaveNote("DesktopWhiteboard", "read-only-board", CreateSampleSnapshot("#FF445566"), retentionDays: 15);
|
||||
var loaded = service.LoadNote("DesktopWhiteboard", "read-only-board", retentionDays: 15);
|
||||
|
||||
Assert.False(saved);
|
||||
Assert.Equal("#FF112233", loaded.Strokes[0].Color);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.SetAttributes(notePath, FileAttributes.Normal);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveNote_WithEmptySnapshot_OverwritesOldContent()
|
||||
{
|
||||
using var sandbox = new WhiteboardNotePersistenceSandbox();
|
||||
var service = sandbox.CreateService();
|
||||
|
||||
Assert.True(service.SaveNote("DesktopWhiteboard", "clear-board", CreateSampleSnapshot(), retentionDays: 15));
|
||||
Assert.True(service.SaveNote("DesktopWhiteboard", "clear-board", new WhiteboardNoteSnapshot
|
||||
{
|
||||
CanvasWidth = 320,
|
||||
CanvasHeight = 180,
|
||||
ViewportZoom = 2d,
|
||||
ViewportOffsetX = -40d,
|
||||
ViewportOffsetY = -20d
|
||||
}, retentionDays: 15));
|
||||
|
||||
var loaded = service.LoadNote("DesktopWhiteboard", "clear-board", retentionDays: 15);
|
||||
|
||||
Assert.Empty(loaded.Strokes);
|
||||
Assert.Equal(2d, loaded.ViewportZoom);
|
||||
Assert.Equal(-40d, loaded.ViewportOffsetX);
|
||||
Assert.Equal(-20d, loaded.ViewportOffsetY);
|
||||
Assert.True(File.Exists(sandbox.GetNoteFilePath("DesktopWhiteboard", "clear-board")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadNote_WithOldJsonWithoutViewport_UsesDefaultViewport()
|
||||
{
|
||||
using var sandbox = new WhiteboardNotePersistenceSandbox();
|
||||
var service = sandbox.CreateService();
|
||||
sandbox.WriteRawNoteJson("DesktopWhiteboard", "old-json-board", """
|
||||
{
|
||||
"version": 2,
|
||||
"canvasWidth": 320,
|
||||
"canvasHeight": 180,
|
||||
"backgroundColor": "#FFFFFFFF",
|
||||
"strokes": []
|
||||
}
|
||||
""");
|
||||
|
||||
var loaded = service.LoadNote("DesktopWhiteboard", "old-json-board", retentionDays: 15);
|
||||
|
||||
Assert.Equal(1d, loaded.ViewportZoom);
|
||||
Assert.Equal(0d, loaded.ViewportOffsetX);
|
||||
Assert.Equal(0d, loaded.ViewportOffsetY);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadNote_RemovesExpiredFile_WhenRetentionExceeded()
|
||||
{
|
||||
using var sandbox = new WhiteboardNotePersistenceSandbox();
|
||||
var service = sandbox.CreateService();
|
||||
@@ -37,11 +116,11 @@ public sealed class WhiteboardNotePersistenceServiceTests
|
||||
var loaded = service.LoadNote("DesktopWhiteboard", "expired-board", retentionDays: 7);
|
||||
|
||||
Assert.Empty(loaded.Strokes);
|
||||
Assert.False(sandbox.Exists("DesktopWhiteboard", "expired-board"));
|
||||
Assert.False(File.Exists(sandbox.GetNoteFilePath("DesktopWhiteboard", "expired-board")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteExpiredNotesBatch_RemovesExpiredRows_AndKeepsFreshRows()
|
||||
public void DeleteExpiredNotesBatch_RemovesExpiredFiles_AndKeepsFreshFiles()
|
||||
{
|
||||
using var sandbox = new WhiteboardNotePersistenceSandbox();
|
||||
var service = sandbox.CreateService();
|
||||
@@ -57,22 +136,59 @@ public sealed class WhiteboardNotePersistenceServiceTests
|
||||
var deletedCount = service.DeleteExpiredNotesBatch(batchSize: 10);
|
||||
|
||||
Assert.Equal(2, deletedCount);
|
||||
Assert.False(sandbox.Exists("DesktopWhiteboard", "expired-a"));
|
||||
Assert.False(sandbox.Exists("DesktopWhiteboard", "expired-b"));
|
||||
Assert.True(sandbox.Exists("DesktopWhiteboard", "fresh-c"));
|
||||
Assert.False(File.Exists(sandbox.GetNoteFilePath("DesktopWhiteboard", "expired-a")));
|
||||
Assert.False(File.Exists(sandbox.GetNoteFilePath("DesktopWhiteboard", "expired-b")));
|
||||
Assert.True(File.Exists(sandbox.GetNoteFilePath("DesktopWhiteboard", "fresh-c")));
|
||||
}
|
||||
|
||||
private static WhiteboardNoteSnapshot CreateSampleSnapshot()
|
||||
[Fact]
|
||||
public void LoadNote_MigratesLegacyDatabaseSnapshot_WhenFileMissing()
|
||||
{
|
||||
using var sandbox = new WhiteboardNotePersistenceSandbox();
|
||||
sandbox.SaveLegacyNote("DesktopWhiteboard", "legacy-board", CreateSampleSnapshot("#FF778899"), retentionDays: 15);
|
||||
var service = sandbox.CreateService();
|
||||
|
||||
var loaded = service.LoadNote("DesktopWhiteboard", "legacy-board", retentionDays: 15);
|
||||
|
||||
Assert.Single(loaded.Strokes);
|
||||
Assert.Equal("#FF778899", loaded.Strokes[0].Color);
|
||||
Assert.True(File.Exists(sandbox.GetNoteFilePath("DesktopWhiteboard", "legacy-board")));
|
||||
Assert.False(sandbox.LegacyExists("DesktopWhiteboard", "legacy-board"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteNote_RemovesFileAndLegacyRow()
|
||||
{
|
||||
using var sandbox = new WhiteboardNotePersistenceSandbox();
|
||||
sandbox.SaveLegacyNote("DesktopWhiteboard", "delete-board", CreateSampleSnapshot(), retentionDays: 15);
|
||||
var service = sandbox.CreateService();
|
||||
service.SaveNote("DesktopWhiteboard", "delete-board", CreateSampleSnapshot(), retentionDays: 15);
|
||||
|
||||
var deleted = service.DeleteNote("DesktopWhiteboard", "delete-board");
|
||||
|
||||
Assert.True(deleted);
|
||||
Assert.False(File.Exists(sandbox.GetNoteFilePath("DesktopWhiteboard", "delete-board")));
|
||||
Assert.False(sandbox.LegacyExists("DesktopWhiteboard", "delete-board"));
|
||||
}
|
||||
|
||||
private static WhiteboardNoteSnapshot CreateSampleSnapshot(string color = "#FF112233")
|
||||
{
|
||||
return new WhiteboardNoteSnapshot
|
||||
{
|
||||
CanvasWidth = 320,
|
||||
CanvasHeight = 180,
|
||||
BackgroundColor = "#FFFFFFFF",
|
||||
ViewportZoom = 1.75d,
|
||||
ViewportOffsetX = -24d,
|
||||
ViewportOffsetY = -36d,
|
||||
Strokes =
|
||||
[
|
||||
new WhiteboardStrokeSnapshot
|
||||
{
|
||||
Color = "#FF112233",
|
||||
Color = color,
|
||||
InkThickness = 3.5d,
|
||||
IgnorePressure = true,
|
||||
PathSvgData = "M 0 0 L 12 12",
|
||||
Points =
|
||||
[
|
||||
new WhiteboardStylusPointSnapshot { X = 12, Y = 34, Pressure = 0.4d, Width = 2, Height = 2 },
|
||||
@@ -91,40 +207,93 @@ public sealed class WhiteboardNotePersistenceServiceTests
|
||||
Guid.NewGuid().ToString("N"));
|
||||
|
||||
private readonly string _databasePath;
|
||||
private readonly string _whiteboardsRootDirectory;
|
||||
|
||||
public WhiteboardNotePersistenceSandbox()
|
||||
{
|
||||
Directory.CreateDirectory(_directoryPath);
|
||||
_databasePath = Path.Combine(_directoryPath, "whiteboard-tests.db");
|
||||
_whiteboardsRootDirectory = Path.Combine(_directoryPath, "Whiteboards");
|
||||
}
|
||||
|
||||
public WhiteboardNotePersistenceService CreateService()
|
||||
{
|
||||
return new WhiteboardNotePersistenceService(new AppDatabaseService(_databasePath));
|
||||
return new WhiteboardNotePersistenceService(
|
||||
_whiteboardsRootDirectory,
|
||||
new AppDatabaseService(_databasePath));
|
||||
}
|
||||
|
||||
public string GetNoteFilePath(string componentId, string placementId)
|
||||
{
|
||||
return CreateService().GetNoteFilePathForTests(componentId, placementId);
|
||||
}
|
||||
|
||||
public void OverrideSavedTimestamp(string componentId, string placementId, DateTimeOffset savedUtc, int retentionDays)
|
||||
{
|
||||
var expiresUtc = savedUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays));
|
||||
var notePath = GetNoteFilePath(componentId, placementId);
|
||||
var snapshot = JsonSerializer.Deserialize<WhiteboardNoteSnapshot>(
|
||||
File.ReadAllText(notePath),
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new WhiteboardNoteSnapshot();
|
||||
snapshot.SavedUtc = savedUtc;
|
||||
snapshot.ExpiresUtc = savedUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays));
|
||||
File.WriteAllText(notePath, JsonSerializer.Serialize(snapshot, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
|
||||
public void SaveLegacyNote(string componentId, string placementId, WhiteboardNoteSnapshot snapshot, int retentionDays)
|
||||
{
|
||||
var nowUtc = DateTimeOffset.UtcNow;
|
||||
var expiresUtc = nowUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays));
|
||||
using var connection = new AppDatabaseService(_databasePath).OpenConnection();
|
||||
using (var schemaCommand = connection.CreateCommand())
|
||||
{
|
||||
schemaCommand.CommandText = """
|
||||
CREATE TABLE IF NOT EXISTS whiteboard_notes (
|
||||
component_id TEXT NOT NULL,
|
||||
placement_id TEXT NOT NULL,
|
||||
note_json TEXT NOT NULL,
|
||||
saved_at_utc_ms INTEGER NOT NULL,
|
||||
expires_at_utc_ms INTEGER NOT NULL,
|
||||
updated_at_utc_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (component_id, placement_id)
|
||||
);
|
||||
""";
|
||||
schemaCommand.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
UPDATE whiteboard_notes
|
||||
SET saved_at_utc_ms = $savedAtUtcMs,
|
||||
expires_at_utc_ms = $expiresAtUtcMs,
|
||||
updated_at_utc_ms = $updatedAtUtcMs
|
||||
WHERE component_id = $componentId
|
||||
AND placement_id = $placementId;
|
||||
INSERT INTO whiteboard_notes(
|
||||
component_id,
|
||||
placement_id,
|
||||
note_json,
|
||||
saved_at_utc_ms,
|
||||
expires_at_utc_ms,
|
||||
updated_at_utc_ms)
|
||||
VALUES(
|
||||
$componentId,
|
||||
$placementId,
|
||||
$noteJson,
|
||||
$savedAtUtcMs,
|
||||
$expiresAtUtcMs,
|
||||
$updatedAtUtcMs);
|
||||
""";
|
||||
command.Parameters.AddWithValue("$savedAtUtcMs", savedUtc.ToUnixTimeMilliseconds());
|
||||
command.Parameters.AddWithValue("$expiresAtUtcMs", expiresUtc.ToUnixTimeMilliseconds());
|
||||
command.Parameters.AddWithValue("$updatedAtUtcMs", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||
command.Parameters.AddWithValue("$componentId", componentId);
|
||||
command.Parameters.AddWithValue("$placementId", placementId);
|
||||
command.Parameters.AddWithValue("$noteJson", JsonSerializer.Serialize(snapshot));
|
||||
command.Parameters.AddWithValue("$savedAtUtcMs", nowUtc.ToUnixTimeMilliseconds());
|
||||
command.Parameters.AddWithValue("$expiresAtUtcMs", expiresUtc.ToUnixTimeMilliseconds());
|
||||
command.Parameters.AddWithValue("$updatedAtUtcMs", nowUtc.ToUnixTimeMilliseconds());
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public bool Exists(string componentId, string placementId)
|
||||
public void WriteRawNoteJson(string componentId, string placementId, string json)
|
||||
{
|
||||
var notePath = GetNoteFilePath(componentId, placementId);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(notePath)!);
|
||||
File.WriteAllText(notePath, json);
|
||||
}
|
||||
|
||||
public bool LegacyExists(string componentId, string placementId)
|
||||
{
|
||||
using var connection = new AppDatabaseService(_databasePath).OpenConnection();
|
||||
using var command = connection.CreateCommand();
|
||||
|
||||
77
LanMountainDesktop.Tests/WhiteboardStrokePathBuilderTests.cs
Normal file
77
LanMountainDesktop.Tests/WhiteboardStrokePathBuilderTests.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using DotNetCampus.Inking.Primitive;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class WhiteboardStrokePathBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildPath_WithEmptyPointList_ReturnsEmptyPath()
|
||||
{
|
||||
using var path = WhiteboardStrokePathBuilder.BuildPath(Array.Empty<InkStylusPoint>(), inkThickness: 3d);
|
||||
|
||||
Assert.True(path.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPath_WithSinglePoint_CreatesVisibleStroke()
|
||||
{
|
||||
using var path = WhiteboardStrokePathBuilder.BuildPath(
|
||||
[CreatePoint(24, 32)],
|
||||
inkThickness: 6d);
|
||||
|
||||
Assert.False(path.IsEmpty);
|
||||
Assert.True(path.Bounds.Width >= 5.5f);
|
||||
Assert.True(path.Bounds.Height >= 5.5f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPath_WithMultiplePoints_CreatesFilledStroke()
|
||||
{
|
||||
using var path = WhiteboardStrokePathBuilder.BuildPath(
|
||||
[
|
||||
CreatePoint(10, 10),
|
||||
CreatePoint(30, 18),
|
||||
CreatePoint(52, 14)
|
||||
],
|
||||
inkThickness: 4d);
|
||||
|
||||
Assert.False(path.IsEmpty);
|
||||
Assert.True(path.Bounds.Width > 40f);
|
||||
Assert.True(path.Bounds.Height > 4f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPath_WithThickerStroke_ExpandsStrokeBounds()
|
||||
{
|
||||
var points = new[]
|
||||
{
|
||||
CreatePoint(10, 10),
|
||||
CreatePoint(80, 10)
|
||||
};
|
||||
|
||||
using var thinPath = WhiteboardStrokePathBuilder.BuildPath(points, inkThickness: 1d);
|
||||
using var thickPath = WhiteboardStrokePathBuilder.BuildPath(points, inkThickness: 8d);
|
||||
|
||||
Assert.True(thickPath.Bounds.Height > thinPath.Bounds.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPath_WithNonFinitePoints_UsesRemainingFinitePoints()
|
||||
{
|
||||
using var path = WhiteboardStrokePathBuilder.BuildPath(
|
||||
[
|
||||
CreatePoint(double.NaN, 10),
|
||||
CreatePoint(20, 20)
|
||||
],
|
||||
inkThickness: 4d);
|
||||
|
||||
Assert.False(path.IsEmpty);
|
||||
}
|
||||
|
||||
private static InkStylusPoint CreatePoint(double x, double y)
|
||||
{
|
||||
return new InkStylusPoint(x, y, pressure: 1f);
|
||||
}
|
||||
}
|
||||
65
LanMountainDesktop.Tests/WhiteboardSvgImportServiceTests.cs
Normal file
65
LanMountainDesktop.Tests/WhiteboardSvgImportServiceTests.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class WhiteboardSvgImportServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Import_WithFilledPath_CreatesStaticStrokeSnapshot()
|
||||
{
|
||||
using var stream = ToStream("""
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50">
|
||||
<path d="M 10 10 L 90 10 L 90 40 Z" fill="#112233" />
|
||||
</svg>
|
||||
""");
|
||||
|
||||
var result = WhiteboardSvgImportService.Import(stream, targetWidth: 200, targetHeight: 100);
|
||||
|
||||
Assert.Single(result.Strokes);
|
||||
Assert.Equal("#FF112233", result.Strokes[0].Color);
|
||||
Assert.Empty(result.Strokes[0].Points);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.Strokes[0].PathSvgData));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Import_WithStrokePath_ConvertsStrokeToFilledPath()
|
||||
{
|
||||
using var stream = ToStream("""
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
|
||||
<path d="M 10 10 L 90 90" fill="none" stroke="red" stroke-width="6" />
|
||||
</svg>
|
||||
""");
|
||||
|
||||
var result = WhiteboardSvgImportService.Import(stream, targetWidth: 100, targetHeight: 100);
|
||||
|
||||
Assert.Single(result.Strokes);
|
||||
Assert.Equal("#FFFF0000", result.Strokes[0].Color);
|
||||
Assert.True(result.Strokes[0].InkThickness >= 6d);
|
||||
Assert.Empty(result.Strokes[0].Points);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.Strokes[0].PathSvgData));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Import_WithStylePresentationAttributes_ParsesStyleValues()
|
||||
{
|
||||
using var stream = ToStream("""
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
|
||||
<path d="M 10 10 L 20 20" style="fill:none;stroke:#00FF00;stroke-width:4px" />
|
||||
</svg>
|
||||
""");
|
||||
|
||||
var result = WhiteboardSvgImportService.Import(stream, targetWidth: 100, targetHeight: 100);
|
||||
|
||||
Assert.Single(result.Strokes);
|
||||
Assert.Equal("#FF00FF00", result.Strokes[0].Color);
|
||||
Assert.True(result.Strokes[0].InkThickness >= 4d);
|
||||
}
|
||||
|
||||
private static MemoryStream ToStream(string svg)
|
||||
{
|
||||
return new MemoryStream(Encoding.UTF8.GetBytes(svg));
|
||||
}
|
||||
}
|
||||
68
LanMountainDesktop.Tests/WhiteboardViewportHelperTests.cs
Normal file
68
LanMountainDesktop.Tests/WhiteboardViewportHelperTests.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using Avalonia;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class WhiteboardViewportHelperTests
|
||||
{
|
||||
[Fact]
|
||||
public void ZoomAt_WithCenterAnchor_KeepsAnchorLogicalPointStable()
|
||||
{
|
||||
var viewportSize = new Size(200, 100);
|
||||
var canvasSize = new Size(400, 200);
|
||||
var state = new WhiteboardViewportState(1d, default);
|
||||
var anchor = new Point(100, 50);
|
||||
var before = WhiteboardViewportHelper.ToLogicalPoint(state, anchor);
|
||||
|
||||
var zoomed = WhiteboardViewportHelper.ZoomAt(state, 2d, anchor, viewportSize, canvasSize);
|
||||
var after = WhiteboardViewportHelper.ToLogicalPoint(zoomed, anchor);
|
||||
|
||||
Assert.Equal(before.X, after.X, precision: 3);
|
||||
Assert.Equal(before.Y, after.Y, precision: 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PanBy_ClampsToScaledCanvasBounds()
|
||||
{
|
||||
var viewportSize = new Size(100, 100);
|
||||
var canvasSize = new Size(200, 200);
|
||||
var state = new WhiteboardViewportState(2d, default);
|
||||
|
||||
var positive = WhiteboardViewportHelper.PanBy(state, new Vector(500, 500), viewportSize, canvasSize);
|
||||
var negative = WhiteboardViewportHelper.PanBy(state, new Vector(-500, -500), viewportSize, canvasSize);
|
||||
|
||||
Assert.Equal(0d, positive.Offset.X, precision: 3);
|
||||
Assert.Equal(0d, positive.Offset.Y, precision: 3);
|
||||
Assert.Equal(-300d, negative.Offset.X, precision: 3);
|
||||
Assert.Equal(-300d, negative.Offset.Y, precision: 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clamp_WhenCanvasIsSmallerThanViewport_CentersCanvas()
|
||||
{
|
||||
var state = new WhiteboardViewportState(1d, new Vector(-40, -40));
|
||||
|
||||
var clamped = WhiteboardViewportHelper.Clamp(
|
||||
state,
|
||||
new Size(300, 300),
|
||||
new Size(100, 100));
|
||||
|
||||
Assert.Equal(100d, clamped.Offset.X, precision: 3);
|
||||
Assert.Equal(100d, clamped.Offset.Y, precision: 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clamp_AfterViewportResize_KeepsOffsetInsideBounds()
|
||||
{
|
||||
var state = new WhiteboardViewportState(2d, new Vector(-220, -220));
|
||||
|
||||
var clamped = WhiteboardViewportHelper.Clamp(
|
||||
state,
|
||||
new Size(300, 300),
|
||||
new Size(200, 200));
|
||||
|
||||
Assert.Equal(-100d, clamped.Offset.X, precision: 3);
|
||||
Assert.Equal(-100d, clamped.Offset.Y, precision: 3);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user