diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md index b816632b..6b3166b7 100644 --- a/core/CHANGELOG.md +++ b/core/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.32.0 + +### Added + +- [`Thing.observeLiterals`](https://pod-os.org/reference/core/classes/thing/#observeliterals) pushes changes to literals + ## 0.31.0 ### Breaking changes @@ -33,7 +39,7 @@ and this project adheres to ## 0.28.0 -- [`addRelation`](https://pod-os.org/reference/core/classes/podos/#addrelation: A method to add a relation (link) between things +- [`addRelation`](https://pod-os.org/reference/core/classes/podos/#addrelation): A method to add a relation (link) between things ## 0.27.0 diff --git a/core/src/Store.ts b/core/src/Store.ts index 2a6e43eb..3c197fbc 100644 --- a/core/src/Store.ts +++ b/core/src/Store.ts @@ -45,6 +45,7 @@ import { Term, } from "rdflib/lib/tf-types"; import { iana, internal } from "./namespaces"; +export { Quad } from "rdflib/lib/tf-types"; /** * The Store contains all data that is known locally. diff --git a/core/src/thing/Thing.literals.spec.ts b/core/src/thing/Thing.literals.spec.ts index d1fe3b3e..aa534e1c 100644 --- a/core/src/thing/Thing.literals.spec.ts +++ b/core/src/thing/Thing.literals.spec.ts @@ -1,20 +1,21 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { blankNode, graph, IndexedFormula, sym } from "rdflib"; +import { afterEach, beforeEach, describe, expect, it, Mock, vi } from "vitest"; +import { blankNode, graph, IndexedFormula, literal, quad, sym } from "rdflib"; import { PodOsSession } from "../authentication"; -import { Thing } from "./Thing"; +import { Literal, Thing } from "./Thing"; import { Store } from "../Store"; +import { Observable } from "rxjs"; describe("Thing", function () { - describe("literals", () => { - let internalStore: IndexedFormula; - const mockSession = {} as unknown as PodOsSession; - let store: Store; + let internalStore: IndexedFormula; + const mockSession = {} as unknown as PodOsSession; + let store: Store; - beforeEach(() => { - internalStore = graph(); - store = new Store(mockSession, undefined, undefined, internalStore); - }); + beforeEach(() => { + internalStore = graph(); + store = new Store(mockSession, undefined, undefined, internalStore); + }); + describe("literals", () => { it("are empty, if store is empty", () => { const it = new Thing( "https://jane.doe.example/container/file.ttl#fragment", @@ -162,4 +163,195 @@ describe("Thing", function () { ]); }); }); + + describe("observeLiterals", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + let uri: string, + subscriber: Mock, + thing: Thing, + literalsSpy: Mock, + observable: Observable; + + beforeEach(() => { + // Given a store with statements about a URI + uri = "https://jane.doe.example/container/file.ttl#fragment"; + internalStore.add( + sym(uri), + sym("http://vocab.test/first"), + "value 1-1", + sym(uri), + ); + internalStore.add( + sym(uri), + sym("http://vocab.test/second"), + "value 2-1", + sym(uri), + ); + internalStore.add( + sym(uri), + sym("http://vocab.test/second"), + "value 2-2", + sym(uri), + ); + internalStore.add( + sym(uri), + sym("http://vocab.test/third"), + "value 3-1", + sym(uri), + ); + + // and a Thing with a literals method + subscriber = vi.fn(); + thing = new Thing(uri, store); + literalsSpy = vi.spyOn(thing, "literals"); + + // and a subscription to changes in relations + observable = thing.observeLiterals(); + observable.subscribe(subscriber); + }); + + it("pushes existing literals immediately", () => { + expect(subscriber).toHaveBeenCalledTimes(1); + expect(literalsSpy).toHaveBeenCalledTimes(1); + expect(subscriber.mock.calls).toEqual([ + [ + [ + { + predicate: "http://vocab.test/first", + label: "first", + values: ["value 1-1"], + }, + { + predicate: "http://vocab.test/second", + label: "second", + values: ["value 2-1", "value 2-2"], + }, + { + predicate: "http://vocab.test/third", + label: "third", + values: ["value 3-1"], + }, + ], + ], + ]); + }); + + it("updates after removals", () => { + internalStore.removeStatement( + quad( + sym(uri), + sym("http://vocab.test/first"), + literal("value 1-1"), + sym(uri), + ), + ); + vi.advanceTimersByTime(250); + expect(subscriber).toHaveBeenCalledTimes(2); + expect(literalsSpy).toHaveBeenCalledTimes(2); + expect(subscriber.mock.lastCall).toEqual([ + [ + { + predicate: "http://vocab.test/second", + label: "second", + values: ["value 2-1", "value 2-2"], + }, + { + predicate: "http://vocab.test/third", + label: "third", + values: ["value 3-1"], + }, + ], + ]); + }); + + it("updates after additions", () => { + internalStore.add(sym(uri), sym("http://vocab.test/first"), "value 1-2"); + vi.advanceTimersByTime(250); + expect(subscriber).toHaveBeenCalledTimes(2); + expect(literalsSpy).toHaveBeenCalledTimes(2); + expect(subscriber.mock.lastCall).toEqual([ + [ + { + predicate: "http://vocab.test/first", + label: "first", + values: ["value 1-1", "value 1-2"], + }, + { + predicate: "http://vocab.test/second", + label: "second", + values: ["value 2-1", "value 2-2"], + }, + { + predicate: "http://vocab.test/third", + label: "third", + values: ["value 3-1"], + }, + ], + ]); + }); + + it("pushes update in groups", () => { + internalStore.removeStatement( + quad( + sym(uri), + sym("http://vocab.test/first"), + literal("value 1-1"), + sym(uri), + ), + ); + internalStore.add(sym(uri), sym("http://vocab.test/first"), "value 1-2"); + internalStore.add(sym(uri), sym("http://vocab.test/third"), "value 3-2"); + vi.advanceTimersByTime(250); + expect(subscriber).toHaveBeenCalledTimes(2); + expect(literalsSpy).toHaveBeenCalledTimes(2); + expect(subscriber.mock.lastCall).toEqual([ + [ + { + predicate: "http://vocab.test/second", + label: "second", + values: ["value 2-1", "value 2-2"], + }, + { + predicate: "http://vocab.test/third", + label: "third", + values: ["value 3-1", "value 3-2"], + }, + { + predicate: "http://vocab.test/first", + label: "first", + values: ["value 1-2"], + }, + ], + ]); + }); + + it("ignores irrelevant statements about other resources", () => { + internalStore.add( + sym("https://other.uri/"), + sym("http://vocab.test/first"), + "value", + ); + vi.advanceTimersByTime(250); + expect(subscriber).toHaveBeenCalledTimes(1); + expect(literalsSpy).toHaveBeenCalledTimes(1); + }); + + it("does not push if literals have not changed", () => { + internalStore.add( + sym(uri), + sym("http://vocab.test/first"), + sym("http://not.a.literal/"), + ); + vi.advanceTimersByTime(250); + expect(literalsSpy).toHaveBeenCalledTimes(2); + expect(subscriber).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/core/src/thing/Thing.ts b/core/src/thing/Thing.ts index 56f7bdc5..4bcd0034 100644 --- a/core/src/thing/Thing.ts +++ b/core/src/thing/Thing.ts @@ -5,7 +5,7 @@ import { accumulateValues } from "./accumulateValues"; import { isRdfType } from "./isRdfType"; import { labelForType } from "./labelForType"; import { labelFromUri } from "./labelFromUri"; -import { Store } from "../Store"; +import { Store, type Quad } from "../Store"; import { debounceTime, distinctUntilChanged, @@ -127,6 +127,23 @@ export class Thing { })); } + /** + * Observe changes in literal values linked to this thing + */ + observeLiterals(): Observable { + return this.observeSubjectChanges({ + observes: () => this.literals(), + compare: (prev, curr) => + prev.length === curr.length && + prev.every( + (rel, i) => + rel.predicate === curr[i].predicate && + rel.values.length === curr[i].values.length && + rel.values.every((val, j) => val === curr[i].values[j]), + ), + }); + } + /** * Returns all the unique links from this thing to other resources. This only includes named nodes and excludes rdf:type relations. */ @@ -151,22 +168,76 @@ export class Thing { * Observe changes in links from this thing to other resources */ observeRelations(predicate?: string): Observable { + return this.observeSubjectChanges({ + observes: () => this.relations(predicate), + compare: (prev, curr) => + prev.length === curr.length && + prev.every( + (rel, i) => + rel.predicate === curr[i].predicate && + rel.uris.length === curr[i].uris.length, + ), + }); + } + + /** + * Generic observable creator that watches the store for changes + * that have this thing as a subject + * @param args.observedValue Function that returns the current value to observe + * @param args.compare Function that compares previous and current values for distinctUntilChanged + */ + private observeSubjectChanges(args: { + observes: () => T; + compare: (prev: T, curr: T) => boolean; + }): Observable { + const { observes, compare } = args; + return this.observeChanges({ + filterFn: (quad) => quad.subject.value === this.uri, + observes, + compare, + }); + } + + /** + * Generic observable creator that watches the store for changes + * that have this thing as an object + * @param args.observedValue Function that returns the current value to observe + * @param args.compare Function that compares previous and current values for distinctUntilChanged + */ + private observeObjectChanges(args: { + observes: () => T; + compare: (prev: T, curr: T) => boolean; + }): Observable { + const { observes, compare } = args; + return this.observeChanges({ + filterFn: (quad) => quad.object.value === this.uri, + observes, + compare, + }); + } + + /** + * Generic observable creator that watches the store for changes + * that match the given filter function before comparing values of this thing for possible changes. + * The observable only emits a new value if relevant quads are added or removed, and in consequence + * the observed value changes according to the compare function + * + * @param args.filterFn Filter for relevant quads + * @param args.observedValue Function that returns the value to observe + * @param args.compare Function that compares previous and current values + */ + private observeChanges(args: { + filterFn: (quad: Quad) => boolean; + observes: () => T; + compare: (prev: T, curr: T) => boolean; + }): Observable { + const { observes, compare, filterFn } = args; return merge(this.store.additions$, this.store.removals$).pipe( - // Note: we assume that cost of filtering by the optional predicate is not worthwhile - filter((quad) => quad.subject.value === this.uri), + filter(filterFn), debounceTime(250), - map(() => this.relations(predicate)), - startWith(this.relations(predicate)), - // Note: label is constructed from predicate and is therefore irrelevant to the comparison - distinctUntilChanged( - (prev, curr) => - prev.length === curr.length && - prev.every( - (rel, i) => - rel.predicate === curr[i].predicate && - rel.uris.length === curr[i].uris.length, - ), - ), + map(observes), + startWith(observes()), + distinctUntilChanged(compare), ); } @@ -193,23 +264,16 @@ export class Thing { * Observe changes in links from other resources to this thing */ observeReverseRelations(predicate?: string): Observable { - return merge(this.store.additions$, this.store.removals$).pipe( - // Note: we assume that cost of filtering by the optional predicate is not worthwhile - filter((quad) => quad.object.value === this.uri), - debounceTime(250), - map(() => this.reverseRelations(predicate)), - startWith(this.reverseRelations(predicate)), - // Note: label is constructed from predicate and is therefore irrelevant to the comparison - distinctUntilChanged( - (prev, curr) => - prev.length === curr.length && - prev.every( - (rel, i) => - rel.predicate === curr[i].predicate && - rel.uris.length === curr[i].uris.length, - ), - ), - ); + return this.observeObjectChanges({ + observes: () => this.reverseRelations(predicate), + compare: (prev, curr) => + prev.length === curr.length && + prev.every( + (rel, i) => + rel.predicate === curr[i].predicate && + rel.uris.length === curr[i].uris.length, + ), + }); } /** diff --git a/docs/elements/components/pos-switch/pos-case/readme.md b/docs/elements/components/pos-switch/pos-case/readme.md index 96b7f34e..9398e63b 100644 --- a/docs/elements/components/pos-switch/pos-case/readme.md +++ b/docs/elements/components/pos-switch/pos-case/readme.md @@ -10,13 +10,37 @@ See [storybook](https://pod-os.github.io/PodOS/storybook/?path=/story/basics--po ## Properties -| Property | Attribute | Description | Type | Default | -| ------------ | ------------- | ----------------------------------------------------------------------------- | --------- | ----------- | -| `else` | `else` | The test only evaluates to true if tests for preceding cases have failed | `boolean` | `undefined` | -| `ifProperty` | `if-property` | Test if the resource has the specified property (forward link) | `string` | `undefined` | -| `ifRev` | `if-rev` | Test if the resource is the subject of the specified property (backward link) | `string` | `undefined` | -| `ifTypeof` | `if-typeof` | Test if the resource is of the specified type | `string` | `undefined` | -| `not` | `not` | Negates the result of the test | `boolean` | `undefined` | +| Property | Attribute | Description | Type | Default | +| --------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ----------- | +| `active` | `active` | Whether this case is active, i.e. shown. Usually this is controlled by the surrounding pos-switch, and there is no need to set this manually. | `boolean` | `false` | +| `else` | `else` | The test only evaluates to true if tests for preceding cases have failed | `boolean` | `undefined` | +| `everyValueEq` | `every-value-eq` | Test if every value linked by if-property or if-rev is equal to the attribute | `string` | `undefined` | +| `everyValueGt` | `every-value-gt` | Test if every value linked by if-property or if-rev is strictly greater than the attribute. First a number comparison is attempted, then string. | `string` | `undefined` | +| `everyValueGte` | `every-value-gte` | Test if every value linked by if-property or if-rev is greater than or equal to the attribute. First a number comparison is attempted, then string. | `string` | `undefined` | +| `everyValueLt` | `every-value-lt` | Test if every value linked by if-property or if-rev is strictly less than the attribute. First a number comparison is attempted, then string. | `string` | `undefined` | +| `everyValueLte` | `every-value-lte` | Test if every value linked by if-property or if-rev is less than or equal to the attribute. First a number comparison is attempted, then string. | `string` | `undefined` | +| `ifProperty` | `if-property` | Test if the resource has the specified property (forward link) | `string` | `undefined` | +| `ifRev` | `if-rev` | Test if the resource is the subject of the specified property (backward link) | `string` | `undefined` | +| `ifTypeof` | `if-typeof` | Test if the resource is of the specified type | `string` | `undefined` | +| `not` | `not` | Negates the result of the test | `boolean` | `undefined` | +| `someValueEq` | `some-value-eq` | Test if some value linked by if-property or if-rev is equal to the attribute | `string` | `undefined` | +| `someValueGt` | `some-value-gt` | Test if some value linked by if-property or if-rev is strictly greater than the attribute. First a number comparison is attempted, then string. | `string` | `undefined` | +| `someValueGte` | `some-value-gte` | Test if some value linked by if-property or if-rev is greater than or equal to the attribute. First a number comparison is attempted, then string. | `string` | `undefined` | +| `someValueLt` | `some-value-lt` | Test if some value linked by if-property or if-rev is strictly less than the attribute. First a number comparison is attempted, then string. | `string` | `undefined` | +| `someValueLte` | `some-value-lte` | Test if some value linked by if-property or if-rev is less than or equal to the attribute. First a number comparison is attempted, then string. | `string` | `undefined` | + + +## Methods + +### `getRule() => Promise` + +Returns the rule definition for this case. The rule determines if the element's content gets rendered. + +#### Returns + +Type: `Promise` + + ---------------------------------------------- diff --git a/elements/.gitignore b/elements/.gitignore index c3ea58a6..fb22fbbc 100644 --- a/elements/.gitignore +++ b/elements/.gitignore @@ -24,3 +24,4 @@ $RECYCLE.BIN/ Thumbs.db UserInterfaceState.xcuserstate .env +coverage/ diff --git a/elements/CHANGELOG.md b/elements/CHANGELOG.md index 4631ef0a..c30f2f8f 100644 --- a/elements/CHANGELOG.md +++ b/elements/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.43.0 + +### Changed + +- [pos-switch](https://pod-os.org/reference/elements/components/pos-switch/) and [pos-case](https://pod-os.org/reference/elements/components/pos-case/): Added attributes `(some|every)-value-(eq|gt|gte|lt|lte)` to test if some/every value linked by `if-property` or `if-rev` is equal to/greater than/less than the attribute. + ## 0.42.0 ### Changed diff --git a/elements/package.json b/elements/package.json index 6a306924..d0f86302 100644 --- a/elements/package.json +++ b/elements/package.json @@ -52,10 +52,11 @@ }, "devDependencies": { "@happy-dom/jest-environment": "^20.9.0", - "@stencil/vitest": "^1.12.1", + "@stencil/vitest": "^1.13.3", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@types/jest": "^30.0.0", + "@vitest/coverage-v8": "^4.1.10", "happy-dom": "^20.10.2", "jest": "^30.3.0", "jest-cli": "^30.3.0", diff --git a/elements/src/components.d.ts b/elements/src/components.d.ts index 53c8e770..c9de9794 100644 --- a/elements/src/components.d.ts +++ b/elements/src/components.d.ts @@ -6,9 +6,11 @@ */ import { HTMLStencilElement, JSXBase } from "@stencil/core/internal"; import { Attachment, HttpProblem, LdpContainer, Literal, NetworkProblem, PodOS, Problem, Relation, SolidFile, Thing } from "@pod-os/core"; +import { SwitchCaseRule } from "./components/pos-switch/rules"; import { ToolConfig } from "./components/pos-type-router/selectToolsForTypes"; import { ResultAsync } from "neverthrow"; export { Attachment, HttpProblem, LdpContainer, Literal, NetworkProblem, PodOS, Problem, Relation, SolidFile, Thing } from "@pod-os/core"; +export { SwitchCaseRule } from "./components/pos-switch/rules"; export { ToolConfig } from "./components/pos-type-router/selectToolsForTypes"; export { ResultAsync } from "neverthrow"; export namespace Components { @@ -68,10 +70,39 @@ export namespace Components { * See [storybook](https://pod-os.github.io/PodOS/storybook/?path=/story/basics--pos-switch) for an example. */ interface PosCase { + /** + * Whether this case is active, i.e. shown. Usually this is controlled by the surrounding pos-switch, and there is no need to set this manually. + * @default false + */ + "active": boolean; /** * The test only evaluates to true if tests for preceding cases have failed */ "else"?: boolean; + /** + * Test if every value linked by if-property or if-rev is equal to the attribute + */ + "everyValueEq"?: string; + /** + * Test if every value linked by if-property or if-rev is strictly greater than the attribute. First a number comparison is attempted, then string. + */ + "everyValueGt"?: string; + /** + * Test if every value linked by if-property or if-rev is greater than or equal to the attribute. First a number comparison is attempted, then string. + */ + "everyValueGte"?: string; + /** + * Test if every value linked by if-property or if-rev is strictly less than the attribute. First a number comparison is attempted, then string. + */ + "everyValueLt"?: string; + /** + * Test if every value linked by if-property or if-rev is less than or equal to the attribute. First a number comparison is attempted, then string. + */ + "everyValueLte"?: string; + /** + * Returns the rule definition for this case. The rule determines if the element's content gets rendered. + */ + "getRule": () => Promise; /** * Test if the resource has the specified property (forward link) */ @@ -88,6 +119,26 @@ export namespace Components { * Negates the result of the test */ "not"?: boolean; + /** + * Test if some value linked by if-property or if-rev is equal to the attribute + */ + "someValueEq"?: string; + /** + * Test if some value linked by if-property or if-rev is strictly greater than the attribute. First a number comparison is attempted, then string. + */ + "someValueGt"?: string; + /** + * Test if some value linked by if-property or if-rev is greater than or equal to the attribute. First a number comparison is attempted, then string. + */ + "someValueGte"?: string; + /** + * Test if some value linked by if-property or if-rev is strictly less than the attribute. First a number comparison is attempted, then string. + */ + "someValueLt"?: string; + /** + * Test if some value linked by if-property or if-rev is less than or equal to the attribute. First a number comparison is attempted, then string. + */ + "someValueLte"?: string; } interface PosContainerContents { } @@ -1512,10 +1563,35 @@ declare namespace LocalJSX { * See [storybook](https://pod-os.github.io/PodOS/storybook/?path=/story/basics--pos-switch) for an example. */ interface PosCase { + /** + * Whether this case is active, i.e. shown. Usually this is controlled by the surrounding pos-switch, and there is no need to set this manually. + * @default false + */ + "active"?: boolean; /** * The test only evaluates to true if tests for preceding cases have failed */ "else"?: boolean; + /** + * Test if every value linked by if-property or if-rev is equal to the attribute + */ + "everyValueEq"?: string; + /** + * Test if every value linked by if-property or if-rev is strictly greater than the attribute. First a number comparison is attempted, then string. + */ + "everyValueGt"?: string; + /** + * Test if every value linked by if-property or if-rev is greater than or equal to the attribute. First a number comparison is attempted, then string. + */ + "everyValueGte"?: string; + /** + * Test if every value linked by if-property or if-rev is strictly less than the attribute. First a number comparison is attempted, then string. + */ + "everyValueLt"?: string; + /** + * Test if every value linked by if-property or if-rev is less than or equal to the attribute. First a number comparison is attempted, then string. + */ + "everyValueLte"?: string; /** * Test if the resource has the specified property (forward link) */ @@ -1532,6 +1608,26 @@ declare namespace LocalJSX { * Negates the result of the test */ "not"?: boolean; + /** + * Test if some value linked by if-property or if-rev is equal to the attribute + */ + "someValueEq"?: string; + /** + * Test if some value linked by if-property or if-rev is strictly greater than the attribute. First a number comparison is attempted, then string. + */ + "someValueGt"?: string; + /** + * Test if some value linked by if-property or if-rev is greater than or equal to the attribute. First a number comparison is attempted, then string. + */ + "someValueGte"?: string; + /** + * Test if some value linked by if-property or if-rev is strictly less than the attribute. First a number comparison is attempted, then string. + */ + "someValueLt"?: string; + /** + * Test if some value linked by if-property or if-rev is less than or equal to the attribute. First a number comparison is attempted, then string. + */ + "someValueLte"?: string; } interface PosContainerContents { "onPod-os:resource"?: (event: PosContainerContentsCustomEvent) => void; @@ -1897,6 +1993,17 @@ declare namespace LocalJSX { "ifRev": string; "not": boolean; "else": boolean; + "someValueEq": string; + "everyValueEq": string; + "someValueGt": string; + "everyValueGt": string; + "someValueGte": string; + "everyValueGte": string; + "someValueLt": string; + "everyValueLt": string; + "someValueLte": string; + "everyValueLte": string; + "active": boolean; } interface PosCreateNewContainerItemAttributes { "type": 'file' | 'folder'; diff --git a/elements/src/components/pos-switch/pos-case/pos-case.css b/elements/src/components/pos-switch/pos-case/pos-case.css new file mode 100644 index 00000000..bd5758ad --- /dev/null +++ b/elements/src/components/pos-switch/pos-case/pos-case.css @@ -0,0 +1,6 @@ +.error { + background-color: var(--pos-error-background-color); + border: 1px solid var(--pos-error-border-color); + padding: var(--size-3); + border-radius: var(--radius-md); +} diff --git a/elements/src/components/pos-switch/pos-case/pos-case.tsx b/elements/src/components/pos-switch/pos-case/pos-case.tsx index 83af2b0e..8024255d 100644 --- a/elements/src/components/pos-switch/pos-case/pos-case.tsx +++ b/elements/src/components/pos-switch/pos-case/pos-case.tsx @@ -1,4 +1,5 @@ -import { Component, Element, Prop, State } from '@stencil/core'; +import { Component, Element, h, Host, Method, Prop, State } from '@stencil/core'; +import { Comparison, ELSE_RULE, NEVER_RULE, Operator, Semantic, SwitchCaseRule } from '../rules'; /** * Defines a template to use if the specified condition is met - to be used with [pos-switch](https://pod-os.org/reference/elements/components/pos-switch/). @@ -7,10 +8,12 @@ import { Component, Element, Prop, State } from '@stencil/core'; @Component({ tag: 'pos-case', shadow: false, + styleUrl: 'pos-case.css', }) export class PosCase { - @Element() host: HTMLElement; - @State() error: string = null; + @Element() host!: HTMLElement; + @State() error: string | null = null; + private templateHTML: string | null = null; /** * Test if the resource is of the specified type @@ -32,16 +35,148 @@ export class PosCase { * The test only evaluates to true if tests for preceding cases have failed */ @Prop() else?: boolean; + /** + * Test if some value linked by if-property or if-rev is equal to the attribute + */ + @Prop() someValueEq?: string; + /** + * Test if every value linked by if-property or if-rev is equal to the attribute + */ + @Prop() everyValueEq?: string; + /** + * Test if some value linked by if-property or if-rev is strictly greater than the attribute. First a number comparison is attempted, then string. + */ + @Prop() someValueGt?: string; + /** + * Test if every value linked by if-property or if-rev is strictly greater than the attribute. First a number comparison is attempted, then string. + */ + @Prop() everyValueGt?: string; + /** + * Test if some value linked by if-property or if-rev is greater than or equal to the attribute. First a number comparison is attempted, then string. + */ + @Prop() someValueGte?: string; + /** + * Test if every value linked by if-property or if-rev is greater than or equal to the attribute. First a number comparison is attempted, then string. + */ + @Prop() everyValueGte?: string; + /** + * Test if some value linked by if-property or if-rev is strictly less than the attribute. First a number comparison is attempted, then string. + */ + @Prop() someValueLt?: string; + /** + * Test if every value linked by if-property or if-rev is strictly less than the attribute. First a number comparison is attempted, then string. + */ + @Prop() everyValueLt?: string; + /** + * Test if some value linked by if-property or if-rev is less than or equal to the attribute. First a number comparison is attempted, then string. + */ + @Prop() someValueLte?: string; + /** + * Test if every value linked by if-property or if-rev is less than or equal to the attribute. First a number comparison is attempted, then string. + */ + @Prop() everyValueLte?: string; + + componentWillRender() { + const countRules = [this.ifTypeof, this.ifProperty, this.ifRev].filter(it => it !== undefined).length; + if (countRules > 1) { + this.error = 'At most 1 "if-" must be present'; + } + const countComparisons = [ + this.someValueLt, + this.everyValueLt, + this.someValueLte, + this.everyValueLte, + this.someValueEq, + this.everyValueEq, + this.someValueGte, + this.everyValueGte, + this.someValueGt, + this.everyValueGt, + ].filter(it => it !== undefined).length; + if (countComparisons > 1) { + this.error = 'At most 1 comparison ("every-" / "some-") must be present'; + } + } + + /** + * Whether this case is active, i.e. shown. Usually this is controlled by the surrounding pos-switch, and there is no need to set this manually. + */ + @Prop() active = false; + + /** + * Returns the rule definition for this case. The rule determines if the element's content gets rendered. + */ + @Method() + async getRule(): Promise { + const modifiers = { + not: this.not, + else: this.else, + }; + const comparison = this.getComparison(); + if (this.ifTypeof) { + return { + type: 'if-typeof', + value: this.ifTypeof, + ...modifiers, + }; + } + if (this.ifProperty) { + return { + type: 'if-property', + value: this.ifProperty, + ...modifiers, + comparison, + }; + } + if (this.ifRev) { + return { + type: 'if-rev', + value: this.ifRev, + ...modifiers, + comparison, + }; + } + return this.else ? ELSE_RULE : NEVER_RULE; + } + + private getComparison(): Comparison | undefined { + return ( + this.comparisonFor(this.someValueEq, 'some', 'eq') ?? + this.comparisonFor(this.everyValueEq, 'every', 'eq') ?? + this.comparisonFor(this.someValueGt, 'some', 'gt') ?? + this.comparisonFor(this.everyValueGt, 'every', 'gt') ?? + this.comparisonFor(this.someValueGte, 'some', 'gte') ?? + this.comparisonFor(this.everyValueGte, 'every', 'gte') ?? + this.comparisonFor(this.someValueLt, 'some', 'lt') ?? + this.comparisonFor(this.everyValueLt, 'every', 'lt') ?? + this.comparisonFor(this.someValueLte, 'some', 'lte') ?? + this.comparisonFor(this.everyValueLte, 'every', 'lte') + ); + } + + private comparisonFor(target: string | undefined, semantic: Semantic, operator: Operator): Comparison | undefined { + if (target) { + return { semantic, operator, target }; + } + } componentWillLoad() { const templateElement = this.host.querySelector('template'); if (templateElement == null) { this.error = 'No template element found'; + } else { + this.templateHTML = templateElement.innerHTML; } } render() { - if (this.error) return this.error; - return null; + if (this.error) return
{this.error}
; + + if (!this.active) { + // because we set innerHTML for active case elements, we need to + // unset it here to not leave state in the dom + return ; + } + return ; } } diff --git a/elements/src/components/pos-switch/pos-case/pos-case.vspec.tsx b/elements/src/components/pos-switch/pos-case/pos-case.vspec.tsx index d0cb9c6a..16f612a2 100644 --- a/elements/src/components/pos-switch/pos-case/pos-case.vspec.tsx +++ b/elements/src/components/pos-switch/pos-case/pos-case.vspec.tsx @@ -1,9 +1,21 @@ import { describe, expect, it, render, h } from '@stencil/vitest'; import './pos-case'; +import { ELSE_RULE } from '../rules'; describe('pos-case', () => { - it('contains only templates initially', async () => { + it('renders template when active', async () => { + const page = await render( + + + , + ); + expect(page.root.innerHTML).toEqual('
Test
'); + }); + + it('renders nothing when inactive', async () => { const page = await render( , ); - expect(page.root).toEqualHtml(` - - - - `); + expect(page.root.innerHTML).toEqual(''); + }); + + it('renders template after being activated', async () => { + const page = await render( + + + , + ); + expect(page.root.innerHTML).toEqual(''); + page.root.setAttribute('active', ''); + await page.waitForChanges(); + expect(page.root.innerHTML).toEqual('
Test
'); }); it('renders empty by default', async () => { @@ -26,14 +48,221 @@ describe('pos-case', () => { , ); - const el: HTMLElement = page.root as unknown as HTMLElement; - expect(el.textContent).toEqualHtml(''); + expect(page.root.innerHTML).toEqualHtml(''); }); - it('displays error on missing template', async () => { - const page = await render(); - const el: HTMLElement = page.root as unknown as HTMLElement; + describe('rule configuration errors', () => { + it.each([ + [ + `if-typeof="http://schema.org/Recipe" + if-rev="http://schema.org/image"`, + ], + [ + `if-typeof="http://schema.org/Recipe" + if-property="http://schema.org/image"`, + ], + [ + `if-rev="http://schema.org/Recipe" + if-property="http://schema.org/image"`, + ], + , + [ + `if-typeof="http://schema.org/Recipe" + if-rev="http://schema.org/Recipe" + if-property="http://schema.org/image"`, + ], + ])('shows error if more than one rule is present', async rules => { + const page = await render( + ` + + `, + ); + expect(page.root).toEqualText('At most 1 "if-" must be present'); + }); + + it.each([ + // we don't check every permutation here, but at least make sure every + // property is tested once + [ + `some-value-eq="Alice" + every-value-eq="Alice"`, + ], + [ + `some-value-gt="Alice" + some-value-lt="Zoe"`, + ], + [ + `every-value-gt="Alice" + every-value-lt="Zoe"`, + ], + [ + `every-value-lte="Alice" + every-value-gte="Zoe"`, + ], + [ + `some-value-lte="Alice" + some-value-gte="Zoe"`, + ], + ])('shows error if more than one rule is present', async comparisons => { + const page = await render( + ` + + `, + ); + expect(page.root).toEqualText('At most 1 comparison ("every-" / "some-") must be present'); + }); + }); + + describe('rules', () => { + it('provides the if-typeof rule', async () => { + const page = await render( + + + , + ); + const rule = await page.root.getRule(); + expect(rule).toEqual({ + type: 'if-typeof', + value: 'http://schema.org/Recipe', + }); + }); + + it.each(['if-typeof', 'if-rev', 'if-property'])('can negate %s with not', async ruleType => { + const page = await render( + ` + + `, + ); + const rule = await page.root.getRule(); + expect(rule).toEqual({ + type: ruleType, + value: 'https://vocab.test#something', + not: true, + }); + }); + + it.each(['if-typeof', 'if-property', 'if-rev'])('provides else %s', async ruleType => { + const page = await render( + ` + + `, + ); + const rule = await page.root.getRule(); + expect(rule).toEqual({ + type: ruleType, + value: 'https://vocab.test#something', + else: true, + }); + }); + + it('provides the if-property rule', async () => { + const page = await render( + + + , + ); + const rule = await page.root.getRule(); + expect(rule).toEqual({ + type: 'if-property', + value: 'http://schema.org/image', + }); + }); + + it.each( + ['if-property', 'if-rev'].flatMap(ruleName => + ['some', 'every'].flatMap(semantic => + ['eq', 'gt', 'gte', 'lt', 'lte'].flatMap(operator => [ + { + appliedProps: { + ruleName, + comparisonName: `${semantic}-value-${operator}`, + }, + expectedComparison: { + semantic, + operator: operator, + }, + }, + ]), + ), + ), + )( + 'provides the $appliedProps.ruleName rule with $appliedProps.comparisonName comparison', + async ({ appliedProps, expectedComparison }) => { + const ruleName = appliedProps.ruleName; + const comparisonName = appliedProps.comparisonName; + const foo = `${ruleName}="http://schema.org/name" ${comparisonName}="Alice"`; + const page = await render( + ` + + `, + ); + const rule = await page.root.getRule(); + expect(rule).toEqual({ + type: ruleName, + value: 'http://schema.org/name', + comparison: { + ...expectedComparison, + target: 'Alice', + }, + }); + }, + ); + + it('provides the if-rev rule', async () => { + const page = await render( + + + , + ); + const rule = await page.root.getRule(); + expect(rule).toEqual({ + type: 'if-rev', + value: 'http://schema.org/image', + }); + }); + + it('provides the else if-rev rule', async () => { + const page = await render( + + + , + ); + const rule = await page.root.getRule(); + expect(rule).toEqual({ + type: 'if-rev', + value: 'http://schema.org/image', + else: true, + }); + }); - expect(el.textContent).toEqual('No template element found'); + it('provides else rule', async () => { + const page = await render( + + + , + ); + const rule = await page.root.getRule(); + expect(rule).toEqual(ELSE_RULE); + }); }); }); diff --git a/elements/src/components/pos-switch/pos-switch.integration.spec.tsx b/elements/src/components/pos-switch/pos-switch.integration.spec.tsx deleted file mode 100644 index 41c4ecc7..00000000 --- a/elements/src/components/pos-switch/pos-switch.integration.spec.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { newSpecPage } from '@stencil/core/testing'; -import { mockPodOS } from '../../test/mockPodOS'; -import { PosApp } from '../pos-app/pos-app'; -import { PosCase } from './pos-case/pos-case'; -import { PosLabel } from '../pos-label/pos-label'; -import { PosPicture } from '../pos-picture/pos-picture'; -import { PosSwitch } from './pos-switch'; -import { PosResource } from '../pos-resource/pos-resource'; -import { when } from 'jest-when'; -import { RdfType, Relation, Thing } from '@pod-os/core'; -import { ReplaySubject, Subject } from 'rxjs'; - -describe('pos-switch', () => { - it('renders template based on properties of resource, reactively', async () => { - const os = mockPodOS(); - const observedLabel$ = new ReplaySubject(); - observedLabel$.next('Recipe 1'); - const observedTypes$ = new Subject(); - const observedRelations$ = new Subject(); - const observedReverseRelations$ = new Subject(); - when(os.store.get) - .calledWith('https://resource.test') - .mockReturnValue({ - uri: 'https://resource.test', - // Label is used by pos-picture, and observeLabel by pos-label - label: () => 'Recipe 1', - observeLabel: () => observedLabel$, - observeTypes: () => observedTypes$, - observeRelations: () => observedRelations$, - observeReverseRelations: () => observedReverseRelations$, - picture: () => ({ - url: 'https://resource.test/recipe-photo.jpg', - }), - } as unknown as Thing); - const page = await newSpecPage({ - components: [PosApp, PosCase, PosLabel, PosPicture, PosSwitch, PosResource], - supportsShadowDom: false, - html: ` - - - - - - - - - - - - - - - - - - - - - - - - - - - `, - }); - expect((os.fetch as jest.Mock).mock.calls).toHaveLength(0); - expect(page.root?.innerText).toEqualText(''); - observedTypes$.next([ - { - label: 'Recipe', - uri: 'http://schema.org/Recipe', - }, - ]); - observedRelations$.next([ - { - predicate: 'http://schema.org/image', - label: 'image', - uris: ['https://resource.test/recipe-photo.jpg'], - }, - ]); - observedReverseRelations$.next([]); - await page.waitForChanges(); - const switchElement = page.root?.querySelector('pos-switch'); - expect(switchElement?.innerHTML).toEqualHtml(` - - - Recipe 1 - - - - - - `); - observedTypes$.next([ - { - label: 'Recipe', - uri: 'http://schema.org/Recipe', - }, - { - label: 'Thing', - uri: 'http://schema.org/Thing', - }, - ]); - observedReverseRelations$.next([ - { - predicate: 'http://schema.org/itemListElement', - label: 'itemListElement', - uris: ['https://pod.example/recipe-list'], - }, - ]); - await page.waitForChanges(); - expect(switchElement?.innerHTML).toEqualHtml(` -
Part of list
- - - Recipe 1 - - - - - -
Also a Thing
- `); - }); -}); diff --git a/elements/src/components/pos-switch/pos-switch.integration.vspec.tsx b/elements/src/components/pos-switch/pos-switch.integration.vspec.tsx new file mode 100644 index 00000000..76fe0df2 --- /dev/null +++ b/elements/src/components/pos-switch/pos-switch.integration.vspec.tsx @@ -0,0 +1,361 @@ +import { describe, expect, h, it, render } from '@stencil/vitest'; +import { server, turtleFile } from '../../test/msw'; +import { waitFor } from '@testing-library/dom'; +import { Store } from '@pod-os/core'; +import { http, HttpResponse } from 'msw'; + +describe('pos-switch', () => { + it('renders matching if-type and if-property cases', async () => { + // given a person with a name + server.use( + turtleFile( + 'https://janedoe.test/profile/card', + ` + <#me> a ; "Jane Doe" . + `, + ), + ); + + // when a pos-switch with cases for type Person and property name + const page = await render( + + + + + + + + + + + + + + + , + ); + await page.waitForChanges(); + + // then both cases are rendert, but the else case is hidden + expect(page.root).toEqualText('They are a personand they have a name!'); + const switchElement = page.root.querySelector('pos-switch'); + expect(switchElement).toEqualLightHtml(` + + + They are a person + + + and they have a name! + + + `); + }); + + it('renders the cases matching the property value in question', async () => { + // given a person named Jane Doe + server.use( + turtleFile( + 'https://janedoe.test/profile/card', + ` + <#me> a ; "Jane Doe" . + `, + ), + ); + // when a pos-switch has cases for name="Jane Doe" and name="Someone else" + const page = await render( + + + + + + + + + + + + + + + , + ); + + // then the case for Jane is shown + expect(page.root).toEqualText('Hi Jane, how are you?'); + }); + + it('when not is added, the cases NOT matching the property value in question are rendered', async () => { + // given a person named Bob + server.use( + turtleFile( + 'https://bob.test/profile/card', + ` + <#me> a ; "Bob" . + `, + ), + ); + // when a pos-switch has cases for anyone NOT named "Jane Doe" and a fallback else + const page = await render( + + + + + + + + + + + + , + ); + + // then the negated case is shown + expect(page.root).toEqualText('Hi Bob, did you see Jane?'); + }); + + it('numeric value comparison', async () => { + // given Bob is 40 years old + server.use( + turtleFile( + 'https://bob.test/profile/card', + ` + <#me> a ; + "Bob"; + 40 . + `, + ), + ); + // when a pos-switch has cases for different age ranges + const page = await render( + + + + + + + + + + + + + + + + + + + + + + + + , + ); + expect(page.root).toEqualText( + `Bob is a grown up.Even in the US.Bob is not older than 60.Bob is exactly 40 years old!`, + ); + }); + + it('re-evaluates cases after the resource changed', async () => { + // given a person named Alice + server.use( + turtleFile( + 'https://alice.test/profile/card', + ` + <#me> a ; "Alice" . + `, + ), + turtleFile( + 'https://bob.test/profile/card', + ` + <#me> a ; "Bob" . + `, + ), + turtleFile( + 'https://someone.test/profile/card', + ` + <#me> a . + `, + ), + ); + // and a pos-switch is set up to greet people depending on the available data + const page = await render( + + + + + + + + + + + + + + + + + + , + ); + + // then it shows the case for Alice + const resourceElement = page.root.querySelector('pos-resource')!; + expect(page.root).toEqualText('Welcome!What a pleasure to see you, Alice!'); + + // and it updates for a differently named person + resourceElement.setAttribute('uri', 'https://bob.test/profile/card#me'); + await page.waitForChanges(); + expect(page.root).toEqualText("Welcome!Nice to meet you! You're Bob, right?"); + + // and it updates for people without a name + resourceElement.setAttribute('uri', 'https://someone.test/profile/card#me'); + await page.waitForChanges(); + expect(page.root).toEqualText('Welcome!Nice to meet you! What is your name?'); + }); + + it('updates the cases after matching data was added', async () => { + let store: Store; + document.addEventListener('pod-os:loaded', evt => { + store = (evt as CustomEvent).detail.os.store; + }); + + // given a person without a name + server.use( + turtleFile( + 'https://janedoe.test/profile/card', + ` + <#me> a . + `, + ), + // but the profile document can be patched to add a name + http.patch('https://janedoe.test/profile/card', async () => { + return HttpResponse.text(); + }), + ); + // and a pos-switch is rendered asking users for a name or greeting them + const page = await render( + + + + + + + + + + + + , + ); + // and we got the PodOS store from the page + await waitFor(() => { + expect(store).toBeDefined(); + }); + + // and the "tell me your name" case is shown at first + expect(page.root).toEqualText('Please tell me your name'); + + // when the user adds their name + const resource = store!.get('https://janedoe.test/profile/card#me'); + await store!.addPropertyValue(resource!, 'http://schema.org/name', 'Jane Doe'); + + // then the page updates to greet them + await waitFor(() => { + expect(page.root).toEqualText('Hi, Jane Doe'); + }); + }); + + describe('misconfiguration is communicated to the user, such as', () => { + it('a pos-case without a template', async () => { + // given a person with a name + server.use( + turtleFile( + 'https://janedoe.test/profile/card', + ` + <#me> a ; "Jane Doe" . + `, + ), + ); + const page = await render( + + + + Light dom still shows up + + + , + ); + + // and the "tell me your name" case is shown at first + expect(page.root).toHaveTextContent('No template element found'); + expect(page.root).toHaveTextContent('Light dom still shows up'); + }); + + it('duplicate rules or comparisons', async () => { + // given a person with a name + server.use( + turtleFile( + 'https://janedoe.test/profile/card', + ` + <#me> a ; "Jane Doe" . + `, + ), + ); + const page = await render( + + + + + + + + + + + + , + ); + + // and the "tell me your name" case is shown at first + expect(page.root).toHaveTextContent('At most 1 "if-" must be present'); + expect(page.root).toHaveTextContent('At most 1 comparison ("every-" / "some-") must be present'); + const switchElement = page.root.querySelector('pos-switch')!; + expect(switchElement).toEqualLightHtml(` + + + +
+ At most 1 "if-" must be present +
+
+ + +
+ At most 1 comparison ("every-" / "some-") must be present +
+
+
+ `); + }); + }); +}); diff --git a/elements/src/components/pos-switch/pos-switch.spec.tsx b/elements/src/components/pos-switch/pos-switch.spec.tsx deleted file mode 100644 index 685e1d62..00000000 --- a/elements/src/components/pos-switch/pos-switch.spec.tsx +++ /dev/null @@ -1,354 +0,0 @@ -import { newSpecPage } from '@stencil/core/testing'; -import { PosSwitch } from './pos-switch'; -import { when } from 'jest-when'; -import { RdfType, Relation, Thing } from '@pod-os/core'; -import { Subject } from 'rxjs'; - -describe('pos-switch', () => { - it('contains only templates initially', async () => { - const page = await newSpecPage({ - components: [PosSwitch], - html: ` - - - - - `, - }); - expect(page.root).toEqualHtml(` - - - - - - `); - }); - - it('loads condition templates', async () => { - const page = await newSpecPage({ - components: [PosSwitch], - html: ` - - - - - - - - `, - }); - expect(page.rootInstance.caseElements.length).toEqual(2); - expect(page.rootInstance.caseElements[0].getAttribute('if-typeof')).toEqual('http://schema.org/Recipe'); - expect(page.rootInstance.caseElements[1].getAttribute('if-typeof')).toEqual('http://schema.org/Video'); - }); - - it('does not load nested templates', async () => { - const page = await newSpecPage({ - components: [PosSwitch], - html: ` - - - - - `, - }); - expect(page.rootInstance.caseElements.length).toEqual(1); - expect(page.rootInstance.caseElements[0].getAttribute('if-typeof')).toEqual('http://schema.org/Recipe'); - }); - - it('displays error on missing template', async () => { - const page = await newSpecPage({ - components: [PosSwitch], - html: ``, - }); - const el: HTMLElement = page.root as unknown as HTMLElement; - expect(el.textContent).toEqual('No pos-case elements found'); - }); - - it('renders matching condition templates, reactively', async () => { - const page = await newSpecPage({ - components: [PosSwitch], - html: ` - - - - - - - - - - - `, - }); - const observedTypes$ = new Subject(); - const thing = { - observeTypes: () => observedTypes$, - } as unknown as Thing; - page.rootInstance.receiveResource(thing); - observedTypes$.next([ - { - label: 'Recipe', - uri: 'http://schema.org/Recipe', - }, - ]); - await page.waitForChanges(); - expect(page.root?.innerHTML).toEqualHtml(` -
Recipe 1
-
Recipe 2
- `); - }); - - it('renders matching condition templates with if-else logic', async () => { - const page = await newSpecPage({ - components: [PosSwitch], - html: ` - - - - - - `, - }); - const observedTypes$ = new Subject(); - const thing = { - observeTypes: () => observedTypes$, - } as unknown as Thing; - page.rootInstance.receiveResource(thing); - observedTypes$.next([ - { - label: 'Recipe', - uri: 'http://schema.org/Recipe', - }, - ]); - await page.waitForChanges(); - expect(page.root?.innerHTML).toEqualHtml(` -
Recipe 1
- `); - }); - - it('renders final else condition if no other templates match', async () => { - const page = await newSpecPage({ - components: [PosSwitch], - html: ` - - - - `, - }); - const observedTypes$ = new Subject(); - const thing = { - observeTypes: () => observedTypes$, - } as unknown as Thing; - page.rootInstance.receiveResource(thing); - observedTypes$.next([ - { - label: 'Recipe', - uri: 'http://schema.org/Recipe', - }, - ]); - await page.waitForChanges(); - expect(page.root?.innerHTML).toEqualHtml(` -
No matches
- `); - }); - - it('renders matching condition with negation', async () => { - const page = await newSpecPage({ - components: [PosSwitch], - html: ` - - - `, - }); - const observedTypes$ = new Subject(); - const thing = { - observeTypes: () => observedTypes$, - } as unknown as Thing; - page.rootInstance.receiveResource(thing); - observedTypes$.next([ - { - label: 'Recipe', - uri: 'http://schema.org/Recipe', - }, - ]); - await page.waitForChanges(); - expect(page.root?.innerHTML).toEqualHtml(` -
Not a Video
- `); - }); - - it('renders templates if a forward link is present', async () => { - const page = await newSpecPage({ - components: [PosSwitch], - html: ` - - - - - - - - `, - }); - const observedRelations$ = new Subject(); - const thing = { - uri: 'https://pod.example/resource', - observeRelations: () => observedRelations$, - }; - page.rootInstance.receiveResource(thing); - observedRelations$.next([ - { predicate: 'https://schema.org/video', label: 'video', uris: ['https://video.test/video-1'] }, - ]); - await page.waitForChanges(); - expect(page.root?.innerHTML).toEqualHtml(` -
Resource has video
- `); - }); - - it('renders templates if a backward link is present', async () => { - const page = await newSpecPage({ - components: [PosSwitch], - html: ` - - - - - - - - `, - }); - const observedReverseRelations$ = new Subject(); - const thing = { - uri: 'https://pod.example/resource', - observeReverseRelations: () => observedReverseRelations$, - }; - page.rootInstance.receiveResource(thing); - observedReverseRelations$.next([ - { predicate: 'https://schema.org/video', label: 'video', uris: ['https://video.test/video-1'] }, - ]); - await page.waitForChanges(); - expect(page.root?.innerHTML).toEqualHtml(` -
Resource is video
- `); - }); - - it('supports mixed test conditions', async () => { - const page = await newSpecPage({ - components: [PosSwitch], - html: ` - - - - - - - - - - - `, - }); - const observedTypes$ = new Subject(); - const observedRelations$ = new Subject(); - const observedReverseRelations$ = new Subject(); - const thing = { - uri: 'https://pod.example/recipe1', - observeTypes: () => observedTypes$, - observeRelations: () => observedRelations$, - observeReverseRelations: () => observedReverseRelations$, - }; - page.rootInstance.receiveResource(thing); - observedTypes$.next([ - { - label: 'Recipe', - uri: 'http://schema.org/Recipe', - }, - ]); - observedRelations$.next([ - { predicate: 'http://schema.org/image', label: 'image', uris: ['https://resource.test/recipe-photo.jpg'] }, - ]); - observedReverseRelations$.next([ - { - predicate: 'http://schema.org/itemListElement', - label: 'itemListElement', - uris: ['https://pod.example/recipe-list'], - }, - ]); - await page.waitForChanges(); - expect(page.root?.innerHTML).toEqualHtml(` -
Recipe.
-
Image.
-
Recipe list.
- `); - }); - - it('resets and updates when resource is changed', async () => { - const page = await newSpecPage({ - components: [PosSwitch], - html: ` - - - - - - - - `, - }); - const observedTypes$ = new Subject(); - const thing = { - uri: 'https://pod.example/recipe1', - observeTypes: () => observedTypes$, - }; - page.rootInstance.receiveResource(thing); - observedTypes$.next([ - { - label: 'Recipe', - uri: 'http://schema.org/Recipe', - }, - ]); - await page.waitForChanges(); - expect(page.root?.innerText).toEqualText('Recipe.'); - // new thing - const observedTypes2$ = new Subject(); - const thing2 = { - uri: 'https://pod.example/video1', - observeTypes: () => observedTypes2$, - }; - page.rootInstance.receiveResource(thing2); - await page.waitForChanges(); - expect(page.root?.innerText).toEqualText(''); - observedTypes2$.next([ - { - label: 'Video', - uri: 'http://schema.org/Video', - }, - ]); - await page.waitForChanges(); - expect(page.root?.innerText).toEqualText('Video.'); - }); -}); diff --git a/elements/src/components/pos-switch/pos-switch.tsx b/elements/src/components/pos-switch/pos-switch.tsx index 79f7597c..8c3e242e 100644 --- a/elements/src/components/pos-switch/pos-switch.tsx +++ b/elements/src/components/pos-switch/pos-switch.tsx @@ -1,7 +1,13 @@ -import { RdfType, Relation, Thing } from '@pod-os/core'; -import { Component, Element, Event, h, Host, State } from '@stencil/core'; +import { Thing } from '@pod-os/core'; +import { Component, Element, Event, h, State } from '@stencil/core'; import { ResourceAware, ResourceEventEmitter, subscribeResource } from '../events/ResourceAware'; -import { combineLatest, firstValueFrom, Observable, Subject, takeUntil } from 'rxjs'; +import { combineLatest, map, of, Subject, takeUntil } from 'rxjs'; +import { findMatchingRules, RuleContext, SwitchCaseRule } from './rules'; + +interface CaseWithRule { + caseElement: HTMLPosCaseElement; + rule: SwitchCaseRule; +} /** * Selects a child template to render based on properties of the subject resource, usually defined by an ancestor `pos-resource` element. @@ -10,78 +16,75 @@ import { combineLatest, firstValueFrom, Observable, Subject, takeUntil } from 'r */ @Component({ tag: 'pos-switch', - shadow: false, + shadow: true, }) export class PosSwitch implements ResourceAware { - @Element() host: HTMLElement; - @State() error: string = null; - @State() resource: Thing; - @State() caseElements: HTMLPosCaseElement[]; - @State() types: RdfType[]; - @State() relations: Relation[]; - @State() reverseRelations: Relation[]; + @Element() host!: HTMLElement; + @State() error: string | null = null; + @State() resource?: Thing; + @State() cases: CaseWithRule[] = []; private readonly disconnected$ = new Subject(); @Event({ eventName: 'pod-os:resource' }) - subscribeResource: ResourceEventEmitter; + subscribeResource!: ResourceEventEmitter; componentWillLoad() { - const caseElements = this.host.querySelectorAll('pos-case'); + const caseElements = Array.from(this.host.querySelectorAll('pos-case')); if (caseElements.length == 0) { this.error = 'No pos-case elements found'; - } else { - this.caseElements = Array.from(caseElements); - subscribeResource(this); - } - } - - test(caseElement): boolean { - let state = null; - if (caseElement.getAttribute('if-typeof') !== null) { - state = this.types.map(x => x.uri).includes(caseElement.getAttribute('if-typeof')); + return; } - if (caseElement.getAttribute('if-property') !== null) { - state = this.relations.map(x => x.predicate).includes(caseElement.getAttribute('if-property')); - } - if (caseElement.getAttribute('if-rev') !== null) { - state = this.reverseRelations.map(x => x.predicate).includes(caseElement.getAttribute('if-rev')); - } - if (caseElement.getAttribute('not') != null) { - state = !state; - } - return state; + // Do NOT await: awaiting a child @Method in componentWillLoad deadlocks the + // lazy load graph (the child can't render until this component renders). + // Build the rules once the children have upgraded, then subscribe to the resource + Promise.all( + caseElements.map(async it => ({ + rule: await it.getRule(), + caseElement: it, + })), + ).then(cases => { + this.cases = cases; + subscribeResource(this); + }); } receiveResource = (resource: Thing) => { // reset any existing resource this.disconnected$.next(); - this.resource = undefined; - let observables: Observable[] = []; - if (this.caseElements.some(caseElement => caseElement.hasAttribute('if-typeof'))) { - const observeTypes = resource.observeTypes().pipe(takeUntil(this.disconnected$)); - observeTypes.subscribe(types => { - this.types = types; - }); - observables.push(observeTypes); - } - if (this.caseElements.some(caseElement => caseElement.hasAttribute('if-property'))) { - const observeRelations = resource.observeRelations().pipe(takeUntil(this.disconnected$)); - observeRelations.subscribe(relations => { - this.relations = relations; - }); - observables.push(observeRelations); - } - if (this.caseElements.some(caseElement => caseElement.hasAttribute('if-rev'))) { - const observeReverseRelations = resource.observeReverseRelations().pipe(takeUntil(this.disconnected$)); - observeReverseRelations.subscribe(reverseRelations => { - this.reverseRelations = reverseRelations; + this.resource = resource; + + combineLatest([ + this.containsRule('if-typeof') ? resource.observeTypes() : of([]), + this.containsRule('if-property') ? resource.observeLiterals() : of([]), + this.containsRule('if-property') ? resource.observeRelations() : of([]), + this.containsRule('if-rev') ? resource.observeReverseRelations() : of([]), + ]) + .pipe( + map(([types, literals, relations, reverseRelations]) => ({ + types, + literals, + relations, + reverseRelations, + })), + takeUntil(this.disconnected$), + ) + .subscribe((context: RuleContext) => { + const activeElements = new Set(findMatchingRules(this.cases, context).map(it => it.caseElement)); + this.cases.forEach(it => { + if (activeElements.has(it.caseElement)) { + it.caseElement.setAttribute('active', ''); + } else { + it.caseElement.removeAttribute('active'); + } + }); }); - observables.push(observeReverseRelations); - } - firstValueFrom(combineLatest(observables)).then(() => (this.resource = resource)); }; + private containsRule(type: SwitchCaseRule['type']) { + return this.cases.some(it => it.rule.type === type); + } + render() { if (this.error) { return this.error; @@ -89,21 +92,8 @@ export class PosSwitch implements ResourceAware { if (!this.resource) { return null; } - let state = null; - let activeElements: HTMLPosCaseElement[] = []; - this.caseElements.forEach(el => { - const elemState = this.test(el); - const includeCondition = state !== true || el.getAttribute('else') === null; - if (elemState && includeCondition) { - state = elemState; - activeElements.push(el); - } - if (elemState === null && includeCondition) { - activeElements.push(el); - } - }); - const activeElementsContent = activeElements.map(el => el.querySelector('template').innerHTML).join('\n'); - return ; + + return ; } disconnectedCallback() { diff --git a/elements/src/components/pos-switch/pos-switch.vspec.tsx b/elements/src/components/pos-switch/pos-switch.vspec.tsx new file mode 100644 index 00000000..c2ac16d1 --- /dev/null +++ b/elements/src/components/pos-switch/pos-switch.vspec.tsx @@ -0,0 +1,422 @@ +import { describe, expect, h, it, render } from '@stencil/vitest'; + +import './pos-switch'; +import './pos-case/pos-case'; +import { BehaviorSubject, Subject } from 'rxjs'; +import { Literal, RdfType, Relation, Thing } from '@pod-os/core'; + +describe('pos-switch', () => { + describe('template loading', () => { + it('contains nothing initially', async () => { + const page = await render( + + + + + , + ); + expect(page.root).toMatchInlineSnapshot(` + + + + + `); + }); + it('load rules from cases', async () => { + const page = await render( + + + + + + + + , + ); + expect(page.instance.cases).toHaveLength(2); + expect(page.instance.cases[0].rule).toEqual({ + type: 'if-typeof', + value: 'http://schema.org/Recipe', + }); + expect(page.instance.cases[1].rule).toEqual({ + type: 'if-typeof', + value: 'http://schema.org/Video', + }); + }); + it('does not load nested templates', async () => { + const page = await render( + + + + + , + ); + expect(page.instance.cases).toHaveLength(1); + }); + it('displays error on missing template', async () => { + const page = await render(); + expect(page.root).toHaveTextContent('No pos-case elements found'); + }); + it('supports mixed test conditions', async () => { + const page = await render( + + + + + + + + + + + , + ); + const observedTypes$ = new BehaviorSubject([]); + const observedRelations$ = new BehaviorSubject([]); + const observedReverseRelations$ = new BehaviorSubject([]); + const observedLiterals$ = new BehaviorSubject([]); + const thing = { + uri: 'https://pod.example/recipe1', + observeLiterals: () => observedLiterals$, + observeTypes: () => observedTypes$, + observeRelations: () => observedRelations$, + observeReverseRelations: () => observedReverseRelations$, + }; + page.instance.receiveResource(thing); + observedLiterals$.next([]); + observedTypes$.next([ + { + label: 'Recipe', + uri: 'http://schema.org/Recipe', + }, + ]); + observedRelations$.next([ + { predicate: 'http://schema.org/image', label: 'image', uris: ['https://resource.test/recipe-photo.jpg'] }, + ]); + observedReverseRelations$.next([ + { + predicate: 'http://schema.org/itemListElement', + label: 'itemListElement', + uris: ['https://pod.example/recipe-list'], + }, + ]); + await page.waitForChanges(); + expect(page.root).toEqualText('Recipe.Image.Recipe list.'); + }); + }); + describe('reactive rendering', () => { + it('renders matching condition templates reactively', async () => { + const page = await render( + + + + + + + + + + + , + ); + const observedTypes$ = new BehaviorSubject([]); + const thing = { + observeTypes: () => observedTypes$, + } as unknown as Thing; + page.instance.receiveResource(thing); + observedTypes$.next([ + { + label: 'Recipe', + uri: 'http://schema.org/Recipe', + }, + ]); + await page.waitForChanges(); + expect(page.root).toEqualText('Recipe 1Recipe 2'); + }); + + it('resets and updates types when resource is changed', async () => { + const page = await render( + + + + + + + + , + ); + const { thing, observedTypes$ } = mockObservedResource('https://pod.example/recipe1'); + page.instance.receiveResource(thing); + observedTypes$.next([ + { + label: 'Recipe', + uri: 'http://schema.org/Recipe', + }, + ]); + await page.waitForChanges(); + expect(page.root).toEqualText('Recipe.'); + // new thing + const { thing: thing2, observedTypes$: observedTypes2$ } = mockObservedResource('https://pod.example/video1'); + page.instance.receiveResource(thing2); + await page.waitForChanges(); + expect(page.root).toEqualText(''); + observedTypes2$.next([ + { + label: 'Video', + uri: 'http://schema.org/Video', + }, + ]); + await page.waitForChanges(); + expect(page.root).toEqualText('Video.'); + }); + + it('resets and updates literals when resource is changed', async () => { + const page = await render( + + + + + + + + , + ); + const { thing, observedLiterals$ } = mockObservedResource('https://pod.example/recipe1'); + page.instance.receiveResource(thing); + observedLiterals$.next([ + { + label: 'name', + predicate: 'http://schema.org/name', + values: ['some name'], + }, + ]); + await page.waitForChanges(); + expect(page.root).toEqualText('Name'); + // new thing + const { thing: thing2, observedLiterals$: observedLiterals2$ } = + mockObservedResource('https://pod.example/video1'); + page.instance.receiveResource(thing2); + await page.waitForChanges(); + expect(page.root).toEqualText(''); + observedLiterals2$.next([ + { + label: 'description', + predicate: 'http://schema.org/description', + values: ['some description'], + }, + ]); + await page.waitForChanges(); + expect(page.root).toEqualText('Description'); + }); + + it('resets and updates relations when resource is changed', async () => { + const page = await render( + + + + + + + + , + ); + const { thing, observedRelations$ } = mockObservedResource('https://pod.example/has-recipe'); + page.instance.receiveResource(thing); + observedRelations$.next([ + { + label: 'recipe', + predicate: 'http://schema.org/recipe', + uris: ['https://recipe.test/#it'], + }, + ]); + await page.waitForChanges(); + expect(page.root).toEqualText('Recipe'); + // new thing + const { thing: thing2, observedRelations$: observedRelations2$ } = mockObservedResource( + 'https://pod.example/has-video', + ); + page.instance.receiveResource(thing2); + await page.waitForChanges(); + expect(page.root).toEqualText(''); + observedRelations2$.next([ + { + label: 'video', + predicate: 'http://schema.org/video', + uris: ['https://video.test/#it'], + }, + ]); + await page.waitForChanges(); + expect(page.root).toEqualText('Video'); + }); + + it('resets and updates reverse relations when resource is changed', async () => { + const page = await render( + + + + + + + + , + ); + const { thing, observedReverseRelations$ } = mockObservedResource('https://recipe.test/#it'); + page.instance.receiveResource(thing); + observedReverseRelations$.next([ + { + label: 'recipe', + predicate: 'http://schema.org/recipe', + uris: ['https://something.test/has-recipe'], + }, + ]); + await page.waitForChanges(); + expect(page.root).toEqualText('Recipe'); + // new thing + const { thing: thing2, observedReverseRelations$: observedReverseRelations2$ } = mockObservedResource( + 'https://pod.example/has-video', + ); + page.instance.receiveResource(thing2); + await page.waitForChanges(); + expect(page.root).toEqualText(''); + observedReverseRelations2$.next([ + { + label: 'video', + predicate: 'http://schema.org/video', + uris: ['https://something.test/has-video'], + }, + ]); + await page.waitForChanges(); + expect(page.root).toEqualText('Video'); + }); + + function mockObservedResource(uri: string) { + // use BehaviorSubject here, since it has a starting value, which matches the original behaviour + // of the observe* functions of Thing which use startWith(currentValue) to have an initial value + const observedTypes$ = new BehaviorSubject([]); + const observedLiterals$ = new BehaviorSubject([]); + const observedRelations$ = new BehaviorSubject([]); + const observedReverseRelations$ = new BehaviorSubject([]); + return { + thing: { + uri, + observeTypes: () => observedTypes$, + observeLiterals: () => observedLiterals$, + observeRelations: () => observedRelations$, + observeReverseRelations: () => observedReverseRelations$, + } as unknown as Thing, + observedTypes$, + observedLiterals$, + observedRelations$, + observedReverseRelations$, + }; + } + }); + + describe('if-else logic', () => { + it('renders matching condition templates with if-else logic', async () => { + const page = await render( + + + + + + + + + + + + + + , + ); + const observedTypes$ = new Subject(); + const thing = { + observeTypes: () => observedTypes$, + } as unknown as Thing; + page.instance.receiveResource(thing); + observedTypes$.next([ + { + label: 'Recipe', + uri: 'http://schema.org/Recipe', + }, + ]); + await page.waitForChanges(); + expect(page.root).toEqualText('Recipe 1'); + }); + + it('renders final else condition if no other templates match', async () => { + const page = await render( + + + + + + + + , + ); + const observedTypes$ = new Subject(); + const thing = { + observeTypes: () => observedTypes$, + } as unknown as Thing; + page.instance.receiveResource(thing); + observedTypes$.next([ + { + label: 'Recipe', + uri: 'http://schema.org/Recipe', + }, + ]); + await page.waitForChanges(); + expect(page.root).toEqualText('No matches'); + }); + + it('renders only the else case if it is the only case', async () => { + const page = await render( + + + + + , + ); + const thing = { uri: 'https://pod.example/resource' } as unknown as Thing; + page.instance.receiveResource(thing); + await page.waitForChanges(); + expect(page.root).toEqualText('fallback'); + }); + }); +}); diff --git a/elements/src/components/pos-switch/rules/doValuesMatch.ts b/elements/src/components/pos-switch/rules/doValuesMatch.ts new file mode 100644 index 00000000..ce9334ff --- /dev/null +++ b/elements/src/components/pos-switch/rules/doValuesMatch.ts @@ -0,0 +1,39 @@ +import { Comparison } from './index'; + +/** + * Checks whether the given values match the given target according to the given comparison + * @param values + * @param comparison + */ +export function doValuesMatch(values: string[], comparison: Comparison): boolean { + const { operator, semantic, target } = comparison; + const matches = values.map(val => { + let cmp; + const numVal = Number(val); + const numTarget = Number(target); + if (!Number.isNaN(numVal) && !Number.isNaN(numTarget)) { + cmp = numVal - numTarget; + } else { + cmp = String(val).localeCompare(String(target)); + } + switch (operator) { + case 'eq': + return cmp === 0; + case 'gt': + return cmp > 0; + case 'gte': + return cmp >= 0; + case 'lt': + return cmp < 0; + case 'lte': + return cmp <= 0; + } + }); + if (semantic == 'some') { + return matches.some(Boolean); + } else if (semantic == 'every') { + return matches.every(Boolean); + } else { + throw new Error(`Unknown semantic: ${semantic}`); + } +} diff --git a/elements/src/components/pos-switch/rules/doValuesMatch.vspec.ts b/elements/src/components/pos-switch/rules/doValuesMatch.vspec.ts new file mode 100644 index 00000000..7002e487 --- /dev/null +++ b/elements/src/components/pos-switch/rules/doValuesMatch.vspec.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from '@stencil/vitest'; +import { doValuesMatch } from './doValuesMatch'; +import { Operator, Semantic } from './index'; + +describe('do values match', () => { + describe.each([[['Alice', 'Bob', 'Claire']], [['99', '100', '101']]])('given %s', list => { + const less = list[0]; + const target = list[1]; + const greater = list[2]; + + it.each([ + ...['eq', 'gt', 'lt', 'gte', 'lte'].map(it => ({ + semantic: 'some' as Semantic, + operator: it as Operator, + comparedAgainst: [], + match: false, + })), + ...['gt', 'gte'].map(it => ({ + semantic: 'some' as Semantic, + operator: it as Operator, + comparedAgainst: [greater], + match: true, + })), + ...['eq', 'lt', 'lte'].map(it => ({ + semantic: 'some' as Semantic, + operator: it as Operator, + comparedAgainst: [greater], + match: false, + })), + ...['eq', 'gt', 'gte', 'lte'].map(it => ({ + semantic: 'some' as Semantic, + operator: it as Operator, + comparedAgainst: [target, greater], + match: true, + })), + ...['lt'].map(it => ({ + semantic: 'some' as Semantic, + operator: it as Operator, + comparedAgainst: [target, greater], + match: false, + })), + ...['gt', 'lt', 'gte', 'lte'].map(it => ({ + semantic: 'some' as Semantic, + operator: it as Operator, + comparedAgainst: [less, greater], + match: true, + })), + ...['eq'].map(it => ({ + semantic: 'some' as Semantic, + operator: it as Operator, + comparedAgainst: [less, greater], + match: false, + })), + ...['eq', 'lte', 'lt', 'gte'].map(it => ({ + semantic: 'some' as Semantic, + operator: it as Operator, + comparedAgainst: [less, target], + match: true, + })), + ...['gt'].map(it => ({ + semantic: 'some' as Semantic, + operator: it as Operator, + comparedAgainst: [less, target], + match: false, + })), + ...['lt', 'lte'].map(it => ({ + semantic: 'some' as Semantic, + operator: it as Operator, + comparedAgainst: [less], + match: true, + })), + ...['eq', 'gte', 'gt'].map(it => ({ + semantic: 'some' as Semantic, + operator: it as Operator, + comparedAgainst: [less], + match: false, + })), + ...['gt', 'lt', 'gte', 'lte', 'eq'].map(it => ({ + semantic: 'some' as Semantic, + operator: it as Operator, + comparedAgainst: [less, target, greater], + match: true, + })), + + // every + ...['eq', 'gt', 'lt', 'gte', 'lte'].map(it => ({ + semantic: 'every' as Semantic, + operator: it as Operator, + comparedAgainst: [], + // vacuous truth applies, see https://en.wikipedia.org/wiki/Vacuous_truth + // There is *no* value that does *not* match Alice => every value matches + // this is in line with the default of [Array.every](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/every) + // Also in RDF, if a property is present, it must have a value, so this is a hypothetical case + match: true, + })), + ...['gt', 'gte'].map(it => ({ + semantic: 'every' as Semantic, + operator: it as Operator, + comparedAgainst: [greater], + match: true, + })), + ...['eq', 'lt', 'lte'].map(it => ({ + semantic: 'every' as Semantic, + operator: it as Operator, + comparedAgainst: [greater], + match: false, + })), + ...['gte'].map(it => ({ + semantic: 'every' as Semantic, + operator: it as Operator, + comparedAgainst: [target, greater], + match: true, + })), + ...['eq', 'lte', 'lt', 'gt'].map(it => ({ + semantic: 'every' as Semantic, + operator: it as Operator, + comparedAgainst: [target, greater], + match: false, + })), + ...['eq', 'gt', 'lt', 'gte', 'lte'].map(it => ({ + semantic: 'every' as Semantic, + operator: it as Operator, + comparedAgainst: [less, greater], + match: false, + })), + ...['lte'].map(it => ({ + semantic: 'every' as Semantic, + operator: it as Operator, + comparedAgainst: [less, target], + match: true, + })), + ...['eq', 'lt', 'gt', 'gte'].map(it => ({ + semantic: 'every' as Semantic, + operator: it as Operator, + comparedAgainst: [less, target], + match: false, + })), + ...['lt', 'lte'].map(it => ({ + semantic: 'every' as Semantic, + operator: it as Operator, + comparedAgainst: [less], + match: true, + })), + ...['eq', 'gte', 'gt'].map(it => ({ + semantic: 'every' as Semantic, + operator: it as Operator, + comparedAgainst: [less], + match: false, + })), + ...['gt', 'lt', 'gte', 'lte', 'eq'].map(it => ({ + semantic: 'every' as Semantic, + operator: it as Operator, + comparedAgainst: [less, target, greater], + match: false, + })), + ])( + `$semantic value $operator: it is $match, that $semantic of $comparedAgainst are $operator compared to ${target}`, + ({ semantic, operator, comparedAgainst, match }) => { + const result = doValuesMatch(comparedAgainst, { + semantic, + operator, + target, + }); + expect(result).toBe(match); + }, + ); + it('throws error for unknown semantic', () => { + expect(() => + doValuesMatch(list, { + semantic: 'unknown' as Semantic, + operator: 'eq' as Operator, + target, + }), + ).toThrow(new Error('Unknown semantic: unknown')); + }); + }); +}); diff --git a/elements/src/components/pos-switch/rules/doesRuleMatch.ts b/elements/src/components/pos-switch/rules/doesRuleMatch.ts new file mode 100644 index 00000000..c37d1fe4 --- /dev/null +++ b/elements/src/components/pos-switch/rules/doesRuleMatch.ts @@ -0,0 +1,44 @@ +import { IfPropertyRule, IfRevRule, IfTypeofRule, RuleContext, SwitchCaseRule } from './index'; + +import { doValuesMatch } from './doValuesMatch'; + +export function doesRuleMatch(rule: SwitchCaseRule, context: RuleContext) { + const ruleResult = evaluateRule(rule, context); + return rule.not ? !ruleResult : ruleResult; +} + +function evaluateRule(rule: SwitchCaseRule, context: RuleContext) { + switch (rule.type) { + case 'if-typeof': + return doesTypeMatch(rule, context); + case 'if-property': + return doesPropertyMatch(rule, context); + case 'if-rev': + return doesReverseRelationMatch(rule, context); + case 'else': + return true; + default: + return false; + } +} + +function doesTypeMatch(rule: IfTypeofRule, context: RuleContext) { + return context.types.map(x => x.uri).includes(rule.value); +} + +function doesPropertyMatch(rule: IfPropertyRule, context: RuleContext) { + const matchingLiteral = context.literals.find(it => it.predicate == rule.value); + const matchingRelation = context.relations.find(it => it.predicate == rule.value); + if (rule.comparison && (matchingLiteral || matchingRelation)) { + return doValuesMatch([...(matchingLiteral?.values ?? []), ...(matchingRelation?.uris ?? [])], rule.comparison); + } + return matchingLiteral !== undefined || matchingRelation !== undefined; +} + +function doesReverseRelationMatch(rule: IfRevRule, context: RuleContext) { + const matchingRev = context.reverseRelations.find(x => x.predicate == rule.value); + if (rule.comparison && matchingRev) { + return doValuesMatch(matchingRev.uris, rule.comparison); + } + return matchingRev !== undefined; +} diff --git a/elements/src/components/pos-switch/rules/doesRuleMatch.vspec.ts b/elements/src/components/pos-switch/rules/doesRuleMatch.vspec.ts new file mode 100644 index 00000000..53868c06 --- /dev/null +++ b/elements/src/components/pos-switch/rules/doesRuleMatch.vspec.ts @@ -0,0 +1,554 @@ +import { describe, expect, it } from '@stencil/vitest'; +import { doesRuleMatch } from './doesRuleMatch'; +import { Literal, RdfType, Relation } from '@pod-os/core'; +import { ELSE_RULE, NEVER_RULE, SwitchCaseRule } from './index'; + +const EMPTY_CONTEXT = { + types: [], + literals: [], + relations: [], + reverseRelations: [], +}; + +describe('does rule match', () => { + describe('never', () => { + it('does never match', () => { + const result = doesRuleMatch(NEVER_RULE, EMPTY_CONTEXT); + expect(result).toBe(false); + }); + }); + describe('else', () => { + it('does always match', () => { + const result = doesRuleMatch(ELSE_RULE, EMPTY_CONTEXT); + expect(result).toBe(true); + }); + }); + describe('if-typeof', () => { + const ifTypeOfRecipe: SwitchCaseRule = { + type: 'if-typeof', + value: 'http://schema.org/Recipe', + }; + + it('does not match if no types are in context', () => { + const result = doesRuleMatch(ifTypeOfRecipe, EMPTY_CONTEXT); + expect(result).toBe(false); + }); + it('does not match if wrong type is present', () => { + const context = { + ...EMPTY_CONTEXT, + types: [type('http://vocab.example.org/OtherType')], + }; + const result = doesRuleMatch(ifTypeOfRecipe, context); + expect(result).toBe(false); + }); + it('matches if context contains the type in question', () => { + const context = { + ...EMPTY_CONTEXT, + types: [type('http://schema.org/Recipe')], + }; + const result = doesRuleMatch(ifTypeOfRecipe, context); + expect(result).toBe(true); + }); + it('matches if context contains a matching type and also other types', () => { + const context = { + ...EMPTY_CONTEXT, + types: [type('http://schema.org/Recipe'), type('http://vocab.example.org/OtherType')], + }; + const result = doesRuleMatch(ifTypeOfRecipe, context); + expect(result).toBe(true); + }); + + describe('when negated', () => { + const notIfTypeOfRecipe = { + ...ifTypeOfRecipe, + not: true, + }; + + it('does match if no types are in context', () => { + const result = doesRuleMatch(notIfTypeOfRecipe, EMPTY_CONTEXT); + expect(result).toBe(true); + }); + it('does match if wrong type is present', () => { + const context = { + ...EMPTY_CONTEXT, + types: [type('http://vocab.example.org/OtherType')], + }; + const result = doesRuleMatch(notIfTypeOfRecipe, context); + expect(result).toBe(true); + }); + it('does not match if context contains the type in question', () => { + const context = { + ...EMPTY_CONTEXT, + types: [type('http://schema.org/Recipe')], + }; + const result = doesRuleMatch(notIfTypeOfRecipe, context); + expect(result).toBe(false); + }); + it('does not matches if context contains a matching type and also other types', () => { + const context = { + ...EMPTY_CONTEXT, + types: [type('http://schema.org/Recipe'), type('http://vocab.example.org/OtherType')], + }; + const result = doesRuleMatch(notIfTypeOfRecipe, context); + expect(result).toBe(false); + }); + }); + }); + describe('if-property', () => { + describe('is present', () => { + const ifPropertyName: SwitchCaseRule = { + type: 'if-property', + value: 'http://schema.org/name', + }; + it('does not match if no properties are in context', () => { + const result = doesRuleMatch(ifPropertyName, EMPTY_CONTEXT); + expect(result).toBe(false); + }); + it('does not match if context contains only relations and literals of other properties', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://vocab.test/other')], + relations: [relation('http://vocab.test/other')], + }; + const result = doesRuleMatch(ifPropertyName, context); + expect(result).toBe(false); + }); + it('matches if context contains a literal of that property', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://schema.org/name')], + }; + const result = doesRuleMatch(ifPropertyName, context); + expect(result).toBe(true); + }); + it('matches if context contains a relation of that property', () => { + const context = { + ...EMPTY_CONTEXT, + relations: [relation('http://schema.org/name')], + }; + const result = doesRuleMatch(ifPropertyName, context); + expect(result).toBe(true); + }); + it('matches if context contains both a relation and a literal of that property', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://schema.org/name')], + relations: [relation('http://schema.org/name')], + }; + const result = doesRuleMatch(ifPropertyName, context); + expect(result).toBe(true); + }); + it('matches if one match is present besides others', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://vocab.example.org/other')], + relations: [relation('http://schema.org/name'), relation('http://vocab.example.org/other-rel')], + }; + const result = doesRuleMatch(ifPropertyName, context); + expect(result).toBe(true); + }); + + describe('when negated', () => { + const notIfPropertyName = { + ...ifPropertyName, + not: true, + }; + it('matches if no properties are in context', () => { + const result = doesRuleMatch(notIfPropertyName, EMPTY_CONTEXT); + expect(result).toBe(true); + }); + it('matches if context contains only relations and literals of other properties', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://vocab.test/other')], + relations: [relation('http://vocab.test/other')], + }; + const result = doesRuleMatch(notIfPropertyName, context); + expect(result).toBe(true); + }); + it('does not match if context contains a literal of that property', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://schema.org/name')], + }; + const result = doesRuleMatch(notIfPropertyName, context); + expect(result).toBe(false); + }); + it('does not match if context contains a relation of that property', () => { + const context = { + ...EMPTY_CONTEXT, + relations: [relation('http://schema.org/name')], + }; + const result = doesRuleMatch(notIfPropertyName, context); + expect(result).toBe(false); + }); + it('does not match if context contains both a relation and a literal of that property', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://schema.org/name')], + relations: [relation('http://schema.org/name')], + }; + const result = doesRuleMatch(notIfPropertyName, context); + expect(result).toBe(false); + }); + it('does not match if one match is present besides others', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://vocab.example.org/other')], + relations: [relation('http://schema.org/name'), relation('http://vocab.example.org/other-rel')], + }; + const result = doesRuleMatch(notIfPropertyName, context); + expect(result).toBe(false); + }); + }); + describe('if-property with specific value', () => { + const ifSomeNameEqualsAlice: SwitchCaseRule = { + type: 'if-property', + value: 'http://schema.org/name', + comparison: { + semantic: 'some', + operator: 'eq', + target: 'Alice', + }, + }; + it('does not match if no properties are in context', () => { + const result = doesRuleMatch(ifSomeNameEqualsAlice, EMPTY_CONTEXT); + expect(result).toBe(false); + }); + it('matches if context contains a literal of that property with that name', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://schema.org/name', ['Alice'])], + }; + const result = doesRuleMatch(ifSomeNameEqualsAlice, context); + expect(result).toBe(true); + }); + it('does not match if context contains a literal of that property but without the name', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://schema.org/name', ['Other'])], + }; + const result = doesRuleMatch(ifSomeNameEqualsAlice, context); + expect(result).toBe(false); + }); + describe('when negated', () => { + const notIfSomeNameEqualsAlice: SwitchCaseRule = { + ...ifSomeNameEqualsAlice, + not: true, + }; + it('matches if no properties are in context', () => { + const result = doesRuleMatch(notIfSomeNameEqualsAlice, EMPTY_CONTEXT); + expect(result).toBe(true); + }); + it('does not match if context contains a literal of that property with that name', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://schema.org/name', ['Alice'])], + }; + const result = doesRuleMatch(notIfSomeNameEqualsAlice, context); + expect(result).toBe(false); + }); + it('matches if context contains a literal of that property but without the name', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://schema.org/name', ['Other'])], + }; + const result = doesRuleMatch(notIfSomeNameEqualsAlice, context); + expect(result).toBe(true); + }); + }); + }); + + describe('if-property with specific relation', () => { + const ifVideoIsSomeVideo: SwitchCaseRule = { + type: 'if-property', + value: 'http://schema.org/video', + comparison: { + semantic: 'some', + operator: 'eq', + target: 'https://video.test/some-video', + }, + }; + it('does not match if no properties are in context', () => { + const result = doesRuleMatch(ifVideoIsSomeVideo, EMPTY_CONTEXT); + expect(result).toBe(false); + }); + it('matches if context contains a relation of that property with that uri', () => { + const context = { + ...EMPTY_CONTEXT, + relations: [relation('http://schema.org/video', ['https://video.test/some-video'])], + }; + const result = doesRuleMatch(ifVideoIsSomeVideo, context); + expect(result).toBe(true); + }); + it('matches if context contains a relation of that property with that uri, even if a literal of the same property is present', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://schema.org/video', ['Other video'])], + relations: [relation('http://schema.org/video', ['https://video.test/some-video'])], + }; + const result = doesRuleMatch(ifVideoIsSomeVideo, context); + expect(result).toBe(true); + }); + it('does not match if context contains a relation of that property but without the uri', () => { + const context = { + ...EMPTY_CONTEXT, + relations: [relation('http://schema.org/video', ['https://video.test/other-video'])], + }; + const result = doesRuleMatch(ifVideoIsSomeVideo, context); + expect(result).toBe(false); + }); + describe('when negated', () => { + const notIfVideoIsSomeVideo: SwitchCaseRule = { + ...ifVideoIsSomeVideo, + not: true, + }; + it('matches if no properties are in context', () => { + const result = doesRuleMatch(notIfVideoIsSomeVideo, EMPTY_CONTEXT); + expect(result).toBe(true); + }); + it('does not match if context contains a relation of that property with that uri', () => { + const context = { + ...EMPTY_CONTEXT, + relations: [relation('http://schema.org/video', ['https://video.test/some-video'])], + }; + const result = doesRuleMatch(notIfVideoIsSomeVideo, context); + expect(result).toBe(false); + }); + it('does not match if context contains a relation of that property with that uri, even if a literal of the same property is present', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://schema.org/video', ['Other video'])], + relations: [relation('http://schema.org/video', ['https://video.test/some-video'])], + }; + const result = doesRuleMatch(notIfVideoIsSomeVideo, context); + expect(result).toBe(false); + }); + it('matches if context contains a relation of that property but without the uri', () => { + const context = { + ...EMPTY_CONTEXT, + relations: [relation('http://schema.org/video', ['https://video.test/other-video'])], + }; + const result = doesRuleMatch(notIfVideoIsSomeVideo, context); + expect(result).toBe(true); + }); + }); + }); + + describe('if-property with every-value-eq', () => { + it('does not match if the name is Alice, but a relation is present as well (which is not the literal "Alice")', () => { + const ifEveryNameEqAlice: SwitchCaseRule = { + type: 'if-property', + value: 'http://schema.org/name', + comparison: { + semantic: 'every', + operator: 'eq', + target: 'Alice', + }, + }; + // every-value-eq must match both literals and relations! + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://schema.org/name', ['Alice'])], + relations: [relation('http://schema.org/name', ['https://alice.test/my-name#it'])], + }; + const result = doesRuleMatch(ifEveryNameEqAlice, context); + expect(result).toBe(false); + }); + + it('does not match if the property is not present at all', () => { + const ifEveryNameEqAlice: SwitchCaseRule = { + type: 'if-property', + value: 'http://schema.org/name', + comparison: { + semantic: 'every', + operator: 'eq', + target: 'Alice', + }, + }; + // every-value-eq must match both literals and relations! + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://schema.org/other', ['Alice'])], + relations: [relation('http://schema.org/other', ['https://alice.test/my-name#it'])], + }; + const result = doesRuleMatch(ifEveryNameEqAlice, context); + // [vacuous truth](https://en.wikipedia.org/wiki/Vacuous_truth) does not apply here! + // The semantics of the rule are: The property is present AND all their values match the target + // So a missing property must evaluate to false - we are not taking any values into account then. + // The value matching is an *additional* condition on top of if-property, it would be surprising if + // it matches when you further constrain it + expect(result).toBe(false); + }); + + it('matches if the URI in question is present as both a literal and a real relation', () => { + const ifEveryVideoIsSomeVideo: SwitchCaseRule = { + type: 'if-property', + value: 'http://schema.org/video', + comparison: { + semantic: 'every', + operator: 'eq', + target: 'https://video.test/some-video', + }, + }; + // every-value-eq must match both literals and relations! + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://schema.org/video', ['https://video.test/some-video'])], + relations: [relation('http://schema.org/video', ['https://video.test/some-video'])], + }; + const result = doesRuleMatch(ifEveryVideoIsSomeVideo, context); + expect(result).toBe(true); + }); + }); + }); + }); + + describe('if-rev', () => { + const ifRevImage: SwitchCaseRule = { + type: 'if-rev', + value: 'http://schema.org/image', + }; + it('does not match if no properties are in context', () => { + const result = doesRuleMatch(ifRevImage, EMPTY_CONTEXT); + expect(result).toBe(false); + }); + it('does not match if only wrong reverse relations are in context', () => { + const context = { + ...EMPTY_CONTEXT, + reverseRelations: [relation('http://vocab.example.org/other-rel')], + }; + const result = doesRuleMatch(ifRevImage, context); + expect(result).toBe(false); + }); + it('matches if context contains a reverse relation of that property', () => { + const context = { + ...EMPTY_CONTEXT, + reverseRelations: [relation('http://schema.org/image')], + }; + const result = doesRuleMatch(ifRevImage, context); + expect(result).toBe(true); + }); + it('matches if one match is present besides others', () => { + const context = { + ...EMPTY_CONTEXT, + reverseRelations: [relation('http://schema.org/image'), relation('http://vocab.example.org/other-rel')], + }; + const result = doesRuleMatch(ifRevImage, context); + expect(result).toBe(true); + }); + + describe('when negated', () => { + const notIfRevImage = { + ...ifRevImage, + not: true, + }; + it('matches if no properties are in context', () => { + const result = doesRuleMatch(notIfRevImage, EMPTY_CONTEXT); + expect(result).toBe(true); + }); + it('matches if only wrong reverse relations are in context', () => { + const context = { + ...EMPTY_CONTEXT, + reverseRelations: [relation('http://vocab.example.org/other-rel')], + }; + const result = doesRuleMatch(notIfRevImage, context); + expect(result).toBe(true); + }); + it('does not match if context contains a reverse relation of that property', () => { + const context = { + ...EMPTY_CONTEXT, + reverseRelations: [relation('http://schema.org/image')], + }; + const result = doesRuleMatch(notIfRevImage, context); + expect(result).toBe(false); + }); + it('does not match if one match is present besides others', () => { + const context = { + ...EMPTY_CONTEXT, + reverseRelations: [relation('http://schema.org/image'), relation('http://vocab.example.org/other-rel')], + }; + const result = doesRuleMatch(notIfRevImage, context); + expect(result).toBe(false); + }); + }); + + describe('if-rev with specific value', () => { + const ifThisIsAnImageOfAlice: SwitchCaseRule = { + type: 'if-rev', + value: 'http://schema.org/image', + comparison: { + semantic: 'every', + operator: 'eq', + target: 'https://alice.example/profile/card#me', + }, + }; + it('does not match if no properties are in context', () => { + const result = doesRuleMatch(ifThisIsAnImageOfAlice, EMPTY_CONTEXT); + expect(result).toBe(false); + }); + it('matches if context contains a reverse relation of that property with that uri', () => { + const context = { + ...EMPTY_CONTEXT, + reverseRelations: [relation('http://schema.org/image', ['https://alice.example/profile/card#me'])], + }; + const result = doesRuleMatch(ifThisIsAnImageOfAlice, context); + expect(result).toBe(true); + }); + it('does not match if context contains a literal of that property but without the name', () => { + const context = { + ...EMPTY_CONTEXT, + reverseRelations: [relation('http://schema.org/image', ['https://other.example/profile/card#me'])], + }; + const result = doesRuleMatch(ifThisIsAnImageOfAlice, context); + expect(result).toBe(false); + }); + describe('when negated', () => { + const notIfThisIsAnImageOfAlice: SwitchCaseRule = { + ...ifThisIsAnImageOfAlice, + not: true, + }; + it('matches if no properties are in context', () => { + const result = doesRuleMatch(notIfThisIsAnImageOfAlice, EMPTY_CONTEXT); + expect(result).toBe(true); + }); + it('does not match if context contains a reverse relation of that property with that uri', () => { + const context = { + ...EMPTY_CONTEXT, + reverseRelations: [relation('http://schema.org/image', ['https://alice.example/profile/card#me'])], + }; + const result = doesRuleMatch(notIfThisIsAnImageOfAlice, context); + expect(result).toBe(false); + }); + it('matches if context contains a literal of that property but without the name', () => { + const context = { + ...EMPTY_CONTEXT, + reverseRelations: [relation('http://schema.org/image', ['https://other.example/profile/card#me'])], + }; + const result = doesRuleMatch(notIfThisIsAnImageOfAlice, context); + expect(result).toBe(true); + }); + }); + }); + }); +}); + +function type(uri: string) { + return { + uri, + } as RdfType; +} + +function literal(predicate: string, values: string[] = []) { + return { + predicate, + values, + } as Literal; +} + +function relation(predicate: string, uris: string[] = []) { + return { + predicate, + uris, + } as Relation; +} diff --git a/elements/src/components/pos-switch/rules/findMatchingRules.ts b/elements/src/components/pos-switch/rules/findMatchingRules.ts new file mode 100644 index 00000000..2dd19024 --- /dev/null +++ b/elements/src/components/pos-switch/rules/findMatchingRules.ts @@ -0,0 +1,28 @@ +import { RuleContext, SwitchCaseRule } from './index'; +import { doesRuleMatch } from './doesRuleMatch'; + +export function findMatchingRules(cases: T[], context: RuleContext) { + /** + * scope of if-else statements + * each plain if (without else) increases the scope by 1 + * else branches are ignored if we already found a match in the current scope + */ + let scope = 0; + /** + * scope of the previous rule that matched + */ + let previousRuleMatched = -1; + return cases.filter(it => { + if (it.rule.else && previousRuleMatched == scope) { + return false; // else branches are ignored if we already found a match in the current scope + } + if (!it.rule.else) { + scope++; // plain if rule -> new scope + } + const match = doesRuleMatch(it.rule, context); + if (match) { + previousRuleMatched = scope; // found a match in the current if-else scope + } + return match; + }); +} diff --git a/elements/src/components/pos-switch/rules/findMatchingRules.vspec.ts b/elements/src/components/pos-switch/rules/findMatchingRules.vspec.ts new file mode 100644 index 00000000..803690a3 --- /dev/null +++ b/elements/src/components/pos-switch/rules/findMatchingRules.vspec.ts @@ -0,0 +1,536 @@ +import { describe, expect, it } from '@stencil/vitest'; +import { findMatchingRules } from './findMatchingRules'; +import { ELSE_RULE, IfTypeofRule, SwitchCaseRule } from './index'; +import { Literal, RdfType, Relation } from '@pod-os/core'; + +const EMPTY_CONTEXT = { + types: [], + literals: [], + relations: [], + reverseRelations: [], +}; + +interface TestRules { + rule: SwitchCaseRule; +} + +describe('find matching rules', () => { + it('finds nothing, if nothing is given', () => { + const rules: TestRules[] = []; + const result = findMatchingRules(rules, EMPTY_CONTEXT); + expect(result).toEqual([]); + }); + + describe('with independent rules', () => { + it('finds nothing if a single rule does not match', () => { + const rule: SwitchCaseRule = { + type: 'if-typeof', + value: 'http://schema.org/Recipe', + }; + const context = { + ...EMPTY_CONTEXT, + types: [type('http://vocab.example.org/OtherType')], + }; + const rules: TestRules[] = [{ rule }]; + const result = findMatchingRules(rules, context); + expect(result).toEqual([]); + }); + + it('finds a single matching rule', () => { + const rule: SwitchCaseRule = { + type: 'if-typeof', + value: 'http://schema.org/Recipe', + }; + const context = { + ...EMPTY_CONTEXT, + types: [type('http://schema.org/Recipe')], + }; + const rules: TestRules[] = [{ rule }]; + const result = findMatchingRules(rules, context); + expect(result).toEqual(rules); + }); + + it('finds all rules if all match', () => { + const ifTypeOfRecipe: SwitchCaseRule = { + type: 'if-typeof', + value: 'http://schema.org/Recipe', + }; + const ifPropertyName: SwitchCaseRule = { + type: 'if-property', + value: 'http://schema.org/name', + }; + const ifReverseOfRecipeRelation: SwitchCaseRule = { + type: 'if-rev', + value: 'http://schema.org/recipe', + }; + const context = { + ...EMPTY_CONTEXT, + types: [type('http://schema.org/Recipe')], + literals: [literal('http://schema.org/name')], + reverseRelations: [relation('http://schema.org/recipe')], + }; + const rules: TestRules[] = [ + { rule: ifTypeOfRecipe }, + { rule: ifPropertyName }, + { rule: ifReverseOfRecipeRelation }, + ]; + const result = findMatchingRules(rules, context); + expect(result).toEqual(rules); + }); + + it('does not find the rules that do not match', () => { + const ifTypeOfRecipe: SwitchCaseRule = { + type: 'if-typeof', + value: 'http://schema.org/Recipe', + }; + const ifPropertyName: SwitchCaseRule = { + type: 'if-property', + value: 'http://schema.org/name', + }; + const ifReverseOfRecipeRelation: SwitchCaseRule = { + type: 'if-rev', + value: 'http://schema.org/recipe', + }; + const context = { + ...EMPTY_CONTEXT, + types: [type('http://schema.org/Recipe')], + literals: [literal('https://vocab.example.org/other-name')], + reverseRelations: [relation('http://schema.org/recipe')], + }; + const rules: TestRules[] = [ + { rule: ifTypeOfRecipe }, + { rule: ifPropertyName }, + { rule: ifReverseOfRecipeRelation }, + ]; + const result = findMatchingRules(rules, context); + expect(result).toEqual([{ rule: ifTypeOfRecipe }, { rule: ifReverseOfRecipeRelation }]); + }); + }); + + describe('with if-else rules', () => { + it('matches the if branch only', () => { + const ifRecipe: SwitchCaseRule = { + type: 'if-typeof', + value: 'http://schema.org/Recipe', + }; + const elseIfBook: SwitchCaseRule = { + type: 'if-typeof', + value: 'http://schema.org/Book', + else: true, + }; + const context = { + ...EMPTY_CONTEXT, + types: [type('http://schema.org/Recipe')], + }; + const rules: TestRules[] = [{ rule: ifRecipe }, { rule: elseIfBook }]; + const result = findMatchingRules(rules, context); + expect(result).toEqual([{ rule: ifRecipe }]); + }); + + it('ignores a matching if-else branch, if first rule already matched', () => { + const ifTypeA: SwitchCaseRule = { + type: 'if-typeof', + value: 'https://vocab.example/A', + }; + const elseIfTypeB: SwitchCaseRule = { + type: 'if-typeof', + value: 'https://vocab.example/B', + else: true, + }; + const context = { + ...EMPTY_CONTEXT, + types: [type('https://vocab.example/A'), type('https://vocab.example/B')], + }; + const rules: TestRules[] = [{ rule: ifTypeA }, { rule: elseIfTypeB }]; + const result = findMatchingRules(rules, context); + expect(result).toEqual([{ rule: ifTypeA }]); + }); + + it('ignores a matching else branch, if first rule already matched', () => { + const ifTypeA: IfTypeofRule = { + type: 'if-typeof', + value: 'https://vocab.example/A', + }; + const context = { + ...EMPTY_CONTEXT, + types: [type('https://vocab.example/A')], + }; + const rules: TestRules[] = [{ rule: ifTypeA }, { rule: ELSE_RULE }]; + const result = findMatchingRules(rules, context); + expect(result).toEqual([{ rule: ifTypeA }]); + }); + + it('falls back to a matching if-else branch, if first rule did not match', () => { + const ifTypeA: SwitchCaseRule = { + type: 'if-typeof', + value: 'https://vocab.example/A', + }; + const elseIfTypeB: SwitchCaseRule = { + type: 'if-typeof', + value: 'https://vocab.example/B', + else: true, + }; + const context = { + ...EMPTY_CONTEXT, + types: [type('https://vocab.example/B')], + }; + const rules: TestRules[] = [{ rule: ifTypeA }, { rule: elseIfTypeB }]; + const result = findMatchingRules(rules, context); + expect(result).toEqual([{ rule: elseIfTypeB }]); + }); + + it('matches both, the first (independent) if rule and the else of the following if', () => { + /* + GIVEN + if-typeof Person + if-property name + else if-property label // the else belongs to the preceding if, not to the first if + */ + const ifPerson: SwitchCaseRule = { + type: 'if-typeof', + value: 'http://schema.org/Person', + }; + const ifHasName: SwitchCaseRule = { + type: 'if-property', + value: 'http://schema.org/name', + }; + const elseIfHasLabel: SwitchCaseRule = { + type: 'if-property', + value: 'http://www.w3.org/2000/01/rdf-schema#label', + else: true, + }; + const context = { + ...EMPTY_CONTEXT, + types: [type('http://schema.org/Person')], + literals: [literal('http://www.w3.org/2000/01/rdf-schema#label')], + }; + const rules: TestRules[] = [{ rule: ifPerson }, { rule: ifHasName }, { rule: elseIfHasLabel }]; + const result = findMatchingRules(rules, context); + expect(result).toEqual([{ rule: ifPerson }, { rule: elseIfHasLabel }]); + }); + + describe('if -> else if -> else', () => { + /* + GIVEN + if-property name + else if-property label + else + */ + const ifHasName: TestRules = { + rule: { + type: 'if-property', + value: 'http://schema.org/name', + }, + }; + const elseIfHasLabel: TestRules = { + rule: { + type: 'if-property', + value: 'http://www.w3.org/2000/01/rdf-schema#label', + else: true, + }, + }; + const _else = { rule: ELSE_RULE }; + const rules: TestRules[] = [ifHasName, elseIfHasLabel, _else]; + + it('if name is present, first rule matches', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://schema.org/name')], + }; + const result = findMatchingRules(rules, context); + expect(result).toEqual([ifHasName]); + }); + + it('if no name, but label is present, second rule matches', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://www.w3.org/2000/01/rdf-schema#label')], + }; + const result = findMatchingRules(rules, context); + expect(result).toEqual([elseIfHasLabel]); + }); + it('if neither name nor label are present, else rule matches', () => { + const context = { + ...EMPTY_CONTEXT, + }; + const result = findMatchingRules(rules, context); + expect(result).toEqual([_else]); + }); + it('if name and label are present, first rule matches', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://schema.org/name'), literal('http://www.w3.org/2000/01/rdf-schema#label')], + }; + const result = findMatchingRules(rules, context); + expect(result).toEqual([ifHasName]); + }); + }); + + describe('if -> else, if -> else', () => { + /* + GIVEN + if-property name + else + if-property image + else + */ + const ifHasName: TestRules = { + rule: { + type: 'if-property', + value: 'http://schema.org/name', + }, + }; + const else_name = { + rule: ELSE_RULE, + id: 1, // id is just a discriminator for the test, a real else cae would contain the element to render + }; + const ifHasImage: TestRules = { + rule: { + type: 'if-property', + value: 'http://schema.org/image', + }, + }; + const else_image = { rule: ELSE_RULE, id: 2 }; + + const rules: TestRules[] = [ifHasName, else_name, ifHasImage, else_image]; + it('if name is present, first if and last else match', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://schema.org/name')], + }; + const result = findMatchingRules(rules, context); + expect(result).toEqual([ifHasName, else_image]); + }); + it('if name and image are present, both if rules match', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://schema.org/name'), literal('http://schema.org/image')], + }; + const result = findMatchingRules(rules, context); + expect(result).toEqual([ifHasName, ifHasImage]); + }); + it('if label and picture are present, both else rules match', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://www.w3.org/2000/01/rdf-schema#label'), literal('http://schema.org/picture')], + }; + const result = findMatchingRules(rules, context); + expect(result).toEqual([else_name, else_image]); + }); + it('if name and picture are present the first if and second else match', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://schema.org/name'), literal('http://schema.org/picture')], + }; + const result = findMatchingRules(rules, context); + expect(result).toEqual([ifHasName, else_image]); + }); + it('if label and image are present the first else and second if match', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://www.w3.org/2000/01/rdf-schema#label'), literal('http://schema.org/image')], + }; + const result = findMatchingRules(rules, context); + expect(result).toEqual([else_name, ifHasImage]); + }); + it('if all are present, all if rules but no else matches', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [ + literal('http://schema.org/name'), + literal('http://schema.org/image'), + literal('http://www.w3.org/2000/01/rdf-schema#label'), + literal('http://schema.org/picture'), + ], + }; + const result = findMatchingRules(rules, context); + expect(result).toEqual([ifHasName, ifHasImage]); + }); + }); + + describe('if -> else if, if -> else if', () => { + /* + GIVEN + if-property name + else if-property label + if-property image + else if-property picture + */ + const ifHasName: TestRules = { + rule: { + type: 'if-property', + value: 'http://schema.org/name', + }, + }; + const elseIfHasLabel: TestRules = { + rule: { + type: 'if-property', + value: 'http://www.w3.org/2000/01/rdf-schema#label', + else: true, + }, + }; + const ifHasImage: TestRules = { + rule: { + type: 'if-property', + value: 'http://schema.org/image', + }, + }; + const elseIfHasPicture: TestRules = { + rule: { + type: 'if-property', + value: 'http://schema.org/picture', + else: true, + }, + }; + const rules: TestRules[] = [ifHasName, elseIfHasLabel, ifHasImage, elseIfHasPicture]; + it('if name is present, first rule matches', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://schema.org/name')], + }; + const result = findMatchingRules(rules, context); + expect(result).toEqual([ifHasName]); + }); + it('if name and image are present, both if rules match', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://schema.org/name'), literal('http://schema.org/image')], + }; + const result = findMatchingRules(rules, context); + expect(result).toEqual([ifHasName, ifHasImage]); + }); + it('if label and picture are present, both else-if rules match', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://www.w3.org/2000/01/rdf-schema#label'), literal('http://schema.org/picture')], + }; + const result = findMatchingRules(rules, context); + expect(result).toEqual([elseIfHasLabel, elseIfHasPicture]); + }); + it('if name and picture are present the first if and second else match', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://schema.org/name'), literal('http://schema.org/picture')], + }; + const result = findMatchingRules(rules, context); + expect(result).toEqual([ifHasName, elseIfHasPicture]); + }); + it('if label and image are present the first elseif and second else match', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://www.w3.org/2000/01/rdf-schema#label'), literal('http://schema.org/image')], + }; + const result = findMatchingRules(rules, context); + expect(result).toEqual([elseIfHasLabel, ifHasImage]); + }); + it('if all are present, all if rules but no else matches', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [ + literal('http://schema.org/name'), + literal('http://schema.org/image'), + literal('http://www.w3.org/2000/01/rdf-schema#label'), + literal('http://schema.org/picture'), + ], + }; + const result = findMatchingRules(rules, context); + expect(result).toEqual([ifHasName, ifHasImage]); + }); + }); + + describe('negations', () => { + describe('if not -> else if not -> else', () => { + /* + GIVEN + not if-property name + else not if-property label + else + */ + const ifNotHasName: TestRules = { + rule: { + type: 'if-property', + value: 'http://schema.org/name', + not: true, + }, + }; + const elseIfNotHasLabel: TestRules = { + rule: { + type: 'if-property', + value: 'http://www.w3.org/2000/01/rdf-schema#label', + not: true, + else: true, + }, + }; + const _else = { rule: ELSE_RULE }; + const rules: TestRules[] = [ifNotHasName, elseIfNotHasLabel, _else]; + + it('if only name is present, else-if matches', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://schema.org/name')], + }; + const result = findMatchingRules(rules, context); + expect(result).toEqual([elseIfNotHasLabel]); + }); + + it('if only label is present, first if matches', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://www.w3.org/2000/01/rdf-schema#label')], + }; + const result = findMatchingRules(rules, context); + expect(result).toEqual([ifNotHasName]); + }); + + it('if nothing is present, first if matches', () => { + const context = { + ...EMPTY_CONTEXT, + }; + const result = findMatchingRules(rules, context); + expect(result).toEqual([ifNotHasName]); + }); + + it('if both name and label are present, else matches', () => { + const context = { + ...EMPTY_CONTEXT, + literals: [literal('http://schema.org/name'), literal('http://www.w3.org/2000/01/rdf-schema#label')], + }; + const result = findMatchingRules(rules, context); + expect(result).toEqual([_else]); + }); + }); + }); + + it('falls back to plain else branch, if first rule did not match', () => { + const ifTypeA: SwitchCaseRule = { + type: 'if-typeof', + value: 'https://vocab.example/A', + }; + const context = { + ...EMPTY_CONTEXT, + types: [type('https://vocab.example/B')], + }; + const rules: TestRules[] = [{ rule: ifTypeA }, { rule: ELSE_RULE }]; + const result = findMatchingRules(rules, context); + expect(result).toEqual([{ rule: ELSE_RULE }]); + }); + }); +}); + +function type(uri: string) { + return { + uri, + } as RdfType; +} + +function literal(predicate: string) { + return { + predicate, + } as Literal; +} + +function relation(predicate: string) { + return { + predicate, + } as Relation; +} diff --git a/elements/src/components/pos-switch/rules/index.ts b/elements/src/components/pos-switch/rules/index.ts new file mode 100644 index 00000000..fd1d464f --- /dev/null +++ b/elements/src/components/pos-switch/rules/index.ts @@ -0,0 +1,141 @@ +import { Literal, RdfType, Relation } from '@pod-os/core'; + +export { findMatchingRules } from './findMatchingRules'; + +/** + * A rule that matches if the resource is of a specific type + */ +export interface IfTypeofRule { + type: 'if-typeof'; + /** + * The URI of the type to match against. + */ + value: string; + /** + * Negate the rule (match if the resource is not of the specified type) + */ + not?: boolean; + /** + * Only apply the rule if no predecessor matched + */ + else?: boolean; +} + +/** + * A rule that matches if the resource has a specific property (literal or relation) + */ +export interface IfPropertyRule { + type: 'if-property'; + /** + * The value of the property to match against (either literal value or the URI of a relation) + */ + value: string; + /** + * Negate the rule (match if the resource does not have the specified property) + */ + not?: boolean; + /** + * Only apply the rule if no predecessor matched + */ + else?: boolean; + /** + * If set, the rule only matches if the value of the property matches the specified comparison + */ + comparison?: Comparison; +} + +/** + * A rule that matches if the resource has a specific reverse relation + */ +export interface IfRevRule { + type: 'if-rev'; + /** + * The URI of the reverse relation to match against. + */ + value: string; + /** + * Negate the rule (match if the resource does not have the specified reverse relation) + */ + not?: boolean; + /** + * Only apply the rule if no predecessor matched + */ + else?: boolean; + /** + * If set, the rule only matches if the URI of the reverse relation matches the specified comparison + */ + comparison?: Comparison; +} + +/** + * A rule that only matches if all others did not + */ +export interface ElseRule { + type: 'else'; + not: false; + else: true; +} + +/** + * A rule that never matches + */ +export interface NeverRule { + type: 'never'; + not: false; + else: false; +} + +/** + * A rule defined by a pos-case + */ +export type SwitchCaseRule = IfTypeofRule | IfPropertyRule | IfRevRule | ElseRule | NeverRule; + +export const NEVER_RULE: NeverRule = { + type: 'never', + not: false, + else: false, +}; + +export const ELSE_RULE: ElseRule = { + type: 'else', + not: false, + else: true, +}; + +/** + * Contains the actual data a rule is matched against + */ +export interface RuleContext { + types: RdfType[]; + literals: Literal[]; + relations: Relation[]; + reverseRelations: Relation[]; +} + +/** + * Defines how to compare values to a specific target value + */ +export interface Comparison { + /** + * Does every value need to match, or only some? + */ + semantic: Semantic; + /** + * The comparison operator to apply (e.g. value must be equal, greater than, etc.) + */ + operator: Operator; + /** + * The target value to compare against. + */ + target: string; +} + +export type equals = 'eq'; +export type greaterThan = 'gt'; +export type greaterThanOrEqual = 'gte'; +export type lessThan = 'lt'; +export type lessThanOrEqual = 'lte'; +export type some = 'some'; +export type every = 'every'; +export type Operator = equals | greaterThan | greaterThanOrEqual | lessThan | lessThanOrEqual; +export type Semantic = some | every; diff --git a/elements/src/test/msw.ts b/elements/src/test/msw.ts index c5aad3b6..ec9d290b 100644 --- a/elements/src/test/msw.ts +++ b/elements/src/test/msw.ts @@ -11,6 +11,8 @@ export function turtleFile(url: string, content: string, { delayedUntil = Promis return http.get(url, async () => { const response = HttpResponse.text(content); response.headers.set('Content-Type', 'text/turtle'); + response.headers.set('accept-patch', 'text/n3, application/sparql-update'); + response.headers.set('wac-allow', 'user="append control read write",public="append control read write"'); await delayedUntil; return response; }); diff --git a/elements/vitest.config.ts b/elements/vitest.config.ts index 3e94e0b0..f41090b3 100644 --- a/elements/vitest.config.ts +++ b/elements/vitest.config.ts @@ -38,6 +38,18 @@ export default defineVitestConfig({ }, { // integration tests against a built bundle (elements including core) running in dom environment + oxc: { + // vitest prints a warning: + // > Both esbuild and oxc options were set. oxc options will be used and esbuild options will be ignored. The following esbuild options were set: `{ jsxFactory: 'h', jsxFragment: 'Fragment' }` + // so osx is used, but it does not have jsx options set correctly, which leads to renderings like `__self="[object global]"` and `__source="[object Object]"` in tests + // we are setting the jsx options for oxc here to fix that + jsx: { + runtime: 'classic', + pragma: 'h', + pragmaFrag: 'Fragment', + development: false, // do not render development props like __self and __source + }, + }, test: { setupFiles: ['vitest/setup-spec.ts', 'vitest/setup-integration.ts'], name: 'integration-dom', diff --git a/package-lock.json b/package-lock.json index d16a5301..7bc49547 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1475,10 +1475,11 @@ }, "devDependencies": { "@happy-dom/jest-environment": "^20.9.0", - "@stencil/vitest": "^1.12.1", + "@stencil/vitest": "^1.13.3", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@types/jest": "^30.0.0", + "@vitest/coverage-v8": "^4.1.10", "happy-dom": "^20.10.2", "jest": "^30.3.0", "jest-cli": "^30.3.0", @@ -1490,6 +1491,40 @@ "vitest-when": "^0.10.0" } }, + "elements/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "elements/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "elements/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "elements/node_modules/@jest/console": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.3.0.tgz", @@ -1780,6 +1815,310 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "elements/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "elements/node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "elements/node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "elements/node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "elements/node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "elements/node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "elements/node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "elements/node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "elements/node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "elements/node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "elements/node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "elements/node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "elements/node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "elements/node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "elements/node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "elements/node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "elements/node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "elements/node_modules/@sinclair/typebox": { "version": "0.34.41", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", @@ -1798,15 +2137,15 @@ } }, "elements/node_modules/@stencil/vitest": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@stencil/vitest/-/vitest-1.12.1.tgz", - "integrity": "sha512-5ke83kEpmyM0t+YUE1VSrAf608G5ZhliSWdOWehJ6sk0E9SnV8KNWoqBaEEDgUcordHF3z//uOrry5pmIBhl2Q==", + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/@stencil/vitest/-/vitest-1.13.3.tgz", + "integrity": "sha512-dWpmN9KPRLZpA6wGJ+46z8wjxrAErqbKEmgKLUIC/Ke/VURMzM1Oh/8F03tmC1qVba3KqAjnO6gpuxMOt/90yw==", "dev": true, "license": "MIT", "dependencies": { "jiti": "^2.6.1", "local-pkg": "^1.1.2", - "vitest-environment-stencil": "1.12.1" + "vitest-environment-stencil": "1.13.3" }, "bin": { "stencil-test": "dist/bin/stencil-test.js" @@ -1943,14 +2282,73 @@ "pretty-format": "^30.0.0" } }, + "elements/node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "elements/node_modules/@vitest/coverage-v8/node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "elements/node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "elements/node_modules/@vitest/mocker": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", - "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.8", + "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1970,6 +2368,74 @@ } } }, + "elements/node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "elements/node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "elements/node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "elements/node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "elements/node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "elements/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", @@ -2712,9 +3178,9 @@ } }, "elements/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -2763,6 +3229,40 @@ "dev": true, "license": "MIT" }, + "elements/node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, "elements/node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -2806,16 +3306,16 @@ } }, "elements/node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", "tinyglobby": "^0.2.17" }, "bin": { @@ -2832,7 +3332,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -2884,19 +3384,19 @@ } }, "elements/node_modules/vitest": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", - "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.8", - "@vitest/mocker": "4.1.8", - "@vitest/pretty-format": "4.1.8", - "@vitest/runner": "4.1.8", - "@vitest/snapshot": "4.1.8", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -2924,12 +3424,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.8", - "@vitest/browser-preview": "4.1.8", - "@vitest/browser-webdriverio": "4.1.8", - "@vitest/coverage-istanbul": "4.1.8", - "@vitest/coverage-v8": "4.1.8", - "@vitest/ui": "4.1.8", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -9625,9 +10125,9 @@ "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.30", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", - "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "dependencies": { @@ -16622,9 +17122,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -18869,6 +19369,35 @@ "node": ">=18" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/async": { "version": "3.2.5", "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", @@ -22349,9 +22878,9 @@ } }, "node_modules/exsolve": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", - "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz", + "integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==", "dev": true, "license": "MIT" }, @@ -25098,10 +25627,11 @@ } }, "node_modules/istanbul-reports": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", - "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" @@ -27553,6 +28083,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -32634,9 +33176,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", "dev": true, "funding": [ { @@ -37573,28 +38115,368 @@ } }, "node_modules/vitest-environment-stencil": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/vitest-environment-stencil/-/vitest-environment-stencil-1.12.1.tgz", - "integrity": "sha512-38mjt32BcjhT08SdVkQlTS1VhQe6HTFdVk0n8PETATxVEyeLPahihKOMmjhHRwzc7xfWsP+nW04NqvPobtQNJg==", + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/vitest-environment-stencil/-/vitest-environment-stencil-1.13.3.tgz", + "integrity": "sha512-dOCA04cVraAJLjCPx6jWAdjoFK/KaMeCAdUkS/K8O8aC869GTy3gJi7WaRKB4NCYaCRLtf9GIlJNVtCegVhwNQ==", "dev": true, "license": "MIT", "dependencies": { - "@stencil/vitest": "1.12.1" + "@stencil/vitest": "1.13.3" }, "peerDependencies": { "@stencil/core": "^4.0.0 || ^5.0.0-0" } }, + "node_modules/vitest-environment-stencil/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/vitest-environment-stencil/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/vitest-environment-stencil/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/vitest-environment-stencil/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/vitest-environment-stencil/node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/vitest-environment-stencil/node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vitest-environment-stencil/node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vitest-environment-stencil/node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vitest-environment-stencil/node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vitest-environment-stencil/node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vitest-environment-stencil/node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vitest-environment-stencil/node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vitest-environment-stencil/node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vitest-environment-stencil/node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vitest-environment-stencil/node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vitest-environment-stencil/node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vitest-environment-stencil/node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vitest-environment-stencil/node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vitest-environment-stencil/node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vitest-environment-stencil/node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/vitest-environment-stencil/node_modules/@stencil/vitest": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@stencil/vitest/-/vitest-1.12.1.tgz", - "integrity": "sha512-5ke83kEpmyM0t+YUE1VSrAf608G5ZhliSWdOWehJ6sk0E9SnV8KNWoqBaEEDgUcordHF3z//uOrry5pmIBhl2Q==", + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/@stencil/vitest/-/vitest-1.13.3.tgz", + "integrity": "sha512-dWpmN9KPRLZpA6wGJ+46z8wjxrAErqbKEmgKLUIC/Ke/VURMzM1Oh/8F03tmC1qVba3KqAjnO6gpuxMOt/90yw==", "dev": true, "license": "MIT", "dependencies": { "jiti": "^2.6.1", "local-pkg": "^1.1.2", - "vitest-environment-stencil": "1.12.1" + "vitest-environment-stencil": "1.13.3" }, "bin": { "stencil-test": "dist/bin/stencil-test.js" @@ -37636,15 +38518,34 @@ } } }, + "node_modules/vitest-environment-stencil/node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vitest-environment-stencil/node_modules/@vitest/mocker": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", - "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@vitest/spy": "4.1.8", + "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -37664,10 +38565,83 @@ } } }, + "node_modules/vitest-environment-stencil/node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest-environment-stencil/node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest-environment-stencil/node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest-environment-stencil/node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest-environment-stencil/node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vitest-environment-stencil/node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", "dev": true, "license": "MIT", "peer": true @@ -37684,9 +38658,9 @@ } }, "node_modules/vitest-environment-stencil/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "peer": true, @@ -37697,18 +38671,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest-environment-stencil/node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, "node_modules/vitest-environment-stencil/node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", "tinyglobby": "^0.2.17" }, "bin": { @@ -37725,7 +38734,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -37777,20 +38786,20 @@ } }, "node_modules/vitest-environment-stencil/node_modules/vitest": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", - "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@vitest/expect": "4.1.8", - "@vitest/mocker": "4.1.8", - "@vitest/pretty-format": "4.1.8", - "@vitest/runner": "4.1.8", - "@vitest/snapshot": "4.1.8", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -37818,12 +38827,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.8", - "@vitest/browser-preview": "4.1.8", - "@vitest/browser-webdriverio": "4.1.8", - "@vitest/coverage-istanbul": "4.1.8", - "@vitest/coverage-v8": "4.1.8", - "@vitest/ui": "4.1.8", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/storybook/stories/11_pos-switch.stories.mdx b/storybook/stories/11_pos-switch.stories.mdx index 512f35b9..36191e1d 100644 --- a/storybook/stories/11_pos-switch.stories.mdx +++ b/storybook/stories/11_pos-switch.stories.mdx @@ -13,7 +13,11 @@ import { ## pos-switch -Selects templates to render based on properties of the subject uri +Selects templates to render based on properties of the subject uri. + +See [pos-case](https://pod-os.org/reference/elements/components/pos-switch/pos-case/) for filter conditions. +Conditions including testing based on type, if/else logic, presence of forward (`if-property`) and backward (`if-rev`) links, +and whether some/every value linked by `if-property` or `if-rev` is equal to/greater than/less than the given attribute. @@ -39,6 +43,21 @@ Selects templates to render based on properties of the subject uri Primary topic of: + + + + + + + + + `} diff --git a/storybook/stories/composition/3_pos-switch-composition.stories.mdx b/storybook/stories/composition/3_pos-switch-composition.stories.mdx index 1522e19b..c49fa3a0 100644 --- a/storybook/stories/composition/3_pos-switch-composition.stories.mdx +++ b/storybook/stories/composition/3_pos-switch-composition.stories.mdx @@ -36,6 +36,11 @@ import { + + +