V13-FE-006: consolidate approved UI and contract hardening
deploy / deploy (push) Successful in 1m52s
deploy / notify (push) Successful in 1s

This commit is contained in:
2026-08-13 02:41:00 +09:00
parent d79edae546
commit 3f293d8aa8
1278 changed files with 14384 additions and 1664 deletions
@@ -1,17 +1,26 @@
<script setup lang="ts">
import { computed } from 'vue'
import { formatAsOf } from '../formatters/financial'
const props = defineProps<{
asOf: string
staleAfterMinutes: number
now: string
source?: string
revision?: number
refreshable?: boolean
}>()
const emit = defineEmits<{ refresh: [] }>()
const ageMinutes = computed(() => Math.max(0, (Date.now() - new Date(props.asOf).getTime()) / 60_000))
const ageMinutes = computed(() => Math.max(0, (new Date(props.now).getTime() - new Date(props.asOf).getTime()) / 60_000))
const stale = computed(() => ageMinutes.value > props.staleAfterMinutes)
</script>
<template>
<span :aria-label="stale ? '데이터 지연' : '데이터 최신'" :data-status="stale ? 'stale' : 'fresh'">
{{ stale ? 'STALE' : 'FRESH' }} · {{ new Date(props.asOf).toLocaleString() }}
<button v-if="props.refreshable" type="button" :aria-label="stale ? '데이터 지연, 새로 고침' : '데이터 최신, 새로 고침'" :data-status="stale ? 'stale' : 'fresh'" :title="props.source ? `출처: ${props.source}` : undefined" @click="emit('refresh')">
<span aria-hidden="true"></span> {{ stale ? 'STALE' : 'FRESH' }} · {{ formatAsOf(props.asOf) }}<span v-if="props.revision !== undefined"> · rev {{ props.revision }}</span>
</button>
<span v-else :aria-label="stale ? '데이터 지연' : '데이터 최신'" :data-status="stale ? 'stale' : 'fresh'" :title="props.source ? `출처: ${props.source}` : undefined">
{{ stale ? 'STALE' : 'FRESH' }} · {{ formatAsOf(props.asOf) }}<span v-if="props.revision !== undefined"> · rev {{ props.revision }}</span>
</span>
</template>
@@ -0,0 +1,26 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import DataFreshnessBadge from '../DataFreshnessBadge.vue'
describe('DataFreshnessBadge', () => {
const base = { asOf: '2026-08-12T00:00:00Z', staleAfterMinutes: 30, now: '2026-08-12T00:29:00Z' }
it('uses the injected instant instead of the machine clock', () => {
const wrapper = mount(DataFreshnessBadge, { props: base })
expect(wrapper.attributes('data-status')).toBe('fresh')
})
it('marks data stale only after the configured boundary', () => {
const wrapper = mount(DataFreshnessBadge, { props: { ...base, now: '2026-08-12T00:30:01Z' } })
expect(wrapper.attributes('data-status')).toBe('stale')
expect(wrapper.attributes('aria-label')).toBe('데이터 지연')
})
it('exposes source/revision and emits an explicit refresh command only when enabled', async () => {
const wrapper = mount(DataFreshnessBadge, { props: { ...base, source: 'approved-read-model', revision: 3, refreshable: true } })
expect(wrapper.text()).toContain('rev 3')
expect(wrapper.attributes('title')).toContain('approved-read-model')
await wrapper.trigger('click')
expect(wrapper.emitted('refresh')).toHaveLength(1)
})
})