Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions Darling/Darling.Tests/DarlingMcpAgToolsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,56 @@ public void Instructions_DocumentTheAgTool()
Assert.Contains("get_ag_health", DarlingMcpInstructions.Text, StringComparison.Ordinal);
Assert.Contains("Availability Group", DarlingMcpInstructions.Text, StringComparison.Ordinal);
}

/// <summary>
/// #3653 A15/A16: the description says the two things the reader's own doc comment warns about, because a
/// caller reads the description and never the reader. <see cref="DarlingAgReader"/>'s summary carries two
/// bolded traps — <b>Latest collection per server</b> (the collectors write NO row for a server with no AGs,
/// so a dropped AG lingers as its last non-empty snapshot and the group's <c>collection_time</c> is the only
/// tell) and <b>What the badge deliberately does NOT band</b> (every severity restates a DMV state or health
/// string; lag and queue depth are raw magnitudes, so a badly lagging asynchronous secondary carries a healthy
/// severity) — and the description used to say neither. #3696 put both sentences on it, plus the trap inside
/// the second (the DMV reports 0 lag while data movement is suspended, so lag is read with
/// <c>is_suspended</c>). Nothing pinned them, and a description that loses a sentence fails nothing: the
/// reader's comment goes on being right in a file no agent opens.
///
/// <para>Anchored on the reader's own paragraph headers first, so the pin fails with "re-anchor" if the
/// reader stops documenting a trap rather than passing against prose that no longer exists; then on the
/// description and the instructions paragraph — the two texts an agent reads before calling — for each
/// trap's mechanism, consequence and the field that tells it apart. Phrase matching rather than a
/// paraphrase test, because these are the exact words a reader would search for.</para>
/// </summary>
[Fact]
public void Description_SurfacesTheReadersTwoDocumentedTraps()
{
var reader = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingAgReader.cs");
Assert.Contains("<b>Latest collection per server.</b>", reader, StringComparison.Ordinal);
Assert.Contains("<b>What the badge deliberately does NOT band: lag and queue depth.</b>", reader, StringComparison.Ordinal);

var description = ToolMethods()
.Single(m => m.GetCustomAttribute<McpServerToolAttribute>()!.Name == "get_ag_health")
.GetCustomAttribute<DescriptionAttribute>()!.Description;

/* Trap one, the stale snapshot: mechanism, consequence, tell. */
Assert.Contains("NO row for a server with no AGs", description, StringComparison.Ordinal);
Assert.Contains("last non-empty snapshot", description, StringComparison.Ordinal);
Assert.Contains("collection_time", description, StringComparison.Ordinal);

/* Trap two, the un-banded magnitudes: the negative on the word a caller would look for, the fields to
read instead, and the DMV's 0-while-suspended inside it. */
Assert.Contains("lag and queue depth are NOT banded", description, StringComparison.Ordinal);
Assert.Contains("secondary_lag_seconds", description, StringComparison.Ordinal);
Assert.Contains("is_suspended", description, StringComparison.Ordinal);
Assert.Contains("0 lag while data movement is suspended", description, StringComparison.Ordinal);

/* The instructions' AG paragraph is the other text an agent reads first; it carries the same two traps
in its own words, and the perspective trap (LOCAL-replica-only columns) it always had. */
var instructions = DarlingMcpInstructions.Text;
Assert.Contains("lag and queue depth are NOT banded", instructions, StringComparison.Ordinal);
Assert.Contains("the DMV reports lag as 0 (not null) while data movement is suspended", instructions, StringComparison.Ordinal);
Assert.Contains("keeps returning its last non-empty snapshot", instructions, StringComparison.Ordinal);
Assert.Contains("populated only for the LOCAL replica", instructions, StringComparison.Ordinal);
}
}

/// <summary>
Expand Down
42 changes: 42 additions & 0 deletions Darling/Darling.Tests/DarlingMcpTrendToolsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
using Npgsql;
using PerformanceMonitor.Collectors;
using PerformanceMonitor.Common;
using PerformanceMonitor.Darling.Service;
using PerformanceMonitor.Darling.Service.Mcp;
using PerformanceMonitor.Darling.Storage;
using PerformanceMonitor.Darling.Viewer;
Expand Down Expand Up @@ -117,6 +118,47 @@ public void MemoryTrend_Description_PromisesTheJoinedGrantSeries_AndNamesTheNull
Assert.Contains("get_memory_grants", description, StringComparison.Ordinal);
}

