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
2 changes: 2 additions & 0 deletions Scene.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,7 @@
<Folder Name="/Source/DotNET/">
<Project Path="Source/DotNET/Model/Model.csproj" />
<Project Path="Source/DotNET/Model.Specs/Model.Specs.csproj" />
<Project Path="Source/DotNET/Engine/Engine.csproj" />
<Project Path="Source/DotNET/Engine.Specs/Engine.Specs.csproj" />
</Folder>
</Solution>
12 changes: 12 additions & 0 deletions Source/DotNET/Engine.Specs/Engine.Specs.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>Cratis.Scene.Engine.Specs</AssemblyName>
<RootNamespace>Cratis.Scene.Engine</RootNamespace>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../Engine/Engine.csproj" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Text.Json;
using Cratis.Scene.Engine.Profiles;
using Cratis.Scene.Model.Profiles;

namespace Cratis.Scene.Engine.for_PackageResolver;

public class when_resolving_against_the_shared_fixture_corpus : Specification
{
record FixtureCase(string Name, UiProfile Profile, IReadOnlyDictionary<string, IReadOnlyList<string>> Catalog, string RequestedName, ComponentResolution? Expected);

List<FixtureCase> _cases = null!;
List<(FixtureCase Case, ComponentResolution? Actual)> _results = null!;

void Establish()
{
var manifestPath = Path.Combine(FindRepositoryRoot(), "package-resolution-fixtures.json");
using var document = JsonDocument.Parse(File.ReadAllText(manifestPath));

_cases = document.RootElement.GetProperty("cases").EnumerateArray().Select(ToFixtureCase).ToList();
}

void Because() => _results = [.. _cases.Select(fixtureCase => (fixtureCase, PackageResolver.Resolve(fixtureCase.RequestedName, fixtureCase.Profile, fixtureCase.Catalog)))];

[Fact]
void should_match_the_expected_resolution_for_every_case()
{
foreach (var (fixtureCase, actual) in _results)
{
(fixtureCase.Name, Flatten(actual)).ShouldEqual((fixtureCase.Name, Flatten(fixtureCase.Expected)));
}
}

static (string Name, string Package, string Shadows)? Flatten(ComponentResolution? resolution) =>
resolution is null ? null : (resolution.Name, resolution.Package, string.Join(',', resolution.Shadows));

static FixtureCase ToFixtureCase(JsonElement element)
{
var name = element.GetProperty("name").GetString()!;
var profile = new UiProfile(
"test",
"web",
element.GetProperty("profile").GetProperty("packages").EnumerateArray().Select(value => value.GetString()!).ToList());

var catalog = element.GetProperty("catalog").EnumerateObject()
.ToDictionary(
property => property.Name,
IReadOnlyList<string> (property) => property.Value.EnumerateArray().Select(value => value.GetString()!).ToList());

var requestedName = element.GetProperty("requestedName").GetString()!;

var expectedElement = element.GetProperty("expected");
var expected = expectedElement.ValueKind == JsonValueKind.Null
? null
: new ComponentResolution(
expectedElement.GetProperty("name").GetString()!,
expectedElement.GetProperty("package").GetString()!,
expectedElement.GetProperty("shadows").EnumerateArray().Select(value => value.GetString()!).ToList());

return new(name, profile, catalog, requestedName, expected);
}

static string FindRepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Scene.slnx")))
{
directory = directory.Parent;
}

return directory?.FullName ?? throw new DirectoryNotFoundException("Could not locate the repository root (Scene.slnx) above " + AppContext.BaseDirectory);
}
}
11 changes: 11 additions & 0 deletions Source/DotNET/Engine/Engine.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>Cratis.Scene.Engine</AssemblyName>
<RootNamespace>Cratis.Scene.Engine</RootNamespace>
<Description>Platform-agnostic runtime and design-time resolution logic for Scene: package/component name resolution, layout arrangement evaluation, contribution aggregation.</Description>
<PackageTags>cratis;scene;screenplay;ui</PackageTags>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../Model/Model.csproj" />
</ItemGroup>
</Project>
17 changes: 17 additions & 0 deletions Source/DotNET/Engine/Profiles/ComponentResolution.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Cratis.Scene.Engine.Profiles;

/// <summary>
/// The outcome of resolving a bare or package-qualified component name against a <see cref="Model.Profiles.UiProfile"/>'s package list.
/// </summary>
/// <param name="Name">The bare component name within <see cref="Package"/>.</param>
/// <param name="Package">The package the name resolved to.</param>
/// <param name="Shadows">
/// Other active packages, in descending priority order, that also declare this name but were shadowed by
/// <see cref="Package"/> - this is what answers "why did this resolve to X and not Y". Always empty for a
/// package-qualified reference, which resolves directly against its named package and never runs shadow
/// tracking.
/// </param>
public record ComponentResolution(string Name, string Package, IReadOnlyList<string> Shadows);
79 changes: 79 additions & 0 deletions Source/DotNET/Engine/Profiles/PackageResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Cratis.Scene.Model.Profiles;

