@titan-design/react-ui 0.10.0 → 0.12.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.
- package/dist/bodymap.js +173 -343
- package/dist/bodymap.js.map +1 -1
- package/dist/bodymap.mjs +173 -343
- package/dist/bodymap.mjs.map +1 -1
- package/dist/index.d.mts +416 -18
- package/dist/index.d.ts +416 -18
- package/dist/index.js +2512 -1307
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2480 -1299
- package/dist/index.mjs.map +1 -1
- package/dist/{pages-DyEx-lBf.d.ts → pages-B1hqnGIC.d.ts} +149 -15
- package/dist/{pages-D7hOD4Vj.d.mts → pages-CvsFqNtx.d.mts} +149 -15
- package/dist/pages.d.mts +1 -1
- package/dist/pages.d.ts +1 -1
- package/dist/pages.js +1644 -1575
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +1645 -1576
- package/dist/pages.mjs.map +1 -1
- package/dist/theme/index.d.mts +409 -415
- package/dist/theme/index.d.ts +409 -415
- package/dist/theme/index.js +234 -408
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +227 -399
- package/dist/theme/index.mjs.map +1 -1
- package/dist/theme/tokens-css.js +141 -140
- package/dist/theme/tokens-css.js.map +1 -1
- package/dist/theme/tokens-css.mjs +141 -140
- package/dist/theme/tokens-css.mjs.map +1 -1
- package/package.json +8 -3
- package/src/arch/Architecture.stories.tsx +9 -9
- package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
- package/src/components/custom/Fatigue/DualGhostSpark.stories.tsx +258 -0
- package/src/components/custom/Fatigue/DualGhostSpark.test.tsx +138 -0
- package/src/components/custom/Fatigue/DualGhostSpark.tsx +208 -0
- package/src/components/custom/Fatigue/FatigueLights.stories.tsx +74 -0
- package/src/components/custom/Fatigue/FatigueLights.test.tsx +33 -0
- package/src/components/custom/Fatigue/FatigueLights.tsx +83 -0
- package/src/components/custom/Fatigue/GhostBand.test.tsx +235 -0
- package/src/components/custom/Fatigue/GhostBand.tsx +246 -0
- package/src/components/custom/Fatigue/GhostBloom.tsx +132 -0
- package/src/components/custom/Fatigue/GhostSpark.stories.tsx +175 -0
- package/src/components/custom/Fatigue/GhostSpark.test.tsx +45 -0
- package/src/components/custom/Fatigue/GhostSpark.tsx +126 -0
- package/src/components/custom/Fatigue/LiveFatigueCard.stories.tsx +126 -0
- package/src/components/custom/Fatigue/LiveFatigueCard.test.tsx +28 -0
- package/src/components/custom/Fatigue/LiveFatigueCard.tsx +86 -0
- package/src/components/custom/Fatigue/LiveFatiguePanel.stories.tsx +95 -0
- package/src/components/custom/Fatigue/LiveFatiguePanel.tsx +151 -0
- package/src/components/custom/Fatigue/README.md +93 -0
- package/src/components/custom/Fatigue/RomProgressionChart.stories.tsx +94 -0
- package/src/components/custom/Fatigue/RomProgressionChart.test.tsx +31 -0
- package/src/components/custom/Fatigue/RomProgressionChart.tsx +132 -0
- package/src/components/custom/Fatigue/SilverRedPalette.stories.tsx +226 -0
- package/src/components/custom/Fatigue/VelocityHero.stories.tsx +70 -0
- package/src/components/custom/Fatigue/VelocityHero.test.tsx +26 -0
- package/src/components/custom/Fatigue/VelocityHero.tsx +46 -0
- package/src/components/custom/Fatigue/VerdictHero.stories.tsx +59 -0
- package/src/components/custom/Fatigue/VerdictHero.test.tsx +49 -0
- package/src/components/custom/Fatigue/VerdictHero.tsx +79 -0
- package/src/components/custom/Fatigue/fatigue-mock.test.ts +59 -0
- package/src/components/custom/Fatigue/fatigue-mock.ts +268 -0
- package/src/components/custom/Fatigue/fatigue-model.ts +129 -0
- package/src/components/custom/Fatigue/fatigue-tokens.test.ts +70 -0
- package/src/components/custom/Fatigue/fatigue-tokens.ts +180 -0
- package/src/components/custom/Fatigue/index.ts +41 -0
- package/src/components/custom/Fatigue/tempo-pacing.test.ts +176 -0
- package/src/components/custom/Fatigue/tempo-pacing.ts +112 -0
- package/src/components/custom/Sidebar/Sidebar.tsx +4 -4
- package/src/components/custom/Table/Table.tsx +2 -2
- package/src/components/custom/Workout/ActiveWorkoutPage.tsx +3 -3
- package/src/components/custom/Workout/BaseBadge.test.tsx +6 -1
- package/src/components/custom/Workout/BaseBadge.tsx +1 -1
- package/src/components/custom/Workout/BodyMap.tsx +2 -2
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +2 -2
- package/src/components/custom/Workout/DeviationBar.test.tsx +2 -1
- package/src/components/custom/Workout/DeviationBar.tsx +3 -2
- package/src/components/custom/Workout/DualVelocityStrip.test.tsx +685 -0
- package/src/components/custom/Workout/ExerciseCard.test.tsx +7 -7
- package/src/components/custom/Workout/ExerciseCard.tsx +5 -3
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +11 -5
- package/src/components/custom/Workout/InputBar.tsx +2 -2
- package/src/components/custom/Workout/LiveAuraFrame.stories.tsx +1 -1
- package/src/components/custom/Workout/LiveAuraFrame.test.tsx +1 -1
- package/src/components/custom/Workout/LiveAuraFrame.tsx +3 -3
- package/src/components/custom/Workout/MesoCard.tsx +3 -1
- package/src/components/custom/Workout/MuscleGroupChip.tsx +4 -3
- package/src/components/custom/Workout/PrHistoryModal.tsx +2 -2
- package/src/components/custom/Workout/ProgramPlanningPage.tsx +1 -1
- package/src/components/custom/Workout/README.md +62 -16
- package/src/components/custom/Workout/ReadinessCheck.tsx +2 -2
- package/src/components/custom/Workout/RestTimer.tsx +2 -2
- package/src/components/custom/Workout/S3SetTypes.stories.tsx +3 -3
- package/src/components/custom/Workout/SegmentedBar.stories.tsx +3 -3
- package/src/components/custom/Workout/SessionHeader.stories.tsx +2 -2
- package/src/components/custom/Workout/SessionHeader.tsx +1 -1
- package/src/components/custom/Workout/SessionRail.stories.tsx +2 -2
- package/src/components/custom/Workout/SessionRail.test.tsx +43 -0
- package/src/components/custom/Workout/SessionRail.tsx +63 -18
- package/src/components/custom/Workout/SetBar.tsx +3 -3
- package/src/components/custom/Workout/SetRow.test.tsx +12 -9
- package/src/components/custom/Workout/SetRow.tsx +9 -2
- package/src/components/custom/Workout/StatusDot.tsx +5 -4
- package/src/components/custom/Workout/StrengthTrendChart.tsx +1 -1
- package/src/components/custom/Workout/TrainingStatusPage.tsx +2 -2
- package/src/components/custom/Workout/VelocityStrip.compact.stories.tsx +191 -0
- package/src/components/custom/Workout/VelocityStrip.dual.stories.tsx +186 -0
- package/src/components/custom/Workout/VelocityStrip.expanded.stories.tsx +296 -0
- package/src/components/custom/Workout/VelocityStrip.hero.stories.tsx +259 -0
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +217 -250
- package/src/components/custom/Workout/VelocityStrip.test.tsx +199 -79
- package/src/components/custom/Workout/VelocityStrip.tsx +953 -609
- package/src/components/custom/Workout/VolumeLandmarkBar.tsx +3 -3
- package/src/components/custom/Workout/WeightBadge.test.tsx +6 -1
- package/src/components/custom/Workout/WeightBadge.tsx +2 -1
- package/src/components/custom/Workout/WorkoutCard.tsx +3 -1
- package/src/components/custom/Workout/WorkoutPill.tsx +5 -2
- package/src/components/custom/Workout/ZoneTrack.tsx +4 -4
- package/src/components/custom/Workout/index.ts +3 -0
- package/src/components/custom/Workout/setHeadingKit.tsx +14 -8
- package/src/components/custom/Workout/velocity-story-kit.tsx +438 -0
- package/src/components/custom/charts/SetBarChart.test.tsx +275 -0
- package/src/components/custom/charts/SetBarChart.tsx +651 -0
- package/src/components/custom/charts/live-rep-growth.ts +91 -0
- package/src/components/custom/index.ts +1 -0
- package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
- package/src/components/custom/stepper/Stepper.tsx +3 -3
- package/src/components/shell/DeviceMenu.tsx +2 -2
- package/src/components/shell/SideNav.tsx +1 -1
- package/src/components/shell/TopBar.tsx +1 -1
- package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
- package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
- package/src/components/ui/avatar/Avatar.tsx +2 -2
- package/src/components/ui/badge/Badge.tsx +2 -2
- package/src/components/ui/card/Card.tsx +3 -3
- package/src/components/ui/checkbox/Checkbox.tsx +1 -1
- package/src/components/ui/collapse/Collapse.tsx +1 -1
- package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
- package/src/components/ui/drawer/Drawer.tsx +3 -3
- package/src/components/ui/form-field/FormField.tsx +1 -1
- package/src/components/ui/help-tip/HelpTip.tsx +1 -1
- package/src/components/ui/menu/Menu.tsx +2 -2
- package/src/components/ui/pill/Pill.tsx +2 -2
- package/src/components/ui/popover/Popover.stories.tsx +2 -2
- package/src/components/ui/popover/Popover.tsx +1 -1
- package/src/components/ui/progress/Progress.tsx +3 -3
- package/src/components/ui/radio/Radio.stories.tsx +1 -1
- package/src/components/ui/radio/Radio.tsx +4 -4
- package/src/components/ui/select/Select.tsx +3 -3
- package/src/components/ui/surface/Surface.stories.tsx +3 -3
- package/src/components/ui/surface/Surface.test.tsx +32 -19
- package/src/components/ui/surface/Surface.tsx +3 -3
- package/src/components/ui/surface/SurfaceContext.ts +19 -29
- package/src/components/ui/surface/surface.contract.test.ts +57 -44
- package/src/components/ui/switch/Switch.tsx +1 -1
- package/src/components/ui/tabs/Tabs.tsx +2 -2
- package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +5 -5
- package/src/components/ui/toolbar-button/ToolbarButton.test.tsx +1 -1
- package/src/components/ui/toolbar-button/ToolbarButton.tsx +36 -22
- package/src/stories/PresetShowcase.stories.tsx +21 -21
- package/src/test/raw-color-patterns.test.ts +71 -0
- package/src/theme/ColorPalettes.stories.tsx +874 -0
- package/src/theme/ColorPrimitives.stories.tsx +348 -0
- package/src/theme/Depth.stories.tsx +312 -0
- package/src/theme/DepthCalibration.stories.tsx +655 -0
- package/src/theme/ThemeProvider.tsx +1 -1
- package/src/theme/barrel.ts +7 -14
- package/src/theme/color-stories.coverage.test.ts +125 -0
- package/src/theme/color-story-kit.tsx +170 -0
- package/src/theme/color-utils.ts +197 -0
- package/src/theme/config.ts +0 -8
- package/src/theme/depth-calibration.test.tsx +97 -0
- package/src/theme/elevation.test.ts +62 -1
- package/src/theme/elevation.ts +34 -8
- package/src/theme/global.css +47 -53
- package/src/theme/grey-ramp.test.ts +166 -0
- package/src/theme/materials.test.ts +106 -0
- package/src/theme/materials.ts +211 -0
- package/src/theme/presets/audiobook.ts +4 -4
- package/src/theme/presets/types.ts +3 -3
- package/src/theme/semantic-contrast.test.ts +83 -0
- package/src/theme/tokens/primitives.ts +62 -115
- package/src/theme/tokens/semantic.ts +130 -82
- package/src/utils/colors.ts +1 -1
- package/tailwind.config.js +2 -5
- package/src/components/custom/Workout/VelocityStrip.modalities.stories.tsx +0 -254
- package/src/components/custom/Workout/VelocityStrip.responsive.stories.tsx +0 -145
- package/src/theme/ColorSystem.stories.tsx +0 -508
- package/src/theme/Colors.stories.tsx +0 -323
- package/src/theme/shadows.stories.tsx +0 -523
- package/src/theme/shadows.ts +0 -439
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { render, screen, within } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import { DualVelocityStrip } from './VelocityStrip'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The newest-rep entrance is an `Animated` value applied IMPERATIVELY, so jsdom never reflects it:
|
|
8
|
+
* a live bar's inline height sits at its static value for the whole animation, and driving
|
|
9
|
+
* requestAnimationFrame does not change that. The shared hook is therefore the only observable
|
|
10
|
+
* seam for "did the entrance actually arm on this wing".
|
|
11
|
+
*
|
|
12
|
+
* The spy PASSES THROUGH to the real implementation, so every other test in this file renders
|
|
13
|
+
* exactly as it would unmocked; it only records the arguments each composed strip armed with.
|
|
14
|
+
*/
|
|
15
|
+
const liveEntranceCalls = vi.hoisted(
|
|
16
|
+
() => [] as Array<[boolean, number | undefined, number | undefined, boolean]>
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
vi.mock('../charts/live-rep-growth', async () => {
|
|
20
|
+
const actual =
|
|
21
|
+
await vi.importActual<typeof import('../charts/live-rep-growth')>('../charts/live-rep-growth')
|
|
22
|
+
return {
|
|
23
|
+
...actual,
|
|
24
|
+
useLiveRepGrowth: (
|
|
25
|
+
active: boolean,
|
|
26
|
+
liveRepIndex: number | undefined,
|
|
27
|
+
liveVelocity: number | undefined,
|
|
28
|
+
isNewPeak: boolean
|
|
29
|
+
) => {
|
|
30
|
+
liveEntranceCalls.push([active, liveRepIndex, liveVelocity, isNewPeak])
|
|
31
|
+
return actual.useLiveRepGrowth(active, liveRepIndex, liveVelocity, isNewPeak)
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The entrance each composed wing armed with, in render order (up wing, then down wing).
|
|
38
|
+
* `velocity: undefined` means the wing resolved no live rep, so its entrance stays inert.
|
|
39
|
+
*/
|
|
40
|
+
const wingEntrances = () =>
|
|
41
|
+
liveEntranceCalls.map(([, index, velocity, isNewPeak]) => ({ index, velocity, isNewPeak }))
|
|
42
|
+
|
|
43
|
+
// Literal-hex zone colours (must match the component's tokens exactly — same as the
|
|
44
|
+
// single-strip suite): the default effort scale and the WA 5-band mapping.
|
|
45
|
+
const GREEN = '#2ED573' // speed / ≥1.0
|
|
46
|
+
const RED = '#D14343' // maximalStrength / <0.5
|
|
47
|
+
const DISTINCT_RED = '#A4221C' // grinding (5th band, no collapse)
|
|
48
|
+
|
|
49
|
+
// The `hero` variant COMPOSES two single VelocityStrip heroes, one per wing, wrapped in
|
|
50
|
+
// `dual-velocity-wing-up` / `-down` so a test can scope to one side. Inside each wing the
|
|
51
|
+
// composed hero emits the SINGLE-strip testIDs: `velocity-bar-N`, `velocity-label-N`,
|
|
52
|
+
// `velocity-slot-todo`, `velocity-hero-reference`. The `rail` variant is a lean dedicated
|
|
53
|
+
// renderer that keeps the `dual-velocity-bar-L/R-N` testIDs.
|
|
54
|
+
const HERO_BARS = /^velocity-bar-\d+$/
|
|
55
|
+
const RAIL_LEFT_BARS = /^dual-velocity-bar-L-\d+$/
|
|
56
|
+
const RAIL_RIGHT_BARS = /^dual-velocity-bar-R-\d+$/
|
|
57
|
+
const wingUp = () => within(screen.getByTestId('dual-velocity-wing-up'))
|
|
58
|
+
const wingDown = () => within(screen.getByTestId('dual-velocity-wing-down'))
|
|
59
|
+
|
|
60
|
+
// WA-shaped 5-band zone set (compound movement-class defaults, mean m/s).
|
|
61
|
+
const compoundBands = [
|
|
62
|
+
{ id: 'grinding', label: 'Grinding', min: 0, max: 0.35 },
|
|
63
|
+
{ id: 'maximalStrength', label: 'Max Strength', min: 0.35, max: 0.5 },
|
|
64
|
+
{ id: 'strengthSpeed', label: 'Strength-Speed', min: 0.5, max: 0.75 },
|
|
65
|
+
{ id: 'power', label: 'Power', min: 0.75, max: 1.0 },
|
|
66
|
+
{ id: 'speed', label: 'Speed', min: 1.0, max: null },
|
|
67
|
+
] as const
|
|
68
|
+
|
|
69
|
+
describe('DualVelocityStrip composition', () => {
|
|
70
|
+
it('composes an up wing and a down wing separated by the shared wing gap (no centre axis)', () => {
|
|
71
|
+
render(<DualVelocityStrip left={{ velocities: [0.9] }} right={{ velocities: [0.8] }} />)
|
|
72
|
+
expect(screen.getByTestId('dual-velocity-strip')).toBeInTheDocument()
|
|
73
|
+
expect(screen.getByTestId('dual-velocity-wing-up')).toBeInTheDocument()
|
|
74
|
+
expect(screen.getByTestId('dual-velocity-wing-down')).toBeInTheDocument()
|
|
75
|
+
// Exactly two composed single-strip heroes, one per wing.
|
|
76
|
+
expect(screen.getAllByTestId('velocity-strip-hero')).toHaveLength(2)
|
|
77
|
+
// No centre-axis rule any more — the wings read as one lockup via the gap between them.
|
|
78
|
+
expect(screen.queryByTestId('dual-velocity-axis')).not.toBeInTheDocument()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('a 120px dual sizes each wing at ~59px — NOT ballooned to 220 by the hero height sentinel', () => {
|
|
82
|
+
// Regression: the single hero bumps an unset height (the 60px expanded default) to 220. A 120px
|
|
83
|
+
// dual splits into two wings of (120 − wing gap) / 2 = 59px, which — before the fix — collided
|
|
84
|
+
// with that sentinel and blew the wings up to 220 (bars overflowing). The columnSlots (dual) path
|
|
85
|
+
// is exempt, so each wing renders at the split height, not 220.
|
|
86
|
+
render(
|
|
87
|
+
<DualVelocityStrip left={{ velocities: [0.9] }} right={{ velocities: [0.8] }} height={120} />
|
|
88
|
+
)
|
|
89
|
+
for (const wing of screen.getAllByTestId('velocity-strip-hero')) {
|
|
90
|
+
expect(wing).toHaveStyle({ height: '59px' })
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('renders one bar per performed rep on each side (each wing scoped independently)', () => {
|
|
95
|
+
render(
|
|
96
|
+
<DualVelocityStrip
|
|
97
|
+
left={{ velocities: [0.9, 0.85, 0.8] }}
|
|
98
|
+
right={{ velocities: [0.82, 0.74] }}
|
|
99
|
+
/>
|
|
100
|
+
)
|
|
101
|
+
expect(wingUp().queryAllByTestId(HERO_BARS)).toHaveLength(3)
|
|
102
|
+
expect(wingDown().queryAllByTestId(HERO_BARS)).toHaveLength(2)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('summarizes both sides’ reps done vs target in the container accessibility label', () => {
|
|
106
|
+
render(
|
|
107
|
+
<DualVelocityStrip
|
|
108
|
+
left={{ velocities: [0.9, 0.85, 0.8] }}
|
|
109
|
+
right={{ velocities: [0.82, 0.74] }}
|
|
110
|
+
targetReps={8}
|
|
111
|
+
/>
|
|
112
|
+
)
|
|
113
|
+
expect(
|
|
114
|
+
screen.getByLabelText('Dual velocity chart, left 3 of 8 reps, right 2 of 8 reps')
|
|
115
|
+
).toBeInTheDocument()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('shows a per-rep value label per side at hero scale', () => {
|
|
119
|
+
render(<DualVelocityStrip left={{ velocities: [0.9] }} right={{ velocities: [0.82] }} />)
|
|
120
|
+
expect(wingUp().getByTestId('velocity-label-0')).toHaveTextContent('0.90')
|
|
121
|
+
expect(wingDown().getByTestId('velocity-label-0')).toHaveTextContent('0.82')
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe('DualVelocityStrip side labels', () => {
|
|
126
|
+
it('renders each side’s slot name from its stream label (no hardcoded LEFT/RIGHT)', () => {
|
|
127
|
+
render(
|
|
128
|
+
<DualVelocityStrip
|
|
129
|
+
left={{ velocities: [0.9], label: 'Left Arm' }}
|
|
130
|
+
right={{ velocities: [0.8], label: 'Right Arm' }}
|
|
131
|
+
/>
|
|
132
|
+
)
|
|
133
|
+
// The DATA keeps its casing (textContent is the original string); the label is
|
|
134
|
+
// uppercased visually via the small-caps/eyebrow `textTransform`, not by mutating text.
|
|
135
|
+
expect(screen.getByTestId('dual-velocity-side-label-L')).toHaveTextContent('Left Arm')
|
|
136
|
+
expect(screen.getByTestId('dual-velocity-side-label-R')).toHaveTextContent('Right Arm')
|
|
137
|
+
// The retired hardcoded copy is gone.
|
|
138
|
+
expect(screen.queryByText('LEFT VOLTRA')).not.toBeInTheDocument()
|
|
139
|
+
expect(screen.queryByText('RIGHT VOLTRA')).not.toBeInTheDocument()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('renders the slot name in the uppercase small-caps label treatment', () => {
|
|
143
|
+
render(
|
|
144
|
+
<DualVelocityStrip
|
|
145
|
+
left={{ velocities: [0.9], label: 'Left Arm' }}
|
|
146
|
+
right={{ velocities: [0.8], label: 'Right Arm' }}
|
|
147
|
+
/>
|
|
148
|
+
)
|
|
149
|
+
expect(screen.getByTestId('dual-velocity-side-label-L')).toHaveStyle({
|
|
150
|
+
textTransform: 'uppercase',
|
|
151
|
+
})
|
|
152
|
+
expect(screen.getByTestId('dual-velocity-side-label-R')).toHaveStyle({
|
|
153
|
+
textTransform: 'uppercase',
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('omits a side’s label when its stream has no label (no left/right fallback)', () => {
|
|
158
|
+
render(
|
|
159
|
+
<DualVelocityStrip
|
|
160
|
+
left={{ velocities: [0.9], label: 'Left Arm' }}
|
|
161
|
+
right={{ velocities: [0.8] }}
|
|
162
|
+
/>
|
|
163
|
+
)
|
|
164
|
+
expect(screen.getByTestId('dual-velocity-side-label-L')).toBeInTheDocument()
|
|
165
|
+
expect(screen.queryByTestId('dual-velocity-side-label-R')).not.toBeInTheDocument()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('renders no side label when neither stream carries one', () => {
|
|
169
|
+
render(<DualVelocityStrip left={{ velocities: [0.9] }} right={{ velocities: [0.8] }} />)
|
|
170
|
+
expect(screen.queryByTestId('dual-velocity-side-label-L')).not.toBeInTheDocument()
|
|
171
|
+
expect(screen.queryByTestId('dual-velocity-side-label-R')).not.toBeInTheDocument()
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('treats an empty-string label as absent', () => {
|
|
175
|
+
render(
|
|
176
|
+
<DualVelocityStrip
|
|
177
|
+
left={{ velocities: [0.9], label: '' }}
|
|
178
|
+
right={{ velocities: [0.8], label: '' }}
|
|
179
|
+
/>
|
|
180
|
+
)
|
|
181
|
+
expect(screen.queryByTestId('dual-velocity-side-label-L')).not.toBeInTheDocument()
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('drops the gutter/slot labels AND the centre axis at rail scale (the lean dual-expanded)', () => {
|
|
185
|
+
render(
|
|
186
|
+
<DualVelocityStrip
|
|
187
|
+
left={{ velocities: [0.9], label: 'Left Arm' }}
|
|
188
|
+
right={{ velocities: [0.8], label: 'Right Arm' }}
|
|
189
|
+
variant="rail"
|
|
190
|
+
/>
|
|
191
|
+
)
|
|
192
|
+
// Rail = the lean dual-expanded: no gutter/vertical-axis + no slot labels (the two wings read as
|
|
193
|
+
// separate rows via the vertical gap), and no centre axis — only the bolder hero keeps those.
|
|
194
|
+
expect(screen.queryByTestId('dual-velocity-side-label-L')).not.toBeInTheDocument()
|
|
195
|
+
expect(screen.queryByTestId('dual-velocity-side-label-R')).not.toBeInTheDocument()
|
|
196
|
+
expect(screen.queryByTestId('dual-velocity-axis')).not.toBeInTheDocument()
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
describe('DualVelocityStrip diverging orientation', () => {
|
|
201
|
+
// The up wing grows upright; the down wing is the SAME hero vertically mirrored (a scaleY(-1)
|
|
202
|
+
// on the whole plot, NOT swapped radii — so both bars keep their DOM top-radius). The mirror is
|
|
203
|
+
// observable via the down wing counter-flipping its value text to stay upright.
|
|
204
|
+
it('grows the up wing upright and the down wing mirrored', () => {
|
|
205
|
+
render(<DualVelocityStrip left={{ velocities: [0.9] }} right={{ velocities: [0.8] }} />)
|
|
206
|
+
expect(wingUp().getByTestId('velocity-bar-0')).toHaveStyle({ borderTopLeftRadius: '5px' })
|
|
207
|
+
// Up text upright (no transform); down text counter-flipped (has a transform) — proof the
|
|
208
|
+
// down wing is the mirrored orientation.
|
|
209
|
+
expect(wingUp().getByTestId('velocity-label-0').style.transform).toBeFalsy()
|
|
210
|
+
expect(wingDown().getByTestId('velocity-label-0').style.transform).toBeTruthy()
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
describe('DualVelocityStrip reference lines', () => {
|
|
215
|
+
it('draws a per-side running-best reference line at hero scale', () => {
|
|
216
|
+
render(
|
|
217
|
+
<DualVelocityStrip
|
|
218
|
+
left={{ velocities: [0.9, 0.85] }}
|
|
219
|
+
right={{ velocities: [0.82, 0.74] }}
|
|
220
|
+
variant="hero"
|
|
221
|
+
/>
|
|
222
|
+
)
|
|
223
|
+
expect(wingUp().getByTestId('velocity-hero-reference')).toBeInTheDocument()
|
|
224
|
+
expect(wingDown().getByTestId('velocity-hero-reference')).toBeInTheDocument()
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('omits reference lines at rail scale', () => {
|
|
228
|
+
render(
|
|
229
|
+
<DualVelocityStrip
|
|
230
|
+
left={{ velocities: [0.9, 0.85] }}
|
|
231
|
+
right={{ velocities: [0.82, 0.74] }}
|
|
232
|
+
variant="rail"
|
|
233
|
+
/>
|
|
234
|
+
)
|
|
235
|
+
expect(screen.queryByTestId('velocity-hero-reference')).not.toBeInTheDocument()
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('omits a side’s reference line when that side has no positive velocity', () => {
|
|
239
|
+
render(<DualVelocityStrip left={{ velocities: [0.9] }} right={{ velocities: [] }} />)
|
|
240
|
+
expect(wingUp().getByTestId('velocity-hero-reference')).toBeInTheDocument()
|
|
241
|
+
expect(wingDown().queryByTestId('velocity-hero-reference')).not.toBeInTheDocument()
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
describe('DualVelocityStrip planned stubs', () => {
|
|
246
|
+
it('index-locks the columns: the lagging side fills its un-logged rep column as empty, not shifted', () => {
|
|
247
|
+
render(
|
|
248
|
+
<DualVelocityStrip
|
|
249
|
+
left={{ velocities: [0.9, 0.85] }}
|
|
250
|
+
right={{ velocities: [0.8] }}
|
|
251
|
+
targetReps={4}
|
|
252
|
+
/>
|
|
253
|
+
)
|
|
254
|
+
// 4 shared columns. left logs reps 0–1 → cols 2,3 planned todo. right logs rep 0 → col 1 is its
|
|
255
|
+
// un-logged rep column (EMPTY, index-locked under left's rep 1), cols 2,3 planned todo.
|
|
256
|
+
expect(wingUp().queryAllByTestId('velocity-slot-todo')).toHaveLength(2)
|
|
257
|
+
expect(wingUp().queryAllByTestId('velocity-slot-empty')).toHaveLength(0)
|
|
258
|
+
expect(wingDown().queryAllByTestId('velocity-slot-todo')).toHaveLength(2)
|
|
259
|
+
expect(wingDown().queryAllByTestId('velocity-slot-empty')).toHaveLength(1)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('renders the planned stub as the solid to-do section (inherited hero styling, not dashed)', () => {
|
|
263
|
+
render(
|
|
264
|
+
<DualVelocityStrip
|
|
265
|
+
left={{ velocities: [0.9] }}
|
|
266
|
+
right={{ velocities: [0.8] }}
|
|
267
|
+
targetReps={2}
|
|
268
|
+
/>
|
|
269
|
+
)
|
|
270
|
+
const stub = wingUp().getAllByTestId('velocity-slot-todo')[0]
|
|
271
|
+
// The hero (and the dual composing it) now use a solid surface-relative section, not a
|
|
272
|
+
// dashed outline. ROM is the only consumer that keeps the dashed stub.
|
|
273
|
+
expect(stub).not.toHaveStyle({ borderTopStyle: 'dashed' })
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('draws no stubs when the target is met', () => {
|
|
277
|
+
render(
|
|
278
|
+
<DualVelocityStrip
|
|
279
|
+
left={{ velocities: [0.9, 0.85] }}
|
|
280
|
+
right={{ velocities: [0.8, 0.7] }}
|
|
281
|
+
targetReps={2}
|
|
282
|
+
/>
|
|
283
|
+
)
|
|
284
|
+
expect(screen.queryByTestId('velocity-slot-todo')).not.toBeInTheDocument()
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('renders both wings entirely as stubs for a planned-but-unstarted set', () => {
|
|
288
|
+
render(
|
|
289
|
+
<DualVelocityStrip left={{ velocities: [] }} right={{ velocities: [] }} targetReps={3} />
|
|
290
|
+
)
|
|
291
|
+
expect(wingUp().queryAllByTestId('velocity-slot-todo')).toHaveLength(3)
|
|
292
|
+
expect(wingUp().queryAllByTestId(HERO_BARS)).toHaveLength(0)
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('symmetric index-lock: left logs 5, right logs 3 → 5 columns, right cols 3–4 aligned empty', () => {
|
|
296
|
+
render(
|
|
297
|
+
<DualVelocityStrip
|
|
298
|
+
left={{ velocities: [0.9, 0.88, 0.86, 0.84, 0.82] }}
|
|
299
|
+
right={{ velocities: [0.85, 0.8, 0.75] }}
|
|
300
|
+
/>
|
|
301
|
+
)
|
|
302
|
+
// Both wings render 5 columns. Left: 5 rep bars. Right: 3 rep bars + cols 3,4 EMPTY (under
|
|
303
|
+
// left's reps 3,4), no shift, no todo (no planned remainder).
|
|
304
|
+
expect(wingUp().queryAllByTestId(HERO_BARS)).toHaveLength(5)
|
|
305
|
+
expect(wingUp().queryAllByTestId('velocity-slot-empty')).toHaveLength(0)
|
|
306
|
+
expect(wingDown().queryAllByTestId(HERO_BARS)).toHaveLength(3)
|
|
307
|
+
expect(wingDown().queryAllByTestId('velocity-slot-empty')).toHaveLength(2)
|
|
308
|
+
})
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
describe('DualVelocityStrip colour mapping', () => {
|
|
312
|
+
it('zone mode colours reps from the absolute effort scale (side is position, not hue)', () => {
|
|
313
|
+
render(
|
|
314
|
+
<DualVelocityStrip
|
|
315
|
+
left={{ velocities: [1.1] }}
|
|
316
|
+
right={{ velocities: [0.4] }}
|
|
317
|
+
barColor="zone"
|
|
318
|
+
/>
|
|
319
|
+
)
|
|
320
|
+
expect(wingUp().getByTestId('velocity-bar-0')).toHaveStyle({ backgroundColor: GREEN })
|
|
321
|
+
expect(wingDown().getByTestId('velocity-bar-0')).toHaveStyle({ backgroundColor: RED })
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('zone mode colours both sides from supplied bands, with distinct 5-band reds', () => {
|
|
325
|
+
render(
|
|
326
|
+
<DualVelocityStrip
|
|
327
|
+
left={{ velocities: [1.1, 0.45] }}
|
|
328
|
+
right={{ velocities: [0.2] }}
|
|
329
|
+
zones={compoundBands}
|
|
330
|
+
barColor="zone"
|
|
331
|
+
/>
|
|
332
|
+
)
|
|
333
|
+
expect(wingUp().getByTestId('velocity-bar-0')).toHaveStyle({ backgroundColor: GREEN })
|
|
334
|
+
expect(wingUp().getByTestId('velocity-bar-1')).toHaveStyle({ backgroundColor: RED })
|
|
335
|
+
expect(wingDown().getByTestId('velocity-bar-0')).toHaveStyle({ backgroundColor: DISTINCT_RED })
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('loss mode (default) colours each wing off its OWN best — per-wing fatigue, shared height', () => {
|
|
339
|
+
// Left ends on its own best (loss 0 → green); right drops hard from its own best (→ red).
|
|
340
|
+
// The shared height scale must NOT bleed the stronger arm's best into the weaker arm's colour.
|
|
341
|
+
render(
|
|
342
|
+
<DualVelocityStrip left={{ velocities: [0.9, 0.9] }} right={{ velocities: [0.9, 0.5] }} />
|
|
343
|
+
)
|
|
344
|
+
expect(wingUp().getByTestId('velocity-bar-1')).toHaveStyle({ backgroundColor: GREEN })
|
|
345
|
+
expect(wingDown().getByTestId('velocity-bar-1')).toHaveStyle({ backgroundColor: RED })
|
|
346
|
+
})
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
describe('DualVelocityStrip set-type streams (windows render on the composed hero)', () => {
|
|
350
|
+
// Each side passes its `set` descriptor straight into the composed hero, which builds its own
|
|
351
|
+
// typed slots — so the set-type WINDOWS (the range cyan variable window, the AMRAP/myo "continue")
|
|
352
|
+
// render on the dual, not just the flattened reps. This is the set-type vocabulary regained.
|
|
353
|
+
it('range set: renders the committed reps as bars AND the cyan variable window', () => {
|
|
354
|
+
render(
|
|
355
|
+
<DualVelocityStrip
|
|
356
|
+
left={{ set: { type: 'range', velocities: [0.9, 0.85], floor: 3, max: 5 } }}
|
|
357
|
+
right={{ velocities: [0.8] }}
|
|
358
|
+
/>
|
|
359
|
+
)
|
|
360
|
+
// floor 3 / max 5, 2 done → 2 rep bars, 1 todo (i<floor), 2 variable (floor..max).
|
|
361
|
+
expect(wingUp().queryAllByTestId(HERO_BARS)).toHaveLength(2)
|
|
362
|
+
expect(wingUp().queryAllByTestId('velocity-slot-variable')).toHaveLength(2)
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
it('amrap set: renders performed reps as bars AND the trailing continue window', () => {
|
|
366
|
+
render(
|
|
367
|
+
<DualVelocityStrip
|
|
368
|
+
left={{ set: { type: 'amrap', velocities: [0.9, 0.85] } }}
|
|
369
|
+
right={{ velocities: [0.8] }}
|
|
370
|
+
/>
|
|
371
|
+
)
|
|
372
|
+
expect(wingUp().queryAllByTestId(HERO_BARS)).toHaveLength(2)
|
|
373
|
+
expect(wingUp().getByTestId('velocity-slot-continue')).toBeInTheDocument()
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
it('myo set: activation + clusters render one bar per rep plus the open continue window', () => {
|
|
377
|
+
render(
|
|
378
|
+
<DualVelocityStrip
|
|
379
|
+
left={{
|
|
380
|
+
set: {
|
|
381
|
+
type: 'myo',
|
|
382
|
+
activation: [0.95, 0.9, 0.85],
|
|
383
|
+
clusters: [
|
|
384
|
+
[0.78, 0.72],
|
|
385
|
+
[0.68, 0.61],
|
|
386
|
+
],
|
|
387
|
+
open: true,
|
|
388
|
+
},
|
|
389
|
+
}}
|
|
390
|
+
right={{ velocities: [0.8] }}
|
|
391
|
+
/>
|
|
392
|
+
)
|
|
393
|
+
// 3 activation + 2 + 2 cluster reps = 7 filled bars, plus the open "continue" window.
|
|
394
|
+
expect(wingUp().queryAllByTestId(HERO_BARS)).toHaveLength(7)
|
|
395
|
+
expect(wingUp().getByTestId('velocity-slot-continue')).toBeInTheDocument()
|
|
396
|
+
})
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
describe('DualVelocityStrip live mode', () => {
|
|
400
|
+
const originalMatchMedia = window.matchMedia
|
|
401
|
+
beforeEach(() => {
|
|
402
|
+
liveEntranceCalls.length = 0
|
|
403
|
+
})
|
|
404
|
+
afterEach(() => {
|
|
405
|
+
if (originalMatchMedia) window.matchMedia = originalMatchMedia
|
|
406
|
+
else delete (window as { matchMedia?: unknown }).matchMedia
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
// The newest rep must animate in on BOTH wings. Before the dual composed real VelocityStrips it
|
|
410
|
+
// hand-rolled its own wing bars and drove them from a dual-only entrance hook, so the animation
|
|
411
|
+
// was a second implementation that could — and did — drift from the single strip's. Now both
|
|
412
|
+
// wings ARE single strips, and `liveRepIndex` reaches them through the ordinary composed prop:
|
|
413
|
+
// an entrance that fires on one wing only is no longer expressible.
|
|
414
|
+
it('arms the newest-rep entrance on BOTH wings, each with its OWN live velocity', () => {
|
|
415
|
+
render(
|
|
416
|
+
<DualVelocityStrip
|
|
417
|
+
left={{ velocities: [0.9, 0.85, 0.8] }}
|
|
418
|
+
right={{ velocities: [0.82, 0.78, 0.7] }}
|
|
419
|
+
variant="hero"
|
|
420
|
+
liveRepIndex={2}
|
|
421
|
+
/>
|
|
422
|
+
)
|
|
423
|
+
expect(wingEntrances()).toEqual([
|
|
424
|
+
{ index: 2, velocity: 0.8, isNewPeak: false },
|
|
425
|
+
{ index: 2, velocity: 0.7, isNewPeak: false },
|
|
426
|
+
])
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
it('flags a new set peak PER WING — a peak on one side never speaks for the other', () => {
|
|
430
|
+
// Up wing climbs to its best on rep 2; down wing declines from its best. The peak bounce is
|
|
431
|
+
// the loud half of the entrance, so a shared flag would fire it on a wing that got slower.
|
|
432
|
+
render(
|
|
433
|
+
<DualVelocityStrip
|
|
434
|
+
left={{ velocities: [0.7, 0.8, 0.9] }}
|
|
435
|
+
right={{ velocities: [0.9, 0.8, 0.7] }}
|
|
436
|
+
variant="hero"
|
|
437
|
+
liveRepIndex={2}
|
|
438
|
+
/>
|
|
439
|
+
)
|
|
440
|
+
expect(wingEntrances()).toEqual([
|
|
441
|
+
{ index: 2, velocity: 0.9, isNewPeak: true },
|
|
442
|
+
{ index: 2, velocity: 0.7, isNewPeak: false },
|
|
443
|
+
])
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
it('keeps a LAGGING wing inert while the leading wing still animates', () => {
|
|
447
|
+
// The lagging side holds an index-locked empty at that column, so it has no rep to animate —
|
|
448
|
+
// but that must not disarm its partner's entrance.
|
|
449
|
+
render(
|
|
450
|
+
<DualVelocityStrip
|
|
451
|
+
left={{ velocities: [0.9, 0.85, 0.8] }}
|
|
452
|
+
right={{ velocities: [0.82, 0.78] }}
|
|
453
|
+
variant="hero"
|
|
454
|
+
liveRepIndex={2}
|
|
455
|
+
/>
|
|
456
|
+
)
|
|
457
|
+
expect(wingEntrances()).toEqual([
|
|
458
|
+
{ index: 2, velocity: 0.8, isNewPeak: false },
|
|
459
|
+
{ index: 2, velocity: undefined, isNewPeak: false },
|
|
460
|
+
])
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
it('reaches both wings through a set descriptor, past its set-type windows', () => {
|
|
464
|
+
// A `range` set puts cyan variable-window stubs after the performed reps. Rep indices are
|
|
465
|
+
// counted over rep cells only, so the window must not shift which bar the entrance lands on.
|
|
466
|
+
render(
|
|
467
|
+
<DualVelocityStrip
|
|
468
|
+
left={{ set: { type: 'range', velocities: [0.9, 0.85, 0.8], floor: 4, max: 6 } }}
|
|
469
|
+
right={{ set: { type: 'range', velocities: [0.8, 0.75, 0.7], floor: 4, max: 6 } }}
|
|
470
|
+
variant="hero"
|
|
471
|
+
liveRepIndex={2}
|
|
472
|
+
/>
|
|
473
|
+
)
|
|
474
|
+
expect(wingEntrances()).toEqual([
|
|
475
|
+
{ index: 2, velocity: 0.8, isNewPeak: false },
|
|
476
|
+
{ index: 2, velocity: 0.7, isNewPeak: false },
|
|
477
|
+
])
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
it('arms both wings at rail scale too — its wings are composed strips as well', () => {
|
|
481
|
+
render(
|
|
482
|
+
<DualVelocityStrip
|
|
483
|
+
left={{ velocities: [0.9, 0.85, 0.8] }}
|
|
484
|
+
right={{ velocities: [0.82, 0.78, 0.7] }}
|
|
485
|
+
variant="rail"
|
|
486
|
+
liveRepIndex={2}
|
|
487
|
+
/>
|
|
488
|
+
)
|
|
489
|
+
expect(wingEntrances()).toEqual([
|
|
490
|
+
{ index: 2, velocity: 0.8, isNewPeak: false },
|
|
491
|
+
{ index: 2, velocity: 0.7, isNewPeak: false },
|
|
492
|
+
])
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
it('leaves the folded compact dual inert — flat bars have no height to animate', () => {
|
|
496
|
+
render(
|
|
497
|
+
<DualVelocityStrip
|
|
498
|
+
left={{ velocities: [0.9, 0.8] }}
|
|
499
|
+
right={{ velocities: [0.8, 0.7] }}
|
|
500
|
+
variant="compact"
|
|
501
|
+
liveRepIndex={1}
|
|
502
|
+
/>
|
|
503
|
+
)
|
|
504
|
+
expect(wingEntrances()).toEqual([
|
|
505
|
+
{ index: undefined, velocity: undefined, isNewPeak: false },
|
|
506
|
+
{ index: undefined, velocity: undefined, isNewPeak: false },
|
|
507
|
+
])
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
it('renders the live mirrored pair at the given index (hero)', () => {
|
|
511
|
+
render(
|
|
512
|
+
<DualVelocityStrip
|
|
513
|
+
left={{ velocities: [0.9, 0.85, 0.8] }}
|
|
514
|
+
right={{ velocities: [0.82, 0.78, 0.7] }}
|
|
515
|
+
variant="hero"
|
|
516
|
+
liveRepIndex={2}
|
|
517
|
+
/>
|
|
518
|
+
)
|
|
519
|
+
expect(wingUp().getByTestId('velocity-bar-2')).toBeInTheDocument()
|
|
520
|
+
expect(wingDown().getByTestId('velocity-bar-2')).toBeInTheDocument()
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
it('renders stably with prefers-reduced-motion set', () => {
|
|
524
|
+
window.matchMedia = vi.fn().mockReturnValue({
|
|
525
|
+
matches: true,
|
|
526
|
+
addEventListener: vi.fn(),
|
|
527
|
+
removeEventListener: vi.fn(),
|
|
528
|
+
}) as unknown as typeof window.matchMedia
|
|
529
|
+
render(
|
|
530
|
+
<DualVelocityStrip
|
|
531
|
+
left={{ velocities: [1.1, 0.9] }}
|
|
532
|
+
right={{ velocities: [0.8, 0.7] }}
|
|
533
|
+
liveRepIndex={0}
|
|
534
|
+
/>
|
|
535
|
+
)
|
|
536
|
+
expect(wingUp().getByTestId('velocity-bar-0')).toBeInTheDocument()
|
|
537
|
+
})
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
describe('DualVelocityStrip compact variant (folded)', () => {
|
|
541
|
+
it('keeps the FOLDED footprint — the pair costs one strip height, not two', () => {
|
|
542
|
+
render(
|
|
543
|
+
<DualVelocityStrip
|
|
544
|
+
left={{ velocities: [0.9, 0.85, 0.8], label: 'Left Arm' }}
|
|
545
|
+
right={{ velocities: [0.82, 0.74], label: 'Right Arm' }}
|
|
546
|
+
variant="compact"
|
|
547
|
+
/>
|
|
548
|
+
)
|
|
549
|
+
// The fold is a FOOTPRINT guarantee, not a DOM one: compact is composed from two wings like
|
|
550
|
+
// the hero and rail, but they share DUAL_COMPACT_HEIGHT (5 + 1.5 gap + 5) rather than stacking
|
|
551
|
+
// to 2x a full strip. Asserting the height keeps the design decision locked while leaving the
|
|
552
|
+
// implementation free to compose.
|
|
553
|
+
expect(screen.getByTestId('dual-velocity-strip')).toHaveStyle({ height: '11.5px' })
|
|
554
|
+
const up = within(screen.getByTestId('dual-velocity-wing-up'))
|
|
555
|
+
const down = within(screen.getByTestId('dual-velocity-wing-down'))
|
|
556
|
+
expect(up.queryAllByTestId(/^velocity-bar-\d+$/)).toHaveLength(3)
|
|
557
|
+
expect(down.queryAllByTestId(/^velocity-bar-\d+$/)).toHaveLength(2)
|
|
558
|
+
// Folded height leaves no room for chrome: no gutter/slot labels, no centre axis, no values.
|
|
559
|
+
expect(screen.queryByTestId('dual-velocity-side-label-L')).not.toBeInTheDocument()
|
|
560
|
+
expect(screen.queryByTestId('dual-velocity-axis')).not.toBeInTheDocument()
|
|
561
|
+
expect(screen.queryByTestId(/^velocity-label-\d+$/)).not.toBeInTheDocument()
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
it('index-locks — a lagging side keeps its columns as empties', () => {
|
|
565
|
+
render(
|
|
566
|
+
<DualVelocityStrip
|
|
567
|
+
left={{ velocities: [0.9, 0.88, 0.86, 0.84] }}
|
|
568
|
+
right={{ velocities: [0.85, 0.8] }}
|
|
569
|
+
variant="compact"
|
|
570
|
+
/>
|
|
571
|
+
)
|
|
572
|
+
const down = within(screen.getByTestId('dual-velocity-wing-down'))
|
|
573
|
+
expect(down.queryAllByTestId(/^velocity-bar-\d+$/)).toHaveLength(2)
|
|
574
|
+
// Four columns overall, so the lagging side holds two of them as empties rather than shrinking.
|
|
575
|
+
expect(down.queryAllByTestId('velocity-slot-empty')).toHaveLength(2)
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
it('carries the set-type vocabulary that the hand-rolled fold used to drop', () => {
|
|
579
|
+
render(
|
|
580
|
+
<DualVelocityStrip
|
|
581
|
+
left={{ set: { type: 'straight', velocities: [0.9, 0.86], planned: 4 } }}
|
|
582
|
+
right={{ set: { type: 'straight', velocities: [0.88, 0.84], planned: 4 } }}
|
|
583
|
+
variant="compact"
|
|
584
|
+
/>
|
|
585
|
+
)
|
|
586
|
+
const up = within(screen.getByTestId('dual-velocity-wing-up'))
|
|
587
|
+
const down = within(screen.getByTestId('dual-velocity-wing-down'))
|
|
588
|
+
expect(up.queryAllByTestId('velocity-slot-todo')).toHaveLength(2)
|
|
589
|
+
expect(down.queryAllByTestId('velocity-slot-todo')).toHaveLength(2)
|
|
590
|
+
})
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
describe('DualVelocityStrip rail variant', () => {
|
|
594
|
+
it('renders compact bars but no value labels or reference lines', () => {
|
|
595
|
+
render(
|
|
596
|
+
<DualVelocityStrip
|
|
597
|
+
left={{ velocities: [0.9, 0.85] }}
|
|
598
|
+
right={{ velocities: [0.82, 0.74] }}
|
|
599
|
+
variant="rail"
|
|
600
|
+
/>
|
|
601
|
+
)
|
|
602
|
+
// The rail is now COMPOSED from two bare `expanded` strips, so each wing emits the shared
|
|
603
|
+
// SetBarChart testIDs rather than the old bespoke `dual-velocity-bar-*` ones. Query per wing.
|
|
604
|
+
const up = within(screen.getByTestId('dual-velocity-wing-up'))
|
|
605
|
+
const down = within(screen.getByTestId('dual-velocity-wing-down'))
|
|
606
|
+
expect(up.queryAllByTestId(/^velocity-bar-\d+$/)).toHaveLength(2)
|
|
607
|
+
expect(down.queryAllByTestId(/^velocity-bar-\d+$/)).toHaveLength(2)
|
|
608
|
+
// Still lean: no per-bar value labels, no running-best reference line.
|
|
609
|
+
expect(screen.queryByTestId(/^velocity-label-\d+$/)).not.toBeInTheDocument()
|
|
610
|
+
expect(screen.queryByTestId('velocity-hero-reference')).not.toBeInTheDocument()
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
it('draws a to-do slot on each wing for the unperformed remainder', () => {
|
|
614
|
+
render(
|
|
615
|
+
<DualVelocityStrip
|
|
616
|
+
left={{ velocities: [0.9] }}
|
|
617
|
+
right={{ velocities: [0.8] }}
|
|
618
|
+
variant="rail"
|
|
619
|
+
targetReps={2}
|
|
620
|
+
/>
|
|
621
|
+
)
|
|
622
|
+
const up = within(screen.getByTestId('dual-velocity-wing-up'))
|
|
623
|
+
const down = within(screen.getByTestId('dual-velocity-wing-down'))
|
|
624
|
+
expect(up.queryAllByTestId('velocity-slot-todo')).toHaveLength(1)
|
|
625
|
+
expect(down.queryAllByTestId('velocity-slot-todo')).toHaveLength(1)
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
it('index-locks a lagging side, giving it an aligned empty rather than fewer bars', () => {
|
|
629
|
+
// The defect this replaces: each side built its own columns, so a lagging side simply
|
|
630
|
+
// rendered FEWER bars and its remaining reps slid left out of alignment.
|
|
631
|
+
render(
|
|
632
|
+
<DualVelocityStrip
|
|
633
|
+
left={{ velocities: [0.9, 0.86, 0.82] }}
|
|
634
|
+
right={{ velocities: [0.85, 0.8] }}
|
|
635
|
+
variant="rail"
|
|
636
|
+
/>
|
|
637
|
+
)
|
|
638
|
+
const up = within(screen.getByTestId('dual-velocity-wing-up'))
|
|
639
|
+
const down = within(screen.getByTestId('dual-velocity-wing-down'))
|
|
640
|
+
expect(up.queryAllByTestId(/^velocity-bar-\d+$/)).toHaveLength(3)
|
|
641
|
+
// The lagging side keeps three COLUMNS — two bars plus an aligned empty.
|
|
642
|
+
expect(down.queryAllByTestId(/^velocity-bar-\d+$/)).toHaveLength(2)
|
|
643
|
+
expect(down.queryAllByTestId('velocity-slot-empty')).toHaveLength(1)
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
it('carries set-type windows onto both wings', () => {
|
|
647
|
+
// The bespoke rail flattened to velocities, so a range's variable window never rendered.
|
|
648
|
+
render(
|
|
649
|
+
<DualVelocityStrip
|
|
650
|
+
left={{ set: { type: 'range', velocities: [0.9, 0.86], floor: 3, max: 4 } }}
|
|
651
|
+
right={{ set: { type: 'range', velocities: [0.85, 0.8], floor: 3, max: 4 } }}
|
|
652
|
+
variant="rail"
|
|
653
|
+
/>
|
|
654
|
+
)
|
|
655
|
+
const up = within(screen.getByTestId('dual-velocity-wing-up'))
|
|
656
|
+
const down = within(screen.getByTestId('dual-velocity-wing-down'))
|
|
657
|
+
expect(up.queryAllByTestId('velocity-slot-variable').length).toBeGreaterThan(0)
|
|
658
|
+
expect(down.queryAllByTestId('velocity-slot-variable').length).toBeGreaterThan(0)
|
|
659
|
+
})
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
describe('DualVelocityStrip accessibility', () => {
|
|
663
|
+
it('has no violations at hero scale with a live rep and planned stubs', async () => {
|
|
664
|
+
const { container } = render(
|
|
665
|
+
<DualVelocityStrip
|
|
666
|
+
left={{ velocities: [0.9, 0.85, 0.8] }}
|
|
667
|
+
right={{ velocities: [0.82, 0.74] }}
|
|
668
|
+
targetReps={8}
|
|
669
|
+
liveRepIndex={2}
|
|
670
|
+
/>
|
|
671
|
+
)
|
|
672
|
+
expect(await axe(container)).toHaveNoViolations()
|
|
673
|
+
})
|
|
674
|
+
|
|
675
|
+
it('has no violations at rail scale', async () => {
|
|
676
|
+
const { container } = render(
|
|
677
|
+
<DualVelocityStrip
|
|
678
|
+
left={{ velocities: [0.9] }}
|
|
679
|
+
right={{ velocities: [0.8] }}
|
|
680
|
+
variant="rail"
|
|
681
|
+
/>
|
|
682
|
+
)
|
|
683
|
+
expect(await axe(container)).toHaveNoViolations()
|
|
684
|
+
})
|
|
685
|
+
})
|