diff --git a/test/MeshWeaver.Hosting.Monolith.Test/PandasExplorerLayoutAreaTest.cs b/test/MeshWeaver.Hosting.Monolith.Test/PandasExplorerLayoutAreaTest.cs new file mode 100644 index 000000000..06cf24cf8 --- /dev/null +++ b/test/MeshWeaver.Hosting.Monolith.Test/PandasExplorerLayoutAreaTest.cs @@ -0,0 +1,146 @@ +using System; +using System.IO; +using System.Linq; +using System.Reactive.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using MeshWeaver.Data; +using MeshWeaver.Documentation; +using MeshWeaver.Graph; +using MeshWeaver.Graph.Configuration; +using MeshWeaver.Hosting.Monolith.TestBase; +using MeshWeaver.Hosting.Persistence; +using MeshWeaver.Layout; +using MeshWeaver.Layout.Composition; +using MeshWeaver.Mesh; +using MeshWeaver.Messaging; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace MeshWeaver.Hosting.Monolith.Test; + +/// +/// Render-safety test for the Python pandas sample's C# frontend — the live-compiled +/// Doc/DataMesh/PythonPandasNode/PandasExplorer NodeType and its Explorer layout area +/// (PandasExplorerLayoutAreas). The area drives a Python py/pandas participant; in prod +/// and CI NO such participant is attached, and the contract is that the grid then DEGRADES to a +/// "No Python pandas node attached" notice within the backend timeout — it must never hang +/// and never show a raw error. +/// +/// This exercises the REAL embedded Source: AddDocumentation() registers the whole +/// Doc partition (the Pandas NodeType, its two Source/*.cs files, and the +/// LiveFrame instance ship as embedded resources under MeshWeaver.Documentation.Data), +/// so the mesh compiles the actual production frontend with Roslyn and renders the actual instance — +/// mirroring . No compiled test double. +/// +/// Every assertion runs under a hard wall-clock budget: if the grid sub-area hung instead of +/// degrading, the Within(...) bound trips and the test FAILS loudly rather than blocking CI — +/// which is the exact production hazard being guarded. +/// +public class PandasExplorerLayoutAreaTest : MonolithMeshTestBase +{ + /// The live explorer instance shipped in the Doc partition (see LiveFrame.json). + private const string LiveFramePath = "Doc/DataMesh/PythonPandasNode/PandasExplorer/LiveFrame"; + + /// The single interactive area the NodeType exposes (its default area). + private const string ExplorerArea = "Explorer"; + + /// + /// Wall-clock budget for the whole render. The area's own backend timeout is 8s, so 50s is + /// generous head-room for the cold Roslyn compile of the NodeType Source on top of the degrade — + /// yet still a HARD ceiling, so a genuine hang (the regression this guards) fails the test. + /// + private static readonly TimeSpan RenderBudget = TimeSpan.FromSeconds(50); + + private readonly string _cacheDirectory; + + public PandasExplorerLayoutAreaTest(ITestOutputHelper output) : base(output) + { + // Per-test-class cache dir — a stale, prior-process DLL in the shared bin/.mesh-cache can lock + // on Windows and wedge the compile (same rationale as CessionLayoutAreaTest). + _cacheDirectory = Path.Combine(Path.GetTempPath(), "MeshWeaverPandasExplorerTests"); + Directory.CreateDirectory(_cacheDirectory); + } + + protected override MeshBuilder ConfigureMesh(MeshBuilder builder) => + builder + .UseMonolithMesh() + // AddDocumentation registers the embedded-resource "Doc" partition (which holds the Pandas + // NodeType + Source + LiveFrame instance); partition-routing persistence is required for + // that provider to actually serve reads. + .AddPartitionedInMemoryPersistence() + .AddDocumentation() + .ConfigureServices(services => + services.Configure(o => o.CacheDirectory = _cacheDirectory)) + .ConfigureDefaultNodeHub(config => config.AddDefaultLayoutAreas()) + .AddGraph() + .AddMeshNodes(TestUsers.PublicAdminAccess()); + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + if (Directory.Exists(_cacheDirectory)) + try { Directory.Delete(_cacheDirectory, recursive: true); } catch { /* best-effort cleanup */ } + } + + [Fact(Timeout = 120000)] + public async Task Explorer_NoPythonNode_RendersToolbarAndDegradesGridWithoutHanging() + { + var client = GetClient(c => c.AddData(data => data)); + var address = new Address(LiveFramePath); + + // No ping: subscribing to the layout area activates the per-node hub AND triggers the cold + // Roslyn compile of the Pandas NodeType Source. The budget below covers that. + var stream = client.GetWorkspace() + .GetRemoteStream( + address, new LayoutAreaReference(ExplorerArea)); + + // 1. The Explorer area renders a Stack composing title + intro + toolbar + grid sub-areas. + var root = await stream.GetControlStream(ExplorerArea) + .Where(c => c is not null) + .Should().Within(RenderBudget).Match(c => c is StackControl); + + Output.WriteLine($"Explorer root control: {root!.GetType().Name}"); + var stack = root.Should().BeOfType().Subject; + + var areaIds = stack.Areas.Select(a => a.Id?.ToString()).ToArray(); + Output.WriteLine($"Explorer sub-areas: {string.Join(", ", areaIds)}"); + stack.Areas.Should().HaveCountGreaterThanOrEqualTo(4, + "Explorer composes title + intro + toolbar + grid"); + areaIds.Should().Contain("toolbar", "the interactive toolbar is a named sub-area"); + areaIds.Should().Contain("grid", "the reactive grid is a named sub-area"); + + // 2. The toolbar resolves to a horizontal Stack whose named sub-areas are the real buttons — + // the point of the sample is genuine framework controls, not an HTML string. + var toolbar = await stream.GetControlStream($"{ExplorerArea}/toolbar") + .Where(c => c is not null) + .Should().Within(RenderBudget).Match(c => c is StackControl); + + var toolbarStack = toolbar!.Should().BeOfType().Subject; + var buttonAreas = toolbarStack.Areas.Select(a => a.Id?.ToString()).ToArray(); + Output.WriteLine($"Toolbar sub-areas: {string.Join(", ", buttonAreas)}"); + foreach (var expected in new[] { "load", "append", "groupby", "rolling", "describe", "refresh", "reset" }) + buttonAreas.Should().Contain(expected, $"the toolbar exposes the '{expected}' button"); + + // Each named button area resolves to a real ButtonControl carrying its label. + var loadButton = await stream.GetControlStream($"{ExplorerArea}/toolbar/load") + .Where(c => c is not null) + .Should().Within(RenderBudget).Match(c => c is ButtonControl); + loadButton.Should().BeOfType() + .Which.Data!.ToString().Should().Contain("Load", "the primary action loads the sales CSV"); + + // 3. THE POINT: with no py/pandas participant attached, the grid MUST degrade to the + // "No Python pandas node attached" notice WITHIN the budget — not hang, not raw error. + // The grid area resolves to a NoNode() stack whose 'status' markdown carries the notice. + var gridStatus = await stream.GetControlStream($"{ExplorerArea}/grid/status") + .Where(c => c is MarkdownControl) + .Should().Within(RenderBudget).Match(c => + ((MarkdownControl)c!).Markdown?.ToString()? + .Contains("No Python pandas node attached", StringComparison.Ordinal) == true); + + var notice = gridStatus.Should().BeOfType().Subject; + Output.WriteLine($"Grid degraded to: {notice.Markdown}"); + notice.Markdown!.ToString().Should().Contain("No Python pandas node attached", + "with no participant the grid degrades to the informative no-node notice — it must never hang"); + } +}