/// <summary>
/// #3653 A15/A16: the web catalogue's line for get_query_duration_trend said "percentiles". The payload has
/// never carried one — <c>SerializeTrend</c>, the helper the duration trio shares, publishes
/// <c>elapsed_ms_per_second</c> and <c>executions_per_second</c> per point, a rate over the collection's
/// stored interval on the raw route and over the bucket width on the hourly one — and the tool's own
/// description has said so since #3541 A12. The catalogue (<see cref="DarlingWebEndpoints.CatalogDescriptors"/>)
/// is what the web client's view composer and the reads listing show a human choosing a read, so it was
/// the one place a wrong word about this read was still standing. #3696 corrected the line; this pins the
/// correction against BOTH ends so they cannot drift apart again: the line names the two rates the
/// serializer publishes, the serializer still publishes exactly those, and across the whole catalogue the
/// word "percentile" appears only negated — no read here computes one. A read that genuinely returns a
/// percentile (none does today) would add itself beside this sentence rather than fall through it.
/// </summary>
[Fact]
public void QueryDurationTrend_CatalogueLine_NamesTheRatesThePayloadCarries_NotPercentiles()
{
var line = DarlingWebEndpoints.CatalogDescriptors["get_query_duration_trend"].Description;
Assert.Contains("ms/sec", line, StringComparison.Ordinal);
Assert.Contains("executions/sec", line, StringComparison.Ordinal);
Assert.Contains("not percentiles", line, StringComparison.Ordinal);

/* The payload side: the shared serializer's point projection, and no percentile of anything in it. */
var source = Lf(RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpTrendTools.cs"));
var at = source.IndexOf("private static string SerializeTrend(", StringComparison.Ordinal);
Assert.True(at > 0, "SerializeTrend moved — re-anchor this pin");
var end = source.IndexOf("\n }\n", at, StringComparison.Ordinal);
var body = source[at..end];
Assert.Contains("elapsed_ms_per_second = p.Value", body, StringComparison.Ordinal);
Assert.Contains("executions_per_second = p.ExecutionsPerSecond", body, StringComparison.Ordinal);
Assert.DoesNotContain("percentile", body, StringComparison.OrdinalIgnoreCase);

/* And the whole catalogue: "percentile" only ever as a negation. */
var claims = DarlingWebEndpoints.CatalogDescriptors
.Where(kv => System.Text.RegularExpressions.Regex.IsMatch(kv.Value.Description, @"(?<!not )percentiles?", System.Text.RegularExpressions.RegexOptions.IgnoreCase))
.Select(kv => kv.Key)
.OrderBy(k => k, StringComparer.Ordinal)
.ToArray();
Assert.True(claims.Length == 0,
"catalogue line(s) claim a percentile no read computes: " + string.Join(", ", claims));
}

[Fact]
public void MemoryTrendSql_WindowedBothSides_CastsNumericToDouble()
{
Expand Down
113 changes: 113 additions & 0 deletions Darling/Darling.Tests/ServerPageTabsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,73 @@ public void TheQueryTrendDrillDown_ReadsTheWindowFloorOffItsOwnKey()
Assert.DoesNotContain("trend.data.truncated", body, StringComparison.Ordinal);
}

/// <summary>
/// The latest-snapshot reads are read by their stamp's one spelling. #3637 made <c>captured_at</c> the
/// census's word for "when this snapshot was taken" and <see cref="McpLatestSnapshotStampTests"/> holds
/// every latest read to it; four of them (<c>get_database_sizes</c>, <c>get_running_jobs</c>,
/// <c>get_server_properties</c>, <c>get_session_stats</c>) had stamped themselves as <c>collection_time</c>
/// before that vocabulary existed, and this page's Sessions tile read the old key — the one consumer outside
/// that census's boundary, and the reason the four sat in a named allowance instead of the roster (#3653
/// A15/A16). #3697 renamed the payloads on both SKUs and moved the tile. Nothing pinned the tile, and a key
/// drifting back fails silently: an absent key renders as the empty dash, the tile still draws, and the
/// snapshot's age — the one number that says whether a Stamped read is stale — quietly stops appearing.
///
/// <para>Derived, not listed. Every read the stamp census rosters as Stamped or SearchBound publishes its
/// stamp ONCE at the top as <c>captured_at</c> and nothing under <c>collection_time</c> anywhere in its
/// payload (the census's negative sweep refuses the top-level key, and none of those tools projects the word
/// into a row either), so ANY descriptor this page binds to one of those reads that names
/// <c>collection_time</c> is a dead read — whether a stat tile over the top-level object or a table column
/// over its rows. Windowed reads are excluded on purpose: their <c>grants[]</c> rows carry a per-row
/// <c>collection_time</c> that IS a series column. The positive half is the one tile the item named: it must
/// show the stamp under the new word, so the pin cannot pass by the tile being deleted.</para>
/// </summary>
[Fact]
public void TheStampedReads_AreReadByTheirStamp_NeverTheRetiredKey()
{
var js = ServerTabsJs;

var stamped = McpLatestSnapshotStampTests.LatestTools
.Where(t => t.Shape is McpLatestSnapshotStampTests.Shape.Stamped or McpLatestSnapshotStampTests.Shape.SearchBound)
.Select(t => t.ToolName)
.ToHashSet(StringComparer.Ordinal);
Assert.True(stamped.IsSupersetOf(["get_database_sizes", "get_running_jobs", "get_server_properties", "get_session_stats"]),
"the four reads this pin was written for have left the stamp roster — re-anchor before editing");

var arrays = DescriptorArraysIn(js);
var problems = new List<string>();
var examined = new List<string>();

foreach (var (read, arrayNames) in DescriptorArraysBoundIn(js))
{
if (!stamped.Contains(read)) continue;

foreach (var name in arrayNames)
{
Assert.True(arrays.TryGetValue(name, out var descriptors),
$"{name}, bound to {read}, is not a top-level const array in server-tabs.js — the parser below needs re-anchoring");
examined.Add($"{read}/{name}");

if (Regex.IsMatch(descriptors, @"key:\s*""collection_time"""))
{
problems.Add($"{read} is read through {name}, which names collection_time — a key no Stamped read publishes; its stamp is captured_at");
}
}
}

/* The four named reads must all have been reached, or the parser has silently stopped seeing the panels
and every assertion above passed on nothing. */
foreach (var read in new[] { "get_database_sizes", "get_running_jobs", "get_server_properties", "get_session_stats" })
{
Assert.Contains(examined, e => e.StartsWith(read + "/", StringComparison.Ordinal));
}

Assert.True(problems.Count == 0, string.Join("; ", problems));

/* The positive half: the Sessions tile shows the stamp, under the word the census uses, as an age. */
Assert.True(arrays.TryGetValue("SESSION_STATS", out var sessionStats), "SESSION_STATS moved — re-anchor this pin");
Assert.Matches(@"\{ key: ""captured_at"", label: ""Collected"", format: ""reltime""", sessionStats);
}

/// <summary>
/// Every viz a descriptor names is in the shipped vocabulary. The four kinds are the whole registry; a fifth
/// would have to be added to panels.js's VIZ, to <c>KnownVizList</c> (or the composer could not offer it), to
Expand Down Expand Up @@ -1096,4 +1163,50 @@ private static HashSet<string> ReadNamesIn(string js) =>
yield return (m.Groups[1].Value, keys);
}
}

