Files
summercms/admin/tests/form/MoneyField.test.ts
Jakub Zych 20385e53ee feat(18-01): add the cabana money field (task 1)
- type: money on fields.yaml with currency or currencyFrom, scale (2..4,
  default 4) and max; boot fails naming the field on two sources, none,
  a bad scale or max, a column that is not a string or a Scanner/Valuer,
  or a currencyFrom that is not a string column
- a save answers 422 money.invalid, money.too_precise or money.too_large
  on the field and fills the accepted decimal string unchanged; the same
  check runs on controller, settings and relation child saves
- backend::lang money.* phrases in en and pl
- admin SPA: money.ts (parseMoneyInput, trimMoneyDisplay, formatMoney over
  Intl with the string itself), MoneyField.vue with the currency suffix,
  blur validation and the read-only box, preview through the control,
  the field scope on PreviewView; regenerated OpenAPI and TS types,
  rebuilt modules/boardwalk/dist
- acme.payments fixture with TestMoneyFieldCompile and the Docker
  TestMoneyFieldSmoke; forms.md Money section and cabana README
2026-10-13 11:15:05 +02:00

193 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Money control (UI-SPEC F1, D-07 with the amended D-09 and D-10), the
// component on its own and the money helpers. No application names appear
// in framework tests.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, mount, type VueWrapper } from '@vue/test-utils'
import { computed, nextTick, ref } from 'vue'
import type { FormField } from '../../src/api/types'
import { setBundle } from '../../src/app/i18n'
import { formatMoney, parseMoneyInput, trimMoneyDisplay } from '../../src/app/money'
import MoneyField from '../../src/components/form/fields/MoneyField.vue'
import { FIELD_SCOPE, type FieldScope } from '../../src/components/form/formContext'
import { langFixture } from '../fixtures/typed'
import { resetState } from '../helpers'
const strings = {
'backend::lang.money.invalid': { other: 'This is not an amount.' },
'backend::lang.money.too_precise': { other: 'Use at most :scale decimal places.' },
'backend::lang.money.currency': { other: 'Currency: :code' },
}
function moneyField(extra: Partial<FormField> = {}): FormField {
return { name: 'total', type: 'money', label: 'Total', currency: 'PLN', scale: 4, ...extra } as FormField
}
function mountField(props: Record<string, unknown> = {}, values: Record<string, unknown> = {}) {
const field = { ...moneyField(), ...((props.field as Partial<FormField>) ?? {}) } as FormField
const scopeValues = ref(values)
const scope: FieldScope = {
fields: computed(() => [field]),
values: computed(() => scopeValues.value),
path: (name: string) => name,
}
const wrapper = mount(MoneyField, {
props: { modelValue: null, controlId: 'f-total', ...props, field },
global: { provide: { [FIELD_SCOPE as symbol]: scope } },
attachTo: document.body,
})
return { wrapper, scopeValues }
}
const input = (wrapper: VueWrapper) => wrapper.find<HTMLInputElement>('input')
const emitted = (wrapper: VueWrapper) => (wrapper.emitted('update:modelValue') ?? []).map((args) => args[0])
async function type(wrapper: VueWrapper, text: string): Promise<void> {
input(wrapper).element.value = text
await input(wrapper).trigger('input')
}
beforeEach(() => {
resetState()
setBundle({ ...langFixture.data, ...strings }, 'en')
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('money helpers', () => {
it('parses typed text into the canonical dot string without a number', () => {
expect(parseMoneyInput('1 234,56', 4)).toEqual({ value: '1234.56', error: null })
expect(parseMoneyInput('1 234.5', 4)).toEqual({ value: '1234.5', error: null })
expect(parseMoneyInput('-12', 2)).toEqual({ value: '-12', error: null })
expect(parseMoneyInput('', 4)).toEqual({ value: null, error: null })
expect(parseMoneyInput(' ', 4)).toEqual({ value: null, error: null })
expect(parseMoneyInput('12.34567', 4)).toEqual({ value: null, error: 'too_precise' })
expect(parseMoneyInput('1.234,56', 4)).toEqual({ value: null, error: 'invalid' })
expect(parseMoneyInput('12a', 4)).toEqual({ value: null, error: 'invalid' })
expect(parseMoneyInput('1e3', 4)).toEqual({ value: null, error: 'invalid' })
expect(parseMoneyInput('.5', 4)).toEqual({ value: null, error: 'invalid' })
// A 20-digit amount survives: no float is involved.
expect(parseMoneyInput('12345678901234567890.1234', 4)).toEqual({ value: '12345678901234567890.1234', error: null })
})
it('trims trailing zeros down to two decimals for display', () => {
expect(trimMoneyDisplay('39.8400')).toBe('39.84')
expect(trimMoneyDisplay('85.4193')).toBe('85.4193')
expect(trimMoneyDisplay('5.0000')).toBe('5.00')
expect(trimMoneyDisplay('5.0100')).toBe('5.01')
expect(trimMoneyDisplay('7')).toBe('7')
expect(trimMoneyDisplay('7.5')).toBe('7.5')
})
it('formats through Intl with the string itself, keeping four decimals', () => {
expect(formatMoney('99999999.9999', 'PLN', 'en', 4)).toBe('PLN 99,999,999.9999')
expect(formatMoney('99999999.9999', 'PLN', 'pl', 4).replace(/\s/g, ' ')).toBe('99 999 999,9999 PLN')
expect(formatMoney('39.8400', 'PLN', 'en', 4)).toBe('PLN 39.84')
expect(formatMoney('39.8400', 'EUR', 'pl', 2).replace(/\s/g, ' ')).toBe('39,84 EUR')
expect(formatMoney('12.5', '', 'en', 4)).toBe('12.50')
})
})
describe('money field component (UI-SPEC F1)', () => {
it('is a text input in decimal mode with the currency suffix', () => {
const { wrapper } = mountField()
const el = input(wrapper)
expect(el.attributes('type')).toBe('text')
expect(el.attributes('inputmode')).toBe('decimal')
expect(el.attributes('autocomplete')).toBe('off')
expect(el.attributes('spellcheck')).toBe('false')
expect(el.classes()).toContain('pr-16')
expect(el.classes()).toContain('text-right')
expect(el.classes()).toContain('tabular-nums')
const suffix = wrapper.find('[data-money-currency]')
expect(suffix.text()).toBe('PLN')
expect(suffix.attributes('aria-hidden')).toBe('true')
expect(wrapper.find('#f-total-currency').text()).toBe('Currency: PLN')
expect(el.attributes('aria-describedby')).toContain('f-total-currency')
})
it('shows a loaded value trimmed, with the locale separator, and does not emit', async () => {
const { wrapper } = mountField({ modelValue: '39.8400' })
expect(input(wrapper).element.value).toBe('39.84')
expect(emitted(wrapper)).toEqual([])
setBundle({ ...langFixture.data, ...strings }, 'pl')
const { wrapper: polish } = mountField({ modelValue: '85.4193' })
expect(input(polish).element.value).toBe('85,4193')
expect(emitted(polish)).toEqual([])
})
it('submits an untouched 85.4193 byte-identical: no emit on load or blur', async () => {
const { wrapper } = mountField({ modelValue: '85.4193' })
await input(wrapper).trigger('focus')
await input(wrapper).trigger('blur')
expect(emitted(wrapper)).toEqual([])
expect(wrapper.find('[data-money-error]').exists()).toBe(false)
})
it('emits the canonical dot string for "1 234,56" and null for empty', async () => {
const { wrapper } = mountField({ modelValue: '1.00' })
await type(wrapper, '1 234,56')
expect(emitted(wrapper)).toEqual(['1234.56'])
await type(wrapper, '')
expect(emitted(wrapper)).toEqual(['1234.56', null])
})
it('refuses 12.34567 at scale 4 on blur, neither rounded nor truncated', async () => {
const { wrapper } = mountField({ modelValue: null })
await type(wrapper, '12.34567')
// Nothing is shown before blur, and no rounded value was emitted.
expect(wrapper.find('[data-money-error]').exists()).toBe(false)
expect(emitted(wrapper)).toEqual(['12.34567'])
await input(wrapper).trigger('blur')
const line = wrapper.find('[data-money-error]')
expect(line.text()).toBe('Use at most 4 decimal places.')
expect(input(wrapper).attributes('aria-invalid')).toBe('true')
expect(input(wrapper).classes()).toContain('border-danger')
expect(input(wrapper).attributes('aria-describedby')).toContain('f-total-money-error')
expect(emitted(wrapper)).toEqual(['12.34567'])
// Fixing the text clears the line while typing.
await type(wrapper, '12.3456')
expect(wrapper.find('[data-money-error]').exists()).toBe(false)
expect(emitted(wrapper)).toEqual(['12.34567', '12.3456'])
})
it('shows money.invalid for a second separator', async () => {
const { wrapper } = mountField()
await type(wrapper, '1.234,56')
await input(wrapper).trigger('blur')
expect(wrapper.find('[data-money-error]').text()).toBe('This is not an amount.')
})
it('follows the currencyFrom sibling and omits the suffix without a code', async () => {
const { wrapper, scopeValues } = mountField({ field: { currency: undefined, currencyFrom: 'currency' } }, { currency: '' })
expect(wrapper.find('[data-money-currency]').exists()).toBe(false)
expect(input(wrapper).classes()).toContain('pr-3.5')
expect(input(wrapper).attributes('aria-describedby')).toBeUndefined()
scopeValues.value = { currency: 'EUR' }
await nextTick()
expect(wrapper.find('[data-money-currency]').text()).toBe('EUR')
expect(input(wrapper).classes()).toContain('pr-16')
})
it('renders a read-only box with the formatted amount or an em dash', () => {
const { wrapper } = mountField({ modelValue: '39.8400', field: { readOnly: true } })
const box = wrapper.find('[data-money-readonly]')
expect(box.exists()).toBe(true)
expect(box.text()).toBe('PLN 39.84')
expect(box.classes()).toContain('tabular-nums')
expect(wrapper.find('input').exists()).toBe(false)
const { wrapper: empty } = mountField({ modelValue: null, field: { attributes: { readonly: true } } })
expect(empty.find('[data-money-readonly] [data-empty]').text()).toBe('—')
})
it('replaces the draft when the value is set from outside', async () => {
const { wrapper } = mountField({ modelValue: '1.00' })
await type(wrapper, '2')
await wrapper.setProps({ modelValue: '5.0000' })
expect(input(wrapper).element.value).toBe('5.00')
})
})