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:
2026-10-13 16:58:03 +02:00
parent eb60c329d8
commit 0f35c9a0ca
13 changed files with 1302 additions and 110 deletions

View 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')
})
})

View File

@@ -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')