diff --git a/.github/workflows/ci-ts.yml b/.github/workflows/ci-ts.yml index df4285f29..4f5408535 100644 --- a/.github/workflows/ci-ts.yml +++ b/.github/workflows/ci-ts.yml @@ -6,10 +6,12 @@ on: paths: - '.github/workflows/ci-ts.yml' - 'resources/**' + - 'tests/RedHerb/**' pull_request: paths: - '.github/workflows/ci-ts.yml' - 'resources/**' + - 'tests/RedHerb/**' jobs: build: diff --git a/resources/ext.neowiki/tests/RedHerb/ColorAttributesEditor.spec.ts b/resources/ext.neowiki/tests/RedHerb/ColorAttributesEditor.spec.ts new file mode 100644 index 000000000..07e0ff3c0 --- /dev/null +++ b/resources/ext.neowiki/tests/RedHerb/ColorAttributesEditor.spec.ts @@ -0,0 +1,160 @@ +import './../neowiki-test-env.ts'; +import { DOMWrapper, VueWrapper } from '@vue/test-utils'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Ref } from 'vue'; +import { CdxTextInput } from '@wikimedia/codex'; +import { UseSortableOptions } from '@/composables/useSortable.ts'; +import { createTestWrapper, setupMwMock } from '../VueTestHelpers.ts'; +import { loadRedHerbFrontend, newColorProperty } from './RedHerbRegistration.ts'; + +const sortableCalls: UseSortableOptions[] = []; + +vi.mock( '@/composables/useSortable.ts', () => ( { + useSortable: ( _containerRef: Ref, options: UseSortableOptions ): void => { + sortableCalls.push( options ); + }, +} ) ); + +const ITEM = '.ext-redherb-color-attributes__item'; +const INVALID_SWATCH = '.ext-redherb-color-attributes__swatch--invalid'; +const REMOVE_BUTTON = '.ext-redherb-color-attributes__remove'; +const ADD_LABEL = 'redherb-color-add-color'; + +// Imported after vi.mock so the component picks the stubbed composable up. +const ColorAttributesEditor = ( await import( '@redherb/ColorAttributesEditor.vue' ) ).default; + +function newWrapper( allowedColors: string[] ): VueWrapper { + return createTestWrapper( ColorAttributesEditor, { + property: newColorProperty( { allowedColors: allowedColors } ), + } ); +} + +function colorInputs( wrapper: VueWrapper ): string[] { + return wrapper.findAll( 'input' ).map( ( input ) => ( input.element as HTMLInputElement ).value ); +} + +function addButton( wrapper: VueWrapper ): DOMWrapper { + const button = wrapper.findAll( 'button' ).find( ( candidate ) => candidate.text().includes( ADD_LABEL ) ); + + expect( button, 'add-color button' ).toBeDefined(); + return button!; +} + +async function typeInto( wrapper: VueWrapper, index: number, color: string ): Promise { + await colorInputAt( wrapper, index ).vm.$emit( 'update:modelValue', color ); +} + +function colorInputAt( wrapper: VueWrapper, index: number ): VueWrapper { + return wrapper.findAllComponents( CdxTextInput )[ index ] as unknown as VueWrapper; +} + +function lastEmittedPalette( wrapper: VueWrapper ): string[] { + const emitted = wrapper.emitted( 'update:property' ); + expect( emitted, 'update:property' ).toBeTruthy(); + return ( emitted![ emitted!.length - 1 ][ 0 ] as { allowedColors: string[] } ).allowedColors; +} + +describe( 'ColorAttributesEditor', () => { + beforeAll( async () => { + await loadRedHerbFrontend(); + } ); + + beforeEach( () => { + sortableCalls.length = 0; + setupMwMock(); + } ); + + it( 'renders an entry per allowed color', () => { + const wrapper = newWrapper( [ '#ff5733', '#000000' ] ); + + expect( wrapper.findAll( ITEM ) ).toHaveLength( 2 ); + expect( colorInputs( wrapper ) ).toEqual( [ '#ff5733', '#000000' ] ); + } ); + + it( 'renders no entries for an empty palette', () => { + const wrapper = newWrapper( [] ); + + expect( wrapper.findAll( ITEM ) ).toHaveLength( 0 ); + } ); + + it( 'appends an empty entry when a color is added', async () => { + const wrapper = newWrapper( [ '#ff5733' ] ); + + await addButton( wrapper ).trigger( 'click' ); + + expect( colorInputs( wrapper ) ).toEqual( [ '#ff5733', '' ] ); + } ); + + it( 'emits the palette with the added entry', async () => { + const wrapper = newWrapper( [ '#ff5733' ] ); + + await addButton( wrapper ).trigger( 'click' ); + + expect( lastEmittedPalette( wrapper ) ).toEqual( [ '#ff5733', '' ] ); + } ); + + it( 'emits the palette with the edited color', async () => { + const wrapper = newWrapper( [ '#ff5733', '#000000' ] ); + + await typeInto( wrapper, 1, '#ffffff' ); + + expect( lastEmittedPalette( wrapper ) ).toEqual( [ '#ff5733', '#ffffff' ] ); + } ); + + it( 'emits the palette without the removed color', async () => { + const wrapper = newWrapper( [ '#ff5733', '#000000', '#ffffff' ] ); + + await wrapper.findAll( REMOVE_BUTTON )[ 1 ].trigger( 'click' ); + + expect( lastEmittedPalette( wrapper ) ).toEqual( [ '#ff5733', '#ffffff' ] ); + } ); + + it( 'keeps the remaining entries in order after a removal', async () => { + const wrapper = newWrapper( [ '#ff5733', '#000000', '#ffffff' ] ); + + await wrapper.findAll( REMOVE_BUTTON )[ 0 ].trigger( 'click' ); + + expect( colorInputs( wrapper ) ).toEqual( [ '#000000', '#ffffff' ] ); + } ); + + it( 'marks entries that are not a six-digit hex color', () => { + const wrapper = newWrapper( [ '#ff5733', 'rebeccapurple' ] ); + + const items = wrapper.findAll( ITEM ); + expect( items[ 0 ].find( INVALID_SWATCH ).exists() ).toBe( false ); + expect( items[ 1 ].find( INVALID_SWATCH ).exists() ).toBe( true ); + } ); + + it( 'emits the reordered palette when an entry is dragged', () => { + const wrapper = newWrapper( [ '#ff5733', '#000000', '#ffffff' ] ); + + sortableCalls[ 0 ].onReorder!( 0, 2 ); + + expect( lastEmittedPalette( wrapper ) ).toEqual( [ '#000000', '#ffffff', '#ff5733' ] ); + } ); + + it( 'reorders only through the drag handle', () => { + newWrapper( [ '#ff5733' ] ); + + expect( sortableCalls[ 0 ].handle ).toBe( '.ext-redherb-color-attributes__drag-handle' ); + } ); + + it( 'shows a palette replaced from outside the editor', async () => { + const wrapper = newWrapper( [ '#ff5733' ] ); + + await wrapper.setProps( { property: newColorProperty( { allowedColors: [ '#000000', '#ffffff' ] } ) } ); + + expect( colorInputs( wrapper ) ).toEqual( [ '#000000', '#ffffff' ] ); + } ); + + // Rebuilding the entries would give them fresh keys, so Vue would replace the inputs + // and drop the caret out of the one being typed into. + it( 'leaves the entry inputs in place when the property echoes the palette back', async () => { + const wrapper = newWrapper( [ '#ff5733' ] ); + const inputBeforeEcho = wrapper.find( 'input' ).element; + + await wrapper.setProps( { property: newColorProperty( { allowedColors: [ '#ff5733' ] } ) } ); + + expect( wrapper.find( 'input' ).element ).toBe( inputBeforeEcho ); + } ); +} ); diff --git a/resources/ext.neowiki/tests/RedHerb/ColorDisplay.spec.ts b/resources/ext.neowiki/tests/RedHerb/ColorDisplay.spec.ts new file mode 100644 index 000000000..4927bb353 --- /dev/null +++ b/resources/ext.neowiki/tests/RedHerb/ColorDisplay.spec.ts @@ -0,0 +1,76 @@ +import './../neowiki-test-env.ts'; +import { mount, VueWrapper } from '@vue/test-utils'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import ColorDisplay from '@redherb/ColorDisplay.vue'; +import { newNumberValue, newStringValue, Value } from '@/domain/Value.ts'; +import { setupMwMock } from '../VueTestHelpers.ts'; +import { loadRedHerbFrontend, newColorProperty } from './RedHerbRegistration.ts'; + +const SWATCH = '.ext-redherb-color-display__swatch'; +const HEX = '.ext-redherb-color-display__hex'; + +function newWrapper( value: Value, allowedColors: string[] = [] ): VueWrapper { + return mount( ColorDisplay, { + props: { + value: value, + property: newColorProperty( { allowedColors: allowedColors } ), + }, + } ); +} + +describe( 'ColorDisplay', () => { + beforeAll( async () => { + await loadRedHerbFrontend(); + } ); + + beforeEach( () => { + setupMwMock( { + messages: { + 'redherb-color-invalid-fallback': ( raw: string ) => `not a color: ${ raw }`, + }, + } ); + } ); + + it( 'renders the hex code of a valid color', () => { + const wrapper = newWrapper( newStringValue( '#ff5733' ) ); + + expect( wrapper.find( HEX ).text() ).toBe( '#ff5733' ); + } ); + + it( 'paints the swatch with the color', () => { + const wrapper = newWrapper( newStringValue( '#ff5733' ) ); + + expect( wrapper.find( SWATCH ).attributes( 'style' ) ).toContain( 'background-color: rgb(255, 87, 51)' ); + } ); + + it( 'accepts uppercase hex digits', () => { + const wrapper = newWrapper( newStringValue( '#FF5733' ) ); + + expect( wrapper.find( HEX ).text() ).toBe( '#FF5733' ); + } ); + + it( 'renders the fallback message instead of a swatch for a value that is not a six-digit hex color', () => { + const wrapper = newWrapper( newStringValue( 'rebeccapurple' ) ); + + expect( wrapper.find( SWATCH ).exists() ).toBe( false ); + expect( wrapper.text() ).toContain( 'not a color: rebeccapurple' ); + } ); + + it( 'renders the fallback message for three-digit shorthand', () => { + const wrapper = newWrapper( newStringValue( '#f53' ) ); + + expect( wrapper.text() ).toContain( 'not a color: #f53' ); + } ); + + it( 'renders the fallback message for a value of the wrong type', () => { + const wrapper = newWrapper( newNumberValue( 42 ) ); + + expect( wrapper.find( SWATCH ).exists() ).toBe( false ); + } ); + + it( 'renders a swatch for a stored color the palette no longer allows', () => { + const wrapper = newWrapper( newStringValue( '#ff5733' ), [ '#000000' ] ); + + expect( wrapper.find( HEX ).text() ).toBe( '#ff5733' ); + } ); +} ); diff --git a/resources/ext.neowiki/tests/RedHerb/ColorInput.spec.ts b/resources/ext.neowiki/tests/RedHerb/ColorInput.spec.ts new file mode 100644 index 000000000..812085afb --- /dev/null +++ b/resources/ext.neowiki/tests/RedHerb/ColorInput.spec.ts @@ -0,0 +1,132 @@ +import './../neowiki-test-env.ts'; +import { VueWrapper } from '@vue/test-utils'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { CdxField, CdxTextInput } from '@wikimedia/codex'; +import ColorInput from '@redherb/ColorInput.vue'; +import { newStringValue, StringValue, Value } from '@/domain/Value.ts'; +import { PropertyDefinition } from '@/domain/PropertyDefinition.ts'; +import { createTestWrapper, setupMwMock } from '../VueTestHelpers.ts'; +import { loadRedHerbFrontend, newColorProperty } from './RedHerbRegistration.ts'; + +const SWATCH = '.ext-redherb-color-input__swatch'; +const EMPTY_SWATCH = '.ext-redherb-color-input__swatch--empty'; + +interface ColorInputExposes { + getCurrentValue: () => Value | undefined; +} + +function newWrapper( + modelValue: Value | undefined = undefined, + property: PropertyDefinition = newColorProperty(), +): VueWrapper { + return createTestWrapper( ColorInput, { + modelValue: modelValue, + property: property, + label: 'Favourite color', + } ); +} + +async function typeColor( wrapper: VueWrapper, color: string ): Promise { + await wrapper.findComponent( CdxTextInput ).vm.$emit( 'update:modelValue', color ); +} + +function lastEmittedValue( wrapper: VueWrapper ): Value | undefined { + const emitted = wrapper.emitted( 'update:modelValue' ); + expect( emitted ).toBeTruthy(); + return emitted![ emitted!.length - 1 ][ 0 ] as Value | undefined; +} + +describe( 'ColorInput', () => { + beforeAll( async () => { + await loadRedHerbFrontend(); + } ); + + beforeEach( () => { + setupMwMock(); + } ); + + it( 'renders the label', () => { + const wrapper = newWrapper(); + + expect( wrapper.text() ).toContain( 'Favourite color' ); + } ); + + it( 'shows the current color in the text input', () => { + const wrapper = newWrapper( newStringValue( '#ff5733' ) ); + + expect( wrapper.findComponent( CdxTextInput ).props( 'modelValue' ) ).toBe( '#ff5733' ); + } ); + + it( 'previews the current color in the swatch', () => { + const wrapper = newWrapper( newStringValue( '#ff5733' ) ); + + expect( wrapper.find( SWATCH ).attributes( 'style' ) ).toContain( 'background-color: rgb(255, 87, 51)' ); + } ); + + it( 'marks the swatch as empty when there is no color yet', () => { + const wrapper = newWrapper(); + + expect( wrapper.find( EMPTY_SWATCH ).exists() ).toBe( true ); + } ); + + it( 'marks the swatch as empty while the typed color is incomplete', async () => { + const wrapper = newWrapper(); + + await typeColor( wrapper, '#ff57' ); + + expect( wrapper.find( EMPTY_SWATCH ).exists() ).toBe( true ); + } ); + + it( 'previews the typed color once it is a complete hex color', async () => { + const wrapper = newWrapper(); + + await typeColor( wrapper, '#ff5733' ); + + expect( wrapper.find( SWATCH ).attributes( 'style' ) ).toContain( 'background-color: rgb(255, 87, 51)' ); + } ); + + it( 'emits the typed color as a string value', async () => { + const wrapper = newWrapper(); + + await typeColor( wrapper, '#ff5733' ); + + expect( lastEmittedValue( wrapper ) ).toEqual( newStringValue( '#ff5733' ) ); + } ); + + it( 'emits a color the palette does not allow, leaving validation to the backend', async () => { + const wrapper = newWrapper( undefined, newColorProperty( { allowedColors: [ '#000000' ] } ) ); + + await typeColor( wrapper, '#ff5733' ); + + expect( lastEmittedValue( wrapper ) ).toEqual( newStringValue( '#ff5733' ) ); + } ); + + it( 'emits no value once the color is cleared', async () => { + const wrapper = newWrapper( newStringValue( '#ff5733' ) ); + + await typeColor( wrapper, '' ); + + expect( lastEmittedValue( wrapper ) ).toBeUndefined(); + } ); + + it( 'exposes the current color to the subject editor', async () => { + const wrapper = newWrapper(); + + await typeColor( wrapper, '#ff5733' ); + + const exposed = ( wrapper.vm as unknown as ColorInputExposes ).getCurrentValue(); + expect( ( exposed as StringValue ).parts ).toEqual( [ '#ff5733' ] ); + } ); + + it( 'marks the field optional when the property is not required', () => { + const wrapper = newWrapper( undefined, newColorProperty( { required: false } ) ); + + expect( wrapper.findComponent( CdxField ).props( 'optional' ) ).toBe( true ); + } ); + + it( 'does not mark the field optional when the property is required', () => { + const wrapper = newWrapper( undefined, newColorProperty( { required: true } ) ); + + expect( wrapper.findComponent( CdxField ).props( 'optional' ) ).toBe( false ); + } ); +} ); diff --git a/resources/ext.neowiki/tests/RedHerb/CreateChildDialog.spec.ts b/resources/ext.neowiki/tests/RedHerb/CreateChildDialog.spec.ts new file mode 100644 index 000000000..3bc7c933e --- /dev/null +++ b/resources/ext.neowiki/tests/RedHerb/CreateChildDialog.spec.ts @@ -0,0 +1,243 @@ +import './../neowiki-test-env.ts'; +import { flushPromises, mount, VueWrapper } from '@vue/test-utils'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import type { Pinia } from 'pinia'; +import { ref, Ref } from 'vue'; +import { CdxDialog, CdxTextInput } from '@wikimedia/codex'; +import CreateChildDialog from '@redherb/createChild/CreateChildDialog.vue'; +import createChildConstants from '@redherb/createChild/constants.js'; +import { createPropertyDefinitionFromJson, PropertyName } from '@/domain/PropertyDefinition.ts'; +import { PropertyDefinitionList } from '@/domain/PropertyDefinitionList.ts'; +import { Schema } from '@/domain/Schema.ts'; +import { StatementList } from '@/domain/StatementList.ts'; +import { newStringValue } from '@/domain/Value.ts'; +import { TextType } from '@/domain/propertyTypes/Text.ts'; +import { useSchemaStore } from '@/stores/SchemaStore.ts'; +import { useSubjectStore } from '@/stores/SubjectStore.ts'; +import SubjectEditor from '@/components/SubjectEditor/SubjectEditor.vue'; +import { NeoWikiTestServices } from '../NeoWikiTestServices.ts'; +import { createI18nMock, setupMwMock } from '../VueTestHelpers.ts'; +import { loadRedHerbFrontend } from './RedHerbRegistration.ts'; + +const DIALOG_OPEN_KEY = createChildConstants.DIALOG_OPEN_KEY; + +const SCHEMA_NAME = 'Company'; +const PAGE_ID = 42; + +const companySchema = new Schema( + SCHEMA_NAME, + 'A company', + new PropertyDefinitionList( [ + createPropertyDefinitionFromJson( 'tradingName', { type: TextType.typeName } ), + ] ), +); + +describe( 'CreateChildDialog', () => { + let pinia: Pinia; + let open: Ref; + + beforeAll( async () => { + await loadRedHerbFrontend(); + } ); + + beforeEach( () => { + setupMwMock( { config: { wgArticleId: PAGE_ID } } ); + ( globalThis as any ).mw.log = { error: vi.fn() }; + + pinia = createPinia(); + setActivePinia( pinia ); + + useSchemaStore().getOrFetchSchema = vi.fn().mockResolvedValue( companySchema ); + useSubjectStore().createChildSubject = vi.fn().mockResolvedValue( undefined ); + + open = ref( false ); + } ); + + function newWrapper(): VueWrapper { + return mount( CreateChildDialog, { + global: { + plugins: [ pinia ], + provide: { + ...NeoWikiTestServices.getServices(), + [ DIALOG_OPEN_KEY ]: open, + }, + directives: { tooltip: {} }, + mocks: { $i18n: createI18nMock() }, + stubs: { teleport: true }, + }, + } ); + } + + async function openDialog(): Promise { + const wrapper = newWrapper(); + open.value = true; + await flushPromises(); + return wrapper; + } + + function textInputAt( wrapper: VueWrapper, index: number ): VueWrapper { + return wrapper.findAllComponents( CdxTextInput )[ index ] as unknown as VueWrapper; + } + + function labelInput( wrapper: VueWrapper ): VueWrapper { + return textInputAt( wrapper, 0 ); + } + + function labelValue( wrapper: VueWrapper ): string { + return ( wrapper.findAll( 'input' )[ 0 ].element as HTMLInputElement ).value; + } + + async function typeLabel( wrapper: VueWrapper, label: string ): Promise { + await labelInput( wrapper ).vm.$emit( 'update:modelValue', label ); + } + + async function save( wrapper: VueWrapper ): Promise { + await wrapper.findComponent( CdxDialog ).vm.$emit( 'primary' ); + } + + it( 'loads the schema of the subjects it creates when opened', async () => { + await openDialog(); + + expect( useSchemaStore().getOrFetchSchema ).toHaveBeenCalledWith( SCHEMA_NAME ); + } ); + + it( 'does not load the schema before it is opened', async () => { + newWrapper(); + await flushPromises(); + + expect( useSchemaStore().getOrFetchSchema ).not.toHaveBeenCalled(); + } ); + + it( 'loads the schema only once across openings', async () => { + const wrapper = await openDialog(); + + open.value = false; + await flushPromises(); + open.value = true; + await flushPromises(); + + expect( useSchemaStore().getOrFetchSchema ).toHaveBeenCalledTimes( 1 ); + expect( wrapper.findComponent( SubjectEditor ).exists() ).toBe( true ); + } ); + + it( 'offers an editor for the blank statements of the schema', async () => { + const wrapper = await openDialog(); + + const editor = wrapper.findComponent( SubjectEditor ); + expect( editor.props( 'statements' ) ).toEqual( companySchema.blankStatements() ); + expect( editor.props( 'schema' ) ).toBe( companySchema ); + } ); + + it( 'creates a child subject of the current page from the label and the edited statements', async () => { + const wrapper = await openDialog(); + await typeLabel( wrapper, 'Acme' ); + + await save( wrapper ); + await flushPromises(); + + expect( useSubjectStore().createChildSubject ).toHaveBeenCalledWith( + PAGE_ID, + 'Acme', + SCHEMA_NAME, + expect.any( StatementList ), + ); + } ); + + it( 'passes on the values entered into the editor', async () => { + const wrapper = await openDialog(); + await typeLabel( wrapper, 'Acme' ); + await textInputAt( wrapper, 1 ).vm.$emit( 'update:modelValue', 'Acme Corporation' ); + + await save( wrapper ); + await flushPromises(); + + const statements = vi.mocked( useSubjectStore().createChildSubject ).mock.calls[ 0 ][ 3 ]; + expect( statements.get( new PropertyName( 'tradingName' ) ).value ).toEqual( newStringValue( 'Acme Corporation' ) ); + } ); + + it( 'trims the label', async () => { + const wrapper = await openDialog(); + await typeLabel( wrapper, ' Acme ' ); + + await save( wrapper ); + await flushPromises(); + + expect( useSubjectStore().createChildSubject ).toHaveBeenCalledWith( + PAGE_ID, + 'Acme', + SCHEMA_NAME, + expect.anything(), + ); + } ); + + it( 'creates nothing when the label is blank', async () => { + const wrapper = await openDialog(); + await typeLabel( wrapper, ' ' ); + + await save( wrapper ); + await flushPromises(); + + expect( useSubjectStore().createChildSubject ).not.toHaveBeenCalled(); + } ); + + it( 'closes after creating the subject', async () => { + const wrapper = await openDialog(); + await typeLabel( wrapper, 'Acme' ); + + await save( wrapper ); + await flushPromises(); + + expect( open.value ).toBe( false ); + } ); + + it( 'stays open when creating the subject fails', async () => { + useSubjectStore().createChildSubject = vi.fn().mockRejectedValue( new Error( 'Save failed' ) ); + const wrapper = await openDialog(); + await typeLabel( wrapper, 'Acme' ); + + await save( wrapper ); + await flushPromises(); + + expect( open.value ).toBe( true ); + expect( mw.notify ).toHaveBeenCalledWith( 'redherb-create-child-error', { type: 'error' } ); + } ); + + it( 'closes and reports when the schema cannot be loaded', async () => { + useSchemaStore().getOrFetchSchema = vi.fn().mockRejectedValue( new Error( 'No such schema' ) ); + + const wrapper = await openDialog(); + + expect( wrapper.findComponent( SubjectEditor ).exists() ).toBe( false ); + expect( open.value ).toBe( false ); + expect( mw.notify ).toHaveBeenCalledWith( 'No such schema', { type: 'error' } ); + } ); + + it( 'closes when cancelled', async () => { + const wrapper = await openDialog(); + + await wrapper.findComponent( CdxDialog ).vm.$emit( 'default' ); + + expect( open.value ).toBe( false ); + } ); + + it( 'closes when dismissed', async () => { + const wrapper = await openDialog(); + + await wrapper.findComponent( CdxDialog ).vm.$emit( 'update:open', false ); + + expect( open.value ).toBe( false ); + } ); + + it( 'forgets the previous label when reopened', async () => { + const wrapper = await openDialog(); + await typeLabel( wrapper, 'Acme' ); + + open.value = false; + await flushPromises(); + open.value = true; + await flushPromises(); + + expect( labelValue( wrapper ) ).toBe( '' ); + } ); +} ); diff --git a/resources/ext.neowiki/tests/RedHerb/EditMainSubjectDialog.spec.ts b/resources/ext.neowiki/tests/RedHerb/EditMainSubjectDialog.spec.ts new file mode 100644 index 000000000..29070a872 --- /dev/null +++ b/resources/ext.neowiki/tests/RedHerb/EditMainSubjectDialog.spec.ts @@ -0,0 +1,271 @@ +import './../neowiki-test-env.ts'; +import { flushPromises, mount, VueWrapper } from '@vue/test-utils'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import type { Pinia } from 'pinia'; +import { reactive } from 'vue'; +import { CdxDialog, CdxTextInput } from '@wikimedia/codex'; +import EditMainSubjectDialog from '@redherb/editMainSubject/EditMainSubjectDialog.vue'; +import editMainSubjectConstants from '@redherb/editMainSubject/constants.js'; +import { createPropertyDefinitionFromJson, PropertyName } from '@/domain/PropertyDefinition.ts'; +import { PropertyDefinitionList } from '@/domain/PropertyDefinitionList.ts'; +import { Schema } from '@/domain/Schema.ts'; +import { Statement } from '@/domain/Statement.ts'; +import { StatementList } from '@/domain/StatementList.ts'; +import { Subject } from '@/domain/Subject.ts'; +import { SubjectId } from '@/domain/SubjectId.ts'; +import { newStringValue } from '@/domain/Value.ts'; +import { TextType } from '@/domain/propertyTypes/Text.ts'; +import { useSchemaStore } from '@/stores/SchemaStore.ts'; +import { useSubjectStore } from '@/stores/SubjectStore.ts'; +import SubjectEditor from '@/components/SubjectEditor/SubjectEditor.vue'; +import { NeoWikiTestServices } from '../NeoWikiTestServices.ts'; +import { createI18nMock, setupMwMock } from '../VueTestHelpers.ts'; +import { loadRedHerbFrontend } from './RedHerbRegistration.ts'; + +const DIALOG_STATE_KEY = editMainSubjectConstants.DIALOG_STATE_KEY; + +const SCHEMA_NAME = 'Company'; +const SUBJECT_ID = 's1demo5sssssss1'; + +const companySchema = new Schema( + SCHEMA_NAME, + 'A company', + new PropertyDefinitionList( [ + createPropertyDefinitionFromJson( 'tradingName', { type: TextType.typeName } ), + ] ), +); + +const company = new Subject( + new SubjectId( SUBJECT_ID ), + 'Acme', + SCHEMA_NAME, + new StatementList( [ + new Statement( new PropertyName( 'tradingName' ), TextType.typeName, newStringValue( 'Acme Corporation' ) ), + ] ), +); + +function pendingForever(): Promise { + return new Promise( () => { + // Never settles, so the caller stays in its loading state. + } ); +} + +interface DialogState { + open: boolean; + subjectId: string | null; +} + +describe( 'EditMainSubjectDialog', () => { + let pinia: Pinia; + let state: DialogState; + + beforeAll( async () => { + await loadRedHerbFrontend(); + } ); + + beforeEach( () => { + setupMwMock(); + ( globalThis as any ).mw.log = { error: vi.fn() }; + + pinia = createPinia(); + setActivePinia( pinia ); + + useSubjectStore().getOrFetchSubject = vi.fn().mockResolvedValue( company ); + useSubjectStore().updateSubject = vi.fn().mockResolvedValue( undefined ); + useSchemaStore().getOrFetchSchema = vi.fn().mockResolvedValue( companySchema ); + + state = reactive( { open: false, subjectId: null } ); + } ); + + function newWrapper(): VueWrapper { + return mount( EditMainSubjectDialog, { + global: { + plugins: [ pinia ], + provide: { + ...NeoWikiTestServices.getServices(), + [ DIALOG_STATE_KEY ]: state, + }, + directives: { tooltip: {} }, + mocks: { $i18n: createI18nMock() }, + stubs: { teleport: true }, + }, + } ); + } + + async function openForSubject(): Promise { + const wrapper = newWrapper(); + state.subjectId = SUBJECT_ID; + state.open = true; + await flushPromises(); + return wrapper; + } + + function textInputAt( wrapper: VueWrapper, index: number ): VueWrapper { + return wrapper.findAllComponents( CdxTextInput )[ index ] as unknown as VueWrapper; + } + + function labelInput( wrapper: VueWrapper ): VueWrapper { + return textInputAt( wrapper, 0 ); + } + + function labelValue( wrapper: VueWrapper ): string { + return ( wrapper.findAll( 'input' )[ 0 ].element as HTMLInputElement ).value; + } + + async function typeLabel( wrapper: VueWrapper, label: string ): Promise { + await labelInput( wrapper ).vm.$emit( 'update:modelValue', label ); + } + + async function save( wrapper: VueWrapper ): Promise { + await wrapper.findComponent( CdxDialog ).vm.$emit( 'primary' ); + } + + function savedSubject(): Subject { + return vi.mocked( useSubjectStore().updateSubject ).mock.calls[ 0 ][ 0 ]; + } + + it( 'loads the subject it was opened for', async () => { + await openForSubject(); + + expect( useSubjectStore().getOrFetchSubject ).toHaveBeenCalledWith( new SubjectId( SUBJECT_ID ) ); + } ); + + it( 'loads the schema of the subject', async () => { + await openForSubject(); + + expect( useSchemaStore().getOrFetchSchema ).toHaveBeenCalledWith( SCHEMA_NAME ); + } ); + + it( 'loads nothing while no subject is set', async () => { + newWrapper(); + state.open = true; + await flushPromises(); + + expect( useSubjectStore().getOrFetchSubject ).not.toHaveBeenCalled(); + } ); + + it( 'prefills the label of the subject', async () => { + const wrapper = await openForSubject(); + + expect( labelValue( wrapper ) ).toBe( 'Acme' ); + } ); + + it( 'offers an editor holding the current statements of the subject', async () => { + const wrapper = await openForSubject(); + + const editor = wrapper.findComponent( SubjectEditor ); + expect( editor.props( 'statements' ) ).toEqual( companySchema.statementsFrom( company.getStatements() ) ); + } ); + + it( 'saves the edited label', async () => { + const wrapper = await openForSubject(); + await typeLabel( wrapper, 'Acme Holdings' ); + + await save( wrapper ); + await flushPromises(); + + expect( savedSubject().getLabel() ).toBe( 'Acme Holdings' ); + } ); + + it( 'saves the edited statements', async () => { + const wrapper = await openForSubject(); + await textInputAt( wrapper, 1 ).vm.$emit( 'update:modelValue', 'Acme Limited' ); + + await save( wrapper ); + await flushPromises(); + + expect( savedSubject().getStatements().get( new PropertyName( 'tradingName' ) ).value ) + .toEqual( newStringValue( 'Acme Limited' ) ); + } ); + + it( 'keeps the identity of the subject it saves', async () => { + const wrapper = await openForSubject(); + + await save( wrapper ); + await flushPromises(); + + expect( savedSubject().getId() ).toEqual( new SubjectId( SUBJECT_ID ) ); + } ); + + it( 'trims the label', async () => { + const wrapper = await openForSubject(); + await typeLabel( wrapper, ' Acme Holdings ' ); + + await save( wrapper ); + await flushPromises(); + + expect( savedSubject().getLabel() ).toBe( 'Acme Holdings' ); + } ); + + it( 'saves nothing when the label is blank', async () => { + const wrapper = await openForSubject(); + await typeLabel( wrapper, ' ' ); + + await save( wrapper ); + await flushPromises(); + + expect( useSubjectStore().updateSubject ).not.toHaveBeenCalled(); + } ); + + it( 'closes after saving', async () => { + const wrapper = await openForSubject(); + + await save( wrapper ); + await flushPromises(); + + expect( state.open ).toBe( false ); + expect( state.subjectId ).toBeNull(); + } ); + + it( 'stays open when saving fails', async () => { + useSubjectStore().updateSubject = vi.fn().mockRejectedValue( new Error( 'Save failed' ) ); + const wrapper = await openForSubject(); + + await save( wrapper ); + await flushPromises(); + + expect( state.open ).toBe( true ); + expect( mw.notify ).toHaveBeenCalledWith( 'Save failed', { type: 'error' } ); + } ); + + it( 'closes and reports when the subject cannot be loaded', async () => { + useSubjectStore().getOrFetchSubject = vi.fn().mockRejectedValue( new Error( 'No such subject' ) ); + + const wrapper = await openForSubject(); + + expect( wrapper.findComponent( SubjectEditor ).exists() ).toBe( false ); + expect( state.open ).toBe( false ); + expect( mw.notify ).toHaveBeenCalledWith( 'No such subject', { type: 'error' } ); + } ); + + it( 'closes when cancelled', async () => { + const wrapper = await openForSubject(); + + await wrapper.findComponent( CdxDialog ).vm.$emit( 'default' ); + + expect( state.open ).toBe( false ); + expect( state.subjectId ).toBeNull(); + } ); + + it( 'closes when dismissed', async () => { + const wrapper = await openForSubject(); + + await wrapper.findComponent( CdxDialog ).vm.$emit( 'update:open', false ); + + expect( state.open ).toBe( false ); + expect( state.subjectId ).toBeNull(); + } ); + + it( 'shows no previously loaded subject while the next one is loading', async () => { + const wrapper = await openForSubject(); + await wrapper.findComponent( CdxDialog ).vm.$emit( 'default' ); + + useSubjectStore().getOrFetchSubject = vi.fn().mockReturnValue( pendingForever() ); + state.subjectId = 's1demo5sssssss2'; + state.open = true; + await flushPromises(); + + expect( wrapper.findComponent( SubjectEditor ).exists() ).toBe( false ); + } ); +} ); diff --git a/resources/ext.neowiki/tests/RedHerb/RedHerbCard.spec.ts b/resources/ext.neowiki/tests/RedHerb/RedHerbCard.spec.ts new file mode 100644 index 000000000..b6eaec027 --- /dev/null +++ b/resources/ext.neowiki/tests/RedHerb/RedHerbCard.spec.ts @@ -0,0 +1,228 @@ +import './../neowiki-test-env.ts'; +import { flushPromises, mount, VueWrapper } from '@vue/test-utils'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import type { Pinia } from 'pinia'; +import RedHerbCard from '@redherb/RedHerbCard.vue'; +import { Layout } from '@/domain/Layout.ts'; +import { PropertyName, createPropertyDefinitionFromJson } from '@/domain/PropertyDefinition.ts'; +import { PropertyDefinitionList } from '@/domain/PropertyDefinitionList.ts'; +import { Schema } from '@/domain/Schema.ts'; +import { Statement } from '@/domain/Statement.ts'; +import { StatementList } from '@/domain/StatementList.ts'; +import { Subject } from '@/domain/Subject.ts'; +import { SubjectId } from '@/domain/SubjectId.ts'; +import { newStringValue } from '@/domain/Value.ts'; +import { TextType } from '@/domain/propertyTypes/Text.ts'; +import { useLayoutStore } from '@/stores/LayoutStore.ts'; +import { useSchemaStore } from '@/stores/SchemaStore.ts'; +import { useSubjectStore } from '@/stores/SubjectStore.ts'; +import SubjectEditorDialog from '@/components/SubjectEditor/SubjectEditorDialog.vue'; +import { NeoWikiTestServices } from '../NeoWikiTestServices.ts'; +import { createI18nMock, setupMwMock } from '../VueTestHelpers.ts'; +import { COLOR_TYPE_NAME, loadRedHerbFrontend } from './RedHerbRegistration.ts'; + +const SCHEMA_NAME = 'Herb'; +const LAYOUT_NAME = 'HerbCard'; +const SUBJECT_ID = new SubjectId( 's1demo5sssssss1' ); + +const TERM = '.ext-redherb-card__term'; +const VALUE = '.ext-redherb-card__value'; +const COLUMN = '.ext-redherb-card__grid'; +const WIDE_FIELD = '.ext-redherb-card__wide-field'; + +const schema = new Schema( + SCHEMA_NAME, + 'A herb', + new PropertyDefinitionList( [ + createPropertyDefinitionFromJson( 'commonName', { type: TextType.typeName } ), + createPropertyDefinitionFromJson( 'flowerColor', { type: COLOR_TYPE_NAME } ), + createPropertyDefinitionFromJson( 'notes', { type: TextType.typeName } ), + ] ), +); + +const subject = new Subject( + SUBJECT_ID, + 'Red Clover', + SCHEMA_NAME, + new StatementList( [ + new Statement( new PropertyName( 'commonName' ), TextType.typeName, newStringValue( 'Red Clover' ) ), + new Statement( new PropertyName( 'flowerColor' ), COLOR_TYPE_NAME, newStringValue( '#ff5733' ) ), + new Statement( new PropertyName( 'notes' ), TextType.typeName, newStringValue( 'Grows in meadows' ) ), + ] ), +); + +function newLayout( settings: Record ): Layout { + return new Layout( LAYOUT_NAME, SCHEMA_NAME, 'redherb-card', '', [], settings ); +} + +describe( 'RedHerbCard', () => { + let pinia: Pinia; + + beforeAll( async () => { + await loadRedHerbFrontend(); + } ); + + beforeEach( () => { + setupMwMock( { functions: [ 'message', 'msg', 'config', 'notify', 'util' ] } ); + + pinia = createPinia(); + setActivePinia( pinia ); + + useSchemaStore().setSchema( SCHEMA_NAME, schema ); + useSubjectStore().setSubject( subject ); + } ); + + function newWrapper( canEditSubject = false, layoutName: string | undefined = undefined ): VueWrapper { + return mount( RedHerbCard, { + props: { + subjectId: SUBJECT_ID, + canEditSubject: canEditSubject, + layoutName: layoutName, + }, + global: { + plugins: [ pinia ], + provide: NeoWikiTestServices.getServices(), + directives: { tooltip: {} }, + mocks: { $i18n: createI18nMock() }, + }, + } ); + } + + function termsIn( wrapper: VueWrapper, selector: string ): string[] { + return wrapper.findAll( `${ selector } ${ TERM }` ).map( ( term ) => term.text() ); + } + + it( 'renders the subject label', () => { + const wrapper = newWrapper(); + + expect( wrapper.find( '.ext-redherb-card__label' ).text() ).toBe( 'Red Clover' ); + } ); + + it( 'renders a term for every property with a value', () => { + const wrapper = newWrapper(); + + expect( wrapper.findAll( TERM ).map( ( term ) => term.text() ) ) + .toEqual( [ 'commonName', 'flowerColor', 'notes' ] ); + } ); + + it( 'renders each value with the display component of its property type', () => { + const wrapper = newWrapper(); + + const values = wrapper.findAll( VALUE ); + expect( values[ 0 ].text() ).toBe( 'Red Clover' ); + expect( values[ 1 ].find( '.ext-redherb-color-display__swatch' ).exists() ).toBe( true ); + expect( values[ 1 ].text() ).toBe( '#ff5733' ); + } ); + + it( 'omits properties the subject has no value for', () => { + const withoutNotes = new Subject( + SUBJECT_ID, + 'Red Clover', + SCHEMA_NAME, + new StatementList( [ + new Statement( new PropertyName( 'commonName' ), TextType.typeName, newStringValue( 'Red Clover' ) ), + ] ), + ); + useSubjectStore().setSubject( withoutNotes ); + + const wrapper = newWrapper(); + + expect( wrapper.findAll( TERM ).map( ( term ) => term.text() ) ).toEqual( [ 'commonName' ] ); + } ); + + it( 'spreads the properties over two columns', () => { + const wrapper = newWrapper(); + + const columns = wrapper.findAll( COLUMN ); + expect( columns ).toHaveLength( 2 ); + expect( termsIn( wrapper, COLUMN ) ).toEqual( [ 'commonName', 'flowerColor', 'notes' ] ); + expect( columns[ 0 ].findAll( TERM ) ).toHaveLength( 2 ); + } ); + + it( 'renders the properties the layout marks full width outside the columns', () => { + useLayoutStore().setLayout( LAYOUT_NAME, newLayout( { fullWidthProperties: [ 'notes' ] } ) ); + + const wrapper = newWrapper( false, LAYOUT_NAME ); + + expect( termsIn( wrapper, WIDE_FIELD ) ).toEqual( [ 'notes' ] ); + expect( termsIn( wrapper, COLUMN ) ).toEqual( [ 'commonName', 'flowerColor' ] ); + } ); + + it( 'renders no full-width section when the layout does not ask for one', () => { + useLayoutStore().setLayout( LAYOUT_NAME, newLayout( {} ) ); + + const wrapper = newWrapper( false, LAYOUT_NAME ); + + expect( wrapper.findAll( WIDE_FIELD ) ).toHaveLength( 0 ); + expect( termsIn( wrapper, COLUMN ) ).toEqual( [ 'commonName', 'flowerColor', 'notes' ] ); + } ); + + it( 'ignores a full-width setting that is not a list of property names', () => { + useLayoutStore().setLayout( LAYOUT_NAME, newLayout( { fullWidthProperties: 'notes' } ) ); + + const wrapper = newWrapper( false, LAYOUT_NAME ); + + expect( wrapper.findAll( WIDE_FIELD ) ).toHaveLength( 0 ); + } ); + + it( 'links to the schema page of the subject', () => { + const wrapper = newWrapper(); + + expect( wrapper.find( `a[href="/wiki/Schema:${ SCHEMA_NAME }"]` ).exists() ).toBe( true ); + } ); + + it( 'links to the layout page when rendered through a layout', () => { + useLayoutStore().setLayout( LAYOUT_NAME, newLayout( {} ) ); + + const wrapper = newWrapper( false, LAYOUT_NAME ); + + expect( wrapper.find( `a[href="/wiki/Layout:${ LAYOUT_NAME }"]` ).exists() ).toBe( true ); + } ); + + it( 'does not link to a layout page when rendered without a layout', () => { + const wrapper = newWrapper(); + + expect( wrapper.find( 'a[href^="/wiki/Layout:"]' ).exists() ).toBe( false ); + } ); + + it( 'offers no editor to a user who may not edit the subject', () => { + const wrapper = newWrapper( false ); + + expect( wrapper.findComponent( SubjectEditorDialog ).exists() ).toBe( false ); + } ); + + it( 'keeps the editor closed until the edit button is used', () => { + const wrapper = newWrapper( true ); + + expect( wrapper.findComponent( SubjectEditorDialog ).props( 'open' ) ).toBe( false ); + } ); + + it( 'refetches the subject and its schema before opening the editor', async () => { + const subjectStore = useSubjectStore(); + const schemaStore = useSchemaStore(); + subjectStore.fetchSubject = vi.fn().mockResolvedValue( undefined ); + schemaStore.fetchSchema = vi.fn().mockResolvedValue( undefined ); + + const wrapper = newWrapper( true ); + await wrapper.find( '.ext-redherb-card__actions button' ).trigger( 'click' ); + await flushPromises(); + + expect( subjectStore.fetchSubject ).toHaveBeenCalledWith( SUBJECT_ID ); + expect( schemaStore.fetchSchema ).toHaveBeenCalledWith( SCHEMA_NAME ); + expect( wrapper.findComponent( SubjectEditorDialog ).props( 'open' ) ).toBe( true ); + } ); + + it( 'reports a failure to load the latest data instead of opening the editor', async () => { + const subjectStore = useSubjectStore(); + subjectStore.fetchSubject = vi.fn().mockRejectedValue( new Error( 'Network is down' ) ); + useSchemaStore().fetchSchema = vi.fn().mockResolvedValue( undefined ); + + const wrapper = newWrapper( true ); + await wrapper.find( '.ext-redherb-card__actions button' ).trigger( 'click' ); + await flushPromises(); + + expect( mw.notify ).toHaveBeenCalledWith( 'Network is down', { type: 'error' } ); + expect( wrapper.findComponent( SubjectEditorDialog ).props( 'open' ) ).toBe( false ); + } ); +} ); diff --git a/resources/ext.neowiki/tests/RedHerb/RedHerbRegistration.ts b/resources/ext.neowiki/tests/RedHerb/RedHerbRegistration.ts new file mode 100644 index 000000000..cedee718f --- /dev/null +++ b/resources/ext.neowiki/tests/RedHerb/RedHerbRegistration.ts @@ -0,0 +1,70 @@ +import { FrontendRegistrar } from '@/presentation/FrontendRegistrar.ts'; +import { NeoWikiExtension } from '@/NeoWikiExtension.ts'; +import { createPropertyDefinitionFromJson, PropertyDefinition } from '@/domain/PropertyDefinition.ts'; + +export const COLOR_TYPE_NAME = 'color'; +export const CARD_VIEW_TYPE_NAME = 'redherb-card'; + +/** + * Runs RedHerb's registration package file the way ResourceLoader and NeoWiki do: the module + * subscribes to neowiki.registration on load, and NeoWiki fires it with a registrar wired to + * the live registries. Specs that mount RedHerb components need this, because the components + * look their own property type up in the registry. + * + * mw.hook replays its last fire to handlers added afterwards, so the order of the two halves + * does not matter here any more than it does in production. + */ +export async function loadRedHerbFrontend(): Promise { + stubMwHook(); + + await import( '@redherb/init.js' ); + + const extension = NeoWikiExtension.getInstance(); + mw.hook( 'neowiki.registration' ).fire( + new FrontendRegistrar( + extension.getTypeSpecificComponentRegistry(), + extension.getPropertyTypeRegistry(), + extension.getViewTypeRegistry(), + ), + ); +} + +/** + * Builds a color PropertyDefinition through the deserializer, so it passes through the + * createPropertyDefinitionFromJson that RedHerb registered. + */ +export function newColorProperty( json: Record = {} ): PropertyDefinition { + return createPropertyDefinitionFromJson( + ( json.name as string ) ?? 'favouriteColor', + { type: COLOR_TYPE_NAME, ...json }, + ); +} + +interface HookState { + firedArguments: unknown[] | null; + handlers: ( ( ...args: unknown[] ) => void )[]; +} + +function stubMwHook(): void { + const hooks: Record = {}; + + ( globalThis as any ).mw = { + hook: ( name: string ) => { + hooks[ name ] ??= { firedArguments: null, handlers: [] }; + const hook = hooks[ name ]; + + return { + add: ( handler: ( ...args: unknown[] ) => void ): void => { + hook.handlers.push( handler ); + if ( hook.firedArguments !== null ) { + handler( ...hook.firedArguments ); + } + }, + fire: ( ...args: unknown[] ): void => { + hook.firedArguments = args; + hook.handlers.forEach( ( handler ) => handler( ...args ) ); + }, + }; + }, + }; +} diff --git a/resources/ext.neowiki/tests/RedHerb/SubjectFinderPanel.spec.ts b/resources/ext.neowiki/tests/RedHerb/SubjectFinderPanel.spec.ts new file mode 100644 index 000000000..dc7e3595e --- /dev/null +++ b/resources/ext.neowiki/tests/RedHerb/SubjectFinderPanel.spec.ts @@ -0,0 +1,208 @@ +import './../neowiki-test-env.ts'; +import { flushPromises, mount, VueWrapper } from '@vue/test-utils'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import type { Pinia } from 'pinia'; +import { CdxTextInput } from '@wikimedia/codex'; +import SubjectFinderPanel from '@redherb/subjectFinder/SubjectFinderPanel.vue'; +import Infobox from '@/components/Views/Infobox.vue'; +import SubjectLookup from '@/components/common/SubjectLookup.vue'; +import { NeoWikiExtension } from '@/NeoWikiExtension.ts'; +import { createPropertyDefinitionFromJson, PropertyName } from '@/domain/PropertyDefinition.ts'; +import { PropertyDefinitionList } from '@/domain/PropertyDefinitionList.ts'; +import { Schema } from '@/domain/Schema.ts'; +import { Statement } from '@/domain/Statement.ts'; +import { StatementList } from '@/domain/StatementList.ts'; +import { Subject } from '@/domain/Subject.ts'; +import { SubjectId } from '@/domain/SubjectId.ts'; +import { newStringValue } from '@/domain/Value.ts'; +import { TextType } from '@/domain/propertyTypes/Text.ts'; +import { useSchemaStore } from '@/stores/SchemaStore.ts'; +import { useSubjectStore } from '@/stores/SubjectStore.ts'; +import { NeoWikiTestServices } from '../NeoWikiTestServices.ts'; +import { createI18nMock, setupMwMock } from '../VueTestHelpers.ts'; +import { loadRedHerbFrontend } from './RedHerbRegistration.ts'; + +const SCHEMA_NAME = 'Company'; +const SUBJECT_ID = 's1demo5sssssss1'; + +const companySchema = new Schema( + SCHEMA_NAME, + 'A company', + new PropertyDefinitionList( [ + createPropertyDefinitionFromJson( 'tradingName', { type: TextType.typeName } ), + ] ), +); + +const company = new Subject( + new SubjectId( SUBJECT_ID ), + 'Acme', + SCHEMA_NAME, + new StatementList( [ + new Statement( new PropertyName( 'tradingName' ), TextType.typeName, newStringValue( 'Acme Corporation' ) ), + ] ), +); + +function pendingForever(): Promise { + return new Promise( () => { + // Never settles, so the caller stays in its loading state. + } ); +} + +describe( 'SubjectFinderPanel', () => { + let pinia: Pinia; + let loadSubjectsAndSchemas: ReturnType; + + beforeAll( async () => { + await loadRedHerbFrontend(); + } ); + + beforeEach( () => { + setupMwMock( { functions: [ 'message', 'msg', 'config', 'notify', 'util' ] } ); + ( globalThis as any ).mw.log = { error: vi.fn() }; + + pinia = createPinia(); + setActivePinia( pinia ); + + useSchemaStore().setSchema( SCHEMA_NAME, companySchema ); + useSubjectStore().setSubject( company ); + + loadSubjectsAndSchemas = vi.fn().mockResolvedValue( undefined ); + vi.spyOn( NeoWikiExtension.getInstance(), 'getStoreStateLoader' ) + .mockReturnValue( { loadSubjectsAndSchemas: loadSubjectsAndSchemas } as any ); + } ); + + function newWrapper(): VueWrapper { + return mount( SubjectFinderPanel, { + global: { + plugins: [ pinia ], + provide: NeoWikiTestServices.getServices(), + directives: { tooltip: {} }, + mocks: { $i18n: createI18nMock() }, + stubs: { teleport: true }, + }, + } ); + } + + async function typeSchemaName( wrapper: VueWrapper, name: string ): Promise { + const schemaInput = wrapper.findComponent( CdxTextInput ); + + await schemaInput.vm.$emit( 'update:modelValue', name ); + } + + async function selectSubject( wrapper: VueWrapper, subjectId: string | null ): Promise { + await wrapper.findComponent( SubjectLookup ).vm.$emit( 'update:selected', subjectId ); + } + + it( 'offers no subject lookup before a schema is named', () => { + const wrapper = newWrapper(); + + expect( wrapper.findComponent( SubjectLookup ).exists() ).toBe( false ); + } ); + + it( 'offers a subject lookup once a schema is named', async () => { + const wrapper = newWrapper(); + + await typeSchemaName( wrapper, SCHEMA_NAME ); + + expect( wrapper.findComponent( SubjectLookup ).exists() ).toBe( true ); + } ); + + it( 'limits the lookup to subjects of the named schema', async () => { + const wrapper = newWrapper(); + + await typeSchemaName( wrapper, SCHEMA_NAME ); + + expect( wrapper.findComponent( SubjectLookup ).props( 'targetSchema' ) ).toBe( SCHEMA_NAME ); + } ); + + it( 'ignores surrounding whitespace in the schema name', async () => { + const wrapper = newWrapper(); + + await typeSchemaName( wrapper, ` ${ SCHEMA_NAME } ` ); + + expect( wrapper.findComponent( SubjectLookup ).props( 'targetSchema' ) ).toBe( SCHEMA_NAME ); + } ); + + it( 'offers no subject lookup for a schema name of only whitespace', async () => { + const wrapper = newWrapper(); + + await typeSchemaName( wrapper, ' ' ); + + expect( wrapper.findComponent( SubjectLookup ).exists() ).toBe( false ); + } ); + + it( 'renders nothing until a subject is selected', async () => { + const wrapper = newWrapper(); + + await typeSchemaName( wrapper, SCHEMA_NAME ); + + expect( wrapper.findComponent( Infobox ).exists() ).toBe( false ); + } ); + + it( 'loads the data of the selected subject', async () => { + const wrapper = newWrapper(); + await typeSchemaName( wrapper, SCHEMA_NAME ); + + await selectSubject( wrapper, SUBJECT_ID ); + await flushPromises(); + + expect( loadSubjectsAndSchemas ).toHaveBeenCalledWith( new Set( [ SUBJECT_ID ] ) ); + } ); + + it( 'renders the selected subject once its data is loaded', async () => { + const wrapper = newWrapper(); + await typeSchemaName( wrapper, SCHEMA_NAME ); + + await selectSubject( wrapper, SUBJECT_ID ); + await flushPromises(); + + expect( wrapper.findComponent( Infobox ).exists() ).toBe( true ); + expect( wrapper.find( '.ext-redherb-subject-finder__rendered' ).text() ).toContain( 'Acme' ); + } ); + + it( 'renders the subject as read-only', async () => { + const wrapper = newWrapper(); + await typeSchemaName( wrapper, SCHEMA_NAME ); + + await selectSubject( wrapper, SUBJECT_ID ); + await flushPromises(); + + expect( wrapper.findComponent( Infobox ).props( 'canEditSubject' ) ).toBe( false ); + } ); + + it( 'waits for the data before rendering the subject', async () => { + loadSubjectsAndSchemas.mockReturnValue( pendingForever() ); + const wrapper = newWrapper(); + await typeSchemaName( wrapper, SCHEMA_NAME ); + + await selectSubject( wrapper, SUBJECT_ID ); + await flushPromises(); + + expect( wrapper.findComponent( Infobox ).exists() ).toBe( false ); + } ); + + it( 'stops rendering a subject when the selection is cleared', async () => { + const wrapper = newWrapper(); + await typeSchemaName( wrapper, SCHEMA_NAME ); + await selectSubject( wrapper, SUBJECT_ID ); + await flushPromises(); + + await selectSubject( wrapper, null ); + await flushPromises(); + + expect( wrapper.findComponent( Infobox ).exists() ).toBe( false ); + } ); + + it( 'reports a failure to load the subject instead of rendering it', async () => { + loadSubjectsAndSchemas.mockRejectedValue( new Error( 'Network is down' ) ); + const wrapper = newWrapper(); + await typeSchemaName( wrapper, SCHEMA_NAME ); + + await selectSubject( wrapper, SUBJECT_ID ); + await flushPromises(); + + expect( wrapper.findComponent( Infobox ).exists() ).toBe( false ); + expect( mw.notify ).toHaveBeenCalledWith( 'Network is down', { type: 'error' } ); + } ); +} ); diff --git a/resources/ext.neowiki/tests/RedHerb/redherb-modules.d.ts b/resources/ext.neowiki/tests/RedHerb/redherb-modules.d.ts new file mode 100644 index 000000000..5308865e6 --- /dev/null +++ b/resources/ext.neowiki/tests/RedHerb/redherb-modules.d.ts @@ -0,0 +1,18 @@ +/** + * RedHerb's package files are plain JavaScript, so they carry no types of their own. + * Declaring them here keeps the specs importing them by their @redherb alias without + * dragging their ResourceLoader-flavoured CommonJS through vue-tsc. + * + * Each package file is reachable as a default export, because that is what a CommonJS + * module.exports becomes once resourceLoaderCommonJs has rewritten it. + */ +declare module '@redherb/*.vue' { + import type { Component } from 'vue'; + const component: Component; + export default component; +} + +declare module '@redherb/*.js' { + const moduleExports: any; + export default moduleExports; +} diff --git a/resources/ext.neowiki/tests/RedHerb/registration.spec.ts b/resources/ext.neowiki/tests/RedHerb/registration.spec.ts new file mode 100644 index 000000000..3c49d0fd8 --- /dev/null +++ b/resources/ext.neowiki/tests/RedHerb/registration.spec.ts @@ -0,0 +1,86 @@ +import './../neowiki-test-env.ts'; +import { mount } from '@vue/test-utils'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { NeoWikiExtension } from '@/NeoWikiExtension.ts'; +import { PropertyType } from '@/domain/PropertyType.ts'; +import { newStringValue, ValueType } from '@/domain/Value.ts'; +import { TypeSpecificComponentRegistry } from '@/TypeSpecificComponentRegistry.ts'; +import { ViewTypeRegistry } from '@/ViewTypeRegistry.ts'; +import { setupMwMock } from '../VueTestHelpers.ts'; +import { CARD_VIEW_TYPE_NAME, COLOR_TYPE_NAME, loadRedHerbFrontend, newColorProperty } from './RedHerbRegistration.ts'; + +describe( 'RedHerb registration', () => { + let colorType: PropertyType; + let componentRegistry: TypeSpecificComponentRegistry; + let viewTypeRegistry: ViewTypeRegistry; + + beforeAll( async () => { + await loadRedHerbFrontend(); + + const extension = NeoWikiExtension.getInstance(); + colorType = extension.getPropertyTypeRegistry().getType( COLOR_TYPE_NAME ); + componentRegistry = extension.getTypeSpecificComponentRegistry(); + viewTypeRegistry = extension.getViewTypeRegistry(); + } ); + + beforeEach( () => { + setupMwMock(); + } ); + + it( 'registers the color property type as string-valued', () => { + expect( colorType.getValueType() ).toBe( ValueType.String ); + } ); + + it( 'registers a display component that renders a color', () => { + const wrapper = mount( componentRegistry.getValueDisplayComponent( COLOR_TYPE_NAME ), { + props: { + value: newStringValue( '#ff5733' ), + property: newColorProperty(), + }, + } ); + + expect( wrapper.text() ).toContain( '#ff5733' ); + } ); + + it( 'registers an input component for the color type', () => { + expect( componentRegistry.getValueEditingComponent( COLOR_TYPE_NAME ) ).toBeDefined(); + } ); + + it( 'registers an attributes editor for the color type', () => { + expect( componentRegistry.getAttributesEditor( COLOR_TYPE_NAME ) ).toBeDefined(); + } ); + + it( 'registers a label and icon for the color type', () => { + expect( componentRegistry.getLabel( COLOR_TYPE_NAME ) ).toBe( 'redherb-property-type-color' ); + expect( componentRegistry.getIcon( COLOR_TYPE_NAME ) ).toBeDefined(); + } ); + + it( 'registers the card view type', () => { + expect( viewTypeRegistry.hasType( CARD_VIEW_TYPE_NAME ) ).toBe( true ); + } ); + + it( 'offers an example value the display component renders as a color', () => { + const example = colorType.getExampleValue( newColorProperty() ); + + expect( example ).toEqual( newStringValue( '#ff5733' ) ); + } ); + + it( 'keeps the palette from the property JSON', () => { + const property = newColorProperty( { allowedColors: [ '#ff5733', '#000000' ] } ); + + expect( property ).toHaveProperty( 'allowedColors', [ '#ff5733', '#000000' ] ); + } ); + + it( 'falls back to an empty palette when the property JSON has no usable one', () => { + const property = newColorProperty( { allowedColors: 'not a list' } ); + + expect( property ).toHaveProperty( 'allowedColors', [] ); + } ); + + it( 'keeps the shared property attributes alongside the palette', () => { + const property = newColorProperty( { description: 'Pick a color', required: true } ); + + expect( property.description ).toBe( 'Pick a color' ); + expect( property.required ).toBe( true ); + } ); +} ); diff --git a/resources/ext.neowiki/tests/RedHerb/resourceLoaderCommonJs.ts b/resources/ext.neowiki/tests/RedHerb/resourceLoaderCommonJs.ts new file mode 100644 index 000000000..ce9cc1512 --- /dev/null +++ b/resources/ext.neowiki/tests/RedHerb/resourceLoaderCommonJs.ts @@ -0,0 +1,117 @@ +import type { Plugin } from 'vite'; + +export interface ResourceLoaderCommonJsOptions { + /** Directory holding the ResourceLoader package files to transform. */ + sourceDir: string; + /** + * What ResourceLoader hands those package files, keyed by the module name they require. + * Module names absent from this map are left alone and resolve as siblings in sourceDir. + */ + providedModules: Record; + /** + * File to resolve the provided modules as if it had imported them. Resolving from + * sourceDir instead would miss ext.neowiki's node_modules, since RedHerb's package files + * live outside it and have no dependencies of their own. It also keeps the package files + * on the vue, Codex and ext.neowiki instances the rest of the suite uses. + */ + resolveFrom: string; +} + +/** + * Lets Vite load ResourceLoader package files, so the RedHerb example extension can be + * tested with the same toolchain as ext.neowiki itself. + * + * ResourceLoader wraps each package file in a CommonJS closure, so RedHerb's modules pull + * their dependencies in with `require()` and expose themselves through `module.exports`. + * Vite only understands ES modules, so this rewrites those two constructs. + * + * A namespace whose only export is `default` is a rewritten package file, and gets unwrapped + * to whatever it assigned to `module.exports`. The modules ResourceLoader provides have no + * default export, so they stay namespaces. + */ +export function resourceLoaderCommonJs( options: ResourceLoaderCommonJsOptions ): Plugin { + return { + name: 'resource-loader-commonjs', + enforce: 'pre', + async transform( code: string, id: string ): Promise<{ code: string; map: null } | undefined> { + const path = id.split( '?' )[ 0 ]; + if ( !path.startsWith( options.sourceDir + '/' ) ) { + return undefined; + } + + const resolveModule = async ( name: string ): Promise => { + const target = options.providedModules[ name ]; + if ( target === undefined ) { + return name; + } + const resolved = await this.resolve( target, options.resolveFrom ); + if ( resolved === null ) { + throw new Error( `Cannot resolve ${ target }, provided to ResourceLoader package files as ${ name }` ); + } + return resolved.id; + }; + + if ( path.endsWith( '.vue' ) ) { + return { code: await transformScriptBlock( code, resolveModule ), map: null }; + } + + if ( path.endsWith( '.js' ) ) { + return { code: await toEsModule( code, resolveModule ), map: null }; + } + + return undefined; + }, + }; +} + +type ModuleResolver = ( name: string ) => Promise; + +const SCRIPT_BLOCK = /(]*>)([\s\S]*?)(<\/script>)/; + +async function transformScriptBlock( sfc: string, resolveModule: ModuleResolver ): Promise { + const match = SCRIPT_BLOCK.exec( sfc ); + if ( match === null ) { + return sfc; + } + + const [ block, open, script, close ] = match; + const rewritten = open + await toEsModule( script, resolveModule ) + close; + + // Spliced rather than replaced, because a replacement string gives $& and friends + // their substitution meaning, which would mangle any script containing them. + return sfc.slice( 0, match.index ) + rewritten + sfc.slice( match.index + block.length ); +} + +const REQUIRE_CALL = /require\(\s*(['"])(.+?)\1\s*\)/g; + +async function toEsModule( code: string, resolveModule: ModuleResolver ): Promise { + const moduleNames: string[] = []; + + const body = code.replace( REQUIRE_CALL, ( _match, _quote: string, name: string ) => { + if ( !moduleNames.includes( name ) ) { + moduleNames.push( name ); + } + return interopName( moduleNames.indexOf( name ) ); + } ); + + const imports = await Promise.all( moduleNames.map( async ( name, index ) => + `import * as ${ namespaceName( index ) } from '${ await resolveModule( name ) }';\n` + + `const ${ interopName( index ) } = ${ namespaceName( index ) }.default ?? ${ namespaceName( index ) };`, + ) ); + + return [ + ...imports, + 'const module = { exports: {} };', + 'let exports = module.exports;', + body, + 'export default module.exports;', + ].join( '\n' ); +} + +function namespaceName( index: number ): string { + return `__rlNamespace${ index }`; +} + +function interopName( index: number ): string { + return `__rlModule${ index }`; +} diff --git a/resources/ext.neowiki/vitest.config.ts b/resources/ext.neowiki/vitest.config.ts index 0eebd637a..cca448c05 100644 --- a/resources/ext.neowiki/vitest.config.ts +++ b/resources/ext.neowiki/vitest.config.ts @@ -1,7 +1,30 @@ import { defineConfig, mergeConfig } from 'vitest/config'; +import { fileURLToPath, URL } from 'node:url'; import viteConfig from './vite.config'; +import { resourceLoaderCommonJs } from './tests/RedHerb/resourceLoaderCommonJs'; + +const redHerbResources = fileURLToPath( new URL( '../../tests/RedHerb/resources', import.meta.url ) ); +const publicApi = fileURLToPath( new URL( './src/public-api.ts', import.meta.url ) ); export default mergeConfig( viteConfig, defineConfig( { + plugins: [ + resourceLoaderCommonJs( { + sourceDir: redHerbResources, + resolveFrom: publicApi, + providedModules: { + vue: 'vue', + 'ext.neowiki': publicApi, + // Generated by CodexModule, so they exist only at runtime. + './codex.js': '@wikimedia/codex', + './icons.json': '@wikimedia/codex-icons', + }, + } ), + ], + resolve: { + alias: { + '@redherb': redHerbResources, + }, + }, test: { environment: 'jsdom', globals: true,