From a44efdcaf2459ea7390c9c05e064bb015693575b Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 11 Nov 2026 18:58:54 +0100 Subject: [PATCH 01/21] Characterize blueprint verification --- .../when_running_the_shared_corpus.cs | 33 + .../when_running_the_shared_corpus.ts | 16 + blueprint-verification-fixtures.json | 2665 ++++++++++++++++- 3 files changed, 2692 insertions(+), 22 deletions(-) diff --git a/Source/DotNET/Engine.Specs/for_Blueprints/when_running_the_shared_corpus.cs b/Source/DotNET/Engine.Specs/for_Blueprints/when_running_the_shared_corpus.cs index a80d4cb2..e3ee261a 100644 --- a/Source/DotNET/Engine.Specs/for_Blueprints/when_running_the_shared_corpus.cs +++ b/Source/DotNET/Engine.Specs/for_Blueprints/when_running_the_shared_corpus.cs @@ -16,6 +16,12 @@ public class when_running_the_shared_corpus : Specification Converters = { new JsonStringEnumConverter() } }; + static readonly JsonSerializerOptions _raw = new() + { + Converters = { new JsonStringEnumConverter() }, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + List _verification = null!; List _catalog = null!; List _pin = null!; @@ -55,6 +61,33 @@ void should_verify_blueprints_like_the_typescript_engine() } } + [Fact] + void should_write_the_problems_in_the_order_they_are_found() + { + var fixture = _verification.Single(candidate => candidate.Name == "screen set that is inconsistent"); + + var written = JsonSerializer.Serialize(BlueprintVerification.Verify(fixture.Source, fixture.Library, fixture.Options), _raw); + + written.ShouldEqual("[\"Blueprint '@cratis/scene.blueprint.crm' declares screen 'Leads' more than once\",\"Blueprint '@cratis/scene.blueprint.crm' declares a screen set entry without a name\",\"Blueprint '@cratis/scene.blueprint.crm' screen 'Leads' uses template 'Missing', which is not available\",\"Blueprint '@cratis/scene.blueprint.crm' screen 'Leads' declares no title\",\"Blueprint '@cratis/scene.blueprint.crm' screen 'Orphan' has parent 'Nowhere', which is not in the screen set\",\"Blueprint '@cratis/scene.blueprint.crm' screen 'Orphan' is named in the screen set but the package does not provide it\",\"Blueprint '@cratis/scene.blueprint.crm' screen 'A' is named in the screen set but the package does not provide it\",\"Blueprint '@cratis/scene.blueprint.crm' screen 'B' is named in the screen set but the package does not provide it\",\"Blueprint '@cratis/scene.blueprint.crm' screen 'A' is its own ancestor\",\"Blueprint '@cratis/scene.blueprint.crm' screen 'B' is its own ancestor\"]"); + } + + [Fact] + void should_leave_the_blueprint_the_library_and_the_options_as_they_were() + { + var mismatches = new List(); + foreach (var fixture in _verification) + { + var before = JsonSerializer.Serialize(new { fixture.Source, fixture.Library, fixture.Options }, _raw); + BlueprintVerification.Verify(fixture.Source, fixture.Library, fixture.Options); + if (JsonSerializer.Serialize(new { fixture.Source, fixture.Library, fixture.Options }, _raw) != before) + { + mismatches.Add(fixture.Name); + } + } + + mismatches.ShouldBeEmpty(); + } + [Fact] void should_describe_the_catalog_like_the_typescript_engine() { diff --git a/Source/JavaScript/engine/for_blueprints/when_running_the_shared_corpus.ts b/Source/JavaScript/engine/for_blueprints/when_running_the_shared_corpus.ts index c0e5c446..913df705 100644 --- a/Source/JavaScript/engine/for_blueprints/when_running_the_shared_corpus.ts +++ b/Source/JavaScript/engine/for_blueprints/when_running_the_shared_corpus.ts @@ -24,9 +24,25 @@ for (const fixture of corpus.verification) { describe(`when verifying a blueprint for ${fixture.name}`, () => { it('should report the same problems, in the same order, as the C# engine', () => verifyBlueprintPackage(fixture.source, fixture.library, fixture.options).should.deep.equal(fixture.expectedProblems)); + + it('should leave the blueprint, the library and the options as they were', () => { + const source = structuredClone(fixture.source); + const library = structuredClone(fixture.library); + const options = structuredClone(fixture.options); + const before = JSON.stringify({ source, library, options }); + verifyBlueprintPackage(source, library, options); + JSON.stringify({ source, library, options }).should.equal(before); + }); }); } +describe('when writing the problems of a blueprint', () => { + const fixture = corpus.verification.find(candidate => candidate.name === 'screen set that is inconsistent')!; + + it('should write them in the order they are found', () => + JSON.stringify(verifyBlueprintPackage(fixture.source, fixture.library, fixture.options)).should.equal(`["Blueprint '@cratis/scene.blueprint.crm' declares screen 'Leads' more than once","Blueprint '@cratis/scene.blueprint.crm' declares a screen set entry without a name","Blueprint '@cratis/scene.blueprint.crm' screen 'Leads' uses template 'Missing', which is not available","Blueprint '@cratis/scene.blueprint.crm' screen 'Leads' declares no title","Blueprint '@cratis/scene.blueprint.crm' screen 'Orphan' has parent 'Nowhere', which is not in the screen set","Blueprint '@cratis/scene.blueprint.crm' screen 'Orphan' is named in the screen set but the package does not provide it","Blueprint '@cratis/scene.blueprint.crm' screen 'A' is named in the screen set but the package does not provide it","Blueprint '@cratis/scene.blueprint.crm' screen 'B' is named in the screen set but the package does not provide it","Blueprint '@cratis/scene.blueprint.crm' screen 'A' is its own ancestor","Blueprint '@cratis/scene.blueprint.crm' screen 'B' is its own ancestor"]`)); +}); + for (const fixture of corpus.catalog) { describe(`when describing the blueprint catalog for ${fixture.name}`, () => { it('should describe the same entries as the C# engine', () => describeBlueprintCatalog(fixture.sources).should.deep.equal(fixture.expectedEntries)); diff --git a/blueprint-verification-fixtures.json b/blueprint-verification-fixtures.json index 402c0406..3f0a371a 100644 --- a/blueprint-verification-fixtures.json +++ b/blueprint-verification-fixtures.json @@ -2277,9 +2277,9 @@ }, { "id": "crm-nb", - "title": "Pack crm-nb", + "title": "Pack crm-en", "locale": "en-GB", - "path": "sampleData/crm-nb.json" + "path": "sampleData/crm-en.json" } ], "blueprint": { @@ -2361,8 +2361,7 @@ "thumbnails/crm.svg", "thumbnails/lead-list.svg", "thumbnails/lead-detail.svg", - "sampleData/crm-en.json", - "sampleData/crm-nb.json" + "sampleData/crm-en.json" ], "screens": [ { @@ -2404,23 +2403,6 @@ ] } ] - }, - { - "id": "crm-nb", - "title": "Pack crm-nb", - "locale": "en-GB", - "owner": "blueprint:crm", - "sets": [ - { - "shape": "Customer", - "rows": [ - { - "id": "1", - "name": "Northgate Ltd" - } - ] - } - ] } ] }, @@ -2492,7 +2474,8 @@ } ], "expectedProblems": [ - "Blueprint '@cratis/scene.blueprint.crm' ships several sample data packs but declares no default" + "Blueprint '@cratis/scene.blueprint.crm' ships several sample data packs but declares no default", + "Package '@cratis/scene.blueprint.crm' sample data pack 'crm-nb' reuses the path 'sampleData/crm-en.json'" ] }, { @@ -4796,6 +4779,2644 @@ "expectedProblems": [ "Template 'MinimalShell' declares no thumbnail" ] + }, + { + "name": "a blueprint verified without a library or options", + "source": { + "manifest": { + "name": "@cratis/scene.blueprint.crm", + "version": "1.2.0", + "kind": "Blueprint", + "dependencies": [ + { + "name": "Cratis.Blueprint.Default", + "versionRange": "^4.0.0" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [ + "LeadList", + "LeadDetail" + ], + "dialogTemplates": [], + "themes": [], + "sampleData": [ + { + "id": "crm-en", + "title": "Pack crm-en", + "locale": "en-GB", + "path": "sampleData/crm-en.json" + } + ], + "blueprint": { + "domain": "crm", + "description": "Leads, accounts and deals for a sales team.", + "thumbnail": "thumbnails/crm.svg", + "screenSet": [ + { + "name": "Leads", + "title": "Leads", + "template": "LeadList", + "order": 1 + }, + { + "name": "LeadDetail", + "title": "Lead", + "template": "LeadDetail", + "parent": "Leads" + }, + { + "name": "Dashboard", + "title": "Dashboard", + "order": 0 + } + ] + }, + "displayName": "CRM", + "license": "MIT" + }, + "screenTemplates": [ + { + "name": "LeadList", + "fitsSlot": "body", + "slots": [ + { + "name": "rows" + } + ], + "displayName": "LeadList", + "description": "The LeadList template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Business / crm", + "type": "List", + "thumbnail": "thumbnails/lead-list.svg" + } + }, + { + "name": "LeadDetail", + "fitsSlot": "body", + "slots": [ + { + "name": "panels" + } + ], + "displayName": "LeadDetail", + "description": "The LeadDetail template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Business / crm", + "type": "Detail", + "thumbnail": "thumbnails/lead-detail.svg" + } + } + ], + "files": [ + "thumbnails/crm.svg", + "thumbnails/lead-list.svg", + "thumbnails/lead-detail.svg", + "sampleData/crm-en.json" + ], + "screens": [ + { + "name": "Leads", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + }, + { + "name": "LeadDetail", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + }, + { + "name": "Dashboard", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + } + ], + "packs": [ + { + "id": "crm-en", + "title": "Pack crm-en", + "locale": "en-GB", + "owner": "blueprint:crm", + "sets": [ + { + "shape": "Customer", + "rows": [ + { + "id": "1", + "name": "Northgate Ltd" + } + ] + } + ] + } + ] + }, + "expectedProblems": [ + "Blueprint '@cratis/scene.blueprint.crm' has no layout to place its screen templates in" + ] + }, + { + "name": "a manifest without a version", + "source": { + "manifest": { + "name": "@cratis/scene.blueprint.crm", + "kind": "Blueprint", + "dependencies": [ + { + "name": "Cratis.Blueprint.Default", + "versionRange": "^4.0.0" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [ + "LeadList", + "LeadDetail" + ], + "dialogTemplates": [], + "themes": [], + "sampleData": [ + { + "id": "crm-en", + "title": "Pack crm-en", + "locale": "en-GB", + "path": "sampleData/crm-en.json" + } + ], + "blueprint": { + "domain": "crm", + "description": "Leads, accounts and deals for a sales team.", + "thumbnail": "thumbnails/crm.svg", + "screenSet": [ + { + "name": "Leads", + "title": "Leads", + "template": "LeadList", + "order": 1 + }, + { + "name": "LeadDetail", + "title": "Lead", + "template": "LeadDetail", + "parent": "Leads" + }, + { + "name": "Dashboard", + "title": "Dashboard", + "order": 0 + } + ] + }, + "displayName": "CRM", + "license": "MIT" + }, + "screenTemplates": [ + { + "name": "LeadList", + "fitsSlot": "body", + "slots": [ + { + "name": "rows" + } + ], + "displayName": "LeadList", + "description": "The LeadList template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Business / crm", + "type": "List", + "thumbnail": "thumbnails/lead-list.svg" + } + }, + { + "name": "LeadDetail", + "fitsSlot": "body", + "slots": [ + { + "name": "panels" + } + ], + "displayName": "LeadDetail", + "description": "The LeadDetail template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Business / crm", + "type": "Detail", + "thumbnail": "thumbnails/lead-detail.svg" + } + } + ], + "files": [ + "thumbnails/crm.svg", + "thumbnails/lead-list.svg", + "thumbnails/lead-detail.svg", + "sampleData/crm-en.json" + ], + "screens": [ + { + "name": "Leads", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + }, + { + "name": "LeadDetail", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + }, + { + "name": "Dashboard", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + } + ], + "packs": [ + { + "id": "crm-en", + "title": "Pack crm-en", + "locale": "en-GB", + "owner": "blueprint:crm", + "sets": [ + { + "shape": "Customer", + "rows": [ + { + "id": "1", + "name": "Northgate Ltd" + } + ] + } + ] + } + ] + }, + "library": [ + { + "manifest": { + "name": "Cratis.Blueprint.Default", + "version": "4.32.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "AppShell" + ], + "screenTemplates": [ + "ListScreen" + ], + "dialogTemplates": [], + "themes": [] + }, + "layouts": [ + { + "name": "AppShell", + "slots": [ + { + "name": "topbar" + }, + { + "name": "content" + } + ], + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Foundation / Shell", + "type": "Shell" + } + } + ], + "screenTemplates": [ + { + "name": "ListScreen", + "fitsSlot": "content", + "slots": [ + { + "name": "body" + } + ], + "displayName": "ListScreen", + "description": "The ListScreen template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Foundation / List", + "type": "List" + } + } + ] + } + ], + "expectedProblems": [ + "Blueprint '@cratis/scene.blueprint.crm' declares an invalid version ''" + ] + }, + { + "name": "blueprint metadata without domain, description, screen set or sample data", + "source": { + "manifest": { + "name": "@cratis/scene.blueprint.crm", + "version": "1.2.0", + "kind": "Blueprint", + "dependencies": [ + { + "name": "Cratis.Blueprint.Default", + "versionRange": "^4.0.0" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [ + "LeadList", + "LeadDetail" + ], + "dialogTemplates": [], + "themes": [], + "blueprint": { + "thumbnail": "thumbnails/crm.svg" + }, + "displayName": "CRM", + "license": "MIT" + }, + "screenTemplates": [ + { + "name": "LeadList", + "fitsSlot": "body", + "slots": [ + { + "name": "rows" + } + ], + "displayName": "LeadList", + "description": "The LeadList template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Business / crm", + "type": "List", + "thumbnail": "thumbnails/lead-list.svg" + } + }, + { + "name": "LeadDetail", + "fitsSlot": "body", + "slots": [ + { + "name": "panels" + } + ], + "displayName": "LeadDetail", + "description": "The LeadDetail template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Business / crm", + "type": "Detail", + "thumbnail": "thumbnails/lead-detail.svg" + } + } + ], + "files": [ + "thumbnails/crm.svg", + "thumbnails/lead-list.svg", + "thumbnails/lead-detail.svg", + "sampleData/crm-en.json" + ], + "screens": [ + { + "name": "Leads", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + }, + { + "name": "LeadDetail", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + }, + { + "name": "Dashboard", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + } + ] + }, + "library": [ + { + "manifest": { + "name": "Cratis.Blueprint.Default", + "version": "4.32.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "AppShell" + ], + "screenTemplates": [ + "ListScreen" + ], + "dialogTemplates": [], + "themes": [] + }, + "layouts": [ + { + "name": "AppShell", + "slots": [ + { + "name": "topbar" + }, + { + "name": "content" + } + ], + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Foundation / Shell", + "type": "Shell" + } + } + ], + "screenTemplates": [ + { + "name": "ListScreen", + "fitsSlot": "content", + "slots": [ + { + "name": "body" + } + ], + "displayName": "ListScreen", + "description": "The ListScreen template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Foundation / List", + "type": "List" + } + } + ] + } + ], + "expectedProblems": [ + "Blueprint '@cratis/scene.blueprint.crm' declares no domain", + "Blueprint '@cratis/scene.blueprint.crm' declares no description", + "Blueprint '@cratis/scene.blueprint.crm' declares no ready-to-use screen set", + "Blueprint '@cratis/scene.blueprint.crm' ships no sample data pack" + ] + }, + { + "name": "a blank domain and description", + "source": { + "manifest": { + "name": "@cratis/scene.blueprint.crm", + "version": "1.2.0", + "kind": "Blueprint", + "dependencies": [ + { + "name": "Cratis.Blueprint.Default", + "versionRange": "^4.0.0" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [ + "LeadList", + "LeadDetail" + ], + "dialogTemplates": [], + "themes": [], + "sampleData": [ + { + "id": "crm-en", + "title": "Pack crm-en", + "locale": "en-GB", + "path": "sampleData/crm-en.json" + } + ], + "blueprint": { + "domain": " ", + "description": " ", + "thumbnail": "thumbnails/crm.svg", + "screenSet": [ + { + "name": "Leads", + "title": "Leads", + "template": "LeadList", + "order": 1 + }, + { + "name": "LeadDetail", + "title": "Lead", + "template": "LeadDetail", + "parent": "Leads" + }, + { + "name": "Dashboard", + "title": "Dashboard", + "order": 0 + } + ] + }, + "displayName": "CRM", + "license": "MIT" + }, + "screenTemplates": [ + { + "name": "LeadList", + "fitsSlot": "body", + "slots": [ + { + "name": "rows" + } + ], + "displayName": "LeadList", + "description": "The LeadList template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Business / crm", + "type": "List", + "thumbnail": "thumbnails/lead-list.svg" + } + }, + { + "name": "LeadDetail", + "fitsSlot": "body", + "slots": [ + { + "name": "panels" + } + ], + "displayName": "LeadDetail", + "description": "The LeadDetail template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Business / crm", + "type": "Detail", + "thumbnail": "thumbnails/lead-detail.svg" + } + } + ], + "files": [ + "thumbnails/crm.svg", + "thumbnails/lead-list.svg", + "thumbnails/lead-detail.svg", + "sampleData/crm-en.json" + ], + "screens": [ + { + "name": "Leads", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + }, + { + "name": "LeadDetail", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + }, + { + "name": "Dashboard", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + } + ], + "packs": [ + { + "id": "crm-en", + "title": "Pack crm-en", + "locale": "en-GB", + "owner": "blueprint:crm", + "sets": [ + { + "shape": "Customer", + "rows": [ + { + "id": "1", + "name": "Northgate Ltd" + } + ] + } + ] + } + ] + }, + "library": [ + { + "manifest": { + "name": "Cratis.Blueprint.Default", + "version": "4.32.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "AppShell" + ], + "screenTemplates": [ + "ListScreen" + ], + "dialogTemplates": [], + "themes": [] + }, + "layouts": [ + { + "name": "AppShell", + "slots": [ + { + "name": "topbar" + }, + { + "name": "content" + } + ], + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Foundation / Shell", + "type": "Shell" + } + } + ], + "screenTemplates": [ + { + "name": "ListScreen", + "fitsSlot": "content", + "slots": [ + { + "name": "body" + } + ], + "displayName": "ListScreen", + "description": "The ListScreen template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Foundation / List", + "type": "List" + } + } + ] + } + ], + "expectedProblems": [ + "Blueprint '@cratis/scene.blueprint.crm' declares no domain", + "Blueprint '@cratis/scene.blueprint.crm' declares no description" + ] + }, + { + "name": "screen set entries without a name or a title", + "source": { + "manifest": { + "name": "@cratis/scene.blueprint.crm", + "version": "1.2.0", + "kind": "Blueprint", + "dependencies": [ + { + "name": "Cratis.Blueprint.Default", + "versionRange": "^4.0.0" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [ + "LeadList", + "LeadDetail" + ], + "dialogTemplates": [], + "themes": [], + "sampleData": [ + { + "id": "crm-en", + "title": "Pack crm-en", + "locale": "en-GB", + "path": "sampleData/crm-en.json" + } + ], + "blueprint": { + "domain": "crm", + "description": "Leads, accounts and deals for a sales team.", + "thumbnail": "thumbnails/crm.svg", + "screenSet": [ + { + "name": "Leads", + "title": "Leads" + }, + { + "title": "No name" + }, + { + "name": "Untitled" + }, + { + "name": "Spaces", + "title": " " + } + ] + }, + "displayName": "CRM", + "license": "MIT" + }, + "screenTemplates": [ + { + "name": "LeadList", + "fitsSlot": "body", + "slots": [ + { + "name": "rows" + } + ], + "displayName": "LeadList", + "description": "The LeadList template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Business / crm", + "type": "List", + "thumbnail": "thumbnails/lead-list.svg" + } + }, + { + "name": "LeadDetail", + "fitsSlot": "body", + "slots": [ + { + "name": "panels" + } + ], + "displayName": "LeadDetail", + "description": "The LeadDetail template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Business / crm", + "type": "Detail", + "thumbnail": "thumbnails/lead-detail.svg" + } + } + ], + "files": [ + "thumbnails/crm.svg", + "thumbnails/lead-list.svg", + "thumbnails/lead-detail.svg", + "sampleData/crm-en.json" + ], + "screens": [ + { + "name": "Leads", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + }, + { + "name": "LeadDetail", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + }, + { + "name": "Dashboard", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + } + ], + "packs": [ + { + "id": "crm-en", + "title": "Pack crm-en", + "locale": "en-GB", + "owner": "blueprint:crm", + "sets": [ + { + "shape": "Customer", + "rows": [ + { + "id": "1", + "name": "Northgate Ltd" + } + ] + } + ] + } + ] + }, + "library": [ + { + "manifest": { + "name": "Cratis.Blueprint.Default", + "version": "4.32.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "AppShell" + ], + "screenTemplates": [ + "ListScreen" + ], + "dialogTemplates": [], + "themes": [] + }, + "layouts": [ + { + "name": "AppShell", + "slots": [ + { + "name": "topbar" + }, + { + "name": "content" + } + ], + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Foundation / Shell", + "type": "Shell" + } + } + ], + "screenTemplates": [ + { + "name": "ListScreen", + "fitsSlot": "content", + "slots": [ + { + "name": "body" + } + ], + "displayName": "ListScreen", + "description": "The ListScreen template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Foundation / List", + "type": "List" + } + } + ] + } + ], + "expectedProblems": [ + "Blueprint '@cratis/scene.blueprint.crm' declares a screen set entry without a name", + "Blueprint '@cratis/scene.blueprint.crm' screen 'Untitled' declares no title", + "Blueprint '@cratis/scene.blueprint.crm' screen 'Untitled' is named in the screen set but the package does not provide it", + "Blueprint '@cratis/scene.blueprint.crm' screen 'Spaces' declares no title", + "Blueprint '@cratis/scene.blueprint.crm' screen 'Spaces' is named in the screen set but the package does not provide it" + ] + }, + { + "name": "a screen under a cycle it is not part of and a screen that is its own parent", + "source": { + "manifest": { + "name": "@cratis/scene.blueprint.crm", + "version": "1.2.0", + "kind": "Blueprint", + "dependencies": [ + { + "name": "Cratis.Blueprint.Default", + "versionRange": "^4.0.0" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [ + "LeadList", + "LeadDetail" + ], + "dialogTemplates": [], + "themes": [], + "sampleData": [ + { + "id": "crm-en", + "title": "Pack crm-en", + "locale": "en-GB", + "path": "sampleData/crm-en.json" + } + ], + "blueprint": { + "domain": "crm", + "description": "Leads, accounts and deals for a sales team.", + "thumbnail": "thumbnails/crm.svg", + "screenSet": [ + { + "name": "Leads", + "title": "Leads" + }, + { + "name": "Tail", + "title": "Tail", + "parent": "Loop1" + }, + { + "name": "Loop1", + "title": "Loop1", + "parent": "Loop2" + }, + { + "name": "Loop2", + "title": "Loop2", + "parent": "Loop1" + }, + { + "name": "Self", + "title": "Self", + "parent": "Self" + } + ] + }, + "displayName": "CRM", + "license": "MIT" + }, + "screenTemplates": [ + { + "name": "LeadList", + "fitsSlot": "body", + "slots": [ + { + "name": "rows" + } + ], + "displayName": "LeadList", + "description": "The LeadList template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Business / crm", + "type": "List", + "thumbnail": "thumbnails/lead-list.svg" + } + }, + { + "name": "LeadDetail", + "fitsSlot": "body", + "slots": [ + { + "name": "panels" + } + ], + "displayName": "LeadDetail", + "description": "The LeadDetail template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Business / crm", + "type": "Detail", + "thumbnail": "thumbnails/lead-detail.svg" + } + } + ], + "files": [ + "thumbnails/crm.svg", + "thumbnails/lead-list.svg", + "thumbnails/lead-detail.svg", + "sampleData/crm-en.json" + ], + "screens": [ + { + "name": "Leads", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + }, + { + "name": "Tail", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + }, + { + "name": "Loop1", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + }, + { + "name": "Loop2", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + }, + { + "name": "Self", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + } + ], + "packs": [ + { + "id": "crm-en", + "title": "Pack crm-en", + "locale": "en-GB", + "owner": "blueprint:crm", + "sets": [ + { + "shape": "Customer", + "rows": [ + { + "id": "1", + "name": "Northgate Ltd" + } + ] + } + ] + } + ] + }, + "library": [ + { + "manifest": { + "name": "Cratis.Blueprint.Default", + "version": "4.32.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "AppShell" + ], + "screenTemplates": [ + "ListScreen" + ], + "dialogTemplates": [], + "themes": [] + }, + "layouts": [ + { + "name": "AppShell", + "slots": [ + { + "name": "topbar" + }, + { + "name": "content" + } + ], + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Foundation / Shell", + "type": "Shell" + } + } + ], + "screenTemplates": [ + { + "name": "ListScreen", + "fitsSlot": "content", + "slots": [ + { + "name": "body" + } + ], + "displayName": "ListScreen", + "description": "The ListScreen template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Foundation / List", + "type": "List" + } + } + ] + } + ], + "expectedProblems": [ + "Blueprint '@cratis/scene.blueprint.crm' screen 'Loop1' is its own ancestor", + "Blueprint '@cratis/scene.blueprint.crm' screen 'Loop2' is its own ancestor", + "Blueprint '@cratis/scene.blueprint.crm' screen 'Self' is its own ancestor" + ] + }, + { + "name": "a dialog template without a display name or description", + "source": { + "manifest": { + "name": "Cratis.Blueprint.Default", + "version": "4.32.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "AppShell" + ], + "screenTemplates": [ + "ListScreen" + ], + "dialogTemplates": [ + "Confirm" + ], + "themes": [] + }, + "layouts": [ + { + "name": "AppShell", + "slots": [ + { + "name": "topbar" + }, + { + "name": "content" + } + ], + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Foundation / Shell", + "type": "Shell" + } + } + ], + "screenTemplates": [ + { + "name": "ListScreen", + "fitsSlot": "content", + "slots": [ + { + "name": "body" + } + ], + "displayName": "ListScreen", + "description": "The ListScreen template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Foundation / List", + "type": "List" + } + } + ], + "dialogTemplates": [ + { + "name": "Confirm", + "slots": [ + { + "name": "body" + } + ], + "displayName": " ", + "description": "", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Foundation / Dialog", + "type": "Dialog" + } + } + ] + }, + "library": [], + "expectedProblems": [ + "Template 'Confirm' declares no display name", + "Template 'Confirm' declares no description" + ] + }, + { + "name": "domain templates with a blank category, no category, no type and no metadata", + "source": { + "manifest": { + "name": "@cratis/scene.blueprint.crm", + "version": "1.2.0", + "kind": "Blueprint", + "dependencies": [ + { + "name": "Cratis.Blueprint.Default", + "versionRange": "^4.0.0" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [ + "LeadList", + "LeadDetail", + "Bare", + "Typeless" + ], + "dialogTemplates": [], + "themes": [], + "sampleData": [ + { + "id": "crm-en", + "title": "Pack crm-en", + "locale": "en-GB", + "path": "sampleData/crm-en.json" + } + ], + "blueprint": { + "domain": "crm", + "description": "Leads, accounts and deals for a sales team.", + "thumbnail": "thumbnails/crm.svg", + "screenSet": [ + { + "name": "Leads", + "title": "Leads", + "template": "LeadList", + "order": 1 + }, + { + "name": "LeadDetail", + "title": "Lead", + "template": "LeadDetail", + "parent": "Leads" + }, + { + "name": "Dashboard", + "title": "Dashboard", + "order": 0 + } + ] + }, + "displayName": "CRM", + "license": "MIT" + }, + "screenTemplates": [ + { + "name": "LeadList", + "fitsSlot": "content", + "slots": [ + { + "name": "x" + } + ], + "displayName": "LeadList", + "description": "The LeadList template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "thumbnail": "thumbnails/lead-list.svg", + "category": " ", + "type": "List" + } + }, + { + "name": "LeadDetail", + "fitsSlot": "content", + "slots": [ + { + "name": "x" + } + ], + "displayName": "LeadDetail", + "description": "The LeadDetail template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "thumbnail": "thumbnails/lead-list.svg", + "type": "Detail" + } + }, + { + "name": "Bare", + "fitsSlot": "content", + "slots": [ + { + "name": "x" + } + ], + "displayName": "Bare", + "description": "The Bare template." + }, + { + "name": "Typeless", + "fitsSlot": "content", + "slots": [ + { + "name": "x" + } + ], + "displayName": "Typeless", + "description": "The Typeless template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "thumbnail": "thumbnails/lead-list.svg", + "category": "Business / crm" + } + } + ], + "files": [ + "thumbnails/crm.svg", + "thumbnails/lead-list.svg", + "thumbnails/lead-detail.svg", + "sampleData/crm-en.json" + ], + "screens": [ + { + "name": "Leads", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + }, + { + "name": "LeadDetail", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + }, + { + "name": "Dashboard", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + } + ], + "packs": [ + { + "id": "crm-en", + "title": "Pack crm-en", + "locale": "en-GB", + "owner": "blueprint:crm", + "sets": [ + { + "shape": "Customer", + "rows": [ + { + "id": "1", + "name": "Northgate Ltd" + } + ] + } + ] + } + ] + }, + "library": [ + { + "manifest": { + "name": "Cratis.Blueprint.Default", + "version": "4.32.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "AppShell" + ], + "screenTemplates": [ + "ListScreen" + ], + "dialogTemplates": [], + "themes": [] + }, + "layouts": [ + { + "name": "AppShell", + "slots": [ + { + "name": "topbar" + }, + { + "name": "content" + } + ], + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Foundation / Shell", + "type": "Shell" + } + } + ], + "screenTemplates": [ + { + "name": "ListScreen", + "fitsSlot": "content", + "slots": [ + { + "name": "body" + } + ], + "displayName": "ListScreen", + "description": "The ListScreen template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Foundation / List", + "type": "List" + } + } + ] + } + ], + "expectedProblems": [ + "Template 'LeadList' declares no category", + "Template 'LeadDetail' declares no category", + "Template 'Bare' declares no compatibility", + "Template 'Bare' declares no author", + "Template 'Bare' declares no license", + "Template 'Bare' declares no category", + "Template 'Bare' declares no type", + "Template 'Bare' declares no thumbnail", + "Template 'Typeless' declares no type" + ] + }, + { + "name": "a blueprint that provides no screen templates", + "source": { + "manifest": { + "name": "@cratis/scene.blueprint.crm", + "version": "1.2.0", + "kind": "Blueprint", + "dependencies": [ + { + "name": "Cratis.Blueprint.Default", + "versionRange": "^4.0.0" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "sampleData": [ + { + "id": "crm-en", + "title": "Pack crm-en", + "locale": "en-GB", + "path": "sampleData/crm-en.json" + } + ], + "blueprint": { + "domain": "crm", + "description": "Leads, accounts and deals for a sales team.", + "thumbnail": "thumbnails/crm.svg", + "screenSet": [ + { + "name": "Leads", + "title": "Leads" + }, + { + "name": "LeadDetail", + "title": "Lead" + }, + { + "name": "Dashboard", + "title": "Dashboard" + } + ] + }, + "displayName": "CRM", + "license": "MIT" + }, + "files": [ + "thumbnails/crm.svg", + "thumbnails/lead-list.svg", + "thumbnails/lead-detail.svg", + "sampleData/crm-en.json" + ], + "screens": [ + { + "name": "Leads", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + }, + { + "name": "LeadDetail", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + }, + { + "name": "Dashboard", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + } + ], + "packs": [ + { + "id": "crm-en", + "title": "Pack crm-en", + "locale": "en-GB", + "owner": "blueprint:crm", + "sets": [ + { + "shape": "Customer", + "rows": [ + { + "id": "1", + "name": "Northgate Ltd" + } + ] + } + ] + } + ] + }, + "library": [ + { + "manifest": { + "name": "Cratis.Blueprint.Default", + "version": "4.32.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "AppShell" + ], + "screenTemplates": [ + "ListScreen" + ], + "dialogTemplates": [], + "themes": [] + }, + "layouts": [ + { + "name": "AppShell", + "slots": [ + { + "name": "topbar" + }, + { + "name": "content" + } + ], + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Foundation / Shell", + "type": "Shell" + } + } + ], + "screenTemplates": [ + { + "name": "ListScreen", + "fitsSlot": "content", + "slots": [ + { + "name": "body" + } + ], + "displayName": "ListScreen", + "description": "The ListScreen template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Foundation / List", + "type": "List" + } + } + ] + } + ], + "expectedProblems": [] + }, + { + "name": "a library package without layouts or templates", + "source": { + "manifest": { + "name": "@cratis/scene.blueprint.crm", + "version": "1.2.0", + "kind": "Blueprint", + "dependencies": [ + { + "name": "Cratis.Blueprint.Default", + "versionRange": "^4.0.0" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [ + "LeadList", + "LeadDetail" + ], + "dialogTemplates": [], + "themes": [], + "sampleData": [ + { + "id": "crm-en", + "title": "Pack crm-en", + "locale": "en-GB", + "path": "sampleData/crm-en.json" + } + ], + "blueprint": { + "domain": "crm", + "description": "Leads, accounts and deals for a sales team.", + "thumbnail": "thumbnails/crm.svg", + "screenSet": [ + { + "name": "Leads", + "title": "Leads", + "template": "LeadList", + "order": 1 + }, + { + "name": "LeadDetail", + "title": "Lead", + "template": "LeadDetail", + "parent": "Leads" + }, + { + "name": "Dashboard", + "title": "Dashboard", + "order": 0 + } + ] + }, + "displayName": "CRM", + "license": "MIT" + }, + "screenTemplates": [ + { + "name": "LeadList", + "fitsSlot": "body", + "slots": [ + { + "name": "rows" + } + ], + "displayName": "LeadList", + "description": "The LeadList template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Business / crm", + "type": "List", + "thumbnail": "thumbnails/lead-list.svg" + } + }, + { + "name": "LeadDetail", + "fitsSlot": "body", + "slots": [ + { + "name": "panels" + } + ], + "displayName": "LeadDetail", + "description": "The LeadDetail template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Business / crm", + "type": "Detail", + "thumbnail": "thumbnails/lead-detail.svg" + } + } + ], + "files": [ + "thumbnails/crm.svg", + "thumbnails/lead-list.svg", + "thumbnails/lead-detail.svg", + "sampleData/crm-en.json" + ], + "screens": [ + { + "name": "Leads", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + }, + { + "name": "LeadDetail", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + }, + { + "name": "Dashboard", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + } + ], + "packs": [ + { + "id": "crm-en", + "title": "Pack crm-en", + "locale": "en-GB", + "owner": "blueprint:crm", + "sets": [ + { + "shape": "Customer", + "rows": [ + { + "id": "1", + "name": "Northgate Ltd" + } + ] + } + ] + } + ] + }, + "library": [ + { + "manifest": { + "name": "Cratis.Blueprint.Default", + "version": "4.32.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "AppShell" + ], + "screenTemplates": [ + "ListScreen" + ], + "dialogTemplates": [], + "themes": [] + } + } + ], + "expectedProblems": [ + "Blueprint '@cratis/scene.blueprint.crm' has no layout to place its screen templates in" + ] + }, + { + "name": "a foundation package with unplaced, ambiguous and nested templates", + "source": { + "manifest": { + "name": "@cratis/scene.blueprint.crm", + "version": "1.2.0", + "kind": "Blueprint", + "dependencies": [ + { + "name": "Cratis.Blueprint.Default", + "versionRange": "^4.0.0" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [ + "Lost", + "Ambiguous", + "Other", + "Loop1", + "Loop2" + ], + "dialogTemplates": [], + "themes": [], + "sampleData": [ + { + "id": "crm-en", + "title": "Pack crm-en", + "locale": "en-GB", + "path": "sampleData/crm-en.json" + } + ], + "displayName": "CRM", + "license": "MIT" + }, + "screenTemplates": [ + { + "name": "Lost", + "fitsSlot": "nowhere", + "slots": [ + { + "name": "x" + } + ], + "displayName": "Lost", + "description": "The Lost template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Business / crm", + "type": "List", + "thumbnail": "thumbnails/lead-list.svg" + } + }, + { + "name": "Ambiguous", + "fitsSlot": "body", + "slots": [ + { + "name": "x" + } + ], + "displayName": "Ambiguous", + "description": "The Ambiguous template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Business / crm", + "type": "List", + "thumbnail": "thumbnails/lead-list.svg" + } + }, + { + "name": "Other", + "fitsSlot": "content", + "slots": [ + { + "name": "body" + } + ], + "displayName": "Other", + "description": "The Other template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Business / crm", + "type": "List", + "thumbnail": "thumbnails/lead-list.svg" + } + }, + { + "name": "Loop1", + "fitsSlot": "loop2", + "slots": [ + { + "name": "loop1" + } + ], + "displayName": "Loop1", + "description": "The Loop1 template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Business / crm", + "type": "List", + "thumbnail": "thumbnails/lead-list.svg" + } + }, + { + "name": "Loop2", + "fitsSlot": "loop1", + "slots": [ + { + "name": "loop2" + } + ], + "displayName": "Loop2", + "description": "The Loop2 template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Business / crm", + "type": "List", + "thumbnail": "thumbnails/lead-list.svg" + } + } + ], + "screens": [ + { + "name": "Leads", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + } + ], + "packs": [ + { + "id": "crm-en", + "title": "Pack crm-en", + "locale": "en-GB", + "owner": "blueprint:crm", + "sets": [ + { + "shape": "Customer", + "rows": [ + { + "id": "1", + "name": "Northgate Ltd" + } + ] + } + ] + } + ] + }, + "library": [ + { + "manifest": { + "name": "Cratis.Blueprint.Default", + "version": "4.32.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "AppShell" + ], + "screenTemplates": [ + "ListScreen" + ], + "dialogTemplates": [], + "themes": [] + }, + "layouts": [ + { + "name": "AppShell", + "slots": [ + { + "name": "topbar" + }, + { + "name": "content" + } + ], + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Foundation / Shell", + "type": "Shell" + } + } + ], + "screenTemplates": [ + { + "name": "ListScreen", + "fitsSlot": "content", + "slots": [ + { + "name": "body" + } + ], + "displayName": "ListScreen", + "description": "The ListScreen template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Foundation / List", + "type": "List" + } + } + ] + } + ], + "expectedProblems": [ + "Template 'Lost' is unplaced: no layout or template declares slot 'nowhere'", + "Template 'Loop1' is nested inside itself", + "Template 'Loop2' is nested inside itself" + ] + }, + { + "name": "a default sample data pack named while the manifest lists none", + "source": { + "manifest": { + "name": "@cratis/scene.blueprint.crm", + "version": "1.2.0", + "kind": "Blueprint", + "dependencies": [ + { + "name": "Cratis.Blueprint.Default", + "versionRange": "^4.0.0" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [ + "LeadList", + "LeadDetail" + ], + "dialogTemplates": [], + "themes": [], + "blueprint": { + "domain": "crm", + "description": "Leads, accounts and deals for a sales team.", + "thumbnail": "thumbnails/crm.svg", + "screenSet": [ + { + "name": "Leads", + "title": "Leads", + "template": "LeadList", + "order": 1 + }, + { + "name": "LeadDetail", + "title": "Lead", + "template": "LeadDetail", + "parent": "Leads" + }, + { + "name": "Dashboard", + "title": "Dashboard", + "order": 0 + } + ], + "defaultSampleData": "crm-en" + }, + "displayName": "CRM", + "license": "MIT" + }, + "screenTemplates": [ + { + "name": "LeadList", + "fitsSlot": "body", + "slots": [ + { + "name": "rows" + } + ], + "displayName": "LeadList", + "description": "The LeadList template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Business / crm", + "type": "List", + "thumbnail": "thumbnails/lead-list.svg" + } + }, + { + "name": "LeadDetail", + "fitsSlot": "body", + "slots": [ + { + "name": "panels" + } + ], + "displayName": "LeadDetail", + "description": "The LeadDetail template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Business / crm", + "type": "Detail", + "thumbnail": "thumbnails/lead-detail.svg" + } + } + ], + "files": [ + "thumbnails/crm.svg", + "thumbnails/lead-list.svg", + "thumbnails/lead-detail.svg", + "sampleData/crm-en.json" + ], + "screens": [ + { + "name": "Leads", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + }, + { + "name": "LeadDetail", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + }, + { + "name": "Dashboard", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + } + ] + }, + "library": [ + { + "manifest": { + "name": "Cratis.Blueprint.Default", + "version": "4.32.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "AppShell" + ], + "screenTemplates": [ + "ListScreen" + ], + "dialogTemplates": [], + "themes": [] + }, + "layouts": [ + { + "name": "AppShell", + "slots": [ + { + "name": "topbar" + }, + { + "name": "content" + } + ], + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Foundation / Shell", + "type": "Shell" + } + } + ], + "screenTemplates": [ + { + "name": "ListScreen", + "fitsSlot": "content", + "slots": [ + { + "name": "body" + } + ], + "displayName": "ListScreen", + "description": "The ListScreen template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Foundation / List", + "type": "List" + } + } + ] + } + ], + "expectedProblems": [ + "Blueprint '@cratis/scene.blueprint.crm' declares default sample data 'crm-en', which is not one of its sample data packs", + "Blueprint '@cratis/scene.blueprint.crm' ships no sample data pack" + ] + }, + { + "name": "a screen template without a display name or description key", + "source": { + "manifest": { + "name": "@cratis/scene.blueprint.crm", + "version": "1.2.0", + "kind": "Blueprint", + "dependencies": [ + { + "name": "Cratis.Blueprint.Default", + "versionRange": "^4.0.0" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [ + "LeadList", + "LeadDetail" + ], + "dialogTemplates": [], + "themes": [], + "sampleData": [ + { + "id": "crm-en", + "title": "Pack crm-en", + "locale": "en-GB", + "path": "sampleData/crm-en.json" + } + ], + "blueprint": { + "domain": "crm", + "description": "Leads, accounts and deals for a sales team.", + "thumbnail": "thumbnails/crm.svg", + "screenSet": [ + { + "name": "Leads", + "title": "Leads", + "template": "LeadList", + "order": 1 + }, + { + "name": "LeadDetail", + "title": "Lead", + "template": "LeadDetail", + "parent": "Leads" + }, + { + "name": "Dashboard", + "title": "Dashboard", + "order": 0 + } + ] + }, + "displayName": "CRM", + "license": "MIT" + }, + "screenTemplates": [ + { + "name": "LeadList", + "fitsSlot": "body", + "slots": [ + { + "name": "rows" + } + ], + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Business / crm", + "type": "List", + "thumbnail": "thumbnails/lead-list.svg" + } + }, + { + "name": "LeadDetail", + "fitsSlot": "body", + "slots": [ + { + "name": "panels" + } + ], + "displayName": "LeadDetail", + "description": "The LeadDetail template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Business / crm", + "type": "Detail", + "thumbnail": "thumbnails/lead-detail.svg" + } + } + ], + "files": [ + "thumbnails/crm.svg", + "thumbnails/lead-list.svg", + "thumbnails/lead-detail.svg", + "sampleData/crm-en.json" + ], + "screens": [ + { + "name": "Leads", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + }, + { + "name": "LeadDetail", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + }, + { + "name": "Dashboard", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + } + ], + "packs": [ + { + "id": "crm-en", + "title": "Pack crm-en", + "locale": "en-GB", + "owner": "blueprint:crm", + "sets": [ + { + "shape": "Customer", + "rows": [ + { + "id": "1", + "name": "Northgate Ltd" + } + ] + } + ] + } + ] + }, + "library": [ + { + "manifest": { + "name": "Cratis.Blueprint.Default", + "version": "4.32.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "AppShell" + ], + "screenTemplates": [ + "ListScreen" + ], + "dialogTemplates": [], + "themes": [] + }, + "layouts": [ + { + "name": "AppShell", + "slots": [ + { + "name": "topbar" + }, + { + "name": "content" + } + ], + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Foundation / Shell", + "type": "Shell" + } + } + ], + "screenTemplates": [ + { + "name": "ListScreen", + "fitsSlot": "content", + "slots": [ + { + "name": "body" + } + ], + "displayName": "ListScreen", + "description": "The ListScreen template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Foundation / List", + "type": "List" + } + } + ] + } + ], + "expectedProblems": [ + "Template 'LeadList' declares no display name", + "Template 'LeadList' declares no description" + ] } ], "catalog": [ From 5bbd4f75a1a751b90f139d3f79b87b052d4f50b1 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 11 Nov 2026 19:11:50 +0100 Subject: [PATCH 02/21] Pin the order blueprint verification reports its groups in --- blueprint-verification-fixtures.json | 207 +++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) diff --git a/blueprint-verification-fixtures.json b/blueprint-verification-fixtures.json index 3f0a371a..8a416f48 100644 --- a/blueprint-verification-fixtures.json +++ b/blueprint-verification-fixtures.json @@ -7417,6 +7417,213 @@ "Template 'LeadList' declares no display name", "Template 'LeadList' declares no description" ] + }, + { + "name": "a blueprint with problems in every group", + "source": { + "manifest": { + "name": "@cratis/scene.blueprint.crm", + "version": "one", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [], + "screenTemplates": [ + "LeadList", + "LeadDetail" + ], + "dialogTemplates": [], + "themes": [], + "sampleData": [ + { + "id": "crm-en", + "title": "", + "locale": "en-GB", + "path": "sampleData/crm-en.json" + } + ], + "blueprint": { + "domain": "", + "description": "", + "screenSet": [ + { + "name": "Leads", + "title": "Leads", + "template": "LeadList", + "order": 1 + }, + { + "name": "LeadDetail", + "title": "Lead", + "template": "LeadDetail", + "parent": "Leads" + }, + { + "name": "Dashboard", + "title": "", + "order": 0 + } + ], + "defaultSampleData": "zzz" + }, + "displayName": "CRM", + "license": "MIT" + }, + "screenTemplates": [ + { + "name": "LeadList", + "fitsSlot": "body", + "slots": [ + { + "name": "rows" + } + ], + "description": "The LeadList template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Business / crm", + "type": "List", + "thumbnail": "thumbnails/lead-list.svg" + } + }, + { + "name": "LeadDetail", + "fitsSlot": "nowhere", + "slots": [ + { + "name": "panels" + } + ], + "displayName": "LeadDetail", + "description": "The LeadDetail template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Business / crm", + "type": "Detail", + "thumbnail": "thumbnails/lead-detail.svg" + } + } + ], + "files": [ + "thumbnails/lead-list.svg", + "thumbnails/lead-detail.svg" + ], + "screens": [ + { + "name": "Leads", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + }, + { + "name": "LeadDetail", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + }, + { + "name": "Dashboard", + "layout": "AppShell", + "slotContent": {}, + "forms": [], + "contributions": [] + } + ] + }, + "library": [ + { + "manifest": { + "name": "Cratis.Blueprint.Default", + "version": "4.32.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "AppShell" + ], + "screenTemplates": [ + "ListScreen" + ], + "dialogTemplates": [], + "themes": [] + }, + "layouts": [ + { + "name": "AppShell", + "slots": [ + { + "name": "topbar" + }, + { + "name": "content" + } + ], + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Foundation / Shell", + "type": "Shell" + } + } + ], + "screenTemplates": [ + { + "name": "ListScreen", + "fitsSlot": "content", + "slots": [ + { + "name": "body" + } + ], + "displayName": "ListScreen", + "description": "The ListScreen template.", + "metadata": { + "compatibility": { + "scene": "^4.0.0" + }, + "attribution": { + "author": "Cratis" + }, + "license": "MIT", + "category": "Foundation / List", + "type": "List" + } + } + ] + } + ], + "expectedProblems": [ + "Blueprint '@cratis/scene.blueprint.crm' declares an invalid version 'one'", + "Blueprint '@cratis/scene.blueprint.crm' declares no domain", + "Blueprint '@cratis/scene.blueprint.crm' declares no description", + "Blueprint '@cratis/scene.blueprint.crm' declares no thumbnail", + "Blueprint '@cratis/scene.blueprint.crm' screen 'Dashboard' declares no title", + "Blueprint '@cratis/scene.blueprint.crm' declares default sample data 'zzz', which is not one of its sample data packs", + "Blueprint '@cratis/scene.blueprint.crm' does not depend on the foundation set 'Cratis.Blueprint.Default'", + "Package '@cratis/scene.blueprint.crm' sample data pack 'crm-en' declares no title", + "Blueprint '@cratis/scene.blueprint.crm' sample data pack 'crm-en' names 'sampleData/crm-en.json', which is not in the package", + "Template 'LeadList' declares no display name", + "Template 'LeadDetail' is unplaced: no layout or template declares slot 'nowhere'" + ] } ], "catalog": [ From 9f7f9230d832db6bd95786102172d4108c50003b Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 11 Nov 2026 19:13:21 +0100 Subject: [PATCH 03/21] Split blueprint verification into focused rule sets --- .../BlueprintMetadataVerification.cs | 119 +++++++ .../BlueprintPlacementVerification.cs | 66 ++++ .../BlueprintScreenSetVerification.cs | 114 +++++++ .../BlueprintTemplateVerification.cs | 132 ++++++++ .../Blueprints/BlueprintVerification.cs | 301 +----------------- .../blueprints/blueprintMetadataProblems.ts | 87 +++++ .../engine/blueprints/placementProblems.ts | 41 +++ .../engine/blueprints/screenSetProblems.ts | 83 +++++ .../engine/blueprints/templateProblems.ts | 90 ++++++ .../blueprints/verifyBlueprintPackage.ts | 191 ++--------- 10 files changed, 770 insertions(+), 454 deletions(-) create mode 100644 Source/DotNET/Engine/Blueprints/BlueprintMetadataVerification.cs create mode 100644 Source/DotNET/Engine/Blueprints/BlueprintPlacementVerification.cs create mode 100644 Source/DotNET/Engine/Blueprints/BlueprintScreenSetVerification.cs create mode 100644 Source/DotNET/Engine/Blueprints/BlueprintTemplateVerification.cs create mode 100644 Source/JavaScript/engine/blueprints/blueprintMetadataProblems.ts create mode 100644 Source/JavaScript/engine/blueprints/placementProblems.ts create mode 100644 Source/JavaScript/engine/blueprints/screenSetProblems.ts create mode 100644 Source/JavaScript/engine/blueprints/templateProblems.ts diff --git a/Source/DotNET/Engine/Blueprints/BlueprintMetadataVerification.cs b/Source/DotNET/Engine/Blueprints/BlueprintMetadataVerification.cs new file mode 100644 index 00000000..b6ecd417 --- /dev/null +++ b/Source/DotNET/Engine/Blueprints/BlueprintMetadataVerification.cs @@ -0,0 +1,119 @@ +// 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.Packages; + +namespace Cratis.Scene.Engine.Blueprints; + +/// +/// The rules for the metadata of a domain blueprint: its domain, description, thumbnail, screen set, sample data and its dependency on the foundation set. +/// +static class BlueprintMetadataVerification +{ + /// + /// Verifies the metadata of a domain blueprint. + /// + /// The blueprint. + /// How the blueprint is named in a problem. + /// The names of the screen templates available to it. + /// The package that provides the foundation set. + /// The problems. + internal static List Problems(BlueprintSource source, string label, HashSet availableTemplates, string foundation) + { + var blueprint = source.Manifest.Blueprint!; + var problems = DomainProblems(label, blueprint); + problems.AddRange(DescriptionProblems(label, blueprint)); + problems.AddRange(ThumbnailProblems(label, blueprint.Thumbnail, source.Files)); + problems.AddRange(BlueprintScreenSetVerification.Problems(source, label, availableTemplates)); + problems.AddRange(SampleDataDefaultProblems(label, blueprint, (source.Manifest.SampleData ?? []).Select(reference => reference.Id).ToList())); + problems.AddRange(FoundationDependencyProblems(source.Manifest, label, foundation)); + return problems; + } + + /// + /// Verifies a thumbnail reference. + /// + /// How the owner is named in a problem. + /// The thumbnail path, if any. + /// The files in the package, when known. + /// The problems. + internal static List ThumbnailProblems(string owner, string? thumbnail, IReadOnlyList? files) + { + if (string.IsNullOrWhiteSpace(thumbnail)) + { + return [$"{owner} declares no thumbnail"]; + } + + if (!BlueprintRules.IsValidThumbnailPath(thumbnail)) + { + return [$"{owner} declares thumbnail '{thumbnail}', which is not 'thumbnails/.png|webp|jpg|svg'"]; + } + + return files?.Contains(thumbnail) == false ? [$"{owner} declares thumbnail '{thumbnail}', which is not in the package"] : []; + } + + /// + /// Verifies that every sample data pack names a file in the package. + /// + /// The blueprint. + /// How the blueprint is named in a problem. + /// The problems. + internal static List SampleDataFileProblems(BlueprintSource source, string label) + { + var problems = new List(); + foreach (var reference in source.Manifest.SampleData ?? []) + { + if (source.Files?.Contains(reference.Path) == false) + { + problems.Add($"{label} sample data pack '{reference.Id}' names '{reference.Path}', which is not in the package"); + } + } + + return problems; + } + + static List DomainProblems(string label, BlueprintMetadata blueprint) + { + if (string.IsNullOrWhiteSpace(blueprint.Domain)) + { + return [$"{label} declares no domain"]; + } + + return BlueprintRules.IsValidDomain(blueprint.Domain) ? [] : [$"{label} declares an invalid domain '{blueprint.Domain}'; use lower-case words separated by '-'"]; + } + + static List DescriptionProblems(string label, BlueprintMetadata blueprint) + { + if (string.IsNullOrWhiteSpace(blueprint.Description)) + { + return [$"{label} declares no description"]; + } + + return blueprint.Description.Length > BlueprintRules.MaximumDescriptionLength ? [$"{label} declares a description longer than {BlueprintRules.MaximumDescriptionLength} characters"] : []; + } + + static List SampleDataDefaultProblems(string label, BlueprintMetadata blueprint, List referenceIds) + { + var problems = new List(); + if (blueprint.DefaultSampleData is not null && !referenceIds.Contains(blueprint.DefaultSampleData)) + { + problems.Add($"{label} declares default sample data '{blueprint.DefaultSampleData}', which is not one of its sample data packs"); + } + + if (referenceIds.Count == 0) + { + problems.Add($"{label} ships no sample data pack"); + } + else if (referenceIds.Count > 1 && blueprint.DefaultSampleData is null) + { + problems.Add($"{label} ships several sample data packs but declares no default"); + } + + return problems; + } + + static List FoundationDependencyProblems(ScenePackage manifest, string label, string foundation) => + manifest.Name != foundation && !manifest.Dependencies.Any(dependency => dependency.Name == foundation) + ? [$"{label} does not depend on the foundation set '{foundation}'"] + : []; +} diff --git a/Source/DotNET/Engine/Blueprints/BlueprintPlacementVerification.cs b/Source/DotNET/Engine/Blueprints/BlueprintPlacementVerification.cs new file mode 100644 index 00000000..7eebef55 --- /dev/null +++ b/Source/DotNET/Engine/Blueprints/BlueprintPlacementVerification.cs @@ -0,0 +1,66 @@ +// 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.Screens; +using Cratis.Scene.Engine.Templates; +using Cratis.Scene.Model.Screens; + +namespace Cratis.Scene.Engine.Blueprints; + +/// +/// The rules for placing the screen templates of a blueprint into the layouts available: placed somewhere, not nested in themselves and, for a domain blueprint, not ambiguous. +/// +static class BlueprintPlacementVerification +{ + /// + /// Verifies that the screen templates of a blueprint are placed. + /// + /// The blueprint. + /// The other packages available. + /// How the blueprint is named in a problem. + /// Whether the blueprint is a domain blueprint, for which ambiguity is a problem. + /// The problems. + internal static List Problems(BlueprintSource source, IReadOnlyList library, string label, bool domain) + { + var own = (source.ScreenTemplates ?? []).Where(template => template.FitsSlot is not null).ToList(); + if (own.Count == 0) + { + return []; + } + + var layouts = (source.Layouts ?? []).Concat(library.SelectMany(other => other.Layouts ?? [])).ToList(); + var everyTemplate = (source.ScreenTemplates ?? []).Concat(library.SelectMany(other => other.ScreenTemplates ?? [])).ToList(); + if (layouts.Count == 0) + { + return [$"{label} has no layout to place its screen templates in"]; + } + + var resolutions = layouts.ConvertAll(layout => ScreenTemplateResolver.Resolve(layout, everyTemplate)); + var problems = new List(); + foreach (var template in own.Where(template => !IsPlaced(template, resolutions))) + { + problems.AddRange(UnplacedTemplateProblems(template, resolutions, domain)); + } + + return problems; + } + + static bool IsPlaced(ScreenTemplate template, List resolutions) => + resolutions.Exists(resolution => resolution.Placements.Any(placement => placement.Template == template.Name)); + + static List UnplacedTemplateProblems(ScreenTemplate template, List resolutions, bool domain) + { + var unplaced = resolutions.SelectMany(resolution => resolution.Unplaced.Where(candidate => candidate.Template == template.Name)).ToList().Find(candidate => candidate.Candidates.Count > 1); + if (resolutions.Exists(resolution => resolution.Cycles.Any(cycle => cycle.Contains(template.Name)))) + { + return [$"Template '{template.Name}' is nested inside itself"]; + } + + if (unplaced is null) + { + return [$"Template '{template.Name}' is unplaced: no layout or template declares slot '{template.FitsSlot}'"]; + } + + return domain ? [$"Template '{template.Name}' is ambiguous: slot '{unplaced.Slot}' is declared by {string.Join(", ", unplaced.Candidates)}"] : []; + } +} diff --git a/Source/DotNET/Engine/Blueprints/BlueprintScreenSetVerification.cs b/Source/DotNET/Engine/Blueprints/BlueprintScreenSetVerification.cs new file mode 100644 index 00000000..cb51229b --- /dev/null +++ b/Source/DotNET/Engine/Blueprints/BlueprintScreenSetVerification.cs @@ -0,0 +1,114 @@ +// 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.Packages; + +namespace Cratis.Scene.Engine.Blueprints; + +/// +/// The rules for the ready-to-use screen set of a domain blueprint: named once, titled, built on available templates, parented inside the set and never its own ancestor. +/// +static class BlueprintScreenSetVerification +{ + /// + /// Verifies the screen set of a domain blueprint. + /// + /// The blueprint. + /// How the blueprint is named in a problem. + /// The names of the screen templates available to it. + /// The problems. + internal static List Problems(BlueprintSource source, string label, HashSet availableTemplates) + { + var set = source.Manifest.Blueprint!.ScreenSet ?? []; + if (set.Count == 0) + { + return [$"{label} declares no ready-to-use screen set"]; + } + + var names = new HashSet(StringComparer.Ordinal); + var problems = NameProblems(label, set, names); + var scope = new ScreenSetScope(source, label, names, availableTemplates); + foreach (var screen in set.Where(screen => !string.IsNullOrWhiteSpace(screen.Name))) + { + problems.AddRange(EntryProblems(scope, screen)); + } + + foreach (var screen in set.Where(screen => HasOwnAncestor(screen, names, set))) + { + problems.Add($"{label} screen '{screen.Name}' is its own ancestor"); + } + + return problems; + } + + static List NameProblems(string label, IReadOnlyList set, HashSet names) + { + var problems = new List(); + foreach (var screen in set) + { + if (string.IsNullOrWhiteSpace(screen.Name)) + { + problems.Add($"{label} declares a screen set entry without a name"); + continue; + } + + if (!names.Add(screen.Name)) + { + problems.Add($"{label} declares screen '{screen.Name}' more than once"); + } + } + + return problems; + } + + static List EntryProblems(ScreenSetScope scope, BlueprintScreen screen) + { + var problems = new List(); + var owner = $"{scope.Label} screen '{screen.Name}'"; + if (string.IsNullOrWhiteSpace(screen.Title)) + { + problems.Add($"{owner} declares no title"); + } + + if (screen.Template is not null && !scope.AvailableTemplates.Contains(screen.Template)) + { + problems.Add($"{owner} uses template '{screen.Template}', which is not available"); + } + + if (screen.Parent is not null && !scope.Names.Contains(screen.Parent)) + { + problems.Add($"{owner} has parent '{screen.Parent}', which is not in the screen set"); + } + + if (scope.Source.Screens?.Any(candidate => candidate.Name == screen.Name) == false) + { + problems.Add($"{owner} is named in the screen set but the package does not provide it"); + } + + return problems; + } + + static bool HasOwnAncestor(BlueprintScreen screen, HashSet names, IReadOnlyList set) => + screen.Parent is not null && names.Contains(screen.Parent) && IsOwnAncestor(screen.Name, set); + + static bool IsOwnAncestor(string name, IReadOnlyList set) + { + var visited = new HashSet(StringComparer.Ordinal); + for (var current = set.FirstOrDefault(screen => screen.Name == name)?.Parent; current is not null; current = set.FirstOrDefault(screen => screen.Name == current)?.Parent) + { + if (current == name) + { + return true; + } + + if (!visited.Add(current)) + { + return false; + } + } + + return false; + } + + sealed record ScreenSetScope(BlueprintSource Source, string Label, HashSet Names, HashSet AvailableTemplates); +} diff --git a/Source/DotNET/Engine/Blueprints/BlueprintTemplateVerification.cs b/Source/DotNET/Engine/Blueprints/BlueprintTemplateVerification.cs new file mode 100644 index 00000000..93e7409c --- /dev/null +++ b/Source/DotNET/Engine/Blueprints/BlueprintTemplateVerification.cs @@ -0,0 +1,132 @@ +// 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.Engine.Templates; +using Cratis.Scene.Model.Screens; + +namespace Cratis.Scene.Engine.Blueprints; + +/// +/// The rules for every template a blueprint provides: what the catalog reports, a display name and description, the shell card a default menu mode implies and, for a domain blueprint, a type, a business category and a thumbnail. +/// +static class BlueprintTemplateVerification +{ + /// + /// Verifies the templates a blueprint provides. + /// + /// The blueprint. + /// The other packages available. + /// Whether the blueprint is a domain blueprint. + /// The problems. + internal static List Problems(BlueprintSource source, IReadOnlyList library, bool domain) + { + var problems = CatalogProblems(source, library); + foreach (var entry in EveryTemplate(source)) + { + var owner = $"Template '{entry.Name}'"; + if (entry.Describable) + { + problems.AddRange(DescribedTemplateProblems(owner, entry)); + } + + problems.AddRange(ShellCardProblems(owner, entry, source.Files)); + + if (domain) + { + problems.AddRange(DomainTemplateProblems(owner, entry.Metadata, source.Files)); + } + } + + return problems; + } + + static IEnumerable EveryTemplate(BlueprintSource source) => + (source.Layouts ?? []).Select(layout => new TemplateEntry(layout.Name, null, null, layout.Metadata, false)) + .Concat((source.ScreenTemplates ?? []).Select(template => new TemplateEntry(template.Name, template.DisplayName, template.Description, template.Metadata, true))) + .Concat((source.DialogTemplates ?? []).Select(template => new TemplateEntry(template.Name, template.DisplayName, template.Description, template.Metadata, true))); + + static List CatalogProblems(BlueprintSource source, IReadOnlyList library) + { + var problems = new List(); + var others = library.Where(other => other.Manifest.Name != source.Manifest.Name); + foreach (var entry in TemplateCatalog.Describe([source.AsTemplateSource(), .. others]).Where(candidate => candidate.Package == source.Manifest.Name)) + { + problems.AddRange(entry.Problems); + } + + return problems; + } + + static List DescribedTemplateProblems(string owner, TemplateEntry entry) + { + var problems = new List(); + if (string.IsNullOrWhiteSpace(entry.DisplayName)) + { + problems.Add($"{owner} declares no display name"); + } + + if (string.IsNullOrWhiteSpace(entry.Description)) + { + problems.Add($"{owner} declares no description"); + } + + return problems; + } + + static List DomainTemplateProblems(string owner, TemplateMetadata? metadata, IReadOnlyList? files) + { + var problems = TypeProblems(owner, metadata); + problems.AddRange(CategoryProblems(owner, metadata)); + problems.AddRange(BlueprintMetadataVerification.ThumbnailProblems(owner, metadata?.Thumbnail, files)); + return problems; + } + + static List TypeProblems(string owner, TemplateMetadata? metadata) + { + if (metadata?.Type is null) + { + return [$"{owner} declares no type"]; + } + + return BlueprintRules.TemplateTypes.Contains(metadata.Type) ? [] : [$"{owner} declares type '{metadata.Type}', which is not one of {string.Join(", ", BlueprintRules.TemplateTypes)}"]; + } + + static List CategoryProblems(string owner, TemplateMetadata? metadata) => + metadata?.Category is { } category && IsOutsideBusinessCategories(category) + ? [$"{owner} declares category '{category}', which does not start with '{BlueprintRules.BusinessCategoryPrefix}'"] + : []; + + static bool IsOutsideBusinessCategories(string category) => + category.Trim().Length > 0 && !category.StartsWith(BlueprintRules.BusinessCategoryPrefix, StringComparison.Ordinal); + + static List ShellCardProblems(string owner, TemplateEntry entry, IReadOnlyList? files) + { + var problems = new List(); + if (entry.Metadata?.DefaultMenuMode is not { } mode) + { + return problems; + } + + var metadata = entry.Metadata; + if (ShellOptions.Normalize(mode) is null) + { + problems.Add($"{owner} declares default menu mode '{mode}', which is not one of {string.Join(", ", ShellOptions.NavigationModes.Select(candidate => char.ToLowerInvariant(candidate.Id.ToString()[0]) + candidate.Id.ToString()[1..]))}"); + } + + if (string.IsNullOrWhiteSpace(metadata.DisplayName ?? entry.DisplayName)) + { + problems.Add($"{owner} is offered as a shell card but declares no title"); + } + + if (string.IsNullOrWhiteSpace(metadata.Description ?? entry.Description)) + { + problems.Add($"{owner} is offered as a shell card but declares no description"); + } + + problems.AddRange(BlueprintMetadataVerification.ThumbnailProblems(owner, metadata.Thumbnail, files)); + return problems; + } + + sealed record TemplateEntry(string Name, string? DisplayName, string? Description, TemplateMetadata? Metadata, bool Describable); +} diff --git a/Source/DotNET/Engine/Blueprints/BlueprintVerification.cs b/Source/DotNET/Engine/Blueprints/BlueprintVerification.cs index 10bcb7e2..aaac4ce4 100644 --- a/Source/DotNET/Engine/Blueprints/BlueprintVerification.cs +++ b/Source/DotNET/Engine/Blueprints/BlueprintVerification.cs @@ -2,12 +2,9 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using Cratis.Scene.Engine.Packages; -using Cratis.Scene.Engine.Profiles; using Cratis.Scene.Engine.SampleData; -using Cratis.Scene.Engine.Screens; using Cratis.Scene.Engine.Templates; using Cratis.Scene.Model.Packages; -using Cratis.Scene.Model.Screens; namespace Cratis.Scene.Engine.Blueprints; @@ -34,305 +31,37 @@ public static IReadOnlyList Verify(BlueprintSource source, IReadOnlyList library ??= []; options ??= new(); var manifest = source.Manifest; - var name = manifest.Name; - var label = $"Blueprint '{name}'"; - var foundation = options.FoundationPackage ?? BlueprintRules.FoundationPackageName; + var label = $"Blueprint '{manifest.Name}'"; if (manifest.Kind != PackageKind.Blueprint) { - return [$"Package '{name}' is not a Blueprint package"]; - } - - var problems = new List(); - if (!PackageVersionRange.IsValidVersion(manifest.Version)) - { - problems.Add($"{label} declares an invalid version '{manifest.Version ?? string.Empty}'"); + return [$"Package '{manifest.Name}' is not a Blueprint package"]; } + var problems = VersionProblems(label, manifest); var blueprint = manifest.Blueprint; var domain = blueprint is not null; - var available = (source.ScreenTemplates ?? []).Concat(library.SelectMany(other => other.ScreenTemplates ?? [])).Select(template => template.Name).ToHashSet(StringComparer.Ordinal); - var references = manifest.SampleData ?? []; - - if (blueprint is null) - { - if (options.RequireBlueprintMetadata) - { - problems.Add($"{label} declares no blueprint metadata (domain, description, thumbnail and screen set)"); - } - } - else - { - if (string.IsNullOrWhiteSpace(blueprint.Domain)) - { - problems.Add($"{label} declares no domain"); - } - else if (!BlueprintRules.IsValidDomain(blueprint.Domain)) - { - problems.Add($"{label} declares an invalid domain '{blueprint.Domain}'; use lower-case words separated by '-'"); - } - - if (string.IsNullOrWhiteSpace(blueprint.Description)) - { - problems.Add($"{label} declares no description"); - } - else if (blueprint.Description.Length > BlueprintRules.MaximumDescriptionLength) - { - problems.Add($"{label} declares a description longer than {BlueprintRules.MaximumDescriptionLength} characters"); - } - - problems.AddRange(ThumbnailProblems(label, blueprint.Thumbnail, source.Files)); - problems.AddRange(ScreenSetProblems(source, label, available)); - - if (blueprint.DefaultSampleData is not null && !references.Any(reference => reference.Id == blueprint.DefaultSampleData)) - { - problems.Add($"{label} declares default sample data '{blueprint.DefaultSampleData}', which is not one of its sample data packs"); - } - - if (references.Count == 0) - { - problems.Add($"{label} ships no sample data pack"); - } - else if (references.Count > 1 && blueprint.DefaultSampleData is null) - { - problems.Add($"{label} ships several sample data packs but declares no default"); - } - - if (name != foundation && !manifest.Dependencies.Any(dependency => dependency.Name == foundation)) - { - problems.Add($"{label} does not depend on the foundation set '{foundation}'"); - } - } + problems.AddRange(BlueprintProblems(source, library, label, options)); problems.AddRange(SampleDataValidation.ValidatePackages([new SampleDataPackSource(manifest, source.Packs)])); - foreach (var reference in references) - { - if (source.Files?.Contains(reference.Path) == false) - { - problems.Add($"{label} sample data pack '{reference.Id}' names '{reference.Path}', which is not in the package"); - } - } - - problems.AddRange(TemplateProblems(source, library, domain)); - problems.AddRange(PlacementProblems(source, library, label, domain)); - return problems; - } - - static List ThumbnailProblems(string owner, string? thumbnail, IReadOnlyList? files) - { - if (string.IsNullOrWhiteSpace(thumbnail)) - { - return [$"{owner} declares no thumbnail"]; - } - - if (!BlueprintRules.IsValidThumbnailPath(thumbnail)) - { - return [$"{owner} declares thumbnail '{thumbnail}', which is not 'thumbnails/.png|webp|jpg|svg'"]; - } - - return files?.Contains(thumbnail) == false ? [$"{owner} declares thumbnail '{thumbnail}', which is not in the package"] : []; - } - - static List ScreenSetProblems(BlueprintSource source, string label, HashSet availableTemplates) - { - var set = source.Manifest.Blueprint!.ScreenSet ?? []; - if (set.Count == 0) - { - return [$"{label} declares no ready-to-use screen set"]; - } - - var problems = new List(); - var names = new HashSet(StringComparer.Ordinal); - foreach (var screen in set) - { - if (string.IsNullOrWhiteSpace(screen.Name)) - { - problems.Add($"{label} declares a screen set entry without a name"); - continue; - } - - if (names.Contains(screen.Name)) - { - problems.Add($"{label} declares screen '{screen.Name}' more than once"); - } - - names.Add(screen.Name); - } - - foreach (var screen in set.Where(screen => !string.IsNullOrWhiteSpace(screen.Name))) - { - var owner = $"{label} screen '{screen.Name}'"; - if (string.IsNullOrWhiteSpace(screen.Title)) - { - problems.Add($"{owner} declares no title"); - } - - if (screen.Template is not null && !availableTemplates.Contains(screen.Template)) - { - problems.Add($"{owner} uses template '{screen.Template}', which is not available"); - } - - if (screen.Parent is not null && !names.Contains(screen.Parent)) - { - problems.Add($"{owner} has parent '{screen.Parent}', which is not in the screen set"); - } - - if (source.Screens?.Any(candidate => candidate.Name == screen.Name) == false) - { - problems.Add($"{owner} is named in the screen set but the package does not provide it"); - } - } - - foreach (var screen in set) - { - if (screen.Parent is not null && names.Contains(screen.Parent) && IsOwnAncestor(screen.Name, set)) - { - problems.Add($"{label} screen '{screen.Name}' is its own ancestor"); - } - } - - return problems; - } - - static bool IsOwnAncestor(string name, IReadOnlyList set) - { - var visited = new HashSet(StringComparer.Ordinal); - for (var current = set.FirstOrDefault(screen => screen.Name == name)?.Parent; current is not null; current = set.FirstOrDefault(screen => screen.Name == current)?.Parent) - { - if (current == name) - { - return true; - } - - if (!visited.Add(current)) - { - return false; - } - } - - return false; - } - - static List TemplateProblems(BlueprintSource source, IReadOnlyList library, bool domain) - { - var problems = new List(); - var everyTemplate = (source.Layouts ?? []).Select(layout => (layout.Name, DisplayName: (string?)null, Description: (string?)null, layout.Metadata, Describable: false)) - .Concat((source.ScreenTemplates ?? []).Select(template => (template.Name, template.DisplayName, template.Description, template.Metadata, Describable: true))) - .Concat((source.DialogTemplates ?? []).Select(template => (template.Name, template.DisplayName, template.Description, template.Metadata, Describable: true))); - - var others = library.Where(other => other.Manifest.Name != source.Manifest.Name); - foreach (var entry in TemplateCatalog.Describe([source.AsTemplateSource(), .. others]).Where(candidate => candidate.Package == source.Manifest.Name)) - { - problems.AddRange(entry.Problems); - } - - foreach (var (templateName, displayName, description, metadata, describable) in everyTemplate) - { - var owner = $"Template '{templateName}'"; - if (describable && string.IsNullOrWhiteSpace(displayName)) - { - problems.Add($"{owner} declares no display name"); - } - - if (describable && string.IsNullOrWhiteSpace(description)) - { - problems.Add($"{owner} declares no description"); - } - - problems.AddRange(ShellCardProblems(owner, displayName, description, metadata, source.Files)); - - if (!domain) - { - continue; - } - - if (metadata?.Type is null) - { - problems.Add($"{owner} declares no type"); - } - else if (!BlueprintRules.TemplateTypes.Contains(metadata.Type)) - { - problems.Add($"{owner} declares type '{metadata.Type}', which is not one of {string.Join(", ", BlueprintRules.TemplateTypes)}"); - } - - if (metadata?.Category is { } category && category.Trim().Length > 0 && !category.StartsWith(BlueprintRules.BusinessCategoryPrefix, StringComparison.Ordinal)) - { - problems.Add($"{owner} declares category '{category}', which does not start with '{BlueprintRules.BusinessCategoryPrefix}'"); - } - - problems.AddRange(ThumbnailProblems(owner, metadata?.Thumbnail, source.Files)); - } - + problems.AddRange(BlueprintMetadataVerification.SampleDataFileProblems(source, label)); + problems.AddRange(BlueprintTemplateVerification.Problems(source, library, domain)); + problems.AddRange(BlueprintPlacementVerification.Problems(source, library, label, domain)); return problems; } - static List ShellCardProblems(string owner, string? displayName, string? description, TemplateMetadata? metadata, IReadOnlyList? files) - { - var problems = new List(); - if (metadata?.DefaultMenuMode is not { } mode) - { - return problems; - } - - if (ShellOptions.Normalize(mode) is null) - { - problems.Add($"{owner} declares default menu mode '{mode}', which is not one of {string.Join(", ", ShellOptions.NavigationModes.Select(candidate => char.ToLowerInvariant(candidate.Id.ToString()[0]) + candidate.Id.ToString()[1..]))}"); - } - - if (string.IsNullOrWhiteSpace(metadata.DisplayName ?? displayName)) - { - problems.Add($"{owner} is offered as a shell card but declares no title"); - } - - if (string.IsNullOrWhiteSpace(metadata.Description ?? description)) - { - problems.Add($"{owner} is offered as a shell card but declares no description"); - } - - problems.AddRange(ThumbnailProblems(owner, metadata.Thumbnail, files)); - return problems; - } + static List VersionProblems(string label, ScenePackage manifest) => + PackageVersionRange.IsValidVersion(manifest.Version) ? [] : [$"{label} declares an invalid version '{manifest.Version ?? string.Empty}'"]; - static List PlacementProblems(BlueprintSource source, IReadOnlyList library, string label, bool domain) + static List BlueprintProblems(BlueprintSource source, IReadOnlyList library, string label, BlueprintVerificationOptions options) { - var own = (source.ScreenTemplates ?? []).Where(template => template.FitsSlot is not null).ToList(); - if (own.Count == 0) - { - return []; - } - - var layouts = (source.Layouts ?? []).Concat(library.SelectMany(other => other.Layouts ?? [])).ToList(); - var everyTemplate = (source.ScreenTemplates ?? []).Concat(library.SelectMany(other => other.ScreenTemplates ?? [])).ToList(); - if (layouts.Count == 0) - { - return [$"{label} has no layout to place its screen templates in"]; - } - - var resolutions = layouts.ConvertAll(layout => ScreenTemplateResolver.Resolve(layout, everyTemplate)); - var problems = new List(); - - foreach (var template in own) + if (source.Manifest.Blueprint is null) { - if (resolutions.Exists(resolution => resolution.Placements.Any(placement => placement.Template == template.Name))) - { - continue; - } - - var unplaced = resolutions.SelectMany(resolution => resolution.Unplaced.Where(candidate => candidate.Template == template.Name)).ToList().Find(candidate => candidate.Candidates.Count > 1); - if (resolutions.Exists(resolution => resolution.Cycles.Any(cycle => cycle.Contains(template.Name)))) - { - problems.Add($"Template '{template.Name}' is nested inside itself"); - } - else if (unplaced is not null && domain) - { - problems.Add($"Template '{template.Name}' is ambiguous: slot '{unplaced.Slot}' is declared by {string.Join(", ", unplaced.Candidates)}"); - } - else if (unplaced is null) - { - problems.Add($"Template '{template.Name}' is unplaced: no layout or template declares slot '{template.FitsSlot}'"); - } + return options.RequireBlueprintMetadata ? [$"{label} declares no blueprint metadata (domain, description, thumbnail and screen set)"] : []; } - return problems; + var available = (source.ScreenTemplates ?? []).Concat(library.SelectMany(other => other.ScreenTemplates ?? [])).Select(template => template.Name).ToHashSet(StringComparer.Ordinal); + var foundation = options.FoundationPackage ?? BlueprintRules.FoundationPackageName; + return BlueprintMetadataVerification.Problems(source, label, available, foundation); } } diff --git a/Source/JavaScript/engine/blueprints/blueprintMetadataProblems.ts b/Source/JavaScript/engine/blueprints/blueprintMetadataProblems.ts new file mode 100644 index 00000000..a28a8e26 --- /dev/null +++ b/Source/JavaScript/engine/blueprints/blueprintMetadataProblems.ts @@ -0,0 +1,87 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { BlueprintSource } from './BlueprintSource'; +import { isValidBlueprintDomain, isValidThumbnailPath, maximumBlueprintDescriptionLength } from './blueprintRules'; +import { screenSetProblems } from './screenSetProblems'; + +type Blueprint = NonNullable; + +/** + * The rules for the metadata of a domain blueprint: its domain, description, thumbnail, screen set, sample data and its dependency on the foundation set. + * + * @param source The blueprint. + * @param label How the blueprint is named in a problem. + * @param availableTemplates The names of the screen templates available to it. + * @param foundation The package that provides the foundation set. + * @returns The problems. + */ +export function blueprintMetadataProblems(source: BlueprintSource, label: string, availableTemplates: Set, foundation: string): string[] { + const blueprint = source.manifest.blueprint!; + return [ + ...domainProblems(label, blueprint), + ...descriptionProblems(label, blueprint), + ...thumbnailProblems(label, blueprint.thumbnail, source.files), + ...screenSetProblems(source, label, availableTemplates), + ...sampleDataDefaultProblems(label, blueprint, (source.manifest.sampleData ?? []).map(reference => reference.id)), + ...foundationDependencyProblems(source.manifest, label, foundation), + ]; +} + +/** + * Verifies a thumbnail reference. + * + * @param owner How the owner is named in a problem. + * @param thumbnail The thumbnail path, if any. + * @param files The files in the package, when known. + * @returns The problems. + */ +export function thumbnailProblems(owner: string, thumbnail: string | undefined, files: string[] | undefined): string[] { + if ((thumbnail ?? '').trim() === '') return [`${owner} declares no thumbnail`]; + if (!isValidThumbnailPath(thumbnail)) return [`${owner} declares thumbnail '${thumbnail}', which is not 'thumbnails/.png|webp|jpg|svg'`]; + if (files !== undefined && !files.includes(thumbnail!)) return [`${owner} declares thumbnail '${thumbnail}', which is not in the package`]; + return []; +} + +/** + * Verifies that every sample data pack names a file in the package. + * + * @param source The blueprint. + * @param label How the blueprint is named in a problem. + * @returns The problems. + */ +export function sampleDataFileProblems(source: BlueprintSource, label: string): string[] { + const problems: string[] = []; + for (const reference of source.manifest.sampleData ?? []) { + if (source.files !== undefined && !source.files.includes(reference.path)) problems.push(`${label} sample data pack '${reference.id}' names '${reference.path}', which is not in the package`); + } + + return problems; +} + +function domainProblems(label: string, blueprint: Blueprint): string[] { + if ((blueprint.domain ?? '').trim() === '') return [`${label} declares no domain`]; + return isValidBlueprintDomain(blueprint.domain) ? [] : [`${label} declares an invalid domain '${blueprint.domain}'; use lower-case words separated by '-'`]; +} + +function descriptionProblems(label: string, blueprint: Blueprint): string[] { + if ((blueprint.description ?? '').trim() === '') return [`${label} declares no description`]; + return blueprint.description.length > maximumBlueprintDescriptionLength ? [`${label} declares a description longer than ${maximumBlueprintDescriptionLength} characters`] : []; +} + +function sampleDataDefaultProblems(label: string, blueprint: Blueprint, referenceIds: string[]): string[] { + const problems: string[] = []; + if (blueprint.defaultSampleData !== undefined && !referenceIds.includes(blueprint.defaultSampleData)) { + problems.push(`${label} declares default sample data '${blueprint.defaultSampleData}', which is not one of its sample data packs`); + } + + if (referenceIds.length === 0) problems.push(`${label} ships no sample data pack`); + else if (referenceIds.length > 1 && blueprint.defaultSampleData === undefined) problems.push(`${label} ships several sample data packs but declares no default`); + return problems; +} + +function foundationDependencyProblems(manifest: BlueprintSource['manifest'], label: string, foundation: string): string[] { + return manifest.name !== foundation && !manifest.dependencies.some(dependency => dependency.name === foundation) + ? [`${label} does not depend on the foundation set '${foundation}'`] + : []; +} diff --git a/Source/JavaScript/engine/blueprints/placementProblems.ts b/Source/JavaScript/engine/blueprints/placementProblems.ts new file mode 100644 index 00000000..c2c99c6d --- /dev/null +++ b/Source/JavaScript/engine/blueprints/placementProblems.ts @@ -0,0 +1,41 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ScreenTemplate } from '@cratis/scene.model'; +import { ScreenTemplateResolution, resolveScreenTemplates } from '../resolveScreenTemplates'; +import { TemplateSource } from '../templates/TemplateSource'; +import { BlueprintSource } from './BlueprintSource'; + +/** + * The rules for placing the screen templates of a blueprint into the layouts available: placed somewhere, not nested in themselves and, for a domain blueprint, not ambiguous. + * + * @param source The blueprint. + * @param library The other packages available. + * @param label How the blueprint is named in a problem. + * @param domain Whether the blueprint is a domain blueprint, for which ambiguity is a problem. + * @returns The problems. + */ +export function placementProblems(source: BlueprintSource, library: readonly TemplateSource[], label: string, domain: boolean): string[] { + const own: ScreenTemplate[] = (source.screenTemplates ?? []).filter(template => template.fitsSlot !== undefined); + if (own.length === 0) return []; + + const layouts = [...(source.layouts ?? []), ...library.flatMap(other => other.layouts ?? [])]; + const everyTemplate = [...(source.screenTemplates ?? []), ...library.flatMap(other => other.screenTemplates ?? [])]; + if (layouts.length === 0) return [`${label} has no layout to place its screen templates in`]; + + const resolutions = layouts.map(layout => resolveScreenTemplates(layout, everyTemplate)); + return own.filter(template => !isPlaced(template, resolutions)).flatMap(template => unplacedTemplateProblems(template, resolutions, domain)); +} + +function isPlaced(template: ScreenTemplate, resolutions: ScreenTemplateResolution[]): boolean { + return resolutions.some(resolution => resolution.placements.some(placement => placement.template === template.name)); +} + +function unplacedTemplateProblems(template: ScreenTemplate, resolutions: ScreenTemplateResolution[], domain: boolean): string[] { + const unplacedEverywhere = resolutions.flatMap(resolution => resolution.unplaced.filter(candidate => candidate.template === template.name)); + const unplaced = unplacedEverywhere.find(candidate => candidate.candidates.length > 1); + const cyclic = resolutions.some(resolution => resolution.cycles.some(cycle => cycle.includes(template.name))); + if (cyclic) return [`Template '${template.name}' is nested inside itself`]; + if (!unplaced) return [`Template '${template.name}' is unplaced: no layout or template declares slot '${template.fitsSlot}'`]; + return domain ? [`Template '${template.name}' is ambiguous: slot '${unplaced.slot}' is declared by ${unplaced.candidates.join(', ')}`] : []; +} diff --git a/Source/JavaScript/engine/blueprints/screenSetProblems.ts b/Source/JavaScript/engine/blueprints/screenSetProblems.ts new file mode 100644 index 00000000..829afd25 --- /dev/null +++ b/Source/JavaScript/engine/blueprints/screenSetProblems.ts @@ -0,0 +1,83 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { BlueprintSource } from './BlueprintSource'; + +type ScreenSetEntry = { name: string; title?: string; template?: string; parent?: string }; + +interface ScreenSetScope { + source: BlueprintSource; + label: string; + names: Set; + availableTemplates: Set; +} + +/** + * The rules for the ready-to-use screen set of a domain blueprint: named once, titled, built on available templates, parented inside the set and never its own ancestor. + * + * @param source The blueprint. + * @param label How the blueprint is named in a problem. + * @param availableTemplates The names of the screen templates available to it. + * @returns The problems. + */ +export function screenSetProblems(source: BlueprintSource, label: string, availableTemplates: Set): string[] { + const set: ScreenSetEntry[] = source.manifest.blueprint!.screenSet ?? []; + if (set.length === 0) return [`${label} declares no ready-to-use screen set`]; + + const names = new Set(); + const problems = nameProblems(label, set, names); + const scope: ScreenSetScope = { source, label, names, availableTemplates }; + for (const screen of set) { + if (!isBlank(screen.name)) problems.push(...entryProblems(scope, screen)); + } + + for (const screen of set) { + if (hasOwnAncestor(screen, names, set)) problems.push(`${label} screen '${screen.name}' is its own ancestor`); + } + + return problems; +} + +function isBlank(value: string | undefined): boolean { + return (value ?? '').trim() === ''; +} + +function nameProblems(label: string, set: ScreenSetEntry[], names: Set): string[] { + const problems: string[] = []; + for (const screen of set) { + if (isBlank(screen.name)) { + problems.push(`${label} declares a screen set entry without a name`); + continue; + } + + if (names.has(screen.name)) problems.push(`${label} declares screen '${screen.name}' more than once`); + names.add(screen.name); + } + + return problems; +} + +function entryProblems(scope: ScreenSetScope, screen: ScreenSetEntry): string[] { + const problems: string[] = []; + const owner = `${scope.label} screen '${screen.name}'`; + if (isBlank(screen.title)) problems.push(`${owner} declares no title`); + if (screen.template !== undefined && !scope.availableTemplates.has(screen.template)) problems.push(`${owner} uses template '${screen.template}', which is not available`); + if (screen.parent !== undefined && !scope.names.has(screen.parent)) problems.push(`${owner} has parent '${screen.parent}', which is not in the screen set`); + if (scope.source.screens !== undefined && !scope.source.screens.some(candidate => candidate.name === screen.name)) problems.push(`${owner} is named in the screen set but the package does not provide it`); + return problems; +} + +function hasOwnAncestor(screen: ScreenSetEntry, names: Set, set: ScreenSetEntry[]): boolean { + return screen.parent !== undefined && names.has(screen.parent) && isOwnAncestor(screen.name, set); +} + +function isOwnAncestor(name: string, set: ScreenSetEntry[]): boolean { + const visited = new Set(); + for (let current = set.find(screen => screen.name === name)?.parent; current !== undefined; current = set.find(screen => screen.name === current)?.parent) { + if (current === name) return true; + if (visited.has(current)) return false; + visited.add(current); + } + + return false; +} diff --git a/Source/JavaScript/engine/blueprints/templateProblems.ts b/Source/JavaScript/engine/blueprints/templateProblems.ts new file mode 100644 index 00000000..ade98495 --- /dev/null +++ b/Source/JavaScript/engine/blueprints/templateProblems.ts @@ -0,0 +1,90 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { navigationModes, normalizeNavigationMode } from '../profiles/shellOptions'; +import { describeTemplateCatalog } from '../templates/describeTemplateCatalog'; +import { TemplateSource } from '../templates/TemplateSource'; +import { BlueprintSource } from './BlueprintSource'; +import { thumbnailProblems } from './blueprintMetadataProblems'; +import { blueprintTemplateTypes, businessCategoryPrefix } from './blueprintRules'; + +type TemplateMetadata = { type?: string; category?: string; thumbnail?: string; defaultMenuMode?: string; displayName?: string; description?: string }; + +interface TemplateEntry { + name: string; + displayName?: string; + description?: string; + metadata?: TemplateMetadata; + describable: boolean; +} + +/** + * The rules for every template a blueprint provides: what the catalog reports, a display name and description, the shell card a default menu mode implies and, for a domain blueprint, a type, a business category and a thumbnail. + * + * @param source The blueprint. + * @param library The other packages available. + * @param domain Whether the blueprint is a domain blueprint. + * @returns The problems. + */ +export function templateProblems(source: BlueprintSource, library: readonly TemplateSource[], domain: boolean): string[] { + const problems = catalogProblems(source, library); + for (const template of everyTemplate(source)) { + const owner = `Template '${template.name}'`; + if (template.describable) problems.push(...describedTemplateProblems(owner, template)); + problems.push(...shellCardProblems(owner, template, source.files)); + if (domain) problems.push(...domainTemplateProblems(owner, template.metadata, source.files)); + } + + return problems; +} + +function everyTemplate(source: BlueprintSource): TemplateEntry[] { + const described = [...(source.screenTemplates ?? []), ...(source.dialogTemplates ?? [])]; + return [ + ...(source.layouts ?? []).map(layout => ({ name: layout.name, metadata: layout.metadata, describable: false })), + ...described.map(template => ({ name: template.name, displayName: template.displayName, description: template.description, metadata: template.metadata, describable: true })), + ]; +} + +function catalogProblems(source: BlueprintSource, library: readonly TemplateSource[]): string[] { + const others = library.filter(other => other.manifest.name !== source.manifest.name); + return describeTemplateCatalog([source, ...others]).filter(candidate => candidate.package === source.manifest.name).flatMap(entry => entry.problems); +} + +function describedTemplateProblems(owner: string, template: TemplateEntry): string[] { + const problems: string[] = []; + if ((template.displayName ?? '').trim() === '') problems.push(`${owner} declares no display name`); + if ((template.description ?? '').trim() === '') problems.push(`${owner} declares no description`); + return problems; +} + +function domainTemplateProblems(owner: string, metadata: TemplateMetadata | undefined, files: string[] | undefined): string[] { + return [...typeProblems(owner, metadata), ...categoryProblems(owner, metadata), ...thumbnailProblems(owner, metadata?.thumbnail, files)]; +} + +function typeProblems(owner: string, metadata: TemplateMetadata | undefined): string[] { + if (metadata?.type === undefined) return [`${owner} declares no type`]; + return blueprintTemplateTypes.includes(metadata.type) ? [] : [`${owner} declares type '${metadata.type}', which is not one of ${blueprintTemplateTypes.join(', ')}`]; +} + +function categoryProblems(owner: string, metadata: TemplateMetadata | undefined): string[] { + const category = metadata?.category; + return category !== undefined && isOutsideBusinessCategories(category) ? [`${owner} declares category '${category}', which does not start with '${businessCategoryPrefix}'`] : []; +} + +function isOutsideBusinessCategories(category: string): boolean { + return category.trim() !== '' && !category.startsWith(businessCategoryPrefix); +} + +/** A shell that declares a default menu mode is offered as a card, so it needs a valid mode, a title, a description and a wireframe thumbnail. */ +function shellCardProblems(owner: string, template: TemplateEntry, files: string[] | undefined): string[] { + const mode = template.metadata?.defaultMenuMode; + if (mode === undefined) return []; + + const problems: string[] = []; + if (normalizeNavigationMode(mode) === undefined) problems.push(`${owner} declares default menu mode '${mode}', which is not one of ${navigationModes.map(candidate => candidate.id).join(', ')}`); + if ((template.metadata?.displayName ?? template.displayName ?? '').trim() === '') problems.push(`${owner} is offered as a shell card but declares no title`); + if ((template.metadata?.description ?? template.description ?? '').trim() === '') problems.push(`${owner} is offered as a shell card but declares no description`); + problems.push(...thumbnailProblems(owner, template.metadata?.thumbnail, files)); + return problems; +} diff --git a/Source/JavaScript/engine/blueprints/verifyBlueprintPackage.ts b/Source/JavaScript/engine/blueprints/verifyBlueprintPackage.ts index 6817f36a..05a59584 100644 --- a/Source/JavaScript/engine/blueprints/verifyBlueprintPackage.ts +++ b/Source/JavaScript/engine/blueprints/verifyBlueprintPackage.ts @@ -1,17 +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 { PackageKind, ScreenTemplate } from '@cratis/scene.model'; +import { PackageKind } from '@cratis/scene.model'; import { isValidVersion } from '../packageVersionRange'; -import { navigationModes, normalizeNavigationMode } from '../profiles/shellOptions'; -import { resolveScreenTemplates } from '../resolveScreenTemplates'; import { validateSampleDataPackages } from '../sampleData/validateSampleDataPackages'; -import { describeTemplateCatalog } from '../templates/describeTemplateCatalog'; import { TemplateSource } from '../templates/TemplateSource'; import { BlueprintSource } from './BlueprintSource'; -import { - blueprintTemplateTypes, businessCategoryPrefix, foundationPackageName, isValidBlueprintDomain, isValidThumbnailPath, maximumBlueprintDescriptionLength, -} from './blueprintRules'; +import { blueprintMetadataProblems, sampleDataFileProblems } from './blueprintMetadataProblems'; +import { foundationPackageName } from './blueprintRules'; +import { placementProblems } from './placementProblems'; +import { templateProblems } from './templateProblems'; /** How a blueprint is verified. */ export interface BlueprintVerificationOptions { @@ -37,171 +35,28 @@ export interface BlueprintVerificationOptions { */ export function verifyBlueprintPackage(source: BlueprintSource, library: readonly TemplateSource[] = [], options: BlueprintVerificationOptions = {}): string[] { const manifest = source.manifest; - const name = manifest.name; - const label = `Blueprint '${name}'`; - const foundation = options.foundationPackage ?? foundationPackageName; - - if (manifest.kind !== PackageKind.Blueprint) return [`Package '${name}' is not a Blueprint package`]; - - const problems: string[] = []; - if (!isValidVersion(manifest.version)) problems.push(`${label} declares an invalid version '${manifest.version ?? ''}'`); - - const blueprint = manifest.blueprint; - const domain = blueprint !== undefined; - const available = new Set([...(source.screenTemplates ?? []), ...library.flatMap(other => other.screenTemplates ?? [])].map(template => template.name)); - - if (!blueprint) { - if (options.requireBlueprintMetadata) problems.push(`${label} declares no blueprint metadata (domain, description, thumbnail and screen set)`); - } else { - if ((blueprint.domain ?? '').trim() === '') problems.push(`${label} declares no domain`); - else if (!isValidBlueprintDomain(blueprint.domain)) problems.push(`${label} declares an invalid domain '${blueprint.domain}'; use lower-case words separated by '-'`); - - if ((blueprint.description ?? '').trim() === '') problems.push(`${label} declares no description`); - else if (blueprint.description.length > maximumBlueprintDescriptionLength) problems.push(`${label} declares a description longer than ${maximumBlueprintDescriptionLength} characters`); - - problems.push(...thumbnailProblems(label, blueprint.thumbnail, source.files)); - problems.push(...screenSetProblems(source, label, available)); - - if (blueprint.defaultSampleData !== undefined && !(manifest.sampleData ?? []).some(reference => reference.id === blueprint.defaultSampleData)) { - problems.push(`${label} declares default sample data '${blueprint.defaultSampleData}', which is not one of its sample data packs`); - } - - if ((manifest.sampleData ?? []).length === 0) problems.push(`${label} ships no sample data pack`); - else if ((manifest.sampleData ?? []).length > 1 && blueprint.defaultSampleData === undefined) problems.push(`${label} ships several sample data packs but declares no default`); - - if (name !== foundation && !manifest.dependencies.some(dependency => dependency.name === foundation)) { - problems.push(`${label} does not depend on the foundation set '${foundation}'`); - } - } - - problems.push(...validateSampleDataPackages([{ manifest, packs: source.packs }])); - for (const reference of manifest.sampleData ?? []) { - if (source.files !== undefined && !source.files.includes(reference.path)) problems.push(`${label} sample data pack '${reference.id}' names '${reference.path}', which is not in the package`); - } - - problems.push(...templateProblems(source, library, domain)); - problems.push(...placementProblems(source, library, label, domain)); - return problems; -} - -function thumbnailProblems(owner: string, thumbnail: string | undefined, files: string[] | undefined): string[] { - if ((thumbnail ?? '').trim() === '') return [`${owner} declares no thumbnail`]; - if (!isValidThumbnailPath(thumbnail)) return [`${owner} declares thumbnail '${thumbnail}', which is not 'thumbnails/.png|webp|jpg|svg'`]; - if (files !== undefined && !files.includes(thumbnail!)) return [`${owner} declares thumbnail '${thumbnail}', which is not in the package`]; - return []; -} - -function screenSetProblems(source: BlueprintSource, label: string, availableTemplates: Set): string[] { - const set = source.manifest.blueprint!.screenSet ?? []; - if (set.length === 0) return [`${label} declares no ready-to-use screen set`]; - - const problems: string[] = []; - const names = new Set(); - for (const screen of set) { - if ((screen.name ?? '').trim() === '') { - problems.push(`${label} declares a screen set entry without a name`); - continue; - } - - if (names.has(screen.name)) problems.push(`${label} declares screen '${screen.name}' more than once`); - names.add(screen.name); - } - - for (const screen of set) { - if ((screen.name ?? '').trim() === '') continue; - const owner = `${label} screen '${screen.name}'`; - if ((screen.title ?? '').trim() === '') problems.push(`${owner} declares no title`); - if (screen.template !== undefined && !availableTemplates.has(screen.template)) problems.push(`${owner} uses template '${screen.template}', which is not available`); - if (screen.parent !== undefined && !names.has(screen.parent)) problems.push(`${owner} has parent '${screen.parent}', which is not in the screen set`); - if (source.screens !== undefined && !source.screens.some(candidate => candidate.name === screen.name)) problems.push(`${owner} is named in the screen set but the package does not provide it`); - } - - for (const screen of set) { - if (screen.parent !== undefined && names.has(screen.parent) && isOwnAncestor(screen.name, set)) problems.push(`${label} screen '${screen.name}' is its own ancestor`); - } - - return problems; -} - -function isOwnAncestor(name: string, set: { name: string; parent?: string }[]): boolean { - const visited = new Set(); - for (let current = set.find(screen => screen.name === name)?.parent; current !== undefined; current = set.find(screen => screen.name === current)?.parent) { - if (current === name) return true; - if (visited.has(current)) return false; - visited.add(current); - } - - return false; -} - -function templateProblems(source: BlueprintSource, library: readonly TemplateSource[], domain: boolean): string[] { - const problems: string[] = []; - const described = [...(source.screenTemplates ?? []), ...(source.dialogTemplates ?? [])]; - const everyTemplate: { name: string; displayName?: string; description?: string; metadata?: { type?: string; category?: string; thumbnail?: string; defaultMenuMode?: string; displayName?: string; description?: string }; describable: boolean }[] = [ - ...(source.layouts ?? []).map(layout => ({ name: layout.name, metadata: layout.metadata, describable: false })), - ...described.map(template => ({ name: template.name, displayName: template.displayName, description: template.description, metadata: template.metadata, describable: true })), + const label = `Blueprint '${manifest.name}'`; + + if (manifest.kind !== PackageKind.Blueprint) return [`Package '${manifest.name}' is not a Blueprint package`]; + + const domain = manifest.blueprint !== undefined; + return [ + ...versionProblems(label, manifest), + ...blueprintProblems(source, library, label, options), + ...validateSampleDataPackages([{ manifest, packs: source.packs }]), + ...sampleDataFileProblems(source, label), + ...templateProblems(source, library, domain), + ...placementProblems(source, library, label, domain), ]; - - const others = library.filter(other => other.manifest.name !== source.manifest.name); - for (const entry of describeTemplateCatalog([source, ...others]).filter(candidate => candidate.package === source.manifest.name)) { - problems.push(...entry.problems); - } - - for (const template of everyTemplate) { - const owner = `Template '${template.name}'`; - if (template.describable && (template.displayName ?? '').trim() === '') problems.push(`${owner} declares no display name`); - if (template.describable && (template.description ?? '').trim() === '') problems.push(`${owner} declares no description`); - problems.push(...shellCardProblems(owner, template, source.files)); - if (!domain) continue; - - const metadata = template.metadata; - if (metadata?.type === undefined) problems.push(`${owner} declares no type`); - else if (!blueprintTemplateTypes.includes(metadata.type)) problems.push(`${owner} declares type '${metadata.type}', which is not one of ${blueprintTemplateTypes.join(', ')}`); - - if (metadata?.category !== undefined && metadata.category.trim() !== '' && !metadata.category.startsWith(businessCategoryPrefix)) { - problems.push(`${owner} declares category '${metadata.category}', which does not start with '${businessCategoryPrefix}'`); - } - - problems.push(...thumbnailProblems(owner, metadata?.thumbnail, source.files)); - } - - return problems; } -/** A shell that declares a default menu mode is offered as a card, so it needs a valid mode, a title, a description and a wireframe thumbnail. */ -function shellCardProblems(owner: string, template: { displayName?: string; description?: string; metadata?: { thumbnail?: string; defaultMenuMode?: string; displayName?: string; description?: string } }, files: string[] | undefined): string[] { - const mode = template.metadata?.defaultMenuMode; - if (mode === undefined) return []; - - const problems: string[] = []; - if (normalizeNavigationMode(mode) === undefined) problems.push(`${owner} declares default menu mode '${mode}', which is not one of ${navigationModes.map(candidate => candidate.id).join(', ')}`); - if ((template.metadata?.displayName ?? template.displayName ?? '').trim() === '') problems.push(`${owner} is offered as a shell card but declares no title`); - if ((template.metadata?.description ?? template.description ?? '').trim() === '') problems.push(`${owner} is offered as a shell card but declares no description`); - problems.push(...thumbnailProblems(owner, template.metadata?.thumbnail, files)); - return problems; +function versionProblems(label: string, manifest: BlueprintSource['manifest']): string[] { + return isValidVersion(manifest.version) ? [] : [`${label} declares an invalid version '${manifest.version ?? ''}'`]; } -function placementProblems(source: BlueprintSource, library: readonly TemplateSource[], label: string, domain: boolean): string[] { - const own: ScreenTemplate[] = (source.screenTemplates ?? []).filter(template => template.fitsSlot !== undefined); - if (own.length === 0) return []; - - const layouts = [...(source.layouts ?? []), ...library.flatMap(other => other.layouts ?? [])]; - const everyTemplate = [...(source.screenTemplates ?? []), ...library.flatMap(other => other.screenTemplates ?? [])]; - if (layouts.length === 0) return [`${label} has no layout to place its screen templates in`]; +function blueprintProblems(source: BlueprintSource, library: readonly TemplateSource[], label: string, options: BlueprintVerificationOptions): string[] { + if (!source.manifest.blueprint) return options.requireBlueprintMetadata ? [`${label} declares no blueprint metadata (domain, description, thumbnail and screen set)`] : []; - const resolutions = layouts.map(layout => ({ layout, resolution: resolveScreenTemplates(layout, everyTemplate) })); - const problems: string[] = []; - - for (const template of own) { - if (resolutions.some(({ resolution }) => resolution.placements.some(placement => placement.template === template.name))) continue; - - const unplacedEverywhere = resolutions.flatMap(({ resolution }) => resolution.unplaced.filter(candidate => candidate.template === template.name)); - const unplaced = unplacedEverywhere.find(candidate => candidate.candidates.length > 1); - const cyclic = resolutions.some(({ resolution }) => resolution.cycles.some(cycle => cycle.includes(template.name))); - if (cyclic) problems.push(`Template '${template.name}' is nested inside itself`); - else if (unplaced && domain) problems.push(`Template '${template.name}' is ambiguous: slot '${unplaced.slot}' is declared by ${unplaced.candidates.join(', ')}`); - else if (!unplaced) problems.push(`Template '${template.name}' is unplaced: no layout or template declares slot '${template.fitsSlot}'`); - } - - return problems; + const available = new Set([...(source.screenTemplates ?? []), ...library.flatMap(other => other.screenTemplates ?? [])].map(template => template.name)); + return blueprintMetadataProblems(source, label, available, options.foundationPackage ?? foundationPackageName); } From 745144d78174a2206846ce161972a8300b687e0e Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 11 Nov 2026 19:14:20 +0100 Subject: [PATCH 04/21] Adopt C# 15 idioms in blueprint verification --- .../BlueprintMetadataVerification.cs | 16 ++++++++------- .../Blueprints/BlueprintVerification.cs | 20 +++++++++---------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/Source/DotNET/Engine/Blueprints/BlueprintMetadataVerification.cs b/Source/DotNET/Engine/Blueprints/BlueprintMetadataVerification.cs index b6ecd417..354567c6 100644 --- a/Source/DotNET/Engine/Blueprints/BlueprintMetadataVerification.cs +++ b/Source/DotNET/Engine/Blueprints/BlueprintMetadataVerification.cs @@ -21,13 +21,15 @@ static class BlueprintMetadataVerification internal static List Problems(BlueprintSource source, string label, HashSet availableTemplates, string foundation) { var blueprint = source.Manifest.Blueprint!; - var problems = DomainProblems(label, blueprint); - problems.AddRange(DescriptionProblems(label, blueprint)); - problems.AddRange(ThumbnailProblems(label, blueprint.Thumbnail, source.Files)); - problems.AddRange(BlueprintScreenSetVerification.Problems(source, label, availableTemplates)); - problems.AddRange(SampleDataDefaultProblems(label, blueprint, (source.Manifest.SampleData ?? []).Select(reference => reference.Id).ToList())); - problems.AddRange(FoundationDependencyProblems(source.Manifest, label, foundation)); - return problems; + return + [ + .. DomainProblems(label, blueprint), + .. DescriptionProblems(label, blueprint), + .. ThumbnailProblems(label, blueprint.Thumbnail, source.Files), + .. BlueprintScreenSetVerification.Problems(source, label, availableTemplates), + .. SampleDataDefaultProblems(label, blueprint, (source.Manifest.SampleData ?? []).Select(reference => reference.Id).ToList()), + .. FoundationDependencyProblems(source.Manifest, label, foundation) + ]; } /// diff --git a/Source/DotNET/Engine/Blueprints/BlueprintVerification.cs b/Source/DotNET/Engine/Blueprints/BlueprintVerification.cs index aaac4ce4..194d8bb6 100644 --- a/Source/DotNET/Engine/Blueprints/BlueprintVerification.cs +++ b/Source/DotNET/Engine/Blueprints/BlueprintVerification.cs @@ -38,16 +38,16 @@ public static IReadOnlyList Verify(BlueprintSource source, IReadOnlyList return [$"Package '{manifest.Name}' is not a Blueprint package"]; } - var problems = VersionProblems(label, manifest); - var blueprint = manifest.Blueprint; - var domain = blueprint is not null; - problems.AddRange(BlueprintProblems(source, library, label, options)); - - problems.AddRange(SampleDataValidation.ValidatePackages([new SampleDataPackSource(manifest, source.Packs)])); - problems.AddRange(BlueprintMetadataVerification.SampleDataFileProblems(source, label)); - problems.AddRange(BlueprintTemplateVerification.Problems(source, library, domain)); - problems.AddRange(BlueprintPlacementVerification.Problems(source, library, label, domain)); - return problems; + var domain = manifest.Blueprint is not null; + return + [ + .. VersionProblems(label, manifest), + .. BlueprintProblems(source, library, label, options), + .. SampleDataValidation.ValidatePackages([new SampleDataPackSource(manifest, source.Packs)]), + .. BlueprintMetadataVerification.SampleDataFileProblems(source, label), + .. BlueprintTemplateVerification.Problems(source, library, domain), + .. BlueprintPlacementVerification.Problems(source, library, label, domain) + ]; } static List VersionProblems(string label, ScenePackage manifest) => From e0427be3da73484e27fc535f9e6093e9fdffe4ba Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 11 Nov 2026 19:33:35 +0100 Subject: [PATCH 05/21] Guard blueprint verification inputs from earlier calls --- .../when_running_the_shared_corpus.cs | 15 +++++---------- .../when_running_the_shared_corpus.ts | 13 ++++++------- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/Source/DotNET/Engine.Specs/for_Blueprints/when_running_the_shared_corpus.cs b/Source/DotNET/Engine.Specs/for_Blueprints/when_running_the_shared_corpus.cs index e3ee261a..2ad45682 100644 --- a/Source/DotNET/Engine.Specs/for_Blueprints/when_running_the_shared_corpus.cs +++ b/Source/DotNET/Engine.Specs/for_Blueprints/when_running_the_shared_corpus.cs @@ -26,6 +26,7 @@ public class when_running_the_shared_corpus : Specification List _catalog = null!; List _pin = null!; List _compare = null!; + List _pristine = null!; List<(VerificationCase Case, IReadOnlyList Problems)> _verified = null!; List<(CatalogCase Case, IReadOnlyList Entries)> _described = null!; List<(PinCase Case, BlueprintPinEvaluation Evaluation)> _evaluated = null!; @@ -43,6 +44,7 @@ void Establish() _catalog = document.RootElement.GetProperty("catalog").Deserialize>(_options); _pin = document.RootElement.GetProperty("pin").Deserialize>(_options); _compare = document.RootElement.GetProperty("compare").Deserialize>(_options); + _pristine = [.. _verification.Select(Snapshot)]; } void Because() @@ -74,16 +76,7 @@ void should_write_the_problems_in_the_order_they_are_found() [Fact] void should_leave_the_blueprint_the_library_and_the_options_as_they_were() { - var mismatches = new List(); - foreach (var fixture in _verification) - { - var before = JsonSerializer.Serialize(new { fixture.Source, fixture.Library, fixture.Options }, _raw); - BlueprintVerification.Verify(fixture.Source, fixture.Library, fixture.Options); - if (JsonSerializer.Serialize(new { fixture.Source, fixture.Library, fixture.Options }, _raw) != before) - { - mismatches.Add(fixture.Name); - } - } + var mismatches = _verification.Where((fixture, index) => Snapshot(fixture) != _pristine[index]).Select(fixture => fixture.Name); mismatches.ShouldBeEmpty(); } @@ -115,6 +108,8 @@ void should_compare_versions_like_the_typescript_engine() } } + static string Snapshot(VerificationCase fixture) => JsonSerializer.Serialize(new { fixture.Source, fixture.Library, fixture.Options }, _raw); + record VerificationCase(string Name, BlueprintSource Source, IReadOnlyList Library, BlueprintVerificationOptions? Options, IReadOnlyList ExpectedProblems); record CatalogCase(string Name, IReadOnlyList Sources, IReadOnlyList ExpectedEntries); diff --git a/Source/JavaScript/engine/for_blueprints/when_running_the_shared_corpus.ts b/Source/JavaScript/engine/for_blueprints/when_running_the_shared_corpus.ts index 913df705..e1ec45bf 100644 --- a/Source/JavaScript/engine/for_blueprints/when_running_the_shared_corpus.ts +++ b/Source/JavaScript/engine/for_blueprints/when_running_the_shared_corpus.ts @@ -20,18 +20,17 @@ const corpus = JSON.parse(readFileSync(join(import.meta.dirname, '../../../../bl compare: CompareCase[]; }; -for (const fixture of corpus.verification) { +const snapshot = (fixture: VerificationCase) => JSON.stringify({ source: fixture.source, library: fixture.library, options: fixture.options }); +const pristine = corpus.verification.map(snapshot); + +for (const [index, fixture] of corpus.verification.entries()) { describe(`when verifying a blueprint for ${fixture.name}`, () => { it('should report the same problems, in the same order, as the C# engine', () => verifyBlueprintPackage(fixture.source, fixture.library, fixture.options).should.deep.equal(fixture.expectedProblems)); it('should leave the blueprint, the library and the options as they were', () => { - const source = structuredClone(fixture.source); - const library = structuredClone(fixture.library); - const options = structuredClone(fixture.options); - const before = JSON.stringify({ source, library, options }); - verifyBlueprintPackage(source, library, options); - JSON.stringify({ source, library, options }).should.equal(before); + verifyBlueprintPackage(fixture.source, fixture.library, fixture.options); + snapshot(fixture).should.equal(pristine[index]); }); }); } From 49887da02ed3191cff77469069bf00e7470905eb Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 11 Nov 2026 19:42:03 +0100 Subject: [PATCH 06/21] Characterize package list branches and mutation --- ...cking_against_the_shared_fixture_corpus.cs | 65 +- ...cking_against_the_shared_fixture_corpus.ts | 36 +- package-list-fixtures.json | 2717 +++++++++++++++++ 3 files changed, 2801 insertions(+), 17 deletions(-) diff --git a/Source/DotNET/Engine.Specs/for_PackageList/when_checking_against_the_shared_fixture_corpus.cs b/Source/DotNET/Engine.Specs/for_PackageList/when_checking_against_the_shared_fixture_corpus.cs index f7105766..aa82e11f 100644 --- a/Source/DotNET/Engine.Specs/for_PackageList/when_checking_against_the_shared_fixture_corpus.cs +++ b/Source/DotNET/Engine.Specs/for_PackageList/when_checking_against_the_shared_fixture_corpus.cs @@ -33,8 +33,10 @@ public class when_checking_against_the_shared_fixture_corpus : Specification }; List _packages = null!; + string _pristineCatalog = null!; JsonArray _cases = null!; List<(string Name, JsonNode Expected, JsonNode Actual)> _results = null!; + List _mutated = null!; void Establish() { @@ -47,18 +49,36 @@ void Establish() var corpus = JsonNode.Parse(File.ReadAllText(Path.Combine(directory.FullName, "package-list-fixtures.json"))); _packages = corpus["packages"].Deserialize>(_readOptions); _cases = corpus["cases"].AsArray(); + _pristineCatalog = JsonSerializer.Serialize(_packages, _options); } - void Because() => _results = [.. _cases.Select(item => + void Because() + { + _mutated = []; + _results = [.. _cases.Select(item => + { + var profile = item["profile"].Deserialize(_readOptions); + var packages = item["packages"]?.Deserialize>(_readOptions) ?? _packages; + var before = JsonSerializer.Serialize((profile, packages), _options); + var result = Run(item, profile, packages); + if (JsonSerializer.Serialize((profile, packages), _options) != before) + { + _mutated.Add(item["name"].GetValue()); + } + + return result; + })]; + } + + static (string Name, JsonNode Expected, JsonNode Actual) Run(JsonNode item, UiProfile profile, List packages) { - var profile = item["profile"].Deserialize(_readOptions); var action = item["action"]; var kind = action["kind"].GetValue(); var expected = item["expected"]; JsonNode actual; if (kind == "describe") { - var listing = PackageList.Describe(profile, _packages); + var listing = PackageList.Describe(profile, packages); actual = new JsonObject { ["rows"] = JsonSerializer.SerializeToNode(listing.Rows, _options), ["addable"] = JsonSerializer.SerializeToNode(listing.Addable, _options) }; foreach (var entry in actual["rows"].AsArray().Concat(actual["addable"].AsArray())) { @@ -77,11 +97,11 @@ void Because() => _results = [.. _cases.Select(item => var name = action["name"]?.GetValue() ?? string.Empty; var edit = kind switch { - "add" => PackageList.Add(profile, _packages, name), - "remove" => PackageList.Remove(profile, _packages, name), - "move" => PackageList.Move(profile, _packages, name, action["direction"].GetValue() == "up" ? MoveDirection.Up : MoveDirection.Down), - "blueprint" => PackageList.ChooseBlueprint(profile, _packages, action["blueprint"].GetValue(), action["shell"]?.GetValue()), - _ => PackageList.ChooseTheme(profile, _packages, action["theme"].GetValue()) + "add" => PackageList.Add(profile, packages, name), + "remove" => PackageList.Remove(profile, packages, name), + "move" => PackageList.Move(profile, packages, name, action["direction"].GetValue() == "up" ? MoveDirection.Up : MoveDirection.Down), + "blueprint" => PackageList.ChooseBlueprint(profile, packages, action["blueprint"].GetValue(), action["shell"]?.GetValue()), + _ => PackageList.ChooseTheme(profile, packages, action["theme"].GetValue()) }; var result = new JsonObject { @@ -105,11 +125,38 @@ void Because() => _results = [.. _cases.Select(item => } return (item["name"].GetValue(), expected.DeepClone(), actual); - })]; + } [Fact] void should_have_cases_in_the_corpus() => _results.Count.ShouldBeGreaterThan(25); + [Fact] + void should_leave_the_profile_and_the_catalog_as_they_were() + { + _mutated.ShouldBeEmpty(); + JsonSerializer.Serialize(_packages, _options).ShouldEqual(_pristineCatalog); + } + + [Fact] + void should_write_the_listing_the_way_the_designer_reads_it() + { + var item = _cases.Single(candidate => candidate["name"].GetValue() == "a package another needs"); + + var written = JsonSerializer.Serialize(PackageList.Describe(item["profile"].Deserialize(_readOptions), _packages), _options); + + written.ShouldEqual("""{"rows":[{"position":1,"name":"material","displayName":"Material Components","version":"1.0.0","kind":"componentLibrary","description":"Material components.","reason":{"kind":"chosen","packages":[],"text":"Chosen"},"canMoveUp":false,"canMoveDown":false},{"position":2,"name":"tailwind","displayName":"Tailwind","version":"1.0.0","kind":"styling","description":"Utility styling.","reason":{"kind":"neededBy","packages":["Material Components"],"text":"Needed by Material Components"},"canMoveUp":false,"canMoveDown":true,"lock":{"kind":"neededBy","packages":["Material Components"],"text":"Tailwind is needed by Material Components, so it stays while they are listed."}},{"position":3,"name":"primereact","displayName":"PrimeReact","version":"1.0.0","kind":"componentLibrary","description":"PrimeReact components.","reason":{"kind":"chosen","packages":[],"text":"Chosen"},"canMoveUp":true,"canMoveDown":false},{"position":4,"name":"core","displayName":"Core","version":"1.0.0","kind":"componentLibrary","description":"The fallback components.","reason":{"kind":"alwaysLast","packages":[],"text":"Always last"},"canMoveUp":false,"canMoveDown":false,"lock":{"kind":"core","packages":[],"text":"Core is the fallback every component name resolves against, so it is always last and cannot be removed."}}],"addable":[{"name":"material-themes","displayName":"Material Themes","version":"1.0.0","kind":"styling","description":"Material themes.","alsoAdds":[],"text":""},{"name":"bp.default","displayName":"Cratis Default","version":"1.4.2","kind":"blueprint","description":"The default blueprint.","alsoAdds":[],"text":""},{"name":"bp.dashboard","displayName":"Dashboard","version":"2.0.0","kind":"blueprint","description":"Horizontal navigation.","alsoAdds":[],"text":""},{"name":"broken","displayName":"Broken","version":"1.0.0","kind":"componentLibrary","description":"Needs a package nobody has.","alsoAdds":[],"text":""}],"suppliers":[{"component":"button","winner":"material","others":["primereact","core"]},{"component":"card","winner":"primereact","others":["core"]},{"component":"datatable","winner":"material","others":["primereact"]},{"component":"table","winner":"core","others":[]}]}"""); + } + + [Fact] + void should_write_an_edit_with_what_it_added_in_the_order_it_added_it() + { + var item = _cases.Single(candidate => candidate["name"].GetValue() == "choose a theme whose package is missing"); + + var edit = PackageList.ChooseTheme(item["profile"].Deserialize(_readOptions), _packages, "Material Ocean"); + + JsonSerializer.Serialize(edit, _options).ShouldEqual("""{"profile":{"name":"Desktop","targetPlatform":"web","packages":["core","tailwind","material","material-themes","primereact"],"theme":"Material Ocean"},"outcome":"changed","added":["tailwind","material","material-themes"],"removed":[],"message":"Theme \u0027Material Ocean\u0027 needs Material Themes, which was added."}"""); + } + [Fact] void should_answer_what_the_corpus_expects_for_every_case() { diff --git a/Source/JavaScript/engine/for_packageList/when_checking_against_the_shared_fixture_corpus.ts b/Source/JavaScript/engine/for_packageList/when_checking_against_the_shared_fixture_corpus.ts index 5bd490e0..190155ea 100644 --- a/Source/JavaScript/engine/for_packageList/when_checking_against_the_shared_fixture_corpus.ts +++ b/Source/JavaScript/engine/for_packageList/when_checking_against_the_shared_fixture_corpus.ts @@ -10,26 +10,29 @@ interface PackageListCase { name: string; description: string; profile: UiProfile; + packages?: ScenePackage[]; action: { kind: string; name?: string; direction?: string; blueprint?: string; shell?: string; theme?: string }; expected: Record; } const corpus = JSON.parse(readFileSync(join(import.meta.dirname, '../../../../package-list-fixtures.json'), 'utf-8')) as { packages: ScenePackage[]; cases: PackageListCase[] }; +const pristine = { catalog: JSON.stringify(corpus.packages), cases: JSON.stringify(corpus.cases.map(fixture => ({ profile: fixture.profile, packages: fixture.packages }))) }; + const camel = (value: string) => value.charAt(0).toLowerCase() + value.slice(1); -const answerOf = (fixture: PackageListCase) => { - const { action, profile } = fixture; +const answerOf = (fixture: PackageListCase, profile: UiProfile = fixture.profile, packages: ScenePackage[] = fixture.packages ?? corpus.packages) => { + const { action } = fixture; if (action.kind === 'describe') { - const list = describePackageList(profile, corpus.packages); + const list = describePackageList(profile, packages); return JSON.parse(JSON.stringify({ rows: list.rows.map(row => ({ ...row, kind: row.kind as string })), addable: list.addable.map(item => ({ ...item, kind: item.kind as string })), ...(fixture.expected.suppliers === undefined ? {} : { suppliers: list.suppliers }) })); } - const edit = action.kind === 'add' ? addPackage(profile, corpus.packages, action.name!) - : action.kind === 'remove' ? removePackage(profile, corpus.packages, action.name!) - : action.kind === 'move' ? movePackage(profile, corpus.packages, action.name!, action.direction === 'up' ? MoveDirection.Up : MoveDirection.Down) - : action.kind === 'blueprint' ? chooseBlueprint(profile, corpus.packages, action.blueprint!, action.shell) - : chooseTheme(profile, corpus.packages, action.theme!); + const edit = action.kind === 'add' ? addPackage(profile, packages, action.name!) + : action.kind === 'remove' ? removePackage(profile, packages, action.name!) + : action.kind === 'move' ? movePackage(profile, packages, action.name!, action.direction === 'up' ? MoveDirection.Up : MoveDirection.Down) + : action.kind === 'blueprint' ? chooseBlueprint(profile, packages, action.blueprint!, action.shell) + : chooseTheme(profile, packages, action.theme!); return JSON.parse(JSON.stringify({ outcome: edit.outcome, packages: edit.profile.packages, @@ -59,5 +62,22 @@ describe('when listing and editing the packages of a profile against the shared }); } + it('should leave the profile and the catalog as they were', () => { + corpus.cases.forEach(fixture => answerOf(fixture)); + + JSON.stringify(corpus.cases.map(fixture => ({ profile: fixture.profile, packages: fixture.packages }))).should.equal(pristine.cases); + JSON.stringify(corpus.packages).should.equal(pristine.catalog); + }); + + it('should write the listing the way the designer reads it', () => { + const fixture = corpus.cases.find(candidate => candidate.name === 'a package another needs')!; + JSON.stringify(describePackageList(fixture.profile, corpus.packages)).should.equal("{\"rows\":[{\"position\":1,\"name\":\"material\",\"displayName\":\"Material Components\",\"version\":\"1.0.0\",\"kind\":\"ComponentLibrary\",\"description\":\"Material components.\",\"reason\":{\"kind\":\"chosen\",\"packages\":[],\"text\":\"Chosen\"},\"canMoveUp\":false,\"canMoveDown\":false},{\"position\":2,\"name\":\"tailwind\",\"displayName\":\"Tailwind\",\"version\":\"1.0.0\",\"kind\":\"Styling\",\"description\":\"Utility styling.\",\"reason\":{\"kind\":\"neededBy\",\"packages\":[\"Material Components\"],\"text\":\"Needed by Material Components\"},\"canMoveUp\":false,\"canMoveDown\":true,\"lock\":{\"kind\":\"neededBy\",\"packages\":[\"Material Components\"],\"text\":\"Tailwind is needed by Material Components, so it stays while they are listed.\"}},{\"position\":3,\"name\":\"primereact\",\"displayName\":\"PrimeReact\",\"version\":\"1.0.0\",\"kind\":\"ComponentLibrary\",\"description\":\"PrimeReact components.\",\"reason\":{\"kind\":\"chosen\",\"packages\":[],\"text\":\"Chosen\"},\"canMoveUp\":true,\"canMoveDown\":false},{\"position\":4,\"name\":\"core\",\"displayName\":\"Core\",\"version\":\"1.0.0\",\"kind\":\"ComponentLibrary\",\"description\":\"The fallback components.\",\"reason\":{\"kind\":\"alwaysLast\",\"packages\":[],\"text\":\"Always last\"},\"canMoveUp\":false,\"canMoveDown\":false,\"lock\":{\"kind\":\"core\",\"packages\":[],\"text\":\"Core is the fallback every component name resolves against, so it is always last and cannot be removed.\"}}],\"addable\":[{\"name\":\"material-themes\",\"displayName\":\"Material Themes\",\"version\":\"1.0.0\",\"kind\":\"Styling\",\"description\":\"Material themes.\",\"alsoAdds\":[],\"text\":\"\"},{\"name\":\"bp.default\",\"displayName\":\"Cratis Default\",\"version\":\"1.4.2\",\"kind\":\"Blueprint\",\"description\":\"The default blueprint.\",\"alsoAdds\":[],\"text\":\"\"},{\"name\":\"bp.dashboard\",\"displayName\":\"Dashboard\",\"version\":\"2.0.0\",\"kind\":\"Blueprint\",\"description\":\"Horizontal navigation.\",\"alsoAdds\":[],\"text\":\"\"},{\"name\":\"broken\",\"displayName\":\"Broken\",\"version\":\"1.0.0\",\"kind\":\"ComponentLibrary\",\"description\":\"Needs a package nobody has.\",\"alsoAdds\":[],\"text\":\"\"}],\"suppliers\":[{\"component\":\"button\",\"winner\":\"material\",\"others\":[\"primereact\",\"core\"]},{\"component\":\"card\",\"winner\":\"primereact\",\"others\":[\"core\"]},{\"component\":\"datatable\",\"winner\":\"material\",\"others\":[\"primereact\"]},{\"component\":\"table\",\"winner\":\"core\",\"others\":[]}]}"); + }); + + it('should write an edit with what it added in the order it added it', () => { + const fixture = corpus.cases.find(candidate => candidate.name === 'choose a theme whose package is missing')!; + JSON.stringify(chooseTheme(fixture.profile, corpus.packages, 'Material Ocean')).should.equal("{\"profile\":{\"name\":\"Desktop\",\"targetPlatform\":\"web\",\"packages\":[\"core\",\"tailwind\",\"material\",\"material-themes\",\"primereact\"],\"theme\":\"Material Ocean\"},\"outcome\":\"changed\",\"added\":[\"tailwind\",\"material\",\"material-themes\"],\"removed\":[],\"message\":\"Theme 'Material Ocean' needs Material Themes, which was added.\"}"); + }); + it('should have lower-case camel names for every enum value', () => camel(PackageEditOutcome.Added).should.equal(PackageEditOutcome.Added)); }); diff --git a/package-list-fixtures.json b/package-list-fixtures.json index 7c435319..34c1d849 100644 --- a/package-list-fixtures.json +++ b/package-list-fixtures.json @@ -1805,6 +1805,2723 @@ "message": "", "theme": "Nowhere" } + }, + { + "name": "add a package with several missing dependencies", + "description": "a message names every missing dependency and says \"are\"", + "profile": { + "name": "Desktop", + "targetPlatform": "web", + "packages": [ + "core" + ] + }, + "packages": [ + { + "name": "core", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [], + "components": [ + "button" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Core" + }, + { + "name": "lib", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [], + "components": [ + "button" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Lib" + }, + { + "name": "bpC", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "CShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint C" + }, + { + "name": "bpD", + "version": "2.1.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "DShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint D" + }, + { + "name": "bpNone", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint None" + }, + { + "name": "bpGhost", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [ + { + "name": "ghost1" + }, + { + "name": "ghost2" + } + ], + "components": [], + "layouts": [ + "GShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint Ghost" + }, + { + "name": "x", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [ + { + "name": "bpC" + }, + { + "name": "bpC" + } + ], + "components": [ + "card" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "X" + }, + { + "name": "twoGhosts", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [ + { + "name": "ghost1" + }, + { + "name": "ghost2" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Two Ghosts" + }, + { + "name": "themed", + "version": "1.0.0", + "kind": "Styling", + "dependencies": [ + { + "name": "ghost1" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [ + "Ghostly" + ], + "displayName": "Themed" + } + ], + "action": { + "kind": "add", + "name": "twoGhosts" + }, + "expected": { + "outcome": "refused", + "packages": [ + "core" + ], + "added": [], + "removed": [], + "message": "Package 'Two Ghosts' needs 'ghost1', 'ghost2', which are not available." + } + }, + { + "name": "choose a blueprint that has no shell", + "description": "no layouts and no shell given: refused", + "profile": { + "name": "Desktop", + "targetPlatform": "web", + "packages": [ + "core" + ] + }, + "packages": [ + { + "name": "core", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [], + "components": [ + "button" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Core" + }, + { + "name": "lib", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [], + "components": [ + "button" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Lib" + }, + { + "name": "bpC", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "CShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint C" + }, + { + "name": "bpD", + "version": "2.1.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "DShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint D" + }, + { + "name": "bpNone", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint None" + }, + { + "name": "bpGhost", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [ + { + "name": "ghost1" + }, + { + "name": "ghost2" + } + ], + "components": [], + "layouts": [ + "GShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint Ghost" + }, + { + "name": "x", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [ + { + "name": "bpC" + }, + { + "name": "bpC" + } + ], + "components": [ + "card" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "X" + }, + { + "name": "twoGhosts", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [ + { + "name": "ghost1" + }, + { + "name": "ghost2" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Two Ghosts" + }, + { + "name": "themed", + "version": "1.0.0", + "kind": "Styling", + "dependencies": [ + { + "name": "ghost1" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [ + "Ghostly" + ], + "displayName": "Themed" + } + ], + "action": { + "kind": "blueprint", + "blueprint": "bpNone" + }, + "expected": { + "outcome": "refused", + "packages": [ + "core" + ], + "added": [], + "removed": [], + "message": "Blueprint None has no shell to offer." + } + }, + { + "name": "choose a named shell on a blueprint with no layouts", + "description": "the named shell is enough", + "profile": { + "name": "Desktop", + "targetPlatform": "web", + "packages": [ + "core" + ] + }, + "packages": [ + { + "name": "core", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [], + "components": [ + "button" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Core" + }, + { + "name": "lib", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [], + "components": [ + "button" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Lib" + }, + { + "name": "bpC", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "CShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint C" + }, + { + "name": "bpD", + "version": "2.1.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "DShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint D" + }, + { + "name": "bpNone", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint None" + }, + { + "name": "bpGhost", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [ + { + "name": "ghost1" + }, + { + "name": "ghost2" + } + ], + "components": [], + "layouts": [ + "GShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint Ghost" + }, + { + "name": "x", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [ + { + "name": "bpC" + }, + { + "name": "bpC" + } + ], + "components": [ + "card" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "X" + }, + { + "name": "twoGhosts", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [ + { + "name": "ghost1" + }, + { + "name": "ghost2" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Two Ghosts" + }, + { + "name": "themed", + "version": "1.0.0", + "kind": "Styling", + "dependencies": [ + { + "name": "ghost1" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [ + "Ghostly" + ], + "displayName": "Themed" + } + ], + "action": { + "kind": "blueprint", + "blueprint": "bpNone", + "shell": "Custom" + }, + "expected": { + "outcome": "changed", + "packages": [ + "core", + "bpNone" + ], + "added": [ + "bpNone" + ], + "removed": [], + "message": "Shell set to Blueprint None.", + "root": { + "source": "Blueprint", + "id": "Custom", + "blueprint": "bpNone", + "version": "^1.0" + } + } + }, + { + "name": "choose a blueprint with several missing dependencies", + "description": "refused with every missing dependency named", + "profile": { + "name": "Desktop", + "targetPlatform": "web", + "packages": [ + "core" + ] + }, + "packages": [ + { + "name": "core", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [], + "components": [ + "button" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Core" + }, + { + "name": "lib", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [], + "components": [ + "button" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Lib" + }, + { + "name": "bpC", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "CShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint C" + }, + { + "name": "bpD", + "version": "2.1.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "DShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint D" + }, + { + "name": "bpNone", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint None" + }, + { + "name": "bpGhost", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [ + { + "name": "ghost1" + }, + { + "name": "ghost2" + } + ], + "components": [], + "layouts": [ + "GShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint Ghost" + }, + { + "name": "x", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [ + { + "name": "bpC" + }, + { + "name": "bpC" + } + ], + "components": [ + "card" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "X" + }, + { + "name": "twoGhosts", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [ + { + "name": "ghost1" + }, + { + "name": "ghost2" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Two Ghosts" + }, + { + "name": "themed", + "version": "1.0.0", + "kind": "Styling", + "dependencies": [ + { + "name": "ghost1" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [ + "Ghostly" + ], + "displayName": "Themed" + } + ], + "action": { + "kind": "blueprint", + "blueprint": "bpGhost" + }, + "expected": { + "outcome": "refused", + "packages": [ + "core" + ], + "added": [], + "removed": [], + "message": "Blueprint Ghost needs 'ghost1', 'ghost2', which are not available." + } + }, + { + "name": "choose a blueprint that is already listed", + "description": "it is moved to the strongest position", + "profile": { + "name": "Desktop", + "targetPlatform": "web", + "packages": [ + "core", + "bpC", + "lib" + ] + }, + "packages": [ + { + "name": "core", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [], + "components": [ + "button" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Core" + }, + { + "name": "lib", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [], + "components": [ + "button" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Lib" + }, + { + "name": "bpC", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "CShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint C" + }, + { + "name": "bpD", + "version": "2.1.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "DShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint D" + }, + { + "name": "bpNone", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint None" + }, + { + "name": "bpGhost", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [ + { + "name": "ghost1" + }, + { + "name": "ghost2" + } + ], + "components": [], + "layouts": [ + "GShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint Ghost" + }, + { + "name": "x", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [ + { + "name": "bpC" + }, + { + "name": "bpC" + } + ], + "components": [ + "card" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "X" + }, + { + "name": "twoGhosts", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [ + { + "name": "ghost1" + }, + { + "name": "ghost2" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Two Ghosts" + }, + { + "name": "themed", + "version": "1.0.0", + "kind": "Styling", + "dependencies": [ + { + "name": "ghost1" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [ + "Ghostly" + ], + "displayName": "Themed" + } + ], + "action": { + "kind": "blueprint", + "blueprint": "bpC" + }, + "expected": { + "outcome": "changed", + "packages": [ + "core", + "lib", + "bpC" + ], + "added": [], + "removed": [], + "message": "Shell set to Blueprint C.", + "root": { + "source": "Blueprint", + "id": "CShell", + "blueprint": "bpC", + "version": "^1.0" + } + } + }, + { + "name": "choose a blueprint another package needs", + "description": "it stays where it is because a package above depends on it", + "profile": { + "name": "Desktop", + "targetPlatform": "web", + "packages": [ + "core", + "bpC", + "x" + ] + }, + "packages": [ + { + "name": "core", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [], + "components": [ + "button" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Core" + }, + { + "name": "lib", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [], + "components": [ + "button" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Lib" + }, + { + "name": "bpC", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "CShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint C" + }, + { + "name": "bpD", + "version": "2.1.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "DShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint D" + }, + { + "name": "bpNone", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint None" + }, + { + "name": "bpGhost", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [ + { + "name": "ghost1" + }, + { + "name": "ghost2" + } + ], + "components": [], + "layouts": [ + "GShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint Ghost" + }, + { + "name": "x", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [ + { + "name": "bpC" + }, + { + "name": "bpC" + } + ], + "components": [ + "card" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "X" + }, + { + "name": "twoGhosts", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [ + { + "name": "ghost1" + }, + { + "name": "ghost2" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Two Ghosts" + }, + { + "name": "themed", + "version": "1.0.0", + "kind": "Styling", + "dependencies": [ + { + "name": "ghost1" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [ + "Ghostly" + ], + "displayName": "Themed" + } + ], + "action": { + "kind": "blueprint", + "blueprint": "bpC" + }, + "expected": { + "outcome": "changed", + "packages": [ + "core", + "bpC", + "x" + ], + "added": [], + "removed": [], + "message": "Shell set to Blueprint C.", + "root": { + "source": "Blueprint", + "id": "CShell", + "blueprint": "bpC", + "version": "^1.0" + } + } + }, + { + "name": "swap away from a blueprint another package needs", + "description": "the previous blueprint stays listed", + "profile": { + "name": "Desktop", + "targetPlatform": "web", + "packages": [ + "core", + "bpC", + "x" + ], + "root": { + "template": { + "source": "Blueprint", + "id": "CShell", + "blueprint": "bpC" + } + } + }, + "packages": [ + { + "name": "core", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [], + "components": [ + "button" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Core" + }, + { + "name": "lib", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [], + "components": [ + "button" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Lib" + }, + { + "name": "bpC", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "CShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint C" + }, + { + "name": "bpD", + "version": "2.1.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "DShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint D" + }, + { + "name": "bpNone", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint None" + }, + { + "name": "bpGhost", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [ + { + "name": "ghost1" + }, + { + "name": "ghost2" + } + ], + "components": [], + "layouts": [ + "GShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint Ghost" + }, + { + "name": "x", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [ + { + "name": "bpC" + }, + { + "name": "bpC" + } + ], + "components": [ + "card" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "X" + }, + { + "name": "twoGhosts", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [ + { + "name": "ghost1" + }, + { + "name": "ghost2" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Two Ghosts" + }, + { + "name": "themed", + "version": "1.0.0", + "kind": "Styling", + "dependencies": [ + { + "name": "ghost1" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [ + "Ghostly" + ], + "displayName": "Themed" + } + ], + "action": { + "kind": "blueprint", + "blueprint": "bpD" + }, + "expected": { + "outcome": "changed", + "packages": [ + "core", + "bpC", + "x", + "bpD" + ], + "added": [ + "bpD" + ], + "removed": [], + "message": "Shell set to Blueprint D.", + "root": { + "source": "Blueprint", + "id": "DShell", + "blueprint": "bpD", + "version": "^2.1" + } + } + }, + { + "name": "swap from a shell blueprint that is not listed", + "description": "nothing to remove", + "profile": { + "name": "Desktop", + "targetPlatform": "web", + "packages": [ + "core" + ], + "root": { + "template": { + "source": "Blueprint", + "id": "CShell", + "blueprint": "bpC" + } + } + }, + "packages": [ + { + "name": "core", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [], + "components": [ + "button" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Core" + }, + { + "name": "lib", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [], + "components": [ + "button" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Lib" + }, + { + "name": "bpC", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "CShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint C" + }, + { + "name": "bpD", + "version": "2.1.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "DShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint D" + }, + { + "name": "bpNone", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint None" + }, + { + "name": "bpGhost", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [ + { + "name": "ghost1" + }, + { + "name": "ghost2" + } + ], + "components": [], + "layouts": [ + "GShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint Ghost" + }, + { + "name": "x", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [ + { + "name": "bpC" + }, + { + "name": "bpC" + } + ], + "components": [ + "card" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "X" + }, + { + "name": "twoGhosts", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [ + { + "name": "ghost1" + }, + { + "name": "ghost2" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Two Ghosts" + }, + { + "name": "themed", + "version": "1.0.0", + "kind": "Styling", + "dependencies": [ + { + "name": "ghost1" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [ + "Ghostly" + ], + "displayName": "Themed" + } + ], + "action": { + "kind": "blueprint", + "blueprint": "bpD" + }, + "expected": { + "outcome": "changed", + "packages": [ + "core", + "bpD" + ], + "added": [ + "bpD" + ], + "removed": [], + "message": "Shell set to Blueprint D.", + "root": { + "source": "Blueprint", + "id": "DShell", + "blueprint": "bpD", + "version": "^2.1" + } + } + }, + { + "name": "choose the current shell blueprint again", + "description": "the previous blueprint is the one chosen, so it is not removed", + "profile": { + "name": "Desktop", + "targetPlatform": "web", + "packages": [ + "core", + "bpC" + ], + "root": { + "template": { + "source": "Blueprint", + "id": "CShell", + "blueprint": "bpC" + } + } + }, + "packages": [ + { + "name": "core", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [], + "components": [ + "button" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Core" + }, + { + "name": "lib", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [], + "components": [ + "button" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Lib" + }, + { + "name": "bpC", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "CShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint C" + }, + { + "name": "bpD", + "version": "2.1.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "DShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint D" + }, + { + "name": "bpNone", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint None" + }, + { + "name": "bpGhost", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [ + { + "name": "ghost1" + }, + { + "name": "ghost2" + } + ], + "components": [], + "layouts": [ + "GShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint Ghost" + }, + { + "name": "x", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [ + { + "name": "bpC" + }, + { + "name": "bpC" + } + ], + "components": [ + "card" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "X" + }, + { + "name": "twoGhosts", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [ + { + "name": "ghost1" + }, + { + "name": "ghost2" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Two Ghosts" + }, + { + "name": "themed", + "version": "1.0.0", + "kind": "Styling", + "dependencies": [ + { + "name": "ghost1" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [ + "Ghostly" + ], + "displayName": "Themed" + } + ], + "action": { + "kind": "blueprint", + "blueprint": "bpC" + }, + "expected": { + "outcome": "changed", + "packages": [ + "core", + "bpC" + ], + "added": [], + "removed": [], + "message": "Shell set to Blueprint C.", + "root": { + "source": "Blueprint", + "id": "CShell", + "blueprint": "bpC", + "version": "^1.0" + } + } + }, + { + "name": "describe a shell blueprint another package needs", + "description": "the shell reason wins over needed by", + "profile": { + "name": "Desktop", + "targetPlatform": "web", + "packages": [ + "core", + "bpC", + "x" + ], + "root": { + "template": { + "source": "Blueprint", + "id": "CShell", + "blueprint": "bpC" + } + } + }, + "packages": [ + { + "name": "core", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [], + "components": [ + "button" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Core" + }, + { + "name": "lib", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [], + "components": [ + "button" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Lib" + }, + { + "name": "bpC", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "CShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint C" + }, + { + "name": "bpD", + "version": "2.1.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "DShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint D" + }, + { + "name": "bpNone", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint None" + }, + { + "name": "bpGhost", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [ + { + "name": "ghost1" + }, + { + "name": "ghost2" + } + ], + "components": [], + "layouts": [ + "GShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint Ghost" + }, + { + "name": "x", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [ + { + "name": "bpC" + }, + { + "name": "bpC" + } + ], + "components": [ + "card" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "X" + }, + { + "name": "twoGhosts", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [ + { + "name": "ghost1" + }, + { + "name": "ghost2" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Two Ghosts" + }, + { + "name": "themed", + "version": "1.0.0", + "kind": "Styling", + "dependencies": [ + { + "name": "ghost1" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [ + "Ghostly" + ], + "displayName": "Themed" + } + ], + "action": { + "kind": "describe" + }, + "expected": { + "rows": [ + { + "position": 1, + "name": "x", + "displayName": "X", + "version": "1.0.0", + "kind": "ComponentLibrary", + "reason": { + "kind": "chosen", + "packages": [], + "text": "Chosen" + }, + "canMoveUp": false, + "canMoveDown": false + }, + { + "position": 2, + "name": "bpC", + "displayName": "Blueprint C", + "version": "1.0.0", + "kind": "Blueprint", + "reason": { + "kind": "usedByShell", + "packages": [], + "text": "Used by Shell" + }, + "canMoveUp": false, + "canMoveDown": false, + "lock": { + "kind": "shell", + "packages": [], + "text": "Blueprint C is the blueprint of the current shell, so it stays while it is the shell." + } + }, + { + "position": 3, + "name": "core", + "displayName": "Core", + "version": "1.0.0", + "kind": "ComponentLibrary", + "reason": { + "kind": "alwaysLast", + "packages": [], + "text": "Always last" + }, + "canMoveUp": false, + "canMoveDown": false, + "lock": { + "kind": "core", + "packages": [], + "text": "Core is the fallback every component name resolves against, so it is always last and cannot be removed." + } + } + ], + "addable": [ + { + "name": "lib", + "displayName": "Lib", + "version": "1.0.0", + "kind": "ComponentLibrary", + "alsoAdds": [], + "text": "" + }, + { + "name": "bpD", + "displayName": "Blueprint D", + "version": "2.1.0", + "kind": "Blueprint", + "alsoAdds": [], + "text": "" + }, + { + "name": "bpNone", + "displayName": "Blueprint None", + "version": "1.0.0", + "kind": "Blueprint", + "alsoAdds": [], + "text": "" + }, + { + "name": "bpGhost", + "displayName": "Blueprint Ghost", + "version": "1.0.0", + "kind": "Blueprint", + "alsoAdds": [], + "text": "" + }, + { + "name": "twoGhosts", + "displayName": "Two Ghosts", + "version": "1.0.0", + "kind": "ComponentLibrary", + "alsoAdds": [], + "text": "" + }, + { + "name": "themed", + "displayName": "Themed", + "version": "1.0.0", + "kind": "Styling", + "alsoAdds": [], + "text": "" + } + ] + } + }, + { + "name": "describe a package needed by one that lists it twice", + "description": "needed by names the dependant once", + "profile": { + "name": "Desktop", + "targetPlatform": "web", + "packages": [ + "core", + "bpC", + "x" + ] + }, + "packages": [ + { + "name": "core", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [], + "components": [ + "button" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Core" + }, + { + "name": "lib", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [], + "components": [ + "button" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Lib" + }, + { + "name": "bpC", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "CShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint C" + }, + { + "name": "bpD", + "version": "2.1.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "DShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint D" + }, + { + "name": "bpNone", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint None" + }, + { + "name": "bpGhost", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [ + { + "name": "ghost1" + }, + { + "name": "ghost2" + } + ], + "components": [], + "layouts": [ + "GShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint Ghost" + }, + { + "name": "x", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [ + { + "name": "bpC" + }, + { + "name": "bpC" + } + ], + "components": [ + "card" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "X" + }, + { + "name": "twoGhosts", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [ + { + "name": "ghost1" + }, + { + "name": "ghost2" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Two Ghosts" + }, + { + "name": "themed", + "version": "1.0.0", + "kind": "Styling", + "dependencies": [ + { + "name": "ghost1" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [ + "Ghostly" + ], + "displayName": "Themed" + } + ], + "action": { + "kind": "describe" + }, + "expected": { + "rows": [ + { + "position": 1, + "name": "x", + "displayName": "X", + "version": "1.0.0", + "kind": "ComponentLibrary", + "reason": { + "kind": "chosen", + "packages": [], + "text": "Chosen" + }, + "canMoveUp": false, + "canMoveDown": false + }, + { + "position": 2, + "name": "bpC", + "displayName": "Blueprint C", + "version": "1.0.0", + "kind": "Blueprint", + "reason": { + "kind": "neededBy", + "packages": [ + "X" + ], + "text": "Needed by X" + }, + "canMoveUp": false, + "canMoveDown": false, + "lock": { + "kind": "neededBy", + "packages": [ + "X" + ], + "text": "Blueprint C is needed by X, so it stays while they are listed." + } + }, + { + "position": 3, + "name": "core", + "displayName": "Core", + "version": "1.0.0", + "kind": "ComponentLibrary", + "reason": { + "kind": "alwaysLast", + "packages": [], + "text": "Always last" + }, + "canMoveUp": false, + "canMoveDown": false, + "lock": { + "kind": "core", + "packages": [], + "text": "Core is the fallback every component name resolves against, so it is always last and cannot be removed." + } + } + ], + "addable": [ + { + "name": "lib", + "displayName": "Lib", + "version": "1.0.0", + "kind": "ComponentLibrary", + "alsoAdds": [], + "text": "" + }, + { + "name": "bpD", + "displayName": "Blueprint D", + "version": "2.1.0", + "kind": "Blueprint", + "alsoAdds": [], + "text": "" + }, + { + "name": "bpNone", + "displayName": "Blueprint None", + "version": "1.0.0", + "kind": "Blueprint", + "alsoAdds": [], + "text": "" + }, + { + "name": "bpGhost", + "displayName": "Blueprint Ghost", + "version": "1.0.0", + "kind": "Blueprint", + "alsoAdds": [], + "text": "" + }, + { + "name": "twoGhosts", + "displayName": "Two Ghosts", + "version": "1.0.0", + "kind": "ComponentLibrary", + "alsoAdds": [], + "text": "" + }, + { + "name": "themed", + "displayName": "Themed", + "version": "1.0.0", + "kind": "Styling", + "alsoAdds": [], + "text": "" + } + ] + } + }, + { + "name": "move without listing core", + "description": "core stays unlisted in the written list", + "profile": { + "name": "Desktop", + "targetPlatform": "web", + "packages": [ + "lib", + "bpC" + ] + }, + "packages": [ + { + "name": "core", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [], + "components": [ + "button" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Core" + }, + { + "name": "lib", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [], + "components": [ + "button" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Lib" + }, + { + "name": "bpC", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "CShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint C" + }, + { + "name": "bpD", + "version": "2.1.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "DShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint D" + }, + { + "name": "bpNone", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint None" + }, + { + "name": "bpGhost", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [ + { + "name": "ghost1" + }, + { + "name": "ghost2" + } + ], + "components": [], + "layouts": [ + "GShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint Ghost" + }, + { + "name": "x", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [ + { + "name": "bpC" + }, + { + "name": "bpC" + } + ], + "components": [ + "card" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "X" + }, + { + "name": "twoGhosts", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [ + { + "name": "ghost1" + }, + { + "name": "ghost2" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Two Ghosts" + }, + { + "name": "themed", + "version": "1.0.0", + "kind": "Styling", + "dependencies": [ + { + "name": "ghost1" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [ + "Ghostly" + ], + "displayName": "Themed" + } + ], + "action": { + "kind": "move", + "name": "lib", + "direction": "down" + }, + "expected": { + "outcome": "unchanged", + "packages": [ + "lib", + "bpC" + ], + "added": [], + "removed": [], + "message": "" + } + }, + { + "name": "remove without listing core", + "description": "core stays unlisted in the written list", + "profile": { + "name": "Desktop", + "targetPlatform": "web", + "packages": [ + "lib", + "bpC" + ] + }, + "packages": [ + { + "name": "core", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [], + "components": [ + "button" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Core" + }, + { + "name": "lib", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [], + "components": [ + "button" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Lib" + }, + { + "name": "bpC", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "CShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint C" + }, + { + "name": "bpD", + "version": "2.1.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "DShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint D" + }, + { + "name": "bpNone", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint None" + }, + { + "name": "bpGhost", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [ + { + "name": "ghost1" + }, + { + "name": "ghost2" + } + ], + "components": [], + "layouts": [ + "GShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint Ghost" + }, + { + "name": "x", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [ + { + "name": "bpC" + }, + { + "name": "bpC" + } + ], + "components": [ + "card" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "X" + }, + { + "name": "twoGhosts", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [ + { + "name": "ghost1" + }, + { + "name": "ghost2" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Two Ghosts" + }, + { + "name": "themed", + "version": "1.0.0", + "kind": "Styling", + "dependencies": [ + { + "name": "ghost1" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [ + "Ghostly" + ], + "displayName": "Themed" + } + ], + "action": { + "kind": "remove", + "name": "lib" + }, + "expected": { + "outcome": "removed", + "packages": [ + "bpC" + ], + "added": [], + "removed": [ + "lib" + ], + "message": "Removed Lib." + } + }, + { + "name": "choose a theme whose package cannot be added", + "description": "the theme is chosen and the refusal is the message", + "profile": { + "name": "Desktop", + "targetPlatform": "web", + "packages": [ + "core" + ] + }, + "packages": [ + { + "name": "core", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [], + "components": [ + "button" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Core" + }, + { + "name": "lib", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [], + "components": [ + "button" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Lib" + }, + { + "name": "bpC", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "CShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint C" + }, + { + "name": "bpD", + "version": "2.1.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "DShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint D" + }, + { + "name": "bpNone", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint None" + }, + { + "name": "bpGhost", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [ + { + "name": "ghost1" + }, + { + "name": "ghost2" + } + ], + "components": [], + "layouts": [ + "GShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Blueprint Ghost" + }, + { + "name": "x", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [ + { + "name": "bpC" + }, + { + "name": "bpC" + } + ], + "components": [ + "card" + ], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "X" + }, + { + "name": "twoGhosts", + "version": "1.0.0", + "kind": "ComponentLibrary", + "dependencies": [ + { + "name": "ghost1" + }, + { + "name": "ghost2" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [], + "displayName": "Two Ghosts" + }, + { + "name": "themed", + "version": "1.0.0", + "kind": "Styling", + "dependencies": [ + { + "name": "ghost1" + } + ], + "components": [], + "layouts": [], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [ + "Ghostly" + ], + "displayName": "Themed" + } + ], + "action": { + "kind": "theme", + "theme": "Ghostly" + }, + "expected": { + "outcome": "changed", + "packages": [ + "core" + ], + "added": [], + "removed": [], + "message": "Package 'Themed' needs 'ghost1', which is not available.", + "theme": "Ghostly" + } } ] } From 9c987ed09b4ca97ae5085b010def8dd81063a447 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 11 Nov 2026 19:45:49 +0100 Subject: [PATCH 07/21] Split the package list into focused helpers --- Source/DotNET/Engine/Profiles/PackageList.cs | 221 ++++++++++-------- .../JavaScript/engine/profiles/packageList.ts | 158 ++++++++----- 2 files changed, 215 insertions(+), 164 deletions(-) diff --git a/Source/DotNET/Engine/Profiles/PackageList.cs b/Source/DotNET/Engine/Profiles/PackageList.cs index c68131bc..634d2be9 100644 --- a/Source/DotNET/Engine/Profiles/PackageList.cs +++ b/Source/DotNET/Engine/Profiles/PackageList.cs @@ -45,76 +45,10 @@ public static PackageListing Describe(UiProfile profile, IReadOnlyList - { - index.TryGetValue(name, out var item); - var needed = names.Where(other => other != name && DependenciesOf(other, index).Contains(name)).Select(other => NameOf(index, other)).ToList(); - var displayName = NameOf(index, name); - PackageReason reason; - PackageLock? packageLock = null; - if (name == PackageResolver.Core) - { - reason = new(PackageReasonKind.AlwaysLast, [], "Always last"); - packageLock = new(PackageLockKind.Core, [], $"{displayName} is the fallback every component name resolves against, so it is always last and cannot be removed."); - } - else if (name == shell) - { - reason = new(PackageReasonKind.UsedByShell, [], "Used by Shell"); - packageLock = new(PackageLockKind.Shell, [], $"{displayName} is the blueprint of the current shell, so it stays while it is the shell."); - } - else if (needed.Count > 0) - { - reason = new(PackageReasonKind.NeededBy, needed, $"Needed by {string.Join(", ", needed)}"); - packageLock = new(PackageLockKind.NeededBy, needed, $"{displayName} is needed by {string.Join(", ", needed)}, so it stays while they are listed."); - } - else - { - reason = new(PackageReasonKind.Chosen, [], "Chosen"); - } - - return new PackageRow( - at + 1, - name, - displayName, - item?.Version ?? string.Empty, - item?.Kind ?? PackageKind.ComponentLibrary, - item?.Description, - reason, - name != PackageResolver.Core && at > 0 && MoveIsValid(stored, name, MoveDirection.Up, index), - name != PackageResolver.Core && at < names.Count - 1 && MoveIsValid(stored, name, MoveDirection.Down, index), - packageLock); - }).ToList(); - - var listed = stored.ToHashSet(StringComparer.Ordinal); - var addable = catalog.Where(item => !listed.Contains(item.Name)).Select(item => - { - var alsoAdds = ClosureOf(item.Name, listed, index).Order.Where(name => name != item.Name).Select(name => NameOf(index, name)).ToList(); - return new AddablePackage(item.Name, NameOf(index, item.Name), item.Version, item.Kind, item.Description, alsoAdds, alsoAdds.Count == 0 ? string.Empty : $"Also adds {string.Join(", ", alsoAdds)}"); - }).ToList(); - - var byComponent = new Dictionary>(StringComparer.Ordinal); - foreach (var name in names) - { - foreach (var component in index.TryGetValue(name, out var item) ? item.Components : []) - { - if (!byComponent.TryGetValue(component, out var suppliers)) - { - byComponent[component] = suppliers = []; - } - - if (!suppliers.Contains(name)) - { - suppliers.Add(name); - } - } - } - - var supplierTable = byComponent - .OrderBy(pair => pair.Key, StringComparer.Ordinal) - .Select(pair => new ComponentSupplier(pair.Key, pair.Value[0], pair.Value.Skip(1).ToList())) - .ToList(); - - return new PackageListing(rows, addable, supplierTable); + var context = new RowContext(names, stored, shell, index); + var rows = names.Select((name, at) => RowOf(name, at, context)).ToList(); + var addable = AddableOf(catalog, stored.ToHashSet(StringComparer.Ordinal), index); + return new PackageListing(rows, addable, SuppliersOf(names, index)); } /// @@ -141,7 +75,7 @@ public static PackageEdit Add(UiProfile profile, IReadOnlyList cat var (order, unknown) = ClosureOf(name, stored.ToHashSet(StringComparer.Ordinal), index); if (unknown.Count > 0) { - return Refused(profile, $"Package '{NameOf(index, name)}' needs {string.Join(", ", unknown.Select(item => $"'{item}'"))}, which {(unknown.Count == 1 ? "is" : "are")} not available."); + return Refused(profile, NeedsMessage($"Package '{NameOf(index, name)}'", unknown)); } var added = order.ConvertAll(item => NameOf(index, item)); @@ -236,41 +170,15 @@ public static PackageEdit ChooseBlueprint(UiProfile profile, IReadOnlyList(); - if (previous is not null && previous != blueprint && stored.Contains(previous)) - { - var needed = stored.Exists(other => other != previous && DependenciesOf(other, index).Contains(previous)); - if (!needed) - { - stored = [.. stored.Where(candidate => candidate != previous)]; - removed.Add(previous); - } - } - + var (stored, removed) = RetirePreviousShell(StoredOf(profile), ShellBlueprintOf(profile), blueprint, index); var (order, unknown) = ClosureOf(blueprint, stored.ToHashSet(StringComparer.Ordinal), index); if (unknown.Count > 0) { - return Refused(profile, $"{NameOf(index, blueprint)} needs {string.Join(", ", unknown.Select(item => $"'{item}'"))}, which {(unknown.Count == 1 ? "is" : "are")} not available."); + return Refused(profile, NeedsMessage(NameOf(index, blueprint), unknown)); } var added = order.Where(item => item != blueprint).ToList(); - stored = InsertAboveCore(stored, added, index); - - var dependedOn = stored.Exists(other => other != blueprint && DependenciesOf(other, index).Contains(blueprint)); - if (stored.Contains(blueprint)) - { - if (!dependedOn) - { - stored = [.. stored.Where(candidate => candidate != blueprint), blueprint]; - } - } - else - { - stored.Add(blueprint); - added.Add(blueprint); - } + stored = PlaceBlueprint(InsertAboveCore(stored, added, index), blueprint, added, index); var template = new TemplateReference(TemplateReferenceSource.Blueprint, shellId, blueprint, ProfileMigrator.PinOf(scenePackage.Version)); var next = WithPackages(profile, stored) with { Root = new RootTemplate(template, profile.Root?.Options) }; @@ -300,6 +208,106 @@ public static PackageEdit ChooseTheme(UiProfile profile, IReadOnlyList other != name && DependenciesOf(other, index).Contains(name)).Select(other => NameOf(index, other)).ToList(); + var displayName = NameOf(index, name); + var (reason, packageLock) = ReasonOf(name, shell, displayName, needed); + return new PackageRow( + at + 1, + name, + displayName, + item?.Version ?? string.Empty, + item?.Kind ?? PackageKind.ComponentLibrary, + item?.Description, + reason, + name != PackageResolver.Core && at > 0 && MoveIsValid(stored, name, MoveDirection.Up, index), + name != PackageResolver.Core && at < names.Count - 1 && MoveIsValid(stored, name, MoveDirection.Down, index), + packageLock); + } + + static (PackageReason Reason, PackageLock? Lock) ReasonOf(string name, string? shell, string displayName, List needed) + { + if (name == PackageResolver.Core) + { + return (new(PackageReasonKind.AlwaysLast, [], "Always last"), new(PackageLockKind.Core, [], $"{displayName} is the fallback every component name resolves against, so it is always last and cannot be removed.")); + } + + if (name == shell) + { + return (new(PackageReasonKind.UsedByShell, [], "Used by Shell"), new(PackageLockKind.Shell, [], $"{displayName} is the blueprint of the current shell, so it stays while it is the shell.")); + } + + if (needed.Count > 0) + { + return (new(PackageReasonKind.NeededBy, needed, $"Needed by {string.Join(", ", needed)}"), new(PackageLockKind.NeededBy, needed, $"{displayName} is needed by {string.Join(", ", needed)}, so it stays while they are listed.")); + } + + return (new(PackageReasonKind.Chosen, [], "Chosen"), null); + } + + static List AddableOf(IReadOnlyList catalog, HashSet listed, Dictionary index) => + [.. catalog.Where(item => !listed.Contains(item.Name)).Select(item => + { + var alsoAdds = ClosureOf(item.Name, listed, index).Order.Where(name => name != item.Name).Select(name => NameOf(index, name)).ToList(); + return new AddablePackage(item.Name, NameOf(index, item.Name), item.Version, item.Kind, item.Description, alsoAdds, alsoAdds.Count == 0 ? string.Empty : $"Also adds {string.Join(", ", alsoAdds)}"); + })]; + + static List SuppliersOf(List names, Dictionary index) + { + var byComponent = new Dictionary>(StringComparer.Ordinal); + foreach (var name in names) + { + foreach (var component in index.TryGetValue(name, out var item) ? item.Components : []) + { + if (!byComponent.TryGetValue(component, out var suppliers)) + { + byComponent[component] = suppliers = []; + } + + if (!suppliers.Contains(name)) + { + suppliers.Add(name); + } + } + } + + return [.. byComponent + .OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => new ComponentSupplier(pair.Key, pair.Value[0], pair.Value.Skip(1).ToList()))]; + } + + static string NeedsMessage(string subject, List unknown) => + $"{subject} needs {string.Join(", ", unknown.Select(item => $"'{item}'"))}, which {(unknown.Count == 1 ? "is" : "are")} not available."; + + static (List Stored, List Removed) RetirePreviousShell(List stored, string? previous, string blueprint, Dictionary index) + { + if (previous is null || !IsRetirable(stored, previous, blueprint)) + { + return (stored, []); + } + + var needed = stored.Exists(other => other != previous && DependenciesOf(other, index).Contains(previous)); + return needed ? (stored, []) : ([.. stored.Where(candidate => candidate != previous)], [previous]); + } + + static bool IsRetirable(List stored, string previous, string blueprint) => previous != blueprint && stored.Contains(previous); + + static List PlaceBlueprint(List stored, string blueprint, List added, Dictionary index) + { + var dependedOn = stored.Exists(other => other != blueprint && DependenciesOf(other, index).Contains(blueprint)); + if (!stored.Contains(blueprint)) + { + stored.Add(blueprint); + added.Add(blueprint); + return stored; + } + + return dependedOn ? stored : [.. stored.Where(candidate => candidate != blueprint), blueprint]; + } + static Dictionary IndexOf(IReadOnlyList catalog) { var index = new Dictionary(StringComparer.Ordinal); @@ -330,9 +338,11 @@ static bool IsOrdered(List stored, Dictionary inde var unknown = new List(); var visiting = new HashSet(StringComparer.Ordinal); + bool IsVisited(string current) => order.Contains(current) || visiting.Contains(current); + void Visit(string current) { - if (listed.Contains(current) || order.Contains(current) || visiting.Contains(current)) + if (listed.Contains(current) || IsVisited(current)) { return; } @@ -383,7 +393,7 @@ static bool MoveIsValid(List stored, string name, MoveDirection directio { var at = stored.IndexOf(name); var other = direction == MoveDirection.Up ? at + 1 : at - 1; - if (at < 0 || other < 0 || other >= stored.Count || stored[other] == PackageResolver.Core || name == PackageResolver.Core) + if (name == PackageResolver.Core || !IsSwappable(stored, at, other)) { return false; } @@ -393,10 +403,17 @@ static bool MoveIsValid(List stored, string name, MoveDirection directio return IsOrdered(next, index); } + static bool IsInsideList(List stored, int position) => position >= 0 && position < stored.Count; + + static bool IsSwappable(List stored, int at, int other) => + IsInsideList(stored, at) && IsInsideList(stored, other) && stored[other] != PackageResolver.Core; + static UiProfile WithPackages(UiProfile profile, List packages) => profile with { Packages = profile.Packages.Contains(PackageResolver.Core) ? packages : [.. packages.Where(name => name != PackageResolver.Core)] }; static PackageEdit Unchanged(UiProfile profile) => new(profile, PackageEditOutcome.Unchanged, [], [], string.Empty); static PackageEdit Refused(UiProfile profile, string message) => new(profile, PackageEditOutcome.Refused, [], [], message); + + sealed record RowContext(List Names, List Stored, string? Shell, Dictionary Index); } diff --git a/Source/JavaScript/engine/profiles/packageList.ts b/Source/JavaScript/engine/profiles/packageList.ts index 61381dbc..2549b6ff 100644 --- a/Source/JavaScript/engine/profiles/packageList.ts +++ b/Source/JavaScript/engine/profiles/packageList.ts @@ -172,8 +172,9 @@ function closureOf(name: string, listed: ReadonlySet, index: Map(); + const isVisited = (current: string) => order.includes(current) || visiting.has(current); const visit = (current: string) => { - if (listed.has(current) || order.includes(current) || visiting.has(current)) return; + if (listed.has(current) || isVisited(current)) return; if (!index.has(current)) { if (!unknown.includes(current)) unknown.push(current); return; @@ -228,45 +229,70 @@ export function describePackageList(profile: UiProfile, catalog: readonly SceneP const names = [...stored].reverse(); const shell = shellBlueprintOf(profile); - const needers = (name: string) => names.filter(other => other !== name && dependenciesOf(other, index).includes(name)); - - const rows = names.map((name, at): PackageRow => { - const item = index.get(name); - const needed = needers(name).map(other => nameOf(index.get(other), other)); - const shellName = nameOf(item, name); - let reason: PackageReason; - let lock: PackageLock | undefined; - if (name === corePackage) { - reason = { kind: PackageReasonKind.AlwaysLast, packages: [], text: 'Always last' }; - lock = { kind: PackageLockKind.Core, packages: [], text: `${shellName} is the fallback every component name resolves against, so it is always last and cannot be removed.` }; - } else if (name === shell) { - reason = { kind: PackageReasonKind.UsedByShell, packages: [], text: 'Used by Shell' }; - lock = { kind: PackageLockKind.Shell, packages: [], text: `${shellName} is the blueprint of the current shell, so it stays while it is the shell.` }; - } else if (needed.length > 0) { - reason = { kind: PackageReasonKind.NeededBy, packages: needed, text: `Needed by ${needed.join(', ')}` }; - lock = { kind: PackageLockKind.NeededBy, packages: needed, text: `${shellName} is needed by ${needed.join(', ')}, so it stays while they are listed.` }; - } else { - reason = { kind: PackageReasonKind.Chosen, packages: [], text: 'Chosen' }; - } + const context: RowContext = { names, stored, shell, index }; + const rows = names.map((name, at) => rowOf(name, at, context)); + const addable = addableOf(catalog, new Set(stored), index); + return { rows, addable, suppliers: suppliersOf(names, index) }; +} + +interface RowContext { + names: string[]; + stored: string[]; + shell: string | undefined; + index: Map; +} + +const canMove = (name: string, at: number, direction: MoveDirection, { names, stored, index }: RowContext) => + name !== corePackage && (direction === MoveDirection.Up ? at > 0 : at < names.length - 1) && moveIsValid(stored, name, direction, index); - const canMoveUp = name !== corePackage && at > 0 && moveIsValid(stored, name, MoveDirection.Up, index); - const canMoveDown = name !== corePackage && at < names.length - 1 && moveIsValid(stored, name, MoveDirection.Down, index); +function rowOf(name: string, at: number, context: RowContext): PackageRow { + const { names, shell, index } = context; + const item = index.get(name); + const needed = names.filter(other => other !== name && dependenciesOf(other, index).includes(name)).map(other => nameOf(index.get(other), other)); + const { reason, lock } = reasonOf(name, shell, nameOf(item, name), needed); + const canMoveUp = canMove(name, at, MoveDirection.Up, context); + const canMoveDown = canMove(name, at, MoveDirection.Down, context); + return { + position: at + 1, + name, + displayName: nameOf(item, name), + version: item?.version ?? '', + kind: item?.kind ?? PackageKind.ComponentLibrary, + ...(item?.description === undefined ? {} : { description: item.description }), + reason, + canMoveUp, + canMoveDown, + ...(lock === undefined ? {} : { lock }), + }; +} + +function reasonOf(name: string, shell: string | undefined, displayName: string, needed: string[]): { reason: PackageReason; lock?: PackageLock } { + if (name === corePackage) { return { - position: at + 1, - name, - displayName: nameOf(item, name), - version: item?.version ?? '', - kind: item?.kind ?? PackageKind.ComponentLibrary, - ...(item?.description === undefined ? {} : { description: item.description }), - reason, - canMoveUp, - canMoveDown, - ...(lock === undefined ? {} : { lock }), + reason: { kind: PackageReasonKind.AlwaysLast, packages: [], text: 'Always last' }, + lock: { kind: PackageLockKind.Core, packages: [], text: `${displayName} is the fallback every component name resolves against, so it is always last and cannot be removed.` }, }; - }); + } + + if (name === shell) { + return { + reason: { kind: PackageReasonKind.UsedByShell, packages: [], text: 'Used by Shell' }, + lock: { kind: PackageLockKind.Shell, packages: [], text: `${displayName} is the blueprint of the current shell, so it stays while it is the shell.` }, + }; + } + + if (needed.length > 0) { + return { + reason: { kind: PackageReasonKind.NeededBy, packages: needed, text: `Needed by ${needed.join(', ')}` }, + lock: { kind: PackageLockKind.NeededBy, packages: needed, text: `${displayName} is needed by ${needed.join(', ')}, so it stays while they are listed.` }, + }; + } + + return { reason: { kind: PackageReasonKind.Chosen, packages: [], text: 'Chosen' } }; +} - const listed = new Set(stored); - const addable = catalog.filter(item => !listed.has(item.name)).map((item): AddablePackage => { +function addableOf(catalog: readonly ScenePackage[], listed: ReadonlySet, index: Map): AddablePackage[] { + return catalog.filter(item => !listed.has(item.name)).map((item): AddablePackage => { const alsoAdds = closureOf(item.name, listed, index).order.filter(name => name !== item.name).map(name => nameOf(index.get(name), name)); return { name: item.name, @@ -278,7 +304,9 @@ export function describePackageList(profile: UiProfile, catalog: readonly SceneP text: alsoAdds.length === 0 ? '' : `Also adds ${alsoAdds.join(', ')}`, }; }); +} +function suppliersOf(names: readonly string[], index: Map): ComponentSupplier[] { const byComponent = new Map(); for (const name of names) { for (const component of index.get(name)?.components ?? []) { @@ -288,11 +316,30 @@ export function describePackageList(profile: UiProfile, catalog: readonly SceneP } } - const suppliers = [...byComponent.entries()] + return [...byComponent.entries()] .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) .map(([component, supplying]): ComponentSupplier => ({ component, winner: supplying[0], others: supplying.slice(1) })); +} + +const needsMessage = (subject: string, unknown: readonly string[]) => + `${subject} needs ${unknown.map(item => `'${item}'`).join(', ')}, which ${unknown.length === 1 ? 'is' : 'are'} not available.`; + +function retirePreviousShell(stored: string[], previous: string | undefined, blueprint: string, index: Map): { stored: string[]; removed: string[] } { + if (previous === undefined || !isRetirable(stored, previous, blueprint)) return { stored, removed: [] }; + const needed = stored.some(other => other !== previous && dependenciesOf(other, index).includes(previous)); + return needed ? { stored, removed: [] } : { stored: stored.filter(candidate => candidate !== previous), removed: [previous] }; +} + +const isRetirable = (stored: readonly string[], previous: string, blueprint: string) => previous !== blueprint && stored.includes(previous); + +function placeBlueprint(stored: string[], blueprint: string, added: string[], index: Map): string[] { + const dependedOn = stored.some(other => other !== blueprint && dependenciesOf(other, index).includes(blueprint)); + if (!stored.includes(blueprint)) { + added.push(blueprint); + return [...stored, blueprint]; + } - return { rows, addable, suppliers }; + return dependedOn ? stored : [...stored.filter(candidate => candidate !== blueprint), blueprint]; } /** Who supplies a component name in the list, or undefined when no listed package does. */ @@ -300,11 +347,15 @@ export function findSupplier(list: PackageList, component: string): ComponentSup return list.suppliers.find(supplier => supplier.component === component); } +const isInsideList = (stored: readonly string[], position: number) => position >= 0 && position < stored.length; + +const isSwappable = (stored: readonly string[], at: number, other: number) => isInsideList(stored, at) && isInsideList(stored, other) && stored[other] !== corePackage; + function moveIsValid(stored: readonly string[], name: string, direction: MoveDirection, index: Map): boolean { const at = stored.indexOf(name); // Up in the designer is later in the stored list. const other = direction === MoveDirection.Up ? at + 1 : at - 1; - if (at < 0 || other < 0 || other >= stored.length || stored[other] === corePackage || name === corePackage) return false; + if (name === corePackage || !isSwappable(stored, at, other)) return false; const next = [...stored]; [next[at], next[other]] = [next[other], next[at]]; return isOrdered(next, index); @@ -325,7 +376,7 @@ export function addPackage(profile: UiProfile, catalog: readonly ScenePackage[], if (!index.has(name)) return refused(profile, `Package '${name}' is not available.`); const { order, unknown } = closureOf(name, new Set(stored), index); - if (unknown.length > 0) return refused(profile, `Package '${nameOf(index.get(name), name)}' needs ${unknown.map(item => `'${item}'`).join(', ')}, which ${unknown.length === 1 ? 'is' : 'are'} not available.`); + if (unknown.length > 0) return refused(profile, needsMessage(`Package '${nameOf(index.get(name), name)}'`, unknown)); const added = order.map(item => nameOf(index.get(item), item)); return { @@ -403,29 +454,12 @@ export function chooseBlueprint(profile: UiProfile, catalog: readonly ScenePacka const shellId = shell ?? scenePackage.layouts[0]; if (shellId === undefined) return refused(profile, `${nameOf(scenePackage, blueprint)} has no shell to offer.`); - let stored = storedOf(profile); - const previous = shellBlueprintOf(profile); - const removed: string[] = []; - if (previous !== undefined && previous !== blueprint && stored.includes(previous)) { - const needed = stored.some(other => other !== previous && dependenciesOf(other, index).includes(previous)); - if (!needed) { - stored = stored.filter(candidate => candidate !== previous); - removed.push(previous); - } - } - - const { order, unknown } = closureOf(blueprint, new Set(stored), index); - if (unknown.length > 0) return refused(profile, `${nameOf(scenePackage, blueprint)} needs ${unknown.map(item => `'${item}'`).join(', ')}, which ${unknown.length === 1 ? 'is' : 'are'} not available.`); + const retired = retirePreviousShell(storedOf(profile), shellBlueprintOf(profile), blueprint, index); + const { removed } = retired; + const { order, unknown } = closureOf(blueprint, new Set(retired.stored), index); + if (unknown.length > 0) return refused(profile, needsMessage(nameOf(scenePackage, blueprint), unknown)); const added = order.filter(item => item !== blueprint); - stored = insertAboveCore(stored, added, index); - - const dependedOn = stored.some(other => other !== blueprint && dependenciesOf(other, index).includes(blueprint)); - if (stored.includes(blueprint)) { - if (!dependedOn) stored = [...stored.filter(candidate => candidate !== blueprint), blueprint]; - } else { - stored = [...stored, blueprint]; - added.push(blueprint); - } + const stored = placeBlueprint(insertAboveCore(retired.stored, added, index), blueprint, added, index); const version = pinOf(scenePackage.version); const template: TemplateReference = { source: TemplateReferenceSource.Blueprint, id: shellId, blueprint, ...(version === undefined ? {} : { version }) }; From f4a32c51268cf83618371cff82c97d221b2f4d9b Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 11 Nov 2026 19:46:57 +0100 Subject: [PATCH 08/21] Adopt C# 15 idioms in the package list --- Source/DotNET/Engine/Profiles/PackageList.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Source/DotNET/Engine/Profiles/PackageList.cs b/Source/DotNET/Engine/Profiles/PackageList.cs index 634d2be9..6d5bea9c 100644 --- a/Source/DotNET/Engine/Profiles/PackageList.cs +++ b/Source/DotNET/Engine/Profiles/PackageList.cs @@ -42,7 +42,7 @@ public static PackageListing Describe(UiProfile profile, IReadOnlyList names = [.. Enumerable.Reverse(stored)]; var shell = ShellBlueprintOf(profile); var context = new RowContext(names, stored, shell, index); @@ -334,8 +334,8 @@ static bool IsOrdered(List stored, Dictionary inde static (List Order, List Unknown) ClosureOf(string name, HashSet listed, Dictionary index) { - var order = new List(); - var unknown = new List(); + List order = []; + List unknown = []; var visiting = new HashSet(StringComparer.Ordinal); bool IsVisited(string current) => order.Contains(current) || visiting.Contains(current); @@ -398,7 +398,7 @@ static bool MoveIsValid(List stored, string name, MoveDirection directio return false; } - var next = new List(stored); + List next = [.. stored]; (next[at], next[other]) = (next[other], next[at]); return IsOrdered(next, index); } From a6742163dacf91c3ef984644dfe8d87375298c1b Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 11 Nov 2026 19:51:02 +0100 Subject: [PATCH 09/21] Mark the package list raw goldens as JSON --- .../when_checking_against_the_shared_fixture_corpus.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Source/DotNET/Engine.Specs/for_PackageList/when_checking_against_the_shared_fixture_corpus.cs b/Source/DotNET/Engine.Specs/for_PackageList/when_checking_against_the_shared_fixture_corpus.cs index aa82e11f..da9fc009 100644 --- a/Source/DotNET/Engine.Specs/for_PackageList/when_checking_against_the_shared_fixture_corpus.cs +++ b/Source/DotNET/Engine.Specs/for_PackageList/when_checking_against_the_shared_fixture_corpus.cs @@ -144,7 +144,7 @@ void should_write_the_listing_the_way_the_designer_reads_it() var written = JsonSerializer.Serialize(PackageList.Describe(item["profile"].Deserialize(_readOptions), _packages), _options); - written.ShouldEqual("""{"rows":[{"position":1,"name":"material","displayName":"Material Components","version":"1.0.0","kind":"componentLibrary","description":"Material components.","reason":{"kind":"chosen","packages":[],"text":"Chosen"},"canMoveUp":false,"canMoveDown":false},{"position":2,"name":"tailwind","displayName":"Tailwind","version":"1.0.0","kind":"styling","description":"Utility styling.","reason":{"kind":"neededBy","packages":["Material Components"],"text":"Needed by Material Components"},"canMoveUp":false,"canMoveDown":true,"lock":{"kind":"neededBy","packages":["Material Components"],"text":"Tailwind is needed by Material Components, so it stays while they are listed."}},{"position":3,"name":"primereact","displayName":"PrimeReact","version":"1.0.0","kind":"componentLibrary","description":"PrimeReact components.","reason":{"kind":"chosen","packages":[],"text":"Chosen"},"canMoveUp":true,"canMoveDown":false},{"position":4,"name":"core","displayName":"Core","version":"1.0.0","kind":"componentLibrary","description":"The fallback components.","reason":{"kind":"alwaysLast","packages":[],"text":"Always last"},"canMoveUp":false,"canMoveDown":false,"lock":{"kind":"core","packages":[],"text":"Core is the fallback every component name resolves against, so it is always last and cannot be removed."}}],"addable":[{"name":"material-themes","displayName":"Material Themes","version":"1.0.0","kind":"styling","description":"Material themes.","alsoAdds":[],"text":""},{"name":"bp.default","displayName":"Cratis Default","version":"1.4.2","kind":"blueprint","description":"The default blueprint.","alsoAdds":[],"text":""},{"name":"bp.dashboard","displayName":"Dashboard","version":"2.0.0","kind":"blueprint","description":"Horizontal navigation.","alsoAdds":[],"text":""},{"name":"broken","displayName":"Broken","version":"1.0.0","kind":"componentLibrary","description":"Needs a package nobody has.","alsoAdds":[],"text":""}],"suppliers":[{"component":"button","winner":"material","others":["primereact","core"]},{"component":"card","winner":"primereact","others":["core"]},{"component":"datatable","winner":"material","others":["primereact"]},{"component":"table","winner":"core","others":[]}]}"""); + written.ShouldEqual(/*lang=json,strict*/ """{"rows":[{"position":1,"name":"material","displayName":"Material Components","version":"1.0.0","kind":"componentLibrary","description":"Material components.","reason":{"kind":"chosen","packages":[],"text":"Chosen"},"canMoveUp":false,"canMoveDown":false},{"position":2,"name":"tailwind","displayName":"Tailwind","version":"1.0.0","kind":"styling","description":"Utility styling.","reason":{"kind":"neededBy","packages":["Material Components"],"text":"Needed by Material Components"},"canMoveUp":false,"canMoveDown":true,"lock":{"kind":"neededBy","packages":["Material Components"],"text":"Tailwind is needed by Material Components, so it stays while they are listed."}},{"position":3,"name":"primereact","displayName":"PrimeReact","version":"1.0.0","kind":"componentLibrary","description":"PrimeReact components.","reason":{"kind":"chosen","packages":[],"text":"Chosen"},"canMoveUp":true,"canMoveDown":false},{"position":4,"name":"core","displayName":"Core","version":"1.0.0","kind":"componentLibrary","description":"The fallback components.","reason":{"kind":"alwaysLast","packages":[],"text":"Always last"},"canMoveUp":false,"canMoveDown":false,"lock":{"kind":"core","packages":[],"text":"Core is the fallback every component name resolves against, so it is always last and cannot be removed."}}],"addable":[{"name":"material-themes","displayName":"Material Themes","version":"1.0.0","kind":"styling","description":"Material themes.","alsoAdds":[],"text":""},{"name":"bp.default","displayName":"Cratis Default","version":"1.4.2","kind":"blueprint","description":"The default blueprint.","alsoAdds":[],"text":""},{"name":"bp.dashboard","displayName":"Dashboard","version":"2.0.0","kind":"blueprint","description":"Horizontal navigation.","alsoAdds":[],"text":""},{"name":"broken","displayName":"Broken","version":"1.0.0","kind":"componentLibrary","description":"Needs a package nobody has.","alsoAdds":[],"text":""}],"suppliers":[{"component":"button","winner":"material","others":["primereact","core"]},{"component":"card","winner":"primereact","others":["core"]},{"component":"datatable","winner":"material","others":["primereact"]},{"component":"table","winner":"core","others":[]}]}"""); } [Fact] @@ -154,7 +154,7 @@ void should_write_an_edit_with_what_it_added_in_the_order_it_added_it() var edit = PackageList.ChooseTheme(item["profile"].Deserialize(_readOptions), _packages, "Material Ocean"); - JsonSerializer.Serialize(edit, _options).ShouldEqual("""{"profile":{"name":"Desktop","targetPlatform":"web","packages":["core","tailwind","material","material-themes","primereact"],"theme":"Material Ocean"},"outcome":"changed","added":["tailwind","material","material-themes"],"removed":[],"message":"Theme \u0027Material Ocean\u0027 needs Material Themes, which was added."}"""); + JsonSerializer.Serialize(edit, _options).ShouldEqual(/*lang=json,strict*/ """{"profile":{"name":"Desktop","targetPlatform":"web","packages":["core","tailwind","material","material-themes","primereact"],"theme":"Material Ocean"},"outcome":"changed","added":["tailwind","material","material-themes"],"removed":[],"message":"Theme \u0027Material Ocean\u0027 needs Material Themes, which was added."}"""); } [Fact] From 57fba70adc63f4f3a315ed0b2b31f433690e4c9e Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 11 Nov 2026 19:55:09 +0100 Subject: [PATCH 10/21] Make the package list mutation guard capture content --- ...cking_against_the_shared_fixture_corpus.cs | 23 +++++++++---------- ...cking_against_the_shared_fixture_corpus.ts | 20 +++++++++++----- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/Source/DotNET/Engine.Specs/for_PackageList/when_checking_against_the_shared_fixture_corpus.cs b/Source/DotNET/Engine.Specs/for_PackageList/when_checking_against_the_shared_fixture_corpus.cs index da9fc009..5d06805b 100644 --- a/Source/DotNET/Engine.Specs/for_PackageList/when_checking_against_the_shared_fixture_corpus.cs +++ b/Source/DotNET/Engine.Specs/for_PackageList/when_checking_against_the_shared_fixture_corpus.cs @@ -37,6 +37,8 @@ public class when_checking_against_the_shared_fixture_corpus : Specification JsonArray _cases = null!; List<(string Name, JsonNode Expected, JsonNode Actual)> _results = null!; List _mutated = null!; + string _listing = null!; + string _themeEdit = null!; void Establish() { @@ -59,17 +61,22 @@ void Because() { var profile = item["profile"].Deserialize(_readOptions); var packages = item["packages"]?.Deserialize>(_readOptions) ?? _packages; - var before = JsonSerializer.Serialize((profile, packages), _options); + var before = JsonSerializer.Serialize(new { profile, packages }, _options); var result = Run(item, profile, packages); - if (JsonSerializer.Serialize((profile, packages), _options) != before) + if (JsonSerializer.Serialize(new { profile, packages }, _options) != before) { _mutated.Add(item["name"].GetValue()); } return result; })]; + _listing = JsonSerializer.Serialize(PackageList.Describe(CaseProfile("a package another needs"), _packages), _options); + _themeEdit = JsonSerializer.Serialize(PackageList.ChooseTheme(CaseProfile("choose a theme whose package is missing"), _packages, "Material Ocean"), _options); } + UiProfile CaseProfile(string name) => + _cases.Single(candidate => candidate["name"].GetValue() == name)["profile"].Deserialize(_readOptions)!; + static (string Name, JsonNode Expected, JsonNode Actual) Run(JsonNode item, UiProfile profile, List packages) { var action = item["action"]; @@ -140,21 +147,13 @@ void should_leave_the_profile_and_the_catalog_as_they_were() [Fact] void should_write_the_listing_the_way_the_designer_reads_it() { - var item = _cases.Single(candidate => candidate["name"].GetValue() == "a package another needs"); - - var written = JsonSerializer.Serialize(PackageList.Describe(item["profile"].Deserialize(_readOptions), _packages), _options); - - written.ShouldEqual(/*lang=json,strict*/ """{"rows":[{"position":1,"name":"material","displayName":"Material Components","version":"1.0.0","kind":"componentLibrary","description":"Material components.","reason":{"kind":"chosen","packages":[],"text":"Chosen"},"canMoveUp":false,"canMoveDown":false},{"position":2,"name":"tailwind","displayName":"Tailwind","version":"1.0.0","kind":"styling","description":"Utility styling.","reason":{"kind":"neededBy","packages":["Material Components"],"text":"Needed by Material Components"},"canMoveUp":false,"canMoveDown":true,"lock":{"kind":"neededBy","packages":["Material Components"],"text":"Tailwind is needed by Material Components, so it stays while they are listed."}},{"position":3,"name":"primereact","displayName":"PrimeReact","version":"1.0.0","kind":"componentLibrary","description":"PrimeReact components.","reason":{"kind":"chosen","packages":[],"text":"Chosen"},"canMoveUp":true,"canMoveDown":false},{"position":4,"name":"core","displayName":"Core","version":"1.0.0","kind":"componentLibrary","description":"The fallback components.","reason":{"kind":"alwaysLast","packages":[],"text":"Always last"},"canMoveUp":false,"canMoveDown":false,"lock":{"kind":"core","packages":[],"text":"Core is the fallback every component name resolves against, so it is always last and cannot be removed."}}],"addable":[{"name":"material-themes","displayName":"Material Themes","version":"1.0.0","kind":"styling","description":"Material themes.","alsoAdds":[],"text":""},{"name":"bp.default","displayName":"Cratis Default","version":"1.4.2","kind":"blueprint","description":"The default blueprint.","alsoAdds":[],"text":""},{"name":"bp.dashboard","displayName":"Dashboard","version":"2.0.0","kind":"blueprint","description":"Horizontal navigation.","alsoAdds":[],"text":""},{"name":"broken","displayName":"Broken","version":"1.0.0","kind":"componentLibrary","description":"Needs a package nobody has.","alsoAdds":[],"text":""}],"suppliers":[{"component":"button","winner":"material","others":["primereact","core"]},{"component":"card","winner":"primereact","others":["core"]},{"component":"datatable","winner":"material","others":["primereact"]},{"component":"table","winner":"core","others":[]}]}"""); + _listing.ShouldEqual(/*lang=json,strict*/ """{"rows":[{"position":1,"name":"material","displayName":"Material Components","version":"1.0.0","kind":"componentLibrary","description":"Material components.","reason":{"kind":"chosen","packages":[],"text":"Chosen"},"canMoveUp":false,"canMoveDown":false},{"position":2,"name":"tailwind","displayName":"Tailwind","version":"1.0.0","kind":"styling","description":"Utility styling.","reason":{"kind":"neededBy","packages":["Material Components"],"text":"Needed by Material Components"},"canMoveUp":false,"canMoveDown":true,"lock":{"kind":"neededBy","packages":["Material Components"],"text":"Tailwind is needed by Material Components, so it stays while they are listed."}},{"position":3,"name":"primereact","displayName":"PrimeReact","version":"1.0.0","kind":"componentLibrary","description":"PrimeReact components.","reason":{"kind":"chosen","packages":[],"text":"Chosen"},"canMoveUp":true,"canMoveDown":false},{"position":4,"name":"core","displayName":"Core","version":"1.0.0","kind":"componentLibrary","description":"The fallback components.","reason":{"kind":"alwaysLast","packages":[],"text":"Always last"},"canMoveUp":false,"canMoveDown":false,"lock":{"kind":"core","packages":[],"text":"Core is the fallback every component name resolves against, so it is always last and cannot be removed."}}],"addable":[{"name":"material-themes","displayName":"Material Themes","version":"1.0.0","kind":"styling","description":"Material themes.","alsoAdds":[],"text":""},{"name":"bp.default","displayName":"Cratis Default","version":"1.4.2","kind":"blueprint","description":"The default blueprint.","alsoAdds":[],"text":""},{"name":"bp.dashboard","displayName":"Dashboard","version":"2.0.0","kind":"blueprint","description":"Horizontal navigation.","alsoAdds":[],"text":""},{"name":"broken","displayName":"Broken","version":"1.0.0","kind":"componentLibrary","description":"Needs a package nobody has.","alsoAdds":[],"text":""}],"suppliers":[{"component":"button","winner":"material","others":["primereact","core"]},{"component":"card","winner":"primereact","others":["core"]},{"component":"datatable","winner":"material","others":["primereact"]},{"component":"table","winner":"core","others":[]}]}"""); } [Fact] void should_write_an_edit_with_what_it_added_in_the_order_it_added_it() { - var item = _cases.Single(candidate => candidate["name"].GetValue() == "choose a theme whose package is missing"); - - var edit = PackageList.ChooseTheme(item["profile"].Deserialize(_readOptions), _packages, "Material Ocean"); - - JsonSerializer.Serialize(edit, _options).ShouldEqual(/*lang=json,strict*/ """{"profile":{"name":"Desktop","targetPlatform":"web","packages":["core","tailwind","material","material-themes","primereact"],"theme":"Material Ocean"},"outcome":"changed","added":["tailwind","material","material-themes"],"removed":[],"message":"Theme \u0027Material Ocean\u0027 needs Material Themes, which was added."}"""); + _themeEdit.ShouldEqual(/*lang=json,strict*/ """{"profile":{"name":"Desktop","targetPlatform":"web","packages":["core","tailwind","material","material-themes","primereact"],"theme":"Material Ocean"},"outcome":"changed","added":["tailwind","material","material-themes"],"removed":[],"message":"Theme \u0027Material Ocean\u0027 needs Material Themes, which was added."}"""); } [Fact] diff --git a/Source/JavaScript/engine/for_packageList/when_checking_against_the_shared_fixture_corpus.ts b/Source/JavaScript/engine/for_packageList/when_checking_against_the_shared_fixture_corpus.ts index 190155ea..00a7ed95 100644 --- a/Source/JavaScript/engine/for_packageList/when_checking_against_the_shared_fixture_corpus.ts +++ b/Source/JavaScript/engine/for_packageList/when_checking_against_the_shared_fixture_corpus.ts @@ -62,21 +62,29 @@ describe('when listing and editing the packages of a profile against the shared }); } - it('should leave the profile and the catalog as they were', () => { - corpus.cases.forEach(fixture => answerOf(fixture)); + describe('and every case has been answered', () => { + let listing = ''; + let themeEdit = ''; + + beforeEach(() => { + corpus.cases.forEach(fixture => answerOf(fixture)); + const profileOf = (name: string) => corpus.cases.find(candidate => candidate.name === name)!.profile; + listing = JSON.stringify(describePackageList(profileOf('a package another needs'), corpus.packages)); + themeEdit = JSON.stringify(chooseTheme(profileOf('choose a theme whose package is missing'), corpus.packages, 'Material Ocean')); + }); + it('should leave the profile and the catalog as they were', () => { JSON.stringify(corpus.cases.map(fixture => ({ profile: fixture.profile, packages: fixture.packages }))).should.equal(pristine.cases); JSON.stringify(corpus.packages).should.equal(pristine.catalog); }); it('should write the listing the way the designer reads it', () => { - const fixture = corpus.cases.find(candidate => candidate.name === 'a package another needs')!; - JSON.stringify(describePackageList(fixture.profile, corpus.packages)).should.equal("{\"rows\":[{\"position\":1,\"name\":\"material\",\"displayName\":\"Material Components\",\"version\":\"1.0.0\",\"kind\":\"ComponentLibrary\",\"description\":\"Material components.\",\"reason\":{\"kind\":\"chosen\",\"packages\":[],\"text\":\"Chosen\"},\"canMoveUp\":false,\"canMoveDown\":false},{\"position\":2,\"name\":\"tailwind\",\"displayName\":\"Tailwind\",\"version\":\"1.0.0\",\"kind\":\"Styling\",\"description\":\"Utility styling.\",\"reason\":{\"kind\":\"neededBy\",\"packages\":[\"Material Components\"],\"text\":\"Needed by Material Components\"},\"canMoveUp\":false,\"canMoveDown\":true,\"lock\":{\"kind\":\"neededBy\",\"packages\":[\"Material Components\"],\"text\":\"Tailwind is needed by Material Components, so it stays while they are listed.\"}},{\"position\":3,\"name\":\"primereact\",\"displayName\":\"PrimeReact\",\"version\":\"1.0.0\",\"kind\":\"ComponentLibrary\",\"description\":\"PrimeReact components.\",\"reason\":{\"kind\":\"chosen\",\"packages\":[],\"text\":\"Chosen\"},\"canMoveUp\":true,\"canMoveDown\":false},{\"position\":4,\"name\":\"core\",\"displayName\":\"Core\",\"version\":\"1.0.0\",\"kind\":\"ComponentLibrary\",\"description\":\"The fallback components.\",\"reason\":{\"kind\":\"alwaysLast\",\"packages\":[],\"text\":\"Always last\"},\"canMoveUp\":false,\"canMoveDown\":false,\"lock\":{\"kind\":\"core\",\"packages\":[],\"text\":\"Core is the fallback every component name resolves against, so it is always last and cannot be removed.\"}}],\"addable\":[{\"name\":\"material-themes\",\"displayName\":\"Material Themes\",\"version\":\"1.0.0\",\"kind\":\"Styling\",\"description\":\"Material themes.\",\"alsoAdds\":[],\"text\":\"\"},{\"name\":\"bp.default\",\"displayName\":\"Cratis Default\",\"version\":\"1.4.2\",\"kind\":\"Blueprint\",\"description\":\"The default blueprint.\",\"alsoAdds\":[],\"text\":\"\"},{\"name\":\"bp.dashboard\",\"displayName\":\"Dashboard\",\"version\":\"2.0.0\",\"kind\":\"Blueprint\",\"description\":\"Horizontal navigation.\",\"alsoAdds\":[],\"text\":\"\"},{\"name\":\"broken\",\"displayName\":\"Broken\",\"version\":\"1.0.0\",\"kind\":\"ComponentLibrary\",\"description\":\"Needs a package nobody has.\",\"alsoAdds\":[],\"text\":\"\"}],\"suppliers\":[{\"component\":\"button\",\"winner\":\"material\",\"others\":[\"primereact\",\"core\"]},{\"component\":\"card\",\"winner\":\"primereact\",\"others\":[\"core\"]},{\"component\":\"datatable\",\"winner\":\"material\",\"others\":[\"primereact\"]},{\"component\":\"table\",\"winner\":\"core\",\"others\":[]}]}"); + listing.should.equal("{\"rows\":[{\"position\":1,\"name\":\"material\",\"displayName\":\"Material Components\",\"version\":\"1.0.0\",\"kind\":\"ComponentLibrary\",\"description\":\"Material components.\",\"reason\":{\"kind\":\"chosen\",\"packages\":[],\"text\":\"Chosen\"},\"canMoveUp\":false,\"canMoveDown\":false},{\"position\":2,\"name\":\"tailwind\",\"displayName\":\"Tailwind\",\"version\":\"1.0.0\",\"kind\":\"Styling\",\"description\":\"Utility styling.\",\"reason\":{\"kind\":\"neededBy\",\"packages\":[\"Material Components\"],\"text\":\"Needed by Material Components\"},\"canMoveUp\":false,\"canMoveDown\":true,\"lock\":{\"kind\":\"neededBy\",\"packages\":[\"Material Components\"],\"text\":\"Tailwind is needed by Material Components, so it stays while they are listed.\"}},{\"position\":3,\"name\":\"primereact\",\"displayName\":\"PrimeReact\",\"version\":\"1.0.0\",\"kind\":\"ComponentLibrary\",\"description\":\"PrimeReact components.\",\"reason\":{\"kind\":\"chosen\",\"packages\":[],\"text\":\"Chosen\"},\"canMoveUp\":true,\"canMoveDown\":false},{\"position\":4,\"name\":\"core\",\"displayName\":\"Core\",\"version\":\"1.0.0\",\"kind\":\"ComponentLibrary\",\"description\":\"The fallback components.\",\"reason\":{\"kind\":\"alwaysLast\",\"packages\":[],\"text\":\"Always last\"},\"canMoveUp\":false,\"canMoveDown\":false,\"lock\":{\"kind\":\"core\",\"packages\":[],\"text\":\"Core is the fallback every component name resolves against, so it is always last and cannot be removed.\"}}],\"addable\":[{\"name\":\"material-themes\",\"displayName\":\"Material Themes\",\"version\":\"1.0.0\",\"kind\":\"Styling\",\"description\":\"Material themes.\",\"alsoAdds\":[],\"text\":\"\"},{\"name\":\"bp.default\",\"displayName\":\"Cratis Default\",\"version\":\"1.4.2\",\"kind\":\"Blueprint\",\"description\":\"The default blueprint.\",\"alsoAdds\":[],\"text\":\"\"},{\"name\":\"bp.dashboard\",\"displayName\":\"Dashboard\",\"version\":\"2.0.0\",\"kind\":\"Blueprint\",\"description\":\"Horizontal navigation.\",\"alsoAdds\":[],\"text\":\"\"},{\"name\":\"broken\",\"displayName\":\"Broken\",\"version\":\"1.0.0\",\"kind\":\"ComponentLibrary\",\"description\":\"Needs a package nobody has.\",\"alsoAdds\":[],\"text\":\"\"}],\"suppliers\":[{\"component\":\"button\",\"winner\":\"material\",\"others\":[\"primereact\",\"core\"]},{\"component\":\"card\",\"winner\":\"primereact\",\"others\":[\"core\"]},{\"component\":\"datatable\",\"winner\":\"material\",\"others\":[\"primereact\"]},{\"component\":\"table\",\"winner\":\"core\",\"others\":[]}]}"); }); it('should write an edit with what it added in the order it added it', () => { - const fixture = corpus.cases.find(candidate => candidate.name === 'choose a theme whose package is missing')!; - JSON.stringify(chooseTheme(fixture.profile, corpus.packages, 'Material Ocean')).should.equal("{\"profile\":{\"name\":\"Desktop\",\"targetPlatform\":\"web\",\"packages\":[\"core\",\"tailwind\",\"material\",\"material-themes\",\"primereact\"],\"theme\":\"Material Ocean\"},\"outcome\":\"changed\",\"added\":[\"tailwind\",\"material\",\"material-themes\"],\"removed\":[],\"message\":\"Theme 'Material Ocean' needs Material Themes, which was added.\"}"); + themeEdit.should.equal("{\"profile\":{\"name\":\"Desktop\",\"targetPlatform\":\"web\",\"packages\":[\"core\",\"tailwind\",\"material\",\"material-themes\",\"primereact\"],\"theme\":\"Material Ocean\"},\"outcome\":\"changed\",\"added\":[\"tailwind\",\"material\",\"material-themes\"],\"removed\":[],\"message\":\"Theme 'Material Ocean' needs Material Themes, which was added.\"}"); + }); }); it('should have lower-case camel names for every enum value', () => camel(PackageEditOutcome.Added).should.equal(PackageEditOutcome.Added)); From 1c2fd49078d05611983f1c4741024bcac59d9d99 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 11 Nov 2026 20:02:25 +0100 Subject: [PATCH 11/21] Characterize package version range --- ...aring_against_the_shared_fixture_corpus.cs | 58 ++++++++++++++++ .../for_packageDependencies/fixtures.ts | 19 ++++++ ...aring_against_the_shared_fixture_corpus.ts | 25 +++++++ package-dependency-fixtures.json | 67 ++++++++++++++++++- 4 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 Source/DotNET/Engine.Specs/for_PackageVersionRange/when_validating_and_comparing_against_the_shared_fixture_corpus.cs create mode 100644 Source/JavaScript/engine/for_packageVersionRange/when_validating_and_comparing_against_the_shared_fixture_corpus.ts diff --git a/Source/DotNET/Engine.Specs/for_PackageVersionRange/when_validating_and_comparing_against_the_shared_fixture_corpus.cs b/Source/DotNET/Engine.Specs/for_PackageVersionRange/when_validating_and_comparing_against_the_shared_fixture_corpus.cs new file mode 100644 index 00000000..9f5d57a1 --- /dev/null +++ b/Source/DotNET/Engine.Specs/for_PackageVersionRange/when_validating_and_comparing_against_the_shared_fixture_corpus.cs @@ -0,0 +1,58 @@ +// 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.for_PackageDependencyResolver; +using Cratis.Scene.Engine.Packages; + +namespace Cratis.Scene.Engine.for_PackageVersionRange; + +public class when_validating_and_comparing_against_the_shared_fixture_corpus : Specification +{ + List<(string? Range, bool Expected, bool Actual)> _ranges = null!; + List<(string? Version, bool Expected, bool Actual)> _versions = null!; + List<(string Left, string Right, int Expected, int Actual)> _comparisons = null!; + + void Establish() + { + using var document = JsonDocument.Parse(File.ReadAllText(PackageFixtures.FixturePath)); + var root = document.RootElement; + _ranges = + [ + .. root.GetProperty("versionRangeValidityCases").EnumerateArray() + .Select(element => (Text(element, "range"), element.GetProperty("expected").GetBoolean(), false)) + ]; + _versions = + [ + .. root.GetProperty("versionValidityCases").EnumerateArray() + .Select(element => (Text(element, "version"), element.GetProperty("expected").GetBoolean(), false)) + ]; + _comparisons = + [ + .. root.GetProperty("versionComparisonCases").EnumerateArray() + .Select(element => (element.GetProperty("left").GetString()!, element.GetProperty("right").GetString()!, element.GetProperty("expected").GetInt32(), 0)) + ]; + } + + void Because() + { + _ranges = [.. _ranges.Select(row => (row.Range, row.Expected, PackageVersionRange.IsValidRange(row.Range)))]; + _versions = [.. _versions.Select(row => (row.Version, row.Expected, PackageVersionRange.IsValidVersion(row.Version)))]; + _comparisons = [.. _comparisons.Select(row => (row.Left, row.Right, row.Expected, Math.Sign(PackageVersionRange.CompareVersions(row.Left, row.Right))))]; + } + + [Fact] + void should_judge_every_range_as_the_corpus_does() => + ((List<(string, bool)>)[.. _ranges.Select(row => ($"range '{row.Range ?? ""}'", row.Actual))]).ShouldEqual([.. _ranges.Select(row => ($"range '{row.Range ?? ""}'", row.Expected))]); + + [Fact] + void should_judge_every_version_as_the_corpus_does() => + ((List<(string, bool)>)[.. _versions.Select(row => ($"version '{row.Version ?? ""}'", row.Actual))]).ShouldEqual([.. _versions.Select(row => ($"version '{row.Version ?? ""}'", row.Expected))]); + + [Fact] + void should_order_every_pair_as_the_corpus_does() => + ((List<(string, int)>)[.. _comparisons.Select(row => ($"'{row.Left}' against '{row.Right}'", row.Actual))]).ShouldEqual([.. _comparisons.Select(row => ($"'{row.Left}' against '{row.Right}'", row.Expected))]); + + static string? Text(JsonElement element, string property) => + element.GetProperty(property).ValueKind == JsonValueKind.Null ? null : element.GetProperty(property).GetString(); +} diff --git a/Source/JavaScript/engine/for_packageDependencies/fixtures.ts b/Source/JavaScript/engine/for_packageDependencies/fixtures.ts index 7a71d38e..6a846a0f 100644 --- a/Source/JavaScript/engine/for_packageDependencies/fixtures.ts +++ b/Source/JavaScript/engine/for_packageDependencies/fixtures.ts @@ -21,6 +21,22 @@ export interface VersionRangeFixtureCase { expected: boolean; } +export interface VersionRangeValidityFixtureCase { + range: string | null; + expected: boolean; +} + +export interface VersionValidityFixtureCase { + version: string | null; + expected: boolean; +} + +export interface VersionComparisonFixtureCase { + left: string; + right: string; + expected: number; +} + export interface CatalogFixtureCase { name: string; query: string; @@ -32,6 +48,9 @@ interface FixtureCorpus { catalog: ScenePackage[]; resolutionCases: ResolutionFixtureCase[]; versionRangeCases: VersionRangeFixtureCase[]; + versionRangeValidityCases: VersionRangeValidityFixtureCase[]; + versionValidityCases: VersionValidityFixtureCase[]; + versionComparisonCases: VersionComparisonFixtureCase[]; catalogCases: CatalogFixtureCase[]; } diff --git a/Source/JavaScript/engine/for_packageVersionRange/when_validating_and_comparing_against_the_shared_fixture_corpus.ts b/Source/JavaScript/engine/for_packageVersionRange/when_validating_and_comparing_against_the_shared_fixture_corpus.ts new file mode 100644 index 00000000..9b30fbb5 --- /dev/null +++ b/Source/JavaScript/engine/for_packageVersionRange/when_validating_and_comparing_against_the_shared_fixture_corpus.ts @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { compareVersions, isValidVersion, isValidVersionRange } from '../index'; +import { corpus } from '../for_packageDependencies/fixtures'; + +describe('when validating and comparing against the shared fixture corpus', () => { + for (const fixtureCase of corpus.versionRangeValidityCases) { + it(`should judge range '${fixtureCase.range ?? ''}' the same way the corpus does`, () => { + isValidVersionRange(fixtureCase.range ?? undefined).should.equal(fixtureCase.expected); + }); + } + + for (const fixtureCase of corpus.versionValidityCases) { + it(`should judge version '${fixtureCase.version ?? ''}' the same way the corpus does`, () => { + isValidVersion(fixtureCase.version ?? undefined).should.equal(fixtureCase.expected); + }); + } + + for (const fixtureCase of corpus.versionComparisonCases) { + it(`should order '${fixtureCase.left}' against '${fixtureCase.right}' the same way the corpus does`, () => { + Math.sign(compareVersions(fixtureCase.left, fixtureCase.right)).should.equal(fixtureCase.expected); + }); + } +}); diff --git a/package-dependency-fixtures.json b/package-dependency-fixtures.json index 384e0334..7908c36f 100644 --- a/package-dependency-fixtures.json +++ b/package-dependency-fixtures.json @@ -468,7 +468,72 @@ "version": "not-a-version", "range": "^1.0.0", "expected": false - } + }, + { "version": "10.8.9", "range": "~10.9.0", "expected": false }, + { "version": "1.2.3", "range": "~1.2", "expected": true }, + { "version": "10.9.8", "range": " ^10.9.0 ", "expected": true }, + { "version": "10.9.8", "range": "^ 10.9.0", "expected": true }, + { "version": "10.9.8", "range": "= 10.9.8", "expected": true }, + { "version": "10.9.8", "range": "1.2.3.4", "expected": false }, + { "version": "10.9.8", "range": ">=", "expected": false }, + { "version": "", "range": "*", "expected": true }, + { "version": "", "range": ">=1.0.0", "expected": false }, + { "version": "10.9.8", "range": "<=10.9.7", "expected": false }, + { "version": "10.9.8", "range": ">=10.9.8", "expected": true }, + { "version": "10.9.8", "range": "<11.0.0", "expected": true }, + { "version": "10.9.8", "range": ">10.9.9", "expected": false }, + { "version": "11.0.0", "range": "<=11", "expected": true }, + { "version": "1.2", "range": "1.2.0", "expected": true }, + { "version": "10.9.8+build.5", "range": "10.9.8", "expected": true }, + { "version": "1.a.3", "range": "^1.0.0", "expected": false }, + { "version": "-1.0.0", "range": "^1.0.0", "expected": false }, + { "version": "0.0.2", "range": "^0.0.3", "expected": false } + ], + "versionRangeValidityCases": [ + { "range": null, "expected": true }, + { "range": "", "expected": true }, + { "range": " ", "expected": true }, + { "range": "*", "expected": true }, + { "range": " * ", "expected": true }, + { "range": "^1.2.3", "expected": true }, + { "range": "~1.2", "expected": true }, + { "range": ">=1", "expected": true }, + { "range": "= 1.2.3", "expected": true }, + { "range": "1.2.3-beta.1", "expected": true }, + { "range": "garbage", "expected": false }, + { "range": "^", "expected": false }, + { "range": ">=x", "expected": false }, + { "range": "1.2.3.4", "expected": false }, + { "range": "^1.2.3.4", "expected": false }, + { "range": "=*", "expected": false } + ], + "versionValidityCases": [ + { "version": null, "expected": false }, + { "version": "", "expected": false }, + { "version": " ", "expected": false }, + { "version": "1", "expected": true }, + { "version": "1.2", "expected": true }, + { "version": "1.2.3", "expected": true }, + { "version": "1.2.3.4", "expected": false }, + { "version": "1.2.3-beta+5", "expected": true }, + { "version": "v1.2.3", "expected": false }, + { "version": "1.a.3", "expected": false }, + { "version": " 1.2.3 ", "expected": true }, + { "version": "-1", "expected": false }, + { "version": "1..3", "expected": false } + ], + "versionComparisonCases": [ + { "left": "1.2.3", "right": "1.2.3", "expected": 0 }, + { "left": "1.2.4", "right": "1.2.3", "expected": 1 }, + { "left": "1.2.3", "right": "1.3.0", "expected": -1 }, + { "left": "2.0.0", "right": "1.9.9", "expected": 1 }, + { "left": "1", "right": "1.0.0", "expected": 0 }, + { "left": "1.2.3-beta", "right": "1.2.3", "expected": 0 }, + { "left": "junk", "right": "1.0.0", "expected": -1 }, + { "left": "1.0.0", "right": "junk", "expected": 1 }, + { "left": "junk", "right": "other", "expected": 0 }, + { "left": "", "right": "1.0.0", "expected": -1 }, + { "left": "1.0.0", "right": "", "expected": 1 } ], "catalogCases": [ { From 3d0db9c58e0e974a5c9818732efd368bdd67e467 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 11 Nov 2026 20:06:26 +0100 Subject: [PATCH 12/21] Split package version range evaluation --- .../Engine/Packages/PackageVersionRange.cs | 121 ++++++++++++------ .../JavaScript/engine/packageVersionRange.ts | 52 +++++--- 2 files changed, 116 insertions(+), 57 deletions(-) diff --git a/Source/DotNET/Engine/Packages/PackageVersionRange.cs b/Source/DotNET/Engine/Packages/PackageVersionRange.cs index a62b2c18..f33f5c1c 100644 --- a/Source/DotNET/Engine/Packages/PackageVersionRange.cs +++ b/Source/DotNET/Engine/Packages/PackageVersionRange.cs @@ -1,6 +1,9 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; + namespace Cratis.Scene.Engine.Packages; /// @@ -21,6 +24,28 @@ namespace Cratis.Scene.Engine.Packages; /// public static class PackageVersionRange { + static readonly (string Token, RangeOperator Operator)[] _operators = + [ + (">=", RangeOperator.AtLeast), + ("<=", RangeOperator.AtMost), + ("^", RangeOperator.Caret), + ("~", RangeOperator.Tilde), + (">", RangeOperator.GreaterThan), + ("<", RangeOperator.LessThan), + ("=", RangeOperator.Exact) + ]; + + enum RangeOperator + { + Exact = 0, + Caret = 1, + Tilde = 2, + AtLeast = 3, + GreaterThan = 4, + AtMost = 5, + LessThan = 6 + } + /// /// Whether a version satisfies a range. /// @@ -30,7 +55,7 @@ public static class PackageVersionRange public static bool IsSatisfiedBy(string version, string? range) { var trimmed = range?.Trim(); - if (string.IsNullOrEmpty(trimmed) || trimmed == "*") + if (MatchesAnyVersion(trimmed)) { return true; } @@ -46,16 +71,7 @@ public static bool IsSatisfiedBy(string version, string? range) return false; } - return op switch - { - "^" => Compare(actual, required) >= 0 && Compare(actual, CaretUpperBound(required)) < 0, - "~" => Compare(actual, required) >= 0 && Compare(actual, (required.Major, required.Minor + 1, 0)) < 0, - ">=" => Compare(actual, required) >= 0, - ">" => Compare(actual, required) > 0, - "<=" => Compare(actual, required) <= 0, - "<" => Compare(actual, required) < 0, - _ => Compare(actual, required) == 0 - }; + return Satisfies(op, actual, required); } /// @@ -67,7 +83,7 @@ public static bool IsSatisfiedBy(string version, string? range) public static bool IsValidRange(string? range) { var trimmed = range?.Trim(); - return string.IsNullOrEmpty(trimmed) || trimmed == "*" || TryParse(Split(trimmed).Literal, out _); + return MatchesAnyVersion(trimmed) || TryParse(Split(trimmed).Literal, out _); } /// @@ -97,17 +113,41 @@ public static int CompareVersions(string? left, string? right) return !rightValid ? 1 : Compare(leftVersion, rightVersion); } - static (string Operator, string Literal) Split(string range) + static bool MatchesAnyVersion([NotNullWhen(false)] string? trimmedRange) => + string.IsNullOrEmpty(trimmedRange) || trimmedRange == "*"; + + static bool Satisfies(RangeOperator op, ParsedVersion actual, ParsedVersion required) => + op switch + { + RangeOperator.Caret => IsWithin(actual, required, CaretUpperBound(required)), + RangeOperator.Tilde => IsWithin(actual, required, new ParsedVersion(required.Major, required.Minor + 1, 0)), + _ => SatisfiesComparison(op, Compare(actual, required)) + }; + + static bool SatisfiesComparison(RangeOperator op, int comparison) => + op switch + { + RangeOperator.AtLeast => comparison >= 0, + RangeOperator.GreaterThan => comparison > 0, + RangeOperator.AtMost => comparison <= 0, + RangeOperator.LessThan => comparison < 0, + _ => comparison == 0 + }; + + static bool IsWithin(ParsedVersion actual, ParsedVersion lower, ParsedVersion upperExclusive) => + Compare(actual, lower) >= 0 && Compare(actual, upperExclusive) < 0; + + static (RangeOperator Operator, string Literal) Split(string range) { - foreach (var op in (string[])[">=", "<=", "^", "~", ">", "<", "="]) + foreach (var (token, op) in _operators) { - if (range.StartsWith(op, StringComparison.Ordinal)) + if (range.StartsWith(token, StringComparison.Ordinal)) { - return (op == "=" ? string.Empty : op, range[op.Length..].Trim()); + return (op, range[token.Length..].Trim()); } } - return (string.Empty, range); + return (RangeOperator.Exact, range); } /// @@ -117,38 +157,39 @@ public static int CompareVersions(string? left, string? right) /// /// The version the caret was written against. /// The exclusive upper bound the caret allows. - static (int Major, int Minor, int Patch) CaretUpperBound((int Major, int Minor, int Patch) version) => + static ParsedVersion CaretUpperBound(ParsedVersion version) => version switch { - { Major: > 0 } => (version.Major + 1, 0, 0), - { Minor: > 0 } => (0, version.Minor + 1, 0), - _ => (0, 0, version.Patch + 1) + { Major: > 0 } => new ParsedVersion(version.Major + 1, 0, 0), + { Minor: > 0 } => new ParsedVersion(0, version.Minor + 1, 0), + _ => new ParsedVersion(0, 0, version.Patch + 1) }; - static int Compare((int Major, int Minor, int Patch) left, (int Major, int Minor, int Patch) right) - { - if (left.Major != right.Major) return left.Major.CompareTo(right.Major); - if (left.Minor != right.Minor) return left.Minor.CompareTo(right.Minor); - return left.Patch.CompareTo(right.Patch); - } + static int Compare(ParsedVersion left, ParsedVersion right) => + (left.Major, left.Minor, left.Patch).CompareTo((right.Major, right.Minor, right.Patch)); - static bool TryParse(string value, out (int Major, int Minor, int Patch) version) + static bool TryParse(string value, out ParsedVersion version) { version = default; - var numeric = value.Trim(); - var suffix = numeric.IndexOfAny(['-', '+']); - if (suffix >= 0) - { - numeric = numeric[..suffix]; - } - - var parts = numeric.Split('.'); - if (parts.Length is 0 or > 3) + var parts = WithoutSuffix(value.Trim()).Split('.'); + if (parts.Length is 0 or > 3 || !TryParseComponents(parts, out var components)) { return false; } - var components = new int[3]; + version = new ParsedVersion(components[0], components[1], components[2]); + return true; + } + + static string WithoutSuffix(string numeric) + { + var suffix = numeric.IndexOfAny(['-', '+']); + return suffix >= 0 ? numeric[..suffix] : numeric; + } + + static bool TryParseComponents(string[] parts, out int[] components) + { + components = new int[3]; for (var index = 0; index < parts.Length; index++) { if (!int.TryParse(parts[index], out components[index]) || components[index] < 0) @@ -157,7 +198,9 @@ static bool TryParse(string value, out (int Major, int Minor, int Patch) version } } - version = (components[0], components[1], components[2]); return true; } + + [StructLayout(LayoutKind.Auto)] + readonly record struct ParsedVersion(int Major, int Minor, int Patch); } diff --git a/Source/JavaScript/engine/packageVersionRange.ts b/Source/JavaScript/engine/packageVersionRange.ts index 6ea87026..9f7d3b80 100644 --- a/Source/JavaScript/engine/packageVersionRange.ts +++ b/Source/JavaScript/engine/packageVersionRange.ts @@ -15,7 +15,7 @@ */ export function isVersionSatisfiedBy(version: string, range: string | undefined): boolean { const trimmed = range?.trim(); - if (!trimmed || trimmed === '*') return true; + if (matchesAnyVersion(trimmed)) return true; const actual = parseVersion(version); if (!actual) return false; @@ -24,22 +24,7 @@ export function isVersionSatisfiedBy(version: string, range: string | undefined) const required = parseVersion(literal); if (!required) return false; - switch (operator) { - case '^': - return compare(actual, required) >= 0 && compare(actual, caretUpperBound(required)) < 0; - case '~': - return compare(actual, required) >= 0 && compare(actual, [required[0], required[1] + 1, 0]) < 0; - case '>=': - return compare(actual, required) >= 0; - case '>': - return compare(actual, required) > 0; - case '<=': - return compare(actual, required) <= 0; - case '<': - return compare(actual, required) < 0; - default: - return compare(actual, required) === 0; - } + return satisfies(operator, actual, required); } /** @@ -49,7 +34,7 @@ export function isVersionSatisfiedBy(version: string, range: string | undefined) */ export function isValidVersionRange(range: string | undefined): boolean { const trimmed = range?.trim(); - if (!trimmed || trimmed === '*') return true; + if (matchesAnyVersion(trimmed)) return true; return parseVersion(splitRange(trimmed)[1]) !== undefined; } @@ -74,6 +59,37 @@ export function compareVersions(left: string, right: string): number { type Version = [number, number, number]; +const matchesAnyVersion = (trimmedRange: string | undefined): boolean => !trimmedRange || trimmedRange === '*'; + +function satisfies(operator: string, actual: Version, required: Version): boolean { + switch (operator) { + case '^': + return isWithin(actual, required, caretUpperBound(required)); + case '~': + return isWithin(actual, required, [required[0], required[1] + 1, 0]); + default: + return satisfiesComparison(operator, compare(actual, required)); + } +} + +function satisfiesComparison(operator: string, comparison: number): boolean { + switch (operator) { + case '>=': + return comparison >= 0; + case '>': + return comparison > 0; + case '<=': + return comparison <= 0; + case '<': + return comparison < 0; + default: + return comparison === 0; + } +} + +const isWithin = (actual: Version, lower: Version, upperExclusive: Version): boolean => + compare(actual, lower) >= 0 && compare(actual, upperExclusive) < 0; + function splitRange(range: string): [string, string] { for (const operator of ['>=', '<=', '^', '~', '>', '<', '=']) { if (range.startsWith(operator)) { From f10706268932085bf5ba80dae39128dc8fa5952f Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 11 Nov 2026 20:10:16 +0100 Subject: [PATCH 13/21] Characterize sparse profile fixes --- ...files_against_the_shared_fixture_corpus.cs | 58 +++++++++++++++++++ ...files_against_the_shared_fixture_corpus.ts | 32 ++++++++++ profile-blockers-fixtures.json | 38 ++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 Source/DotNET/Engine.Specs/for_ProfileBlockers/when_fixing_sparse_profiles_against_the_shared_fixture_corpus.cs create mode 100644 Source/JavaScript/engine/for_findProfileBlockers/when_fixing_sparse_profiles_against_the_shared_fixture_corpus.ts diff --git a/Source/DotNET/Engine.Specs/for_ProfileBlockers/when_fixing_sparse_profiles_against_the_shared_fixture_corpus.cs b/Source/DotNET/Engine.Specs/for_ProfileBlockers/when_fixing_sparse_profiles_against_the_shared_fixture_corpus.cs new file mode 100644 index 00000000..ccf4e10b --- /dev/null +++ b/Source/DotNET/Engine.Specs/for_ProfileBlockers/when_fixing_sparse_profiles_against_the_shared_fixture_corpus.cs @@ -0,0 +1,58 @@ +// 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 System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Cratis.Scene.Engine.Templates; +using Cratis.Scene.Model.Profiles; + +namespace Cratis.Scene.Engine.Profiles.for_ProfileBlockers; + +public class when_fixing_sparse_profiles_against_the_shared_fixture_corpus : Specification +{ + static readonly JsonSerializerOptions _options = new() + { + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() } + }; + + List<(string Name, UiProfile Profile, ProfileFix Fix, JsonNode? Expected)> _cases = null!; + List<(string Name, JsonNode? Expected, JsonNode? Actual)> _results = null!; + IReadOnlyList _problemsWithNothingToCheck = null!; + + void Establish() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Scene.slnx"))) + { + directory = directory.Parent; + } + + using var document = JsonDocument.Parse(File.ReadAllText(Path.Combine(directory!.FullName, "profile-blockers-fixtures.json"))); + _cases = + [ + .. document.RootElement.GetProperty("fixEdgeCases").EnumerateArray().Select(element => ( + element.GetProperty("name").GetString()!, + element.GetProperty("profile").Deserialize(_options)!, + element.GetProperty("fix").Deserialize(_options)!, + JsonNode.Parse(element.GetProperty("expected").GetRawText()))) + ]; + } + + void Because() + { + _results = [.. _cases.Select(item => (item.Name, item.Expected, JsonNode.Parse(UiProfileJson.Serialize(ProfileBlockers.ApplyFix(item.Profile, item.Fix, [])))))]; + _problemsWithNothingToCheck = ProfileBlockers.FindProblems(_cases[0].Profile, new ProfileBlockerContext([], new TemplateLibrary(null, []))); + } + + [Fact] + void should_give_the_profile_the_corpus_expects_for_every_sparse_fix() => + _results.Where(item => !JsonNode.DeepEquals(item.Expected, item.Actual)).Select(item => $"{item.Name}: expected {item.Expected?.ToJsonString()} but was {item.Actual?.ToJsonString()}").ShouldBeEmpty(); + + [Fact] + void should_cover_every_fix_kind() => _cases.Select(item => item.Fix.Kind).Distinct().Count().ShouldEqual(Enum.GetValues().Length); + + [Fact] + void should_find_no_problems_when_there_is_no_shell_theme_usage_or_own_library() => _problemsWithNothingToCheck.ShouldBeEmpty(); +} diff --git a/Source/JavaScript/engine/for_findProfileBlockers/when_fixing_sparse_profiles_against_the_shared_fixture_corpus.ts b/Source/JavaScript/engine/for_findProfileBlockers/when_fixing_sparse_profiles_against_the_shared_fixture_corpus.ts new file mode 100644 index 00000000..d0a03e49 --- /dev/null +++ b/Source/JavaScript/engine/for_findProfileBlockers/when_fixing_sparse_profiles_against_the_shared_fixture_corpus.ts @@ -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 { UiProfile } from '@cratis/scene.model'; +import { ProfileFix, applyProfileFix, findProfileProblems } from '../index'; + +interface FixEdgeCase { + name: string; + profile: UiProfile; + fix: ProfileFix; + expected: UiProfile; +} + +const corpus = JSON.parse(readFileSync(join(import.meta.dirname, '../../../../profile-blockers-fixtures.json'), 'utf-8')) as { fixEdgeCases: FixEdgeCase[] }; + +describe('when fixing sparse profiles against the shared fixture corpus', () => { + for (const fixture of corpus.fixEdgeCases) { + let result: unknown; + + beforeEach(() => { + result = JSON.parse(JSON.stringify(applyProfileFix(fixture.profile, fixture.fix, []))); + }); + + it(`should give the profile the corpus expects for ${fixture.name}`, () => (result as UiProfile).should.deep.equal(fixture.expected)); + } + + it('should find no problems when there is no shell, theme, usage or own library', () => { + findProfileProblems(corpus.fixEdgeCases[0].profile, { packages: [], library: { blueprints: [] } }).should.deep.equal([]); + }); +}); diff --git a/profile-blockers-fixtures.json b/profile-blockers-fixtures.json index c8b287f6..5f557825 100644 --- a/profile-blockers-fixtures.json +++ b/profile-blockers-fixtures.json @@ -1543,6 +1543,44 @@ "expected": [] } ], + "fixEdgeCases": [ + { + "name": "add package without a package name", + "profile": { "name": "Desktop", "targetPlatform": "web", "packages": ["core"] }, + "fix": { "kind": "addPackage", "label": "Add package" }, + "expected": { "name": "Desktop", "targetPlatform": "web", "packages": ["core"] } + }, + { + "name": "use theme accent without an appearance", + "profile": { "name": "Desktop", "targetPlatform": "web", "packages": ["core"] }, + "fix": { "kind": "useThemeAccent", "label": "Use theme accent" }, + "expected": { "name": "Desktop", "targetPlatform": "web", "packages": ["core"] } + }, + { + "name": "use default blueprint without a root", + "profile": { "name": "Desktop", "targetPlatform": "web", "packages": ["core"] }, + "fix": { "kind": "useDefaultBlueprint", "label": "Use default blueprint" }, + "expected": { "name": "Desktop", "targetPlatform": "web", "packages": ["core"] } + }, + { + "name": "use initials without an identity", + "profile": { "name": "Desktop", "targetPlatform": "web", "packages": ["core"] }, + "fix": { "kind": "useInitials", "label": "Use initials" }, + "expected": { "name": "Desktop", "targetPlatform": "web", "packages": ["core"], "identity": { "logo": { "kind": "Initials" } } } + }, + { + "name": "switch to drawer without a root", + "profile": { "name": "Desktop", "targetPlatform": "web", "packages": ["core"] }, + "fix": { "kind": "switchToDrawer", "label": "Switch to Drawer" }, + "expected": { "name": "Desktop", "targetPlatform": "web", "packages": ["core"] } + }, + { + "name": "switch to drawer for a root without options", + "profile": { "name": "Desktop", "targetPlatform": "web", "packages": ["core"], "root": { "template": { "source": "Own", "id": "OwnShell" } } }, + "fix": { "kind": "switchToDrawer", "label": "Switch to Drawer" }, + "expected": { "name": "Desktop", "targetPlatform": "web", "packages": ["core"], "root": { "template": { "source": "Own", "id": "OwnShell" }, "options": { "navigationMode": "drawer" } } } + } + ], "contrast": [ { "color": "#ffffff", From 9ed23e092715a19dbc7a089f8b1c036279b614f7 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 11 Nov 2026 20:11:33 +0100 Subject: [PATCH 14/21] Split profile blocker rules --- .../DotNET/Engine/Profiles/ProfileBlockers.cs | 331 ++++++++++-------- .../JavaScript/engine/packageVersionRange.ts | 2 +- .../engine/profiles/findProfileBlockers.ts | 129 +++---- 3 files changed, 249 insertions(+), 213 deletions(-) diff --git a/Source/DotNET/Engine/Profiles/ProfileBlockers.cs b/Source/DotNET/Engine/Profiles/ProfileBlockers.cs index 73bb01fa..a12a8f11 100644 --- a/Source/DotNET/Engine/Profiles/ProfileBlockers.cs +++ b/Source/DotNET/Engine/Profiles/ProfileBlockers.cs @@ -42,74 +42,15 @@ public static class ProfileBlockers /// The blockers, empty when the profile can be applied. public static IReadOnlyList Find(UiProfile profile, ProfileBlockerContext context) { - var blockers = new List(); - var name = profile.Name; - - var selection = PackageDependencyResolver.Resolve(profile.Packages, context.Packages); - blockers.AddRange(selection.Missing.Select(missing => new ProfileBlocker( - ProfileBlockerCode.MissingDependency, - missing.DependsOn, - $"Package '{missing.Package}' needs '{missing.DependsOn}', which is not available"))); - blockers.AddRange(selection.VersionConflicts.Select(conflict => new ProfileBlocker( - ProfileBlockerCode.VersionConflict, - conflict.DependsOn, - $"Package '{conflict.Package}' needs '{conflict.DependsOn}' {conflict.RequiredRange}, but version {conflict.ActualVersion} is what is available"))); - blockers.AddRange(selection.Cycles.Select(cycle => new ProfileBlocker( - ProfileBlockerCode.DependencyCycle, - string.Join(" → ", cycle), - $"Packages depend on each other in a circle: {string.Join(" → ", cycle)}"))); - - foreach (var diagnostic in context.Shell?.Diagnostics ?? []) - { - if (_unusableShell.Contains(diagnostic.Code)) - { - blockers.Add(new ProfileBlocker(ProfileBlockerCode.ShellUnusable, diagnostic.Subject ?? context.Shell!.Name, diagnostic.Message, diagnostic.Code)); - } - } - - if (context.Theme is not null) - { - var missing = ThemeCompatibility.IncompatiblePackages(context.Theme, profile); - if (missing.Count > 0) - { - blockers.Add(new ProfileBlocker( - ProfileBlockerCode.ThemeIncompatible, - context.Theme.Name, - $"Theme '{context.Theme.Name}' is not marked compatible with {string.Join(", ", missing.Select(item => $"'{item}'"))}, which profile '{name}' uses")); - } - } - var vocabulary = Vocabulary.Of(profile, context.Library); - var defaultTemplate = profile.Defaults?.NewScreenTemplate; - if (defaultTemplate is not null && !vocabulary.ScreenTemplates.ContainsKey(defaultTemplate.Id)) - { - blockers.Add(new ProfileBlocker( - ProfileBlockerCode.DefaultScreenTemplateUnavailable, - defaultTemplate.Id, - $"Screen template '{defaultTemplate.Id}', the default for new screens of profile '{name}', is not provided by the application or its packages")); - } - - foreach (var usage in context.Usages ?? []) - { - blockers.AddRange(usage.Layouts.Where(item => !vocabulary.Layouts.ContainsKey(item)).Select(item => new ProfileBlocker( - ProfileBlockerCode.UsedLayoutUnavailable, item, $"Screen '{usage.Screen}' uses layout '{item}', which the packages of profile '{name}' do not provide"))); - blockers.AddRange(usage.ScreenTemplates.Where(item => !vocabulary.ScreenTemplates.ContainsKey(item)).Select(item => new ProfileBlocker( - ProfileBlockerCode.UsedScreenTemplateUnavailable, item, $"Screen '{usage.Screen}' uses screen template '{item}', which the packages of profile '{name}' do not provide"))); - blockers.AddRange(usage.DialogTemplates.Where(item => !vocabulary.DialogTemplates.ContainsKey(item)).Select(item => new ProfileBlocker( - ProfileBlockerCode.UsedDialogTemplateUnavailable, item, $"Screen '{usage.Screen}' uses dialog template '{item}', which the packages of profile '{name}' do not provide"))); - - foreach (var (owner, slot) in usage.Slots) - { - var slots = vocabulary.SlotsOf(owner); - if (slots?.Contains(slot) == false) - { - blockers.Add(new ProfileBlocker( - ProfileBlockerCode.UsedSlotUnavailable, - $"{owner}.{slot}", - $"Screen '{usage.Screen}' places content in slot '{slot}' of '{owner}', which no longer has it")); - } - } - } + ProfileBlocker[] blockers = + [ + .. DependencyBlockers(profile, context), + .. ShellBlockers(context), + .. ThemeBlockers(profile, context), + .. DefaultTemplateBlockers(profile, vocabulary), + .. (context.Usages ?? []).SelectMany(usage => UsageBlockers(profile.Name, usage, vocabulary)) + ]; return [.. blockers.Select(blocker => blocker with { Severity = ProblemSeverity.Blocker, Section = SectionOf(blocker.Code) })]; } @@ -123,87 +64,177 @@ public static IReadOnlyList Find(UiProfile profile, ProfileBlock /// The problems, blockers first. public static IReadOnlyList FindProblems(UiProfile profile, ProfileBlockerContext context) { - var problems = Find(profile, context).ToList(); + ProfileBlocker?[] advisories = + [ + ThemeNeedsPackage(profile, context), + AccentTooLight(profile), + OwnShellHasNoValue(profile), + LogoImageMissing(profile), + RevealNeedsHover(profile) + ]; + + return [.. Find(profile, context), .. advisories.OfType()]; + } + + /// + /// Applies a problem's fix. One function for every host, so the same fix gives the same profile in C# and TypeScript. + /// + /// The profile. + /// The fix. + /// Every package that could be selected. + /// The profile after the fix. + public static UiProfile ApplyFix(UiProfile profile, ProfileFix fix, IReadOnlyList catalog) => fix.Kind switch + { + ProfileFixKind.AddPackage => fix.Package is null ? profile : PackageList.Add(profile, catalog, fix.Package).Profile, + ProfileFixKind.UseThemeAccent => profile.Appearance is null ? profile : profile with { Appearance = profile.Appearance with { Accent = null } }, + ProfileFixKind.UseDefaultBlueprint => profile with { Root = null }, + ProfileFixKind.UseInitials => UseInitials(profile), + ProfileFixKind.SwitchToDrawer => SwitchToDrawer(profile), + _ => profile + }; + + static IEnumerable DependencyBlockers(UiProfile profile, ProfileBlockerContext context) + { + var selection = PackageDependencyResolver.Resolve(profile.Packages, context.Packages); + return + [ + .. selection.Missing.Select(missing => new ProfileBlocker( + ProfileBlockerCode.MissingDependency, + missing.DependsOn, + $"Package '{missing.Package}' needs '{missing.DependsOn}', which is not available")), + .. selection.VersionConflicts.Select(conflict => new ProfileBlocker( + ProfileBlockerCode.VersionConflict, + conflict.DependsOn, + $"Package '{conflict.Package}' needs '{conflict.DependsOn}' {conflict.RequiredRange}, but version {conflict.ActualVersion} is what is available")), + .. selection.Cycles.Select(cycle => new ProfileBlocker( + ProfileBlockerCode.DependencyCycle, + string.Join(" → ", cycle), + $"Packages depend on each other in a circle: {string.Join(" → ", cycle)}")) + ]; + } + + static IEnumerable ShellBlockers(ProfileBlockerContext context) => + (context.Shell?.Diagnostics ?? []) + .Where(diagnostic => _unusableShell.Contains(diagnostic.Code)) + .Select(diagnostic => new ProfileBlocker(ProfileBlockerCode.ShellUnusable, diagnostic.Subject ?? context.Shell!.Name, diagnostic.Message, diagnostic.Code)); + + static IEnumerable ThemeBlockers(UiProfile profile, ProfileBlockerContext context) + { + if (context.Theme is null) + { + return []; + } + + var missing = ThemeCompatibility.IncompatiblePackages(context.Theme, profile); + return missing.Count == 0 + ? [] + : + [ + new ProfileBlocker( + ProfileBlockerCode.ThemeIncompatible, + context.Theme.Name, + $"Theme '{context.Theme.Name}' is not marked compatible with {string.Join(", ", missing.Select(item => $"'{item}'"))}, which profile '{profile.Name}' uses") + ]; + } + + static IEnumerable DefaultTemplateBlockers(UiProfile profile, Vocabulary vocabulary) + { + var defaultTemplate = profile.Defaults?.NewScreenTemplate; + return defaultTemplate is not null && !vocabulary.ScreenTemplates.ContainsKey(defaultTemplate.Id) + ? + [ + new ProfileBlocker( + ProfileBlockerCode.DefaultScreenTemplateUnavailable, + defaultTemplate.Id, + $"Screen template '{defaultTemplate.Id}', the default for new screens of profile '{profile.Name}', is not provided by the application or its packages") + ] + : []; + } + + static IEnumerable UsageBlockers(string profileName, ProfileUsage usage, Vocabulary vocabulary) => + [ + .. Unavailable(usage.Layouts, vocabulary.Layouts, ProfileBlockerCode.UsedLayoutUnavailable, item => $"Screen '{usage.Screen}' uses layout '{item}', which the packages of profile '{profileName}' do not provide"), + .. Unavailable(usage.ScreenTemplates, vocabulary.ScreenTemplates, ProfileBlockerCode.UsedScreenTemplateUnavailable, item => $"Screen '{usage.Screen}' uses screen template '{item}', which the packages of profile '{profileName}' do not provide"), + .. Unavailable(usage.DialogTemplates, vocabulary.DialogTemplates, ProfileBlockerCode.UsedDialogTemplateUnavailable, item => $"Screen '{usage.Screen}' uses dialog template '{item}', which the packages of profile '{profileName}' do not provide"), + .. usage.Slots + .Where(slotUsage => vocabulary.SlotsOf(slotUsage.Owner)?.Contains(slotUsage.Slot) == false) + .Select(slotUsage => new ProfileBlocker( + ProfileBlockerCode.UsedSlotUnavailable, + $"{slotUsage.Owner}.{slotUsage.Slot}", + $"Screen '{usage.Screen}' places content in slot '{slotUsage.Slot}' of '{slotUsage.Owner}', which no longer has it")) + ]; + static IEnumerable Unavailable(IEnumerable used, Dictionary> provided, ProfileBlockerCode code, Func message) => + used.Where(item => !provided.ContainsKey(item)).Select(item => new ProfileBlocker(code, item, message(item))); + + static ProfileBlocker? ThemeNeedsPackage(UiProfile profile, ProfileBlockerContext context) + { var theme = profile.Theme ?? context.Theme?.Name; - if (theme is not null) + if (theme is null) { - var suppliers = context.Packages.Where(candidate => candidate.Themes.Contains(theme)).ToList(); - if (suppliers.Count > 0 && !suppliers.Exists(supplier => profile.Packages.Contains(supplier.Name))) - { - problems.Add(Advisory( - ProfileBlockerCode.ThemeNeedsPackage, - theme, - ProblemSeverity.Warning, - $"Theme '{theme}' comes from '{suppliers[0].Name}', which is not in the package list", - new ProfileFix(ProfileFixKind.AddPackage, "Add package", suppliers[0].Name))); - } + return null; } + var suppliers = context.Packages.Where(candidate => candidate.Themes.Contains(theme)).ToList(); + return suppliers.Count > 0 && !suppliers.Exists(supplier => profile.Packages.Contains(supplier.Name)) + ? Warning( + ProfileBlockerCode.ThemeNeedsPackage, + theme, + $"Theme '{theme}' comes from '{suppliers[0].Name}', which is not in the package list", + new ProfileFix(ProfileFixKind.AddPackage, "Add package", suppliers[0].Name)) + : null; + } + + static ProfileBlocker? AccentTooLight(UiProfile profile) + { var accent = profile.Appearance?.Accent; - if (!string.IsNullOrEmpty(accent)) + if (string.IsNullOrEmpty(accent)) { - var contrast = Contrast.AgainstWhite(accent); - if (contrast.Valid && contrast.Ratio < Contrast.MinimumAccent) - { - problems.Add(Advisory( - ProfileBlockerCode.AccentTooLight, - accent, - ProblemSeverity.Warning, - $"Accent {accent} is too light for white button text ({contrast.Ratio.Value.ToString("0.00", CultureInfo.InvariantCulture)}:1; it needs at least {Contrast.MinimumAccent.ToString(CultureInfo.InvariantCulture)}:1)", - new ProfileFix(ProfileFixKind.UseThemeAccent, "Use theme accent"))); - } + return null; } - var template = profile.Root?.Template; - if (template is not null && template.Source == TemplateReferenceSource.Own && string.IsNullOrWhiteSpace(template.Id)) - { - problems.Add(Advisory( + var contrast = Contrast.AgainstWhite(accent); + return contrast.Valid && contrast.Ratio < Contrast.MinimumAccent + ? Warning( + ProfileBlockerCode.AccentTooLight, + accent, + $"Accent {accent} is too light for white button text ({contrast.Ratio.Value.ToString("0.00", CultureInfo.InvariantCulture)}:1; it needs at least {Contrast.MinimumAccent.ToString(CultureInfo.InvariantCulture)}:1)", + new ProfileFix(ProfileFixKind.UseThemeAccent, "Use theme accent")) + : null; + } + + static ProfileBlocker? OwnShellHasNoValue(UiProfile profile) => + profile.Root?.Template is { Source: TemplateReferenceSource.Own } template && string.IsNullOrWhiteSpace(template.Id) + ? Warning( ProfileBlockerCode.OwnShellHasNoValue, "root", - ProblemSeverity.Warning, "My own shell is chosen but names nothing; enter a package name or a path", - new ProfileFix(ProfileFixKind.UseDefaultBlueprint, "Use default blueprint"))); - } + new ProfileFix(ProfileFixKind.UseDefaultBlueprint, "Use default blueprint")) + : null; - var logo = profile.Identity?.Logo; - if (logo?.Kind == LogoKind.Image && string.IsNullOrEmpty(logo.Image)) - { - problems.Add(Advisory( + static ProfileBlocker? LogoImageMissing(UiProfile profile) => + profile.Identity?.Logo is { Kind: LogoKind.Image } logo && string.IsNullOrEmpty(logo.Image) + ? Warning( ProfileBlockerCode.LogoImageMissing, "logo", - ProblemSeverity.Warning, "The logo is an image, but no image is chosen", - new ProfileFix(ProfileFixKind.UseInitials, "Use initials"))); - } + new ProfileFix(ProfileFixKind.UseInitials, "Use initials")) + : null; - if (profile.DefaultSizeClass == TargetSizeClass.Compact && ShellOptions.Normalize(ModeOf(profile)) == NavigationMode.Reveal) - { - problems.Add(Advisory( + static ProfileBlocker? RevealNeedsHover(UiProfile profile) => + profile.DefaultSizeClass == TargetSizeClass.Compact && ShellOptions.Normalize(ModeOf(profile)) == NavigationMode.Reveal + ? Info( ProfileBlockerCode.RevealNeedsHover, "navigationMode", - ProblemSeverity.Info, "Reveal needs a pointer that can hover, so a touch screen shows a drawer instead", - new ProfileFix(ProfileFixKind.SwitchToDrawer, "Switch to Drawer"))); - } + new ProfileFix(ProfileFixKind.SwitchToDrawer, "Switch to Drawer")) + : null; - return problems; - } + static UiProfile UseInitials(UiProfile profile) => + profile with { Identity = (profile.Identity ?? new ProfileIdentity()) with { Logo = (profile.Identity?.Logo ?? new ProfileLogo()) with { Kind = LogoKind.Initials } } }; - /// - /// Applies a problem's fix. One function for every host, so the same fix gives the same profile in C# and TypeScript. - /// - /// The profile. - /// The fix. - /// Every package that could be selected. - /// The profile after the fix. - public static UiProfile ApplyFix(UiProfile profile, ProfileFix fix, IReadOnlyList catalog) => fix.Kind switch - { - ProfileFixKind.AddPackage => fix.Package is null ? profile : PackageList.Add(profile, catalog, fix.Package).Profile, - ProfileFixKind.UseThemeAccent => profile.Appearance is null ? profile : profile with { Appearance = profile.Appearance with { Accent = null } }, - ProfileFixKind.UseDefaultBlueprint => profile with { Root = null }, - ProfileFixKind.UseInitials => profile with { Identity = (profile.Identity ?? new ProfileIdentity()) with { Logo = (profile.Identity?.Logo ?? new ProfileLogo()) with { Kind = LogoKind.Initials } } }, - ProfileFixKind.SwitchToDrawer => profile.Root is null + static UiProfile SwitchToDrawer(UiProfile profile) => + profile.Root is null ? profile : profile with { @@ -211,15 +242,16 @@ public static IReadOnlyList FindProblems(UiProfile profile, Prof { Options = new Dictionary(profile.Root.Options ?? new Dictionary()) { ["navigationMode"] = JsonValue.Create("drawer") } } - }, - _ => profile - }; + }; static string? ModeOf(UiProfile profile) => profile.Root?.Options is not null && profile.Root.Options.TryGetValue("navigationMode", out var mode) && mode is JsonValue value && value.TryGetValue(out var text) ? text : null; - static ProfileBlocker Advisory(ProfileBlockerCode code, string subject, ProblemSeverity severity, string message, ProfileFix fix) => - new(code, subject, message, null, severity, SectionOf(code), fix); + static ProfileBlocker Warning(ProfileBlockerCode code, string subject, string message, ProfileFix fix) => + new(code, subject, message, null, ProblemSeverity.Warning, SectionOf(code), fix); + + static ProfileBlocker Info(ProfileBlockerCode code, string subject, string message, ProfileFix fix) => + new(code, subject, message, null, ProblemSeverity.Info, SectionOf(code), fix); static ProfileSection SectionOf(ProfileBlockerCode code) => code switch { @@ -242,27 +274,9 @@ public static Vocabulary Of(UiProfile profile, TemplateLibrary library) { var vocabulary = new Vocabulary(); var installed = library.Blueprints.Where(candidate => profile.Packages.Contains(candidate.Manifest.Name)); - foreach (var source in new[] { library.Own }.Concat(installed)) + foreach (var source in new[] { library.Own }.Concat(installed).OfType()) { - if (source is null) - { - continue; - } - - foreach (var layout in source.Layouts ?? []) - { - vocabulary.Layouts.TryAdd(layout.Name, [.. layout.Slots.Select(slot => slot.Name)]); - } - - foreach (var template in source.ScreenTemplates ?? []) - { - vocabulary.ScreenTemplates.TryAdd(template.Name, [.. template.Slots.Select(slot => slot.Name)]); - } - - foreach (var template in source.DialogTemplates ?? []) - { - vocabulary.DialogTemplates.TryAdd(template.Name, [.. template.Slots.Select(slot => slot.Name)]); - } + vocabulary.Add(source); } return vocabulary; @@ -273,5 +287,20 @@ public static Vocabulary Of(UiProfile profile, TemplateLibrary library) : ScreenTemplates.TryGetValue(owner, out var screenTemplate) ? screenTemplate : DialogTemplates.TryGetValue(owner, out var dialogTemplate) ? dialogTemplate : null; + + static void AddAll(Dictionary> target, IEnumerable? items, Func nameOf, Func> slotNamesOf) + { + foreach (var item in items ?? []) + { + target.TryAdd(nameOf(item), [.. slotNamesOf(item)]); + } + } + + void Add(TemplateSource source) + { + AddAll(Layouts, source.Layouts, layout => layout.Name, layout => layout.Slots.Select(slot => slot.Name)); + AddAll(ScreenTemplates, source.ScreenTemplates, template => template.Name, template => template.Slots.Select(slot => slot.Name)); + AddAll(DialogTemplates, source.DialogTemplates, template => template.Name, template => template.Slots.Select(slot => slot.Name)); + } } } diff --git a/Source/JavaScript/engine/packageVersionRange.ts b/Source/JavaScript/engine/packageVersionRange.ts index 9f7d3b80..eb8d9743 100644 --- a/Source/JavaScript/engine/packageVersionRange.ts +++ b/Source/JavaScript/engine/packageVersionRange.ts @@ -59,7 +59,7 @@ export function compareVersions(left: string, right: string): number { type Version = [number, number, number]; -const matchesAnyVersion = (trimmedRange: string | undefined): boolean => !trimmedRange || trimmedRange === '*'; +const matchesAnyVersion = (trimmedRange: string | undefined): trimmedRange is undefined | '' | '*' => !trimmedRange || trimmedRange === '*'; function satisfies(operator: string, actual: Version, required: Version): boolean { switch (operator) { diff --git a/Source/JavaScript/engine/profiles/findProfileBlockers.ts b/Source/JavaScript/engine/profiles/findProfileBlockers.ts index f929a83b..5c9ebfa1 100644 --- a/Source/JavaScript/engine/profiles/findProfileBlockers.ts +++ b/Source/JavaScript/engine/profiles/findProfileBlockers.ts @@ -71,77 +71,78 @@ const unusableShell = new Set([ * @returns The blockers, empty when the profile can be applied. */ export function findProfileBlockers(profile: UiProfile, context: ProfileBlockerContext): ProfileBlocker[] { - const blockers: Omit[] = []; - const name = profile.name; + const vocabulary = vocabularyOf(profile, context.library); + const blockers: UnclassifiedBlocker[] = [ + ...dependencyBlockers(profile, context), + ...shellBlockers(context), + ...themeBlockers(profile, context), + ...defaultTemplateBlockers(profile, vocabulary), + ...(context.usages ?? []).flatMap(usage => usageBlockers(profile.name, usage, vocabulary)), + ]; - const selection = resolvePackageDependencies(profile.packages, [...context.packages]); - for (const missing of selection.missing) { - blockers.push({ code: ProfileBlockerCode.MissingDependency, subject: missing.dependsOn, message: `Package '${missing.package}' needs '${missing.dependsOn}', which is not available` }); - } + return blockers.map(blocker => ({ ...blocker, severity: ProblemSeverity.Blocker, section: sectionOf[blocker.code] })); +} + +type UnclassifiedBlocker = Omit; - for (const conflict of selection.versionConflicts) { - blockers.push({ +function dependencyBlockers(profile: UiProfile, context: ProfileBlockerContext): UnclassifiedBlocker[] { + const selection = resolvePackageDependencies(profile.packages, [...context.packages]); + return [ + ...selection.missing.map(missing => ({ code: ProfileBlockerCode.MissingDependency, subject: missing.dependsOn, message: `Package '${missing.package}' needs '${missing.dependsOn}', which is not available` })), + ...selection.versionConflicts.map(conflict => ({ code: ProfileBlockerCode.VersionConflict, subject: conflict.dependsOn, message: `Package '${conflict.package}' needs '${conflict.dependsOn}' ${conflict.requiredRange}, but version ${conflict.actualVersion} is what is available`, - }); - } - - for (const cycle of selection.cycles) { - blockers.push({ code: ProfileBlockerCode.DependencyCycle, subject: cycle.join(' → '), message: `Packages depend on each other in a circle: ${cycle.join(' → ')}` }); - } - - for (const diagnostic of context.shell?.diagnostics ?? []) { - if (unusableShell.has(diagnostic.code)) { - blockers.push({ code: ProfileBlockerCode.ShellUnusable, subject: diagnostic.subject ?? context.shell!.name, message: diagnostic.message, shellCode: diagnostic.code }); - } - } + })), + ...selection.cycles.map(cycle => ({ code: ProfileBlockerCode.DependencyCycle, subject: cycle.join(' → '), message: `Packages depend on each other in a circle: ${cycle.join(' → ')}` })), + ]; +} - if (context.theme !== undefined) { - const missing = incompatiblePackages(context.theme, profile); - if (missing.length > 0) { - blockers.push({ - code: ProfileBlockerCode.ThemeIncompatible, - subject: context.theme.name, - message: `Theme '${context.theme.name}' is not marked compatible with ${missing.map(item => `'${item}'`).join(', ')}, which profile '${name}' uses`, - }); - } - } +const shellBlockers = (context: ProfileBlockerContext): UnclassifiedBlocker[] => + (context.shell?.diagnostics ?? []) + .filter(diagnostic => unusableShell.has(diagnostic.code)) + .map(diagnostic => ({ code: ProfileBlockerCode.ShellUnusable, subject: diagnostic.subject ?? context.shell!.name, message: diagnostic.message, shellCode: diagnostic.code })); + +function themeBlockers(profile: UiProfile, context: ProfileBlockerContext): UnclassifiedBlocker[] { + if (context.theme === undefined) return []; + const missing = incompatiblePackages(context.theme, profile); + return missing.length === 0 + ? [] + : [{ + code: ProfileBlockerCode.ThemeIncompatible, + subject: context.theme.name, + message: `Theme '${context.theme.name}' is not marked compatible with ${missing.map(item => `'${item}'`).join(', ')}, which profile '${profile.name}' uses`, + }]; +} - const vocabulary = vocabularyOf(profile, context.library); +function defaultTemplateBlockers(profile: UiProfile, vocabulary: Vocabulary): UnclassifiedBlocker[] { const defaultTemplate = profile.defaults?.newScreenTemplate; - if (defaultTemplate !== undefined && !vocabulary.screenTemplates.has(defaultTemplate.id)) { - blockers.push({ + return defaultTemplate !== undefined && !vocabulary.screenTemplates.has(defaultTemplate.id) + ? [{ code: ProfileBlockerCode.DefaultScreenTemplateUnavailable, subject: defaultTemplate.id, - message: `Screen template '${defaultTemplate.id}', the default for new screens of profile '${name}', is not provided by the application or its packages`, - }); - } - - for (const usage of context.usages ?? []) { - for (const layout of usage.layouts.filter(item => !vocabulary.layouts.has(item))) { - blockers.push({ code: ProfileBlockerCode.UsedLayoutUnavailable, subject: layout, message: `Screen '${usage.screen}' uses layout '${layout}', which the packages of profile '${name}' do not provide` }); - } - - for (const template of usage.screenTemplates.filter(item => !vocabulary.screenTemplates.has(item))) { - blockers.push({ code: ProfileBlockerCode.UsedScreenTemplateUnavailable, subject: template, message: `Screen '${usage.screen}' uses screen template '${template}', which the packages of profile '${name}' do not provide` }); - } - - for (const template of usage.dialogTemplates.filter(item => !vocabulary.dialogTemplates.has(item))) { - blockers.push({ code: ProfileBlockerCode.UsedDialogTemplateUnavailable, subject: template, message: `Screen '${usage.screen}' uses dialog template '${template}', which the packages of profile '${name}' do not provide` }); - } - - for (const { owner, slot } of usage.slots) { - const slots = vocabulary.layouts.get(owner) ?? vocabulary.screenTemplates.get(owner) ?? vocabulary.dialogTemplates.get(owner); - if (slots !== undefined && !slots.includes(slot)) { - blockers.push({ code: ProfileBlockerCode.UsedSlotUnavailable, subject: `${owner}.${slot}`, message: `Screen '${usage.screen}' places content in slot '${slot}' of '${owner}', which no longer has it` }); - } - } - } + message: `Screen template '${defaultTemplate.id}', the default for new screens of profile '${profile.name}', is not provided by the application or its packages`, + }] + : []; +} - return blockers.map(blocker => ({ ...blocker, severity: ProblemSeverity.Blocker, section: sectionOf[blocker.code] })); +function usageBlockers(profileName: string, usage: ProfileUsage, vocabulary: Vocabulary): UnclassifiedBlocker[] { + return [ + ...unavailable(usage.layouts, vocabulary.layouts, ProfileBlockerCode.UsedLayoutUnavailable, item => `Screen '${usage.screen}' uses layout '${item}', which the packages of profile '${profileName}' do not provide`), + ...unavailable(usage.screenTemplates, vocabulary.screenTemplates, ProfileBlockerCode.UsedScreenTemplateUnavailable, item => `Screen '${usage.screen}' uses screen template '${item}', which the packages of profile '${profileName}' do not provide`), + ...unavailable(usage.dialogTemplates, vocabulary.dialogTemplates, ProfileBlockerCode.UsedDialogTemplateUnavailable, item => `Screen '${usage.screen}' uses dialog template '${item}', which the packages of profile '${profileName}' do not provide`), + ...usage.slots + .filter(({ owner, slot }) => slotsOf(vocabulary, owner)?.includes(slot) === false) + .map(({ owner, slot }) => ({ code: ProfileBlockerCode.UsedSlotUnavailable, subject: `${owner}.${slot}`, message: `Screen '${usage.screen}' places content in slot '${slot}' of '${owner}', which no longer has it` })), + ]; } +const unavailable = (used: readonly string[], provided: Map, code: ProfileBlockerCode, message: (item: string) => string): UnclassifiedBlocker[] => + used.filter(item => !provided.has(item)).map(item => ({ code, subject: item, message: message(item) })); + +const slotsOf = (vocabulary: Vocabulary, owner: string): string[] | undefined => + vocabulary.layouts.get(owner) ?? vocabulary.screenTemplates.get(owner) ?? vocabulary.dialogTemplates.get(owner); + interface Vocabulary { layouts: Map; screenTemplates: Map; @@ -154,10 +155,16 @@ function vocabularyOf(profile: UiProfile, library: TemplateLibrary): Vocabulary const installed = library.blueprints.filter(candidate => profile.packages.includes(candidate.manifest.name)); for (const source of [library.own, ...installed]) { if (source === undefined) continue; - for (const layout of source.layouts ?? []) if (!vocabulary.layouts.has(layout.name)) vocabulary.layouts.set(layout.name, layout.slots.map(slot => slot.name)); - for (const template of source.screenTemplates ?? []) if (!vocabulary.screenTemplates.has(template.name)) vocabulary.screenTemplates.set(template.name, template.slots.map(slot => slot.name)); - for (const template of source.dialogTemplates ?? []) if (!vocabulary.dialogTemplates.has(template.name)) vocabulary.dialogTemplates.set(template.name, template.slots.map(slot => slot.name)); + addAll(vocabulary.layouts, source.layouts); + addAll(vocabulary.screenTemplates, source.screenTemplates); + addAll(vocabulary.dialogTemplates, source.dialogTemplates); } return vocabulary; } + +function addAll(target: Map, items: readonly { name: string; slots: readonly { name: string }[] }[] | undefined): void { + for (const item of items ?? []) { + if (!target.has(item.name)) target.set(item.name, item.slots.map(slot => slot.name)); + } +} From 8ad3a7ef40ba75a24882e3e6e9a1c73cebd5bfcb Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 11 Nov 2026 20:12:51 +0100 Subject: [PATCH 15/21] Split profile migration steps --- .../DotNET/Engine/Profiles/ProfileMigrator.cs | 81 ++++++++++------- .../engine/profiles/migrateLegacyProfile.ts | 87 +++++++++++-------- 2 files changed, 97 insertions(+), 71 deletions(-) diff --git a/Source/DotNET/Engine/Profiles/ProfileMigrator.cs b/Source/DotNET/Engine/Profiles/ProfileMigrator.cs index aff52850..398f7347 100644 --- a/Source/DotNET/Engine/Profiles/ProfileMigrator.cs +++ b/Source/DotNET/Engine/Profiles/ProfileMigrator.cs @@ -51,7 +51,7 @@ public static ProfileMigration Migrate(LegacyProfile legacy, TemplateLibrary lib var root = profile.Root; if (root is null) { - var template = DeriveRoot(NonBlank(legacy.Shell), layout, profile, library, diagnostics); + var template = DeriveRoot(NonBlank(legacy.Shell), layout, new Resolution(profile, library, diagnostics)); if (template is not null) { root = new RootTemplate(template); @@ -63,25 +63,7 @@ public static ProfileMigration Migrate(LegacyProfile legacy, TemplateLibrary lib layout = null; } - var navigationMode = NonBlank(legacy.NavigationMode); - if (navigationMode is not null) - { - if (root is null) - { - diagnostics.Add(new( - ProfileMigrationDiagnosticCode.LegacyNavigationModeWithoutRoot, - $"Navigation mode '{navigationMode}' of profile '{profile.Name}' cannot be kept: the profile has no root template to carry it", - navigationMode)); - } - else if (root.Options?.ContainsKey(NavigationModeOption) != true) - { - var options = new Dictionary(root.Options ?? new Dictionary(), StringComparer.Ordinal) - { - [NavigationModeOption] = JsonValue.Create(navigationMode) - }; - root = root with { Options = options }; - } - } + root = WithNavigationMode(root, NonBlank(legacy.NavigationMode), profile, diagnostics); var migrated = profile with { @@ -106,7 +88,35 @@ public static ProfileMigration Migrate(LegacyProfile legacy, TemplateLibrary lib return match.Success ? $"^{match.Groups["major"].Value}.{match.Groups["minor"].Value}" : null; } - static TemplateReference? DeriveRoot(string? shell, string? layout, UiProfile profile, TemplateLibrary library, List diagnostics) + static RootTemplate? WithNavigationMode(RootTemplate? root, string? navigationMode, UiProfile profile, List diagnostics) + { + if (navigationMode is null) + { + return root; + } + + if (root is null) + { + diagnostics.Add(new( + ProfileMigrationDiagnosticCode.LegacyNavigationModeWithoutRoot, + $"Navigation mode '{navigationMode}' of profile '{profile.Name}' cannot be kept: the profile has no root template to carry it", + navigationMode)); + return root; + } + + if (root.Options?.ContainsKey(NavigationModeOption) == true) + { + return root; + } + + var options = new Dictionary(root.Options ?? new Dictionary(), StringComparer.Ordinal) + { + [NavigationModeOption] = JsonValue.Create(navigationMode) + }; + return root with { Options = options }; + } + + static TemplateReference? DeriveRoot(string? shell, string? layout, Resolution resolution) { if (shell is null && layout is null) { @@ -115,30 +125,32 @@ public static ProfileMigration Migrate(LegacyProfile legacy, TemplateLibrary lib if (shell is null) { - return FromLayoutName(layout!, null, profile, library, diagnostics); + return FromLayoutName(layout!, null, resolution); } - var blueprint = library.Blueprints.FirstOrDefault(candidate => candidate.Manifest.Name == shell && candidate.Manifest.Kind == PackageKind.Blueprint); + var blueprint = resolution.Library.Blueprints.FirstOrDefault(candidate => candidate.Manifest.Name == shell && candidate.Manifest.Kind == PackageKind.Blueprint); if (blueprint is null) { - diagnostics.Add(new( + resolution.Diagnostics.Add(new( ProfileMigrationDiagnosticCode.LegacyShellNotInstalled, - $"Shell '{shell}' of profile '{profile.Name}' is not an available blueprint", + $"Shell '{shell}' of profile '{resolution.Profile.Name}' is not an available blueprint", shell)); - return layout is null ? null : FromLayoutName(layout, null, profile, library, diagnostics); + return layout is null ? null : FromLayoutName(layout, null, resolution); } - if (layout is not null) - { - return FromLayoutName(layout, blueprint, profile, library, diagnostics); - } + return layout is not null + ? FromLayoutName(layout, blueprint, resolution) + : FirstShellReference(blueprint, shell, resolution); + } + static TemplateReference? FirstShellReference(TemplateSource blueprint, string shell, Resolution resolution) + { var first = FirstShellOf(blueprint); if (first is null) { - diagnostics.Add(new( + resolution.Diagnostics.Add(new( ProfileMigrationDiagnosticCode.LegacyShellHasNoLayout, - $"Blueprint '{shell}' of profile '{profile.Name}' provides no layout or root template an application can use as its shell", + $"Blueprint '{shell}' of profile '{resolution.Profile.Name}' provides no layout or root template an application can use as its shell", shell)); return null; } @@ -146,8 +158,9 @@ public static ProfileMigration Migrate(LegacyProfile legacy, TemplateLibrary lib return BlueprintReference(blueprint, first); } - static TemplateReference? FromLayoutName(string name, TemplateSource? preferred, UiProfile profile, TemplateLibrary library, List diagnostics) + static TemplateReference? FromLayoutName(string name, TemplateSource? preferred, Resolution resolution) { + var (profile, library, diagnostics) = resolution; if (preferred is not null && HasShell(preferred, name)) { return BlueprintReference(preferred, name); @@ -269,4 +282,6 @@ static TemplateReference BlueprintReference(TemplateSource blueprint, string id) [GeneratedRegex(@"^(?\d+)\.(?\d+)", RegexOptions.None, matchTimeoutMilliseconds: 200)] private static partial Regex VersionPrefix(); + + sealed record Resolution(UiProfile Profile, TemplateLibrary Library, List Diagnostics); } diff --git a/Source/JavaScript/engine/profiles/migrateLegacyProfile.ts b/Source/JavaScript/engine/profiles/migrateLegacyProfile.ts index f8655554..a698c7d0 100644 --- a/Source/JavaScript/engine/profiles/migrateLegacyProfile.ts +++ b/Source/JavaScript/engine/profiles/migrateLegacyProfile.ts @@ -7,6 +7,7 @@ import { PackageKind, ProfileAppearance, ProfileDefaults, + RootTemplate, ScreenTemplate, TemplateReference, TemplateReferenceSource, @@ -49,7 +50,7 @@ export function migrateLegacyProfile(legacy: LegacyProfile, library: TemplateLib let root = legacy.profile.root; if (root === undefined) { - const template = deriveRoot(nonBlank(legacy.shell), layout, legacy.profile, library, diagnostics); + const template = deriveRoot(nonBlank(legacy.shell), layout, { profile: legacy.profile, library, diagnostics }); if (template !== undefined) { root = { template }; layout = undefined; @@ -58,18 +59,7 @@ export function migrateLegacyProfile(legacy: LegacyProfile, library: TemplateLib layout = undefined; } - const navigationMode = nonBlank(legacy.navigationMode); - if (navigationMode !== undefined) { - if (root === undefined) { - diagnostics.push({ - code: ProfileMigrationDiagnosticCode.LegacyNavigationModeWithoutRoot, - subject: navigationMode, - message: `Navigation mode '${navigationMode}' of profile '${legacy.profile.name}' cannot be kept: the profile has no root template to carry it`, - }); - } else if (root.options?.[navigationModeOption] === undefined) { - root = { ...root, options: { ...root.options, [navigationModeOption]: navigationMode } }; - } - } + root = withNavigationMode(root, nonBlank(legacy.navigationMode), legacy.profile, diagnostics); const migrated: UiProfile = { ...rest }; if (layout !== undefined) migrated.layout = layout; @@ -86,39 +76,60 @@ export function migrateLegacyProfile(legacy: LegacyProfile, library: TemplateLib return { profile: migrated, changed: serializeUiProfile(migrated) !== serializeUiProfile(legacy.profile), diagnostics }; } -function deriveRoot(shell: string | undefined, layout: string | undefined, profile: UiProfile, library: TemplateLibrary, diagnostics: ProfileMigrationDiagnostic[]): TemplateReference | undefined { - if (shell === undefined && layout === undefined) return undefined; +interface Resolution { + profile: UiProfile; + library: TemplateLibrary; + diagnostics: ProfileMigrationDiagnostic[]; +} - if (shell !== undefined) { - const blueprint = library.blueprints.find(candidate => candidate.manifest.name === shell && candidate.manifest.kind === PackageKind.Blueprint); - if (blueprint === undefined) { - diagnostics.push({ - code: ProfileMigrationDiagnosticCode.LegacyShellNotInstalled, - subject: shell, - message: `Shell '${shell}' of profile '${profile.name}' is not an available blueprint`, - }); - return layout === undefined ? undefined : fromLayoutName(layout, undefined, profile, library, diagnostics); - } +function withNavigationMode(root: RootTemplate | undefined, navigationMode: string | undefined, profile: UiProfile, diagnostics: ProfileMigrationDiagnostic[]): RootTemplate | undefined { + if (navigationMode === undefined) return root; + + if (root === undefined) { + diagnostics.push({ + code: ProfileMigrationDiagnosticCode.LegacyNavigationModeWithoutRoot, + subject: navigationMode, + message: `Navigation mode '${navigationMode}' of profile '${profile.name}' cannot be kept: the profile has no root template to carry it`, + }); + return root; + } - if (layout !== undefined) return fromLayoutName(layout, blueprint, profile, library, diagnostics); + if (root.options?.[navigationModeOption] !== undefined) return root; + return { ...root, options: { ...root.options, [navigationModeOption]: navigationMode } }; +} - const first = firstShellOf(blueprint); - if (first === undefined) { - diagnostics.push({ - code: ProfileMigrationDiagnosticCode.LegacyShellHasNoLayout, - subject: shell, - message: `Blueprint '${shell}' of profile '${profile.name}' provides no layout or root template an application can use as its shell`, - }); - return undefined; - } +function deriveRoot(shell: string | undefined, layout: string | undefined, resolution: Resolution): TemplateReference | undefined { + if (shell === undefined && layout === undefined) return undefined; + if (shell === undefined) return fromLayoutName(layout!, undefined, resolution); + + const blueprint = resolution.library.blueprints.find(candidate => candidate.manifest.name === shell && candidate.manifest.kind === PackageKind.Blueprint); + if (blueprint === undefined) { + resolution.diagnostics.push({ + code: ProfileMigrationDiagnosticCode.LegacyShellNotInstalled, + subject: shell, + message: `Shell '${shell}' of profile '${resolution.profile.name}' is not an available blueprint`, + }); + return layout === undefined ? undefined : fromLayoutName(layout, undefined, resolution); + } + + return layout !== undefined ? fromLayoutName(layout, blueprint, resolution) : firstShellReference(blueprint, shell, resolution); +} - return blueprintReference(blueprint, first); +function firstShellReference(blueprint: TemplateSource, shell: string, resolution: Resolution): TemplateReference | undefined { + const first = firstShellOf(blueprint); + if (first === undefined) { + resolution.diagnostics.push({ + code: ProfileMigrationDiagnosticCode.LegacyShellHasNoLayout, + subject: shell, + message: `Blueprint '${shell}' of profile '${resolution.profile.name}' provides no layout or root template an application can use as its shell`, + }); + return undefined; } - return fromLayoutName(layout!, undefined, profile, library, diagnostics); + return blueprintReference(blueprint, first); } -function fromLayoutName(name: string, preferred: TemplateSource | undefined, profile: UiProfile, library: TemplateLibrary, diagnostics: ProfileMigrationDiagnostic[]): TemplateReference | undefined { +function fromLayoutName(name: string, preferred: TemplateSource | undefined, { profile, library, diagnostics }: Resolution): TemplateReference | undefined { if (preferred !== undefined && hasShell(preferred, name)) return blueprintReference(preferred, name); if (library.own !== undefined && hasShell(library.own, name)) return { source: TemplateReferenceSource.Own, id: name }; From 6a86ac7bdfe3b32f949c91958c95f27f76b5c01e Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 11 Nov 2026 20:18:28 +0100 Subject: [PATCH 16/21] Keep profile fix and version corpus specs out of assertions --- ...files_against_the_shared_fixture_corpus.cs | 7 +++++ ...files_against_the_shared_fixture_corpus.ts | 15 ++++++++-- ...aring_against_the_shared_fixture_corpus.ts | 30 +++++++++++++++---- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/Source/DotNET/Engine.Specs/for_ProfileBlockers/when_fixing_sparse_profiles_against_the_shared_fixture_corpus.cs b/Source/DotNET/Engine.Specs/for_ProfileBlockers/when_fixing_sparse_profiles_against_the_shared_fixture_corpus.cs index ccf4e10b..11019922 100644 --- a/Source/DotNET/Engine.Specs/for_ProfileBlockers/when_fixing_sparse_profiles_against_the_shared_fixture_corpus.cs +++ b/Source/DotNET/Engine.Specs/for_ProfileBlockers/when_fixing_sparse_profiles_against_the_shared_fixture_corpus.cs @@ -19,6 +19,7 @@ public class when_fixing_sparse_profiles_against_the_shared_fixture_corpus : Spe List<(string Name, UiProfile Profile, ProfileFix Fix, JsonNode? Expected)> _cases = null!; List<(string Name, JsonNode? Expected, JsonNode? Actual)> _results = null!; + List<(string Name, string Before, string After)> _inputs = null!; IReadOnlyList _problemsWithNothingToCheck = null!; void Establish() @@ -42,7 +43,9 @@ .. document.RootElement.GetProperty("fixEdgeCases").EnumerateArray().Select(elem void Because() { + List snapshots = [.. _cases.Select(item => UiProfileJson.Serialize(item.Profile))]; _results = [.. _cases.Select(item => (item.Name, item.Expected, JsonNode.Parse(UiProfileJson.Serialize(ProfileBlockers.ApplyFix(item.Profile, item.Fix, [])))))]; + _inputs = [.. _cases.Select((item, index) => (item.Name, snapshots[index], UiProfileJson.Serialize(item.Profile)))]; _problemsWithNothingToCheck = ProfileBlockers.FindProblems(_cases[0].Profile, new ProfileBlockerContext([], new TemplateLibrary(null, []))); } @@ -50,6 +53,10 @@ void Because() void should_give_the_profile_the_corpus_expects_for_every_sparse_fix() => _results.Where(item => !JsonNode.DeepEquals(item.Expected, item.Actual)).Select(item => $"{item.Name}: expected {item.Expected?.ToJsonString()} but was {item.Actual?.ToJsonString()}").ShouldBeEmpty(); + [Fact] + void should_leave_every_input_profile_untouched() => + _inputs.Where(item => item.Before != item.After).Select(item => item.Name).ShouldBeEmpty(); + [Fact] void should_cover_every_fix_kind() => _cases.Select(item => item.Fix.Kind).Distinct().Count().ShouldEqual(Enum.GetValues().Length); diff --git a/Source/JavaScript/engine/for_findProfileBlockers/when_fixing_sparse_profiles_against_the_shared_fixture_corpus.ts b/Source/JavaScript/engine/for_findProfileBlockers/when_fixing_sparse_profiles_against_the_shared_fixture_corpus.ts index d0a03e49..c5c33468 100644 --- a/Source/JavaScript/engine/for_findProfileBlockers/when_fixing_sparse_profiles_against_the_shared_fixture_corpus.ts +++ b/Source/JavaScript/engine/for_findProfileBlockers/when_fixing_sparse_profiles_against_the_shared_fixture_corpus.ts @@ -18,15 +18,26 @@ const corpus = JSON.parse(readFileSync(join(import.meta.dirname, '../../../../pr describe('when fixing sparse profiles against the shared fixture corpus', () => { for (const fixture of corpus.fixEdgeCases) { let result: unknown; + let before: string; + let after: string; beforeEach(() => { + before = JSON.stringify(fixture.profile); result = JSON.parse(JSON.stringify(applyProfileFix(fixture.profile, fixture.fix, []))); + after = JSON.stringify(fixture.profile); }); it(`should give the profile the corpus expects for ${fixture.name}`, () => (result as UiProfile).should.deep.equal(fixture.expected)); + it(`should leave the input profile untouched for ${fixture.name}`, () => after.should.equal(before)); } - it('should find no problems when there is no shell, theme, usage or own library', () => { - findProfileProblems(corpus.fixEdgeCases[0].profile, { packages: [], library: { blueprints: [] } }).should.deep.equal([]); + describe('with no shell, theme, usage or own library', () => { + let problems: unknown; + + beforeEach(() => { + problems = findProfileProblems(corpus.fixEdgeCases[0].profile, { packages: [], library: { blueprints: [] } }); + }); + + it('should find no problems', () => (problems as unknown[]).should.deep.equal([])); }); }); diff --git a/Source/JavaScript/engine/for_packageVersionRange/when_validating_and_comparing_against_the_shared_fixture_corpus.ts b/Source/JavaScript/engine/for_packageVersionRange/when_validating_and_comparing_against_the_shared_fixture_corpus.ts index 9b30fbb5..35c3b9c3 100644 --- a/Source/JavaScript/engine/for_packageVersionRange/when_validating_and_comparing_against_the_shared_fixture_corpus.ts +++ b/Source/JavaScript/engine/for_packageVersionRange/when_validating_and_comparing_against_the_shared_fixture_corpus.ts @@ -6,20 +6,38 @@ import { corpus } from '../for_packageDependencies/fixtures'; describe('when validating and comparing against the shared fixture corpus', () => { for (const fixtureCase of corpus.versionRangeValidityCases) { - it(`should judge range '${fixtureCase.range ?? ''}' the same way the corpus does`, () => { - isValidVersionRange(fixtureCase.range ?? undefined).should.equal(fixtureCase.expected); + describe(`range '${fixtureCase.range ?? ''}'`, () => { + let result: boolean; + + beforeEach(() => { + result = isValidVersionRange(fixtureCase.range ?? undefined); + }); + + it('should be judged the same way the corpus does', () => result.should.equal(fixtureCase.expected)); }); } for (const fixtureCase of corpus.versionValidityCases) { - it(`should judge version '${fixtureCase.version ?? ''}' the same way the corpus does`, () => { - isValidVersion(fixtureCase.version ?? undefined).should.equal(fixtureCase.expected); + describe(`version '${fixtureCase.version ?? ''}'`, () => { + let result: boolean; + + beforeEach(() => { + result = isValidVersion(fixtureCase.version ?? undefined); + }); + + it('should be judged the same way the corpus does', () => result.should.equal(fixtureCase.expected)); }); } for (const fixtureCase of corpus.versionComparisonCases) { - it(`should order '${fixtureCase.left}' against '${fixtureCase.right}' the same way the corpus does`, () => { - Math.sign(compareVersions(fixtureCase.left, fixtureCase.right)).should.equal(fixtureCase.expected); + describe(`'${fixtureCase.left}' against '${fixtureCase.right}'`, () => { + let result: number; + + beforeEach(() => { + result = Math.sign(compareVersions(fixtureCase.left, fixtureCase.right)); + }); + + it('should be ordered the same way the corpus does', () => result.should.equal(fixtureCase.expected)); }); } }); From 19466cd29a965e819573df30661a4f4d1f1e7f13 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 11 Nov 2026 20:27:08 +0100 Subject: [PATCH 17/21] Characterize shell resolution and switch planning edge cases in the shared fixtures --- effective-shell-fixtures.json | 101 ++++++++++++++++++ shell-switch-fixtures.json | 187 ++++++++++++++++++++++++++++++++++ 2 files changed, 288 insertions(+) diff --git a/effective-shell-fixtures.json b/effective-shell-fixtures.json index fae755af..aa6dd596 100644 --- a/effective-shell-fixtures.json +++ b/effective-shell-fixtures.json @@ -214,6 +214,21 @@ } } ] + }, + { + "manifest": { + "name": "shadow", + "version": "1.0.0", + "kind": "Blueprint", + "dependencies": [], + "components": [], + "layouts": [ + "ShadowShell" + ], + "screenTemplates": [], + "dialogTemplates": [], + "themes": [] + } } ] }, @@ -1623,6 +1638,92 @@ "diagnostics": [] } }, + { + "name": "a pin the installed version does not satisfy on a template that is not a shell", + "library": "default", + "profile": { + "name": "Desktop", + "targetPlatform": "web", + "packages": [ + "core", + "crm" + ], + "root": { + "template": { + "source": "Blueprint", + "id": "Pipeline Board", + "blueprint": "crm", + "version": "^2.0.0" + } + } + }, + "expected": { + "origin": "blueprintDefault", + "kind": "layout", + "source": "blueprint", + "name": "CrmShell", + "reference": { + "source": "Blueprint", + "id": "CrmShell", + "blueprint": "crm", + "version": "^1.4.2" + }, + "package": "crm", + "packageVersion": "1.4.2", + "pinStatus": "Current", + "pin": "^1.4.2", + "slots": [ + "sidebar", + "content" + ], + "primarySlot": "content", + "options": {}, + "diagnostics": [ + { + "code": "rootTemplateVersionNotSatisfied", + "subject": "Pipeline Board", + "message": "Root template 'Pipeline Board' cannot be used: Template 'Pipeline Board' was chosen from blueprint 'crm' ^2.0.0 but version 1.4.2 is installed" + } + ] + } + }, + { + "name": "a layout only the manifest of the blueprint lists", + "library": "default", + "profile": { + "name": "Desktop", + "targetPlatform": "web", + "packages": [ + "core", + "shadow" + ], + "root": { + "template": { + "source": "Blueprint", + "id": "ShadowShell", + "blueprint": "shadow" + } + } + }, + "expected": { + "origin": "bare", + "kind": "layout", + "source": "builtIn", + "name": "Bare", + "slots": [ + "content" + ], + "primarySlot": "content", + "options": {}, + "diagnostics": [ + { + "code": "rootTemplateNotApplicable", + "subject": "ShadowShell", + "message": "Root template 'ShadowShell' cannot be the application's shell: it is a template, and only a layout or a screen template with the root role can be a shell" + } + ] + } + }, { "name": "packages that are not available are reported", "library": "default", diff --git a/shell-switch-fixtures.json b/shell-switch-fixtures.json index 641643e3..59def3db 100644 --- a/shell-switch-fixtures.json +++ b/shell-switch-fixtures.json @@ -153,6 +153,21 @@ "name": "Aside Note", "slots": [], "fitsSlot": "ToolShell.subject" + }, + { + "name": "SideFrame", + "slots": [ + { + "name": "content" + }, + { + "name": "side", + "primary": true + } + ], + "metadata": { + "role": "Root" + } } ], "dialogTemplates": [ @@ -2513,6 +2528,178 @@ "canSwitch": true } }, + { + "name": "primary slot nobody uses follows the primary slot", + "description": "the primary slot of the current shell becomes the primary slot of the next even when no screen uses it", + "profile": { + "name": "Desktop", + "targetPlatform": "web", + "packages": [ + "core", + "crm", + "hr", + "analytics" + ] + }, + "currentRoot": { + "source": "Own", + "id": "OwnShell" + }, + "candidateRoot": { + "source": "Blueprint", + "id": "AnalyticsRoot", + "blueprint": "analytics" + }, + "screens": [], + "remaps": {}, + "expected": { + "from": "OwnShell", + "to": "AnalyticsRoot", + "fromPrimary": "content", + "toPrimary": "main", + "isNoOp": false, + "moves": [ + { + "slot": "sidebar", + "action": "unused", + "screens": [], + "description": "Slot 'sidebar' of shell 'OwnShell' has no counterpart in shell 'AnalyticsRoot'; no screen uses it" + }, + { + "slot": "content", + "action": "primary", + "target": "main", + "screens": [], + "description": "Slot 'content' becomes 'main', the primary slot of shell 'AnalyticsRoot'; no screen uses it" + } + ], + "addPackages": [], + "blockers": [], + "warnings": [], + "canSwitch": true + } + }, + { + "name": "content slot that is not the primary one follows the primary slot", + "description": "a slot named content that the current shell has beside its primary slot goes to the primary slot of a shell without one", + "profile": { + "name": "Desktop", + "targetPlatform": "web", + "packages": [ + "core", + "crm", + "hr", + "analytics" + ] + }, + "currentRoot": { + "source": "Own", + "id": "SideFrame" + }, + "candidateRoot": { + "source": "Blueprint", + "id": "AnalyticsRoot", + "blueprint": "analytics" + }, + "screens": [ + { + "name": "Orders", + "layout": "x", + "slotContent": { + "content": [ + { + "type": "test", + "id": "Orders-content" + } + ] + }, + "forms": [], + "contributions": [] + } + ], + "remaps": {}, + "expected": { + "from": "SideFrame", + "to": "AnalyticsRoot", + "fromPrimary": "side", + "toPrimary": "main", + "isNoOp": false, + "moves": [ + { + "slot": "content", + "action": "primary", + "target": "main", + "screens": [ + "Orders" + ], + "description": "'Orders' moves from slot 'content' to 'main', the primary slot of shell 'AnalyticsRoot'" + }, + { + "slot": "side", + "action": "primary", + "target": "main", + "screens": [], + "description": "Slot 'side' becomes 'main', the primary slot of shell 'AnalyticsRoot'; no screen uses it" + } + ], + "addPackages": [], + "blockers": [], + "warnings": [], + "canSwitch": true + } + }, + { + "name": "content slot nobody uses follows the primary slot", + "description": "an unused content slot beside the primary slot becomes the primary slot of a shell without one", + "profile": { + "name": "Desktop", + "targetPlatform": "web", + "packages": [ + "core", + "crm", + "hr", + "analytics" + ] + }, + "currentRoot": { + "source": "Own", + "id": "SideFrame" + }, + "candidateRoot": { + "source": "Blueprint", + "id": "AnalyticsRoot", + "blueprint": "analytics" + }, + "screens": [], + "remaps": {}, + "expected": { + "from": "SideFrame", + "to": "AnalyticsRoot", + "fromPrimary": "side", + "toPrimary": "main", + "isNoOp": false, + "moves": [ + { + "slot": "content", + "action": "primary", + "target": "main", + "screens": [], + "description": "Slot 'content' becomes 'main', the primary slot of shell 'AnalyticsRoot'; no screen uses it" + }, + { + "slot": "side", + "action": "primary", + "target": "main", + "screens": [], + "description": "Slot 'side' becomes 'main', the primary slot of shell 'AnalyticsRoot'; no screen uses it" + } + ], + "addPackages": [], + "blockers": [], + "warnings": [], + "canSwitch": true + } + }, { "name": "shell blocker comes first", "description": "the shell's own blockers come before the slots to decide", From d044b7594885c33ed1030ab206710e5a1555b674 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 11 Nov 2026 20:30:01 +0100 Subject: [PATCH 18/21] Break the shell switch planner into named steps in both engines --- .../DotNET/Engine/Shell/ShellSwitchPlanner.cs | 195 ++++++++++-------- .../engine/shell/planShellSwitch.ts | 151 +++++++++----- 2 files changed, 199 insertions(+), 147 deletions(-) diff --git a/Source/DotNET/Engine/Shell/ShellSwitchPlanner.cs b/Source/DotNET/Engine/Shell/ShellSwitchPlanner.cs index 95abffaa..f6ccad57 100644 --- a/Source/DotNET/Engine/Shell/ShellSwitchPlanner.cs +++ b/Source/DotNET/Engine/Shell/ShellSwitchPlanner.cs @@ -47,57 +47,60 @@ public static ShellSwitchPlan Plan( IReadOnlyList? templates = null, IReadOnlyDictionary? remaps = null) { - templates ??= []; - var users = new Dictionary>(StringComparer.Ordinal); - var used = new List(); - foreach (var screen in screens) + var users = UsersBySlot(screens, current, templates ?? []); + var shells = new ShellPair(current, candidate); + var blockers = new List(); + var slots = current.Slots.Concat(users.Keys.Where(slot => !current.Slots.Contains(slot))); + var moves = slots + .Select(slot => MoveOf(new SlotUse(slot, users.GetValueOrDefault(slot, []), RemapOf(remaps, slot)), shells, blockers)) + .ToList(); + + var (addPackages, warnings) = SortCandidateDiagnostics(candidate, blockers); + + return new ShellSwitchPlan( + current.Name, + candidate.Name, + current.PrimarySlot, + candidate.PrimarySlot, + current.Name == candidate.Name && current.Package == candidate.Package, + moves, + addPackages, + blockers, + warnings); + } + + static OrderedDictionary> UsersBySlot(IReadOnlyList screens, EffectiveShell current, IReadOnlyList templates) + { + var users = new OrderedDictionary>(StringComparer.Ordinal); + var uses = screens.SelectMany(screen => ShellPlacement.SlotsUsedBy(screen, current.Name, templates).Select(slot => (Slot: slot, Screen: screen.Name))); + foreach (var (slot, screen) in uses) { - foreach (var slot in ShellPlacement.SlotsUsedBy(screen, current.Name, templates)) + if (!users.TryGetValue(slot, out var names)) { - if (!users.TryGetValue(slot, out var names)) - { - users[slot] = names = []; - } - - if (!names.Contains(screen.Name)) - { - names.Add(screen.Name); - } - - if (!used.Contains(slot)) - { - used.Add(slot); - } + users[slot] = names = []; + } + + if (!names.Contains(screen)) + { + names.Add(screen); } } - var blockers = new List(); - var slots = current.Slots.Concat(used.Where(slot => !current.Slots.Contains(slot))); - var moves = slots - .Select(slot => MoveOf( - slot, - current, - candidate, - users.TryGetValue(slot, out var found) ? found : [], - remaps is not null && remaps.TryGetValue(slot, out var remap) ? remap : null, - blockers)) - .ToList(); + return users; + } + + static SlotRemap? RemapOf(IReadOnlyDictionary? remaps, string slot) => + remaps is not null && remaps.TryGetValue(slot, out var remap) ? remap : null; + static (List AddPackages, List Warnings) SortCandidateDiagnostics(EffectiveShell candidate, List blockers) + { var addPackages = new List(); var warnings = new List(); - foreach (var diagnostic in candidate.Diagnostics) + foreach (var diagnostic in candidate.Diagnostics.Where(diagnostic => diagnostic.Code != ShellDiagnosticCode.ProfileSlotUnplaced)) { - if (diagnostic.Code == ShellDiagnosticCode.ProfileSlotUnplaced) - { - continue; - } - if (diagnostic.Code == ShellDiagnosticCode.RootTemplateBlueprintNotInstalled) { - if (candidate.Package is not null && !addPackages.Contains(candidate.Package)) - { - addPackages.Add(candidate.Package); - } + AddOnce(addPackages, candidate.Package); } else if (_blocking.Contains(diagnostic.Code)) { @@ -109,80 +112,86 @@ public static ShellSwitchPlan Plan( } } - return new ShellSwitchPlan( - current.Name, - candidate.Name, - current.PrimarySlot, - candidate.PrimarySlot, - current.Name == candidate.Name && current.Package == candidate.Package, - moves, - addPackages, - blockers, - warnings); + return (addPackages, warnings); } - static ShellSlotMove MoveOf(string slot, EffectiveShell current, EffectiveShell candidate, List screens, SlotRemap? remap, List blockers) + static void AddOnce(List packages, string? package) { - if (current.Slots.Contains(slot) && slot == current.PrimarySlot) + if (package is not null && !packages.Contains(package)) { - return candidate.PrimarySlot == slot - ? new(slot, ShellSlotAction.Keep, screens, KeepText(slot, screens), slot) - : new(slot, ShellSlotAction.Primary, screens, PrimaryText(slot, candidate, screens), candidate.PrimarySlot); + packages.Add(package); } + } - if (candidate.Slots.Contains(slot)) - { - return new(slot, ShellSlotAction.Keep, screens, KeepText(slot, screens), slot); - } + static ShellSlotMove MoveOf(SlotUse use, ShellPair shells, List blockers) + { + var (slot, screens, _) = use; + var (current, candidate) = shells; + return DefaultMove(use, shells) ?? (screens.Count == 0 + ? new ShellSlotMove(slot, ShellSlotAction.Unused, screens, $"Slot '{slot}' of shell '{current.Name}' has no counterpart in shell '{candidate.Name}'; no screen uses it") + : Remapped(use, candidate, blockers)); + } + + static ShellSlotMove? DefaultMove(SlotUse use, ShellPair shells) + { + var (slot, screens, _) = use; + var (current, candidate) = shells; + var inCurrent = current.Slots.Contains(slot); + var keep = new ShellSlotMove(slot, ShellSlotAction.Keep, screens, KeepText(slot, screens), slot); + var primary = new ShellSlotMove(slot, ShellSlotAction.Primary, screens, PrimaryText(slot, candidate, screens), candidate.PrimarySlot); - if (slot == ContentSlot && current.Slots.Contains(slot)) + if (inCurrent && slot == current.PrimarySlot) { - return new(slot, ShellSlotAction.Primary, screens, PrimaryText(slot, candidate, screens), candidate.PrimarySlot); + return candidate.PrimarySlot == slot ? keep : primary; } - if (screens.Count == 0) + if (candidate.Slots.Contains(slot)) { - return new(slot, ShellSlotAction.Unused, screens, $"Slot '{slot}' of shell '{current.Name}' has no counterpart in shell '{candidate.Name}'; no screen uses it"); + return keep; } - return Remapped(slot, candidate, screens, remap, blockers); + return slot == ContentSlot && inCurrent ? primary : null; } - static ShellSlotMove Remapped(string slot, EffectiveShell candidate, List screens, SlotRemap? remap, List blockers) + static ShellSlotMove Remapped(SlotUse use, EffectiveShell candidate, List blockers) { - var (count, names) = ScreensText(screens); - var place = screens.Count == 1 ? "places" : "place"; - List choices = - [ - new(ShellSlotAction.MoveToPrimary, candidate.PrimarySlot), - .. candidate.Slots.Where(name => name != candidate.PrimarySlot).Select(name => new SlotRemap(ShellSlotAction.MapTo, name)), - new(ShellSlotAction.Drop) - ]; - - if (remap?.Action == ShellSlotAction.MoveToPrimary) + var (slot, screens, remap) = use; + var decided = Decided(use, candidate); + if (decided is not null) { - return new(slot, ShellSlotAction.MoveToPrimary, screens, $"The content that {names} {place} in slot '{slot}' moves into '{candidate.PrimarySlot}', the primary slot of shell '{candidate.Name}'", candidate.PrimarySlot); + return decided; } - if (remap?.Action == ShellSlotAction.Drop) - { - return new(slot, ShellSlotAction.Drop, screens, $"The content that {names} {place} in slot '{slot}' is dropped"); - } + var (count, names) = ScreensText(screens); + var message = remap?.Action == ShellSlotAction.MapTo + ? $"The content in slot '{slot}' cannot be mapped to '{remap.Target ?? string.Empty}': shell '{candidate.Name}' has no such slot" + : $"{count} ({names}) {PlaceVerb(screens)} content in slot '{slot}', which shell '{candidate.Name}' does not have: move it into '{candidate.PrimarySlot}', map it to another slot or drop it"; + blockers.Add(new ShellDiagnostic(ShellDiagnosticCode.ProfileSlotUnplaced, message, slot)); + return new(slot, ShellSlotAction.Unresolved, screens, message, null, ChoicesFor(candidate)); + } - var message = $"{count} ({names}) {place} content in slot '{slot}', which shell '{candidate.Name}' does not have: move it into '{candidate.PrimarySlot}', map it to another slot or drop it"; - if (remap?.Action == ShellSlotAction.MapTo) + static ShellSlotMove? Decided(SlotUse use, EffectiveShell candidate) + { + var (slot, screens, remap) = use; + var content = $"The content that {ScreensText(screens).Names} {PlaceVerb(screens)} in slot '{slot}'"; + return remap?.Action switch { - if (remap.Target is not null && candidate.Slots.Contains(remap.Target)) - { - return new(slot, ShellSlotAction.MapTo, screens, $"The content that {names} {place} in slot '{slot}' moves into '{remap.Target}' of shell '{candidate.Name}'", remap.Target); - } + ShellSlotAction.MoveToPrimary => new(slot, ShellSlotAction.MoveToPrimary, screens, $"{content} moves into '{candidate.PrimarySlot}', the primary slot of shell '{candidate.Name}'", candidate.PrimarySlot), + ShellSlotAction.Drop => new(slot, ShellSlotAction.Drop, screens, $"{content} is dropped"), + ShellSlotAction.MapTo when remap.Target is not null && candidate.Slots.Contains(remap.Target) => + new(slot, ShellSlotAction.MapTo, screens, $"{content} moves into '{remap.Target}' of shell '{candidate.Name}'", remap.Target), + _ => null + }; + } - message = $"The content in slot '{slot}' cannot be mapped to '{remap.Target ?? string.Empty}': shell '{candidate.Name}' has no such slot"; - } + static List ChoicesFor(EffectiveShell candidate) => + [ + new(ShellSlotAction.MoveToPrimary, candidate.PrimarySlot), + .. candidate.Slots.Where(name => name != candidate.PrimarySlot).Select(name => new SlotRemap(ShellSlotAction.MapTo, name)), + new(ShellSlotAction.Drop) + ]; - blockers.Add(new ShellDiagnostic(ShellDiagnosticCode.ProfileSlotUnplaced, message, slot)); - return new(slot, ShellSlotAction.Unresolved, screens, message, null, choices); - } + static string PlaceVerb(List screens) => screens.Count == 1 ? "places" : "place"; static string KeepText(string slot, List screens) { @@ -215,4 +224,8 @@ static string PrimaryText(string slot, EffectiveShell candidate, List sc return (count, $"{string.Join(", ", quoted.Take(NamedScreens))} and {screens.Count - NamedScreens} more"); } + + sealed record ShellPair(EffectiveShell Current, EffectiveShell Candidate); + + sealed record SlotUse(string Slot, List Screens, SlotRemap? Remap); } diff --git a/Source/JavaScript/engine/shell/planShellSwitch.ts b/Source/JavaScript/engine/shell/planShellSwitch.ts index f55f4097..2d52dc68 100644 --- a/Source/JavaScript/engine/shell/planShellSwitch.ts +++ b/Source/JavaScript/engine/shell/planShellSwitch.ts @@ -21,6 +21,17 @@ const blocking = new Set([ ShellDiagnosticCode.RootTemplateCycle, ]); +interface ShellPair { + current: EffectiveShell; + candidate: EffectiveShell; +} + +interface SlotUse { + slot: string; + screens: string[]; + remap: SlotRemap | undefined; +} + /** * Plans switching the application's shell: where the content of every slot of the current shell goes in the new one, what would be * lost, and what stops the switch. The pre-flight of "Use this shell"; the host shows the plan, offers the choices of any unresolved @@ -45,27 +56,46 @@ export function planShellSwitch( templates: readonly ScreenTemplate[] = [], remaps: SlotRemaps = {}, ): ShellSwitchPlan { + const users = usersBySlot(screens, current, templates); + const shells: ShellPair = { current, candidate }; + const blockers: ShellDiagnostic[] = []; + const slots = [...current.slots, ...[...users.keys()].filter(slot => !current.slots.includes(slot))]; + const moves = slots.map(slot => moveOf({ slot, screens: users.get(slot) ?? [], remap: remaps[slot] }, shells, blockers)); + const { addPackages, warnings } = sortCandidateDiagnostics(candidate, blockers); + + return { + from: current.name, + to: candidate.name, + fromPrimary: current.primarySlot, + toPrimary: candidate.primarySlot, + isNoOp: current.name === candidate.name && current.package === candidate.package, + moves, + addPackages, + blockers, + warnings, + canSwitch: blockers.length === 0, + }; +} + +function usersBySlot(screens: readonly Screen[], current: EffectiveShell, templates: readonly ScreenTemplate[]): Map { const users = new Map(); - const used: string[] = []; for (const screen of screens) { for (const slot of slotsUsedBy(screen, current.name, templates)) { const names = users.get(slot) ?? []; if (!names.includes(screen.name)) names.push(screen.name); users.set(slot, names); - if (!used.includes(slot)) used.push(slot); } } - const blockers: ShellDiagnostic[] = []; - const slots = [...current.slots, ...used.filter(slot => !current.slots.includes(slot))]; - const moves = slots.map(slot => moveOf(slot, current, candidate, users.get(slot) ?? [], remaps[slot], blockers)); + return users; +} +function sortCandidateDiagnostics(candidate: EffectiveShell, blockers: ShellDiagnostic[]): { addPackages: string[]; warnings: ShellDiagnostic[] } { const addPackages: string[] = []; const warnings: ShellDiagnostic[] = []; - for (const diagnostic of candidate.diagnostics) { - if (diagnostic.code === ShellDiagnosticCode.ProfileSlotUnplaced) continue; + for (const diagnostic of candidate.diagnostics.filter(item => item.code !== ShellDiagnosticCode.ProfileSlotUnplaced)) { if (diagnostic.code === ShellDiagnosticCode.RootTemplateBlueprintNotInstalled) { - if (candidate.package !== undefined && !addPackages.includes(candidate.package)) addPackages.push(candidate.package); + addOnce(addPackages, candidate.package); } else if (blocking.has(diagnostic.code)) { blockers.unshift(diagnostic); } else { @@ -73,67 +103,76 @@ export function planShellSwitch( } } - return { - from: current.name, - to: candidate.name, - fromPrimary: current.primarySlot, - toPrimary: candidate.primarySlot, - isNoOp: current.name === candidate.name && current.package === candidate.package, - moves, - addPackages, - blockers, - warnings, - canSwitch: blockers.length === 0, - }; + return { addPackages, warnings }; +} + +function addOnce(packages: string[], name: string | undefined): void { + if (name !== undefined && !packages.includes(name)) packages.push(name); } -function moveOf(slot: string, current: EffectiveShell, candidate: EffectiveShell, screens: string[], remap: SlotRemap | undefined, blockers: ShellDiagnostic[]): ShellSlotMove { - const move = (action: ShellSlotAction, target: string | undefined, description: string): ShellSlotMove => - target === undefined ? { slot, action, screens, description } : { slot, action, target, screens, description }; +function moveFor(use: SlotUse, action: ShellSlotAction, target: string | undefined, description: string): ShellSlotMove { + const { slot, screens } = use; + return target === undefined ? { slot, action, screens, description } : { slot, action, target, screens, description }; +} - if (current.slots.includes(slot) && slot === current.primarySlot) { - return candidate.primarySlot === slot - ? move(ShellSlotAction.Keep, slot, keepText(slot, screens)) - : move(ShellSlotAction.Primary, candidate.primarySlot, primaryText(slot, candidate, screens)); - } +function moveOf(use: SlotUse, shells: ShellPair, blockers: ShellDiagnostic[]): ShellSlotMove { + const { slot, screens } = use; + const unused = `Slot '${slot}' of shell '${shells.current.name}' has no counterpart in shell '${shells.candidate.name}'; no screen uses it`; + return defaultMove(use, shells) + ?? (screens.length === 0 ? moveFor(use, ShellSlotAction.Unused, undefined, unused) : remapped(use, shells.candidate, blockers)); +} - if (candidate.slots.includes(slot)) return move(ShellSlotAction.Keep, slot, keepText(slot, screens)); - if (slot === contentSlot && current.slots.includes(slot)) return move(ShellSlotAction.Primary, candidate.primarySlot, primaryText(slot, candidate, screens)); - if (screens.length === 0) return move(ShellSlotAction.Unused, undefined, `Slot '${slot}' of shell '${current.name}' has no counterpart in shell '${candidate.name}'; no screen uses it`); +function defaultMove(use: SlotUse, { current, candidate }: ShellPair): ShellSlotMove | undefined { + const { slot, screens } = use; + const inCurrent = current.slots.includes(slot); + const keep = (): ShellSlotMove => moveFor(use, ShellSlotAction.Keep, slot, keepText(slot, screens)); + const primary = (): ShellSlotMove => moveFor(use, ShellSlotAction.Primary, candidate.primarySlot, primaryText(slot, candidate, screens)); - return remapped(slot, candidate, screens, remap, blockers); + if (inCurrent && slot === current.primarySlot) return candidate.primarySlot === slot ? keep() : primary(); + if (candidate.slots.includes(slot)) return keep(); + return slot === contentSlot && inCurrent ? primary() : undefined; } -function remapped(slot: string, candidate: EffectiveShell, screens: string[], remap: SlotRemap | undefined, blockers: ShellDiagnostic[]): ShellSlotMove { +function remapped(use: SlotUse, candidate: EffectiveShell, blockers: ShellDiagnostic[]): ShellSlotMove { + const { slot, screens, remap } = use; + const decided = decidedMove(use, candidate); + if (decided !== undefined) return decided; + const who = screensText(screens); - const place = screens.length === 1 ? 'places' : 'place'; - const choices: SlotRemap[] = [ + const message = remap?.action === ShellSlotAction.MapTo + ? `The content in slot '${slot}' cannot be mapped to '${remap.target ?? ''}': shell '${candidate.name}' has no such slot` + : `${who.count} (${who.names}) ${placeVerb(screens)} content in slot '${slot}', which shell '${candidate.name}' does not have: move it into '${candidate.primarySlot}', map it to another slot or drop it`; + blockers.push({ code: ShellDiagnosticCode.ProfileSlotUnplaced, subject: slot, message }); + return { slot, action: ShellSlotAction.Unresolved, screens, description: message, choices: choicesFor(candidate) }; +} + +function decidedMove(use: SlotUse, candidate: EffectiveShell): ShellSlotMove | undefined { + const { slot, screens, remap } = use; + const content = `The content that ${screensText(screens).names} ${placeVerb(screens)} in slot '${slot}'`; + switch (remap?.action) { + case ShellSlotAction.MoveToPrimary: + return moveFor(use, ShellSlotAction.MoveToPrimary, candidate.primarySlot, `${content} moves into '${candidate.primarySlot}', the primary slot of shell '${candidate.name}'`); + case ShellSlotAction.Drop: + return moveFor(use, ShellSlotAction.Drop, undefined, `${content} is dropped`); + case ShellSlotAction.MapTo: + return remap.target !== undefined && candidate.slots.includes(remap.target) + ? moveFor(use, ShellSlotAction.MapTo, remap.target, `${content} moves into '${remap.target}' of shell '${candidate.name}'`) + : undefined; + default: + return undefined; + } +} + +function choicesFor(candidate: EffectiveShell): SlotRemap[] { + return [ { action: ShellSlotAction.MoveToPrimary, target: candidate.primarySlot }, ...candidate.slots.filter(name => name !== candidate.primarySlot).map((name): SlotRemap => ({ action: ShellSlotAction.MapTo, target: name })), { action: ShellSlotAction.Drop }, ]; - const decided = (action: ShellSlotAction, target: string | undefined, description: string): ShellSlotMove => - target === undefined ? { slot, action, screens, description } : { slot, action, target, screens, description }; - - if (remap?.action === ShellSlotAction.MoveToPrimary) { - return decided(ShellSlotAction.MoveToPrimary, candidate.primarySlot, `The content that ${who.names} ${place} in slot '${slot}' moves into '${candidate.primarySlot}', the primary slot of shell '${candidate.name}'`); - } - - if (remap?.action === ShellSlotAction.Drop) { - return decided(ShellSlotAction.Drop, undefined, `The content that ${who.names} ${place} in slot '${slot}' is dropped`); - } - - let message = `${who.count} (${who.names}) ${place} content in slot '${slot}', which shell '${candidate.name}' does not have: move it into '${candidate.primarySlot}', map it to another slot or drop it`; - if (remap?.action === ShellSlotAction.MapTo) { - if (remap.target !== undefined && candidate.slots.includes(remap.target)) { - return decided(ShellSlotAction.MapTo, remap.target, `The content that ${who.names} ${place} in slot '${slot}' moves into '${remap.target}' of shell '${candidate.name}'`); - } - - message = `The content in slot '${slot}' cannot be mapped to '${remap.target ?? ''}': shell '${candidate.name}' has no such slot`; - } +} - blockers.push({ code: ShellDiagnosticCode.ProfileSlotUnplaced, subject: slot, message }); - return { slot, action: ShellSlotAction.Unresolved, screens, description: message, choices }; +function placeVerb(screens: string[]): string { + return screens.length === 1 ? 'places' : 'place'; } function keepText(slot: string, screens: string[]): string { From d435dab7f2fcb5d966ce7aa7b1b4ba2ccc2274f5 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 11 Nov 2026 20:31:42 +0100 Subject: [PATCH 19/21] Break the shell resolver into named steps --- Source/DotNET/Engine/Shell/ShellResolver.cs | 157 +++++++++++--------- 1 file changed, 84 insertions(+), 73 deletions(-) diff --git a/Source/DotNET/Engine/Shell/ShellResolver.cs b/Source/DotNET/Engine/Shell/ShellResolver.cs index 1f338c17..0e84aa63 100644 --- a/Source/DotNET/Engine/Shell/ShellResolver.cs +++ b/Source/DotNET/Engine/Shell/ShellResolver.cs @@ -58,12 +58,12 @@ public static EffectiveShell Resolve(UiProfile profile, ShellResolutionContext c EffectiveShell? shell = null; if (profile.Root is not null) { - shell = FromReference(profile.Root.Template, ShellOrigin.Root, profile, context, diagnostics, profile.Root.Options); + shell = FromReference(new RootRequest(profile.Root.Template, ShellOrigin.Root, profile.Root.Options), profile, context, diagnostics); } if (shell is null && !string.IsNullOrWhiteSpace(profile.Layout)) { - shell = FromLegacyLayout(profile.Layout, profile, context, installed, diagnostics); + shell = FromLegacyLayout(profile, context, installed, diagnostics); } shell ??= FromBlueprintDefault(installed); @@ -87,82 +87,78 @@ [.. profile.Packages .Select(name => context.Library.Blueprints.FirstOrDefault(source => source.Manifest.Name == name && source.Manifest.Kind == PackageKind.Blueprint)) .OfType()]; - static EffectiveShell? FromReference( - TemplateReference reference, - ShellOrigin origin, - UiProfile profile, - ShellResolutionContext context, - List diagnostics, - IReadOnlyDictionary? options) + static EffectiveShell? FromReference(RootRequest request, UiProfile profile, ShellResolutionContext context, List diagnostics) { + var reference = request.Reference; var resolution = TemplateReferenceResolver.Resolve(reference, context.Library); - var label = ReferenceLabel(reference); - - switch (resolution.Status) + var rejection = RejectUnresolved(reference, resolution); + var found = rejection is null ? FindReferenced(reference, context.Library) : null; + rejection ??= found is null ? RejectNotAShell(reference, resolution) : RejectProblems(found, reference); + if (rejection is not null) { - case TemplateReferenceStatus.Malformed: - case TemplateReferenceStatus.UnknownTemplate: - diagnostics.Add(new(ShellDiagnosticCode.RootTemplateUnknown, $"Root template {label} cannot be used: {resolution.Message}", reference.Id)); - return null; - case TemplateReferenceStatus.UnknownBlueprint: - diagnostics.Add(new(ShellDiagnosticCode.RootTemplateBlueprintNotInstalled, $"Root template {label} comes from blueprint '{reference.Blueprint}', which is not installed", reference.Blueprint)); - return null; + diagnostics.Add(rejection); + return null; } + diagnostics.AddRange(ReferenceWarnings(found!, reference, resolution, profile)); + return Build(found!, request.Origin, reference, request.Options); + } + + static ShellDiagnostic? RejectUnresolved(TemplateReference reference, TemplateReferenceResolution resolution) => resolution.Status switch + { + TemplateReferenceStatus.Malformed or TemplateReferenceStatus.UnknownTemplate => + new(ShellDiagnosticCode.RootTemplateUnknown, $"Root template {ReferenceLabel(reference)} cannot be used: {resolution.Message}", reference.Id), + TemplateReferenceStatus.UnknownBlueprint => + new(ShellDiagnosticCode.RootTemplateBlueprintNotInstalled, $"Root template {ReferenceLabel(reference)} comes from blueprint '{reference.Blueprint}', which is not installed", reference.Blueprint), + _ => null + }; + + static Found? FindReferenced(TemplateReference reference, TemplateLibrary library) + { var isBlueprint = reference.Source == TemplateReferenceSource.Blueprint; var source = isBlueprint - ? context.Library.Blueprints.FirstOrDefault(candidate => candidate.Manifest.Name == reference.Blueprint) - : context.Library.Own; - var found = source is null ? null : FindShell(reference.Id, source, isBlueprint); - if (found is null) - { - if (resolution.Status == TemplateReferenceStatus.Resolved) - { - diagnostics.Add(new( - ShellDiagnosticCode.RootTemplateNotApplicable, - $"Root template {label} cannot be the application's shell: it is a {KindName(resolution.Kind)}, and only a layout or a screen template with the root role can be a shell", - reference.Id)); - } - else - { - diagnostics.Add(new(ShellDiagnosticCode.RootTemplateVersionNotSatisfied, $"Root template {label} cannot be used: {resolution.Message}", reference.Id)); - } + ? library.Blueprints.FirstOrDefault(candidate => candidate.Manifest.Name == reference.Blueprint) + : library.Own; + return source is null ? null : FindShell(reference.Id, source, isBlueprint); + } - return null; - } + static ShellDiagnostic RejectNotAShell(TemplateReference reference, TemplateReferenceResolution resolution) => + resolution.Status == TemplateReferenceStatus.Resolved + ? new( + ShellDiagnosticCode.RootTemplateNotApplicable, + $"Root template {ReferenceLabel(reference)} cannot be the application's shell: it is a {KindName(resolution.Kind)}, and only a layout or a screen template with the root role can be a shell", + reference.Id) + : new(ShellDiagnosticCode.RootTemplateVersionNotSatisfied, $"Root template {ReferenceLabel(reference)} cannot be used: {resolution.Message}", reference.Id); + static ShellDiagnostic? RejectProblems(Found found, TemplateReference reference) + { var problems = found.Kind == ShellKind.Layout ? TemplateApplicability.ValidateLayout(found.Layout, TemplateScope.Application) : TemplateApplicability.ValidateScreenTemplate(found.ScreenTemplate, TemplateScope.Application); - if (problems.Count > 0) - { - diagnostics.Add(new(ShellDiagnosticCode.RootTemplateNotApplicable, $"Root template {label} cannot be the application's shell: {string.Join("; ", problems)}", reference.Id)); - return null; - } + return problems.Count > 0 + ? new(ShellDiagnosticCode.RootTemplateNotApplicable, $"Root template {ReferenceLabel(reference)} cannot be the application's shell: {string.Join("; ", problems)}", reference.Id) + : null; + } + static IEnumerable ReferenceWarnings(Found found, TemplateReference reference, TemplateReferenceResolution resolution, UiProfile profile) + { if (resolution.Status == TemplateReferenceStatus.VersionNotSatisfied) { - diagnostics.Add(new(ShellDiagnosticCode.RootTemplateVersionNotSatisfied, resolution.Message!, reference.Id)); + yield return new(ShellDiagnosticCode.RootTemplateVersionNotSatisfied, resolution.Message!, reference.Id); } if (found.IsBlueprint && !profile.Packages.Contains(reference.Blueprint!)) { - diagnostics.Add(new( + yield return new( ShellDiagnosticCode.RootTemplateBlueprintNotInstalled, - $"Root template {label} comes from blueprint '{reference.Blueprint}', which is available but not among the packages of profile '{profile.Name}'", - reference.Blueprint)); + $"Root template {ReferenceLabel(reference)} comes from blueprint '{reference.Blueprint}', which is available but not among the packages of profile '{profile.Name}'", + reference.Blueprint); } - - return Build(found, origin, reference, options); } - static EffectiveShell? FromLegacyLayout( - string name, - UiProfile profile, - ShellResolutionContext context, - List installed, - List diagnostics) + static EffectiveShell? FromLegacyLayout(UiProfile profile, ShellResolutionContext context, List installed, List diagnostics) { + var name = profile.Layout!; var own = context.Library.Own is null ? null : FindLayout(name, context.Library.Own, false); if (own is not null) { @@ -217,9 +213,9 @@ [.. _bareShell.Slots.Select(slot => slot.Name)], static EffectiveShell Build(Found found, ShellOrigin origin, TemplateReference reference, IReadOnlyDictionary? options = null) { - var name = found.Kind == ShellKind.Layout ? found.Layout!.Name : found.ScreenTemplate!.Name; - var slots = found.Kind == ShellKind.Layout ? found.Layout!.Slots : found.ScreenTemplate!.Slots; - var pin = found.IsBlueprint && found.Source is not null ? BlueprintVersionPin.Evaluate(reference.Version, found.Source.Manifest.Version) : null; + var (name, slots) = found.Layout is { } layout ? (layout.Name, layout.Slots) : (found.ScreenTemplate!.Name, found.ScreenTemplate.Slots); + var manifest = found.IsBlueprint ? found.Source?.Manifest : null; + var pin = manifest is null ? null : BlueprintVersionPin.Evaluate(reference.Version, manifest.Version); return new( origin, @@ -231,8 +227,8 @@ [.. slots.Select(slot => slot.Name)], SlotRoles.PrimaryOf(slots) ?? DefaultPrimarySlot, new Dictionary(options ?? new Dictionary()), [], - found.IsBlueprint && found.Source is not null ? found.Source.Manifest.Name : null, - found.IsBlueprint && found.Source is not null ? found.Source.Manifest.Version : null, + manifest?.Name, + manifest?.Version, pin, found.Layout, found.ScreenTemplate); @@ -285,26 +281,39 @@ static void CheckCycles(EffectiveShell shell, List templates, Li } static void CheckProfile(UiProfile profile, ShellResolutionContext context, List diagnostics) + { + CheckTheme(profile, context, diagnostics); + CheckAvailablePackages(profile, context, diagnostics); + } + + static void CheckTheme(UiProfile profile, ShellResolutionContext context, List diagnostics) { var theme = profile.Theme is null ? null : context.Themes?.FirstOrDefault(candidate => candidate.Name == profile.Theme); - if (theme is not null) + if (theme is null) { - var incompatible = ThemeCompatibility.IncompatiblePackages(theme, profile); - if (incompatible.Count > 0) - { - diagnostics.Add(new( - ShellDiagnosticCode.ProfileThemeIncompatible, - $"Theme '{theme.Name}' does not support {(incompatible.Count == 1 ? "package" : "packages")} {string.Join(", ", incompatible.Select(name => $"'{name}'"))} of profile '{profile.Name}'", - theme.Name)); - } + return; } - if (context.AvailablePackages is not null) + var incompatible = ThemeCompatibility.IncompatiblePackages(theme, profile); + if (incompatible.Count > 0) { - foreach (var name in profile.Packages.Where(candidate => !context.AvailablePackages.Contains(candidate))) - { - diagnostics.Add(new(ShellDiagnosticCode.ProfilePackageMissing, $"Package '{name}' of profile '{profile.Name}' is not available", name)); - } + diagnostics.Add(new( + ShellDiagnosticCode.ProfileThemeIncompatible, + $"Theme '{theme.Name}' does not support {(incompatible.Count == 1 ? "package" : "packages")} {string.Join(", ", incompatible.Select(name => $"'{name}'"))} of profile '{profile.Name}'", + theme.Name)); + } + } + + static void CheckAvailablePackages(UiProfile profile, ShellResolutionContext context, List diagnostics) + { + if (context.AvailablePackages is null) + { + return; + } + + foreach (var name in profile.Packages.Where(candidate => !context.AvailablePackages.Contains(candidate))) + { + diagnostics.Add(new(ShellDiagnosticCode.ProfilePackageMissing, $"Package '{name}' of profile '{profile.Name}' is not available", name)); } } @@ -319,5 +328,7 @@ static void CheckScreens(EffectiveShell shell, ShellResolutionContext context, L } } + sealed record RootRequest(TemplateReference Reference, ShellOrigin Origin, IReadOnlyDictionary? Options); + sealed record Found(Layout? Layout, ScreenTemplate? ScreenTemplate, ShellKind Kind, TemplateSource? Source, bool IsBlueprint); } From 1fa0e8ba65a309c869341a3b0f24aa7069529ab3 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 11 Nov 2026 20:32:41 +0100 Subject: [PATCH 20/21] Break the effective shell resolver into named steps --- .../engine/shell/resolveEffectiveShell.ts | 186 ++++++++++-------- 1 file changed, 107 insertions(+), 79 deletions(-) diff --git a/Source/JavaScript/engine/shell/resolveEffectiveShell.ts b/Source/JavaScript/engine/shell/resolveEffectiveShell.ts index 39e73165..b4cab4ec 100644 --- a/Source/JavaScript/engine/shell/resolveEffectiveShell.ts +++ b/Source/JavaScript/engine/shell/resolveEffectiveShell.ts @@ -12,13 +12,13 @@ import { TemplateScope, UiProfile, } from '@cratis/scene.model'; -import { BlueprintPinEvaluation, defaultBlueprintPin, evaluateBlueprintPin } from '../blueprints/evaluateBlueprintPin'; +import { defaultBlueprintPin, evaluateBlueprintPin } from '../blueprints/evaluateBlueprintPin'; import { resolveScreenTemplates } from '../resolveScreenTemplates'; import { primarySlotOf } from '../slots/resolveSlotRole'; import { validateLayoutApplicability, validateScreenTemplateApplicability } from '../templateApplicability'; import { incompatiblePackages } from '../themeCompatibility'; import { TemplateCatalogKind } from '../templates/TemplateCatalogKind'; -import { TemplateReferenceStatus } from '../templates/TemplateReferenceResolution'; +import { TemplateReferenceResolution, TemplateReferenceStatus } from '../templates/TemplateReferenceResolution'; import { TemplateSource } from '../templates/TemplateSource'; import { resolveTemplateReference } from '../templates/resolveTemplateReference'; import { EffectiveShell, ShellKind, ShellOrigin, ShellSource } from './EffectiveShell'; @@ -33,6 +33,12 @@ export const bareShellName = 'Bare'; const bareShell: Layout = { name: bareShellName, slots: [{ name: defaultPrimarySlot }] }; +interface RootRequest { + reference: TemplateReference; + origin: ShellOrigin; + options: Record | undefined; +} + interface Found { template: Layout | ScreenTemplate; kind: ShellKind; @@ -63,11 +69,11 @@ export function resolveEffectiveShell(profile: UiProfile, context: ShellResoluti let shell: EffectiveShell | undefined; if (profile.root !== undefined) { - shell = fromReference(profile.root.template, ShellOrigin.Root, profile, context, diagnostics, profile.root.options); + shell = fromReference({ reference: profile.root.template, origin: ShellOrigin.Root, options: profile.root.options }, profile, context, diagnostics); } - if (shell === undefined && profile.layout !== undefined && profile.layout.trim() !== '') { - shell = fromLegacyLayout(profile.layout, profile, context, installed, diagnostics); + if (shell === undefined && namesLegacyLayout(profile)) { + shell = fromLegacyLayout(profile, context, installed, diagnostics); } shell ??= fromBlueprintDefault(installed); @@ -83,82 +89,91 @@ export function resolveEffectiveShell(profile: UiProfile, context: ShellResoluti return shell; } +function namesLegacyLayout(profile: UiProfile): boolean { + return profile.layout !== undefined && profile.layout.trim() !== ''; +} + function installedBlueprints(profile: UiProfile, context: ShellResolutionContext): TemplateSource[] { return profile.packages .map(name => context.library.blueprints.find(source => source.manifest.name === name && source.manifest.kind === PackageKind.Blueprint)) .filter((source): source is TemplateSource => source !== undefined); } -function fromReference( - reference: TemplateReference, - origin: ShellOrigin, - profile: UiProfile, - context: ShellResolutionContext, - diagnostics: ShellDiagnostic[], - options: Record | undefined, -): EffectiveShell | undefined { +function fromReference(request: RootRequest, profile: UiProfile, context: ShellResolutionContext, diagnostics: ShellDiagnostic[]): EffectiveShell | undefined { + const { reference } = request; const resolution = resolveTemplateReference(reference, context.library); - const label = referenceLabel(reference); + let rejection = rejectUnresolved(reference, resolution); + const found = rejection === undefined ? findReferenced(reference, context) : undefined; + rejection ??= found === undefined ? rejectNotAShell(reference, resolution) : rejectProblems(found, reference); + if (rejection !== undefined) { + diagnostics.push(rejection); + return undefined; + } + diagnostics.push(...referenceWarnings(found!, reference, resolution, profile)); + return build(found!, request.origin, reference, request.options); +} + +function rejectUnresolved(reference: TemplateReference, resolution: TemplateReferenceResolution): ShellDiagnostic | undefined { + const label = referenceLabel(reference); switch (resolution.status) { case TemplateReferenceStatus.Malformed: case TemplateReferenceStatus.UnknownTemplate: - diagnostics.push({ code: ShellDiagnosticCode.RootTemplateUnknown, subject: reference.id, message: `Root template ${label} cannot be used: ${resolution.message}` }); - return undefined; + return { code: ShellDiagnosticCode.RootTemplateUnknown, subject: reference.id, message: `Root template ${label} cannot be used: ${resolution.message}` }; case TemplateReferenceStatus.UnknownBlueprint: - diagnostics.push({ code: ShellDiagnosticCode.RootTemplateBlueprintNotInstalled, subject: reference.blueprint, message: `Root template ${label} comes from blueprint '${reference.blueprint}', which is not installed` }); - return undefined; + return { code: ShellDiagnosticCode.RootTemplateBlueprintNotInstalled, subject: reference.blueprint, message: `Root template ${label} comes from blueprint '${reference.blueprint}', which is not installed` }; default: - break; + return undefined; } +} + +function findReferenced(reference: TemplateReference, context: ShellResolutionContext): Found | undefined { + const isBlueprint = reference.source === TemplateReferenceSource.Blueprint; + const source = isBlueprint + ? context.library.blueprints.find(candidate => candidate.manifest.name === reference.blueprint) + : context.library.own; + return source === undefined ? undefined : findShell(reference.id, source, isBlueprint); +} - const source = reference.source === TemplateReferenceSource.Own - ? context.library.own - : context.library.blueprints.find(candidate => candidate.manifest.name === reference.blueprint); - const found = source === undefined ? undefined : findShell(reference.id, source, reference.source === TemplateReferenceSource.Blueprint); - if (found === undefined) { - if (resolution.status === TemplateReferenceStatus.Resolved) { - diagnostics.push({ - code: ShellDiagnosticCode.RootTemplateNotApplicable, - subject: reference.id, - message: `Root template ${label} cannot be the application's shell: it is a ${kindName(resolution.kind)}, and only a layout or a screen template with the root role can be a shell`, - }); - } else { - diagnostics.push({ code: ShellDiagnosticCode.RootTemplateVersionNotSatisfied, subject: reference.id, message: `Root template ${label} cannot be used: ${resolution.message}` }); +function rejectNotAShell(reference: TemplateReference, resolution: TemplateReferenceResolution): ShellDiagnostic { + const label = referenceLabel(reference); + return resolution.status === TemplateReferenceStatus.Resolved + ? { + code: ShellDiagnosticCode.RootTemplateNotApplicable, + subject: reference.id, + message: `Root template ${label} cannot be the application's shell: it is a ${kindName(resolution.kind)}, and only a layout or a screen template with the root role can be a shell`, } - return undefined; - } + : { code: ShellDiagnosticCode.RootTemplateVersionNotSatisfied, subject: reference.id, message: `Root template ${label} cannot be used: ${resolution.message}` }; +} +function rejectProblems(found: Found, reference: TemplateReference): ShellDiagnostic | undefined { const problems = found.kind === ShellKind.Layout ? validateLayoutApplicability(found.template as Layout, TemplateScope.Application) : validateScreenTemplateApplicability(found.template as ScreenTemplate, TemplateScope.Application); - if (problems.length > 0) { - diagnostics.push({ code: ShellDiagnosticCode.RootTemplateNotApplicable, subject: reference.id, message: `Root template ${label} cannot be the application's shell: ${problems.join('; ')}` }); - return undefined; - } + return problems.length > 0 + ? { code: ShellDiagnosticCode.RootTemplateNotApplicable, subject: reference.id, message: `Root template ${referenceLabel(reference)} cannot be the application's shell: ${problems.join('; ')}` } + : undefined; +} - if (resolution.status === TemplateReferenceStatus.VersionNotSatisfied) { - diagnostics.push({ code: ShellDiagnosticCode.RootTemplateVersionNotSatisfied, subject: reference.id, message: resolution.message! }); +function referenceWarnings(found: Found, reference: TemplateReference, resolution: TemplateReferenceResolution, profile: UiProfile): ShellDiagnostic[] { + const warnings: ShellDiagnostic[] = []; + if (resolution.status === TemplateReferenceStatus.Resolved) { + warnings.push({ code: ShellDiagnosticCode.RootTemplateVersionNotSatisfied, subject: reference.id, message: resolution.message! }); } if (found.isBlueprint && !profile.packages.includes(reference.blueprint!)) { - diagnostics.push({ + warnings.push({ code: ShellDiagnosticCode.RootTemplateBlueprintNotInstalled, subject: reference.blueprint, - message: `Root template ${label} comes from blueprint '${reference.blueprint}', which is available but not among the packages of profile '${profile.name}'`, + message: `Root template ${referenceLabel(reference)} comes from blueprint '${reference.blueprint}', which is available but not among the packages of profile '${profile.name}'`, }); } - return build(found, origin, reference, options); + return warnings; } -function fromLegacyLayout( - name: string, - profile: UiProfile, - context: ShellResolutionContext, - installed: TemplateSource[], - diagnostics: ShellDiagnostic[], -): EffectiveShell | undefined { +function fromLegacyLayout(profile: UiProfile, context: ShellResolutionContext, installed: TemplateSource[], diagnostics: ShellDiagnostic[]): EffectiveShell | undefined { + const name = profile.layout!; const own = context.library.own === undefined ? undefined : findLayout(name, context.library.own, false); if (own !== undefined) return build(own, ShellOrigin.LegacyLayout, { source: TemplateReferenceSource.Own, id: name }); @@ -204,10 +219,8 @@ function bare(): EffectiveShell { function build(found: Found, origin: ShellOrigin, reference: TemplateReference, options?: Record): EffectiveShell { const template = found.template; const primarySlot = primarySlotOf(template.slots) ?? defaultPrimarySlot; - let pin: BlueprintPinEvaluation | undefined; - if (found.isBlueprint && found.source !== undefined) { - pin = evaluateBlueprintPin(reference.version, found.source.manifest.version); - } + const manifest = found.isBlueprint ? found.source?.manifest : undefined; + const pin = manifest === undefined ? undefined : evaluateBlueprintPin(reference.version, manifest.version); return { origin, @@ -216,7 +229,7 @@ function build(found: Found, origin: ShellOrigin, reference: TemplateReference, kind: found.kind, source: found.isBlueprint ? ShellSource.Blueprint : ShellSource.Own, name: template.name, - ...(found.isBlueprint && found.source !== undefined ? { package: found.source.manifest.name, packageVersion: found.source.manifest.version } : {}), + ...(manifest === undefined ? {} : { package: manifest.name, packageVersion: manifest.version }), ...(pin === undefined ? {} : { pin }), slots: template.slots.map(slot => slot.name), primarySlot, @@ -299,26 +312,34 @@ function checkCycles(shell: EffectiveShell, templates: readonly ScreenTemplate[] } function checkProfile(shell: EffectiveShell, profile: UiProfile, context: ShellResolutionContext): void { + checkTheme(shell, profile, context); + checkAvailablePackages(shell, profile, context); +} + +function checkTheme(shell: EffectiveShell, profile: UiProfile, context: ShellResolutionContext): void { const theme = profile.theme === undefined ? undefined : context.themes?.find(candidate => candidate.name === profile.theme); - if (theme !== undefined) { - const incompatible = incompatiblePackages(theme, profile); - if (incompatible.length > 0) { - shell.diagnostics.push({ - code: ShellDiagnosticCode.ProfileThemeIncompatible, - subject: theme.name, - message: `Theme '${theme.name}' does not support ${incompatible.length === 1 ? 'package' : 'packages'} ${incompatible.map(name => `'${name}'`).join(', ')} of profile '${profile.name}'`, - }); - } + if (theme === undefined) return; + + const incompatible = incompatiblePackages(theme, profile); + if (incompatible.length > 0) { + shell.diagnostics.push({ + code: ShellDiagnosticCode.ProfileThemeIncompatible, + subject: theme.name, + message: `Theme '${theme.name}' does not support ${incompatible.length === 1 ? 'package' : 'packages'} ${incompatible.map(name => `'${name}'`).join(', ')} of profile '${profile.name}'`, + }); } +} - if (context.availablePackages !== undefined) { - for (const name of profile.packages.filter(candidate => !context.availablePackages!.includes(candidate))) { - shell.diagnostics.push({ - code: ShellDiagnosticCode.ProfilePackageMissing, - subject: name, - message: `Package '${name}' of profile '${profile.name}' is not available`, - }); - } +function checkAvailablePackages(shell: EffectiveShell, profile: UiProfile, context: ShellResolutionContext): void { + const available = context.availablePackages; + if (available === undefined) return; + + for (const name of profile.packages.filter(candidate => !available.includes(candidate))) { + shell.diagnostics.push({ + code: ShellDiagnosticCode.ProfilePackageMissing, + subject: name, + message: `Package '${name}' of profile '${profile.name}' is not available`, + }); } } @@ -364,19 +385,26 @@ export function templatePlacement(templateName: string, shellName: string, templ while (current !== undefined && !visited.has(current.name)) { visited.add(current.name); if (current.fitsSlot === undefined) return undefined; - const separator = current.fitsSlot.lastIndexOf('.'); - const qualifier = separator < 0 ? undefined : current.fitsSlot.slice(0, separator); - const slot = separator < 0 ? current.fitsSlot : current.fitsSlot.slice(separator + 1); - if (qualifier === shellName) return { template: current.name, slot: slot, qualifier }; - const self: ScreenTemplate = current; - const container = templates.find(template => template !== self && (qualifier === undefined || template.name === qualifier) && template.slots.some(candidate => candidate.name === slot)); - if (container === undefined) return { template: current.name, slot: slot, qualifier }; + const { qualifier, slot } = splitFitsSlot(current.fitsSlot); + const container = qualifier === shellName ? undefined : containerOf(current, qualifier, slot, templates); + if (container === undefined) return { template: current.name, slot, qualifier }; current = container; } return undefined; } +function splitFitsSlot(fitsSlot: string): { qualifier: string | undefined; slot: string } { + const separator = fitsSlot.lastIndexOf('.'); + return separator < 0 + ? { qualifier: undefined, slot: fitsSlot } + : { qualifier: fitsSlot.slice(0, separator), slot: fitsSlot.slice(separator + 1) }; +} + +function containerOf(template: ScreenTemplate, qualifier: string | undefined, slot: string, templates: readonly ScreenTemplate[]): ScreenTemplate | undefined { + return templates.find(candidate => candidate !== template && (qualifier === undefined || candidate.name === qualifier) && candidate.slots.some(item => item.name === slot)); +} + /** * The slots of a shell a screen puts content in: its own slots with content, or the slot its outermost template fits. * From 8dbd17a38734dc68afc5622b1e2c90b6c7b3707c Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 11 Nov 2026 20:32:58 +0100 Subject: [PATCH 21/21] Restore the version pin warning in the effective shell resolver --- Source/JavaScript/engine/shell/resolveEffectiveShell.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/JavaScript/engine/shell/resolveEffectiveShell.ts b/Source/JavaScript/engine/shell/resolveEffectiveShell.ts index b4cab4ec..8a3f62de 100644 --- a/Source/JavaScript/engine/shell/resolveEffectiveShell.ts +++ b/Source/JavaScript/engine/shell/resolveEffectiveShell.ts @@ -157,7 +157,7 @@ function rejectProblems(found: Found, reference: TemplateReference): ShellDiagno function referenceWarnings(found: Found, reference: TemplateReference, resolution: TemplateReferenceResolution, profile: UiProfile): ShellDiagnostic[] { const warnings: ShellDiagnostic[] = []; - if (resolution.status === TemplateReferenceStatus.Resolved) { + if (resolution.status === TemplateReferenceStatus.VersionNotSatisfied) { warnings.push({ code: ShellDiagnosticCode.RootTemplateVersionNotSatisfied, subject: reference.id, message: resolution.message! }); }