Skip to content

Commit 5cc7c5f

Browse files
committed
VPR-104 feat(cts): redesign MyAssessments with Bubble + Timeline modes
- Two display modes via q-btn-toggle: Timeline (default when a student views their own page) and Bubble (default when a non-student opens ?student=X). Adds role="group" + aria-label on the toggle. - New Timeline view (EpaVoiceThread + EpaProgressionChart) renders each EPA as voice-card comments with a gutter spline connecting them and a chart-expand dialog. Bubble mode's expanded comments use the same voice-card style so the two modes feel consistent. - AssessmentBubble: typed defineProps/defineEmits; aria-label surfaces the descriptive level name (never the numeric score); the decorative span falls back to aria-hidden when levelName is empty so role="img" never lands with an empty label. - EpaVoiceThread: ResizeObserver keeps the gutter spline aligned after window resize or font load; useId() prevents collisions on aria-labelledby when multiple instances render with null epaIds. - MyAssessments: bucket assessments by epaId in a computed Map so the template's repeated lookups don't refilter the full list per render. - Drop the unused legacy/abbreviation/blend display variants along with their supporting components, props, and CSS. - Lock the AssessmentBubble contract with 16 unit tests covering the aria-label privacy paths, value→class mapping, and click emission.
1 parent 8227e33 commit 5cc7c5f

6 files changed

Lines changed: 1266 additions & 140 deletions

File tree

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { mount } from "@vue/test-utils"
2+
import { Quasar } from "quasar"
3+
4+
import AssessmentBubble from "../components/AssessmentBubble.vue"
5+
6+
/**
7+
* Tests for AssessmentBubble — the rating dot rendered on CTS assessment lists.
8+
*
9+
* Focus areas:
10+
* 1. Privacy: aria-label must surface the descriptive rating label, never the
11+
* numeric value. Students should not hear "Rating 1 of 5" from a screen
12+
* reader when they are low-rated.
13+
* 2. Class mapping: value/maxValue drive the bubbleClass contract consumed
14+
* by cts.css.
15+
* 3. Click contract: clickable variant (id prop set) emits bubble-click with
16+
* the id; non-clickable variant renders as a non-interactive span.
17+
*/
18+
19+
function createWrapper(props: Record<string, unknown>) {
20+
return mount(AssessmentBubble, {
21+
props: props as never,
22+
global: {
23+
plugins: [[Quasar, {}]],
24+
},
25+
})
26+
}
27+
28+
describe(AssessmentBubble, () => {
29+
describe("aria-label privacy", () => {
30+
it("uses levelName on the clickable button and does not expose the numeric value", () => {
31+
const wrapper = createWrapper({
32+
maxValue: 5,
33+
value: 1,
34+
levelName: "Trust with indirect supervision",
35+
id: 42,
36+
})
37+
38+
const label = wrapper.get("button").attributes("aria-label")!
39+
expect(label).toContain("Trust with indirect supervision")
40+
expect(label).not.toMatch(/\b1 of 5\b/i)
41+
expect(label).not.toMatch(/rating\s+\d/i)
42+
})
43+
44+
it("uses levelName on the standalone span and does not expose the numeric value", () => {
45+
const wrapper = createWrapper({
46+
maxValue: 5,
47+
value: 2,
48+
levelName: "Trust with direct supervision",
49+
})
50+
51+
const label = wrapper.get('span[role="img"]').attributes("aria-label")!
52+
expect(label).toBe("Trust with direct supervision")
53+
expect(label).not.toMatch(/\b2 of 5\b/i)
54+
})
55+
56+
it("renders the standalone span as aria-hidden when levelName is empty", () => {
57+
const wrapper = createWrapper({
58+
maxValue: 5,
59+
value: 2,
60+
})
61+
62+
expect(wrapper.find('span[role="img"]').exists()).toBeFalsy()
63+
const decorative = wrapper.get('span[aria-hidden="true"]')
64+
expect(decorative.attributes("aria-label")).toBeUndefined()
65+
})
66+
67+
it("appends open-details hint on the clickable variant", () => {
68+
const wrapper = createWrapper({
69+
maxValue: 5,
70+
value: 3,
71+
levelName: "Independent remote supervision",
72+
id: 7,
73+
})
74+
75+
expect(wrapper.get("button").attributes("aria-label")).toBe(
76+
"Independent remote supervision, open assessment details",
77+
)
78+
})
79+
80+
it("falls back to a generic hint when levelName is missing on a clickable bubble", () => {
81+
const wrapper = createWrapper({
82+
maxValue: 5,
83+
value: 3,
84+
id: 7,
85+
})
86+
87+
expect(wrapper.get("button").attributes("aria-label")).toBe("Open assessment details")
88+
})
89+
})
90+
91+
describe("bubbleClass contract", () => {
92+
it.each([
93+
[1, "assessmentBubble5_1"],
94+
[2, "assessmentBubble5_2"],
95+
[3, "assessmentBubble5_3"],
96+
[4, "assessmentBubble5_4"],
97+
[5, "assessmentBubble5_5"],
98+
])("maps value=%i to %s", (value, expected) => {
99+
const wrapper = createWrapper({
100+
maxValue: 5,
101+
value,
102+
levelName: "Label",
103+
})
104+
105+
expect(wrapper.get('span[role="img"]').classes()).toContain(expected)
106+
})
107+
108+
it.each([0, 6])("yields no level class for out-of-range value=%i", (value) => {
109+
const wrapper = createWrapper({
110+
maxValue: 5,
111+
value,
112+
levelName: "Label",
113+
})
114+
115+
const classes = wrapper.get('span[role="img"]').classes()
116+
expect(classes.some((c) => c.startsWith("assessmentBubble5_"))).toBeFalsy()
117+
})
118+
119+
it("yields no level class when maxValue is not 5", () => {
120+
const wrapper = createWrapper({
121+
maxValue: 3,
122+
value: 2,
123+
levelName: "Label",
124+
})
125+
126+
const classes = wrapper.get('span[role="img"]').classes()
127+
expect(classes.some((c) => c.startsWith("assessmentBubble5_"))).toBeFalsy()
128+
})
129+
})
130+
131+
describe("click behaviour", () => {
132+
it("renders a button and emits bubble-click with the id when clicked", async () => {
133+
const wrapper = createWrapper({
134+
maxValue: 5,
135+
value: 3,
136+
levelName: "Label",
137+
id: 99,
138+
})
139+
140+
await wrapper.get("button").trigger("click")
141+
142+
expect(wrapper.emitted("bubble-click")).toEqual([[99]])
143+
})
144+
145+
it("renders a non-interactive span and does not emit when id is omitted", () => {
146+
const wrapper = createWrapper({
147+
maxValue: 5,
148+
value: 3,
149+
levelName: "Label",
150+
})
151+
152+
expect(wrapper.find("button").exists()).toBeFalsy()
153+
expect(wrapper.find('span[role="img"]').exists()).toBeTruthy()
154+
expect(wrapper.emitted("bubble-click")).toBeUndefined()
155+
})
156+
})
157+
158+
describe("bubble content", () => {
159+
it("does not render the numeric value inside the bubble", () => {
160+
const wrapper = createWrapper({
161+
maxValue: 5,
162+
value: 4,
163+
levelName: "Label",
164+
id: 1,
165+
})
166+
167+
const bubble = wrapper.get("span.assessmentBubble")
168+
expect(bubble.text()).toBe("")
169+
})
170+
})
171+
})

0 commit comments

Comments
 (0)