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>
This commit is contained in:
parent
2bf108905c
commit
54f8b3be25
14 changed files with 1064 additions and 8 deletions
121
src/Junction.App/Controls/Sparkline.cs
Normal file
121
src/Junction.App/Controls/Sparkline.cs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace Junction.App.Controls
|
||||
{
|
||||
/// <summary>
|
||||
/// Lightweight per-item trend line: draws a numeric time-series (oldest → newest, left → right)
|
||||
/// as a single polyline scaled to fill the control. No axes, no labels — a table-cell sparkline.
|
||||
/// Rendering-only; the parsing/collection of trend values lives in the view-model layer, so this
|
||||
/// control stays a pure <see cref="Control"/> with no data-shaping logic to unit-test.
|
||||
/// <para>net48-safe: uses only core Avalonia drawing APIs (StreamGeometry + Pen), no external
|
||||
/// charting dependency. Blank when there is nothing meaningful to draw (< 2 points).</para>
|
||||
/// </summary>
|
||||
public sealed class Sparkline : Control
|
||||
{
|
||||
/// <summary>Trend samples in ascending time order. < 2 points renders blank.</summary>
|
||||
public static readonly StyledProperty<IReadOnlyList<double>?> PointsProperty =
|
||||
AvaloniaProperty.Register<Sparkline, IReadOnlyList<double>?>(nameof(Points));
|
||||
|
||||
/// <summary>Line color. Defaults to Junction's accent blue; the view binds the themed brush.</summary>
|
||||
public static readonly StyledProperty<IBrush?> StrokeProperty =
|
||||
AvaloniaProperty.Register<Sparkline, IBrush?>(
|
||||
nameof(Stroke),
|
||||
new SolidColorBrush(Color.FromRgb(0x25, 0x63, 0xEB)));
|
||||
|
||||
static Sparkline()
|
||||
{
|
||||
AffectsRender<Sparkline>(PointsProperty, StrokeProperty);
|
||||
AffectsMeasure<Sparkline>(PointsProperty);
|
||||
}
|
||||
|
||||
public IReadOnlyList<double>? Points
|
||||
{
|
||||
get => GetValue(PointsProperty);
|
||||
set => SetValue(PointsProperty, value);
|
||||
}
|
||||
|
||||
public IBrush? Stroke
|
||||
{
|
||||
get => GetValue(StrokeProperty);
|
||||
set => SetValue(StrokeProperty, value);
|
||||
}
|
||||
|
||||
protected override Size MeasureOverride(Size availableSize)
|
||||
{
|
||||
// Own no intrinsic width (stretch into the column); pick a sensible default height
|
||||
// when the parent imposes none.
|
||||
double height = double.IsInfinity(availableSize.Height) ? 24 : availableSize.Height;
|
||||
return new Size(0, height);
|
||||
}
|
||||
|
||||
public override void Render(DrawingContext context)
|
||||
{
|
||||
base.Render(context);
|
||||
|
||||
var points = Points;
|
||||
if (points == null || points.Count < 2)
|
||||
{
|
||||
return; // nothing meaningful to draw
|
||||
}
|
||||
|
||||
double width = Bounds.Width;
|
||||
double height = Bounds.Height;
|
||||
if (width <= 0 || height <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double min = points[0];
|
||||
double max = points[0];
|
||||
for (int i = 1; i < points.Count; i++)
|
||||
{
|
||||
double v = points[i];
|
||||
if (v < min) min = v;
|
||||
if (v > max) max = v;
|
||||
}
|
||||
double range = max - min;
|
||||
|
||||
// Small vertical inset so the stroke never clips at the top/bottom edge.
|
||||
const double pad = 1.5;
|
||||
double usableHeight = Math.Max(0, height - (2 * pad));
|
||||
double stepX = width / (points.Count - 1);
|
||||
|
||||
var pen = new Pen(Stroke ?? Brushes.Gray, 1.5)
|
||||
{
|
||||
LineCap = PenLineCap.Round,
|
||||
LineJoin = PenLineJoin.Round
|
||||
};
|
||||
|
||||
var geometry = new StreamGeometry();
|
||||
using (var ctx = geometry.Open())
|
||||
{
|
||||
for (int i = 0; i < points.Count; i++)
|
||||
{
|
||||
double x = i * stepX;
|
||||
// All-equal series → flat mid line; else normalize into [0,1] and invert
|
||||
// so a higher value sits higher on screen.
|
||||
double norm = range > 0 ? (points[i] - min) / range : 0.5;
|
||||
double y = pad + ((1 - norm) * usableHeight);
|
||||
|
||||
var p = new Point(x, y);
|
||||
if (i == 0)
|
||||
{
|
||||
ctx.BeginFigure(p, false);
|
||||
}
|
||||
else
|
||||
{
|
||||
ctx.LineTo(p);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.EndFigure(false);
|
||||
}
|
||||
|
||||
context.DrawGeometry(null, pen, geometry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +1,33 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Junction.Domain.Models;
|
||||
|
||||
namespace Junction.App.ViewModels
|
||||
{
|
||||
/// <summary>
|
||||
/// One row of the machine-detail data-item table. Immutable projection of a
|
||||
/// <see cref="DataItem"/>; rebuilt (not mutated) whenever a fresh snapshot arrives,
|
||||
/// so it needs no change-notification.
|
||||
/// One row of the machine-detail data-item table. The string fields are a fixed projection of a
|
||||
/// <see cref="DataItem"/> (set in the ctor; rebuilt, not mutated, per snapshot). Observable only
|
||||
/// for <see cref="Trend"/>, which is populated asynchronously after the row is created (history
|
||||
/// query) and drives the per-item sparkline.
|
||||
/// </summary>
|
||||
public sealed class DataItemRowViewModel
|
||||
public sealed partial class DataItemRowViewModel : ViewModelBase
|
||||
{
|
||||
/// <summary>History item-id key (matches <see cref="DataItem.Id"/> / snapshot item id).</summary>
|
||||
public string Id { get; }
|
||||
public string Name { get; }
|
||||
public string Value { get; }
|
||||
public string Category { get; }
|
||||
public string Timestamp { get; }
|
||||
|
||||
/// <summary>Numeric trend samples (ascending time order) for the sparkline; null when none.</summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasTrend))]
|
||||
private IReadOnlyList<double>? _trend;
|
||||
|
||||
/// <summary>True when there are enough numeric samples to draw a trend line.</summary>
|
||||
public bool HasTrend => Trend != null && Trend.Count >= 2;
|
||||
|
||||
public DataItemRowViewModel(DataItem item)
|
||||
{
|
||||
Id = item.Id;
|
||||
|
|
@ -23,5 +36,24 @@ namespace Junction.App.ViewModels
|
|||
Category = string.IsNullOrWhiteSpace(item.Category) ? "—" : item.Category;
|
||||
Timestamp = item.Timestamp.LocalDateTime.ToString("HH:mm:ss");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Culture-invariant numeric parse of a datum value. Trims; returns false for null/blank or
|
||||
/// non-numeric text (e.g. "ON", "AVAILABLE"). Pure and Avalonia-free — unit-testable.
|
||||
/// </summary>
|
||||
public static bool TryParseNumeric(string? value, out double result)
|
||||
{
|
||||
result = 0;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return double.TryParse(
|
||||
value!.Trim(),
|
||||
NumberStyles.Float | NumberStyles.AllowThousands,
|
||||
CultureInfo.InvariantCulture,
|
||||
out result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Threading;
|
||||
|
|
@ -23,6 +25,12 @@ namespace Junction.App.ViewModels
|
|||
/// </summary>
|
||||
public sealed partial class MachineDetailViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
/// <summary>How far back the per-item trend sparkline looks.</summary>
|
||||
private static readonly TimeSpan TrendWindow = TimeSpan.FromHours(1);
|
||||
|
||||
/// <summary>Cap on samples pulled per item for a sparkline (most-recent kept).</summary>
|
||||
private const int TrendMaxPoints = 60;
|
||||
|
||||
private readonly IMachineRepository _repository;
|
||||
private readonly IMachineMonitor _monitor;
|
||||
private readonly ILogger<MachineDetailViewModel> _logger;
|
||||
|
|
@ -30,6 +38,12 @@ namespace Junction.App.ViewModels
|
|||
private bool _subscribed;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Handle on the most recently kicked (fire-and-forget) trend load. Production ignores it;
|
||||
/// tests await it to observe the populated <see cref="DataItemRowViewModel.Trend"/> values.
|
||||
/// </summary>
|
||||
public Task? TrendLoadTask { get; private set; }
|
||||
|
||||
/// <summary>Shell reference used by <see cref="BackCommand"/> to return to the dashboard.</summary>
|
||||
public MainWindowViewModel? Navigator { get; set; }
|
||||
|
||||
|
|
@ -163,6 +177,7 @@ namespace Junction.App.ViewModels
|
|||
if (snapshot != null)
|
||||
{
|
||||
ApplySnapshot(snapshot);
|
||||
KickTrendLoad();
|
||||
}
|
||||
|
||||
_logger.LogInformation("Machine detail opened for {Name} ({MachineId}); {Count} item(s).",
|
||||
|
|
@ -210,9 +225,93 @@ namespace Junction.App.ViewModels
|
|||
}
|
||||
|
||||
ApplySnapshot(snapshot);
|
||||
KickTrendLoad();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fires (fire-and-forget) a per-item history query for every current row and, for items
|
||||
/// whose samples parse as numeric, populates the row's <see cref="DataItemRowViewModel.Trend"/>
|
||||
/// so its sparkline renders. Rebuilt rows carry no stale trend. A per-item failure never
|
||||
/// aborts the others and never propagates.
|
||||
/// </summary>
|
||||
private void KickTrendLoad()
|
||||
{
|
||||
TrendLoadTask = LoadTrendsAsync();
|
||||
}
|
||||
|
||||
private async Task LoadTrendsAsync()
|
||||
{
|
||||
// Snapshot the rows: the collection may be rebuilt by a later snapshot while we await.
|
||||
var rows = Items.ToArray();
|
||||
var since = DateTimeOffset.UtcNow - TrendWindow;
|
||||
|
||||
for (int r = 0; r < rows.Length; r++)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var row = rows[r];
|
||||
try
|
||||
{
|
||||
var result = await _repository
|
||||
.GetHistoryAsync(_machineId, row.Id, since, TrendMaxPoints, CancellationToken.None)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.IsSuccess || result.Value == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var history = result.Value;
|
||||
var values = new List<double>(history.Count);
|
||||
for (int i = 0; i < history.Count; i++)
|
||||
{
|
||||
if (DataItemRowViewModel.TryParseNumeric(history[i].Value, out var d))
|
||||
{
|
||||
values.Add(d);
|
||||
}
|
||||
}
|
||||
|
||||
// Non-numeric / empty history → leave Trend null (sparkline stays blank).
|
||||
if (values.Count > 0)
|
||||
{
|
||||
SetTrendOnUi(row, values.ToArray());
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Trend load failed for item {ItemId} on machine {MachineId}.",
|
||||
row.Id, _machineId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SetTrendOnUi(DataItemRowViewModel row, IReadOnlyList<double> trend)
|
||||
{
|
||||
if (Dispatcher.UIThread.CheckAccess())
|
||||
{
|
||||
row.Trend = trend;
|
||||
}
|
||||
else
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
row.Trend = trend;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Back()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="clr-namespace:Junction.App.ViewModels"
|
||||
xmlns:controls="clr-namespace:Junction.App.Controls"
|
||||
mc:Ignorable="d"
|
||||
x:Class="Junction.App.Views.MachineDetailView"
|
||||
x:DataType="vm:MachineDetailViewModel"
|
||||
|
|
@ -68,11 +69,12 @@
|
|||
BorderBrush="{DynamicResource CardBorderBrush}"
|
||||
Padding="0,0,0,6" Margin="0,0,0,4"
|
||||
IsVisible="{Binding HasItems}">
|
||||
<Grid ColumnDefinitions="2*,2*,1.2*,1.2*">
|
||||
<Grid ColumnDefinitions="2*,1.6*,1*,1.2*,1.4*">
|
||||
<TextBlock Grid.Column="0" Classes="fieldLabel" Text="Name" />
|
||||
<TextBlock Grid.Column="1" Classes="fieldLabel" Text="Value" />
|
||||
<TextBlock Grid.Column="2" Classes="fieldLabel" Text="Category" />
|
||||
<TextBlock Grid.Column="3" Classes="fieldLabel" Text="Timestamp" />
|
||||
<TextBlock Grid.Column="4" Classes="fieldLabel" Text="Trend" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
|
|
@ -82,7 +84,7 @@
|
|||
<ItemsControl ItemsSource="{Binding Items}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:DataItemRowViewModel">
|
||||
<Grid ColumnDefinitions="2*,2*,1.2*,1.2*" Margin="0,5">
|
||||
<Grid ColumnDefinitions="2*,1.6*,1*,1.2*,1.4*" Margin="0,5">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" TextWrapping="Wrap" />
|
||||
<TextBlock Text="{Binding Id}" Classes="subtle" FontSize="11" TextWrapping="Wrap" />
|
||||
|
|
@ -90,6 +92,9 @@
|
|||
<TextBlock Grid.Column="1" Text="{Binding Value}" VerticalAlignment="Center" TextWrapping="Wrap" />
|
||||
<TextBlock Grid.Column="2" Text="{Binding Category}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Text="{Binding Timestamp}" VerticalAlignment="Center" />
|
||||
<controls:Sparkline Grid.Column="4" Points="{Binding Trend}" IsVisible="{Binding HasTrend}"
|
||||
Height="24" VerticalAlignment="Center"
|
||||
Stroke="{DynamicResource AccentBrush}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,15 @@ namespace Junction.Core.Monitoring
|
|||
private readonly ConcurrentDictionary<Guid, MachineSnapshot> _snapshots =
|
||||
new ConcurrentDictionary<Guid, MachineSnapshot>();
|
||||
|
||||
// History retention policy (const-fixed for this slice; not config-driven yet).
|
||||
// TimeSpan can't be a C# const, so these are static readonly.
|
||||
private static readonly TimeSpan RetentionWindow = TimeSpan.FromDays(7);
|
||||
private static readonly TimeSpan PruneInterval = TimeSpan.FromHours(1);
|
||||
|
||||
// UTC ticks of the last prune claim. Interlocked-guarded because PersistAsync runs
|
||||
// concurrently across per-machine poll loops. Init to MinValue so the first persist prunes.
|
||||
private long _lastPruneTicks = DateTimeOffset.MinValue.UtcTicks;
|
||||
|
||||
private readonly object _lifecycleLock = new object();
|
||||
private readonly List<RunningLoop> _running = new List<RunningLoop>();
|
||||
|
||||
|
|
@ -462,6 +471,23 @@ namespace Junction.Core.Monitoring
|
|||
"Failed to persist snapshot for machine {MachineId}: {Errors}",
|
||||
snapshot.MachineId, DescribeErrors(result.Errors));
|
||||
}
|
||||
|
||||
// Append time-series history for live reads only. Synthetic Disconnected
|
||||
// snapshots carry no items (AppendHistory no-ops) but gating here avoids the
|
||||
// round-trip and keeps disconnect noise out of the trend. A history failure
|
||||
// must never undo the latest-snapshot save above.
|
||||
if (snapshot.ConnectionState == ConnectionState.Connected)
|
||||
{
|
||||
Result historyResult = await _repository.AppendHistoryAsync(snapshot, cancellationToken).ConfigureAwait(false);
|
||||
if (!historyResult.IsSuccess && !historyResult.WasCancelled)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Failed to append history for machine {MachineId}: {Errors}",
|
||||
snapshot.MachineId, DescribeErrors(historyResult.Errors));
|
||||
}
|
||||
|
||||
await MaybePruneHistoryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
|
|
@ -473,6 +499,38 @@ namespace Junction.Core.Monitoring
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prunes history older than <see cref="RetentionWindow"/> at most once per
|
||||
/// <see cref="PruneInterval"/>, regardless of poll frequency or machine count. The
|
||||
/// window is claimed via a single Interlocked CompareExchange so concurrent poll loops
|
||||
/// don't all prune at once; a lost race simply skips (harmless).
|
||||
/// </summary>
|
||||
private async Task MaybePruneHistoryAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
long lastPrune = Interlocked.Read(ref _lastPruneTicks);
|
||||
|
||||
if (now - new DateTimeOffset(lastPrune, TimeSpan.Zero) < PruneInterval)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Claim the interval; only the loop that wins the swap performs the prune.
|
||||
if (Interlocked.CompareExchange(ref _lastPruneTicks, now.UtcTicks, lastPrune) != lastPrune)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DateTimeOffset cutoff = now - RetentionWindow;
|
||||
Result pruneResult = await _repository.PruneHistoryAsync(cutoff, cancellationToken).ConfigureAwait(false);
|
||||
if (!pruneResult.IsSuccess && !pruneResult.WasCancelled)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Failed to prune history older than {Cutoff}: {Errors}",
|
||||
cutoff, DescribeErrors(pruneResult.Errors));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps ProtocolId (case-insensitive) to factory. On duplicate protocol ids the first
|
||||
/// loaded plugin wins; the collision is logged.
|
||||
|
|
|
|||
31
src/Junction.Domain/Models/HistoryPoint.cs
Normal file
31
src/Junction.Domain/Models/HistoryPoint.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
using System;
|
||||
|
||||
namespace Junction.Domain.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// One time-series sample of one data item: the value a datum had at a point in time.
|
||||
/// Immutable, protocol-agnostic. Unlike <see cref="DataItem"/> (only-latest), history points
|
||||
/// accumulate append-only so a trend over time can be reconstructed.
|
||||
/// </summary>
|
||||
public sealed class HistoryPoint
|
||||
{
|
||||
/// <summary>Stable identifier of the datum this sample belongs to (see <see cref="DataItem.Id"/>).</summary>
|
||||
public string ItemId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Sampled value kept as string to stay type-agnostic across protocols;
|
||||
/// callers parse to the concrete type they need.
|
||||
/// </summary>
|
||||
public string Value { get; }
|
||||
|
||||
/// <summary>Instant the value was reported/observed.</summary>
|
||||
public DateTimeOffset Timestamp { get; }
|
||||
|
||||
public HistoryPoint(string itemId, string value, DateTimeOffset timestamp)
|
||||
{
|
||||
ItemId = itemId ?? "";
|
||||
Value = value ?? "";
|
||||
Timestamp = timestamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -42,5 +42,27 @@ namespace Junction.Domain.Persistence
|
|||
|
||||
/// <summary>Returns the latest stored snapshot for the machine, or a failed result when none exists.</summary>
|
||||
Task<Result<MachineSnapshot>> GetLatestSnapshotAsync(Guid machineId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Appends one time-series history row per item in <paramref name="snapshot"/> (append-only:
|
||||
/// never overwrites an earlier sample). A snapshot with no items is a no-op success.
|
||||
/// This is separate from <see cref="SaveSnapshotAsync"/>, which keeps only the latest set.
|
||||
/// </summary>
|
||||
Task<Result> AppendHistoryAsync(MachineSnapshot snapshot, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Returns time-series samples for a single item of a machine at or after <paramref name="since"/>,
|
||||
/// ascending by timestamp, capped at <paramref name="maxPoints"/> (keeping the MOST RECENT
|
||||
/// <paramref name="maxPoints"/> when more exist). Empty list on none — a success, not a failure.
|
||||
/// A non-positive <paramref name="maxPoints"/> yields an empty success.
|
||||
/// </summary>
|
||||
Task<Result<IReadOnlyList<HistoryPoint>>> GetHistoryAsync(Guid machineId, string itemId, DateTimeOffset since, int maxPoints, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all history rows whose timestamp is strictly older than <paramref name="olderThan"/>.
|
||||
/// Idempotent: safe to call when nothing matches. Provides the mechanism only; retention
|
||||
/// policy (deciding the cutoff) lives above this port.
|
||||
/// </summary>
|
||||
Task<Result> PruneHistoryAsync(DateTimeOffset olderThan, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -189,6 +189,10 @@ namespace Junction.Persistence
|
|||
{
|
||||
var idParam = new { Id = GuidText(id) };
|
||||
|
||||
await connection.ExecuteAsync(new CommandDefinition(
|
||||
"DELETE FROM snapshot_history WHERE MachineId = @Id;",
|
||||
idParam, transaction, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||
|
||||
await connection.ExecuteAsync(new CommandDefinition(
|
||||
"DELETE FROM snapshot_items WHERE MachineId = @Id;",
|
||||
idParam, transaction, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||
|
|
@ -354,6 +358,150 @@ namespace Junction.Persistence
|
|||
}
|
||||
}
|
||||
|
||||
public async Task<Result> AppendHistoryAsync(MachineSnapshot snapshot, CancellationToken cancellationToken)
|
||||
{
|
||||
if (snapshot == null)
|
||||
{
|
||||
return Result.Fail(OperationError.Of(Source, "Snapshot is null."));
|
||||
}
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return Result.Cancelled();
|
||||
}
|
||||
|
||||
// Append-only: nothing to record for an empty snapshot.
|
||||
if (snapshot.Items.Count == 0)
|
||||
{
|
||||
return Result.Ok();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using (var connection = OpenConnection())
|
||||
using (var transaction = connection.BeginTransaction())
|
||||
{
|
||||
var machineIdText = GuidText(snapshot.MachineId);
|
||||
|
||||
var itemParams = new List<object>(snapshot.Items.Count);
|
||||
foreach (var item in snapshot.Items)
|
||||
{
|
||||
itemParams.Add(new
|
||||
{
|
||||
MachineId = machineIdText,
|
||||
ItemId = item.Id,
|
||||
item.Name,
|
||||
item.Value,
|
||||
item.Category,
|
||||
Timestamp = IsoText(item.Timestamp)
|
||||
});
|
||||
}
|
||||
|
||||
await connection.ExecuteAsync(new CommandDefinition(
|
||||
"INSERT INTO snapshot_history (MachineId, ItemId, Name, Value, Category, Timestamp) " +
|
||||
"VALUES (@MachineId, @ItemId, @Name, @Value, @Category, @Timestamp);",
|
||||
itemParams,
|
||||
transaction, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||
|
||||
transaction.Commit();
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return Result.Cancelled();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(OperationError.Of(Source, "AppendHistory failed: " + ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<IReadOnlyList<HistoryPoint>>> GetHistoryAsync(
|
||||
Guid machineId, string itemId, DateTimeOffset since, int maxPoints, CancellationToken cancellationToken)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return Result<IReadOnlyList<HistoryPoint>>.Cancelled();
|
||||
}
|
||||
|
||||
// Non-positive cap => nothing requested; empty success (not a failure).
|
||||
if (maxPoints <= 0)
|
||||
{
|
||||
return Result<IReadOnlyList<HistoryPoint>>.Ok(Array.Empty<HistoryPoint>());
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using (var connection = OpenConnection())
|
||||
{
|
||||
// Take the most-recent N at/after `since` (DESC + LIMIT), then reverse to ascending.
|
||||
var rows = await connection.QueryAsync<HistoryRow>(new CommandDefinition(
|
||||
"SELECT ItemId, Value, Timestamp FROM snapshot_history " +
|
||||
"WHERE MachineId = @MachineId AND ItemId = @ItemId AND Timestamp >= @Since " +
|
||||
"ORDER BY Timestamp DESC LIMIT @MaxPoints;",
|
||||
new
|
||||
{
|
||||
MachineId = GuidText(machineId),
|
||||
ItemId = itemId ?? "",
|
||||
Since = IsoText(since),
|
||||
MaxPoints = maxPoints
|
||||
},
|
||||
cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||
|
||||
var points = new List<HistoryPoint>();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
points.Add(new HistoryPoint(row.ItemId, row.Value, ParseIso(row.Timestamp)));
|
||||
}
|
||||
|
||||
// Query is DESC (newest first); reverse to ascending by timestamp.
|
||||
points.Reverse();
|
||||
|
||||
return Result<IReadOnlyList<HistoryPoint>>.Ok(points);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return Result<IReadOnlyList<HistoryPoint>>.Cancelled();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<IReadOnlyList<HistoryPoint>>.Fail(
|
||||
OperationError.Of(Source, "GetHistory failed: " + ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> PruneHistoryAsync(DateTimeOffset olderThan, CancellationToken cancellationToken)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return Result.Cancelled();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using (var connection = OpenConnection())
|
||||
{
|
||||
await connection.ExecuteAsync(new CommandDefinition(
|
||||
"DELETE FROM snapshot_history WHERE Timestamp < @OlderThan;",
|
||||
new { OlderThan = IsoText(olderThan) },
|
||||
cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return Result.Cancelled();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(OperationError.Of(Source, "PruneHistory failed: " + ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
// -- helpers -------------------------------------------------------------------------
|
||||
|
||||
private DbConnection OpenConnection()
|
||||
|
|
@ -471,5 +619,12 @@ namespace Junction.Persistence
|
|||
public string Category { get; set; } = "";
|
||||
public string Timestamp { get; set; } = "";
|
||||
}
|
||||
|
||||
private sealed class HistoryRow
|
||||
{
|
||||
public string ItemId { get; set; } = "";
|
||||
public string Value { get; set; } = "";
|
||||
public string Timestamp { get; set; } = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ namespace Junction.Persistence
|
|||
{
|
||||
/// <summary>
|
||||
/// Owns the SQLite schema (DDL). Idempotent: safe to run on every startup.
|
||||
/// Only-latest policy — one snapshot row and one item-set per machine — but the
|
||||
/// shape leaves room to add history tables later without breaking these tables.
|
||||
/// latest_snapshots/snapshot_items keep the only-latest set per machine; snapshot_history
|
||||
/// is the append-only time series added alongside them without breaking those tables.
|
||||
/// </summary>
|
||||
public static class SqliteSchema
|
||||
{
|
||||
|
|
@ -42,6 +42,23 @@ namespace Junction.Persistence
|
|||
PRIMARY KEY (MachineId, ItemId)
|
||||
);";
|
||||
|
||||
// snapshot_history: append-only time series. Many rows per (MachineId, ItemId) — one per
|
||||
// appended sample — so NO single-row primary key. Never overwritten; pruned by timestamp.
|
||||
private const string CreateSnapshotHistory =
|
||||
@"CREATE TABLE IF NOT EXISTS snapshot_history (
|
||||
MachineId TEXT NOT NULL,
|
||||
ItemId TEXT NOT NULL,
|
||||
Name TEXT NOT NULL,
|
||||
Value TEXT NOT NULL,
|
||||
Category TEXT NOT NULL,
|
||||
Timestamp TEXT NOT NULL
|
||||
);";
|
||||
|
||||
// Index for the query path (GetHistory: WHERE MachineId=@ AND ItemId=@ AND Timestamp>=@ ORDER BY Timestamp).
|
||||
private const string CreateSnapshotHistoryIndex =
|
||||
@"CREATE INDEX IF NOT EXISTS ix_snapshot_history_machine_item_ts
|
||||
ON snapshot_history (MachineId, ItemId, Timestamp);";
|
||||
|
||||
/// <summary>
|
||||
/// Creates all tables if they do not already exist. Idempotent.
|
||||
/// Returns a failed <see cref="Result"/> instead of throwing on store errors.
|
||||
|
|
@ -63,6 +80,8 @@ namespace Junction.Persistence
|
|||
Execute(connection, CreateMachines);
|
||||
Execute(connection, CreateLatestSnapshots);
|
||||
Execute(connection, CreateSnapshotItems);
|
||||
Execute(connection, CreateSnapshotHistory);
|
||||
Execute(connection, CreateSnapshotHistoryIndex);
|
||||
|
||||
// Migration for DBs created by an older schema (before MonitoredItemIdsJson existed):
|
||||
// CREATE TABLE IF NOT EXISTS never alters an existing table, so add the column here if
|
||||
|
|
|
|||
39
tests/Junction.Tests/Unit/DataItemRowViewModelTests.cs
Normal file
39
tests/Junction.Tests/Unit/DataItemRowViewModelTests.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
using Junction.App.ViewModels;
|
||||
using Xunit;
|
||||
|
||||
namespace Junction.Tests.Unit
|
||||
{
|
||||
/// <summary>
|
||||
/// Data-level tests for the culture-invariant numeric parse that decides whether a datum can
|
||||
/// feed the trend sparkline. Pure static helper — no Avalonia platform required.
|
||||
/// </summary>
|
||||
public class DataItemRowViewModelTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("12.5", 12.5)]
|
||||
[InlineData(" 3 ", 3)]
|
||||
[InlineData("1000", 1000)]
|
||||
[InlineData("-4.25", -4.25)]
|
||||
public void TryParseNumeric_Numeric_ReturnsTrueWithInvariantValue(string input, double expected)
|
||||
{
|
||||
var ok = DataItemRowViewModel.TryParseNumeric(input, out var result);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("abc")]
|
||||
[InlineData("ON")]
|
||||
[InlineData("AVAILABLE")]
|
||||
[InlineData(null)]
|
||||
public void TryParseNumeric_NonNumeric_ReturnsFalse(string? input)
|
||||
{
|
||||
var ok = DataItemRowViewModel.TryParseNumeric(input, out _);
|
||||
|
||||
Assert.False(ok);
|
||||
}
|
||||
}
|
||||
}
|
||||
165
tests/Junction.Tests/Unit/MachineDetailViewModelTests.cs
Normal file
165
tests/Junction.Tests/Unit/MachineDetailViewModelTests.cs
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -60,9 +60,19 @@ namespace Junction.Tests.Unit
|
|||
.ReturnsAsync(Result<IReadOnlyList<Machine>>.Ok(machines));
|
||||
mock.Setup(r => r.SaveSnapshotAsync(It.IsAny<MachineSnapshot>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result.Ok());
|
||||
mock.Setup(r => r.AppendHistoryAsync(It.IsAny<MachineSnapshot>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result.Ok());
|
||||
mock.Setup(r => r.PruneHistoryAsync(It.IsAny<DateTimeOffset>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result.Ok());
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static MachineSnapshot SnapshotWithItems(Guid machineId) =>
|
||||
new MachineSnapshot(machineId, DateTimeOffset.UtcNow, ConnectionState.Connected, new[]
|
||||
{
|
||||
new DataItem("x1_pos", "X", "12.5", "SAMPLE", DateTimeOffset.UtcNow),
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_PollsMachine_RaisesEvent_UpdatesCache_Persists()
|
||||
{
|
||||
|
|
@ -95,6 +105,114 @@ namespace Junction.Tests.Unit
|
|||
It.IsAny<CancellationToken>()), Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectedSnapshotWithItems_AppendsHistory_AndStillSavesLatest()
|
||||
{
|
||||
var machine = MachineWith("test");
|
||||
var factory = new FakeFactory("test", m => Result<IProtocolDriver>.Ok(
|
||||
new FakeDriver("test", () => Result<MachineSnapshot>.Ok(SnapshotWithItems(machine.Id)))));
|
||||
|
||||
var loader = LoaderReturning(factory);
|
||||
var repo = RepoReturning(machine);
|
||||
|
||||
var appended = new ManualResetEventSlim(false);
|
||||
repo.Setup(r => r.AppendHistoryAsync(
|
||||
It.Is<MachineSnapshot>(s => s.MachineId == machine.Id && s.Items.Count > 0),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result.Ok())
|
||||
.Callback(() => appended.Set());
|
||||
|
||||
var monitor = NewMonitor(repo.Object, loader.Object);
|
||||
|
||||
await monitor.StartAsync(PluginsDir, CancellationToken.None);
|
||||
Assert.True(appended.Wait(TimeSpan.FromSeconds(2)), "AppendHistoryAsync was not invoked");
|
||||
await monitor.StopAsync();
|
||||
|
||||
// History appended AND latest-snapshot save still happens (existing contract intact).
|
||||
repo.Verify(r => r.AppendHistoryAsync(
|
||||
It.Is<MachineSnapshot>(s => s.MachineId == machine.Id),
|
||||
It.IsAny<CancellationToken>()), Times.AtLeastOnce);
|
||||
repo.Verify(r => r.SaveSnapshotAsync(
|
||||
It.Is<MachineSnapshot>(s => s.MachineId == machine.Id),
|
||||
It.IsAny<CancellationToken>()), Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HistoryAppendFailure_DoesNotThrow_NorStopLatestSave()
|
||||
{
|
||||
var machine = MachineWith("test");
|
||||
var factory = new FakeFactory("test", m => Result<IProtocolDriver>.Ok(
|
||||
new FakeDriver("test", () => Result<MachineSnapshot>.Ok(SnapshotWithItems(machine.Id)))));
|
||||
|
||||
var loader = LoaderReturning(factory);
|
||||
var repo = RepoReturning(machine);
|
||||
|
||||
// Append both fails AND throws on alternating calls to exercise both paths.
|
||||
repo.Setup(r => r.AppendHistoryAsync(It.IsAny<MachineSnapshot>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("history store boom"));
|
||||
|
||||
var monitor = NewMonitor(repo.Object, loader.Object);
|
||||
|
||||
var eventRaised = new ManualResetEventSlim(false);
|
||||
monitor.SnapshotUpdated += (_, snap) =>
|
||||
{
|
||||
if (snap.MachineId == machine.Id) eventRaised.Set();
|
||||
};
|
||||
|
||||
var start = await monitor.StartAsync(PluginsDir, CancellationToken.None);
|
||||
Assert.True(start.IsSuccess);
|
||||
|
||||
// Monitor keeps running: events keep flowing and latest-save keeps being called.
|
||||
Assert.True(eventRaised.Wait(TimeSpan.FromSeconds(2)), "monitor stalled after history failure");
|
||||
await Task.Delay(120);
|
||||
await monitor.StopAsync();
|
||||
|
||||
repo.Verify(r => r.SaveSnapshotAsync(
|
||||
It.Is<MachineSnapshot>(s => s.MachineId == machine.Id),
|
||||
It.IsAny<CancellationToken>()), Times.AtLeastOnce);
|
||||
Assert.True(monitor.LatestSnapshots.ContainsKey(machine.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PruneHistory_Throttled_AtMostOncePerInterval_AcrossManySnapshots()
|
||||
{
|
||||
// PruneInterval is 1h; many rapid snapshots must trigger at most one prune.
|
||||
var machine = MachineWith("test");
|
||||
var factory = new FakeFactory("test", m => Result<IProtocolDriver>.Ok(
|
||||
new FakeDriver("test", () => Result<MachineSnapshot>.Ok(SnapshotWithItems(machine.Id)))));
|
||||
|
||||
var loader = LoaderReturning(factory);
|
||||
var repo = RepoReturning(machine);
|
||||
|
||||
int appendCount = 0;
|
||||
int pruneCount = 0;
|
||||
repo.Setup(r => r.AppendHistoryAsync(It.IsAny<MachineSnapshot>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result.Ok())
|
||||
.Callback(() => Interlocked.Increment(ref appendCount));
|
||||
repo.Setup(r => r.PruneHistoryAsync(It.IsAny<DateTimeOffset>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result.Ok())
|
||||
.Callback(() => Interlocked.Increment(ref pruneCount));
|
||||
|
||||
var monitor = NewMonitor(repo.Object, loader.Object);
|
||||
|
||||
await monitor.StartAsync(PluginsDir, CancellationToken.None);
|
||||
|
||||
// Let many poll cycles run (FastPoll = 25ms => dozens of appends).
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
while (Volatile.Read(ref appendCount) < 5 && sw.Elapsed < TimeSpan.FromSeconds(2))
|
||||
{
|
||||
await Task.Delay(20);
|
||||
}
|
||||
await Task.Delay(150);
|
||||
await monitor.StopAsync();
|
||||
|
||||
Assert.True(Volatile.Read(ref appendCount) >= 5, "expected many history appends across poll cycles");
|
||||
// Throttle proof: append fired many times, prune fired at most once in the window.
|
||||
repo.Verify(r => r.PruneHistoryAsync(It.IsAny<DateTimeOffset>(), It.IsAny<CancellationToken>()), Times.AtMostOnce);
|
||||
// And it did fire on the first persist (MinValue-init guarantees the first call prunes).
|
||||
Assert.Equal(1, Volatile.Read(ref pruneCount));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_UnknownProtocol_SkipsMachine_OthersRun_StillOk()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -350,6 +350,187 @@ namespace Junction.Tests.Unit
|
|||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
// -- history (append-only time series) ----------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task AppendHistory_ThenGetHistory_ReturnsAppendedPoints_Ascending()
|
||||
{
|
||||
var machineId = Guid.NewGuid();
|
||||
var t0 = new DateTimeOffset(2026, 7, 22, 8, 0, 0, TimeSpan.Zero);
|
||||
|
||||
// Two snapshots of the same item at different times, appended in reverse order.
|
||||
await _repo.AppendHistoryAsync(
|
||||
Snap(machineId, t0.AddSeconds(10), new DataItem("d1", "Speed", "20", "Sample", t0.AddSeconds(10))),
|
||||
CancellationToken.None);
|
||||
Result append = await _repo.AppendHistoryAsync(
|
||||
Snap(machineId, t0, new DataItem("d1", "Speed", "10", "Sample", t0)),
|
||||
CancellationToken.None);
|
||||
Assert.True(append.IsSuccess, Describe(append));
|
||||
|
||||
Result<IReadOnlyList<HistoryPoint>> got =
|
||||
await _repo.GetHistoryAsync(machineId, "d1", t0.AddSeconds(-1), 100, CancellationToken.None);
|
||||
Assert.True(got.IsSuccess, Describe(got));
|
||||
|
||||
// Append is additive (accumulates, not overwrite) and result is ascending by timestamp.
|
||||
Assert.Equal(2, got.Value.Count);
|
||||
Assert.Equal("10", got.Value[0].Value);
|
||||
Assert.Equal(t0, got.Value[0].Timestamp);
|
||||
Assert.Equal("20", got.Value[1].Value);
|
||||
Assert.Equal(t0.AddSeconds(10), got.Value[1].Timestamp);
|
||||
Assert.Equal("d1", got.Value[0].ItemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetHistory_SinceFilter_ExcludesOlderRows()
|
||||
{
|
||||
var machineId = Guid.NewGuid();
|
||||
var t0 = new DateTimeOffset(2026, 7, 22, 8, 0, 0, TimeSpan.Zero);
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await _repo.AppendHistoryAsync(
|
||||
Snap(machineId, t0.AddMinutes(i), new DataItem("d1", "Speed", i.ToString(), "Sample", t0.AddMinutes(i))),
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
// since = t0 + 3min => only minute 3 and 4 remain.
|
||||
Result<IReadOnlyList<HistoryPoint>> got =
|
||||
await _repo.GetHistoryAsync(machineId, "d1", t0.AddMinutes(3), 100, CancellationToken.None);
|
||||
Assert.True(got.IsSuccess, Describe(got));
|
||||
|
||||
Assert.Equal(2, got.Value.Count);
|
||||
Assert.Equal("3", got.Value[0].Value);
|
||||
Assert.Equal("4", got.Value[1].Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetHistory_MaxPoints_KeepsMostRecentN_Ascending()
|
||||
{
|
||||
var machineId = Guid.NewGuid();
|
||||
var t0 = new DateTimeOffset(2026, 7, 22, 8, 0, 0, TimeSpan.Zero);
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await _repo.AppendHistoryAsync(
|
||||
Snap(machineId, t0.AddMinutes(i), new DataItem("d1", "Speed", i.ToString(), "Sample", t0.AddMinutes(i))),
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
// 5 rows exist, ask for at most 2 => most-recent two (3,4), returned ascending.
|
||||
Result<IReadOnlyList<HistoryPoint>> got =
|
||||
await _repo.GetHistoryAsync(machineId, "d1", t0.AddMinutes(-1), 2, CancellationToken.None);
|
||||
Assert.True(got.IsSuccess, Describe(got));
|
||||
|
||||
Assert.Equal(2, got.Value.Count);
|
||||
Assert.Equal("3", got.Value[0].Value);
|
||||
Assert.Equal("4", got.Value[1].Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetHistory_UnknownItemOrMachine_ReturnsEmptyOk()
|
||||
{
|
||||
var machineId = Guid.NewGuid();
|
||||
var t0 = new DateTimeOffset(2026, 7, 22, 8, 0, 0, TimeSpan.Zero);
|
||||
await _repo.AppendHistoryAsync(
|
||||
Snap(machineId, t0, new DataItem("d1", "Speed", "10", "Sample", t0)),
|
||||
CancellationToken.None);
|
||||
|
||||
Result<IReadOnlyList<HistoryPoint>> unknownItem =
|
||||
await _repo.GetHistoryAsync(machineId, "nope", t0.AddSeconds(-1), 100, CancellationToken.None);
|
||||
Assert.True(unknownItem.IsSuccess, Describe(unknownItem));
|
||||
Assert.Empty(unknownItem.Value);
|
||||
|
||||
Result<IReadOnlyList<HistoryPoint>> unknownMachine =
|
||||
await _repo.GetHistoryAsync(Guid.NewGuid(), "d1", t0.AddSeconds(-1), 100, CancellationToken.None);
|
||||
Assert.True(unknownMachine.IsSuccess, Describe(unknownMachine));
|
||||
Assert.Empty(unknownMachine.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetHistory_NonPositiveMaxPoints_ReturnsEmptyOk()
|
||||
{
|
||||
var machineId = Guid.NewGuid();
|
||||
var t0 = new DateTimeOffset(2026, 7, 22, 8, 0, 0, TimeSpan.Zero);
|
||||
await _repo.AppendHistoryAsync(
|
||||
Snap(machineId, t0, new DataItem("d1", "Speed", "10", "Sample", t0)),
|
||||
CancellationToken.None);
|
||||
|
||||
Result<IReadOnlyList<HistoryPoint>> got =
|
||||
await _repo.GetHistoryAsync(machineId, "d1", t0.AddSeconds(-1), 0, CancellationToken.None);
|
||||
Assert.True(got.IsSuccess, Describe(got));
|
||||
Assert.Empty(got.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppendHistory_EmptySnapshot_IsNoOpSuccess()
|
||||
{
|
||||
var machineId = Guid.NewGuid();
|
||||
var t0 = new DateTimeOffset(2026, 7, 22, 8, 0, 0, TimeSpan.Zero);
|
||||
|
||||
Result append = await _repo.AppendHistoryAsync(
|
||||
new MachineSnapshot(machineId, t0, ConnectionState.Connected, null),
|
||||
CancellationToken.None);
|
||||
Assert.True(append.IsSuccess, Describe(append));
|
||||
|
||||
Result<IReadOnlyList<HistoryPoint>> got =
|
||||
await _repo.GetHistoryAsync(machineId, "d1", t0.AddSeconds(-1), 100, CancellationToken.None);
|
||||
Assert.True(got.IsSuccess, Describe(got));
|
||||
Assert.Empty(got.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PruneHistory_RemovesOlderThanCutoff_KeepsNewer()
|
||||
{
|
||||
var machineId = Guid.NewGuid();
|
||||
var t0 = new DateTimeOffset(2026, 7, 22, 8, 0, 0, TimeSpan.Zero);
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await _repo.AppendHistoryAsync(
|
||||
Snap(machineId, t0.AddMinutes(i), new DataItem("d1", "Speed", i.ToString(), "Sample", t0.AddMinutes(i))),
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
// Cutoff at minute 3: rows < t0+3min (minutes 0,1,2) removed, 3 and 4 kept.
|
||||
Result prune = await _repo.PruneHistoryAsync(t0.AddMinutes(3), CancellationToken.None);
|
||||
Assert.True(prune.IsSuccess, Describe(prune));
|
||||
|
||||
Result<IReadOnlyList<HistoryPoint>> got =
|
||||
await _repo.GetHistoryAsync(machineId, "d1", DateTimeOffset.MinValue, 100, CancellationToken.None);
|
||||
Assert.True(got.IsSuccess, Describe(got));
|
||||
Assert.Equal(2, got.Value.Count);
|
||||
Assert.Equal("3", got.Value[0].Value);
|
||||
Assert.Equal("4", got.Value[1].Value);
|
||||
|
||||
// Idempotent: pruning again with a cutoff below everything removed changes nothing.
|
||||
Result prune2 = await _repo.PruneHistoryAsync(t0.AddMinutes(3), CancellationToken.None);
|
||||
Assert.True(prune2.IsSuccess, Describe(prune2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_AlsoClearsMachineHistory()
|
||||
{
|
||||
var machineId = Guid.NewGuid();
|
||||
var t0 = new DateTimeOffset(2026, 7, 22, 8, 0, 0, TimeSpan.Zero);
|
||||
|
||||
await _repo.UpsertAsync(
|
||||
new Machine(machineId, "M", "mtconnect", null, TimeSpan.FromSeconds(1)), CancellationToken.None);
|
||||
await _repo.AppendHistoryAsync(
|
||||
Snap(machineId, t0, new DataItem("d1", "Speed", "10", "Sample", t0)),
|
||||
CancellationToken.None);
|
||||
|
||||
Result delete = await _repo.DeleteAsync(machineId, CancellationToken.None);
|
||||
Assert.True(delete.IsSuccess, Describe(delete));
|
||||
|
||||
Result<IReadOnlyList<HistoryPoint>> got =
|
||||
await _repo.GetHistoryAsync(machineId, "d1", DateTimeOffset.MinValue, 100, CancellationToken.None);
|
||||
Assert.True(got.IsSuccess, Describe(got));
|
||||
Assert.Empty(got.Value);
|
||||
}
|
||||
|
||||
private static MachineSnapshot Snap(Guid machineId, DateTimeOffset capturedAt, params DataItem[] items) =>
|
||||
new MachineSnapshot(machineId, capturedAt, ConnectionState.Connected, items);
|
||||
|
||||
private static string Describe<T>(Result<T> result) =>
|
||||
result.IsSuccess ? "" : string.Join("; ", result.Errors.Select(e => e.ToString()));
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,17 @@ namespace Junction.Tests.Unit
|
|||
Assert.Contains("snapshot_items", tables);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureCreated_CreatesSnapshotHistoryTable()
|
||||
{
|
||||
var factory = new SqliteConnectionFactory(_connectionString);
|
||||
|
||||
Result result = SqliteSchema.EnsureCreated(factory);
|
||||
|
||||
Assert.True(result.IsSuccess, DescribeErrors(result));
|
||||
Assert.Contains("snapshot_history", QueryTableNames());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureCreated_IsIdempotent()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue