import {
ComponentType,
type EastingNorthingFieldComponent
} from '@defra/forms-model'
import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js'
import { EastingNorthingField } from '~/src/server/plugins/engine/components/EastingNorthingField.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('EastingNorthingField', () => {
let model: FormModel
beforeEach(() => {
model = new FormModel(definition, {
basePath: 'test'
})
})
describe('Defaults', () => {
let def: EastingNorthingFieldComponent
let collection: ComponentCollection
let field: Field
beforeEach(() => {
def = {
title: 'Example easting northing',
shortDescription: 'Example location',
name: 'myComponent',
type: ComponentType.EastingNorthingField,
options: {},
schema: {}
} satisfies EastingNorthingFieldComponent
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__easting',
expect.objectContaining({
flags: expect.objectContaining({ label: 'Easting' })
})
)
expect(keys).toHaveProperty(
'myComponent__northing',
expect.objectContaining({
flags: expect.objectContaining({ label: 'Northing' })
})
)
})
it('uses collection names as keys', () => {
const { formSchema } = collection
const { keys } = formSchema.describe()
expect(field.keys).toEqual([
'myComponent',
'myComponent__easting',
'myComponent__northing'
])
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__easting',
expect.objectContaining({
flags: expect.objectContaining({ presence: 'required' })
})
)
expect(keys).toHaveProperty(
'myComponent__northing',
expect.objectContaining({
flags: expect.objectContaining({ presence: 'required' })
})
)
})
it('is optional when configured', () => {
const collectionOptional = new ComponentCollection(
[
{
title: 'Example easting northing',
name: 'myComponent',
type: ComponentType.EastingNorthingField,
options: { required: false },
schema: {}
}
],
{ model }
)
const { formSchema } = collectionOptional
const { keys } = formSchema.describe()
expect(keys).toHaveProperty(
'myComponent__easting',
expect.objectContaining({ allow: [''] })
)
expect(keys).toHaveProperty(
'myComponent__northing',
expect.objectContaining({ allow: [''] })
)
const result1 = collectionOptional.validate(
getFormData({
easting: '',
northing: ''
})
)
const result2 = collectionOptional.validate(
getFormData({
easting: '12345',
northing: ''
})
)
expect(result1.errors).toBeUndefined()
expect(result2.errors).toBeTruthy()
expect(result2.errors?.length).toBeGreaterThan(0)
})
it('accepts valid values', () => {
const result1 = collection.validate(
getFormData({
easting: '12345',
northing: '1234567'
})
)
const result2 = collection.validate(
getFormData({
easting: '0',
northing: '0'
})
)
expect(result1.errors).toBeUndefined()
expect(result2.errors).toBeUndefined()
})
it('adds errors for empty value when short description exists', () => {
const result = collection.validate(
getFormData({
easting: '',
northing: ''
})
)
expect(result.errors).toBeTruthy()
expect(result.errors?.length).toBe(2)
})
it('adds errors for invalid values', () => {
const result1 = collection.validate(
getFormData({
easting: 'invalid',
northing: 'invalid'
})
)
const result2 = collection.validate(
getFormData({
easting: '12345.5',
northing: '1234567.5'
})
)
expect(result1.errors).toBeTruthy()
expect(result2.errors).toBeTruthy()
})
})
describe('State', () => {
it('returns text from state', () => {
const state1 = getFormState({
easting: 12345,
northing: 1234567
})
const state2 = getFormState({})
const answer1 = getAnswer(field, state1)
const answer2 = getAnswer(field, state2)
expect(answer1).toBe('Easting: 12345
Northing: 1234567
')
expect(answer2).toBe('')
})
it('returns payload from state', () => {
const state1 = getFormState({
easting: 12345,
northing: 1234567
})
const state2 = getFormState({})
const payload1 = field.getFormDataFromState(state1)
const payload2 = field.getFormDataFromState(state2)
expect(payload1).toEqual(
getFormData({
easting: 12345,
northing: 1234567
})
)
expect(payload2).toEqual(getFormData({}))
})
it('returns value from state', () => {
const state1 = getFormState({
easting: 12345,
northing: 1234567
})
const state2 = getFormState({})
const value1 = field.getFormValueFromState(state1)
const value2 = field.getFormValueFromState(state2)
expect(value1).toEqual({
easting: 12345,
northing: 1234567
})
expect(value2).toBeUndefined()
})
it('returns context for conditions and form submission', () => {
const state1 = getFormState({
easting: 12345,
northing: 1234567
})
const state2 = getFormState({})
const value1 = field.getContextValueFromState(state1)
const value2 = field.getContextValueFromState(state2)
expect(value1).toBe('Easting: 12345\nNorthing: 1234567')
expect(value2).toBeNull()
})
it('returns state from payload', () => {
const payload1 = getFormData({
easting: 12345,
northing: 1234567
})
const payload2 = getFormData({})
const value1 = field.getStateFromValidForm(payload1)
const value2 = field.getStateFromValidForm(payload2)
expect(value1).toEqual(
getFormState({
easting: 12345,
northing: 1234567
})
)
expect(value2).toEqual(getFormState({}))
})
})
describe('View model', () => {
it('sets Nunjucks component defaults', () => {
const payload = getFormData({
easting: 12345,
northing: 1234567
})
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: 'Easting' }),
name: 'myComponent__easting',
id: 'myComponent__easting',
value: 12345
}),
expect.objectContaining({
label: expect.objectContaining({ text: 'Northing' }),
name: 'myComponent__northing',
id: 'myComponent__northing',
value: 1234567
})
]
})
)
})
it('includes instruction text when provided', () => {
const componentWithInstruction = new EastingNorthingField(
{
...def,
options: { instructionText: 'Enter coordinates in **meters**' }
},
{ model }
)
const viewModel = componentWithInstruction.getViewModel(
getFormData({
easting: 12345,
northing: 1234567
})
)
const instructionText =
'instructionText' in viewModel ? viewModel.instructionText : undefined
expect(instructionText).toBeTruthy()
expect(instructionText).toContain('meters')
})
it('handles errors when component has validation errors', () => {
const payload = getFormData({
easting: '',
northing: ''
})
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__easting',
name: 'myComponent__easting'
})
)
expect(viewModel.items?.[1]).toEqual(
expect.objectContaining({
id: 'myComponent__northing',
name: 'myComponent__northing'
})
)
})
it('getViewErrors returns all errors for error summary', () => {
const errors: FormSubmissionError[] = [
{
name: 'myComponent__easting',
text: 'Enter easting',
path: ['myComponent__easting'],
href: '#myComponent__easting'
},
{
name: 'myComponent__northing',
text: 'Enter northing',
path: ['myComponent__northing'],
href: '#myComponent__northing'
}
]
const viewErrors = field.getViewErrors(errors)
// Should return all errors, not just the first one
expect(viewErrors).toHaveLength(2)
expect(viewErrors).toEqual([
expect.objectContaining({ text: 'Enter easting' }),
expect.objectContaining({ text: 'Enter northing' })
])
})
})
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 = EastingNorthingField.getAllPossibleErrors()
expect(staticErrors.baseErrors).not.toBeEmpty()
expect(staticErrors.advancedSettingsErrors).not.toBeEmpty()
})
it('instance method should delegate to static method', () => {
const staticResult = EastingNorthingField.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: {
title: 'Example easting northing',
name: 'myComponent',
type: ComponentType.EastingNorthingField,
options: {},
schema: {}
} satisfies EastingNorthingFieldComponent,
assertions: [
{
input: getFormData({
easting: ' 12345',
northing: ' 1234567'
}),
output: {
value: getFormData({
easting: 12345,
northing: 1234567
})
}
},
{
input: getFormData({
easting: '12345 ',
northing: '1234567 '
}),
output: {
value: getFormData({
easting: 12345,
northing: 1234567
})
}
}
]
},
{
description: 'Schema min and max for easting',
component: {
title: 'Example easting northing',
name: 'myComponent',
type: ComponentType.EastingNorthingField,
options: {},
schema: {
easting: {
min: 1000,
max: 60000
}
}
} satisfies EastingNorthingFieldComponent,
assertions: [
{
input: getFormData({
easting: '999',
northing: '1234567'
}),
output: {
value: getFormData({
easting: 999,
northing: 1234567
}),
errors: [
expect.objectContaining({
text: expect.stringMatching(
/Easting for .* must be between 1000 and 60000/
)
})
]
}
},
{
input: getFormData({
easting: '60001',
northing: '1234567'
}),
output: {
value: getFormData({
easting: 60001,
northing: 1234567
}),
errors: [
expect.objectContaining({
text: expect.stringMatching(
/Easting for .* must be between 1000 and 60000/
)
})
]
}
}
]
},
{
description: 'Schema min and max for northing',
component: {
title: 'Example easting northing',
name: 'myComponent',
type: ComponentType.EastingNorthingField,
options: {},
schema: {
northing: {
min: 1000,
max: 1200000
}
}
} satisfies EastingNorthingFieldComponent,
assertions: [
{
input: getFormData({
easting: '12345',
northing: '999'
}),
output: {
value: getFormData({
easting: 12345,
northing: 999
}),
errors: [
expect.objectContaining({
text: expect.stringMatching(
/Northing for .* must be between 1000 and 1200000/
)
})
]
}
},
{
input: getFormData({
easting: '12345',
northing: '1200001'
}),
output: {
value: getFormData({
easting: 12345,
northing: 1200001
}),
errors: [
expect.objectContaining({
text: expect.stringMatching(
/Northing for .* must be between 1000 and 1200000/
)
})
]
}
}
]
},
{
description: 'Precision validation',
component: {
title: 'Example easting northing',
name: 'myComponent',
type: ComponentType.EastingNorthingField,
options: {},
schema: {}
} satisfies EastingNorthingFieldComponent,
assertions: [
{
input: getFormData({
easting: '12345.5',
northing: '1234567'
}),
output: {
value: getFormData({
easting: 12345.5,
northing: 1234567
}),
errors: [
expect.objectContaining({
text: expect.stringMatching(
/Easting for .* must be between 1 and 6 digits/
)
})
]
}
},
{
input: getFormData({
easting: '12345',
northing: '1234567.5'
}),
output: {
value: getFormData({
easting: 12345,
northing: 1234567.5
}),
errors: [
expect.objectContaining({
text: expect.stringMatching(
/Northing for .* must be between 1 and 7 digits/
)
})
]
}
}
]
},
{
description: 'Optional field',
component: {
title: 'Example easting northing',
name: 'myComponent',
type: ComponentType.EastingNorthingField,
options: {
required: false
},
schema: {}
} satisfies EastingNorthingFieldComponent,
assertions: [
{
input: getFormData({
easting: '',
northing: ''
}),
output: {
value: getFormData({
easting: '',
northing: ''
})
}
}
]
}
])('$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)
}
)
})
})
})
function getFormData(
value:
| { easting?: string | number; northing?: string | number }
| Record
) {
if ('easting' in value || 'northing' in value) {
return {
myComponent__easting: value.easting,
myComponent__northing: value.northing
}
}
return {}
}
function getFormState(
value:
| {
easting?: number
northing?: number
}
| Record
) {
if ('easting' in value || 'northing' in value) {
return {
myComponent__easting: value.easting ?? null,
myComponent__northing: value.northing ?? null
}
}
return {
myComponent__easting: null,
myComponent__northing: null
}
}