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,72 @@
// 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.Layouts;
using Cratis.Scene.Model.SizeClasses;

namespace Cratis.Scene.Engine.for_FreeformSlotArrangementEvaluator;

public class when_evaluating_against_the_shared_fixture_corpus : Specification
{
record FixtureCase(string Name, FreeformSlotArrangement Arrangement, SizeClass SizeClass, string? ExpectedTag);

List<FixtureCase> _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(FreeformSlotArrangementEvaluator.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 FreeformSlotVariant(
new SizeClass(
Enum.Parse<WidthSizeClass>(variantElement.GetProperty("width").GetString()!),
Enum.Parse<HeightSizeClass>(variantElement.GetProperty("height").GetString()!)),
[new SlotPlacement(variantElement.GetProperty("tag").GetString()!, 0, 0, 0, 0)]))
.ToList();

var sizeClassElement = element.GetProperty("sizeClass");
var sizeClass = new SizeClass(
Enum.Parse<WidthSizeClass>(sizeClassElement.GetProperty("width").GetString()!),
Enum.Parse<HeightSizeClass>(sizeClassElement.GetProperty("height").GetString()!));

var expectedTagElement = element.GetProperty("expectedTag");
var expectedTag = expectedTagElement.ValueKind == JsonValueKind.Null ? null : expectedTagElement.GetString();

return new(name, new FreeformSlotArrangement(variants), sizeClass, expectedTag);
}

static string? TagOf(FreeformSlotVariant? variant) => variant?.Placements.Single().SlotName;

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);
}
}
29 changes: 29 additions & 0 deletions Source/DotNET/Engine/Layouts/FreeformSlotArrangementEvaluator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// 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;

/// <summary>
/// Evaluates a <see cref="FreeformSlotArrangement"/> for a given <see cref="SizeClass"/> - the counterpart
/// to <see cref="FreeformArrangementEvaluator"/> for a <see cref="Layout"/>'s own macro
/// <see cref="Layout.Arrangement"/>, which places the layout's slots themselves rather than the content of
/// one slot.
/// </summary>
public static class FreeformSlotArrangementEvaluator
{
/// <summary>
/// Selects the <see cref="FreeformSlotVariant"/> that targets a given <see cref="SizeClass"/>.
/// </summary>
/// <param name="arrangement">The <see cref="FreeformSlotArrangement"/> to evaluate.</param>
/// <param name="sizeClass">The current <see cref="SizeClass"/>.</param>
/// <returns>
/// The variant whose <see cref="FreeformSlotVariant.SizeClass"/> exactly matches, or <see langword="null"/>
/// 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.
/// </returns>
public static FreeformSlotVariant? Evaluate(FreeformSlotArrangement arrangement, SizeClass sizeClass) =>
arrangement.Variants.FirstOrDefault(variant => variant.SizeClass == sizeClass);
}
9 changes: 7 additions & 2 deletions Source/DotNET/Model/Layouts/Arrangement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@
namespace Cratis.Scene.Model.Layouts;

/// <summary>
/// How a <see cref="Slot"/>'s content is arranged: <see cref="FlowArrangement"/> (reflowing, computed per
/// size class) or <see cref="FreeformArrangement"/> (one placement variant per size class).
/// How content is arranged: <see cref="FlowArrangement"/> (reflowing, computed per size class) or
/// <see cref="FreeformArrangement"/>/<see cref="FreeformSlotArrangement"/> (one placement variant per size
/// class). Used at two levels - a <see cref="Layout"/>'s own <see cref="Layout.Arrangement"/> positions its
/// named <see cref="Slot"/>s relative to each other (leaves reference a slot by name: <see cref="FlowSlotLeaf"/>/
/// <see cref="SlotPlacement"/>), while a <see cref="Slot"/>'s own <see cref="Slot.Arrangement"/> positions
/// that slot's own filled content elements (leaves carry the element itself: <see cref="FlowLeaf"/>/
/// <see cref="ElementPlacement"/>).
/// </summary>
public abstract record Arrangement;
12 changes: 12 additions & 0 deletions Source/DotNET/Model/Layouts/FlowSlotLeaf.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// 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.Layouts;

