diff --git a/extension.json b/extension.json index decb74b5f..2e28dd051 100644 --- a/extension.json +++ b/extension.json @@ -302,6 +302,7 @@ "CdxToggleSwitch", "CdxToggleButtonGroup", "CdxLookup", + "CdxCombobox", "CdxTable", "CdxInfoChip" ], @@ -412,7 +413,7 @@ "neowiki-subject-creator-schema-title", "neowiki-subject-creator-existing-schema", "neowiki-subject-creator-new-schema", - "neowiki-subject-creator-schema-search-placeholder", + "neowiki-schema-picker-placeholder", "neowiki-schema-display-property-name", "neowiki-schema-display-property-type", "neowiki-schema-display-property-required", @@ -502,7 +503,6 @@ "neowiki-layout-creator-name-required", "neowiki-layout-creator-name-taken", "neowiki-layout-creator-schema-field", - "neowiki-layout-creator-schema-placeholder", "neowiki-layout-creator-view-type-field", "neowiki-layout-creator-view-type-placeholder", "neowiki-layout-creator-save", diff --git a/i18n/en.json b/i18n/en.json index aef3b2956..3e457ec7b 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -103,7 +103,7 @@ "neowiki-subject-creator-schema-title": "Have an existing schema?", "neowiki-subject-creator-existing-schema": "Use existing", "neowiki-subject-creator-new-schema": "Create new", - "neowiki-subject-creator-schema-search-placeholder": "Search for a schema", + "neowiki-schema-picker-placeholder": "Select a schema", "neowiki-subject-creator-label-field": "Subject label", "neowiki-subject-creator-label-placeholder": "Enter a label for the subject", "neowiki-subject-creator-save": "Create subject", @@ -234,7 +234,6 @@ "neowiki-layout-creator-name-required": "Please enter a layout name.", "neowiki-layout-creator-name-taken": "A layout with this name already exists.", "neowiki-layout-creator-schema-field": "Schema", - "neowiki-layout-creator-schema-placeholder": "Select a schema", "neowiki-layout-creator-view-type-field": "View type", "neowiki-layout-creator-view-type-placeholder": "Select a view type", "neowiki-layout-creator-save": "Create layout", diff --git a/i18n/qqq.json b/i18n/qqq.json index fc292cf9f..fdb44cec5 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -52,6 +52,7 @@ "neowiki-subject-editor-error": "Error notification title shown when updating a subject fails. $1 is the subject label.", "neowiki-subject-editor-validation-failed": "Toast title shown when the backend rejects a Subject save under enforcement. $1 is the Subject label.", "neowiki-subject-lookup-placeholder": "Placeholder text shown in the subject search input field.", + "neowiki-schema-picker-placeholder": "Placeholder text shown in the schema picker input field.", "neowiki-subject-lookup-no-results": "Message shown in the subject lookup dropdown when no subjects match the search query.", "neowiki-subject-lookup-no-match": "Error message shown in the subject lookup when the user types text but does not select a subject from the dropdown results.", @@ -138,7 +139,6 @@ "neowiki-layout-creator-name-required": "Error shown when the layout name field is empty in the layout creation dialog.", "neowiki-layout-creator-name-taken": "Error shown when a layout with the entered name already exists in the layout creation dialog.", "neowiki-layout-creator-schema-field": "Label for the schema selector in the layout creation dialog.", - "neowiki-layout-creator-schema-placeholder": "Placeholder text for the schema selector in the layout creation dialog.", "neowiki-layout-creator-view-type-field": "Label for the view type selector in the layout creation dialog.", "neowiki-layout-creator-view-type-placeholder": "Placeholder text for the view type selector in the layout creation dialog.", "neowiki-layout-creator-save": "Label for the save button in the layout creation dialog.", diff --git a/resources/ext.neowiki/src/application/SchemaLookup.ts b/resources/ext.neowiki/src/application/SchemaLookup.ts index a98fb395e..670d9adda 100644 --- a/resources/ext.neowiki/src/application/SchemaLookup.ts +++ b/resources/ext.neowiki/src/application/SchemaLookup.ts @@ -1,9 +1,21 @@ import type { Schema, SchemaName } from '@/domain/Schema'; +export interface SchemaSummary { + name: string; + description: string; + propertyCount: number; +} + +export interface SchemaSummaryPage { + schemas: SchemaSummary[]; + totalRows: number; +} + export interface SchemaLookup { getSchema( schemaName: SchemaName ): Promise; getSchemaNames( search: string ): Promise; + getSchemaSummaries( offset: number, limit: number ): Promise; } @@ -31,6 +43,19 @@ export class InMemorySchemaLookup implements SchemaLookup { return [ ...this.schemaNames ]; } + public async getSchemaSummaries( offset: number, limit: number ): Promise { + const summaries = [ ...this.schemas.values() ].map( ( schema ) => ( { + name: schema.getName(), + description: schema.getDescription(), + propertyCount: [ ...schema.getPropertyDefinitions() ].length, + } ) ); + + return { + schemas: summaries.slice( offset, offset + limit ), + totalRows: summaries.length, + }; + } + public clearSchemas(): void { this.schemas.clear(); } diff --git a/resources/ext.neowiki/src/components/LayoutsPage/LayoutCreator.vue b/resources/ext.neowiki/src/components/LayoutsPage/LayoutCreator.vue index 99c86df72..544f9ad86 100644 --- a/resources/ext.neowiki/src/components/LayoutsPage/LayoutCreator.vue +++ b/resources/ext.neowiki/src/components/LayoutsPage/LayoutCreator.vue @@ -16,11 +16,9 @@ - diff --git a/resources/ext.neowiki/src/components/common/SchemaPicker.vue b/resources/ext.neowiki/src/components/common/SchemaPicker.vue new file mode 100644 index 000000000..cb86b84d7 --- /dev/null +++ b/resources/ext.neowiki/src/components/common/SchemaPicker.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/resources/ext.neowiki/src/persistence/RestSchemaRepository.ts b/resources/ext.neowiki/src/persistence/RestSchemaRepository.ts index c135c3171..ef772ae8d 100644 --- a/resources/ext.neowiki/src/persistence/RestSchemaRepository.ts +++ b/resources/ext.neowiki/src/persistence/RestSchemaRepository.ts @@ -1,6 +1,7 @@ import { Schema, type SchemaName } from '@/domain/Schema'; import type { HttpClient } from '@/infrastructure/HttpClient/HttpClient'; import type { SchemaRepository } from '@/application/SchemaRepository'; +import type { SchemaSummaryPage } from '@/application/SchemaLookup'; import { SchemaSerializer } from '@/persistence/SchemaSerializer.ts'; import { SchemaDeserializer } from '@/persistence/SchemaDeserializer.ts'; import { PageSaver } from '@/persistence/PageSaver.ts'; @@ -47,6 +48,18 @@ export class RestSchemaRepository implements SchemaRepository { return await response.json(); } + public async getSchemaSummaries( offset: number, limit: number ): Promise { + const response = await this.httpClient.get( + `${ this.mediaWikiRestApiUrl }/neowiki/v0/schemas?limit=${ limit }&offset=${ offset }`, + ); + + if ( !response.ok ) { + throw new Error( 'Error fetching schema summaries' ); + } + + return await response.json(); + } + public async saveSchema( schema: Schema, comment?: string ): Promise { const status = await this.pageSaver.savePage( `Schema:${ encodeURIComponent( schema.getName() ) }`, diff --git a/resources/ext.neowiki/src/public-api.ts b/resources/ext.neowiki/src/public-api.ts index af2de850d..0124dc654 100644 --- a/resources/ext.neowiki/src/public-api.ts +++ b/resources/ext.neowiki/src/public-api.ts @@ -128,7 +128,7 @@ export { default as SchemaEditorDialog } from './components/SchemaEditor/SchemaE export { default as SchemaCreatorDialog } from './components/SchemasPage/SchemaCreatorDialog.vue'; export { default as SchemasPage } from './components/SchemasPage/SchemasPage.vue'; export { default as SchemaAbandonmentDialog } from './components/SubjectCreator/SchemaAbandonmentDialog.vue'; -export { default as SchemaLookup } from './components/common/SchemaLookup.vue'; +export { default as SchemaPicker } from './components/common/SchemaPicker.vue'; export { default as SubjectCreatorDialog } from './components/SubjectCreator/SubjectCreatorDialog.vue'; export { default as SubjectEditor } from './components/SubjectEditor/SubjectEditor.vue'; export { default as SubjectEditorDialog } from './components/SubjectEditor/SubjectEditorDialog.vue'; diff --git a/resources/ext.neowiki/src/stores/SchemaStore.ts b/resources/ext.neowiki/src/stores/SchemaStore.ts index cb321b245..ebc3ddaee 100644 --- a/resources/ext.neowiki/src/stores/SchemaStore.ts +++ b/resources/ext.neowiki/src/stores/SchemaStore.ts @@ -1,6 +1,7 @@ import { defineStore } from 'pinia'; import { Schema } from '@/domain/Schema.ts'; import { NeoWikiExtension } from '@/NeoWikiExtension.ts'; +import type { SchemaSummary } from '@/application/SchemaLookup.ts'; /** * Approximates MediaWiki title normalisation for a Schema name (schemas are @@ -16,6 +17,7 @@ export function normalizeSchemaName( name: string ): string { export const useSchemaStore = defineStore( 'schema', { state: () => ( { schemas: new Map(), + allSummaries: null as SchemaSummary[] | null, } ), getters: { getSchemas: ( state ) => state.schemas, @@ -42,10 +44,32 @@ export const useSchemaStore = defineStore( 'schema', { } return this.getSchema( name ); }, - async searchAndFetchMissingSchemas( search: string ): Promise { - const schemaNames = await NeoWikiExtension.getInstance().getSchemaRepository().getSchemaNames( search ); - await Promise.all( schemaNames.map( ( name ) => this.getOrFetchSchema( name ) ) ); - return schemaNames; + // Loads every Schema summary (name + description) once and caches it so the + // schema picker can show the full list and filter client-side. The cache is + // cleared on saveSchema. Pages through the summaries endpoint (capped at 50). + async getAllSchemaSummaries(): Promise { + if ( this.allSummaries !== null ) { + return this.allSummaries; + } + + const repository = NeoWikiExtension.getInstance().getSchemaRepository(); + const pageSize = 50; + const summaries: SchemaSummary[] = []; + + const firstPage = await repository.getSchemaSummaries( 0, pageSize ); + summaries.push( ...firstPage.schemas ); + + // Page by request offset, not by loaded count: the endpoint counts every + // Schema page in totalRows but omits ones it cannot load (restricted or + // malformed), so advancing by summaries.length would re-request earlier + // names and duplicate entries. Stop once the offset passes the total. + for ( let offset = pageSize; offset < firstPage.totalRows; offset += pageSize ) { + const page = await repository.getSchemaSummaries( offset, pageSize ); + summaries.push( ...page.schemas ); + } + + this.allSummaries = summaries; + return summaries; }, // Checks existence via the schema-names search (a 200 response) rather // than getOrFetchSchema, which 404s for a missing name — those 404s are @@ -60,6 +84,7 @@ export const useSchemaStore = defineStore( 'schema', { async saveSchema( schema: Schema, comment?: string ): Promise { await NeoWikiExtension.getInstance().getSchemaRepository().saveSchema( schema, comment ); this.setSchema( schema.getName(), schema ); + this.allSummaries = null; }, }, } ); diff --git a/resources/ext.neowiki/tests/components/SchemaEditor/Property/RelationAttributesEditor.spec.ts b/resources/ext.neowiki/tests/components/SchemaEditor/Property/RelationAttributesEditor.spec.ts index 5da7be2e6..90517a3c1 100644 --- a/resources/ext.neowiki/tests/components/SchemaEditor/Property/RelationAttributesEditor.spec.ts +++ b/resources/ext.neowiki/tests/components/SchemaEditor/Property/RelationAttributesEditor.spec.ts @@ -7,9 +7,9 @@ import { PropertyName } from '@/domain/PropertyDefinition.ts'; import { AttributesEditorProps } from '@/components/SchemaEditor/Property/AttributesEditorContract.ts'; import { createI18nMock, FieldProps, setupMwMock } from '../../../VueTestHelpers.ts'; -const SchemaLookupStub = { +const SchemaPickerStub = { props: [ 'selected' ], - emits: [ 'select' ], + emits: [ 'select', 'blur' ], template: '
', }; @@ -37,7 +37,7 @@ describe( 'RelationAttributesEditor', () => { }, global: { mocks: { $i18n: createI18nMock() }, - stubs: { SchemaLookup: SchemaLookupStub }, + stubs: { SchemaPicker: SchemaPickerStub }, }, } ); } @@ -51,16 +51,24 @@ describe( 'RelationAttributesEditor', () => { const wrapper = newWrapper(); expect( wrapper.find( '.relation-attributes__relation' ).exists() ).toBe( true ); - expect( wrapper.findComponent( SchemaLookupStub ).exists() ).toBe( true ); + expect( wrapper.findComponent( SchemaPickerStub ).exists() ).toBe( true ); expect( wrapper.find( 'input[type="checkbox"]' ).exists() ).toBe( true ); } ); - it( 'passes the current target schema to SchemaLookup', () => { + it( 'passes the current target schema to SchemaPicker', () => { const wrapper = newWrapper( { property: relationProperty( { targetSchema: 'Office' } ), } ); - expect( wrapper.findComponent( SchemaLookupStub ).props( 'selected' ) ).toBe( 'Office' ); + expect( wrapper.findComponent( SchemaPickerStub ).props( 'selected' ) ).toBe( 'Office' ); + } ); + + it( 'passes null to SchemaPicker when no target schema is set', () => { + const wrapper = newWrapper( { + property: relationProperty( { targetSchema: '' } ), + } ); + + expect( wrapper.findComponent( SchemaPickerStub ).props( 'selected' ) ).toBe( null ); } ); it( 'displays the stored relation in the input', () => { @@ -78,6 +86,18 @@ describe( 'RelationAttributesEditor', () => { expect( wrapper.findComponent( CdxTextInput ).props( 'modelValue' ) ).toBe( 'Main product' ); } ); + + it( 'clears the displayed relation when the stored relation is emptied', async () => { + const wrapper = newWrapper( { + property: relationProperty( { relation: 'Has product' } ), + } ); + + await wrapper.setProps( { + property: relationProperty( { relation: '' } ), + } ); + + expect( wrapper.findComponent( CdxTextInput ).props( 'modelValue' ) ).toBe( '' ); + } ); } ); describe( 'relation default', () => { @@ -115,10 +135,18 @@ describe( 'RelationAttributesEditor', () => { expect( wrapper.emitted( 'update:property' )?.[ 0 ] ).toEqual( [ { relation: 'Owns' } ] ); } ); + it( 'emits an empty relation when the field is cleared', async () => { + const wrapper = newWrapper(); + + await wrapper.findComponent( CdxTextInput ).vm.$emit( 'update:modelValue', '' ); + + expect( wrapper.emitted( 'update:property' )?.[ 0 ] ).toEqual( [ { relation: '' } ] ); + } ); + it( 'emits targetSchema when the picker selects a schema', async () => { const wrapper = newWrapper(); - await wrapper.findComponent( SchemaLookupStub ).vm.$emit( 'select', 'Office' ); + await wrapper.findComponent( SchemaPickerStub ).vm.$emit( 'select', 'Office' ); expect( wrapper.emitted( 'update:property' )?.[ 0 ] ).toEqual( [ { targetSchema: 'Office' } ] ); } ); @@ -148,10 +176,9 @@ describe( 'RelationAttributesEditor', () => { const props = fieldProps( wrapper, '.relation-attributes__relation' ); expect( props.status ).toBe( 'error' ); expect( props.messages ).toEqual( { error: 'Relation type is required.' } ); - expect( wrapper.emitted( 'update:property' ) ).toBeFalsy(); } ); - it( 'treats a whitespace-only relation as required and does not emit it', async () => { + it( 'treats a whitespace-only relation as required', async () => { const wrapper = newWrapper(); await wrapper.findComponent( CdxTextInput ).vm.$emit( 'update:modelValue', ' ' ); @@ -159,14 +186,23 @@ describe( 'RelationAttributesEditor', () => { const props = fieldProps( wrapper, '.relation-attributes__relation' ); expect( props.status ).toBe( 'error' ); expect( props.messages ).toEqual( { error: 'Relation type is required.' } ); - expect( wrapper.emitted( 'update:property' ) ).toBeFalsy(); } ); - it( 'shows a required error when the target schema is empty', () => { + it( 'does not show the target schema error before the field is touched', () => { + const wrapper = newWrapper( { + property: relationProperty( { targetSchema: '' } ), + } ); + + expect( fieldProps( wrapper, '.relation-attributes__target-schema' ).status ).toBe( 'default' ); + } ); + + it( 'shows a required error after the empty target schema field is blurred', async () => { const wrapper = newWrapper( { property: relationProperty( { targetSchema: '' } ), } ); + await wrapper.findComponent( SchemaPickerStub ).vm.$emit( 'blur' ); + const props = fieldProps( wrapper, '.relation-attributes__target-schema' ); expect( props.status ).toBe( 'error' ); expect( props.messages ).toEqual( { error: 'Target schema is required.' } ); diff --git a/resources/ext.neowiki/tests/components/SubjectCreator/SubjectCreatorDialog.spec.ts b/resources/ext.neowiki/tests/components/SubjectCreator/SubjectCreatorDialog.spec.ts index 661091291..6f06d1d70 100644 --- a/resources/ext.neowiki/tests/components/SubjectCreator/SubjectCreatorDialog.spec.ts +++ b/resources/ext.neowiki/tests/components/SubjectCreator/SubjectCreatorDialog.spec.ts @@ -2,7 +2,7 @@ import { mount, VueWrapper, flushPromises } from '@vue/test-utils'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ref } from 'vue'; import SubjectCreatorDialog from '@/components/SubjectCreator/SubjectCreatorDialog.vue'; -import SchemaLookup from '@/components/common/SchemaLookup.vue'; +import SchemaPicker from '@/components/common/SchemaPicker.vue'; import SchemaCreator from '@/components/SchemaCreator/SchemaCreator.vue'; import EditSummary from '@/components/common/EditSummary.vue'; import { createPinia, setActivePinia } from 'pinia'; @@ -36,7 +36,7 @@ const NEW_SCHEMA_NAME = 'NewSchema'; vi.mock( '@/composables/useSchemaPermissions.ts' ); -const SchemaLookupStub = { +const SchemaPickerStub = { template: '
', emits: [ 'select' ], methods: { @@ -129,7 +129,7 @@ describe( 'SubjectCreatorDialog', () => { global: { plugins: [ pinia ], stubs: { - SchemaLookup: SchemaLookupStub, + SchemaPicker: SchemaPickerStub, SubjectEditor: SubjectEditorStub, SchemaCreator: SchemaCreatorStub, EditSummary: EditSummaryStub, @@ -246,7 +246,7 @@ describe( 'SubjectCreatorDialog', () => { expect( wrapper.find( '.schema-lookup-stub' ).exists() ).toBe( true ); expect( wrapper.find( '.cdx-toggle-button-group-stub' ).exists() ).toBe( true ); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); expect( wrapper.find( '.schema-lookup-stub' ).exists() ).toBe( false ); @@ -273,7 +273,7 @@ describe( 'SubjectCreatorDialog', () => { it( 'shows label input and SubjectEditor after schema selection', async () => { const wrapper = mountComponent(); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); expect( wrapper.find( '.cdx-text-input-stub' ).exists() ).toBe( true ); @@ -284,7 +284,7 @@ describe( 'SubjectCreatorDialog', () => { it( 'defaults label to page title', async () => { const wrapper = mountComponent(); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); const labelInput = wrapper.find( '.cdx-text-input-stub' ); @@ -294,7 +294,7 @@ describe( 'SubjectCreatorDialog', () => { it( 'calls createMainSubject on save with correct arguments', async () => { const wrapper = mountComponent(); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); await wrapper.findComponent( EditSummary ).vm.$emit( 'save', 'test summary' ); @@ -312,7 +312,7 @@ describe( 'SubjectCreatorDialog', () => { it( 'does not pass summary when it is empty', async () => { const wrapper = mountComponent(); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); await wrapper.findComponent( EditSummary ).vm.$emit( 'save', '' ); @@ -330,7 +330,7 @@ describe( 'SubjectCreatorDialog', () => { it( 'calls createChildSubject when the page already has a main subject', async () => { const wrapper = mountComponent( {}, { pageHasMainSubject: true } ); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); await wrapper.findComponent( EditSummary ).vm.$emit( 'save', 'test summary' ); @@ -351,7 +351,7 @@ describe( 'SubjectCreatorDialog', () => { subjectStore.openSubjectCreator(); await flushPromises(); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); await wrapper.findComponent( EditSummary ).vm.$emit( 'save', '' ); @@ -368,7 +368,7 @@ describe( 'SubjectCreatorDialog', () => { subjectStore.openSubjectCreator(); await flushPromises(); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); await wrapper.findComponent( EditSummary ).vm.$emit( 'save', '' ); @@ -386,7 +386,7 @@ describe( 'SubjectCreatorDialog', () => { it( 'does not save when label is empty', async () => { const wrapper = mountComponent(); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); const labelInput = wrapper.find( '.cdx-text-input-stub' ); @@ -413,7 +413,7 @@ describe( 'SubjectCreatorDialog', () => { expect( wrapper.find( '.ext-neowiki-subject-creator-continue' ).exists() ).toBe( true ); } ); - it( 'does not show SchemaLookup when "Create new" is selected', async () => { + it( 'does not show SchemaPicker when "Create new" is selected', async () => { const wrapper = mountComponent(); await switchToNewSchema( wrapper ); @@ -591,7 +591,7 @@ describe( 'SubjectCreatorDialog', () => { it( 'shows back button after selecting a schema', async () => { const wrapper = mountComponent(); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); expect( wrapper.find( '.ext-neowiki-subject-creator-back-button' ).exists() ).toBe( true ); @@ -600,7 +600,7 @@ describe( 'SubjectCreatorDialog', () => { it( 'returns to schema selection when back button is clicked', async () => { const wrapper = mountComponent(); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); expect( wrapper.find( '.subject-editor-stub' ).exists() ).toBe( true ); @@ -655,7 +655,7 @@ describe( 'SubjectCreatorDialog', () => { it( 'returns to schema selector when clicking back after selecting existing schema', async () => { const wrapper = mountComponent(); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); await wrapper.find( '.ext-neowiki-subject-creator-back-button' ).trigger( 'click' ); @@ -672,7 +672,7 @@ describe( 'SubjectCreatorDialog', () => { subjectStore.openSubjectCreator(); await flushPromises(); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); const labelInput = wrapper.find( '.cdx-text-input-stub' ); @@ -706,7 +706,7 @@ describe( 'SubjectCreatorDialog', () => { subjectStore.openSubjectCreator(); await flushPromises(); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); const labelInput = wrapper.find( '.cdx-text-input-stub' ); @@ -729,7 +729,7 @@ describe( 'SubjectCreatorDialog', () => { subjectStore.openSubjectCreator(); await flushPromises(); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); const labelInput = wrapper.find( '.cdx-text-input-stub' ); @@ -830,7 +830,7 @@ describe( 'SubjectCreatorDialog', () => { subjectStore.openSubjectCreator(); await flushPromises(); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); const labelInput = wrapper.find( '.cdx-text-input-stub' ); @@ -914,7 +914,7 @@ describe( 'SubjectCreatorDialog', () => { async function openSelectSchemaAndSave( wrapper: VueWrapper ): Promise { subjectStore.openSubjectCreator(); await flushPromises(); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); await wrapper.findComponent( EditSummary ).vm.$emit( 'save', '' ); await flushPromises(); @@ -1033,7 +1033,7 @@ describe( 'SubjectCreatorDialog', () => { subjectStore.openSubjectCreator(); await flushPromises(); // Re-select schema so SubjectEditor renders again - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); const after = wrapper.findComponent( SubjectEditor ).props( 'serverViolations' ) as SubjectViolation[]; @@ -1050,7 +1050,7 @@ describe( 'SubjectCreatorDialog', () => { }; async function selectSchema( wrapper: VueWrapper ): Promise { - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); } diff --git a/resources/ext.neowiki/tests/components/common/SchemaLookup.spec.ts b/resources/ext.neowiki/tests/components/common/SchemaLookup.spec.ts deleted file mode 100644 index f8eed5d4a..000000000 --- a/resources/ext.neowiki/tests/components/common/SchemaLookup.spec.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { mount, VueWrapper, flushPromises } from '@vue/test-utils'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import SchemaLookup from '@/components/common/SchemaLookup.vue'; -import { createPinia, setActivePinia } from 'pinia'; -import { useSchemaStore } from '@/stores/SchemaStore.ts'; -import { CdxLookup } from '@wikimedia/codex'; -import { createI18nMock } from '../../VueTestHelpers.ts'; -import { Schema } from '@/domain/Schema.ts'; -import { PropertyDefinitionList } from '@/domain/PropertyDefinitionList.ts'; - -const $i18n = createI18nMock(); - -describe( 'SchemaLookup', () => { - let pinia: ReturnType; - let schemaStore: any; - - const mountComponent = ( props: Record = {} ): VueWrapper => ( - mount( SchemaLookup, { - props, - global: { - mocks: { - $i18n, - }, - plugins: [ pinia ], - stubs: { - CdxLookup: true, - }, - }, - } ) - ); - - beforeEach( () => { - pinia = createPinia(); - setActivePinia( pinia ); - - schemaStore = useSchemaStore(); - schemaStore.searchAndFetchMissingSchemas = vi.fn().mockResolvedValue( [] ); - } ); - - it( 'searches for schemas when input changes', () => { - const wrapper = mountComponent(); - const lookup = wrapper.findComponent( CdxLookup ); - - lookup.vm.$emit( 'input', 'test query' ); - - expect( schemaStore.searchAndFetchMissingSchemas ).toHaveBeenCalledWith( 'test query' ); - } ); - - it( 'updates menu items with search results', async () => { - const mockResults = [ 'Schema1', 'Schema2' ]; - schemaStore.searchAndFetchMissingSchemas.mockResolvedValue( mockResults ); - schemaStore.schemas.set( 'Schema1', new Schema( 'Schema1', 'First description', new PropertyDefinitionList( [] ) ) ); - schemaStore.schemas.set( 'Schema2', new Schema( 'Schema2', 'Second description', new PropertyDefinitionList( [] ) ) ); - - const wrapper = mountComponent(); - const lookup = wrapper.findComponent( CdxLookup ); - - lookup.vm.$emit( 'input', 'test' ); - await flushPromises(); - - expect( lookup.props( 'menuItems' ) ).toEqual( [ - { label: 'Schema1', value: 'Schema1', description: 'First description' }, - { label: 'Schema2', value: 'Schema2', description: 'Second description' }, - ] ); - } ); - - it( 'omits description from menu items when schema has empty description', async () => { - schemaStore.searchAndFetchMissingSchemas.mockResolvedValue( [ 'WithDesc', 'NoDesc' ] ); - schemaStore.schemas.set( 'WithDesc', new Schema( 'WithDesc', 'Has a description', new PropertyDefinitionList( [] ) ) ); - schemaStore.schemas.set( 'NoDesc', new Schema( 'NoDesc', '', new PropertyDefinitionList( [] ) ) ); - - const wrapper = mountComponent(); - const lookup = wrapper.findComponent( CdxLookup ); - - lookup.vm.$emit( 'input', 'test' ); - await flushPromises(); - - expect( lookup.props( 'menuItems' ) ).toEqual( [ - { label: 'WithDesc', value: 'WithDesc', description: 'Has a description' }, - { label: 'NoDesc', value: 'NoDesc', description: undefined }, - ] ); - } ); - - it( 'discards stale search results when a newer request completes first', async () => { - let resolveFirst: ( value: string[] ) => void; - const firstCallPromise = new Promise( ( resolve ) => { - resolveFirst = resolve; - } ); - - schemaStore.searchAndFetchMissingSchemas = vi.fn() - .mockReturnValueOnce( firstCallPromise ) - .mockResolvedValueOnce( [ 'SecondSchema' ] ); - - schemaStore.schemas.set( 'FirstSchema', new Schema( 'FirstSchema', 'Stale', new PropertyDefinitionList( [] ) ) ); - schemaStore.schemas.set( 'SecondSchema', new Schema( 'SecondSchema', 'Fresh', new PropertyDefinitionList( [] ) ) ); - - const wrapper = mountComponent(); - const lookup = wrapper.findComponent( CdxLookup ); - - lookup.vm.$emit( 'input', 'first' ); - lookup.vm.$emit( 'input', 'second' ); - await flushPromises(); - - expect( lookup.props( 'menuItems' ) ).toEqual( [ - { label: 'SecondSchema', value: 'SecondSchema', description: 'Fresh' }, - ] ); - - resolveFirst!( [ 'FirstSchema' ] ); - await flushPromises(); - - expect( lookup.props( 'menuItems' ) ).toEqual( [ - { label: 'SecondSchema', value: 'SecondSchema', description: 'Fresh' }, - ] ); - } ); - - it( 'reflects the selected prop on the lookup', () => { - const wrapper = mountComponent( { selected: 'Product' } ); - const lookup = wrapper.findComponent( CdxLookup ); - - expect( lookup.props( 'selected' ) ).toBe( 'Product' ); - expect( lookup.props( 'inputValue' ) ).toBe( 'Product' ); - expect( lookup.props( 'menuItems' ) ).toEqual( [ { label: 'Product', value: 'Product' } ] ); - } ); - - it( 'updates the lookup when the selected prop changes after mount', async () => { - const wrapper = mountComponent(); - const lookup = wrapper.findComponent( CdxLookup ); - - await wrapper.setProps( { selected: 'NewSchema' } ); - - expect( lookup.props( 'selected' ) ).toBe( 'NewSchema' ); - expect( lookup.props( 'inputValue' ) ).toBe( 'NewSchema' ); - expect( lookup.props( 'menuItems' ) ).toEqual( [ { label: 'NewSchema', value: 'NewSchema' } ] ); - - await wrapper.setProps( { selected: null } ); - - expect( lookup.props( 'inputValue' ) ).toBe( '' ); - expect( lookup.props( 'menuItems' ) ).toEqual( [] ); - } ); - - it( 'exposes focus method', () => { - const CdxLookupStub = { - template: '
', - }; - - const wrapper = mount( SchemaLookup, { - global: { - mocks: { - $i18n, - }, - plugins: [ pinia ], - stubs: { - CdxLookup: CdxLookupStub, - }, - }, - } ); - - const input = wrapper.find( 'input' ); - const focusSpy = vi.spyOn( input.element, 'focus' ); - - ( wrapper.vm as any ).focus(); - - expect( focusSpy ).toHaveBeenCalled(); - } ); -} ); diff --git a/resources/ext.neowiki/tests/components/common/SchemaPicker.spec.ts b/resources/ext.neowiki/tests/components/common/SchemaPicker.spec.ts new file mode 100644 index 000000000..ccbcfa564 --- /dev/null +++ b/resources/ext.neowiki/tests/components/common/SchemaPicker.spec.ts @@ -0,0 +1,226 @@ +import { mount, VueWrapper, flushPromises } from '@vue/test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { nextTick } from 'vue'; +import SchemaPicker from '@/components/common/SchemaPicker.vue'; +import { createPinia, setActivePinia } from 'pinia'; +import { useSchemaStore } from '@/stores/SchemaStore.ts'; +import { createI18nMock } from '../../VueTestHelpers.ts'; + +const $i18n = createI18nMock(); + +const CdxComboboxStub = { + props: [ 'selected', 'menuItems' ], + emits: [ 'update:selected', 'input', 'blur' ], + template: '
', +}; + +const SUMMARIES = [ + { name: 'Product', description: 'A product', propertyCount: 2 }, + { name: 'Office', description: 'A physical location', propertyCount: 4 }, + { name: 'City', description: '', propertyCount: 3 }, +]; + +describe( 'SchemaPicker', () => { + let pinia: ReturnType; + let schemaStore: any; + + const mountComponent = ( props: Record = {} ): VueWrapper => ( + mount( SchemaPicker, { + props, + global: { + mocks: { + $i18n, + }, + plugins: [ pinia ], + stubs: { + CdxCombobox: CdxComboboxStub, + }, + }, + } ) + ); + + async function mountLoaded( props: Record = {} ): Promise { + const wrapper = mountComponent( props ); + await flushPromises(); + return wrapper; + } + + function typeText( combobox: VueWrapper, value: string ): void { + combobox.vm.$emit( 'input', { target: { value } } ); + } + + beforeEach( () => { + pinia = createPinia(); + setActivePinia( pinia ); + + schemaStore = useSchemaStore(); + schemaStore.getAllSchemaSummaries = vi.fn().mockResolvedValue( SUMMARIES ); + } ); + + describe( 'browsing and filtering', () => { + it( 'populates the menu with all schemas and their descriptions on mount', async () => { + const wrapper = await mountLoaded(); + const combobox = wrapper.findComponent( CdxComboboxStub ); + + expect( combobox.props( 'menuItems' ) ).toEqual( [ + { label: 'Product', value: 'Product', description: 'A product' }, + { label: 'Office', value: 'Office', description: 'A physical location' }, + { label: 'City', value: 'City', description: undefined }, + ] ); + } ); + + it( 'filters the menu to schemas matching the typed text', async () => { + const wrapper = await mountLoaded(); + const combobox = wrapper.findComponent( CdxComboboxStub ); + + typeText( combobox, 'off' ); + await nextTick(); + + expect( combobox.props( 'menuItems' ) ).toEqual( [ + { label: 'Office', value: 'Office', description: 'A physical location' }, + ] ); + } ); + + it( 'shows all schemas again when the input is cleared', async () => { + const wrapper = await mountLoaded(); + const combobox = wrapper.findComponent( CdxComboboxStub ); + + typeText( combobox, 'off' ); + typeText( combobox, '' ); + await nextTick(); + + expect( combobox.props( 'menuItems' ) ).toHaveLength( 3 ); + } ); + + it( 'leaves the menu empty without throwing when loading schemas fails', async () => { + const consoleError = vi.spyOn( console, 'error' ).mockImplementation( () => undefined ); + schemaStore.getAllSchemaSummaries = vi.fn().mockRejectedValue( new Error( 'load failed' ) ); + + const wrapper = await mountLoaded(); + const combobox = wrapper.findComponent( CdxComboboxStub ); + + expect( combobox.props( 'menuItems' ) ).toEqual( [] ); + expect( consoleError ).toHaveBeenCalled(); + consoleError.mockRestore(); + } ); + } ); + + describe( 'committing a selection', () => { + it( 'emits the schema when an exact schema name is selected', async () => { + const wrapper = await mountLoaded(); + const combobox = wrapper.findComponent( CdxComboboxStub ); + + combobox.vm.$emit( 'update:selected', 'Office' ); + + expect( wrapper.emitted( 'select' )?.[ 0 ] ).toEqual( [ 'Office' ] ); + } ); + + it( 'does not emit for a value that is not a schema name', async () => { + const wrapper = await mountLoaded(); + const combobox = wrapper.findComponent( CdxComboboxStub ); + + combobox.vm.$emit( 'update:selected', 'Off' ); + + expect( wrapper.emitted( 'select' ) ).toBeFalsy(); + } ); + + it( 'commits the canonical schema name when the input has surrounding whitespace', async () => { + const wrapper = await mountLoaded(); + const combobox = wrapper.findComponent( CdxComboboxStub ); + + combobox.vm.$emit( 'update:selected', 'Office ' ); + + expect( wrapper.emitted( 'select' )?.[ 0 ] ).toEqual( [ 'Office' ] ); + } ); + + it( 'does not re-emit when the value already equals the committed schema', async () => { + const wrapper = await mountLoaded( { selected: 'Office' } ); + const combobox = wrapper.findComponent( CdxComboboxStub ); + + combobox.vm.$emit( 'update:selected', 'Office' ); + + expect( wrapper.emitted( 'select' ) ).toBeFalsy(); + } ); + } ); + + describe( 'rejecting invalid input', () => { + it( 'reverts to the committed schema and restores the menu on blur', async () => { + const wrapper = await mountLoaded( { selected: 'Product' } ); + const combobox = wrapper.findComponent( CdxComboboxStub ); + + combobox.vm.$emit( 'update:selected', 'xyz' ); + combobox.vm.$emit( 'blur' ); + await nextTick(); + + expect( combobox.props( 'selected' ) ).toBe( 'Product' ); + expect( combobox.props( 'menuItems' ) ).toHaveLength( 3 ); + expect( wrapper.emitted( 'select' ) ).toBeFalsy(); + } ); + + it( 'leaves a not-yet-set field empty on blur', async () => { + const wrapper = await mountLoaded(); + const combobox = wrapper.findComponent( CdxComboboxStub ); + + combobox.vm.$emit( 'update:selected', 'xyz' ); + combobox.vm.$emit( 'blur' ); + await nextTick(); + + expect( combobox.props( 'selected' ) ).toBe( '' ); + expect( wrapper.emitted( 'select' ) ).toBeFalsy(); + } ); + + it( 'emits blur so the consumer can mark the field touched', async () => { + const wrapper = await mountLoaded(); + const combobox = wrapper.findComponent( CdxComboboxStub ); + + combobox.vm.$emit( 'blur' ); + + expect( wrapper.emitted( 'blur' ) ).toBeTruthy(); + } ); + } ); + + describe( 'reflecting the selected prop', () => { + it( 'shows the selected schema in the field', () => { + const wrapper = mountComponent( { selected: 'Product' } ); + const combobox = wrapper.findComponent( CdxComboboxStub ); + + expect( combobox.props( 'selected' ) ).toBe( 'Product' ); + } ); + + it( 'updates the field when the selected prop changes', async () => { + const wrapper = mountComponent(); + const combobox = wrapper.findComponent( CdxComboboxStub ); + + await wrapper.setProps( { selected: 'NewSchema' } ); + expect( combobox.props( 'selected' ) ).toBe( 'NewSchema' ); + + await wrapper.setProps( { selected: null } ); + expect( combobox.props( 'selected' ) ).toBe( '' ); + } ); + } ); + + it( 'exposes focus method', () => { + const CdxComboboxInputStub = { + template: '
', + }; + + const wrapper = mount( SchemaPicker, { + global: { + mocks: { + $i18n, + }, + plugins: [ pinia ], + stubs: { + CdxCombobox: CdxComboboxInputStub, + }, + }, + } ); + + const input = wrapper.find( 'input' ); + const focusSpy = vi.spyOn( input.element, 'focus' ); + + ( wrapper.vm as any ).focus(); + + expect( focusSpy ).toHaveBeenCalled(); + } ); +} ); diff --git a/resources/ext.neowiki/tests/stores/SchemaStore.spec.ts b/resources/ext.neowiki/tests/stores/SchemaStore.spec.ts index 76627b261..8cb95501e 100644 --- a/resources/ext.neowiki/tests/stores/SchemaStore.spec.ts +++ b/resources/ext.neowiki/tests/stores/SchemaStore.spec.ts @@ -1,5 +1,9 @@ -import { describe, it, expect } from 'vitest'; -import { normalizeSchemaName } from '@/stores/SchemaStore.ts'; +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import { normalizeSchemaName, useSchemaStore } from '@/stores/SchemaStore.ts'; +import { NeoWikiExtension } from '@/NeoWikiExtension.ts'; +import { Schema } from '@/domain/Schema.ts'; +import { PropertyDefinitionList } from '@/domain/PropertyDefinitionList.ts'; describe( 'normalizeSchemaName', () => { it( 'upper-cases the first character', () => { @@ -23,3 +27,81 @@ describe( 'normalizeSchemaName', () => { expect( normalizeSchemaName( 'Validation Demo' ) ).toBe( 'Validation Demo' ); } ); } ); + +describe( 'SchemaStore getAllSchemaSummaries', () => { + + function summary( name: string ): { name: string; description: string; propertyCount: number } { + return { name, description: '', propertyCount: 0 }; + } + + function manySummaries( count: number, prefix: string ): ReturnType[] { + return Array.from( { length: count }, ( _value, index ) => summary( `${ prefix }${ index }` ) ); + } + + function withRepository( repository: Record ): void { + vi.spyOn( NeoWikiExtension, 'getInstance' ).mockReturnValue( + { getSchemaRepository: () => repository } as unknown as NeoWikiExtension, + ); + } + + beforeEach( () => { + setActivePinia( createPinia() ); + } ); + + afterEach( () => { + vi.restoreAllMocks(); + } ); + + it( 'pages through every schema summary across multiple pages', async () => { + const getSchemaSummaries = vi.fn() + .mockResolvedValueOnce( { schemas: manySummaries( 50, 'A' ), totalRows: 60 } ) + .mockResolvedValueOnce( { schemas: manySummaries( 10, 'B' ), totalRows: 60 } ); + withRepository( { getSchemaSummaries } ); + + const result = await useSchemaStore().getAllSchemaSummaries(); + + expect( result ).toHaveLength( 60 ); + expect( getSchemaSummaries ).toHaveBeenNthCalledWith( 1, 0, 50 ); + expect( getSchemaSummaries ).toHaveBeenNthCalledWith( 2, 50, 50 ); + } ); + + it( 'advances by page size, not loaded count, when a page omits unloadable schemas', async () => { + // The endpoint counts 60 schema pages in totalRows but can only load 49 in the + // first window (one is restricted or malformed) and 10 in the second. + const getSchemaSummaries = vi.fn() + .mockResolvedValueOnce( { schemas: manySummaries( 49, 'A' ), totalRows: 60 } ) + .mockResolvedValueOnce( { schemas: manySummaries( 10, 'B' ), totalRows: 60 } ); + withRepository( { getSchemaSummaries } ); + + const result = await useSchemaStore().getAllSchemaSummaries(); + + expect( result ).toHaveLength( 59 ); + expect( getSchemaSummaries ).toHaveBeenNthCalledWith( 2, 50, 50 ); + expect( getSchemaSummaries ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'caches the summaries and does not refetch on the next call', async () => { + const getSchemaSummaries = vi.fn().mockResolvedValue( { schemas: [ summary( 'A' ) ], totalRows: 1 } ); + withRepository( { getSchemaSummaries } ); + const store = useSchemaStore(); + + await store.getAllSchemaSummaries(); + await store.getAllSchemaSummaries(); + + expect( getSchemaSummaries ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'refetches summaries after a schema is saved', async () => { + const getSchemaSummaries = vi.fn().mockResolvedValue( { schemas: [ summary( 'A' ) ], totalRows: 1 } ); + const saveSchema = vi.fn().mockResolvedValue( undefined ); + withRepository( { getSchemaSummaries, saveSchema } ); + const store = useSchemaStore(); + + await store.getAllSchemaSummaries(); + await store.saveSchema( new Schema( 'B', '', new PropertyDefinitionList( [] ) ) ); + await store.getAllSchemaSummaries(); + + expect( getSchemaSummaries ).toHaveBeenCalledTimes( 2 ); + } ); + +} ); diff --git a/src/Persistence/MediaWiki/schemaContentSchema.json b/src/Persistence/MediaWiki/schemaContentSchema.json index d16d9bbb2..0eba12d82 100644 --- a/src/Persistence/MediaWiki/schemaContentSchema.json +++ b/src/Persistence/MediaWiki/schemaContentSchema.json @@ -31,7 +31,39 @@ "uniqueItems": { "type": "boolean" } - } + }, + "allOf": [ + { + "if": { + "required": [ + "type" + ], + "properties": { + "type": { + "const": "relation" + } + } + }, + "then": { + "required": [ + "relation", + "targetSchema" + ], + "properties": { + "relation": { + "type": "string", + "pattern": "\\S", + "$error": "The relation type must not be empty." + }, + "targetSchema": { + "type": "string", + "pattern": "\\S", + "$error": "The target schema must not be empty." + } + } + } + } + ] } } } diff --git a/tests/phpunit/Persistence/MediaWiki/SchemaContentValidatorTest.php b/tests/phpunit/Persistence/MediaWiki/SchemaContentValidatorTest.php index 0c7faab07..f24169df7 100644 --- a/tests/phpunit/Persistence/MediaWiki/SchemaContentValidatorTest.php +++ b/tests/phpunit/Persistence/MediaWiki/SchemaContentValidatorTest.php @@ -120,4 +120,70 @@ public function testMissingTypeFailsValidation(): void { ); } + public function testValidRelationPropertyPassesValidation(): void { + $validator = SchemaContentValidator::newInstance(); + + $valid = $validator->validate( + $this->schemaWithProperty( '{ "type": "relation", "relation": "Likes", "targetSchema": "Person" }' ) + ); + + if ( !$valid ) { + $this->assertSame( [], $validator->getErrors() ); + } + + $this->assertTrue( $valid ); + } + + public function testEmptyRelationTypeFailsValidation(): void { + $validator = SchemaContentValidator::newInstance(); + + $this->assertFalse( + $validator->validate( + $this->schemaWithProperty( '{ "type": "relation", "relation": "", "targetSchema": "Person" }' ) + ) + ); + + $this->assertContains( 'The relation type must not be empty.', $validator->getErrors() ); + } + + public function testWhitespaceOnlyRelationTypeFailsValidation(): void { + $validator = SchemaContentValidator::newInstance(); + + $this->assertFalse( + $validator->validate( + $this->schemaWithProperty( '{ "type": "relation", "relation": " ", "targetSchema": "Person" }' ) + ) + ); + + $this->assertContains( 'The relation type must not be empty.', $validator->getErrors() ); + } + + public function testEmptyTargetSchemaFailsValidation(): void { + $validator = SchemaContentValidator::newInstance(); + + $this->assertFalse( + $validator->validate( + $this->schemaWithProperty( '{ "type": "relation", "relation": "Likes", "targetSchema": "" }' ) + ) + ); + + $this->assertContains( 'The target schema must not be empty.', $validator->getErrors() ); + } + + /** + * Wraps the property under test between two valid siblings so a regression that only + * inspects the first or last property definition is caught. + */ + private function schemaWithProperty( string $propertyJson ): string { + return <<