import { ComponentType, type LatLongFieldComponent } from '@defra/forms-model' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { LatLongField } from '~/src/server/plugins/engine/components/LatLongField.js' import { getAnswer, type Field } from '~/src/server/plugins/engine/components/helpers/components.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import { type FormSubmissionError } from '~/src/server/plugins/engine/types.js' import definition from '~/test/form/definitions/blank.js' describe('LatLongField', () => { let model: FormModel beforeEach(() => { model = new FormModel(definition, { basePath: 'test' }) }) describe('Defaults', () => { let def: LatLongFieldComponent let collection: ComponentCollection let field: Field beforeEach(() => { def = { title: 'Example lat long', shortDescription: 'Example location', name: 'myComponent', type: ComponentType.LatLongField, options: {}, schema: {} } satisfies LatLongFieldComponent collection = new ComponentCollection([def], { model }) field = collection.fields[0] }) describe('Schema', () => { it('uses collection titles as labels', () => { const { formSchema } = collection const { keys } = formSchema.describe() expect(keys).toHaveProperty( 'myComponent__latitude', expect.objectContaining({ flags: expect.objectContaining({ label: 'Latitude' }) }) ) expect(keys).toHaveProperty( 'myComponent__longitude', expect.objectContaining({ flags: expect.objectContaining({ label: 'Longitude' }) }) ) }) it('uses collection names as keys', () => { const { formSchema } = collection const { keys } = formSchema.describe() expect(field.keys).toEqual([ 'myComponent', 'myComponent__latitude', 'myComponent__longitude' ]) expect(field.collection?.keys).not.toHaveProperty('myComponent') for (const key of field.collection?.keys ?? []) { expect(keys).toHaveProperty(key) } }) it('is required by default', () => { const { formSchema } = collection const { keys } = formSchema.describe() expect(keys).toHaveProperty( 'myComponent__latitude', expect.objectContaining({ flags: expect.objectContaining({ presence: 'required' }) }) ) expect(keys).toHaveProperty( 'myComponent__longitude', expect.objectContaining({ flags: expect.objectContaining({ presence: 'required' }) }) ) }) it('is optional when configured', () => { const collectionOptional = new ComponentCollection( [ { title: 'Example lat long', name: 'myComponent', type: ComponentType.LatLongField, options: { required: false }, schema: {} } ], { model } ) const { formSchema } = collectionOptional const { keys } = formSchema.describe() expect(keys).toHaveProperty( 'myComponent__latitude', expect.objectContaining({ allow: [''] }) ) expect(keys).toHaveProperty( 'myComponent__longitude', expect.objectContaining({ allow: [''] }) ) const result1 = collectionOptional.validate( getFormData({ latitude: '', longitude: '' }) ) const result2 = collectionOptional.validate( getFormData({ latitude: '51.5', longitude: '' }) ) expect(result1.errors).toBeUndefined() expect(result2.errors).toBeTruthy() expect(result2.errors?.length).toBeGreaterThan(0) }) it('accepts valid values', () => { const result1 = collection.validate( getFormData({ latitude: '51.519450', longitude: '-0.127758' }) ) const result2 = collection.validate( getFormData({ latitude: '50.5', longitude: '-8.9' }) ) expect(result1.errors).toBeUndefined() expect(result2.errors).toBeUndefined() }) it('adds errors for empty value when short description exists', () => { const result = collection.validate( getFormData({ latitude: '', longitude: '' }) ) expect(result.errors).toBeTruthy() expect(result.errors?.length).toBe(2) }) it('adds errors for invalid values', () => { const result1 = collection.validate( getFormData({ latitude: 'invalid', longitude: 'invalid' }) ) expect(result1.errors).toBeTruthy() }) }) describe('State', () => { it('returns text from state', () => { const state1 = getFormState({ latitude: 51.51945, longitude: -0.127758 }) const state2 = getFormState({}) const answer1 = getAnswer(field, state1) const answer2 = getAnswer(field, state2) expect(answer1).toBe('Latitude: 51.51945
Longitude: -0.127758
') expect(answer2).toBe('') }) it('returns payload from state', () => { const state1 = getFormState({ latitude: 51.51945, longitude: -0.127758 }) const state2 = getFormState({}) const payload1 = field.getFormDataFromState(state1) const payload2 = field.getFormDataFromState(state2) expect(payload1).toEqual( getFormData({ latitude: 51.51945, longitude: -0.127758 }) ) expect(payload2).toEqual(getFormData({})) }) it('returns value from state', () => { const state1 = getFormState({ latitude: 51.51945, longitude: -0.127758 }) const state2 = getFormState({}) const value1 = field.getFormValueFromState(state1) const value2 = field.getFormValueFromState(state2) expect(value1).toEqual({ latitude: 51.51945, longitude: -0.127758 }) expect(value2).toBeUndefined() }) it('returns context for conditions and form submission', () => { const state1 = getFormState({ latitude: 51.51945, longitude: -0.127758 }) const state2 = getFormState({}) const value1 = field.getContextValueFromState(state1) const value2 = field.getContextValueFromState(state2) expect(value1).toBe('Latitude: 51.51945\nLongitude: -0.127758') expect(value2).toBeNull() }) it('returns state from payload', () => { const payload1 = getFormData({ latitude: 51.51945, longitude: -0.127758 }) const payload2 = getFormData({}) const value1 = field.getStateFromValidForm(payload1) const value2 = field.getStateFromValidForm(payload2) expect(value1).toEqual( getFormState({ latitude: 51.51945, longitude: -0.127758 }) ) expect(value2).toEqual(getFormState({})) }) }) describe('View model', () => { it('sets Nunjucks component defaults', () => { const payload = getFormData({ latitude: 51.51945, longitude: -0.127758 }) const viewModel = field.getViewModel(payload) expect(viewModel).toEqual( expect.objectContaining({ fieldset: { legend: { text: def.title, classes: 'govuk-fieldset__legend--m' } }, items: [ expect.objectContaining({ label: expect.objectContaining({ text: 'Latitude' }), name: 'myComponent__latitude', id: 'myComponent__latitude', value: 51.51945 }), expect.objectContaining({ label: expect.objectContaining({ text: 'Longitude' }), name: 'myComponent__longitude', id: 'myComponent__longitude', value: -0.127758 }) ] }) ) }) it('includes instruction text when provided', () => { const componentWithInstruction = new LatLongField( { ...def, options: { instructionText: 'Enter coordinates in **decimal**' } }, { model } ) const viewModel = componentWithInstruction.getViewModel( getFormData({ latitude: 51.51945, longitude: -0.127758 }) ) const instructionText = 'instructionText' in viewModel ? viewModel.instructionText : undefined expect(instructionText).toBeTruthy() expect(instructionText).toContain('decimal') }) it('handles errors when component has validation errors', () => { const payload = getFormData({ latitude: '', longitude: '' }) const errors = [ { name: 'myComponent', text: 'Error message', path: ['myComponent'], href: '#myComponent' } ] const viewModel = field.getViewModel(payload, errors) // Check that error is passed to the viewModel expect(viewModel.errors).toEqual(errors) // Items should be present with their basic structure expect(viewModel.items?.[0]).toEqual( expect.objectContaining({ id: 'myComponent__latitude', name: 'myComponent__latitude' }) ) expect(viewModel.items?.[1]).toEqual( expect.objectContaining({ id: 'myComponent__longitude', name: 'myComponent__longitude' }) ) }) it('getViewErrors returns all errors for error summary', () => { const errors: FormSubmissionError[] = [ { name: 'myComponent__latitude', text: 'Enter valid latitude', path: ['myComponent__latitude'], href: '#myComponent__latitude' }, { name: 'myComponent__longitude', text: 'Enter valid longitude', path: ['myComponent__longitude'], href: '#myComponent__longitude' } ] const viewErrors = field.getViewErrors(errors) expect(viewErrors).toHaveLength(2) expect(viewErrors).toEqual([ expect.objectContaining({ text: 'Enter valid latitude' }), expect.objectContaining({ text: 'Enter valid longitude' }) ]) }) }) describe('AllPossibleErrors', () => { it('should return errors from instance method', () => { const errors = field.getAllPossibleErrors() expect(errors.baseErrors).not.toBeEmpty() expect(errors.advancedSettingsErrors).not.toBeEmpty() }) it('should return errors from static method', () => { const staticErrors = LatLongField.getAllPossibleErrors() expect(staticErrors.baseErrors).not.toBeEmpty() expect(staticErrors.advancedSettingsErrors).not.toBeEmpty() }) it('instance method should delegate to static method', () => { const staticResult = LatLongField.getAllPossibleErrors() const instanceResult = field.getAllPossibleErrors() // Compare structure and content expect(instanceResult.baseErrors).toHaveLength( staticResult.baseErrors.length ) expect(instanceResult.advancedSettingsErrors).toHaveLength( staticResult.advancedSettingsErrors.length ) // Compare error types expect(instanceResult.baseErrors.map((e) => e.type)).toEqual( staticResult.baseErrors.map((e) => e.type) ) expect( instanceResult.advancedSettingsErrors.map((e) => e.type) ).toEqual(staticResult.advancedSettingsErrors.map((e) => e.type)) // Compare rendered templates expect( instanceResult.baseErrors.map((e) => typeof e.template === 'object' && 'rendered' in e.template ? e.template.rendered : e.template ) ).toEqual( staticResult.baseErrors.map((e) => typeof e.template === 'object' && 'rendered' in e.template ? e.template.rendered : e.template ) ) }) }) }) describe('Validation', () => { describe.each([ { description: 'Trim empty spaces', component: createLatLongComponent(), assertions: [ { input: getFormData({ latitude: ' 51.5', longitude: ' -0.1' }), output: { value: getFormData({ latitude: 51.5, longitude: -0.1 }) } }, { input: getFormData({ latitude: '51.5 ', longitude: '-0.1 ' }), output: { value: getFormData({ latitude: 51.5, longitude: -0.1 }) } } ] }, { description: 'Schema min and max for latitude', component: { title: 'Example lat long', name: 'myComponent', type: ComponentType.LatLongField, options: {}, schema: { latitude: { min: 50, max: 55 } } } satisfies LatLongFieldComponent, assertions: [ { input: getFormData({ latitude: '49.9', longitude: '-0.1' }), output: { value: getFormData({ latitude: 49.9, longitude: -0.1 }), errors: [ expect.objectContaining({ text: expect.stringMatching( /Latitude for .* must be between 50 and 55/ ) }) ] } }, { input: getFormData({ latitude: '55.1', longitude: '-0.1' }), output: { value: getFormData({ latitude: 55.1, longitude: -0.1 }), errors: [ expect.objectContaining({ text: expect.stringMatching( /Latitude for .* must be between 50 and 55/ ) }) ] } } ] }, { description: 'Schema min and max for longitude', component: { title: 'Example lat long', name: 'myComponent', type: ComponentType.LatLongField, options: {}, schema: { longitude: { min: -5, max: 1 } } } satisfies LatLongFieldComponent, assertions: [ { input: getFormData({ latitude: '51.5', longitude: '-5.1' }), output: { value: getFormData({ latitude: 51.5, longitude: -5.1 }), errors: [ expect.objectContaining({ text: expect.stringMatching( /Longitude for .* must be between -5 and 1/ ) }) ] } }, { input: getFormData({ latitude: '51.5', longitude: '1.1' }), output: { value: getFormData({ latitude: 51.5, longitude: 1.1 }), errors: [ expect.objectContaining({ text: expect.stringMatching( /Longitude for .* must be between -5 and 1/ ) }) ] } } ] }, { description: 'Precision validation', component: { title: 'Example lat long', name: 'myComponent', type: ComponentType.LatLongField, options: {}, schema: {} } satisfies LatLongFieldComponent, assertions: [ { input: getFormData({ latitude: '51.12345678', longitude: '-0.1' }), output: { value: getFormData({ latitude: 51.12345678, longitude: -0.1 }), errors: [ expect.objectContaining({ text: 'Latitude must have no more than 7 decimal places' }) ] } }, { input: getFormData({ latitude: '51.5', longitude: '-0.12345678' }), output: { value: getFormData({ latitude: 51.5, longitude: -0.12345678 }), errors: [ expect.objectContaining({ text: 'Longitude must have no more than 7 decimal places' }) ] } } ] }, { description: 'Minimum precision validation', component: createLatLongComponent(), assertions: [ { input: getFormData({ latitude: '52', longitude: '-1' }), output: { value: getFormData({ latitude: 52, longitude: -1 }) } }, { input: getFormData({ latitude: '52.1', longitude: '-1.5' }), output: { value: getFormData({ latitude: 52.1, longitude: -1.5 }) } }, { input: getFormData({ latitude: '52.123456', longitude: '-1.123456' }), output: { value: getFormData({ latitude: 52.123456, longitude: -1.123456 }) } } ] }, { description: 'Length and precision validation', component: createLatLongComponent(), assertions: [ { input: getFormData({ latitude: '52', longitude: '-1.5' }), output: { value: getFormData({ latitude: 52, longitude: -1.5 }) } }, // Latitude too long { input: getFormData({ latitude: '52.12345678', longitude: '-1.5' }), output: { value: getFormData({ latitude: 52.12345678, longitude: -1.5 }), errors: [ expect.objectContaining({ text: 'Latitude must have no more than 7 decimal places' }) ] } }, { input: getFormData({ latitude: '52.1', longitude: '-1' }), output: { value: getFormData({ latitude: 52.1, longitude: -1 }) } }, // Longitude too long { input: getFormData({ latitude: '52.1', longitude: '-1.12345678' }), output: { value: getFormData({ latitude: 52.1, longitude: -1.12345678 }), errors: [ expect.objectContaining({ text: 'Longitude must have no more than 7 decimal places' }) ] } }, // Valid values { input: getFormData({ latitude: '52.1', longitude: '-1.5' }), output: { value: getFormData({ latitude: 52.1, longitude: -1.5 }) } }, { input: getFormData({ latitude: '52.1234', longitude: '-1.123' }), output: { value: getFormData({ latitude: 52.1234, longitude: -1.123 }) } } ] }, { description: 'Invalid format', component: createLatLongComponent(), assertions: [ { input: getFormData({ latitude: 'invalid', longitude: '-0.1' }), output: { value: getFormData({ latitude: 'invalid', longitude: -0.1 }), errors: [ expect.objectContaining({ text: expect.stringMatching( /Enter a valid latitude for .* like 51.519450/ ) }) ] } }, { input: getFormData({ latitude: '51.5', longitude: 'invalid' }), output: { value: getFormData({ latitude: 51.5, longitude: 'invalid' }), errors: [ expect.objectContaining({ text: expect.stringMatching( /Enter a valid longitude for .* like -0.127758/ ) }) ] } } ] }, { description: 'Optional field', component: { title: 'Example lat long', name: 'myComponent', type: ComponentType.LatLongField, options: { required: false }, schema: {} } satisfies LatLongFieldComponent, assertions: [ { input: getFormData({ latitude: '', longitude: '' }), output: { value: getFormData({ latitude: '', longitude: '' }) } } ] } ])('$description', ({ component: def, assertions }) => { let collection: ComponentCollection beforeEach(() => { collection = new ComponentCollection([def], { model }) }) it.each([...assertions])( 'validates custom example', ({ input, output }) => { const result = collection.validate(input) expect(result).toEqual(output) } ) }) }) }) /** * Factory function to create a default LatLongField component with optional overrides */ function createLatLongComponent( overrides: Partial = {} ): LatLongFieldComponent { return { title: 'Example lat long', name: 'myComponent', type: ComponentType.LatLongField, options: {}, schema: {}, ...overrides } satisfies LatLongFieldComponent } function getFormData( value: | { latitude?: string | number; longitude?: string | number } | Record ) { if ('latitude' in value || 'longitude' in value) { return { myComponent__latitude: value.latitude, myComponent__longitude: value.longitude } } return {} } function getFormState( value: | { latitude?: number longitude?: number } | Record ) { if ('latitude' in value || 'longitude' in value) { return { myComponent__latitude: value.latitude ?? null, myComponent__longitude: value.longitude ?? null } } return { myComponent__latitude: null, myComponent__longitude: null } }