/// <summary>
/// A <see cref="FlowNode"/> leaf that positions one of a <see cref="Layout"/>'s own named <see cref="Slot"/>s
/// within its macro <see cref="Layout.Arrangement"/> tree - the counterpart to <see cref="FlowLeaf"/>, which
/// positions an element within a single slot's own content instead of positioning a slot within the layout.
/// </summary>
/// <param name="SlotName">The name of the <see cref="Slot"/> being positioned.</param>
public record FlowSlotLeaf(string SlotName) : FlowNode;
13 changes: 13 additions & 0 deletions Source/DotNET/Model/Layouts/FreeformSlotArrangement.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// 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.Layouts;

/// <summary>
/// Arranges a <see cref="Layout"/>'s own named <see cref="Slot"/>s with one placement variant per size
/// class - the Xcode-storyboard model applied to the layout's slots themselves, rather than to the content
/// of one slot (see <see cref="FreeformArrangement"/>). The same slot set is shared across every variant;
/// only placement differs.
/// </summary>
/// <param name="Variants">The placement variants, one per targeted size class.</param>
public record FreeformSlotArrangement(IReadOnlyList<FreeformSlotVariant> Variants) : Arrangement;
16 changes: 16 additions & 0 deletions Source/DotNET/Model/Layouts/FreeformSlotVariant.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// 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;

/// <summary>
/// One placement variant of a <see cref="FreeformSlotArrangement"/>, targeting a specific size class - the
/// counterpart to <see cref="FreeformVariant"/>, which places elements within a single slot instead of
/// placing a layout's own slots. A size class with no matching variant is a compiler/engine warning, never
/// a silent fallback, matching <see cref="FreeformVariant"/>.
/// </summary>
/// <param name="SizeClass">The size class this variant targets.</param>
/// <param name="Placements">Where each of the layout's slots is placed for this variant.</param>
public record FreeformSlotVariant(SizeClass SizeClass, IReadOnlyList<SlotPlacement> Placements);
9 changes: 8 additions & 1 deletion Source/DotNET/Model/Layouts/Layout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,11 @@ namespace Cratis.Scene.Model.Layouts;
/// </summary>
/// <param name="Name">The layout's name.</param>
/// <param name="Slots">The slots the layout declares, in declaration order.</param>
public record Layout(string Name, IReadOnlyList<Slot> Slots);
/// <param name="Arrangement">
/// How the layout's own <paramref name="Slots"/> position relative to each other - a <see cref="FlowArrangement"/>
/// (leaves are <see cref="FlowSlotLeaf"/>) or <see cref="FreeformSlotArrangement"/>, or <see langword="null"/>
/// for the slots' declaration order with no further positioning information. Distinct from each
/// <see cref="Layouts.Slot"/>'s own <see cref="Slot.Arrangement"/>, which positions that one slot's filled
/// content instead of positioning the slots themselves.
/// </param>
public record Layout(string Name, IReadOnlyList<Slot> Slots, Arrangement? Arrangement = null);
16 changes: 16 additions & 0 deletions Source/DotNET/Model/Layouts/SlotPlacement.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// 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.Layouts;

/// <summary>
/// The absolute position and size of one of a <see cref="Layout"/>'s own named <see cref="Slot"/>s within a
/// <see cref="FreeformSlotVariant"/> - the counterpart to <see cref="ElementPlacement"/>, which places an
/// element within a single slot's own content instead of placing a slot within the layout.
/// </summary>
/// <param name="SlotName">The name of the <see cref="Slot"/> being placed.</param>
/// <param name="X">The horizontal offset.</param>
/// <param name="Y">The vertical offset.</param>
/// <param name="Width">The width.</param>
/// <param name="Height">The height.</param>
public record SlotPlacement(string SlotName, double X, double Y, double Width, double Height);
17 changes: 17 additions & 0 deletions Source/JavaScript/engine/evaluateFreeformSlotArrangement.ts
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.

