File size: 5,558 Bytes
dcedb25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
import { describe, expect, it } from 'vitest'
import type { CategoryAxisProps } from './chart-axis'
import {
  categoryAxisProps,
  truncateAxisLabel,
  verticalCategoryAxisProps,
  MAX_AXIS_LABEL,
  MAX_AXIS_LABEL_STEEP,
  VERTICAL_AXIS_WIDTH,
} from './chart-axis'

// Same estimate the module sizes itself with: ~0.5em per glyph at 11px.
const CHAR_WIDTH = 5.5
const LINE_HEIGHT = 13
const NARROW_PLOT_WIDTH = 280

const sin = (deg: number) => Math.sin((Math.abs(deg) * Math.PI) / 180)

describe('categoryAxisProps (#890)', () => {
  it('forces every tick so recharts never skips a provider label', () => {
    // recharts' default interval is 'preserveEnd', which is exactly what hid
    // the middle provider names; 0 means "render a tick for every category".
    for (const count of [0, 1, 5, 10, 11, 15, 16, 40]) {
      expect(categoryAxisProps(count).interval).toBe(0)
    }
  })

  it('rotates labels and reserves vertical room for them', () => {
    const p = categoryAxisProps(5)
    expect(p.angle).not.toBe(0)
    expect(p.textAnchor).toBe('end')
    expect(p.height).toBeGreaterThan(30)
  })

  it('tilts harder as the chart gets more crowded', () => {
    // A handful of providers reads best on a gentle tilt...
    expect(categoryAxisProps(4).angle).toBe(-30)
    expect(categoryAxisProps(10).angle).toBe(-30)
    // ...but past what -30° can seat on a phone the labels have to steepen.
    expect(categoryAxisProps(11).angle).toBe(-45)
    expect(categoryAxisProps(15).angle).toBe(-45)
    expect(categoryAxisProps(16).angle).toBe(-90)
    // Beyond even the vertical tier's capacity we stay vertical — there is
    // nothing steeper, and interval 0 still beats dropping labels.
    expect(categoryAxisProps(500).angle).toBe(-90)
  })

  it('never lets a tilt pack labels closer together than they are thick', () => {
    // The point of the tiers: at the chosen angle, each label's horizontal
    // footprint must still fit the slice of a narrow (mobile) plot it gets.
    for (const count of [1, 5, 10, 11, 15, 16, 21]) {
      const { angle } = categoryAxisProps(count)
      const bandNeeded = LINE_HEIGHT / sin(angle)
      const bandAvailable = NARROW_PLOT_WIDTH / Math.max(count, 1)
      expect(bandAvailable).toBeGreaterThanOrEqual(bandNeeded)
    }
  })

  it('reserves enough height for the longest label it will actually draw', () => {
    // The original fix under-provisioned this: an 18-char label at -30° needs
    // ~50px and only ~48 were left after the tick gap.
    const longest = 'x'.repeat(64)
    for (const count of [1, 8, 10, 11, 15, 16, 40]) {
      const p = categoryAxisProps(count)
      const drawn = p.tickFormatter(longest)
      const needed = drawn.length * CHAR_WIDTH * sin(p.angle)
      expect(p.height).toBeGreaterThanOrEqual(needed)
    }
  })

  it('keeps the reserved strip a sane share of a 240px chart', () => {
    for (const count of [1, 12, 16, 40]) {
      expect(categoryAxisProps(count).height).toBeLessThanOrEqual(80)
    }
  })

  it('truncates to the cap that matches the tier it picked', () => {
    const long = 'anthropic-compatible-very-long-relay-host'
    expect(categoryAxisProps(4).tickFormatter(long).length).toBe(MAX_AXIS_LABEL)
    expect(categoryAxisProps(40).tickFormatter(long).length).toBe(MAX_AXIS_LABEL_STEEP)
  })

  it('assumes the roomiest tier when the caller does not pass a count', () => {
    // Compare the plain fields: the two tickFormatters are equivalent
    // closures, but not the same object, so toEqual would trip over them.
    const bare = ({ interval, angle, textAnchor, height }: CategoryAxisProps) =>
      ({ interval, angle, textAnchor, height })
    expect(bare(categoryAxisProps())).toEqual(bare(categoryAxisProps(1)))
    expect(categoryAxisProps().tickFormatter('x'.repeat(64)).length).toBe(MAX_AXIS_LABEL)
  })
})

describe('truncateAxisLabel', () => {
  it('truncates over-long labels but leaves short ones alone', () => {
    expect(truncateAxisLabel('groq')).toBe('groq')
    expect(truncateAxisLabel('x'.repeat(MAX_AXIS_LABEL))).toBe('x'.repeat(MAX_AXIS_LABEL))
    const long = truncateAxisLabel('anthropic-compatible-very-long-relay-host')
    expect(long.length).toBe(MAX_AXIS_LABEL)
    expect(long.endsWith('…')).toBe(true)
  })

  it('honours an explicit cap', () => {
    expect(truncateAxisLabel('anthropic-relay', 8)).toBe('anthrop…')
  })

  it('truncation is stable (idempotent) so a second pass changes nothing', () => {
    const once = truncateAxisLabel('anthropic-compatible-very-long-relay-host')
    expect(truncateAxisLabel(once)).toBe(once)
  })
})

describe('verticalCategoryAxisProps (#890, errors-by-category)', () => {
  it('forces every tick on the category (Y) axis of a vertical bar chart', () => {
    // implicitYAxis defaults to 'preserveEnd' just like the X one, so the
    // horizontal "errors by category" chart dropped labels the same way.
    expect(verticalCategoryAxisProps().interval).toBe(0)
  })

  it('keeps the gutter it reserves and truncates labels to fit it', () => {
    const p = verticalCategoryAxisProps()
    expect(p.width).toBe(VERTICAL_AXIS_WIDTH)
    const drawn = p.tickFormatter('upstream-authentication-failed')
    expect(drawn.length * CHAR_WIDTH).toBeLessThanOrEqual(VERTICAL_AXIS_WIDTH)
    expect(drawn.endsWith('…')).toBe(true)
    expect(p.tickFormatter('rate_limit')).toBe('rate_limit')
  })

  it('does not rotate — a side-on axis has its own gutter, not a tick pitch', () => {
    expect(verticalCategoryAxisProps()).not.toHaveProperty('angle')
  })
})