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
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// 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.Starters;
using Cratis.Scene.Model.Profiles;
using Cratis.Scene.Model.Starters;

namespace Cratis.Scene.Engine.for_StarterProfileBuilder;

public class when_checking_against_the_shared_fixture_corpus : Specification
{
record FixtureCase(string Name, UiStarter Starter, string TargetPlatform, UiProfile ExpectedProfile);

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

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

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

void Because() => _results = [.. _cases.Select(fixtureCase => (fixtureCase, StarterProfileBuilder.BuildProfile(fixtureCase.Starter, fixtureCase.TargetPlatform)))];

[Fact]
void should_match_the_expected_name_for_every_case()
{
foreach (var (fixtureCase, actual) in _results)
{
(fixtureCase.Name, actual.Name).ShouldEqual((fixtureCase.Name, fixtureCase.ExpectedProfile.Name));
}
}

[Fact]
void should_match_the_expected_target_platform_for_every_case()
{
foreach (var (fixtureCase, actual) in _results)
{
(fixtureCase.Name, actual.TargetPlatform).ShouldEqual((fixtureCase.Name, fixtureCase.ExpectedProfile.TargetPlatform));
}
}

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

static string Flatten(IReadOnlyList<string> packages) => string.Join(',', packages);

static FixtureCase ToFixtureCase(JsonElement element)
{
var name = element.GetProperty("name").GetString()!;

var starterElement = element.GetProperty("starter");
var starter = new UiStarter(
starterElement.GetProperty("name").GetString()!,
starterElement.GetProperty("packages").EnumerateArray().Select(value => value.GetString()!).ToList(),
starterElement.GetProperty("themes").EnumerateArray().Select(value => value.GetString()!).ToList(),
starterElement.GetProperty("gallery").EnumerateArray().Select(value => value.GetString()!).ToList());

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

var expectedProfileElement = element.GetProperty("expectedProfile");
var expectedProfile = new UiProfile(
expectedProfileElement.GetProperty("name").GetString()!,
expectedProfileElement.GetProperty("targetPlatform").GetString()!,
expectedProfileElement.GetProperty("packages").EnumerateArray().Select(value => value.GetString()!).ToList());

return new(name, starter, targetPlatform, expectedProfile);
}

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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// 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.Starters;
using Cratis.Scene.Model.Profiles;
using Cratis.Scene.Model.Starters;

namespace Cratis.Scene.Engine.for_StarterThemeValidation;

public class when_checking_against_the_shared_fixture_corpus : Specification
{
record FixtureCase(string Name, UiStarter Starter, Dictionary<string, Theme> Themes, IReadOnlyList<string> ExpectedIncompatible);

List<FixtureCase> _cases = null!;
List<(FixtureCase Case, IReadOnlyList<string> Incompatible)> _results = null!;

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

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

void Because() => _results = [.. _cases.Select(fixtureCase => (fixtureCase, StarterThemeValidation.IncompatibleThemes(fixtureCase.Starter, fixtureCase.Themes)))];

[Fact]
void should_match_the_expected_incompatible_themes_for_every_case()
{
foreach (var (fixtureCase, incompatible) in _results)
{
(fixtureCase.Name, Flatten(incompatible)).ShouldEqual((fixtureCase.Name, Flatten(fixtureCase.ExpectedIncompatible)));
}
}

static string Flatten(IReadOnlyList<string> themes) => string.Join(',', themes);

static FixtureCase ToFixtureCase(JsonElement element)
{
var name = element.GetProperty("name").GetString()!;

var starterElement = element.GetProperty("starter");
var starter = new UiStarter(
starterElement.GetProperty("name").GetString()!,
starterElement.GetProperty("packages").EnumerateArray().Select(value => value.GetString()!).ToList(),
starterElement.GetProperty("themes").EnumerateArray().Select(value => value.GetString()!).ToList(),
starterElement.GetProperty("gallery").EnumerateArray().Select(value => value.GetString()!).ToList());

var themes = element.GetProperty("themes").EnumerateObject()
.ToDictionary(
property => property.Name,
property => new Theme(property.Name, property.Value.GetProperty("compatibleWith").EnumerateArray().Select(value => value.GetString()!).ToList()));

var expectedIncompatible = element.GetProperty("expectedIncompatible").EnumerateArray().Select(value => value.GetString()!).ToList();

return new(name, starter, themes, expectedIncompatible);
}

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);
}
}
27 changes: 27 additions & 0 deletions Source/DotNET/Engine/Starters/StarterProfileBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// 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;
using Cratis.Scene.Model.SizeClasses;
using Cratis.Scene.Model.Starters;

namespace Cratis.Scene.Engine.Starters;

