diff --git a/src/MeshWeaver.Graph/DeckLayoutAreas.cs b/src/MeshWeaver.Graph/DeckLayoutAreas.cs index 509d3f9cc1..4f916ac398 100644 --- a/src/MeshWeaver.Graph/DeckLayoutAreas.cs +++ b/src/MeshWeaver.Graph/DeckLayoutAreas.cs @@ -5,7 +5,9 @@ using MeshWeaver.Layout; using MeshWeaver.Layout.Composition; using MeshWeaver.Mesh; +using MeshWeaver.Mesh.Services; using MeshWeaver.Messaging; +using Microsoft.Extensions.DependencyInjection; namespace MeshWeaver.Graph; @@ -276,12 +278,52 @@ private static ImmutableList ResolveSlidePaths( .Select(node => ResolveSlidePaths(host, deckPath, node)) .DistinctUntilChanged(paths => string.Join("\n", paths)) .Select(paths => paths.Count == 0 - ? Observable.Return((IReadOnlyList<(string, MeshNode?)>)ImmutableList<(string, MeshNode?)>.Empty) + // No explicit manifest → DEFAULT query: every Slide node under the deck, ordered by + // Order. A deck whose slides are simply its children needs no hardcoded array (the + // reported "deck has no slides yet" despite child slides existing). + ? ObserveDefaultSubtreeSlides(host, deckPath) : Observable.CombineLatest(paths.Select(ObserveSlide(host))) .Select(items => (IReadOnlyList<(string, MeshNode?)>)items.ToImmutableList())) .Switch() .StartWith((IReadOnlyList<(string, MeshNode?)>)ImmutableList<(string, MeshNode?)>.Empty); + /// + /// The deck's DEFAULT slide set when it carries no explicit + /// manifest: the live result of nodeType:Slide scope:subtree under the deck path, ordered + /// by (null last), ties by path. This is what lets a deck be just "a + /// folder of slides" — presentation order falls out of the nodes' own Order, no hardcoded array. + /// + private static IObservable> ObserveDefaultSubtreeSlides( + LayoutAreaHost host, string deckPath) + { + var meshService = host.Hub.ServiceProvider.GetService(); + if (meshService is null) + return Observable.Return>( + ImmutableList<(string, MeshNode?)>.Empty); + + return meshService + .Query(MeshQueryRequest.FromQuery( + $"path:{deckPath} nodeType:{SlideNodeType.NodeType} scope:subtree")) + .Scan(ImmutableDictionary.Empty, (map, change) => + { + if (change.ChangeType is QueryChangeType.Initial or QueryChangeType.Reset) + return change.Items.ToImmutableDictionary(n => n.Path); + foreach (var item in change.Items) + map = change.ChangeType switch + { + QueryChangeType.Added or QueryChangeType.Updated => map.SetItem(item.Path, item), + QueryChangeType.Removed => map.Remove(item.Path), + _ => map + }; + return map; + }) + .Select(map => (IReadOnlyList<(string, MeshNode?)>)map.Values + .OrderBy(n => n.Order ?? int.MaxValue) + .ThenBy(n => n.Path, StringComparer.Ordinal) + .Select(n => (n.Path, (MeshNode?)n)) + .ToImmutableList()); + } + /// One resolved slide stream: its path paired with the live node (null until it loads / if missing). private static Func> ObserveSlide(LayoutAreaHost host) => path => host.Workspace.GetMeshNodeStream(path) diff --git a/src/MeshWeaver.Mesh.Contract/Query/QueryParser.cs b/src/MeshWeaver.Mesh.Contract/Query/QueryParser.cs index f77e55465a..5fd84cbb50 100644 --- a/src/MeshWeaver.Mesh.Contract/Query/QueryParser.cs +++ b/src/MeshWeaver.Mesh.Contract/Query/QueryParser.cs @@ -22,6 +22,16 @@ public ParsedQuery Parse(string? query) if (string.IsNullOrWhiteSpace(query)) return ParsedQuery.Empty; + // Explicit ORDERED path list: `[a, b, c]`. The "alternatively, specify the exact nodes + // in order" surface (a Deck's explicit slide manifest, vs. the default + // `nodeType:Slide scope:subtree`). It populates the SAME Paths list as `path:a|b|c` — so + // backends push it down as `WHERE path IN (...)` — but ORDER-PRESERVING, so an + // order-sensitive consumer (deck slide sequence) can present in the listed order. Entries + // are trimmed; blanks dropped. A bare bracket list is a WHOLE-query form (not composed + // with other qualifiers). + if (TryParseBracketPathList(query, out var listPaths)) + return ParsedQuery.Empty with { Paths = listPaths, Path = listPaths[0] }; + var tokens = Tokenize(query); var (filterTokens, textSearch, path, scope, orderBy, limit, source, select, context, isMain, paths) = ExtractReservedQualifiers(tokens); @@ -36,6 +46,29 @@ public ParsedQuery Parse(string? query) return new ParsedQuery(filter, textSearch, path, scope, orderBy, limit, source, select, context, isMain, paths); } + /// + /// Recognizes a whole-query explicit path list [a, b, c] and returns its ordered, + /// trimmed, non-blank entries. Returns false (and null) when the query is not a + /// bracket list or contains no entries. + /// + private static bool TryParseBracketPathList(string query, out IReadOnlyList paths) + { + paths = System.Array.Empty(); + var trimmed = query.Trim(); + if (trimmed.Length < 2 || trimmed[0] != '[' || trimmed[^1] != ']') + return false; + var inner = trimmed[1..^1]; + var entries = inner + .Split(',') + .Select(s => s.Trim()) + .Where(s => s.Length > 0) + .ToArray(); + if (entries.Length == 0) + return false; + paths = entries; + return true; + } + /// /// Tokenizes the input query into a list of tokens. /// diff --git a/test/MeshWeaver.Hosting.Monolith.Test/DeckLayoutAreaTest.cs b/test/MeshWeaver.Hosting.Monolith.Test/DeckLayoutAreaTest.cs index 4e38a6bf1b..e28da714bb 100644 --- a/test/MeshWeaver.Hosting.Monolith.Test/DeckLayoutAreaTest.cs +++ b/test/MeshWeaver.Hosting.Monolith.Test/DeckLayoutAreaTest.cs @@ -201,6 +201,37 @@ await AssertDeckPresentSlide(workspace, deck, index: 1, await NodeFactory.DeleteNode(space).Should().Emit(); } + /// + /// A deck with NO explicit manifest (empty ) must fall back to the + /// DEFAULT query nodeType:Slide scope:subtree — every Slide node under the deck, ordered by + /// . Without this, a deck whose slides live as children but that carries + /// no hardcoded array renders "no slides yet" even though its slides exist (the reported bug). Here + /// two child slides (Order 1, 2) must present in Order despite no manifest. + /// + [Fact(Timeout = 60000)] + public async Task DeckPresent_NoManifest_DefaultsToSubtreeSlideQuery_InOrder() + { + var space = await CreateSpaceNode(); + var deck = $"{space}/auto"; + await CreateDeckNode(deck, "Auto Deck", ImmutableList.Empty); // NO manifest + + // Two slides live as children of the deck; created out of order, Order rules. + await CreateSlide($"{deck}/second", "Second", 2, "# Second\n\nthe-second-body"); + await CreateSlide($"{deck}/first", "First", 1, "# First\n\nthe-first-body"); + + var workspace = GetClient(c => c.AddData()).GetWorkspace(); + + // i=0 → lowest-Order subtree slide = First; next drives to the second. + await AssertDeckPresentSlide(workspace, deck, index: 0, + expectBody: "the-first-body", expectCounter: "Slide 1 / 2", + expectPrev: null, expectNext: $"/{deck}/Present?i=1"); + await AssertDeckPresentSlide(workspace, deck, index: 1, + expectBody: "the-second-body", expectCounter: "Slide 2 / 2", + expectPrev: $"/{deck}/Present?i=0", expectNext: null); + + await NodeFactory.DeleteNode(space).Should().Emit(); + } + /// /// The SAME slide path, referenced by TWO decks in OPPOSITE orders, presents in each deck's own /// order — proving presentation order lives on the deck, not the slide. Deck1 = [X, Y] shows X diff --git a/test/MeshWeaver.Query.Test/QueryParserTests.cs b/test/MeshWeaver.Query.Test/QueryParserTests.cs index 0559b3a4fb..07418a3c9e 100644 --- a/test/MeshWeaver.Query.Test/QueryParserTests.cs +++ b/test/MeshWeaver.Query.Test/QueryParserTests.cs @@ -184,6 +184,29 @@ public void Parse_SinglePath_LeavesPathsNull() result.Paths.Should().BeNull(); } + [Fact] + public void Parse_BracketList_PopulatesOrderedPaths() + { + // The [a, b, c] surface is an EXPLICIT, ORDERED list of node paths — the + // "alternatively specify the exact slides" form for a Deck's manifest. It + // populates the SAME Paths list as path:a|b|c (so backends push it down as + // WHERE path IN (...)), but the ORDER is preserved in Paths for order-sensitive + // consumers (a deck presents its slides in the listed order). + var result = _parser.Parse("[foo/bar/baz, foo/bar, foo]"); + + result.Paths.Should().Equal("foo/bar/baz", "foo/bar", "foo"); + result.Path.Should().Be("foo/bar/baz"); + } + + [Fact] + public void Parse_BracketList_SingleEntry() + { + var result = _parser.Parse("[only/one]"); + + result.Paths.Should().Equal("only/one"); + result.Path.Should().Be("only/one"); + } + // ─── SQL-function selectors in sort: ─── // `sort:length(path)-desc` — function-call syntax in the sort selector lets // backends emit `ORDER BY length(n.path) DESC` without hardcoding sort