namespace Cratis.Scene.Engine.Profiles;

/// <summary>
/// Resolves a bare or package-qualified component name against a <see cref="UiProfile"/>'s package list -
/// the design-time half of Cratis/Scene#3, run by Studio's tooling. Stage's build-time resolution and
/// Scene.React's runtime resolution use the TypeScript twin of this algorithm in <c>@cratis/scene.engine</c>;
/// both sides are asserted against the same shared fixture corpus so they cannot drift apart.
/// </summary>
public static class PackageResolver
{
/// <summary>
/// The name of the package that is always present as the final fallback, regardless of what a
/// <see cref="UiProfile"/> lists in its own <see cref="UiProfile.Packages"/>.
/// </summary>
public const string Core = "core";

/// <summary>
/// Computes a <see cref="UiProfile"/>'s effective package priority order - its own declared packages,
/// with <see cref="Core"/> prepended as the final fallback when not already present.
/// </summary>
/// <param name="profile">The <see cref="UiProfile"/> to compute the order for.</param>
/// <returns>The packages in ascending priority order - the last entry wins when more than one declares the same name.</returns>
/// <remarks>
/// <see cref="Core"/> is the lowest-priority fallback, so it belongs at the FRONT of the ascending-priority
/// order, not the back - prepending it (rather than appending) is what makes every explicitly listed
/// package outrank it.
/// </remarks>
public static IReadOnlyList<string> EffectivePackages(UiProfile profile) =>
profile.Packages.Contains(Core) ? profile.Packages : [Core, .. profile.Packages];

/// <summary>
/// Resolves a component name against a <see cref="UiProfile"/>.
/// </summary>
/// <param name="requestedName">The name as written on a screen - bare (<c>button</c>) or package-qualified (<c>Internal.Widgets.TrendChart</c>).</param>
/// <param name="profile">The <see cref="UiProfile"/> whose package list to resolve against.</param>
/// <param name="catalog">Every active package's declared component names, keyed by package name.</param>
/// <returns>The <see cref="ComponentResolution"/>, or <see langword="null"/> when nothing in scope declares the name.</returns>
/// <remarks>
/// A name containing a <c>.</c> is package-qualified - everything before the last <c>.</c> is the package,
/// everything after is the bare name - and resolves directly against that one package, bypassing shadow
/// tracking and the profile's priority order entirely (an author naming the package explicitly has
/// already disambiguated). A name with no <c>.</c> is bare and resolves by walking
/// <see cref="EffectivePackages"/> from highest to lowest priority; every other active package that also
/// declares the name is recorded in <see cref="ComponentResolution.Shadows"/>, not discarded, so a caller
/// can explain the pick rather than only report it.
/// </remarks>
public static ComponentResolution? Resolve(string requestedName, UiProfile profile, IReadOnlyDictionary<string, IReadOnlyList<string>> catalog)
{
var lastDot = requestedName.LastIndexOf('.');
if (lastDot >= 0)
{
var qualifiedPackage = requestedName[..lastDot];
var qualifiedName = requestedName[(lastDot + 1)..];
return Declares(catalog, qualifiedPackage, qualifiedName)
? new ComponentResolution(qualifiedName, qualifiedPackage, [])
: null;
}

var priority = EffectivePackages(profile);
var matches = new List<string>();
for (var index = priority.Count - 1; index >= 0; index--)
{
if (Declares(catalog, priority[index], requestedName))
{
matches.Add(priority[index]);
}
}

return matches.Count == 0 ? null : new ComponentResolution(requestedName, matches[0], matches.Skip(1).ToList());
}

static bool Declares(IReadOnlyDictionary<string, IReadOnlyList<string>> catalog, string package, string name) =>
catalog.TryGetValue(package, out var components) && components.Contains(name);
}
22 changes: 22 additions & 0 deletions Source/JavaScript/engine/ComponentResolution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

