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,79 @@
// 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_ThemeCompatibility;

public class when_checking_against_the_shared_fixture_corpus : Specification
{
record FixtureCase(string Name, Theme Theme, UiProfile Profile, IReadOnlyList<string> ExpectedIncompatible, IReadOnlyList<string> ExpectedApplicable);

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

void Establish()
{
var manifestPath = Path.Combine(FindRepositoryRoot(), "theme-compatibility-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,
ThemeCompatibility.IncompatiblePackages(fixtureCase.Theme, fixtureCase.Profile),
ThemeCompatibility.ApplicablePackages(fixtureCase.Theme, fixtureCase.Profile)))];

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

[Fact]
void should_match_the_expected_applicable_packages_for_every_case()
{
foreach (var (fixtureCase, _, applicable) in _results)
{
(fixtureCase.Name, Flatten(applicable)).ShouldEqual((fixtureCase.Name, Flatten(fixtureCase.ExpectedApplicable)));
}
}

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

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

var theme = new Theme(
"test-theme",
element.GetProperty("theme").GetProperty("compatibleWith").EnumerateArray().Select(value => value.GetString()!).ToList());

var profile = new UiProfile(
"test-profile",
"web",
element.GetProperty("profile").GetProperty("packages").EnumerateArray().Select(value => value.GetString()!).ToList());

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

return new(name, theme, profile, expectedIncompatible, expectedApplicable);
}

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);
}
}
48 changes: 48 additions & 0 deletions Source/DotNET/Engine/Profiles/ThemeCompatibility.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// 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>
/// Checks a <see cref="Theme"/>'s <see cref="Theme.CompatibleWith"/> declaration against a
/// <see cref="UiProfile"/>'s package list - part of Cratis/Scene#5. The same rule Screenplay's compiler
/// already applies to warn on an incompatible <c>ui profile</c>/<c>theme</c> pairing, reused here so
/// Stage (build time) and Studio (design time) don't each reimplement it - and so <c>Scene.React</c> knows
/// which packages to actually apply a theme's tokens to. There is deliberately no implicit exemption for
/// <c>core</c>: a theme wanting broad applicability declares <c>compatible with core</c> itself, exactly
/// like the profile/package resolver has no implicit special case for it either.
/// </summary>
public static class ThemeCompatibility
{
/// <summary>
/// Finds the packages a <see cref="UiProfile"/> declares that a <see cref="Theme"/> is not declared
/// compatible with - the same set Screenplay's compiler warns on for an incompatible pairing.
/// </summary>
/// <param name="theme">The <see cref="Theme"/> to check.</param>
/// <param name="profile">The <see cref="UiProfile"/> whose packages to check against.</param>
/// <returns>The packages in <see cref="UiProfile.Packages"/> that <paramref name="theme"/> does not declare compatibility with.</returns>
public static IReadOnlyList<string> IncompatiblePackages(Theme theme, UiProfile profile) =>
profile.Packages.Where(package => !theme.CompatibleWith.Contains(package)).ToList();

/// <summary>
/// Whether a <see cref="Theme"/> is compatible with every package a <see cref="UiProfile"/> declares.
/// </summary>
/// <param name="theme">The <see cref="Theme"/> to check.</param>
/// <param name="profile">The <see cref="UiProfile"/> whose packages to check against.</param>
/// <returns><see langword="true"/> when <paramref name="theme"/> declares compatibility with every package <paramref name="profile"/> lists.</returns>
public static bool IsCompatible(Theme theme, UiProfile profile) => IncompatiblePackages(theme, profile).Count == 0;

/// <summary>
/// Finds the packages a <see cref="Theme"/>'s tokens actually apply to for a given
/// <see cref="UiProfile"/> - the packages the profile activates that the theme also declares
/// compatibility with. This is what a renderer scopes token application to, rather than applying a
/// theme's tokens globally.
/// </summary>
/// <param name="theme">The <see cref="Theme"/> to apply.</param>
/// <param name="profile">The active <see cref="UiProfile"/>.</param>
/// <returns>The packages both active in <paramref name="profile"/> and declared compatible by <paramref name="theme"/>.</returns>
public static IReadOnlyList<string> ApplicablePackages(Theme theme, UiProfile profile) =>
profile.Packages.Where(theme.CompatibleWith.Contains).ToList();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// 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, UiProfile } from '@cratis/scene.model';
import { applicableThemePackages, incompatiblePackages } from '../index';

interface FixtureCase {
name: string;
theme: { compatibleWith: string[] };
profile: { packages: string[] };
expectedIncompatible: string[];
expectedApplicable: string[];
}

interface FixtureCorpus {
cases: FixtureCase[];
}

const manifestPath = join(import.meta.dirname, '..', '..', '..', '..', 'theme-compatibility-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.cases) {
const theme: Theme = { name: 'test-theme', compatibleWith: fixtureCase.theme.compatibleWith };
const profile: UiProfile = { name: 'test-profile', targetPlatform: 'web', packages: fixtureCase.profile.packages };

it(`should match the expected incompatible packages for "${fixtureCase.name}"`, () => {
incompatiblePackages(theme, profile).should.deep.equal(fixtureCase.expectedIncompatible);
});

it(`should match the expected applicable packages for "${fixtureCase.name}"`, () => {
applicableThemePackages(theme, profile).should.deep.equal(fixtureCase.expectedApplicable);
});
}
});
1 change: 1 addition & 0 deletions Source/JavaScript/engine/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export * from './computeSizeClass';
export * from './evaluateFlowArrangement';
export * from './evaluateFreeformArrangement';
export * from './aggregateContributions';
export * from './themeCompatibility';
46 changes: 46 additions & 0 deletions Source/JavaScript/engine/themeCompatibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// 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 } from '@cratis/scene.model';

