Files
summercms/admin/tests/form/MoneyField.test.ts
Jakub Zych 0f35c9a0ca test(18-07): framework unit coverage of money, settings secrets, tabs, richeditor and danger actions (task 2)
- money_field_test.go: compile gates (moved from the smoke file and
  extended), model column gate, save-time value check, list projection
- settings secret projection and put without a database; tabs, settings
  richeditor sanitize and the danger flag as internal unit tests
- Postgres: TestMoneyFieldSave, TestSettingsSecretPostgres and the Phase 18
  payload cases of TestPhase10OpenAPIConformance
- vitest: money.ts helpers spec, MoneyField blur-only and server-error cases
- gate arrays and the cabana README Testing section name every test
2026-10-13 16:58:03 +02:00

228 lines
11 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('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')
})
})