junction/tests/Junction.Tests/Unit/MachineDetailViewModelTests.cs
dtrentin 54f8b3be25 feat: history & trends (v0.6)
Time-series history persisted + per-item trend sparklines in detail view.

Data (Domain+Persistence): HistoryPoint model; IMachineRepository +Append/
Get/Prune history; snapshot_history table (append-only) + index; DeleteAsync
cascades history. Port purity kept (Result/Cancelled, no throw).

Core: MachineMonitor.PersistAsync appends history on Connected snapshots +
throttled retention prune (7-day window, <=1/hour via Interlocked-CAS), never
blocks/undoes latest-snapshot save.

App: custom Sparkline Control (StreamGeometry polyline, min/max-scaled,
net48-safe, no charting dep); DataItemRowViewModel observable +Trend/HasTrend +
pure TryParseNumeric; MachineDetailViewModel loads per-item history (1h window,
60 pts) on load + each snapshot, parses numeric, sets Trend on UI thread;
detail view Trend column.

Projects: Domain, Persistence, Core, App, Tests (+31: 196 pass + 2 skip).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 08:57:52 +02:00

165 lines
7.2 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Junction.App.ViewModels;
using Junction.Core.Monitoring;
using Junction.Domain;
using Junction.Domain.Models;
using Junction.Domain.Persistence;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Junction.Tests.Unit
{
/// <summary>
/// Trend-load behaviour of the detail VM: after a load, each row whose history parses as numeric
/// gets a populated <see cref="DataItemRowViewModel.Trend"/> (ascending order) with
/// <c>HasTrend</c> true; a row with non-numeric history stays null / <c>HasTrend</c> false.
/// Fixture: Moq'd <see cref="IMachineRepository"/> + <see cref="IMachineMonitor"/> + null logger.
/// The fire-and-forget trend load is awaited via the VM's internal <c>TrendLoadTask</c>.
/// </summary>
public class MachineDetailViewModelTests
{
private const string NumericItemId = "temp";
private const string TextItemId = "avail";
private static readonly Guid MachineId = Guid.NewGuid();
private static MachineSnapshot SnapshotWithBothItems()
{
var now = DateTimeOffset.UtcNow;
var items = new List<DataItem>
{
new DataItem(NumericItemId, "Temperature", "12", "Sample", now),
new DataItem(TextItemId, "Availability", "AVAILABLE", "Event", now),
};
return new MachineSnapshot(MachineId, now, ConnectionState.Connected, items);
}
private static IReadOnlyList<HistoryPoint> NumericHistory()
{
var t = DateTimeOffset.UtcNow;
return new List<HistoryPoint>
{
new HistoryPoint(NumericItemId, "10", t.AddSeconds(-30)),
new HistoryPoint(NumericItemId, "12.5", t.AddSeconds(-20)),
new HistoryPoint(NumericItemId, "11", t.AddSeconds(-10)),
};
}
private static IReadOnlyList<HistoryPoint> TextHistory()
{
var t = DateTimeOffset.UtcNow;
return new List<HistoryPoint>
{
new HistoryPoint(TextItemId, "AVAILABLE", t.AddSeconds(-20)),
new HistoryPoint(TextItemId, "UNAVAILABLE", t.AddSeconds(-10)),
};
}
private static MachineDetailViewModel BuildVm(
Mock<IMachineRepository> repo,
Mock<IMachineMonitor> monitor)
{
var vm = new MachineDetailViewModel(
repo.Object, monitor.Object, NullLogger<MachineDetailViewModel>.Instance);
vm.Initialize(MachineId, "VMC Sim (mock)");
return vm;
}
private static Mock<IMachineMonitor> MonitorWithSnapshot(MachineSnapshot snapshot)
{
var monitor = new Mock<IMachineMonitor>();
var cache = new Dictionary<Guid, MachineSnapshot> { [MachineId] = snapshot };
monitor.SetupGet(m => m.LatestSnapshots)
.Returns((IReadOnlyDictionary<Guid, MachineSnapshot>)cache);
return monitor;
}
private static DataItemRowViewModel Row(MachineDetailViewModel vm, string id) =>
vm.Items.First(r => r.Id == id);
[Fact]
public async Task LoadAsync_NumericHistory_PopulatesTrendAscending()
{
var snapshot = SnapshotWithBothItems();
var repo = new Mock<IMachineRepository>();
repo.Setup(r => r.GetByIdAsync(MachineId, It.IsAny<CancellationToken>()))
.ReturnsAsync(Result<Machine>.Ok(new Machine(MachineId, "VMC", "mtconnect", null, TimeSpan.FromSeconds(2))));
repo.Setup(r => r.GetHistoryAsync(MachineId, NumericItemId, It.IsAny<DateTimeOffset>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Result<IReadOnlyList<HistoryPoint>>.Ok(NumericHistory()));
repo.Setup(r => r.GetHistoryAsync(MachineId, TextItemId, It.IsAny<DateTimeOffset>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Result<IReadOnlyList<HistoryPoint>>.Ok(TextHistory()));
var monitor = MonitorWithSnapshot(snapshot);
var vm = BuildVm(repo, monitor);
await vm.LoadAsync();
if (vm.TrendLoadTask != null)
{
await vm.TrendLoadTask;
}
var numericRow = Row(vm, NumericItemId);
Assert.True(numericRow.HasTrend);
Assert.NotNull(numericRow.Trend);
Assert.Equal(new double[] { 10, 12.5, 11 }, numericRow.Trend!.ToArray());
}
[Fact]
public async Task LoadAsync_NonNumericHistory_LeavesTrendNull()
{
var snapshot = SnapshotWithBothItems();
var repo = new Mock<IMachineRepository>();
repo.Setup(r => r.GetByIdAsync(MachineId, It.IsAny<CancellationToken>()))
.ReturnsAsync(Result<Machine>.Ok(new Machine(MachineId, "VMC", "mtconnect", null, TimeSpan.FromSeconds(2))));
repo.Setup(r => r.GetHistoryAsync(MachineId, NumericItemId, It.IsAny<DateTimeOffset>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Result<IReadOnlyList<HistoryPoint>>.Ok(NumericHistory()));
repo.Setup(r => r.GetHistoryAsync(MachineId, TextItemId, It.IsAny<DateTimeOffset>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Result<IReadOnlyList<HistoryPoint>>.Ok(TextHistory()));
var monitor = MonitorWithSnapshot(snapshot);
var vm = BuildVm(repo, monitor);
await vm.LoadAsync();
if (vm.TrendLoadTask != null)
{
await vm.TrendLoadTask;
}
var textRow = Row(vm, TextItemId);
Assert.False(textRow.HasTrend);
Assert.Null(textRow.Trend);
}
[Fact]
public async Task LoadAsync_HistoryFailureForOneItem_DoesNotThrowOrBlockOthers()
{
var snapshot = SnapshotWithBothItems();
var repo = new Mock<IMachineRepository>();
repo.Setup(r => r.GetByIdAsync(MachineId, It.IsAny<CancellationToken>()))
.ReturnsAsync(Result<Machine>.Ok(new Machine(MachineId, "VMC", "mtconnect", null, TimeSpan.FromSeconds(2))));
// Numeric item succeeds; text item fails hard.
repo.Setup(r => r.GetHistoryAsync(MachineId, NumericItemId, It.IsAny<DateTimeOffset>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Result<IReadOnlyList<HistoryPoint>>.Ok(NumericHistory()));
repo.Setup(r => r.GetHistoryAsync(MachineId, TextItemId, It.IsAny<DateTimeOffset>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("boom"));
var monitor = MonitorWithSnapshot(snapshot);
var vm = BuildVm(repo, monitor);
await vm.LoadAsync();
if (vm.TrendLoadTask != null)
{
await vm.TrendLoadTask;
}
// The failing item leaves the successful item's trend intact.
Assert.True(Row(vm, NumericItemId).HasTrend);
Assert.False(Row(vm, TextItemId).HasTrend);
}
}
}