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
44 changes: 43 additions & 1 deletion src/MeshWeaver.Graph/DeckLayoutAreas.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -276,12 +278,52 @@ private static ImmutableList<string> 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);

/// <summary>
/// The deck's DEFAULT slide set when it carries no explicit <see cref="DeckContent.Slides"/>
/// manifest: the live result of <c>nodeType:Slide scope:subtree</c> under the deck path, ordered
/// by <see cref="MeshNode.Order"/> (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.
/// </summary>
private static IObservable<IReadOnlyList<(string Path, MeshNode? Node)>> ObserveDefaultSubtreeSlides(
LayoutAreaHost host, string deckPath)
{
var meshService = host.Hub.ServiceProvider.GetService<IMeshService>();
if (meshService is null)
return Observable.Return<IReadOnlyList<(string, MeshNode?)>>(
ImmutableList<(string, MeshNode?)>.Empty);

return meshService
.Query<MeshNode>(MeshQueryRequest.FromQuery(
$"path:{deckPath} nodeType:{SlideNodeType.NodeType} scope:subtree"))
.Scan(ImmutableDictionary<string, MeshNode>.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());
}

/// <summary>One resolved slide stream: its path paired with the live node (null until it loads / if missing).</summary>
private static Func<string, IObservable<(string Path, MeshNode? Node)>> ObserveSlide(LayoutAreaHost host)
=> path => host.Workspace.GetMeshNodeStream(path)
Expand Down
33 changes: 33 additions & 0 deletions src/MeshWeaver.Mesh.Contract/Query/QueryParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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] };
Comment on lines +32 to +33

var tokens = Tokenize(query);
var (filterTokens, textSearch, path, scope, orderBy, limit, source, select, context, isMain, paths) = ExtractReservedQualifiers(tokens);

Expand All @@ -36,6 +46,29 @@ public ParsedQuery Parse(string? query)
return new ParsedQuery(filter, textSearch, path, scope, orderBy, limit, source, select, context, isMain, paths);
}

/// <summary>
/// Recognizes a whole-query explicit path list <c>[a, b, c]</c> and returns its ordered,
/// trimmed, non-blank entries. Returns <c>false</c> (and null) when the query is not a
/// bracket list or contains no entries.
/// </summary>
private static bool TryParseBracketPathList(string query, out IReadOnlyList<string> paths)
{
paths = System.Array.Empty<string>();
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;
}

/// <summary>
/// Tokenizes the input query into a list of tokens.
/// </summary>
Expand Down
31 changes: 31 additions & 0 deletions test/MeshWeaver.Hosting.Monolith.Test/DeckLayoutAreaTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,37 @@ await AssertDeckPresentSlide(workspace, deck, index: 1,
await NodeFactory.DeleteNode(space).Should().Emit();
}

/// <summary>
/// A deck with NO explicit manifest (empty <see cref="DeckContent.Slides"/>) must fall back to the
/// DEFAULT query <c>nodeType:Slide scope:subtree</c> — every Slide node under the deck, ordered by
/// <see cref="MeshNode.Order"/>. 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.
/// </summary>
[Fact(Timeout = 60000)]
public async Task DeckPresent_NoManifest_DefaultsToSubtreeSlideQuery_InOrder()
{
var space = await CreateSpaceNode();
var deck = $"{space}/auto";
await CreateDeckNode(deck, "Auto Deck", ImmutableList<string>.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();
}

/// <summary>
/// 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
Expand Down
23 changes: 23 additions & 0 deletions test/MeshWeaver.Query.Test/QueryParserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading