mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-06 17:37:47 +08:00
Combine retained daily aggregates with recent usage without replaying historical records or changing the activation boundary. Complete activation-day metrics and expose the UTC calendar used by legacy history. Render all 365 calendar dates, size heatmap cells responsively, and soften cell corners. Add regression coverage for history cutoffs, retention, sparse dates, and timezone boundaries.
163 lines
6.8 KiB
TypeScript
163 lines
6.8 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue'
|
|
import ActivityHeatmap from '../ActivityHeatmap.vue'
|
|
import type { ActivityHeatmap as HeatmapData } from '@/types/activity'
|
|
|
|
const mounted: Array<{ app: App; root: HTMLElement }> = []
|
|
const observers: ResizeObserverStub[] = []
|
|
|
|
class ResizeObserverStub {
|
|
target: Element | null = null
|
|
constructor(private callback: ResizeObserverCallback) { observers.push(this) }
|
|
observe(target: Element) { this.target = target }
|
|
unobserve() {}
|
|
disconnect() { this.target = null }
|
|
resize(width: number) {
|
|
if (!this.target) throw new Error('Heatmap has not registered its resize observer')
|
|
this.callback([{ target: this.target, contentRect: { width } } as ResizeObserverEntry], this as unknown as ResizeObserver)
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
observers.length = 0
|
|
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
|
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })))
|
|
})
|
|
|
|
afterEach(() => {
|
|
for (const { app, root } of mounted.splice(0)) { app.unmount(); root.remove() }
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
function heatmap(startDate: string, length: number): HeatmapData {
|
|
const start = new Date(`${startDate}T00:00:00Z`).getTime()
|
|
const days = Array.from({ length }, (_, index) => ({
|
|
date: new Date(start + index * 86400000).toISOString().slice(0, 10),
|
|
requests: index === length - 1 ? 250 : 0,
|
|
}))
|
|
return { start_date: startDate, end_date: days[days.length - 1]!.date, total_days: length, max_requests: 250, days }
|
|
}
|
|
|
|
async function settle() { await nextTick(); await nextTick() }
|
|
|
|
async function mount(data: HeatmapData, compact = true) {
|
|
const model = ref(data)
|
|
const isCompact = ref(compact)
|
|
const root = document.createElement('div')
|
|
document.body.appendChild(root)
|
|
const app = createApp(defineComponent({
|
|
setup: () => () => h(ActivityHeatmap, { data: model.value, compact: isCompact.value, showHeader: false }),
|
|
}))
|
|
mounted.push({ app, root })
|
|
app.mount(root)
|
|
await settle()
|
|
return { root, model, isCompact, observer: observers[observers.length - 1]! }
|
|
}
|
|
|
|
function cells(root: HTMLElement) {
|
|
return Array.from(root.querySelectorAll<HTMLElement>('.cell-emerge'))
|
|
}
|
|
|
|
function size(root: HTMLElement) { return Number.parseFloat(cells(root)[0]!.style.width) }
|
|
|
|
describe('activity heatmap layout', () => {
|
|
it.each([
|
|
{ compact: true, length: 1, maximum: 18 },
|
|
{ compact: false, length: 1, maximum: 16 },
|
|
{ compact: true, length: 31, maximum: 18 },
|
|
{ compact: false, length: 31, maximum: 16 },
|
|
])('keeps $length days within $maximum px cells (compact: $compact)', async ({ compact, length, maximum }) => {
|
|
const { root, observer } = await mount(heatmap('2026-10-05', length), compact)
|
|
observer.resize(1040)
|
|
await settle()
|
|
expect(cells(root)).toHaveLength(length)
|
|
for (const cell of cells(root)) {
|
|
expect(Number.parseFloat(cell.style.width)).toBeLessThanOrEqual(maximum)
|
|
expect(Number.parseFloat(cell.style.width)).toBeGreaterThanOrEqual(6)
|
|
expect(cell.style.height).toBe(cell.style.width)
|
|
}
|
|
const firstWeek = cells(root)[0]!.parentElement!.parentElement!
|
|
expect(firstWeek.children).toHaveLength(7)
|
|
expect(firstWeek.children[0]!.querySelector('.cell-emerge')).toBeNull()
|
|
expect(firstWeek.children[1]!.querySelector('.cell-emerge')).toBe(cells(root)[0])
|
|
})
|
|
|
|
it('keeps a full year legible in a narrow container and resizes when its width or data changes', async () => {
|
|
const { root, observer, model } = await mount(heatmap('2025-10-06', 365))
|
|
observer.resize(280)
|
|
await settle()
|
|
expect(cells(root)).toHaveLength(365)
|
|
expect(size(root)).toBe(6)
|
|
observer.resize(640)
|
|
await settle()
|
|
expect(size(root)).toBeGreaterThan(6)
|
|
expect(size(root)).toBeLessThanOrEqual(12)
|
|
const yearCellSize = size(root)
|
|
model.value = heatmap('2026-10-05', 1)
|
|
await settle()
|
|
expect(cells(root)).toHaveLength(1)
|
|
expect(size(root)).toBeGreaterThan(yearCellSize)
|
|
expect(size(root)).toBeLessThanOrEqual(12)
|
|
model.value = heatmap('2025-10-06', 365)
|
|
await settle()
|
|
expect(size(root)).toBe(yearCellSize)
|
|
observer.resize(1400)
|
|
await settle()
|
|
expect(size(root) * 53 + 52 * 2).toBeCloseTo(1400)
|
|
expect(cells(root)[0]!.style.height).toBe(cells(root)[0]!.style.width)
|
|
})
|
|
|
|
it('updates the cell size and spacing when compact mode changes', async () => {
|
|
const { root, observer, isCompact } = await mount(heatmap('2026-10-05', 1), false)
|
|
observer.resize(1040)
|
|
await settle()
|
|
expect(size(root)).toBeLessThanOrEqual(16)
|
|
expect(cells(root)[0]!.parentElement!.parentElement!.style.rowGap).toBe('4px')
|
|
isCompact.value = true
|
|
await settle()
|
|
expect(size(root)).toBeLessThanOrEqual(18)
|
|
expect(cells(root)[0]!.parentElement!.parentElement!.style.rowGap).toBe('2px')
|
|
})
|
|
|
|
it('preserves calendar gaps between activity dates without inventing token or cost data', async () => {
|
|
const data = heatmap('2026-10-05', 4)
|
|
data.days = [
|
|
{ date: '2026-10-05', requests: 250, total_tokens: 1000, total_cost: 1.25 },
|
|
{ date: '2026-10-08', requests: 650 },
|
|
]
|
|
data.max_requests = 650
|
|
const { root } = await mount(data)
|
|
const days = cells(root)
|
|
expect(days).toHaveLength(4)
|
|
expect(days[0]!.title).toContain('250')
|
|
expect(days[0]!.title).toContain('$1.25')
|
|
expect(days[3]!.title).toContain('650')
|
|
for (const [index, date] of ['2026-10-06', '2026-10-07'].entries()) {
|
|
const cell = days[index + 1]!
|
|
expect(cell.title).toContain(new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeZone: 'UTC' }).format(new Date(`${date}T00:00:00Z`)))
|
|
expect(cell.title.split(' · ')[1]).toMatch(/^0\D/)
|
|
expect(cell.title).not.toContain('tokens')
|
|
expect(cell.title).not.toContain('$')
|
|
}
|
|
const week = days[0]!.parentElement!.parentElement!
|
|
expect(week.children[1]!.querySelector('.cell-emerge')).toBe(days[0])
|
|
expect(week.children[4]!.querySelector('.cell-emerge')).toBe(days[3])
|
|
})
|
|
|
|
it.each([false, true])('renders the full declared year with sparse or empty activity (empty: %s)', async empty => {
|
|
const data = heatmap('2025-10-06', 365)
|
|
data.days = empty ? [] : [{ date: '2026-05-17', requests: 250 }]
|
|
const { root, observer } = await mount(data)
|
|
observer.resize(1040)
|
|
await settle()
|
|
const days = cells(root)
|
|
expect(days).toHaveLength(365)
|
|
expect(days[0]!.title).toContain('2025年10月6日')
|
|
expect(days[364]!.title).toContain('2026年10月5日')
|
|
expect(days[0]!.title.split(' · ')[1]).toMatch(/^0\D/)
|
|
expect(days[364]!.title.split(' · ')[1]).toMatch(/^0\D/)
|
|
expect(days.filter(day => day.title.split(' · ')[1]?.startsWith('250'))).toHaveLength(empty ? 0 : 1)
|
|
expect(size(root) * 53 + 52 * 2).toBeCloseTo(1040)
|
|
})
|
|
})
|