mgv-backoffice 1.20.0 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import BaseSegmentedControl from './BaseSegmentedControl.vue'
4
+ import type { SegmentedOption } from '../types/segmented'
5
+
6
+ const options: SegmentedOption[] = [
7
+ { value: 'ALL', label: 'All' },
8
+ { value: 'STOCK', label: 'Stock' },
9
+ { value: 'CRYPTO', label: 'Crypto', title: 'Crypto pairs' },
10
+ ]
11
+
12
+ describe('BaseSegmentedControl', () => {
13
+ it('renders one button per option and marks the selected one', () => {
14
+ const wrapper = mount(BaseSegmentedControl, {
15
+ props: { options, modelValue: 'STOCK' },
16
+ })
17
+ const buttons = wrapper.findAll('button')
18
+ expect(buttons.map((b) => b.text())).toEqual(['All', 'Stock', 'Crypto'])
19
+ expect(buttons[1].attributes('aria-pressed')).toBe('true')
20
+ expect(buttons[0].attributes('aria-pressed')).toBe('false')
21
+ expect(buttons[1].classes()).toContain('bg-emerald-500')
22
+ expect(buttons[0].classes()).toContain('bg-slate-50')
23
+ expect(buttons[2].attributes('title')).toBe('Crypto pairs')
24
+ })
25
+
26
+ it('emits update:modelValue with the clicked option value', async () => {
27
+ const wrapper = mount(BaseSegmentedControl, {
28
+ props: { options, modelValue: 'ALL' },
29
+ })
30
+ await wrapper.findAll('button')[2].trigger('click')
31
+ expect(wrapper.emitted('update:modelValue')).toEqual([['CRYPTO']])
32
+ })
33
+
34
+ it('uses the emerald-600 fill and wider padding in the wide variant', () => {
35
+ const wrapper = mount(BaseSegmentedControl, {
36
+ props: { options, modelValue: 'ALL', variant: 'wide' },
37
+ })
38
+ const active = wrapper.findAll('button')[0]
39
+ expect(active.classes()).toContain('bg-emerald-600')
40
+ expect(active.classes()).toContain('px-4')
41
+ })
42
+
43
+ it('renders the toolbar variant with h-9 divide-x wrapper and uppercase buttons', () => {
44
+ const wrapper = mount(BaseSegmentedControl, {
45
+ props: { options, modelValue: 'ALL', variant: 'toolbar' },
46
+ })
47
+ expect(wrapper.find('div').classes()).toContain('h-9')
48
+ expect(wrapper.find('div').classes()).toContain('divide-x')
49
+ expect(wrapper.findAll('button')[0].classes()).toContain('uppercase')
50
+ })
51
+
52
+ it('announces itself as a labelled group only when ariaLabel is set', () => {
53
+ const labelled = mount(BaseSegmentedControl, {
54
+ props: { options, modelValue: 'ALL', ariaLabel: 'Exchange' },
55
+ })
56
+ expect(labelled.find('div').attributes('role')).toBe('group')
57
+ expect(labelled.find('div').attributes('aria-label')).toBe('Exchange')
58
+
59
+ const bare = mount(BaseSegmentedControl, { props: { options, modelValue: 'ALL' } })
60
+ expect(bare.find('div').attributes('role')).toBeUndefined()
61
+ })
62
+
63
+ it('lets optionClass override the fill classes per option', () => {
64
+ const wrapper = mount(BaseSegmentedControl, {
65
+ props: {
66
+ options,
67
+ modelValue: 'ALL',
68
+ optionClass: (opt: SegmentedOption, active: boolean) =>
69
+ active ? 'bg-red-500 text-white' : 'text-red-600',
70
+ },
71
+ })
72
+ const buttons = wrapper.findAll('button')
73
+ expect(buttons[0].classes()).toContain('bg-red-500')
74
+ expect(buttons[1].classes()).toContain('text-red-600')
75
+ // Layout classes stay owned by the variant.
76
+ expect(buttons[0].classes()).toContain('px-3')
77
+ })
78
+ })
@@ -0,0 +1,98 @@
1
+ <template>
2
+ <!--
3
+ Segmented button group ("Class: All | Stock | Crypto"). One button per
4
+ option; the selected one gets the filled treatment and aria-pressed.
5
+
6
+ Variants:
7
+ • base — px-3 py-2 text-sm buttons, emerald-500 active fill
8
+ • wide — px-4 py-2 buttons, emerald-600 active fill
9
+ • toolbar — h-9 uppercase text-xs buttons with focus-visible rings
10
+ (divide-x separators instead of border-l)
11
+
12
+ `optionClass` overrides the per-button fill classes (e.g. severity
13
+ colours per option); layout classes stay owned by the variant.
14
+ Same `dark:`-variant note as BaseInput.
15
+ -->
16
+ <div
17
+ :role="ariaLabel ? 'group' : undefined"
18
+ :aria-label="ariaLabel || undefined"
19
+ :class="wrapClass"
20
+ >
21
+ <button
22
+ v-for="opt in options"
23
+ :key="String(opt.value)"
24
+ type="button"
25
+ :class="[buttonClass, fillClass(opt)]"
26
+ :aria-pressed="opt.value === modelValue"
27
+ :title="opt.title"
28
+ @click="emit('update:modelValue', opt.value)"
29
+ >
30
+ {{ opt.label }}
31
+ </button>
32
+ </div>
33
+ </template>
34
+
35
+ <script setup lang="ts">
36
+ import { computed } from 'vue'
37
+ import type { SegmentedOption } from '../types/segmented'
38
+
39
+ // NOT generic on purpose: vite-plugin-dts flattens SFC generics out of the
40
+ // emitted declarations, so consumers would see the erased constraint anyway.
41
+ // The update:modelValue payload is `any` so `v-model` on a union-typed ref
42
+ // ('all' | 'STOCK' | …) type-checks; the actual payload is always one of the
43
+ // options' values.
44
+ interface Props {
45
+ options: readonly SegmentedOption[]
46
+ modelValue: string | number
47
+ variant?: 'base' | 'wide' | 'toolbar'
48
+ /** When set, the wrapper announces itself as a labelled group. */
49
+ ariaLabel?: string
50
+ /**
51
+ * Override the fill (colour) classes per button — receives the option and
52
+ * whether it is the selected one. Defaults to the variant's emerald/slate
53
+ * treatment.
54
+ */
55
+ optionClass?: (option: SegmentedOption, active: boolean) => string
56
+ }
57
+
58
+ const props = withDefaults(defineProps<Props>(), {
59
+ variant: 'base',
60
+ ariaLabel: '',
61
+ optionClass: undefined,
62
+ })
63
+
64
+ const emit = defineEmits<{
65
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
66
+ (e: 'update:modelValue', value: any): void
67
+ }>()
68
+
69
+ const wrapClass = computed(() =>
70
+ props.variant === 'toolbar'
71
+ ? 'inline-flex h-9 divide-x divide-slate-300 dark:divide-slate-600 rounded-md border border-slate-300 dark:border-slate-600 overflow-hidden'
72
+ : 'inline-flex rounded border border-slate-300 dark:border-slate-600 overflow-hidden',
73
+ )
74
+
75
+ const buttonClass = computed(() => {
76
+ if (props.variant === 'toolbar') {
77
+ return (
78
+ 'flex items-center px-3.5 text-xs font-semibold uppercase tracking-wide transition-colors ' +
79
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-500'
80
+ )
81
+ }
82
+ const padding = props.variant === 'wide' ? 'px-4 py-2' : 'px-3 py-2'
83
+ return `${padding} text-sm font-medium transition-colors border-l first:border-l-0 border-slate-300 dark:border-slate-600`
84
+ })
85
+
86
+ const activeFill = computed(() =>
87
+ props.variant === 'wide' ? 'bg-emerald-600 text-white' : 'bg-emerald-500 text-white',
88
+ )
89
+
90
+ const idleFill =
91
+ 'bg-slate-50 dark:bg-slate-900 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'
92
+
93
+ function fillClass(opt: SegmentedOption): string {
94
+ const active = opt.value === props.modelValue
95
+ if (props.optionClass) return props.optionClass(opt, active)
96
+ return active ? activeFill.value : idleFill
97
+ }
98
+ </script>
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import BaseSelect from './BaseSelect.vue'
4
+
5
+ const slots = {
6
+ default: '<option value="a">A</option><option value="b">B</option>',
7
+ }
8
+
9
+ describe('BaseSelect', () => {
10
+ it('renders the field skin, full-width compact by default', () => {
11
+ const wrapper = mount(BaseSelect, { slots })
12
+ const select = wrapper.find('select')
13
+ expect(select.classes()).toEqual(
14
+ expect.arrayContaining(['rounded', 'border', 'w-full', 'px-2', 'py-1.5', 'text-sm']),
15
+ )
16
+ })
17
+
18
+ it('supports md size and inline (non-block) layout', () => {
19
+ const wrapper = mount(BaseSelect, { props: { size: 'md', block: false }, slots })
20
+ const select = wrapper.find('select')
21
+ expect(select.classes()).toEqual(expect.arrayContaining(['px-3', 'py-2']))
22
+ expect(select.classes()).not.toContain('w-full')
23
+ })
24
+
25
+ it('reflects modelValue and emits the selected value on change', async () => {
26
+ const wrapper = mount(BaseSelect, { props: { modelValue: 'a' }, slots })
27
+ const select = wrapper.find('select')
28
+ expect((select.element as HTMLSelectElement).value).toBe('a')
29
+ await select.setValue('b')
30
+ expect(wrapper.emitted('update:modelValue')).toEqual([['b']])
31
+ })
32
+
33
+ it('renders caller-provided options via the default slot', () => {
34
+ const wrapper = mount(BaseSelect, { slots })
35
+ expect(wrapper.findAll('option').map((o) => o.text())).toEqual(['A', 'B'])
36
+ })
37
+ })
@@ -0,0 +1,46 @@
1
+ <template>
2
+ <!--
3
+ Themed select sharing BaseInput's field skin. Options come from the
4
+ default slot so callers keep full control of <option> rendering (values,
5
+ titles, disabled entries). Attrs (id, disabled, extra classes) fall
6
+ through. Same `dark:`-variant note as BaseInput.
7
+ -->
8
+ <select
9
+ :value="modelValue"
10
+ class="rounded border border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-900 text-slate-900 dark:text-slate-100 text-sm"
11
+ :class="[block ? 'w-full' : '', size === 'md' ? 'px-3 py-2' : 'px-2 py-1.5']"
12
+ @change="onChange"
13
+ >
14
+ <slot />
15
+ </select>
16
+ </template>
17
+
18
+ <script setup lang="ts">
19
+ // String-valued only — a DOM select always yields strings; use v-model.number
20
+ // semantics in the owner if needed. The update:modelValue payload is `any`
21
+ // (not generic: vite-plugin-dts flattens SFC generics out of the emitted
22
+ // declarations) so `v-model` on a union-typed ref ('7D' | '30D' | …)
23
+ // type-checks; the actual payload is always the selected option's string.
24
+ interface Props {
25
+ modelValue?: string | null
26
+ /** 'sm' = px-2 py-1.5 (default, the usual select height); 'md' = px-3 py-2. */
27
+ size?: 'sm' | 'md'
28
+ /** Full-width (w-full). Set false for inline selects. */
29
+ block?: boolean
30
+ }
31
+
32
+ withDefaults(defineProps<Props>(), {
33
+ modelValue: undefined,
34
+ size: 'sm',
35
+ block: true,
36
+ })
37
+
38
+ const emit = defineEmits<{
39
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
40
+ (e: 'update:modelValue', value: any): void
41
+ }>()
42
+
43
+ function onChange(e: Event) {
44
+ emit('update:modelValue', (e.target as HTMLSelectElement).value)
45
+ }
46
+ </script>
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import BaseSpecFields from './BaseSpecFields.vue'
4
+ import type { SpecField } from '../types/specField'
5
+
6
+ const specs: SpecField[] = [
7
+ { key: 'mode', label: 'Mode', type: 'select', options: ['fast', 'slow'] },
8
+ { key: 'enabled', label: 'Enabled', type: 'boolean' },
9
+ { key: 'threshold', label: 'Threshold', type: 'decimal', min: 0, help: 'Trigger level.' },
10
+ { key: 'lookback', label: 'Lookback', type: 'integer' },
11
+ ]
12
+
13
+ const params = { mode: 'fast', enabled: true, threshold: 1.5, lookback: 10 }
14
+
15
+ function mountFields(extra = {}) {
16
+ return mount(BaseSpecFields, { props: { specs, params }, ...extra })
17
+ }
18
+
19
+ describe('BaseSpecFields', () => {
20
+ it('renders a select, a checkbox and number inputs per spec type', () => {
21
+ const wrapper = mountFields()
22
+ expect(wrapper.findAll('select')).toHaveLength(1)
23
+ expect(wrapper.findAll('input[type="checkbox"]')).toHaveLength(1)
24
+ expect(wrapper.findAll('input[type="number"]')).toHaveLength(2)
25
+ expect(wrapper.text()).toContain('Trigger level.')
26
+ })
27
+
28
+ it('defaults numeric steps to 1 for integers and 0.01 for decimals', () => {
29
+ const inputs = mountFields().findAll('input[type="number"]')
30
+ expect(inputs[0].attributes('step')).toBe('0.01')
31
+ expect(inputs[1].attributes('step')).toBe('1')
32
+ expect(inputs[0].attributes('min')).toBe('0')
33
+ })
34
+
35
+ it('emits update(key, value) for select changes', async () => {
36
+ const wrapper = mountFields()
37
+ await wrapper.find('select').setValue('slow')
38
+ expect(wrapper.emitted('update')).toEqual([['mode', 'slow']])
39
+ })
40
+
41
+ it('emits update(key, checked) for checkbox toggles', async () => {
42
+ const wrapper = mountFields()
43
+ await wrapper.find('input[type="checkbox"]').setValue(false)
44
+ expect(wrapper.emitted('update')).toEqual([['enabled', false]])
45
+ })
46
+
47
+ it('parses numeric input, passing unparseable values through raw', async () => {
48
+ const wrapper = mountFields()
49
+ const threshold = wrapper.findAll('input[type="number"]')[0]
50
+ await threshold.setValue('2.25')
51
+ await threshold.setValue('')
52
+ expect(wrapper.emitted('update')).toEqual([
53
+ ['threshold', 2.25],
54
+ ['threshold', ''],
55
+ ])
56
+ })
57
+
58
+ it('never mutates params directly', async () => {
59
+ const frozen = Object.freeze({ ...params })
60
+ const wrapper = mount(BaseSpecFields, { props: { specs, params: frozen } })
61
+ await wrapper.find('select').setValue('slow')
62
+ expect(frozen.mode).toBe('fast')
63
+ })
64
+
65
+ it('exposes each spec to the after slot', () => {
66
+ const wrapper = mountFields({
67
+ slots: {
68
+ after: `<template #after="{ spec }"><p class="after-probe">{{ spec.key }}</p></template>`,
69
+ },
70
+ })
71
+ expect(wrapper.findAll('.after-probe').map((p) => p.text())).toEqual([
72
+ 'mode',
73
+ 'enabled',
74
+ 'threshold',
75
+ 'lookback',
76
+ ])
77
+ })
78
+ })
@@ -0,0 +1,83 @@
1
+ <script setup lang="ts">
2
+ import BaseInput from './BaseInput.vue'
3
+ import BaseSelect from './BaseSelect.vue'
4
+ import type { SpecField, SpecFieldValue } from '../types/specField'
5
+
6
+ // Spec-driven form fields: renders a select / checkbox / number input per
7
+ // field spec, with labels and help text, in a responsive two-column grid.
8
+ // Feed it a backend-described catalogue (or any SpecField[]) and every form
9
+ // that edits those values stays in lockstep.
10
+ //
11
+ // The component never mutates `params`: every edit is emitted as
12
+ // (key, value) and the owner writes it back into its own form state.
13
+ defineProps<{
14
+ specs: SpecField[]
15
+ params: Record<string, SpecFieldValue>
16
+ }>()
17
+
18
+ const emit = defineEmits<{
19
+ (e: 'update', key: string, value: SpecFieldValue): void
20
+ }>()
21
+
22
+ // Per-spec extra content rendered under a field (e.g. a live preview a
23
+ // caller attaches to one specific key).
24
+ defineSlots<{
25
+ after?: (props: { spec: SpecField }) => unknown
26
+ }>()
27
+
28
+ function onCheckbox(key: string, e: Event) {
29
+ emit('update', key, (e.target as HTMLInputElement).checked)
30
+ }
31
+
32
+ // Mirror v-model.number: a parseable value becomes a number, anything else
33
+ // (including a cleared field, which becomes '') passes through as the raw
34
+ // string so the owner's "enter a valid number" validation can catch it.
35
+ function onNumber(key: string, raw: string) {
36
+ const n = parseFloat(raw)
37
+ emit('update', key, Number.isNaN(n) ? raw : n)
38
+ }
39
+ </script>
40
+
41
+ <template>
42
+ <div class="grid gap-3 md:grid-cols-2">
43
+ <div v-for="spec in specs" :key="spec.key">
44
+ <label
45
+ v-if="spec.type !== 'boolean'"
46
+ class="block text-[11px] uppercase tracking-wide text-slate-500 mb-1"
47
+ >
48
+ {{ spec.label }}
49
+ </label>
50
+
51
+ <BaseSelect
52
+ v-if="spec.type === 'select'"
53
+ :modelValue="String(params[spec.key] ?? '')"
54
+ @update:modelValue="(v: string) => emit('update', spec.key, v)"
55
+ >
56
+ <option v-for="opt in spec.options ?? []" :key="opt" :value="opt">{{ opt }}</option>
57
+ </BaseSelect>
58
+
59
+ <label v-else-if="spec.type === 'boolean'" class="flex items-center gap-2 mt-4">
60
+ <input
61
+ :checked="Boolean(params[spec.key])"
62
+ type="checkbox"
63
+ class="h-4 w-4"
64
+ @change="onCheckbox(spec.key, $event)"
65
+ />
66
+ <span class="text-sm text-slate-900 dark:text-slate-100">{{ spec.label }}</span>
67
+ </label>
68
+
69
+ <BaseInput
70
+ v-else
71
+ :modelValue="params[spec.key] as string | number"
72
+ type="number"
73
+ :step="spec.step ?? (spec.type === 'integer' ? 1 : 0.01)"
74
+ :min="spec.min ?? undefined"
75
+ @update:modelValue="(v: string) => onNumber(spec.key, v)"
76
+ />
77
+
78
+ <p v-if="spec.help" class="text-[11px] text-slate-400 mt-1">{{ spec.help }}</p>
79
+
80
+ <slot name="after" :spec="spec" />
81
+ </div>
82
+ </div>
83
+ </template>
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import BaseTable from './BaseTable.vue'
4
+ import type { TableColumn } from '../types/table'
5
+
6
+ const columns: TableColumn[] = [
7
+ { label: 'Symbol' },
8
+ { label: 'Qty', align: 'right' },
9
+ { label: 'Bought' },
10
+ ]
11
+
12
+ describe('BaseTable', () => {
13
+ it('renders header cells with right-alignment where requested', () => {
14
+ const wrapper = mount(BaseTable, { props: { columns } })
15
+ const ths = wrapper.findAll('th')
16
+ expect(ths.map((th) => th.text())).toEqual(['Symbol', 'Qty', 'Bought'])
17
+ expect(ths[1].classes()).toContain('text-right')
18
+ expect(ths[0].classes()).not.toContain('text-right')
19
+ expect(ths[0].classes()).toEqual(expect.arrayContaining(['px-4', 'py-3']))
20
+ })
21
+
22
+ it('renders caller rows through the default slot', () => {
23
+ const wrapper = mount(BaseTable, {
24
+ props: { columns },
25
+ slots: { default: '<tr><td>AMD</td><td>3</td><td>today</td></tr>' },
26
+ })
27
+ expect(wrapper.find('tbody td').text()).toBe('AMD')
28
+ expect(wrapper.find('table').classes()).toEqual(expect.arrayContaining(['w-full', 'text-sm']))
29
+ })
30
+
31
+ it('renders the empty-state row spanning every column when empty', () => {
32
+ const wrapper = mount(BaseTable, {
33
+ props: { columns, empty: true, emptyText: 'Nothing here.' },
34
+ })
35
+ const td = wrapper.find('tbody td')
36
+ expect(td.attributes('colspan')).toBe('3')
37
+ expect(td.text()).toBe('Nothing here.')
38
+ })
39
+
40
+ it('prefers the empty slot over emptyText', () => {
41
+ const wrapper = mount(BaseTable, {
42
+ props: { columns, empty: true, emptyText: 'fallback' },
43
+ slots: { empty: 'No trades match your filters.' },
44
+ })
45
+ expect(wrapper.find('tbody td').text()).toBe('No trades match your filters.')
46
+ })
47
+
48
+ it('renders no empty-state row when not empty', () => {
49
+ const wrapper = mount(BaseTable, {
50
+ props: { columns, empty: false },
51
+ slots: { default: '<tr><td>row</td></tr>' },
52
+ })
53
+ expect(wrapper.findAll('tbody tr')).toHaveLength(1)
54
+ })
55
+ })
@@ -0,0 +1,50 @@
1
+ <template>
2
+ <!--
3
+ Styling shell for data tables — NOT a data grid. Owns the table skin
4
+ (slate header band, px-4 py-3 header cells, empty-state row); rows are
5
+ the caller's own <tr> markup via the default slot, so cell content and
6
+ per-cell styling stay fully in the caller's hands.
7
+
8
+ Wrap it yourself for scrolling/card chrome (e.g. inside a BaseRow with
9
+ overflow-x-auto). Same `dark:`-variant note as BaseInput.
10
+ -->
11
+ <table class="w-full text-sm">
12
+ <thead class="bg-slate-50 dark:bg-slate-800/60 text-slate-600 dark:text-slate-300">
13
+ <tr class="text-left">
14
+ <th
15
+ v-for="(col, i) in columns"
16
+ :key="i"
17
+ class="px-4 py-3"
18
+ :class="col.align === 'right' ? 'text-right' : ''"
19
+ >
20
+ {{ col.label }}
21
+ </th>
22
+ </tr>
23
+ </thead>
24
+ <tbody>
25
+ <tr v-if="empty">
26
+ <td :colspan="columns.length" class="px-4 py-12 text-center text-slate-500">
27
+ <slot name="empty">{{ emptyText }}</slot>
28
+ </td>
29
+ </tr>
30
+ <slot />
31
+ </tbody>
32
+ </table>
33
+ </template>
34
+
35
+ <script setup lang="ts">
36
+ import type { TableColumn } from '../types/table'
37
+
38
+ interface Props {
39
+ columns: TableColumn[]
40
+ /** True when there are no rows — renders the empty-state row. */
41
+ empty?: boolean
42
+ /** Fallback empty-state text; the `empty` slot overrides it. */
43
+ emptyText?: string
44
+ }
45
+
46
+ withDefaults(defineProps<Props>(), {
47
+ empty: false,
48
+ emptyText: 'No rows.',
49
+ })
50
+ </script>
@@ -30,4 +30,61 @@ describe('EarningsCard', () => {
30
30
  expect(w.html()).toContain('border-emerald-300')
31
31
  expect(w.html()).not.toContain('border-orange-300')
32
32
  })
