mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
5eb09176b9
Registers 5 secondary-index tables (SessionsByTerminal, SessionsByStage, DeadEndsByReason, ClassifierVerdictsByIdLevel, SignalsByKind) in Constants.TableNames.All[] so TableStorageService.InitializeTablesAsync auto-creates them at startup. Adds: - 5 sealed DTO records in Shared/Models/IndexRecords.cs carrying only discriminator columns + back-refs (StepIndex / SessionSignalOrdinal) to the primary row — no PayloadJson chunking needed. - IIndexTableRepository with 5 Store*Async methods (single-interface shape keeps boilerplate low; Cosmos swap remains trivial since each Store* is independent). - IndexRowKeys pure static helper for all PK/RK formats per Plan §2.8: D6 StepIndex suffix, D10 SessionSignalOrdinal suffix, D19 Ticks padding for SessionsByStage (matches lex ordering to numeric time). Defensive Sanitize() for forbidden PK/RK chars (/\#?). - TableIndexRepository with a generic group-by-PK + 100-op-chunk UpsertReplace write path mirroring TableSignalRepository. - DI wiring in DataAccessServiceExtensions. Scope: write-only. Query methods deferred until MCP/Web concretely need them. No queue producer / reconcile handler yet — that lands in M5.d.2+. Tests (+25 in Functions): 15 PK/RK format assertions in IndexRowKeysTests + 10 entity-projection round-trips (incl. null-tolerance) in TableIndexRepositoryMappingTests. Full Functions suite: 661 green (prev 636 + 25 new). Full solution build green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
154 lines
5.5 KiB
C#
154 lines
5.5 KiB
C#
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
|
|
|
namespace AutopilotMonitor.Functions.Tests;
|
|
|
|
/// <summary>
|
|
/// Pure PK/RK format tests for <see cref="IndexRowKeys"/> (Plan §2.8 query matrix, §M5.d).
|
|
/// These verify the string shape contract the queue-driven index writer + the reconciliation
|
|
/// timer both depend on — any drift here silently breaks cross-session queries.
|
|
/// </summary>
|
|
public class IndexRowKeysTests
|
|
{
|
|
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
|
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
|
|
|
private static readonly DateTime Occurred =
|
|
new(2026, 4, 21, 14, 7, 33, DateTimeKind.Utc);
|
|
|
|
// ============================================================ SessionsByTerminal
|
|
|
|
[Fact]
|
|
public void SessionsByTerminal_pk_is_tenant_underscore_terminalStage()
|
|
{
|
|
Assert.Equal(
|
|
$"{TenantId}_WhiteGloveSealed",
|
|
IndexRowKeys.BuildSessionsByTerminalPk(TenantId, "WhiteGloveSealed"));
|
|
}
|
|
|
|
[Fact]
|
|
public void SessionsByTerminal_rk_is_yyyyMMddHHmmss_underscore_sessionId()
|
|
{
|
|
Assert.Equal(
|
|
$"20260421140733_{SessionId}",
|
|
IndexRowKeys.BuildSessionsByTerminalRk(Occurred, SessionId));
|
|
}
|
|
|
|
// ============================================================ SessionsByStage
|
|
|
|
[Fact]
|
|
public void SessionsByStage_pk_is_tenant_underscore_stage()
|
|
{
|
|
Assert.Equal(
|
|
$"{TenantId}_EspInProgress",
|
|
IndexRowKeys.BuildSessionsByStagePk(TenantId, "EspInProgress"));
|
|
}
|
|
|
|
[Fact]
|
|
public void SessionsByStage_rk_pads_ticks_to_19_digits_for_lex_ordering()
|
|
{
|
|
var rk = IndexRowKeys.BuildSessionsByStageRk(Occurred, SessionId);
|
|
|
|
// 19-digit zero-padding is the contract — string comparison then matches numeric time order.
|
|
var parts = rk.Split('_');
|
|
Assert.Equal(19, parts[0].Length);
|
|
Assert.Equal(Occurred.Ticks.ToString("D19"), parts[0]);
|
|
Assert.Equal(SessionId, parts[1]);
|
|
}
|
|
|
|
[Fact]
|
|
public void SessionsByStage_rk_orders_lexicographically_matching_chronology()
|
|
{
|
|
var earlier = IndexRowKeys.BuildSessionsByStageRk(Occurred, SessionId);
|
|
var later = IndexRowKeys.BuildSessionsByStageRk(Occurred.AddSeconds(1), SessionId);
|
|
|
|
Assert.True(string.CompareOrdinal(earlier, later) < 0);
|
|
}
|
|
|
|
// ============================================================ DeadEndsByReason
|
|
|
|
[Fact]
|
|
public void DeadEndsByReason_pk_is_tenant_underscore_reason()
|
|
{
|
|
Assert.Equal(
|
|
$"{TenantId}_hybrid_reboot_gate_blocking",
|
|
IndexRowKeys.BuildDeadEndsByReasonPk(TenantId, "hybrid_reboot_gate_blocking"));
|
|
}
|
|
|
|
[Fact]
|
|
public void DeadEndsByReason_rk_combines_timestamp_session_stepIndex_padded_to_6()
|
|
{
|
|
Assert.Equal(
|
|
$"20260421140733_{SessionId}_000042",
|
|
IndexRowKeys.BuildDeadEndsByReasonRk(Occurred, SessionId, 42));
|
|
}
|
|
|
|
// ============================================================ ClassifierVerdictsByIdLevel
|
|
|
|
[Fact]
|
|
public void ClassifierVerdictsByIdLevel_pk_is_tenant_underscore_classifierId_underscore_level()
|
|
{
|
|
Assert.Equal(
|
|
$"{TenantId}_whiteglove-sealing_Weak",
|
|
IndexRowKeys.BuildClassifierVerdictsByIdLevelPk(TenantId, "whiteglove-sealing", "Weak"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ClassifierVerdictsByIdLevel_rk_combines_timestamp_session_stepIndex_padded_to_6()
|
|
{
|
|
Assert.Equal(
|
|
$"20260421140733_{SessionId}_000007",
|
|
IndexRowKeys.BuildClassifierVerdictsByIdLevelRk(Occurred, SessionId, 7));
|
|
}
|
|
|
|
// ============================================================ SignalsByKind
|
|
|
|
[Fact]
|
|
public void SignalsByKind_pk_is_tenant_underscore_signalKind()
|
|
{
|
|
Assert.Equal(
|
|
$"{TenantId}_EspTerminalFailure",
|
|
IndexRowKeys.BuildSignalsByKindPk(TenantId, "EspTerminalFailure"));
|
|
}
|
|
|
|
[Fact]
|
|
public void SignalsByKind_rk_combines_timestamp_session_ordinal_padded_to_10()
|
|
{
|
|
Assert.Equal(
|
|
$"20260421140733_{SessionId}_0000000123",
|
|
IndexRowKeys.BuildSignalsByKindRk(Occurred, SessionId, 123));
|
|
}
|
|
|
|
// ============================================================ UTC coercion + sanitizing
|
|
|
|
[Fact]
|
|
public void Timestamp_always_normalizes_to_UTC()
|
|
{
|
|
// If a caller passes Local-kind DateTime, conversion must still produce the UTC wall clock.
|
|
var localKind = DateTime.SpecifyKind(Occurred, DateTimeKind.Local);
|
|
var rk = IndexRowKeys.BuildSessionsByTerminalRk(localKind, SessionId);
|
|
|
|
Assert.Equal($"{localKind.ToUniversalTime():yyyyMMddHHmmss}_{SessionId}", rk);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("foo/bar", "foo_bar")]
|
|
[InlineData("foo\\bar", "foo_bar")]
|
|
[InlineData("foo#bar", "foo_bar")]
|
|
[InlineData("foo?bar", "foo_bar")]
|
|
[InlineData("clean_name", "clean_name")]
|
|
[InlineData("", "")]
|
|
public void Sanitize_replaces_forbidden_key_chars_with_underscore(string input, string expected)
|
|
{
|
|
Assert.Equal(expected, IndexRowKeys.Sanitize(input));
|
|
}
|
|
|
|
[Fact]
|
|
public void Sanitize_preserves_enum_style_identifiers_unchanged()
|
|
{
|
|
// Discriminators are overwhelmingly enum names — sanitize must not mangle them.
|
|
Assert.Equal("WhiteGloveSealed", IndexRowKeys.Sanitize("WhiteGloveSealed"));
|
|
Assert.Equal("EspTerminalFailure", IndexRowKeys.Sanitize("EspTerminalFailure"));
|
|
Assert.Equal("hybrid_reboot_gate_blocking", IndexRowKeys.Sanitize("hybrid_reboot_gate_blocking"));
|
|
}
|
|
}
|