From 8ffbb448701015f1deaa350dec91156142f8d773 Mon Sep 17 00:00:00 2001 From: Einar Date: Sat, 15 Aug 2026 10:16:03 +0200 Subject: [PATCH 1/4] Extend FlowArrangement with width/height size-class overrides FlowArrangement had only a single unconditional root tree - nowhere to represent Screenplay's when width/height overrides, unlike FreeformArrangement which already keys its variants by size class. FlowOverride replaces the root for a targeted width and/or height size class; both dimensions are independently optional to match what Screenplay's when clause actually allows (width-only, height-only, or both). --- .../DotNET/Model/Layouts/FlowArrangement.cs | 7 ++++++- Source/DotNET/Model/Layouts/FlowOverride.cs | 14 +++++++++++++ .../model/layouts/FlowArrangement.ts | 10 ++++++++- .../JavaScript/model/layouts/FlowOverride.ts | 21 +++++++++++++++++++ Source/JavaScript/model/layouts/index.ts | 1 + scene-model-shape.json | 3 ++- 6 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 Source/DotNET/Model/Layouts/FlowOverride.cs create mode 100644 Source/JavaScript/model/layouts/FlowOverride.ts diff --git a/Source/DotNET/Model/Layouts/FlowArrangement.cs b/Source/DotNET/Model/Layouts/FlowArrangement.cs index f431467..4d10e53 100644 --- a/Source/DotNET/Model/Layouts/FlowArrangement.cs +++ b/Source/DotNET/Model/Layouts/FlowArrangement.cs @@ -11,4 +11,9 @@ namespace Cratis.Scene.Model.Layouts; /// in a future native renderer). /// /// The root of the flow tree. -public record FlowArrangement(FlowNode Root) : Arrangement; +/// +/// Replacements for targeting specific width/height size classes - the most +/// specific match wins (both dimensions targeted beats one), and the last declared wins among equally +/// specific matches. or empty when the tree never varies by size class. +/// +public record FlowArrangement(FlowNode Root, IReadOnlyList? Overrides = null) : Arrangement; diff --git a/Source/DotNET/Model/Layouts/FlowOverride.cs b/Source/DotNET/Model/Layouts/FlowOverride.cs new file mode 100644 index 0000000..dd8b9ee --- /dev/null +++ b/Source/DotNET/Model/Layouts/FlowOverride.cs @@ -0,0 +1,14 @@ +// 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.SizeClasses; + +namespace Cratis.Scene.Model.Layouts; + +/// +/// Replaces a 's root tree for a targeted width and/or height size class. +/// +/// The width size class this override targets, or to target any width. +/// The height size class this override targets, or to target any height. +/// The replacement tree. +public record FlowOverride(WidthSizeClass? Width, HeightSizeClass? Height, FlowNode Root); diff --git a/Source/JavaScript/model/layouts/FlowArrangement.ts b/Source/JavaScript/model/layouts/FlowArrangement.ts index ca6e194..64c10f3 100644 --- a/Source/JavaScript/model/layouts/FlowArrangement.ts +++ b/Source/JavaScript/model/layouts/FlowArrangement.ts @@ -3,6 +3,7 @@ import { Arrangement } from './Arrangement'; import { FlowNode } from './FlowNode'; +import { FlowOverride } from './FlowOverride'; /** * Arranges a slot's content by reflowing it with the neutral `flow` primitives ({@link FlowRow}, @@ -13,6 +14,13 @@ import { FlowNode } from './FlowNode'; */ export interface FlowArrangement extends Arrangement { root: FlowNode; + + /** + * Replacements for `root` targeting specific width/height size classes - the most specific match + * wins (both dimensions targeted beats one), and the last declared wins among equally specific + * matches. `undefined` or empty when the tree never varies by size class. + */ + overrides?: FlowOverride[]; } -export const FlowArrangementPropertyNames: (keyof FlowArrangement)[] = ['root']; +export const FlowArrangementPropertyNames: (keyof FlowArrangement)[] = ['root', 'overrides']; diff --git a/Source/JavaScript/model/layouts/FlowOverride.ts b/Source/JavaScript/model/layouts/FlowOverride.ts new file mode 100644 index 0000000..ce59125 --- /dev/null +++ b/Source/JavaScript/model/layouts/FlowOverride.ts @@ -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 { WidthSizeClass, HeightSizeClass } from '../sizeClasses'; +import { FlowNode } from './FlowNode'; + +/** + * Replaces a {@link FlowArrangement}'s root tree for a targeted width and/or height size class. + */ +export interface FlowOverride { + /** The width size class this override targets, or `undefined` to target any width. */ + width?: WidthSizeClass; + + /** The height size class this override targets, or `undefined` to target any height. */ + height?: HeightSizeClass; + + /** The replacement tree. */ + root: FlowNode; +} + +export const FlowOverridePropertyNames: (keyof FlowOverride)[] = ['width', 'height', 'root']; diff --git a/Source/JavaScript/model/layouts/index.ts b/Source/JavaScript/model/layouts/index.ts index 413cd6f..911f55c 100644 --- a/Source/JavaScript/model/layouts/index.ts +++ b/Source/JavaScript/model/layouts/index.ts @@ -5,6 +5,7 @@ export * from './Layout'; export * from './Slot'; export * from './Arrangement'; export * from './FlowArrangement'; +export * from './FlowOverride'; export * from './FlowNode'; export * from './FlowContainer'; export * from './FlowRow'; diff --git a/scene-model-shape.json b/scene-model-shape.json index ef00c8b..ea98d44 100644 --- a/scene-model-shape.json +++ b/scene-model-shape.json @@ -17,7 +17,8 @@ "Layout": ["name", "slots"], "Slot": ["name", "arrangement"], "Arrangement": [], - "FlowArrangement": ["root"], + "FlowArrangement": ["root", "overrides"], + "FlowOverride": ["width", "height", "root"], "FlowNode": ["grow", "span"], "FlowContainer": ["gap", "children"], "FlowRow": [], From 564896272724fe1ddc047033f8ade82e1aa30c2a Mon Sep 17 00:00:00 2001 From: Einar Date: Sat, 15 Aug 2026 10:17:08 +0200 Subject: [PATCH 2/4] Add C# layout evaluation engine SizeClassCalculator computes a SizeClass from actual available dimensions, shared by every renderer. FlowArrangementEvaluator selects the most specific matching override's tree (both dimensions targeted beats one; last declared wins among equally specific matches), falling back to the base root. FreeformArrangementEvaluator selects the variant with an exact size-class match, or nothing - deliberately no fallback, matching the language's own "warn, don't silently pick" design for an unmatched size class. --- .../Layouts/FlowArrangementEvaluator.cs | 49 +++++++++++++++++++ .../Layouts/FreeformArrangementEvaluator.cs | 26 ++++++++++ .../Engine/Layouts/SizeClassCalculator.cs | 40 +++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 Source/DotNET/Engine/Layouts/FlowArrangementEvaluator.cs create mode 100644 Source/DotNET/Engine/Layouts/FreeformArrangementEvaluator.cs create mode 100644 Source/DotNET/Engine/Layouts/SizeClassCalculator.cs diff --git a/Source/DotNET/Engine/Layouts/FlowArrangementEvaluator.cs b/Source/DotNET/Engine/Layouts/FlowArrangementEvaluator.cs new file mode 100644 index 0000000..55eace7 --- /dev/null +++ b/Source/DotNET/Engine/Layouts/FlowArrangementEvaluator.cs @@ -0,0 +1,49 @@ +// 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.Layouts; +using Cratis.Scene.Model.SizeClasses; + +namespace Cratis.Scene.Engine.Layouts; + +/// +/// Evaluates a for a given - part of Cratis/Scene#4. +/// +public static class FlowArrangementEvaluator +{ + /// + /// Selects the tree that applies for a given . + /// + /// The to evaluate. + /// The current . + /// + /// The most specific matching (both dimensions targeted beats one; the + /// last declared wins among equally specific matches), or when no + /// override matches. + /// + public static FlowNode Evaluate(FlowArrangement arrangement, SizeClass sizeClass) + { + FlowOverride? best = null; + foreach (var candidate in arrangement.Overrides ?? []) + { + if (!Matches(candidate, sizeClass)) + { + continue; + } + + if (best is null || Specificity(candidate) >= Specificity(best)) + { + best = candidate; + } + } + + return best?.Root ?? arrangement.Root; + } + + static bool Matches(FlowOverride @override, SizeClass sizeClass) => + (@override.Width is null || @override.Width == sizeClass.Width) && + (@override.Height is null || @override.Height == sizeClass.Height); + + static int Specificity(FlowOverride @override) => + (@override.Width is not null ? 1 : 0) + (@override.Height is not null ? 1 : 0); +} diff --git a/Source/DotNET/Engine/Layouts/FreeformArrangementEvaluator.cs b/Source/DotNET/Engine/Layouts/FreeformArrangementEvaluator.cs new file mode 100644 index 0000000..a0ba553 --- /dev/null +++ b/Source/DotNET/Engine/Layouts/FreeformArrangementEvaluator.cs @@ -0,0 +1,26 @@ +// 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.Layouts; +using Cratis.Scene.Model.SizeClasses; + +namespace Cratis.Scene.Engine.Layouts; + +/// +/// Evaluates a for a given - part of Cratis/Scene#4. +/// +public static class FreeformArrangementEvaluator +{ + /// + /// Selects the that targets a given . + /// + /// The to evaluate. + /// The current . + /// + /// The variant whose exactly matches, or + /// when nothing targets it. There is deliberately no fallback here - a size class with no matching + /// variant is a design-time/build-time warning elsewhere, never a silently picked variant. + /// + public static FreeformVariant? Evaluate(FreeformArrangement arrangement, SizeClass sizeClass) => + arrangement.Variants.FirstOrDefault(variant => variant.SizeClass == sizeClass); +} diff --git a/Source/DotNET/Engine/Layouts/SizeClassCalculator.cs b/Source/DotNET/Engine/Layouts/SizeClassCalculator.cs new file mode 100644 index 0000000..1c18be7 --- /dev/null +++ b/Source/DotNET/Engine/Layouts/SizeClassCalculator.cs @@ -0,0 +1,40 @@ +// 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.SizeClasses; + +namespace Cratis.Scene.Engine.Layouts; + +/// +/// Computes the current from actual available dimensions - shared by every +/// renderer (Scene.React, any future native renderer, Studio's preview surface) so they all agree +/// on when a class boundary is crossed. Reactive (recompute on resize) vs. fixed-per-launch (mobile, +/// where orientation change is the only runtime variable) are both just "call this again when the +/// dimensions you have available change" - the API shape is the same either way, only the caller's +/// triggering mechanism differs. +/// +public static class SizeClassCalculator +{ + /// + /// The default width, in device-independent pixels, at or above which applies. + /// + public const double DefaultWidthBreakpoint = 600; + + /// + /// The default height, in device-independent pixels, at or above which applies. + /// + public const double DefaultHeightBreakpoint = 600; + + /// + /// Computes the for a given available width and height. + /// + /// The available width, in device-independent pixels. + /// The available height, in device-independent pixels. + /// The width at or above which applies. Defaults to . + /// The height at or above which applies. Defaults to . + /// The computed . + public static SizeClass Compute(double width, double height, double widthBreakpoint = DefaultWidthBreakpoint, double heightBreakpoint = DefaultHeightBreakpoint) => + new( + width >= widthBreakpoint ? WidthSizeClass.Regular : WidthSizeClass.Compact, + height >= heightBreakpoint ? HeightSizeClass.Regular : HeightSizeClass.Compact); +} From 599e21004887cda1897a2b0b494c39a666f9b1c6 Mon Sep 17 00:00:00 2001 From: Einar Date: Sat, 15 Aug 2026 10:17:12 +0200 Subject: [PATCH 3/4] Add TypeScript layout evaluation engine computeSizeClass/evaluateFlowArrangement/evaluateFreeformArrangement are the runtime twins of the C# evaluators, run whenever a screen renders and its available size may have changed. --- Source/JavaScript/engine/computeSizeClass.ts | 30 ++++++++++++++++ .../engine/evaluateFlowArrangement.ts | 35 +++++++++++++++++++ .../engine/evaluateFreeformArrangement.ts | 15 ++++++++ Source/JavaScript/engine/index.ts | 3 ++ 4 files changed, 83 insertions(+) create mode 100644 Source/JavaScript/engine/computeSizeClass.ts create mode 100644 Source/JavaScript/engine/evaluateFlowArrangement.ts create mode 100644 Source/JavaScript/engine/evaluateFreeformArrangement.ts diff --git a/Source/JavaScript/engine/computeSizeClass.ts b/Source/JavaScript/engine/computeSizeClass.ts new file mode 100644 index 0000000..830dac5 --- /dev/null +++ b/Source/JavaScript/engine/computeSizeClass.ts @@ -0,0 +1,30 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { HeightSizeClass, SizeClass, WidthSizeClass } from '@cratis/scene.model'; + +/** The default width, in device-independent pixels, at or above which {@link WidthSizeClass.Regular} applies. */ +export const defaultWidthBreakpoint = 600; + +/** The default height, in device-independent pixels, at or above which {@link HeightSizeClass.Regular} applies. */ +export const defaultHeightBreakpoint = 600; + +/** + * Computes the current {@link SizeClass} from actual available dimensions - shared by every renderer + * (`Scene.React`, any future native renderer, Studio's preview surface) so they all agree on when a class + * boundary is crossed. Reactive (recompute on resize) vs. fixed-per-launch (mobile, where orientation + * change is the only runtime variable) are both just "call this again when the dimensions you have + * available change" - the shape is the same either way, only the caller's triggering mechanism differs. + * + * @param width The available width, in device-independent pixels. + * @param height The available height, in device-independent pixels. + * @param widthBreakpoint The width at or above which {@link WidthSizeClass.Regular} applies. Defaults to {@link defaultWidthBreakpoint}. + * @param heightBreakpoint The height at or above which {@link HeightSizeClass.Regular} applies. Defaults to {@link defaultHeightBreakpoint}. + * @returns The computed {@link SizeClass}. + */ +export function computeSizeClass(width: number, height: number, widthBreakpoint = defaultWidthBreakpoint, heightBreakpoint = defaultHeightBreakpoint): SizeClass { + return { + width: width >= widthBreakpoint ? WidthSizeClass.Regular : WidthSizeClass.Compact, + height: height >= heightBreakpoint ? HeightSizeClass.Regular : HeightSizeClass.Compact, + }; +} diff --git a/Source/JavaScript/engine/evaluateFlowArrangement.ts b/Source/JavaScript/engine/evaluateFlowArrangement.ts new file mode 100644 index 0000000..e52a10d --- /dev/null +++ b/Source/JavaScript/engine/evaluateFlowArrangement.ts @@ -0,0 +1,35 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { FlowArrangement, FlowNode, FlowOverride, SizeClass } from '@cratis/scene.model'; + +/** + * Selects the {@link FlowNode} tree that applies for a given {@link SizeClass} - part of Cratis/Scene#4. + * + * @param arrangement The {@link FlowArrangement} to evaluate. + * @param sizeClass The current {@link SizeClass}. + * @returns The most specific matching override's root (both dimensions targeted beats one; the last declared wins among equally specific matches), or `arrangement.root` when no override matches. + */ +export function evaluateFlowArrangement(arrangement: FlowArrangement, sizeClass: SizeClass): FlowNode { + let best: FlowOverride | undefined; + for (const candidate of arrangement.overrides ?? []) { + if (!matches(candidate, sizeClass)) { + continue; + } + + if (!best || specificity(candidate) >= specificity(best)) { + best = candidate; + } + } + + return best?.root ?? arrangement.root; +} + +function matches(override: FlowOverride, sizeClass: SizeClass): boolean { + return (override.width === undefined || override.width === sizeClass.width) && + (override.height === undefined || override.height === sizeClass.height); +} + +function specificity(override: FlowOverride): number { + return (override.width !== undefined ? 1 : 0) + (override.height !== undefined ? 1 : 0); +} diff --git a/Source/JavaScript/engine/evaluateFreeformArrangement.ts b/Source/JavaScript/engine/evaluateFreeformArrangement.ts new file mode 100644 index 0000000..5bcec61 --- /dev/null +++ b/Source/JavaScript/engine/evaluateFreeformArrangement.ts @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { FreeformArrangement, FreeformVariant, SizeClass } from '@cratis/scene.model'; + +/** + * Selects the {@link FreeformVariant} that targets a given {@link SizeClass} - part of Cratis/Scene#4. + * + * @param arrangement The {@link FreeformArrangement} to evaluate. + * @param sizeClass The current {@link SizeClass}. + * @returns The variant whose size class exactly matches, or `undefined` when nothing targets it. There is deliberately no fallback here - a size class with no matching variant is a design-time/build-time warning elsewhere, never a silently picked variant. + */ +export function evaluateFreeformArrangement(arrangement: FreeformArrangement, sizeClass: SizeClass): FreeformVariant | undefined { + return arrangement.variants.find(variant => variant.sizeClass.width === sizeClass.width && variant.sizeClass.height === sizeClass.height); +} diff --git a/Source/JavaScript/engine/index.ts b/Source/JavaScript/engine/index.ts index 2e743fb..b6638e3 100644 --- a/Source/JavaScript/engine/index.ts +++ b/Source/JavaScript/engine/index.ts @@ -7,3 +7,6 @@ export * from './renderElement'; export * from './elementKind'; export * from './ComponentResolution'; export * from './resolveComponentName'; +export * from './computeSizeClass'; +export * from './evaluateFlowArrangement'; +export * from './evaluateFreeformArrangement'; From ae2347c5f5c4313ed0cf9698101286a1bd198cb2 Mon Sep 17 00:00:00 2001 From: Einar Date: Sat, 15 Aug 2026 10:17:16 +0200 Subject: [PATCH 4/4] Add shared fixture corpus proving C#/TS evaluator parity layout-evaluation-fixtures.json is asserted independently by both Cratis.Scene.Engine.Specs (C#) and @cratis/scene.engine's own Vitest suite, covering flow override specificity/tie-breaking and freeform exact-match selection - the same pattern used for the package resolver in Cratis/Scene#3. --- ...ating_against_the_shared_fixture_corpus.cs | 86 +++++++++++++++++++ ...ating_against_the_shared_fixture_corpus.cs | 75 ++++++++++++++++ .../when_computing_the_size_class.cs | 23 +++++ .../when_computing_the_size_class.ts | 23 +++++ ...ating_against_the_shared_fixture_corpus.ts | 67 +++++++++++++++ ...ating_against_the_shared_fixture_corpus.ts | 68 +++++++++++++++ layout-evaluation-fixtures.json | 77 +++++++++++++++++ 7 files changed, 419 insertions(+) create mode 100644 Source/DotNET/Engine.Specs/for_FlowArrangementEvaluator/when_evaluating_against_the_shared_fixture_corpus.cs create mode 100644 Source/DotNET/Engine.Specs/for_FreeformArrangementEvaluator/when_evaluating_against_the_shared_fixture_corpus.cs create mode 100644 Source/DotNET/Engine.Specs/for_SizeClassCalculator/when_computing_the_size_class.cs create mode 100644 Source/JavaScript/engine/for_computeSizeClass/when_computing_the_size_class.ts create mode 100644 Source/JavaScript/engine/for_evaluateFlowArrangement/when_evaluating_against_the_shared_fixture_corpus.ts create mode 100644 Source/JavaScript/engine/for_evaluateFreeformArrangement/when_evaluating_against_the_shared_fixture_corpus.ts create mode 100644 layout-evaluation-fixtures.json diff --git a/Source/DotNET/Engine.Specs/for_FlowArrangementEvaluator/when_evaluating_against_the_shared_fixture_corpus.cs b/Source/DotNET/Engine.Specs/for_FlowArrangementEvaluator/when_evaluating_against_the_shared_fixture_corpus.cs new file mode 100644 index 0000000..c28c7ac --- /dev/null +++ b/Source/DotNET/Engine.Specs/for_FlowArrangementEvaluator/when_evaluating_against_the_shared_fixture_corpus.cs @@ -0,0 +1,86 @@ +// 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.Layouts; +using Cratis.Scene.Model.Elements; +using Cratis.Scene.Model.Layouts; +using Cratis.Scene.Model.SizeClasses; + +namespace Cratis.Scene.Engine.for_FlowArrangementEvaluator; + +public class when_evaluating_against_the_shared_fixture_corpus : Specification +{ + record FixtureCase(string Name, FlowArrangement Arrangement, SizeClass SizeClass, string ExpectedTag); + + List _cases = null!; + List<(FixtureCase Case, string ActualTag)> _results = null!; + + void Establish() + { + var manifestPath = Path.Combine(FindRepositoryRoot(), "layout-evaluation-fixtures.json"); + using var document = JsonDocument.Parse(File.ReadAllText(manifestPath)); + + _cases = document.RootElement.GetProperty("flowCases").EnumerateArray().Select(ToFixtureCase).ToList(); + } + + void Because() => _results = [.. _cases.Select(fixtureCase => (fixtureCase, TagOf(FlowArrangementEvaluator.Evaluate(fixtureCase.Arrangement, fixtureCase.SizeClass))))]; + + [Fact] + void should_match_the_expected_tag_for_every_case() + { + foreach (var (fixtureCase, actualTag) in _results) + { + (fixtureCase.Name, actualTag).ShouldEqual((fixtureCase.Name, fixtureCase.ExpectedTag)); + } + } + + static FixtureCase ToFixtureCase(JsonElement element) + { + var name = element.GetProperty("name").GetString()!; + var root = Leaf(element.GetProperty("rootTag").GetString()!); + + var overrides = element.GetProperty("overrides").EnumerateArray() + .Select(overrideElement => new FlowOverride( + ParseWidth(overrideElement), + ParseHeight(overrideElement), + Leaf(overrideElement.GetProperty("tag").GetString()!))) + .ToList(); + + var sizeClassElement = element.GetProperty("sizeClass"); + var sizeClass = new SizeClass( + Enum.Parse(sizeClassElement.GetProperty("width").GetString()!), + Enum.Parse(sizeClassElement.GetProperty("height").GetString()!)); + + var expectedTag = element.GetProperty("expectedTag").GetString()!; + + return new(name, new FlowArrangement(root, overrides), sizeClass, expectedTag); + } + + static WidthSizeClass? ParseWidth(JsonElement overrideElement) + { + var widthElement = overrideElement.GetProperty("width"); + return widthElement.ValueKind == JsonValueKind.Null ? null : Enum.Parse(widthElement.GetString()!); + } + + static HeightSizeClass? ParseHeight(JsonElement overrideElement) + { + var heightElement = overrideElement.GetProperty("height"); + return heightElement.ValueKind == JsonValueKind.Null ? null : Enum.Parse(heightElement.GetString()!); + } + + static FlowLeaf Leaf(string tag) => new(new ExternalComponent { Id = tag, Name = tag, ComponentName = "core:text" }); + + static string TagOf(FlowNode node) => ((FlowLeaf)node).Content.Id; + + 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.Specs/for_FreeformArrangementEvaluator/when_evaluating_against_the_shared_fixture_corpus.cs b/Source/DotNET/Engine.Specs/for_FreeformArrangementEvaluator/when_evaluating_against_the_shared_fixture_corpus.cs new file mode 100644 index 0000000..b242a2b --- /dev/null +++ b/Source/DotNET/Engine.Specs/for_FreeformArrangementEvaluator/when_evaluating_against_the_shared_fixture_corpus.cs @@ -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.Layouts; +using Cratis.Scene.Model.Elements; +using Cratis.Scene.Model.Layouts; +using Cratis.Scene.Model.SizeClasses; + +namespace Cratis.Scene.Engine.for_FreeformArrangementEvaluator; + +public class when_evaluating_against_the_shared_fixture_corpus : Specification +{ + record FixtureCase(string Name, FreeformArrangement Arrangement, SizeClass SizeClass, string? ExpectedTag); + + List _cases = null!; + List<(FixtureCase Case, string? ActualTag)> _results = null!; + + void Establish() + { + var manifestPath = Path.Combine(FindRepositoryRoot(), "layout-evaluation-fixtures.json"); + using var document = JsonDocument.Parse(File.ReadAllText(manifestPath)); + + _cases = document.RootElement.GetProperty("freeformCases").EnumerateArray().Select(ToFixtureCase).ToList(); + } + + void Because() => _results = [.. _cases.Select(fixtureCase => (fixtureCase, TagOf(FreeformArrangementEvaluator.Evaluate(fixtureCase.Arrangement, fixtureCase.SizeClass))))]; + + [Fact] + void should_match_the_expected_tag_for_every_case() + { + foreach (var (fixtureCase, actualTag) in _results) + { + (fixtureCase.Name, actualTag).ShouldEqual((fixtureCase.Name, fixtureCase.ExpectedTag)); + } + } + + static FixtureCase ToFixtureCase(JsonElement element) + { + var name = element.GetProperty("name").GetString()!; + + var variants = element.GetProperty("variants").EnumerateArray() + .Select(variantElement => new FreeformVariant( + new SizeClass( + Enum.Parse(variantElement.GetProperty("width").GetString()!), + Enum.Parse(variantElement.GetProperty("height").GetString()!)), + [Placement(variantElement.GetProperty("tag").GetString()!)])) + .ToList(); + + var sizeClassElement = element.GetProperty("sizeClass"); + var sizeClass = new SizeClass( + Enum.Parse(sizeClassElement.GetProperty("width").GetString()!), + Enum.Parse(sizeClassElement.GetProperty("height").GetString()!)); + + var expectedTagElement = element.GetProperty("expectedTag"); + var expectedTag = expectedTagElement.ValueKind == JsonValueKind.Null ? null : expectedTagElement.GetString(); + + return new(name, new FreeformArrangement(variants), sizeClass, expectedTag); + } + + static ElementPlacement Placement(string tag) => new(new ExternalComponent { Id = tag, Name = tag, ComponentName = "core:text" }, 0, 0, 0, 0); + + static string? TagOf(FreeformVariant? variant) => variant?.Placements.Single().Element.Id; + + 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.Specs/for_SizeClassCalculator/when_computing_the_size_class.cs b/Source/DotNET/Engine.Specs/for_SizeClassCalculator/when_computing_the_size_class.cs new file mode 100644 index 0000000..520ed72 --- /dev/null +++ b/Source/DotNET/Engine.Specs/for_SizeClassCalculator/when_computing_the_size_class.cs @@ -0,0 +1,23 @@ +// 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.Layouts; +using Cratis.Scene.Model.SizeClasses; + +namespace Cratis.Scene.Engine.for_SizeClassCalculator; + +public class when_computing_the_size_class : Specification +{ + [Fact] void should_be_compact_by_compact_below_both_breakpoints() => + SizeClassCalculator.Compute(320, 480).ShouldEqual(new SizeClass(WidthSizeClass.Compact, HeightSizeClass.Compact)); + + [Fact] void should_be_regular_width_below_height_breakpoint_only() => + SizeClassCalculator.Compute(1024, 480).ShouldEqual(new SizeClass(WidthSizeClass.Regular, HeightSizeClass.Compact)); + + [Fact] void should_be_regular_by_regular_at_exactly_both_breakpoints() => + SizeClassCalculator.Compute(SizeClassCalculator.DefaultWidthBreakpoint, SizeClassCalculator.DefaultHeightBreakpoint) + .ShouldEqual(new SizeClass(WidthSizeClass.Regular, HeightSizeClass.Regular)); + + [Fact] void should_honor_a_custom_breakpoint() => + SizeClassCalculator.Compute(500, 500, widthBreakpoint: 400).Width.ShouldEqual(WidthSizeClass.Regular); +} diff --git a/Source/JavaScript/engine/for_computeSizeClass/when_computing_the_size_class.ts b/Source/JavaScript/engine/for_computeSizeClass/when_computing_the_size_class.ts new file mode 100644 index 0000000..88c41bf --- /dev/null +++ b/Source/JavaScript/engine/for_computeSizeClass/when_computing_the_size_class.ts @@ -0,0 +1,23 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { HeightSizeClass, WidthSizeClass } from '@cratis/scene.model'; +import { computeSizeClass, defaultHeightBreakpoint, defaultWidthBreakpoint } from '../index'; + +describe('when computing the size class', () => { + it('should be compact by compact below both breakpoints', () => { + computeSizeClass(320, 480).should.deep.equal({ width: WidthSizeClass.Compact, height: HeightSizeClass.Compact }); + }); + + it('should be regular width below height breakpoint only', () => { + computeSizeClass(1024, 480).should.deep.equal({ width: WidthSizeClass.Regular, height: HeightSizeClass.Compact }); + }); + + it('should be regular by regular at exactly both breakpoints', () => { + computeSizeClass(defaultWidthBreakpoint, defaultHeightBreakpoint).should.deep.equal({ width: WidthSizeClass.Regular, height: HeightSizeClass.Regular }); + }); + + it('should honor a custom breakpoint', () => { + computeSizeClass(500, 500, 400).width.should.equal(WidthSizeClass.Regular); + }); +}); diff --git a/Source/JavaScript/engine/for_evaluateFlowArrangement/when_evaluating_against_the_shared_fixture_corpus.ts b/Source/JavaScript/engine/for_evaluateFlowArrangement/when_evaluating_against_the_shared_fixture_corpus.ts new file mode 100644 index 0000000..3c6bab6 --- /dev/null +++ b/Source/JavaScript/engine/for_evaluateFlowArrangement/when_evaluating_against_the_shared_fixture_corpus.ts @@ -0,0 +1,67 @@ +// 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 { ExternalComponent, FlowArrangement, FlowLeaf, FlowOverride, HeightSizeClass, HorizontalAlignment, SizeClass, VerticalAlignment, Visibility, WidthSizeClass } from '@cratis/scene.model'; +import { evaluateFlowArrangement } from '../index'; + +interface FixtureOverride { + width: WidthSizeClass | null; + height: HeightSizeClass | null; + tag: string; +} + +interface FixtureCase { + name: string; + rootTag: string; + overrides: FixtureOverride[]; + sizeClass: SizeClass; + expectedTag: string; +} + +interface FixtureCorpus { + flowCases: FixtureCase[]; +} + +const manifestPath = join(import.meta.dirname, '..', '..', '..', '..', 'layout-evaluation-fixtures.json'); +const corpus = JSON.parse(readFileSync(manifestPath, 'utf-8')) as FixtureCorpus; + +function leaf(tag: string): FlowLeaf { + const content: ExternalComponent = { + id: tag, + name: tag, + properties: {}, + visibility: Visibility.Visible, + isEnabled: true, + opacity: 1, + size: {}, + zIndex: 0, + minimumSize: {}, + maximumSize: {}, + margin: { left: 0, top: 0, right: 0, bottom: 0 }, + horizontalAlignment: HorizontalAlignment.Stretch, + verticalAlignment: VerticalAlignment.Stretch, + componentName: 'core:text', + slots: {}, + }; + return { content }; +} + +describe('when evaluating against the shared fixture corpus', () => { + for (const fixtureCase of corpus.flowCases) { + it(`should match the expected tag for "${fixtureCase.name}"`, () => { + const arrangement: FlowArrangement = { + root: leaf(fixtureCase.rootTag), + overrides: fixtureCase.overrides.map((override): FlowOverride => ({ + width: override.width ?? undefined, + height: override.height ?? undefined, + root: leaf(override.tag), + })), + }; + + const result = evaluateFlowArrangement(arrangement, fixtureCase.sizeClass) as FlowLeaf; + (result.content as ExternalComponent).id.should.equal(fixtureCase.expectedTag); + }); + } +}); diff --git a/Source/JavaScript/engine/for_evaluateFreeformArrangement/when_evaluating_against_the_shared_fixture_corpus.ts b/Source/JavaScript/engine/for_evaluateFreeformArrangement/when_evaluating_against_the_shared_fixture_corpus.ts new file mode 100644 index 0000000..d02e54b --- /dev/null +++ b/Source/JavaScript/engine/for_evaluateFreeformArrangement/when_evaluating_against_the_shared_fixture_corpus.ts @@ -0,0 +1,68 @@ +// 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 { ElementPlacement, ExternalComponent, FreeformArrangement, HeightSizeClass, HorizontalAlignment, SizeClass, VerticalAlignment, Visibility, WidthSizeClass } from '@cratis/scene.model'; +import { evaluateFreeformArrangement } from '../index'; + +interface FixtureVariant { + width: WidthSizeClass; + height: HeightSizeClass; + tag: string; +} + +interface FixtureCase { + name: string; + variants: FixtureVariant[]; + sizeClass: SizeClass; + expectedTag: string | null; +} + +interface FixtureCorpus { + freeformCases: FixtureCase[]; +} + +const manifestPath = join(import.meta.dirname, '..', '..', '..', '..', 'layout-evaluation-fixtures.json'); +const corpus = JSON.parse(readFileSync(manifestPath, 'utf-8')) as FixtureCorpus; + +function placement(tag: string): ElementPlacement { + const element: ExternalComponent = { + id: tag, + name: tag, + properties: {}, + visibility: Visibility.Visible, + isEnabled: true, + opacity: 1, + size: {}, + zIndex: 0, + minimumSize: {}, + maximumSize: {}, + margin: { left: 0, top: 0, right: 0, bottom: 0 }, + horizontalAlignment: HorizontalAlignment.Stretch, + verticalAlignment: VerticalAlignment.Stretch, + componentName: 'core:text', + slots: {}, + }; + return { element, x: 0, y: 0, width: 0, height: 0 }; +} + +describe('when evaluating against the shared fixture corpus', () => { + for (const fixtureCase of corpus.freeformCases) { + it(`should match the expected tag for "${fixtureCase.name}"`, () => { + const arrangement: FreeformArrangement = { + variants: fixtureCase.variants.map(variant => ({ + sizeClass: { width: variant.width, height: variant.height }, + placements: [placement(variant.tag)], + })), + }; + + const result = evaluateFreeformArrangement(arrangement, fixtureCase.sizeClass); + if (fixtureCase.expectedTag === null) { + (result === undefined).should.be.true; + } else { + (result!.placements[0].element as ExternalComponent).id.should.equal(fixtureCase.expectedTag); + } + }); + } +}); diff --git a/layout-evaluation-fixtures.json b/layout-evaluation-fixtures.json new file mode 100644 index 0000000..8477a34 --- /dev/null +++ b/layout-evaluation-fixtures.json @@ -0,0 +1,77 @@ +{ + "description": "Shared behavior corpus for Cratis.Scene.Engine.Layouts.FlowArrangementEvaluator/FreeformArrangementEvaluator (C#) and evaluateFlowArrangement/evaluateFreeformArrangement (TypeScript, @cratis/scene.engine) - both sides assert every case here independently, so the two implementations of Cratis/Scene#4's selection algorithm cannot drift apart. Trees and placements are reduced to a distinguishing 'tag' string, since the evaluators only select which one wins - they never descend into or transform the tree itself.", + "flowCases": [ + { + "name": "no overrides - the base root always wins", + "rootTag": "base", + "overrides": [], + "sizeClass": { "width": "Compact", "height": "Regular" }, + "expectedTag": "base" + }, + { + "name": "a width-only override matching the current size class wins over the base root", + "rootTag": "base", + "overrides": [{ "width": "Compact", "height": null, "tag": "compact-width" }], + "sizeClass": { "width": "Compact", "height": "Regular" }, + "expectedTag": "compact-width" + }, + { + "name": "a height-only override matching the current size class wins over the base root", + "rootTag": "base", + "overrides": [{ "width": null, "height": "Compact", "tag": "compact-height" }], + "sizeClass": { "width": "Regular", "height": "Compact" }, + "expectedTag": "compact-height" + }, + { + "name": "an override targeting both dimensions wins over a less specific one, regardless of declaration order", + "rootTag": "base", + "overrides": [ + { "width": "Compact", "height": "Regular", "tag": "both-dimensions" }, + { "width": "Compact", "height": null, "tag": "width-only" } + ], + "sizeClass": { "width": "Compact", "height": "Regular" }, + "expectedTag": "both-dimensions" + }, + { + "name": "two equally specific overrides both matching - the later declared wins", + "rootTag": "base", + "overrides": [ + { "width": "Compact", "height": null, "tag": "first-declared" }, + { "width": null, "height": "Regular", "tag": "second-declared" } + ], + "sizeClass": { "width": "Compact", "height": "Regular" }, + "expectedTag": "second-declared" + }, + { + "name": "an override targeting a size class that is not current does not match - the base root wins", + "rootTag": "base", + "overrides": [{ "width": "Compact", "height": null, "tag": "compact-width" }], + "sizeClass": { "width": "Regular", "height": "Regular" }, + "expectedTag": "base" + } + ], + "freeformCases": [ + { + "name": "a variant whose size class exactly matches resolves", + "variants": [{ "width": "Compact", "height": "Regular", "tag": "phone-portrait" }], + "sizeClass": { "width": "Compact", "height": "Regular" }, + "expectedTag": "phone-portrait" + }, + { + "name": "no variant matches the current size class - nothing resolves", + "variants": [{ "width": "Compact", "height": "Regular", "tag": "phone-portrait" }], + "sizeClass": { "width": "Regular", "height": "Regular" }, + "expectedTag": null + }, + { + "name": "only the exact match resolves among several variants", + "variants": [ + { "width": "Compact", "height": "Compact", "tag": "phone-landscape" }, + { "width": "Compact", "height": "Regular", "tag": "phone-portrait" }, + { "width": "Regular", "height": "Regular", "tag": "tablet-desktop" } + ], + "sizeClass": { "width": "Regular", "height": "Regular" }, + "expectedTag": "tablet-desktop" + } + ] +}