From 4c53df7092291ae3f8bca651ecbe456bc782b9cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Tue, 14 Jul 2026 08:10:54 +0200 Subject: [PATCH] test: render-safety test for the Python pandas sample's Explorer area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds PandasExplorerLayoutAreaTest to MeshWeaver.Hosting.Monolith.Test. It renders the REAL live-compiled Doc/DataMesh/PythonPandasNode/PandasExplorer NodeType (Source ships as embedded resources served by AddDocumentation, so the mesh compiles the actual production frontend with Roslyn) and its LiveFrame instance's Explorer area, mirroring CessionLayoutAreaTest. Verifies the production render-safety contract when NO py/pandas participant is attached (the default in prod/CI): 1. Explorer renders a StackControl composing title + intro + toolbar + grid. 2. The toolbar resolves to a Stack of real ButtonControls (load, append, groupby, rolling, describe, refresh, reset). 3. THE POINT: the reactive grid DEGRADES to the "No Python pandas node attached" notice within the backend timeout — it never hangs and never shows a raw error. Every assertion runs under a hard wall-clock budget, so a genuine hang trips the bound and fails the test instead of blocking CI (the exact production hazard guarded). The Python participant (clients/python/.../pandas_node.py) is already covered by clients/python/tests/test_pandas_node.py (15 tests: load/append/reset/ render/groupby/rolling/describe, the CSV-from-content path, the code surface, and the end-to-end demo), so no new Python test is added. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../PandasExplorerLayoutAreaTest.cs | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 test/MeshWeaver.Hosting.Monolith.Test/PandasExplorerLayoutAreaTest.cs 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"); + } +}