using Robotico.Nexus.Client.Tests.TestSupport; using Robotico.Nexus.Client.Time; using Robotico.Result.Errors; using Robotico.Time; using Xunit; namespace Robotico.Nexus.Client.Tests.Time; public class NexusTimeSessionTests : IAsyncLifetime { private static readonly IReadOnlyDictionary NoLabels = new Dictionary(); private NexusTestHost _host = null!; private NexusClient _client = null!; private ITimeSession _session = null!; public async Task InitializeAsync() { _host = await NexusTestHost.StartAsync(); Robotico.Result.Result connected = NexusClient.Connect(_host.ConnectionUri(NexusUriSchemes.Time)); Assert.True(connected.IsSuccess(out NexusClient? client, out _)); _client = client!; Robotico.Result.Result opened = _client.Time.Open("session-time-db"); Assert.True(opened.IsSuccess(out ITimeSession? session, out _)); _session = session!; } public async Task DisposeAsync() { _session.Dispose(); _client.Dispose(); await _host.DisposeAsync(); } [Fact] public void Doctor_ReturnsSyntheticIdentity_AndCounts() { Robotico.Result.Result result = _session.Doctor(); Assert.True(result.IsSuccess(out TimeDoctorReport? report, out _)); Assert.StartsWith("nexus://", report!.PrimaryPath, StringComparison.Ordinal); Assert.Equal(report.PrimaryPath, report.WalPath); Assert.Equal(0, report.MaxActiveSeries); } [Fact] public void RegisterSeries_Succeeds_AndReturnsId() { Robotico.Result.Result result = _session.RegisterSeries(new TimeSeriesKey("cpu.load", NoLabels)); Assert.True(result.IsSuccess(out ulong seriesId, out _)); Assert.True(seriesId > 0); } [Fact] public void RegisterSeries_Fails_WhenMetricNameBlank() { Robotico.Result.Result result = _session.RegisterSeries(new TimeSeriesKey(" ", NoLabels)); Assert.True(result.IsError(out IError? error)); Assert.Equal(TimeErrors.InvalidArgument, error!.Code); } [Fact] public void AppendSample_ThenQueryRange_RoundTrips() { ulong seriesId = RegisterSeries("cpu.load"); Robotico.Result.Result appended = _session.AppendSample(seriesId, 1000, 42.5); Assert.True(appended.IsSuccess()); Robotico.Result.Result> queried = _session.QueryRange(seriesId, 0, 2000, 10); Assert.True(queried.IsSuccess(out IReadOnlyList? samples, out _)); Assert.Single(samples!); Assert.Equal(42.5, samples![0].Value); } [Fact] public void AppendSample_Fails_WhenValueNotFinite() { ulong seriesId = RegisterSeries("cpu.load"); Robotico.Result.Result result = _session.AppendSample(seriesId, 1000, double.NaN); Assert.True(result.IsError(out IError? error)); Assert.Equal(TimeErrors.InvalidArgument, error!.Code); } [Fact] public void AppendSampleBatch_Succeeds() { ulong seriesId = RegisterSeries("mem.used"); TimeSample[] samples = [new TimeSample(1000, 1.0), new TimeSample(2000, 2.0)]; Robotico.Result.Result result = _session.AppendSampleBatch(seriesId, samples); Assert.True(result.IsSuccess()); Robotico.Result.Result> queried = _session.QueryRange(seriesId, 0, 3000, 10); Assert.True(queried.IsSuccess(out IReadOnlyList? results, out _)); Assert.Equal(2, results!.Count); } [Fact] public void AppendSampleBatch_Fails_WhenEmpty() { ulong seriesId = RegisterSeries("mem.used"); Robotico.Result.Result result = _session.AppendSampleBatch(seriesId, []); Assert.True(result.IsError(out IError? error)); Assert.Equal(TimeErrors.InvalidArgument, error!.Code); } [Fact] public void AppendSampleBatch_Fails_WhenAnyValueNotFinite() { ulong seriesId = RegisterSeries("mem.used"); Robotico.Result.Result result = _session.AppendSampleBatch(seriesId, [new TimeSample(1000, double.PositiveInfinity)]); Assert.True(result.IsError(out IError? error)); Assert.Equal(TimeErrors.InvalidArgument, error!.Code); } [Fact] public void Last_ReturnsNull_WhenNoSamples() { ulong seriesId = RegisterSeries("disk.free"); Robotico.Result.Result result = _session.Last(seriesId); Assert.True(result.IsSuccess(out TimeSample? sample, out _)); Assert.Null(sample); } [Fact] public void Last_ReturnsMostRecentSample() { ulong seriesId = RegisterSeries("disk.free"); _session.AppendSample(seriesId, 1000, 1.0); _session.AppendSample(seriesId, 2000, 2.0); Robotico.Result.Result result = _session.Last(seriesId); Assert.True(result.IsSuccess(out TimeSample? sample, out _)); Assert.Equal(2.0, sample!.Value.Value); } [Fact] public void RegisterEventTable_Succeeds() { TimeEventTableSchema schema = new("requests", [new TimeEventField("path", "Utf8", TimeEventFieldIndexKind.Inverted)]); Robotico.Result.Result result = _session.RegisterEventTable(schema); Assert.True(result.IsSuccess(out ulong tableId, out _)); Assert.True(tableId > 0); } [Fact] public void RegisterEventTable_Fails_WhenNoFields() { TimeEventTableSchema schema = new("requests", []); Robotico.Result.Result result = _session.RegisterEventTable(schema); Assert.True(result.IsError(out IError? error)); Assert.Equal(TimeErrors.InvalidArgument, error!.Code); } [Fact] public void RegisterEventTable_Fails_WhenTableNameBlank() { TimeEventTableSchema schema = new(string.Empty, [new TimeEventField("path", "Utf8", TimeEventFieldIndexKind.None)]); Robotico.Result.Result result = _session.RegisterEventTable(schema); Assert.True(result.IsError(out IError? error)); Assert.Equal(TimeErrors.InvalidArgument, error!.Code); } [Fact] public void AppendEvent_ThenQueryEvents_RoundTrips() { ulong tableId = RegisterEventTable(); Robotico.Result.Result appended = _session.AppendEvent(tableId, 1000, [new TimeCell("Utf8", Utf8: "/api/foo")]); Assert.True(appended.IsSuccess()); Robotico.Result.Result> queried = _session.QueryEvents(tableId, 0, 2000, 10); Assert.True(queried.IsSuccess(out IReadOnlyList? events, out _)); Assert.Single(events!); Assert.Equal("/api/foo", events![0].Cells[0].Utf8); } [Fact] public void AppendEvent_Fails_WhenCellsEmpty() { ulong tableId = RegisterEventTable(); Robotico.Result.Result result = _session.AppendEvent(tableId, 1000, []); Assert.True(result.IsError(out IError? error)); Assert.Equal(TimeErrors.InvalidArgument, error!.Code); } [Fact] public void LookupInverted_FindsExactMatch() { ulong tableId = RegisterEventTable(); _session.AppendEvent(tableId, 1000, [new TimeCell("Utf8", Utf8: "/api/foo")]); _session.AppendEvent(tableId, 2000, [new TimeCell("Utf8", Utf8: "/api/bar")]); Robotico.Result.Result> result = _session.LookupInverted(tableId, 0, "/api/foo", 10); Assert.True(result.IsSuccess(out IReadOnlyList? events, out _)); Assert.Single(events!); } [Fact] public void SearchFullText_FindsSubstringMatch() { ulong tableId = RegisterEventTable(); _session.AppendEvent(tableId, 1000, [new TimeCell("Utf8", Utf8: "hello world")]); Robotico.Result.Result> result = _session.SearchFullText(tableId, 0, "world", 10); Assert.True(result.IsSuccess(out IReadOnlyList? hits, out _)); Assert.Single(hits!); } [Fact] public void ListSeries_GetCardinality_ApplyRetention_CatalogOps() { NexusTimeSession nexus = Assert.IsType(_session); ulong s1 = RegisterSeries("cpu.load"); _session.AppendSample(s1, 100, 1.0); _session.AppendSample(s1, 200, 2.0); ulong s2 = RegisterSeries("cpu.load"); RegisterSeries("mem.used"); Assert.True(nexus.ListSeries(10).IsSuccess(out IReadOnlyList? listed, out _)); Assert.Equal(3, listed!.Count); Assert.Contains(listed, e => e is { SeriesId: var id, MetricName: "cpu.load", SampleCount: 2 } && id == s1); Assert.True(nexus.GetCardinality(10).IsSuccess(out TimeCardinalitySnapshot? card, out _)); Assert.Equal(3, card!.ActiveSeriesCount); Assert.Equal(2, card.TotalSampleCount); Assert.Contains(card.TopMetricsBySeriesCount, m => m is { MetricName: "cpu.load", SeriesCount: 2 }); Assert.True(nexus.ApplyRetention(150).IsSuccess(out long removed, out _)); Assert.True(removed >= 1); Assert.True(nexus.ApplyRetention(0).IsError(out IError? retErr)); Assert.Equal(TimeErrors.InvalidArgument, retErr!.Code); } [Fact] public void AllOperations_Fail_AfterDispose() { NexusTimeSession nexus = Assert.IsType(_session); _session.Dispose(); Assert.True(_session.Doctor().IsError(out IError? e1)); Assert.Equal(NexusErrors.Disposed, e1!.Code); Assert.True(_session.RegisterSeries(new TimeSeriesKey("x", NoLabels)).IsError(out IError? e2)); Assert.Equal(NexusErrors.Disposed, e2!.Code); Assert.True(_session.AppendSample(1, 1, 1.0).IsError(out IError? e3)); Assert.Equal(NexusErrors.Disposed, e3!.Code); Assert.True(_session.AppendSampleBatch(1, [new TimeSample(1, 1.0)]).IsError(out IError? e4)); Assert.Equal(NexusErrors.Disposed, e4!.Code); Assert.True(_session.QueryRange(1, 0, 10, 10).IsError(out IError? e5)); Assert.Equal(NexusErrors.Disposed, e5!.Code); Assert.True(_session.Last(1).IsError(out IError? e6)); Assert.Equal(NexusErrors.Disposed, e6!.Code); Assert.True(_session.RegisterEventTable(new TimeEventTableSchema("t", [new TimeEventField("f", "Utf8", TimeEventFieldIndexKind.None)])).IsError(out IError? e7)); Assert.Equal(NexusErrors.Disposed, e7!.Code); Assert.True(_session.AppendEvent(1, 1, [new TimeCell("Utf8", Utf8: "x")]).IsError(out IError? e8)); Assert.Equal(NexusErrors.Disposed, e8!.Code); Assert.True(_session.QueryEvents(1, 0, 10, 10).IsError(out IError? e9)); Assert.Equal(NexusErrors.Disposed, e9!.Code); Assert.True(_session.LookupInverted(1, 0, "x", 10).IsError(out IError? e10)); Assert.Equal(NexusErrors.Disposed, e10!.Code); Assert.True(_session.SearchFullText(1, 0, "x", 10).IsError(out IError? e11)); Assert.Equal(NexusErrors.Disposed, e11!.Code); Assert.True(nexus.ListSeries().IsError(out IError? e12)); Assert.Equal(NexusErrors.Disposed, e12!.Code); Assert.True(nexus.GetCardinality().IsError(out IError? e13)); Assert.Equal(NexusErrors.Disposed, e13!.Code); Assert.True(nexus.ApplyRetention(1).IsError(out IError? e14)); Assert.Equal(NexusErrors.Disposed, e14!.Code); } private ulong RegisterSeries(string metricName) { Robotico.Result.Result result = _session.RegisterSeries(new TimeSeriesKey(metricName, NoLabels)); Assert.True(result.IsSuccess(out ulong id, out _)); return id; } private ulong RegisterEventTable() { TimeEventTableSchema schema = new("events-" + Guid.NewGuid(), [new TimeEventField("body", "Utf8", TimeEventFieldIndexKind.FullText)]); Robotico.Result.Result result = _session.RegisterEventTable(schema); Assert.True(result.IsSuccess(out ulong id, out _)); return id; } }