/// <summary>
/// Builds the sandboxed <see cref="UiProfile"/> a <see cref="UiStarter"/>'s gallery boots through - part of
/// Cratis/Scene#6. Studio runs a starter's gallery screens as a working mini-app rather than a simulated
/// preview, so the gallery needs a real profile scoped to exactly the starter's own package list, not the
/// consuming project's eventual profile.
/// </summary>
public static class StarterProfileBuilder
{
/// <summary>
/// Builds the sandboxed <see cref="UiProfile"/> for a <see cref="UiStarter"/>'s gallery.
/// </summary>
/// <param name="starter">The <see cref="UiStarter"/> to build the profile for.</param>
/// <param name="targetPlatform">The platform the gallery runs on (e.g. <c>web</c>).</param>
/// <param name="defaultSizeClass">The size class assumed when the renderer cannot otherwise determine one.</param>
/// <returns>A <see cref="UiProfile"/> named after the starter, scoped to exactly its own <see cref="UiStarter.Packages"/>.</returns>
public static UiProfile BuildProfile(UiStarter starter, string targetPlatform, SizeClass? defaultSizeClass = null) =>
new(starter.Name, targetPlatform, starter.Packages, defaultSizeClass);
}
33 changes: 33 additions & 0 deletions Source/DotNET/Engine/Starters/StarterThemeValidation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// 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.Engine.Profiles;
using Cratis.Scene.Model.Profiles;
using Cratis.Scene.Model.Starters;

namespace Cratis.Scene.Engine.Starters;

/// <summary>
/// Checks a <see cref="UiStarter"/>'s declared <see cref="UiStarter.Themes"/> against its own
/// <see cref="UiStarter.Packages"/> - part of Cratis/Scene#6. A starter is versioned per package
/// combination it targets, so a theme it ships as a choice that turns out incompatible with the starter's
/// own packages is exactly the gap <see cref="ThemeCompatibility"/> already surfaces for a
/// <see cref="UiProfile"/>; this reuses that rule rather than reimplementing it for starters.
/// </summary>
public static class StarterThemeValidation
{
/// <summary>
/// Finds the <see cref="UiStarter.Themes"/> that are not compatible with the starter's own
/// <see cref="UiStarter.Packages"/>.
/// </summary>
/// <param name="starter">The <see cref="UiStarter"/> to check.</param>
/// <param name="themes">Every known <see cref="Theme"/>, keyed by name.</param>
/// <returns>The names in <see cref="UiStarter.Themes"/> that are either unknown to <paramref name="themes"/> or incompatible with <see cref="UiStarter.Packages"/>.</returns>
public static IReadOnlyList<string> IncompatibleThemes(UiStarter starter, IReadOnlyDictionary<string, Theme> themes)
{
var profile = new UiProfile(starter.Name, string.Empty, starter.Packages);
return starter.Themes
.Where(name => !themes.TryGetValue(name, out var theme) || !ThemeCompatibility.IsCompatible(theme, profile))
.ToList();
}
}
17 changes: 17 additions & 0 deletions Source/DotNET/Model/Starters/UiStarter.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.Model.Starters;

/// <summary>
/// A packaged, versioned UI starting point: a package list, the themes it ships compatible with, and a
/// gallery of ordinary screens shipped alongside it - part of Cratis/Scene#6. A starter is data, not a
/// <c>.play</c> language construct; Studio's "new project" flow scaffolds from it, and its gallery boots
/// through the real <c>Scene.Engine</c> + <c>Scene.React</c> inside a sandboxed <see cref="Profiles.UiProfile"/>
/// built from <see cref="Packages"/> - there is no separate preview pipeline and no mocked screens.
/// </summary>
/// <param name="Name">The starter's name.</param>
/// <param name="Packages">The component packages this starter bundles, in the same override-priority order a <see cref="Profiles.UiProfile"/> declares them.</param>
/// <param name="Themes">The names of the themes this starter ships as compatible choices.</param>
/// <param name="Gallery">The names of the ordinary <see cref="Screens.Screen"/>s shipped as this starter's sample gallery.</param>
public record UiStarter(string Name, IReadOnlyList<string> Packages, IReadOnlyList<string> Themes, IReadOnlyList<string> Gallery);
21 changes: 21 additions & 0 deletions Source/JavaScript/engine/buildStarterProfile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { SizeClass, UiProfile, UiStarter } from '@cratis/scene.model';

