diff --git a/Source/DotNET/Engine.Specs/for_ThemeCompatibility/when_checking_against_the_shared_fixture_corpus.cs b/Source/DotNET/Engine.Specs/for_ThemeCompatibility/when_checking_against_the_shared_fixture_corpus.cs new file mode 100644 index 0000000..db7b790 --- /dev/null +++ b/Source/DotNET/Engine.Specs/for_ThemeCompatibility/when_checking_against_the_shared_fixture_corpus.cs @@ -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 ExpectedIncompatible, IReadOnlyList ExpectedApplicable); + + List _cases = null!; + List<(FixtureCase Case, IReadOnlyList Incompatible, IReadOnlyList 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 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); + } +} diff --git a/Source/DotNET/Engine/Profiles/ThemeCompatibility.cs b/Source/DotNET/Engine/Profiles/ThemeCompatibility.cs new file mode 100644 index 0000000..022fe4b --- /dev/null +++ b/Source/DotNET/Engine/Profiles/ThemeCompatibility.cs @@ -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; + +/// +/// Checks a 's declaration against a +/// 's package list - 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 +/// Stage (build time) and Studio (design time) don't each reimplement it - and so Scene.React knows +/// which packages to actually apply a theme's tokens to. 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. +/// +public static class ThemeCompatibility +{ + /// + /// Finds the packages a declares that a is not declared + /// compatible with - the same set Screenplay's compiler warns on for an incompatible pairing. + /// + /// The to check. + /// The whose packages to check against. + /// The packages in that does not declare compatibility with. + public static IReadOnlyList IncompatiblePackages(Theme theme, UiProfile profile) => + profile.Packages.Where(package => !theme.CompatibleWith.Contains(package)).ToList(); + + /// + /// Whether a is compatible with every package a declares. + /// + /// The to check. + /// The whose packages to check against. + /// when declares compatibility with every package lists. + public static bool IsCompatible(Theme theme, UiProfile profile) => IncompatiblePackages(theme, profile).Count == 0; + + /// + /// Finds the packages a 's tokens actually apply to for a given + /// - 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. + /// + /// The to apply. + /// The active . + /// The packages both active in and declared compatible by . + public static IReadOnlyList ApplicablePackages(Theme theme, UiProfile profile) => + profile.Packages.Where(theme.CompatibleWith.Contains).ToList(); +} diff --git a/Source/JavaScript/engine/for_themeCompatibility/when_checking_against_the_shared_fixture_corpus.ts b/Source/JavaScript/engine/for_themeCompatibility/when_checking_against_the_shared_fixture_corpus.ts new file mode 100644 index 0000000..055ce8a --- /dev/null +++ b/Source/JavaScript/engine/for_themeCompatibility/when_checking_against_the_shared_fixture_corpus.ts @@ -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); + }); + } +}); diff --git a/Source/JavaScript/engine/index.ts b/Source/JavaScript/engine/index.ts index a650624..e22f45d 100644 --- a/Source/JavaScript/engine/index.ts +++ b/Source/JavaScript/engine/index.ts @@ -11,3 +11,4 @@ export * from './computeSizeClass'; export * from './evaluateFlowArrangement'; export * from './evaluateFreeformArrangement'; export * from './aggregateContributions'; +export * from './themeCompatibility'; diff --git a/Source/JavaScript/engine/themeCompatibility.ts b/Source/JavaScript/engine/themeCompatibility.ts new file mode 100644 index 0000000..8022605 --- /dev/null +++ b/Source/JavaScript/engine/themeCompatibility.ts @@ -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_)); +} diff --git a/theme-compatibility-fixtures.json b/theme-compatibility-fixtures.json new file mode 100644 index 0000000..bfc3f98 --- /dev/null +++ b/theme-compatibility-fixtures.json @@ -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": [] + } + ] +}