import { expect, fixture, html } from '@open-wc/testing'; import './nile-segmented-progress-bar'; import type { NileProgressSegment, NileSegmentedProgressBar, } from './nile-segmented-progress-bar'; /** The reference "Run outcomes" data — sum 98365, with two sub-1% slivers. */ const RUN_OUTCOMES: NileProgressSegment[] = [ { label: 'Success', value: 45623 }, { label: 'Failure', value: 50342 }, { label: 'Errors', value: 682 }, { label: 'Running', value: 166 }, { label: 'Other', value: 1552 }, ]; const SUM = 98365; interface SegmentClickDetail { segment: NileProgressSegment; index: number; percentage: number; } const segmentsOf = (el: NileSegmentedProgressBar): HTMLElement[] => Array.from(el.shadowRoot!.querySelectorAll('[part="segment"]')); const trackOf = (el: NileSegmentedProgressBar): HTMLElement => el.shadowRoot!.querySelector('[part~="track"]')!; const legendItemsOf = (el: NileSegmentedProgressBar): HTMLElement[] => Array.from( el.shadowRoot!.querySelectorAll('[part="legend-item"]') ); const tooltipsOf = (el: NileSegmentedProgressBar): HTMLElement[] => Array.from(el.shadowRoot!.querySelectorAll('nile-lite-tooltip')); /** Mounts a bar inside a fixed-width wrapper so layout assertions are stable. */ async function mount( width: number, segments: NileProgressSegment[] = RUN_OUTCOMES, minSegmentWidth = 2 ): Promise { const wrapper = await fixture(html`
`); const el = wrapper.querySelector( 'nile-segmented-progress-bar' ) as NileSegmentedProgressBar; await el.updateComplete; return el; } /** Runs `fn` with `console.error` captured, then restores it. */ async function captureErrors(fn: () => Promise): Promise { const errors: string[] = []; const original = console.error; console.error = (...args: unknown[]) => { errors.push(args.map(arg => String(arg)).join(' ')); }; try { await fn(); } finally { console.error = original; } return errors; } describe('NileSegmentedProgressBar', () => { /* #region Boilerplate */ it('1. renders', async () => { const el = await fixture( html`` ); expect(el).to.exist; }); it('2. shadow root', async () => { const el = await fixture( html`` ); expect(el.shadowRoot).to.not.be.null; }); it('3. tag name', async () => { const el = await fixture( html`` ); expect(el.tagName.toLowerCase()).to.equal('nile-segmented-progress-bar'); }); it('4. is defined', async () => { expect(customElements.get('nile-segmented-progress-bar')).to.exist; }); it('5. shadow mode is open', async () => { const el = await fixture( html`` ); expect(el.shadowRoot!.mode).to.equal('open'); }); /* #endregion */ /* #region Property defaults */ it('6. segments defaults to an empty array', async () => { const el = await fixture( html`` ); expect(el.segments).to.deep.equal([]); }); it('7. label defaults empty', async () => { const el = await fixture( html`` ); expect(el.label).to.equal(''); }); it('8. showLegend defaults true', async () => { const el = await fixture( html`` ); expect(el.showLegend).to.be.true; }); it('9. showTooltip defaults true', async () => { const el = await fixture( html`` ); expect(el.showTooltip).to.be.true; }); it('10. showValues defaults true', async () => { const el = await fixture( html`` ); expect(el.showValues).to.be.true; }); it('11. size defaults medium', async () => { const el = await fixture( html`` ); expect(el.size).to.equal('medium'); }); it('12. total defaults undefined', async () => { const el = await fixture( html`` ); expect(el.total).to.be.undefined; }); it('13. rounded defaults true', async () => { const el = await fixture( html`` ); expect(el.rounded).to.be.true; }); it('14. minSegmentWidth defaults 2', async () => { const el = await fixture( html`` ); expect(el.minSegmentWidth).to.equal(2); }); it('15. tooltipPlacement defaults top', async () => { const el = await fixture( html`` ); expect(el.tooltipPlacement).to.equal('top'); }); /* #endregion */ /* #region Attributes */ it('16. label reflects to the label attribute', async () => { const el = await fixture( html`` ); el.label = 'Run outcomes'; await el.updateComplete; expect(el.getAttribute('label')).to.equal('Run outcomes'); }); it('17. size reflects to the size attribute', async () => { const el = await fixture( html`` ); el.size = 'large'; await el.updateComplete; expect(el.getAttribute('size')).to.equal('large'); }); it('18. size attribute sets the property', async () => { const el = await fixture( html`` ); expect(el.size).to.equal('small'); }); it('19. show-legend attribute maps to showLegend', async () => { const el = await fixture( html`` ); expect(el.showLegend).to.be.true; }); it('20. show-tooltip attribute maps to showTooltip', async () => { const el = await fixture( html`` ); expect(el.showTooltip).to.be.true; }); it('21. show-values attribute maps to showValues', async () => { const el = await fixture( html`` ); expect(el.showValues).to.be.true; }); it('22. min-segment-width attribute maps to minSegmentWidth', async () => { const el = await fixture( html`` ); expect(el.minSegmentWidth).to.equal(6); }); it('23. tooltip-placement attribute maps to tooltipPlacement', async () => { const el = await fixture( html`` ); expect(el.tooltipPlacement).to.equal('bottom'); }); it('24. total attribute maps to total as a number', async () => { const el = await fixture( html`` ); expect(el.total).to.equal(200); }); /* #endregion */ /* #region Parts */ it('25. container part', async () => { const el = await mount(800); expect(el.shadowRoot!.querySelector('[part="container"]')).to.exist; }); it('26. label part renders when label is set', async () => { const el = await mount(800); const label = el.shadowRoot!.querySelector('[part="label"]'); expect(label!.textContent!.trim()).to.equal('Run outcomes'); }); it('27. label part absent when label is empty', async () => { const el = await fixture( html`` ); expect(el.shadowRoot!.querySelector('[part="label"]')).to.be.null; }); it('28. bar and track are the same element', async () => { const el = await mount(800); const bar = el.shadowRoot!.querySelector('[part~="bar"]'); expect(bar).to.equal(trackOf(el)); }); it('29. segment parts', async () => { const el = await mount(800); expect(segmentsOf(el).length).to.equal(5); }); it('30. legend part', async () => { const el = await mount(800); expect(el.shadowRoot!.querySelector('[part="legend"]')).to.exist; }); it('31. legend-item parts', async () => { const el = await mount(800); expect(legendItemsOf(el).length).to.equal(5); }); it('32. legend-swatch part', async () => { const el = await mount(800); expect( el.shadowRoot!.querySelectorAll('[part="legend-swatch"]').length ).to.equal(5); }); it('33. legend-label part text', async () => { const el = await mount(800); const labels = Array.from( el.shadowRoot!.querySelectorAll('[part="legend-label"]') ).map(node => node.textContent!.trim()); expect(labels).to.deep.equal([ 'Success', 'Failure', 'Errors', 'Running', 'Other', ]); }); it('34. legend-value part text', async () => { const el = await mount(800); const values = Array.from( el.shadowRoot!.querySelectorAll('[part="legend-value"]') ).map(node => node.textContent!.trim()); expect(values).to.deep.equal([ '45623', '50342', '682', '166', '1552', ]); }); /* #endregion */ /* #region Classes and sizes */ it('35. small size class', async () => { const el = await fixture( html`` ); expect(el.shadowRoot!.querySelector('.segmented-progress-bar--small')).to .exist; }); it('36. medium size class by default', async () => { const el = await fixture( html`` ); expect(el.shadowRoot!.querySelector('.segmented-progress-bar--medium')).to .exist; }); it('37. large size class', async () => { const el = await fixture( html`` ); expect(el.shadowRoot!.querySelector('.segmented-progress-bar--large')).to .exist; }); it('38. rounded class present by default', async () => { const el = await fixture( html`` ); expect(el.shadowRoot!.querySelector('.segmented-progress-bar--rounded')).to .exist; }); it('39. rounded class removed when rounded is false', async () => { const el = await fixture( html`` ); expect(el.shadowRoot!.querySelector('.segmented-progress-bar--rounded')).to .be.null; }); it('40. palette class rotates by index', async () => { const el = await mount(800); const classes = segmentsOf(el).map(segment => Array.from(segment.classList).find(name => name.includes('palette')) ); expect(classes).to.deep.equal([ 'segmented-progress-bar__palette-0', 'segmented-progress-bar__palette-1', 'segmented-progress-bar__palette-2', 'segmented-progress-bar__palette-3', 'segmented-progress-bar__palette-4', ]); }); it('41. palette wraps after 10 segments', async () => { const many = Array.from({ length: 11 }, (_, index) => ({ label: `S${index}`, value: 10, })); const el = await mount(800, many); expect(segmentsOf(el)[10].classList.contains( 'segmented-progress-bar__palette-0' )).to.be.true; }); it('42. custom color suppresses the palette class', async () => { const el = await mount(800, [ { label: 'A', value: 1, color: 'rgb(255, 0, 0)' }, ]); const segment = segmentsOf(el)[0]; expect(segment.className).to.not.contain('palette'); expect(segment.style.backgroundColor).to.equal('rgb(255, 0, 0)'); }); it('43. no custom color leaves inline background unset', async () => { const el = await mount(800); expect(segmentsOf(el)[0].style.backgroundColor).to.equal(''); }); it('44. custom color is applied to the legend swatch', async () => { const el = await mount(800, [ { label: 'A', value: 1, color: 'rgb(0, 128, 0)' }, ]); const swatch = el.shadowRoot!.querySelector( '[part="legend-swatch"]' )!; expect(swatch.style.backgroundColor).to.equal('rgb(0, 128, 0)'); }); /* #endregion */ /* #region Proportional math */ it('45. reference data flex-basis percentages', async () => { const el = await mount(800); const bases = segmentsOf(el).map(segment => parseFloat(segment.style.flexBasis) ); RUN_OUTCOMES.forEach((segment, index) => { expect(bases[index]).to.be.closeTo((segment.value / SUM) * 100, 0.01); }); }); it('46. reference data flex-basis sums to 100%', async () => { const el = await mount(800); const total = segmentsOf(el).reduce( (sum, segment) => sum + parseFloat(segment.style.flexBasis), 0 ); expect(total).to.be.closeTo(100, 0.01); }); it('47. omitted total means the segments fill 100%', async () => { const el = await mount(800, [ { label: 'A', value: 30 }, { label: 'B', value: 70 }, ]); const bases = segmentsOf(el).map(segment => parseFloat(segment.style.flexBasis) ); expect(bases).to.deep.equal([30, 70]); }); it('48. total larger than the sum leaves an unfilled remainder', async () => { const el = await fixture( html`` ); const bases = segmentsOf(el).map(segment => parseFloat(segment.style.flexBasis) ); expect(bases).to.deep.equal([15, 35]); }); it('49. total smaller than the sum falls back to the sum', async () => { let el!: NileSegmentedProgressBar; const errors = await captureErrors(async () => { el = await fixture( html`` ); }); const bases = segmentsOf(el).map(segment => parseFloat(segment.style.flexBasis) ); expect(bases).to.deep.equal([30, 70]); expect(errors.join('\n')).to.contain('Invalid total (50)'); }); it('50. total equal to the sum fills 100%', async () => { const el = await fixture( html`` ); expect(parseFloat(segmentsOf(el)[0].style.flexBasis)).to.equal(100); }); it('51. only the largest segment may shrink', async () => { const el = await mount(800); const shrinks = segmentsOf(el).map(segment => segment.style.flexShrink); expect(shrinks).to.deep.equal(['0', '1', '0', '0', '0']); }); it('52. the largest segment has no min-width floor', async () => { const el = await mount(800); const minWidths = segmentsOf(el).map(segment => segment.style.minWidth); expect(minWidths).to.deep.equal(['2px', '0px', '2px', '2px', '2px']); }); it('53. minSegmentWidth is honoured on non-largest segments', async () => { const el = await mount(800, RUN_OUTCOMES, 8); const minWidths = segmentsOf(el).map(segment => segment.style.minWidth); expect(minWidths).to.deep.equal(['8px', '0px', '8px', '8px', '8px']); }); it('54. minSegmentWidth of 0 removes the floor', async () => { const el = await mount(800, RUN_OUTCOMES, 0); const minWidths = segmentsOf(el).map(segment => segment.style.minWidth); expect(minWidths).to.deep.equal(['0px', '0px', '0px', '0px', '0px']); }); it('55. negative minSegmentWidth is clamped to 0', async () => { const el = await mount(800, RUN_OUTCOMES, -10); expect(segmentsOf(el)[0].style.minWidth).to.equal('0px'); }); it('56. a single segment fills the whole bar', async () => { const el = await mount(800, [{ label: 'Only', value: 5 }]); const segments = segmentsOf(el); expect(segments.length).to.equal(1); expect(parseFloat(segments[0].style.flexBasis)).to.equal(100); expect(segments[0].style.flexShrink).to.equal('1'); }); /* #endregion */ /* #region Layout — no overflow, no wrap */ [800, 360, 240].forEach((width, offset) => { it(`${57 + offset}. track does not overflow at ${width}px`, async () => { const el = await mount(width); const track = trackOf(el); expect(track.scrollWidth).to.equal(track.clientWidth); }); }); it('60. tiny segments still add up to the full track width', async () => { const el = await mount(360); const track = trackOf(el); const rendered = segmentsOf(el).reduce( (sum, segment) => sum + segment.getBoundingClientRect().width, 0 ); expect(rendered).to.be.closeTo(track.getBoundingClientRect().width, 0.5); }); it('61. segments never wrap onto a second row', async () => { const el = await mount(240); const tops = segmentsOf(el).map( segment => Math.round(segment.getBoundingClientRect().top * 10) / 10 ); expect(new Set(tops).size).to.equal(1); }); it('62. the first segment starts at the track edge and the last ends at it', async () => { const el = await mount(800); const track = trackOf(el).getBoundingClientRect(); const segments = segmentsOf(el); const first = segments[0].getBoundingClientRect(); const last = segments[segments.length - 1].getBoundingClientRect(); expect(first.left).to.be.closeTo(track.left, 0.5); expect(last.right).to.be.closeTo(track.right, 0.5); }); it('63. sub-1% segments stay at least minSegmentWidth wide', async () => { const el = await mount(240); const running = segmentsOf(el)[3].getBoundingClientRect().width; expect(running).to.be.at.least(2); }); it('64. a large minSegmentWidth still does not overflow', async () => { const el = await mount(240, RUN_OUTCOMES, 12); const track = trackOf(el); expect(track.scrollWidth).to.equal(track.clientWidth); }); it('65. many tiny segments against one dominant one do not overflow', async () => { const el = await mount(240, [ { label: 'Dominant', value: 99000 }, { label: 'A', value: 40 }, { label: 'B', value: 25 }, { label: 'C', value: 12 }, { label: 'D', value: 8 }, { label: 'E', value: 3 }, { label: 'F', value: 1 }, ]); const track = trackOf(el); expect(track.scrollWidth).to.equal(track.clientWidth); }); /* #endregion */ /* #region Zero, empty and invalid data */ it('66. zero-value segments render no bar segment', async () => { const el = await mount(800, [ { label: 'Success', value: 512 }, { label: 'Failure', value: 0 }, { label: 'Errors', value: 0 }, { label: 'Running', value: 64 }, ]); expect(segmentsOf(el).length).to.equal(2); }); it('67. zero-value segments still appear in the legend', async () => { const el = await mount(800, [ { label: 'Success', value: 512 }, { label: 'Failure', value: 0 }, { label: 'Errors', value: 0 }, { label: 'Running', value: 64 }, ]); expect(legendItemsOf(el).length).to.equal(4); }); it('68. an empty segments array renders without throwing', async () => { const el = await mount(800, []); expect(segmentsOf(el).length).to.equal(0); expect(legendItemsOf(el).length).to.equal(0); expect(trackOf(el)).to.exist; }); it('69. an all-zero array renders an empty track and a full legend', async () => { const el = await mount(800, [ { label: 'A', value: 0 }, { label: 'B', value: 0 }, { label: 'C', value: 0 }, ]); expect(segmentsOf(el).length).to.equal(0); expect(legendItemsOf(el).length).to.equal(3); }); it('70. negative values are clamped to 0', async () => { let el!: NileSegmentedProgressBar; const errors = await captureErrors(async () => { el = await mount(800, [ { label: 'A', value: -5 }, { label: 'B', value: 10 }, ]); }); expect(segmentsOf(el).length).to.equal(1); expect(parseFloat(segmentsOf(el)[0].style.flexBasis)).to.equal(100); expect(errors.join('\n')).to.contain( 'Invalid value (-5) detected at segment index 0' ); }); it('71. NaN values are guarded to 0', async () => { let el!: NileSegmentedProgressBar; const errors = await captureErrors(async () => { el = await mount(800, [ { label: 'A', value: Number.NaN }, { label: 'B', value: 10 }, ]); }); expect(segmentsOf(el).length).to.equal(1); expect(errors.join('\n')).to.contain( 'Invalid value (NaN) detected at segment index 0' ); }); it('72. clamped values still render as 0 in the legend', async () => { let el!: NileSegmentedProgressBar; await captureErrors(async () => { el = await mount(800, [ { label: 'A', value: -5 }, { label: 'B', value: 10 }, ]); }); const values = Array.from( el.shadowRoot!.querySelectorAll('[part="legend-value"]') ).map(node => node.textContent!.trim()); expect(values).to.deep.equal(['0', '10']); }); it('73. Infinity is guarded to 0', async () => { let el!: NileSegmentedProgressBar; const errors = await captureErrors(async () => { el = await mount(800, [ { label: 'A', value: Number.POSITIVE_INFINITY }, { label: 'B', value: 10 }, ]); }); expect(segmentsOf(el).length).to.equal(1); expect(errors.join('\n')).to.contain('Invalid value (Infinity)'); }); it('74. a negative total is reported and ignored', async () => { let el!: NileSegmentedProgressBar; const errors = await captureErrors(async () => { el = await fixture( html`` ); }); expect(parseFloat(segmentsOf(el)[0].style.flexBasis)).to.equal(100); expect(errors.join('\n')).to.contain('Invalid total (-1)'); }); it('75. a valid config logs nothing', async () => { const errors = await captureErrors(async () => { await mount(800); }); expect(errors).to.deep.equal([]); }); /* #endregion */ /* #region Percentage formatting */ it('76. percentages render to one decimal', async () => { const el = await mount(800); expect(segmentsOf(el)[0].getAttribute('aria-label')).to.equal( 'Success: 45623 (46.4%)' ); }); it('77. sub-1% percentages keep their decimal', async () => { const el = await mount(800); expect(segmentsOf(el)[3].getAttribute('aria-label')).to.equal( 'Running: 166 (0.2%)' ); }); it('78. whole percentages are trimmed', async () => { const el = await mount(800, [ { label: 'A', value: 1 }, { label: 'B', value: 1 }, ]); expect(segmentsOf(el)[0].getAttribute('aria-label')).to.equal('A: 1 (50%)'); }); it('79. a 100% segment is trimmed to 100%', async () => { const el = await mount(800, [{ label: 'A', value: 7 }]); expect(segmentsOf(el)[0].getAttribute('aria-label')).to.equal('A: 7 (100%)'); }); it('80. zero segments describe themselves as 0%', async () => { const el = await mount(800, [ { label: 'A', value: 0 }, { label: 'B', value: 10 }, ]); expect(legendItemsOf(el)[0].getAttribute('aria-label')).to.equal( 'A: 0 (0%)' ); }); /* #endregion */ /* #region Accessibility */ it('81. the track is a group, not an img', async () => { const el = await mount(800); expect(trackOf(el).getAttribute('role')).to.equal('group'); }); it('82. the track carries the whole distribution as its accessible name', async () => { const el = await mount(800); expect(trackOf(el).getAttribute('aria-label')).to.equal( 'Run outcomes: Success: 45623 (46.4%), Failure: 50342 (51.2%), Errors: 682 (0.7%), Running: 166 (0.2%), Other: 1552 (1.6%)' ); }); it('83. the track summary omits the prefix when there is no label', async () => { const el = await fixture( html`` ); expect(trackOf(el).getAttribute('aria-label')).to.equal('A: 1 (100%)'); }); it('84. the track summary reads "No data" when empty', async () => { const el = await mount(800, []); expect(trackOf(el).getAttribute('aria-label')).to.equal( 'Run outcomes: No data' ); }); it('85. segments are focusable buttons', async () => { const el = await mount(800); segmentsOf(el).forEach(segment => { expect(segment.getAttribute('role')).to.equal('button'); expect(segment.getAttribute('tabindex')).to.equal('0'); }); }); it('86. legend items are focusable buttons', async () => { const el = await mount(800); legendItemsOf(el).forEach(item => { expect(item.getAttribute('role')).to.equal('button'); expect(item.getAttribute('tabindex')).to.equal('0'); }); }); it('87. legend swatches are hidden from assistive tech', async () => { const el = await mount(800); const swatches = el.shadowRoot!.querySelectorAll('[part="legend-swatch"]'); Array.from(swatches).forEach(swatch => { expect(swatch.getAttribute('aria-hidden')).to.equal('true'); }); }); it('88. segments carry their config index', async () => { const el = await mount(800); expect( segmentsOf(el).map(segment => segment.getAttribute('data-index')) ).to.deep.equal(['0', '1', '2', '3', '4']); }); it('89. no native buttons are rendered', async () => { const el = await mount(800); expect(el.shadowRoot!.querySelectorAll('button').length).to.equal(0); }); /* #endregion */ /* #region Tooltips */ it('90. every rendered segment is wrapped in a tooltip', async () => { const el = await mount(800); expect(tooltipsOf(el).length).to.equal(5); }); it('91. tooltip content defaults to the segment description', async () => { const el = await mount(800); expect(tooltipsOf(el)[0].getAttribute('content')).to.equal( 'Success: 45623 (46.4%)' ); }); it('92. a per-segment tooltip overrides the default', async () => { const el = await mount(800, [ { label: 'Documents', value: 4200, tooltip: '4.2 GB of documents' }, { label: 'Media', value: 8800 }, ]); const tooltips = tooltipsOf(el); expect(tooltips[0].getAttribute('content')).to.equal( '4.2 GB of documents' ); expect(tooltips[1].getAttribute('content')).to.equal( 'Media: 8800 (67.7%)' ); }); it('93. an empty tooltip override falls back to the default', async () => { const el = await mount(800, [{ label: 'A', value: 1, tooltip: '' }]); expect(tooltipsOf(el)[0].getAttribute('content')).to.equal('A: 1 (100%)'); }); it('94. tooltipPlacement is forwarded', async () => { const el = await fixture( html`` ); expect(tooltipsOf(el)[0].getAttribute('placement')).to.equal('bottom'); }); it('95. showTooltip false creates no tooltip wrappers at all', async () => { const el = await fixture( html`` ); expect(tooltipsOf(el).length).to.equal(0); expect(segmentsOf(el).length).to.equal(5); }); it('96. tooltips are display:contents so they never become flex items', async () => { const el = await mount(800); const tooltip = tooltipsOf(el)[0]; expect(getComputedStyle(tooltip).display).to.equal('contents'); }); it('97. the tooltip wrapper is the segment parent inside the track', async () => { const el = await mount(800); const segment = segmentsOf(el)[0]; expect(segment.parentElement!.tagName.toLowerCase()).to.equal( 'nile-lite-tooltip' ); expect(segment.parentElement!.parentElement).to.equal(trackOf(el)); }); it('98. the tooltip layer does not break the 100% layout', async () => { const el = await mount(360); const track = trackOf(el); expect(track.scrollWidth).to.equal(track.clientWidth); const rendered = segmentsOf(el).reduce( (sum, segment) => sum + segment.getBoundingClientRect().width, 0 ); expect(rendered).to.be.closeTo(track.getBoundingClientRect().width, 0.5); }); /* #endregion */ /* #region Events */ it('99. clicking a segment emits nile-segment-click once', async () => { const el = await mount(800); const events: CustomEvent[] = []; el.addEventListener('nile-segment-click', (event: Event) => { events.push(event as CustomEvent); }); segmentsOf(el)[2].click(); expect(events.length).to.equal(1); }); it('100. the click payload carries segment, index and raw percentage', async () => { const el = await mount(800); const details: SegmentClickDetail[] = []; el.addEventListener('nile-segment-click', (event: Event) => { details.push((event as CustomEvent).detail as SegmentClickDetail); }); segmentsOf(el)[0].click(); expect(details[0].segment).to.equal(el.segments[0]); expect(details[0].index).to.equal(0); expect(details[0].percentage).to.equal((45623 / SUM) * 100); }); it('101. the percentage in the payload is unrounded', async () => { const el = await mount(800); let percentage = 0; el.addEventListener('nile-segment-click', (event: Event) => { percentage = (event as CustomEvent).detail.percentage; }); segmentsOf(el)[3].click(); expect(percentage).to.equal((166 / SUM) * 100); expect(percentage).to.not.equal(0.2); }); it('102. clicking a legend item emits the same payload', async () => { const el = await mount(800); const details: SegmentClickDetail[] = []; el.addEventListener('nile-segment-click', (event: Event) => { details.push((event as CustomEvent).detail as SegmentClickDetail); }); legendItemsOf(el)[4].click(); expect(details.length).to.equal(1); expect(details[0].index).to.equal(4); expect(details[0].percentage).to.equal((1552 / SUM) * 100); }); it('103. clicking a zero-value legend item still emits', async () => { const el = await mount(800, [ { label: 'A', value: 0 }, { label: 'B', value: 10 }, ]); const details: SegmentClickDetail[] = []; el.addEventListener('nile-segment-click', (event: Event) => { details.push((event as CustomEvent).detail as SegmentClickDetail); }); legendItemsOf(el)[0].click(); expect(details[0].index).to.equal(0); expect(details[0].percentage).to.equal(0); }); it('104. Enter on a segment emits exactly once', async () => { const el = await mount(800); const events: CustomEvent[] = []; el.addEventListener('nile-segment-click', (event: Event) => { events.push(event as CustomEvent); }); segmentsOf(el)[1].dispatchEvent( new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }) ); expect(events.length).to.equal(1); expect(events[0].detail.index).to.equal(1); }); it('105. Space on a segment emits exactly once', async () => { const el = await mount(800); const events: CustomEvent[] = []; el.addEventListener('nile-segment-click', (event: Event) => { events.push(event as CustomEvent); }); segmentsOf(el)[1].dispatchEvent( new KeyboardEvent('keydown', { key: ' ', bubbles: true }) ); expect(events.length).to.equal(1); }); it('106. legacy Spacebar key also activates', async () => { const el = await mount(800); const events: CustomEvent[] = []; el.addEventListener('nile-segment-click', (event: Event) => { events.push(event as CustomEvent); }); segmentsOf(el)[0].dispatchEvent( new KeyboardEvent('keydown', { key: 'Spacebar', bubbles: true }) ); expect(events.length).to.equal(1); }); it('107. Enter on a legend item emits once', async () => { const el = await mount(800); const events: CustomEvent[] = []; el.addEventListener('nile-segment-click', (event: Event) => { events.push(event as CustomEvent); }); legendItemsOf(el)[2].dispatchEvent( new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }) ); expect(events.length).to.equal(1); expect(events[0].detail.index).to.equal(2); }); it('108. Space is prevented so the page does not scroll', async () => { const el = await mount(800); const event = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true, }); segmentsOf(el)[0].dispatchEvent(event); expect(event.defaultPrevented).to.be.true; }); it('109. other keys do not activate a segment', async () => { const el = await mount(800); const events: CustomEvent[] = []; el.addEventListener('nile-segment-click', (event: Event) => { events.push(event as CustomEvent); }); ['a', 'Tab', 'Escape', 'ArrowRight'].forEach(key => { segmentsOf(el)[0].dispatchEvent( new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }) ); }); expect(events.length).to.equal(0); }); it('110. the event bubbles and is composed', async () => { const el = await mount(800); const received: Event[] = []; const listener = (event: Event) => received.push(event); document.addEventListener('nile-segment-click', listener); try { segmentsOf(el)[0].click(); } finally { document.removeEventListener('nile-segment-click', listener); } expect(received.length).to.equal(1); expect(received[0].bubbles).to.be.true; expect(received[0].composed).to.be.true; }); /* #endregion */ /* #region Toggles */ it('111. showLegend false removes the legend', async () => { const el = await fixture( html`` ); expect(el.shadowRoot!.querySelector('[part="legend"]')).to.be.null; expect(segmentsOf(el).length).to.equal(5); }); it('112. showValues false removes only the legend values', async () => { const el = await fixture( html`` ); expect(el.shadowRoot!.querySelectorAll('[part="legend-value"]').length).to .equal(0); expect(el.shadowRoot!.querySelectorAll('[part="legend-label"]').length).to .equal(5); expect(legendItemsOf(el).length).to.equal(5); }); /* #endregion */ /* #region Dynamic updates */ it('113. updating segments re-renders the bar', async () => { const el = await mount(800, [{ label: 'A', value: 1 }]); expect(segmentsOf(el).length).to.equal(1); el.segments = RUN_OUTCOMES; await el.updateComplete; expect(segmentsOf(el).length).to.equal(5); expect(parseFloat(segmentsOf(el)[0].style.flexBasis)).to.be.closeTo( 46.381, 0.01 ); }); it('114. updating total re-renders the proportions', async () => { const el = await mount(800, [{ label: 'A', value: 50 }]); expect(parseFloat(segmentsOf(el)[0].style.flexBasis)).to.equal(100); el.total = 200; await el.updateComplete; expect(parseFloat(segmentsOf(el)[0].style.flexBasis)).to.equal(25); }); it('115. updating size swaps the size class', async () => { const el = await mount(800); el.size = 'large'; await el.updateComplete; expect(el.shadowRoot!.querySelector('.segmented-progress-bar--large')).to .exist; expect(el.shadowRoot!.querySelector('.segmented-progress-bar--medium')).to .be.null; }); it('116. updating label adds the label part', async () => { const el = await fixture( html`` ); expect(el.shadowRoot!.querySelector('[part="label"]')).to.be.null; el.label = 'Later'; await el.updateComplete; expect( el.shadowRoot!.querySelector('[part="label"]')!.textContent!.trim() ).to.equal('Later'); }); it('117. toggling showLegend at runtime adds and removes the legend', async () => { const el = await mount(800); expect(el.shadowRoot!.querySelector('[part="legend"]')).to.exist; el.showLegend = false; await el.updateComplete; expect(el.shadowRoot!.querySelector('[part="legend"]')).to.be.null; el.showLegend = true; await el.updateComplete; expect(el.shadowRoot!.querySelector('[part="legend"]')).to.exist; }); it('118. toggling showTooltip at runtime adds and removes the wrappers', async () => { const el = await mount(800); expect(tooltipsOf(el).length).to.equal(5); el.showTooltip = false; await el.updateComplete; expect(tooltipsOf(el).length).to.equal(0); el.showTooltip = true; await el.updateComplete; expect(tooltipsOf(el).length).to.equal(5); }); it('119. toggling rounded at runtime swaps the class', async () => { const el = await mount(800); el.rounded = false; await el.updateComplete; expect(el.shadowRoot!.querySelector('.segmented-progress-bar--rounded')).to .be.null; }); it('120. updating minSegmentWidth re-applies the floors', async () => { const el = await mount(800); el.minSegmentWidth = 10; await el.updateComplete; expect(segmentsOf(el)[2].style.minWidth).to.equal('10px'); const track = trackOf(el); expect(track.scrollWidth).to.equal(track.clientWidth); }); it('121. the largest index follows the data', async () => { const el = await mount(800, [ { label: 'A', value: 10 }, { label: 'B', value: 90 }, ]); expect(segmentsOf(el).map(segment => segment.style.flexShrink)).to.deep.equal([ '0', '1', ]); el.segments = [ { label: 'A', value: 90 }, { label: 'B', value: 10 }, ]; await el.updateComplete; expect(segmentsOf(el).map(segment => segment.style.flexShrink)).to.deep.equal([ '1', '0', ]); }); it('122. going from data to an empty array clears the track', async () => { const el = await mount(800); el.segments = []; await el.updateComplete; expect(segmentsOf(el).length).to.equal(0); expect(trackOf(el)).to.exist; }); it('123. a non-array segments value is tolerated', async () => { const el = await mount(800); (el as unknown as { segments: unknown }).segments = null; await el.updateComplete; expect(segmentsOf(el).length).to.equal(0); expect(trackOf(el)).to.exist; }); /* #endregion */ /* #region Integration */ it('124. a non-array, non-nullish segments value is tolerated', async () => { const el = await mount(800); (el as unknown as { segments: unknown }).segments = {}; await el.updateComplete; expect(segmentsOf(el).length).to.equal(0); expect(trackOf(el).getAttribute('aria-label')).to.equal( 'Run outcomes: No data' ); }); it('125. multiple instances are independent', async () => { const wrapper = await fixture(html`
`); const bars = Array.from( wrapper.querySelectorAll( 'nile-segmented-progress-bar' ) ); await Promise.all(bars.map(bar => bar.updateComplete)); expect(segmentsOf(bars[0]).length).to.equal(1); expect(segmentsOf(bars[1]).length).to.equal(5); }); it('126. full reference configuration', async () => { const el = await fixture(html` `); expect(el.getAttribute('label')).to.equal('Run outcomes'); expect(el.getAttribute('size')).to.equal('large'); expect(segmentsOf(el).length).to.equal(5); expect(legendItemsOf(el).length).to.equal(5); expect(tooltipsOf(el).length).to.equal(5); expect(el.shadowRoot!.querySelector('.segmented-progress-bar--large')).to .exist; expect(trackOf(el).getAttribute('role')).to.equal('group'); }); /* #endregion */ });