/**
* Builds the sandboxed {@link UiProfile} a {@link UiStarter}'s gallery boots through - part of
* Cratis/Scene#6. Studio runs a starter's gallery screens as a working mini-app rather than a simulated
* preview, so the gallery needs a real profile scoped to exactly the starter's own package list, not the
* consuming project's eventual profile. Studio's design-time tooling and Stage's build-time resolution
* use the C# twin of this function in `Cratis.Scene.Engine`; both sides are asserted against the same
* shared fixture corpus so they cannot drift apart.
*
* @param starter The {@link UiStarter} to build the profile for.
* @param targetPlatform The platform the gallery runs on (e.g. `web`).
* @param defaultSizeClass The size class assumed when the renderer cannot otherwise determine one.
* @returns A {@link UiProfile} named after the starter, scoped to exactly its own `packages`.
*/
export function buildStarterProfile(starter: UiStarter, targetPlatform: string, defaultSizeClass?: SizeClass): UiProfile {
return { name: starter.name, targetPlatform, packages: starter.packages, defaultSizeClass };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// 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 { UiStarter } from '@cratis/scene.model';
import { buildStarterProfile } from '../index';

interface FixtureCase {
name: string;
starter: UiStarter;
targetPlatform: string;
expectedProfile: { name: string; targetPlatform: string; packages: string[] };
}

interface FixtureCorpus {
profileCases: FixtureCase[];
}

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

describe('when checking against the shared fixture corpus', () => {
for (const fixtureCase of corpus.profileCases) {
it(`should build the expected profile for "${fixtureCase.name}"`, () => {
const profile = buildStarterProfile(fixtureCase.starter, fixtureCase.targetPlatform);
profile.name.should.equal(fixtureCase.expectedProfile.name);
profile.targetPlatform.should.equal(fixtureCase.expectedProfile.targetPlatform);
profile.packages.should.deep.equal(fixtureCase.expectedProfile.packages);
});
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// 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 { Theme, UiStarter } from '@cratis/scene.model';
import { incompatibleStarterThemes } from '../index';

interface FixtureCase {
name: string;
starter: UiStarter;
themes: Record<string, Theme>;
expectedIncompatible: string[];
}

interface FixtureCorpus {
themeCases: FixtureCase[];
}

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

describe('when checking against the shared fixture corpus', () => {
for (const fixtureCase of corpus.themeCases) {
const themes: Record<string, Theme> = {};
for (const [themeName, theme] of Object.entries(fixtureCase.themes)) {
themes[themeName] = { name: themeName, compatibleWith: theme.compatibleWith };
}

it(`should match the expected incompatible themes for "${fixtureCase.name}"`, () => {
incompatibleStarterThemes(fixtureCase.starter, themes).should.deep.equal(fixtureCase.expectedIncompatible);
});
}
});
24 changes: 24 additions & 0 deletions Source/JavaScript/engine/incompatibleStarterThemes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { Theme, UiProfile, UiStarter } from '@cratis/scene.model';
import { incompatiblePackages } from './themeCompatibility';

/**
* Finds the {@link UiStarter.themes} that are not compatible with the starter's own {@link UiStarter.packages} -
* part of Cratis/Scene#6. A starter is versioned per package combination it targets, so a theme it ships
* as a choice that turns out incompatible with the starter's own packages is exactly the gap
* `themeCompatibility` already surfaces for a {@link UiProfile}; this reuses that rule rather than
* reimplementing it for starters.
*
* @param starter The {@link UiStarter} to check.
* @param themes Every known {@link Theme}, keyed by name.
* @returns The names in `starter.themes` that are either unknown to `themes` or incompatible with `starter.packages`.
*/
export function incompatibleStarterThemes(starter: UiStarter, themes: Record<string, Theme>): string[] {
const profile: UiProfile = { name: starter.name, targetPlatform: '', packages: starter.packages };
return starter.themes.filter(name => {
const theme = themes[name];
return !theme || incompatiblePackages(theme, profile).length > 0;
});
}
2 changes: 2 additions & 0 deletions Source/JavaScript/engine/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ export * from './evaluateFlowArrangement';
export * from './evaluateFreeformArrangement';
export * from './aggregateContributions';
export * from './themeCompatibility';
export * from './buildStarterProfile';
export * from './incompatibleStarterThemes';
1 change: 1 addition & 0 deletions Source/JavaScript/model/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export * from './forms';
export * from './contributionPoints';
export * from './profiles';
export * from './screens';
export * from './starters';
18 changes: 18 additions & 0 deletions Source/JavaScript/model/starters/UiStarter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

/**
* A packaged, versioned UI starting point: a package list, the themes it ships compatible with, and a
* gallery of ordinary screens shipped alongside it - part of Cratis/Scene#6. A starter is data, not a
* `.play` language construct; Studio's "new project" flow scaffolds from it, and its gallery boots
* through the real `Scene.Engine` + `Scene.React` inside a sandboxed `UiProfile` built from `packages` -
* there is no separate preview pipeline and no mocked screens.
*/
export interface UiStarter {
name: string;
packages: string[];
themes: string[];
gallery: string[];
}

export const UiStarterPropertyNames: (keyof UiStarter)[] = ['name', 'packages', 'themes', 'gallery'];
4 changes: 4 additions & 0 deletions Source/JavaScript/model/starters/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

export * from './UiStarter';
Loading
Loading