Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci-ts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
160 changes: 160 additions & 0 deletions resources/ext.neowiki/tests/RedHerb/ColorAttributesEditor.spec.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement | null>, 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<Element> {
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<void> {
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 );
} );
} );
76 changes: 76 additions & 0 deletions resources/ext.neowiki/tests/RedHerb/ColorDisplay.spec.ts
Original file line number Diff line number Diff line change
@@ -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' );
} );
} );
132 changes: 132 additions & 0 deletions resources/ext.neowiki/tests/RedHerb/ColorInput.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 );
} );
} );
Loading