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
This commit is contained in:
101
admin/tests/app/money.test.ts
Normal file
101
admin/tests/app/money.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
// The exact-decimal money helpers of the money field and list column
|
||||
// (Phase 18 D-07 with the amended D-09 and D-10): text in, text out, never a
|
||||
// JS number. No application names appear in framework tests.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { decimalSeparator, formatMoney, parseMoneyInput, trimMoneyDisplay } from '../../src/app/money'
|
||||
|
||||
const spaces = (s: string) => s.replace(/\s/g, ' ')
|
||||
|
||||
describe('parseMoneyInput', () => {
|
||||
it('accepts a comma or a dot as the separator and strips spaces and no-break spaces', () => {
|
||||
expect(parseMoneyInput('1234,56', 4)).toEqual({ value: '1234.56', error: null })
|
||||
expect(parseMoneyInput('1234.56', 4)).toEqual({ value: '1234.56', error: null })
|
||||
expect(parseMoneyInput(' 1 234,5 ', 4)).toEqual({ value: '1234.5', error: null })
|
||||
expect(parseMoneyInput('1 234,56', 4)).toEqual({ value: '1234.56', error: null })
|
||||
expect(parseMoneyInput('7', 2)).toEqual({ value: '7', error: null })
|
||||
})
|
||||
|
||||
it('keeps a leading minus and refuses a minus anywhere else', () => {
|
||||
expect(parseMoneyInput('-0,5', 4)).toEqual({ value: '-0.5', error: null })
|
||||
expect(parseMoneyInput('5-', 4)).toEqual({ value: null, error: 'invalid' })
|
||||
expect(parseMoneyInput('--5', 4)).toEqual({ value: null, error: 'invalid' })
|
||||
expect(parseMoneyInput('+5', 4)).toEqual({ value: null, error: 'invalid' })
|
||||
})
|
||||
|
||||
it('is null for empty or blank text', () => {
|
||||
expect(parseMoneyInput('', 2)).toEqual({ value: null, error: null })
|
||||
expect(parseMoneyInput(' ', 2)).toEqual({ value: null, error: null })
|
||||
})
|
||||
|
||||
it('refuses letters, exponents, two separators and lone separators as invalid', () => {
|
||||
for (const text of ['abc', '1e3', '1E3', '1.2.3', '1,234.56', '1.234,56', '.5', '5.', ',', '0x10', 'NaN', 'Infinity', '12 a']) {
|
||||
expect(parseMoneyInput(text, 4), text).toEqual({ value: null, error: 'invalid' })
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses more significant fraction digits than the scale, never rounding or truncating', () => {
|
||||
expect(parseMoneyInput('12.34567', 4)).toEqual({ value: null, error: 'too_precise' })
|
||||
expect(parseMoneyInput('1,234', 2)).toEqual({ value: null, error: 'too_precise' })
|
||||
expect(parseMoneyInput('1.234', 3)).toEqual({ value: '1.234', error: null })
|
||||
expect(parseMoneyInput('0.0001', 4)).toEqual({ value: '0.0001', error: null })
|
||||
})
|
||||
|
||||
it('accepts and keeps trailing zeros beyond the scale, as the server does', () => {
|
||||
expect(parseMoneyInput('12.5000', 2)).toEqual({ value: '12.5000', error: null })
|
||||
expect(parseMoneyInput('39,840000', 4)).toEqual({ value: '39.840000', error: null })
|
||||
expect(parseMoneyInput('12.5001', 2)).toEqual({ value: null, error: 'too_precise' })
|
||||
})
|
||||
|
||||
it('keeps amounts beyond the float range digit for digit', () => {
|
||||
expect(parseMoneyInput('99999999999999999999.9999', 4)).toEqual({ value: '99999999999999999999.9999', error: null })
|
||||
expect(parseMoneyInput('0.1', 4).value).toBe('0.1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('trimMoneyDisplay', () => {
|
||||
it('trims trailing fraction zeros down to two decimals and keeps the rest', () => {
|
||||
expect(trimMoneyDisplay('39.8400')).toBe('39.84')
|
||||
expect(trimMoneyDisplay('85.4193')).toBe('85.4193')
|
||||
expect(trimMoneyDisplay('85.4190')).toBe('85.419')
|
||||
expect(trimMoneyDisplay('5.0000')).toBe('5.00')
|
||||
expect(trimMoneyDisplay('-0.5000')).toBe('-0.50')
|
||||
expect(trimMoneyDisplay('7')).toBe('7')
|
||||
expect(trimMoneyDisplay('7.5')).toBe('7.5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('decimalSeparator', () => {
|
||||
it('is a dot in en and a comma in pl', () => {
|
||||
expect(decimalSeparator('en')).toBe('.')
|
||||
expect(decimalSeparator('pl')).toBe(',')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatMoney', () => {
|
||||
it('formats en and pl with the ISO code and at least two decimals', () => {
|
||||
expect(spaces(formatMoney('39.8400', 'PLN', 'en'))).toBe('PLN 39.84')
|
||||
expect(spaces(formatMoney('39.8400', 'PLN', 'pl'))).toBe('39,84 PLN')
|
||||
expect(spaces(formatMoney('7', 'EUR', 'en'))).toBe('EUR 7.00')
|
||||
expect(spaces(formatMoney('1234567.5', 'EUR', 'pl'))).toBe('1 234 567,50 EUR')
|
||||
})
|
||||
|
||||
it('keeps up to scale decimals and never rounds a four-decimal amount', () => {
|
||||
expect(spaces(formatMoney('85.4193', 'PLN', 'en', 4))).toBe('PLN 85.4193')
|
||||
expect(spaces(formatMoney('99999999.9999', 'PLN', 'en', 4))).toBe('PLN 99,999,999.9999')
|
||||
expect(spaces(formatMoney('99999999.9999', 'PLN', 'pl', 4))).toBe('99 999 999,9999 PLN')
|
||||
expect(spaces(formatMoney('12.5', 'EUR', 'en', 2))).toBe('EUR 12.50')
|
||||
})
|
||||
|
||||
it('formats a negative amount', () => {
|
||||
expect(spaces(formatMoney('-0.5000', 'PLN', 'en'))).toBe('-PLN 0.50')
|
||||
})
|
||||
|
||||
it('formats the amount alone without a currency', () => {
|
||||
expect(spaces(formatMoney('12.5', '', 'en'))).toBe('12.50')
|
||||
expect(spaces(formatMoney('1234.5678', '', 'pl'))).toBe('1234,5678')
|
||||
})
|
||||
|
||||
it('appends an unknown code to the plain amount instead of throwing', () => {
|
||||
expect(spaces(formatMoney('12.5', 'ZZZZ', 'en'))).toBe('12.50 ZZZZ')
|
||||
})
|
||||
})
|
||||
@@ -186,6 +186,38 @@ describe('money field component (UI-SPEC F1)', () => {
|
||||
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')
|
||||
|
||||
Reference in New Issue
Block a user