/**
* Finds the packages a {@link UiProfile} declares that a {@link Theme} is not declared compatible with -
* part of Cratis/Scene#5, the same rule Screenplay's compiler already applies to warn on an incompatible
* `ui profile`/`theme` pairing, reused here so `Scene.React` knows which packages to actually apply a
* theme's tokens to, and so Studio's design-time tooling and Stage's build-time checks don't each
* reimplement it. There is deliberately no implicit exemption for `core`: a theme wanting broad
* applicability declares `compatible with core` itself, exactly like the profile/package resolver has no
* implicit special case for it either.
*
* @param theme The {@link Theme} to check.
* @param profile The {@link UiProfile} whose packages to check against.
* @returns The packages in `profile.packages` that `theme` does not declare compatibility with.
*/
export function incompatiblePackages(theme: Theme, profile: UiProfile): string[] {
return profile.packages.filter(package_ => !theme.compatibleWith.includes(package_));
}

/**
* Whether a {@link Theme} is compatible with every package a {@link UiProfile} declares.
*
* @param theme The {@link Theme} to check.
* @param profile The {@link UiProfile} whose packages to check against.
* @returns `true` when `theme` declares compatibility with every package `profile` lists.
*/
export function isThemeCompatible(theme: Theme, profile: UiProfile): boolean {
return incompatiblePackages(theme, profile).length === 0;
}

/**
* Finds the packages a {@link Theme}'s tokens actually apply to for a given {@link UiProfile} - the
* packages the profile activates that the theme also declares compatibility with. This is what a
* renderer scopes token application to, rather than applying a theme's tokens globally - and is
* recomputed on every theme switch for the live re-resolution Cratis/Scene#5 requires (no reload).
*
* @param theme The {@link Theme} to apply.
* @param profile The active {@link UiProfile}.
* @returns The packages both active in `profile` and declared compatible by `theme`.
*/
export function applicableThemePackages(theme: Theme, profile: UiProfile): string[] {
return profile.packages.filter(package_ => theme.compatibleWith.includes(package_));
}
40 changes: 40 additions & 0 deletions theme-compatibility-fixtures.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"description": "Shared behavior corpus for Cratis.Scene.Engine.Profiles.ThemeCompatibility (C#) and themeCompatibility.ts (TypeScript, @cratis/scene.engine) - both sides assert every case here independently, so the two implementations of Cratis/Scene#5's compatibility rule cannot drift apart. Mirrors the exact rule already implemented in Screenplay's compiler (ValidateThemes): every package a profile declares must be in the theme's compatibleWith list - no implicit exemption for 'core'.",
"cases": [
{
"name": "a theme compatible with every profile package is fully compatible",
"theme": { "compatibleWith": ["core", "PrimeReact"] },
"profile": { "packages": ["core", "PrimeReact"] },
"expectedIncompatible": [],
"expectedApplicable": ["core", "PrimeReact"]
},
{
"name": "a theme missing one profile package is incompatible for exactly that package",
"theme": { "compatibleWith": ["core"] },
"profile": { "packages": ["core", "PrimeReact"] },
"expectedIncompatible": ["PrimeReact"],
"expectedApplicable": ["core"]
},
{
"name": "core is not implicitly exempt - a profile explicitly listing core against a theme that does not declare it is incompatible for core too",
"theme": { "compatibleWith": ["PrimeReact"] },
"profile": { "packages": ["core", "PrimeReact"] },
"expectedIncompatible": ["core"],
"expectedApplicable": ["PrimeReact"]
},
{
"name": "a theme declaring compatibility with packages the profile does not use does not affect compatibility",
"theme": { "compatibleWith": ["core", "PrimeReact", "Internal.Widgets"] },
"profile": { "packages": ["core"] },
"expectedIncompatible": [],
"expectedApplicable": ["core"]
},
{
"name": "a theme compatible with none of the profile's packages is incompatible with all of them",
"theme": { "compatibleWith": ["Internal.Widgets"] },
"profile": { "packages": ["core", "PrimeReact"] },
"expectedIncompatible": ["core", "PrimeReact"],
"expectedApplicable": []
}
]
}
Loading