mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
## Summary Add historical trend visualization (ASCII sparklines + directional arrows) to the performance PR report, showing how each metric has moved over recent commits on main. ## Changes - **What**: New `sparkline()`, `trendDirection()`, `trendArrow()` functions in `perf-stats.ts`. New collapsible "Trend" section in the perf report showing per-metric sparklines, direction indicators, and latest values. CI workflow updated to download historical data from the `perf-data` orphan branch and switched to `setup-frontend` action with `pnpm exec tsx`. ## Review Focus - The trend section only renders when ≥3 historical data points exist (gracefully absent otherwise) - `trendDirection()` uses a split-half mean comparison with ±10% threshold — review whether this sensitivity is appropriate - The `git archive` step in `pr-perf-report.yaml` is idempotent and fails silently if no perf-history data exists yet on the perf-data branch ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9939-feat-add-trend-visualization-with-sparklines-to-perf-report-3246d73d36508125a6fcc39612f850fe) by [Unito](https://www.unito.io) --------- Co-authored-by: GitHub Action <action@github.com>
202 lines
5.8 KiB
TypeScript
202 lines
5.8 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import {
|
|
classifyChange,
|
|
computeStats,
|
|
formatSignificance,
|
|
isNoteworthy,
|
|
sparkline,
|
|
trendArrow,
|
|
trendDirection,
|
|
zScore
|
|
} from './perf-stats'
|
|
|
|
describe('computeStats', () => {
|
|
it('returns zeros for empty array', () => {
|
|
const stats = computeStats([])
|
|
expect(stats).toEqual({ mean: 0, stddev: 0, min: 0, max: 0, n: 0 })
|
|
})
|
|
|
|
it('returns value with zero stddev for single element', () => {
|
|
const stats = computeStats([42])
|
|
expect(stats).toEqual({ mean: 42, stddev: 0, min: 42, max: 42, n: 1 })
|
|
})
|
|
|
|
it('computes correct stats for known values', () => {
|
|
// Values: [2, 4, 4, 4, 5, 5, 7, 9]
|
|
// Mean = 5, sample variance ≈ 4.57, sample stddev ≈ 2.14
|
|
const stats = computeStats([2, 4, 4, 4, 5, 5, 7, 9])
|
|
expect(stats.mean).toBe(5)
|
|
expect(stats.stddev).toBeCloseTo(2.138, 2)
|
|
expect(stats.min).toBe(2)
|
|
expect(stats.max).toBe(9)
|
|
expect(stats.n).toBe(8)
|
|
})
|
|
|
|
it('uses sample stddev (n-1 denominator)', () => {
|
|
// [10, 20] → mean=15, variance=(25+25)/1=50, stddev≈7.07
|
|
const stats = computeStats([10, 20])
|
|
expect(stats.mean).toBe(15)
|
|
expect(stats.stddev).toBeCloseTo(7.071, 2)
|
|
expect(stats.n).toBe(2)
|
|
})
|
|
|
|
it('handles identical values', () => {
|
|
const stats = computeStats([5, 5, 5, 5])
|
|
expect(stats.mean).toBe(5)
|
|
expect(stats.stddev).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('zScore', () => {
|
|
it('returns null when stddev is 0', () => {
|
|
const stats = computeStats([5, 5, 5])
|
|
expect(zScore(10, stats)).toBeNull()
|
|
})
|
|
|
|
it('returns null when n < 2', () => {
|
|
const stats = computeStats([5])
|
|
expect(zScore(10, stats)).toBeNull()
|
|
})
|
|
|
|
it('computes correct z-score', () => {
|
|
const stats = { mean: 100, stddev: 10, min: 80, max: 120, n: 5 }
|
|
expect(zScore(120, stats)).toBe(2)
|
|
expect(zScore(80, stats)).toBe(-2)
|
|
expect(zScore(100, stats)).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('classifyChange', () => {
|
|
it('returns noisy when CV > 50%', () => {
|
|
expect(classifyChange(3, 60)).toBe('noisy')
|
|
expect(classifyChange(-3, 51)).toBe('noisy')
|
|
})
|
|
|
|
it('does not classify as noisy when CV is exactly 50%', () => {
|
|
expect(classifyChange(3, 50)).toBe('regression')
|
|
expect(classifyChange(-3, 50)).toBe('improvement')
|
|
})
|
|
|
|
it('returns neutral when z is null', () => {
|
|
expect(classifyChange(null, 10)).toBe('neutral')
|
|
})
|
|
|
|
it('returns regression when z > 2', () => {
|
|
expect(classifyChange(2.1, 10)).toBe('regression')
|
|
expect(classifyChange(5, 10)).toBe('regression')
|
|
})
|
|
|
|
it('returns improvement when z < -2', () => {
|
|
expect(classifyChange(-2.1, 10)).toBe('improvement')
|
|
expect(classifyChange(-5, 10)).toBe('improvement')
|
|
})
|
|
|
|
it('returns neutral when z is within [-2, 2]', () => {
|
|
expect(classifyChange(0, 10)).toBe('neutral')
|
|
expect(classifyChange(1.9, 10)).toBe('neutral')
|
|
expect(classifyChange(-1.9, 10)).toBe('neutral')
|
|
expect(classifyChange(2, 10)).toBe('neutral')
|
|
expect(classifyChange(-2, 10)).toBe('neutral')
|
|
})
|
|
})
|
|
|
|
describe('formatSignificance', () => {
|
|
it('formats regression with z-score and emoji', () => {
|
|
expect(formatSignificance('regression', 3.2)).toBe('⚠️ z=3.2')
|
|
})
|
|
|
|
it('formats improvement with z-score without emoji', () => {
|
|
expect(formatSignificance('improvement', -2.5)).toBe('z=-2.5')
|
|
})
|
|
|
|
it('formats noisy as descriptive text', () => {
|
|
expect(formatSignificance('noisy', null)).toBe('variance too high')
|
|
})
|
|
|
|
it('formats neutral with z-score without emoji', () => {
|
|
expect(formatSignificance('neutral', 0.5)).toBe('z=0.5')
|
|
})
|
|
|
|
it('formats neutral without z-score as dash', () => {
|
|
expect(formatSignificance('neutral', null)).toBe('—')
|
|
})
|
|
})
|
|
|
|
describe('isNoteworthy', () => {
|
|
it('returns true for regressions', () => {
|
|
expect(isNoteworthy('regression')).toBe(true)
|
|
})
|
|
|
|
it('returns false for non-regressions', () => {
|
|
expect(isNoteworthy('improvement')).toBe(false)
|
|
expect(isNoteworthy('neutral')).toBe(false)
|
|
expect(isNoteworthy('noisy')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('sparkline', () => {
|
|
it('returns empty string for no values', () => {
|
|
expect(sparkline([])).toBe('')
|
|
})
|
|
|
|
it('returns mid-height for single value', () => {
|
|
expect(sparkline([50])).toBe('▄')
|
|
})
|
|
|
|
it('renders ascending values low to high', () => {
|
|
const result = sparkline([0, 25, 50, 75, 100])
|
|
expect(result).toBe('▁▃▅▆█')
|
|
})
|
|
|
|
it('renders identical values as flat line', () => {
|
|
const result = sparkline([10, 10, 10])
|
|
expect(result).toBe('▄▄▄')
|
|
})
|
|
|
|
it('renders descending values high to low', () => {
|
|
const result = sparkline([100, 50, 0])
|
|
expect(result).toBe('█▅▁')
|
|
})
|
|
})
|
|
|
|
describe('trendDirection', () => {
|
|
it('returns stable for fewer than 3 values', () => {
|
|
expect(trendDirection([])).toBe('stable')
|
|
expect(trendDirection([1])).toBe('stable')
|
|
expect(trendDirection([1, 2])).toBe('stable')
|
|
})
|
|
|
|
it('detects rising trend', () => {
|
|
expect(trendDirection([10, 10, 10, 20, 20, 20])).toBe('rising')
|
|
})
|
|
|
|
it('detects falling trend', () => {
|
|
expect(trendDirection([20, 20, 20, 10, 10, 10])).toBe('falling')
|
|
})
|
|
|
|
it('returns stable for flat data', () => {
|
|
expect(trendDirection([100, 100, 100, 100])).toBe('stable')
|
|
})
|
|
|
|
it('returns stable for small fluctuations within 10%', () => {
|
|
expect(trendDirection([100, 100, 100, 105, 105, 105])).toBe('stable')
|
|
})
|
|
|
|
it('detects rising when baseline is zero but current is non-zero', () => {
|
|
expect(trendDirection([0, 0, 0, 5, 5, 5])).toBe('rising')
|
|
})
|
|
|
|
it('returns stable when both halves are zero', () => {
|
|
expect(trendDirection([0, 0, 0, 0, 0, 0])).toBe('stable')
|
|
})
|
|
})
|
|
|
|
describe('trendArrow', () => {
|
|
it('returns correct emoji for each direction', () => {
|
|
expect(trendArrow('rising')).toBe('📈')
|
|
expect(trendArrow('falling')).toBe('📉')
|
|
expect(trendArrow('stable')).toBe('➡️')
|
|
})
|
|
})
|