import { FreeformSlotArrangement, FreeformSlotVariant, SizeClass } from '@cratis/scene.model';

/**
* Selects the {@link FreeformSlotVariant} that targets a given {@link SizeClass} - the counterpart to
* {@link evaluateFreeformArrangement} for a `Layout`'s own macro `arrangement`, which places the layout's
* slots themselves rather than the content of one slot.
*
* @param arrangement The {@link FreeformSlotArrangement} 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 evaluateFreeformSlotArrangement(arrangement: FreeformSlotArrangement, sizeClass: SizeClass): FreeformSlotVariant | undefined {
return arrangement.variants.find(variant => variant.sizeClass.width === sizeClass.width && variant.sizeClass.height === sizeClass.height);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// 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 { FreeformSlotArrangement, HeightSizeClass, SizeClass, WidthSizeClass } from '@cratis/scene.model';
import { evaluateFreeformSlotArrangement } 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;

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: FreeformSlotArrangement = {
variants: fixtureCase.variants.map(variant => ({
sizeClass: { width: variant.width, height: variant.height },
placements: [{ slotName: variant.tag, x: 0, y: 0, width: 0, height: 0 }],
})),
};

const result = evaluateFreeformSlotArrangement(arrangement, fixtureCase.sizeClass);
if (fixtureCase.expectedTag === null) {
(result === undefined).should.be.true;
} else {
result!.placements[0].slotName.should.equal(fixtureCase.expectedTag);
}
});
}
});
1 change: 1 addition & 0 deletions Source/JavaScript/engine/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from './resolveComponentName';
export * from './computeSizeClass';
export * from './evaluateFlowArrangement';
export * from './evaluateFreeformArrangement';
export * from './evaluateFreeformSlotArrangement';
export * from './aggregateContributions';
export * from './themeCompatibility';
export * from './buildStarterProfile';
Expand Down
8 changes: 6 additions & 2 deletions Source/JavaScript/model/layouts/Arrangement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

/**
* How a {@link Slot}'s content is arranged: {@link FlowArrangement} (reflowing, computed per size class)
* or {@link FreeformArrangement} (one placement variant per size class).
* How content is arranged: {@link FlowArrangement} (reflowing, computed per size class) or
* {@link FreeformArrangement}/{@link FreeformSlotArrangement} (one placement variant per size class). Used
* at two levels - a {@link Layout}'s own `arrangement` positions its named {@link Slot}s relative to each
* other (leaves reference a slot by name: {@link FlowSlotLeaf}/{@link SlotPlacement}), while a
* {@link Slot}'s own `arrangement` positions that slot's own filled content elements (leaves carry the
* element itself: {@link FlowLeaf}/{@link ElementPlacement}).
*/
export interface Arrangement {}
15 changes: 15 additions & 0 deletions Source/JavaScript/model/layouts/FlowSlotLeaf.ts
Original file line number Diff line number Diff line change
@@ -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 { FlowNode } from './FlowNode';

/**
* A {@link FlowNode} leaf that positions one of a {@link Layout}'s own named {@link Slot}s within its
* macro `arrangement` tree - the counterpart to {@link FlowLeaf}, which positions an element within a
* single slot's own content instead of positioning a slot within the layout.
*/
export interface FlowSlotLeaf extends FlowNode {
slotName: string;
}

export const FlowSlotLeafPropertyNames: (keyof FlowSlotLeaf)[] = ['slotName'];
17 changes: 17 additions & 0 deletions Source/JavaScript/model/layouts/FreeformSlotArrangement.ts
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.