33
+
34
+ it('applies the red accent when requested explicitly', () => {
35
+ const w = mount(EarningsCard, { props: { amount: 1, accent: 'red' } })
36
+ expect(w.html()).toContain('border-red-300')
37
+ })
38
+
39
+ describe('signed (P&L) mode', () => {
40
+ // The "up-trend" arrowhead the default glyph uses; absent in the loss glyph.
41
+ const UP_GLYPH = 'points="16 7 22 7 22 13"'
42
+ const DOWN_GLYPH = 'points="16 17 22 17 22 11"'
43
+
44
+ it('renders the red loss theme and a downward glyph for a negative amount', () => {
45
+ const w = mount(EarningsCard, {
46
+ props: { amount: -5, signed: true, accent: 'emerald' },
47
+ })
48
+ const html = w.html()
49
+ expect(html).toContain('border-red-300') // loss recolour overrides emerald
50
+ expect(html).not.toContain('border-emerald-300')
51
+ expect(html).toContain(DOWN_GLYPH)
52
+ expect(html).not.toContain(UP_GLYPH)
53
+ // Existing convention is currency-then-value, so a negative reads "$-5.00".
54
+ expect(w.text()).toContain('$-5.00')
55
+ })
56
+
57
+ it('keeps the chosen accent and upward glyph for a non-negative amount', () => {
58
+ const w = mount(EarningsCard, {
59
+ props: { amount: 5, signed: true, accent: 'emerald' },
60
+ })
61
+ const html = w.html()
62
+ expect(html).toContain('border-emerald-300')
63
+ expect(html).not.toContain('border-red-300')
64
+ expect(html).toContain(UP_GLYPH)
65
+ })
66
+
67
+ it('treats exactly zero as non-negative (no loss theme)', () => {
68
+ const w = mount(EarningsCard, { props: { amount: 0, signed: true, accent: 'emerald' } })
69
+ expect(w.html()).not.toContain('border-red-300')
70
+ expect(w.html()).toContain('border-emerald-300')
71
+ })
72
+
73
+ it('does NOT apply the loss theme to a negative amount unless signed is set', () => {
74
+ // Backward compatibility: without `signed`, a negative amount is just a
75
+ // number rendered in the normal accent (no auto-red).
76
+ const w = mount(EarningsCard, { props: { amount: -5, accent: 'emerald' } })
77
+ expect(w.html()).not.toContain('border-red-300')
78
+ expect(w.html()).toContain('border-emerald-300')
79
+ })
80
+
81
+ it('coerces a stringified negative amount before the sign check', () => {
82
+ const w = mount(EarningsCard, {
83
+ // The API can hand back numbers as JSON strings.
84
+ props: { amount: '-0.01' as unknown as number, signed: true, accent: 'emerald' },
85
+ })
86
+ expect(w.html()).toContain('border-red-300')
87
+ expect(w.text()).toContain('$-0.01')
88
+ })
89
+ })
33
90
  })