/// <summary>Every top-level <c>const NAME = [ ... ];</c> descriptor array in the module, by name. These are
/// the column and stat vocabularies the panels bind to (<c>*_COLUMNS</c>, <c>*_STATS</c>, <c>*_SERIES</c>);
/// the body is the text between the brackets, comments included.</summary>
private static Dictionary<string, string> DescriptorArraysIn(string js) =>
Regex.Matches(js, @"^const ([A-Z][A-Z0-9_]+) = \[(.*?)^\];", RegexOptions.Multiline | RegexOptions.Singleline)
.ToDictionary(m => m.Groups[1].Value, m => m.Groups[2].Value, StringComparer.Ordinal);

/// <summary>
/// The (read, descriptor-array names) pairs the module's panel helpers bind: <c>stat("T", "read", {...},
/// NAME, ...)</c>, <c>table("T", "read", {...}, "rows", NAME, ...)</c> and <c>fanout("read", {...}, [{ stats:
/// NAME }, { columns: NAME }])</c>. Each call is sliced by bracket balance rather than by regex because a
/// fanout's spec array nests objects inside an array inside the call; the read is the first read-shaped
/// literal in the call, and the arrays are every <c>*_COLUMNS</c> / <c>*_STATS</c> token in it. The
/// helpers' own definitions (<c>function stat(title, read, ...)</c>) carry no literal and are skipped.
/// String literals are NOT skipped: a bracket inside a title or an empty-state sentence is balanced in
/// practice, and a lone one would end the slice early — a silent miss on the arrays after it, never a false
/// alarm, which the caller's reach check on its named reads bounds.
/// </summary>
private static IEnumerable<(string Read, string[] Arrays)> DescriptorArraysBoundIn(string js)
{
foreach (Match open in Regex.Matches(js, @"(?<![A-Za-z0-9_])(?:stat|table|fanout)\("))
{
var depth = 1;
var i = open.Index + open.Length;
while (depth > 0 && i < js.Length)
{
switch (js[i])
{
case '(' or '[' or '{': depth++; break;
case ')' or ']' or '}': depth--; break;
}
i++;
}

var call = js[open.Index..i];
var read = Regex.Match(call, "\"(get_[a-z0-9_]+|audit_config)\"");
if (!read.Success) continue;

var arrays = Regex.Matches(call, @"\b([A-Z][A-Z0-9_]*_(?:COLUMNS|STATS))\b")
.Select(m => m.Groups[1].Value)
.Distinct(StringComparer.Ordinal)
.ToArray();
yield return (read.Groups[1].Value, arrays);
}
}
}
Loading