import { Arrangement } from './Arrangement';
import { FreeformSlotVariant } from './FreeformSlotVariant';

/**
* Arranges a {@link Layout}'s own named {@link Slot}s with one placement variant per size class - the
* Xcode-storyboard model applied to the layout's slots themselves, rather than to the content of one slot
* (see {@link FreeformArrangement}). The same slot set is shared across every variant; only placement
* differs.
*/
export interface FreeformSlotArrangement extends Arrangement {
variants: FreeformSlotVariant[];
}

export const FreeformSlotArrangementPropertyNames: (keyof FreeformSlotArrangement)[] = ['variants'];
18 changes: 18 additions & 0 deletions Source/JavaScript/model/layouts/FreeformSlotVariant.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.

import { SizeClass } from '../sizeClasses';
import { SlotPlacement } from './SlotPlacement';

/**
* One placement variant of a {@link FreeformSlotArrangement}, targeting a specific size class - the
* counterpart to {@link FreeformVariant}, which places elements within a single slot instead of placing a
* layout's own slots. A size class with no matching variant is a compiler/engine warning, never a silent
* fallback, matching {@link FreeformVariant}.
*/
export interface FreeformSlotVariant {
sizeClass: SizeClass;
placements: SlotPlacement[];
}

export const FreeformSlotVariantPropertyNames: (keyof FreeformSlotVariant)[] = ['sizeClass', 'placements'];
12 changes: 11 additions & 1 deletion Source/JavaScript/model/layouts/Layout.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { Arrangement } from './Arrangement';
import { Slot } from './Slot';

/**
Expand All @@ -11,6 +12,15 @@ import { Slot } from './Slot';
export interface Layout {
name: string;
slots: Slot[];

/**
* How the layout's own `slots` position relative to each other - a {@link FlowArrangement} (leaves
* are {@link FlowSlotLeaf}) or {@link FreeformSlotArrangement}, or `undefined` for the slots'
* declaration order with no further positioning information. Distinct from each {@link Slot}'s own
* `arrangement`, which positions that one slot's filled content instead of positioning the slots
* themselves.
*/
arrangement?: Arrangement;
}

export const LayoutPropertyNames: (keyof Layout)[] = ['name', 'slots'];
export const LayoutPropertyNames: (keyof Layout)[] = ['name', 'slots', 'arrangement'];
17 changes: 17 additions & 0 deletions Source/JavaScript/model/layouts/SlotPlacement.ts
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.

/**
* The absolute position and size of one of a {@link Layout}'s own named {@link Slot}s within a
* {@link FreeformSlotVariant} - the counterpart to {@link ElementPlacement}, which places an element
* within a single slot's own content instead of placing a slot within the layout.
*/
export interface SlotPlacement {
slotName: string;
x: number;
y: number;
width: number;
height: number;
}

export const SlotPlacementPropertyNames: (keyof SlotPlacement)[] = ['slotName', 'x', 'y', 'width', 'height'];
4 changes: 4 additions & 0 deletions Source/JavaScript/model/layouts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ export * from './FlowRow';
export * from './FlowColumn';
export * from './FlowGrid';
export * from './FlowLeaf';
export * from './FlowSlotLeaf';
export * from './FreeformArrangement';
export * from './FreeformVariant';
export * from './ElementPlacement';
export * from './FreeformSlotArrangement';
export * from './FreeformSlotVariant';
export * from './SlotPlacement';
2 changes: 1 addition & 1 deletion layout-evaluation-fixtures.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"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.",
"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. 'freeformCases' is reused verbatim (the 'tag' read as a slot name instead of an element id) by FreeformSlotArrangementEvaluator/evaluateFreeformSlotArrangement - the slot-vs-element leaf content is exactly what those evaluators don't look at, so one corpus proves both.",
"flowCases": [
{
"name": "no overrides - the base root always wins",
Expand Down
Loading
Loading