// 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 { return { name: 'total', type: 'money', label: 'Total', currency: 'PLN', scale: 4, ...extra } as FormField } function mountField(props: Record = {}, values: Record = {}) { const field = { ...moneyField(), ...((props.field as Partial) ?? {}) } 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('input') const emitted = (wrapper: VueWrapper) => (wrapper.emitted('update:modelValue') ?? []).map((args) => args[0]) async function type(wrapper: VueWrapper, text: string): Promise { 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('validates on blur only: a malformed draft shows nothing while typing until the first blur', async () => { const { wrapper } = mountField({ modelValue: null }) await type(wrapper, 'abc') expect(wrapper.find('[data-money-error]').exists()).toBe(false) expect(input(wrapper).attributes('aria-invalid')).toBeUndefined() // The raw text is emitted so the server's 422 stays the gate. expect(emitted(wrapper)).toEqual(['abc']) await input(wrapper).trigger('blur') expect(wrapper.find('[data-money-error]').text()).toBe('This is not an amount.') expect(input(wrapper).attributes('aria-invalid')).toBe('true') // Once shown, errors follow the typing. await type(wrapper, '12,5') expect(wrapper.find('[data-money-error]').exists()).toBe(false) expect(input(wrapper).attributes('aria-invalid')).toBeUndefined() expect(emitted(wrapper)).toEqual(['abc', '12.5']) }) it('marks the input invalid when the form reports a server error for the field', () => { const { wrapper } = mountField({ modelValue: '1.00', invalid: true }) expect(input(wrapper).attributes('aria-invalid')).toBe('true') expect(input(wrapper).classes()).toContain('border-danger') expect(wrapper.find('[data-money-error]').exists()).toBe(false) }) it('accepts a loaded four-decimal value on a scale-2 field without an error on blur', async () => { const { wrapper } = mountField({ modelValue: '12.5000', field: { scale: 2 } }) expect(input(wrapper).element.value).toBe('12.50') await input(wrapper).trigger('blur') expect(wrapper.find('[data-money-error]').exists()).toBe(false) expect(emitted(wrapper)).toEqual([]) }) 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') }) })