/**
* The outcome of resolving a bare or package-qualified component name against a {@link UiProfile}'s
* package list.
*/
export interface ComponentResolution {
/** The bare component name within {@link package}. */
name: string;

/** The package the name resolved to. */
package: string;

/**
* Other active packages, in descending priority order, that also declare this name but were
* shadowed by {@link package} - this is what answers "why did this resolve to X and not Y". Always
* empty for a package-qualified reference, which resolves directly against its named package and
* never runs shadow tracking.
*/
shadows: string[];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { UiProfile } from '@cratis/scene.model';
import { ComponentResolution, PackageCatalog, resolveComponentName } from '../index';

interface FixtureCase {
name: string;
profile: { packages: string[] };
catalog: PackageCatalog;
requestedName: string;
expected: ComponentResolution | null;
}

interface FixtureCorpus {
cases: FixtureCase[];
}

const manifestPath = join(import.meta.dirname, '..', '..', '..', '..', 'package-resolution-fixtures.json');
const corpus = JSON.parse(readFileSync(manifestPath, 'utf-8')) as FixtureCorpus;

describe('when resolving against the shared fixture corpus', () => {
for (const fixtureCase of corpus.cases) {
it(`should match the expected resolution for "${fixtureCase.name}"`, () => {
const profile: UiProfile = { name: 'test', targetPlatform: 'web', packages: fixtureCase.profile.packages };
const actual = resolveComponentName(fixtureCase.requestedName, profile, fixtureCase.catalog);
if (fixtureCase.expected === null) {
(actual === undefined).should.be.true;
} else {
actual!.should.deep.equal(fixtureCase.expected);
}
});
}
});
2 changes: 2 additions & 0 deletions Source/JavaScript/engine/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ export * from './Renderer';
export * from './BindingResolver';
export * from './renderElement';
export * from './elementKind';
export * from './ComponentResolution';
export * from './resolveComponentName';
66 changes: 66 additions & 0 deletions Source/JavaScript/engine/resolveComponentName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { UiProfile } from '@cratis/scene.model';
import { ComponentResolution } from './ComponentResolution';

/** The name of the package that is always present as the final fallback, regardless of what a {@link UiProfile} lists in its own `packages`. */
export const corePackage = 'core';

/** Every active package's declared component names, keyed by package name. */
export type PackageCatalog = Record<string, string[]>;

/**
* Computes a {@link UiProfile}'s effective package priority order - its own declared packages, with
* {@link corePackage} prepended as the final fallback when not already present. {@link corePackage} is
* the lowest-priority fallback, so it belongs at the front of the ascending-priority order, not the
* back - prepending it (rather than appending) is what makes every explicitly listed package outrank it.
*
* @returns The packages in ascending priority order - the last entry wins when more than one declares the same name.
*/
export function effectivePackages(profile: UiProfile): string[] {
return profile.packages.includes(corePackage) ? profile.packages : [corePackage, ...profile.packages];
}

/**
* Resolves a component name against a {@link UiProfile} - the runtime half of Cratis/Scene#3, run
* whenever a profile is applied. Studio's design-time tooling and Stage's build-time resolution use the
* C# twin of this algorithm in `Cratis.Scene.Engine`; both sides are asserted against the same shared
* fixture corpus so they cannot drift apart.
*
* A name containing a `.` is package-qualified - everything before the last `.` is the package,
* everything after is the bare name - and resolves directly against that one package, bypassing shadow
* tracking and the profile's priority order entirely (an author naming the package explicitly has
* already disambiguated). A name with no `.` is bare and resolves by walking {@link effectivePackages}
* from highest to lowest priority; every other active package that also declares the name is recorded in
* the result's `shadows`, not discarded, so a caller can explain the pick rather than only report it.
*
* @param requestedName The name as written on a screen - bare (`button`) or package-qualified (`Internal.Widgets.TrendChart`).
* @param profile The {@link UiProfile} whose package list to resolve against.
* @param catalog Every active package's declared component names, keyed by package name.
* @returns The {@link ComponentResolution}, or `undefined` when nothing in scope declares the name.
*/
export function resolveComponentName(requestedName: string, profile: UiProfile, catalog: PackageCatalog): ComponentResolution | undefined {
const lastDot = requestedName.lastIndexOf('.');
if (lastDot >= 0) {
const qualifiedPackage = requestedName.slice(0, lastDot);
const qualifiedName = requestedName.slice(lastDot + 1);
return declares(catalog, qualifiedPackage, qualifiedName)
? { name: qualifiedName, package: qualifiedPackage, shadows: [] }
: undefined;
}

const priority = effectivePackages(profile);
const matches: string[] = [];
for (let index = priority.length - 1; index >= 0; index--) {
if (declares(catalog, priority[index], requestedName)) {
matches.push(priority[index]);
}
}

return matches.length === 0 ? undefined : { name: requestedName, package: matches[0], shadows: matches.slice(1) };
}

function declares(catalog: PackageCatalog, packageName: string, name: string): boolean {
return catalog[packageName]?.includes(name) ?? false;
}
8 changes: 5 additions & 3 deletions Source/JavaScript/react/renderer/ComponentRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ export interface RegisteredComponentProps {

/**
* Maps a resolved component name (as `ExternalComponent.componentName` already carries it, post
* `ui profile` package resolution) to the React component that renders it. Real bare-name resolution
* against a profile's package list is Scene#3's job - this registry is keyed by the already-resolved
* name.
* `ui profile` package resolution) to the React component that renders it. Bare-name resolution against a
* profile's package list is `resolveComponentName` in `@cratis/scene.engine` (Cratis/Scene#3) - this
* registry is keyed by the already-resolved name; wiring the two together (resolving an entire element
* tree's component names before it reaches this registry) is Stage#39/StudioIssues#160's job, not this
* registry's.
*/
export type ComponentRegistry = Record<string, ComponentType<RegisteredComponentProps>>;
Loading
Loading