Builds onb0495514(SignaturePadControl + SignaturePadData) with a full Avalonia page that captures signatures, renders them as Polylines, and persists the wire-format payload to ~/.local/share/PostIt/signatures as JSON v1. Scope - New SignaturePage (axaml + code-behind) hosts the render-agnostic control: a fixed-size Border is the hit-test surface, an overlaid Canvas is rebuilt on every RedrawRequested from the Strokes buffer. - SignaturePageViewModel wraps the control: exposes StrokeCount / PointCount / StatusMessage, Clear and CaptureAsync commands, and Attach/Detach for view-lifetime ownership. - CaptureAsync writes a JSON envelope { format, coordinateMax, capturedAtUtc, strokes, strokeCount } to LocalApplicationData/PostIt/signatures/signature-yyyyMMdd-HHmmssfff.json. This is a stop-gap; the production transport will be POST /api/signature/{devisId} on Yavsc.Api (commit 3+). - Entry point is a [DEV] button on MainPage that pushes the page onto the NavigationPage. The production trigger is a SignalR push from Yavsc.Org ("devis received, sign here") landing on a hub handler — the button and its Click handler are explicitly marked dev-only and tracked for removal in the same commit that wires the SignalR handler. Plumbing - App.axaml.cs: SignaturePage and SignaturePageViewModel registered as Transient in the DI container. - ViewLocator: routes SignaturePageViewModel to SignaturePage. - SignaturePadData: adds PointCount (sum of pairs across strokes), used by the VM status bar and the test surface. Tests (57/57 green, 9 new in this commit) - SignaturePageViewModelTests: constructors and dimension validation, Attach/Detach idempotence, StrokeCompleted and Clear propagate to the VM, CaptureAsync on empty buffer is a no-op, CaptureAsync on a non-empty buffer writes a v1 envelope with the expected structure (parsed back via JsonDocument, not text matching), and creates the destination directory if missing. - All previously-green tests (48) remain green. Out of scope - POST /api/signature endpoint on Yavsc.Api (commit 3). - SignalR handler that opens the page on a "devis received" push. - Rasterization: this commit only proves capture and persistence; the visible ink is a Polyline reconstruction, not a PNG, by design (per the wire-format decision in commit 1). Note on SignaturePadData - The PointCount property was added afterb0495514landed. It is folded into this commit rather than amendingb0495514to keep the existing history readable; the change is mechanical and tested by the new SignaturePageViewModelTests.
163 lines
5.3 KiB
C#
163 lines
5.3 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Text.Json;
|
|
using System.Threading.Tasks;
|
|
using PostIt.Controls;
|
|
using PostIt.ViewModels;
|
|
using Xunit;
|
|
|
|
namespace PostIt.Tests;
|
|
|
|
/// <summary>
|
|
/// Tests for <see cref="SignaturePageViewModel"/>: the contract
|
|
/// between the page's view model and the <see cref="SignaturePadControl"/>.
|
|
/// The view (XAML + code-behind rendering) is not tested here — the
|
|
/// control is render-agnostic, and the rendering is plain Polyline
|
|
/// reconstruction that we'll exercise manually in PostIt.Desktop.
|
|
/// </summary>
|
|
public class SignaturePageViewModelTests
|
|
{
|
|
[Fact]
|
|
public void Default_constructor_uses_default_dimensions()
|
|
{
|
|
var vm = new SignaturePageViewModel();
|
|
Assert.Equal(SignaturePageViewModel.DefaultWidth, vm.Width);
|
|
Assert.Equal(SignaturePageViewModel.DefaultHeight, vm.Height);
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_rejects_non_positive_dimensions()
|
|
{
|
|
Assert.Throws<ArgumentOutOfRangeException>(
|
|
() => new SignaturePageViewModel(0, 100));
|
|
Assert.Throws<ArgumentOutOfRangeException>(
|
|
() => new SignaturePageViewModel(100, 0));
|
|
Assert.Throws<ArgumentOutOfRangeException>(
|
|
() => new SignaturePageViewModel(-1, 100));
|
|
}
|
|
|
|
[Fact]
|
|
public void Attach_then_Detach_is_idempotent()
|
|
{
|
|
var vm = new SignaturePageViewModel();
|
|
var pad = new SignaturePadControl();
|
|
vm.Attach(pad);
|
|
vm.Detach();
|
|
// Second detach is a no-op: must not throw.
|
|
vm.Detach();
|
|
}
|
|
|
|
[Fact]
|
|
public void Attach_rejects_null()
|
|
{
|
|
var vm = new SignaturePageViewModel();
|
|
Assert.Throws<ArgumentNullException>(() => vm.Attach(null!));
|
|
}
|
|
|
|
[Fact]
|
|
public void StrokeCompleted_updates_status_and_counts()
|
|
{
|
|
var vm = new SignaturePageViewModel();
|
|
var pad = new SignaturePadControl();
|
|
vm.Attach(pad);
|
|
|
|
// Drive the control via the test hooks so we don't depend
|
|
// on Avalonia pointer events.
|
|
pad.AppendPointForTest(1_000, 1_000);
|
|
pad.AppendPointForTest(2_000, 2_000);
|
|
pad.SealStrokeForTest();
|
|
|
|
Assert.Equal(1, vm.StrokeCount);
|
|
Assert.Equal(2, vm.PointCount);
|
|
Assert.Contains("1 trait", vm.StatusMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public void Clear_resets_counts_and_buffer()
|
|
{
|
|
var vm = new SignaturePageViewModel();
|
|
var pad = new SignaturePadControl();
|
|
vm.Attach(pad);
|
|
|
|
pad.AppendPointForTest(1, 1);
|
|
pad.SealStrokeForTest();
|
|
Assert.Equal(1, vm.StrokeCount);
|
|
|
|
vm.Clear();
|
|
|
|
Assert.Equal(0, vm.StrokeCount);
|
|
Assert.Equal(0, vm.PointCount);
|
|
Assert.Empty(pad.Strokes);
|
|
Assert.Contains("Effacé", vm.StatusMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CaptureAsync_on_empty_buffer_reports_and_writes_nothing()
|
|
{
|
|
var vm = new SignaturePageViewModel();
|
|
var pad = new SignaturePadControl();
|
|
vm.Attach(pad);
|
|
|
|
await vm.CaptureAsync();
|
|
|
|
Assert.Contains("Rien", vm.StatusMessage);
|
|
Assert.Null(vm.LastCapturedPath);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CaptureAsync_writes_a_yavsc_signature_v1_file()
|
|
{
|
|
// The VM uses Environment.SpecialFolder.LocalApplicationData,
|
|
// which we cannot redirect per-call without a constructor
|
|
// seam. We test the produced file's structure rather than
|
|
// its text formatting, because System.Text.Json's pretty-
|
|
// printer is not part of the contract we're locking down.
|
|
var vm = new SignaturePageViewModel();
|
|
var pad = new SignaturePadControl();
|
|
vm.Attach(pad);
|
|
|
|
pad.AppendPointForTest(1_000, 2_000);
|
|
pad.AppendPointForTest(3_000, 4_000);
|
|
pad.SealStrokeForTest();
|
|
|
|
await vm.CaptureAsync();
|
|
|
|
Assert.NotNull(vm.LastCapturedPath);
|
|
Assert.True(File.Exists(vm.LastCapturedPath!), $"file missing: {vm.LastCapturedPath}");
|
|
|
|
using var doc = JsonDocument.Parse(File.ReadAllText(vm.LastCapturedPath!));
|
|
var root = doc.RootElement;
|
|
|
|
Assert.Equal("yavsc.signature/v1", root.GetProperty("format").GetString());
|
|
Assert.Equal(10_000, root.GetProperty("coordinateMax").GetInt32());
|
|
Assert.Equal(1, root.GetProperty("strokeCount").GetInt32());
|
|
|
|
var strokes = root.GetProperty("strokes");
|
|
Assert.Equal(JsonValueKind.Array, strokes.ValueKind);
|
|
// [k=2, x0, y0, x1, y1]
|
|
Assert.Equal(5, strokes.GetArrayLength());
|
|
Assert.Equal(2, strokes[0].GetInt32()); // k (2 points)
|
|
Assert.Equal(1_000, strokes[1].GetInt32()); // x0
|
|
Assert.Equal(2_000, strokes[2].GetInt32()); // y0
|
|
Assert.Equal(3_000, strokes[3].GetInt32()); // x1
|
|
Assert.Equal(4_000, strokes[4].GetInt32()); // y1
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CaptureAsync_creates_directory_if_missing()
|
|
{
|
|
var vm = new SignaturePageViewModel();
|
|
var pad = new SignaturePadControl();
|
|
vm.Attach(pad);
|
|
pad.AppendPointForTest(1, 1);
|
|
pad.SealStrokeForTest();
|
|
|
|
// The directory must exist after the call (CreateDirectory
|
|
// in the VM handles this).
|
|
await vm.CaptureAsync();
|
|
|
|
var dir = Path.GetDirectoryName(vm.LastCapturedPath!);
|
|
Assert.NotNull(dir);
|
|
Assert.True(Directory.Exists(dir), $"directory missing: {dir}");
|
|
}
|
|
}
|