Files
summercms/admin/tests/form/MoneyField.test.ts
Jakub Zych eb60c329d8 fix(18-07): money accepts trailing zeros beyond the field scale
A scale-2 money field over a four-decimal column (the documented fee
example) was served "12.5000" and answered money.too_precise when the
untouched value was saved back. Trailing zeros carry no precision, so the
server check and the SPA parser count only significant fraction digits;
the value still reaches the column unchanged, never rounded or truncated.

- cabana validateMoneyValue and admin parseMoneyInput trim trailing zeros
  before the scale comparison
- forms docs and cabana README name the rule; embedded dist rebuilt
- MoneyField spec asserts 12.5000 at scale 2 is accepted, 12.5010 refused
2026-10-13 16:53:58 +02:00

196 lines
9.2 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' })
// Trailing zeros beyond the scale carry no precision: kept, accepted.
expect(parseMoneyInput('12.5000', 2)).toEqual({ value: '12.5000', error: null })
expect(parseMoneyInput('12.5010', 2)).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')
})
})