From 99f13f8f1f7d3ffcde4b72d03b5311ec33f92d49 Mon Sep 17 00:00:00 2001 From: Kris Heinrich Date: Tue, 22 Sep 2026 13:54:57 -0700 Subject: [PATCH 1/5] fix(MatchScore): clamp negative score to avoid invalid array length crash --- src/components/MatchScore/MatchScore.test.tsx | 6 ++++++ src/components/MatchScore/MatchScore.tsx | 9 ++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/components/MatchScore/MatchScore.test.tsx b/src/components/MatchScore/MatchScore.test.tsx index 5abc75066..d0a76f6af 100644 --- a/src/components/MatchScore/MatchScore.test.tsx +++ b/src/components/MatchScore/MatchScore.test.tsx @@ -43,4 +43,10 @@ describe('MatchScore', () => { ); expect(container.querySelector('.label').textContent).toBe('Some label '); }); + + test('MatchScore clamps a negative score instead of throwing', () => { + const { container } = render(); + expect(container.querySelector('.match-score-container')).toBeTruthy(); + expect(container.querySelector('.label').textContent).toContain('0/5'); + }); }); diff --git a/src/components/MatchScore/MatchScore.tsx b/src/components/MatchScore/MatchScore.tsx index e155c3720..93fa38e3a 100644 --- a/src/components/MatchScore/MatchScore.tsx +++ b/src/components/MatchScore/MatchScore.tsx @@ -40,10 +40,13 @@ export const MatchScore: FC = React.forwardRef( : contextualTheme || theme; const absTotal: number = Math.abs(total); - const absScore: number = Math.round(score); - const fullCircles: number = Math.trunc(Math.round(score * 2.0) / 2.0); + const clampedScore: number = Math.max(0, score); + const absScore: number = Math.round(clampedScore); + const fullCircles: number = Math.trunc( + Math.round(clampedScore * 2.0) / 2.0 + ); const halfCircle: number = Math.trunc( - Math.ceil(score - fullCircles - 0.25) + Math.ceil(clampedScore - fullCircles - 0.25) ); const emptyCircles: number = total - fullCircles - halfCircle; const matchScoreLabelClasses: string = mergeClasses(styles.label); From fb845b23b2a0a979d7c89f04dd93a33effd7efc9 Mon Sep 17 00:00:00 2001 From: Kris Heinrich Date: Tue, 22 Sep 2026 13:56:23 -0700 Subject: [PATCH 2/5] refactor(MatchScore): rename absScore to displayScore for clarity --- src/components/MatchScore/MatchScore.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/MatchScore/MatchScore.tsx b/src/components/MatchScore/MatchScore.tsx index 93fa38e3a..130654814 100644 --- a/src/components/MatchScore/MatchScore.tsx +++ b/src/components/MatchScore/MatchScore.tsx @@ -41,7 +41,7 @@ export const MatchScore: FC = React.forwardRef( const absTotal: number = Math.abs(total); const clampedScore: number = Math.max(0, score); - const absScore: number = Math.round(clampedScore); + const displayScore: number = Math.round(clampedScore); const fullCircles: number = Math.trunc( Math.round(clampedScore * 2.0) / 2.0 ); @@ -84,7 +84,7 @@ export const MatchScore: FC = React.forwardRef( {!hideLabel && (

- {label} {!hideValues && absScore + '/' + absTotal} + {label} {!hideValues && displayScore + '/' + absTotal}

)} From 7afc8ef048d590dff5f3c41ff77dbd4b88901557 Mon Sep 17 00:00:00 2001 From: Kris Heinrich Date: Tue, 22 Sep 2026 14:02:59 -0700 Subject: [PATCH 3/5] fix(MatchScore): clamp score to total to avoid invalid array length crash --- src/components/MatchScore/MatchScore.test.tsx | 6 ++++++ src/components/MatchScore/MatchScore.tsx | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/components/MatchScore/MatchScore.test.tsx b/src/components/MatchScore/MatchScore.test.tsx index d0a76f6af..bc251cbe9 100644 --- a/src/components/MatchScore/MatchScore.test.tsx +++ b/src/components/MatchScore/MatchScore.test.tsx @@ -49,4 +49,10 @@ describe('MatchScore', () => { expect(container.querySelector('.match-score-container')).toBeTruthy(); expect(container.querySelector('.label').textContent).toContain('0/5'); }); + + test('MatchScore clamps a score above total instead of throwing', () => { + const { container } = render(); + expect(container.querySelector('.match-score-container')).toBeTruthy(); + expect(container.querySelector('.label').textContent).toContain('5/5'); + }); }); diff --git a/src/components/MatchScore/MatchScore.tsx b/src/components/MatchScore/MatchScore.tsx index 130654814..fd04b15ce 100644 --- a/src/components/MatchScore/MatchScore.tsx +++ b/src/components/MatchScore/MatchScore.tsx @@ -40,7 +40,7 @@ export const MatchScore: FC = React.forwardRef( : contextualTheme || theme; const absTotal: number = Math.abs(total); - const clampedScore: number = Math.max(0, score); + const clampedScore: number = Math.max(0, Math.min(score, absTotal)); const displayScore: number = Math.round(clampedScore); const fullCircles: number = Math.trunc( Math.round(clampedScore * 2.0) / 2.0 @@ -48,7 +48,7 @@ export const MatchScore: FC = React.forwardRef( const halfCircle: number = Math.trunc( Math.ceil(clampedScore - fullCircles - 0.25) ); - const emptyCircles: number = total - fullCircles - halfCircle; + const emptyCircles: number = absTotal - fullCircles - halfCircle; const matchScoreLabelClasses: string = mergeClasses(styles.label); return ( From 05c011fa54950e77438ad7964b9f4bd0980a3a79 Mon Sep 17 00:00:00 2001 From: Kris Heinrich Date: Wed, 23 Sep 2026 11:41:58 -0700 Subject: [PATCH 4/5] fix(MatchScore): guard against NaN and fractional total in circle math --- src/components/MatchScore/MatchScore.test.tsx | 32 +++++++++++++++++-- src/components/MatchScore/MatchScore.tsx | 13 +++++--- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/components/MatchScore/MatchScore.test.tsx b/src/components/MatchScore/MatchScore.test.tsx index bc251cbe9..f2b12f5e1 100644 --- a/src/components/MatchScore/MatchScore.test.tsx +++ b/src/components/MatchScore/MatchScore.test.tsx @@ -46,13 +46,41 @@ describe('MatchScore', () => { test('MatchScore clamps a negative score instead of throwing', () => { const { container } = render(); - expect(container.querySelector('.match-score-container')).toBeTruthy(); + expect(container.querySelectorAll('.match-score-circle').length).toBe(5); + expect(container.querySelectorAll('.full').length).toBe(0); + expect(container.querySelectorAll('.half').length).toBe(0); expect(container.querySelector('.label').textContent).toContain('0/5'); }); test('MatchScore clamps a score above total instead of throwing', () => { const { container } = render(); - expect(container.querySelector('.match-score-container')).toBeTruthy(); + expect(container.querySelectorAll('.match-score-circle').length).toBe(5); + expect(container.querySelectorAll('.full').length).toBe(5); + expect(container.querySelectorAll('.half').length).toBe(0); expect(container.querySelector('.label').textContent).toContain('5/5'); }); + + test('MatchScore treats a NaN score as 0 instead of throwing', () => { + const { container } = render(); + expect(container.querySelectorAll('.match-score-circle').length).toBe(5); + expect(container.querySelectorAll('.full').length).toBe(0); + expect(container.querySelectorAll('.half').length).toBe(0); + expect(container.querySelector('.label').textContent).toContain('0/5'); + }); + + test('MatchScore falls back to the default total when total is NaN', () => { + const { container } = render(); + expect(container.querySelectorAll('.match-score-circle').length).toBe(5); + expect(container.querySelectorAll('.full').length).toBe(3); + expect(container.querySelectorAll('.half').length).toBe(0); + expect(container.querySelector('.label').textContent).toContain('3/5'); + }); + + test('MatchScore truncates a fractional total instead of throwing', () => { + const { container } = render(); + expect(container.querySelectorAll('.match-score-circle').length).toBe(5); + expect(container.querySelectorAll('.full').length).toBe(3); + expect(container.querySelectorAll('.half').length).toBe(0); + expect(container.querySelector('.label').textContent).toContain('3/5'); + }); }); diff --git a/src/components/MatchScore/MatchScore.tsx b/src/components/MatchScore/MatchScore.tsx index fd04b15ce..22f5d88b6 100644 --- a/src/components/MatchScore/MatchScore.tsx +++ b/src/components/MatchScore/MatchScore.tsx @@ -13,6 +13,9 @@ import { mergeClasses } from '../../shared/utilities'; import styles from './matchScore.module.scss'; import themedComponentStyles from './matchScore.theme.module.scss'; +const DEFAULT_SCORE = 0; +const DEFAULT_TOTAL = 5; + export const MatchScore: FC = React.forwardRef( ( { @@ -24,10 +27,10 @@ export const MatchScore: FC = React.forwardRef( hideLabel = false, hideValues = false, label, - score = 0, + score = DEFAULT_SCORE, theme, themeContainerId, - total = 5, + total = DEFAULT_TOTAL, ...rest }, ref: Ref @@ -39,8 +42,10 @@ export const MatchScore: FC = React.forwardRef( ? theme : contextualTheme || theme; - const absTotal: number = Math.abs(total); - const clampedScore: number = Math.max(0, Math.min(score, absTotal)); + const safeTotal: number = Number.isFinite(total) ? total : DEFAULT_TOTAL; + const absTotal: number = Math.trunc(Math.abs(safeTotal)); + const safeScore: number = Number.isFinite(score) ? score : DEFAULT_SCORE; + const clampedScore: number = Math.max(0, Math.min(safeScore, absTotal)); const displayScore: number = Math.round(clampedScore); const fullCircles: number = Math.trunc( Math.round(clampedScore * 2.0) / 2.0 From 17a4079809dfd85092a907c143e5741711734db3 Mon Sep 17 00:00:00 2001 From: Kris Heinrich Date: Thu, 24 Sep 2026 10:40:37 -0700 Subject: [PATCH 5/5] test(MatchScore): cover half-circle rendering, default total, and explicit theme --- src/components/MatchScore/MatchScore.test.tsx | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/components/MatchScore/MatchScore.test.tsx b/src/components/MatchScore/MatchScore.test.tsx index f2b12f5e1..0a747ca40 100644 --- a/src/components/MatchScore/MatchScore.test.tsx +++ b/src/components/MatchScore/MatchScore.test.tsx @@ -83,4 +83,34 @@ describe('MatchScore', () => { expect(container.querySelectorAll('.half').length).toBe(0); expect(container.querySelector('.label').textContent).toContain('3/5'); }); + + test('MatchScore renders a half circle for a half-point score', () => { + const { container } = render(); + expect(container.querySelectorAll('.match-score-circle').length).toBe(5); + expect(container.querySelectorAll('.full').length).toBe(2); + expect(container.querySelectorAll('.half').length).toBe(1); + expect(container.querySelector('.label').textContent).toContain('3/5'); + }); + + test('MatchScore defaults total to 5 when omitted', () => { + const { container } = render(); + expect(container.querySelectorAll('.match-score-circle').length).toBe(5); + expect(container.querySelectorAll('.full').length).toBe(3); + expect(container.querySelectorAll('.half').length).toBe(0); + expect(container.querySelector('.label').textContent).toContain('3/5'); + }); + + test('MatchScore applies an explicit theme when noThemeContext is set', () => { + const { container } = render( + + ); + expect( + container.querySelector('.match-score-container').classList + ).toContain('theme'); + }); });