diff --git a/src/PostIt.Tests/SignaturePageViewModelTests.cs b/src/PostIt.Tests/SignaturePageViewModelTests.cs new file mode 100644 index 00000000..37f17a58 --- /dev/null +++ b/src/PostIt.Tests/SignaturePageViewModelTests.cs @@ -0,0 +1,163 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using PostIt.Controls; +using PostIt.ViewModels; +using Xunit; + +namespace PostIt.Tests; + +/// +/// Tests for : the contract +/// between the page's view model and the . +/// 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. +/// +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( + () => new SignaturePageViewModel(0, 100)); + Assert.Throws( + () => new SignaturePageViewModel(100, 0)); + Assert.Throws( + () => 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(() => 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}"); + } +} diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index 65ce8b26..134ae42f 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -63,6 +63,7 @@ public partial class App : Application services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // ViewModels services.AddSingleton(settings); @@ -72,6 +73,7 @@ public partial class App : Application services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // Persistent session banner: one instance for the lifetime of // the app so the same VM survives page navigation. diff --git a/src/PostIt/PostIt/Models/SignaturePadData.cs b/src/PostIt/PostIt/Models/SignaturePadData.cs index 9a2c0e48..11568eac 100644 --- a/src/PostIt/PostIt/Models/SignaturePadData.cs +++ b/src/PostIt/PostIt/Models/SignaturePadData.cs @@ -78,4 +78,26 @@ public sealed class SignaturePadData return n; } } + + /// + /// Total number of (x, y) pairs across all strokes. Useful + /// for sanity-checks and for displaying capture density + /// without re-walking the wire format. + /// + public int PointCount + { + get + { + int n = 0; + int i = 0; + while (i < Strokes.Length) + { + int k = Strokes[i]; + if (k <= 0) break; + n += k; + i += 1 + 2 * k; + } + return n; + } + } } diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index 9a05aa84..e6d0e91a 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -29,6 +29,7 @@ public class ViewLocator : IDataTemplate SettingsPageViewModel => _services.GetRequiredService(), LoginPageViewModel => _services.GetRequiredService(), HomePageViewModel => _services.GetRequiredService(), + SignaturePageViewModel => _services.GetRequiredService(), _ => new TextBlock { Text = $"No view for {data.GetType().Name}" } }; } diff --git a/src/PostIt/PostIt/ViewModels/SignaturePageViewModel.cs b/src/PostIt/PostIt/ViewModels/SignaturePageViewModel.cs new file mode 100644 index 00000000..b4b37974 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/SignaturePageViewModel.cs @@ -0,0 +1,185 @@ +using System; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PostIt.Controls; +using PostIt.Models; + +namespace PostIt.ViewModels; + +/// +/// Backing state for . +/// +/// The page exists to produce a +/// (length-prefixed normalised int[]) from a human signature drawn +/// with the mouse (Desktop) or finger (touch / Android). The page +/// is a recipient of an external trigger — a SignalR push from +/// Yavsc.Org telling PostIt "a devis has been sent, sign here" — +/// so it intentionally has no first-class entry point in +/// . The only "open" affordance today is a +/// dev-only shortcut on the blog editor, marked for removal once +/// the SignalR handler lands. +/// +/// Output path is the platform-friendly per-user data directory +/// (XDG_DATA_HOME / AppData / NSDocumentDirectory on iOS). Files +/// are JSON, one per capture, named +/// signature-{yyyyMMdd-HHmmssfff}.json. This is a stop-gap +/// until the Yavsc.Org endpoint exists; the contract there will +/// be POST /api/signature/{devisId} with this same payload. +/// +public partial class SignaturePageViewModel : ViewModelBase +{ + /// + /// Default capture surface, in DIPs. 3:1 ratio matches a + /// signature line at the bottom of an A4 contract. + /// + public const double DefaultWidth = 600; + public const double DefaultHeight = 200; + + [ObservableProperty] + public partial string StatusMessage { get; set; } = "Prêt."; + + [ObservableProperty] + public partial int StrokeCount { get; set; } + + [ObservableProperty] + public partial int PointCount { get; set; } + + [ObservableProperty] + public partial string? LastCapturedPath { get; set; } + + public double Width { get; } + public double Height { get; } + + private SignaturePadControl? _control; + + public override bool CanNavigateNext + { + get => false; + protected set { _ = value; } + } + + public override bool CanNavigatePrevious + { + get => true; + protected set { _ = value; } + } + + public SignaturePageViewModel() + : this(DefaultWidth, DefaultHeight) + { + } + + public SignaturePageViewModel(double width, double height) + { + if (width <= 0) throw new ArgumentOutOfRangeException(nameof(width)); + if (height <= 0) throw new ArgumentOutOfRangeException(nameof(height)); + Width = width; + Height = height; + } + + /// + /// Bind a freshly-constructed (or re-templated) control to this + /// VM. Called from the view's code-behind once the control has + /// been added to the visual tree and its template applied (so + /// is wired). + /// + public void Attach(SignaturePadControl control) + { + if (control is null) throw new ArgumentNullException(nameof(control)); + Detach(); + _control = control; + _control.RedrawRequested += OnRedraw; + _control.StrokeCompleted += OnStrokeCompleted; + RefreshCounts(); + } + + public void Detach() + { + if (_control is null) return; + _control.RedrawRequested -= OnRedraw; + _control.StrokeCompleted -= OnStrokeCompleted; + _control = null; + } + + private void OnStrokeCompleted(object? sender, SignaturePadData data) + { + StatusMessage = $"Trait terminé. {data.StrokeCount} trait(s)."; + RefreshCounts(); + } + + private void OnRedraw(object? sender, EventArgs e) => RefreshCounts(); + + private void RefreshCounts() + { + if (_control is null) return; + var snap = _control.Snapshot(); + StrokeCount = snap.StrokeCount; + PointCount = snap.PointCount; + } + + [RelayCommand] + public void Clear() + { + _control?.Clear(); + StatusMessage = "Effacé."; + RefreshCounts(); + } + + [RelayCommand] + public async Task CaptureAsync() + { + if (_control is null) + { + StatusMessage = "Contrôle non attaché."; + return; + } + + var data = _control.Snapshot(); + if (data.IsEmpty) + { + StatusMessage = "Rien à capturer."; + return; + } + + try + { + var path = WriteCapture(data); + LastCapturedPath = path; + StatusMessage = $"Capture enregistrée: {path}"; + } + catch (Exception ex) + { + StatusMessage = $"Erreur: {ex.Message}"; + } + await Task.CompletedTask; + } + + private static string WriteCapture(SignaturePadData data) + { + var dir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "PostIt", "signatures"); + Directory.CreateDirectory(dir); + + var fileName = $"signature-{DateTime.UtcNow:yyyyMMdd-HHmmssfff}.json"; + var path = Path.Combine(dir, fileName); + + var payload = new + { + format = "yavsc.signature/v1", + coordinateMax = SignaturePadData.CoordinateMax, + capturedAtUtc = DateTime.UtcNow, + strokes = data.Strokes, + strokeCount = data.StrokeCount, + }; + File.WriteAllText( + path, + JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }), + Encoding.UTF8); + return path; + } +} diff --git a/src/PostIt/PostIt/Views/MainPage.axaml b/src/PostIt/PostIt/Views/MainPage.axaml index b9e0308d..c09c2f7a 100644 --- a/src/PostIt/PostIt/Views/MainPage.axaml +++ b/src/PostIt/PostIt/Views/MainPage.axaml @@ -35,6 +35,17 @@