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>
165 lines
7.2 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|